@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/auth/api/roles/acl/route.js +28 -33
- package/dist/modules/auth/api/roles/acl/route.js.map +2 -2
- package/dist/modules/auth/api/users/acl/route.js +96 -51
- package/dist/modules/auth/api/users/acl/route.js.map +2 -2
- package/dist/modules/auth/commands/acl.js +254 -0
- package/dist/modules/auth/commands/acl.js.map +7 -0
- package/dist/modules/auth/lib/grantChecks.js.map +2 -2
- package/dist/modules/communication_channels/commands/ingest-inbound-message.js +9 -1
- package/dist/modules/communication_channels/commands/ingest-inbound-message.js.map +2 -2
- package/dist/modules/inbox_ops/lib/htmlToPlainText.js +1 -12
- package/dist/modules/inbox_ops/lib/htmlToPlainText.js.map +2 -2
- package/dist/modules/messages/api/[id]/route.js +3 -0
- package/dist/modules/messages/api/[id]/route.js.map +2 -2
- package/dist/modules/messages/api/openapi.js +3 -0
- package/dist/modules/messages/api/openapi.js.map +2 -2
- package/dist/modules/messages/components/MessagesInboxPageClient.js +3 -13
- package/dist/modules/messages/components/MessagesInboxPageClient.js.map +2 -2
- package/dist/modules/messages/components/inboxFilters.js +15 -0
- package/dist/modules/messages/components/inboxFilters.js.map +2 -2
- package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsConversation.js +11 -1
- package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsConversation.js.map +2 -2
- package/dist/modules/messages/components/message-detail/panels/MessageHeader.js +3 -1
- package/dist/modules/messages/components/message-detail/panels/MessageHeader.js.map +2 -2
- package/dist/modules/messages/components/message-detail/panels/thread-panel.js +2 -1
- package/dist/modules/messages/components/message-detail/panels/thread-panel.js.map +2 -2
- package/dist/modules/messages/components/messageListLabels.js +14 -2
- package/dist/modules/messages/components/messageListLabels.js.map +2 -2
- package/dist/modules/sales/components/documents/SalesDocumentsTable.js +64 -14
- package/dist/modules/sales/components/documents/SalesDocumentsTable.js.map +2 -2
- package/package.json +7 -9
- package/src/modules/auth/api/roles/acl/route.ts +42 -39
- package/src/modules/auth/api/users/acl/route.ts +177 -53
- package/src/modules/auth/commands/acl.ts +495 -0
- package/src/modules/auth/i18n/de.json +2 -0
- package/src/modules/auth/i18n/en.json +2 -0
- package/src/modules/auth/i18n/es.json +2 -0
- package/src/modules/auth/i18n/ko.json +2 -0
- package/src/modules/auth/i18n/pl.json +2 -0
- package/src/modules/auth/lib/grantChecks.ts +6 -0
- package/src/modules/communication_channels/commands/ingest-inbound-message.ts +33 -1
- package/src/modules/inbox_ops/lib/htmlToPlainText.ts +1 -17
- package/src/modules/messages/api/[id]/route.ts +3 -0
- package/src/modules/messages/api/openapi.ts +3 -0
- package/src/modules/messages/components/MessagesInboxPageClient.tsx +5 -16
- package/src/modules/messages/components/inboxFilters.ts +36 -0
- package/src/modules/messages/components/message-detail/hooks/useMessageDetailsConversation.ts +14 -1
- package/src/modules/messages/components/message-detail/panels/MessageHeader.tsx +3 -1
- package/src/modules/messages/components/message-detail/panels/thread-panel.tsx +2 -1
- package/src/modules/messages/components/message-detail/types.ts +3 -0
- package/src/modules/messages/components/messageListLabels.ts +52 -3
- package/src/modules/sales/components/documents/SalesDocumentsTable.tsx +101 -14
- package/src/modules/sales/i18n/de.json +2 -0
- package/src/modules/sales/i18n/en.json +2 -0
- package/src/modules/sales/i18n/es.json +2 -0
- package/src/modules/sales/i18n/ko.json +2 -0
- 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
|
-
|
|
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 }[]
|
package/src/modules/messages/components/message-detail/hooks/useMessageDetailsConversation.ts
CHANGED
|
@@ -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
|
|
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')}: ${
|
|
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
|
|
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
|
|
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
|
|
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: '
|
|
311
|
-
options:
|
|
312
|
-
loadOptions:
|
|
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,
|
|
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
|
|
383
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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">{
|
|
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,
|
|
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",
|