merge: server fixes + suggestions voting

This commit is contained in:
root 2026-07-16 12:42:31 +00:00
commit 319761a24c
37 changed files with 2911 additions and 419 deletions

3
.gitignore vendored
View File

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

View File

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

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

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

13
scripts/sms_run_with_db.sh Executable file
View File

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

84
scripts/sms_timer_manager.sh Executable file
View File

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

216
src/AppShell.jsx Normal file
View File

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

View File

@ -0,0 +1,73 @@
import React, { createContext, useContext, useEffect, useState } from "react";
const FontSettingsContext = createContext(null);
const STORAGE_KEY = "supersam-font-settings";
// Categories: key { label, defaultScale, min, max, step, description }
export const FONT_CATEGORIES = [
{ key: "table", label: "Таблицы", description: "Текст в таблицах и списках", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
{ key: "card", label: "Карточки", description: "Текст в карточках и панелях", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
{ key: "nav", label: "Меню и навигация", description: "Пункты меню, вкладки, кнопки", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
{ key: "heading", label: "Заголовки", description: "Названия секций и страниц", defaultScale: 1.0, min: 0.8, max: 1.6, step: 0.05 },
{ key: "body", label: "Основной текст", description: "Обычный текст в интерфейсе", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
{ key: "small", label: "Мелкий текст", description: "Подписи, метки, временные отметки", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
];
const DEFAULT_SETTINGS = FONT_CATEGORIES.reduce((acc, cat) => {
acc[cat.key] = cat.defaultScale;
return acc;
}, {});
function loadSettings() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { ...DEFAULT_SETTINGS };
const parsed = JSON.parse(raw);
// Merge with defaults to handle new categories
return { ...DEFAULT_SETTINGS, ...parsed };
} catch {
return { ...DEFAULT_SETTINGS };
}
}
function applySettings(settings) {
const root = document.documentElement;
for (const cat of FONT_CATEGORIES) {
root.style.setProperty(`--fs-scale-${cat.key}`, String(settings[cat.key] ?? 1.0));
}
}
export const FontSettingsProvider = ({ children }) => {
const [settings, setSettings] = useState(loadSettings);
useEffect(() => {
applySettings(settings);
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
}, [settings]);
const updateCategory = (key, scale) => {
setSettings((prev) => ({ ...prev, [key]: scale }));
};
const resetAll = () => {
setSettings({ ...DEFAULT_SETTINGS });
};
const value = {
settings,
updateCategory,
resetAll,
categories: FONT_CATEGORIES,
};
return <FontSettingsContext.Provider value={value}>{children}</FontSettingsContext.Provider>;
};
export const useFontSettings = () => {
const context = useContext(FontSettingsContext);
if (!context) {
throw new Error("useFontSettings must be used within FontSettingsProvider");
}
return context;
};

View File

@ -0,0 +1,389 @@
import React from "react";
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
filterOrderGroups,
getOrderGroupDisplayStatusLabel,
getOrderGroupDisplayStatusValue,
getOrderGroupStatusTone,
ORDER_GROUP_DISPLAY_STATUS_OPTIONS,
} from "../../services/orderGroupViews";
import { Badge } from "../UI/Badge";
import { Panel } from "../UI/Panel";
import { SkeletonPage } from "../UI/Loading";
import { OrderFilters } from "../orders/OrderFilters";
import { formatDate, formatDateTime } from "../../utils/formatters";
const fmtDate = (d) => {
if (!d) return "";
const [y, m, day] = d.split("-");
if (!y || !m || !day) return d;
return `${day}.${m}.${y}`;
};
// Default priority: agreed first, manual_required second, then funnel
const DEFAULT_FUNNEL_ORDER = [
"delivery:agreed",
"status:manual_required",
"status:ready_for_notification",
"delivery:pending_confirmation",
"status:first_sms_sent",
"status:second_sms_sent",
"delivery:driver_assigned",
"delivery:loaded",
"delivery:on_route",
"delivery:delivered",
"delivery:picked_up",
"delivery:paid_storage",
"delivery:problem",
"delivery:cancelled",
];
const STORAGE_KEY = "logistics-section-order";
const COLLAPSED_KEY = "logistics-section-collapsed";
// Load custom order from localStorage, merge with defaults
const loadCustomOrder = () => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
};
const saveCustomOrder = (order) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(order));
} catch {
// ignore
}
};
// Load collapsed sections from localStorage
const loadCollapsedSections = () => {
try {
const raw = localStorage.getItem(COLLAPSED_KEY);
if (!raw) return new Set();
const parsed = JSON.parse(raw);
return new Set(Array.isArray(parsed) ? parsed : []);
} catch {
return new Set();
}
};
const saveCollapsedSections = (collapsedSet) => {
try {
localStorage.setItem(COLLAPSED_KEY, JSON.stringify([...collapsedSet]));
} catch {
// ignore
}
};
// 7 columns: Клиент | Город | Тип | Дата | Водитель | Статус | Обновлён
const COLS = "grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(100px,0.8fr)_minmax(100px,1fr)_minmax(90px,1fr)_minmax(100px,1fr)_minmax(90px,0.8fr)]";
const MIN_W = "min-w-[1080px]";
const TableHeader = () => (
<div className={`grid ${COLS} gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.12em] text-[var(--color-text-muted)]`}>
<div className="px-3 py-1.5 font-medium">Клиент</div>
<div className="px-3 py-1.5 font-medium">Город</div>
<div className="px-3 py-1.5 font-medium">Тип</div>
<div className="px-3 py-1.5 font-medium">Дата доставки</div>
<div className="px-3 py-1.5 font-medium">Водитель</div>
<div className="px-3 py-1.5 font-medium">Статус</div>
<div className="px-3 py-1.5 font-medium">Обновлён</div>
</div>
);
const renderRow = (group, onSelectSet) => (
<button
key={group.id}
type="button"
className={`grid ${COLS} gap-0 w-full border-t border-[var(--color-border)] text-left transition hover:bg-[var(--color-accent-soft)]`}
onClick={() => { if (onSelectSet) onSelectSet(group.id); }}
>
<div className="min-w-0 px-3 py-1.5">
<div className="text-xs font-medium leading-snug break-words" style={{ display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden" }}>
{group.displayTitle || group.customerName || group.groupKey}
</div>
<div className="mt-0.5 text-[11px] text-[var(--color-text-muted)]">
{group.customerPhone || ""}
</div>
</div>
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
{group.city || group.customerAddress || "—"}
</div>
<div className="px-3 py-1.5 text-xs">
<span className="inline-flex items-center gap-1 whitespace-nowrap">
{group.deliveryType === "pickup" ? "🏪" : "🚚"}
<span className="text-[var(--color-text-muted)]">{group.deliveryType === "pickup" ? "Самовывоз" : "Доставка"}</span>
</span>
</div>
<div className="px-3 py-1.5 text-xs">
{group.deliveryDate ? (
<span>{fmtDate(group.deliveryDate)}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
) : (
<span className="text-[var(--color-text-muted)]"></span>
)}
</div>
<div className="px-3 py-1.5 text-xs">
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]"></span>}
</div>
<div className="px-3 py-1.5">
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge>
</div>
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
{formatDateTime(group.updatedAt)}
</div>
</button>
);
// Sortable section wrapper
const SortableSection = ({ statusValue, label, groups, isCollapsed, onToggle, onSelectSet }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: statusValue });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className="rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden"
>
{/* Section header — drag handle + collapse toggle */}
<div className="flex w-full items-center justify-between">
{/* Drag handle */}
<button
type="button"
className="flex items-center px-3 py-3 cursor-grab active:cursor-grabbing text-[var(--color-text)] hover:bg-[var(--color-accent-soft)] rounded-l-[28px] touch-none"
title="Перетащите для изменения порядка"
{...attributes}
{...listeners}
>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24" style={{ opacity: 0.6 }}>
<circle cx="9" cy="5" r="1.8" />
<circle cx="15" cy="5" r="1.8" />
<circle cx="9" cy="12" r="1.8" />
<circle cx="15" cy="12" r="1.8" />
<circle cx="9" cy="19" r="1.8" />
<circle cx="15" cy="19" r="1.8" />
</svg>
</button>
{/* Collapse toggle */}
<button
type="button"
className="flex flex-1 items-center justify-between py-3 pr-5 text-left transition hover:bg-[var(--color-surface-strong)]"
onClick={onToggle}
>
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">{label}</h3>
<Badge tone={groups.length > 0 ? "neutral" : "muted"}>{groups.length}</Badge>
</div>
<svg
className="h-4 w-4 text-[var(--color-text-muted)] transition-transform"
style={{ transform: isCollapsed ? "rotate(-90deg)" : "rotate(0deg)" }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
{!isCollapsed && (
<div className="overflow-x-auto">
<div className={MIN_W}>
<TableHeader />
{groups.map((g) => renderRow(g, onSelectSet))}
</div>
</div>
)}
</div>
);
};
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS, isLoading = false }) => {
const [filters, setFilters] = React.useState({ query: "", displayStatus: "all", city: "" });
const [collapsedSections, setCollapsedSections] = React.useState(() => loadCollapsedSections());
const [sectionOrder, setSectionOrder] = React.useState(() => {
const custom = loadCustomOrder();
return custom || [...DEFAULT_FUNNEL_ORDER];
});
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
const cities = React.useMemo(() => {
const set = new Set();
for (const g of orderGroups) {
if (g.city) set.add(g.city);
}
return [...set].sort();
}, [orderGroups]);
const filteredGroups = React.useMemo(
() => filterOrderGroups(orderGroups, filters),
[filters, orderGroups],
);
const statusGroups = React.useMemo(() => {
const map = new Map();
for (const group of filteredGroups) {
const statusValue = getOrderGroupDisplayStatusValue(group);
if (!map.has(statusValue)) {
const label = getOrderGroupDisplayStatusLabel(group);
map.set(statusValue, { label, groups: [] });
}
map.get(statusValue).groups.push(group);
}
return map;
}, [filteredGroups]);
const totalGroups = filteredGroups.length;
// Build sorted list: use sectionOrder for known statuses, append unknown ones at end
const sortedEntries = React.useMemo(() => {
const present = new Set(statusGroups.keys());
const result = [];
// First: statuses in custom order that are present
for (const statusValue of sectionOrder) {
if (present.has(statusValue)) {
const data = statusGroups.get(statusValue);
result.push([statusValue, data]);
}
}
// Then: any statuses not in sectionOrder (new statuses), sorted alphabetically
for (const [statusValue, data] of statusGroups.entries()) {
if (!sectionOrder.includes(statusValue)) {
result.push([statusValue, data]);
}
}
return result;
}, [statusGroups, sectionOrder]);
const handleDragEnd = (event) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setSectionOrder((prevOrder) => {
// Build the full order including any new statuses
const allIds = sortedEntries.map(([id]) => id);
const oldIndex = allIds.indexOf(active.id);
const newIndex = allIds.indexOf(over.id);
if (oldIndex === -1 || newIndex === -1) return prevOrder;
const newAllOrder = arrayMove(allIds, oldIndex, newIndex);
// Merge: replace positions of known statuses, keep unknown at end
// Save the full new order so it persists
saveCustomOrder(newAllOrder);
return newAllOrder;
});
};
if (isLoading) {
return <SkeletonPage panels={3} />;
}
return (
<div className="space-y-6 fs-zone-table">
<Panel className="space-y-4 p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<h2 className="text-lg font-semibold">Наборы доставки</h2>
<p className="text-xs text-[var(--color-text-muted)] mt-0.5">
Перетаскивайте секции за ручку слева, чтобы изменить порядок отображения.
</p>
</div>
<Badge tone="neutral">{totalGroups} групп</Badge>
</div>
<OrderFilters
filters={filters}
setFilters={setFilters}
statusOptions={statusOptions}
cities={cities}
/>
</Panel>
{!totalGroups ? (
<div className="rounded-[28px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
По этому поиску ничего не найдено.
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sortedEntries.map(([id]) => id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-4">
{sortedEntries.map(([statusValue, { label, groups }]) => {
const isCollapsed = collapsedSections.has(statusValue);
return (
<SortableSection
key={statusValue}
statusValue={statusValue}
label={label}
groups={groups}
isCollapsed={isCollapsed}
onToggle={() => {
setCollapsedSections((prev) => {
const next = new Set(prev);
if (next.has(statusValue)) {
next.delete(statusValue);
} else {
next.add(statusValue);
}
saveCollapsedSections(next);
return next;
});
}}
onSelectSet={onSelectSet}
/>
);
})}
</div>
</SortableContext>
</DndContext>
)}
</div>
);
};

257
src/OrdersTable.jsx Normal file
View File

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

186
src/SettingsPage.jsx Normal file
View File

@ -0,0 +1,186 @@
/**
* @file SettingsPage.jsx
* @description Page for user settings: font size controls for all UI categories.
* Settings persist to localStorage via FontSettingsContext.
*/
import React from "react";
import { useNavigate } from "react-router-dom";
import { useFontSettings, FONT_CATEGORIES } from "../context/FontSettingsContext";
import { Panel } from "../components/UI/Panel";
import { Button } from "../components/UI/Button";
const PRESETS = [
{ label: "Мелкий", scales: { table: 0.85, card: 0.85, nav: 0.85, heading: 0.85, body: 0.85, small: 0.85 } },
{ label: "Стандарт", scales: { table: 1.0, card: 1.0, nav: 1.0, heading: 1.0, body: 1.0, small: 1.0 } },
{ label: "Крупный", scales: { table: 1.15, card: 1.15, nav: 1.15, heading: 1.2, body: 1.15, small: 1.1 } },
{ label: "Очень крупный", scales: { table: 1.3, card: 1.3, nav: 1.25, heading: 1.4, body: 1.3, small: 1.2 } },
];
const Slider = ({ category, value, onChange }) => {
const pct = ((value - category.min) / (category.max - category.min)) * 100;
return (
<div className="space-y-2">
<div className="flex items-baseline justify-between">
<div>
<span className="font-medium text-[var(--color-text)]">{category.label}</span>
<p className="text-xs text-[var(--color-text-muted)]">{category.description}</p>
</div>
<span className="text-sm font-semibold tabular-nums text-[var(--color-accent)]">
{Math.round(value * 100)}%
</span>
</div>
<div className="flex items-center gap-3">
<input
type="range"
min={category.min}
max={category.max}
step={category.step}
value={value}
onChange={(e) => onChange(parseFloat(e.target.value))}
className="fs-slider flex-1"
style={{
background: `linear-gradient(to right, var(--color-accent) 0%, var(--color-accent) ${pct}%, var(--color-border) ${pct}%, var(--color-border) 100%)`,
}}
aria-label={category.label}
/>
<span className="text-xs tabular-nums text-[var(--color-text-muted)] w-16 text-right">
{value.toFixed(2)}×
</span>
</div>
</div>
);
};
export const SettingsPage = () => {
const { settings, updateCategory, resetAll, categories } = useFontSettings();
const navigate = useNavigate();
const applyPreset = (scales) => {
for (const [key, scale] of Object.entries(scales)) {
updateCategory(key, scale);
}
};
// Check if current settings match a preset
const activePreset = PRESETS.findIndex((p) =>
Object.entries(p.scales).every(([k, v]) => Math.abs((settings[k] ?? 1.0) - v) < 0.001)
);
return (
<div className="mx-auto max-w-3xl space-y-6 px-3 py-4 sm:px-4 md:px-6 md:py-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Настройки</p>
<h1 className="mt-1 text-2xl font-bold leading-tight" style={{ fontSize: `calc(1.5rem * var(--fs-scale-heading, 1))` }}>
Настройки интерфейса
</h1>
</div>
<Button variant="ghost" onClick={() => navigate(-1)}>
Назад
</Button>
</div>
{/* Presets */}
<Panel className="p-5 space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Быстрые пресеты
</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{PRESETS.map((preset, i) => (
<button
key={preset.label}
type="button"
onClick={() => applyPreset(preset.scales)}
className={[
"rounded-[18px] border-2 px-4 py-3 text-center transition",
activePreset === i
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)] font-semibold"
: "border-[var(--color-border)] bg-[var(--color-surface-strong)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-text)]",
].join(" ")}
>
<div className="text-sm font-medium">{preset.label}</div>
</button>
))}
</div>
</Panel>
{/* Font size sliders */}
<Panel className="p-5 space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Размеры шрифтов
</h2>
<Button size="sm" variant="ghost" onClick={resetAll}>
Сбросить
</Button>
</div>
<div className="space-y-5">
{categories.map((cat) => (
<Slider
key={cat.key}
category={cat}
value={settings[cat.key] ?? cat.defaultScale}
onChange={(v) => updateCategory(cat.key, v)}
/>
))}
</div>
{/* Preview */}
<div className="rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 space-y-2">
<p className="text-xs uppercase tracking-wider text-[var(--color-text-muted)]">Предпросмотр</p>
<div className="fs-zone-heading space-y-1">
<h3 className="font-bold" style={{ fontSize: `calc(1.25rem * var(--fs-scale-heading, 1))` }}>
Заголовок секции
</h3>
</div>
<div className="fs-zone-nav flex gap-2">
<span className="rounded-[14px] bg-[var(--color-accent)] px-3 py-1.5 text-[var(--color-accent-contrast)]"
style={{ fontSize: `calc(0.875rem * var(--fs-scale-nav, 1))` }}>
Пункт меню
</span>
<span className="rounded-[14px] bg-[var(--color-surface)] px-3 py-1.5 border border-[var(--color-border)]"
style={{ fontSize: `calc(0.875rem * var(--fs-scale-nav, 1))` }}>
Вкладка
</span>
</div>
<div className="fs-zone-table rounded-[14px] border border-[var(--color-border)] overflow-hidden">
<table className="w-full">
<thead>
<tr className="bg-[var(--color-surface)]">
<th className="px-3 py-2 text-left font-semibold" style={{ fontSize: `calc(0.875rem * var(--fs-scale-table, 1))` }}>Дата</th>
<th className="px-3 py-2 text-left font-semibold" style={{ fontSize: `calc(0.875rem * var(--fs-scale-table, 1))` }}>Статус</th>
</tr>
</thead>
<tbody>
<tr className="border-t border-[var(--color-border)]">
<td className="px-3 py-2" style={{ fontSize: `calc(0.875rem * var(--fs-scale-table, 1))` }}>02.07.2026</td>
<td className="px-3 py-2" style={{ fontSize: `calc(0.875rem * var(--fs-scale-table, 1))` }}>В работе</td>
</tr>
</tbody>
</table>
</div>
<div className="fs-zone-card rounded-[14px] bg-[var(--color-surface)] p-3">
<p style={{ fontSize: `calc(0.875rem * var(--fs-scale-card, 1))` }}>
Текст в карточке описание доставки или заказа.
</p>
</div>
<p className="fs-zone-body text-[var(--color-text)]" style={{ fontSize: `calc(1rem * var(--fs-scale-body, 1))` }}>
Основной текст интерфейса.
</p>
<p className="fs-zone-small text-[var(--color-text-muted)]" style={{ fontSize: `calc(0.75rem * var(--fs-scale-small, 1))` }}>
мелкая подпись · временная отметка
</p>
</div>
</Panel>
<p className="text-center text-xs text-[var(--color-text-muted)]">
Настройки сохраняются на этом устройстве
</p>
</div>
);
};
export default SettingsPage;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -138,11 +138,17 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
const [comments, setComments] = React.useState(initialComments);
const [commentInput, setCommentInput] = React.useState("");
// Sync state when order data changes (e.g. after save)
// Sync state ONLY when order ID changes (navigating to different order), not on every order data update
const orderId = order?.id;
const prevOrderId = React.useRef(orderId);
React.useEffect(() => {
setShippedItems(initialShippedIds);
setComments(initialComments);
}, [initialShippedIds, initialComments]);
if (prevOrderId.current !== orderId) {
prevOrderId.current = orderId;
setShippedItems(initialShippedIds);
setComments(initialComments);
setJustSaved(false);
}
}, [orderId, initialShippedIds, initialComments]);
// Track last saved shipment data for visual display
React.useEffect(() => {
@ -170,7 +176,7 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
};
const currentDeliveryStatus = order?.deliveryStatus || order?.delivery_status;
const isStatusFinal = ["delivered", "problem", "picked_up"].includes(currentDeliveryStatus);
const isStatusFinal = ["delivered", "problem", "picked_up", "loaded", "on_route", "driver_assigned"].includes(currentDeliveryStatus);
const unshipAll = () => {
if (isStatusFinal && onResetStatus) {
@ -222,7 +228,7 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
if (items.length === 0) {
return (
<Panel className="space-y-3 p-5">
<Panel className="space-y-3 p-5 fs-zone-card">
<strong>Состав заказа</strong>
<p className="text-sm text-[var(--color-text-muted)]">Позиции не указаны</p>
</Panel>
@ -230,7 +236,7 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
}
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>
<strong>Отгрузка</strong>
@ -250,7 +256,7 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
Отгрузить всё
</Button>
<Button variant="ghost" size="sm" onClick={unshipAll} disabled={shippedCount === 0 && !isStatusFinal}>
Сбросить
Сбросить отгрузку
</Button>
</div>

View File

@ -158,12 +158,12 @@ const renderRow = (group, onSelectSet) => (
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]"></span>}
</div>
<div className="px-3 py-1.5">
<div className="flex items-center gap-1.5">
<div className="flex flex-col gap-1">
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge>
{(group.hasDeliveryProblem || group.has_delivery_problem) && (
<span
title={group.deliveryProblemNote || group.delivery_problem_note || "Есть проблемы с отгрузкой позиций"}
className="inline-flex 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>
@ -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 }) => {
const [filters, setFilters] = React.useState({ query: "", displayStatus: "all", city: "" });
const FILTERS_KEY = 'logistics-board-filters';
const [filters, setFilters] = React.useState(() => {
try {
const raw = localStorage.getItem(FILTERS_KEY);
if (raw) return { query: '', displayStatus: 'all', city: '', ...JSON.parse(raw) };
} catch {}
return { query: '', displayStatus: 'all', city: '' };
});
React.useEffect(() => {
try { localStorage.setItem(FILTERS_KEY, JSON.stringify(filters)); } catch {}
}, [filters]);
const [collapsedSections, setCollapsedSections] = React.useState(() => loadCollapsedSections());
const [sectionOrder, setSectionOrder] = React.useState(() => {
const custom = loadCustomOrder();

View File

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

View File

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

View File

@ -2,42 +2,60 @@
const DriverShipmentReport = ({ shipmentData }) => {
if (!Array.isArray(shipmentData) || shipmentData.length === 0) return null;
const deliveredItems = shipmentData.filter((i) => i.shipped);
const notDeliveredItems = shipmentData.filter((i) => !i.shipped);
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">
<svg className="h-5 w-5 text-[var(--color-warning)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<strong className="text-[var(--color-warning)]">Проблемы с доставкой позиций</strong>
<span className="text-lg">📋</span>
<strong>Отчёт об отгрузке</strong>
<Badge tone={notDeliveredItems.length > 0 ? "warning" : "accent"}>
{deliveredItems.length}/{shipmentData.length} доставлено
</Badge>
</div>
<p className="text-sm text-[var(--color-text-muted)]">
Не доставлено {shipmentData.length} {shipmentData.length === 1 ? "позиция" : shipmentData.length < 5 ? "позиции" : "позиций"}. Остальное доставлено.
</p>
{notDeliveredItems.length > 0 && (
<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">
{shipmentData.map((item) => (
<div
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">
<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 ? (
<Badge tone="neutral">{[item.quantity, item.unit].filter(Boolean).join(" ")}</Badge>
) : null}
</div>
{item.comment ? (
<p className="mt-1 text-xs text-[var(--color-text-muted)]">Причина: {item.comment}</p>
) : (
<p className="mt-1 text-xs text-[var(--color-text-muted)] italic">Причина не указана</p>
{!item.shipped && item.comment && (
<p className="mt-1 ml-6 text-xs text-[var(--color-warning)]">Причина: {item.comment}</p>
)}
</div>
))}
</div>
</Panel>
);
};
import React from "react";
import { formatDateTime } from "../../utils/formatters";
import { Badge } from "../UI/Badge";
@ -54,7 +72,11 @@ import {
getOrderGroupStatusTone,
DELIVERY_GROUP_STATUS_LABELS,
} from "../../services/orderGroupViews";
import { getErrorMessage, normalizeNom } from "../../utils/deliveryUtils";
import {
getErrorMessage,
normalizeNom,
} from "../../utils/deliveryUtils";
import { SmsStatusCard } from "./SmsStatusCard";
const fmtTime = (ts) => {
if (!ts) return "—";
@ -331,6 +353,12 @@ const normalizeDateForInput = (value) => {
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 "";
};
@ -594,11 +622,19 @@ export const OrderDetailPanel = ({
setIsSavingShipment(false);
}
}, [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 existingDeliveryDate = fromDateKey(order?.deliveryDate);
const fallbackDate = fromDateKey(minSelectableDateKey) || new Date();
const sourceDate = existingDeliveryDate && isFutureDeliveryDate(toDateKey(existingDeliveryDate))
const sourceDate = existingDeliveryDate
? existingDeliveryDate
: fallbackDate;
@ -613,7 +649,10 @@ export const OrderDetailPanel = ({
}),
[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(() => {
setSelectedDriverId(order?.assignedDriverId || "");
@ -621,9 +660,12 @@ export const OrderDetailPanel = ({
}, [order?.id, order?.assignedDriverId]);
React.useEffect(() => {
const normalizedDeliveryDate = normalizeDateForInput(order?.deliveryDate);
const normalizedDeliveryDate = normalizeDateForInput(order?.deliveryDate || order?.customerDate);
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);
const selectedDate = fromDateKey(selectedDateKey) || new Date();
setCurrentMonth(startOfMonth(selectedDate));
@ -686,7 +728,8 @@ export const OrderDetailPanel = ({
return;
}
if (!isFutureDeliveryDate(effectiveDate)) {
const canUsePastDate = ["logistician", "admin", "mega_admin", "manager"].includes(userRole);
if (!canUsePastDate && !isFutureDeliveryDate(effectiveDate)) {
setFormMessage(deliveryType === "pickup" ? "Выберите дату самовывоза позже сегодняшнего дня." : "Выберите дату доставки позже сегодняшнего дня.");
return;
}
@ -889,10 +932,7 @@ export const OrderDetailPanel = ({
<p className="text-xs text-[var(--color-text-muted)]">Обновлена</p>
<p className="font-medium !text-[var(--color-text)]">{formatDateTime(order.updatedAt)}</p>
</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") ? (
<div>
<p className="text-xs text-[var(--color-text-muted)]">Код выдачи</p>
@ -1053,6 +1093,7 @@ export const OrderDetailPanel = ({
/>
)}
<SmsStatusCard order={order} userRole={userRole} />
<StatusActionPanel
order={order}
@ -1087,7 +1128,7 @@ export const OrderDetailPanel = ({
/>
) : null}
{userRole === "driver" && order ? (
{["driver", "logistician", "admin", "mega_admin"].includes(userRole) && order ? (
<DriverShipmentPanel
order={order}
onShipmentChange={handleShipmentChange}
@ -1097,7 +1138,7 @@ export const OrderDetailPanel = ({
if (onChangeDeliveryStatus) {
onChangeDeliveryStatus({
orderGroupId: order.id,
status: "loaded",
status: "driver_assigned",
}).then((response) => {
if (!response.success) {
setFormMessage(response.error || "Не удалось сбросить статус");
@ -1131,15 +1172,15 @@ export const OrderDetailPanel = ({
<div className="flex flex-wrap gap-2">
{(() => {
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";
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 = [];
} else {
// Primary button matches delivery type, secondary requires confirmation
if (isPickup) {
statusOptions = [
{ 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);
if (statusOptions.length === 0 && !canReturn) return null;
@ -1163,6 +1203,7 @@ export const OrderDetailPanel = ({
return statusOptions.map((statusOption) => {
const isSelected = pendingStatus?.value === statusOption.value;
const isMismatch = statusOption.mismatch;
const blockedBySchedule = statusOption.requiresSchedule && !hasDeliverySchedule;
return (
<Button
key={statusOption.value}
@ -1196,7 +1237,7 @@ export const OrderDetailPanel = ({
disabled={isSavingStatusChange}
onClick={() => {
setPendingStatus({
value: "loaded",
value: "driver_assigned",
label: "Вернуть в работу",
mismatch: false,
deliveryType: "delivery",
@ -1303,7 +1344,7 @@ export const OrderDetailPanel = ({
<CollapsibleOrderComposition order={order} />
</Panel>
{userRole !== "driver" && order?.deliveryLink ? (
{order?.deliveryLink ? (
<Panel className="space-y-3 p-5">
<strong>Ссылка на согласование</strong>
<p className="text-sm text-[var(--color-text-muted)]">

View File

@ -22,6 +22,8 @@ const NOTIF_LABELS = {
paid_storage_sending: "Отправляется…",
paid_storage_sent: "Платное хранение: отправлено",
draft: "Черновик",
confirmed: "Клиент согласовал дату",
completed: "Завершено",
};
const NOTIF_TONES = {
@ -36,6 +38,8 @@ const NOTIF_TONES = {
paid_storage_sending: "info",
paid_storage_sent: "accent",
draft: "neutral",
confirmed: "accent",
completed: "neutral",
};
// Helpers
@ -173,11 +177,8 @@ export const SmsStatusCard = ({ order, userRole }) => {
return (
<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>
<Badge tone={NOTIF_TONES[notifStatus] || "neutral"}>
{NOTIF_LABELS[notifStatus] || notifStatus}
</Badge>
</div>
{/* Timeline */}
@ -188,9 +189,9 @@ export const SmsStatusCard = ({ order, userRole }) => {
<div className="flex-1">
<div className="text-[var(--color-text)]">1-е SMS</div>
{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" ? (
<div className="text-[var(--color-text-muted)]">{fmtTime(smsSentAt)} · отправлено, ждём подтверждения</div>
<div className="text-[var(--color-text-muted)]">{fmtTime(smsSentAt)} · отправлено, ждём ответ оператора</div>
) : notifStatus === "link_ready" ? (
<div className="text-[var(--color-text-muted)]">в очереди на отправку</div>
) : (
@ -205,7 +206,7 @@ export const SmsStatusCard = ({ order, userRole }) => {
<div className="flex-1">
<div className="text-[var(--color-text)]">2-е SMS</div>
{hasSecondSms ? (
<div className="text-[var(--color-text-muted)]">{fmtTime(secondSmsAt)}</div>
<div className="text-[var(--color-text-muted)]">{fmtTime(secondSmsAt)} получено клиентом</div>
) : notifStatus === "first_sms_sent" && countdown ? (
<div className="text-[var(--color-text-muted)]">
отправка через <span className="font-mono text-[var(--color-accent)]">{countdown}</span>
@ -260,6 +261,23 @@ export const SmsStatusCard = ({ order, userRole }) => {
</div>
)}
{/* Client page access info */}
{(order.invitationAccessCount > 0 || order.invitationOpenedAt) ? (
<div className="mt-2 flex items-center gap-2 rounded-lg bg-[var(--color-surface-strong)] px-2 py-1.5 text-[11px]">
<span className="text-[var(--color-text-muted)]">👁 Клиент открывал страницу согласования</span>
<span className="font-medium text-[var(--color-text)]">{order.invitationAccessCount || 1} раз</span>
{(order.invitationLastAccessedAt || order.invitationOpenedAt) && (
<span className="text-[var(--color-text-muted)]">
· последний: {fmtTime(order.invitationLastAccessedAt || order.invitationOpenedAt)}
</span>
)}
</div>
) : (
<div className="mt-2 flex items-center gap-2 rounded-lg bg-[var(--color-surface-strong)] px-2 py-1.5 text-[11px] text-[var(--color-text-muted)]">
📭 Клиент ещё не открывал страницу согласования
</div>
)}
{/* Restart buttons */}
{canManage && (
<div className="mt-3 flex gap-2 border-t border-[var(--color-border)] pt-3">

View File

@ -4,8 +4,6 @@ import { Button } from "../UI/Button";
import { Panel } from "../UI/Panel";
import { DELIVERY_GROUP_STATUS_LABELS } from "../../services/orderGroupViews";
const STATUS_LABELS = DELIVERY_GROUP_STATUS_LABELS;
const StatusActionPanel = ({
order,
userRole,
@ -18,6 +16,48 @@ const StatusActionPanel = ({
}
const currentStatus = order.deliveryStatus || order.delivery_status;
const isPickup = (order.deliveryType || order.delivery_type) === "pickup" || currentStatus === "pickup";
// Check delivery schedule
const hasDeliveryDate = !!(order.deliveryDate || order.customerDate);
const hasDeliveryHalfDay = !!(order.deliveryTime || order.deliveryHalfDay || order.delivery_time || order.delivery_half_day);
const hasDeliverySchedule = hasDeliveryDate && hasDeliveryHalfDay;
const hasDriver = !!order.assignedDriverId;
// Smart hints: show actual state instead of generic "do X"
const getHint = (statusValue) => {
if (statusValue === "agreed") {
if (hasDeliverySchedule) return "Дата доставки уже согласована";
return "Согласуйте дату доставки выше";
}
if (statusValue === "driver_assigned") {
if (hasDriver) return `Водитель уже назначен: ${order.assignedDriverName || ""}`.trim();
return "Назначьте водителя из списка выше";
}
return "";
};
const allStatuses = isPickup
? [
{ value: "pending_confirmation", label: "Ожидает согласования" },
{ value: "agreed", label: "Согласовано" },
{ value: "driver_assigned", label: "Назначен водитель" },
{ value: "picked_up", label: "Вывезено", primary: true, requiresSchedule: true, requiresDriver: false },
{ value: "delivered", label: "Доставлено", mismatch: true, requiresSchedule: true },
{ value: "requires_address", label: "Требуется адрес" },
{ value: "problem", label: "Проблема" },
{ value: "cancelled", label: "Отменено" },
]
: [
{ value: "pending_confirmation", label: "Ожидает согласования" },
{ value: "agreed", label: "Согласовано" },
{ value: "driver_assigned", label: "Назначен водитель" },
{ value: "delivered", label: "Доставлено", primary: true, requiresSchedule: true, requiresDriver: true },
{ value: "picked_up", label: "Вывезено", mismatch: true, requiresSchedule: true },
{ value: "requires_address", label: "Требуется адрес" },
{ value: "problem", label: "Проблема" },
{ value: "cancelled", label: "Отменено" },
];
return (
<Panel className="space-y-4 p-5">
@ -27,39 +67,69 @@ const StatusActionPanel = ({
Измените статус, если водитель забыл обновить или нужна корректировка.
</p>
</div>
{/* Status indicators: show current state clearly */}
<div className="flex flex-wrap gap-2">
{[
{ value: "pending_confirmation", label: "Ожидает согласования", manual: true },
{ value: "agreed", label: "Согласовано", manual: false, hint: "Согласуйте дату доставки выше" },
{ value: "driver_assigned", label: "Назначен водитель", manual: false, hint: "Назначьте водителя из списка" },
{ value: "loaded", label: "Загружено", manual: true },
{ value: "delivered", label: "Доставлено", manual: true },
{ value: "picked_up", label: "Вывезено", manual: true },
{ value: "requires_address", label: "Требуется адрес", manual: true },
{ value: "problem", label: "Проблема", manual: true },
{ value: "cancelled", label: "Отменено", manual: true },
].map((statusOption) => {
{hasDeliverySchedule && (
<Badge tone="accent"> Дата согласована</Badge>
)}
{hasDriver && (
<Badge tone="accent"> Водитель: {order.assignedDriverName || "назначен"}</Badge>
)}
{!hasDeliverySchedule && (
<Badge tone="warning"> Дата не указана</Badge>
)}
{!hasDriver && !isPickup && (
<Badge tone="warning"> Водитель не назначен</Badge>
)}
</div>
<div className="flex flex-wrap gap-2">
{allStatuses.map((statusOption) => {
const isCurrent = currentStatus === statusOption.value;
const isClickable = statusOption.manual !== false && !isCurrent;
const blockedBySchedule = statusOption.requiresSchedule && !hasDeliverySchedule;
const blockedByDriver = statusOption.requiresDriver !== false && !hasDriver;
const hint = getHint(statusOption.value);
return (
<div key={statusOption.value} className="relative group">
<Button
variant={isCurrent ? "primary" : "secondary"}
onClick={() => {
if (!isClickable) {
onConfirmStatus?.({ type: "hint", hint: statusOption.hint || "" });
return;
}
onConfirmStatus?.({ type: "status", status: statusOption.value });
}}
disabled={isSavingStatusChange}
>
{statusOption.label}
</Button>
</div>
<Button
key={statusOption.value}
variant={isCurrent ? "primary" : (statusOption.mismatch ? "ghost" : "secondary")}
onClick={() => {
if (blockedBySchedule) {
return;
}
if (blockedByDriver) {
onConfirmStatus?.({
type: "hint",
hint: "⚠ Назначьте водителя перед статусом «Доставлено»",
});
return;
}
if (isCurrent && hint) {
onConfirmStatus?.({ type: "hint", hint });
return;
}
onConfirmStatus?.({
type: "status",
status: statusOption.value,
label: statusOption.label,
mismatch: !!statusOption.mismatch,
deliveryType: isPickup ? "pickup" : "delivery",
});
}}
disabled={isSavingStatusChange}
className={(blockedBySchedule || blockedByDriver) ? "opacity-40 cursor-not-allowed" : ""}
>
{statusOption.label}
</Button>
);
})}
</div>
{!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>
);
};

View File

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

101
src/fontSettings.css Normal file
View File

@ -0,0 +1,101 @@
/* Font size scale variables — applied by FontSettingsContext */
:root {
--fs-scale-table: 1.0;
--fs-scale-card: 1.0;
--fs-scale-nav: 1.0;
--fs-scale-heading: 1.0;
--fs-scale-body: 1.0;
--fs-scale-small: 1.0;
}
/* Slider styling */
.fs-slider {
-webkit-appearance: none;
appearance: none;
height: 6px;
border-radius: 3px;
outline: none;
cursor: pointer;
}
.fs-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--color-accent);
border: 3px solid var(--color-surface-strong);
cursor: pointer;
transition: transform 120ms ease;
}
.fs-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
.fs-slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--color-accent);
border: 3px solid var(--color-surface-strong);
cursor: pointer;
border: none;
}
/* Zone overrides
.fs-zone-* wrappers override Tailwind text-* classes inside them.
Specificity: .fs-zone-X .text-Y (0,2,0) > .text-Y (0,1,0).
This lets us scale fonts without patching every component.
*/
/* Table zone */
.fs-zone-table { font-size: calc(0.875rem * var(--fs-scale-table, 1)); }
.fs-zone-table .text-xs { font-size: calc(0.75rem * var(--fs-scale-table, 1)); }
.fs-zone-table .text-sm { font-size: calc(0.875rem * var(--fs-scale-table, 1)); }
.fs-zone-table .text-base { font-size: calc(1rem * var(--fs-scale-table, 1)); }
.fs-zone-table .text-lg { font-size: calc(1.125rem * var(--fs-scale-table, 1)); }
.fs-zone-table .text-xl { font-size: calc(1.25rem * var(--fs-scale-table, 1)); }
/* Arbitrary pixel sizes used in tables */
.fs-zone-table .text-\[10px\] { font-size: calc(10px * var(--fs-scale-table, 1)); }
.fs-zone-table .text-\[11px\] { font-size: calc(11px * var(--fs-scale-table, 1)); }
.fs-zone-table .text-\[12px\] { font-size: calc(12px * var(--fs-scale-table, 1)); }
.fs-zone-table .text-\[13px\] { font-size: calc(13px * var(--fs-scale-table, 1)); }
.fs-zone-table .text-\[14px\] { font-size: calc(14px * var(--fs-scale-table, 1)); }
/* Card zone */
.fs-zone-card { font-size: calc(0.875rem * var(--fs-scale-card, 1)); }
.fs-zone-card .text-xs { font-size: calc(0.75rem * var(--fs-scale-card, 1)); }
.fs-zone-card .text-sm { font-size: calc(0.875rem * var(--fs-scale-card, 1)); }
.fs-zone-card .text-base { font-size: calc(1rem * var(--fs-scale-card, 1)); }
.fs-zone-card .text-lg { font-size: calc(1.125rem * var(--fs-scale-card, 1)); }
.fs-zone-card .text-\[10px\] { font-size: calc(10px * var(--fs-scale-card, 1)); }
.fs-zone-card .text-\[11px\] { font-size: calc(11px * var(--fs-scale-card, 1)); }
/* Nav zone */
.fs-zone-nav { font-size: calc(0.875rem * var(--fs-scale-nav, 1)); }
.fs-zone-nav .text-xs { font-size: calc(0.75rem * var(--fs-scale-nav, 1)); }
.fs-zone-nav .text-sm { font-size: calc(0.875rem * var(--fs-scale-nav, 1)); }
.fs-zone-nav .text-base { font-size: calc(1rem * var(--fs-scale-nav, 1)); }
/* Heading zone */
.fs-zone-heading { font-size: calc(1rem * var(--fs-scale-heading, 1)); }
.fs-zone-heading .text-sm { font-size: calc(0.875rem * var(--fs-scale-heading, 1)); }
.fs-zone-heading .text-base { font-size: calc(1rem * var(--fs-scale-heading, 1)); }
.fs-zone-heading .text-lg { font-size: calc(1.125rem * var(--fs-scale-heading, 1)); }
.fs-zone-heading .text-xl { font-size: calc(1.25rem * var(--fs-scale-heading, 1)); }
.fs-zone-heading .text-2xl { font-size: calc(1.5rem * var(--fs-scale-heading, 1)); }
.fs-zone-heading .text-3xl { font-size: calc(1.875rem * var(--fs-scale-heading, 1)); }
/* Small text zone */
.fs-zone-small { font-size: calc(0.75rem * var(--fs-scale-small, 1)); }
.fs-zone-small .text-xs { font-size: calc(0.75rem * var(--fs-scale-small, 1)); }
.fs-zone-small .text-sm { font-size: calc(0.875rem * var(--fs-scale-small, 1)); }
/* Body zone — LAST so table/card/nav/heading zones inside body win at equal specificity */
.fs-zone-body { font-size: calc(1rem * var(--fs-scale-body, 1)); }
.fs-zone-body .text-xs { font-size: calc(0.75rem * var(--fs-scale-body, 1)); }
.fs-zone-body .text-sm { font-size: calc(0.875rem * var(--fs-scale-body, 1)); }
.fs-zone-body .text-base { font-size: calc(1rem * var(--fs-scale-body, 1)); }
.fs-zone-body .text-lg { font-size: calc(1.125rem * var(--fs-scale-body, 1)); }

View File

@ -11,11 +11,21 @@ import { getErrorMessage } from "../utils/deliveryUtils";
export const useOrderGroups = () => {
const [orderGroups, setOrderGroups] = React.useState(() => []);
const [filters, setFilters] = React.useState({
query: "",
displayStatus: "all",
deliveryType: "",
const FILTERS_STORAGE_KEY = "supersam_order_filters";
const [filters, setFilters] = React.useState(() => {
try {
const saved = localStorage.getItem(FILTERS_STORAGE_KEY);
if (saved) return JSON.parse(saved);
} catch (e) {}
return { query: "", displayStatus: "all", deliveryType: "" };
});
// Persist filters to localStorage on every change
React.useEffect(() => {
try {
localStorage.setItem(FILTERS_STORAGE_KEY, JSON.stringify(filters));
} catch (e) {}
}, [filters]);
const [selectedOrderGroupId, setSelectedOrderGroupId] = React.useState(null);
const [isLoading, setIsLoading] = React.useState(true);
const [loadError, setLoadError] = React.useState("");

View File

@ -17,6 +17,7 @@ import { StopWordsPanel } from "../components/admin/StopWordsPanel";
import { ActionLogPanel } from "../components/admin/ActionLogPanel";
import { SuggestionsPanel } from "../components/admin/SuggestionsPanel";
import { SmsCampaignPanel } from "../components/admin/SmsCampaignPanel";
import { BusinessSchedulePanel } from "../components/admin/BusinessSchedulePanel";
import { Panel } from "../components/UI/Panel";
import { SkeletonPage, SkeletonTable } from "../components/UI/Loading";
import { useAuth } from "../context/AuthContext";
@ -36,6 +37,7 @@ const MEGA_ADMIN_NAV = [
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
{ key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null },
{ key: "sms_campaign", label: "SMS-кампании", description: "Логи и настройки SMS-рассылок.", badge: null },
{ key: "schedule", label: "Расписание", description: "Рабочие дни доставки, самовывоза и SMS.", badge: null },
];
// Role Default Section Map
@ -137,6 +139,7 @@ export const DashboardPage = () => {
{ key: "errors", label: "Ошибки", description: "Журнал ошибок приложения.", badge: null },
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
{ key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null },
{ key: "schedule", label: "Расписание", description: "Рабочие дни доставки, самовывоза и SMS.", badge: null },
]
: userRole === "logistician"
? [
@ -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 === "suggestions") return <div className="space-y-6 xl:space-y-8"><SuggestionsPanel /></div>;
if (activeSection === "sms_campaign") return <div className="space-y-6 xl:space-y-8"><SmsCampaignPanel /></div>;
if (activeSection === "schedule") return <div className="space-y-6 xl:space-y-8"><BusinessSchedulePanel /></div>;
if (isLoading) {
if (userRole === "driver") {

View File

@ -39,7 +39,7 @@ export const DRIVER_VISIBLE_DELIVERY_STATUSES = [
"paid_storage",
];
export const DRIVER_ACTIVE_DELIVERY_STATUSES = ["driver_assigned", "loaded", "on_route", "problem"];
export const DRIVER_ACTIVE_DELIVERY_STATUSES = ["driver_assigned", "problem"];
const HALF_DAY_LABELS = {
morning: "Первая половина дня",
@ -143,9 +143,17 @@ export const isOrderGroupAgreedForDelivery = (group) => {
export const getOrderGroupDeliveryStatusLabel = (status) =>
DELIVERY_GROUP_STATUS_LABELS[status] || (status ? `Неизвестно (${status})` : "Неизвестно");
// Status values that represent a delivery state (not SMS/notification state)
const DELIVERY_STATUS_VALUES = new Set([
"pending_confirmation", "agreed", "driver_assigned", "loaded", "on_route",
"delivered", "picked_up", "pickup", "requires_address", "problem",
"cancelled", "paid_storage", "address_required", "manual_confirmation_required",
]);
export const getOrderGroupDisplayStatusLabel = (group) => {
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
const statusCol = group?.status;
const notificationStatus = group?.notificationStatus || group?.notification_status;
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
// When auto-SMS failed and logistics hasn't taken action yet → show as a todo item
const isManualRequired = notificationStatus === "manual_required";
@ -154,6 +162,12 @@ export const getOrderGroupDisplayStatusLabel = (group) => {
return "Требуется ручное управление";
}
// Primary: status column (now synced with delivery_status)
if (statusCol && DELIVERY_STATUS_VALUES.has(statusCol) && statusCol !== "pending_confirmation" && statusCol !== "manual_confirmation_required") {
return getOrderGroupDeliveryStatusLabel(statusCol);
}
// Fallback: delivery_status (for pending/manual_confirmation groups)
if (deliveryStatus && deliveryStatus !== "pending_confirmation" && deliveryStatus !== "manual_confirmation_required") {
return getOrderGroupDeliveryStatusLabel(deliveryStatus);
}
@ -163,12 +177,13 @@ export const getOrderGroupDisplayStatusLabel = (group) => {
return notificationLabel;
}
return getOrderGroupStatusLabel(group?.status);
return getOrderGroupStatusLabel(statusCol);
};
export const getOrderGroupDisplayStatusValue = (group) => {
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
const statusCol = group?.status;
const notificationStatus = group?.notificationStatus || group?.notification_status;
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
// Unify manual_required into a single bucket regardless of delivery_status detail
const isManualRequired = notificationStatus === "manual_required";
@ -177,11 +192,17 @@ export const getOrderGroupDisplayStatusValue = (group) => {
return "status:manual_required";
}
// Primary: status column (now synced with delivery_status)
if (statusCol && DELIVERY_STATUS_VALUES.has(statusCol) && statusCol !== "pending_confirmation" && statusCol !== "manual_confirmation_required") {
return `delivery:${statusCol}`;
}
// Fallback: delivery_status
if (deliveryStatus && deliveryStatus !== "pending_confirmation" && deliveryStatus !== "manual_confirmation_required") {
return `delivery:${deliveryStatus}`;
}
return `status:${group?.status || "unknown"}`;
return `status:${statusCol || "unknown"}`;
};
export const isOrderGroupVisibleToDriver = (group) => {
@ -483,10 +504,16 @@ export const buildOrderGroupBuckets = (groups) => {
export const getOrderGroupStatusTone = (group) => {
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
const statusCol = group?.status;
// Highlight groups with delivery problems
if (group?.hasDeliveryProblem) return "warning";
// Priority: if status column already holds a delivery-level value, use it
if (statusCol && DELIVERY_STATUS_VALUES.has(statusCol) && statusCol !== "pending_confirmation" && statusCol !== "manual_confirmation_required") {
return getOrderGroupDeliveryStatusTone(statusCol);
}
if (deliveryStatus && deliveryStatus !== "pending_confirmation") {
return getOrderGroupDeliveryStatusTone(deliveryStatus);
}

View File

@ -142,12 +142,23 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
const extractCity = (addr) => {
if (!addr) return "";
// 1) explicit marker: г. Ялта, пгт. Куйбышево, etc.
const m = addr.match(/(?:г\.\s|гор\.\s|пос\.\s|с\.\s|дер\.\s|пгт\.\s|город\s|село\s|г\s)\s*([А-ЯЁа-яёA-Za-z\-\s]+?)(?:[,\\s]|$)/i);
if (m) return m[1].trim();
// 2) known city name anywhere in address (case-insensitive)
// Word markers (город/село) require space after to avoid matching "Стройгородок"
// Dot markers (г./гор./etc) allow zero space (г.Ялта)
const m = addr.match(/(?:г\.\s*|гор\.\s*|пос\.\s*|с\.\s*|дер\.\s*|пгт\.\s*|город\s+|село\s+|г\s+)\s*([А-ЯЁа-яёA-Za-z\-\s]+?)(?:[,\s]|$)/i);
if (m) {
const candidate = m[1].trim();
for (const city of CRIMEAN_CITIES) {
if (city.toLowerCase() === candidate.toLowerCase()) return city;
}
// Not a known city — continue to step 2
}
// 2) known city name via custom word boundary (JS \b doesn't work with Cyrillic)
// City must be preceded by start/comma/space/dot and followed by comma/space/end
const lower = addr.toLowerCase();
for (const city of CRIMEAN_CITIES) {
if (lower.includes(city.toLowerCase())) return city;
const esc = city.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp("(^|[,.\\s])" + esc + "(?=[,\\s]|$)", "i");
if (re.test(lower)) return city;
}
// 3) Бахчисарайский р-н → Бахчисарай
const district = addr.match(/([А-ЯЁа-яё]+)ский\s*(?:р-н|район)/i);
@ -460,15 +471,22 @@ export const updateDeliveryStatus = async ({ orderGroupId, status, details, ship
return safeSupabaseCall(async () => {
const client = requireSupabase();
// Fetch current status before any update (needed for audit log)
// Fetch current status before any update (needed for audit log + status sync)
const { data: current, error: fetchCurrentError } = await client
.from("order_groups")
.select("delivery_status")
.select("delivery_status, delivery_type")
.eq("id", orderGroupId)
.single();
if (fetchCurrentError) throw fetchCurrentError;
// Compute status column: pickup+picked_up → picked_up, delivery+picked_up → delivered
const deliveryType = current.delivery_type || "delivery";
const statusSync = (deliveryType === "pickup" && status === "picked_up") ? "picked_up"
: (deliveryType === "delivery" && status === "picked_up") ? "delivered"
: (status === "delivered") ? "delivered"
: status;
// Bypass stale RPC for paid_storage transitions
// Server-side RPC still enforces driver-assignment checks that block
// manager/logistician from moving groups into/out of paid_storage.
@ -479,6 +497,7 @@ export const updateDeliveryStatus = async ({ orderGroupId, status, details, ship
.from("order_groups")
.update({
delivery_status: status,
status: statusSync,
paid_storage_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
@ -490,6 +509,7 @@ export const updateDeliveryStatus = async ({ orderGroupId, status, details, ship
.from("order_groups")
.update({
delivery_status: status,
status: statusSync,
paid_storage_at: null,
updated_at: new Date().toISOString(),
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

54
webhook-deploy.py Executable file
View File

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