@mohasinac/appkit 4.61.0 → 4.62.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/dist/.build-stamp.json +3 -3
- package/dist/_internal/client/features/lottery/LotteryAdminEditView.js +19 -3
- package/dist/_internal/client/features/maintenance/views/AnalysisRunnerView.js +1 -1
- package/dist/_internal/client/features/maintenance/views/CloudLogsListView.js +1 -1
- package/dist/_internal/server/jobs/core/paymentReconcile.js +87 -97
- package/dist/_internal/shared/actions/action-registry.js +149 -9
- package/dist/_internal/shared/media/limits.d.ts +10 -0
- package/dist/_internal/shared/media/limits.js +10 -0
- package/dist/_internal/shared/types/action-result.js +15 -1
- package/dist/client/api/surface-error.js +16 -2
- package/dist/client/api/useApiMutation.js +17 -1
- package/dist/client.d.ts +1 -1
- package/dist/client.js +1 -1
- package/dist/constants/api-endpoints.d.ts +3 -0
- package/dist/constants/api-endpoints.js +1 -0
- package/dist/constants/field-names.d.ts +17 -0
- package/dist/constants/field-names.js +23 -0
- package/dist/core/server-action.js +19 -3
- package/dist/errors/error-mapping.js +26 -1
- package/dist/features/account/components/UserOffersPanel.js +5 -3
- package/dist/features/admin/components/AdminActionIndexView.js +100 -5
- package/dist/features/admin/components/AdminAddressClustersView.js +21 -2
- package/dist/features/admin/components/AdminBlogEditorView.js +8 -8
- package/dist/features/admin/components/AdminCarouselEditorView.js +1 -1
- package/dist/features/admin/components/AdminCarouselView.js +96 -4
- package/dist/features/admin/components/AdminFulfillmentView.js +3 -2
- package/dist/features/admin/components/AdminMediaView.js +36 -3
- package/dist/features/admin/components/AdminNavigationView.js +4 -1
- package/dist/features/admin/components/AdminOffersView.js +2 -1
- package/dist/features/admin/components/AdminPaymentClustersView.js +19 -2
- package/dist/features/admin/components/AdminProductEditorView.js +2 -2
- package/dist/features/admin/components/AdminSectionsView.d.ts +10 -0
- package/dist/features/admin/components/AdminSectionsView.js +486 -224
- package/dist/features/admin/components/ReferenceRecordEditor.js +1 -1
- package/dist/features/admin/hooks/useOptimisticRowFlag.d.ts +33 -0
- package/dist/features/admin/hooks/useOptimisticRowFlag.js +87 -0
- package/dist/features/admin/repository/notification.repository.d.ts +15 -0
- package/dist/features/admin/repository/notification.repository.js +32 -0
- package/dist/features/admin/schemas/action-index-form.d.ts +34 -0
- package/dist/features/admin/schemas/action-index-form.js +115 -0
- package/dist/features/auth/components/RegisterForm.js +2 -1
- package/dist/features/catalogue/components/CatalogueItemEditorView.js +3 -3
- package/dist/features/events/components/AdminEventEditorView.js +8 -6
- package/dist/features/layout/hooks/useFormBottomActions.js +20 -1
- package/dist/features/orders/components/EmiInstallmentsPanel.js +25 -7
- package/dist/features/orders/components/RefundRequestView.js +4 -4
- package/dist/features/orders/schemas/index.d.ts +6 -6
- package/dist/features/products/components/GroupMemberPicker.js +4 -1
- package/dist/features/products/components/PrizeDrawEntryActions.js +2 -1
- package/dist/features/products/components/ProductDetailActions.js +2 -1
- package/dist/features/seller/components/FulfillmentView.js +3 -2
- package/dist/features/seller/components/SellerAddressesView.js +32 -4
- package/dist/features/seller/components/SellerAnalyticsAlertsView.js +62 -3
- package/dist/features/seller/components/SellerBundlesView.js +53 -14
- package/dist/features/seller/components/SellerGroupedListingsView.js +23 -3
- package/dist/features/seller/components/SellerListingTypeView.js +56 -10
- package/dist/features/seller/components/SellerPayoutMethodsView.js +33 -1
- package/dist/features/seller/components/SellerPayoutSettingsView.js +48 -9
- package/dist/features/seller/components/SellerProductsView.js +5 -1
- package/dist/features/seller/components/SellerShippingConfigsView.js +33 -1
- package/dist/features/seller/components/SellerShippingView.js +2 -1
- package/dist/features/seller/components/SellerStoreCategoriesView.js +33 -1
- package/dist/features/seller/components/SellerSupportView.js +69 -2
- package/dist/features/shell/SectionForm.js +20 -16
- package/dist/features/shell/build-sections.js +14 -1
- package/dist/features/shipments/components/AdminShipmentLotItemsView.js +54 -1
- package/dist/features/shipments/components/AdminShipmentProjectionsView.js +3 -2
- package/dist/features/sticker-trace/components/StickerCommitPanel.js +2 -1
- package/dist/features/sticker-trace/components/StickerPrintPanel.js +2 -1
- package/dist/features/sticker-trace/components/StickerRetryPanel.js +24 -4
- package/dist/features/sticker-trace/components/StickerTraceStudio.js +2 -1
- package/dist/features/tester/components/AdminTesterFeedbackIssuesView.js +3 -2
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -1
- package/dist/next/routing/route-map.d.ts +4 -0
- package/dist/next/routing/route-map.js +9 -0
- package/dist/schemas/registry.d.ts +4 -4
- package/dist/schemas/sieve.d.ts +3 -3
- package/dist/server.d.ts +1 -1
- package/dist/server.js +1 -1
- package/dist/ui/components/Checkbox.js +10 -2
- package/dist/ui/components/DateInput.js +8 -1
- package/dist/ui/components/Input.js +22 -2
- package/dist/ui/components/PaginatedSelect.d.ts +8 -19
- package/dist/ui/components/PaginatedSelect.js +43 -9
- package/dist/ui/components/Radio.js +8 -1
- package/dist/ui/components/Select.js +10 -2
- package/dist/ui/components/TagInput.d.ts +8 -1
- package/dist/ui/components/TagInput.js +4 -3
- package/dist/ui/components/Textarea.js +10 -2
- package/dist/ui/forms/FieldCheckbox.js +1 -1
- package/dist/ui/forms/FormErrorList.d.ts +0 -16
- package/dist/ui/forms/FormErrorList.js +34 -1
- package/dist/ui/forms/apply-zod-issues.d.ts +19 -1
- package/dist/ui/forms/apply-zod-issues.js +23 -1
- package/package.json +1 -1
|
@@ -8,6 +8,8 @@ import { Badge, ConfirmDeleteModal, Div, Row, RowActionMenu, Span, Text, useToas
|
|
|
8
8
|
import { SELLER_ENDPOINTS } from "../../../constants/api-endpoints.js";
|
|
9
9
|
import { ROUTES } from "../../../constants/index.js";
|
|
10
10
|
import { toRecordArray, toStringValue } from "../hooks/useSellerListingData.js";
|
|
11
|
+
import { useOptimisticRowFlag } from "../../admin/hooks/useOptimisticRowFlag.js";
|
|
12
|
+
import { apiClient } from "../../../http/index.js";
|
|
11
13
|
import { DataListingView } from "../../admin/components/DataListingView.js";
|
|
12
14
|
const CLS_TYPE_PILL = "inline-flex items-center rounded-full px-[var(--appkit-space-2)] py-[var(--appkit-space-0-5)] text-[length:var(--appkit-text-xs)] font-semibold uppercase tracking-wide bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300";
|
|
13
15
|
const DEFAULT_SORT = "-createdAt";
|
|
@@ -42,6 +44,22 @@ const COLUMNS = [
|
|
|
42
44
|
},
|
|
43
45
|
];
|
|
44
46
|
export function SellerPayoutMethodsView({ onCreateClick, onEditClick, onDelete, onSetDefault, }) {
|
|
47
|
+
/*
|
|
48
|
+
* 🛑 `isActive` was a READ-ONLY badge here, and the update schema was built
|
|
49
|
+
* to take it on its own — shipping-config's even says so in a comment: "a
|
|
50
|
+
* PATCH may legitimately carry only `isActive`". Nothing in the client had
|
|
51
|
+
* ever sent one, so flipping one boolean meant a full-editor round trip.
|
|
52
|
+
*
|
|
53
|
+
* The optimistic/rollback logic lives in the hook because eight listings
|
|
54
|
+
* grew this control at once; see its header for why rollback must DELETE
|
|
55
|
+
* the override key rather than write back a captured value.
|
|
56
|
+
*/
|
|
57
|
+
const activeFlag = useOptimisticRowFlag({
|
|
58
|
+
mutate: (id, next) => apiClient.patch(SELLER_ENDPOINTS.PAYOUT_METHOD_BY_ID(id), { isActive: next }),
|
|
59
|
+
invalidate: [["seller", "payout-methods", "listing"]],
|
|
60
|
+
successMessage: (next) => (next ? "Payout method activated." : "Payout method deactivated."),
|
|
61
|
+
errorMessage: "Could not change that payout method's status.",
|
|
62
|
+
});
|
|
45
63
|
const [deleteTargetId, setDeleteTargetId] = useState(null);
|
|
46
64
|
const { deletingId, handleDelete: performDelete } = useEntityDelete({
|
|
47
65
|
endpoint: SELLER_ENDPOINTS.PAYOUT_METHOD_BY_ID,
|
|
@@ -114,13 +132,27 @@ export function SellerPayoutMethodsView({ onCreateClick, onEditClick, onDelete,
|
|
|
114
132
|
label: String(item.label ?? ""),
|
|
115
133
|
type: String(item.type ?? ""),
|
|
116
134
|
isDefault: Boolean(item.isDefault),
|
|
117
|
-
|
|
135
|
+
// Resolved through the pending override so the Status badge and the
|
|
136
|
+
// row toggle cannot disagree between the click and the refetch.
|
|
137
|
+
isActive: activeFlag.resolve(toStringValue(item.id, `pm-${index}`), Boolean(item.isActive)),
|
|
118
138
|
maskedIdentifier: getMaskedIdentifier(item),
|
|
119
139
|
})),
|
|
120
140
|
getTotal: (response, mappedRows) => (typeof response.total === "number" ? response.total : mappedRows.length),
|
|
121
141
|
buildFilters: () => undefined,
|
|
122
142
|
primaryAction: { label: "New Method", onClick: handleCreate },
|
|
123
143
|
renderRowActions: (row) => (_jsx(RowActionMenu, { actions: [
|
|
144
|
+
/*
|
|
145
|
+
* 🛑 `isActive` was a read-only badge and the update schema was
|
|
146
|
+
* built to accept it alone. A menu entry rather than a switch here,
|
|
147
|
+
* to match the other actions in this menu — the menu is already
|
|
148
|
+
* click-stopped in all three renderers.
|
|
149
|
+
*/
|
|
150
|
+
{
|
|
151
|
+
label: row.isActive ? "Deactivate" : "Activate",
|
|
152
|
+
separator: true,
|
|
153
|
+
disabled: activeFlag.pendingId === row.id,
|
|
154
|
+
onClick: () => { void activeFlag.set(row.id, !row.isActive); },
|
|
155
|
+
},
|
|
124
156
|
...(!row.isDefault
|
|
125
157
|
? [{ label: "Set as Default", disabled: settingDefaultId === row.id, onClick: () => handleSetDefault(row.id) }]
|
|
126
158
|
: []),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { Alert, Badge, Checkbox, Div, FormField, FormGroup, Heading, Label, Row, Span, Stack, Text, Toggle } from "../../../ui/index.js";
|
|
@@ -8,6 +8,7 @@ import { FormShellContext, useFormShellState, applyZodIssues, FormErrorSummary }
|
|
|
8
8
|
import { SectionForm, useSectionFormNav, annotate } from "../../shell/index.js";
|
|
9
9
|
import { SELLER_ENDPOINTS } from "../../../constants/api-endpoints.js";
|
|
10
10
|
import { normalizeError } from "../../../errors/normalize.js";
|
|
11
|
+
import { toUserMessage } from "../../../errors/error-display-map.js";
|
|
11
12
|
const __P = {
|
|
12
13
|
p3: "p-[var(--appkit-space-3)]",
|
|
13
14
|
};
|
|
@@ -153,6 +154,20 @@ const DEFAULT_DRAFT = {
|
|
|
153
154
|
*/
|
|
154
155
|
advancedListingTypesEnabled: false,
|
|
155
156
|
};
|
|
157
|
+
/**
|
|
158
|
+
* One radio group for a single-value field, with ONE error message.
|
|
159
|
+
*
|
|
160
|
+
* Extracted because the payout-method and account-type groups were the same
|
|
161
|
+
* eleven lines twice, and because adding the error handling to both tipped
|
|
162
|
+
* this view past `audit-code-quality`'s component-size threshold. The error
|
|
163
|
+
* placement is the part worth sharing: `aria-invalid` goes on every radio so
|
|
164
|
+
* assistive tech marks the whole group, but the message renders ONCE
|
|
165
|
+
* underneath — the field is one value, so an `error` per radio would print
|
|
166
|
+
* and announce the same sentence for every option.
|
|
167
|
+
*/
|
|
168
|
+
function RadioFieldGroup({ name, value, options, error, disabled, onSelect, renderOption, layout: Layout, }) {
|
|
169
|
+
return (_jsxs(_Fragment, { children: [_jsx(Layout, { gap: "sm", children: options.map((option) => (_jsxs(Label, { layout: "flex", gap: "md", className: "cursor-pointer", children: [_jsx(Checkbox, { bare: true, type: "radio", name: name, value: option, checked: value === option, onChange: () => onSelect(option), className: "accent-[var(--appkit-color-primary)]", disabled: disabled, "aria-invalid": error ? "true" : undefined }), renderOption(option)] }, option))) }), error && (_jsx(Text, { size: "sm", variant: "error", role: "alert", children: error }))] }));
|
|
170
|
+
}
|
|
156
171
|
export function SellerPayoutSettingsView({ apiBase = SELLER_ENDPOINTS.PAYOUT_SETTINGS }) {
|
|
157
172
|
const [draft, setDraft] = useState(DEFAULT_DRAFT);
|
|
158
173
|
const [current, setCurrent] = useState(null);
|
|
@@ -253,7 +268,22 @@ export function SellerPayoutSettingsView({ apiBase = SELLER_ENDPOINTS.PAYOUT_SET
|
|
|
253
268
|
if (Array.isArray(json?.issues) && json.issues.length > 0) {
|
|
254
269
|
applyZodIssues(json.issues, setFieldError);
|
|
255
270
|
}
|
|
256
|
-
|
|
271
|
+
/*
|
|
272
|
+
* `json.error` is the SERVER's sentence — never shown to a user
|
|
273
|
+
* (Rule #9.6). Resolve the stable `code` instead; `toUserMessage`
|
|
274
|
+
* terminates in this fallback and can never return server text.
|
|
275
|
+
*
|
|
276
|
+
* Only a banner when the issues above attached nothing: if they did,
|
|
277
|
+
* the fields now carry the detail and a banner repeating a vaguer
|
|
278
|
+
* version of it is noise.
|
|
279
|
+
*/
|
|
280
|
+
const attached = Array.isArray(json?.issues) &&
|
|
281
|
+
json.issues.some((i) => i?.path?.length);
|
|
282
|
+
if (!attached) {
|
|
283
|
+
setError(toUserMessage(json?.code, undefined, {
|
|
284
|
+
fallback: "We couldn't save your payout settings.",
|
|
285
|
+
}));
|
|
286
|
+
}
|
|
257
287
|
return;
|
|
258
288
|
}
|
|
259
289
|
clearErrors();
|
|
@@ -263,8 +293,17 @@ export function SellerPayoutSettingsView({ apiBase = SELLER_ENDPOINTS.PAYOUT_SET
|
|
|
263
293
|
setDraft((prev) => ({ ...prev, accountNumber: "" }));
|
|
264
294
|
}
|
|
265
295
|
catch (err) {
|
|
266
|
-
|
|
267
|
-
|
|
296
|
+
/*
|
|
297
|
+
* `normalizeError` was already being called and its result DISCARDED,
|
|
298
|
+
* then the raw `.message` shown anyway — a thrown value's message is
|
|
299
|
+
* written for a developer, and this is the path a network failure or a
|
|
300
|
+
* bundler fault takes (Root Cause #86 rendered a require stack this
|
|
301
|
+
* way). Use the normalized code; the sentence is ours.
|
|
302
|
+
*/
|
|
303
|
+
const normalized = normalizeError(err);
|
|
304
|
+
setError(toUserMessage(normalized.code, undefined, {
|
|
305
|
+
fallback: "We couldn't save your payout settings. Please try again.",
|
|
306
|
+
}));
|
|
268
307
|
}
|
|
269
308
|
finally {
|
|
270
309
|
setSaving(false);
|
|
@@ -283,12 +322,12 @@ export function SellerPayoutSettingsView({ apiBase = SELLER_ENDPOINTS.PAYOUT_SET
|
|
|
283
322
|
required: true,
|
|
284
323
|
quick: true,
|
|
285
324
|
fields: ["method", "upiId", "accountHolderName", "accountNumber", "ifscCode", "bankName", "accountType"],
|
|
286
|
-
render: ({ values, onChange }) => (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Payout Method" }), current && (_jsx(Row, { className: "mb-2", align: "center", gap: "sm", children: _jsx(Badge, { variant: current.isConfigured ? "success" : "warning", children: current.isConfigured ? "Payout configured" : "Not configured" }) })), current?.isConfigured && (_jsx(Alert, { variant: "info", children: current.method === "upi"
|
|
325
|
+
render: ({ values, onChange, errors }) => (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Payout Method" }), current && (_jsx(Row, { className: "mb-2", align: "center", gap: "sm", children: _jsx(Badge, { variant: current.isConfigured ? "success" : "warning", children: current.isConfigured ? "Payout configured" : "Not configured" }) })), current?.isConfigured && (_jsx(Alert, { variant: "info", children: current.method === "upi"
|
|
287
326
|
? `Current UPI: ${current.upiId}`
|
|
288
|
-
: `Current bank: ${current.bankAccount?.bankName ?? ""} — ••••${current.bankAccount?.accountNumberMasked?.slice(-4) ?? "••••"} (${current.bankAccount?.accountType})` })),
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
327
|
+
: `Current bank: ${current.bankAccount?.bankName ?? ""} — ••••${current.bankAccount?.accountNumberMasked?.slice(-4) ?? "••••"} (${current.bankAccount?.accountType})` })), _jsxs(Stack, { gap: "sm", children: [([
|
|
328
|
+
{ value: "upi", label: "UPI", desc: "Instant payouts via UPI VPA (e.g. name@upi)." },
|
|
329
|
+
{ value: "bank_transfer", label: "Bank Transfer", desc: "NEFT/RTGS to your bank account within 2–3 business days." },
|
|
330
|
+
]).map(({ value, label, desc }) => (_jsxs("label", { className: "flex items-center gap-[var(--appkit-space-3)] p-[var(--appkit-space-3)] rounded-lg border border-[var(--appkit-color-border)] cursor-pointer has-[:checked]:border-[var(--appkit-color-primary)] has-[:checked]:bg-[var(--appkit-color-primary)]/5", children: [_jsx(Checkbox, { bare: true, type: "radio", name: "payoutMethod", value: value, checked: values.method === value, onChange: () => onChange({ method: value }), className: "accent-[var(--appkit-color-primary)]", disabled: busy, "aria-invalid": errors.method ? "true" : undefined }), _jsxs(Div, { children: [_jsx(Text, { weight: "medium", children: label }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: desc })] })] }, value))), errors.method && (_jsx(Text, { size: "sm", variant: "error", role: "alert", children: errors.method }))] }), values.method === "upi" && (_jsx(FormField, { name: "upiId", label: "UPI ID (VPA)", type: "text", value: values.upiId, onChange: (v) => onChange({ upiId: v }), placeholder: "yourname@upi", helpText: "Ensure this VPA is registered and active.", disabled: busy })), values.method === "bank_transfer" && (_jsxs(Stack, { gap: "md", className: "mt-2", children: [_jsx(FormField, { name: "accountHolderName", label: "Account Holder Name", type: "text", value: values.accountHolderName, onChange: (v) => onChange({ accountHolderName: v }), placeholder: "Name as on bank account", disabled: busy }), _jsxs(FormGroup, { columns: 2, children: [_jsx(FormField, { name: "accountNumber", label: "Account Number", type: "text", value: values.accountNumber, onChange: (v) => onChange({ accountNumber: v }), placeholder: "Enter full account number", helpText: current?.bankAccount ? `Saved: ••••${current.bankAccount.accountNumberMasked?.slice(-4) ?? "••••"}` : "Stored securely, never displayed in full.", disabled: busy }), _jsx(FormField, { name: "ifscCode", label: "IFSC Code", type: "text", value: values.ifscCode, onChange: (v) => onChange({ ifscCode: v.toUpperCase() }), placeholder: "e.g. SBIN0001234", disabled: busy })] }), _jsxs(FormGroup, { columns: 2, children: [_jsx(FormField, { name: "bankName", label: "Bank Name", type: "text", value: values.bankName, onChange: (v) => onChange({ bankName: v }), placeholder: "e.g. State Bank of India", disabled: busy }), _jsxs(Div, { children: [_jsx(Text, { className: "mb-1.5", size: "sm", weight: "medium", children: "Account Type" }), _jsx(RadioFieldGroup, { name: "accountType", value: values.accountType, options: ["savings", "current"], error: errors.accountType, disabled: busy, onSelect: (t) => onChange({ accountType: t }), layout: Row, renderOption: (t) => (_jsx(Text, { size: "sm", transform: "capitalize", children: t })) })] })] })] }))] })),
|
|
292
331
|
},
|
|
293
332
|
{
|
|
294
333
|
id: "tax",
|
|
@@ -641,7 +641,11 @@ export function SellerProductsView({ onDeleteProduct, onCreateClick, }) {
|
|
|
641
641
|
: `/products/${row.id}`,
|
|
642
642
|
renderRowActions: (row) => {
|
|
643
643
|
const isPublished = (statusOverrides.get(row.id) ?? row.status) === "published";
|
|
644
|
-
return (_jsxs(Row, { gap: "xs", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: (e) => { e.stopPropagation(); handleEdit(row); }, "aria-label": ACTIONS.STORE["edit-listing"].ariaLabel, children: _jsx(Pencil, { className: "w-4 h-4" }) }), _jsx(Button, { variant: "ghost", size: "sm", onClick: (e) => { e.stopPropagation(); void openAssembly(row, "assemble"); }, "aria-label": ACTIONS.STORE["assemble-from-parts"].ariaLabel, title: ACTIONS.STORE["assemble-from-parts"].label, disabled: assembleLoading, children: _jsx(Wrench, { className: "w-4 h-4" }) }), _jsx(Button, { variant: "ghost", size: "sm", onClick: (e) => { e.stopPropagation(); void openAssembly(row, "part-out"); }, "aria-label": ACTIONS.STORE["part-out-listing"].ariaLabel, title: ACTIONS.STORE["part-out-listing"].label, disabled: assembleLoading, children: _jsx(Scissors, { className: "w-4 h-4" }) }), _jsx(Button, { variant: "ghost", size: "sm", onClick: (e) => { e.stopPropagation(); void handleTogglePublish(row); }, "aria-label": isPublished
|
|
644
|
+
return (_jsxs(Row, { gap: "xs", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: (e) => { e.stopPropagation(); handleEdit(row); }, "aria-label": ACTIONS.STORE["edit-listing"].ariaLabel, children: _jsx(Pencil, { className: "w-4 h-4" }) }), _jsx(Button, { variant: "ghost", size: "sm", onClick: (e) => { e.stopPropagation(); void openAssembly(row, "assemble"); }, "aria-label": ACTIONS.STORE["assemble-from-parts"].ariaLabel, title: ACTIONS.STORE["assemble-from-parts"].label, disabled: assembleLoading, children: _jsx(Wrench, { className: "w-4 h-4" }) }), _jsx(Button, { variant: "ghost", size: "sm", onClick: (e) => { e.stopPropagation(); void openAssembly(row, "part-out"); }, "aria-label": ACTIONS.STORE["part-out-listing"].ariaLabel, title: ACTIONS.STORE["part-out-listing"].label, disabled: assembleLoading, children: _jsx(Scissors, { className: "w-4 h-4" }) }), _jsx(Button, { variant: "ghost", size: "sm", onClick: (e) => { e.stopPropagation(); void handleTogglePublish(row); }, "aria-label": isPublished
|
|
645
|
+
? ACTIONS.STORE["unpublish-listing"].label
|
|
646
|
+
: ACTIONS.STORE["publish-listing"].label, title: isPublished
|
|
647
|
+
? ACTIONS.STORE["unpublish-listing"].description
|
|
648
|
+
: ACTIONS.STORE["publish-listing"].description, disabled: publishingId === row.id, children: isPublished ? _jsx(EyeOff, { className: "w-4 h-4" }) : _jsx(Eye, { className: "w-4 h-4" }) }), _jsx(Button, { variant: "ghost", size: "sm", onClick: (e) => { e.stopPropagation(); void handleDuplicate(row); }, "aria-label": "Duplicate listing", title: "Duplicate", children: "\u29C9" }), onDeleteProduct && (_jsx(Button, { variant: "ghost", size: "sm", action: ACTIONS.STORE["delete-listing"], onClick: (e) => { e.stopPropagation(); void handleDelete(row); }, disabled: deletingId === row.id, className: "text-[var(--appkit-color-error)] hover:bg-[var(--appkit-color-border-subtle)]", children: _jsx(Trash2, { className: "w-4 h-4" }) }))] }));
|
|
645
649
|
},
|
|
646
650
|
};
|
|
647
651
|
return (_jsxs(_Fragment, { children: [_jsx(DataListingView, { config: config }), setLocationOpen && (_jsx(PhysicalLocationModal, { count: selectedIdsForLocation.length, onSave: (loc) => handleSetLocation(loc, selectedIdsForLocation), onClose: () => setSetLocationOpen(false) })), setStockOpen && (_jsx(BulkStockModal, { count: selectedIdsForStock.length, busy: stockBusy, onSave: (qty) => handleSetStock(qty, selectedIdsForStock), onClose: () => setSetStockOpen(false) })), assembleFor && assembleData && (_jsx(AssemblyDrawer, { isOpen: true,
|
|
@@ -10,6 +10,8 @@ import { SELLER_ENDPOINTS } from "../../../constants/api-endpoints.js";
|
|
|
10
10
|
import { ACTIONS } from "../../../_internal/shared/actions/action-registry.js";
|
|
11
11
|
import { ROUTES } from "../../../constants/index.js";
|
|
12
12
|
import { toRecordArray, toStringValue } from "../hooks/useSellerListingData.js";
|
|
13
|
+
import { useOptimisticRowFlag } from "../../admin/hooks/useOptimisticRowFlag.js";
|
|
14
|
+
import { apiClient } from "../../../http/index.js";
|
|
13
15
|
import { DataListingView } from "../../admin/components/DataListingView.js";
|
|
14
16
|
const DEFAULT_SORT = "-createdAt";
|
|
15
17
|
const SORT_OPTIONS = [
|
|
@@ -40,6 +42,22 @@ const COLUMNS = [
|
|
|
40
42
|
},
|
|
41
43
|
];
|
|
42
44
|
export function SellerShippingConfigsView({ onCreateClick, onEditClick, onDelete, onSetDefault, }) {
|
|
45
|
+
/*
|
|
46
|
+
* 🛑 `isActive` was a READ-ONLY badge here, and the update schema was built
|
|
47
|
+
* to take it on its own — shipping-config's even says so in a comment: "a
|
|
48
|
+
* PATCH may legitimately carry only `isActive`". Nothing in the client had
|
|
49
|
+
* ever sent one, so flipping one boolean meant a full-editor round trip.
|
|
50
|
+
*
|
|
51
|
+
* The optimistic/rollback logic lives in the hook because eight listings
|
|
52
|
+
* grew this control at once; see its header for why rollback must DELETE
|
|
53
|
+
* the override key rather than write back a captured value.
|
|
54
|
+
*/
|
|
55
|
+
const activeFlag = useOptimisticRowFlag({
|
|
56
|
+
mutate: (id, next) => apiClient.patch(SELLER_ENDPOINTS.SHIPPING_CONFIG_BY_ID(id), { isActive: next }),
|
|
57
|
+
invalidate: [["seller", "shipping-configs", "listing"]],
|
|
58
|
+
successMessage: (next) => (next ? "Shipping option activated." : "Shipping option deactivated."),
|
|
59
|
+
errorMessage: "Could not change that shipping option's status.",
|
|
60
|
+
});
|
|
43
61
|
const [deleteTargetId, setDeleteTargetId] = useState(null);
|
|
44
62
|
const { deletingId, handleDelete: performDelete } = useEntityDelete({
|
|
45
63
|
endpoint: SELLER_ENDPOINTS.SHIPPING_CONFIG_BY_ID,
|
|
@@ -113,12 +131,26 @@ export function SellerShippingConfigsView({ onCreateClick, onEditClick, onDelete
|
|
|
113
131
|
method: String(item.method ?? ""),
|
|
114
132
|
estimatedDays: typeof item.estimatedDays === "number" ? item.estimatedDays : null,
|
|
115
133
|
isDefault: Boolean(item.isDefault),
|
|
116
|
-
|
|
134
|
+
// Resolved through the pending override so the Status badge and the
|
|
135
|
+
// row toggle cannot disagree between the click and the refetch.
|
|
136
|
+
isActive: activeFlag.resolve(toStringValue(item.id, `sc-${index}`), Boolean(item.isActive)),
|
|
117
137
|
})),
|
|
118
138
|
getTotal: (response, mappedRows) => (typeof response.total === "number" ? response.total : mappedRows.length),
|
|
119
139
|
buildFilters: () => undefined,
|
|
120
140
|
primaryAction: { label: "New Config", onClick: handleCreate },
|
|
121
141
|
renderRowActions: (row) => (_jsx(RowActionMenu, { actions: [
|
|
142
|
+
/*
|
|
143
|
+
* 🛑 `isActive` was a read-only badge and the update schema was
|
|
144
|
+
* built to accept it alone. A menu entry rather than a switch here,
|
|
145
|
+
* to match the other actions in this menu — the menu is already
|
|
146
|
+
* click-stopped in all three renderers.
|
|
147
|
+
*/
|
|
148
|
+
{
|
|
149
|
+
label: row.isActive ? "Deactivate" : "Activate",
|
|
150
|
+
separator: true,
|
|
151
|
+
disabled: activeFlag.pendingId === row.id,
|
|
152
|
+
onClick: () => { void activeFlag.set(row.id, !row.isActive); },
|
|
153
|
+
},
|
|
122
154
|
{ label: ACTIONS.STORE["edit-listing"].label, onClick: () => handleEdit(row.id) },
|
|
123
155
|
...(!row.isDefault
|
|
124
156
|
? [{ label: "Set as Default", disabled: settingDefaultId === row.id, onClick: () => handleSetDefault(row.id) }]
|
|
@@ -9,6 +9,7 @@ import { StoreAddressSelectorCreate } from "../../stores/components/StoreAddress
|
|
|
9
9
|
import { SectionForm, useSectionFormNav } from "../../shell/index.js";
|
|
10
10
|
import { SELLER_ENDPOINTS } from "../../../constants/api-endpoints.js";
|
|
11
11
|
import { normalizeError } from "../../../errors/normalize.js";
|
|
12
|
+
import { toUserMessage } from "../../../errors/error-display-map.js";
|
|
12
13
|
const shippingDraftSchema = z.object({
|
|
13
14
|
customCarrierName: z.string().min(1, "Carrier name is required"),
|
|
14
15
|
customShippingPrice: z.string(),
|
|
@@ -82,7 +83,7 @@ export function SellerShippingView({ apiBase = SELLER_ENDPOINTS.SHIPPING }) {
|
|
|
82
83
|
if (Array.isArray(json?.issues) && json.issues.length > 0) {
|
|
83
84
|
applyZodIssues(json.issues, setFieldError);
|
|
84
85
|
}
|
|
85
|
-
setError(json?.
|
|
86
|
+
setError(toUserMessage(json?.code, undefined, { fallback: "We couldn't save your shipping settings." }));
|
|
86
87
|
return;
|
|
87
88
|
}
|
|
88
89
|
clearErrors();
|
|
@@ -11,6 +11,8 @@ import { ACTIONS } from "../../../_internal/shared/actions/action-registry.js";
|
|
|
11
11
|
import { buildBulkAction } from "../../../_internal/shared/actions/bulk-helpers.js";
|
|
12
12
|
import { ROUTES } from "../../../next/routing/route-map.js";
|
|
13
13
|
import { toRecordArray, toStringValue } from "../hooks/useSellerListingData.js";
|
|
14
|
+
import { useOptimisticRowFlag } from "../../admin/hooks/useOptimisticRowFlag.js";
|
|
15
|
+
import { apiClient } from "../../../http/index.js";
|
|
14
16
|
import { DataListingView } from "../../admin/components/DataListingView.js";
|
|
15
17
|
const DEFAULT_SORT = "displayOrder";
|
|
16
18
|
const SORT_OPTIONS = [
|
|
@@ -37,6 +39,22 @@ const COLUMNS = [
|
|
|
37
39
|
},
|
|
38
40
|
];
|
|
39
41
|
export function SellerStoreCategoriesView({ onCreateClick, onEditClick, onDelete, onBulkDelete, onBulkActivate, onBulkDeactivate, }) {
|
|
42
|
+
/*
|
|
43
|
+
* 🛑 `isActive` was a READ-ONLY badge here, and the update schema was built
|
|
44
|
+
* to take it on its own — shipping-config's even says so in a comment: "a
|
|
45
|
+
* PATCH may legitimately carry only `isActive`". Nothing in the client had
|
|
46
|
+
* ever sent one, so flipping one boolean meant a full-editor round trip.
|
|
47
|
+
*
|
|
48
|
+
* The optimistic/rollback logic lives in the hook because eight listings
|
|
49
|
+
* grew this control at once; see its header for why rollback must DELETE
|
|
50
|
+
* the override key rather than write back a captured value.
|
|
51
|
+
*/
|
|
52
|
+
const activeFlag = useOptimisticRowFlag({
|
|
53
|
+
mutate: (id, next) => apiClient.patch(SELLER_ENDPOINTS.STORE_CATEGORY_BY_ID(id), { isActive: next }),
|
|
54
|
+
invalidate: [["seller", "store-categories", "listing"]],
|
|
55
|
+
successMessage: (next) => (next ? "Category activated." : "Category deactivated."),
|
|
56
|
+
errorMessage: "Could not change that category's status.",
|
|
57
|
+
});
|
|
40
58
|
const { showToast } = useToast();
|
|
41
59
|
const [deleteTargetId, setDeleteTargetId] = useState(null);
|
|
42
60
|
const { deletingId, handleDelete: performDelete } = useEntityDelete({
|
|
@@ -79,7 +97,9 @@ export function SellerStoreCategoriesView({ onCreateClick, onEditClick, onDelete
|
|
|
79
97
|
label: String(item.label ?? ""),
|
|
80
98
|
slug: String(item.slug ?? ""),
|
|
81
99
|
productCount: Number(item.productIds?.length ?? 0),
|
|
82
|
-
|
|
100
|
+
// Resolved through the pending override so the Status badge and the
|
|
101
|
+
// row toggle cannot disagree between the click and the refetch.
|
|
102
|
+
isActive: activeFlag.resolve(toStringValue(item.id, `cat-${index}`), Boolean(item.isActive)),
|
|
83
103
|
})),
|
|
84
104
|
getTotal: (response, rows) => (typeof response.total === "number" ? response.total : rows.length),
|
|
85
105
|
buildFilters: () => undefined,
|
|
@@ -93,6 +113,18 @@ export function SellerStoreCategoriesView({ onCreateClick, onEditClick, onDelete
|
|
|
93
113
|
return actions;
|
|
94
114
|
},
|
|
95
115
|
renderRowActions: (row) => (_jsx(RowActionMenu, { actions: [
|
|
116
|
+
/*
|
|
117
|
+
* 🛑 `isActive` was a read-only badge and the update schema was
|
|
118
|
+
* built to accept it alone. A menu entry rather than a switch here,
|
|
119
|
+
* to match the other actions in this menu — the menu is already
|
|
120
|
+
* click-stopped in all three renderers.
|
|
121
|
+
*/
|
|
122
|
+
{
|
|
123
|
+
label: row.isActive ? "Deactivate" : "Activate",
|
|
124
|
+
separator: true,
|
|
125
|
+
disabled: activeFlag.pendingId === row.id,
|
|
126
|
+
onClick: () => { void activeFlag.set(row.id, !row.isActive); },
|
|
127
|
+
},
|
|
96
128
|
{
|
|
97
129
|
label: ACTIONS.STORE["edit-listing"].label,
|
|
98
130
|
onClick: () => onEditClick
|
|
@@ -24,7 +24,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
24
24
|
* close a complaint made against them.
|
|
25
25
|
*/
|
|
26
26
|
import React from "react";
|
|
27
|
-
import {
|
|
27
|
+
import { useQuery } from "@tanstack/react-query";
|
|
28
|
+
import { Badge, RecordDetailModal, Span, Stack, Text } from "../../../ui/index.js";
|
|
29
|
+
import { apiClient } from "../../../http/index.js";
|
|
28
30
|
import { DataListingView } from "../../admin/components/DataListingView.js";
|
|
29
31
|
import { SELLER_ENDPOINTS } from "../../../constants/api-endpoints.js";
|
|
30
32
|
import { SUPPORT_TICKET_FIELDS } from "../../../constants/field-names.js";
|
|
@@ -38,7 +40,58 @@ const STATUS_VARIANT = {
|
|
|
38
40
|
resolved: "success",
|
|
39
41
|
closed: "default",
|
|
40
42
|
};
|
|
43
|
+
/**
|
|
44
|
+
* One ticket, read-only, in the shared detail modal.
|
|
45
|
+
*
|
|
46
|
+
* Its own component so the query only runs once a row has actually been
|
|
47
|
+
* opened — the list is the common case and nobody should pay a per-ticket
|
|
48
|
+
* fetch to scroll it.
|
|
49
|
+
*/
|
|
50
|
+
function SellerSupportTicketModal({ ticketId, onClose, }) {
|
|
51
|
+
const { data, isLoading } = useQuery({
|
|
52
|
+
queryKey: ["store", "support-ticket", ticketId],
|
|
53
|
+
queryFn: () => apiClient.get(SELLER_ENDPOINTS.SUPPORT_TICKET_BY_ID(ticketId)),
|
|
54
|
+
staleTime: 30000,
|
|
55
|
+
});
|
|
56
|
+
const ticket = data?.ticket;
|
|
57
|
+
const messages = Array.isArray(ticket?.messages)
|
|
58
|
+
? ticket.messages
|
|
59
|
+
: [];
|
|
60
|
+
return (_jsx(RecordDetailModal, { isOpen: true, onClose: onClose, title: toStringValue(ticket?.subject, isLoading ? "Loading…" : "Ticket"), badges: ticket
|
|
61
|
+
? [
|
|
62
|
+
{
|
|
63
|
+
label: toStringValue(ticket.status).replace(/_/g, " "),
|
|
64
|
+
variant: STATUS_VARIANT[toStringValue(ticket.status)] ?? "default",
|
|
65
|
+
},
|
|
66
|
+
{ label: toStringValue(ticket.category).replace(/_/g, " "), variant: "default" },
|
|
67
|
+
]
|
|
68
|
+
: undefined, description: toStringValue(ticket?.description), fields: ticket
|
|
69
|
+
? [
|
|
70
|
+
{ label: "Ticket", value: toStringValue(ticket.id) },
|
|
71
|
+
{ label: "Raised", value: toRelativeDate(ticket.createdAt) },
|
|
72
|
+
{ label: "Last update", value: toRelativeDate(ticket.updatedAt ?? ticket.createdAt) },
|
|
73
|
+
{ label: "Priority", value: toStringValue(ticket.priority, "—") },
|
|
74
|
+
]
|
|
75
|
+
: undefined, items: messages.length > 0
|
|
76
|
+
? {
|
|
77
|
+
heading: `Thread (${messages.length})`,
|
|
78
|
+
entries: messages.map((m) => ({
|
|
79
|
+
/*
|
|
80
|
+
* The author's ROLE, not their name. A seller reading a
|
|
81
|
+
* complaint made against them does not need the buyer's
|
|
82
|
+
* identity to follow the thread, and those fields are
|
|
83
|
+
* PII-encrypted at rest precisely so they are not handed out
|
|
84
|
+
* by default.
|
|
85
|
+
*/
|
|
86
|
+
title: m.authorRole === "admin" ? "Support team" : "Buyer",
|
|
87
|
+
subtitle: toStringValue(m.body),
|
|
88
|
+
trailing: toRelativeDate((m.createdAt ?? null)),
|
|
89
|
+
})),
|
|
90
|
+
}
|
|
91
|
+
: undefined }));
|
|
92
|
+
}
|
|
41
93
|
export function SellerSupportView({ labels = {} }) {
|
|
94
|
+
const [detailId, setDetailId] = React.useState(null);
|
|
42
95
|
const mapRows = React.useCallback((response) => {
|
|
43
96
|
const items = Array.isArray(response?.items) ? response.items : [];
|
|
44
97
|
return items.map((raw) => {
|
|
@@ -96,5 +149,19 @@ export function SellerSupportView({ labels = {} }) {
|
|
|
96
149
|
},
|
|
97
150
|
{ key: "updated", header: "Updated" },
|
|
98
151
|
],
|
|
99
|
-
|
|
152
|
+
/*
|
|
153
|
+
* 🛑 This list was a DEAD END, and unlike most of the ones fixed
|
|
154
|
+
* alongside it that was not a pure UI gap: a seller could read a
|
|
155
|
+
* subject and a 140-character slice of the description and never
|
|
156
|
+
* open the thread, under the footer below promising the thread is
|
|
157
|
+
* where replies happen. `/api/store/support` was GET-list-only with
|
|
158
|
+
* no `[id]` sibling at all, so the route had to be built first.
|
|
159
|
+
*
|
|
160
|
+
* A modal rather than a page: the seller cannot act on these — a
|
|
161
|
+
* ticket raised ABOUT a store is not one its owner can reply to or
|
|
162
|
+
* close — so there is nothing here a route would add, and this is
|
|
163
|
+
* the same `RecordDetailModal` the other dead ends were fixed with.
|
|
164
|
+
*/
|
|
165
|
+
onRowClick: (row) => setDetailId(row.id),
|
|
166
|
+
} }), detailId && (_jsx(SellerSupportTicketModal, { ticketId: detailId, onClose: () => setDetailId(null) })), _jsx(Text, { size: "xs", color: "muted", children: "Replies and status changes happen in the ticket thread with our support team \u2014 a ticket raised about your store is not one you can close." })] }));
|
|
100
167
|
}
|
|
@@ -325,6 +325,25 @@ export function SectionForm({ sections, values, onChange, onSubmit, submitLabel
|
|
|
325
325
|
}
|
|
326
326
|
void onSubmit();
|
|
327
327
|
}, [shellCtx, onSubmit, erroringSectionIds, fieldErrors, openIds, setOpenIds, expandMode]);
|
|
328
|
+
/**
|
|
329
|
+
* Open a section if it is closed, then scroll to it.
|
|
330
|
+
*
|
|
331
|
+
* Named rather than inlined in the rail's `onClick`: inline it sat five
|
|
332
|
+
* braces deep inside the JSX, and it is the same open-then-scroll the
|
|
333
|
+
* submit handler above performs for the first erroring section — so the
|
|
334
|
+
* two now read as the same operation rather than two similar ones.
|
|
335
|
+
*
|
|
336
|
+
* 🛑 Opening is CONDITIONAL on purpose. In `single` mode an unconditional
|
|
337
|
+
* `setOpenIds([id])` would COLLAPSE the section you are already in when you
|
|
338
|
+
* click its own rail entry, so the rail would read as having closed the
|
|
339
|
+
* form.
|
|
340
|
+
*/
|
|
341
|
+
const openAndScrollTo = useCallback((id) => {
|
|
342
|
+
if (!openIds.includes(id)) {
|
|
343
|
+
setOpenIds(expandMode === "single" ? [id] : [...openIds, id]);
|
|
344
|
+
}
|
|
345
|
+
scrollToSection(id);
|
|
346
|
+
}, [openIds, setOpenIds, expandMode]);
|
|
328
347
|
useFormBottomActions({
|
|
329
348
|
onSubmit: handleSubmit,
|
|
330
349
|
onCancel,
|
|
@@ -391,21 +410,6 @@ export function SectionForm({ sections, values, onChange, onSubmit, submitLabel
|
|
|
391
410
|
* sections").
|
|
392
411
|
*/
|
|
393
412
|
const showRail = ordered.length >= 3;
|
|
394
|
-
/**
|
|
395
|
-
* One rail click: open the section if it is not already, then scroll to it.
|
|
396
|
-
* Named rather than inline in the button's `onClick`, where the `if` body
|
|
397
|
-
* sat a brace too deep inside the mapped JSX.
|
|
398
|
-
*
|
|
399
|
-
* Opening is conditional on purpose — in `single` mode an unconditional
|
|
400
|
-
* `setOpenIds([id])` would COLLAPSE the section you are already in when you
|
|
401
|
-
* click its own rail entry, so the rail would look like it closed the form.
|
|
402
|
-
*/
|
|
403
|
-
const openAndScrollTo = (id) => {
|
|
404
|
-
if (!openIds.includes(id)) {
|
|
405
|
-
setOpenIds(expandMode === "single" ? [id] : [...openIds, id]);
|
|
406
|
-
}
|
|
407
|
-
scrollToSection(id);
|
|
408
|
-
};
|
|
409
413
|
/*
|
|
410
414
|
* `sticky` under the measured header, and it publishes NO height: per the
|
|
411
415
|
* three-tier rule a sticky element CONSUMES the tier and never joins it.
|
|
@@ -444,7 +448,7 @@ export function SectionForm({ sections, values, onChange, onSubmit, submitLabel
|
|
|
444
448
|
forceOpenIds.includes(section.id);
|
|
445
449
|
return (_jsxs(Button, { type: "button",
|
|
446
450
|
/* `ghost`, not `bare` — UI_BUTTON has no `bare` variant. */
|
|
447
|
-
variant: "ghost", size: "sm", justify: "between", gap: "sm", className: `w-full text-left ${isOpen ? "appkit-section-rail__item--active" : ""}`, "aria-current": isOpen ? "true" : undefined, onClick: () => openAndScrollTo(section.id), children: [_jsx(Span, { size: "sm", color: isOpen ? "primary" : "muted", children: section.label }), errorCount > 0 && (_jsx(Span, { size: "xs", color: "error", weight: "semibold", children: errorCount }))] }, section.id));
|
|
451
|
+
variant: "ghost", size: "sm", justify: "between", gap: "sm", className: `w-full text-left ${isOpen ? "appkit-section-rail__item--active" : ""}`, "aria-current": isOpen ? "true" : undefined, onClick: () => openAndScrollTo(section.id), children: [_jsx(Span, { size: "sm", color: isOpen ? "primary" : "muted", children: section.label }), submitAttempted && errorCount > 0 && (_jsx(Span, { size: "xs", color: "error", weight: "semibold", children: errorCount }))] }, section.id));
|
|
448
452
|
}) }) }) })) : null;
|
|
449
453
|
const panels = (_jsxs(Stack, { gap: "md", children: [ordered.map((section) => {
|
|
450
454
|
const isRequired = section.required === true;
|
|
@@ -288,7 +288,20 @@ function renderField(field, ctx, opts) {
|
|
|
288
288
|
* case that most needs searching.
|
|
289
289
|
*/
|
|
290
290
|
if (options.length > PAGINATED_SELECT_THRESHOLD) {
|
|
291
|
-
|
|
291
|
+
/*
|
|
292
|
+
* 🛑 The error block here used to be hand-rolled, and was the only
|
|
293
|
+
* field in this generator whose error was not wired like the rest:
|
|
294
|
+
* a `<Text>` with no `id`, nothing pointing `aria-describedby` at it,
|
|
295
|
+
* no `aria-invalid` on the control, and — unlike every `Field*`
|
|
296
|
+
* branch below — the help text was still rendered underneath the
|
|
297
|
+
* error rather than replaced by it.
|
|
298
|
+
*
|
|
299
|
+
* `PaginatedSelect` now takes `name`/`error` and owns all of that,
|
|
300
|
+
* so hand it the error and delete the copy. This is the one branch
|
|
301
|
+
* that every schema-generated form shares, so the fix lands on all
|
|
302
|
+
* of them at once rather than per call site.
|
|
303
|
+
*/
|
|
304
|
+
return (_jsxs(Stack, { gap: "xs", children: [_jsxs(Text, { size: "sm", weight: "medium", color: "muted", children: [label, required ? " *" : ""] }), _jsx(PaginatedSelect, { name: name, error: ctx.errors[name], value: typeof value === "string" ? value : null, onChange: (v) => set(v ?? ""), options: options, ariaLabel: label, placeholder: `Select ${label.toLowerCase()}…` }), meta.help && !ctx.errors[name] && (_jsx(Text, { size: "xs", color: "muted", children: meta.help }))] }, name));
|
|
292
305
|
}
|
|
293
306
|
return (_jsx(FieldSelect, { name: name, label: label, hint: meta.help, required: required, value: typeof value === "string" ? value : "", options: options, onChange: set }, name));
|
|
294
307
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import React from "react";
|
|
4
|
+
import { z } from "zod";
|
|
4
5
|
import { useApiMutation } from "@mohasinac/appkit/client";
|
|
5
6
|
import { Alert, Badge, Button, Div, Heading, Modal, Pagination, Skeleton, Stack, Table, Thead, Tbody, Tr, Th, Td, Text, Textarea, } from "../../../ui/index.js";
|
|
6
7
|
import { apiClient } from "../../../http/index.js";
|
|
@@ -11,6 +12,7 @@ import { applyZodIssues } from "../../../ui/forms/apply-zod-issues.js";
|
|
|
11
12
|
import { ValidationError } from "../../../errors/validation-error.js";
|
|
12
13
|
import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints.js";
|
|
13
14
|
import { ACTIONS } from "../../../_internal/shared/actions/action-registry.js";
|
|
15
|
+
import { QuickEditMenu } from "../../admin/components/QuickEditMenu.js";
|
|
14
16
|
import { useShipmentItems } from "../hooks/useShipments.js";
|
|
15
17
|
import { ShipmentItemLinkModal } from "./ShipmentItemLinkModal.js";
|
|
16
18
|
import { formatCurrency } from "../../../utils/number.formatter.js";
|
|
@@ -32,6 +34,30 @@ function parseBulkText(text) {
|
|
|
32
34
|
})
|
|
33
35
|
.filter((row) => row.title.length > 0);
|
|
34
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* The three fields a lot item can be corrected on after import.
|
|
39
|
+
*
|
|
40
|
+
* Deliberately NOT the whole of `shipmentItemBaseSchema`: `isForSelfUse`
|
|
41
|
+
* decides whether `price` is required at all and flipping it from a
|
|
42
|
+
* three-field drawer would silently change what the row means to the
|
|
43
|
+
* projections, and `categorySlugs`/`brandSlug` belong to the link flow that
|
|
44
|
+
* already owns them.
|
|
45
|
+
*/
|
|
46
|
+
const shipmentItemQuickEditSchema = z.object({
|
|
47
|
+
title: z.string().trim().min(1, "Item title is required"),
|
|
48
|
+
quantity: z.coerce.number().int().min(1, "Quantity must be at least 1."),
|
|
49
|
+
price: z.coerce.number().min(0, "Price cannot be negative."),
|
|
50
|
+
});
|
|
51
|
+
const SHIPMENT_ITEM_QUICK_FIELDS = [
|
|
52
|
+
{ name: "title", label: "Title", type: "text", required: true },
|
|
53
|
+
{ name: "quantity", label: "Quantity", type: "number", required: true },
|
|
54
|
+
{
|
|
55
|
+
name: "price",
|
|
56
|
+
label: "Projected sale price",
|
|
57
|
+
type: "number",
|
|
58
|
+
helperText: "Feeds this lot's projected revenue, and every profit figure on the projections screen.",
|
|
59
|
+
},
|
|
60
|
+
];
|
|
35
61
|
export function AdminShipmentLotItemsView({ shipmentId, lotId }) {
|
|
36
62
|
const [page, setPage] = React.useState(1);
|
|
37
63
|
const [pageSize, setPageSize] = React.useState(50);
|
|
@@ -113,6 +139,19 @@ export function AdminShipmentLotItemsView({ shipmentId, lotId }) {
|
|
|
113
139
|
mutationFn: (itemId) => apiClient.delete(ADMIN_ENDPOINTS.SHIPMENT_LOT_ITEM_BY_ID(shipmentId, lotId, itemId)),
|
|
114
140
|
onSuccess: () => refetch(),
|
|
115
141
|
});
|
|
142
|
+
const editItemMutation = useApiMutation({
|
|
143
|
+
successMessage: "Item updated",
|
|
144
|
+
errorMessage: "Could not update that item.",
|
|
145
|
+
mutationFn: ({ itemId, values }) => {
|
|
146
|
+
const parsed = shipmentItemQuickEditSchema.parse(values);
|
|
147
|
+
return apiClient.patch(ADMIN_ENDPOINTS.SHIPMENT_LOT_ITEM_BY_ID(shipmentId, lotId, itemId), parsed);
|
|
148
|
+
},
|
|
149
|
+
onSuccess: () => refetch(),
|
|
150
|
+
});
|
|
151
|
+
/** Named so the row-action array stays a flat list of declarations. */
|
|
152
|
+
const saveItemEdit = React.useCallback(async (itemId, values) => {
|
|
153
|
+
await editItemMutation.mutateAsync({ itemId, values });
|
|
154
|
+
}, [editItemMutation]);
|
|
116
155
|
const unlinkItemMutation = useApiMutation({
|
|
117
156
|
successMessage: "Item unlinked",
|
|
118
157
|
mutationFn: (itemId) => apiClient.patch(ADMIN_ENDPOINTS.SHIPMENT_LOT_ITEM_BY_ID(shipmentId, lotId, itemId), {
|
|
@@ -134,7 +173,21 @@ export function AdminShipmentLotItemsView({ shipmentId, lotId }) {
|
|
|
134
173
|
return;
|
|
135
174
|
}
|
|
136
175
|
addItemMutation.mutate();
|
|
137
|
-
}, onValidationChange: () => itemForm.validate(itemDraft), schema: shipmentItemFormSchema, openIds: itemNav.openIds, onOpenChange: itemNav.setOpenIds, submitLabel: "Save Item", cancelLabel: "Cancel", onCancel: () => setShowAddItem(false), isLoading: addItemMutation.isPending })] })), isLoading ? (_jsxs(Stack, { gap: "sm", children: [_jsx(Skeleton, { variant: "rectangular", height: "32px" }), _jsx(Skeleton, { variant: "rectangular", height: "32px" })] })) : items.length === 0 ? (_jsx(Text, { variant: "secondary", children: "No items tracked yet." })) : (_jsxs(Table, { children: [_jsx(Thead, { children: _jsxs(Tr, { children: [_jsx(Th, { children: "Title" }), _jsx(Th, { children: "Qty" }), _jsx(Th, { children: "Price" }), _jsx(Th, { children: "Status" }), _jsx(Th, {})] }) }), _jsx(Tbody, { children: items.map((item) => (_jsxs(Tr, { children: [_jsx(Td, { children: item.title }), _jsx(Td, { children: item.quantity }), _jsx(Td, { children: item.isForSelfUse ? "—" : formatCurrency(item.price ?? 0) }), _jsx(Td, { children: item.isForSelfUse ? (_jsx(Badge, { variant: "secondary", children: "Self use" })) : item.linkedProductId ? (_jsx(Badge, { variant: "success", children: "Linked" })) : (_jsx(Badge, { variant: "default", children: "Unlinked" })) }), _jsxs(Td, { className: "flex gap-[var(--appkit-space-2)]", children: [
|
|
176
|
+
}, onValidationChange: () => itemForm.validate(itemDraft), schema: shipmentItemFormSchema, openIds: itemNav.openIds, onOpenChange: itemNav.setOpenIds, submitLabel: "Save Item", cancelLabel: "Cancel", onCancel: () => setShowAddItem(false), isLoading: addItemMutation.isPending })] })), isLoading ? (_jsxs(Stack, { gap: "sm", children: [_jsx(Skeleton, { variant: "rectangular", height: "32px" }), _jsx(Skeleton, { variant: "rectangular", height: "32px" })] })) : items.length === 0 ? (_jsx(Text, { variant: "secondary", children: "No items tracked yet." })) : (_jsxs(Table, { children: [_jsx(Thead, { children: _jsxs(Tr, { children: [_jsx(Th, { children: "Title" }), _jsx(Th, { children: "Qty" }), _jsx(Th, { children: "Price" }), _jsx(Th, { children: "Status" }), _jsx(Th, {})] }) }), _jsx(Tbody, { children: items.map((item) => (_jsxs(Tr, { children: [_jsx(Td, { children: item.title }), _jsx(Td, { children: item.quantity }), _jsx(Td, { children: item.isForSelfUse ? "—" : formatCurrency(item.price ?? 0) }), _jsx(Td, { children: item.isForSelfUse ? (_jsx(Badge, { variant: "secondary", children: "Self use" })) : item.linkedProductId ? (_jsx(Badge, { variant: "success", children: "Linked" })) : (_jsx(Badge, { variant: "default", children: "Unlinked" })) }), _jsxs(Td, { className: "flex gap-[var(--appkit-space-2)]", children: [_jsx(QuickEditMenu, { actions: [
|
|
177
|
+
{
|
|
178
|
+
label: "Edit item",
|
|
179
|
+
formTitle: `Edit "${item.title}"`,
|
|
180
|
+
schema: shipmentItemQuickEditSchema,
|
|
181
|
+
fields: SHIPMENT_ITEM_QUICK_FIELDS,
|
|
182
|
+
defaultValues: {
|
|
183
|
+
title: item.title,
|
|
184
|
+
quantity: item.quantity,
|
|
185
|
+
price: item.price ?? 0,
|
|
186
|
+
},
|
|
187
|
+
onSubmit: (values) => saveItemEdit(item.id, values),
|
|
188
|
+
submitLabel: "Save item",
|
|
189
|
+
},
|
|
190
|
+
] }), !item.isForSelfUse &&
|
|
138
191
|
(item.linkedProductId ? (_jsx(Button, { size: "sm", variant: "ghost", isLoading: unlinkItemMutation.isPending, onClick: () => unlinkItemMutation.mutate(item.id), children: "Unlink" })) : (_jsx(Button, { size: "sm", variant: "ghost", onClick: () => setLinkTarget({ itemId: item.id, title: item.title }), children: "Create pre-order link" }))), _jsx(Button, { size: "sm", variant: "ghost", action: ACTIONS.SHIPMENT["delete"], isLoading: deleteItemMutation.isPending, onClick: () => deleteItemMutation.mutate(item.id), children: "Delete" })] })] }, item.id))) })] })), meta && meta.totalPages > 1 && (_jsx(Pagination, { currentPage: page, totalPages: meta.totalPages, onPageChange: setPage, pageSize: pageSize, onPageSizeChange: (size) => {
|
|
139
192
|
setPageSize(size);
|
|
140
193
|
setPage(1);
|