fix: extractCity word boundary + filter persistence in sessionStorage

This commit is contained in:
root 2026-07-03 14:07:13 +00:00
parent 8ade6a6ff3
commit 1d288279c0
21 changed files with 2071 additions and 250 deletions

View File

@ -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) => {

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

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

13
scripts/sms_run_with_db.sh Executable file
View File

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

207
src/AppShell.jsx Normal file
View File

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

View File

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

View File

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

257
src/OrdersTable.jsx Normal file
View File

@ -0,0 +1,257 @@
import { formatDateTime } from "../../utils/formatters";
import { Badge } from "../UI/Badge";
import { Panel } from "../UI/Panel";
import { SkeletonTable } from "../UI/Loading";
import { OrderFilters } from "./OrderFilters";
import {
getOrderGroupDisplayStatusLabel,
getOrderGroupStatusTone,
} from "../../services/orderGroupViews";
const MAX_VISIBLE_INVOICES = 2;
const fmtDate = (d) => {
if (!d) return '';
const [y, m, day] = d.split('-');
if (!y || !m || !day) return d;
return `${day}.${m}.${y}`;
};
const getShipmentIssues = (group) => {
const data = group?.driverShipmentData;
if (!Array.isArray(data) || data.length === 0) return null;
const unshipped = data.filter((i) => !i.shipped);
if (unshipped.length === 0) return null;
return unshipped;
};
const buildGroupSummary = (group) => {
const orderCountLabel = `${group.ordersCount || 0} ${group.ordersCount === 1 ? "заказ" : group.ordersCount < 5 ? "заказа" : "заказов"}`;
const parts = [orderCountLabel];
if (group.deliveryDate) {
const datePart = group.deliveryTime ? `${fmtDate(group.deliveryDate)} · ${group.deliveryTime}` : fmtDate(group.deliveryDate);
parts.push(datePart);
}
if (group.assignedDriverName) {
parts.push(group.assignedDriverName);
}
return parts.join(" · ");
};
const renderOrderNumbers = (group) => {
const numbers = group.allBillNumbers || group.orderNumbers;
if (!Array.isArray(numbers) || !numbers.length) {
return "Номера не указаны";
}
if (numbers.length <= MAX_VISIBLE_INVOICES) {
return numbers.join(", ");
}
const visible = numbers.slice(0, MAX_VISIBLE_INVOICES);
const remaining = numbers.length - MAX_VISIBLE_INVOICES;
return `${visible.join(", ")} +${remaining}`;
};
const getDeliveryTypeLabel = (deliveryType) => {
if (deliveryType === "pickup") return "Самовывоз";
if (deliveryType === "delivery") return "Доставка";
return "—";
};
const renderMobileOrderNumbers = (group) => {
const numbers = group.allBillNumbers || group.orderNumbers;
if (!Array.isArray(numbers) || !numbers.length) {
return "Номера не указаны";
}
if (numbers.length <= MAX_VISIBLE_INVOICES) {
return numbers.join(", ");
}
const visible = numbers.slice(0, MAX_VISIBLE_INVOICES);
const remaining = numbers.length - MAX_VISIBLE_INVOICES;
return (
<>
{visible.join(", ")}
<span className="ml-1 rounded-full bg-[var(--color-accent-soft)] px-1.5 py-0.5 text-xs font-medium text-[var(--color-accent)]">+{remaining}</span>
</>
);
};
export const OrdersTable = ({
orderGroups = [],
selectedOrderGroupId,
onOpenOrder,
filters,
setFilters,
statusOptions,
cities = [],
isLoading = false,
}) => {
if (isLoading) {
return <SkeletonTable rows={5} cols={5} />;
}
return (
<Panel className="p-0 fs-zone-table">
<div className="space-y-4 border-b border-[var(--color-border)] px-5 py-4">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-semibold">Группы доставки</h2>
<p className="text-sm text-[var(--color-text-muted)]">
Поиск по группе, клиенту, телефону и дате доставки.
</p>
</div>
<Badge tone="neutral">{orderGroups.length}</Badge>
</div>
{filters && setFilters ? (
<OrderFilters filters={filters} setFilters={setFilters} statusOptions={statusOptions} cities={cities} />
) : null}
</div>
<div className="space-y-3 p-4 md:hidden">
{!orderGroups.length ? (
<div className="rounded-[28px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
Группы не найдены. Попробуйте изменить поиск или статус.
</div>
) : null}
{orderGroups.map((group) => {
const hasProblem = group.hasDeliveryProblem;
const baseClass = "w-full rounded-[22px] border text-left transition";
const selectedClass = selectedOrderGroupId === group.id
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"
: hasProblem
? "border-[var(--color-danger)] bg-[rgba(201,61,61,0.1)]"
: "border-[var(--color-border)] bg-[var(--color-surface-strong)]";
const allNumbers = group.allBillNumbers || group.orderNumbers || [];
const primaryBill = allNumbers[0] || "—";
const totalCount = allNumbers.length;
return (
<div key={group.id}>
<div className="mb-1 flex items-center gap-2 px-1 text-xs">
<span className="font-medium text-[var(--color-text)]"> {primaryBill}</span>
{totalCount > 1 && (
<span className="rounded-full bg-[var(--color-accent-soft)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent)]">
{totalCount} сч.
</span>
)}
</div>
<button
type="button"
onClick={() => onOpenOrder(group.id)}
className={[baseClass, selectedClass, "flex flex-col items-start justify-start p-4"].join(" ")}
>
<div className="min-w-0 w-full font-medium leading-snug break-words">
{group.displayTitle || group.customerName || group.groupKey}
</div>
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
<span className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold ${group.deliveryType === "pickup" ? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]" : "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]"}`}>
{group.deliveryType === "pickup" ? "🏪 Самовывоз" : "🚚 Доставка"}
</span>
<Badge tone={getOrderGroupStatusTone(group)}>
{getOrderGroupDisplayStatusLabel(group)}
</Badge>
</div>
{group.hasDeliveryProblem && (
<div className="mt-2 rounded-lg border border-[var(--color-warning)] bg-[var(--color-warning-soft)] px-2 py-1 text-xs">
<span className="font-medium text-[var(--color-warning)]"> Проблема с доставкой</span>
{group.deliveryProblemNote && (
<div className="mt-0.5 text-[var(--color-text-muted)]">{group.deliveryProblemNote}</div>
)}
</div>
)}
</button>
</div>
);
})}
</div>
<div className="hidden md:block">
{!orderGroups.length ? (
<div className="px-5 py-6 text-sm text-[var(--color-text-muted)]">
Группы не найдены. Попробуйте изменить поиск или статус.
</div>
) : (
<div className="overflow-x-auto">
<div className="min-w-[1080px]">
<div className="grid grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(100px,0.8fr)_minmax(100px,1fr)_minmax(100px,1fr)_minmax(100px,0.8fr)_minmax(90px,0.8fr)] gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.12em] text-[var(--color-text-muted)]">
<div className="px-3 py-1.5 font-medium">Группа / Клиент</div>
<div className="px-3 py-1.5 font-medium">Счета</div>
<div className="px-3 py-1.5 font-medium">Город</div>
<div className="px-3 py-1.5 font-medium">Статус</div>
<div className="px-3 py-1.5 font-medium">Дата доставки</div>
<div className="px-3 py-1.5 font-medium">Тип</div>
<div className="px-3 py-1.5 font-medium">Водитель</div>
</div>
{orderGroups.map((group) => {
const hasProblem = group.hasDeliveryProblem;
const rowClassName = `grid grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(100px,0.8fr)_minmax(100px,1fr)_minmax(100px,1fr)_minmax(100px,0.8fr)_minmax(90px,0.8fr)] gap-0 w-full border-t border-[var(--color-border)] text-left transition ${
hasProblem
? "bg-[rgba(201,61,61,0.1)] hover:bg-[rgba(201,61,61,0.15)]"
: "hover:bg-[var(--color-accent-soft)]"
} ${selectedOrderGroupId === group.id ? "bg-[var(--color-accent-soft)]" : ""}`;
const billNumbers = group.allBillNumbers || group.orderNumbers || [];
const primaryBill = billNumbers[0] || "—";
const totalBills = billNumbers.length;
return (
<button
key={group.id}
type="button"
className={rowClassName}
onClick={() => onOpenOrder(group.id)}
>
<div className="min-w-0 px-3 py-1.5">
<div className="text-xs font-medium leading-snug break-words" style={{display:"-webkit-box",WebkitLineClamp:2,WebkitBoxOrient:"vertical",overflow:"hidden"}}>{group.displayTitle || group.customerName || group.groupKey}</div>
<div className="mt-0.5 text-[11px] text-[var(--color-text-muted)]">
{group.customerPhone || ""}
</div>
</div>
<div className="px-3 py-1.5">
<div className="text-xs text-[var(--color-text)]">{primaryBill}</div>
{totalBills > 1 && (
<span className="inline-block mt-0.5 rounded-full bg-[var(--color-accent-soft)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent)]">
{totalBills} сч.
</span>
)}
</div>
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
{group.city || "—"}
</div>
<div className="px-3 py-1.5">
<Badge tone={getOrderGroupStatusTone(group)}>
{getOrderGroupDisplayStatusLabel(group)}
</Badge>
</div>
<div className="px-3 py-1.5 text-xs">
{group.deliveryDate ? (
<span>{fmtDate(group.deliveryDate)}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
) : (
<span className="text-[var(--color-text-muted)]"></span>
)}
</div>
<div className="px-3 py-1.5 text-xs">
<span className="inline-flex items-center gap-1">
{group.deliveryType === "pickup" ? "🏪" : "🚚"}
<span className="text-[var(--color-text-muted)]">{group.deliveryType === "pickup" ? "Самовывоз" : "Доставка"}</span>
</span>
</div>
<div className="px-3 py-1.5 text-xs">
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]"></span>}
</div>
</button>
);
})}
</div>
</div>
)}
</div>
</Panel>
);
};

186
src/SettingsPage.jsx Normal file
View File

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

View File

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

View File

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

View File

@ -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>

View File

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

View File

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

101
src/fontSettings.css Normal file
View File

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

View File

@ -11,11 +11,21 @@ import { getErrorMessage } from "../utils/deliveryUtils";
export const useOrderGroups = () => {
const [orderGroups, setOrderGroups] = React.useState(() => []);
const [filters, setFilters] = React.useState({
query: "",
displayStatus: "all",
deliveryType: "",
const FILTERS_STORAGE_KEY = "supersam_order_filters";
const [filters, setFilters] = React.useState(() => {
try {
const saved = 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("");

View File

@ -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>

View File

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

186
src/pages/SettingsPage.jsx Normal file
View File

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

View File

@ -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 />,

View File

@ -142,12 +142,23 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
const extractCity = (addr) => {
if (!addr) return "";
// 1) explicit marker: г. Ялта, пгт. Куйбышево, etc.
const m = addr.match(/(?:г\.\s|гор\.\s|пос\.\s|с\.\s|дер\.\s|пгт\.\s|город\s|село\s|г\s)\s*([А-ЯЁа-яёA-Za-z\-\s]+?)(?:[,\\s]|$)/i);
if (m) return m[1].trim();
// 2) known city name anywhere in address (case-insensitive)
// Word markers (город/село) require space after to avoid matching "Стройгородок"
// Dot markers (г./гор./etc) allow zero space (г.Ялта)
const m = addr.match(/(?:г\.\s*|гор\.\s*|пос\.\s*|с\.\s*|дер\.\s*|пгт\.\s*|город\s+|село\s+|г\s+)\s*([А-ЯЁа-яёA-Za-z\-\s]+?)(?:[,\s]|$)/i);
if (m) {
const candidate = m[1].trim();
for (const city of CRIMEAN_CITIES) {
if (city.toLowerCase() === candidate.toLowerCase()) return city;
}
// Not a known city — continue to step 2
}
// 2) known city name via custom word boundary (JS \b doesn't work with Cyrillic)
// City must be preceded by start/comma/space/dot and followed by comma/space/end
const lower = addr.toLowerCase();
for (const city of CRIMEAN_CITIES) {
if (lower.includes(city.toLowerCase())) return city;
const esc = city.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp("(^|[,.\\s])" + esc + "(?=[,\\s]|$)", "i");
if (re.test(lower)) return city;
}
// 3) Бахчисарайский р-н → Бахчисарай
const district = addr.match(/([А-ЯЁа-яё]+)ский\s*(?:р-н|район)/i);

103
src/styles/fontSettings.css Normal file
View File

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