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";
|
||||
|
||||
if (!isLocalhost) {
|
||||
const STATIC_CACHE = "construction-delivery-static-v11";
|
||||
const RUNTIME_CACHE = "construction-delivery-runtime-v11";
|
||||
const STATIC_CACHE = "construction-delivery-static-v49";
|
||||
const RUNTIME_CACHE = "construction-delivery-runtime-v49";
|
||||
const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
|
|
|
|||
|
|
@ -110,19 +110,32 @@ const TableHeader = () => (
|
|||
</div>
|
||||
);
|
||||
|
||||
// Stale = updatedAt > 24h and not in agreed/delivered/picked_up/cancelled
|
||||
const STALE_STATUSES = ["delivery:agreed", "delivery:driver_assigned", "delivery:loaded", "delivery:on_route", "delivery:delivered", "delivery:picked_up", "delivery:pickup", "delivery:cancelled"];
|
||||
const isStale = (group) => {
|
||||
const sv = getOrderGroupDisplayStatusValue(group);
|
||||
if (STALE_STATUSES.includes(sv)) return false;
|
||||
if (!group.updatedAt) return false;
|
||||
const diff = Date.now() - new Date(group.updatedAt).getTime();
|
||||
return diff > 24 * 60 * 60 * 1000;
|
||||
};
|
||||
|
||||
const isLinkOpened = (group) => !!(group.invitationOpenedAt || (group.invitationAccessCount && group.invitationAccessCount > 0));
|
||||
|
||||
const renderRow = (group, onSelectSet) => (
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
className={`grid ${COLS} gap-0 w-full border-t border-[var(--color-border)] text-left transition hover:bg-[var(--color-accent-soft)]`}
|
||||
className={`grid ${COLS} gap-0 w-full border-t border-[var(--color-border)] text-left transition hover:bg-[var(--color-accent-soft)] ${isStale(group) ? "bg-[rgba(191,123,33,0.06)]" : ""}`}
|
||||
onClick={() => { if (onSelectSet) onSelectSet(group.id); }}
|
||||
>
|
||||
<div className="min-w-0 px-3 py-1.5">
|
||||
<div className="text-xs font-medium leading-snug break-words" style={{ display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden" }}>
|
||||
{group.displayTitle || group.customerName || group.groupKey}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-[var(--color-text-muted)]">
|
||||
<div className="mt-0.5 text-[11px] text-[var(--color-text-muted)] flex items-center gap-1">
|
||||
{group.customerPhone || ""}
|
||||
{isLinkOpened(group) && <span title="Клиент открывал ссылку" style={{ color: "#22c55e", fontSize: "11px" }}>👁</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
|
||||
|
|
|
|||
|
|
@ -56,6 +56,14 @@ import {
|
|||
} from "../../services/orderGroupViews";
|
||||
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 STATUS_LABELS = DELIVERY_GROUP_STATUS_LABELS;
|
||||
|
||||
|
|
@ -643,10 +651,32 @@ export const OrderDetailPanel = ({
|
|||
|| (deliveryType === "delivery" && !isPickupOrder)
|
||||
);
|
||||
const canEditDelivery = canManageDelivery && ["admin", "mega_admin", "logistician"].includes(userRole);
|
||||
const agreedDeliveryLabel = [
|
||||
formatDeliveryDateDisplay(order.deliveryDate),
|
||||
order.deliveryTime || order.deliveryHalfDay,
|
||||
].filter((value) => value && value !== "Нет данных").join(" · ");
|
||||
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),
|
||||
order.deliveryTime || order.deliveryHalfDay,
|
||||
].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 effectiveDate = deliveryType === "pickup" ? pickupDate : deliveryDate;
|
||||
|
|
@ -937,7 +967,7 @@ export const OrderDetailPanel = ({
|
|||
{deliveryType === "pickup" ? "Самовывоз согласован" : "Доставка согласована"}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold">
|
||||
{agreedDeliveryLabel || "Дата и время сохранены"}
|
||||
{agreedDeliveryLabel || "Дата не указана — нажмите «Изменить дату»"}
|
||||
</p>
|
||||
</div>
|
||||
<Badge tone="accent">Согласовано</Badge>
|
||||
|
|
@ -1033,7 +1063,13 @@ export const OrderDetailPanel = ({
|
|||
if (action.type === "hint") {
|
||||
setFormMessage(action.hint);
|
||||
} 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 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") {
|
||||
statusOptions = [];
|
||||
} else {
|
||||
statusOptions = [
|
||||
{ value: "delivered", label: "Доставлено" },
|
||||
{ value: "picked_up", label: "Вывезено" },
|
||||
{ value: "problem", label: "Проблема" },
|
||||
];
|
||||
// Primary button matches delivery type, secondary requires confirmation
|
||||
if (isPickup) {
|
||||
statusOptions = [
|
||||
{ value: "picked_up", label: "Вывезено", mismatch: false },
|
||||
{ value: "delivered", label: "Доставлено", mismatch: true },
|
||||
{ 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;
|
||||
|
||||
return statusOptions.map((statusOption) => {
|
||||
const isSelected = pendingStatus?.value === statusOption.value;
|
||||
const isMismatch = statusOption.mismatch;
|
||||
return (
|
||||
<Button
|
||||
key={statusOption.value}
|
||||
variant={isSelected ? "primary" : "secondary"}
|
||||
variant={isSelected ? "primary" : isMismatch ? "ghost" : "secondary"}
|
||||
disabled={isSavingStatusChange}
|
||||
onClick={() => {
|
||||
if (statusOption.value === "problem") {
|
||||
setProblemReason("selecting");
|
||||
return;
|
||||
}
|
||||
setPendingStatus({ value: statusOption.value });
|
||||
setPendingStatus({
|
||||
value: statusOption.value,
|
||||
label: statusOption.label,
|
||||
mismatch: isMismatch,
|
||||
deliveryType: isPickup ? "pickup" : "delivery",
|
||||
});
|
||||
}}
|
||||
>
|
||||
{statusOption.label}
|
||||
|
|
@ -1114,6 +1166,25 @@ export const OrderDetailPanel = ({
|
|||
</div>
|
||||
{pendingStatus ? (
|
||||
<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
|
||||
variant="primary"
|
||||
disabled={isSavingStatusChange}
|
||||
|
|
@ -1319,8 +1390,11 @@ export const OrderDetailPanel = ({
|
|||
|
||||
<ConfirmModal
|
||||
open={confirmAction?.type === 'status'}
|
||||
title="Изменить статус?"
|
||||
message={`Установить статус «${STATUS_LABELS[confirmAction?.status] || confirmAction?.status}»?`}
|
||||
title={confirmAction?.mismatch ? "⚠ Статус возможно не совпадает" : "Изменить статус?"}
|
||||
message={confirmAction?.mismatch
|
||||
? `Тип отгрузки: ${confirmAction.deliveryType === "pickup" ? "🏪 Самовывоз" : "🚚 Доставка"}. Вы выбрали «${confirmAction.label}». ${confirmAction.deliveryType === "pickup" ? "Обычно при самовывозе используется «Вывезено»." : "Обычно при доставке используется «Доставлено»."} Подтвердить?`
|
||||
: `Установить статус «${confirmAction?.label || STATUS_LABELS[confirmAction?.status] || confirmAction?.status}»?`
|
||||
}
|
||||
onConfirm={() => {
|
||||
const status = confirmAction.status;
|
||||
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 [showNotifSettings, setShowNotifSettings] = React.useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (showNotifSettings) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -47,9 +47,18 @@
|
|||
/* ── Zone overrides ──────────────────────────────────────────────────────
|
||||
.fs-zone-* wrappers override Tailwind text-* classes inside them.
|
||||
Specificity: .fs-zone-X .text-Y (0,2,0) > .text-Y (0,1,0).
|
||||
Body zone FIRST — specific zones (table/card/nav/heading) come after
|
||||
and win at equal specificity when nested inside body.
|
||||
This lets us scale fonts without patching every component.
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Body zone — FIRST so specific zones inside body win at equal specificity */
|
||||
.fs-zone-body { font-size: calc(1rem * var(--fs-scale-body, 1)); }
|
||||
.fs-zone-body .text-xs { font-size: calc(0.75rem * var(--fs-scale-body, 1)); }
|
||||
.fs-zone-body .text-sm { font-size: calc(0.875rem * var(--fs-scale-body, 1)); }
|
||||
.fs-zone-body .text-base { font-size: calc(1rem * var(--fs-scale-body, 1)); }
|
||||
.fs-zone-body .text-lg { font-size: calc(1.125rem * var(--fs-scale-body, 1)); }
|
||||
|
||||
/* Table zone */
|
||||
.fs-zone-table { font-size: calc(0.875rem * var(--fs-scale-table, 1)); }
|
||||
.fs-zone-table .text-xs { font-size: calc(0.75rem * var(--fs-scale-table, 1)); }
|
||||
|
|
@ -91,11 +100,4 @@
|
|||
/* 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)); }
|
||||
.fs-zone-small .text-sm { font-size: calc(0.875rem * var(--fs-scale-small, 1)); }
|
||||
Loading…
Reference in New Issue