@open-mercato/core 0.6.6-develop.6229.1.ebe6706732 → 0.6.6-develop.6241.1.47c01ad4ed

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 (125) 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/entities/api/encryption.js +109 -38
  22. package/dist/modules/entities/api/encryption.js.map +2 -2
  23. package/dist/modules/entities/components/EncryptionManager.js +22 -9
  24. package/dist/modules/entities/components/EncryptionManager.js.map +2 -2
  25. package/dist/modules/integrations/api/[id]/credentials/route.js +23 -1
  26. package/dist/modules/integrations/api/[id]/credentials/route.js.map +2 -2
  27. package/dist/modules/integrations/api/[id]/route.js +10 -3
  28. package/dist/modules/integrations/api/[id]/route.js.map +2 -2
  29. package/dist/modules/integrations/api/[id]/state/route.js +20 -5
  30. package/dist/modules/integrations/api/[id]/state/route.js.map +2 -2
  31. package/dist/modules/integrations/api/[id]/version/route.js +17 -1
  32. package/dist/modules/integrations/api/[id]/version/route.js.map +2 -2
  33. package/dist/modules/integrations/api/route.js +2 -0
  34. package/dist/modules/integrations/api/route.js.map +2 -2
  35. package/dist/modules/integrations/backend/integrations/[id]/page.js +36 -19
  36. package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
  37. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js +13 -9
  38. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js.map +2 -2
  39. package/dist/modules/integrations/backend/integrations/page.js +4 -3
  40. package/dist/modules/integrations/backend/integrations/page.js.map +2 -2
  41. package/dist/modules/integrations/data/entities.js +2 -2
  42. package/dist/modules/integrations/data/entities.js.map +2 -2
  43. package/dist/modules/integrations/lib/credentials-service.js +32 -0
  44. package/dist/modules/integrations/lib/credentials-service.js.map +2 -2
  45. package/dist/modules/integrations/lib/state-service.js +4 -2
  46. package/dist/modules/integrations/lib/state-service.js.map +2 -2
  47. package/dist/modules/notifications/lib/notificationService.js +42 -10
  48. package/dist/modules/notifications/lib/notificationService.js.map +2 -2
  49. package/dist/modules/payment_gateways/api/status/route.js +91 -6
  50. package/dist/modules/payment_gateways/api/status/route.js.map +2 -2
  51. package/dist/modules/payment_gateways/backend/payment-gateways/page.js +25 -11
  52. package/dist/modules/payment_gateways/backend/payment-gateways/page.js.map +2 -2
  53. package/dist/modules/sales/backend/sales/channels/offers/page.js +25 -7
  54. package/dist/modules/sales/backend/sales/channels/offers/page.js.map +2 -2
  55. package/dist/modules/sales/backend/sales/channels/page.js +25 -7
  56. package/dist/modules/sales/backend/sales/channels/page.js.map +2 -2
  57. package/dist/modules/sales/components/AdjustmentKindSettings.js +52 -24
  58. package/dist/modules/sales/components/AdjustmentKindSettings.js.map +2 -2
  59. package/dist/modules/sales/components/DocumentNumberSettings.js +26 -9
  60. package/dist/modules/sales/components/DocumentNumberSettings.js.map +2 -2
  61. package/dist/modules/sales/components/OrderEditingSettings.js +26 -9
  62. package/dist/modules/sales/components/OrderEditingSettings.js.map +2 -2
  63. package/dist/modules/sales/components/StatusSettings.js +66 -31
  64. package/dist/modules/sales/components/StatusSettings.js.map +2 -2
  65. package/dist/modules/sales/components/channels/SalesChannelOffersPanel.js +25 -7
  66. package/dist/modules/sales/components/channels/SalesChannelOffersPanel.js.map +2 -2
  67. package/dist/modules/shipping_carriers/api/cancel/route.js +38 -0
  68. package/dist/modules/shipping_carriers/api/cancel/route.js.map +2 -2
  69. package/dist/modules/shipping_carriers/api/shipments/route.js +12 -5
  70. package/dist/modules/shipping_carriers/api/shipments/route.js.map +2 -2
  71. package/dist/modules/staff/lib/timesheets-ui/TimerBar.js +7 -8
  72. package/dist/modules/staff/lib/timesheets-ui/TimerBar.js.map +2 -2
  73. package/dist/modules/staff/lib/timesheets-ui/timerErrors.js +12 -0
  74. package/dist/modules/staff/lib/timesheets-ui/timerErrors.js.map +7 -0
  75. package/dist/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.js +4 -3
  76. package/dist/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.js.map +2 -2
  77. package/dist/modules/workflows/backend/definitions/page.js +19 -10
  78. package/dist/modules/workflows/backend/definitions/page.js.map +2 -2
  79. package/package.json +7 -7
  80. package/src/modules/auth/commands/users.ts +1 -0
  81. package/src/modules/communication_channels/data/enrichers.ts +25 -0
  82. package/src/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.client.tsx +7 -6
  83. package/src/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.ts +5 -2
  84. package/src/modules/customers/api/pipeline-stages/reorder/route.ts +41 -1
  85. package/src/modules/customers/api/pipeline-stages/route.ts +112 -13
  86. package/src/modules/customers/api/pipelines/route.ts +112 -13
  87. package/src/modules/customers/api/settings/address-format/route.ts +36 -1
  88. package/src/modules/customers/api/tags/assign/route.ts +39 -0
  89. package/src/modules/customers/api/tags/unassign/route.ts +39 -0
  90. package/src/modules/customers/i18n/de.json +1 -0
  91. package/src/modules/customers/i18n/en.json +1 -0
  92. package/src/modules/customers/i18n/es.json +1 -0
  93. package/src/modules/customers/i18n/pl.json +1 -0
  94. package/src/modules/entities/api/encryption.ts +122 -39
  95. package/src/modules/entities/components/EncryptionManager.tsx +28 -9
  96. package/src/modules/integrations/api/[id]/credentials/route.ts +23 -0
  97. package/src/modules/integrations/api/[id]/route.ts +8 -1
  98. package/src/modules/integrations/api/[id]/state/route.ts +20 -4
  99. package/src/modules/integrations/api/[id]/version/route.ts +18 -1
  100. package/src/modules/integrations/api/route.ts +3 -0
  101. package/src/modules/integrations/backend/integrations/[id]/page.tsx +39 -20
  102. package/src/modules/integrations/backend/integrations/bundle/[id]/page.tsx +19 -11
  103. package/src/modules/integrations/backend/integrations/page.tsx +8 -5
  104. package/src/modules/integrations/data/entities.ts +2 -2
  105. package/src/modules/integrations/lib/credentials-service.ts +35 -0
  106. package/src/modules/integrations/lib/state-service.ts +3 -0
  107. package/src/modules/notifications/lib/notificationService.ts +74 -11
  108. package/src/modules/payment_gateways/api/status/route.ts +97 -5
  109. package/src/modules/payment_gateways/backend/payment-gateways/page.tsx +27 -11
  110. package/src/modules/sales/backend/sales/channels/offers/page.tsx +31 -7
  111. package/src/modules/sales/backend/sales/channels/page.tsx +31 -7
  112. package/src/modules/sales/components/AdjustmentKindSettings.tsx +60 -24
  113. package/src/modules/sales/components/DocumentNumberSettings.tsx +32 -10
  114. package/src/modules/sales/components/OrderEditingSettings.tsx +32 -10
  115. package/src/modules/sales/components/StatusSettings.tsx +74 -33
  116. package/src/modules/sales/components/channels/SalesChannelOffersPanel.tsx +30 -6
  117. package/src/modules/shipping_carriers/api/cancel/route.ts +46 -0
  118. package/src/modules/shipping_carriers/api/shipments/route.ts +21 -6
  119. package/src/modules/staff/i18n/de.json +5 -5
  120. package/src/modules/staff/i18n/es.json +5 -5
  121. package/src/modules/staff/i18n/pl.json +5 -5
  122. package/src/modules/staff/lib/timesheets-ui/TimerBar.tsx +7 -8
  123. package/src/modules/staff/lib/timesheets-ui/timerErrors.ts +23 -0
  124. package/src/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.tsx +4 -3
  125. package/src/modules/workflows/backend/definitions/page.tsx +19 -10
@@ -13,6 +13,7 @@ import { readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato
13
13
  import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
14
14
  import { deleteCrud } from '@open-mercato/ui/backend/utils/crud'
15
15
  import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
16
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
16
17
  import { useT } from '@open-mercato/shared/lib/i18n/context'
17
18
  import { mapOfferRow, renderOfferPriceSummary, type OfferRow } from './offerTableUtils'
18
19
 
@@ -24,9 +25,27 @@ type OffersResponse = {
24
25
 
25
26
  const PAGE_SIZE = 25
26
27
 
28
+ const SAVE_CONTEXT_ID = 'sales-channel-offers-panel'
29
+
27
30
  export function SalesChannelOffersPanel({ channelId, channelName }: { channelId: string; channelName?: string }) {
28
31
  const t = useT()
29
32
  const router = useRouter()
33
+ const { runMutation, retryLastMutation } = useGuardedMutation<{
34
+ formId: string
35
+ resourceKind: string
36
+ retryLastMutation: () => Promise<boolean>
37
+ }>({
38
+ contextId: SAVE_CONTEXT_ID,
39
+ blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
40
+ })
41
+ const mutationContext = React.useMemo(
42
+ () => ({
43
+ formId: SAVE_CONTEXT_ID,
44
+ resourceKind: 'sales.channels.offers',
45
+ retryLastMutation,
46
+ }),
47
+ [retryLastMutation],
48
+ )
30
49
  const [rows, setRows] = React.useState<OfferRow[]>([])
31
50
  const [page, setPage] = React.useState(1)
32
51
  const [total, setTotal] = React.useState(0)
@@ -125,11 +144,16 @@ export function SalesChannelOffersPanel({ channelId, channelName }: { channelId:
125
144
 
126
145
  const handleDelete = React.useCallback(async (row: OfferRow) => {
127
146
  try {
128
- await withScopedApiRequestHeaders(buildOptimisticLockHeader(row.updatedAt), () =>
129
- deleteCrud('catalog/offers', row.id, {
130
- errorMessage: t('sales.channels.offers.errors.delete', 'Failed to delete offer.'),
131
- }),
132
- )
147
+ await runMutation({
148
+ operation: () =>
149
+ withScopedApiRequestHeaders(buildOptimisticLockHeader(row.updatedAt), () =>
150
+ deleteCrud('catalog/offers', row.id, {
151
+ errorMessage: t('sales.channels.offers.errors.delete', 'Failed to delete offer.'),
152
+ }),
153
+ ),
154
+ context: mutationContext,
155
+ mutationPayload: { action: 'delete', id: row.id },
156
+ })
133
157
  flash(t('sales.channels.offers.messages.deleted', 'Offer deleted.'), 'success')
134
158
  setReloadToken((token) => token + 1)
135
159
  } catch (err) {
@@ -137,7 +161,7 @@ export function SalesChannelOffersPanel({ channelId, channelName }: { channelId:
137
161
  console.error('sales.channels.offers.delete', err)
138
162
  flash(t('sales.channels.offers.errors.delete', 'Failed to delete offer.'), 'error')
139
163
  }
140
- }, [t])
164
+ }, [mutationContext, runMutation, t])
141
165
 
142
166
  const handleSearchChange = React.useCallback((value: string) => {
143
167
  setSearch(value)
@@ -2,11 +2,26 @@ import { NextResponse } from 'next/server'
2
2
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
3
3
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
4
  import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
5
+ import {
6
+ runCrudMutationGuardAfterSuccess,
7
+ validateCrudMutationGuard,
8
+ } from '@open-mercato/shared/lib/crud/mutation-guard'
5
9
  import type { ShippingCarrierService } from '../../lib/shipping-service'
6
10
  import { isShipmentCancelNotAllowedError } from '../../lib/status-sync'
7
11
  import { cancelShipmentSchema } from '../../data/validators'
8
12
  import { shippingCarriersTag } from '../openapi'
9
13
 
14
+ function resolveGuardUserId(auth: {
15
+ sub?: string | null
16
+ userId?: string | null
17
+ keyId?: string | null
18
+ }): string {
19
+ if (typeof auth.sub === 'string' && auth.sub.trim().length > 0) return auth.sub
20
+ if (typeof auth.userId === 'string' && auth.userId.trim().length > 0) return auth.userId
21
+ if (typeof auth.keyId === 'string' && auth.keyId.trim().length > 0) return auth.keyId
22
+ return 'system'
23
+ }
24
+
10
25
  export const metadata = {
11
26
  path: '/shipping-carriers/cancel',
12
27
  POST: { requireAuth: true, requireFeatures: ['shipping_carriers.manage'] },
@@ -23,6 +38,22 @@ export async function POST(req: Request) {
23
38
  return NextResponse.json({ error: 'Invalid payload', details: parsed.error.flatten() }, { status: 422 })
24
39
  }
25
40
  const container = await createRequestContainer()
41
+ const guardUserId = resolveGuardUserId(auth)
42
+ const guardResult = await validateCrudMutationGuard(container, {
43
+ tenantId: auth.tenantId,
44
+ organizationId: auth.orgId,
45
+ userId: guardUserId,
46
+ resourceKind: 'shipping_carriers.shipment',
47
+ resourceId: parsed.data.shipmentId,
48
+ operation: 'custom',
49
+ requestMethod: req.method,
50
+ requestHeaders: req.headers,
51
+ mutationPayload: parsed.data as Record<string, unknown>,
52
+ })
53
+ if (guardResult && !guardResult.ok) {
54
+ return NextResponse.json(guardResult.body, { status: guardResult.status })
55
+ }
56
+
26
57
  const service = container.resolve('shippingCarrierService') as ShippingCarrierService
27
58
  try {
28
59
  const result = await service.cancelShipment({
@@ -30,6 +61,21 @@ export async function POST(req: Request) {
30
61
  organizationId: auth.orgId as string,
31
62
  tenantId: auth.tenantId,
32
63
  })
64
+
65
+ if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
66
+ await runCrudMutationGuardAfterSuccess(container, {
67
+ tenantId: auth.tenantId,
68
+ organizationId: auth.orgId,
69
+ userId: guardUserId,
70
+ resourceKind: 'shipping_carriers.shipment',
71
+ resourceId: parsed.data.shipmentId,
72
+ operation: 'custom',
73
+ requestMethod: req.method,
74
+ requestHeaders: req.headers,
75
+ metadata: guardResult.metadata ?? null,
76
+ })
77
+ }
78
+
33
79
  return NextResponse.json(result)
34
80
  } catch (error: unknown) {
35
81
  if (isShipmentCancelNotAllowedError(error)) {
@@ -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
  }
@@ -9,6 +9,7 @@ import { apiCall, apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
9
9
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
10
10
  import { ProjectColorDot } from './ProjectColorDot'
11
11
  import { startTimerEntry } from './startTimer'
12
+ import { resolveTimerActionError } from './timerErrors'
12
13
 
13
14
  type ProjectOption = {
14
15
  id: string
@@ -151,9 +152,9 @@ export function TimerBar({ projects, staffMemberId, onTimerStopped }: TimerBarPr
151
152
  setActiveEntryId(entryId)
152
153
  setActiveProjectId(selectedProjectId)
153
154
  startElapsedCounter(new Date().toISOString())
154
- } catch {
155
+ } catch (err) {
155
156
  flash(
156
- t('staff.timesheets.my.timer.startError', 'Failed to start timer'),
157
+ resolveTimerActionError(err, t('staff.timesheets.my.timer.startError', 'Failed to start timer')),
157
158
  'error',
158
159
  )
159
160
  } finally {
@@ -176,9 +177,9 @@ export function TimerBar({ projects, staffMemberId, onTimerStopped }: TimerBarPr
176
177
  setDescription('')
177
178
  stopElapsedCounter()
178
179
  onTimerStopped()
179
- } catch {
180
+ } catch (err) {
180
181
  flash(
181
- t('staff.timesheets.my.timer.stopError', 'Failed to stop timer'),
182
+ resolveTimerActionError(err, t('staff.timesheets.my.timer.stopError', 'Failed to stop timer')),
182
183
  'error',
183
184
  )
184
185
  } finally {
@@ -284,11 +285,10 @@ export function TimerBar({ projects, staffMemberId, onTimerStopped }: TimerBarPr
284
285
  {isRunning ? (
285
286
  <IconButton
286
287
  type="button"
287
- variant="outline"
288
+ variant="destructive"
288
289
  size="default"
289
290
  onClick={handleStop}
290
291
  disabled={isStopping}
291
- className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
292
292
  aria-label={t('staff.timesheets.my.timer.stop', 'Stop timer')}
293
293
  >
294
294
  <Square className="size-4" />
@@ -296,11 +296,10 @@ export function TimerBar({ projects, staffMemberId, onTimerStopped }: TimerBarPr
296
296
  ) : (
297
297
  <IconButton
298
298
  type="button"
299
- variant="outline"
299
+ variant="primary"
300
300
  size="default"
301
301
  onClick={handleStart}
302
302
  disabled={isStarting || !selectedProjectId}
303
- className="bg-primary text-primary-foreground hover:bg-primary/90"
304
303
  aria-label={t('staff.timesheets.my.timer.start', 'Start timer')}
305
304
  >
306
305
  <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
+ }
@@ -7,6 +7,7 @@ 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
9
  import { startTimerEntry } from '../../../lib/timesheets-ui/startTimer'
10
+ import { resolveTimerActionError } from '../../../lib/timesheets-ui/timerErrors'
10
11
  import { DEFAULT_SETTINGS, hydrateSettings, type TimeReportingSettings } from './config'
11
12
 
12
13
  type ProjectOption = { id: string; name: string; code: string | null }
@@ -153,7 +154,7 @@ const TimeReportingWidget: React.FC<DashboardWidgetComponentProps<TimeReportingS
153
154
  await loadState()
154
155
  } catch (err) {
155
156
  console.error('staff.timesheets.timeReporting.start', err)
156
- setError(t('staff.timesheets.widgets.timeReporting.startError', 'Failed to start timer'))
157
+ setError(resolveTimerActionError(err, t('staff.timesheets.widgets.timeReporting.startError', 'Failed to start timer')))
157
158
  } finally {
158
159
  setActionLoading(false)
159
160
  }
@@ -167,7 +168,7 @@ const TimeReportingWidget: React.FC<DashboardWidgetComponentProps<TimeReportingS
167
168
  await loadState()
168
169
  } catch (err) {
169
170
  console.error('staff.timesheets.timeReporting.stop', err)
170
- setError(t('staff.timesheets.widgets.timeReporting.stopError', 'Failed to stop timer'))
171
+ setError(resolveTimerActionError(err, t('staff.timesheets.widgets.timeReporting.stopError', 'Failed to stop timer')))
171
172
  } finally {
172
173
  setActionLoading(false)
173
174
  }
@@ -194,7 +195,7 @@ const TimeReportingWidget: React.FC<DashboardWidgetComponentProps<TimeReportingS
194
195
  if (error) {
195
196
  return (
196
197
  <div className="flex h-full items-center justify-center py-8">
197
- <p className="text-sm text-destructive">{error}</p>
198
+ <p role="alert" className="text-sm text-destructive">{error}</p>
198
199
  </div>
199
200
  )
200
201
  }
@@ -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',