@open-mercato/core 0.10.1-develop.7833.1.d6cbf0e3ab → 0.10.1-develop.7841.1.af87ac614d

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/cart/api/current/lines/bulk/route.js +1 -1
  3. package/dist/modules/cart/api/current/lines/bulk/route.js.map +2 -2
  4. package/dist/modules/cart/api/current/lines/route.js +1 -1
  5. package/dist/modules/cart/api/current/lines/route.js.map +2 -2
  6. package/dist/modules/cart/commands/lines.js +5 -10
  7. package/dist/modules/cart/commands/lines.js.map +2 -2
  8. package/dist/modules/cart/lib/currentCartRoute.js +8 -0
  9. package/dist/modules/cart/lib/currentCartRoute.js.map +3 -3
  10. package/dist/modules/cart/lib/lineMutationLock.js +52 -0
  11. package/dist/modules/cart/lib/lineMutationLock.js.map +7 -0
  12. package/dist/modules/customer_accounts/api/admin/roles.js +25 -2
  13. package/dist/modules/customer_accounts/api/admin/roles.js.map +2 -2
  14. package/dist/modules/customer_accounts/api/admin/users.js +24 -7
  15. package/dist/modules/customer_accounts/api/admin/users.js.map +2 -2
  16. package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js +77 -15
  17. package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js.map +2 -2
  18. package/dist/modules/customer_accounts/backend/customer_accounts/users/page.js +12 -1
  19. package/dist/modules/customer_accounts/backend/customer_accounts/users/page.js.map +2 -2
  20. package/dist/modules/customer_accounts/data/validators.js +2 -1
  21. package/dist/modules/customer_accounts/data/validators.js.map +2 -2
  22. package/dist/modules/customer_accounts/lib/adminOrganizationScope.js +46 -0
  23. package/dist/modules/customer_accounts/lib/adminOrganizationScope.js.map +7 -0
  24. package/dist/modules/customers/api/customer-dictionary-entries/route.js +86 -0
  25. package/dist/modules/customers/api/customer-dictionary-entries/route.js.map +7 -0
  26. package/dist/modules/customers/api/dictionaries/[kind]/route.js +29 -7
  27. package/dist/modules/customers/api/dictionaries/[kind]/route.js.map +2 -2
  28. package/dist/modules/customers/cli.js +28 -110
  29. package/dist/modules/customers/cli.js.map +3 -3
  30. package/dist/modules/customers/components/DictionarySettings.js +1 -1
  31. package/dist/modules/customers/components/DictionarySettings.js.map +1 -1
  32. package/dist/modules/customers/components/detail/ManageTagsDialog.js +1 -1
  33. package/dist/modules/customers/components/detail/ManageTagsDialog.js.map +2 -2
  34. package/dist/modules/customers/components/detail/TaskDialog.js +14 -1
  35. package/dist/modules/customers/components/detail/TaskDialog.js.map +2 -2
  36. package/dist/modules/customers/components/detail/TaskForm.js +1 -16
  37. package/dist/modules/customers/components/detail/TaskForm.js.map +2 -2
  38. package/dist/modules/customers/components/detail/TasksSection.js +38 -10
  39. package/dist/modules/customers/components/detail/TasksSection.js.map +2 -2
  40. package/dist/modules/customers/components/detail/hooks/useCustomerDictionary.js +12 -5
  41. package/dist/modules/customers/components/detail/hooks/useCustomerDictionary.js.map +2 -2
  42. package/dist/modules/customers/components/detail/hooks/useInteractions.js +3 -13
  43. package/dist/modules/customers/components/detail/hooks/useInteractions.js.map +2 -2
  44. package/dist/modules/customers/lib/dictionaryDefaults.js +134 -0
  45. package/dist/modules/customers/lib/dictionaryDefaults.js.map +7 -0
  46. package/dist/modules/customers/lib/dictionaryLabels.js +59 -0
  47. package/dist/modules/customers/lib/dictionaryLabels.js.map +7 -0
  48. package/dist/modules/customers/lib/interactionCompatibility.js +1 -0
  49. package/dist/modules/customers/lib/interactionCompatibility.js.map +2 -2
  50. package/dist/modules/customers/translations.js +9 -0
  51. package/dist/modules/customers/translations.js.map +7 -0
  52. package/dist/modules/eudr/index.js +13 -0
  53. package/dist/modules/eudr/index.js.map +2 -2
  54. package/dist/modules/sales/commands/documents.js +3 -3
  55. package/dist/modules/sales/commands/documents.js.map +2 -2
  56. package/dist/modules/sales/components/documents/ItemsSection.js +75 -21
  57. package/dist/modules/sales/components/documents/ItemsSection.js.map +2 -2
  58. package/dist/modules/sales/components/documents/lineItemUtils.js +2 -2
  59. package/dist/modules/sales/components/documents/lineItemUtils.js.map +2 -2
  60. package/dist/modules/staff/api/timesheets/access-requests/route.js +11 -5
  61. package/dist/modules/staff/api/timesheets/access-requests/route.js.map +2 -2
  62. package/dist/modules/staff/api/timesheets/time-projects/route.js +19 -12
  63. package/dist/modules/staff/api/timesheets/time-projects/route.js.map +2 -2
  64. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.js +21 -12
  65. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.js.map +2 -2
  66. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js +14 -9
  67. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js.map +2 -2
  68. package/dist/modules/staff/lib/time-tracking-ui/NoProjectAccess.js +31 -10
  69. package/dist/modules/staff/lib/time-tracking-ui/NoProjectAccess.js.map +2 -2
  70. package/dist/modules/staff/lib/time-tracking-ui/projectAccessScope.js +64 -0
  71. package/dist/modules/staff/lib/time-tracking-ui/projectAccessScope.js.map +7 -0
  72. package/dist/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.js +13 -0
  73. package/dist/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.js.map +7 -0
  74. package/dist/modules/sync_excel/index.js +11 -1
  75. package/dist/modules/sync_excel/index.js.map +2 -2
  76. package/dist/modules/translations/di.js +2 -0
  77. package/dist/modules/translations/di.js.map +2 -2
  78. package/package.json +7 -7
  79. package/src/modules/cart/api/current/lines/bulk/route.ts +1 -1
  80. package/src/modules/cart/api/current/lines/route.ts +1 -1
  81. package/src/modules/cart/commands/lines.ts +5 -10
  82. package/src/modules/cart/i18n/de.json +1 -0
  83. package/src/modules/cart/i18n/en.json +1 -0
  84. package/src/modules/cart/i18n/es.json +1 -0
  85. package/src/modules/cart/i18n/ko.json +1 -0
  86. package/src/modules/cart/i18n/pl.json +1 -0
  87. package/src/modules/cart/lib/currentCartRoute.ts +8 -0
  88. package/src/modules/cart/lib/lineMutationLock.ts +59 -0
  89. package/src/modules/customer_accounts/api/admin/roles.ts +26 -2
  90. package/src/modules/customer_accounts/api/admin/users.ts +26 -7
  91. package/src/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.tsx +96 -13
  92. package/src/modules/customer_accounts/backend/customer_accounts/users/page.tsx +9 -1
  93. package/src/modules/customer_accounts/data/validators.ts +1 -0
  94. package/src/modules/customer_accounts/i18n/de.json +10 -0
  95. package/src/modules/customer_accounts/i18n/en.json +10 -0
  96. package/src/modules/customer_accounts/i18n/es.json +10 -0
  97. package/src/modules/customer_accounts/i18n/ko.json +10 -0
  98. package/src/modules/customer_accounts/i18n/pl.json +10 -0
  99. package/src/modules/customer_accounts/lib/adminOrganizationScope.ts +71 -0
  100. package/src/modules/customers/api/customer-dictionary-entries/route.ts +82 -0
  101. package/src/modules/customers/api/dictionaries/[kind]/route.ts +29 -6
  102. package/src/modules/customers/cli.ts +23 -124
  103. package/src/modules/customers/components/DictionarySettings.tsx +1 -1
  104. package/src/modules/customers/components/detail/ManageTagsDialog.tsx +1 -1
  105. package/src/modules/customers/components/detail/TaskDialog.tsx +15 -1
  106. package/src/modules/customers/components/detail/TaskForm.tsx +1 -17
  107. package/src/modules/customers/components/detail/TasksSection.tsx +44 -14
  108. package/src/modules/customers/components/detail/hooks/useCustomerDictionary.ts +14 -8
  109. package/src/modules/customers/components/detail/hooks/useInteractions.ts +3 -4
  110. package/src/modules/customers/i18n/de.json +81 -0
  111. package/src/modules/customers/i18n/en.json +81 -0
  112. package/src/modules/customers/i18n/es.json +81 -0
  113. package/src/modules/customers/i18n/ko.json +81 -0
  114. package/src/modules/customers/i18n/pl.json +81 -0
  115. package/src/modules/customers/lib/dictionaryDefaults.ts +137 -0
  116. package/src/modules/customers/lib/dictionaryLabels.ts +73 -0
  117. package/src/modules/customers/lib/interactionCompatibility.ts +1 -0
  118. package/src/modules/customers/translations.ts +5 -0
  119. package/src/modules/eudr/index.ts +13 -0
  120. package/src/modules/sales/commands/documents.ts +3 -3
  121. package/src/modules/sales/components/documents/ItemsSection.tsx +82 -23
  122. package/src/modules/sales/components/documents/lineItemUtils.ts +2 -2
  123. package/src/modules/sales/i18n/de.json +1 -0
  124. package/src/modules/sales/i18n/en.json +1 -0
  125. package/src/modules/sales/i18n/es.json +1 -0
  126. package/src/modules/sales/i18n/ko.json +1 -0
  127. package/src/modules/sales/i18n/pl.json +1 -0
  128. package/src/modules/staff/api/timesheets/access-requests/route.ts +13 -14
  129. package/src/modules/staff/api/timesheets/time-projects/route.ts +22 -13
  130. package/src/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.tsx +25 -14
  131. package/src/modules/staff/backend/staff/time-tracking/projects/[id]/page.tsx +14 -13
  132. package/src/modules/staff/lib/time-tracking-ui/NoProjectAccess.tsx +30 -8
  133. package/src/modules/staff/lib/time-tracking-ui/projectAccessScope.ts +102 -0
  134. package/src/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.ts +15 -0
  135. package/src/modules/sync_excel/index.ts +10 -0
  136. package/src/modules/translations/di.ts +2 -0
@@ -0,0 +1,137 @@
1
+ type DictionaryDefault = {
2
+ value: string
3
+ label: string
4
+ color?: string
5
+ icon?: string
6
+ }
7
+
8
+ export const DEAL_STATUS_DEFAULTS: DictionaryDefault[] = [
9
+ { value: 'open', label: 'Open', color: '#2563eb', icon: 'lucide:circle' },
10
+ { value: 'closed', label: 'Closed', color: '#6b7280', icon: 'lucide:check-circle' },
11
+ { value: 'win', label: 'Win', color: '#22c55e', icon: 'lucide:trophy' },
12
+ { value: 'lost', label: 'Lost', color: '#ef4444', icon: 'lucide:flag' },
13
+ { value: 'in_progress', label: 'In progress', color: '#f59e0b', icon: 'lucide:activity' },
14
+ ]
15
+
16
+ export const PIPELINE_STAGE_DEFAULTS: DictionaryDefault[] = [
17
+ { value: 'opportunity', label: 'Opportunity', color: '#38bdf8', icon: 'lucide:target' },
18
+ { value: 'marketing_qualified_lead', label: 'Marketing Qualified Lead', color: '#a855f7', icon: 'lucide:sparkles' },
19
+ { value: 'sales_qualified_lead', label: 'Sales Qualified Lead', color: '#f97316', icon: 'lucide:users' },
20
+ { value: 'offering', label: 'Offering', color: '#22c55e', icon: 'lucide:package' },
21
+ { value: 'negotiations', label: 'Negotiations', color: '#facc15', icon: 'lucide:handshake' },
22
+ { value: 'win', label: 'Win', color: '#16a34a', icon: 'lucide:award' },
23
+ { value: 'lost', label: 'Lost', color: '#ef4444', icon: 'lucide:flag' },
24
+ { value: 'stalled', label: 'Stalled', color: '#6b7280', icon: 'lucide:alert-circle' },
25
+ ]
26
+
27
+ export const ENTITY_STATUS_DEFAULTS: DictionaryDefault[] = [
28
+ { value: 'active', label: 'Active', color: '#22c55e', icon: 'lucide:user-check' },
29
+ { value: 'inactive', label: 'Inactive', color: '#94a3b8', icon: 'lucide:pause-circle' },
30
+ { value: 'pending', label: 'Pending', color: '#f59e0b', icon: 'lucide:clock' },
31
+ { value: 'archived', label: 'Archived', color: '#64748b', icon: 'lucide:archive' },
32
+ ]
33
+
34
+ export const ENTITY_LIFECYCLE_STAGE_DEFAULTS: DictionaryDefault[] = [
35
+ { value: 'lead', label: 'Lead', color: '#3b82f6', icon: 'lucide:sparkles' },
36
+ { value: 'prospect', label: 'Prospect', color: '#8b5cf6', icon: 'lucide:eye' },
37
+ { value: 'customer', label: 'Customer', color: '#22c55e', icon: 'lucide:handshake' },
38
+ { value: 'subscriber', label: 'Subscriber', color: '#10b981', icon: 'lucide:bell' },
39
+ { value: 'churned', label: 'Churned', color: '#ef4444', icon: 'lucide:user-x' },
40
+ { value: 'other', label: 'Other', color: '#94a3b8', icon: 'lucide:circle' },
41
+ ]
42
+
43
+ export const ENTITY_SOURCE_DEFAULTS: DictionaryDefault[] = [
44
+ { value: 'linkedin', label: 'LinkedIn', color: '#0a66c2', icon: 'lucide:linkedin' },
45
+ { value: 'email', label: 'Email', color: '#3b82f6', icon: 'lucide:mail' },
46
+ { value: 'web_form', label: 'Web form', color: '#22c55e', icon: 'lucide:globe' },
47
+ { value: 'referral', label: 'Referral', color: '#8b5cf6', icon: 'lucide:users' },
48
+ { value: 'customer_referral', label: 'Customer referral', color: '#22c55e', icon: 'lucide:thumbs-up' },
49
+ { value: 'partner_referral', label: 'Partner referral', color: '#3b82f6', icon: 'lucide:handshake' },
50
+ { value: 'event', label: 'Conference / Event', color: '#f59e0b', icon: 'lucide:calendar' },
51
+ { value: 'cold_outreach', label: 'Cold outreach', color: '#94a3b8', icon: 'lucide:phone' },
52
+ { value: 'facebook', label: 'Facebook', color: '#1877f2', icon: 'lucide:facebook' },
53
+ { value: 'typeform', label: 'Typeform', color: '#262627', icon: 'lucide:file-text' },
54
+ { value: 'other', label: 'Other', color: '#64748b', icon: 'lucide:circle' },
55
+ ]
56
+
57
+ export const ADDRESS_TYPE_DEFAULTS: DictionaryDefault[] = [
58
+ { value: 'office', label: 'Office', color: '#3b82f6', icon: 'lucide:building' },
59
+ { value: 'work', label: 'Work', color: '#6366f1', icon: 'lucide:briefcase' },
60
+ { value: 'billing', label: 'Billing', color: '#f97316', icon: 'lucide:wallet' },
61
+ { value: 'shipping', label: 'Shipping', color: '#22c55e', icon: 'lucide:truck' },
62
+ { value: 'home', label: 'Home', color: '#10b981', icon: 'lucide:map-pin' },
63
+ ]
64
+
65
+ export const ACTIVITY_TYPE_DEFAULTS: DictionaryDefault[] = [
66
+ { value: 'call', label: 'Call', color: '#2563eb', icon: 'lucide:phone-call' },
67
+ { value: 'email', label: 'Email', color: '#16a34a', icon: 'lucide:mail' },
68
+ { value: 'event', label: 'Event', color: '#6366f1', icon: 'lucide:calendar' },
69
+ { value: 'meeting', label: 'Meeting', color: '#f59e0b', icon: 'lucide:users' },
70
+ { value: 'note', label: 'Note', color: '#a855f7', icon: 'lucide:notebook' },
71
+ { value: 'task', label: 'Task', color: '#ef4444', icon: 'lucide:check-square' },
72
+ ]
73
+
74
+ export const INTERACTION_STATUS_DEFAULTS: DictionaryDefault[] = [
75
+ { value: 'planned', label: 'Planned', color: '#2563eb', icon: 'lucide:circle' },
76
+ { value: 'in_progress', label: 'In progress', color: '#f59e0b', icon: 'lucide:activity' },
77
+ { value: 'waiting', label: 'Waiting / blocked', color: '#a855f7', icon: 'lucide:pause-circle' },
78
+ { value: 'done', label: 'Done', color: '#22c55e', icon: 'lucide:check-circle' },
79
+ { value: 'canceled', label: 'Canceled', color: '#6b7280', icon: 'lucide:x-circle' },
80
+ ]
81
+
82
+ export const JOB_TITLE_DEFAULTS: DictionaryDefault[] = [
83
+ { value: 'Director of Operations', label: 'Director of Operations', color: '#f97316', icon: 'lucide:settings' },
84
+ { value: 'VP of Partnerships', label: 'VP of Partnerships', color: '#6366f1', icon: 'lucide:users' },
85
+ { value: 'Founder & Principal', label: 'Founder & Principal', color: '#ec4899', icon: 'lucide:star' },
86
+ { value: 'Senior Project Manager', label: 'Senior Project Manager', color: '#0ea5e9', icon: 'lucide:clipboard-list' },
87
+ { value: 'Chief Revenue Officer', label: 'Chief Revenue Officer', color: '#8b5cf6', icon: 'lucide:bar-chart-3' },
88
+ { value: 'Director of Retail Partnerships', label: 'Director of Retail Partnerships', color: '#f59e0b', icon: 'lucide:shopping-bag' },
89
+ ]
90
+
91
+ export const INDUSTRY_DEFAULTS: DictionaryDefault[] = [
92
+ { value: 'Renewable Energy', label: 'Renewable Energy' },
93
+ { value: 'Software', label: 'Software' },
94
+ { value: 'Interior Design', label: 'Interior Design' },
95
+ { value: 'SaaS', label: 'SaaS' },
96
+ { value: 'E-commerce', label: 'E-commerce' },
97
+ { value: 'Healthcare', label: 'Healthcare' },
98
+ { value: 'Manufacturing', label: 'Manufacturing' },
99
+ { value: 'Logistics', label: 'Logistics' },
100
+ { value: 'Financial Services', label: 'Financial Services' },
101
+ { value: 'Retail', label: 'Retail' },
102
+ { value: 'Hospitality', label: 'Hospitality' },
103
+ { value: 'Energy', label: 'Energy' },
104
+ { value: 'Media', label: 'Media' },
105
+ ]
106
+
107
+ export const TEMPERATURE_DEFAULTS: DictionaryDefault[] = [
108
+ { value: 'hot', label: 'Hot', color: '#ef4444', icon: 'lucide:flame' },
109
+ { value: 'high', label: 'High', color: '#f59e0b', icon: 'lucide:trending-up' },
110
+ { value: 'medium', label: 'Medium', color: '#8b5cf6', icon: 'lucide:sparkles' },
111
+ { value: 'low', label: 'Low', color: '#64748b', icon: 'lucide:clock' },
112
+ { value: 'cold', label: 'Cold', color: '#94a3b8', icon: 'lucide:snowflake' },
113
+ ]
114
+
115
+ export const PERSON_COMPANY_ROLE_DEFAULTS = [
116
+ { value: 'decision_maker', label: 'Decision maker', color: '#f59e0b', icon: 'lucide:crown' },
117
+ { value: 'influencer', label: 'Influencer', color: '#8b5cf6', icon: 'lucide:sparkles' },
118
+ { value: 'budget_holder', label: 'Budget holder', color: '#3b82f6', icon: 'lucide:wallet' },
119
+ { value: 'technical_evaluator', label: 'Technical evaluator', color: '#22c55e', icon: 'lucide:wrench' },
120
+ { value: 'primary_contact', label: 'Primary contact', color: '#0ea5e9', icon: 'lucide:star' },
121
+ { value: 'end_user', label: 'End user', color: '#64748b', icon: 'lucide:user' },
122
+ ]
123
+
124
+ export const CUSTOMER_DICTIONARY_DEFAULTS: Record<string, readonly DictionaryDefault[]> = {
125
+ status: ENTITY_STATUS_DEFAULTS,
126
+ source: ENTITY_SOURCE_DEFAULTS,
127
+ lifecycle_stage: ENTITY_LIFECYCLE_STAGE_DEFAULTS,
128
+ address_type: ADDRESS_TYPE_DEFAULTS,
129
+ activity_type: ACTIVITY_TYPE_DEFAULTS,
130
+ interaction_status: INTERACTION_STATUS_DEFAULTS,
131
+ job_title: JOB_TITLE_DEFAULTS,
132
+ deal_status: DEAL_STATUS_DEFAULTS,
133
+ pipeline_stage: PIPELINE_STAGE_DEFAULTS,
134
+ industry: INDUSTRY_DEFAULTS,
135
+ temperature: TEMPERATURE_DEFAULTS,
136
+ person_company_role: PERSON_COMPANY_ROLE_DEFAULTS,
137
+ }
@@ -0,0 +1,73 @@
1
+ import type { AwilixContainer } from 'awilix'
2
+ import type { Locale } from '@open-mercato/shared/lib/i18n/config'
3
+ import { loadDictionary } from '@open-mercato/shared/lib/i18n/server'
4
+ import { createFallbackTranslator } from '@open-mercato/shared/lib/i18n/translate'
5
+ import { getTranslationOverlayPlugin } from '@open-mercato/shared/lib/localization/overlay-plugin'
6
+ import { createLogger } from '@open-mercato/shared/lib/logger'
7
+ import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
8
+ import { CUSTOMER_DICTIONARY_DEFAULTS } from './dictionaryDefaults'
9
+
10
+ const logger = createLogger('customers').child({ component: 'dictionary-labels' })
11
+
12
+ type DictionaryLabelEntry = {
13
+ id: string
14
+ value: string
15
+ label: string
16
+ organizationId: string
17
+ }
18
+
19
+ export function customerDictionaryLabelKey(kind: string, value: string): string {
20
+ const token = value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '')
21
+ return `customers.dictionaries.defaults.${kind}.${token}`
22
+ }
23
+
24
+ export async function localizeCustomerDictionaryEntries<T extends DictionaryLabelEntry>(
25
+ entries: T[],
26
+ options: { kind: string; locale: string; tenantId: string; container: AwilixContainer },
27
+ ): Promise<T[]> {
28
+ if (!entries.length) return []
29
+ const dictionaryLocale = options.locale.toLowerCase().split('-')[0] as Locale
30
+ const translate = createFallbackTranslator(await loadDictionary(dictionaryLocale))
31
+ const defaults = Object.prototype.hasOwnProperty.call(CUSTOMER_DICTIONARY_DEFAULTS, options.kind)
32
+ ? CUSTOMER_DICTIONARY_DEFAULTS[options.kind]
33
+ : []
34
+ const localized = entries.map((entry) => {
35
+ const seeded = defaults.find((candidate) => candidate.value === entry.value && candidate.label === entry.label)
36
+ const renewalQuarter = options.kind === 'renewal_quarter' ? /^(\d{4})_q([1-4])$/.exec(entry.value) : null
37
+ const label = renewalQuarter && entry.label === `Q${renewalQuarter[2]} ${renewalQuarter[1]}`
38
+ ? translate('customers.dictionaries.defaults.renewal_quarter.label', entry.label, {
39
+ year: renewalQuarter[1], quarter: renewalQuarter[2],
40
+ })
41
+ : seeded ? translate(customerDictionaryLabelKey(options.kind, seeded.value), entry.label) : entry.label
42
+ return {
43
+ ...entry,
44
+ label,
45
+ }
46
+ })
47
+ const { overlay } = getTranslationOverlayPlugin()
48
+ if (!overlay) return localized
49
+
50
+ const organizationIds = new Set(localized.map((entry) => entry.organizationId))
51
+ const labelsById = new Map<string, string>()
52
+ for (const organizationId of organizationIds) {
53
+ const items = localized.filter((entry) => entry.organizationId === organizationId)
54
+ try {
55
+ const translated = await overlay(items.map(({ id, label }) => ({ id, label })), {
56
+ entityType: 'customers:customer_dictionary_entry',
57
+ locale: options.locale,
58
+ tenantId: options.tenantId,
59
+ organizationId,
60
+ container: options.container,
61
+ })
62
+ for (const item of translated) {
63
+ if (typeof item.id === 'string' && typeof item.label === 'string' && item.label.trim()) {
64
+ labelsById.set(item.id, item.label)
65
+ }
66
+ }
67
+ } catch (error) {
68
+ getTelemetryRuntime()?.reportError(error, { module: 'customers', code: 'customers.dictionary_translation_failed' })
69
+ logger.warn('Translation overlay failed', { error })
70
+ }
71
+ }
72
+ return localized.map((entry) => ({ ...entry, label: labelsById.get(entry.id) ?? entry.label }))
73
+ }
@@ -63,6 +63,7 @@ export function mapInteractionRecordToTodoSummary(interaction: InteractionRecord
63
63
  todoId: interaction.id,
64
64
  todoSource: CUSTOMER_INTERACTION_TASK_SOURCE,
65
65
  createdAt: interaction.createdAt,
66
+ updatedAt: interaction.updatedAt,
66
67
  title: interaction.title ?? null,
67
68
  isDone: interaction.status === 'done',
68
69
  status: interaction.status,
@@ -0,0 +1,5 @@
1
+ export const translatableFields: Record<string, string[]> = {
2
+ 'customers:customer_dictionary_entry': ['label'],
3
+ }
4
+
5
+ export default translatableFields
@@ -7,6 +7,19 @@ export const metadata: ModuleInfo = {
7
7
  description: 'EU Deforestation Regulation compliance: product commodity mappings, supplier origin evidence, due diligence statements.',
8
8
  author: 'Open Mercato Team',
9
9
  license: 'MIT',
10
+ /**
11
+ * `catalog` is a HARD dependency, and saying so is the fix.
12
+ *
13
+ * `data/enrichers.ts` reads `E.catalog.catalog_product` at module scope, so with `catalog` disabled
14
+ * `E.catalog` is undefined and the read throws while the enricher registry is being imported — which takes
15
+ * `/api/auth/login` with it. An installation shaped as a CRM rather than a shop could not reach its own login
16
+ * screen, and the error named a property rather than a module.
17
+ *
18
+ * Declared rather than defended with optional chaining. This module exists to map PRODUCTS to deforestation
19
+ * compliance evidence, so without a catalogue it has nothing to work on at all — the honest answer is that it
20
+ * cannot be enabled, which the generator now says before anybody starts the app rather than after.
21
+ */
22
+ requires: ['catalog'],
10
23
  ejectable: true,
11
24
  }
12
25
 
@@ -646,8 +646,9 @@ const documentUpdateFieldsSchema = z
646
646
  currencyCode: currencyCodeSchema.optional(),
647
647
  // Order-only columns — SalesQuote declares none of them. Declared here
648
648
  // because the create schema declares them and a caller reusing its create
649
- // payload on an update otherwise lost them silently; `applyDocumentUpdate`
650
- // rejects each with a 400 on a quote rather than dropping it.
649
+ // payload on an update otherwise lost them silently. `applyDocumentUpdate`
650
+ // rejects them on a quote, except `totalsMode`, which is ignored to preserve
651
+ // quote-create compatibility without enabling external quote amounts.
651
652
  exchangeRate: z.union([z.null(), exchangeRateSchema]).optional(),
652
653
  paymentStatusEntryId: z.string().uuid().nullable().optional(),
653
654
  fulfillmentStatusEntryId: z.string().uuid().nullable().optional(),
@@ -5894,7 +5895,6 @@ const updateQuoteCommand: CommandHandler<
5894
5895
  },
5895
5896
  async execute(rawInput, ctx) {
5896
5897
  const parsed = documentUpdateSchema.parse(rawInput ?? {});
5897
- await assertAmountsModeUnsupportedOnQuote([parsed]);
5898
5898
  documentEditSchema.parse(rawInput ?? {});
5899
5899
  const em = (ctx.container.resolve("em") as EntityManager).fork();
5900
5900
  let quote!: SalesQuote;
@@ -7,7 +7,8 @@ import { buildOptimisticLockHeader } from "@open-mercato/ui/backend/utils/optimi
7
7
  import { deleteCrud } from "@open-mercato/ui/backend/utils/crud";
8
8
  import { normalizeCrudServerError } from "@open-mercato/ui/backend/utils/serverErrors";
9
9
  import { ErrorMessage, LoadingMessage, TabEmptyState } from "@open-mercato/ui/backend/detail";
10
- import { Button } from "@open-mercato/ui/primitives/button";
10
+ import { IconButton } from "@open-mercato/ui/primitives/icon-button";
11
+ import { SimpleTooltip } from "@open-mercato/ui/primitives/tooltip";
11
12
  import { flash } from "@open-mercato/ui/backend/FlashMessages";
12
13
  import { Pencil, Trash2 } from "lucide-react";
13
14
  import {
@@ -41,6 +42,53 @@ import { createLogger } from '@open-mercato/shared/lib/logger'
41
42
 
42
43
  const logger = createLogger('sales')
43
44
 
45
+ function DisabledActionHint({
46
+ label,
47
+ reason,
48
+ children,
49
+ }: {
50
+ label: string
51
+ reason?: string
52
+ children: React.ReactNode
53
+ }) {
54
+ const [open, setOpen] = React.useState(false)
55
+ const touchOpenRef = React.useRef(false)
56
+ if (!reason) return <>{children}</>
57
+ return (
58
+ <SimpleTooltip
59
+ content={reason}
60
+ open={open}
61
+ onOpenChange={(nextOpen) => {
62
+ if (nextOpen || !touchOpenRef.current) setOpen(nextOpen)
63
+ }}
64
+ >
65
+ <span
66
+ className="inline-flex rounded-md focus-visible:outline-none focus-visible:shadow-focus"
67
+ tabIndex={0}
68
+ aria-label={`${label}: ${reason}`}
69
+ onClick={(event) => event.stopPropagation()}
70
+ onPointerDown={(event) => {
71
+ if (event.pointerType !== 'touch') return
72
+ touchOpenRef.current = true
73
+ setOpen(true)
74
+ event.currentTarget.focus()
75
+ }}
76
+ onBlur={() => {
77
+ touchOpenRef.current = false
78
+ setOpen(false)
79
+ }}
80
+ onKeyDown={(event) => {
81
+ if (event.key !== 'Escape') return
82
+ touchOpenRef.current = false
83
+ setOpen(false)
84
+ }}
85
+ >
86
+ {children}
87
+ </span>
88
+ </SimpleTooltip>
89
+ )
90
+ }
91
+
44
92
  type ResolvedUnitPriceReference = {
45
93
  grossPerReference: number;
46
94
  referenceUnitCode: string;
@@ -932,11 +980,13 @@ export function SalesDocumentItemsSection({
932
980
  {discount.amount !== null ? (
933
981
  <span className="font-mono text-sm">
934
982
  {t(
935
- "sales.documents.items.table.discountAmount",
936
- "−{{value}}",
983
+ discount.amount < 0
984
+ ? "sales.documents.items.table.markupAmount"
985
+ : "sales.documents.items.table.discountAmount",
986
+ discount.amount < 0 ? "+{{value}}" : "−{{value}}",
937
987
  {
938
988
  value: formatMoney(
939
- discount.amount,
989
+ Math.abs(discount.amount),
940
990
  item.currencyCode ??
941
991
  currencyCode ??
942
992
  undefined,
@@ -1005,30 +1055,39 @@ export function SalesDocumentItemsSection({
1005
1055
  })}
1006
1056
  <td className="px-3 py-3">
1007
1057
  <div className="flex items-center gap-2 justify-end">
1008
- <Button
1009
- size="icon"
1010
- variant="ghost"
1011
- className="h-8 w-8"
1012
- aria-label={t('ui.actions.edit', 'Edit')}
1013
- disabled={amountsReadOnly}
1014
- onClick={(event) => {
1015
- event.stopPropagation();
1016
- handleEdit(item);
1017
- }}
1058
+ <DisabledActionHint
1059
+ label={t('ui.actions.edit', 'Edit')}
1060
+ reason={amountsReadOnly
1061
+ ? t('sales.documents.amountsExternalHint', 'Totals on this document come from an external system and are stored as supplied.')
1062
+ : undefined}
1018
1063
  >
1019
- <Pencil className="h-4 w-4" />
1020
- </Button>
1021
- <span
1022
- title={amountsReadOnly
1064
+ <IconButton
1065
+ type="button"
1066
+ size="default"
1067
+ variant="ghost"
1068
+ aria-label={t('ui.actions.edit', 'Edit')}
1069
+ disabled={amountsReadOnly}
1070
+ onClick={(event) => {
1071
+ event.stopPropagation();
1072
+ handleEdit(item);
1073
+ }}
1074
+ >
1075
+ <Pencil className="h-4 w-4" />
1076
+ </IconButton>
1077
+ </DisabledActionHint>
1078
+ <DisabledActionHint
1079
+ label={t('ui.actions.delete', 'Delete')}
1080
+ reason={amountsReadOnly
1023
1081
  ? t('sales.documents.amountsExternalHint', 'Totals on this document come from an external system and are stored as supplied.')
1024
1082
  : kind === 'order' && items.length === 1
1025
1083
  ? t('sales.documents.items.errorDeleteLast', 'An order must contain at least one line item.')
1026
1084
  : undefined}
1027
1085
  >
1028
- <Button
1029
- size="icon"
1086
+ <IconButton
1087
+ type="button"
1088
+ size="default"
1030
1089
  variant="ghost"
1031
- className="h-8 w-8 text-destructive"
1090
+ className="text-destructive"
1032
1091
  aria-label={t('ui.actions.delete', 'Delete')}
1033
1092
  disabled={amountsReadOnly || (kind === 'order' && items.length === 1)}
1034
1093
  onClick={(event) => {
@@ -1037,8 +1096,8 @@ export function SalesDocumentItemsSection({
1037
1096
  }}
1038
1097
  >
1039
1098
  <Trash2 className="h-4 w-4" />
1040
- </Button>
1041
- </span>
1099
+ </IconButton>
1100
+ </DisabledActionHint>
1042
1101
  </div>
1043
1102
  </td>
1044
1103
  </tr>
@@ -73,10 +73,10 @@ export function resolveLineDiscountDisplay(
73
73
  ): LineDiscountDisplay | null {
74
74
  const amount = normalizeNumber(line.discountAmount, 0)
75
75
  const percent = normalizeNumber(line.discountPercent, 0)
76
- if (amount > 0) {
76
+ if (amount !== 0) {
77
77
  return {
78
78
  amount,
79
- percent: percentAccountsForAmount(line, percent, amount) ? percent : null,
79
+ percent: amount > 0 && percentAccountsForAmount(line, percent, amount) ? percent : null,
80
80
  }
81
81
  }
82
82
  if (percent > 0) return { amount: null, percent }
@@ -1091,6 +1091,7 @@
1091
1091
  "sales.documents.items.table.discountAmount": "−{{value}}",
1092
1092
  "sales.documents.items.table.discountPercent": "{{value}} %",
1093
1093
  "sales.documents.items.table.gross": "brutto",
1094
+ "sales.documents.items.table.markupAmount": "+{{value}}",
1094
1095
  "sales.documents.items.table.net": "netto",
1095
1096
  "sales.documents.items.table.product": "Produkt",
1096
1097
  "sales.documents.items.table.quantity": "Menge",
@@ -1091,6 +1091,7 @@
1091
1091
  "sales.documents.items.table.discountAmount": "−{{value}}",
1092
1092
  "sales.documents.items.table.discountPercent": "{{value}}%",
1093
1093
  "sales.documents.items.table.gross": "gross",
1094
+ "sales.documents.items.table.markupAmount": "+{{value}}",
1094
1095
  "sales.documents.items.table.net": "net",
1095
1096
  "sales.documents.items.table.product": "Product",
1096
1097
  "sales.documents.items.table.quantity": "Qty",
@@ -1091,6 +1091,7 @@
1091
1091
  "sales.documents.items.table.discountAmount": "−{{value}}",
1092
1092
  "sales.documents.items.table.discountPercent": "{{value}} %",
1093
1093
  "sales.documents.items.table.gross": "bruto",
1094
+ "sales.documents.items.table.markupAmount": "+{{value}}",
1094
1095
  "sales.documents.items.table.net": "neto",
1095
1096
  "sales.documents.items.table.product": "Producto",
1096
1097
  "sales.documents.items.table.quantity": "Cant.",
@@ -1091,6 +1091,7 @@
1091
1091
  "sales.documents.items.table.discountAmount": "−{{value}}",
1092
1092
  "sales.documents.items.table.discountPercent": "{{value}}%",
1093
1093
  "sales.documents.items.table.gross": "세금 포함",
1094
+ "sales.documents.items.table.markupAmount": "+{{value}}",
1094
1095
  "sales.documents.items.table.net": "세금 제외",
1095
1096
  "sales.documents.items.table.product": "상품",
1096
1097
  "sales.documents.items.table.quantity": "수량",
@@ -1091,6 +1091,7 @@
1091
1091
  "sales.documents.items.table.discountAmount": "−{{value}}",
1092
1092
  "sales.documents.items.table.discountPercent": "{{value}}%",
1093
1093
  "sales.documents.items.table.gross": "brutto",
1094
+ "sales.documents.items.table.markupAmount": "+{{value}}",
1094
1095
  "sales.documents.items.table.net": "netto",
1095
1096
  "sales.documents.items.table.product": "Produkt",
1096
1097
  "sales.documents.items.table.quantity": "Ilość",
@@ -3,6 +3,7 @@ import { z } from 'zod'
3
3
  import type { EntityManager } from '@mikro-orm/postgresql'
4
4
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
5
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
6
+ import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
6
7
  import { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
7
8
  import { runRouteMutationGuards } from '@open-mercato/shared/lib/crud/route-mutation-guard'
8
9
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
@@ -74,7 +75,8 @@ async function resolveAccessRequestContext(req: Request): Promise<AccessRequestC
74
75
  if (!requesterUserId) {
75
76
  throw new CrudHttpError(401, { error: translate('staff.errors.unauthorized', 'Unauthorized') })
76
77
  }
77
- if (!auth.orgId) {
78
+ const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })
79
+ if (!scope.tenantId || !scope.selectedId || scope.selectionRejected === true) {
78
80
  throw new CrudHttpError(400, {
79
81
  error: translate('staff.errors.missingScope', 'Missing tenant or organization scope.'),
80
82
  })
@@ -82,8 +84,8 @@ async function resolveAccessRequestContext(req: Request): Promise<AccessRequestC
82
84
  return {
83
85
  container,
84
86
  em: (container.resolve('em') as EntityManager).fork(),
85
- tenantId: auth.tenantId,
86
- organizationId: auth.orgId,
87
+ tenantId: scope.tenantId,
88
+ organizationId: scope.selectedId,
87
89
  requesterUserId,
88
90
  requesterEmail: typeof auth.email === 'string' && auth.email.trim().length > 0 ? auth.email : null,
89
91
  translate,
@@ -155,17 +157,14 @@ async function resolveRequesterName(context: AccessRequestContext): Promise<stri
155
157
  async function POST(req: Request) {
156
158
  try {
157
159
  const context = await resolveAccessRequestContext(req)
158
- // The grant list the interceptors and the mutation-guard registry gate on
159
- // comes from the module's single RBAC authority, so it is always an array:
160
- // the nullable read it replaces could not say whether an empty answer meant
161
- // "no grants" or "could not ask". Authorization itself stays with the
162
- // declarative `requireFeatures` guard in `metadata`.
163
- const grantedFeatures = (
164
- await resolveFeatureAccess(context.container, context.requesterUserId, [VIEW_FEATURE], {
165
- tenantId: context.tenantId,
166
- organizationId: context.organizationId,
167
- })
168
- ).grantedFeatures
160
+ const featureAccess = await resolveFeatureAccess(context.container, context.requesterUserId, [VIEW_FEATURE], {
161
+ tenantId: context.tenantId,
162
+ organizationId: context.organizationId,
163
+ })
164
+ if (!featureAccess.allowed) {
165
+ throw new CrudHttpError(403, { error: context.translate('staff.errors.forbidden', 'Forbidden') })
166
+ }
167
+ const grantedFeatures = featureAccess.grantedFeatures
169
168
 
170
169
  const interceptors = await runTimesheetInterceptors({
171
170
  request: req,
@@ -3,6 +3,7 @@ import { z } from 'zod'
3
3
  import type { EntityManager } from '@mikro-orm/postgresql'
4
4
  import { makeCrudRoute, type CrudCtx } from '@open-mercato/shared/lib/crud/factory'
5
5
  import { staffTimeProjectCrudEvents } from '../../../lib/crud'
6
+ import { PROJECT_SCOPE_TENANT_HEADER, PROJECT_SCOPE_ORGANIZATION_HEADER, type ResolvedProjectAccessScope } from '../../../lib/time-tracking-ui/projectAccessScopeHeaders'
6
7
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
7
8
  import { resolveCrudRecordId, parseScopedCommandInput } from '@open-mercato/shared/lib/api/scoped'
8
9
  import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
@@ -500,24 +501,28 @@ async function buildAccessDenialResponse(): Promise<Response> {
500
501
  * `reason` discriminator carries only "you are not a member of this project",
501
502
  * never the project or customer it is refusing to describe.
502
503
  */
503
- async function resolveProjectAccessDenial(req: Request): Promise<Response | null> {
504
+ async function resolveProjectAccessDenial(req: Request): Promise<{ denial: Response | null; scope: ResolvedProjectAccessScope | null }> {
505
+ const unresolved = { denial: null, scope: null }
504
506
  const url = new URL(req.url)
505
507
  const rawIds = url.searchParams.get('ids') ?? url.searchParams.get('id')
506
- if (!isIdsParamProvided(rawIds)) return null
508
+ if (!isIdsParamProvided(rawIds)) return unresolved
507
509
  const requestedIds = parseIdsParam(rawIds)
508
510
  // Malformed ids match nothing on their own; the factory answers an empty list.
509
- if (requestedIds.length === 0) return null
511
+ if (requestedIds.length === 0) return unresolved
510
512
 
511
513
  try {
512
514
  const container = await createRequestContainer()
513
515
  const auth = await getAuthFromRequest(req)
514
516
  // Unauthenticated and unscoped requests are the factory's to reject.
515
- if (!auth) return null
517
+ if (!auth) return unresolved
516
518
  const orgScope = await resolveOrganizationScopeForRequest({ container, auth, request: req })
517
519
  const tenantId = orgScope?.tenantId ?? auth.tenantId ?? null
518
520
  const organizationId = resolveSingleOrganizationIdOrDeny(orgScope, auth) ?? null
519
- if (!tenantId || !organizationId) return null
521
+ if (!tenantId || !organizationId) return unresolved
520
522
 
523
+ const scope = orgScope?.tenantId && orgScope.selectedId && orgScope.selectionRejected !== true
524
+ ? { tenantId: orgScope.tenantId, organizationId: orgScope.selectedId }
525
+ : null
521
526
  const access = await resolveRequestProjectAccess(req, () =>
522
527
  loadProjectAccess(
523
528
  container,
@@ -526,20 +531,24 @@ async function resolveProjectAccessDenial(req: Request): Promise<Response | null
526
531
  organizationId,
527
532
  ),
528
533
  )
529
- if (access.canManageAll) return null
530
- if (requestedIds.some((id) => access.projectIds.includes(id))) return null
531
- return await buildAccessDenialResponse()
534
+ if (access.canManageAll) return { denial: null, scope }
535
+ if (requestedIds.some((id) => access.projectIds.includes(id))) return { denial: null, scope }
536
+ return { denial: await buildAccessDenialResponse(), scope }
532
537
  } catch (err) {
533
538
  // Fail closed: an access decision that could not be made is a denial.
534
539
  logger.error('staff.timesheets.time-projects denial probe failed', { err })
535
- return await buildAccessDenialResponse()
540
+ return { denial: await buildAccessDenialResponse(), scope: null }
536
541
  }
537
542
  }
538
543
 
539
544
  export async function GET(req: Request): Promise<Response> {
540
- const denial = await resolveProjectAccessDenial(req)
541
- if (denial) return denial
542
- return crud.GET(req)
545
+ const { denial, scope } = await resolveProjectAccessDenial(req)
546
+ const response = denial ?? await crud.GET(req)
547
+ if (scope) {
548
+ response.headers.set(PROJECT_SCOPE_TENANT_HEADER, scope.tenantId)
549
+ response.headers.set(PROJECT_SCOPE_ORGANIZATION_HEADER, scope.organizationId)
550
+ }
551
+ return response
543
552
  }
544
553
 
545
554
  export const POST = crud.POST
@@ -611,7 +620,7 @@ export const openApi = {
611
620
  ? {
612
621
  GET: {
613
622
  ...baseOpenApi.methods.GET,
614
- description: `${baseOpenApi.methods.GET.description ?? ''} Rows are intersected with the caller's project access: without staff.timesheets.projects.manage only projects the caller is an active member of are returned. Asking for a single id the caller may not see answers 404 with reason=${NO_PROJECT_ACCESS_REASON} and never names the project or its customer; the same 404 covers ids that do not exist, so the endpoint cannot be used to enumerate projects.`.trim(),
623
+ description: `${baseOpenApi.methods.GET.description ?? ''} Project-specific reads include optional x-om-project-scope-tenant-id and x-om-project-scope-organization-id response headers when the server resolves a scope. These identify the request scope for client hydration; a missing header provides no scope identity. Rows are intersected with the caller's project access: without staff.timesheets.projects.manage only projects the caller is an active member of are returned. Asking for a single id the caller may not see answers 404 with reason=${NO_PROJECT_ACCESS_REASON} and never names the project or its customer; the same 404 covers ids that do not exist, so the endpoint cannot be used to enumerate projects.`.trim(),
615
624
  responses: [
616
625
  ...(baseOpenApi.methods.GET.responses ?? []),
617
626
  {