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:
Codex 2026-05-21 11:52:37 +03:00
parent 5a5636c738
commit e6dc1972fb
7 changed files with 317 additions and 91 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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