@open-mercato/core 0.6.6-develop.6229.1.ebe6706732 → 0.6.6-develop.6245.1.2be3b151f8

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 (137) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/commands/users.js +1 -0
  3. package/dist/modules/auth/commands/users.js.map +2 -2
  4. package/dist/modules/communication_channels/data/enrichers.js +9 -1
  5. package/dist/modules/communication_channels/data/enrichers.js.map +2 -2
  6. package/dist/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.client.js +3 -5
  7. package/dist/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.client.js.map +2 -2
  8. package/dist/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.js.map +2 -2
  9. package/dist/modules/customers/api/pipeline-stages/reorder/route.js +39 -1
  10. package/dist/modules/customers/api/pipeline-stages/reorder/route.js.map +2 -2
  11. package/dist/modules/customers/api/pipeline-stages/route.js +107 -12
  12. package/dist/modules/customers/api/pipeline-stages/route.js.map +2 -2
  13. package/dist/modules/customers/api/pipelines/route.js +107 -12
  14. package/dist/modules/customers/api/pipelines/route.js.map +2 -2
  15. package/dist/modules/customers/api/settings/address-format/route.js +33 -1
  16. package/dist/modules/customers/api/settings/address-format/route.js.map +2 -2
  17. package/dist/modules/customers/api/tags/assign/route.js +37 -0
  18. package/dist/modules/customers/api/tags/assign/route.js.map +2 -2
  19. package/dist/modules/customers/api/tags/unassign/route.js +37 -0
  20. package/dist/modules/customers/api/tags/unassign/route.js.map +2 -2
  21. package/dist/modules/customers/components/calendar/CalendarToolbar.js +4 -6
  22. package/dist/modules/customers/components/calendar/CalendarToolbar.js.map +2 -2
  23. package/dist/modules/customers/components/calendar/EventBlock.js +2 -6
  24. package/dist/modules/customers/components/calendar/EventBlock.js.map +2 -2
  25. package/dist/modules/customers/components/calendar/UpcomingCards.js +9 -7
  26. package/dist/modules/customers/components/calendar/UpcomingCards.js.map +2 -2
  27. package/dist/modules/customers/lib/calendar/format.js +27 -0
  28. package/dist/modules/customers/lib/calendar/format.js.map +7 -0
  29. package/dist/modules/entities/api/encryption.js +109 -38
  30. package/dist/modules/entities/api/encryption.js.map +2 -2
  31. package/dist/modules/entities/components/EncryptionManager.js +22 -9
  32. package/dist/modules/entities/components/EncryptionManager.js.map +2 -2
  33. package/dist/modules/integrations/api/[id]/credentials/route.js +23 -1
  34. package/dist/modules/integrations/api/[id]/credentials/route.js.map +2 -2
  35. package/dist/modules/integrations/api/[id]/route.js +10 -3
  36. package/dist/modules/integrations/api/[id]/route.js.map +2 -2
  37. package/dist/modules/integrations/api/[id]/state/route.js +20 -5
  38. package/dist/modules/integrations/api/[id]/state/route.js.map +2 -2
  39. package/dist/modules/integrations/api/[id]/version/route.js +17 -1
  40. package/dist/modules/integrations/api/[id]/version/route.js.map +2 -2
  41. package/dist/modules/integrations/api/route.js +2 -0
  42. package/dist/modules/integrations/api/route.js.map +2 -2
  43. package/dist/modules/integrations/backend/integrations/[id]/page.js +36 -19
  44. package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
  45. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js +13 -9
  46. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js.map +2 -2
  47. package/dist/modules/integrations/backend/integrations/page.js +4 -3
  48. package/dist/modules/integrations/backend/integrations/page.js.map +2 -2
  49. package/dist/modules/integrations/data/entities.js +2 -2
  50. package/dist/modules/integrations/data/entities.js.map +2 -2
  51. package/dist/modules/integrations/lib/credentials-service.js +32 -0
  52. package/dist/modules/integrations/lib/credentials-service.js.map +2 -2
  53. package/dist/modules/integrations/lib/state-service.js +4 -2
  54. package/dist/modules/integrations/lib/state-service.js.map +2 -2
  55. package/dist/modules/notifications/lib/notificationService.js +42 -10
  56. package/dist/modules/notifications/lib/notificationService.js.map +2 -2
  57. package/dist/modules/payment_gateways/api/status/route.js +91 -6
  58. package/dist/modules/payment_gateways/api/status/route.js.map +2 -2
  59. package/dist/modules/payment_gateways/backend/payment-gateways/page.js +25 -11
  60. package/dist/modules/payment_gateways/backend/payment-gateways/page.js.map +2 -2
  61. package/dist/modules/sales/backend/sales/channels/offers/page.js +25 -7
  62. package/dist/modules/sales/backend/sales/channels/offers/page.js.map +2 -2
  63. package/dist/modules/sales/backend/sales/channels/page.js +25 -7
  64. package/dist/modules/sales/backend/sales/channels/page.js.map +2 -2
  65. package/dist/modules/sales/components/AdjustmentKindSettings.js +52 -24
  66. package/dist/modules/sales/components/AdjustmentKindSettings.js.map +2 -2
  67. package/dist/modules/sales/components/DocumentNumberSettings.js +26 -9
  68. package/dist/modules/sales/components/DocumentNumberSettings.js.map +2 -2
  69. package/dist/modules/sales/components/OrderEditingSettings.js +26 -9
  70. package/dist/modules/sales/components/OrderEditingSettings.js.map +2 -2
  71. package/dist/modules/sales/components/StatusSettings.js +66 -31
  72. package/dist/modules/sales/components/StatusSettings.js.map +2 -2
  73. package/dist/modules/sales/components/channels/SalesChannelOffersPanel.js +25 -7
  74. package/dist/modules/sales/components/channels/SalesChannelOffersPanel.js.map +2 -2
  75. package/dist/modules/shipping_carriers/api/cancel/route.js +38 -0
  76. package/dist/modules/shipping_carriers/api/cancel/route.js.map +2 -2
  77. package/dist/modules/shipping_carriers/api/shipments/route.js +12 -5
  78. package/dist/modules/shipping_carriers/api/shipments/route.js.map +2 -2
  79. package/dist/modules/staff/lib/timesheets-ui/TimerBar.js +46 -13
  80. package/dist/modules/staff/lib/timesheets-ui/TimerBar.js.map +2 -2
  81. package/dist/modules/staff/lib/timesheets-ui/timerErrors.js +12 -0
  82. package/dist/modules/staff/lib/timesheets-ui/timerErrors.js.map +7 -0
  83. package/dist/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.js +43 -8
  84. package/dist/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.js.map +2 -2
  85. package/dist/modules/workflows/backend/definitions/page.js +19 -10
  86. package/dist/modules/workflows/backend/definitions/page.js.map +2 -2
  87. package/package.json +7 -7
  88. package/src/modules/auth/commands/users.ts +1 -0
  89. package/src/modules/communication_channels/data/enrichers.ts +25 -0
  90. package/src/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.client.tsx +7 -6
  91. package/src/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.ts +5 -2
  92. package/src/modules/customers/api/pipeline-stages/reorder/route.ts +41 -1
  93. package/src/modules/customers/api/pipeline-stages/route.ts +112 -13
  94. package/src/modules/customers/api/pipelines/route.ts +112 -13
  95. package/src/modules/customers/api/settings/address-format/route.ts +36 -1
  96. package/src/modules/customers/api/tags/assign/route.ts +39 -0
  97. package/src/modules/customers/api/tags/unassign/route.ts +39 -0
  98. package/src/modules/customers/components/calendar/CalendarToolbar.tsx +4 -7
  99. package/src/modules/customers/components/calendar/EventBlock.tsx +2 -6
  100. package/src/modules/customers/components/calendar/UpcomingCards.tsx +9 -7
  101. package/src/modules/customers/i18n/de.json +1 -0
  102. package/src/modules/customers/i18n/en.json +1 -0
  103. package/src/modules/customers/i18n/es.json +1 -0
  104. package/src/modules/customers/i18n/pl.json +1 -0
  105. package/src/modules/customers/lib/calendar/format.ts +25 -0
  106. package/src/modules/entities/api/encryption.ts +122 -39
  107. package/src/modules/entities/components/EncryptionManager.tsx +28 -9
  108. package/src/modules/integrations/api/[id]/credentials/route.ts +23 -0
  109. package/src/modules/integrations/api/[id]/route.ts +8 -1
  110. package/src/modules/integrations/api/[id]/state/route.ts +20 -4
  111. package/src/modules/integrations/api/[id]/version/route.ts +18 -1
  112. package/src/modules/integrations/api/route.ts +3 -0
  113. package/src/modules/integrations/backend/integrations/[id]/page.tsx +39 -20
  114. package/src/modules/integrations/backend/integrations/bundle/[id]/page.tsx +19 -11
  115. package/src/modules/integrations/backend/integrations/page.tsx +8 -5
  116. package/src/modules/integrations/data/entities.ts +2 -2
  117. package/src/modules/integrations/lib/credentials-service.ts +35 -0
  118. package/src/modules/integrations/lib/state-service.ts +3 -0
  119. package/src/modules/notifications/lib/notificationService.ts +74 -11
  120. package/src/modules/payment_gateways/api/status/route.ts +97 -5
  121. package/src/modules/payment_gateways/backend/payment-gateways/page.tsx +27 -11
  122. package/src/modules/sales/backend/sales/channels/offers/page.tsx +31 -7
  123. package/src/modules/sales/backend/sales/channels/page.tsx +31 -7
  124. package/src/modules/sales/components/AdjustmentKindSettings.tsx +60 -24
  125. package/src/modules/sales/components/DocumentNumberSettings.tsx +32 -10
  126. package/src/modules/sales/components/OrderEditingSettings.tsx +32 -10
  127. package/src/modules/sales/components/StatusSettings.tsx +74 -33
  128. package/src/modules/sales/components/channels/SalesChannelOffersPanel.tsx +30 -6
  129. package/src/modules/shipping_carriers/api/cancel/route.ts +46 -0
  130. package/src/modules/shipping_carriers/api/shipments/route.ts +21 -6
  131. package/src/modules/staff/i18n/de.json +5 -5
  132. package/src/modules/staff/i18n/es.json +5 -5
  133. package/src/modules/staff/i18n/pl.json +5 -5
  134. package/src/modules/staff/lib/timesheets-ui/TimerBar.tsx +57 -13
  135. package/src/modules/staff/lib/timesheets-ui/timerErrors.ts +23 -0
  136. package/src/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.tsx +57 -9
  137. package/src/modules/workflows/backend/definitions/page.tsx +19 -10
@@ -10,6 +10,17 @@ import type { ShippingCarrierService } from '../../lib/shipping-service'
10
10
  import { createShipmentSchema } from '../../data/validators'
11
11
  import { shippingCarriersTag } from '../openapi'
12
12
 
13
+ function resolveGuardUserId(auth: {
14
+ sub?: string | null
15
+ userId?: string | null
16
+ keyId?: string | null
17
+ }): string {
18
+ if (typeof auth.sub === 'string' && auth.sub.trim().length > 0) return auth.sub
19
+ if (typeof auth.userId === 'string' && auth.userId.trim().length > 0) return auth.userId
20
+ if (typeof auth.keyId === 'string' && auth.keyId.trim().length > 0) return auth.keyId
21
+ return 'system'
22
+ }
23
+
13
24
  export const metadata = {
14
25
  path: '/shipping-carriers/shipments',
15
26
  POST: { requireAuth: true, requireFeatures: ['shipping_carriers.manage'] },
@@ -26,40 +37,44 @@ export async function POST(req: Request) {
26
37
  return NextResponse.json({ error: 'Invalid payload', details: parsed.error.flatten() }, { status: 422 })
27
38
  }
28
39
  const container = await createRequestContainer()
29
- const service = container.resolve('shippingCarrierService') as ShippingCarrierService
40
+ const guardUserId = resolveGuardUserId(auth)
30
41
  const guardResult = await validateCrudMutationGuard(container, {
31
42
  tenantId: auth.tenantId,
32
43
  organizationId: auth.orgId,
33
- userId: auth.sub ?? '',
44
+ userId: guardUserId,
34
45
  resourceKind: 'shipping_carriers.shipment',
35
46
  resourceId: parsed.data.orderId,
36
47
  operation: 'create',
37
48
  requestMethod: req.method,
38
49
  requestHeaders: req.headers,
39
- mutationPayload: parsed.data as unknown as Record<string, unknown>,
50
+ mutationPayload: parsed.data as Record<string, unknown>,
40
51
  })
41
52
  if (guardResult && !guardResult.ok) {
42
53
  return NextResponse.json(guardResult.body, { status: guardResult.status })
43
54
  }
55
+
56
+ const service = container.resolve('shippingCarrierService') as ShippingCarrierService
44
57
  try {
45
58
  const shipment = await service.createShipment({
46
59
  ...parsed.data,
47
60
  organizationId: auth.orgId as string,
48
61
  tenantId: auth.tenantId,
49
62
  })
50
- if (guardResult?.shouldRunAfterSuccess) {
63
+
64
+ if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
51
65
  await runCrudMutationGuardAfterSuccess(container, {
52
66
  tenantId: auth.tenantId,
53
67
  organizationId: auth.orgId,
54
- userId: auth.sub ?? '',
68
+ userId: guardUserId,
55
69
  resourceKind: 'shipping_carriers.shipment',
56
- resourceId: shipment.id,
70
+ resourceId: parsed.data.orderId,
57
71
  operation: 'create',
58
72
  requestMethod: req.method,
59
73
  requestHeaders: req.headers,
60
74
  metadata: guardResult.metadata ?? null,
61
75
  })
62
76
  }
77
+
63
78
  return NextResponse.json({
64
79
  shipmentId: shipment.id,
65
80
  carrierShipmentId: shipment.carrierShipmentId,
@@ -1002,9 +1002,9 @@
1002
1002
  "staff.timesheets.my.timer.searchProject": "Search projects...",
1003
1003
  "staff.timesheets.my.timer.selectProject": "Project",
1004
1004
  "staff.timesheets.my.timer.start": "Start timer",
1005
- "staff.timesheets.my.timer.startError": "Failed to start timer",
1005
+ "staff.timesheets.my.timer.startError": "Timer konnte nicht gestartet werden",
1006
1006
  "staff.timesheets.my.timer.stop": "Stop timer",
1007
- "staff.timesheets.my.timer.stopError": "Failed to stop timer",
1007
+ "staff.timesheets.my.timer.stopError": "Timer konnte nicht gestoppt werden",
1008
1008
  "staff.timesheets.my.timerStart": "Start Timer",
1009
1009
  "staff.timesheets.my.timerStop": "Stop Timer",
1010
1010
  "staff.timesheets.my.total": "Total",
@@ -1143,7 +1143,7 @@
1143
1143
  "staff.timesheets.widgets.hoursByProject.loading": "Loading...",
1144
1144
  "staff.timesheets.widgets.hoursByProject.settings.dateRange": "Date Range",
1145
1145
  "staff.timesheets.widgets.hoursByProject.total": "Total",
1146
- "staff.timesheets.widgets.timeReporting.error": "Failed to load timer state",
1146
+ "staff.timesheets.widgets.timeReporting.error": "Timer-Status konnte nicht geladen werden",
1147
1147
  "staff.timesheets.widgets.timeReporting.loading": "Loading...",
1148
1148
  "staff.timesheets.widgets.timeReporting.noProjects": "No projects assigned.",
1149
1149
  "staff.timesheets.widgets.timeReporting.notRunning": "Not running",
@@ -1153,9 +1153,9 @@
1153
1153
  "staff.timesheets.widgets.timeReporting.selectProject": "Select project...",
1154
1154
  "staff.timesheets.widgets.timeReporting.settings.description": "No additional settings. Select a project and start tracking from the widget.",
1155
1155
  "staff.timesheets.widgets.timeReporting.start": "Start Timer",
1156
- "staff.timesheets.widgets.timeReporting.startError": "Failed to start timer",
1156
+ "staff.timesheets.widgets.timeReporting.startError": "Timer konnte nicht gestartet werden",
1157
1157
  "staff.timesheets.widgets.timeReporting.stop": "Stop Timer",
1158
- "staff.timesheets.widgets.timeReporting.stopError": "Failed to stop timer",
1158
+ "staff.timesheets.widgets.timeReporting.stopError": "Timer konnte nicht gestoppt werden",
1159
1159
  "staff.timesheets.widgets.timeReporting.taskNote": "Task / Note",
1160
1160
  "staff.timesheets.widgets.timeReporting.unknownProject": "Unknown project"
1161
1161
  }
@@ -1002,9 +1002,9 @@
1002
1002
  "staff.timesheets.my.timer.searchProject": "Search projects...",
1003
1003
  "staff.timesheets.my.timer.selectProject": "Project",
1004
1004
  "staff.timesheets.my.timer.start": "Start timer",
1005
- "staff.timesheets.my.timer.startError": "Failed to start timer",
1005
+ "staff.timesheets.my.timer.startError": "No se pudo iniciar el temporizador",
1006
1006
  "staff.timesheets.my.timer.stop": "Stop timer",
1007
- "staff.timesheets.my.timer.stopError": "Failed to stop timer",
1007
+ "staff.timesheets.my.timer.stopError": "No se pudo detener el temporizador",
1008
1008
  "staff.timesheets.my.timerStart": "Start Timer",
1009
1009
  "staff.timesheets.my.timerStop": "Stop Timer",
1010
1010
  "staff.timesheets.my.total": "Total",
@@ -1143,7 +1143,7 @@
1143
1143
  "staff.timesheets.widgets.hoursByProject.loading": "Loading...",
1144
1144
  "staff.timesheets.widgets.hoursByProject.settings.dateRange": "Date Range",
1145
1145
  "staff.timesheets.widgets.hoursByProject.total": "Total",
1146
- "staff.timesheets.widgets.timeReporting.error": "Failed to load timer state",
1146
+ "staff.timesheets.widgets.timeReporting.error": "No se pudo cargar el estado del temporizador",
1147
1147
  "staff.timesheets.widgets.timeReporting.loading": "Loading...",
1148
1148
  "staff.timesheets.widgets.timeReporting.noProjects": "No projects assigned.",
1149
1149
  "staff.timesheets.widgets.timeReporting.notRunning": "Not running",
@@ -1153,9 +1153,9 @@
1153
1153
  "staff.timesheets.widgets.timeReporting.selectProject": "Select project...",
1154
1154
  "staff.timesheets.widgets.timeReporting.settings.description": "No additional settings. Select a project and start tracking from the widget.",
1155
1155
  "staff.timesheets.widgets.timeReporting.start": "Start Timer",
1156
- "staff.timesheets.widgets.timeReporting.startError": "Failed to start timer",
1156
+ "staff.timesheets.widgets.timeReporting.startError": "No se pudo iniciar el temporizador",
1157
1157
  "staff.timesheets.widgets.timeReporting.stop": "Stop Timer",
1158
- "staff.timesheets.widgets.timeReporting.stopError": "Failed to stop timer",
1158
+ "staff.timesheets.widgets.timeReporting.stopError": "No se pudo detener el temporizador",
1159
1159
  "staff.timesheets.widgets.timeReporting.taskNote": "Task / Note",
1160
1160
  "staff.timesheets.widgets.timeReporting.unknownProject": "Unknown project"
1161
1161
  }
@@ -1002,9 +1002,9 @@
1002
1002
  "staff.timesheets.my.timer.searchProject": "Search projects...",
1003
1003
  "staff.timesheets.my.timer.selectProject": "Project",
1004
1004
  "staff.timesheets.my.timer.start": "Start timer",
1005
- "staff.timesheets.my.timer.startError": "Failed to start timer",
1005
+ "staff.timesheets.my.timer.startError": "Nie udało się uruchomić licznika czasu",
1006
1006
  "staff.timesheets.my.timer.stop": "Stop timer",
1007
- "staff.timesheets.my.timer.stopError": "Failed to stop timer",
1007
+ "staff.timesheets.my.timer.stopError": "Nie udało się zatrzymać licznika czasu",
1008
1008
  "staff.timesheets.my.timerStart": "Start Timer",
1009
1009
  "staff.timesheets.my.timerStop": "Stop Timer",
1010
1010
  "staff.timesheets.my.total": "Total",
@@ -1143,7 +1143,7 @@
1143
1143
  "staff.timesheets.widgets.hoursByProject.loading": "Loading...",
1144
1144
  "staff.timesheets.widgets.hoursByProject.settings.dateRange": "Date Range",
1145
1145
  "staff.timesheets.widgets.hoursByProject.total": "Total",
1146
- "staff.timesheets.widgets.timeReporting.error": "Failed to load timer state",
1146
+ "staff.timesheets.widgets.timeReporting.error": "Nie udało się wczytać stanu licznika czasu",
1147
1147
  "staff.timesheets.widgets.timeReporting.loading": "Loading...",
1148
1148
  "staff.timesheets.widgets.timeReporting.noProjects": "No projects assigned.",
1149
1149
  "staff.timesheets.widgets.timeReporting.notRunning": "Not running",
@@ -1153,9 +1153,9 @@
1153
1153
  "staff.timesheets.widgets.timeReporting.selectProject": "Select project...",
1154
1154
  "staff.timesheets.widgets.timeReporting.settings.description": "No additional settings. Select a project and start tracking from the widget.",
1155
1155
  "staff.timesheets.widgets.timeReporting.start": "Start Timer",
1156
- "staff.timesheets.widgets.timeReporting.startError": "Failed to start timer",
1156
+ "staff.timesheets.widgets.timeReporting.startError": "Nie udało się uruchomić licznika czasu",
1157
1157
  "staff.timesheets.widgets.timeReporting.stop": "Stop Timer",
1158
- "staff.timesheets.widgets.timeReporting.stopError": "Failed to stop timer",
1158
+ "staff.timesheets.widgets.timeReporting.stopError": "Nie udało się zatrzymać licznika czasu",
1159
1159
  "staff.timesheets.widgets.timeReporting.taskNote": "Task / Note",
1160
1160
  "staff.timesheets.widgets.timeReporting.unknownProject": "Unknown project"
1161
1161
  }
@@ -7,8 +7,10 @@ import { IconButton } from '@open-mercato/ui/primitives/icon-button'
7
7
  import { useT } from '@open-mercato/shared/lib/i18n/context'
8
8
  import { apiCall, apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
9
9
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
10
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
10
11
  import { ProjectColorDot } from './ProjectColorDot'
11
12
  import { startTimerEntry } from './startTimer'
13
+ import { resolveTimerActionError } from './timerErrors'
12
14
 
13
15
  type ProjectOption = {
14
16
  id: string
@@ -23,6 +25,17 @@ type TimerBarProps = {
23
25
  onTimerStopped: () => void
24
26
  }
25
27
 
28
+ const TIMER_MUTATION_CONTEXT_ID = 'staff-timesheets-timer-bar'
29
+
30
+ type TimerMutationContext = {
31
+ formId: string
32
+ resourceKind: string
33
+ resourceId: string
34
+ staffMemberId: string | null
35
+ action: 'timer-create' | 'timer-start' | 'timer-stop'
36
+ retryLastMutation: () => Promise<boolean>
37
+ }
38
+
26
39
  function formatElapsed(seconds: number): string {
27
40
  const h = Math.floor(seconds / 3600)
28
41
  const m = Math.floor((seconds % 3600) / 60)
@@ -40,6 +53,10 @@ function getToday(): string {
40
53
 
41
54
  export function TimerBar({ projects, staffMemberId, onTimerStopped }: TimerBarProps) {
42
55
  const t = useT()
56
+ const { runMutation, retryLastMutation } = useGuardedMutation<TimerMutationContext>({
57
+ contextId: TIMER_MUTATION_CONTEXT_ID,
58
+ blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
59
+ })
43
60
 
44
61
  const [activeEntryId, setActiveEntryId] = useState<string | null>(null)
45
62
  const [activeProjectId, setActiveProjectId] = useState<string | null>(null)
@@ -141,19 +158,31 @@ export function TimerBar({ projects, staffMemberId, onTimerStopped }: TimerBarPr
141
158
  setIsStarting(true)
142
159
  try {
143
160
  const today = getToday()
144
- const { id: entryId } = await startTimerEntry({
161
+ const startPayload = {
145
162
  staffMemberId,
146
163
  timeProjectId: selectedProjectId,
147
164
  date: today,
148
165
  notes: description || null,
166
+ }
167
+ const { id: entryId } = await runMutation({
168
+ operation: () => startTimerEntry(startPayload),
169
+ context: {
170
+ formId: TIMER_MUTATION_CONTEXT_ID,
171
+ resourceKind: 'staff.timesheets.time_entry',
172
+ resourceId: staffMemberId,
173
+ staffMemberId,
174
+ action: 'timer-start',
175
+ retryLastMutation,
176
+ },
177
+ mutationPayload: startPayload,
149
178
  })
150
179
 
151
180
  setActiveEntryId(entryId)
152
181
  setActiveProjectId(selectedProjectId)
153
182
  startElapsedCounter(new Date().toISOString())
154
- } catch {
183
+ } catch (err) {
155
184
  flash(
156
- t('staff.timesheets.my.timer.startError', 'Failed to start timer'),
185
+ resolveTimerActionError(err, t('staff.timesheets.my.timer.startError', 'Failed to start timer')),
157
186
  'error',
158
187
  )
159
188
  } finally {
@@ -166,19 +195,36 @@ export function TimerBar({ projects, staffMemberId, onTimerStopped }: TimerBarPr
166
195
 
167
196
  setIsStopping(true)
168
197
  try {
169
- await apiCallOrThrow(
170
- `/api/staff/timesheets/time-entries/${activeEntryId}/timer-stop`,
171
- { method: 'POST' },
172
- )
198
+ const stopPayload = {
199
+ id: activeEntryId,
200
+ action: 'timer-stop',
201
+ staffMemberId,
202
+ }
203
+ await runMutation({
204
+ operation: () =>
205
+ apiCallOrThrow(
206
+ `/api/staff/timesheets/time-entries/${activeEntryId}/timer-stop`,
207
+ { method: 'POST' },
208
+ ),
209
+ context: {
210
+ formId: TIMER_MUTATION_CONTEXT_ID,
211
+ resourceKind: 'staff.timesheets.time_entry',
212
+ resourceId: activeEntryId,
213
+ staffMemberId,
214
+ action: 'timer-stop',
215
+ retryLastMutation,
216
+ },
217
+ mutationPayload: stopPayload,
218
+ })
173
219
 
174
220
  setActiveEntryId(null)
175
221
  setActiveProjectId(null)
176
222
  setDescription('')
177
223
  stopElapsedCounter()
178
224
  onTimerStopped()
179
- } catch {
225
+ } catch (err) {
180
226
  flash(
181
- t('staff.timesheets.my.timer.stopError', 'Failed to stop timer'),
227
+ resolveTimerActionError(err, t('staff.timesheets.my.timer.stopError', 'Failed to stop timer')),
182
228
  'error',
183
229
  )
184
230
  } finally {
@@ -284,11 +330,10 @@ export function TimerBar({ projects, staffMemberId, onTimerStopped }: TimerBarPr
284
330
  {isRunning ? (
285
331
  <IconButton
286
332
  type="button"
287
- variant="outline"
333
+ variant="destructive"
288
334
  size="default"
289
335
  onClick={handleStop}
290
336
  disabled={isStopping}
291
- className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
292
337
  aria-label={t('staff.timesheets.my.timer.stop', 'Stop timer')}
293
338
  >
294
339
  <Square className="size-4" />
@@ -296,11 +341,10 @@ export function TimerBar({ projects, staffMemberId, onTimerStopped }: TimerBarPr
296
341
  ) : (
297
342
  <IconButton
298
343
  type="button"
299
- variant="outline"
344
+ variant="primary"
300
345
  size="default"
301
346
  onClick={handleStart}
302
347
  disabled={isStarting || !selectedProjectId}
303
- className="bg-primary text-primary-foreground hover:bg-primary/90"
304
348
  aria-label={t('staff.timesheets.my.timer.start', 'Start timer')}
305
349
  >
306
350
  <Play className="size-4" />
@@ -0,0 +1,23 @@
1
+ import { normalizeCrudServerError } from '@open-mercato/ui/backend/utils/serverErrors'
2
+
3
+ /**
4
+ * Resolves the message to show when a timesheet timer start/stop request
5
+ * fails. Prefers the localized message the server already returned (e.g. the
6
+ * 409 "another timer is already running" reason, surfaced by the atomic
7
+ * start-timer route) over a generic client-side fallback. Both the TimerBar
8
+ * and the dashboard time-reporting widget use this so a rejected timer action
9
+ * shows the specific server reason in the active locale instead of a hardcoded
10
+ * English string (issue #3507).
11
+ *
12
+ * The server message is only trusted when the error originated from an HTTP
13
+ * response (`raiseCrudError` attaches a numeric `status`); transport-level
14
+ * failures (no response) fall back to the localized fallback so the user never
15
+ * sees an untranslated network error string.
16
+ */
17
+ export function resolveTimerActionError(err: unknown, fallback: string): string {
18
+ const status = (err as { status?: unknown } | null)?.status
19
+ if (typeof status !== 'number') return fallback
20
+ const message = normalizeCrudServerError(err).message
21
+ const trimmed = typeof message === 'string' ? message.trim() : ''
22
+ return trimmed.length > 0 ? trimmed : fallback
23
+ }
@@ -6,7 +6,9 @@ import { apiCall, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/ap
6
6
  import { Button } from '@open-mercato/ui/primitives/button'
7
7
  import { Input } from '@open-mercato/ui/primitives/input'
8
8
  import { useT } from '@open-mercato/shared/lib/i18n/context'
9
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
9
10
  import { startTimerEntry } from '../../../lib/timesheets-ui/startTimer'
11
+ import { resolveTimerActionError } from '../../../lib/timesheets-ui/timerErrors'
10
12
  import { DEFAULT_SETTINGS, hydrateSettings, type TimeReportingSettings } from './config'
11
13
 
12
14
  type ProjectOption = { id: string; name: string; code: string | null }
@@ -18,6 +20,17 @@ type TimerState = {
18
20
  projectId: string | null
19
21
  }
20
22
 
23
+ const TIMER_WIDGET_MUTATION_CONTEXT_ID = 'staff-timesheets-time-reporting-widget'
24
+
25
+ type TimerWidgetMutationContext = {
26
+ formId: string
27
+ resourceKind: string
28
+ resourceId: string
29
+ staffMemberId: string
30
+ action: 'timer-create' | 'timer-start' | 'timer-stop'
31
+ retryLastMutation: () => Promise<boolean>
32
+ }
33
+
21
34
  function formatElapsed(startedAt: string): string {
22
35
  const elapsed = Math.max(0, Date.now() - new Date(startedAt).getTime())
23
36
  const totalSeconds = Math.floor(elapsed / 1000)
@@ -35,6 +48,10 @@ const TimeReportingWidget: React.FC<DashboardWidgetComponentProps<TimeReportingS
35
48
  onRefreshStateChange,
36
49
  }) => {
37
50
  const t = useT()
51
+ const { runMutation, retryLastMutation } = useGuardedMutation<TimerWidgetMutationContext>({
52
+ contextId: TIMER_WIDGET_MUTATION_CONTEXT_ID,
53
+ blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
54
+ })
38
55
  const hydrated = React.useMemo(() => hydrateSettings(settings), [settings])
39
56
 
40
57
  const [projects, setProjects] = React.useState<ProjectOption[]>([])
@@ -141,37 +158,68 @@ const TimeReportingWidget: React.FC<DashboardWidgetComponentProps<TimeReportingS
141
158
  const today = new Date().toISOString().slice(0, 10)
142
159
  // Single atomic create+start request (issue #3311): a partial failure can
143
160
  // no longer leave an orphaned, unstarted timer entry, and a rejected start
144
- // now surfaces here instead of being silently ignored.
145
- await startTimerEntry({
161
+ // now surfaces here instead of being silently ignored. The call is routed
162
+ // through the mutation guard (issue #3308) so global injection modules can
163
+ // run onBeforeSave/onAfterSave and surface conflicts consistently.
164
+ const startPayload = {
146
165
  staffMemberId,
147
166
  timeProjectId: selectedProjectId,
148
167
  date: today,
149
168
  notes: notes.trim() || null,
169
+ }
170
+ await runMutation({
171
+ operation: () => startTimerEntry(startPayload),
172
+ context: {
173
+ formId: TIMER_WIDGET_MUTATION_CONTEXT_ID,
174
+ resourceKind: 'staff.timesheets.time_entry',
175
+ resourceId: staffMemberId,
176
+ staffMemberId,
177
+ action: 'timer-start',
178
+ retryLastMutation,
179
+ },
180
+ mutationPayload: startPayload,
150
181
  })
151
182
 
152
183
  onSettingsChange({ ...hydrated, lastProjectId: selectedProjectId })
153
184
  await loadState()
154
185
  } catch (err) {
155
186
  console.error('staff.timesheets.timeReporting.start', err)
156
- setError(t('staff.timesheets.widgets.timeReporting.startError', 'Failed to start timer'))
187
+ setError(resolveTimerActionError(err, t('staff.timesheets.widgets.timeReporting.startError', 'Failed to start timer')))
157
188
  } finally {
158
189
  setActionLoading(false)
159
190
  }
160
- }, [selectedProjectId, staffMemberId, timer.running, notes, hydrated, onSettingsChange, loadState, t])
191
+ }, [selectedProjectId, staffMemberId, timer.running, notes, hydrated, onSettingsChange, loadState, runMutation, retryLastMutation, t])
161
192
 
162
193
  const handleStop = React.useCallback(async () => {
163
- if (!timer.entryId) return
194
+ if (!timer.entryId || !staffMemberId) return
164
195
  setActionLoading(true)
165
196
  try {
166
- await apiCall(`/api/staff/timesheets/time-entries/${timer.entryId}/timer-stop`, { method: 'POST' })
197
+ const stopPayload = {
198
+ id: timer.entryId,
199
+ action: 'timer-stop',
200
+ staffMemberId,
201
+ }
202
+ await runMutation({
203
+ operation: () =>
204
+ apiCall(`/api/staff/timesheets/time-entries/${timer.entryId}/timer-stop`, { method: 'POST' }),
205
+ context: {
206
+ formId: TIMER_WIDGET_MUTATION_CONTEXT_ID,
207
+ resourceKind: 'staff.timesheets.time_entry',
208
+ resourceId: timer.entryId,
209
+ staffMemberId,
210
+ action: 'timer-stop',
211
+ retryLastMutation,
212
+ },
213
+ mutationPayload: stopPayload,
214
+ })
167
215
  await loadState()
168
216
  } catch (err) {
169
217
  console.error('staff.timesheets.timeReporting.stop', err)
170
- setError(t('staff.timesheets.widgets.timeReporting.stopError', 'Failed to stop timer'))
218
+ setError(resolveTimerActionError(err, t('staff.timesheets.widgets.timeReporting.stopError', 'Failed to stop timer')))
171
219
  } finally {
172
220
  setActionLoading(false)
173
221
  }
174
- }, [timer.entryId, loadState, t])
222
+ }, [timer.entryId, staffMemberId, loadState, runMutation, retryLastMutation, t])
175
223
 
176
224
  if (mode === 'settings') {
177
225
  return (
@@ -194,7 +242,7 @@ const TimeReportingWidget: React.FC<DashboardWidgetComponentProps<TimeReportingS
194
242
  if (error) {
195
243
  return (
196
244
  <div className="flex h-full items-center justify-center py-8">
197
- <p className="text-sm text-destructive">{error}</p>
245
+ <p role="alert" className="text-sm text-destructive">{error}</p>
198
246
  </div>
199
247
  )
200
248
  }
@@ -153,20 +153,29 @@ export default function WorkflowDefinitionsListPage() {
153
153
  setDeleteTarget(null)
154
154
  }
155
155
 
156
- const handleToggleEnabled = async (id: string, currentEnabled: boolean) => {
157
- const result = await apiCall(`/api/workflows/definitions/${id}`, {
158
- method: 'PUT',
159
- headers: { 'Content-Type': 'application/json' },
160
- body: JSON.stringify({
161
- enabled: !currentEnabled,
156
+ const handleToggleEnabled = async (id: string, currentEnabled: boolean, updatedAt: string | null) => {
157
+ const result = await withScopedApiRequestHeaders(
158
+ buildOptimisticLockHeader(updatedAt),
159
+ () => apiCall(`/api/workflows/definitions/${id}`, {
160
+ method: 'PUT',
161
+ headers: { 'Content-Type': 'application/json' },
162
+ body: JSON.stringify({
163
+ enabled: !currentEnabled,
164
+ }),
162
165
  }),
163
- })
166
+ )
164
167
 
165
168
  if (result.ok) {
166
169
  flash(t('workflows.messages.updated'), 'success')
167
170
  queryClient.invalidateQueries({ queryKey: ['workflow-definitions'] })
168
171
  } else {
169
- flash(t('workflows.messages.updateFailed'), 'error')
172
+ const conflictError = Object.assign(new Error(t('workflows.messages.updateFailed')), {
173
+ status: result.status,
174
+ ...(result.result && typeof result.result === 'object' ? result.result : {}),
175
+ })
176
+ if (!surfaceRecordConflict(conflictError, t)) {
177
+ flash(t('workflows.messages.updateFailed'), 'error')
178
+ }
170
179
  }
171
180
  }
172
181
 
@@ -296,7 +305,7 @@ export default function WorkflowDefinitionsListPage() {
296
305
  accessorKey: 'enabled',
297
306
  cell: ({ row }) => (
298
307
  <button
299
- onClick={() => handleToggleEnabled(row.original.id, row.original.enabled)}
308
+ onClick={() => handleToggleEnabled(row.original.id, row.original.enabled, row.original.updatedAt)}
300
309
  className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium cursor-pointer ${
301
310
  row.original.enabled
302
311
  ? 'bg-status-success-bg text-status-success-text hover:bg-status-success-border'
@@ -356,7 +365,7 @@ export default function WorkflowDefinitionsListPage() {
356
365
  ...(!isCodeOnly ? [{
357
366
  id: row.original.enabled ? 'disable' : 'enable',
358
367
  label: row.original.enabled ? t('common.disable') : t('common.enable'),
359
- onSelect: () => handleToggleEnabled(row.original.id, row.original.enabled),
368
+ onSelect: () => handleToggleEnabled(row.original.id, row.original.enabled, row.original.updatedAt),
360
369
  }] : []),
361
370
  ...(!isCodeOnly ? [{
362
371
  id: 'duplicate',