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";
|
||||
|
||||
if (!isLocalhost) {
|
||||
const STATIC_CACHE = "construction-delivery-static-v57";
|
||||
const RUNTIME_CACHE = "construction-delivery-runtime-v57";
|
||||
const STATIC_CACHE = "construction-delivery-static-v58";
|
||||
const RUNTIME_CACHE = "construction-delivery-runtime-v58";
|
||||
const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
|
||||
|
||||
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 {
|
||||
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,
|
||||
|
|
@ -9,79 +23,238 @@ import {
|
|||
import { Badge } from "../UI/Badge";
|
||||
import { Panel } from "../UI/Panel";
|
||||
import { SkeletonPage } from "../UI/Loading";
|
||||
import { Pagination } from "../UI/Pagination";
|
||||
import { OrderFilters } from "../orders/OrderFilters";
|
||||
import { formatDate, formatDateTime } from "../../utils/formatters";
|
||||
|
||||
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 }) => {
|
||||
const STORAGE_KEY = "logistics-filters";
|
||||
const savedFilters = (() => { try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || "null"); } catch { return null; } })();
|
||||
const [filters, setFilters] = React.useState(savedFilters || { query: "", displayStatus: "all", city: "" });
|
||||
React.useEffect(() => { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(filters)); } catch {} }, [filters]);
|
||||
const [page, setPage] = React.useState(1);
|
||||
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]);
|
||||
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];
|
||||
});
|
||||
|
||||
// Touch drag for mobile
|
||||
const [touchDragging, setTouchDragging] = React.useState(null);
|
||||
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 sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
);
|
||||
|
||||
const cities = React.useMemo(() => {
|
||||
const set = new Set();
|
||||
|
|
@ -96,15 +269,9 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
|
|||
[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 map = new Map();
|
||||
for (const group of paginatedGroups) {
|
||||
for (const group of filteredGroups) {
|
||||
const statusValue = getOrderGroupDisplayStatusValue(group);
|
||||
if (!map.has(statusValue)) {
|
||||
const label = getOrderGroupDisplayStatusLabel(group);
|
||||
|
|
@ -113,51 +280,66 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
|
|||
map.get(statusValue).groups.push(group);
|
||||
}
|
||||
return map;
|
||||
}, [paginatedGroups]);
|
||||
|
||||
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",
|
||||
];
|
||||
}, [filteredGroups]);
|
||||
|
||||
const totalGroups = filteredGroups.length;
|
||||
|
||||
// Same column layout as OrdersTable + Тип
|
||||
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)]";
|
||||
// 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 = [];
|
||||
|
||||
const TableHeader = () => (
|
||||
<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)]`}>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
// 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">
|
||||
<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>
|
||||
|
|
@ -175,125 +357,45 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
|
|||
По этому поиску ничего не найдено.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-0">
|
||||
{Array.from(statusGroups.entries()).sort(([a], [b]) => {
|
||||
const order = sectionOrder && sectionOrder.length > 0 ? sectionOrder : FUNNEL_ORDER;
|
||||
const idxA = order.indexOf(a);
|
||||
const idxB = order.indexOf(b);
|
||||
if (idxA === -1 && idxB === -1) return a.localeCompare(b);
|
||||
if (idxA === -1) return 1;
|
||||
if (idxB === -1) return -1;
|
||||
return idxA - idxB;
|
||||
}).map(([statusValue, { label, groups }]) => {
|
||||
const isCollapsed = collapsedSections.has(statusValue);
|
||||
<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 (
|
||||
<div key={statusValue} className="border-b border-[var(--color-border)] last:border-b-0">
|
||||
{/* Section header — drag handle + collapse */}
|
||||
<div
|
||||
data-section-key={statusValue}
|
||||
draggable
|
||||
onDragStart={(e) => { setDraggingStatus(statusValue); e.dataTransfer.effectAllowed = "move"; }}
|
||||
onDragEnd={() => { setDraggingStatus(null); setDragOverStatus(null); }}
|
||||
onDragOver={(e) => { e.preventDefault(); if (draggingStatus && draggingStatus !== statusValue) setDragOverStatus(statusValue); }}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
if (draggingStatus && draggingStatus !== statusValue) {
|
||||
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(draggingStatus);
|
||||
const toIdx = fullOrder.indexOf(statusValue);
|
||||
if (fromIdx !== -1 && toIdx !== -1) {
|
||||
const newOrd = [...fullOrder];
|
||||
newOrd.splice(fromIdx, 1);
|
||||
newOrd.splice(toIdx, 0, draggingStatus);
|
||||
setSectionOrder(newOrd);
|
||||
}
|
||||
}
|
||||
setDraggingStatus(null);
|
||||
setDragOverStatus(null);
|
||||
}}
|
||||
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} />
|
||||
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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -54,7 +54,19 @@ import {
|
|||
getOrderGroupStatusTone,
|
||||
DELIVERY_GROUP_STATUS_LABELS,
|
||||
} from "../../services/orderGroupViews";
|
||||
import { getErrorMessage, normalizeNom } from "../../utils/deliveryUtils";
|
||||
import {
|
||||
getErrorMessage,
|
||||
normalizeNom,
|
||||
} from "../../utils/deliveryUtils";
|
||||
import { SmsStatusCard } from "./SmsStatusCard";
|
||||
|
||||
const fmtTime = (ts) => {
|
||||
if (!ts) return "—";
|
||||
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 STATUS_LABELS = DELIVERY_GROUP_STATUS_LABELS;
|
||||
|
|
@ -1045,6 +1057,7 @@ export const OrderDetailPanel = ({
|
|||
/>
|
||||
)}
|
||||
|
||||
<SmsStatusCard order={order} userRole={userRole} />
|
||||
|
||||
<StatusActionPanel
|
||||
order={order}
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ export const OrdersTable = ({
|
|||
}
|
||||
|
||||
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="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ const fmtTime = (ts) => {
|
|||
try {
|
||||
return new Date(ts).toLocaleString("ru-RU", {
|
||||
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit",
|
||||
timeZone: "Europe/Moscow",
|
||||
});
|
||||
} catch { return ts; }
|
||||
};
|
||||
|
|
@ -64,6 +65,9 @@ const fmtCountdown = (targetTs) => {
|
|||
|
||||
// ── Component ────────────────────────────────────────────────────────────────
|
||||
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 [restartDone, setRestartDone] = useState(false);
|
||||
const [now, setNow] = useState(Date.now());
|
||||
|
|
@ -181,7 +185,7 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
|||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="space-y-2.5 text-sm">
|
||||
{/* 1st SMS */}
|
||||
<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)]"}`} />
|
||||
|
|
@ -201,15 +205,19 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
|||
|
||||
{/* 2nd SMS */}
|
||||
<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="text-[var(--color-text)]">2-е SMS</div>
|
||||
{hasSecondSms ? (
|
||||
<div className="text-[var(--color-text-muted)]">{fmtTime(secondSmsAt)}</div>
|
||||
<div className="text-[var(--color-text-muted)]">{fmtTime(secondSmsAt)} ✓ доставлено</div>
|
||||
) : notifStatus === "second_sms_sending" && hasSmsSent ? (
|
||||
<div className="text-[var(--color-text-muted)]">{fmtTime(smsSentAt)} · отправлено, ждём подтверждения…</div>
|
||||
) : notifStatus === "first_sms_sent" && countdown ? (
|
||||
<div className="text-[var(--color-text-muted)]">
|
||||
отправка через <span className="font-mono text-[var(--color-accent)]">{countdown}</span>
|
||||
</div>
|
||||
) : notifStatus === "first_sms_sent" ? (
|
||||
<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 */}
|
||||
{smsLog.length > 0 && (
|
||||
<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="space-y-1.5">
|
||||
<div className="mb-2 text-xs font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">История SMS</div>
|
||||
<div className="space-y-2">
|
||||
{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>
|
||||
<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}
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
const [orderGroups, setOrderGroups] = React.useState(() => []);
|
||||
const [filters, setFilters] = React.useState({
|
||||
query: "",
|
||||
displayStatus: "all",
|
||||
deliveryType: "",
|
||||
const FILTERS_STORAGE_KEY = "supersam_order_filters";
|
||||
const [filters, setFilters] = React.useState(() => {
|
||||
try {
|
||||
const saved = 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 [isLoading, setIsLoading] = React.useState(true);
|
||||
const [loadError, setLoadError] = React.useState("");
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
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";
|
||||
|
|
@ -28,6 +29,7 @@ export const AppShell = ({
|
|||
}) => {
|
||||
const shouldShowMobileNav = !isGuideOpen && navItems.length > 1;
|
||||
const [showNotifSettings, setShowNotifSettings] = React.useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (showNotifSettings) {
|
||||
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="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="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>
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-[var(--color-text-muted)]">
|
||||
Панель
|
||||
|
|
@ -81,6 +83,9 @@ export const AppShell = ({
|
|||
{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>
|
||||
|
|
@ -88,9 +93,9 @@ export const AppShell = ({
|
|||
</Panel>
|
||||
|
||||
{/* 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 */}
|
||||
<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="min-w-0 flex-1 space-y-1">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
||||
|
|
@ -117,6 +122,9 @@ export const AppShell = ({
|
|||
</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}>
|
||||
Выйти
|
||||
|
|
@ -127,7 +135,7 @@ export const AppShell = ({
|
|||
|
||||
{/* 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">
|
||||
<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
|
||||
|
|
@ -153,7 +161,7 @@ export const AppShell = ({
|
|||
)}
|
||||
|
||||
{/* 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>
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
||||
|
|
@ -184,6 +192,9 @@ export const AppShell = ({
|
|||
</Button>
|
||||
) : null}
|
||||
<PwaInstallButton onInstall={onInstallApp} isInstalled={isInstalled} isInstallAvailable={isInstallAvailable} />
|
||||
<Button size="sm" variant="ghost" onClick={() => navigate("/settings")} aria-label="Настройки">
|
||||
⚙
|
||||
</Button>
|
||||
<ThemeToggle />
|
||||
</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 { ThemeProvider } from "./context/ThemeContext";
|
||||
import { AuthProvider } from "./context/AuthContext";
|
||||
import { FontSettingsProvider } from "./context/FontSettingsContext";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import { initErrorLogging } from "./utils/errorLogger";
|
||||
import { registerPwaServiceWorker } from "./hooks/usePwaStatus";
|
||||
import "./index.css";
|
||||
import "./styles/fontSettings.css";
|
||||
|
||||
registerPwaServiceWorker();
|
||||
initErrorLogging();
|
||||
|
|
@ -15,9 +17,11 @@ initErrorLogging();
|
|||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<ErrorBoundary>
|
||||
<RouterProvider router={router} />
|
||||
</ErrorBoundary>
|
||||
<FontSettingsProvider>
|
||||
<ErrorBoundary>
|
||||
<RouterProvider router={router} />
|
||||
</ErrorBoundary>
|
||||
</FontSettingsProvider>
|
||||
</AuthProvider>
|
||||
</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 { NotFoundPage } from "./pages/NotFoundPage";
|
||||
import { ForbiddenPage } from "./pages/ForbiddenPage";
|
||||
import { SettingsPage } from "./pages/SettingsPage";
|
||||
import { useAuth } from "./context/AuthContext";
|
||||
|
||||
/**
|
||||
|
|
@ -66,6 +67,14 @@ export const router = createBrowserRouter([
|
|||
</RequireAuth>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "settings",
|
||||
element: (
|
||||
<RequireAuth>
|
||||
<SettingsPage />
|
||||
</RequireAuth>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "*",
|
||||
element: <NotFoundPage />,
|
||||
|
|
|
|||
|
|
@ -142,12 +142,23 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
|
|||
const extractCity = (addr) => {
|
||||
if (!addr) return "";
|
||||
// 1) explicit marker: г. Ялта, пгт. Куйбышево, etc.
|
||||
const m = addr.match(/(?:г\.\s|гор\.\s|пос\.\s|с\.\s|дер\.\s|пгт\.\s|город\s|село\s|г\s)\s*([А-ЯЁа-яёA-Za-z\-\s]+?)(?:[,\\s]|$)/i);
|
||||
if (m) return m[1].trim();
|
||||
// 2) known city name anywhere in address (case-insensitive)
|
||||
// Word markers (город/село) require space after to avoid matching "Стройгородок"
|
||||
// Dot markers (г./гор./etc) allow zero space (г.Ялта)
|
||||
const m = addr.match(/(?:г\.\s*|гор\.\s*|пос\.\s*|с\.\s*|дер\.\s*|пгт\.\s*|город\s+|село\s+|г\s+)\s*([А-ЯЁа-яёA-Za-z\-\s]+?)(?:[,\s]|$)/i);
|
||||
if (m) {
|
||||
const candidate = m[1].trim();
|
||||
for (const city of CRIMEAN_CITIES) {
|
||||
if (city.toLowerCase() === candidate.toLowerCase()) return city;
|
||||
}
|
||||
// Not a known city — continue to step 2
|
||||
}
|
||||
// 2) known city name via custom word boundary (JS \b doesn't work with Cyrillic)
|
||||
// City must be preceded by start/comma/space/dot and followed by comma/space/end
|
||||
const lower = addr.toLowerCase();
|
||||
for (const city of CRIMEAN_CITIES) {
|
||||
if (lower.includes(city.toLowerCase())) return city;
|
||||
const esc = city.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const re = new RegExp("(^|[,.\\s])" + esc + "(?=[,\\s]|$)", "i");
|
||||
if (re.test(lower)) return city;
|
||||
}
|
||||
// 3) Бахчисарайский р-н → Бахчисарай
|
||||
const district = addr.match(/([А-ЯЁа-яё]+)ский\s*(?:р-н|район)/i);
|
||||
|
|
|
|||
|
|
@ -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