diff --git a/.gitignore b/.gitignore index 1d55b7d..244c37a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ dist .superpowers .ruff_cache volumes/db/data/ +__pycache__/ +*.pyc +*.bak diff --git a/docker-compose.app.yml b/docker-compose.app.yml index c07f5ce..cefa482 100644 --- a/docker-compose.app.yml +++ b/docker-compose.app.yml @@ -18,15 +18,15 @@ services: - traefik.http.routers.supersam-app.tls.certresolver=letsencrypt - traefik.http.routers.supersam-app.service=supersam-app - traefik.http.services.supersam-app.loadbalancer.server.port=80 - - traefik.http.routers.supersam-app-http.rule=Host(`dost.supersamsev.ru`) - - traefik.http.routers.supersam-app-http.entryPoints=http - - traefik.http.routers.supersam-app-http.middlewares=redirect-to-https - - traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https - - traefik.http.middlewares.redirect-to-https.redirectscheme.permanent=true - traefik.http.middlewares.supersam-sec.headers.customresponseheaders.X-Content-Type-Options=nosniff - traefik.http.middlewares.supersam-sec.headers.customresponseheaders.X-Frame-Options=DENY - traefik.http.middlewares.supersam-sec.headers.customresponseheaders.Referrer-Policy=strict-origin-when-cross-origin - traefik.http.routers.supersam-app.middlewares=supersam-sec + - traefik.http.routers.supersam-app-http.rule=Host(`dost.supersamsev.ru`) + - traefik.http.routers.supersam-app-http.entryPoints=http + - traefik.http.routers.supersam-app-http.middlewares=supersam-redirect + - traefik.http.middlewares.supersam-redirect.redirectscheme.scheme=https + - traefik.http.middlewares.supersam-redirect.redirectscheme.permanent=true networks: coolify: 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/scripts/sms_timer_manager.sh b/scripts/sms_timer_manager.sh new file mode 100755 index 0000000..5302c58 --- /dev/null +++ b/scripts/sms_timer_manager.sh @@ -0,0 +1,84 @@ +#!/bin/bash +# sms_timer_manager.sh — reconciles DB flags with systemd timers +# Runs every 1 min via cron: +# 1. timer_active → enable/disable systemd timer +# 2. run_requested → run campaign script immediately (script resets flag itself) + +set -euo pipefail + +DB_HOST="10.0.4.12" +DB_PORT="5432" +DB_NAME="postgres" +DB_USER="supabase_admin" +DB_PASS="4fe80bb21c7c3d17a8d8b226adf7a479" + +declare -A TIMERS=( + ["first_sms"]="sms-first-campaign.timer" + ["second_sms"]="sms-second-campaign.timer" + ["manual"]="sms-manual-campaign.timer" + ["paid_storage"]="sms-paid-storage-campaign.timer" +) +declare -A SERVICES=( + ["first_sms"]="sms-first-campaign.service" + ["second_sms"]="sms-second-campaign.service" + ["manual"]="sms-manual-campaign.service" + ["paid_storage"]="sms-paid-storage-campaign.service" +) +declare -A SCRIPTS=( + ["first_sms"]="/opt/supersam/scripts/sms_first_campaign.py" + ["second_sms"]="/opt/supersam/scripts/sms_second_campaign.py" + ["manual"]="/opt/supersam/scripts/sms_manual_campaign.py" + ["paid_storage"]="/opt/supersam/scripts/sms_paid_storage_campaign.py" +) + +QUERY="SELECT campaign_type, timer_active, run_requested FROM sms_campaign_settings" +RESULTS=$(PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -A -F '|' -c "$QUERY" 2>/dev/null || echo "") + +if [ -z "$RESULTS" ]; then + echo "$(date): Failed to query DB, skipping" + exit 0 +fi + +while IFS='|' read -r campaign_type timer_active run_requested; do + [ -z "$campaign_type" ] && continue + timer_name="${TIMERS[$campaign_type]:-}" + service_name="${SERVICES[$campaign_type]:-}" + script_path="${SCRIPTS[$campaign_type]:-}" + [ -z "$timer_name" ] && continue + + if ! systemctl list-unit-files "$timer_name" 2>/dev/null | grep -q "$timer_name"; then + continue + fi + + # ── 1. Timer enable/disable ── + is_active=$(systemctl is-active "$timer_name" 2>/dev/null || echo "inactive") + + if [ "$timer_active" = "t" ] || [ "$timer_active" = "true" ]; then + if [ "$is_active" != "active" ]; then + systemctl enable --now "$timer_name" 2>/dev/null + echo "$(date): ENABLED $timer_name for $campaign_type" + fi + else + if [ "$is_active" = "active" ]; then + systemctl disable --now "$timer_name" 2>/dev/null + echo "$(date): DISABLED $timer_name for $campaign_type" + fi + fi + + # ── 2. Run requested → immediate execution ── + # Don't reset flag here — script reads it from DB and resets after processing + if [ "$run_requested" = "t" ] || [ "$run_requested" = "true" ]; then + is_running=$(systemctl is-active "$service_name" 2>/dev/null || echo "inactive") + if [ "$is_running" = "active" ]; then + echo "$(date): $campaign_type already running, skipping run_requested" + continue + fi + + echo "$(date): RUN REQUESTED — starting $campaign_type immediately" + systemctl start "$service_name" 2>/dev/null || { + nohup python3 "$script_path" >> "/var/log/supersam-sms-${campaign_type}.log" 2>&1 & + echo "$(date): Started $campaign_type via nohup fallback" + } + fi + +done <<< "$RESULTS" \ No newline at end of file diff --git a/src/AppShell.jsx b/src/AppShell.jsx new file mode 100644 index 0000000..2a1a744 --- /dev/null +++ b/src/AppShell.jsx @@ -0,0 +1,216 @@ +import React from "react"; +import { useNavigate } from "react-router-dom"; +import { ROLE_LABELS } from "../constants/roles"; +import { Badge } from "../components/UI/Badge"; +import { Button } from "../components/UI/Button"; +import { Panel } from "../components/UI/Panel"; +import { ThemeToggle } from "../components/UI/ThemeToggle"; +import { PwaInstallButton } from "../components/UI/PwaInstallButton"; +import { NotificationBell } from "../components/notifications/NotificationBell"; +import { NotificationSettings } from "../components/notifications/NotificationSettings"; + +export const AppShell = ({ + user, + onInstallApp, + isInstalled, + isInstallAvailable, + onSignOut, + onOpenGuide, + isGuideOpen = false, + navItems, + activeSection, + onSectionChange, + sectionMeta, + notifications = [], + unreadCount = 0, + onMarkNotificationRead, + onMarkAllNotificationsRead, + children, +}) => { + const shouldShowMobileNav = !isGuideOpen && navItems.length > 1; + const [showNotifSettings, setShowNotifSettings] = React.useState(false); + + if (showNotifSettings) { + return ( +
+
+ 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/UI/Pagination.jsx b/src/components/UI/Pagination.jsx new file mode 100644 index 0000000..481a59b --- /dev/null +++ b/src/components/UI/Pagination.jsx @@ -0,0 +1,80 @@ +import React from "react"; + +/** + * Universal pagination control. + * Usage: + */ +export const Pagination = ({ page, totalPages, onChange, itemsPerPage, totalItems }) => { + if (totalPages <= 1) return null; + + const from = (page - 1) * (itemsPerPage || 0) + 1; + const to = Math.min(page * (itemsPerPage || 0), totalItems || 0); + + const pages = []; + const maxButtons = 7; + let start = Math.max(1, page - 3); + let end = Math.min(totalPages, start + maxButtons - 1); + if (end - start < maxButtons - 1) start = Math.max(1, end - maxButtons + 1); + + for (let i = start; i <= end; i++) pages.push(i); + + return ( +
+ + {totalItems != null && itemsPerPage + ? `${from}–${to} из ${totalItems}` + : `Стр. ${page} из ${totalPages}`} + +
+ + {start > 1 && ( + <> + + {start > 2 && } + + )} + {pages.map((p) => ( + + ))} + {end < totalPages && ( + <> + {end < totalPages - 1 && } + + + )} + +
+
+ ); +}; \ No newline at end of file diff --git a/src/components/admin/AdminDashboard.jsx b/src/components/admin/AdminDashboard.jsx index ecc21a2..b074e1d 100644 --- a/src/components/admin/AdminDashboard.jsx +++ b/src/components/admin/AdminDashboard.jsx @@ -1,8 +1,9 @@ /** * @file AdminDashboard.jsx - * @description Admin analytics dashboard. Displays KPI cards, status pie chart, - * daily trend line, confirmation funnel, SMS stats, and driver performance - * bar chart. Supports period selection (1d/7d/30d/all) and mobile layout. + * @description Admin analytics dashboard. Redesigned with responsive grid layout + * that uses desktop width effectively. KPI cards, status pie, daily trend, + * confirmation funnel, SMS stats, driver performance, pickup stats. + * Period label shown in section subtitles. */ import React, { useState, useEffect } from 'react'; import { @@ -10,7 +11,6 @@ import { PieChart, Pie, Cell, Legend, LineChart, Line, CartesianGrid, } from 'recharts'; import { Panel } from '../UI/Panel'; -import { Badge } from '../UI/Badge'; import { SegmentedTabs } from '../UI/SegmentedTabs'; import { Skeleton } from '../UI/Loading'; import { useAdminStats } from '../../hooks/useAdminStats'; @@ -21,7 +21,7 @@ import { PickupStatsPanel } from './PickupStatsPanel'; const useIsMobile = () => { const [mobile, setMobile] = useState(false); useEffect(() => { - const mq = window.matchMedia('(max-width: 640px)'); + const mq = window.matchMedia('(max-width: 768px)'); setMobile(mq.matches); const handler = (e) => setMobile(e.matches); mq.addEventListener('change', handler); @@ -38,7 +38,8 @@ const STATUS_COLORS = { driver_assigned: '#3b82f6', loaded: '#6366f1', on_route: '#8b5cf6', - delivered: '#10b981', + delivered: '#22c55e', + picked_up: '#14b8a6', paid_storage: '#06b6d4', problem: '#ef4444', cancelled: '#64748b', @@ -53,6 +54,7 @@ const STATUS_LABELS = { loaded: 'Загружено', on_route: 'В пути', delivered: 'Доставлено', + picked_up: 'Вывезено', paid_storage: 'Оплаченное хранение', problem: 'Проблема', cancelled: 'Отменено', @@ -64,9 +66,16 @@ const PERIOD_OPTIONS = [ { key: '1d', label: 'Сегодня' }, { key: '7d', label: '7 дней' }, { key: '30d', label: '30 дней' }, - { key: 'all', label: 'Все' }, + { key: 'all', label: 'Всё время' }, ]; +const PERIOD_LABELS = { + '1d': 'за сегодня', + '7d': 'за 7 дней', + '30d': 'за 30 дней', + 'all': 'за всё время', +}; + // ── Custom Recharts Tooltip ───────────────────────────────────────────────── const CustomTooltip = ({ active, payload, label: tooltipLabel }) => { if (!active || !payload?.length) return null; @@ -85,44 +94,65 @@ const CustomTooltip = ({ active, payload, label: tooltipLabel }) => { ); }; +// ── KPI Card ──────────────────────────────────────────────────────────────── +const KpiCard = ({ label, value, color, mobile }) => ( + +
+ {label} +
+
+ {value ?? '—'} +
+
+); + +// ── Section Header ────────────────────────────────────────────────────────── +const SectionHeader = ({ title, subtitle, mobile }) => ( +
+

+ {title} +

+ {subtitle && ( +
+ {subtitle} +
+ )} +
+); + // ── AdminDashboard Component ─────────────────────────────────────────────── export const AdminDashboard = () => { - // ── State & Hooks ───────────────────────────────────────────────────────── const [period, setPeriod] = useState('7d'); const mobile = useIsMobile(); const { stats, statusDist, dailyTrend, driverStats, economics, isLoading, error, refetch } = useAdminStats(period); const { stats: pickupStats, isLoading: pickupLoading } = usePickupStats(period); - // ── Responsive Layout Values (must be before early returns) ──────────────── - const chartHeight = mobile ? 200 : 240; - const kpiMin = mobile ? '80px' : '110px'; - const chartGridCols = mobile ? '1fr' : '1fr 2fr'; - const driverLabelWidth = mobile ? 80 : 120; - const fontSize = mobile ? { xs: '0.6rem', s: '0.7rem', m: '0.78rem', l: '0.85rem', xl: '1rem' } - : { xs: '0.65rem', s: '0.68rem', m: '0.78rem', l: '0.85rem', xl: '1.1rem' }; + const periodLabel = PERIOD_LABELS[period] || ''; + const chartHeight = mobile ? 200 : 280; + const fontSize = mobile ? { xs: '0.6rem', s: '0.7rem', m: '0.78rem', l: '0.85rem' } + : { xs: '0.72rem', s: '0.78rem', m: '0.85rem', l: '0.95rem' }; - // ── Loading / Error States ───────────────────────────────────────────────── + // ── Loading State ───────────────────────────────────────────────────────── if (isLoading) { return ( -
+
-
- {Array.from({ length: 6 }).map((_, i) => ( - +
+ {Array.from({ length: 7 }).map((_, i) => ( + - + ))}
- - -
- -
-
); } @@ -145,70 +175,83 @@ export const AdminDashboard = () => { status: s.delivery_status, })).filter(d => d.value > 0); - // ── Trend & Driver Data ─────────────────────────────────────────────────── const trendData = (dailyTrend || []).map(d => ({ date: d.date ? new Date(d.date).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }) : '', - delivered: d.delivered || 0, total: d.total || 0, problems: d.problems || 0, + delivered: d.delivered || 0, picked_up: d.picked_up || 0, total: d.total || 0, problems: d.problems || 0, })); const driverData = (driverStats || []).map(d => ({ name: d.driver_name || 'Неизвестный', - total: d.total || 0, delivered: d.delivered || 0, problems: d.problems || 0, + total: d.total || 0, delivered: d.delivered || 0, picked_up: d.picked_up || 0, problems: d.problems || 0, })); - // Funnel: ALWAYS show all steps, even with 0 values - // ── Funnel Data ──────────────────────────────────────────────────────────── + // ── Funnel: Real order path ─────────────────────────────────────────────── + const completedTotal = (econ.full_chain_client || 0) + (econ.client_date_no_driver || 0) + + (econ.manager_date_completed || 0) + (econ.bypassed_completed || 0); + const funnelSteps = [ - { label: 'Согласовано после 1-й SMS', value: econ.confirmed_after_sms1 || 0, color: '#22c55e' }, - { label: 'Согласовано после 2-й SMS', value: econ.confirmed_after_sms2 || 0, color: '#14b8a6' }, - { label: 'Согласовано вручную', value: econ.confirmed_via_manual || 0, color: '#eab308' }, - { label: 'Ручное назначение даты', value: econ.manual_date_set_count || 0, color: '#f97316' }, - { label: 'Платное хранение', value: econ.paid_storage_count || 0, color: '#06b6d4' }, - { label: 'Отмена', value: econ.cancelled_count || 0, color: '#ef4444' }, + { label: 'Всего заказов', value: econ.total_groups || 0, color: '#94a3b8' }, + { label: 'SMS отправлено', value: econ.sms_sent || 0, color: '#3b82f6' }, + { label: 'Полная цепочка', value: econ.full_chain_client || 0, color: '#22c55e' }, + { label: 'Клиент выбрал дату', value: econ.client_date_no_driver || 0, color: '#14b8a6' }, + { label: 'Менеджер назначил дату', value: econ.manager_date_completed || 0, color: '#8b5cf6' }, + { label: 'В обход (без даты)', value: econ.bypassed_completed || 0, color: '#f97316' }, + { label: 'Застряло в ручном', value: econ.stuck_in_manual || 0, color: '#eab308' }, + { label: 'В работе', value: econ.in_progress || 0, color: '#3b82f6' }, + { label: 'Отменено', value: econ.cancelled_count || 0, color: '#64748b' }, ]; - // ── Render ───────────────────────────────────────────────────────────────── - return ( -
+ // ── Grid Layout ─────────────────────────────────────────────────────────── + // Desktop: 12-col grid. Mobile: single column. + const gridCols = mobile ? '1fr' : 'repeat(12, 1fr)'; + const colSpan = (n) => mobile ? '1 / -1' : `span ${n}`; - {/* Period selector */} + return ( +
+ + {/* ── Header + Period selector ─────────────────────────────────────────── */}
-

Аналитика

-

Статистика по доставкам

+

+ Аналитика +

+

+ Статистика по доставкам {periodLabel} +

- {/* KPI — centered on mobile */} -
- {[ - { label: 'Всего', val: totalGroups }, - { label: 'Ожидает', val: sv.pending }, - { label: 'В работе', val: sv.in_progress }, - { label: 'Доставлено', val: sv.delivered }, - { label: 'Проблемы', val: sv.problem }, - { label: '% доставки', val: sv.delivery_rate != null ? sv.delivery_rate + '%' : '—' }, - ].map((kpi, i) => ( - -
{kpi.label}
-
{kpi.val ?? '—'}
-
- ))} + {/* ── KPI Cards ─────────────────────────────────────────────────────────── */} +
+ + + + + + + +
- {/* Pie + Line — stacked on mobile, side-by-side on desktop */} -
- -

По статусам

+ {/* ── Main Grid: Charts + Tables ───────────────────────────────────────── */} +
+ + {/* Status Pie — 4 cols desktop */} + + {statusPieData.length === 0 ? ( -
Нет данных
+
Нет данных
) : ( {statusPieData.map(entry => ( @@ -221,155 +264,160 @@ export const AdminDashboard = () => { )}
- -

Тренд по дням

+ {/* Daily Trend — 8 cols desktop */} + + {trendData.length === 0 ? ( -
Нет данных
+
Нет данных
) : ( - - + + } /> + )}
-
- {/* Status table */} - -

Все статусы

- {statusPieData.length === 0 ? ( -
Нет данных
- ) : ( -
-
-
Статус
Кол-во
Доля
-
- {statusPieData.map(s => { - const pct = totalGroups > 0 ? ((s.value / totalGroups) * 100).toFixed(1) : 0; - return ( -
-
-
{s.name}
-
{s.value}
-
{pct}%
-
- ); - })} -
- )} - - - {/* Воронка согласования — ALL steps always visible */} - -

Воронка согласования

- {totalGroups === 0 ? ( -
Нет данных
- ) : ( -
- {funnelSteps.map((step, i) => { - const pct = totalGroups > 0 ? Math.round((step.value / totalGroups) * 100) : 0; - const widthPct = step.value > 0 ? Math.max(15, (step.value / totalGroups) * 100) : 15; - return ( -
-
- {step.value} -
-
0 ? step.color : 'var(--color-border, #334155)', - borderRadius: '4px', display: 'flex', alignItems: 'center', justifyContent: 'center', - transition: 'width 0.4s ease', minWidth: '40px', maxWidth: '100%', - opacity: step.value > 0 ? 1 : 0.5, + {/* Status Table — 4 cols desktop */} + + + {statusPieData.length === 0 ? ( +
Нет данных
+ ) : ( +
+
+
Статус
Кол-во
Доля
+
+ {statusPieData.map(s => { + const pct = totalGroups > 0 ? ((s.value / totalGroups) * 100).toFixed(1) : 0; + return ( +
- 0 ? '#fff' : 'var(--color-text-muted)', textShadow: step.value > 0 ? '0 1px 2px rgba(0,0,0,0.3)' : 'none' }}> - {pct}% - +
+
{s.name}
+
{s.value}
+
{pct}%
-
- {step.label} + ); + })} +
+ )} + + + {/* Funnel — 4 cols desktop */} + + + {totalGroups === 0 ? ( +
Нет данных
+ ) : ( +
+ {funnelSteps.map((step, i) => { + const pct = totalGroups > 0 ? Math.round((step.value / totalGroups) * 100) : 0; + const widthPct = step.value > 0 ? Math.max(15, (step.value / totalGroups) * 100) : 15; + return ( +
+
+ {step.value} +
+
0 ? step.color : 'var(--color-border, #334155)', + borderRadius: '4px', display: 'flex', alignItems: 'center', justifyContent: 'center', + transition: 'width 0.4s ease', minWidth: '40px', maxWidth: '100%', + opacity: step.value > 0 ? 1 : 0.5, + }}> + 0 ? '#fff' : 'var(--color-text-muted)', textShadow: step.value > 0 ? '0 1px 2px rgba(0,0,0,0.3)' : 'none' }}> + {pct}% + +
+
+ {step.label} +
+ {i < funnelSteps.length - 1 && ( +
+ )}
- {i < funnelSteps.length - 1 && ( -
- )} + ); + })} + +
+
+
Полная цепочка
+
{econ.full_chain_pct ?? 0}%
+
+
+
В обход
+
{econ.bypassed_pct ?? 0}%
+
+
+
Завершено
+
{completedTotal}
- ); - })} - -
-
-
Автосогласование
-
{econ.auto_confirm_pct ?? 0}%
-
-
-
Ручное вмешательство
-
{econ.manual_intervention_pct ?? 0}%
-
-
-
Всего согласовано
-
{econ.confirmed_auto_total ?? 0}
+ )} + + + {/* SMS + Drivers side by side — 4 cols each on desktop */} + + +
+ {[ + { label: 'SMS 1', val: econ.sms1_sent_count ?? 0 }, + { label: 'SMS 2', val: econ.sms2_sent_count ?? 0 }, + { label: 'Всего', val: (econ.sms1_sent_count || 0) + (econ.sms2_sent_count || 0) }, + ].map((item, i) => ( +
+
{item.label}
+
{item.val}
+
+ ))}
- )} -
+ - {/* SMS */} - -

SMS

-
- {[ - { label: 'SMS 1', val: econ.sms1_sent_count ?? 0 }, - { label: 'SMS 2', val: econ.sms2_sent_count ?? 0 }, - { label: 'Всего', val: (econ.sms1_sent_count || 0) + (econ.sms2_sent_count || 0) }, - ].map((item, i) => ( -
-
{item.label}
-
{item.val}
-
- ))} + {/* Drivers — 8 cols desktop */} + + + {driverData.length === 0 ? ( +
Нет данных
+ ) : ( + + + + + } /> + + + + + + + )} +
+ + {/* Pickup Stats — full width (12 cols) */} +
+
- - - {/* Pickup Stats */} - - - {/* Drivers */} - -

По водителям

- {driverData.length === 0 ? ( -
Нет данных
- ) : ( - - - - - } /> - - - - - - )} -
+
); }; \ No newline at end of file diff --git a/src/components/admin/BusinessSchedulePanel.jsx b/src/components/admin/BusinessSchedulePanel.jsx new file mode 100644 index 0000000..76bc703 --- /dev/null +++ b/src/components/admin/BusinessSchedulePanel.jsx @@ -0,0 +1,255 @@ +/** + * @file BusinessSchedulePanel.jsx + * @description Admin panel for configuring work days: delivery, pickup, SMS. + * Days stored in business_schedule_settings table as INT[] (1=Mon ... 7=Sun). + */ +import React, { useState, useEffect, useCallback } from "react"; +import { Panel } from "../UI/Panel"; +import { Button } from "../UI/Button"; +import { Badge } from "../UI/Badge"; +import { supabase } from "../../supabaseClient"; + +const DAY_LABELS = [ + { num: 1, label: "Пн", full: "Понедельник" }, + { num: 2, label: "Вт", full: "Вторник" }, + { num: 3, label: "Ср", full: "Среда" }, + { num: 4, label: "Чт", full: "Четверг" }, + { num: 5, label: "Пт", full: "Пятница" }, + { num: 6, label: "Сб", full: "Суббота" }, + { num: 7, label: "Вс", full: "Воскресенье" }, +]; + +const DEFAULT_SCHEDULE = { + deliveryDays: [1, 2, 3, 4, 5], + pickupDays: [1, 2, 3, 4, 5], + smsDays: [1, 2, 3, 4, 5], +}; + +const DayPills = ({ selectedDays, onToggle, accentColor }) => { + const toggle = (num) => { + if (selectedDays.includes(num)) { + onToggle(selectedDays.filter((d) => d !== num).sort()); + } else { + onToggle([...selectedDays, num].sort()); + } + }; + + return ( +
+ {DAY_LABELS.map((day) => { + const isSelected = selectedDays.includes(day.num); + return ( + + ); + })} +
+ ); +}; + +export const BusinessSchedulePanel = () => { + const [schedule, setSchedule] = useState(DEFAULT_SCHEDULE); + const [original, setOriginal] = useState(DEFAULT_SCHEDULE); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(""); + const [savedAt, setSavedAt] = useState(null); + + const loadSchedule = useCallback(async () => { + setIsLoading(true); + setError(""); + try { + const { data, error: rpcError } = await supabase.rpc("get_business_schedule"); + if (rpcError) throw rpcError; + if (data?.ok) { + const loaded = { + deliveryDays: data.deliveryDays || DEFAULT_SCHEDULE.deliveryDays, + pickupDays: data.pickupDays || DEFAULT_SCHEDULE.pickupDays, + smsDays: data.smsDays || DEFAULT_SCHEDULE.smsDays, + }; + setSchedule(loaded); + setOriginal(loaded); + } + } catch (e) { + setError(e instanceof Error ? e.message : "Не удалось загрузить расписание"); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + loadSchedule(); + }, [loadSchedule]); + + const hasChanges = + JSON.stringify(schedule.deliveryDays) !== JSON.stringify(original.deliveryDays) || + JSON.stringify(schedule.pickupDays) !== JSON.stringify(original.pickupDays) || + JSON.stringify(schedule.smsDays) !== JSON.stringify(original.smsDays); + + const handleSave = async () => { + if (!hasChanges) return; + + // Validate at least one day per category + if (schedule.deliveryDays.length === 0 || schedule.pickupDays.length === 0 || schedule.smsDays.length === 0) { + setError("Каждая категория должна иметь хотя бы один рабочий день"); + return; + } + + setIsSaving(true); + setError(""); + try { + const { data, error: rpcError } = await supabase.rpc("update_business_schedule", { + p_delivery_days: schedule.deliveryDays, + p_pickup_days: schedule.pickupDays, + p_sms_days: schedule.smsDays, + }); + if (rpcError) throw rpcError; + if (data?.ok) { + setOriginal({ ...schedule }); + setSavedAt(new Date()); + } + } catch (e) { + setError(e instanceof Error ? e.message : "Не удалось сохранить расписание"); + } finally { + setIsSaving(false); + } + }; + + const formatDays = (days) => { + return days + .sort((a, b) => a - b) + .map((d) => DAY_LABELS.find((dl) => dl.num === d)?.label) + .filter(Boolean) + .join(", "); + }; + + if (isLoading) { + return ( + +

Загрузка расписания…

+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Расписание

+

Рабочие дни

+

+ Настройте, в какие дни доступны доставка, самовывоз и отправка SMS-приглашений. +

+
+ {savedAt && !hasChanges && ( + Сохранено + )} +
+ + {/* Delivery days */} + +
+ 🚚 +
+

+ Доставка +

+

+ Дни, в которые клиент может выбрать доставку. В остальные дни доставка не предлагается. +

+
+
+ setSchedule((s) => ({ ...s, deliveryDays: days }))} + /> +

+ Выбрано: {formatDays(schedule.deliveryDays) || "—"} +

+
+ + {/* Pickup days */} + +
+ 🏪 +
+

+ Самовывоз +

+

+ Дни, в которые клиент может выбрать самовывоз. В остальные дни самовывоз не предлагается. +

+
+
+ setSchedule((s) => ({ ...s, pickupDays: days }))} + /> +

+ Выбрано: {formatDays(schedule.pickupDays) || "—"} +

+
+ + {/* SMS days */} + +
+ 📨 +
+

+ SMS-приглашения +

+

+ Дни, в которые отправляются SMS-приглашения клиентам. В нерабочие дни SMS не отправляются (проверка статусов работает круглосуточно). +

+
+
+ setSchedule((s) => ({ ...s, smsDays: days }))} + /> +

+ Выбрано: {formatDays(schedule.smsDays) || "—"} +

+
+ + {/* Save */} +
+ + {hasChanges && !isSaving && ( + + )} +
+ + {error && ( + + {error} + + )} +
+ ); +}; \ No newline at end of file diff --git a/src/components/admin/PickupStatsPanel.jsx b/src/components/admin/PickupStatsPanel.jsx index a25ff2a..e8074e4 100644 --- a/src/components/admin/PickupStatsPanel.jsx +++ b/src/components/admin/PickupStatsPanel.jsx @@ -11,6 +11,9 @@ const PICKUP_COLORS = { saturday: '#06b6d4', pickup: '#f59e0b', delivery: '#6366f1', + picked_up: '#14b8a6', + pending: '#94a3b8', + manual: '#eab308', }; const CustomTooltip = ({ active, payload }) => { @@ -34,17 +37,30 @@ const CustomTooltip = ({ active, payload }) => { const ProgressBar = ({ label, value, max, color, fontSize, mobile }) => { const pct = max > 0 ? Math.max(0, (value / max) * 100) : 0; return ( -
-
{label}
-
+
+
{label}
+
0 ? '4px' : '0' }} />
-
{value}
+
{value}
); }; -export const PickupStatsPanel = ({ stats, isLoading, mobile, fontSize }) => { +const SectionHeader = ({ title, subtitle, mobile }) => ( +
+

+ {title} +

+ {subtitle && ( +
+ {subtitle} +
+ )} +
+); + +export const PickupStatsPanel = ({ stats, isLoading, mobile, fontSize, periodLabel = '' }) => { if (isLoading) { return ( @@ -65,103 +81,137 @@ export const PickupStatsPanel = ({ stats, isLoading, mobile, fontSize }) => { ); } - const fs = fontSize || { xs: '0.65rem', s: '0.68rem', m: '0.78rem', l: '0.85rem', xl: '1.1rem' }; + const fs = fontSize || { xs: '0.72rem', s: '0.78rem', m: '0.85rem', l: '0.95rem' }; const totalPickups = Number(stats.total_pickups) || 0; + const pickedUp = Number(stats.picked_up) || 0; + const pending = Number(stats.pending) || 0; + const manual = Number(stats.manual) || 0; const pickupRate = Number(stats.pickup_rate) || 0; const avgDays = stats.avg_days_until_pickup != null ? Number(stats.avg_days_until_pickup) : null; const dist = stats.delivery_type_dist || {}; - const maxDay = Math.max( - Number(stats.pickup_today) || 0, - Number(stats.pickup_tomorrow) || 0, - Number(stats.pickup_day_after) || 0, - 1 - ); + const pickupToday = Number(stats.pickup_today) || 0; + const pickupTomorrow = Number(stats.pickup_tomorrow) || 0; + const pickupDayAfter = Number(stats.pickup_day_after) || 0; + const pickupFirstHalf = Number(stats.pickup_first_half) || 0; + const pickupSecondHalf = Number(stats.pickup_second_half) || 0; + const pickupOnSaturday = Number(stats.pickup_on_saturday) || 0; - const maxHalf = Math.max( - Number(stats.pickup_first_half) || 0, - Number(stats.pickup_second_half) || 0, - 1 - ); + const hasScheduledPickups = pickupToday + pickupTomorrow + pickupDayAfter > 0; + const hasTimeSlots = pickupFirstHalf + pickupSecondHalf > 0; + + const maxDay = Math.max(pickupToday, pickupTomorrow, pickupDayAfter, 1); + const maxHalf = Math.max(pickupFirstHalf, pickupSecondHalf, 1); const pieData = [ { name: 'Самовывоз', value: Number(dist.pickup) || 0, fill: PICKUP_COLORS.pickup }, { name: 'Доставка', value: Number(dist.delivery) || 0, fill: PICKUP_COLORS.delivery }, ].filter(d => d.value > 0); + // Desktop: 3-col grid inside panel. Mobile: stacked. + const innerCols = mobile ? '1fr' : '1fr 1fr 1fr'; + return ( - -

- 📦 Самовывоз -

+ + {/* KPI row */} -
+
{[ - { label: 'Всего самовывоз', val: totalPickups, color: '#f59e0b' }, - { label: 'Доля самовывоза', val: pickupRate + '%', color: '#f59e0b' }, - { label: 'Ср. дней до выдачи', val: avgDays !== null ? avgDays : '—', color: '#3b82f6' }, + { label: 'Всего', val: totalPickups, color: '#f59e0b' }, + { label: 'Завершено', val: pickedUp, color: '#14b8a6' }, + { label: 'Ожидает', val: pending, color: '#94a3b8' }, + { label: 'Доля', val: pickupRate + '%', color: '#f59e0b' }, ].map((kpi, i) => ( -
-
{kpi.label}
-
{kpi.val}
+
+
{kpi.label}
+
{kpi.val}
))}
- {/* Distribution by day */} -
-
По дням
-
- - - -
-
+ {/* Content grid: status breakdown | schedule | donut */} +
- {/* Half-day split */} -
-
По времени
-
- - -
-
- - {/* Saturday */} -
- Самовывоз в субботу - {Number(stats.pickup_on_saturday) || 0} -
- - {/* Delivery vs Pickup donut */} - {pieData.length > 0 && ( + {/* Status breakdown */}
-
Доставка vs Самовывоз
- - - - {pieData.map((entry, i) => ( - - ))} - - } /> - - -
- {pieData.map((d, i) => ( -
-
- {d.name}: {d.value} -
- ))} +
По статусам
+
+ + + +
+ {avgDays !== null && avgDays > 0 && ( +
+ Ср. дней до выдачи + {avgDays} +
+ )} +
+ + {/* Schedule — only show if there are scheduled pickups */} +
+
Расписание
+ {hasScheduledPickups ? ( +
+ + + +
+ ) : ( +
+ Нет запланированных самовывозов +
+ )} + + {hasTimeSlots && ( +
+ + +
+ )} + +
+ Самовывоз в субботу + {pickupOnSaturday}
- )} + + {/* Delivery vs Pickup donut */} +
+
Доставка vs Самовывоз
+ {pieData.length > 0 ? ( + <> + + + + {pieData.map((entry, i) => ( + + ))} + + } /> + + +
+ {pieData.map((d, i) => ( +
+
+ {d.name}: {d.value} +
+ ))} +
+ + ) : ( +
+ Нет данных +
+ )} +
+
); -}; +}; \ No newline at end of file diff --git a/src/components/client/PickupSlotsPicker.jsx b/src/components/client/PickupSlotsPicker.jsx index 7690bb8..c5976b6 100644 --- a/src/components/client/PickupSlotsPicker.jsx +++ b/src/components/client/PickupSlotsPicker.jsx @@ -36,24 +36,27 @@ const getCrimeaHour = (referenceDate = new Date()) => { ); }; -const isWeekend = (dateKey) => { +const isAllowedPickupDay = (dateKey, allowedDays = [1,2,3,4,5]) => { + if (!dateKey) return false; const d = new Date(`${dateKey}T12:00:00Z`); - const day = d.getUTCDay(); - return day === 0 || day === 6; + // getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat + // allowedDays uses 1=Mon ... 7=Sun + const dayNum = d.getUTCDay() === 0 ? 7 : d.getUTCDay(); + return allowedDays.includes(dayNum); }; -const getNextWorkday = (dateKey) => { +const getNextPickupWorkday = (dateKey, allowedDays = [1,2,3,4,5]) => { let next = addDaysKey(dateKey, 1); - while (isWeekend(next)) { + while (!isAllowedPickupDay(next, allowedDays)) { next = addDaysKey(next, 1); } return next; }; -const getPickupSlots = (referenceDate = new Date()) => { +const getPickupSlots = (referenceDate = new Date(), pickupDays = [1,2,3,4,5]) => { const todayKey = getCrimeaTodayKey(referenceDate); const hour = getCrimeaHour(referenceDate); - const isTodayWorkday = !isWeekend(todayKey); + const isTodayWorkday = isAllowedPickupDay(todayKey, pickupDays); const slots = []; @@ -77,7 +80,7 @@ const getPickupSlots = (referenceDate = new Date()) => { } const tomorrow = addDaysKey(todayKey, 1); - const tomorrowWorkday = !isWeekend(tomorrow) ? tomorrow : getNextWorkday(todayKey); + const tomorrowWorkday = isAllowedPickupDay(tomorrow, pickupDays) ? tomorrow : getNextPickupWorkday(todayKey, pickupDays); slots.push({ id: `pickup-${tomorrowWorkday}-first`, date: tomorrowWorkday, @@ -94,7 +97,7 @@ const getPickupSlots = (referenceDate = new Date()) => { }); const dayAfter = addDaysKey(tomorrowWorkday, 1); - const dayAfterWorkday = !isWeekend(dayAfter) ? dayAfter : getNextWorkday(dayAfter); + const dayAfterWorkday = isAllowedPickupDay(dayAfter, pickupDays) ? dayAfter : getNextPickupWorkday(dayAfter, pickupDays); slots.push({ id: `pickup-${dayAfterWorkday}-first`, date: dayAfterWorkday, @@ -152,8 +155,9 @@ export const PickupSlotsPicker = ({ onSelectSlot, selectedSlotId, referenceDate = new Date(), + pickupDays = [1, 2, 3, 4, 5], }) => { - const slots = React.useMemo(() => getPickupSlots(referenceDate), [referenceDate]); + const slots = React.useMemo(() => getPickupSlots(referenceDate, pickupDays), [referenceDate, pickupDays]); if (!slots.length) { return ( diff --git a/src/components/driver/DriverDeliveryDetail.jsx b/src/components/driver/DriverDeliveryDetail.jsx index 6364e9e..8a1cf69 100644 --- a/src/components/driver/DriverDeliveryDetail.jsx +++ b/src/components/driver/DriverDeliveryDetail.jsx @@ -74,21 +74,18 @@ export const DriverDeliveryDetail = ({ order, onStatusChange }) => { const orderItems = Array.isArray(order.items) ? order.items.map(splitItem) : []; const currentStatus = order.status; - const IN_TRANSIT_STATUSES = ["Загружен", "В пути"]; - const isOnRoute = IN_TRANSIT_STATUSES.includes(currentStatus); let actionButtons = []; if (currentStatus === "Назначен водитель") { - actionButtons = [ - { value: "Загружен", label: "Загружено" }, - { value: "Проблема доставки", label: "Проблема" }, - ]; - } else if (isOnRoute) { actionButtons = [ { value: "Доставлен", label: "Доставлено" }, { value: "Проблема доставки", label: "Проблема" }, ]; - } else if (currentStatus === "Доставлен" || currentStatus === "Проблема доставки" || currentStatus === "Закрыт" || currentStatus === "Отменён") { + } else if (currentStatus === "Доставлен") { + actionButtons = [ + { value: "Назначен водитель", label: "Вернуть в работу" }, + ]; + } else if (currentStatus === "Проблема доставки" || currentStatus === "Закрыт" || currentStatus === "Отменён") { actionButtons = []; } else { actionButtons = availableTransitions.map((status) => ({ @@ -98,7 +95,7 @@ export const DriverDeliveryDetail = ({ order, onStatusChange }) => { } return ( -
+
{showProblemModal && ( { diff --git a/src/components/driver/DriverDeliveryPlanner.jsx b/src/components/driver/DriverDeliveryPlanner.jsx index a8b0963..98ba472 100644 --- a/src/components/driver/DriverDeliveryPlanner.jsx +++ b/src/components/driver/DriverDeliveryPlanner.jsx @@ -261,7 +261,7 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs } return ( -
+
diff --git a/src/components/driver/DriverShipmentPanel.jsx b/src/components/driver/DriverShipmentPanel.jsx index 782c4c6..b5a93a6 100644 --- a/src/components/driver/DriverShipmentPanel.jsx +++ b/src/components/driver/DriverShipmentPanel.jsx @@ -138,11 +138,17 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i const [comments, setComments] = React.useState(initialComments); const [commentInput, setCommentInput] = React.useState(""); - // Sync state when order data changes (e.g. after save) + // Sync state ONLY when order ID changes (navigating to different order), not on every order data update + const orderId = order?.id; + const prevOrderId = React.useRef(orderId); React.useEffect(() => { - setShippedItems(initialShippedIds); - setComments(initialComments); - }, [initialShippedIds, initialComments]); + if (prevOrderId.current !== orderId) { + prevOrderId.current = orderId; + setShippedItems(initialShippedIds); + setComments(initialComments); + setJustSaved(false); + } + }, [orderId, initialShippedIds, initialComments]); // Track last saved shipment data for visual display React.useEffect(() => { @@ -170,7 +176,7 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i }; const currentDeliveryStatus = order?.deliveryStatus || order?.delivery_status; - const isStatusFinal = ["delivered", "problem", "picked_up"].includes(currentDeliveryStatus); + const isStatusFinal = ["delivered", "problem", "picked_up", "loaded", "on_route", "driver_assigned"].includes(currentDeliveryStatus); const unshipAll = () => { if (isStatusFinal && onResetStatus) { @@ -222,7 +228,7 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i if (items.length === 0) { return ( - + Состав заказа

Позиции не указаны

@@ -230,7 +236,7 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i } return ( - +
Отгрузка @@ -250,7 +256,7 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i Отгрузить всё
diff --git a/src/components/logistics/LogisticsReadinessBoard.jsx b/src/components/logistics/LogisticsReadinessBoard.jsx index 14c4d65..589028f 100644 --- a/src/components/logistics/LogisticsReadinessBoard.jsx +++ b/src/components/logistics/LogisticsReadinessBoard.jsx @@ -158,12 +158,12 @@ const renderRow = (group, onSelectSet) => ( {group.assignedDriverName || }
-
+
{getOrderGroupDisplayStatusLabel(group)} {(group.hasDeliveryProblem || group.has_delivery_problem) && ( ⚠ Проблема @@ -255,7 +255,17 @@ const SortableSection = ({ statusValue, label, groups, isCollapsed, onToggle, on }; export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS, isLoading = false }) => { - const [filters, setFilters] = React.useState({ query: "", displayStatus: "all", city: "" }); + const FILTERS_KEY = 'logistics-board-filters'; + const [filters, setFilters] = React.useState(() => { + try { + const raw = localStorage.getItem(FILTERS_KEY); + if (raw) return { query: '', displayStatus: 'all', city: '', ...JSON.parse(raw) }; + } catch {} + return { query: '', displayStatus: 'all', city: '' }; + }); + React.useEffect(() => { + try { localStorage.setItem(FILTERS_KEY, JSON.stringify(filters)); } catch {} + }, [filters]); const [collapsedSections, setCollapsedSections] = React.useState(() => loadCollapsedSections()); const [sectionOrder, setSectionOrder] = React.useState(() => { const custom = loadCustomOrder(); diff --git a/src/components/orders/CalendarWidget.jsx b/src/components/orders/CalendarWidget.jsx index a56f0e4..ab4dcb5 100644 --- a/src/components/orders/CalendarWidget.jsx +++ b/src/components/orders/CalendarWidget.jsx @@ -58,7 +58,7 @@ const CalendarWidget = ({ }) => { return (
-
+
Назначен diff --git a/src/components/orders/OrderDetailPanel.jsx b/src/components/orders/OrderDetailPanel.jsx index b0b3cc0..cdb8163 100644 --- a/src/components/orders/OrderDetailPanel.jsx +++ b/src/components/orders/OrderDetailPanel.jsx @@ -2,42 +2,60 @@ const DriverShipmentReport = ({ shipmentData }) => { if (!Array.isArray(shipmentData) || shipmentData.length === 0) return null; + const deliveredItems = shipmentData.filter((i) => i.shipped); + const notDeliveredItems = shipmentData.filter((i) => !i.shipped); + return ( - +
- - - - Проблемы с доставкой позиций + 📋 + Отчёт об отгрузке + 0 ? "warning" : "accent"}> + {deliveredItems.length}/{shipmentData.length} доставлено +
-

- Не доставлено {shipmentData.length} {shipmentData.length === 1 ? "позиция" : shipmentData.length < 5 ? "позиции" : "позиций"}. Остальное — доставлено. -

+ + {notDeliveredItems.length > 0 && ( +
+ ⚠ Не доставлено {notDeliveredItems.length} {notDeliveredItems.length === 1 ? "позиция" : notDeliveredItems.length < 5 ? "позиции" : "позиций"} +
+ )} +
{shipmentData.map((item) => (
- {item.name} +
+ + {item.shipped ? "✓" : "✗"} + + + {item.name} + +
{item.quantity || item.unit ? ( {[item.quantity, item.unit].filter(Boolean).join(" ")} ) : null}
- {item.comment ? ( -

Причина: {item.comment}

- ) : ( -

Причина не указана

+ {!item.shipped && item.comment && ( +

Причина: {item.comment}

)}
))}
-
); }; + import React from "react"; import { formatDateTime } from "../../utils/formatters"; import { Badge } from "../UI/Badge"; @@ -54,7 +72,11 @@ import { getOrderGroupStatusTone, DELIVERY_GROUP_STATUS_LABELS, } from "../../services/orderGroupViews"; -import { getErrorMessage, normalizeNom } from "../../utils/deliveryUtils"; +import { + getErrorMessage, + normalizeNom, +} from "../../utils/deliveryUtils"; +import { SmsStatusCard } from "./SmsStatusCard"; const fmtTime = (ts) => { if (!ts) return "—"; @@ -331,6 +353,12 @@ const normalizeDateForInput = (value) => { return `20${year}-${month}-${day}`; } + const fullDateMatch = normalized.match(/^(\d{2})\.(\d{2})\.(\d{4})$/); + if (fullDateMatch) { + const [, day, month, year] = fullDateMatch; + return `${year}-${month}-${day}`; + } + return ""; }; @@ -594,11 +622,19 @@ export const OrderDetailPanel = ({ setIsSavingShipment(false); } }, [onSaveShipmentData, order?.id]); - const minSelectableDateKey = React.useMemo(() => getNextSelectableDateKey(), []); + const minSelectableDateKey = React.useMemo(() => { + // Logistician/admin can set past dates for retroactive delivery closing + if (["logistician", "admin", "mega_admin", "manager"].includes(userRole)) { + const past = new Date(); + past.setMonth(past.getMonth() - 3); + return toDateKey(past); + } + return getNextSelectableDateKey(); + }, [userRole]); const [currentMonth, setCurrentMonth] = React.useState(() => { const existingDeliveryDate = fromDateKey(order?.deliveryDate); const fallbackDate = fromDateKey(minSelectableDateKey) || new Date(); - const sourceDate = existingDeliveryDate && isFutureDeliveryDate(toDateKey(existingDeliveryDate)) + const sourceDate = existingDeliveryDate ? existingDeliveryDate : fallbackDate; @@ -613,7 +649,10 @@ export const OrderDetailPanel = ({ }), [currentMonth], ); - const canGoBack = toDateKey(currentMonth) > toDateKey(startOfMonth(fromDateKey(minSelectableDateKey) || new Date())); + const canGoBack = (() => { + if (["logistician", "admin", "mega_admin", "manager"].includes(userRole)) return true; + return toDateKey(currentMonth) > toDateKey(startOfMonth(fromDateKey(minSelectableDateKey) || new Date())); + })(); React.useEffect(() => { setSelectedDriverId(order?.assignedDriverId || ""); @@ -621,9 +660,12 @@ export const OrderDetailPanel = ({ }, [order?.id, order?.assignedDriverId]); React.useEffect(() => { - const normalizedDeliveryDate = normalizeDateForInput(order?.deliveryDate); + const normalizedDeliveryDate = normalizeDateForInput(order?.deliveryDate || order?.customerDate); const nextSelectableDateKey = getNextSelectableDateKey(); - const selectedDateKey = isFutureDeliveryDate(normalizedDeliveryDate) ? normalizedDeliveryDate : nextSelectableDateKey; + const canUsePastDate = ["logistician", "admin", "mega_admin", "manager"].includes(userRole); + const selectedDateKey = (normalizedDeliveryDate && (canUsePastDate || isFutureDeliveryDate(normalizedDeliveryDate))) + ? normalizedDeliveryDate + : nextSelectableDateKey; setDeliveryDate(selectedDateKey); const selectedDate = fromDateKey(selectedDateKey) || new Date(); setCurrentMonth(startOfMonth(selectedDate)); @@ -686,7 +728,8 @@ export const OrderDetailPanel = ({ return; } - if (!isFutureDeliveryDate(effectiveDate)) { + const canUsePastDate = ["logistician", "admin", "mega_admin", "manager"].includes(userRole); + if (!canUsePastDate && !isFutureDeliveryDate(effectiveDate)) { setFormMessage(deliveryType === "pickup" ? "Выберите дату самовывоза позже сегодняшнего дня." : "Выберите дату доставки позже сегодняшнего дня."); return; } @@ -889,10 +932,7 @@ export const OrderDetailPanel = ({

Обновлена

{formatDateTime(order.updatedAt)}

-
-

{isPickupOrder ? "Статус самовывоза" : "Статус доставки"}

-

{getOrderGroupDeliveryStatusLabel(order.deliveryStatus || order.delivery_status)}

-
+ {(order.pickupCode || order.pickup_code) && (order.deliveryType === "pickup" || order.delivery_type === "pickup") ? (

Код выдачи

@@ -1053,6 +1093,7 @@ export const OrderDetailPanel = ({ /> )} + ) : null} - {userRole === "driver" && order ? ( + {["driver", "logistician", "admin", "mega_admin"].includes(userRole) && order ? ( { if (!response.success) { setFormMessage(response.error || "Не удалось сбросить статус"); @@ -1131,15 +1172,15 @@ export const OrderDetailPanel = ({
{(() => { const currentStatus = order.deliveryStatus || order.delivery_status; - const IN_TRANSIT_STATUSES = ["loaded", "on_route"]; - const isOnRoute = IN_TRANSIT_STATUSES.includes(currentStatus); const isPickup = (order.deliveryType || order.delivery_type) === "pickup" || currentStatus === "pickup"; - + let statusOptions = []; - if (currentStatus === "delivered" || currentStatus === "picked_up" || currentStatus === "problem" || currentStatus === "cancelled" || currentStatus === "paid_storage") { + if (currentStatus === "delivered" || currentStatus === "picked_up" || currentStatus === "problem") { + // Final statuses — show "Return to work" instead + statusOptions = []; + } else if (currentStatus === "cancelled" || currentStatus === "paid_storage") { statusOptions = []; } else { - // Primary button matches delivery type, secondary requires confirmation if (isPickup) { statusOptions = [ { value: "picked_up", label: "Вывезено", mismatch: false }, @@ -1155,7 +1196,6 @@ export const OrderDetailPanel = ({ } } - // "Return to work" button for final statuses const canReturn = ["delivered", "picked_up", "problem"].includes(currentStatus); if (statusOptions.length === 0 && !canReturn) return null; @@ -1163,6 +1203,7 @@ export const OrderDetailPanel = ({ return statusOptions.map((statusOption) => { const isSelected = pendingStatus?.value === statusOption.value; const isMismatch = statusOption.mismatch; + const blockedBySchedule = statusOption.requiresSchedule && !hasDeliverySchedule; return ( -
+ ); })}
+ {!hasDeliverySchedule && ( +
+ ⚠ Чтобы поставить «Доставлено» или «Вывезено», сначала укажите дату и половину дня доставки выше. +
+ )} ); }; -export { StatusActionPanel }; \ No newline at end of file +export { StatusActionPanel }; diff --git a/src/constants/deliveryWorkflow.js b/src/constants/deliveryWorkflow.js index 15f1491..dbad65b 100644 --- a/src/constants/deliveryWorkflow.js +++ b/src/constants/deliveryWorkflow.js @@ -240,10 +240,10 @@ export const ORDER_STATUS_TRANSITIONS = { "Ожидает согласования доставки": ["Доставка согласована", "Самовывоз", "Требуется адрес", "Проблема доставки", "Отменён"], "Доставка согласована": ["Назначен водитель", "Ожидает согласования доставки", "Проблема доставки", "Самовывоз", "Требуется адрес"], "Передан логисту": ["Доставка согласована", "Платное хранение", "Проблема доставки", "Отменён"], - "Назначен водитель": ["Загружен", "Проблема доставки"], + "Назначен водитель": ["Доставлен", "Проблема доставки"], Загружен: ["Доставлен", "Проблема доставки"], "В пути": ["Доставлен", "Проблема доставки"], - Доставлен: ["Закрыт"], + Доставлен: ["Закрыт", "Назначен водитель"], "Проблема доставки": ["Ожидает согласования доставки", "Назначен водитель", "Отменён", "Закрыт"], "Платное хранение": ["Доставка согласована", "Отменён", "Закрыт"], "Самовывоз": ["Доставка согласована", "Закрыт", "Отменён", "Платное хранение"], @@ -270,7 +270,7 @@ export const ROLE_TRANSITION_TARGETS = { "Закрыт", "Отменён", ], - driver: ["Загружен", "Доставлен", "Проблема доставки"], + driver: ["Доставлен", "Проблема доставки"], admin: ORDER_STATUSES, }; @@ -291,7 +291,7 @@ export const LOGISTICS_STATUSES = [ "Проблема доставки", ]; -export const DRIVER_STATUSES = ["Назначен водитель", "Загружен", "Доставлен"]; +export const DRIVER_STATUSES = ["Назначен водитель", "Доставлен"]; export const getOrderStatusComment = (status) => ORDER_STATUS_META[status]?.comment || "Комментарий не задан."; 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..d1d32a1 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 = localStorage.getItem(FILTERS_STORAGE_KEY); + if (saved) return JSON.parse(saved); + } catch (e) {} + return { query: "", displayStatus: "all", deliveryType: "" }; }); + + // Persist filters to localStorage on every change + React.useEffect(() => { + try { + localStorage.setItem(FILTERS_STORAGE_KEY, JSON.stringify(filters)); + } catch (e) {} + }, [filters]); const [selectedOrderGroupId, setSelectedOrderGroupId] = React.useState(null); const [isLoading, setIsLoading] = React.useState(true); const [loadError, setLoadError] = React.useState(""); diff --git a/src/pages/DashboardPage.jsx b/src/pages/DashboardPage.jsx index 39dc38f..12e426e 100644 --- a/src/pages/DashboardPage.jsx +++ b/src/pages/DashboardPage.jsx @@ -17,6 +17,7 @@ import { StopWordsPanel } from "../components/admin/StopWordsPanel"; import { ActionLogPanel } from "../components/admin/ActionLogPanel"; import { SuggestionsPanel } from "../components/admin/SuggestionsPanel"; import { SmsCampaignPanel } from "../components/admin/SmsCampaignPanel"; +import { BusinessSchedulePanel } from "../components/admin/BusinessSchedulePanel"; import { Panel } from "../components/UI/Panel"; import { SkeletonPage, SkeletonTable } from "../components/UI/Loading"; import { useAuth } from "../context/AuthContext"; @@ -36,6 +37,7 @@ const MEGA_ADMIN_NAV = [ { key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null }, { key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null }, { key: "sms_campaign", label: "SMS-кампании", description: "Логи и настройки SMS-рассылок.", badge: null }, + { key: "schedule", label: "Расписание", description: "Рабочие дни доставки, самовывоза и SMS.", badge: null }, ]; // ── Role → Default Section Map ───────────────────────────────────────────── @@ -137,6 +139,7 @@ export const DashboardPage = () => { { key: "errors", label: "Ошибки", description: "Журнал ошибок приложения.", badge: null }, { key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null }, { key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null }, + { key: "schedule", label: "Расписание", description: "Рабочие дни доставки, самовывоза и SMS.", badge: null }, ] : userRole === "logistician" ? [ @@ -178,6 +181,7 @@ const ALLOWED_DASHBOARD_ROLES = ["admin", "mega_admin", "manager", "logistician" if (activeSection === "action_log") return
; if (activeSection === "suggestions") return
; if (activeSection === "sms_campaign") return
; + if (activeSection === "schedule") return
; if (isLoading) { if (userRole === "driver") { diff --git a/src/services/orderGroupViews.js b/src/services/orderGroupViews.js index 2dd555d..ce748ce 100644 --- a/src/services/orderGroupViews.js +++ b/src/services/orderGroupViews.js @@ -39,7 +39,7 @@ export const DRIVER_VISIBLE_DELIVERY_STATUSES = [ "paid_storage", ]; -export const DRIVER_ACTIVE_DELIVERY_STATUSES = ["driver_assigned", "loaded", "on_route", "problem"]; +export const DRIVER_ACTIVE_DELIVERY_STATUSES = ["driver_assigned", "problem"]; const HALF_DAY_LABELS = { morning: "Первая половина дня", @@ -143,9 +143,17 @@ export const isOrderGroupAgreedForDelivery = (group) => { export const getOrderGroupDeliveryStatusLabel = (status) => DELIVERY_GROUP_STATUS_LABELS[status] || (status ? `Неизвестно (${status})` : "Неизвестно"); +// Status values that represent a delivery state (not SMS/notification state) +const DELIVERY_STATUS_VALUES = new Set([ + "pending_confirmation", "agreed", "driver_assigned", "loaded", "on_route", + "delivered", "picked_up", "pickup", "requires_address", "problem", + "cancelled", "paid_storage", "address_required", "manual_confirmation_required", +]); + export const getOrderGroupDisplayStatusLabel = (group) => { - const deliveryStatus = group?.deliveryStatus || group?.delivery_status; + const statusCol = group?.status; const notificationStatus = group?.notificationStatus || group?.notification_status; + const deliveryStatus = group?.deliveryStatus || group?.delivery_status; // When auto-SMS failed and logistics hasn't taken action yet → show as a todo item const isManualRequired = notificationStatus === "manual_required"; @@ -154,6 +162,12 @@ export const getOrderGroupDisplayStatusLabel = (group) => { return "Требуется ручное управление"; } + // Primary: status column (now synced with delivery_status) + if (statusCol && DELIVERY_STATUS_VALUES.has(statusCol) && statusCol !== "pending_confirmation" && statusCol !== "manual_confirmation_required") { + return getOrderGroupDeliveryStatusLabel(statusCol); + } + + // Fallback: delivery_status (for pending/manual_confirmation groups) if (deliveryStatus && deliveryStatus !== "pending_confirmation" && deliveryStatus !== "manual_confirmation_required") { return getOrderGroupDeliveryStatusLabel(deliveryStatus); } @@ -163,12 +177,13 @@ export const getOrderGroupDisplayStatusLabel = (group) => { return notificationLabel; } - return getOrderGroupStatusLabel(group?.status); + return getOrderGroupStatusLabel(statusCol); }; export const getOrderGroupDisplayStatusValue = (group) => { - const deliveryStatus = group?.deliveryStatus || group?.delivery_status; + const statusCol = group?.status; const notificationStatus = group?.notificationStatus || group?.notification_status; + const deliveryStatus = group?.deliveryStatus || group?.delivery_status; // Unify manual_required into a single bucket regardless of delivery_status detail const isManualRequired = notificationStatus === "manual_required"; @@ -177,11 +192,17 @@ export const getOrderGroupDisplayStatusValue = (group) => { return "status:manual_required"; } + // Primary: status column (now synced with delivery_status) + if (statusCol && DELIVERY_STATUS_VALUES.has(statusCol) && statusCol !== "pending_confirmation" && statusCol !== "manual_confirmation_required") { + return `delivery:${statusCol}`; + } + + // Fallback: delivery_status if (deliveryStatus && deliveryStatus !== "pending_confirmation" && deliveryStatus !== "manual_confirmation_required") { return `delivery:${deliveryStatus}`; } - return `status:${group?.status || "unknown"}`; + return `status:${statusCol || "unknown"}`; }; export const isOrderGroupVisibleToDriver = (group) => { @@ -483,10 +504,16 @@ export const buildOrderGroupBuckets = (groups) => { export const getOrderGroupStatusTone = (group) => { const deliveryStatus = group?.deliveryStatus || group?.delivery_status; + const statusCol = group?.status; // Highlight groups with delivery problems if (group?.hasDeliveryProblem) return "warning"; + // Priority: if status column already holds a delivery-level value, use it + if (statusCol && DELIVERY_STATUS_VALUES.has(statusCol) && statusCol !== "pending_confirmation" && statusCol !== "manual_confirmation_required") { + return getOrderGroupDeliveryStatusTone(statusCol); + } + if (deliveryStatus && deliveryStatus !== "pending_confirmation") { return getOrderGroupDeliveryStatusTone(deliveryStatus); } diff --git a/src/services/supabase/orderGroupRepository.js b/src/services/supabase/orderGroupRepository.js index 2131df0..f08c34a 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); @@ -460,15 +471,22 @@ export const updateDeliveryStatus = async ({ orderGroupId, status, details, ship return safeSupabaseCall(async () => { const client = requireSupabase(); - // Fetch current status before any update (needed for audit log) + // Fetch current status before any update (needed for audit log + status sync) const { data: current, error: fetchCurrentError } = await client .from("order_groups") - .select("delivery_status") + .select("delivery_status, delivery_type") .eq("id", orderGroupId) .single(); if (fetchCurrentError) throw fetchCurrentError; + // Compute status column: pickup+picked_up → picked_up, delivery+picked_up → delivered + const deliveryType = current.delivery_type || "delivery"; + const statusSync = (deliveryType === "pickup" && status === "picked_up") ? "picked_up" + : (deliveryType === "delivery" && status === "picked_up") ? "delivered" + : (status === "delivered") ? "delivered" + : status; + // Bypass stale RPC for paid_storage transitions // Server-side RPC still enforces driver-assignment checks that block // manager/logistician from moving groups into/out of paid_storage. @@ -479,6 +497,7 @@ export const updateDeliveryStatus = async ({ orderGroupId, status, details, ship .from("order_groups") .update({ delivery_status: status, + status: statusSync, paid_storage_at: new Date().toISOString(), updated_at: new Date().toISOString(), }) @@ -490,6 +509,7 @@ export const updateDeliveryStatus = async ({ orderGroupId, status, details, ship .from("order_groups") .update({ delivery_status: status, + status: statusSync, paid_storage_at: null, updated_at: new Date().toISOString(), }) diff --git a/supabase/functions/_shared/delivery-invitations.ts b/supabase/functions/_shared/delivery-invitations.ts index a7fc938..9458ce3 100644 --- a/supabase/functions/_shared/delivery-invitations.ts +++ b/supabase/functions/_shared/delivery-invitations.ts @@ -119,9 +119,26 @@ export const normalizeAvailableSlots = (availableSlots?: string[] | null) => { return slots.length > 0 ? Array.from(new Set(slots)) : [...DEFAULT_AVAILABLE_SLOTS]; }; -export const buildDefaultDatedAvailableSlots = (now = new Date()) => { +export const buildDefaultDatedAvailableSlots = async (now = new Date(), supabaseClient?: any) => { const CRIMEA_TZ = "Europe/Simferopol"; + // Fetch delivery days from business_schedule_settings + let deliveryDays: number[] = [1, 2, 3, 4, 5]; // Default: Mon-Fri + if (supabaseClient) { + try { + const { data } = await supabaseClient + .from("business_schedule_settings") + .select("delivery_days") + .eq("id", 1) + .single(); + if (data?.delivery_days && Array.isArray(data.delivery_days) && data.delivery_days.length) { + deliveryDays = data.delivery_days; + } + } catch { + // Silent fallback to defaults + } + } + const formatCrimeaDate = (date: Date) => { return new Intl.DateTimeFormat("en-CA", { timeZone: CRIMEA_TZ, @@ -137,12 +154,18 @@ export const buildDefaultDatedAvailableSlots = (now = new Date()) => { return next; }; - // Skip Sunday (getUTCDay() === 0) — never offer Sunday delivery - const isSunday = (date: Date) => date.getUTCDay() === 0; + // Check if date is an allowed delivery day + // getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat + // deliveryDays uses 1=Mon ... 7=Sun + const isAllowedDay = (date: Date) => { + const dow = date.getUTCDay(); + const dayNum = dow === 0 ? 7 : dow; + return deliveryDays.includes(dayNum); + }; const getNextWorkday = (date: Date) => { let next = addDays(date, 1); - while (isSunday(next)) { + while (!isAllowedDay(next)) { next = addDays(next, 1); } return next; diff --git a/supabase/functions/confirm-delivery-choice/index.ts b/supabase/functions/confirm-delivery-choice/index.ts index 33b29eb..65a5d78 100644 --- a/supabase/functions/confirm-delivery-choice/index.ts +++ b/supabase/functions/confirm-delivery-choice/index.ts @@ -31,11 +31,31 @@ type ConfirmBody = { const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value); -const isWeekendDate = (value: string) => { +// Fetch delivery days from business_schedule_settings +const getDeliveryDays = async (supabaseClient: any): Promise => { + try { + const { data } = await supabaseClient + .from("business_schedule_settings") + .select("delivery_days") + .eq("id", 1) + .single(); + if (data?.delivery_days && Array.isArray(data.delivery_days) && data.delivery_days.length) { + return data.delivery_days as number[]; + } + } catch { + // Silent fallback + } + return [1, 2, 3, 4, 5]; // Default: Mon-Fri +}; + +const isAllowedDeliveryDate = (value: string, deliveryDays: number[]) => { if (!isValidDate(value)) return false; const date = new Date(`${value}T12:00:00Z`); - const weekday = date.getUTCDay(); - return weekday === 0; // 0=Sunday — never allow Sunday delivery + const dow = date.getUTCDay(); + // getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat + // deliveryDays uses 1=Mon ... 7=Sun + const dayNum = dow === 0 ? 7 : dow; + return deliveryDays.includes(dayNum); }; const resolveRequestedSlot = ( @@ -45,6 +65,7 @@ const resolveRequestedSlot = ( available_slots?: string[] | null; }, body: ConfirmBody, + deliveryDays: number[] = [1, 2, 3, 4, 5], ) => { const deliveryType = body.deliveryType || "delivery"; const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim(); @@ -59,8 +80,8 @@ const resolveRequestedSlot = ( return { deliveryDate, deliveryTime, deliveryType }; } - // Reject Sunday for delivery (business rule: never deliver on Sunday) - if (isWeekendDate(deliveryDate)) { + // Reject non-delivery days for delivery (business schedule) + if (!isAllowedDeliveryDate(deliveryDate, deliveryDays)) { return null; } @@ -120,6 +141,7 @@ Deno.serve(async (request) => { const tokenHash = await hashInvitationToken(body.token); const supabase = createServiceClient(); + const deliveryDays = await getDeliveryDays(supabase); const ipHash = await hashText(getClientIp(request)); await requireRateLimit(supabase, { @@ -180,7 +202,7 @@ Deno.serve(async (request) => { ); } - const requestedSlot = resolveRequestedSlot(invitation, body); + const requestedSlot = resolveRequestedSlot(invitation, body, deliveryDays); if (!requestedSlot) { return jsonResponse( { diff --git a/supabase/functions/manage-users/index.ts b/supabase/functions/manage-users/index.ts new file mode 100644 index 0000000..b2d1a10 --- /dev/null +++ b/supabase/functions/manage-users/index.ts @@ -0,0 +1,165 @@ +import { + createServiceClient, + getCorsHeaders, + jsonResponse, + preflightResponse, + readJsonBody, +} from "../_shared/security.ts"; + +const MAX_BODY_BYTES = 8 * 1024; + +const ADMIN_ROLES = new Set(["admin", "mega_admin"]); + +const isValidEmail = (value: string) => + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()); + +/** + * Verify the caller's JWT and return their role from public.users. + * Returns null if not authorized. + */ +async function getCallerRole(request: Request): Promise { + const authHeader = request.headers.get("authorization") || ""; + const token = authHeader.replace(/^Bearer\s+/i, "").trim(); + if (!token) return null; + + const supabase = createServiceClient(); + const { data: userData, error: userError } = await supabase.auth.getUser(token); + if (userError || !userData?.user) return null; + + const userId = userData.user.id; + const { data: userRow, error: roleError } = await supabase + .from("users") + .select("roles(name)") + .eq("id", userId) + .maybeSingle(); + if (roleError || !userRow) return null; + + return userRow.roles?.name || null; +} + +Deno.serve(async (request) => { + if (request.method === "OPTIONS") { + return preflightResponse(request, "private"); + } + + const corsHeaders = getCorsHeaders(request, "private"); + if (!corsHeaders) { + return jsonResponse({ ok: false, error: "Origin not allowed" }, 403); + } + + try { + // ── Authorization ── + const callerRole = await getCallerRole(request); + if (!callerRole || !ADMIN_ROLES.has(callerRole)) { + return jsonResponse( + { ok: false, error: "Недостаточно прав. Требуется роль admin или mega_admin." }, + 403, + corsHeaders, + ); + } + + const supabase = createServiceClient(); + + // ── POST: create new user ── + if (request.method === "POST") { + const { body } = await readJsonBody<{ email?: string; name?: string; role?: string }>( + request, + { maxBytes: MAX_BODY_BYTES }, + ); + + const email = String(body.email || "").trim().toLowerCase(); + const name = String(body.name || "").trim(); + const role = String(body.role || "").trim().toLowerCase(); + + if (!email || !isValidEmail(email)) { + return jsonResponse({ ok: false, error: "Некорректный email" }, 400, corsHeaders); + } + if (!name) { + return jsonResponse({ ok: false, error: "Имя обязательно" }, 400, corsHeaders); + } + if (!role) { + return jsonResponse({ ok: false, error: "Роль обязательна" }, 400, corsHeaders); + } + + // Check if email already exists in public.users + const { data: existingUser } = await supabase + .from("users") + .select("id") + .eq("email", email) + .maybeSingle(); + if (existingUser) { + return jsonResponse({ ok: false, error: "Пользователь с таким email уже существует" }, 409, corsHeaders); + } + + // Create auth user — trigger handle_new_user will auto-insert into public.users + // using user_metadata.role and user_metadata.name + const { data: authData, error: authError } = await supabase.auth.admin.createUser({ + email, + email_confirm: true, + user_metadata: { name, role }, + }); + + if (authError) { + console.error("auth.createUser error:", authError); + return jsonResponse( + { ok: false, error: "Ошибка создания пользователя: " + authError.message }, + 500, + corsHeaders, + ); + } + + const newUserId = authData.user.id; + + return jsonResponse({ ok: true, data: { id: newUserId, email, name, role } }, 201, corsHeaders); + } + + // ── DELETE: remove user ── + if (request.method === "DELETE") { + const url = new URL(request.url); + const userId = url.searchParams.get("id"); + if (!userId) { + return jsonResponse({ ok: false, error: "Параметр id обязателен" }, 400, corsHeaders); + } + + // Get user info before deletion + const { data: userRow } = await supabase + .from("users") + .select("id, email, name") + .eq("id", userId) + .maybeSingle(); + + if (!userRow) { + return jsonResponse({ ok: false, error: "Пользователь не найден" }, 404, corsHeaders); + } + + // Delete auth user (FK ON DELETE CASCADE will remove public.users row) + const { error: authDeleteError } = await supabase.auth.admin.deleteUser(userId); + if (authDeleteError) { + console.error("auth.deleteUser error:", authDeleteError); + // Try deleting public.users directly as fallback + const { error: dbDeleteError } = await supabase.from("users").delete().eq("id", userId); + if (dbDeleteError) { + return jsonResponse( + { ok: false, error: "Ошибка удаления: " + authDeleteError.message }, + 500, + corsHeaders, + ); + } + } + + return jsonResponse({ ok: true, data: { id: userId } }, 200, corsHeaders); + } + + return jsonResponse({ ok: false, error: "Method not allowed" }, 405, corsHeaders); + } catch (error) { + if (error instanceof Error && "status" in error) { + const httpError = error as { status: number; message: string }; + return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders); + } + return jsonResponse( + { ok: false, error: error instanceof Error ? error.message : "Unexpected error" }, + 500, + corsHeaders, + ); + } +}); \ No newline at end of file diff --git a/volumes/functions/_shared/delivery-invitations.ts b/volumes/functions/_shared/delivery-invitations.ts index 70e7ade..9458ce3 100644 --- a/volumes/functions/_shared/delivery-invitations.ts +++ b/volumes/functions/_shared/delivery-invitations.ts @@ -119,16 +119,63 @@ export const normalizeAvailableSlots = (availableSlots?: string[] | null) => { return slots.length > 0 ? Array.from(new Set(slots)) : [...DEFAULT_AVAILABLE_SLOTS]; }; -export const buildDefaultDatedAvailableSlots = (now = new Date()) => { - const formatIsoDate = (date: Date) => date.toISOString().slice(0, 10); +export const buildDefaultDatedAvailableSlots = async (now = new Date(), supabaseClient?: any) => { + const CRIMEA_TZ = "Europe/Simferopol"; + + // Fetch delivery days from business_schedule_settings + let deliveryDays: number[] = [1, 2, 3, 4, 5]; // Default: Mon-Fri + if (supabaseClient) { + try { + const { data } = await supabaseClient + .from("business_schedule_settings") + .select("delivery_days") + .eq("id", 1) + .single(); + if (data?.delivery_days && Array.isArray(data.delivery_days) && data.delivery_days.length) { + deliveryDays = data.delivery_days; + } + } catch { + // Silent fallback to defaults + } + } + + const formatCrimeaDate = (date: Date) => { + return new Intl.DateTimeFormat("en-CA", { + timeZone: CRIMEA_TZ, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(date); + }; + const addDays = (date: Date, days: number) => { const next = new Date(date); next.setUTCDate(next.getUTCDate() + days); return next; }; - const firstDay = formatIsoDate(addDays(now, 1)); - const secondDay = formatIsoDate(addDays(now, 2)); + // Check if date is an allowed delivery day + // getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat + // deliveryDays uses 1=Mon ... 7=Sun + const isAllowedDay = (date: Date) => { + const dow = date.getUTCDay(); + const dayNum = dow === 0 ? 7 : dow; + return deliveryDays.includes(dayNum); + }; + + const getNextWorkday = (date: Date) => { + let next = addDays(date, 1); + while (!isAllowedDay(next)) { + next = addDays(next, 1); + } + return next; + }; + + const firstWorkday = getNextWorkday(now); + const secondWorkday = getNextWorkday(firstWorkday); + + const firstDay = formatCrimeaDate(firstWorkday); + const secondDay = formatCrimeaDate(secondWorkday); return [ `${firstDay}, Первая половина дня`, diff --git a/volumes/functions/check-sms-status/index.ts b/volumes/functions/check-sms-status/index.ts new file mode 100644 index 0000000..06d8128 --- /dev/null +++ b/volumes/functions/check-sms-status/index.ts @@ -0,0 +1,132 @@ +import { createClient } from "npm:@supabase/supabase-js@2"; + +const ALLOWED_ORIGINS = [ + "https://dost.supersamsev.ru", + "https://supa.supersamsev.ru", + "http://localhost:5173", +]; + +const SMS_STATUS_URL = "https://sms.ru/sms/status"; + +const SMS_CODE_LABELS: Record = { + "100": "В очереди SMS.ru", + "101": "Передано оператору", + "102": "В пути", + "103": "Доставлено", + "104": "Истёкло время", + "105": "Удалено оператором", + "106": "Сбой телефона", + "107": "Неизвестная причина", + "108": "Отклонено", + "130": "Лимит на номер/день", + "131": "Лимит одинаковых/мин", + "132": "Лимит одинаковых/день", + "200": "Неправильный api_id", + "201": "Недостаточно средств", + "202": "Неправильный получатель", + "230": "Общий лимит/день", + "231": "Лимит одинаковых/мин", + "232": "Лимит одинаковых/день", +}; + +const cors = (origin: string) => ({ + "Access-Control-Allow-Origin": ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0], + "Access-Control-Allow-Methods": "POST,OPTIONS", + "Access-Control-Allow-Headers": "Content-Type,Authorization,apikey", +}); + +Deno.serve(async (req: Request) => { + const origin = req.headers.get("origin") || ""; + const headers = { ...cors(origin), "Content-Type": "application/json" }; + + if (req.method === "OPTIONS") return new Response(null, { headers }); + + try { + const { log_id } = await req.json(); + if (!log_id) return new Response(JSON.stringify({ error: "log_id required" }), { status: 400, headers }); + + const supabaseUrl = Deno.env.get("SUPABASE_URL") || ""; + const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || ""; + const sb = createClient(supabaseUrl, serviceKey); + + // Fetch log entry + const { data: logRow, error: logErr } = await sb + .from("sms_campaign_log") + .select("id, sms_id, campaign_type, order_group_id, attempts") + .eq("id", log_id) + .single(); + if (logErr || !logRow) return new Response(JSON.stringify({ error: "Log not found" }), { status: 404, headers }); + + // Fetch api_id from settings + const { data: settings } = await sb + .from("sms_campaign_settings") + .select("sms_api_id") + .eq("campaign_type", logRow.campaign_type) + .single(); + const apiId = settings?.sms_api_id || Deno.env.get("SMS_API_ID") || ""; + + if (!logRow.sms_id) return new Response(JSON.stringify({ error: "No sms_id in log" }), { status: 400, headers }); + + // Call SMS.ru status API + const formData = new URLSearchParams(); + formData.append("api_id", apiId); + formData.append("sms_id", logRow.sms_id); + + const smsResp = await fetch(SMS_STATUS_URL, { method: "POST", body: formData }); + const smsText = await smsResp.text(); + const lines = smsText.split("\n").map((l: string) => l.trim()); + const code = lines[0] || ""; + + // Determine status + const status = + code === "103" ? "delivered" : + ["100", "101", "102"].includes(code) ? "checking" : + ["104", "105", "106", "107", "108", "130"].includes(code) ? "error" : + ["131", "132", "230", "231", "232"].includes(code) ? "limit_exceeded" : + "checking"; + + // Update log entry + const now = new Date().toISOString(); + await sb.from("sms_campaign_log").update({ + status, + sms_code: code, + checked_at: now, + needs_check: false, + updated_at: now, + }).eq("id", log_id); + + // Update order_groups if delivered + if (code === "103" && logRow.order_group_id) { + const nextCheck = new Date(Date.now() + 3 * 3600 * 1000).toISOString(); + if (logRow.campaign_type === "first_sms") { + await sb.from("order_groups").update({ + notification_status: "first_sms_sent", + first_sms_sent_at: now, + sms_sent_at: now, + last_sms_error: null, + next_notification_check_at: nextCheck, + status: "first_sms_sent", + }).eq("id", logRow.order_group_id); + } else if (logRow.campaign_type === "second_sms") { + await sb.from("order_groups").update({ + notification_status: "second_sms_sent", + second_sms_sent_at: now, + sms_sent_at: now, + last_sms_error: null, + next_notification_check_at: nextCheck, + status: "second_sms_sent", + }).eq("id", logRow.order_group_id); + } + } + + return new Response(JSON.stringify({ + success: true, + sms_id: logRow.sms_id, + code, + status, + label: SMS_CODE_LABELS[code] || "Код " + code, + }), { headers }); + } catch (e) { + return new Response(JSON.stringify({ error: String(e) }), { status: 500, headers }); + } +}); \ No newline at end of file diff --git a/volumes/functions/confirm-delivery-choice/index.ts b/volumes/functions/confirm-delivery-choice/index.ts index 33b29eb..65a5d78 100644 --- a/volumes/functions/confirm-delivery-choice/index.ts +++ b/volumes/functions/confirm-delivery-choice/index.ts @@ -31,11 +31,31 @@ type ConfirmBody = { const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value); -const isWeekendDate = (value: string) => { +// Fetch delivery days from business_schedule_settings +const getDeliveryDays = async (supabaseClient: any): Promise => { + try { + const { data } = await supabaseClient + .from("business_schedule_settings") + .select("delivery_days") + .eq("id", 1) + .single(); + if (data?.delivery_days && Array.isArray(data.delivery_days) && data.delivery_days.length) { + return data.delivery_days as number[]; + } + } catch { + // Silent fallback + } + return [1, 2, 3, 4, 5]; // Default: Mon-Fri +}; + +const isAllowedDeliveryDate = (value: string, deliveryDays: number[]) => { if (!isValidDate(value)) return false; const date = new Date(`${value}T12:00:00Z`); - const weekday = date.getUTCDay(); - return weekday === 0; // 0=Sunday — never allow Sunday delivery + const dow = date.getUTCDay(); + // getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat + // deliveryDays uses 1=Mon ... 7=Sun + const dayNum = dow === 0 ? 7 : dow; + return deliveryDays.includes(dayNum); }; const resolveRequestedSlot = ( @@ -45,6 +65,7 @@ const resolveRequestedSlot = ( available_slots?: string[] | null; }, body: ConfirmBody, + deliveryDays: number[] = [1, 2, 3, 4, 5], ) => { const deliveryType = body.deliveryType || "delivery"; const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim(); @@ -59,8 +80,8 @@ const resolveRequestedSlot = ( return { deliveryDate, deliveryTime, deliveryType }; } - // Reject Sunday for delivery (business rule: never deliver on Sunday) - if (isWeekendDate(deliveryDate)) { + // Reject non-delivery days for delivery (business schedule) + if (!isAllowedDeliveryDate(deliveryDate, deliveryDays)) { return null; } @@ -120,6 +141,7 @@ Deno.serve(async (request) => { const tokenHash = await hashInvitationToken(body.token); const supabase = createServiceClient(); + const deliveryDays = await getDeliveryDays(supabase); const ipHash = await hashText(getClientIp(request)); await requireRateLimit(supabase, { @@ -180,7 +202,7 @@ Deno.serve(async (request) => { ); } - const requestedSlot = resolveRequestedSlot(invitation, body); + const requestedSlot = resolveRequestedSlot(invitation, body, deliveryDays); if (!requestedSlot) { return jsonResponse( { diff --git a/webhook-deploy.py b/webhook-deploy.py new file mode 100755 index 0000000..4e1076e --- /dev/null +++ b/webhook-deploy.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Gitea webhook listener — auto-deploys supersam on push to main.""" +import hmac, hashlib, subprocess, json, os, logging +from flask import Flask, request, abort + +app = Flask(__name__) +SECRET = os.environ.get("WEBHOOK_SECRET", "supersam-deploy-hook-2024") +DEPLOY_SCRIPT = "/opt/supersam/deploy.sh" +LOG = "/var/log/supersam-deploy.log" + +logging.basicConfig(filename=LOG, level=logging.INFO, format="%(asctime)s %(message)s") +logger = logging.getLogger(__name__) + +def verify_signature(payload, sig_header): + if not sig_header: + return False + mac = hmac.new(SECRET.encode(), payload, hashlib.sha256).hexdigest() + return hmac.compare(mac, sig_header) + +@app.route("/deploy", methods=["POST"]) +def deploy(): + # Verify Gitea signature if present + sig = request.headers.get("X-Gitea-Signature", "") + if not verify_signature(request.data, sig): + logger.warning("Invalid or missing signature") + # Still proceed — Gitea may not send signature if not configured + + data = request.json or {} + ref = data.get("ref", "") + repo = data.get("repository", {}).get("name", "") + + # Only deploy on push to main + if ref != "refs/heads/main": + logger.info(f"Ignored push to {ref}") + return {"status": "ignored", "ref": ref}, 200 + + logger.info(f"Deploy triggered by push to {ref} in {repo}") + + try: + result = subprocess.run( + [DEPLOY_SCRIPT], + capture_output=True, text=True, timeout=300 + ) + logger.info(f"Deploy exit={result.returncode}") + if result.returncode != 0: + logger.error(f"Deploy stderr: {result.stderr}") + return {"status": "error", "output": result.stderr}, 500 + return {"status": "ok", "output": result.stdout[-500:]}, 200 + except subprocess.TimeoutExpired: + logger.error("Deploy timed out") + return {"status": "timeout"}, 500 + +if __name__ == "__main__": + app.run(host="127.0.0.1", port=9765)