fix(call): show & persist 'called' status

- mapOrderGroupRowToDeliveryGroup dropped called/called_at/called_comment
  fields (they were selected but never mapped) so the panel always read
  'not called' and freshly saved calls appeared to vanish.
- CalledPanel: one-tap 'Звонили' marks called with now; app-styled
  date/time dropdowns (CallDateTime) default to current moment for fast
  entry; comment edit form pre-filled.
- OrderDetailPanel: forward real onRefreshOrder (was a silent no-op) so
  the card refreshes from DB after a save.
This commit is contained in:
Hermes 2026-08-21 14:09:54 +02:00
parent f5a06188a2
commit 6ca8bd545e
5 changed files with 238 additions and 44 deletions

View File

@ -0,0 +1,149 @@
import React from "react";
// ─── helpers ────────────────────────────────────────────────────────────────
const pad = (n) => String(n).padStart(2, "0");
export const toDateInputValue = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
export const toTimeInputValue = (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}`;
const formatRuDate = (dateStr) => {
if (!dateStr) return "";
const [y, m, d] = dateStr.split("-").map(Number);
if (!y || !m || !d) return dateStr;
const dt = new Date(y, m - 1, d);
const weekday = dt.toLocaleDateString("ru-RU", { weekday: "short" });
return `${pad(d)}.${pad(m)}.${y} (${weekday[0]})`;
};
const useOutsideClose = (isOpen, setIsOpen) => {
const menuRef = React.useRef(null);
React.useEffect(() => {
if (!isOpen) return undefined;
const onPointerDown = (e) => {
if (menuRef.current && !menuRef.current.contains(e.target)) setIsOpen(false);
};
const onKeyDown = (e) => {
if (e.key === "Escape") setIsOpen(false);
};
document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [isOpen, setIsOpen]);
return menuRef;
};
const SelectShell = ({ label, valueLabel, children, isOpen, setIsOpen, menuRef, wide }) => (
<div className="min-w-0 flex-1">
<label className="mb-1 block text-xs font-medium text-[var(--color-text-muted)]">{label}</label>
<div ref={menuRef} className="relative">
<button
type="button"
aria-haspopup="listbox"
aria-expanded={isOpen}
className={[
"flex h-[46px] w-full items-center justify-between rounded-2xl border px-4 text-left text-sm transition",
isOpen
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text)] hover:border-[var(--color-accent)]",
].join(" ")}
onClick={() => setIsOpen((c) => !c)}
>
<span className="min-w-0 flex-1 truncate">{valueLabel || "Выбрать"}</span>
<span aria-hidden="true" className="ml-3 text-[var(--color-text-muted)]">▾</span>
</button>
{isOpen ? (
<div
role="listbox"
className={[
"absolute left-0 top-full z-20 mt-2 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-dropdown-surface)] shadow-soft",
wide ? "w-max min-w-full" : "w-full",
].join(" ")}
>
{children}
</div>
) : null}
</div>
</div>
);
const OptionRow = ({ value, selectedValue, label, onPick }) => (
<button
type="button"
role="option"
aria-selected={selectedValue === value}
className={[
"flex w-full items-center justify-between px-4 py-2.5 text-left text-sm transition",
selectedValue === value
? "bg-[var(--color-accent-soft)] text-[var(--color-text)]"
: "text-[var(--color-text)] hover:bg-[var(--color-surface-strong)]",
].join(" ")}
onClick={() => onPick(value)}
>
<span className="min-w-0 flex-1 truncate">{label}</span>
{selectedValue === value ? <span className="ml-3 text-[var(--color-accent)]">✓</span> : null}
</button>
);
// ─── Date ─────────────────────────────────────────────────────────────────
const buildDateOptions = (currentValue) => {
const today = new Date();
const options = [];
for (let offset = -15; offset <= 15; offset += 1) {
const d = new Date(today.getFullYear(), today.getMonth(), today.getDate() + offset);
options.push({ value: toDateInputValue(d), label: formatRuDate(toDateInputValue(d)) });
}
if (currentValue && !options.some((o) => o.value === currentValue)) {
options.unshift({ value: currentValue, label: formatRuDate(currentValue) });
}
return options;
};
export const CallDateSelect = ({ value, onChange }) => {
const [isOpen, setIsOpen] = React.useState(false);
const menuRef = useOutsideClose(isOpen, setIsOpen);
const options = React.useMemo(() => buildDateOptions(value), [value]);
return (
<SelectShell label="Дата звонка" valueLabel={formatRuDate(value)} isOpen={isOpen} setIsOpen={setIsOpen} menuRef={menuRef} wide>
<div className="max-h-[280px] overflow-y-auto">
{options.map((o) => (
<OptionRow key={o.value} value={o.value} selectedValue={value} label={o.label} onPick={(v) => { onChange(v); setIsOpen(false); }} />
))}
</div>
</SelectShell>
);
};
// ─── Time ──────────────────────────────────────────────────────────────────
const buildTimeOptions = (currentValue) => {
const options = [];
for (let h = 0; h < 24; h += 1) {
for (const m of ["00", "30"]) {
options.push({ value: `${pad(h)}:${m}`, label: `${pad(h)}:${m}` });
}
}
if (currentValue && !options.some((o) => o.value === currentValue)) {
options.unshift({ value: currentValue, label: currentValue });
}
return options;
};
export const CallTimeSelect = ({ value, onChange }) => {
const [isOpen, setIsOpen] = React.useState(false);
const menuRef = useOutsideClose(isOpen, setIsOpen);
const options = React.useMemo(() => buildTimeOptions(value), [value]);
return (
<SelectShell label="Время звонка" valueLabel={value || "Выбрать"} isOpen={isOpen} setIsOpen={setIsOpen} menuRef={menuRef}>
<div className="max-h-[280px] overflow-y-auto">
{options.map((o) => (
<OptionRow key={o.value} value={o.value} selectedValue={value} label={o.label} onPick={(v) => { onChange(v); setIsOpen(false); }} />
))}
</div>
</SelectShell>
);
};

View File

@ -1,29 +1,54 @@
import React, { useState } from "react";
import { supabase } from "../../supabaseClient";
import { Button } from "../UI/Button";
import { CallDateSelect, CallTimeSelect, toDateInputValue, toTimeInputValue } from "./CallDateTime";
const CalledPanel = ({ order, onUpdate }) => {
const [busy, setBusy] = useState(false);
const [editing, setEditing] = useState(false);
const [comment, setComment] = useState(order?.called_comment || "");
const [callDate, setCallDate] = useState(() => {
if (!order?.called_at) return "";
const d = new Date(order.called_at);
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
});
const [callTime, setCallTime] = useState(() => {
if (!order?.called_at) return "";
const d = new Date(order.called_at);
return `${String(d.getHours()).padStart(2,"0")}:${String(d.getMinutes()).padStart(2,"0")}`;
});
const [comment, setComment] = useState(order?.calledComment || order?.called_comment || "");
const [callDate, setCallDate] = useState("");
const [callTime, setCallTime] = useState("");
const isCalled = !!order?.called;
const formatCalledAt = (ts) => {
if (!ts) return "";
try {
return new Date(ts).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" });
} catch { return ""; }
return new Date(ts).toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return "";
}
};
// Open add form with date+time defaulted to NOW for fast entry.
const startAdd = () => {
const now = new Date();
setComment("");
setCallDate(toDateInputValue(now));
setCallTime(toTimeInputValue(now));
setEditing(true);
};
// Open edit form for an existing call, pre-filled from stored values (or now).
const startEdit = () => {
setComment(order?.calledComment || order?.called_comment || "");
if (order?.calledAt || order?.called_at) {
const d = new Date(order.calledAt || order.called_at);
setCallDate(toDateInputValue(d));
setCallTime(toTimeInputValue(d));
} else {
const now = new Date();
setCallDate(toDateInputValue(now));
setCallTime(toTimeInputValue(now));
}
setEditing(true);
};
const save = async (newCalled) => {
@ -40,7 +65,11 @@ const CalledPanel = ({ order, onUpdate }) => {
}
const { error } = await supabase
.from("order_groups")
.update({ called: newCalled, called_at: calledAt, called_comment: newCalled ? comment.trim() : null })
.update({
called: newCalled,
called_at: calledAt,
called_comment: newCalled ? (comment.trim() || null) : null,
})
.eq("id", order.id);
if (error) {
console.warn("[CalledPanel] update error", error);
@ -53,70 +82,78 @@ const CalledPanel = ({ order, onUpdate }) => {
}
};
// Form mode: date + time + comment
// Editing form: app-styled date/time pickers + comment + save/reset/cancel.
if (editing) {
return (
<div className="space-y-3 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4">
<div className="space-y-3">
<div className="flex flex-wrap gap-3">
<div>
<label className="block text-xs font-medium text-[var(--color-text-muted)] mb-1">Дата звонка</label>
<input type="date" value={callDate} onChange={(e) => setCallDate(e.target.value)}
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-sm !text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none" />
</div>
<div>
<label className="block text-xs font-medium text-[var(--color-text-muted)] mb-1">Время звонка</label>
<input type="time" value={callTime} onChange={(e) => setCallTime(e.target.value)}
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-sm !text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none" />
</div>
<CallDateSelect value={callDate} onChange={setCallDate} />
<CallTimeSelect value={callTime} onChange={setCallTime} />
</div>
<div>
<label className="block text-xs font-medium text-[var(--color-text-muted)] mb-1">Комментарий</label>
<textarea value={comment} onChange={(e) => setComment(e.target.value)} placeholder="Результат звонка..." rows={2}
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-sm !text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)] focus:outline-none resize-none" />
<label className="mb-1 block text-xs font-medium text-[var(--color-text-muted)]">Комментарий</label>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Результат звонка..."
rows={2}
className="w-full rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-sm text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)] focus:outline-none resize-none"
/>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="primary" size="sm" onClick={() => save(true)} disabled={busy}>
{busy ? "Сохраняем..." : "Сохранить"}
</Button>
{isCalled && (
<Button variant="ghost" size="sm" onClick={() => save(false)} disabled={busy}>Сбросить</Button>
<Button variant="ghost" size="sm" onClick={() => save(false)} disabled={busy}>
Сбросить
</Button>
)}
<Button variant="secondary" size="sm" onClick={() => setEditing(false)} disabled={busy}>Отмена</Button>
<Button variant="secondary" size="sm" onClick={() => setEditing(false)} disabled={busy}>
Отмена
</Button>
</div>
</div>
);
}
// Default mode: buttons like status delivery
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
{isCalled ? (
<>
<Button variant="primary" size="sm" onClick={() => setEditing(true)} disabled={busy}>
<Button variant="primary" size="sm" disabled>
📞 Звонили
</Button>
<Button variant="secondary" size="sm" onClick={() => save(false)} disabled={busy}>
Не звонили
</Button>
{order?.called_at && (
<span className="text-xs text-[var(--color-text-muted)]">{formatCalledAt(order.called_at)}</span>
)}
{order?.called_comment && (
<span className="text-xs text-[var(--color-text-muted)] italic">«{order.called_comment}»</span>
)}
<Button variant="ghost" size="sm" onClick={startEdit} disabled={busy} title="Изменить дату или комментарий">
✎ Изменить
</Button>
{order?.calledAt || order?.called_at ? (
<span className="text-xs text-[var(--color-text-muted)]">{formatCalledAt(order.calledAt || order.called_at)}</span>
) : null}
{order?.calledComment || order?.called_comment ? (
<span className="text-xs text-[var(--color-text-muted)] italic">«{order.calledComment || order.called_comment}»</span>
) : null}
</>
) : (
<>
<Button variant="ghost" size="sm" onClick={() => setEditing(true)} disabled={busy}>
📞 Звонили
<Button variant="primary" size="sm" onClick={() => save(true)} disabled={busy}>
{busy ? "Сохраняем..." : "📞 Звонили"}
</Button>
<Button variant="primary" size="sm" disabled>
Не звонили
<Button variant="ghost" size="sm" onClick={startAdd} disabled={busy} title="Записать дату и время звонка">
✎ Дата/комментарий
</Button>
</>
)}
</div>
{!isCalled && (
<p className="text-xs text-[var(--color-text-muted)]">
Нажмите «Звонили», чтобы отметить звонок прямо сейчас.
</p>
)}
</div>
);
};

View File

@ -663,6 +663,7 @@ export const OrderDetailPanel = ({
onAssignDriver,
onChangeDeliveryStatus,
onSaveShipmentData,
onRefreshOrder,
userRole = "driver",
}) => {
const [problemReason, setProblemReason] = React.useState(null);
@ -1090,7 +1091,7 @@ export const OrderDetailPanel = ({
userRole={userRole}
canManageDelivery={canManageDelivery}
isSavingStatusChange={isSavingStatusChange}
onRefreshOrder={() => {}}
onRefreshOrder={onRefreshOrder}
onConfirmStatus={(action) => {
if (action.type === "hint") {
setFormMessage(action.hint);
@ -1666,6 +1667,7 @@ export const OrderDetailPanel = ({
userRole={userRole}
canManageDelivery={canManageDelivery}
isSavingStatusChange={isSavingStatusChange}
onRefreshOrder={onRefreshOrder}
onConfirmStatus={(action) => {
if (action.type === "hint") {
setFormMessage(action.hint);

View File

@ -119,6 +119,7 @@ export const GroupDetailPage = () => {
onAssignDriver={assignDriver}
onChangeDeliveryStatus={changeDeliveryStatus}
onSaveShipmentData={saveShipmentData}
onRefreshOrder={() => refetch(false)}
userRole={userRole}
/>
</ErrorBoundary>

View File

@ -245,6 +245,11 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
managerTel: normalizeText(row.manager_tel) || null,
payedShip: row.payed_ship || null,
isPayedShip: row.is_payed_ship || false,
called: !!row.called,
calledAt: row.called_at || null,
called_at: row.called_at || null,
calledComment: row.called_comment || null,
called_comment: row.called_comment || null,
invitationOpenedAt: row.delivery_invitation?.opened_at || null,
invitationAccessCount: row.delivery_invitation?.access_count || 0,
invitationLastAccessedAt: row.delivery_invitation?.last_accessed_at || null,