@open-mercato/core 0.6.8-develop.7023.1.a1960eb208 → 0.6.8-develop.7026.1.23445b3f3e

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 (57) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/api/roles/acl/route.js +28 -33
  3. package/dist/modules/auth/api/roles/acl/route.js.map +2 -2
  4. package/dist/modules/auth/api/users/acl/route.js +96 -51
  5. package/dist/modules/auth/api/users/acl/route.js.map +2 -2
  6. package/dist/modules/auth/commands/acl.js +254 -0
  7. package/dist/modules/auth/commands/acl.js.map +7 -0
  8. package/dist/modules/auth/lib/grantChecks.js.map +2 -2
  9. package/dist/modules/communication_channels/commands/ingest-inbound-message.js +9 -1
  10. package/dist/modules/communication_channels/commands/ingest-inbound-message.js.map +2 -2
  11. package/dist/modules/inbox_ops/lib/htmlToPlainText.js +1 -12
  12. package/dist/modules/inbox_ops/lib/htmlToPlainText.js.map +2 -2
  13. package/dist/modules/messages/api/[id]/route.js +3 -0
  14. package/dist/modules/messages/api/[id]/route.js.map +2 -2
  15. package/dist/modules/messages/api/openapi.js +3 -0
  16. package/dist/modules/messages/api/openapi.js.map +2 -2
  17. package/dist/modules/messages/components/MessagesInboxPageClient.js +3 -13
  18. package/dist/modules/messages/components/MessagesInboxPageClient.js.map +2 -2
  19. package/dist/modules/messages/components/inboxFilters.js +15 -0
  20. package/dist/modules/messages/components/inboxFilters.js.map +2 -2
  21. package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsConversation.js +11 -1
  22. package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsConversation.js.map +2 -2
  23. package/dist/modules/messages/components/message-detail/panels/MessageHeader.js +3 -1
  24. package/dist/modules/messages/components/message-detail/panels/MessageHeader.js.map +2 -2
  25. package/dist/modules/messages/components/message-detail/panels/thread-panel.js +2 -1
  26. package/dist/modules/messages/components/message-detail/panels/thread-panel.js.map +2 -2
  27. package/dist/modules/messages/components/messageListLabels.js +14 -2
  28. package/dist/modules/messages/components/messageListLabels.js.map +2 -2
  29. package/dist/modules/sales/components/documents/SalesDocumentsTable.js +64 -14
  30. package/dist/modules/sales/components/documents/SalesDocumentsTable.js.map +2 -2
  31. package/package.json +7 -9
  32. package/src/modules/auth/api/roles/acl/route.ts +42 -39
  33. package/src/modules/auth/api/users/acl/route.ts +177 -53
  34. package/src/modules/auth/commands/acl.ts +495 -0
  35. package/src/modules/auth/i18n/de.json +2 -0
  36. package/src/modules/auth/i18n/en.json +2 -0
  37. package/src/modules/auth/i18n/es.json +2 -0
  38. package/src/modules/auth/i18n/ko.json +2 -0
  39. package/src/modules/auth/i18n/pl.json +2 -0
  40. package/src/modules/auth/lib/grantChecks.ts +6 -0
  41. package/src/modules/communication_channels/commands/ingest-inbound-message.ts +33 -1
  42. package/src/modules/inbox_ops/lib/htmlToPlainText.ts +1 -17
  43. package/src/modules/messages/api/[id]/route.ts +3 -0
  44. package/src/modules/messages/api/openapi.ts +3 -0
  45. package/src/modules/messages/components/MessagesInboxPageClient.tsx +5 -16
  46. package/src/modules/messages/components/inboxFilters.ts +36 -0
  47. package/src/modules/messages/components/message-detail/hooks/useMessageDetailsConversation.ts +14 -1
  48. package/src/modules/messages/components/message-detail/panels/MessageHeader.tsx +3 -1
  49. package/src/modules/messages/components/message-detail/panels/thread-panel.tsx +2 -1
  50. package/src/modules/messages/components/message-detail/types.ts +3 -0
  51. package/src/modules/messages/components/messageListLabels.ts +52 -3
  52. package/src/modules/sales/components/documents/SalesDocumentsTable.tsx +101 -14
  53. package/src/modules/sales/i18n/de.json +2 -0
  54. package/src/modules/sales/i18n/en.json +2 -0
  55. package/src/modules/sales/i18n/es.json +2 -0
  56. package/src/modules/sales/i18n/ko.json +2 -0
  57. package/src/modules/sales/i18n/pl.json +2 -0
@@ -24,6 +24,7 @@ import { useMessagesInboxBulkActions, type MessageFolder } from './useMessagesIn
24
24
  import {
25
25
  buildMessagesInboxFilters,
26
26
  buildMessagesListParams,
27
+ buildSenderOptionsFromMessages,
27
28
  type SenderOption,
28
29
  } from './inboxFilters'
29
30
  import { createLogger } from '@open-mercato/shared/lib/logger'
@@ -38,6 +39,9 @@ type MessageListItem = {
38
39
  senderUserId: string
39
40
  senderName?: string | null
40
41
  senderEmail?: string | null
42
+ externalName?: string | null
43
+ externalEmail?: string | null
44
+ sourceEntityType?: string | null
41
45
  priority: string
42
46
  status: string
43
47
  hasObjects: boolean
@@ -236,22 +240,7 @@ export function MessagesInboxPageClient() {
236
240
 
237
241
  React.useEffect(() => {
238
242
  const items = listQuery.data?.items ?? []
239
- const next = items.flatMap((item): SenderOption[] => {
240
- if (typeof item.senderUserId !== 'string' || item.senderUserId.trim().length === 0) return []
241
- const name = typeof item.senderName === 'string' && item.senderName.trim().length > 0
242
- ? item.senderName.trim()
243
- : null
244
- const email = typeof item.senderEmail === 'string' && item.senderEmail.trim().length > 0
245
- ? item.senderEmail.trim()
246
- : null
247
- const label = name ?? email ?? item.senderUserId
248
- return [{
249
- value: item.senderUserId,
250
- label,
251
- description: email && email !== label ? email : null,
252
- }]
253
- })
254
- mergeSenderOptions(next)
243
+ mergeSenderOptions(buildSenderOptionsFromMessages(items))
255
244
  }, [listQuery.data?.items, mergeSenderOptions])
256
245
 
257
246
  React.useEffect(() => {
@@ -8,6 +8,42 @@ export type SenderOption = {
8
8
  description?: string | null
9
9
  }
10
10
 
11
+ type SenderOptionSource = {
12
+ senderUserId?: string | null
13
+ senderName?: string | null
14
+ senderEmail?: string | null
15
+ }
16
+
17
+ /**
18
+ * Sender-filter options derived from the rows currently in the list.
19
+ *
20
+ * The filter narrows by `senderUserId`, so a row only yields an honest option
21
+ * when it also carries the platform identity behind that id. Ingested external
22
+ * mail does not: every such message in a tenant is composed under one
23
+ * system-user id, so labelling that id with one external sender's name would
24
+ * collapse every external sender into a single option that quietly returns all
25
+ * of their mail. Those rows are skipped, and the directory-loaded options —
26
+ * which are real platform users — stand alone.
27
+ */
28
+ export function buildSenderOptionsFromMessages(items: SenderOptionSource[]): SenderOption[] {
29
+ return items.flatMap((item): SenderOption[] => {
30
+ if (typeof item.senderUserId !== 'string' || item.senderUserId.trim().length === 0) return []
31
+ const name = typeof item.senderName === 'string' && item.senderName.trim().length > 0
32
+ ? item.senderName.trim()
33
+ : null
34
+ const email = typeof item.senderEmail === 'string' && item.senderEmail.trim().length > 0
35
+ ? item.senderEmail.trim()
36
+ : null
37
+ const label = name ?? email
38
+ if (!label) return []
39
+ return [{
40
+ value: item.senderUserId,
41
+ label,
42
+ description: email && email !== label ? email : null,
43
+ }]
44
+ })
45
+ }
46
+
11
47
  export type MessagesInboxFilterContext = {
12
48
  t: Translator
13
49
  typeOptions: { value: string; label: string }[]
@@ -4,12 +4,16 @@ import * as React from 'react'
4
4
  import type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'
5
5
  import type { MessageListItemProps } from '@open-mercato/shared/modules/messages/types'
6
6
  import type { MessageDetail } from '../types'
7
+ import { getMessageParticipantLabel } from '../../messageListLabels'
7
8
 
8
9
  export type ConversationItem = {
9
10
  id: string
10
11
  senderUserId: string
11
12
  senderName?: string | null
12
13
  senderEmail?: string | null
14
+ externalName?: string | null
15
+ externalEmail?: string | null
16
+ sourceEntityType?: string | null
13
17
  body: string
14
18
  bodyFormat?: 'text' | 'markdown'
15
19
  sentAt?: string | null
@@ -33,6 +37,9 @@ export function useMessageDetailsConversation({
33
37
  senderUserId: detail.senderUserId,
34
38
  senderName: detail.senderName,
35
39
  senderEmail: detail.senderEmail,
40
+ externalName: detail.externalName,
41
+ externalEmail: detail.externalEmail,
42
+ sourceEntityType: detail.sourceEntityType,
36
43
  body: detail.body,
37
44
  bodyFormat: detail.bodyFormat,
38
45
  sentAt: detail.sentAt,
@@ -46,6 +53,9 @@ export function useMessageDetailsConversation({
46
53
  senderUserId: item.senderUserId,
47
54
  senderName: item.senderName,
48
55
  senderEmail: item.senderEmail,
56
+ externalName: item.externalName,
57
+ externalEmail: item.externalEmail,
58
+ sourceEntityType: item.sourceEntityType,
49
59
  body: item.body,
50
60
  bodyFormat: item.bodyFormat,
51
61
  sentAt: item.sentAt,
@@ -58,6 +68,9 @@ export function useMessageDetailsConversation({
58
68
  senderUserId: detail.senderUserId,
59
69
  senderName: detail.senderName,
60
70
  senderEmail: detail.senderEmail,
71
+ externalName: detail.externalName,
72
+ externalEmail: detail.externalEmail,
73
+ sourceEntityType: detail.sourceEntityType,
61
74
  body: detail.body,
62
75
  bodyFormat: detail.bodyFormat,
63
76
  sentAt: detail.sentAt,
@@ -114,7 +127,7 @@ export function useMessageDetailsConversation({
114
127
  bodyFormat: item.bodyFormat ?? 'text',
115
128
  priority: 'normal',
116
129
  sentAt: item.sentAt ? new Date(item.sentAt) : null,
117
- senderName: item.senderName || item.senderEmail || item.senderUserId,
130
+ senderName: getMessageParticipantLabel(item),
118
131
  hasObjects: (item.objects ?? []).length > 0,
119
132
  objectCount: (item.objects ?? []).length,
120
133
  hasAttachments: false,
@@ -18,6 +18,7 @@ import {
18
18
  } from 'lucide-react'
19
19
  import type { MessageDetail } from '../types'
20
20
  import { formatDateTime } from '../utils'
21
+ import { getMessageParticipantLabel } from '../../messageListLabels'
21
22
 
22
23
  type MessageHeaderProps = {
23
24
  detail: MessageDetail
@@ -40,6 +41,7 @@ export function MessageHeader(props: MessageHeaderProps) {
40
41
  const t = useT()
41
42
  const showSubject = props.showSubject !== false
42
43
  const canReply = !props.detail.isDraft && props.detail.typeDefinition.allowReply
44
+ const senderLabel = getMessageParticipantLabel(props.detail)
43
45
  const actionItems = React.useMemo(() => {
44
46
  const items: ActionItem[] = []
45
47
 
@@ -121,7 +123,7 @@ export function MessageHeader(props: MessageHeaderProps) {
121
123
  <FormHeader
122
124
  mode="detail"
123
125
  title={showSubject ? props.detail.subject : undefined}
124
- subtitle={`${t('messages.detail.from', 'From')}: ${props.detail.senderName || props.detail.senderEmail || props.detail.senderUserId}`}
126
+ subtitle={`${t('messages.detail.from', 'From')}: ${senderLabel}`}
125
127
  statusBadge={<p className="text-xs text-muted-foreground">{formatDateTime(props.detail.sentAt)}</p>}
126
128
  utilityActions={canReply ? (
127
129
  <IconButton
@@ -6,6 +6,7 @@ import { getMessageUiComponentRegistry } from '../../utils/typeUiRegistry'
6
6
  import { getMessageObjectType } from '../../../lib/message-objects-registry'
7
7
  import type { MessageDetail, MessageDetailObject } from '../types'
8
8
  import { formatDateTime } from '../utils'
9
+ import { getMessageParticipantLabel } from '../../messageListLabels'
9
10
 
10
11
  export function MessageDetailThreadSection({ detail }: { detail: MessageDetail }) {
11
12
  const t = useT()
@@ -20,7 +21,7 @@ export function MessageDetailThreadSection({ detail }: { detail: MessageDetail }
20
21
  {(detail.thread ?? []).map((threadItem) => (
21
22
  <article key={threadItem.id} className="rounded border p-3">
22
23
  <p className="text-xs text-muted-foreground">
23
- {(threadItem.senderName || threadItem.senderEmail || threadItem.senderUserId)} • {formatDateTime(threadItem.sentAt)}
24
+ {getMessageParticipantLabel(threadItem)} • {formatDateTime(threadItem.sentAt)}
24
25
  </p>
25
26
  <div className="mt-2 max-h-[60vh] overflow-y-auto pr-1">
26
27
  <MarkdownContent
@@ -94,6 +94,9 @@ export type MessageDetail = {
94
94
  senderUserId: string
95
95
  senderName?: string | null
96
96
  senderEmail?: string | null
97
+ externalName?: string | null
98
+ externalEmail?: string | null
99
+ sourceEntityType?: string | null
97
100
  body: string
98
101
  bodyFormat?: 'text' | 'markdown'
99
102
  sentAt?: string | null
@@ -2,9 +2,29 @@ import type { MessageFolder } from './useMessagesInboxBulkActions'
2
2
 
3
3
  type Translate = (key: string, fallback: string, params?: Record<string, string | number>) => string
4
4
 
5
+ /**
6
+ * `Message.sourceEntityType` written by `communication_channels`'
7
+ * `ingest-inbound-message` command, and by nothing else — the outbound bridge
8
+ * treats it as the marker that a message arrived from outside the platform
9
+ * rather than being sent from it. It is the only reliable inbound
10
+ * discriminator: `type` is `channel.<provider>` on the outbound
11
+ * `send-as-user` path too, where `externalName` carries the recipient side.
12
+ */
13
+ const INBOUND_EXTERNAL_SOURCE_ENTITY_TYPE = 'communication_channels.external_conversation'
14
+
5
15
  type MessageParticipantSource = {
6
16
  senderName?: string | null
7
17
  senderEmail?: string | null
18
+ /**
19
+ * The external counterparty on a message that crosses the platform boundary —
20
+ * the sender for an inbound email/chat ingested by `communication_channels`,
21
+ * the recipient for an outbound reply sent through `inbox_ops`. Which side it
22
+ * represents is decided by `sourceEntityType`, not by whether a platform
23
+ * sender happens to be present.
24
+ */
25
+ externalName?: string | null
26
+ externalEmail?: string | null
27
+ sourceEntityType?: string | null
8
28
  senderUserId: string
9
29
  recipientCount?: number | null
10
30
  }
@@ -14,6 +34,37 @@ function normalizeLabel(value: string | null | undefined): string | null {
14
34
  return normalized ? normalized : null
15
35
  }
16
36
 
37
+ function isInboundExternalMessage(item: MessageParticipantSource): boolean {
38
+ return item.sourceEntityType === INBOUND_EXTERNAL_SOURCE_ENTITY_TYPE
39
+ }
40
+
41
+ /**
42
+ * Human-readable label for a message's author.
43
+ *
44
+ * An ingested inbound message is authored by the external counterparty, even
45
+ * though it is composed under a platform user id: that id is the
46
+ * `communication_channels` system user, or — once an operator assigns the
47
+ * conversation — the assigned agent, who resolves to a real name in the user
48
+ * directory and would otherwise be printed as the author of the customer's own
49
+ * email. So on the inbound side the external identity wins, and the platform
50
+ * identity is only the fallback. Everywhere else the platform sender is the
51
+ * author and the external identity describes the recipient, so the order is
52
+ * reversed. The raw user id remains the last resort in both directions.
53
+ *
54
+ * The single source of truth for every place the messages module prints a
55
+ * participant — the inbox list, the detail header and the conversation rows —
56
+ * so an ingested email renders as "Jane Doe" / "jane@example.com" rather than
57
+ * the `communication_channels` system user id or the agent handling it.
58
+ */
59
+ export function getMessageParticipantLabel(item: MessageParticipantSource): string {
60
+ const platformIdentity = normalizeLabel(item.senderName) ?? normalizeLabel(item.senderEmail)
61
+ const externalIdentity = normalizeLabel(item.externalName) ?? normalizeLabel(item.externalEmail)
62
+ const preferred = isInboundExternalMessage(item) ? externalIdentity : platformIdentity
63
+ const fallback = isInboundExternalMessage(item) ? platformIdentity : externalIdentity
64
+
65
+ return preferred ?? fallback ?? item.senderUserId
66
+ }
67
+
17
68
  export function getMessageListParticipantLabel(
18
69
  item: MessageParticipantSource,
19
70
  folder: MessageFolder,
@@ -23,7 +74,5 @@ export function getMessageListParticipantLabel(
23
74
  return t('messages.list.noRecipient', '(No recipient)')
24
75
  }
25
76
 
26
- return normalizeLabel(item.senderName)
27
- ?? normalizeLabel(item.senderEmail)
28
- ?? item.senderUserId
77
+ return getMessageParticipantLabel(item)
29
78
  }
@@ -55,6 +55,7 @@ type ApiDocument = {
55
55
  customerEntityId?: string | null
56
56
  customerSnapshot?: Record<string, unknown> | null
57
57
  channelId?: string | null
58
+ channelName?: string | null
58
59
  lineItemCount?: number | null
59
60
  grandTotalNetAmount?: number | null
60
61
  grandTotalGrossAmount?: number | null
@@ -79,6 +80,7 @@ type SalesDocumentRow = {
79
80
  customerName?: string | null
80
81
  customerEmail?: string | null
81
82
  channelId?: string | null
83
+ channelName?: string | null
82
84
  lineItemCount?: number | null
83
85
  totalNet?: number | null
84
86
  totalGross?: number | null
@@ -89,6 +91,20 @@ type SalesDocumentRow = {
89
91
 
90
92
  const PAGE_SIZE = 20
91
93
 
94
+ // Not a uuid on purpose: the API drops non-uuid entries from channelIds, so a leak of this sentinel
95
+ // into the id list narrows the filter instead of erroring.
96
+ const UNASSIGNED_CHANNEL_VALUE = '__unassigned__'
97
+
98
+ // Perspectives persist filterValues, so snapshots saved while this filter was a single-value select
99
+ // hold a bare string. Without this the multi-select would read it as an unknown shape and silently
100
+ // clear the user's saved filter.
101
+ function normalizeChannelSelection(value: unknown): string[] {
102
+ const raw = Array.isArray(value) ? value : typeof value === 'string' ? [value] : []
103
+ return raw
104
+ .map((entry) => (typeof entry === 'string' ? entry.trim() : String(entry || '').trim()))
105
+ .filter((entry) => entry.length > 0)
106
+ }
107
+
92
108
  function resolveCustomerName(snapshot: CustomerSnapshot | null | undefined, fallback?: string | null) {
93
109
  if (!snapshot) return fallback ?? null
94
110
  const base = snapshot.customer?.displayName ?? null
@@ -178,9 +194,7 @@ export function SalesDocumentsTable({ kind }: { kind: SalesDocumentKind }) {
178
194
  'Review documents with customer context, totals, and channels.'
179
195
  )
180
196
 
181
- const fetchChannelOptions = React.useCallback(async (query?: string): Promise<FilterOption[]> => {
182
- const params = new URLSearchParams({ page: '1', pageSize: '50' })
183
- if (query && query.trim()) params.set('search', query.trim())
197
+ const fetchChannelOptionsBy = React.useCallback(async (params: URLSearchParams): Promise<FilterOption[]> => {
184
198
  try {
185
199
  const call = await apiCall<{ items?: unknown[] }>(`/api/sales/channels?${params.toString()}`)
186
200
  if (!call.ok) return []
@@ -198,6 +212,32 @@ export function SalesDocumentsTable({ kind }: { kind: SalesDocumentKind }) {
198
212
  }
199
213
  }, [])
200
214
 
215
+ const fetchChannelOptions = React.useCallback(
216
+ (query?: string) => {
217
+ const params = new URLSearchParams({ page: '1', pageSize: '50' })
218
+ if (query && query.trim()) params.set('search', query.trim())
219
+ return fetchChannelOptionsBy(params)
220
+ },
221
+ [fetchChannelOptionsBy]
222
+ )
223
+
224
+ // A selected channel outside the first options page — restored from a perspective, or simply the
225
+ // 51st channel — would otherwise render as a bare uuid in the filter chip and the selected tag,
226
+ // which is the same defect this change removes from the column. The route supports `ids=`, so
227
+ // fetch exactly the selected ones and merge them in. See
228
+ // `.ai/lessons/async-edit-selects-must-be-hydrated-as-value-plus.md`.
229
+ const seedChannelOptionsForIds = React.useCallback(
230
+ async (ids: string[]) => {
231
+ const missing = ids.filter((id) => id !== UNASSIGNED_CHANNEL_VALUE)
232
+ if (!missing.length) return
233
+ const params = new URLSearchParams({ page: '1', pageSize: String(missing.length) })
234
+ params.set('ids', missing.join(','))
235
+ const opts = await fetchChannelOptionsBy(params)
236
+ if (opts.length) setChannelOptions((prev) => mergeOptions(prev, opts))
237
+ },
238
+ [fetchChannelOptionsBy]
239
+ )
240
+
201
241
  const fetchTagOptions = React.useCallback(async (query?: string): Promise<FilterOption[]> => {
202
242
  const params = new URLSearchParams({ page: '1', pageSize: '50' })
203
243
  if (query && query.trim()) params.set('search', query.trim())
@@ -278,6 +318,32 @@ export function SalesDocumentsTable({ kind }: { kind: SalesDocumentKind }) {
278
318
  [fetchChannelOptions]
279
319
  )
280
320
 
321
+ const unassignedChannelOption = React.useMemo<FilterOption>(
322
+ () => ({
323
+ value: UNASSIGNED_CHANNEL_VALUE,
324
+ label: t('sales.documents.list.filters.channelUnassigned', '(No channel)'),
325
+ }),
326
+ [t]
327
+ )
328
+
329
+ // The sentinel is offered alongside the real channels so "these channels or unassigned" is one
330
+ // selection. It is stripped back out when the query is built — the API takes it as a separate
331
+ // channelIdsEmpty flag, not as an id.
332
+ const channelFilterOptions = React.useMemo<FilterOption[]>(
333
+ () => [unassignedChannelOption, ...channelOptions],
334
+ [channelOptions, unassignedChannelOption]
335
+ )
336
+
337
+ const loadChannelFilterOptions = React.useCallback(
338
+ async (query?: string) => {
339
+ const opts = await loadChannelOptions(query)
340
+ // Only offer the sentinel when the user is browsing rather than searching — typing "web"
341
+ // should not suggest "(No channel)".
342
+ return query && query.trim() ? opts : [unassignedChannelOption, ...opts]
343
+ },
344
+ [loadChannelOptions, unassignedChannelOption]
345
+ )
346
+
281
347
  const loadTagOptions = React.useCallback(
282
348
  async (query?: string) => {
283
349
  const opts = await fetchTagOptions(query)
@@ -307,9 +373,14 @@ export function SalesDocumentsTable({ kind }: { kind: SalesDocumentKind }) {
307
373
  ...(channelsEnabled ? [{
308
374
  id: 'channelId',
309
375
  label: t('sales.documents.list.filters.channel', 'Channel'),
310
- type: 'select',
311
- options: channelOptions,
312
- loadOptions: loadChannelOptions,
376
+ type: 'tags',
377
+ options: channelFilterOptions,
378
+ loadOptions: loadChannelFilterOptions,
379
+ placeholder: t('sales.documents.list.filters.channelPlaceholder', 'Search channels'),
380
+ formatValue: (val: string) =>
381
+ val === UNASSIGNED_CHANNEL_VALUE
382
+ ? t('sales.documents.list.filters.channelUnassigned', '(No channel)')
383
+ : channelOptions.find((opt) => opt.value === val)?.label ?? val,
313
384
  } satisfies FilterDef] : []),
314
385
  {
315
386
  id: 'date',
@@ -367,7 +438,7 @@ export function SalesDocumentsTable({ kind }: { kind: SalesDocumentKind }) {
367
438
  formatValue: (val: string) => tagOptions.find((o) => o.value === val)?.label ?? val,
368
439
  formatDescription: (val: string) => tagOptions.find((o) => o.value === val)?.description ?? null,
369
440
  },
370
- ], [channelsEnabled, channelOptions, loadChannelOptions, customerOptions, loadCustomerOptions, loadTagOptions, tagOptions, t])
441
+ ], [channelsEnabled, channelFilterOptions, channelOptions, loadChannelFilterOptions, customerOptions, loadCustomerOptions, loadTagOptions, tagOptions, t])
371
442
 
372
443
  const queryParams = React.useMemo(() => {
373
444
  const params = new URLSearchParams()
@@ -379,8 +450,10 @@ export function SalesDocumentsTable({ kind }: { kind: SalesDocumentKind }) {
379
450
  params.set('sortField', sort.id)
380
451
  params.set('sortDir', sort.desc ? 'desc' : 'asc')
381
452
  }
382
- const channelId = typeof filterValues.channelId === 'string' ? filterValues.channelId : ''
383
- if (channelId) params.set('channelId', channelId)
453
+ const channelSelection = normalizeChannelSelection(filterValues.channelId)
454
+ const channelIds = channelSelection.filter((value) => value !== UNASSIGNED_CHANNEL_VALUE)
455
+ if (channelIds.length > 0) params.set('channelIds', channelIds.join(','))
456
+ if (channelSelection.includes(UNASSIGNED_CHANNEL_VALUE)) params.set('channelIdsEmpty', 'true')
384
457
  const customerIds = Array.isArray(filterValues.customerId)
385
458
  ? filterValues.customerId
386
459
  .map((value) => (typeof value === 'string' ? value.trim() : String(value || '').trim()))
@@ -469,6 +542,7 @@ export function SalesDocumentsTable({ kind }: { kind: SalesDocumentKind }) {
469
542
  customerName,
470
543
  customerEmail,
471
544
  channelId: doc.channelId ?? null,
545
+ channelName: doc.channelName ?? null,
472
546
  lineItemCount: doc.lineItemCount ?? null,
473
547
  totalNet,
474
548
  totalGross,
@@ -514,10 +588,22 @@ export function SalesDocumentsTable({ kind }: { kind: SalesDocumentKind }) {
514
588
  void loadDocuments()
515
589
  }, [loadDocuments, reloadToken, scopeVersion])
516
590
 
591
+ // Normalize at the hydration boundary, not only where the query string is built: a perspective
592
+ // saved while this filter was a single-value select restores `channelId` as a bare string, and
593
+ // the `tags` control renders nothing for a non-array value. Storing the normalized shape keeps
594
+ // the control, the active-filter chip and the query builder all reading one shape — otherwise the
595
+ // list is filtered while the field looks empty, and the next channel the user picks replaces the
596
+ // restored one instead of joining it.
517
597
  const handleFiltersApply = React.useCallback((values: FilterValues) => {
518
- setFilterValues(values)
598
+ if (values.channelId === undefined) {
599
+ setFilterValues(values)
600
+ } else {
601
+ const channelSelection = normalizeChannelSelection(values.channelId)
602
+ setFilterValues({ ...values, channelId: channelSelection })
603
+ void seedChannelOptionsForIds(channelSelection)
604
+ }
519
605
  setPage(1)
520
- }, [])
606
+ }, [seedChannelOptionsForIds])
521
607
 
522
608
  const handleFiltersClear = React.useCallback(() => {
523
609
  setFilterValues({})
@@ -625,9 +711,10 @@ export function SalesDocumentsTable({ kind }: { kind: SalesDocumentKind }) {
625
711
  cell: ({ row }) => {
626
712
  const channelId = row.original.channelId
627
713
  if (!channelId) return <span className="text-xs text-muted-foreground">{t('sales.documents.list.table.unassigned', 'Unassigned')}</span>
628
- const channel = channelOptions.find((opt) => opt.value === channelId)
714
+ // Server-resolved, so this no longer depends on the channel being inside the options page.
715
+ // The uuid remains only for a channel the list response could not resolve.
629
716
  return (
630
- <span className="text-sm">{channel?.label ?? channelId}</span>
717
+ <span className="text-sm">{row.original.channelName ?? channelId}</span>
631
718
  )
632
719
  },
633
720
  enableSorting: false,
@@ -665,7 +752,7 @@ export function SalesDocumentsTable({ kind }: { kind: SalesDocumentKind }) {
665
752
  ? <span className="text-xs text-muted-foreground">{new Date(row.original.date).toLocaleString()}</span>
666
753
  : <span className="text-xs text-muted-foreground">—</span>,
667
754
  },
668
- ], [channelsEnabled, channelOptions, kind, statusMap, t])
755
+ ], [channelsEnabled, kind, statusMap, t])
669
756
 
670
757
  const emptyLabel = kind === 'order'
671
758
  ? t('sales.documents.list.table.emptyOrders', 'No orders yet.')
@@ -1056,6 +1056,8 @@
1056
1056
  "sales.documents.items.variantSearch": "Variante suchen",
1057
1057
  "sales.documents.list.errors.load": "Dokumente konnten nicht geladen werden.",
1058
1058
  "sales.documents.list.filters.channel": "Kanal",
1059
+ "sales.documents.list.filters.channelPlaceholder": "Kanäle suchen",
1060
+ "sales.documents.list.filters.channelUnassigned": "(Kein Kanal)",
1059
1061
  "sales.documents.list.filters.customer": "Kunden-ID",
1060
1062
  "sales.documents.list.filters.customerPlaceholder": "Kunden-ID",
1061
1063
  "sales.documents.list.filters.date": "Datum",
@@ -1056,6 +1056,8 @@
1056
1056
  "sales.documents.items.variantSearch": "Search variant",
1057
1057
  "sales.documents.list.errors.load": "Failed to load documents.",
1058
1058
  "sales.documents.list.filters.channel": "Channel",
1059
+ "sales.documents.list.filters.channelPlaceholder": "Search channels",
1060
+ "sales.documents.list.filters.channelUnassigned": "(No channel)",
1059
1061
  "sales.documents.list.filters.customer": "Customer id",
1060
1062
  "sales.documents.list.filters.customerPlaceholder": "Customer id",
1061
1063
  "sales.documents.list.filters.date": "Date",
@@ -1056,6 +1056,8 @@
1056
1056
  "sales.documents.items.variantSearch": "Buscar variante",
1057
1057
  "sales.documents.list.errors.load": "No se pudieron cargar los documentos.",
1058
1058
  "sales.documents.list.filters.channel": "Canal",
1059
+ "sales.documents.list.filters.channelPlaceholder": "Buscar canales",
1060
+ "sales.documents.list.filters.channelUnassigned": "(Sin canal)",
1059
1061
  "sales.documents.list.filters.customer": "ID de cliente",
1060
1062
  "sales.documents.list.filters.customerPlaceholder": "ID de cliente",
1061
1063
  "sales.documents.list.filters.date": "Fecha",
@@ -1056,6 +1056,8 @@
1056
1056
  "sales.documents.items.variantSearch": "옵션(변형) 검색",
1057
1057
  "sales.documents.list.errors.load": "문서를 불러오지 못했습니다.",
1058
1058
  "sales.documents.list.filters.channel": "채널",
1059
+ "sales.documents.list.filters.channelPlaceholder": "채널 검색",
1060
+ "sales.documents.list.filters.channelUnassigned": "(채널 없음)",
1059
1061
  "sales.documents.list.filters.customer": "고객 ID",
1060
1062
  "sales.documents.list.filters.customerPlaceholder": "고객 ID",
1061
1063
  "sales.documents.list.filters.date": "날짜",
@@ -1056,6 +1056,8 @@
1056
1056
  "sales.documents.items.variantSearch": "Szukaj wariantu",
1057
1057
  "sales.documents.list.errors.load": "Nie udało się wczytać dokumentów.",
1058
1058
  "sales.documents.list.filters.channel": "Kanał",
1059
+ "sales.documents.list.filters.channelPlaceholder": "Szukaj kanałów",
1060
+ "sales.documents.list.filters.channelUnassigned": "(Brak kanału)",
1059
1061
  "sales.documents.list.filters.customer": "ID klienta",
1060
1062
  "sales.documents.list.filters.customerPlaceholder": "ID klienta",
1061
1063
  "sales.documents.list.filters.date": "Data",