@open-mercato/core 0.10.1-develop.7836.1.0ef0f453c6 → 0.10.1-develop.7847.1.7d83dc5794

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 (136) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/api_keys/api/keys/route.js +15 -1
  3. package/dist/modules/api_keys/api/keys/route.js.map +2 -2
  4. package/dist/modules/auth/api/users/route.js +16 -1
  5. package/dist/modules/auth/api/users/route.js.map +2 -2
  6. package/dist/modules/auth/backend/users/page.js +13 -2
  7. package/dist/modules/auth/backend/users/page.js.map +2 -2
  8. package/dist/modules/catalog/commands/products.js +4 -2
  9. package/dist/modules/catalog/commands/products.js.map +2 -2
  10. package/dist/modules/customer_accounts/api/admin/roles.js +25 -2
  11. package/dist/modules/customer_accounts/api/admin/roles.js.map +2 -2
  12. package/dist/modules/customer_accounts/api/admin/users.js +24 -7
  13. package/dist/modules/customer_accounts/api/admin/users.js.map +2 -2
  14. package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js +77 -15
  15. package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js.map +2 -2
  16. package/dist/modules/customer_accounts/backend/customer_accounts/users/page.js +12 -1
  17. package/dist/modules/customer_accounts/backend/customer_accounts/users/page.js.map +2 -2
  18. package/dist/modules/customer_accounts/data/validators.js +2 -1
  19. package/dist/modules/customer_accounts/data/validators.js.map +2 -2
  20. package/dist/modules/customer_accounts/lib/adminOrganizationScope.js +46 -0
  21. package/dist/modules/customer_accounts/lib/adminOrganizationScope.js.map +7 -0
  22. package/dist/modules/customers/api/customer-dictionary-entries/route.js +86 -0
  23. package/dist/modules/customers/api/customer-dictionary-entries/route.js.map +7 -0
  24. package/dist/modules/customers/api/dictionaries/[kind]/route.js +29 -7
  25. package/dist/modules/customers/api/dictionaries/[kind]/route.js.map +2 -2
  26. package/dist/modules/customers/cli.js +28 -110
  27. package/dist/modules/customers/cli.js.map +3 -3
  28. package/dist/modules/customers/components/DictionarySettings.js +1 -1
  29. package/dist/modules/customers/components/DictionarySettings.js.map +1 -1
  30. package/dist/modules/customers/components/detail/ManageTagsDialog.js +1 -1
  31. package/dist/modules/customers/components/detail/ManageTagsDialog.js.map +2 -2
  32. package/dist/modules/customers/components/detail/TaskDialog.js +14 -1
  33. package/dist/modules/customers/components/detail/TaskDialog.js.map +2 -2
  34. package/dist/modules/customers/components/detail/TaskForm.js +1 -16
  35. package/dist/modules/customers/components/detail/TaskForm.js.map +2 -2
  36. package/dist/modules/customers/components/detail/TasksSection.js +38 -10
  37. package/dist/modules/customers/components/detail/TasksSection.js.map +2 -2
  38. package/dist/modules/customers/components/detail/hooks/useCustomerDictionary.js +12 -5
  39. package/dist/modules/customers/components/detail/hooks/useCustomerDictionary.js.map +2 -2
  40. package/dist/modules/customers/components/detail/hooks/useInteractions.js +3 -13
  41. package/dist/modules/customers/components/detail/hooks/useInteractions.js.map +2 -2
  42. package/dist/modules/customers/lib/dictionaryDefaults.js +134 -0
  43. package/dist/modules/customers/lib/dictionaryDefaults.js.map +7 -0
  44. package/dist/modules/customers/lib/dictionaryLabels.js +59 -0
  45. package/dist/modules/customers/lib/dictionaryLabels.js.map +7 -0
  46. package/dist/modules/customers/lib/interactionCompatibility.js +1 -0
  47. package/dist/modules/customers/lib/interactionCompatibility.js.map +2 -2
  48. package/dist/modules/customers/translations.js +9 -0
  49. package/dist/modules/customers/translations.js.map +7 -0
  50. package/dist/modules/directory/api/organization-branding/route.js +6 -9
  51. package/dist/modules/directory/api/organization-branding/route.js.map +2 -2
  52. package/dist/modules/directory/backend/directory/branding/page.js +15 -3
  53. package/dist/modules/directory/backend/directory/branding/page.js.map +2 -2
  54. package/dist/modules/eudr/index.js +13 -0
  55. package/dist/modules/eudr/index.js.map +2 -2
  56. package/dist/modules/staff/api/timesheets/access-requests/route.js +11 -5
  57. package/dist/modules/staff/api/timesheets/access-requests/route.js.map +2 -2
  58. package/dist/modules/staff/api/timesheets/time-projects/route.js +19 -12
  59. package/dist/modules/staff/api/timesheets/time-projects/route.js.map +2 -2
  60. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.js +21 -12
  61. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.js.map +2 -2
  62. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js +14 -9
  63. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js.map +2 -2
  64. package/dist/modules/staff/lib/time-tracking-ui/NoProjectAccess.js +31 -10
  65. package/dist/modules/staff/lib/time-tracking-ui/NoProjectAccess.js.map +2 -2
  66. package/dist/modules/staff/lib/time-tracking-ui/projectAccessScope.js +64 -0
  67. package/dist/modules/staff/lib/time-tracking-ui/projectAccessScope.js.map +7 -0
  68. package/dist/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.js +13 -0
  69. package/dist/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.js.map +7 -0
  70. package/dist/modules/sync_excel/index.js +11 -1
  71. package/dist/modules/sync_excel/index.js.map +2 -2
  72. package/dist/modules/translations/di.js +2 -0
  73. package/dist/modules/translations/di.js.map +2 -2
  74. package/dist/modules/wms/api/listEnrichers.js +3 -1
  75. package/dist/modules/wms/api/listEnrichers.js.map +2 -2
  76. package/dist/modules/wms/index.js +1 -1
  77. package/dist/modules/wms/index.js.map +2 -2
  78. package/package.json +7 -7
  79. package/src/modules/api_keys/api/keys/route.ts +15 -1
  80. package/src/modules/api_keys/i18n/de.json +1 -0
  81. package/src/modules/api_keys/i18n/en.json +1 -0
  82. package/src/modules/api_keys/i18n/es.json +1 -0
  83. package/src/modules/api_keys/i18n/ko.json +1 -0
  84. package/src/modules/api_keys/i18n/pl.json +1 -0
  85. package/src/modules/auth/api/users/route.ts +18 -1
  86. package/src/modules/auth/backend/users/page.tsx +14 -2
  87. package/src/modules/auth/i18n/de.json +1 -0
  88. package/src/modules/auth/i18n/en.json +1 -0
  89. package/src/modules/auth/i18n/es.json +1 -0
  90. package/src/modules/auth/i18n/ko.json +1 -0
  91. package/src/modules/auth/i18n/pl.json +1 -0
  92. package/src/modules/catalog/commands/products.ts +4 -1
  93. package/src/modules/customer_accounts/api/admin/roles.ts +26 -2
  94. package/src/modules/customer_accounts/api/admin/users.ts +26 -7
  95. package/src/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.tsx +96 -13
  96. package/src/modules/customer_accounts/backend/customer_accounts/users/page.tsx +9 -1
  97. package/src/modules/customer_accounts/data/validators.ts +1 -0
  98. package/src/modules/customer_accounts/i18n/de.json +10 -0
  99. package/src/modules/customer_accounts/i18n/en.json +10 -0
  100. package/src/modules/customer_accounts/i18n/es.json +10 -0
  101. package/src/modules/customer_accounts/i18n/ko.json +10 -0
  102. package/src/modules/customer_accounts/i18n/pl.json +10 -0
  103. package/src/modules/customer_accounts/lib/adminOrganizationScope.ts +71 -0
  104. package/src/modules/customers/api/customer-dictionary-entries/route.ts +82 -0
  105. package/src/modules/customers/api/dictionaries/[kind]/route.ts +29 -6
  106. package/src/modules/customers/cli.ts +23 -124
  107. package/src/modules/customers/components/DictionarySettings.tsx +1 -1
  108. package/src/modules/customers/components/detail/ManageTagsDialog.tsx +1 -1
  109. package/src/modules/customers/components/detail/TaskDialog.tsx +15 -1
  110. package/src/modules/customers/components/detail/TaskForm.tsx +1 -17
  111. package/src/modules/customers/components/detail/TasksSection.tsx +44 -14
  112. package/src/modules/customers/components/detail/hooks/useCustomerDictionary.ts +14 -8
  113. package/src/modules/customers/components/detail/hooks/useInteractions.ts +3 -4
  114. package/src/modules/customers/i18n/de.json +81 -0
  115. package/src/modules/customers/i18n/en.json +81 -0
  116. package/src/modules/customers/i18n/es.json +81 -0
  117. package/src/modules/customers/i18n/ko.json +81 -0
  118. package/src/modules/customers/i18n/pl.json +81 -0
  119. package/src/modules/customers/lib/dictionaryDefaults.ts +137 -0
  120. package/src/modules/customers/lib/dictionaryLabels.ts +73 -0
  121. package/src/modules/customers/lib/interactionCompatibility.ts +1 -0
  122. package/src/modules/customers/translations.ts +5 -0
  123. package/src/modules/directory/api/organization-branding/route.ts +5 -8
  124. package/src/modules/directory/backend/directory/branding/page.tsx +15 -3
  125. package/src/modules/eudr/index.ts +13 -0
  126. package/src/modules/staff/api/timesheets/access-requests/route.ts +13 -14
  127. package/src/modules/staff/api/timesheets/time-projects/route.ts +22 -13
  128. package/src/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.tsx +25 -14
  129. package/src/modules/staff/backend/staff/time-tracking/projects/[id]/page.tsx +14 -13
  130. package/src/modules/staff/lib/time-tracking-ui/NoProjectAccess.tsx +30 -8
  131. package/src/modules/staff/lib/time-tracking-ui/projectAccessScope.ts +102 -0
  132. package/src/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.ts +15 -0
  133. package/src/modules/sync_excel/index.ts +10 -0
  134. package/src/modules/translations/di.ts +2 -0
  135. package/src/modules/wms/api/listEnrichers.ts +5 -2
  136. package/src/modules/wms/index.ts +1 -1
@@ -0,0 +1,71 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import type { AwilixContainer } from 'awilix'
4
+ import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
5
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
6
+ import {
7
+ organizationScopeRequiredResponse,
8
+ resolveActiveOrganizationId,
9
+ } from '@open-mercato/shared/lib/auth/organizationScope'
10
+ import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
11
+
12
+ export type AdminTargetOrganizationResult =
13
+ | { ok: true; organizationId: string }
14
+ | { ok: false; response: Response }
15
+
16
+ const organizationIdSchema = z.string().uuid()
17
+
18
+ async function organizationNotFound(): Promise<AdminTargetOrganizationResult> {
19
+ const { translate } = await resolveTranslations()
20
+ return {
21
+ ok: false,
22
+ response: NextResponse.json({ ok: false, error: translate('customer_accounts.admin.errors.organizationNotFound', 'Organization not found') }, { status: 400 }),
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Resolves the organization a staff-initiated customer-accounts write targets.
28
+ *
29
+ * Without a request the caller's active organization is used, falling back to the
30
+ * actor's own organization under an "all organizations" selection.
31
+ *
32
+ * An explicitly requested organization is honored only when the organization-scope
33
+ * resolver grants it for the caller's own tenant: it must exist, not be deleted, and
34
+ * sit inside the caller's allowed organization set.
35
+ */
36
+ export async function resolveAdminTargetOrganization({
37
+ container,
38
+ auth,
39
+ request,
40
+ requestedOrganizationId,
41
+ }: {
42
+ container: AwilixContainer
43
+ auth: NonNullable<AuthContext>
44
+ request: Request
45
+ requestedOrganizationId?: string | null
46
+ }): Promise<AdminTargetOrganizationResult> {
47
+ const requested = typeof requestedOrganizationId === 'string' && requestedOrganizationId.trim().length > 0
48
+ ? requestedOrganizationId.trim()
49
+ : null
50
+ const activeOrganizationId = resolveActiveOrganizationId(auth)
51
+
52
+ if (!requested) {
53
+ if (!activeOrganizationId) return { ok: false, response: organizationScopeRequiredResponse() }
54
+ return { ok: true, organizationId: activeOrganizationId }
55
+ }
56
+
57
+ if (!organizationIdSchema.safeParse(requested).success) return organizationNotFound()
58
+ if (requested === activeOrganizationId) return { ok: true, organizationId: requested }
59
+
60
+ const scope = await resolveOrganizationScopeForRequest({
61
+ container,
62
+ auth,
63
+ request,
64
+ selectedId: requested,
65
+ tenantId: auth.tenantId ?? null,
66
+ })
67
+ if (scope.selectionRejected || scope.selectedId !== requested || scope.tenantId !== (auth.tenantId ?? null)) {
68
+ return organizationNotFound()
69
+ }
70
+ return { ok: true, organizationId: requested }
71
+ }
@@ -0,0 +1,82 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import type { FilterQuery } from '@mikro-orm/core'
4
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
5
+ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
6
+ import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
7
+ import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
8
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
9
+ import { CustomerDictionaryEntry } from '../../data/entities'
10
+ import { resolveDictionaryRouteContext } from '../dictionaries/context'
11
+ import { createPagedListResponseSchema } from '../openapi'
12
+
13
+ const querySchema = z.object({
14
+ page: z.coerce.number().int().min(1).default(1),
15
+ pageSize: z.coerce.number().int().min(1).max(100).default(20),
16
+ search: z.string().trim().max(150).optional(),
17
+ id: z.string().uuid().optional(),
18
+ })
19
+
20
+ export const metadata = {
21
+ GET: { requireAuth: true, requireFeatures: ['customers.settings.manage', 'translations.view'] },
22
+ }
23
+
24
+ export async function GET(req: Request) {
25
+ try {
26
+ const url = new URL(req.url)
27
+ const query = querySchema.parse({
28
+ page: url.searchParams.get('page') ?? undefined,
29
+ pageSize: url.searchParams.get('pageSize') ?? undefined,
30
+ search: url.searchParams.get('search') ?? undefined,
31
+ id: url.searchParams.get('id') ?? undefined,
32
+ })
33
+ const { em, tenantId, organizationId, translate } = await resolveDictionaryRouteContext(req)
34
+ if (!organizationId) {
35
+ return NextResponse.json({ error: translate('customers.errors.organization_required', 'Organization context is required') }, { status: 400 })
36
+ }
37
+ const where: FilterQuery<CustomerDictionaryEntry> = {
38
+ tenantId,
39
+ organizationId,
40
+ ...(query.id ? { id: query.id } : {}),
41
+ ...(query.search ? { label: { $ilike: `%${escapeLikePattern(query.search)}%` } } : {}),
42
+ }
43
+ const total = await em.count(CustomerDictionaryEntry, where)
44
+ const entries = await findWithDecryption(em, CustomerDictionaryEntry, where, {
45
+ orderBy: { kind: 'asc', label: 'asc', id: 'asc' },
46
+ limit: query.pageSize,
47
+ offset: (query.page - 1) * query.pageSize,
48
+ }, { tenantId, organizationId })
49
+ return NextResponse.json({
50
+ items: entries.map(({ id, kind, value, label, updatedAt }) => ({ id, kind, value, label, updatedAt })),
51
+ total,
52
+ page: query.page,
53
+ pageSize: query.pageSize,
54
+ totalPages: Math.ceil(total / query.pageSize),
55
+ })
56
+ } catch (error) {
57
+ if (isCrudHttpError(error)) return NextResponse.json(error.body, { status: error.status })
58
+ if (error instanceof z.ZodError) {
59
+ const { translate } = await resolveTranslations()
60
+ return NextResponse.json({ error: translate('customers.errors.invalid_query', 'Invalid query parameters') }, { status: 400 })
61
+ }
62
+ throw error
63
+ }
64
+ }
65
+
66
+ export const openApi: OpenApiRouteDoc = {
67
+ tag: 'Customers',
68
+ summary: 'Customer dictionary translation records',
69
+ methods: {
70
+ GET: {
71
+ summary: 'List base labels for translation management',
72
+ query: querySchema,
73
+ responses: [{
74
+ status: 200,
75
+ description: 'Dictionary entries in the selected organization with their stored base labels',
76
+ schema: createPagedListResponseSchema(z.object({
77
+ id: z.string().uuid(), kind: z.string(), value: z.string(), label: z.string(), updatedAt: z.string(),
78
+ })),
79
+ }],
80
+ },
81
+ },
82
+ }
@@ -24,6 +24,8 @@ import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
24
24
  import { createLogger } from '@open-mercato/shared/lib/logger'
25
25
  import { CUSTOMER_DICTIONARY_ORGANIZATION_REQUIRED_CODE } from '../../../lib/dictionaries'
26
26
  import { getCommandInterceptorHttpRejection } from '@open-mercato/shared/lib/commands/errors'
27
+ import { getTranslationOverlayPlugin } from '@open-mercato/shared/lib/localization/overlay-plugin'
28
+ import { localizeCustomerDictionaryEntries } from '../../../lib/dictionaryLabels'
27
29
  import { calendarEventTypeBehaviorSchema } from '../../../calendar-event-types'
28
30
  import type { OrganizationHierarchyService } from '@open-mercato/shared/lib/auth/principal-service'
29
31
 
@@ -42,6 +44,9 @@ const postSchema = z.object({
42
44
 
43
45
  const querySchema = z.object({
44
46
  organizationId: z.string().uuid().optional(),
47
+ labels: z.enum(['localized', 'base']).optional().describe(
48
+ 'Label projection. `localized` (default) resolves labels for the request locale; `base` returns the stored labels that management screens edit.',
49
+ ),
45
50
  })
46
51
 
47
52
  export const metadata = {
@@ -54,11 +59,24 @@ export async function GET(req: Request, ctx: { params?: { kind?: string } }) {
54
59
  const url = new URL(req.url)
55
60
  const query = querySchema.parse({
56
61
  organizationId: url.searchParams.get('organizationId') ?? undefined,
62
+ labels: url.searchParams.get('labels') ?? undefined,
57
63
  })
58
64
  const { translate, em, organizationId, readableOrganizationIds, tenantId, cache, container } = await resolveDictionaryRouteContext(req, {
59
65
  selectedId: query.organizationId ?? undefined,
60
66
  })
61
67
  const { kind, mappedKind } = mapDictionaryKind(ctx.params?.kind)
68
+ const localizeResponse = async (body: z.infer<typeof dictionaryCacheResponseSchema>) => {
69
+ if (query.labels === 'base') return body
70
+ const { resolveLocale } = getTranslationOverlayPlugin()
71
+ const locale = resolveLocale?.(req) ?? (await resolveTranslations()).locale ?? 'en'
72
+ return {
73
+ ...body,
74
+ items: sortDictionaryEntries(
75
+ await localizeCustomerDictionaryEntries(body.items, { kind: mappedKind, locale, tenantId, container }),
76
+ resolveDictionaryEntrySortMode(body.sortMode),
77
+ ),
78
+ }
79
+ }
62
80
  if (!organizationId) {
63
81
  throw new CrudHttpError(400, {
64
82
  error: translate('customers.errors.organization_required', 'Organization context is required'),
@@ -87,16 +105,16 @@ export async function GET(req: Request, ctx: { params?: { kind?: string } }) {
87
105
  sortMode,
88
106
  readableOrganizationIds: scopedOrganizationIds,
89
107
  })
90
- const cached = await cache.get(cacheKey)
91
- if (cached) {
92
- return NextResponse.json(cached)
108
+ const cached = dictionaryCacheResponseSchema.safeParse(await cache.get(cacheKey))
109
+ if (cached.success) {
110
+ return NextResponse.json(await localizeResponse(cached.data))
93
111
  }
94
112
  }
95
113
 
96
114
  const entries = await findWithDecryption(
97
115
  em,
98
116
  CustomerDictionaryEntry,
99
- { tenantId, kind: mappedKind, organizationId: { $in: scopedOrganizationIds } } as any,
117
+ { tenantId, kind: mappedKind, organizationId: { $in: scopedOrganizationIds } },
100
118
  { orderBy: { label: 'asc' } },
101
119
  { tenantId, organizationId },
102
120
  )
@@ -180,7 +198,7 @@ export async function GET(req: Request, ctx: { params?: { kind?: string } }) {
180
198
  }
181
199
  }
182
200
 
183
- return NextResponse.json(responseBody)
201
+ return NextResponse.json(await localizeResponse(responseBody))
184
202
  } catch (err) {
185
203
  if (isCrudHttpError(err)) {
186
204
  return NextResponse.json(err.body, { status: err.status })
@@ -315,6 +333,10 @@ const dictionaryListResponseSchema = z.object({
315
333
  items: z.array(dictionaryEntrySchema),
316
334
  })
317
335
 
336
+ const dictionaryCacheResponseSchema = dictionaryListResponseSchema.extend({
337
+ items: z.array(dictionaryEntrySchema.extend({ label: z.string(), organizationId: z.string() }).passthrough()),
338
+ })
339
+
318
340
  const dictionaryErrorSchema = z.object({
319
341
  error: z.string(),
320
342
  code: z.string().optional(),
@@ -326,7 +348,8 @@ export const openApi: OpenApiRouteDoc = {
326
348
  methods: {
327
349
  GET: {
328
350
  summary: 'List dictionary entries',
329
- description: 'Returns dictionary entries for the requested kind within the currently selected organization.',
351
+ description: 'Returns dictionary entries for the requested kind within the currently selected organization. Labels are localized for the request locale unless `labels=base` is passed.',
352
+ query: querySchema,
330
353
  responses: [
331
354
  { status: 200, description: 'Dictionary entries', schema: dictionaryListResponseSchema },
332
355
  { status: 401, description: 'Unauthorized', schema: dictionaryErrorSchema },
@@ -39,129 +39,38 @@ import {
39
39
  CUSTOMER_INTERACTION_TODO_ADAPTER_SOURCE,
40
40
  } from './lib/interactionCompatibility'
41
41
  import { CUSTOMER_CUSTOM_FIELD_SETS } from './customFieldDefaults'
42
+ import {
43
+ ENTITY_STATUS_DEFAULTS,
44
+ ENTITY_SOURCE_DEFAULTS,
45
+ ENTITY_LIFECYCLE_STAGE_DEFAULTS,
46
+ ADDRESS_TYPE_DEFAULTS,
47
+ ACTIVITY_TYPE_DEFAULTS,
48
+ INTERACTION_STATUS_DEFAULTS,
49
+ JOB_TITLE_DEFAULTS,
50
+ DEAL_STATUS_DEFAULTS,
51
+ PIPELINE_STAGE_DEFAULTS,
52
+ INDUSTRY_DEFAULTS,
53
+ TEMPERATURE_DEFAULTS,
54
+ PERSON_COMPANY_ROLE_DEFAULTS,
55
+ } from './lib/dictionaryDefaults'
56
+
57
+ export {
58
+ DEAL_STATUS_DEFAULTS,
59
+ PIPELINE_STAGE_DEFAULTS,
60
+ ENTITY_STATUS_DEFAULTS,
61
+ ENTITY_LIFECYCLE_STAGE_DEFAULTS,
62
+ ENTITY_SOURCE_DEFAULTS,
63
+ INTERACTION_STATUS_DEFAULTS,
64
+ } from './lib/dictionaryDefaults'
42
65
 
43
66
  type SeedArgs = {
44
67
  tenantId: string
45
68
  organizationId: string
46
69
  }
47
70
 
48
- type DictionaryDefault = {
49
- value: string
50
- label: string
51
- color?: string
52
- icon?: string
53
- }
54
-
55
71
  type CustomFieldValuesPayload = Parameters<DataEngine['setCustomFields']>[0]['values']
56
72
  type ProgressBarHandle = ReturnType<typeof createProgressBar>
57
73
 
58
- export const DEAL_STATUS_DEFAULTS: DictionaryDefault[] = [
59
- { value: 'open', label: 'Open', color: '#2563eb', icon: 'lucide:circle' },
60
- { value: 'closed', label: 'Closed', color: '#6b7280', icon: 'lucide:check-circle' },
61
- { value: 'win', label: 'Win', color: '#22c55e', icon: 'lucide:trophy' },
62
- { value: 'lost', label: 'Lost', color: '#ef4444', icon: 'lucide:flag' },
63
- { value: 'in_progress', label: 'In progress', color: '#f59e0b', icon: 'lucide:activity' },
64
- ]
65
-
66
- export const PIPELINE_STAGE_DEFAULTS: DictionaryDefault[] = [
67
- { value: 'opportunity', label: 'Opportunity', color: '#38bdf8', icon: 'lucide:target' },
68
- { value: 'marketing_qualified_lead', label: 'Marketing Qualified Lead', color: '#a855f7', icon: 'lucide:sparkles' },
69
- { value: 'sales_qualified_lead', label: 'Sales Qualified Lead', color: '#f97316', icon: 'lucide:users' },
70
- { value: 'offering', label: 'Offering', color: '#22c55e', icon: 'lucide:package' },
71
- { value: 'negotiations', label: 'Negotiations', color: '#facc15', icon: 'lucide:handshake' },
72
- { value: 'win', label: 'Win', color: '#16a34a', icon: 'lucide:award' },
73
- { value: 'lost', label: 'Lost', color: '#ef4444', icon: 'lucide:flag' },
74
- { value: 'stalled', label: 'Stalled', color: '#6b7280', icon: 'lucide:alert-circle' },
75
- ]
76
-
77
- export const ENTITY_STATUS_DEFAULTS: DictionaryDefault[] = [
78
- { value: 'active', label: 'Active', color: '#22c55e', icon: 'lucide:user-check' },
79
- { value: 'inactive', label: 'Inactive', color: '#94a3b8', icon: 'lucide:pause-circle' },
80
- { value: 'pending', label: 'Pending', color: '#f59e0b', icon: 'lucide:clock' },
81
- { value: 'archived', label: 'Archived', color: '#64748b', icon: 'lucide:archive' },
82
- ]
83
-
84
- export const ENTITY_LIFECYCLE_STAGE_DEFAULTS: DictionaryDefault[] = [
85
- { value: 'lead', label: 'Lead', color: '#3b82f6', icon: 'lucide:sparkles' },
86
- { value: 'prospect', label: 'Prospect', color: '#8b5cf6', icon: 'lucide:eye' },
87
- { value: 'customer', label: 'Customer', color: '#22c55e', icon: 'lucide:handshake' },
88
- { value: 'subscriber', label: 'Subscriber', color: '#10b981', icon: 'lucide:bell' },
89
- { value: 'churned', label: 'Churned', color: '#ef4444', icon: 'lucide:user-x' },
90
- { value: 'other', label: 'Other', color: '#94a3b8', icon: 'lucide:circle' },
91
- ]
92
-
93
- export const ENTITY_SOURCE_DEFAULTS: DictionaryDefault[] = [
94
- { value: 'linkedin', label: 'LinkedIn', color: '#0a66c2', icon: 'lucide:linkedin' },
95
- { value: 'email', label: 'Email', color: '#3b82f6', icon: 'lucide:mail' },
96
- { value: 'web_form', label: 'Web form', color: '#22c55e', icon: 'lucide:globe' },
97
- { value: 'referral', label: 'Referral', color: '#8b5cf6', icon: 'lucide:users' },
98
- { value: 'customer_referral', label: 'Customer referral', color: '#22c55e', icon: 'lucide:thumbs-up' },
99
- { value: 'partner_referral', label: 'Partner referral', color: '#3b82f6', icon: 'lucide:handshake' },
100
- { value: 'event', label: 'Conference / Event', color: '#f59e0b', icon: 'lucide:calendar' },
101
- { value: 'cold_outreach', label: 'Cold outreach', color: '#94a3b8', icon: 'lucide:phone' },
102
- { value: 'facebook', label: 'Facebook', color: '#1877f2', icon: 'lucide:facebook' },
103
- { value: 'typeform', label: 'Typeform', color: '#262627', icon: 'lucide:file-text' },
104
- { value: 'other', label: 'Other', color: '#64748b', icon: 'lucide:circle' },
105
- ]
106
-
107
- const ADDRESS_TYPE_DEFAULTS: DictionaryDefault[] = [
108
- { value: 'office', label: 'Office', color: '#3b82f6', icon: 'lucide:building' },
109
- { value: 'work', label: 'Work', color: '#6366f1', icon: 'lucide:briefcase' },
110
- { value: 'billing', label: 'Billing', color: '#f97316', icon: 'lucide:wallet' },
111
- { value: 'shipping', label: 'Shipping', color: '#22c55e', icon: 'lucide:truck' },
112
- { value: 'home', label: 'Home', color: '#10b981', icon: 'lucide:map-pin' },
113
- ]
114
-
115
- const ACTIVITY_TYPE_DEFAULTS: DictionaryDefault[] = [
116
- { value: 'call', label: 'Call', color: '#2563eb', icon: 'lucide:phone-call' },
117
- { value: 'email', label: 'Email', color: '#16a34a', icon: 'lucide:mail' },
118
- { value: 'event', label: 'Event', color: '#6366f1', icon: 'lucide:calendar' },
119
- { value: 'meeting', label: 'Meeting', color: '#f59e0b', icon: 'lucide:users' },
120
- { value: 'note', label: 'Note', color: '#a855f7', icon: 'lucide:notebook' },
121
- { value: 'task', label: 'Task', color: '#ef4444', icon: 'lucide:check-square' },
122
- ]
123
-
124
- export const INTERACTION_STATUS_DEFAULTS: DictionaryDefault[] = [
125
- { value: 'planned', label: 'Planned', color: '#2563eb', icon: 'lucide:circle' },
126
- { value: 'in_progress', label: 'In progress', color: '#f59e0b', icon: 'lucide:activity' },
127
- { value: 'waiting', label: 'Waiting / blocked', color: '#a855f7', icon: 'lucide:pause-circle' },
128
- { value: 'done', label: 'Done', color: '#22c55e', icon: 'lucide:check-circle' },
129
- { value: 'canceled', label: 'Canceled', color: '#6b7280', icon: 'lucide:x-circle' },
130
- ]
131
-
132
- const JOB_TITLE_DEFAULTS: DictionaryDefault[] = [
133
- { value: 'Director of Operations', label: 'Director of Operations', color: '#f97316', icon: 'lucide:settings' },
134
- { value: 'VP of Partnerships', label: 'VP of Partnerships', color: '#6366f1', icon: 'lucide:users' },
135
- { value: 'Founder & Principal', label: 'Founder & Principal', color: '#ec4899', icon: 'lucide:star' },
136
- { value: 'Senior Project Manager', label: 'Senior Project Manager', color: '#0ea5e9', icon: 'lucide:clipboard-list' },
137
- { value: 'Chief Revenue Officer', label: 'Chief Revenue Officer', color: '#8b5cf6', icon: 'lucide:bar-chart-3' },
138
- { value: 'Director of Retail Partnerships', label: 'Director of Retail Partnerships', color: '#f59e0b', icon: 'lucide:shopping-bag' },
139
- ]
140
-
141
- const INDUSTRY_DEFAULTS: DictionaryDefault[] = [
142
- { value: 'Renewable Energy', label: 'Renewable Energy' },
143
- { value: 'Software', label: 'Software' },
144
- { value: 'Interior Design', label: 'Interior Design' },
145
- { value: 'SaaS', label: 'SaaS' },
146
- { value: 'E-commerce', label: 'E-commerce' },
147
- { value: 'Healthcare', label: 'Healthcare' },
148
- { value: 'Manufacturing', label: 'Manufacturing' },
149
- { value: 'Logistics', label: 'Logistics' },
150
- { value: 'Financial Services', label: 'Financial Services' },
151
- { value: 'Retail', label: 'Retail' },
152
- { value: 'Hospitality', label: 'Hospitality' },
153
- { value: 'Energy', label: 'Energy' },
154
- { value: 'Media', label: 'Media' },
155
- ]
156
-
157
- const TEMPERATURE_DEFAULTS: DictionaryDefault[] = [
158
- { value: 'hot', label: 'Hot', color: '#ef4444', icon: 'lucide:flame' },
159
- { value: 'high', label: 'High', color: '#f59e0b', icon: 'lucide:trending-up' },
160
- { value: 'medium', label: 'Medium', color: '#8b5cf6', icon: 'lucide:sparkles' },
161
- { value: 'low', label: 'Low', color: '#64748b', icon: 'lucide:clock' },
162
- { value: 'cold', label: 'Cold', color: '#94a3b8', icon: 'lucide:snowflake' },
163
- ]
164
-
165
74
  const CUSTOM_TAG_SEED_DEFAULTS = [
166
75
  { value: 'architecture', label: 'architecture' },
167
76
  { value: 'hospitality', label: 'hospitality' },
@@ -179,15 +88,6 @@ const CUSTOM_TAG_SEED_DEFAULTS = [
179
88
  { value: 'case-study-candidate', label: 'case-study-candidate' },
180
89
  ]
181
90
 
182
- const PERSON_COMPANY_ROLE_DEFAULTS = [
183
- { value: 'decision_maker', label: 'Decision maker', color: '#f59e0b', icon: 'lucide:crown' },
184
- { value: 'influencer', label: 'Influencer', color: '#8b5cf6', icon: 'lucide:sparkles' },
185
- { value: 'budget_holder', label: 'Budget holder', color: '#3b82f6', icon: 'lucide:wallet' },
186
- { value: 'technical_evaluator', label: 'Technical evaluator', color: '#22c55e', icon: 'lucide:wrench' },
187
- { value: 'primary_contact', label: 'Primary contact', color: '#0ea5e9', icon: 'lucide:star' },
188
- { value: 'end_user', label: 'End user', color: '#64748b', icon: 'lucide:user' },
189
- ]
190
-
191
91
  const PRIORITY_CURRENCIES = ['EUR', 'USD', 'GBP', 'PLN']
192
92
 
193
93
  type ExampleAddress = {
@@ -2679,7 +2579,6 @@ async function seedCustomerStressTest(
2679
2579
  return { created: toCreate, existing: existingPersons }
2680
2580
  }
2681
2581
 
2682
-
2683
2582
  const STRESS_TEST_REQUIRED_COLUMNS: Record<string, readonly string[]> = {
2684
2583
  customer_entities: [
2685
2584
  'id',
@@ -237,7 +237,7 @@ function CustomerDictionarySection({ kind, title, description }: CustomerDiction
237
237
  setLoading(true)
238
238
  try {
239
239
  const data = await readApiResultOrThrow<{ items?: unknown[] }>(
240
- `/api/customers/dictionaries/${kind}`,
240
+ `/api/customers/dictionaries/${kind}?labels=base`,
241
241
  undefined,
242
242
  { errorMessage: errorLoad },
243
243
  )
@@ -560,7 +560,7 @@ export function ManageTagsDialog({ open, onClose }: ManageTagsDialogProps) {
560
560
  try {
561
561
  const data = await readApiResultOrThrow<{
562
562
  items?: Array<Record<string, unknown>>
563
- }>(`/api/customers/dictionaries/${category.kind}`, { cache: 'no-store' })
563
+ }>(`/api/customers/dictionaries/${category.kind}?labels=base`, { cache: 'no-store' })
564
564
  const entries = Array.isArray(data?.items)
565
565
  ? data.items
566
566
  .map(makeDraftEntry)
@@ -38,9 +38,23 @@ export function TaskDialog({ open, mode, onOpenChange, initialValues, onSubmit,
38
38
  [onOpenChange, onSubmit],
39
39
  )
40
40
 
41
+ const handleKeyDown = React.useCallback(
42
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
43
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
44
+ event.preventDefault()
45
+ event.currentTarget.querySelector('form')?.requestSubmit()
46
+ }
47
+ if (event.key === 'Escape') {
48
+ event.preventDefault()
49
+ handleCancel()
50
+ }
51
+ },
52
+ [handleCancel],
53
+ )
54
+
41
55
  return (
42
56
  <Dialog open={open} onOpenChange={onOpenChange}>
43
- <DialogContent className="sm:max-w-2xl">
57
+ <DialogContent className="sm:max-w-2xl" onKeyDown={handleKeyDown}>
44
58
  <DialogHeader>
45
59
  <DialogTitle>{dialogTitle}</DialogTitle>
46
60
  {contextMessage ? <DialogDescription>{contextMessage}</DialogDescription> : null}
@@ -59,7 +59,6 @@ export function TaskForm({
59
59
  useCanonicalInteractions = false,
60
60
  }: TaskFormProps) {
61
61
  const t = useT()
62
- const containerRef = React.useRef<HTMLDivElement | null>(null)
63
62
  const scopeVersion = useOrganizationScopeVersion()
64
63
  const statusDictionary = useCustomerDictionary('interaction-statuses', scopeVersion)
65
64
 
@@ -101,21 +100,6 @@ export function TaskForm({
101
100
  : t('customers.people.detail.tasks.form.submitCreate', 'Save task (⌘/Ctrl + Enter)'))
102
101
  const resolvedCancelLabel = cancelLabel ?? t('customers.people.detail.tasks.cancel', 'Cancel')
103
102
 
104
- const handleKeyDown = React.useCallback(
105
- (event: React.KeyboardEvent<HTMLDivElement>) => {
106
- if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
107
- event.preventDefault()
108
- const form = containerRef.current?.querySelector('form')
109
- form?.requestSubmit()
110
- }
111
- if (event.key === 'Escape') {
112
- event.preventDefault()
113
- onCancel()
114
- }
115
- },
116
- [onCancel],
117
- )
118
-
119
103
  const handleSubmit = React.useCallback(
120
104
  async (values: Record<string, unknown>) => {
121
105
  const payload = buildTaskSubmitPayload(values, t)
@@ -133,7 +117,7 @@ export function TaskForm({
133
117
  }, [initialValues, mode, useCanonicalInteractions])
134
118
 
135
119
  return (
136
- <div ref={containerRef} onKeyDown={handleKeyDown}>
120
+ <div>
137
121
  <CrudForm
138
122
  embedded
139
123
  entityId={formEntityId}
@@ -9,6 +9,11 @@ import { StatusBadge, type StatusBadgeVariant } from '@open-mercato/ui/primitive
9
9
  import { mapDictionaryColorToTone } from '@open-mercato/shared/lib/query/advanced-filter'
10
10
  import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
11
11
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
12
+ import { dismissRecordConflict } from '@open-mercato/ui/backend/conflicts'
13
+ import {
14
+ extractOptimisticLockConflict,
15
+ extractRecordLockConflict,
16
+ } from '@open-mercato/ui/backend/utils/optimisticLock'
12
17
  import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
13
18
  import { LoadingMessage, TabEmptyState } from '@open-mercato/ui/backend/detail'
14
19
  import { cn } from '@open-mercato/shared/lib/utils'
@@ -49,6 +54,10 @@ type TasksSectionProps = {
49
54
 
50
55
  const RESERVED_TASK_CUSTOM_KEYS = new Set(['priority', 'description', 'due_at', 'dueAt'])
51
56
 
57
+ function isRecordConflict(error: unknown): boolean {
58
+ return Boolean(extractOptimisticLockConflict(error) || extractRecordLockConflict(error))
59
+ }
60
+
52
61
  function toTimestamp(value: string | null | undefined): number | null {
53
62
  if (!value) return null
54
63
  const timestamp = new Date(value).getTime()
@@ -240,6 +249,13 @@ export function TasksSection({
240
249
  setEditingTask(null)
241
250
  }, [])
242
251
 
252
+ React.useEffect(() => {
253
+ if (!dialogOpen || dialogMode !== 'edit' || !editingTask) return
254
+ const refreshedTask = tasks.find((task) => task.id === editingTask.id)
255
+ if (!refreshedTask || refreshedTask === editingTask) return
256
+ setEditingTask(refreshedTask)
257
+ }, [dialogMode, dialogOpen, editingTask, tasks])
258
+
243
259
  React.useEffect(() => {
244
260
  if (!onActionChange) return
245
261
  if (!entityId) {
@@ -295,8 +311,10 @@ export function TasksSection({
295
311
  flash(t('customers.people.detail.tasks.createSuccess', 'Task created'), 'success')
296
312
  await Promise.resolve(onDataRefresh?.())
297
313
  } catch (err) {
298
- const message = err instanceof Error ? err.message : t('customers.people.detail.tasks.error', 'Failed to create task')
299
- flash(message, 'error')
314
+ if (!isRecordConflict(err)) {
315
+ const message = err instanceof Error ? err.message : t('customers.people.detail.tasks.error', 'Failed to create task')
316
+ flash(message, 'error')
317
+ }
300
318
  throw err
301
319
  }
302
320
  },
@@ -315,12 +333,15 @@ export function TasksSection({
315
333
  isDone: payload.base.is_done ?? undefined,
316
334
  },
317
335
  )
336
+ dismissRecordConflict()
318
337
  flash(t('customers.people.detail.tasks.updateSuccess', 'Task updated'), 'success')
319
338
  await Promise.resolve(onDataRefresh?.())
320
339
  } catch (err) {
321
- const message =
322
- err instanceof Error ? err.message : t('customers.people.detail.tasks.updateError', 'Failed to update task')
323
- flash(message, 'error')
340
+ if (!isRecordConflict(err)) {
341
+ const message =
342
+ err instanceof Error ? err.message : t('customers.people.detail.tasks.updateError', 'Failed to update task')
343
+ flash(message, 'error')
344
+ }
324
345
  throw err
325
346
  }
326
347
  },
@@ -338,6 +359,7 @@ export function TasksSection({
338
359
  isDone: nextIsDone,
339
360
  },
340
361
  )
362
+ dismissRecordConflict()
341
363
  flash(
342
364
  nextIsDone
343
365
  ? t('customers.people.detail.tasks.completeSuccess', 'Task marked as done')
@@ -346,9 +368,11 @@ export function TasksSection({
346
368
  )
347
369
  await Promise.resolve(onDataRefresh?.())
348
370
  } catch (err) {
349
- const message =
350
- err instanceof Error ? err.message : t('customers.people.detail.tasks.toggleError', 'Failed to update task status')
351
- flash(message, 'error')
371
+ if (!isRecordConflict(err)) {
372
+ const message =
373
+ err instanceof Error ? err.message : t('customers.people.detail.tasks.toggleError', 'Failed to update task status')
374
+ flash(message, 'error')
375
+ }
352
376
  }
353
377
  },
354
378
  [onDataRefresh, runWriteMutation, t, toggleTask],
@@ -374,13 +398,16 @@ export function TasksSection({
374
398
  todoId: task.todoId,
375
399
  },
376
400
  )
401
+ dismissRecordConflict()
377
402
  flash(t('customers.people.detail.tasks.deleteSuccess', 'Task removed'), 'success')
378
403
  await Promise.resolve(onDataRefresh?.())
379
404
  await refresh()
380
405
  } catch (err) {
381
- const message =
382
- err instanceof Error ? err.message : t('customers.people.detail.tasks.deleteError', 'Failed to remove task')
383
- flash(message, 'error')
406
+ if (!isRecordConflict(err)) {
407
+ const message =
408
+ err instanceof Error ? err.message : t('customers.people.detail.tasks.deleteError', 'Failed to remove task')
409
+ flash(message, 'error')
410
+ }
384
411
  }
385
412
  },
386
413
  [confirm, onDataRefresh, refresh, runWriteMutation, t, unlinkTask],
@@ -394,12 +421,15 @@ export function TasksSection({
394
421
  () => canonicalResult.cancelInteraction(task.todoId),
395
422
  { id: task.todoId },
396
423
  )
424
+ dismissRecordConflict()
397
425
  flash(t('customers.people.detail.tasks.cancelSuccess', 'Task canceled'), 'success')
398
426
  await Promise.resolve(onDataRefresh?.())
399
427
  } catch (err) {
400
- const message =
401
- err instanceof Error ? err.message : t('customers.people.detail.tasks.cancelError', 'Failed to cancel task')
402
- flash(message, 'error')
428
+ if (!isRecordConflict(err)) {
429
+ const message =
430
+ err instanceof Error ? err.message : t('customers.people.detail.tasks.cancelError', 'Failed to cancel task')
431
+ flash(message, 'error')
432
+ }
403
433
  }
404
434
  },
405
435
  [canonicalResult, onDataRefresh, runWriteMutation, t, useCanonicalInteractions],