@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.
Files changed (96) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/client/features/lottery/LotteryAdminEditView.js +19 -3
  3. package/dist/_internal/client/features/maintenance/views/AnalysisRunnerView.js +1 -1
  4. package/dist/_internal/client/features/maintenance/views/CloudLogsListView.js +1 -1
  5. package/dist/_internal/server/jobs/core/paymentReconcile.js +87 -97
  6. package/dist/_internal/shared/actions/action-registry.js +149 -9
  7. package/dist/_internal/shared/media/limits.d.ts +10 -0
  8. package/dist/_internal/shared/media/limits.js +10 -0
  9. package/dist/_internal/shared/types/action-result.js +15 -1
  10. package/dist/client/api/surface-error.js +16 -2
  11. package/dist/client/api/useApiMutation.js +17 -1
  12. package/dist/client.d.ts +1 -1
  13. package/dist/client.js +1 -1
  14. package/dist/constants/api-endpoints.d.ts +3 -0
  15. package/dist/constants/api-endpoints.js +1 -0
  16. package/dist/constants/field-names.d.ts +17 -0
  17. package/dist/constants/field-names.js +23 -0
  18. package/dist/core/server-action.js +19 -3
  19. package/dist/errors/error-mapping.js +26 -1
  20. package/dist/features/account/components/UserOffersPanel.js +5 -3
  21. package/dist/features/admin/components/AdminActionIndexView.js +100 -5
  22. package/dist/features/admin/components/AdminAddressClustersView.js +21 -2
  23. package/dist/features/admin/components/AdminBlogEditorView.js +8 -8
  24. package/dist/features/admin/components/AdminCarouselEditorView.js +1 -1
  25. package/dist/features/admin/components/AdminCarouselView.js +96 -4
  26. package/dist/features/admin/components/AdminFulfillmentView.js +3 -2
  27. package/dist/features/admin/components/AdminMediaView.js +36 -3
  28. package/dist/features/admin/components/AdminNavigationView.js +4 -1
  29. package/dist/features/admin/components/AdminOffersView.js +2 -1
  30. package/dist/features/admin/components/AdminPaymentClustersView.js +19 -2
  31. package/dist/features/admin/components/AdminProductEditorView.js +2 -2
  32. package/dist/features/admin/components/AdminSectionsView.d.ts +10 -0
  33. package/dist/features/admin/components/AdminSectionsView.js +486 -224
  34. package/dist/features/admin/components/ReferenceRecordEditor.js +1 -1
  35. package/dist/features/admin/hooks/useOptimisticRowFlag.d.ts +33 -0
  36. package/dist/features/admin/hooks/useOptimisticRowFlag.js +87 -0
  37. package/dist/features/admin/repository/notification.repository.d.ts +15 -0
  38. package/dist/features/admin/repository/notification.repository.js +32 -0
  39. package/dist/features/admin/schemas/action-index-form.d.ts +34 -0
  40. package/dist/features/admin/schemas/action-index-form.js +115 -0
  41. package/dist/features/auth/components/RegisterForm.js +2 -1
  42. package/dist/features/catalogue/components/CatalogueItemEditorView.js +3 -3
  43. package/dist/features/events/components/AdminEventEditorView.js +8 -6
  44. package/dist/features/layout/hooks/useFormBottomActions.js +20 -1
  45. package/dist/features/orders/components/EmiInstallmentsPanel.js +25 -7
  46. package/dist/features/orders/components/RefundRequestView.js +4 -4
  47. package/dist/features/orders/schemas/index.d.ts +6 -6
  48. package/dist/features/products/components/GroupMemberPicker.js +4 -1
  49. package/dist/features/products/components/PrizeDrawEntryActions.js +2 -1
  50. package/dist/features/products/components/ProductDetailActions.js +2 -1
  51. package/dist/features/seller/components/FulfillmentView.js +3 -2
  52. package/dist/features/seller/components/SellerAddressesView.js +32 -4
  53. package/dist/features/seller/components/SellerAnalyticsAlertsView.js +62 -3
  54. package/dist/features/seller/components/SellerBundlesView.js +53 -14
  55. package/dist/features/seller/components/SellerGroupedListingsView.js +23 -3
  56. package/dist/features/seller/components/SellerListingTypeView.js +56 -10
  57. package/dist/features/seller/components/SellerPayoutMethodsView.js +33 -1
  58. package/dist/features/seller/components/SellerPayoutSettingsView.js +48 -9
  59. package/dist/features/seller/components/SellerProductsView.js +5 -1
  60. package/dist/features/seller/components/SellerShippingConfigsView.js +33 -1
  61. package/dist/features/seller/components/SellerShippingView.js +2 -1
  62. package/dist/features/seller/components/SellerStoreCategoriesView.js +33 -1
  63. package/dist/features/seller/components/SellerSupportView.js +69 -2
  64. package/dist/features/shell/SectionForm.js +20 -16
  65. package/dist/features/shell/build-sections.js +14 -1
  66. package/dist/features/shipments/components/AdminShipmentLotItemsView.js +54 -1
  67. package/dist/features/shipments/components/AdminShipmentProjectionsView.js +3 -2
  68. package/dist/features/sticker-trace/components/StickerCommitPanel.js +2 -1
  69. package/dist/features/sticker-trace/components/StickerPrintPanel.js +2 -1
  70. package/dist/features/sticker-trace/components/StickerRetryPanel.js +24 -4
  71. package/dist/features/sticker-trace/components/StickerTraceStudio.js +2 -1
  72. package/dist/features/tester/components/AdminTesterFeedbackIssuesView.js +3 -2
  73. package/dist/index.d.ts +5 -1
  74. package/dist/index.js +3 -1
  75. package/dist/next/routing/route-map.d.ts +4 -0
  76. package/dist/next/routing/route-map.js +9 -0
  77. package/dist/schemas/registry.d.ts +4 -4
  78. package/dist/schemas/sieve.d.ts +3 -3
  79. package/dist/server.d.ts +1 -1
  80. package/dist/server.js +1 -1
  81. package/dist/ui/components/Checkbox.js +10 -2
  82. package/dist/ui/components/DateInput.js +8 -1
  83. package/dist/ui/components/Input.js +22 -2
  84. package/dist/ui/components/PaginatedSelect.d.ts +8 -19
  85. package/dist/ui/components/PaginatedSelect.js +43 -9
  86. package/dist/ui/components/Radio.js +8 -1
  87. package/dist/ui/components/Select.js +10 -2
  88. package/dist/ui/components/TagInput.d.ts +8 -1
  89. package/dist/ui/components/TagInput.js +4 -3
  90. package/dist/ui/components/Textarea.js +10 -2
  91. package/dist/ui/forms/FieldCheckbox.js +1 -1
  92. package/dist/ui/forms/FormErrorList.d.ts +0 -16
  93. package/dist/ui/forms/FormErrorList.js +34 -1
  94. package/dist/ui/forms/apply-zod-issues.d.ts +19 -1
  95. package/dist/ui/forms/apply-zod-issues.js +23 -1
  96. 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
- isActive: Boolean(item.isActive),
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
- setError(json?.error ?? "Failed to save");
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
- void normalizeError(err);
267
- setError(err.message);
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})` })), _jsx(Stack, { gap: "sm", children: ([
289
- { value: "upi", label: "UPI", desc: "Instant payouts via UPI VPA (e.g. name@upi)." },
290
- { value: "bank_transfer", label: "Bank Transfer", desc: "NEFT/RTGS to your bank account within 2–3 business days." },
291
- ]).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 }), _jsxs(Div, { children: [_jsx(Text, { weight: "medium", children: label }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: desc })] })] }, value))) }), 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(Row, { gap: "xs", children: ["savings", "current"].map((t) => (_jsxs(Label, { layout: "flex", gap: "md", className: "cursor-pointer", children: [_jsx(Checkbox, { bare: true, type: "radio", name: "accountType", value: t, checked: values.accountType === t, onChange: () => onChange({ accountType: t }), className: "accent-[var(--appkit-color-primary)]", disabled: busy }), _jsx(Text, { size: "sm", transform: "capitalize", children: t })] }, t))) })] })] })] }))] })),
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 ? "Unpublish" : "Publish", title: isPublished ? "Unpublish" : "Publish", 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" }) }))] }));
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
- isActive: Boolean(item.isActive),
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?.error ?? "Failed to save");
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
- isActive: Boolean(item.isActive),
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 { Badge, Span, Stack, Text } from "../../../ui/index.js";
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
- } }), _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." })] }));
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
- return (_jsxs(Stack, { gap: "xs", children: [_jsxs(Text, { size: "sm", weight: "medium", color: "muted", children: [label, required ? " *" : ""] }), _jsx(PaginatedSelect, { value: typeof value === "string" ? value : null, onChange: (v) => set(v ?? ""), options: options, ariaLabel: label, placeholder: `Select ${label.toLowerCase()}…` }), meta.help && _jsx(Text, { size: "xs", color: "muted", children: meta.help }), ctx.errors[name] && (_jsx(Text, { size: "xs", color: "error", role: "alert", children: ctx.errors[name] }))] }, name));
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: [!item.isForSelfUse &&
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);