Compare commits

...

67 Commits

Author SHA1 Message Date
Hermes f5a06188a2 fix: align row-2 controls by bottom edge of input fields (SMS + toggle flush with dates) 2026-08-19 13:02:30 +02:00
Hermes ebff56c749 fix: SMS dropdown text fits, align hide toggle on row, no skeleton flash on toggle 2026-08-19 12:52:38 +02:00
Hermes 46fac18e28 ui: restructure filters — search wider on row 1, SMS + hide-completed toggle switch on row 2 2026-08-19 12:36:27 +02:00
Hermes 0cdd9f52a4 ui: move hide-completed toggle into filter bar as checkbox next to SMS filter 2026-08-19 12:16:52 +02:00
Hermes a3cd0838f0 perf: hide-completed server filter + in-memory list cache for fast return-to-list 2026-08-19 11:58:20 +02:00
Hermes 8131e26c77 perf: load single order group by id in detail view instead of all 430 groups
- add fetchOrderGroupById (single .eq(id).single() query)
- useOrderGroups gains groupId mode: fetches one group, not full 2.6MB list
- GroupDetailPage passes groupId — fixes 30-40s card open on slow connections
2026-08-19 11:02:23 +02:00
Hermes 5ca9bd7ebf perf: code-split routes — client delivery page loads 34KB chunk instead of 932KB bundle
- lazy-load all pages via React.lazy + Suspense
- client-facing pages (SMS links) no longer pull DashboardPage (605KB) and admin panels
- fixes 30-40s loads on slow mobile connections (Crimea customers)
2026-08-19 10:58:39 +02:00
Hermes c62606b09d fix(push): remove staff calls to non-existent subscribe-push/unsubscribe-push edge functions (500 InvalidWorkerCreation)
- staff hook called /functions/v1/subscribe-push which does not exist on the
  server (functions are named push-subscribe/push-unsubscribe and are keyed by
  phone_normalized for the CLIENT flow only; push_subscriptions has no user_id)
- keep local browser PushManager subscription; drop broken server round-trips
- client PushSubscriptionBanner flow untouched (uses correct endpoint)
2026-08-18 12:32:26 +02:00
Hermes 7a76c0a6c0 feat(notifications): disable realtime/WebSocket entirely — REST fetch + focus refresh only
- remove postgres_changes .channel().subscribe() from useNotifications
- notifications now load via REST on mount and refresh on tab focus
- eliminates WS retry hangs (30-40s stalls) from dead realtime container;
  web-push (Push API) is unaffected and works independently
2026-08-18 10:58:39 +02:00
Hermes cd72507766 fix(pwa): purge stale caches from app startup — clear construction-delivery-* caches directly in page context, independent of SW update
- new src/utils/pwaCacheCleanup.js: deletes all construction-delivery-* caches
  except current v115 on every app load; runs in page context so it works even
  for users still on old SW
- wire into main.jsx startup
- fixes 3GB cache bloat / 30-40s hangs for users whose SW hasn't updated
2026-08-18 10:52:49 +02:00
Hermes 981ca827a2 fix(sw): stop unbounded cache growth — delete stale caches on activate, cache only static assets
- activate: delete ALL caches except current STATIC/RUNTIME (was keeping every
  previous construction-delivery-* cache forever → 3GB storage, 30-40s caches.match)
- fetch: only cache same-origin static assets (js/css/img/fonts); API/data and
  cross-origin requests go straight to network, never cached
- bump to v115
2026-08-17 16:17:36 +02:00
Hermes 56cb4b460f fix(analytics): period key '1d' (Сегодня) not in PERIOD_DAYS map — fell back to 30 days; add '1d' to both hooks 2026-08-14 16:00:36 +02:00
Hermes c0ebe7f59f fix(manage-users): detach FK references before deleting user (RESTRICT FKs in order_history/action_logs/suggestions/order_groups blocked auth.deleteUser with 'Database error deleting user') 2026-08-14 15:20:40 +02:00
Hermes c4aebb08a4 chore(sw): bump to v113 (timeline SMS state from log) 2026-08-14 15:08:35 +02:00
Hermes 4a4b088949 fix(timeline): StatusSummary/Stepper show real SMS state from sms_campaign_log (delivered/failed), not just first_sms_sent_at 2026-08-14 15:07:22 +02:00
Hermes 6fe0bfbc73 chore(sw): bump to v112 (endpoint-targeted test push) 2026-08-13 15:03:23 +02:00
Hermes 7654413d8b fix(push): test notification targets the device's own subscription (by endpoint), not localStorage phone 2026-08-13 15:02:26 +02:00
Hermes 4fed962f23 chore(sw): bump to v111 (direct test push button on /client) 2026-08-13 14:34:17 +02:00
Hermes fee800e3fa fix(client): test-notification button on /client sends push directly (url → /client/test-consent), no detour via /client/test 2026-08-13 14:33:32 +02:00
Hermes 91245169f4 chore(sw): bump to v110 (text-base fix, login link) 2026-08-13 14:26:38 +02:00
Hermes 1b41b5e135 fix: text-base color conflict (removed base from tailwind colors); add login link + test page link on /client 2026-08-13 14:25:56 +02:00
Hermes e21cea1b1c chore(sw): bump to v109 (test consent page + test push button) 2026-08-13 14:18:49 +02:00
Hermes 663b31b3df feat(client): test consent page + 'test notification' button on /client/test 2026-08-13 14:17:18 +02:00
Hermes 045c903074 chore(sw): bump to v108 (root redirect → /client) 2026-08-13 14:09:16 +02:00
Hermes f14ca8fae9 fix: root redirect → /client (not /dashboard) for PWA start_url 2026-08-13 14:06:42 +02:00
Hermes 1cbcff6a16 feat(push): auto-subscribe in standalone mode — no button needed, iOS system prompt only 2026-08-13 13:58:32 +02:00
Hermes 47a06bb285 fix(push): reject phone input shorter than 10 digits (was accepting '7') 2026-08-11 15:39:53 +02:00
Hermes bdeb055fae feat(client): test page /client/test for PWA install + push flow (mock data, no real order) 2026-08-11 15:12:19 +02:00
Hermes b2bd855de3 feat(client): iOS install → prompt notification permission; phone input + localStorage fallback on /client (no token) 2026-08-11 13:47:10 +02:00
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
43 changed files with 4519 additions and 718 deletions

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,14 @@
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-v115";
const RUNTIME_CACHE = "construction-delivery-runtime-v115";
const APP_SHELL_URLS = ["/", "/client", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
// Only these are treated as cacheable static assets. Everything else
// (API/data requests, cross-origin) goes straight to the network and is
// NEVER written to cache — prevents unbounded cache growth.
const ASSET_EXT = /\.(js|mjs|css|png|jpe?g|gif|svg|webp|ico|woff2?|ttf|eot|webmanifest)$/i;
self.addEventListener("install", (event) => {
event.waitUntil(
@ -38,6 +43,7 @@ if (!isLocalhost) {
const requestUrl = new URL(event.request.url);
const isSameOrigin = requestUrl.origin === self.location.origin;
// Navigation: network-first, fall back to cached index.html when offline.
if (event.request.mode === "navigate") {
event.respondWith(
fetch(event.request)
@ -54,7 +60,9 @@ if (!isLocalhost) {
return;
}
if (!isSameOrigin) {
// Only cache same-origin static assets (hashed build files, images, fonts).
// API/data requests and cross-origin calls are never cached.
if (!isSameOrigin || !ASSET_EXT.test(requestUrl.pathname)) {
return;
}
@ -90,34 +98,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);
}),

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

@ -11,6 +11,8 @@ import {
const MAX_VISIBLE_INVOICES = 2;
const TERMINAL_STATUSES = ["delivered", "picked_up", "cancelled"];
const fmtDate = (d) => {
if (!d) return '';
const [y, m, day] = d.split('-');
@ -18,6 +20,28 @@ const fmtDate = (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;
@ -119,11 +143,15 @@ export const OrdersTable = ({
) : 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 || [];
@ -155,6 +183,17 @@ export const OrdersTable = ({
<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 && (
@ -178,23 +217,32 @@ export const OrdersTable = ({
</div>
) : (
<div className="overflow-x-auto">
<div className="min-w-[1080px]">
<div className="grid grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(100px,0.8fr)_minmax(100px,1fr)_minmax(100px,1fr)_minmax(100px,0.8fr)_minmax(90px,0.8fr)] gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.12em] text-[var(--color-text-muted)]">
<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 rowClassName = `grid grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(100px,0.8fr)_minmax(100px,1fr)_minmax(100px,1fr)_minmax(100px,0.8fr)_minmax(90px,0.8fr)] gap-0 w-full border-t border-[var(--color-border)] text-left transition ${
hasProblem
? "bg-[rgba(201,61,61,0.1)] hover:bg-[rgba(201,61,61,0.15)]"
: "hover:bg-[var(--color-accent-soft)]"
} ${selectedOrderGroupId === group.id ? "bg-[var(--color-accent-soft)]" : ""}`;
const 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] || "—";
@ -225,9 +273,17 @@ export const OrdersTable = ({
{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 ? (
@ -236,6 +292,9 @@ export const OrdersTable = ({
<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" ? "🏪" : "🚚"}

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

@ -0,0 +1,38 @@
import React from "react";
/**
* Accessible iOS-style toggle switch.
* @param {object} props
* @param {boolean} props.checked
* @param {function} props.onChange Called with the next boolean value.
* @param {string} [props.label] Visible text next to the switch.
* @param {string} [props.title] Hover tooltip.
* @param {string} [props.className]
*/
export const Switch = ({ checked = false, onChange, label, title, className = "" }) => {
const id = React.useId();
return (
<label htmlFor={id} className={`inline-flex cursor-pointer select-none items-center gap-2.5 text-sm text-[var(--color-text)] ${className}`} title={title}>
<button
type="button"
id={id}
role="switch"
aria-checked={checked}
onClick={() => onChange && onChange(!checked)}
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition-colors ${
checked
? "border-[var(--color-accent)] bg-[var(--color-accent)]"
: "border-[var(--color-border)] bg-[var(--color-surface-strong)]"
}`}
>
<span
aria-hidden="true"
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${
checked ? "translate-x-[22px]" : "translate-x-0.5"
}`}
/>
</button>
{label ? <span>{label}</span> : null}
</label>
);
};

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

@ -0,0 +1,347 @@
import React, { useState, useEffect, useCallback } 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 PHONE_STORAGE_KEY = "supersam_phone";
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.length < 10) return "";
if (clean.startsWith("8")) return "7" + clean.slice(1);
if (!clean.startsWith("7")) return "7" + clean;
return clean;
};
const readStoredPhone = () => {
try {
return window.localStorage.getItem(PHONE_STORAGE_KEY) || "";
} catch {
return "";
}
};
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");
const [dismissed, setDismissed] = useState(false);
const [phoneInput, setPhoneInput] = useState("");
const propPhone = normalizePhone(phone);
const storedPhone = propPhone || readStoredPhone();
useEffect(() => {
if (propPhone) {
try {
window.localStorage.setItem(PHONE_STORAGE_KEY, propPhone);
} catch (e) {
void e;
}
}
}, [propPhone]);
const phoneNorm = storedPhone || normalizePhone(phoneInput);
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;
const subscribe = useCallback(async (overridePhone) => {
const resolvedPhone = overridePhone || normalizePhone(phoneInput) || storedPhone;
if (!resolvedPhone) {
setStatus("need_phone");
return;
}
if (resolvedPhone !== storedPhone) {
try {
window.localStorage.setItem(PHONE_STORAGE_KEY, resolvedPhone);
} catch (e) {
void e;
}
}
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),
});
}
const sub = subscription.toJSON();
const resp = await fetch(getEndpoint("push-subscribe"), {
method: "POST",
headers: apiHeaders,
body: JSON.stringify({
phone_normalized: resolvedPhone,
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");
}
}
}, [phoneInput, storedPhone, orderGroupId]);
// Init: check support → check existing subscription → auto-subscribe if possible
useEffect(() => {
if (isIOSNotInstalled) {
setStatus("ios_not_installed");
return;
}
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
setStatus("unsupported");
return;
}
let cancelled = false;
const init = async () => {
const reg = await navigator.serviceWorker.ready;
if (cancelled) return;
const existing = await reg.pushManager.getSubscription();
if (cancelled) return;
if (existing) {
setStatus("subscribed");
return;
}
// No existing subscription — decide whether to auto-subscribe
const permission = Notification.permission;
if (permission === "denied") {
setStatus("denied");
return;
}
// Auto-subscribe when:
// - permission already granted (user allowed before), OR
// - standalone mode (installed PWA — iOS shows system prompt on subscribe)
// AND we have a phone to associate
const canAutoSubscribe =
(permission === "granted" || isStandalone) && storedPhone;
if (canAutoSubscribe) {
await subscribe(storedPhone);
}
// else: stay idle, show banner with button / phone input
};
init().catch(() => undefined);
return () => { cancelled = true; };
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const unsubscribe = async () => {
try {
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
if (sub) {
await sub.unsubscribe();
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
if (status === "unsupported") return null;
// Auto-subscribed or prompting — no UI needed
if (status === "subscribed" || status === "prompting") {
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>
);
}
// prompting — show minimal loading
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>
);
}
if (dismissed) 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. Разрешите уведомления при первом открытии</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>
);
}
// 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>
);
}
// Need a phone number to subscribe (opened the installed app without a token)
if (status === "need_phone" || (!phoneNorm && !isIOSNotInstalled)) {
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">
Введите номер телефона, чтобы получать push-уведомления о заказе.
</p>
<div className="mt-2 flex items-center gap-2">
<input
type="tel"
inputMode="tel"
placeholder="+7 (___) ___-__-__"
value={phoneInput}
onChange={(e) => setPhoneInput(e.target.value)}
className="min-w-0 flex-1 rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
/>
<button
type="button"
onClick={() => subscribe()}
className="flex-shrink-0 rounded-xl bg-[var(--color-accent)] px-4 py-2 text-sm font-semibold text-white transition hover:opacity-90"
>
Разрешить
</button>
</div>
</div>
</div>
</div>
);
}
// Idle — show subscription banner with button (non-standalone, permission=default)
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

@ -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(() => {
@ -171,7 +225,8 @@ 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({});
};
@ -196,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;
@ -226,67 +290,7 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
}
}, [items.length, shippedCount, unshippedCount, unshippedWithoutComment, allShipped, shippedItems, comments, onShipmentChange]);
if (items.length === 0) {
return (
<Panel className="space-y-3 p-5 fs-zone-card">
<strong>Состав заказа</strong>
<p className="text-sm text-[var(--color-text-muted)]">Позиции не указаны</p>
</Panel>
);
}
return (
<Panel className="space-y-4 p-5 fs-zone-card">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<strong>Отгрузка</strong>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
Отметьте позиции, которые отгружены. Для смены статуса на «Доставлено» все позиции должны быть отгружены.
</p>
</div>
<div className="flex items-center gap-2 text-sm">
<Badge tone={allShipped ? "accent" : "neutral"}>
{shippedCount}/{items.length} отгружено
</Badge>
</div>
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={shipAll} disabled={allShipped}>
Отгрузить всё
</Button>
<Button variant="ghost" size="sm" onClick={unshipAll} disabled={shippedCount === 0 && !isStatusFinal}>
Сбросить отгрузку
</Button>
</div>
{showResetConfirm && (
<div className="rounded-xl border border-[var(--color-warning)] bg-[var(--color-warning-soft)] p-4 space-y-3">
<p className="text-sm font-medium text-[var(--color-text)]">
Сбросить отгрузку и вернуть статус?
</p>
<p className="text-xs text-[var(--color-text-muted)]">
Текущий статус («{currentDeliveryStatus === "delivered" ? "Доставлено" : currentDeliveryStatus === "problem" ? "Проблема" : "Вывезено"}») будет сброшен.
Отгрузка будет очищена, логист увидит что доставка требует доработки.
</p>
<div className="flex gap-2">
<Button
variant="primary"
size="sm"
onClick={confirmResetAll}
disabled={isSavingStatusChange}
>
{isSavingStatusChange ? "Сохраняем..." : "Да, сбросить"}
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowResetConfirm(false)}>
Отмена
</Button>
</div>
</div>
)}
<div className="space-y-2">
{items.map((item) => {
const renderItem = (item) => {
const isShipped = shippedItems.has(item.id);
const hasComment = !isShipped && comments[item.id]?.trim();
return (
@ -332,8 +336,155 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
</label>
</div>
);
})}
};
if (items.length === 0) {
return (
<div className="space-y-3">
<p className="text-sm text-[var(--color-text-muted)]">Позиции не указаны</p>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm">
<Badge tone={allShipped ? "accent" : "neutral"}>
{shippedCount}/{items.length} отгружено
</Badge>
</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>
{showResetConfirm && (
<div className="rounded-xl border border-[var(--color-warning)] bg-[var(--color-warning-soft)] p-4 space-y-3">
<p className="text-sm font-medium text-[var(--color-text)]">
Сбросить отгрузку и вернуть статус?
</p>
<p className="text-xs text-[var(--color-text-muted)]">
Текущий статус («{currentDeliveryStatus === "delivered" ? "Доставлено" : currentDeliveryStatus === "problem" ? "Проблема" : "Вывезено"}») будет сброшен.
Отгрузка будет очищена, логист увидит что доставка требует доработки.
</p>
<div className="flex gap-2">
<Button
variant="primary"
size="sm"
onClick={confirmResetAll}
disabled={isSavingStatusChange}
>
{isSavingStatusChange ? "Сохраняем..." : "Да, сбросить"}
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowResetConfirm(false)}>
Отмена
</Button>
</div>
</div>
)}
{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>
);
})
)}
</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">
@ -435,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) => (
// 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
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); }}
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-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>
<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>
</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 flex-col gap-1">
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge>
{(group.hasDeliveryProblem || group.has_delivery_problem) && (
<span
title={group.deliveryProblemNote || group.delivery_problem_note || "Есть проблемы с отгрузкой позиций"}
className="inline-flex w-fit 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)]"
>
⚠ Проблема
{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 className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
{formatDateTime(group.updatedAt)}
<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,7 +288,21 @@ const SortableSection = ({ statusValue, label, groups, isCollapsed, onToggle, on
);
};
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS, isLoading = false }) => {
// 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 = [], totalGroups: totalGroupsProp, hideCompleted = true, onToggleHideCompleted, onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS, isLoading = false }) => {
const FILTERS_KEY = 'logistics-board-filters';
const [filters, setFilters] = React.useState(() => {
try {
@ -271,6 +319,36 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
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 } }),
@ -288,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);
@ -300,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(() => {
@ -361,7 +487,7 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
Перетаскивайте секции за ручку слева, чтобы изменить порядок отображения.
</p>
</div>
<Badge tone="neutral">{totalGroups} групп</Badge>
<Badge tone="neutral">{totalGroupsProp ?? totalGroups} групп</Badge>
</div>
<OrderFilters
@ -369,7 +495,15 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
setFilters={setFilters}
statusOptions={statusOptions}
cities={cities}
hideCompleted={hideCompleted}
onToggleHideCompleted={onToggleHideCompleted}
/>
{!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 ? (
@ -410,6 +544,8 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
});
}}
onSelectSet={onSelectSet}
sort={sort}
onSort={handleSort}
/>
);
})}
@ -417,6 +553,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

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

@ -77,6 +77,89 @@ import {
normalizeNom,
} from "../../utils/deliveryUtils";
import { SmsStatusCard } from "./SmsStatusCard";
import { OrderHistoryTimeline } from "./OrderHistoryTimeline";
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
// ---- CollapsibleBlock wrapper (localStorage-persisted collapse state) ----
const COLLAPSE_STORAGE_KEY = "supersam-block-collapsed";
const CollapsibleBlock = ({ blockKey, title, children, defaultCollapsed = false, dragAttributes, dragListeners }) => {
const [collapsed, setCollapsed] = React.useState(() => {
try {
const saved = JSON.parse(localStorage.getItem(COLLAPSE_STORAGE_KEY) || "{}");
return saved[blockKey] ?? defaultCollapsed;
} catch { return defaultCollapsed; }
});
const toggle = () => {
setCollapsed(prev => {
const next = !prev;
try {
const saved = JSON.parse(localStorage.getItem(COLLAPSE_STORAGE_KEY) || "{}");
saved[blockKey] = next;
localStorage.setItem(COLLAPSE_STORAGE_KEY, JSON.stringify(saved));
} catch {}
return next;
});
};
return (
<Panel className="space-y-3 p-4">
<div className="flex w-full items-center justify-between gap-2 pb-2 border-b border-[var(--color-border)]">
<div className="flex items-center gap-2">
<button type="button" {...dragAttributes} {...dragListeners} className="cursor-grab touch-none text-[var(--color-text-muted)] hover:text-[var(--color-text)]">
<svg className="h-4 w-4" 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>
<button type="button" onClick={toggle} className="text-sm font-bold text-[var(--color-text)]">{title}</button>
</div>
<button type="button" onClick={toggle} className="text-xs text-[var(--color-text-muted)]">{collapsed ? "▼" : "▲"}</button>
</div>
{!collapsed && <div className="space-y-4 pt-2">{children}</div>}
</Panel>
);
};
// ---- SortableBlock wrapper (drag handle for reordering) ----
const SortableBlock = ({ id, children }) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div ref={setNodeRef} style={style}>
{typeof children === "function" ? children({ dragAttributes: attributes, dragListeners: listeners }) : children}
</div>
);
};
// Default block order — same keys as CollapsibleBlock blockKeys
const DEFAULT_BLOCK_ORDER = [
"manual_confirmation",
"driver_assignment",
"shipment",
"sms_status",
"status_actions",
"paid_storage",
"delivery_link",
"order_history",
"extra_data",
];
const BLOCK_TITLES = {
manual_confirmation: "Ручное согласование",
driver_assignment: "Назначение водителя",
shipment: "Отгрузка",
sms_status: "Статус SMS",
status_actions: "Действия по статусу",
paid_storage: "Платное хранение",
delivery_link: "Ссылка на согласование",
order_history: "История действий по заказу",
extra_data: "Дополнительные данные",
};
const ORDER_STORAGE_KEY = "supersam-block-order";
const fmtTime = (ts) => {
if (!ts) return "—";
@ -451,10 +534,10 @@ const PaidStoragePanel = ({ order, onChangeDeliveryStatus, isSavingStatusChange,
if (isPaidStorage) {
return (
<Panel className="space-y-4 p-5">
<div className="space-y-4">
<div className="flex items-center gap-2">
<span className="inline-flex h-2 w-2 rounded-full bg-[var(--color-warning)]"></span>
<strong>Платное хранение</strong>
<span className="font-semibold text-[var(--color-text)]">Заказ на платном хранении</span>
</div>
{order.paidStorageAt && (
<p className="text-sm text-[var(--color-text-muted)]">
@ -479,15 +562,14 @@ const PaidStoragePanel = ({ order, onChangeDeliveryStatus, isSavingStatusChange,
>
Отменить платное хранение
</Button>
</Panel>
</div>
);
}
return (
<Panel className="space-y-4 p-5">
<div className="space-y-4">
<div>
<strong>Платное хранение</strong>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
<p className="text-sm text-[var(--color-text-muted)]">
Переведите заказ в статус платного хранения, если клиент не забрал товар в срок.
</p>
</div>
@ -533,7 +615,7 @@ const PaidStoragePanel = ({ order, onChangeDeliveryStatus, isSavingStatusChange,
Перевести в платное хранение
</Button>
)}
</Panel>
</div>
);
};
@ -599,6 +681,41 @@ export const OrderDetailPanel = ({
const [deliveryAddress, setDeliveryAddress] = React.useState(order?.originalDeliveryAddress || order?.deliveryAddress || order?.customerAddress || "");
const [confirmAction, setConfirmAction] = React.useState(null);
const [isEditingDate, setIsEditingDate] = React.useState(false);
// ---- Drag-and-drop block reordering state ----
const isLogisticsRole = ["manager", "logistician", "admin", "mega_admin"].includes(userRole);
const [blockOrder, setBlockOrder] = React.useState(() => {
try {
const saved = JSON.parse(localStorage.getItem(ORDER_STORAGE_KEY) || "[]");
if (Array.isArray(saved) && saved.length > 0) return saved;
} catch {}
return DEFAULT_BLOCK_ORDER;
});
const [activeTab, setActiveTab] = React.useState(() => {
try {
return localStorage.getItem("supersam-active-tab") || "all";
} catch {}
return "all";
});
const handleTabChange = (tab) => {
setActiveTab(tab);
try { localStorage.setItem("supersam-active-tab", tab); } catch {}
};
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
);
const handleDragEnd = (event) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setBlockOrder(prev => {
const oldIndex = prev.indexOf(active.id);
const newIndex = prev.indexOf(over.id);
if (oldIndex === -1 || newIndex === -1) return prev;
const next = arrayMove(prev, oldIndex, newIndex);
try { localStorage.setItem(ORDER_STORAGE_KEY, JSON.stringify(next)); } catch {}
return next;
});
};
const handleShipmentChange = React.useCallback((state) => {
setShipmentState(state);
}, []);
@ -779,6 +896,353 @@ export const OrderDetailPanel = ({
}
};
// Render the inner content of a block by blockKey (used in both DnD "all" mode and single-tab mode)
const renderBlockContent = (blockKey) => {
if (blockKey === "manual_confirmation" && canManageDelivery) {
return (
<>
<p className="text-sm text-[var(--color-text-muted)]">
{isDeliveryAgreed
? "Дата и время уже зафиксированы."
: "Если клиент согласовал доставку или самовывоз по телефону, сохраните дату и время здесь."}
</p>
{/* Delivery type tabs */}
<div className="flex gap-2 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-1">
<button
type="button"
className={["flex-1 rounded-xl px-3 py-2 text-sm font-semibold transition",
deliveryType === "delivery"
? "bg-[var(--color-accent)] text-[var(--color-accent-contrast)]"
: "text-[var(--color-text-muted)] hover:bg-[var(--color-accent-soft)]"
].join(" ")}
onClick={() => { setDeliveryType("delivery"); setFormMessage(""); }}
>
🚚 Доставка
</button>
<button
type="button"
className={["flex-1 rounded-xl px-3 py-2 text-sm font-semibold transition",
deliveryType === "pickup"
? "bg-[var(--color-accent)] text-[var(--color-accent-contrast)]"
: "text-[var(--color-text-muted)] hover:bg-[var(--color-accent-soft)]"
].join(" ")}
onClick={() => { setDeliveryType("pickup"); setFormMessage(""); }}
>
🏪 Самовывоз
</button>
</div>
{deliveryType === "pickup" && (
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3 text-sm text-[var(--color-text-muted)]">
<p className="font-semibold text-[var(--color-text)]">ℹ️ Условия хранения</p>
<p className="mt-1">Бесплатное хранение — <strong>2 рабочих дня</strong> с даты готовности.</p>
<p>Начиная с 3-го рабочего дня — <strong>300 ₽/день</strong> платного хранения.</p>
</div>
)}
{deliveryType === "delivery" && (
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
Адрес доставки
</label>
<input
type="text"
value={deliveryAddress}
onChange={(e) => setDeliveryAddress(e.target.value)}
placeholder="Введите адрес доставки"
className="w-full rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-sm !text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)] focus:outline-none"
/>
</div>
)}
{agreedTypeMatchesTab ? (
<div className="space-y-3">
<div className="rounded-[24px] border border-[rgba(18,128,92,0.35)] bg-[var(--color-accent-soft)] p-4 !text-[var(--color-text)]">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-accent)]">
{deliveryType === "pickup" ? "Самовывоз согласован" : "Доставка согласована"}
</p>
<p className="mt-1 text-lg font-semibold">
{agreedDeliveryLabel || "Дата не указана — нажмите «Изменить дату»"}
</p>
</div>
<Badge tone="accent">Согласовано</Badge>
</div>
</div>
{canEditDelivery ? (
<Button
variant="secondary"
onClick={() => { setIsEditingDate(true); setFormMessage(""); }}
disabled={isSavingDeliveryChoice}
className="text-sm"
>
Изменить дату {deliveryType === "pickup" ? "самовывоза" : "доставки"}
</Button>
) : null}
</div>
) : deliveryType === "delivery" ? (
<CalendarWidget
label="Календарь доставки"
selectedDate={deliveryDate}
onDateChange={(dateKey) => { setDeliveryDate(dateKey); setFormMessage(""); }}
minDateKey={minSelectableDateKey}
isCalendarOpen={isCalendarOpen}
setIsCalendarOpen={setIsCalendarOpen}
currentMonth={currentMonth}
setCurrentMonth={setCurrentMonth}
calendarDays={calendarDays}
monthLabel={monthLabel}
canGoBack={canGoBack}
timeOptions={DELIVERY_TIME_OPTIONS}
selectedTime={deliveryTime}
onTimeChange={(option) => { setDeliveryTime(option); setFormMessage(""); }}
layoutClassName="flex flex-col gap-3 md:flex-row md:items-start md:relative md:z-10"
calendarClassName="relative space-y-3 md:min-w-0 md:flex-1 md:pr-4"
timeClassName="grid gap-2 sm:grid-cols-2 md:w-[320px] md:flex-none"
/>
) : (
<CalendarWidget
label="Календарь самовывоза"
selectedDate={pickupDate}
onDateChange={(dateKey) => { setPickupDate(dateKey); setFormMessage(""); }}
minDateKey={minSelectableDateKey}
isCalendarOpen={isCalendarOpen}
setIsCalendarOpen={setIsCalendarOpen}
currentMonth={currentMonth}
setCurrentMonth={setCurrentMonth}
calendarDays={calendarDays}
monthLabel={monthLabel}
canGoBack={canGoBack}
timeOptions={DELIVERY_TIME_OPTIONS}
selectedTime={pickupTimeSlot}
onTimeChange={(option) => { setPickupTimeSlot(option); setFormMessage(""); }}
layoutClassName="space-y-3"
calendarClassName="relative"
timeClassName="grid gap-2 sm:grid-cols-2"
/>
)}
<Button
className="w-full md:w-[180px] md:flex-none md:self-start"
onClick={() => setConfirmAction({ type: 'delivery' })}
disabled={isSavingDeliveryChoice}
>
{isSavingDeliveryChoice ? "Сохраняем..." : "Согласовать"}
</Button>
{formMessage ? (
<p className="text-sm text-[var(--color-text-muted)]">{formMessage}</p>
) : null}
</>
);
}
if (blockKey === "driver_assignment" && deliveryType === "delivery") {
return (
<DriverAssignmentPanel
order={order}
userRole={userRole}
canManageDelivery={canManageDelivery}
isSavingDriverAssignment={isSavingDriverAssignment}
selectedDriverId={selectedDriverId}
onDriverSelect={(id) => { setSelectedDriverId(id); setDriverMessage(""); }}
onConfirmDriver={() => setConfirmAction({ type: 'driver' })}
driverMessage={driverMessage}
drivers={drivers}
/>
);
}
if (blockKey === "shipment") {
return (
<DriverShipmentPanel
order={order}
onShipmentChange={handleShipmentChange}
onSaveShipment={handleSaveShipment}
isSavingShipment={isSavingShipment}
onResetStatus={() => {
if (onChangeDeliveryStatus) {
onChangeDeliveryStatus({
orderGroupId: order.id,
status: "driver_assigned",
}).then((response) => {
if (!response.success) {
setFormMessage(response.error || "Не удалось сбросить статус");
} else {
setFormMessage("Статус сброшен, отгрузка очищена");
}
});
}
}}
isSavingStatusChange={isSavingStatusChange}
groupByOrder={true}
/>
);
}
if (blockKey === "sms_status") {
return (
<SmsStatusCard order={order} userRole={userRole} />
);
}
if (blockKey === "status_actions") {
return (
<>
<StatusActionPanel
order={order}
userRole={userRole}
canManageDelivery={canManageDelivery}
isSavingStatusChange={isSavingStatusChange}
onRefreshOrder={() => {}}
onConfirmStatus={(action) => {
if (action.type === "hint") {
setFormMessage(action.hint);
} else if (action.type === "status") {
setConfirmAction({
type: "status",
status: action.status,
label: action.label,
mismatch: action.mismatch,
deliveryType: action.deliveryType,
});
}
}}
/>
{formMessage && onChangeDeliveryStatus ? (
<p className="text-sm text-[var(--color-warning)]">{formMessage}</p>
) : null}
</>
);
}
if (blockKey === "paid_storage" && onChangeDeliveryStatus) {
return (
<PaidStoragePanel
order={order}
onChangeDeliveryStatus={onChangeDeliveryStatus}
isSavingStatusChange={isSavingStatusChange}
setFormMessage={setFormMessage}
/>
);
}
if (blockKey === "delivery_link" && order?.deliveryLink) {
return (
<>
<p className="text-sm text-[var(--color-text-muted)]">
Отправьте эту ссылку клиенту, чтобы он мог согласовать доставку или самовывоз самостоятельно.
</p>
<div className="flex flex-wrap items-center gap-2">
<a
href={order.deliveryLink}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-2xl bg-[var(--color-accent)] px-4 py-2.5 text-sm font-semibold text-white transition hover:opacity-90"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
Открыть страницу согласования
</a>
<Button
variant="secondary"
onClick={() => {
navigator.clipboard?.writeText(order.deliveryLink).then(() => {
setFormMessage("Ссылка скопирована в буфер обмена");
setTimeout(() => setFormMessage(""), 3000);
}).catch(() => {
setFormMessage("Не удалось скопировать ссылку");
});
}}
>
<span className="inline-flex items-center gap-1.5">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
Скопировать ссылку
</span>
</Button>
</div>
{order.invitationAccessCount > 0 ? (
<p className="text-sm text-[var(--color-text-muted)]">
👁 Клиент открывал страницу {order.invitationAccessCount} раз{(order.invitationLastAccessedAt || order.invitationOpenedAt) ? `, последний раз ${new Date(order.invitationLastAccessedAt || order.invitationOpenedAt).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "2-digit" })}` : ""}.
</p>
) : (
<p className="text-sm text-[var(--color-text-muted)]">
⏳ Клиент ещё не открывал страницу согласования.
</p>
)}
</>
);
}
if (blockKey === "order_history") {
return (
<OrderHistoryTimeline order={order} userRole={userRole} />
);
}
if (blockKey === "extra_data") {
return (
<div className="grid gap-4 md:grid-cols-2">
{order.managerName ? (
<div>
<p className="text-xs text-[var(--color-text-muted)]">Менеджер</p>
<p className="mt-1 font-medium !text-[var(--color-text)]">{order.managerName}</p>
{order.managerTel ? (
<a href={`tel:${order.managerTel}`} className="text-sm text-[var(--color-accent)] hover:underline">{order.managerTel}</a>
) : null}
</div>
) : null}
<div>
<p className="text-xs text-[var(--color-text-muted)]">Оплата доставки</p>
<p className="mt-1 font-medium !text-[var(--color-text)]">
{order.isPayedShip ? (
<span className="inline-flex items-center gap-1.5">
<span className="rounded-full bg-[var(--color-accent-soft)] px-2 py-0.5 text-xs font-semibold text-[var(--color-accent)]">✓ Оплачено</span>
{order.payedShip ? <span className="text-sm">{Number(order.payedShip).toLocaleString("ru-RU")} ₽</span> : null}
</span>
) : (
<span className="text-sm text-[var(--color-text-muted)]">Не оплачено</span>
)}
</p>
</div>
{order.firstSmsSentAt ? (
<div>
<p className="text-xs text-[var(--color-text-muted)]">1-е SMS отправлено</p>
<p className="mt-1 font-medium !text-[var(--color-text)]">{formatDateTime(order.firstSmsSentAt)}</p>
</div>
) : null}
{order.secondSmsSentAt ? (
<div>
<p className="text-xs text-[var(--color-text-muted)]">2-е SMS отправлено</p>
<p className="mt-1 font-medium !text-[var(--color-text)]">{formatDateTime(order.secondSmsSentAt)}</p>
</div>
) : null}
{!order.firstSmsSentAt && !order.secondSmsSentAt ? (
<div>
<p className="text-xs text-[var(--color-text-muted)]">SMS отправлено</p>
<p className="mt-1 font-medium !text-[var(--color-text)]">Нет</p>
</div>
) : null}
<div>
<p className="text-xs text-[var(--color-text-muted)]">Ручное согласование выполнено</p>
<p className="mt-1 font-medium !text-[var(--color-text)]">{order.manualConfirmationAt ? formatDateTime(order.manualConfirmationAt) : "Нет"}</p>
</div>
<div>
<p className="text-xs text-[var(--color-text-muted)]">Платное хранение</p>
<p className="mt-1 font-medium !text-[var(--color-text)]">{order.paidStorageAt ? formatDateTime(order.paidStorageAt) : "Нет"}</p>
</div>
{order.createdFromExchangeAt ? (
<div>
<p className="text-xs text-[var(--color-text-muted)]">Создано из обмена</p>
<p className="mt-1 font-medium !text-[var(--color-text)]">{formatDateTime(order.createdFromExchangeAt)}</p>
</div>
) : null}
</div>
);
}
return null;
};
return (
<div className="space-y-5">
<Panel className="space-y-5 p-6">
@ -942,216 +1406,81 @@ export const OrderDetailPanel = ({
</div>
</Panel>
{canManageDelivery ? (
<Panel className="space-y-4 p-5">
<div>
<strong>Ручное согласование</strong>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
{isDeliveryAgreed
? "Дата и время уже зафиксированы."
: "Если клиент согласовал доставку или самовывоз по телефону, сохраните дату и время здесь."}
</p>
</div>
{/* Delivery type tabs */}
<div className="flex gap-2 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-1">
{/* ===== Tabbed blocks (logistics/admin/manager only) ===== */}
{isLogisticsRole && order ? (
<>
{/* Tab bar */}
<div className="flex gap-2 overflow-x-auto pb-2">
<button
type="button"
className={["flex-1 rounded-xl px-3 py-2 text-sm font-semibold transition",
deliveryType === "delivery"
? "bg-[var(--color-accent)] text-[var(--color-accent-contrast)]"
: "text-[var(--color-text-muted)] hover:bg-[var(--color-accent-soft)]"
onClick={() => handleTabChange("all")}
className={["rounded-xl px-3 py-2 text-sm font-medium whitespace-nowrap transition",
activeTab === "all"
? "bg-[var(--color-accent)] text-white"
: "bg-[var(--color-surface)] text-[var(--color-text-muted)]"
].join(" ")}
onClick={() => { setDeliveryType("delivery"); setFormMessage(""); }}
>
🚚 Доставка
Все
</button>
{blockOrder.map(blockKey => {
// Only show tab if the block would be visible (match gating conditions)
if (blockKey === "manual_confirmation" && !canManageDelivery) return null;
if (blockKey === "driver_assignment" && deliveryType !== "delivery") return null;
if (blockKey === "paid_storage" && !onChangeDeliveryStatus) return null;
if (blockKey === "delivery_link" && !order?.deliveryLink) return null;
return (
<button
key={blockKey}
type="button"
className={["flex-1 rounded-xl px-3 py-2 text-sm font-semibold transition",
deliveryType === "pickup"
? "bg-[var(--color-accent)] text-[var(--color-accent-contrast)]"
: "text-[var(--color-text-muted)] hover:bg-[var(--color-accent-soft)]"
onClick={() => handleTabChange(blockKey)}
className={["rounded-xl px-3 py-2 text-sm font-medium whitespace-nowrap transition",
activeTab === blockKey
? "bg-[var(--color-accent)] text-white"
: "bg-[var(--color-surface)] text-[var(--color-text-muted)]"
].join(" ")}
onClick={() => { setDeliveryType("pickup"); setFormMessage(""); }}
>
🏪 Самовывоз
{BLOCK_TITLES[blockKey]}
</button>
);
})}
</div>
{deliveryType === "pickup" && (
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3 text-sm text-[var(--color-text-muted)]">
<p className="font-semibold text-[var(--color-text)]">ℹ️ Условия хранения</p>
<p className="mt-1">Бесплатное хранение — <strong>2 рабочих дня</strong> с даты готовности.</p>
<p>Начиная с 3-го рабочего дня — <strong>300 ₽/день</strong> платного хранения.</p>
</div>
{/* Block content — "all" mode = DnD + collapsible, single-tab = just the block content */}
{activeTab === "all" ? (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={blockOrder} strategy={verticalListSortingStrategy}>
{blockOrder.map(blockKey => {
const renderWith = (extraProps = {}) => {
if (blockKey === "extra_data") return { ...extraProps, defaultCollapsed: true };
return extraProps;
};
// Check visibility gates
if (blockKey === "manual_confirmation" && !canManageDelivery) return null;
if (blockKey === "driver_assignment" && deliveryType !== "delivery") return null;
if (blockKey === "paid_storage" && !onChangeDeliveryStatus) return null;
if (blockKey === "delivery_link" && !order?.deliveryLink) return null;
return (
<SortableBlock key={blockKey} id={blockKey}>
{({ dragAttributes, dragListeners }) => (
<CollapsibleBlock blockKey={blockKey} title={BLOCK_TITLES[blockKey]} dragAttributes={dragAttributes} dragListeners={dragListeners} {...renderWith()}>
{renderBlockContent(blockKey)}
</CollapsibleBlock>
)}
{deliveryType === "delivery" && (
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
Адрес доставки
</label>
<input
type="text"
value={deliveryAddress}
onChange={(e) => setDeliveryAddress(e.target.value)}
placeholder="Введите адрес доставки"
className="w-full rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-sm !text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)] focus:outline-none"
/>
</div>
)}
{agreedTypeMatchesTab ? (
<div className="space-y-3">
<div className="rounded-[24px] border border-[rgba(18,128,92,0.35)] bg-[var(--color-accent-soft)] p-4 !text-[var(--color-text)]">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-accent)]">
{deliveryType === "pickup" ? "Самовывоз согласован" : "Доставка согласована"}
</p>
<p className="mt-1 text-lg font-semibold">
{agreedDeliveryLabel || "Дата не указана — нажмите «Изменить дату»"}
</p>
</div>
<Badge tone="accent">Согласовано</Badge>
</div>
</div>
{canEditDelivery ? (
<Button
variant="secondary"
onClick={() => { setIsEditingDate(true); setFormMessage(""); }}
disabled={isSavingDeliveryChoice}
className="text-sm"
>
Изменить дату {deliveryType === "pickup" ? "самовывоза" : "доставки"}
</Button>
) : null}
</div>
) : deliveryType === "delivery" ? (
<CalendarWidget
label="Календарь доставки"
selectedDate={deliveryDate}
onDateChange={(dateKey) => { setDeliveryDate(dateKey); setFormMessage(""); }}
minDateKey={minSelectableDateKey}
isCalendarOpen={isCalendarOpen}
setIsCalendarOpen={setIsCalendarOpen}
currentMonth={currentMonth}
setCurrentMonth={setCurrentMonth}
calendarDays={calendarDays}
monthLabel={monthLabel}
canGoBack={canGoBack}
timeOptions={DELIVERY_TIME_OPTIONS}
selectedTime={deliveryTime}
onTimeChange={(option) => { setDeliveryTime(option); setFormMessage(""); }}
layoutClassName="flex flex-col gap-3 md:flex-row md:items-start md:relative md:z-10"
calendarClassName="relative space-y-3 md:min-w-0 md:flex-1 md:pr-4"
timeClassName="grid gap-2 sm:grid-cols-2 md:w-[320px] md:flex-none"
/>
</SortableBlock>
);
})}
</SortableContext>
</DndContext>
) : (
<CalendarWidget
label="Календарь самовывоза"
selectedDate={pickupDate}
onDateChange={(dateKey) => { setPickupDate(dateKey); setFormMessage(""); }}
minDateKey={minSelectableDateKey}
isCalendarOpen={isCalendarOpen}
setIsCalendarOpen={setIsCalendarOpen}
currentMonth={currentMonth}
setCurrentMonth={setCurrentMonth}
calendarDays={calendarDays}
monthLabel={monthLabel}
canGoBack={canGoBack}
timeOptions={DELIVERY_TIME_OPTIONS}
selectedTime={pickupTimeSlot}
onTimeChange={(option) => { setPickupTimeSlot(option); setFormMessage(""); }}
layoutClassName="space-y-3"
calendarClassName="relative"
timeClassName="grid gap-2 sm:grid-cols-2"
/>
)}
<Button
className="w-full md:w-[180px] md:flex-none md:self-start"
onClick={() => setConfirmAction({ type: 'delivery' })}
disabled={isSavingDeliveryChoice}
>
{isSavingDeliveryChoice ? "Сохраняем..." : "Согласовать"}
</Button>
{formMessage ? (
<p className="text-sm text-[var(--color-text-muted)]">{formMessage}</p>
) : null}
<Panel className="space-y-4 p-4">
<div className="font-bold border-b pb-2 mb-2">{BLOCK_TITLES[activeTab]}</div>
{renderBlockContent(activeTab)}
</Panel>
) : null}
{deliveryType === "delivery" && (
<DriverAssignmentPanel
order={order}
userRole={userRole}
canManageDelivery={canManageDelivery}
isSavingDriverAssignment={isSavingDriverAssignment}
selectedDriverId={selectedDriverId}
onDriverSelect={(id) => { setSelectedDriverId(id); setDriverMessage(""); }}
onConfirmDriver={() => setConfirmAction({ type: 'driver' })}
driverMessage={driverMessage}
drivers={drivers}
/>
)}
<SmsStatusCard order={order} userRole={userRole} />
<StatusActionPanel
order={order}
userRole={userRole}
canManageDelivery={canManageDelivery}
isSavingStatusChange={isSavingStatusChange}
onConfirmStatus={(action) => {
if (action.type === "hint") {
setFormMessage(action.hint);
} else if (action.type === "status") {
setConfirmAction({
type: "status",
status: action.status,
label: action.label,
mismatch: action.mismatch,
deliveryType: action.deliveryType,
});
}
}}
/>
{formMessage && ["manager", "logistician", "admin", "mega_admin"].includes(userRole) && order && onChangeDeliveryStatus ? (
<p className="text-sm text-[var(--color-warning)]">{formMessage}</p>
) : null}
{["manager", "logistician", "admin", "mega_admin"].includes(userRole) && order && onChangeDeliveryStatus ? (
<PaidStoragePanel
order={order}
onChangeDeliveryStatus={onChangeDeliveryStatus}
isSavingStatusChange={isSavingStatusChange}
setFormMessage={setFormMessage}
/>
) : null}
{["driver", "logistician", "admin", "mega_admin"].includes(userRole) && order ? (
<DriverShipmentPanel
order={order}
onShipmentChange={handleShipmentChange}
onSaveShipment={handleSaveShipment}
isSavingShipment={isSavingShipment}
onResetStatus={() => {
if (onChangeDeliveryStatus) {
onChangeDeliveryStatus({
orderGroupId: order.id,
status: "driver_assigned",
}).then((response) => {
if (!response.success) {
setFormMessage(response.error || "Не удалось сбросить статус");
} else {
setFormMessage("Статус сброшен, отгрузка очищена");
}
});
}
}}
isSavingStatusChange={isSavingStatusChange}
/>
</>
) : null}
{/* ===== Driver-only blocks (not sortable/collapsible) ===== */}
{userRole === "driver" && order && onChangeDeliveryStatus ? (
<Panel className="space-y-4 p-5">
<div>
@ -1176,7 +1505,6 @@ export const OrderDetailPanel = ({
let statusOptions = [];
if (currentStatus === "delivered" || currentStatus === "picked_up" || currentStatus === "problem") {
// Final statuses — show "Return to work" instead
statusOptions = [];
} else if (currentStatus === "cancelled" || currentStatus === "paid_storage") {
statusOptions = [];
@ -1203,7 +1531,6 @@ export const OrderDetailPanel = ({
return statusOptions.map((statusOption) => {
const isSelected = pendingStatus?.value === statusOption.value;
const isMismatch = statusOption.mismatch;
const blockedBySchedule = statusOption.requiresSchedule && !hasDeliverySchedule;
return (
<Button
key={statusOption.value}
@ -1303,6 +1630,58 @@ export const OrderDetailPanel = ({
</Panel>
) : null}
{/* ===== Non-logistics blocks (client views) ===== */}
{!isLogisticsRole ? (
<>
{/* Driver shipment panel for driver role */}
{userRole === "driver" && order ? (
<DriverShipmentPanel
order={order}
onShipmentChange={handleShipmentChange}
onSaveShipment={handleSaveShipment}
isSavingShipment={isSavingShipment}
onResetStatus={() => {
if (onChangeDeliveryStatus) {
onChangeDeliveryStatus({
orderGroupId: order.id,
status: "driver_assigned",
}).then((response) => {
if (!response.success) {
setFormMessage(response.error || "Не удалось сбросить статус");
} else {
setFormMessage("Статус сброшен, отгрузка очищена");
}
});
}
}}
isSavingStatusChange={isSavingStatusChange}
groupByOrder={false}
/>
) : null}
<SmsStatusCard order={order} userRole={userRole} />
<StatusActionPanel
order={order}
userRole={userRole}
canManageDelivery={canManageDelivery}
isSavingStatusChange={isSavingStatusChange}
onConfirmStatus={(action) => {
if (action.type === "hint") {
setFormMessage(action.hint);
} else if (action.type === "status") {
setConfirmAction({
type: "status",
status: action.status,
label: action.label,
mismatch: action.mismatch,
deliveryType: action.deliveryType,
});
}
}}
/>
{/* Счета + Состав заказа — separate panels for client/non-logistics views only. */}
<Panel className="space-y-4 p-5">
<strong>Счета</strong>
{(() => {
@ -1393,10 +1772,6 @@ export const OrderDetailPanel = ({
</Panel>
) : null}
{userRole !== "driver" && (order?.driver_shipment_data || order?.driverShipmentData) ? (
<DriverShipmentReport shipmentData={order.driver_shipment_data || order.driverShipmentData} />
) : null}
{userRole !== "driver" ? (
<Panel className="space-y-4 p-5">
<strong>Дополнительные данные</strong>
<div className="grid gap-4 md:grid-cols-2">
@ -1456,6 +1831,7 @@ export const OrderDetailPanel = ({
) : null}
</div>
</Panel>
</>
) : null}
<ConfirmModal

View File

@ -3,6 +3,7 @@ import { Button } from "../UI/Button";
import { Input } from "../UI/Input";
import { Panel } from "../UI/Panel";
import { DatePicker } from "../UI/DatePicker";
import { Switch } from "../UI/Switch";
const DropdownSelect = ({ value, options, onChange, placeholder }) => {
const [isOpen, setIsOpen] = React.useState(false);
@ -46,7 +47,7 @@ const DropdownSelect = ({ value, options, onChange, placeholder }) => {
{isOpen ? (
<div
role="listbox"
className="absolute left-0 right-0 top-full z-20 mt-2 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-dropdown-surface)] shadow-soft"
className="absolute left-0 top-full z-20 mt-2 w-max min-w-full whitespace-nowrap overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-dropdown-surface)] shadow-soft"
>
{options.map((option) => {
const isSelected = option.value === value;
@ -77,7 +78,7 @@ const DropdownSelect = ({ value, options, onChange, placeholder }) => {
);
};
export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities = [] }) => {
export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities = [], hideCompleted = true, onToggleHideCompleted }) => {
const statusValue = filters.displayStatus || filters.status || "all";
const selectedStatusLabel = statusOptions.find((option) => option.value === statusValue)?.label || statusValue;
const [isStatusOpen, setIsStatusOpen] = React.useState(false);
@ -120,11 +121,17 @@ 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">
{/* Row 1: Status + City + DeliveryType + Search */}
<div className="grid gap-3 md:grid-cols-[minmax(12rem,0.7fr)_minmax(0,0.5fr)_minmax(0,0.5fr)_minmax(0,1.4fr)] md:items-end">
{/* Row 1: Status + City + DeliveryType + Search (search spans wider) */}
<div className="grid gap-3 md:grid-cols-[minmax(12rem,0.7fr)_minmax(0,0.5fr)_minmax(0,0.5fr)_minmax(0,1.6fr)] md:items-end">
<div ref={statusMenuRef} className="relative flex min-w-0 flex-col gap-2">
<button
type="button"
@ -198,8 +205,10 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities =
/>
</div>
{/* Row 2: Date range */}
<div className="flex items-end gap-3 flex-wrap">
{/* Row 2: Date range + SMS problems (width matches its text) + hide-completed toggle.
Align by the bottom edge of the input fields, not by center — dates have a
label above the field, so items-center would lift SMS/toggle above them. */}
<div className="flex flex-wrap items-end gap-3">
<DatePicker
value={filters.dateFrom || ""}
onChange={(v) => updateFilter("dateFrom", v)}
@ -223,6 +232,22 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities =
✕ Сбросить
</button>
)}
<DropdownSelect
value={filters.smsStatus || "all"}
options={smsProblemOptions}
onChange={(v) => updateFilter("smsStatus", v)}
placeholder="Проблемы SMS"
/>
{typeof onToggleHideCompleted === "function" && (
<Switch
checked={hideCompleted}
onChange={onToggleHideCompleted}
label="Скрыть завершённые"
title="Доставленные и отгруженные заказы скрыты"
/>
)}
</div>
</div>
</Panel>

View File

@ -0,0 +1,731 @@
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, smsLogs = []) => {
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;
}
// SMS stage counts as done when any first_sms record exists (even failed), not just when delivered
const hasFirstSmsLog = smsLogs.some((l) => l.campaign_type === "first_sms");
if (firstSms || hasFirstSmsLog || ["first_sms_sent", "second_sms_sent", "confirmed"].includes(notifStatus)) {
return 1;
}
return 0;
};
const ProgressStepper = ({ order, smsLogs = [] }) => {
const isPickup = (order?.deliveryType || order?.delivery_type) === "pickup";
const stages = isPickup ? STAGES_PICKUP : STAGES_DELIVERY;
const activeIdx = getActiveStageIndex(order, smsLogs);
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, smsLogs = [] }) => {
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 — prefer sms_campaign_log when available (real delivery state)
const firstSmsLog = smsLogs.find((l) => l.campaign_type === "first_sms");
const secondSmsLog = smsLogs.find((l) => l.campaign_type === "second_sms");
const isDelivered = (log) => log && (log.status === "delivered" || log.sms_code === "103");
const isFailed = (log) => log && ["expired", "error", "send_failed", "limit_exceeded"].includes(log.status);
if (isDelivered(firstSmsLog)) {
items.push({ icon: "📨", label: "1-е SMS", value: `доставлено ${fmtTimestamp(firstSmsLog.updated_at || firstSmsLog.created_at)}`, tone: "ok" });
} else if (isFailed(firstSmsLog)) {
items.push({ icon: "📨", label: "1-е SMS", value: `${fmtTimestamp(firstSmsLog.created_at)} · ${firstSmsLog.status === "expired" ? "не доставлено (срок истёк)" : firstSmsLog.status}${firstSmsLog.error_message ? ` · ${firstSmsLog.error_message}` : ""}`, tone: "danger" });
} else if (firstSms) {
items.push({ icon: "📨", label: "1-е SMS", value: fmtTimestamp(firstSms), tone: "ok" });
} else if (firstSmsLog) {
items.push({ icon: "📨", label: "1-е SMS", value: `${fmtTimestamp(firstSmsLog.created_at)} · ${firstSmsLog.status || "отправлено"}`, tone: "warning" });
} else {
items.push({ icon: "📨", label: "1-е SMS", value: "не отправлено", tone: "muted" });
}
if (isDelivered(secondSmsLog)) {
items.push({ icon: "📨", label: "2-е SMS", value: `доставлено ${fmtTimestamp(secondSmsLog.updated_at || secondSmsLog.created_at)}`, tone: "ok" });
} else if (isFailed(secondSmsLog)) {
items.push({ icon: "📨", label: "2-е SMS", value: `${fmtTimestamp(secondSmsLog.created_at)} · ${secondSmsLog.status === "expired" ? "не доставлено (срок истёк)" : secondSmsLog.status}${secondSmsLog.error_message ? ` · ${secondSmsLog.error_message}` : ""}`, tone: "danger" });
} else 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} smsLogs={smsLogs} />
<StatusSummary order={order} smsLogs={smsLogs} />
<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";
@ -212,7 +211,7 @@ export const SmsStatusCard = ({ order, userRole }) => {
const secondSmsFailed = secondSmsLog && ["expired", "error", "send_failed", "limit_exceeded"].includes(secondSmsLog.status);
return (
<Panel className="p-4">
<div className="p-4">
<div className="mb-3">
<h3 className="text-sm font-semibold text-[var(--color-text)]">📱 SMS-уведомления</h3>
</div>
@ -330,6 +329,6 @@ export const SmsStatusCard = ({ order, userRole }) => {
)}
</div>
)}
</Panel>
</div>
);
};

View File

@ -1,8 +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";
import { CalledPanel } from "./CalledPanel";
const StatusActionPanel = ({
order,
@ -10,6 +10,7 @@ const StatusActionPanel = ({
canManageDelivery,
isSavingStatusChange,
onConfirmStatus,
onRefreshOrder,
}) => {
if (!canManageDelivery || !["manager", "logistician", "admin", "mega_admin"].includes(userRole) || !order) {
return null;
@ -60,13 +61,10 @@ const StatusActionPanel = ({
];
return (
<Panel className="space-y-4 p-5">
<div>
<strong>Статус доставки</strong>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
<div className="space-y-4">
<p className="text-sm text-[var(--color-text-muted)]">
Измените статус, если водитель забыл обновить или нужна корректировка.
</p>
</div>
{/* Status indicators: show current state clearly */}
<div className="flex flex-wrap gap-2">
@ -130,7 +128,12 @@ const StatusActionPanel = ({
⚠ Чтобы поставить «Доставлено» или «Вывезено», сначала укажите дату и половину дня доставки выше.
</div>
)}
</Panel>
<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>
);
};

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from "react";
import { supabase, hasSupabaseConfig } from "../supabaseClient";
const PERIOD_DAYS = { today: 1, "7d": 7, "30d": 30, all: 0 };
const PERIOD_DAYS = { today: 1, "1d": 1, "7d": 7, "30d": 30, all: 0 };
const rpcCall = async (fnName, params) => {
if (!hasSupabaseConfig || !supabase) {

View File

@ -31,50 +31,19 @@ export function useNotifications(userId) {
fetchNotifications();
}, [fetchNotifications]);
// Realtime subscription
// NOTE: realtime (WebSocket) subscription intentionally removed (2026-08).
// The supabase realtime container kept dying and the client hung in WS retry
// loops (30-40s stalls on open). Notifications now update via REST fetch on
// page load / focus only — see handleFocus below for refresh on tab focus.
// Refresh notifications when the tab regains focus so the unread badge stays
// reasonably fresh without a live WebSocket connection.
React.useEffect(() => {
if (!hasSupabaseConfig || !userId) return;
const channel = supabase
.channel(`notifications:${userId}`)
.on(
"postgres_changes",
{
event: "INSERT",
schema: "public",
table: "notifications",
filter: `user_id=eq.${userId}`,
},
(payload) => {
const newNotif = payload.new;
setNotifications((prev) => [newNotif, ...prev].slice(0, 50));
setUnreadCount((prev) => prev + 1);
}
)
.on(
"postgres_changes",
{
event: "UPDATE",
schema: "public",
table: "notifications",
filter: `user_id=eq.${userId}`,
},
(payload) => {
const updated = payload.new;
setNotifications((prev) =>
prev.map((n) => (n.id === updated.id ? updated : n))
);
setUnreadCount((prev) =>
Math.max(0, prev + (updated.read ? -1 : 0) + (prev === 0 && !updated.read ? 1 : 0))
);
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [userId]);
const handleFocus = () => fetchNotifications();
window.addEventListener("focus", handleFocus);
return () => window.removeEventListener("focus", handleFocus);
}, [fetchNotifications, userId]);
// Fix unread count: always recalculate from notifications
React.useEffect(() => {

View File

@ -1,5 +1,5 @@
import React from "react";
import { assignDriverToOrderGroup, fetchOrderGroups, saveShipmentData, updateDeliveryStatus, updateOrderGroupDeliveryChoice } from "../services/supabase/orderGroupRepository";
import { assignDriverToOrderGroup, fetchOrderGroupById, fetchOrderGroups, readOrderGroupsCache, saveShipmentData, updateDeliveryStatus, updateOrderGroupDeliveryChoice } from "../services/supabase/orderGroupRepository";
import {
buildOrderGroupBuckets,
filterOrderGroups,
@ -9,8 +9,21 @@ import {
} from "../services/orderGroupViews";
import { getErrorMessage } from "../utils/deliveryUtils";
export const useOrderGroups = () => {
const [orderGroups, setOrderGroups] = React.useState(() => []);
export const useOrderGroups = ({
enabled = true,
details = false,
groupId = null,
hideCompleted = true,
} = {}) => {
const [orderGroups, setOrderGroups] = React.useState(() => {
// Instant paint from cache on return-to-list, then background refresh.
const cached = readOrderGroupsCache(details, hideCompleted);
return cached?.data || [];
});
const [totalGroups, setTotalGroups] = React.useState(() => {
const cached = readOrderGroupsCache(details, hideCompleted);
return cached?.total || 0;
});
const FILTERS_STORAGE_KEY = "supersam_order_filters";
const [filters, setFilters] = React.useState(() => {
try {
@ -27,19 +40,39 @@ export const useOrderGroups = () => {
} 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);
const [isSavingStatusChange, setIsSavingStatusChange] = React.useState(false);
const loadLiveData = React.useCallback(async (showLoading = true) => {
const loadLiveData = React.useCallback(async (showLoading = true, opts = {}) => {
if (showLoading) {
setIsLoading(true);
}
setLoadError("");
const groupsResult = await fetchOrderGroups();
if (!enabled) { setOrderGroups([]); setIsLoading(false); return; }
// Single-group mode: load only the requested group (avoids pulling all
// 430 groups with their full source_orders JSON — 2.6MB — for a detail view).
if (groupId) {
const singleResult = await fetchOrderGroupById(groupId);
if (!singleResult) {
return;
}
if (singleResult.error) {
setLoadError(singleResult.error?.message || "Не удалось загрузить группу доставки");
setOrderGroups([]);
setIsLoading(false);
return;
}
setOrderGroups(singleResult.data ? [singleResult.data] : []);
setIsLoading(false);
return;
}
const groupsResult = await fetchOrderGroups({ details, hideCompleted, forceRefresh: opts?.forceRefresh });
if (!groupsResult) {
return;
@ -52,30 +85,44 @@ export const useOrderGroups = () => {
return;
}
setOrderGroups(groupsResult.data || []);
const payload = groupsResult.data || {};
setOrderGroups(payload.data || []);
setTotalGroups(payload.total ?? (payload.data?.length || 0));
setIsLoading(false);
}, []);
}, [enabled, details, groupId, hideCompleted]);
// Initial load
const hasLoadedRef = React.useRef(false);
React.useEffect(() => {
let cancelled = false;
const doLoad = async () => {
setIsLoading(true);
await loadLiveData(true);
const isFirst = !hasLoadedRef.current;
if (isFirst) {
const hasCache = !!readOrderGroupsCache(details, hideCompleted);
// Full skeleton only on the very first entry; otherwise refresh silently
// so toggling the "hide completed" switch updates the list in place
// without flashing the whole page / jumping to top.
setIsLoading(!hasCache);
await loadLiveData(!hasCache, { forceRefresh: true });
hasLoadedRef.current = true;
} else {
await loadLiveData(false, { forceRefresh: 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) {
@ -241,6 +288,7 @@ export const useOrderGroups = () => {
return {
orderGroups,
allOrderGroups: orderGroups,
totalGroups,
filteredOrderGroups,
visibleOrderGroups,
selectedOrderGroup,

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from "react";
import { supabase, hasSupabaseConfig } from "../supabaseClient";
const PERIOD_DAYS = { today: 1, "7d": 7, "30d": 30, all: 0 };
const PERIOD_DAYS = { today: 1, "1d": 1, "7d": 7, "30d": 30, all: 0 };
export const usePickupStats = (period = "7d") => {
const [stats, setStats] = useState(null);

View File

@ -1,5 +1,5 @@
import React from "react";
import { supabase, hasSupabaseConfig } from "../supabaseClient";
import { hasSupabaseConfig } from "../supabaseClient";
const VAPID_PUBLIC_KEY = "BBHRiMfIGQf3EjnnPQ6QS-jS2oset-YMmufJSvw-0a974ULxLaxQqZkfB0ldz-tqH6B9WAIh9Et86i0ezjuedmU";
@ -32,22 +32,13 @@ export async function subscribeUserToPush(userId) {
}
}
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) return null;
const res = await fetch(`${supabase.supabaseUrl}/functions/v1/subscribe-push`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.access_token}`,
},
body: JSON.stringify({ subscription: subscription.toJSON() }),
});
if (!res.ok) {
console.error("Failed to save push subscription:", await res.text());
return null;
}
// NOTE (2026-08): the server-side push_subscriptions table is keyed by
// phone_normalized and is used by the CLIENT flow (PushSubscriptionBanner →
// functions/v1/push-subscribe). Staff users authenticate by email and have no
// phone in the session, so there is no working server endpoint for staff push
// subscriptions — the old code called /functions/v1/subscribe-push which does
// not exist (500 InvalidWorkerCreation). We keep the local browser
// subscription only; server-side push for staff remains out of scope.
return subscription;
}
@ -59,20 +50,9 @@ export async function unsubscribeUserFromPush() {
const subscription = await registration.pushManager.getSubscription();
if (!subscription) return;
const endpoint = subscription.endpoint;
// NOTE (2026-08): no server-side unsubscribe endpoint for staff (see
// subscribeUserToPush above). Local browser subscription is removed only.
await subscription.unsubscribe();
const { data: { session } } = await supabase.auth.getSession();
if (session?.access_token) {
await fetch(`${supabase.supabaseUrl}/functions/v1/unsubscribe-push`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.access_token}`,
},
body: JSON.stringify({ endpoint }),
});
}
}
export function usePushNotifications(userId) {

View File

@ -8,11 +8,15 @@ import { FontSettingsProvider } from "./context/FontSettingsContext";
import ErrorBoundary from "./components/ErrorBoundary";
import { initErrorLogging } from "./utils/errorLogger";
import { registerPwaServiceWorker } from "./hooks/usePwaStatus";
import { clearStalePwaCaches } from "./utils/pwaCacheCleanup";
import "./index.css";
import "./styles/fontSettings.css";
registerPwaServiceWorker();
initErrorLogging();
// Purge stale PWA caches on every startup so bloated caches (up to 3GB from old
// SW versions) are cleared even if the SW hasn't updated yet.
clearStalePwaCaches();
ReactDOM.createRoot(document.getElementById("root")).render(
<ThemeProvider>

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";
@ -15,6 +17,83 @@ import {
fetchDeliveryInvitation,
} from "../services/deliveryInvitationApi";
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || "";
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || "";
const TestPushButton = () => {
const [sending, setSending] = React.useState(false);
const [sendResult, setSendResult] = React.useState(null); // null | {ok, sent, error}
const handleClick = async () => {
setSending(true);
setSendResult(null);
try {
// Send to THIS device's own subscription (endpoint), not a phone from localStorage
let endpoint = "";
let phone = "";
try {
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
endpoint = sub?.endpoint || "";
} catch (e) {
void e;
}
phone = window.localStorage.getItem("supersam_phone") || "";
if (!endpoint && !phone) {
setSendResult({ ok: false, error: "Сначала подпишитесь на уведомления (кнопка «Разрешить» выше)" });
return;
}
const resp = await fetch(`${SUPABASE_URL}/functions/v1/send-test-push`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(SUPABASE_ANON_KEY ? { apikey: SUPABASE_ANON_KEY } : {}),
},
body: JSON.stringify({
phone_normalized: phone || undefined,
endpoint: endpoint || undefined,
title: "СуперСам — согласуйте доставку",
body: "Ваш заказ готов к доставке. Выберите удобное время.",
url: `${window.location.origin}/client/test-consent`,
}),
});
const data = await resp.json().catch(() => ({}));
setSendResult({ ok: resp.ok, ...data });
} catch (e) {
setSendResult({ ok: false, error: e instanceof Error ? e.message : "Ошибка отправки" });
} finally {
setSending(false);
}
};
return (
<div className="space-y-2">
<button
type="button"
onClick={handleClick}
disabled={sending}
className="rounded-xl bg-[var(--color-accent)] px-4 py-2 text-sm font-semibold text-white transition hover:opacity-90 disabled:opacity-50"
>
{sending ? "Отправляем…" : "Отправить тестовое уведомление"}
</button>
{sendResult?.ok ? (
<p className="text-sm text-[var(--color-accent)]">
✅ Отправлено: {sendResult.sent} уведомление(й). Проверьте телефон.
</p>
) : null}
{sendResult && !sendResult.ok ? (
<p className="text-sm text-[var(--color-danger)]">
❌ {sendResult.error || "Не удалось отправить уведомление"}
</p>
) : null}
</div>
);
};
const DELIVERY_TIMEZONE = "Europe/Simferopol";
const getBusinessTodayKey = (referenceDate = new Date()) => {
@ -339,6 +418,57 @@ 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>
<PushSubscriptionBanner
phone=""
orderGroupId={null}
/>
</Panel>
<Panel className="space-y-3 border border-dashed border-[var(--color-accent)] bg-[var(--color-surface)] p-5 sm:p-6">
<div>
<p className="text-sm font-semibold text-[var(--color-text)]">🧪 Тест уведомления</p>
<p className="text-xs text-[var(--color-text-muted)] mt-1 leading-5">
Отправить тестовое push-уведомление на этот телефон. Нажмите на уведомление —
откроется тестовая страница согласования доставки.
</p>
</div>
<TestPushButton />
</Panel>
<div className="text-center">
<a
href="/login"
className="inline-block rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-2 text-sm text-[var(--color-text-muted)] transition hover:text-[var(--color-text)]"
>
Вход для сотрудников
</a>
</div>
</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 +525,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

@ -0,0 +1,204 @@
import React, { useState } from "react";
import { Panel } from "../components/UI/Panel";
import { Button } from "../components/UI/Button";
import { formatDeliveryDate, getDeliveryRelativeDayLabel } from "../components/client/deliveryDateFormatting";
const TAB_DELIVERY = "delivery";
const TAB_PICKUP = "pickup";
const MOCK_PRODUCTS = [
{ name: "Цемент М500, 50 кг", quantity: "10", unit: "меш" },
{ name: "Песок строительный", quantity: "2", unit: "м³" },
{ name: "Кирпич рядовой", quantity: "400", unit: "шт" },
{ name: "Арматура 12 мм", quantity: "60", unit: "м" },
];
const buildMockSlots = () => {
const day = (offset) => {
const d = new Date();
d.setDate(d.getDate() + offset);
return d.toISOString().slice(0, 10);
};
const skipSunday = (dateStr) => {
const d = new Date(`${dateStr}T12:00:00Z`);
if (d.getUTCDay() === 0) {
d.setUTCDate(d.getUTCDate() + 1);
}
return d.toISOString().slice(0, 10);
};
const d1 = skipSunday(day(1));
const d2 = skipSunday(day(2));
return [
{ id: `slot-${d1}-first`, date: d1, time: "Первая половина дня" },
{ id: `slot-${d1}-second`, date: d1, time: "Вторая половина дня" },
{ id: `slot-${d2}-first`, date: d2, time: "Первая половина дня" },
{ id: `slot-${d2}-second`, date: d2, time: "Вторая половина дня" },
];
};
const groupSlotsByDate = (slots) => {
const groups = new Map();
for (const slot of slots) {
if (!groups.has(slot.date)) groups.set(slot.date, []);
groups.get(slot.date).push(slot);
}
return Array.from(groups.entries()).sort(([a], [b]) => a.localeCompare(b));
};
const SlotGroupHeading = ({ dateStr }) => {
const relative = getDeliveryRelativeDayLabel(dateStr);
const formatted = formatDeliveryDate(dateStr);
return relative
? `Доставка ${relative.charAt(0).toLowerCase()}${relative.slice(1)} · ${formatted}`
: `Доставка ${formatted}`;
};
export const ClientTestConsentPage = () => {
const [activeTab, setActiveTab] = useState(TAB_DELIVERY);
const [selectedSlot, setSelectedSlot] = useState(null);
const [saved, setSaved] = useState(false);
const slots = buildMockSlots();
const grouped = groupSlotsByDate(slots);
const typeLabel = activeTab === TAB_PICKUP ? "самовывоз" : "доставку";
const handleSave = () => {
if (!selectedSlot) return;
setSaved(true);
};
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-3 p-5 sm:p-6">
<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">
{activeTab === TAB_PICKUP ? "Выберите время самовывоза" : "Выберите время доставки"}
</h1>
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
Это тестовая страница — данные ненастоящие. Выберите удобную половину дня для {typeLabel}.
</p>
</Panel>
<Panel className="space-y-3 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 text-[var(--color-text-muted)]">Заказ TEST-001 · счёт 1</p>
<p className="text-base font-semibold text-[var(--color-text)]">Тестов Тест Тестович</p>
<p className="text-sm text-[var(--color-text-muted)]">
{activeTab === TAB_PICKUP
? "Самовывоз со склада"
: "г. Симферополь, ул. Тестовая, д. 1"}
</p>
</div>
</div>
<div className="flex gap-2 rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] p-1">
<button
type="button"
className={`flex-1 rounded-[24px] px-4 py-2.5 text-sm font-semibold transition ${
activeTab === TAB_DELIVERY
? "bg-[var(--color-accent)] text-white"
: "text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
}`}
onClick={() => { setActiveTab(TAB_DELIVERY); setSelectedSlot(null); setSaved(false); }}
>
🚚 Доставка
</button>
<button
type="button"
className={`flex-1 rounded-[24px] px-4 py-2.5 text-sm font-semibold transition ${
activeTab === TAB_PICKUP
? "bg-[var(--color-accent)] text-white"
: "text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
}`}
onClick={() => { setActiveTab(TAB_PICKUP); setSelectedSlot(null); setSaved(false); }}
>
🏪 Самовывоз
</button>
</div>
</Panel>
<Panel className="space-y-3 p-5 sm:p-6">
<p className="text-sm font-semibold text-[var(--color-text)]">Состав заказа</p>
<div className="space-y-2">
{MOCK_PRODUCTS.map((p, idx) => (
<div
key={idx}
className="flex items-center justify-between gap-3 rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] px-4 py-3 text-sm"
>
<span className="leading-6">{p.name}</span>
<span className="flex-shrink-0 rounded-full bg-[var(--color-accent-soft)] px-2.5 py-0.5 text-xs font-medium text-[var(--color-accent)]">
{p.quantity} {p.unit}
</span>
</div>
))}
</div>
</Panel>
{saved && selectedSlot ? (
<Panel className="space-y-2 p-5 sm:p-6">
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Ваш выбор</p>
<h2 className="text-xl font-semibold leading-tight">
{activeTab === TAB_PICKUP ? "Самовывоз" : "Доставка"}: {formatDeliveryDate(selectedSlot.date)} / {selectedSlot.time}
</h2>
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
Выбор сохранен (тестовая страница — в реальном заказе здесь будет подтверждение от менеджера).
</p>
</Panel>
) : (
<>
{grouped.map(([date, dateSlots]) => (
<details key={date} className="group rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-soft backdrop-blur" open>
<summary className="cursor-pointer list-none p-5 sm:p-6">
<div className="flex items-center justify-between gap-3">
<h4 className="font-medium"><SlotGroupHeading dateStr={date} /></h4>
<span className="text-sm text-[var(--color-text-muted)] group-open:hidden">Раскрыть</span>
<span className="hidden text-sm text-[var(--color-text-muted)] group-open:inline">Свернуть</span>
</div>
</summary>
<div className="px-5 pb-5 sm:px-6 sm:pb-6">
<div className="grid gap-3 sm:grid-cols-2">
{dateSlots.map((slot) => {
const isSelected = selectedSlot?.id === slot.id;
return (
<Button
key={slot.id}
variant={isSelected ? "primary" : "secondary"}
aria-pressed={isSelected}
onClick={() => { setSelectedSlot(slot); setSaved(false); }}
>
{slot.time}
{isSelected ? " — Выбрано" : ""}
</Button>
);
})}
</div>
</div>
</details>
))}
<div className="flex flex-col gap-3 sm:flex-row">
<Button
className="w-full sm:w-auto"
disabled={!selectedSlot}
onClick={handleSave}
>
Сохранить
</Button>
</div>
</>
)}
</div>
</main>
);
};
export default ClientTestConsentPage;

View File

@ -0,0 +1,137 @@
import React, { useState } from "react";
import { Panel } from "../components/UI/Panel";
import { PwaInstallCard } from "../components/client/PwaInstallCard";
import { PushSubscriptionBanner } from "../components/client/PushSubscriptionBanner";
/**
* Test page for PWA install + push notification flow.
* Mock data — no real order, no API calls.
* Route: /client/test
*/
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || "";
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || "";
const MOCK_INVITATION = {
customerName: "Тестов Тест Тестович",
customerPhone: "79990000000",
orderNumber: "TEST-001",
deliveryAddress: "г. Симферополь, ул. Тестовая, д. 1",
deliveryDate: null,
deliveryTime: null,
state: "awaiting_choice",
availableSlots: [],
orderGroupId: "test-group-001",
};
const sendTestPush = async (phone) => {
const resp = await fetch(`${SUPABASE_URL}/functions/v1/send-test-push`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(SUPABASE_ANON_KEY ? { apikey: SUPABASE_ANON_KEY } : {}),
},
body: JSON.stringify({
phone_normalized: phone,
title: "СуперСам — согласуйте доставку",
body: "Ваш заказ готов к доставке. Выберите удобное время.",
url: `${window.location.origin}/client/test-consent`,
}),
});
const data = await resp.json().catch(() => ({}));
return { ok: resp.ok, ...data };
};
export const ClientTestPage = () => {
const [sending, setSending] = useState(false);
const [sendResult, setSendResult] = useState(null); // null | {ok, sent, error}
const handleTestPush = async () => {
setSending(true);
setSendResult(null);
try {
const phone = window.localStorage.getItem("supersam_phone") || MOCK_INVITATION.customerPhone;
const result = await sendTestPush(phone);
setSendResult(result);
} catch (e) {
setSendResult({ ok: false, error: e instanceof Error ? e.message : "Ошибка отправки" });
} finally {
setSending(false);
}
};
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-3 p-5 sm:p-6">
<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>
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
Это тестовая страница для проверки установки PWA и подписки на push-уведомления.
Заказ и телефон — ненастоящие.
</p>
</Panel>
<Panel className="space-y-3 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 text-[var(--color-text-muted)]">Заказ {MOCK_INVITATION.orderNumber}</p>
<p className="text-base font-semibold text-[var(--color-text)]">{MOCK_INVITATION.customerName}</p>
<p className="text-sm text-[var(--color-text-muted)]">{MOCK_INVITATION.deliveryAddress}</p>
</div>
</div>
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2">
<p className="text-xs text-[var(--color-text-muted)]">Телефон для подписки</p>
<p className="text-sm font-mono text-[var(--color-text)]">{MOCK_INVITATION.customerPhone}</p>
</div>
</Panel>
<PwaInstallCard />
<PushSubscriptionBanner
phone={MOCK_INVITATION.customerPhone}
orderGroupId={MOCK_INVITATION.orderGroupId}
/>
<Panel className="space-y-3 border border-dashed border-[var(--color-accent)] bg-[var(--color-surface)] p-5 sm:p-6">
<div>
<p className="text-sm font-semibold text-[var(--color-text)]">🧪 Тест уведомления</p>
<p className="text-xs text-[var(--color-text-muted)] mt-1 leading-5">
Отправить тестовое push-уведомление на этот телефон. Нажмите на уведомление —
откроется тестовая страница согласования доставки.
</p>
</div>
<button
type="button"
onClick={handleTestPush}
disabled={sending}
className="rounded-xl bg-[var(--color-accent)] px-4 py-2 text-sm font-semibold text-white transition hover:opacity-90 disabled:opacity-50"
>
{sending ? "Отправляем…" : "Отправить тестовое уведомление"}
</button>
{sendResult?.ok ? (
<p className="text-sm text-[var(--color-accent)]">
✅ Отправлено: {sendResult.sent} уведомление(й). Проверьте телефон.
</p>
) : null}
{sendResult && !sendResult.ok ? (
<p className="text-sm text-[var(--color-danger)]">
❌ {sendResult.error || "Не удалось отправить уведомление"}
</p>
) : null}
</Panel>
</div>
</main>
);
};
export default ClientTestPage;

View File

@ -91,6 +91,14 @@ export const DashboardPage = () => {
const { isInstalled, isInstallAvailable, installApp: onInstallApp } = usePwaStatus();
// ── Order Groups ─────────────────────────────────────────────────────────
const HIDE_COMPLETED_KEY = "supersam-hide-completed";
const [hideCompleted, setHideCompleted] = React.useState(() => {
try { return localStorage.getItem(HIDE_COMPLETED_KEY) !== "false"; } catch { return true; }
});
React.useEffect(() => {
try { localStorage.setItem(HIDE_COMPLETED_KEY, hideCompleted ? "true" : "false"); } catch {}
}, [hideCompleted]);
const {
orderGroups,
allOrderGroups,
@ -102,7 +110,8 @@ export const DashboardPage = () => {
statusOptions,
isLoading,
loadError,
} = useOrderGroups();
totalGroups,
} = useOrderGroups({ enabled: ["orders", "logistics", "deliveries"].includes(activeSection), details: false, hideCompleted });
// ── Derived City List ─────────────────────────────────────────────────────
const cities = React.useMemo(() => {
@ -200,7 +209,7 @@ const ALLOWED_DASHBOARD_ROLES = ["admin", "mega_admin", "manager", "logistician"
// All roles (logistician, admin, mega_admin, manager) — same grouped board
return (
<div className="space-y-6 xl:space-y-8">
<LogisticsReadinessBoard orderGroups={allOrderGroups} onSelectSet={openGroupPage} statusOptions={statusOptions} isLoading={isLoading} />
<LogisticsReadinessBoard orderGroups={allOrderGroups} totalGroups={totalGroups} hideCompleted={hideCompleted} onToggleHideCompleted={setHideCompleted} onSelectSet={openGroupPage} statusOptions={statusOptions} isLoading={isLoading} />
</div>
);
};

View File

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

View File

@ -1,15 +1,61 @@
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";
import { GroupDetailPage } from "./pages/GroupDetailPage";
import { LoginPage } from "./pages/LoginPage";
import { NotFoundPage } from "./pages/NotFoundPage";
import { ForbiddenPage } from "./pages/ForbiddenPage";
import { SettingsPage } from "./pages/SettingsPage";
import { useAuth } from "./context/AuthContext";
/**
* Code-splitting: client-facing pages (opened from SMS links on mobile) load a
* small chunk instead of the full 932KB bundle that includes the staff
* dashboard, admin panels, etc. This fixes 30-40s loads on slow mobile
* connections (customers in Crimea opening delivery links).
*
* Staff pages are also lazy so the login page stays light.
*/
const ClientDeliveryPage = React.lazy(() =>
import("./pages/ClientDeliveryPage").then((m) => ({ default: m.ClientDeliveryPage }))
);
const ClientTestPage = React.lazy(() =>
import("./pages/ClientTestPage").then((m) => ({ default: m.ClientTestPage }))
);
const ClientTestConsentPage = React.lazy(() =>
import("./pages/ClientTestConsentPage").then((m) => ({ default: m.ClientTestConsentPage }))
);
const DashboardPage = React.lazy(() =>
import("./pages/DashboardPage").then((m) => ({ default: m.DashboardPage }))
);
const GroupDetailPage = React.lazy(() =>
import("./pages/GroupDetailPage").then((m) => ({ default: m.GroupDetailPage }))
);
const LoginPage = React.lazy(() =>
import("./pages/LoginPage").then((m) => ({ default: m.LoginPage }))
);
const NotFoundPage = React.lazy(() =>
import("./pages/NotFoundPage").then((m) => ({ default: m.NotFoundPage }))
);
const ForbiddenPage = React.lazy(() =>
import("./pages/ForbiddenPage").then((m) => ({ default: m.ForbiddenPage }))
);
const SettingsPage = React.lazy(() =>
import("./pages/SettingsPage").then((m) => ({ default: m.SettingsPage }))
);
const PageLoader = () => (
<div
style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--color-text-muted, #888)",
fontFamily: "system-ui, sans-serif",
}}
>
Загрузка…
</div>
);
const withSuspense = (node) => <React.Suspense fallback={<PageLoader />}>{node}</React.Suspense>;
/**
* Protects routes that require authentication.
* Redirects to /login with return URL if no user and session is loaded.
@ -37,23 +83,39 @@ export const router = createBrowserRouter([
children: [
{
index: true,
element: <Navigate to="/dashboard" replace />,
element: <Navigate to="/client" replace />,
},
{
path: "login",
element: <LoginPage />,
element: withSuspense(<LoginPage />),
},
{
path: "client",
element: withSuspense(<ClientDeliveryPage />),
},
{
path: "client/:token",
element: withSuspense(<ClientDeliveryPage />),
},
{
path: "client/test",
element: withSuspense(<ClientTestPage />),
},
{
path: "client/test-consent",
element: withSuspense(<ClientTestConsentPage />),
},
{
path: "delivery/:token",
element: <ClientDeliveryPage />,
element: <LegacyDeliveryRedirect />,
},
{
path: "forbidden",
element: <ForbiddenPage />,
element: withSuspense(<ForbiddenPage />),
},
{
path: "dashboard",
element: (
element: withSuspense(
<RequireAuth>
<DashboardPage />
</RequireAuth>
@ -61,7 +123,7 @@ export const router = createBrowserRouter([
},
{
path: "dashboard/group/:groupId",
element: (
element: withSuspense(
<RequireAuth>
<GroupDetailPage />
</RequireAuth>
@ -69,7 +131,7 @@ export const router = createBrowserRouter([
},
{
path: "settings",
element: (
element: withSuspense(
<RequireAuth>
<SettingsPage />
</RequireAuth>
@ -77,8 +139,13 @@ export const router = createBrowserRouter([
},
{
path: "*",
element: <NotFoundPage />,
element: withSuspense(<NotFoundPage />),
},
],
},
]);
function LegacyDeliveryRedirect() {
const { token } = useParams();
return <Navigate to={`/client/${encodeURIComponent(token || "")}`} replace />;
}

View File

@ -244,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";
@ -252,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));
@ -343,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;
}

View File

@ -189,6 +189,7 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
customerPhone,
customerPhoneNormalized: parsedKey.phone || normalizePhone(customerPhone),
customerDate,
town: row.town || null,
deliveryAddress: resolvedDeliveryAddress,
originalDeliveryAddress,
customerAddress,
@ -207,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,
@ -285,7 +288,11 @@ 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)`;
export 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`;
// Delivery statuses treated as "finished" — hidden by default in the board.
const COMPLETED_DELIVERY_STATUSES = ["delivered", "picked_up", "cancelled"];
export const updateOrderGroupDeliveryChoice = async ({
orderGroupId,
@ -296,6 +303,7 @@ export const updateOrderGroupDeliveryChoice = async ({
pickupDate,
pickupTimeSlot,
}) => {
orderGroupsCache.clear();
return safeSupabaseCall(async () => {
const client = requireSupabase();
const effectiveDeliveryStatus = deliveryType === "pickup" ? "pickup" : "agreed";
@ -350,6 +358,7 @@ export const assignDriverToOrderGroup = async ({
orderGroupId,
driverId,
}) => {
orderGroupsCache.clear();
return safeSupabaseCall(async () => {
const client = requireSupabase();
@ -412,6 +421,7 @@ export const assignDriverToOrderGroup = async ({
};
export const saveShipmentData = async ({ orderGroupId, shipmentData }) => {
orderGroupsCache.clear();
return safeSupabaseCall(async () => {
const client = requireSupabase();
const hasAnyShipped = shipmentData.some((i) => i.shipped);
@ -468,6 +478,7 @@ export const saveShipmentData = async ({ orderGroupId, shipmentData }) => {
};
export const updateDeliveryStatus = async ({ orderGroupId, status, details, shipmentData } = {}) => {
orderGroupsCache.clear();
return safeSupabaseCall(async () => {
const client = requireSupabase();
@ -562,14 +573,55 @@ export const updateDeliveryStatus = async ({ orderGroupId, status, details, ship
}, "Ошибка обновления статуса доставки");
};
export const fetchOrderGroups = async () => {
// Module-level cache so returning to the list from a card doesn't refetch the
// whole board (the previous "return to list is slow" annoyance). Keyed by details
// + hideCompleted. The hook refetches silently in the background to stay fresh.
const orderGroupsCache = new Map();
const orderGroupsCacheKey = (details, hideCompleted) =>
`v1:${details ? "detail" : "list"}:${hideCompleted ? "hide" : "all"}`;
/** Synchronous read of the cached board list ({ data, total } | null). */
export const readOrderGroupsCache = (details = false, hideCompleted = true) =>
orderGroupsCache.get(orderGroupsCacheKey(details, hideCompleted)) || null;
/**
* Fetch order groups.
*
* Returns { data, total } where total is the full count matching the filter
* (requires `count` support from PostgREST — available on self-hosted).
*
* @param {object} options
* @param {boolean} [options.details=false] Load full detail fields (for detail pages)
* @param {boolean} [options.hideCompleted=true] Exclude delivered/picked_up/cancelled groups
* @param {boolean} [options.forceRefresh=false] Bypass the in-memory cache and refetch
*/
export const fetchOrderGroups = async ({
details = false,
hideCompleted = true,
forceRefresh = false,
} = {}) => {
const cacheKey = orderGroupsCacheKey(details, hideCompleted);
if (!forceRefresh) {
const cached = orderGroupsCache.get(cacheKey);
if (cached) return cached;
}
return safeSupabaseCall(async () => {
const client = requireSupabase();
const { data, error } = await client
let query = client
.from("order_groups")
.select(ORDER_GROUP_SELECT_FIELDS)
.select(details ? ORDER_GROUP_SELECT_FIELDS : ORDER_GROUP_LIST_SELECT_FIELDS, { count: "exact" })
.order("updated_at", { ascending: false });
if (hideCompleted) {
query = query.not("delivery_status", "in", `(${COMPLETED_DELIVERY_STATUSES.join(",")})`);
}
const { data, error, count } = await query;
if (error) {
throw error;
}
@ -581,12 +633,44 @@ export const fetchOrderGroups = async () => {
drivers.forEach((d) => driverMap.set(d.id, d.name || d.email));
}
return (data || []).map((row) => {
const groups = (data || []).map((row) => {
const group = mapOrderGroupRowToDeliveryGroup(row);
if (group && group.assignedDriverId && !group.assignedDriverName) {
group.assignedDriverName = driverMap.get(group.assignedDriverId) || "";
}
return group;
}).filter(Boolean);
const result = { data: groups, total: count ?? groups.length };
orderGroupsCache.set(cacheKey, result);
return result;
}, "Ошибка загрузки групп доставки");
};
export const fetchOrderGroupById = async (orderGroupId) => {
return safeSupabaseCall(async () => {
const client = requireSupabase();
const { data, error } = await client
.from("order_groups")
.select(ORDER_GROUP_SELECT_FIELDS)
.eq("id", orderGroupId)
.single();
if (error) {
throw error;
}
const group = mapOrderGroupRowToDeliveryGroup(data);
if (group && group.assignedDriverId && !group.assignedDriverName) {
const { data: drivers, error: driversError } = await client.rpc("get_drivers");
if (!driversError && drivers) {
const driver = drivers.find((d) => d.id === group.assignedDriverId);
if (driver) {
group.assignedDriverName = driver.name || driver.email;
}
}
}
return group;
}, "Ошибка загрузки группы доставки");
};

View File

@ -0,0 +1,34 @@
// Clears stale PWA caches directly from the page context, independent of the
// service worker. Runs on every app startup so that even users whose SW has not
// yet updated to the version with the fixed `activate` handler get their bloated
// (3GB) caches purged. Safe to run repeatedly: only `construction-delivery-*`
// caches are removed; nothing else is touched.
export const clearStalePwaCaches = async () => {
if (typeof caches === "undefined" || !("caches" in window)) {
return;
}
try {
const keys = await caches.keys();
const staleKeys = keys.filter(
(key) =>
key.startsWith("construction-delivery-") &&
// Keep only the current cache versions we ship with (v115). Any other
// version is stale and free to purge.
!["construction-delivery-static-v115", "construction-delivery-runtime-v115"].includes(key),
);
if (staleKeys.length === 0) return;
// Delete old caches first so the SW activation has less to scan next time.
await Promise.all(staleKeys.map((key) => caches.delete(key)));
// Kick the service worker to re-run its activate handler (which also prunes
// caches and reclaims control) now that old caches are gone.
if ("serviceWorker" in navigator && navigator.serviceWorker?.controller) {
navigator.serviceWorker.controller.postMessage({ type: "CLEAR_STALE_CACHES" });
}
} catch (err) {
// Best-effort cleanup — never block app startup on cache errors.
console.warn("[pwa] cache cleanup skipped:", err);
}
};

View File

@ -197,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

@ -132,6 +132,25 @@ Deno.serve(async (request) => {
return jsonResponse({ ok: false, error: "Пользователь не найден" }, 404, corsHeaders);
}
// Detach FK references before deleting auth user.
// Several tables reference public.users via RESTRICT FKs (no ON DELETE CASCADE):
// order_history, action_logs, suggestions, order_groups.assigned_driver_id, etc.
// Without detaching, auth.admin.deleteUser fails with "Database error deleting user".
const detachStatements: Array<[string, string]> = [
["orders", "manager_id"],
["orders", "logistician_id"],
["orders", "assigned_driver_id"],
["order_logisticians", "assigned_by"],
["order_history", "user_id"],
["delivery_slots", "logistician_id"],
["order_groups", "assigned_driver_id"],
["action_logs", "performed_by"],
["suggestions", "author_id"],
];
for (const [table, column] of detachStatements) {
await supabase.from(table).update({ [column]: null }).eq(column, userId);
}
// Delete auth user (FK ON DELETE CASCADE will remove public.users row)
const { error: authDeleteError } = await supabase.auth.admin.deleteUser(userId);
if (authDeleteError) {

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

@ -0,0 +1,210 @@
import { createServiceClient } from "../_shared/security.ts";
import {
getCorsHeaders,
jsonResponse,
preflightResponse,
readJsonBody,
requireRateLimit,
} from "../_shared/security.ts";
// @ts-ignore — web-push is a Node package; Deno edge functions resolve it via npm:
import webpush from "npm:web-push@3.6.7";
const MAX_BODY_BYTES = 8 * 1024;
type TestPushBody = {
phone_normalized?: string;
endpoint?: string;
title?: string;
body?: string;
url?: string;
};
/**
* Sends a test push notification to all active subscriptions of a phone.
* Used by the /client/test page to demo how notifications look.
* URL defaults to the test delivery-consent page.
*/
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<TestPushBody>(request, {
maxBytes: MAX_BODY_BYTES,
});
const phoneNormalized = String(body.phone_normalized || "").trim();
const endpoint = String(body.endpoint || "").trim();
if (!phoneNormalized && !endpoint) {
return jsonResponse({ ok: false, error: "phone_normalized or endpoint is required" }, 400, corsHeaders);
}
const supabase = createServiceClient();
await requireRateLimit(supabase, {
scope: "test-push-send",
key: endpoint || phoneNormalized,
maxCount: 5,
windowSeconds: 300,
blockSeconds: 600,
});
// Load VAPID keys from app_settings
const { data: settingsRows, error: settingsError } = await supabase
.from("app_settings")
.select("key, value")
.in("key", ["vapid_public_key", "vapid_private_key"]);
if (settingsError) {
console.error("send-test-push: app_settings error:", settingsError);
return jsonResponse({ ok: false, error: "Database error" }, 500, corsHeaders);
}
const settings = (settingsRows || []).reduce((acc, row) => {
acc[row.key] = row.value;
return acc;
}, {} as Record<string, string>);
if (!settings.vapid_public_key || !settings.vapid_private_key) {
return jsonResponse({ ok: false, error: "VAPID keys not configured" }, 500, corsHeaders);
}
// Target subscriptions: by endpoint (this device) when provided, else by phone
let subsQuery = supabase
.from("push_subscriptions")
.select("id, endpoint, p256dh, auth, phone_normalized")
.eq("is_active", true)
.order("created_at", { ascending: false });
if (endpoint) {
subsQuery = subsQuery.eq("endpoint", endpoint);
} else {
subsQuery = subsQuery.eq("phone_normalized", phoneNormalized);
}
const { data: subs, error: subsError } = await subsQuery;
if (subsError) {
console.error("send-test-push: subscriptions error:", subsError);
return jsonResponse({ ok: false, error: "Database error" }, 500, corsHeaders);
}
if (!subs || subs.length === 0) {
return jsonResponse(
{ ok: false, error: "Нет активных подписок для этого устройства" },
404,
corsHeaders,
);
}
const title = String(body.title || "СуперСам — тест уведомления").slice(0, 120);
const bodyText = String(body.body || "Проверка, как выглядят уведомления о доставке").slice(0, 300);
const url = String(body.url || `${new URL(request.url).origin}/client/test-consent`).slice(0, 500);
webpush.setVapidDetails(
"mailto:admin@supersam.ru",
settings.vapid_public_key,
settings.vapid_private_key,
);
const payload = JSON.stringify({
title,
body: bodyText,
url,
order_group_id: null,
icon: "/icons/icon-512.png",
badge: "/icons/icon-512.png",
vibrate: [200, 100, 200],
});
const sent: string[] = [];
const failed: string[] = [];
const deactivated: string[] = [];
for (const sub of subs) {
try {
await webpush.sendNotification(
{
endpoint: sub.endpoint,
keys: { p256dh: sub.p256dh, auth: sub.auth },
},
payload,
{ TTL: 86400 },
);
sent.push(sub.id);
await supabase
.from("push_subscriptions")
.update({ last_sent_at: new Date().toISOString() })
.eq("id", sub.id);
} catch (err) {
const statusCode = err?.statusCode || err?.response?.statusCode || 0;
if (statusCode === 410 || statusCode === 404) {
deactivated.push(sub.id);
await supabase
.from("push_subscriptions")
.update({ is_active: false })
.eq("id", sub.id);
} else {
failed.push(sub.id);
console.error("send-test-push: send error:", err?.message || err);
}
}
}
// Log to push_log for the audit trail
if (sent.length > 0) {
for (const subId of sent) {
await supabase.from("push_log").insert({
subscription_id: subId,
order_group_id: null,
phone_normalized: phoneNormalized,
title,
body: bodyText,
url,
status: "sent",
});
}
}
return jsonResponse(
{
ok: true,
sent: sent.length,
failed: failed.length,
deactivated: deactivated.length,
total: subs.length,
url,
},
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

@ -6,7 +6,6 @@ export default {
extend: {
colors: {
surface: "var(--color-surface)",
base: "var(--color-base)",
text: "var(--color-text)",
muted: "var(--color-text-muted)",
accent: "var(--color-accent)",

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,
);
}
});