@open-mercato/core 0.4.5-develop-811deeb983 → 0.4.5-develop-3d8e759e45

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 (76) hide show
  1. package/dist/modules/catalog/inbox-actions.js +51 -0
  2. package/dist/modules/catalog/inbox-actions.js.map +7 -0
  3. package/dist/modules/customers/inbox-actions.js +230 -0
  4. package/dist/modules/customers/inbox-actions.js.map +7 -0
  5. package/dist/modules/inbox_ops/api/emails/[id]/route.js +40 -1
  6. package/dist/modules/inbox_ops/api/emails/[id]/route.js.map +2 -2
  7. package/dist/modules/inbox_ops/api/extract/route.js +87 -0
  8. package/dist/modules/inbox_ops/api/extract/route.js.map +7 -0
  9. package/dist/modules/inbox_ops/api/proposals/[id]/translate/route.js +6 -1
  10. package/dist/modules/inbox_ops/api/proposals/[id]/translate/route.js.map +2 -2
  11. package/dist/modules/inbox_ops/api/proposals/counts/route.js.map +2 -2
  12. package/dist/modules/inbox_ops/backend/inbox-ops/log/page.js +40 -14
  13. package/dist/modules/inbox_ops/backend/inbox-ops/log/page.js.map +2 -2
  14. package/dist/modules/inbox_ops/backend/inbox-ops/log/page.meta.js +2 -2
  15. package/dist/modules/inbox_ops/backend/inbox-ops/log/page.meta.js.map +2 -2
  16. package/dist/modules/inbox_ops/backend/inbox-ops/page.js +161 -79
  17. package/dist/modules/inbox_ops/backend/inbox-ops/page.js.map +2 -2
  18. package/dist/modules/inbox_ops/backend/inbox-ops/page.meta.js +2 -2
  19. package/dist/modules/inbox_ops/backend/inbox-ops/page.meta.js.map +2 -2
  20. package/dist/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.js +109 -62
  21. package/dist/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.js.map +3 -3
  22. package/dist/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.meta.js +2 -2
  23. package/dist/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.meta.js.map +2 -2
  24. package/dist/modules/inbox_ops/backend/inbox-ops/settings/page.js +36 -14
  25. package/dist/modules/inbox_ops/backend/inbox-ops/settings/page.js.map +2 -2
  26. package/dist/modules/inbox_ops/backend/inbox-ops/settings/page.meta.js +2 -2
  27. package/dist/modules/inbox_ops/backend/inbox-ops/settings/page.meta.js.map +2 -2
  28. package/dist/modules/inbox_ops/components/proposals/ActionCard.js +65 -10
  29. package/dist/modules/inbox_ops/components/proposals/ActionCard.js.map +2 -2
  30. package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js +58 -10
  31. package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js.map +2 -2
  32. package/dist/modules/inbox_ops/lib/constants.js.map +2 -2
  33. package/dist/modules/inbox_ops/lib/contactValidation.js +40 -0
  34. package/dist/modules/inbox_ops/lib/contactValidation.js.map +7 -0
  35. package/dist/modules/inbox_ops/lib/executionEngine.js +31 -826
  36. package/dist/modules/inbox_ops/lib/executionEngine.js.map +3 -3
  37. package/dist/modules/inbox_ops/lib/executionHelpers.js +368 -0
  38. package/dist/modules/inbox_ops/lib/executionHelpers.js.map +7 -0
  39. package/dist/modules/inbox_ops/lib/extractionPrompt.js +28 -35
  40. package/dist/modules/inbox_ops/lib/extractionPrompt.js.map +3 -3
  41. package/dist/modules/inbox_ops/lib/inbox-actions-generated.d.js +1 -0
  42. package/dist/modules/inbox_ops/lib/inbox-actions-generated.d.js.map +7 -0
  43. package/dist/modules/inbox_ops/lib/translationProvider.js +15 -10
  44. package/dist/modules/inbox_ops/lib/translationProvider.js.map +2 -2
  45. package/dist/modules/inbox_ops/subscribers/extractionWorker.js +16 -16
  46. package/dist/modules/inbox_ops/subscribers/extractionWorker.js.map +2 -2
  47. package/dist/modules/sales/inbox-actions.js +278 -0
  48. package/dist/modules/sales/inbox-actions.js.map +7 -0
  49. package/jest.config.cjs +1 -0
  50. package/jest.mocks/inbox-actions.generated.js +5 -0
  51. package/package.json +2 -2
  52. package/src/modules/catalog/inbox-actions.ts +60 -0
  53. package/src/modules/customers/inbox-actions.ts +285 -0
  54. package/src/modules/inbox_ops/api/emails/[id]/route.ts +44 -0
  55. package/src/modules/inbox_ops/api/extract/route.ts +94 -0
  56. package/src/modules/inbox_ops/api/proposals/[id]/translate/route.ts +6 -1
  57. package/src/modules/inbox_ops/api/proposals/counts/route.ts +2 -0
  58. package/src/modules/inbox_ops/backend/inbox-ops/log/page.meta.ts +2 -2
  59. package/src/modules/inbox_ops/backend/inbox-ops/log/page.tsx +43 -13
  60. package/src/modules/inbox_ops/backend/inbox-ops/page.meta.ts +2 -2
  61. package/src/modules/inbox_ops/backend/inbox-ops/page.tsx +176 -81
  62. package/src/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.meta.ts +2 -2
  63. package/src/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.tsx +122 -68
  64. package/src/modules/inbox_ops/backend/inbox-ops/settings/page.meta.ts +2 -2
  65. package/src/modules/inbox_ops/backend/inbox-ops/settings/page.tsx +36 -14
  66. package/src/modules/inbox_ops/components/proposals/ActionCard.tsx +91 -7
  67. package/src/modules/inbox_ops/components/proposals/EditActionDialog.tsx +64 -12
  68. package/src/modules/inbox_ops/lib/constants.ts +9 -0
  69. package/src/modules/inbox_ops/lib/contactValidation.ts +54 -0
  70. package/src/modules/inbox_ops/lib/executionEngine.ts +47 -1060
  71. package/src/modules/inbox_ops/lib/executionHelpers.ts +527 -0
  72. package/src/modules/inbox_ops/lib/extractionPrompt.ts +45 -34
  73. package/src/modules/inbox_ops/lib/inbox-actions-generated.d.ts +11 -0
  74. package/src/modules/inbox_ops/lib/translationProvider.ts +16 -10
  75. package/src/modules/inbox_ops/subscribers/extractionWorker.ts +16 -18
  76. package/src/modules/sales/inbox-actions.ts +359 -0
@@ -7,9 +7,10 @@ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
7
7
  import { Button } from '@open-mercato/ui/primitives/button'
8
8
  import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
9
9
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
10
- import { LoadingMessage } from '@open-mercato/ui/backend/detail'
10
+ import { LoadingMessage, ErrorMessage } from '@open-mercato/ui/backend/detail'
11
11
  import { useT, useLocale } from '@open-mercato/shared/lib/i18n/context'
12
12
  import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
13
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
13
14
  import {
14
15
  ArrowLeft,
15
16
  CheckCircle,
@@ -24,7 +25,8 @@ import {
24
25
  } from 'lucide-react'
25
26
  import type { ProposalTranslationEntry } from '../../../../data/entities'
26
27
  import type { ProposalDetail, ActionDetail, DiscrepancyDetail, EmailDetail } from '../../../../components/proposals/types'
27
- import { ActionCard, ConfidenceBadge, useActionTypeLabels } from '../../../../components/proposals/ActionCard'
28
+ import { ActionCard, ConfidenceBadge, useActionTypeLabels, useDiscrepancyDescriptions } from '../../../../components/proposals/ActionCard'
29
+ import { hasContactNameIssue } from '../../../../lib/contactValidation'
28
30
  import { EditActionDialog } from '../../../../components/proposals/EditActionDialog'
29
31
 
30
32
  function EmailThreadViewer({ email }: { email: EmailDetail | null }) {
@@ -75,10 +77,15 @@ export default function ProposalDetailPage({ params }: { params?: { id?: string
75
77
  const [discrepancies, setDiscrepancies] = React.useState<DiscrepancyDetail[]>([])
76
78
  const [email, setEmail] = React.useState<EmailDetail | null>(null)
77
79
  const [isLoading, setIsLoading] = React.useState(true)
80
+ const [error, setError] = React.useState<string | null>(null)
78
81
  const [isProcessing, setIsProcessing] = React.useState(false)
79
82
 
80
83
  const { confirm, ConfirmDialogElement } = useConfirmDialog()
84
+ const { runMutation } = useGuardedMutation<Record<string, unknown>>({
85
+ contextId: 'inbox-ops-proposal-detail',
86
+ })
81
87
  const actionTypeLabels = useActionTypeLabels()
88
+ const resolveDiscrepancyDescription = useDiscrepancyDescriptions()
82
89
  const [editingAction, setEditingAction] = React.useState<ActionDetail | null>(null)
83
90
  const [sendingReplyId, setSendingReplyId] = React.useState<string | null>(null)
84
91
 
@@ -122,45 +129,59 @@ export default function ProposalDetailPage({ params }: { params?: { id?: string
122
129
  const handleTranslate = React.useCallback(async () => {
123
130
  if (!proposalId) return
124
131
  setIsTranslating(true)
125
- const result = await apiCall<{ translation: ProposalTranslationEntry; cached: boolean }>(
126
- `/api/inbox_ops/proposals/${proposalId}/translate`,
127
- { method: 'POST', body: JSON.stringify({ targetLocale: locale }) },
128
- )
132
+ const result = await runMutation({
133
+ operation: () => apiCall<{ translation: ProposalTranslationEntry; cached: boolean }>(
134
+ `/api/inbox_ops/proposals/${proposalId}/translate`,
135
+ { method: 'POST', body: JSON.stringify({ targetLocale: locale }) },
136
+ ),
137
+ context: {},
138
+ })
129
139
  if (result?.ok && result.result?.translation) {
130
140
  setTranslation(result.result.translation)
131
141
  setShowTranslation(true)
132
142
  } else {
133
- flash(t('inbox_ops.translate.failed', 'Translation failed'), 'error')
143
+ const detail = (result?.result as Record<string, unknown> | null)?.error
144
+ flash(detail ? `${t('inbox_ops.translate.failed', 'Translation failed')}: ${detail}` : t('inbox_ops.translate.failed', 'Translation failed'), 'error')
134
145
  }
135
146
  setIsTranslating(false)
136
- }, [proposalId, locale, t])
147
+ }, [proposalId, locale, t, runMutation])
137
148
 
138
149
  const loadData = React.useCallback(async () => {
139
150
  if (!proposalId) return
140
151
  setIsLoading(true)
141
- const result = await apiCall<{
142
- proposal: ProposalDetail
143
- actions: ActionDetail[]
144
- discrepancies: DiscrepancyDetail[]
145
- email: EmailDetail
146
- }>(`/api/inbox_ops/proposals/${proposalId}`)
147
- if (result?.ok && result.result) {
148
- setProposal(result.result.proposal)
149
- setActions(result.result.actions || [])
150
- setDiscrepancies(result.result.discrepancies || [])
151
- setEmail(result.result.email)
152
+ setError(null)
153
+ try {
154
+ const result = await apiCall<{
155
+ proposal: ProposalDetail
156
+ actions: ActionDetail[]
157
+ discrepancies: DiscrepancyDetail[]
158
+ email: EmailDetail
159
+ }>(`/api/inbox_ops/proposals/${proposalId}`)
160
+ if (result?.ok && result.result) {
161
+ setProposal(result.result.proposal)
162
+ setActions(result.result.actions || [])
163
+ setDiscrepancies(result.result.discrepancies || [])
164
+ setEmail(result.result.email)
165
+ } else {
166
+ setError(t('inbox_ops.flash.load_failed', 'Failed to load proposal'))
167
+ }
168
+ } catch {
169
+ setError(t('inbox_ops.flash.load_failed', 'Failed to load proposal'))
152
170
  }
153
171
  setIsLoading(false)
154
- }, [proposalId])
172
+ }, [proposalId, t])
155
173
 
156
174
  React.useEffect(() => { loadData() }, [loadData])
157
175
 
158
176
  const handleAcceptAction = React.useCallback(async (actionId: string) => {
159
177
  setIsProcessing(true)
160
- const result = await apiCall<{ ok: boolean; error?: string }>(
161
- `/api/inbox_ops/proposals/${proposalId}/actions/${actionId}/accept`,
162
- { method: 'POST' },
163
- )
178
+ const result = await runMutation({
179
+ operation: () => apiCall<{ ok: boolean; error?: string }>(
180
+ `/api/inbox_ops/proposals/${proposalId}/actions/${actionId}/accept`,
181
+ { method: 'POST' },
182
+ ),
183
+ context: {},
184
+ })
164
185
  if (result?.ok && result.result?.ok) {
165
186
  flash(t('inbox_ops.flash.action_executed', 'Action executed'), 'success')
166
187
  await loadData()
@@ -168,14 +189,17 @@ export default function ProposalDetailPage({ params }: { params?: { id?: string
168
189
  flash(result?.result?.error || t('inbox_ops.flash.action_execute_failed', 'Failed to execute action'), 'error')
169
190
  }
170
191
  setIsProcessing(false)
171
- }, [proposalId, loadData, t])
192
+ }, [proposalId, loadData, t, runMutation])
172
193
 
173
194
  const handleRejectAction = React.useCallback(async (actionId: string) => {
174
195
  setIsProcessing(true)
175
- const result = await apiCall<{ ok: boolean }>(
176
- `/api/inbox_ops/proposals/${proposalId}/actions/${actionId}/reject`,
177
- { method: 'POST' },
178
- )
196
+ const result = await runMutation({
197
+ operation: () => apiCall<{ ok: boolean }>(
198
+ `/api/inbox_ops/proposals/${proposalId}/actions/${actionId}/reject`,
199
+ { method: 'POST' },
200
+ ),
201
+ context: {},
202
+ })
179
203
  if (result?.ok && result.result?.ok) {
180
204
  flash(t('inbox_ops.flash.action_rejected', 'Action rejected'), 'success')
181
205
  await loadData()
@@ -183,31 +207,47 @@ export default function ProposalDetailPage({ params }: { params?: { id?: string
183
207
  flash(t('inbox_ops.flash.action_reject_failed', 'Failed to reject action'), 'error')
184
208
  }
185
209
  setIsProcessing(false)
186
- }, [proposalId, loadData])
210
+ }, [proposalId, loadData, runMutation])
187
211
 
188
212
  const handleAcceptAll = React.useCallback(async () => {
189
- const pendingCount = actions.filter((a) => a.status === 'pending').length
213
+ const pendingActions = actions.filter((a) => a.status === 'pending')
214
+ const pendingCount = pendingActions.length
215
+ const nameIssueCount = pendingActions.filter((a) => hasContactNameIssue(a)).length
216
+
217
+ const confirmText = nameIssueCount > 0
218
+ ? t('inbox_ops.action.accept_all_confirm_with_skip', 'Execute {count} pending actions? {skipCount} contact actions will be skipped due to missing names.')
219
+ .replace('{count}', String(pendingCount))
220
+ .replace('{skipCount}', String(nameIssueCount))
221
+ : t('inbox_ops.action.accept_all_confirm', 'Execute {count} pending actions?').replace('{count}', String(pendingCount))
222
+
190
223
  const confirmed = await confirm({
191
224
  title: t('inbox_ops.action.accept_all', 'Accept All'),
192
- text: t('inbox_ops.action.accept_all_confirm', `Execute ${pendingCount} pending actions?`).replace('{count}', String(pendingCount)),
225
+ text: confirmText,
193
226
  })
194
227
  if (!confirmed) return
195
228
 
196
229
  setIsProcessing(true)
197
- const result = await apiCall<{ ok: boolean; succeeded: number; failed: number }>(
198
- `/api/inbox_ops/proposals/${proposalId}/accept-all`,
199
- { method: 'POST' },
200
- )
230
+ const result = await runMutation({
231
+ operation: () => apiCall<{ ok: boolean; succeeded: number; failed: number }>(
232
+ `/api/inbox_ops/proposals/${proposalId}/accept-all`,
233
+ { method: 'POST' },
234
+ ),
235
+ context: {},
236
+ })
201
237
  if (result?.ok && result.result?.ok) {
202
- flash(t('inbox_ops.flash.accept_all_success', '{succeeded} actions executed')
203
- .replace('{succeeded}', String(result.result.succeeded))
204
- + (result.result.failed > 0 ? `, ${result.result.failed} failed` : ''), 'success')
238
+ const msg = result.result.failed > 0
239
+ ? t('inbox_ops.flash.accept_all_partial', '{succeeded} actions executed, {failed} failed')
240
+ .replace('{succeeded}', String(result.result.succeeded))
241
+ .replace('{failed}', String(result.result.failed))
242
+ : t('inbox_ops.flash.accept_all_success', '{succeeded} actions executed')
243
+ .replace('{succeeded}', String(result.result.succeeded))
244
+ flash(msg, 'success')
205
245
  await loadData()
206
246
  } else {
207
247
  flash(t('inbox_ops.flash.accept_all_failed', 'Failed to accept all actions'), 'error')
208
248
  }
209
249
  setIsProcessing(false)
210
- }, [proposalId, actions, confirm, t, loadData])
250
+ }, [proposalId, actions, confirm, t, loadData, runMutation])
211
251
 
212
252
  const handleRejectAll = React.useCallback(async () => {
213
253
  const confirmed = await confirm({
@@ -217,37 +257,46 @@ export default function ProposalDetailPage({ params }: { params?: { id?: string
217
257
  if (!confirmed) return
218
258
 
219
259
  setIsProcessing(true)
220
- const result = await apiCall<{ ok: boolean }>(
221
- `/api/inbox_ops/proposals/${proposalId}/reject`,
222
- { method: 'POST' },
223
- )
260
+ const result = await runMutation({
261
+ operation: () => apiCall<{ ok: boolean }>(
262
+ `/api/inbox_ops/proposals/${proposalId}/reject`,
263
+ { method: 'POST' },
264
+ ),
265
+ context: {},
266
+ })
224
267
  if (result?.ok && result.result?.ok) {
225
268
  flash(t('inbox_ops.action.proposal_rejected', 'Proposal rejected'), 'success')
226
269
  await loadData()
227
270
  }
228
271
  setIsProcessing(false)
229
- }, [proposalId, confirm, t, loadData])
272
+ }, [proposalId, confirm, t, loadData, runMutation])
230
273
 
231
274
  const handleRetryExtraction = React.useCallback(async () => {
232
275
  if (!email) return
233
276
  setIsProcessing(true)
234
- const result = await apiCall<{ ok: boolean }>(
235
- `/api/inbox_ops/emails/${email.id}/reprocess`,
236
- { method: 'POST' },
237
- )
277
+ const result = await runMutation({
278
+ operation: () => apiCall<{ ok: boolean }>(
279
+ `/api/inbox_ops/emails/${email.id}/reprocess`,
280
+ { method: 'POST' },
281
+ ),
282
+ context: {},
283
+ })
238
284
  if (result?.ok && result.result?.ok) {
239
285
  flash(t('inbox_ops.flash.reprocessing_started', 'Reprocessing started'), 'success')
240
286
  await loadData()
241
287
  }
242
288
  setIsProcessing(false)
243
- }, [email, loadData])
289
+ }, [email, loadData, runMutation])
244
290
 
245
291
  const handleSendReply = React.useCallback(async (actionId: string) => {
246
292
  setSendingReplyId(actionId)
247
- const result = await apiCall<{ ok: boolean; error?: string }>(
248
- `/api/inbox_ops/proposals/${proposalId}/replies/${actionId}/send`,
249
- { method: 'POST' },
250
- )
293
+ const result = await runMutation({
294
+ operation: () => apiCall<{ ok: boolean; error?: string }>(
295
+ `/api/inbox_ops/proposals/${proposalId}/replies/${actionId}/send`,
296
+ { method: 'POST' },
297
+ ),
298
+ context: {},
299
+ })
251
300
  if (result?.ok && result.result?.ok) {
252
301
  flash(t('inbox_ops.reply.sent_success', 'Reply sent successfully'), 'success')
253
302
  await loadData()
@@ -255,9 +304,10 @@ export default function ProposalDetailPage({ params }: { params?: { id?: string
255
304
  flash(result?.result?.error || t('inbox_ops.flash.send_reply_failed', 'Failed to send reply'), 'error')
256
305
  }
257
306
  setSendingReplyId(null)
258
- }, [proposalId, t, loadData])
307
+ }, [proposalId, t, loadData, runMutation])
259
308
 
260
309
  if (isLoading) return <LoadingMessage label={t('inbox_ops.loading_proposal', 'Loading proposal...')} />
310
+ if (error) return <ErrorMessage label={error} />
261
311
 
262
312
  const pendingActions = actions.filter((a) => a.status === 'pending')
263
313
  const emailIsProcessing = email?.status === 'processing'
@@ -275,23 +325,24 @@ export default function ProposalDetailPage({ params }: { params?: { id?: string
275
325
  />
276
326
  )}
277
327
 
278
- <div className="flex items-center justify-between px-3 py-3 md:px-6 md:py-4 border-b">
279
- <div className="flex items-center gap-3">
328
+ <div className="flex items-center justify-between px-4 py-3 md:px-6 md:py-4 border-b bg-background">
329
+ <div className="flex items-center gap-2 md:gap-3 min-w-0">
280
330
  <Link href="/backend/inbox-ops">
281
- <Button variant="ghost" size="sm">
331
+ <Button type="button" variant="ghost" size="sm">
282
332
  <ArrowLeft className="h-4 w-4" />
283
333
  </Button>
284
334
  </Link>
285
- <div className="min-w-0">
286
- <h1 className="text-lg font-semibold truncate">{email?.subject || t('inbox_ops.proposal', 'Proposal')}</h1>
287
- <p className="text-xs text-muted-foreground">
335
+ <div className="min-w-0 flex-1">
336
+ <h1 className="text-base md:text-lg font-semibold truncate">{email?.subject || t('inbox_ops.proposal', 'Proposal')}</h1>
337
+ <p className="text-xs text-muted-foreground truncate">
288
338
  {email?.forwardedByName || email?.forwardedByAddress} · {email?.receivedAt && new Date(email.receivedAt).toLocaleString()}
289
339
  </p>
290
340
  </div>
291
341
  </div>
292
- <div className="flex items-center gap-2">
342
+ <div className="flex items-center gap-2 flex-shrink-0">
293
343
  {pendingActions.length > 0 && (
294
344
  <Button
345
+ type="button"
295
346
  variant="outline"
296
347
  size="sm"
297
348
  className="h-11 md:h-9 text-destructive border-destructive/30 hover:bg-destructive/10"
@@ -303,7 +354,7 @@ export default function ProposalDetailPage({ params }: { params?: { id?: string
303
354
  </Button>
304
355
  )}
305
356
  {pendingActions.length > 1 && (
306
- <Button size="sm" className="h-11 md:h-9" onClick={handleAcceptAll} disabled={isProcessing}>
357
+ <Button type="button" size="sm" className="h-11 md:h-9" onClick={handleAcceptAll} disabled={isProcessing}>
307
358
  {isProcessing ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <CheckCheck className="h-4 w-4 mr-1" />}
308
359
  <span className="hidden md:inline">{t('inbox_ops.action.accept_all', 'Accept All')}</span>
309
360
  </Button>
@@ -334,7 +385,7 @@ export default function ProposalDetailPage({ params }: { params?: { id?: string
334
385
  {email?.processingError && (
335
386
  <p className="text-xs text-red-600 mb-3">{email.processingError}</p>
336
387
  )}
337
- <Button size="sm" variant="outline" onClick={handleRetryExtraction} disabled={isProcessing}>
388
+ <Button type="button" size="sm" variant="outline" onClick={handleRetryExtraction} disabled={isProcessing}>
338
389
  <RefreshCw className="h-4 w-4 mr-1" />
339
390
  {t('inbox_ops.action.retry', 'Retry')}
340
391
  </Button>
@@ -345,8 +396,9 @@ export default function ProposalDetailPage({ params }: { params?: { id?: string
345
396
  <div className="border rounded-lg p-3 md:p-4">
346
397
  <div className="flex items-center justify-between mb-2">
347
398
  <h3 className="font-semibold text-sm">{t('inbox_ops.summary', 'Summary')}</h3>
348
- {proposal.workingLanguage && proposal.workingLanguage !== locale && (
399
+ {(proposal.workingLanguage || 'en') !== locale && (
349
400
  <Button
401
+ type="button"
350
402
  variant="ghost"
351
403
  size="sm"
352
404
  className="h-8 text-xs"
@@ -418,12 +470,12 @@ export default function ProposalDetailPage({ params }: { params?: { id?: string
418
470
  }`}>
419
471
  <AlertTriangle className="h-3 w-3 mt-0.5 flex-shrink-0" />
420
472
  <div>
421
- <span>{d.description}</span>
473
+ <span>{resolveDiscrepancyDescription(d.description, d.foundValue)}</span>
422
474
  {(d.expectedValue || d.foundValue) && (
423
475
  <div className="mt-0.5 text-[11px] opacity-80">
424
- {d.expectedValue && <span>Expected: {d.expectedValue}</span>}
476
+ {d.expectedValue && <span>{t('inbox_ops.discrepancy.expected', 'Expected')}: {d.expectedValue}</span>}
425
477
  {d.expectedValue && d.foundValue && <span> · </span>}
426
- {d.foundValue && <span>Found: {d.foundValue}</span>}
478
+ {d.foundValue && <span>{t('inbox_ops.discrepancy.found', 'Found')}: {d.foundValue}</span>}
427
479
  </div>
428
480
  )}
429
481
  </div>
@@ -452,10 +504,12 @@ export default function ProposalDetailPage({ params }: { params?: { id?: string
452
504
  onRetry={handleAcceptAction}
453
505
  onEdit={handleEditAction}
454
506
  translatedDescription={showTranslation ? translation?.actions[action.id] : undefined}
507
+ resolveDiscrepancyDescription={resolveDiscrepancyDescription}
455
508
  />
456
509
  {action.actionType === 'draft_reply' && (action.status === 'executed' || action.status === 'accepted') && (
457
510
  <div className="mt-2 pl-7">
458
511
  <Button
512
+ type="button"
459
513
  size="sm"
460
514
  variant="outline"
461
515
  className="h-11 md:h-9"
@@ -3,11 +3,11 @@ export const metadata = {
3
3
  requireFeatures: ['inbox_ops.settings.manage'],
4
4
  pageTitle: 'Inbox Settings',
5
5
  pageTitleKey: 'inbox_ops.nav.settings',
6
- pageGroup: 'InboxOps',
6
+ pageGroup: 'AI Inbox Actions',
7
7
  pageGroupKey: 'inbox_ops.nav.group',
8
8
  navHidden: true,
9
9
  breadcrumb: [
10
- { label: 'InboxOps', labelKey: 'inbox_ops.nav.group', href: '/backend/inbox-ops' },
10
+ { label: 'AI Inbox Actions', labelKey: 'inbox_ops.nav.group', href: '/backend/inbox-ops' },
11
11
  { label: 'Settings', labelKey: 'inbox_ops.nav.settings' },
12
12
  ],
13
13
  }
@@ -6,13 +6,18 @@ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
6
6
  import { Button } from '@open-mercato/ui/primitives/button'
7
7
  import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
8
8
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
9
+ import { LoadingMessage, ErrorMessage } from '@open-mercato/ui/backend/detail'
9
10
  import { useT } from '@open-mercato/shared/lib/i18n/context'
11
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
10
12
  import { ArrowLeft, Copy, CheckCircle } from 'lucide-react'
11
13
 
12
14
  const LANGUAGE_KEYS = ['en', 'de', 'es', 'pl'] as const
13
15
 
14
16
  export default function InboxSettingsPage() {
15
17
  const t = useT()
18
+ const { runMutation } = useGuardedMutation<Record<string, unknown>>({
19
+ contextId: 'inbox-ops-settings',
20
+ })
16
21
 
17
22
  const languageOptions = LANGUAGE_KEYS.map((key) => ({
18
23
  value: key,
@@ -20,18 +25,33 @@ export default function InboxSettingsPage() {
20
25
  }))
21
26
  const [settings, setSettings] = React.useState<{ inboxAddress?: string; isActive?: boolean; workingLanguage?: string } | null>(null)
22
27
  const [isLoading, setIsLoading] = React.useState(true)
28
+ const [error, setError] = React.useState<string | null>(null)
23
29
  const [copied, setCopied] = React.useState(false)
24
30
  const [isSavingLanguage, setIsSavingLanguage] = React.useState(false)
25
31
 
26
32
  React.useEffect(() => {
33
+ let cancelled = false
27
34
  async function load() {
28
35
  setIsLoading(true)
29
- const result = await apiCall<{ settings: { inboxAddress?: string; isActive?: boolean; workingLanguage?: string } | null }>('/api/inbox_ops/settings')
30
- if (result?.ok && result.result?.settings) setSettings(result.result.settings)
31
- setIsLoading(false)
36
+ setError(null)
37
+ try {
38
+ const result = await apiCall<{ settings: { inboxAddress?: string; isActive?: boolean; workingLanguage?: string } | null }>('/api/inbox_ops/settings')
39
+ if (!cancelled) {
40
+ if (result?.ok && result.result?.settings) {
41
+ setSettings(result.result.settings)
42
+ } else {
43
+ setError(t('inbox_ops.settings.load_failed', 'Failed to load settings'))
44
+ }
45
+ }
46
+ } catch {
47
+ if (!cancelled) setError(t('inbox_ops.settings.load_failed', 'Failed to load settings'))
48
+ } finally {
49
+ if (!cancelled) setIsLoading(false)
50
+ }
32
51
  }
33
52
  load()
34
- }, [])
53
+ return () => { cancelled = true }
54
+ }, [t])
35
55
 
36
56
  const handleCopy = React.useCallback(() => {
37
57
  if (settings?.inboxAddress) {
@@ -44,9 +64,12 @@ export default function InboxSettingsPage() {
44
64
  const handleLanguageChange = React.useCallback(async (event: React.ChangeEvent<HTMLSelectElement>) => {
45
65
  const workingLanguage = event.target.value
46
66
  setIsSavingLanguage(true)
47
- const result = await apiCall<{ ok: boolean; settings: { workingLanguage: string } }>('/api/inbox_ops/settings', {
48
- method: 'PATCH',
49
- body: JSON.stringify({ workingLanguage }),
67
+ const result = await runMutation({
68
+ operation: () => apiCall<{ ok: boolean; settings: { workingLanguage: string } }>('/api/inbox_ops/settings', {
69
+ method: 'PATCH',
70
+ body: JSON.stringify({ workingLanguage }),
71
+ }),
72
+ context: {},
50
73
  })
51
74
  if (result?.ok && result.result?.ok) {
52
75
  setSettings((prev) => prev ? { ...prev, workingLanguage: result.result!.settings.workingLanguage } : prev)
@@ -55,13 +78,13 @@ export default function InboxSettingsPage() {
55
78
  flash(t('inbox_ops.settings.language_save_failed', 'Failed to update working language'), 'error')
56
79
  }
57
80
  setIsSavingLanguage(false)
58
- }, [t])
81
+ }, [t, runMutation])
59
82
 
60
83
  return (
61
84
  <Page>
62
85
  <div className="flex items-center gap-3 px-3 py-3 md:px-6 md:py-4">
63
86
  <Link href="/backend/inbox-ops">
64
- <Button variant="ghost" size="sm"><ArrowLeft className="h-4 w-4" /></Button>
87
+ <Button type="button" variant="ghost" size="sm"><ArrowLeft className="h-4 w-4" /></Button>
65
88
  </Link>
66
89
  <h1 className="text-lg font-semibold">{t('inbox_ops.settings.title', 'Inbox Settings')}</h1>
67
90
  </div>
@@ -69,10 +92,9 @@ export default function InboxSettingsPage() {
69
92
  <PageBody>
70
93
  <div className="max-w-lg">
71
94
  {isLoading ? (
72
- <div className="animate-pulse space-y-4">
73
- <div className="h-6 bg-muted rounded w-1/3" />
74
- <div className="h-12 bg-muted rounded" />
75
- </div>
95
+ <LoadingMessage label={t('inbox_ops.settings.loading', 'Loading settings...')} />
96
+ ) : error ? (
97
+ <ErrorMessage label={error} />
76
98
  ) : settings ? (
77
99
  <div className="space-y-6">
78
100
  <div>
@@ -86,7 +108,7 @@ export default function InboxSettingsPage() {
86
108
  <div className="flex-1 bg-muted rounded-lg px-4 py-3">
87
109
  <code className="text-sm font-mono">{settings.inboxAddress}</code>
88
110
  </div>
89
- <Button variant="outline" size="sm" className="h-11 md:h-9" onClick={handleCopy}>
111
+ <Button type="button" variant="outline" size="sm" className="h-11 md:h-9" onClick={handleCopy}>
90
112
  {copied ? <CheckCircle className="h-4 w-4 text-green-600" /> : <Copy className="h-4 w-4" />}
91
113
  <span className="ml-1">{copied ? t('inbox_ops.settings.copied', 'Copied') : t('inbox_ops.settings.copy', 'Copy')}</span>
92
114
  </Button>