@open-mercato/core 0.7.1-develop.7153.1.7145d295e6 → 0.7.1-develop.7170.1.d95074d7ba
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/AGENTS.md +2 -2
- package/dist/modules/auth/commands/users.js +14 -1
- package/dist/modules/auth/commands/users.js.map +2 -2
- package/dist/modules/auth/data/entities.js +2 -2
- package/dist/modules/auth/data/entities.js.map +2 -2
- package/dist/modules/auth/lib/backendChrome.js +26 -3
- package/dist/modules/auth/lib/backendChrome.js.map +2 -2
- package/dist/modules/auth/lib/profile-sections.js +1 -1
- package/dist/modules/auth/lib/profile-sections.js.map +2 -2
- package/dist/modules/auth/migrations/Migration20260907120000_auth.js +19 -0
- package/dist/modules/auth/migrations/Migration20260907120000_auth.js.map +7 -0
- package/dist/modules/communication_channels/backend/profile/communication-channels/page.meta.js +2 -2
- package/dist/modules/communication_channels/backend/profile/communication-channels/page.meta.js.map +1 -1
- package/dist/modules/customer_accounts/api/admin/demo-accounts.js +118 -0
- package/dist/modules/customer_accounts/api/admin/demo-accounts.js.map +7 -0
- package/dist/modules/customer_accounts/api/invitations/accept.js +23 -6
- package/dist/modules/customer_accounts/api/invitations/accept.js.map +2 -2
- package/dist/modules/customer_accounts/backend/customer_accounts/settings/CustomerAccountsSettingsPageClient.js +10 -13
- package/dist/modules/customer_accounts/backend/customer_accounts/settings/CustomerAccountsSettingsPageClient.js.map +2 -2
- package/dist/modules/customer_accounts/backend/customer_accounts/useDemoPortalAccounts.js +28 -0
- package/dist/modules/customer_accounts/backend/customer_accounts/useDemoPortalAccounts.js.map +7 -0
- package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js +6 -1
- package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js.map +2 -2
- package/dist/modules/customer_accounts/data/entities.js +4 -1
- package/dist/modules/customer_accounts/data/entities.js.map +2 -2
- package/dist/modules/customer_accounts/lib/exampleAccounts.js +9 -0
- package/dist/modules/customer_accounts/lib/exampleAccounts.js.map +7 -0
- package/dist/modules/customer_accounts/migrations/Migration20260827160000_customer_accounts.js +16 -0
- package/dist/modules/customer_accounts/migrations/Migration20260827160000_customer_accounts.js.map +7 -0
- package/dist/modules/customer_accounts/services/customerInvitationService.js +53 -6
- package/dist/modules/customer_accounts/services/customerInvitationService.js.map +2 -2
- package/dist/modules/customer_accounts/setup.js +2 -6
- package/dist/modules/customer_accounts/setup.js.map +2 -2
- package/dist/modules/customer_accounts/widgets/injection/account-status/widget.client.js +55 -40
- package/dist/modules/customer_accounts/widgets/injection/account-status/widget.client.js.map +2 -2
- package/dist/modules/customers/commands/people.js +16 -1
- package/dist/modules/customers/commands/people.js.map +2 -2
- package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js +18 -36
- package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js.map +2 -2
- package/dist/modules/data_sync/lib/sync-schedule-service.js +25 -23
- package/dist/modules/data_sync/lib/sync-schedule-service.js.map +2 -2
- package/dist/modules/entities/api/records.js +18 -15
- package/dist/modules/entities/api/records.js.map +3 -3
- package/dist/modules/entities/lib/scoped-field-defs.js +52 -0
- package/dist/modules/entities/lib/scoped-field-defs.js.map +7 -0
- package/dist/modules/entities/lib/validation.js +4 -35
- package/dist/modules/entities/lib/validation.js.map +2 -2
- package/dist/modules/integrations/backend/integrations/[id]/page.js +32 -54
- package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
- package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js +12 -29
- package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js.map +2 -2
- package/dist/modules/notifications/backend/profile/notification-preferences/page.meta.js +2 -2
- package/dist/modules/notifications/backend/profile/notification-preferences/page.meta.js.map +1 -1
- package/dist/modules/progress/lib/progressServiceImpl.js +14 -3
- package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
- package/dist/modules/sales/backend/sales/documents/[id]/page.js +9 -9
- package/dist/modules/sales/backend/sales/documents/[id]/page.js.map +2 -2
- package/dist/modules/sales/components/documents/LineItemDialog.js +50 -10
- package/dist/modules/sales/components/documents/LineItemDialog.js.map +2 -2
- package/dist/modules/sales/components/documents/PaymentsSection.js +8 -3
- package/dist/modules/sales/components/documents/PaymentsSection.js.map +2 -2
- package/dist/modules/sales/components/documents/ReturnEditDialog.js +2 -7
- package/dist/modules/sales/components/documents/ReturnEditDialog.js.map +2 -2
- package/dist/modules/sales/components/documents/ReturnsSection.js +2 -7
- package/dist/modules/sales/components/documents/ReturnsSection.js.map +2 -2
- package/dist/modules/sales/components/documents/ShipmentsSection.js +3 -8
- package/dist/modules/sales/components/documents/ShipmentsSection.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/auth/commands/users.ts +14 -1
- package/src/modules/auth/data/entities.ts +2 -2
- package/src/modules/auth/lib/backendChrome.tsx +46 -2
- package/src/modules/auth/lib/profile-sections.tsx +14 -3
- package/src/modules/auth/migrations/.snapshot-open-mercato.json +4 -2
- package/src/modules/auth/migrations/Migration20260907120000_auth.ts +21 -0
- package/src/modules/communication_channels/backend/profile/communication-channels/page.meta.ts +2 -2
- package/src/modules/customer_accounts/AGENTS.md +3 -1
- package/src/modules/customer_accounts/api/admin/demo-accounts.ts +149 -0
- package/src/modules/customer_accounts/api/invitations/accept.ts +23 -6
- package/src/modules/customer_accounts/backend/customer_accounts/settings/CustomerAccountsSettingsPageClient.tsx +43 -41
- package/src/modules/customer_accounts/backend/customer_accounts/useDemoPortalAccounts.ts +42 -0
- package/src/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.tsx +10 -3
- package/src/modules/customer_accounts/data/entities.ts +5 -1
- package/src/modules/customer_accounts/i18n/de.json +4 -2
- package/src/modules/customer_accounts/i18n/en.json +4 -2
- package/src/modules/customer_accounts/i18n/es.json +4 -2
- package/src/modules/customer_accounts/i18n/ko.json +4 -2
- package/src/modules/customer_accounts/i18n/pl.json +4 -2
- package/src/modules/customer_accounts/lib/exampleAccounts.ts +18 -0
- package/src/modules/customer_accounts/migrations/.snapshot-open-mercato.json +5 -7
- package/src/modules/customer_accounts/migrations/Migration20260827160000_customer_accounts.ts +23 -0
- package/src/modules/customer_accounts/services/customerInvitationService.ts +89 -5
- package/src/modules/customer_accounts/setup.ts +2 -6
- package/src/modules/customer_accounts/widgets/injection/account-status/widget.client.tsx +74 -41
- package/src/modules/customers/commands/people.ts +15 -0
- package/src/modules/data_sync/backend/data-sync/runs/[id]/page.tsx +19 -44
- package/src/modules/data_sync/lib/sync-schedule-service.ts +32 -26
- package/src/modules/entities/api/records.ts +24 -15
- package/src/modules/entities/lib/scoped-field-defs.ts +76 -0
- package/src/modules/entities/lib/validation.ts +5 -45
- package/src/modules/integrations/backend/integrations/[id]/page.tsx +33 -62
- package/src/modules/integrations/backend/integrations/bundle/[id]/page.tsx +13 -37
- package/src/modules/notifications/backend/profile/notification-preferences/page.meta.ts +2 -2
- package/src/modules/progress/AGENTS.md +1 -1
- package/src/modules/progress/lib/progressServiceImpl.ts +26 -3
- package/src/modules/sales/backend/sales/documents/[id]/page.tsx +19 -25
- package/src/modules/sales/components/documents/LineItemDialog.tsx +53 -9
- package/src/modules/sales/components/documents/PaymentsSection.tsx +8 -8
- package/src/modules/sales/components/documents/ReturnEditDialog.tsx +2 -8
- package/src/modules/sales/components/documents/ReturnsSection.tsx +8 -7
- package/src/modules/sales/components/documents/ShipmentsSection.tsx +9 -8
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { EntityManager } from '@mikro-orm/core'
|
|
2
|
-
import { CustomFieldDef } from '../data/entities'
|
|
3
2
|
import { validateValuesAgainstDefs } from '@open-mercato/shared/modules/entities/validation'
|
|
3
|
+
import { loadScopedCustomFieldDefs } from './scoped-field-defs'
|
|
4
4
|
|
|
5
5
|
export async function validateCustomFieldValuesServer(
|
|
6
6
|
em: EntityManager,
|
|
@@ -12,51 +12,11 @@ export async function validateCustomFieldValuesServer(
|
|
|
12
12
|
rejectUndeclaredKeys?: boolean
|
|
13
13
|
},
|
|
14
14
|
): Promise<{ ok: boolean; fieldErrors: Record<string, string> }> {
|
|
15
|
-
const
|
|
16
|
-
const tenantId = opts.tenantId ?? null
|
|
17
|
-
const defs = await em.find(CustomFieldDef, {
|
|
15
|
+
const byKey = await loadScopedCustomFieldDefs(em, {
|
|
18
16
|
entityId: opts.entityId,
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
{
|
|
23
|
-
$or: organizationId === null
|
|
24
|
-
? [{ organizationId: null }]
|
|
25
|
-
: [{ organizationId }, { organizationId: null }],
|
|
26
|
-
},
|
|
27
|
-
{
|
|
28
|
-
$or: tenantId === null
|
|
29
|
-
? [{ tenantId: null }]
|
|
30
|
-
: [{ tenantId }, { tenantId: null }],
|
|
31
|
-
},
|
|
32
|
-
],
|
|
33
|
-
} as any)
|
|
34
|
-
|
|
35
|
-
// Prefer the most specific scope and newest definition for duplicate keys.
|
|
36
|
-
const scopeScore = (def: CustomFieldDef) => (def.tenantId ? 2 : 0) + (def.organizationId ? 1 : 0)
|
|
37
|
-
const byKey = new Map<string, CustomFieldDef>()
|
|
38
|
-
for (const d of defs) {
|
|
39
|
-
const existing = byKey.get(d.key)
|
|
40
|
-
if (!existing) {
|
|
41
|
-
byKey.set(d.key, d)
|
|
42
|
-
continue
|
|
43
|
-
}
|
|
44
|
-
const nextScore = scopeScore(d)
|
|
45
|
-
const existingScore = scopeScore(existing)
|
|
46
|
-
if (nextScore > existingScore) {
|
|
47
|
-
byKey.set(d.key, d)
|
|
48
|
-
continue
|
|
49
|
-
}
|
|
50
|
-
if (nextScore < existingScore) continue
|
|
51
|
-
|
|
52
|
-
const nextUpdatedAt = d.updatedAt instanceof Date ? d.updatedAt.getTime() : new Date(d.updatedAt).getTime()
|
|
53
|
-
const existingUpdatedAt = existing.updatedAt instanceof Date
|
|
54
|
-
? existing.updatedAt.getTime()
|
|
55
|
-
: new Date(existing.updatedAt).getTime()
|
|
56
|
-
if (nextUpdatedAt >= existingUpdatedAt) {
|
|
57
|
-
byKey.set(d.key, d)
|
|
58
|
-
}
|
|
59
|
-
}
|
|
17
|
+
organizationId: opts.organizationId,
|
|
18
|
+
tenantId: opts.tenantId,
|
|
19
|
+
})
|
|
60
20
|
return validateValuesAgainstDefs(opts.values, Array.from(byKey.values()) as any, {
|
|
61
21
|
rejectUndeclaredKeys: opts.rejectUndeclaredKeys === true,
|
|
62
22
|
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import { extensionPoints } from '@open-mercato/core/modules/integrations/extension-points'
|
|
4
|
-
import {
|
|
4
|
+
import { useRouter, useSearchParams } from 'next/navigation'
|
|
5
5
|
import { z } from 'zod'
|
|
6
6
|
import { Page, PageBody } from '@open-mercato/ui/backend/Page'
|
|
7
7
|
import { CrudForm, type CrudField } from '@open-mercato/ui/backend/CrudForm'
|
|
@@ -139,7 +139,7 @@ type LogEntry = {
|
|
|
139
139
|
|
|
140
140
|
type IntegrationDetailPageProps = {
|
|
141
141
|
params?: {
|
|
142
|
-
id?: string
|
|
142
|
+
id?: string
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
145
|
|
|
@@ -289,18 +289,6 @@ const CATEGORY_ICONS: Record<string, React.ElementType> = {
|
|
|
289
289
|
webhook: Webhook,
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
-
function resolveRouteId(value: string | string[] | undefined): string | undefined {
|
|
293
|
-
if (Array.isArray(value)) return value[0]
|
|
294
|
-
return value
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
function resolvePathnameId(pathname: string): string | undefined {
|
|
298
|
-
const parts = pathname.split('/').filter(Boolean)
|
|
299
|
-
const integrationId = parts.at(-1)
|
|
300
|
-
if (!integrationId || integrationId === 'integrations' || integrationId === 'bundle') return undefined
|
|
301
|
-
return decodeURIComponent(integrationId)
|
|
302
|
-
}
|
|
303
|
-
|
|
304
292
|
function buildCredentialFields(
|
|
305
293
|
credFields: CredentialField[],
|
|
306
294
|
secretFieldsConfigured: SecretFieldsConfigured,
|
|
@@ -439,10 +427,9 @@ function splitLogPayload(payload: Record<string, unknown> | null | undefined) {
|
|
|
439
427
|
}
|
|
440
428
|
|
|
441
429
|
export default function IntegrationDetailPage({ params }: IntegrationDetailPageProps) {
|
|
442
|
-
const pathname = usePathname()
|
|
443
430
|
const router = useRouter()
|
|
444
431
|
const searchParams = useSearchParams()
|
|
445
|
-
const integrationId =
|
|
432
|
+
const integrationId = params?.id
|
|
446
433
|
const t = useT()
|
|
447
434
|
|
|
448
435
|
const [detail, setDetail] = React.useState<IntegrationDetail | null>(null)
|
|
@@ -474,18 +461,9 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
|
|
|
474
461
|
canManageCredentials,
|
|
475
462
|
} = useIntegrationCredentialsFeatureAccess()
|
|
476
463
|
|
|
477
|
-
const resolveCurrentIntegrationId = React.useCallback(() => {
|
|
478
|
-
return integrationId ?? (
|
|
479
|
-
typeof window !== 'undefined'
|
|
480
|
-
? resolvePathnameId(window.location.pathname)
|
|
481
|
-
: undefined
|
|
482
|
-
)
|
|
483
|
-
}, [integrationId])
|
|
484
|
-
|
|
485
464
|
const loadDetail = React.useCallback(async (options?: { showLoading?: boolean }) => {
|
|
486
465
|
const showLoading = options?.showLoading ?? true
|
|
487
|
-
|
|
488
|
-
if (!currentIntegrationId) {
|
|
466
|
+
if (!integrationId) {
|
|
489
467
|
if (showLoading) setIsLoading(false)
|
|
490
468
|
if (showLoading) setError(t('integrations.detail.loadError', 'Failed to load integration'))
|
|
491
469
|
return
|
|
@@ -495,7 +473,7 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
|
|
|
495
473
|
if (showLoading) setIsLoading(true)
|
|
496
474
|
try {
|
|
497
475
|
const call = await apiCall<IntegrationDetail>(
|
|
498
|
-
`/api/integrations/${encodeURIComponent(
|
|
476
|
+
`/api/integrations/${encodeURIComponent(integrationId)}`,
|
|
499
477
|
undefined,
|
|
500
478
|
{ fallback: null },
|
|
501
479
|
)
|
|
@@ -515,17 +493,16 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
|
|
|
515
493
|
if (showLoading) setError(t('integrations.detail.loadError', 'Failed to load integration'))
|
|
516
494
|
if (showLoading) setIsLoading(false)
|
|
517
495
|
}
|
|
518
|
-
}, [
|
|
496
|
+
}, [integrationId, t])
|
|
519
497
|
|
|
520
498
|
const loadCredentials = React.useCallback(async () => {
|
|
521
|
-
|
|
522
|
-
if (!currentIntegrationId) return
|
|
499
|
+
if (!integrationId) return
|
|
523
500
|
const call = await apiCall<{
|
|
524
501
|
credentials: Record<string, unknown>
|
|
525
502
|
secretFieldsConfigured?: SecretFieldsConfigured
|
|
526
503
|
updatedAt?: string | null
|
|
527
504
|
}>(
|
|
528
|
-
`/api/integrations/${encodeURIComponent(
|
|
505
|
+
`/api/integrations/${encodeURIComponent(integrationId)}/credentials`,
|
|
529
506
|
{ headers: credentialsRequestHeaders },
|
|
530
507
|
{ fallback: null },
|
|
531
508
|
)
|
|
@@ -535,7 +512,7 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
|
|
|
535
512
|
}
|
|
536
513
|
if (call.ok && call.result?.credentials) {
|
|
537
514
|
const next = { ...call.result.credentials }
|
|
538
|
-
if (
|
|
515
|
+
if (integrationId === 'storage_s3') {
|
|
539
516
|
const authMode = next.authMode
|
|
540
517
|
if (authMode !== 'access_keys' && authMode !== 'ambient') {
|
|
541
518
|
const hasKeys = Boolean(next.accessKeyId || next.secretAccessKey)
|
|
@@ -545,13 +522,12 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
|
|
|
545
522
|
setCredValues(next)
|
|
546
523
|
setCredentialsFormKey((current) => current + 1)
|
|
547
524
|
}
|
|
548
|
-
}, [
|
|
525
|
+
}, [integrationId])
|
|
549
526
|
|
|
550
527
|
const loadLogs = React.useCallback(async () => {
|
|
551
|
-
|
|
552
|
-
if (!currentIntegrationId) return
|
|
528
|
+
if (!integrationId) return
|
|
553
529
|
setIsLoadingLogs(true)
|
|
554
|
-
const params = new URLSearchParams({ integrationId
|
|
530
|
+
const params = new URLSearchParams({ integrationId, pageSize: '50' })
|
|
555
531
|
if (logLevel) params.set('level', logLevel)
|
|
556
532
|
const call = await apiCall<{ items: LogEntry[] }>(
|
|
557
533
|
`/api/integrations/logs?${params.toString()}`,
|
|
@@ -562,7 +538,7 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
|
|
|
562
538
|
setLogs(call.result.items)
|
|
563
539
|
}
|
|
564
540
|
setIsLoadingLogs(false)
|
|
565
|
-
}, [logLevel,
|
|
541
|
+
}, [logLevel, integrationId])
|
|
566
542
|
|
|
567
543
|
const detailWidgetSpotId = React.useMemo(
|
|
568
544
|
() => resolveIntegrationDetailWidgetSpotId(detail?.integration ?? null, extensionPoints.hosts.legacyDetailTabs.spotId),
|
|
@@ -706,16 +682,15 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
|
|
|
706
682
|
React.useEffect(() => { void loadLogs() }, [loadLogs])
|
|
707
683
|
|
|
708
684
|
const handleToggleState = React.useCallback(async (enabled: boolean) => {
|
|
709
|
-
|
|
710
|
-
if (!currentIntegrationId) return
|
|
685
|
+
if (!integrationId) return
|
|
711
686
|
setIsTogglingState(true)
|
|
712
687
|
try {
|
|
713
688
|
const call = await runMutationWithContext({
|
|
714
689
|
actionId: 'toggle-state',
|
|
715
|
-
mutationPayload: { integrationId
|
|
690
|
+
mutationPayload: { integrationId, isEnabled: enabled },
|
|
716
691
|
operation: () => withScopedApiRequestHeaders(
|
|
717
692
|
buildOptimisticLockHeader(detail?.state.updatedAt),
|
|
718
|
-
() => apiCall(`/api/integrations/${encodeURIComponent(
|
|
693
|
+
() => apiCall(`/api/integrations/${encodeURIComponent(integrationId)}/state`, {
|
|
719
694
|
method: 'PUT',
|
|
720
695
|
headers: { 'Content-Type': 'application/json' },
|
|
721
696
|
body: JSON.stringify({ isEnabled: enabled }),
|
|
@@ -741,11 +716,10 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
|
|
|
741
716
|
} finally {
|
|
742
717
|
setIsTogglingState(false)
|
|
743
718
|
}
|
|
744
|
-
}, [detail?.state.updatedAt, refreshDetail,
|
|
719
|
+
}, [detail?.state.updatedAt, refreshDetail, integrationId, runMutationWithContext, t])
|
|
745
720
|
|
|
746
721
|
const handleSaveCredentials = React.useCallback(async (values: Record<string, unknown>) => {
|
|
747
|
-
|
|
748
|
-
if (!currentIntegrationId) return
|
|
722
|
+
if (!integrationId) return
|
|
749
723
|
setIsSavingCredentials(true)
|
|
750
724
|
try {
|
|
751
725
|
const credentialFields = (
|
|
@@ -754,7 +728,7 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
|
|
|
754
728
|
?? []
|
|
755
729
|
)
|
|
756
730
|
const savePayload = buildIntegrationCredentialSavePayload(
|
|
757
|
-
|
|
731
|
+
integrationId,
|
|
758
732
|
values,
|
|
759
733
|
credentialFields,
|
|
760
734
|
secretFieldsConfigured,
|
|
@@ -762,10 +736,10 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
|
|
|
762
736
|
const call = await runMutationWithContext({
|
|
763
737
|
actionId: 'save-credentials',
|
|
764
738
|
tabId: 'credentials',
|
|
765
|
-
mutationPayload: { integrationId
|
|
739
|
+
mutationPayload: { integrationId, ...savePayload },
|
|
766
740
|
operation: () => withScopedApiRequestHeaders(
|
|
767
741
|
buildOptimisticLockHeader(credentialsUpdatedAt),
|
|
768
|
-
() => apiCall(`/api/integrations/${encodeURIComponent(
|
|
742
|
+
() => apiCall(`/api/integrations/${encodeURIComponent(integrationId)}/credentials`, {
|
|
769
743
|
method: 'PUT',
|
|
770
744
|
headers: { 'Content-Type': 'application/json' },
|
|
771
745
|
body: JSON.stringify(savePayload),
|
|
@@ -791,19 +765,18 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
|
|
|
791
765
|
} finally {
|
|
792
766
|
setIsSavingCredentials(false)
|
|
793
767
|
}
|
|
794
|
-
}, [credentialsUpdatedAt, detail, loadCredentials,
|
|
768
|
+
}, [credentialsUpdatedAt, detail, loadCredentials, integrationId, runMutationWithContext, secretFieldsConfigured, t])
|
|
795
769
|
|
|
796
770
|
const handleVersionChange = React.useCallback(async (version: string) => {
|
|
797
|
-
|
|
798
|
-
if (!currentIntegrationId) return
|
|
771
|
+
if (!integrationId) return
|
|
799
772
|
try {
|
|
800
773
|
const call = await runMutationWithContext({
|
|
801
774
|
actionId: 'change-version',
|
|
802
775
|
tabId: 'version',
|
|
803
|
-
mutationPayload: { integrationId
|
|
776
|
+
mutationPayload: { integrationId, apiVersion: version },
|
|
804
777
|
operation: () => withScopedApiRequestHeaders(
|
|
805
778
|
buildOptimisticLockHeader(detail?.state.updatedAt),
|
|
806
|
-
() => apiCall(`/api/integrations/${encodeURIComponent(
|
|
779
|
+
() => apiCall(`/api/integrations/${encodeURIComponent(integrationId)}/version`, {
|
|
807
780
|
method: 'PUT',
|
|
808
781
|
headers: { 'Content-Type': 'application/json' },
|
|
809
782
|
body: JSON.stringify({ apiVersion: version }),
|
|
@@ -820,19 +793,18 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
|
|
|
820
793
|
} catch {
|
|
821
794
|
flash(t('integrations.detail.version.saveError'), 'error')
|
|
822
795
|
}
|
|
823
|
-
}, [detail?.state.updatedAt, refreshDetail,
|
|
796
|
+
}, [detail?.state.updatedAt, refreshDetail, integrationId, runMutationWithContext, t])
|
|
824
797
|
|
|
825
798
|
const handleHealthCheck = React.useCallback(async () => {
|
|
826
|
-
|
|
827
|
-
if (!currentIntegrationId) return
|
|
799
|
+
if (!integrationId) return
|
|
828
800
|
setIsCheckingHealth(true)
|
|
829
801
|
try {
|
|
830
802
|
const call = await runMutationWithContext({
|
|
831
803
|
actionId: 'run-health-check',
|
|
832
804
|
tabId: 'health',
|
|
833
|
-
mutationPayload: { integrationId
|
|
805
|
+
mutationPayload: { integrationId },
|
|
834
806
|
operation: () => apiCall<HealthCheckResponse>(
|
|
835
|
-
`/api/integrations/${encodeURIComponent(
|
|
807
|
+
`/api/integrations/${encodeURIComponent(integrationId)}/health`,
|
|
836
808
|
{ method: 'POST' },
|
|
837
809
|
{ fallback: null },
|
|
838
810
|
),
|
|
@@ -859,7 +831,7 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
|
|
|
859
831
|
} finally {
|
|
860
832
|
setIsCheckingHealth(false)
|
|
861
833
|
}
|
|
862
|
-
}, [refreshLogs,
|
|
834
|
+
}, [refreshLogs, integrationId, runMutationWithContext, t])
|
|
863
835
|
|
|
864
836
|
const hasVersions = Boolean(detail?.integration.apiVersions?.length)
|
|
865
837
|
const integration = detail?.integration ?? null
|
|
@@ -1052,17 +1024,16 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
|
|
|
1052
1024
|
}, [searchParams, visibleTabIds])
|
|
1053
1025
|
|
|
1054
1026
|
const handleTabChange = React.useCallback((nextValue: string) => {
|
|
1055
|
-
const currentIntegrationId = resolveCurrentIntegrationId()
|
|
1056
1027
|
const nextTab = resolveRequestedIntegrationDetailTab(nextValue, visibleTabIds)
|
|
1057
1028
|
setActiveTab(nextTab)
|
|
1058
|
-
if (!
|
|
1059
|
-
const basePath = `/backend/integrations/${encodeURIComponent(
|
|
1029
|
+
if (!integrationId) return
|
|
1030
|
+
const basePath = `/backend/integrations/${encodeURIComponent(integrationId)}`
|
|
1060
1031
|
const params = new URLSearchParams(searchParams?.toString() ?? '')
|
|
1061
1032
|
if (nextTab === 'credentials') params.delete('tab')
|
|
1062
1033
|
else params.set('tab', nextTab)
|
|
1063
1034
|
const query = params.toString()
|
|
1064
1035
|
router.replace(query ? `${basePath}?${query}` : basePath)
|
|
1065
|
-
}, [
|
|
1036
|
+
}, [integrationId, router, searchParams, visibleTabIds])
|
|
1066
1037
|
|
|
1067
1038
|
React.useEffect(() => {
|
|
1068
1039
|
if (!runIdFromUrl) {
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import Link from 'next/link'
|
|
4
|
-
import { usePathname } from 'next/navigation'
|
|
5
4
|
import { Page, PageBody } from '@open-mercato/ui/backend/Page'
|
|
6
5
|
import { Card, CardHeader, CardTitle, CardContent } from '@open-mercato/ui/primitives/card'
|
|
7
6
|
import { Badge } from '@open-mercato/ui/primitives/badge'
|
|
@@ -68,25 +67,12 @@ type BundleDetail = {
|
|
|
68
67
|
|
|
69
68
|
type BundleConfigPageProps = {
|
|
70
69
|
params?: {
|
|
71
|
-
id?: string
|
|
70
|
+
id?: string
|
|
72
71
|
}
|
|
73
72
|
}
|
|
74
73
|
|
|
75
|
-
function resolveRouteId(value: string | string[] | undefined): string | undefined {
|
|
76
|
-
if (Array.isArray(value)) return value[0]
|
|
77
|
-
return value
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function resolvePathnameId(pathname: string): string | undefined {
|
|
81
|
-
const parts = pathname.split('/').filter(Boolean)
|
|
82
|
-
const bundleId = parts.at(-1)
|
|
83
|
-
if (!bundleId || bundleId === 'bundle' || bundleId === 'integrations') return undefined
|
|
84
|
-
return decodeURIComponent(bundleId)
|
|
85
|
-
}
|
|
86
|
-
|
|
87
74
|
export default function BundleConfigPage({ params }: BundleConfigPageProps) {
|
|
88
|
-
const
|
|
89
|
-
const bundleId = resolveRouteId(params?.id) ?? resolvePathnameId(pathname)
|
|
75
|
+
const bundleId = params?.id
|
|
90
76
|
const t = useT()
|
|
91
77
|
|
|
92
78
|
const [detail, setDetail] = React.useState<BundleDetail | null>(null)
|
|
@@ -107,17 +93,8 @@ export default function BundleConfigPage({ params }: BundleConfigPageProps) {
|
|
|
107
93
|
contextId: mutationContextId,
|
|
108
94
|
})
|
|
109
95
|
|
|
110
|
-
const resolveCurrentBundleId = React.useCallback(() => {
|
|
111
|
-
return bundleId ?? (
|
|
112
|
-
typeof window !== 'undefined'
|
|
113
|
-
? resolvePathnameId(window.location.pathname)
|
|
114
|
-
: undefined
|
|
115
|
-
)
|
|
116
|
-
}, [bundleId])
|
|
117
|
-
|
|
118
96
|
const load = React.useCallback(async () => {
|
|
119
|
-
|
|
120
|
-
if (!currentBundleId) {
|
|
97
|
+
if (!bundleId) {
|
|
121
98
|
setError(t('integrations.detail.loadError'))
|
|
122
99
|
setIsLoading(false)
|
|
123
100
|
return
|
|
@@ -126,7 +103,7 @@ export default function BundleConfigPage({ params }: BundleConfigPageProps) {
|
|
|
126
103
|
setError(null)
|
|
127
104
|
setIsNotFound(false)
|
|
128
105
|
const call = await apiCall<BundleDetail>(
|
|
129
|
-
`/api/integrations/${encodeURIComponent(
|
|
106
|
+
`/api/integrations/${encodeURIComponent(bundleId)}`,
|
|
130
107
|
undefined,
|
|
131
108
|
{ fallback: null },
|
|
132
109
|
)
|
|
@@ -146,7 +123,7 @@ export default function BundleConfigPage({ params }: BundleConfigPageProps) {
|
|
|
146
123
|
secretFieldsConfigured?: SecretFieldsConfigured
|
|
147
124
|
updatedAt?: string | null
|
|
148
125
|
}>(
|
|
149
|
-
`/api/integrations/${encodeURIComponent(
|
|
126
|
+
`/api/integrations/${encodeURIComponent(bundleId)}/credentials`,
|
|
150
127
|
undefined,
|
|
151
128
|
{ fallback: null },
|
|
152
129
|
)
|
|
@@ -156,7 +133,7 @@ export default function BundleConfigPage({ params }: BundleConfigPageProps) {
|
|
|
156
133
|
}
|
|
157
134
|
if (credCall.ok && credCall.result?.credentials) {
|
|
158
135
|
const next = { ...credCall.result.credentials }
|
|
159
|
-
if (
|
|
136
|
+
if (bundleId === 'storage_s3') {
|
|
160
137
|
const authMode = next.authMode
|
|
161
138
|
if (authMode !== 'access_keys' && authMode !== 'ambient') {
|
|
162
139
|
const hasKeys = Boolean(next.accessKeyId || next.secretAccessKey)
|
|
@@ -169,13 +146,12 @@ export default function BundleConfigPage({ params }: BundleConfigPageProps) {
|
|
|
169
146
|
))
|
|
170
147
|
}
|
|
171
148
|
setIsLoading(false)
|
|
172
|
-
}, [
|
|
149
|
+
}, [bundleId, t])
|
|
173
150
|
|
|
174
151
|
React.useEffect(() => { void load() }, [load])
|
|
175
152
|
|
|
176
153
|
const handleSaveCredentials = React.useCallback(async () => {
|
|
177
|
-
|
|
178
|
-
if (!currentBundleId) return
|
|
154
|
+
if (!bundleId) return
|
|
179
155
|
setIsSavingCreds(true)
|
|
180
156
|
try {
|
|
181
157
|
const savePayload = buildCredentialSavePayload(
|
|
@@ -184,19 +160,19 @@ export default function BundleConfigPage({ params }: BundleConfigPageProps) {
|
|
|
184
160
|
secretFieldsConfigured,
|
|
185
161
|
)
|
|
186
162
|
const call = await runMutation({
|
|
187
|
-
mutationPayload: { bundleId
|
|
163
|
+
mutationPayload: { bundleId, ...savePayload },
|
|
188
164
|
context: {
|
|
189
165
|
formId: mutationContextId,
|
|
190
166
|
operation: 'update',
|
|
191
167
|
actionId: 'save-credentials',
|
|
192
168
|
resourceKind: 'integrations.bundle',
|
|
193
|
-
resourceId:
|
|
194
|
-
bundleId
|
|
169
|
+
resourceId: bundleId,
|
|
170
|
+
bundleId,
|
|
195
171
|
retryLastMutation,
|
|
196
172
|
},
|
|
197
173
|
operation: () => withScopedApiRequestHeaders(
|
|
198
174
|
buildOptimisticLockHeader(credentialsUpdatedAt),
|
|
199
|
-
() => apiCall(`/api/integrations/${encodeURIComponent(
|
|
175
|
+
() => apiCall(`/api/integrations/${encodeURIComponent(bundleId)}/credentials`, {
|
|
200
176
|
method: 'PUT',
|
|
201
177
|
headers: { 'Content-Type': 'application/json' },
|
|
202
178
|
body: JSON.stringify(savePayload),
|
|
@@ -214,7 +190,7 @@ export default function BundleConfigPage({ params }: BundleConfigPageProps) {
|
|
|
214
190
|
} finally {
|
|
215
191
|
setIsSavingCreds(false)
|
|
216
192
|
}
|
|
217
|
-
}, [
|
|
193
|
+
}, [bundleId, runMutation, mutationContextId, retryLastMutation, credValues, credentialsUpdatedAt, detail?.bundle?.credentials?.fields, load, secretFieldsConfigured, t])
|
|
218
194
|
|
|
219
195
|
const handleToggle = React.useCallback(async (integrationId: string, enabled: boolean, updatedAt?: string | null) => {
|
|
220
196
|
setTogglingIds((prev) => new Set(prev).add(integrationId))
|
|
@@ -19,8 +19,8 @@ export const metadata = {
|
|
|
19
19
|
requireFeatures: ['notifications.manage_preferences'],
|
|
20
20
|
pageTitle: 'Notification Preferences',
|
|
21
21
|
pageTitleKey: 'notifications.preferences.pageTitle',
|
|
22
|
-
pageGroup: '
|
|
23
|
-
pageGroupKey: '
|
|
22
|
+
pageGroup: 'Account',
|
|
23
|
+
pageGroupKey: 'profile.sections.account',
|
|
24
24
|
pageOrder: 30,
|
|
25
25
|
icon: slidersIcon,
|
|
26
26
|
pageContext: 'profile' as const,
|
|
@@ -93,7 +93,7 @@ Use stable, grep-friendly ids:
|
|
|
93
93
|
`progressService` is safe to run across many app/worker instances. Do not reintroduce read-modify-write transitions:
|
|
94
94
|
|
|
95
95
|
- **Every status transition is a status-guarded `nativeUpdate` (CAS).** An update that matches zero rows lost the race — it MUST NOT emit events or overwrite the row. Allowed transitions: start from `pending|failed` (an already-running start is an idempotent no-op; `failed` allows queue retries and recovery from a wrong stale sweep), complete from `pending|running|failed`, fail from `pending|running`, cancel from `pending|running|failed`.
|
|
96
|
-
- **Progress writes are guarded on `status IN ('pending','running')`** — once another process finishes/cancels a job, buffered updaters stop writing to it. Exception: when a write path finds the job `failed`, it attempts exactly one revival through the start CAS (a live producer writing progress proves a stale sweep false-positived) before giving up. That revive CAS is narrowed to rows whose `errorMessage` starts with `STALE_SWEEP_ERROR_PREFIX`, so a job that failed for a real reason keeps its message and stack instead of being resurrected by a racing buffered writer; `completed`/`cancelled` are never revived, and genuinely dead producers never write again, so real sweeps stick. Only `startJob` uses the unrestricted CAS, because a queue retry restarts the whole unit of work. A revive never resets `startedAt` — progress counts are absolute across deliveries, so the elapsed window `calculateEta` divides them by must be too.
|
|
96
|
+
- **Progress writes are guarded on `status IN ('pending','running')`** — once another process finishes/cancels a job, buffered updaters stop writing to it. Exception: when a write path finds the job `failed`, it attempts exactly one revival through the start CAS (a live producer writing progress proves a stale sweep false-positived) before giving up. That revive CAS is narrowed to rows whose `errorMessage` starts with `STALE_SWEEP_ERROR_PREFIX`, so a job that failed for a real reason keeps its message and stack instead of being resurrected by a racing buffered writer; `completed`/`cancelled` are never revived, and genuinely dead producers never write again, so real sweeps stick. Only `startJob` uses the unrestricted CAS, because a queue retry restarts the whole unit of work. A revive never resets `startedAt` — progress counts are absolute across deliveries, so the elapsed window `calculateEta` divides them by must be too. A **zero-row revive CAS is not automatically the end of the write**: when the cached status is `failed` and the CAS misses, `incrementProgress` re-reads the row once, and adopts it as the throttle entry's snapshot if it came back writable — the miss usually means another writer (typically `touchJobHeartbeat`'s forked revive) already flipped the row to `running`, and a relative increment dropped there is unrecoverable.
|
|
97
97
|
- **Long single units of work MUST call `touchJobHeartbeat`** — when one batch/step can outlast `STALE_JOB_TIMEOUT_SECONDS` (60s), run a keepalive that calls the optional `touchJobHeartbeat(jobId, ctx)` while awaiting it (see `withHeartbeat` in `data_sync/lib/sync-engine.ts`). It writes only `heartbeat_at`/`updated_at` on a **forked** EntityManager, so it is safe while the producer's own EM is mid-transaction, and it self-heals a falsely-swept job via the start CAS. Always optional-chain it (`progressService.touchJobHeartbeat?.(...)`) — third-party implementations may not provide it. The "no timers for durable work" rule below bans replacing queue delivery with timers, not an in-process keepalive around a pending await.
|
|
98
98
|
- **`incrementProgress` deltas persist as atomic SQL increments** (`processed_count + n`), and the service reloads the database winner before returning or emitting, so concurrent writers never lose or report stale counts.
|
|
99
99
|
- **Update-path reads use `disableIdentityMap: true`** — `isCancellationRequested` and lifecycle reads must always see fresh cross-process state, never a stale managed entity.
|
|
@@ -363,10 +363,33 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
|
|
|
363
363
|
|
|
364
364
|
async incrementProgress(jobId, delta, ctx) {
|
|
365
365
|
const entry = await ensureThrottleEntry(jobId, ctx)
|
|
366
|
-
|
|
366
|
+
let job = entry.job
|
|
367
367
|
if (TERMINAL_STATUSES.includes(job.status)) {
|
|
368
|
-
|
|
369
|
-
if (
|
|
368
|
+
let writable = false
|
|
369
|
+
if (job.status === 'failed') {
|
|
370
|
+
writable = (await startJobViaCas(em, job, ctx, { staleSweptOnly: true })) != null
|
|
371
|
+
if (!writable) {
|
|
372
|
+
// The cached snapshot's status lags the row whenever another writer — typically
|
|
373
|
+
// the forked heartbeat in touchJobHeartbeat — already revived this job: the start
|
|
374
|
+
// CAS then misses because the row is `running`, not `failed`. Increments are
|
|
375
|
+
// relative, so returning here would lose the caller's delta for good (unlike
|
|
376
|
+
// updateProgress, whose next absolute write self-corrects). Unlike
|
|
377
|
+
// persistAndMaybeBroadcast, the re-read happens after the CAS rather than before
|
|
378
|
+
// it: startJobViaCas filters on database state, so retrying it against the fresh
|
|
379
|
+
// row could not change whether it matches.
|
|
380
|
+
const fresh = await loadFreshJob(jobId, ctx)
|
|
381
|
+
if (fresh) {
|
|
382
|
+
job = fresh
|
|
383
|
+
writable = PROGRESS_WRITABLE_STATUSES.includes(fresh.status)
|
|
384
|
+
// Adopting the fresh row cannot desync entry.pendingDelta, which is provably 0
|
|
385
|
+
// here: this guard is only reachable on a cached terminal status, and the only
|
|
386
|
+
// writers that cache one — ensureThrottleEntry's initial load and
|
|
387
|
+
// persistAndMaybeBroadcast's post-persist assignment — both leave the buffer empty.
|
|
388
|
+
if (writable) entry.job = fresh
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (!writable) {
|
|
370
393
|
forgetJobThrottle(jobId)
|
|
371
394
|
return job
|
|
372
395
|
}
|
|
@@ -24,6 +24,7 @@ import { Badge } from '@open-mercato/ui/primitives/badge'
|
|
|
24
24
|
import { Spinner } from '@open-mercato/ui/primitives/spinner'
|
|
25
25
|
import { Input } from '@open-mercato/ui/primitives/input'
|
|
26
26
|
import { EmailInput } from '@open-mercato/ui/primitives/email-input'
|
|
27
|
+
import { formatDisplayDate, formatDisplayDateTime, toDateInputValue, toUtcDateInputValue } from '@open-mercato/ui/primitives/date-format'
|
|
27
28
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@open-mercato/ui/primitives/dialog'
|
|
28
29
|
import { ArrowRightLeft, Building2, CreditCard, Mail, Pencil, Plus, Send, Store, Truck, UserRound, Wand2, X } from 'lucide-react'
|
|
29
30
|
import { FormHeader, type ActionItem } from '@open-mercato/ui/backend/forms'
|
|
@@ -3864,17 +3865,16 @@ export default function SalesDocumentDetailPage({
|
|
|
3864
3865
|
label: t('sales.documents.detail.expectedDeliveryAt.label', 'Expected delivery'),
|
|
3865
3866
|
emptyLabel: t('sales.documents.detail.empty', 'Not set'),
|
|
3866
3867
|
placeholder: t('sales.documents.detail.expectedDeliveryAt.placeholder', 'Add expected delivery date'),
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3868
|
+
// UTC, not local: this editor submits a bare `yyyy-MM-dd`, which `z.coerce.date()`
|
|
3869
|
+
// stores as UTC midnight. Reading it back locally names the previous day west of UTC.
|
|
3870
|
+
value: toUtcDateInputValue(record?.expectedDeliveryAt),
|
|
3870
3871
|
onSave: handleUpdateExpectedDeliveryAt,
|
|
3871
3872
|
inputType: 'date',
|
|
3872
3873
|
renderDisplay: (params) => {
|
|
3873
3874
|
const { value, emptyLabel } = params
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
return <span className="text-sm text-muted-foreground">{emptyLabel}</span>
|
|
3875
|
+
return (
|
|
3876
|
+
<span className="text-sm text-muted-foreground">{formatDisplayDate(value, locale) ?? emptyLabel}</span>
|
|
3877
|
+
)
|
|
3878
3878
|
},
|
|
3879
3879
|
})
|
|
3880
3880
|
}
|
|
@@ -3915,10 +3915,10 @@ export default function SalesDocumentDetailPage({
|
|
|
3915
3915
|
render: () => (
|
|
3916
3916
|
<SectionCard title={t('sales.documents.detail.timestamps', 'Timestamps')} muted>
|
|
3917
3917
|
<p className="text-sm text-muted-foreground">
|
|
3918
|
-
{t('sales.documents.detail.created', 'Created')}: {record?.createdAt ?? '—'}
|
|
3918
|
+
{t('sales.documents.detail.created', 'Created')}: {formatDisplayDateTime(record?.createdAt, locale) ?? '—'}
|
|
3919
3919
|
</p>
|
|
3920
3920
|
<p className="text-sm text-muted-foreground">
|
|
3921
|
-
{t('sales.documents.detail.updated', 'Updated')}: {record?.updatedAt ?? '—'}
|
|
3921
|
+
{t('sales.documents.detail.updated', 'Updated')}: {formatDisplayDateTime(record?.updatedAt, locale) ?? '—'}
|
|
3922
3922
|
</p>
|
|
3923
3923
|
</SectionCard>
|
|
3924
3924
|
),
|
|
@@ -4707,28 +4707,22 @@ export default function SalesDocumentDetailPage({
|
|
|
4707
4707
|
<InlineTextEditor
|
|
4708
4708
|
key="date"
|
|
4709
4709
|
label={t('sales.documents.detail.date', 'Date')}
|
|
4710
|
-
|
|
4711
|
-
|
|
4712
|
-
|
|
4713
|
-
|
|
4714
|
-
|
|
4715
|
-
: null
|
|
4716
|
-
}
|
|
4710
|
+
// Two readings on purpose. `placedAt` is settable from this date input, so it is
|
|
4711
|
+
// stored as UTC midnight and must be read back in UTC or the field flips a day west
|
|
4712
|
+
// of UTC. `createdAt` is only ever stamped from a clock — a real instant, whose local
|
|
4713
|
+
// day is the one to show.
|
|
4714
|
+
value={toUtcDateInputValue(record?.placedAt) ?? toDateInputValue(record?.createdAt)}
|
|
4717
4715
|
emptyLabel={t('sales.documents.detail.empty', 'Not set')}
|
|
4718
4716
|
onSave={handleUpdatePlacedAt}
|
|
4719
4717
|
inputType="date"
|
|
4720
4718
|
activateOnClick
|
|
4721
4719
|
containerClassName="h-full"
|
|
4722
4720
|
saveLabel={t('customers.people.detail.inline.saveShortcut')}
|
|
4723
|
-
renderDisplay={({ value, emptyLabel }) =>
|
|
4724
|
-
|
|
4725
|
-
|
|
4726
|
-
|
|
4727
|
-
|
|
4728
|
-
) : (
|
|
4729
|
-
<span className="text-sm text-muted-foreground">{emptyLabel}</span>
|
|
4730
|
-
)
|
|
4731
|
-
}
|
|
4721
|
+
renderDisplay={({ value, emptyLabel }) => (
|
|
4722
|
+
<span className="text-sm text-muted-foreground">
|
|
4723
|
+
{formatDisplayDate(value, locale) ?? emptyLabel}
|
|
4724
|
+
</span>
|
|
4725
|
+
)}
|
|
4732
4726
|
/>
|
|
4733
4727
|
</div>
|
|
4734
4728
|
|