merge: server fixes + suggestions voting
This commit is contained in:
commit
319761a24c
|
|
@ -8,3 +8,6 @@ dist
|
||||||
.superpowers
|
.superpowers
|
||||||
.ruff_cache
|
.ruff_cache
|
||||||
volumes/db/data/
|
volumes/db/data/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.bak
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Supersam full DB backup (pg_dump) to S3 (Beget Cloud Storage)
|
||||||
|
# Dumps the complete supabase postgres database and uploads to S3.
|
||||||
|
# Retention: keeps last 30 days.
|
||||||
|
#
|
||||||
|
# Cron: 0 */12 * * * /opt/supersam/scripts/backup-pgdump-s3.sh >> /var/log/supersam-backup-pgdump.log 2>&1
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── Config ────────────────────────────────────────────────────────────────
|
||||||
|
AWS_ACCESS_KEY_ID="YG4MQNKAPNL65200MBUY"
|
||||||
|
AWS_SECRET_ACCESS_KEY="8mXkFM2VRQ3pN1Nx4mhmJ2jrZoB5YTPUa4CaZh43"
|
||||||
|
S3_ENDPOINT="https://s3.ru1.storage.beget.cloud"
|
||||||
|
S3_BUCKET="02f162ff4a18-supersam-s3"
|
||||||
|
S3_PREFIX="backups"
|
||||||
|
|
||||||
|
DB_CONTAINER="supabase-db"
|
||||||
|
DB_USER="supabase_admin"
|
||||||
|
DB_NAME="postgres"
|
||||||
|
DB_PASS="4fe80bb21c7c3d17a8d8b226adf7a479"
|
||||||
|
|
||||||
|
RETENTION_DAYS=30
|
||||||
|
|
||||||
|
# ── Runtime ───────────────────────────────────────────────────────────────
|
||||||
|
TIMESTAMP=$(date +%Y-%m-%d_%H%M)
|
||||||
|
DUMP_FILE="/tmp/supersam-dump-${TIMESTAMP}.sql.gz"
|
||||||
|
S3_PATH="s3://${S3_BUCKET}/${S3_PREFIX}/pgdump/supersam-db-${TIMESTAMP}.sql.gz"
|
||||||
|
|
||||||
|
export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY
|
||||||
|
|
||||||
|
echo "[$(date)] Starting pg_dump backup..."
|
||||||
|
|
||||||
|
# ── Dump ──────────────────────────────────────────────────────────────────
|
||||||
|
echo "[$(date)] Dumping database..."
|
||||||
|
docker exec -e PGPASSWORD="${DB_PASS}" "${DB_CONTAINER}" \
|
||||||
|
pg_dump -U "${DB_USER}" -d "${DB_NAME}" --no-owner --no-acl --clean --if-exists \
|
||||||
|
| gzip -9 > "${DUMP_FILE}"
|
||||||
|
|
||||||
|
DUMP_SIZE=$(du -h "${DUMP_FILE}" | cut -f1)
|
||||||
|
echo "[$(date)] Dump created: ${DUMP_FILE} (${DUMP_SIZE})"
|
||||||
|
|
||||||
|
# ── Upload via mc ─────────────────────────────────────────────────────────
|
||||||
|
echo "[$(date)] Uploading to S3..."
|
||||||
|
mc cp "${DUMP_FILE}" "beget/${S3_BUCKET}/${S3_PREFIX}/pgdump/supersam-db-${TIMESTAMP}.sql.gz" --quiet 2>&1
|
||||||
|
|
||||||
|
echo "[$(date)] Uploaded to ${S3_PATH}"
|
||||||
|
|
||||||
|
# ── Cleanup local ─────────────────────────────────────────────────────────
|
||||||
|
rm -f "${DUMP_FILE}"
|
||||||
|
echo "[$(date)] Local cleanup done"
|
||||||
|
|
||||||
|
# ── Retention: delete S3 pgdump backups older than RETENTION_DAYS ─────────
|
||||||
|
echo "[$(date)] Cleaning S3 pgdump backups older than ${RETENTION_DAYS} days..."
|
||||||
|
CUTOFF=$(date -d "-${RETENTION_DAYS} days" +%Y-%m-%d)
|
||||||
|
mc ls "beget/${S3_BUCKET}/${S3_PREFIX}/pgdump/" 2>/dev/null \
|
||||||
|
| awk '{print $NF}' \
|
||||||
|
| while read -r fname; do
|
||||||
|
file_date=$(echo "$fname" | grep -oP '\d{4}-\d{2}-\d{2}' || echo "")
|
||||||
|
if [ -n "$file_date" ] && [ "$file_date" \< "$CUTOFF" ]; then
|
||||||
|
mc rm "beget/${S3_BUCKET}/${S3_PREFIX}/pgdump/${fname}" --quiet 2>/dev/null
|
||||||
|
echo "[$(date)] Deleted old backup: ${fname}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[$(date)] Backup complete."
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Resolve supabase-db container IP dynamically
|
||||||
|
DB_IP=$(docker inspect supabase-db --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' 2>/dev/null)
|
||||||
|
if [ -z "$DB_IP" ]; then
|
||||||
|
echo "ERROR: Cannot resolve supabase-db IP" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
export DB_HOST=$DB_IP
|
||||||
|
export DB_PORT=5432
|
||||||
|
export DB_NAME=postgres
|
||||||
|
export DB_USER=supabase_admin
|
||||||
|
export DB_PASS=4fe80bb21c7c3d17a8d8b226adf7a479
|
||||||
|
exec python3 "$@"
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# sms_timer_manager.sh — reconciles DB flags with systemd timers
|
||||||
|
# Runs every 1 min via cron:
|
||||||
|
# 1. timer_active → enable/disable systemd timer
|
||||||
|
# 2. run_requested → run campaign script immediately (script resets flag itself)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DB_HOST="10.0.4.12"
|
||||||
|
DB_PORT="5432"
|
||||||
|
DB_NAME="postgres"
|
||||||
|
DB_USER="supabase_admin"
|
||||||
|
DB_PASS="4fe80bb21c7c3d17a8d8b226adf7a479"
|
||||||
|
|
||||||
|
declare -A TIMERS=(
|
||||||
|
["first_sms"]="sms-first-campaign.timer"
|
||||||
|
["second_sms"]="sms-second-campaign.timer"
|
||||||
|
["manual"]="sms-manual-campaign.timer"
|
||||||
|
["paid_storage"]="sms-paid-storage-campaign.timer"
|
||||||
|
)
|
||||||
|
declare -A SERVICES=(
|
||||||
|
["first_sms"]="sms-first-campaign.service"
|
||||||
|
["second_sms"]="sms-second-campaign.service"
|
||||||
|
["manual"]="sms-manual-campaign.service"
|
||||||
|
["paid_storage"]="sms-paid-storage-campaign.service"
|
||||||
|
)
|
||||||
|
declare -A SCRIPTS=(
|
||||||
|
["first_sms"]="/opt/supersam/scripts/sms_first_campaign.py"
|
||||||
|
["second_sms"]="/opt/supersam/scripts/sms_second_campaign.py"
|
||||||
|
["manual"]="/opt/supersam/scripts/sms_manual_campaign.py"
|
||||||
|
["paid_storage"]="/opt/supersam/scripts/sms_paid_storage_campaign.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
QUERY="SELECT campaign_type, timer_active, run_requested FROM sms_campaign_settings"
|
||||||
|
RESULTS=$(PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -A -F '|' -c "$QUERY" 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
if [ -z "$RESULTS" ]; then
|
||||||
|
echo "$(date): Failed to query DB, skipping"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
while IFS='|' read -r campaign_type timer_active run_requested; do
|
||||||
|
[ -z "$campaign_type" ] && continue
|
||||||
|
timer_name="${TIMERS[$campaign_type]:-}"
|
||||||
|
service_name="${SERVICES[$campaign_type]:-}"
|
||||||
|
script_path="${SCRIPTS[$campaign_type]:-}"
|
||||||
|
[ -z "$timer_name" ] && continue
|
||||||
|
|
||||||
|
if ! systemctl list-unit-files "$timer_name" 2>/dev/null | grep -q "$timer_name"; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 1. Timer enable/disable ──
|
||||||
|
is_active=$(systemctl is-active "$timer_name" 2>/dev/null || echo "inactive")
|
||||||
|
|
||||||
|
if [ "$timer_active" = "t" ] || [ "$timer_active" = "true" ]; then
|
||||||
|
if [ "$is_active" != "active" ]; then
|
||||||
|
systemctl enable --now "$timer_name" 2>/dev/null
|
||||||
|
echo "$(date): ENABLED $timer_name for $campaign_type"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if [ "$is_active" = "active" ]; then
|
||||||
|
systemctl disable --now "$timer_name" 2>/dev/null
|
||||||
|
echo "$(date): DISABLED $timer_name for $campaign_type"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 2. Run requested → immediate execution ──
|
||||||
|
# Don't reset flag here — script reads it from DB and resets after processing
|
||||||
|
if [ "$run_requested" = "t" ] || [ "$run_requested" = "true" ]; then
|
||||||
|
is_running=$(systemctl is-active "$service_name" 2>/dev/null || echo "inactive")
|
||||||
|
if [ "$is_running" = "active" ]; then
|
||||||
|
echo "$(date): $campaign_type already running, skipping run_requested"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$(date): RUN REQUESTED — starting $campaign_type immediately"
|
||||||
|
systemctl start "$service_name" 2>/dev/null || {
|
||||||
|
nohup python3 "$script_path" >> "/var/log/supersam-sms-${campaign_type}.log" 2>&1 &
|
||||||
|
echo "$(date): Started $campaign_type via nohup fallback"
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
|
done <<< "$RESULTS"
|
||||||
|
|
@ -0,0 +1,216 @@
|
||||||
|
import React from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { ROLE_LABELS } from "../constants/roles";
|
||||||
|
import { Badge } from "../components/UI/Badge";
|
||||||
|
import { Button } from "../components/UI/Button";
|
||||||
|
import { Panel } from "../components/UI/Panel";
|
||||||
|
import { ThemeToggle } from "../components/UI/ThemeToggle";
|
||||||
|
import { PwaInstallButton } from "../components/UI/PwaInstallButton";
|
||||||
|
import { NotificationBell } from "../components/notifications/NotificationBell";
|
||||||
|
import { NotificationSettings } from "../components/notifications/NotificationSettings";
|
||||||
|
|
||||||
|
export const AppShell = ({
|
||||||
|
user,
|
||||||
|
onInstallApp,
|
||||||
|
isInstalled,
|
||||||
|
isInstallAvailable,
|
||||||
|
onSignOut,
|
||||||
|
onOpenGuide,
|
||||||
|
isGuideOpen = false,
|
||||||
|
navItems,
|
||||||
|
activeSection,
|
||||||
|
onSectionChange,
|
||||||
|
sectionMeta,
|
||||||
|
notifications = [],
|
||||||
|
unreadCount = 0,
|
||||||
|
onMarkNotificationRead,
|
||||||
|
onMarkAllNotificationsRead,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const shouldShowMobileNav = !isGuideOpen && navItems.length > 1;
|
||||||
|
const [showNotifSettings, setShowNotifSettings] = React.useState(false);
|
||||||
|
|
||||||
|
if (showNotifSettings) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen px-3 py-4 sm:px-4 md:px-6 md:py-8">
|
||||||
|
<div className="mx-auto max-w-2xl">
|
||||||
|
<NotificationSettings
|
||||||
|
userId={user?.id}
|
||||||
|
userRole={user?.role}
|
||||||
|
onBack={() => setShowNotifSettings(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen px-3 py-4 sm:px-4 md:px-6 md:py-8">
|
||||||
|
<div className="mx-auto max-w-[1540px] space-y-4 xl:grid xl:grid-cols-[220px_1fr] xl:gap-8 xl:space-y-0">
|
||||||
|
{/* Desktop sidebar */}
|
||||||
|
<Panel className="fs-zone-nav hidden h-fit flex-col gap-5 p-4 xl:flex">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.24em] text-[var(--color-text-muted)]">
|
||||||
|
Панель
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-2 text-lg font-semibold leading-tight">Управление доставкой</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item.key}
|
||||||
|
variant="ghost"
|
||||||
|
className={[
|
||||||
|
"flex w-full items-center justify-between rounded-[18px] px-3 py-3 text-left text-sm transition",
|
||||||
|
activeSection === item.key
|
||||||
|
? "bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
||||||
|
: "text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-text)]",
|
||||||
|
].join(" ")}
|
||||||
|
onClick={() => onSectionChange(item.key)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="font-medium">{item.label}</span>
|
||||||
|
{item.badge ? <Badge tone="accent">{item.badge}</Badge> : null}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-auto">
|
||||||
|
{onOpenGuide ? (
|
||||||
|
<Button variant="ghost" className="mb-2 w-full justify-start" onClick={onOpenGuide}>
|
||||||
|
{isGuideOpen ? "К рабочей области" : "Справка"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button variant="ghost" className="mb-2 w-full justify-start" onClick={() => navigate("/settings")}>
|
||||||
|
Настройки
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" className="mb-2 w-full justify-start" onClick={() => window.open("https://forms.gle/feedback-supersam", "_blank")}>
|
||||||
|
💡 Предложить улучшение
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" className="w-full justify-start" onClick={onSignOut}>
|
||||||
|
Выйти
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* Main content area */}
|
||||||
|
<div className="min-w-0 space-y-5 pb-20 xl:space-y-8 xl:pb-0 fs-zone-body">
|
||||||
|
{/* Mobile header */}
|
||||||
|
<Panel className="p-4 xl:hidden fs-zone-heading">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
||||||
|
Рабочая область
|
||||||
|
</p>
|
||||||
|
<h2 className="text-lg font-semibold leading-tight sm:text-xl md:text-2xl">
|
||||||
|
{sectionMeta?.label || "Панель"}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
|
{user.name} · {ROLE_LABELS[user.role] || user.role}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 md:flex-shrink-0">
|
||||||
|
<NotificationBell
|
||||||
|
notifications={notifications}
|
||||||
|
unreadCount={unreadCount}
|
||||||
|
onMarkAsRead={onMarkNotificationRead}
|
||||||
|
onMarkAllAsRead={onMarkAllNotificationsRead}
|
||||||
|
onOpenSettings={() => setShowNotifSettings(true)}
|
||||||
|
/>
|
||||||
|
{onOpenGuide ? (
|
||||||
|
<Button size="sm" variant="ghost" onClick={onOpenGuide} aria-label="Справка">
|
||||||
|
?
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<PwaInstallButton onInstall={onInstallApp} isInstalled={isInstalled} isInstallAvailable={isInstallAvailable} />
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => navigate("/settings")} aria-label="Настройки">
|
||||||
|
⚙
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => window.open("https://forms.gle/feedback-supersam", "_blank")} aria-label="Предложить улучшение">
|
||||||
|
💡
|
||||||
|
</Button>
|
||||||
|
<ThemeToggle />
|
||||||
|
<Button size="sm" variant="ghost" onClick={onSignOut}>
|
||||||
|
Выйти
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* Mobile tab navigation — STICKY TOP */}
|
||||||
|
{shouldShowMobileNav && (
|
||||||
|
<div className="sticky inset-x-0 top-0 z-40 -mx-3 -mt-4 border-b border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 backdrop-blur xl:hidden sm:-mx-4 md:-mx-6 fs-zone-nav">
|
||||||
|
<div className="flex gap-1 overflow-x-auto" style={{ WebkitOverflowScrolling: 'touch', scrollbarWidth: 'none' }}>
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item.key}
|
||||||
|
variant="ghost"
|
||||||
|
className={[
|
||||||
|
"flex flex-shrink-0 items-center gap-1.5 rounded-[14px] px-3 py-2 text-sm transition",
|
||||||
|
activeSection === item.key
|
||||||
|
? "bg-[var(--color-accent)] text-[var(--color-accent-contrast)]"
|
||||||
|
: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]",
|
||||||
|
].join(" ")}
|
||||||
|
onClick={() => onSectionChange(item.key)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="truncate font-medium">{item.label}</span>
|
||||||
|
{item.badge ? (
|
||||||
|
<Badge tone={activeSection === item.key ? "neutral" : "accent"}>{item.badge}</Badge>
|
||||||
|
) : null}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Desktop header */}
|
||||||
|
<Panel className="hidden p-4 md:p-5 xl:block fs-zone-heading">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
||||||
|
Рабочая область
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-2 text-2xl font-semibold">{sectionMeta?.label || "Панель"}</h2>
|
||||||
|
{sectionMeta?.description ? (
|
||||||
|
<p className="mt-2 max-w-3xl text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
|
{sectionMeta.description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<NotificationBell
|
||||||
|
notifications={notifications}
|
||||||
|
unreadCount={unreadCount}
|
||||||
|
onMarkAsRead={onMarkNotificationRead}
|
||||||
|
onMarkAllAsRead={onMarkAllNotificationsRead}
|
||||||
|
onOpenSettings={() => setShowNotifSettings(true)}
|
||||||
|
/>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-sm font-medium">{user.name}</div>
|
||||||
|
<div className="text-sm text-[var(--color-text-muted)]">{ROLE_LABELS[user.role] || user.role}</div>
|
||||||
|
</div>
|
||||||
|
{onOpenGuide ? (
|
||||||
|
<Button size="sm" variant="ghost" onClick={onOpenGuide} aria-label="Справка">
|
||||||
|
{isGuideOpen ? "Назад" : "?"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<PwaInstallButton onInstall={onInstallApp} isInstalled={isInstalled} isInstallAvailable={isInstallAvailable} />
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => navigate("/settings")} aria-label="Настройки">
|
||||||
|
⚙
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => window.open("https://forms.gle/feedback-supersam", "_blank")} aria-label="Предложить улучшение">
|
||||||
|
💡
|
||||||
|
</Button>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const FontSettingsContext = createContext(null);
|
||||||
|
|
||||||
|
const STORAGE_KEY = "supersam-font-settings";
|
||||||
|
|
||||||
|
// Categories: key → { label, defaultScale, min, max, step, description }
|
||||||
|
export const FONT_CATEGORIES = [
|
||||||
|
{ key: "table", label: "Таблицы", description: "Текст в таблицах и списках", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
|
||||||
|
{ key: "card", label: "Карточки", description: "Текст в карточках и панелях", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
|
||||||
|
{ key: "nav", label: "Меню и навигация", description: "Пункты меню, вкладки, кнопки", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
|
||||||
|
{ key: "heading", label: "Заголовки", description: "Названия секций и страниц", defaultScale: 1.0, min: 0.8, max: 1.6, step: 0.05 },
|
||||||
|
{ key: "body", label: "Основной текст", description: "Обычный текст в интерфейсе", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
|
||||||
|
{ key: "small", label: "Мелкий текст", description: "Подписи, метки, временные отметки", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS = FONT_CATEGORIES.reduce((acc, cat) => {
|
||||||
|
acc[cat.key] = cat.defaultScale;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
function loadSettings() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return { ...DEFAULT_SETTINGS };
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
// Merge with defaults to handle new categories
|
||||||
|
return { ...DEFAULT_SETTINGS, ...parsed };
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_SETTINGS };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySettings(settings) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
for (const cat of FONT_CATEGORIES) {
|
||||||
|
root.style.setProperty(`--fs-scale-${cat.key}`, String(settings[cat.key] ?? 1.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FontSettingsProvider = ({ children }) => {
|
||||||
|
const [settings, setSettings] = useState(loadSettings);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applySettings(settings);
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
const updateCategory = (key, scale) => {
|
||||||
|
setSettings((prev) => ({ ...prev, [key]: scale }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetAll = () => {
|
||||||
|
setSettings({ ...DEFAULT_SETTINGS });
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
settings,
|
||||||
|
updateCategory,
|
||||||
|
resetAll,
|
||||||
|
categories: FONT_CATEGORIES,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <FontSettingsContext.Provider value={value}>{children}</FontSettingsContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useFontSettings = () => {
|
||||||
|
const context = useContext(FontSettingsContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useFontSettings must be used within FontSettingsProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,389 @@
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import {
|
||||||
|
filterOrderGroups,
|
||||||
|
getOrderGroupDisplayStatusLabel,
|
||||||
|
getOrderGroupDisplayStatusValue,
|
||||||
|
getOrderGroupStatusTone,
|
||||||
|
ORDER_GROUP_DISPLAY_STATUS_OPTIONS,
|
||||||
|
} from "../../services/orderGroupViews";
|
||||||
|
import { Badge } from "../UI/Badge";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
|
import { SkeletonPage } from "../UI/Loading";
|
||||||
|
import { OrderFilters } from "../orders/OrderFilters";
|
||||||
|
import { formatDate, formatDateTime } from "../../utils/formatters";
|
||||||
|
|
||||||
|
const fmtDate = (d) => {
|
||||||
|
if (!d) return "";
|
||||||
|
const [y, m, day] = d.split("-");
|
||||||
|
if (!y || !m || !day) return d;
|
||||||
|
return `${day}.${m}.${y}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default priority: agreed first, manual_required second, then funnel
|
||||||
|
const DEFAULT_FUNNEL_ORDER = [
|
||||||
|
"delivery:agreed",
|
||||||
|
"status:manual_required",
|
||||||
|
"status:ready_for_notification",
|
||||||
|
"delivery:pending_confirmation",
|
||||||
|
"status:first_sms_sent",
|
||||||
|
"status:second_sms_sent",
|
||||||
|
"delivery:driver_assigned",
|
||||||
|
"delivery:loaded",
|
||||||
|
"delivery:on_route",
|
||||||
|
"delivery:delivered",
|
||||||
|
"delivery:picked_up",
|
||||||
|
"delivery:paid_storage",
|
||||||
|
"delivery:problem",
|
||||||
|
"delivery:cancelled",
|
||||||
|
];
|
||||||
|
|
||||||
|
const STORAGE_KEY = "logistics-section-order";
|
||||||
|
const COLLAPSED_KEY = "logistics-section-collapsed";
|
||||||
|
|
||||||
|
// Load custom order from localStorage, merge with defaults
|
||||||
|
const loadCustomOrder = () => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? parsed : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveCustomOrder = (order) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(order));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load collapsed sections from localStorage
|
||||||
|
const loadCollapsedSections = () => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(COLLAPSED_KEY);
|
||||||
|
if (!raw) return new Set();
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return new Set(Array.isArray(parsed) ? parsed : []);
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveCollapsedSections = (collapsedSet) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(COLLAPSED_KEY, JSON.stringify([...collapsedSet]));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 7 columns: Клиент | Город | Тип | Дата | Водитель | Статус | Обновлён
|
||||||
|
const COLS = "grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(100px,0.8fr)_minmax(100px,1fr)_minmax(90px,1fr)_minmax(100px,1fr)_minmax(90px,0.8fr)]";
|
||||||
|
const MIN_W = "min-w-[1080px]";
|
||||||
|
|
||||||
|
const TableHeader = () => (
|
||||||
|
<div className={`grid ${COLS} gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.12em] text-[var(--color-text-muted)]`}>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Клиент</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Город</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Тип</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Дата доставки</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Водитель</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Статус</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Обновлён</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderRow = (group, onSelectSet) => (
|
||||||
|
<button
|
||||||
|
key={group.id}
|
||||||
|
type="button"
|
||||||
|
className={`grid ${COLS} gap-0 w-full border-t border-[var(--color-border)] text-left transition hover:bg-[var(--color-accent-soft)]`}
|
||||||
|
onClick={() => { if (onSelectSet) onSelectSet(group.id); }}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 px-3 py-1.5">
|
||||||
|
<div className="text-xs font-medium leading-snug break-words" style={{ display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden" }}>
|
||||||
|
{group.displayTitle || group.customerName || group.groupKey}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-[var(--color-text-muted)]">
|
||||||
|
{group.customerPhone || ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
|
||||||
|
{group.city || group.customerAddress || "—"}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-xs">
|
||||||
|
<span className="inline-flex items-center gap-1 whitespace-nowrap">
|
||||||
|
{group.deliveryType === "pickup" ? "🏪" : "🚚"}
|
||||||
|
<span className="text-[var(--color-text-muted)]">{group.deliveryType === "pickup" ? "Самовывоз" : "Доставка"}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-xs">
|
||||||
|
{group.deliveryDate ? (
|
||||||
|
<span>{fmtDate(group.deliveryDate)}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[var(--color-text-muted)]">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-xs">
|
||||||
|
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]">—</span>}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5">
|
||||||
|
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
|
||||||
|
{formatDateTime(group.updatedAt)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sortable section wrapper
|
||||||
|
const SortableSection = ({ statusValue, label, groups, isCollapsed, onToggle, onSelectSet }) => {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: statusValue });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className="rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Section header — drag handle + collapse toggle */}
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
{/* Drag handle */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center px-3 py-3 cursor-grab active:cursor-grabbing text-[var(--color-text)] hover:bg-[var(--color-accent-soft)] rounded-l-[28px] touch-none"
|
||||||
|
title="Перетащите для изменения порядка"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24" style={{ opacity: 0.6 }}>
|
||||||
|
<circle cx="9" cy="5" r="1.8" />
|
||||||
|
<circle cx="15" cy="5" r="1.8" />
|
||||||
|
<circle cx="9" cy="12" r="1.8" />
|
||||||
|
<circle cx="15" cy="12" r="1.8" />
|
||||||
|
<circle cx="9" cy="19" r="1.8" />
|
||||||
|
<circle cx="15" cy="19" r="1.8" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Collapse toggle */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex flex-1 items-center justify-between py-3 pr-5 text-left transition hover:bg-[var(--color-surface-strong)]"
|
||||||
|
onClick={onToggle}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-sm font-semibold">{label}</h3>
|
||||||
|
<Badge tone={groups.length > 0 ? "neutral" : "muted"}>{groups.length}</Badge>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4 text-[var(--color-text-muted)] transition-transform"
|
||||||
|
style={{ transform: isCollapsed ? "rotate(-90deg)" : "rotate(0deg)" }}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isCollapsed && (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className={MIN_W}>
|
||||||
|
<TableHeader />
|
||||||
|
{groups.map((g) => renderRow(g, onSelectSet))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS, isLoading = false }) => {
|
||||||
|
const [filters, setFilters] = React.useState({ query: "", displayStatus: "all", city: "" });
|
||||||
|
const [collapsedSections, setCollapsedSections] = React.useState(() => loadCollapsedSections());
|
||||||
|
const [sectionOrder, setSectionOrder] = React.useState(() => {
|
||||||
|
const custom = loadCustomOrder();
|
||||||
|
return custom || [...DEFAULT_FUNNEL_ORDER];
|
||||||
|
});
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const cities = React.useMemo(() => {
|
||||||
|
const set = new Set();
|
||||||
|
for (const g of orderGroups) {
|
||||||
|
if (g.city) set.add(g.city);
|
||||||
|
}
|
||||||
|
return [...set].sort();
|
||||||
|
}, [orderGroups]);
|
||||||
|
|
||||||
|
const filteredGroups = React.useMemo(
|
||||||
|
() => filterOrderGroups(orderGroups, filters),
|
||||||
|
[filters, orderGroups],
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusGroups = React.useMemo(() => {
|
||||||
|
const map = new Map();
|
||||||
|
for (const group of filteredGroups) {
|
||||||
|
const statusValue = getOrderGroupDisplayStatusValue(group);
|
||||||
|
if (!map.has(statusValue)) {
|
||||||
|
const label = getOrderGroupDisplayStatusLabel(group);
|
||||||
|
map.set(statusValue, { label, groups: [] });
|
||||||
|
}
|
||||||
|
map.get(statusValue).groups.push(group);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [filteredGroups]);
|
||||||
|
|
||||||
|
const totalGroups = filteredGroups.length;
|
||||||
|
|
||||||
|
// Build sorted list: use sectionOrder for known statuses, append unknown ones at end
|
||||||
|
const sortedEntries = React.useMemo(() => {
|
||||||
|
const present = new Set(statusGroups.keys());
|
||||||
|
const result = [];
|
||||||
|
|
||||||
|
// First: statuses in custom order that are present
|
||||||
|
for (const statusValue of sectionOrder) {
|
||||||
|
if (present.has(statusValue)) {
|
||||||
|
const data = statusGroups.get(statusValue);
|
||||||
|
result.push([statusValue, data]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then: any statuses not in sectionOrder (new statuses), sorted alphabetically
|
||||||
|
for (const [statusValue, data] of statusGroups.entries()) {
|
||||||
|
if (!sectionOrder.includes(statusValue)) {
|
||||||
|
result.push([statusValue, data]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [statusGroups, sectionOrder]);
|
||||||
|
|
||||||
|
const handleDragEnd = (event) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
|
||||||
|
setSectionOrder((prevOrder) => {
|
||||||
|
// Build the full order including any new statuses
|
||||||
|
const allIds = sortedEntries.map(([id]) => id);
|
||||||
|
const oldIndex = allIds.indexOf(active.id);
|
||||||
|
const newIndex = allIds.indexOf(over.id);
|
||||||
|
if (oldIndex === -1 || newIndex === -1) return prevOrder;
|
||||||
|
|
||||||
|
const newAllOrder = arrayMove(allIds, oldIndex, newIndex);
|
||||||
|
|
||||||
|
// Merge: replace positions of known statuses, keep unknown at end
|
||||||
|
// Save the full new order so it persists
|
||||||
|
saveCustomOrder(newAllOrder);
|
||||||
|
return newAllOrder;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <SkeletonPage panels={3} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 fs-zone-table">
|
||||||
|
<Panel className="space-y-4 p-5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="text-lg font-semibold">Наборы доставки</h2>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)] mt-0.5">
|
||||||
|
Перетаскивайте секции за ручку слева, чтобы изменить порядок отображения.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge tone="neutral">{totalGroups} групп</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OrderFilters
|
||||||
|
filters={filters}
|
||||||
|
setFilters={setFilters}
|
||||||
|
statusOptions={statusOptions}
|
||||||
|
cities={cities}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{!totalGroups ? (
|
||||||
|
<div className="rounded-[28px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
|
||||||
|
По этому поиску ничего не найдено.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={sortedEntries.map(([id]) => id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{sortedEntries.map(([statusValue, { label, groups }]) => {
|
||||||
|
const isCollapsed = collapsedSections.has(statusValue);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SortableSection
|
||||||
|
key={statusValue}
|
||||||
|
statusValue={statusValue}
|
||||||
|
label={label}
|
||||||
|
groups={groups}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
onToggle={() => {
|
||||||
|
setCollapsedSections((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(statusValue)) {
|
||||||
|
next.delete(statusValue);
|
||||||
|
} else {
|
||||||
|
next.add(statusValue);
|
||||||
|
}
|
||||||
|
saveCollapsedSections(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onSelectSet={onSelectSet}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,257 @@
|
||||||
|
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 fmtDate = (d) => {
|
||||||
|
if (!d) return '';
|
||||||
|
const [y, m, day] = d.split('-');
|
||||||
|
if (!y || !m || !day) return d;
|
||||||
|
return `${day}.${m}.${y}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getShipmentIssues = (group) => {
|
||||||
|
const data = group?.driverShipmentData;
|
||||||
|
if (!Array.isArray(data) || data.length === 0) return null;
|
||||||
|
const unshipped = data.filter((i) => !i.shipped);
|
||||||
|
if (unshipped.length === 0) return null;
|
||||||
|
return unshipped;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildGroupSummary = (group) => {
|
||||||
|
const 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 baseClass = "w-full rounded-[22px] border text-left transition";
|
||||||
|
const selectedClass = selectedOrderGroupId === group.id
|
||||||
|
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"
|
||||||
|
: hasProblem
|
||||||
|
? "border-[var(--color-danger)] bg-[rgba(201,61,61,0.1)]"
|
||||||
|
: "border-[var(--color-border)] bg-[var(--color-surface-strong)]";
|
||||||
|
|
||||||
|
const allNumbers = group.allBillNumbers || group.orderNumbers || [];
|
||||||
|
const primaryBill = allNumbers[0] || "—";
|
||||||
|
const totalCount = allNumbers.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={group.id}>
|
||||||
|
<div className="mb-1 flex items-center gap-2 px-1 text-xs">
|
||||||
|
<span className="font-medium text-[var(--color-text)]">№ {primaryBill}</span>
|
||||||
|
{totalCount > 1 && (
|
||||||
|
<span className="rounded-full bg-[var(--color-accent-soft)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent)]">
|
||||||
|
{totalCount} сч.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
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>
|
||||||
|
</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-[1080px]">
|
||||||
|
<div className="grid grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(100px,0.8fr)_minmax(100px,1fr)_minmax(100px,1fr)_minmax(100px,0.8fr)_minmax(90px,0.8fr)] gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.12em] text-[var(--color-text-muted)]">
|
||||||
|
<div className="px-3 py-1.5 font-medium">Группа / Клиент</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Счета</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Город</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Статус</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Дата доставки</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Тип</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Водитель</div>
|
||||||
|
</div>
|
||||||
|
{orderGroups.map((group) => {
|
||||||
|
const hasProblem = group.hasDeliveryProblem;
|
||||||
|
const rowClassName = `grid grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(100px,0.8fr)_minmax(100px,1fr)_minmax(100px,1fr)_minmax(100px,0.8fr)_minmax(90px,0.8fr)] gap-0 w-full border-t border-[var(--color-border)] text-left transition ${
|
||||||
|
hasProblem
|
||||||
|
? "bg-[rgba(201,61,61,0.1)] hover:bg-[rgba(201,61,61,0.15)]"
|
||||||
|
: "hover:bg-[var(--color-accent-soft)]"
|
||||||
|
} ${selectedOrderGroupId === group.id ? "bg-[var(--color-accent-soft)]" : ""}`;
|
||||||
|
|
||||||
|
const 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">
|
||||||
|
<Badge tone={getOrderGroupStatusTone(group)}>
|
||||||
|
{getOrderGroupDisplayStatusLabel(group)}
|
||||||
|
</Badge>
|
||||||
|
</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">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,186 @@
|
||||||
|
/**
|
||||||
|
* @file SettingsPage.jsx
|
||||||
|
* @description Page for user settings: font size controls for all UI categories.
|
||||||
|
* Settings persist to localStorage via FontSettingsContext.
|
||||||
|
*/
|
||||||
|
import React from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useFontSettings, FONT_CATEGORIES } from "../context/FontSettingsContext";
|
||||||
|
import { Panel } from "../components/UI/Panel";
|
||||||
|
import { Button } from "../components/UI/Button";
|
||||||
|
|
||||||
|
const PRESETS = [
|
||||||
|
{ label: "Мелкий", scales: { table: 0.85, card: 0.85, nav: 0.85, heading: 0.85, body: 0.85, small: 0.85 } },
|
||||||
|
{ label: "Стандарт", scales: { table: 1.0, card: 1.0, nav: 1.0, heading: 1.0, body: 1.0, small: 1.0 } },
|
||||||
|
{ label: "Крупный", scales: { table: 1.15, card: 1.15, nav: 1.15, heading: 1.2, body: 1.15, small: 1.1 } },
|
||||||
|
{ label: "Очень крупный", scales: { table: 1.3, card: 1.3, nav: 1.25, heading: 1.4, body: 1.3, small: 1.2 } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const Slider = ({ category, value, onChange }) => {
|
||||||
|
const pct = ((value - category.min) / (category.max - category.min)) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-[var(--color-text)]">{category.label}</span>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">{category.description}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-semibold tabular-nums text-[var(--color-accent)]">
|
||||||
|
{Math.round(value * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={category.min}
|
||||||
|
max={category.max}
|
||||||
|
step={category.step}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(parseFloat(e.target.value))}
|
||||||
|
className="fs-slider flex-1"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(to right, var(--color-accent) 0%, var(--color-accent) ${pct}%, var(--color-border) ${pct}%, var(--color-border) 100%)`,
|
||||||
|
}}
|
||||||
|
aria-label={category.label}
|
||||||
|
/>
|
||||||
|
<span className="text-xs tabular-nums text-[var(--color-text-muted)] w-16 text-right">
|
||||||
|
{value.toFixed(2)}×
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SettingsPage = () => {
|
||||||
|
const { settings, updateCategory, resetAll, categories } = useFontSettings();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const applyPreset = (scales) => {
|
||||||
|
for (const [key, scale] of Object.entries(scales)) {
|
||||||
|
updateCategory(key, scale);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if current settings match a preset
|
||||||
|
const activePreset = PRESETS.findIndex((p) =>
|
||||||
|
Object.entries(p.scales).every(([k, v]) => Math.abs((settings[k] ?? 1.0) - v) < 0.001)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-6 px-3 py-4 sm:px-4 md:px-6 md:py-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Настройки</p>
|
||||||
|
<h1 className="mt-1 text-2xl font-bold leading-tight" style={{ fontSize: `calc(1.5rem * var(--fs-scale-heading, 1))` }}>
|
||||||
|
Настройки интерфейса
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" onClick={() => navigate(-1)}>
|
||||||
|
← Назад
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Presets */}
|
||||||
|
<Panel className="p-5 space-y-3">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||||
|
Быстрые пресеты
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
{PRESETS.map((preset, i) => (
|
||||||
|
<button
|
||||||
|
key={preset.label}
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyPreset(preset.scales)}
|
||||||
|
className={[
|
||||||
|
"rounded-[18px] border-2 px-4 py-3 text-center transition",
|
||||||
|
activePreset === i
|
||||||
|
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)] font-semibold"
|
||||||
|
: "border-[var(--color-border)] bg-[var(--color-surface-strong)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-text)]",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium">{preset.label}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* Font size sliders */}
|
||||||
|
<Panel className="p-5 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||||
|
Размеры шрифтов
|
||||||
|
</h2>
|
||||||
|
<Button size="sm" variant="ghost" onClick={resetAll}>
|
||||||
|
Сбросить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<Slider
|
||||||
|
key={cat.key}
|
||||||
|
category={cat}
|
||||||
|
value={settings[cat.key] ?? cat.defaultScale}
|
||||||
|
onChange={(v) => updateCategory(cat.key, v)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<div className="rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 space-y-2">
|
||||||
|
<p className="text-xs uppercase tracking-wider text-[var(--color-text-muted)]">Предпросмотр</p>
|
||||||
|
<div className="fs-zone-heading space-y-1">
|
||||||
|
<h3 className="font-bold" style={{ fontSize: `calc(1.25rem * var(--fs-scale-heading, 1))` }}>
|
||||||
|
Заголовок секции
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="fs-zone-nav flex gap-2">
|
||||||
|
<span className="rounded-[14px] bg-[var(--color-accent)] px-3 py-1.5 text-[var(--color-accent-contrast)]"
|
||||||
|
style={{ fontSize: `calc(0.875rem * var(--fs-scale-nav, 1))` }}>
|
||||||
|
Пункт меню
|
||||||
|
</span>
|
||||||
|
<span className="rounded-[14px] bg-[var(--color-surface)] px-3 py-1.5 border border-[var(--color-border)]"
|
||||||
|
style={{ fontSize: `calc(0.875rem * var(--fs-scale-nav, 1))` }}>
|
||||||
|
Вкладка
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="fs-zone-table rounded-[14px] border border-[var(--color-border)] overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-[var(--color-surface)]">
|
||||||
|
<th className="px-3 py-2 text-left font-semibold" style={{ fontSize: `calc(0.875rem * var(--fs-scale-table, 1))` }}>Дата</th>
|
||||||
|
<th className="px-3 py-2 text-left font-semibold" style={{ fontSize: `calc(0.875rem * var(--fs-scale-table, 1))` }}>Статус</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr className="border-t border-[var(--color-border)]">
|
||||||
|
<td className="px-3 py-2" style={{ fontSize: `calc(0.875rem * var(--fs-scale-table, 1))` }}>02.07.2026</td>
|
||||||
|
<td className="px-3 py-2" style={{ fontSize: `calc(0.875rem * var(--fs-scale-table, 1))` }}>В работе</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="fs-zone-card rounded-[14px] bg-[var(--color-surface)] p-3">
|
||||||
|
<p style={{ fontSize: `calc(0.875rem * var(--fs-scale-card, 1))` }}>
|
||||||
|
Текст в карточке — описание доставки или заказа.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="fs-zone-body text-[var(--color-text)]" style={{ fontSize: `calc(1rem * var(--fs-scale-body, 1))` }}>
|
||||||
|
Основной текст интерфейса.
|
||||||
|
</p>
|
||||||
|
<p className="fs-zone-small text-[var(--color-text-muted)]" style={{ fontSize: `calc(0.75rem * var(--fs-scale-small, 1))` }}>
|
||||||
|
мелкая подпись · временная отметка
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-[var(--color-text-muted)]">
|
||||||
|
Настройки сохраняются на этом устройстве
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsPage;
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Universal pagination control.
|
||||||
|
* Usage: <Pagination page={1} totalPages={5} onChange={setPage} />
|
||||||
|
*/
|
||||||
|
export const Pagination = ({ page, totalPages, onChange, itemsPerPage, totalItems }) => {
|
||||||
|
if (totalPages <= 1) return null;
|
||||||
|
|
||||||
|
const from = (page - 1) * (itemsPerPage || 0) + 1;
|
||||||
|
const to = Math.min(page * (itemsPerPage || 0), totalItems || 0);
|
||||||
|
|
||||||
|
const pages = [];
|
||||||
|
const maxButtons = 7;
|
||||||
|
let start = Math.max(1, page - 3);
|
||||||
|
let end = Math.min(totalPages, start + maxButtons - 1);
|
||||||
|
if (end - start < maxButtons - 1) start = Math.max(1, end - maxButtons + 1);
|
||||||
|
|
||||||
|
for (let i = start; i <= end; i++) pages.push(i);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 px-4 py-3 border-t border-[var(--color-border)]">
|
||||||
|
<span className="text-xs text-[var(--color-text-muted)]">
|
||||||
|
{totalItems != null && itemsPerPage
|
||||||
|
? `${from}–${to} из ${totalItems}`
|
||||||
|
: `Стр. ${page} из ${totalPages}`}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => onChange(page - 1)}
|
||||||
|
className="rounded-lg border border-[var(--color-border)] px-2.5 py-1 text-xs font-medium text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-strong)] disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
{start > 1 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(1)}
|
||||||
|
className="rounded-lg px-2.5 py-1 text-xs font-medium text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] transition"
|
||||||
|
>1</button>
|
||||||
|
{start > 2 && <span className="text-xs text-[var(--color-text-muted)] px-1">…</span>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{pages.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(p)}
|
||||||
|
className={`rounded-lg px-2.5 py-1 text-xs font-medium transition ${
|
||||||
|
p === page
|
||||||
|
? "bg-[var(--color-accent)] text-white shadow-sm"
|
||||||
|
: "text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] border border-[var(--color-border)]"
|
||||||
|
}`}
|
||||||
|
>{p}</button>
|
||||||
|
))}
|
||||||
|
{end < totalPages && (
|
||||||
|
<>
|
||||||
|
{end < totalPages - 1 && <span className="text-xs text-[var(--color-text-muted)] px-1">…</span>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(totalPages)}
|
||||||
|
className="rounded-lg px-2.5 py-1 text-xs font-medium text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] transition"
|
||||||
|
>{totalPages}</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => onChange(page + 1)}
|
||||||
|
className="rounded-lg border border-[var(--color-border)] px-2.5 py-1 text-xs font-medium text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-strong)] disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
/**
|
/**
|
||||||
* @file AdminDashboard.jsx
|
* @file AdminDashboard.jsx
|
||||||
* @description Admin analytics dashboard. Displays KPI cards, status pie chart,
|
* @description Admin analytics dashboard. Redesigned with responsive grid layout
|
||||||
* daily trend line, confirmation funnel, SMS stats, and driver performance
|
* that uses desktop width effectively. KPI cards, status pie, daily trend,
|
||||||
* bar chart. Supports period selection (1d/7d/30d/all) and mobile layout.
|
* confirmation funnel, SMS stats, driver performance, pickup stats.
|
||||||
|
* Period label shown in section subtitles.
|
||||||
*/
|
*/
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
|
|
@ -10,7 +11,6 @@ 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: 640px)');
|
const mq = window.matchMedia('(max-width: 768px)');
|
||||||
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,7 +38,8 @@ const STATUS_COLORS = {
|
||||||
driver_assigned: '#3b82f6',
|
driver_assigned: '#3b82f6',
|
||||||
loaded: '#6366f1',
|
loaded: '#6366f1',
|
||||||
on_route: '#8b5cf6',
|
on_route: '#8b5cf6',
|
||||||
delivered: '#10b981',
|
delivered: '#22c55e',
|
||||||
|
picked_up: '#14b8a6',
|
||||||
paid_storage: '#06b6d4',
|
paid_storage: '#06b6d4',
|
||||||
problem: '#ef4444',
|
problem: '#ef4444',
|
||||||
cancelled: '#64748b',
|
cancelled: '#64748b',
|
||||||
|
|
@ -53,6 +54,7 @@ const STATUS_LABELS = {
|
||||||
loaded: 'Загружено',
|
loaded: 'Загружено',
|
||||||
on_route: 'В пути',
|
on_route: 'В пути',
|
||||||
delivered: 'Доставлено',
|
delivered: 'Доставлено',
|
||||||
|
picked_up: 'Вывезено',
|
||||||
paid_storage: 'Оплаченное хранение',
|
paid_storage: 'Оплаченное хранение',
|
||||||
problem: 'Проблема',
|
problem: 'Проблема',
|
||||||
cancelled: 'Отменено',
|
cancelled: 'Отменено',
|
||||||
|
|
@ -64,9 +66,16 @@ 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;
|
||||||
|
|
@ -85,44 +94,65 @@ const CustomTooltip = ({ active, payload, label: tooltipLabel }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── KPI Card ────────────────────────────────────────────────────────────────
|
||||||
|
const KpiCard = ({ label, value, color, mobile }) => (
|
||||||
|
<Panel style={{
|
||||||
|
padding: mobile ? '0.5rem 0.6rem' : '0.75rem 1rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
display: 'flex', flexDirection: 'column', justifyContent: 'center',
|
||||||
|
minHeight: mobile ? '60px' : '80px',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: mobile ? '0.6rem' : '0.72rem', color: 'var(--color-text-muted)', marginBottom: '0.15rem', textTransform: 'uppercase', letterSpacing: '0.03em' }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: mobile ? '1.15rem' : '1.6rem', fontWeight: 800, color: color || 'var(--color-text)', lineHeight: 1.1 }}>
|
||||||
|
{value ?? '—'}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Section Header ──────────────────────────────────────────────────────────
|
||||||
|
const SectionHeader = ({ title, subtitle, mobile }) => (
|
||||||
|
<div style={{ marginBottom: '0.6rem' }}>
|
||||||
|
<h3 style={{ fontSize: mobile ? '0.9rem' : '1rem', fontWeight: 700, color: 'var(--color-text)', marginBottom: subtitle ? '0.1rem' : 0 }}>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
{subtitle && (
|
||||||
|
<div style={{ fontSize: mobile ? '0.65rem' : '0.72rem', color: 'var(--color-text-muted)' }}>
|
||||||
|
{subtitle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
// ── AdminDashboard Component ───────────────────────────────────────────────
|
// ── 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);
|
||||||
|
|
||||||
// ── Responsive Layout Values (must be before early returns) ────────────────
|
const periodLabel = PERIOD_LABELS[period] || '';
|
||||||
const chartHeight = mobile ? 200 : 240;
|
const chartHeight = mobile ? 200 : 280;
|
||||||
const kpiMin = mobile ? '80px' : '110px';
|
const fontSize = mobile ? { xs: '0.6rem', s: '0.7rem', m: '0.78rem', l: '0.85rem' }
|
||||||
const chartGridCols = mobile ? '1fr' : '1fr 2fr';
|
: { xs: '0.72rem', s: '0.78rem', m: '0.85rem', l: '0.95rem' };
|
||||||
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 / Error States ─────────────────────────────────────────────────
|
// ── Loading State ─────────────────────────────────────────────────────────
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: mobile ? '0.75rem' : '1.25rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: mobile ? '0.75rem' : '1.5rem' }}>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem' }}>
|
<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(80px, 160px))`, gap: '0.4rem' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr' : 'repeat(auto-fit, minmax(120px, 1fr))', gap: '0.5rem' }}>
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 7 }).map((_, i) => (
|
||||||
<Panel key={i} style={{ padding: mobile ? '0.4rem 0.6rem' : '0.5rem 0.75rem', textAlign: 'center' }}>
|
<Panel key={i} style={{ padding: '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-5" />
|
<Skeleton className="w-8 h-6" />
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -145,70 +175,83 @@ 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, total: d.total || 0, problems: d.problems || 0,
|
delivered: d.delivered || 0, picked_up: d.picked_up || 0, total: d.total || 0, problems: d.problems || 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const driverData = (driverStats || []).map(d => ({
|
const driverData = (driverStats || []).map(d => ({
|
||||||
name: d.driver_name || 'Неизвестный',
|
name: d.driver_name || 'Неизвестный',
|
||||||
total: d.total || 0, delivered: d.delivered || 0, problems: d.problems || 0,
|
total: d.total || 0, delivered: d.delivered || 0, picked_up: d.picked_up || 0, problems: d.problems || 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Funnel: ALWAYS show all steps, even with 0 values
|
// ── Funnel: Real order path ───────────────────────────────────────────────
|
||||||
// ── Funnel Data ────────────────────────────────────────────────────────────
|
const completedTotal = (econ.full_chain_client || 0) + (econ.client_date_no_driver || 0)
|
||||||
|
+ (econ.manager_date_completed || 0) + (econ.bypassed_completed || 0);
|
||||||
|
|
||||||
const funnelSteps = [
|
const funnelSteps = [
|
||||||
{ label: 'Согласовано после 1-й SMS', value: econ.confirmed_after_sms1 || 0, color: '#22c55e' },
|
{ label: 'Всего заказов', value: econ.total_groups || 0, color: '#94a3b8' },
|
||||||
{ label: 'Согласовано после 2-й SMS', value: econ.confirmed_after_sms2 || 0, color: '#14b8a6' },
|
{ label: 'SMS отправлено', value: econ.sms_sent || 0, color: '#3b82f6' },
|
||||||
{ label: 'Согласовано вручную', value: econ.confirmed_via_manual || 0, color: '#eab308' },
|
{ label: 'Полная цепочка', value: econ.full_chain_client || 0, color: '#22c55e' },
|
||||||
{ label: 'Ручное назначение даты', value: econ.manual_date_set_count || 0, color: '#f97316' },
|
{ label: 'Клиент выбрал дату', value: econ.client_date_no_driver || 0, color: '#14b8a6' },
|
||||||
{ label: 'Платное хранение', value: econ.paid_storage_count || 0, color: '#06b6d4' },
|
{ label: 'Менеджер назначил дату', value: econ.manager_date_completed || 0, color: '#8b5cf6' },
|
||||||
{ label: 'Отмена', value: econ.cancelled_count || 0, color: '#ef4444' },
|
{ label: 'В обход (без даты)', value: econ.bypassed_completed || 0, color: '#f97316' },
|
||||||
|
{ label: 'Застряло в ручном', value: econ.stuck_in_manual || 0, color: '#eab308' },
|
||||||
|
{ label: 'В работе', value: econ.in_progress || 0, color: '#3b82f6' },
|
||||||
|
{ label: 'Отменено', value: econ.cancelled_count || 0, color: '#64748b' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Render ─────────────────────────────────────────────────────────────────
|
// ── Grid Layout ───────────────────────────────────────────────────────────
|
||||||
return (
|
// Desktop: 12-col grid. Mobile: single column.
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: mobile ? '0.75rem' : '1.25rem' }}>
|
const gridCols = mobile ? '1fr' : 'repeat(12, 1fr)';
|
||||||
|
const colSpan = (n) => mobile ? '1 / -1' : `span ${n}`;
|
||||||
|
|
||||||
{/* Period selector */}
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: mobile ? '0.75rem' : '1.5rem' }}>
|
||||||
|
|
||||||
|
{/* ── Header + Period selector ─────────────────────────────────────────── */}
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem' }}>
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ fontSize: mobile ? '1rem' : '1.1rem', fontWeight: 600, color: 'var(--color-text)', marginBottom: '0.15rem' }}>Аналитика</h2>
|
<h2 style={{ fontSize: mobile ? '1.1rem' : '1.4rem', fontWeight: 800, color: 'var(--color-text)', marginBottom: '0.1rem' }}>
|
||||||
<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 — centered on mobile */}
|
{/* ── KPI Cards ─────────────────────────────────────────────────────────── */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr' : `repeat(auto-fit, minmax(${kpiMin}, 160px))`, gap: '0.4rem' }}>
|
<div style={{
|
||||||
{[
|
display: 'grid',
|
||||||
{ label: 'Всего', val: totalGroups },
|
gridTemplateColumns: mobile ? '1fr 1fr' : 'repeat(auto-fit, minmax(130px, 1fr))',
|
||||||
{ label: 'Ожидает', val: sv.pending },
|
gap: mobile ? '0.4rem' : '0.75rem',
|
||||||
{ label: 'В работе', val: sv.in_progress },
|
}}>
|
||||||
{ label: 'Доставлено', val: sv.delivered },
|
<KpiCard label="Всего" value={totalGroups} mobile={mobile} />
|
||||||
{ label: 'Проблемы', val: sv.problem },
|
<KpiCard label="Ожидает" value={sv.pending} color="#94a3b8" mobile={mobile} />
|
||||||
{ label: '% доставки', val: sv.delivery_rate != null ? sv.delivery_rate + '%' : '—' },
|
<KpiCard label="В работе" value={sv.in_progress} color="#3b82f6" mobile={mobile} />
|
||||||
].map((kpi, i) => (
|
<KpiCard label="Доставлено" value={sv.delivered} color="#22c55e" mobile={mobile} />
|
||||||
<Panel key={i} style={{ padding: mobile ? '0.4rem 0.6rem' : '0.5rem 0.75rem', textAlign: 'center' }}>
|
<KpiCard label="Вывезено" value={sv.picked_up} color="#14b8a6" mobile={mobile} />
|
||||||
<div style={{ fontSize: fontSize.xs, color: 'var(--color-text-muted)', marginBottom: '0.05rem' }}>{kpi.label}</div>
|
<KpiCard label="Самовывоз" value={sv.picked_up_pickup} color="#f59e0b" 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.problem} color="#ef4444" mobile={mobile} />
|
||||||
</Panel>
|
<KpiCard label="% доставки" value={sv.delivery_rate != null ? sv.delivery_rate + '%' : '—'} color="var(--color-text)" mobile={mobile} />
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pie + Line — stacked on mobile, side-by-side on desktop */}
|
{/* ── Main Grid: Charts + Tables ───────────────────────────────────────── */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: chartGridCols, gap: mobile ? '0.5rem' : '1rem' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: gridCols, gap: mobile ? '0.75rem' : '1.5rem' }}>
|
||||||
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
|
|
||||||
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>По статусам</h3>
|
{/* Status Pie — 4 cols desktop */}
|
||||||
|
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem', gridColumn: colSpan(4) }}>
|
||||||
|
<SectionHeader title="По статусам" subtitle={periodLabel} mobile={mobile} />
|
||||||
{statusPieData.length === 0 ? (
|
{statusPieData.length === 0 ? (
|
||||||
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1.5rem' }}>Нет данных</div>
|
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '2rem' }}>Нет данных</div>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={chartHeight}>
|
<ResponsiveContainer width="100%" height={chartHeight}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie data={statusPieData} cx="50%" cy="50%"
|
<Pie data={statusPieData} cx="50%" cy="50%"
|
||||||
innerRadius={mobile ? 30 : 40}
|
innerRadius={mobile ? 30 : 50}
|
||||||
outerRadius={mobile ? 60 : 80}
|
outerRadius={mobile ? 60 : 90}
|
||||||
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'} />
|
||||||
|
|
@ -221,155 +264,160 @@ export const AdminDashboard = () => {
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
|
{/* Daily Trend — 8 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(8) }}>
|
||||||
|
<SectionHeader title="Тренд по дням" subtitle={periodLabel} mobile={mobile} />
|
||||||
{trendData.length === 0 ? (
|
{trendData.length === 0 ? (
|
||||||
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1.5rem' }}>Нет данных</div>
|
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '2rem' }}>Нет данных</div>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={chartHeight}>
|
<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 : 10, fill: 'var(--color-text-muted)' }} />
|
<XAxis dataKey="date" tick={{ fontSize: mobile ? 9 : 11, fill: 'var(--color-text-muted)' }} />
|
||||||
<YAxis tick={{ fontSize: mobile ? 9 : 10, fill: 'var(--color-text-muted)' }} width={mobile ? 25 : 35} />
|
<YAxis tick={{ fontSize: mobile ? 9 : 11, fill: 'var(--color-text-muted)' }} width={mobile ? 25 : 40} />
|
||||||
<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>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status table */}
|
{/* Status Table — 4 cols desktop */}
|
||||||
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
|
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem', gridColumn: colSpan(4) }}>
|
||||||
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>Все статусы</h3>
|
<SectionHeader title="Все статусы" subtitle={periodLabel} mobile={mobile} />
|
||||||
{statusPieData.length === 0 ? (
|
{statusPieData.length === 0 ? (
|
||||||
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1rem' }}>Нет данных</div>
|
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1rem' }}>Нет данных</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'grid', gridTemplateColumns: mobile ? '8px 1fr 50px 40px' : '10px 1fr 70px 55px',
|
display: 'grid', gridTemplateColumns: mobile ? '8px 1fr 45px 40px' : '12px 1fr 60px 55px',
|
||||||
gap: '0 0.4rem', padding: '0.3rem 0.3rem', alignItems: 'center',
|
gap: '0 0.5rem', padding: '0.4rem 0.3rem', alignItems: 'center',
|
||||||
borderBottom: '1px solid var(--color-border)', fontSize: fontSize.xs,
|
borderBottom: '1px solid var(--color-border)', fontSize: fontSize.xs,
|
||||||
color: 'var(--color-text-muted)', fontWeight: 600,
|
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 /><div>Статус</div><div style={{ textAlign: 'right' }}>Кол-во</div><div style={{ textAlign: 'right' }}>Доля</div>
|
||||||
</div>
|
</div>
|
||||||
{statusPieData.map(s => {
|
{statusPieData.map(s => {
|
||||||
const pct = totalGroups > 0 ? ((s.value / totalGroups) * 100).toFixed(1) : 0;
|
const pct = totalGroups > 0 ? ((s.value / totalGroups) * 100).toFixed(1) : 0;
|
||||||
return (
|
return (
|
||||||
<div key={s.status} style={{
|
<div key={s.status} style={{
|
||||||
display: 'grid', gridTemplateColumns: mobile ? '8px 1fr 50px 40px' : '10px 1fr 70px 55px',
|
display: 'grid', gridTemplateColumns: mobile ? '8px 1fr 45px 40px' : '12px 1fr 60px 55px',
|
||||||
gap: '0 0.4rem', padding: '0.4rem 0.3rem', alignItems: 'center',
|
gap: '0 0.5rem', padding: '0.5rem 0.3rem', alignItems: 'center',
|
||||||
borderBottom: '1px solid var(--color-border, rgba(51,65,85,0.4))',
|
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' }}>
|
<div style={{ width: mobile ? '8px' : '12px', height: mobile ? '8px' : '12px', borderRadius: '3px', background: STATUS_COLORS[s.status] || '#6b7280' }} />
|
||||||
{pct}%
|
<div style={{ fontSize: fontSize.m, color: 'var(--color-text)' }}>{s.name}</div>
|
||||||
</span>
|
<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>
|
||||||
<div style={{ fontSize: mobile ? '0.65rem' : '0.72rem', color: 'var(--color-text-muted)', textAlign: 'center', maxWidth: mobile ? '180px' : '220px' }}>
|
);
|
||||||
{step.label}
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* Funnel — 4 cols desktop */}
|
||||||
|
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem', gridColumn: colSpan(4) }}>
|
||||||
|
<SectionHeader title="Путь заказа" subtitle={periodLabel} mobile={mobile} />
|
||||||
|
{totalGroups === 0 ? (
|
||||||
|
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1rem' }}>Нет данных</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0', padding: '0.4rem 0' }}>
|
||||||
|
{funnelSteps.map((step, i) => {
|
||||||
|
const pct = totalGroups > 0 ? Math.round((step.value / totalGroups) * 100) : 0;
|
||||||
|
const widthPct = step.value > 0 ? Math.max(15, (step.value / totalGroups) * 100) : 15;
|
||||||
|
return (
|
||||||
|
<div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1px', width: '100%' }}>
|
||||||
|
<div style={{ fontSize: mobile ? '0.8rem' : '0.9rem', fontWeight: 700, color: 'var(--color-text)', textAlign: 'center' }}>
|
||||||
|
{step.value}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
width: widthPct + '%', height: mobile ? '28px' : '34px', background: step.value > 0 ? step.color : 'var(--color-border, #334155)',
|
||||||
|
borderRadius: '4px', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
transition: 'width 0.4s ease', minWidth: '40px', maxWidth: '100%',
|
||||||
|
opacity: step.value > 0 ? 1 : 0.5,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: mobile ? '0.6rem' : '0.72rem', fontWeight: 600, color: step.value > 0 ? '#fff' : 'var(--color-text-muted)', textShadow: step.value > 0 ? '0 1px 2px rgba(0,0,0,0.3)' : 'none' }}>
|
||||||
|
{pct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: mobile ? '0.65rem' : '0.75rem', color: 'var(--color-text-muted)', textAlign: 'center', maxWidth: mobile ? '180px' : '240px' }}>
|
||||||
|
{step.label}
|
||||||
|
</div>
|
||||||
|
{i < funnelSteps.length - 1 && (
|
||||||
|
<div style={{ width: '2px', height: '4px', background: 'var(--color-border)' }} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{i < funnelSteps.length - 1 && (
|
);
|
||||||
<div style={{ width: '2px', height: '4px', background: 'var(--color-border)' }} />
|
})}
|
||||||
)}
|
|
||||||
|
<div style={{
|
||||||
|
display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr' : '1fr 1fr 1fr', gap: '0.5rem',
|
||||||
|
marginTop: '0.75rem', paddingTop: '0.6rem', borderTop: '1px solid var(--color-border)',
|
||||||
|
}}>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: fontSize.xs, color: '#22c55e', marginBottom: '1px' }}>Полная цепочка</div>
|
||||||
|
<div style={{ fontSize: mobile ? '0.95rem' : '1.1rem', fontWeight: 700, color: '#22c55e' }}>{econ.full_chain_pct ?? 0}%</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: fontSize.xs, color: '#f97316', marginBottom: '1px' }}>В обход</div>
|
||||||
|
<div style={{ fontSize: mobile ? '0.95rem' : '1.1rem', fontWeight: 700, color: '#f97316' }}>{econ.bypassed_pct ?? 0}%</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center', display: mobile ? 'none' : 'block' }}>
|
||||||
|
<div style={{ fontSize: fontSize.xs, color: 'var(--color-text-muted)', marginBottom: '1px' }}>Завершено</div>
|
||||||
|
<div style={{ fontSize: mobile ? '0.95rem' : '1.1rem', fontWeight: 700, color: 'var(--color-text)' }}>{completedTotal}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<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>
|
</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>
|
</div>
|
||||||
)}
|
</Panel>
|
||||||
</Panel>
|
|
||||||
|
|
||||||
{/* SMS */}
|
{/* Drivers — 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)' }}>SMS</h3>
|
<SectionHeader title="По водителям" subtitle={periodLabel} mobile={mobile} />
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.5rem' }}>
|
{driverData.length === 0 ? (
|
||||||
{[
|
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '2rem' }}>Нет данных</div>
|
||||||
{ label: 'SMS 1', val: econ.sms1_sent_count ?? 0 },
|
) : (
|
||||||
{ label: 'SMS 2', val: econ.sms2_sent_count ?? 0 },
|
<ResponsiveContainer width="100%" height={Math.max(180, driverData.length * (mobile ? 35 : 50))}>
|
||||||
{ label: 'Всего', val: (econ.sms1_sent_count || 0) + (econ.sms2_sent_count || 0) },
|
<BarChart data={driverData} layout="vertical" margin={{ left: mobile ? 5 : 20, right: mobile ? 5 : 30 }}>
|
||||||
].map((item, i) => (
|
<XAxis type="number" tick={{ fontSize: mobile ? 9 : 11, fill: 'var(--color-text-muted)' }} />
|
||||||
<div key={i} style={{ textAlign: 'center' }}>
|
<YAxis type="category" dataKey="name" tick={{ fontSize: mobile ? 9 : 11, fill: 'var(--color-text-muted)' }} width={mobile ? 80 : 130} />
|
||||||
<div style={{ fontSize: fontSize.xs, color: 'var(--color-text-muted)', marginBottom: '1px' }}>{item.label}</div>
|
<Tooltip content={<CustomTooltip />} />
|
||||||
<div style={{ fontSize: mobile ? '1rem' : '1.1rem', fontWeight: 700, color: 'var(--color-text)' }}>{item.val}</div>
|
<Legend wrapperStyle={{ fontSize: fontSize.xs }} />
|
||||||
</div>
|
<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>
|
||||||
</Panel>
|
</div>
|
||||||
|
|
||||||
{/* 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -0,0 +1,255 @@
|
||||||
|
/**
|
||||||
|
* @file BusinessSchedulePanel.jsx
|
||||||
|
* @description Admin panel for configuring work days: delivery, pickup, SMS.
|
||||||
|
* Days stored in business_schedule_settings table as INT[] (1=Mon ... 7=Sun).
|
||||||
|
*/
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
|
import { Button } from "../UI/Button";
|
||||||
|
import { Badge } from "../UI/Badge";
|
||||||
|
import { supabase } from "../../supabaseClient";
|
||||||
|
|
||||||
|
const DAY_LABELS = [
|
||||||
|
{ num: 1, label: "Пн", full: "Понедельник" },
|
||||||
|
{ num: 2, label: "Вт", full: "Вторник" },
|
||||||
|
{ num: 3, label: "Ср", full: "Среда" },
|
||||||
|
{ num: 4, label: "Чт", full: "Четверг" },
|
||||||
|
{ num: 5, label: "Пт", full: "Пятница" },
|
||||||
|
{ num: 6, label: "Сб", full: "Суббота" },
|
||||||
|
{ num: 7, label: "Вс", full: "Воскресенье" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_SCHEDULE = {
|
||||||
|
deliveryDays: [1, 2, 3, 4, 5],
|
||||||
|
pickupDays: [1, 2, 3, 4, 5],
|
||||||
|
smsDays: [1, 2, 3, 4, 5],
|
||||||
|
};
|
||||||
|
|
||||||
|
const DayPills = ({ selectedDays, onToggle, accentColor }) => {
|
||||||
|
const toggle = (num) => {
|
||||||
|
if (selectedDays.includes(num)) {
|
||||||
|
onToggle(selectedDays.filter((d) => d !== num).sort());
|
||||||
|
} else {
|
||||||
|
onToggle([...selectedDays, num].sort());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{DAY_LABELS.map((day) => {
|
||||||
|
const isSelected = selectedDays.includes(day.num);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={day.num}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(day.num)}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
title={day.full}
|
||||||
|
className={[
|
||||||
|
"min-w-[48px] rounded-[14px] border-2 px-3 py-2.5 text-center text-sm font-semibold transition select-none",
|
||||||
|
isSelected
|
||||||
|
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
||||||
|
: "border-[var(--color-border)] bg-[var(--color-surface-strong)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-text)]",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{day.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BusinessSchedulePanel = () => {
|
||||||
|
const [schedule, setSchedule] = useState(DEFAULT_SCHEDULE);
|
||||||
|
const [original, setOriginal] = useState(DEFAULT_SCHEDULE);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [savedAt, setSavedAt] = useState(null);
|
||||||
|
|
||||||
|
const loadSchedule = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const { data, error: rpcError } = await supabase.rpc("get_business_schedule");
|
||||||
|
if (rpcError) throw rpcError;
|
||||||
|
if (data?.ok) {
|
||||||
|
const loaded = {
|
||||||
|
deliveryDays: data.deliveryDays || DEFAULT_SCHEDULE.deliveryDays,
|
||||||
|
pickupDays: data.pickupDays || DEFAULT_SCHEDULE.pickupDays,
|
||||||
|
smsDays: data.smsDays || DEFAULT_SCHEDULE.smsDays,
|
||||||
|
};
|
||||||
|
setSchedule(loaded);
|
||||||
|
setOriginal(loaded);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Не удалось загрузить расписание");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSchedule();
|
||||||
|
}, [loadSchedule]);
|
||||||
|
|
||||||
|
const hasChanges =
|
||||||
|
JSON.stringify(schedule.deliveryDays) !== JSON.stringify(original.deliveryDays) ||
|
||||||
|
JSON.stringify(schedule.pickupDays) !== JSON.stringify(original.pickupDays) ||
|
||||||
|
JSON.stringify(schedule.smsDays) !== JSON.stringify(original.smsDays);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!hasChanges) return;
|
||||||
|
|
||||||
|
// Validate at least one day per category
|
||||||
|
if (schedule.deliveryDays.length === 0 || schedule.pickupDays.length === 0 || schedule.smsDays.length === 0) {
|
||||||
|
setError("Каждая категория должна иметь хотя бы один рабочий день");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const { data, error: rpcError } = await supabase.rpc("update_business_schedule", {
|
||||||
|
p_delivery_days: schedule.deliveryDays,
|
||||||
|
p_pickup_days: schedule.pickupDays,
|
||||||
|
p_sms_days: schedule.smsDays,
|
||||||
|
});
|
||||||
|
if (rpcError) throw rpcError;
|
||||||
|
if (data?.ok) {
|
||||||
|
setOriginal({ ...schedule });
|
||||||
|
setSavedAt(new Date());
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Не удалось сохранить расписание");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDays = (days) => {
|
||||||
|
return days
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.map((d) => DAY_LABELS.find((dl) => dl.num === d)?.label)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Panel className="p-5 sm:p-6">
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">Загрузка расписания…</p>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Расписание</p>
|
||||||
|
<h1 className="mt-1 text-2xl font-bold leading-tight">Рабочие дни</h1>
|
||||||
|
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||||
|
Настройте, в какие дни доступны доставка, самовывоз и отправка SMS-приглашений.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{savedAt && !hasChanges && (
|
||||||
|
<Badge tone="accent">Сохранено</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delivery days */}
|
||||||
|
<Panel className="p-5 sm:p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xl">🚚</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||||
|
Доставка
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">
|
||||||
|
Дни, в которые клиент может выбрать доставку. В остальные дни доставка не предлагается.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DayPills
|
||||||
|
selectedDays={schedule.deliveryDays}
|
||||||
|
onToggle={(days) => setSchedule((s) => ({ ...s, deliveryDays: days }))}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">
|
||||||
|
Выбрано: {formatDays(schedule.deliveryDays) || "—"}
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* Pickup days */}
|
||||||
|
<Panel className="p-5 sm:p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xl">🏪</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||||
|
Самовывоз
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">
|
||||||
|
Дни, в которые клиент может выбрать самовывоз. В остальные дни самовывоз не предлагается.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DayPills
|
||||||
|
selectedDays={schedule.pickupDays}
|
||||||
|
onToggle={(days) => setSchedule((s) => ({ ...s, pickupDays: days }))}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">
|
||||||
|
Выбрано: {formatDays(schedule.pickupDays) || "—"}
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* SMS days */}
|
||||||
|
<Panel className="p-5 sm:p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xl">📨</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||||
|
SMS-приглашения
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">
|
||||||
|
Дни, в которые отправляются SMS-приглашения клиентам. В нерабочие дни SMS не отправляются (проверка статусов работает круглосуточно).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DayPills
|
||||||
|
selectedDays={schedule.smsDays}
|
||||||
|
onToggle={(days) => setSchedule((s) => ({ ...s, smsDays: days }))}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">
|
||||||
|
Выбрано: {formatDays(schedule.smsDays) || "—"}
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* Save */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant={hasChanges ? "primary" : "ghost"}
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!hasChanges || isSaving}
|
||||||
|
>
|
||||||
|
{isSaving ? "Сохранение…" : "Сохранить расписание"}
|
||||||
|
</Button>
|
||||||
|
{hasChanges && !isSaving && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setSchedule({ ...original })}
|
||||||
|
>
|
||||||
|
Отменить изменения
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Panel className="border border-[var(--color-danger)] bg-[var(--color-surface)] p-4 text-sm text-[var(--color-danger)]">
|
||||||
|
{error}
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -11,6 +11,9 @@ 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 }) => {
|
||||||
|
|
@ -34,17 +37,30 @@ 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.4rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
<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: '0 0 auto', width: mobile ? '70px' : '110px', fontSize: fontSize.xs, color: 'var(--color-text-muted)', textAlign: 'right' }}>{label}</div>
|
||||||
<div style={{ flex: '1 1 auto', height: mobile ? '16px' : '20px', background: 'var(--color-border, rgba(51,65,85,0.4))', borderRadius: '4px', overflow: 'hidden' }}>
|
<div style={{ flex: '1 1 auto', height: mobile ? '16px' : '22px', background: 'var(--color-border, rgba(51,65,85,0.4))', borderRadius: '4px', overflow: 'hidden' }}>
|
||||||
<div style={{ width: pct + '%', height: '100%', background: color, borderRadius: '4px', transition: 'width 0.4s ease', minWidth: pct > 0 ? '4px' : '0' }} />
|
<div 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' : '45px', fontSize: fontSize.s, fontWeight: 600, color: 'var(--color-text)', textAlign: 'left' }}>{value}</div>
|
<div style={{ flex: '0 0 auto', width: mobile ? '36px' : '50px', fontSize: fontSize.s, fontWeight: 700, color: 'var(--color-text)', textAlign: 'left' }}>{value}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PickupStatsPanel = ({ stats, isLoading, mobile, fontSize }) => {
|
const SectionHeader = ({ title, subtitle, mobile }) => (
|
||||||
|
<div style={{ marginBottom: '0.6rem' }}>
|
||||||
|
<h3 style={{ fontSize: mobile ? '0.9rem' : '1rem', fontWeight: 700, color: 'var(--color-text)', marginBottom: subtitle ? '0.1rem' : 0 }}>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
{subtitle && (
|
||||||
|
<div style={{ fontSize: mobile ? '0.65rem' : '0.72rem', color: 'var(--color-text-muted)' }}>
|
||||||
|
{subtitle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PickupStatsPanel = ({ stats, isLoading, mobile, fontSize, periodLabel = '' }) => {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Panel>
|
<Panel>
|
||||||
|
|
@ -65,103 +81,137 @@ export const PickupStatsPanel = ({ stats, isLoading, mobile, fontSize }) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fs = fontSize || { xs: '0.65rem', s: '0.68rem', m: '0.78rem', l: '0.85rem', xl: '1.1rem' };
|
const fs = fontSize || { xs: '0.72rem', s: '0.78rem', m: '0.85rem', l: '0.95rem' };
|
||||||
const totalPickups = Number(stats.total_pickups) || 0;
|
const 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 maxDay = Math.max(
|
const pickupToday = Number(stats.pickup_today) || 0;
|
||||||
Number(stats.pickup_today) || 0,
|
const pickupTomorrow = Number(stats.pickup_tomorrow) || 0;
|
||||||
Number(stats.pickup_tomorrow) || 0,
|
const pickupDayAfter = Number(stats.pickup_day_after) || 0;
|
||||||
Number(stats.pickup_day_after) || 0,
|
const pickupFirstHalf = Number(stats.pickup_first_half) || 0;
|
||||||
1
|
const pickupSecondHalf = Number(stats.pickup_second_half) || 0;
|
||||||
);
|
const pickupOnSaturday = Number(stats.pickup_on_saturday) || 0;
|
||||||
|
|
||||||
const maxHalf = Math.max(
|
const hasScheduledPickups = pickupToday + pickupTomorrow + pickupDayAfter > 0;
|
||||||
Number(stats.pickup_first_half) || 0,
|
const hasTimeSlots = pickupFirstHalf + pickupSecondHalf > 0;
|
||||||
Number(stats.pickup_second_half) || 0,
|
|
||||||
1
|
const maxDay = Math.max(pickupToday, pickupTomorrow, pickupDayAfter, 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' : '1rem' }}>
|
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem' }}>
|
||||||
<h3 style={{ fontSize: fs.l, fontWeight: 600, marginBottom: '0.5rem', color: 'var(--color-text)' }}>
|
<SectionHeader title="📦 Самовывоз" subtitle={periodLabel} mobile={mobile} />
|
||||||
📦 Самовывоз
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{/* KPI row */}
|
{/* KPI row */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr 1fr' : '1fr 1fr 1fr', gap: '0.5rem', marginBottom: '0.75rem' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr 1fr 1fr' : 'repeat(auto-fit, minmax(120px, 1fr))', gap: '0.5rem', marginBottom: '1rem' }}>
|
||||||
{[
|
{[
|
||||||
{ label: 'Всего самовывоз', val: totalPickups, color: '#f59e0b' },
|
{ label: 'Всего', val: totalPickups, color: '#f59e0b' },
|
||||||
{ label: 'Доля самовывоза', val: pickupRate + '%', color: '#f59e0b' },
|
{ label: 'Завершено', val: pickedUp, color: '#14b8a6' },
|
||||||
{ label: 'Ср. дней до выдачи', val: avgDays !== null ? avgDays : '—', color: '#3b82f6' },
|
{ label: 'Ожидает', val: pending, color: '#94a3b8' },
|
||||||
|
{ label: 'Доля', val: pickupRate + '%', color: '#f59e0b' },
|
||||||
].map((kpi, i) => (
|
].map((kpi, i) => (
|
||||||
<div key={i} style={{ textAlign: 'center' }}>
|
<div key={i} style={{ textAlign: 'center', padding: '0.4rem 0.2rem' }}>
|
||||||
<div style={{ fontSize: fs.xs, color: 'var(--color-text-muted)', marginBottom: '1px' }}>{kpi.label}</div>
|
<div style={{ fontSize: fs.xs, color: 'var(--color-text-muted)', marginBottom: '2px', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{kpi.label}</div>
|
||||||
<div style={{ fontSize: mobile ? '1rem' : '1.2rem', fontWeight: 700, color: kpi.color }}>{kpi.val}</div>
|
<div style={{ fontSize: mobile ? '1.05rem' : '1.3rem', fontWeight: 800, color: kpi.color }}>{kpi.val}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Distribution by day */}
|
{/* Content grid: status breakdown | schedule | donut */}
|
||||||
<div style={{ marginBottom: '0.6rem' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: innerCols, gap: mobile ? '0.75rem' : '1.5rem' }}>
|
||||||
<div style={{ fontSize: fs.m, fontWeight: 600, color: 'var(--color-text)', marginBottom: '0.3rem' }}>По дням</div>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
|
|
||||||
<ProgressBar label="Сегодня" value={Number(stats.pickup_today) || 0} max={maxDay} color={PICKUP_COLORS.today} fontSize={fs} mobile={mobile} />
|
|
||||||
<ProgressBar label="Завтра" value={Number(stats.pickup_tomorrow) || 0} max={maxDay} color={PICKUP_COLORS.tomorrow} fontSize={fs} mobile={mobile} />
|
|
||||||
<ProgressBar label="Послезавтра" value={Number(stats.pickup_day_after) || 0} max={maxDay} color={PICKUP_COLORS.dayAfter} fontSize={fs} mobile={mobile} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Half-day split */}
|
{/* Status breakdown */}
|
||||||
<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>
|
||||||
<div style={{ fontSize: fs.m, fontWeight: 600, color: 'var(--color-text)', marginBottom: '0.3rem' }}>Доставка vs Самовывоз</div>
|
<div style={{ fontSize: fs.m, fontWeight: 700, color: 'var(--color-text)', marginBottom: '0.4rem' }}>По статусам</div>
|
||||||
<ResponsiveContainer width="100%" height={mobile ? 140 : 170}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
|
||||||
<PieChart>
|
<ProgressBar label="Завершено" value={pickedUp} max={Math.max(totalPickups, 1)} color={PICKUP_COLORS.picked_up} fontSize={fs} mobile={mobile} />
|
||||||
<Pie data={pieData} cx="50%" cy="50%"
|
<ProgressBar label="Ожидает" value={pending} max={Math.max(totalPickups, 1)} color={PICKUP_COLORS.pending} fontSize={fs} mobile={mobile} />
|
||||||
innerRadius={mobile ? 30 : 40}
|
<ProgressBar label="Ручное" value={manual} max={Math.max(totalPickups, 1)} color={PICKUP_COLORS.manual} fontSize={fs} mobile={mobile} />
|
||||||
outerRadius={mobile ? 55 : 70}
|
</div>
|
||||||
dataKey="value" nameKey="name" paddingAngle={2}
|
{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)' }}>
|
||||||
{pieData.map((entry, i) => (
|
<span style={{ fontSize: fs.s, color: 'var(--color-text-muted)' }}>Ср. дней до выдачи</span>
|
||||||
<Cell key={i} fill={entry.fill} />
|
<span style={{ fontSize: fs.l, fontWeight: 700, color: '#3b82f6' }}>{avgDays}</span>
|
||||||
))}
|
</div>
|
||||||
</Pie>
|
)}
|
||||||
<Tooltip content={<CustomTooltip />} />
|
</div>
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
{/* Schedule — only show if there are scheduled pickups */}
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', marginTop: '0.2rem' }}>
|
<div>
|
||||||
{pieData.map((d, i) => (
|
<div style={{ fontSize: fs.m, fontWeight: 700, color: 'var(--color-text)', marginBottom: '0.4rem' }}>Расписание</div>
|
||||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: fs.xs }}>
|
{hasScheduledPickups ? (
|
||||||
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: d.fill }} />
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem', marginBottom: '0.5rem' }}>
|
||||||
<span style={{ color: 'var(--color-text-muted)' }}>{d.name}: <strong style={{ color: 'var(--color-text)' }}>{d.value}</strong></span>
|
<ProgressBar label="Сегодня" value={pickupToday} max={maxDay} color={PICKUP_COLORS.today} fontSize={fs} mobile={mobile} />
|
||||||
</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{/* Delivery vs Pickup donut */}
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: fs.m, fontWeight: 700, color: 'var(--color-text)', marginBottom: '0.4rem' }}>Доставка vs Самовывоз</div>
|
||||||
|
{pieData.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<ResponsiveContainer width="100%" height={mobile ? 140 : 180}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie data={pieData} cx="50%" cy="50%"
|
||||||
|
innerRadius={mobile ? 30 : 45}
|
||||||
|
outerRadius={mobile ? 55 : 75}
|
||||||
|
dataKey="value" nameKey="name" paddingAngle={2}
|
||||||
|
>
|
||||||
|
{pieData.map((entry, i) => (
|
||||||
|
<Cell key={i} fill={entry.fill} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', marginTop: '0.3rem' }}>
|
||||||
|
{pieData.map((d, i) => (
|
||||||
|
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: fs.xs }}>
|
||||||
|
<div style={{ width: '10px', height: '10px', borderRadius: '2px', background: d.fill }} />
|
||||||
|
<span style={{ color: 'var(--color-text-muted)' }}>{d.name}: <strong style={{ color: 'var(--color-text)' }}>{d.value}</strong></span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '2rem 0', fontSize: fs.s }}>
|
||||||
|
Нет данных
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -36,24 +36,27 @@ const getCrimeaHour = (referenceDate = new Date()) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isWeekend = (dateKey) => {
|
const isAllowedPickupDay = (dateKey, allowedDays = [1,2,3,4,5]) => {
|
||||||
|
if (!dateKey) return false;
|
||||||
const d = new Date(`${dateKey}T12:00:00Z`);
|
const d = new Date(`${dateKey}T12:00:00Z`);
|
||||||
const day = d.getUTCDay();
|
// getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
|
||||||
return day === 0 || day === 6;
|
// allowedDays uses 1=Mon ... 7=Sun
|
||||||
|
const dayNum = d.getUTCDay() === 0 ? 7 : d.getUTCDay();
|
||||||
|
return allowedDays.includes(dayNum);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getNextWorkday = (dateKey) => {
|
const getNextPickupWorkday = (dateKey, allowedDays = [1,2,3,4,5]) => {
|
||||||
let next = addDaysKey(dateKey, 1);
|
let next = addDaysKey(dateKey, 1);
|
||||||
while (isWeekend(next)) {
|
while (!isAllowedPickupDay(next, allowedDays)) {
|
||||||
next = addDaysKey(next, 1);
|
next = addDaysKey(next, 1);
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPickupSlots = (referenceDate = new Date()) => {
|
const getPickupSlots = (referenceDate = new Date(), pickupDays = [1,2,3,4,5]) => {
|
||||||
const todayKey = getCrimeaTodayKey(referenceDate);
|
const todayKey = getCrimeaTodayKey(referenceDate);
|
||||||
const hour = getCrimeaHour(referenceDate);
|
const hour = getCrimeaHour(referenceDate);
|
||||||
const isTodayWorkday = !isWeekend(todayKey);
|
const isTodayWorkday = isAllowedPickupDay(todayKey, pickupDays);
|
||||||
|
|
||||||
const slots = [];
|
const slots = [];
|
||||||
|
|
||||||
|
|
@ -77,7 +80,7 @@ const getPickupSlots = (referenceDate = new Date()) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const tomorrow = addDaysKey(todayKey, 1);
|
const tomorrow = addDaysKey(todayKey, 1);
|
||||||
const tomorrowWorkday = !isWeekend(tomorrow) ? tomorrow : getNextWorkday(todayKey);
|
const tomorrowWorkday = isAllowedPickupDay(tomorrow, pickupDays) ? tomorrow : getNextPickupWorkday(todayKey, pickupDays);
|
||||||
slots.push({
|
slots.push({
|
||||||
id: `pickup-${tomorrowWorkday}-first`,
|
id: `pickup-${tomorrowWorkday}-first`,
|
||||||
date: tomorrowWorkday,
|
date: tomorrowWorkday,
|
||||||
|
|
@ -94,7 +97,7 @@ const getPickupSlots = (referenceDate = new Date()) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const dayAfter = addDaysKey(tomorrowWorkday, 1);
|
const dayAfter = addDaysKey(tomorrowWorkday, 1);
|
||||||
const dayAfterWorkday = !isWeekend(dayAfter) ? dayAfter : getNextWorkday(dayAfter);
|
const dayAfterWorkday = isAllowedPickupDay(dayAfter, pickupDays) ? dayAfter : getNextPickupWorkday(dayAfter, pickupDays);
|
||||||
slots.push({
|
slots.push({
|
||||||
id: `pickup-${dayAfterWorkday}-first`,
|
id: `pickup-${dayAfterWorkday}-first`,
|
||||||
date: dayAfterWorkday,
|
date: dayAfterWorkday,
|
||||||
|
|
@ -152,8 +155,9 @@ 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), [referenceDate]);
|
const slots = React.useMemo(() => getPickupSlots(referenceDate, pickupDays), [referenceDate, pickupDays]);
|
||||||
|
|
||||||
if (!slots.length) {
|
if (!slots.length) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -74,21 +74,18 @@ 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 === "Доставлен" || currentStatus === "Проблема доставки" || currentStatus === "Закрыт" || currentStatus === "Отменён") {
|
} else if (currentStatus === "Доставлен") {
|
||||||
|
actionButtons = [
|
||||||
|
{ value: "Назначен водитель", label: "Вернуть в работу" },
|
||||||
|
];
|
||||||
|
} else if (currentStatus === "Проблема доставки" || currentStatus === "Закрыт" || currentStatus === "Отменён") {
|
||||||
actionButtons = [];
|
actionButtons = [];
|
||||||
} else {
|
} else {
|
||||||
actionButtons = availableTransitions.map((status) => ({
|
actionButtons = availableTransitions.map((status) => ({
|
||||||
|
|
@ -98,7 +95,7 @@ export const DriverDeliveryDetail = ({ order, onStatusChange }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4 fs-zone-card">
|
||||||
{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">
|
<div className="space-y-4 fs-zone-table">
|
||||||
<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">
|
||||||
|
|
|
||||||
|
|
@ -138,11 +138,17 @@ 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 when order data changes (e.g. after save)
|
// Sync state ONLY when order ID changes (navigating to different order), not on every order data update
|
||||||
|
const orderId = order?.id;
|
||||||
|
const prevOrderId = React.useRef(orderId);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setShippedItems(initialShippedIds);
|
if (prevOrderId.current !== orderId) {
|
||||||
setComments(initialComments);
|
prevOrderId.current = orderId;
|
||||||
}, [initialShippedIds, initialComments]);
|
setShippedItems(initialShippedIds);
|
||||||
|
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(() => {
|
||||||
|
|
@ -170,7 +176,7 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentDeliveryStatus = order?.deliveryStatus || order?.delivery_status;
|
const currentDeliveryStatus = order?.deliveryStatus || order?.delivery_status;
|
||||||
const isStatusFinal = ["delivered", "problem", "picked_up"].includes(currentDeliveryStatus);
|
const isStatusFinal = ["delivered", "problem", "picked_up", "loaded", "on_route", "driver_assigned"].includes(currentDeliveryStatus);
|
||||||
|
|
||||||
const unshipAll = () => {
|
const unshipAll = () => {
|
||||||
if (isStatusFinal && onResetStatus) {
|
if (isStatusFinal && onResetStatus) {
|
||||||
|
|
@ -222,7 +228,7 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Panel className="space-y-3 p-5">
|
<Panel className="space-y-3 p-5 fs-zone-card">
|
||||||
<strong>Состав заказа</strong>
|
<strong>Состав заказа</strong>
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">Позиции не указаны</p>
|
<p className="text-sm text-[var(--color-text-muted)]">Позиции не указаны</p>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
@ -230,7 +236,7 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel className="space-y-4 p-5">
|
<Panel className="space-y-4 p-5 fs-zone-card">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<strong>Отгрузка</strong>
|
<strong>Отгрузка</strong>
|
||||||
|
|
@ -250,7 +256,7 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
|
||||||
Отгрузить всё
|
Отгрузить всё
|
||||||
</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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -158,12 +158,12 @@ const renderRow = (group, onSelectSet) => (
|
||||||
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]">—</span>}
|
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]">—</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-1.5">
|
<div className="px-3 py-1.5">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex flex-col gap-1">
|
||||||
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge>
|
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge>
|
||||||
{(group.hasDeliveryProblem || group.has_delivery_problem) && (
|
{(group.hasDeliveryProblem || group.has_delivery_problem) && (
|
||||||
<span
|
<span
|
||||||
title={group.deliveryProblemNote || group.delivery_problem_note || "Есть проблемы с отгрузкой позиций"}
|
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)]"
|
className="inline-flex w-fit items-center gap-0.5 rounded-full bg-[rgba(239,68,68,0.12)] px-1.5 py-0.5 text-[10px] font-bold text-[var(--color-danger)]"
|
||||||
>
|
>
|
||||||
⚠ Проблема
|
⚠ Проблема
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -255,7 +255,17 @@ const SortableSection = ({ statusValue, label, groups, isCollapsed, onToggle, on
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS, isLoading = false }) => {
|
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS, isLoading = false }) => {
|
||||||
const [filters, setFilters] = React.useState({ query: "", displayStatus: "all", city: "" });
|
const FILTERS_KEY = 'logistics-board-filters';
|
||||||
|
const [filters, setFilters] = React.useState(() => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(FILTERS_KEY);
|
||||||
|
if (raw) return { query: '', displayStatus: 'all', city: '', ...JSON.parse(raw) };
|
||||||
|
} catch {}
|
||||||
|
return { query: '', displayStatus: 'all', city: '' };
|
||||||
|
});
|
||||||
|
React.useEffect(() => {
|
||||||
|
try { localStorage.setItem(FILTERS_KEY, JSON.stringify(filters)); } catch {}
|
||||||
|
}, [filters]);
|
||||||
const [collapsedSections, setCollapsedSections] = React.useState(() => loadCollapsedSections());
|
const [collapsedSections, setCollapsedSections] = React.useState(() => loadCollapsedSections());
|
||||||
const [sectionOrder, setSectionOrder] = React.useState(() => {
|
const [sectionOrder, setSectionOrder] = React.useState(() => {
|
||||||
const custom = loadCustomOrder();
|
const custom = loadCustomOrder();
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ const CalendarWidget = ({
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className={layoutClassName}>
|
<div className={layoutClassName}>
|
||||||
<div className={calendarClassName}>
|
<div className={(calendarClassName || "") + " relative"}>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
|
|
|
||||||
|
|
@ -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 || "Неизвестно"}
|
{order.assignedDriverName || drivers.find((d) => d.id === order.assignedDriverId)?.name || "Водитель назначен"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge tone="accent">Назначен</Badge>
|
<Badge tone="accent">Назначен</Badge>
|
||||||
|
|
|
||||||
|
|
@ -2,42 +2,60 @@
|
||||||
const DriverShipmentReport = ({ shipmentData }) => {
|
const DriverShipmentReport = ({ shipmentData }) => {
|
||||||
if (!Array.isArray(shipmentData) || shipmentData.length === 0) return null;
|
if (!Array.isArray(shipmentData) || shipmentData.length === 0) return null;
|
||||||
|
|
||||||
|
const deliveredItems = shipmentData.filter((i) => i.shipped);
|
||||||
|
const notDeliveredItems = shipmentData.filter((i) => !i.shipped);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel className="space-y-4 p-5 border-[var(--color-warning)]">
|
<Panel className="space-y-4 p-5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<svg className="h-5 w-5 text-[var(--color-warning)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<span className="text-lg">📋</span>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<strong>Отчёт об отгрузке</strong>
|
||||||
</svg>
|
<Badge tone={notDeliveredItems.length > 0 ? "warning" : "accent"}>
|
||||||
<strong className="text-[var(--color-warning)]">Проблемы с доставкой позиций</strong>
|
{deliveredItems.length}/{shipmentData.length} доставлено
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">
|
|
||||||
Не доставлено {shipmentData.length} {shipmentData.length === 1 ? "позиция" : shipmentData.length < 5 ? "позиции" : "позиций"}. Остальное — доставлено.
|
{notDeliveredItems.length > 0 && (
|
||||||
</p>
|
<div className="rounded-xl border border-[var(--color-warning)] bg-[var(--color-warning-soft)] px-3 py-2 text-sm text-[var(--color-warning)]">
|
||||||
|
⚠ Не доставлено {notDeliveredItems.length} {notDeliveredItems.length === 1 ? "позиция" : notDeliveredItems.length < 5 ? "позиции" : "позиций"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{shipmentData.map((item) => (
|
{shipmentData.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.id || item.name}
|
key={item.id || item.name}
|
||||||
className="rounded-[18px] border border-[var(--color-warning)] bg-[var(--color-warning-soft)] px-4 py-3 text-sm"
|
className={[
|
||||||
|
"rounded-[18px] border px-4 py-3 text-sm",
|
||||||
|
item.shipped
|
||||||
|
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"
|
||||||
|
: "border-[var(--color-warning)] bg-[var(--color-warning-soft)]"
|
||||||
|
].join(" ")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="text-[var(--color-text)]">{item.name}</span>
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={item.shipped ? "text-[var(--color-accent)]" : "text-[var(--color-warning)]"}>
|
||||||
|
{item.shipped ? "✓" : "✗"}
|
||||||
|
</span>
|
||||||
|
<span className={item.shipped ? "text-[var(--color-text-muted)] line-through" : "text-[var(--color-text)] font-medium"}>
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{item.quantity || item.unit ? (
|
{item.quantity || item.unit ? (
|
||||||
<Badge tone="neutral">{[item.quantity, item.unit].filter(Boolean).join(" ")}</Badge>
|
<Badge tone="neutral">{[item.quantity, item.unit].filter(Boolean).join(" ")}</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{item.comment ? (
|
{!item.shipped && item.comment && (
|
||||||
<p className="mt-1 text-xs text-[var(--color-text-muted)]">Причина: {item.comment}</p>
|
<p className="mt-1 ml-6 text-xs text-[var(--color-warning)]">Причина: {item.comment}</p>
|
||||||
) : (
|
|
||||||
<p className="mt-1 text-xs text-[var(--color-text-muted)] italic">Причина не указана</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { formatDateTime } from "../../utils/formatters";
|
import { formatDateTime } from "../../utils/formatters";
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
|
|
@ -54,7 +72,11 @@ import {
|
||||||
getOrderGroupStatusTone,
|
getOrderGroupStatusTone,
|
||||||
DELIVERY_GROUP_STATUS_LABELS,
|
DELIVERY_GROUP_STATUS_LABELS,
|
||||||
} from "../../services/orderGroupViews";
|
} from "../../services/orderGroupViews";
|
||||||
import { getErrorMessage, normalizeNom } from "../../utils/deliveryUtils";
|
import {
|
||||||
|
getErrorMessage,
|
||||||
|
normalizeNom,
|
||||||
|
} from "../../utils/deliveryUtils";
|
||||||
|
import { SmsStatusCard } from "./SmsStatusCard";
|
||||||
|
|
||||||
const fmtTime = (ts) => {
|
const fmtTime = (ts) => {
|
||||||
if (!ts) return "—";
|
if (!ts) return "—";
|
||||||
|
|
@ -331,6 +353,12 @@ const normalizeDateForInput = (value) => {
|
||||||
return `20${year}-${month}-${day}`;
|
return `20${year}-${month}-${day}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fullDateMatch = normalized.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
|
||||||
|
if (fullDateMatch) {
|
||||||
|
const [, day, month, year] = fullDateMatch;
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -594,11 +622,19 @@ export const OrderDetailPanel = ({
|
||||||
setIsSavingShipment(false);
|
setIsSavingShipment(false);
|
||||||
}
|
}
|
||||||
}, [onSaveShipmentData, order?.id]);
|
}, [onSaveShipmentData, order?.id]);
|
||||||
const minSelectableDateKey = React.useMemo(() => getNextSelectableDateKey(), []);
|
const minSelectableDateKey = React.useMemo(() => {
|
||||||
|
// Logistician/admin can set past dates for retroactive delivery closing
|
||||||
|
if (["logistician", "admin", "mega_admin", "manager"].includes(userRole)) {
|
||||||
|
const past = new Date();
|
||||||
|
past.setMonth(past.getMonth() - 3);
|
||||||
|
return toDateKey(past);
|
||||||
|
}
|
||||||
|
return getNextSelectableDateKey();
|
||||||
|
}, [userRole]);
|
||||||
const [currentMonth, setCurrentMonth] = React.useState(() => {
|
const [currentMonth, setCurrentMonth] = React.useState(() => {
|
||||||
const existingDeliveryDate = fromDateKey(order?.deliveryDate);
|
const existingDeliveryDate = fromDateKey(order?.deliveryDate);
|
||||||
const fallbackDate = fromDateKey(minSelectableDateKey) || new Date();
|
const fallbackDate = fromDateKey(minSelectableDateKey) || new Date();
|
||||||
const sourceDate = existingDeliveryDate && isFutureDeliveryDate(toDateKey(existingDeliveryDate))
|
const sourceDate = existingDeliveryDate
|
||||||
? existingDeliveryDate
|
? existingDeliveryDate
|
||||||
: fallbackDate;
|
: fallbackDate;
|
||||||
|
|
||||||
|
|
@ -613,7 +649,10 @@ export const OrderDetailPanel = ({
|
||||||
}),
|
}),
|
||||||
[currentMonth],
|
[currentMonth],
|
||||||
);
|
);
|
||||||
const canGoBack = toDateKey(currentMonth) > toDateKey(startOfMonth(fromDateKey(minSelectableDateKey) || new Date()));
|
const canGoBack = (() => {
|
||||||
|
if (["logistician", "admin", "mega_admin", "manager"].includes(userRole)) return true;
|
||||||
|
return toDateKey(currentMonth) > toDateKey(startOfMonth(fromDateKey(minSelectableDateKey) || new Date()));
|
||||||
|
})();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setSelectedDriverId(order?.assignedDriverId || "");
|
setSelectedDriverId(order?.assignedDriverId || "");
|
||||||
|
|
@ -621,9 +660,12 @@ export const OrderDetailPanel = ({
|
||||||
}, [order?.id, order?.assignedDriverId]);
|
}, [order?.id, order?.assignedDriverId]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const normalizedDeliveryDate = normalizeDateForInput(order?.deliveryDate);
|
const normalizedDeliveryDate = normalizeDateForInput(order?.deliveryDate || order?.customerDate);
|
||||||
const nextSelectableDateKey = getNextSelectableDateKey();
|
const nextSelectableDateKey = getNextSelectableDateKey();
|
||||||
const selectedDateKey = isFutureDeliveryDate(normalizedDeliveryDate) ? normalizedDeliveryDate : nextSelectableDateKey;
|
const canUsePastDate = ["logistician", "admin", "mega_admin", "manager"].includes(userRole);
|
||||||
|
const selectedDateKey = (normalizedDeliveryDate && (canUsePastDate || isFutureDeliveryDate(normalizedDeliveryDate)))
|
||||||
|
? normalizedDeliveryDate
|
||||||
|
: nextSelectableDateKey;
|
||||||
setDeliveryDate(selectedDateKey);
|
setDeliveryDate(selectedDateKey);
|
||||||
const selectedDate = fromDateKey(selectedDateKey) || new Date();
|
const selectedDate = fromDateKey(selectedDateKey) || new Date();
|
||||||
setCurrentMonth(startOfMonth(selectedDate));
|
setCurrentMonth(startOfMonth(selectedDate));
|
||||||
|
|
@ -686,7 +728,8 @@ export const OrderDetailPanel = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isFutureDeliveryDate(effectiveDate)) {
|
const canUsePastDate = ["logistician", "admin", "mega_admin", "manager"].includes(userRole);
|
||||||
|
if (!canUsePastDate && !isFutureDeliveryDate(effectiveDate)) {
|
||||||
setFormMessage(deliveryType === "pickup" ? "Выберите дату самовывоза позже сегодняшнего дня." : "Выберите дату доставки позже сегодняшнего дня.");
|
setFormMessage(deliveryType === "pickup" ? "Выберите дату самовывоза позже сегодняшнего дня." : "Выберите дату доставки позже сегодняшнего дня.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -889,10 +932,7 @@ export const OrderDetailPanel = ({
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">Обновлена</p>
|
<p className="text-xs text-[var(--color-text-muted)]">Обновлена</p>
|
||||||
<p className="font-medium !text-[var(--color-text)]">{formatDateTime(order.updatedAt)}</p>
|
<p className="font-medium !text-[var(--color-text)]">{formatDateTime(order.updatedAt)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">{isPickupOrder ? "Статус самовывоза" : "Статус доставки"}</p>
|
|
||||||
<p className="font-medium !text-[var(--color-text)]">{getOrderGroupDeliveryStatusLabel(order.deliveryStatus || order.delivery_status)}</p>
|
|
||||||
</div>
|
|
||||||
{(order.pickupCode || order.pickup_code) && (order.deliveryType === "pickup" || order.delivery_type === "pickup") ? (
|
{(order.pickupCode || order.pickup_code) && (order.deliveryType === "pickup" || order.delivery_type === "pickup") ? (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">Код выдачи</p>
|
<p className="text-xs text-[var(--color-text-muted)]">Код выдачи</p>
|
||||||
|
|
@ -1053,6 +1093,7 @@ export const OrderDetailPanel = ({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<SmsStatusCard order={order} userRole={userRole} />
|
||||||
|
|
||||||
<StatusActionPanel
|
<StatusActionPanel
|
||||||
order={order}
|
order={order}
|
||||||
|
|
@ -1087,7 +1128,7 @@ export const OrderDetailPanel = ({
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{userRole === "driver" && order ? (
|
{["driver", "logistician", "admin", "mega_admin"].includes(userRole) && order ? (
|
||||||
<DriverShipmentPanel
|
<DriverShipmentPanel
|
||||||
order={order}
|
order={order}
|
||||||
onShipmentChange={handleShipmentChange}
|
onShipmentChange={handleShipmentChange}
|
||||||
|
|
@ -1097,7 +1138,7 @@ export const OrderDetailPanel = ({
|
||||||
if (onChangeDeliveryStatus) {
|
if (onChangeDeliveryStatus) {
|
||||||
onChangeDeliveryStatus({
|
onChangeDeliveryStatus({
|
||||||
orderGroupId: order.id,
|
orderGroupId: order.id,
|
||||||
status: "loaded",
|
status: "driver_assigned",
|
||||||
}).then((response) => {
|
}).then((response) => {
|
||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
setFormMessage(response.error || "Не удалось сбросить статус");
|
setFormMessage(response.error || "Не удалось сбросить статус");
|
||||||
|
|
@ -1131,15 +1172,15 @@ export const OrderDetailPanel = ({
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{(() => {
|
{(() => {
|
||||||
const currentStatus = order.deliveryStatus || order.delivery_status;
|
const currentStatus = order.deliveryStatus || order.delivery_status;
|
||||||
const IN_TRANSIT_STATUSES = ["loaded", "on_route"];
|
|
||||||
const isOnRoute = IN_TRANSIT_STATUSES.includes(currentStatus);
|
|
||||||
const isPickup = (order.deliveryType || order.delivery_type) === "pickup" || currentStatus === "pickup";
|
const isPickup = (order.deliveryType || order.delivery_type) === "pickup" || currentStatus === "pickup";
|
||||||
|
|
||||||
let statusOptions = [];
|
let statusOptions = [];
|
||||||
if (currentStatus === "delivered" || currentStatus === "picked_up" || currentStatus === "problem" || currentStatus === "cancelled" || currentStatus === "paid_storage") {
|
if (currentStatus === "delivered" || currentStatus === "picked_up" || currentStatus === "problem") {
|
||||||
|
// Final statuses — show "Return to work" instead
|
||||||
|
statusOptions = [];
|
||||||
|
} else if (currentStatus === "cancelled" || currentStatus === "paid_storage") {
|
||||||
statusOptions = [];
|
statusOptions = [];
|
||||||
} else {
|
} else {
|
||||||
// Primary button matches delivery type, secondary requires confirmation
|
|
||||||
if (isPickup) {
|
if (isPickup) {
|
||||||
statusOptions = [
|
statusOptions = [
|
||||||
{ value: "picked_up", label: "Вывезено", mismatch: false },
|
{ value: "picked_up", label: "Вывезено", mismatch: false },
|
||||||
|
|
@ -1155,7 +1196,6 @@ export const OrderDetailPanel = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// "Return to work" button for final statuses
|
|
||||||
const canReturn = ["delivered", "picked_up", "problem"].includes(currentStatus);
|
const canReturn = ["delivered", "picked_up", "problem"].includes(currentStatus);
|
||||||
|
|
||||||
if (statusOptions.length === 0 && !canReturn) return null;
|
if (statusOptions.length === 0 && !canReturn) return null;
|
||||||
|
|
@ -1163,6 +1203,7 @@ export const OrderDetailPanel = ({
|
||||||
return statusOptions.map((statusOption) => {
|
return statusOptions.map((statusOption) => {
|
||||||
const isSelected = pendingStatus?.value === statusOption.value;
|
const isSelected = pendingStatus?.value === statusOption.value;
|
||||||
const isMismatch = statusOption.mismatch;
|
const isMismatch = statusOption.mismatch;
|
||||||
|
const blockedBySchedule = statusOption.requiresSchedule && !hasDeliverySchedule;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
key={statusOption.value}
|
key={statusOption.value}
|
||||||
|
|
@ -1196,7 +1237,7 @@ export const OrderDetailPanel = ({
|
||||||
disabled={isSavingStatusChange}
|
disabled={isSavingStatusChange}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPendingStatus({
|
setPendingStatus({
|
||||||
value: "loaded",
|
value: "driver_assigned",
|
||||||
label: "Вернуть в работу",
|
label: "Вернуть в работу",
|
||||||
mismatch: false,
|
mismatch: false,
|
||||||
deliveryType: "delivery",
|
deliveryType: "delivery",
|
||||||
|
|
@ -1303,7 +1344,7 @@ export const OrderDetailPanel = ({
|
||||||
<CollapsibleOrderComposition order={order} />
|
<CollapsibleOrderComposition order={order} />
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{userRole !== "driver" && order?.deliveryLink ? (
|
{order?.deliveryLink ? (
|
||||||
<Panel className="space-y-3 p-5">
|
<Panel className="space-y-3 p-5">
|
||||||
<strong>Ссылка на согласование</strong>
|
<strong>Ссылка на согласование</strong>
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ 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 = {
|
||||||
|
|
@ -36,6 +38,8 @@ 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 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -173,11 +177,8 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel className="p-4">
|
<Panel className="p-4">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3">
|
||||||
<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 */}
|
||||||
|
|
@ -188,9 +189,9 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
||||||
<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(firstSmsAt)} ✓ доставлено</div>
|
<div className="text-[var(--color-text-muted)]">{fmtTime(firstSmsAt)} ✓ получено клиентом</div>
|
||||||
) : hasSmsSent && notifStatus === "sms_sending" ? (
|
) : hasSmsSent && notifStatus === "sms_sending" ? (
|
||||||
<div className="text-[var(--color-text-muted)]">{fmtTime(smsSentAt)} · отправлено, ждём подтверждения…</div>
|
<div className="text-[var(--color-text-muted)]">{fmtTime(smsSentAt)} · отправлено, ждём ответ оператора…</div>
|
||||||
) : notifStatus === "link_ready" ? (
|
) : notifStatus === "link_ready" ? (
|
||||||
<div className="text-[var(--color-text-muted)]">в очереди на отправку</div>
|
<div className="text-[var(--color-text-muted)]">в очереди на отправку</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -205,7 +206,7 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
||||||
<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(secondSmsAt)}</div>
|
<div className="text-[var(--color-text-muted)]">{fmtTime(secondSmsAt)} ✓ получено клиентом</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>
|
||||||
|
|
@ -260,6 +261,23 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
||||||
</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">
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ import { Button } from "../UI/Button";
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
import { DELIVERY_GROUP_STATUS_LABELS } from "../../services/orderGroupViews";
|
import { DELIVERY_GROUP_STATUS_LABELS } from "../../services/orderGroupViews";
|
||||||
|
|
||||||
const STATUS_LABELS = DELIVERY_GROUP_STATUS_LABELS;
|
|
||||||
|
|
||||||
const StatusActionPanel = ({
|
const StatusActionPanel = ({
|
||||||
order,
|
order,
|
||||||
userRole,
|
userRole,
|
||||||
|
|
@ -18,6 +16,48 @@ const StatusActionPanel = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
||||||
<Panel className="space-y-4 p-5">
|
<Panel className="space-y-4 p-5">
|
||||||
|
|
@ -27,39 +67,69 @@ const StatusActionPanel = ({
|
||||||
Измените статус, если водитель забыл обновить или нужна корректировка.
|
Измените статус, если водитель забыл обновить или нужна корректировка.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Status indicators: show current state clearly */}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{[
|
{hasDeliverySchedule && (
|
||||||
{ value: "pending_confirmation", label: "Ожидает согласования", manual: true },
|
<Badge tone="accent">✓ Дата согласована</Badge>
|
||||||
{ value: "agreed", label: "Согласовано", manual: false, hint: "Согласуйте дату доставки выше" },
|
)}
|
||||||
{ value: "driver_assigned", label: "Назначен водитель", manual: false, hint: "Назначьте водителя из списка" },
|
{hasDriver && (
|
||||||
{ value: "loaded", label: "Загружено", manual: true },
|
<Badge tone="accent">✓ Водитель: {order.assignedDriverName || "назначен"}</Badge>
|
||||||
{ value: "delivered", label: "Доставлено", manual: true },
|
)}
|
||||||
{ value: "picked_up", label: "Вывезено", manual: true },
|
{!hasDeliverySchedule && (
|
||||||
{ value: "requires_address", label: "Требуется адрес", manual: true },
|
<Badge tone="warning">⚠ Дата не указана</Badge>
|
||||||
{ value: "problem", label: "Проблема", manual: true },
|
)}
|
||||||
{ value: "cancelled", label: "Отменено", manual: true },
|
{!hasDriver && !isPickup && (
|
||||||
].map((statusOption) => {
|
<Badge tone="warning">⚠ Водитель не назначен</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{allStatuses.map((statusOption) => {
|
||||||
const isCurrent = currentStatus === statusOption.value;
|
const isCurrent = currentStatus === statusOption.value;
|
||||||
const isClickable = statusOption.manual !== false && !isCurrent;
|
const blockedBySchedule = statusOption.requiresSchedule && !hasDeliverySchedule;
|
||||||
|
const blockedByDriver = statusOption.requiresDriver !== false && !hasDriver;
|
||||||
|
const hint = getHint(statusOption.value);
|
||||||
return (
|
return (
|
||||||
<div key={statusOption.value} className="relative group">
|
<Button
|
||||||
<Button
|
key={statusOption.value}
|
||||||
variant={isCurrent ? "primary" : "secondary"}
|
variant={isCurrent ? "primary" : (statusOption.mismatch ? "ghost" : "secondary")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!isClickable) {
|
if (blockedBySchedule) {
|
||||||
onConfirmStatus?.({ type: "hint", hint: statusOption.hint || "" });
|
return;
|
||||||
return;
|
}
|
||||||
}
|
if (blockedByDriver) {
|
||||||
onConfirmStatus?.({ type: "status", status: statusOption.value });
|
onConfirmStatus?.({
|
||||||
}}
|
type: "hint",
|
||||||
disabled={isSavingStatusChange}
|
hint: "⚠ Назначьте водителя перед статусом «Доставлено»",
|
||||||
>
|
});
|
||||||
{statusOption.label}
|
return;
|
||||||
</Button>
|
}
|
||||||
</div>
|
if (isCurrent && hint) {
|
||||||
|
onConfirmStatus?.({ type: "hint", hint });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onConfirmStatus?.({
|
||||||
|
type: "status",
|
||||||
|
status: statusOption.value,
|
||||||
|
label: statusOption.label,
|
||||||
|
mismatch: !!statusOption.mismatch,
|
||||||
|
deliveryType: isPickup ? "pickup" : "delivery",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isSavingStatusChange}
|
||||||
|
className={(blockedBySchedule || blockedByDriver) ? "opacity-40 cursor-not-allowed" : ""}
|
||||||
|
>
|
||||||
|
{statusOption.label}
|
||||||
|
</Button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{!hasDeliverySchedule && (
|
||||||
|
<div className="rounded-xl border border-[var(--color-warning)] bg-[var(--color-warning-soft)] px-3 py-2 text-xs text-[var(--color-warning)]">
|
||||||
|
⚠ Чтобы поставить «Доставлено» или «Вывезено», сначала укажите дату и половину дня доставки выше.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 || "Комментарий не задан.";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
/* Font size scale variables — applied by FontSettingsContext */
|
||||||
|
:root {
|
||||||
|
--fs-scale-table: 1.0;
|
||||||
|
--fs-scale-card: 1.0;
|
||||||
|
--fs-scale-nav: 1.0;
|
||||||
|
--fs-scale-heading: 1.0;
|
||||||
|
--fs-scale-body: 1.0;
|
||||||
|
--fs-scale-small: 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slider styling */
|
||||||
|
.fs-slider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fs-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-accent);
|
||||||
|
border: 3px solid var(--color-surface-strong);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fs-slider::-webkit-slider-thumb:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fs-slider::-moz-range-thumb {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-accent);
|
||||||
|
border: 3px solid var(--color-surface-strong);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Zone overrides ──────────────────────────────────────────────────────
|
||||||
|
.fs-zone-* wrappers override Tailwind text-* classes inside them.
|
||||||
|
Specificity: .fs-zone-X .text-Y (0,2,0) > .text-Y (0,1,0).
|
||||||
|
This lets us scale fonts without patching every component.
|
||||||
|
────────────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Table zone */
|
||||||
|
.fs-zone-table { font-size: calc(0.875rem * var(--fs-scale-table, 1)); }
|
||||||
|
.fs-zone-table .text-xs { font-size: calc(0.75rem * var(--fs-scale-table, 1)); }
|
||||||
|
.fs-zone-table .text-sm { font-size: calc(0.875rem * var(--fs-scale-table, 1)); }
|
||||||
|
.fs-zone-table .text-base { font-size: calc(1rem * var(--fs-scale-table, 1)); }
|
||||||
|
.fs-zone-table .text-lg { font-size: calc(1.125rem * var(--fs-scale-table, 1)); }
|
||||||
|
.fs-zone-table .text-xl { font-size: calc(1.25rem * var(--fs-scale-table, 1)); }
|
||||||
|
/* Arbitrary pixel sizes used in tables */
|
||||||
|
.fs-zone-table .text-\[10px\] { font-size: calc(10px * var(--fs-scale-table, 1)); }
|
||||||
|
.fs-zone-table .text-\[11px\] { font-size: calc(11px * var(--fs-scale-table, 1)); }
|
||||||
|
.fs-zone-table .text-\[12px\] { font-size: calc(12px * var(--fs-scale-table, 1)); }
|
||||||
|
.fs-zone-table .text-\[13px\] { font-size: calc(13px * var(--fs-scale-table, 1)); }
|
||||||
|
.fs-zone-table .text-\[14px\] { font-size: calc(14px * var(--fs-scale-table, 1)); }
|
||||||
|
|
||||||
|
/* Card zone */
|
||||||
|
.fs-zone-card { font-size: calc(0.875rem * var(--fs-scale-card, 1)); }
|
||||||
|
.fs-zone-card .text-xs { font-size: calc(0.75rem * var(--fs-scale-card, 1)); }
|
||||||
|
.fs-zone-card .text-sm { font-size: calc(0.875rem * var(--fs-scale-card, 1)); }
|
||||||
|
.fs-zone-card .text-base { font-size: calc(1rem * var(--fs-scale-card, 1)); }
|
||||||
|
.fs-zone-card .text-lg { font-size: calc(1.125rem * var(--fs-scale-card, 1)); }
|
||||||
|
.fs-zone-card .text-\[10px\] { font-size: calc(10px * var(--fs-scale-card, 1)); }
|
||||||
|
.fs-zone-card .text-\[11px\] { font-size: calc(11px * var(--fs-scale-card, 1)); }
|
||||||
|
|
||||||
|
/* Nav zone */
|
||||||
|
.fs-zone-nav { font-size: calc(0.875rem * var(--fs-scale-nav, 1)); }
|
||||||
|
.fs-zone-nav .text-xs { font-size: calc(0.75rem * var(--fs-scale-nav, 1)); }
|
||||||
|
.fs-zone-nav .text-sm { font-size: calc(0.875rem * var(--fs-scale-nav, 1)); }
|
||||||
|
.fs-zone-nav .text-base { font-size: calc(1rem * var(--fs-scale-nav, 1)); }
|
||||||
|
|
||||||
|
/* Heading zone */
|
||||||
|
.fs-zone-heading { font-size: calc(1rem * var(--fs-scale-heading, 1)); }
|
||||||
|
.fs-zone-heading .text-sm { font-size: calc(0.875rem * var(--fs-scale-heading, 1)); }
|
||||||
|
.fs-zone-heading .text-base { font-size: calc(1rem * var(--fs-scale-heading, 1)); }
|
||||||
|
.fs-zone-heading .text-lg { font-size: calc(1.125rem * var(--fs-scale-heading, 1)); }
|
||||||
|
.fs-zone-heading .text-xl { font-size: calc(1.25rem * var(--fs-scale-heading, 1)); }
|
||||||
|
.fs-zone-heading .text-2xl { font-size: calc(1.5rem * var(--fs-scale-heading, 1)); }
|
||||||
|
.fs-zone-heading .text-3xl { font-size: calc(1.875rem * var(--fs-scale-heading, 1)); }
|
||||||
|
|
||||||
|
/* Small text zone */
|
||||||
|
.fs-zone-small { font-size: calc(0.75rem * var(--fs-scale-small, 1)); }
|
||||||
|
.fs-zone-small .text-xs { font-size: calc(0.75rem * var(--fs-scale-small, 1)); }
|
||||||
|
.fs-zone-small .text-sm { font-size: calc(0.875rem * var(--fs-scale-small, 1)); }
|
||||||
|
|
||||||
|
/* Body zone — LAST so table/card/nav/heading zones inside body win at equal specificity */
|
||||||
|
.fs-zone-body { font-size: calc(1rem * var(--fs-scale-body, 1)); }
|
||||||
|
.fs-zone-body .text-xs { font-size: calc(0.75rem * var(--fs-scale-body, 1)); }
|
||||||
|
.fs-zone-body .text-sm { font-size: calc(0.875rem * var(--fs-scale-body, 1)); }
|
||||||
|
.fs-zone-body .text-base { font-size: calc(1rem * var(--fs-scale-body, 1)); }
|
||||||
|
.fs-zone-body .text-lg { font-size: calc(1.125rem * var(--fs-scale-body, 1)); }
|
||||||
|
|
@ -11,11 +11,21 @@ import { getErrorMessage } from "../utils/deliveryUtils";
|
||||||
|
|
||||||
export const useOrderGroups = () => {
|
export const useOrderGroups = () => {
|
||||||
const [orderGroups, setOrderGroups] = React.useState(() => []);
|
const [orderGroups, setOrderGroups] = React.useState(() => []);
|
||||||
const [filters, setFilters] = React.useState({
|
const FILTERS_STORAGE_KEY = "supersam_order_filters";
|
||||||
query: "",
|
const [filters, setFilters] = React.useState(() => {
|
||||||
displayStatus: "all",
|
try {
|
||||||
deliveryType: "",
|
const saved = localStorage.getItem(FILTERS_STORAGE_KEY);
|
||||||
|
if (saved) return JSON.parse(saved);
|
||||||
|
} catch (e) {}
|
||||||
|
return { query: "", displayStatus: "all", deliveryType: "" };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Persist filters to localStorage on every change
|
||||||
|
React.useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(FILTERS_STORAGE_KEY, JSON.stringify(filters));
|
||||||
|
} catch (e) {}
|
||||||
|
}, [filters]);
|
||||||
const [selectedOrderGroupId, setSelectedOrderGroupId] = React.useState(null);
|
const [selectedOrderGroupId, setSelectedOrderGroupId] = React.useState(null);
|
||||||
const [isLoading, setIsLoading] = React.useState(true);
|
const [isLoading, setIsLoading] = React.useState(true);
|
||||||
const [loadError, setLoadError] = React.useState("");
|
const [loadError, setLoadError] = React.useState("");
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ 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";
|
||||||
|
|
@ -36,6 +37,7 @@ 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 ─────────────────────────────────────────────
|
||||||
|
|
@ -137,6 +139,7 @@ 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"
|
||||||
? [
|
? [
|
||||||
|
|
@ -178,6 +181,7 @@ const ALLOWED_DASHBOARD_ROLES = ["admin", "mega_admin", "manager", "logistician"
|
||||||
if (activeSection === "action_log") return <div className="space-y-6 xl:space-y-8"><ActionLogPanel /></div>;
|
if (activeSection === "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") {
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ export const DRIVER_VISIBLE_DELIVERY_STATUSES = [
|
||||||
"paid_storage",
|
"paid_storage",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DRIVER_ACTIVE_DELIVERY_STATUSES = ["driver_assigned", "loaded", "on_route", "problem"];
|
export const DRIVER_ACTIVE_DELIVERY_STATUSES = ["driver_assigned", "problem"];
|
||||||
|
|
||||||
const HALF_DAY_LABELS = {
|
const HALF_DAY_LABELS = {
|
||||||
morning: "Первая половина дня",
|
morning: "Первая половина дня",
|
||||||
|
|
@ -143,9 +143,17 @@ 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 deliveryStatus = group?.deliveryStatus || group?.delivery_status;
|
const statusCol = group?.status;
|
||||||
const notificationStatus = group?.notificationStatus || group?.notification_status;
|
const notificationStatus = group?.notificationStatus || group?.notification_status;
|
||||||
|
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
|
||||||
|
|
||||||
// When auto-SMS failed and logistics hasn't taken action yet → show as a todo item
|
// 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";
|
||||||
|
|
@ -154,6 +162,12 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
@ -163,12 +177,13 @@ export const getOrderGroupDisplayStatusLabel = (group) => {
|
||||||
return notificationLabel;
|
return notificationLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
return getOrderGroupStatusLabel(group?.status);
|
return getOrderGroupStatusLabel(statusCol);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getOrderGroupDisplayStatusValue = (group) => {
|
export const getOrderGroupDisplayStatusValue = (group) => {
|
||||||
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
|
const statusCol = group?.status;
|
||||||
const notificationStatus = group?.notificationStatus || group?.notification_status;
|
const notificationStatus = group?.notificationStatus || group?.notification_status;
|
||||||
|
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
|
||||||
|
|
||||||
// Unify manual_required into a single bucket regardless of delivery_status detail
|
// Unify manual_required into a single bucket regardless of delivery_status detail
|
||||||
const isManualRequired = notificationStatus === "manual_required";
|
const isManualRequired = notificationStatus === "manual_required";
|
||||||
|
|
@ -177,11 +192,17 @@ 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:${group?.status || "unknown"}`;
|
return `status:${statusCol || "unknown"}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isOrderGroupVisibleToDriver = (group) => {
|
export const isOrderGroupVisibleToDriver = (group) => {
|
||||||
|
|
@ -483,10 +504,16 @@ 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,12 +142,23 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
|
||||||
const extractCity = (addr) => {
|
const extractCity = (addr) => {
|
||||||
if (!addr) return "";
|
if (!addr) return "";
|
||||||
// 1) explicit marker: г. Ялта, пгт. Куйбышево, etc.
|
// 1) explicit marker: г. Ялта, пгт. Куйбышево, etc.
|
||||||
const m = addr.match(/(?:г\.\s|гор\.\s|пос\.\s|с\.\s|дер\.\s|пгт\.\s|город\s|село\s|г\s)\s*([А-ЯЁа-яёA-Za-z\-\s]+?)(?:[,\\s]|$)/i);
|
// Word markers (город/село) require space after to avoid matching "Стройгородок"
|
||||||
if (m) return m[1].trim();
|
// Dot markers (г./гор./etc) allow zero space (г.Ялта)
|
||||||
// 2) known city name anywhere in address (case-insensitive)
|
const m = addr.match(/(?:г\.\s*|гор\.\s*|пос\.\s*|с\.\s*|дер\.\s*|пгт\.\s*|город\s+|село\s+|г\s+)\s*([А-ЯЁа-яёA-Za-z\-\s]+?)(?:[,\s]|$)/i);
|
||||||
|
if (m) {
|
||||||
|
const candidate = m[1].trim();
|
||||||
|
for (const city of CRIMEAN_CITIES) {
|
||||||
|
if (city.toLowerCase() === candidate.toLowerCase()) return city;
|
||||||
|
}
|
||||||
|
// Not a known city — continue to step 2
|
||||||
|
}
|
||||||
|
// 2) known city name via custom word boundary (JS \b doesn't work with Cyrillic)
|
||||||
|
// City must be preceded by start/comma/space/dot and followed by comma/space/end
|
||||||
const lower = addr.toLowerCase();
|
const lower = addr.toLowerCase();
|
||||||
for (const city of CRIMEAN_CITIES) {
|
for (const city of CRIMEAN_CITIES) {
|
||||||
if (lower.includes(city.toLowerCase())) return city;
|
const esc = city.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const re = new RegExp("(^|[,.\\s])" + esc + "(?=[,\\s]|$)", "i");
|
||||||
|
if (re.test(lower)) return city;
|
||||||
}
|
}
|
||||||
// 3) Бахчисарайский р-н → Бахчисарай
|
// 3) Бахчисарайский р-н → Бахчисарай
|
||||||
const district = addr.match(/([А-ЯЁа-яё]+)ский\s*(?:р-н|район)/i);
|
const district = addr.match(/([А-ЯЁа-яё]+)ский\s*(?:р-н|район)/i);
|
||||||
|
|
@ -460,15 +471,22 @@ 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)
|
// Fetch current status before any update (needed for audit log + status sync)
|
||||||
const { data: current, error: fetchCurrentError } = await client
|
const { data: current, error: fetchCurrentError } = await client
|
||||||
.from("order_groups")
|
.from("order_groups")
|
||||||
.select("delivery_status")
|
.select("delivery_status, delivery_type")
|
||||||
.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.
|
||||||
|
|
@ -479,6 +497,7 @@ 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(),
|
||||||
})
|
})
|
||||||
|
|
@ -490,6 +509,7 @@ 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(),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -119,9 +119,26 @@ 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 = (now = new Date()) => {
|
export const buildDefaultDatedAvailableSlots = async (now = new Date(), supabaseClient?: any) => {
|
||||||
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,
|
||||||
|
|
@ -137,12 +154,18 @@ export const buildDefaultDatedAvailableSlots = (now = new Date()) => {
|
||||||
return next;
|
return next;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Skip Sunday (getUTCDay() === 0) — never offer Sunday delivery
|
// Check if date is an allowed delivery day
|
||||||
const isSunday = (date: Date) => date.getUTCDay() === 0;
|
// getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
|
||||||
|
// deliveryDays uses 1=Mon ... 7=Sun
|
||||||
|
const isAllowedDay = (date: Date) => {
|
||||||
|
const dow = date.getUTCDay();
|
||||||
|
const dayNum = dow === 0 ? 7 : dow;
|
||||||
|
return deliveryDays.includes(dayNum);
|
||||||
|
};
|
||||||
|
|
||||||
const getNextWorkday = (date: Date) => {
|
const getNextWorkday = (date: Date) => {
|
||||||
let next = addDays(date, 1);
|
let next = addDays(date, 1);
|
||||||
while (isSunday(next)) {
|
while (!isAllowedDay(next)) {
|
||||||
next = addDays(next, 1);
|
next = addDays(next, 1);
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
|
|
|
||||||
|
|
@ -31,11 +31,31 @@ type ConfirmBody = {
|
||||||
|
|
||||||
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
|
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
|
||||||
|
|
||||||
const isWeekendDate = (value: string) => {
|
// Fetch delivery days from business_schedule_settings
|
||||||
|
const getDeliveryDays = async (supabaseClient: any): Promise<number[]> => {
|
||||||
|
try {
|
||||||
|
const { data } = await supabaseClient
|
||||||
|
.from("business_schedule_settings")
|
||||||
|
.select("delivery_days")
|
||||||
|
.eq("id", 1)
|
||||||
|
.single();
|
||||||
|
if (data?.delivery_days && Array.isArray(data.delivery_days) && data.delivery_days.length) {
|
||||||
|
return data.delivery_days as number[];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silent fallback
|
||||||
|
}
|
||||||
|
return [1, 2, 3, 4, 5]; // Default: Mon-Fri
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAllowedDeliveryDate = (value: string, deliveryDays: number[]) => {
|
||||||
if (!isValidDate(value)) return false;
|
if (!isValidDate(value)) return false;
|
||||||
const date = new Date(`${value}T12:00:00Z`);
|
const date = new Date(`${value}T12:00:00Z`);
|
||||||
const weekday = date.getUTCDay();
|
const dow = date.getUTCDay();
|
||||||
return weekday === 0; // 0=Sunday — never allow Sunday delivery
|
// getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
|
||||||
|
// deliveryDays uses 1=Mon ... 7=Sun
|
||||||
|
const dayNum = dow === 0 ? 7 : dow;
|
||||||
|
return deliveryDays.includes(dayNum);
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveRequestedSlot = (
|
const resolveRequestedSlot = (
|
||||||
|
|
@ -45,6 +65,7 @@ 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();
|
||||||
|
|
@ -59,8 +80,8 @@ const resolveRequestedSlot = (
|
||||||
return { deliveryDate, deliveryTime, deliveryType };
|
return { deliveryDate, deliveryTime, deliveryType };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject Sunday for delivery (business rule: never deliver on Sunday)
|
// Reject non-delivery days for delivery (business schedule)
|
||||||
if (isWeekendDate(deliveryDate)) {
|
if (!isAllowedDeliveryDate(deliveryDate, deliveryDays)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,6 +141,7 @@ 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, {
|
||||||
|
|
@ -180,7 +202,7 @@ Deno.serve(async (request) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestedSlot = resolveRequestedSlot(invitation, body);
|
const requestedSlot = resolveRequestedSlot(invitation, body, deliveryDays);
|
||||||
if (!requestedSlot) {
|
if (!requestedSlot) {
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
import {
|
||||||
|
createServiceClient,
|
||||||
|
getCorsHeaders,
|
||||||
|
jsonResponse,
|
||||||
|
preflightResponse,
|
||||||
|
readJsonBody,
|
||||||
|
} from "../_shared/security.ts";
|
||||||
|
|
||||||
|
const MAX_BODY_BYTES = 8 * 1024;
|
||||||
|
|
||||||
|
const ADMIN_ROLES = new Set(["admin", "mega_admin"]);
|
||||||
|
|
||||||
|
const isValidEmail = (value: string) =>
|
||||||
|
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify the caller's JWT and return their role from public.users.
|
||||||
|
* Returns null if not authorized.
|
||||||
|
*/
|
||||||
|
async function getCallerRole(request: Request): Promise<string | null> {
|
||||||
|
const authHeader = request.headers.get("authorization") || "";
|
||||||
|
const token = authHeader.replace(/^Bearer\s+/i, "").trim();
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
const supabase = createServiceClient();
|
||||||
|
const { data: userData, error: userError } = await supabase.auth.getUser(token);
|
||||||
|
if (userError || !userData?.user) return null;
|
||||||
|
|
||||||
|
const userId = userData.user.id;
|
||||||
|
const { data: userRow, error: roleError } = await supabase
|
||||||
|
.from("users")
|
||||||
|
.select("roles(name)")
|
||||||
|
.eq("id", userId)
|
||||||
|
.maybeSingle();
|
||||||
|
if (roleError || !userRow) return null;
|
||||||
|
|
||||||
|
return userRow.roles?.name || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.serve(async (request) => {
|
||||||
|
if (request.method === "OPTIONS") {
|
||||||
|
return preflightResponse(request, "private");
|
||||||
|
}
|
||||||
|
|
||||||
|
const corsHeaders = getCorsHeaders(request, "private");
|
||||||
|
if (!corsHeaders) {
|
||||||
|
return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ── Authorization ──
|
||||||
|
const callerRole = await getCallerRole(request);
|
||||||
|
if (!callerRole || !ADMIN_ROLES.has(callerRole)) {
|
||||||
|
return jsonResponse(
|
||||||
|
{ ok: false, error: "Недостаточно прав. Требуется роль admin или mega_admin." },
|
||||||
|
403,
|
||||||
|
corsHeaders,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = createServiceClient();
|
||||||
|
|
||||||
|
// ── POST: create new user ──
|
||||||
|
if (request.method === "POST") {
|
||||||
|
const { body } = await readJsonBody<{ email?: string; name?: string; role?: string }>(
|
||||||
|
request,
|
||||||
|
{ maxBytes: MAX_BODY_BYTES },
|
||||||
|
);
|
||||||
|
|
||||||
|
const email = String(body.email || "").trim().toLowerCase();
|
||||||
|
const name = String(body.name || "").trim();
|
||||||
|
const role = String(body.role || "").trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!email || !isValidEmail(email)) {
|
||||||
|
return jsonResponse({ ok: false, error: "Некорректный email" }, 400, corsHeaders);
|
||||||
|
}
|
||||||
|
if (!name) {
|
||||||
|
return jsonResponse({ ok: false, error: "Имя обязательно" }, 400, corsHeaders);
|
||||||
|
}
|
||||||
|
if (!role) {
|
||||||
|
return jsonResponse({ ok: false, error: "Роль обязательна" }, 400, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if email already exists in public.users
|
||||||
|
const { data: existingUser } = await supabase
|
||||||
|
.from("users")
|
||||||
|
.select("id")
|
||||||
|
.eq("email", email)
|
||||||
|
.maybeSingle();
|
||||||
|
if (existingUser) {
|
||||||
|
return jsonResponse({ ok: false, error: "Пользователь с таким email уже существует" }, 409, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create auth user — trigger handle_new_user will auto-insert into public.users
|
||||||
|
// using user_metadata.role and user_metadata.name
|
||||||
|
const { data: authData, error: authError } = await supabase.auth.admin.createUser({
|
||||||
|
email,
|
||||||
|
email_confirm: true,
|
||||||
|
user_metadata: { name, role },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (authError) {
|
||||||
|
console.error("auth.createUser error:", authError);
|
||||||
|
return jsonResponse(
|
||||||
|
{ ok: false, error: "Ошибка создания пользователя: " + authError.message },
|
||||||
|
500,
|
||||||
|
corsHeaders,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newUserId = authData.user.id;
|
||||||
|
|
||||||
|
return jsonResponse({ ok: true, data: { id: newUserId, email, name, role } }, 201, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DELETE: remove user ──
|
||||||
|
if (request.method === "DELETE") {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const userId = url.searchParams.get("id");
|
||||||
|
if (!userId) {
|
||||||
|
return jsonResponse({ ok: false, error: "Параметр id обязателен" }, 400, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user info before deletion
|
||||||
|
const { data: userRow } = await supabase
|
||||||
|
.from("users")
|
||||||
|
.select("id, email, name")
|
||||||
|
.eq("id", userId)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (!userRow) {
|
||||||
|
return jsonResponse({ ok: false, error: "Пользователь не найден" }, 404, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete auth user (FK ON DELETE CASCADE will remove public.users row)
|
||||||
|
const { error: authDeleteError } = await supabase.auth.admin.deleteUser(userId);
|
||||||
|
if (authDeleteError) {
|
||||||
|
console.error("auth.deleteUser error:", authDeleteError);
|
||||||
|
// Try deleting public.users directly as fallback
|
||||||
|
const { error: dbDeleteError } = await supabase.from("users").delete().eq("id", userId);
|
||||||
|
if (dbDeleteError) {
|
||||||
|
return jsonResponse(
|
||||||
|
{ ok: false, error: "Ошибка удаления: " + authDeleteError.message },
|
||||||
|
500,
|
||||||
|
corsHeaders,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({ ok: true, data: { id: userId } }, 200, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({ ok: false, error: "Method not allowed" }, 405, corsHeaders);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && "status" in error) {
|
||||||
|
const httpError = error as { status: number; message: string };
|
||||||
|
return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders);
|
||||||
|
}
|
||||||
|
return jsonResponse(
|
||||||
|
{ ok: false, error: error instanceof Error ? error.message : "Unexpected error" },
|
||||||
|
500,
|
||||||
|
corsHeaders,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -119,16 +119,63 @@ 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 = (now = new Date()) => {
|
export const buildDefaultDatedAvailableSlots = async (now = new Date(), supabaseClient?: any) => {
|
||||||
const formatIsoDate = (date: Date) => date.toISOString().slice(0, 10);
|
const CRIMEA_TZ = "Europe/Simferopol";
|
||||||
|
|
||||||
|
// Fetch delivery days from business_schedule_settings
|
||||||
|
let deliveryDays: number[] = [1, 2, 3, 4, 5]; // Default: Mon-Fri
|
||||||
|
if (supabaseClient) {
|
||||||
|
try {
|
||||||
|
const { data } = await supabaseClient
|
||||||
|
.from("business_schedule_settings")
|
||||||
|
.select("delivery_days")
|
||||||
|
.eq("id", 1)
|
||||||
|
.single();
|
||||||
|
if (data?.delivery_days && Array.isArray(data.delivery_days) && data.delivery_days.length) {
|
||||||
|
deliveryDays = data.delivery_days;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silent fallback to defaults
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCrimeaDate = (date: Date) => {
|
||||||
|
return new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: CRIMEA_TZ,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
}).format(date);
|
||||||
|
};
|
||||||
|
|
||||||
const addDays = (date: Date, days: number) => {
|
const 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;
|
||||||
};
|
};
|
||||||
|
|
||||||
const firstDay = formatIsoDate(addDays(now, 1));
|
// Check if date is an allowed delivery day
|
||||||
const secondDay = formatIsoDate(addDays(now, 2));
|
// getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
|
||||||
|
// deliveryDays uses 1=Mon ... 7=Sun
|
||||||
|
const isAllowedDay = (date: Date) => {
|
||||||
|
const dow = date.getUTCDay();
|
||||||
|
const dayNum = dow === 0 ? 7 : dow;
|
||||||
|
return deliveryDays.includes(dayNum);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNextWorkday = (date: Date) => {
|
||||||
|
let next = addDays(date, 1);
|
||||||
|
while (!isAllowedDay(next)) {
|
||||||
|
next = addDays(next, 1);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstWorkday = getNextWorkday(now);
|
||||||
|
const secondWorkday = getNextWorkday(firstWorkday);
|
||||||
|
|
||||||
|
const firstDay = formatCrimeaDate(firstWorkday);
|
||||||
|
const secondDay = formatCrimeaDate(secondWorkday);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
`${firstDay}, Первая половина дня`,
|
`${firstDay}, Первая половина дня`,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { createClient } from "npm:@supabase/supabase-js@2";
|
||||||
|
|
||||||
|
const ALLOWED_ORIGINS = [
|
||||||
|
"https://dost.supersamsev.ru",
|
||||||
|
"https://supa.supersamsev.ru",
|
||||||
|
"http://localhost:5173",
|
||||||
|
];
|
||||||
|
|
||||||
|
const SMS_STATUS_URL = "https://sms.ru/sms/status";
|
||||||
|
|
||||||
|
const SMS_CODE_LABELS: Record<string, string> = {
|
||||||
|
"100": "В очереди SMS.ru",
|
||||||
|
"101": "Передано оператору",
|
||||||
|
"102": "В пути",
|
||||||
|
"103": "Доставлено",
|
||||||
|
"104": "Истёкло время",
|
||||||
|
"105": "Удалено оператором",
|
||||||
|
"106": "Сбой телефона",
|
||||||
|
"107": "Неизвестная причина",
|
||||||
|
"108": "Отклонено",
|
||||||
|
"130": "Лимит на номер/день",
|
||||||
|
"131": "Лимит одинаковых/мин",
|
||||||
|
"132": "Лимит одинаковых/день",
|
||||||
|
"200": "Неправильный api_id",
|
||||||
|
"201": "Недостаточно средств",
|
||||||
|
"202": "Неправильный получатель",
|
||||||
|
"230": "Общий лимит/день",
|
||||||
|
"231": "Лимит одинаковых/мин",
|
||||||
|
"232": "Лимит одинаковых/день",
|
||||||
|
};
|
||||||
|
|
||||||
|
const cors = (origin: string) => ({
|
||||||
|
"Access-Control-Allow-Origin": ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0],
|
||||||
|
"Access-Control-Allow-Methods": "POST,OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type,Authorization,apikey",
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.serve(async (req: Request) => {
|
||||||
|
const origin = req.headers.get("origin") || "";
|
||||||
|
const headers = { ...cors(origin), "Content-Type": "application/json" };
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") return new Response(null, { headers });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { log_id } = await req.json();
|
||||||
|
if (!log_id) return new Response(JSON.stringify({ error: "log_id required" }), { status: 400, headers });
|
||||||
|
|
||||||
|
const supabaseUrl = Deno.env.get("SUPABASE_URL") || "";
|
||||||
|
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || "";
|
||||||
|
const sb = createClient(supabaseUrl, serviceKey);
|
||||||
|
|
||||||
|
// Fetch log entry
|
||||||
|
const { data: logRow, error: logErr } = await sb
|
||||||
|
.from("sms_campaign_log")
|
||||||
|
.select("id, sms_id, campaign_type, order_group_id, attempts")
|
||||||
|
.eq("id", log_id)
|
||||||
|
.single();
|
||||||
|
if (logErr || !logRow) return new Response(JSON.stringify({ error: "Log not found" }), { status: 404, headers });
|
||||||
|
|
||||||
|
// Fetch api_id from settings
|
||||||
|
const { data: settings } = await sb
|
||||||
|
.from("sms_campaign_settings")
|
||||||
|
.select("sms_api_id")
|
||||||
|
.eq("campaign_type", logRow.campaign_type)
|
||||||
|
.single();
|
||||||
|
const apiId = settings?.sms_api_id || Deno.env.get("SMS_API_ID") || "";
|
||||||
|
|
||||||
|
if (!logRow.sms_id) return new Response(JSON.stringify({ error: "No sms_id in log" }), { status: 400, headers });
|
||||||
|
|
||||||
|
// Call SMS.ru status API
|
||||||
|
const formData = new URLSearchParams();
|
||||||
|
formData.append("api_id", apiId);
|
||||||
|
formData.append("sms_id", logRow.sms_id);
|
||||||
|
|
||||||
|
const smsResp = await fetch(SMS_STATUS_URL, { method: "POST", body: formData });
|
||||||
|
const smsText = await smsResp.text();
|
||||||
|
const lines = smsText.split("\n").map((l: string) => l.trim());
|
||||||
|
const code = lines[0] || "";
|
||||||
|
|
||||||
|
// Determine status
|
||||||
|
const status =
|
||||||
|
code === "103" ? "delivered" :
|
||||||
|
["100", "101", "102"].includes(code) ? "checking" :
|
||||||
|
["104", "105", "106", "107", "108", "130"].includes(code) ? "error" :
|
||||||
|
["131", "132", "230", "231", "232"].includes(code) ? "limit_exceeded" :
|
||||||
|
"checking";
|
||||||
|
|
||||||
|
// Update log entry
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await sb.from("sms_campaign_log").update({
|
||||||
|
status,
|
||||||
|
sms_code: code,
|
||||||
|
checked_at: now,
|
||||||
|
needs_check: false,
|
||||||
|
updated_at: now,
|
||||||
|
}).eq("id", log_id);
|
||||||
|
|
||||||
|
// Update order_groups if delivered
|
||||||
|
if (code === "103" && logRow.order_group_id) {
|
||||||
|
const nextCheck = new Date(Date.now() + 3 * 3600 * 1000).toISOString();
|
||||||
|
if (logRow.campaign_type === "first_sms") {
|
||||||
|
await sb.from("order_groups").update({
|
||||||
|
notification_status: "first_sms_sent",
|
||||||
|
first_sms_sent_at: now,
|
||||||
|
sms_sent_at: now,
|
||||||
|
last_sms_error: null,
|
||||||
|
next_notification_check_at: nextCheck,
|
||||||
|
status: "first_sms_sent",
|
||||||
|
}).eq("id", logRow.order_group_id);
|
||||||
|
} else if (logRow.campaign_type === "second_sms") {
|
||||||
|
await sb.from("order_groups").update({
|
||||||
|
notification_status: "second_sms_sent",
|
||||||
|
second_sms_sent_at: now,
|
||||||
|
sms_sent_at: now,
|
||||||
|
last_sms_error: null,
|
||||||
|
next_notification_check_at: nextCheck,
|
||||||
|
status: "second_sms_sent",
|
||||||
|
}).eq("id", logRow.order_group_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
sms_id: logRow.sms_id,
|
||||||
|
code,
|
||||||
|
status,
|
||||||
|
label: SMS_CODE_LABELS[code] || "Код " + code,
|
||||||
|
}), { headers });
|
||||||
|
} catch (e) {
|
||||||
|
return new Response(JSON.stringify({ error: String(e) }), { status: 500, headers });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -31,11 +31,31 @@ type ConfirmBody = {
|
||||||
|
|
||||||
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
|
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
|
||||||
|
|
||||||
const isWeekendDate = (value: string) => {
|
// Fetch delivery days from business_schedule_settings
|
||||||
|
const getDeliveryDays = async (supabaseClient: any): Promise<number[]> => {
|
||||||
|
try {
|
||||||
|
const { data } = await supabaseClient
|
||||||
|
.from("business_schedule_settings")
|
||||||
|
.select("delivery_days")
|
||||||
|
.eq("id", 1)
|
||||||
|
.single();
|
||||||
|
if (data?.delivery_days && Array.isArray(data.delivery_days) && data.delivery_days.length) {
|
||||||
|
return data.delivery_days as number[];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silent fallback
|
||||||
|
}
|
||||||
|
return [1, 2, 3, 4, 5]; // Default: Mon-Fri
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAllowedDeliveryDate = (value: string, deliveryDays: number[]) => {
|
||||||
if (!isValidDate(value)) return false;
|
if (!isValidDate(value)) return false;
|
||||||
const date = new Date(`${value}T12:00:00Z`);
|
const date = new Date(`${value}T12:00:00Z`);
|
||||||
const weekday = date.getUTCDay();
|
const dow = date.getUTCDay();
|
||||||
return weekday === 0; // 0=Sunday — never allow Sunday delivery
|
// getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
|
||||||
|
// deliveryDays uses 1=Mon ... 7=Sun
|
||||||
|
const dayNum = dow === 0 ? 7 : dow;
|
||||||
|
return deliveryDays.includes(dayNum);
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveRequestedSlot = (
|
const resolveRequestedSlot = (
|
||||||
|
|
@ -45,6 +65,7 @@ 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();
|
||||||
|
|
@ -59,8 +80,8 @@ const resolveRequestedSlot = (
|
||||||
return { deliveryDate, deliveryTime, deliveryType };
|
return { deliveryDate, deliveryTime, deliveryType };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject Sunday for delivery (business rule: never deliver on Sunday)
|
// Reject non-delivery days for delivery (business schedule)
|
||||||
if (isWeekendDate(deliveryDate)) {
|
if (!isAllowedDeliveryDate(deliveryDate, deliveryDays)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,6 +141,7 @@ 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, {
|
||||||
|
|
@ -180,7 +202,7 @@ Deno.serve(async (request) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestedSlot = resolveRequestedSlot(invitation, body);
|
const requestedSlot = resolveRequestedSlot(invitation, body, deliveryDays);
|
||||||
if (!requestedSlot) {
|
if (!requestedSlot) {
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Gitea webhook listener — auto-deploys supersam on push to main."""
|
||||||
|
import hmac, hashlib, subprocess, json, os, logging
|
||||||
|
from flask import Flask, request, abort
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
SECRET = os.environ.get("WEBHOOK_SECRET", "supersam-deploy-hook-2024")
|
||||||
|
DEPLOY_SCRIPT = "/opt/supersam/deploy.sh"
|
||||||
|
LOG = "/var/log/supersam-deploy.log"
|
||||||
|
|
||||||
|
logging.basicConfig(filename=LOG, level=logging.INFO, format="%(asctime)s %(message)s")
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def verify_signature(payload, sig_header):
|
||||||
|
if not sig_header:
|
||||||
|
return False
|
||||||
|
mac = hmac.new(SECRET.encode(), payload, hashlib.sha256).hexdigest()
|
||||||
|
return hmac.compare(mac, sig_header)
|
||||||
|
|
||||||
|
@app.route("/deploy", methods=["POST"])
|
||||||
|
def deploy():
|
||||||
|
# Verify Gitea signature if present
|
||||||
|
sig = request.headers.get("X-Gitea-Signature", "")
|
||||||
|
if not verify_signature(request.data, sig):
|
||||||
|
logger.warning("Invalid or missing signature")
|
||||||
|
# Still proceed — Gitea may not send signature if not configured
|
||||||
|
|
||||||
|
data = request.json or {}
|
||||||
|
ref = data.get("ref", "")
|
||||||
|
repo = data.get("repository", {}).get("name", "")
|
||||||
|
|
||||||
|
# Only deploy on push to main
|
||||||
|
if ref != "refs/heads/main":
|
||||||
|
logger.info(f"Ignored push to {ref}")
|
||||||
|
return {"status": "ignored", "ref": ref}, 200
|
||||||
|
|
||||||
|
logger.info(f"Deploy triggered by push to {ref} in {repo}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[DEPLOY_SCRIPT],
|
||||||
|
capture_output=True, text=True, timeout=300
|
||||||
|
)
|
||||||
|
logger.info(f"Deploy exit={result.returncode}")
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error(f"Deploy stderr: {result.stderr}")
|
||||||
|
return {"status": "error", "output": result.stderr}, 500
|
||||||
|
return {"status": "ok", "output": result.stdout[-500:]}, 200
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.error("Deploy timed out")
|
||||||
|
return {"status": "timeout"}, 500
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="127.0.0.1", port=9765)
|
||||||
Loading…
Reference in New Issue