@base44/app-plugin-commerce 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +117 -0
- package/base44/agents/commerce/StoreAdmin.jsonc +64 -0
- package/base44/entities/commerce.Cart.jsonc +73 -0
- package/base44/entities/commerce.Coupon.jsonc +113 -0
- package/base44/entities/commerce.Customer.jsonc +96 -0
- package/base44/entities/commerce.DownloadPermission.jsonc +54 -0
- package/base44/entities/commerce.EmailLog.jsonc +43 -0
- package/base44/entities/commerce.Order.jsonc +287 -0
- package/base44/entities/commerce.OrderNote.jsonc +31 -0
- package/base44/entities/commerce.OrderRefund.jsonc +64 -0
- package/base44/entities/commerce.PaymentGateway.jsonc +48 -0
- package/base44/entities/commerce.Product.jsonc +291 -0
- package/base44/entities/commerce.ProductAttribute.jsonc +39 -0
- package/base44/entities/commerce.ProductAttributeTerm.jsonc +38 -0
- package/base44/entities/commerce.ProductCategory.jsonc +51 -0
- package/base44/entities/commerce.ProductReview.jsonc +48 -0
- package/base44/entities/commerce.ProductTag.jsonc +30 -0
- package/base44/entities/commerce.ProductVariation.jsonc +167 -0
- package/base44/entities/commerce.ShippingClass.jsonc +30 -0
- package/base44/entities/commerce.ShippingZone.jsonc +41 -0
- package/base44/entities/commerce.ShippingZoneMethod.jsonc +84 -0
- package/base44/entities/commerce.StoreSettings.jsonc +23 -0
- package/base44/entities/commerce.TaxClass.jsonc +23 -0
- package/base44/entities/commerce.TaxRate.jsonc +68 -0
- package/base44/entities/commerce.Webhook.jsonc +57 -0
- package/base44/entities/commerce.WebhookDelivery.jsonc +45 -0
- package/base44/functions/commerce/admin-coupons/entry.ts +100 -0
- package/base44/functions/commerce/admin-customers/entry.ts +141 -0
- package/base44/functions/commerce/admin-orders/entry.ts +396 -0
- package/base44/functions/commerce/admin-orders/helpers.ts +246 -0
- package/base44/functions/commerce/admin-products/entry.ts +506 -0
- package/base44/functions/commerce/admin-refunds/entry.ts +158 -0
- package/base44/functions/commerce/admin-reports/entry.ts +283 -0
- package/base44/functions/commerce/admin-reviews/entry.ts +66 -0
- package/base44/functions/commerce/admin-tools/entry.ts +261 -0
- package/base44/functions/commerce/admin-webhooks/entry.ts +52 -0
- package/base44/functions/commerce/payment-webhook/entry.ts +135 -0
- package/base44/functions/commerce/payments/entry.ts +238 -0
- package/base44/functions/commerce/seed-store/defaults.ts +162 -0
- package/base44/functions/commerce/seed-store/entry.ts +310 -0
- package/base44/functions/commerce/seed-store/sample-data.ts +349 -0
- package/base44/functions/commerce/storefront-account/entry.ts +207 -0
- package/base44/functions/commerce/storefront-cart/cart-pricing.ts +258 -0
- package/base44/functions/commerce/storefront-cart/entry.ts +283 -0
- package/base44/functions/commerce/storefront-catalog/entry.ts +459 -0
- package/base44/functions/commerce/storefront-checkout/cart-pricing.ts +258 -0
- package/base44/functions/commerce/storefront-checkout/entry.ts +485 -0
- package/base44/shared/commerce/auth.ts +60 -0
- package/base44/shared/commerce/coupons.ts +257 -0
- package/base44/shared/commerce/data/continents.ts +75 -0
- package/base44/shared/commerce/data/countries.ts +307 -0
- package/base44/shared/commerce/data/currencies.ts +46 -0
- package/base44/shared/commerce/email-templates.ts +240 -0
- package/base44/shared/commerce/emails.ts +225 -0
- package/base44/shared/commerce/money.ts +66 -0
- package/base44/shared/commerce/orders.ts +251 -0
- package/base44/shared/commerce/payments.ts +495 -0
- package/base44/shared/commerce/reviews.ts +36 -0
- package/base44/shared/commerce/scan.ts +57 -0
- package/base44/shared/commerce/sequence.ts +35 -0
- package/base44/shared/commerce/settings.ts +57 -0
- package/base44/shared/commerce/shipping.ts +215 -0
- package/base44/shared/commerce/stock.ts +227 -0
- package/base44/shared/commerce/stripe.ts +463 -0
- package/base44/shared/commerce/tax.ts +136 -0
- package/base44/shared/commerce/totals.ts +314 -0
- package/base44/shared/commerce/webhooks.ts +116 -0
- package/package.json +37 -0
- package/scripts/install.js +156 -0
- package/skills/commerce/SKILL.md +62 -0
- package/skills/commerce/docs/api-admin.md +186 -0
- package/skills/commerce/docs/api-storefront.md +408 -0
- package/skills/commerce/installation-guidelines.md +91 -0
- package/skills/commerce/post-installation.md +157 -0
- package/skills/commerce/references/emails.md +13 -0
- package/skills/commerce/references/guest-access-security.md +18 -0
- package/skills/commerce/references/limits-and-performance.md +16 -0
- package/skills/commerce/references/media-and-downloads.md +4 -0
- package/skills/commerce/references/online-payments.md +201 -0
- package/skills/commerce/references/product-render.md +87 -0
- package/skills/commerce/references/scheduled-work.md +19 -0
- package/skills/commerce/references/storefront-product-page.md +83 -0
- package/skills/commerce/references/webhooks.md +8 -0
- package/src/commerce/admin/README.md +107 -0
- package/src/commerce/admin/bot/Markdown.jsx +138 -0
- package/src/commerce/admin/bot/StoreAdminBot.jsx +249 -0
- package/src/commerce/admin/bot/pipe-tables.js +116 -0
- package/src/commerce/admin/components/AddressForm.jsx +78 -0
- package/src/commerce/admin/components/ConfirmDialog.jsx +52 -0
- package/src/commerce/admin/components/CountrySelect.jsx +81 -0
- package/src/commerce/admin/components/DataTable.jsx +192 -0
- package/src/commerce/admin/components/DateRangePicker.jsx +91 -0
- package/src/commerce/admin/components/EmptyState.jsx +17 -0
- package/src/commerce/admin/components/MediaUploader.jsx +116 -0
- package/src/commerce/admin/components/MetaDataEditor.jsx +45 -0
- package/src/commerce/admin/components/MoneyInput.jsx +50 -0
- package/src/commerce/admin/components/PageHeader.jsx +29 -0
- package/src/commerce/admin/components/RichTextarea.jsx +21 -0
- package/src/commerce/admin/components/SearchSelect.jsx +142 -0
- package/src/commerce/admin/components/StatusBadge.jsx +17 -0
- package/src/commerce/admin/context/BasePathContext.jsx +26 -0
- package/src/commerce/admin/context/SettingsContext.jsx +207 -0
- package/src/commerce/admin/hooks/useAsync.js +46 -0
- package/src/commerce/admin/hooks/useDebounce.js +11 -0
- package/src/commerce/admin/hooks/useMoney.js +52 -0
- package/src/commerce/admin/hooks/usePagedList.js +83 -0
- package/src/commerce/admin/hooks/usePaymentProvider.js +27 -0
- package/src/commerce/admin/hooks/useRealtime.js +129 -0
- package/src/commerce/admin/index.jsx +34 -0
- package/src/commerce/admin/layout/AccessDenied.jsx +54 -0
- package/src/commerce/admin/layout/AdminLayout.jsx +33 -0
- package/src/commerce/admin/layout/AuthGuard.jsx +84 -0
- package/src/commerce/admin/layout/Sidebar.jsx +130 -0
- package/src/commerce/admin/layout/Topbar.jsx +94 -0
- package/src/commerce/admin/lib/api.js +55 -0
- package/src/commerce/admin/lib/constants.js +157 -0
- package/src/commerce/admin/lib/format.js +27 -0
- package/src/commerce/admin/lib/geo-data.js +125 -0
- package/src/commerce/admin/lib/order-utils.js +147 -0
- package/src/commerce/admin/lib/paths.js +35 -0
- package/src/commerce/admin/lib/product-utils.js +55 -0
- package/src/commerce/admin/pages/Dashboard.jsx +245 -0
- package/src/commerce/admin/pages/coupons/CouponEditor.jsx +565 -0
- package/src/commerce/admin/pages/coupons/CouponsList.jsx +172 -0
- package/src/commerce/admin/pages/customers/CustomerEditor.jsx +318 -0
- package/src/commerce/admin/pages/customers/CustomersList.jsx +169 -0
- package/src/commerce/admin/pages/orders/OrderEditor.jsx +952 -0
- package/src/commerce/admin/pages/orders/OrdersList.jsx +227 -0
- package/src/commerce/admin/pages/orders/components/AddProductDialog.jsx +149 -0
- package/src/commerce/admin/pages/orders/components/DownloadPermissionsPanel.jsx +119 -0
- package/src/commerce/admin/pages/orders/components/LineItemsTable.jsx +208 -0
- package/src/commerce/admin/pages/orders/components/OrderNotesPanel.jsx +123 -0
- package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +199 -0
- package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +239 -0
- package/src/commerce/admin/pages/orders/components/TotalsBox.jsx +52 -0
- package/src/commerce/admin/pages/products/AttributeTerms.jsx +180 -0
- package/src/commerce/admin/pages/products/Attributes.jsx +183 -0
- package/src/commerce/admin/pages/products/Categories.jsx +236 -0
- package/src/commerce/admin/pages/products/ProductEditor.jsx +267 -0
- package/src/commerce/admin/pages/products/ProductsList.jsx +391 -0
- package/src/commerce/admin/pages/products/Reviews.jsx +255 -0
- package/src/commerce/admin/pages/products/Tags.jsx +150 -0
- package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +132 -0
- package/src/commerce/admin/pages/products/components/PublishBox.jsx +101 -0
- package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +243 -0
- package/src/commerce/admin/pages/products/components/tabs/AdvancedTab.jsx +48 -0
- package/src/commerce/admin/pages/products/components/tabs/AttributesTab.jsx +208 -0
- package/src/commerce/admin/pages/products/components/tabs/DownloadsTab.jsx +91 -0
- package/src/commerce/admin/pages/products/components/tabs/ExternalTab.jsx +41 -0
- package/src/commerce/admin/pages/products/components/tabs/GeneralTab.jsx +103 -0
- package/src/commerce/admin/pages/products/components/tabs/InventoryTab.jsx +93 -0
- package/src/commerce/admin/pages/products/components/tabs/LinkedTab.jsx +102 -0
- package/src/commerce/admin/pages/products/components/tabs/ShippingTab.jsx +86 -0
- package/src/commerce/admin/pages/products/components/tabs/VariationsTab.jsx +377 -0
- package/src/commerce/admin/pages/reports/Reports.jsx +416 -0
- package/src/commerce/admin/pages/settings/EmailsSettings.jsx +240 -0
- package/src/commerce/admin/pages/settings/GeneralSettings.jsx +232 -0
- package/src/commerce/admin/pages/settings/InventorySettings.jsx +146 -0
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +260 -0
- package/src/commerce/admin/pages/settings/ProductsSettings.jsx +118 -0
- package/src/commerce/admin/pages/settings/SettingsLayout.jsx +53 -0
- package/src/commerce/admin/pages/settings/ShippingSettings.jsx +304 -0
- package/src/commerce/admin/pages/settings/ShippingZoneEditor.jsx +514 -0
- package/src/commerce/admin/pages/settings/TaxRatesTable.jsx +231 -0
- package/src/commerce/admin/pages/settings/TaxSettings.jsx +281 -0
- package/src/commerce/admin/pages/settings/useGroupForm.jsx +76 -0
- package/src/commerce/admin/pages/status/WebhookEditor.jsx +296 -0
- package/src/commerce/admin/pages/status/Webhooks.jsx +53 -0
- package/src/commerce/admin/routes.jsx +151 -0
- package/src/commerce/utils/index.js +19 -0
- package/src/commerce/utils/shipping-promos.js +99 -0
- package/src/commerce/utils/variants.js +411 -0
|
@@ -0,0 +1,952 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { Link, useNavigate, useParams } from "react-router-dom";
|
|
3
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
4
|
+
import { Badge } from "@/components/ui/badge";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Input } from "@/components/ui/input";
|
|
7
|
+
import { Label } from "@/components/ui/label";
|
|
8
|
+
import {
|
|
9
|
+
Select,
|
|
10
|
+
SelectContent,
|
|
11
|
+
SelectItem,
|
|
12
|
+
SelectTrigger,
|
|
13
|
+
SelectValue,
|
|
14
|
+
} from "@/components/ui/select";
|
|
15
|
+
import {
|
|
16
|
+
Dialog,
|
|
17
|
+
DialogContent,
|
|
18
|
+
DialogFooter,
|
|
19
|
+
DialogHeader,
|
|
20
|
+
DialogTitle,
|
|
21
|
+
} from "@/components/ui/dialog";
|
|
22
|
+
import { Separator } from "@/components/ui/separator";
|
|
23
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
24
|
+
import { Copy, ExternalLink, Loader2, Mail, Pencil, Plus, RotateCw, Ticket, Trash2, Truck, UserPlus } from "lucide-react";
|
|
25
|
+
import { toast } from "sonner";
|
|
26
|
+
|
|
27
|
+
import { call, base44 } from "../../lib/api";
|
|
28
|
+
import useAsync from "../../hooks/useAsync";
|
|
29
|
+
import useDebounce from "../../hooks/useDebounce";
|
|
30
|
+
import { useAdminHref } from "../../context/BasePathContext";
|
|
31
|
+
import { ORDER_STATUSES } from "../../lib/constants";
|
|
32
|
+
import { formatDateTime } from "../../lib/format";
|
|
33
|
+
import {
|
|
34
|
+
buildOrderPatch,
|
|
35
|
+
canEditLines,
|
|
36
|
+
customerName,
|
|
37
|
+
isBlankAddress,
|
|
38
|
+
mergePricedLines,
|
|
39
|
+
pricedPatchOf,
|
|
40
|
+
} from "../../lib/order-utils";
|
|
41
|
+
import PageHeader from "../../components/PageHeader";
|
|
42
|
+
import StatusBadge from "../../components/StatusBadge";
|
|
43
|
+
import AddressForm from "../../components/AddressForm";
|
|
44
|
+
import SearchSelect from "../../components/SearchSelect";
|
|
45
|
+
import ConfirmDialog from "../../components/ConfirmDialog";
|
|
46
|
+
import MetaDataEditor from "../../components/MetaDataEditor";
|
|
47
|
+
import LineItemsTable from "./components/LineItemsTable";
|
|
48
|
+
import AddProductDialog from "./components/AddProductDialog";
|
|
49
|
+
import TotalsBox from "./components/TotalsBox";
|
|
50
|
+
import RefundPanel from "./components/RefundPanel";
|
|
51
|
+
import OrderNotesPanel from "./components/OrderNotesPanel";
|
|
52
|
+
import PaymentPanel from "./components/PaymentPanel";
|
|
53
|
+
import DownloadPermissionsPanel from "./components/DownloadPermissionsPanel";
|
|
54
|
+
|
|
55
|
+
/** Keys of the order that this editor patches. */
|
|
56
|
+
const DRAFT_KEYS = ["status", "customer_id", "billing", "shipping", "line_items", "fee_lines", "shipping_lines", "meta_data"];
|
|
57
|
+
|
|
58
|
+
const draftFrom = (order) =>
|
|
59
|
+
DRAFT_KEYS.reduce((d, k) => {
|
|
60
|
+
d[k] = order?.[k] ?? (k.endsWith("_lines") || k === "line_items" || k === "meta_data" ? [] : k === "billing" || k === "shipping" ? {} : "");
|
|
61
|
+
return d;
|
|
62
|
+
}, {});
|
|
63
|
+
|
|
64
|
+
const uid = () => (crypto.randomUUID ? crypto.randomUUID() : `line_${Math.random().toString(36).slice(2)}`);
|
|
65
|
+
|
|
66
|
+
/** Copy a Customer's saved address onto the order's address shape. */
|
|
67
|
+
const addressFromCustomer = (address, { email } = {}) => {
|
|
68
|
+
const next = { ...(address || {}) };
|
|
69
|
+
if (email !== undefined) next.email = email || next.email || "";
|
|
70
|
+
return next;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export default function OrderEditor() {
|
|
74
|
+
const { id } = useParams();
|
|
75
|
+
const href = useAdminHref();
|
|
76
|
+
const navigate = useNavigate();
|
|
77
|
+
|
|
78
|
+
// `/orders/new` → create a draft server-side, then swap to the real id.
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (id) return;
|
|
81
|
+
let cancelled = false;
|
|
82
|
+
call("admin-orders", "create-draft").then((data) => {
|
|
83
|
+
const newId = data?.id || data?.order_id || data?.order?.id;
|
|
84
|
+
if (!cancelled && newId) navigate(href(`orders/${newId}`), { replace: true });
|
|
85
|
+
});
|
|
86
|
+
return () => {
|
|
87
|
+
cancelled = true;
|
|
88
|
+
};
|
|
89
|
+
// eslint-disable-next-line -- deps intentionally partial
|
|
90
|
+
}, [id]);
|
|
91
|
+
|
|
92
|
+
const order = useAsync(() => (id ? base44.entities["commerce.Order"].get(id) : Promise.resolve(null)), [id]);
|
|
93
|
+
const notes = useAsync(
|
|
94
|
+
() => (id ? base44.entities["commerce.OrderNote"].filter({ order_id: id }, "-created_date", 100) : Promise.resolve([])),
|
|
95
|
+
[id]
|
|
96
|
+
);
|
|
97
|
+
const refunds = useAsync(
|
|
98
|
+
() => (id ? base44.entities["commerce.OrderRefund"].filter({ order_id: id }, "-created_date", 100) : Promise.resolve([])),
|
|
99
|
+
[id]
|
|
100
|
+
);
|
|
101
|
+
const permissions = useAsync(
|
|
102
|
+
() => (id ? base44.entities["commerce.DownloadPermission"].filter({ order_id: id }, "-created_date", 100) : Promise.resolve([])),
|
|
103
|
+
[id]
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const [draft, setDraft] = useState(null);
|
|
107
|
+
const [customerOpt, setCustomerOpt] = useState(null);
|
|
108
|
+
const [fillingAddresses, setFillingAddresses] = useState(false);
|
|
109
|
+
const [savingCustomer, setSavingCustomer] = useState(false);
|
|
110
|
+
const [editBilling, setEditBilling] = useState(false);
|
|
111
|
+
const [editShipping, setEditShipping] = useState(false);
|
|
112
|
+
const [showRefund, setShowRefund] = useState(false);
|
|
113
|
+
const [addProductOpen, setAddProductOpen] = useState(false);
|
|
114
|
+
const [feeDialog, setFeeDialog] = useState(false);
|
|
115
|
+
const [shippingDialog, setShippingDialog] = useState(false);
|
|
116
|
+
const [couponDialog, setCouponDialog] = useState(false);
|
|
117
|
+
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
118
|
+
const [saving, setSaving] = useState(false);
|
|
119
|
+
const [working, setWorking] = useState(""); // recalculate | coupon | invoice | delete
|
|
120
|
+
|
|
121
|
+
// (Re)initialize the local draft whenever the order loads.
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (!order.data) return;
|
|
124
|
+
setDraft(draftFrom(order.data));
|
|
125
|
+
setCustomerOpt(
|
|
126
|
+
order.data.customer_id
|
|
127
|
+
? { value: order.data.customer_id, label: customerName(order.data) }
|
|
128
|
+
: null
|
|
129
|
+
);
|
|
130
|
+
setEditBilling(false);
|
|
131
|
+
setEditShipping(false);
|
|
132
|
+
}, [order.data]);
|
|
133
|
+
|
|
134
|
+
const dirty = useMemo(() => {
|
|
135
|
+
if (!order.data || !draft) return false;
|
|
136
|
+
return JSON.stringify(draft) !== JSON.stringify(draftFrom(order.data));
|
|
137
|
+
}, [draft, order.data]);
|
|
138
|
+
|
|
139
|
+
const patchDraft = (patch) => setDraft((d) => ({ ...d, ...patch }));
|
|
140
|
+
|
|
141
|
+
// Contact details typed onto the order itself. With no customer linked this is
|
|
142
|
+
// a guest order, and these two drive the offer to keep the details as a
|
|
143
|
+
// customer record (the email is required — it's the Customer entity's key).
|
|
144
|
+
const billingEmail = String(draft?.billing?.email || "").trim();
|
|
145
|
+
const hasBillingDetails = !isBlankAddress(draft?.billing);
|
|
146
|
+
|
|
147
|
+
// ── live totals for unsaved edits ────────────────────────────────────────
|
|
148
|
+
// Adding a product (or changing a qty, fee, shipping line or address) has to
|
|
149
|
+
// show up in the totals immediately. Tax, coupon and shipping math lives in
|
|
150
|
+
// the backend totals engine, so instead of approximating it here we ask the
|
|
151
|
+
// server to price the *pending* patch read-only (`preview`) and display that.
|
|
152
|
+
const [priceView, setPriceView] = useState(null); // priced Order fields | null
|
|
153
|
+
const [previewing, setPreviewing] = useState(false);
|
|
154
|
+
const [previewError, setPreviewError] = useState("");
|
|
155
|
+
|
|
156
|
+
// Only the keys the server reprices from, so editing the status, the customer
|
|
157
|
+
// or a custom field doesn't trigger a pointless round trip. These are the same
|
|
158
|
+
// values `save` would send, so the preview matches the saved result exactly.
|
|
159
|
+
const pricedPatch = useMemo(() => {
|
|
160
|
+
if (!order.data || !draft) return null;
|
|
161
|
+
const priced = pricedPatchOf(buildOrderPatch(draft, draftFrom(order.data)));
|
|
162
|
+
return Object.keys(priced).length ? priced : null;
|
|
163
|
+
}, [draft, order.data]);
|
|
164
|
+
|
|
165
|
+
// Serialized so the debounce keys off content, not object identity.
|
|
166
|
+
const pricedKey = useMemo(() => (pricedPatch ? JSON.stringify(pricedPatch) : ""), [pricedPatch]);
|
|
167
|
+
const debouncedKey = useDebounce(pricedKey, 400);
|
|
168
|
+
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
if (!id || !debouncedKey) {
|
|
171
|
+
setPriceView(null);
|
|
172
|
+
setPreviewError("");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
let cancelled = false;
|
|
176
|
+
setPreviewing(true);
|
|
177
|
+
call("admin-orders", "preview", { order_id: id, patch: JSON.parse(debouncedKey) }, { silent: true })
|
|
178
|
+
.then((data) => {
|
|
179
|
+
if (cancelled) return;
|
|
180
|
+
setPriceView(data?.fields || null);
|
|
181
|
+
setPreviewError("");
|
|
182
|
+
})
|
|
183
|
+
.catch((err) => {
|
|
184
|
+
if (cancelled) return;
|
|
185
|
+
setPriceView(null);
|
|
186
|
+
setPreviewError(err.message || "Could not price these changes");
|
|
187
|
+
})
|
|
188
|
+
.finally(() => !cancelled && setPreviewing(false));
|
|
189
|
+
return () => {
|
|
190
|
+
cancelled = true;
|
|
191
|
+
};
|
|
192
|
+
}, [id, debouncedKey]);
|
|
193
|
+
|
|
194
|
+
/** True while the shown totals are older than the current edits. */
|
|
195
|
+
const previewStale = Boolean(pricedKey) && (previewing || pricedKey !== debouncedKey);
|
|
196
|
+
|
|
197
|
+
const refetchAll = () => {
|
|
198
|
+
order.refetch();
|
|
199
|
+
notes.refetch();
|
|
200
|
+
refunds.refetch();
|
|
201
|
+
permissions.refetch();
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/** Write the pending draft. Returns true when something was sent. */
|
|
205
|
+
const persist = async () => {
|
|
206
|
+
const patch = buildOrderPatch(draft, draftFrom(order.data));
|
|
207
|
+
if (Object.keys(patch).length === 0) return false;
|
|
208
|
+
await call("admin-orders", "update", { order_id: id, patch });
|
|
209
|
+
return true;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const save = async () => {
|
|
213
|
+
if (!dirty) return;
|
|
214
|
+
setSaving(true);
|
|
215
|
+
try {
|
|
216
|
+
if (await persist()) toast.success("Order saved");
|
|
217
|
+
refetchAll();
|
|
218
|
+
} catch {
|
|
219
|
+
/* toast handled by call() */
|
|
220
|
+
} finally {
|
|
221
|
+
setSaving(false);
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Run a server-side order operation.
|
|
227
|
+
*
|
|
228
|
+
* `flush` first writes any unsaved draft edits: these operations reprice the
|
|
229
|
+
* *persisted* order and end in a refetch that resets the draft, so without it
|
|
230
|
+
* they would either be dead ends (disabled while dirty) or silently discard
|
|
231
|
+
* the admin's pending changes.
|
|
232
|
+
*/
|
|
233
|
+
const serverOp = async (label, fn, { flush = false } = {}) => {
|
|
234
|
+
setWorking(label);
|
|
235
|
+
try {
|
|
236
|
+
if (flush && dirty) await persist();
|
|
237
|
+
await fn();
|
|
238
|
+
refetchAll();
|
|
239
|
+
} catch {
|
|
240
|
+
/* toast handled by call() */
|
|
241
|
+
} finally {
|
|
242
|
+
setWorking("");
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const recalculate = () =>
|
|
247
|
+
serverOp(
|
|
248
|
+
"recalculate",
|
|
249
|
+
async () => {
|
|
250
|
+
await call("admin-orders", "recalculate", { order_id: id });
|
|
251
|
+
toast.success(dirty ? "Order saved and totals recalculated" : "Totals recalculated");
|
|
252
|
+
},
|
|
253
|
+
{ flush: true }
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const sendInvoice = () =>
|
|
257
|
+
serverOp("invoice", async () => {
|
|
258
|
+
await call("admin-orders", "send-email", { order_id: id, type: "customer_invoice" });
|
|
259
|
+
toast.success("Order details sent to customer");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const deleteOrder = () =>
|
|
263
|
+
serverOp("delete", async () => {
|
|
264
|
+
await call("admin-orders", "delete", { order_id: id });
|
|
265
|
+
toast.success("Order deleted");
|
|
266
|
+
navigate(href("orders"));
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const searchCustomers = useCallback(async (q) => {
|
|
270
|
+
const data = await call("admin-customers", "search", { q, limit: 20 }, { silent: true });
|
|
271
|
+
const rows = data?.rows || data || [];
|
|
272
|
+
return rows.map((c) => ({
|
|
273
|
+
value: c.id,
|
|
274
|
+
label: [c.first_name, c.last_name].filter(Boolean).join(" ") || c.email,
|
|
275
|
+
meta: [c.email, c.orders_count ? `${c.orders_count} order(s)` : null].filter(Boolean).join(" · "),
|
|
276
|
+
record: c,
|
|
277
|
+
}));
|
|
278
|
+
}, []);
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Copy a customer's saved billing/shipping onto the order.
|
|
282
|
+
* `overwrite: false` (picking a customer) only fills addresses that are still
|
|
283
|
+
* empty, so it can never wipe details the admin already typed; the explicit
|
|
284
|
+
* "Use customer's addresses" button passes `overwrite: true`.
|
|
285
|
+
*/
|
|
286
|
+
const applyCustomerAddresses = async (customer, { overwrite = false } = {}) => {
|
|
287
|
+
const record = customer;
|
|
288
|
+
if (!record) return { filled: [], skipped: [] };
|
|
289
|
+
|
|
290
|
+
const filled = [];
|
|
291
|
+
const skipped = [];
|
|
292
|
+
const patch = {};
|
|
293
|
+
|
|
294
|
+
const billing = addressFromCustomer(record.billing, { email: record.email });
|
|
295
|
+
if (!isBlankAddress(billing)) {
|
|
296
|
+
if (overwrite || isBlankAddress(draft.billing)) {
|
|
297
|
+
patch.billing = billing;
|
|
298
|
+
filled.push("billing");
|
|
299
|
+
} else {
|
|
300
|
+
skipped.push("billing");
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const shipping = addressFromCustomer(record.shipping);
|
|
305
|
+
const shippingSource = isBlankAddress(shipping) ? { ...billing, email: undefined } : shipping;
|
|
306
|
+
if (!isBlankAddress(shippingSource)) {
|
|
307
|
+
if (overwrite || isBlankAddress(draft.shipping)) {
|
|
308
|
+
patch.shipping = shippingSource;
|
|
309
|
+
filled.push("shipping");
|
|
310
|
+
} else {
|
|
311
|
+
skipped.push("shipping");
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (Object.keys(patch).length) patchDraft(patch);
|
|
316
|
+
return { filled, skipped };
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const pickCustomer = async (opt) => {
|
|
320
|
+
setCustomerOpt(opt);
|
|
321
|
+
patchDraft({ customer_id: opt?.value || "" });
|
|
322
|
+
if (!opt) return;
|
|
323
|
+
|
|
324
|
+
const record = opt.record ?? (await base44.entities["commerce.Customer"].get(opt.value).catch(() => null));
|
|
325
|
+
const { filled, skipped } = await applyCustomerAddresses(record);
|
|
326
|
+
if (filled.length) {
|
|
327
|
+
toast.success(`Filled ${filled.join(" and ")} from ${opt.label}`);
|
|
328
|
+
} else if (skipped.length) {
|
|
329
|
+
toast.info("Kept the addresses already on this order — use “Use customer's addresses” to replace them.");
|
|
330
|
+
} else {
|
|
331
|
+
toast.info(`${opt.label} has no saved address — enter the details below.`);
|
|
332
|
+
setEditBilling(true);
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
/** Explicit overwrite from the currently linked customer. */
|
|
337
|
+
const refillFromCustomer = async () => {
|
|
338
|
+
if (!draft.customer_id) return;
|
|
339
|
+
setFillingAddresses(true);
|
|
340
|
+
try {
|
|
341
|
+
const record =
|
|
342
|
+
customerOpt?.record ?? (await base44.entities["commerce.Customer"].get(draft.customer_id).catch(() => null));
|
|
343
|
+
if (!record) {
|
|
344
|
+
toast.error("Could not load that customer record");
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const { filled } = await applyCustomerAddresses(record, { overwrite: true });
|
|
348
|
+
if (filled.length) toast.success(`Replaced ${filled.join(" and ")} from the customer record`);
|
|
349
|
+
else toast.info("That customer has no saved address");
|
|
350
|
+
} finally {
|
|
351
|
+
setFillingAddresses(false);
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Turn the details typed on this order into a customer record and link it.
|
|
357
|
+
*
|
|
358
|
+
* An order with no customer is a guest order; once the admin has filled in the
|
|
359
|
+
* billing details there's a whole contact sitting there, so offer to keep it.
|
|
360
|
+
* Reuses an existing record when that email is already a customer (the email
|
|
361
|
+
* is unique — creating would fail with `duplicate_email`), otherwise creates
|
|
362
|
+
* one. The link itself lands in the draft, so it's saved with the order.
|
|
363
|
+
*/
|
|
364
|
+
const saveAsCustomer = async () => {
|
|
365
|
+
const email = billingEmail;
|
|
366
|
+
if (!email) return;
|
|
367
|
+
setSavingCustomer(true);
|
|
368
|
+
try {
|
|
369
|
+
const found = await call("admin-customers", "search", { q: email, limit: 5 }, { silent: true }).catch(() => null);
|
|
370
|
+
const rows = found?.rows || found || [];
|
|
371
|
+
const existing = rows.find((c) => String(c.email || "").toLowerCase() === email.toLowerCase());
|
|
372
|
+
|
|
373
|
+
const record =
|
|
374
|
+
existing ??
|
|
375
|
+
(await call("admin-customers", "save", {
|
|
376
|
+
customer: {
|
|
377
|
+
email,
|
|
378
|
+
first_name: draft.billing?.first_name || "",
|
|
379
|
+
last_name: draft.billing?.last_name || "",
|
|
380
|
+
billing: { ...(draft.billing || {}), email },
|
|
381
|
+
shipping: isBlankAddress(draft.shipping)
|
|
382
|
+
? { ...(draft.billing || {}), email: undefined }
|
|
383
|
+
: { ...draft.shipping },
|
|
384
|
+
},
|
|
385
|
+
}));
|
|
386
|
+
|
|
387
|
+
if (!record?.id) return;
|
|
388
|
+
const label = [record.first_name, record.last_name].filter(Boolean).join(" ") || record.email;
|
|
389
|
+
setCustomerOpt({ value: record.id, label, meta: record.email, record });
|
|
390
|
+
patchDraft({ customer_id: record.id });
|
|
391
|
+
toast.success(
|
|
392
|
+
existing
|
|
393
|
+
? `${label} is already a customer — linked. Save the order to keep the link.`
|
|
394
|
+
: `Customer ${label} created. Save the order to keep the link.`
|
|
395
|
+
);
|
|
396
|
+
} catch {
|
|
397
|
+
/* toast handled by call() */
|
|
398
|
+
} finally {
|
|
399
|
+
setSavingCustomer(false);
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const addProductLine = ({ product, variation, quantity }) => {
|
|
404
|
+
const price = variation?.price ?? variation?.regular_price ?? product.price ?? product.regular_price ?? 0;
|
|
405
|
+
const line = {
|
|
406
|
+
line_id: uid(),
|
|
407
|
+
product_id: product.id,
|
|
408
|
+
variation_id: variation?.id || "",
|
|
409
|
+
name: product.name,
|
|
410
|
+
sku: variation?.sku || product.sku || "",
|
|
411
|
+
quantity,
|
|
412
|
+
price,
|
|
413
|
+
tax_class: variation?.tax_class || product.tax_class || "standard",
|
|
414
|
+
subtotal: price * quantity,
|
|
415
|
+
subtotal_tax: 0,
|
|
416
|
+
total: price * quantity,
|
|
417
|
+
total_tax: 0,
|
|
418
|
+
taxes: [],
|
|
419
|
+
attributes: (variation?.attributes || []).map((a) => ({ name: a.name, option: a.option })),
|
|
420
|
+
meta_data: [],
|
|
421
|
+
};
|
|
422
|
+
patchDraft({ line_items: [...(draft?.line_items || []), line] });
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
if (!id || order.loading || !draft) {
|
|
426
|
+
return (
|
|
427
|
+
<div className="space-y-4">
|
|
428
|
+
<Skeleton className="h-8 w-64" />
|
|
429
|
+
<Skeleton className="h-64 w-full" />
|
|
430
|
+
</div>
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
if (!order.data) {
|
|
434
|
+
return <p className="py-12 text-center text-sm text-muted-foreground">Order not found.</p>;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const o = order.data;
|
|
438
|
+
const editable = canEditLines(o);
|
|
439
|
+
|
|
440
|
+
// Server-priced view of the pending edits (falls back to the persisted order
|
|
441
|
+
// when there is nothing pending, or while a preview is in flight/failed).
|
|
442
|
+
const totalsView = priceView ? { ...o, ...priceView } : o;
|
|
443
|
+
|
|
444
|
+
// Draft lines carry only local price × qty math; a preview's per-line tax and
|
|
445
|
+
// discount figures are folded in so the table agrees with the totals box.
|
|
446
|
+
const viewLines = mergePricedLines(draft.line_items, priceView?.line_items);
|
|
447
|
+
|
|
448
|
+
// draft lines/addresses over server totals. Fee and shipping lines stay as the
|
|
449
|
+
// draft has them (they are edited in place); only the coupon chips come from
|
|
450
|
+
// the preview, since coupons are applied server-side and their discount
|
|
451
|
+
// changes with the items.
|
|
452
|
+
const view = {
|
|
453
|
+
...o,
|
|
454
|
+
...draft,
|
|
455
|
+
line_items: viewLines,
|
|
456
|
+
coupon_lines: priceView?.coupon_lines ?? o.coupon_lines,
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
return (
|
|
460
|
+
<div>
|
|
461
|
+
<PageHeader
|
|
462
|
+
title={
|
|
463
|
+
<span className="flex items-center gap-3">
|
|
464
|
+
Order #{o.order_number || o.id}
|
|
465
|
+
<StatusBadge status={o.status} />
|
|
466
|
+
</span>
|
|
467
|
+
}
|
|
468
|
+
description={`Placed ${formatDateTime(o.created_date)} via ${o.created_via || "checkout"}${
|
|
469
|
+
o.payment_method_title ? ` · ${o.payment_method_title}` : ""
|
|
470
|
+
}`}
|
|
471
|
+
backHref={href("orders")}
|
|
472
|
+
/>
|
|
473
|
+
|
|
474
|
+
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3">
|
|
475
|
+
{/* ── Main column ─────────────────────────────────────────────── */}
|
|
476
|
+
<div className="space-y-6 xl:col-span-2">
|
|
477
|
+
{/* General */}
|
|
478
|
+
<Card>
|
|
479
|
+
<CardHeader className="pb-3">
|
|
480
|
+
<CardTitle className="text-base">General</CardTitle>
|
|
481
|
+
</CardHeader>
|
|
482
|
+
{/* items-start: without it the shorter Status cell stretches to the
|
|
483
|
+
row height set by the Customer cell's helper text, and its
|
|
484
|
+
dropdown drifts below the Customer one. */}
|
|
485
|
+
<CardContent className="grid grid-cols-1 items-start gap-4 sm:grid-cols-2">
|
|
486
|
+
<div className="grid gap-1.5">
|
|
487
|
+
<Label className="text-xs text-muted-foreground">Status</Label>
|
|
488
|
+
<Select value={draft.status} onValueChange={(v) => patchDraft({ status: v })}>
|
|
489
|
+
<SelectTrigger>
|
|
490
|
+
<SelectValue />
|
|
491
|
+
</SelectTrigger>
|
|
492
|
+
<SelectContent>
|
|
493
|
+
{ORDER_STATUSES.map((s) => (
|
|
494
|
+
<SelectItem key={s.value} value={s.value}>
|
|
495
|
+
{s.label}
|
|
496
|
+
</SelectItem>
|
|
497
|
+
))}
|
|
498
|
+
</SelectContent>
|
|
499
|
+
</Select>
|
|
500
|
+
</div>
|
|
501
|
+
<div className="grid gap-1.5">
|
|
502
|
+
<Label className="text-xs text-muted-foreground">Customer</Label>
|
|
503
|
+
<SearchSelect
|
|
504
|
+
search={searchCustomers}
|
|
505
|
+
value={customerOpt}
|
|
506
|
+
onChange={pickCustomer}
|
|
507
|
+
placeholder="Guest — search customers…"
|
|
508
|
+
/>
|
|
509
|
+
|
|
510
|
+
{draft.customer_id ? (
|
|
511
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
512
|
+
<Button
|
|
513
|
+
variant="ghost"
|
|
514
|
+
size="sm"
|
|
515
|
+
className="h-7 px-2 text-xs"
|
|
516
|
+
disabled={fillingAddresses}
|
|
517
|
+
onClick={refillFromCustomer}
|
|
518
|
+
title="Replace the order's billing and shipping with the customer's saved addresses"
|
|
519
|
+
>
|
|
520
|
+
{fillingAddresses ? (
|
|
521
|
+
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
|
522
|
+
) : (
|
|
523
|
+
<RotateCw className="mr-1 h-3.5 w-3.5" />
|
|
524
|
+
)}
|
|
525
|
+
Use customer's addresses
|
|
526
|
+
</Button>
|
|
527
|
+
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" asChild>
|
|
528
|
+
<Link to={href(`customers/${draft.customer_id}`)}>
|
|
529
|
+
<ExternalLink className="mr-1 h-3.5 w-3.5" /> Open customer
|
|
530
|
+
</Link>
|
|
531
|
+
</Button>
|
|
532
|
+
</div>
|
|
533
|
+
) : hasBillingDetails ? (
|
|
534
|
+
// Guest order with details already typed in — offer to keep them.
|
|
535
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
536
|
+
<Button
|
|
537
|
+
variant="ghost"
|
|
538
|
+
size="sm"
|
|
539
|
+
className="h-7 px-2 text-xs"
|
|
540
|
+
disabled={savingCustomer || !billingEmail}
|
|
541
|
+
onClick={saveAsCustomer}
|
|
542
|
+
title={
|
|
543
|
+
billingEmail
|
|
544
|
+
? "Create a customer from the billing details below and link this order to it"
|
|
545
|
+
: "Add a billing email below to save these details as a customer"
|
|
546
|
+
}
|
|
547
|
+
>
|
|
548
|
+
{savingCustomer ? (
|
|
549
|
+
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
|
550
|
+
) : (
|
|
551
|
+
<UserPlus className="mr-1 h-3.5 w-3.5" />
|
|
552
|
+
)}
|
|
553
|
+
Save as customer
|
|
554
|
+
</Button>
|
|
555
|
+
<span className="text-xs text-muted-foreground">
|
|
556
|
+
{billingEmail ? "Guest order" : "Guest order — needs a billing email"}
|
|
557
|
+
</span>
|
|
558
|
+
</div>
|
|
559
|
+
) : (
|
|
560
|
+
<p className="text-xs text-muted-foreground">
|
|
561
|
+
Guest order. Pick a customer to fill the addresses from their record, or enter the details
|
|
562
|
+
below — the billing email is where this order's emails go.
|
|
563
|
+
</p>
|
|
564
|
+
)}
|
|
565
|
+
</div>
|
|
566
|
+
</CardContent>
|
|
567
|
+
</Card>
|
|
568
|
+
|
|
569
|
+
{/* Addresses */}
|
|
570
|
+
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
571
|
+
{[
|
|
572
|
+
{ key: "billing", title: "Billing", edit: editBilling, setEdit: setEditBilling, showEmail: true },
|
|
573
|
+
{ key: "shipping", title: "Shipping", edit: editShipping, setEdit: setEditShipping, showEmail: false },
|
|
574
|
+
].map(({ key, title, edit, setEdit, showEmail }) => (
|
|
575
|
+
<Card key={key}>
|
|
576
|
+
<CardHeader className="flex-row items-center justify-between space-y-0 pb-3">
|
|
577
|
+
<CardTitle className="text-base">{title}</CardTitle>
|
|
578
|
+
<div className="flex gap-1">
|
|
579
|
+
{key === "shipping" && edit && (
|
|
580
|
+
<Button
|
|
581
|
+
variant="ghost"
|
|
582
|
+
size="sm"
|
|
583
|
+
onClick={() => patchDraft({ shipping: { ...draft.billing, email: undefined } })}
|
|
584
|
+
title="Copy from billing"
|
|
585
|
+
>
|
|
586
|
+
<Copy className="mr-1 h-3.5 w-3.5" /> From billing
|
|
587
|
+
</Button>
|
|
588
|
+
)}
|
|
589
|
+
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setEdit(!edit)}>
|
|
590
|
+
<Pencil className="h-3.5 w-3.5" />
|
|
591
|
+
</Button>
|
|
592
|
+
</div>
|
|
593
|
+
</CardHeader>
|
|
594
|
+
<CardContent>
|
|
595
|
+
{edit ? (
|
|
596
|
+
<AddressForm
|
|
597
|
+
value={draft[key]}
|
|
598
|
+
onChange={(v) => patchDraft({ [key]: v })}
|
|
599
|
+
showEmail={showEmail}
|
|
600
|
+
/>
|
|
601
|
+
) : (
|
|
602
|
+
<AddressDisplay address={draft[key]} />
|
|
603
|
+
)}
|
|
604
|
+
</CardContent>
|
|
605
|
+
</Card>
|
|
606
|
+
))}
|
|
607
|
+
</div>
|
|
608
|
+
|
|
609
|
+
{/* Items */}
|
|
610
|
+
<Card>
|
|
611
|
+
<CardHeader className="pb-3">
|
|
612
|
+
<CardTitle className="text-base">Items</CardTitle>
|
|
613
|
+
</CardHeader>
|
|
614
|
+
<CardContent className="p-0">
|
|
615
|
+
<LineItemsTable
|
|
616
|
+
order={view}
|
|
617
|
+
editable={editable}
|
|
618
|
+
onChange={patchDraft}
|
|
619
|
+
removingCoupon={working === "coupon" ? "*" : ""}
|
|
620
|
+
onRemoveCoupon={(code) =>
|
|
621
|
+
serverOp(
|
|
622
|
+
"coupon",
|
|
623
|
+
async () => {
|
|
624
|
+
await call("admin-orders", "remove-coupon", { order_id: id, code });
|
|
625
|
+
toast.success(`Coupon ${code} removed`);
|
|
626
|
+
},
|
|
627
|
+
{ flush: true }
|
|
628
|
+
)
|
|
629
|
+
}
|
|
630
|
+
/>
|
|
631
|
+
{editable && (
|
|
632
|
+
<div className="flex flex-wrap gap-2 border-t p-3">
|
|
633
|
+
<Button variant="outline" size="sm" onClick={() => setAddProductOpen(true)}>
|
|
634
|
+
<Plus className="mr-1 h-3.5 w-3.5" /> Add product(s)
|
|
635
|
+
</Button>
|
|
636
|
+
<Button variant="outline" size="sm" onClick={() => setFeeDialog(true)}>
|
|
637
|
+
<Plus className="mr-1 h-3.5 w-3.5" /> Add fee
|
|
638
|
+
</Button>
|
|
639
|
+
<Button variant="outline" size="sm" onClick={() => setShippingDialog(true)}>
|
|
640
|
+
<Truck className="mr-1 h-3.5 w-3.5" /> Add shipping
|
|
641
|
+
</Button>
|
|
642
|
+
<Button
|
|
643
|
+
variant="outline"
|
|
644
|
+
size="sm"
|
|
645
|
+
disabled={Boolean(working)}
|
|
646
|
+
title={dirty ? "Saves your pending changes, then applies the coupon" : undefined}
|
|
647
|
+
onClick={() => setCouponDialog(true)}
|
|
648
|
+
>
|
|
649
|
+
<Ticket className="mr-1 h-3.5 w-3.5" /> Apply coupon
|
|
650
|
+
</Button>
|
|
651
|
+
<Button
|
|
652
|
+
variant="outline"
|
|
653
|
+
size="sm"
|
|
654
|
+
className="ml-auto"
|
|
655
|
+
disabled={Boolean(working) || saving}
|
|
656
|
+
title={
|
|
657
|
+
dirty
|
|
658
|
+
? "Saves your pending changes, then re-runs tax and totals against the current settings"
|
|
659
|
+
: "Re-run tax and totals against the current settings"
|
|
660
|
+
}
|
|
661
|
+
onClick={recalculate}
|
|
662
|
+
>
|
|
663
|
+
{working === "recalculate" ? (
|
|
664
|
+
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
|
665
|
+
) : (
|
|
666
|
+
<RotateCw className="mr-1 h-3.5 w-3.5" />
|
|
667
|
+
)}
|
|
668
|
+
{dirty ? "Save & recalculate" : "Recalculate"}
|
|
669
|
+
</Button>
|
|
670
|
+
</div>
|
|
671
|
+
)}
|
|
672
|
+
<div className="border-t p-4">
|
|
673
|
+
{pricedPatch && (
|
|
674
|
+
<div className="mb-2 flex items-center justify-end gap-1.5 text-xs text-muted-foreground">
|
|
675
|
+
{previewStale ? (
|
|
676
|
+
<>
|
|
677
|
+
<Loader2 className="h-3 w-3 animate-spin" /> Pricing your changes…
|
|
678
|
+
</>
|
|
679
|
+
) : previewError ? (
|
|
680
|
+
<span className="text-destructive">
|
|
681
|
+
Could not price these changes: {previewError}
|
|
682
|
+
</span>
|
|
683
|
+
) : (
|
|
684
|
+
<Badge variant="secondary" className="font-normal">
|
|
685
|
+
Unsaved — totals below include your changes
|
|
686
|
+
</Badge>
|
|
687
|
+
)}
|
|
688
|
+
</div>
|
|
689
|
+
)}
|
|
690
|
+
<TotalsBox order={totalsView} />
|
|
691
|
+
<div className="mt-3 flex justify-end">
|
|
692
|
+
<Button variant="outline" size="sm" onClick={() => setShowRefund((v) => !v)}>
|
|
693
|
+
{showRefund ? "Close refund" : "Refund"}
|
|
694
|
+
</Button>
|
|
695
|
+
</div>
|
|
696
|
+
</div>
|
|
697
|
+
</CardContent>
|
|
698
|
+
</Card>
|
|
699
|
+
|
|
700
|
+
{showRefund && (
|
|
701
|
+
<RefundPanel
|
|
702
|
+
order={o}
|
|
703
|
+
refunds={refunds.data || []}
|
|
704
|
+
onDone={() => {
|
|
705
|
+
setShowRefund(false);
|
|
706
|
+
refetchAll();
|
|
707
|
+
}}
|
|
708
|
+
/>
|
|
709
|
+
)}
|
|
710
|
+
|
|
711
|
+
<DownloadPermissionsPanel
|
|
712
|
+
orderId={id}
|
|
713
|
+
permissions={permissions.data || []}
|
|
714
|
+
loading={permissions.loading}
|
|
715
|
+
onChanged={() => permissions.refetch()}
|
|
716
|
+
/>
|
|
717
|
+
|
|
718
|
+
{/* Custom fields */}
|
|
719
|
+
<Card>
|
|
720
|
+
<CardHeader className="pb-3">
|
|
721
|
+
<CardTitle className="text-base">Custom fields</CardTitle>
|
|
722
|
+
</CardHeader>
|
|
723
|
+
<CardContent>
|
|
724
|
+
<MetaDataEditor value={draft.meta_data || []} onChange={(v) => patchDraft({ meta_data: v })} />
|
|
725
|
+
</CardContent>
|
|
726
|
+
</Card>
|
|
727
|
+
</div>
|
|
728
|
+
|
|
729
|
+
{/* ── Sidebar ─────────────────────────────────────────────────── */}
|
|
730
|
+
<div className="space-y-6">
|
|
731
|
+
<Card>
|
|
732
|
+
<CardHeader className="pb-3">
|
|
733
|
+
<CardTitle className="text-base">Order actions</CardTitle>
|
|
734
|
+
</CardHeader>
|
|
735
|
+
<CardContent className="space-y-2">
|
|
736
|
+
<Button className="w-full" onClick={save} disabled={!dirty || saving}>
|
|
737
|
+
{saving && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
|
|
738
|
+
Save order
|
|
739
|
+
</Button>
|
|
740
|
+
<Button
|
|
741
|
+
variant="outline"
|
|
742
|
+
className="w-full"
|
|
743
|
+
onClick={sendInvoice}
|
|
744
|
+
disabled={working === "invoice"}
|
|
745
|
+
>
|
|
746
|
+
{working === "invoice" ? (
|
|
747
|
+
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
|
748
|
+
) : (
|
|
749
|
+
<Mail className="mr-1.5 h-4 w-4" />
|
|
750
|
+
)}
|
|
751
|
+
Send order details to customer
|
|
752
|
+
</Button>
|
|
753
|
+
<Separator />
|
|
754
|
+
<Button
|
|
755
|
+
variant="ghost"
|
|
756
|
+
className="w-full text-destructive hover:text-destructive"
|
|
757
|
+
onClick={() => setDeleteOpen(true)}
|
|
758
|
+
>
|
|
759
|
+
<Trash2 className="mr-1.5 h-4 w-4" /> Delete order
|
|
760
|
+
</Button>
|
|
761
|
+
</CardContent>
|
|
762
|
+
</Card>
|
|
763
|
+
|
|
764
|
+
<PaymentPanel order={o} onChanged={refetchAll} />
|
|
765
|
+
|
|
766
|
+
{o.customer_note && (
|
|
767
|
+
<Card>
|
|
768
|
+
<CardHeader className="pb-3">
|
|
769
|
+
<CardTitle className="text-base">Customer provided note</CardTitle>
|
|
770
|
+
</CardHeader>
|
|
771
|
+
<CardContent>
|
|
772
|
+
<p className="whitespace-pre-wrap rounded-md border border-blue-200 bg-blue-50 p-2.5 text-sm">
|
|
773
|
+
{o.customer_note}
|
|
774
|
+
</p>
|
|
775
|
+
</CardContent>
|
|
776
|
+
</Card>
|
|
777
|
+
)}
|
|
778
|
+
|
|
779
|
+
<OrderNotesPanel
|
|
780
|
+
orderId={id}
|
|
781
|
+
notes={notes.data || []}
|
|
782
|
+
loading={notes.loading}
|
|
783
|
+
onChanged={() => notes.refetch()}
|
|
784
|
+
/>
|
|
785
|
+
</div>
|
|
786
|
+
</div>
|
|
787
|
+
|
|
788
|
+
{/* ── Dialogs ───────────────────────────────────────────────────── */}
|
|
789
|
+
<AddProductDialog open={addProductOpen} onOpenChange={setAddProductOpen} onAdd={addProductLine} />
|
|
790
|
+
|
|
791
|
+
<NameAmountDialog
|
|
792
|
+
open={feeDialog}
|
|
793
|
+
onOpenChange={setFeeDialog}
|
|
794
|
+
title="Add fee"
|
|
795
|
+
nameLabel="Fee name"
|
|
796
|
+
onSubmit={({ name, amount }) =>
|
|
797
|
+
patchDraft({
|
|
798
|
+
fee_lines: [
|
|
799
|
+
...(draft.fee_lines || []),
|
|
800
|
+
{ line_id: uid(), name, total: amount, total_tax: 0, tax_status: "taxable", tax_class: "standard" },
|
|
801
|
+
],
|
|
802
|
+
})
|
|
803
|
+
}
|
|
804
|
+
/>
|
|
805
|
+
|
|
806
|
+
<NameAmountDialog
|
|
807
|
+
open={shippingDialog}
|
|
808
|
+
onOpenChange={setShippingDialog}
|
|
809
|
+
title="Add shipping"
|
|
810
|
+
nameLabel="Method title"
|
|
811
|
+
onSubmit={({ name, amount }) =>
|
|
812
|
+
patchDraft({
|
|
813
|
+
shipping_lines: [
|
|
814
|
+
...(draft.shipping_lines || []),
|
|
815
|
+
{ line_id: uid(), method_id: "flat_rate", instance_id: "", method_title: name, total: amount, total_tax: 0, taxes: [] },
|
|
816
|
+
],
|
|
817
|
+
})
|
|
818
|
+
}
|
|
819
|
+
/>
|
|
820
|
+
|
|
821
|
+
<CouponDialog
|
|
822
|
+
open={couponDialog}
|
|
823
|
+
onOpenChange={setCouponDialog}
|
|
824
|
+
onSubmit={(code) =>
|
|
825
|
+
serverOp(
|
|
826
|
+
"coupon",
|
|
827
|
+
async () => {
|
|
828
|
+
await call("admin-orders", "apply-coupon", { order_id: id, code });
|
|
829
|
+
toast.success(`Coupon ${code} applied`);
|
|
830
|
+
},
|
|
831
|
+
{ flush: true }
|
|
832
|
+
)
|
|
833
|
+
}
|
|
834
|
+
/>
|
|
835
|
+
|
|
836
|
+
<ConfirmDialog
|
|
837
|
+
open={deleteOpen}
|
|
838
|
+
onOpenChange={setDeleteOpen}
|
|
839
|
+
title={`Delete order #${o.order_number || o.id}?`}
|
|
840
|
+
description="Only pending, cancelled or failed orders can be deleted. This cannot be undone."
|
|
841
|
+
confirmLabel="Delete order"
|
|
842
|
+
loading={working === "delete"}
|
|
843
|
+
onConfirm={deleteOrder}
|
|
844
|
+
/>
|
|
845
|
+
</div>
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/* ── Small local pieces ─────────────────────────────────────────────── */
|
|
850
|
+
|
|
851
|
+
function AddressDisplay({ address = {} }) {
|
|
852
|
+
const rows = [
|
|
853
|
+
[address.first_name, address.last_name].filter(Boolean).join(" "),
|
|
854
|
+
address.company,
|
|
855
|
+
address.address_1,
|
|
856
|
+
address.address_2,
|
|
857
|
+
[address.city, address.state, address.postcode].filter(Boolean).join(", "),
|
|
858
|
+
address.country,
|
|
859
|
+
address.email,
|
|
860
|
+
address.phone,
|
|
861
|
+
].filter(Boolean);
|
|
862
|
+
if (rows.length === 0) return <p className="text-sm text-muted-foreground">No address set.</p>;
|
|
863
|
+
return (
|
|
864
|
+
<address className="text-sm not-italic leading-6 text-muted-foreground">
|
|
865
|
+
{rows.map((r, i) => (
|
|
866
|
+
<div key={i}>{r}</div>
|
|
867
|
+
))}
|
|
868
|
+
</address>
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function NameAmountDialog({ open, onOpenChange, title, nameLabel, onSubmit }) {
|
|
873
|
+
const [name, setName] = useState("");
|
|
874
|
+
const [amount, setAmount] = useState(0);
|
|
875
|
+
|
|
876
|
+
useEffect(() => {
|
|
877
|
+
if (!open) {
|
|
878
|
+
setName("");
|
|
879
|
+
setAmount(0);
|
|
880
|
+
}
|
|
881
|
+
}, [open]);
|
|
882
|
+
|
|
883
|
+
return (
|
|
884
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
885
|
+
<DialogContent className="sm:max-w-sm">
|
|
886
|
+
<DialogHeader>
|
|
887
|
+
<DialogTitle>{title}</DialogTitle>
|
|
888
|
+
</DialogHeader>
|
|
889
|
+
<div className="space-y-3">
|
|
890
|
+
<div className="grid gap-1.5">
|
|
891
|
+
<Label>{nameLabel}</Label>
|
|
892
|
+
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
|
893
|
+
</div>
|
|
894
|
+
<div className="grid gap-1.5">
|
|
895
|
+
<Label>Amount</Label>
|
|
896
|
+
<Input type="number" step="any" value={amount} onChange={(e) => setAmount(Number(e.target.value) || 0)} />
|
|
897
|
+
</div>
|
|
898
|
+
</div>
|
|
899
|
+
<DialogFooter>
|
|
900
|
+
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
901
|
+
Cancel
|
|
902
|
+
</Button>
|
|
903
|
+
<Button
|
|
904
|
+
disabled={!name.trim()}
|
|
905
|
+
onClick={() => {
|
|
906
|
+
onSubmit({ name: name.trim(), amount });
|
|
907
|
+
onOpenChange(false);
|
|
908
|
+
}}
|
|
909
|
+
>
|
|
910
|
+
Add
|
|
911
|
+
</Button>
|
|
912
|
+
</DialogFooter>
|
|
913
|
+
</DialogContent>
|
|
914
|
+
</Dialog>
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function CouponDialog({ open, onOpenChange, onSubmit }) {
|
|
919
|
+
const [code, setCode] = useState("");
|
|
920
|
+
|
|
921
|
+
useEffect(() => {
|
|
922
|
+
if (!open) setCode("");
|
|
923
|
+
}, [open]);
|
|
924
|
+
|
|
925
|
+
return (
|
|
926
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
927
|
+
<DialogContent className="sm:max-w-sm">
|
|
928
|
+
<DialogHeader>
|
|
929
|
+
<DialogTitle>Apply coupon</DialogTitle>
|
|
930
|
+
</DialogHeader>
|
|
931
|
+
<div className="grid gap-1.5">
|
|
932
|
+
<Label>Coupon code</Label>
|
|
933
|
+
<Input value={code} onChange={(e) => setCode(e.target.value)} placeholder="e.g. welcome10" />
|
|
934
|
+
</div>
|
|
935
|
+
<DialogFooter>
|
|
936
|
+
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
937
|
+
Cancel
|
|
938
|
+
</Button>
|
|
939
|
+
<Button
|
|
940
|
+
disabled={!code.trim()}
|
|
941
|
+
onClick={() => {
|
|
942
|
+
onSubmit(code.trim().toLowerCase());
|
|
943
|
+
onOpenChange(false);
|
|
944
|
+
}}
|
|
945
|
+
>
|
|
946
|
+
Apply
|
|
947
|
+
</Button>
|
|
948
|
+
</DialogFooter>
|
|
949
|
+
</DialogContent>
|
|
950
|
+
</Dialog>
|
|
951
|
+
);
|
|
952
|
+
}
|