Compare commits
No commits in common. "15132f56e88b5dd97f4fedf2c6bb6c8e552f6f1c" and "112147cb0a2f441a5c90803989b7e4822e7625da" have entirely different histories.
15132f56e8
...
112147cb0a
|
|
@ -8,6 +8,3 @@ dist
|
||||||
.superpowers
|
.superpowers
|
||||||
.ruff_cache
|
.ruff_cache
|
||||||
volumes/db/data/
|
volumes/db/data/
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
*.bak
|
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,3 @@ cd /opt/supersam
|
||||||
git pull origin main
|
git pull origin main
|
||||||
docker compose -f docker-compose.app.yml up -d --build
|
docker compose -f docker-compose.app.yml up -d --build
|
||||||
echo 'Deploy completed at' $(date)
|
echo 'Deploy completed at' $(date)
|
||||||
|
|
||||||
# ── Backup code to S3 after deploy ─────────────────────────────────────────
|
|
||||||
if [ -x ./scripts/backup-code-s3.sh ]; then
|
|
||||||
echo "Running code backup to S3..."
|
|
||||||
./scripts/backup-code-s3.sh || echo "WARNING: code backup failed, deploy OK"
|
|
||||||
fi
|
|
||||||
|
|
|
||||||
|
|
@ -18,15 +18,15 @@ services:
|
||||||
- traefik.http.routers.supersam-app.tls.certresolver=letsencrypt
|
- traefik.http.routers.supersam-app.tls.certresolver=letsencrypt
|
||||||
- traefik.http.routers.supersam-app.service=supersam-app
|
- traefik.http.routers.supersam-app.service=supersam-app
|
||||||
- traefik.http.services.supersam-app.loadbalancer.server.port=80
|
- traefik.http.services.supersam-app.loadbalancer.server.port=80
|
||||||
|
- traefik.http.routers.supersam-app-http.rule=Host(`dost.supersamsev.ru`)
|
||||||
|
- traefik.http.routers.supersam-app-http.entryPoints=http
|
||||||
|
- traefik.http.routers.supersam-app-http.middlewares=redirect-to-https
|
||||||
|
- traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https
|
||||||
|
- traefik.http.middlewares.redirect-to-https.redirectscheme.permanent=true
|
||||||
- traefik.http.middlewares.supersam-sec.headers.customresponseheaders.X-Content-Type-Options=nosniff
|
- traefik.http.middlewares.supersam-sec.headers.customresponseheaders.X-Content-Type-Options=nosniff
|
||||||
- traefik.http.middlewares.supersam-sec.headers.customresponseheaders.X-Frame-Options=DENY
|
- traefik.http.middlewares.supersam-sec.headers.customresponseheaders.X-Frame-Options=DENY
|
||||||
- traefik.http.middlewares.supersam-sec.headers.customresponseheaders.Referrer-Policy=strict-origin-when-cross-origin
|
- traefik.http.middlewares.supersam-sec.headers.customresponseheaders.Referrer-Policy=strict-origin-when-cross-origin
|
||||||
- traefik.http.routers.supersam-app.middlewares=supersam-sec
|
- traefik.http.routers.supersam-app.middlewares=supersam-sec
|
||||||
- traefik.http.routers.supersam-app-http.rule=Host(`dost.supersamsev.ru`)
|
|
||||||
- traefik.http.routers.supersam-app-http.entryPoints=http
|
|
||||||
- traefik.http.routers.supersam-app-http.middlewares=supersam-redirect
|
|
||||||
- traefik.http.middlewares.supersam-redirect.redirectscheme.scheme=https
|
|
||||||
- traefik.http.middlewares.supersam-redirect.redirectscheme.permanent=true
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
coolify:
|
coolify:
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
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": "СуперСам Доставка",
|
"name": "SuperSam Доставка",
|
||||||
"short_name": "СуперСам",
|
"short_name": "SuperSam",
|
||||||
"description": "Согласование доставки заказов и уведомления для клиентов СуперСам.",
|
"description": "Панель управления доставкой стройматериалов с уведомлениями.",
|
||||||
"start_url": "/client",
|
"start_url": "/dashboard",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#f4f7f5",
|
"background_color": "#f4f7f5",
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
|
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
|
||||||
|
|
||||||
if (!isLocalhost) {
|
if (!isLocalhost) {
|
||||||
const STATIC_CACHE = "construction-delivery-static-v107";
|
const STATIC_CACHE = "construction-delivery-static-v49";
|
||||||
const RUNTIME_CACHE = "construction-delivery-runtime-v107";
|
const RUNTIME_CACHE = "construction-delivery-runtime-v49";
|
||||||
const APP_SHELL_URLS = ["/", "/client", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
|
const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
|
|
@ -90,50 +90,34 @@ self.addEventListener("push", (event) => {
|
||||||
data = { title: "Уведомление", body: "" };
|
data = { title: "Уведомление", body: "" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title, body, url } = data;
|
const title = data.title || "Уведомление";
|
||||||
const targetUrl = (() => {
|
const options = {
|
||||||
if (!url) return "/client";
|
body: data.body || "",
|
||||||
try {
|
icon: data.icon || "/icons/icon-192.png",
|
||||||
const parsed = new URL(url, self.location.origin);
|
badge: data.badge || "/icons/icon-192.png",
|
||||||
if (parsed.origin !== self.location.origin) return "/client";
|
data: data.data || {},
|
||||||
const legacyMatch = parsed.pathname.match(/^\/delivery\/([^/]+)$/);
|
tag: data.tag || "default",
|
||||||
if (legacyMatch) return `/client/${legacyMatch[1]}${parsed.search}${parsed.hash}`;
|
vibrate: [100, 50, 100],
|
||||||
return parsed.pathname.startsWith("/client")
|
requireInteraction: data.requireInteraction || false,
|
||||||
? `${parsed.pathname}${parsed.search}${parsed.hash}`
|
};
|
||||||
: "/client";
|
|
||||||
} catch {
|
|
||||||
return "/client";
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil(self.registration.showNotification(title, options));
|
||||||
self.registration.showNotification(title || "Уведомление", {
|
|
||||||
body: body || "",
|
|
||||||
icon: "/icons/icon-512.png",
|
|
||||||
data: { url: targetUrl },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notification click handler
|
|
||||||
self.addEventListener("notificationclick", (event) => {
|
self.addEventListener("notificationclick", (event) => {
|
||||||
event.notification.close();
|
event.notification.close();
|
||||||
|
|
||||||
const targetPath = (event.notification.data && event.notification.data.url) || "/client";
|
const clickData = event.notification.data || {};
|
||||||
const targetUrl = new URL(targetPath, self.location.origin).href;
|
const targetUrl = clickData.order_id
|
||||||
|
? "/dashboard/group/" + clickData.order_id
|
||||||
|
: "/dashboard";
|
||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then(async (clientList) => {
|
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => {
|
||||||
const sameOriginClient = clientList.find((client) => {
|
for (const client of clientList) {
|
||||||
try {
|
if (client.url.includes("/dashboard") && "focus" in client) {
|
||||||
return new URL(client.url).origin === self.location.origin;
|
return client.focus();
|
||||||
} 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);
|
return self.clients.openWindow(targetUrl);
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
# Supersam app code backup to S3 (Beget Cloud Storage)
|
|
||||||
# Archives /opt/supersam source code (excluding node_modules, .git, dist)
|
|
||||||
# and uploads to S3. Retention: keeps last 30 days.
|
|
||||||
#
|
|
||||||
# Cron: 0 1 * * * /opt/supersam/scripts/backup-code-s3.sh >> /var/log/supersam-backup-code.log 2>&1
|
|
||||||
# Also triggered after deploy via deploy.sh
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# ── Config ────────────────────────────────────────────────────────────────
|
|
||||||
AWS_ACCESS_KEY_ID="YG4MQNKAPNL65200MBUY"
|
|
||||||
AWS_SECRET_ACCESS_KEY="8mXkFM2VRQ3pN1Nx4mhmJ2jrZoB5YTPUa4CaZh43"
|
|
||||||
S3_BUCKET="02f162ff4a18-supersam-s3"
|
|
||||||
S3_PREFIX="backups"
|
|
||||||
|
|
||||||
SOURCE_DIR="/opt/supersam"
|
|
||||||
RETENTION_DAYS=30
|
|
||||||
|
|
||||||
# ── Runtime ───────────────────────────────────────────────────────────────
|
|
||||||
TIMESTAMP=$(date +%Y-%m-%d_%H%M)
|
|
||||||
ARCHIVE_FILE="/tmp/supersam-code-${TIMESTAMP}.tar.gz"
|
|
||||||
|
|
||||||
export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY
|
|
||||||
|
|
||||||
echo "[$(date)] Starting code backup..."
|
|
||||||
|
|
||||||
# ── Archive ──────────────────────────────────────────────────────────────
|
|
||||||
echo "[$(date)] Archiving ${SOURCE_DIR}..."
|
|
||||||
tar -czf "${ARCHIVE_FILE}" \
|
|
||||||
--directory="${SOURCE_DIR}" \
|
|
||||||
--exclude="node_modules" \
|
|
||||||
--exclude=".git" \
|
|
||||||
--exclude="dist" \
|
|
||||||
--exclude="volumes" \
|
|
||||||
--exclude="__pycache__" \
|
|
||||||
. 2>/dev/null
|
|
||||||
|
|
||||||
ARCHIVE_SIZE=$(du -h "${ARCHIVE_FILE}" | cut -f1)
|
|
||||||
echo "[$(date)] Archive created: ${ARCHIVE_FILE} (${ARCHIVE_SIZE})"
|
|
||||||
|
|
||||||
# ── Upload via mc ─────────────────────────────────────────────────────────
|
|
||||||
echo "[$(date)] Uploading to S3..."
|
|
||||||
mc cp "${ARCHIVE_FILE}" "beget/${S3_BUCKET}/${S3_PREFIX}/code/supersam-code-${TIMESTAMP}.tar.gz" --quiet 2>&1
|
|
||||||
|
|
||||||
echo "[$(date)] Uploaded to s3://${S3_BUCKET}/${S3_PREFIX}/code/supersam-code-${TIMESTAMP}.tar.gz"
|
|
||||||
|
|
||||||
# ── Cleanup local ─────────────────────────────────────────────────────────
|
|
||||||
rm -f "${ARCHIVE_FILE}"
|
|
||||||
echo "[$(date)] Local cleanup done"
|
|
||||||
|
|
||||||
# ── Retention: delete S3 code backups older than RETENTION_DAYS ───────────
|
|
||||||
echo "[$(date)] Cleaning S3 code backups older than ${RETENTION_DAYS} days..."
|
|
||||||
CUTOFF=$(date -d "-${RETENTION_DAYS} days" +%Y-%m-%d)
|
|
||||||
mc ls "beget/${S3_BUCKET}/${S3_PREFIX}/code/" 2>/dev/null \
|
|
||||||
| awk '{print $NF}' \
|
|
||||||
| while read -r fname; do
|
|
||||||
file_date=$(echo "$fname" | grep -oP '\d{4}-\d{2}-\d{2}' || echo "")
|
|
||||||
if [ -n "$file_date" ] && [[ "$file_date" < "$CUTOFF" ]]; then
|
|
||||||
mc rm "beget/${S3_BUCKET}/${S3_PREFIX}/code/${fname}" --quiet 2>/dev/null
|
|
||||||
echo "[$(date)] Deleted old backup: ${fname}"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "[$(date)] Code backup complete."
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
# Supersam full DB backup (pg_dump) to S3 (Beget Cloud Storage)
|
|
||||||
# Dumps the complete supabase postgres database and uploads to S3.
|
|
||||||
# Retention: keeps last 30 days.
|
|
||||||
#
|
|
||||||
# Cron: 0 */12 * * * /opt/supersam/scripts/backup-pgdump-s3.sh >> /var/log/supersam-backup-pgdump.log 2>&1
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# ── Config ────────────────────────────────────────────────────────────────
|
|
||||||
AWS_ACCESS_KEY_ID="YG4MQNKAPNL65200MBUY"
|
|
||||||
AWS_SECRET_ACCESS_KEY="8mXkFM2VRQ3pN1Nx4mhmJ2jrZoB5YTPUa4CaZh43"
|
|
||||||
S3_ENDPOINT="https://s3.ru1.storage.beget.cloud"
|
|
||||||
S3_BUCKET="02f162ff4a18-supersam-s3"
|
|
||||||
S3_PREFIX="backups"
|
|
||||||
|
|
||||||
DB_CONTAINER="supabase-db"
|
|
||||||
DB_USER="supabase_admin"
|
|
||||||
DB_NAME="postgres"
|
|
||||||
DB_PASS="4fe80bb21c7c3d17a8d8b226adf7a479"
|
|
||||||
|
|
||||||
RETENTION_DAYS=30
|
|
||||||
|
|
||||||
# ── Runtime ───────────────────────────────────────────────────────────────
|
|
||||||
TIMESTAMP=$(date +%Y-%m-%d_%H%M)
|
|
||||||
DUMP_FILE="/tmp/supersam-dump-${TIMESTAMP}.sql.gz"
|
|
||||||
S3_PATH="s3://${S3_BUCKET}/${S3_PREFIX}/pgdump/supersam-db-${TIMESTAMP}.sql.gz"
|
|
||||||
|
|
||||||
export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY
|
|
||||||
|
|
||||||
echo "[$(date)] Starting pg_dump backup..."
|
|
||||||
|
|
||||||
# ── Dump ──────────────────────────────────────────────────────────────────
|
|
||||||
echo "[$(date)] Dumping database..."
|
|
||||||
docker exec -e PGPASSWORD="${DB_PASS}" "${DB_CONTAINER}" \
|
|
||||||
pg_dump -U "${DB_USER}" -d "${DB_NAME}" --no-owner --no-acl --clean --if-exists \
|
|
||||||
| gzip -9 > "${DUMP_FILE}"
|
|
||||||
|
|
||||||
DUMP_SIZE=$(du -h "${DUMP_FILE}" | cut -f1)
|
|
||||||
echo "[$(date)] Dump created: ${DUMP_FILE} (${DUMP_SIZE})"
|
|
||||||
|
|
||||||
# ── Upload via mc ─────────────────────────────────────────────────────────
|
|
||||||
echo "[$(date)] Uploading to S3..."
|
|
||||||
mc cp "${DUMP_FILE}" "beget/${S3_BUCKET}/${S3_PREFIX}/pgdump/supersam-db-${TIMESTAMP}.sql.gz" --quiet 2>&1
|
|
||||||
|
|
||||||
echo "[$(date)] Uploaded to ${S3_PATH}"
|
|
||||||
|
|
||||||
# ── Cleanup local ─────────────────────────────────────────────────────────
|
|
||||||
rm -f "${DUMP_FILE}"
|
|
||||||
echo "[$(date)] Local cleanup done"
|
|
||||||
|
|
||||||
# ── Retention: delete S3 pgdump backups older than RETENTION_DAYS ─────────
|
|
||||||
echo "[$(date)] Cleaning S3 pgdump backups older than ${RETENTION_DAYS} days..."
|
|
||||||
CUTOFF=$(date -d "-${RETENTION_DAYS} days" +%Y-%m-%d)
|
|
||||||
mc ls "beget/${S3_BUCKET}/${S3_PREFIX}/pgdump/" 2>/dev/null \
|
|
||||||
| awk '{print $NF}' \
|
|
||||||
| while read -r fname; do
|
|
||||||
file_date=$(echo "$fname" | grep -oP '\d{4}-\d{2}-\d{2}' || echo "")
|
|
||||||
if [ -n "$file_date" ] && [ "$file_date" \< "$CUTOFF" ]; then
|
|
||||||
mc rm "beget/${S3_BUCKET}/${S3_PREFIX}/pgdump/${fname}" --quiet 2>/dev/null
|
|
||||||
echo "[$(date)] Deleted old backup: ${fname}"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "[$(date)] Backup complete."
|
|
||||||
|
|
@ -1,240 +0,0 @@
|
||||||
#!/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()
|
|
||||||
|
|
@ -173,44 +173,18 @@ def send_telegram(message, chat_id):
|
||||||
# ─── State Machine ───────────────────────────────────────────────────────────
|
# ─── State Machine ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_groups_to_send(conn):
|
def get_groups_to_send(conn):
|
||||||
"""Группы, которым ещё не отправили SMS.
|
"""Группы, которым ещё не отправили SMS (или отправили очень давно)"""
|
||||||
|
|
||||||
Режимы (из sms_city_profiles):
|
|
||||||
- link: отправка ссылки на согласование (старый флоу)
|
|
||||||
- notification: уведомление без ссылки (для доставки/самовывоза без согласования)
|
|
||||||
"""
|
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
|
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
|
||||||
og.customer_phone_normalized, og.delivery_link, og.notification_status,
|
og.customer_phone_normalized, og.delivery_link, og.notification_status
|
||||||
og.delivery_type, og.town,
|
|
||||||
-- Профиль города (fallback на дефолтные значения если нет записи)
|
|
||||||
COALESCE(scp.delivery_mode, 'link') as delivery_mode,
|
|
||||||
COALESCE(scp.delivery_text, '') as delivery_text,
|
|
||||||
COALESCE(scp.pickup_mode, 'link') as pickup_mode,
|
|
||||||
COALESCE(scp.pickup_text, '') as pickup_text,
|
|
||||||
COALESCE(scp.sms_enabled, true) as sms_enabled,
|
|
||||||
-- Выбор режима и текста по типу доставки
|
|
||||||
CASE
|
|
||||||
WHEN og.delivery_type = 'pickup'
|
|
||||||
THEN COALESCE(scp.pickup_mode, 'link')
|
|
||||||
ELSE
|
|
||||||
COALESCE(scp.delivery_mode, 'link')
|
|
||||||
END as sms_mode,
|
|
||||||
CASE
|
|
||||||
WHEN og.delivery_type = 'pickup'
|
|
||||||
THEN COALESCE(scp.pickup_text, '')
|
|
||||||
ELSE
|
|
||||||
COALESCE(scp.delivery_text, '')
|
|
||||||
END as sms_text_custom
|
|
||||||
FROM order_groups og
|
FROM order_groups og
|
||||||
LEFT JOIN sms_city_profiles scp ON scp.city = og.town
|
|
||||||
WHERE og.status = 'ready_to_launch'
|
WHERE og.status = 'ready_to_launch'
|
||||||
AND og.delivery_status = 'pending_confirmation'
|
AND og.delivery_status = 'pending_confirmation'
|
||||||
|
AND og.delivery_link IS NOT NULL
|
||||||
|
AND og.delivery_link != ''
|
||||||
AND COALESCE(og.notification_status, '') = 'link_ready'
|
AND COALESCE(og.notification_status, '') = 'link_ready'
|
||||||
-- Профиль sms_enabled (замена старого sms_allowed_for_city)
|
-- Нет активной SMS в логе (sent/checking) за последние max_check_duration минут
|
||||||
AND COALESCE(scp.sms_enabled, true) = true
|
|
||||||
-- Нет активной SMS в логе (sent/checking) за последние 2 часа
|
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM sms_campaign_log scl
|
SELECT 1 FROM sms_campaign_log scl
|
||||||
WHERE scl.order_group_id = og.id
|
WHERE scl.order_group_id = og.id
|
||||||
|
|
@ -318,10 +292,6 @@ def step_send_new(conn, settings):
|
||||||
name = group.get("customer_name") or group.get("group_key", "—")
|
name = group.get("customer_name") or group.get("group_key", "—")
|
||||||
phone = group.get("customer_phone_normalized") or group.get("customer_phone", "")
|
phone = group.get("customer_phone_normalized") or group.get("customer_phone", "")
|
||||||
delivery_link = group.get("delivery_link", "")
|
delivery_link = group.get("delivery_link", "")
|
||||||
delivery_type = group.get("delivery_type") or "delivery"
|
|
||||||
town = group.get("town") or "—"
|
|
||||||
sms_mode = group.get("sms_mode") or "link"
|
|
||||||
custom_text = group.get("sms_text_custom") or ""
|
|
||||||
|
|
||||||
# Проверяем: была ли уже SMS этой группе сегодня (защита от повторной отправки)
|
# Проверяем: была ли уже SMS этой группе сегодня (защита от повторной отправки)
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
|
|
@ -336,15 +306,7 @@ def step_send_new(conn, settings):
|
||||||
log.info(f"Group {group_id}: already has recent SMS in log, skipping")
|
log.info(f"Group {group_id}: already has recent SMS in log, skipping")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Формируем текст SMS по режиму
|
sms_text = f"Ваш заказ готов. Согласуйте дату доставки по ссылке: {delivery_link}"
|
||||||
if sms_mode == "notification":
|
|
||||||
# Уведомление без ссылки на согласование
|
|
||||||
sms_text = custom_text if custom_text else "Ваш заказ готов."
|
|
||||||
log.info(f"Group {group_id}: NOTIFICATION mode (town={town}, type={delivery_type}), no link")
|
|
||||||
else:
|
|
||||||
# Стандартный флоу — ссылка на согласование
|
|
||||||
sms_text = f"Ваш заказ готов. Согласуйте дату доставки по ссылке: {delivery_link}"
|
|
||||||
log.info(f"Group {group_id}: LINK mode (town={town}, type={delivery_type})")
|
|
||||||
|
|
||||||
# ТЕСТОВЫЙ РЕЖИМ: подменяем номер на тестовый
|
# ТЕСТОВЫЙ РЕЖИМ: подменяем номер на тестовый
|
||||||
send_phone = phone
|
send_phone = phone
|
||||||
|
|
@ -366,23 +328,13 @@ def step_send_new(conn, settings):
|
||||||
sms_code=code,
|
sms_code=code,
|
||||||
attempts=1,
|
attempts=1,
|
||||||
)
|
)
|
||||||
# ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status
|
# ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status,
|
||||||
if sms_mode == "notification":
|
# чтобы get_groups_to_send не нашёл эту группу при следующем запуске
|
||||||
# Notification: нет 2-й SMS, сразу first_sms_sent
|
update_order_group(conn, group_id, {
|
||||||
update_order_group(conn, group_id, {
|
"notification_status": "sms_sending",
|
||||||
"notification_status": "first_sms_sent",
|
"sms_sent_at": "NOW()",
|
||||||
"sms_sent_at": "NOW()",
|
})
|
||||||
"first_sms_sent_at": "NOW()",
|
log.info(f"Group {group_id}: SMS sent, sms_id={sms_id}, log_id={log_id}, notification_status→sms_sending")
|
||||||
"status": "first_sms_sent",
|
|
||||||
})
|
|
||||||
log.info(f"Group {group_id}: NOTIFICATION SMS sent, status→first_sms_sent (no second SMS)")
|
|
||||||
else:
|
|
||||||
# Link: ждём доставки SMS, потом 2-я SMS
|
|
||||||
update_order_group(conn, group_id, {
|
|
||||||
"notification_status": "sms_sending",
|
|
||||||
"sms_sent_at": "NOW()",
|
|
||||||
})
|
|
||||||
log.info(f"Group {group_id}: LINK SMS sent, status→sms_sending")
|
|
||||||
sent_count += 1
|
sent_count += 1
|
||||||
else:
|
else:
|
||||||
# Ошибка отправки
|
# Ошибка отправки
|
||||||
|
|
|
||||||
|
|
@ -180,14 +180,11 @@ def get_groups_to_send(conn):
|
||||||
og.customer_phone_normalized, og.delivery_link, og.notification_status,
|
og.customer_phone_normalized, og.delivery_link, og.notification_status,
|
||||||
og.paid_storage_at
|
og.paid_storage_at
|
||||||
FROM order_groups og
|
FROM order_groups og
|
||||||
LEFT JOIN sms_city_profiles scp ON scp.city = og.town
|
|
||||||
WHERE og.delivery_status = 'paid_storage'
|
WHERE og.delivery_status = 'paid_storage'
|
||||||
AND og.paid_storage_at IS NOT NULL
|
AND og.paid_storage_at IS NOT NULL
|
||||||
AND COALESCE(og.notification_status, '') NOT IN ('paid_storage_sending', 'paid_storage_sent')
|
AND COALESCE(og.notification_status, '') NOT IN ('paid_storage_sending', 'paid_storage_sent')
|
||||||
AND og.delivery_link IS NOT NULL
|
AND og.delivery_link IS NOT NULL
|
||||||
AND og.delivery_link != ''
|
AND og.delivery_link != ''
|
||||||
-- Профиль: paid_storage_enabled (если false — SMS о платном хранении не отправляется)
|
|
||||||
AND COALESCE(scp.paid_storage_enabled, true) = true
|
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM sms_campaign_log scl
|
SELECT 1 FROM sms_campaign_log scl
|
||||||
WHERE scl.order_group_id = og.id
|
WHERE scl.order_group_id = og.id
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
# Resolve supabase-db container IP dynamically
|
|
||||||
DB_IP=$(docker inspect supabase-db --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' 2>/dev/null)
|
|
||||||
if [ -z "$DB_IP" ]; then
|
|
||||||
echo "ERROR: Cannot resolve supabase-db IP" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
export DB_HOST=$DB_IP
|
|
||||||
export DB_PORT=5432
|
|
||||||
export DB_NAME=postgres
|
|
||||||
export DB_USER=supabase_admin
|
|
||||||
export DB_PASS=4fe80bb21c7c3d17a8d8b226adf7a479
|
|
||||||
exec python3 "$@"
|
|
||||||
|
|
@ -200,15 +200,12 @@ def get_groups_to_send(conn):
|
||||||
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
|
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
|
||||||
og.customer_phone_normalized, og.delivery_link, og.notification_status
|
og.customer_phone_normalized, og.delivery_link, og.notification_status
|
||||||
FROM order_groups og
|
FROM order_groups og
|
||||||
LEFT JOIN sms_city_profiles scp ON scp.city = og.town
|
|
||||||
WHERE og.delivery_status = 'pending_confirmation'
|
WHERE og.delivery_status = 'pending_confirmation'
|
||||||
AND og.delivery_link IS NOT NULL
|
AND og.delivery_link IS NOT NULL
|
||||||
AND og.delivery_link != ''
|
AND og.delivery_link != ''
|
||||||
AND COALESCE(og.notification_status, '') = 'first_sms_sent'
|
AND COALESCE(og.notification_status, '') = 'first_sms_sent'
|
||||||
AND og.second_sms_sent_at IS NULL
|
AND og.second_sms_sent_at IS NULL
|
||||||
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())
|
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())
|
||||||
-- Профиль: second_sms_enabled (если false — 2-я SMS не отправляется)
|
|
||||||
AND COALESCE(scp.second_sms_enabled, true) = true
|
|
||||||
-- Нет активной второй SMS в логе
|
-- Нет активной второй SMS в логе
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM sms_campaign_log scl
|
SELECT 1 FROM sms_campaign_log scl
|
||||||
|
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
# sms_timer_manager.sh — reconciles DB flags with systemd timers
|
|
||||||
# Runs every 1 min via cron:
|
|
||||||
# 1. timer_active → enable/disable systemd timer
|
|
||||||
# 2. run_requested → run campaign script immediately (script resets flag itself)
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
DB_HOST="10.0.4.12"
|
|
||||||
DB_PORT="5432"
|
|
||||||
DB_NAME="postgres"
|
|
||||||
DB_USER="supabase_admin"
|
|
||||||
DB_PASS="4fe80bb21c7c3d17a8d8b226adf7a479"
|
|
||||||
|
|
||||||
declare -A TIMERS=(
|
|
||||||
["first_sms"]="sms-first-campaign.timer"
|
|
||||||
["second_sms"]="sms-second-campaign.timer"
|
|
||||||
["manual"]="sms-manual-campaign.timer"
|
|
||||||
["paid_storage"]="sms-paid-storage-campaign.timer"
|
|
||||||
)
|
|
||||||
declare -A SERVICES=(
|
|
||||||
["first_sms"]="sms-first-campaign.service"
|
|
||||||
["second_sms"]="sms-second-campaign.service"
|
|
||||||
["manual"]="sms-manual-campaign.service"
|
|
||||||
["paid_storage"]="sms-paid-storage-campaign.service"
|
|
||||||
)
|
|
||||||
declare -A SCRIPTS=(
|
|
||||||
["first_sms"]="/opt/supersam/scripts/sms_first_campaign.py"
|
|
||||||
["second_sms"]="/opt/supersam/scripts/sms_second_campaign.py"
|
|
||||||
["manual"]="/opt/supersam/scripts/sms_manual_campaign.py"
|
|
||||||
["paid_storage"]="/opt/supersam/scripts/sms_paid_storage_campaign.py"
|
|
||||||
)
|
|
||||||
|
|
||||||
QUERY="SELECT campaign_type, timer_active, run_requested FROM sms_campaign_settings"
|
|
||||||
RESULTS=$(PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -A -F '|' -c "$QUERY" 2>/dev/null || echo "")
|
|
||||||
|
|
||||||
if [ -z "$RESULTS" ]; then
|
|
||||||
echo "$(date): Failed to query DB, skipping"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
while IFS='|' read -r campaign_type timer_active run_requested; do
|
|
||||||
[ -z "$campaign_type" ] && continue
|
|
||||||
timer_name="${TIMERS[$campaign_type]:-}"
|
|
||||||
service_name="${SERVICES[$campaign_type]:-}"
|
|
||||||
script_path="${SCRIPTS[$campaign_type]:-}"
|
|
||||||
[ -z "$timer_name" ] && continue
|
|
||||||
|
|
||||||
if ! systemctl list-unit-files "$timer_name" 2>/dev/null | grep -q "$timer_name"; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 1. Timer enable/disable ──
|
|
||||||
is_active=$(systemctl is-active "$timer_name" 2>/dev/null || echo "inactive")
|
|
||||||
|
|
||||||
if [ "$timer_active" = "t" ] || [ "$timer_active" = "true" ]; then
|
|
||||||
if [ "$is_active" != "active" ]; then
|
|
||||||
systemctl enable --now "$timer_name" 2>/dev/null
|
|
||||||
echo "$(date): ENABLED $timer_name for $campaign_type"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if [ "$is_active" = "active" ]; then
|
|
||||||
systemctl disable --now "$timer_name" 2>/dev/null
|
|
||||||
echo "$(date): DISABLED $timer_name for $campaign_type"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 2. Run requested → immediate execution ──
|
|
||||||
# Don't reset flag here — script reads it from DB and resets after processing
|
|
||||||
if [ "$run_requested" = "t" ] || [ "$run_requested" = "true" ]; then
|
|
||||||
is_running=$(systemctl is-active "$service_name" 2>/dev/null || echo "inactive")
|
|
||||||
if [ "$is_running" = "active" ]; then
|
|
||||||
echo "$(date): $campaign_type already running, skipping run_requested"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "$(date): RUN REQUESTED — starting $campaign_type immediately"
|
|
||||||
systemctl start "$service_name" 2>/dev/null || {
|
|
||||||
nohup python3 "$script_path" >> "/var/log/supersam-sms-${campaign_type}.log" 2>&1 &
|
|
||||||
echo "$(date): Started $campaign_type via nohup fallback"
|
|
||||||
}
|
|
||||||
fi
|
|
||||||
|
|
||||||
done <<< "$RESULTS"
|
|
||||||
216
src/AppShell.jsx
216
src/AppShell.jsx
|
|
@ -1,216 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { ROLE_LABELS } from "../constants/roles";
|
|
||||||
import { Badge } from "../components/UI/Badge";
|
|
||||||
import { Button } from "../components/UI/Button";
|
|
||||||
import { Panel } from "../components/UI/Panel";
|
|
||||||
import { ThemeToggle } from "../components/UI/ThemeToggle";
|
|
||||||
import { PwaInstallButton } from "../components/UI/PwaInstallButton";
|
|
||||||
import { NotificationBell } from "../components/notifications/NotificationBell";
|
|
||||||
import { NotificationSettings } from "../components/notifications/NotificationSettings";
|
|
||||||
|
|
||||||
export const AppShell = ({
|
|
||||||
user,
|
|
||||||
onInstallApp,
|
|
||||||
isInstalled,
|
|
||||||
isInstallAvailable,
|
|
||||||
onSignOut,
|
|
||||||
onOpenGuide,
|
|
||||||
isGuideOpen = false,
|
|
||||||
navItems,
|
|
||||||
activeSection,
|
|
||||||
onSectionChange,
|
|
||||||
sectionMeta,
|
|
||||||
notifications = [],
|
|
||||||
unreadCount = 0,
|
|
||||||
onMarkNotificationRead,
|
|
||||||
onMarkAllNotificationsRead,
|
|
||||||
children,
|
|
||||||
}) => {
|
|
||||||
const shouldShowMobileNav = !isGuideOpen && navItems.length > 1;
|
|
||||||
const [showNotifSettings, setShowNotifSettings] = React.useState(false);
|
|
||||||
|
|
||||||
if (showNotifSettings) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen px-3 py-4 sm:px-4 md:px-6 md:py-8">
|
|
||||||
<div className="mx-auto max-w-2xl">
|
|
||||||
<NotificationSettings
|
|
||||||
userId={user?.id}
|
|
||||||
userRole={user?.role}
|
|
||||||
onBack={() => setShowNotifSettings(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen px-3 py-4 sm:px-4 md:px-6 md:py-8">
|
|
||||||
<div className="mx-auto max-w-[1540px] space-y-4 xl:grid xl:grid-cols-[220px_1fr] xl:gap-8 xl:space-y-0">
|
|
||||||
{/* Desktop sidebar */}
|
|
||||||
<Panel className="fs-zone-nav hidden h-fit flex-col gap-5 p-4 xl:flex">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase tracking-[0.24em] text-[var(--color-text-muted)]">
|
|
||||||
Панель
|
|
||||||
</p>
|
|
||||||
<h1 className="mt-2 text-lg font-semibold leading-tight">Управление доставкой</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
{navItems.map((item) => (
|
|
||||||
<Button
|
|
||||||
key={item.key}
|
|
||||||
variant="ghost"
|
|
||||||
className={[
|
|
||||||
"flex w-full items-center justify-between rounded-[18px] px-3 py-3 text-left text-sm transition",
|
|
||||||
activeSection === item.key
|
|
||||||
? "bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
|
||||||
: "text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-text)]",
|
|
||||||
].join(" ")}
|
|
||||||
onClick={() => onSectionChange(item.key)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span className="font-medium">{item.label}</span>
|
|
||||||
{item.badge ? <Badge tone="accent">{item.badge}</Badge> : null}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-auto">
|
|
||||||
{onOpenGuide ? (
|
|
||||||
<Button variant="ghost" className="mb-2 w-full justify-start" onClick={onOpenGuide}>
|
|
||||||
{isGuideOpen ? "К рабочей области" : "Справка"}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<Button variant="ghost" className="mb-2 w-full justify-start" onClick={() => navigate("/settings")}>
|
|
||||||
Настройки
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" className="mb-2 w-full justify-start" onClick={() => window.open("https://forms.gle/feedback-supersam", "_blank")}>
|
|
||||||
💡 Предложить улучшение
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" className="w-full justify-start" onClick={onSignOut}>
|
|
||||||
Выйти
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
{/* Main content area */}
|
|
||||||
<div className="min-w-0 space-y-5 pb-20 xl:space-y-8 xl:pb-0 fs-zone-body">
|
|
||||||
{/* Mobile header */}
|
|
||||||
<Panel className="p-4 xl:hidden fs-zone-heading">
|
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
|
||||||
<div className="min-w-0 flex-1 space-y-1">
|
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
|
||||||
Рабочая область
|
|
||||||
</p>
|
|
||||||
<h2 className="text-lg font-semibold leading-tight sm:text-xl md:text-2xl">
|
|
||||||
{sectionMeta?.label || "Панель"}
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
|
||||||
{user.name} · {ROLE_LABELS[user.role] || user.role}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 md:flex-shrink-0">
|
|
||||||
<NotificationBell
|
|
||||||
notifications={notifications}
|
|
||||||
unreadCount={unreadCount}
|
|
||||||
onMarkAsRead={onMarkNotificationRead}
|
|
||||||
onMarkAllAsRead={onMarkAllNotificationsRead}
|
|
||||||
onOpenSettings={() => setShowNotifSettings(true)}
|
|
||||||
/>
|
|
||||||
{onOpenGuide ? (
|
|
||||||
<Button size="sm" variant="ghost" onClick={onOpenGuide} aria-label="Справка">
|
|
||||||
?
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<PwaInstallButton onInstall={onInstallApp} isInstalled={isInstalled} isInstallAvailable={isInstallAvailable} />
|
|
||||||
<Button size="sm" variant="ghost" onClick={() => navigate("/settings")} aria-label="Настройки">
|
|
||||||
⚙
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="ghost" onClick={() => window.open("https://forms.gle/feedback-supersam", "_blank")} aria-label="Предложить улучшение">
|
|
||||||
💡
|
|
||||||
</Button>
|
|
||||||
<ThemeToggle />
|
|
||||||
<Button size="sm" variant="ghost" onClick={onSignOut}>
|
|
||||||
Выйти
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
{/* Mobile tab navigation — STICKY TOP */}
|
|
||||||
{shouldShowMobileNav && (
|
|
||||||
<div className="sticky inset-x-0 top-0 z-40 -mx-3 -mt-4 border-b border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 backdrop-blur xl:hidden sm:-mx-4 md:-mx-6 fs-zone-nav">
|
|
||||||
<div className="flex gap-1 overflow-x-auto" style={{ WebkitOverflowScrolling: 'touch', scrollbarWidth: 'none' }}>
|
|
||||||
{navItems.map((item) => (
|
|
||||||
<Button
|
|
||||||
key={item.key}
|
|
||||||
variant="ghost"
|
|
||||||
className={[
|
|
||||||
"flex flex-shrink-0 items-center gap-1.5 rounded-[14px] px-3 py-2 text-sm transition",
|
|
||||||
activeSection === item.key
|
|
||||||
? "bg-[var(--color-accent)] text-[var(--color-accent-contrast)]"
|
|
||||||
: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]",
|
|
||||||
].join(" ")}
|
|
||||||
onClick={() => onSectionChange(item.key)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span className="truncate font-medium">{item.label}</span>
|
|
||||||
{item.badge ? (
|
|
||||||
<Badge tone={activeSection === item.key ? "neutral" : "accent"}>{item.badge}</Badge>
|
|
||||||
) : null}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Desktop header */}
|
|
||||||
<Panel className="hidden p-4 md:p-5 xl:block fs-zone-heading">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
|
||||||
Рабочая область
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-2 text-2xl font-semibold">{sectionMeta?.label || "Панель"}</h2>
|
|
||||||
{sectionMeta?.description ? (
|
|
||||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-[var(--color-text-muted)]">
|
|
||||||
{sectionMeta.description}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<NotificationBell
|
|
||||||
notifications={notifications}
|
|
||||||
unreadCount={unreadCount}
|
|
||||||
onMarkAsRead={onMarkNotificationRead}
|
|
||||||
onMarkAllAsRead={onMarkAllNotificationsRead}
|
|
||||||
onOpenSettings={() => setShowNotifSettings(true)}
|
|
||||||
/>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-sm font-medium">{user.name}</div>
|
|
||||||
<div className="text-sm text-[var(--color-text-muted)]">{ROLE_LABELS[user.role] || user.role}</div>
|
|
||||||
</div>
|
|
||||||
{onOpenGuide ? (
|
|
||||||
<Button size="sm" variant="ghost" onClick={onOpenGuide} aria-label="Справка">
|
|
||||||
{isGuideOpen ? "Назад" : "?"}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<PwaInstallButton onInstall={onInstallApp} isInstalled={isInstalled} isInstallAvailable={isInstallAvailable} />
|
|
||||||
<Button size="sm" variant="ghost" onClick={() => navigate("/settings")} aria-label="Настройки">
|
|
||||||
⚙
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="ghost" onClick={() => window.open("https://forms.gle/feedback-supersam", "_blank")} aria-label="Предложить улучшение">
|
|
||||||
💡
|
|
||||||
</Button>
|
|
||||||
<ThemeToggle />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
|
||||||
|
|
||||||
const FontSettingsContext = createContext(null);
|
|
||||||
|
|
||||||
const STORAGE_KEY = "supersam-font-settings";
|
|
||||||
|
|
||||||
// Categories: key → { label, defaultScale, min, max, step, description }
|
|
||||||
export const FONT_CATEGORIES = [
|
|
||||||
{ key: "table", label: "Таблицы", description: "Текст в таблицах и списках", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
|
|
||||||
{ key: "card", label: "Карточки", description: "Текст в карточках и панелях", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
|
|
||||||
{ key: "nav", label: "Меню и навигация", description: "Пункты меню, вкладки, кнопки", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
|
|
||||||
{ key: "heading", label: "Заголовки", description: "Названия секций и страниц", defaultScale: 1.0, min: 0.8, max: 1.6, step: 0.05 },
|
|
||||||
{ key: "body", label: "Основной текст", description: "Обычный текст в интерфейсе", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
|
|
||||||
{ key: "small", label: "Мелкий текст", description: "Подписи, метки, временные отметки", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const DEFAULT_SETTINGS = FONT_CATEGORIES.reduce((acc, cat) => {
|
|
||||||
acc[cat.key] = cat.defaultScale;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
function loadSettings() {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (!raw) return { ...DEFAULT_SETTINGS };
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
// Merge with defaults to handle new categories
|
|
||||||
return { ...DEFAULT_SETTINGS, ...parsed };
|
|
||||||
} catch {
|
|
||||||
return { ...DEFAULT_SETTINGS };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applySettings(settings) {
|
|
||||||
const root = document.documentElement;
|
|
||||||
for (const cat of FONT_CATEGORIES) {
|
|
||||||
root.style.setProperty(`--fs-scale-${cat.key}`, String(settings[cat.key] ?? 1.0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FontSettingsProvider = ({ children }) => {
|
|
||||||
const [settings, setSettings] = useState(loadSettings);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
applySettings(settings);
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
|
||||||
}, [settings]);
|
|
||||||
|
|
||||||
const updateCategory = (key, scale) => {
|
|
||||||
setSettings((prev) => ({ ...prev, [key]: scale }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetAll = () => {
|
|
||||||
setSettings({ ...DEFAULT_SETTINGS });
|
|
||||||
};
|
|
||||||
|
|
||||||
const value = {
|
|
||||||
settings,
|
|
||||||
updateCategory,
|
|
||||||
resetAll,
|
|
||||||
categories: FONT_CATEGORIES,
|
|
||||||
};
|
|
||||||
|
|
||||||
return <FontSettingsContext.Provider value={value}>{children}</FontSettingsContext.Provider>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useFontSettings = () => {
|
|
||||||
const context = useContext(FontSettingsContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useFontSettings must be used within FontSettingsProvider");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
@ -1,389 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
DndContext,
|
|
||||||
closestCenter,
|
|
||||||
PointerSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
} from "@dnd-kit/core";
|
|
||||||
import {
|
|
||||||
arrayMove,
|
|
||||||
SortableContext,
|
|
||||||
useSortable,
|
|
||||||
verticalListSortingStrategy,
|
|
||||||
} from "@dnd-kit/sortable";
|
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
|
||||||
import {
|
|
||||||
filterOrderGroups,
|
|
||||||
getOrderGroupDisplayStatusLabel,
|
|
||||||
getOrderGroupDisplayStatusValue,
|
|
||||||
getOrderGroupStatusTone,
|
|
||||||
ORDER_GROUP_DISPLAY_STATUS_OPTIONS,
|
|
||||||
} from "../../services/orderGroupViews";
|
|
||||||
import { Badge } from "../UI/Badge";
|
|
||||||
import { Panel } from "../UI/Panel";
|
|
||||||
import { SkeletonPage } from "../UI/Loading";
|
|
||||||
import { OrderFilters } from "../orders/OrderFilters";
|
|
||||||
import { formatDate, formatDateTime } from "../../utils/formatters";
|
|
||||||
|
|
||||||
const fmtDate = (d) => {
|
|
||||||
if (!d) return "";
|
|
||||||
const [y, m, day] = d.split("-");
|
|
||||||
if (!y || !m || !day) return d;
|
|
||||||
return `${day}.${m}.${y}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Default priority: agreed first, manual_required second, then funnel
|
|
||||||
const DEFAULT_FUNNEL_ORDER = [
|
|
||||||
"delivery:agreed",
|
|
||||||
"status:manual_required",
|
|
||||||
"status:ready_for_notification",
|
|
||||||
"delivery:pending_confirmation",
|
|
||||||
"status:first_sms_sent",
|
|
||||||
"status:second_sms_sent",
|
|
||||||
"delivery:driver_assigned",
|
|
||||||
"delivery:loaded",
|
|
||||||
"delivery:on_route",
|
|
||||||
"delivery:delivered",
|
|
||||||
"delivery:picked_up",
|
|
||||||
"delivery:paid_storage",
|
|
||||||
"delivery:problem",
|
|
||||||
"delivery:cancelled",
|
|
||||||
];
|
|
||||||
|
|
||||||
const STORAGE_KEY = "logistics-section-order";
|
|
||||||
const COLLAPSED_KEY = "logistics-section-collapsed";
|
|
||||||
|
|
||||||
// Load custom order from localStorage, merge with defaults
|
|
||||||
const loadCustomOrder = () => {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (!raw) return null;
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
return Array.isArray(parsed) ? parsed : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveCustomOrder = (order) => {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(order));
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load collapsed sections from localStorage
|
|
||||||
const loadCollapsedSections = () => {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(COLLAPSED_KEY);
|
|
||||||
if (!raw) return new Set();
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
return new Set(Array.isArray(parsed) ? parsed : []);
|
|
||||||
} catch {
|
|
||||||
return new Set();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveCollapsedSections = (collapsedSet) => {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(COLLAPSED_KEY, JSON.stringify([...collapsedSet]));
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 7 columns: Клиент | Город | Тип | Дата | Водитель | Статус | Обновлён
|
|
||||||
const COLS = "grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(100px,0.8fr)_minmax(100px,1fr)_minmax(90px,1fr)_minmax(100px,1fr)_minmax(90px,0.8fr)]";
|
|
||||||
const MIN_W = "min-w-[1080px]";
|
|
||||||
|
|
||||||
const TableHeader = () => (
|
|
||||||
<div className={`grid ${COLS} gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.12em] text-[var(--color-text-muted)]`}>
|
|
||||||
<div className="px-3 py-1.5 font-medium">Клиент</div>
|
|
||||||
<div className="px-3 py-1.5 font-medium">Город</div>
|
|
||||||
<div className="px-3 py-1.5 font-medium">Тип</div>
|
|
||||||
<div className="px-3 py-1.5 font-medium">Дата доставки</div>
|
|
||||||
<div className="px-3 py-1.5 font-medium">Водитель</div>
|
|
||||||
<div className="px-3 py-1.5 font-medium">Статус</div>
|
|
||||||
<div className="px-3 py-1.5 font-medium">Обновлён</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderRow = (group, onSelectSet) => (
|
|
||||||
<button
|
|
||||||
key={group.id}
|
|
||||||
type="button"
|
|
||||||
className={`grid ${COLS} gap-0 w-full border-t border-[var(--color-border)] text-left transition hover:bg-[var(--color-accent-soft)]`}
|
|
||||||
onClick={() => { if (onSelectSet) onSelectSet(group.id); }}
|
|
||||||
>
|
|
||||||
<div className="min-w-0 px-3 py-1.5">
|
|
||||||
<div className="text-xs font-medium leading-snug break-words" style={{ display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden" }}>
|
|
||||||
{group.displayTitle || group.customerName || group.groupKey}
|
|
||||||
</div>
|
|
||||||
<div className="mt-0.5 text-[11px] text-[var(--color-text-muted)]">
|
|
||||||
{group.customerPhone || ""}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
|
|
||||||
{group.city || group.customerAddress || "—"}
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5 text-xs">
|
|
||||||
<span className="inline-flex items-center gap-1 whitespace-nowrap">
|
|
||||||
{group.deliveryType === "pickup" ? "🏪" : "🚚"}
|
|
||||||
<span className="text-[var(--color-text-muted)]">{group.deliveryType === "pickup" ? "Самовывоз" : "Доставка"}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5 text-xs">
|
|
||||||
{group.deliveryDate ? (
|
|
||||||
<span>{fmtDate(group.deliveryDate)}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-[var(--color-text-muted)]">—</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5 text-xs">
|
|
||||||
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]">—</span>}
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5">
|
|
||||||
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
|
|
||||||
{formatDateTime(group.updatedAt)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sortable section wrapper
|
|
||||||
const SortableSection = ({ statusValue, label, groups, isCollapsed, onToggle, onSelectSet }) => {
|
|
||||||
const {
|
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
setNodeRef,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
isDragging,
|
|
||||||
} = useSortable({ id: statusValue });
|
|
||||||
|
|
||||||
const style = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition,
|
|
||||||
opacity: isDragging ? 0.5 : 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={setNodeRef}
|
|
||||||
style={style}
|
|
||||||
className="rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden"
|
|
||||||
>
|
|
||||||
{/* Section header — drag handle + collapse toggle */}
|
|
||||||
<div className="flex w-full items-center justify-between">
|
|
||||||
{/* Drag handle */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex items-center px-3 py-3 cursor-grab active:cursor-grabbing text-[var(--color-text)] hover:bg-[var(--color-accent-soft)] rounded-l-[28px] touch-none"
|
|
||||||
title="Перетащите для изменения порядка"
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
>
|
|
||||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24" style={{ opacity: 0.6 }}>
|
|
||||||
<circle cx="9" cy="5" r="1.8" />
|
|
||||||
<circle cx="15" cy="5" r="1.8" />
|
|
||||||
<circle cx="9" cy="12" r="1.8" />
|
|
||||||
<circle cx="15" cy="12" r="1.8" />
|
|
||||||
<circle cx="9" cy="19" r="1.8" />
|
|
||||||
<circle cx="15" cy="19" r="1.8" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Collapse toggle */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex flex-1 items-center justify-between py-3 pr-5 text-left transition hover:bg-[var(--color-surface-strong)]"
|
|
||||||
onClick={onToggle}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="text-sm font-semibold">{label}</h3>
|
|
||||||
<Badge tone={groups.length > 0 ? "neutral" : "muted"}>{groups.length}</Badge>
|
|
||||||
</div>
|
|
||||||
<svg
|
|
||||||
className="h-4 w-4 text-[var(--color-text-muted)] transition-transform"
|
|
||||||
style={{ transform: isCollapsed ? "rotate(-90deg)" : "rotate(0deg)" }}
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isCollapsed && (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<div className={MIN_W}>
|
|
||||||
<TableHeader />
|
|
||||||
{groups.map((g) => renderRow(g, onSelectSet))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS, isLoading = false }) => {
|
|
||||||
const [filters, setFilters] = React.useState({ query: "", displayStatus: "all", city: "" });
|
|
||||||
const [collapsedSections, setCollapsedSections] = React.useState(() => loadCollapsedSections());
|
|
||||||
const [sectionOrder, setSectionOrder] = React.useState(() => {
|
|
||||||
const custom = loadCustomOrder();
|
|
||||||
return custom || [...DEFAULT_FUNNEL_ORDER];
|
|
||||||
});
|
|
||||||
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const cities = React.useMemo(() => {
|
|
||||||
const set = new Set();
|
|
||||||
for (const g of orderGroups) {
|
|
||||||
if (g.city) set.add(g.city);
|
|
||||||
}
|
|
||||||
return [...set].sort();
|
|
||||||
}, [orderGroups]);
|
|
||||||
|
|
||||||
const filteredGroups = React.useMemo(
|
|
||||||
() => filterOrderGroups(orderGroups, filters),
|
|
||||||
[filters, orderGroups],
|
|
||||||
);
|
|
||||||
|
|
||||||
const statusGroups = React.useMemo(() => {
|
|
||||||
const map = new Map();
|
|
||||||
for (const group of filteredGroups) {
|
|
||||||
const statusValue = getOrderGroupDisplayStatusValue(group);
|
|
||||||
if (!map.has(statusValue)) {
|
|
||||||
const label = getOrderGroupDisplayStatusLabel(group);
|
|
||||||
map.set(statusValue, { label, groups: [] });
|
|
||||||
}
|
|
||||||
map.get(statusValue).groups.push(group);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, [filteredGroups]);
|
|
||||||
|
|
||||||
const totalGroups = filteredGroups.length;
|
|
||||||
|
|
||||||
// Build sorted list: use sectionOrder for known statuses, append unknown ones at end
|
|
||||||
const sortedEntries = React.useMemo(() => {
|
|
||||||
const present = new Set(statusGroups.keys());
|
|
||||||
const result = [];
|
|
||||||
|
|
||||||
// First: statuses in custom order that are present
|
|
||||||
for (const statusValue of sectionOrder) {
|
|
||||||
if (present.has(statusValue)) {
|
|
||||||
const data = statusGroups.get(statusValue);
|
|
||||||
result.push([statusValue, data]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then: any statuses not in sectionOrder (new statuses), sorted alphabetically
|
|
||||||
for (const [statusValue, data] of statusGroups.entries()) {
|
|
||||||
if (!sectionOrder.includes(statusValue)) {
|
|
||||||
result.push([statusValue, data]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}, [statusGroups, sectionOrder]);
|
|
||||||
|
|
||||||
const handleDragEnd = (event) => {
|
|
||||||
const { active, over } = event;
|
|
||||||
if (!over || active.id === over.id) return;
|
|
||||||
|
|
||||||
setSectionOrder((prevOrder) => {
|
|
||||||
// Build the full order including any new statuses
|
|
||||||
const allIds = sortedEntries.map(([id]) => id);
|
|
||||||
const oldIndex = allIds.indexOf(active.id);
|
|
||||||
const newIndex = allIds.indexOf(over.id);
|
|
||||||
if (oldIndex === -1 || newIndex === -1) return prevOrder;
|
|
||||||
|
|
||||||
const newAllOrder = arrayMove(allIds, oldIndex, newIndex);
|
|
||||||
|
|
||||||
// Merge: replace positions of known statuses, keep unknown at end
|
|
||||||
// Save the full new order so it persists
|
|
||||||
saveCustomOrder(newAllOrder);
|
|
||||||
return newAllOrder;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <SkeletonPage panels={3} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 fs-zone-table">
|
|
||||||
<Panel className="space-y-4 p-5">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<h2 className="text-lg font-semibold">Наборы доставки</h2>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)] mt-0.5">
|
|
||||||
Перетаскивайте секции за ручку слева, чтобы изменить порядок отображения.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Badge tone="neutral">{totalGroups} групп</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<OrderFilters
|
|
||||||
filters={filters}
|
|
||||||
setFilters={setFilters}
|
|
||||||
statusOptions={statusOptions}
|
|
||||||
cities={cities}
|
|
||||||
/>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
{!totalGroups ? (
|
|
||||||
<div className="rounded-[28px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
|
|
||||||
По этому поиску ничего не найдено.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
<SortableContext
|
|
||||||
items={sortedEntries.map(([id]) => id)}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{sortedEntries.map(([statusValue, { label, groups }]) => {
|
|
||||||
const isCollapsed = collapsedSections.has(statusValue);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SortableSection
|
|
||||||
key={statusValue}
|
|
||||||
statusValue={statusValue}
|
|
||||||
label={label}
|
|
||||||
groups={groups}
|
|
||||||
isCollapsed={isCollapsed}
|
|
||||||
onToggle={() => {
|
|
||||||
setCollapsedSections((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(statusValue)) {
|
|
||||||
next.delete(statusValue);
|
|
||||||
} else {
|
|
||||||
next.add(statusValue);
|
|
||||||
}
|
|
||||||
saveCollapsedSections(next);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onSelectSet={onSelectSet}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,316 +0,0 @@
|
||||||
import { formatDateTime } from "../../utils/formatters";
|
|
||||||
import { Badge } from "../UI/Badge";
|
|
||||||
|
|
||||||
import { Panel } from "../UI/Panel";
|
|
||||||
import { SkeletonTable } from "../UI/Loading";
|
|
||||||
import { OrderFilters } from "./OrderFilters";
|
|
||||||
import {
|
|
||||||
getOrderGroupDisplayStatusLabel,
|
|
||||||
getOrderGroupStatusTone,
|
|
||||||
} from "../../services/orderGroupViews";
|
|
||||||
|
|
||||||
const MAX_VISIBLE_INVOICES = 2;
|
|
||||||
|
|
||||||
const TERMINAL_STATUSES = ["delivered", "picked_up", "cancelled"];
|
|
||||||
|
|
||||||
const fmtDate = (d) => {
|
|
||||||
if (!d) return '';
|
|
||||||
const [y, m, day] = d.split('-');
|
|
||||||
if (!y || !m || !day) return d;
|
|
||||||
return `${day}.${m}.${y}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasNoSms = (group) => {
|
|
||||||
if (!group) return false;
|
|
||||||
if (group.firstSmsSentAt) return false;
|
|
||||||
if (TERMINAL_STATUSES.includes(group.deliveryStatus)) return false;
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDaysSinceCreation = (group) => {
|
|
||||||
if (!group) return null;
|
|
||||||
const ts = group.createdFromExchangeAt || group.createdAt || group.updatedAt;
|
|
||||||
if (!ts) return null;
|
|
||||||
const days = Math.floor((Date.now() - new Date(ts)) / 86400000);
|
|
||||||
return days < 0 ? 0 : days;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDaysColorClass = (days) => {
|
|
||||||
if (days == null) return "text-[var(--color-text-muted)]";
|
|
||||||
if (days < 3) return "text-[var(--color-text)]";
|
|
||||||
if (days <= 7) return "text-[var(--color-warning)]";
|
|
||||||
return "text-[var(--color-danger)]";
|
|
||||||
};
|
|
||||||
|
|
||||||
const getShipmentIssues = (group) => {
|
|
||||||
const data = group?.driverShipmentData;
|
|
||||||
if (!Array.isArray(data) || data.length === 0) return null;
|
|
||||||
const unshipped = data.filter((i) => !i.shipped);
|
|
||||||
if (unshipped.length === 0) return null;
|
|
||||||
return unshipped;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildGroupSummary = (group) => {
|
|
||||||
const orderCountLabel = `${group.ordersCount || 0} ${group.ordersCount === 1 ? "заказ" : group.ordersCount < 5 ? "заказа" : "заказов"}`;
|
|
||||||
const parts = [orderCountLabel];
|
|
||||||
if (group.deliveryDate) {
|
|
||||||
const datePart = group.deliveryTime ? `${fmtDate(group.deliveryDate)} · ${group.deliveryTime}` : fmtDate(group.deliveryDate);
|
|
||||||
parts.push(datePart);
|
|
||||||
}
|
|
||||||
if (group.assignedDriverName) {
|
|
||||||
parts.push(group.assignedDriverName);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.join(" · ");
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderOrderNumbers = (group) => {
|
|
||||||
const numbers = group.allBillNumbers || group.orderNumbers;
|
|
||||||
if (!Array.isArray(numbers) || !numbers.length) {
|
|
||||||
return "Номера не указаны";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (numbers.length <= MAX_VISIBLE_INVOICES) {
|
|
||||||
return numbers.join(", ");
|
|
||||||
}
|
|
||||||
const visible = numbers.slice(0, MAX_VISIBLE_INVOICES);
|
|
||||||
const remaining = numbers.length - MAX_VISIBLE_INVOICES;
|
|
||||||
return `${visible.join(", ")} +${remaining}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDeliveryTypeLabel = (deliveryType) => {
|
|
||||||
if (deliveryType === "pickup") return "Самовывоз";
|
|
||||||
if (deliveryType === "delivery") return "Доставка";
|
|
||||||
return "—";
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderMobileOrderNumbers = (group) => {
|
|
||||||
const numbers = group.allBillNumbers || group.orderNumbers;
|
|
||||||
if (!Array.isArray(numbers) || !numbers.length) {
|
|
||||||
return "Номера не указаны";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (numbers.length <= MAX_VISIBLE_INVOICES) {
|
|
||||||
return numbers.join(", ");
|
|
||||||
}
|
|
||||||
const visible = numbers.slice(0, MAX_VISIBLE_INVOICES);
|
|
||||||
const remaining = numbers.length - MAX_VISIBLE_INVOICES;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{visible.join(", ")}
|
|
||||||
<span className="ml-1 rounded-full bg-[var(--color-accent-soft)] px-1.5 py-0.5 text-xs font-medium text-[var(--color-accent)]">+{remaining}</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const OrdersTable = ({
|
|
||||||
orderGroups = [],
|
|
||||||
selectedOrderGroupId,
|
|
||||||
onOpenOrder,
|
|
||||||
filters,
|
|
||||||
setFilters,
|
|
||||||
statusOptions,
|
|
||||||
cities = [],
|
|
||||||
isLoading = false,
|
|
||||||
}) => {
|
|
||||||
if (isLoading) {
|
|
||||||
return <SkeletonTable rows={5} cols={5} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Panel className="p-0 fs-zone-table">
|
|
||||||
<div className="space-y-4 border-b border-[var(--color-border)] px-5 py-4">
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold">Группы доставки</h2>
|
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">
|
|
||||||
Поиск по группе, клиенту, телефону и дате доставки.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Badge tone="neutral">{orderGroups.length}</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filters && setFilters ? (
|
|
||||||
<OrderFilters filters={filters} setFilters={setFilters} statusOptions={statusOptions} cities={cities} />
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 p-4 md:hidden">
|
|
||||||
{!orderGroups.length ? (
|
|
||||||
<div className="rounded-[28px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
|
|
||||||
Группы не найдены. Попробуйте изменить поиск или статус.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{orderGroups.map((group) => {
|
|
||||||
const hasProblem = group.hasDeliveryProblem;
|
|
||||||
const noSms = hasNoSms(group);
|
|
||||||
const days = getDaysSinceCreation(group);
|
|
||||||
const baseClass = "w-full rounded-[22px] border text-left transition";
|
|
||||||
const selectedClass = selectedOrderGroupId === group.id
|
|
||||||
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"
|
|
||||||
: hasProblem
|
|
||||||
? "border-[var(--color-danger)] bg-[rgba(201,61,61,0.1)]"
|
|
||||||
: noSms
|
|
||||||
? "border-[var(--color-border)] bg-[rgba(245,158,11,0.05)]"
|
|
||||||
: "border-[var(--color-border)] bg-[var(--color-surface-strong)]";
|
|
||||||
|
|
||||||
const allNumbers = group.allBillNumbers || group.orderNumbers || [];
|
|
||||||
const primaryBill = allNumbers[0] || "—";
|
|
||||||
const totalCount = allNumbers.length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={group.id}>
|
|
||||||
<div className="mb-1 flex items-center gap-2 px-1 text-xs">
|
|
||||||
<span className="font-medium text-[var(--color-text)]">№ {primaryBill}</span>
|
|
||||||
{totalCount > 1 && (
|
|
||||||
<span className="rounded-full bg-[var(--color-accent-soft)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent)]">
|
|
||||||
{totalCount} сч.
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onOpenOrder(group.id)}
|
|
||||||
className={[baseClass, selectedClass, "flex flex-col items-start justify-start p-4"].join(" ")}
|
|
||||||
>
|
|
||||||
<div className="min-w-0 w-full font-medium leading-snug break-words">
|
|
||||||
{group.displayTitle || group.customerName || group.groupKey}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
|
||||||
<span className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold ${group.deliveryType === "pickup" ? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]" : "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]"}`}>
|
|
||||||
{group.deliveryType === "pickup" ? "🏪 Самовывоз" : "🚚 Доставка"}
|
|
||||||
</span>
|
|
||||||
<Badge tone={getOrderGroupStatusTone(group)}>
|
|
||||||
{getOrderGroupDisplayStatusLabel(group)}
|
|
||||||
</Badge>
|
|
||||||
{noSms && (
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-[rgba(245,158,11,0.15)] px-2 py-0.5 text-[10px] font-medium text-[#d97706]" title="SMS не отправлено">
|
|
||||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[#f59e0b]"></span>
|
|
||||||
SMS не отправлено
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{days != null && (
|
|
||||||
<span className={`inline-flex items-center rounded-full bg-[var(--color-surface-strong)] px-2 py-0.5 text-[10px] font-medium ${getDaysColorClass(days)}`}>
|
|
||||||
{days} дн
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{group.hasDeliveryProblem && (
|
|
||||||
<div className="mt-2 rounded-lg border border-[var(--color-warning)] bg-[var(--color-warning-soft)] px-2 py-1 text-xs">
|
|
||||||
<span className="font-medium text-[var(--color-warning)]">⚠ Проблема с доставкой</span>
|
|
||||||
{group.deliveryProblemNote && (
|
|
||||||
<div className="mt-0.5 text-[var(--color-text-muted)]">{group.deliveryProblemNote}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hidden md:block">
|
|
||||||
{!orderGroups.length ? (
|
|
||||||
<div className="px-5 py-6 text-sm text-[var(--color-text-muted)]">
|
|
||||||
Группы не найдены. Попробуйте изменить поиск или статус.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<div className="min-w-[1180px]">
|
|
||||||
<div className="grid grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(100px,0.8fr)_minmax(100px,1fr)_minmax(100px,1fr)_minmax(70px,0.5fr)_minmax(100px,0.8fr)_minmax(90px,0.8fr)] gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.12em] text-[var(--color-text-muted)]">
|
|
||||||
<div className="px-3 py-1.5 font-medium">Группа / Клиент</div>
|
|
||||||
<div className="px-3 py-1.5 font-medium">Счета</div>
|
|
||||||
<div className="px-3 py-1.5 font-medium">Город</div>
|
|
||||||
<div className="px-3 py-1.5 font-medium">Статус</div>
|
|
||||||
<div className="px-3 py-1.5 font-medium">Дата доставки</div>
|
|
||||||
<div className="px-3 py-1.5 font-medium">Дней</div>
|
|
||||||
<div className="px-3 py-1.5 font-medium">Тип</div>
|
|
||||||
<div className="px-3 py-1.5 font-medium">Водитель</div>
|
|
||||||
</div>
|
|
||||||
{orderGroups.map((group) => {
|
|
||||||
const hasProblem = group.hasDeliveryProblem;
|
|
||||||
const noSms = hasNoSms(group);
|
|
||||||
const days = getDaysSinceCreation(group);
|
|
||||||
let rowBg = "";
|
|
||||||
if (selectedOrderGroupId === group.id) {
|
|
||||||
rowBg = "bg-[var(--color-accent-soft)]";
|
|
||||||
} else if (hasProblem) {
|
|
||||||
rowBg = "bg-[rgba(201,61,61,0.1)] hover:bg-[rgba(201,61,61,0.15)]";
|
|
||||||
} else if (noSms) {
|
|
||||||
rowBg = "bg-[rgba(245,158,11,0.05)] hover:bg-[rgba(245,158,11,0.1)]";
|
|
||||||
} else {
|
|
||||||
rowBg = "hover:bg-[var(--color-accent-soft)]";
|
|
||||||
}
|
|
||||||
const rowClassName = `grid grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(100px,0.8fr)_minmax(100px,1fr)_minmax(100px,1fr)_minmax(70px,0.5fr)_minmax(100px,0.8fr)_minmax(90px,0.8fr)] gap-0 w-full border-t border-[var(--color-border)] text-left transition ${rowBg}`;
|
|
||||||
|
|
||||||
const billNumbers = group.allBillNumbers || group.orderNumbers || [];
|
|
||||||
const primaryBill = billNumbers[0] || "—";
|
|
||||||
const totalBills = billNumbers.length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={group.id}
|
|
||||||
type="button"
|
|
||||||
className={rowClassName}
|
|
||||||
onClick={() => onOpenOrder(group.id)}
|
|
||||||
>
|
|
||||||
<div className="min-w-0 px-3 py-1.5">
|
|
||||||
<div className="text-xs font-medium leading-snug break-words" style={{display:"-webkit-box",WebkitLineClamp:2,WebkitBoxOrient:"vertical",overflow:"hidden"}}>{group.displayTitle || group.customerName || group.groupKey}</div>
|
|
||||||
<div className="mt-0.5 text-[11px] text-[var(--color-text-muted)]">
|
|
||||||
{group.customerPhone || ""}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5">
|
|
||||||
<div className="text-xs text-[var(--color-text)]">{primaryBill}</div>
|
|
||||||
{totalBills > 1 && (
|
|
||||||
<span className="inline-block mt-0.5 rounded-full bg-[var(--color-accent-soft)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent)]">
|
|
||||||
{totalBills} сч.
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
|
|
||||||
{group.city || "—"}
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5">
|
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
|
||||||
<Badge tone={getOrderGroupStatusTone(group)}>
|
|
||||||
{getOrderGroupDisplayStatusLabel(group)}
|
|
||||||
</Badge>
|
|
||||||
{noSms && (
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-[rgba(245,158,11,0.15)] px-1.5 py-0.5 text-[10px] font-medium text-[#d97706]" title="SMS не отправлено">
|
|
||||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[#f59e0b]"></span>
|
|
||||||
SMS
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5 text-xs">
|
|
||||||
{group.deliveryDate ? (
|
|
||||||
<span>{fmtDate(group.deliveryDate)}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-[var(--color-text-muted)]">—</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className={`px-3 py-1.5 text-xs font-medium ${getDaysColorClass(days)}`}>
|
|
||||||
{days != null ? `${days} дн` : "—"}
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5 text-xs">
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
{group.deliveryType === "pickup" ? "🏪" : "🚚"}
|
|
||||||
<span className="text-[var(--color-text-muted)]">{group.deliveryType === "pickup" ? "Самовывоз" : "Доставка"}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5 text-xs">
|
|
||||||
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]">—</span>}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,186 +0,0 @@
|
||||||
/**
|
|
||||||
* @file SettingsPage.jsx
|
|
||||||
* @description Page for user settings: font size controls for all UI categories.
|
|
||||||
* Settings persist to localStorage via FontSettingsContext.
|
|
||||||
*/
|
|
||||||
import React from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { useFontSettings, FONT_CATEGORIES } from "../context/FontSettingsContext";
|
|
||||||
import { Panel } from "../components/UI/Panel";
|
|
||||||
import { Button } from "../components/UI/Button";
|
|
||||||
|
|
||||||
const PRESETS = [
|
|
||||||
{ label: "Мелкий", scales: { table: 0.85, card: 0.85, nav: 0.85, heading: 0.85, body: 0.85, small: 0.85 } },
|
|
||||||
{ label: "Стандарт", scales: { table: 1.0, card: 1.0, nav: 1.0, heading: 1.0, body: 1.0, small: 1.0 } },
|
|
||||||
{ label: "Крупный", scales: { table: 1.15, card: 1.15, nav: 1.15, heading: 1.2, body: 1.15, small: 1.1 } },
|
|
||||||
{ label: "Очень крупный", scales: { table: 1.3, card: 1.3, nav: 1.25, heading: 1.4, body: 1.3, small: 1.2 } },
|
|
||||||
];
|
|
||||||
|
|
||||||
const Slider = ({ category, value, onChange }) => {
|
|
||||||
const pct = ((value - category.min) / (category.max - category.min)) * 100;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-baseline justify-between">
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-[var(--color-text)]">{category.label}</span>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">{category.description}</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-semibold tabular-nums text-[var(--color-accent)]">
|
|
||||||
{Math.round(value * 100)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={category.min}
|
|
||||||
max={category.max}
|
|
||||||
step={category.step}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(parseFloat(e.target.value))}
|
|
||||||
className="fs-slider flex-1"
|
|
||||||
style={{
|
|
||||||
background: `linear-gradient(to right, var(--color-accent) 0%, var(--color-accent) ${pct}%, var(--color-border) ${pct}%, var(--color-border) 100%)`,
|
|
||||||
}}
|
|
||||||
aria-label={category.label}
|
|
||||||
/>
|
|
||||||
<span className="text-xs tabular-nums text-[var(--color-text-muted)] w-16 text-right">
|
|
||||||
{value.toFixed(2)}×
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SettingsPage = () => {
|
|
||||||
const { settings, updateCategory, resetAll, categories } = useFontSettings();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const applyPreset = (scales) => {
|
|
||||||
for (const [key, scale] of Object.entries(scales)) {
|
|
||||||
updateCategory(key, scale);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if current settings match a preset
|
|
||||||
const activePreset = PRESETS.findIndex((p) =>
|
|
||||||
Object.entries(p.scales).every(([k, v]) => Math.abs((settings[k] ?? 1.0) - v) < 0.001)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-3xl space-y-6 px-3 py-4 sm:px-4 md:px-6 md:py-8">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Настройки</p>
|
|
||||||
<h1 className="mt-1 text-2xl font-bold leading-tight" style={{ fontSize: `calc(1.5rem * var(--fs-scale-heading, 1))` }}>
|
|
||||||
Настройки интерфейса
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" onClick={() => navigate(-1)}>
|
|
||||||
← Назад
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Presets */}
|
|
||||||
<Panel className="p-5 space-y-3">
|
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
|
||||||
Быстрые пресеты
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
||||||
{PRESETS.map((preset, i) => (
|
|
||||||
<button
|
|
||||||
key={preset.label}
|
|
||||||
type="button"
|
|
||||||
onClick={() => applyPreset(preset.scales)}
|
|
||||||
className={[
|
|
||||||
"rounded-[18px] border-2 px-4 py-3 text-center transition",
|
|
||||||
activePreset === i
|
|
||||||
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)] font-semibold"
|
|
||||||
: "border-[var(--color-border)] bg-[var(--color-surface-strong)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-text)]",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
<div className="text-sm font-medium">{preset.label}</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
{/* Font size sliders */}
|
|
||||||
<Panel className="p-5 space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
|
||||||
Размеры шрифтов
|
|
||||||
</h2>
|
|
||||||
<Button size="sm" variant="ghost" onClick={resetAll}>
|
|
||||||
Сбросить
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-5">
|
|
||||||
{categories.map((cat) => (
|
|
||||||
<Slider
|
|
||||||
key={cat.key}
|
|
||||||
category={cat}
|
|
||||||
value={settings[cat.key] ?? cat.defaultScale}
|
|
||||||
onChange={(v) => updateCategory(cat.key, v)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Preview */}
|
|
||||||
<div className="rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 space-y-2">
|
|
||||||
<p className="text-xs uppercase tracking-wider text-[var(--color-text-muted)]">Предпросмотр</p>
|
|
||||||
<div className="fs-zone-heading space-y-1">
|
|
||||||
<h3 className="font-bold" style={{ fontSize: `calc(1.25rem * var(--fs-scale-heading, 1))` }}>
|
|
||||||
Заголовок секции
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="fs-zone-nav flex gap-2">
|
|
||||||
<span className="rounded-[14px] bg-[var(--color-accent)] px-3 py-1.5 text-[var(--color-accent-contrast)]"
|
|
||||||
style={{ fontSize: `calc(0.875rem * var(--fs-scale-nav, 1))` }}>
|
|
||||||
Пункт меню
|
|
||||||
</span>
|
|
||||||
<span className="rounded-[14px] bg-[var(--color-surface)] px-3 py-1.5 border border-[var(--color-border)]"
|
|
||||||
style={{ fontSize: `calc(0.875rem * var(--fs-scale-nav, 1))` }}>
|
|
||||||
Вкладка
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="fs-zone-table rounded-[14px] border border-[var(--color-border)] overflow-hidden">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-[var(--color-surface)]">
|
|
||||||
<th className="px-3 py-2 text-left font-semibold" style={{ fontSize: `calc(0.875rem * var(--fs-scale-table, 1))` }}>Дата</th>
|
|
||||||
<th className="px-3 py-2 text-left font-semibold" style={{ fontSize: `calc(0.875rem * var(--fs-scale-table, 1))` }}>Статус</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr className="border-t border-[var(--color-border)]">
|
|
||||||
<td className="px-3 py-2" style={{ fontSize: `calc(0.875rem * var(--fs-scale-table, 1))` }}>02.07.2026</td>
|
|
||||||
<td className="px-3 py-2" style={{ fontSize: `calc(0.875rem * var(--fs-scale-table, 1))` }}>В работе</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div className="fs-zone-card rounded-[14px] bg-[var(--color-surface)] p-3">
|
|
||||||
<p style={{ fontSize: `calc(0.875rem * var(--fs-scale-card, 1))` }}>
|
|
||||||
Текст в карточке — описание доставки или заказа.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="fs-zone-body text-[var(--color-text)]" style={{ fontSize: `calc(1rem * var(--fs-scale-body, 1))` }}>
|
|
||||||
Основной текст интерфейса.
|
|
||||||
</p>
|
|
||||||
<p className="fs-zone-small text-[var(--color-text-muted)]" style={{ fontSize: `calc(0.75rem * var(--fs-scale-small, 1))` }}>
|
|
||||||
мелкая подпись · временная отметка
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<p className="text-center text-xs text-[var(--color-text-muted)]">
|
|
||||||
Настройки сохраняются на этом устройстве
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SettingsPage;
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Universal pagination control.
|
|
||||||
* Usage: <Pagination page={1} totalPages={5} onChange={setPage} />
|
|
||||||
*/
|
|
||||||
export const Pagination = ({ page, totalPages, onChange, itemsPerPage, totalItems }) => {
|
|
||||||
if (totalPages <= 1) return null;
|
|
||||||
|
|
||||||
const from = (page - 1) * (itemsPerPage || 0) + 1;
|
|
||||||
const to = Math.min(page * (itemsPerPage || 0), totalItems || 0);
|
|
||||||
|
|
||||||
const pages = [];
|
|
||||||
const maxButtons = 7;
|
|
||||||
let start = Math.max(1, page - 3);
|
|
||||||
let end = Math.min(totalPages, start + maxButtons - 1);
|
|
||||||
if (end - start < maxButtons - 1) start = Math.max(1, end - maxButtons + 1);
|
|
||||||
|
|
||||||
for (let i = start; i <= end; i++) pages.push(i);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 px-4 py-3 border-t border-[var(--color-border)]">
|
|
||||||
<span className="text-xs text-[var(--color-text-muted)]">
|
|
||||||
{totalItems != null && itemsPerPage
|
|
||||||
? `${from}–${to} из ${totalItems}`
|
|
||||||
: `Стр. ${page} из ${totalPages}`}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={page <= 1}
|
|
||||||
onClick={() => onChange(page - 1)}
|
|
||||||
className="rounded-lg border border-[var(--color-border)] px-2.5 py-1 text-xs font-medium text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-strong)] disabled:opacity-30 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
←
|
|
||||||
</button>
|
|
||||||
{start > 1 && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onChange(1)}
|
|
||||||
className="rounded-lg px-2.5 py-1 text-xs font-medium text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] transition"
|
|
||||||
>1</button>
|
|
||||||
{start > 2 && <span className="text-xs text-[var(--color-text-muted)] px-1">…</span>}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{pages.map((p) => (
|
|
||||||
<button
|
|
||||||
key={p}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onChange(p)}
|
|
||||||
className={`rounded-lg px-2.5 py-1 text-xs font-medium transition ${
|
|
||||||
p === page
|
|
||||||
? "bg-[var(--color-accent)] text-white shadow-sm"
|
|
||||||
: "text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] border border-[var(--color-border)]"
|
|
||||||
}`}
|
|
||||||
>{p}</button>
|
|
||||||
))}
|
|
||||||
{end < totalPages && (
|
|
||||||
<>
|
|
||||||
{end < totalPages - 1 && <span className="text-xs text-[var(--color-text-muted)] px-1">…</span>}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onChange(totalPages)}
|
|
||||||
className="rounded-lg px-2.5 py-1 text-xs font-medium text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] transition"
|
|
||||||
>{totalPages}</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={page >= totalPages}
|
|
||||||
onClick={() => onChange(page + 1)}
|
|
||||||
className="rounded-lg border border-[var(--color-border)] px-2.5 py-1 text-xs font-medium text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-strong)] disabled:opacity-30 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
→
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -51,7 +51,7 @@ export const PwaInstallButton = ({ onInstall, isInstalled, isInstallAvailable })
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showTip && (
|
{showTip && (
|
||||||
<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">
|
<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">
|
||||||
{isIOS ? (
|
{isIOS ? (
|
||||||
<>
|
<>
|
||||||
<p className="font-medium">Установка на iOS</p>
|
<p className="font-medium">Установка на iOS</p>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
/**
|
/**
|
||||||
* @file AdminDashboard.jsx
|
* @file AdminDashboard.jsx
|
||||||
* @description Admin analytics dashboard. Redesigned with responsive grid layout
|
* @description Admin analytics dashboard. Displays KPI cards, status pie chart,
|
||||||
* that uses desktop width effectively. KPI cards, status pie, daily trend,
|
* daily trend line, confirmation funnel, SMS stats, and driver performance
|
||||||
* confirmation funnel, SMS stats, driver performance, pickup stats.
|
* bar chart. Supports period selection (1d/7d/30d/all) and mobile layout.
|
||||||
* Period label shown in section subtitles.
|
|
||||||
*/
|
*/
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
|
|
@ -11,6 +10,7 @@ import {
|
||||||
PieChart, Pie, Cell, Legend, LineChart, Line, CartesianGrid,
|
PieChart, Pie, Cell, Legend, LineChart, Line, CartesianGrid,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { Panel } from '../UI/Panel';
|
import { Panel } from '../UI/Panel';
|
||||||
|
import { Badge } from '../UI/Badge';
|
||||||
import { SegmentedTabs } from '../UI/SegmentedTabs';
|
import { SegmentedTabs } from '../UI/SegmentedTabs';
|
||||||
import { Skeleton } from '../UI/Loading';
|
import { Skeleton } from '../UI/Loading';
|
||||||
import { useAdminStats } from '../../hooks/useAdminStats';
|
import { useAdminStats } from '../../hooks/useAdminStats';
|
||||||
|
|
@ -21,7 +21,7 @@ import { PickupStatsPanel } from './PickupStatsPanel';
|
||||||
const useIsMobile = () => {
|
const useIsMobile = () => {
|
||||||
const [mobile, setMobile] = useState(false);
|
const [mobile, setMobile] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mq = window.matchMedia('(max-width: 768px)');
|
const mq = window.matchMedia('(max-width: 640px)');
|
||||||
setMobile(mq.matches);
|
setMobile(mq.matches);
|
||||||
const handler = (e) => setMobile(e.matches);
|
const handler = (e) => setMobile(e.matches);
|
||||||
mq.addEventListener('change', handler);
|
mq.addEventListener('change', handler);
|
||||||
|
|
@ -38,8 +38,7 @@ const STATUS_COLORS = {
|
||||||
driver_assigned: '#3b82f6',
|
driver_assigned: '#3b82f6',
|
||||||
loaded: '#6366f1',
|
loaded: '#6366f1',
|
||||||
on_route: '#8b5cf6',
|
on_route: '#8b5cf6',
|
||||||
delivered: '#22c55e',
|
delivered: '#10b981',
|
||||||
picked_up: '#14b8a6',
|
|
||||||
paid_storage: '#06b6d4',
|
paid_storage: '#06b6d4',
|
||||||
problem: '#ef4444',
|
problem: '#ef4444',
|
||||||
cancelled: '#64748b',
|
cancelled: '#64748b',
|
||||||
|
|
@ -54,7 +53,6 @@ const STATUS_LABELS = {
|
||||||
loaded: 'Загружено',
|
loaded: 'Загружено',
|
||||||
on_route: 'В пути',
|
on_route: 'В пути',
|
||||||
delivered: 'Доставлено',
|
delivered: 'Доставлено',
|
||||||
picked_up: 'Вывезено',
|
|
||||||
paid_storage: 'Оплаченное хранение',
|
paid_storage: 'Оплаченное хранение',
|
||||||
problem: 'Проблема',
|
problem: 'Проблема',
|
||||||
cancelled: 'Отменено',
|
cancelled: 'Отменено',
|
||||||
|
|
@ -66,16 +64,9 @@ const PERIOD_OPTIONS = [
|
||||||
{ key: '1d', label: 'Сегодня' },
|
{ key: '1d', label: 'Сегодня' },
|
||||||
{ key: '7d', label: '7 дней' },
|
{ key: '7d', label: '7 дней' },
|
||||||
{ key: '30d', label: '30 дней' },
|
{ key: '30d', label: '30 дней' },
|
||||||
{ key: 'all', label: 'Всё время' },
|
{ key: 'all', label: 'Все' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const PERIOD_LABELS = {
|
|
||||||
'1d': 'за сегодня',
|
|
||||||
'7d': 'за 7 дней',
|
|
||||||
'30d': 'за 30 дней',
|
|
||||||
'all': 'за всё время',
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Custom Recharts Tooltip ─────────────────────────────────────────────────
|
// ── Custom Recharts Tooltip ─────────────────────────────────────────────────
|
||||||
const CustomTooltip = ({ active, payload, label: tooltipLabel }) => {
|
const CustomTooltip = ({ active, payload, label: tooltipLabel }) => {
|
||||||
if (!active || !payload?.length) return null;
|
if (!active || !payload?.length) return null;
|
||||||
|
|
@ -94,65 +85,44 @@ const CustomTooltip = ({ active, payload, label: tooltipLabel }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── KPI Card ────────────────────────────────────────────────────────────────
|
|
||||||
const KpiCard = ({ label, value, color, mobile }) => (
|
|
||||||
<Panel style={{
|
|
||||||
padding: mobile ? '0.5rem 0.6rem' : '0.75rem 1rem',
|
|
||||||
textAlign: 'center',
|
|
||||||
display: 'flex', flexDirection: 'column', justifyContent: 'center',
|
|
||||||
minHeight: mobile ? '60px' : '80px',
|
|
||||||
}}>
|
|
||||||
<div style={{ fontSize: mobile ? '0.6rem' : '0.72rem', color: 'var(--color-text-muted)', marginBottom: '0.15rem', textTransform: 'uppercase', letterSpacing: '0.03em' }}>
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: mobile ? '1.15rem' : '1.6rem', fontWeight: 800, color: color || 'var(--color-text)', lineHeight: 1.1 }}>
|
|
||||||
{value ?? '—'}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Section Header ──────────────────────────────────────────────────────────
|
|
||||||
const SectionHeader = ({ title, subtitle, mobile }) => (
|
|
||||||
<div style={{ marginBottom: '0.6rem' }}>
|
|
||||||
<h3 style={{ fontSize: mobile ? '0.9rem' : '1rem', fontWeight: 700, color: 'var(--color-text)', marginBottom: subtitle ? '0.1rem' : 0 }}>
|
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
{subtitle && (
|
|
||||||
<div style={{ fontSize: mobile ? '0.65rem' : '0.72rem', color: 'var(--color-text-muted)' }}>
|
|
||||||
{subtitle}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── AdminDashboard Component ───────────────────────────────────────────────
|
// ── AdminDashboard Component ───────────────────────────────────────────────
|
||||||
export const AdminDashboard = () => {
|
export const AdminDashboard = () => {
|
||||||
|
// ── State & Hooks ─────────────────────────────────────────────────────────
|
||||||
const [period, setPeriod] = useState('7d');
|
const [period, setPeriod] = useState('7d');
|
||||||
const mobile = useIsMobile();
|
const mobile = useIsMobile();
|
||||||
const { stats, statusDist, dailyTrend, driverStats, economics, isLoading, error, refetch } = useAdminStats(period);
|
const { stats, statusDist, dailyTrend, driverStats, economics, isLoading, error, refetch } = useAdminStats(period);
|
||||||
const { stats: pickupStats, isLoading: pickupLoading } = usePickupStats(period);
|
const { stats: pickupStats, isLoading: pickupLoading } = usePickupStats(period);
|
||||||
|
|
||||||
const periodLabel = PERIOD_LABELS[period] || '';
|
// ── Responsive Layout Values (must be before early returns) ────────────────
|
||||||
const chartHeight = mobile ? 200 : 280;
|
const chartHeight = mobile ? 200 : 240;
|
||||||
const fontSize = mobile ? { xs: '0.6rem', s: '0.7rem', m: '0.78rem', l: '0.85rem' }
|
const kpiMin = mobile ? '80px' : '110px';
|
||||||
: { xs: '0.72rem', s: '0.78rem', m: '0.85rem', l: '0.95rem' };
|
const chartGridCols = mobile ? '1fr' : '1fr 2fr';
|
||||||
|
const driverLabelWidth = mobile ? 80 : 120;
|
||||||
|
const fontSize = mobile ? { xs: '0.6rem', s: '0.7rem', m: '0.78rem', l: '0.85rem', xl: '1rem' }
|
||||||
|
: { xs: '0.65rem', s: '0.68rem', m: '0.78rem', l: '0.85rem', xl: '1.1rem' };
|
||||||
|
|
||||||
// ── Loading State ─────────────────────────────────────────────────────────
|
// ── Loading / Error States ─────────────────────────────────────────────────
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: mobile ? '0.75rem' : '1.5rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: mobile ? '0.75rem' : '1.25rem' }}>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem' }}>
|
||||||
<Skeleton variant="heading" className="w-1/4" />
|
<Skeleton variant="heading" className="w-1/4" />
|
||||||
<Skeleton className="w-32 h-8" />
|
<Skeleton className="w-32 h-8" />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr' : 'repeat(auto-fit, minmax(120px, 1fr))', gap: '0.5rem' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr' : `repeat(auto-fit, minmax(80px, 160px))`, gap: '0.4rem' }}>
|
||||||
{Array.from({ length: 7 }).map((_, i) => (
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
<Panel key={i} style={{ padding: '0.75rem', textAlign: 'center' }}>
|
<Panel key={i} style={{ padding: mobile ? '0.4rem 0.6rem' : '0.5rem 0.75rem', textAlign: 'center' }}>
|
||||||
<Skeleton className="w-12 h-3 mb-1" />
|
<Skeleton className="w-12 h-3 mb-1" />
|
||||||
<Skeleton className="w-8 h-6" />
|
<Skeleton className="w-8 h-5" />
|
||||||
</Panel>
|
</Panel>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
|
||||||
|
<Skeleton variant="heading" className="w-1/3 mb-3" />
|
||||||
|
<div style={{ height: chartHeight }} className="flex items-center justify-center">
|
||||||
|
<Skeleton className="w-3/4 h-40" />
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -175,83 +145,70 @@ export const AdminDashboard = () => {
|
||||||
status: s.delivery_status,
|
status: s.delivery_status,
|
||||||
})).filter(d => d.value > 0);
|
})).filter(d => d.value > 0);
|
||||||
|
|
||||||
|
// ── Trend & Driver Data ───────────────────────────────────────────────────
|
||||||
const trendData = (dailyTrend || []).map(d => ({
|
const trendData = (dailyTrend || []).map(d => ({
|
||||||
date: d.date ? new Date(d.date).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }) : '',
|
date: d.date ? new Date(d.date).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }) : '',
|
||||||
delivered: d.delivered || 0, picked_up: d.picked_up || 0, total: d.total || 0, problems: d.problems || 0,
|
delivered: d.delivered || 0, total: d.total || 0, problems: d.problems || 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const driverData = (driverStats || []).map(d => ({
|
const driverData = (driverStats || []).map(d => ({
|
||||||
name: d.driver_name || 'Неизвестный',
|
name: d.driver_name || 'Неизвестный',
|
||||||
total: d.total || 0, delivered: d.delivered || 0, picked_up: d.picked_up || 0, problems: d.problems || 0,
|
total: d.total || 0, delivered: d.delivered || 0, problems: d.problems || 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// ── Funnel: Real order path ───────────────────────────────────────────────
|
// Funnel: ALWAYS show all steps, even with 0 values
|
||||||
const completedTotal = (econ.full_chain_client || 0) + (econ.client_date_no_driver || 0)
|
// ── Funnel Data ────────────────────────────────────────────────────────────
|
||||||
+ (econ.manager_date_completed || 0) + (econ.bypassed_completed || 0);
|
|
||||||
|
|
||||||
const funnelSteps = [
|
const funnelSteps = [
|
||||||
{ label: 'Всего заказов', value: econ.total_groups || 0, color: '#94a3b8' },
|
{ label: 'Согласовано после 1-й SMS', value: econ.confirmed_after_sms1 || 0, color: '#22c55e' },
|
||||||
{ label: 'SMS отправлено', value: econ.sms_sent || 0, color: '#3b82f6' },
|
{ label: 'Согласовано после 2-й SMS', value: econ.confirmed_after_sms2 || 0, color: '#14b8a6' },
|
||||||
{ label: 'Полная цепочка', value: econ.full_chain_client || 0, color: '#22c55e' },
|
{ label: 'Согласовано вручную', value: econ.confirmed_via_manual || 0, color: '#eab308' },
|
||||||
{ label: 'Клиент выбрал дату', value: econ.client_date_no_driver || 0, color: '#14b8a6' },
|
{ label: 'Ручное назначение даты', value: econ.manual_date_set_count || 0, color: '#f97316' },
|
||||||
{ label: 'Менеджер назначил дату', value: econ.manager_date_completed || 0, color: '#8b5cf6' },
|
{ label: 'Платное хранение', value: econ.paid_storage_count || 0, color: '#06b6d4' },
|
||||||
{ label: 'В обход (без даты)', value: econ.bypassed_completed || 0, color: '#f97316' },
|
{ label: 'Отмена', value: econ.cancelled_count || 0, color: '#ef4444' },
|
||||||
{ label: 'Застряло в ручном', value: econ.stuck_in_manual || 0, color: '#eab308' },
|
|
||||||
{ label: 'В работе', value: econ.in_progress || 0, color: '#3b82f6' },
|
|
||||||
{ label: 'Отменено', value: econ.cancelled_count || 0, color: '#64748b' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Grid Layout ───────────────────────────────────────────────────────────
|
// ── Render ─────────────────────────────────────────────────────────────────
|
||||||
// Desktop: 12-col grid. Mobile: single column.
|
|
||||||
const gridCols = mobile ? '1fr' : 'repeat(12, 1fr)';
|
|
||||||
const colSpan = (n) => mobile ? '1 / -1' : `span ${n}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: mobile ? '0.75rem' : '1.5rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: mobile ? '0.75rem' : '1.25rem' }}>
|
||||||
|
|
||||||
{/* ── Header + Period selector ─────────────────────────────────────────── */}
|
{/* Period selector */}
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem' }}>
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ fontSize: mobile ? '1.1rem' : '1.4rem', fontWeight: 800, color: 'var(--color-text)', marginBottom: '0.1rem' }}>
|
<h2 style={{ fontSize: mobile ? '1rem' : '1.1rem', fontWeight: 600, color: 'var(--color-text)', marginBottom: '0.15rem' }}>Аналитика</h2>
|
||||||
Аналитика
|
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>Статистика по доставкам</p>
|
||||||
</h2>
|
|
||||||
<p style={{ fontSize: mobile ? '0.72rem' : '0.82rem', color: 'var(--color-text-muted)' }}>
|
|
||||||
Статистика по доставкам {periodLabel}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<SegmentedTabs items={PERIOD_OPTIONS} activeKey={period} onChange={setPeriod} />
|
<SegmentedTabs items={PERIOD_OPTIONS} activeKey={period} onChange={setPeriod} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── KPI Cards ─────────────────────────────────────────────────────────── */}
|
{/* KPI — centered on mobile */}
|
||||||
<div style={{
|
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr' : `repeat(auto-fit, minmax(${kpiMin}, 160px))`, gap: '0.4rem' }}>
|
||||||
display: 'grid',
|
{[
|
||||||
gridTemplateColumns: mobile ? '1fr 1fr' : 'repeat(auto-fit, minmax(130px, 1fr))',
|
{ label: 'Всего', val: totalGroups },
|
||||||
gap: mobile ? '0.4rem' : '0.75rem',
|
{ label: 'Ожидает', val: sv.pending },
|
||||||
}}>
|
{ label: 'В работе', val: sv.in_progress },
|
||||||
<KpiCard label="Всего" value={totalGroups} mobile={mobile} />
|
{ label: 'Доставлено', val: sv.delivered },
|
||||||
<KpiCard label="Ожидает" value={sv.pending} color="#94a3b8" mobile={mobile} />
|
{ label: 'Проблемы', val: sv.problem },
|
||||||
<KpiCard label="В работе" value={sv.in_progress} color="#3b82f6" mobile={mobile} />
|
{ label: '% доставки', val: sv.delivery_rate != null ? sv.delivery_rate + '%' : '—' },
|
||||||
<KpiCard label="Доставлено" value={sv.delivered} color="#22c55e" mobile={mobile} />
|
].map((kpi, i) => (
|
||||||
<KpiCard label="Вывезено" value={sv.picked_up} color="#14b8a6" mobile={mobile} />
|
<Panel key={i} style={{ padding: mobile ? '0.4rem 0.6rem' : '0.5rem 0.75rem', textAlign: 'center' }}>
|
||||||
<KpiCard label="Самовывоз" value={sv.picked_up_pickup} color="#f59e0b" mobile={mobile} />
|
<div style={{ fontSize: fontSize.xs, color: 'var(--color-text-muted)', marginBottom: '0.05rem' }}>{kpi.label}</div>
|
||||||
<KpiCard label="Проблемы" value={sv.problem} color="#ef4444" mobile={mobile} />
|
<div style={{ fontSize: mobile ? '1.1rem' : '1.3rem', fontWeight: 700, color: 'var(--color-text)', textAlign: 'center' }}>{kpi.val ?? '—'}</div>
|
||||||
<KpiCard label="% доставки" value={sv.delivery_rate != null ? sv.delivery_rate + '%' : '—'} color="var(--color-text)" mobile={mobile} />
|
</Panel>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Main Grid: Charts + Tables ───────────────────────────────────────── */}
|
{/* Pie + Line — stacked on mobile, side-by-side on desktop */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: gridCols, gap: mobile ? '0.75rem' : '1.5rem' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: chartGridCols, gap: mobile ? '0.5rem' : '1rem' }}>
|
||||||
|
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
|
||||||
{/* Status Pie — 4 cols desktop */}
|
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>По статусам</h3>
|
||||||
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem', gridColumn: colSpan(4) }}>
|
|
||||||
<SectionHeader title="По статусам" subtitle={periodLabel} mobile={mobile} />
|
|
||||||
{statusPieData.length === 0 ? (
|
{statusPieData.length === 0 ? (
|
||||||
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '2rem' }}>Нет данных</div>
|
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1.5rem' }}>Нет данных</div>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={chartHeight}>
|
<ResponsiveContainer width="100%" height={chartHeight}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie data={statusPieData} cx="50%" cy="50%"
|
<Pie data={statusPieData} cx="50%" cy="50%"
|
||||||
innerRadius={mobile ? 30 : 50}
|
innerRadius={mobile ? 30 : 40}
|
||||||
outerRadius={mobile ? 60 : 90}
|
outerRadius={mobile ? 60 : 80}
|
||||||
dataKey="value" nameKey="name" paddingAngle={2}>
|
dataKey="value" nameKey="name" paddingAngle={2}>
|
||||||
{statusPieData.map(entry => (
|
{statusPieData.map(entry => (
|
||||||
<Cell key={entry.status} fill={STATUS_COLORS[entry.status] || '#6b7280'} />
|
<Cell key={entry.status} fill={STATUS_COLORS[entry.status] || '#6b7280'} />
|
||||||
|
|
@ -264,160 +221,155 @@ export const AdminDashboard = () => {
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{/* Daily Trend — 8 cols desktop */}
|
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
|
||||||
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem', gridColumn: colSpan(8) }}>
|
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>Тренд по дням</h3>
|
||||||
<SectionHeader title="Тренд по дням" subtitle={periodLabel} mobile={mobile} />
|
|
||||||
{trendData.length === 0 ? (
|
{trendData.length === 0 ? (
|
||||||
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '2rem' }}>Нет данных</div>
|
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1.5rem' }}>Нет данных</div>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={chartHeight}>
|
<ResponsiveContainer width="100%" height={chartHeight}>
|
||||||
<LineChart data={trendData}>
|
<LineChart data={trendData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border, #334155)" />
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border, #334155)" />
|
||||||
<XAxis dataKey="date" tick={{ fontSize: mobile ? 9 : 11, fill: 'var(--color-text-muted)' }} />
|
<XAxis dataKey="date" tick={{ fontSize: mobile ? 9 : 10, fill: 'var(--color-text-muted)' }} />
|
||||||
<YAxis tick={{ fontSize: mobile ? 9 : 11, fill: 'var(--color-text-muted)' }} width={mobile ? 25 : 40} />
|
<YAxis tick={{ fontSize: mobile ? 9 : 10, fill: 'var(--color-text-muted)' }} width={mobile ? 25 : 35} />
|
||||||
<Tooltip content={<CustomTooltip />} />
|
<Tooltip content={<CustomTooltip />} />
|
||||||
<Legend wrapperStyle={{ fontSize: fontSize.xs }} />
|
<Legend wrapperStyle={{ fontSize: fontSize.xs }} />
|
||||||
<Line type="monotone" dataKey="total" name="Всего" stroke="#94a3b8" strokeWidth={2} dot={false} />
|
<Line type="monotone" dataKey="total" name="Всего" stroke="#94a3b8" strokeWidth={2} dot={false} />
|
||||||
<Line type="monotone" dataKey="delivered" name="Доставлено" stroke="#22c55e" strokeWidth={2} dot={false} />
|
<Line type="monotone" dataKey="delivered" name="Доставлено" stroke="#22c55e" strokeWidth={2} dot={false} />
|
||||||
<Line type="monotone" dataKey="picked_up" name="Вывезено" stroke="#14b8a6" strokeWidth={2} dot={false} />
|
|
||||||
<Line type="monotone" dataKey="problems" name="Проблемы" stroke="#ef4444" strokeWidth={2} dot={false} />
|
<Line type="monotone" dataKey="problems" name="Проблемы" stroke="#ef4444" strokeWidth={2} dot={false} />
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{/* Status Table — 4 cols desktop */}
|
|
||||||
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem', gridColumn: colSpan(4) }}>
|
|
||||||
<SectionHeader title="Все статусы" subtitle={periodLabel} mobile={mobile} />
|
|
||||||
{statusPieData.length === 0 ? (
|
|
||||||
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1rem' }}>Нет данных</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<div style={{
|
|
||||||
display: 'grid', gridTemplateColumns: mobile ? '8px 1fr 45px 40px' : '12px 1fr 60px 55px',
|
|
||||||
gap: '0 0.5rem', padding: '0.4rem 0.3rem', alignItems: 'center',
|
|
||||||
borderBottom: '1px solid var(--color-border)', fontSize: fontSize.xs,
|
|
||||||
color: 'var(--color-text-muted)', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.03em',
|
|
||||||
}}>
|
|
||||||
<div /><div>Статус</div><div style={{ textAlign: 'right' }}>Кол-во</div><div style={{ textAlign: 'right' }}>Доля</div>
|
|
||||||
</div>
|
|
||||||
{statusPieData.map(s => {
|
|
||||||
const pct = totalGroups > 0 ? ((s.value / totalGroups) * 100).toFixed(1) : 0;
|
|
||||||
return (
|
|
||||||
<div key={s.status} style={{
|
|
||||||
display: 'grid', gridTemplateColumns: mobile ? '8px 1fr 45px 40px' : '12px 1fr 60px 55px',
|
|
||||||
gap: '0 0.5rem', padding: '0.5rem 0.3rem', alignItems: 'center',
|
|
||||||
borderBottom: '1px solid var(--color-border, rgba(51,65,85,0.4))',
|
|
||||||
}}>
|
|
||||||
<div style={{ width: mobile ? '8px' : '12px', height: mobile ? '8px' : '12px', borderRadius: '3px', background: STATUS_COLORS[s.status] || '#6b7280' }} />
|
|
||||||
<div style={{ fontSize: fontSize.m, color: 'var(--color-text)' }}>{s.name}</div>
|
|
||||||
<div style={{ textAlign: 'right', fontSize: fontSize.m, fontWeight: 700, color: 'var(--color-text)' }}>{s.value}</div>
|
|
||||||
<div style={{ textAlign: 'right', fontSize: fontSize.s, color: 'var(--color-text-muted)' }}>{pct}%</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
{/* Funnel — 4 cols desktop */}
|
|
||||||
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem', gridColumn: colSpan(4) }}>
|
|
||||||
<SectionHeader title="Путь заказа" subtitle={periodLabel} mobile={mobile} />
|
|
||||||
{totalGroups === 0 ? (
|
|
||||||
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1rem' }}>Нет данных</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0', padding: '0.4rem 0' }}>
|
|
||||||
{funnelSteps.map((step, i) => {
|
|
||||||
const pct = totalGroups > 0 ? Math.round((step.value / totalGroups) * 100) : 0;
|
|
||||||
const widthPct = step.value > 0 ? Math.max(15, (step.value / totalGroups) * 100) : 15;
|
|
||||||
return (
|
|
||||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1px', width: '100%' }}>
|
|
||||||
<div style={{ fontSize: mobile ? '0.8rem' : '0.9rem', fontWeight: 700, color: 'var(--color-text)', textAlign: 'center' }}>
|
|
||||||
{step.value}
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
width: widthPct + '%', height: mobile ? '28px' : '34px', background: step.value > 0 ? step.color : 'var(--color-border, #334155)',
|
|
||||||
borderRadius: '4px', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
transition: 'width 0.4s ease', minWidth: '40px', maxWidth: '100%',
|
|
||||||
opacity: step.value > 0 ? 1 : 0.5,
|
|
||||||
}}>
|
|
||||||
<span style={{ fontSize: mobile ? '0.6rem' : '0.72rem', fontWeight: 600, color: step.value > 0 ? '#fff' : 'var(--color-text-muted)', textShadow: step.value > 0 ? '0 1px 2px rgba(0,0,0,0.3)' : 'none' }}>
|
|
||||||
{pct}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: mobile ? '0.65rem' : '0.75rem', color: 'var(--color-text-muted)', textAlign: 'center', maxWidth: mobile ? '180px' : '240px' }}>
|
|
||||||
{step.label}
|
|
||||||
</div>
|
|
||||||
{i < funnelSteps.length - 1 && (
|
|
||||||
<div style={{ width: '2px', height: '4px', background: 'var(--color-border)' }} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr' : '1fr 1fr 1fr', gap: '0.5rem',
|
|
||||||
marginTop: '0.75rem', paddingTop: '0.6rem', borderTop: '1px solid var(--color-border)',
|
|
||||||
}}>
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
|
||||||
<div style={{ fontSize: fontSize.xs, color: '#22c55e', marginBottom: '1px' }}>Полная цепочка</div>
|
|
||||||
<div style={{ fontSize: mobile ? '0.95rem' : '1.1rem', fontWeight: 700, color: '#22c55e' }}>{econ.full_chain_pct ?? 0}%</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
|
||||||
<div style={{ fontSize: fontSize.xs, color: '#f97316', marginBottom: '1px' }}>В обход</div>
|
|
||||||
<div style={{ fontSize: mobile ? '0.95rem' : '1.1rem', fontWeight: 700, color: '#f97316' }}>{econ.bypassed_pct ?? 0}%</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ textAlign: 'center', display: mobile ? 'none' : 'block' }}>
|
|
||||||
<div style={{ fontSize: fontSize.xs, color: 'var(--color-text-muted)', marginBottom: '1px' }}>Завершено</div>
|
|
||||||
<div style={{ fontSize: mobile ? '0.95rem' : '1.1rem', fontWeight: 700, color: 'var(--color-text)' }}>{completedTotal}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
{/* SMS + Drivers side by side — 4 cols each on desktop */}
|
|
||||||
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem', gridColumn: colSpan(4) }}>
|
|
||||||
<SectionHeader title="SMS" subtitle={periodLabel} mobile={mobile} />
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.5rem' }}>
|
|
||||||
{[
|
|
||||||
{ label: 'SMS 1', val: econ.sms1_sent_count ?? 0 },
|
|
||||||
{ label: 'SMS 2', val: econ.sms2_sent_count ?? 0 },
|
|
||||||
{ label: 'Всего', val: (econ.sms1_sent_count || 0) + (econ.sms2_sent_count || 0) },
|
|
||||||
].map((item, i) => (
|
|
||||||
<div key={i} style={{ textAlign: 'center' }}>
|
|
||||||
<div style={{ fontSize: fontSize.xs, color: 'var(--color-text-muted)', marginBottom: '2px', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{item.label}</div>
|
|
||||||
<div style={{ fontSize: mobile ? '1.1rem' : '1.3rem', fontWeight: 800, color: 'var(--color-text)' }}>{item.val}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
{/* Drivers — 8 cols desktop */}
|
|
||||||
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem', gridColumn: colSpan(8) }}>
|
|
||||||
<SectionHeader title="По водителям" subtitle={periodLabel} mobile={mobile} />
|
|
||||||
{driverData.length === 0 ? (
|
|
||||||
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '2rem' }}>Нет данных</div>
|
|
||||||
) : (
|
|
||||||
<ResponsiveContainer width="100%" height={Math.max(180, driverData.length * (mobile ? 35 : 50))}>
|
|
||||||
<BarChart data={driverData} layout="vertical" margin={{ left: mobile ? 5 : 20, right: mobile ? 5 : 30 }}>
|
|
||||||
<XAxis type="number" tick={{ fontSize: mobile ? 9 : 11, fill: 'var(--color-text-muted)' }} />
|
|
||||||
<YAxis type="category" dataKey="name" tick={{ fontSize: mobile ? 9 : 11, fill: 'var(--color-text-muted)' }} width={mobile ? 80 : 130} />
|
|
||||||
<Tooltip content={<CustomTooltip />} />
|
|
||||||
<Legend wrapperStyle={{ fontSize: fontSize.xs }} />
|
|
||||||
<Bar dataKey="delivered" name="Доставлено" fill="#22c55e" stackId="a" radius={[0, 0, 0, 0]} />
|
|
||||||
<Bar dataKey="picked_up" name="Вывезено" fill="#14b8a6" stackId="a" radius={[0, 0, 0, 0]} />
|
|
||||||
<Bar dataKey="problems" name="Проблемы" fill="#ef4444" stackId="a" radius={[0, 4, 4, 0]} />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
)}
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
{/* Pickup Stats — full width (12 cols) */}
|
|
||||||
<div style={{ gridColumn: colSpan(12) }}>
|
|
||||||
<PickupStatsPanel stats={pickupStats} isLoading={pickupLoading} mobile={mobile} fontSize={fontSize} periodLabel={periodLabel} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Status table */}
|
||||||
|
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
|
||||||
|
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>Все статусы</h3>
|
||||||
|
{statusPieData.length === 0 ? (
|
||||||
|
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1rem' }}>Нет данных</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div style={{
|
||||||
|
display: 'grid', gridTemplateColumns: mobile ? '8px 1fr 50px 40px' : '10px 1fr 70px 55px',
|
||||||
|
gap: '0 0.4rem', padding: '0.3rem 0.3rem', alignItems: 'center',
|
||||||
|
borderBottom: '1px solid var(--color-border)', fontSize: fontSize.xs,
|
||||||
|
color: 'var(--color-text-muted)', fontWeight: 600,
|
||||||
|
}}>
|
||||||
|
<div /><div>Статус</div><div style={{ textAlign: 'right' }}>Кол-во</div><div style={{ textAlign: 'right' }}>Доля</div>
|
||||||
|
</div>
|
||||||
|
{statusPieData.map(s => {
|
||||||
|
const pct = totalGroups > 0 ? ((s.value / totalGroups) * 100).toFixed(1) : 0;
|
||||||
|
return (
|
||||||
|
<div key={s.status} style={{
|
||||||
|
display: 'grid', gridTemplateColumns: mobile ? '8px 1fr 50px 40px' : '10px 1fr 70px 55px',
|
||||||
|
gap: '0 0.4rem', padding: '0.4rem 0.3rem', alignItems: 'center',
|
||||||
|
borderBottom: '1px solid var(--color-border, rgba(51,65,85,0.4))',
|
||||||
|
}}>
|
||||||
|
<div style={{ width: mobile ? '8px' : '10px', height: mobile ? '8px' : '10px', borderRadius: '3px', background: STATUS_COLORS[s.status] || '#6b7280' }} />
|
||||||
|
<div style={{ fontSize: fontSize.m, color: 'var(--color-text)' }}>{s.name}</div>
|
||||||
|
<div style={{ textAlign: 'right', fontSize: fontSize.m, fontWeight: 600, color: 'var(--color-text)' }}>{s.value}</div>
|
||||||
|
<div style={{ textAlign: 'right', fontSize: fontSize.s, color: 'var(--color-text-muted)' }}>{pct}%</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* Воронка согласования — ALL steps always visible */}
|
||||||
|
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
|
||||||
|
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.5rem', color: 'var(--color-text)' }}>Воронка согласования</h3>
|
||||||
|
{totalGroups === 0 ? (
|
||||||
|
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1rem' }}>Нет данных</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0', padding: '0.4rem 0' }}>
|
||||||
|
{funnelSteps.map((step, i) => {
|
||||||
|
const pct = totalGroups > 0 ? Math.round((step.value / totalGroups) * 100) : 0;
|
||||||
|
const widthPct = step.value > 0 ? Math.max(15, (step.value / totalGroups) * 100) : 15;
|
||||||
|
return (
|
||||||
|
<div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1px', width: '100%' }}>
|
||||||
|
<div style={{ fontSize: mobile ? '0.8rem' : '0.85rem', fontWeight: 700, color: 'var(--color-text)', textAlign: 'center' }}>
|
||||||
|
{step.value}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
width: widthPct + '%', height: mobile ? '28px' : '32px', background: step.value > 0 ? step.color : 'var(--color-border, #334155)',
|
||||||
|
borderRadius: '4px', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
transition: 'width 0.4s ease', minWidth: '40px', maxWidth: '100%',
|
||||||
|
opacity: step.value > 0 ? 1 : 0.5,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: mobile ? '0.6rem' : '0.7rem', fontWeight: 600, color: step.value > 0 ? '#fff' : 'var(--color-text-muted)', textShadow: step.value > 0 ? '0 1px 2px rgba(0,0,0,0.3)' : 'none' }}>
|
||||||
|
{pct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: mobile ? '0.65rem' : '0.72rem', color: 'var(--color-text-muted)', textAlign: 'center', maxWidth: mobile ? '180px' : '220px' }}>
|
||||||
|
{step.label}
|
||||||
|
</div>
|
||||||
|
{i < funnelSteps.length - 1 && (
|
||||||
|
<div style={{ width: '2px', height: '4px', background: 'var(--color-border)' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr' : '1fr 1fr 1fr', gap: '0.5rem',
|
||||||
|
marginTop: '0.75rem', paddingTop: '0.6rem', borderTop: '1px solid var(--color-border)',
|
||||||
|
}}>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: fontSize.xs, color: '#22c55e', marginBottom: '1px' }}>Автосогласование</div>
|
||||||
|
<div style={{ fontSize: mobile ? '0.95rem' : '1.05rem', fontWeight: 700, color: '#22c55e' }}>{econ.auto_confirm_pct ?? 0}%</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: fontSize.xs, color: '#ef4444', marginBottom: '1px' }}>Ручное вмешательство</div>
|
||||||
|
<div style={{ fontSize: mobile ? '0.95rem' : '1.05rem', fontWeight: 700, color: '#ef4444' }}>{econ.manual_intervention_pct ?? 0}%</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center', display: mobile ? 'none' : 'block' }}>
|
||||||
|
<div style={{ fontSize: fontSize.xs, color: 'var(--color-text-muted)', marginBottom: '1px' }}>Всего согласовано</div>
|
||||||
|
<div style={{ fontSize: mobile ? '0.95rem' : '1.05rem', fontWeight: 700, color: 'var(--color-text)' }}>{econ.confirmed_auto_total ?? 0}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* SMS */}
|
||||||
|
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
|
||||||
|
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>SMS</h3>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.5rem' }}>
|
||||||
|
{[
|
||||||
|
{ label: 'SMS 1', val: econ.sms1_sent_count ?? 0 },
|
||||||
|
{ label: 'SMS 2', val: econ.sms2_sent_count ?? 0 },
|
||||||
|
{ label: 'Всего', val: (econ.sms1_sent_count || 0) + (econ.sms2_sent_count || 0) },
|
||||||
|
].map((item, i) => (
|
||||||
|
<div key={i} style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: fontSize.xs, color: 'var(--color-text-muted)', marginBottom: '1px' }}>{item.label}</div>
|
||||||
|
<div style={{ fontSize: mobile ? '1rem' : '1.1rem', fontWeight: 700, color: 'var(--color-text)' }}>{item.val}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* Pickup Stats */}
|
||||||
|
<PickupStatsPanel stats={pickupStats} isLoading={pickupLoading} mobile={mobile} fontSize={fontSize} />
|
||||||
|
|
||||||
|
{/* Drivers */}
|
||||||
|
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
|
||||||
|
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>По водителям</h3>
|
||||||
|
{driverData.length === 0 ? (
|
||||||
|
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1.5rem' }}>Нет данных</div>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={Math.max(150, driverData.length * (mobile ? 35 : 45))}>
|
||||||
|
<BarChart data={driverData} layout="vertical" margin={{ left: mobile ? 5 : 20, right: mobile ? 5 : 20 }}>
|
||||||
|
<XAxis type="number" tick={{ fontSize: mobile ? 9 : 10, fill: 'var(--color-text-muted)' }} />
|
||||||
|
<YAxis type="category" dataKey="name" tick={{ fontSize: mobile ? 9 : 10, fill: 'var(--color-text-muted)' }} width={driverLabelWidth} />
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Legend wrapperStyle={{ fontSize: fontSize.xs }} />
|
||||||
|
<Bar dataKey="delivered" name="Доставлено" fill="#22c55e" stackId="a" />
|
||||||
|
<Bar dataKey="problems" name="Проблемы" fill="#ef4444" stackId="a" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -1,255 +0,0 @@
|
||||||
/**
|
|
||||||
* @file BusinessSchedulePanel.jsx
|
|
||||||
* @description Admin panel for configuring work days: delivery, pickup, SMS.
|
|
||||||
* Days stored in business_schedule_settings table as INT[] (1=Mon ... 7=Sun).
|
|
||||||
*/
|
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
|
||||||
import { Panel } from "../UI/Panel";
|
|
||||||
import { Button } from "../UI/Button";
|
|
||||||
import { Badge } from "../UI/Badge";
|
|
||||||
import { supabase } from "../../supabaseClient";
|
|
||||||
|
|
||||||
const DAY_LABELS = [
|
|
||||||
{ num: 1, label: "Пн", full: "Понедельник" },
|
|
||||||
{ num: 2, label: "Вт", full: "Вторник" },
|
|
||||||
{ num: 3, label: "Ср", full: "Среда" },
|
|
||||||
{ num: 4, label: "Чт", full: "Четверг" },
|
|
||||||
{ num: 5, label: "Пт", full: "Пятница" },
|
|
||||||
{ num: 6, label: "Сб", full: "Суббота" },
|
|
||||||
{ num: 7, label: "Вс", full: "Воскресенье" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const DEFAULT_SCHEDULE = {
|
|
||||||
deliveryDays: [1, 2, 3, 4, 5],
|
|
||||||
pickupDays: [1, 2, 3, 4, 5],
|
|
||||||
smsDays: [1, 2, 3, 4, 5],
|
|
||||||
};
|
|
||||||
|
|
||||||
const DayPills = ({ selectedDays, onToggle, accentColor }) => {
|
|
||||||
const toggle = (num) => {
|
|
||||||
if (selectedDays.includes(num)) {
|
|
||||||
onToggle(selectedDays.filter((d) => d !== num).sort());
|
|
||||||
} else {
|
|
||||||
onToggle([...selectedDays, num].sort());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{DAY_LABELS.map((day) => {
|
|
||||||
const isSelected = selectedDays.includes(day.num);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={day.num}
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggle(day.num)}
|
|
||||||
aria-pressed={isSelected}
|
|
||||||
title={day.full}
|
|
||||||
className={[
|
|
||||||
"min-w-[48px] rounded-[14px] border-2 px-3 py-2.5 text-center text-sm font-semibold transition select-none",
|
|
||||||
isSelected
|
|
||||||
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
|
||||||
: "border-[var(--color-border)] bg-[var(--color-surface-strong)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-text)]",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
{day.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BusinessSchedulePanel = () => {
|
|
||||||
const [schedule, setSchedule] = useState(DEFAULT_SCHEDULE);
|
|
||||||
const [original, setOriginal] = useState(DEFAULT_SCHEDULE);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [savedAt, setSavedAt] = useState(null);
|
|
||||||
|
|
||||||
const loadSchedule = useCallback(async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const { data, error: rpcError } = await supabase.rpc("get_business_schedule");
|
|
||||||
if (rpcError) throw rpcError;
|
|
||||||
if (data?.ok) {
|
|
||||||
const loaded = {
|
|
||||||
deliveryDays: data.deliveryDays || DEFAULT_SCHEDULE.deliveryDays,
|
|
||||||
pickupDays: data.pickupDays || DEFAULT_SCHEDULE.pickupDays,
|
|
||||||
smsDays: data.smsDays || DEFAULT_SCHEDULE.smsDays,
|
|
||||||
};
|
|
||||||
setSchedule(loaded);
|
|
||||||
setOriginal(loaded);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "Не удалось загрузить расписание");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadSchedule();
|
|
||||||
}, [loadSchedule]);
|
|
||||||
|
|
||||||
const hasChanges =
|
|
||||||
JSON.stringify(schedule.deliveryDays) !== JSON.stringify(original.deliveryDays) ||
|
|
||||||
JSON.stringify(schedule.pickupDays) !== JSON.stringify(original.pickupDays) ||
|
|
||||||
JSON.stringify(schedule.smsDays) !== JSON.stringify(original.smsDays);
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!hasChanges) return;
|
|
||||||
|
|
||||||
// Validate at least one day per category
|
|
||||||
if (schedule.deliveryDays.length === 0 || schedule.pickupDays.length === 0 || schedule.smsDays.length === 0) {
|
|
||||||
setError("Каждая категория должна иметь хотя бы один рабочий день");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSaving(true);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const { data, error: rpcError } = await supabase.rpc("update_business_schedule", {
|
|
||||||
p_delivery_days: schedule.deliveryDays,
|
|
||||||
p_pickup_days: schedule.pickupDays,
|
|
||||||
p_sms_days: schedule.smsDays,
|
|
||||||
});
|
|
||||||
if (rpcError) throw rpcError;
|
|
||||||
if (data?.ok) {
|
|
||||||
setOriginal({ ...schedule });
|
|
||||||
setSavedAt(new Date());
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "Не удалось сохранить расписание");
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDays = (days) => {
|
|
||||||
return days
|
|
||||||
.sort((a, b) => a - b)
|
|
||||||
.map((d) => DAY_LABELS.find((dl) => dl.num === d)?.label)
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(", ");
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<Panel className="p-5 sm:p-6">
|
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">Загрузка расписания…</p>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Расписание</p>
|
|
||||||
<h1 className="mt-1 text-2xl font-bold leading-tight">Рабочие дни</h1>
|
|
||||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
|
||||||
Настройте, в какие дни доступны доставка, самовывоз и отправка SMS-приглашений.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{savedAt && !hasChanges && (
|
|
||||||
<Badge tone="accent">Сохранено</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Delivery days */}
|
|
||||||
<Panel className="p-5 sm:p-6 space-y-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-xl">🚚</span>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
|
||||||
Доставка
|
|
||||||
</h2>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">
|
|
||||||
Дни, в которые клиент может выбрать доставку. В остальные дни доставка не предлагается.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DayPills
|
|
||||||
selectedDays={schedule.deliveryDays}
|
|
||||||
onToggle={(days) => setSchedule((s) => ({ ...s, deliveryDays: days }))}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">
|
|
||||||
Выбрано: {formatDays(schedule.deliveryDays) || "—"}
|
|
||||||
</p>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
{/* Pickup days */}
|
|
||||||
<Panel className="p-5 sm:p-6 space-y-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-xl">🏪</span>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
|
||||||
Самовывоз
|
|
||||||
</h2>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">
|
|
||||||
Дни, в которые клиент может выбрать самовывоз. В остальные дни самовывоз не предлагается.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DayPills
|
|
||||||
selectedDays={schedule.pickupDays}
|
|
||||||
onToggle={(days) => setSchedule((s) => ({ ...s, pickupDays: days }))}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">
|
|
||||||
Выбрано: {formatDays(schedule.pickupDays) || "—"}
|
|
||||||
</p>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
{/* SMS days */}
|
|
||||||
<Panel className="p-5 sm:p-6 space-y-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-xl">📨</span>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
|
||||||
SMS-приглашения
|
|
||||||
</h2>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">
|
|
||||||
Дни, в которые отправляются SMS-приглашения клиентам. В нерабочие дни SMS не отправляются (проверка статусов работает круглосуточно).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DayPills
|
|
||||||
selectedDays={schedule.smsDays}
|
|
||||||
onToggle={(days) => setSchedule((s) => ({ ...s, smsDays: days }))}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">
|
|
||||||
Выбрано: {formatDays(schedule.smsDays) || "—"}
|
|
||||||
</p>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
{/* Save */}
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button
|
|
||||||
variant={hasChanges ? "primary" : "ghost"}
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={!hasChanges || isSaving}
|
|
||||||
>
|
|
||||||
{isSaving ? "Сохранение…" : "Сохранить расписание"}
|
|
||||||
</Button>
|
|
||||||
{hasChanges && !isSaving && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setSchedule({ ...original })}
|
|
||||||
>
|
|
||||||
Отменить изменения
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Panel className="border border-[var(--color-danger)] bg-[var(--color-surface)] p-4 text-sm text-[var(--color-danger)]">
|
|
||||||
{error}
|
|
||||||
</Panel>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -11,9 +11,6 @@ const PICKUP_COLORS = {
|
||||||
saturday: '#06b6d4',
|
saturday: '#06b6d4',
|
||||||
pickup: '#f59e0b',
|
pickup: '#f59e0b',
|
||||||
delivery: '#6366f1',
|
delivery: '#6366f1',
|
||||||
picked_up: '#14b8a6',
|
|
||||||
pending: '#94a3b8',
|
|
||||||
manual: '#eab308',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const CustomTooltip = ({ active, payload }) => {
|
const CustomTooltip = ({ active, payload }) => {
|
||||||
|
|
@ -37,30 +34,17 @@ const CustomTooltip = ({ active, payload }) => {
|
||||||
const ProgressBar = ({ label, value, max, color, fontSize, mobile }) => {
|
const ProgressBar = ({ label, value, max, color, fontSize, mobile }) => {
|
||||||
const pct = max > 0 ? Math.max(0, (value / max) * 100) : 0;
|
const pct = max > 0 ? Math.max(0, (value / max) * 100) : 0;
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||||
<div style={{ flex: '0 0 auto', width: mobile ? '70px' : '110px', fontSize: fontSize.xs, color: 'var(--color-text-muted)', textAlign: 'right' }}>{label}</div>
|
<div style={{ flex: '0 0 auto', width: mobile ? '70px' : '100px', fontSize: fontSize.xs, color: 'var(--color-text-muted)', textAlign: 'right' }}>{label}</div>
|
||||||
<div style={{ flex: '1 1 auto', height: mobile ? '16px' : '22px', background: 'var(--color-border, rgba(51,65,85,0.4))', borderRadius: '4px', overflow: 'hidden' }}>
|
<div style={{ flex: '1 1 auto', height: mobile ? '16px' : '20px', background: 'var(--color-border, rgba(51,65,85,0.4))', borderRadius: '4px', overflow: 'hidden' }}>
|
||||||
<div style={{ width: pct + '%', height: '100%', background: color, borderRadius: '4px', transition: 'width 0.4s ease', minWidth: pct > 0 ? '4px' : '0' }} />
|
<div style={{ width: pct + '%', height: '100%', background: color, borderRadius: '4px', transition: 'width 0.4s ease', minWidth: pct > 0 ? '4px' : '0' }} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: '0 0 auto', width: mobile ? '36px' : '50px', fontSize: fontSize.s, fontWeight: 700, color: 'var(--color-text)', textAlign: 'left' }}>{value}</div>
|
<div style={{ flex: '0 0 auto', width: mobile ? '36px' : '45px', fontSize: fontSize.s, fontWeight: 600, color: 'var(--color-text)', textAlign: 'left' }}>{value}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const SectionHeader = ({ title, subtitle, mobile }) => (
|
export const PickupStatsPanel = ({ stats, isLoading, mobile, fontSize }) => {
|
||||||
<div style={{ marginBottom: '0.6rem' }}>
|
|
||||||
<h3 style={{ fontSize: mobile ? '0.9rem' : '1rem', fontWeight: 700, color: 'var(--color-text)', marginBottom: subtitle ? '0.1rem' : 0 }}>
|
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
{subtitle && (
|
|
||||||
<div style={{ fontSize: mobile ? '0.65rem' : '0.72rem', color: 'var(--color-text-muted)' }}>
|
|
||||||
{subtitle}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const PickupStatsPanel = ({ stats, isLoading, mobile, fontSize, periodLabel = '' }) => {
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Panel>
|
<Panel>
|
||||||
|
|
@ -81,137 +65,103 @@ export const PickupStatsPanel = ({ stats, isLoading, mobile, fontSize, periodLab
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fs = fontSize || { xs: '0.72rem', s: '0.78rem', m: '0.85rem', l: '0.95rem' };
|
const fs = fontSize || { xs: '0.65rem', s: '0.68rem', m: '0.78rem', l: '0.85rem', xl: '1.1rem' };
|
||||||
const totalPickups = Number(stats.total_pickups) || 0;
|
const totalPickups = Number(stats.total_pickups) || 0;
|
||||||
const pickedUp = Number(stats.picked_up) || 0;
|
|
||||||
const pending = Number(stats.pending) || 0;
|
|
||||||
const manual = Number(stats.manual) || 0;
|
|
||||||
const pickupRate = Number(stats.pickup_rate) || 0;
|
const pickupRate = Number(stats.pickup_rate) || 0;
|
||||||
const avgDays = stats.avg_days_until_pickup != null ? Number(stats.avg_days_until_pickup) : null;
|
const avgDays = stats.avg_days_until_pickup != null ? Number(stats.avg_days_until_pickup) : null;
|
||||||
const dist = stats.delivery_type_dist || {};
|
const dist = stats.delivery_type_dist || {};
|
||||||
|
|
||||||
const pickupToday = Number(stats.pickup_today) || 0;
|
const maxDay = Math.max(
|
||||||
const pickupTomorrow = Number(stats.pickup_tomorrow) || 0;
|
Number(stats.pickup_today) || 0,
|
||||||
const pickupDayAfter = Number(stats.pickup_day_after) || 0;
|
Number(stats.pickup_tomorrow) || 0,
|
||||||
const pickupFirstHalf = Number(stats.pickup_first_half) || 0;
|
Number(stats.pickup_day_after) || 0,
|
||||||
const pickupSecondHalf = Number(stats.pickup_second_half) || 0;
|
1
|
||||||
const pickupOnSaturday = Number(stats.pickup_on_saturday) || 0;
|
);
|
||||||
|
|
||||||
const hasScheduledPickups = pickupToday + pickupTomorrow + pickupDayAfter > 0;
|
const maxHalf = Math.max(
|
||||||
const hasTimeSlots = pickupFirstHalf + pickupSecondHalf > 0;
|
Number(stats.pickup_first_half) || 0,
|
||||||
|
Number(stats.pickup_second_half) || 0,
|
||||||
const maxDay = Math.max(pickupToday, pickupTomorrow, pickupDayAfter, 1);
|
1
|
||||||
const maxHalf = Math.max(pickupFirstHalf, pickupSecondHalf, 1);
|
);
|
||||||
|
|
||||||
const pieData = [
|
const pieData = [
|
||||||
{ name: 'Самовывоз', value: Number(dist.pickup) || 0, fill: PICKUP_COLORS.pickup },
|
{ name: 'Самовывоз', value: Number(dist.pickup) || 0, fill: PICKUP_COLORS.pickup },
|
||||||
{ name: 'Доставка', value: Number(dist.delivery) || 0, fill: PICKUP_COLORS.delivery },
|
{ name: 'Доставка', value: Number(dist.delivery) || 0, fill: PICKUP_COLORS.delivery },
|
||||||
].filter(d => d.value > 0);
|
].filter(d => d.value > 0);
|
||||||
|
|
||||||
// Desktop: 3-col grid inside panel. Mobile: stacked.
|
|
||||||
const innerCols = mobile ? '1fr' : '1fr 1fr 1fr';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem' }}>
|
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
|
||||||
<SectionHeader title="📦 Самовывоз" subtitle={periodLabel} mobile={mobile} />
|
<h3 style={{ fontSize: fs.l, fontWeight: 600, marginBottom: '0.5rem', color: 'var(--color-text)' }}>
|
||||||
|
📦 Самовывоз
|
||||||
|
</h3>
|
||||||
|
|
||||||
{/* KPI row */}
|
{/* KPI row */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr 1fr 1fr' : 'repeat(auto-fit, minmax(120px, 1fr))', gap: '0.5rem', marginBottom: '1rem' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr 1fr' : '1fr 1fr 1fr', gap: '0.5rem', marginBottom: '0.75rem' }}>
|
||||||
{[
|
{[
|
||||||
{ label: 'Всего', val: totalPickups, color: '#f59e0b' },
|
{ label: 'Всего самовывоз', val: totalPickups, color: '#f59e0b' },
|
||||||
{ label: 'Завершено', val: pickedUp, color: '#14b8a6' },
|
{ label: 'Доля самовывоза', val: pickupRate + '%', color: '#f59e0b' },
|
||||||
{ label: 'Ожидает', val: pending, color: '#94a3b8' },
|
{ label: 'Ср. дней до выдачи', val: avgDays !== null ? avgDays : '—', color: '#3b82f6' },
|
||||||
{ label: 'Доля', val: pickupRate + '%', color: '#f59e0b' },
|
|
||||||
].map((kpi, i) => (
|
].map((kpi, i) => (
|
||||||
<div key={i} style={{ textAlign: 'center', padding: '0.4rem 0.2rem' }}>
|
<div key={i} style={{ textAlign: 'center' }}>
|
||||||
<div style={{ fontSize: fs.xs, color: 'var(--color-text-muted)', marginBottom: '2px', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{kpi.label}</div>
|
<div style={{ fontSize: fs.xs, color: 'var(--color-text-muted)', marginBottom: '1px' }}>{kpi.label}</div>
|
||||||
<div style={{ fontSize: mobile ? '1.05rem' : '1.3rem', fontWeight: 800, color: kpi.color }}>{kpi.val}</div>
|
<div style={{ fontSize: mobile ? '1rem' : '1.2rem', fontWeight: 700, color: kpi.color }}>{kpi.val}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content grid: status breakdown | schedule | donut */}
|
{/* Distribution by day */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: innerCols, gap: mobile ? '0.75rem' : '1.5rem' }}>
|
<div style={{ marginBottom: '0.6rem' }}>
|
||||||
|
<div style={{ fontSize: fs.m, fontWeight: 600, color: 'var(--color-text)', marginBottom: '0.3rem' }}>По дням</div>
|
||||||
{/* Status breakdown */}
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
|
||||||
<div>
|
<ProgressBar label="Сегодня" value={Number(stats.pickup_today) || 0} max={maxDay} color={PICKUP_COLORS.today} fontSize={fs} mobile={mobile} />
|
||||||
<div style={{ fontSize: fs.m, fontWeight: 700, color: 'var(--color-text)', marginBottom: '0.4rem' }}>По статусам</div>
|
<ProgressBar label="Завтра" value={Number(stats.pickup_tomorrow) || 0} max={maxDay} color={PICKUP_COLORS.tomorrow} fontSize={fs} mobile={mobile} />
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
|
<ProgressBar label="Послезавтра" value={Number(stats.pickup_day_after) || 0} max={maxDay} color={PICKUP_COLORS.dayAfter} fontSize={fs} mobile={mobile} />
|
||||||
<ProgressBar label="Завершено" value={pickedUp} max={Math.max(totalPickups, 1)} color={PICKUP_COLORS.picked_up} fontSize={fs} mobile={mobile} />
|
|
||||||
<ProgressBar label="Ожидает" value={pending} max={Math.max(totalPickups, 1)} color={PICKUP_COLORS.pending} fontSize={fs} mobile={mobile} />
|
|
||||||
<ProgressBar label="Ручное" value={manual} max={Math.max(totalPickups, 1)} color={PICKUP_COLORS.manual} fontSize={fs} mobile={mobile} />
|
|
||||||
</div>
|
|
||||||
{avgDays !== null && avgDays > 0 && (
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.4rem 0', marginTop: '0.3rem', borderTop: '1px solid var(--color-border)' }}>
|
|
||||||
<span style={{ fontSize: fs.s, color: 'var(--color-text-muted)' }}>Ср. дней до выдачи</span>
|
|
||||||
<span style={{ fontSize: fs.l, fontWeight: 700, color: '#3b82f6' }}>{avgDays}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Schedule — only show if there are scheduled pickups */}
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: fs.m, fontWeight: 700, color: 'var(--color-text)', marginBottom: '0.4rem' }}>Расписание</div>
|
|
||||||
{hasScheduledPickups ? (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem', marginBottom: '0.5rem' }}>
|
|
||||||
<ProgressBar label="Сегодня" value={pickupToday} max={maxDay} color={PICKUP_COLORS.today} fontSize={fs} mobile={mobile} />
|
|
||||||
<ProgressBar label="Завтра" value={pickupTomorrow} max={maxDay} color={PICKUP_COLORS.tomorrow} fontSize={fs} mobile={mobile} />
|
|
||||||
<ProgressBar label="Послезавтра" value={pickupDayAfter} max={maxDay} color={PICKUP_COLORS.dayAfter} fontSize={fs} mobile={mobile} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ fontSize: fs.s, color: 'var(--color-text-muted)', padding: '0.5rem 0', marginBottom: '0.5rem' }}>
|
|
||||||
Нет запланированных самовывозов
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasTimeSlots && (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
|
|
||||||
<ProgressBar label="До обеда" value={pickupFirstHalf} max={maxHalf} color={PICKUP_COLORS.firstHalf} fontSize={fs} mobile={mobile} />
|
|
||||||
<ProgressBar label="После обеда" value={pickupSecondHalf} max={maxHalf} color={PICKUP_COLORS.secondHalf} fontSize={fs} mobile={mobile} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.4rem 0', marginTop: '0.3rem', borderTop: '1px solid var(--color-border)' }}>
|
|
||||||
<span style={{ fontSize: fs.s, color: 'var(--color-text-muted)' }}>Самовывоз в субботу</span>
|
|
||||||
<span style={{ fontSize: fs.l, fontWeight: 700, color: PICKUP_COLORS.saturday }}>{pickupOnSaturday}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Delivery vs Pickup donut */}
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: fs.m, fontWeight: 700, color: 'var(--color-text)', marginBottom: '0.4rem' }}>Доставка vs Самовывоз</div>
|
|
||||||
{pieData.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<ResponsiveContainer width="100%" height={mobile ? 140 : 180}>
|
|
||||||
<PieChart>
|
|
||||||
<Pie data={pieData} cx="50%" cy="50%"
|
|
||||||
innerRadius={mobile ? 30 : 45}
|
|
||||||
outerRadius={mobile ? 55 : 75}
|
|
||||||
dataKey="value" nameKey="name" paddingAngle={2}
|
|
||||||
>
|
|
||||||
{pieData.map((entry, i) => (
|
|
||||||
<Cell key={i} fill={entry.fill} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip content={<CustomTooltip />} />
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', marginTop: '0.3rem' }}>
|
|
||||||
{pieData.map((d, i) => (
|
|
||||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: fs.xs }}>
|
|
||||||
<div style={{ width: '10px', height: '10px', borderRadius: '2px', background: d.fill }} />
|
|
||||||
<span style={{ color: 'var(--color-text-muted)' }}>{d.name}: <strong style={{ color: 'var(--color-text)' }}>{d.value}</strong></span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '2rem 0', fontSize: fs.s }}>
|
|
||||||
Нет данных
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Half-day split */}
|
||||||
|
<div style={{ marginBottom: '0.6rem' }}>
|
||||||
|
<div style={{ fontSize: fs.m, fontWeight: 600, color: 'var(--color-text)', marginBottom: '0.3rem' }}>По времени</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
|
||||||
|
<ProgressBar label="До обеда" value={Number(stats.pickup_first_half) || 0} max={maxHalf} color={PICKUP_COLORS.firstHalf} fontSize={fs} mobile={mobile} />
|
||||||
|
<ProgressBar label="После обеда" value={Number(stats.pickup_second_half) || 0} max={maxHalf} color={PICKUP_COLORS.secondHalf} fontSize={fs} mobile={mobile} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Saturday */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.3rem 0', borderTop: '1px solid var(--color-border)', marginBottom: '0.6rem' }}>
|
||||||
|
<span style={{ fontSize: fs.s, color: 'var(--color-text-muted)' }}>Самовывоз в субботу</span>
|
||||||
|
<span style={{ fontSize: fs.l, fontWeight: 700, color: PICKUP_COLORS.saturday }}>{Number(stats.pickup_on_saturday) || 0}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delivery vs Pickup donut */}
|
||||||
|
{pieData.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: fs.m, fontWeight: 600, color: 'var(--color-text)', marginBottom: '0.3rem' }}>Доставка vs Самовывоз</div>
|
||||||
|
<ResponsiveContainer width="100%" height={mobile ? 140 : 170}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie data={pieData} cx="50%" cy="50%"
|
||||||
|
innerRadius={mobile ? 30 : 40}
|
||||||
|
outerRadius={mobile ? 55 : 70}
|
||||||
|
dataKey="value" nameKey="name" paddingAngle={2}
|
||||||
|
>
|
||||||
|
{pieData.map((entry, i) => (
|
||||||
|
<Cell key={i} fill={entry.fill} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', marginTop: '0.2rem' }}>
|
||||||
|
{pieData.map((d, i) => (
|
||||||
|
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: fs.xs }}>
|
||||||
|
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: d.fill }} />
|
||||||
|
<span style={{ color: 'var(--color-text-muted)' }}>{d.name}: <strong style={{ color: 'var(--color-text)' }}>{d.value}</strong></span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -9,7 +9,6 @@ import { Panel } from "../UI/Panel";
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
import { supabase } from "../../supabaseClient";
|
import { supabase } from "../../supabaseClient";
|
||||||
import { SmsCampaignStats } from "./SmsCampaignStats";
|
import { SmsCampaignStats } from "./SmsCampaignStats";
|
||||||
import { SmsCityProfilesPanel } from "./SmsCityProfilesPanel";
|
|
||||||
|
|
||||||
// ── Campaigns ───────────────────────────────────────────────────────────────
|
// ── Campaigns ───────────────────────────────────────────────────────────────
|
||||||
const CAMPAIGNS = [
|
const CAMPAIGNS = [
|
||||||
|
|
@ -122,11 +121,7 @@ export const SmsCampaignPanel = () => {
|
||||||
const [checkingIds, setCheckingIds] = useState(new Set());
|
const [checkingIds, setCheckingIds] = useState(new Set());
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||||
const [togglingCampaign, setTogglingCampaign] = useState(null);
|
const [togglingCampaign, setTogglingCampaign] = useState(null);
|
||||||
const [showCityRules, setShowCityRules] = useState(false);
|
|
||||||
const [logPage, setLogPage] = useState(1);
|
|
||||||
const [manualPage, setManualPage] = useState(1);
|
|
||||||
const refreshTimer = useRef(null);
|
const refreshTimer = useRef(null);
|
||||||
const PER_PAGE = 20;
|
|
||||||
|
|
||||||
const handleOpenGroup = useCallback((groupId) => {
|
const handleOpenGroup = useCallback((groupId) => {
|
||||||
if (groupId) navigate("/dashboard/group/" + groupId);
|
if (groupId) navigate("/dashboard/group/" + groupId);
|
||||||
|
|
@ -334,7 +329,7 @@ export const SmsCampaignPanel = () => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Campaign cards */}
|
{/* Campaign cards */}
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4 max-w-4xl">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{CAMPAIGNS.map(c => {
|
{CAMPAIGNS.map(c => {
|
||||||
const isActive = activeCampaign === c.key;
|
const isActive = activeCampaign === c.key;
|
||||||
const s = allSettings[c.key];
|
const s = allSettings[c.key];
|
||||||
|
|
@ -351,7 +346,7 @@ export const SmsCampaignPanel = () => {
|
||||||
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"
|
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"
|
||||||
: "border-[var(--color-border)] bg-[var(--color-surface)] hover:bg-[var(--color-surface-strong)]"
|
: "border-[var(--color-border)] bg-[var(--color-surface)] hover:bg-[var(--color-surface-strong)]"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => { setActiveCampaign(c.key); setFilter("all"); setLogPage(1); setManualPage(1); }}
|
onClick={() => { setActiveCampaign(c.key); setFilter("all"); }}
|
||||||
>
|
>
|
||||||
{/* Header: icon + toggle */}
|
{/* Header: icon + toggle */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
@ -410,25 +405,6 @@ export const SmsCampaignPanel = () => {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── City rules button ──────────────────────────────────────────── */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowCityRules(!showCityRules)}
|
|
||||||
className={[
|
|
||||||
"rounded-2xl border px-4 py-2.5 text-sm font-medium transition flex items-center gap-2",
|
|
||||||
showCityRules
|
|
||||||
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-accent)]"
|
|
||||||
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
🏙️ SMS по городам
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── City profiles panel ────────────────────────────────────────── */}
|
|
||||||
{showCityRules && <SmsCityProfilesPanel />}
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Panel className="p-3">
|
<Panel className="p-3">
|
||||||
<div className="text-xs text-[var(--color-danger)]">{error}</div>
|
<div className="text-xs text-[var(--color-danger)]">{error}</div>
|
||||||
|
|
@ -437,10 +413,10 @@ export const SmsCampaignPanel = () => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Statistics ────────────────────────────────────────────────────── */}
|
{/* ── Statistics ────────────────────────────────────────────────────── */}
|
||||||
{!showCityRules && <SmsCampaignStats campaignType={activeCampaign} />}
|
<SmsCampaignStats campaignType={activeCampaign} />
|
||||||
|
|
||||||
{/* ── Settings ──────────────────────────────────────────────────────── */}
|
{/* ── Settings ──────────────────────────────────────────────────────── */}
|
||||||
{!showCityRules && showSettings && settings && (
|
{showSettings && settings && (
|
||||||
<Panel className="p-5">
|
<Panel className="p-5">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold text-[var(--color-text)]">
|
<h3 className="text-sm font-semibold text-[var(--color-text)]">
|
||||||
|
|
@ -591,7 +567,7 @@ export const SmsCampaignPanel = () => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Not implemented ──────────────────────────────────────────────── */}
|
{/* ── Not implemented ──────────────────────────────────────────────── */}
|
||||||
{!showCityRules && !showSettings && (
|
{!showSettings && (
|
||||||
<Panel className="p-5">
|
<Panel className="p-5">
|
||||||
<div className="text-sm text-[var(--color-text-muted)]">
|
<div className="text-sm text-[var(--color-text-muted)]">
|
||||||
{CAMPAIGNS.find(c => c.key === activeCampaign)?.label} — в разработке
|
{CAMPAIGNS.find(c => c.key === activeCampaign)?.label} — в разработке
|
||||||
|
|
@ -621,10 +597,7 @@ export const SmsCampaignPanel = () => {
|
||||||
{manualGroups.length === 0 ? (
|
{manualGroups.length === 0 ? (
|
||||||
<div className="px-4 py-6 text-xs text-[var(--color-text-muted)]">Нет групп в ручном управлении</div>
|
<div className="px-4 py-6 text-xs text-[var(--color-text-muted)]">Нет групп в ручном управлении</div>
|
||||||
) : (
|
) : (
|
||||||
(() => {
|
manualGroups.map((group) => (
|
||||||
const start = (manualPage - 1) * PER_PAGE;
|
|
||||||
const pageGroups = manualGroups.slice(start, start + PER_PAGE);
|
|
||||||
return pageGroups.map((group) => (
|
|
||||||
<div
|
<div
|
||||||
key={group.id}
|
key={group.id}
|
||||||
onClick={() => handleOpenGroup(group.id)}
|
onClick={() => handleOpenGroup(group.id)}
|
||||||
|
|
@ -646,31 +619,11 @@ export const SmsCampaignPanel = () => {
|
||||||
<div className="text-[10px]">{fmtElapsed(group.updated_at)}</div>
|
<div className="text-[10px]">{fmtElapsed(group.updated_at)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
));
|
))
|
||||||
})()
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{/* ── Pagination: manual groups ─────────────────────────────────── */}
|
|
||||||
{manualGroups.length > PER_PAGE && (
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setManualPage(p => Math.max(1, p - 1))}
|
|
||||||
disabled={manualPage <= 1}
|
|
||||||
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-40"
|
|
||||||
>← Назад</button>
|
|
||||||
<span className="text-xs text-[var(--color-text-muted)]">
|
|
||||||
Стр. {manualPage} / {Math.ceil(manualGroups.length / PER_PAGE)} · {manualGroups.length} записей
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setManualPage(p => Math.min(Math.ceil(manualGroups.length / PER_PAGE), p + 1))}
|
|
||||||
disabled={manualPage >= Math.ceil(manualGroups.length / PER_PAGE)}
|
|
||||||
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-40"
|
|
||||||
>Вперёд →</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
@ -689,11 +642,11 @@ export const SmsCampaignPanel = () => {
|
||||||
{/* ── Filter + auto-refresh ─────────────────────────────────────────── */}
|
{/* ── Filter + auto-refresh ─────────────────────────────────────────── */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<FilterButton active={filter === "all"} onClick={() => { setFilter("all"); setLogPage(1); }}>Все ({logs.length})</FilterButton>
|
<FilterButton active={filter === "all"} onClick={() => setFilter("all")}>Все ({logs.length})</FilterButton>
|
||||||
{Object.entries(STATUS_LABELS).map(([status, label]) => {
|
{Object.entries(STATUS_LABELS).map(([status, label]) => {
|
||||||
const count = stats[status] || 0;
|
const count = stats[status] || 0;
|
||||||
if (count === 0) return null;
|
if (count === 0) return null;
|
||||||
return <FilterButton key={status} active={filter === status} onClick={() => { setFilter(status); setLogPage(1); }}>{label} ({count})</FilterButton>;
|
return <FilterButton key={status} active={filter === status} onClick={() => setFilter(status)}>{label} ({count})</FilterButton>;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-1.5 text-xs text-[var(--color-text-muted)]">
|
<label className="flex items-center gap-1.5 text-xs text-[var(--color-text-muted)]">
|
||||||
|
|
@ -718,11 +671,7 @@ export const SmsCampaignPanel = () => {
|
||||||
{logs.length === 0 ? (
|
{logs.length === 0 ? (
|
||||||
<div className="px-4 py-6 text-xs text-[var(--color-text-muted)]">Нет записей</div>
|
<div className="px-4 py-6 text-xs text-[var(--color-text-muted)]">Нет записей</div>
|
||||||
) : (
|
) : (
|
||||||
(() => {
|
logs.map((entry) => {
|
||||||
const totalPages = Math.ceil(logs.length / PER_PAGE);
|
|
||||||
const start = (logPage - 1) * PER_PAGE;
|
|
||||||
const pageLogs = logs.slice(start, start + PER_PAGE);
|
|
||||||
return pageLogs.map((entry) => {
|
|
||||||
const canRecheck = entry.status === "sent" || entry.status === "checking";
|
const canRecheck = entry.status === "sent" || entry.status === "checking";
|
||||||
const isChecking = checkingIds.has(entry.id);
|
const isChecking = checkingIds.has(entry.id);
|
||||||
const isTestMode = settings?.test_mode;
|
const isTestMode = settings?.test_mode;
|
||||||
|
|
@ -766,32 +715,12 @@ export const SmsCampaignPanel = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
})
|
||||||
})()
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{/* ── Pagination: logs ─────────────────────────────────────────────── */}
|
|
||||||
{logs.length > PER_PAGE && (
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setLogPage(p => Math.max(1, p - 1))}
|
|
||||||
disabled={logPage <= 1}
|
|
||||||
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-40"
|
|
||||||
>← Назад</button>
|
|
||||||
<span className="text-xs text-[var(--color-text-muted)]">
|
|
||||||
Стр. {logPage} / {Math.ceil(logs.length / PER_PAGE)} · {logs.length} записей
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setLogPage(p => Math.min(Math.ceil(logs.length / PER_PAGE), p + 1))}
|
|
||||||
disabled={logPage >= Math.ceil(logs.length / PER_PAGE)}
|
|
||||||
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-40"
|
|
||||||
>Вперёд →</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button onClick={loadData} className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)]">
|
<button onClick={loadData} className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)]">
|
||||||
↻ Обновить
|
↻ Обновить
|
||||||
|
|
|
||||||
|
|
@ -1,348 +0,0 @@
|
||||||
/**
|
|
||||||
* @file SmsCityProfilesPanel.jsx
|
|
||||||
* @description Профили SMS по городам.
|
|
||||||
*/
|
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
|
||||||
import { Panel } from "../UI/Panel";
|
|
||||||
import { supabase } from "../../supabaseClient";
|
|
||||||
import { CRIMEAN_CITIES } from "../../constants/cities.js";
|
|
||||||
|
|
||||||
const THEME_STYLES = `
|
|
||||||
.sms-card {
|
|
||||||
background: #e8f0ec;
|
|
||||||
border: 1px solid #c8d8d2;
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
[data-theme="dark"] .sms-card {
|
|
||||||
background: #0d1a16;
|
|
||||||
border: 1px solid rgba(236,245,242,0.12);
|
|
||||||
}
|
|
||||||
.sms-inner {
|
|
||||||
background: #ffffff;
|
|
||||||
border: 1px solid #d0dad6;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
[data-theme="dark"] .sms-inner {
|
|
||||||
background: #142822;
|
|
||||||
border: 1px solid rgba(236,245,242,0.08);
|
|
||||||
}
|
|
||||||
.sms-input {
|
|
||||||
background: #f4f7f5;
|
|
||||||
border: 1px solid #c8d8d2;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
color: #10211b;
|
|
||||||
width: 100%;
|
|
||||||
font-size: 14px;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
[data-theme="dark"] .sms-input {
|
|
||||||
background: #09110f;
|
|
||||||
border: 1px solid rgba(236,245,242,0.12);
|
|
||||||
color: #ecf5f2;
|
|
||||||
}
|
|
||||||
.sms-input::placeholder { color: #5d6d66; }
|
|
||||||
[data-theme="dark"] .sms-input::placeholder { color: #9eb0aa; }
|
|
||||||
.sms-label { font-size: 12px; font-weight: 500; color: #5d6d66; }
|
|
||||||
[data-theme="dark"] .sms-label { color: #9eb0aa; }
|
|
||||||
.sms-city-name { font-size: 16px; font-weight: 700; color: #10211b; }
|
|
||||||
[data-theme="dark"] .sms-city-name { color: #ecf5f2; }
|
|
||||||
.sms-section-title { font-size: 14px; font-weight: 600; color: #10211b; }
|
|
||||||
[data-theme="dark"] .sms-section-title { color: #ecf5f2; }
|
|
||||||
.sms-muted { font-size: 12px; color: #5d6d66; }
|
|
||||||
[data-theme="dark"] .sms-muted { color: #9eb0aa; }
|
|
||||||
.sms-text { font-size: 13px; color: #10211b; }
|
|
||||||
[data-theme="dark"] .sms-text { color: #ecf5f2; }
|
|
||||||
.sms-italic { font-size: 12px; color: #5d6d66; font-style: italic; line-height: 1.4; margin-top: 6px; }
|
|
||||||
[data-theme="dark"] .sms-italic { color: #9eb0aa; }
|
|
||||||
.sms-toggle-text { font-size: 13px; color: #10211b; }
|
|
||||||
[data-theme="dark"] .sms-toggle-text { color: #ecf5f2; }
|
|
||||||
.sms-btn {
|
|
||||||
border-radius: 12px; padding: 8px 16px; font-size: 12px; font-weight: 600;
|
|
||||||
color: #fff; border: none; cursor: pointer; background: #12805c;
|
|
||||||
}
|
|
||||||
[data-theme="dark"] .sms-btn { background: #57d8a9; color: #06120d; }
|
|
||||||
.sms-btn-sm {
|
|
||||||
border-radius: 8px; border: 1px solid #c8d8d2; padding: 4px 10px;
|
|
||||||
font-size: 12px; font-weight: 500; color: #10211b; cursor: pointer; background: transparent;
|
|
||||||
}
|
|
||||||
[data-theme="dark"] .sms-btn-sm { border: 1px solid rgba(236,245,242,0.12); color: #ecf5f2; }
|
|
||||||
.sms-btn-del {
|
|
||||||
border-radius: 8px; padding: 4px 8px; font-size: 14px;
|
|
||||||
color: #5d6d66; cursor: pointer; background: transparent; border: none;
|
|
||||||
}
|
|
||||||
[data-theme="dark"] .sms-btn-del { color: #9eb0aa; }
|
|
||||||
.sms-mode-btn-active-g {
|
|
||||||
flex: 1; border-radius: 10px; border: 1px solid #22c55e;
|
|
||||||
background: rgba(34,197,94,0.1); color: #22c55e;
|
|
||||||
padding: 8px 12px; font-size: 14px; font-weight: 500; cursor: pointer;
|
|
||||||
}
|
|
||||||
.sms-mode-btn-inactive {
|
|
||||||
flex: 1; border-radius: 10px; border: 1px solid #c8d8d2;
|
|
||||||
background: transparent; color: #5d6d66;
|
|
||||||
padding: 8px 12px; font-size: 14px; font-weight: 500; cursor: pointer;
|
|
||||||
}
|
|
||||||
[data-theme="dark"] .sms-mode-btn-inactive {
|
|
||||||
border: 1px solid rgba(236,245,242,0.12); color: #9eb0aa;
|
|
||||||
}
|
|
||||||
.sms-mode-btn-active-b {
|
|
||||||
flex: 1; border-radius: 10px; border: 1px solid #3b82f6;
|
|
||||||
background: rgba(59,130,246,0.1); color: #3b82f6;
|
|
||||||
padding: 8px 12px; font-size: 14px; font-weight: 500; cursor: pointer;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const SmsCityProfilesPanel = () => {
|
|
||||||
const [profiles, setProfiles] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [showForm, setShowForm] = useState(false);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [message, setMessage] = useState("");
|
|
||||||
|
|
||||||
const [formCity, setFormCity] = useState("Ялта");
|
|
||||||
const [formDeliveryMode, setFormDeliveryMode] = useState("notification");
|
|
||||||
const [formDeliveryText, setFormDeliveryText] = useState("");
|
|
||||||
const [formPickupMode, setFormPickupMode] = useState("notification");
|
|
||||||
const [formPickupText, setFormPickupText] = useState("");
|
|
||||||
const [formSecondSms, setFormSecondSms] = useState(true);
|
|
||||||
const [formPaidStorage, setFormPaidStorage] = useState(true);
|
|
||||||
const [formSmsEnabled, setFormSmsEnabled] = useState(true);
|
|
||||||
const [formNote, setFormNote] = useState("");
|
|
||||||
const [editingId, setEditingId] = useState(null);
|
|
||||||
|
|
||||||
const fetchProfiles = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from("sms_city_profiles")
|
|
||||||
.select("*")
|
|
||||||
.order("city", { ascending: true });
|
|
||||||
if (error) throw error;
|
|
||||||
setProfiles(data || []);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("fetch sms_city_profiles error", e);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => { fetchProfiles(); }, [fetchProfiles]);
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
setFormCity("Ялта"); setFormDeliveryMode("notification"); setFormDeliveryText("");
|
|
||||||
setFormPickupMode("notification"); setFormPickupText("");
|
|
||||||
setFormSecondSms(true); setFormPaidStorage(true); setFormSmsEnabled(true);
|
|
||||||
setFormNote(""); setEditingId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
city: formCity, delivery_mode: formDeliveryMode, delivery_text: formDeliveryText || null,
|
|
||||||
pickup_mode: formPickupMode, pickup_text: formPickupText || null,
|
|
||||||
second_sms_enabled: formSecondSms, paid_storage_enabled: formPaidStorage,
|
|
||||||
sms_enabled: formSmsEnabled, note: formNote || "",
|
|
||||||
};
|
|
||||||
if (editingId) {
|
|
||||||
const { error } = await supabase.from("sms_city_profiles").update(payload).eq("id", editingId);
|
|
||||||
if (error) throw error;
|
|
||||||
setMessage("✅ Профиль обновлён");
|
|
||||||
} else {
|
|
||||||
const { error } = await supabase.from("sms_city_profiles").insert(payload);
|
|
||||||
if (error) throw error;
|
|
||||||
setMessage("✅ Профиль добавлен");
|
|
||||||
}
|
|
||||||
setShowForm(false); resetForm(); fetchProfiles();
|
|
||||||
setTimeout(() => setMessage(""), 3000);
|
|
||||||
} catch (e) {
|
|
||||||
setMessage("❌ Ошибка: " + e.message);
|
|
||||||
} finally { setSaving(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = (p) => {
|
|
||||||
setEditingId(p.id); setFormCity(p.city);
|
|
||||||
setFormDeliveryMode(p.delivery_mode || "link"); setFormDeliveryText(p.delivery_text || "");
|
|
||||||
setFormPickupMode(p.pickup_mode || "link"); setFormPickupText(p.pickup_text || "");
|
|
||||||
setFormSecondSms(p.second_sms_enabled ?? true); setFormPaidStorage(p.paid_storage_enabled ?? true);
|
|
||||||
setFormSmsEnabled(p.sms_enabled ?? true); setFormNote(p.note || "");
|
|
||||||
setShowForm(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id) => {
|
|
||||||
try { const { error } = await supabase.from("sms_city_profiles").delete().eq("id", id); if (error) throw error; fetchProfiles(); }
|
|
||||||
catch (e) { console.error("delete error", e); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const ModeBadge = ({ mode }) => mode === "notification"
|
|
||||||
? <span style={{ backgroundColor: "rgba(59,130,246,0.15)", color: "#3b82f6", borderRadius: "6px", padding: "2px 8px", fontSize: "10px", fontWeight: 600 }}>Уведомление</span>
|
|
||||||
: <span style={{ backgroundColor: "rgba(34,197,94,0.15)", color: "#22c55e", borderRadius: "6px", padding: "2px 8px", fontSize: "10px", fontWeight: 600 }}>Ссылка</span>;
|
|
||||||
|
|
||||||
const Toggle = ({ checked, onChange, label }) => (
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
|
||||||
<button type="button" onClick={onChange} style={{
|
|
||||||
position: "relative", height: "20px", width: "40px", borderRadius: "9999px",
|
|
||||||
transition: "all 0.2s", flexShrink: 0, border: "none", cursor: "pointer",
|
|
||||||
backgroundColor: checked ? "#22c55e" : "#c8d8d2",
|
|
||||||
}}>
|
|
||||||
<span style={{ position: "absolute", top: "2px", height: "16px", width: "16px", borderRadius: "9999px", backgroundColor: "#fff", transition: "all 0.2s", left: checked ? "20px" : "2px" }} />
|
|
||||||
</button>
|
|
||||||
{label && <span className="sms-toggle-text">{label}</span>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
|
|
||||||
<style>{THEME_STYLES}</style>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<Panel className="p-5">
|
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
|
||||||
<span style={{ fontSize: "20px" }}>🏙️</span>
|
|
||||||
<div>
|
|
||||||
<h2 className="sms-section-title" style={{ textTransform: "uppercase", letterSpacing: "0.16em" }}>
|
|
||||||
Профили SMS по городам
|
|
||||||
</h2>
|
|
||||||
<p className="sms-muted" style={{ marginTop: "4px" }}>
|
|
||||||
Настройка поведения SMS для каждого города.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" className="sms-btn" onClick={() => { setShowForm(!showForm); if (!showForm) resetForm(); }}>
|
|
||||||
{showForm ? "Отмена" : "+ Профиль"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{message && <span className="sms-text" style={{ display: "block", marginTop: "8px" }}>{message}</span>}
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
{/* Form */}
|
|
||||||
{showForm && (
|
|
||||||
<Panel className="p-5" style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
|
|
||||||
<h3 className="sms-section-title">{editingId ? "Редактировать профиль" : "Новый профиль города"}</h3>
|
|
||||||
|
|
||||||
<div style={{ display: "grid", gap: "16px", gridTemplateColumns: "1fr 1fr" }}>
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
|
||||||
<label className="sms-label">Город</label>
|
|
||||||
<select value={formCity} onChange={(e) => setFormCity(e.target.value)} disabled={!!editingId}
|
|
||||||
className="sms-input" style={{ opacity: editingId ? 0.5 : 1 }}>
|
|
||||||
{CRIMEAN_CITIES.map((c) => <option key={c} value={c}>{c}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="sms-inner">
|
|
||||||
<Toggle checked={formSmsEnabled} onChange={() => setFormSmsEnabled(!formSmsEnabled)} label="SMS включены для города" />
|
|
||||||
{!formSmsEnabled && <p style={{ fontSize: "12px", color: "#c93d3d", marginTop: "8px" }}>⚠ Все SMS в этот город отключены</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Delivery */}
|
|
||||||
<div className="sms-inner">
|
|
||||||
<div className="sms-muted" style={{ fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: "12px" }}>📦 Доставка</div>
|
|
||||||
<div style={{ display: "flex", gap: "8px" }}>
|
|
||||||
<button type="button" className={formDeliveryMode === "link" ? "sms-mode-btn-active-g" : "sms-mode-btn-inactive"} onClick={() => setFormDeliveryMode("link")}>🔗 Ссылка</button>
|
|
||||||
<button type="button" className={formDeliveryMode === "notification" ? "sms-mode-btn-active-b" : "sms-mode-btn-inactive"} onClick={() => setFormDeliveryMode("notification")}>📢 Уведомление</button>
|
|
||||||
</div>
|
|
||||||
{formDeliveryMode === "notification" && (
|
|
||||||
<textarea value={formDeliveryText} onChange={(e) => setFormDeliveryText(e.target.value)} placeholder="Ваш заказ готов. Доставим по графику." rows={2} className="sms-input" style={{ resize: "none", marginTop: "8px" }} />
|
|
||||||
)}
|
|
||||||
<p className="sms-muted" style={{ marginTop: "8px" }}>
|
|
||||||
{formDeliveryMode === "link" ? "Клиент получит ссылку для выбора даты" : "Уведомление без ссылки, без 2-й SMS"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pickup */}
|
|
||||||
<div className="sms-inner">
|
|
||||||
<div className="sms-muted" style={{ fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: "12px" }}>🏪 Самовывоз</div>
|
|
||||||
<div style={{ display: "flex", gap: "8px" }}>
|
|
||||||
<button type="button" className={formPickupMode === "link" ? "sms-mode-btn-active-g" : "sms-mode-btn-inactive"} onClick={() => setFormPickupMode("link")}>🔗 Ссылка</button>
|
|
||||||
<button type="button" className={formPickupMode === "notification" ? "sms-mode-btn-active-b" : "sms-mode-btn-inactive"} onClick={() => setFormPickupMode("notification")}>📢 Уведомление</button>
|
|
||||||
</div>
|
|
||||||
{formPickupMode === "notification" && (
|
|
||||||
<textarea value={formPickupText} onChange={(e) => setFormPickupText(e.target.value)} placeholder="Ваш заказ готов. Заберите со склада." rows={2} className="sms-input" style={{ resize: "none", marginTop: "8px" }} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: "grid", gap: "12px", gridTemplateColumns: "1fr 1fr" }}>
|
|
||||||
<div className="sms-inner"><Toggle checked={formSecondSms} onChange={() => setFormSecondSms(!formSecondSms)} label="2-я SMS (повтор)" /></div>
|
|
||||||
<div className="sms-inner"><Toggle checked={formPaidStorage} onChange={() => setFormPaidStorage(!formPaidStorage)} label="SMS: платное хранение" /></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
|
||||||
<label className="sms-label">Комментарий</label>
|
|
||||||
<input type="text" value={formNote} onChange={(e) => setFormNote(e.target.value)} placeholder="Например: Ялта — доставка без согласования" className="sms-input" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="button" disabled={saving} onClick={handleSave} className="sms-btn" style={{ padding: "10px 20px", fontSize: "14px", opacity: saving ? 0.4 : 1 }}>
|
|
||||||
{saving ? "Сохранение..." : editingId ? "Обновить профиль" : "Сохранить профиль"}
|
|
||||||
</button>
|
|
||||||
</Panel>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Profiles list */}
|
|
||||||
<Panel className="p-5" style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
|
||||||
<h2 className="sms-section-title" style={{ textTransform: "uppercase", letterSpacing: "0.16em" }}>
|
|
||||||
Профили ({profiles.length})
|
|
||||||
</h2>
|
|
||||||
{loading ? (
|
|
||||||
<p className="sms-muted">Загрузка...</p>
|
|
||||||
) : profiles.length === 0 ? (
|
|
||||||
<div style={{ border: "1px dashed #c8d8d2", borderRadius: "12px", padding: "24px", textAlign: "center" }}>
|
|
||||||
<p className="sms-muted">Профилей нет. Города без профиля используют стандартный флоу (ссылка на согласование).</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
|
||||||
{profiles.map((p) => (
|
|
||||||
<div key={p.id} className="sms-card">
|
|
||||||
{/* Header */}
|
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "12px" }}>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
|
||||||
<span className="sms-city-name">{p.city}</span>
|
|
||||||
{!p.sms_enabled
|
|
||||||
? <span style={{ backgroundColor: "rgba(239,68,68,0.15)", color: "#ef4444", borderRadius: "6px", padding: "2px 8px", fontSize: "10px", fontWeight: 600 }}>SMS OFF</span>
|
|
||||||
: <span style={{ backgroundColor: "rgba(34,197,94,0.15)", color: "#22c55e", borderRadius: "6px", padding: "2px 8px", fontSize: "10px", fontWeight: 600 }}>SMS ON</span>}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
|
||||||
<button type="button" className="sms-btn-sm" onClick={() => handleEdit(p)}>✏️ Изменить</button>
|
|
||||||
<button type="button" className="sms-btn-del" onClick={() => handleDelete(p.id)} title="Удалить">✕</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Config */}
|
|
||||||
<div style={{ display: "grid", gap: "8px", gridTemplateColumns: "1fr 1fr", marginBottom: "12px" }}>
|
|
||||||
<div className="sms-inner">
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}>
|
|
||||||
<span className="sms-text" style={{ fontSize: "12px", fontWeight: 500 }}>📦 Доставка</span>
|
|
||||||
<ModeBadge mode={p.delivery_mode} />
|
|
||||||
</div>
|
|
||||||
{p.delivery_text && <p className="sms-italic">"{p.delivery_text}"</p>}
|
|
||||||
</div>
|
|
||||||
<div className="sms-inner">
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}>
|
|
||||||
<span className="sms-text" style={{ fontSize: "12px", fontWeight: 500 }}>🏪 Самовывоз</span>
|
|
||||||
<ModeBadge mode={p.pickup_mode} />
|
|
||||||
</div>
|
|
||||||
{p.pickup_text && <p className="sms-italic">"{p.pickup_text}"</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Toggles */}
|
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "16px", paddingTop: "8px", borderTop: "1px solid #c8d8d2" }}>
|
|
||||||
<span className="sms-text" style={{ fontSize: "12px", fontWeight: 500, opacity: p.second_sms_enabled ? 1 : 0.5 }}>
|
|
||||||
2-я SMS: {p.second_sms_enabled ? "✅" : "❌"}
|
|
||||||
</span>
|
|
||||||
<span className="sms-text" style={{ fontSize: "12px", fontWeight: 500, opacity: p.paid_storage_enabled ? 1 : 0.5 }}>
|
|
||||||
Платное хранение: {p.paid_storage_enabled ? "✅" : "❌"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{p.note && <p className="sms-muted" style={{ paddingTop: "8px" }}>💬 {p.note}</p>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Panel>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,335 +0,0 @@
|
||||||
/**
|
|
||||||
* @file SmsCityRulesPanel.jsx
|
|
||||||
* @description Управление SMS-отправкой по городам.
|
|
||||||
* Админ может:
|
|
||||||
* - Включить/выключить SMS для города × кампания (постоянно)
|
|
||||||
* - Указать период (с/по дату) — временно
|
|
||||||
* - Добавить комментарий
|
|
||||||
* - «Все города» (*) или «Все кампании» (*) — массовое правило
|
|
||||||
*/
|
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
|
||||||
import { Panel } from "../UI/Panel";
|
|
||||||
import { supabase } from "../../supabaseClient";
|
|
||||||
import { CRIMEAN_CITIES } from "../../constants/cities.js";
|
|
||||||
|
|
||||||
const CAMPAIGN_LABELS = {
|
|
||||||
first_sms: "1-е SMS",
|
|
||||||
second_sms: "2-е SMS",
|
|
||||||
"*": "Все кампании",
|
|
||||||
};
|
|
||||||
|
|
||||||
const CITY_ALL = "*";
|
|
||||||
|
|
||||||
export const SmsCityRulesPanel = () => {
|
|
||||||
const [rules, setRules] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [showForm, setShowForm] = useState(false);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [message, setMessage] = useState("");
|
|
||||||
|
|
||||||
// Form state
|
|
||||||
const [formCity, setFormCity] = useState("Ялта");
|
|
||||||
const [formCampaign, setFormCampaign] = useState("*");
|
|
||||||
const [formEnabled, setFormEnabled] = useState(false);
|
|
||||||
const [formFrom, setFormFrom] = useState("");
|
|
||||||
const [formUntil, setFormUntil] = useState("");
|
|
||||||
const [formNote, setFormNote] = useState("");
|
|
||||||
|
|
||||||
const fetchRules = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from("sms_city_rules")
|
|
||||||
.select("*")
|
|
||||||
.order("city", { ascending: true })
|
|
||||||
.order("campaign_type", { ascending: true });
|
|
||||||
if (error) throw error;
|
|
||||||
setRules(data || []);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("fetch sms_city_rules error", e);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => { fetchRules(); }, [fetchRules]);
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const { error } = await supabase.from("sms_city_rules").insert({
|
|
||||||
city: formCity,
|
|
||||||
campaign_type: formCampaign,
|
|
||||||
enabled: formEnabled,
|
|
||||||
valid_from: formFrom || null,
|
|
||||||
valid_until: formUntil || null,
|
|
||||||
note: formNote || "",
|
|
||||||
});
|
|
||||||
if (error) throw error;
|
|
||||||
setShowForm(false);
|
|
||||||
setFormNote("");
|
|
||||||
setFormFrom("");
|
|
||||||
setFormUntil("");
|
|
||||||
setMessage("✅ Правило добавлено");
|
|
||||||
fetchRules();
|
|
||||||
setTimeout(() => setMessage(""), 3000);
|
|
||||||
} catch (e) {
|
|
||||||
setMessage("❌ Ошибка: " + e.message);
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggle = async (rule) => {
|
|
||||||
try {
|
|
||||||
const { error } = await supabase
|
|
||||||
.from("sms_city_rules")
|
|
||||||
.update({ enabled: !rule.enabled })
|
|
||||||
.eq("id", rule.id);
|
|
||||||
if (error) throw error;
|
|
||||||
fetchRules();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("toggle error", e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id) => {
|
|
||||||
try {
|
|
||||||
const { error } = await supabase
|
|
||||||
.from("sms_city_rules")
|
|
||||||
.delete()
|
|
||||||
.eq("id", id);
|
|
||||||
if (error) throw error;
|
|
||||||
fetchRules();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("delete error", e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fmtDate = (d) => {
|
|
||||||
if (!d) return "—";
|
|
||||||
try {
|
|
||||||
return new Date(d).toLocaleDateString("ru-RU", { day: "2-digit", month: "short", year: "numeric" });
|
|
||||||
} catch { return d; }
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Header */}
|
|
||||||
<Panel className="p-5 space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-xl">🏙️</span>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-[0.16em] text-[var(--color-text)]">
|
|
||||||
SMS по городам
|
|
||||||
</h2>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)] mt-1">
|
|
||||||
Управление отправкой SMS по городам. Выключенное правило = SMS не отправляется. Только для 1-го и 2-го SMS (согласование доставки). Платное хранение не регулируется.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowForm(!showForm)}
|
|
||||||
className="rounded-xl bg-[var(--color-accent)] px-4 py-2 text-xs font-semibold text-white transition hover:opacity-90"
|
|
||||||
>
|
|
||||||
{showForm ? "Отмена" : "+ Правило"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{message && <span className="text-sm text-[var(--color-text-muted)]">{message}</span>}
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
{/* Add rule form */}
|
|
||||||
{showForm && (
|
|
||||||
<Panel className="p-5 space-y-4">
|
|
||||||
<h3 className="text-sm font-semibold text-[var(--color-text)]">Новое правило</h3>
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
{/* City */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-xs font-medium text-[var(--color-text-muted)]">Город</label>
|
|
||||||
<select
|
|
||||||
value={formCity}
|
|
||||||
onChange={(e) => setFormCity(e.target.value)}
|
|
||||||
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm text-[var(--color-text)]"
|
|
||||||
>
|
|
||||||
<option value={CITY_ALL}>⭐ Все города</option>
|
|
||||||
{CRIMEAN_CITIES.map((c) => (
|
|
||||||
<option key={c} value={c}>{c}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{/* Campaign */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-xs font-medium text-[var(--color-text-muted)]">Кампания</label>
|
|
||||||
<select
|
|
||||||
value={formCampaign}
|
|
||||||
onChange={(e) => setFormCampaign(e.target.value)}
|
|
||||||
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm text-[var(--color-text)]"
|
|
||||||
>
|
|
||||||
<option value="*">⭐ Все кампании</option>
|
|
||||||
<option value="first_sms">1-е SMS</option>
|
|
||||||
<option value="second_sms">2-е SMS</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{/* Enabled toggle */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-xs font-medium text-[var(--color-text-muted)]">Статус</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFormEnabled(true)}
|
|
||||||
className={["rounded-xl border px-4 py-2 text-sm font-medium transition",
|
|
||||||
formEnabled ? "border-[#22c55e] bg-[rgba(34,197,94,0.12)] text-[#22c55e]" : "border-[var(--color-border)] text-[var(--color-text-muted)]"].join(" ")}
|
|
||||||
>
|
|
||||||
✅ Включить
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFormEnabled(false)}
|
|
||||||
className={["rounded-xl border px-4 py-2 text-sm font-medium transition",
|
|
||||||
!formEnabled ? "border-[#ef4444] bg-[rgba(239,68,68,0.12)] text-[#ef4444]" : "border-[var(--color-border)] text-[var(--color-text-muted)]"].join(" ")}
|
|
||||||
>
|
|
||||||
❌ Выключить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Period */}
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-xs font-medium text-[var(--color-text-muted)]">С даты</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={formFrom}
|
|
||||||
onChange={(e) => setFormFrom(e.target.value)}
|
|
||||||
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm text-[var(--color-text)]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-xs font-medium text-[var(--color-text-muted)]">По дату</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={formUntil}
|
|
||||||
onChange={(e) => setFormUntil(e.target.value)}
|
|
||||||
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm text-[var(--color-text)]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Note */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-xs font-medium text-[var(--color-text-muted)]">Комментарий</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formNote}
|
|
||||||
onChange={(e) => setFormNote(e.target.value)}
|
|
||||||
placeholder="Например: Ялта временно отключена"
|
|
||||||
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm text-[var(--color-text)] placeholder:text-[var(--color-text-muted)]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">
|
|
||||||
Пустые даты = правило действует постоянно. Указав даты, можно временно отключить SMS на период.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={saving}
|
|
||||||
onClick={handleSave}
|
|
||||||
className="rounded-xl bg-[var(--color-accent)] px-5 py-2.5 text-sm font-semibold text-white transition hover:opacity-90 disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{saving ? "Сохранение..." : "Сохранить правило"}
|
|
||||||
</button>
|
|
||||||
</Panel>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Rules table */}
|
|
||||||
<Panel className="p-5 space-y-3">
|
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-[0.16em] text-[var(--color-text)]">
|
|
||||||
Правила ({rules.length})
|
|
||||||
</h2>
|
|
||||||
{loading ? (
|
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">Загрузка...</p>
|
|
||||||
) : rules.length === 0 ? (
|
|
||||||
<div className="rounded-xl border border-dashed border-[var(--color-border)] p-6 text-center">
|
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">
|
|
||||||
Правил пока нет. SMS отправляются во все города.
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)] mt-1">
|
|
||||||
Нажмите «+ Правило» чтобы отключить SMS для конкретного города.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{rules.map((rule) => {
|
|
||||||
const isActive = rule.enabled &&
|
|
||||||
(!rule.valid_from || new Date(rule.valid_from) <= new Date()) &&
|
|
||||||
(!rule.valid_until || new Date(rule.valid_until) >= new Date());
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={rule.id}
|
|
||||||
className="flex items-center justify-between gap-3 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-4"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3 flex-1">
|
|
||||||
{/* Toggle */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleToggle(rule)}
|
|
||||||
className={[
|
|
||||||
"relative h-6 w-11 rounded-full transition shrink-0",
|
|
||||||
rule.enabled ? "bg-[#22c55e]" : "bg-[var(--color-border)]"
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
<span className={[
|
|
||||||
"absolute top-0.5 h-5 w-5 rounded-full bg-white transition-all",
|
|
||||||
rule.enabled ? "left-[22px]" : "left-0.5"
|
|
||||||
].join(" ")} />
|
|
||||||
</button>
|
|
||||||
{/* Info */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<span className="text-sm font-medium text-[var(--color-text)]">
|
|
||||||
{rule.city === CITY_ALL ? "⭐ Все города" : rule.city}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-[var(--color-text-muted)]">·</span>
|
|
||||||
<span className="text-xs font-medium text-[var(--color-text-muted)]">
|
|
||||||
{CAMPAIGN_LABELS[rule.campaign_type] || rule.campaign_type}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="rounded-lg px-2 py-0.5 text-[10px] font-semibold uppercase"
|
|
||||||
style={{
|
|
||||||
backgroundColor: isActive ? "rgba(34,197,94,0.12)" : "rgba(239,68,68,0.12)",
|
|
||||||
color: isActive ? "#22c55e" : "#ef4444",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isActive ? "Активно" : rule.enabled ? "Запланировано" : "Выключено"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{(rule.valid_from || rule.valid_until) && (
|
|
||||||
<div className="text-xs text-[var(--color-text-muted)] mt-1">
|
|
||||||
📅 {fmtDate(rule.valid_from)} — {fmtDate(rule.valid_until)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{rule.note && (
|
|
||||||
<div className="text-xs text-[var(--color-text-muted)] mt-0.5">
|
|
||||||
💬 {rule.note}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Delete */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleDelete(rule.id)}
|
|
||||||
className="rounded-lg p-1.5 text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-danger)] transition shrink-0"
|
|
||||||
title="Удалить правило"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Panel>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -27,7 +27,6 @@ export const SuggestionsPanel = () => {
|
||||||
const [newCategory, setNewCategory] = React.useState("feature");
|
const [newCategory, setNewCategory] = React.useState("feature");
|
||||||
const [submitting, setSubmitting] = React.useState(false);
|
const [submitting, setSubmitting] = React.useState(false);
|
||||||
const [message, setMessage] = React.useState("");
|
const [message, setMessage] = React.useState("");
|
||||||
const [myVotes, setMyVotes] = React.useState(new Set());
|
|
||||||
|
|
||||||
const getSupabase = React.useCallback(async () => {
|
const getSupabase = React.useCallback(async () => {
|
||||||
const { createClient } = await import("@supabase/supabase-js");
|
const { createClient } = await import("@supabase/supabase-js");
|
||||||
|
|
@ -43,23 +42,14 @@ export const SuggestionsPanel = () => {
|
||||||
const { data } = await supabase
|
const { data } = await supabase
|
||||||
.from("suggestions")
|
.from("suggestions")
|
||||||
.select("*")
|
.select("*")
|
||||||
.order("vote_count", { ascending: false })
|
|
||||||
.order("created_at", { ascending: false });
|
.order("created_at", { ascending: false });
|
||||||
|
|
||||||
// Fetch my votes
|
|
||||||
const { data: votesData } = await supabase
|
|
||||||
.from("suggestion_votes")
|
|
||||||
.select("suggestion_id")
|
|
||||||
.eq("user_id", user?.id);
|
|
||||||
setMyVotes(new Set((votesData || []).map((v) => v.suggestion_id)));
|
|
||||||
|
|
||||||
setSuggestions(data || []);
|
setSuggestions(data || []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("fetch suggestions error", e);
|
console.error("fetch suggestions error", e);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [getSupabase, user?.id]);
|
}, [getSupabase]);
|
||||||
|
|
||||||
React.useEffect(() => { fetchSuggestions(); }, [fetchSuggestions]);
|
React.useEffect(() => { fetchSuggestions(); }, [fetchSuggestions]);
|
||||||
|
|
||||||
|
|
@ -100,40 +90,6 @@ export const SuggestionsPanel = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVote = async (suggestionId) => {
|
|
||||||
const hasVoted = myVotes.has(suggestionId);
|
|
||||||
try {
|
|
||||||
const supabase = await getSupabase();
|
|
||||||
if (hasVoted) {
|
|
||||||
await supabase
|
|
||||||
.from("suggestion_votes")
|
|
||||||
.delete()
|
|
||||||
.eq("suggestion_id", suggestionId)
|
|
||||||
.eq("user_id", user?.id);
|
|
||||||
setMyVotes((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.delete(suggestionId);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await supabase
|
|
||||||
.from("suggestion_votes")
|
|
||||||
.insert({ suggestion_id: suggestionId, user_id: user?.id });
|
|
||||||
setMyVotes((prev) => new Set(prev).add(suggestionId));
|
|
||||||
}
|
|
||||||
fetchSuggestions();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("vote error", e);
|
|
||||||
// Revert optimistic update
|
|
||||||
setMyVotes((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (hasVoted) next.add(suggestionId);
|
|
||||||
else next.delete(suggestionId);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Panel className="space-y-4 p-5">
|
<Panel className="space-y-4 p-5">
|
||||||
|
|
@ -144,7 +100,7 @@ export const SuggestionsPanel = () => {
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
Есть идея? Опишите — другие смогут проголосовать, админы рассмотрят.
|
Есть идея? Опишите — админы рассмотрят.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{CATEGORY_OPTIONS.map((cat) => (
|
{CATEGORY_OPTIONS.map((cat) => (
|
||||||
|
|
@ -184,14 +140,9 @@ export const SuggestionsPanel = () => {
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<Panel className="space-y-3 p-5">
|
<Panel className="space-y-3 p-5">
|
||||||
<div className="flex items-center justify-between">
|
<h2 className="text-sm font-semibold uppercase tracking-[0.16em] text-[var(--color-text)]">
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-[0.16em] text-[var(--color-text)]">
|
Все предложения
|
||||||
Все предложения
|
</h2>
|
||||||
</h2>
|
|
||||||
<span className="text-xs text-[var(--color-text-muted)]">
|
|
||||||
↑ Сортировка: по голосам
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">Загрузка...</p>
|
<p className="text-sm text-[var(--color-text-muted)]">Загрузка...</p>
|
||||||
) : suggestions.length === 0 ? (
|
) : suggestions.length === 0 ? (
|
||||||
|
|
@ -201,7 +152,6 @@ export const SuggestionsPanel = () => {
|
||||||
{suggestions.map((s) => {
|
{suggestions.map((s) => {
|
||||||
const cat = CATEGORY_OPTIONS.find((c) => c.value === s.category) || CATEGORY_OPTIONS[3];
|
const cat = CATEGORY_OPTIONS.find((c) => c.value === s.category) || CATEGORY_OPTIONS[3];
|
||||||
const st = STATUS_MAP[s.status] || STATUS_MAP.new;
|
const st = STATUS_MAP[s.status] || STATUS_MAP.new;
|
||||||
const hasVoted = myVotes.has(s.id);
|
|
||||||
return (
|
return (
|
||||||
<SuggestionCard
|
<SuggestionCard
|
||||||
key={s.id}
|
key={s.id}
|
||||||
|
|
@ -210,8 +160,6 @@ export const SuggestionsPanel = () => {
|
||||||
status={st}
|
status={st}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
onStatusChange={handleStatusChange}
|
onStatusChange={handleStatusChange}
|
||||||
hasVoted={hasVoted}
|
|
||||||
onVote={handleVote}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -222,7 +170,7 @@ export const SuggestionsPanel = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const SuggestionCard = ({ suggestion: s, category, status, isAdmin, onStatusChange, hasVoted, onVote }) => {
|
const SuggestionCard = ({ suggestion: s, category, status, isAdmin, onStatusChange }) => {
|
||||||
const [editing, setEditing] = React.useState(false);
|
const [editing, setEditing] = React.useState(false);
|
||||||
const [comment, setComment] = React.useState(s.admin_comment || "");
|
const [comment, setComment] = React.useState(s.admin_comment || "");
|
||||||
|
|
||||||
|
|
@ -248,27 +196,10 @@ const SuggestionCard = ({ suggestion: s, category, status, isAdmin, onStatusChan
|
||||||
<span className="text-[10px] text-[var(--color-text-muted)] whitespace-nowrap">{formatDate(s.created_at)}</span>
|
<span className="text-[10px] text-[var(--color-text-muted)] whitespace-nowrap">{formatDate(s.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-[var(--color-text)] whitespace-pre-wrap">{s.content}</p>
|
<p className="text-sm text-[var(--color-text)] whitespace-pre-wrap">{s.content}</p>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
|
||||||
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
|
<span className="font-medium">{s.author_name}</span>
|
||||||
<span className="font-medium">{s.author_name}</span>
|
<span>·</span>
|
||||||
<span>·</span>
|
<span>{s.author_role}</span>
|
||||||
<span>{s.author_role}</span>
|
|
||||||
</div>
|
|
||||||
{/* Vote button */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onVote(s.id)}
|
|
||||||
className={[
|
|
||||||
"flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-semibold transition",
|
|
||||||
hasVoted
|
|
||||||
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-accent)]"
|
|
||||||
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-accent)]"
|
|
||||||
].join(" ")}
|
|
||||||
title={hasVoted ? "Нажмите, чтобы отменить голос" : "Голосовать за предложение"}
|
|
||||||
>
|
|
||||||
<span className="text-sm">{hasVoted ? "👍" : "⬆️"}</span>
|
|
||||||
<span>{s.vote_count || 0}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{s.admin_comment && (
|
{s.admin_comment && (
|
||||||
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] p-3 text-xs text-[var(--color-text-muted)]">
|
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] p-3 text-xs text-[var(--color-text-muted)]">
|
||||||
|
|
|
||||||
|
|
@ -92,13 +92,6 @@ export const OrderCompositionPanel = ({ invitation = {} }) => {
|
||||||
|
|
||||||
const filteredCount = allProducts.length - products.length;
|
const filteredCount = allProducts.length - products.length;
|
||||||
const reference = getInvitationReferenceLabel(invitation);
|
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);
|
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||||
|
|
||||||
|
|
@ -113,10 +106,11 @@ export const OrderCompositionPanel = ({ invitation = {} }) => {
|
||||||
className="flex w-full items-center justify-between text-left"
|
className="flex w-full items-center justify-between text-left"
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
>
|
>
|
||||||
<p className="text-sm font-semibold text-[var(--color-text)]">
|
<p className="text-sm uppercase tracking-[0.18em] text-[var(--color-text-muted)]">
|
||||||
Состав заказа · {invoiceCount} {invoiceCount === 1 ? "счёт" : invoiceCount < 5 ? "счёта" : "счетов"}
|
Состав заказа{reference && reference !== "Счет —" ? ` ${reference}` : ""}
|
||||||
</p>
|
</p>
|
||||||
<span className="flex items-center gap-2 text-sm text-[var(--color-text-muted)]">
|
<span className="flex items-center gap-2 text-sm text-[var(--color-text-muted)]">
|
||||||
|
{products.length} поз.
|
||||||
<svg
|
<svg
|
||||||
className="h-4 w-4 transition-transform"
|
className="h-4 w-4 transition-transform"
|
||||||
style={{ transform: isExpanded ? "rotate(180deg)" : "rotate(0deg)" }}
|
style={{ transform: isExpanded ? "rotate(180deg)" : "rotate(0deg)" }}
|
||||||
|
|
|
||||||
|
|
@ -36,27 +36,24 @@ const getCrimeaHour = (referenceDate = new Date()) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isAllowedPickupDay = (dateKey, allowedDays = [1,2,3,4,5]) => {
|
const isWeekend = (dateKey) => {
|
||||||
if (!dateKey) return false;
|
|
||||||
const d = new Date(`${dateKey}T12:00:00Z`);
|
const d = new Date(`${dateKey}T12:00:00Z`);
|
||||||
// getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
|
const day = d.getUTCDay();
|
||||||
// allowedDays uses 1=Mon ... 7=Sun
|
return day === 0 || day === 6;
|
||||||
const dayNum = d.getUTCDay() === 0 ? 7 : d.getUTCDay();
|
|
||||||
return allowedDays.includes(dayNum);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getNextPickupWorkday = (dateKey, allowedDays = [1,2,3,4,5]) => {
|
const getNextWorkday = (dateKey) => {
|
||||||
let next = addDaysKey(dateKey, 1);
|
let next = addDaysKey(dateKey, 1);
|
||||||
while (!isAllowedPickupDay(next, allowedDays)) {
|
while (isWeekend(next)) {
|
||||||
next = addDaysKey(next, 1);
|
next = addDaysKey(next, 1);
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPickupSlots = (referenceDate = new Date(), pickupDays = [1,2,3,4,5]) => {
|
const getPickupSlots = (referenceDate = new Date()) => {
|
||||||
const todayKey = getCrimeaTodayKey(referenceDate);
|
const todayKey = getCrimeaTodayKey(referenceDate);
|
||||||
const hour = getCrimeaHour(referenceDate);
|
const hour = getCrimeaHour(referenceDate);
|
||||||
const isTodayWorkday = isAllowedPickupDay(todayKey, pickupDays);
|
const isTodayWorkday = !isWeekend(todayKey);
|
||||||
|
|
||||||
const slots = [];
|
const slots = [];
|
||||||
|
|
||||||
|
|
@ -80,7 +77,7 @@ const getPickupSlots = (referenceDate = new Date(), pickupDays = [1,2,3,4,5]) =>
|
||||||
}
|
}
|
||||||
|
|
||||||
const tomorrow = addDaysKey(todayKey, 1);
|
const tomorrow = addDaysKey(todayKey, 1);
|
||||||
const tomorrowWorkday = isAllowedPickupDay(tomorrow, pickupDays) ? tomorrow : getNextPickupWorkday(todayKey, pickupDays);
|
const tomorrowWorkday = !isWeekend(tomorrow) ? tomorrow : getNextWorkday(todayKey);
|
||||||
slots.push({
|
slots.push({
|
||||||
id: `pickup-${tomorrowWorkday}-first`,
|
id: `pickup-${tomorrowWorkday}-first`,
|
||||||
date: tomorrowWorkday,
|
date: tomorrowWorkday,
|
||||||
|
|
@ -97,7 +94,7 @@ const getPickupSlots = (referenceDate = new Date(), pickupDays = [1,2,3,4,5]) =>
|
||||||
});
|
});
|
||||||
|
|
||||||
const dayAfter = addDaysKey(tomorrowWorkday, 1);
|
const dayAfter = addDaysKey(tomorrowWorkday, 1);
|
||||||
const dayAfterWorkday = isAllowedPickupDay(dayAfter, pickupDays) ? dayAfter : getNextPickupWorkday(dayAfter, pickupDays);
|
const dayAfterWorkday = !isWeekend(dayAfter) ? dayAfter : getNextWorkday(dayAfter);
|
||||||
slots.push({
|
slots.push({
|
||||||
id: `pickup-${dayAfterWorkday}-first`,
|
id: `pickup-${dayAfterWorkday}-first`,
|
||||||
date: dayAfterWorkday,
|
date: dayAfterWorkday,
|
||||||
|
|
@ -155,9 +152,8 @@ export const PickupSlotsPicker = ({
|
||||||
onSelectSlot,
|
onSelectSlot,
|
||||||
selectedSlotId,
|
selectedSlotId,
|
||||||
referenceDate = new Date(),
|
referenceDate = new Date(),
|
||||||
pickupDays = [1, 2, 3, 4, 5],
|
|
||||||
}) => {
|
}) => {
|
||||||
const slots = React.useMemo(() => getPickupSlots(referenceDate, pickupDays), [referenceDate, pickupDays]);
|
const slots = React.useMemo(() => getPickupSlots(referenceDate), [referenceDate]);
|
||||||
|
|
||||||
if (!slots.length) {
|
if (!slots.length) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,248 +0,0 @@
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
const VAPID_PUBLIC_KEY = "BDLNGyVp7hDzRujZBncNQ0qyz4zVFAhAWX1nsCMRC2tagmer46Sy6exZOymsGVk-TDb1bhkkXXth9Jkzdv4SdJg";
|
|
||||||
|
|
||||||
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || "";
|
|
||||||
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || "";
|
|
||||||
|
|
||||||
const getEndpoint = (action) => `${SUPABASE_URL}/functions/v1/${action}`;
|
|
||||||
const apiHeaders = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(SUPABASE_ANON_KEY ? { apikey: SUPABASE_ANON_KEY } : {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizePhone = (phone) => {
|
|
||||||
if (!phone) return "";
|
|
||||||
const clean = String(phone).replace(/\D/g, "");
|
|
||||||
if (clean.startsWith("8")) return "7" + clean.slice(1);
|
|
||||||
if (!clean.startsWith("7")) return "7" + clean;
|
|
||||||
return clean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const urlBase64ToUint8Array = (base64String) => {
|
|
||||||
const padding = "=".repeat((4 - base64String.length % 4) % 4);
|
|
||||||
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
|
||||||
const rawData = window.atob(base64);
|
|
||||||
const outputArray = new Uint8Array(rawData.length);
|
|
||||||
for (let i = 0; i < rawData.length; ++i) {
|
|
||||||
outputArray[i] = rawData.charCodeAt(i);
|
|
||||||
}
|
|
||||||
return outputArray;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PushSubscriptionBanner = ({ phone, orderGroupId }) => {
|
|
||||||
const [status, setStatus] = useState("idle"); // idle | prompting | subscribed | denied | unsupported
|
|
||||||
const [dismissed, setDismissed] = useState(false);
|
|
||||||
const phoneNorm = normalizePhone(phone);
|
|
||||||
|
|
||||||
// Detect iOS Safari (not in standalone mode = not added to Home Screen)
|
|
||||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
|
|
||||||
const isStandalone = window.matchMedia("(display-mode: standalone)").matches || navigator.standalone === true;
|
|
||||||
const isIOSNotInstalled = isIOS && !isStandalone;
|
|
||||||
|
|
||||||
// Check if push is supported
|
|
||||||
useEffect(() => {
|
|
||||||
// iOS Safari in browser mode (not standalone) doesn't support Push API
|
|
||||||
if (isIOSNotInstalled) {
|
|
||||||
setStatus("ios_not_installed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
|
|
||||||
setStatus("unsupported");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Check existing subscription
|
|
||||||
navigator.serviceWorker.ready.then(async (reg) => {
|
|
||||||
const existing = await reg.pushManager.getSubscription();
|
|
||||||
if (existing) {
|
|
||||||
setStatus("subscribed");
|
|
||||||
}
|
|
||||||
}).catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const subscribe = async () => {
|
|
||||||
try {
|
|
||||||
setStatus("prompting");
|
|
||||||
const reg = await navigator.serviceWorker.ready;
|
|
||||||
const existing = await reg.pushManager.getSubscription();
|
|
||||||
let subscription = existing;
|
|
||||||
if (!subscription) {
|
|
||||||
subscription = await reg.pushManager.subscribe({
|
|
||||||
userVisibleOnly: true,
|
|
||||||
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Send to backend
|
|
||||||
const sub = subscription.toJSON();
|
|
||||||
const resp = await fetch(getEndpoint("push-subscribe"), {
|
|
||||||
method: "POST",
|
|
||||||
headers: apiHeaders,
|
|
||||||
body: JSON.stringify({
|
|
||||||
phone_normalized: phoneNorm,
|
|
||||||
endpoint: sub.endpoint,
|
|
||||||
p256dh: sub.keys?.p256dh || "",
|
|
||||||
auth: sub.keys?.auth || "",
|
|
||||||
user_agent: navigator.userAgent || "",
|
|
||||||
order_group_id: orderGroupId || null,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (resp.ok) {
|
|
||||||
setStatus("subscribed");
|
|
||||||
} else {
|
|
||||||
console.warn("[Push] subscribe API error:", resp.status);
|
|
||||||
setStatus("idle");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (err?.name === "NotAllowedError" || err?.name === "denied") {
|
|
||||||
setStatus("denied");
|
|
||||||
} else {
|
|
||||||
console.warn("[Push] subscribe error:", err);
|
|
||||||
setStatus("idle");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const unsubscribe = async () => {
|
|
||||||
try {
|
|
||||||
const reg = await navigator.serviceWorker.ready;
|
|
||||||
const sub = await reg.pushManager.getSubscription();
|
|
||||||
if (sub) {
|
|
||||||
await sub.unsubscribe();
|
|
||||||
// Notify backend
|
|
||||||
await fetch(getEndpoint("push-unsubscribe"), {
|
|
||||||
method: "POST",
|
|
||||||
headers: apiHeaders,
|
|
||||||
body: JSON.stringify({
|
|
||||||
endpoint: sub.endpoint,
|
|
||||||
phone_normalized: phoneNorm,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setStatus("idle");
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("[Push] unsubscribe error:", err);
|
|
||||||
setStatus("idle");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Don't render if unsupported, dismissed, or no phone
|
|
||||||
if (status === "unsupported" || !phoneNorm) return null;
|
|
||||||
if (dismissed && status !== "subscribed" && status !== "denied") return null;
|
|
||||||
|
|
||||||
// iOS not installed — show instruction to add to Home Screen
|
|
||||||
if (status === "ios_not_installed") {
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-[rgba(18,128,92,0.2)] bg-[var(--color-accent-soft)] px-4 py-3">
|
|
||||||
<div className="flex items-start gap-2.5">
|
|
||||||
<span className="text-base flex-shrink-0 mt-0.5">🔔</span>
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-sm font-semibold text-[var(--color-text)]">
|
|
||||||
Включить уведомления о доставке
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)] mt-1 leading-5">
|
|
||||||
На iPhone уведомления работают только если приложение добавлено на главный экран.
|
|
||||||
</p>
|
|
||||||
<ol className="text-xs text-[var(--color-text-muted)] mt-2 space-y-1 leading-5">
|
|
||||||
<li>1. Нажмите <span className="font-medium">«Поделиться»</span> (квадрат со стрелкой ↑) внизу Safari</li>
|
|
||||||
<li>2. Выберите <span className="font-medium">«На экран Домой»</span> → «Добавить»</li>
|
|
||||||
<li>3. Откройте приложение с иконки <span className="font-medium">«СуперСам»</span> на главном экране</li>
|
|
||||||
<li>4. Нажмите <span className="font-medium">«Разрешить»</span> в этом баннере</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end mt-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setDismissed(true)}
|
|
||||||
className="text-xs text-[var(--color-text-muted)] transition hover:text-[var(--color-text)]"
|
|
||||||
>
|
|
||||||
Позже
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribed state — show small confirmation + unsubscribe option
|
|
||||||
if (status === "subscribed") {
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-[rgba(18,128,92,0.25)] bg-[var(--color-accent-soft)] px-4 py-3">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm">🔔</span>
|
|
||||||
<p className="text-sm font-medium text-[var(--color-accent)]">
|
|
||||||
Уведомления включены
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={unsubscribe}
|
|
||||||
className="text-xs text-[var(--color-text-muted)] transition hover:text-[var(--color-text)] underline"
|
|
||||||
>
|
|
||||||
Отключить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Denied — user blocked notifications
|
|
||||||
if (status === "denied") {
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm">🔕</span>
|
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">
|
|
||||||
Уведомления заблокированы. Разрешите их в настройках браузера.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prompting — show loading
|
|
||||||
if (status === "prompting") {
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3">
|
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">Ожидание разрешения…</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Idle — show subscription banner
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-[rgba(18,128,92,0.2)] bg-[var(--color-accent-soft)] px-4 py-3">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="flex items-start gap-2.5">
|
|
||||||
<span className="text-base flex-shrink-0 mt-0.5">🔔</span>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-semibold text-[var(--color-text)]">
|
|
||||||
Включить уведомления о доставке
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)] mt-0.5">
|
|
||||||
Получайте push-уведомления вместо SMS. Быстрее и удобнее.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={subscribe}
|
|
||||||
className="rounded-xl bg-[var(--color-accent)] px-4 py-2 text-sm font-semibold text-white transition hover:opacity-90"
|
|
||||||
>
|
|
||||||
Разрешить
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setDismissed(true)}
|
|
||||||
className="text-xs text-[var(--color-text-muted)] transition hover:text-[var(--color-text)]"
|
|
||||||
>
|
|
||||||
Позже
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PushSubscriptionBanner;
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -74,18 +74,21 @@ export const DriverDeliveryDetail = ({ order, onStatusChange }) => {
|
||||||
const orderItems = Array.isArray(order.items) ? order.items.map(splitItem) : [];
|
const orderItems = Array.isArray(order.items) ? order.items.map(splitItem) : [];
|
||||||
|
|
||||||
const currentStatus = order.status;
|
const currentStatus = order.status;
|
||||||
|
const IN_TRANSIT_STATUSES = ["Загружен", "В пути"];
|
||||||
|
const isOnRoute = IN_TRANSIT_STATUSES.includes(currentStatus);
|
||||||
|
|
||||||
let actionButtons = [];
|
let actionButtons = [];
|
||||||
if (currentStatus === "Назначен водитель") {
|
if (currentStatus === "Назначен водитель") {
|
||||||
|
actionButtons = [
|
||||||
|
{ value: "Загружен", label: "Загружено" },
|
||||||
|
{ value: "Проблема доставки", label: "Проблема" },
|
||||||
|
];
|
||||||
|
} else if (isOnRoute) {
|
||||||
actionButtons = [
|
actionButtons = [
|
||||||
{ value: "Доставлен", label: "Доставлено" },
|
{ value: "Доставлен", label: "Доставлено" },
|
||||||
{ value: "Проблема доставки", label: "Проблема" },
|
{ value: "Проблема доставки", label: "Проблема" },
|
||||||
];
|
];
|
||||||
} else if (currentStatus === "Доставлен") {
|
} else if (currentStatus === "Доставлен" || currentStatus === "Проблема доставки" || currentStatus === "Закрыт" || currentStatus === "Отменён") {
|
||||||
actionButtons = [
|
|
||||||
{ value: "Назначен водитель", label: "Вернуть в работу" },
|
|
||||||
];
|
|
||||||
} else if (currentStatus === "Проблема доставки" || currentStatus === "Закрыт" || currentStatus === "Отменён") {
|
|
||||||
actionButtons = [];
|
actionButtons = [];
|
||||||
} else {
|
} else {
|
||||||
actionButtons = availableTransitions.map((status) => ({
|
actionButtons = availableTransitions.map((status) => ({
|
||||||
|
|
@ -95,7 +98,7 @@ export const DriverDeliveryDetail = ({ order, onStatusChange }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 fs-zone-card">
|
<div className="space-y-4">
|
||||||
{showProblemModal && (
|
{showProblemModal && (
|
||||||
<ProblemReasonModal
|
<ProblemReasonModal
|
||||||
onSelect={(reasonValue, reasonLabel) => {
|
onSelect={(reasonValue, reasonLabel) => {
|
||||||
|
|
|
||||||
|
|
@ -261,7 +261,7 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 fs-zone-table">
|
<div className="space-y-4">
|
||||||
<Panel className="space-y-3 p-5">
|
<Panel className="space-y-3 p-5">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React from "react";
|
||||||
import { supabase } from "../../supabaseClient";
|
import { supabase } from "../../supabaseClient";
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
import { Button } from "../UI/Button";
|
import { Button } from "../UI/Button";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
import { matchesStopWord } from "../../hooks/useStopWords";
|
import { matchesStopWord } from "../../hooks/useStopWords";
|
||||||
|
|
||||||
const parseOrderItems = (order) => {
|
const parseOrderItems = (order) => {
|
||||||
|
|
@ -19,7 +20,6 @@ const parseOrderItems = (order) => {
|
||||||
const hasProducts = subItems.some(
|
const hasProducts = subItems.some(
|
||||||
(p) => typeof p === "object" && (p.product_name || p.name)
|
(p) => typeof p === "object" && (p.product_name || p.name)
|
||||||
);
|
);
|
||||||
const orderNom = String(sub.nom || sub.name || "").trim();
|
|
||||||
if (hasProducts) {
|
if (hasProducts) {
|
||||||
for (const p of subItems) {
|
for (const p of subItems) {
|
||||||
if (!p || typeof p !== "object") continue;
|
if (!p || typeof p !== "object") continue;
|
||||||
|
|
@ -30,7 +30,6 @@ const parseOrderItems = (order) => {
|
||||||
name,
|
name,
|
||||||
quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(),
|
quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(),
|
||||||
unit: String(p.product_ed || p.unit || "").trim(),
|
unit: String(p.product_ed || p.unit || "").trim(),
|
||||||
orderNom,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (sub.nom || sub.name) {
|
} else if (sub.nom || sub.name) {
|
||||||
|
|
@ -39,7 +38,6 @@ const parseOrderItems = (order) => {
|
||||||
name: String(sub.nom || sub.name || "").trim(),
|
name: String(sub.nom || sub.name || "").trim(),
|
||||||
quantity: "",
|
quantity: "",
|
||||||
unit: "",
|
unit: "",
|
||||||
orderNom,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -59,7 +57,6 @@ const parseOrderItems = (order) => {
|
||||||
name,
|
name,
|
||||||
quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(),
|
quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(),
|
||||||
unit: String(p.product_ed || p.unit || "").trim(),
|
unit: String(p.product_ed || p.unit || "").trim(),
|
||||||
orderNom: String(src.nom || "").trim(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -74,7 +71,6 @@ const parseOrderItems = (order) => {
|
||||||
for (const sub of orderList) {
|
for (const sub of orderList) {
|
||||||
if (!sub || typeof sub !== "object") continue;
|
if (!sub || typeof sub !== "object") continue;
|
||||||
const items = Array.isArray(sub.items) ? sub.items : [];
|
const items = Array.isArray(sub.items) ? sub.items : [];
|
||||||
const orderNom = String(sub.nom || sub.name || "").trim();
|
|
||||||
for (const p of items) {
|
for (const p of items) {
|
||||||
if (!p || typeof p !== "object") continue;
|
if (!p || typeof p !== "object") continue;
|
||||||
const name = String(p.product_name || p.name || "").trim();
|
const name = String(p.product_name || p.name || "").trim();
|
||||||
|
|
@ -84,7 +80,6 @@ const parseOrderItems = (order) => {
|
||||||
name,
|
name,
|
||||||
quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(),
|
quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(),
|
||||||
unit: String(p.product_ed || p.unit || "").trim(),
|
unit: String(p.product_ed || p.unit || "").trim(),
|
||||||
orderNom,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -94,15 +89,12 @@ const parseOrderItems = (order) => {
|
||||||
return [];
|
return [];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, isSavingShipment, onResetStatus, isSavingStatusChange, groupByOrder = false }) => {
|
export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, isSavingShipment, onResetStatus, isSavingStatusChange }) => {
|
||||||
const [stopWords, setStopWords] = React.useState([]);
|
const [stopWords, setStopWords] = React.useState([]);
|
||||||
const [scopeActive, setScopeActive] = React.useState(true);
|
const [scopeActive, setScopeActive] = React.useState(true);
|
||||||
const [savedShipment, setSavedShipment] = React.useState([]);
|
const [savedShipment, setSavedShipment] = React.useState([]);
|
||||||
const [justSaved, setJustSaved] = React.useState(false);
|
const [justSaved, setJustSaved] = React.useState(false);
|
||||||
const [showResetConfirm, setShowResetConfirm] = 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(() => {
|
React.useEffect(() => {
|
||||||
if (!supabase) return;
|
if (!supabase) return;
|
||||||
|
|
@ -116,56 +108,10 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const allItems = React.useMemo(() => parseOrderItems(order), [order]);
|
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(() => {
|
const items = React.useMemo(() => {
|
||||||
if (groupByOrder) return allItems;
|
|
||||||
if (!stopWords.length || !scopeActive) return allItems;
|
if (!stopWords.length || !scopeActive) return allItems;
|
||||||
return allItems.filter((item) => !matchesStopWord(item.name, stopWords));
|
return allItems.filter((item) => !matchesStopWord(item.name, stopWords));
|
||||||
}, [allItems, stopWords, scopeActive, groupByOrder]);
|
}, [allItems, stopWords, scopeActive]);
|
||||||
|
|
||||||
// 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
|
// Restore previously saved shipment data from order
|
||||||
const initialShippedIds = React.useMemo(() => {
|
const initialShippedIds = React.useMemo(() => {
|
||||||
|
|
@ -192,17 +138,11 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
|
||||||
const [comments, setComments] = React.useState(initialComments);
|
const [comments, setComments] = React.useState(initialComments);
|
||||||
const [commentInput, setCommentInput] = React.useState("");
|
const [commentInput, setCommentInput] = React.useState("");
|
||||||
|
|
||||||
// Sync state ONLY when order ID changes (navigating to different order), not on every order data update
|
// Sync state when order data changes (e.g. after save)
|
||||||
const orderId = order?.id;
|
|
||||||
const prevOrderId = React.useRef(orderId);
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (prevOrderId.current !== orderId) {
|
setShippedItems(initialShippedIds);
|
||||||
prevOrderId.current = orderId;
|
setComments(initialComments);
|
||||||
setShippedItems(initialShippedIds);
|
}, [initialShippedIds, initialComments]);
|
||||||
setComments(initialComments);
|
|
||||||
setJustSaved(false);
|
|
||||||
}
|
|
||||||
}, [orderId, initialShippedIds, initialComments]);
|
|
||||||
|
|
||||||
// Track last saved shipment data for visual display
|
// Track last saved shipment data for visual display
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
@ -225,13 +165,12 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
|
||||||
};
|
};
|
||||||
|
|
||||||
const shipAll = () => {
|
const shipAll = () => {
|
||||||
const targetItems = groupByOrder ? displayItems : items;
|
setShippedItems(new Set(items.map((i) => i.id)));
|
||||||
setShippedItems(new Set(targetItems.map((i) => i.id)));
|
|
||||||
setComments({});
|
setComments({});
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentDeliveryStatus = order?.deliveryStatus || order?.delivery_status;
|
const currentDeliveryStatus = order?.deliveryStatus || order?.delivery_status;
|
||||||
const isStatusFinal = ["delivered", "problem", "picked_up", "loaded", "on_route", "driver_assigned"].includes(currentDeliveryStatus);
|
const isStatusFinal = ["delivered", "problem", "picked_up"].includes(currentDeliveryStatus);
|
||||||
|
|
||||||
const unshipAll = () => {
|
const unshipAll = () => {
|
||||||
if (isStatusFinal && onResetStatus) {
|
if (isStatusFinal && onResetStatus) {
|
||||||
|
|
@ -251,15 +190,6 @@ 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 shippedCount = items.filter((i) => shippedItems.has(i.id)).length;
|
||||||
const unshippedCount = items.length - shippedCount;
|
const unshippedCount = items.length - shippedCount;
|
||||||
const allShipped = items.length > 0 && shippedCount === items.length;
|
const allShipped = items.length > 0 && shippedCount === items.length;
|
||||||
|
|
@ -290,65 +220,24 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
|
||||||
}
|
}
|
||||||
}, [items.length, shippedCount, unshippedCount, unshippedWithoutComment, allShipped, shippedItems, comments, onShipmentChange]);
|
}, [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) {
|
if (items.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<Panel className="space-y-3 p-5">
|
||||||
|
<strong>Состав заказа</strong>
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">Позиции не указаны</p>
|
<p className="text-sm text-[var(--color-text-muted)]">Позиции не указаны</p>
|
||||||
</div>
|
</Panel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<Panel className="space-y-4 p-5">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<strong>Отгрузка</strong>
|
||||||
|
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||||
|
Отметьте позиции, которые отгружены. Для смены статуса на «Доставлено» все позиции должны быть отгружены.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<Badge tone={allShipped ? "accent" : "neutral"}>
|
<Badge tone={allShipped ? "accent" : "neutral"}>
|
||||||
{shippedCount}/{items.length} отгружено
|
{shippedCount}/{items.length} отгружено
|
||||||
|
|
@ -356,48 +245,12 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex gap-2">
|
||||||
<Button variant="secondary" size="sm" onClick={shipAll} disabled={allShipped}>
|
<Button variant="secondary" size="sm" onClick={shipAll} disabled={allShipped}>
|
||||||
Отгрузить всё
|
Отгрузить всё
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={unshipAll} disabled={shippedCount === 0 && !isStatusFinal}>
|
<Button variant="ghost" size="sm" onClick={unshipAll} disabled={shippedCount === 0 && !isStatusFinal}>
|
||||||
Сбросить отгрузку
|
Сбросить
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -426,65 +279,55 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{groupByOrder ? (
|
<div className="space-y-2">
|
||||||
/* === Grouped by order (счёту) === */
|
{items.map((item) => {
|
||||||
<div className="space-y-3">
|
const isShipped = shippedItems.has(item.id);
|
||||||
{displayItems.length === 0 ? (
|
const hasComment = !isShipped && comments[item.id]?.trim();
|
||||||
<p className="text-sm text-[var(--color-text-muted)] italic">
|
return (
|
||||||
{filterMode === "stop_only" ? "Нет позиций со стоп-словами" : searchQuery.trim() ? "Ничего не найдено" : "Нет позиций для отображения"}
|
<div
|
||||||
</p>
|
key={item.id}
|
||||||
) : (
|
className={[
|
||||||
Object.entries(groupedItems).map(([orderNom, groupItems]) => {
|
"rounded-[18px] border px-4 py-3 text-sm transition",
|
||||||
const isCollapsed = collapsedGroups === null ? true : collapsedGroups.has(orderNom);
|
isShipped
|
||||||
const groupShipped = groupItems.filter((i) => shippedItems.has(i.id)).length;
|
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"
|
||||||
return (
|
: hasComment
|
||||||
<div
|
? "border-[var(--color-warning)] bg-[var(--color-warning-soft)]"
|
||||||
key={orderNom}
|
: "border-[var(--color-border)] bg-[var(--color-surface-strong)]",
|
||||||
className="rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] overflow-hidden"
|
].join(" ")}
|
||||||
>
|
>
|
||||||
<button
|
<label className="flex cursor-pointer items-start gap-3">
|
||||||
type="button"
|
<input
|
||||||
className="flex w-full items-center justify-between gap-2 px-4 py-3 text-left transition hover:bg-[var(--color-accent-soft)]"
|
type="checkbox"
|
||||||
onClick={() => toggleGroup(orderNom)}
|
checked={isShipped}
|
||||||
>
|
onChange={() => toggleItem(item.id)}
|
||||||
<div className="flex items-center gap-2">
|
className="mt-0.5 h-4 w-4 flex-shrink-0 accent-[var(--color-accent)]"
|
||||||
<span className="rounded-full bg-[var(--color-accent-soft)] px-3 py-1 text-sm font-semibold text-[var(--color-accent)]">
|
/>
|
||||||
Счёт {orderNom}
|
<div className="min-w-0 flex-1">
|
||||||
</span>
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className="text-sm text-[var(--color-text-muted)]">
|
<span className={isShipped ? "text-[var(--color-text-muted)]" : "text-[var(--color-text)]"}>
|
||||||
· {groupItems.length} поз.
|
{item.name}
|
||||||
</span>
|
</span>
|
||||||
{groupShipped === groupItems.length && groupItems.length > 0 && (
|
{(item.quantity || item.unit) ? (
|
||||||
<span className="text-xs text-[var(--color-accent)]">✓</span>
|
<Badge tone="neutral">{[item.quantity, item.unit].filter(Boolean).join(" ")}</Badge>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<svg
|
{!isShipped && (
|
||||||
className="h-4 w-4 flex-shrink-0 text-[var(--color-text-muted)] transition-transform"
|
<input
|
||||||
style={{ transform: isCollapsed ? "rotate(0deg)" : "rotate(180deg)" }}
|
type="text"
|
||||||
fill="none"
|
placeholder="Причина неотгрузки (дефект, нет в наличии...)"
|
||||||
viewBox="0 0 24 24"
|
value={comments[item.id] || ""}
|
||||||
stroke="currentColor"
|
onChange={(e) =>
|
||||||
strokeWidth={2}
|
setComments((prev) => ({ ...prev, [item.id]: e.target.value }))
|
||||||
>
|
}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
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"
|
||||||
</svg>
|
/>
|
||||||
</button>
|
|
||||||
{!isCollapsed && (
|
|
||||||
<div className="space-y-2 px-4 pb-4">
|
|
||||||
{groupItems.map((item) => renderItem(item))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</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 && (
|
{unshippedCount > 0 && (
|
||||||
<div className="rounded-xl border border-[var(--color-warning)] bg-[var(--color-warning-soft)] p-3 text-sm">
|
<div className="rounded-xl border border-[var(--color-warning)] bg-[var(--color-warning-soft)] p-3 text-sm">
|
||||||
|
|
@ -586,6 +429,6 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Panel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -18,13 +18,11 @@ import {
|
||||||
getOrderGroupDisplayStatusLabel,
|
getOrderGroupDisplayStatusLabel,
|
||||||
getOrderGroupDisplayStatusValue,
|
getOrderGroupDisplayStatusValue,
|
||||||
getOrderGroupStatusTone,
|
getOrderGroupStatusTone,
|
||||||
getSmsProblemTone,
|
|
||||||
ORDER_GROUP_DISPLAY_STATUS_OPTIONS,
|
ORDER_GROUP_DISPLAY_STATUS_OPTIONS,
|
||||||
} from "../../services/orderGroupViews";
|
} from "../../services/orderGroupViews";
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
import { SkeletonPage } from "../UI/Loading";
|
import { SkeletonPage } from "../UI/Loading";
|
||||||
import { Pagination } from "../UI/Pagination";
|
|
||||||
import { OrderFilters } from "../orders/OrderFilters";
|
import { OrderFilters } from "../orders/OrderFilters";
|
||||||
import { formatDate, formatDateTime } from "../../utils/formatters";
|
import { formatDate, formatDateTime } from "../../utils/formatters";
|
||||||
|
|
||||||
|
|
@ -55,9 +53,6 @@ const DEFAULT_FUNNEL_ORDER = [
|
||||||
|
|
||||||
const STORAGE_KEY = "logistics-section-order";
|
const STORAGE_KEY = "logistics-section-order";
|
||||||
const COLLAPSED_KEY = "logistics-section-collapsed";
|
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
|
// Load custom order from localStorage, merge with defaults
|
||||||
const loadCustomOrder = () => {
|
const loadCustomOrder = () => {
|
||||||
|
|
@ -99,39 +94,19 @@ const saveCollapsedSections = (collapsedSet) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 8 columns: Клиент | Город | Тип | Дата | Водитель | Статус | Обновлён | Добавлен в базу
|
// 7 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 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-[1060px]";
|
const MIN_W = "min-w-[1080px]";
|
||||||
|
|
||||||
const TABLE_COLUMNS = [
|
const TableHeader = () => (
|
||||||
{ key: "customer", label: "Клиент" },
|
<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)]`}>
|
||||||
{ key: "city", label: "Город" },
|
<div className="px-3 py-1.5 font-medium">Клиент</div>
|
||||||
{ key: "deliveryType", label: "Тип" },
|
<div className="px-3 py-1.5 font-medium">Город</div>
|
||||||
{ key: "deliveryDate", label: "Доставка" },
|
<div className="px-3 py-1.5 font-medium">Тип</div>
|
||||||
{ key: "driver", label: "Водитель" },
|
<div className="px-3 py-1.5 font-medium">Дата доставки</div>
|
||||||
{ key: "status", label: "Статус" },
|
<div className="px-3 py-1.5 font-medium">Водитель</div>
|
||||||
{ key: "updatedAt", label: "Обновлён" },
|
<div className="px-3 py-1.5 font-medium">Статус</div>
|
||||||
{ key: "createdAt", label: "Добавлен" },
|
<div className="px-3 py-1.5 font-medium">Обновлён</div>
|
||||||
];
|
|
||||||
|
|
||||||
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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -145,104 +120,72 @@ const isStale = (group) => {
|
||||||
return diff > 24 * 60 * 60 * 1000;
|
return diff > 24 * 60 * 60 * 1000;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDatabaseAge = (group) => {
|
const isLinkOpened = (group) => !!(group.invitationOpenedAt || (group.invitationAccessCount && group.invitationAccessCount > 0));
|
||||||
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)]" };
|
|
||||||
};
|
|
||||||
|
|
||||||
// Not agreed = still waiting for customer confirmation / no delivery settled.
|
const renderRow = (group, onSelectSet) => (
|
||||||
// These statuses mean the order has not been agreed yet.
|
<button
|
||||||
const NOT_AGREED_STATUSES = new Set([
|
key={group.id}
|
||||||
"delivery:pending_confirmation",
|
type="button"
|
||||||
"delivery:manual_confirmation_required",
|
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)]" : ""}`}
|
||||||
"status:manual_required",
|
onClick={() => { if (onSelectSet) onSelectSet(group.id); }}
|
||||||
"status:not_started",
|
>
|
||||||
"status:link_ready",
|
<div className="min-w-0 px-3 py-1.5">
|
||||||
"status:pending_confirmation",
|
<div className="text-xs font-medium leading-snug break-words" style={{ display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden" }}>
|
||||||
"status:unknown",
|
{group.displayTitle || group.customerName || group.groupKey}
|
||||||
]);
|
</div>
|
||||||
const isNotAgreed = (group) => NOT_AGREED_STATUSES.has(getOrderGroupDisplayStatusValue(group));
|
<div className="mt-0.5 text-[11px] text-[var(--color-text-muted)] flex items-center gap-1">
|
||||||
|
{group.customerPhone || ""}
|
||||||
// Red highlight: not agreed and 2+ days since the order was added to the system.
|
{isLinkOpened(group) && <span title="Клиент открывал ссылку" style={{ color: "#22c55e", fontSize: "11px" }}>👁</span>}
|
||||||
const getNotAgreedHighlight = (group) => {
|
</div>
|
||||||
if (!isNotAgreed(group)) return "";
|
</div>
|
||||||
const created = group.createdAt ? new Date(group.createdAt).getTime() : 0;
|
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
|
||||||
if (!Number.isFinite(created)) return "";
|
{group.city || group.customerAddress || "—"}
|
||||||
const days = Math.max(0, Math.floor((Date.now() - created) / 86400000));
|
</div>
|
||||||
return days >= 2 ? "bg-[rgba(239,68,68,0.12)]" : "";
|
<div className="px-3 py-1.5 text-xs">
|
||||||
};
|
<span className="inline-flex items-center gap-1 whitespace-nowrap">
|
||||||
|
{group.deliveryType === "pickup" ? "🏪" : "🚚"}
|
||||||
const SMS_TAG = {
|
<span className="text-[var(--color-text-muted)]">{group.deliveryType === "pickup" ? "Самовывоз" : "Доставка"}</span>
|
||||||
not_sent: { label: "SMS не отправлено", cls: "bg-[rgba(239,68,68,0.14)] text-[var(--color-danger)]" },
|
</span>
|
||||||
not_delivered: { label: "SMS не доставлено", cls: "bg-[rgba(245,158,11,0.14)] text-[var(--color-warning)]" },
|
</div>
|
||||||
};
|
<div className="px-3 py-1.5 text-xs">
|
||||||
|
{group.deliveryDate ? (
|
||||||
const renderRow = (g, onSelectSet) => {
|
<span>{fmtDate(group.deliveryDate)}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
|
||||||
const statusTone = getOrderGroupStatusTone(g);
|
) : (
|
||||||
const stale = isStale(g);
|
<span className="text-[var(--color-text-muted)]">—</span>
|
||||||
const databaseAge = getDatabaseAge(g);
|
)}
|
||||||
const smsTone = getSmsProblemTone(g);
|
</div>
|
||||||
const smsTag = smsTone !== "ok" ? SMS_TAG[smsTone] : null;
|
<div className="px-3 py-1.5 text-xs">
|
||||||
const notAgreedHighlight = getNotAgreedHighlight(g);
|
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]">—</span>}
|
||||||
return (
|
</div>
|
||||||
<button
|
<div className="px-3 py-1.5">
|
||||||
type="button"
|
<div className="flex items-center gap-1.5">
|
||||||
key={g.id}
|
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge>
|
||||||
onClick={() => onSelectSet(g.id)}
|
{(group.hasDeliveryProblem || group.has_delivery_problem) && (
|
||||||
className={`grid ${COLS} ${MIN_W} w-full border-t border-[var(--color-border)] text-left transition hover:bg-[var(--color-accent-soft)] ${
|
<span
|
||||||
notAgreedHighlight || (stale ? "bg-[rgba(245,158,11,0.06)]" : "")
|
title={group.deliveryProblemNote || group.delivery_problem_note || "Есть проблемы с отгрузкой позиций"}
|
||||||
}`}
|
className="inline-flex items-center gap-0.5 rounded-full bg-[rgba(239,68,68,0.12)] px-1.5 py-0.5 text-[10px] font-bold text-[var(--color-danger)]"
|
||||||
>
|
>
|
||||||
<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>
|
</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-2.5 text-sm text-[var(--color-text-muted)]">{g.city || "—"}</div>
|
</div>
|
||||||
<div className="px-3 py-2.5 text-xs">
|
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
|
||||||
<span className={`inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2 py-0.5 font-medium ${
|
{formatDateTime(group.updatedAt)}
|
||||||
g.deliveryType === "pickup"
|
</div>
|
||||||
? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]"
|
</button>
|
||||||
: "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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const SortableSection = ({ statusValue, label, groups, isCollapsed, onToggle, onSelectSet, sort, onSort }) => {
|
// Sortable section wrapper
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: statusValue });
|
const SortableSection = ({ statusValue, label, groups, isCollapsed, onToggle, onSelectSet }) => {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: statusValue });
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
|
|
@ -251,35 +194,58 @@ const SortableSection = ({ statusValue, label, groups, isCollapsed, onToggle, on
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setNodeRef} style={style} className="rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden">
|
<div
|
||||||
<div className="flex items-center gap-2 border-b border-[var(--color-border)] px-4 py-3">
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className="rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Section header — drag handle + collapse toggle */}
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
{/* Drag handle */}
|
||||||
<button
|
<button
|
||||||
type="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}
|
{...attributes}
|
||||||
{...listeners}
|
{...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 20 20">
|
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24" style={{ opacity: 0.6 }}>
|
||||||
<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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Collapse toggle */}
|
||||||
<button
|
<button
|
||||||
type="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}
|
onClick={onToggle}
|
||||||
className="flex flex-1 items-center justify-between text-left"
|
|
||||||
>
|
>
|
||||||
<span className="text-sm font-semibold text-[var(--color-text)]">{label}</span>
|
<div className="flex items-center gap-2">
|
||||||
<span className="flex items-center gap-2">
|
<h3 className="text-sm font-semibold">{label}</h3>
|
||||||
<Badge tone="neutral">{groups.length}</Badge>
|
<Badge tone={groups.length > 0 ? "neutral" : "muted"}>{groups.length}</Badge>
|
||||||
<span className="text-xs text-[var(--color-text-muted)]">{isCollapsed ? "▶" : "▼"}</span>
|
</div>
|
||||||
</span>
|
<svg
|
||||||
|
className="h-4 w-4 text-[var(--color-text-muted)] transition-transform"
|
||||||
|
style={{ transform: isCollapsed ? "rotate(-90deg)" : "rotate(0deg)" }}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<div className={MIN_W}>
|
<div className={MIN_W}>
|
||||||
<TableHeader sort={sort} onSort={onSort} />
|
<TableHeader />
|
||||||
{groups.map((g) => renderRow(g, onSelectSet))}
|
{groups.map((g) => renderRow(g, onSelectSet))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -288,67 +254,13 @@ const SortableSection = ({ statusValue, label, groups, isCollapsed, onToggle, on
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if any filter is active
|
|
||||||
const isFilterActive = (filters) => {
|
|
||||||
if (!filters) return false;
|
|
||||||
if (filters.query && filters.query.trim()) return true;
|
|
||||||
if (filters.displayStatus && filters.displayStatus !== "all") return true;
|
|
||||||
if (filters.city && filters.city.trim()) return true;
|
|
||||||
if (filters.deliveryStatus && filters.deliveryStatus !== "all") return true;
|
|
||||||
if (filters.deliveryHalfDay && filters.deliveryHalfDay !== "all") return true;
|
|
||||||
if (filters.smsStatus && filters.smsStatus !== "all") return true;
|
|
||||||
if (filters.dateFrom) return true;
|
|
||||||
if (filters.dateTo) return true;
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS, isLoading = false }) => {
|
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS, isLoading = false }) => {
|
||||||
const FILTERS_KEY = 'logistics-board-filters';
|
const [filters, setFilters] = React.useState({ query: "", displayStatus: "all", city: "" });
|
||||||
const [filters, setFilters] = React.useState(() => {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(FILTERS_KEY);
|
|
||||||
if (raw) return { query: '', displayStatus: 'all', city: '', ...JSON.parse(raw) };
|
|
||||||
} catch {}
|
|
||||||
return { query: '', displayStatus: 'all', city: '' };
|
|
||||||
});
|
|
||||||
React.useEffect(() => {
|
|
||||||
try { localStorage.setItem(FILTERS_KEY, JSON.stringify(filters)); } catch {}
|
|
||||||
}, [filters]);
|
|
||||||
const [collapsedSections, setCollapsedSections] = React.useState(() => loadCollapsedSections());
|
const [collapsedSections, setCollapsedSections] = React.useState(() => loadCollapsedSections());
|
||||||
const [sectionOrder, setSectionOrder] = React.useState(() => {
|
const [sectionOrder, setSectionOrder] = React.useState(() => {
|
||||||
const custom = loadCustomOrder();
|
const custom = loadCustomOrder();
|
||||||
return custom || [...DEFAULT_FUNNEL_ORDER];
|
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(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
|
|
@ -366,60 +278,10 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
|
||||||
() => filterOrderGroups(orderGroups, filters),
|
() => filterOrderGroups(orderGroups, filters),
|
||||||
[filters, orderGroups],
|
[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 statusGroups = React.useMemo(() => {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
for (const group of paginatedGroups) {
|
for (const group of filteredGroups) {
|
||||||
const statusValue = getOrderGroupDisplayStatusValue(group);
|
const statusValue = getOrderGroupDisplayStatusValue(group);
|
||||||
if (!map.has(statusValue)) {
|
if (!map.has(statusValue)) {
|
||||||
const label = getOrderGroupDisplayStatusLabel(group);
|
const label = getOrderGroupDisplayStatusLabel(group);
|
||||||
|
|
@ -428,7 +290,9 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
|
||||||
map.get(statusValue).groups.push(group);
|
map.get(statusValue).groups.push(group);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [paginatedGroups]);
|
}, [filteredGroups]);
|
||||||
|
|
||||||
|
const totalGroups = filteredGroups.length;
|
||||||
|
|
||||||
// Build sorted list: use sectionOrder for known statuses, append unknown ones at end
|
// Build sorted list: use sectionOrder for known statuses, append unknown ones at end
|
||||||
const sortedEntries = React.useMemo(() => {
|
const sortedEntries = React.useMemo(() => {
|
||||||
|
|
@ -496,12 +360,6 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
|
||||||
statusOptions={statusOptions}
|
statusOptions={statusOptions}
|
||||||
cities={cities}
|
cities={cities}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!hasActiveFilters && totalGroups > PAGE_SIZE && (
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">
|
|
||||||
Показано {Math.min(currentPage * PAGE_SIZE, totalGroups)} из {totalGroups} · по {PAGE_SIZE} на странице
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{!totalGroups ? (
|
{!totalGroups ? (
|
||||||
|
|
@ -542,8 +400,6 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onSelectSet={onSelectSet}
|
onSelectSet={onSelectSet}
|
||||||
sort={sort}
|
|
||||||
onSort={handleSort}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -551,16 +407,6 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!hasActiveFilters && totalGroups > PAGE_SIZE && (
|
|
||||||
<Pagination
|
|
||||||
page={currentPage}
|
|
||||||
totalPages={totalPages}
|
|
||||||
onChange={setPage}
|
|
||||||
itemsPerPage={PAGE_SIZE}
|
|
||||||
totalItems={totalGroups}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -61,7 +61,7 @@ export function NotificationBell({ notifications, unreadCount, onMarkAsRead, onM
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<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">
|
<div className="absolute right-0 top-full z-50 mt-2 w-80 sm:w-96">
|
||||||
<Panel className="max-h-[480px] overflow-hidden p-0 shadow-xl">
|
<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">
|
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-4 py-3">
|
||||||
<h3 className="text-sm font-semibold">Уведомления</h3>
|
<h3 className="text-sm font-semibold">Уведомления</h3>
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ const CalendarWidget = ({
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className={layoutClassName}>
|
<div className={layoutClassName}>
|
||||||
<div className={(calendarClassName || "") + " relative"}>
|
<div className={calendarClassName}>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
|
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -122,7 +122,7 @@ const DriverAssignmentPanel = ({
|
||||||
Водитель назначен
|
Водитель назначен
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-lg font-semibold">
|
<p className="mt-1 text-lg font-semibold">
|
||||||
{order.assignedDriverName || drivers.find((d) => d.id === order.assignedDriverId)?.name || "Водитель назначен"}
|
{order.assignedDriverName || "Неизвестно"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge tone="accent">Назначен</Badge>
|
<Badge tone="accent">Назначен</Badge>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -120,12 +120,6 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities =
|
||||||
{ value: "pickup", label: "Самовывоз" },
|
{ value: "pickup", label: "Самовывоз" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const smsProblemOptions = [
|
|
||||||
{ value: "all", label: "Проблемы SMS" },
|
|
||||||
{ value: "problem_not_sent", label: "SMS не отправлено" },
|
|
||||||
{ value: "problem_not_delivered", label: "SMS не доставлено" },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel className="p-4">
|
<Panel className="p-4">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
|
|
@ -196,13 +190,6 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities =
|
||||||
placeholder="Тип получения"
|
placeholder="Тип получения"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DropdownSelect
|
|
||||||
value={filters.smsStatus || "all"}
|
|
||||||
options={smsProblemOptions}
|
|
||||||
onChange={(v) => updateFilter("smsStatus", v)}
|
|
||||||
placeholder="Проблемы SMS"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
className="h-[46px] py-0"
|
className="h-[46px] py-0"
|
||||||
placeholder="Поиск по группе, клиенту или телефону"
|
placeholder="Поиск по группе, клиенту или телефону"
|
||||||
|
|
|
||||||
|
|
@ -1,713 +0,0 @@
|
||||||
import React, { useEffect, useState, useMemo } from "react";
|
|
||||||
import { supabase } from "../../supabaseClient";
|
|
||||||
|
|
||||||
// ─── Status labels ──────────────────────────────────────────────────
|
|
||||||
const STATUS_LABELS = {
|
|
||||||
pending_confirmation: "Ожидает согласования",
|
|
||||||
first_sms_sent: "1-е SMS отправлено",
|
|
||||||
second_sms_sent: "2-е SMS отправлено",
|
|
||||||
second_sms_sending: "Отправка 2-го SMS",
|
|
||||||
first_sms_sending: "Отправка 1-го SMS",
|
|
||||||
sms_sending: "Отправка SMS",
|
|
||||||
manual_required: "Требуется ручное согласование",
|
|
||||||
manual_confirmation_required: "Требуется ручное согласование",
|
|
||||||
ready_to_launch: "Готов к отправке",
|
|
||||||
ready_for_notification: "Готов к отправке",
|
|
||||||
link_ready: "Ссылка готова",
|
|
||||||
not_started: "Не начато",
|
|
||||||
confirmed: "Подтверждено",
|
|
||||||
agreed: "Согласовано",
|
|
||||||
driver_assigned: "Водитель назначен",
|
|
||||||
loaded: "Загружено",
|
|
||||||
on_route: "В пути",
|
|
||||||
delivered: "Доставлено",
|
|
||||||
picked_up: "Вывезено",
|
|
||||||
problem: "Проблема",
|
|
||||||
cancelled: "Отменено",
|
|
||||||
paid_storage: "Платное хранение",
|
|
||||||
pickup: "Самовывоз",
|
|
||||||
null: "—",
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Field labels for changes ────────────────────────────────────────
|
|
||||||
const FIELD_LABELS = {
|
|
||||||
delivery_status: "Статус доставки",
|
|
||||||
notification_status: "Статус уведомления",
|
|
||||||
first_sms_sent_at: "Время 1-го SMS",
|
|
||||||
second_sms_sent_at: "Время 2-го SMS",
|
|
||||||
manual_confirmation_at: "Ручное согласование",
|
|
||||||
paid_storage_at: "Платное хранение",
|
|
||||||
has_delivery_problem: "Проблема доставки",
|
|
||||||
delivery_problem_note: "Описание проблемы",
|
|
||||||
assigned_driver_id: "Водитель",
|
|
||||||
delivery_date: "Дата доставки",
|
|
||||||
delivery_time: "Время доставки",
|
|
||||||
delivery_address: "Адрес доставки",
|
|
||||||
called: "Статус звонка",
|
|
||||||
called_at: "Время звонка",
|
|
||||||
called_comment: "Комментарий к звонку",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Specific human-readable labels for field_change entries
|
|
||||||
const FIELD_CHANGE_LABELS = {
|
|
||||||
first_sms_sent_at: "Обновлено время отправки 1-го SMS",
|
|
||||||
second_sms_sent_at: "Обновлено время отправки 2-го SMS",
|
|
||||||
manual_confirmation_at: "Ручное согласование",
|
|
||||||
paid_storage_at: "Платное хранение",
|
|
||||||
has_delivery_problem: "Отметка проблемы доставки",
|
|
||||||
delivery_problem_note: "Описание проблемы",
|
|
||||||
assigned_driver_id: "Назначение водителя",
|
|
||||||
delivery_date: "Дата доставки",
|
|
||||||
delivery_time: "Время доставки",
|
|
||||||
delivery_address: "Адрес доставки",
|
|
||||||
called: "Статус звонка",
|
|
||||||
called_at: "Время звонка",
|
|
||||||
called_comment: "Комментарий к звонку",
|
|
||||||
delivery_status: "Статус доставки",
|
|
||||||
notification_status: "Статус уведомления",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fields to SKIP (redundant or noisy)
|
|
||||||
const SKIP_FIELDS = new Set([
|
|
||||||
"status",
|
|
||||||
"group_key",
|
|
||||||
"delivery_link",
|
|
||||||
"delivery_link_code",
|
|
||||||
"delivery_invitation_id",
|
|
||||||
"next_notification_check_at",
|
|
||||||
"sms_attempts",
|
|
||||||
"last_sms_error",
|
|
||||||
"updated_at",
|
|
||||||
"can_launch_invitation",
|
|
||||||
"source",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const ACTION_LABELS = {
|
|
||||||
delivery_status_change: "Изменение статуса доставки",
|
|
||||||
notification_status_change: "Изменение статуса уведомления",
|
|
||||||
status_change: "Изменение статуса",
|
|
||||||
field_change: "Изменение данных заказа",
|
|
||||||
};
|
|
||||||
|
|
||||||
const SMS_CAMPAIGN_LABELS = {
|
|
||||||
first_sms: "Отправка 1-го SMS",
|
|
||||||
second_sms: "Отправка 2-го SMS",
|
|
||||||
manual: "Ручная отправка SMS",
|
|
||||||
paid_storage: "SMS о платном хранении",
|
|
||||||
};
|
|
||||||
|
|
||||||
const SMS_STATUS_LABELS = {
|
|
||||||
delivered: "доставлено",
|
|
||||||
sent: "отправлено",
|
|
||||||
failed: "ошибка",
|
|
||||||
pending: "в очереди",
|
|
||||||
};
|
|
||||||
|
|
||||||
const fmtTimestamp = (ts) => {
|
|
||||||
if (!ts) return "—";
|
|
||||||
try {
|
|
||||||
return new Date(ts).toLocaleString("ru-RU", {
|
|
||||||
day: "2-digit", month: "2-digit", year: "numeric",
|
|
||||||
hour: "2-digit", minute: "2-digit",
|
|
||||||
});
|
|
||||||
} catch { return "—"; }
|
|
||||||
};
|
|
||||||
|
|
||||||
const translateValue = (v) => {
|
|
||||||
if (v === null || v === undefined) return "—";
|
|
||||||
if (typeof v === "string" && v.includes("T") && v.length > 10) {
|
|
||||||
return fmtTimestamp(v);
|
|
||||||
}
|
|
||||||
if (typeof v === "boolean") return v ? "да" : "нет";
|
|
||||||
return STATUS_LABELS[v] ?? String(v);
|
|
||||||
};
|
|
||||||
|
|
||||||
const translateField = (f) => FIELD_LABELS[f] ?? f;
|
|
||||||
|
|
||||||
const formatChanges = (changes) => {
|
|
||||||
if (!changes || typeof changes !== "object") return [];
|
|
||||||
const lines = [];
|
|
||||||
for (const [field, change] of Object.entries(changes)) {
|
|
||||||
if (SKIP_FIELDS.has(field)) continue;
|
|
||||||
if (change && typeof change === "object" && "old" in change && "new" in change) {
|
|
||||||
const oldVal = translateValue(change.old);
|
|
||||||
const newVal = translateValue(change.new);
|
|
||||||
if (oldVal !== newVal) {
|
|
||||||
lines.push({ field: translateField(field), oldVal, newVal });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return lines;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get specific label for field_change action based on what actually changed
|
|
||||||
const getFieldChangeLabel = (changes) => {
|
|
||||||
if (!changes || typeof changes !== "object") return null;
|
|
||||||
const keys = Object.keys(changes).filter((k) => !SKIP_FIELDS.has(k));
|
|
||||||
if (keys.length === 1 && FIELD_CHANGE_LABELS[keys[0]]) {
|
|
||||||
return FIELD_CHANGE_LABELS[keys[0]];
|
|
||||||
}
|
|
||||||
if (keys.length === 1) {
|
|
||||||
return `Изменение: ${translateField(keys[0])}`;
|
|
||||||
}
|
|
||||||
if (keys.length > 1) {
|
|
||||||
return `Изменено полей: ${keys.length}`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Progress Stepper ────────────────────────────────────────────────
|
|
||||||
const STAGES_DELIVERY = [
|
|
||||||
{ key: "created", icon: "📥", label: "Добавлен" },
|
|
||||||
{ key: "sms", icon: "📨", label: "SMS" },
|
|
||||||
{ key: "agreed", icon: "✅", label: "Согласовано" },
|
|
||||||
{ key: "driver", icon: "🚚", label: "Водитель" },
|
|
||||||
{ key: "delivered", icon: "📦", label: "Доставлено" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const STAGES_PICKUP = [
|
|
||||||
{ key: "created", icon: "📥", label: "Добавлен" },
|
|
||||||
{ key: "sms", icon: "📨", label: "SMS" },
|
|
||||||
{ key: "agreed", icon: "✅", label: "Согласовано" },
|
|
||||||
{ key: "picked_up", icon: "📦", label: "Вывезено" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const getActiveStageIndex = (order) => {
|
|
||||||
const deliveryStatus = order?.deliveryStatus || order?.delivery_status || "";
|
|
||||||
const notifStatus = order?.notificationStatus || order?.notification_status || "";
|
|
||||||
const firstSms = order?.firstSmsSentAt || order?.first_sms_sent_at;
|
|
||||||
const isPickup = (order?.deliveryType || order?.delivery_type) === "pickup";
|
|
||||||
const stages = isPickup ? STAGES_PICKUP : STAGES_DELIVERY;
|
|
||||||
|
|
||||||
if (deliveryStatus === "cancelled") return -1;
|
|
||||||
if (deliveryStatus === "problem") return -2;
|
|
||||||
if (deliveryStatus === "paid_storage") return -3;
|
|
||||||
|
|
||||||
if (isPickup) {
|
|
||||||
if (deliveryStatus === "picked_up") return stages.length - 1;
|
|
||||||
} else {
|
|
||||||
if (deliveryStatus === "delivered") return stages.length - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isPickup && ["driver_assigned", "loaded", "on_route"].includes(deliveryStatus)) {
|
|
||||||
return 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deliveryStatus === "agreed" || notifStatus === "confirmed") {
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (firstSms || ["first_sms_sent", "second_sms_sent", "confirmed"].includes(notifStatus)) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ProgressStepper = ({ order }) => {
|
|
||||||
const isPickup = (order?.deliveryType || order?.delivery_type) === "pickup";
|
|
||||||
const stages = isPickup ? STAGES_PICKUP : STAGES_DELIVERY;
|
|
||||||
const activeIdx = getActiveStageIndex(order);
|
|
||||||
|
|
||||||
if (activeIdx === -1) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 rounded-xl bg-[rgba(239,68,68,0.08)] px-3 py-2">
|
|
||||||
<span className="text-sm">❌</span>
|
|
||||||
<span className="text-sm font-semibold text-[var(--color-danger)]">Заказ отменён</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (activeIdx === -2) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 rounded-xl bg-[rgba(239,68,68,0.08)] px-3 py-2">
|
|
||||||
<span className="text-sm">⚠️</span>
|
|
||||||
<span className="text-sm font-semibold text-[var(--color-danger)]">Проблема с заказом</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (activeIdx === -3) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 rounded-xl bg-[rgba(245,158,11,0.08)] px-3 py-2">
|
|
||||||
<span className="text-sm">📦</span>
|
|
||||||
<span className="text-sm font-semibold text-[var(--color-warning)]">Платное хранение</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{stages.map((stage, idx) => {
|
|
||||||
const isDone = idx < activeIdx;
|
|
||||||
const isActive = idx === activeIdx;
|
|
||||||
return (
|
|
||||||
<React.Fragment key={stage.key}>
|
|
||||||
{idx > 0 && (
|
|
||||||
<div className={`h-0.5 flex-1 min-w-[12px] rounded-full transition-colors ${
|
|
||||||
isDone ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"
|
|
||||||
}`} />
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col items-center gap-1 flex-shrink-0">
|
|
||||||
<div className={`flex h-7 w-7 items-center justify-center rounded-full text-xs transition-all ${
|
|
||||||
isActive
|
|
||||||
? "bg-[var(--color-accent)] text-white ring-2 ring-[var(--color-accent)] ring-offset-2 ring-offset-[var(--color-surface)]"
|
|
||||||
: isDone
|
|
||||||
? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]"
|
|
||||||
: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]"
|
|
||||||
}`}>
|
|
||||||
{isDone ? "✓" : stage.icon}
|
|
||||||
</div>
|
|
||||||
<span className={`text-[10px] whitespace-nowrap ${
|
|
||||||
isActive
|
|
||||||
? "font-bold text-[var(--color-text)]"
|
|
||||||
: isDone
|
|
||||||
? "font-medium text-[var(--color-accent)]"
|
|
||||||
: "text-[var(--color-text-muted)]"
|
|
||||||
}`}>
|
|
||||||
{stage.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Current Status Summary (compact card above timeline) ───────────
|
|
||||||
const StatusSummary = ({ order }) => {
|
|
||||||
const isPickup = (order?.deliveryType || order?.delivery_type) === "pickup";
|
|
||||||
const deliveryStatus = order?.deliveryStatus || order?.delivery_status || "";
|
|
||||||
const notifStatus = order?.notificationStatus || order?.notification_status || "";
|
|
||||||
const firstSms = order?.firstSmsSentAt || order?.first_sms_sent_at;
|
|
||||||
const secondSms = order?.secondSmsSentAt || order?.second_sms_sent_at;
|
|
||||||
const called = order?.called;
|
|
||||||
const calledAt = order?.calledAt || order?.called_at;
|
|
||||||
const calledComment = order?.calledComment || order?.called_comment;
|
|
||||||
const invitationOpened = order?.invitationOpenedAt || order?.invitationOpenedAt;
|
|
||||||
const invitationAccessCount = order?.invitationAccessCount || order?.invitationAccessCount;
|
|
||||||
const manualConfirmationAt = order?.manualConfirmationAt || order?.manual_confirmation_at;
|
|
||||||
const paidStorageAt = order?.paidStorageAt || order?.paid_storage_at;
|
|
||||||
const driverName = order?.assignedDriverName || order?.assignedDriverName;
|
|
||||||
|
|
||||||
const items = [];
|
|
||||||
|
|
||||||
// SMS status
|
|
||||||
if (firstSms) {
|
|
||||||
items.push({ icon: "📨", label: "1-е SMS", value: fmtTimestamp(firstSms), tone: "ok" });
|
|
||||||
} else {
|
|
||||||
items.push({ icon: "📨", label: "1-е SMS", value: "не отправлено", tone: "muted" });
|
|
||||||
}
|
|
||||||
if (secondSms) {
|
|
||||||
items.push({ icon: "📨", label: "2-е SMS", value: fmtTimestamp(secondSms), tone: "ok" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invitation access
|
|
||||||
if (invitationOpened || invitationAccessCount > 0) {
|
|
||||||
const count = invitationAccessCount || 1;
|
|
||||||
items.push({
|
|
||||||
icon: "👁",
|
|
||||||
label: "Страница согласования",
|
|
||||||
value: `открыл ${count} раз${(order?.invitationLastAccessedAt || order?.invitationLastAccessedAt) ? `, последний: ${fmtTimestamp(order.invitationLastAccessedAt || order.invitationLastAccessedAt)}` : ""}`,
|
|
||||||
tone: "ok",
|
|
||||||
});
|
|
||||||
} else if (firstSms) {
|
|
||||||
items.push({ icon: "👁", label: "Страница согласования", value: "не открывал", tone: "warning" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Agreement
|
|
||||||
if (deliveryStatus === "agreed" || notifStatus === "confirmed") {
|
|
||||||
items.push({ icon: "✅", label: "Согласовано", value: "да", tone: "ok" });
|
|
||||||
} else if (manualConfirmationAt) {
|
|
||||||
items.push({ icon: "✋", label: "Ручное согласование", value: fmtTimestamp(manualConfirmationAt), tone: "warning" });
|
|
||||||
} else if (firstSms) {
|
|
||||||
items.push({ icon: "⏳", label: "Согласование", value: "ждёт ответа клиента", tone: "warning" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call status
|
|
||||||
if (called) {
|
|
||||||
const parts = [fmtTimestamp(calledAt)];
|
|
||||||
if (calledComment) parts.push(`«${calledComment}»`);
|
|
||||||
items.push({ icon: "📞", label: "Звонок", value: parts.join(" · "), tone: "ok" });
|
|
||||||
} else {
|
|
||||||
items.push({ icon: "📞", label: "Звонок", value: "не звонили", tone: "muted" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Driver (delivery only)
|
|
||||||
if (!isPickup) {
|
|
||||||
if (driverName) {
|
|
||||||
items.push({ icon: "🚚", label: "Водитель", value: driverName, tone: "ok" });
|
|
||||||
} else if (deliveryStatus === "agreed" || notifStatus === "confirmed") {
|
|
||||||
items.push({ icon: "🚚", label: "Водитель", value: "не назначен", tone: "warning" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Paid storage
|
|
||||||
if (paidStorageAt) {
|
|
||||||
items.push({ icon: "📦", label: "Платное хранение", value: fmtTimestamp(paidStorageAt), tone: "warning" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Problem
|
|
||||||
if (deliveryStatus === "problem") {
|
|
||||||
items.push({ icon: "⚠️", label: "Проблема", value: order?.deliveryProblemNote || order?.delivery_problem_note || "есть", tone: "danger" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (items.length === 0) return null;
|
|
||||||
|
|
||||||
const toneStyles = {
|
|
||||||
ok: "border-[rgba(18,128,92,0.25)] bg-[var(--color-accent-soft)]",
|
|
||||||
warning: "border-[rgba(245,158,11,0.3)] bg-[rgba(245,158,11,0.06)]",
|
|
||||||
danger: "border-[rgba(239,68,68,0.3)] bg-[rgba(239,68,68,0.06)]",
|
|
||||||
muted: "border-[var(--color-border)] bg-[var(--color-surface)]",
|
|
||||||
};
|
|
||||||
const textStyles = {
|
|
||||||
ok: "text-[var(--color-accent)]",
|
|
||||||
warning: "text-[var(--color-warning)]",
|
|
||||||
danger: "text-[var(--color-danger)]",
|
|
||||||
muted: "text-[var(--color-text-muted)]",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
||||||
{items.map((item, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className={`flex items-center gap-2.5 rounded-xl border px-3 py-2.5 ${toneStyles[item.tone] || toneStyles.muted}`}
|
|
||||||
>
|
|
||||||
<span className="text-base flex-shrink-0">{item.icon}</span>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="text-[11px] font-medium text-[var(--color-text-muted)] leading-tight">{item.label}</p>
|
|
||||||
<p className={`text-sm font-semibold truncate ${textStyles[item.tone] || textStyles.muted}`}>
|
|
||||||
{item.value}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Main component ─────────────────────────────────────────────────
|
|
||||||
export const OrderHistoryTimeline = ({ order, userRole }) => {
|
|
||||||
const [history, setHistory] = useState(null);
|
|
||||||
const [smsLogs, setSmsLogs] = useState([]);
|
|
||||||
const [pushLogs, setPushLogs] = useState([]);
|
|
||||||
const [pushSubscriptions, setPushSubscriptions] = useState([]);
|
|
||||||
const [cityProfile, setCityProfile] = useState(null);
|
|
||||||
const [invitation, setInvitation] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
const fetchAll = async () => {
|
|
||||||
if (!order?.id || !supabase) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from("order_history")
|
|
||||||
.select("id, action, old_status, new_status, user_id, metadata, created_at")
|
|
||||||
.eq("order_group_id", order.id)
|
|
||||||
.order("created_at", { ascending: false })
|
|
||||||
.limit(50);
|
|
||||||
if (cancelled) return;
|
|
||||||
if (error) { setHistory([]); } else { setHistory(data || []); }
|
|
||||||
} catch { if (!cancelled) setHistory([]); }
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { data: smsData, error: smsError } = await supabase
|
|
||||||
.from("sms_campaign_log")
|
|
||||||
.select("id, campaign_type, status, sms_text, error_message, created_at, updated_at, sent_to")
|
|
||||||
.eq("order_group_id", order.id)
|
|
||||||
.order("created_at", { ascending: false })
|
|
||||||
.limit(50);
|
|
||||||
if (cancelled) return;
|
|
||||||
if (!smsError) setSmsLogs(smsData || []);
|
|
||||||
} catch { if (!cancelled) setSmsLogs([]); }
|
|
||||||
|
|
||||||
// Push is stored on the client phone, so it is available for every current and future order.
|
|
||||||
const phoneNormalized = String(order?.customerPhoneNormalized || order?.customer_phone_normalized || order?.customerPhone || order?.customer_phone || "").replace(/\D/g, "").replace(/^8/, "7");
|
|
||||||
if (phoneNormalized) {
|
|
||||||
try {
|
|
||||||
const { data: pushData, error: pushError } = await supabase
|
|
||||||
.from("push_log")
|
|
||||||
.select("id, status, title, body, url, error_message, created_at")
|
|
||||||
.eq("phone_normalized", phoneNormalized)
|
|
||||||
.order("created_at", { ascending: false })
|
|
||||||
.limit(50);
|
|
||||||
if (cancelled) return;
|
|
||||||
if (!pushError) setPushLogs(pushData || []);
|
|
||||||
} catch { if (!cancelled) setPushLogs([]); }
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { data: subscriptionData, error: subscriptionError } = await supabase
|
|
||||||
.from("push_subscriptions")
|
|
||||||
.select("id, is_active, created_at, last_sent_at, last_seen_at")
|
|
||||||
.eq("phone_normalized", phoneNormalized)
|
|
||||||
.order("created_at", { ascending: false });
|
|
||||||
if (cancelled) return;
|
|
||||||
if (!subscriptionError) setPushSubscriptions(subscriptionData || []);
|
|
||||||
} catch { if (!cancelled) setPushSubscriptions([]); }
|
|
||||||
} else {
|
|
||||||
setPushLogs([]);
|
|
||||||
setPushSubscriptions([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const town = order?.town || order?.city || null;
|
|
||||||
if (town) {
|
|
||||||
const { data: cpData, error: cpError } = await supabase
|
|
||||||
.from("sms_city_profiles")
|
|
||||||
.select("city, second_sms_enabled, sms_enabled")
|
|
||||||
.eq("city", town)
|
|
||||||
.maybeSingle();
|
|
||||||
if (cancelled) return;
|
|
||||||
if (!cpError) setCityProfile(cpData || null);
|
|
||||||
}
|
|
||||||
} catch { if (!cancelled) setCityProfile(null); }
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { data: invData, error: invError } = await supabase
|
|
||||||
.from("delivery_invitations")
|
|
||||||
.select("id, sent_at, opened_at, confirmed_at, access_count, last_accessed_at")
|
|
||||||
.eq("order_group_id", order.id)
|
|
||||||
.order("created_at", { ascending: false })
|
|
||||||
.limit(1);
|
|
||||||
if (cancelled) return;
|
|
||||||
if (!invError) setInvitation((invData && invData.length > 0) ? invData[0] : null);
|
|
||||||
} catch { if (!cancelled) setInvitation(null); }
|
|
||||||
};
|
|
||||||
fetchAll();
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [order?.id]);
|
|
||||||
|
|
||||||
const allEvents = useMemo(() => {
|
|
||||||
const events = [];
|
|
||||||
|
|
||||||
if (history && history.length > 0) {
|
|
||||||
for (const h of history) {
|
|
||||||
const changes = formatChanges(h.metadata?.changes);
|
|
||||||
|
|
||||||
// For field_change, generate a specific label instead of generic "Изменение данных заказа"
|
|
||||||
let label = ACTION_LABELS[h.action] ?? h.action;
|
|
||||||
if (h.action === "field_change") {
|
|
||||||
const specificLabel = getFieldChangeLabel(h.metadata?.changes);
|
|
||||||
if (specificLabel) label = specificLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For field_change: skip noisy system rewrites of sms timestamps (they are
|
|
||||||
// already shown as separate "SMS доставлена" events). Other changes keep their label.
|
|
||||||
const smsRewriteFields = ["first_sms_sent_at", "second_sms_sent_at"];
|
|
||||||
if (h.action === "field_change") {
|
|
||||||
const keys = Object.keys(h.metadata?.changes || {}).filter((k) => !SKIP_FIELDS.has(k));
|
|
||||||
if (keys.length === 1 && smsRewriteFields.includes(keys[0])) {
|
|
||||||
continue; // system update of delivery timestamp — redundant
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
events.push({
|
|
||||||
id: `hist-${h.id}`,
|
|
||||||
created_at: h.created_at,
|
|
||||||
type: "history",
|
|
||||||
label,
|
|
||||||
changes,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (smsLogs && smsLogs.length > 0) {
|
|
||||||
for (const s of smsLogs) {
|
|
||||||
const text = s.sms_text ? (s.sms_text.length > 100 ? s.sms_text.slice(0, 100) + "…" : s.sms_text) : "";
|
|
||||||
const isDelivered = s.status === "delivered";
|
|
||||||
const deliveryTime = isDelivered ? (s.updated_at || s.created_at) : null;
|
|
||||||
// sent time is created_at
|
|
||||||
events.push({
|
|
||||||
id: `sms-${s.id}`,
|
|
||||||
created_at: s.created_at,
|
|
||||||
type: "sms",
|
|
||||||
label: SMS_CAMPAIGN_LABELS[s.campaign_type] ?? `SMS (${s.campaign_type || "—"})`,
|
|
||||||
smsStatus: SMS_STATUS_LABELS[s.status] ?? s.status ?? "—",
|
|
||||||
smsText: text,
|
|
||||||
smsError: s.error_message || null,
|
|
||||||
smsSentAt: s.created_at,
|
|
||||||
smsDeliveredAt: deliveryTime,
|
|
||||||
isDelivered,
|
|
||||||
});
|
|
||||||
// Separate delivery-confirmation event with the actual delivery time
|
|
||||||
if (isDelivered) {
|
|
||||||
events.push({
|
|
||||||
id: `sms-delivered-${s.id}`,
|
|
||||||
created_at: deliveryTime,
|
|
||||||
type: "sms_delivered",
|
|
||||||
label: "SMS доставлена (подтверждение)",
|
|
||||||
campaignType: s.campaign_type,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pushLogs && pushLogs.length > 0) {
|
|
||||||
for (const push of pushLogs) {
|
|
||||||
events.push({
|
|
||||||
id: `push-${push.id}`,
|
|
||||||
created_at: push.created_at,
|
|
||||||
type: "push",
|
|
||||||
label: push.status === "sent" ? "Push-уведомление отправлено" : "Ошибка отправки Push",
|
|
||||||
pushStatus: push.status,
|
|
||||||
pushTitle: push.title || "СуперСам",
|
|
||||||
pushBody: push.body || "",
|
|
||||||
pushError: push.error_message || null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (invitation) {
|
|
||||||
if (invitation.sent_at) {
|
|
||||||
events.push({ id: `inv-sent-${invitation.id}`, created_at: invitation.sent_at, type: "invitation", label: "Приглашение отправлено" });
|
|
||||||
}
|
|
||||||
if (invitation.opened_at) {
|
|
||||||
events.push({ id: `inv-opened-${invitation.id}`, created_at: invitation.opened_at, type: "invitation", label: "Клиент открыл страницу", accessCount: invitation.access_count });
|
|
||||||
}
|
|
||||||
if (invitation.confirmed_at) {
|
|
||||||
events.push({ id: `inv-confirmed-${invitation.id}`, created_at: invitation.confirmed_at, type: "invitation", label: "Клиент подтвердил дату доставки" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reason why the 2nd SMS was not sent (e.g. city profile disables it)
|
|
||||||
const secondSmsSent = order?.secondSmsSentAt || order?.second_sms_sent_at;
|
|
||||||
const hasSecondSmsLog = smsLogs.some((s) => s.campaign_type === "second_sms");
|
|
||||||
if (!secondSmsSent && !hasSecondSmsLog) {
|
|
||||||
const firstSmsDelivered = smsLogs.some((s) => s.campaign_type === "first_sms" && s.status === "delivered");
|
|
||||||
const town = order?.town || order?.city || null;
|
|
||||||
const cityDisables = cityProfile && cityProfile.second_sms_enabled === false;
|
|
||||||
if (firstSmsDelivered && cityDisables && town) {
|
|
||||||
events.push({
|
|
||||||
id: `second-sms-reason`,
|
|
||||||
created_at: order?.firstSmsSentAt || order?.first_sms_sent_at || new Date().toISOString(),
|
|
||||||
type: "reason",
|
|
||||||
label: "2-е SMS не отправлено",
|
|
||||||
reason: `Для города ${town} вторая SMS отключена в настройках.`,
|
|
||||||
tone: "warning",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
events.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
|
||||||
return events;
|
|
||||||
}, [history, smsLogs, pushLogs, invitation, cityProfile]);
|
|
||||||
|
|
||||||
const activePushSubscriptions = pushSubscriptions.filter((sub) => sub.is_active);
|
|
||||||
const latestPush = pushLogs[0] || null;
|
|
||||||
|
|
||||||
if (!history && smsLogs.length === 0 && pushLogs.length === 0 && !invitation) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<ProgressStepper order={order} />
|
|
||||||
<StatusSummary order={order} />
|
|
||||||
<div className={`rounded-xl border px-3 py-2.5 ${
|
|
||||||
activePushSubscriptions.length > 0
|
|
||||||
? "border-[rgba(18,128,92,0.25)] bg-[var(--color-accent-soft)]"
|
|
||||||
: "border-[var(--color-border)] bg-[var(--color-surface)]"
|
|
||||||
}`}>
|
|
||||||
<div className="flex items-start gap-2.5">
|
|
||||||
<span className="mt-0.5 text-base">{activePushSubscriptions.length > 0 ? "🔔" : "🔕"}</span>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="text-[11px] font-medium text-[var(--color-text-muted)]">Push-уведомления клиента</p>
|
|
||||||
{activePushSubscriptions.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<p className="text-sm font-semibold text-[var(--color-accent)]">
|
|
||||||
Подписан · устройств: {activePushSubscriptions.length}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">
|
|
||||||
Подписка: {fmtTimestamp(activePushSubscriptions[0]?.created_at)}
|
|
||||||
{latestPush ? ` · последнее push: ${fmtTimestamp(latestPush.created_at)}` : " · уведомлений ещё не было"}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm font-semibold text-[var(--color-text-muted)]">Не подписан</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{allEvents.length > 0 && (
|
|
||||||
<ol className="relative space-y-3 pl-4">
|
|
||||||
{allEvents.map((ev, idx) => (
|
|
||||||
<li key={ev.id ?? idx} className="relative border-l border-[var(--color-border)] pl-4">
|
|
||||||
<span className="absolute -left-[5px] top-1 h-2.5 w-2.5 rounded-full bg-[var(--color-accent)]" aria-hidden />
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">{fmtTimestamp(ev.created_at)}</p>
|
|
||||||
<p className="mt-0.5 text-sm font-medium text-[var(--color-text)]">{ev.label}</p>
|
|
||||||
|
|
||||||
{ev.type === "history" && ev.changes.length > 0 && (
|
|
||||||
<div className="mt-1 space-y-0.5">
|
|
||||||
{ev.changes.map((c, i) => (
|
|
||||||
<p key={i} className="text-sm text-[var(--color-text-muted)]">
|
|
||||||
{c.field}: <span className="font-medium">{c.oldVal}</span>
|
|
||||||
{" → "}
|
|
||||||
<span className="font-medium text-[var(--color-text)]">{c.newVal}</span>
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ev.type === "sms" && (
|
|
||||||
<>
|
|
||||||
<p className="mt-0.5 text-sm">
|
|
||||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${
|
|
||||||
ev.smsStatus === "доставлено"
|
|
||||||
? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]"
|
|
||||||
: ev.smsStatus === "ошибка"
|
|
||||||
? "bg-[rgba(239,68,68,0.12)] text-[var(--color-danger)]"
|
|
||||||
: "bg-[var(--color-surface)] text-[var(--color-text-muted)]"
|
|
||||||
}`}>
|
|
||||||
{ev.smsStatus}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<p className="mt-0.5 text-xs text-[var(--color-text-muted)]">
|
|
||||||
Отправлено: {fmtTimestamp(ev.smsSentAt)}
|
|
||||||
{ev.isDelivered ? ` · Доставлено: ${fmtTimestamp(ev.smsDeliveredAt)}` : ""}
|
|
||||||
</p>
|
|
||||||
{ev.smsText && <p className="mt-0.5 text-xs text-[var(--color-text-muted)]">«{ev.smsText}»</p>}
|
|
||||||
{ev.smsError && <p className="mt-0.5 text-xs text-[var(--color-danger)]">Ошибка: {ev.smsError}</p>}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ev.type === "sms_delivered" && (
|
|
||||||
<p className="mt-0.5 text-xs text-[var(--color-accent)]">
|
|
||||||
Подтверждение доставки в {fmtTimestamp(ev.created_at)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ev.type === "reason" && (
|
|
||||||
<p className={`mt-0.5 text-xs ${
|
|
||||||
ev.tone === "warning" ? "text-[var(--color-warning)]" : "text-[var(--color-text-muted)]"
|
|
||||||
}`}>
|
|
||||||
{ev.reason}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ev.type === "push" && (
|
|
||||||
<>
|
|
||||||
<p className="mt-0.5 text-sm">
|
|
||||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${
|
|
||||||
ev.pushStatus === "sent"
|
|
||||||
? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]"
|
|
||||||
: "bg-[rgba(239,68,68,0.12)] text-[var(--color-danger)]"
|
|
||||||
}`}>
|
|
||||||
{ev.pushStatus === "sent" ? "принято сервисом Push" : "не отправлено"}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<p className="mt-0.5 text-xs text-[var(--color-text-muted)]">«{ev.pushTitle}{ev.pushBody ? ` — ${ev.pushBody}` : ""}»</p>
|
|
||||||
{ev.pushError && <p className="mt-0.5 text-xs text-[var(--color-danger)]">Ошибка: {ev.pushError}</p>}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ev.type === "invitation" && ev.accessCount != null && ev.accessCount > 0 && (
|
|
||||||
<p className="mt-0.5 text-xs text-[var(--color-text-muted)]">Открытий страницы: {ev.accessCount}</p>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default OrderHistoryTimeline;
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
* and a "Restart SMS" button that resets this group's notification status.
|
* and a "Restart SMS" button that resets this group's notification status.
|
||||||
*/
|
*/
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
import { supabase } from "../../supabaseClient";
|
import { supabase } from "../../supabaseClient";
|
||||||
|
|
||||||
|
|
@ -21,8 +22,6 @@ const NOTIF_LABELS = {
|
||||||
paid_storage_sending: "Отправляется…",
|
paid_storage_sending: "Отправляется…",
|
||||||
paid_storage_sent: "Платное хранение: отправлено",
|
paid_storage_sent: "Платное хранение: отправлено",
|
||||||
draft: "Черновик",
|
draft: "Черновик",
|
||||||
confirmed: "Клиент согласовал дату",
|
|
||||||
completed: "Завершено",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const NOTIF_TONES = {
|
const NOTIF_TONES = {
|
||||||
|
|
@ -37,8 +36,6 @@ const NOTIF_TONES = {
|
||||||
paid_storage_sending: "info",
|
paid_storage_sending: "info",
|
||||||
paid_storage_sent: "accent",
|
paid_storage_sent: "accent",
|
||||||
draft: "neutral",
|
draft: "neutral",
|
||||||
confirmed: "accent",
|
|
||||||
completed: "neutral",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -65,38 +62,6 @@ const fmtCountdown = (targetTs) => {
|
||||||
return `${m}м ${s}с`;
|
return `${m}м ${s}с`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── SMS log entry (expandable to show text) ──────────────────────────────────
|
|
||||||
const SmsLogEntry = ({ log }) => {
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const hasText = !!log.sms_text;
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1.5">
|
|
||||||
<div
|
|
||||||
className={`flex items-center gap-2 text-[11px] ${hasText ? "cursor-pointer" : ""}`}
|
|
||||||
onClick={hasText ? () => setExpanded(e => !e) : undefined}
|
|
||||||
>
|
|
||||||
<span className="text-[var(--color-text-muted)]">{fmtTime(log.created_at)}</span>
|
|
||||||
<Badge tone={log.status === "delivered" ? "accent" : log.status === "sent" || log.status === "checking" ? "info" : "danger"}>
|
|
||||||
{log.status === "delivered" ? "доставлено" : log.status === "sent" ? "отправлено" : log.status === "checking" ? "проверка" : log.status === "expired" ? "истёк" : log.status}
|
|
||||||
</Badge>
|
|
||||||
{log.campaign_type && (
|
|
||||||
<span className="text-[var(--color-text-muted)]">
|
|
||||||
{log.campaign_type === "first_sms" ? "1-е SMS" : log.campaign_type === "second_sms" ? "2-е SMS" : log.campaign_type === "manual" ? "ручное" : log.campaign_type === "paid_storage" ? "платное хранение" : log.campaign_type}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{hasText && (
|
|
||||||
<span className="ml-auto text-[var(--color-accent)] text-[10px]">{expanded ? "▲" : "▼"}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{hasText && expanded && (
|
|
||||||
<div className="mt-1.5 rounded-md bg-[var(--color-surface-strong)] px-2.5 py-2 text-[11px] text-[var(--color-text)] leading-relaxed whitespace-pre-wrap">
|
|
||||||
{log.sms_text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Component ────────────────────────────────────────────────────────────────
|
// ── Component ────────────────────────────────────────────────────────────────
|
||||||
export const SmsStatusCard = ({ order, userRole }) => {
|
export const SmsStatusCard = ({ order, userRole }) => {
|
||||||
const [restarting, setRestarting] = useState(false);
|
const [restarting, setRestarting] = useState(false);
|
||||||
|
|
@ -201,34 +166,31 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const countdown = nextCheck ? fmtCountdown(nextCheck) : null;
|
const countdown = nextCheck ? fmtCountdown(nextCheck) : null;
|
||||||
// Determine SMS display state from sms_log (source of truth)
|
// Determine SMS display state
|
||||||
const firstSmsLog = smsLog.find(l => l.campaign_type === "first_sms");
|
const hasSmsSent = !!smsSentAt; // SMS physically sent (may not be delivered yet)
|
||||||
const secondSmsLog = smsLog.find(l => l.campaign_type === "second_sms");
|
const hasFirstSms = !!firstSmsAt; // First SMS confirmed delivered (103)
|
||||||
const isDelivered = (log) => log && (log.status === "delivered" || log.sms_code === "103");
|
const hasSecondSms = !!secondSmsAt; // Second SMS confirmed delivered (103)
|
||||||
const hasFirstSms = isDelivered(firstSmsLog);
|
|
||||||
const hasSecondSms = isDelivered(secondSmsLog);
|
|
||||||
const firstSmsFailed = firstSmsLog && ["expired", "error", "send_failed", "limit_exceeded"].includes(firstSmsLog.status);
|
|
||||||
const secondSmsFailed = secondSmsLog && ["expired", "error", "send_failed", "limit_exceeded"].includes(secondSmsLog.status);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<Panel className="p-4">
|
||||||
<div className="mb-3">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold text-[var(--color-text)]">📱 SMS-уведомления</h3>
|
<h3 className="text-sm font-semibold text-[var(--color-text)]">📱 SMS-уведомления</h3>
|
||||||
|
<Badge tone={NOTIF_TONES[notifStatus] || "neutral"}>
|
||||||
|
{NOTIF_LABELS[notifStatus] || notifStatus}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Timeline */}
|
{/* Timeline */}
|
||||||
<div className="space-y-2 text-xs">
|
<div className="space-y-2 text-xs">
|
||||||
{/* 1st SMS */}
|
{/* 1st SMS */}
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<span className={`mt-0.5 h-2 w-2 rounded-full ${hasFirstSms ? "bg-[#22c55e]" : firstSmsFailed ? "bg-[var(--color-danger)]" : firstSmsAt ? "bg-[var(--color-accent)]" : notifStatus === "link_ready" || notifStatus === "not_started" ? "bg-[var(--color-warning)]" : "bg-[var(--color-border)]"}`} />
|
<span className={`mt-0.5 h-2 w-2 rounded-full ${hasFirstSms ? "bg-[#22c55e]" : hasSmsSent && notifStatus === "sms_sending" ? "bg-[var(--color-accent)]" : notifStatus === "link_ready" || notifStatus === "not_started" ? "bg-[var(--color-warning)]" : "bg-[var(--color-border)]"}`} />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-[var(--color-text)]">1-е SMS</div>
|
<div className="text-[var(--color-text)]">1-е SMS</div>
|
||||||
{hasFirstSms ? (
|
{hasFirstSms ? (
|
||||||
<div className="text-[var(--color-text-muted)]">{fmtTime(firstSmsLog.created_at)} ✓ получено клиентом</div>
|
<div className="text-[var(--color-text-muted)]">{fmtTime(firstSmsAt)} ✓ доставлено</div>
|
||||||
) : firstSmsFailed ? (
|
) : hasSmsSent && notifStatus === "sms_sending" ? (
|
||||||
<div className="text-[var(--color-danger)]">{fmtTime(firstSmsLog.created_at)} ⚠ {firstSmsLog.status === "expired" ? "срок доставки истёк" : firstSmsLog.status}</div>
|
<div className="text-[var(--color-text-muted)]">{fmtTime(smsSentAt)} · отправлено, ждём подтверждения…</div>
|
||||||
) : firstSmsAt ? (
|
|
||||||
<div className="text-[var(--color-text-muted)]">{fmtTime(firstSmsAt)} · отправлено, ждём ответ оператора…</div>
|
|
||||||
) : notifStatus === "link_ready" ? (
|
) : notifStatus === "link_ready" ? (
|
||||||
<div className="text-[var(--color-text-muted)]">в очереди на отправку</div>
|
<div className="text-[var(--color-text-muted)]">в очереди на отправку</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -239,13 +201,11 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
||||||
|
|
||||||
{/* 2nd SMS */}
|
{/* 2nd SMS */}
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<span className={`mt-0.5 h-2 w-2 rounded-full ${hasSecondSms ? "bg-[#22c55e]" : secondSmsFailed ? "bg-[var(--color-danger)]" : notifStatus === "first_sms_sent" ? "bg-[var(--color-warning)]" : "bg-[var(--color-border)]"}`} />
|
<span className={`mt-0.5 h-2 w-2 rounded-full ${hasSecondSms ? "bg-[#22c55e]" : notifStatus === "first_sms_sent" ? "bg-[var(--color-warning)]" : "bg-[var(--color-border)]"}`} />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-[var(--color-text)]">2-е SMS</div>
|
<div className="text-[var(--color-text)]">2-е SMS</div>
|
||||||
{hasSecondSms ? (
|
{hasSecondSms ? (
|
||||||
<div className="text-[var(--color-text-muted)]">{fmtTime(secondSmsLog.created_at)} ✓ получено клиентом</div>
|
<div className="text-[var(--color-text-muted)]">{fmtTime(secondSmsAt)}</div>
|
||||||
) : secondSmsFailed ? (
|
|
||||||
<div className="text-[var(--color-danger)]">{fmtTime(secondSmsLog.created_at)} ⚠ {secondSmsLog.status === "expired" ? "срок доставки истёк" : secondSmsLog.status}</div>
|
|
||||||
) : notifStatus === "first_sms_sent" && countdown ? (
|
) : notifStatus === "first_sms_sent" && countdown ? (
|
||||||
<div className="text-[var(--color-text-muted)]">
|
<div className="text-[var(--color-text-muted)]">
|
||||||
отправка через <span className="font-mono text-[var(--color-accent)]">{countdown}</span>
|
отправка через <span className="font-mono text-[var(--color-accent)]">{countdown}</span>
|
||||||
|
|
@ -283,29 +243,23 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
||||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">История SMS</div>
|
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">История SMS</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{smsLog.map((log) => (
|
{smsLog.map((log) => (
|
||||||
<SmsLogEntry key={log.id} log={log} />
|
<div key={log.id} className="flex items-center gap-2 text-[11px]">
|
||||||
|
<span className="text-[var(--color-text-muted)]">{fmtTime(log.created_at)}</span>
|
||||||
|
<Badge tone={log.status === "delivered" ? "accent" : log.status === "sent" || log.status === "checking" ? "info" : "danger"}>
|
||||||
|
{log.status === "delivered" ? "доставлено" : log.status === "sent" ? "отправлено" : log.status === "checking" ? "проверка" : log.status === "expired" ? "истёк" : log.status}
|
||||||
|
</Badge>
|
||||||
|
{log.sent_to && (
|
||||||
|
<span className="text-[var(--color-text-muted)]">→ {log.sent_to}</span>
|
||||||
|
)}
|
||||||
|
{log.sms_code && (
|
||||||
|
<span className="text-[var(--color-text-muted)]">код: {log.sms_code}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Client page access info */}
|
|
||||||
{(order.invitationAccessCount > 0 || order.invitationOpenedAt) ? (
|
|
||||||
<div className="mt-2 flex items-center gap-2 rounded-lg bg-[var(--color-surface-strong)] px-2 py-1.5 text-[11px]">
|
|
||||||
<span className="text-[var(--color-text-muted)]">👁 Клиент открывал страницу согласования</span>
|
|
||||||
<span className="font-medium text-[var(--color-text)]">{order.invitationAccessCount || 1} раз</span>
|
|
||||||
{(order.invitationLastAccessedAt || order.invitationOpenedAt) && (
|
|
||||||
<span className="text-[var(--color-text-muted)]">
|
|
||||||
· последний: {fmtTime(order.invitationLastAccessedAt || order.invitationOpenedAt)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mt-2 flex items-center gap-2 rounded-lg bg-[var(--color-surface-strong)] px-2 py-1.5 text-[11px] text-[var(--color-text-muted)]">
|
|
||||||
📭 Клиент ещё не открывал страницу согласования
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Restart buttons */}
|
{/* Restart buttons */}
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<div className="mt-3 flex gap-2 border-t border-[var(--color-border)] pt-3">
|
<div className="mt-3 flex gap-2 border-t border-[var(--color-border)] pt-3">
|
||||||
|
|
@ -329,6 +283,6 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Panel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
import { Button } from "../UI/Button";
|
import { Button } from "../UI/Button";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
import { DELIVERY_GROUP_STATUS_LABELS } from "../../services/orderGroupViews";
|
import { DELIVERY_GROUP_STATUS_LABELS } from "../../services/orderGroupViews";
|
||||||
import { CalledPanel } from "./CalledPanel";
|
|
||||||
|
const STATUS_LABELS = DELIVERY_GROUP_STATUS_LABELS;
|
||||||
|
|
||||||
const StatusActionPanel = ({
|
const StatusActionPanel = ({
|
||||||
order,
|
order,
|
||||||
|
|
@ -10,130 +12,55 @@ const StatusActionPanel = ({
|
||||||
canManageDelivery,
|
canManageDelivery,
|
||||||
isSavingStatusChange,
|
isSavingStatusChange,
|
||||||
onConfirmStatus,
|
onConfirmStatus,
|
||||||
onRefreshOrder,
|
|
||||||
}) => {
|
}) => {
|
||||||
if (!canManageDelivery || !["manager", "logistician", "admin", "mega_admin"].includes(userRole) || !order) {
|
if (!canManageDelivery || !["manager", "logistician", "admin", "mega_admin"].includes(userRole) || !order) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentStatus = order.deliveryStatus || order.delivery_status;
|
const currentStatus = order.deliveryStatus || order.delivery_status;
|
||||||
const isPickup = (order.deliveryType || order.delivery_type) === "pickup" || currentStatus === "pickup";
|
|
||||||
|
|
||||||
// Check delivery schedule
|
|
||||||
const hasDeliveryDate = !!(order.deliveryDate || order.customerDate);
|
|
||||||
const hasDeliveryHalfDay = !!(order.deliveryTime || order.deliveryHalfDay || order.delivery_time || order.delivery_half_day);
|
|
||||||
const hasDeliverySchedule = hasDeliveryDate && hasDeliveryHalfDay;
|
|
||||||
const hasDriver = !!order.assignedDriverId;
|
|
||||||
|
|
||||||
// Smart hints: show actual state instead of generic "do X"
|
|
||||||
const getHint = (statusValue) => {
|
|
||||||
if (statusValue === "agreed") {
|
|
||||||
if (hasDeliverySchedule) return "Дата доставки уже согласована";
|
|
||||||
return "Согласуйте дату доставки выше";
|
|
||||||
}
|
|
||||||
if (statusValue === "driver_assigned") {
|
|
||||||
if (hasDriver) return `Водитель уже назначен: ${order.assignedDriverName || ""}`.trim();
|
|
||||||
return "Назначьте водителя из списка выше";
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const allStatuses = isPickup
|
|
||||||
? [
|
|
||||||
{ value: "pending_confirmation", label: "Ожидает согласования" },
|
|
||||||
{ value: "agreed", label: "Согласовано" },
|
|
||||||
{ value: "driver_assigned", label: "Назначен водитель" },
|
|
||||||
{ value: "picked_up", label: "Вывезено", primary: true, requiresSchedule: true, requiresDriver: false },
|
|
||||||
{ value: "delivered", label: "Доставлено", mismatch: true, requiresSchedule: true },
|
|
||||||
{ value: "requires_address", label: "Требуется адрес" },
|
|
||||||
{ value: "problem", label: "Проблема" },
|
|
||||||
{ value: "cancelled", label: "Отменено" },
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
{ value: "pending_confirmation", label: "Ожидает согласования" },
|
|
||||||
{ value: "agreed", label: "Согласовано" },
|
|
||||||
{ value: "driver_assigned", label: "Назначен водитель" },
|
|
||||||
{ value: "delivered", label: "Доставлено", primary: true, requiresSchedule: true, requiresDriver: true },
|
|
||||||
{ value: "picked_up", label: "Вывезено", mismatch: true, requiresSchedule: true },
|
|
||||||
{ value: "requires_address", label: "Требуется адрес" },
|
|
||||||
{ value: "problem", label: "Проблема" },
|
|
||||||
{ value: "cancelled", label: "Отменено" },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<Panel className="space-y-4 p-5">
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">
|
<div>
|
||||||
Измените статус, если водитель забыл обновить или нужна корректировка.
|
<strong>Статус доставки</strong>
|
||||||
</p>
|
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||||
|
Измените статус, если водитель забыл обновить или нужна корректировка.
|
||||||
{/* Status indicators: show current state clearly */}
|
</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{hasDeliverySchedule && (
|
|
||||||
<Badge tone="accent">✓ Дата согласована</Badge>
|
|
||||||
)}
|
|
||||||
{hasDriver && (
|
|
||||||
<Badge tone="accent">✓ Водитель: {order.assignedDriverName || "назначен"}</Badge>
|
|
||||||
)}
|
|
||||||
{!hasDeliverySchedule && (
|
|
||||||
<Badge tone="warning">⚠ Дата не указана</Badge>
|
|
||||||
)}
|
|
||||||
{!hasDriver && !isPickup && (
|
|
||||||
<Badge tone="warning">⚠ Водитель не назначен</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{allStatuses.map((statusOption) => {
|
{[
|
||||||
|
{ value: "pending_confirmation", label: "Ожидает согласования", manual: true },
|
||||||
|
{ value: "agreed", label: "Согласовано", manual: false, hint: "Согласуйте дату доставки выше" },
|
||||||
|
{ value: "driver_assigned", label: "Назначен водитель", manual: false, hint: "Назначьте водителя из списка" },
|
||||||
|
{ value: "loaded", label: "Загружено", manual: true },
|
||||||
|
{ value: "delivered", label: "Доставлено", manual: true },
|
||||||
|
{ value: "picked_up", label: "Вывезено", manual: true },
|
||||||
|
{ value: "requires_address", label: "Требуется адрес", manual: true },
|
||||||
|
{ value: "problem", label: "Проблема", manual: true },
|
||||||
|
{ value: "cancelled", label: "Отменено", manual: true },
|
||||||
|
].map((statusOption) => {
|
||||||
const isCurrent = currentStatus === statusOption.value;
|
const isCurrent = currentStatus === statusOption.value;
|
||||||
const blockedBySchedule = statusOption.requiresSchedule && !hasDeliverySchedule;
|
const isClickable = statusOption.manual !== false && !isCurrent;
|
||||||
const blockedByDriver = statusOption.requiresDriver !== false && !hasDriver;
|
|
||||||
const hint = getHint(statusOption.value);
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<div key={statusOption.value} className="relative group">
|
||||||
key={statusOption.value}
|
<Button
|
||||||
variant={isCurrent ? "primary" : (statusOption.mismatch ? "ghost" : "secondary")}
|
variant={isCurrent ? "primary" : "secondary"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (blockedBySchedule) {
|
if (!isClickable) {
|
||||||
return;
|
onConfirmStatus?.({ type: "hint", hint: statusOption.hint || "" });
|
||||||
}
|
return;
|
||||||
if (blockedByDriver) {
|
}
|
||||||
onConfirmStatus?.({
|
onConfirmStatus?.({ type: "status", status: statusOption.value });
|
||||||
type: "hint",
|
}}
|
||||||
hint: "⚠ Назначьте водителя перед статусом «Доставлено»",
|
disabled={isSavingStatusChange}
|
||||||
});
|
>
|
||||||
return;
|
{statusOption.label}
|
||||||
}
|
</Button>
|
||||||
if (isCurrent && hint) {
|
</div>
|
||||||
onConfirmStatus?.({ type: "hint", hint });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onConfirmStatus?.({
|
|
||||||
type: "status",
|
|
||||||
status: statusOption.value,
|
|
||||||
label: statusOption.label,
|
|
||||||
mismatch: !!statusOption.mismatch,
|
|
||||||
deliveryType: isPickup ? "pickup" : "delivery",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
disabled={isSavingStatusChange}
|
|
||||||
className={(blockedBySchedule || blockedByDriver) ? "opacity-40 cursor-not-allowed" : ""}
|
|
||||||
>
|
|
||||||
{statusOption.label}
|
|
||||||
</Button>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{!hasDeliverySchedule && (
|
</Panel>
|
||||||
<div className="rounded-xl border border-[var(--color-warning)] bg-[var(--color-warning-soft)] px-3 py-2 text-xs text-[var(--color-warning)]">
|
|
||||||
⚠ Чтобы поставить «Доставлено» или «Вывезено», сначала укажите дату и половину дня доставки выше.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="pt-3 border-t border-[var(--color-border)]">
|
|
||||||
<p className="mb-2 text-sm text-[var(--color-text-muted)]">Статус звонка:</p>
|
|
||||||
<CalledPanel order={order} onUpdate={onRefreshOrder} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -240,10 +240,10 @@ export const ORDER_STATUS_TRANSITIONS = {
|
||||||
"Ожидает согласования доставки": ["Доставка согласована", "Самовывоз", "Требуется адрес", "Проблема доставки", "Отменён"],
|
"Ожидает согласования доставки": ["Доставка согласована", "Самовывоз", "Требуется адрес", "Проблема доставки", "Отменён"],
|
||||||
"Доставка согласована": ["Назначен водитель", "Ожидает согласования доставки", "Проблема доставки", "Самовывоз", "Требуется адрес"],
|
"Доставка согласована": ["Назначен водитель", "Ожидает согласования доставки", "Проблема доставки", "Самовывоз", "Требуется адрес"],
|
||||||
"Передан логисту": ["Доставка согласована", "Платное хранение", "Проблема доставки", "Отменён"],
|
"Передан логисту": ["Доставка согласована", "Платное хранение", "Проблема доставки", "Отменён"],
|
||||||
"Назначен водитель": ["Доставлен", "Проблема доставки"],
|
"Назначен водитель": ["Загружен", "Проблема доставки"],
|
||||||
Загружен: ["Доставлен", "Проблема доставки"],
|
Загружен: ["Доставлен", "Проблема доставки"],
|
||||||
"В пути": ["Доставлен", "Проблема доставки"],
|
"В пути": ["Доставлен", "Проблема доставки"],
|
||||||
Доставлен: ["Закрыт", "Назначен водитель"],
|
Доставлен: ["Закрыт"],
|
||||||
"Проблема доставки": ["Ожидает согласования доставки", "Назначен водитель", "Отменён", "Закрыт"],
|
"Проблема доставки": ["Ожидает согласования доставки", "Назначен водитель", "Отменён", "Закрыт"],
|
||||||
"Платное хранение": ["Доставка согласована", "Отменён", "Закрыт"],
|
"Платное хранение": ["Доставка согласована", "Отменён", "Закрыт"],
|
||||||
"Самовывоз": ["Доставка согласована", "Закрыт", "Отменён", "Платное хранение"],
|
"Самовывоз": ["Доставка согласована", "Закрыт", "Отменён", "Платное хранение"],
|
||||||
|
|
@ -270,7 +270,7 @@ export const ROLE_TRANSITION_TARGETS = {
|
||||||
"Закрыт",
|
"Закрыт",
|
||||||
"Отменён",
|
"Отменён",
|
||||||
],
|
],
|
||||||
driver: ["Доставлен", "Проблема доставки"],
|
driver: ["Загружен", "Доставлен", "Проблема доставки"],
|
||||||
admin: ORDER_STATUSES,
|
admin: ORDER_STATUSES,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -291,7 +291,7 @@ export const LOGISTICS_STATUSES = [
|
||||||
"Проблема доставки",
|
"Проблема доставки",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DRIVER_STATUSES = ["Назначен водитель", "Доставлен"];
|
export const DRIVER_STATUSES = ["Назначен водитель", "Загружен", "Доставлен"];
|
||||||
|
|
||||||
export const getOrderStatusComment = (status) => ORDER_STATUS_META[status]?.comment || "Комментарий не задан.";
|
export const getOrderStatusComment = (status) => ORDER_STATUS_META[status]?.comment || "Комментарий не задан.";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
/* Font size scale variables — applied by FontSettingsContext */
|
|
||||||
:root {
|
|
||||||
--fs-scale-table: 1.0;
|
|
||||||
--fs-scale-card: 1.0;
|
|
||||||
--fs-scale-nav: 1.0;
|
|
||||||
--fs-scale-heading: 1.0;
|
|
||||||
--fs-scale-body: 1.0;
|
|
||||||
--fs-scale-small: 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Slider styling */
|
|
||||||
.fs-slider {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
outline: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fs-slider::-webkit-slider-thumb {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--color-accent);
|
|
||||||
border: 3px solid var(--color-surface-strong);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 120ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fs-slider::-webkit-slider-thumb:hover {
|
|
||||||
transform: scale(1.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fs-slider::-moz-range-thumb {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--color-accent);
|
|
||||||
border: 3px solid var(--color-surface-strong);
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Zone overrides ──────────────────────────────────────────────────────
|
|
||||||
.fs-zone-* wrappers override Tailwind text-* classes inside them.
|
|
||||||
Specificity: .fs-zone-X .text-Y (0,2,0) > .text-Y (0,1,0).
|
|
||||||
This lets us scale fonts without patching every component.
|
|
||||||
────────────────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
/* Table zone */
|
|
||||||
.fs-zone-table { font-size: calc(0.875rem * var(--fs-scale-table, 1)); }
|
|
||||||
.fs-zone-table .text-xs { font-size: calc(0.75rem * var(--fs-scale-table, 1)); }
|
|
||||||
.fs-zone-table .text-sm { font-size: calc(0.875rem * var(--fs-scale-table, 1)); }
|
|
||||||
.fs-zone-table .text-base { font-size: calc(1rem * var(--fs-scale-table, 1)); }
|
|
||||||
.fs-zone-table .text-lg { font-size: calc(1.125rem * var(--fs-scale-table, 1)); }
|
|
||||||
.fs-zone-table .text-xl { font-size: calc(1.25rem * var(--fs-scale-table, 1)); }
|
|
||||||
/* Arbitrary pixel sizes used in tables */
|
|
||||||
.fs-zone-table .text-\[10px\] { font-size: calc(10px * var(--fs-scale-table, 1)); }
|
|
||||||
.fs-zone-table .text-\[11px\] { font-size: calc(11px * var(--fs-scale-table, 1)); }
|
|
||||||
.fs-zone-table .text-\[12px\] { font-size: calc(12px * var(--fs-scale-table, 1)); }
|
|
||||||
.fs-zone-table .text-\[13px\] { font-size: calc(13px * var(--fs-scale-table, 1)); }
|
|
||||||
.fs-zone-table .text-\[14px\] { font-size: calc(14px * var(--fs-scale-table, 1)); }
|
|
||||||
|
|
||||||
/* Card zone */
|
|
||||||
.fs-zone-card { font-size: calc(0.875rem * var(--fs-scale-card, 1)); }
|
|
||||||
.fs-zone-card .text-xs { font-size: calc(0.75rem * var(--fs-scale-card, 1)); }
|
|
||||||
.fs-zone-card .text-sm { font-size: calc(0.875rem * var(--fs-scale-card, 1)); }
|
|
||||||
.fs-zone-card .text-base { font-size: calc(1rem * var(--fs-scale-card, 1)); }
|
|
||||||
.fs-zone-card .text-lg { font-size: calc(1.125rem * var(--fs-scale-card, 1)); }
|
|
||||||
.fs-zone-card .text-\[10px\] { font-size: calc(10px * var(--fs-scale-card, 1)); }
|
|
||||||
.fs-zone-card .text-\[11px\] { font-size: calc(11px * var(--fs-scale-card, 1)); }
|
|
||||||
|
|
||||||
/* Nav zone */
|
|
||||||
.fs-zone-nav { font-size: calc(0.875rem * var(--fs-scale-nav, 1)); }
|
|
||||||
.fs-zone-nav .text-xs { font-size: calc(0.75rem * var(--fs-scale-nav, 1)); }
|
|
||||||
.fs-zone-nav .text-sm { font-size: calc(0.875rem * var(--fs-scale-nav, 1)); }
|
|
||||||
.fs-zone-nav .text-base { font-size: calc(1rem * var(--fs-scale-nav, 1)); }
|
|
||||||
|
|
||||||
/* Heading zone */
|
|
||||||
.fs-zone-heading { font-size: calc(1rem * var(--fs-scale-heading, 1)); }
|
|
||||||
.fs-zone-heading .text-sm { font-size: calc(0.875rem * var(--fs-scale-heading, 1)); }
|
|
||||||
.fs-zone-heading .text-base { font-size: calc(1rem * var(--fs-scale-heading, 1)); }
|
|
||||||
.fs-zone-heading .text-lg { font-size: calc(1.125rem * var(--fs-scale-heading, 1)); }
|
|
||||||
.fs-zone-heading .text-xl { font-size: calc(1.25rem * var(--fs-scale-heading, 1)); }
|
|
||||||
.fs-zone-heading .text-2xl { font-size: calc(1.5rem * var(--fs-scale-heading, 1)); }
|
|
||||||
.fs-zone-heading .text-3xl { font-size: calc(1.875rem * var(--fs-scale-heading, 1)); }
|
|
||||||
|
|
||||||
/* Small text zone */
|
|
||||||
.fs-zone-small { font-size: calc(0.75rem * var(--fs-scale-small, 1)); }
|
|
||||||
.fs-zone-small .text-xs { font-size: calc(0.75rem * var(--fs-scale-small, 1)); }
|
|
||||||
.fs-zone-small .text-sm { font-size: calc(0.875rem * var(--fs-scale-small, 1)); }
|
|
||||||
|
|
||||||
/* Body zone — LAST so table/card/nav/heading zones inside body win at equal specificity */
|
|
||||||
.fs-zone-body { font-size: calc(1rem * var(--fs-scale-body, 1)); }
|
|
||||||
.fs-zone-body .text-xs { font-size: calc(0.75rem * var(--fs-scale-body, 1)); }
|
|
||||||
.fs-zone-body .text-sm { font-size: calc(0.875rem * var(--fs-scale-body, 1)); }
|
|
||||||
.fs-zone-body .text-base { font-size: calc(1rem * var(--fs-scale-body, 1)); }
|
|
||||||
.fs-zone-body .text-lg { font-size: calc(1.125rem * var(--fs-scale-body, 1)); }
|
|
||||||
|
|
@ -9,25 +9,15 @@ import {
|
||||||
} from "../services/orderGroupViews";
|
} from "../services/orderGroupViews";
|
||||||
import { getErrorMessage } from "../utils/deliveryUtils";
|
import { getErrorMessage } from "../utils/deliveryUtils";
|
||||||
|
|
||||||
export const useOrderGroups = ({ enabled = true, details = false } = {}) => {
|
export const useOrderGroups = () => {
|
||||||
const [orderGroups, setOrderGroups] = React.useState(() => []);
|
const [orderGroups, setOrderGroups] = React.useState(() => []);
|
||||||
const FILTERS_STORAGE_KEY = "supersam_order_filters";
|
const [filters, setFilters] = React.useState({
|
||||||
const [filters, setFilters] = React.useState(() => {
|
query: "",
|
||||||
try {
|
displayStatus: "all",
|
||||||
const saved = localStorage.getItem(FILTERS_STORAGE_KEY);
|
deliveryType: "",
|
||||||
if (saved) return JSON.parse(saved);
|
|
||||||
} catch (e) {}
|
|
||||||
return { query: "", displayStatus: "all", deliveryType: "" };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Persist filters to localStorage on every change
|
|
||||||
React.useEffect(() => {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(FILTERS_STORAGE_KEY, JSON.stringify(filters));
|
|
||||||
} catch (e) {}
|
|
||||||
}, [filters]);
|
|
||||||
const [selectedOrderGroupId, setSelectedOrderGroupId] = React.useState(null);
|
const [selectedOrderGroupId, setSelectedOrderGroupId] = React.useState(null);
|
||||||
const [isLoading, setIsLoading] = React.useState(enabled);
|
const [isLoading, setIsLoading] = React.useState(true);
|
||||||
const [loadError, setLoadError] = React.useState("");
|
const [loadError, setLoadError] = React.useState("");
|
||||||
const [isSavingDeliveryChoice, setIsSavingDeliveryChoice] = React.useState(false);
|
const [isSavingDeliveryChoice, setIsSavingDeliveryChoice] = React.useState(false);
|
||||||
const [isSavingDriverAssignment, setIsSavingDriverAssignment] = React.useState(false);
|
const [isSavingDriverAssignment, setIsSavingDriverAssignment] = React.useState(false);
|
||||||
|
|
@ -39,9 +29,7 @@ export const useOrderGroups = ({ enabled = true, details = false } = {}) => {
|
||||||
}
|
}
|
||||||
setLoadError("");
|
setLoadError("");
|
||||||
|
|
||||||
if (!enabled) { setOrderGroups([]); setIsLoading(false); return; }
|
const groupsResult = await fetchOrderGroups();
|
||||||
|
|
||||||
const groupsResult = await fetchOrderGroups({ details });
|
|
||||||
|
|
||||||
if (!groupsResult) {
|
if (!groupsResult) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -56,7 +44,7 @@ export const useOrderGroups = ({ enabled = true, details = false } = {}) => {
|
||||||
|
|
||||||
setOrderGroups(groupsResult.data || []);
|
setOrderGroups(groupsResult.data || []);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}, [enabled, details]);
|
}, []);
|
||||||
|
|
||||||
// Initial load
|
// Initial load
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
@ -66,19 +54,18 @@ export const useOrderGroups = ({ enabled = true, details = false } = {}) => {
|
||||||
await loadLiveData(true);
|
await loadLiveData(true);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
};
|
};
|
||||||
if (enabled) doLoad(); else { setOrderGroups([]); setIsLoading(false); }
|
doLoad();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [loadLiveData, enabled]);
|
}, [loadLiveData]);
|
||||||
|
|
||||||
// Refetch on window focus to keep data fresh across tabs/pages
|
// Refetch on window focus to keep data fresh across tabs/pages
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!enabled) return undefined;
|
|
||||||
const handleFocus = () => {
|
const handleFocus = () => {
|
||||||
loadLiveData(false); // silent refetch, no loading spinner
|
loadLiveData(false); // silent refetch, no loading spinner
|
||||||
};
|
};
|
||||||
window.addEventListener("focus", handleFocus);
|
window.addEventListener("focus", handleFocus);
|
||||||
return () => window.removeEventListener("focus", handleFocus);
|
return () => window.removeEventListener("focus", handleFocus);
|
||||||
}, [loadLiveData, enabled]);
|
}, [loadLiveData]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!orderGroups.length) {
|
if (!orderGroups.length) {
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,6 @@ import { PickupSlotsPicker } from "../components/client/PickupSlotsPicker";
|
||||||
import { OrderCompositionPanel } from "../components/client/OrderCompositionPanel";
|
import { OrderCompositionPanel } from "../components/client/OrderCompositionPanel";
|
||||||
import { getInvitationReferenceLabel } from "../components/client/invitationReference";
|
import { getInvitationReferenceLabel } from "../components/client/invitationReference";
|
||||||
import { DeliveryStateNotice } from "../components/client/DeliveryStateNotice";
|
import { DeliveryStateNotice } from "../components/client/DeliveryStateNotice";
|
||||||
import { PushSubscriptionBanner } from "../components/client/PushSubscriptionBanner";
|
|
||||||
import { PwaInstallCard } from "../components/client/PwaInstallCard";
|
|
||||||
import { Panel } from "../components/UI/Panel";
|
import { Panel } from "../components/UI/Panel";
|
||||||
|
|
||||||
import { Skeleton } from "../components/UI/Loading";
|
import { Skeleton } from "../components/UI/Loading";
|
||||||
|
|
@ -341,33 +339,6 @@ export const ClientDeliveryPage = () => {
|
||||||
setError("");
|
setError("");
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-[var(--color-bg)] px-3 py-4 sm:px-6 sm:py-8">
|
|
||||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4">
|
|
||||||
<Panel className="space-y-4 p-5 sm:p-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<img src="/icons/icon-192.png" alt="" className="h-12 w-12 rounded-xl" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">СуперСам</p>
|
|
||||||
<h1 className="text-2xl font-semibold leading-tight sm:text-3xl">Доставка заказа</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
|
||||||
Здесь можно согласовать дату доставки или самовывоза и включить уведомления по заказу.
|
|
||||||
</p>
|
|
||||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-bg)] p-4">
|
|
||||||
<p className="text-sm font-semibold text-[var(--color-text)]">Откройте персональную ссылку</p>
|
|
||||||
<p className="mt-1 text-sm leading-6 text-[var(--color-text-muted)]">
|
|
||||||
Она приходит в сообщении от «СуперСам». Данные заказа без персональной ссылки не отображаются.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-[var(--color-bg)] px-3 py-4 sm:px-6 sm:py-8">
|
<main className="min-h-screen bg-[var(--color-bg)] px-3 py-4 sm:px-6 sm:py-8">
|
||||||
|
|
@ -424,13 +395,6 @@ export const ClientDeliveryPage = () => {
|
||||||
|
|
||||||
<OrderCompositionPanel invitation={invitation} />
|
<OrderCompositionPanel invitation={invitation} />
|
||||||
|
|
||||||
<PwaInstallCard />
|
|
||||||
|
|
||||||
<PushSubscriptionBanner
|
|
||||||
phone={invitation?.customerPhone || invitation?.customer_phone || ""}
|
|
||||||
orderGroupId={invitation?.orderGroupId || invitation?.order_group_id || null}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isChoiceSaved && savedChoiceLabel ? (
|
{isChoiceSaved && savedChoiceLabel ? (
|
||||||
<Panel className="space-y-2 p-5 sm:p-6">
|
<Panel className="space-y-2 p-5 sm:p-6">
|
||||||
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Ваш выбор</p>
|
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Ваш выбор</p>
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ import { StopWordsPanel } from "../components/admin/StopWordsPanel";
|
||||||
import { ActionLogPanel } from "../components/admin/ActionLogPanel";
|
import { ActionLogPanel } from "../components/admin/ActionLogPanel";
|
||||||
import { SuggestionsPanel } from "../components/admin/SuggestionsPanel";
|
import { SuggestionsPanel } from "../components/admin/SuggestionsPanel";
|
||||||
import { SmsCampaignPanel } from "../components/admin/SmsCampaignPanel";
|
import { SmsCampaignPanel } from "../components/admin/SmsCampaignPanel";
|
||||||
import { BusinessSchedulePanel } from "../components/admin/BusinessSchedulePanel";
|
|
||||||
import { Panel } from "../components/UI/Panel";
|
import { Panel } from "../components/UI/Panel";
|
||||||
import { SkeletonPage, SkeletonTable } from "../components/UI/Loading";
|
import { SkeletonPage, SkeletonTable } from "../components/UI/Loading";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
|
@ -37,7 +36,6 @@ const MEGA_ADMIN_NAV = [
|
||||||
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
|
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
|
||||||
{ key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null },
|
{ key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null },
|
||||||
{ key: "sms_campaign", label: "SMS-кампании", description: "Логи и настройки SMS-рассылок.", badge: null },
|
{ key: "sms_campaign", label: "SMS-кампании", description: "Логи и настройки SMS-рассылок.", badge: null },
|
||||||
{ key: "schedule", label: "Расписание", description: "Рабочие дни доставки, самовывоза и SMS.", badge: null },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Role → Default Section Map ─────────────────────────────────────────────
|
// ── Role → Default Section Map ─────────────────────────────────────────────
|
||||||
|
|
@ -102,7 +100,7 @@ export const DashboardPage = () => {
|
||||||
statusOptions,
|
statusOptions,
|
||||||
isLoading,
|
isLoading,
|
||||||
loadError,
|
loadError,
|
||||||
} = useOrderGroups({ enabled: ["orders", "logistics", "deliveries"].includes(activeSection), details: false });
|
} = useOrderGroups();
|
||||||
|
|
||||||
// ── Derived City List ─────────────────────────────────────────────────────
|
// ── Derived City List ─────────────────────────────────────────────────────
|
||||||
const cities = React.useMemo(() => {
|
const cities = React.useMemo(() => {
|
||||||
|
|
@ -139,12 +137,10 @@ export const DashboardPage = () => {
|
||||||
{ key: "errors", label: "Ошибки", description: "Журнал ошибок приложения.", badge: null },
|
{ key: "errors", label: "Ошибки", description: "Журнал ошибок приложения.", badge: null },
|
||||||
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
|
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
|
||||||
{ key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null },
|
{ key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null },
|
||||||
{ key: "schedule", label: "Расписание", description: "Рабочие дни доставки, самовывоза и SMS.", badge: null },
|
|
||||||
]
|
]
|
||||||
: userRole === "logistician"
|
: userRole === "logistician"
|
||||||
? [
|
? [
|
||||||
{ key: "logistics", label: "Логистика", description: "Группы доставки по готовности к уведомлению.", badge: String(allOrderGroups.length || orderGroups.length || 0) },
|
{ key: "logistics", label: "Логистика", description: "Группы доставки по готовности к уведомлению.", badge: String(allOrderGroups.length || orderGroups.length || 0) },
|
||||||
{ key: "suggestions", label: "Предложения", description: "Предложить улучшение.", badge: null },
|
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{ key: section.key, label: section.label, description: section.description, badge: String(userRole === "driver" ? driverOrderCount : (allOrderGroups.length || orderGroups.length || 0)) },
|
{ key: section.key, label: section.label, description: section.description, badge: String(userRole === "driver" ? driverOrderCount : (allOrderGroups.length || orderGroups.length || 0)) },
|
||||||
|
|
@ -181,7 +177,6 @@ const ALLOWED_DASHBOARD_ROLES = ["admin", "mega_admin", "manager", "logistician"
|
||||||
if (activeSection === "action_log") return <div className="space-y-6 xl:space-y-8"><ActionLogPanel /></div>;
|
if (activeSection === "action_log") return <div className="space-y-6 xl:space-y-8"><ActionLogPanel /></div>;
|
||||||
if (activeSection === "suggestions") return <div className="space-y-6 xl:space-y-8"><SuggestionsPanel /></div>;
|
if (activeSection === "suggestions") return <div className="space-y-6 xl:space-y-8"><SuggestionsPanel /></div>;
|
||||||
if (activeSection === "sms_campaign") return <div className="space-y-6 xl:space-y-8"><SmsCampaignPanel /></div>;
|
if (activeSection === "sms_campaign") return <div className="space-y-6 xl:space-y-8"><SmsCampaignPanel /></div>;
|
||||||
if (activeSection === "schedule") return <div className="space-y-6 xl:space-y-8"><BusinessSchedulePanel /></div>;
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
if (userRole === "driver") {
|
if (userRole === "driver") {
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ export const GroupDetailPage = () => {
|
||||||
saveShipmentData,
|
saveShipmentData,
|
||||||
isLoading,
|
isLoading,
|
||||||
refetch,
|
refetch,
|
||||||
} = useOrderGroups({ details: true });
|
} = useOrderGroups();
|
||||||
|
|
||||||
// ── Drivers ────────────────────────────────────────────────────────────────
|
// ── Drivers ────────────────────────────────────────────────────────────────
|
||||||
const [drivers, setDrivers] = React.useState([]);
|
const [drivers, setDrivers] = React.useState([]);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Navigate, createBrowserRouter, useParams } from "react-router-dom";
|
import { Navigate, createBrowserRouter, Outlet } from "react-router-dom";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { ClientDeliveryPage } from "./pages/ClientDeliveryPage";
|
import { ClientDeliveryPage } from "./pages/ClientDeliveryPage";
|
||||||
import { DashboardPage } from "./pages/DashboardPage";
|
import { DashboardPage } from "./pages/DashboardPage";
|
||||||
|
|
@ -43,17 +43,9 @@ export const router = createBrowserRouter([
|
||||||
path: "login",
|
path: "login",
|
||||||
element: <LoginPage />,
|
element: <LoginPage />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "client",
|
|
||||||
element: <ClientDeliveryPage />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "client/:token",
|
|
||||||
element: <ClientDeliveryPage />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "delivery/:token",
|
path: "delivery/:token",
|
||||||
element: <LegacyDeliveryRedirect />,
|
element: <ClientDeliveryPage />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "forbidden",
|
path: "forbidden",
|
||||||
|
|
@ -90,8 +82,3 @@ export const router = createBrowserRouter([
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function LegacyDeliveryRedirect() {
|
|
||||||
const { token } = useParams();
|
|
||||||
return <Navigate to={`/client/${encodeURIComponent(token || "")}`} replace />;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ export const DRIVER_VISIBLE_DELIVERY_STATUSES = [
|
||||||
"paid_storage",
|
"paid_storage",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DRIVER_ACTIVE_DELIVERY_STATUSES = ["driver_assigned", "problem"];
|
export const DRIVER_ACTIVE_DELIVERY_STATUSES = ["driver_assigned", "loaded", "on_route", "problem"];
|
||||||
|
|
||||||
const HALF_DAY_LABELS = {
|
const HALF_DAY_LABELS = {
|
||||||
morning: "Первая половина дня",
|
morning: "Первая половина дня",
|
||||||
|
|
@ -143,17 +143,9 @@ export const isOrderGroupAgreedForDelivery = (group) => {
|
||||||
export const getOrderGroupDeliveryStatusLabel = (status) =>
|
export const getOrderGroupDeliveryStatusLabel = (status) =>
|
||||||
DELIVERY_GROUP_STATUS_LABELS[status] || (status ? `Неизвестно (${status})` : "Неизвестно");
|
DELIVERY_GROUP_STATUS_LABELS[status] || (status ? `Неизвестно (${status})` : "Неизвестно");
|
||||||
|
|
||||||
// Status values that represent a delivery state (not SMS/notification state)
|
|
||||||
const DELIVERY_STATUS_VALUES = new Set([
|
|
||||||
"pending_confirmation", "agreed", "driver_assigned", "loaded", "on_route",
|
|
||||||
"delivered", "picked_up", "pickup", "requires_address", "problem",
|
|
||||||
"cancelled", "paid_storage", "address_required", "manual_confirmation_required",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const getOrderGroupDisplayStatusLabel = (group) => {
|
export const getOrderGroupDisplayStatusLabel = (group) => {
|
||||||
const statusCol = group?.status;
|
|
||||||
const notificationStatus = group?.notificationStatus || group?.notification_status;
|
|
||||||
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
|
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
|
||||||
|
const notificationStatus = group?.notificationStatus || group?.notification_status;
|
||||||
|
|
||||||
// When auto-SMS failed and logistics hasn't taken action yet → show as a todo item
|
// When auto-SMS failed and logistics hasn't taken action yet → show as a todo item
|
||||||
const isManualRequired = notificationStatus === "manual_required";
|
const isManualRequired = notificationStatus === "manual_required";
|
||||||
|
|
@ -162,12 +154,6 @@ export const getOrderGroupDisplayStatusLabel = (group) => {
|
||||||
return "Требуется ручное управление";
|
return "Требуется ручное управление";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Primary: status column (now synced with delivery_status)
|
|
||||||
if (statusCol && DELIVERY_STATUS_VALUES.has(statusCol) && statusCol !== "pending_confirmation" && statusCol !== "manual_confirmation_required") {
|
|
||||||
return getOrderGroupDeliveryStatusLabel(statusCol);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: delivery_status (for pending/manual_confirmation groups)
|
|
||||||
if (deliveryStatus && deliveryStatus !== "pending_confirmation" && deliveryStatus !== "manual_confirmation_required") {
|
if (deliveryStatus && deliveryStatus !== "pending_confirmation" && deliveryStatus !== "manual_confirmation_required") {
|
||||||
return getOrderGroupDeliveryStatusLabel(deliveryStatus);
|
return getOrderGroupDeliveryStatusLabel(deliveryStatus);
|
||||||
}
|
}
|
||||||
|
|
@ -177,13 +163,12 @@ export const getOrderGroupDisplayStatusLabel = (group) => {
|
||||||
return notificationLabel;
|
return notificationLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
return getOrderGroupStatusLabel(statusCol);
|
return getOrderGroupStatusLabel(group?.status);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getOrderGroupDisplayStatusValue = (group) => {
|
export const getOrderGroupDisplayStatusValue = (group) => {
|
||||||
const statusCol = group?.status;
|
|
||||||
const notificationStatus = group?.notificationStatus || group?.notification_status;
|
|
||||||
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
|
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
|
||||||
|
const notificationStatus = group?.notificationStatus || group?.notification_status;
|
||||||
|
|
||||||
// Unify manual_required into a single bucket regardless of delivery_status detail
|
// Unify manual_required into a single bucket regardless of delivery_status detail
|
||||||
const isManualRequired = notificationStatus === "manual_required";
|
const isManualRequired = notificationStatus === "manual_required";
|
||||||
|
|
@ -192,17 +177,11 @@ export const getOrderGroupDisplayStatusValue = (group) => {
|
||||||
return "status:manual_required";
|
return "status:manual_required";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Primary: status column (now synced with delivery_status)
|
|
||||||
if (statusCol && DELIVERY_STATUS_VALUES.has(statusCol) && statusCol !== "pending_confirmation" && statusCol !== "manual_confirmation_required") {
|
|
||||||
return `delivery:${statusCol}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: delivery_status
|
|
||||||
if (deliveryStatus && deliveryStatus !== "pending_confirmation" && deliveryStatus !== "manual_confirmation_required") {
|
if (deliveryStatus && deliveryStatus !== "pending_confirmation" && deliveryStatus !== "manual_confirmation_required") {
|
||||||
return `delivery:${deliveryStatus}`;
|
return `delivery:${deliveryStatus}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `status:${statusCol || "unknown"}`;
|
return `status:${group?.status || "unknown"}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isOrderGroupVisibleToDriver = (group) => {
|
export const isOrderGroupVisibleToDriver = (group) => {
|
||||||
|
|
@ -244,23 +223,6 @@ export const parseGroupDate = (value) => {
|
||||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
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 = {}) => {
|
export const filterOrderGroups = (groups, filters = {}) => {
|
||||||
const query = normalizeDate(filters.query).trim().toLowerCase();
|
const query = normalizeDate(filters.query).trim().toLowerCase();
|
||||||
const status = filters.status || "all";
|
const status = filters.status || "all";
|
||||||
|
|
@ -269,7 +231,6 @@ export const filterOrderGroups = (groups, filters = {}) => {
|
||||||
const dateFrom = normalizeDate(filters.dateFrom);
|
const dateFrom = normalizeDate(filters.dateFrom);
|
||||||
const dateTo = normalizeDate(filters.dateTo);
|
const dateTo = normalizeDate(filters.dateTo);
|
||||||
const deliveryHalfDay = normalizeDate(filters.deliveryHalfDay || filters.timeSlot || "all");
|
const deliveryHalfDay = normalizeDate(filters.deliveryHalfDay || filters.timeSlot || "all");
|
||||||
const smsStatus = filters.smsStatus || "all";
|
|
||||||
|
|
||||||
const isWithinDateRange = (group) => {
|
const isWithinDateRange = (group) => {
|
||||||
const deliveryDate = parseGroupDate(getDeliveryDate(group));
|
const deliveryDate = parseGroupDate(getDeliveryDate(group));
|
||||||
|
|
@ -361,12 +322,6 @@ export const filterOrderGroups = (groups, filters = {}) => {
|
||||||
return false;
|
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) {
|
if (!query) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -528,16 +483,10 @@ export const buildOrderGroupBuckets = (groups) => {
|
||||||
|
|
||||||
export const getOrderGroupStatusTone = (group) => {
|
export const getOrderGroupStatusTone = (group) => {
|
||||||
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
|
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
|
||||||
const statusCol = group?.status;
|
|
||||||
|
|
||||||
// Highlight groups with delivery problems
|
// Highlight groups with delivery problems
|
||||||
if (group?.hasDeliveryProblem) return "warning";
|
if (group?.hasDeliveryProblem) return "warning";
|
||||||
|
|
||||||
// Priority: if status column already holds a delivery-level value, use it
|
|
||||||
if (statusCol && DELIVERY_STATUS_VALUES.has(statusCol) && statusCol !== "pending_confirmation" && statusCol !== "manual_confirmation_required") {
|
|
||||||
return getOrderGroupDeliveryStatusTone(statusCol);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deliveryStatus && deliveryStatus !== "pending_confirmation") {
|
if (deliveryStatus && deliveryStatus !== "pending_confirmation") {
|
||||||
return getOrderGroupDeliveryStatusTone(deliveryStatus);
|
return getOrderGroupDeliveryStatusTone(deliveryStatus);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -142,23 +142,12 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
|
||||||
const extractCity = (addr) => {
|
const extractCity = (addr) => {
|
||||||
if (!addr) return "";
|
if (!addr) return "";
|
||||||
// 1) explicit marker: г. Ялта, пгт. Куйбышево, etc.
|
// 1) explicit marker: г. Ялта, пгт. Куйбышево, etc.
|
||||||
// Word markers (город/село) require space after to avoid matching "Стройгородок"
|
const m = addr.match(/(?:г\.\s|гор\.\s|пос\.\s|с\.\s|дер\.\s|пгт\.\s|город\s|село\s|г\s)\s*([А-ЯЁа-яёA-Za-z\-\s]+?)(?:[,\\s]|$)/i);
|
||||||
// Dot markers (г./гор./etc) allow zero space (г.Ялта)
|
if (m) return m[1].trim();
|
||||||
const m = addr.match(/(?:г\.\s*|гор\.\s*|пос\.\s*|с\.\s*|дер\.\s*|пгт\.\s*|город\s+|село\s+|г\s+)\s*([А-ЯЁа-яёA-Za-z\-\s]+?)(?:[,\s]|$)/i);
|
// 2) known city name anywhere in address (case-insensitive)
|
||||||
if (m) {
|
|
||||||
const candidate = m[1].trim();
|
|
||||||
for (const city of CRIMEAN_CITIES) {
|
|
||||||
if (city.toLowerCase() === candidate.toLowerCase()) return city;
|
|
||||||
}
|
|
||||||
// Not a known city — continue to step 2
|
|
||||||
}
|
|
||||||
// 2) known city name via custom word boundary (JS \b doesn't work with Cyrillic)
|
|
||||||
// City must be preceded by start/comma/space/dot and followed by comma/space/end
|
|
||||||
const lower = addr.toLowerCase();
|
const lower = addr.toLowerCase();
|
||||||
for (const city of CRIMEAN_CITIES) {
|
for (const city of CRIMEAN_CITIES) {
|
||||||
const esc = city.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
if (lower.includes(city.toLowerCase())) return city;
|
||||||
const re = new RegExp("(^|[,.\\s])" + esc + "(?=[,\\s]|$)", "i");
|
|
||||||
if (re.test(lower)) return city;
|
|
||||||
}
|
}
|
||||||
// 3) Бахчисарайский р-н → Бахчисарай
|
// 3) Бахчисарайский р-н → Бахчисарай
|
||||||
const district = addr.match(/([А-ЯЁа-яё]+)ский\s*(?:р-н|район)/i);
|
const district = addr.match(/([А-ЯЁа-яё]+)ский\s*(?:р-н|район)/i);
|
||||||
|
|
@ -189,7 +178,6 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
|
||||||
customerPhone,
|
customerPhone,
|
||||||
customerPhoneNormalized: parsedKey.phone || normalizePhone(customerPhone),
|
customerPhoneNormalized: parsedKey.phone || normalizePhone(customerPhone),
|
||||||
customerDate,
|
customerDate,
|
||||||
town: row.town || null,
|
|
||||||
deliveryAddress: resolvedDeliveryAddress,
|
deliveryAddress: resolvedDeliveryAddress,
|
||||||
originalDeliveryAddress,
|
originalDeliveryAddress,
|
||||||
customerAddress,
|
customerAddress,
|
||||||
|
|
@ -208,8 +196,6 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
|
||||||
manualConfirmationAt: row.manual_confirmation_at || null,
|
manualConfirmationAt: row.manual_confirmation_at || null,
|
||||||
paidStorageAt: row.paid_storage_at || null,
|
paidStorageAt: row.paid_storage_at || null,
|
||||||
notificationStatus: normalizeText(row.notification_status),
|
notificationStatus: normalizeText(row.notification_status),
|
||||||
lastSmsError: row.last_sms_error || null,
|
|
||||||
smsAttempts: row.sms_attempts || 0,
|
|
||||||
createdFromExchangeAt: row.created_from_exchange_at || null,
|
createdFromExchangeAt: row.created_from_exchange_at || null,
|
||||||
sourceKey: row.source_key || null,
|
sourceKey: row.source_key || null,
|
||||||
legacyCustomerName: row.legacy_customer_name || null,
|
legacyCustomerName: row.legacy_customer_name || null,
|
||||||
|
|
@ -288,8 +274,7 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const ORDER_GROUP_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, town, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, order_list, order_list_structured, delivery_invitation_id, delivery_link, delivery_link_code, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, delivery_invitation:delivery_invitations!order_groups_delivery_invitation_id_fkey(opened_at, access_count, last_accessed_at), called, called_at, called_comment, order_history(id, action, old_status, new_status, user_id, metadata, created_at)`;
|
const ORDER_GROUP_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_LIST_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, town, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, notification_status, sms_attempts, last_sms_error, first_sms_sent_at, second_sms_sent_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, called, called_at, called_comment`;
|
|
||||||
|
|
||||||
export const updateOrderGroupDeliveryChoice = async ({
|
export const updateOrderGroupDeliveryChoice = async ({
|
||||||
orderGroupId,
|
orderGroupId,
|
||||||
|
|
@ -475,22 +460,15 @@ export const updateDeliveryStatus = async ({ orderGroupId, status, details, ship
|
||||||
return safeSupabaseCall(async () => {
|
return safeSupabaseCall(async () => {
|
||||||
const client = requireSupabase();
|
const client = requireSupabase();
|
||||||
|
|
||||||
// Fetch current status before any update (needed for audit log + status sync)
|
// Fetch current status before any update (needed for audit log)
|
||||||
const { data: current, error: fetchCurrentError } = await client
|
const { data: current, error: fetchCurrentError } = await client
|
||||||
.from("order_groups")
|
.from("order_groups")
|
||||||
.select("delivery_status, delivery_type")
|
.select("delivery_status")
|
||||||
.eq("id", orderGroupId)
|
.eq("id", orderGroupId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (fetchCurrentError) throw fetchCurrentError;
|
if (fetchCurrentError) throw fetchCurrentError;
|
||||||
|
|
||||||
// Compute status column: pickup+picked_up → picked_up, delivery+picked_up → delivered
|
|
||||||
const deliveryType = current.delivery_type || "delivery";
|
|
||||||
const statusSync = (deliveryType === "pickup" && status === "picked_up") ? "picked_up"
|
|
||||||
: (deliveryType === "delivery" && status === "picked_up") ? "delivered"
|
|
||||||
: (status === "delivered") ? "delivered"
|
|
||||||
: status;
|
|
||||||
|
|
||||||
// Bypass stale RPC for paid_storage transitions
|
// Bypass stale RPC for paid_storage transitions
|
||||||
// Server-side RPC still enforces driver-assignment checks that block
|
// Server-side RPC still enforces driver-assignment checks that block
|
||||||
// manager/logistician from moving groups into/out of paid_storage.
|
// manager/logistician from moving groups into/out of paid_storage.
|
||||||
|
|
@ -501,7 +479,6 @@ export const updateDeliveryStatus = async ({ orderGroupId, status, details, ship
|
||||||
.from("order_groups")
|
.from("order_groups")
|
||||||
.update({
|
.update({
|
||||||
delivery_status: status,
|
delivery_status: status,
|
||||||
status: statusSync,
|
|
||||||
paid_storage_at: new Date().toISOString(),
|
paid_storage_at: new Date().toISOString(),
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
|
|
@ -513,7 +490,6 @@ export const updateDeliveryStatus = async ({ orderGroupId, status, details, ship
|
||||||
.from("order_groups")
|
.from("order_groups")
|
||||||
.update({
|
.update({
|
||||||
delivery_status: status,
|
delivery_status: status,
|
||||||
status: statusSync,
|
|
||||||
paid_storage_at: null,
|
paid_storage_at: null,
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
|
|
@ -566,12 +542,12 @@ export const updateDeliveryStatus = async ({ orderGroupId, status, details, ship
|
||||||
}, "Ошибка обновления статуса доставки");
|
}, "Ошибка обновления статуса доставки");
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchOrderGroups = async ({ details = true } = {}) => {
|
export const fetchOrderGroups = async () => {
|
||||||
return safeSupabaseCall(async () => {
|
return safeSupabaseCall(async () => {
|
||||||
const client = requireSupabase();
|
const client = requireSupabase();
|
||||||
const { data, error } = await client
|
const { data, error } = await client
|
||||||
.from("order_groups")
|
.from("order_groups")
|
||||||
.select(details ? ORDER_GROUP_SELECT_FIELDS : ORDER_GROUP_LIST_SELECT_FIELDS)
|
.select(ORDER_GROUP_SELECT_FIELDS)
|
||||||
.order("updated_at", { ascending: false });
|
.order("updated_at", { ascending: false });
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|
|
||||||
|
|
@ -119,26 +119,9 @@ export const normalizeAvailableSlots = (availableSlots?: string[] | null) => {
|
||||||
return slots.length > 0 ? Array.from(new Set(slots)) : [...DEFAULT_AVAILABLE_SLOTS];
|
return slots.length > 0 ? Array.from(new Set(slots)) : [...DEFAULT_AVAILABLE_SLOTS];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildDefaultDatedAvailableSlots = async (now = new Date(), supabaseClient?: any) => {
|
export const buildDefaultDatedAvailableSlots = (now = new Date()) => {
|
||||||
const CRIMEA_TZ = "Europe/Simferopol";
|
const CRIMEA_TZ = "Europe/Simferopol";
|
||||||
|
|
||||||
// Fetch delivery days from business_schedule_settings
|
|
||||||
let deliveryDays: number[] = [1, 2, 3, 4, 5]; // Default: Mon-Fri
|
|
||||||
if (supabaseClient) {
|
|
||||||
try {
|
|
||||||
const { data } = await supabaseClient
|
|
||||||
.from("business_schedule_settings")
|
|
||||||
.select("delivery_days")
|
|
||||||
.eq("id", 1)
|
|
||||||
.single();
|
|
||||||
if (data?.delivery_days && Array.isArray(data.delivery_days) && data.delivery_days.length) {
|
|
||||||
deliveryDays = data.delivery_days;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Silent fallback to defaults
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatCrimeaDate = (date: Date) => {
|
const formatCrimeaDate = (date: Date) => {
|
||||||
return new Intl.DateTimeFormat("en-CA", {
|
return new Intl.DateTimeFormat("en-CA", {
|
||||||
timeZone: CRIMEA_TZ,
|
timeZone: CRIMEA_TZ,
|
||||||
|
|
@ -154,18 +137,12 @@ export const buildDefaultDatedAvailableSlots = async (now = new Date(), supabase
|
||||||
return next;
|
return next;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if date is an allowed delivery day
|
// Skip Sunday (getUTCDay() === 0) — never offer Sunday delivery
|
||||||
// getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
|
const isSunday = (date: Date) => date.getUTCDay() === 0;
|
||||||
// deliveryDays uses 1=Mon ... 7=Sun
|
|
||||||
const isAllowedDay = (date: Date) => {
|
|
||||||
const dow = date.getUTCDay();
|
|
||||||
const dayNum = dow === 0 ? 7 : dow;
|
|
||||||
return deliveryDays.includes(dayNum);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getNextWorkday = (date: Date) => {
|
const getNextWorkday = (date: Date) => {
|
||||||
let next = addDays(date, 1);
|
let next = addDays(date, 1);
|
||||||
while (!isAllowedDay(next)) {
|
while (isSunday(next)) {
|
||||||
next = addDays(next, 1);
|
next = addDays(next, 1);
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
|
|
@ -197,7 +174,7 @@ export const resolvePublicAppUrl = (
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildInvitationUrl = (baseUrl: string, token: string) =>
|
export const buildInvitationUrl = (baseUrl: string, token: string) =>
|
||||||
`${baseUrl.replace(/\/$/, "")}/client/${token}`;
|
`${baseUrl.replace(/\/$/, "")}/delivery/${token}`;
|
||||||
|
|
||||||
export type DeliveryInvitationRecord = {
|
export type DeliveryInvitationRecord = {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
|
|
||||||
|
|
@ -31,31 +31,11 @@ type ConfirmBody = {
|
||||||
|
|
||||||
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
|
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
|
||||||
|
|
||||||
// Fetch delivery days from business_schedule_settings
|
const isWeekendDate = (value: string) => {
|
||||||
const getDeliveryDays = async (supabaseClient: any): Promise<number[]> => {
|
|
||||||
try {
|
|
||||||
const { data } = await supabaseClient
|
|
||||||
.from("business_schedule_settings")
|
|
||||||
.select("delivery_days")
|
|
||||||
.eq("id", 1)
|
|
||||||
.single();
|
|
||||||
if (data?.delivery_days && Array.isArray(data.delivery_days) && data.delivery_days.length) {
|
|
||||||
return data.delivery_days as number[];
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Silent fallback
|
|
||||||
}
|
|
||||||
return [1, 2, 3, 4, 5]; // Default: Mon-Fri
|
|
||||||
};
|
|
||||||
|
|
||||||
const isAllowedDeliveryDate = (value: string, deliveryDays: number[]) => {
|
|
||||||
if (!isValidDate(value)) return false;
|
if (!isValidDate(value)) return false;
|
||||||
const date = new Date(`${value}T12:00:00Z`);
|
const date = new Date(`${value}T12:00:00Z`);
|
||||||
const dow = date.getUTCDay();
|
const weekday = date.getUTCDay();
|
||||||
// getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
|
return weekday === 0; // 0=Sunday — never allow Sunday delivery
|
||||||
// deliveryDays uses 1=Mon ... 7=Sun
|
|
||||||
const dayNum = dow === 0 ? 7 : dow;
|
|
||||||
return deliveryDays.includes(dayNum);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveRequestedSlot = (
|
const resolveRequestedSlot = (
|
||||||
|
|
@ -65,7 +45,6 @@ const resolveRequestedSlot = (
|
||||||
available_slots?: string[] | null;
|
available_slots?: string[] | null;
|
||||||
},
|
},
|
||||||
body: ConfirmBody,
|
body: ConfirmBody,
|
||||||
deliveryDays: number[] = [1, 2, 3, 4, 5],
|
|
||||||
) => {
|
) => {
|
||||||
const deliveryType = body.deliveryType || "delivery";
|
const deliveryType = body.deliveryType || "delivery";
|
||||||
const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim();
|
const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim();
|
||||||
|
|
@ -80,8 +59,8 @@ const resolveRequestedSlot = (
|
||||||
return { deliveryDate, deliveryTime, deliveryType };
|
return { deliveryDate, deliveryTime, deliveryType };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject non-delivery days for delivery (business schedule)
|
// Reject Sunday for delivery (business rule: never deliver on Sunday)
|
||||||
if (!isAllowedDeliveryDate(deliveryDate, deliveryDays)) {
|
if (isWeekendDate(deliveryDate)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -141,7 +120,6 @@ Deno.serve(async (request) => {
|
||||||
|
|
||||||
const tokenHash = await hashInvitationToken(body.token);
|
const tokenHash = await hashInvitationToken(body.token);
|
||||||
const supabase = createServiceClient();
|
const supabase = createServiceClient();
|
||||||
const deliveryDays = await getDeliveryDays(supabase);
|
|
||||||
const ipHash = await hashText(getClientIp(request));
|
const ipHash = await hashText(getClientIp(request));
|
||||||
|
|
||||||
await requireRateLimit(supabase, {
|
await requireRateLimit(supabase, {
|
||||||
|
|
@ -202,7 +180,7 @@ Deno.serve(async (request) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestedSlot = resolveRequestedSlot(invitation, body, deliveryDays);
|
const requestedSlot = resolveRequestedSlot(invitation, body);
|
||||||
if (!requestedSlot) {
|
if (!requestedSlot) {
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,165 +0,0 @@
|
||||||
import {
|
|
||||||
createServiceClient,
|
|
||||||
getCorsHeaders,
|
|
||||||
jsonResponse,
|
|
||||||
preflightResponse,
|
|
||||||
readJsonBody,
|
|
||||||
} from "../_shared/security.ts";
|
|
||||||
|
|
||||||
const MAX_BODY_BYTES = 8 * 1024;
|
|
||||||
|
|
||||||
const ADMIN_ROLES = new Set(["admin", "mega_admin"]);
|
|
||||||
|
|
||||||
const isValidEmail = (value: string) =>
|
|
||||||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify the caller's JWT and return their role from public.users.
|
|
||||||
* Returns null if not authorized.
|
|
||||||
*/
|
|
||||||
async function getCallerRole(request: Request): Promise<string | null> {
|
|
||||||
const authHeader = request.headers.get("authorization") || "";
|
|
||||||
const token = authHeader.replace(/^Bearer\s+/i, "").trim();
|
|
||||||
if (!token) return null;
|
|
||||||
|
|
||||||
const supabase = createServiceClient();
|
|
||||||
const { data: userData, error: userError } = await supabase.auth.getUser(token);
|
|
||||||
if (userError || !userData?.user) return null;
|
|
||||||
|
|
||||||
const userId = userData.user.id;
|
|
||||||
const { data: userRow, error: roleError } = await supabase
|
|
||||||
.from("users")
|
|
||||||
.select("roles(name)")
|
|
||||||
.eq("id", userId)
|
|
||||||
.maybeSingle();
|
|
||||||
if (roleError || !userRow) return null;
|
|
||||||
|
|
||||||
return userRow.roles?.name || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Deno.serve(async (request) => {
|
|
||||||
if (request.method === "OPTIONS") {
|
|
||||||
return preflightResponse(request, "private");
|
|
||||||
}
|
|
||||||
|
|
||||||
const corsHeaders = getCorsHeaders(request, "private");
|
|
||||||
if (!corsHeaders) {
|
|
||||||
return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// ── Authorization ──
|
|
||||||
const callerRole = await getCallerRole(request);
|
|
||||||
if (!callerRole || !ADMIN_ROLES.has(callerRole)) {
|
|
||||||
return jsonResponse(
|
|
||||||
{ ok: false, error: "Недостаточно прав. Требуется роль admin или mega_admin." },
|
|
||||||
403,
|
|
||||||
corsHeaders,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabase = createServiceClient();
|
|
||||||
|
|
||||||
// ── POST: create new user ──
|
|
||||||
if (request.method === "POST") {
|
|
||||||
const { body } = await readJsonBody<{ email?: string; name?: string; role?: string }>(
|
|
||||||
request,
|
|
||||||
{ maxBytes: MAX_BODY_BYTES },
|
|
||||||
);
|
|
||||||
|
|
||||||
const email = String(body.email || "").trim().toLowerCase();
|
|
||||||
const name = String(body.name || "").trim();
|
|
||||||
const role = String(body.role || "").trim().toLowerCase();
|
|
||||||
|
|
||||||
if (!email || !isValidEmail(email)) {
|
|
||||||
return jsonResponse({ ok: false, error: "Некорректный email" }, 400, corsHeaders);
|
|
||||||
}
|
|
||||||
if (!name) {
|
|
||||||
return jsonResponse({ ok: false, error: "Имя обязательно" }, 400, corsHeaders);
|
|
||||||
}
|
|
||||||
if (!role) {
|
|
||||||
return jsonResponse({ ok: false, error: "Роль обязательна" }, 400, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if email already exists in public.users
|
|
||||||
const { data: existingUser } = await supabase
|
|
||||||
.from("users")
|
|
||||||
.select("id")
|
|
||||||
.eq("email", email)
|
|
||||||
.maybeSingle();
|
|
||||||
if (existingUser) {
|
|
||||||
return jsonResponse({ ok: false, error: "Пользователь с таким email уже существует" }, 409, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create auth user — trigger handle_new_user will auto-insert into public.users
|
|
||||||
// using user_metadata.role and user_metadata.name
|
|
||||||
const { data: authData, error: authError } = await supabase.auth.admin.createUser({
|
|
||||||
email,
|
|
||||||
email_confirm: true,
|
|
||||||
user_metadata: { name, role },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (authError) {
|
|
||||||
console.error("auth.createUser error:", authError);
|
|
||||||
return jsonResponse(
|
|
||||||
{ ok: false, error: "Ошибка создания пользователя: " + authError.message },
|
|
||||||
500,
|
|
||||||
corsHeaders,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newUserId = authData.user.id;
|
|
||||||
|
|
||||||
return jsonResponse({ ok: true, data: { id: newUserId, email, name, role } }, 201, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── DELETE: remove user ──
|
|
||||||
if (request.method === "DELETE") {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const userId = url.searchParams.get("id");
|
|
||||||
if (!userId) {
|
|
||||||
return jsonResponse({ ok: false, error: "Параметр id обязателен" }, 400, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user info before deletion
|
|
||||||
const { data: userRow } = await supabase
|
|
||||||
.from("users")
|
|
||||||
.select("id, email, name")
|
|
||||||
.eq("id", userId)
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
if (!userRow) {
|
|
||||||
return jsonResponse({ ok: false, error: "Пользователь не найден" }, 404, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete auth user (FK ON DELETE CASCADE will remove public.users row)
|
|
||||||
const { error: authDeleteError } = await supabase.auth.admin.deleteUser(userId);
|
|
||||||
if (authDeleteError) {
|
|
||||||
console.error("auth.deleteUser error:", authDeleteError);
|
|
||||||
// Try deleting public.users directly as fallback
|
|
||||||
const { error: dbDeleteError } = await supabase.from("users").delete().eq("id", userId);
|
|
||||||
if (dbDeleteError) {
|
|
||||||
return jsonResponse(
|
|
||||||
{ ok: false, error: "Ошибка удаления: " + authDeleteError.message },
|
|
||||||
500,
|
|
||||||
corsHeaders,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse({ ok: true, data: { id: userId } }, 200, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse({ ok: false, error: "Method not allowed" }, 405, corsHeaders);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && "status" in error) {
|
|
||||||
const httpError = error as { status: number; message: string };
|
|
||||||
return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders);
|
|
||||||
}
|
|
||||||
return jsonResponse(
|
|
||||||
{ ok: false, error: error instanceof Error ? error.message : "Unexpected error" },
|
|
||||||
500,
|
|
||||||
corsHeaders,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -119,63 +119,16 @@ export const normalizeAvailableSlots = (availableSlots?: string[] | null) => {
|
||||||
return slots.length > 0 ? Array.from(new Set(slots)) : [...DEFAULT_AVAILABLE_SLOTS];
|
return slots.length > 0 ? Array.from(new Set(slots)) : [...DEFAULT_AVAILABLE_SLOTS];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildDefaultDatedAvailableSlots = async (now = new Date(), supabaseClient?: any) => {
|
export const buildDefaultDatedAvailableSlots = (now = new Date()) => {
|
||||||
const CRIMEA_TZ = "Europe/Simferopol";
|
const formatIsoDate = (date: Date) => date.toISOString().slice(0, 10);
|
||||||
|
|
||||||
// Fetch delivery days from business_schedule_settings
|
|
||||||
let deliveryDays: number[] = [1, 2, 3, 4, 5]; // Default: Mon-Fri
|
|
||||||
if (supabaseClient) {
|
|
||||||
try {
|
|
||||||
const { data } = await supabaseClient
|
|
||||||
.from("business_schedule_settings")
|
|
||||||
.select("delivery_days")
|
|
||||||
.eq("id", 1)
|
|
||||||
.single();
|
|
||||||
if (data?.delivery_days && Array.isArray(data.delivery_days) && data.delivery_days.length) {
|
|
||||||
deliveryDays = data.delivery_days;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Silent fallback to defaults
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatCrimeaDate = (date: Date) => {
|
|
||||||
return new Intl.DateTimeFormat("en-CA", {
|
|
||||||
timeZone: CRIMEA_TZ,
|
|
||||||
year: "numeric",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
}).format(date);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addDays = (date: Date, days: number) => {
|
const addDays = (date: Date, days: number) => {
|
||||||
const next = new Date(date);
|
const next = new Date(date);
|
||||||
next.setUTCDate(next.getUTCDate() + days);
|
next.setUTCDate(next.getUTCDate() + days);
|
||||||
return next;
|
return next;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if date is an allowed delivery day
|
const firstDay = formatIsoDate(addDays(now, 1));
|
||||||
// getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
|
const secondDay = formatIsoDate(addDays(now, 2));
|
||||||
// deliveryDays uses 1=Mon ... 7=Sun
|
|
||||||
const isAllowedDay = (date: Date) => {
|
|
||||||
const dow = date.getUTCDay();
|
|
||||||
const dayNum = dow === 0 ? 7 : dow;
|
|
||||||
return deliveryDays.includes(dayNum);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getNextWorkday = (date: Date) => {
|
|
||||||
let next = addDays(date, 1);
|
|
||||||
while (!isAllowedDay(next)) {
|
|
||||||
next = addDays(next, 1);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
};
|
|
||||||
|
|
||||||
const firstWorkday = getNextWorkday(now);
|
|
||||||
const secondWorkday = getNextWorkday(firstWorkday);
|
|
||||||
|
|
||||||
const firstDay = formatCrimeaDate(firstWorkday);
|
|
||||||
const secondDay = formatCrimeaDate(secondWorkday);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
`${firstDay}, Первая половина дня`,
|
`${firstDay}, Первая половина дня`,
|
||||||
|
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
import { createClient } from "npm:@supabase/supabase-js@2";
|
|
||||||
|
|
||||||
const ALLOWED_ORIGINS = [
|
|
||||||
"https://dost.supersamsev.ru",
|
|
||||||
"https://supa.supersamsev.ru",
|
|
||||||
"http://localhost:5173",
|
|
||||||
];
|
|
||||||
|
|
||||||
const SMS_STATUS_URL = "https://sms.ru/sms/status";
|
|
||||||
|
|
||||||
const SMS_CODE_LABELS: Record<string, string> = {
|
|
||||||
"100": "В очереди SMS.ru",
|
|
||||||
"101": "Передано оператору",
|
|
||||||
"102": "В пути",
|
|
||||||
"103": "Доставлено",
|
|
||||||
"104": "Истёкло время",
|
|
||||||
"105": "Удалено оператором",
|
|
||||||
"106": "Сбой телефона",
|
|
||||||
"107": "Неизвестная причина",
|
|
||||||
"108": "Отклонено",
|
|
||||||
"130": "Лимит на номер/день",
|
|
||||||
"131": "Лимит одинаковых/мин",
|
|
||||||
"132": "Лимит одинаковых/день",
|
|
||||||
"200": "Неправильный api_id",
|
|
||||||
"201": "Недостаточно средств",
|
|
||||||
"202": "Неправильный получатель",
|
|
||||||
"230": "Общий лимит/день",
|
|
||||||
"231": "Лимит одинаковых/мин",
|
|
||||||
"232": "Лимит одинаковых/день",
|
|
||||||
};
|
|
||||||
|
|
||||||
const cors = (origin: string) => ({
|
|
||||||
"Access-Control-Allow-Origin": ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0],
|
|
||||||
"Access-Control-Allow-Methods": "POST,OPTIONS",
|
|
||||||
"Access-Control-Allow-Headers": "Content-Type,Authorization,apikey",
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.serve(async (req: Request) => {
|
|
||||||
const origin = req.headers.get("origin") || "";
|
|
||||||
const headers = { ...cors(origin), "Content-Type": "application/json" };
|
|
||||||
|
|
||||||
if (req.method === "OPTIONS") return new Response(null, { headers });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { log_id } = await req.json();
|
|
||||||
if (!log_id) return new Response(JSON.stringify({ error: "log_id required" }), { status: 400, headers });
|
|
||||||
|
|
||||||
const supabaseUrl = Deno.env.get("SUPABASE_URL") || "";
|
|
||||||
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || "";
|
|
||||||
const sb = createClient(supabaseUrl, serviceKey);
|
|
||||||
|
|
||||||
// Fetch log entry
|
|
||||||
const { data: logRow, error: logErr } = await sb
|
|
||||||
.from("sms_campaign_log")
|
|
||||||
.select("id, sms_id, campaign_type, order_group_id, attempts")
|
|
||||||
.eq("id", log_id)
|
|
||||||
.single();
|
|
||||||
if (logErr || !logRow) return new Response(JSON.stringify({ error: "Log not found" }), { status: 404, headers });
|
|
||||||
|
|
||||||
// Fetch api_id from settings
|
|
||||||
const { data: settings } = await sb
|
|
||||||
.from("sms_campaign_settings")
|
|
||||||
.select("sms_api_id")
|
|
||||||
.eq("campaign_type", logRow.campaign_type)
|
|
||||||
.single();
|
|
||||||
const apiId = settings?.sms_api_id || Deno.env.get("SMS_API_ID") || "";
|
|
||||||
|
|
||||||
if (!logRow.sms_id) return new Response(JSON.stringify({ error: "No sms_id in log" }), { status: 400, headers });
|
|
||||||
|
|
||||||
// Call SMS.ru status API
|
|
||||||
const formData = new URLSearchParams();
|
|
||||||
formData.append("api_id", apiId);
|
|
||||||
formData.append("sms_id", logRow.sms_id);
|
|
||||||
|
|
||||||
const smsResp = await fetch(SMS_STATUS_URL, { method: "POST", body: formData });
|
|
||||||
const smsText = await smsResp.text();
|
|
||||||
const lines = smsText.split("\n").map((l: string) => l.trim());
|
|
||||||
const code = lines[0] || "";
|
|
||||||
|
|
||||||
// Determine status
|
|
||||||
const status =
|
|
||||||
code === "103" ? "delivered" :
|
|
||||||
["100", "101", "102"].includes(code) ? "checking" :
|
|
||||||
["104", "105", "106", "107", "108", "130"].includes(code) ? "error" :
|
|
||||||
["131", "132", "230", "231", "232"].includes(code) ? "limit_exceeded" :
|
|
||||||
"checking";
|
|
||||||
|
|
||||||
// Update log entry
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
await sb.from("sms_campaign_log").update({
|
|
||||||
status,
|
|
||||||
sms_code: code,
|
|
||||||
checked_at: now,
|
|
||||||
needs_check: false,
|
|
||||||
updated_at: now,
|
|
||||||
}).eq("id", log_id);
|
|
||||||
|
|
||||||
// Update order_groups if delivered
|
|
||||||
if (code === "103" && logRow.order_group_id) {
|
|
||||||
const nextCheck = new Date(Date.now() + 3 * 3600 * 1000).toISOString();
|
|
||||||
if (logRow.campaign_type === "first_sms") {
|
|
||||||
await sb.from("order_groups").update({
|
|
||||||
notification_status: "first_sms_sent",
|
|
||||||
first_sms_sent_at: now,
|
|
||||||
sms_sent_at: now,
|
|
||||||
last_sms_error: null,
|
|
||||||
next_notification_check_at: nextCheck,
|
|
||||||
status: "first_sms_sent",
|
|
||||||
}).eq("id", logRow.order_group_id);
|
|
||||||
} else if (logRow.campaign_type === "second_sms") {
|
|
||||||
await sb.from("order_groups").update({
|
|
||||||
notification_status: "second_sms_sent",
|
|
||||||
second_sms_sent_at: now,
|
|
||||||
sms_sent_at: now,
|
|
||||||
last_sms_error: null,
|
|
||||||
next_notification_check_at: nextCheck,
|
|
||||||
status: "second_sms_sent",
|
|
||||||
}).eq("id", logRow.order_group_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
success: true,
|
|
||||||
sms_id: logRow.sms_id,
|
|
||||||
code,
|
|
||||||
status,
|
|
||||||
label: SMS_CODE_LABELS[code] || "Код " + code,
|
|
||||||
}), { headers });
|
|
||||||
} catch (e) {
|
|
||||||
return new Response(JSON.stringify({ error: String(e) }), { status: 500, headers });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -31,31 +31,11 @@ type ConfirmBody = {
|
||||||
|
|
||||||
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
|
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
|
||||||
|
|
||||||
// Fetch delivery days from business_schedule_settings
|
const isWeekendDate = (value: string) => {
|
||||||
const getDeliveryDays = async (supabaseClient: any): Promise<number[]> => {
|
|
||||||
try {
|
|
||||||
const { data } = await supabaseClient
|
|
||||||
.from("business_schedule_settings")
|
|
||||||
.select("delivery_days")
|
|
||||||
.eq("id", 1)
|
|
||||||
.single();
|
|
||||||
if (data?.delivery_days && Array.isArray(data.delivery_days) && data.delivery_days.length) {
|
|
||||||
return data.delivery_days as number[];
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Silent fallback
|
|
||||||
}
|
|
||||||
return [1, 2, 3, 4, 5]; // Default: Mon-Fri
|
|
||||||
};
|
|
||||||
|
|
||||||
const isAllowedDeliveryDate = (value: string, deliveryDays: number[]) => {
|
|
||||||
if (!isValidDate(value)) return false;
|
if (!isValidDate(value)) return false;
|
||||||
const date = new Date(`${value}T12:00:00Z`);
|
const date = new Date(`${value}T12:00:00Z`);
|
||||||
const dow = date.getUTCDay();
|
const weekday = date.getUTCDay();
|
||||||
// getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
|
return weekday === 0; // 0=Sunday — never allow Sunday delivery
|
||||||
// deliveryDays uses 1=Mon ... 7=Sun
|
|
||||||
const dayNum = dow === 0 ? 7 : dow;
|
|
||||||
return deliveryDays.includes(dayNum);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveRequestedSlot = (
|
const resolveRequestedSlot = (
|
||||||
|
|
@ -65,7 +45,6 @@ const resolveRequestedSlot = (
|
||||||
available_slots?: string[] | null;
|
available_slots?: string[] | null;
|
||||||
},
|
},
|
||||||
body: ConfirmBody,
|
body: ConfirmBody,
|
||||||
deliveryDays: number[] = [1, 2, 3, 4, 5],
|
|
||||||
) => {
|
) => {
|
||||||
const deliveryType = body.deliveryType || "delivery";
|
const deliveryType = body.deliveryType || "delivery";
|
||||||
const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim();
|
const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim();
|
||||||
|
|
@ -80,8 +59,8 @@ const resolveRequestedSlot = (
|
||||||
return { deliveryDate, deliveryTime, deliveryType };
|
return { deliveryDate, deliveryTime, deliveryType };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject non-delivery days for delivery (business schedule)
|
// Reject Sunday for delivery (business rule: never deliver on Sunday)
|
||||||
if (!isAllowedDeliveryDate(deliveryDate, deliveryDays)) {
|
if (isWeekendDate(deliveryDate)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -141,7 +120,6 @@ Deno.serve(async (request) => {
|
||||||
|
|
||||||
const tokenHash = await hashInvitationToken(body.token);
|
const tokenHash = await hashInvitationToken(body.token);
|
||||||
const supabase = createServiceClient();
|
const supabase = createServiceClient();
|
||||||
const deliveryDays = await getDeliveryDays(supabase);
|
|
||||||
const ipHash = await hashText(getClientIp(request));
|
const ipHash = await hashText(getClientIp(request));
|
||||||
|
|
||||||
await requireRateLimit(supabase, {
|
await requireRateLimit(supabase, {
|
||||||
|
|
@ -202,7 +180,7 @@ Deno.serve(async (request) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestedSlot = resolveRequestedSlot(invitation, body, deliveryDays);
|
const requestedSlot = resolveRequestedSlot(invitation, body);
|
||||||
if (!requestedSlot) {
|
if (!requestedSlot) {
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Gitea webhook listener — auto-deploys supersam on push to main."""
|
|
||||||
import hmac, hashlib, subprocess, json, os, logging
|
|
||||||
from flask import Flask, request, abort
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
|
||||||
SECRET = os.environ.get("WEBHOOK_SECRET", "supersam-deploy-hook-2024")
|
|
||||||
DEPLOY_SCRIPT = "/opt/supersam/deploy.sh"
|
|
||||||
LOG = "/var/log/supersam-deploy.log"
|
|
||||||
|
|
||||||
logging.basicConfig(filename=LOG, level=logging.INFO, format="%(asctime)s %(message)s")
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
def verify_signature(payload, sig_header):
|
|
||||||
if not sig_header:
|
|
||||||
return False
|
|
||||||
mac = hmac.new(SECRET.encode(), payload, hashlib.sha256).hexdigest()
|
|
||||||
return hmac.compare(mac, sig_header)
|
|
||||||
|
|
||||||
@app.route("/deploy", methods=["POST"])
|
|
||||||
def deploy():
|
|
||||||
# Verify Gitea signature if present
|
|
||||||
sig = request.headers.get("X-Gitea-Signature", "")
|
|
||||||
if not verify_signature(request.data, sig):
|
|
||||||
logger.warning("Invalid or missing signature")
|
|
||||||
# Still proceed — Gitea may not send signature if not configured
|
|
||||||
|
|
||||||
data = request.json or {}
|
|
||||||
ref = data.get("ref", "")
|
|
||||||
repo = data.get("repository", {}).get("name", "")
|
|
||||||
|
|
||||||
# Only deploy on push to main
|
|
||||||
if ref != "refs/heads/main":
|
|
||||||
logger.info(f"Ignored push to {ref}")
|
|
||||||
return {"status": "ignored", "ref": ref}, 200
|
|
||||||
|
|
||||||
logger.info(f"Deploy triggered by push to {ref} in {repo}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
[DEPLOY_SCRIPT],
|
|
||||||
capture_output=True, text=True, timeout=300
|
|
||||||
)
|
|
||||||
logger.info(f"Deploy exit={result.returncode}")
|
|
||||||
if result.returncode != 0:
|
|
||||||
logger.error(f"Deploy stderr: {result.stderr}")
|
|
||||||
return {"status": "error", "output": result.stderr}, 500
|
|
||||||
return {"status": "ok", "output": result.stdout[-500:]}, 200
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
logger.error("Deploy timed out")
|
|
||||||
return {"status": "timeout"}, 500
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
app.run(host="127.0.0.1", port=9765)
|
|
||||||
Loading…
Reference in New Issue