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:
root 2026-07-02 14:25:40 +00:00
parent f69a52779f
commit 20873bdc5f
6 changed files with 405 additions and 27 deletions

View File

@ -1,8 +1,8 @@
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
if (!isLocalhost) {
const STATIC_CACHE = "construction-delivery-static-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) => {

View File

@ -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)]">

View File

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

View File

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

View File

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

View File

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