feat: show full order composition on delivery page
- Extract OrderCompositionPanel as standalone component - Support nested order items with sub-positions from source_orders - Enrich order items from Edge Function when RPC only has invoice refs - Update RPC SQL to extract product items from source_orders - Update Edge Function to include source_orders items in invitation - Add detail rows (account, customer) when no order items available
This commit is contained in:
parent
5a5636c738
commit
e6dc1972fb
|
|
@ -84,17 +84,49 @@ begin
|
|||
nullif(v_group.customer_phone_normalized, ''),
|
||||
nullif(v_invitation.customer_phone, '')
|
||||
);
|
||||
select coalesce(
|
||||
jsonb_agg(jsonb_build_object('name', order_number, 'quantity', '')),
|
||||
'[]'::jsonb
|
||||
)
|
||||
into v_order_items
|
||||
from jsonb_array_elements_text(
|
||||
case
|
||||
when jsonb_typeof(to_jsonb(v_group.order_numbers)) = 'array' then to_jsonb(v_group.order_numbers)
|
||||
else '[]'::jsonb
|
||||
end
|
||||
) as order_number;
|
||||
-- Build orderItems from source_orders if available (real product lines),
|
||||
-- otherwise fall back to invoice numbers from order_numbers.
|
||||
v_order_items := case
|
||||
when v_group.source_orders is not null
|
||||
and jsonb_typeof(v_group.source_orders) = 'array'
|
||||
and jsonb_array_length(v_group.source_orders) > 0
|
||||
then
|
||||
coalesce(
|
||||
(select jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'name', coalesce(src ->> 'nom', src ->> 'name', onum),
|
||||
'quantity', '',
|
||||
'items', coalesce(
|
||||
src -> 'orderList',
|
||||
src -> 'items',
|
||||
'[]'::jsonb
|
||||
)
|
||||
)
|
||||
)
|
||||
from jsonb_array_elements(v_group.source_orders) with ordinality as t(src, idx)
|
||||
cross join lateral (
|
||||
select coalesce(
|
||||
nullif(v_group.order_numbers[idx], ''),
|
||||
src ->> 'nom',
|
||||
src ->> 'name',
|
||||
''
|
||||
) as onum
|
||||
) as names
|
||||
),
|
||||
'[]'::jsonb
|
||||
)
|
||||
else
|
||||
coalesce(
|
||||
(select jsonb_agg(jsonb_build_object('name', order_number, 'quantity', ''))
|
||||
from jsonb_array_elements_text(
|
||||
case
|
||||
when jsonb_typeof(to_jsonb(v_group.order_numbers)) = 'array' then to_jsonb(v_group.order_numbers)
|
||||
else '[]'::jsonb
|
||||
end
|
||||
) as order_number),
|
||||
'[]'::jsonb
|
||||
)
|
||||
end;
|
||||
|
||||
return jsonb_build_object(
|
||||
'ok', true,
|
||||
|
|
|
|||
|
|
@ -18,36 +18,6 @@ const STATE_LABELS = {
|
|||
agreed: "Доставка согласована",
|
||||
};
|
||||
|
||||
const splitOrderItem = (item) => {
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof item === "string") {
|
||||
const [name, quantity] = item.split("|").map((part) => part.trim());
|
||||
return {
|
||||
name: name || item.trim(),
|
||||
quantity: quantity || "",
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof item === "object") {
|
||||
const name = typeof item.name === "string" ? item.name.trim() : typeof item.label === "string" ? item.label.trim() : "";
|
||||
const quantity = typeof item.quantity === "string"
|
||||
? item.quantity.trim()
|
||||
: typeof item.quantity === "number"
|
||||
? String(item.quantity)
|
||||
: "";
|
||||
|
||||
return {
|
||||
name: name || "Позиция",
|
||||
quantity,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const DeliveryChoiceFlow = ({
|
||||
invitation = {},
|
||||
selectedSlot = null,
|
||||
|
|
@ -56,9 +26,6 @@ export const DeliveryChoiceFlow = ({
|
|||
const state = invitation.state || "awaiting_choice";
|
||||
const isActive = ACTIVE_STATES.has(state);
|
||||
const invitationReference = getInvitationReferenceLabel(invitation);
|
||||
const orderItems = (invitation.orderItems || invitation.items || [])
|
||||
.map(splitOrderItem)
|
||||
.filter(Boolean);
|
||||
const slotSummary = selectedSlot ? formatDeliverySlotLabel(selectedSlot) : "";
|
||||
|
||||
if (!isActive) {
|
||||
|
|
@ -78,27 +45,10 @@ export const DeliveryChoiceFlow = ({
|
|||
<Badge tone="warning">{STATE_LABELS[state]}</Badge>
|
||||
</div>
|
||||
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
||||
{invitationReference}. Проверьте состав заказа и выберите удобную половину дня.
|
||||
{invitationReference}. Выберите удобную половину дня.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{orderItems.length ? (
|
||||
<div className="space-y-3 rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface)] p-4">
|
||||
<p className="text-sm uppercase tracking-[0.18em] text-[var(--color-text-muted)]">Состав заказа</p>
|
||||
<div className="space-y-2">
|
||||
{orderItems.map((item) => (
|
||||
<div
|
||||
key={`${item.name}-${item.quantity || "item"}`}
|
||||
className="flex items-center justify-between gap-3 rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] px-4 py-3 text-sm"
|
||||
>
|
||||
<span className="leading-6">{item.name}</span>
|
||||
{item.quantity ? <Badge tone="neutral">{item.quantity}</Badge> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Button
|
||||
className="w-full sm:w-auto"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React from "react";
|
|||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DeliveryChoiceFlow } from "./DeliveryChoiceFlow";
|
||||
import { OrderCompositionPanel } from "./OrderCompositionPanel";
|
||||
|
||||
describe("DeliveryChoiceFlow", () => {
|
||||
it("renders the active delivery choice with half-day actions", () => {
|
||||
|
|
@ -38,32 +39,6 @@ describe("DeliveryChoiceFlow", () => {
|
|||
expect(markup).toContain("disabled");
|
||||
});
|
||||
|
||||
it("renders order items with quantities when they are provided", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DeliveryChoiceFlow
|
||||
invitation={{
|
||||
state: "awaiting_choice",
|
||||
orderNumber: "CD-240031",
|
||||
customerName: "Мария Волкова",
|
||||
orderItems: [
|
||||
{ name: "Кухонный гарнитур", quantity: "1 комплект" },
|
||||
{ name: "Фурнитура Blum", quantity: "12 шт" },
|
||||
{ name: "Монтажный комплект" },
|
||||
],
|
||||
availableSlots: ["Первая половина дня", "Вторая половина дня"],
|
||||
}}
|
||||
onConfirmChoice={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("Состав заказа");
|
||||
expect(markup).toContain("Кухонный гарнитур");
|
||||
expect(markup).toContain("1 комплект");
|
||||
expect(markup).toContain("Фурнитура Blum");
|
||||
expect(markup).toContain("12 шт");
|
||||
expect(markup).toContain("Монтажный комплект");
|
||||
});
|
||||
|
||||
it("renders a logistics notice when the order is transferred", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DeliveryChoiceFlow
|
||||
|
|
@ -113,3 +88,59 @@ describe("DeliveryChoiceFlow", () => {
|
|||
expect(markup).not.toContain("Александр Савин");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OrderCompositionPanel", () => {
|
||||
it("renders order items with quantities when they are provided", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<OrderCompositionPanel
|
||||
invitation={{
|
||||
orderNumber: "CD-240031",
|
||||
orderItems: [
|
||||
{ name: "Кухонный гарнитур", quantity: "1 комплект" },
|
||||
{ name: "Фурнитура Blum", quantity: "12 шт" },
|
||||
{ name: "Монтажный комплект" },
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("Состав заказа");
|
||||
expect(markup).toContain("Кухонный гарнитур");
|
||||
expect(markup).toContain("1 комплект");
|
||||
expect(markup).toContain("Фурнитура Blum");
|
||||
expect(markup).toContain("12 шт");
|
||||
expect(markup).toContain("Монтажный комплект");
|
||||
});
|
||||
|
||||
it("renders reference and customer info when there are no order items", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<OrderCompositionPanel invitation={{ orderNumber: "CD-240031", customerName: "Мария Волкова" }} />,
|
||||
);
|
||||
|
||||
expect(markup).toContain("Состав заказа");
|
||||
expect(markup).toContain("CD-240031");
|
||||
expect(markup).toContain("Мария Волкова");
|
||||
});
|
||||
|
||||
it("renders nothing when there are no order items and no reference info", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<OrderCompositionPanel invitation={{}} />,
|
||||
);
|
||||
|
||||
expect(markup).toBe("");
|
||||
});
|
||||
|
||||
it("handles string items with pipe separator", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<OrderCompositionPanel
|
||||
invitation={{
|
||||
orderNumber: "CD-240031",
|
||||
orderItems: ["Плинтус|5 шт"],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("Плинтус");
|
||||
expect(markup).toContain("5 шт");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
import React from "react";
|
||||
import { Badge } from "../UI/Badge";
|
||||
import { Panel } from "../UI/Panel";
|
||||
import { getInvitationReferenceLabel } from "./invitationReference";
|
||||
|
||||
const splitOrderItem = (item) => {
|
||||
if (!item) return null;
|
||||
|
||||
if (typeof item === "string") {
|
||||
const [name, quantity] = item.split("|").map((p) => p.trim());
|
||||
return { name: name || item.trim(), quantity: quantity || "", items: [] };
|
||||
}
|
||||
|
||||
if (typeof item === "object") {
|
||||
const name = typeof item.name === "string"
|
||||
? item.name.trim()
|
||||
: typeof item.label === "string"
|
||||
? item.label.trim()
|
||||
: "";
|
||||
const quantity = typeof item.quantity === "string"
|
||||
? item.quantity.trim()
|
||||
: typeof item.quantity === "number"
|
||||
? String(item.quantity)
|
||||
: "";
|
||||
const nestedItems = Array.isArray(item.items)
|
||||
? item.items.map((sub) => {
|
||||
if (!sub || typeof sub !== "object") return null;
|
||||
return {
|
||||
name: String(sub.product_name || sub.name || sub.title || "").trim(),
|
||||
quantity: String(sub.product_quantity || sub.quantity || sub.count || sub.amount || "").trim(),
|
||||
};
|
||||
}).filter(Boolean)
|
||||
: [];
|
||||
return { name: name || "Позиция", quantity, items: nestedItems };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const buildDetailRows = (invitation) => {
|
||||
const rows = [];
|
||||
const reference = getInvitationReferenceLabel(invitation);
|
||||
if (reference && reference !== "Счет —") {
|
||||
rows.push({ label: "Счет", value: reference.replace(/^Счета?:\s*/, "") });
|
||||
}
|
||||
if (invitation.customerName) {
|
||||
rows.push({ label: "Клиент", value: invitation.customerName });
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
|
||||
export const OrderCompositionPanel = ({ invitation = {} }) => {
|
||||
const orderItems = (invitation.orderItems || invitation.items || [])
|
||||
.map(splitOrderItem)
|
||||
.filter(Boolean);
|
||||
|
||||
const detailRows = buildDetailRows(invitation);
|
||||
const hasContent = orderItems.length > 0 || detailRows.length > 0;
|
||||
|
||||
if (!hasContent) return null;
|
||||
|
||||
const reference = getInvitationReferenceLabel(invitation);
|
||||
|
||||
return (
|
||||
<Panel className="space-y-3 border shadow-soft p-5 sm:p-6">
|
||||
<p className="text-sm uppercase tracking-[0.18em] text-[var(--color-text-muted)]">
|
||||
Состав заказа {reference !== "Счет —" ? reference : ""}
|
||||
</p>
|
||||
|
||||
{detailRows.length > 0 && !orderItems.length ? (
|
||||
<div className="space-y-2">
|
||||
{detailRows.map((row) => (
|
||||
<div
|
||||
key={row.label}
|
||||
className="flex items-center justify-between gap-3 rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] px-4 py-3 text-sm"
|
||||
>
|
||||
<span className="leading-6 text-[var(--color-text-muted)]">{row.label}</span>
|
||||
<span className="font-medium leading-6">{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{orderItems.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{orderItems.map((item) => (
|
||||
<div key={`${item.name}-${item.quantity || "item"}`}>
|
||||
<div className="flex items-center justify-between gap-3 rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] px-4 py-3 text-sm">
|
||||
<span className="font-medium leading-6">{item.name}</span>
|
||||
{item.quantity ? <Badge tone="neutral">{item.quantity}</Badge> : null}
|
||||
</div>
|
||||
{item.items && item.items.length > 0 ? (
|
||||
<div className="mt-1 space-y-1 pl-3">
|
||||
{item.items.map((sub, idx) => (
|
||||
<div
|
||||
key={`${sub.name}-${idx}`}
|
||||
className="flex items-center justify-between gap-3 rounded-[14px] border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="leading-6">{sub.name}</span>
|
||||
{sub.quantity ? <Badge tone="neutral">{sub.quantity}</Badge> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</Panel>
|
||||
);
|
||||
};
|
||||
|
|
@ -2,6 +2,7 @@ import React from "react";
|
|||
import { useParams } from "react-router-dom";
|
||||
import { DeliveryChoiceFlow } from "../components/client/DeliveryChoiceFlow";
|
||||
import { DeliverySlotsPicker } from "../components/client/DeliverySlotsPicker";
|
||||
import { OrderCompositionPanel } from "../components/client/OrderCompositionPanel";
|
||||
import { getInvitationReferenceLabel } from "../components/client/invitationReference";
|
||||
import { DeliveryStateNotice } from "../components/client/DeliveryStateNotice";
|
||||
import { Panel } from "../components/UI/Panel";
|
||||
|
|
@ -315,6 +316,8 @@ export const ClientDeliveryPage = () => {
|
|||
) : null}
|
||||
</Panel>
|
||||
|
||||
<OrderCompositionPanel invitation={invitation} />
|
||||
|
||||
{isChoiceSaved && savedChoiceLabel ? (
|
||||
<Panel className="space-y-2 p-5 sm:p-6">
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Ваш выбор</p>
|
||||
|
|
|
|||
|
|
@ -183,6 +183,60 @@ export const __resetLocalDeliveryInvitationCache = () => {
|
|||
localDeliveryInvitationCache.clear();
|
||||
};
|
||||
|
||||
const enrichOrderItemsFromEdgeFunction = (invitation, token) => {
|
||||
// Fire-and-forget: try the Edge Function in the background to get richer
|
||||
// order item data (with source_orders). On success, update the cache.
|
||||
// The initial RPC response is returned immediately so the page renders
|
||||
// without delay; the richer data arrives shortly after.
|
||||
if (!token || isLocalClientInvitationToken(token)) {
|
||||
return invitation;
|
||||
}
|
||||
|
||||
const hasOnlyInvoiceItems = Array.isArray(invitation.orderItems)
|
||||
&& invitation.orderItems.every(
|
||||
(item) => typeof item === "object" && item !== null
|
||||
&& (!Array.isArray(item.items) || item.items.length === 0)
|
||||
&& (!item.quantity || item.quantity === ""),
|
||||
);
|
||||
|
||||
if (!hasOnlyInvoiceItems) {
|
||||
return invitation;
|
||||
}
|
||||
|
||||
invokeDeliveryFunction("get-delivery-invitation", { token })
|
||||
.then((response) => {
|
||||
if (response?.invitation?.orderItems) {
|
||||
const enriched = {
|
||||
...invitation,
|
||||
orderItems: response.invitation.orderItems,
|
||||
};
|
||||
cacheInvitation(enriched);
|
||||
const storage = getLocalStorage();
|
||||
if (storage) {
|
||||
try {
|
||||
storage.setItem(
|
||||
getLocalStorageKey(token),
|
||||
JSON.stringify(enriched),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", {
|
||||
key: getLocalStorageKey(token),
|
||||
newValue: JSON.stringify(enriched),
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently ignore — the initial RPC data is already shown.
|
||||
});
|
||||
|
||||
return invitation;
|
||||
};
|
||||
|
||||
export const fetchDeliveryInvitation = async (token) => {
|
||||
if (!token) {
|
||||
throw new Error("Token is required");
|
||||
|
|
@ -208,7 +262,8 @@ export const fetchDeliveryInvitation = async (token) => {
|
|||
p_token: token,
|
||||
});
|
||||
if (response?.invitation) {
|
||||
return cacheInvitation(response.invitation);
|
||||
const enriched = enrichOrderItemsFromEdgeFunction(response.invitation, token);
|
||||
return cacheInvitation(enriched);
|
||||
}
|
||||
if (isLocalClientInvitationToken(token)) {
|
||||
return cacheInvitation(buildFallbackInvitation(token));
|
||||
|
|
|
|||
|
|
@ -190,8 +190,8 @@ export type OrderGroupInvitationSource = {
|
|||
order_numbers?: string[] | null;
|
||||
delivery_status?: string | null;
|
||||
delivery_link?: string | null;
|
||||
source_orders?: unknown[] | null;
|
||||
};
|
||||
|
||||
export const isInvitationExpired = (invitation: DeliveryInvitationRecord, now = new Date()) => {
|
||||
if (invitation.revoked_at) {
|
||||
return true;
|
||||
|
|
@ -212,6 +212,45 @@ const parseGroupKey = (groupKey?: string | null) => {
|
|||
};
|
||||
};
|
||||
|
||||
const extractOrderItemsFromSourceOrders = (sourceOrders: unknown): Array<{ name: string; quantity: string; items?: unknown[] }> => {
|
||||
if (!Array.isArray(sourceOrders) || sourceOrders.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items: Array<{ name: string; quantity: string; items?: unknown[] }> = [];
|
||||
|
||||
for (const source of sourceOrders) {
|
||||
if (!source || typeof source !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const record = source as Record<string, unknown>;
|
||||
const nom = typeof record.nom === "string" ? record.nom : typeof record.name === "string" ? record.name : "";
|
||||
const orderList = Array.isArray(record.orderList) ? record.orderList : Array.isArray(record.items) ? record.items : [];
|
||||
|
||||
if (orderList.length > 0) {
|
||||
items.push({
|
||||
name: nom || "Позиция",
|
||||
quantity: "",
|
||||
items: orderList.map((item: unknown) => {
|
||||
if (!item || typeof item !== "object") {
|
||||
return { name: String(item), quantity: "" };
|
||||
}
|
||||
const row = item as Record<string, unknown>;
|
||||
return {
|
||||
name: String(row.product_name || row.name || row.title || ""),
|
||||
quantity: String(row.product_quantity || row.quantity || row.count || row.amount || ""),
|
||||
};
|
||||
}),
|
||||
});
|
||||
} else if (nom) {
|
||||
items.push({ name: nom, quantity: "" });
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
export const buildPublicOrderGroupInvitationView = (
|
||||
invitation: DeliveryInvitationRecord,
|
||||
group: OrderGroupInvitationSource,
|
||||
|
|
@ -221,6 +260,11 @@ export const buildPublicOrderGroupInvitationView = (
|
|||
const customerPhone = group.customer_phone || group.customer?.phone || invitation.customer_phone || parsedKey.phone || null;
|
||||
const orderNumbers = Array.isArray(group.order_numbers) ? group.order_numbers : [];
|
||||
|
||||
const orderItemsFromSource = extractOrderItemsFromSourceOrders(group.source_orders);
|
||||
const orderItems = orderItemsFromSource.length > 0
|
||||
? orderItemsFromSource
|
||||
: orderNumbers.map((number) => ({ name: number, quantity: "" }));
|
||||
|
||||
return {
|
||||
orderId: invitation.order_group_id || group.id,
|
||||
orderGroupId: invitation.order_group_id || group.id,
|
||||
|
|
@ -229,7 +273,7 @@ export const buildPublicOrderGroupInvitationView = (
|
|||
orderNumber: invitation.order_number || orderNumbers[0] || group.group_key || null,
|
||||
customerName: maskCustomerName(customerName),
|
||||
customerPhone: maskPhoneNumber(customerPhone),
|
||||
orderItems: orderNumbers.map((number) => ({ name: number, quantity: "" })),
|
||||
orderItems,
|
||||
availableSlots: invitation.available_slots || [],
|
||||
deliveryDate: invitation.delivery_date || null,
|
||||
deliveryTime: invitation.delivery_time || null,
|
||||
|
|
|
|||
Loading…
Reference in New Issue