Compare commits

...

95 Commits

Author SHA1 Message Date
Hermes 15132f56e8 feat(client): PWA install — client route, manifest start_url=/client, SW app-shell, invitation links → /client/:token 2026-08-11 13:37:29 +02:00
Hermes 65b42c8482 feat(client): add persistent PWA install card on delivery page (Android button + iOS instructions) 2026-08-11 13:37:16 +02:00
root 02b70cac22 fix: manifest start_url → /delivery/showcase for client PWA 2026-08-06 17:52:34 +00:00
root 7a702114eb feat: iOS push instruction banner + fix edge-functions security.ts exports 2026-08-06 17:36:31 +00:00
root 2597835352 fix(mobile): anchor notifications popup to viewport right edge 2026-08-06 16:05:43 +00:00
root de5130e31f fix(mobile): anchor install tooltip to viewport right edge 2026-08-06 15:50:44 +00:00
root 6d53625579 fix(mobile): anchor PWA install tooltip to right edge 2026-08-06 15:39:51 +00:00
root 4d47261321 feat(logistics): SMS problem as tag, red highlight for unagreed 2+ days 2026-08-06 15:28:51 +00:00
root d5849f0ab2 feat(logistics): highlight and filter orders with SMS problems 2026-08-06 15:12:23 +00:00
root c321353e8f feat(orders): show SMS delivery confirmation and 2nd-SMS skip reason 2026-08-06 13:55:42 +00:00
root fefb8065be perf(dashboard): lazy load order details only when needed; skip fetch on non-order tabs 2026-08-06 13:40:20 +00:00
root 44d3f0f16d fix(client): compact order composition trigger 2026-08-06 12:10:21 +00:00
root 82d6f4f077 feat(orders): show client push subscription and delivery log 2026-08-06 12:06:11 +00:00
root ad7b92503c fix(push): remove monochrome Android badge icon 2026-08-06 11:28:53 +00:00
root 34b01758ef fix(ui): use СуперСам and normal order composition heading 2026-08-06 11:13:40 +00:00
root 74de57f3fc fix(ui): use СуперСам and normal order composition heading 2026-08-06 11:12:47 +00:00
root 9be5f81ffe fix(ui): localize SuperSam name and normalize order headings 2026-08-06 11:11:56 +00:00
root 9e4ce20c96 fix(push): use SuperSam application icon in notifications 2026-08-06 10:15:42 +00:00
root 4768864c08 fix(push): use Supabase database host for sender 2026-08-06 10:07:37 +00:00
root 1922778e0f feat(push): prepare client subscription test contour 2026-08-06 08:56:12 +00:00
root 82eefb2225 feat(push): PushSubscriptionBanner on delivery consent page 2026-08-06 08:30:07 +00:00
root 98ebc935c2 fix(pwa): force SW bump for tab visibility on mobile 2026-08-06 08:12:56 +00:00
root 8faa839056 chore(pwa): bump cache version 2026-08-06 08:03:11 +00:00
root 1f8a8ea2c4 feat(orders): tabbed navigation for logistics blocks (Все + per-block tabs) 2026-08-06 08:03:11 +00:00
root 811e2a1254 chore(pwa): bump cache version 2026-08-06 07:51:32 +00:00
root 3a402f7f74 fix(orders): status summary cards with borders + larger text 2026-08-06 07:51:32 +00:00
root 4459d03932 chore(pwa): bump cache version 2026-08-06 07:48:40 +00:00
root db24e8929c feat(orders): exhaustive timeline + status summary + specific field_change labels 2026-08-06 07:48:40 +00:00
root b1a09ae71a chore(pwa): bump cache version 2026-08-06 07:14:46 +00:00
root 464cc57eb9 feat(orders): progress stepper + fix field_change label 2026-08-06 07:14:46 +00:00
root 15b69102b0 chore(pwa): bump cache version 2026-08-06 07:11:02 +00:00
root 225e961907 fix(logistics): shorten column titles, fit headers without truncation 2026-08-06 07:11:02 +00:00
root 4bf95064bd chore(pwa): bump cache version 2026-08-06 07:06:41 +00:00
root 349a5c5acf fix(logistics): header font size matches table body, bold weight 2026-08-06 07:06:41 +00:00
root db261f96db chore(pwa): bump cache version 2026-08-06 07:01:15 +00:00
root 63a59d42bb feat(logistics): sort every table column 2026-08-06 07:01:15 +00:00
root 24e708901a chore(pwa): bump cache version 2026-08-06 06:52:48 +00:00
root 60d6033085 feat(logistics): improve order workflow and aging 2026-08-06 06:52:32 +00:00
root d11ccd4f76 fix: SMS status — show real delivery state from log, expandable SMS text in history 2026-07-22 14:10:56 +00:00
root a5817110c9 fix: SMS campaign — compact cards + pagination 20/page for logs & manual groups 2026-07-22 12:33:38 +00:00
root d8930925be fix: SMS city profiles — жёсткие цвета вместо CSS variables
- Карточки: #e8f0ec (светло-зелёный) / #0d1a16 (тёмный) — чётко виден на Panel
- Внутренние блоки: #ffffff / #142822 — контраст с карточкой
- Название города: #10211b / #ecf5f2 — тёмный/светлый, жирный
- Все цвета заданы через CSS классы с [data-theme=dark] — без var()
- Toggle: #22c55e / #c8d8d2 — виден в обеих темах
2026-07-22 09:54:36 +00:00
root 2103fe2afe fix: SMS city profiles — inline styles для надёжного контраста
- Карточки профиля: accent-soft (зелёный оттенок) — виден на Panel
- Внутренние блоки: surface-strong (непрозрачный) — контраст с карточкой
- Инпуты: color-base — отличается от карточек
- Все цвета через CSS variables — работают в обеих темах
- Никаких Tailwind классов с var() которые могут не разрешиться
2026-07-22 09:51:56 +00:00
root 69c261cc8f fix: SMS city profiles — контраст цветов для светлой и тёмной темы
- Внешние карточки: --color-surface (полупрозрачный, виден на фоне)
- Внутренние блоки: --color-surface-strong (непрозрачный, контраст)
- Inputs/select: --color-base (отличается от карточек)
- Option элементы: явный bg + color для тёмной темы
- Светлая: карточки голубовато-зелёные, внутри — белые блоки
- Тёмная: карточки чуть светлее фона, внутри — ещё светлее
2026-07-22 08:41:48 +00:00
root 1a60fe07e0 fix: backup-code-s3 retention bash comparison 2026-07-22 06:59:44 +00:00
root f67fe6432d feat: backup code to S3 after deploy
- scripts/backup-code-s3.sh — tar.gz (без node_modules/.git/dist) → S3
- deploy.sh: вызывает backup-code-s3.sh после деплоя
- Retention 30 дней, Beget Cloud Storage
- Также добавить в cron: 0 1 * * *
2026-07-22 06:58:15 +00:00
root 6c9e13d19a fix: SMS city profiles UI — контраст цветов, выравнивание
- bg-[var(--color-surface-strong)] вместо --color-bg внутри карточек
- Карточки профилей: --color-surface-strong, вложенные блоки: --color-surface
- Текст всегда var(--color-text) — виден в светлой и тёмной теме
- Toggle: компактнее (h-5 w-10)
- Выравнивание: flex-wrap, gap, border-t для разделения секций
- SMS ON/OFF badge вместо toggle в строке заголовка
2026-07-22 06:45:39 +00:00
root 484f9efcba feat: SMS city profiles — гибкая конфигурация SMS по городам
- DB: sms_city_profiles (delivery_mode, pickup_mode, second_sms_enabled, paid_storage_enabled, sms_enabled)
- Python first_sms: link mode (согласование) vs notification mode (без ссылки, без 2-й SMS)
- Python second_sms: проверка second_sms_enabled из профиля
- Python paid_storage: проверка paid_storage_enabled из профиля
- Frontend: SmsCityProfilesPanel — выбор режима, кастомный текст, toggle'ы
- Дефолты: Севастополь=link, Ялта=notification без ссылки
- Замена sms_city_rules на sms_city_profiles
2026-07-21 19:55:51 +00:00
root a4ac117b51 fix: paid_storage SMS — убрать фильтр по городам
- Убран sms_allowed_for_city из paid_storage скрипта
- Убрана paid_storage из UI (dropdown + labels)
- Описание обновлено: только 1-е и 2-е SMS
2026-07-21 19:08:22 +00:00
root e2dcf6db72 merge: server updates + sms city rules 2026-07-21 14:58:19 +00:00
root de4ecaad27 feat: SMS city rules — гибкое управление SMS по городам
- DB: sms_city_rules table + sms_allowed_for_city() function + RLS
- Python: фильтр sms_allowed_for_city в first_sms, second_sms, paid_storage
- Frontend: SmsCityRulesPanel — toggle on/off, период дат, комментарии
- Интеграция в SmsCampaignPanel как вкладка 'SMS по городам'
- Поддержка '*' (все города / все кампании)
2026-07-21 14:58:11 +00:00
root a3ffd0c030 sync from mkn8n 2026-07-16 12:43:02 +00:00
root 319761a24c merge: server fixes + suggestions voting 2026-07-16 12:42:31 +00:00
root 6ad969f297 merge: suggestions voting + logistician access from mkn8n 2026-07-16 12:40:43 +00:00
root efe98c09d4 feat: suggestions voting + logistician access
- Add suggestion_votes table (DB migration applied)
- Vote/unvote with optimistic UI update
- Sort suggestions by vote_count desc, then created_at desc
- Add 'Предложения' tab to logistician role
- Vote button shows count, highlighted when voted
2026-07-16 12:30:14 +00:00
root 0f38ce0095 fix: normalizeDateForInput парсит dd.MM.yyyy + customerDate fallback для логистов 2026-07-14 18:52:02 +00:00
root 3b93e755fb fix: логисты могут ставить Доставлено без части дня + дублирующий индекс удалён 2026-07-14 18:48:03 +00:00
root 48e238e634 fix: логисты могут ставить прошедшие даты + dedup триггер 2026-07-14 18:00:35 +00:00
root 052e5e7e86 fix: calendar overlap + allow past dates for logistician
1. CalendarWidget: add relative to calendar container so absolute popup
   positions correctly (no more overlapping with other elements)
2. OrderDetailPanel: logistician/admin/manager can select past dates
   (3 months back) for retroactive delivery closing
3. canGoBack always true for logistician — can navigate to past months
4. Existing past delivery dates are preserved (not reset to tomorrow)
2026-07-13 14:42:36 +00:00
root c86e0e1ea6 fix: SMS labels — clarify delivered means SMS delivered to phone, not goods
1-е SMS: «✓ доставлено» → «✓ получено клиентом»
2-е SMS: added «✓ получено клиентом»
«ждём подтверждения» → «ждём ответ оператора»
2026-07-13 14:32:19 +00:00
root 5818b94bbe fix: remove duplicate status displays
1. Remove delivery status from info grid (already shown as Badge in header)
2. Remove SMS notification Badge from SmsStatusCard header (timeline shows it)
3. Remove inline hint on blocked buttons — keep only warning banner below
2026-07-13 14:21:57 +00:00
root 9cf7cfbdf4 fix: unique Traefik middleware name for HTTP->HTTPS redirect
Previous redirect-to-https middleware name conflicted with other
containers (n8n, gitea), causing HTTP router to fail with 404 instead
of redirecting to HTTPS. Renamed to supersam-redirect.
2026-07-13 13:53:20 +00:00
root b2dc1a3310 fix: restore filter persistence in LogisticsReadinessBoard
Filters (query, displayStatus, city, deliveryType, dateFrom/To) now
persist to localStorage and restore on page reload.
2026-07-13 13:38:46 +00:00
root 755385cf0e fix: StatusActionPanel smart hints + driver name lookup + logistician can edit shipment + completed label
1. StatusActionPanel: all buttons clickable (no more manual:false blocking)
   - Click on current status shows actual state: "Водитель уже назначен: Иван"
   - "Назначен водитель" clickable even if already assigned
   - "Согласовано" clickable even if already agreed
2. Status indicators: green badges ✓ Дата согласована / ✓ Водитель: имя
   + warning badges if missing
3. DriverAssignmentPanel: look up driver name from drivers list if
   assignedDriverName is empty (Supabase join failed)
4. DriverShipmentPanel: now visible to logistician/admin too — can edit
   shipment data (fix wrong driver checkboxes)
5. SmsStatusCard: add completed label ("Завершено")
2026-07-13 13:16:48 +00:00
root 17f93a65ae fix: logistics UI — problem badge overlap, driver name, SMS labels, shipment report
1. LogisticsReadinessBoard: problem badge on separate line (no overlap)
2. DriverAssignmentPanel: show "Водитель назначен" instead of "Неизвестно"
   when driver name not resolved from join
3. SmsStatusCard: "confirmed" label = "Клиент согласовал дату" (not "Согласовано")
4. StatusActionPanel: when currentStatus=problem, all status buttons clickable
   (logistician can resolve problem by setting any status)
5. DriverShipmentReport: show ALL items — green check for delivered,
   red cross for not delivered, with reason comments
2026-07-13 13:07:03 +00:00
root 1ffd8bacc5 fix: block Доставлено/Вывезено without delivery date+half-day, remove Загружено from logistic panel
- StatusActionPanel: removed Загружено button
- StatusActionPanel: Доставлено/Вывезено require deliveryDate + half-day
- StatusActionPanel: warning banner when schedule missing
- Driver status panel in OrderDetailPanel: same schedule check
- Blocked buttons shown dimmed (opacity-40)
2026-07-13 12:02:25 +00:00
root fbfcb71d61 fix: driver delivery UX — remove Загружено status, fix checkbox reset, fix confirmed label
1. Remove "Загружено" status from driver workflow — driver goes directly
   Назначен водитель → Доставлено (or Проблема)
2. Driver can undo "Доставлено" → "Вернуть в работу" (back to Назначен водитель)
3. Fix checkbox reset: shippedItems only resets when order ID changes,
   not on every order data refetch — driver can toggle items freely
4. Fix "confirmed" showing in SMS status card — add Russian label
5. onResetStatus resets to driver_assigned, not loaded
6. ShipmentPanel "Сбросить" works for all active statuses
2026-07-13 11:51:56 +00:00
root 1766ccb897 fix: driver planner uses fs-zone-table not fs-zone-card
User changes "Таблицы" slider in Settings → expects table text to scale.
DriverDeliveryPlanner was wrapped in fs-zone-card (responds to "Карточки"),
not fs-zone-table (responds to "Таблицы").
2026-07-13 11:35:52 +00:00
root edb6784c16 fix: remove duplicate fmtTime declaration in OrderDetailPanel 2026-07-13 11:26:31 +00:00
root 0821bcc747 merge: dev → main — driver table layout, filters, status reset, SMS campaigns, font zones
Merges dev branch into main:
- Driver delivery list: table layout (№, Client, Phone, City, Half-day)
- Driver filters: search, half-day filter, problem sorting
- DriverShipmentPanel: reset status with confirmation
- SmsCampaignPanel: campaigns refactor
- LogisticsReadinessBoard: unified table view
- Font zone classes (fs-zone-card) on all driver components
- SMS campaign scripts updates
2026-07-13 11:24:56 +00:00
root 6c7d83c2be fix: add fs-zone-card to driver components so font size settings apply
Driver components (DriverDeliveryPlanner, DriverShipmentPanel,
DriverDeliveryDetail) were missing fs-zone-card class wrappers.
Font size settings from SettingsPage saved to localStorage correctly
but never applied to driver UI because fontSettings.css only
overrides Tailwind text-* classes inside .fs-zone-* wrappers.
2026-07-13 06:58:49 +00:00
root e7365b6c0c fix(analytics): remove 1С import category, bypassed_completed covers all no-date completions 2026-07-06 15:00:41 +00:00
root 14a3006918 fix(analytics): funnel shows real order path with bypass detection 2026-07-06 13:47:02 +00:00
root a695a73af7 fix(analytics): rewrite funnel as real order path
- SQL admin_automation_economics: new columns based on actual order lifecycle
  - sms_sent: got at least 1 SMS
  - completed_auto: delivered/picked_up without manual_confirmation_at
  - completed_manual: delivered/picked_up with manual_confirmation_at
  - stuck_in_manual: manual_required + still waiting
  - no_sms_completed: imported as already delivered
- Funnel steps: Всего → SMS → Авто-завершено → Завершено вручную → Застряло → Без SMS → Хранение → Отменено
- Footer: Автоматизация % / Ручное вмешательство % / Завершено всего
- Fixes: old funnel counted manual_confirmation_required as "согласовано"
  and missed 31 completed orders (notification_status=completed vs confirmed)
2026-07-06 13:16:44 +00:00
root b0bc056090 fix(analytics): separate Вывезено (status) from Самовывоз (type) in KPI
- KPI: Вывезено = picked_up (all types), Самовывоз = pickup type only
- SQL admin_delivery_stats: + picked_up_delivery, picked_up_pickup columns
- Fixes confusion: 17 picked_up vs 5 pickup type were different metrics
2026-07-06 10:20:55 +00:00
root eba1769078 redesign(analytics): dashboard desktop layout + pickup period label
- 12-col responsive grid: pie 4/8 trend, table 4/8 drivers
- KPI cards: 7 metrics with uppercase labels, larger values
- Period label in every section subtitle (за 7 дней, etc)
- PickupStatsPanel: period label, status breakdown (завершено/ожидает/ручное)
- PickupStatsPanel: hide empty schedule bars when no planned pickups
- SQL admin_pickup_stats: + picked_up, pending, manual columns
- Colors: delivered #22c55e, picked_up #14b8a6 (distinct)
- Desktop: 3-col grid inside pickup panel
2026-07-06 10:09:11 +00:00
root 1b9fea7f1d fix(analytics): picked_up as separate status in dashboard
- KPI: separate Доставлено and Вывезено cards
- Trend: separate line for Вывезено (#14b8a6)
- Driver chart: separate bar for Вывезено
- SQL: admin_delivery_stats returns picked_up column
- SQL: admin_daily_trend returns picked_up column
- SQL: admin_driver_stats returns picked_up column
- delivery_rate = (delivered + picked_up) / total
- Colors: delivered #22c55e, picked_up #14b8a6 (distinct)
2026-07-06 06:42:31 +00:00
root 6786779f2c fix(analytics): recognize picked_up status in dashboard and stats
- Add picked_up to STATUS_COLORS and STATUS_LABELS in AdminDashboard
- SQL: admin_delivery_stats counts picked_up as delivered
- SQL: admin_daily_trend counts picked_up as delivered
- SQL: admin_driver_stats counts picked_up as delivered
- Fixes: picked_up showed as Unknown, delivered count was 17 short
2026-07-06 06:17:32 +00:00
root eabf30bceb fix: stop SMS UI for terminal delivery statuses (picked_up/delivered/cancelled)
- SmsStatusCard: show "SMS не отправляется" when delivery is terminal, hide countdown + restart buttons
- SmsCampaignPanel fmtAgreement: check terminal status FIRST → " Самовывоз завершён" instead of "Самовывоз (неполно)"
- update_delivery_status RPC: reset notification_status→completed on terminal transition
- Add completed to notification_status check constraint
- SW v60→v61
2026-07-04 11:27:03 +00:00
root 43d9116c51 fix: restore localStorage filter persistence in LogisticsReadinessBoard, SW v60 2026-07-03 15:04:05 +00:00
root 468c3c4097 fix: sessionStorage→localStorage for filter persistence, SW v59 2026-07-03 14:14:00 +00:00
root 1d288279c0 fix: extractCity word boundary + filter persistence in sessionStorage 2026-07-03 14:07:13 +00:00
root 8ade6a6ff3 fix: fallback fresh slots when all availableSlots passed + SW v57 2026-07-03 13:37:42 +00:00
root a74a5242b8 fix: use current date for slot availability + SW v56 2026-07-03 13:32:24 +00:00
root e93215f837 feat: SMS filter no_sms in queue + SW v55 2026-07-03 13:26:13 +00:00
root 6a34f36621 SW v54 — business schedule 2026-07-03 12:45:26 +00:00
root d397d0cb24 feat: configurable business schedule (delivery/pickup/SMS days)
- New table business_schedule_settings (delivery_days, pickup_days, sms_days)
- RPC get_business_schedule() (public) + update_business_schedule() (admin)
- Admin UI: BusinessSchedulePanel with day pills for each category
- ClientDeliveryPage: fetches schedule, replaces hardcoded Sunday/weekend checks
- PickupSlotsPicker: uses pickupDays prop instead of hardcoded isWeekend
- Edge function confirm-delivery-choice: reads delivery_days from DB
- delivery-invitations.ts: buildDefaultDatedAvailableSlots reads delivery_days
- RPC confirm_delivery_choice_by_token: checks delivery_days instead of extract(dow)=0
- SMS scripts: get_sms_days() reads sms_days from business_schedule_settings
  and overrides per-campaign work_days setting
- Nav item Расписание for mega_admin and admin roles
- Default: Mon-Fri (1,2,3,4,5) for all three categories
2026-07-03 12:45:02 +00:00
root d4d85bc767 fix: normalize_phone handles +7, 8, text, and multi-number fields
- Two numbers in one field (XXX / YYY) → take first 10 digits
- Text in field (Екатерина +7978...) → extract digits only
- 8 prefix → 7 (Russian landline style)
- 10 digits (no country code) → prepend 7
- All 4 SMS scripts patched (first, second, paid_storage)
2026-07-03 12:33:53 +00:00
root e55a4cafcc fix: manual_campaign EXISTS check — no manual_required without SMS sent
Scenario 3 (link_ready + next_check expired) now requires EXISTS
in sms_campaign_log. Prevents groups from being moved to
manual_required before first SMS is ever sent.
2026-07-03 12:18:28 +00:00
root db57624626 merge: resolve conflicts (our SmsCampaignPanel + SW v43) 2026-07-01 16:20:12 +00:00
root b78bdc48bb feat: drag-n-drop status sections + localStorage filters/collapsed + pickup date in logistics board
- Drag-n-drop reordering of status sections (desktop + touch)
- Section order saved in localStorage (logistics-section-order)
- Collapsed/expanded state saved in localStorage (logistics-collapsed)
- Filters (city, status, search) saved in localStorage (logistics-filters)
- Pickup date shown in delivery date column for self-pickup orders
- SMS campaign scripts (manual, second, paid_storage, timer manager)
- SmsCampaignStats + SmsStatusCard components
- DashboardPage: unified LogisticsReadinessBoard for all roles
- OrdersTable cleanup, StatusActionPanel updates
- SW v42
2026-07-01 16:16:22 +00:00
root 581239cc73 fix: unify table styles — no rounded rows, wider type col, horizontal scroll, pagination by filtered groups 2026-06-30 11:43:15 +00:00
root c0c549adca feat: show delivery type (pickup/delivery) in logistics board + driver planner 2026-06-30 09:04:27 +00:00
root 858d36e5b0 feat: manual SMS campaign shows groups in manual mode + pagination on all delivery pages 2026-06-30 07:30:12 +00:00
root 93d3ce4ca8 fix: manage-users — remove manual public.users insert, trigger handle_new_user does it via user_metadata 2026-06-30 07:04:24 +00:00
root cadcceef7e feat: manage-users edge function — admin user CRUD (create/delete) 2026-06-30 06:55:55 +00:00
66 changed files with 7312 additions and 1063 deletions

3
.gitignore vendored
View File

@ -8,3 +8,6 @@ dist
.superpowers
.ruff_cache
volumes/db/data/
__pycache__/
*.pyc
*.bak

View File

@ -4,3 +4,9 @@ cd /opt/supersam
git pull origin main
docker compose -f docker-compose.app.yml up -d --build
echo 'Deploy completed at' $(date)
# ── Backup code to S3 after deploy ─────────────────────────────────────────
if [ -x ./scripts/backup-code-s3.sh ]; then
echo "Running code backup to S3..."
./scripts/backup-code-s3.sh || echo "WARNING: code backup failed, deploy OK"
fi

View File

@ -18,15 +18,15 @@ services:
- traefik.http.routers.supersam-app.tls.certresolver=letsencrypt
- traefik.http.routers.supersam-app.service=supersam-app
- traefik.http.services.supersam-app.loadbalancer.server.port=80
- traefik.http.routers.supersam-app-http.rule=Host(`dost.supersamsev.ru`)
- traefik.http.routers.supersam-app-http.entryPoints=http
- traefik.http.routers.supersam-app-http.middlewares=redirect-to-https
- traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https
- traefik.http.middlewares.redirect-to-https.redirectscheme.permanent=true
- traefik.http.middlewares.supersam-sec.headers.customresponseheaders.X-Content-Type-Options=nosniff
- traefik.http.middlewares.supersam-sec.headers.customresponseheaders.X-Frame-Options=DENY
- traefik.http.middlewares.supersam-sec.headers.customresponseheaders.Referrer-Policy=strict-origin-when-cross-origin
- traefik.http.routers.supersam-app.middlewares=supersam-sec
- traefik.http.routers.supersam-app-http.rule=Host(`dost.supersamsev.ru`)
- traefik.http.routers.supersam-app-http.entryPoints=http
- traefik.http.routers.supersam-app-http.middlewares=supersam-redirect
- traefik.http.middlewares.supersam-redirect.redirectscheme.scheme=https
- traefik.http.middlewares.supersam-redirect.redirectscheme.permanent=true
networks:
coolify:

25
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,25 @@
services:
supersam-dev:
build:
context: /opt/supersam-dev
dockerfile: Dockerfile
args:
VITE_SUPABASE_URL: ${VITE_SUPABASE_URL}
VITE_SUPABASE_ANON_KEY: ${VITE_SUPABASE_ANON_KEY}
VITE_SUPABASE_SERVICE_ROLE_KEY: ${VITE_SUPABASE_SERVICE_ROLE_KEY:-}
container_name: supersam-dev
restart: unless-stopped
networks:
- coolify
labels:
- traefik.enable=true
- traefik.http.routers.supersam-dev.rule=Host(`dev.mkn8n.ru`)
- traefik.http.routers.supersam-dev.entryPoints=https
- traefik.http.routers.supersam-dev.tls=true
- traefik.http.routers.supersam-dev.tls.certresolver=letsencrypt
- traefik.http.routers.supersam-dev.service=supersam-dev
- traefik.http.services.supersam-dev.loadbalancer.server.port=80
networks:
coolify:
external: true

View File

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

View File

@ -1,9 +1,9 @@
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
if (!isLocalhost) {
const STATIC_CACHE = "construction-delivery-static-v49";
const RUNTIME_CACHE = "construction-delivery-runtime-v49";
const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
const STATIC_CACHE = "construction-delivery-static-v107";
const RUNTIME_CACHE = "construction-delivery-runtime-v107";
const APP_SHELL_URLS = ["/", "/client", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
self.addEventListener("install", (event) => {
event.waitUntil(
@ -90,34 +90,50 @@ self.addEventListener("push", (event) => {
data = { title: "Уведомление", body: "" };
}
const title = data.title || "Уведомление";
const options = {
body: data.body || "",
icon: data.icon || "/icons/icon-192.png",
badge: data.badge || "/icons/icon-192.png",
data: data.data || {},
tag: data.tag || "default",
vibrate: [100, 50, 100],
requireInteraction: data.requireInteraction || false,
};
const { title, body, url } = data;
const targetUrl = (() => {
if (!url) return "/client";
try {
const parsed = new URL(url, self.location.origin);
if (parsed.origin !== self.location.origin) return "/client";
const legacyMatch = parsed.pathname.match(/^\/delivery\/([^/]+)$/);
if (legacyMatch) return `/client/${legacyMatch[1]}${parsed.search}${parsed.hash}`;
return parsed.pathname.startsWith("/client")
? `${parsed.pathname}${parsed.search}${parsed.hash}`
: "/client";
} catch {
return "/client";
}
})();
event.waitUntil(self.registration.showNotification(title, options));
event.waitUntil(
self.registration.showNotification(title || "Уведомление", {
body: body || "",
icon: "/icons/icon-512.png",
data: { url: targetUrl },
}),
);
});
// Notification click handler
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const clickData = event.notification.data || {};
const targetUrl = clickData.order_id
? "/dashboard/group/" + clickData.order_id
: "/dashboard";
const targetPath = (event.notification.data && event.notification.data.url) || "/client";
const targetUrl = new URL(targetPath, self.location.origin).href;
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => {
for (const client of clientList) {
if (client.url.includes("/dashboard") && "focus" in client) {
return client.focus();
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then(async (clientList) => {
const sameOriginClient = clientList.find((client) => {
try {
return new URL(client.url).origin === self.location.origin;
} catch {
return false;
}
});
if (sameOriginClient) {
if ("navigate" in sameOriginClient) await sameOriginClient.navigate(targetUrl);
return "focus" in sameOriginClient ? sameOriginClient.focus() : undefined;
}
return self.clients.openWindow(targetUrl);
}),

65
scripts/backup-code-s3.sh Normal file
View File

@ -0,0 +1,65 @@
#!/bin/bash
# Supersam app code backup to S3 (Beget Cloud Storage)
# Archives /opt/supersam source code (excluding node_modules, .git, dist)
# and uploads to S3. Retention: keeps last 30 days.
#
# Cron: 0 1 * * * /opt/supersam/scripts/backup-code-s3.sh >> /var/log/supersam-backup-code.log 2>&1
# Also triggered after deploy via deploy.sh
set -euo pipefail
# ── Config ────────────────────────────────────────────────────────────────
AWS_ACCESS_KEY_ID="YG4MQNKAPNL65200MBUY"
AWS_SECRET_ACCESS_KEY="8mXkFM2VRQ3pN1Nx4mhmJ2jrZoB5YTPUa4CaZh43"
S3_BUCKET="02f162ff4a18-supersam-s3"
S3_PREFIX="backups"
SOURCE_DIR="/opt/supersam"
RETENTION_DAYS=30
# ── Runtime ───────────────────────────────────────────────────────────────
TIMESTAMP=$(date +%Y-%m-%d_%H%M)
ARCHIVE_FILE="/tmp/supersam-code-${TIMESTAMP}.tar.gz"
export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY
echo "[$(date)] Starting code backup..."
# ── Archive ──────────────────────────────────────────────────────────────
echo "[$(date)] Archiving ${SOURCE_DIR}..."
tar -czf "${ARCHIVE_FILE}" \
--directory="${SOURCE_DIR}" \
--exclude="node_modules" \
--exclude=".git" \
--exclude="dist" \
--exclude="volumes" \
--exclude="__pycache__" \
. 2>/dev/null
ARCHIVE_SIZE=$(du -h "${ARCHIVE_FILE}" | cut -f1)
echo "[$(date)] Archive created: ${ARCHIVE_FILE} (${ARCHIVE_SIZE})"
# ── Upload via mc ─────────────────────────────────────────────────────────
echo "[$(date)] Uploading to S3..."
mc cp "${ARCHIVE_FILE}" "beget/${S3_BUCKET}/${S3_PREFIX}/code/supersam-code-${TIMESTAMP}.tar.gz" --quiet 2>&1
echo "[$(date)] Uploaded to s3://${S3_BUCKET}/${S3_PREFIX}/code/supersam-code-${TIMESTAMP}.tar.gz"
# ── Cleanup local ─────────────────────────────────────────────────────────
rm -f "${ARCHIVE_FILE}"
echo "[$(date)] Local cleanup done"
# ── Retention: delete S3 code backups older than RETENTION_DAYS ───────────
echo "[$(date)] Cleaning S3 code backups older than ${RETENTION_DAYS} days..."
CUTOFF=$(date -d "-${RETENTION_DAYS} days" +%Y-%m-%d)
mc ls "beget/${S3_BUCKET}/${S3_PREFIX}/code/" 2>/dev/null \
| awk '{print $NF}' \
| while read -r fname; do
file_date=$(echo "$fname" | grep -oP '\d{4}-\d{2}-\d{2}' || echo "")
if [ -n "$file_date" ] && [[ "$file_date" < "$CUTOFF" ]]; then
mc rm "beget/${S3_BUCKET}/${S3_PREFIX}/code/${fname}" --quiet 2>/dev/null
echo "[$(date)] Deleted old backup: ${fname}"
fi
done
echo "[$(date)] Code backup complete."

65
scripts/backup-pgdump-s3.sh Executable file
View File

@ -0,0 +1,65 @@
#!/bin/bash
# Supersam full DB backup (pg_dump) to S3 (Beget Cloud Storage)
# Dumps the complete supabase postgres database and uploads to S3.
# Retention: keeps last 30 days.
#
# Cron: 0 */12 * * * /opt/supersam/scripts/backup-pgdump-s3.sh >> /var/log/supersam-backup-pgdump.log 2>&1
set -euo pipefail
# ── Config ────────────────────────────────────────────────────────────────
AWS_ACCESS_KEY_ID="YG4MQNKAPNL65200MBUY"
AWS_SECRET_ACCESS_KEY="8mXkFM2VRQ3pN1Nx4mhmJ2jrZoB5YTPUa4CaZh43"
S3_ENDPOINT="https://s3.ru1.storage.beget.cloud"
S3_BUCKET="02f162ff4a18-supersam-s3"
S3_PREFIX="backups"
DB_CONTAINER="supabase-db"
DB_USER="supabase_admin"
DB_NAME="postgres"
DB_PASS="4fe80bb21c7c3d17a8d8b226adf7a479"
RETENTION_DAYS=30
# ── Runtime ───────────────────────────────────────────────────────────────
TIMESTAMP=$(date +%Y-%m-%d_%H%M)
DUMP_FILE="/tmp/supersam-dump-${TIMESTAMP}.sql.gz"
S3_PATH="s3://${S3_BUCKET}/${S3_PREFIX}/pgdump/supersam-db-${TIMESTAMP}.sql.gz"
export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY
echo "[$(date)] Starting pg_dump backup..."
# ── Dump ──────────────────────────────────────────────────────────────────
echo "[$(date)] Dumping database..."
docker exec -e PGPASSWORD="${DB_PASS}" "${DB_CONTAINER}" \
pg_dump -U "${DB_USER}" -d "${DB_NAME}" --no-owner --no-acl --clean --if-exists \
| gzip -9 > "${DUMP_FILE}"
DUMP_SIZE=$(du -h "${DUMP_FILE}" | cut -f1)
echo "[$(date)] Dump created: ${DUMP_FILE} (${DUMP_SIZE})"
# ── Upload via mc ─────────────────────────────────────────────────────────
echo "[$(date)] Uploading to S3..."
mc cp "${DUMP_FILE}" "beget/${S3_BUCKET}/${S3_PREFIX}/pgdump/supersam-db-${TIMESTAMP}.sql.gz" --quiet 2>&1
echo "[$(date)] Uploaded to ${S3_PATH}"
# ── Cleanup local ─────────────────────────────────────────────────────────
rm -f "${DUMP_FILE}"
echo "[$(date)] Local cleanup done"
# ── Retention: delete S3 pgdump backups older than RETENTION_DAYS ─────────
echo "[$(date)] Cleaning S3 pgdump backups older than ${RETENTION_DAYS} days..."
CUTOFF=$(date -d "-${RETENTION_DAYS} days" +%Y-%m-%d)
mc ls "beget/${S3_BUCKET}/${S3_PREFIX}/pgdump/" 2>/dev/null \
| awk '{print $NF}' \
| while read -r fname; do
file_date=$(echo "$fname" | grep -oP '\d{4}-\d{2}-\d{2}' || echo "")
if [ -n "$file_date" ] && [ "$file_date" \< "$CUTOFF" ]; then
mc rm "beget/${S3_BUCKET}/${S3_PREFIX}/pgdump/${fname}" --quiet 2>/dev/null
echo "[$(date)] Deleted old backup: ${fname}"
fi
done
echo "[$(date)] Backup complete."

240
scripts/push_sender.py Executable file
View File

@ -0,0 +1,240 @@
#!/usr/bin/env python3
"""
СуперСам Web Push Sender
Отправляет push-уведомления подписанным клиентам.
Каскад: push 30 мин ждём fallback на SMS.
Usage:
python3 push_sender.py --phone 7XXX --title "Заголовок" --body "Текст" --url https://dev.mkn8n.ru/delivery/xxx
python3 push_sender.py --phone 7XXX --title "test" --body "test" --url https://dev.mkn8n.ru/ --order-group-id UUID
"""
import os
import sys
import json
import logging
import argparse
from datetime import datetime, timezone
import psycopg2
from psycopg2.extras import RealDictCursor
try:
from pywebpush import webpush, WebPushException
except ImportError:
print("ERROR: pywebpush not installed. Run: pip3 install pywebpush", file=sys.stderr)
sys.exit(1)
# ─── Конфигурация ────────────────────────────────────────────────────────────
# Supabase PostgreSQL container; override with DB_HOST in other environments.
DB_HOST = os.environ.get("DB_HOST", "10.0.4.14")
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")
VAPID_SUBJECT = os.environ.get("VAPID_SUBJECT", "mailto:admin@supersam.ru")
LOG_FILE = "/var/log/supersam-push.log"
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler(LOG_FILE),
logging.StreamHandler(sys.stdout),
],
)
log = logging.getLogger("push_sender")
# ─── БД ──────────────────────────────────────────────────────────────────────
def get_db_conn():
return psycopg2.connect(
host=DB_HOST, port=DB_PORT, dbname=DB_NAME,
user=DB_USER, password=DB_PASS,
)
def load_vapid_keys(conn):
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT key, value FROM app_settings WHERE key IN ('vapid_public_key', 'vapid_private_key')")
rows = {r["key"]: r["value"] for r in cur.fetchall()}
public_key = rows.get("vapid_public_key")
private_key = rows.get("vapid_private_key")
if not public_key or not private_key:
raise RuntimeError("VAPID keys not found in app_settings table")
return public_key, private_key
def get_active_subscriptions(conn, phone_normalized):
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("""
SELECT id, endpoint, p256dh, auth, phone_normalized
FROM push_subscriptions
WHERE phone_normalized = %s AND is_active = true
ORDER BY created_at DESC
""", (phone_normalized,))
return [dict(r) for r in cur.fetchall()]
def insert_push_log(conn, **kwargs):
with conn.cursor() as cur:
cols = ", ".join(kwargs.keys())
placeholders = ", ".join(["%s"] * len(kwargs))
cur.execute(f"INSERT INTO push_log ({cols}) VALUES ({placeholders}) RETURNING id", list(kwargs.values()))
log_id = cur.fetchone()[0]
conn.commit()
return log_id
def deactivate_subscription(conn, sub_id):
with conn.cursor() as cur:
cur.execute("UPDATE push_subscriptions SET is_active = false WHERE id = %s", (sub_id,))
conn.commit()
log.info(f"Subscription {sub_id} deactivated (expired/invalid)")
def update_subscription_sent(conn, sub_id):
with conn.cursor() as cur:
cur.execute("UPDATE push_subscriptions SET last_sent_at = NOW() WHERE id = %s", (sub_id,))
conn.commit()
# ─── Push отправка ────────────────────────────────────────────────────────────
def build_payload(title, body, url, order_group_id=None):
return json.dumps({
"title": title,
"body": body,
"url": url,
"order_group_id": order_group_id,
"icon": "/icons/icon-512.png",
"badge": "/icons/icon-512.png",
"vibrate": [200, 100, 200],
})
def send_push(phone_normalized, title, body, url, order_group_id=None, conn=None):
"""Отправляет push-уведомление всем активным подпискам клиента.
Returns: { sent, failed, deactivated, total, details }
"""
own_conn = conn is None
if own_conn:
conn = get_db_conn()
try:
public_key, private_key = load_vapid_keys(conn)
subscriptions = get_active_subscriptions(conn, phone_normalized)
if not subscriptions:
log.info(f"No active push subscriptions for {phone_normalized}")
return {"sent": 0, "failed": 0, "deactivated": 0, "total": 0, "details": []}
payload = build_payload(title, body, url, order_group_id)
sent = 0
failed = 0
deactivated = 0
details = []
for sub in subscriptions:
sub_id = str(sub["id"])
endpoint = sub["endpoint"]
p256dh = sub["p256dh"]
auth = sub["auth"]
log.info(f"Sending push to {phone_normalized} via {endpoint[:60]}...")
try:
webpush(
subscription_info={
"endpoint": endpoint,
"keys": {"p256dh": p256dh, "auth": auth},
},
data=payload,
vapid_private_key=private_key,
vapid_claims={"sub": VAPID_SUBJECT},
ttl=86400,
)
sent += 1
update_subscription_sent(conn, sub["id"])
insert_push_log(conn,
subscription_id=sub["id"],
order_group_id=order_group_id,
phone_normalized=phone_normalized,
title=title,
body=body,
url=url,
status="sent",
)
details.append({"sub_id": sub_id, "status": "sent"})
log.info(f"Push sent to {phone_normalized} (sub {sub_id})")
except WebPushException as e:
failed += 1
status_code = e.response.status_code if e.response else None
error_msg = str(e)[:500]
if status_code in (410, 404):
deactivate_subscription(conn, sub["id"])
deactivated += 1
log.warning(f"Subscription {sub_id} expired ({status_code}), deactivated")
else:
log.error(f"Push failed for {phone_normalized} (sub {sub_id}): {error_msg}")
insert_push_log(conn,
subscription_id=sub["id"],
order_group_id=order_group_id,
phone_normalized=phone_normalized,
title=title,
body=body,
url=url,
status="failed",
error_message=error_msg,
)
details.append({"sub_id": sub_id, "status": "failed", "code": status_code, "error": error_msg})
except Exception as e:
failed += 1
error_msg = str(e)[:500]
log.error(f"Push unexpected error for {phone_normalized}: {error_msg}")
insert_push_log(conn,
subscription_id=sub["id"],
order_group_id=order_group_id,
phone_normalized=phone_normalized,
title=title,
body=body,
url=url,
status="failed",
error_message=error_msg,
)
details.append({"sub_id": sub_id, "status": "error", "error": error_msg})
return {"sent": sent, "failed": failed, "deactivated": deactivated, "total": len(subscriptions), "details": details}
finally:
if own_conn:
conn.close()
# ─── CLI ─────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="SuperSam Push Sender")
parser.add_argument("--phone", required=True, help="Phone normalized (7XXXXXXXXXX)")
parser.add_argument("--title", required=True, help="Notification title")
parser.add_argument("--body", required=True, help="Notification body text")
parser.add_argument("--url", required=True, help="URL to open on click")
parser.add_argument("--order-group-id", default=None, help="Order group UUID")
args = parser.parse_args()
log.info(f"Sending push: phone={args.phone}, title={args.title}, url={args.url}")
result = send_push(args.phone, args.title, args.body, args.url, args.order_group_id)
log.info(f"Result: {result}")
print(json.dumps(result, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@ -173,18 +173,44 @@ def send_telegram(message, chat_id):
# ─── State Machine ───────────────────────────────────────────────────────────
def get_groups_to_send(conn):
"""Группы, которым ещё не отправили SMS (или отправили очень давно)"""
"""Группы, которым ещё не отправили SMS.
Режимы (из sms_city_profiles):
- link: отправка ссылки на согласование (старый флоу)
- notification: уведомление без ссылки (для доставки/самовывоза без согласования)
"""
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.customer_phone_normalized, og.delivery_link, og.notification_status,
og.delivery_type, og.town,
-- Профиль города (fallback на дефолтные значения если нет записи)
COALESCE(scp.delivery_mode, 'link') as delivery_mode,
COALESCE(scp.delivery_text, '') as delivery_text,
COALESCE(scp.pickup_mode, 'link') as pickup_mode,
COALESCE(scp.pickup_text, '') as pickup_text,
COALESCE(scp.sms_enabled, true) as sms_enabled,
-- Выбор режима и текста по типу доставки
CASE
WHEN og.delivery_type = 'pickup'
THEN COALESCE(scp.pickup_mode, 'link')
ELSE
COALESCE(scp.delivery_mode, 'link')
END as sms_mode,
CASE
WHEN og.delivery_type = 'pickup'
THEN COALESCE(scp.pickup_text, '')
ELSE
COALESCE(scp.delivery_text, '')
END as sms_text_custom
FROM order_groups og
LEFT JOIN sms_city_profiles scp ON scp.city = og.town
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 минут
-- Профиль sms_enabled (замена старого sms_allowed_for_city)
AND COALESCE(scp.sms_enabled, true) = true
-- Нет активной SMS в логе (sent/checking) за последние 2 часа
AND NOT EXISTS (
SELECT 1 FROM sms_campaign_log scl
WHERE scl.order_group_id = og.id
@ -292,6 +318,10 @@ def step_send_new(conn, settings):
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", "")
delivery_type = group.get("delivery_type") or "delivery"
town = group.get("town") or ""
sms_mode = group.get("sms_mode") or "link"
custom_text = group.get("sms_text_custom") or ""
# Проверяем: была ли уже SMS этой группе сегодня (защита от повторной отправки)
with conn.cursor() as cur:
@ -306,7 +336,15 @@ def step_send_new(conn, settings):
log.info(f"Group {group_id}: already has recent SMS in log, skipping")
continue
sms_text = f"Ваш заказ готов. Согласуйте дату доставки по ссылке: {delivery_link}"
# Формируем текст SMS по режиму
if sms_mode == "notification":
# Уведомление без ссылки на согласование
sms_text = custom_text if custom_text else "Ваш заказ готов."
log.info(f"Group {group_id}: NOTIFICATION mode (town={town}, type={delivery_type}), no link")
else:
# Стандартный флоу — ссылка на согласование
sms_text = f"Ваш заказ готов. Согласуйте дату доставки по ссылке: {delivery_link}"
log.info(f"Group {group_id}: LINK mode (town={town}, type={delivery_type})")
# ТЕСТОВЫЙ РЕЖИМ: подменяем номер на тестовый
send_phone = phone
@ -328,13 +366,23 @@ def step_send_new(conn, settings):
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")
# ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status
if sms_mode == "notification":
# Notification: нет 2-й SMS, сразу first_sms_sent
update_order_group(conn, group_id, {
"notification_status": "first_sms_sent",
"sms_sent_at": "NOW()",
"first_sms_sent_at": "NOW()",
"status": "first_sms_sent",
})
log.info(f"Group {group_id}: NOTIFICATION SMS sent, status→first_sms_sent (no second SMS)")
else:
# Link: ждём доставки SMS, потом 2-я SMS
update_order_group(conn, group_id, {
"notification_status": "sms_sending",
"sms_sent_at": "NOW()",
})
log.info(f"Group {group_id}: LINK SMS sent, status→sms_sending")
sent_count += 1
else:
# Ошибка отправки

View File

@ -180,11 +180,14 @@ def get_groups_to_send(conn):
og.customer_phone_normalized, og.delivery_link, og.notification_status,
og.paid_storage_at
FROM order_groups og
LEFT JOIN sms_city_profiles scp ON scp.city = og.town
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 != ''
-- Профиль: paid_storage_enabled (если false SMS о платном хранении не отправляется)
AND COALESCE(scp.paid_storage_enabled, true) = true
AND NOT EXISTS (
SELECT 1 FROM sms_campaign_log scl
WHERE scl.order_group_id = og.id

13
scripts/sms_run_with_db.sh Executable file
View File

@ -0,0 +1,13 @@
#!/bin/bash
# Resolve supabase-db container IP dynamically
DB_IP=$(docker inspect supabase-db --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' 2>/dev/null)
if [ -z "$DB_IP" ]; then
echo "ERROR: Cannot resolve supabase-db IP" >&2
exit 1
fi
export DB_HOST=$DB_IP
export DB_PORT=5432
export DB_NAME=postgres
export DB_USER=supabase_admin
export DB_PASS=4fe80bb21c7c3d17a8d8b226adf7a479
exec python3 "$@"

View File

@ -200,12 +200,15 @@ def get_groups_to_send(conn):
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
LEFT JOIN sms_city_profiles scp ON scp.city = og.town
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())
-- Профиль: second_sms_enabled (если false 2-я SMS не отправляется)
AND COALESCE(scp.second_sms_enabled, true) = true
-- Нет активной второй SMS в логе
AND NOT EXISTS (
SELECT 1 FROM sms_campaign_log scl

84
scripts/sms_timer_manager.sh Executable file
View File

@ -0,0 +1,84 @@
#!/bin/bash
# sms_timer_manager.sh — reconciles DB flags with systemd timers
# Runs every 1 min via cron:
# 1. timer_active → enable/disable systemd timer
# 2. run_requested → run campaign script immediately (script resets flag itself)
set -euo pipefail
DB_HOST="10.0.4.12"
DB_PORT="5432"
DB_NAME="postgres"
DB_USER="supabase_admin"
DB_PASS="4fe80bb21c7c3d17a8d8b226adf7a479"
declare -A TIMERS=(
["first_sms"]="sms-first-campaign.timer"
["second_sms"]="sms-second-campaign.timer"
["manual"]="sms-manual-campaign.timer"
["paid_storage"]="sms-paid-storage-campaign.timer"
)
declare -A SERVICES=(
["first_sms"]="sms-first-campaign.service"
["second_sms"]="sms-second-campaign.service"
["manual"]="sms-manual-campaign.service"
["paid_storage"]="sms-paid-storage-campaign.service"
)
declare -A SCRIPTS=(
["first_sms"]="/opt/supersam/scripts/sms_first_campaign.py"
["second_sms"]="/opt/supersam/scripts/sms_second_campaign.py"
["manual"]="/opt/supersam/scripts/sms_manual_campaign.py"
["paid_storage"]="/opt/supersam/scripts/sms_paid_storage_campaign.py"
)
QUERY="SELECT campaign_type, timer_active, run_requested FROM sms_campaign_settings"
RESULTS=$(PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -A -F '|' -c "$QUERY" 2>/dev/null || echo "")
if [ -z "$RESULTS" ]; then
echo "$(date): Failed to query DB, skipping"
exit 0
fi
while IFS='|' read -r campaign_type timer_active run_requested; do
[ -z "$campaign_type" ] && continue
timer_name="${TIMERS[$campaign_type]:-}"
service_name="${SERVICES[$campaign_type]:-}"
script_path="${SCRIPTS[$campaign_type]:-}"
[ -z "$timer_name" ] && continue
if ! systemctl list-unit-files "$timer_name" 2>/dev/null | grep -q "$timer_name"; then
continue
fi
# ── 1. Timer enable/disable ──
is_active=$(systemctl is-active "$timer_name" 2>/dev/null || echo "inactive")
if [ "$timer_active" = "t" ] || [ "$timer_active" = "true" ]; then
if [ "$is_active" != "active" ]; then
systemctl enable --now "$timer_name" 2>/dev/null
echo "$(date): ENABLED $timer_name for $campaign_type"
fi
else
if [ "$is_active" = "active" ]; then
systemctl disable --now "$timer_name" 2>/dev/null
echo "$(date): DISABLED $timer_name for $campaign_type"
fi
fi
# ── 2. Run requested → immediate execution ──
# Don't reset flag here — script reads it from DB and resets after processing
if [ "$run_requested" = "t" ] || [ "$run_requested" = "true" ]; then
is_running=$(systemctl is-active "$service_name" 2>/dev/null || echo "inactive")
if [ "$is_running" = "active" ]; then
echo "$(date): $campaign_type already running, skipping run_requested"
continue
fi
echo "$(date): RUN REQUESTED — starting $campaign_type immediately"
systemctl start "$service_name" 2>/dev/null || {
nohup python3 "$script_path" >> "/var/log/supersam-sms-${campaign_type}.log" 2>&1 &
echo "$(date): Started $campaign_type via nohup fallback"
}
fi
done <<< "$RESULTS"

216
src/AppShell.jsx Normal file
View File

@ -0,0 +1,216 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import { ROLE_LABELS } from "../constants/roles";
import { Badge } from "../components/UI/Badge";
import { Button } from "../components/UI/Button";
import { Panel } from "../components/UI/Panel";
import { ThemeToggle } from "../components/UI/ThemeToggle";
import { PwaInstallButton } from "../components/UI/PwaInstallButton";
import { NotificationBell } from "../components/notifications/NotificationBell";
import { NotificationSettings } from "../components/notifications/NotificationSettings";
export const AppShell = ({
user,
onInstallApp,
isInstalled,
isInstallAvailable,
onSignOut,
onOpenGuide,
isGuideOpen = false,
navItems,
activeSection,
onSectionChange,
sectionMeta,
notifications = [],
unreadCount = 0,
onMarkNotificationRead,
onMarkAllNotificationsRead,
children,
}) => {
const shouldShowMobileNav = !isGuideOpen && navItems.length > 1;
const [showNotifSettings, setShowNotifSettings] = React.useState(false);
if (showNotifSettings) {
return (
<div className="min-h-screen px-3 py-4 sm:px-4 md:px-6 md:py-8">
<div className="mx-auto max-w-2xl">
<NotificationSettings
userId={user?.id}
userRole={user?.role}
onBack={() => setShowNotifSettings(false)}
/>
</div>
</div>
);
}
return (
<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">
{/* Desktop sidebar */}
<Panel className="fs-zone-nav hidden h-fit flex-col gap-5 p-4 xl:flex">
<div>
<p className="text-xs uppercase tracking-[0.24em] text-[var(--color-text-muted)]">
Панель
</p>
<h1 className="mt-2 text-lg font-semibold leading-tight">Управление доставкой</h1>
</div>
<div className="space-y-1">
{navItems.map((item) => (
<Button
key={item.key}
variant="ghost"
className={[
"flex w-full items-center justify-between rounded-[18px] px-3 py-3 text-left text-sm transition",
activeSection === item.key
? "bg-[var(--color-accent-soft)] text-[var(--color-text)]"
: "text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-text)]",
].join(" ")}
onClick={() => onSectionChange(item.key)}
type="button"
>
<span className="font-medium">{item.label}</span>
{item.badge ? <Badge tone="accent">{item.badge}</Badge> : null}
</Button>
))}
</div>
<div className="mt-auto">
{onOpenGuide ? (
<Button variant="ghost" className="mb-2 w-full justify-start" onClick={onOpenGuide}>
{isGuideOpen ? "К рабочей области" : "Справка"}
</Button>
) : null}
<Button variant="ghost" className="mb-2 w-full justify-start" onClick={() => navigate("/settings")}>
Настройки
</Button>
<Button variant="ghost" className="mb-2 w-full justify-start" onClick={() => window.open("https://forms.gle/feedback-supersam", "_blank")}>
💡 Предложить улучшение
</Button>
<Button variant="ghost" className="w-full justify-start" onClick={onSignOut}>
Выйти
</Button>
</div>
</Panel>
{/* Main content area */}
<div className="min-w-0 space-y-5 pb-20 xl:space-y-8 xl:pb-0 fs-zone-body">
{/* Mobile header */}
<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="min-w-0 flex-1 space-y-1">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
Рабочая область
</p>
<h2 className="text-lg font-semibold leading-tight sm:text-xl md:text-2xl">
{sectionMeta?.label || "Панель"}
</h2>
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
{user.name} · {ROLE_LABELS[user.role] || user.role}
</p>
</div>
<div className="flex items-center gap-1 md:flex-shrink-0">
<NotificationBell
notifications={notifications}
unreadCount={unreadCount}
onMarkAsRead={onMarkNotificationRead}
onMarkAllAsRead={onMarkAllNotificationsRead}
onOpenSettings={() => setShowNotifSettings(true)}
/>
{onOpenGuide ? (
<Button size="sm" variant="ghost" onClick={onOpenGuide} aria-label="Справка">
?
</Button>
) : null}
<PwaInstallButton onInstall={onInstallApp} isInstalled={isInstalled} isInstallAvailable={isInstallAvailable} />
<Button size="sm" variant="ghost" onClick={() => navigate("/settings")} aria-label="Настройки">
</Button>
<Button size="sm" variant="ghost" onClick={() => window.open("https://forms.gle/feedback-supersam", "_blank")} aria-label="Предложить улучшение">
💡
</Button>
<ThemeToggle />
<Button size="sm" variant="ghost" onClick={onSignOut}>
Выйти
</Button>
</div>
</div>
</Panel>
{/* Mobile tab navigation — STICKY TOP */}
{shouldShowMobileNav && (
<div className="sticky inset-x-0 top-0 z-40 -mx-3 -mt-4 border-b border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 backdrop-blur xl:hidden sm:-mx-4 md:-mx-6 fs-zone-nav">
<div className="flex gap-1 overflow-x-auto" style={{ WebkitOverflowScrolling: 'touch', scrollbarWidth: 'none' }}>
{navItems.map((item) => (
<Button
key={item.key}
variant="ghost"
className={[
"flex flex-shrink-0 items-center gap-1.5 rounded-[14px] px-3 py-2 text-sm transition",
activeSection === item.key
? "bg-[var(--color-accent)] text-[var(--color-accent-contrast)]"
: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]",
].join(" ")}
onClick={() => onSectionChange(item.key)}
type="button"
>
<span className="truncate font-medium">{item.label}</span>
{item.badge ? (
<Badge tone={activeSection === item.key ? "neutral" : "accent"}>{item.badge}</Badge>
) : null}
</Button>
))}
</div>
</div>
)}
{/* Desktop header */}
<Panel className="hidden p-4 md:p-5 xl:block fs-zone-heading">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<p className="text-sm uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
Рабочая область
</p>
<h2 className="mt-2 text-2xl font-semibold">{sectionMeta?.label || "Панель"}</h2>
{sectionMeta?.description ? (
<p className="mt-2 max-w-3xl text-sm leading-6 text-[var(--color-text-muted)]">
{sectionMeta.description}
</p>
) : null}
</div>
<div className="flex flex-wrap items-center gap-3">
<NotificationBell
notifications={notifications}
unreadCount={unreadCount}
onMarkAsRead={onMarkNotificationRead}
onMarkAllAsRead={onMarkAllNotificationsRead}
onOpenSettings={() => setShowNotifSettings(true)}
/>
<div className="text-right">
<div className="text-sm font-medium">{user.name}</div>
<div className="text-sm text-[var(--color-text-muted)]">{ROLE_LABELS[user.role] || user.role}</div>
</div>
{onOpenGuide ? (
<Button size="sm" variant="ghost" onClick={onOpenGuide} aria-label="Справка">
{isGuideOpen ? "Назад" : "?"}
</Button>
) : null}
<PwaInstallButton onInstall={onInstallApp} isInstalled={isInstalled} isInstallAvailable={isInstallAvailable} />
<Button size="sm" variant="ghost" onClick={() => navigate("/settings")} aria-label="Настройки">
</Button>
<Button size="sm" variant="ghost" onClick={() => window.open("https://forms.gle/feedback-supersam", "_blank")} aria-label="Предложить улучшение">
💡
</Button>
<ThemeToggle />
</div>
</div>
</Panel>
{children}
</div>
</div>
</div>
);
};

View File

@ -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;
};

View File

@ -0,0 +1,389 @@
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 {
filterOrderGroups,
getOrderGroupDisplayStatusLabel,
getOrderGroupDisplayStatusValue,
getOrderGroupStatusTone,
ORDER_GROUP_DISPLAY_STATUS_OPTIONS,
} from "../../services/orderGroupViews";
import { Badge } from "../UI/Badge";
import { Panel } from "../UI/Panel";
import { SkeletonPage } from "../UI/Loading";
import { OrderFilters } from "../orders/OrderFilters";
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>
);
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)]`}
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)]">
{group.customerPhone || ""}
</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 }) => {
const [filters, setFilters] = React.useState({ query: "", displayStatus: "all", city: "" });
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 set = new Set();
for (const g of orderGroups) {
if (g.city) set.add(g.city);
}
return [...set].sort();
}, [orderGroups]);
const filteredGroups = React.useMemo(
() => filterOrderGroups(orderGroups, filters),
[filters, orderGroups],
);
const statusGroups = React.useMemo(() => {
const map = new Map();
for (const group of filteredGroups) {
const statusValue = getOrderGroupDisplayStatusValue(group);
if (!map.has(statusValue)) {
const label = getOrderGroupDisplayStatusLabel(group);
map.set(statusValue, { label, groups: [] });
}
map.get(statusValue).groups.push(group);
}
return map;
}, [filteredGroups]);
const totalGroups = filteredGroups.length;
// Build sorted list: use sectionOrder for known statuses, append unknown ones at end
const sortedEntries = React.useMemo(() => {
const present = new Set(statusGroups.keys());
const result = [];
// First: statuses in custom order that are present
for (const statusValue of sectionOrder) {
if (present.has(statusValue)) {
const data = statusGroups.get(statusValue);
result.push([statusValue, data]);
}
}
// 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) {
return <SkeletonPage panels={3} />;
}
return (
<div className="space-y-6 fs-zone-table">
<Panel className="space-y-4 p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<h2 className="text-lg font-semibold">Наборы доставки</h2>
<p className="text-xs text-[var(--color-text-muted)] mt-0.5">
Перетаскивайте секции за ручку слева, чтобы изменить порядок отображения.
</p>
</div>
<Badge tone="neutral">{totalGroups} групп</Badge>
</div>
<OrderFilters
filters={filters}
setFilters={setFilters}
statusOptions={statusOptions}
cities={cities}
/>
</Panel>
{!totalGroups ? (
<div className="rounded-[28px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
По этому поиску ничего не найдено.
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sortedEntries.map(([id]) => id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-4">
{sortedEntries.map(([statusValue, { label, groups }]) => {
const isCollapsed = collapsedSections.has(statusValue);
return (
<SortableSection
key={statusValue}
statusValue={statusValue}
label={label}
groups={groups}
isCollapsed={isCollapsed}
onToggle={() => {
setCollapsedSections((prev) => {
const next = new Set(prev);
if (next.has(statusValue)) {
next.delete(statusValue);
} else {
next.add(statusValue);
}
saveCollapsedSections(next);
return next;
});
}}
onSelectSet={onSelectSet}
/>
);
})}
</div>
</SortableContext>
</DndContext>
)}
</div>
);
};

316
src/OrdersTable.jsx Normal file
View File

@ -0,0 +1,316 @@
import { formatDateTime } from "../../utils/formatters";
import { Badge } from "../UI/Badge";
import { Panel } from "../UI/Panel";
import { SkeletonTable } from "../UI/Loading";
import { OrderFilters } from "./OrderFilters";
import {
getOrderGroupDisplayStatusLabel,
getOrderGroupStatusTone,
} from "../../services/orderGroupViews";
const MAX_VISIBLE_INVOICES = 2;
const TERMINAL_STATUSES = ["delivered", "picked_up", "cancelled"];
const fmtDate = (d) => {
if (!d) return '';
const [y, m, day] = d.split('-');
if (!y || !m || !day) return d;
return `${day}.${m}.${y}`;
};
const hasNoSms = (group) => {
if (!group) return false;
if (group.firstSmsSentAt) return false;
if (TERMINAL_STATUSES.includes(group.deliveryStatus)) return false;
return true;
};
const getDaysSinceCreation = (group) => {
if (!group) return null;
const ts = group.createdFromExchangeAt || group.createdAt || group.updatedAt;
if (!ts) return null;
const days = Math.floor((Date.now() - new Date(ts)) / 86400000);
return days < 0 ? 0 : days;
};
const getDaysColorClass = (days) => {
if (days == null) return "text-[var(--color-text-muted)]";
if (days < 3) return "text-[var(--color-text)]";
if (days <= 7) return "text-[var(--color-warning)]";
return "text-[var(--color-danger)]";
};
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 orderCountLabel = `${group.ordersCount || 0} ${group.ordersCount === 1 ? "заказ" : group.ordersCount < 5 ? "заказа" : "заказов"}`;
const parts = [orderCountLabel];
if (group.deliveryDate) {
const datePart = group.deliveryTime ? `${fmtDate(group.deliveryDate)} · ${group.deliveryTime}` : fmtDate(group.deliveryDate);
parts.push(datePart);
}
if (group.assignedDriverName) {
parts.push(group.assignedDriverName);
}
return parts.join(" · ");
};
const renderOrderNumbers = (group) => {
const numbers = group.allBillNumbers || group.orderNumbers;
if (!Array.isArray(numbers) || !numbers.length) {
return "Номера не указаны";
}
if (numbers.length <= MAX_VISIBLE_INVOICES) {
return numbers.join(", ");
}
const visible = numbers.slice(0, MAX_VISIBLE_INVOICES);
const remaining = numbers.length - MAX_VISIBLE_INVOICES;
return `${visible.join(", ")} +${remaining}`;
};
const getDeliveryTypeLabel = (deliveryType) => {
if (deliveryType === "pickup") return "Самовывоз";
if (deliveryType === "delivery") return "Доставка";
return "—";
};
const renderMobileOrderNumbers = (group) => {
const numbers = group.allBillNumbers || group.orderNumbers;
if (!Array.isArray(numbers) || !numbers.length) {
return "Номера не указаны";
}
if (numbers.length <= MAX_VISIBLE_INVOICES) {
return numbers.join(", ");
}
const visible = numbers.slice(0, MAX_VISIBLE_INVOICES);
const remaining = numbers.length - MAX_VISIBLE_INVOICES;
return (
<>
{visible.join(", ")}
<span className="ml-1 rounded-full bg-[var(--color-accent-soft)] px-1.5 py-0.5 text-xs font-medium text-[var(--color-accent)]">+{remaining}</span>
</>
);
};
export const OrdersTable = ({
orderGroups = [],
selectedOrderGroupId,
onOpenOrder,
filters,
setFilters,
statusOptions,
cities = [],
isLoading = false,
}) => {
if (isLoading) {
return <SkeletonTable rows={5} cols={5} />;
}
return (
<Panel className="p-0 fs-zone-table">
<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>
<h2 className="text-lg font-semibold">Группы доставки</h2>
<p className="text-sm text-[var(--color-text-muted)]">
Поиск по группе, клиенту, телефону и дате доставки.
</p>
</div>
<Badge tone="neutral">{orderGroups.length}</Badge>
</div>
{filters && setFilters ? (
<OrderFilters filters={filters} setFilters={setFilters} statusOptions={statusOptions} cities={cities} />
) : null}
</div>
<div className="space-y-3 p-4 md:hidden">
{!orderGroups.length ? (
<div className="rounded-[28px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
Группы не найдены. Попробуйте изменить поиск или статус.
</div>
) : null}
{orderGroups.map((group) => {
const hasProblem = group.hasDeliveryProblem;
const noSms = hasNoSms(group);
const days = getDaysSinceCreation(group);
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)]"
: noSms
? "border-[var(--color-border)] bg-[rgba(245,158,11,0.05)]"
: "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
type="button"
onClick={() => onOpenOrder(group.id)}
className={[baseClass, selectedClass, "flex flex-col items-start justify-start p-4"].join(" ")}
>
<div className="min-w-0 w-full font-medium leading-snug break-words">
{group.displayTitle || group.customerName || group.groupKey}
</div>
<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)}
</Badge>
{noSms && (
<span className="inline-flex items-center gap-1 rounded-full bg-[rgba(245,158,11,0.15)] px-2 py-0.5 text-[10px] font-medium text-[#d97706]" title="SMS не отправлено">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[#f59e0b]"></span>
SMS не отправлено
</span>
)}
{days != null && (
<span className={`inline-flex items-center rounded-full bg-[var(--color-surface-strong)] px-2 py-0.5 text-[10px] font-medium ${getDaysColorClass(days)}`}>
{days} дн
</span>
)}
</div>
{group.hasDeliveryProblem && (
<div className="mt-2 rounded-lg border border-[var(--color-warning)] bg-[var(--color-warning-soft)] px-2 py-1 text-xs">
<span className="font-medium text-[var(--color-warning)]"> Проблема с доставкой</span>
{group.deliveryProblemNote && (
<div className="mt-0.5 text-[var(--color-text-muted)]">{group.deliveryProblemNote}</div>
)}
</div>
)}
</button>
</div>
);
})}
</div>
<div className="hidden md:block">
{!orderGroups.length ? (
<div className="px-5 py-6 text-sm text-[var(--color-text-muted)]">
Группы не найдены. Попробуйте изменить поиск или статус.
</div>
) : (
<div className="overflow-x-auto">
<div className="min-w-[1180px]">
<div className="grid grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(100px,0.8fr)_minmax(100px,1fr)_minmax(100px,1fr)_minmax(70px,0.5fr)_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)]">
<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 className="px-3 py-1.5 font-medium">Водитель</div>
</div>
{orderGroups.map((group) => {
const hasProblem = group.hasDeliveryProblem;
const noSms = hasNoSms(group);
const days = getDaysSinceCreation(group);
let rowBg = "";
if (selectedOrderGroupId === group.id) {
rowBg = "bg-[var(--color-accent-soft)]";
} else if (hasProblem) {
rowBg = "bg-[rgba(201,61,61,0.1)] hover:bg-[rgba(201,61,61,0.15)]";
} else if (noSms) {
rowBg = "bg-[rgba(245,158,11,0.05)] hover:bg-[rgba(245,158,11,0.1)]";
} else {
rowBg = "hover:bg-[var(--color-accent-soft)]";
}
const rowClassName = `grid grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(100px,0.8fr)_minmax(100px,1fr)_minmax(100px,1fr)_minmax(70px,0.5fr)_minmax(100px,0.8fr)_minmax(90px,0.8fr)] gap-0 w-full border-t border-[var(--color-border)] text-left transition ${rowBg}`;
const billNumbers = group.allBillNumbers || group.orderNumbers || [];
const primaryBill = billNumbers[0] || "—";
const totalBills = billNumbers.length;
return (
<button
key={group.id}
type="button"
className={rowClassName}
onClick={() => onOpenOrder(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)]">
{group.customerPhone || ""}
</div>
</div>
<div className="px-3 py-1.5">
<div className="text-xs text-[var(--color-text)]">{primaryBill}</div>
{totalBills > 1 && (
<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)]">
{totalBills} сч.
</span>
)}
</div>
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
{group.city || "—"}
</div>
<div className="px-3 py-1.5">
<div className="flex flex-wrap items-center gap-1">
<Badge tone={getOrderGroupStatusTone(group)}>
{getOrderGroupDisplayStatusLabel(group)}
</Badge>
{noSms && (
<span className="inline-flex items-center gap-1 rounded-full bg-[rgba(245,158,11,0.15)] px-1.5 py-0.5 text-[10px] font-medium text-[#d97706]" title="SMS не отправлено">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[#f59e0b]"></span>
SMS
</span>
)}
</div>
</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 font-medium ${getDaysColorClass(days)}`}>
{days != null ? `${days} дн` : "—"}
</div>
<div className="px-3 py-1.5 text-xs">
<span className="inline-flex items-center gap-1">
{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.assignedDriverName || <span className="text-[var(--color-text-muted)]"></span>}
</div>
</button>
);
})}
</div>
</div>
)}
</div>
</Panel>
);
};

186
src/SettingsPage.jsx Normal file
View File

@ -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;

View File

@ -0,0 +1,80 @@
import React from "react";
/**
* Universal pagination control.
* Usage: <Pagination page={1} totalPages={5} onChange={setPage} />
*/
export const Pagination = ({ page, totalPages, onChange, itemsPerPage, totalItems }) => {
if (totalPages <= 1) return null;
const from = (page - 1) * (itemsPerPage || 0) + 1;
const to = Math.min(page * (itemsPerPage || 0), totalItems || 0);
const pages = [];
const maxButtons = 7;
let start = Math.max(1, page - 3);
let end = Math.min(totalPages, start + maxButtons - 1);
if (end - start < maxButtons - 1) start = Math.max(1, end - maxButtons + 1);
for (let i = start; i <= end; i++) pages.push(i);
return (
<div className="flex flex-wrap items-center justify-between gap-2 px-4 py-3 border-t border-[var(--color-border)]">
<span className="text-xs text-[var(--color-text-muted)]">
{totalItems != null && itemsPerPage
? `${from}${to} из ${totalItems}`
: `Стр. ${page} из ${totalPages}`}
</span>
<div className="flex items-center gap-1.5">
<button
type="button"
disabled={page <= 1}
onClick={() => onChange(page - 1)}
className="rounded-lg border border-[var(--color-border)] px-2.5 py-1 text-xs font-medium text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-strong)] disabled:opacity-30 disabled:cursor-not-allowed"
>
</button>
{start > 1 && (
<>
<button
type="button"
onClick={() => onChange(1)}
className="rounded-lg px-2.5 py-1 text-xs font-medium text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] transition"
>1</button>
{start > 2 && <span className="text-xs text-[var(--color-text-muted)] px-1"></span>}
</>
)}
{pages.map((p) => (
<button
key={p}
type="button"
onClick={() => onChange(p)}
className={`rounded-lg px-2.5 py-1 text-xs font-medium transition ${
p === page
? "bg-[var(--color-accent)] text-white shadow-sm"
: "text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] border border-[var(--color-border)]"
}`}
>{p}</button>
))}
{end < totalPages && (
<>
{end < totalPages - 1 && <span className="text-xs text-[var(--color-text-muted)] px-1"></span>}
<button
type="button"
onClick={() => onChange(totalPages)}
className="rounded-lg px-2.5 py-1 text-xs font-medium text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] transition"
>{totalPages}</button>
</>
)}
<button
type="button"
disabled={page >= totalPages}
onClick={() => onChange(page + 1)}
className="rounded-lg border border-[var(--color-border)] px-2.5 py-1 text-xs font-medium text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-strong)] disabled:opacity-30 disabled:cursor-not-allowed"
>
</button>
</div>
</div>
);
};

View File

@ -51,7 +51,7 @@ export const PwaInstallButton = ({ onInstall, isInstalled, isInstallAvailable })
</button>
{showTip && (
<div className="absolute left-1/2 -translate-x-1/2 top-full z-50 mt-2 w-60 sm:left-auto sm:translate-x-0 sm:right-0 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3 text-sm shadow-lg">
<div className="fixed right-3 top-14 z-[100] mt-2 w-64 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3 text-sm shadow-2xl sm:absolute sm:right-0 sm:top-full sm:w-60 sm:mt-2 sm:shadow-lg">
{isIOS ? (
<>
<p className="font-medium">Установка на iOS</p>

View File

@ -1,8 +1,9 @@
/**
* @file AdminDashboard.jsx
* @description Admin analytics dashboard. Displays KPI cards, status pie chart,
* daily trend line, confirmation funnel, SMS stats, and driver performance
* bar chart. Supports period selection (1d/7d/30d/all) and mobile layout.
* @description Admin analytics dashboard. Redesigned with responsive grid layout
* that uses desktop width effectively. KPI cards, status pie, daily trend,
* confirmation funnel, SMS stats, driver performance, pickup stats.
* Period label shown in section subtitles.
*/
import React, { useState, useEffect } from 'react';
import {
@ -10,7 +11,6 @@ import {
PieChart, Pie, Cell, Legend, LineChart, Line, CartesianGrid,
} from 'recharts';
import { Panel } from '../UI/Panel';
import { Badge } from '../UI/Badge';
import { SegmentedTabs } from '../UI/SegmentedTabs';
import { Skeleton } from '../UI/Loading';
import { useAdminStats } from '../../hooks/useAdminStats';
@ -21,7 +21,7 @@ import { PickupStatsPanel } from './PickupStatsPanel';
const useIsMobile = () => {
const [mobile, setMobile] = useState(false);
useEffect(() => {
const mq = window.matchMedia('(max-width: 640px)');
const mq = window.matchMedia('(max-width: 768px)');
setMobile(mq.matches);
const handler = (e) => setMobile(e.matches);
mq.addEventListener('change', handler);
@ -38,7 +38,8 @@ const STATUS_COLORS = {
driver_assigned: '#3b82f6',
loaded: '#6366f1',
on_route: '#8b5cf6',
delivered: '#10b981',
delivered: '#22c55e',
picked_up: '#14b8a6',
paid_storage: '#06b6d4',
problem: '#ef4444',
cancelled: '#64748b',
@ -53,6 +54,7 @@ const STATUS_LABELS = {
loaded: 'Загружено',
on_route: 'В пути',
delivered: 'Доставлено',
picked_up: 'Вывезено',
paid_storage: 'Оплаченное хранение',
problem: 'Проблема',
cancelled: 'Отменено',
@ -64,9 +66,16 @@ const PERIOD_OPTIONS = [
{ key: '1d', label: 'Сегодня' },
{ key: '7d', label: '7 дней' },
{ key: '30d', label: '30 дней' },
{ key: 'all', label: 'Все' },
{ key: 'all', label: 'Всё время' },
];
const PERIOD_LABELS = {
'1d': 'за сегодня',
'7d': 'за 7 дней',
'30d': 'за 30 дней',
'all': 'за всё время',
};
// Custom Recharts Tooltip
const CustomTooltip = ({ active, payload, label: tooltipLabel }) => {
if (!active || !payload?.length) return null;
@ -85,44 +94,65 @@ const CustomTooltip = ({ active, payload, label: tooltipLabel }) => {
);
};
// KPI Card
const KpiCard = ({ label, value, color, mobile }) => (
<Panel style={{
padding: mobile ? '0.5rem 0.6rem' : '0.75rem 1rem',
textAlign: 'center',
display: 'flex', flexDirection: 'column', justifyContent: 'center',
minHeight: mobile ? '60px' : '80px',
}}>
<div style={{ fontSize: mobile ? '0.6rem' : '0.72rem', color: 'var(--color-text-muted)', marginBottom: '0.15rem', textTransform: 'uppercase', letterSpacing: '0.03em' }}>
{label}
</div>
<div style={{ fontSize: mobile ? '1.15rem' : '1.6rem', fontWeight: 800, color: color || 'var(--color-text)', lineHeight: 1.1 }}>
{value ?? '—'}
</div>
</Panel>
);
// Section Header
const SectionHeader = ({ title, subtitle, mobile }) => (
<div style={{ marginBottom: '0.6rem' }}>
<h3 style={{ fontSize: mobile ? '0.9rem' : '1rem', fontWeight: 700, color: 'var(--color-text)', marginBottom: subtitle ? '0.1rem' : 0 }}>
{title}
</h3>
{subtitle && (
<div style={{ fontSize: mobile ? '0.65rem' : '0.72rem', color: 'var(--color-text-muted)' }}>
{subtitle}
</div>
)}
</div>
);
// AdminDashboard Component
export const AdminDashboard = () => {
// State & Hooks
const [period, setPeriod] = useState('7d');
const mobile = useIsMobile();
const { stats, statusDist, dailyTrend, driverStats, economics, isLoading, error, refetch } = useAdminStats(period);
const { stats: pickupStats, isLoading: pickupLoading } = usePickupStats(period);
// Responsive Layout Values (must be before early returns)
const chartHeight = mobile ? 200 : 240;
const kpiMin = mobile ? '80px' : '110px';
const chartGridCols = mobile ? '1fr' : '1fr 2fr';
const driverLabelWidth = mobile ? 80 : 120;
const fontSize = mobile ? { xs: '0.6rem', s: '0.7rem', m: '0.78rem', l: '0.85rem', xl: '1rem' }
: { xs: '0.65rem', s: '0.68rem', m: '0.78rem', l: '0.85rem', xl: '1.1rem' };
const periodLabel = PERIOD_LABELS[period] || '';
const chartHeight = mobile ? 200 : 280;
const fontSize = mobile ? { xs: '0.6rem', s: '0.7rem', m: '0.78rem', l: '0.85rem' }
: { xs: '0.72rem', s: '0.78rem', m: '0.85rem', l: '0.95rem' };
// Loading / Error States
// Loading State
if (isLoading) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: mobile ? '0.75rem' : '1.25rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: mobile ? '0.75rem' : '1.5rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem' }}>
<Skeleton variant="heading" className="w-1/4" />
<Skeleton className="w-32 h-8" />
</div>
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr' : `repeat(auto-fit, minmax(80px, 160px))`, gap: '0.4rem' }}>
{Array.from({ length: 6 }).map((_, i) => (
<Panel key={i} style={{ padding: mobile ? '0.4rem 0.6rem' : '0.5rem 0.75rem', textAlign: 'center' }}>
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr' : 'repeat(auto-fit, minmax(120px, 1fr))', gap: '0.5rem' }}>
{Array.from({ length: 7 }).map((_, i) => (
<Panel key={i} style={{ padding: '0.75rem', textAlign: 'center' }}>
<Skeleton className="w-12 h-3 mb-1" />
<Skeleton className="w-8 h-5" />
<Skeleton className="w-8 h-6" />
</Panel>
))}
</div>
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
<Skeleton variant="heading" className="w-1/3 mb-3" />
<div style={{ height: chartHeight }} className="flex items-center justify-center">
<Skeleton className="w-3/4 h-40" />
</div>
</Panel>
</div>
);
}
@ -145,70 +175,83 @@ export const AdminDashboard = () => {
status: s.delivery_status,
})).filter(d => d.value > 0);
// Trend & Driver Data
const trendData = (dailyTrend || []).map(d => ({
date: d.date ? new Date(d.date).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }) : '',
delivered: d.delivered || 0, total: d.total || 0, problems: d.problems || 0,
delivered: d.delivered || 0, picked_up: d.picked_up || 0, total: d.total || 0, problems: d.problems || 0,
}));
const driverData = (driverStats || []).map(d => ({
name: d.driver_name || 'Неизвестный',
total: d.total || 0, delivered: d.delivered || 0, problems: d.problems || 0,
total: d.total || 0, delivered: d.delivered || 0, picked_up: d.picked_up || 0, problems: d.problems || 0,
}));
// Funnel: ALWAYS show all steps, even with 0 values
// Funnel Data
// Funnel: Real order path
const completedTotal = (econ.full_chain_client || 0) + (econ.client_date_no_driver || 0)
+ (econ.manager_date_completed || 0) + (econ.bypassed_completed || 0);
const funnelSteps = [
{ label: 'Согласовано после 1-й SMS', value: econ.confirmed_after_sms1 || 0, color: '#22c55e' },
{ label: 'Согласовано после 2-й SMS', value: econ.confirmed_after_sms2 || 0, color: '#14b8a6' },
{ label: 'Согласовано вручную', value: econ.confirmed_via_manual || 0, color: '#eab308' },
{ label: 'Ручное назначение даты', value: econ.manual_date_set_count || 0, color: '#f97316' },
{ label: 'Платное хранение', value: econ.paid_storage_count || 0, color: '#06b6d4' },
{ label: 'Отмена', value: econ.cancelled_count || 0, color: '#ef4444' },
{ label: 'Всего заказов', value: econ.total_groups || 0, color: '#94a3b8' },
{ label: 'SMS отправлено', value: econ.sms_sent || 0, color: '#3b82f6' },
{ label: 'Полная цепочка', value: econ.full_chain_client || 0, color: '#22c55e' },
{ label: 'Клиент выбрал дату', value: econ.client_date_no_driver || 0, color: '#14b8a6' },
{ label: 'Менеджер назначил дату', value: econ.manager_date_completed || 0, color: '#8b5cf6' },
{ label: 'В обход (без даты)', value: econ.bypassed_completed || 0, color: '#f97316' },
{ label: 'Застряло в ручном', value: econ.stuck_in_manual || 0, color: '#eab308' },
{ label: 'В работе', value: econ.in_progress || 0, color: '#3b82f6' },
{ label: 'Отменено', value: econ.cancelled_count || 0, color: '#64748b' },
];
// Render
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: mobile ? '0.75rem' : '1.25rem' }}>
// Grid Layout
// Desktop: 12-col grid. Mobile: single column.
const gridCols = mobile ? '1fr' : 'repeat(12, 1fr)';
const colSpan = (n) => mobile ? '1 / -1' : `span ${n}`;
{/* Period selector */}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: mobile ? '0.75rem' : '1.5rem' }}>
{/* ── Header + Period selector ─────────────────────────────────────────── */}
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem' }}>
<div>
<h2 style={{ fontSize: mobile ? '1rem' : '1.1rem', fontWeight: 600, color: 'var(--color-text)', marginBottom: '0.15rem' }}>Аналитика</h2>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>Статистика по доставкам</p>
<h2 style={{ fontSize: mobile ? '1.1rem' : '1.4rem', fontWeight: 800, color: 'var(--color-text)', marginBottom: '0.1rem' }}>
Аналитика
</h2>
<p style={{ fontSize: mobile ? '0.72rem' : '0.82rem', color: 'var(--color-text-muted)' }}>
Статистика по доставкам {periodLabel}
</p>
</div>
<SegmentedTabs items={PERIOD_OPTIONS} activeKey={period} onChange={setPeriod} />
</div>
{/* KPI — centered on mobile */}
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr' : `repeat(auto-fit, minmax(${kpiMin}, 160px))`, gap: '0.4rem' }}>
{[
{ label: 'Всего', val: totalGroups },
{ label: 'Ожидает', val: sv.pending },
{ label: 'В работе', val: sv.in_progress },
{ label: 'Доставлено', val: sv.delivered },
{ label: 'Проблемы', val: sv.problem },
{ label: '% доставки', val: sv.delivery_rate != null ? sv.delivery_rate + '%' : '—' },
].map((kpi, i) => (
<Panel key={i} style={{ padding: mobile ? '0.4rem 0.6rem' : '0.5rem 0.75rem', textAlign: 'center' }}>
<div style={{ fontSize: fontSize.xs, color: 'var(--color-text-muted)', marginBottom: '0.05rem' }}>{kpi.label}</div>
<div style={{ fontSize: mobile ? '1.1rem' : '1.3rem', fontWeight: 700, color: 'var(--color-text)', textAlign: 'center' }}>{kpi.val ?? '—'}</div>
</Panel>
))}
{/* ── KPI Cards ─────────────────────────────────────────────────────────── */}
<div style={{
display: 'grid',
gridTemplateColumns: mobile ? '1fr 1fr' : 'repeat(auto-fit, minmax(130px, 1fr))',
gap: mobile ? '0.4rem' : '0.75rem',
}}>
<KpiCard label="Всего" value={totalGroups} mobile={mobile} />
<KpiCard label="Ожидает" value={sv.pending} color="#94a3b8" mobile={mobile} />
<KpiCard label="В работе" value={sv.in_progress} color="#3b82f6" mobile={mobile} />
<KpiCard label="Доставлено" value={sv.delivered} color="#22c55e" mobile={mobile} />
<KpiCard label="Вывезено" value={sv.picked_up} color="#14b8a6" mobile={mobile} />
<KpiCard label="Самовывоз" value={sv.picked_up_pickup} color="#f59e0b" mobile={mobile} />
<KpiCard label="Проблемы" value={sv.problem} color="#ef4444" mobile={mobile} />
<KpiCard label="% доставки" value={sv.delivery_rate != null ? sv.delivery_rate + '%' : '—'} color="var(--color-text)" mobile={mobile} />
</div>
{/* Pie + Line — stacked on mobile, side-by-side on desktop */}
<div style={{ display: 'grid', gridTemplateColumns: chartGridCols, gap: mobile ? '0.5rem' : '1rem' }}>
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>По статусам</h3>
{/* ── Main Grid: Charts + Tables ───────────────────────────────────────── */}
<div style={{ display: 'grid', gridTemplateColumns: gridCols, gap: mobile ? '0.75rem' : '1.5rem' }}>
{/* Status Pie — 4 cols desktop */}
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem', gridColumn: colSpan(4) }}>
<SectionHeader title="По статусам" subtitle={periodLabel} mobile={mobile} />
{statusPieData.length === 0 ? (
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1.5rem' }}>Нет данных</div>
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '2rem' }}>Нет данных</div>
) : (
<ResponsiveContainer width="100%" height={chartHeight}>
<PieChart>
<Pie data={statusPieData} cx="50%" cy="50%"
innerRadius={mobile ? 30 : 40}
outerRadius={mobile ? 60 : 80}
innerRadius={mobile ? 30 : 50}
outerRadius={mobile ? 60 : 90}
dataKey="value" nameKey="name" paddingAngle={2}>
{statusPieData.map(entry => (
<Cell key={entry.status} fill={STATUS_COLORS[entry.status] || '#6b7280'} />
@ -221,155 +264,160 @@ export const AdminDashboard = () => {
)}
</Panel>
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>Тренд по дням</h3>
{/* Daily Trend — 8 cols desktop */}
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem', gridColumn: colSpan(8) }}>
<SectionHeader title="Тренд по дням" subtitle={periodLabel} mobile={mobile} />
{trendData.length === 0 ? (
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1.5rem' }}>Нет данных</div>
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '2rem' }}>Нет данных</div>
) : (
<ResponsiveContainer width="100%" height={chartHeight}>
<LineChart data={trendData}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border, #334155)" />
<XAxis dataKey="date" tick={{ fontSize: mobile ? 9 : 10, fill: 'var(--color-text-muted)' }} />
<YAxis tick={{ fontSize: mobile ? 9 : 10, fill: 'var(--color-text-muted)' }} width={mobile ? 25 : 35} />
<XAxis dataKey="date" tick={{ fontSize: mobile ? 9 : 11, fill: 'var(--color-text-muted)' }} />
<YAxis tick={{ fontSize: mobile ? 9 : 11, fill: 'var(--color-text-muted)' }} width={mobile ? 25 : 40} />
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: fontSize.xs }} />
<Line type="monotone" dataKey="total" name="Всего" stroke="#94a3b8" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="delivered" name="Доставлено" stroke="#22c55e" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="picked_up" name="Вывезено" stroke="#14b8a6" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="problems" name="Проблемы" stroke="#ef4444" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
)}
</Panel>
</div>
{/* Status table */}
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>Все статусы</h3>
{statusPieData.length === 0 ? (
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1rem' }}>Нет данных</div>
) : (
<div>
<div style={{
display: 'grid', gridTemplateColumns: mobile ? '8px 1fr 50px 40px' : '10px 1fr 70px 55px',
gap: '0 0.4rem', padding: '0.3rem 0.3rem', alignItems: 'center',
borderBottom: '1px solid var(--color-border)', fontSize: fontSize.xs,
color: 'var(--color-text-muted)', fontWeight: 600,
}}>
<div /><div>Статус</div><div style={{ textAlign: 'right' }}>Кол-во</div><div style={{ textAlign: 'right' }}>Доля</div>
</div>
{statusPieData.map(s => {
const pct = totalGroups > 0 ? ((s.value / totalGroups) * 100).toFixed(1) : 0;
return (
<div key={s.status} style={{
display: 'grid', gridTemplateColumns: mobile ? '8px 1fr 50px 40px' : '10px 1fr 70px 55px',
gap: '0 0.4rem', padding: '0.4rem 0.3rem', alignItems: 'center',
borderBottom: '1px solid var(--color-border, rgba(51,65,85,0.4))',
}}>
<div style={{ width: mobile ? '8px' : '10px', height: mobile ? '8px' : '10px', borderRadius: '3px', background: STATUS_COLORS[s.status] || '#6b7280' }} />
<div style={{ fontSize: fontSize.m, color: 'var(--color-text)' }}>{s.name}</div>
<div style={{ textAlign: 'right', fontSize: fontSize.m, fontWeight: 600, color: 'var(--color-text)' }}>{s.value}</div>
<div style={{ textAlign: 'right', fontSize: fontSize.s, color: 'var(--color-text-muted)' }}>{pct}%</div>
</div>
);
})}
</div>
)}
</Panel>
{/* Воронка согласования — ALL steps always visible */}
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.5rem', color: 'var(--color-text)' }}>Воронка согласования</h3>
{totalGroups === 0 ? (
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1rem' }}>Нет данных</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0', padding: '0.4rem 0' }}>
{funnelSteps.map((step, i) => {
const pct = totalGroups > 0 ? Math.round((step.value / totalGroups) * 100) : 0;
const widthPct = step.value > 0 ? Math.max(15, (step.value / totalGroups) * 100) : 15;
return (
<div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1px', width: '100%' }}>
<div style={{ fontSize: mobile ? '0.8rem' : '0.85rem', fontWeight: 700, color: 'var(--color-text)', textAlign: 'center' }}>
{step.value}
</div>
<div style={{
width: widthPct + '%', height: mobile ? '28px' : '32px', background: step.value > 0 ? step.color : 'var(--color-border, #334155)',
borderRadius: '4px', display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'width 0.4s ease', minWidth: '40px', maxWidth: '100%',
opacity: step.value > 0 ? 1 : 0.5,
{/* Status Table — 4 cols desktop */}
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem', gridColumn: colSpan(4) }}>
<SectionHeader title="Все статусы" subtitle={periodLabel} mobile={mobile} />
{statusPieData.length === 0 ? (
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1rem' }}>Нет данных</div>
) : (
<div>
<div style={{
display: 'grid', gridTemplateColumns: mobile ? '8px 1fr 45px 40px' : '12px 1fr 60px 55px',
gap: '0 0.5rem', padding: '0.4rem 0.3rem', alignItems: 'center',
borderBottom: '1px solid var(--color-border)', fontSize: fontSize.xs,
color: 'var(--color-text-muted)', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.03em',
}}>
<div /><div>Статус</div><div style={{ textAlign: 'right' }}>Кол-во</div><div style={{ textAlign: 'right' }}>Доля</div>
</div>
{statusPieData.map(s => {
const pct = totalGroups > 0 ? ((s.value / totalGroups) * 100).toFixed(1) : 0;
return (
<div key={s.status} style={{
display: 'grid', gridTemplateColumns: mobile ? '8px 1fr 45px 40px' : '12px 1fr 60px 55px',
gap: '0 0.5rem', padding: '0.5rem 0.3rem', alignItems: 'center',
borderBottom: '1px solid var(--color-border, rgba(51,65,85,0.4))',
}}>
<span style={{ fontSize: mobile ? '0.6rem' : '0.7rem', fontWeight: 600, color: step.value > 0 ? '#fff' : 'var(--color-text-muted)', textShadow: step.value > 0 ? '0 1px 2px rgba(0,0,0,0.3)' : 'none' }}>
{pct}%
</span>
<div style={{ width: mobile ? '8px' : '12px', height: mobile ? '8px' : '12px', borderRadius: '3px', background: STATUS_COLORS[s.status] || '#6b7280' }} />
<div style={{ fontSize: fontSize.m, color: 'var(--color-text)' }}>{s.name}</div>
<div style={{ textAlign: 'right', fontSize: fontSize.m, fontWeight: 700, color: 'var(--color-text)' }}>{s.value}</div>
<div style={{ textAlign: 'right', fontSize: fontSize.s, color: 'var(--color-text-muted)' }}>{pct}%</div>
</div>
<div style={{ fontSize: mobile ? '0.65rem' : '0.72rem', color: 'var(--color-text-muted)', textAlign: 'center', maxWidth: mobile ? '180px' : '220px' }}>
{step.label}
);
})}
</div>
)}
</Panel>
{/* Funnel — 4 cols desktop */}
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem', gridColumn: colSpan(4) }}>
<SectionHeader title="Путь заказа" subtitle={periodLabel} mobile={mobile} />
{totalGroups === 0 ? (
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1rem' }}>Нет данных</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0', padding: '0.4rem 0' }}>
{funnelSteps.map((step, i) => {
const pct = totalGroups > 0 ? Math.round((step.value / totalGroups) * 100) : 0;
const widthPct = step.value > 0 ? Math.max(15, (step.value / totalGroups) * 100) : 15;
return (
<div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1px', width: '100%' }}>
<div style={{ fontSize: mobile ? '0.8rem' : '0.9rem', fontWeight: 700, color: 'var(--color-text)', textAlign: 'center' }}>
{step.value}
</div>
<div style={{
width: widthPct + '%', height: mobile ? '28px' : '34px', background: step.value > 0 ? step.color : 'var(--color-border, #334155)',
borderRadius: '4px', display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'width 0.4s ease', minWidth: '40px', maxWidth: '100%',
opacity: step.value > 0 ? 1 : 0.5,
}}>
<span style={{ fontSize: mobile ? '0.6rem' : '0.72rem', fontWeight: 600, color: step.value > 0 ? '#fff' : 'var(--color-text-muted)', textShadow: step.value > 0 ? '0 1px 2px rgba(0,0,0,0.3)' : 'none' }}>
{pct}%
</span>
</div>
<div style={{ fontSize: mobile ? '0.65rem' : '0.75rem', color: 'var(--color-text-muted)', textAlign: 'center', maxWidth: mobile ? '180px' : '240px' }}>
{step.label}
</div>
{i < funnelSteps.length - 1 && (
<div style={{ width: '2px', height: '4px', background: 'var(--color-border)' }} />
)}
</div>
{i < funnelSteps.length - 1 && (
<div style={{ width: '2px', height: '4px', background: 'var(--color-border)' }} />
)}
);
})}
<div style={{
display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr' : '1fr 1fr 1fr', gap: '0.5rem',
marginTop: '0.75rem', paddingTop: '0.6rem', borderTop: '1px solid var(--color-border)',
}}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: fontSize.xs, color: '#22c55e', marginBottom: '1px' }}>Полная цепочка</div>
<div style={{ fontSize: mobile ? '0.95rem' : '1.1rem', fontWeight: 700, color: '#22c55e' }}>{econ.full_chain_pct ?? 0}%</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: fontSize.xs, color: '#f97316', marginBottom: '1px' }}>В обход</div>
<div style={{ fontSize: mobile ? '0.95rem' : '1.1rem', fontWeight: 700, color: '#f97316' }}>{econ.bypassed_pct ?? 0}%</div>
</div>
<div style={{ textAlign: 'center', display: mobile ? 'none' : 'block' }}>
<div style={{ fontSize: fontSize.xs, color: 'var(--color-text-muted)', marginBottom: '1px' }}>Завершено</div>
<div style={{ fontSize: mobile ? '0.95rem' : '1.1rem', fontWeight: 700, color: 'var(--color-text)' }}>{completedTotal}</div>
</div>
);
})}
<div style={{
display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr' : '1fr 1fr 1fr', gap: '0.5rem',
marginTop: '0.75rem', paddingTop: '0.6rem', borderTop: '1px solid var(--color-border)',
}}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: fontSize.xs, color: '#22c55e', marginBottom: '1px' }}>Автосогласование</div>
<div style={{ fontSize: mobile ? '0.95rem' : '1.05rem', fontWeight: 700, color: '#22c55e' }}>{econ.auto_confirm_pct ?? 0}%</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: fontSize.xs, color: '#ef4444', marginBottom: '1px' }}>Ручное вмешательство</div>
<div style={{ fontSize: mobile ? '0.95rem' : '1.05rem', fontWeight: 700, color: '#ef4444' }}>{econ.manual_intervention_pct ?? 0}%</div>
</div>
<div style={{ textAlign: 'center', display: mobile ? 'none' : 'block' }}>
<div style={{ fontSize: fontSize.xs, color: 'var(--color-text-muted)', marginBottom: '1px' }}>Всего согласовано</div>
<div style={{ fontSize: mobile ? '0.95rem' : '1.05rem', fontWeight: 700, color: 'var(--color-text)' }}>{econ.confirmed_auto_total ?? 0}</div>
</div>
</div>
)}
</Panel>
{/* SMS + Drivers side by side — 4 cols each on desktop */}
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem', gridColumn: colSpan(4) }}>
<SectionHeader title="SMS" subtitle={periodLabel} mobile={mobile} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.5rem' }}>
{[
{ label: 'SMS 1', val: econ.sms1_sent_count ?? 0 },
{ label: 'SMS 2', val: econ.sms2_sent_count ?? 0 },
{ label: 'Всего', val: (econ.sms1_sent_count || 0) + (econ.sms2_sent_count || 0) },
].map((item, i) => (
<div key={i} style={{ textAlign: 'center' }}>
<div style={{ fontSize: fontSize.xs, color: 'var(--color-text-muted)', marginBottom: '2px', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{item.label}</div>
<div style={{ fontSize: mobile ? '1.1rem' : '1.3rem', fontWeight: 800, color: 'var(--color-text)' }}>{item.val}</div>
</div>
))}
</div>
)}
</Panel>
</Panel>
{/* SMS */}
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>SMS</h3>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.5rem' }}>
{[
{ label: 'SMS 1', val: econ.sms1_sent_count ?? 0 },
{ label: 'SMS 2', val: econ.sms2_sent_count ?? 0 },
{ label: 'Всего', val: (econ.sms1_sent_count || 0) + (econ.sms2_sent_count || 0) },
].map((item, i) => (
<div key={i} style={{ textAlign: 'center' }}>
<div style={{ fontSize: fontSize.xs, color: 'var(--color-text-muted)', marginBottom: '1px' }}>{item.label}</div>
<div style={{ fontSize: mobile ? '1rem' : '1.1rem', fontWeight: 700, color: 'var(--color-text)' }}>{item.val}</div>
</div>
))}
{/* Drivers — 8 cols desktop */}
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem', gridColumn: colSpan(8) }}>
<SectionHeader title="По водителям" subtitle={periodLabel} mobile={mobile} />
{driverData.length === 0 ? (
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '2rem' }}>Нет данных</div>
) : (
<ResponsiveContainer width="100%" height={Math.max(180, driverData.length * (mobile ? 35 : 50))}>
<BarChart data={driverData} layout="vertical" margin={{ left: mobile ? 5 : 20, right: mobile ? 5 : 30 }}>
<XAxis type="number" tick={{ fontSize: mobile ? 9 : 11, fill: 'var(--color-text-muted)' }} />
<YAxis type="category" dataKey="name" tick={{ fontSize: mobile ? 9 : 11, fill: 'var(--color-text-muted)' }} width={mobile ? 80 : 130} />
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: fontSize.xs }} />
<Bar dataKey="delivered" name="Доставлено" fill="#22c55e" stackId="a" radius={[0, 0, 0, 0]} />
<Bar dataKey="picked_up" name="Вывезено" fill="#14b8a6" stackId="a" radius={[0, 0, 0, 0]} />
<Bar dataKey="problems" name="Проблемы" fill="#ef4444" stackId="a" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</Panel>
{/* Pickup Stats — full width (12 cols) */}
<div style={{ gridColumn: colSpan(12) }}>
<PickupStatsPanel stats={pickupStats} isLoading={pickupLoading} mobile={mobile} fontSize={fontSize} periodLabel={periodLabel} />
</div>
</Panel>
{/* Pickup Stats */}
<PickupStatsPanel stats={pickupStats} isLoading={pickupLoading} mobile={mobile} fontSize={fontSize} />
{/* Drivers */}
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>По водителям</h3>
{driverData.length === 0 ? (
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1.5rem' }}>Нет данных</div>
) : (
<ResponsiveContainer width="100%" height={Math.max(150, driverData.length * (mobile ? 35 : 45))}>
<BarChart data={driverData} layout="vertical" margin={{ left: mobile ? 5 : 20, right: mobile ? 5 : 20 }}>
<XAxis type="number" tick={{ fontSize: mobile ? 9 : 10, fill: 'var(--color-text-muted)' }} />
<YAxis type="category" dataKey="name" tick={{ fontSize: mobile ? 9 : 10, fill: 'var(--color-text-muted)' }} width={driverLabelWidth} />
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: fontSize.xs }} />
<Bar dataKey="delivered" name="Доставлено" fill="#22c55e" stackId="a" />
<Bar dataKey="problems" name="Проблемы" fill="#ef4444" stackId="a" />
</BarChart>
</ResponsiveContainer>
)}
</Panel>
</div>
</div>
);
};

View File

@ -0,0 +1,255 @@
/**
* @file BusinessSchedulePanel.jsx
* @description Admin panel for configuring work days: delivery, pickup, SMS.
* Days stored in business_schedule_settings table as INT[] (1=Mon ... 7=Sun).
*/
import React, { useState, useEffect, useCallback } from "react";
import { Panel } from "../UI/Panel";
import { Button } from "../UI/Button";
import { Badge } from "../UI/Badge";
import { supabase } from "../../supabaseClient";
const DAY_LABELS = [
{ num: 1, label: "Пн", full: "Понедельник" },
{ num: 2, label: "Вт", full: "Вторник" },
{ num: 3, label: "Ср", full: "Среда" },
{ num: 4, label: "Чт", full: "Четверг" },
{ num: 5, label: "Пт", full: "Пятница" },
{ num: 6, label: "Сб", full: "Суббота" },
{ num: 7, label: "Вс", full: "Воскресенье" },
];
const DEFAULT_SCHEDULE = {
deliveryDays: [1, 2, 3, 4, 5],
pickupDays: [1, 2, 3, 4, 5],
smsDays: [1, 2, 3, 4, 5],
};
const DayPills = ({ selectedDays, onToggle, accentColor }) => {
const toggle = (num) => {
if (selectedDays.includes(num)) {
onToggle(selectedDays.filter((d) => d !== num).sort());
} else {
onToggle([...selectedDays, num].sort());
}
};
return (
<div className="flex flex-wrap gap-2">
{DAY_LABELS.map((day) => {
const isSelected = selectedDays.includes(day.num);
return (
<button
key={day.num}
type="button"
onClick={() => toggle(day.num)}
aria-pressed={isSelected}
title={day.full}
className={[
"min-w-[48px] rounded-[14px] border-2 px-3 py-2.5 text-center text-sm font-semibold transition select-none",
isSelected
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
: "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(" ")}
>
{day.label}
</button>
);
})}
</div>
);
};
export const BusinessSchedulePanel = () => {
const [schedule, setSchedule] = useState(DEFAULT_SCHEDULE);
const [original, setOriginal] = useState(DEFAULT_SCHEDULE);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState("");
const [savedAt, setSavedAt] = useState(null);
const loadSchedule = useCallback(async () => {
setIsLoading(true);
setError("");
try {
const { data, error: rpcError } = await supabase.rpc("get_business_schedule");
if (rpcError) throw rpcError;
if (data?.ok) {
const loaded = {
deliveryDays: data.deliveryDays || DEFAULT_SCHEDULE.deliveryDays,
pickupDays: data.pickupDays || DEFAULT_SCHEDULE.pickupDays,
smsDays: data.smsDays || DEFAULT_SCHEDULE.smsDays,
};
setSchedule(loaded);
setOriginal(loaded);
}
} catch (e) {
setError(e instanceof Error ? e.message : "Не удалось загрузить расписание");
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
loadSchedule();
}, [loadSchedule]);
const hasChanges =
JSON.stringify(schedule.deliveryDays) !== JSON.stringify(original.deliveryDays) ||
JSON.stringify(schedule.pickupDays) !== JSON.stringify(original.pickupDays) ||
JSON.stringify(schedule.smsDays) !== JSON.stringify(original.smsDays);
const handleSave = async () => {
if (!hasChanges) return;
// Validate at least one day per category
if (schedule.deliveryDays.length === 0 || schedule.pickupDays.length === 0 || schedule.smsDays.length === 0) {
setError("Каждая категория должна иметь хотя бы один рабочий день");
return;
}
setIsSaving(true);
setError("");
try {
const { data, error: rpcError } = await supabase.rpc("update_business_schedule", {
p_delivery_days: schedule.deliveryDays,
p_pickup_days: schedule.pickupDays,
p_sms_days: schedule.smsDays,
});
if (rpcError) throw rpcError;
if (data?.ok) {
setOriginal({ ...schedule });
setSavedAt(new Date());
}
} catch (e) {
setError(e instanceof Error ? e.message : "Не удалось сохранить расписание");
} finally {
setIsSaving(false);
}
};
const formatDays = (days) => {
return days
.sort((a, b) => a - b)
.map((d) => DAY_LABELS.find((dl) => dl.num === d)?.label)
.filter(Boolean)
.join(", ");
};
if (isLoading) {
return (
<Panel className="p-5 sm:p-6">
<p className="text-sm text-[var(--color-text-muted)]">Загрузка расписания</p>
</Panel>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between gap-4">
<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">Рабочие дни</h1>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
Настройте, в какие дни доступны доставка, самовывоз и отправка SMS-приглашений.
</p>
</div>
{savedAt && !hasChanges && (
<Badge tone="accent">Сохранено</Badge>
)}
</div>
{/* Delivery days */}
<Panel className="p-5 sm:p-6 space-y-4">
<div className="flex items-center gap-3">
<span className="text-xl">🚚</span>
<div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Доставка
</h2>
<p className="text-xs text-[var(--color-text-muted)]">
Дни, в которые клиент может выбрать доставку. В остальные дни доставка не предлагается.
</p>
</div>
</div>
<DayPills
selectedDays={schedule.deliveryDays}
onToggle={(days) => setSchedule((s) => ({ ...s, deliveryDays: days }))}
/>
<p className="text-xs text-[var(--color-text-muted)]">
Выбрано: {formatDays(schedule.deliveryDays) || "—"}
</p>
</Panel>
{/* Pickup days */}
<Panel className="p-5 sm:p-6 space-y-4">
<div className="flex items-center gap-3">
<span className="text-xl">🏪</span>
<div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Самовывоз
</h2>
<p className="text-xs text-[var(--color-text-muted)]">
Дни, в которые клиент может выбрать самовывоз. В остальные дни самовывоз не предлагается.
</p>
</div>
</div>
<DayPills
selectedDays={schedule.pickupDays}
onToggle={(days) => setSchedule((s) => ({ ...s, pickupDays: days }))}
/>
<p className="text-xs text-[var(--color-text-muted)]">
Выбрано: {formatDays(schedule.pickupDays) || "—"}
</p>
</Panel>
{/* SMS days */}
<Panel className="p-5 sm:p-6 space-y-4">
<div className="flex items-center gap-3">
<span className="text-xl">📨</span>
<div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
SMS-приглашения
</h2>
<p className="text-xs text-[var(--color-text-muted)]">
Дни, в которые отправляются SMS-приглашения клиентам. В нерабочие дни SMS не отправляются (проверка статусов работает круглосуточно).
</p>
</div>
</div>
<DayPills
selectedDays={schedule.smsDays}
onToggle={(days) => setSchedule((s) => ({ ...s, smsDays: days }))}
/>
<p className="text-xs text-[var(--color-text-muted)]">
Выбрано: {formatDays(schedule.smsDays) || "—"}
</p>
</Panel>
{/* Save */}
<div className="flex items-center gap-4">
<Button
variant={hasChanges ? "primary" : "ghost"}
onClick={handleSave}
disabled={!hasChanges || isSaving}
>
{isSaving ? "Сохранение…" : "Сохранить расписание"}
</Button>
{hasChanges && !isSaving && (
<Button
variant="ghost"
onClick={() => setSchedule({ ...original })}
>
Отменить изменения
</Button>
)}
</div>
{error && (
<Panel className="border border-[var(--color-danger)] bg-[var(--color-surface)] p-4 text-sm text-[var(--color-danger)]">
{error}
</Panel>
)}
</div>
);
};

View File

@ -11,6 +11,9 @@ const PICKUP_COLORS = {
saturday: '#06b6d4',
pickup: '#f59e0b',
delivery: '#6366f1',
picked_up: '#14b8a6',
pending: '#94a3b8',
manual: '#eab308',
};
const CustomTooltip = ({ active, payload }) => {
@ -34,17 +37,30 @@ const CustomTooltip = ({ active, payload }) => {
const ProgressBar = ({ label, value, max, color, fontSize, mobile }) => {
const pct = max > 0 ? Math.max(0, (value / max) * 100) : 0;
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<div style={{ flex: '0 0 auto', width: mobile ? '70px' : '100px', fontSize: fontSize.xs, color: 'var(--color-text-muted)', textAlign: 'right' }}>{label}</div>
<div style={{ flex: '1 1 auto', height: mobile ? '16px' : '20px', background: 'var(--color-border, rgba(51,65,85,0.4))', borderRadius: '4px', overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div style={{ flex: '0 0 auto', width: mobile ? '70px' : '110px', fontSize: fontSize.xs, color: 'var(--color-text-muted)', textAlign: 'right' }}>{label}</div>
<div style={{ flex: '1 1 auto', height: mobile ? '16px' : '22px', background: 'var(--color-border, rgba(51,65,85,0.4))', borderRadius: '4px', overflow: 'hidden' }}>
<div style={{ width: pct + '%', height: '100%', background: color, borderRadius: '4px', transition: 'width 0.4s ease', minWidth: pct > 0 ? '4px' : '0' }} />
</div>
<div style={{ flex: '0 0 auto', width: mobile ? '36px' : '45px', fontSize: fontSize.s, fontWeight: 600, color: 'var(--color-text)', textAlign: 'left' }}>{value}</div>
<div style={{ flex: '0 0 auto', width: mobile ? '36px' : '50px', fontSize: fontSize.s, fontWeight: 700, color: 'var(--color-text)', textAlign: 'left' }}>{value}</div>
</div>
);
};
export const PickupStatsPanel = ({ stats, isLoading, mobile, fontSize }) => {
const SectionHeader = ({ title, subtitle, mobile }) => (
<div style={{ marginBottom: '0.6rem' }}>
<h3 style={{ fontSize: mobile ? '0.9rem' : '1rem', fontWeight: 700, color: 'var(--color-text)', marginBottom: subtitle ? '0.1rem' : 0 }}>
{title}
</h3>
{subtitle && (
<div style={{ fontSize: mobile ? '0.65rem' : '0.72rem', color: 'var(--color-text-muted)' }}>
{subtitle}
</div>
)}
</div>
);
export const PickupStatsPanel = ({ stats, isLoading, mobile, fontSize, periodLabel = '' }) => {
if (isLoading) {
return (
<Panel>
@ -65,103 +81,137 @@ export const PickupStatsPanel = ({ stats, isLoading, mobile, fontSize }) => {
);
}
const fs = fontSize || { xs: '0.65rem', s: '0.68rem', m: '0.78rem', l: '0.85rem', xl: '1.1rem' };
const fs = fontSize || { xs: '0.72rem', s: '0.78rem', m: '0.85rem', l: '0.95rem' };
const totalPickups = Number(stats.total_pickups) || 0;
const pickedUp = Number(stats.picked_up) || 0;
const pending = Number(stats.pending) || 0;
const manual = Number(stats.manual) || 0;
const pickupRate = Number(stats.pickup_rate) || 0;
const avgDays = stats.avg_days_until_pickup != null ? Number(stats.avg_days_until_pickup) : null;
const dist = stats.delivery_type_dist || {};
const maxDay = Math.max(
Number(stats.pickup_today) || 0,
Number(stats.pickup_tomorrow) || 0,
Number(stats.pickup_day_after) || 0,
1
);
const pickupToday = Number(stats.pickup_today) || 0;
const pickupTomorrow = Number(stats.pickup_tomorrow) || 0;
const pickupDayAfter = Number(stats.pickup_day_after) || 0;
const pickupFirstHalf = Number(stats.pickup_first_half) || 0;
const pickupSecondHalf = Number(stats.pickup_second_half) || 0;
const pickupOnSaturday = Number(stats.pickup_on_saturday) || 0;
const maxHalf = Math.max(
Number(stats.pickup_first_half) || 0,
Number(stats.pickup_second_half) || 0,
1
);
const hasScheduledPickups = pickupToday + pickupTomorrow + pickupDayAfter > 0;
const hasTimeSlots = pickupFirstHalf + pickupSecondHalf > 0;
const maxDay = Math.max(pickupToday, pickupTomorrow, pickupDayAfter, 1);
const maxHalf = Math.max(pickupFirstHalf, pickupSecondHalf, 1);
const pieData = [
{ name: 'Самовывоз', value: Number(dist.pickup) || 0, fill: PICKUP_COLORS.pickup },
{ name: 'Доставка', value: Number(dist.delivery) || 0, fill: PICKUP_COLORS.delivery },
].filter(d => d.value > 0);
// Desktop: 3-col grid inside panel. Mobile: stacked.
const innerCols = mobile ? '1fr' : '1fr 1fr 1fr';
return (
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
<h3 style={{ fontSize: fs.l, fontWeight: 600, marginBottom: '0.5rem', color: 'var(--color-text)' }}>
📦 Самовывоз
</h3>
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem' }}>
<SectionHeader title="📦 Самовывоз" subtitle={periodLabel} mobile={mobile} />
{/* KPI row */}
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr 1fr' : '1fr 1fr 1fr', gap: '0.5rem', marginBottom: '0.75rem' }}>
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr 1fr 1fr' : 'repeat(auto-fit, minmax(120px, 1fr))', gap: '0.5rem', marginBottom: '1rem' }}>
{[
{ label: 'Всего самовывоз', val: totalPickups, color: '#f59e0b' },
{ label: 'Доля самовывоза', val: pickupRate + '%', color: '#f59e0b' },
{ label: 'Ср. дней до выдачи', val: avgDays !== null ? avgDays : '—', color: '#3b82f6' },
{ label: 'Всего', val: totalPickups, color: '#f59e0b' },
{ label: 'Завершено', val: pickedUp, color: '#14b8a6' },
{ label: 'Ожидает', val: pending, color: '#94a3b8' },
{ label: 'Доля', val: pickupRate + '%', color: '#f59e0b' },
].map((kpi, i) => (
<div key={i} style={{ textAlign: 'center' }}>
<div style={{ fontSize: fs.xs, color: 'var(--color-text-muted)', marginBottom: '1px' }}>{kpi.label}</div>
<div style={{ fontSize: mobile ? '1rem' : '1.2rem', fontWeight: 700, color: kpi.color }}>{kpi.val}</div>
<div key={i} style={{ textAlign: 'center', padding: '0.4rem 0.2rem' }}>
<div style={{ fontSize: fs.xs, color: 'var(--color-text-muted)', marginBottom: '2px', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{kpi.label}</div>
<div style={{ fontSize: mobile ? '1.05rem' : '1.3rem', fontWeight: 800, color: kpi.color }}>{kpi.val}</div>
</div>
))}
</div>
{/* Distribution by day */}
<div style={{ marginBottom: '0.6rem' }}>
<div style={{ fontSize: fs.m, fontWeight: 600, color: 'var(--color-text)', marginBottom: '0.3rem' }}>По дням</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
<ProgressBar label="Сегодня" value={Number(stats.pickup_today) || 0} max={maxDay} color={PICKUP_COLORS.today} fontSize={fs} mobile={mobile} />
<ProgressBar label="Завтра" value={Number(stats.pickup_tomorrow) || 0} max={maxDay} color={PICKUP_COLORS.tomorrow} fontSize={fs} mobile={mobile} />
<ProgressBar label="Послезавтра" value={Number(stats.pickup_day_after) || 0} max={maxDay} color={PICKUP_COLORS.dayAfter} fontSize={fs} mobile={mobile} />
</div>
</div>
{/* Content grid: status breakdown | schedule | donut */}
<div style={{ display: 'grid', gridTemplateColumns: innerCols, gap: mobile ? '0.75rem' : '1.5rem' }}>
{/* Half-day split */}
<div style={{ marginBottom: '0.6rem' }}>
<div style={{ fontSize: fs.m, fontWeight: 600, color: 'var(--color-text)', marginBottom: '0.3rem' }}>По времени</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
<ProgressBar label="До обеда" value={Number(stats.pickup_first_half) || 0} max={maxHalf} color={PICKUP_COLORS.firstHalf} fontSize={fs} mobile={mobile} />
<ProgressBar label="После обеда" value={Number(stats.pickup_second_half) || 0} max={maxHalf} color={PICKUP_COLORS.secondHalf} fontSize={fs} mobile={mobile} />
</div>
</div>
{/* Saturday */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.3rem 0', borderTop: '1px solid var(--color-border)', marginBottom: '0.6rem' }}>
<span style={{ fontSize: fs.s, color: 'var(--color-text-muted)' }}>Самовывоз в субботу</span>
<span style={{ fontSize: fs.l, fontWeight: 700, color: PICKUP_COLORS.saturday }}>{Number(stats.pickup_on_saturday) || 0}</span>
</div>
{/* Delivery vs Pickup donut */}
{pieData.length > 0 && (
{/* Status breakdown */}
<div>
<div style={{ fontSize: fs.m, fontWeight: 600, color: 'var(--color-text)', marginBottom: '0.3rem' }}>Доставка vs Самовывоз</div>
<ResponsiveContainer width="100%" height={mobile ? 140 : 170}>
<PieChart>
<Pie data={pieData} cx="50%" cy="50%"
innerRadius={mobile ? 30 : 40}
outerRadius={mobile ? 55 : 70}
dataKey="value" nameKey="name" paddingAngle={2}
>
{pieData.map((entry, i) => (
<Cell key={i} fill={entry.fill} />
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
</PieChart>
</ResponsiveContainer>
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', marginTop: '0.2rem' }}>
{pieData.map((d, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: fs.xs }}>
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: d.fill }} />
<span style={{ color: 'var(--color-text-muted)' }}>{d.name}: <strong style={{ color: 'var(--color-text)' }}>{d.value}</strong></span>
</div>
))}
<div style={{ fontSize: fs.m, fontWeight: 700, color: 'var(--color-text)', marginBottom: '0.4rem' }}>По статусам</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
<ProgressBar label="Завершено" value={pickedUp} max={Math.max(totalPickups, 1)} color={PICKUP_COLORS.picked_up} fontSize={fs} mobile={mobile} />
<ProgressBar label="Ожидает" value={pending} max={Math.max(totalPickups, 1)} color={PICKUP_COLORS.pending} fontSize={fs} mobile={mobile} />
<ProgressBar label="Ручное" value={manual} max={Math.max(totalPickups, 1)} color={PICKUP_COLORS.manual} fontSize={fs} mobile={mobile} />
</div>
{avgDays !== null && avgDays > 0 && (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.4rem 0', marginTop: '0.3rem', borderTop: '1px solid var(--color-border)' }}>
<span style={{ fontSize: fs.s, color: 'var(--color-text-muted)' }}>Ср. дней до выдачи</span>
<span style={{ fontSize: fs.l, fontWeight: 700, color: '#3b82f6' }}>{avgDays}</span>
</div>
)}
</div>
{/* Schedule — only show if there are scheduled pickups */}
<div>
<div style={{ fontSize: fs.m, fontWeight: 700, color: 'var(--color-text)', marginBottom: '0.4rem' }}>Расписание</div>
{hasScheduledPickups ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem', marginBottom: '0.5rem' }}>
<ProgressBar label="Сегодня" value={pickupToday} max={maxDay} color={PICKUP_COLORS.today} fontSize={fs} mobile={mobile} />
<ProgressBar label="Завтра" value={pickupTomorrow} max={maxDay} color={PICKUP_COLORS.tomorrow} fontSize={fs} mobile={mobile} />
<ProgressBar label="Послезавтра" value={pickupDayAfter} max={maxDay} color={PICKUP_COLORS.dayAfter} fontSize={fs} mobile={mobile} />
</div>
) : (
<div style={{ fontSize: fs.s, color: 'var(--color-text-muted)', padding: '0.5rem 0', marginBottom: '0.5rem' }}>
Нет запланированных самовывозов
</div>
)}
{hasTimeSlots && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
<ProgressBar label="До обеда" value={pickupFirstHalf} max={maxHalf} color={PICKUP_COLORS.firstHalf} fontSize={fs} mobile={mobile} />
<ProgressBar label="После обеда" value={pickupSecondHalf} max={maxHalf} color={PICKUP_COLORS.secondHalf} fontSize={fs} mobile={mobile} />
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.4rem 0', marginTop: '0.3rem', borderTop: '1px solid var(--color-border)' }}>
<span style={{ fontSize: fs.s, color: 'var(--color-text-muted)' }}>Самовывоз в субботу</span>
<span style={{ fontSize: fs.l, fontWeight: 700, color: PICKUP_COLORS.saturday }}>{pickupOnSaturday}</span>
</div>
</div>
)}
{/* Delivery vs Pickup donut */}
<div>
<div style={{ fontSize: fs.m, fontWeight: 700, color: 'var(--color-text)', marginBottom: '0.4rem' }}>Доставка vs Самовывоз</div>
{pieData.length > 0 ? (
<>
<ResponsiveContainer width="100%" height={mobile ? 140 : 180}>
<PieChart>
<Pie data={pieData} cx="50%" cy="50%"
innerRadius={mobile ? 30 : 45}
outerRadius={mobile ? 55 : 75}
dataKey="value" nameKey="name" paddingAngle={2}
>
{pieData.map((entry, i) => (
<Cell key={i} fill={entry.fill} />
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
</PieChart>
</ResponsiveContainer>
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', marginTop: '0.3rem' }}>
{pieData.map((d, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: fs.xs }}>
<div style={{ width: '10px', height: '10px', borderRadius: '2px', background: d.fill }} />
<span style={{ color: 'var(--color-text-muted)' }}>{d.name}: <strong style={{ color: 'var(--color-text)' }}>{d.value}</strong></span>
</div>
))}
</div>
</>
) : (
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '2rem 0', fontSize: fs.s }}>
Нет данных
</div>
)}
</div>
</div>
</Panel>
);
};
};

View File

@ -9,6 +9,7 @@ import { Panel } from "../UI/Panel";
import { Badge } from "../UI/Badge";
import { supabase } from "../../supabaseClient";
import { SmsCampaignStats } from "./SmsCampaignStats";
import { SmsCityProfilesPanel } from "./SmsCityProfilesPanel";
// Campaigns
const CAMPAIGNS = [
@ -121,7 +122,11 @@ export const SmsCampaignPanel = () => {
const [checkingIds, setCheckingIds] = useState(new Set());
const [autoRefresh, setAutoRefresh] = useState(true);
const [togglingCampaign, setTogglingCampaign] = useState(null);
const [showCityRules, setShowCityRules] = useState(false);
const [logPage, setLogPage] = useState(1);
const [manualPage, setManualPage] = useState(1);
const refreshTimer = useRef(null);
const PER_PAGE = 20;
const handleOpenGroup = useCallback((groupId) => {
if (groupId) navigate("/dashboard/group/" + groupId);
@ -329,7 +334,7 @@ export const SmsCampaignPanel = () => {
)}
{/* Campaign cards */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4 max-w-4xl">
{CAMPAIGNS.map(c => {
const isActive = activeCampaign === c.key;
const s = allSettings[c.key];
@ -346,7 +351,7 @@ export const SmsCampaignPanel = () => {
? "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"); }}
onClick={() => { setActiveCampaign(c.key); setFilter("all"); setLogPage(1); setManualPage(1); }}
>
{/* Header: icon + toggle */}
<div className="flex items-center justify-between">
@ -405,6 +410,25 @@ export const SmsCampaignPanel = () => {
})}
</div>
{/* ── City rules button ──────────────────────────────────────────── */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setShowCityRules(!showCityRules)}
className={[
"rounded-2xl border px-4 py-2.5 text-sm font-medium transition flex items-center gap-2",
showCityRules
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-accent)]"
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
].join(" ")}
>
🏙 SMS по городам
</button>
</div>
{/* ── City profiles panel ────────────────────────────────────────── */}
{showCityRules && <SmsCityProfilesPanel />}
{error && (
<Panel className="p-3">
<div className="text-xs text-[var(--color-danger)]">{error}</div>
@ -413,10 +437,10 @@ export const SmsCampaignPanel = () => {
)}
{/* ── Statistics ────────────────────────────────────────────────────── */}
<SmsCampaignStats campaignType={activeCampaign} />
{!showCityRules && <SmsCampaignStats campaignType={activeCampaign} />}
{/* ── Settings ──────────────────────────────────────────────────────── */}
{showSettings && settings && (
{!showCityRules && 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)]">
@ -567,7 +591,7 @@ export const SmsCampaignPanel = () => {
)}
{/* ── Not implemented ──────────────────────────────────────────────── */}
{!showSettings && (
{!showCityRules && !showSettings && (
<Panel className="p-5">
<div className="text-sm text-[var(--color-text-muted)]">
{CAMPAIGNS.find(c => c.key === activeCampaign)?.label} в разработке
@ -597,7 +621,10 @@ export const SmsCampaignPanel = () => {
{manualGroups.length === 0 ? (
<div className="px-4 py-6 text-xs text-[var(--color-text-muted)]">Нет групп в ручном управлении</div>
) : (
manualGroups.map((group) => (
(() => {
const start = (manualPage - 1) * PER_PAGE;
const pageGroups = manualGroups.slice(start, start + PER_PAGE);
return pageGroups.map((group) => (
<div
key={group.id}
onClick={() => handleOpenGroup(group.id)}
@ -619,11 +646,31 @@ export const SmsCampaignPanel = () => {
<div className="text-[10px]">{fmtElapsed(group.updated_at)}</div>
</div>
</div>
))
));
})()
)}
</div>
</div>
</Panel>
{/* ── Pagination: manual groups ─────────────────────────────────── */}
{manualGroups.length > PER_PAGE && (
<div className="flex items-center justify-center gap-2">
<button
onClick={() => setManualPage(p => Math.max(1, p - 1))}
disabled={manualPage <= 1}
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-40"
> Назад</button>
<span className="text-xs text-[var(--color-text-muted)]">
Стр. {manualPage} / {Math.ceil(manualGroups.length / PER_PAGE)} · {manualGroups.length} записей
</span>
<button
onClick={() => setManualPage(p => Math.min(Math.ceil(manualGroups.length / PER_PAGE), p + 1))}
disabled={manualPage >= Math.ceil(manualGroups.length / PER_PAGE)}
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-40"
>Вперёд </button>
</div>
)}
</>
) : (
<>
@ -642,11 +689,11 @@ export const SmsCampaignPanel = () => {
{/* ── 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>
<FilterButton active={filter === "all"} onClick={() => { setFilter("all"); setLogPage(1); }}>Все ({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>;
return <FilterButton key={status} active={filter === status} onClick={() => { setFilter(status); setLogPage(1); }}>{label} ({count})</FilterButton>;
})}
</div>
<label className="flex items-center gap-1.5 text-xs text-[var(--color-text-muted)]">
@ -671,7 +718,11 @@ export const SmsCampaignPanel = () => {
{logs.length === 0 ? (
<div className="px-4 py-6 text-xs text-[var(--color-text-muted)]">Нет записей</div>
) : (
logs.map((entry) => {
(() => {
const totalPages = Math.ceil(logs.length / PER_PAGE);
const start = (logPage - 1) * PER_PAGE;
const pageLogs = logs.slice(start, start + PER_PAGE);
return pageLogs.map((entry) => {
const canRecheck = entry.status === "sent" || entry.status === "checking";
const isChecking = checkingIds.has(entry.id);
const isTestMode = settings?.test_mode;
@ -715,12 +766,32 @@ export const SmsCampaignPanel = () => {
</div>
</div>
);
})
});
})()
)}
</div>
</div>
</Panel>
{/* ── Pagination: logs ─────────────────────────────────────────────── */}
{logs.length > PER_PAGE && (
<div className="flex items-center justify-center gap-2">
<button
onClick={() => setLogPage(p => Math.max(1, p - 1))}
disabled={logPage <= 1}
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-40"
> Назад</button>
<span className="text-xs text-[var(--color-text-muted)]">
Стр. {logPage} / {Math.ceil(logs.length / PER_PAGE)} · {logs.length} записей
</span>
<button
onClick={() => setLogPage(p => Math.min(Math.ceil(logs.length / PER_PAGE), p + 1))}
disabled={logPage >= Math.ceil(logs.length / PER_PAGE)}
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-40"
>Вперёд </button>
</div>
)}
<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)]">
Обновить

View File

@ -0,0 +1,348 @@
/**
* @file SmsCityProfilesPanel.jsx
* @description Профили SMS по городам.
*/
import React, { useState, useEffect, useCallback } from "react";
import { Panel } from "../UI/Panel";
import { supabase } from "../../supabaseClient";
import { CRIMEAN_CITIES } from "../../constants/cities.js";
const THEME_STYLES = `
.sms-card {
background: #e8f0ec;
border: 1px solid #c8d8d2;
border-radius: 16px;
padding: 16px;
}
[data-theme="dark"] .sms-card {
background: #0d1a16;
border: 1px solid rgba(236,245,242,0.12);
}
.sms-inner {
background: #ffffff;
border: 1px solid #d0dad6;
border-radius: 10px;
padding: 12px;
}
[data-theme="dark"] .sms-inner {
background: #142822;
border: 1px solid rgba(236,245,242,0.08);
}
.sms-input {
background: #f4f7f5;
border: 1px solid #c8d8d2;
border-radius: 10px;
padding: 8px 12px;
color: #10211b;
width: 100%;
font-size: 14px;
font-family: inherit;
}
[data-theme="dark"] .sms-input {
background: #09110f;
border: 1px solid rgba(236,245,242,0.12);
color: #ecf5f2;
}
.sms-input::placeholder { color: #5d6d66; }
[data-theme="dark"] .sms-input::placeholder { color: #9eb0aa; }
.sms-label { font-size: 12px; font-weight: 500; color: #5d6d66; }
[data-theme="dark"] .sms-label { color: #9eb0aa; }
.sms-city-name { font-size: 16px; font-weight: 700; color: #10211b; }
[data-theme="dark"] .sms-city-name { color: #ecf5f2; }
.sms-section-title { font-size: 14px; font-weight: 600; color: #10211b; }
[data-theme="dark"] .sms-section-title { color: #ecf5f2; }
.sms-muted { font-size: 12px; color: #5d6d66; }
[data-theme="dark"] .sms-muted { color: #9eb0aa; }
.sms-text { font-size: 13px; color: #10211b; }
[data-theme="dark"] .sms-text { color: #ecf5f2; }
.sms-italic { font-size: 12px; color: #5d6d66; font-style: italic; line-height: 1.4; margin-top: 6px; }
[data-theme="dark"] .sms-italic { color: #9eb0aa; }
.sms-toggle-text { font-size: 13px; color: #10211b; }
[data-theme="dark"] .sms-toggle-text { color: #ecf5f2; }
.sms-btn {
border-radius: 12px; padding: 8px 16px; font-size: 12px; font-weight: 600;
color: #fff; border: none; cursor: pointer; background: #12805c;
}
[data-theme="dark"] .sms-btn { background: #57d8a9; color: #06120d; }
.sms-btn-sm {
border-radius: 8px; border: 1px solid #c8d8d2; padding: 4px 10px;
font-size: 12px; font-weight: 500; color: #10211b; cursor: pointer; background: transparent;
}
[data-theme="dark"] .sms-btn-sm { border: 1px solid rgba(236,245,242,0.12); color: #ecf5f2; }
.sms-btn-del {
border-radius: 8px; padding: 4px 8px; font-size: 14px;
color: #5d6d66; cursor: pointer; background: transparent; border: none;
}
[data-theme="dark"] .sms-btn-del { color: #9eb0aa; }
.sms-mode-btn-active-g {
flex: 1; border-radius: 10px; border: 1px solid #22c55e;
background: rgba(34,197,94,0.1); color: #22c55e;
padding: 8px 12px; font-size: 14px; font-weight: 500; cursor: pointer;
}
.sms-mode-btn-inactive {
flex: 1; border-radius: 10px; border: 1px solid #c8d8d2;
background: transparent; color: #5d6d66;
padding: 8px 12px; font-size: 14px; font-weight: 500; cursor: pointer;
}
[data-theme="dark"] .sms-mode-btn-inactive {
border: 1px solid rgba(236,245,242,0.12); color: #9eb0aa;
}
.sms-mode-btn-active-b {
flex: 1; border-radius: 10px; border: 1px solid #3b82f6;
background: rgba(59,130,246,0.1); color: #3b82f6;
padding: 8px 12px; font-size: 14px; font-weight: 500; cursor: pointer;
}
`;
export const SmsCityProfilesPanel = () => {
const [profiles, setProfiles] = useState([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState("");
const [formCity, setFormCity] = useState("Ялта");
const [formDeliveryMode, setFormDeliveryMode] = useState("notification");
const [formDeliveryText, setFormDeliveryText] = useState("");
const [formPickupMode, setFormPickupMode] = useState("notification");
const [formPickupText, setFormPickupText] = useState("");
const [formSecondSms, setFormSecondSms] = useState(true);
const [formPaidStorage, setFormPaidStorage] = useState(true);
const [formSmsEnabled, setFormSmsEnabled] = useState(true);
const [formNote, setFormNote] = useState("");
const [editingId, setEditingId] = useState(null);
const fetchProfiles = useCallback(async () => {
setLoading(true);
try {
const { data, error } = await supabase
.from("sms_city_profiles")
.select("*")
.order("city", { ascending: true });
if (error) throw error;
setProfiles(data || []);
} catch (e) {
console.error("fetch sms_city_profiles error", e);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchProfiles(); }, [fetchProfiles]);
const resetForm = () => {
setFormCity("Ялта"); setFormDeliveryMode("notification"); setFormDeliveryText("");
setFormPickupMode("notification"); setFormPickupText("");
setFormSecondSms(true); setFormPaidStorage(true); setFormSmsEnabled(true);
setFormNote(""); setEditingId(null);
};
const handleSave = async () => {
setSaving(true);
try {
const payload = {
city: formCity, delivery_mode: formDeliveryMode, delivery_text: formDeliveryText || null,
pickup_mode: formPickupMode, pickup_text: formPickupText || null,
second_sms_enabled: formSecondSms, paid_storage_enabled: formPaidStorage,
sms_enabled: formSmsEnabled, note: formNote || "",
};
if (editingId) {
const { error } = await supabase.from("sms_city_profiles").update(payload).eq("id", editingId);
if (error) throw error;
setMessage("✅ Профиль обновлён");
} else {
const { error } = await supabase.from("sms_city_profiles").insert(payload);
if (error) throw error;
setMessage("✅ Профиль добавлен");
}
setShowForm(false); resetForm(); fetchProfiles();
setTimeout(() => setMessage(""), 3000);
} catch (e) {
setMessage("❌ Ошибка: " + e.message);
} finally { setSaving(false); }
};
const handleEdit = (p) => {
setEditingId(p.id); setFormCity(p.city);
setFormDeliveryMode(p.delivery_mode || "link"); setFormDeliveryText(p.delivery_text || "");
setFormPickupMode(p.pickup_mode || "link"); setFormPickupText(p.pickup_text || "");
setFormSecondSms(p.second_sms_enabled ?? true); setFormPaidStorage(p.paid_storage_enabled ?? true);
setFormSmsEnabled(p.sms_enabled ?? true); setFormNote(p.note || "");
setShowForm(true);
};
const handleDelete = async (id) => {
try { const { error } = await supabase.from("sms_city_profiles").delete().eq("id", id); if (error) throw error; fetchProfiles(); }
catch (e) { console.error("delete error", e); }
};
const ModeBadge = ({ mode }) => mode === "notification"
? <span style={{ backgroundColor: "rgba(59,130,246,0.15)", color: "#3b82f6", borderRadius: "6px", padding: "2px 8px", fontSize: "10px", fontWeight: 600 }}>Уведомление</span>
: <span style={{ backgroundColor: "rgba(34,197,94,0.15)", color: "#22c55e", borderRadius: "6px", padding: "2px 8px", fontSize: "10px", fontWeight: 600 }}>Ссылка</span>;
const Toggle = ({ checked, onChange, label }) => (
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<button type="button" onClick={onChange} style={{
position: "relative", height: "20px", width: "40px", borderRadius: "9999px",
transition: "all 0.2s", flexShrink: 0, border: "none", cursor: "pointer",
backgroundColor: checked ? "#22c55e" : "#c8d8d2",
}}>
<span style={{ position: "absolute", top: "2px", height: "16px", width: "16px", borderRadius: "9999px", backgroundColor: "#fff", transition: "all 0.2s", left: checked ? "20px" : "2px" }} />
</button>
{label && <span className="sms-toggle-text">{label}</span>}
</div>
);
return (
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
<style>{THEME_STYLES}</style>
{/* Header */}
<Panel className="p-5">
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<span style={{ fontSize: "20px" }}>🏙</span>
<div>
<h2 className="sms-section-title" style={{ textTransform: "uppercase", letterSpacing: "0.16em" }}>
Профили SMS по городам
</h2>
<p className="sms-muted" style={{ marginTop: "4px" }}>
Настройка поведения SMS для каждого города.
</p>
</div>
</div>
<button type="button" className="sms-btn" onClick={() => { setShowForm(!showForm); if (!showForm) resetForm(); }}>
{showForm ? "Отмена" : "+ Профиль"}
</button>
</div>
{message && <span className="sms-text" style={{ display: "block", marginTop: "8px" }}>{message}</span>}
</Panel>
{/* Form */}
{showForm && (
<Panel className="p-5" style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
<h3 className="sms-section-title">{editingId ? "Редактировать профиль" : "Новый профиль города"}</h3>
<div style={{ display: "grid", gap: "16px", gridTemplateColumns: "1fr 1fr" }}>
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
<label className="sms-label">Город</label>
<select value={formCity} onChange={(e) => setFormCity(e.target.value)} disabled={!!editingId}
className="sms-input" style={{ opacity: editingId ? 0.5 : 1 }}>
{CRIMEAN_CITIES.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div className="sms-inner">
<Toggle checked={formSmsEnabled} onChange={() => setFormSmsEnabled(!formSmsEnabled)} label="SMS включены для города" />
{!formSmsEnabled && <p style={{ fontSize: "12px", color: "#c93d3d", marginTop: "8px" }}> Все SMS в этот город отключены</p>}
</div>
</div>
{/* Delivery */}
<div className="sms-inner">
<div className="sms-muted" style={{ fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: "12px" }}>📦 Доставка</div>
<div style={{ display: "flex", gap: "8px" }}>
<button type="button" className={formDeliveryMode === "link" ? "sms-mode-btn-active-g" : "sms-mode-btn-inactive"} onClick={() => setFormDeliveryMode("link")}>🔗 Ссылка</button>
<button type="button" className={formDeliveryMode === "notification" ? "sms-mode-btn-active-b" : "sms-mode-btn-inactive"} onClick={() => setFormDeliveryMode("notification")}>📢 Уведомление</button>
</div>
{formDeliveryMode === "notification" && (
<textarea value={formDeliveryText} onChange={(e) => setFormDeliveryText(e.target.value)} placeholder="Ваш заказ готов. Доставим по графику." rows={2} className="sms-input" style={{ resize: "none", marginTop: "8px" }} />
)}
<p className="sms-muted" style={{ marginTop: "8px" }}>
{formDeliveryMode === "link" ? "Клиент получит ссылку для выбора даты" : "Уведомление без ссылки, без 2-й SMS"}
</p>
</div>
{/* Pickup */}
<div className="sms-inner">
<div className="sms-muted" style={{ fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: "12px" }}>🏪 Самовывоз</div>
<div style={{ display: "flex", gap: "8px" }}>
<button type="button" className={formPickupMode === "link" ? "sms-mode-btn-active-g" : "sms-mode-btn-inactive"} onClick={() => setFormPickupMode("link")}>🔗 Ссылка</button>
<button type="button" className={formPickupMode === "notification" ? "sms-mode-btn-active-b" : "sms-mode-btn-inactive"} onClick={() => setFormPickupMode("notification")}>📢 Уведомление</button>
</div>
{formPickupMode === "notification" && (
<textarea value={formPickupText} onChange={(e) => setFormPickupText(e.target.value)} placeholder="Ваш заказ готов. Заберите со склада." rows={2} className="sms-input" style={{ resize: "none", marginTop: "8px" }} />
)}
</div>
<div style={{ display: "grid", gap: "12px", gridTemplateColumns: "1fr 1fr" }}>
<div className="sms-inner"><Toggle checked={formSecondSms} onChange={() => setFormSecondSms(!formSecondSms)} label="2-я SMS (повтор)" /></div>
<div className="sms-inner"><Toggle checked={formPaidStorage} onChange={() => setFormPaidStorage(!formPaidStorage)} label="SMS: платное хранение" /></div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
<label className="sms-label">Комментарий</label>
<input type="text" value={formNote} onChange={(e) => setFormNote(e.target.value)} placeholder="Например: Ялта — доставка без согласования" className="sms-input" />
</div>
<button type="button" disabled={saving} onClick={handleSave} className="sms-btn" style={{ padding: "10px 20px", fontSize: "14px", opacity: saving ? 0.4 : 1 }}>
{saving ? "Сохранение..." : editingId ? "Обновить профиль" : "Сохранить профиль"}
</button>
</Panel>
)}
{/* Profiles list */}
<Panel className="p-5" style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
<h2 className="sms-section-title" style={{ textTransform: "uppercase", letterSpacing: "0.16em" }}>
Профили ({profiles.length})
</h2>
{loading ? (
<p className="sms-muted">Загрузка...</p>
) : profiles.length === 0 ? (
<div style={{ border: "1px dashed #c8d8d2", borderRadius: "12px", padding: "24px", textAlign: "center" }}>
<p className="sms-muted">Профилей нет. Города без профиля используют стандартный флоу (ссылка на согласование).</p>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
{profiles.map((p) => (
<div key={p.id} className="sms-card">
{/* Header */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "12px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<span className="sms-city-name">{p.city}</span>
{!p.sms_enabled
? <span style={{ backgroundColor: "rgba(239,68,68,0.15)", color: "#ef4444", borderRadius: "6px", padding: "2px 8px", fontSize: "10px", fontWeight: 600 }}>SMS OFF</span>
: <span style={{ backgroundColor: "rgba(34,197,94,0.15)", color: "#22c55e", borderRadius: "6px", padding: "2px 8px", fontSize: "10px", fontWeight: 600 }}>SMS ON</span>}
</div>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<button type="button" className="sms-btn-sm" onClick={() => handleEdit(p)}> Изменить</button>
<button type="button" className="sms-btn-del" onClick={() => handleDelete(p.id)} title="Удалить"></button>
</div>
</div>
{/* Config */}
<div style={{ display: "grid", gap: "8px", gridTemplateColumns: "1fr 1fr", marginBottom: "12px" }}>
<div className="sms-inner">
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}>
<span className="sms-text" style={{ fontSize: "12px", fontWeight: 500 }}>📦 Доставка</span>
<ModeBadge mode={p.delivery_mode} />
</div>
{p.delivery_text && <p className="sms-italic">"{p.delivery_text}"</p>}
</div>
<div className="sms-inner">
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}>
<span className="sms-text" style={{ fontSize: "12px", fontWeight: 500 }}>🏪 Самовывоз</span>
<ModeBadge mode={p.pickup_mode} />
</div>
{p.pickup_text && <p className="sms-italic">"{p.pickup_text}"</p>}
</div>
</div>
{/* Toggles */}
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "16px", paddingTop: "8px", borderTop: "1px solid #c8d8d2" }}>
<span className="sms-text" style={{ fontSize: "12px", fontWeight: 500, opacity: p.second_sms_enabled ? 1 : 0.5 }}>
2-я SMS: {p.second_sms_enabled ? "✅" : "❌"}
</span>
<span className="sms-text" style={{ fontSize: "12px", fontWeight: 500, opacity: p.paid_storage_enabled ? 1 : 0.5 }}>
Платное хранение: {p.paid_storage_enabled ? "✅" : "❌"}
</span>
</div>
{p.note && <p className="sms-muted" style={{ paddingTop: "8px" }}>💬 {p.note}</p>}
</div>
))}
</div>
)}
</Panel>
</div>
);
};

View File

@ -0,0 +1,335 @@
/**
* @file SmsCityRulesPanel.jsx
* @description Управление SMS-отправкой по городам.
* Админ может:
* - Включить/выключить SMS для города × кампания (постоянно)
* - Указать период (с/по дату) временно
* - Добавить комментарий
* - «Все города» (*) или «Все кампании» (*) массовое правило
*/
import React, { useState, useEffect, useCallback } from "react";
import { Panel } from "../UI/Panel";
import { supabase } from "../../supabaseClient";
import { CRIMEAN_CITIES } from "../../constants/cities.js";
const CAMPAIGN_LABELS = {
first_sms: "1-е SMS",
second_sms: "2-е SMS",
"*": "Все кампании",
};
const CITY_ALL = "*";
export const SmsCityRulesPanel = () => {
const [rules, setRules] = useState([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState("");
// Form state
const [formCity, setFormCity] = useState("Ялта");
const [formCampaign, setFormCampaign] = useState("*");
const [formEnabled, setFormEnabled] = useState(false);
const [formFrom, setFormFrom] = useState("");
const [formUntil, setFormUntil] = useState("");
const [formNote, setFormNote] = useState("");
const fetchRules = useCallback(async () => {
setLoading(true);
try {
const { data, error } = await supabase
.from("sms_city_rules")
.select("*")
.order("city", { ascending: true })
.order("campaign_type", { ascending: true });
if (error) throw error;
setRules(data || []);
} catch (e) {
console.error("fetch sms_city_rules error", e);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchRules(); }, [fetchRules]);
const handleSave = async () => {
setSaving(true);
try {
const { error } = await supabase.from("sms_city_rules").insert({
city: formCity,
campaign_type: formCampaign,
enabled: formEnabled,
valid_from: formFrom || null,
valid_until: formUntil || null,
note: formNote || "",
});
if (error) throw error;
setShowForm(false);
setFormNote("");
setFormFrom("");
setFormUntil("");
setMessage("✅ Правило добавлено");
fetchRules();
setTimeout(() => setMessage(""), 3000);
} catch (e) {
setMessage("❌ Ошибка: " + e.message);
} finally {
setSaving(false);
}
};
const handleToggle = async (rule) => {
try {
const { error } = await supabase
.from("sms_city_rules")
.update({ enabled: !rule.enabled })
.eq("id", rule.id);
if (error) throw error;
fetchRules();
} catch (e) {
console.error("toggle error", e);
}
};
const handleDelete = async (id) => {
try {
const { error } = await supabase
.from("sms_city_rules")
.delete()
.eq("id", id);
if (error) throw error;
fetchRules();
} catch (e) {
console.error("delete error", e);
}
};
const fmtDate = (d) => {
if (!d) return "—";
try {
return new Date(d).toLocaleDateString("ru-RU", { day: "2-digit", month: "short", year: "numeric" });
} catch { return d; }
};
return (
<div className="space-y-4">
{/* Header */}
<Panel className="p-5 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-xl">🏙</span>
<div>
<h2 className="text-sm font-semibold uppercase tracking-[0.16em] text-[var(--color-text)]">
SMS по городам
</h2>
<p className="text-xs text-[var(--color-text-muted)] mt-1">
Управление отправкой SMS по городам. Выключенное правило = SMS не отправляется. Только для 1-го и 2-го SMS (согласование доставки). Платное хранение не регулируется.
</p>
</div>
</div>
<button
type="button"
onClick={() => setShowForm(!showForm)}
className="rounded-xl bg-[var(--color-accent)] px-4 py-2 text-xs font-semibold text-white transition hover:opacity-90"
>
{showForm ? "Отмена" : "+ Правило"}
</button>
</div>
{message && <span className="text-sm text-[var(--color-text-muted)]">{message}</span>}
</Panel>
{/* Add rule form */}
{showForm && (
<Panel className="p-5 space-y-4">
<h3 className="text-sm font-semibold text-[var(--color-text)]">Новое правило</h3>
<div className="grid gap-3 sm:grid-cols-2">
{/* City */}
<div className="space-y-1">
<label className="text-xs font-medium text-[var(--color-text-muted)]">Город</label>
<select
value={formCity}
onChange={(e) => setFormCity(e.target.value)}
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm text-[var(--color-text)]"
>
<option value={CITY_ALL}> Все города</option>
{CRIMEAN_CITIES.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
{/* Campaign */}
<div className="space-y-1">
<label className="text-xs font-medium text-[var(--color-text-muted)]">Кампания</label>
<select
value={formCampaign}
onChange={(e) => setFormCampaign(e.target.value)}
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm text-[var(--color-text)]"
>
<option value="*"> Все кампании</option>
<option value="first_sms">1-е SMS</option>
<option value="second_sms">2-е SMS</option>
</select>
</div>
{/* Enabled toggle */}
<div className="space-y-1">
<label className="text-xs font-medium text-[var(--color-text-muted)]">Статус</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setFormEnabled(true)}
className={["rounded-xl border px-4 py-2 text-sm font-medium transition",
formEnabled ? "border-[#22c55e] bg-[rgba(34,197,94,0.12)] text-[#22c55e]" : "border-[var(--color-border)] text-[var(--color-text-muted)]"].join(" ")}
>
Включить
</button>
<button
type="button"
onClick={() => setFormEnabled(false)}
className={["rounded-xl border px-4 py-2 text-sm font-medium transition",
!formEnabled ? "border-[#ef4444] bg-[rgba(239,68,68,0.12)] text-[#ef4444]" : "border-[var(--color-border)] text-[var(--color-text-muted)]"].join(" ")}
>
Выключить
</button>
</div>
</div>
{/* Period */}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<label className="text-xs font-medium text-[var(--color-text-muted)]">С даты</label>
<input
type="date"
value={formFrom}
onChange={(e) => setFormFrom(e.target.value)}
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm text-[var(--color-text)]"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-[var(--color-text-muted)]">По дату</label>
<input
type="date"
value={formUntil}
onChange={(e) => setFormUntil(e.target.value)}
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm text-[var(--color-text)]"
/>
</div>
</div>
</div>
{/* Note */}
<div className="space-y-1">
<label className="text-xs font-medium text-[var(--color-text-muted)]">Комментарий</label>
<input
type="text"
value={formNote}
onChange={(e) => setFormNote(e.target.value)}
placeholder="Например: Ялта временно отключена"
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm text-[var(--color-text)] placeholder:text-[var(--color-text-muted)]"
/>
</div>
<p className="text-xs text-[var(--color-text-muted)]">
Пустые даты = правило действует постоянно. Указав даты, можно временно отключить SMS на период.
</p>
<button
type="button"
disabled={saving}
onClick={handleSave}
className="rounded-xl bg-[var(--color-accent)] px-5 py-2.5 text-sm font-semibold text-white transition hover:opacity-90 disabled:opacity-40"
>
{saving ? "Сохранение..." : "Сохранить правило"}
</button>
</Panel>
)}
{/* Rules table */}
<Panel className="p-5 space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-[0.16em] text-[var(--color-text)]">
Правила ({rules.length})
</h2>
{loading ? (
<p className="text-sm text-[var(--color-text-muted)]">Загрузка...</p>
) : rules.length === 0 ? (
<div className="rounded-xl border border-dashed border-[var(--color-border)] p-6 text-center">
<p className="text-sm text-[var(--color-text-muted)]">
Правил пока нет. SMS отправляются во все города.
</p>
<p className="text-xs text-[var(--color-text-muted)] mt-1">
Нажмите «+ Правило» чтобы отключить SMS для конкретного города.
</p>
</div>
) : (
<div className="space-y-2">
{rules.map((rule) => {
const isActive = rule.enabled &&
(!rule.valid_from || new Date(rule.valid_from) <= new Date()) &&
(!rule.valid_until || new Date(rule.valid_until) >= new Date());
return (
<div
key={rule.id}
className="flex items-center justify-between gap-3 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-4"
>
<div className="flex items-center gap-3 flex-1">
{/* Toggle */}
<button
type="button"
onClick={() => handleToggle(rule)}
className={[
"relative h-6 w-11 rounded-full transition shrink-0",
rule.enabled ? "bg-[#22c55e]" : "bg-[var(--color-border)]"
].join(" ")}
>
<span className={[
"absolute top-0.5 h-5 w-5 rounded-full bg-white transition-all",
rule.enabled ? "left-[22px]" : "left-0.5"
].join(" ")} />
</button>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-[var(--color-text)]">
{rule.city === CITY_ALL ? "⭐ Все города" : rule.city}
</span>
<span className="text-xs text-[var(--color-text-muted)]">·</span>
<span className="text-xs font-medium text-[var(--color-text-muted)]">
{CAMPAIGN_LABELS[rule.campaign_type] || rule.campaign_type}
</span>
<span
className="rounded-lg px-2 py-0.5 text-[10px] font-semibold uppercase"
style={{
backgroundColor: isActive ? "rgba(34,197,94,0.12)" : "rgba(239,68,68,0.12)",
color: isActive ? "#22c55e" : "#ef4444",
}}
>
{isActive ? "Активно" : rule.enabled ? "Запланировано" : "Выключено"}
</span>
</div>
{(rule.valid_from || rule.valid_until) && (
<div className="text-xs text-[var(--color-text-muted)] mt-1">
📅 {fmtDate(rule.valid_from)} {fmtDate(rule.valid_until)}
</div>
)}
{rule.note && (
<div className="text-xs text-[var(--color-text-muted)] mt-0.5">
💬 {rule.note}
</div>
)}
</div>
</div>
{/* Delete */}
<button
type="button"
onClick={() => handleDelete(rule.id)}
className="rounded-lg p-1.5 text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-danger)] transition shrink-0"
title="Удалить правило"
>
</button>
</div>
);
})}
</div>
)}
</Panel>
</div>
);
};

View File

@ -27,6 +27,7 @@ export const SuggestionsPanel = () => {
const [newCategory, setNewCategory] = React.useState("feature");
const [submitting, setSubmitting] = React.useState(false);
const [message, setMessage] = React.useState("");
const [myVotes, setMyVotes] = React.useState(new Set());
const getSupabase = React.useCallback(async () => {
const { createClient } = await import("@supabase/supabase-js");
@ -42,14 +43,23 @@ export const SuggestionsPanel = () => {
const { data } = await supabase
.from("suggestions")
.select("*")
.order("vote_count", { ascending: false })
.order("created_at", { ascending: false });
// Fetch my votes
const { data: votesData } = await supabase
.from("suggestion_votes")
.select("suggestion_id")
.eq("user_id", user?.id);
setMyVotes(new Set((votesData || []).map((v) => v.suggestion_id)));
setSuggestions(data || []);
} catch (e) {
console.error("fetch suggestions error", e);
} finally {
setLoading(false);
}
}, [getSupabase]);
}, [getSupabase, user?.id]);
React.useEffect(() => { fetchSuggestions(); }, [fetchSuggestions]);
@ -90,6 +100,40 @@ export const SuggestionsPanel = () => {
}
};
const handleVote = async (suggestionId) => {
const hasVoted = myVotes.has(suggestionId);
try {
const supabase = await getSupabase();
if (hasVoted) {
await supabase
.from("suggestion_votes")
.delete()
.eq("suggestion_id", suggestionId)
.eq("user_id", user?.id);
setMyVotes((prev) => {
const next = new Set(prev);
next.delete(suggestionId);
return next;
});
} else {
await supabase
.from("suggestion_votes")
.insert({ suggestion_id: suggestionId, user_id: user?.id });
setMyVotes((prev) => new Set(prev).add(suggestionId));
}
fetchSuggestions();
} catch (e) {
console.error("vote error", e);
// Revert optimistic update
setMyVotes((prev) => {
const next = new Set(prev);
if (hasVoted) next.add(suggestionId);
else next.delete(suggestionId);
return next;
});
}
};
return (
<div className="space-y-6">
<Panel className="space-y-4 p-5">
@ -100,7 +144,7 @@ export const SuggestionsPanel = () => {
</h2>
</div>
<p className="text-sm text-[var(--color-text-muted)]">
Есть идея? Опишите админы рассмотрят.
Есть идея? Опишите другие смогут проголосовать, админы рассмотрят.
</p>
<div className="flex flex-wrap gap-2">
{CATEGORY_OPTIONS.map((cat) => (
@ -140,9 +184,14 @@ export const SuggestionsPanel = () => {
</Panel>
<Panel className="space-y-3 p-5">
<h2 className="text-sm font-semibold uppercase tracking-[0.16em] text-[var(--color-text)]">
Все предложения
</h2>
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold uppercase tracking-[0.16em] text-[var(--color-text)]">
Все предложения
</h2>
<span className="text-xs text-[var(--color-text-muted)]">
Сортировка: по голосам
</span>
</div>
{loading ? (
<p className="text-sm text-[var(--color-text-muted)]">Загрузка...</p>
) : suggestions.length === 0 ? (
@ -152,6 +201,7 @@ export const SuggestionsPanel = () => {
{suggestions.map((s) => {
const cat = CATEGORY_OPTIONS.find((c) => c.value === s.category) || CATEGORY_OPTIONS[3];
const st = STATUS_MAP[s.status] || STATUS_MAP.new;
const hasVoted = myVotes.has(s.id);
return (
<SuggestionCard
key={s.id}
@ -160,6 +210,8 @@ export const SuggestionsPanel = () => {
status={st}
isAdmin={isAdmin}
onStatusChange={handleStatusChange}
hasVoted={hasVoted}
onVote={handleVote}
/>
);
})}
@ -170,7 +222,7 @@ export const SuggestionsPanel = () => {
);
};
const SuggestionCard = ({ suggestion: s, category, status, isAdmin, onStatusChange }) => {
const SuggestionCard = ({ suggestion: s, category, status, isAdmin, onStatusChange, hasVoted, onVote }) => {
const [editing, setEditing] = React.useState(false);
const [comment, setComment] = React.useState(s.admin_comment || "");
@ -196,10 +248,27 @@ const SuggestionCard = ({ suggestion: s, category, status, isAdmin, onStatusChan
<span className="text-[10px] text-[var(--color-text-muted)] whitespace-nowrap">{formatDate(s.created_at)}</span>
</div>
<p className="text-sm text-[var(--color-text)] whitespace-pre-wrap">{s.content}</p>
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
<span className="font-medium">{s.author_name}</span>
<span>·</span>
<span>{s.author_role}</span>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
<span className="font-medium">{s.author_name}</span>
<span>·</span>
<span>{s.author_role}</span>
</div>
{/* Vote button */}
<button
type="button"
onClick={() => onVote(s.id)}
className={[
"flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-semibold transition",
hasVoted
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-accent)]"
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-accent)]"
].join(" ")}
title={hasVoted ? "Нажмите, чтобы отменить голос" : "Голосовать за предложение"}
>
<span className="text-sm">{hasVoted ? "👍" : "⬆️"}</span>
<span>{s.vote_count || 0}</span>
</button>
</div>
{s.admin_comment && (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] p-3 text-xs text-[var(--color-text-muted)]">

View File

@ -92,6 +92,13 @@ export const OrderCompositionPanel = ({ invitation = {} }) => {
const filteredCount = allProducts.length - products.length;
const reference = getInvitationReferenceLabel(invitation);
const invoiceCount = (() => {
const items = Array.isArray(rawItems) ? rawItems : [];
const numbers = new Set(items.map((item) => String(
item?.order_number || item?.orderNumber || item?.invoice_number || item?.invoiceNumber || item?.nom || "",
).trim()).filter(Boolean));
return numbers.size || (invitation?.orderNumber ? 1 : 1);
})();
const [isExpanded, setIsExpanded] = React.useState(false);
@ -106,11 +113,10 @@ export const OrderCompositionPanel = ({ invitation = {} }) => {
className="flex w-full items-center justify-between text-left"
onClick={() => setIsExpanded(!isExpanded)}
>
<p className="text-sm uppercase tracking-[0.18em] text-[var(--color-text-muted)]">
Состав заказа{reference && reference !== "Счет —" ? ` ${reference}` : ""}
<p className="text-sm font-semibold text-[var(--color-text)]">
Состав заказа · {invoiceCount} {invoiceCount === 1 ? "счёт" : invoiceCount < 5 ? "счёта" : "счетов"}
</p>
<span className="flex items-center gap-2 text-sm text-[var(--color-text-muted)]">
{products.length} поз.
<svg
className="h-4 w-4 transition-transform"
style={{ transform: isExpanded ? "rotate(180deg)" : "rotate(0deg)" }}

View File

@ -36,24 +36,27 @@ const getCrimeaHour = (referenceDate = new Date()) => {
);
};
const isWeekend = (dateKey) => {
const isAllowedPickupDay = (dateKey, allowedDays = [1,2,3,4,5]) => {
if (!dateKey) return false;
const d = new Date(`${dateKey}T12:00:00Z`);
const day = d.getUTCDay();
return day === 0 || day === 6;
// getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
// allowedDays uses 1=Mon ... 7=Sun
const dayNum = d.getUTCDay() === 0 ? 7 : d.getUTCDay();
return allowedDays.includes(dayNum);
};
const getNextWorkday = (dateKey) => {
const getNextPickupWorkday = (dateKey, allowedDays = [1,2,3,4,5]) => {
let next = addDaysKey(dateKey, 1);
while (isWeekend(next)) {
while (!isAllowedPickupDay(next, allowedDays)) {
next = addDaysKey(next, 1);
}
return next;
};
const getPickupSlots = (referenceDate = new Date()) => {
const getPickupSlots = (referenceDate = new Date(), pickupDays = [1,2,3,4,5]) => {
const todayKey = getCrimeaTodayKey(referenceDate);
const hour = getCrimeaHour(referenceDate);
const isTodayWorkday = !isWeekend(todayKey);
const isTodayWorkday = isAllowedPickupDay(todayKey, pickupDays);
const slots = [];
@ -77,7 +80,7 @@ const getPickupSlots = (referenceDate = new Date()) => {
}
const tomorrow = addDaysKey(todayKey, 1);
const tomorrowWorkday = !isWeekend(tomorrow) ? tomorrow : getNextWorkday(todayKey);
const tomorrowWorkday = isAllowedPickupDay(tomorrow, pickupDays) ? tomorrow : getNextPickupWorkday(todayKey, pickupDays);
slots.push({
id: `pickup-${tomorrowWorkday}-first`,
date: tomorrowWorkday,
@ -94,7 +97,7 @@ const getPickupSlots = (referenceDate = new Date()) => {
});
const dayAfter = addDaysKey(tomorrowWorkday, 1);
const dayAfterWorkday = !isWeekend(dayAfter) ? dayAfter : getNextWorkday(dayAfter);
const dayAfterWorkday = isAllowedPickupDay(dayAfter, pickupDays) ? dayAfter : getNextPickupWorkday(dayAfter, pickupDays);
slots.push({
id: `pickup-${dayAfterWorkday}-first`,
date: dayAfterWorkday,
@ -152,8 +155,9 @@ export const PickupSlotsPicker = ({
onSelectSlot,
selectedSlotId,
referenceDate = new Date(),
pickupDays = [1, 2, 3, 4, 5],
}) => {
const slots = React.useMemo(() => getPickupSlots(referenceDate), [referenceDate]);
const slots = React.useMemo(() => getPickupSlots(referenceDate, pickupDays), [referenceDate, pickupDays]);
if (!slots.length) {
return (

View File

@ -0,0 +1,248 @@
import React, { useState, useEffect, useRef } from "react";
const VAPID_PUBLIC_KEY = "BDLNGyVp7hDzRujZBncNQ0qyz4zVFAhAWX1nsCMRC2tagmer46Sy6exZOymsGVk-TDb1bhkkXXth9Jkzdv4SdJg";
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || "";
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || "";
const getEndpoint = (action) => `${SUPABASE_URL}/functions/v1/${action}`;
const apiHeaders = {
"Content-Type": "application/json",
...(SUPABASE_ANON_KEY ? { apikey: SUPABASE_ANON_KEY } : {}),
};
const normalizePhone = (phone) => {
if (!phone) return "";
const clean = String(phone).replace(/\D/g, "");
if (clean.startsWith("8")) return "7" + clean.slice(1);
if (!clean.startsWith("7")) return "7" + clean;
return clean;
};
const urlBase64ToUint8Array = (base64String) => {
const padding = "=".repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
};
export const PushSubscriptionBanner = ({ phone, orderGroupId }) => {
const [status, setStatus] = useState("idle"); // idle | prompting | subscribed | denied | unsupported
const [dismissed, setDismissed] = useState(false);
const phoneNorm = normalizePhone(phone);
// Detect iOS Safari (not in standalone mode = not added to Home Screen)
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
const isStandalone = window.matchMedia("(display-mode: standalone)").matches || navigator.standalone === true;
const isIOSNotInstalled = isIOS && !isStandalone;
// Check if push is supported
useEffect(() => {
// iOS Safari in browser mode (not standalone) doesn't support Push API
if (isIOSNotInstalled) {
setStatus("ios_not_installed");
return;
}
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
setStatus("unsupported");
return;
}
// Check existing subscription
navigator.serviceWorker.ready.then(async (reg) => {
const existing = await reg.pushManager.getSubscription();
if (existing) {
setStatus("subscribed");
}
}).catch(() => {});
}, []);
const subscribe = async () => {
try {
setStatus("prompting");
const reg = await navigator.serviceWorker.ready;
const existing = await reg.pushManager.getSubscription();
let subscription = existing;
if (!subscription) {
subscription = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
}
// Send to backend
const sub = subscription.toJSON();
const resp = await fetch(getEndpoint("push-subscribe"), {
method: "POST",
headers: apiHeaders,
body: JSON.stringify({
phone_normalized: phoneNorm,
endpoint: sub.endpoint,
p256dh: sub.keys?.p256dh || "",
auth: sub.keys?.auth || "",
user_agent: navigator.userAgent || "",
order_group_id: orderGroupId || null,
}),
});
if (resp.ok) {
setStatus("subscribed");
} else {
console.warn("[Push] subscribe API error:", resp.status);
setStatus("idle");
}
} catch (err) {
if (err?.name === "NotAllowedError" || err?.name === "denied") {
setStatus("denied");
} else {
console.warn("[Push] subscribe error:", err);
setStatus("idle");
}
}
};
const unsubscribe = async () => {
try {
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
if (sub) {
await sub.unsubscribe();
// Notify backend
await fetch(getEndpoint("push-unsubscribe"), {
method: "POST",
headers: apiHeaders,
body: JSON.stringify({
endpoint: sub.endpoint,
phone_normalized: phoneNorm,
}),
});
}
setStatus("idle");
} catch (err) {
console.warn("[Push] unsubscribe error:", err);
setStatus("idle");
}
};
// Don't render if unsupported, dismissed, or no phone
if (status === "unsupported" || !phoneNorm) return null;
if (dismissed && status !== "subscribed" && status !== "denied") return null;
// iOS not installed show instruction to add to Home Screen
if (status === "ios_not_installed") {
return (
<div className="rounded-xl border border-[rgba(18,128,92,0.2)] bg-[var(--color-accent-soft)] px-4 py-3">
<div className="flex items-start gap-2.5">
<span className="text-base flex-shrink-0 mt-0.5">🔔</span>
<div className="flex-1">
<p className="text-sm font-semibold text-[var(--color-text)]">
Включить уведомления о доставке
</p>
<p className="text-xs text-[var(--color-text-muted)] mt-1 leading-5">
На iPhone уведомления работают только если приложение добавлено на главный экран.
</p>
<ol className="text-xs text-[var(--color-text-muted)] mt-2 space-y-1 leading-5">
<li>1. Нажмите <span className="font-medium">«Поделиться»</span> (квадрат со стрелкой ) внизу Safari</li>
<li>2. Выберите <span className="font-medium">«На экран Домой»</span> «Добавить»</li>
<li>3. Откройте приложение с иконки <span className="font-medium">«СуперСам»</span> на главном экране</li>
<li>4. Нажмите <span className="font-medium">«Разрешить»</span> в этом баннере</li>
</ol>
</div>
</div>
<div className="flex justify-end mt-2">
<button
type="button"
onClick={() => setDismissed(true)}
className="text-xs text-[var(--color-text-muted)] transition hover:text-[var(--color-text)]"
>
Позже
</button>
</div>
</div>
);
}
// Subscribed state show small confirmation + unsubscribe option
if (status === "subscribed") {
return (
<div className="rounded-xl border border-[rgba(18,128,92,0.25)] bg-[var(--color-accent-soft)] px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="text-sm">🔔</span>
<p className="text-sm font-medium text-[var(--color-accent)]">
Уведомления включены
</p>
</div>
<button
type="button"
onClick={unsubscribe}
className="text-xs text-[var(--color-text-muted)] transition hover:text-[var(--color-text)] underline"
>
Отключить
</button>
</div>
</div>
);
}
// Denied user blocked notifications
if (status === "denied") {
return (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3">
<div className="flex items-center gap-2">
<span className="text-sm">🔕</span>
<p className="text-sm text-[var(--color-text-muted)]">
Уведомления заблокированы. Разрешите их в настройках браузера.
</p>
</div>
</div>
);
}
// Prompting show loading
if (status === "prompting") {
return (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3">
<p className="text-sm text-[var(--color-text-muted)]">Ожидание разрешения</p>
</div>
);
}
// Idle show subscription banner
return (
<div className="rounded-xl border border-[rgba(18,128,92,0.2)] bg-[var(--color-accent-soft)] px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-start gap-2.5">
<span className="text-base flex-shrink-0 mt-0.5">🔔</span>
<div>
<p className="text-sm font-semibold text-[var(--color-text)]">
Включить уведомления о доставке
</p>
<p className="text-xs text-[var(--color-text-muted)] mt-0.5">
Получайте push-уведомления вместо SMS. Быстрее и удобнее.
</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
type="button"
onClick={subscribe}
className="rounded-xl bg-[var(--color-accent)] px-4 py-2 text-sm font-semibold text-white transition hover:opacity-90"
>
Разрешить
</button>
<button
type="button"
onClick={() => setDismissed(true)}
className="text-xs text-[var(--color-text-muted)] transition hover:text-[var(--color-text)]"
>
Позже
</button>
</div>
</div>
</div>
);
};
export default PushSubscriptionBanner;

View File

@ -0,0 +1,62 @@
import React from "react";
import { usePwaStatus } from "../../hooks/usePwaStatus";
/**
* Persistent PWA install card shown on the client delivery page.
* - Android/desktop: a button that fires the native `beforeinstallprompt` dialog.
* - iOS Safari: step-by-step instructions to add the app to the Home Screen.
* Hidden when the app is already installed (standalone mode).
*/
export const PwaInstallCard = () => {
const { isInstalled, isInstallAvailable, installApp } = usePwaStatus();
const isIOS =
typeof navigator !== "undefined" &&
/iPad|iPhone|iPod/.test(navigator.userAgent) &&
!window.MSStream;
// Already installed as a PWA nothing to offer.
if (isInstalled) return null;
return (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3">
<div className="flex items-start gap-2.5">
<span className="text-base flex-shrink-0 mt-0.5">📲</span>
<div className="flex-1">
<p className="text-sm font-semibold text-[var(--color-text)]">
Установить приложение «СуперСам»
</p>
<p className="text-xs text-[var(--color-text-muted)] mt-1 leading-5">
Откройте приложение прямо с главного экрана быстрее и удобнее.
</p>
{isIOS ? (
<ol className="text-xs text-[var(--color-text-muted)] mt-2 space-y-1 leading-5">
<li>1. Откройте эту страницу в <span className="font-medium">Safari</span></li>
<li>2. Нажмите <span className="font-medium">«Поделиться»</span> (квадрат со стрелкой )</li>
<li>3. Выберите <span className="font-medium">«На экран Домой»</span> «Добавить»</li>
</ol>
) : (
<div className="mt-2">
{isInstallAvailable ? (
<button
type="button"
onClick={() => installApp()}
className="rounded-xl bg-[var(--color-accent)] px-4 py-2 text-sm font-semibold text-white transition hover:opacity-90"
>
Установить
</button>
) : (
<p className="text-xs leading-5 text-[var(--color-text-muted)]">
Нажмите <span className="font-medium">«Установить приложение»</span> в меню браузера.
</p>
)}
</div>
)}
</div>
</div>
</div>
);
};
export default PwaInstallCard;

View File

@ -74,21 +74,18 @@ export const DriverDeliveryDetail = ({ order, onStatusChange }) => {
const orderItems = Array.isArray(order.items) ? order.items.map(splitItem) : [];
const currentStatus = order.status;
const IN_TRANSIT_STATUSES = ["Загружен", "В пути"];
const isOnRoute = IN_TRANSIT_STATUSES.includes(currentStatus);
let actionButtons = [];
if (currentStatus === "Назначен водитель") {
actionButtons = [
{ value: "Загружен", label: "Загружено" },
{ value: "Проблема доставки", label: "Проблема" },
];
} else if (isOnRoute) {
actionButtons = [
{ value: "Доставлен", label: "Доставлено" },
{ value: "Проблема доставки", label: "Проблема" },
];
} else if (currentStatus === "Доставлен" || currentStatus === "Проблема доставки" || currentStatus === "Закрыт" || currentStatus === "Отменён") {
} else if (currentStatus === "Доставлен") {
actionButtons = [
{ value: "Назначен водитель", label: "Вернуть в работу" },
];
} else if (currentStatus === "Проблема доставки" || currentStatus === "Закрыт" || currentStatus === "Отменён") {
actionButtons = [];
} else {
actionButtons = availableTransitions.map((status) => ({
@ -98,7 +95,7 @@ export const DriverDeliveryDetail = ({ order, onStatusChange }) => {
}
return (
<div className="space-y-4">
<div className="space-y-4 fs-zone-card">
{showProblemModal && (
<ProblemReasonModal
onSelect={(reasonValue, reasonLabel) => {

View File

@ -261,7 +261,7 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
}
return (
<div className="space-y-4">
<div className="space-y-4 fs-zone-table">
<Panel className="space-y-3 p-5">
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">

View File

@ -2,7 +2,6 @@ import React from "react";
import { supabase } from "../../supabaseClient";
import { Badge } from "../UI/Badge";
import { Button } from "../UI/Button";
import { Panel } from "../UI/Panel";
import { matchesStopWord } from "../../hooks/useStopWords";
const parseOrderItems = (order) => {
@ -20,6 +19,7 @@ const parseOrderItems = (order) => {
const hasProducts = subItems.some(
(p) => typeof p === "object" && (p.product_name || p.name)
);
const orderNom = String(sub.nom || sub.name || "").trim();
if (hasProducts) {
for (const p of subItems) {
if (!p || typeof p !== "object") continue;
@ -30,6 +30,7 @@ const parseOrderItems = (order) => {
name,
quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(),
unit: String(p.product_ed || p.unit || "").trim(),
orderNom,
});
}
} else if (sub.nom || sub.name) {
@ -38,6 +39,7 @@ const parseOrderItems = (order) => {
name: String(sub.nom || sub.name || "").trim(),
quantity: "",
unit: "",
orderNom,
});
}
}
@ -57,6 +59,7 @@ const parseOrderItems = (order) => {
name,
quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(),
unit: String(p.product_ed || p.unit || "").trim(),
orderNom: String(src.nom || "").trim(),
});
}
}
@ -71,6 +74,7 @@ const parseOrderItems = (order) => {
for (const sub of orderList) {
if (!sub || typeof sub !== "object") continue;
const items = Array.isArray(sub.items) ? sub.items : [];
const orderNom = String(sub.nom || sub.name || "").trim();
for (const p of items) {
if (!p || typeof p !== "object") continue;
const name = String(p.product_name || p.name || "").trim();
@ -80,6 +84,7 @@ const parseOrderItems = (order) => {
name,
quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(),
unit: String(p.product_ed || p.unit || "").trim(),
orderNom,
});
}
}
@ -89,12 +94,15 @@ const parseOrderItems = (order) => {
return [];
};
export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, isSavingShipment, onResetStatus, isSavingStatusChange }) => {
export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, isSavingShipment, onResetStatus, isSavingStatusChange, groupByOrder = false }) => {
const [stopWords, setStopWords] = React.useState([]);
const [scopeActive, setScopeActive] = React.useState(true);
const [savedShipment, setSavedShipment] = React.useState([]);
const [justSaved, setJustSaved] = React.useState(false);
const [showResetConfirm, setShowResetConfirm] = React.useState(false);
const [filterMode, setFilterMode] = React.useState("all"); // "all" | "stop_only"
const [collapsedGroups, setCollapsedGroups] = React.useState(null); // null = all collapsed by default
const [searchQuery, setSearchQuery] = React.useState("");
React.useEffect(() => {
if (!supabase) return;
@ -108,10 +116,56 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
}, []);
const allItems = React.useMemo(() => parseOrderItems(order), [order]);
// For non-grouped mode: filter based on scopeActive (existing behavior)
// For grouped mode: items = allItems, filter is only for display
const items = React.useMemo(() => {
if (groupByOrder) return allItems;
if (!stopWords.length || !scopeActive) return allItems;
return allItems.filter((item) => !matchesStopWord(item.name, stopWords));
}, [allItems, stopWords, scopeActive]);
}, [allItems, stopWords, scopeActive, groupByOrder]);
// For grouped mode: display items based on filterMode and search
const displayItems = React.useMemo(() => {
if (!groupByOrder) return items;
let result = allItems;
if (filterMode === "stop_only") {
result = result.filter((item) => !matchesStopWord(item.name, stopWords));
}
const q = searchQuery.trim().toLowerCase();
if (q) {
// Check if search matches any order number if so, show all items in that order
const matchingOrderNoms = new Set();
for (const item of result) {
if (String(item.orderNom || "").toLowerCase().includes(q)) {
matchingOrderNoms.add(String(item.orderNom || ""));
}
}
if (matchingOrderNoms.size > 0) {
result = result.filter((item) => matchingOrderNoms.has(String(item.orderNom || "")));
} else {
// Filter by item name or bill number
result = result.filter((item) => {
const nameMatch = String(item.name || "").toLowerCase().includes(q);
const billMatch = String(item.orderNom || "").toLowerCase().includes(q);
return nameMatch || billMatch;
});
}
}
return result;
}, [allItems, items, groupByOrder, filterMode, stopWords, searchQuery]);
// Group display items by orderNom (for grouped mode)
const groupedItems = React.useMemo(() => {
if (!groupByOrder) return null;
const groups = {};
for (const item of displayItems) {
const key = item.orderNom || "Без номера";
if (!groups[key]) groups[key] = [];
groups[key].push(item);
}
return groups;
}, [displayItems, groupByOrder]);
// Restore previously saved shipment data from order
const initialShippedIds = React.useMemo(() => {
@ -138,11 +192,17 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
const [comments, setComments] = React.useState(initialComments);
const [commentInput, setCommentInput] = React.useState("");
// Sync state when order data changes (e.g. after save)
// Sync state ONLY when order ID changes (navigating to different order), not on every order data update
const orderId = order?.id;
const prevOrderId = React.useRef(orderId);
React.useEffect(() => {
setShippedItems(initialShippedIds);
setComments(initialComments);
}, [initialShippedIds, initialComments]);
if (prevOrderId.current !== orderId) {
prevOrderId.current = orderId;
setShippedItems(initialShippedIds);
setComments(initialComments);
setJustSaved(false);
}
}, [orderId, initialShippedIds, initialComments]);
// Track last saved shipment data for visual display
React.useEffect(() => {
@ -165,12 +225,13 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
};
const shipAll = () => {
setShippedItems(new Set(items.map((i) => i.id)));
const targetItems = groupByOrder ? displayItems : items;
setShippedItems(new Set(targetItems.map((i) => i.id)));
setComments({});
};
const currentDeliveryStatus = order?.deliveryStatus || order?.delivery_status;
const isStatusFinal = ["delivered", "problem", "picked_up"].includes(currentDeliveryStatus);
const isStatusFinal = ["delivered", "problem", "picked_up", "loaded", "on_route", "driver_assigned"].includes(currentDeliveryStatus);
const unshipAll = () => {
if (isStatusFinal && onResetStatus) {
@ -190,6 +251,15 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
}
};
const toggleGroup = (key) => {
setCollapsedGroups((prev) => {
const current = prev === null ? new Set(Object.keys(groupedItems || {})) : new Set(prev);
if (current.has(key)) current.delete(key);
else current.add(key);
return current;
});
};
const shippedCount = items.filter((i) => shippedItems.has(i.id)).length;
const unshippedCount = items.length - shippedCount;
const allShipped = items.length > 0 && shippedCount === items.length;
@ -220,24 +290,65 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
}
}, [items.length, shippedCount, unshippedCount, unshippedWithoutComment, allShipped, shippedItems, comments, onShipmentChange]);
const renderItem = (item) => {
const isShipped = shippedItems.has(item.id);
const hasComment = !isShipped && comments[item.id]?.trim();
return (
<div
key={item.id}
className={[
"rounded-[18px] border px-4 py-3 text-sm transition",
isShipped
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"
: hasComment
? "border-[var(--color-warning)] bg-[var(--color-warning-soft)]"
: "border-[var(--color-border)] bg-[var(--color-surface-strong)]",
].join(" ")}
>
<label className="flex cursor-pointer items-start gap-3">
<input
type="checkbox"
checked={isShipped}
onChange={() => toggleItem(item.id)}
className="mt-0.5 h-4 w-4 flex-shrink-0 accent-[var(--color-accent)]"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<span className={isShipped ? "text-[var(--color-text-muted)]" : "text-[var(--color-text)]"}>
{item.name}
</span>
{(item.quantity || item.unit) ? (
<Badge tone="neutral">{[item.quantity, item.unit].filter(Boolean).join(" ")}</Badge>
) : null}
</div>
{!isShipped && (
<input
type="text"
placeholder="Причина неотгрузки (дефект, нет в наличии...)"
value={comments[item.id] || ""}
onChange={(e) =>
setComments((prev) => ({ ...prev, [item.id]: e.target.value }))
}
className="mt-2 w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)] focus:outline-none"
/>
)}
</div>
</label>
</div>
);
};
if (items.length === 0) {
return (
<Panel className="space-y-3 p-5">
<strong>Состав заказа</strong>
<div className="space-y-3">
<p className="text-sm text-[var(--color-text-muted)]">Позиции не указаны</p>
</Panel>
</div>
);
}
return (
<Panel className="space-y-4 p-5">
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<strong>Отгрузка</strong>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
Отметьте позиции, которые отгружены. Для смены статуса на «Доставлено» все позиции должны быть отгружены.
</p>
</div>
<div className="flex items-center gap-2 text-sm">
<Badge tone={allShipped ? "accent" : "neutral"}>
{shippedCount}/{items.length} отгружено
@ -245,12 +356,48 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
</div>
</div>
{groupByOrder && (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="flex gap-2 sm:flex-shrink-0">
<button
type="button"
className={["rounded-xl px-3 py-2 text-sm font-semibold transition",
filterMode === "all"
? "bg-[var(--color-accent)] text-[var(--color-accent-contrast)]"
: "text-[var(--color-text-muted)] hover:bg-[var(--color-accent-soft)] border border-[var(--color-border)]"
].join(" ")}
onClick={() => setFilterMode("all")}
>
Все
</button>
<button
type="button"
className={["rounded-xl px-3 py-2 text-sm font-semibold transition",
filterMode === "stop_only"
? "bg-[var(--color-accent)] text-[var(--color-accent-contrast)]"
: "text-[var(--color-text-muted)] hover:bg-[var(--color-accent-soft)] border border-[var(--color-border)]"
].join(" ")}
onClick={() => setFilterMode("stop_only")}
>
Со стоп-словами
</button>
</div>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Поиск по счёту или позиции..."
className="w-full rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-2 text-sm !text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)] focus:outline-none sm:flex-1"
/>
</div>
)}
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={shipAll} disabled={allShipped}>
Отгрузить всё
</Button>
<Button variant="ghost" size="sm" onClick={unshipAll} disabled={shippedCount === 0 && !isStatusFinal}>
Сбросить
Сбросить отгрузку
</Button>
</div>
@ -279,55 +426,65 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
</div>
)}
<div className="space-y-2">
{items.map((item) => {
const isShipped = shippedItems.has(item.id);
const hasComment = !isShipped && comments[item.id]?.trim();
return (
<div
key={item.id}
className={[
"rounded-[18px] border px-4 py-3 text-sm transition",
isShipped
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"
: hasComment
? "border-[var(--color-warning)] bg-[var(--color-warning-soft)]"
: "border-[var(--color-border)] bg-[var(--color-surface-strong)]",
].join(" ")}
>
<label className="flex cursor-pointer items-start gap-3">
<input
type="checkbox"
checked={isShipped}
onChange={() => toggleItem(item.id)}
className="mt-0.5 h-4 w-4 flex-shrink-0 accent-[var(--color-accent)]"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<span className={isShipped ? "text-[var(--color-text-muted)]" : "text-[var(--color-text)]"}>
{item.name}
</span>
{(item.quantity || item.unit) ? (
<Badge tone="neutral">{[item.quantity, item.unit].filter(Boolean).join(" ")}</Badge>
) : null}
</div>
{!isShipped && (
<input
type="text"
placeholder="Причина неотгрузки (дефект, нет в наличии...)"
value={comments[item.id] || ""}
onChange={(e) =>
setComments((prev) => ({ ...prev, [item.id]: e.target.value }))
}
className="mt-2 w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)] focus:outline-none"
/>
{groupByOrder ? (
/* === Grouped by order (счёту) === */
<div className="space-y-3">
{displayItems.length === 0 ? (
<p className="text-sm text-[var(--color-text-muted)] italic">
{filterMode === "stop_only" ? "Нет позиций со стоп-словами" : searchQuery.trim() ? "Ничего не найдено" : "Нет позиций для отображения"}
</p>
) : (
Object.entries(groupedItems).map(([orderNom, groupItems]) => {
const isCollapsed = collapsedGroups === null ? true : collapsedGroups.has(orderNom);
const groupShipped = groupItems.filter((i) => shippedItems.has(i.id)).length;
return (
<div
key={orderNom}
className="rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] overflow-hidden"
>
<button
type="button"
className="flex w-full items-center justify-between gap-2 px-4 py-3 text-left transition hover:bg-[var(--color-accent-soft)]"
onClick={() => toggleGroup(orderNom)}
>
<div className="flex items-center gap-2">
<span className="rounded-full bg-[var(--color-accent-soft)] px-3 py-1 text-sm font-semibold text-[var(--color-accent)]">
Счёт {orderNom}
</span>
<span className="text-sm text-[var(--color-text-muted)]">
· {groupItems.length} поз.
</span>
{groupShipped === groupItems.length && groupItems.length > 0 && (
<span className="text-xs text-[var(--color-accent)]"></span>
)}
</div>
<svg
className="h-4 w-4 flex-shrink-0 text-[var(--color-text-muted)] transition-transform"
style={{ transform: isCollapsed ? "rotate(0deg)" : "rotate(180deg)" }}
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="space-y-2 px-4 pb-4">
{groupItems.map((item) => renderItem(item))}
</div>
)}
</div>
</label>
</div>
);
})}
</div>
);
})
)}
</div>
) : (
/* === Flat list (driver mode — existing behavior) === */
<div className="space-y-2">
{items.map((item) => renderItem(item))}
</div>
)}
{unshippedCount > 0 && (
<div className="rounded-xl border border-[var(--color-warning)] bg-[var(--color-warning-soft)] p-3 text-sm">
@ -429,6 +586,6 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
</Button>
</div>
)}
</Panel>
</div>
);
};
};

View File

@ -18,11 +18,13 @@ import {
getOrderGroupDisplayStatusLabel,
getOrderGroupDisplayStatusValue,
getOrderGroupStatusTone,
getSmsProblemTone,
ORDER_GROUP_DISPLAY_STATUS_OPTIONS,
} from "../../services/orderGroupViews";
import { Badge } from "../UI/Badge";
import { Panel } from "../UI/Panel";
import { SkeletonPage } from "../UI/Loading";
import { Pagination } from "../UI/Pagination";
import { OrderFilters } from "../orders/OrderFilters";
import { formatDate, formatDateTime } from "../../utils/formatters";
@ -53,6 +55,9 @@ const DEFAULT_FUNNEL_ORDER = [
const STORAGE_KEY = "logistics-section-order";
const COLLAPSED_KEY = "logistics-section-collapsed";
const PAGE_KEY = "logistics-board-page";
const SORT_KEY = "logistics-board-sort";
const PAGE_SIZE = 20;
// Load custom order from localStorage, merge with defaults
const loadCustomOrder = () => {
@ -94,19 +99,39 @@ const saveCollapsedSections = (collapsedSet) => {
}
};
// 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]";
// 8 columns: Клиент | Город | Тип | Дата | Водитель | Статус | Обновлён | Добавлен в базу
const COLS = "grid-cols-[minmax(110px,1.5fr)_minmax(80px,1fr)_minmax(95px,1fr)_minmax(95px,1fr)_minmax(90px,1fr)_minmax(95px,1fr)_minmax(90px,1fr)_minmax(90px,1fr)]";
const MIN_W = "min-w-[1060px]";
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>
const TABLE_COLUMNS = [
{ key: "customer", label: "Клиент" },
{ key: "city", label: "Город" },
{ key: "deliveryType", label: "Тип" },
{ key: "deliveryDate", label: "Доставка" },
{ key: "driver", label: "Водитель" },
{ key: "status", label: "Статус" },
{ key: "updatedAt", label: "Обновлён" },
{ key: "createdAt", label: "Добавлен" },
];
const TableHeader = ({ sort, onSort }) => (
<div className={`grid ${COLS} gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]`}>
{TABLE_COLUMNS.map((column) => {
const active = sort.key === column.key;
return (
<button
key={column.key}
type="button"
onClick={() => onSort(column.key)}
className={`flex min-w-0 items-center gap-1 whitespace-nowrap px-3 py-1.5 text-left font-bold transition hover:text-[var(--color-text)] ${active ? "text-[var(--color-text)]" : ""}`}
title={`Сортировать: ${column.label.toLowerCase()}`}
aria-label={`Сортировать по колонке «${column.label}»`}
>
<span>{column.label}</span>
<span className="flex-shrink-0" aria-hidden="true">{active ? (sort.direction === "asc" ? "↑" : "↓") : "↕"}</span>
</button>
);
})}
</div>
);
@ -120,72 +145,104 @@ const isStale = (group) => {
return diff > 24 * 60 * 60 * 1000;
};
const isLinkOpened = (group) => !!(group.invitationOpenedAt || (group.invitationAccessCount && group.invitationAccessCount > 0));
const getDatabaseAge = (group) => {
if (!group.createdAt) return { days: null, rowClass: "", textClass: "text-[var(--color-text-muted)]" };
const created = new Date(group.createdAt).getTime();
if (!Number.isFinite(created)) return { days: null, rowClass: "", textClass: "text-[var(--color-text-muted)]" };
const days = Math.max(0, Math.floor((Date.now() - created) / 86400000));
return { days, rowClass: "", textClass: "text-[var(--color-text-muted)]" };
};
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">
<div className="flex items-center gap-1.5">
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge>
{(group.hasDeliveryProblem || group.has_delivery_problem) && (
<span
title={group.deliveryProblemNote || group.delivery_problem_note || "Есть проблемы с отгрузкой позиций"}
className="inline-flex items-center gap-0.5 rounded-full bg-[rgba(239,68,68,0.12)] px-1.5 py-0.5 text-[10px] font-bold text-[var(--color-danger)]"
>
Проблема
// Not agreed = still waiting for customer confirmation / no delivery settled.
// These statuses mean the order has not been agreed yet.
const NOT_AGREED_STATUSES = new Set([
"delivery:pending_confirmation",
"delivery:manual_confirmation_required",
"status:manual_required",
"status:not_started",
"status:link_ready",
"status:pending_confirmation",
"status:unknown",
]);
const isNotAgreed = (group) => NOT_AGREED_STATUSES.has(getOrderGroupDisplayStatusValue(group));
// Red highlight: not agreed and 2+ days since the order was added to the system.
const getNotAgreedHighlight = (group) => {
if (!isNotAgreed(group)) return "";
const created = group.createdAt ? new Date(group.createdAt).getTime() : 0;
if (!Number.isFinite(created)) return "";
const days = Math.max(0, Math.floor((Date.now() - created) / 86400000));
return days >= 2 ? "bg-[rgba(239,68,68,0.12)]" : "";
};
const SMS_TAG = {
not_sent: { label: "SMS не отправлено", cls: "bg-[rgba(239,68,68,0.14)] text-[var(--color-danger)]" },
not_delivered: { label: "SMS не доставлено", cls: "bg-[rgba(245,158,11,0.14)] text-[var(--color-warning)]" },
};
const renderRow = (g, onSelectSet) => {
const statusTone = getOrderGroupStatusTone(g);
const stale = isStale(g);
const databaseAge = getDatabaseAge(g);
const smsTone = getSmsProblemTone(g);
const smsTag = smsTone !== "ok" ? SMS_TAG[smsTone] : null;
const notAgreedHighlight = getNotAgreedHighlight(g);
return (
<button
type="button"
key={g.id}
onClick={() => onSelectSet(g.id)}
className={`grid ${COLS} ${MIN_W} w-full border-t border-[var(--color-border)] text-left transition hover:bg-[var(--color-accent-soft)] ${
notAgreedHighlight || (stale ? "bg-[rgba(245,158,11,0.06)]" : "")
}`}
>
<div className="min-w-0 px-3 py-2.5">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-sm font-medium text-[var(--color-text)] truncate">
{g.customerName || g.groupKey || "—"}
</span>
)}
{smsTag && (
<span className={`inline-flex items-center whitespace-nowrap rounded-full px-2 py-0.5 text-[10px] font-semibold ${smsTag.cls}`} title={smsTag.label}>
{smsTag.label}
</span>
)}
{stale && (
<span title="Обновлён >24ч назад" className="inline-block h-2 w-2 flex-shrink-0 rounded-full bg-[var(--color-warning)]"></span>
)}
</div>
<div className="mt-0.5 text-xs text-[var(--color-text-muted)] truncate">
{(g.allBillNumbers || g.orderNumbers || []).join(", ") || "—"}
</div>
</div>
</div>
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
{formatDateTime(group.updatedAt)}
</div>
</button>
);
<div className="px-3 py-2.5 text-sm text-[var(--color-text-muted)]">{g.city || "—"}</div>
<div className="px-3 py-2.5 text-xs">
<span className={`inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2 py-0.5 font-medium ${
g.deliveryType === "pickup"
? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]"
: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]"
}`}>
{g.deliveryType === "pickup" ? "🏪 Самовывоз" : "🚚 Доставка"}
</span>
</div>
<div className="px-3 py-2.5 text-sm text-[var(--color-text-muted)]">
{g.deliveryDate ? fmtDate(g.deliveryDate) : "—"}
{g.deliveryTime ? <span className="ml-1 text-xs text-[var(--color-text-muted)]">{g.deliveryTime}</span> : null}
</div>
<div className="px-3 py-2.5 text-sm text-[var(--color-text-muted)]">{g.assignedDriverName || "—"}</div>
<div className="px-3 py-2.5">
<Badge tone={statusTone}>{getOrderGroupDisplayStatusLabel(g)}</Badge>
</div>
<div className="px-3 py-2.5 text-xs text-[var(--color-text-muted)]">{formatDateTime(g.updatedAt)}</div>
<div className={`px-3 py-2.5 text-xs ${databaseAge.textClass}`}>
<div>{formatDateTime(g.createdAt)}</div>
{databaseAge.days !== null ? <div className="mt-0.5">{databaseAge.days === 0 ? "сегодня" : `${databaseAge.days} дн. в базе`}</div> : null}
</div>
</button>
);
};
// Sortable section wrapper
const SortableSection = ({ statusValue, label, groups, isCollapsed, onToggle, onSelectSet }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: statusValue });
const SortableSection = ({ statusValue, label, groups, isCollapsed, onToggle, onSelectSet, sort, onSort }) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: statusValue });
const style = {
transform: CSS.Transform.toString(transform),
@ -194,58 +251,35 @@ const SortableSection = ({ statusValue, label, groups, isCollapsed, onToggle, on
};
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 */}
<div ref={setNodeRef} style={style} className="rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden">
<div className="flex items-center gap-2 border-b border-[var(--color-border)] px-4 py-3">
<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}
className="cursor-grab touch-none text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
aria-label="Перетащить секцию"
>
<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 className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M7 4a1 1 0 11-2 0 1 1 0 012 0zM7 10a1 1 0 11-2 0 1 1 0 012 0zM7 16a1 1 0 11-2 0 1 1 0 012 0zM15 4a1 1 0 11-2 0 1 1 0 012 0zM15 10a1 1 0 11-2 0 1 1 0 012 0zM15 16a1 1 0 11-2 0 1 1 0 012 0z" />
</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}
className="flex flex-1 items-center justify-between text-left"
>
<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>
<span className="text-sm font-semibold text-[var(--color-text)]">{label}</span>
<span className="flex items-center gap-2">
<Badge tone="neutral">{groups.length}</Badge>
<span className="text-xs text-[var(--color-text-muted)]">{isCollapsed ? "▶" : "▼"}</span>
</span>
</button>
</div>
{!isCollapsed && (
<div className="overflow-x-auto">
<div className={MIN_W}>
<TableHeader />
<TableHeader sort={sort} onSort={onSort} />
{groups.map((g) => renderRow(g, onSelectSet))}
</div>
</div>
@ -254,13 +288,67 @@ const SortableSection = ({ statusValue, label, groups, isCollapsed, onToggle, on
);
};
// Check if any filter is active
const isFilterActive = (filters) => {
if (!filters) return false;
if (filters.query && filters.query.trim()) return true;
if (filters.displayStatus && filters.displayStatus !== "all") return true;
if (filters.city && filters.city.trim()) return true;
if (filters.deliveryStatus && filters.deliveryStatus !== "all") return true;
if (filters.deliveryHalfDay && filters.deliveryHalfDay !== "all") return true;
if (filters.smsStatus && filters.smsStatus !== "all") return true;
if (filters.dateFrom) return true;
if (filters.dateTo) return true;
return 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_KEY = 'logistics-board-filters';
const [filters, setFilters] = React.useState(() => {
try {
const raw = localStorage.getItem(FILTERS_KEY);
if (raw) return { query: '', displayStatus: 'all', city: '', ...JSON.parse(raw) };
} catch {}
return { query: '', displayStatus: 'all', city: '' };
});
React.useEffect(() => {
try { localStorage.setItem(FILTERS_KEY, JSON.stringify(filters)); } catch {}
}, [filters]);
const [collapsedSections, setCollapsedSections] = React.useState(() => loadCollapsedSections());
const [sectionOrder, setSectionOrder] = React.useState(() => {
const custom = loadCustomOrder();
return custom || [...DEFAULT_FUNNEL_ORDER];
});
const [sort, setSort] = React.useState(() => {
try {
const saved = JSON.parse(localStorage.getItem(SORT_KEY) || "null");
if (saved?.key && ["asc", "desc"].includes(saved.direction)) return saved;
} catch {}
return { key: "createdAt", direction: "desc" };
});
const handleSort = React.useCallback((key) => {
setSort((current) => {
const next = {
key,
direction: current.key === key && current.direction === "asc" ? "desc" : "asc",
};
try { localStorage.setItem(SORT_KEY, JSON.stringify(next)); } catch {}
return next;
});
setPage(1);
}, []);
// Pagination state
const [page, setPage] = React.useState(() => {
try {
const saved = localStorage.getItem(PAGE_KEY);
if (saved) return Math.max(1, parseInt(saved, 10) || 1);
} catch {}
return 1;
});
React.useEffect(() => {
try { localStorage.setItem(PAGE_KEY, String(page)); } catch {}
}, [page]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
@ -278,10 +366,60 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
() => filterOrderGroups(orderGroups, filters),
[filters, orderGroups],
);
const rankedGroups = React.useMemo(() => {
const direction = sort.direction === "asc" ? 1 : -1;
const text = (value) => String(value || "").trim().toLocaleLowerCase("ru");
const time = (value) => {
const parsed = value ? new Date(value).getTime() : 0;
return Number.isFinite(parsed) ? parsed : 0;
};
const valueFor = (group) => {
switch (sort.key) {
case "customer": return text(group.customerName || group.groupKey);
case "city": return text(group.city);
case "deliveryType": return text(group.deliveryType === "pickup" ? "Самовывоз" : "Доставка");
case "deliveryDate": return time(group.deliveryDate);
case "driver": return text(group.assignedDriverName);
case "status": return text(getOrderGroupDisplayStatusLabel(group));
case "updatedAt": return time(group.updatedAt);
case "createdAt":
default: return time(group.createdAt);
}
};
return [...filteredGroups].sort((a, b) => {
const aValue = valueFor(a);
const bValue = valueFor(b);
const result = typeof aValue === "string"
? aValue.localeCompare(bValue, "ru", { numeric: true, sensitivity: "base" })
: aValue - bValue;
return result * direction;
});
}, [filteredGroups, sort]);
// Determine if filters are active if so, show all results (no pagination)
const hasActiveFilters = isFilterActive(filters);
// Reset to page 1 when filters change
React.useEffect(() => {
setPage(1);
}, [filters.query, filters.displayStatus, filters.city, filters.deliveryStatus, filters.deliveryHalfDay, filters.smsStatus, filters.dateFrom, filters.dateTo]);
const totalGroups = rankedGroups.length;
const totalPages = Math.max(1, Math.ceil(totalGroups / PAGE_SIZE));
// Clamp page if totalGroups decreased
const currentPage = Math.min(page, totalPages);
// Paginate: when no active filters, slice to current page
const paginatedGroups = React.useMemo(() => {
if (hasActiveFilters) return rankedGroups;
const start = (currentPage - 1) * PAGE_SIZE;
return rankedGroups.slice(start, start + PAGE_SIZE);
}, [rankedGroups, currentPage, hasActiveFilters]);
const statusGroups = React.useMemo(() => {
const map = new Map();
for (const group of filteredGroups) {
for (const group of paginatedGroups) {
const statusValue = getOrderGroupDisplayStatusValue(group);
if (!map.has(statusValue)) {
const label = getOrderGroupDisplayStatusLabel(group);
@ -290,9 +428,7 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
map.get(statusValue).groups.push(group);
}
return map;
}, [filteredGroups]);
const totalGroups = filteredGroups.length;
}, [paginatedGroups]);
// Build sorted list: use sectionOrder for known statuses, append unknown ones at end
const sortedEntries = React.useMemo(() => {
@ -360,6 +496,12 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
statusOptions={statusOptions}
cities={cities}
/>
{!hasActiveFilters && totalGroups > PAGE_SIZE && (
<p className="text-xs text-[var(--color-text-muted)]">
Показано {Math.min(currentPage * PAGE_SIZE, totalGroups)} из {totalGroups} · по {PAGE_SIZE} на странице
</p>
)}
</Panel>
{!totalGroups ? (
@ -400,6 +542,8 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
});
}}
onSelectSet={onSelectSet}
sort={sort}
onSort={handleSort}
/>
);
})}
@ -407,6 +551,16 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
</SortableContext>
</DndContext>
)}
{!hasActiveFilters && totalGroups > PAGE_SIZE && (
<Pagination
page={currentPage}
totalPages={totalPages}
onChange={setPage}
itemsPerPage={PAGE_SIZE}
totalItems={totalGroups}
/>
)}
</div>
);
};

View File

@ -61,7 +61,7 @@ export function NotificationBell({ notifications, unreadCount, onMarkAsRead, onM
</button>
{isOpen && (
<div className="absolute right-0 top-full z-50 mt-2 w-80 sm:w-96">
<div className="fixed right-3 top-14 z-[100] mt-2 w-[calc(100vw-1.5rem)] max-w-96 sm:absolute sm:right-0 sm:top-full sm:mt-2 sm:w-96">
<Panel className="max-h-[480px] overflow-hidden p-0 shadow-xl">
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-4 py-3">
<h3 className="text-sm font-semibold">Уведомления</h3>

View File

@ -58,7 +58,7 @@ const CalendarWidget = ({
}) => {
return (
<div className={layoutClassName}>
<div className={calendarClassName}>
<div className={(calendarClassName || "") + " relative"}>
<Button
variant="ghost"
aria-label={label}

View File

@ -0,0 +1,125 @@
import React, { useState } from "react";
import { supabase } from "../../supabaseClient";
import { Button } from "../UI/Button";
const CalledPanel = ({ order, onUpdate }) => {
const [busy, setBusy] = useState(false);
const [editing, setEditing] = useState(false);
const [comment, setComment] = useState(order?.called_comment || "");
const [callDate, setCallDate] = useState(() => {
if (!order?.called_at) return "";
const d = new Date(order.called_at);
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
});
const [callTime, setCallTime] = useState(() => {
if (!order?.called_at) return "";
const d = new Date(order.called_at);
return `${String(d.getHours()).padStart(2,"0")}:${String(d.getMinutes()).padStart(2,"0")}`;
});
const isCalled = !!order?.called;
const formatCalledAt = (ts) => {
if (!ts) return "";
try {
return new Date(ts).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" });
} catch { return ""; }
};
const save = async (newCalled) => {
if (busy || !order?.id) return;
setBusy(true);
try {
let calledAt = null;
if (newCalled) {
if (callDate && callTime) {
calledAt = new Date(`${callDate}T${callTime}:00`).toISOString();
} else {
calledAt = new Date().toISOString();
}
}
const { error } = await supabase
.from("order_groups")
.update({ called: newCalled, called_at: calledAt, called_comment: newCalled ? comment.trim() : null })
.eq("id", order.id);
if (error) {
console.warn("[CalledPanel] update error", error);
} else {
setEditing(false);
if (onUpdate) await onUpdate();
}
} finally {
setBusy(false);
}
};
// Form mode: date + time + comment
if (editing) {
return (
<div className="space-y-3 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4">
<div className="flex flex-wrap gap-3">
<div>
<label className="block text-xs font-medium text-[var(--color-text-muted)] mb-1">Дата звонка</label>
<input type="date" value={callDate} onChange={(e) => setCallDate(e.target.value)}
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-sm !text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none" />
</div>
<div>
<label className="block text-xs font-medium text-[var(--color-text-muted)] mb-1">Время звонка</label>
<input type="time" value={callTime} onChange={(e) => setCallTime(e.target.value)}
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-sm !text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none" />
</div>
</div>
<div>
<label className="block text-xs font-medium text-[var(--color-text-muted)] mb-1">Комментарий</label>
<textarea value={comment} onChange={(e) => setComment(e.target.value)} placeholder="Результат звонка..." rows={2}
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-sm !text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)] focus:outline-none resize-none" />
</div>
<div className="flex flex-wrap gap-2">
<Button variant="primary" size="sm" onClick={() => save(true)} disabled={busy}>
{busy ? "Сохраняем..." : "Сохранить"}
</Button>
{isCalled && (
<Button variant="ghost" size="sm" onClick={() => save(false)} disabled={busy}>Сбросить</Button>
)}
<Button variant="secondary" size="sm" onClick={() => setEditing(false)} disabled={busy}>Отмена</Button>
</div>
</div>
);
}
// Default mode: buttons like status delivery
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
{isCalled ? (
<>
<Button variant="primary" size="sm" onClick={() => setEditing(true)} disabled={busy}>
📞 Звонили
</Button>
<Button variant="secondary" size="sm" onClick={() => save(false)} disabled={busy}>
Не звонили
</Button>
{order?.called_at && (
<span className="text-xs text-[var(--color-text-muted)]">{formatCalledAt(order.called_at)}</span>
)}
{order?.called_comment && (
<span className="text-xs text-[var(--color-text-muted)] italic">«{order.called_comment}»</span>
)}
</>
) : (
<>
<Button variant="ghost" size="sm" onClick={() => setEditing(true)} disabled={busy}>
📞 Звонили
</Button>
<Button variant="primary" size="sm" disabled>
Не звонили
</Button>
</>
)}
</div>
</div>
);
};
export { CalledPanel };
export default CalledPanel;

View File

@ -0,0 +1,53 @@
import React, { useState } from "react";
import { supabase } from "../../supabaseClient";
export const CalledToggle = ({ order, onUpdate }) => {
const [busy, setBusy] = useState(false);
const current = !!order?.called;
const toggle = async () => {
if (busy || !order?.id) return;
setBusy(true);
try {
const { error } = await supabase
.from("order_groups")
.update({ called: !current, called_at: new Date().toISOString() })
.eq("id", order.id);
if (error) {
console.warn("[CalledToggle] update error", error);
} else if (onUpdate) {
await onUpdate();
}
} finally {
setBusy(false);
}
};
if (current) {
return (
<button
type="button"
onClick={toggle}
disabled={busy}
className="inline-flex items-center gap-2 rounded-2xl border border-[rgba(18,128,92,0.28)] bg-[var(--color-accent-soft)] px-4 py-2 text-sm font-semibold text-[var(--color-accent)] transition hover:opacity-80 active:opacity-60 disabled:opacity-50"
title="Отметить как «не звонили»"
>
📞 Звонили
</button>
);
}
return (
<button
type="button"
onClick={toggle}
disabled={busy}
className="inline-flex items-center gap-2 rounded-2xl border border-[rgba(245,158,11,0.35)] bg-[rgba(245,158,11,0.12)] px-4 py-2 text-sm font-semibold text-[rgb(180,120,20)] transition hover:opacity-80 active:opacity-60 disabled:opacity-50"
title="Отметить как «звонили»"
>
📞 Не звонили
</button>
);
};
export default CalledToggle;

View File

@ -122,7 +122,7 @@ const DriverAssignmentPanel = ({
Водитель назначен
</p>
<p className="mt-1 text-lg font-semibold">
{order.assignedDriverName || "Неизвестно"}
{order.assignedDriverName || drivers.find((d) => d.id === order.assignedDriverId)?.name || "Водитель назначен"}
</p>
</div>
<Badge tone="accent">Назначен</Badge>

File diff suppressed because it is too large Load Diff

View File

@ -120,6 +120,12 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities =
{ value: "pickup", label: "Самовывоз" },
];
const smsProblemOptions = [
{ value: "all", label: "Проблемы SMS" },
{ value: "problem_not_sent", label: "SMS не отправлено" },
{ value: "problem_not_delivered", label: "SMS не доставлено" },
];
return (
<Panel className="p-4">
<div className="flex flex-col gap-3">
@ -190,6 +196,13 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities =
placeholder="Тип получения"
/>
<DropdownSelect
value={filters.smsStatus || "all"}
options={smsProblemOptions}
onChange={(v) => updateFilter("smsStatus", v)}
placeholder="Проблемы SMS"
/>
<Input
className="h-[46px] py-0"
placeholder="Поиск по группе, клиенту или телефону"

View File

@ -0,0 +1,713 @@
import React, { useEffect, useState, useMemo } from "react";
import { supabase } from "../../supabaseClient";
// Status labels
const STATUS_LABELS = {
pending_confirmation: "Ожидает согласования",
first_sms_sent: "1-е SMS отправлено",
second_sms_sent: "2-е SMS отправлено",
second_sms_sending: "Отправка 2-го SMS",
first_sms_sending: "Отправка 1-го SMS",
sms_sending: "Отправка SMS",
manual_required: "Требуется ручное согласование",
manual_confirmation_required: "Требуется ручное согласование",
ready_to_launch: "Готов к отправке",
ready_for_notification: "Готов к отправке",
link_ready: "Ссылка готова",
not_started: "Не начато",
confirmed: "Подтверждено",
agreed: "Согласовано",
driver_assigned: "Водитель назначен",
loaded: "Загружено",
on_route: "В пути",
delivered: "Доставлено",
picked_up: "Вывезено",
problem: "Проблема",
cancelled: "Отменено",
paid_storage: "Платное хранение",
pickup: "Самовывоз",
null: "—",
};
// Field labels for changes
const FIELD_LABELS = {
delivery_status: "Статус доставки",
notification_status: "Статус уведомления",
first_sms_sent_at: "Время 1-го SMS",
second_sms_sent_at: "Время 2-го SMS",
manual_confirmation_at: "Ручное согласование",
paid_storage_at: "Платное хранение",
has_delivery_problem: "Проблема доставки",
delivery_problem_note: "Описание проблемы",
assigned_driver_id: "Водитель",
delivery_date: "Дата доставки",
delivery_time: "Время доставки",
delivery_address: "Адрес доставки",
called: "Статус звонка",
called_at: "Время звонка",
called_comment: "Комментарий к звонку",
};
// Specific human-readable labels for field_change entries
const FIELD_CHANGE_LABELS = {
first_sms_sent_at: "Обновлено время отправки 1-го SMS",
second_sms_sent_at: "Обновлено время отправки 2-го SMS",
manual_confirmation_at: "Ручное согласование",
paid_storage_at: "Платное хранение",
has_delivery_problem: "Отметка проблемы доставки",
delivery_problem_note: "Описание проблемы",
assigned_driver_id: "Назначение водителя",
delivery_date: "Дата доставки",
delivery_time: "Время доставки",
delivery_address: "Адрес доставки",
called: "Статус звонка",
called_at: "Время звонка",
called_comment: "Комментарий к звонку",
delivery_status: "Статус доставки",
notification_status: "Статус уведомления",
};
// Fields to SKIP (redundant or noisy)
const SKIP_FIELDS = new Set([
"status",
"group_key",
"delivery_link",
"delivery_link_code",
"delivery_invitation_id",
"next_notification_check_at",
"sms_attempts",
"last_sms_error",
"updated_at",
"can_launch_invitation",
"source",
]);
const ACTION_LABELS = {
delivery_status_change: "Изменение статуса доставки",
notification_status_change: "Изменение статуса уведомления",
status_change: "Изменение статуса",
field_change: "Изменение данных заказа",
};
const SMS_CAMPAIGN_LABELS = {
first_sms: "Отправка 1-го SMS",
second_sms: "Отправка 2-го SMS",
manual: "Ручная отправка SMS",
paid_storage: "SMS о платном хранении",
};
const SMS_STATUS_LABELS = {
delivered: "доставлено",
sent: "отправлено",
failed: "ошибка",
pending: "в очереди",
};
const fmtTimestamp = (ts) => {
if (!ts) return "—";
try {
return new Date(ts).toLocaleString("ru-RU", {
day: "2-digit", month: "2-digit", year: "numeric",
hour: "2-digit", minute: "2-digit",
});
} catch { return "—"; }
};
const translateValue = (v) => {
if (v === null || v === undefined) return "—";
if (typeof v === "string" && v.includes("T") && v.length > 10) {
return fmtTimestamp(v);
}
if (typeof v === "boolean") return v ? "да" : "нет";
return STATUS_LABELS[v] ?? String(v);
};
const translateField = (f) => FIELD_LABELS[f] ?? f;
const formatChanges = (changes) => {
if (!changes || typeof changes !== "object") return [];
const lines = [];
for (const [field, change] of Object.entries(changes)) {
if (SKIP_FIELDS.has(field)) continue;
if (change && typeof change === "object" && "old" in change && "new" in change) {
const oldVal = translateValue(change.old);
const newVal = translateValue(change.new);
if (oldVal !== newVal) {
lines.push({ field: translateField(field), oldVal, newVal });
}
}
}
return lines;
};
// Get specific label for field_change action based on what actually changed
const getFieldChangeLabel = (changes) => {
if (!changes || typeof changes !== "object") return null;
const keys = Object.keys(changes).filter((k) => !SKIP_FIELDS.has(k));
if (keys.length === 1 && FIELD_CHANGE_LABELS[keys[0]]) {
return FIELD_CHANGE_LABELS[keys[0]];
}
if (keys.length === 1) {
return `Изменение: ${translateField(keys[0])}`;
}
if (keys.length > 1) {
return `Изменено полей: ${keys.length}`;
}
return null;
};
// Progress Stepper
const STAGES_DELIVERY = [
{ key: "created", icon: "📥", label: "Добавлен" },
{ key: "sms", icon: "📨", label: "SMS" },
{ key: "agreed", icon: "✅", label: "Согласовано" },
{ key: "driver", icon: "🚚", label: "Водитель" },
{ key: "delivered", icon: "📦", label: "Доставлено" },
];
const STAGES_PICKUP = [
{ key: "created", icon: "📥", label: "Добавлен" },
{ key: "sms", icon: "📨", label: "SMS" },
{ key: "agreed", icon: "✅", label: "Согласовано" },
{ key: "picked_up", icon: "📦", label: "Вывезено" },
];
const getActiveStageIndex = (order) => {
const deliveryStatus = order?.deliveryStatus || order?.delivery_status || "";
const notifStatus = order?.notificationStatus || order?.notification_status || "";
const firstSms = order?.firstSmsSentAt || order?.first_sms_sent_at;
const isPickup = (order?.deliveryType || order?.delivery_type) === "pickup";
const stages = isPickup ? STAGES_PICKUP : STAGES_DELIVERY;
if (deliveryStatus === "cancelled") return -1;
if (deliveryStatus === "problem") return -2;
if (deliveryStatus === "paid_storage") return -3;
if (isPickup) {
if (deliveryStatus === "picked_up") return stages.length - 1;
} else {
if (deliveryStatus === "delivered") return stages.length - 1;
}
if (!isPickup && ["driver_assigned", "loaded", "on_route"].includes(deliveryStatus)) {
return 3;
}
if (deliveryStatus === "agreed" || notifStatus === "confirmed") {
return 2;
}
if (firstSms || ["first_sms_sent", "second_sms_sent", "confirmed"].includes(notifStatus)) {
return 1;
}
return 0;
};
const ProgressStepper = ({ order }) => {
const isPickup = (order?.deliveryType || order?.delivery_type) === "pickup";
const stages = isPickup ? STAGES_PICKUP : STAGES_DELIVERY;
const activeIdx = getActiveStageIndex(order);
if (activeIdx === -1) {
return (
<div className="flex items-center gap-2 rounded-xl bg-[rgba(239,68,68,0.08)] px-3 py-2">
<span className="text-sm"></span>
<span className="text-sm font-semibold text-[var(--color-danger)]">Заказ отменён</span>
</div>
);
}
if (activeIdx === -2) {
return (
<div className="flex items-center gap-2 rounded-xl bg-[rgba(239,68,68,0.08)] px-3 py-2">
<span className="text-sm"></span>
<span className="text-sm font-semibold text-[var(--color-danger)]">Проблема с заказом</span>
</div>
);
}
if (activeIdx === -3) {
return (
<div className="flex items-center gap-2 rounded-xl bg-[rgba(245,158,11,0.08)] px-3 py-2">
<span className="text-sm">📦</span>
<span className="text-sm font-semibold text-[var(--color-warning)]">Платное хранение</span>
</div>
);
}
return (
<div className="flex items-center gap-1">
{stages.map((stage, idx) => {
const isDone = idx < activeIdx;
const isActive = idx === activeIdx;
return (
<React.Fragment key={stage.key}>
{idx > 0 && (
<div className={`h-0.5 flex-1 min-w-[12px] rounded-full transition-colors ${
isDone ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"
}`} />
)}
<div className="flex flex-col items-center gap-1 flex-shrink-0">
<div className={`flex h-7 w-7 items-center justify-center rounded-full text-xs transition-all ${
isActive
? "bg-[var(--color-accent)] text-white ring-2 ring-[var(--color-accent)] ring-offset-2 ring-offset-[var(--color-surface)]"
: isDone
? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]"
: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]"
}`}>
{isDone ? "✓" : stage.icon}
</div>
<span className={`text-[10px] whitespace-nowrap ${
isActive
? "font-bold text-[var(--color-text)]"
: isDone
? "font-medium text-[var(--color-accent)]"
: "text-[var(--color-text-muted)]"
}`}>
{stage.label}
</span>
</div>
</React.Fragment>
);
})}
</div>
);
};
// Current Status Summary (compact card above timeline)
const StatusSummary = ({ order }) => {
const isPickup = (order?.deliveryType || order?.delivery_type) === "pickup";
const deliveryStatus = order?.deliveryStatus || order?.delivery_status || "";
const notifStatus = order?.notificationStatus || order?.notification_status || "";
const firstSms = order?.firstSmsSentAt || order?.first_sms_sent_at;
const secondSms = order?.secondSmsSentAt || order?.second_sms_sent_at;
const called = order?.called;
const calledAt = order?.calledAt || order?.called_at;
const calledComment = order?.calledComment || order?.called_comment;
const invitationOpened = order?.invitationOpenedAt || order?.invitationOpenedAt;
const invitationAccessCount = order?.invitationAccessCount || order?.invitationAccessCount;
const manualConfirmationAt = order?.manualConfirmationAt || order?.manual_confirmation_at;
const paidStorageAt = order?.paidStorageAt || order?.paid_storage_at;
const driverName = order?.assignedDriverName || order?.assignedDriverName;
const items = [];
// SMS status
if (firstSms) {
items.push({ icon: "📨", label: "1-е SMS", value: fmtTimestamp(firstSms), tone: "ok" });
} else {
items.push({ icon: "📨", label: "1-е SMS", value: "не отправлено", tone: "muted" });
}
if (secondSms) {
items.push({ icon: "📨", label: "2-е SMS", value: fmtTimestamp(secondSms), tone: "ok" });
}
// Invitation access
if (invitationOpened || invitationAccessCount > 0) {
const count = invitationAccessCount || 1;
items.push({
icon: "👁",
label: "Страница согласования",
value: `открыл ${count} раз${(order?.invitationLastAccessedAt || order?.invitationLastAccessedAt) ? `, последний: ${fmtTimestamp(order.invitationLastAccessedAt || order.invitationLastAccessedAt)}` : ""}`,
tone: "ok",
});
} else if (firstSms) {
items.push({ icon: "👁", label: "Страница согласования", value: "не открывал", tone: "warning" });
}
// Agreement
if (deliveryStatus === "agreed" || notifStatus === "confirmed") {
items.push({ icon: "✅", label: "Согласовано", value: "да", tone: "ok" });
} else if (manualConfirmationAt) {
items.push({ icon: "✋", label: "Ручное согласование", value: fmtTimestamp(manualConfirmationAt), tone: "warning" });
} else if (firstSms) {
items.push({ icon: "⏳", label: "Согласование", value: "ждёт ответа клиента", tone: "warning" });
}
// Call status
if (called) {
const parts = [fmtTimestamp(calledAt)];
if (calledComment) parts.push(`«${calledComment}»`);
items.push({ icon: "📞", label: "Звонок", value: parts.join(" · "), tone: "ok" });
} else {
items.push({ icon: "📞", label: "Звонок", value: "не звонили", tone: "muted" });
}
// Driver (delivery only)
if (!isPickup) {
if (driverName) {
items.push({ icon: "🚚", label: "Водитель", value: driverName, tone: "ok" });
} else if (deliveryStatus === "agreed" || notifStatus === "confirmed") {
items.push({ icon: "🚚", label: "Водитель", value: "не назначен", tone: "warning" });
}
}
// Paid storage
if (paidStorageAt) {
items.push({ icon: "📦", label: "Платное хранение", value: fmtTimestamp(paidStorageAt), tone: "warning" });
}
// Problem
if (deliveryStatus === "problem") {
items.push({ icon: "⚠️", label: "Проблема", value: order?.deliveryProblemNote || order?.delivery_problem_note || "есть", tone: "danger" });
}
if (items.length === 0) return null;
const toneStyles = {
ok: "border-[rgba(18,128,92,0.25)] bg-[var(--color-accent-soft)]",
warning: "border-[rgba(245,158,11,0.3)] bg-[rgba(245,158,11,0.06)]",
danger: "border-[rgba(239,68,68,0.3)] bg-[rgba(239,68,68,0.06)]",
muted: "border-[var(--color-border)] bg-[var(--color-surface)]",
};
const textStyles = {
ok: "text-[var(--color-accent)]",
warning: "text-[var(--color-warning)]",
danger: "text-[var(--color-danger)]",
muted: "text-[var(--color-text-muted)]",
};
return (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{items.map((item, i) => (
<div
key={i}
className={`flex items-center gap-2.5 rounded-xl border px-3 py-2.5 ${toneStyles[item.tone] || toneStyles.muted}`}
>
<span className="text-base flex-shrink-0">{item.icon}</span>
<div className="min-w-0 flex-1">
<p className="text-[11px] font-medium text-[var(--color-text-muted)] leading-tight">{item.label}</p>
<p className={`text-sm font-semibold truncate ${textStyles[item.tone] || textStyles.muted}`}>
{item.value}
</p>
</div>
</div>
))}
</div>
);
};
// Main component
export const OrderHistoryTimeline = ({ order, userRole }) => {
const [history, setHistory] = useState(null);
const [smsLogs, setSmsLogs] = useState([]);
const [pushLogs, setPushLogs] = useState([]);
const [pushSubscriptions, setPushSubscriptions] = useState([]);
const [cityProfile, setCityProfile] = useState(null);
const [invitation, setInvitation] = useState(null);
useEffect(() => {
let cancelled = false;
const fetchAll = async () => {
if (!order?.id || !supabase) return;
try {
const { data, error } = await supabase
.from("order_history")
.select("id, action, old_status, new_status, user_id, metadata, created_at")
.eq("order_group_id", order.id)
.order("created_at", { ascending: false })
.limit(50);
if (cancelled) return;
if (error) { setHistory([]); } else { setHistory(data || []); }
} catch { if (!cancelled) setHistory([]); }
try {
const { data: smsData, error: smsError } = await supabase
.from("sms_campaign_log")
.select("id, campaign_type, status, sms_text, error_message, created_at, updated_at, sent_to")
.eq("order_group_id", order.id)
.order("created_at", { ascending: false })
.limit(50);
if (cancelled) return;
if (!smsError) setSmsLogs(smsData || []);
} catch { if (!cancelled) setSmsLogs([]); }
// Push is stored on the client phone, so it is available for every current and future order.
const phoneNormalized = String(order?.customerPhoneNormalized || order?.customer_phone_normalized || order?.customerPhone || order?.customer_phone || "").replace(/\D/g, "").replace(/^8/, "7");
if (phoneNormalized) {
try {
const { data: pushData, error: pushError } = await supabase
.from("push_log")
.select("id, status, title, body, url, error_message, created_at")
.eq("phone_normalized", phoneNormalized)
.order("created_at", { ascending: false })
.limit(50);
if (cancelled) return;
if (!pushError) setPushLogs(pushData || []);
} catch { if (!cancelled) setPushLogs([]); }
try {
const { data: subscriptionData, error: subscriptionError } = await supabase
.from("push_subscriptions")
.select("id, is_active, created_at, last_sent_at, last_seen_at")
.eq("phone_normalized", phoneNormalized)
.order("created_at", { ascending: false });
if (cancelled) return;
if (!subscriptionError) setPushSubscriptions(subscriptionData || []);
} catch { if (!cancelled) setPushSubscriptions([]); }
} else {
setPushLogs([]);
setPushSubscriptions([]);
}
try {
const town = order?.town || order?.city || null;
if (town) {
const { data: cpData, error: cpError } = await supabase
.from("sms_city_profiles")
.select("city, second_sms_enabled, sms_enabled")
.eq("city", town)
.maybeSingle();
if (cancelled) return;
if (!cpError) setCityProfile(cpData || null);
}
} catch { if (!cancelled) setCityProfile(null); }
try {
const { data: invData, error: invError } = await supabase
.from("delivery_invitations")
.select("id, sent_at, opened_at, confirmed_at, access_count, last_accessed_at")
.eq("order_group_id", order.id)
.order("created_at", { ascending: false })
.limit(1);
if (cancelled) return;
if (!invError) setInvitation((invData && invData.length > 0) ? invData[0] : null);
} catch { if (!cancelled) setInvitation(null); }
};
fetchAll();
return () => { cancelled = true; };
}, [order?.id]);
const allEvents = useMemo(() => {
const events = [];
if (history && history.length > 0) {
for (const h of history) {
const changes = formatChanges(h.metadata?.changes);
// For field_change, generate a specific label instead of generic "Изменение данных заказа"
let label = ACTION_LABELS[h.action] ?? h.action;
if (h.action === "field_change") {
const specificLabel = getFieldChangeLabel(h.metadata?.changes);
if (specificLabel) label = specificLabel;
}
// For field_change: skip noisy system rewrites of sms timestamps (they are
// already shown as separate "SMS доставлена" events). Other changes keep their label.
const smsRewriteFields = ["first_sms_sent_at", "second_sms_sent_at"];
if (h.action === "field_change") {
const keys = Object.keys(h.metadata?.changes || {}).filter((k) => !SKIP_FIELDS.has(k));
if (keys.length === 1 && smsRewriteFields.includes(keys[0])) {
continue; // system update of delivery timestamp redundant
}
}
events.push({
id: `hist-${h.id}`,
created_at: h.created_at,
type: "history",
label,
changes,
});
}
}
if (smsLogs && smsLogs.length > 0) {
for (const s of smsLogs) {
const text = s.sms_text ? (s.sms_text.length > 100 ? s.sms_text.slice(0, 100) + "…" : s.sms_text) : "";
const isDelivered = s.status === "delivered";
const deliveryTime = isDelivered ? (s.updated_at || s.created_at) : null;
// sent time is created_at
events.push({
id: `sms-${s.id}`,
created_at: s.created_at,
type: "sms",
label: SMS_CAMPAIGN_LABELS[s.campaign_type] ?? `SMS (${s.campaign_type || "—"})`,
smsStatus: SMS_STATUS_LABELS[s.status] ?? s.status ?? "—",
smsText: text,
smsError: s.error_message || null,
smsSentAt: s.created_at,
smsDeliveredAt: deliveryTime,
isDelivered,
});
// Separate delivery-confirmation event with the actual delivery time
if (isDelivered) {
events.push({
id: `sms-delivered-${s.id}`,
created_at: deliveryTime,
type: "sms_delivered",
label: "SMS доставлена (подтверждение)",
campaignType: s.campaign_type,
});
}
}
}
if (pushLogs && pushLogs.length > 0) {
for (const push of pushLogs) {
events.push({
id: `push-${push.id}`,
created_at: push.created_at,
type: "push",
label: push.status === "sent" ? "Push-уведомление отправлено" : "Ошибка отправки Push",
pushStatus: push.status,
pushTitle: push.title || "СуперСам",
pushBody: push.body || "",
pushError: push.error_message || null,
});
}
}
if (invitation) {
if (invitation.sent_at) {
events.push({ id: `inv-sent-${invitation.id}`, created_at: invitation.sent_at, type: "invitation", label: "Приглашение отправлено" });
}
if (invitation.opened_at) {
events.push({ id: `inv-opened-${invitation.id}`, created_at: invitation.opened_at, type: "invitation", label: "Клиент открыл страницу", accessCount: invitation.access_count });
}
if (invitation.confirmed_at) {
events.push({ id: `inv-confirmed-${invitation.id}`, created_at: invitation.confirmed_at, type: "invitation", label: "Клиент подтвердил дату доставки" });
}
}
// Reason why the 2nd SMS was not sent (e.g. city profile disables it)
const secondSmsSent = order?.secondSmsSentAt || order?.second_sms_sent_at;
const hasSecondSmsLog = smsLogs.some((s) => s.campaign_type === "second_sms");
if (!secondSmsSent && !hasSecondSmsLog) {
const firstSmsDelivered = smsLogs.some((s) => s.campaign_type === "first_sms" && s.status === "delivered");
const town = order?.town || order?.city || null;
const cityDisables = cityProfile && cityProfile.second_sms_enabled === false;
if (firstSmsDelivered && cityDisables && town) {
events.push({
id: `second-sms-reason`,
created_at: order?.firstSmsSentAt || order?.first_sms_sent_at || new Date().toISOString(),
type: "reason",
label: "2-е SMS не отправлено",
reason: `Для города ${town} вторая SMS отключена в настройках.`,
tone: "warning",
});
}
}
events.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
return events;
}, [history, smsLogs, pushLogs, invitation, cityProfile]);
const activePushSubscriptions = pushSubscriptions.filter((sub) => sub.is_active);
const latestPush = pushLogs[0] || null;
if (!history && smsLogs.length === 0 && pushLogs.length === 0 && !invitation) return null;
return (
<div className="space-y-4">
<ProgressStepper order={order} />
<StatusSummary order={order} />
<div className={`rounded-xl border px-3 py-2.5 ${
activePushSubscriptions.length > 0
? "border-[rgba(18,128,92,0.25)] bg-[var(--color-accent-soft)]"
: "border-[var(--color-border)] bg-[var(--color-surface)]"
}`}>
<div className="flex items-start gap-2.5">
<span className="mt-0.5 text-base">{activePushSubscriptions.length > 0 ? "🔔" : "🔕"}</span>
<div className="min-w-0 flex-1">
<p className="text-[11px] font-medium text-[var(--color-text-muted)]">Push-уведомления клиента</p>
{activePushSubscriptions.length > 0 ? (
<>
<p className="text-sm font-semibold text-[var(--color-accent)]">
Подписан · устройств: {activePushSubscriptions.length}
</p>
<p className="text-xs text-[var(--color-text-muted)]">
Подписка: {fmtTimestamp(activePushSubscriptions[0]?.created_at)}
{latestPush ? ` · последнее push: ${fmtTimestamp(latestPush.created_at)}` : " · уведомлений ещё не было"}
</p>
</>
) : (
<p className="text-sm font-semibold text-[var(--color-text-muted)]">Не подписан</p>
)}
</div>
</div>
</div>
{allEvents.length > 0 && (
<ol className="relative space-y-3 pl-4">
{allEvents.map((ev, idx) => (
<li key={ev.id ?? idx} className="relative border-l border-[var(--color-border)] pl-4">
<span className="absolute -left-[5px] top-1 h-2.5 w-2.5 rounded-full bg-[var(--color-accent)]" aria-hidden />
<p className="text-xs text-[var(--color-text-muted)]">{fmtTimestamp(ev.created_at)}</p>
<p className="mt-0.5 text-sm font-medium text-[var(--color-text)]">{ev.label}</p>
{ev.type === "history" && ev.changes.length > 0 && (
<div className="mt-1 space-y-0.5">
{ev.changes.map((c, i) => (
<p key={i} className="text-sm text-[var(--color-text-muted)]">
{c.field}: <span className="font-medium">{c.oldVal}</span>
{" → "}
<span className="font-medium text-[var(--color-text)]">{c.newVal}</span>
</p>
))}
</div>
)}
{ev.type === "sms" && (
<>
<p className="mt-0.5 text-sm">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${
ev.smsStatus === "доставлено"
? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]"
: ev.smsStatus === "ошибка"
? "bg-[rgba(239,68,68,0.12)] text-[var(--color-danger)]"
: "bg-[var(--color-surface)] text-[var(--color-text-muted)]"
}`}>
{ev.smsStatus}
</span>
</p>
<p className="mt-0.5 text-xs text-[var(--color-text-muted)]">
Отправлено: {fmtTimestamp(ev.smsSentAt)}
{ev.isDelivered ? ` · Доставлено: ${fmtTimestamp(ev.smsDeliveredAt)}` : ""}
</p>
{ev.smsText && <p className="mt-0.5 text-xs text-[var(--color-text-muted)]">«{ev.smsText}»</p>}
{ev.smsError && <p className="mt-0.5 text-xs text-[var(--color-danger)]">Ошибка: {ev.smsError}</p>}
</>
)}
{ev.type === "sms_delivered" && (
<p className="mt-0.5 text-xs text-[var(--color-accent)]">
Подтверждение доставки в {fmtTimestamp(ev.created_at)}
</p>
)}
{ev.type === "reason" && (
<p className={`mt-0.5 text-xs ${
ev.tone === "warning" ? "text-[var(--color-warning)]" : "text-[var(--color-text-muted)]"
}`}>
{ev.reason}
</p>
)}
{ev.type === "push" && (
<>
<p className="mt-0.5 text-sm">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${
ev.pushStatus === "sent"
? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]"
: "bg-[rgba(239,68,68,0.12)] text-[var(--color-danger)]"
}`}>
{ev.pushStatus === "sent" ? "принято сервисом Push" : "не отправлено"}
</span>
</p>
<p className="mt-0.5 text-xs text-[var(--color-text-muted)]">«{ev.pushTitle}{ev.pushBody ? `${ev.pushBody}` : ""}»</p>
{ev.pushError && <p className="mt-0.5 text-xs text-[var(--color-danger)]">Ошибка: {ev.pushError}</p>}
</>
)}
{ev.type === "invitation" && ev.accessCount != null && ev.accessCount > 0 && (
<p className="mt-0.5 text-xs text-[var(--color-text-muted)]">Открытий страницы: {ev.accessCount}</p>
)}
</li>
))}
</ol>
)}
</div>
);
};
export default OrderHistoryTimeline;

View File

@ -5,7 +5,6 @@
* 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";
@ -22,6 +21,8 @@ const NOTIF_LABELS = {
paid_storage_sending: "Отправляется…",
paid_storage_sent: "Платное хранение: отправлено",
draft: "Черновик",
confirmed: "Клиент согласовал дату",
completed: "Завершено",
};
const NOTIF_TONES = {
@ -36,6 +37,8 @@ const NOTIF_TONES = {
paid_storage_sending: "info",
paid_storage_sent: "accent",
draft: "neutral",
confirmed: "accent",
completed: "neutral",
};
// Helpers
@ -62,6 +65,38 @@ const fmtCountdown = (targetTs) => {
return `${m}м ${s}с`;
};
// SMS log entry (expandable to show text)
const SmsLogEntry = ({ log }) => {
const [expanded, setExpanded] = useState(false);
const hasText = !!log.sms_text;
return (
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1.5">
<div
className={`flex items-center gap-2 text-[11px] ${hasText ? "cursor-pointer" : ""}`}
onClick={hasText ? () => setExpanded(e => !e) : undefined}
>
<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.campaign_type && (
<span className="text-[var(--color-text-muted)]">
{log.campaign_type === "first_sms" ? "1-е SMS" : log.campaign_type === "second_sms" ? "2-е SMS" : log.campaign_type === "manual" ? "ручное" : log.campaign_type === "paid_storage" ? "платное хранение" : log.campaign_type}
</span>
)}
{hasText && (
<span className="ml-auto text-[var(--color-accent)] text-[10px]">{expanded ? "▲" : "▼"}</span>
)}
</div>
{hasText && expanded && (
<div className="mt-1.5 rounded-md bg-[var(--color-surface-strong)] px-2.5 py-2 text-[11px] text-[var(--color-text)] leading-relaxed whitespace-pre-wrap">
{log.sms_text}
</div>
)}
</div>
);
};
// Component
export const SmsStatusCard = ({ order, userRole }) => {
const [restarting, setRestarting] = useState(false);
@ -166,31 +201,34 @@ export const SmsStatusCard = ({ order, userRole }) => {
};
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)
// Determine SMS display state from sms_log (source of truth)
const firstSmsLog = smsLog.find(l => l.campaign_type === "first_sms");
const secondSmsLog = smsLog.find(l => l.campaign_type === "second_sms");
const isDelivered = (log) => log && (log.status === "delivered" || log.sms_code === "103");
const hasFirstSms = isDelivered(firstSmsLog);
const hasSecondSms = isDelivered(secondSmsLog);
const firstSmsFailed = firstSmsLog && ["expired", "error", "send_failed", "limit_exceeded"].includes(firstSmsLog.status);
const secondSmsFailed = secondSmsLog && ["expired", "error", "send_failed", "limit_exceeded"].includes(secondSmsLog.status);
return (
<Panel className="p-4">
<div className="mb-3 flex items-center justify-between">
<div className="p-4">
<div className="mb-3">
<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)]"}`} />
<span className={`mt-0.5 h-2 w-2 rounded-full ${hasFirstSms ? "bg-[#22c55e]" : firstSmsFailed ? "bg-[var(--color-danger)]" : firstSmsAt ? "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>
<div className="text-[var(--color-text-muted)]">{fmtTime(firstSmsLog.created_at)} получено клиентом</div>
) : firstSmsFailed ? (
<div className="text-[var(--color-danger)]">{fmtTime(firstSmsLog.created_at)} {firstSmsLog.status === "expired" ? "срок доставки истёк" : firstSmsLog.status}</div>
) : firstSmsAt ? (
<div className="text-[var(--color-text-muted)]">{fmtTime(firstSmsAt)} · отправлено, ждём ответ оператора</div>
) : notifStatus === "link_ready" ? (
<div className="text-[var(--color-text-muted)]">в очереди на отправку</div>
) : (
@ -201,11 +239,13 @@ export const SmsStatusCard = ({ order, userRole }) => {
{/* 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)]"}`} />
<span className={`mt-0.5 h-2 w-2 rounded-full ${hasSecondSms ? "bg-[#22c55e]" : secondSmsFailed ? "bg-[var(--color-danger)]" : 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>
<div className="text-[var(--color-text-muted)]">{fmtTime(secondSmsLog.created_at)} получено клиентом</div>
) : secondSmsFailed ? (
<div className="text-[var(--color-danger)]">{fmtTime(secondSmsLog.created_at)} {secondSmsLog.status === "expired" ? "срок доставки истёк" : secondSmsLog.status}</div>
) : notifStatus === "first_sms_sent" && countdown ? (
<div className="text-[var(--color-text-muted)]">
отправка через <span className="font-mono text-[var(--color-accent)]">{countdown}</span>
@ -243,23 +283,29 @@ export const SmsStatusCard = ({ order, userRole }) => {
<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>
<SmsLogEntry key={log.id} log={log} />
))}
</div>
</div>
)}
{/* Client page access info */}
{(order.invitationAccessCount > 0 || order.invitationOpenedAt) ? (
<div className="mt-2 flex items-center gap-2 rounded-lg bg-[var(--color-surface-strong)] px-2 py-1.5 text-[11px]">
<span className="text-[var(--color-text-muted)]">👁 Клиент открывал страницу согласования</span>
<span className="font-medium text-[var(--color-text)]">{order.invitationAccessCount || 1} раз</span>
{(order.invitationLastAccessedAt || order.invitationOpenedAt) && (
<span className="text-[var(--color-text-muted)]">
· последний: {fmtTime(order.invitationLastAccessedAt || order.invitationOpenedAt)}
</span>
)}
</div>
) : (
<div className="mt-2 flex items-center gap-2 rounded-lg bg-[var(--color-surface-strong)] px-2 py-1.5 text-[11px] text-[var(--color-text-muted)]">
📭 Клиент ещё не открывал страницу согласования
</div>
)}
{/* Restart buttons */}
{canManage && (
<div className="mt-3 flex gap-2 border-t border-[var(--color-border)] pt-3">
@ -283,6 +329,6 @@ export const SmsStatusCard = ({ order, userRole }) => {
)}
</div>
)}
</Panel>
</div>
);
};

View File

@ -1,10 +1,8 @@
import React from "react";
import { Badge } from "../UI/Badge";
import { Button } from "../UI/Button";
import { Panel } from "../UI/Panel";
import { DELIVERY_GROUP_STATUS_LABELS } from "../../services/orderGroupViews";
const STATUS_LABELS = DELIVERY_GROUP_STATUS_LABELS;
import { CalledPanel } from "./CalledPanel";
const StatusActionPanel = ({
order,
@ -12,56 +10,131 @@ const StatusActionPanel = ({
canManageDelivery,
isSavingStatusChange,
onConfirmStatus,
onRefreshOrder,
}) => {
if (!canManageDelivery || !["manager", "logistician", "admin", "mega_admin"].includes(userRole) || !order) {
return null;
}
const currentStatus = order.deliveryStatus || order.delivery_status;
const isPickup = (order.deliveryType || order.delivery_type) === "pickup" || currentStatus === "pickup";
// Check delivery schedule
const hasDeliveryDate = !!(order.deliveryDate || order.customerDate);
const hasDeliveryHalfDay = !!(order.deliveryTime || order.deliveryHalfDay || order.delivery_time || order.delivery_half_day);
const hasDeliverySchedule = hasDeliveryDate && hasDeliveryHalfDay;
const hasDriver = !!order.assignedDriverId;
// Smart hints: show actual state instead of generic "do X"
const getHint = (statusValue) => {
if (statusValue === "agreed") {
if (hasDeliverySchedule) return "Дата доставки уже согласована";
return "Согласуйте дату доставки выше";
}
if (statusValue === "driver_assigned") {
if (hasDriver) return `Водитель уже назначен: ${order.assignedDriverName || ""}`.trim();
return "Назначьте водителя из списка выше";
}
return "";
};
const allStatuses = isPickup
? [
{ value: "pending_confirmation", label: "Ожидает согласования" },
{ value: "agreed", label: "Согласовано" },
{ value: "driver_assigned", label: "Назначен водитель" },
{ value: "picked_up", label: "Вывезено", primary: true, requiresSchedule: true, requiresDriver: false },
{ value: "delivered", label: "Доставлено", mismatch: true, requiresSchedule: true },
{ value: "requires_address", label: "Требуется адрес" },
{ value: "problem", label: "Проблема" },
{ value: "cancelled", label: "Отменено" },
]
: [
{ value: "pending_confirmation", label: "Ожидает согласования" },
{ value: "agreed", label: "Согласовано" },
{ value: "driver_assigned", label: "Назначен водитель" },
{ value: "delivered", label: "Доставлено", primary: true, requiresSchedule: true, requiresDriver: true },
{ value: "picked_up", label: "Вывезено", mismatch: true, requiresSchedule: true },
{ value: "requires_address", label: "Требуется адрес" },
{ value: "problem", label: "Проблема" },
{ value: "cancelled", label: "Отменено" },
];
return (
<Panel className="space-y-4 p-5">
<div>
<strong>Статус доставки</strong>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
Измените статус, если водитель забыл обновить или нужна корректировка.
</p>
</div>
<div className="space-y-4">
<p className="text-sm text-[var(--color-text-muted)]">
Измените статус, если водитель забыл обновить или нужна корректировка.
</p>
{/* Status indicators: show current state clearly */}
<div className="flex flex-wrap gap-2">
{[
{ value: "pending_confirmation", label: "Ожидает согласования", manual: true },
{ value: "agreed", label: "Согласовано", manual: false, hint: "Согласуйте дату доставки выше" },
{ value: "driver_assigned", label: "Назначен водитель", manual: false, hint: "Назначьте водителя из списка" },
{ value: "loaded", label: "Загружено", manual: true },
{ value: "delivered", label: "Доставлено", manual: true },
{ value: "picked_up", label: "Вывезено", manual: true },
{ value: "requires_address", label: "Требуется адрес", manual: true },
{ value: "problem", label: "Проблема", manual: true },
{ value: "cancelled", label: "Отменено", manual: true },
].map((statusOption) => {
{hasDeliverySchedule && (
<Badge tone="accent"> Дата согласована</Badge>
)}
{hasDriver && (
<Badge tone="accent"> Водитель: {order.assignedDriverName || "назначен"}</Badge>
)}
{!hasDeliverySchedule && (
<Badge tone="warning"> Дата не указана</Badge>
)}
{!hasDriver && !isPickup && (
<Badge tone="warning"> Водитель не назначен</Badge>
)}
</div>
<div className="flex flex-wrap gap-2">
{allStatuses.map((statusOption) => {
const isCurrent = currentStatus === statusOption.value;
const isClickable = statusOption.manual !== false && !isCurrent;
const blockedBySchedule = statusOption.requiresSchedule && !hasDeliverySchedule;
const blockedByDriver = statusOption.requiresDriver !== false && !hasDriver;
const hint = getHint(statusOption.value);
return (
<div key={statusOption.value} className="relative group">
<Button
variant={isCurrent ? "primary" : "secondary"}
onClick={() => {
if (!isClickable) {
onConfirmStatus?.({ type: "hint", hint: statusOption.hint || "" });
return;
}
onConfirmStatus?.({ type: "status", status: statusOption.value });
}}
disabled={isSavingStatusChange}
>
{statusOption.label}
</Button>
</div>
<Button
key={statusOption.value}
variant={isCurrent ? "primary" : (statusOption.mismatch ? "ghost" : "secondary")}
onClick={() => {
if (blockedBySchedule) {
return;
}
if (blockedByDriver) {
onConfirmStatus?.({
type: "hint",
hint: "⚠ Назначьте водителя перед статусом «Доставлено»",
});
return;
}
if (isCurrent && hint) {
onConfirmStatus?.({ type: "hint", hint });
return;
}
onConfirmStatus?.({
type: "status",
status: statusOption.value,
label: statusOption.label,
mismatch: !!statusOption.mismatch,
deliveryType: isPickup ? "pickup" : "delivery",
});
}}
disabled={isSavingStatusChange}
className={(blockedBySchedule || blockedByDriver) ? "opacity-40 cursor-not-allowed" : ""}
>
{statusOption.label}
</Button>
);
})}
</div>
</Panel>
{!hasDeliverySchedule && (
<div className="rounded-xl border border-[var(--color-warning)] bg-[var(--color-warning-soft)] px-3 py-2 text-xs text-[var(--color-warning)]">
Чтобы поставить «Доставлено» или «Вывезено», сначала укажите дату и половину дня доставки выше.
</div>
)}
<div className="pt-3 border-t border-[var(--color-border)]">
<p className="mb-2 text-sm text-[var(--color-text-muted)]">Статус звонка:</p>
<CalledPanel order={order} onUpdate={onRefreshOrder} />
</div>
</div>
);
};
export { StatusActionPanel };
export { StatusActionPanel };

View File

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

101
src/fontSettings.css Normal file
View File

@ -0,0 +1,101 @@
/* 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).
This lets us scale fonts without patching every component.
*/
/* 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)); }
/* Body zone — LAST so table/card/nav/heading 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)); }

View File

@ -9,15 +9,25 @@ import {
} from "../services/orderGroupViews";
import { getErrorMessage } from "../utils/deliveryUtils";
export const useOrderGroups = () => {
export const useOrderGroups = ({ enabled = true, details = false } = {}) => {
const [orderGroups, setOrderGroups] = React.useState(() => []);
const [filters, setFilters] = React.useState({
query: "",
displayStatus: "all",
deliveryType: "",
const FILTERS_STORAGE_KEY = "supersam_order_filters";
const [filters, setFilters] = React.useState(() => {
try {
const saved = localStorage.getItem(FILTERS_STORAGE_KEY);
if (saved) return JSON.parse(saved);
} catch (e) {}
return { query: "", displayStatus: "all", deliveryType: "" };
});
// Persist filters to localStorage on every change
React.useEffect(() => {
try {
localStorage.setItem(FILTERS_STORAGE_KEY, JSON.stringify(filters));
} catch (e) {}
}, [filters]);
const [selectedOrderGroupId, setSelectedOrderGroupId] = React.useState(null);
const [isLoading, setIsLoading] = React.useState(true);
const [isLoading, setIsLoading] = React.useState(enabled);
const [loadError, setLoadError] = React.useState("");
const [isSavingDeliveryChoice, setIsSavingDeliveryChoice] = React.useState(false);
const [isSavingDriverAssignment, setIsSavingDriverAssignment] = React.useState(false);
@ -29,7 +39,9 @@ export const useOrderGroups = () => {
}
setLoadError("");
const groupsResult = await fetchOrderGroups();
if (!enabled) { setOrderGroups([]); setIsLoading(false); return; }
const groupsResult = await fetchOrderGroups({ details });
if (!groupsResult) {
return;
@ -44,7 +56,7 @@ export const useOrderGroups = () => {
setOrderGroups(groupsResult.data || []);
setIsLoading(false);
}, []);
}, [enabled, details]);
// Initial load
React.useEffect(() => {
@ -54,18 +66,19 @@ export const useOrderGroups = () => {
await loadLiveData(true);
if (cancelled) return;
};
doLoad();
if (enabled) doLoad(); else { setOrderGroups([]); setIsLoading(false); }
return () => { cancelled = true; };
}, [loadLiveData]);
}, [loadLiveData, enabled]);
// Refetch on window focus to keep data fresh across tabs/pages
React.useEffect(() => {
if (!enabled) return undefined;
const handleFocus = () => {
loadLiveData(false); // silent refetch, no loading spinner
};
window.addEventListener("focus", handleFocus);
return () => window.removeEventListener("focus", handleFocus);
}, [loadLiveData]);
}, [loadLiveData, enabled]);
React.useEffect(() => {
if (!orderGroups.length) {

View File

@ -6,6 +6,8 @@ import { PickupSlotsPicker } from "../components/client/PickupSlotsPicker";
import { OrderCompositionPanel } from "../components/client/OrderCompositionPanel";
import { getInvitationReferenceLabel } from "../components/client/invitationReference";
import { DeliveryStateNotice } from "../components/client/DeliveryStateNotice";
import { PushSubscriptionBanner } from "../components/client/PushSubscriptionBanner";
import { PwaInstallCard } from "../components/client/PwaInstallCard";
import { Panel } from "../components/UI/Panel";
import { Skeleton } from "../components/UI/Loading";
@ -339,6 +341,33 @@ export const ClientDeliveryPage = () => {
setError("");
};
if (!token) {
return (
<main className="min-h-screen bg-[var(--color-bg)] px-3 py-4 sm:px-6 sm:py-8">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4">
<Panel className="space-y-4 p-5 sm:p-6">
<div className="flex items-center gap-3">
<img src="/icons/icon-192.png" alt="" className="h-12 w-12 rounded-xl" />
<div>
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">СуперСам</p>
<h1 className="text-2xl font-semibold leading-tight sm:text-3xl">Доставка заказа</h1>
</div>
</div>
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
Здесь можно согласовать дату доставки или самовывоза и включить уведомления по заказу.
</p>
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-bg)] p-4">
<p className="text-sm font-semibold text-[var(--color-text)]">Откройте персональную ссылку</p>
<p className="mt-1 text-sm leading-6 text-[var(--color-text-muted)]">
Она приходит в сообщении от «СуперСам». Данные заказа без персональной ссылки не отображаются.
</p>
</div>
</Panel>
</div>
</main>
);
}
if (loading) {
return (
<main className="min-h-screen bg-[var(--color-bg)] px-3 py-4 sm:px-6 sm:py-8">
@ -395,6 +424,13 @@ export const ClientDeliveryPage = () => {
<OrderCompositionPanel invitation={invitation} />
<PwaInstallCard />
<PushSubscriptionBanner
phone={invitation?.customerPhone || invitation?.customer_phone || ""}
orderGroupId={invitation?.orderGroupId || invitation?.order_group_id || null}
/>
{isChoiceSaved && savedChoiceLabel ? (
<Panel className="space-y-2 p-5 sm:p-6">
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Ваш выбор</p>

View File

@ -17,6 +17,7 @@ import { StopWordsPanel } from "../components/admin/StopWordsPanel";
import { ActionLogPanel } from "../components/admin/ActionLogPanel";
import { SuggestionsPanel } from "../components/admin/SuggestionsPanel";
import { SmsCampaignPanel } from "../components/admin/SmsCampaignPanel";
import { BusinessSchedulePanel } from "../components/admin/BusinessSchedulePanel";
import { Panel } from "../components/UI/Panel";
import { SkeletonPage, SkeletonTable } from "../components/UI/Loading";
import { useAuth } from "../context/AuthContext";
@ -36,6 +37,7 @@ const MEGA_ADMIN_NAV = [
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
{ key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null },
{ key: "sms_campaign", label: "SMS-кампании", description: "Логи и настройки SMS-рассылок.", badge: null },
{ key: "schedule", label: "Расписание", description: "Рабочие дни доставки, самовывоза и SMS.", badge: null },
];
// Role Default Section Map
@ -100,7 +102,7 @@ export const DashboardPage = () => {
statusOptions,
isLoading,
loadError,
} = useOrderGroups();
} = useOrderGroups({ enabled: ["orders", "logistics", "deliveries"].includes(activeSection), details: false });
// Derived City List
const cities = React.useMemo(() => {
@ -137,10 +139,12 @@ export const DashboardPage = () => {
{ key: "errors", label: "Ошибки", description: "Журнал ошибок приложения.", badge: null },
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
{ key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null },
{ key: "schedule", label: "Расписание", description: "Рабочие дни доставки, самовывоза и SMS.", badge: null },
]
: userRole === "logistician"
? [
{ key: "logistics", label: "Логистика", description: "Группы доставки по готовности к уведомлению.", badge: String(allOrderGroups.length || orderGroups.length || 0) },
{ key: "suggestions", label: "Предложения", description: "Предложить улучшение.", badge: null },
]
: [
{ key: section.key, label: section.label, description: section.description, badge: String(userRole === "driver" ? driverOrderCount : (allOrderGroups.length || orderGroups.length || 0)) },
@ -177,6 +181,7 @@ const ALLOWED_DASHBOARD_ROLES = ["admin", "mega_admin", "manager", "logistician"
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 === "sms_campaign") return <div className="space-y-6 xl:space-y-8"><SmsCampaignPanel /></div>;
if (activeSection === "schedule") return <div className="space-y-6 xl:space-y-8"><BusinessSchedulePanel /></div>;
if (isLoading) {
if (userRole === "driver") {

View File

@ -38,7 +38,7 @@ export const GroupDetailPage = () => {
saveShipmentData,
isLoading,
refetch,
} = useOrderGroups();
} = useOrderGroups({ details: true });
// Drivers
const [drivers, setDrivers] = React.useState([]);

View File

@ -1,5 +1,5 @@
import React from "react";
import { Navigate, createBrowserRouter, Outlet } from "react-router-dom";
import { Navigate, createBrowserRouter, useParams } from "react-router-dom";
import App from "./App";
import { ClientDeliveryPage } from "./pages/ClientDeliveryPage";
import { DashboardPage } from "./pages/DashboardPage";
@ -44,9 +44,17 @@ export const router = createBrowserRouter([
element: <LoginPage />,
},
{
path: "delivery/:token",
path: "client",
element: <ClientDeliveryPage />,
},
{
path: "client/:token",
element: <ClientDeliveryPage />,
},
{
path: "delivery/:token",
element: <LegacyDeliveryRedirect />,
},
{
path: "forbidden",
element: <ForbiddenPage />,
@ -82,3 +90,8 @@ export const router = createBrowserRouter([
],
},
]);
function LegacyDeliveryRedirect() {
const { token } = useParams();
return <Navigate to={`/client/${encodeURIComponent(token || "")}`} replace />;
}

View File

@ -39,7 +39,7 @@ export const DRIVER_VISIBLE_DELIVERY_STATUSES = [
"paid_storage",
];
export const DRIVER_ACTIVE_DELIVERY_STATUSES = ["driver_assigned", "loaded", "on_route", "problem"];
export const DRIVER_ACTIVE_DELIVERY_STATUSES = ["driver_assigned", "problem"];
const HALF_DAY_LABELS = {
morning: "Первая половина дня",
@ -143,9 +143,17 @@ export const isOrderGroupAgreedForDelivery = (group) => {
export const getOrderGroupDeliveryStatusLabel = (status) =>
DELIVERY_GROUP_STATUS_LABELS[status] || (status ? `Неизвестно (${status})` : "Неизвестно");
// Status values that represent a delivery state (not SMS/notification state)
const DELIVERY_STATUS_VALUES = new Set([
"pending_confirmation", "agreed", "driver_assigned", "loaded", "on_route",
"delivered", "picked_up", "pickup", "requires_address", "problem",
"cancelled", "paid_storage", "address_required", "manual_confirmation_required",
]);
export const getOrderGroupDisplayStatusLabel = (group) => {
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
const statusCol = group?.status;
const notificationStatus = group?.notificationStatus || group?.notification_status;
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
// When auto-SMS failed and logistics hasn't taken action yet → show as a todo item
const isManualRequired = notificationStatus === "manual_required";
@ -154,6 +162,12 @@ export const getOrderGroupDisplayStatusLabel = (group) => {
return "Требуется ручное управление";
}
// Primary: status column (now synced with delivery_status)
if (statusCol && DELIVERY_STATUS_VALUES.has(statusCol) && statusCol !== "pending_confirmation" && statusCol !== "manual_confirmation_required") {
return getOrderGroupDeliveryStatusLabel(statusCol);
}
// Fallback: delivery_status (for pending/manual_confirmation groups)
if (deliveryStatus && deliveryStatus !== "pending_confirmation" && deliveryStatus !== "manual_confirmation_required") {
return getOrderGroupDeliveryStatusLabel(deliveryStatus);
}
@ -163,12 +177,13 @@ export const getOrderGroupDisplayStatusLabel = (group) => {
return notificationLabel;
}
return getOrderGroupStatusLabel(group?.status);
return getOrderGroupStatusLabel(statusCol);
};
export const getOrderGroupDisplayStatusValue = (group) => {
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
const statusCol = group?.status;
const notificationStatus = group?.notificationStatus || group?.notification_status;
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
// Unify manual_required into a single bucket regardless of delivery_status detail
const isManualRequired = notificationStatus === "manual_required";
@ -177,11 +192,17 @@ export const getOrderGroupDisplayStatusValue = (group) => {
return "status:manual_required";
}
// Primary: status column (now synced with delivery_status)
if (statusCol && DELIVERY_STATUS_VALUES.has(statusCol) && statusCol !== "pending_confirmation" && statusCol !== "manual_confirmation_required") {
return `delivery:${statusCol}`;
}
// Fallback: delivery_status
if (deliveryStatus && deliveryStatus !== "pending_confirmation" && deliveryStatus !== "manual_confirmation_required") {
return `delivery:${deliveryStatus}`;
}
return `status:${group?.status || "unknown"}`;
return `status:${statusCol || "unknown"}`;
};
export const isOrderGroupVisibleToDriver = (group) => {
@ -223,6 +244,23 @@ export const parseGroupDate = (value) => {
return Number.isNaN(parsed.getTime()) ? null : parsed;
};
/**
* SMS delivery problem tone used for both table highlighting and filtering.
* Returns "ok" | "not_sent" | "not_delivered".
*/
export const getSmsProblemTone = (group) => {
if (!group) return "ok";
const firstSms = group.firstSmsSentAt || group.first_sms_sent_at;
const secondSms = group.secondSmsSentAt || group.second_sms_sent_at;
const lastError = group.lastSmsError;
// Sent an SMS (1st or 2nd) and it has a recorded delivery error → problem with delivery.
if ((firstSms || secondSms) && lastError) return "not_delivered";
// SMS should be expected but not sent at all yet.
if (!firstSms && !secondSms) return "not_sent";
// Sent but no error recorded — assume delivered / in progress.
return "ok";
};
export const filterOrderGroups = (groups, filters = {}) => {
const query = normalizeDate(filters.query).trim().toLowerCase();
const status = filters.status || "all";
@ -231,6 +269,7 @@ export const filterOrderGroups = (groups, filters = {}) => {
const dateFrom = normalizeDate(filters.dateFrom);
const dateTo = normalizeDate(filters.dateTo);
const deliveryHalfDay = normalizeDate(filters.deliveryHalfDay || filters.timeSlot || "all");
const smsStatus = filters.smsStatus || "all";
const isWithinDateRange = (group) => {
const deliveryDate = parseGroupDate(getDeliveryDate(group));
@ -322,6 +361,12 @@ export const filterOrderGroups = (groups, filters = {}) => {
return false;
}
if (smsStatus !== "all") {
const tone = getSmsProblemTone(group);
if (smsStatus === "problem_not_sent" && tone !== "not_sent") return false;
if (smsStatus === "problem_not_delivered" && tone !== "not_delivered") return false;
}
if (!query) {
return true;
}
@ -483,10 +528,16 @@ export const buildOrderGroupBuckets = (groups) => {
export const getOrderGroupStatusTone = (group) => {
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
const statusCol = group?.status;
// Highlight groups with delivery problems
if (group?.hasDeliveryProblem) return "warning";
// Priority: if status column already holds a delivery-level value, use it
if (statusCol && DELIVERY_STATUS_VALUES.has(statusCol) && statusCol !== "pending_confirmation" && statusCol !== "manual_confirmation_required") {
return getOrderGroupDeliveryStatusTone(statusCol);
}
if (deliveryStatus && deliveryStatus !== "pending_confirmation") {
return getOrderGroupDeliveryStatusTone(deliveryStatus);
}

View File

@ -142,12 +142,23 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
const extractCity = (addr) => {
if (!addr) return "";
// 1) explicit marker: г. Ялта, пгт. Куйбышево, etc.
const m = addr.match(/(?:г\.\s|гор\.\s|пос\.\s|с\.\s|дер\.\s|пгт\.\s|город\s|село\s|г\s)\s*([А-ЯЁа-яёA-Za-z\-\s]+?)(?:[,\\s]|$)/i);
if (m) return m[1].trim();
// 2) known city name anywhere in address (case-insensitive)
// Word markers (город/село) require space after to avoid matching "Стройгородок"
// Dot markers (г./гор./etc) allow zero space (г.Ялта)
const m = addr.match(/(?:г\.\s*|гор\.\s*|пос\.\s*|с\.\s*|дер\.\s*|пгт\.\s*|город\s+|село\s+|г\s+)\s*([А-ЯЁа-яёA-Za-z\-\s]+?)(?:[,\s]|$)/i);
if (m) {
const candidate = m[1].trim();
for (const city of CRIMEAN_CITIES) {
if (city.toLowerCase() === candidate.toLowerCase()) return city;
}
// Not a known city — continue to step 2
}
// 2) known city name via custom word boundary (JS \b doesn't work with Cyrillic)
// City must be preceded by start/comma/space/dot and followed by comma/space/end
const lower = addr.toLowerCase();
for (const city of CRIMEAN_CITIES) {
if (lower.includes(city.toLowerCase())) return city;
const esc = city.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp("(^|[,.\\s])" + esc + "(?=[,\\s]|$)", "i");
if (re.test(lower)) return city;
}
// 3) Бахчисарайский р-н → Бахчисарай
const district = addr.match(/([А-ЯЁа-яё]+)ский\s*(?:р-н|район)/i);
@ -178,6 +189,7 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
customerPhone,
customerPhoneNormalized: parsedKey.phone || normalizePhone(customerPhone),
customerDate,
town: row.town || null,
deliveryAddress: resolvedDeliveryAddress,
originalDeliveryAddress,
customerAddress,
@ -196,6 +208,8 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
manualConfirmationAt: row.manual_confirmation_at || null,
paidStorageAt: row.paid_storage_at || null,
notificationStatus: normalizeText(row.notification_status),
lastSmsError: row.last_sms_error || null,
smsAttempts: row.sms_attempts || 0,
createdFromExchangeAt: row.created_from_exchange_at || null,
sourceKey: row.source_key || null,
legacyCustomerName: row.legacy_customer_name || null,
@ -274,7 +288,8 @@ 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, 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)`;
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, town, 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), called, called_at, called_comment, order_history(id, action, old_status, new_status, user_id, metadata, created_at)`;
const ORDER_GROUP_LIST_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, town, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, notification_status, sms_attempts, last_sms_error, first_sms_sent_at, second_sms_sent_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, called, called_at, called_comment`;
export const updateOrderGroupDeliveryChoice = async ({
orderGroupId,
@ -460,15 +475,22 @@ export const updateDeliveryStatus = async ({ orderGroupId, status, details, ship
return safeSupabaseCall(async () => {
const client = requireSupabase();
// Fetch current status before any update (needed for audit log)
// Fetch current status before any update (needed for audit log + status sync)
const { data: current, error: fetchCurrentError } = await client
.from("order_groups")
.select("delivery_status")
.select("delivery_status, delivery_type")
.eq("id", orderGroupId)
.single();
if (fetchCurrentError) throw fetchCurrentError;
// Compute status column: pickup+picked_up → picked_up, delivery+picked_up → delivered
const deliveryType = current.delivery_type || "delivery";
const statusSync = (deliveryType === "pickup" && status === "picked_up") ? "picked_up"
: (deliveryType === "delivery" && status === "picked_up") ? "delivered"
: (status === "delivered") ? "delivered"
: status;
// Bypass stale RPC for paid_storage transitions
// Server-side RPC still enforces driver-assignment checks that block
// manager/logistician from moving groups into/out of paid_storage.
@ -479,6 +501,7 @@ export const updateDeliveryStatus = async ({ orderGroupId, status, details, ship
.from("order_groups")
.update({
delivery_status: status,
status: statusSync,
paid_storage_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
@ -490,6 +513,7 @@ export const updateDeliveryStatus = async ({ orderGroupId, status, details, ship
.from("order_groups")
.update({
delivery_status: status,
status: statusSync,
paid_storage_at: null,
updated_at: new Date().toISOString(),
})
@ -542,12 +566,12 @@ export const updateDeliveryStatus = async ({ orderGroupId, status, details, ship
}, "Ошибка обновления статуса доставки");
};
export const fetchOrderGroups = async () => {
export const fetchOrderGroups = async ({ details = true } = {}) => {
return safeSupabaseCall(async () => {
const client = requireSupabase();
const { data, error } = await client
.from("order_groups")
.select(ORDER_GROUP_SELECT_FIELDS)
.select(details ? ORDER_GROUP_SELECT_FIELDS : ORDER_GROUP_LIST_SELECT_FIELDS)
.order("updated_at", { ascending: false });
if (error) {

View File

@ -119,9 +119,26 @@ export const normalizeAvailableSlots = (availableSlots?: string[] | null) => {
return slots.length > 0 ? Array.from(new Set(slots)) : [...DEFAULT_AVAILABLE_SLOTS];
};
export const buildDefaultDatedAvailableSlots = (now = new Date()) => {
export const buildDefaultDatedAvailableSlots = async (now = new Date(), supabaseClient?: any) => {
const CRIMEA_TZ = "Europe/Simferopol";
// Fetch delivery days from business_schedule_settings
let deliveryDays: number[] = [1, 2, 3, 4, 5]; // Default: Mon-Fri
if (supabaseClient) {
try {
const { data } = await supabaseClient
.from("business_schedule_settings")
.select("delivery_days")
.eq("id", 1)
.single();
if (data?.delivery_days && Array.isArray(data.delivery_days) && data.delivery_days.length) {
deliveryDays = data.delivery_days;
}
} catch {
// Silent fallback to defaults
}
}
const formatCrimeaDate = (date: Date) => {
return new Intl.DateTimeFormat("en-CA", {
timeZone: CRIMEA_TZ,
@ -137,12 +154,18 @@ export const buildDefaultDatedAvailableSlots = (now = new Date()) => {
return next;
};
// Skip Sunday (getUTCDay() === 0) — never offer Sunday delivery
const isSunday = (date: Date) => date.getUTCDay() === 0;
// Check if date is an allowed delivery day
// getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
// deliveryDays uses 1=Mon ... 7=Sun
const isAllowedDay = (date: Date) => {
const dow = date.getUTCDay();
const dayNum = dow === 0 ? 7 : dow;
return deliveryDays.includes(dayNum);
};
const getNextWorkday = (date: Date) => {
let next = addDays(date, 1);
while (isSunday(next)) {
while (!isAllowedDay(next)) {
next = addDays(next, 1);
}
return next;
@ -174,7 +197,7 @@ export const resolvePublicAppUrl = (
};
export const buildInvitationUrl = (baseUrl: string, token: string) =>
`${baseUrl.replace(/\/$/, "")}/delivery/${token}`;
`${baseUrl.replace(/\/$/, "")}/client/${token}`;
export type DeliveryInvitationRecord = {
id?: string;

View File

@ -31,11 +31,31 @@ type ConfirmBody = {
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
const isWeekendDate = (value: string) => {
// Fetch delivery days from business_schedule_settings
const getDeliveryDays = async (supabaseClient: any): Promise<number[]> => {
try {
const { data } = await supabaseClient
.from("business_schedule_settings")
.select("delivery_days")
.eq("id", 1)
.single();
if (data?.delivery_days && Array.isArray(data.delivery_days) && data.delivery_days.length) {
return data.delivery_days as number[];
}
} catch {
// Silent fallback
}
return [1, 2, 3, 4, 5]; // Default: Mon-Fri
};
const isAllowedDeliveryDate = (value: string, deliveryDays: number[]) => {
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 dow = date.getUTCDay();
// getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
// deliveryDays uses 1=Mon ... 7=Sun
const dayNum = dow === 0 ? 7 : dow;
return deliveryDays.includes(dayNum);
};
const resolveRequestedSlot = (
@ -45,6 +65,7 @@ const resolveRequestedSlot = (
available_slots?: string[] | null;
},
body: ConfirmBody,
deliveryDays: number[] = [1, 2, 3, 4, 5],
) => {
const deliveryType = body.deliveryType || "delivery";
const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim();
@ -59,8 +80,8 @@ const resolveRequestedSlot = (
return { deliveryDate, deliveryTime, deliveryType };
}
// Reject Sunday for delivery (business rule: never deliver on Sunday)
if (isWeekendDate(deliveryDate)) {
// Reject non-delivery days for delivery (business schedule)
if (!isAllowedDeliveryDate(deliveryDate, deliveryDays)) {
return null;
}
@ -120,6 +141,7 @@ Deno.serve(async (request) => {
const tokenHash = await hashInvitationToken(body.token);
const supabase = createServiceClient();
const deliveryDays = await getDeliveryDays(supabase);
const ipHash = await hashText(getClientIp(request));
await requireRateLimit(supabase, {
@ -180,7 +202,7 @@ Deno.serve(async (request) => {
);
}
const requestedSlot = resolveRequestedSlot(invitation, body);
const requestedSlot = resolveRequestedSlot(invitation, body, deliveryDays);
if (!requestedSlot) {
return jsonResponse(
{

View File

@ -0,0 +1,165 @@
import {
createServiceClient,
getCorsHeaders,
jsonResponse,
preflightResponse,
readJsonBody,
} from "../_shared/security.ts";
const MAX_BODY_BYTES = 8 * 1024;
const ADMIN_ROLES = new Set(["admin", "mega_admin"]);
const isValidEmail = (value: string) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
/**
* Verify the caller's JWT and return their role from public.users.
* Returns null if not authorized.
*/
async function getCallerRole(request: Request): Promise<string | null> {
const authHeader = request.headers.get("authorization") || "";
const token = authHeader.replace(/^Bearer\s+/i, "").trim();
if (!token) return null;
const supabase = createServiceClient();
const { data: userData, error: userError } = await supabase.auth.getUser(token);
if (userError || !userData?.user) return null;
const userId = userData.user.id;
const { data: userRow, error: roleError } = await supabase
.from("users")
.select("roles(name)")
.eq("id", userId)
.maybeSingle();
if (roleError || !userRow) return null;
return userRow.roles?.name || null;
}
Deno.serve(async (request) => {
if (request.method === "OPTIONS") {
return preflightResponse(request, "private");
}
const corsHeaders = getCorsHeaders(request, "private");
if (!corsHeaders) {
return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
}
try {
// ── Authorization ──
const callerRole = await getCallerRole(request);
if (!callerRole || !ADMIN_ROLES.has(callerRole)) {
return jsonResponse(
{ ok: false, error: "Недостаточно прав. Требуется роль admin или mega_admin." },
403,
corsHeaders,
);
}
const supabase = createServiceClient();
// ── POST: create new user ──
if (request.method === "POST") {
const { body } = await readJsonBody<{ email?: string; name?: string; role?: string }>(
request,
{ maxBytes: MAX_BODY_BYTES },
);
const email = String(body.email || "").trim().toLowerCase();
const name = String(body.name || "").trim();
const role = String(body.role || "").trim().toLowerCase();
if (!email || !isValidEmail(email)) {
return jsonResponse({ ok: false, error: "Некорректный email" }, 400, corsHeaders);
}
if (!name) {
return jsonResponse({ ok: false, error: "Имя обязательно" }, 400, corsHeaders);
}
if (!role) {
return jsonResponse({ ok: false, error: "Роль обязательна" }, 400, corsHeaders);
}
// Check if email already exists in public.users
const { data: existingUser } = await supabase
.from("users")
.select("id")
.eq("email", email)
.maybeSingle();
if (existingUser) {
return jsonResponse({ ok: false, error: "Пользователь с таким email уже существует" }, 409, corsHeaders);
}
// Create auth user — trigger handle_new_user will auto-insert into public.users
// using user_metadata.role and user_metadata.name
const { data: authData, error: authError } = await supabase.auth.admin.createUser({
email,
email_confirm: true,
user_metadata: { name, role },
});
if (authError) {
console.error("auth.createUser error:", authError);
return jsonResponse(
{ ok: false, error: "Ошибка создания пользователя: " + authError.message },
500,
corsHeaders,
);
}
const newUserId = authData.user.id;
return jsonResponse({ ok: true, data: { id: newUserId, email, name, role } }, 201, corsHeaders);
}
// ── DELETE: remove user ──
if (request.method === "DELETE") {
const url = new URL(request.url);
const userId = url.searchParams.get("id");
if (!userId) {
return jsonResponse({ ok: false, error: "Параметр id обязателен" }, 400, corsHeaders);
}
// Get user info before deletion
const { data: userRow } = await supabase
.from("users")
.select("id, email, name")
.eq("id", userId)
.maybeSingle();
if (!userRow) {
return jsonResponse({ ok: false, error: "Пользователь не найден" }, 404, corsHeaders);
}
// Delete auth user (FK ON DELETE CASCADE will remove public.users row)
const { error: authDeleteError } = await supabase.auth.admin.deleteUser(userId);
if (authDeleteError) {
console.error("auth.deleteUser error:", authDeleteError);
// Try deleting public.users directly as fallback
const { error: dbDeleteError } = await supabase.from("users").delete().eq("id", userId);
if (dbDeleteError) {
return jsonResponse(
{ ok: false, error: "Ошибка удаления: " + authDeleteError.message },
500,
corsHeaders,
);
}
}
return jsonResponse({ ok: true, data: { id: userId } }, 200, corsHeaders);
}
return jsonResponse({ ok: false, error: "Method not allowed" }, 405, corsHeaders);
} catch (error) {
if (error instanceof Error && "status" in error) {
const httpError = error as { status: number; message: string };
return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders);
}
return jsonResponse(
{ ok: false, error: error instanceof Error ? error.message : "Unexpected error" },
500,
corsHeaders,
);
}
});

View File

@ -0,0 +1,128 @@
import { createServiceClient } from "../_shared/security.ts";
import {
getCorsHeaders,
jsonResponse,
preflightResponse,
readJsonBody,
} from "../_shared/security.ts";
const MAX_BODY_BYTES = 8 * 1024;
type SubscribeBody = {
phone_normalized?: string;
endpoint?: string;
p256dh?: string;
auth?: string;
user_agent?: string;
};
Deno.serve(async (request) => {
if (request.method === "OPTIONS") {
return preflightResponse(request, "public");
}
if (request.method !== "POST") {
return jsonResponse({ ok: false, error: "Method not allowed" }, 405);
}
const corsHeaders = getCorsHeaders(request, "public");
if (!corsHeaders) {
return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
}
try {
const { body } = await readJsonBody<SubscribeBody>(request, {
maxBytes: MAX_BODY_BYTES,
});
const phoneNormalized = String(body.phone_normalized || "").trim();
const endpoint = String(body.endpoint || "").trim();
const p256dh = String(body.p256dh || "").trim();
const auth = String(body.auth || "").trim();
const userAgent = String(body.user_agent || "").trim() || null;
if (!phoneNormalized) {
return jsonResponse(
{ ok: false, error: "phone_normalized is required" },
400,
corsHeaders,
);
}
if (!endpoint) {
return jsonResponse(
{ ok: false, error: "endpoint is required" },
400,
corsHeaders,
);
}
if (!p256dh) {
return jsonResponse(
{ ok: false, error: "p256dh is required" },
400,
corsHeaders,
);
}
if (!auth) {
return jsonResponse(
{ ok: false, error: "auth is required" },
400,
corsHeaders,
);
}
const supabase = createServiceClient();
const now = new Date().toISOString();
// Upsert on (phone_normalized, endpoint) — unique constraint idx_push_subs_phone_endpoint
const { data, error } = await supabase
.from("push_subscriptions")
.upsert(
{
phone_normalized: phoneNormalized,
endpoint,
p256dh,
auth,
user_agent: userAgent,
is_active: true,
updated_at: now,
last_seen_at: now,
},
{ onConflict: "phone_normalized,endpoint" },
)
.select("id, phone_normalized, endpoint, is_active, updated_at")
.single();
if (error) {
console.error("push-subscribe upsert error:", error);
return jsonResponse(
{ ok: false, error: "Database error", detail: error.message },
500,
corsHeaders,
);
}
return jsonResponse(
{ ok: true, subscription: data },
200,
corsHeaders,
);
} catch (error) {
if (error instanceof Error && "status" in error) {
const httpError = error as { status: number; message: string };
return jsonResponse(
{ ok: false, error: httpError.message },
httpError.status,
corsHeaders,
);
}
return jsonResponse(
{
ok: false,
error: error instanceof Error ? error.message : "Unexpected error",
},
500,
corsHeaders,
);
}
});

View File

@ -0,0 +1,105 @@
import { createServiceClient } from "../_shared/security.ts";
import {
getCorsHeaders,
jsonResponse,
preflightResponse,
readJsonBody,
} from "../_shared/security.ts";
const MAX_BODY_BYTES = 4 * 1024;
type UnsubscribeBody = {
endpoint?: string;
phone_normalized?: string;
};
Deno.serve(async (request) => {
if (request.method === "OPTIONS") {
return preflightResponse(request, "public");
}
if (request.method !== "POST") {
return jsonResponse({ ok: false, error: "Method not allowed" }, 405);
}
const corsHeaders = getCorsHeaders(request, "public");
if (!corsHeaders) {
return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
}
try {
const { body } = await readJsonBody<UnsubscribeBody>(request, {
maxBytes: MAX_BODY_BYTES,
});
const endpoint = String(body.endpoint || "").trim();
const phoneNormalized = String(body.phone_normalized || "").trim();
if (!endpoint) {
return jsonResponse(
{ ok: false, error: "endpoint is required" },
400,
corsHeaders,
);
}
if (!phoneNormalized) {
return jsonResponse(
{ ok: false, error: "phone_normalized is required" },
400,
corsHeaders,
);
}
const supabase = createServiceClient();
const now = new Date().toISOString();
const { data, error } = await supabase
.from("push_subscriptions")
.update({
is_active: false,
updated_at: now,
})
.eq("endpoint", endpoint)
.eq("phone_normalized", phoneNormalized)
.select("id, phone_normalized, endpoint, is_active, updated_at");
if (error) {
console.error("push-unsubscribe update error:", error);
return jsonResponse(
{ ok: false, error: "Database error", detail: error.message },
500,
corsHeaders,
);
}
const deactivatedCount = data?.length || 0;
return jsonResponse(
{
ok: true,
deactivated: deactivatedCount,
subscriptions: data,
},
200,
corsHeaders,
);
} catch (error) {
if (error instanceof Error && "status" in error) {
const httpError = error as { status: number; message: string };
return jsonResponse(
{ ok: false, error: httpError.message },
httpError.status,
corsHeaders,
);
}
return jsonResponse(
{
ok: false,
error: error instanceof Error ? error.message : "Unexpected error",
},
500,
corsHeaders,
);
}
});

View File

@ -119,16 +119,63 @@ export const normalizeAvailableSlots = (availableSlots?: string[] | null) => {
return slots.length > 0 ? Array.from(new Set(slots)) : [...DEFAULT_AVAILABLE_SLOTS];
};
export const buildDefaultDatedAvailableSlots = (now = new Date()) => {
const formatIsoDate = (date: Date) => date.toISOString().slice(0, 10);
export const buildDefaultDatedAvailableSlots = async (now = new Date(), supabaseClient?: any) => {
const CRIMEA_TZ = "Europe/Simferopol";
// Fetch delivery days from business_schedule_settings
let deliveryDays: number[] = [1, 2, 3, 4, 5]; // Default: Mon-Fri
if (supabaseClient) {
try {
const { data } = await supabaseClient
.from("business_schedule_settings")
.select("delivery_days")
.eq("id", 1)
.single();
if (data?.delivery_days && Array.isArray(data.delivery_days) && data.delivery_days.length) {
deliveryDays = data.delivery_days;
}
} catch {
// Silent fallback to defaults
}
}
const formatCrimeaDate = (date: Date) => {
return new Intl.DateTimeFormat("en-CA", {
timeZone: CRIMEA_TZ,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(date);
};
const addDays = (date: Date, days: number) => {
const next = new Date(date);
next.setUTCDate(next.getUTCDate() + days);
return next;
};
const firstDay = formatIsoDate(addDays(now, 1));
const secondDay = formatIsoDate(addDays(now, 2));
// Check if date is an allowed delivery day
// getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
// deliveryDays uses 1=Mon ... 7=Sun
const isAllowedDay = (date: Date) => {
const dow = date.getUTCDay();
const dayNum = dow === 0 ? 7 : dow;
return deliveryDays.includes(dayNum);
};
const getNextWorkday = (date: Date) => {
let next = addDays(date, 1);
while (!isAllowedDay(next)) {
next = addDays(next, 1);
}
return next;
};
const firstWorkday = getNextWorkday(now);
const secondWorkday = getNextWorkday(firstWorkday);
const firstDay = formatCrimeaDate(firstWorkday);
const secondDay = formatCrimeaDate(secondWorkday);
return [
`${firstDay}, Первая половина дня`,

View File

@ -0,0 +1,132 @@
import { createClient } from "npm:@supabase/supabase-js@2";
const ALLOWED_ORIGINS = [
"https://dost.supersamsev.ru",
"https://supa.supersamsev.ru",
"http://localhost:5173",
];
const SMS_STATUS_URL = "https://sms.ru/sms/status";
const SMS_CODE_LABELS: Record<string, string> = {
"100": "В очереди SMS.ru",
"101": "Передано оператору",
"102": "В пути",
"103": "Доставлено",
"104": "Истёкло время",
"105": "Удалено оператором",
"106": "Сбой телефона",
"107": "Неизвестная причина",
"108": "Отклонено",
"130": "Лимит на номер/день",
"131": "Лимит одинаковых/мин",
"132": "Лимит одинаковых/день",
"200": "Неправильный api_id",
"201": "Недостаточно средств",
"202": "Неправильный получатель",
"230": "Общий лимит/день",
"231": "Лимит одинаковых/мин",
"232": "Лимит одинаковых/день",
};
const cors = (origin: string) => ({
"Access-Control-Allow-Origin": ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0],
"Access-Control-Allow-Methods": "POST,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type,Authorization,apikey",
});
Deno.serve(async (req: Request) => {
const origin = req.headers.get("origin") || "";
const headers = { ...cors(origin), "Content-Type": "application/json" };
if (req.method === "OPTIONS") return new Response(null, { headers });
try {
const { log_id } = await req.json();
if (!log_id) return new Response(JSON.stringify({ error: "log_id required" }), { status: 400, headers });
const supabaseUrl = Deno.env.get("SUPABASE_URL") || "";
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || "";
const sb = createClient(supabaseUrl, serviceKey);
// Fetch log entry
const { data: logRow, error: logErr } = await sb
.from("sms_campaign_log")
.select("id, sms_id, campaign_type, order_group_id, attempts")
.eq("id", log_id)
.single();
if (logErr || !logRow) return new Response(JSON.stringify({ error: "Log not found" }), { status: 404, headers });
// Fetch api_id from settings
const { data: settings } = await sb
.from("sms_campaign_settings")
.select("sms_api_id")
.eq("campaign_type", logRow.campaign_type)
.single();
const apiId = settings?.sms_api_id || Deno.env.get("SMS_API_ID") || "";
if (!logRow.sms_id) return new Response(JSON.stringify({ error: "No sms_id in log" }), { status: 400, headers });
// Call SMS.ru status API
const formData = new URLSearchParams();
formData.append("api_id", apiId);
formData.append("sms_id", logRow.sms_id);
const smsResp = await fetch(SMS_STATUS_URL, { method: "POST", body: formData });
const smsText = await smsResp.text();
const lines = smsText.split("\n").map((l: string) => l.trim());
const code = lines[0] || "";
// Determine status
const status =
code === "103" ? "delivered" :
["100", "101", "102"].includes(code) ? "checking" :
["104", "105", "106", "107", "108", "130"].includes(code) ? "error" :
["131", "132", "230", "231", "232"].includes(code) ? "limit_exceeded" :
"checking";
// Update log entry
const now = new Date().toISOString();
await sb.from("sms_campaign_log").update({
status,
sms_code: code,
checked_at: now,
needs_check: false,
updated_at: now,
}).eq("id", log_id);
// Update order_groups if delivered
if (code === "103" && logRow.order_group_id) {
const nextCheck = new Date(Date.now() + 3 * 3600 * 1000).toISOString();
if (logRow.campaign_type === "first_sms") {
await sb.from("order_groups").update({
notification_status: "first_sms_sent",
first_sms_sent_at: now,
sms_sent_at: now,
last_sms_error: null,
next_notification_check_at: nextCheck,
status: "first_sms_sent",
}).eq("id", logRow.order_group_id);
} else if (logRow.campaign_type === "second_sms") {
await sb.from("order_groups").update({
notification_status: "second_sms_sent",
second_sms_sent_at: now,
sms_sent_at: now,
last_sms_error: null,
next_notification_check_at: nextCheck,
status: "second_sms_sent",
}).eq("id", logRow.order_group_id);
}
}
return new Response(JSON.stringify({
success: true,
sms_id: logRow.sms_id,
code,
status,
label: SMS_CODE_LABELS[code] || "Код " + code,
}), { headers });
} catch (e) {
return new Response(JSON.stringify({ error: String(e) }), { status: 500, headers });
}
});

View File

@ -31,11 +31,31 @@ type ConfirmBody = {
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
const isWeekendDate = (value: string) => {
// Fetch delivery days from business_schedule_settings
const getDeliveryDays = async (supabaseClient: any): Promise<number[]> => {
try {
const { data } = await supabaseClient
.from("business_schedule_settings")
.select("delivery_days")
.eq("id", 1)
.single();
if (data?.delivery_days && Array.isArray(data.delivery_days) && data.delivery_days.length) {
return data.delivery_days as number[];
}
} catch {
// Silent fallback
}
return [1, 2, 3, 4, 5]; // Default: Mon-Fri
};
const isAllowedDeliveryDate = (value: string, deliveryDays: number[]) => {
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 dow = date.getUTCDay();
// getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
// deliveryDays uses 1=Mon ... 7=Sun
const dayNum = dow === 0 ? 7 : dow;
return deliveryDays.includes(dayNum);
};
const resolveRequestedSlot = (
@ -45,6 +65,7 @@ const resolveRequestedSlot = (
available_slots?: string[] | null;
},
body: ConfirmBody,
deliveryDays: number[] = [1, 2, 3, 4, 5],
) => {
const deliveryType = body.deliveryType || "delivery";
const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim();
@ -59,8 +80,8 @@ const resolveRequestedSlot = (
return { deliveryDate, deliveryTime, deliveryType };
}
// Reject Sunday for delivery (business rule: never deliver on Sunday)
if (isWeekendDate(deliveryDate)) {
// Reject non-delivery days for delivery (business schedule)
if (!isAllowedDeliveryDate(deliveryDate, deliveryDays)) {
return null;
}
@ -120,6 +141,7 @@ Deno.serve(async (request) => {
const tokenHash = await hashInvitationToken(body.token);
const supabase = createServiceClient();
const deliveryDays = await getDeliveryDays(supabase);
const ipHash = await hashText(getClientIp(request));
await requireRateLimit(supabase, {
@ -180,7 +202,7 @@ Deno.serve(async (request) => {
);
}
const requestedSlot = resolveRequestedSlot(invitation, body);
const requestedSlot = resolveRequestedSlot(invitation, body, deliveryDays);
if (!requestedSlot) {
return jsonResponse(
{

View File

@ -0,0 +1,128 @@
import { createServiceClient } from "../_shared/security.ts";
import {
getCorsHeaders,
jsonResponse,
preflightResponse,
readJsonBody,
} from "../_shared/security.ts";
const MAX_BODY_BYTES = 8 * 1024;
type SubscribeBody = {
phone_normalized?: string;
endpoint?: string;
p256dh?: string;
auth?: string;
user_agent?: string;
};
Deno.serve(async (request) => {
if (request.method === "OPTIONS") {
return preflightResponse(request, "public");
}
if (request.method !== "POST") {
return jsonResponse({ ok: false, error: "Method not allowed" }, 405);
}
const corsHeaders = getCorsHeaders(request, "public");
if (!corsHeaders) {
return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
}
try {
const { body } = await readJsonBody<SubscribeBody>(request, {
maxBytes: MAX_BODY_BYTES,
});
const phoneNormalized = String(body.phone_normalized || "").trim();
const endpoint = String(body.endpoint || "").trim();
const p256dh = String(body.p256dh || "").trim();
const auth = String(body.auth || "").trim();
const userAgent = String(body.user_agent || "").trim() || null;
if (!phoneNormalized) {
return jsonResponse(
{ ok: false, error: "phone_normalized is required" },
400,
corsHeaders,
);
}
if (!endpoint) {
return jsonResponse(
{ ok: false, error: "endpoint is required" },
400,
corsHeaders,
);
}
if (!p256dh) {
return jsonResponse(
{ ok: false, error: "p256dh is required" },
400,
corsHeaders,
);
}
if (!auth) {
return jsonResponse(
{ ok: false, error: "auth is required" },
400,
corsHeaders,
);
}
const supabase = createServiceClient();
const now = new Date().toISOString();
// Upsert on (phone_normalized, endpoint) — unique constraint idx_push_subs_phone_endpoint
const { data, error } = await supabase
.from("push_subscriptions")
.upsert(
{
phone_normalized: phoneNormalized,
endpoint,
p256dh,
auth,
user_agent: userAgent,
is_active: true,
updated_at: now,
last_seen_at: now,
},
{ onConflict: "phone_normalized,endpoint" },
)
.select("id, phone_normalized, endpoint, is_active, updated_at")
.single();
if (error) {
console.error("push-subscribe upsert error:", error);
return jsonResponse(
{ ok: false, error: "Database error", detail: error.message },
500,
corsHeaders,
);
}
return jsonResponse(
{ ok: true, subscription: data },
200,
corsHeaders,
);
} catch (error) {
if (error instanceof Error && "status" in error) {
const httpError = error as { status: number; message: string };
return jsonResponse(
{ ok: false, error: httpError.message },
httpError.status,
corsHeaders,
);
}
return jsonResponse(
{
ok: false,
error: error instanceof Error ? error.message : "Unexpected error",
},
500,
corsHeaders,
);
}
});

View File

@ -0,0 +1,105 @@
import { createServiceClient } from "../_shared/security.ts";
import {
getCorsHeaders,
jsonResponse,
preflightResponse,
readJsonBody,
} from "../_shared/security.ts";
const MAX_BODY_BYTES = 4 * 1024;
type UnsubscribeBody = {
endpoint?: string;
phone_normalized?: string;
};
Deno.serve(async (request) => {
if (request.method === "OPTIONS") {
return preflightResponse(request, "public");
}
if (request.method !== "POST") {
return jsonResponse({ ok: false, error: "Method not allowed" }, 405);
}
const corsHeaders = getCorsHeaders(request, "public");
if (!corsHeaders) {
return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
}
try {
const { body } = await readJsonBody<UnsubscribeBody>(request, {
maxBytes: MAX_BODY_BYTES,
});
const endpoint = String(body.endpoint || "").trim();
const phoneNormalized = String(body.phone_normalized || "").trim();
if (!endpoint) {
return jsonResponse(
{ ok: false, error: "endpoint is required" },
400,
corsHeaders,
);
}
if (!phoneNormalized) {
return jsonResponse(
{ ok: false, error: "phone_normalized is required" },
400,
corsHeaders,
);
}
const supabase = createServiceClient();
const now = new Date().toISOString();
const { data, error } = await supabase
.from("push_subscriptions")
.update({
is_active: false,
updated_at: now,
})
.eq("endpoint", endpoint)
.eq("phone_normalized", phoneNormalized)
.select("id, phone_normalized, endpoint, is_active, updated_at");
if (error) {
console.error("push-unsubscribe update error:", error);
return jsonResponse(
{ ok: false, error: "Database error", detail: error.message },
500,
corsHeaders,
);
}
const deactivatedCount = data?.length || 0;
return jsonResponse(
{
ok: true,
deactivated: deactivatedCount,
subscriptions: data,
},
200,
corsHeaders,
);
} catch (error) {
if (error instanceof Error && "status" in error) {
const httpError = error as { status: number; message: string };
return jsonResponse(
{ ok: false, error: httpError.message },
httpError.status,
corsHeaders,
);
}
return jsonResponse(
{
ok: false,
error: error instanceof Error ? error.message : "Unexpected error",
},
500,
corsHeaders,
);
}
});

54
webhook-deploy.py Executable file
View File

@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""Gitea webhook listener — auto-deploys supersam on push to main."""
import hmac, hashlib, subprocess, json, os, logging
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ.get("WEBHOOK_SECRET", "supersam-deploy-hook-2024")
DEPLOY_SCRIPT = "/opt/supersam/deploy.sh"
LOG = "/var/log/supersam-deploy.log"
logging.basicConfig(filename=LOG, level=logging.INFO, format="%(asctime)s %(message)s")
logger = logging.getLogger(__name__)
def verify_signature(payload, sig_header):
if not sig_header:
return False
mac = hmac.new(SECRET.encode(), payload, hashlib.sha256).hexdigest()
return hmac.compare(mac, sig_header)
@app.route("/deploy", methods=["POST"])
def deploy():
# Verify Gitea signature if present
sig = request.headers.get("X-Gitea-Signature", "")
if not verify_signature(request.data, sig):
logger.warning("Invalid or missing signature")
# Still proceed — Gitea may not send signature if not configured
data = request.json or {}
ref = data.get("ref", "")
repo = data.get("repository", {}).get("name", "")
# Only deploy on push to main
if ref != "refs/heads/main":
logger.info(f"Ignored push to {ref}")
return {"status": "ignored", "ref": ref}, 200
logger.info(f"Deploy triggered by push to {ref} in {repo}")
try:
result = subprocess.run(
[DEPLOY_SCRIPT],
capture_output=True, text=True, timeout=300
)
logger.info(f"Deploy exit={result.returncode}")
if result.returncode != 0:
logger.error(f"Deploy stderr: {result.stderr}")
return {"status": "error", "output": result.stderr}, 500
return {"status": "ok", "output": result.stdout[-500:]}, 200
except subprocess.TimeoutExpired:
logger.error("Deploy timed out")
return {"status": "timeout"}, 500
if __name__ == "__main__":
app.run(host="127.0.0.1", port=9765)