Compare commits
67 Commits
d11ccd4f76
...
f5a06188a2
| Author | SHA1 | Date |
|---|---|---|
|
|
f5a06188a2 | |
|
|
ebff56c749 | |
|
|
46fac18e28 | |
|
|
0cdd9f52a4 | |
|
|
a3cd0838f0 | |
|
|
8131e26c77 | |
|
|
5ca9bd7ebf | |
|
|
c62606b09d | |
|
|
7a76c0a6c0 | |
|
|
cd72507766 | |
|
|
981ca827a2 | |
|
|
56cb4b460f | |
|
|
c0ebe7f59f | |
|
|
c4aebb08a4 | |
|
|
4a4b088949 | |
|
|
6fe0bfbc73 | |
|
|
7654413d8b | |
|
|
4fed962f23 | |
|
|
fee800e3fa | |
|
|
91245169f4 | |
|
|
1b41b5e135 | |
|
|
e21cea1b1c | |
|
|
663b31b3df | |
|
|
045c903074 | |
|
|
f14ca8fae9 | |
|
|
1cbcff6a16 | |
|
|
47a06bb285 | |
|
|
bdeb055fae | |
|
|
b2bd855de3 | |
|
|
15132f56e8 | |
|
|
65b42c8482 | |
|
|
02b70cac22 | |
|
|
7a702114eb | |
|
|
2597835352 | |
|
|
de5130e31f | |
|
|
6d53625579 | |
|
|
4d47261321 | |
|
|
d5849f0ab2 | |
|
|
c321353e8f | |
|
|
fefb8065be | |
|
|
44d3f0f16d | |
|
|
82d6f4f077 | |
|
|
ad7b92503c | |
|
|
34b01758ef | |
|
|
74de57f3fc | |
|
|
9be5f81ffe | |
|
|
9e4ce20c96 | |
|
|
4768864c08 | |
|
|
1922778e0f | |
|
|
82eefb2225 | |
|
|
98ebc935c2 | |
|
|
8faa839056 | |
|
|
1f8a8ea2c4 | |
|
|
811e2a1254 | |
|
|
3a402f7f74 | |
|
|
4459d03932 | |
|
|
db24e8929c | |
|
|
b1a09ae71a | |
|
|
464cc57eb9 | |
|
|
15b69102b0 | |
|
|
225e961907 | |
|
|
4bf95064bd | |
|
|
349a5c5acf | |
|
|
db261f96db | |
|
|
63a59d42bb | |
|
|
24e708901a | |
|
|
60d6033085 |
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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">
|
||||
<Badge tone={getOrderGroupStatusTone(group)}>
|
||||
{getOrderGroupDisplayStatusLabel(group)}
|
||||
</Badge>
|
||||
<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" ? "🏪" : "🚚"}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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)" }}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,24 +290,65 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
|
|||
}
|
||||
}, [items.length, shippedCount, unshippedCount, unshippedWithoutComment, allShipped, shippedItems, comments, onShipmentChange]);
|
||||
|
||||
const renderItem = (item) => {
|
||||
const isShipped = shippedItems.has(item.id);
|
||||
const hasComment = !isShipped && comments[item.id]?.trim();
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={[
|
||||
"rounded-[18px] border px-4 py-3 text-sm transition",
|
||||
isShipped
|
||||
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"
|
||||
: hasComment
|
||||
? "border-[var(--color-warning)] bg-[var(--color-warning-soft)]"
|
||||
: "border-[var(--color-border)] bg-[var(--color-surface-strong)]",
|
||||
].join(" ")}
|
||||
>
|
||||
<label className="flex cursor-pointer items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isShipped}
|
||||
onChange={() => toggleItem(item.id)}
|
||||
className="mt-0.5 h-4 w-4 flex-shrink-0 accent-[var(--color-accent)]"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className={isShipped ? "text-[var(--color-text-muted)]" : "text-[var(--color-text)]"}>
|
||||
{item.name}
|
||||
</span>
|
||||
{(item.quantity || item.unit) ? (
|
||||
<Badge tone="neutral">{[item.quantity, item.unit].filter(Boolean).join(" ")}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{!isShipped && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Причина неотгрузки (дефект, нет в наличии...)"
|
||||
value={comments[item.id] || ""}
|
||||
onChange={(e) =>
|
||||
setComments((prev) => ({ ...prev, [item.id]: e.target.value }))
|
||||
}
|
||||
className="mt-2 w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)] focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<Panel className="space-y-3 p-5 fs-zone-card">
|
||||
<strong>Состав заказа</strong>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-[var(--color-text-muted)]">Позиции не указаны</p>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel className="space-y-4 p-5 fs-zone-card">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<strong>Отгрузка</strong>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||
Отметьте позиции, которые отгружены. Для смены статуса на «Доставлено» все позиции должны быть отгружены.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Badge tone={allShipped ? "accent" : "neutral"}>
|
||||
{shippedCount}/{items.length} отгружено
|
||||
|
|
@ -251,6 +356,42 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{groupByOrder && (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="flex gap-2 sm:flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className={["rounded-xl px-3 py-2 text-sm font-semibold transition",
|
||||
filterMode === "all"
|
||||
? "bg-[var(--color-accent)] text-[var(--color-accent-contrast)]"
|
||||
: "text-[var(--color-text-muted)] hover:bg-[var(--color-accent-soft)] border border-[var(--color-border)]"
|
||||
].join(" ")}
|
||||
onClick={() => setFilterMode("all")}
|
||||
>
|
||||
Все
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={["rounded-xl px-3 py-2 text-sm font-semibold transition",
|
||||
filterMode === "stop_only"
|
||||
? "bg-[var(--color-accent)] text-[var(--color-accent-contrast)]"
|
||||
: "text-[var(--color-text-muted)] hover:bg-[var(--color-accent-soft)] border border-[var(--color-border)]"
|
||||
].join(" ")}
|
||||
onClick={() => setFilterMode("stop_only")}
|
||||
>
|
||||
Со стоп-словами
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Поиск по счёту или позиции..."
|
||||
className="w-full rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-2 text-sm !text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)] focus:outline-none sm:flex-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={shipAll} disabled={allShipped}>
|
||||
Отгрузить всё
|
||||
|
|
@ -285,55 +426,65 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => {
|
||||
const isShipped = shippedItems.has(item.id);
|
||||
const hasComment = !isShipped && comments[item.id]?.trim();
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={[
|
||||
"rounded-[18px] border px-4 py-3 text-sm transition",
|
||||
isShipped
|
||||
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"
|
||||
: hasComment
|
||||
? "border-[var(--color-warning)] bg-[var(--color-warning-soft)]"
|
||||
: "border-[var(--color-border)] bg-[var(--color-surface-strong)]",
|
||||
].join(" ")}
|
||||
>
|
||||
<label className="flex cursor-pointer items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isShipped}
|
||||
onChange={() => toggleItem(item.id)}
|
||||
className="mt-0.5 h-4 w-4 flex-shrink-0 accent-[var(--color-accent)]"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className={isShipped ? "text-[var(--color-text-muted)]" : "text-[var(--color-text)]"}>
|
||||
{item.name}
|
||||
</span>
|
||||
{(item.quantity || item.unit) ? (
|
||||
<Badge tone="neutral">{[item.quantity, item.unit].filter(Boolean).join(" ")}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{!isShipped && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Причина неотгрузки (дефект, нет в наличии...)"
|
||||
value={comments[item.id] || ""}
|
||||
onChange={(e) =>
|
||||
setComments((prev) => ({ ...prev, [item.id]: e.target.value }))
|
||||
}
|
||||
className="mt-2 w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)] focus:outline-none"
|
||||
/>
|
||||
{groupByOrder ? (
|
||||
/* === Grouped by order (счёту) === */
|
||||
<div className="space-y-3">
|
||||
{displayItems.length === 0 ? (
|
||||
<p className="text-sm text-[var(--color-text-muted)] italic">
|
||||
{filterMode === "stop_only" ? "Нет позиций со стоп-словами" : searchQuery.trim() ? "Ничего не найдено" : "Нет позиций для отображения"}
|
||||
</p>
|
||||
) : (
|
||||
Object.entries(groupedItems).map(([orderNom, groupItems]) => {
|
||||
const isCollapsed = collapsedGroups === null ? true : collapsedGroups.has(orderNom);
|
||||
const groupShipped = groupItems.filter((i) => shippedItems.has(i.id)).length;
|
||||
return (
|
||||
<div
|
||||
key={orderNom}
|
||||
className="rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] overflow-hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-2 px-4 py-3 text-left transition hover:bg-[var(--color-accent-soft)]"
|
||||
onClick={() => toggleGroup(orderNom)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full bg-[var(--color-accent-soft)] px-3 py-1 text-sm font-semibold text-[var(--color-accent)]">
|
||||
Счёт {orderNom}
|
||||
</span>
|
||||
<span className="text-sm text-[var(--color-text-muted)]">
|
||||
· {groupItems.length} поз.
|
||||
</span>
|
||||
{groupShipped === groupItems.length && groupItems.length > 0 && (
|
||||
<span className="text-xs text-[var(--color-accent)]">✓</span>
|
||||
)}
|
||||
</div>
|
||||
<svg
|
||||
className="h-4 w-4 flex-shrink-0 text-[var(--color-text-muted)] transition-transform"
|
||||
style={{ transform: isCollapsed ? "rotate(0deg)" : "rotate(180deg)" }}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{!isCollapsed && (
|
||||
<div className="space-y-2 px-4 pb-4">
|
||||
{groupItems.map((item) => renderItem(item))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* === Flat list (driver mode — existing behavior) === */
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => renderItem(item))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{unshippedCount > 0 && (
|
||||
<div className="rounded-xl border border-[var(--color-warning)] bg-[var(--color-warning-soft)] p-3 text-sm">
|
||||
|
|
@ -435,6 +586,6 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
|
|||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
|
@ -18,11 +18,13 @@ import {
|
|||
getOrderGroupDisplayStatusLabel,
|
||||
getOrderGroupDisplayStatusValue,
|
||||
getOrderGroupStatusTone,
|
||||
getSmsProblemTone,
|
||||
ORDER_GROUP_DISPLAY_STATUS_OPTIONS,
|
||||
} from "../../services/orderGroupViews";
|
||||
import { Badge } from "../UI/Badge";
|
||||
import { Panel } from "../UI/Panel";
|
||||
import { SkeletonPage } from "../UI/Loading";
|
||||
import { Pagination } from "../UI/Pagination";
|
||||
import { OrderFilters } from "../orders/OrderFilters";
|
||||
import { formatDate, formatDateTime } from "../../utils/formatters";
|
||||
|
||||
|
|
@ -53,6 +55,9 @@ const DEFAULT_FUNNEL_ORDER = [
|
|||
|
||||
const STORAGE_KEY = "logistics-section-order";
|
||||
const COLLAPSED_KEY = "logistics-section-collapsed";
|
||||
const PAGE_KEY = "logistics-board-page";
|
||||
const SORT_KEY = "logistics-board-sort";
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
// Load custom order from localStorage, merge with defaults
|
||||
const loadCustomOrder = () => {
|
||||
|
|
@ -94,19 +99,39 @@ const saveCollapsedSections = (collapsedSet) => {
|
|||
}
|
||||
};
|
||||
|
||||
// 7 columns: Клиент | Город | Тип | Дата | Водитель | Статус | Обновлён
|
||||
const COLS = "grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(100px,0.8fr)_minmax(100px,1fr)_minmax(90px,1fr)_minmax(100px,1fr)_minmax(90px,0.8fr)]";
|
||||
const MIN_W = "min-w-[1080px]";
|
||||
// 8 columns: Клиент | Город | Тип | Дата | Водитель | Статус | Обновлён | Добавлен в базу
|
||||
const COLS = "grid-cols-[minmax(110px,1.5fr)_minmax(80px,1fr)_minmax(95px,1fr)_minmax(95px,1fr)_minmax(90px,1fr)_minmax(95px,1fr)_minmax(90px,1fr)_minmax(90px,1fr)]";
|
||||
const MIN_W = "min-w-[1060px]";
|
||||
|
||||
const TableHeader = () => (
|
||||
<div className={`grid ${COLS} gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.12em] text-[var(--color-text-muted)]`}>
|
||||
<div className="px-3 py-1.5 font-medium">Клиент</div>
|
||||
<div className="px-3 py-1.5 font-medium">Город</div>
|
||||
<div className="px-3 py-1.5 font-medium">Тип</div>
|
||||
<div className="px-3 py-1.5 font-medium">Дата доставки</div>
|
||||
<div className="px-3 py-1.5 font-medium">Водитель</div>
|
||||
<div className="px-3 py-1.5 font-medium">Статус</div>
|
||||
<div className="px-3 py-1.5 font-medium">Обновлён</div>
|
||||
const TABLE_COLUMNS = [
|
||||
{ key: "customer", label: "Клиент" },
|
||||
{ key: "city", label: "Город" },
|
||||
{ key: "deliveryType", label: "Тип" },
|
||||
{ key: "deliveryDate", label: "Доставка" },
|
||||
{ key: "driver", label: "Водитель" },
|
||||
{ key: "status", label: "Статус" },
|
||||
{ key: "updatedAt", label: "Обновлён" },
|
||||
{ key: "createdAt", label: "Добавлен" },
|
||||
];
|
||||
|
||||
const TableHeader = ({ sort, onSort }) => (
|
||||
<div className={`grid ${COLS} gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]`}>
|
||||
{TABLE_COLUMNS.map((column) => {
|
||||
const active = sort.key === column.key;
|
||||
return (
|
||||
<button
|
||||
key={column.key}
|
||||
type="button"
|
||||
onClick={() => onSort(column.key)}
|
||||
className={`flex min-w-0 items-center gap-1 whitespace-nowrap px-3 py-1.5 text-left font-bold transition hover:text-[var(--color-text)] ${active ? "text-[var(--color-text)]" : ""}`}
|
||||
title={`Сортировать: ${column.label.toLowerCase()}`}
|
||||
aria-label={`Сортировать по колонке «${column.label}»`}
|
||||
>
|
||||
<span>{column.label}</span>
|
||||
<span className="flex-shrink-0" aria-hidden="true">{active ? (sort.direction === "asc" ? "↑" : "↓") : "↕"}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
@ -120,72 +145,104 @@ const isStale = (group) => {
|
|||
return diff > 24 * 60 * 60 * 1000;
|
||||
};
|
||||
|
||||
const isLinkOpened = (group) => !!(group.invitationOpenedAt || (group.invitationAccessCount && group.invitationAccessCount > 0));
|
||||
const getDatabaseAge = (group) => {
|
||||
if (!group.createdAt) return { days: null, rowClass: "", textClass: "text-[var(--color-text-muted)]" };
|
||||
const created = new Date(group.createdAt).getTime();
|
||||
if (!Number.isFinite(created)) return { days: null, rowClass: "", textClass: "text-[var(--color-text-muted)]" };
|
||||
const days = Math.max(0, Math.floor((Date.now() - created) / 86400000));
|
||||
return { days, rowClass: "", textClass: "text-[var(--color-text-muted)]" };
|
||||
};
|
||||
|
||||
const renderRow = (group, onSelectSet) => (
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
className={`grid ${COLS} gap-0 w-full border-t border-[var(--color-border)] text-left transition hover:bg-[var(--color-accent-soft)] ${isStale(group) ? "bg-[rgba(191,123,33,0.06)]" : ""}`}
|
||||
onClick={() => { if (onSelectSet) onSelectSet(group.id); }}
|
||||
>
|
||||
<div className="min-w-0 px-3 py-1.5">
|
||||
<div className="text-xs font-medium leading-snug break-words" style={{ display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden" }}>
|
||||
{group.displayTitle || group.customerName || group.groupKey}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-[var(--color-text-muted)] flex items-center gap-1">
|
||||
{group.customerPhone || ""}
|
||||
{isLinkOpened(group) && <span title="Клиент открывал ссылку" style={{ color: "#22c55e", fontSize: "11px" }}>👁</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
|
||||
{group.city || group.customerAddress || "—"}
|
||||
</div>
|
||||
<div className="px-3 py-1.5 text-xs">
|
||||
<span className="inline-flex items-center gap-1 whitespace-nowrap">
|
||||
{group.deliveryType === "pickup" ? "🏪" : "🚚"}
|
||||
<span className="text-[var(--color-text-muted)]">{group.deliveryType === "pickup" ? "Самовывоз" : "Доставка"}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-3 py-1.5 text-xs">
|
||||
{group.deliveryDate ? (
|
||||
<span>{fmtDate(group.deliveryDate)}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
|
||||
) : (
|
||||
<span className="text-[var(--color-text-muted)]">—</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-3 py-1.5 text-xs">
|
||||
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]">—</span>}
|
||||
</div>
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="flex 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)]"
|
||||
>
|
||||
⚠ Проблема
|
||||
// Not agreed = still waiting for customer confirmation / no delivery settled.
|
||||
// These statuses mean the order has not been agreed yet.
|
||||
const NOT_AGREED_STATUSES = new Set([
|
||||
"delivery:pending_confirmation",
|
||||
"delivery:manual_confirmation_required",
|
||||
"status:manual_required",
|
||||
"status:not_started",
|
||||
"status:link_ready",
|
||||
"status:pending_confirmation",
|
||||
"status:unknown",
|
||||
]);
|
||||
const isNotAgreed = (group) => NOT_AGREED_STATUSES.has(getOrderGroupDisplayStatusValue(group));
|
||||
|
||||
// Red highlight: not agreed and 2+ days since the order was added to the system.
|
||||
const getNotAgreedHighlight = (group) => {
|
||||
if (!isNotAgreed(group)) return "";
|
||||
const created = group.createdAt ? new Date(group.createdAt).getTime() : 0;
|
||||
if (!Number.isFinite(created)) return "";
|
||||
const days = Math.max(0, Math.floor((Date.now() - created) / 86400000));
|
||||
return days >= 2 ? "bg-[rgba(239,68,68,0.12)]" : "";
|
||||
};
|
||||
|
||||
const SMS_TAG = {
|
||||
not_sent: { label: "SMS не отправлено", cls: "bg-[rgba(239,68,68,0.14)] text-[var(--color-danger)]" },
|
||||
not_delivered: { label: "SMS не доставлено", cls: "bg-[rgba(245,158,11,0.14)] text-[var(--color-warning)]" },
|
||||
};
|
||||
|
||||
const renderRow = (g, onSelectSet) => {
|
||||
const statusTone = getOrderGroupStatusTone(g);
|
||||
const stale = isStale(g);
|
||||
const databaseAge = getDatabaseAge(g);
|
||||
const smsTone = getSmsProblemTone(g);
|
||||
const smsTag = smsTone !== "ok" ? SMS_TAG[smsTone] : null;
|
||||
const notAgreedHighlight = getNotAgreedHighlight(g);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={g.id}
|
||||
onClick={() => onSelectSet(g.id)}
|
||||
className={`grid ${COLS} ${MIN_W} w-full border-t border-[var(--color-border)] text-left transition hover:bg-[var(--color-accent-soft)] ${
|
||||
notAgreedHighlight || (stale ? "bg-[rgba(245,158,11,0.06)]" : "")
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 px-3 py-2.5">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-sm font-medium text-[var(--color-text)] truncate">
|
||||
{g.customerName || g.groupKey || "—"}
|
||||
</span>
|
||||
)}
|
||||
{smsTag && (
|
||||
<span className={`inline-flex items-center whitespace-nowrap rounded-full px-2 py-0.5 text-[10px] font-semibold ${smsTag.cls}`} title={smsTag.label}>
|
||||
{smsTag.label}
|
||||
</span>
|
||||
)}
|
||||
{stale && (
|
||||
<span title="Обновлён >24ч назад" className="inline-block h-2 w-2 flex-shrink-0 rounded-full bg-[var(--color-warning)]"></span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-[var(--color-text-muted)] truncate">
|
||||
{(g.allBillNumbers || g.orderNumbers || []).join(", ") || "—"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
|
||||
{formatDateTime(group.updatedAt)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
<div className="px-3 py-2.5 text-sm text-[var(--color-text-muted)]">{g.city || "—"}</div>
|
||||
<div className="px-3 py-2.5 text-xs">
|
||||
<span className={`inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2 py-0.5 font-medium ${
|
||||
g.deliveryType === "pickup"
|
||||
? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]"
|
||||
: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]"
|
||||
}`}>
|
||||
{g.deliveryType === "pickup" ? "🏪 Самовывоз" : "🚚 Доставка"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-3 py-2.5 text-sm text-[var(--color-text-muted)]">
|
||||
{g.deliveryDate ? fmtDate(g.deliveryDate) : "—"}
|
||||
{g.deliveryTime ? <span className="ml-1 text-xs text-[var(--color-text-muted)]">{g.deliveryTime}</span> : null}
|
||||
</div>
|
||||
<div className="px-3 py-2.5 text-sm text-[var(--color-text-muted)]">{g.assignedDriverName || "—"}</div>
|
||||
<div className="px-3 py-2.5">
|
||||
<Badge tone={statusTone}>{getOrderGroupDisplayStatusLabel(g)}</Badge>
|
||||
</div>
|
||||
<div className="px-3 py-2.5 text-xs text-[var(--color-text-muted)]">{formatDateTime(g.updatedAt)}</div>
|
||||
<div className={`px-3 py-2.5 text-xs ${databaseAge.textClass}`}>
|
||||
<div>{formatDateTime(g.createdAt)}</div>
|
||||
{databaseAge.days !== null ? <div className="mt-0.5">{databaseAge.days === 0 ? "сегодня" : `${databaseAge.days} дн. в базе`}</div> : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// Sortable section wrapper
|
||||
const SortableSection = ({ statusValue, label, groups, isCollapsed, onToggle, onSelectSet }) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: statusValue });
|
||||
const SortableSection = ({ statusValue, label, groups, isCollapsed, onToggle, onSelectSet, sort, onSort }) => {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: statusValue });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
|
|
@ -194,58 +251,35 @@ const SortableSection = ({ statusValue, label, groups, isCollapsed, onToggle, on
|
|||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden"
|
||||
>
|
||||
{/* Section header — drag handle + collapse toggle */}
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{/* Drag handle */}
|
||||
<div ref={setNodeRef} style={style} className="rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden">
|
||||
<div className="flex items-center gap-2 border-b border-[var(--color-border)] px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center px-3 py-3 cursor-grab active:cursor-grabbing text-[var(--color-text)] hover:bg-[var(--color-accent-soft)] rounded-l-[28px] touch-none"
|
||||
title="Перетащите для изменения порядка"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab touch-none text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
aria-label="Перетащить секцию"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24" style={{ opacity: 0.6 }}>
|
||||
<circle cx="9" cy="5" r="1.8" />
|
||||
<circle cx="15" cy="5" r="1.8" />
|
||||
<circle cx="9" cy="12" r="1.8" />
|
||||
<circle cx="15" cy="12" r="1.8" />
|
||||
<circle cx="9" cy="19" r="1.8" />
|
||||
<circle cx="15" cy="19" r="1.8" />
|
||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M7 4a1 1 0 11-2 0 1 1 0 012 0zM7 10a1 1 0 11-2 0 1 1 0 012 0zM7 16a1 1 0 11-2 0 1 1 0 012 0zM15 4a1 1 0 11-2 0 1 1 0 012 0zM15 10a1 1 0 11-2 0 1 1 0 012 0zM15 16a1 1 0 11-2 0 1 1 0 012 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Collapse toggle */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-1 items-center justify-between py-3 pr-5 text-left transition hover:bg-[var(--color-surface-strong)]"
|
||||
onClick={onToggle}
|
||||
className="flex flex-1 items-center justify-between text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">{label}</h3>
|
||||
<Badge tone={groups.length > 0 ? "neutral" : "muted"}>{groups.length}</Badge>
|
||||
</div>
|
||||
<svg
|
||||
className="h-4 w-4 text-[var(--color-text-muted)] transition-transform"
|
||||
style={{ transform: isCollapsed ? "rotate(-90deg)" : "rotate(0deg)" }}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
<span className="text-sm font-semibold text-[var(--color-text)]">{label}</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<Badge tone="neutral">{groups.length}</Badge>
|
||||
<span className="text-xs text-[var(--color-text-muted)]">{isCollapsed ? "▶" : "▼"}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="overflow-x-auto">
|
||||
<div className={MIN_W}>
|
||||
<TableHeader />
|
||||
<TableHeader sort={sort} onSort={onSort} />
|
||||
{groups.map((g) => renderRow(g, onSelectSet))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -254,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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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)]">
|
||||
Измените статус, если водитель забыл обновить или нужна корректировка.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-[var(--color-text-muted)]">
|
||||
Измените статус, если водитель забыл обновить или нужна корректировка.
|
||||
</p>
|
||||
|
||||
{/* Status indicators: show current state clearly */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export const GroupDetailPage = () => {
|
|||
saveShipmentData,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useOrderGroups();
|
||||
} = useOrderGroups({ details: true, groupId });
|
||||
|
||||
// ── Drivers ────────────────────────────────────────────────────────────────
|
||||
const [drivers, setDrivers] = React.useState([]);
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}, "Ошибка загрузки группы доставки");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue