@open-mercato/core 0.9.1-develop.7477.1.64ba865101 → 0.9.1-develop.7497.1.543ea44c40

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 (84) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/business_rules/data/validators.js +8 -3
  3. package/dist/modules/business_rules/data/validators.js.map +2 -2
  4. package/dist/modules/catalog/api/dictionaries/[key]/route.js +35 -7
  5. package/dist/modules/catalog/api/dictionaries/[key]/route.js.map +2 -2
  6. package/dist/modules/communication_channels/lib/email-mime.js +1 -2
  7. package/dist/modules/communication_channels/lib/email-mime.js.map +2 -2
  8. package/dist/modules/configs/cli.js +8 -2
  9. package/dist/modules/configs/cli.js.map +2 -2
  10. package/dist/modules/customers/api/companies/[id]/people/route.js +3 -0
  11. package/dist/modules/customers/api/companies/[id]/people/route.js.map +2 -2
  12. package/dist/modules/customers/api/companies/route.js +3 -3
  13. package/dist/modules/customers/api/companies/route.js.map +2 -2
  14. package/dist/modules/customers/api/deals/[id]/companies/route.js +3 -0
  15. package/dist/modules/customers/api/deals/[id]/companies/route.js.map +2 -2
  16. package/dist/modules/customers/api/deals/[id]/people/route.js +3 -0
  17. package/dist/modules/customers/api/deals/[id]/people/route.js.map +2 -2
  18. package/dist/modules/customers/api/dictionaries/kind-settings/route.js +6 -0
  19. package/dist/modules/customers/api/dictionaries/kind-settings/route.js.map +2 -2
  20. package/dist/modules/customers/api/labels/route.js +10 -1
  21. package/dist/modules/customers/api/labels/route.js.map +2 -2
  22. package/dist/modules/customers/api/people/[id]/companies/[linkId]/route.js +6 -0
  23. package/dist/modules/customers/api/people/[id]/companies/[linkId]/route.js.map +2 -2
  24. package/dist/modules/customers/api/people/[id]/companies/enriched/route.js +3 -0
  25. package/dist/modules/customers/api/people/[id]/companies/enriched/route.js.map +2 -2
  26. package/dist/modules/customers/api/people/[id]/companies/route.js +6 -0
  27. package/dist/modules/customers/api/people/[id]/companies/route.js.map +2 -2
  28. package/dist/modules/customers/api/people/route.js +3 -3
  29. package/dist/modules/customers/api/people/route.js.map +2 -2
  30. package/dist/modules/customers/commands/deals.js +2 -0
  31. package/dist/modules/customers/commands/deals.js.map +2 -2
  32. package/dist/modules/customers/components/ActivityTypeEditor.js +11 -5
  33. package/dist/modules/customers/components/ActivityTypeEditor.js.map +2 -2
  34. package/dist/modules/customers/components/detail/InlineEditors.js +7 -3
  35. package/dist/modules/customers/components/detail/InlineEditors.js.map +2 -2
  36. package/dist/modules/customers/components/detail/PersonHighlights.js +1 -1
  37. package/dist/modules/customers/components/detail/PersonHighlights.js.map +2 -2
  38. package/dist/modules/customers/components/detail/PlannedActivitiesSection.js +4 -5
  39. package/dist/modules/customers/components/detail/PlannedActivitiesSection.js.map +2 -2
  40. package/dist/modules/data_sync/di.js.map +2 -2
  41. package/dist/modules/data_sync/lib/sync-schedule-service.js +60 -27
  42. package/dist/modules/data_sync/lib/sync-schedule-service.js.map +2 -2
  43. package/dist/modules/messages/commands/messages.js +10 -7
  44. package/dist/modules/messages/commands/messages.js.map +2 -2
  45. package/dist/modules/messages/lib/constants.js +3 -1
  46. package/dist/modules/messages/lib/constants.js.map +2 -2
  47. package/dist/modules/messages/subscribers/message-notification.js +2 -1
  48. package/dist/modules/messages/subscribers/message-notification.js.map +2 -2
  49. package/dist/modules/sales/api/documents/factory.js +3 -4
  50. package/dist/modules/sales/api/documents/factory.js.map +2 -2
  51. package/dist/modules/sales/commands/payments.js +9 -1
  52. package/dist/modules/sales/commands/payments.js.map +2 -2
  53. package/package.json +7 -7
  54. package/src/modules/business_rules/data/validators.ts +12 -3
  55. package/src/modules/catalog/api/dictionaries/[key]/route.ts +49 -7
  56. package/src/modules/communication_channels/lib/email-mime.ts +1 -2
  57. package/src/modules/configs/cli.ts +10 -1
  58. package/src/modules/customers/api/companies/[id]/people/route.ts +3 -0
  59. package/src/modules/customers/api/companies/route.ts +6 -6
  60. package/src/modules/customers/api/deals/[id]/companies/route.ts +3 -0
  61. package/src/modules/customers/api/deals/[id]/people/route.ts +3 -0
  62. package/src/modules/customers/api/dictionaries/kind-settings/route.ts +6 -0
  63. package/src/modules/customers/api/labels/route.ts +10 -1
  64. package/src/modules/customers/api/people/[id]/companies/[linkId]/route.ts +6 -0
  65. package/src/modules/customers/api/people/[id]/companies/enriched/route.ts +3 -0
  66. package/src/modules/customers/api/people/[id]/companies/route.ts +6 -0
  67. package/src/modules/customers/api/people/route.ts +6 -6
  68. package/src/modules/customers/commands/deals.ts +2 -0
  69. package/src/modules/customers/components/ActivityTypeEditor.tsx +11 -5
  70. package/src/modules/customers/components/detail/InlineEditors.tsx +8 -4
  71. package/src/modules/customers/components/detail/PersonHighlights.tsx +4 -1
  72. package/src/modules/customers/components/detail/PlannedActivitiesSection.tsx +4 -5
  73. package/src/modules/customers/i18n/de.json +2 -0
  74. package/src/modules/customers/i18n/en.json +2 -0
  75. package/src/modules/customers/i18n/es.json +2 -0
  76. package/src/modules/customers/i18n/ko.json +2 -0
  77. package/src/modules/customers/i18n/pl.json +2 -0
  78. package/src/modules/data_sync/di.ts +1 -0
  79. package/src/modules/data_sync/lib/sync-schedule-service.ts +80 -28
  80. package/src/modules/messages/commands/messages.ts +10 -7
  81. package/src/modules/messages/lib/constants.ts +6 -0
  82. package/src/modules/messages/subscribers/message-notification.ts +2 -0
  83. package/src/modules/sales/api/documents/factory.ts +6 -7
  84. package/src/modules/sales/commands/payments.ts +10 -2
@@ -25,6 +25,7 @@ import {
25
25
  } from '@open-mercato/shared/lib/crud/custom-fields'
26
26
  import { buildIlikeTerm } from '@open-mercato/shared/lib/db/buildIlikeTerm'
27
27
  import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
28
+ import { isUuidListParam, splitIdListParam } from '@open-mercato/shared/lib/crud/ids'
28
29
  import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
29
30
  import { consumeAdvancedFilterState, mergeAdvancedFilterTree } from '@open-mercato/shared/lib/crud/advanced-filter-integration'
30
31
  import {
@@ -64,7 +65,10 @@ const listSchema = z
64
65
  createdFrom: z.string().optional(),
65
66
  createdTo: z.string().optional(),
66
67
  id: z.string().uuid().optional(),
67
- tagIds: z.string().optional(),
68
+ tagIds: z
69
+ .string()
70
+ .optional()
71
+ .refine(isUuidListParam, { message: 'tagIds must be a comma-separated list of UUIDs' }),
68
72
  tagIdsEmpty: z.string().optional(),
69
73
  excludeIds: z.string().optional(),
70
74
  excludeLinkedPersonId: z.string().uuid().optional(),
@@ -201,11 +205,7 @@ const crud = makeCrudRoute({
201
205
  if (query.source) {
202
206
  filters.source = { $eq: query.source }
203
207
  }
204
- const tagIdsRaw = typeof query.tagIds === 'string' ? query.tagIds : ''
205
- const tagIds = tagIdsRaw
206
- .split(',')
207
- .map((value: string) => value.trim())
208
- .filter((value: string) => value.length > 0)
208
+ const tagIds = splitIdListParam(query.tagIds)
209
209
  const tagIdsEmpty = parseBooleanToken(query.tagIdsEmpty) === true
210
210
  if (tagIdsEmpty) {
211
211
  filters.id = { $eq: '00000000-0000-0000-0000-000000000000' }
@@ -171,6 +171,9 @@ export async function GET(req: Request, ctx: { params?: { id?: string } }) {
171
171
  if (isCrudHttpError(error)) {
172
172
  return NextResponse.json(error.body, { status: error.status })
173
173
  }
174
+ if (error instanceof z.ZodError) {
175
+ return NextResponse.json({ error: translate('customers.errors.validationFailed', 'Validation failed'), details: error.issues }, { status: 400 })
176
+ }
174
177
  logger.error('customers.deals.companies.GET', { err: error })
175
178
  return NextResponse.json({ error: translate('customers.errors.deal_companies_load_failed', 'Failed to load linked companies') }, { status: 500 })
176
179
  }
@@ -220,6 +220,9 @@ export async function GET(req: Request, ctx: { params?: { id?: string } }) {
220
220
  if (isCrudHttpError(error)) {
221
221
  return NextResponse.json(error.body, { status: error.status })
222
222
  }
223
+ if (error instanceof z.ZodError) {
224
+ return NextResponse.json({ error: translate('customers.errors.validationFailed', 'Validation failed'), details: error.issues }, { status: 400 })
225
+ }
223
226
  logger.error('customers.deals.people.GET', { err: error })
224
227
  return NextResponse.json({ error: translate('customers.errors.deal_people_load_failed', 'Failed to load linked people') }, { status: 500 })
225
228
  }
@@ -86,6 +86,9 @@ export async function GET(req: Request) {
86
86
  if (isCrudHttpError(err)) {
87
87
  return NextResponse.json(err.body, { status: err.status })
88
88
  }
89
+ if (err instanceof z.ZodError) {
90
+ return NextResponse.json({ error: translate('customers.errors.validationFailed', 'Validation failed'), details: err.issues }, { status: 400 })
91
+ }
89
92
  logger.error('customers/dictionaries/kind-settings.GET', { err })
90
93
  return NextResponse.json({ error: translate('customers.errors.kind_settings_load_failed', 'Failed to load kind settings') }, { status: 500 })
91
94
  }
@@ -187,6 +190,9 @@ export async function PATCH(req: Request) {
187
190
  if (isCrudHttpError(err)) {
188
191
  return NextResponse.json(err.body, { status: err.status })
189
192
  }
193
+ if (err instanceof z.ZodError) {
194
+ return NextResponse.json({ error: translate('customers.errors.validationFailed', 'Validation failed'), details: err.issues }, { status: 400 })
195
+ }
190
196
  const interceptorRejection = getCommandInterceptorHttpRejection(err)
191
197
  if (interceptorRejection) {
192
198
  return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status })
@@ -172,6 +172,9 @@ export async function GET(req: Request) {
172
172
  if (isCrudHttpError(err)) {
173
173
  return NextResponse.json(err.body, { status: err.status })
174
174
  }
175
+ if (err instanceof z.ZodError) {
176
+ return NextResponse.json({ error: translate('customers.errors.validationFailed', 'Validation failed'), details: err.issues }, { status: 400 })
177
+ }
175
178
  logger.error('customers/labels.GET', { err })
176
179
  return NextResponse.json({ error: translate('customers.errors.labels_load_failed', 'Failed to load labels') }, { status: 500 })
177
180
  }
@@ -186,8 +189,8 @@ function slugifyLabel(value: string): string {
186
189
  }
187
190
 
188
191
  export async function POST(req: Request) {
192
+ const { translate } = await resolveTranslations()
189
193
  try {
190
- const { translate } = await resolveTranslations()
191
194
  const container = await createRequestContainer()
192
195
  const auth = await getAuthFromRequest(req)
193
196
  const actorUserId = resolveLabelActorUserId(auth)
@@ -207,6 +210,9 @@ export async function POST(req: Request) {
207
210
  throw new CrudHttpError(400, { error: translate('customers.errors.organization_required', 'Organization context is required') })
208
211
  }
209
212
  const slug = body.slug || slugifyLabel(body.label)
213
+ if (!slug) {
214
+ throw new CrudHttpError(400, { error: translate('customers.errors.label_slug_invalid', 'Label must contain at least one letter or number') })
215
+ }
210
216
 
211
217
  const guardResult = await validateCrudMutationGuard(container, {
212
218
  tenantId: auth.tenantId,
@@ -289,6 +295,9 @@ export async function POST(req: Request) {
289
295
  if (isCrudHttpError(err)) {
290
296
  return NextResponse.json(err.body, { status: err.status })
291
297
  }
298
+ if (err instanceof z.ZodError) {
299
+ return NextResponse.json({ error: translate('customers.errors.validationFailed', 'Validation failed'), details: err.issues }, { status: 400 })
300
+ }
292
301
  const interceptorRejection = getCommandInterceptorHttpRejection(err)
293
302
  if (interceptorRejection) {
294
303
  return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status })
@@ -227,6 +227,9 @@ export async function PATCH(req: Request, ctx: { params?: { id?: string; linkId?
227
227
  if (isCrudHttpError(err)) {
228
228
  return NextResponse.json(err.body, { status: err.status })
229
229
  }
230
+ if (err instanceof z.ZodError) {
231
+ return NextResponse.json({ error: translate('customers.errors.validationFailed', 'Validation failed'), details: err.issues }, { status: 400 })
232
+ }
230
233
  const interceptorRejection = getCommandInterceptorHttpRejection(err)
231
234
  if (interceptorRejection) {
232
235
  return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status })
@@ -335,6 +338,9 @@ export async function DELETE(req: Request, ctx: { params?: { id?: string; linkId
335
338
  if (isCrudHttpError(err)) {
336
339
  return NextResponse.json(err.body, { status: err.status })
337
340
  }
341
+ if (err instanceof z.ZodError) {
342
+ return NextResponse.json({ error: translate('customers.errors.validationFailed', 'Validation failed'), details: err.issues }, { status: 400 })
343
+ }
338
344
  const interceptorRejection = getCommandInterceptorHttpRejection(err)
339
345
  if (interceptorRejection) {
340
346
  return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status })
@@ -387,6 +387,9 @@ export async function GET(req: Request, ctx: { params?: { id?: string } }) {
387
387
  if (isCrudHttpError(err)) {
388
388
  return NextResponse.json(err.body, { status: err.status })
389
389
  }
390
+ if (err instanceof z.ZodError) {
391
+ return NextResponse.json({ error: translate('customers.errors.validationFailed', 'Validation failed'), details: err.issues }, { status: 400 })
392
+ }
390
393
  logger.error('/companies/enriched] GET failed', { component: 'people/[id', err })
391
394
  return NextResponse.json({ error: translate('customers.errors.internal', 'Internal server error') }, { status: 500 })
392
395
  }
@@ -95,6 +95,9 @@ export async function GET(req: Request, ctx: { params?: { id?: string } }) {
95
95
  if (isCrudHttpError(err)) {
96
96
  return NextResponse.json(err.body, { status: err.status })
97
97
  }
98
+ if (err instanceof z.ZodError) {
99
+ return NextResponse.json({ error: translate('customers.errors.validationFailed', 'Validation failed'), details: err.issues }, { status: 400 })
100
+ }
98
101
  return NextResponse.json({ error: translate('customers.errors.internal', 'Internal server error') }, { status: 500 })
99
102
  }
100
103
  }
@@ -211,6 +214,9 @@ export async function POST(req: Request, ctx: { params?: { id?: string } }) {
211
214
  if (isCrudHttpError(err)) {
212
215
  return NextResponse.json(err.body, { status: err.status })
213
216
  }
217
+ if (err instanceof z.ZodError) {
218
+ return NextResponse.json({ error: translate('customers.errors.validationFailed', 'Validation failed'), details: err.issues }, { status: 400 })
219
+ }
214
220
  const interceptorRejection = getCommandInterceptorHttpRejection(err)
215
221
  if (interceptorRejection) {
216
222
  return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status })
@@ -21,6 +21,7 @@ import {
21
21
  import { buildCustomFieldFiltersFromQuery, extractAllCustomFieldEntries, splitCustomFieldPayload } from '@open-mercato/shared/lib/crud/custom-fields'
22
22
  import { buildIlikeTerm } from '@open-mercato/shared/lib/db/buildIlikeTerm'
23
23
  import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
24
+ import { isUuidListParam, splitIdListParam } from '@open-mercato/shared/lib/crud/ids'
24
25
  import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
25
26
  import { consumeAdvancedFilterState, mergeAdvancedFilterTree } from '@open-mercato/shared/lib/crud/advanced-filter-integration'
26
27
  import {
@@ -60,7 +61,10 @@ const listSchema = z
60
61
  sortField: z.string().optional(),
61
62
  sortDir: z.enum(['asc', 'desc']).optional(),
62
63
  id: z.string().uuid().optional(),
63
- tagIds: z.string().optional(),
64
+ tagIds: z
65
+ .string()
66
+ .optional()
67
+ .refine(isUuidListParam, { message: 'tagIds must be a comma-separated list of UUIDs' }),
64
68
  tagIdsEmpty: z.string().optional(),
65
69
  excludeIds: z.string().optional(),
66
70
  excludeLinkedCompanyId: z.string().uuid().optional(),
@@ -205,11 +209,7 @@ const crud = makeCrudRoute({
205
209
  if (query.source) {
206
210
  filters.source = { $eq: query.source }
207
211
  }
208
- const tagIdsRaw = typeof query.tagIds === 'string' ? query.tagIds : ''
209
- const tagIds = tagIdsRaw
210
- .split(',')
211
- .map((value: string) => value.trim())
212
- .filter((value: string) => value.length > 0)
212
+ const tagIds = splitIdListParam(query.tagIds)
213
213
  const tagIdsEmpty = parseBooleanToken(query.tagIdsEmpty) === true
214
214
  if (tagIdsEmpty) {
215
215
  filters.id = { $eq: '00000000-0000-0000-0000-000000000000' }
@@ -21,6 +21,7 @@ import {
21
21
  CustomerDealStageTransition,
22
22
  CustomerPipelineStage,
23
23
  } from '../data/entities'
24
+ import { CustomFieldValue } from '@open-mercato/core/modules/entities/data/entities'
24
25
  import {
25
26
  dealCreateSchema,
26
27
  dealUpdateSchema,
@@ -1119,6 +1120,7 @@ const deleteDealCommand: CommandHandler<{ body?: Record<string, unknown>; query?
1119
1120
  await deleteDealStageTransitions(em, record)
1120
1121
  await em.nativeDelete(CustomerDealPersonLink, { deal: record })
1121
1122
  await em.nativeDelete(CustomerDealCompanyLink, { deal: record })
1123
+ await em.nativeDelete(CustomFieldValue, { entityId: DEAL_ENTITY_ID, recordId: record.id })
1122
1124
  em.remove(record)
1123
1125
  await em.flush()
1124
1126
 
@@ -35,12 +35,12 @@ type FormValues = Record<string, unknown>
35
35
 
36
36
  const defaults = calendarEventTypes[0]!.behavior
37
37
 
38
- function initialValues(item: ScopedCalendarEventType | null): FormValues {
38
+ function initialValues(item: ScopedCalendarEventType | null, version: string | null | undefined): FormValues {
39
39
  const behavior = item?.behavior ?? defaults
40
40
  return {
41
41
  value: item?.key ?? '', label: item?.label ?? '', icon: item?.icon ?? '', color: item?.color ?? '',
42
42
  order: behavior.order, baseKind: behavior.baseKind, selectable: behavior.selectable,
43
- ...behavior.fields, customFieldsetIds: [...behavior.customFieldsetIds], updatedAt: item?.updatedAt ?? undefined,
43
+ ...behavior.fields, customFieldsetIds: [...behavior.customFieldsetIds], updatedAt: version ?? undefined,
44
44
  }
45
45
  }
46
46
 
@@ -51,6 +51,7 @@ export function ActivityTypeEditor({ title, description }: { title: string; desc
51
51
  const [items, setItems] = React.useState<ScopedCalendarEventType[]>([])
52
52
  const [rows, setRows] = React.useState<DictionaryRow[]>([])
53
53
  const [selected, setSelected] = React.useState<ScopedCalendarEventType | null | undefined>(undefined)
54
+ const [selectedVersion, setSelectedVersion] = React.useState<string | null | undefined>(undefined)
54
55
  const [fieldsets, setFieldsets] = React.useState<Fieldset[]>([])
55
56
  const [fieldsetsStatus, setFieldsetsStatus] = React.useState<'loading' | 'ready' | 'error'>('loading')
56
57
  const [fieldsetAttempt, setFieldsetAttempt] = React.useState(0)
@@ -103,6 +104,10 @@ export function ActivityTypeEditor({ title, description }: { title: string; desc
103
104
  }, [selected, fieldsetAttempt])
104
105
 
105
106
  const localRow = (key: string) => rows.find((row) => row.value.toLowerCase() === key && !row.isInherited)
107
+ const openEditor = (item: ScopedCalendarEventType | null) => {
108
+ setSelectedVersion(item ? localRow(item.key)?.updatedAt ?? item.updatedAt : undefined)
109
+ setSelected(item)
110
+ }
106
111
  const remove = async (item: ScopedCalendarEventType) => {
107
112
  const row = localRow(item.key)
108
113
  if (!row) return
@@ -157,6 +162,7 @@ export function ActivityTypeEditor({ title, description }: { title: string; desc
157
162
  { id: 'behavior', title: t('customers.config.activityTypes.behavior', 'Form behavior'), fields: ['baseKind', 'selectable', 'endTime', 'allDay', 'recurrence', 'location', 'people', 'priority', 'resources'] },
158
163
  ...(fieldsets.length || selected?.behavior.customFieldsetIds.length ? [{ id: 'fieldsets', fields: ['customFieldsetIds'] }] : []),
159
164
  ]
165
+ const formInitialValues = React.useMemo(() => initialValues(selected ?? null, selectedVersion), [selected, selectedVersion])
160
166
  const save = async (values: FormValues) => {
161
167
  const key = String(values.value ?? '').trim()
162
168
  if (!calendarEventTypeKeySchema.safeParse(key).success) throw createCrudFormError(t('customers.config.activityTypes.invalidKey', 'Use a lowercase key with letters, numbers, hyphens, or underscores.'), { value: t('customers.config.activityTypes.invalidKey', 'Use a lowercase key with letters, numbers, hyphens, or underscores.') })
@@ -206,11 +212,11 @@ export function ActivityTypeEditor({ title, description }: { title: string; desc
206
212
  {status === 'error' ? <div className="space-y-3"><ErrorMessage label={t('customers.config.activityTypes.loadFailed', 'Failed to load activity types.')} /><Button type="button" onClick={() => { void load() }}>{t('customers.config.activityTypes.retry', 'Retry')}</Button></div> :
207
213
  <DataTable<ScopedCalendarEventType> embedded columns={columns} data={items} isLoading={status === 'loading'}
208
214
  title={title} titleHeadingLevel={2} emptyState={t('customers.config.activityTypes.empty', 'No activity types available.')}
209
- actions={<Button type="button" size="sm" onClick={() => setSelected(null)}>{t('customers.config.activityTypes.new', 'New activity type')}</Button>}
215
+ actions={<Button type="button" size="sm" onClick={() => openEditor(null)}>{t('customers.config.activityTypes.new', 'New activity type')}</Button>}
210
216
  refreshButton={{ label: t('customers.config.activityTypes.refresh', 'Refresh'), onRefresh: load, isRefreshing: status === 'loading' }}
211
217
  rowActions={(item) => {
212
218
  const actions: RowActionItem[] = [{ id: 'edit', label: item.adminConfigurable
213
- ? t('customers.config.activityTypes.configure', 'Configure') : t('customers.config.activityTypes.view', 'View'), onSelect: () => setSelected(item) }]
219
+ ? t('customers.config.activityTypes.configure', 'Configure') : t('customers.config.activityTypes.view', 'View'), onSelect: () => openEditor(item) }]
214
220
  if (localRow(item.key)) actions.push({ id: 'delete', label: item.source === 'dictionary'
215
221
  ? t('customers.config.activityTypes.delete', 'Delete') : t('customers.config.activityTypes.reset', 'Reset override'),
216
222
  destructive: true, onSelect: () => { void remove(item) } })
@@ -230,7 +236,7 @@ export function ActivityTypeEditor({ title, description }: { title: string; desc
230
236
  {fieldsetsStatus === 'ready' && fieldsets.length === 0 ? <p className="text-sm text-muted-foreground">{t('customers.config.activityTypes.noFieldsets', 'No custom-field fieldsets are defined yet.')}</p> : null}
231
237
  {selected !== undefined && fieldsetsStatus === 'ready' ? <CrudForm<FormValues> key={selected?.key ?? 'new'} embedded
232
238
  readOnly={selected?.adminConfigurable === false} fields={fields} groups={groups}
233
- initialValues={initialValues(selected)} onSubmit={save} submitLabel={t('customers.config.activityTypes.save', 'Save')}
239
+ initialValues={formInitialValues} onSubmit={save} submitLabel={t('customers.config.activityTypes.save', 'Save')}
234
240
  extraActions={<Button type="button" variant="outline" onClick={() => setSelected(undefined)}>{t('customers.config.dictionaries.dialog.cancel', 'Cancel')}</Button>} /> : null}
235
241
  </DialogContent>
236
242
  </Dialog>
@@ -19,6 +19,7 @@ import {
19
19
  type InlineMultilineEditorProps,
20
20
  type InlineTextEditorProps,
21
21
  InlineSelectEditor as UiInlineSelectEditor,
22
+ INLINE_TRIGGER_HIDDEN_CLASSES,
22
23
  type InlineSelectOption,
23
24
  } from '@open-mercato/ui/backend/detail/InlineEditors'
24
25
  import {
@@ -372,6 +373,9 @@ export function InlineNextInteractionEditor({
372
373
  const dateErrorId = React.useId()
373
374
  const nameErrorId = React.useId()
374
375
  const containerClasses = cn('group relative rounded-lg border p-4', activateOnClick && !editing ? 'cursor-pointer' : null)
376
+ const triggerLabel = editing
377
+ ? t('ui.detail.inline.cancel', 'Cancel')
378
+ : t('ui.detail.inline.edit', 'Edit')
375
379
  const requiredMessage = React.useMemo(
376
380
  () => t('customers.people.detail.inline.required', 'This field is required'),
377
381
  [t],
@@ -535,16 +539,16 @@ export function InlineNextInteractionEditor({
535
539
  size="sm"
536
540
  className={cn(
537
541
  'absolute right-3 top-3 transition-opacity duration-150',
538
- editing
539
- ? 'opacity-100'
540
- : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100',
542
+ editing ? 'opacity-100' : INLINE_TRIGGER_HIDDEN_CLASSES,
541
543
  )}
542
544
  onClick={(event) => {
543
545
  event.stopPropagation()
544
546
  setEditing((state) => !state)
545
547
  }}
548
+ aria-label={triggerLabel}
549
+ title={triggerLabel}
546
550
  >
547
- {editing ? <X className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
551
+ {editing ? <X className="h-4 w-4" aria-hidden /> : <Pencil className="h-4 w-4" aria-hidden />}
548
552
  </Button>
549
553
  <div className="flex items-start gap-2" {...interactiveProps}>
550
554
  <div className="flex-1">
@@ -322,7 +322,10 @@ export function PersonHighlights({
322
322
  className={
323
323
  editingCompany
324
324
  ? 'opacity-100'
325
- : 'opacity-0 transition-opacity duration-150 group-hover:opacity-100 hover:opacity-100 focus-visible:opacity-100'
325
+ // The enclosing panel only activates once a company is assigned, and then it
326
+ // navigates to that company instead of opening this editor — so without the
327
+ // coarse-pointer reveal the Company field cannot be set or changed on touch.
328
+ : 'opacity-0 transition-opacity duration-150 group-hover:opacity-100 hover:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:opacity-100'
326
329
  }
327
330
  >
328
331
  {editingCompany ? <X className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
@@ -171,7 +171,7 @@ export function PlannedActivitiesSection({ activities, onComplete, onSchedule, o
171
171
  {activity.title ?? activity.body ?? activity.interactionType}
172
172
  </span>
173
173
  <div className="flex items-center gap-2 text-xs text-muted-foreground">
174
- <span>{activity.scheduledAt ? formatScheduledDate(activity.scheduledAt) : ''}</span>
174
+ <span>{activity.scheduledAt ? formatScheduledDate(activity.scheduledAt, t) : ''}</span>
175
175
  {activity.authorName && (
176
176
  <>
177
177
  <span>·</span>
@@ -235,11 +235,10 @@ function formatRelativeOverdue(isoString: string, t: TranslateFn): string {
235
235
  }
236
236
  }
237
237
 
238
- function formatScheduledDate(isoString: string): string {
238
+ function formatScheduledDate(isoString: string, t: TranslateFn): string {
239
239
  try {
240
240
  const date = new Date(isoString)
241
- const now = new Date()
242
- const tomorrow = new Date(now)
241
+ const tomorrow = new Date()
243
242
  tomorrow.setDate(tomorrow.getDate() + 1)
244
243
 
245
244
  const dayName = date.toLocaleDateString(undefined, { weekday: 'short' })
@@ -247,7 +246,7 @@ function formatScheduledDate(isoString: string): string {
247
246
  const timeStr = date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
248
247
 
249
248
  if (isSameDay(date, tomorrow)) {
250
- return `Tomorrow ${timeStr}`
249
+ return t('customers.timeline.planned.tomorrow', 'Tomorrow {{time}}', { time: timeStr })
251
250
  }
252
251
  return `${dayName}, ${dateStr} · ${timeStr}`
253
252
  } catch {
@@ -1728,6 +1728,7 @@
1728
1728
  "customers.errors.kind_settings_load_failed": "Einstellungen der Kategorie konnten nicht geladen werden",
1729
1729
  "customers.errors.label_duplicate": "Ein Label mit diesem Slug existiert bereits",
1730
1730
  "customers.errors.label_not_found": "Label nicht gefunden",
1731
+ "customers.errors.label_slug_invalid": "Das Label muss mindestens einen Buchstaben oder eine Ziffer enthalten",
1731
1732
  "customers.errors.labels_load_failed": "Labels konnten nicht geladen werden",
1732
1733
  "customers.errors.lookup_failed": "Lexikon konnte nicht geladen werden",
1733
1734
  "customers.errors.organization_forbidden": "Organisation nicht verfügbar",
@@ -2935,6 +2936,7 @@
2935
2936
  "customers.timeline.planned.reschedule": "Reschedule",
2936
2937
  "customers.timeline.planned.schedule": "Schedule",
2937
2938
  "customers.timeline.planned.title": "Planned activities",
2939
+ "customers.timeline.planned.tomorrow": "Morgen {{time}}",
2938
2940
  "customers.timeline.planned.upcoming": "Upcoming",
2939
2941
  "customers.timeline.unpin": "Unpin",
2940
2942
  "customers.widgets.common.unknown": "Unbekannt",
@@ -1728,6 +1728,7 @@
1728
1728
  "customers.errors.kind_settings_load_failed": "Failed to load kind settings",
1729
1729
  "customers.errors.label_duplicate": "A label with this slug already exists",
1730
1730
  "customers.errors.label_not_found": "Label not found",
1731
+ "customers.errors.label_slug_invalid": "Label must contain at least one letter or number",
1731
1732
  "customers.errors.labels_load_failed": "Failed to load labels",
1732
1733
  "customers.errors.lookup_failed": "Failed to load dictionary entries",
1733
1734
  "customers.errors.organization_forbidden": "Organization not accessible",
@@ -2935,6 +2936,7 @@
2935
2936
  "customers.timeline.planned.reschedule": "Reschedule",
2936
2937
  "customers.timeline.planned.schedule": "Schedule",
2937
2938
  "customers.timeline.planned.title": "Planned activities",
2939
+ "customers.timeline.planned.tomorrow": "Tomorrow {{time}}",
2938
2940
  "customers.timeline.planned.upcoming": "Upcoming",
2939
2941
  "customers.timeline.unpin": "Unpin",
2940
2942
  "customers.widgets.common.unknown": "Unknown",
@@ -1728,6 +1728,7 @@
1728
1728
  "customers.errors.kind_settings_load_failed": "No se pudo cargar la configuración de la categoría",
1729
1729
  "customers.errors.label_duplicate": "Ya existe una etiqueta con este slug",
1730
1730
  "customers.errors.label_not_found": "Etiqueta no encontrada",
1731
+ "customers.errors.label_slug_invalid": "La etiqueta debe contener al menos una letra o un número",
1731
1732
  "customers.errors.labels_load_failed": "No se pudieron cargar las etiquetas",
1732
1733
  "customers.errors.lookup_failed": "No se pudo cargar el diccionario",
1733
1734
  "customers.errors.organization_forbidden": "Organización no accesible",
@@ -2935,6 +2936,7 @@
2935
2936
  "customers.timeline.planned.reschedule": "Reschedule",
2936
2937
  "customers.timeline.planned.schedule": "Schedule",
2937
2938
  "customers.timeline.planned.title": "Planned activities",
2939
+ "customers.timeline.planned.tomorrow": "Mañana {{time}}",
2938
2940
  "customers.timeline.planned.upcoming": "Upcoming",
2939
2941
  "customers.timeline.unpin": "Unpin",
2940
2942
  "customers.widgets.common.unknown": "Desconocido",
@@ -1728,6 +1728,7 @@
1728
1728
  "customers.errors.kind_settings_load_failed": "유형 설정을 불러오지 못했습니다",
1729
1729
  "customers.errors.label_duplicate": "이 슬러그를 가진 라벨이 이미 존재합니다",
1730
1730
  "customers.errors.label_not_found": "라벨을 찾을 수 없습니다",
1731
+ "customers.errors.label_slug_invalid": "라벨에는 문자나 숫자가 하나 이상 포함되어야 합니다",
1731
1732
  "customers.errors.labels_load_failed": "라벨을 불러오지 못했습니다",
1732
1733
  "customers.errors.lookup_failed": "사전 항목을 불러오지 못했습니다",
1733
1734
  "customers.errors.organization_forbidden": "조직에 접근할 수 없습니다",
@@ -2935,6 +2936,7 @@
2935
2936
  "customers.timeline.planned.reschedule": "일정 변경",
2936
2937
  "customers.timeline.planned.schedule": "예약",
2937
2938
  "customers.timeline.planned.title": "예정된 활동",
2939
+ "customers.timeline.planned.tomorrow": "내일 {{time}}",
2938
2940
  "customers.timeline.planned.upcoming": "다가오는 일정",
2939
2941
  "customers.timeline.unpin": "고정 해제",
2940
2942
  "customers.widgets.common.unknown": "알 수 없음",
@@ -1728,6 +1728,7 @@
1728
1728
  "customers.errors.kind_settings_load_failed": "Nie udało się załadować ustawień kategorii",
1729
1729
  "customers.errors.label_duplicate": "Etykieta o tym identyfikatorze już istnieje",
1730
1730
  "customers.errors.label_not_found": "Nie znaleziono etykiety",
1731
+ "customers.errors.label_slug_invalid": "Etykieta musi zawierać co najmniej jedną literę lub cyfrę",
1731
1732
  "customers.errors.labels_load_failed": "Nie udało się załadować etykiet",
1732
1733
  "customers.errors.lookup_failed": "Nie udało się załadować słownika",
1733
1734
  "customers.errors.organization_forbidden": "Organizacja jest niedostępna",
@@ -2935,6 +2936,7 @@
2935
2936
  "customers.timeline.planned.reschedule": "Przełóż",
2936
2937
  "customers.timeline.planned.schedule": "Zaplanuj",
2937
2938
  "customers.timeline.planned.title": "Zaplanowane aktywności",
2939
+ "customers.timeline.planned.tomorrow": "Jutro {{time}}",
2938
2940
  "customers.timeline.planned.upcoming": "Nadchodzące",
2939
2941
  "customers.timeline.unpin": "Odepnij",
2940
2942
  "customers.widgets.common.unknown": "Nieznane",
@@ -20,6 +20,7 @@ type Cradle = {
20
20
  schedulerService?: {
21
21
  register: (registration: Record<string, unknown>) => Promise<void>
22
22
  unregister: (scheduleId: string) => Promise<void>
23
+ exists?: (scheduleId: string) => Promise<boolean>
23
24
  }
24
25
  }
25
26
 
@@ -34,6 +34,15 @@ type SchedulerServiceLike = {
34
34
  isEnabled?: boolean
35
35
  }) => Promise<void>
36
36
  unregister: (scheduleId: string) => Promise<void>
37
+ exists?: (scheduleId: string) => Promise<boolean>
38
+ }
39
+
40
+ async function scheduledJobExists(scheduler: SchedulerServiceLike, scheduledJobId: string): Promise<boolean> {
41
+ // A scheduler substitute that cannot answer keeps the pre-existing behaviour:
42
+ // treat the registration as minted, which compensates rather than stranding an
43
+ // orphan on the create path where this is by far the likelier outcome.
44
+ if (!scheduler.exists) return false
45
+ return scheduler.exists(scheduledJobId)
37
46
  }
38
47
 
39
48
  export function createSyncScheduleService(em: EntityManager, schedulerService?: SchedulerServiceLike) {
@@ -162,11 +171,21 @@ export function createSyncScheduleService(em: EntityManager, schedulerService?:
162
171
 
163
172
  const id = existing?.id ?? randomUUID()
164
173
  const scheduledJobId = existing?.scheduledJobId ?? id
174
+ const scheduler = requireScheduler()
175
+ // Only a registration this call mints may be compensated below. A create
176
+ // mints a fresh id, so no job can already live at it. An update of a row
177
+ // whose scheduledJobId is null is the ambiguous case — deleteSchedule reads
178
+ // that null as "the job lives at row.id", and register() upserts on id — so
179
+ // ask the scheduler instead of assuming, otherwise compensation would
180
+ // delete a job this call merely overwrote.
181
+ const mintsRegistration = existing
182
+ ? !existing.scheduledJobId && !(await scheduledJobExists(scheduler, scheduledJobId))
183
+ : true
165
184
 
166
185
  // Validate the schedule (and register it with the scheduler) before writing
167
186
  // the SyncSchedule row — an unparseable scheduleValue must not leave a
168
187
  // persisted row with no working schedule behind it.
169
- await requireScheduler().register({
188
+ await scheduler.register({
170
189
  id: scheduledJobId,
171
190
  name: buildScheduleName(input),
172
191
  description: buildScheduleDescription(input),
@@ -188,37 +207,70 @@ export function createSyncScheduleService(em: EntityManager, schedulerService?:
188
207
  isEnabled: input.isEnabled,
189
208
  })
190
209
 
191
- const row = existing ?? em.create(SyncSchedule, {
192
- id,
193
- integrationId: input.integrationId,
194
- entityType: input.entityType,
195
- direction: input.direction,
196
- scheduleType: input.scheduleType,
197
- scheduleValue: input.scheduleValue,
198
- timezone: input.timezone,
199
- fullSync: input.fullSync,
200
- isEnabled: input.isEnabled,
201
- organizationId: scope.organizationId,
202
- tenantId: scope.tenantId,
203
- })
210
+ try {
211
+ const row = existing ?? em.create(SyncSchedule, {
212
+ id,
213
+ integrationId: input.integrationId,
214
+ entityType: input.entityType,
215
+ direction: input.direction,
216
+ scheduleType: input.scheduleType,
217
+ scheduleValue: input.scheduleValue,
218
+ timezone: input.timezone,
219
+ fullSync: input.fullSync,
220
+ isEnabled: input.isEnabled,
221
+ organizationId: scope.organizationId,
222
+ tenantId: scope.tenantId,
223
+ })
204
224
 
205
- row.integrationId = input.integrationId
206
- row.entityType = input.entityType
207
- row.direction = input.direction
208
- row.scheduleType = input.scheduleType
209
- row.scheduleValue = input.scheduleValue
210
- row.timezone = input.timezone
211
- row.fullSync = input.fullSync
212
- row.isEnabled = input.isEnabled
213
- row.scheduledJobId = scheduledJobId
225
+ row.integrationId = input.integrationId
226
+ row.entityType = input.entityType
227
+ row.direction = input.direction
228
+ row.scheduleType = input.scheduleType
229
+ row.scheduleValue = input.scheduleValue
230
+ row.timezone = input.timezone
231
+ row.fullSync = input.fullSync
232
+ row.isEnabled = input.isEnabled
233
+ row.scheduledJobId = scheduledJobId
214
234
 
215
- if (!existing) {
216
- em.persist(row)
217
- }
235
+ if (!existing) {
236
+ em.persist(row)
237
+ }
218
238
 
219
- await em.flush()
239
+ await em.flush()
220
240
 
221
- return row
241
+ return row
242
+ } catch (error: unknown) {
243
+ // The registration above is already durable, so a failed write would
244
+ // otherwise strand a live ScheduledJob the data-sync page cannot explain
245
+ // or delete. Only a registration this call minted is compensated: when a
246
+ // job was already there, register() overwrote one we inherited, and
247
+ // SchedulerServiceLike offers no way to restore the previous definition,
248
+ // so unregistering would destroy that job rather than roll anything back.
249
+ if (mintsRegistration) {
250
+ try {
251
+ await scheduler.unregister(scheduledJobId)
252
+ } catch (compensationError: unknown) {
253
+ logger.error('Failed to unregister the scheduled job after a schedule write failure', {
254
+ scheduledJobId,
255
+ scheduleId: id,
256
+ organizationId: scope.organizationId,
257
+ tenantId: scope.tenantId,
258
+ err: compensationError,
259
+ })
260
+ getTelemetryRuntime()?.reportError(compensationError, {
261
+ module: 'data_sync',
262
+ code: 'data_sync.schedule_save_compensation_failed',
263
+ attributes: {
264
+ scheduledJobId,
265
+ scheduleId: id,
266
+ organizationId: scope.organizationId,
267
+ tenantId: scope.tenantId,
268
+ },
269
+ })
270
+ }
271
+ }
272
+ throw error
273
+ }
222
274
  },
223
275
 
224
276
  async deleteSchedule(