fix: font zones + stale highlight + fmtTime + SW v49
- FontSettingsContext: body zone first in CSS (specific zones win) - Arbitrary px sizes (10-14px) overrides in table/card zones - fs-zone-table on OrdersTable + LogisticsReadinessBoard - fs-zone-body/heading/nav in AppShell + const navigate = useNavigate() - Stale delivery highlight: isStale() 24h + bg tint, isLinkOpened 👁 - fmtTime defined in OrderDetailPanel (was used but never declared) - SmsStatusCard synced from prod - SW v49 (fixed: addEventListener was broken by sed)
This commit is contained in:
parent
f69a52779f
commit
20873bdc5f
|
|
@ -1,8 +1,8 @@
|
||||||
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
|
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
|
||||||
|
|
||||||
if (!isLocalhost) {
|
if (!isLocalhost) {
|
||||||
const STATIC_CACHE = "construction-delivery-static-v11";
|
const STATIC_CACHE = "construction-delivery-static-v49";
|
||||||
const RUNTIME_CACHE = "construction-delivery-runtime-v11";
|
const RUNTIME_CACHE = "construction-delivery-runtime-v49";
|
||||||
const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
|
const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
|
|
|
||||||
|
|
@ -110,19 +110,32 @@ const TableHeader = () => (
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Stale = updatedAt > 24h and not in agreed/delivered/picked_up/cancelled
|
||||||
|
const STALE_STATUSES = ["delivery:agreed", "delivery:driver_assigned", "delivery:loaded", "delivery:on_route", "delivery:delivered", "delivery:picked_up", "delivery:pickup", "delivery:cancelled"];
|
||||||
|
const isStale = (group) => {
|
||||||
|
const sv = getOrderGroupDisplayStatusValue(group);
|
||||||
|
if (STALE_STATUSES.includes(sv)) return false;
|
||||||
|
if (!group.updatedAt) return false;
|
||||||
|
const diff = Date.now() - new Date(group.updatedAt).getTime();
|
||||||
|
return diff > 24 * 60 * 60 * 1000;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLinkOpened = (group) => !!(group.invitationOpenedAt || (group.invitationAccessCount && group.invitationAccessCount > 0));
|
||||||
|
|
||||||
const renderRow = (group, onSelectSet) => (
|
const renderRow = (group, onSelectSet) => (
|
||||||
<button
|
<button
|
||||||
key={group.id}
|
key={group.id}
|
||||||
type="button"
|
type="button"
|
||||||
className={`grid ${COLS} gap-0 w-full border-t border-[var(--color-border)] text-left transition hover:bg-[var(--color-accent-soft)]`}
|
className={`grid ${COLS} gap-0 w-full border-t border-[var(--color-border)] text-left transition hover:bg-[var(--color-accent-soft)] ${isStale(group) ? "bg-[rgba(191,123,33,0.06)]" : ""}`}
|
||||||
onClick={() => { if (onSelectSet) onSelectSet(group.id); }}
|
onClick={() => { if (onSelectSet) onSelectSet(group.id); }}
|
||||||
>
|
>
|
||||||
<div className="min-w-0 px-3 py-1.5">
|
<div className="min-w-0 px-3 py-1.5">
|
||||||
<div className="text-xs font-medium leading-snug break-words" style={{ display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden" }}>
|
<div className="text-xs font-medium leading-snug break-words" style={{ display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden" }}>
|
||||||
{group.displayTitle || group.customerName || group.groupKey}
|
{group.displayTitle || group.customerName || group.groupKey}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 text-[11px] text-[var(--color-text-muted)]">
|
<div className="mt-0.5 text-[11px] text-[var(--color-text-muted)] flex items-center gap-1">
|
||||||
{group.customerPhone || ""}
|
{group.customerPhone || ""}
|
||||||
|
{isLinkOpened(group) && <span title="Клиент открывал ссылку" style={{ color: "#22c55e", fontSize: "11px" }}>👁</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
|
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,14 @@ import {
|
||||||
} from "../../services/orderGroupViews";
|
} from "../../services/orderGroupViews";
|
||||||
import { getErrorMessage, normalizeNom } from "../../utils/deliveryUtils";
|
import { getErrorMessage, normalizeNom } from "../../utils/deliveryUtils";
|
||||||
|
|
||||||
|
const fmtTime = (ts) => {
|
||||||
|
if (!ts) return "—";
|
||||||
|
try {
|
||||||
|
const d = new Date(ts);
|
||||||
|
return d.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||||
|
} catch { return "—"; }
|
||||||
|
};
|
||||||
|
|
||||||
const DELIVERY_TIME_OPTIONS = ["Первая половина дня", "Вторая половина дня"];
|
const DELIVERY_TIME_OPTIONS = ["Первая половина дня", "Вторая половина дня"];
|
||||||
const STATUS_LABELS = DELIVERY_GROUP_STATUS_LABELS;
|
const STATUS_LABELS = DELIVERY_GROUP_STATUS_LABELS;
|
||||||
|
|
||||||
|
|
@ -643,10 +651,32 @@ export const OrderDetailPanel = ({
|
||||||
|| (deliveryType === "delivery" && !isPickupOrder)
|
|| (deliveryType === "delivery" && !isPickupOrder)
|
||||||
);
|
);
|
||||||
const canEditDelivery = canManageDelivery && ["admin", "mega_admin", "logistician"].includes(userRole);
|
const canEditDelivery = canManageDelivery && ["admin", "mega_admin", "logistician"].includes(userRole);
|
||||||
const agreedDeliveryLabel = [
|
const agreedDeliveryLabel = (() => {
|
||||||
|
if (isPickupOrder) {
|
||||||
|
const parts = [
|
||||||
|
formatDeliveryDateDisplay(order.pickupDate || order.pickup_date),
|
||||||
|
order.pickupTimeSlot || order.pickup_time_slot || order.deliveryTime || order.deliveryHalfDay,
|
||||||
|
].filter((value) => value && value !== "Нет данных");
|
||||||
|
if (parts.length > 0) return parts.join(" · ");
|
||||||
|
// Fallback: show manual_confirmation_at date if no pickup date
|
||||||
|
if (order.manualConfirmationAt || order.manual_confirmation_at) {
|
||||||
|
const mcDate = order.manualConfirmationAt || order.manual_confirmation_at;
|
||||||
|
return `Согласовано: ${fmtTime(mcDate)}`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const parts = [
|
||||||
formatDeliveryDateDisplay(order.deliveryDate),
|
formatDeliveryDateDisplay(order.deliveryDate),
|
||||||
order.deliveryTime || order.deliveryHalfDay,
|
order.deliveryTime || order.deliveryHalfDay,
|
||||||
].filter((value) => value && value !== "Нет данных").join(" · ");
|
].filter((value) => value && value !== "Нет данных");
|
||||||
|
if (parts.length > 0) return parts.join(" · ");
|
||||||
|
// Fallback: show manual_confirmation_at date if no delivery date
|
||||||
|
if (order.manualConfirmationAt || order.manual_confirmation_at) {
|
||||||
|
const mcDate = order.manualConfirmationAt || order.manual_confirmation_at;
|
||||||
|
return `Согласовано: ${fmtTime(mcDate)}`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
})();
|
||||||
|
|
||||||
const handleSaveDeliveryChoice = async () => {
|
const handleSaveDeliveryChoice = async () => {
|
||||||
const effectiveDate = deliveryType === "pickup" ? pickupDate : deliveryDate;
|
const effectiveDate = deliveryType === "pickup" ? pickupDate : deliveryDate;
|
||||||
|
|
@ -937,7 +967,7 @@ export const OrderDetailPanel = ({
|
||||||
{deliveryType === "pickup" ? "Самовывоз согласован" : "Доставка согласована"}
|
{deliveryType === "pickup" ? "Самовывоз согласован" : "Доставка согласована"}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-lg font-semibold">
|
<p className="mt-1 text-lg font-semibold">
|
||||||
{agreedDeliveryLabel || "Дата и время сохранены"}
|
{agreedDeliveryLabel || "Дата не указана — нажмите «Изменить дату»"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge tone="accent">Согласовано</Badge>
|
<Badge tone="accent">Согласовано</Badge>
|
||||||
|
|
@ -1033,7 +1063,13 @@ export const OrderDetailPanel = ({
|
||||||
if (action.type === "hint") {
|
if (action.type === "hint") {
|
||||||
setFormMessage(action.hint);
|
setFormMessage(action.hint);
|
||||||
} else if (action.type === "status") {
|
} else if (action.type === "status") {
|
||||||
setConfirmAction({ type: "status", status: action.status });
|
setConfirmAction({
|
||||||
|
type: "status",
|
||||||
|
status: action.status,
|
||||||
|
label: action.label,
|
||||||
|
mismatch: action.mismatch,
|
||||||
|
deliveryType: action.deliveryType,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1077,33 +1113,49 @@ export const OrderDetailPanel = ({
|
||||||
const currentStatus = order.deliveryStatus || order.delivery_status;
|
const currentStatus = order.deliveryStatus || order.delivery_status;
|
||||||
const IN_TRANSIT_STATUSES = ["loaded", "on_route"];
|
const IN_TRANSIT_STATUSES = ["loaded", "on_route"];
|
||||||
const isOnRoute = IN_TRANSIT_STATUSES.includes(currentStatus);
|
const isOnRoute = IN_TRANSIT_STATUSES.includes(currentStatus);
|
||||||
|
const isPickup = (order.deliveryType || order.delivery_type) === "pickup" || currentStatus === "pickup";
|
||||||
|
|
||||||
let statusOptions = [];
|
let statusOptions = [];
|
||||||
if (currentStatus === "delivered" || currentStatus === "picked_up" || currentStatus === "problem" || currentStatus === "cancelled" || currentStatus === "paid_storage") {
|
if (currentStatus === "delivered" || currentStatus === "picked_up" || currentStatus === "problem" || currentStatus === "cancelled" || currentStatus === "paid_storage") {
|
||||||
statusOptions = [];
|
statusOptions = [];
|
||||||
} else {
|
} else {
|
||||||
|
// Primary button matches delivery type, secondary requires confirmation
|
||||||
|
if (isPickup) {
|
||||||
statusOptions = [
|
statusOptions = [
|
||||||
{ value: "delivered", label: "Доставлено" },
|
{ value: "picked_up", label: "Вывезено", mismatch: false },
|
||||||
{ value: "picked_up", label: "Вывезено" },
|
{ value: "delivered", label: "Доставлено", mismatch: true },
|
||||||
{ value: "problem", label: "Проблема" },
|
{ value: "problem", label: "Проблема" },
|
||||||
];
|
];
|
||||||
|
} else {
|
||||||
|
statusOptions = [
|
||||||
|
{ value: "delivered", label: "Доставлено", mismatch: false },
|
||||||
|
{ value: "picked_up", label: "Вывезено", mismatch: true },
|
||||||
|
{ value: "problem", label: "Проблема" },
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (statusOptions.length === 0) return null;
|
if (statusOptions.length === 0) return null;
|
||||||
|
|
||||||
return statusOptions.map((statusOption) => {
|
return statusOptions.map((statusOption) => {
|
||||||
const isSelected = pendingStatus?.value === statusOption.value;
|
const isSelected = pendingStatus?.value === statusOption.value;
|
||||||
|
const isMismatch = statusOption.mismatch;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
key={statusOption.value}
|
key={statusOption.value}
|
||||||
variant={isSelected ? "primary" : "secondary"}
|
variant={isSelected ? "primary" : isMismatch ? "ghost" : "secondary"}
|
||||||
disabled={isSavingStatusChange}
|
disabled={isSavingStatusChange}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (statusOption.value === "problem") {
|
if (statusOption.value === "problem") {
|
||||||
setProblemReason("selecting");
|
setProblemReason("selecting");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPendingStatus({ value: statusOption.value });
|
setPendingStatus({
|
||||||
|
value: statusOption.value,
|
||||||
|
label: statusOption.label,
|
||||||
|
mismatch: isMismatch,
|
||||||
|
deliveryType: isPickup ? "pickup" : "delivery",
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{statusOption.label}
|
{statusOption.label}
|
||||||
|
|
@ -1114,6 +1166,25 @@ export const OrderDetailPanel = ({
|
||||||
</div>
|
</div>
|
||||||
{pendingStatus ? (
|
{pendingStatus ? (
|
||||||
<div className="flex items-center gap-3 mt-2">
|
<div className="flex items-center gap-3 mt-2">
|
||||||
|
{pendingStatus.mismatch ? (
|
||||||
|
<div className="flex-1 rounded-xl border border-[var(--color-warning)] bg-[var(--color-warning-soft)] p-3 text-sm">
|
||||||
|
<p className="font-medium text-[var(--color-text)]">
|
||||||
|
⚠ Статус возможно не совпадает
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[var(--color-text-muted)]">
|
||||||
|
Тип отгрузки: <strong>{pendingStatus.deliveryType === "pickup" ? "🏪 Самовывоз" : "🚚 Доставка"}</strong>.
|
||||||
|
Вы выбрали: <strong>«{pendingStatus.label}»</strong>.
|
||||||
|
{pendingStatus.deliveryType === "pickup"
|
||||||
|
? "Обычно при самовывозе используется «Вывезено»."
|
||||||
|
: "Обычно при доставке используется «Доставлено»."}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-[var(--color-text)]">Подтвердить действие?</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
|
Установить статус: <strong>{pendingStatus.label}</strong>?
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
disabled={isSavingStatusChange}
|
disabled={isSavingStatusChange}
|
||||||
|
|
@ -1319,8 +1390,11 @@ export const OrderDetailPanel = ({
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
open={confirmAction?.type === 'status'}
|
open={confirmAction?.type === 'status'}
|
||||||
title="Изменить статус?"
|
title={confirmAction?.mismatch ? "⚠ Статус возможно не совпадает" : "Изменить статус?"}
|
||||||
message={`Установить статус «${STATUS_LABELS[confirmAction?.status] || confirmAction?.status}»?`}
|
message={confirmAction?.mismatch
|
||||||
|
? `Тип отгрузки: ${confirmAction.deliveryType === "pickup" ? "🏪 Самовывоз" : "🚚 Доставка"}. Вы выбрали «${confirmAction.label}». ${confirmAction.deliveryType === "pickup" ? "Обычно при самовывозе используется «Вывезено»." : "Обычно при доставке используется «Доставлено»."} Подтвердить?`
|
||||||
|
: `Установить статус «${confirmAction?.label || STATUS_LABELS[confirmAction?.status] || confirmAction?.status}»?`
|
||||||
|
}
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
const status = confirmAction.status;
|
const status = confirmAction.status;
|
||||||
setConfirmAction(null);
|
setConfirmAction(null);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,288 @@
|
||||||
|
/**
|
||||||
|
* @file SmsStatusCard.jsx
|
||||||
|
* @description SMS status + restart button for a specific delivery group.
|
||||||
|
* Shows: current notification status, when SMS was sent, countdown to next SMS,
|
||||||
|
* and a "Restart SMS" button that resets this group's notification status.
|
||||||
|
*/
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
|
import { Badge } from "../UI/Badge";
|
||||||
|
import { supabase } from "../../supabaseClient";
|
||||||
|
|
||||||
|
// ── Status labels ────────────────────────────────────────────────────────────
|
||||||
|
const NOTIF_LABELS = {
|
||||||
|
not_started: "Не отправлено",
|
||||||
|
link_ready: "В очереди на отправку",
|
||||||
|
sms_sending: "Отправляется…",
|
||||||
|
first_sms_sent: "1-е SMS отправлено",
|
||||||
|
second_sms_sending: "2-я SMS отправляется…",
|
||||||
|
second_sms_sent: "2-е SMS отправлено",
|
||||||
|
send_failed: "Ошибка отправки",
|
||||||
|
manual_required: "Требуется ручное управление",
|
||||||
|
paid_storage_sending: "Отправляется…",
|
||||||
|
paid_storage_sent: "Платное хранение: отправлено",
|
||||||
|
draft: "Черновик",
|
||||||
|
};
|
||||||
|
|
||||||
|
const NOTIF_TONES = {
|
||||||
|
not_started: "neutral",
|
||||||
|
link_ready: "warning",
|
||||||
|
sms_sending: "info",
|
||||||
|
first_sms_sent: "info",
|
||||||
|
second_sms_sending: "info",
|
||||||
|
second_sms_sent: "accent",
|
||||||
|
send_failed: "danger",
|
||||||
|
manual_required: "warning",
|
||||||
|
paid_storage_sending: "info",
|
||||||
|
paid_storage_sent: "accent",
|
||||||
|
draft: "neutral",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
const fmtTime = (ts) => {
|
||||||
|
if (!ts) return "—";
|
||||||
|
try {
|
||||||
|
return new Date(ts).toLocaleString("ru-RU", {
|
||||||
|
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit",
|
||||||
|
});
|
||||||
|
} catch { return ts; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtCountdown = (targetTs) => {
|
||||||
|
if (!targetTs) return null;
|
||||||
|
const diff = new Date(targetTs).getTime() - Date.now();
|
||||||
|
if (diff <= 0) return "готово к отправке";
|
||||||
|
const m = Math.floor(diff / 60000);
|
||||||
|
const s = Math.floor((diff % 60000) / 1000);
|
||||||
|
if (m >= 60) {
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
const restM = m % 60;
|
||||||
|
return `${h}ч ${restM}м`;
|
||||||
|
}
|
||||||
|
return `${m}м ${s}с`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Component ────────────────────────────────────────────────────────────────
|
||||||
|
export const SmsStatusCard = ({ order, userRole }) => {
|
||||||
|
const [restarting, setRestarting] = useState(false);
|
||||||
|
const [restartDone, setRestartDone] = useState(false);
|
||||||
|
const [now, setNow] = useState(Date.now());
|
||||||
|
const [smsLog, setSmsLog] = useState([]);
|
||||||
|
const [loadingLog, setLoadingLog] = useState(false);
|
||||||
|
|
||||||
|
// Tick every 1s for countdown
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load SMS log for this group
|
||||||
|
const loadSmsLog = useCallback(async () => {
|
||||||
|
if (!order?.id) return;
|
||||||
|
setLoadingLog(true);
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("sms_campaign_log")
|
||||||
|
.select("*")
|
||||||
|
.eq("order_group_id", order.id)
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
.limit(10);
|
||||||
|
if (error) throw error;
|
||||||
|
setSmsLog(data || []);
|
||||||
|
} catch (e) {
|
||||||
|
// Silent fail — not critical
|
||||||
|
} finally {
|
||||||
|
setLoadingLog(false);
|
||||||
|
}
|
||||||
|
}, [order?.id]);
|
||||||
|
|
||||||
|
useEffect(() => { loadSmsLog(); }, [loadSmsLog]);
|
||||||
|
|
||||||
|
// Can manage?
|
||||||
|
const canManage = ["mega_admin", "admin"].includes(userRole);
|
||||||
|
|
||||||
|
const notifStatus = order.notificationStatus || order.notification_status || "not_started";
|
||||||
|
const nextCheck = order.nextNotificationCheckAt || order.next_notification_check_at;
|
||||||
|
const firstSmsAt = order.firstSmsSentAt || order.first_sms_sent_at;
|
||||||
|
const secondSmsAt = order.secondSmsSentAt || order.second_sms_sent_at;
|
||||||
|
const smsSentAt = order.smsSentAt || order.sms_sent_at;
|
||||||
|
const smsAttempts = order.smsAttempts ?? order.sms_attempts ?? 0;
|
||||||
|
const lastError = order.lastSmsError || order.last_sms_error;
|
||||||
|
|
||||||
|
// Restart: reset this group's notification_status to link_ready
|
||||||
|
const handleRestart = async () => {
|
||||||
|
if (!order?.id) return;
|
||||||
|
setRestarting(true);
|
||||||
|
try {
|
||||||
|
// Reset this group to link_ready — restart SMS flow from beginning
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from("order_groups")
|
||||||
|
.update({
|
||||||
|
notification_status: "link_ready",
|
||||||
|
sms_sent_at: null,
|
||||||
|
first_sms_sent_at: null,
|
||||||
|
second_sms_sent_at: null,
|
||||||
|
next_notification_check_at: null,
|
||||||
|
sms_attempts: 0,
|
||||||
|
last_sms_error: null,
|
||||||
|
})
|
||||||
|
.eq("id", order.id);
|
||||||
|
if (updateError) throw updateError;
|
||||||
|
setRestartDone(true);
|
||||||
|
setTimeout(() => setRestartDone(false), 3000);
|
||||||
|
// Reload log
|
||||||
|
loadSmsLog();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Restart SMS error:", e);
|
||||||
|
} finally {
|
||||||
|
setRestarting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Restart second SMS only (keep first SMS sent)
|
||||||
|
const handleRestartSecond = async () => {
|
||||||
|
if (!order?.id) return;
|
||||||
|
setRestarting(true);
|
||||||
|
try {
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from("order_groups")
|
||||||
|
.update({
|
||||||
|
notification_status: "first_sms_sent",
|
||||||
|
second_sms_sent_at: null,
|
||||||
|
next_notification_check_at: null,
|
||||||
|
sms_attempts: 0,
|
||||||
|
last_sms_error: null,
|
||||||
|
})
|
||||||
|
.eq("id", order.id);
|
||||||
|
if (updateError) throw updateError;
|
||||||
|
setRestartDone(true);
|
||||||
|
setTimeout(() => setRestartDone(false), 3000);
|
||||||
|
loadSmsLog();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Restart second SMS error:", e);
|
||||||
|
} finally {
|
||||||
|
setRestarting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const countdown = nextCheck ? fmtCountdown(nextCheck) : null;
|
||||||
|
// Determine SMS display state
|
||||||
|
const hasSmsSent = !!smsSentAt; // SMS physically sent (may not be delivered yet)
|
||||||
|
const hasFirstSms = !!firstSmsAt; // First SMS confirmed delivered (103)
|
||||||
|
const hasSecondSms = !!secondSmsAt; // Second SMS confirmed delivered (103)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel className="p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--color-text)]">📱 SMS-уведомления</h3>
|
||||||
|
<Badge tone={NOTIF_TONES[notifStatus] || "neutral"}>
|
||||||
|
{NOTIF_LABELS[notifStatus] || notifStatus}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
{/* 1st SMS */}
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className={`mt-0.5 h-2 w-2 rounded-full ${hasFirstSms ? "bg-[#22c55e]" : hasSmsSent && notifStatus === "sms_sending" ? "bg-[var(--color-accent)]" : notifStatus === "link_ready" || notifStatus === "not_started" ? "bg-[var(--color-warning)]" : "bg-[var(--color-border)]"}`} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-[var(--color-text)]">1-е SMS</div>
|
||||||
|
{hasFirstSms ? (
|
||||||
|
<div className="text-[var(--color-text-muted)]">{fmtTime(firstSmsAt)} ✓ доставлено</div>
|
||||||
|
) : hasSmsSent && notifStatus === "sms_sending" ? (
|
||||||
|
<div className="text-[var(--color-text-muted)]">{fmtTime(smsSentAt)} · отправлено, ждём подтверждения…</div>
|
||||||
|
) : notifStatus === "link_ready" ? (
|
||||||
|
<div className="text-[var(--color-text-muted)]">в очереди на отправку</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-[var(--color-text-muted)]">не отправлено</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2nd SMS */}
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className={`mt-0.5 h-2 w-2 rounded-full ${hasSecondSms ? "bg-[#22c55e]" : notifStatus === "first_sms_sent" ? "bg-[var(--color-warning)]" : "bg-[var(--color-border)]"}`} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-[var(--color-text)]">2-е SMS</div>
|
||||||
|
{hasSecondSms ? (
|
||||||
|
<div className="text-[var(--color-text-muted)]">{fmtTime(secondSmsAt)}</div>
|
||||||
|
) : notifStatus === "first_sms_sent" && countdown ? (
|
||||||
|
<div className="text-[var(--color-text-muted)]">
|
||||||
|
отправка через <span className="font-mono text-[var(--color-accent)]">{countdown}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-[var(--color-text-muted)]">—</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Next check countdown */}
|
||||||
|
{countdown && notifStatus !== "second_sms_sent" && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg bg-[var(--color-surface-strong)] px-2 py-1.5">
|
||||||
|
<span className="text-[var(--color-text-muted)]">⏱ Следующая проверка:</span>
|
||||||
|
<span className="font-mono text-[var(--color-accent)]">{countdown}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{lastError && (
|
||||||
|
<div className="rounded-lg bg-[rgba(239,68,68,0.08)] px-2 py-1.5 text-[var(--color-danger)]">
|
||||||
|
⚠ {lastError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Attempts */}
|
||||||
|
{smsAttempts > 0 && (
|
||||||
|
<div className="text-[var(--color-text-muted)]">Попыток отправки: {smsAttempts}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SMS log for this group */}
|
||||||
|
{smsLog.length > 0 && (
|
||||||
|
<div className="mt-3 border-t border-[var(--color-border)] pt-3">
|
||||||
|
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">История SMS</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{smsLog.map((log) => (
|
||||||
|
<div key={log.id} className="flex items-center gap-2 text-[11px]">
|
||||||
|
<span className="text-[var(--color-text-muted)]">{fmtTime(log.created_at)}</span>
|
||||||
|
<Badge tone={log.status === "delivered" ? "accent" : log.status === "sent" || log.status === "checking" ? "info" : "danger"}>
|
||||||
|
{log.status === "delivered" ? "доставлено" : log.status === "sent" ? "отправлено" : log.status === "checking" ? "проверка" : log.status === "expired" ? "истёк" : log.status}
|
||||||
|
</Badge>
|
||||||
|
{log.sent_to && (
|
||||||
|
<span className="text-[var(--color-text-muted)]">→ {log.sent_to}</span>
|
||||||
|
)}
|
||||||
|
{log.sms_code && (
|
||||||
|
<span className="text-[var(--color-text-muted)]">код: {log.sms_code}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Restart buttons */}
|
||||||
|
{canManage && (
|
||||||
|
<div className="mt-3 flex gap-2 border-t border-[var(--color-border)] pt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRestart}
|
||||||
|
disabled={restarting}
|
||||||
|
className="rounded-xl border border-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-[var(--color-accent)] hover:bg-[var(--color-accent-soft)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{restarting ? "Сброс…" : restartDone ? "✓ Сброшено" : "🔄 Перезапустить SMS"}
|
||||||
|
</button>
|
||||||
|
{hasFirstSms && !hasSecondSms && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRestartSecond}
|
||||||
|
disabled={restarting}
|
||||||
|
className="rounded-xl border border-[var(--color-border)] px-3 py-1.5 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
⟳ Только 2-ю SMS
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -29,6 +29,7 @@ export const AppShell = ({
|
||||||
}) => {
|
}) => {
|
||||||
const shouldShowMobileNav = !isGuideOpen && navItems.length > 1;
|
const shouldShowMobileNav = !isGuideOpen && navItems.length > 1;
|
||||||
const [showNotifSettings, setShowNotifSettings] = React.useState(false);
|
const [showNotifSettings, setShowNotifSettings] = React.useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
if (showNotifSettings) {
|
if (showNotifSettings) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -47,9 +47,18 @@
|
||||||
/* ── Zone overrides ──────────────────────────────────────────────────────
|
/* ── Zone overrides ──────────────────────────────────────────────────────
|
||||||
.fs-zone-* wrappers override Tailwind text-* classes inside them.
|
.fs-zone-* wrappers override Tailwind text-* classes inside them.
|
||||||
Specificity: .fs-zone-X .text-Y (0,2,0) > .text-Y (0,1,0).
|
Specificity: .fs-zone-X .text-Y (0,2,0) > .text-Y (0,1,0).
|
||||||
|
Body zone FIRST — specific zones (table/card/nav/heading) come after
|
||||||
|
and win at equal specificity when nested inside body.
|
||||||
This lets us scale fonts without patching every component.
|
This lets us scale fonts without patching every component.
|
||||||
────────────────────────────────────────────────────────────────────────── */
|
────────────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Body zone — FIRST so specific zones inside body win at equal specificity */
|
||||||
|
.fs-zone-body { font-size: calc(1rem * var(--fs-scale-body, 1)); }
|
||||||
|
.fs-zone-body .text-xs { font-size: calc(0.75rem * var(--fs-scale-body, 1)); }
|
||||||
|
.fs-zone-body .text-sm { font-size: calc(0.875rem * var(--fs-scale-body, 1)); }
|
||||||
|
.fs-zone-body .text-base { font-size: calc(1rem * var(--fs-scale-body, 1)); }
|
||||||
|
.fs-zone-body .text-lg { font-size: calc(1.125rem * var(--fs-scale-body, 1)); }
|
||||||
|
|
||||||
/* Table zone */
|
/* Table zone */
|
||||||
.fs-zone-table { font-size: calc(0.875rem * var(--fs-scale-table, 1)); }
|
.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-xs { font-size: calc(0.75rem * var(--fs-scale-table, 1)); }
|
||||||
|
|
@ -92,10 +101,3 @@
|
||||||
.fs-zone-small { font-size: calc(0.75rem * var(--fs-scale-small, 1)); }
|
.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-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)); }
|
.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)); }
|
|
||||||
Loading…
Reference in New Issue