@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js +15 -10
- package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js.map +2 -2
- package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +2 -6
- package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
- package/dist/modules/entities/api/records.js +2 -2
- package/dist/modules/entities/api/records.js.map +2 -2
- package/dist/modules/entities/lib/helpers.js +25 -1
- package/dist/modules/entities/lib/helpers.js.map +2 -2
- package/dist/modules/entities/lib/validation.js +3 -1
- package/dist/modules/entities/lib/validation.js.map +2 -2
- package/dist/modules/sales/components/documents/AdjustmentDialog.js +10 -12
- package/dist/modules/sales/components/documents/AdjustmentDialog.js.map +2 -2
- package/dist/modules/sales/components/documents/LineItemDialog.js +10 -10
- package/dist/modules/sales/components/documents/LineItemDialog.js.map +2 -2
- package/dist/modules/sales/components/documents/PaymentDialog.js +8 -15
- package/dist/modules/sales/components/documents/PaymentDialog.js.map +2 -2
- package/dist/modules/sales/components/documents/ShipmentDialog.js +10 -14
- package/dist/modules/sales/components/documents/ShipmentDialog.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/[segmentId]/route.js +50 -17
- package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/[segmentId]/route.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/route.js +24 -10
- package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/route.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js +31 -12
- package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-stop/route.js +42 -29
- package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-stop/route.js.map +2 -2
- package/dist/modules/workflows/components/EdgeEditDialog.js +3 -9
- package/dist/modules/workflows/components/EdgeEditDialog.js.map +2 -2
- package/dist/modules/workflows/components/NodeEditDialog.js +3 -9
- package/dist/modules/workflows/components/NodeEditDialog.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/customers/components/detail/ConfirmDealLostDialog.tsx +15 -10
- package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +2 -6
- package/src/modules/entities/api/records.ts +2 -2
- package/src/modules/entities/lib/helpers.ts +29 -4
- package/src/modules/entities/lib/validation.ts +10 -2
- package/src/modules/sales/components/documents/AdjustmentDialog.tsx +11 -12
- package/src/modules/sales/components/documents/LineItemDialog.tsx +11 -10
- package/src/modules/sales/components/documents/PaymentDialog.tsx +9 -16
- package/src/modules/sales/components/documents/ShipmentDialog.tsx +10 -14
- package/src/modules/staff/api/timesheets/time-entries/[id]/segments/[segmentId]/route.ts +57 -18
- package/src/modules/staff/api/timesheets/time-entries/[id]/segments/route.ts +29 -10
- package/src/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.ts +38 -14
- package/src/modules/staff/api/timesheets/time-entries/[id]/timer-stop/route.ts +52 -34
- package/src/modules/workflows/components/EdgeEditDialog.tsx +3 -9
- package/src/modules/workflows/components/NodeEditDialog.tsx +3 -9
|
@@ -8,6 +8,7 @@ import { Alert, AlertDescription, AlertTitle } from '@open-mercato/ui/primitives
|
|
|
8
8
|
import { Button } from '@open-mercato/ui/primitives/button'
|
|
9
9
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@open-mercato/ui/primitives/dialog'
|
|
10
10
|
import { Textarea } from '@open-mercato/ui/primitives/textarea'
|
|
11
|
+
import { useDialogKeyHandler } from '@open-mercato/ui/hooks/useDialogKeyHandler'
|
|
11
12
|
|
|
12
13
|
type LossReasonOption = {
|
|
13
14
|
id: string
|
|
@@ -39,6 +40,7 @@ export function ConfirmDealLostDialog({
|
|
|
39
40
|
const [lossReasons, setLossReasons] = React.useState<LossReasonOption[]>([])
|
|
40
41
|
const [reasonListOpen, setReasonListOpen] = React.useState(false)
|
|
41
42
|
const [error, setError] = React.useState('')
|
|
43
|
+
const [isConfirming, setIsConfirming] = React.useState(false)
|
|
42
44
|
|
|
43
45
|
React.useEffect(() => {
|
|
44
46
|
if (!open) return
|
|
@@ -74,18 +76,21 @@ export function ConfirmDealLostDialog({
|
|
|
74
76
|
setError(t('customers.deals.detail.lost.reasonRequired', 'Please select a loss reason'))
|
|
75
77
|
return
|
|
76
78
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
setIsConfirming(true)
|
|
80
|
+
try {
|
|
81
|
+
await onConfirm({
|
|
82
|
+
lossReasonId,
|
|
83
|
+
lossNotes: lossNotes.trim() || undefined,
|
|
84
|
+
})
|
|
85
|
+
} finally {
|
|
86
|
+
setIsConfirming(false)
|
|
87
|
+
}
|
|
81
88
|
}, [lossNotes, lossReasonId, onConfirm, t])
|
|
82
89
|
|
|
83
|
-
const handleKeyDown =
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
}, [handleConfirm])
|
|
90
|
+
const handleKeyDown = useDialogKeyHandler({
|
|
91
|
+
onConfirm: () => void handleConfirm(),
|
|
92
|
+
disabled: isConfirming,
|
|
93
|
+
})
|
|
89
94
|
|
|
90
95
|
return (
|
|
91
96
|
<Dialog open={open} onOpenChange={(nextOpen) => { if (!nextOpen) onClose() }}>
|
|
@@ -13,6 +13,7 @@ import { Alert, AlertDescription, AlertTitle } from '@open-mercato/ui/primitives
|
|
|
13
13
|
import { Button } from '@open-mercato/ui/primitives/button'
|
|
14
14
|
import { IconButton } from '@open-mercato/ui/primitives/icon-button'
|
|
15
15
|
import { Dialog, DialogContent, DialogTitle } from '@open-mercato/ui/primitives/dialog'
|
|
16
|
+
import { useDialogKeyHandler } from '@open-mercato/ui/hooks/useDialogKeyHandler'
|
|
16
17
|
import { VisuallyHidden } from '@radix-ui/react-visually-hidden'
|
|
17
18
|
import { PhoneNumberField, SwitchableMarkdownInput } from '@open-mercato/ui/backend/inputs'
|
|
18
19
|
import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
|
|
@@ -444,12 +445,7 @@ export function ScheduleActivityDialog({
|
|
|
444
445
|
}
|
|
445
446
|
}, [callDirection, callOutcome, callPhoneInvalidMessage, callPhoneNumber, isDateMissing, isTimeMissing, state.activityType, state.allDay, state.date, state.description, dealId, state.duration, editData, entityId, state.guestPermissions, state.linkedEntities, state.location, onActivityCreated, onClose, state.participants, state.recurrenceCount, state.recurrenceDays, state.recurrenceEnabled, state.recurrenceEndDate, state.recurrenceEndType, state.reminderMinutes, runGuardedMutation, state.startTime, t, taskPriority, state.title, translateErrorMessage, trimmedCallPhone, trimmedDate, trimmedStartTime, state.visibility, visibleFields]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
446
447
|
|
|
447
|
-
const handleKeyDown =
|
|
448
|
-
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
|
449
|
-
e.preventDefault()
|
|
450
|
-
handleSave()
|
|
451
|
-
}
|
|
452
|
-
}, [handleSave])
|
|
448
|
+
const handleKeyDown = useDialogKeyHandler({ onConfirm: handleSave })
|
|
453
449
|
|
|
454
450
|
return (
|
|
455
451
|
<Dialog open={open} onOpenChange={(o) => { if (!o) void guardedClose() }}>
|
|
@@ -261,7 +261,7 @@ export async function POST(req: Request) {
|
|
|
261
261
|
// Validate against custom field definitions
|
|
262
262
|
try {
|
|
263
263
|
const { validateCustomFieldValuesServer } = await import('../lib/validation')
|
|
264
|
-
const check = await validateCustomFieldValuesServer(em, { entityId, organizationId: targetOrgId, tenantId: auth.tenantId!, values: norm })
|
|
264
|
+
const check = await validateCustomFieldValuesServer(em, { entityId, organizationId: targetOrgId, tenantId: auth.tenantId!, values: norm, rejectUndeclaredKeys: true })
|
|
265
265
|
if (!check.ok) return NextResponse.json({ error: 'Validation failed', fields: check.fieldErrors }, { status: 400 })
|
|
266
266
|
} catch { /* ignore if helper missing */ }
|
|
267
267
|
|
|
@@ -322,7 +322,7 @@ export async function PUT(req: Request) {
|
|
|
322
322
|
// Validate against custom field definitions
|
|
323
323
|
try {
|
|
324
324
|
const { validateCustomFieldValuesServer } = await import('../lib/validation')
|
|
325
|
-
const check = await validateCustomFieldValuesServer(em, { entityId, organizationId: targetOrgId, tenantId: auth.tenantId!, values: norm })
|
|
325
|
+
const check = await validateCustomFieldValuesServer(em, { entityId, organizationId: targetOrgId, tenantId: auth.tenantId!, values: norm, rejectUndeclaredKeys: true })
|
|
326
326
|
if (!check.ok) return NextResponse.json({ error: 'Validation failed', fields: check.fieldErrors }, { status: 400 })
|
|
327
327
|
} catch { /* ignore if helper missing */ }
|
|
328
328
|
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { EntityManager } from '@mikro-orm/core'
|
|
2
2
|
import type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'
|
|
3
3
|
import { encryptCustomFieldValue, resolveTenantEncryptionService } from '@open-mercato/shared/lib/encryption/customFieldValues'
|
|
4
|
+
import {
|
|
5
|
+
MAX_CUSTOM_FIELD_KEYS_PER_RECORD,
|
|
6
|
+
TOO_MANY_CUSTOM_FIELDS_ERROR,
|
|
7
|
+
} from '@open-mercato/shared/modules/entities/validation'
|
|
4
8
|
import { CustomFieldDef, CustomFieldValue } from '../data/entities'
|
|
5
9
|
|
|
6
10
|
type Primitive = string | number | boolean | null | undefined
|
|
@@ -69,16 +73,32 @@ export async function setRecordCustomFields(
|
|
|
69
73
|
if (preferDefs) {
|
|
70
74
|
const defs = await em.find(CustomFieldDef, {
|
|
71
75
|
entityId,
|
|
76
|
+
isActive: true,
|
|
77
|
+
deletedAt: null,
|
|
72
78
|
organizationId: { $in: [organizationId, null] as any },
|
|
73
79
|
tenantId: { $in: [tenantId, null] as any },
|
|
74
80
|
})
|
|
75
|
-
|
|
81
|
+
const scopeScore = (def: CustomFieldDef) => (def.tenantId ? 2 : 0) + (def.organizationId ? 1 : 0)
|
|
76
82
|
defsByKey = {}
|
|
77
83
|
for (const d of defs) {
|
|
78
84
|
const existing = defsByKey[d.key]
|
|
79
|
-
if (!existing
|
|
80
|
-
|
|
81
|
-
|
|
85
|
+
if (!existing) {
|
|
86
|
+
defsByKey[d.key] = d
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
const nextScore = scopeScore(d)
|
|
90
|
+
const existingScore = scopeScore(existing)
|
|
91
|
+
if (nextScore > existingScore) {
|
|
92
|
+
defsByKey[d.key] = d
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
if (nextScore < existingScore) continue
|
|
96
|
+
|
|
97
|
+
const nextUpdatedAt = d.updatedAt instanceof Date ? d.updatedAt.getTime() : new Date(d.updatedAt).getTime()
|
|
98
|
+
const existingUpdatedAt = existing.updatedAt instanceof Date
|
|
99
|
+
? existing.updatedAt.getTime()
|
|
100
|
+
: new Date(existing.updatedAt).getTime()
|
|
101
|
+
if (nextUpdatedAt >= existingUpdatedAt) {
|
|
82
102
|
defsByKey[d.key] = d
|
|
83
103
|
}
|
|
84
104
|
}
|
|
@@ -93,6 +113,11 @@ export async function setRecordCustomFields(
|
|
|
93
113
|
return encryptionService
|
|
94
114
|
}
|
|
95
115
|
const keys = Object.keys(values)
|
|
116
|
+
const presentKeyCount = keys.filter((key) => values[key] !== undefined).length
|
|
117
|
+
if (preferDefs && presentKeyCount > MAX_CUSTOM_FIELD_KEYS_PER_RECORD) {
|
|
118
|
+
throw new Error(TOO_MANY_CUSTOM_FIELDS_ERROR)
|
|
119
|
+
}
|
|
120
|
+
|
|
96
121
|
for (const fieldKey of keys) {
|
|
97
122
|
const raw = values[fieldKey]
|
|
98
123
|
if (raw === undefined) continue
|
|
@@ -4,7 +4,13 @@ import { validateValuesAgainstDefs } from '@open-mercato/shared/modules/entities
|
|
|
4
4
|
|
|
5
5
|
export async function validateCustomFieldValuesServer(
|
|
6
6
|
em: EntityManager,
|
|
7
|
-
opts: {
|
|
7
|
+
opts: {
|
|
8
|
+
entityId: string
|
|
9
|
+
organizationId?: string | null
|
|
10
|
+
tenantId?: string | null
|
|
11
|
+
values: Record<string, any>
|
|
12
|
+
rejectUndeclaredKeys?: boolean
|
|
13
|
+
},
|
|
8
14
|
): Promise<{ ok: boolean; fieldErrors: Record<string, string> }> {
|
|
9
15
|
const organizationId = opts.organizationId ?? null
|
|
10
16
|
const tenantId = opts.tenantId ?? null
|
|
@@ -51,5 +57,7 @@ export async function validateCustomFieldValuesServer(
|
|
|
51
57
|
byKey.set(d.key, d)
|
|
52
58
|
}
|
|
53
59
|
}
|
|
54
|
-
return validateValuesAgainstDefs(opts.values, Array.from(byKey.values()) as any
|
|
60
|
+
return validateValuesAgainstDefs(opts.values, Array.from(byKey.values()) as any, {
|
|
61
|
+
rejectUndeclaredKeys: opts.rejectUndeclaredKeys === true,
|
|
62
|
+
})
|
|
55
63
|
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import * as React from 'react'
|
|
6
6
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@open-mercato/ui/primitives/dialog'
|
|
7
|
+
import { useDialogKeyHandler } from '@open-mercato/ui/hooks/useDialogKeyHandler'
|
|
7
8
|
import { Badge } from '@open-mercato/ui/primitives/badge'
|
|
8
9
|
import { Button } from '@open-mercato/ui/primitives/button'
|
|
9
10
|
import { Input } from '@open-mercato/ui/primitives/input'
|
|
@@ -716,22 +717,20 @@ export function AdjustmentDialog({
|
|
|
716
717
|
]
|
|
717
718
|
)
|
|
718
719
|
|
|
720
|
+
const handleSubmitForm = React.useCallback(
|
|
721
|
+
() => dialogContentRef.current?.querySelector('form')?.requestSubmit(),
|
|
722
|
+
[],
|
|
723
|
+
)
|
|
724
|
+
const handleKeyDown = useDialogKeyHandler({
|
|
725
|
+
onConfirm: handleSubmitForm,
|
|
726
|
+
onCancel: () => onOpenChange(false),
|
|
727
|
+
})
|
|
728
|
+
|
|
719
729
|
return (
|
|
720
730
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
721
731
|
<DialogContent
|
|
722
732
|
className="sm:max-w-5xl"
|
|
723
|
-
onKeyDown={
|
|
724
|
-
if (event.key === 'Escape') {
|
|
725
|
-
event.preventDefault()
|
|
726
|
-
onOpenChange(false)
|
|
727
|
-
return
|
|
728
|
-
}
|
|
729
|
-
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
|
730
|
-
event.preventDefault()
|
|
731
|
-
const form = dialogContentRef.current?.querySelector('form')
|
|
732
|
-
form?.requestSubmit()
|
|
733
|
-
}
|
|
734
|
-
}}
|
|
733
|
+
onKeyDown={handleKeyDown}
|
|
735
734
|
ref={dialogContentRef}
|
|
736
735
|
>
|
|
737
736
|
<DialogHeader>
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
DialogHeader,
|
|
22
22
|
DialogTitle,
|
|
23
23
|
} from "@open-mercato/ui/primitives/dialog";
|
|
24
|
+
import { useDialogKeyHandler } from "@open-mercato/ui/hooks/useDialogKeyHandler";
|
|
24
25
|
import { Button } from "@open-mercato/ui/primitives/button";
|
|
25
26
|
import { Input } from "@open-mercato/ui/primitives/input";
|
|
26
27
|
import {
|
|
@@ -2795,6 +2796,15 @@ export function LineItemDialog({
|
|
|
2795
2796
|
resetForm,
|
|
2796
2797
|
]);
|
|
2797
2798
|
|
|
2799
|
+
const handleSubmitForm = React.useCallback(
|
|
2800
|
+
() => dialogContentRef.current?.querySelector("form")?.requestSubmit(),
|
|
2801
|
+
[],
|
|
2802
|
+
)
|
|
2803
|
+
const handleKeyDown = useDialogKeyHandler({
|
|
2804
|
+
onConfirm: handleSubmitForm,
|
|
2805
|
+
onCancel: closeDialog,
|
|
2806
|
+
})
|
|
2807
|
+
|
|
2798
2808
|
return (
|
|
2799
2809
|
<Dialog
|
|
2800
2810
|
open={open}
|
|
@@ -2803,16 +2813,7 @@ export function LineItemDialog({
|
|
|
2803
2813
|
<DialogContent
|
|
2804
2814
|
className="sm:max-w-5xl"
|
|
2805
2815
|
ref={dialogContentRef}
|
|
2806
|
-
onKeyDown={
|
|
2807
|
-
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
|
2808
|
-
event.preventDefault();
|
|
2809
|
-
dialogContentRef.current?.querySelector("form")?.requestSubmit();
|
|
2810
|
-
}
|
|
2811
|
-
if (event.key === "Escape") {
|
|
2812
|
-
event.preventDefault();
|
|
2813
|
-
closeDialog();
|
|
2814
|
-
}
|
|
2815
|
-
}}
|
|
2816
|
+
onKeyDown={handleKeyDown}
|
|
2816
2817
|
>
|
|
2817
2818
|
<DialogHeader>
|
|
2818
2819
|
<DialogTitle>
|
|
@@ -9,6 +9,7 @@ import { collectCustomFieldValues } from '@open-mercato/ui/backend/utils/customF
|
|
|
9
9
|
import { createCrud, updateCrud } from '@open-mercato/ui/backend/utils/crud'
|
|
10
10
|
import { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'
|
|
11
11
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@open-mercato/ui/primitives/dialog'
|
|
12
|
+
import { useDialogKeyHandler } from '@open-mercato/ui/hooks/useDialogKeyHandler'
|
|
12
13
|
import { Input } from '@open-mercato/ui/primitives/input'
|
|
13
14
|
import { Spinner } from '@open-mercato/ui/primitives/spinner'
|
|
14
15
|
import { E } from '#generated/entities.ids.generated'
|
|
@@ -552,29 +553,21 @@ export function PaymentDialog({
|
|
|
552
553
|
[currencyCode, mode, onOpenChange, onSaved, orderId, organizationId, payment?.id, t, tenantId]
|
|
553
554
|
)
|
|
554
555
|
|
|
555
|
-
const
|
|
556
|
-
(
|
|
557
|
-
|
|
558
|
-
event.preventDefault()
|
|
559
|
-
const form = dialogContentRef.current?.querySelector('form')
|
|
560
|
-
form?.requestSubmit()
|
|
561
|
-
}
|
|
562
|
-
},
|
|
563
|
-
[]
|
|
556
|
+
const handleSubmitForm = React.useCallback(
|
|
557
|
+
() => dialogContentRef.current?.querySelector('form')?.requestSubmit(),
|
|
558
|
+
[],
|
|
564
559
|
)
|
|
560
|
+
const handleKeyDown = useDialogKeyHandler({
|
|
561
|
+
onConfirm: handleSubmitForm,
|
|
562
|
+
onCancel: () => onOpenChange(false),
|
|
563
|
+
})
|
|
565
564
|
|
|
566
565
|
return (
|
|
567
566
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
568
567
|
<DialogContent
|
|
569
568
|
ref={dialogContentRef}
|
|
570
569
|
className="sm:max-w-5xl"
|
|
571
|
-
onKeyDown={
|
|
572
|
-
if (event.key === 'Escape') {
|
|
573
|
-
event.preventDefault()
|
|
574
|
-
onOpenChange(false)
|
|
575
|
-
}
|
|
576
|
-
handleShortcutSubmit(event)
|
|
577
|
-
}}
|
|
570
|
+
onKeyDown={handleKeyDown}
|
|
578
571
|
>
|
|
579
572
|
<DialogHeader>
|
|
580
573
|
<DialogTitle>
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import * as React from 'react'
|
|
4
4
|
import { MapPin, Truck } from 'lucide-react'
|
|
5
5
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@open-mercato/ui/primitives/dialog'
|
|
6
|
+
import { useDialogKeyHandler } from '@open-mercato/ui/hooks/useDialogKeyHandler'
|
|
6
7
|
import { Input } from '@open-mercato/ui/primitives/input'
|
|
7
8
|
import { Textarea } from '@open-mercato/ui/primitives/textarea'
|
|
8
9
|
import { Label } from '@open-mercato/ui/primitives/label'
|
|
@@ -1109,13 +1110,14 @@ export function ShipmentDialog({
|
|
|
1109
1110
|
],
|
|
1110
1111
|
)
|
|
1111
1112
|
|
|
1112
|
-
const
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1113
|
+
const handleSubmitForm = React.useCallback(
|
|
1114
|
+
() => dialogContentRef.current?.querySelector('form')?.requestSubmit(),
|
|
1115
|
+
[],
|
|
1116
|
+
)
|
|
1117
|
+
const handleKeyDown = useDialogKeyHandler({
|
|
1118
|
+
onConfirm: handleSubmitForm,
|
|
1119
|
+
onCancel: onClose,
|
|
1120
|
+
})
|
|
1119
1121
|
|
|
1120
1122
|
const fields = React.useMemo<CrudField[]>(() => {
|
|
1121
1123
|
const shippingAdjustmentLabel = t(
|
|
@@ -1523,13 +1525,7 @@ export function ShipmentDialog({
|
|
|
1523
1525
|
<DialogContent
|
|
1524
1526
|
ref={dialogContentRef}
|
|
1525
1527
|
className="sm:max-w-5xl"
|
|
1526
|
-
onKeyDown={
|
|
1527
|
-
if (event.key === 'Escape') {
|
|
1528
|
-
event.preventDefault()
|
|
1529
|
-
onClose()
|
|
1530
|
-
}
|
|
1531
|
-
handleShortcutSubmit(event)
|
|
1532
|
-
}}
|
|
1528
|
+
onKeyDown={handleKeyDown}
|
|
1533
1529
|
>
|
|
1534
1530
|
<DialogHeader>
|
|
1535
1531
|
<DialogTitle>
|
|
@@ -6,7 +6,9 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
|
6
6
|
import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
7
7
|
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
8
8
|
import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
|
|
9
|
+
import { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
9
10
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
11
|
+
import { LockMode } from '@mikro-orm/core'
|
|
10
12
|
import { StaffTimeEntry, StaffTimeEntrySegment } from '../../../../../../data/entities'
|
|
11
13
|
import { staffTimeEntrySegmentUpdateSchema } from '../../../../../../data/validators'
|
|
12
14
|
import { getStaffMemberByUserId } from '../../../../../../lib/staffMemberResolver'
|
|
@@ -110,25 +112,62 @@ export async function PATCH(req: Request) {
|
|
|
110
112
|
)
|
|
111
113
|
}
|
|
112
114
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
115
|
+
// Apply the segment edit inside a single transaction with a PESSIMISTIC_WRITE
|
|
116
|
+
// lock on the parent time entry row, re-loading the segment under the lock so
|
|
117
|
+
// concurrent segment edits / timer-stop recomputes on the same entry serialize
|
|
118
|
+
// instead of racing on a shared in-memory snapshot (issue #2416).
|
|
119
|
+
let updatedSegment: StaffTimeEntrySegment
|
|
120
|
+
try {
|
|
121
|
+
updatedSegment = await em.transactional(async (trx) => {
|
|
122
|
+
const lockedEntry = await findOneWithDecryption(
|
|
123
|
+
trx,
|
|
124
|
+
StaffTimeEntry,
|
|
125
|
+
{ id: ids.entryId, tenantId, organizationId, deletedAt: null },
|
|
126
|
+
{ lockMode: LockMode.PESSIMISTIC_WRITE },
|
|
127
|
+
scopeCtx,
|
|
128
|
+
)
|
|
129
|
+
if (!lockedEntry) {
|
|
130
|
+
throw new CrudHttpError(404, { error: 'Time entry not found' })
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const lockedSegment = await findOneWithDecryption(
|
|
134
|
+
trx,
|
|
135
|
+
StaffTimeEntrySegment,
|
|
136
|
+
{ id: ids.segmentId, timeEntryId: ids.entryId, tenantId, organizationId, deletedAt: null },
|
|
137
|
+
{},
|
|
138
|
+
scopeCtx,
|
|
139
|
+
)
|
|
140
|
+
if (!lockedSegment) {
|
|
141
|
+
throw new CrudHttpError(404, { error: 'Segment not found' })
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (parsed.data.startedAt !== undefined) {
|
|
145
|
+
lockedSegment.startedAt = parsed.data.startedAt
|
|
146
|
+
}
|
|
147
|
+
if (parsed.data.endedAt !== undefined) {
|
|
148
|
+
lockedSegment.endedAt = parsed.data.endedAt ?? null
|
|
149
|
+
}
|
|
150
|
+
if (parsed.data.segmentType !== undefined) {
|
|
151
|
+
lockedSegment.segmentType = parsed.data.segmentType
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
await trx.flush()
|
|
155
|
+
return lockedSegment
|
|
156
|
+
})
|
|
157
|
+
} catch (err) {
|
|
158
|
+
if (isCrudHttpError(err)) {
|
|
159
|
+
return NextResponse.json(err.body, { status: err.status })
|
|
160
|
+
}
|
|
161
|
+
throw err
|
|
121
162
|
}
|
|
122
163
|
|
|
123
|
-
await em.flush()
|
|
124
|
-
|
|
125
164
|
if (guardResult.afterSuccessCallbacks.length) {
|
|
126
165
|
await runStaffMutationGuardAfterSuccess(guardResult.afterSuccessCallbacks, {
|
|
127
166
|
tenantId,
|
|
128
167
|
organizationId,
|
|
129
168
|
userId: auth.sub ?? '',
|
|
130
169
|
resourceKind: 'staff.timesheets.time_entry_segment',
|
|
131
|
-
resourceId:
|
|
170
|
+
resourceId: updatedSegment.id,
|
|
132
171
|
operation: 'update',
|
|
133
172
|
requestMethod: req.method,
|
|
134
173
|
requestHeaders: req.headers,
|
|
@@ -138,13 +177,13 @@ export async function PATCH(req: Request) {
|
|
|
138
177
|
return NextResponse.json({
|
|
139
178
|
ok: true,
|
|
140
179
|
item: {
|
|
141
|
-
id:
|
|
142
|
-
timeEntryId:
|
|
143
|
-
startedAt:
|
|
144
|
-
endedAt:
|
|
145
|
-
segmentType:
|
|
146
|
-
createdAt:
|
|
147
|
-
updatedAt:
|
|
180
|
+
id: updatedSegment.id,
|
|
181
|
+
timeEntryId: updatedSegment.timeEntryId,
|
|
182
|
+
startedAt: updatedSegment.startedAt,
|
|
183
|
+
endedAt: updatedSegment.endedAt,
|
|
184
|
+
segmentType: updatedSegment.segmentType,
|
|
185
|
+
createdAt: updatedSegment.createdAt,
|
|
186
|
+
updatedAt: updatedSegment.updatedAt,
|
|
148
187
|
},
|
|
149
188
|
})
|
|
150
189
|
}
|
|
@@ -9,6 +9,7 @@ import { parseScopedCommandInput } from '@open-mercato/shared/lib/api/scoped'
|
|
|
9
9
|
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
10
10
|
import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
|
|
11
11
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
12
|
+
import { LockMode } from '@mikro-orm/core'
|
|
12
13
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
13
14
|
import { StaffTimeEntry, StaffTimeEntrySegment } from '../../../../../data/entities'
|
|
14
15
|
import { staffTimeEntrySegmentCreateSchema } from '../../../../../data/validators'
|
|
@@ -109,17 +110,35 @@ export async function POST(req: Request) {
|
|
|
109
110
|
)
|
|
110
111
|
}
|
|
111
112
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
113
|
+
// Create the segment inside a single transaction with a PESSIMISTIC_WRITE
|
|
114
|
+
// lock on the parent time entry row, so segment writes serialize against
|
|
115
|
+
// concurrent timer-stop / segment mutations that recompute the entry from a
|
|
116
|
+
// shared snapshot (issue #2416).
|
|
117
|
+
const segment = await em.transactional(async (trx) => {
|
|
118
|
+
const lockedEntry = await findOneWithDecryption(
|
|
119
|
+
trx,
|
|
120
|
+
StaffTimeEntry,
|
|
121
|
+
{ id: entryId, tenantId, organizationId, deletedAt: null },
|
|
122
|
+
{ lockMode: LockMode.PESSIMISTIC_WRITE },
|
|
123
|
+
scopeCtx,
|
|
124
|
+
)
|
|
125
|
+
if (!lockedEntry) {
|
|
126
|
+
throw new CrudHttpError(404, { error: translate('staff.timesheets.errors.entryNotFound', 'Time entry not found.') })
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const segmentData = {
|
|
130
|
+
tenantId: input.tenantId,
|
|
131
|
+
organizationId: input.organizationId,
|
|
132
|
+
timeEntryId: input.timeEntryId,
|
|
133
|
+
startedAt: input.startedAt,
|
|
134
|
+
endedAt: input.endedAt ?? null,
|
|
135
|
+
segmentType: input.segmentType,
|
|
136
|
+
}
|
|
137
|
+
const created = trx.create(StaffTimeEntrySegment, segmentData as never)
|
|
121
138
|
|
|
122
|
-
|
|
139
|
+
await trx.flush()
|
|
140
|
+
return created
|
|
141
|
+
})
|
|
123
142
|
|
|
124
143
|
if (guardResult.afterSuccessCallbacks.length) {
|
|
125
144
|
await runStaffMutationGuardAfterSuccess(guardResult.afterSuccessCallbacks, {
|
|
@@ -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 } 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'
|
|
@@ -98,20 +99,43 @@ export async function POST(req: Request) {
|
|
|
98
99
|
)
|
|
99
100
|
}
|
|
100
101
|
|
|
101
|
-
|
|
102
|
-
entry
|
|
103
|
-
entry
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
102
|
+
// Start the timer inside a single transaction with a PESSIMISTIC_WRITE lock
|
|
103
|
+
// on the time entry row, re-checking startedAt under the lock so two
|
|
104
|
+
// concurrent timer-start calls on the same entry cannot both create an
|
|
105
|
+
// initial work segment (issue #2416).
|
|
106
|
+
const now = await em.transactional(async (trx) => {
|
|
107
|
+
const lockedEntry = await findOneWithDecryption(
|
|
108
|
+
trx,
|
|
109
|
+
StaffTimeEntry,
|
|
110
|
+
{ id: entryId, tenantId, organizationId, deletedAt: null },
|
|
111
|
+
{ lockMode: LockMode.PESSIMISTIC_WRITE },
|
|
112
|
+
scopeCtx,
|
|
113
|
+
)
|
|
114
|
+
if (!lockedEntry) {
|
|
115
|
+
throw new CrudHttpError(404, { error: translate('staff.timesheets.errors.entryNotFound', 'Time entry not found.') })
|
|
116
|
+
}
|
|
117
|
+
if (lockedEntry.startedAt) {
|
|
118
|
+
throw new CrudHttpError(409, {
|
|
119
|
+
error: translate('staff.timesheets.errors.timerAlreadyStarted', 'Timer is already started for this entry.'),
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const startedAt = new Date()
|
|
124
|
+
lockedEntry.startedAt = startedAt
|
|
125
|
+
lockedEntry.source = 'timer'
|
|
126
|
+
|
|
127
|
+
const segmentData = {
|
|
128
|
+
tenantId,
|
|
129
|
+
organizationId,
|
|
130
|
+
timeEntryId: lockedEntry.id,
|
|
131
|
+
startedAt,
|
|
132
|
+
segmentType: 'work' as const,
|
|
133
|
+
}
|
|
134
|
+
trx.create(StaffTimeEntrySegment, segmentData as never)
|
|
135
|
+
|
|
136
|
+
await trx.flush()
|
|
137
|
+
return startedAt
|
|
138
|
+
})
|
|
115
139
|
|
|
116
140
|
void emitStaffEvent('staff.timesheets.time_entry.timer_started', {
|
|
117
141
|
id: entry.id,
|