feat(paid-storage): inline confirmation + cancel action + status display
- Replaced window.confirm with inline confirmation panel in app style - Shows confirmation block with 'Yes, transfer' / 'Cancel' buttons - After transfer: shows 'Transferred to paid storage' with date and yellow indicator - Added cancel button to revert from paid_storage back to pending_confirmation
This commit is contained in:
parent
140fbc8122
commit
c8fbe95bd1
|
|
@ -6,6 +6,7 @@ import {
|
|||
DRIVER_VISIBLE_DELIVERY_STATUSES,
|
||||
isOrderGroupVisibleToDriver,
|
||||
groupOrderGroupsByDate,
|
||||
parseGroupDate,
|
||||
} from "../../services/orderGroupViews";
|
||||
import { Badge } from "../UI/Badge";
|
||||
import { Input } from "../UI/Input";
|
||||
|
|
@ -149,7 +150,7 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
|
|||
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)]",
|
||||
].join(" ")}
|
||||
>
|
||||
<span>{new Date(`${date}T12:00:00`).toLocaleDateString("ru-RU", { day: "numeric", month: "short" })}</span>
|
||||
<span>{parseGroupDate(date)?.toLocaleDateString("ru-RU", { day: "numeric", month: "short" }) || "—"}</span>
|
||||
{count > 0 && (
|
||||
<span className="rounded-full bg-[var(--color-accent)] px-1.5 py-0.5 text-[10px] font-bold text-white">
|
||||
{count}
|
||||
|
|
@ -169,17 +170,26 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
|
|||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold capitalize">
|
||||
{new Date(`${group.date}T12:00:00`).toLocaleDateString("ru-RU", {
|
||||
{parseGroupDate(group.date)?.toLocaleDateString("ru-RU", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
weekday: "long",
|
||||
})}
|
||||
}) || "Без даты"}
|
||||
</h4>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||
{group.items.length} {group.items.length === 1 ? "группа" : "группы"}
|
||||
</p>
|
||||
</div>
|
||||
<Badge tone="neutral">{group.date}</Badge>
|
||||
<Badge tone="neutral">
|
||||
{(() => {
|
||||
const d = parseGroupDate(group.date);
|
||||
if (!d) return group.date || "—";
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const year = d.getFullYear();
|
||||
return `${day}.${month}.${year}`;
|
||||
})()}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ const renderOrderNumbers = (group) => {
|
|||
|
||||
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS }) => {
|
||||
const [filters, setFilters] = React.useState({ query: "", displayStatus: "all" });
|
||||
const [collapsedSections, setCollapsedSections] = React.useState(new Set());
|
||||
|
||||
const filteredGroups = React.useMemo(
|
||||
() => filterOrderGroups(orderGroups, filters),
|
||||
|
|
@ -78,15 +79,42 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
|
|||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
{Array.from(statusGroups.entries()).map(([statusValue, { label, groups }]) => {
|
||||
if (!groups.length) return null;
|
||||
const isCollapsed = collapsedSections.has(statusValue);
|
||||
|
||||
return (
|
||||
<div key={statusValue} className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between text-left"
|
||||
onClick={() => {
|
||||
setCollapsedSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(statusValue)) {
|
||||
next.delete(statusValue);
|
||||
} else {
|
||||
next.add(statusValue);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold">{label}</h3>
|
||||
<Badge tone="neutral">{groups.length}</Badge>
|
||||
</div>
|
||||
<svg
|
||||
className="h-4 w-4 text-[var(--color-text-muted)] transition-transform"
|
||||
style={{ transform: isCollapsed ? "rotate(-90deg)" : "rotate(0deg)" }}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{groups.map((group) => (
|
||||
{!isCollapsed && groups.map((group) => (
|
||||
<button
|
||||
key={group.id}
|
||||
className="w-full rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 !text-left !text-[var(--color-text)] transition hover:bg-[var(--color-accent-soft)] sm:p-5"
|
||||
|
|
|
|||
|
|
@ -266,7 +266,9 @@ const CollapsibleOrderComposition = ({ order }) => {
|
|||
) : (
|
||||
orders.map((orderItem, idx) => (
|
||||
<div key={idx} className="rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4">
|
||||
<p className="font-medium !text-[var(--color-text)] mb-3 text-sm">{orderItem.nom || orderItem.name || `Заказ ${idx + 1}`}</p>
|
||||
<div className="mb-3 pb-2 border-b border-[var(--color-border)]">
|
||||
<p className="font-bold text-[var(--color-text)] text-sm">{orderItem.nom || orderItem.name || `Заказ ${idx + 1}`}</p>
|
||||
</div>
|
||||
{orderItem.items && orderItem.items.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{orderItem.items.map((item, itemIdx) => (
|
||||
|
|
@ -576,6 +578,101 @@ export const OrderDetailPanel = ({
|
|||
</Panel>
|
||||
) : null}
|
||||
|
||||
|
||||
{["manager", "logistician", "admin"].includes(userRole) && order && onChangeDeliveryStatus ? (
|
||||
(() => {
|
||||
const [showConfirm, setShowConfirm] = React.useState(false);
|
||||
const isPaidStorage = (order.deliveryStatus || order.delivery_status) === "paid_storage";
|
||||
|
||||
if (isPaidStorage) {
|
||||
return (
|
||||
<Panel className="space-y-4 p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex h-2 w-2 rounded-full bg-[var(--color-warning)]"></span>
|
||||
<strong>Платное хранение</strong>
|
||||
</div>
|
||||
{order.paidStorageAt && (
|
||||
<p className="text-sm text-[var(--color-text-muted)]">
|
||||
Переведено: {formatDateTime(order.paidStorageAt)}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
onChangeDeliveryStatus({
|
||||
orderGroupId: order.id,
|
||||
status: "pending_confirmation",
|
||||
}).then((response) => {
|
||||
if (!response.success) {
|
||||
setFormMessage(response.error || "Не удалось отменить платное хранение");
|
||||
} else {
|
||||
setFormMessage("Платное хранение отменено");
|
||||
}
|
||||
});
|
||||
}}
|
||||
disabled={isSavingDeliveryChoice}
|
||||
>
|
||||
Отменить платное хранение
|
||||
</Button>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel className="space-y-4 p-5">
|
||||
<div>
|
||||
<strong>Платное хранение</strong>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||
Переведите заказ в статус платного хранения, если клиент не забрал товар в срок.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showConfirm ? (
|
||||
<div className="space-y-3 rounded-2xl border border-[var(--color-warning)] bg-[var(--color-warning-soft)] p-4">
|
||||
<p className="text-sm font-medium">Перевести заказ в платное хранение? Клиент получит уведомление.</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
onChangeDeliveryStatus({
|
||||
orderGroupId: order.id,
|
||||
status: "paid_storage",
|
||||
}).then((response) => {
|
||||
if (!response.success) {
|
||||
setFormMessage(response.error || "Не удалось обновить статус");
|
||||
} else {
|
||||
setFormMessage("Заказ переведён в платное хранение");
|
||||
setShowConfirm(false);
|
||||
}
|
||||
});
|
||||
}}
|
||||
disabled={isSavingDeliveryChoice}
|
||||
>
|
||||
Да, перевести
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowConfirm(false)}
|
||||
disabled={isSavingDeliveryChoice}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowConfirm(true)}
|
||||
disabled={isSavingDeliveryChoice}
|
||||
>
|
||||
Перевести в платное хранение
|
||||
</Button>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
})()
|
||||
) : null}
|
||||
|
||||
{canManageDelivery ? (
|
||||
<Panel className="space-y-4 p-5">
|
||||
<div>
|
||||
|
|
@ -779,6 +876,10 @@ export const OrderDetailPanel = ({
|
|||
<p className="text-xs text-[var(--color-text-muted)]">Ручное согласование выполнено</p>
|
||||
<p className="mt-1 font-medium !text-[var(--color-text)]">{order.manualConfirmationAt ? formatDateTime(order.manualConfirmationAt) : "Нет"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">Платное хранение</p>
|
||||
<p className="mt-1 font-medium !text-[var(--color-text)]">{order.paidStorageAt ? formatDateTime(order.paidStorageAt) : "Нет"}</p>
|
||||
</div>
|
||||
{order.createdFromExchangeAt ? (
|
||||
<div>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">Создано из обмена</p>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export const DELIVERY_GROUP_STATUS_LABELS = {
|
|||
on_route: "В пути",
|
||||
delivered: "Доставлено",
|
||||
problem: "Проблема",
|
||||
paid_storage: "Платное хранение",
|
||||
cancelled: "Отменено",
|
||||
};
|
||||
|
||||
|
|
@ -168,7 +169,7 @@ export const isOrderGroupVisibleToDriver = (group) => {
|
|||
return DRIVER_VISIBLE_DELIVERY_STATUSES.includes(deliveryStatus);
|
||||
};
|
||||
|
||||
const parseGroupDate = (value) => {
|
||||
export const parseGroupDate = (value) => {
|
||||
const normalized = normalizeDate(value);
|
||||
|
||||
if (!normalized) {
|
||||
|
|
@ -299,6 +300,7 @@ export const ORDER_GROUP_DISPLAY_STATUS_OPTIONS = [
|
|||
{ value: "delivery:on_route", label: DELIVERY_GROUP_STATUS_LABELS.on_route },
|
||||
{ value: "delivery:delivered", label: DELIVERY_GROUP_STATUS_LABELS.delivered },
|
||||
{ value: "delivery:problem", label: DELIVERY_GROUP_STATUS_LABELS.problem },
|
||||
{ value: "delivery:paid_storage", label: DELIVERY_GROUP_STATUS_LABELS.paid_storage },
|
||||
{ value: "delivery:cancelled", label: DELIVERY_GROUP_STATUS_LABELS.cancelled },
|
||||
];
|
||||
|
||||
|
|
@ -321,6 +323,8 @@ export const getOrderGroupDeliveryStatusTone = (status) => {
|
|||
return "accent";
|
||||
case "delivered":
|
||||
return "accent";
|
||||
case "paid_storage":
|
||||
return "warning";
|
||||
case "problem":
|
||||
return "danger";
|
||||
case "cancelled":
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
|
|||
firstSmsSentAt: row.first_sms_sent_at || null,
|
||||
secondSmsSentAt: row.second_sms_sent_at || null,
|
||||
manualConfirmationAt: row.manual_confirmation_at || null,
|
||||
paidStorageAt: row.paid_storage_at || null,
|
||||
notificationStatus: normalizeText(row.notification_status),
|
||||
createdFromExchangeAt: row.created_from_exchange_at || null,
|
||||
sourceKey: row.source_key || null,
|
||||
|
|
@ -199,7 +200,7 @@ export const updateOrderGroupDeliveryChoice = async ({
|
|||
|
||||
const { data, error } = await client
|
||||
.from("order_groups")
|
||||
.select("id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, order_list, order_list_structured, delivery_invitation_id, delivery_link, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, manual_confirmation_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)")
|
||||
.select("id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, order_list, order_list_structured, delivery_invitation_id, delivery_link, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)")
|
||||
.eq("id", orderGroupId)
|
||||
.single();
|
||||
|
||||
|
|
@ -282,7 +283,7 @@ export const fetchOrderGroups = async () => {
|
|||
const client = requireSupabase();
|
||||
const { data, error } = await client
|
||||
.from("order_groups")
|
||||
.select("id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, order_list, order_list_structured, delivery_invitation_id, delivery_link, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, manual_confirmation_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)")
|
||||
.select("id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, order_list, order_list_structured, delivery_invitation_id, delivery_link, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)")
|
||||
.order("updated_at", { ascending: false });
|
||||
|
||||
if (error) {
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ describe("updateOrderGroupDeliveryChoice", () => {
|
|||
updated_at: expect.any(String),
|
||||
});
|
||||
expect(eqMock).toHaveBeenCalledWith("id", "group-id");
|
||||
expect(selectMock).toHaveBeenCalledWith("id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, order_list, order_list_structured, delivery_invitation_id, delivery_link, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, manual_confirmation_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)");
|
||||
expect(selectMock).toHaveBeenCalledWith("id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, order_list, order_list_structured, delivery_invitation_id, delivery_link, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)");
|
||||
expect(singleMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
-- Patch: Add paid_storage status support for order_groups
|
||||
|
||||
-- 1. Add paid_storage_at column to order_groups if not exists
|
||||
alter table public.order_groups add column if not exists paid_storage_at timestamptz;
|
||||
|
||||
-- 2. Update update_delivery_status to allow paid_storage without assigned driver
|
||||
create or replace function public.update_delivery_status(
|
||||
p_order_group_id uuid,
|
||||
p_status text
|
||||
)
|
||||
returns boolean
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
v_assigned_driver_id uuid;
|
||||
v_current_status text;
|
||||
begin
|
||||
select assigned_driver_id, delivery_status
|
||||
into v_assigned_driver_id, v_current_status
|
||||
from public.order_groups
|
||||
where id = p_order_group_id;
|
||||
|
||||
-- Allow paid_storage status for any authenticated user with proper role
|
||||
-- (checked by RLS policy)
|
||||
if p_status = 'paid_storage' then
|
||||
update public.order_groups
|
||||
set delivery_status = p_status,
|
||||
paid_storage_at = timezone('utc', now()),
|
||||
updated_at = timezone('utc', now())
|
||||
where id = p_order_group_id;
|
||||
return true;
|
||||
end if;
|
||||
|
||||
if v_assigned_driver_id is null then
|
||||
raise exception 'Группа не назначена водителю';
|
||||
end if;
|
||||
|
||||
if v_assigned_driver_id != auth.uid() then
|
||||
raise exception 'Вы не назначены на эту доставку';
|
||||
end if;
|
||||
|
||||
update public.order_groups
|
||||
set delivery_status = p_status,
|
||||
updated_at = timezone('utc', now())
|
||||
where id = p_order_group_id;
|
||||
|
||||
return true;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- 3. Ensure proper grants
|
||||
revoke execute on function public.update_delivery_status(uuid, text) from anon;
|
||||
grant execute on function public.update_delivery_status(uuid, text) to authenticated;
|
||||
|
||||
Loading…
Reference in New Issue