fix: extractCity word boundary + filter persistence in sessionStorage
This commit is contained in:
parent
8ade6a6ff3
commit
1d288279c0
|
|
@ -1,8 +1,8 @@
|
||||||
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
|
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
|
||||||
|
|
||||||
if (!isLocalhost) {
|
if (!isLocalhost) {
|
||||||
const STATIC_CACHE = "construction-delivery-static-v57";
|
const STATIC_CACHE = "construction-delivery-static-v58";
|
||||||
const RUNTIME_CACHE = "construction-delivery-runtime-v57";
|
const RUNTIME_CACHE = "construction-delivery-runtime-v58";
|
||||||
const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
|
const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Supersam full DB backup (pg_dump) to S3 (Beget Cloud Storage)
|
||||||
|
# Dumps the complete supabase postgres database and uploads to S3.
|
||||||
|
# Retention: keeps last 30 days.
|
||||||
|
#
|
||||||
|
# Cron: 0 */12 * * * /opt/supersam/scripts/backup-pgdump-s3.sh >> /var/log/supersam-backup-pgdump.log 2>&1
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── Config ────────────────────────────────────────────────────────────────
|
||||||
|
AWS_ACCESS_KEY_ID="YG4MQNKAPNL65200MBUY"
|
||||||
|
AWS_SECRET_ACCESS_KEY="8mXkFM2VRQ3pN1Nx4mhmJ2jrZoB5YTPUa4CaZh43"
|
||||||
|
S3_ENDPOINT="https://s3.ru1.storage.beget.cloud"
|
||||||
|
S3_BUCKET="02f162ff4a18-supersam-s3"
|
||||||
|
S3_PREFIX="backups"
|
||||||
|
|
||||||
|
DB_CONTAINER="supabase-db"
|
||||||
|
DB_USER="supabase_admin"
|
||||||
|
DB_NAME="postgres"
|
||||||
|
DB_PASS="4fe80bb21c7c3d17a8d8b226adf7a479"
|
||||||
|
|
||||||
|
RETENTION_DAYS=30
|
||||||
|
|
||||||
|
# ── Runtime ───────────────────────────────────────────────────────────────
|
||||||
|
TIMESTAMP=$(date +%Y-%m-%d_%H%M)
|
||||||
|
DUMP_FILE="/tmp/supersam-dump-${TIMESTAMP}.sql.gz"
|
||||||
|
S3_PATH="s3://${S3_BUCKET}/${S3_PREFIX}/pgdump/supersam-db-${TIMESTAMP}.sql.gz"
|
||||||
|
|
||||||
|
export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY
|
||||||
|
|
||||||
|
echo "[$(date)] Starting pg_dump backup..."
|
||||||
|
|
||||||
|
# ── Dump ──────────────────────────────────────────────────────────────────
|
||||||
|
echo "[$(date)] Dumping database..."
|
||||||
|
docker exec -e PGPASSWORD="${DB_PASS}" "${DB_CONTAINER}" \
|
||||||
|
pg_dump -U "${DB_USER}" -d "${DB_NAME}" --no-owner --no-acl --clean --if-exists \
|
||||||
|
| gzip -9 > "${DUMP_FILE}"
|
||||||
|
|
||||||
|
DUMP_SIZE=$(du -h "${DUMP_FILE}" | cut -f1)
|
||||||
|
echo "[$(date)] Dump created: ${DUMP_FILE} (${DUMP_SIZE})"
|
||||||
|
|
||||||
|
# ── Upload via mc ─────────────────────────────────────────────────────────
|
||||||
|
echo "[$(date)] Uploading to S3..."
|
||||||
|
mc cp "${DUMP_FILE}" "beget/${S3_BUCKET}/${S3_PREFIX}/pgdump/supersam-db-${TIMESTAMP}.sql.gz" --quiet 2>&1
|
||||||
|
|
||||||
|
echo "[$(date)] Uploaded to ${S3_PATH}"
|
||||||
|
|
||||||
|
# ── Cleanup local ─────────────────────────────────────────────────────────
|
||||||
|
rm -f "${DUMP_FILE}"
|
||||||
|
echo "[$(date)] Local cleanup done"
|
||||||
|
|
||||||
|
# ── Retention: delete S3 pgdump backups older than RETENTION_DAYS ─────────
|
||||||
|
echo "[$(date)] Cleaning S3 pgdump backups older than ${RETENTION_DAYS} days..."
|
||||||
|
CUTOFF=$(date -d "-${RETENTION_DAYS} days" +%Y-%m-%d)
|
||||||
|
mc ls "beget/${S3_BUCKET}/${S3_PREFIX}/pgdump/" 2>/dev/null \
|
||||||
|
| awk '{print $NF}' \
|
||||||
|
| while read -r fname; do
|
||||||
|
file_date=$(echo "$fname" | grep -oP '\d{4}-\d{2}-\d{2}' || echo "")
|
||||||
|
if [ -n "$file_date" ] && [ "$file_date" \< "$CUTOFF" ]; then
|
||||||
|
mc rm "beget/${S3_BUCKET}/${S3_PREFIX}/pgdump/${fname}" --quiet 2>/dev/null
|
||||||
|
echo "[$(date)] Deleted old backup: ${fname}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[$(date)] Backup complete."
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Resolve supabase-db container IP dynamically
|
||||||
|
DB_IP=$(docker inspect supabase-db --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' 2>/dev/null)
|
||||||
|
if [ -z "$DB_IP" ]; then
|
||||||
|
echo "ERROR: Cannot resolve supabase-db IP" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
export DB_HOST=$DB_IP
|
||||||
|
export DB_PORT=5432
|
||||||
|
export DB_NAME=postgres
|
||||||
|
export DB_USER=supabase_admin
|
||||||
|
export DB_PASS=4fe80bb21c7c3d17a8d8b226adf7a479
|
||||||
|
exec python3 "$@"
|
||||||
|
|
@ -0,0 +1,207 @@
|
||||||
|
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="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>
|
||||||
|
<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>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const FontSettingsContext = createContext(null);
|
||||||
|
|
||||||
|
const STORAGE_KEY = "supersam-font-settings";
|
||||||
|
|
||||||
|
// Categories: key → { label, defaultScale, min, max, step, description }
|
||||||
|
export const FONT_CATEGORIES = [
|
||||||
|
{ key: "table", label: "Таблицы", description: "Текст в таблицах и списках", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
|
||||||
|
{ key: "card", label: "Карточки", description: "Текст в карточках и панелях", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
|
||||||
|
{ key: "nav", label: "Меню и навигация", description: "Пункты меню, вкладки, кнопки", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
|
||||||
|
{ key: "heading", label: "Заголовки", description: "Названия секций и страниц", defaultScale: 1.0, min: 0.8, max: 1.6, step: 0.05 },
|
||||||
|
{ key: "body", label: "Основной текст", description: "Обычный текст в интерфейсе", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
|
||||||
|
{ key: "small", label: "Мелкий текст", description: "Подписи, метки, временные отметки", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS = FONT_CATEGORIES.reduce((acc, cat) => {
|
||||||
|
acc[cat.key] = cat.defaultScale;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
function loadSettings() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return { ...DEFAULT_SETTINGS };
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
// Merge with defaults to handle new categories
|
||||||
|
return { ...DEFAULT_SETTINGS, ...parsed };
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_SETTINGS };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySettings(settings) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
for (const cat of FONT_CATEGORIES) {
|
||||||
|
root.style.setProperty(`--fs-scale-${cat.key}`, String(settings[cat.key] ?? 1.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FontSettingsProvider = ({ children }) => {
|
||||||
|
const [settings, setSettings] = useState(loadSettings);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applySettings(settings);
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
const updateCategory = (key, scale) => {
|
||||||
|
setSettings((prev) => ({ ...prev, [key]: scale }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetAll = () => {
|
||||||
|
setSettings({ ...DEFAULT_SETTINGS });
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
settings,
|
||||||
|
updateCategory,
|
||||||
|
resetAll,
|
||||||
|
categories: FONT_CATEGORIES,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <FontSettingsContext.Provider value={value}>{children}</FontSettingsContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useFontSettings = () => {
|
||||||
|
const context = useContext(FontSettingsContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useFontSettings must be used within FontSettingsProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,389 @@
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import {
|
||||||
|
filterOrderGroups,
|
||||||
|
getOrderGroupDisplayStatusLabel,
|
||||||
|
getOrderGroupDisplayStatusValue,
|
||||||
|
getOrderGroupStatusTone,
|
||||||
|
ORDER_GROUP_DISPLAY_STATUS_OPTIONS,
|
||||||
|
} from "../../services/orderGroupViews";
|
||||||
|
import { Badge } from "../UI/Badge";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
|
import { SkeletonPage } from "../UI/Loading";
|
||||||
|
import { OrderFilters } from "../orders/OrderFilters";
|
||||||
|
import { formatDate, formatDateTime } from "../../utils/formatters";
|
||||||
|
|
||||||
|
const fmtDate = (d) => {
|
||||||
|
if (!d) return "";
|
||||||
|
const [y, m, day] = d.split("-");
|
||||||
|
if (!y || !m || !day) return d;
|
||||||
|
return `${day}.${m}.${y}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default priority: agreed first, manual_required second, then funnel
|
||||||
|
const DEFAULT_FUNNEL_ORDER = [
|
||||||
|
"delivery:agreed",
|
||||||
|
"status:manual_required",
|
||||||
|
"status:ready_for_notification",
|
||||||
|
"delivery:pending_confirmation",
|
||||||
|
"status:first_sms_sent",
|
||||||
|
"status:second_sms_sent",
|
||||||
|
"delivery:driver_assigned",
|
||||||
|
"delivery:loaded",
|
||||||
|
"delivery:on_route",
|
||||||
|
"delivery:delivered",
|
||||||
|
"delivery:picked_up",
|
||||||
|
"delivery:paid_storage",
|
||||||
|
"delivery:problem",
|
||||||
|
"delivery:cancelled",
|
||||||
|
];
|
||||||
|
|
||||||
|
const STORAGE_KEY = "logistics-section-order";
|
||||||
|
const COLLAPSED_KEY = "logistics-section-collapsed";
|
||||||
|
|
||||||
|
// Load custom order from localStorage, merge with defaults
|
||||||
|
const loadCustomOrder = () => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? parsed : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveCustomOrder = (order) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(order));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load collapsed sections from localStorage
|
||||||
|
const loadCollapsedSections = () => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(COLLAPSED_KEY);
|
||||||
|
if (!raw) return new Set();
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return new Set(Array.isArray(parsed) ? parsed : []);
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveCollapsedSections = (collapsedSet) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(COLLAPSED_KEY, JSON.stringify([...collapsedSet]));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 7 columns: Клиент | Город | Тип | Дата | Водитель | Статус | Обновлён
|
||||||
|
const COLS = "grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(100px,0.8fr)_minmax(100px,1fr)_minmax(90px,1fr)_minmax(100px,1fr)_minmax(90px,0.8fr)]";
|
||||||
|
const MIN_W = "min-w-[1080px]";
|
||||||
|
|
||||||
|
const TableHeader = () => (
|
||||||
|
<div className={`grid ${COLS} gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.12em] text-[var(--color-text-muted)]`}>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Клиент</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Город</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Тип</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Дата доставки</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Водитель</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Статус</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Обновлён</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderRow = (group, onSelectSet) => (
|
||||||
|
<button
|
||||||
|
key={group.id}
|
||||||
|
type="button"
|
||||||
|
className={`grid ${COLS} gap-0 w-full border-t border-[var(--color-border)] text-left transition hover:bg-[var(--color-accent-soft)]`}
|
||||||
|
onClick={() => { if (onSelectSet) onSelectSet(group.id); }}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 px-3 py-1.5">
|
||||||
|
<div className="text-xs font-medium leading-snug break-words" style={{ display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden" }}>
|
||||||
|
{group.displayTitle || group.customerName || group.groupKey}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-[var(--color-text-muted)]">
|
||||||
|
{group.customerPhone || ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
|
||||||
|
{group.city || group.customerAddress || "—"}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-xs">
|
||||||
|
<span className="inline-flex items-center gap-1 whitespace-nowrap">
|
||||||
|
{group.deliveryType === "pickup" ? "🏪" : "🚚"}
|
||||||
|
<span className="text-[var(--color-text-muted)]">{group.deliveryType === "pickup" ? "Самовывоз" : "Доставка"}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-xs">
|
||||||
|
{group.deliveryDate ? (
|
||||||
|
<span>{fmtDate(group.deliveryDate)}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[var(--color-text-muted)]">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-xs">
|
||||||
|
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]">—</span>}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5">
|
||||||
|
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
|
||||||
|
{formatDateTime(group.updatedAt)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sortable section wrapper
|
||||||
|
const SortableSection = ({ statusValue, label, groups, isCollapsed, onToggle, onSelectSet }) => {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: statusValue });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className="rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Section header — drag handle + collapse toggle */}
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
{/* Drag handle */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center px-3 py-3 cursor-grab active:cursor-grabbing text-[var(--color-text)] hover:bg-[var(--color-accent-soft)] rounded-l-[28px] touch-none"
|
||||||
|
title="Перетащите для изменения порядка"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24" style={{ opacity: 0.6 }}>
|
||||||
|
<circle cx="9" cy="5" r="1.8" />
|
||||||
|
<circle cx="15" cy="5" r="1.8" />
|
||||||
|
<circle cx="9" cy="12" r="1.8" />
|
||||||
|
<circle cx="15" cy="12" r="1.8" />
|
||||||
|
<circle cx="9" cy="19" r="1.8" />
|
||||||
|
<circle cx="15" cy="19" r="1.8" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Collapse toggle */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex flex-1 items-center justify-between py-3 pr-5 text-left transition hover:bg-[var(--color-surface-strong)]"
|
||||||
|
onClick={onToggle}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-sm font-semibold">{label}</h3>
|
||||||
|
<Badge tone={groups.length > 0 ? "neutral" : "muted"}>{groups.length}</Badge>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4 text-[var(--color-text-muted)] transition-transform"
|
||||||
|
style={{ transform: isCollapsed ? "rotate(-90deg)" : "rotate(0deg)" }}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isCollapsed && (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className={MIN_W}>
|
||||||
|
<TableHeader />
|
||||||
|
{groups.map((g) => renderRow(g, onSelectSet))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS, isLoading = false }) => {
|
||||||
|
const [filters, setFilters] = React.useState({ query: "", displayStatus: "all", city: "" });
|
||||||
|
const [collapsedSections, setCollapsedSections] = React.useState(() => loadCollapsedSections());
|
||||||
|
const [sectionOrder, setSectionOrder] = React.useState(() => {
|
||||||
|
const custom = loadCustomOrder();
|
||||||
|
return custom || [...DEFAULT_FUNNEL_ORDER];
|
||||||
|
});
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const cities = React.useMemo(() => {
|
||||||
|
const set = new Set();
|
||||||
|
for (const g of orderGroups) {
|
||||||
|
if (g.city) set.add(g.city);
|
||||||
|
}
|
||||||
|
return [...set].sort();
|
||||||
|
}, [orderGroups]);
|
||||||
|
|
||||||
|
const filteredGroups = React.useMemo(
|
||||||
|
() => filterOrderGroups(orderGroups, filters),
|
||||||
|
[filters, orderGroups],
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusGroups = React.useMemo(() => {
|
||||||
|
const map = new Map();
|
||||||
|
for (const group of filteredGroups) {
|
||||||
|
const statusValue = getOrderGroupDisplayStatusValue(group);
|
||||||
|
if (!map.has(statusValue)) {
|
||||||
|
const label = getOrderGroupDisplayStatusLabel(group);
|
||||||
|
map.set(statusValue, { label, groups: [] });
|
||||||
|
}
|
||||||
|
map.get(statusValue).groups.push(group);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [filteredGroups]);
|
||||||
|
|
||||||
|
const totalGroups = filteredGroups.length;
|
||||||
|
|
||||||
|
// Build sorted list: use sectionOrder for known statuses, append unknown ones at end
|
||||||
|
const sortedEntries = React.useMemo(() => {
|
||||||
|
const present = new Set(statusGroups.keys());
|
||||||
|
const result = [];
|
||||||
|
|
||||||
|
// First: statuses in custom order that are present
|
||||||
|
for (const statusValue of sectionOrder) {
|
||||||
|
if (present.has(statusValue)) {
|
||||||
|
const data = statusGroups.get(statusValue);
|
||||||
|
result.push([statusValue, data]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then: any statuses not in sectionOrder (new statuses), sorted alphabetically
|
||||||
|
for (const [statusValue, data] of statusGroups.entries()) {
|
||||||
|
if (!sectionOrder.includes(statusValue)) {
|
||||||
|
result.push([statusValue, data]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [statusGroups, sectionOrder]);
|
||||||
|
|
||||||
|
const handleDragEnd = (event) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
|
||||||
|
setSectionOrder((prevOrder) => {
|
||||||
|
// Build the full order including any new statuses
|
||||||
|
const allIds = sortedEntries.map(([id]) => id);
|
||||||
|
const oldIndex = allIds.indexOf(active.id);
|
||||||
|
const newIndex = allIds.indexOf(over.id);
|
||||||
|
if (oldIndex === -1 || newIndex === -1) return prevOrder;
|
||||||
|
|
||||||
|
const newAllOrder = arrayMove(allIds, oldIndex, newIndex);
|
||||||
|
|
||||||
|
// Merge: replace positions of known statuses, keep unknown at end
|
||||||
|
// Save the full new order so it persists
|
||||||
|
saveCustomOrder(newAllOrder);
|
||||||
|
return newAllOrder;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <SkeletonPage panels={3} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 fs-zone-table">
|
||||||
|
<Panel className="space-y-4 p-5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="text-lg font-semibold">Наборы доставки</h2>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)] mt-0.5">
|
||||||
|
Перетаскивайте секции за ручку слева, чтобы изменить порядок отображения.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge tone="neutral">{totalGroups} групп</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OrderFilters
|
||||||
|
filters={filters}
|
||||||
|
setFilters={setFilters}
|
||||||
|
statusOptions={statusOptions}
|
||||||
|
cities={cities}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{!totalGroups ? (
|
||||||
|
<div className="rounded-[28px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
|
||||||
|
По этому поиску ничего не найдено.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={sortedEntries.map(([id]) => id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{sortedEntries.map(([statusValue, { label, groups }]) => {
|
||||||
|
const isCollapsed = collapsedSections.has(statusValue);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SortableSection
|
||||||
|
key={statusValue}
|
||||||
|
statusValue={statusValue}
|
||||||
|
label={label}
|
||||||
|
groups={groups}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
onToggle={() => {
|
||||||
|
setCollapsedSections((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(statusValue)) {
|
||||||
|
next.delete(statusValue);
|
||||||
|
} else {
|
||||||
|
next.add(statusValue);
|
||||||
|
}
|
||||||
|
saveCollapsedSections(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onSelectSet={onSelectSet}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,257 @@
|
||||||
|
import { formatDateTime } from "../../utils/formatters";
|
||||||
|
import { Badge } from "../UI/Badge";
|
||||||
|
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
|
import { SkeletonTable } from "../UI/Loading";
|
||||||
|
import { OrderFilters } from "./OrderFilters";
|
||||||
|
import {
|
||||||
|
getOrderGroupDisplayStatusLabel,
|
||||||
|
getOrderGroupStatusTone,
|
||||||
|
} from "../../services/orderGroupViews";
|
||||||
|
|
||||||
|
const MAX_VISIBLE_INVOICES = 2;
|
||||||
|
|
||||||
|
const fmtDate = (d) => {
|
||||||
|
if (!d) return '';
|
||||||
|
const [y, m, day] = d.split('-');
|
||||||
|
if (!y || !m || !day) return d;
|
||||||
|
return `${day}.${m}.${y}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getShipmentIssues = (group) => {
|
||||||
|
const data = group?.driverShipmentData;
|
||||||
|
if (!Array.isArray(data) || data.length === 0) return null;
|
||||||
|
const unshipped = data.filter((i) => !i.shipped);
|
||||||
|
if (unshipped.length === 0) return null;
|
||||||
|
return unshipped;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildGroupSummary = (group) => {
|
||||||
|
const orderCountLabel = `${group.ordersCount || 0} ${group.ordersCount === 1 ? "заказ" : group.ordersCount < 5 ? "заказа" : "заказов"}`;
|
||||||
|
const parts = [orderCountLabel];
|
||||||
|
if (group.deliveryDate) {
|
||||||
|
const datePart = group.deliveryTime ? `${fmtDate(group.deliveryDate)} · ${group.deliveryTime}` : fmtDate(group.deliveryDate);
|
||||||
|
parts.push(datePart);
|
||||||
|
}
|
||||||
|
if (group.assignedDriverName) {
|
||||||
|
parts.push(group.assignedDriverName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(" · ");
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderOrderNumbers = (group) => {
|
||||||
|
const numbers = group.allBillNumbers || group.orderNumbers;
|
||||||
|
if (!Array.isArray(numbers) || !numbers.length) {
|
||||||
|
return "Номера не указаны";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (numbers.length <= MAX_VISIBLE_INVOICES) {
|
||||||
|
return numbers.join(", ");
|
||||||
|
}
|
||||||
|
const visible = numbers.slice(0, MAX_VISIBLE_INVOICES);
|
||||||
|
const remaining = numbers.length - MAX_VISIBLE_INVOICES;
|
||||||
|
return `${visible.join(", ")} +${remaining}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDeliveryTypeLabel = (deliveryType) => {
|
||||||
|
if (deliveryType === "pickup") return "Самовывоз";
|
||||||
|
if (deliveryType === "delivery") return "Доставка";
|
||||||
|
return "—";
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMobileOrderNumbers = (group) => {
|
||||||
|
const numbers = group.allBillNumbers || group.orderNumbers;
|
||||||
|
if (!Array.isArray(numbers) || !numbers.length) {
|
||||||
|
return "Номера не указаны";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (numbers.length <= MAX_VISIBLE_INVOICES) {
|
||||||
|
return numbers.join(", ");
|
||||||
|
}
|
||||||
|
const visible = numbers.slice(0, MAX_VISIBLE_INVOICES);
|
||||||
|
const remaining = numbers.length - MAX_VISIBLE_INVOICES;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{visible.join(", ")}
|
||||||
|
<span className="ml-1 rounded-full bg-[var(--color-accent-soft)] px-1.5 py-0.5 text-xs font-medium text-[var(--color-accent)]">+{remaining}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OrdersTable = ({
|
||||||
|
orderGroups = [],
|
||||||
|
selectedOrderGroupId,
|
||||||
|
onOpenOrder,
|
||||||
|
filters,
|
||||||
|
setFilters,
|
||||||
|
statusOptions,
|
||||||
|
cities = [],
|
||||||
|
isLoading = false,
|
||||||
|
}) => {
|
||||||
|
if (isLoading) {
|
||||||
|
return <SkeletonTable rows={5} cols={5} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel className="p-0 fs-zone-table">
|
||||||
|
<div className="space-y-4 border-b border-[var(--color-border)] px-5 py-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Группы доставки</h2>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
|
Поиск по группе, клиенту, телефону и дате доставки.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge tone="neutral">{orderGroups.length}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filters && setFilters ? (
|
||||||
|
<OrderFilters filters={filters} setFilters={setFilters} statusOptions={statusOptions} cities={cities} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 p-4 md:hidden">
|
||||||
|
{!orderGroups.length ? (
|
||||||
|
<div className="rounded-[28px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
|
||||||
|
Группы не найдены. Попробуйте изменить поиск или статус.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{orderGroups.map((group) => {
|
||||||
|
const hasProblem = group.hasDeliveryProblem;
|
||||||
|
const baseClass = "w-full rounded-[22px] border text-left transition";
|
||||||
|
const selectedClass = selectedOrderGroupId === group.id
|
||||||
|
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"
|
||||||
|
: hasProblem
|
||||||
|
? "border-[var(--color-danger)] bg-[rgba(201,61,61,0.1)]"
|
||||||
|
: "border-[var(--color-border)] bg-[var(--color-surface-strong)]";
|
||||||
|
|
||||||
|
const allNumbers = group.allBillNumbers || group.orderNumbers || [];
|
||||||
|
const primaryBill = allNumbers[0] || "—";
|
||||||
|
const totalCount = allNumbers.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={group.id}>
|
||||||
|
<div className="mb-1 flex items-center gap-2 px-1 text-xs">
|
||||||
|
<span className="font-medium text-[var(--color-text)]">№ {primaryBill}</span>
|
||||||
|
{totalCount > 1 && (
|
||||||
|
<span className="rounded-full bg-[var(--color-accent-soft)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent)]">
|
||||||
|
{totalCount} сч.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenOrder(group.id)}
|
||||||
|
className={[baseClass, selectedClass, "flex flex-col items-start justify-start p-4"].join(" ")}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 w-full font-medium leading-snug break-words">
|
||||||
|
{group.displayTitle || group.customerName || group.groupKey}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold ${group.deliveryType === "pickup" ? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]" : "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]"}`}>
|
||||||
|
{group.deliveryType === "pickup" ? "🏪 Самовывоз" : "🚚 Доставка"}
|
||||||
|
</span>
|
||||||
|
<Badge tone={getOrderGroupStatusTone(group)}>
|
||||||
|
{getOrderGroupDisplayStatusLabel(group)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{group.hasDeliveryProblem && (
|
||||||
|
<div className="mt-2 rounded-lg border border-[var(--color-warning)] bg-[var(--color-warning-soft)] px-2 py-1 text-xs">
|
||||||
|
<span className="font-medium text-[var(--color-warning)]">⚠ Проблема с доставкой</span>
|
||||||
|
{group.deliveryProblemNote && (
|
||||||
|
<div className="mt-0.5 text-[var(--color-text-muted)]">{group.deliveryProblemNote}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden md:block">
|
||||||
|
{!orderGroups.length ? (
|
||||||
|
<div className="px-5 py-6 text-sm text-[var(--color-text-muted)]">
|
||||||
|
Группы не найдены. Попробуйте изменить поиск или статус.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="min-w-[1080px]">
|
||||||
|
<div className="grid grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(100px,0.8fr)_minmax(100px,1fr)_minmax(100px,1fr)_minmax(100px,0.8fr)_minmax(90px,0.8fr)] gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.12em] text-[var(--color-text-muted)]">
|
||||||
|
<div className="px-3 py-1.5 font-medium">Группа / Клиент</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Счета</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Город</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Статус</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Дата доставки</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Тип</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Водитель</div>
|
||||||
|
</div>
|
||||||
|
{orderGroups.map((group) => {
|
||||||
|
const hasProblem = group.hasDeliveryProblem;
|
||||||
|
const rowClassName = `grid grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(100px,0.8fr)_minmax(100px,1fr)_minmax(100px,1fr)_minmax(100px,0.8fr)_minmax(90px,0.8fr)] gap-0 w-full border-t border-[var(--color-border)] text-left transition ${
|
||||||
|
hasProblem
|
||||||
|
? "bg-[rgba(201,61,61,0.1)] hover:bg-[rgba(201,61,61,0.15)]"
|
||||||
|
: "hover:bg-[var(--color-accent-soft)]"
|
||||||
|
} ${selectedOrderGroupId === group.id ? "bg-[var(--color-accent-soft)]" : ""}`;
|
||||||
|
|
||||||
|
const billNumbers = group.allBillNumbers || group.orderNumbers || [];
|
||||||
|
const primaryBill = billNumbers[0] || "—";
|
||||||
|
const totalBills = billNumbers.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={group.id}
|
||||||
|
type="button"
|
||||||
|
className={rowClassName}
|
||||||
|
onClick={() => onOpenOrder(group.id)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 px-3 py-1.5">
|
||||||
|
<div className="text-xs font-medium leading-snug break-words" style={{display:"-webkit-box",WebkitLineClamp:2,WebkitBoxOrient:"vertical",overflow:"hidden"}}>{group.displayTitle || group.customerName || group.groupKey}</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-[var(--color-text-muted)]">
|
||||||
|
{group.customerPhone || ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5">
|
||||||
|
<div className="text-xs text-[var(--color-text)]">{primaryBill}</div>
|
||||||
|
{totalBills > 1 && (
|
||||||
|
<span className="inline-block mt-0.5 rounded-full bg-[var(--color-accent-soft)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent)]">
|
||||||
|
{totalBills} сч.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
|
||||||
|
{group.city || "—"}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5">
|
||||||
|
<Badge tone={getOrderGroupStatusTone(group)}>
|
||||||
|
{getOrderGroupDisplayStatusLabel(group)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-xs">
|
||||||
|
{group.deliveryDate ? (
|
||||||
|
<span>{fmtDate(group.deliveryDate)}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[var(--color-text-muted)]">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-xs">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{group.deliveryType === "pickup" ? "🏪" : "🚚"}
|
||||||
|
<span className="text-[var(--color-text-muted)]">{group.deliveryType === "pickup" ? "Самовывоз" : "Доставка"}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-xs">
|
||||||
|
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]">—</span>}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,186 @@
|
||||||
|
/**
|
||||||
|
* @file SettingsPage.jsx
|
||||||
|
* @description Page for user settings: font size controls for all UI categories.
|
||||||
|
* Settings persist to localStorage via FontSettingsContext.
|
||||||
|
*/
|
||||||
|
import React from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useFontSettings, FONT_CATEGORIES } from "../context/FontSettingsContext";
|
||||||
|
import { Panel } from "../components/UI/Panel";
|
||||||
|
import { Button } from "../components/UI/Button";
|
||||||
|
|
||||||
|
const PRESETS = [
|
||||||
|
{ label: "Мелкий", scales: { table: 0.85, card: 0.85, nav: 0.85, heading: 0.85, body: 0.85, small: 0.85 } },
|
||||||
|
{ label: "Стандарт", scales: { table: 1.0, card: 1.0, nav: 1.0, heading: 1.0, body: 1.0, small: 1.0 } },
|
||||||
|
{ label: "Крупный", scales: { table: 1.15, card: 1.15, nav: 1.15, heading: 1.2, body: 1.15, small: 1.1 } },
|
||||||
|
{ label: "Очень крупный", scales: { table: 1.3, card: 1.3, nav: 1.25, heading: 1.4, body: 1.3, small: 1.2 } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const Slider = ({ category, value, onChange }) => {
|
||||||
|
const pct = ((value - category.min) / (category.max - category.min)) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-[var(--color-text)]">{category.label}</span>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">{category.description}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-semibold tabular-nums text-[var(--color-accent)]">
|
||||||
|
{Math.round(value * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={category.min}
|
||||||
|
max={category.max}
|
||||||
|
step={category.step}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(parseFloat(e.target.value))}
|
||||||
|
className="fs-slider flex-1"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(to right, var(--color-accent) 0%, var(--color-accent) ${pct}%, var(--color-border) ${pct}%, var(--color-border) 100%)`,
|
||||||
|
}}
|
||||||
|
aria-label={category.label}
|
||||||
|
/>
|
||||||
|
<span className="text-xs tabular-nums text-[var(--color-text-muted)] w-16 text-right">
|
||||||
|
{value.toFixed(2)}×
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SettingsPage = () => {
|
||||||
|
const { settings, updateCategory, resetAll, categories } = useFontSettings();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const applyPreset = (scales) => {
|
||||||
|
for (const [key, scale] of Object.entries(scales)) {
|
||||||
|
updateCategory(key, scale);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if current settings match a preset
|
||||||
|
const activePreset = PRESETS.findIndex((p) =>
|
||||||
|
Object.entries(p.scales).every(([k, v]) => Math.abs((settings[k] ?? 1.0) - v) < 0.001)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-6 px-3 py-4 sm:px-4 md:px-6 md:py-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Настройки</p>
|
||||||
|
<h1 className="mt-1 text-2xl font-bold leading-tight" style={{ fontSize: `calc(1.5rem * var(--fs-scale-heading, 1))` }}>
|
||||||
|
Настройки интерфейса
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" onClick={() => navigate(-1)}>
|
||||||
|
← Назад
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Presets */}
|
||||||
|
<Panel className="p-5 space-y-3">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||||
|
Быстрые пресеты
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
{PRESETS.map((preset, i) => (
|
||||||
|
<button
|
||||||
|
key={preset.label}
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyPreset(preset.scales)}
|
||||||
|
className={[
|
||||||
|
"rounded-[18px] border-2 px-4 py-3 text-center transition",
|
||||||
|
activePreset === i
|
||||||
|
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)] font-semibold"
|
||||||
|
: "border-[var(--color-border)] bg-[var(--color-surface-strong)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-text)]",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium">{preset.label}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* Font size sliders */}
|
||||||
|
<Panel className="p-5 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||||
|
Размеры шрифтов
|
||||||
|
</h2>
|
||||||
|
<Button size="sm" variant="ghost" onClick={resetAll}>
|
||||||
|
Сбросить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<Slider
|
||||||
|
key={cat.key}
|
||||||
|
category={cat}
|
||||||
|
value={settings[cat.key] ?? cat.defaultScale}
|
||||||
|
onChange={(v) => updateCategory(cat.key, v)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<div className="rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 space-y-2">
|
||||||
|
<p className="text-xs uppercase tracking-wider text-[var(--color-text-muted)]">Предпросмотр</p>
|
||||||
|
<div className="fs-zone-heading space-y-1">
|
||||||
|
<h3 className="font-bold" style={{ fontSize: `calc(1.25rem * var(--fs-scale-heading, 1))` }}>
|
||||||
|
Заголовок секции
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="fs-zone-nav flex gap-2">
|
||||||
|
<span className="rounded-[14px] bg-[var(--color-accent)] px-3 py-1.5 text-[var(--color-accent-contrast)]"
|
||||||
|
style={{ fontSize: `calc(0.875rem * var(--fs-scale-nav, 1))` }}>
|
||||||
|
Пункт меню
|
||||||
|
</span>
|
||||||
|
<span className="rounded-[14px] bg-[var(--color-surface)] px-3 py-1.5 border border-[var(--color-border)]"
|
||||||
|
style={{ fontSize: `calc(0.875rem * var(--fs-scale-nav, 1))` }}>
|
||||||
|
Вкладка
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="fs-zone-table rounded-[14px] border border-[var(--color-border)] overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-[var(--color-surface)]">
|
||||||
|
<th className="px-3 py-2 text-left font-semibold" style={{ fontSize: `calc(0.875rem * var(--fs-scale-table, 1))` }}>Дата</th>
|
||||||
|
<th className="px-3 py-2 text-left font-semibold" style={{ fontSize: `calc(0.875rem * var(--fs-scale-table, 1))` }}>Статус</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr className="border-t border-[var(--color-border)]">
|
||||||
|
<td className="px-3 py-2" style={{ fontSize: `calc(0.875rem * var(--fs-scale-table, 1))` }}>02.07.2026</td>
|
||||||
|
<td className="px-3 py-2" style={{ fontSize: `calc(0.875rem * var(--fs-scale-table, 1))` }}>В работе</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="fs-zone-card rounded-[14px] bg-[var(--color-surface)] p-3">
|
||||||
|
<p style={{ fontSize: `calc(0.875rem * var(--fs-scale-card, 1))` }}>
|
||||||
|
Текст в карточке — описание доставки или заказа.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="fs-zone-body text-[var(--color-text)]" style={{ fontSize: `calc(1rem * var(--fs-scale-body, 1))` }}>
|
||||||
|
Основной текст интерфейса.
|
||||||
|
</p>
|
||||||
|
<p className="fs-zone-small text-[var(--color-text-muted)]" style={{ fontSize: `calc(0.75rem * var(--fs-scale-small, 1))` }}>
|
||||||
|
мелкая подпись · временная отметка
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-[var(--color-text-muted)]">
|
||||||
|
Настройки сохраняются на этом устройстве
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsPage;
|
||||||
|
|
@ -1,4 +1,18 @@
|
||||||
import React from "react";
|
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 {
|
import {
|
||||||
filterOrderGroups,
|
filterOrderGroups,
|
||||||
getOrderGroupDisplayStatusLabel,
|
getOrderGroupDisplayStatusLabel,
|
||||||
|
|
@ -9,79 +23,238 @@ import {
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
import { SkeletonPage } from "../UI/Loading";
|
import { SkeletonPage } from "../UI/Loading";
|
||||||
import { Pagination } from "../UI/Pagination";
|
|
||||||
import { OrderFilters } from "../orders/OrderFilters";
|
import { OrderFilters } from "../orders/OrderFilters";
|
||||||
import { formatDate, formatDateTime } from "../../utils/formatters";
|
import { formatDate, formatDateTime } from "../../utils/formatters";
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stale = updatedAt > 24h and not in agreed/delivered/picked_up/cancelled
|
||||||
|
const STALE_STATUSES = ["delivery:agreed", "delivery:driver_assigned", "delivery:loaded", "delivery:on_route", "delivery:delivered", "delivery:picked_up", "delivery:pickup", "delivery:cancelled"];
|
||||||
|
const isStale = (group) => {
|
||||||
|
const sv = getOrderGroupDisplayStatusValue(group);
|
||||||
|
if (STALE_STATUSES.includes(sv)) return false;
|
||||||
|
if (!group.updatedAt) return false;
|
||||||
|
const diff = Date.now() - new Date(group.updatedAt).getTime();
|
||||||
|
return diff > 24 * 60 * 60 * 1000;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLinkOpened = (group) => !!(group.invitationOpenedAt || (group.invitationAccessCount && group.invitationAccessCount > 0));
|
||||||
|
|
||||||
|
const renderRow = (group, onSelectSet) => (
|
||||||
|
<button
|
||||||
|
key={group.id}
|
||||||
|
type="button"
|
||||||
|
className={`grid ${COLS} gap-0 w-full border-t border-[var(--color-border)] text-left transition hover:bg-[var(--color-accent-soft)] ${isStale(group) ? "bg-[rgba(191,123,33,0.06)]" : ""}`}
|
||||||
|
onClick={() => { if (onSelectSet) onSelectSet(group.id); }}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 px-3 py-1.5">
|
||||||
|
<div className="text-xs font-medium leading-snug break-words" style={{ display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden" }}>
|
||||||
|
{group.displayTitle || group.customerName || group.groupKey}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-[var(--color-text-muted)] flex items-center gap-1">
|
||||||
|
{group.customerPhone || ""}
|
||||||
|
{isLinkOpened(group) && <span title="Клиент открывал ссылку" style={{ color: "#22c55e", fontSize: "11px" }}>👁</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
|
||||||
|
{group.city || group.customerAddress || "—"}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-xs">
|
||||||
|
<span className="inline-flex items-center gap-1 whitespace-nowrap">
|
||||||
|
{group.deliveryType === "pickup" ? "🏪" : "🚚"}
|
||||||
|
<span className="text-[var(--color-text-muted)]">{group.deliveryType === "pickup" ? "Самовывоз" : "Доставка"}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-xs">
|
||||||
|
{group.deliveryDate ? (
|
||||||
|
<span>{fmtDate(group.deliveryDate)}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[var(--color-text-muted)]">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-xs">
|
||||||
|
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]">—</span>}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5">
|
||||||
|
<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 }) => {
|
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS, isLoading = false }) => {
|
||||||
const STORAGE_KEY = "logistics-filters";
|
const [filters, setFilters] = React.useState({ query: "", displayStatus: "all", city: "" });
|
||||||
const savedFilters = (() => { try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || "null"); } catch { return null; } })();
|
const [collapsedSections, setCollapsedSections] = React.useState(() => loadCollapsedSections());
|
||||||
const [filters, setFilters] = React.useState(savedFilters || { query: "", displayStatus: "all", city: "" });
|
const [sectionOrder, setSectionOrder] = React.useState(() => {
|
||||||
React.useEffect(() => { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(filters)); } catch {} }, [filters]);
|
const custom = loadCustomOrder();
|
||||||
const [page, setPage] = React.useState(1);
|
return custom || [...DEFAULT_FUNNEL_ORDER];
|
||||||
const savedCollapsed = (() => { try { return new Set(JSON.parse(localStorage.getItem("logistics-collapsed") || "[]")); } catch { return new Set(); } })();
|
});
|
||||||
const [collapsedSections, setCollapsedSections] = React.useState(savedCollapsed);
|
|
||||||
React.useEffect(() => { try { localStorage.setItem("logistics-collapsed", JSON.stringify([...collapsedSections])); } catch {} }, [collapsedSections]);
|
|
||||||
const [draggingStatus, setDraggingStatus] = React.useState(null);
|
|
||||||
const [dragOverStatus, setDragOverStatus] = React.useState(null);
|
|
||||||
const savedOrder = (() => { try { return JSON.parse(localStorage.getItem("logistics-section-order") || "null"); } catch { return null; } })();
|
|
||||||
const [sectionOrder, setSectionOrder] = React.useState(savedOrder || null);
|
|
||||||
React.useEffect(() => { if (sectionOrder) { try { localStorage.setItem("logistics-section-order", JSON.stringify(sectionOrder)); } catch {} } }, [sectionOrder]);
|
|
||||||
|
|
||||||
// Touch drag for mobile
|
const sensors = useSensors(
|
||||||
const [touchDragging, setTouchDragging] = React.useState(null);
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
const touchStartY = React.useRef(null);
|
);
|
||||||
const touchStartStatus = React.useRef(null);
|
|
||||||
|
|
||||||
const handleTouchStart = (statusValue, e) => {
|
|
||||||
if (e.target.closest(".drag-handle")) {
|
|
||||||
touchStartY.current = e.touches[0].clientY;
|
|
||||||
touchStartStatus.current = statusValue;
|
|
||||||
setTouchDragging(statusValue);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTouchMove = (e) => {
|
|
||||||
if (!touchStartStatus.current) return;
|
|
||||||
e.preventDefault();
|
|
||||||
const touch = e.touches[0];
|
|
||||||
const el = document.elementFromPoint(touch.clientX, touch.clientY);
|
|
||||||
const section = el?.closest("[data-section-key]");
|
|
||||||
if (section) {
|
|
||||||
const overKey = section.getAttribute("data-section-key");
|
|
||||||
if (overKey && overKey !== touchStartStatus.current) {
|
|
||||||
setDragOverStatus(overKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTouchEnd = (e) => {
|
|
||||||
if (!touchStartStatus.current) return;
|
|
||||||
const touch = e.changedTouches[0];
|
|
||||||
const el = document.elementFromPoint(touch.clientX, touch.clientY);
|
|
||||||
const section = el?.closest("[data-section-key]");
|
|
||||||
if (section) {
|
|
||||||
const overKey = section.getAttribute("data-section-key");
|
|
||||||
if (overKey && overKey !== touchStartStatus.current) {
|
|
||||||
const order = sectionOrder && sectionOrder.length > 0 ? sectionOrder : FUNNEL_ORDER;
|
|
||||||
const entries = Array.from(statusGroups.keys());
|
|
||||||
const fullOrder = [...new Set([...order.filter(k => entries.includes(k)), ...entries])];
|
|
||||||
const fromIdx = fullOrder.indexOf(touchStartStatus.current);
|
|
||||||
const toIdx = fullOrder.indexOf(overKey);
|
|
||||||
if (fromIdx !== -1 && toIdx !== -1) {
|
|
||||||
const newOrd = [...fullOrder];
|
|
||||||
newOrd.splice(fromIdx, 1);
|
|
||||||
newOrd.splice(toIdx, 0, touchStartStatus.current);
|
|
||||||
setSectionOrder(newOrd);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setTouchDragging(null);
|
|
||||||
setDragOverStatus(null);
|
|
||||||
touchStartStatus.current = null;
|
|
||||||
touchStartY.current = null;
|
|
||||||
};
|
|
||||||
const PAGE_SIZE = 30;
|
|
||||||
|
|
||||||
const cities = React.useMemo(() => {
|
const cities = React.useMemo(() => {
|
||||||
const set = new Set();
|
const set = new Set();
|
||||||
|
|
@ -96,15 +269,9 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
|
||||||
[filters, orderGroups],
|
[filters, orderGroups],
|
||||||
);
|
);
|
||||||
|
|
||||||
const totalPages = Math.ceil(filteredGroups.length / PAGE_SIZE);
|
|
||||||
const paginatedGroups = React.useMemo(() => {
|
|
||||||
const start = (page - 1) * PAGE_SIZE;
|
|
||||||
return filteredGroups.slice(start, start + PAGE_SIZE);
|
|
||||||
}, [filteredGroups, page]);
|
|
||||||
|
|
||||||
const statusGroups = React.useMemo(() => {
|
const statusGroups = React.useMemo(() => {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
for (const group of paginatedGroups) {
|
for (const group of filteredGroups) {
|
||||||
const statusValue = getOrderGroupDisplayStatusValue(group);
|
const statusValue = getOrderGroupDisplayStatusValue(group);
|
||||||
if (!map.has(statusValue)) {
|
if (!map.has(statusValue)) {
|
||||||
const label = getOrderGroupDisplayStatusLabel(group);
|
const label = getOrderGroupDisplayStatusLabel(group);
|
||||||
|
|
@ -113,51 +280,66 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
|
||||||
map.get(statusValue).groups.push(group);
|
map.get(statusValue).groups.push(group);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [paginatedGroups]);
|
}, [filteredGroups]);
|
||||||
|
|
||||||
const FUNNEL_ORDER = [
|
|
||||||
"status:ready_for_notification",
|
|
||||||
"delivery:pending_confirmation",
|
|
||||||
"status:manual_required",
|
|
||||||
"status:first_sms_sent",
|
|
||||||
"status:second_sms_sent",
|
|
||||||
"delivery:agreed",
|
|
||||||
"delivery:driver_assigned",
|
|
||||||
"delivery:loaded",
|
|
||||||
"delivery:on_route",
|
|
||||||
"delivery:delivered",
|
|
||||||
"delivery:paid_storage",
|
|
||||||
"delivery:problem",
|
|
||||||
"delivery:cancelled",
|
|
||||||
];
|
|
||||||
|
|
||||||
const totalGroups = filteredGroups.length;
|
const totalGroups = filteredGroups.length;
|
||||||
|
|
||||||
// Same column layout as OrdersTable + Тип
|
// Build sorted list: use sectionOrder for known statuses, append unknown ones at end
|
||||||
const COLS = "grid-cols-[minmax(130px,2fr)_minmax(80px,1fr)_minmax(100px,0.8fr)_minmax(100px,1fr)_minmax(100px,1fr)_minmax(90px,0.8fr)_minmax(110px,1fr)]";
|
const sortedEntries = React.useMemo(() => {
|
||||||
|
const present = new Set(statusGroups.keys());
|
||||||
|
const result = [];
|
||||||
|
|
||||||
const TableHeader = () => (
|
// First: statuses in custom order that are present
|
||||||
<div className={`grid ${COLS} gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]`}>
|
for (const statusValue of sectionOrder) {
|
||||||
<div className="px-3 py-2.5">Клиент</div>
|
if (present.has(statusValue)) {
|
||||||
<div className="px-3 py-2.5">Город</div>
|
const data = statusGroups.get(statusValue);
|
||||||
<div className="px-3 py-2.5">Тип</div>
|
result.push([statusValue, data]);
|
||||||
<div className="px-3 py-2.5">Дата доставки</div>
|
}
|
||||||
<div className="px-3 py-2.5">Водитель</div>
|
}
|
||||||
<div className="px-3 py-2.5">Статус</div>
|
|
||||||
<div className="px-3 py-2.5">Обновлён</div>
|
// Then: any statuses not in sectionOrder (new statuses), sorted alphabetically
|
||||||
</div>
|
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) {
|
if (isLoading) {
|
||||||
return <SkeletonPage panels={3} />;
|
return <SkeletonPage panels={3} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 fs-zone-table">
|
||||||
<Panel className="space-y-4 p-5">
|
<Panel className="space-y-4 p-5">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="text-lg font-semibold">Наборы доставки</h2>
|
<h2 className="text-lg font-semibold">Наборы доставки</h2>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)] mt-0.5">
|
||||||
|
Перетаскивайте секции за ручку слева, чтобы изменить порядок отображения.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge tone="neutral">{totalGroups} групп</Badge>
|
<Badge tone="neutral">{totalGroups} групп</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -175,125 +357,45 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
|
||||||
По этому поиску ничего не найдено.
|
По этому поиску ничего не найдено.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-0">
|
<DndContext
|
||||||
{Array.from(statusGroups.entries()).sort(([a], [b]) => {
|
sensors={sensors}
|
||||||
const order = sectionOrder && sectionOrder.length > 0 ? sectionOrder : FUNNEL_ORDER;
|
collisionDetection={closestCenter}
|
||||||
const idxA = order.indexOf(a);
|
onDragEnd={handleDragEnd}
|
||||||
const idxB = order.indexOf(b);
|
>
|
||||||
if (idxA === -1 && idxB === -1) return a.localeCompare(b);
|
<SortableContext
|
||||||
if (idxA === -1) return 1;
|
items={sortedEntries.map(([id]) => id)}
|
||||||
if (idxB === -1) return -1;
|
strategy={verticalListSortingStrategy}
|
||||||
return idxA - idxB;
|
>
|
||||||
}).map(([statusValue, { label, groups }]) => {
|
<div className="space-y-4">
|
||||||
const isCollapsed = collapsedSections.has(statusValue);
|
{sortedEntries.map(([statusValue, { label, groups }]) => {
|
||||||
|
const isCollapsed = collapsedSections.has(statusValue);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={statusValue} className="border-b border-[var(--color-border)] last:border-b-0">
|
<SortableSection
|
||||||
{/* Section header — drag handle + collapse */}
|
key={statusValue}
|
||||||
<div
|
statusValue={statusValue}
|
||||||
data-section-key={statusValue}
|
label={label}
|
||||||
draggable
|
groups={groups}
|
||||||
onDragStart={(e) => { setDraggingStatus(statusValue); e.dataTransfer.effectAllowed = "move"; }}
|
isCollapsed={isCollapsed}
|
||||||
onDragEnd={() => { setDraggingStatus(null); setDragOverStatus(null); }}
|
onToggle={() => {
|
||||||
onDragOver={(e) => { e.preventDefault(); if (draggingStatus && draggingStatus !== statusValue) setDragOverStatus(statusValue); }}
|
setCollapsedSections((prev) => {
|
||||||
onDrop={(e) => {
|
const next = new Set(prev);
|
||||||
e.preventDefault();
|
if (next.has(statusValue)) {
|
||||||
if (draggingStatus && draggingStatus !== statusValue) {
|
next.delete(statusValue);
|
||||||
const order = sectionOrder && sectionOrder.length > 0 ? sectionOrder : FUNNEL_ORDER;
|
} else {
|
||||||
const entries = Array.from(statusGroups.keys());
|
next.add(statusValue);
|
||||||
const fullOrder = [...new Set([...order.filter(k => entries.includes(k)), ...entries])];
|
}
|
||||||
const fromIdx = fullOrder.indexOf(draggingStatus);
|
saveCollapsedSections(next);
|
||||||
const toIdx = fullOrder.indexOf(statusValue);
|
return next;
|
||||||
if (fromIdx !== -1 && toIdx !== -1) {
|
});
|
||||||
const newOrd = [...fullOrder];
|
}}
|
||||||
newOrd.splice(fromIdx, 1);
|
onSelectSet={onSelectSet}
|
||||||
newOrd.splice(toIdx, 0, draggingStatus);
|
/>
|
||||||
setSectionOrder(newOrd);
|
);
|
||||||
}
|
})}
|
||||||
}
|
</div>
|
||||||
setDraggingStatus(null);
|
</SortableContext>
|
||||||
setDragOverStatus(null);
|
</DndContext>
|
||||||
}}
|
|
||||||
onTouchStart={(e) => handleTouchStart(statusValue, e)}
|
|
||||||
onTouchMove={handleTouchMove}
|
|
||||||
onTouchEnd={handleTouchEnd}
|
|
||||||
onClick={() => {
|
|
||||||
if (touchStartStatus.current) return;
|
|
||||||
setCollapsedSections((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(statusValue)) next.delete(statusValue);
|
|
||||||
else next.add(statusValue);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left transition cursor-grab active:cursor-grabbing ${dragOverStatus === statusValue ? "border-t-2 border-t-[var(--color-accent)]" : ""} ${(draggingStatus === statusValue || touchDragging === statusValue) ? "opacity-50" : ""} hover:bg-[var(--color-accent-soft)]`}
|
|
||||||
>
|
|
||||||
<span className="drag-handle shrink-0 text-[var(--color-text-muted)] text-lg select-none touch-none" style={{cursor: "grab", lineHeight: "1"}}>⋮⋮</span>
|
|
||||||
<svg
|
|
||||||
className="h-4 w-4 shrink-0 text-[var(--color-text-muted)] transition-transform"
|
|
||||||
style={{ transform: isCollapsed ? "rotate(0deg)" : "rotate(90deg)" }}
|
|
||||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
<Badge tone={groups.length > 0 ? "neutral" : "muted"}>{label}</Badge>
|
|
||||||
<span className="text-xs text-[var(--color-text-muted)]">{groups.length}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isCollapsed && (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
{/* Horizontal scroll wrapper — min-w so columns don't squish */}
|
|
||||||
<div className="min-w-[960px]">
|
|
||||||
<TableHeader />
|
|
||||||
{groups.map((group) => (
|
|
||||||
<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 || "—"}
|
|
||||||
</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.deliveryDate
|
|
||||||
? <span>{formatDate(group.deliveryDate)}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
|
|
||||||
: group.updatedAt
|
|
||||||
? <span>{formatDate(group.updatedAt)}</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 flex items-center">
|
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<Pagination page={page} totalPages={totalPages} onChange={setPage} itemsPerPage={PAGE_SIZE} totalItems={filteredGroups.length} />
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,19 @@ import {
|
||||||
getOrderGroupStatusTone,
|
getOrderGroupStatusTone,
|
||||||
DELIVERY_GROUP_STATUS_LABELS,
|
DELIVERY_GROUP_STATUS_LABELS,
|
||||||
} from "../../services/orderGroupViews";
|
} from "../../services/orderGroupViews";
|
||||||
import { getErrorMessage, normalizeNom } from "../../utils/deliveryUtils";
|
import {
|
||||||
|
getErrorMessage,
|
||||||
|
normalizeNom,
|
||||||
|
} from "../../utils/deliveryUtils";
|
||||||
|
import { SmsStatusCard } from "./SmsStatusCard";
|
||||||
|
|
||||||
|
const fmtTime = (ts) => {
|
||||||
|
if (!ts) return "—";
|
||||||
|
try {
|
||||||
|
const d = new Date(ts);
|
||||||
|
return d.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||||
|
} catch { return "—"; }
|
||||||
|
};
|
||||||
|
|
||||||
const DELIVERY_TIME_OPTIONS = ["Первая половина дня", "Вторая половина дня"];
|
const DELIVERY_TIME_OPTIONS = ["Первая половина дня", "Вторая половина дня"];
|
||||||
const STATUS_LABELS = DELIVERY_GROUP_STATUS_LABELS;
|
const STATUS_LABELS = DELIVERY_GROUP_STATUS_LABELS;
|
||||||
|
|
@ -1045,6 +1057,7 @@ export const OrderDetailPanel = ({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<SmsStatusCard order={order} userRole={userRole} />
|
||||||
|
|
||||||
<StatusActionPanel
|
<StatusActionPanel
|
||||||
order={order}
|
order={order}
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ export const OrdersTable = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel className="p-0">
|
<Panel className="p-0 fs-zone-table">
|
||||||
<div className="space-y-4 border-b border-[var(--color-border)] px-5 py-4">
|
<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 className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ const fmtTime = (ts) => {
|
||||||
try {
|
try {
|
||||||
return new Date(ts).toLocaleString("ru-RU", {
|
return new Date(ts).toLocaleString("ru-RU", {
|
||||||
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit",
|
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit",
|
||||||
|
timeZone: "Europe/Moscow",
|
||||||
});
|
});
|
||||||
} catch { return ts; }
|
} catch { return ts; }
|
||||||
};
|
};
|
||||||
|
|
@ -64,6 +65,9 @@ const fmtCountdown = (targetTs) => {
|
||||||
|
|
||||||
// ── Component ────────────────────────────────────────────────────────────────
|
// ── Component ────────────────────────────────────────────────────────────────
|
||||||
export const SmsStatusCard = ({ order, userRole }) => {
|
export const SmsStatusCard = ({ order, userRole }) => {
|
||||||
|
// Only show to staff (not clients)
|
||||||
|
const isStaff = ["mega_admin", "admin", "manager", "logistician", "driver"].includes(userRole);
|
||||||
|
if (!isStaff) return null;
|
||||||
const [restarting, setRestarting] = useState(false);
|
const [restarting, setRestarting] = useState(false);
|
||||||
const [restartDone, setRestartDone] = useState(false);
|
const [restartDone, setRestartDone] = useState(false);
|
||||||
const [now, setNow] = useState(Date.now());
|
const [now, setNow] = useState(Date.now());
|
||||||
|
|
@ -181,7 +185,7 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Timeline */}
|
{/* Timeline */}
|
||||||
<div className="space-y-2 text-xs">
|
<div className="space-y-2.5 text-sm">
|
||||||
{/* 1st SMS */}
|
{/* 1st SMS */}
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<span className={`mt-0.5 h-2 w-2 rounded-full ${hasFirstSms ? "bg-[#22c55e]" : hasSmsSent && notifStatus === "sms_sending" ? "bg-[var(--color-accent)]" : notifStatus === "link_ready" || notifStatus === "not_started" ? "bg-[var(--color-warning)]" : "bg-[var(--color-border)]"}`} />
|
<span className={`mt-0.5 h-2 w-2 rounded-full ${hasFirstSms ? "bg-[#22c55e]" : hasSmsSent && notifStatus === "sms_sending" ? "bg-[var(--color-accent)]" : notifStatus === "link_ready" || notifStatus === "not_started" ? "bg-[var(--color-warning)]" : "bg-[var(--color-border)]"}`} />
|
||||||
|
|
@ -201,15 +205,19 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
||||||
|
|
||||||
{/* 2nd SMS */}
|
{/* 2nd SMS */}
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<span className={`mt-0.5 h-2 w-2 rounded-full ${hasSecondSms ? "bg-[#22c55e]" : notifStatus === "first_sms_sent" ? "bg-[var(--color-warning)]" : "bg-[var(--color-border)]"}`} />
|
<span className={`mt-0.5 h-2 w-2 rounded-full ${hasSecondSms ? "bg-[#22c55e]" : notifStatus === "second_sms_sending" ? "bg-[var(--color-accent)]" : notifStatus === "first_sms_sent" ? "bg-[var(--color-warning)]" : "bg-[var(--color-border)]"}`} />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-[var(--color-text)]">2-е SMS</div>
|
<div className="text-[var(--color-text)]">2-е SMS</div>
|
||||||
{hasSecondSms ? (
|
{hasSecondSms ? (
|
||||||
<div className="text-[var(--color-text-muted)]">{fmtTime(secondSmsAt)}</div>
|
<div className="text-[var(--color-text-muted)]">{fmtTime(secondSmsAt)} ✓ доставлено</div>
|
||||||
|
) : notifStatus === "second_sms_sending" && hasSmsSent ? (
|
||||||
|
<div className="text-[var(--color-text-muted)]">{fmtTime(smsSentAt)} · отправлено, ждём подтверждения…</div>
|
||||||
) : notifStatus === "first_sms_sent" && countdown ? (
|
) : notifStatus === "first_sms_sent" && countdown ? (
|
||||||
<div className="text-[var(--color-text-muted)]">
|
<div className="text-[var(--color-text-muted)]">
|
||||||
отправка через <span className="font-mono text-[var(--color-accent)]">{countdown}</span>
|
отправка через <span className="font-mono text-[var(--color-accent)]">{countdown}</span>
|
||||||
</div>
|
</div>
|
||||||
|
) : notifStatus === "first_sms_sent" ? (
|
||||||
|
<div className="text-[var(--color-text-muted)]">ожидает отправки</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-[var(--color-text-muted)]">—</div>
|
<div className="text-[var(--color-text-muted)]">—</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -240,10 +248,10 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
||||||
{/* SMS log for this group */}
|
{/* SMS log for this group */}
|
||||||
{smsLog.length > 0 && (
|
{smsLog.length > 0 && (
|
||||||
<div className="mt-3 border-t border-[var(--color-border)] pt-3">
|
<div className="mt-3 border-t border-[var(--color-border)] pt-3">
|
||||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">История SMS</div>
|
<div className="mb-2 text-xs font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">История SMS</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
{smsLog.map((log) => (
|
{smsLog.map((log) => (
|
||||||
<div key={log.id} className="flex items-center gap-2 text-[11px]">
|
<div key={log.id} className="flex items-center gap-2 text-xs">
|
||||||
<span className="text-[var(--color-text-muted)]">{fmtTime(log.created_at)}</span>
|
<span className="text-[var(--color-text-muted)]">{fmtTime(log.created_at)}</span>
|
||||||
<Badge tone={log.status === "delivered" ? "accent" : log.status === "sent" || log.status === "checking" ? "info" : "danger"}>
|
<Badge tone={log.status === "delivered" ? "accent" : log.status === "sent" || log.status === "checking" ? "info" : "danger"}>
|
||||||
{log.status === "delivered" ? "доставлено" : log.status === "sent" ? "отправлено" : log.status === "checking" ? "проверка" : log.status === "expired" ? "истёк" : log.status}
|
{log.status === "delivered" ? "доставлено" : log.status === "sent" ? "отправлено" : log.status === "checking" ? "проверка" : log.status === "expired" ? "истёк" : log.status}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const FontSettingsContext = createContext(null);
|
||||||
|
|
||||||
|
const STORAGE_KEY = "supersam-font-settings";
|
||||||
|
|
||||||
|
// Categories: key → { label, defaultScale, min, max, step, description }
|
||||||
|
export const FONT_CATEGORIES = [
|
||||||
|
{ key: "table", label: "Таблицы", description: "Текст в таблицах и списках", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
|
||||||
|
{ key: "card", label: "Карточки", description: "Текст в карточках и панелях", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
|
||||||
|
{ key: "nav", label: "Меню и навигация", description: "Пункты меню, вкладки, кнопки", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
|
||||||
|
{ key: "heading", label: "Заголовки", description: "Названия секций и страниц", defaultScale: 1.0, min: 0.8, max: 1.6, step: 0.05 },
|
||||||
|
{ key: "body", label: "Основной текст", description: "Обычный текст в интерфейсе", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
|
||||||
|
{ key: "small", label: "Мелкий текст", description: "Подписи, метки, временные отметки", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS = FONT_CATEGORIES.reduce((acc, cat) => {
|
||||||
|
acc[cat.key] = cat.defaultScale;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
function loadSettings() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return { ...DEFAULT_SETTINGS };
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
// Merge with defaults to handle new categories
|
||||||
|
return { ...DEFAULT_SETTINGS, ...parsed };
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_SETTINGS };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySettings(settings) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
for (const cat of FONT_CATEGORIES) {
|
||||||
|
root.style.setProperty(`--fs-scale-${cat.key}`, String(settings[cat.key] ?? 1.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FontSettingsProvider = ({ children }) => {
|
||||||
|
const [settings, setSettings] = useState(loadSettings);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applySettings(settings);
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
const updateCategory = (key, scale) => {
|
||||||
|
setSettings((prev) => ({ ...prev, [key]: scale }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetAll = () => {
|
||||||
|
setSettings({ ...DEFAULT_SETTINGS });
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
settings,
|
||||||
|
updateCategory,
|
||||||
|
resetAll,
|
||||||
|
categories: FONT_CATEGORIES,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <FontSettingsContext.Provider value={value}>{children}</FontSettingsContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useFontSettings = () => {
|
||||||
|
const context = useContext(FontSettingsContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useFontSettings must be used within FontSettingsProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
/* Font size scale variables — applied by FontSettingsContext */
|
||||||
|
:root {
|
||||||
|
--fs-scale-table: 1.0;
|
||||||
|
--fs-scale-card: 1.0;
|
||||||
|
--fs-scale-nav: 1.0;
|
||||||
|
--fs-scale-heading: 1.0;
|
||||||
|
--fs-scale-body: 1.0;
|
||||||
|
--fs-scale-small: 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slider styling */
|
||||||
|
.fs-slider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fs-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-accent);
|
||||||
|
border: 3px solid var(--color-surface-strong);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fs-slider::-webkit-slider-thumb:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fs-slider::-moz-range-thumb {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-accent);
|
||||||
|
border: 3px solid var(--color-surface-strong);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Zone overrides ──────────────────────────────────────────────────────
|
||||||
|
.fs-zone-* wrappers override Tailwind text-* classes inside them.
|
||||||
|
Specificity: .fs-zone-X .text-Y (0,2,0) > .text-Y (0,1,0).
|
||||||
|
This lets us scale fonts without patching every component.
|
||||||
|
────────────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Table zone */
|
||||||
|
.fs-zone-table { font-size: calc(0.875rem * var(--fs-scale-table, 1)); }
|
||||||
|
.fs-zone-table .text-xs { font-size: calc(0.75rem * var(--fs-scale-table, 1)); }
|
||||||
|
.fs-zone-table .text-sm { font-size: calc(0.875rem * var(--fs-scale-table, 1)); }
|
||||||
|
.fs-zone-table .text-base { font-size: calc(1rem * var(--fs-scale-table, 1)); }
|
||||||
|
.fs-zone-table .text-lg { font-size: calc(1.125rem * var(--fs-scale-table, 1)); }
|
||||||
|
.fs-zone-table .text-xl { font-size: calc(1.25rem * var(--fs-scale-table, 1)); }
|
||||||
|
/* Arbitrary pixel sizes used in tables */
|
||||||
|
.fs-zone-table .text-\[10px\] { font-size: calc(10px * var(--fs-scale-table, 1)); }
|
||||||
|
.fs-zone-table .text-\[11px\] { font-size: calc(11px * var(--fs-scale-table, 1)); }
|
||||||
|
.fs-zone-table .text-\[12px\] { font-size: calc(12px * var(--fs-scale-table, 1)); }
|
||||||
|
.fs-zone-table .text-\[13px\] { font-size: calc(13px * var(--fs-scale-table, 1)); }
|
||||||
|
.fs-zone-table .text-\[14px\] { font-size: calc(14px * var(--fs-scale-table, 1)); }
|
||||||
|
|
||||||
|
/* Card zone */
|
||||||
|
.fs-zone-card { font-size: calc(0.875rem * var(--fs-scale-card, 1)); }
|
||||||
|
.fs-zone-card .text-xs { font-size: calc(0.75rem * var(--fs-scale-card, 1)); }
|
||||||
|
.fs-zone-card .text-sm { font-size: calc(0.875rem * var(--fs-scale-card, 1)); }
|
||||||
|
.fs-zone-card .text-base { font-size: calc(1rem * var(--fs-scale-card, 1)); }
|
||||||
|
.fs-zone-card .text-lg { font-size: calc(1.125rem * var(--fs-scale-card, 1)); }
|
||||||
|
.fs-zone-card .text-\[10px\] { font-size: calc(10px * var(--fs-scale-card, 1)); }
|
||||||
|
.fs-zone-card .text-\[11px\] { font-size: calc(11px * var(--fs-scale-card, 1)); }
|
||||||
|
|
||||||
|
/* Nav zone */
|
||||||
|
.fs-zone-nav { font-size: calc(0.875rem * var(--fs-scale-nav, 1)); }
|
||||||
|
.fs-zone-nav .text-xs { font-size: calc(0.75rem * var(--fs-scale-nav, 1)); }
|
||||||
|
.fs-zone-nav .text-sm { font-size: calc(0.875rem * var(--fs-scale-nav, 1)); }
|
||||||
|
.fs-zone-nav .text-base { font-size: calc(1rem * var(--fs-scale-nav, 1)); }
|
||||||
|
|
||||||
|
/* Heading zone */
|
||||||
|
.fs-zone-heading { font-size: calc(1rem * var(--fs-scale-heading, 1)); }
|
||||||
|
.fs-zone-heading .text-sm { font-size: calc(0.875rem * var(--fs-scale-heading, 1)); }
|
||||||
|
.fs-zone-heading .text-base { font-size: calc(1rem * var(--fs-scale-heading, 1)); }
|
||||||
|
.fs-zone-heading .text-lg { font-size: calc(1.125rem * var(--fs-scale-heading, 1)); }
|
||||||
|
.fs-zone-heading .text-xl { font-size: calc(1.25rem * var(--fs-scale-heading, 1)); }
|
||||||
|
.fs-zone-heading .text-2xl { font-size: calc(1.5rem * var(--fs-scale-heading, 1)); }
|
||||||
|
.fs-zone-heading .text-3xl { font-size: calc(1.875rem * var(--fs-scale-heading, 1)); }
|
||||||
|
|
||||||
|
/* Small text zone */
|
||||||
|
.fs-zone-small { font-size: calc(0.75rem * var(--fs-scale-small, 1)); }
|
||||||
|
.fs-zone-small .text-xs { font-size: calc(0.75rem * var(--fs-scale-small, 1)); }
|
||||||
|
.fs-zone-small .text-sm { font-size: calc(0.875rem * var(--fs-scale-small, 1)); }
|
||||||
|
|
||||||
|
/* Body zone — LAST so table/card/nav/heading zones inside body win at equal specificity */
|
||||||
|
.fs-zone-body { font-size: calc(1rem * var(--fs-scale-body, 1)); }
|
||||||
|
.fs-zone-body .text-xs { font-size: calc(0.75rem * var(--fs-scale-body, 1)); }
|
||||||
|
.fs-zone-body .text-sm { font-size: calc(0.875rem * var(--fs-scale-body, 1)); }
|
||||||
|
.fs-zone-body .text-base { font-size: calc(1rem * var(--fs-scale-body, 1)); }
|
||||||
|
.fs-zone-body .text-lg { font-size: calc(1.125rem * var(--fs-scale-body, 1)); }
|
||||||
|
|
@ -11,11 +11,21 @@ import { getErrorMessage } from "../utils/deliveryUtils";
|
||||||
|
|
||||||
export const useOrderGroups = () => {
|
export const useOrderGroups = () => {
|
||||||
const [orderGroups, setOrderGroups] = React.useState(() => []);
|
const [orderGroups, setOrderGroups] = React.useState(() => []);
|
||||||
const [filters, setFilters] = React.useState({
|
const FILTERS_STORAGE_KEY = "supersam_order_filters";
|
||||||
query: "",
|
const [filters, setFilters] = React.useState(() => {
|
||||||
displayStatus: "all",
|
try {
|
||||||
deliveryType: "",
|
const saved = sessionStorage.getItem(FILTERS_STORAGE_KEY);
|
||||||
|
if (saved) return JSON.parse(saved);
|
||||||
|
} catch (e) {}
|
||||||
|
return { query: "", displayStatus: "all", deliveryType: "" };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Persist filters to sessionStorage on every change
|
||||||
|
React.useEffect(() => {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(FILTERS_STORAGE_KEY, JSON.stringify(filters));
|
||||||
|
} catch (e) {}
|
||||||
|
}, [filters]);
|
||||||
const [selectedOrderGroupId, setSelectedOrderGroupId] = React.useState(null);
|
const [selectedOrderGroupId, setSelectedOrderGroupId] = React.useState(null);
|
||||||
const [isLoading, setIsLoading] = React.useState(true);
|
const [isLoading, setIsLoading] = React.useState(true);
|
||||||
const [loadError, setLoadError] = React.useState("");
|
const [loadError, setLoadError] = React.useState("");
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import { ROLE_LABELS } from "../constants/roles";
|
import { ROLE_LABELS } from "../constants/roles";
|
||||||
import { Badge } from "../components/UI/Badge";
|
import { Badge } from "../components/UI/Badge";
|
||||||
import { Button } from "../components/UI/Button";
|
import { Button } from "../components/UI/Button";
|
||||||
|
|
@ -28,6 +29,7 @@ export const AppShell = ({
|
||||||
}) => {
|
}) => {
|
||||||
const shouldShowMobileNav = !isGuideOpen && navItems.length > 1;
|
const shouldShowMobileNav = !isGuideOpen && navItems.length > 1;
|
||||||
const [showNotifSettings, setShowNotifSettings] = React.useState(false);
|
const [showNotifSettings, setShowNotifSettings] = React.useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
if (showNotifSettings) {
|
if (showNotifSettings) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -47,7 +49,7 @@ export const AppShell = ({
|
||||||
<div className="min-h-screen px-3 py-4 sm:px-4 md:px-6 md:py-8">
|
<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">
|
<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 */}
|
{/* Desktop sidebar */}
|
||||||
<Panel className="hidden h-fit flex-col gap-5 p-4 xl:flex">
|
<Panel className="fs-zone-nav hidden h-fit flex-col gap-5 p-4 xl:flex">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-[0.24em] text-[var(--color-text-muted)]">
|
<p className="text-xs uppercase tracking-[0.24em] text-[var(--color-text-muted)]">
|
||||||
Панель
|
Панель
|
||||||
|
|
@ -81,6 +83,9 @@ export const AppShell = ({
|
||||||
{isGuideOpen ? "К рабочей области" : "Справка"}
|
{isGuideOpen ? "К рабочей области" : "Справка"}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
<Button variant="ghost" className="mb-2 w-full justify-start" onClick={() => navigate("/settings")}>
|
||||||
|
Настройки
|
||||||
|
</Button>
|
||||||
<Button variant="ghost" className="w-full justify-start" onClick={onSignOut}>
|
<Button variant="ghost" className="w-full justify-start" onClick={onSignOut}>
|
||||||
Выйти
|
Выйти
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -88,9 +93,9 @@ export const AppShell = ({
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{/* Main content area */}
|
{/* Main content area */}
|
||||||
<div className="min-w-0 space-y-5 pb-20 xl:space-y-8 xl:pb-0">
|
<div className="min-w-0 space-y-5 pb-20 xl:space-y-8 xl:pb-0 fs-zone-body">
|
||||||
{/* Mobile header */}
|
{/* Mobile header */}
|
||||||
<Panel className="p-4 xl:hidden">
|
<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="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
<div className="min-w-0 flex-1 space-y-1">
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
||||||
|
|
@ -117,6 +122,9 @@ export const AppShell = ({
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<PwaInstallButton onInstall={onInstallApp} isInstalled={isInstalled} isInstallAvailable={isInstallAvailable} />
|
<PwaInstallButton onInstall={onInstallApp} isInstalled={isInstalled} isInstallAvailable={isInstallAvailable} />
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => navigate("/settings")} aria-label="Настройки">
|
||||||
|
⚙
|
||||||
|
</Button>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<Button size="sm" variant="ghost" onClick={onSignOut}>
|
<Button size="sm" variant="ghost" onClick={onSignOut}>
|
||||||
Выйти
|
Выйти
|
||||||
|
|
@ -127,7 +135,7 @@ export const AppShell = ({
|
||||||
|
|
||||||
{/* Mobile tab navigation — STICKY TOP */}
|
{/* Mobile tab navigation — STICKY TOP */}
|
||||||
{shouldShowMobileNav && (
|
{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">
|
<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' }}>
|
<div className="flex gap-1 overflow-x-auto" style={{ WebkitOverflowScrolling: 'touch', scrollbarWidth: 'none' }}>
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -153,7 +161,7 @@ export const AppShell = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Desktop header */}
|
{/* Desktop header */}
|
||||||
<Panel className="hidden p-4 md:p-5 xl:block">
|
<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 className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
<p className="text-sm uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
||||||
|
|
@ -184,6 +192,9 @@ export const AppShell = ({
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<PwaInstallButton onInstall={onInstallApp} isInstalled={isInstalled} isInstallAvailable={isInstallAvailable} />
|
<PwaInstallButton onInstall={onInstallApp} isInstalled={isInstalled} isInstallAvailable={isInstallAvailable} />
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => navigate("/settings")} aria-label="Настройки">
|
||||||
|
⚙
|
||||||
|
</Button>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
10
src/main.jsx
10
src/main.jsx
|
|
@ -4,10 +4,12 @@ import { RouterProvider } from "react-router-dom";
|
||||||
import { router } from "./router";
|
import { router } from "./router";
|
||||||
import { ThemeProvider } from "./context/ThemeContext";
|
import { ThemeProvider } from "./context/ThemeContext";
|
||||||
import { AuthProvider } from "./context/AuthContext";
|
import { AuthProvider } from "./context/AuthContext";
|
||||||
|
import { FontSettingsProvider } from "./context/FontSettingsContext";
|
||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import { initErrorLogging } from "./utils/errorLogger";
|
import { initErrorLogging } from "./utils/errorLogger";
|
||||||
import { registerPwaServiceWorker } from "./hooks/usePwaStatus";
|
import { registerPwaServiceWorker } from "./hooks/usePwaStatus";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
import "./styles/fontSettings.css";
|
||||||
|
|
||||||
registerPwaServiceWorker();
|
registerPwaServiceWorker();
|
||||||
initErrorLogging();
|
initErrorLogging();
|
||||||
|
|
@ -15,9 +17,11 @@ initErrorLogging();
|
||||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<ErrorBoundary>
|
<FontSettingsProvider>
|
||||||
<RouterProvider router={router} />
|
<ErrorBoundary>
|
||||||
</ErrorBoundary>
|
<RouterProvider router={router} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</FontSettingsProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ThemeProvider>,
|
</ThemeProvider>,
|
||||||
);
|
);
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -7,6 +7,7 @@ import { GroupDetailPage } from "./pages/GroupDetailPage";
|
||||||
import { LoginPage } from "./pages/LoginPage";
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||||
import { ForbiddenPage } from "./pages/ForbiddenPage";
|
import { ForbiddenPage } from "./pages/ForbiddenPage";
|
||||||
|
import { SettingsPage } from "./pages/SettingsPage";
|
||||||
import { useAuth } from "./context/AuthContext";
|
import { useAuth } from "./context/AuthContext";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -66,6 +67,14 @@ export const router = createBrowserRouter([
|
||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "settings",
|
||||||
|
element: (
|
||||||
|
<RequireAuth>
|
||||||
|
<SettingsPage />
|
||||||
|
</RequireAuth>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "*",
|
path: "*",
|
||||||
element: <NotFoundPage />,
|
element: <NotFoundPage />,
|
||||||
|
|
|
||||||
|
|
@ -142,12 +142,23 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
|
||||||
const extractCity = (addr) => {
|
const extractCity = (addr) => {
|
||||||
if (!addr) return "";
|
if (!addr) return "";
|
||||||
// 1) explicit marker: г. Ялта, пгт. Куйбышево, etc.
|
// 1) explicit marker: г. Ялта, пгт. Куйбышево, etc.
|
||||||
const m = addr.match(/(?:г\.\s|гор\.\s|пос\.\s|с\.\s|дер\.\s|пгт\.\s|город\s|село\s|г\s)\s*([А-ЯЁа-яёA-Za-z\-\s]+?)(?:[,\\s]|$)/i);
|
// Word markers (город/село) require space after to avoid matching "Стройгородок"
|
||||||
if (m) return m[1].trim();
|
// Dot markers (г./гор./etc) allow zero space (г.Ялта)
|
||||||
// 2) known city name anywhere in address (case-insensitive)
|
const m = addr.match(/(?:г\.\s*|гор\.\s*|пос\.\s*|с\.\s*|дер\.\s*|пгт\.\s*|город\s+|село\s+|г\s+)\s*([А-ЯЁа-яёA-Za-z\-\s]+?)(?:[,\s]|$)/i);
|
||||||
|
if (m) {
|
||||||
|
const candidate = m[1].trim();
|
||||||
|
for (const city of CRIMEAN_CITIES) {
|
||||||
|
if (city.toLowerCase() === candidate.toLowerCase()) return city;
|
||||||
|
}
|
||||||
|
// Not a known city — continue to step 2
|
||||||
|
}
|
||||||
|
// 2) known city name via custom word boundary (JS \b doesn't work with Cyrillic)
|
||||||
|
// City must be preceded by start/comma/space/dot and followed by comma/space/end
|
||||||
const lower = addr.toLowerCase();
|
const lower = addr.toLowerCase();
|
||||||
for (const city of CRIMEAN_CITIES) {
|
for (const city of CRIMEAN_CITIES) {
|
||||||
if (lower.includes(city.toLowerCase())) return city;
|
const esc = city.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const re = new RegExp("(^|[,.\\s])" + esc + "(?=[,\\s]|$)", "i");
|
||||||
|
if (re.test(lower)) return city;
|
||||||
}
|
}
|
||||||
// 3) Бахчисарайский р-н → Бахчисарай
|
// 3) Бахчисарайский р-н → Бахчисарай
|
||||||
const district = addr.match(/([А-ЯЁа-яё]+)ский\s*(?:р-н|район)/i);
|
const district = addr.match(/([А-ЯЁа-яё]+)ский\s*(?:р-н|район)/i);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
/* 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).
|
||||||
|
Body zone FIRST — specific zones (table/card/nav/heading) come after
|
||||||
|
and win at equal specificity when nested inside body.
|
||||||
|
This lets us scale fonts without patching every component.
|
||||||
|
────────────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Body zone — FIRST so specific 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)); }
|
||||||
|
|
||||||
|
/* 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)); }
|
||||||
Loading…
Reference in New Issue