@open-mercato/core 0.5.1-develop.3045.b4b3320cc2 → 0.6.0

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 (106) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +21 -1
  3. package/dist/modules/api_keys/api/keys/route.js +9 -0
  4. package/dist/modules/api_keys/api/keys/route.js.map +2 -2
  5. package/dist/modules/audit_logs/services/accessLogService.js +13 -0
  6. package/dist/modules/audit_logs/services/accessLogService.js.map +3 -3
  7. package/dist/modules/audit_logs/services/actionLogService.js +6 -5
  8. package/dist/modules/audit_logs/services/actionLogService.js.map +2 -2
  9. package/dist/modules/auth/api/roles/acl/route.js +27 -37
  10. package/dist/modules/auth/api/roles/acl/route.js.map +2 -2
  11. package/dist/modules/auth/api/users/route.js +41 -28
  12. package/dist/modules/auth/api/users/route.js.map +3 -3
  13. package/dist/modules/auth/lib/grantChecks.js +160 -0
  14. package/dist/modules/auth/lib/grantChecks.js.map +7 -0
  15. package/dist/modules/configs/cli.js +11 -0
  16. package/dist/modules/configs/cli.js.map +2 -2
  17. package/dist/modules/configs/lib/touchGeneratedBarrels.js +46 -0
  18. package/dist/modules/configs/lib/touchGeneratedBarrels.js.map +7 -0
  19. package/dist/modules/customers/api/activities/route.js +1 -52
  20. package/dist/modules/customers/api/activities/route.js.map +2 -2
  21. package/dist/modules/customers/api/interactions/counts/route.js +2 -1
  22. package/dist/modules/customers/api/interactions/counts/route.js.map +2 -2
  23. package/dist/modules/customers/api/interactions/route.js +21 -1
  24. package/dist/modules/customers/api/interactions/route.js.map +2 -2
  25. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js +7 -3
  26. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js.map +2 -2
  27. package/dist/modules/customers/backend/customers/deals/[id]/page.js +5 -1
  28. package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
  29. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +7 -3
  30. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
  31. package/dist/modules/customers/components/detail/ActivitiesCard.js +62 -6
  32. package/dist/modules/customers/components/detail/ActivitiesCard.js.map +2 -2
  33. package/dist/modules/customers/components/detail/ActivitiesDayStrip.js +21 -6
  34. package/dist/modules/customers/components/detail/ActivitiesDayStrip.js.map +2 -2
  35. package/dist/modules/customers/components/detail/ActivitiesSection.js +37 -5
  36. package/dist/modules/customers/components/detail/ActivitiesSection.js.map +2 -2
  37. package/dist/modules/customers/components/detail/ActivityCard.js +69 -17
  38. package/dist/modules/customers/components/detail/ActivityCard.js.map +2 -2
  39. package/dist/modules/customers/components/detail/ActivityHistorySection.js +94 -34
  40. package/dist/modules/customers/components/detail/ActivityHistorySection.js.map +2 -2
  41. package/dist/modules/customers/components/detail/ActivityLogTab.js +3 -1
  42. package/dist/modules/customers/components/detail/ActivityLogTab.js.map +2 -2
  43. package/dist/modules/customers/components/detail/ActivityTimeline.js +41 -8
  44. package/dist/modules/customers/components/detail/ActivityTimeline.js.map +2 -2
  45. package/dist/modules/customers/components/detail/ActivityTimelineFilters.js +19 -6
  46. package/dist/modules/customers/components/detail/ActivityTimelineFilters.js.map +2 -2
  47. package/dist/modules/customers/components/detail/ActivityTypeSelector.js +4 -3
  48. package/dist/modules/customers/components/detail/ActivityTypeSelector.js.map +2 -2
  49. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +80 -12
  50. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
  51. package/dist/modules/customers/components/detail/schedule/DateTimeFields.js +65 -10
  52. package/dist/modules/customers/components/detail/schedule/DateTimeFields.js.map +2 -2
  53. package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js +10 -5
  54. package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js.map +2 -2
  55. package/dist/modules/customers/data/validators.js +74 -2
  56. package/dist/modules/customers/data/validators.js.map +2 -2
  57. package/dist/modules/customers/lib/legacyActivityBridge.js +61 -0
  58. package/dist/modules/customers/lib/legacyActivityBridge.js.map +7 -0
  59. package/dist/modules/integrations/data/validators.js +2 -2
  60. package/dist/modules/integrations/data/validators.js.map +2 -2
  61. package/dist/modules/integrations/lib/credentials-service.js +12 -1
  62. package/dist/modules/integrations/lib/credentials-service.js.map +2 -2
  63. package/dist/modules/messages/commands/actions.js +29 -14
  64. package/dist/modules/messages/commands/actions.js.map +2 -2
  65. package/dist/modules/messages/lib/actions.js +24 -4
  66. package/dist/modules/messages/lib/actions.js.map +2 -2
  67. package/dist/modules/sales/api/documents/factory.js +49 -36
  68. package/dist/modules/sales/api/documents/factory.js.map +2 -2
  69. package/package.json +9 -10
  70. package/src/modules/api_keys/api/keys/route.ts +9 -0
  71. package/src/modules/audit_logs/services/accessLogService.ts +20 -0
  72. package/src/modules/audit_logs/services/actionLogService.ts +13 -5
  73. package/src/modules/auth/api/roles/acl/route.ts +32 -46
  74. package/src/modules/auth/api/users/route.ts +48 -33
  75. package/src/modules/auth/lib/grantChecks.ts +234 -0
  76. package/src/modules/configs/cli.ts +11 -0
  77. package/src/modules/configs/lib/touchGeneratedBarrels.ts +61 -0
  78. package/src/modules/customers/api/activities/route.ts +1 -76
  79. package/src/modules/customers/api/interactions/counts/route.ts +2 -1
  80. package/src/modules/customers/api/interactions/route.ts +28 -1
  81. package/src/modules/customers/backend/customers/companies-v2/[id]/page.tsx +13 -3
  82. package/src/modules/customers/backend/customers/deals/[id]/page.tsx +14 -2
  83. package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +13 -3
  84. package/src/modules/customers/components/detail/ActivitiesCard.tsx +92 -5
  85. package/src/modules/customers/components/detail/ActivitiesDayStrip.tsx +38 -6
  86. package/src/modules/customers/components/detail/ActivitiesSection.tsx +37 -3
  87. package/src/modules/customers/components/detail/ActivityCard.tsx +79 -14
  88. package/src/modules/customers/components/detail/ActivityHistorySection.tsx +102 -33
  89. package/src/modules/customers/components/detail/ActivityLogTab.tsx +7 -1
  90. package/src/modules/customers/components/detail/ActivityTimeline.tsx +39 -5
  91. package/src/modules/customers/components/detail/ActivityTimelineFilters.tsx +29 -7
  92. package/src/modules/customers/components/detail/ActivityTypeSelector.tsx +3 -2
  93. package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +96 -13
  94. package/src/modules/customers/components/detail/schedule/DateTimeFields.tsx +50 -4
  95. package/src/modules/customers/components/detail/schedule/useScheduleFormState.ts +21 -5
  96. package/src/modules/customers/data/validators.ts +85 -2
  97. package/src/modules/customers/i18n/de.json +11 -0
  98. package/src/modules/customers/i18n/en.json +11 -0
  99. package/src/modules/customers/i18n/es.json +11 -0
  100. package/src/modules/customers/i18n/pl.json +11 -0
  101. package/src/modules/customers/lib/legacyActivityBridge.ts +106 -0
  102. package/src/modules/integrations/data/validators.ts +8 -6
  103. package/src/modules/integrations/lib/credentials-service.ts +15 -1
  104. package/src/modules/messages/commands/actions.ts +28 -13
  105. package/src/modules/messages/lib/actions.ts +34 -3
  106. package/src/modules/sales/api/documents/factory.ts +55 -38
@@ -4,7 +4,9 @@ import * as React from 'react'
4
4
  import { Users, Phone, Check, Mail, Calendar, AlertTriangle, X } from 'lucide-react'
5
5
  import { cn } from '@open-mercato/shared/lib/utils'
6
6
  import { useT } from '@open-mercato/shared/lib/i18n/context'
7
+ import { validatePhoneNumber } from '@open-mercato/shared/lib/phone'
7
8
  import { apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
9
+ import { mapCrudServerErrorToFormErrors } from '@open-mercato/ui/backend/utils/serverErrors'
8
10
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
9
11
  import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
10
12
  import { Alert, AlertDescription, AlertTitle } from '@open-mercato/ui/primitives/alert'
@@ -12,7 +14,7 @@ import { Button } from '@open-mercato/ui/primitives/button'
12
14
  import { IconButton } from '@open-mercato/ui/primitives/icon-button'
13
15
  import { Dialog, DialogContent, DialogTitle } from '@open-mercato/ui/primitives/dialog'
14
16
  import { VisuallyHidden } from '@radix-ui/react-visually-hidden'
15
- import { SwitchableMarkdownInput } from '@open-mercato/ui/backend/inputs'
17
+ import { PhoneNumberField, SwitchableMarkdownInput } from '@open-mercato/ui/backend/inputs'
16
18
  import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
17
19
  import {
18
20
  useScheduleFormState,
@@ -112,15 +114,41 @@ export function ScheduleActivityDialog({
112
114
  const [callDirection, setCallDirection] = React.useState<'outbound' | 'inbound'>('outbound')
113
115
  const [callOutcome, setCallOutcome] = React.useState<string | null>(null)
114
116
  const [callPhoneNumber, setCallPhoneNumber] = React.useState('')
117
+ const [callPhoneError, setCallPhoneError] = React.useState<string | null>(null)
115
118
  const [taskPriority, setTaskPriority] = React.useState<string>('medium')
119
+ const callPhoneInvalidMessage = React.useMemo(
120
+ () =>
121
+ t(
122
+ 'customers.activities.errors.phoneInvalid',
123
+ 'Enter a valid phone number with country code (e.g. +1 212 555 1234)',
124
+ ),
125
+ [t],
126
+ )
127
+ const translateErrorMessage = React.useCallback(
128
+ (message: string | undefined, fallback: string) => {
129
+ const key = typeof message === 'string' ? message.trim() : ''
130
+ return key ? t(key, key) : fallback
131
+ },
132
+ [t],
133
+ )
116
134
 
117
135
  React.useEffect(() => {
118
136
  if (!open) return
119
- const raw = editData as (Record<string, unknown> & { customValues?: unknown }) | null | undefined
137
+ const raw = editData as (Record<string, unknown> & { customValues?: unknown; phoneNumber?: unknown }) | null | undefined
120
138
  const cv = (raw?.customValues && typeof raw.customValues === 'object' ? raw.customValues : null) as Record<string, unknown> | null
121
139
  setCallDirection(typeof cv?.callDirection === 'string' && cv.callDirection === 'inbound' ? 'inbound' : 'outbound')
122
140
  setCallOutcome(typeof cv?.callOutcome === 'string' ? cv.callOutcome : null)
123
- setCallPhoneNumber(typeof cv?.callPhoneNumber === 'string' ? cv.callPhoneNumber : '')
141
+ // Seed phone number from either top-level `phoneNumber` (newer write path)
142
+ // or legacy `customValues.callPhoneNumber` so previously-saved calls still
143
+ // round-trip on edit (#1808).
144
+ const seededPhone =
145
+ typeof raw?.phoneNumber === 'string' && raw.phoneNumber.trim().length > 0
146
+ ? raw.phoneNumber
147
+ : typeof cv?.callPhoneNumber === 'string'
148
+ ? cv.callPhoneNumber
149
+ : ''
150
+ setCallPhoneNumber(seededPhone)
151
+ setCallPhoneError(null)
124
152
  setTaskPriority(typeof cv?.taskPriority === 'string' ? cv.taskPriority : 'medium')
125
153
  }, [open, editData])
126
154
 
@@ -131,9 +159,15 @@ export function ScheduleActivityDialog({
131
159
  setCallDirection('outbound')
132
160
  setCallOutcome(null)
133
161
  setCallPhoneNumber('')
162
+ setCallPhoneError(null)
134
163
  setTaskPriority('medium')
135
164
  }, [state.activityType, open, isEditing])
136
165
 
166
+ const handleCallPhoneChange = React.useCallback((next: string | undefined) => {
167
+ setCallPhoneNumber(next ?? '')
168
+ setCallPhoneError(null)
169
+ }, [])
170
+
137
171
  const formSnapshot = React.useMemo(() => JSON.stringify({
138
172
  activityType: state.activityType,
139
173
  title: state.title,
@@ -284,8 +318,41 @@ export function ScheduleActivityDialog({
284
318
  return () => clearTimeout(timer)
285
319
  }, [editData?.id, open, state.date, state.startTime, state.duration, state.allDay, t]) // eslint-disable-line react-hooks/exhaustive-deps
286
320
 
321
+ const trimmedDate = state.date.trim()
322
+ const trimmedStartTime = state.startTime.trim()
323
+ const trimmedCallPhone = callPhoneNumber.trim()
324
+ const isDateMissing = !trimmedDate
325
+ const isTimeMissing = !state.allDay && !trimmedStartTime
326
+ const isSubmitDisabled =
327
+ state.saving ||
328
+ !state.title.trim() ||
329
+ isDateMissing ||
330
+ isTimeMissing
331
+
287
332
  const handleSave = React.useCallback(async () => {
288
333
  if (!state.title.trim()) return
334
+ if (isDateMissing) {
335
+ flash(t('customers.activities.errors.dateRequired', 'Date is required'), 'error')
336
+ return
337
+ }
338
+ if (isTimeMissing) {
339
+ flash(t('customers.activities.errors.timeRequired', 'Time is required'), 'error')
340
+ return
341
+ }
342
+ let phoneNumberForPayload: string | undefined
343
+ if (state.activityType === 'call' && trimmedCallPhone) {
344
+ const phoneValidation = validatePhoneNumber(trimmedCallPhone)
345
+ if (!phoneValidation.valid) {
346
+ setCallPhoneError(callPhoneInvalidMessage)
347
+ flash(callPhoneInvalidMessage, 'error')
348
+ return
349
+ }
350
+ phoneNumberForPayload = phoneValidation.normalized ?? trimmedCallPhone
351
+ setCallPhoneError(null)
352
+ if (phoneNumberForPayload !== callPhoneNumber) {
353
+ setCallPhoneNumber(phoneNumberForPayload)
354
+ }
355
+ }
289
356
  state.setSaving(true)
290
357
  try {
291
358
  const scheduledAt = state.allDay
@@ -301,7 +368,7 @@ export function ScheduleActivityDialog({
301
368
  if (state.activityType === 'call') {
302
369
  customValues.callDirection = callDirection
303
370
  if (callOutcome) customValues.callOutcome = callOutcome
304
- if (callPhoneNumber.trim()) customValues.callPhoneNumber = callPhoneNumber.trim()
371
+ if (phoneNumberForPayload) customValues.callPhoneNumber = phoneNumberForPayload
305
372
  }
306
373
  if (state.activityType === 'task') {
307
374
  customValues.taskPriority = taskPriority
@@ -314,6 +381,9 @@ export function ScheduleActivityDialog({
314
381
  title: state.title.trim(),
315
382
  body: state.description.trim() || null,
316
383
  status: 'planned',
384
+ date: trimmedDate,
385
+ time: state.allDay ? '00:00' : trimmedStartTime,
386
+ phoneNumber: state.activityType === 'call' ? phoneNumberForPayload : undefined,
317
387
  scheduledAt,
318
388
  durationMinutes: visibleFields.has('duration') && !state.allDay ? state.duration : null,
319
389
  location: visibleFields.has('location') ? (state.location.trim() || null) : null,
@@ -350,12 +420,23 @@ export function ScheduleActivityDialog({
350
420
  onClose()
351
421
  // Delay data reload so the dialog can unmount cleanly and Radix restores body scroll
352
422
  requestAnimationFrame(() => { onActivityCreated?.() })
353
- } catch {
354
- flash(t('customers.schedule.error', 'Failed to schedule activity'), 'error')
423
+ } catch (err) {
424
+ const { message, fieldErrors } = mapCrudServerErrorToFormErrors(err)
425
+ const phoneFieldError = fieldErrors?.phoneNumber
426
+ if (state.activityType === 'call' && phoneFieldError) {
427
+ const translatedPhoneError = translateErrorMessage(phoneFieldError, callPhoneInvalidMessage)
428
+ setCallPhoneError(translatedPhoneError)
429
+ flash(translatedPhoneError, 'error')
430
+ return
431
+ }
432
+ flash(
433
+ translateErrorMessage(message, t('customers.schedule.error', 'Failed to schedule activity')),
434
+ 'error',
435
+ )
355
436
  } finally {
356
437
  state.setSaving(false)
357
438
  }
358
- }, [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, state.title, state.visibility, visibleFields]) // eslint-disable-line react-hooks/exhaustive-deps
439
+ }, [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
359
440
 
360
441
  const handleKeyDown = React.useCallback((e: React.KeyboardEvent) => {
361
442
  if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
@@ -580,15 +661,17 @@ export function ScheduleActivityDialog({
580
661
  {/* Location (or phone number for calls) */}
581
662
  {state.activityType === 'call' ? (
582
663
  <div className="flex flex-col gap-1.5">
583
- <label className="text-overline font-semibold uppercase text-muted-foreground tracking-wider">
664
+ <label htmlFor="schedule-call-phone" className="text-overline font-semibold uppercase text-muted-foreground tracking-wider">
584
665
  {t('customers.schedule.call.phoneLabel', 'Phone number')}
585
666
  </label>
586
- <input
587
- type="tel"
667
+ <PhoneNumberField
668
+ id="schedule-call-phone"
588
669
  value={callPhoneNumber}
589
- onChange={(e) => setCallPhoneNumber(e.target.value)}
670
+ onValueChange={handleCallPhoneChange}
590
671
  placeholder={t('customers.schedule.call.phonePlaceholder', '+1 555 000 0000')}
591
- className="w-full rounded-md border border-border bg-background px-3 py-2.5 text-sm text-foreground outline-none focus:border-foreground"
672
+ externalError={callPhoneError}
673
+ invalidLabel={callPhoneInvalidMessage}
674
+ minDigits={7}
592
675
  />
593
676
  </div>
594
677
  ) : (
@@ -642,7 +725,7 @@ export function ScheduleActivityDialog({
642
725
  <Button type="button" variant="outline" onClick={() => { void guardedClose() }} className="rounded-md border border-input bg-background px-5 py-3 text-sm font-semibold text-foreground">
643
726
  {t('customers.schedule.cancel', 'Cancel')}
644
727
  </Button>
645
- <Button type="button" onClick={handleSave} disabled={state.saving || !state.title.trim()} className="flex items-center gap-2 rounded-md bg-foreground px-5 py-3 text-sm font-semibold text-background hover:bg-foreground/90 disabled:opacity-50">
728
+ <Button type="button" onClick={handleSave} disabled={isSubmitDisabled} className="flex items-center gap-2 rounded-md bg-foreground px-5 py-3 text-sm font-semibold text-background hover:bg-foreground/90 disabled:opacity-50">
646
729
  <SaveIcon className="size-3.5" />
647
730
  {state.saving
648
731
  ? t('customers.schedule.saving', 'Saving...')
@@ -65,6 +65,11 @@ export function DateTimeFields({
65
65
  const showAllDay = isVisible(activityType, 'allDay')
66
66
  const showRecurrence = isVisible(activityType, 'recurrence')
67
67
 
68
+ const dateMissing = !date.trim()
69
+ const timeMissing = showStartTime && !allDay && !startTime.trim()
70
+ const dateErrorId = 'schedule-date-error'
71
+ const timeErrorId = 'schedule-time-error'
72
+
68
73
  return (
69
74
  <>
70
75
  {/* Date / Time / Duration */}
@@ -72,21 +77,62 @@ export function DateTimeFields({
72
77
  <div className="flex flex-[2] flex-col gap-1.5">
73
78
  <label className="text-overline font-semibold text-muted-foreground tracking-wider">
74
79
  {getFieldLabel(activityType, 'date', t, 'customers.schedule.date', 'Date')}
80
+ <span aria-hidden="true" className="ml-1 text-status-error-foreground">*</span>
75
81
  </label>
76
- <div className="flex items-center gap-2 rounded-md border border-border bg-background px-3 py-2.5">
82
+ <div
83
+ className={cn(
84
+ 'flex items-center gap-2 rounded-md border bg-background px-3 py-2.5',
85
+ dateMissing ? 'border-status-error-border' : 'border-border',
86
+ )}
87
+ >
77
88
  <Calendar className="size-3.5 text-muted-foreground" />
78
- <input type="date" value={date} onChange={(e) => setDate(e.target.value)} className="flex-1 bg-transparent text-sm text-foreground focus:outline-none" />
89
+ <input
90
+ type="date"
91
+ value={date}
92
+ onChange={(e) => setDate(e.target.value)}
93
+ required
94
+ aria-required="true"
95
+ aria-invalid={dateMissing ? true : undefined}
96
+ aria-describedby={dateMissing ? dateErrorId : undefined}
97
+ className="flex-1 bg-transparent text-sm text-foreground focus:outline-none"
98
+ />
79
99
  </div>
100
+ {dateMissing ? (
101
+ <p id={dateErrorId} className="text-xs text-status-error-foreground">
102
+ {t('customers.activities.errors.dateRequired', 'Date is required')}
103
+ </p>
104
+ ) : null}
80
105
  </div>
81
106
  {showStartTime && (
82
107
  <div className="flex flex-1 flex-col gap-1.5">
83
108
  <label className="text-overline font-semibold text-muted-foreground tracking-wider">
84
109
  {getFieldLabel(activityType, 'startTime', t, 'customers.schedule.start', 'Start')}
110
+ <span aria-hidden="true" className="ml-1 text-status-error-foreground">*</span>
85
111
  </label>
86
- <div className="flex items-center gap-2 rounded-md border border-border bg-background px-3 py-2.5">
112
+ <div
113
+ className={cn(
114
+ 'flex items-center gap-2 rounded-md border bg-background px-3 py-2.5',
115
+ timeMissing ? 'border-status-error-border' : 'border-border',
116
+ )}
117
+ >
87
118
  <Clock className="size-3.5 text-muted-foreground" />
88
- <input type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} disabled={allDay} className="flex-1 bg-transparent text-sm text-foreground focus:outline-none disabled:opacity-50" />
119
+ <input
120
+ type="time"
121
+ value={startTime}
122
+ onChange={(e) => setStartTime(e.target.value)}
123
+ disabled={allDay}
124
+ required={!allDay}
125
+ aria-required={!allDay}
126
+ aria-invalid={timeMissing ? true : undefined}
127
+ aria-describedby={timeMissing ? timeErrorId : undefined}
128
+ className="flex-1 bg-transparent text-sm text-foreground focus:outline-none disabled:opacity-50"
129
+ />
89
130
  </div>
131
+ {timeMissing ? (
132
+ <p id={timeErrorId} className="text-xs text-status-error-foreground">
133
+ {t('customers.activities.errors.timeRequired', 'Time is required')}
134
+ </p>
135
+ ) : null}
90
136
  </div>
91
137
  )}
92
138
  {showDuration && (
@@ -1,4 +1,5 @@
1
1
  import * as React from 'react'
2
+ import { format } from 'date-fns'
2
3
  import type { ActivityType } from './fieldConfig'
3
4
 
4
5
  export type RsvpStatus = 'pending' | 'accepted' | 'declined' | 'tentative'
@@ -23,6 +24,12 @@ export type ScheduleActivityEditData = {
23
24
  title?: string | null
24
25
  body?: string | null
25
26
  scheduledAt?: string | null
27
+ /**
28
+ * Historical timestamp for completed activities (status `done`). Required for
29
+ * the edit prefill to restore the original date/time instead of falling back
30
+ * to "today" (#1807).
31
+ */
32
+ occurredAt?: string | null
26
33
  durationMinutes?: number | null
27
34
  location?: string | null
28
35
  allDay?: boolean | null
@@ -64,7 +71,7 @@ interface UseScheduleFormStateParams {
64
71
  export function useScheduleFormState({ open, editData }: UseScheduleFormStateParams) {
65
72
  const [activityType, setActivityType] = React.useState<ActivityType>('meeting')
66
73
  const [title, setTitle] = React.useState('')
67
- const [date, setDate] = React.useState(() => new Date().toISOString().slice(0, 10))
74
+ const [date, setDate] = React.useState(() => format(new Date(), 'yyyy-MM-dd'))
68
75
  const [startTime, setStartTime] = React.useState('10:00')
69
76
  const [duration, setDuration] = React.useState(30)
70
77
  const [allDay, setAllDay] = React.useState(false)
@@ -91,9 +98,18 @@ export function useScheduleFormState({ open, editData }: UseScheduleFormStatePar
91
98
  const resolvedType = (editData.interactionType as ActivityType) ?? 'meeting'
92
99
  setActivityType(resolvedType)
93
100
  setTitle(editData.title ?? '')
94
- const scheduledDate = editData.scheduledAt ? new Date(editData.scheduledAt) : new Date()
95
- setDate(scheduledDate.toISOString().slice(0, 10))
96
- setStartTime(scheduledDate.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }))
101
+ // For historical activities the canonical timestamp is `occurredAt`; for
102
+ // planned/future ones it's `scheduledAt`. Without this fallback editing a
103
+ // past activity prefilled to "today" instead of its actual moment (#1807).
104
+ // Use `date-fns` `format(...)` so the seed values are in the user's local
105
+ // timezone (matches the cluster-E local-day convention).
106
+ const sourceTimestamp = editData.occurredAt ?? editData.scheduledAt ?? null
107
+ const seedDate = sourceTimestamp ? new Date(sourceTimestamp) : new Date()
108
+ const seedDateValid = !Number.isNaN(seedDate.getTime())
109
+ const fallbackNow = new Date()
110
+ const dateForForm = seedDateValid ? seedDate : fallbackNow
111
+ setDate(format(dateForForm, 'yyyy-MM-dd'))
112
+ setStartTime(format(dateForForm, 'HH:mm'))
97
113
  setDuration(editData.durationMinutes ?? 30)
98
114
  setAllDay(editData.allDay ?? false)
99
115
  setDescription(editData.body ?? '')
@@ -154,7 +170,7 @@ export function useScheduleFormState({ open, editData }: UseScheduleFormStatePar
154
170
  // Create mode: reset all fields
155
171
  setActivityType('meeting')
156
172
  setTitle('')
157
- setDate(new Date().toISOString().slice(0, 10))
173
+ setDate(format(new Date(), 'yyyy-MM-dd'))
158
174
  setStartTime('10:00')
159
175
  setDuration(30)
160
176
  setAllDay(false)
@@ -4,11 +4,17 @@ import { isValidPhoneNumber } from '@open-mercato/shared/lib/phone'
4
4
  const uuid = () => z.string().uuid()
5
5
 
6
6
  export const CUSTOMER_PHONE_INVALID_MESSAGE_KEY = 'customers.people.form.primaryPhone.invalid'
7
+ export const ACTIVITY_DATE_REQUIRED_MESSAGE_KEY = 'customers.activities.errors.dateRequired'
8
+ export const ACTIVITY_TIME_REQUIRED_MESSAGE_KEY = 'customers.activities.errors.timeRequired'
9
+ export const ACTIVITY_PHONE_REQUIRED_MESSAGE_KEY = 'customers.activities.errors.phoneRequired'
10
+ export const ACTIVITY_PHONE_INVALID_MESSAGE_KEY = 'customers.activities.errors.phoneInvalid'
7
11
 
8
12
  const phoneSchema = z.string().trim().max(50).refine((val) => {
9
13
  return isValidPhoneNumber(val)
10
14
  }, { message: CUSTOMER_PHONE_INVALID_MESSAGE_KEY }).optional()
11
15
 
16
+ const interactionPhoneNumberSchema = z.string().trim().max(50).optional().nullable()
17
+
12
18
  const scopedSchema = z.object({
13
19
  organizationId: uuid(),
14
20
  tenantId: uuid(),
@@ -140,6 +146,9 @@ export const activityCreateSchema = scopedSchema.extend({
140
146
  activityType: z.string().min(1).max(100),
141
147
  subject: z.string().max(200).optional(),
142
148
  body: z.string().max(8000).optional(),
149
+ date: z.string().trim().min(1, ACTIVITY_DATE_REQUIRED_MESSAGE_KEY).optional(),
150
+ time: z.string().trim().min(1, ACTIVITY_TIME_REQUIRED_MESSAGE_KEY).optional(),
151
+ phoneNumber: interactionPhoneNumberSchema,
143
152
  occurredAt: z.coerce.date().optional(),
144
153
  dealId: uuid().optional(),
145
154
  authorUserId: uuid().optional(),
@@ -351,13 +360,16 @@ const interactionExtendedFields = {
351
360
  guestPermissions: interactionGuestPermissionsSchema.optional().nullable(),
352
361
  } as const
353
362
 
354
- export const interactionCreateSchema = scopedSchema.extend({
363
+ const interactionCreateBaseSchema = scopedSchema.extend({
355
364
  id: z.string().uuid().optional(),
356
365
  entityId: z.string().uuid(),
357
366
  interactionType: z.string().trim().min(1).max(100),
358
367
  title: z.string().trim().max(500).optional().nullable(),
359
368
  body: z.string().trim().max(10000).optional().nullable(),
360
369
  status: z.enum(interactionStatusValues).optional().default('planned'),
370
+ date: z.string().trim().min(1, ACTIVITY_DATE_REQUIRED_MESSAGE_KEY).optional(),
371
+ time: z.string().trim().min(1, ACTIVITY_TIME_REQUIRED_MESSAGE_KEY).optional(),
372
+ phoneNumber: interactionPhoneNumberSchema,
361
373
  scheduledAt: z.coerce.date().optional().nullable(),
362
374
  occurredAt: z.coerce.date().optional().nullable(),
363
375
  priority: z.number().int().min(0).max(100).optional().nullable(),
@@ -370,9 +382,48 @@ export const interactionCreateSchema = scopedSchema.extend({
370
382
  ...interactionExtendedFields,
371
383
  })
372
384
 
385
+ function deriveScheduledAtFromDateTime(date?: string, time?: string): Date | null {
386
+ if (!date || typeof date !== 'string') return null
387
+ const trimmedDate = date.trim()
388
+ if (!trimmedDate) return null
389
+ const trimmedTime = typeof time === 'string' ? time.trim() : ''
390
+ const iso = trimmedTime ? `${trimmedDate}T${trimmedTime}:00` : `${trimmedDate}T00:00:00`
391
+ const parsed = new Date(iso)
392
+ return Number.isNaN(parsed.getTime()) ? null : parsed
393
+ }
394
+
395
+ export const interactionCreateSchema = interactionCreateBaseSchema
396
+ .superRefine((value, ctx) => {
397
+ if (value.interactionType === 'call' && value.phoneNumber !== undefined && value.phoneNumber !== null) {
398
+ const phone = typeof value.phoneNumber === 'string' ? value.phoneNumber.trim() : ''
399
+ if (!phone) {
400
+ ctx.addIssue({
401
+ code: z.ZodIssueCode.custom,
402
+ path: ['phoneNumber'],
403
+ message: ACTIVITY_PHONE_REQUIRED_MESSAGE_KEY,
404
+ })
405
+ } else if (!isValidPhoneNumber(phone)) {
406
+ ctx.addIssue({
407
+ code: z.ZodIssueCode.custom,
408
+ path: ['phoneNumber'],
409
+ message: ACTIVITY_PHONE_INVALID_MESSAGE_KEY,
410
+ })
411
+ }
412
+ }
413
+ })
414
+ // Derive `scheduledAt` from `date+time` when only the latter are sent so
415
+ // external API consumers don't silently persist `scheduled_at: null` after
416
+ // the validator already enforced non-empty date/time. The form already
417
+ // computes `scheduledAt` itself, so this branch is a no-op for the form path.
418
+ .transform((value) => {
419
+ if (value.scheduledAt) return value
420
+ const derived = deriveScheduledAtFromDateTime(value.date, value.time)
421
+ return derived ? { ...value, scheduledAt: derived } : value
422
+ })
423
+
373
424
  export type InteractionCreateInput = z.infer<typeof interactionCreateSchema>
374
425
 
375
- export const interactionUpdateSchema = z
426
+ const interactionUpdateBaseSchema = z
376
427
  .object({
377
428
  id: z.string().uuid(),
378
429
  })
@@ -383,6 +434,9 @@ export const interactionUpdateSchema = z
383
434
  title: z.string().trim().max(500).optional().nullable(),
384
435
  body: z.string().trim().max(10000).optional().nullable(),
385
436
  status: z.enum(interactionStatusValues).optional(),
437
+ date: z.string().trim().min(1, ACTIVITY_DATE_REQUIRED_MESSAGE_KEY).optional(),
438
+ time: z.string().trim().min(1, ACTIVITY_TIME_REQUIRED_MESSAGE_KEY).optional(),
439
+ phoneNumber: interactionPhoneNumberSchema,
386
440
  scheduledAt: z.coerce.date().optional().nullable(),
387
441
  occurredAt: z.coerce.date().optional().nullable(),
388
442
  priority: z.number().int().min(0).max(100).optional().nullable(),
@@ -397,6 +451,35 @@ export const interactionUpdateSchema = z
397
451
  .partial(),
398
452
  )
399
453
 
454
+ export const interactionUpdateSchema = interactionUpdateBaseSchema
455
+ .superRefine((value, ctx) => {
456
+ if (value.interactionType === 'call' && value.phoneNumber !== undefined) {
457
+ const phone = typeof value.phoneNumber === 'string' ? value.phoneNumber.trim() : ''
458
+ if (!phone) {
459
+ ctx.addIssue({
460
+ code: z.ZodIssueCode.custom,
461
+ path: ['phoneNumber'],
462
+ message: ACTIVITY_PHONE_REQUIRED_MESSAGE_KEY,
463
+ })
464
+ } else if (!isValidPhoneNumber(phone)) {
465
+ ctx.addIssue({
466
+ code: z.ZodIssueCode.custom,
467
+ path: ['phoneNumber'],
468
+ message: ACTIVITY_PHONE_INVALID_MESSAGE_KEY,
469
+ })
470
+ }
471
+ }
472
+ })
473
+ // Mirror the create-schema derivation for partial updates: when an external
474
+ // caller supplies `date+time` without `scheduledAt`, derive the timestamp so
475
+ // the update doesn't silently leave `scheduled_at` stale.
476
+ .transform((value) => {
477
+ if (value.scheduledAt !== undefined) return value
478
+ if (!value.date && !value.time) return value
479
+ const derived = deriveScheduledAtFromDateTime(value.date, value.time)
480
+ return derived ? { ...value, scheduledAt: derived } : value
481
+ })
482
+
400
483
  export type InteractionUpdateInput = z.infer<typeof interactionUpdateSchema>
401
484
 
402
485
  export const interactionCompleteSchema = z.object({
@@ -4,6 +4,9 @@
4
4
  "audit_logs.resource_kind.customers.comment": "Kommentar",
5
5
  "audit_logs.resource_kind.customers.todoLink": "Aufgabe",
6
6
  "backend.nav.configuration": "Konfiguration",
7
+ "customers.activities.actions.markDone": "Als erledigt markieren",
8
+ "customers.activities.actions.markDoneError": "Aktivität konnte nicht als erledigt markiert werden",
9
+ "customers.activities.actions.markDoneSuccess": "Aktivität als erledigt markiert",
7
10
  "customers.activities.add.call": "Anruf protokollieren",
8
11
  "customers.activities.add.email": "E-Mail verfassen",
9
12
  "customers.activities.add.meeting": "Neues Meeting",
@@ -24,6 +27,10 @@
24
27
  "customers.activities.card.empty": "Für diesen Tag ist nichts geplant.",
25
28
  "customers.activities.card.overdue": "{count} überfällig",
26
29
  "customers.activities.card.title": "Aktivitäten",
30
+ "customers.activities.errors.dateRequired": "Datum ist erforderlich",
31
+ "customers.activities.errors.phoneInvalid": "Geben Sie eine gültige Telefonnummer mit Ländervorwahl ein (z. B. +49 30 1234567)",
32
+ "customers.activities.errors.phoneRequired": "Telefonnummer ist für Anrufaktivitäten erforderlich",
33
+ "customers.activities.errors.timeRequired": "Uhrzeit ist erforderlich",
27
34
  "customers.activities.filters.clearAll": "Clear filters",
28
35
  "customers.activities.filters.dateRange": "Date range",
29
36
  "customers.activities.loadFailed": "Aktivitäten konnten nicht geladen werden.",
@@ -52,6 +59,7 @@
52
59
  "customers.activityComposer.types.email": "Email",
53
60
  "customers.activityComposer.types.meeting": "Meeting",
54
61
  "customers.activityComposer.types.note": "Note",
62
+ "customers.activityComposer.types.task": "Aufgabe",
55
63
  "customers.activityComposer.validation.descriptionRequired": "Description is required",
56
64
  "customers.activityComposer.validation.typeRequired": "Select an activity type",
57
65
  "customers.activityComposer.weekPreviewTitle": "Diese Woche",
@@ -59,6 +67,8 @@
59
67
  "customers.activityLog.direction.with": "with",
60
68
  "customers.activityLog.emptyDescription": "Try broadening the date range or removing some filters.",
61
69
  "customers.activityLog.error": "Failed to load activity history",
70
+ "customers.activityLog.filters.dateRangeLabel": "Nach Datumsbereich filtern",
71
+ "customers.activityLog.filters.sortLabel": "Aktivitäten sortieren",
62
72
  "customers.activityLog.searchPlaceholder": "Search by title, note, or author",
63
73
  "customers.activityLog.sort.recent": "Sort: newest",
64
74
  "customers.activityLog.sort.titleAsc": "Sort: Name A-Z",
@@ -2022,6 +2032,7 @@
2022
2032
  "customers.timeline.filter.from": "From date",
2023
2033
  "customers.timeline.filter.meeting": "Meeting",
2024
2034
  "customers.timeline.filter.note": "Note",
2035
+ "customers.timeline.filter.task": "Aufgabe",
2025
2036
  "customers.timeline.filter.to": "To date",
2026
2037
  "customers.timeline.history.filtered": "filtered: {{types}} · {{count}} results",
2027
2038
  "customers.timeline.history.searchAriaLabel": "",
@@ -4,6 +4,9 @@
4
4
  "audit_logs.resource_kind.customers.comment": "Comment",
5
5
  "audit_logs.resource_kind.customers.todoLink": "Todo",
6
6
  "backend.nav.configuration": "Configuration",
7
+ "customers.activities.actions.markDone": "Mark done",
8
+ "customers.activities.actions.markDoneError": "Could not mark activity as done",
9
+ "customers.activities.actions.markDoneSuccess": "Activity marked done",
7
10
  "customers.activities.add.call": "Log call",
8
11
  "customers.activities.add.email": "Compose email",
9
12
  "customers.activities.add.meeting": "New meeting",
@@ -24,6 +27,10 @@
24
27
  "customers.activities.card.empty": "Nothing scheduled for this day.",
25
28
  "customers.activities.card.overdue": "{count} overdue",
26
29
  "customers.activities.card.title": "Activities",
30
+ "customers.activities.errors.dateRequired": "Date is required",
31
+ "customers.activities.errors.phoneInvalid": "Enter a valid phone number with country code (e.g. +1 212 555 1234)",
32
+ "customers.activities.errors.phoneRequired": "Phone number is required for Call activities",
33
+ "customers.activities.errors.timeRequired": "Time is required",
27
34
  "customers.activities.filters.clearAll": "Clear filters",
28
35
  "customers.activities.filters.dateRange": "Date range",
29
36
  "customers.activities.loadFailed": "Failed to load activities.",
@@ -52,6 +59,7 @@
52
59
  "customers.activityComposer.types.email": "Email",
53
60
  "customers.activityComposer.types.meeting": "Meeting",
54
61
  "customers.activityComposer.types.note": "Note",
62
+ "customers.activityComposer.types.task": "Task",
55
63
  "customers.activityComposer.validation.descriptionRequired": "Description is required",
56
64
  "customers.activityComposer.validation.typeRequired": "Select an activity type",
57
65
  "customers.activityComposer.weekPreviewTitle": "This week",
@@ -59,6 +67,8 @@
59
67
  "customers.activityLog.direction.with": "with",
60
68
  "customers.activityLog.emptyDescription": "Try broadening the date range or removing some filters.",
61
69
  "customers.activityLog.error": "Failed to load activity history",
70
+ "customers.activityLog.filters.dateRangeLabel": "Date range",
71
+ "customers.activityLog.filters.sortLabel": "Sort order",
62
72
  "customers.activityLog.searchPlaceholder": "Search by title, note, or author",
63
73
  "customers.activityLog.sort.recent": "Sort: newest",
64
74
  "customers.activityLog.sort.titleAsc": "Sort: Name A-Z",
@@ -2022,6 +2032,7 @@
2022
2032
  "customers.timeline.filter.from": "From date",
2023
2033
  "customers.timeline.filter.meeting": "Meeting",
2024
2034
  "customers.timeline.filter.note": "Note",
2035
+ "customers.timeline.filter.task": "Task",
2025
2036
  "customers.timeline.filter.to": "To date",
2026
2037
  "customers.timeline.history.filtered": "filtered: {{types}} · {{count}} results",
2027
2038
  "customers.timeline.history.searchAriaLabel": "Search interaction history",
@@ -4,6 +4,9 @@
4
4
  "audit_logs.resource_kind.customers.comment": "Comentario",
5
5
  "audit_logs.resource_kind.customers.todoLink": "Tarea",
6
6
  "backend.nav.configuration": "Configuración",
7
+ "customers.activities.actions.markDone": "Marcar como hecho",
8
+ "customers.activities.actions.markDoneError": "No se pudo marcar la actividad como hecha",
9
+ "customers.activities.actions.markDoneSuccess": "Actividad marcada como hecha",
7
10
  "customers.activities.add.call": "Registrar llamada",
8
11
  "customers.activities.add.email": "Redactar correo",
9
12
  "customers.activities.add.meeting": "Nueva reunión",
@@ -24,6 +27,10 @@
24
27
  "customers.activities.card.empty": "Nada programado para este día.",
25
28
  "customers.activities.card.overdue": "{count} vencidas",
26
29
  "customers.activities.card.title": "Actividades",
30
+ "customers.activities.errors.dateRequired": "La fecha es obligatoria",
31
+ "customers.activities.errors.phoneInvalid": "Introduce un número de teléfono válido con prefijo internacional (p. ej., +34 912 345 678)",
32
+ "customers.activities.errors.phoneRequired": "El número de teléfono es obligatorio para actividades de llamada",
33
+ "customers.activities.errors.timeRequired": "La hora es obligatoria",
27
34
  "customers.activities.filters.clearAll": "Clear filters",
28
35
  "customers.activities.filters.dateRange": "Date range",
29
36
  "customers.activities.loadFailed": "No se pudieron cargar las actividades.",
@@ -52,6 +59,7 @@
52
59
  "customers.activityComposer.types.email": "Email",
53
60
  "customers.activityComposer.types.meeting": "Meeting",
54
61
  "customers.activityComposer.types.note": "Note",
62
+ "customers.activityComposer.types.task": "Tarea",
55
63
  "customers.activityComposer.validation.descriptionRequired": "Description is required",
56
64
  "customers.activityComposer.validation.typeRequired": "Select an activity type",
57
65
  "customers.activityComposer.weekPreviewTitle": "Esta semana",
@@ -59,6 +67,8 @@
59
67
  "customers.activityLog.direction.with": "with",
60
68
  "customers.activityLog.emptyDescription": "Try broadening the date range or removing some filters.",
61
69
  "customers.activityLog.error": "Failed to load activity history",
70
+ "customers.activityLog.filters.dateRangeLabel": "Filtrar por rango de fechas",
71
+ "customers.activityLog.filters.sortLabel": "Ordenar actividades",
62
72
  "customers.activityLog.searchPlaceholder": "Search by title, note, or author",
63
73
  "customers.activityLog.sort.recent": "Sort: newest",
64
74
  "customers.activityLog.sort.titleAsc": "Sort: Name A-Z",
@@ -2022,6 +2032,7 @@
2022
2032
  "customers.timeline.filter.from": "From date",
2023
2033
  "customers.timeline.filter.meeting": "Meeting",
2024
2034
  "customers.timeline.filter.note": "Note",
2035
+ "customers.timeline.filter.task": "Tarea",
2025
2036
  "customers.timeline.filter.to": "To date",
2026
2037
  "customers.timeline.history.filtered": "filtered: {{types}} · {{count}} results",
2027
2038
  "customers.timeline.history.searchAriaLabel": "",