feat(paid-storage): bypass stale RPC for paid_storage transitions
- OrderDetailPanel: paid_storage bypasses update_delivery_status RPC - Repository: direct table update for paid_storage entry/exit - LogisticsBoard: collapsible sections ordered by funnel - SQL: updated paid_storage schema patch - LoginPage: hide dev quick-login in production
This commit is contained in:
parent
c8fbe95bd1
commit
838c4cb7ae
|
|
@ -52,6 +52,22 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
|
||||||
return map;
|
return map;
|
||||||
}, [filteredGroups]);
|
}, [filteredGroups]);
|
||||||
|
|
||||||
|
const FUNNEL_ORDER = [
|
||||||
|
"status:ready_for_notification",
|
||||||
|
"delivery:pending_confirmation",
|
||||||
|
"delivery:manual_confirmation_required",
|
||||||
|
"status:first_sms_sent",
|
||||||
|
"status:second_sms_sent",
|
||||||
|
"delivery:agreed",
|
||||||
|
"delivery:driver_assigned",
|
||||||
|
"delivery:loaded",
|
||||||
|
"delivery:on_route",
|
||||||
|
"delivery:delivered",
|
||||||
|
"delivery:paid_storage",
|
||||||
|
"delivery:problem",
|
||||||
|
"delivery:cancelled",
|
||||||
|
];
|
||||||
|
|
||||||
const totalGroups = filteredGroups.length;
|
const totalGroups = filteredGroups.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -76,9 +92,15 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
|
||||||
По этому поиску ничего не найдено.
|
По этому поиску ничего не найдено.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-6 xl:grid-cols-2">
|
<div className="grid gap-6">
|
||||||
{Array.from(statusGroups.entries()).map(([statusValue, { label, groups }]) => {
|
{Array.from(statusGroups.entries()).sort(([a], [b]) => {
|
||||||
if (!groups.length) return null;
|
const idxA = FUNNEL_ORDER.indexOf(a);
|
||||||
|
const idxB = FUNNEL_ORDER.indexOf(b);
|
||||||
|
if (idxA === -1 && idxB === -1) return a.localeCompare(b);
|
||||||
|
if (idxA === -1) return 1;
|
||||||
|
if (idxB === -1) return -1;
|
||||||
|
return idxA - idxB;
|
||||||
|
}).map(([statusValue, { label, groups }]) => {
|
||||||
const isCollapsed = collapsedSections.has(statusValue);
|
const isCollapsed = collapsedSections.has(statusValue);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -100,7 +122,7 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="font-semibold">{label}</h3>
|
<h3 className="font-semibold">{label}</h3>
|
||||||
<Badge tone="neutral">{groups.length}</Badge>
|
<Badge tone={groups.length > 0 ? "neutral" : "muted"}>{groups.length}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<svg
|
<svg
|
||||||
className="h-4 w-4 text-[var(--color-text-muted)] transition-transform"
|
className="h-4 w-4 text-[var(--color-text-muted)] transition-transform"
|
||||||
|
|
|
||||||
|
|
@ -292,6 +292,98 @@ const CollapsibleOrderComposition = ({ order }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PaidStoragePanel = ({ order, onChangeDeliveryStatus, isSavingDeliveryChoice, setFormMessage }) => {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const OrderDetailPanel = ({
|
export const OrderDetailPanel = ({
|
||||||
order,
|
order,
|
||||||
canManageDelivery = false,
|
canManageDelivery = false,
|
||||||
|
|
@ -580,97 +672,12 @@ export const OrderDetailPanel = ({
|
||||||
|
|
||||||
|
|
||||||
{["manager", "logistician", "admin"].includes(userRole) && order && onChangeDeliveryStatus ? (
|
{["manager", "logistician", "admin"].includes(userRole) && order && onChangeDeliveryStatus ? (
|
||||||
(() => {
|
<PaidStoragePanel
|
||||||
const [showConfirm, setShowConfirm] = React.useState(false);
|
order={order}
|
||||||
const isPaidStorage = (order.deliveryStatus || order.delivery_status) === "paid_storage";
|
onChangeDeliveryStatus={onChangeDeliveryStatus}
|
||||||
|
isSavingDeliveryChoice={isSavingDeliveryChoice}
|
||||||
if (isPaidStorage) {
|
setFormMessage={setFormMessage}
|
||||||
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}
|
) : null}
|
||||||
|
|
||||||
{canManageDelivery ? (
|
{canManageDelivery ? (
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,7 @@ export const useOrderGroups = () => {
|
||||||
deliveryGroupBuckets,
|
deliveryGroupBuckets,
|
||||||
saveManualDeliveryChoice,
|
saveManualDeliveryChoice,
|
||||||
assignDriver,
|
assignDriver,
|
||||||
|
changeDeliveryStatus,
|
||||||
isSavingDeliveryChoice,
|
isSavingDeliveryChoice,
|
||||||
isLoading,
|
isLoading,
|
||||||
loadError,
|
loadError,
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ export const LoginPage = () => {
|
||||||
error={displayError}
|
error={displayError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(isDemoMode || import.meta.env.DEV === true && import.meta.env.VITE_ENABLE_DEMO === 'true') ? (
|
{isDemoMode ? (
|
||||||
<div className="w-full max-w-md space-y-3">
|
<div className="w-full max-w-md space-y-3">
|
||||||
<p className="text-center text-sm text-[var(--color-text-muted)]">
|
<p className="text-center text-sm text-[var(--color-text-muted)]">
|
||||||
{isDemoMode ? "Демо-режим — войдите под любой ролью" : "Быстрый вход (только для разработки)"}
|
{isDemoMode ? "Демо-режим — войдите под любой ролью" : "Быстрый вход (только для разработки)"}
|
||||||
|
|
|
||||||
|
|
@ -254,13 +254,53 @@ export const assignDriverToOrderGroup = async ({
|
||||||
export const updateDeliveryStatus = async ({ orderGroupId, status }) => {
|
export const updateDeliveryStatus = async ({ orderGroupId, status }) => {
|
||||||
return safeSupabaseCall(async () => {
|
return safeSupabaseCall(async () => {
|
||||||
const client = requireSupabase();
|
const client = requireSupabase();
|
||||||
const { data: rpcData, error: rpcError } = await client.rpc("update_delivery_status", {
|
|
||||||
p_order_group_id: orderGroupId,
|
|
||||||
p_status: status,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (rpcError) {
|
// Bypass stale RPC for paid_storage transitions
|
||||||
throw rpcError;
|
// Server-side RPC still enforces driver-assignment checks that block
|
||||||
|
// manager/logistician from moving groups into/out of paid_storage.
|
||||||
|
// RLS policy "order groups update coordination roles" allows
|
||||||
|
// manager/logistician/admin to update order_groups directly.
|
||||||
|
if (status === "paid_storage") {
|
||||||
|
const { error: updateError } = await client
|
||||||
|
.from("order_groups")
|
||||||
|
.update({
|
||||||
|
delivery_status: status,
|
||||||
|
paid_storage_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq("id", orderGroupId);
|
||||||
|
|
||||||
|
if (updateError) throw updateError;
|
||||||
|
} else {
|
||||||
|
// For cancelling paid_storage: check current status first
|
||||||
|
const { data: current, error: fetchError } = await client
|
||||||
|
.from("order_groups")
|
||||||
|
.select("delivery_status")
|
||||||
|
.eq("id", orderGroupId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (fetchError) throw fetchError;
|
||||||
|
|
||||||
|
if (current.delivery_status === "paid_storage" && status === "pending_confirmation") {
|
||||||
|
const { error: updateError } = await client
|
||||||
|
.from("order_groups")
|
||||||
|
.update({
|
||||||
|
delivery_status: status,
|
||||||
|
paid_storage_at: null,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq("id", orderGroupId);
|
||||||
|
|
||||||
|
if (updateError) throw updateError;
|
||||||
|
} else {
|
||||||
|
// All other statuses use the RPC (driver workflows, etc.)
|
||||||
|
const { error: rpcError } = await client.rpc("update_delivery_status", {
|
||||||
|
p_order_group_id: orderGroupId,
|
||||||
|
p_status: status,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rpcError) throw rpcError;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch updated group
|
// Fetch updated group
|
||||||
|
|
@ -270,9 +310,7 @@ export const updateDeliveryStatus = async ({ orderGroupId, status }) => {
|
||||||
.eq("id", orderGroupId)
|
.eq("id", orderGroupId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (error) {
|
if (error) throw error;
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return mapOrderGroupRowToDeliveryGroup(data);
|
return mapOrderGroupRowToDeliveryGroup(data);
|
||||||
}, "Ошибка обновления статуса доставки");
|
}, "Ошибка обновления статуса доставки");
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,23 @@
|
||||||
-- 1. Add paid_storage_at column to order_groups if not exists
|
-- 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;
|
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
|
-- 2. Drop and recreate delivery_status check constraint to include paid_storage
|
||||||
|
alter table public.order_groups drop constraint if exists order_groups_delivery_status_check;
|
||||||
|
alter table public.order_groups add constraint order_groups_delivery_status_check
|
||||||
|
check (delivery_status in (
|
||||||
|
'pending_confirmation',
|
||||||
|
'manual_confirmation_required',
|
||||||
|
'agreed',
|
||||||
|
'driver_assigned',
|
||||||
|
'loaded',
|
||||||
|
'on_route',
|
||||||
|
'delivered',
|
||||||
|
'paid_storage',
|
||||||
|
'problem',
|
||||||
|
'cancelled'
|
||||||
|
));
|
||||||
|
|
||||||
|
-- 3. Update update_delivery_status to allow paid_storage without assigned driver
|
||||||
create or replace function public.update_delivery_status(
|
create or replace function public.update_delivery_status(
|
||||||
p_order_group_id uuid,
|
p_order_group_id uuid,
|
||||||
p_status text
|
p_status text
|
||||||
|
|
@ -50,7 +66,7 @@ begin
|
||||||
end;
|
end;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
-- 3. Ensure proper grants
|
-- 4. Ensure proper grants
|
||||||
revoke execute on function public.update_delivery_status(uuid, text) from anon;
|
revoke execute on function public.update_delivery_status(uuid, text) from anon;
|
||||||
grant execute on function public.update_delivery_status(uuid, text) to authenticated;
|
grant execute on function public.update_delivery_status(uuid, text) to authenticated;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue