diff --git a/public/service-worker.js b/public/service-worker.js index 62336ee..93b0259 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -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) => { diff --git a/scripts/backup-pgdump-s3.sh b/scripts/backup-pgdump-s3.sh new file mode 100755 index 0000000..44ccaff --- /dev/null +++ b/scripts/backup-pgdump-s3.sh @@ -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." \ No newline at end of file diff --git a/scripts/sms_run_with_db.sh b/scripts/sms_run_with_db.sh new file mode 100755 index 0000000..19ec9ce --- /dev/null +++ b/scripts/sms_run_with_db.sh @@ -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 "$@" diff --git a/src/AppShell.jsx b/src/AppShell.jsx new file mode 100644 index 0000000..53a162d --- /dev/null +++ b/src/AppShell.jsx @@ -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 ( +
+
+ setShowNotifSettings(false)} + /> +
+
+ ); + } + + return ( +
+
+ {/* Desktop sidebar */} + +
+

+ Панель +

+

Управление доставкой

+
+ +
+ {navItems.map((item) => ( + + ))} +
+ +
+ {onOpenGuide ? ( + + ) : null} + + +
+
+ + {/* Main content area */} +
+ {/* Mobile header */} + +
+
+

+ Рабочая область +

+

+ {sectionMeta?.label || "Панель"} +

+

+ {user.name} · {ROLE_LABELS[user.role] || user.role} +

+
+
+ setShowNotifSettings(true)} + /> + {onOpenGuide ? ( + + ) : null} + + + + +
+
+
+ + {/* Mobile tab navigation — STICKY TOP */} + {shouldShowMobileNav && ( +
+
+ {navItems.map((item) => ( + + ))} +
+
+ )} + + {/* Desktop header */} + +
+
+

+ Рабочая область +

+

{sectionMeta?.label || "Панель"}

+ {sectionMeta?.description ? ( +

+ {sectionMeta.description} +

+ ) : null} +
+
+ setShowNotifSettings(true)} + /> +
+
{user.name}
+
{ROLE_LABELS[user.role] || user.role}
+
+ {onOpenGuide ? ( + + ) : null} + + + +
+
+
+ + {children} +
+
+
+ ); +}; \ No newline at end of file diff --git a/src/FontSettingsContext.jsx b/src/FontSettingsContext.jsx new file mode 100644 index 0000000..9511cf1 --- /dev/null +++ b/src/FontSettingsContext.jsx @@ -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 {children}; +}; + +export const useFontSettings = () => { + const context = useContext(FontSettingsContext); + if (!context) { + throw new Error("useFontSettings must be used within FontSettingsProvider"); + } + return context; +}; \ No newline at end of file diff --git a/src/LogisticsReadinessBoard.jsx b/src/LogisticsReadinessBoard.jsx new file mode 100644 index 0000000..8bda277 --- /dev/null +++ b/src/LogisticsReadinessBoard.jsx @@ -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 = () => ( +
+
Клиент
+
Город
+
Тип
+
Дата доставки
+
Водитель
+
Статус
+
Обновлён
+
+); + +const renderRow = (group, onSelectSet) => ( + +); + +// 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 ( +
+ {/* Section header — drag handle + collapse toggle */} +
+ {/* Drag handle */} + + + {/* Collapse toggle */} + +
+ + {!isCollapsed && ( +
+
+ + {groups.map((g) => renderRow(g, onSelectSet))} +
+
+ )} +
+ ); +}; + +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 ; + } + + return ( +
+ +
+
+

Наборы доставки

+

+ Перетаскивайте секции за ручку слева, чтобы изменить порядок отображения. +

+
+ {totalGroups} групп +
+ + +
+ + {!totalGroups ? ( +
+ По этому поиску ничего не найдено. +
+ ) : ( + + id)} + strategy={verticalListSortingStrategy} + > +
+ {sortedEntries.map(([statusValue, { label, groups }]) => { + const isCollapsed = collapsedSections.has(statusValue); + + return ( + { + setCollapsedSections((prev) => { + const next = new Set(prev); + if (next.has(statusValue)) { + next.delete(statusValue); + } else { + next.add(statusValue); + } + saveCollapsedSections(next); + return next; + }); + }} + onSelectSet={onSelectSet} + /> + ); + })} +
+
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/src/OrdersTable.jsx b/src/OrdersTable.jsx new file mode 100644 index 0000000..f6774f0 --- /dev/null +++ b/src/OrdersTable.jsx @@ -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(", ")} + +{remaining} + + ); +}; + +export const OrdersTable = ({ + orderGroups = [], + selectedOrderGroupId, + onOpenOrder, + filters, + setFilters, + statusOptions, + cities = [], + isLoading = false, +}) => { + if (isLoading) { + return ; + } + + return ( + +
+
+
+

Группы доставки

+

+ Поиск по группе, клиенту, телефону и дате доставки. +

+
+ {orderGroups.length} +
+ + {filters && setFilters ? ( + + ) : null} +
+ +
+ {!orderGroups.length ? ( +
+ Группы не найдены. Попробуйте изменить поиск или статус. +
+ ) : 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 ( +
+
+ № {primaryBill} + {totalCount > 1 && ( + + {totalCount} сч. + + )} +
+ +
+ ); + })} +
+ +
+ {!orderGroups.length ? ( +
+ Группы не найдены. Попробуйте изменить поиск или статус. +
+ ) : ( +
+
+
+
Группа / Клиент
+
Счета
+
Город
+
Статус
+
Дата доставки
+
Тип
+
Водитель
+
+ {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 ( + + ); + })} +
+
+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/src/SettingsPage.jsx b/src/SettingsPage.jsx new file mode 100644 index 0000000..22a2237 --- /dev/null +++ b/src/SettingsPage.jsx @@ -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 ( +
+
+
+ {category.label} +

{category.description}

+
+ + {Math.round(value * 100)}% + +
+
+ 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} + /> + + {value.toFixed(2)}× + +
+
+ ); +}; + +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 ( +
+ {/* Header */} +
+
+

Настройки

+

+ Настройки интерфейса +

+
+ +
+ + {/* Presets */} + +

+ Быстрые пресеты +

+
+ {PRESETS.map((preset, i) => ( + + ))} +
+
+ + {/* Font size sliders */} + +
+

+ Размеры шрифтов +

+ +
+ +
+ {categories.map((cat) => ( + updateCategory(cat.key, v)} + /> + ))} +
+ + {/* Preview */} +
+

Предпросмотр

+
+

+ Заголовок секции +

+
+
+ + Пункт меню + + + Вкладка + +
+
+ + + + + + + + + + + + + +
ДатаСтатус
02.07.2026В работе
+
+
+

+ Текст в карточке — описание доставки или заказа. +

+
+

+ Основной текст интерфейса. +

+

+ мелкая подпись · временная отметка +

+
+
+ +

+ Настройки сохраняются на этом устройстве +

+
+ ); +}; + +export default SettingsPage; \ No newline at end of file diff --git a/src/components/logistics/LogisticsReadinessBoard.jsx b/src/components/logistics/LogisticsReadinessBoard.jsx index d8e774b..eb5e035 100644 --- a/src/components/logistics/LogisticsReadinessBoard.jsx +++ b/src/components/logistics/LogisticsReadinessBoard.jsx @@ -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 = () => ( +
+
Клиент
+
Город
+
Тип
+
Дата доставки
+
Водитель
+
Статус
+
Обновлён
+
+); + +// 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) => ( + +); + +// 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 ( +
+ {/* Section header — drag handle + collapse toggle */} +
+ {/* Drag handle */} + + + {/* Collapse toggle */} + +
+ + {!isCollapsed && ( +
+
+ + {groups.map((g) => renderRow(g, onSelectSet))} +
+
+ )} +
+ ); +}; + 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 = () => ( -
-
Клиент
-
Город
-
Тип
-
Дата доставки
-
Водитель
-
Статус
-
Обновлён
-
- ); + // 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 ; } return ( -
+

Наборы доставки

+

+ Перетаскивайте секции за ручку слева, чтобы изменить порядок отображения. +

{totalGroups} групп
@@ -175,125 +357,45 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO По этому поиску ничего не найдено.
) : ( -
- {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); + + id)} + strategy={verticalListSortingStrategy} + > +
+ {sortedEntries.map(([statusValue, { label, groups }]) => { + const isCollapsed = collapsedSections.has(statusValue); - return ( -
- {/* Section header — drag handle + collapse */} -
{ 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)]`} - > - ⋮⋮ - - - - 0 ? "neutral" : "muted"}>{label} - {groups.length} -
- - {!isCollapsed && ( -
- {/* Horizontal scroll wrapper — min-w so columns don't squish */} -
- - {groups.map((group) => ( - - ))} -
-
- )} -
- ); - })} -
- )} - {totalPages > 1 && ( - + return ( + { + setCollapsedSections((prev) => { + const next = new Set(prev); + if (next.has(statusValue)) { + next.delete(statusValue); + } else { + next.add(statusValue); + } + saveCollapsedSections(next); + return next; + }); + }} + onSelectSet={onSelectSet} + /> + ); + })} +
+ + )}
); diff --git a/src/components/orders/OrderDetailPanel.jsx b/src/components/orders/OrderDetailPanel.jsx index 16c5284..8900612 100644 --- a/src/components/orders/OrderDetailPanel.jsx +++ b/src/components/orders/OrderDetailPanel.jsx @@ -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 = ({ /> )} + +
diff --git a/src/components/orders/SmsStatusCard.jsx b/src/components/orders/SmsStatusCard.jsx index 3f56c95..c6b1f44 100644 --- a/src/components/orders/SmsStatusCard.jsx +++ b/src/components/orders/SmsStatusCard.jsx @@ -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 }) => {
{/* Timeline */} -
+
{/* 1st SMS */}
@@ -201,15 +205,19 @@ export const SmsStatusCard = ({ order, userRole }) => { {/* 2nd SMS */}
- +
2-е SMS
{hasSecondSms ? ( -
{fmtTime(secondSmsAt)}
+
{fmtTime(secondSmsAt)} ✓ доставлено
+ ) : notifStatus === "second_sms_sending" && hasSmsSent ? ( +
{fmtTime(smsSentAt)} · отправлено, ждём подтверждения…
) : notifStatus === "first_sms_sent" && countdown ? (
отправка через {countdown}
+ ) : notifStatus === "first_sms_sent" ? ( +
ожидает отправки
) : (
)} @@ -240,10 +248,10 @@ export const SmsStatusCard = ({ order, userRole }) => { {/* SMS log for this group */} {smsLog.length > 0 && (
-
История SMS
-
+
История SMS
+
{smsLog.map((log) => ( -
+
{fmtTime(log.created_at)} {log.status === "delivered" ? "доставлено" : log.status === "sent" ? "отправлено" : log.status === "checking" ? "проверка" : log.status === "expired" ? "истёк" : log.status} diff --git a/src/context/FontSettingsContext.jsx b/src/context/FontSettingsContext.jsx new file mode 100644 index 0000000..9511cf1 --- /dev/null +++ b/src/context/FontSettingsContext.jsx @@ -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 {children}; +}; + +export const useFontSettings = () => { + const context = useContext(FontSettingsContext); + if (!context) { + throw new Error("useFontSettings must be used within FontSettingsProvider"); + } + return context; +}; \ No newline at end of file diff --git a/src/fontSettings.css b/src/fontSettings.css new file mode 100644 index 0000000..6f71344 --- /dev/null +++ b/src/fontSettings.css @@ -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)); } \ No newline at end of file diff --git a/src/hooks/useOrderGroups.js b/src/hooks/useOrderGroups.js index 1cecf6c..f0e63ee 100644 --- a/src/hooks/useOrderGroups.js +++ b/src/hooks/useOrderGroups.js @@ -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(""); diff --git a/src/layouts/AppShell.jsx b/src/layouts/AppShell.jsx index 85626c8..0e70315 100644 --- a/src/layouts/AppShell.jsx +++ b/src/layouts/AppShell.jsx @@ -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 = ({
{/* Desktop sidebar */} - +

Панель @@ -81,6 +83,9 @@ export const AppShell = ({ {isGuideOpen ? "К рабочей области" : "Справка"} ) : null} + @@ -88,9 +93,9 @@ export const AppShell = ({ {/* Main content area */} -

+
{/* Mobile header */} - +

@@ -117,6 +122,9 @@ export const AppShell = ({ ) : null} + ) : null} +

diff --git a/src/main.jsx b/src/main.jsx index dd5a49f..391bef8 100644 --- a/src/main.jsx +++ b/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( - - - + + + + + , ); \ No newline at end of file diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx new file mode 100644 index 0000000..22a2237 --- /dev/null +++ b/src/pages/SettingsPage.jsx @@ -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 ( +
+
+
+ {category.label} +

{category.description}

+
+ + {Math.round(value * 100)}% + +
+
+ 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} + /> + + {value.toFixed(2)}× + +
+
+ ); +}; + +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 ( +
+ {/* Header */} +
+
+

Настройки

+

+ Настройки интерфейса +

+
+ +
+ + {/* Presets */} + +

+ Быстрые пресеты +

+
+ {PRESETS.map((preset, i) => ( + + ))} +
+
+ + {/* Font size sliders */} + +
+

+ Размеры шрифтов +

+ +
+ +
+ {categories.map((cat) => ( + updateCategory(cat.key, v)} + /> + ))} +
+ + {/* Preview */} +
+

Предпросмотр

+
+

+ Заголовок секции +

+
+
+ + Пункт меню + + + Вкладка + +
+
+ + + + + + + + + + + + + +
ДатаСтатус
02.07.2026В работе
+
+
+

+ Текст в карточке — описание доставки или заказа. +

+
+

+ Основной текст интерфейса. +

+

+ мелкая подпись · временная отметка +

+
+
+ +

+ Настройки сохраняются на этом устройстве +

+
+ ); +}; + +export default SettingsPage; \ No newline at end of file diff --git a/src/router.jsx b/src/router.jsx index 20f16de..1ad44e4 100644 --- a/src/router.jsx +++ b/src/router.jsx @@ -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([ ), }, + { + path: "settings", + element: ( + + + + ), + }, { path: "*", element: , diff --git a/src/services/supabase/orderGroupRepository.js b/src/services/supabase/orderGroupRepository.js index 565528f..40ae612 100644 --- a/src/services/supabase/orderGroupRepository.js +++ b/src/services/supabase/orderGroupRepository.js @@ -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); diff --git a/src/styles/fontSettings.css b/src/styles/fontSettings.css new file mode 100644 index 0000000..6a1c6b2 --- /dev/null +++ b/src/styles/fontSettings.css @@ -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)); } \ No newline at end of file