@open-mercato/core 0.6.4-develop.4331.1.64a8535120 → 0.6.4-develop.4358.1.233d5675c7

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 (47) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js +15 -10
  3. package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js.map +2 -2
  4. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +2 -6
  5. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
  6. package/dist/modules/entities/api/records.js +2 -2
  7. package/dist/modules/entities/api/records.js.map +2 -2
  8. package/dist/modules/entities/lib/helpers.js +25 -1
  9. package/dist/modules/entities/lib/helpers.js.map +2 -2
  10. package/dist/modules/entities/lib/validation.js +3 -1
  11. package/dist/modules/entities/lib/validation.js.map +2 -2
  12. package/dist/modules/sales/components/documents/AdjustmentDialog.js +10 -12
  13. package/dist/modules/sales/components/documents/AdjustmentDialog.js.map +2 -2
  14. package/dist/modules/sales/components/documents/LineItemDialog.js +10 -10
  15. package/dist/modules/sales/components/documents/LineItemDialog.js.map +2 -2
  16. package/dist/modules/sales/components/documents/PaymentDialog.js +8 -15
  17. package/dist/modules/sales/components/documents/PaymentDialog.js.map +2 -2
  18. package/dist/modules/sales/components/documents/ShipmentDialog.js +10 -14
  19. package/dist/modules/sales/components/documents/ShipmentDialog.js.map +2 -2
  20. package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/[segmentId]/route.js +50 -17
  21. package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/[segmentId]/route.js.map +2 -2
  22. package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/route.js +24 -10
  23. package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/route.js.map +2 -2
  24. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js +31 -12
  25. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js.map +2 -2
  26. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-stop/route.js +42 -29
  27. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-stop/route.js.map +2 -2
  28. package/dist/modules/workflows/components/EdgeEditDialog.js +3 -9
  29. package/dist/modules/workflows/components/EdgeEditDialog.js.map +2 -2
  30. package/dist/modules/workflows/components/NodeEditDialog.js +3 -9
  31. package/dist/modules/workflows/components/NodeEditDialog.js.map +2 -2
  32. package/package.json +7 -7
  33. package/src/modules/customers/components/detail/ConfirmDealLostDialog.tsx +15 -10
  34. package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +2 -6
  35. package/src/modules/entities/api/records.ts +2 -2
  36. package/src/modules/entities/lib/helpers.ts +29 -4
  37. package/src/modules/entities/lib/validation.ts +10 -2
  38. package/src/modules/sales/components/documents/AdjustmentDialog.tsx +11 -12
  39. package/src/modules/sales/components/documents/LineItemDialog.tsx +11 -10
  40. package/src/modules/sales/components/documents/PaymentDialog.tsx +9 -16
  41. package/src/modules/sales/components/documents/ShipmentDialog.tsx +10 -14
  42. package/src/modules/staff/api/timesheets/time-entries/[id]/segments/[segmentId]/route.ts +57 -18
  43. package/src/modules/staff/api/timesheets/time-entries/[id]/segments/route.ts +29 -10
  44. package/src/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.ts +38 -14
  45. package/src/modules/staff/api/timesheets/time-entries/[id]/timer-stop/route.ts +52 -34
  46. package/src/modules/workflows/components/EdgeEditDialog.tsx +3 -9
  47. package/src/modules/workflows/components/NodeEditDialog.tsx +3 -9
@@ -7,6 +7,7 @@ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
7
7
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
8
8
  import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
9
9
  import type { EntityManager } from '@mikro-orm/postgresql'
10
+ import { LockMode } from '@mikro-orm/core'
10
11
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
11
12
  import { StaffTimeEntry, StaffTimeEntrySegment } from '../../../../../data/entities'
12
13
  import { getStaffMemberByUserId } from '../../../../../lib/staffMemberResolver'
@@ -70,22 +71,6 @@ export async function POST(req: Request) {
70
71
  throw new CrudHttpError(403, { error: translate('staff.timesheets.errors.notOwner', 'You can only manage your own time entries.') })
71
72
  }
72
73
 
73
- const segments = await findWithDecryption(
74
- em,
75
- StaffTimeEntrySegment,
76
- { timeEntryId: entry.id, tenantId, organizationId, deletedAt: null },
77
- {},
78
- scopeCtx,
79
- )
80
-
81
- const activeSegment = segments.find((segment) => !segment.endedAt)
82
- if (!activeSegment) {
83
- return NextResponse.json(
84
- { error: translate('staff.timesheets.errors.noActiveSegment', 'No active timer segment found for this entry.') },
85
- { status: 409 },
86
- )
87
- }
88
-
89
74
  const guardResult = await runStaffMutationGuards(
90
75
  container,
91
76
  {
@@ -107,29 +92,62 @@ export async function POST(req: Request) {
107
92
  )
108
93
  }
109
94
 
110
- const now = new Date()
111
- activeSegment.endedAt = now
112
- entry.endedAt = now
95
+ // Recompute and persist the timer state inside a single transaction with a
96
+ // PESSIMISTIC_WRITE lock on the time entry row, so concurrent timer-stop /
97
+ // segment writes on the same entry serialize instead of racing on a shared
98
+ // in-memory snapshot (issue #2416).
99
+ const { now, durationMinutes } = await em.transactional(async (trx) => {
100
+ const lockedEntry = await findOneWithDecryption(
101
+ trx,
102
+ StaffTimeEntry,
103
+ { id: entryId, tenantId, organizationId, deletedAt: null },
104
+ { lockMode: LockMode.PESSIMISTIC_WRITE },
105
+ scopeCtx,
106
+ )
107
+ if (!lockedEntry) {
108
+ throw new CrudHttpError(404, { error: translate('staff.timesheets.errors.entryNotFound', 'Time entry not found.') })
109
+ }
113
110
 
114
- const allSegments = segments.map((segment) => {
115
- if (segment.id === activeSegment.id) {
116
- return { ...segment, endedAt: now }
111
+ const segments = await findWithDecryption(
112
+ trx,
113
+ StaffTimeEntrySegment,
114
+ { timeEntryId: lockedEntry.id, tenantId, organizationId, deletedAt: null },
115
+ {},
116
+ scopeCtx,
117
+ )
118
+
119
+ const activeSegment = segments.find((segment) => !segment.endedAt)
120
+ if (!activeSegment) {
121
+ throw new CrudHttpError(409, {
122
+ error: translate('staff.timesheets.errors.noActiveSegment', 'No active timer segment found for this entry.'),
123
+ })
117
124
  }
118
- return segment
119
- })
120
125
 
121
- const totalWorkMinutes = allSegments
122
- .filter((segment) => segment.segmentType === 'work' && segment.startedAt && segment.endedAt)
123
- .reduce((sum, segment) => {
124
- const startMs = new Date(segment.startedAt).getTime()
125
- const endMs = new Date(segment.endedAt!).getTime()
126
- return sum + (endMs - startMs)
127
- }, 0)
126
+ const stoppedAt = new Date()
127
+ activeSegment.endedAt = stoppedAt
128
+ lockedEntry.endedAt = stoppedAt
128
129
 
129
- const durationMinutes = Math.round(totalWorkMinutes / 60000)
130
- entry.durationMinutes = durationMinutes
130
+ const allSegments = segments.map((segment) => {
131
+ if (segment.id === activeSegment.id) {
132
+ return { ...segment, endedAt: stoppedAt }
133
+ }
134
+ return segment
135
+ })
136
+
137
+ const totalWorkMinutes = allSegments
138
+ .filter((segment) => segment.segmentType === 'work' && segment.startedAt && segment.endedAt)
139
+ .reduce((sum, segment) => {
140
+ const startMs = new Date(segment.startedAt).getTime()
141
+ const endMs = new Date(segment.endedAt!).getTime()
142
+ return sum + (endMs - startMs)
143
+ }, 0)
131
144
 
132
- await em.flush()
145
+ const computedMinutes = Math.round(totalWorkMinutes / 60000)
146
+ lockedEntry.durationMinutes = computedMinutes
147
+
148
+ await trx.flush()
149
+ return { now: stoppedAt, durationMinutes: computedMinutes }
150
+ })
133
151
 
134
152
  void emitStaffEvent('staff.timesheets.time_entry.timer_stopped', {
135
153
  id: entry.id,
@@ -26,6 +26,7 @@ import {Plus, Trash2} from 'lucide-react'
26
26
  import {type BusinessRule, BusinessRulesSelector} from './BusinessRulesSelector'
27
27
  import {JsonBuilder} from '@open-mercato/ui/backend/JsonBuilder'
28
28
  import {useT} from '@open-mercato/shared/lib/i18n/context'
29
+ import {useDialogKeyHandler} from '@open-mercato/ui/hooks/useDialogKeyHandler'
29
30
  import {useConfirmDialog} from '@open-mercato/ui/backend/confirm-dialog'
30
31
 
31
32
  export interface EdgeEditDialogProps {
@@ -302,14 +303,7 @@ export function EdgeEditDialog({ edge, isOpen, onClose, onSave, onDelete }: Edge
302
303
  onDelete(edge.id)
303
304
  }
304
305
 
305
- const handleKeyDown = (e: React.KeyboardEvent) => {
306
- if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
307
- handleSave()
308
- }
309
- if (e.key === 'Escape') {
310
- onClose()
311
- }
312
- }
306
+ const handleKeyDown = useDialogKeyHandler({ onConfirm: handleSave, onCancel: onClose })
313
307
 
314
308
  if (!isOpen || !edge) return null
315
309
 
@@ -317,7 +311,7 @@ export function EdgeEditDialog({ edge, isOpen, onClose, onSave, onDelete }: Edge
317
311
 
318
312
  return (
319
313
  <Dialog open={isOpen} onOpenChange={onClose}>
320
- <DialogContent className="sm:max-w-4xl max-h-[90vh] overflow-y-auto">
314
+ <DialogContent className="sm:max-w-4xl max-h-[90vh] overflow-y-auto" onKeyDown={handleKeyDown}>
321
315
  <DialogHeader>
322
316
  <div className="flex items-center gap-2 mb-2">
323
317
  <DialogTitle>{t('workflows.edgeEditor.title')}</DialogTitle>
@@ -20,6 +20,7 @@ import {WorkflowDefinition, WorkflowSelector} from './WorkflowSelector'
20
20
  import {JsonBuilder} from '@open-mercato/ui/backend/JsonBuilder'
21
21
  import {StartPreConditionsEditor, type StartPreCondition} from './fields/StartPreConditionsEditor'
22
22
  import {useT} from '@open-mercato/shared/lib/i18n/context'
23
+ import {useDialogKeyHandler} from '@open-mercato/ui/hooks/useDialogKeyHandler'
23
24
  import {useConfirmDialog} from '@open-mercato/ui/backend/confirm-dialog'
24
25
  import {isFutureIsoDateString, isValidDurationString} from '../data/validators'
25
26
 
@@ -485,14 +486,7 @@ export function NodeEditDialog({ node, isOpen, onClose, onSave, onDelete }: Node
485
486
  onDelete(node.id)
486
487
  }
487
488
 
488
- const handleKeyDown = (e: React.KeyboardEvent) => {
489
- if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
490
- handleSave()
491
- }
492
- if (e.key === 'Escape') {
493
- onClose()
494
- }
495
- }
489
+ const handleKeyDown = useDialogKeyHandler({ onConfirm: handleSave, onCancel: onClose })
496
490
 
497
491
  if (!isOpen || !node) return null
498
492
 
@@ -518,7 +512,7 @@ export function NodeEditDialog({ node, isOpen, onClose, onSave, onDelete }: Node
518
512
 
519
513
  return (
520
514
  <Dialog open={isOpen} onOpenChange={onClose}>
521
- <DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
515
+ <DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto" onKeyDown={handleKeyDown}>
522
516
  <DialogHeader>
523
517
  <div className="flex items-center gap-2 mb-2">
524
518
  <DialogTitle>{t('workflows.nodeEditor.title')}</DialogTitle>