@open-mercato/core 0.6.2-develop.3461.1.605f31c2c9 → 0.6.2

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 (173) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/helpers/integration/queue-runner.js +15 -3
  3. package/dist/helpers/integration/queue-runner.js.map +2 -2
  4. package/dist/helpers/integration/queue.js +1 -1
  5. package/dist/helpers/integration/queue.js.map +2 -2
  6. package/dist/helpers/integration/salesUi.js +24 -1
  7. package/dist/helpers/integration/salesUi.js.map +2 -2
  8. package/dist/modules/auth/api/roles/acl/route.js +37 -1
  9. package/dist/modules/auth/api/roles/acl/route.js.map +2 -2
  10. package/dist/modules/auth/api/roles/route.js +36 -2
  11. package/dist/modules/auth/api/roles/route.js.map +2 -2
  12. package/dist/modules/auth/api/users/acl/route.js +36 -1
  13. package/dist/modules/auth/api/users/acl/route.js.map +2 -2
  14. package/dist/modules/auth/api/users/route.js +101 -13
  15. package/dist/modules/auth/api/users/route.js.map +2 -2
  16. package/dist/modules/auth/backend/roles/[id]/edit/page.js +32 -14
  17. package/dist/modules/auth/backend/roles/[id]/edit/page.js.map +2 -2
  18. package/dist/modules/auth/backend/users/[id]/edit/page.js +1 -1
  19. package/dist/modules/auth/backend/users/[id]/edit/page.js.map +2 -2
  20. package/dist/modules/auth/backend/users/create/page.js +1 -1
  21. package/dist/modules/auth/backend/users/create/page.js.map +2 -2
  22. package/dist/modules/auth/backend/users/roleOptions.js +1 -1
  23. package/dist/modules/auth/backend/users/roleOptions.js.map +2 -2
  24. package/dist/modules/auth/commands/roles.js +8 -0
  25. package/dist/modules/auth/commands/roles.js.map +2 -2
  26. package/dist/modules/auth/commands/users.js +3 -0
  27. package/dist/modules/auth/commands/users.js.map +2 -2
  28. package/dist/modules/auth/components/AclEditor.js +53 -26
  29. package/dist/modules/auth/components/AclEditor.js.map +2 -2
  30. package/dist/modules/auth/frontend/login.js +35 -0
  31. package/dist/modules/auth/frontend/login.js.map +2 -2
  32. package/dist/modules/auth/lib/grantChecks.js +97 -2
  33. package/dist/modules/auth/lib/grantChecks.js.map +2 -2
  34. package/dist/modules/business_rules/lib/expression-evaluator.js +9 -7
  35. package/dist/modules/business_rules/lib/expression-evaluator.js.map +2 -2
  36. package/dist/modules/business_rules/lib/rule-evaluator.js +3 -3
  37. package/dist/modules/business_rules/lib/rule-evaluator.js.map +2 -2
  38. package/dist/modules/customers/ai-agents.js +10 -1
  39. package/dist/modules/customers/ai-agents.js.map +2 -2
  40. package/dist/modules/customers/ai-tools/settings-pack.js +55 -1
  41. package/dist/modules/customers/ai-tools/settings-pack.js.map +2 -2
  42. package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js +13 -5
  43. package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js.map +2 -2
  44. package/dist/modules/customers/lib/personCompanyLinkTable.js +4 -30
  45. package/dist/modules/customers/lib/personCompanyLinkTable.js.map +2 -2
  46. package/dist/modules/customers/migrations/Migration20260521175027.js +12 -0
  47. package/dist/modules/customers/migrations/Migration20260521175027.js.map +7 -0
  48. package/dist/modules/dashboards/components/WidgetVisibilityEditor.js +41 -12
  49. package/dist/modules/dashboards/components/WidgetVisibilityEditor.js.map +2 -2
  50. package/dist/modules/data_sync/api/options.js +2 -0
  51. package/dist/modules/data_sync/api/options.js.map +2 -2
  52. package/dist/modules/data_sync/api/run.js +77 -58
  53. package/dist/modules/data_sync/api/run.js.map +2 -2
  54. package/dist/modules/data_sync/backend/data-sync/page.js +3 -2
  55. package/dist/modules/data_sync/backend/data-sync/page.js.map +2 -2
  56. package/dist/modules/data_sync/components/IntegrationScheduleTab.js +8 -3
  57. package/dist/modules/data_sync/components/IntegrationScheduleTab.js.map +2 -2
  58. package/dist/modules/integrations/backend/integrations/[id]/page.js +15 -13
  59. package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
  60. package/dist/modules/integrations/data/validators.js +9 -6
  61. package/dist/modules/integrations/data/validators.js.map +2 -2
  62. package/dist/modules/messages/api/route.js +2 -2
  63. package/dist/modules/messages/api/route.js.map +2 -2
  64. package/dist/modules/messages/commands/messages.js +42 -2
  65. package/dist/modules/messages/commands/messages.js.map +2 -2
  66. package/dist/modules/messages/components/MessagesInboxPageClient.js +70 -89
  67. package/dist/modules/messages/components/MessagesInboxPageClient.js.map +2 -2
  68. package/dist/modules/messages/components/defaults/DefaultMessageListItem.js +8 -6
  69. package/dist/modules/messages/components/defaults/DefaultMessageListItem.js.map +2 -2
  70. package/dist/modules/messages/components/inboxFilters.js +133 -0
  71. package/dist/modules/messages/components/inboxFilters.js.map +7 -0
  72. package/dist/modules/messages/components/messageListLabels.js +14 -0
  73. package/dist/modules/messages/components/messageListLabels.js.map +7 -0
  74. package/dist/modules/messages/components/useMessagesInboxBulkActions.js +47 -23
  75. package/dist/modules/messages/components/useMessagesInboxBulkActions.js.map +2 -2
  76. package/dist/modules/messages/data/validators.js +8 -6
  77. package/dist/modules/messages/data/validators.js.map +2 -2
  78. package/dist/modules/progress/api/jobs/route.js +26 -16
  79. package/dist/modules/progress/api/jobs/route.js.map +2 -2
  80. package/dist/modules/query_index/di.js +8 -6
  81. package/dist/modules/query_index/di.js.map +2 -2
  82. package/dist/modules/query_index/lib/indexer.js +12 -10
  83. package/dist/modules/query_index/lib/indexer.js.map +2 -2
  84. package/dist/modules/query_index/subscribers/upsert_one.js +7 -1
  85. package/dist/modules/query_index/subscribers/upsert_one.js.map +2 -2
  86. package/dist/modules/sales/api/order-adjustments/route.js.map +2 -2
  87. package/dist/modules/sales/api/quote-adjustments/route.js.map +2 -2
  88. package/dist/modules/sales/commands/documents.js +104 -0
  89. package/dist/modules/sales/commands/documents.js.map +2 -2
  90. package/dist/modules/sales/data/validators.js +20 -1
  91. package/dist/modules/sales/data/validators.js.map +2 -2
  92. package/dist/modules/sales/lib/makeSalesLineRoute.js.map +2 -2
  93. package/dist/modules/sync_excel/api/import/route.js +126 -116
  94. package/dist/modules/sync_excel/api/import/route.js.map +2 -2
  95. package/dist/modules/sync_excel/lib/adapters/customers.js +1 -0
  96. package/dist/modules/sync_excel/lib/adapters/customers.js.map +2 -2
  97. package/dist/modules/translations/components/TranslationManager.js +9 -4
  98. package/dist/modules/translations/components/TranslationManager.js.map +2 -2
  99. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  100. package/dist/modules/workflows/lib/event-trigger-service.js +23 -11
  101. package/dist/modules/workflows/lib/event-trigger-service.js.map +2 -2
  102. package/jest.config.cjs +4 -1
  103. package/package.json +15 -16
  104. package/src/helpers/integration/queue-runner.ts +21 -2
  105. package/src/helpers/integration/queue.ts +1 -1
  106. package/src/helpers/integration/salesUi.ts +26 -1
  107. package/src/modules/auth/api/roles/acl/route.ts +39 -1
  108. package/src/modules/auth/api/roles/route.ts +38 -2
  109. package/src/modules/auth/api/users/acl/route.ts +41 -1
  110. package/src/modules/auth/api/users/route.ts +105 -12
  111. package/src/modules/auth/backend/roles/[id]/edit/page.tsx +35 -13
  112. package/src/modules/auth/backend/users/[id]/edit/page.tsx +1 -1
  113. package/src/modules/auth/backend/users/create/page.tsx +1 -1
  114. package/src/modules/auth/backend/users/roleOptions.ts +2 -1
  115. package/src/modules/auth/commands/roles.ts +8 -0
  116. package/src/modules/auth/commands/users.ts +3 -0
  117. package/src/modules/auth/components/AclEditor.tsx +50 -24
  118. package/src/modules/auth/frontend/login.tsx +40 -0
  119. package/src/modules/auth/lib/grantChecks.ts +116 -2
  120. package/src/modules/business_rules/lib/expression-evaluator.ts +9 -13
  121. package/src/modules/business_rules/lib/rule-evaluator.ts +3 -3
  122. package/src/modules/customers/ai-agents.ts +10 -1
  123. package/src/modules/customers/ai-tools/settings-pack.ts +62 -1
  124. package/src/modules/customers/components/detail/schedule/useScheduleFormState.ts +18 -7
  125. package/src/modules/customers/lib/personCompanyLinkTable.ts +33 -41
  126. package/src/modules/customers/migrations/Migration20260521175027.ts +39 -0
  127. package/src/modules/dashboards/components/WidgetVisibilityEditor.tsx +38 -12
  128. package/src/modules/data_sync/api/options.ts +2 -0
  129. package/src/modules/data_sync/api/run.ts +80 -61
  130. package/src/modules/data_sync/backend/data-sync/page.tsx +10 -0
  131. package/src/modules/data_sync/components/IntegrationScheduleTab.tsx +17 -3
  132. package/src/modules/data_sync/i18n/de.json +3 -0
  133. package/src/modules/data_sync/i18n/en.json +3 -0
  134. package/src/modules/data_sync/i18n/es.json +3 -0
  135. package/src/modules/data_sync/i18n/pl.json +3 -0
  136. package/src/modules/data_sync/lib/adapter.ts +9 -0
  137. package/src/modules/integrations/backend/integrations/[id]/page.tsx +15 -13
  138. package/src/modules/integrations/data/validators.ts +5 -4
  139. package/src/modules/messages/api/route.ts +2 -2
  140. package/src/modules/messages/commands/messages.ts +54 -2
  141. package/src/modules/messages/components/MessagesInboxPageClient.tsx +80 -92
  142. package/src/modules/messages/components/defaults/DefaultMessageListItem.tsx +9 -5
  143. package/src/modules/messages/components/inboxFilters.ts +162 -0
  144. package/src/modules/messages/components/messageListLabels.ts +29 -0
  145. package/src/modules/messages/components/useMessagesInboxBulkActions.ts +58 -26
  146. package/src/modules/messages/data/validators.ts +7 -5
  147. package/src/modules/messages/i18n/de.json +7 -4
  148. package/src/modules/messages/i18n/en.json +7 -4
  149. package/src/modules/messages/i18n/es.json +7 -4
  150. package/src/modules/messages/i18n/pl.json +7 -4
  151. package/src/modules/progress/api/jobs/route.ts +30 -20
  152. package/src/modules/query_index/di.ts +9 -7
  153. package/src/modules/query_index/lib/indexer.ts +13 -11
  154. package/src/modules/query_index/subscribers/upsert_one.ts +9 -1
  155. package/src/modules/sales/api/order-adjustments/route.ts +2 -1
  156. package/src/modules/sales/api/quote-adjustments/route.ts +2 -1
  157. package/src/modules/sales/commands/documents.ts +116 -0
  158. package/src/modules/sales/data/validators.ts +38 -0
  159. package/src/modules/sales/i18n/de.json +2 -0
  160. package/src/modules/sales/i18n/en.json +2 -0
  161. package/src/modules/sales/i18n/es.json +2 -0
  162. package/src/modules/sales/i18n/pl.json +2 -0
  163. package/src/modules/sales/lib/makeSalesLineRoute.ts +2 -1
  164. package/src/modules/staff/i18n/de.json +1 -0
  165. package/src/modules/staff/i18n/en.json +1 -0
  166. package/src/modules/staff/i18n/es.json +1 -0
  167. package/src/modules/staff/i18n/pl.json +1 -0
  168. package/src/modules/sync_excel/api/import/route.ts +141 -131
  169. package/src/modules/sync_excel/lib/adapters/customers.ts +1 -0
  170. package/src/modules/translations/components/TranslationManager.tsx +9 -4
  171. package/src/modules/workflows/lib/activity-executor.ts +4 -3
  172. package/src/modules/workflows/lib/event-trigger-service.ts +24 -11
  173. package/tsconfig.json +1 -0
@@ -16,8 +16,14 @@ import { useAppEvent } from '@open-mercato/ui/backend/injection/useAppEvent'
16
16
  import { Archive, ChevronDown, FilePenLine, Inbox, Layers, Send } from 'lucide-react'
17
17
  import { getMessageUiComponentRegistry } from './utils/typeUiRegistry'
18
18
  import { DefaultMessageListItem } from './defaults/DefaultMessageListItem'
19
+ import { getMessageListParticipantLabel } from './messageListLabels'
19
20
  import { toErrorMessage } from './message-detail/utils'
20
21
  import { useMessagesInboxBulkActions, type MessageFolder } from './useMessagesInboxBulkActions'
22
+ import {
23
+ buildMessagesInboxFilters,
24
+ buildMessagesListParams,
25
+ type SenderOption,
26
+ } from './inboxFilters'
21
27
 
22
28
  type MessageListItem = {
23
29
  id: string
@@ -33,6 +39,7 @@ type MessageListItem = {
33
39
  objectCount: number
34
40
  hasAttachments: boolean
35
41
  attachmentCount: number
42
+ recipientCount?: number
36
43
  hasActions: boolean
37
44
  actionTaken?: string | null
38
45
  sentAt?: string | null
@@ -104,30 +111,13 @@ export function MessagesInboxPageClient() {
104
111
  scopeVersion,
105
112
  ],
106
113
  queryFn: async () => {
107
- const params = new URLSearchParams()
108
- params.set('folder', folder)
109
- params.set('page', String(page))
110
- params.set('pageSize', String(pageSize))
111
-
112
- if (search.trim()) {
113
- params.set('search', search.trim())
114
- }
115
-
116
- const status = typeof filterValues.status === 'string' ? filterValues.status.trim() : ''
117
- const type = typeof filterValues.type === 'string' ? filterValues.type.trim() : ''
118
- const hasObjects = typeof filterValues.hasObjects === 'string' ? filterValues.hasObjects.trim() : ''
119
- const hasAttachments = typeof filterValues.hasAttachments === 'string' ? filterValues.hasAttachments.trim() : ''
120
- const hasActions = typeof filterValues.hasActions === 'string' ? filterValues.hasActions.trim() : ''
121
- const senderId = typeof filterValues.senderId === 'string' ? filterValues.senderId.trim() : ''
122
- const since = typeof filterValues.since === 'string' ? filterValues.since.trim() : ''
123
-
124
- if (status) params.set('status', status)
125
- if (type) params.set('type', type)
126
- if (hasObjects) params.set('hasObjects', hasObjects)
127
- if (hasAttachments) params.set('hasAttachments', hasAttachments)
128
- if (hasActions) params.set('hasActions', hasActions)
129
- if (senderId) params.set('senderId', senderId)
130
- if (since) params.set('since', since)
114
+ const params = buildMessagesListParams({
115
+ folder,
116
+ page,
117
+ pageSize,
118
+ search,
119
+ filterValues,
120
+ })
131
121
 
132
122
  const call = await apiCall<MessageListResponse>(`/api/messages?${params.toString()}`)
133
123
  if (!call.ok) {
@@ -189,6 +179,19 @@ export function MessagesInboxPageClient() {
189
179
  return map
190
180
  }, [messageTypesQuery.data, t])
191
181
 
182
+ const [senderOptions, setSenderOptions] = React.useState<SenderOption[]>([])
183
+ const senderOptionsScopeRef = React.useRef(scopeVersion)
184
+
185
+ const mergeSenderOptions = React.useCallback((incoming: SenderOption[]) => {
186
+ if (incoming.length === 0) return
187
+ setSenderOptions((prev) => {
188
+ const map = new Map<string, SenderOption>()
189
+ for (const opt of prev) map.set(opt.value, opt)
190
+ for (const opt of incoming) map.set(opt.value, opt)
191
+ return Array.from(map.values())
192
+ })
193
+ }, [])
194
+
192
195
  const loadSenderOptions = React.useCallback(async (query?: string) => {
193
196
  const params = new URLSearchParams()
194
197
  params.set('page', '1')
@@ -197,11 +200,19 @@ export function MessagesInboxPageClient() {
197
200
  params.set('search', query.trim())
198
201
  }
199
202
 
200
- const call = await apiCall<{ items?: UserListItem[] }>(`/api/auth/users?${params.toString()}`)
203
+ const call = await apiCall<{ items?: UserListItem[] }>(
204
+ `/api/auth/users?${params.toString()}`,
205
+ {
206
+ headers: {
207
+ 'x-om-forbidden-redirect': '0',
208
+ },
209
+ },
210
+ ).catch(() => null)
211
+ if (!call) return []
201
212
  if (!call.ok) return []
202
213
 
203
214
  const items = Array.isArray(call.result?.items) ? call.result?.items ?? [] : []
204
- return items.flatMap((item) => {
215
+ const next: SenderOption[] = items.flatMap((item) => {
205
216
  if (!item || typeof item.id !== 'string' || item.id.trim().length === 0) return []
206
217
  const name = typeof item.name === 'string' && item.name.trim().length > 0 ? item.name.trim() : null
207
218
  const email = typeof item.email === 'string' && item.email.trim().length > 0 ? item.email.trim() : null
@@ -212,7 +223,39 @@ export function MessagesInboxPageClient() {
212
223
  description: email && email !== label ? email : null,
213
224
  }]
214
225
  })
215
- }, [])
226
+ if (senderOptionsScopeRef.current === scopeVersion) {
227
+ mergeSenderOptions(next)
228
+ }
229
+ return next
230
+ }, [mergeSenderOptions, scopeVersion])
231
+
232
+ React.useEffect(() => {
233
+ const items = listQuery.data?.items ?? []
234
+ const next = items.flatMap((item): SenderOption[] => {
235
+ if (typeof item.senderUserId !== 'string' || item.senderUserId.trim().length === 0) return []
236
+ const name = typeof item.senderName === 'string' && item.senderName.trim().length > 0
237
+ ? item.senderName.trim()
238
+ : null
239
+ const email = typeof item.senderEmail === 'string' && item.senderEmail.trim().length > 0
240
+ ? item.senderEmail.trim()
241
+ : null
242
+ const label = name ?? email ?? item.senderUserId
243
+ return [{
244
+ value: item.senderUserId,
245
+ label,
246
+ description: email && email !== label ? email : null,
247
+ }]
248
+ })
249
+ mergeSenderOptions(next)
250
+ }, [listQuery.data?.items, mergeSenderOptions])
251
+
252
+ React.useEffect(() => {
253
+ senderOptionsScopeRef.current = scopeVersion
254
+ setSenderOptions([])
255
+ loadSenderOptions().catch((error: unknown) => {
256
+ console.warn('[messages] Failed to load sender filter options', error)
257
+ })
258
+ }, [loadSenderOptions, scopeVersion])
216
259
 
217
260
  const listItemComponentKeyByType = React.useMemo(() => {
218
261
  const map: Record<string, string | null> = {}
@@ -228,69 +271,13 @@ export function MessagesInboxPageClient() {
228
271
  label: t(item.labelKey, item.type),
229
272
  }))
230
273
 
231
- return [
232
- {
233
- id: 'status',
234
- label: t('messages.filters.status', 'Status'),
235
- type: 'select',
236
- options: [
237
- { value: '', label: t('messages.filters.all', 'All') },
238
- { value: 'unread', label: t('messages.status.unread', 'Unread') },
239
- { value: 'read', label: t('messages.status.read', 'Read') },
240
- { value: 'archived', label: t('messages.status.archived', 'Archived') },
241
- ],
242
- },
243
- {
244
- id: 'type',
245
- label: t('messages.filters.type', 'Type'),
246
- type: 'select',
247
- options: [{ value: '', label: t('messages.filters.all', 'All') }, ...typeOptions],
248
- },
249
- {
250
- id: 'hasObjects',
251
- label: t('messages.filters.hasObjects', 'Objects'),
252
- type: 'select',
253
- options: [
254
- { value: '', label: t('messages.filters.all', 'All') },
255
- { value: 'true', label: t('common.yes', 'Yes') },
256
- { value: 'false', label: t('common.no', 'No') },
257
- ],
258
- },
259
- {
260
- id: 'hasAttachments',
261
- label: t('messages.filters.hasAttachments', 'Attachments'),
262
- type: 'select',
263
- options: [
264
- { value: '', label: t('messages.filters.all', 'All') },
265
- { value: 'true', label: t('common.yes', 'Yes') },
266
- { value: 'false', label: t('common.no', 'No') },
267
- ],
268
- },
269
- {
270
- id: 'hasActions',
271
- label: t('messages.filters.hasActions', 'Actions'),
272
- type: 'select',
273
- options: [
274
- { value: '', label: t('messages.filters.all', 'All') },
275
- { value: 'true', label: t('common.yes', 'Yes') },
276
- { value: 'false', label: t('common.no', 'No') },
277
- ],
278
- },
279
- {
280
- id: 'senderId',
281
- label: t('messages.filters.sender', 'Sender'),
282
- type: 'select',
283
- options: [{ value: '', label: t('messages.filters.all', 'All') }],
284
- loadOptions: loadSenderOptions,
285
- },
286
- {
287
- id: 'since',
288
- label: t('messages.filters.since', 'Sent after'),
289
- type: 'text',
290
- placeholder: t('messages.filters.sincePlaceholder', 'YYYY-MM-DDTHH:mm:ssZ'),
291
- },
292
- ]
293
- }, [loadSenderOptions, messageTypesQuery.data, t])
274
+ return buildMessagesInboxFilters({
275
+ t,
276
+ typeOptions,
277
+ senderOptions,
278
+ loadSenderOptions,
279
+ })
280
+ }, [loadSenderOptions, messageTypesQuery.data, senderOptions, t])
294
281
 
295
282
  const columns = React.useMemo<ColumnDef<MessageListItem>[]>(() => [
296
283
  {
@@ -319,11 +306,12 @@ export function MessagesInboxPageClient() {
319
306
  bodyFormat: 'text' as const,
320
307
  priority: (item.priority as 'low' | 'normal' | 'high' | 'urgent') ?? 'normal',
321
308
  sentAt: item.sentAt ? new Date(item.sentAt) : null,
322
- senderName: item.senderName || item.senderEmail || item.senderUserId,
309
+ senderName: getMessageListParticipantLabel(item, folder, t),
323
310
  hasObjects: item.hasObjects,
324
311
  objectCount: item.objectCount,
325
312
  hasAttachments: item.hasAttachments,
326
313
  attachmentCount: item.attachmentCount,
314
+ recipientCount: item.recipientCount ?? 0,
327
315
  hasActions: item.hasActions,
328
316
  actionTaken: item.actionTaken ?? null,
329
317
  unread: item.status === 'unread',
@@ -333,7 +321,7 @@ export function MessagesInboxPageClient() {
333
321
  )
334
322
  },
335
323
  },
336
- ], [listItemComponentKeyByType, messageTypeLabelMap, messageUiRegistry, router, t])
324
+ ], [folder, listItemComponentKeyByType, messageTypeLabelMap, messageUiRegistry, router, t])
337
325
 
338
326
  const folderOptions = React.useMemo(() => [
339
327
  { id: 'inbox' as const, label: t('messages.folder.inbox', 'Inbox'), icon: Inbox },
@@ -31,7 +31,7 @@ function formatSentTime(value: Date | null): string {
31
31
 
32
32
  function truncateWords(value: string, maxWords: number): string {
33
33
  const normalized = value.trim().replace(/\s+/g, ' ')
34
- if (!normalized) return '—'
34
+ if (!normalized) return ''
35
35
  const words = normalized.split(' ')
36
36
  if (words.length <= maxWords) return normalized
37
37
  return `${words.slice(0, maxWords).join(' ')}...`
@@ -39,8 +39,8 @@ function truncateWords(value: string, maxWords: number): string {
39
39
 
40
40
  export function DefaultMessageListItem({ message, onClick }: MessageListItemProps) {
41
41
  const t = useT()
42
- const senderLabel = message.senderName || '—'
43
- const subject = message.subject || '—'
42
+ const senderLabel = message.senderName?.trim() || t('messages.list.noRecipient', '(No recipient)')
43
+ const subject = message.subject.trim() || t('messages.list.noSubject', '(No subject)')
44
44
  const absoluteSentAt = formatDateTime(message.sentAt)
45
45
  const sentAtLabel = formatSentTime(message.sentAt)
46
46
  const bodyPreview = truncateWords(message.body || '', 16)
@@ -75,8 +75,12 @@ export function DefaultMessageListItem({ message, onClick }: MessageListItemProp
75
75
  <span className={cn('truncate text-foreground', message.unread ? 'font-semibold' : 'font-normal')}>
76
76
  {subject}
77
77
  </span>
78
- <span className="text-muted-foreground">-</span>
79
- <span className="truncate text-muted-foreground">{bodyPreview}</span>
78
+ {bodyPreview ? (
79
+ <>
80
+ <span className="text-muted-foreground">-</span>
81
+ <span className="truncate text-muted-foreground">{bodyPreview}</span>
82
+ </>
83
+ ) : null}
80
84
  </div>
81
85
 
82
86
  <div className="flex flex-shrink-0 items-center gap-1.5 text-muted-foreground">
@@ -0,0 +1,162 @@
1
+ import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
2
+
3
+ type Translator = (key: string, fallback?: string) => string
4
+
5
+ export type SenderOption = {
6
+ value: string
7
+ label: string
8
+ description?: string | null
9
+ }
10
+
11
+ export type MessagesInboxFilterContext = {
12
+ t: Translator
13
+ typeOptions: { value: string; label: string }[]
14
+ senderOptions: SenderOption[]
15
+ loadSenderOptions: (query?: string) => Promise<SenderOption[]>
16
+ }
17
+
18
+ export function buildMessagesInboxFilters({
19
+ t,
20
+ typeOptions,
21
+ senderOptions,
22
+ loadSenderOptions,
23
+ }: MessagesInboxFilterContext): FilterDef[] {
24
+ const senderLabel = (val: string): string => {
25
+ const match = senderOptions.find((opt) => opt.value === val)
26
+ return match?.label ?? val
27
+ }
28
+
29
+ return [
30
+ {
31
+ id: 'status',
32
+ label: t('messages.filters.status', 'Status'),
33
+ type: 'select',
34
+ options: [
35
+ { value: '', label: t('messages.filters.all', 'All') },
36
+ { value: 'unread', label: t('messages.status.unread', 'Unread') },
37
+ { value: 'read', label: t('messages.status.read', 'Read') },
38
+ { value: 'archived', label: t('messages.status.archived', 'Archived') },
39
+ ],
40
+ },
41
+ {
42
+ id: 'type',
43
+ label: t('messages.filters.type', 'Type'),
44
+ type: 'select',
45
+ options: [{ value: '', label: t('messages.filters.all', 'All') }, ...typeOptions],
46
+ },
47
+ {
48
+ id: 'hasObjects',
49
+ label: t('messages.filters.hasObjects', 'Has objects'),
50
+ type: 'select',
51
+ options: [
52
+ { value: '', label: t('messages.filters.all', 'All') },
53
+ { value: 'true', label: t('common.yes', 'Yes') },
54
+ { value: 'false', label: t('common.no', 'No') },
55
+ ],
56
+ },
57
+ {
58
+ id: 'hasAttachments',
59
+ label: t('messages.filters.hasAttachments', 'Has attachments'),
60
+ type: 'select',
61
+ options: [
62
+ { value: '', label: t('messages.filters.all', 'All') },
63
+ { value: 'true', label: t('common.yes', 'Yes') },
64
+ { value: 'false', label: t('common.no', 'No') },
65
+ ],
66
+ },
67
+ {
68
+ id: 'hasActions',
69
+ label: t('messages.filters.hasActions', 'Has actions'),
70
+ type: 'select',
71
+ options: [
72
+ { value: '', label: t('messages.filters.all', 'All') },
73
+ { value: 'true', label: t('common.yes', 'Yes') },
74
+ { value: 'false', label: t('common.no', 'No') },
75
+ ],
76
+ },
77
+ {
78
+ id: 'senderId',
79
+ label: t('messages.filters.sender', 'Sender'),
80
+ type: 'combobox',
81
+ placeholder: t('messages.filters.senderEmpty', 'Type to search users'),
82
+ options: senderOptions,
83
+ loadOptions: loadSenderOptions,
84
+ formatValue: senderLabel,
85
+ formatDescription: (val: string) =>
86
+ senderOptions.find((opt) => opt.value === val)?.description ?? null,
87
+ },
88
+ {
89
+ id: 'since',
90
+ label: t('messages.filters.since', 'Sent after'),
91
+ type: 'text',
92
+ placeholder: t('messages.filters.sincePlaceholder', 'YYYY-MM-DD'),
93
+ },
94
+ ]
95
+ }
96
+
97
+ const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/
98
+
99
+ export function normalizeMessagesSinceValue(input: string): string | null {
100
+ const trimmed = input.trim()
101
+ if (!trimmed) return null
102
+ if (DATE_ONLY_PATTERN.test(trimmed)) {
103
+ const parsed = new Date(`${trimmed}T00:00:00.000Z`)
104
+ if (Number.isNaN(parsed.getTime())) return null
105
+ if (parsed.toISOString().slice(0, 10) !== trimmed) return null
106
+ return parsed.toISOString()
107
+ }
108
+ const parsed = new Date(trimmed)
109
+ if (Number.isNaN(parsed.getTime())) return null
110
+ return parsed.toISOString()
111
+ }
112
+
113
+ export type BuildMessagesListParamsOptions = {
114
+ folder: string
115
+ page: number
116
+ pageSize: number
117
+ search: string
118
+ filterValues: FilterValues
119
+ }
120
+
121
+ export function buildMessagesListParams({
122
+ folder,
123
+ page,
124
+ pageSize,
125
+ search,
126
+ filterValues,
127
+ }: BuildMessagesListParamsOptions): URLSearchParams {
128
+ const params = new URLSearchParams()
129
+ params.set('folder', folder)
130
+ params.set('page', String(page))
131
+ params.set('pageSize', String(pageSize))
132
+
133
+ if (search.trim()) {
134
+ params.set('search', search.trim())
135
+ }
136
+
137
+ const stringValue = (id: string): string => {
138
+ const raw = filterValues[id]
139
+ return typeof raw === 'string' ? raw.trim() : ''
140
+ }
141
+
142
+ const status = stringValue('status')
143
+ const type = stringValue('type')
144
+ const hasObjects = stringValue('hasObjects')
145
+ const hasAttachments = stringValue('hasAttachments')
146
+ const hasActions = stringValue('hasActions')
147
+ const senderId = stringValue('senderId')
148
+ const sinceRaw = stringValue('since')
149
+
150
+ if (status) params.set('status', status)
151
+ if (type) params.set('type', type)
152
+ if (hasObjects) params.set('hasObjects', hasObjects)
153
+ if (hasAttachments) params.set('hasAttachments', hasAttachments)
154
+ if (hasActions) params.set('hasActions', hasActions)
155
+ if (senderId) params.set('senderId', senderId)
156
+ if (sinceRaw) {
157
+ const normalized = normalizeMessagesSinceValue(sinceRaw)
158
+ if (normalized) params.set('since', normalized)
159
+ }
160
+
161
+ return params
162
+ }
@@ -0,0 +1,29 @@
1
+ import type { MessageFolder } from './useMessagesInboxBulkActions'
2
+
3
+ type Translate = (key: string, fallback: string, params?: Record<string, string | number>) => string
4
+
5
+ type MessageParticipantSource = {
6
+ senderName?: string | null
7
+ senderEmail?: string | null
8
+ senderUserId: string
9
+ recipientCount?: number | null
10
+ }
11
+
12
+ function normalizeLabel(value: string | null | undefined): string | null {
13
+ const normalized = value?.trim()
14
+ return normalized ? normalized : null
15
+ }
16
+
17
+ export function getMessageListParticipantLabel(
18
+ item: MessageParticipantSource,
19
+ folder: MessageFolder,
20
+ t: Translate,
21
+ ): string {
22
+ if ((folder === 'sent' || folder === 'drafts') && Number(item.recipientCount ?? 0) <= 0) {
23
+ return t('messages.list.noRecipient', '(No recipient)')
24
+ }
25
+
26
+ return normalizeLabel(item.senderName)
27
+ ?? normalizeLabel(item.senderEmail)
28
+ ?? item.senderUserId
29
+ }
@@ -13,7 +13,7 @@ import { toErrorMessage } from './message-detail/utils'
13
13
 
14
14
  export type MessageFolder = 'inbox' | 'sent' | 'drafts' | 'archived' | 'all'
15
15
 
16
- type MessageBulkActionId = 'markRead' | 'markUnread' | 'archive' | 'delete'
16
+ export type MessageBulkActionId = 'markRead' | 'markUnread' | 'archive' | 'delete'
17
17
 
18
18
  type BulkExecutionSummary = {
19
19
  action: MessageBulkActionId
@@ -31,6 +31,13 @@ type MessageBulkRequestConfig = {
31
31
  errorFallback: string
32
32
  }
33
33
 
34
+ type MessageBulkActionDefinition = {
35
+ id: string
36
+ labelKey: string
37
+ labelFallback: string
38
+ destructive?: boolean
39
+ }
40
+
34
41
  type MessageInboxBulkMutationContext = {
35
42
  actionId: MessageBulkActionId
36
43
  messageIds: string[]
@@ -87,6 +94,42 @@ const MESSAGE_BULK_REQUESTS: Record<MessageBulkActionId, MessageBulkRequestConfi
87
94
  },
88
95
  }
89
96
 
97
+ const MESSAGE_BULK_ACTIONS: Record<MessageBulkActionId, MessageBulkActionDefinition> = {
98
+ markRead: {
99
+ id: 'messages-mark-read',
100
+ labelKey: 'messages.actions.markRead',
101
+ labelFallback: 'Mark read',
102
+ },
103
+ markUnread: {
104
+ id: 'messages-mark-unread',
105
+ labelKey: 'messages.actions.markUnread',
106
+ labelFallback: 'Mark unread',
107
+ },
108
+ archive: {
109
+ id: 'messages-archive',
110
+ labelKey: 'messages.actions.archive',
111
+ labelFallback: 'Archive',
112
+ },
113
+ delete: {
114
+ id: 'messages-delete',
115
+ labelKey: 'messages.actions.delete',
116
+ labelFallback: 'Delete',
117
+ destructive: true,
118
+ },
119
+ }
120
+
121
+ const MESSAGE_BULK_ACTION_IDS_BY_FOLDER = {
122
+ inbox: ['markRead', 'markUnread', 'archive', 'delete'],
123
+ archived: ['markRead', 'markUnread', 'delete'],
124
+ sent: ['delete'],
125
+ drafts: ['delete'],
126
+ all: [],
127
+ } satisfies Record<MessageFolder, readonly MessageBulkActionId[]>
128
+
129
+ export function getMessageBulkActionIdsForFolder(folder: MessageFolder): MessageBulkActionId[] {
130
+ return [...MESSAGE_BULK_ACTION_IDS_BY_FOLDER[folder]]
131
+ }
132
+
90
133
  function normalizeSelectionScopeValue(value: unknown): unknown {
91
134
  if (value == null) return undefined
92
135
  if (typeof value === 'string') {
@@ -287,31 +330,20 @@ export function useMessagesInboxBulkActions<T extends MessageInboxBulkRow>({
287
330
  }, [confirm, filterValues, folder, page, queryClient, retryLastMutation, runMutation, search, t])
288
331
 
289
332
  const bulkActions = React.useMemo<BulkAction<T>[] | undefined>(
290
- () => folder === 'inbox'
291
- ? [
292
- {
293
- id: 'messages-mark-read',
294
- label: t('messages.actions.markRead', 'Mark read'),
295
- onExecute: (selectedRows: T[]) => executeBulkAction('markRead', selectedRows),
296
- },
297
- {
298
- id: 'messages-mark-unread',
299
- label: t('messages.actions.markUnread', 'Mark unread'),
300
- onExecute: (selectedRows: T[]) => executeBulkAction('markUnread', selectedRows),
301
- },
302
- {
303
- id: 'messages-archive',
304
- label: t('messages.actions.archive', 'Archive'),
305
- onExecute: (selectedRows: T[]) => executeBulkAction('archive', selectedRows),
306
- },
307
- {
308
- id: 'messages-delete',
309
- label: t('messages.actions.delete', 'Delete'),
310
- destructive: true,
311
- onExecute: (selectedRows: T[]) => executeBulkAction('delete', selectedRows),
312
- },
313
- ]
314
- : undefined,
333
+ () => {
334
+ const actionIds = getMessageBulkActionIdsForFolder(folder)
335
+ if (actionIds.length === 0) return undefined
336
+
337
+ return actionIds.map((actionId) => {
338
+ const action = MESSAGE_BULK_ACTIONS[actionId]
339
+ return {
340
+ id: action.id,
341
+ label: t(action.labelKey, action.labelFallback),
342
+ destructive: action.destructive,
343
+ onExecute: (selectedRows: T[]) => executeBulkAction(actionId, selectedRows),
344
+ }
345
+ })
346
+ },
315
347
  [executeBulkAction, folder, t],
316
348
  )
317
349
 
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod'
2
+ import { parseBooleanFlag } from '@open-mercato/shared/lib/boolean'
2
3
 
3
4
  function collectDuplicateRecipientIds(
4
5
  recipients: Array<{ userId: string }>,
@@ -178,14 +179,15 @@ export const updateDraftSchema = z.object({
178
179
  externalEmail: z.string().email().optional(),
179
180
  externalName: z.string().min(1).max(255).optional(),
180
181
  recipients: z.array(messageRecipientSchema).optional(),
181
- subject: z.string().min(1).max(500).optional(),
182
- body: z.string().min(1).max(50000).optional(),
182
+ subject: z.string().max(500).optional(),
183
+ body: z.string().max(50000).optional(),
183
184
  bodyFormat: z.enum(['text', 'markdown']).optional(),
184
185
  priority: z.enum(['low', 'normal', 'high', 'urgent']).optional(),
185
186
  objects: z.array(messageObjectSchema).optional(),
186
187
  attachmentIds: z.array(z.string().uuid()).optional(),
187
188
  actionData: messageActionDataSchema.optional(),
188
189
  sendViaEmail: z.boolean().optional(),
190
+ isDraft: z.literal(false).optional(),
189
191
  }).superRefine((value, ctx) => {
190
192
  if (value.recipients) {
191
193
  const duplicateRecipientIds = collectDuplicateRecipientIds(value.recipients)
@@ -209,9 +211,9 @@ export const listMessagesSchema = z.object({
209
211
  sourceEntityType: z.string().optional(),
210
212
  sourceEntityId: z.string().uuid().optional(),
211
213
  externalEmail: z.string().email().optional(),
212
- hasObjects: z.coerce.boolean().optional(),
213
- hasAttachments: z.coerce.boolean().optional(),
214
- hasActions: z.coerce.boolean().optional(),
214
+ hasObjects: z.string().transform(parseBooleanFlag).optional(),
215
+ hasAttachments: z.string().transform(parseBooleanFlag).optional(),
216
+ hasActions: z.string().transform(parseBooleanFlag).optional(),
215
217
  senderId: z.string().uuid().optional(),
216
218
  search: z.string().max(200).optional(),
217
219
  since: z.string().datetime().optional(),
@@ -131,12 +131,13 @@
131
131
  "messages.externalName": "Externer Name",
132
132
  "messages.fields.type": "Nachrichtentyp",
133
133
  "messages.filters.all": "Alle",
134
- "messages.filters.hasActions": "Aktionen",
135
- "messages.filters.hasAttachments": "Anhänge",
136
- "messages.filters.hasObjects": "Objekte",
134
+ "messages.filters.hasActions": "Hat Aktionen",
135
+ "messages.filters.hasAttachments": "Hat Anhänge",
136
+ "messages.filters.hasObjects": "Hat Objekte",
137
137
  "messages.filters.sender": "Absender",
138
+ "messages.filters.senderEmpty": "Tippe, um Benutzer zu suchen",
138
139
  "messages.filters.since": "Gesendet nach",
139
- "messages.filters.sincePlaceholder": "YYYY-MM-DDTHH:mm:ssZ",
140
+ "messages.filters.sincePlaceholder": "YYYY-MM-DD",
140
141
  "messages.filters.status": "Status",
141
142
  "messages.filters.type": "Typ",
142
143
  "messages.flash.actionSuccess": "Aktion abgeschlossen.",
@@ -159,6 +160,8 @@
159
160
  "messages.forwardNote": "Optionale Notiz",
160
161
  "messages.includeAttachments": "Anhänge einschließen",
161
162
  "messages.includeAttachmentsHint": "Anhänge aus der Originalnachricht übernehmen.",
163
+ "messages.list.noRecipient": "(Kein Empfänger)",
164
+ "messages.list.noSubject": "(Kein Betreff)",
162
165
  "messages.loading.attachments": "Anhänge werden geladen...",
163
166
  "messages.loading.detail": "Nachricht wird geladen...",
164
167
  "messages.loading.objectTypes": "Objekttypen werden geladen...",
@@ -131,12 +131,13 @@
131
131
  "messages.externalName": "External name",
132
132
  "messages.fields.type": "Message type",
133
133
  "messages.filters.all": "All",
134
- "messages.filters.hasActions": "Actions",
135
- "messages.filters.hasAttachments": "Attachments",
136
- "messages.filters.hasObjects": "Objects",
134
+ "messages.filters.hasActions": "Has actions",
135
+ "messages.filters.hasAttachments": "Has attachments",
136
+ "messages.filters.hasObjects": "Has objects",
137
137
  "messages.filters.sender": "Sender",
138
+ "messages.filters.senderEmpty": "Type to search users",
138
139
  "messages.filters.since": "Sent after",
139
- "messages.filters.sincePlaceholder": "YYYY-MM-DDTHH:mm:ssZ",
140
+ "messages.filters.sincePlaceholder": "YYYY-MM-DD",
140
141
  "messages.filters.status": "Status",
141
142
  "messages.filters.type": "Type",
142
143
  "messages.flash.actionSuccess": "Action completed.",
@@ -159,6 +160,8 @@
159
160
  "messages.forwardNote": "Optional note",
160
161
  "messages.includeAttachments": "Include attachments",
161
162
  "messages.includeAttachmentsHint": "Carry over attachments from the original message.",
163
+ "messages.list.noRecipient": "(No recipient)",
164
+ "messages.list.noSubject": "(No subject)",
162
165
  "messages.loading.attachments": "Loading attachments...",
163
166
  "messages.loading.detail": "Loading message...",
164
167
  "messages.loading.objectTypes": "Loading object types...",