@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/cart/api/current/lines/bulk/route.js +1 -1
- package/dist/modules/cart/api/current/lines/bulk/route.js.map +2 -2
- package/dist/modules/cart/api/current/lines/route.js +1 -1
- package/dist/modules/cart/api/current/lines/route.js.map +2 -2
- package/dist/modules/cart/commands/lines.js +5 -10
- package/dist/modules/cart/commands/lines.js.map +2 -2
- package/dist/modules/cart/lib/currentCartRoute.js +8 -0
- package/dist/modules/cart/lib/currentCartRoute.js.map +3 -3
- package/dist/modules/cart/lib/lineMutationLock.js +52 -0
- package/dist/modules/cart/lib/lineMutationLock.js.map +7 -0
- package/dist/modules/customer_accounts/api/admin/roles.js +25 -2
- package/dist/modules/customer_accounts/api/admin/roles.js.map +2 -2
- package/dist/modules/customer_accounts/api/admin/users.js +24 -7
- package/dist/modules/customer_accounts/api/admin/users.js.map +2 -2
- package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js +77 -15
- package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js.map +2 -2
- package/dist/modules/customer_accounts/backend/customer_accounts/users/page.js +12 -1
- package/dist/modules/customer_accounts/backend/customer_accounts/users/page.js.map +2 -2
- package/dist/modules/customer_accounts/data/validators.js +2 -1
- package/dist/modules/customer_accounts/data/validators.js.map +2 -2
- package/dist/modules/customer_accounts/lib/adminOrganizationScope.js +46 -0
- package/dist/modules/customer_accounts/lib/adminOrganizationScope.js.map +7 -0
- package/dist/modules/customers/api/customer-dictionary-entries/route.js +86 -0
- package/dist/modules/customers/api/customer-dictionary-entries/route.js.map +7 -0
- package/dist/modules/customers/api/dictionaries/[kind]/route.js +29 -7
- package/dist/modules/customers/api/dictionaries/[kind]/route.js.map +2 -2
- package/dist/modules/customers/cli.js +28 -110
- package/dist/modules/customers/cli.js.map +3 -3
- package/dist/modules/customers/components/DictionarySettings.js +1 -1
- package/dist/modules/customers/components/DictionarySettings.js.map +1 -1
- package/dist/modules/customers/components/detail/ManageTagsDialog.js +1 -1
- package/dist/modules/customers/components/detail/ManageTagsDialog.js.map +2 -2
- package/dist/modules/customers/components/detail/TaskDialog.js +14 -1
- package/dist/modules/customers/components/detail/TaskDialog.js.map +2 -2
- package/dist/modules/customers/components/detail/TaskForm.js +1 -16
- package/dist/modules/customers/components/detail/TaskForm.js.map +2 -2
- package/dist/modules/customers/components/detail/TasksSection.js +38 -10
- package/dist/modules/customers/components/detail/TasksSection.js.map +2 -2
- package/dist/modules/customers/components/detail/hooks/useCustomerDictionary.js +12 -5
- package/dist/modules/customers/components/detail/hooks/useCustomerDictionary.js.map +2 -2
- package/dist/modules/customers/components/detail/hooks/useInteractions.js +3 -13
- package/dist/modules/customers/components/detail/hooks/useInteractions.js.map +2 -2
- package/dist/modules/customers/lib/dictionaryDefaults.js +134 -0
- package/dist/modules/customers/lib/dictionaryDefaults.js.map +7 -0
- package/dist/modules/customers/lib/dictionaryLabels.js +59 -0
- package/dist/modules/customers/lib/dictionaryLabels.js.map +7 -0
- package/dist/modules/customers/lib/interactionCompatibility.js +1 -0
- package/dist/modules/customers/lib/interactionCompatibility.js.map +2 -2
- package/dist/modules/customers/translations.js +9 -0
- package/dist/modules/customers/translations.js.map +7 -0
- package/dist/modules/eudr/index.js +13 -0
- package/dist/modules/eudr/index.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +3 -3
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/components/documents/ItemsSection.js +75 -21
- package/dist/modules/sales/components/documents/ItemsSection.js.map +2 -2
- package/dist/modules/sales/components/documents/lineItemUtils.js +2 -2
- package/dist/modules/sales/components/documents/lineItemUtils.js.map +2 -2
- package/dist/modules/staff/api/timesheets/access-requests/route.js +11 -5
- package/dist/modules/staff/api/timesheets/access-requests/route.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-projects/route.js +19 -12
- package/dist/modules/staff/api/timesheets/time-projects/route.js.map +2 -2
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.js +21 -12
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.js.map +2 -2
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js +14 -9
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js.map +2 -2
- package/dist/modules/staff/lib/time-tracking-ui/NoProjectAccess.js +31 -10
- package/dist/modules/staff/lib/time-tracking-ui/NoProjectAccess.js.map +2 -2
- package/dist/modules/staff/lib/time-tracking-ui/projectAccessScope.js +64 -0
- package/dist/modules/staff/lib/time-tracking-ui/projectAccessScope.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.js +13 -0
- package/dist/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.js.map +7 -0
- package/dist/modules/sync_excel/index.js +11 -1
- package/dist/modules/sync_excel/index.js.map +2 -2
- package/dist/modules/translations/di.js +2 -0
- package/dist/modules/translations/di.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/cart/api/current/lines/bulk/route.ts +1 -1
- package/src/modules/cart/api/current/lines/route.ts +1 -1
- package/src/modules/cart/commands/lines.ts +5 -10
- package/src/modules/cart/i18n/de.json +1 -0
- package/src/modules/cart/i18n/en.json +1 -0
- package/src/modules/cart/i18n/es.json +1 -0
- package/src/modules/cart/i18n/ko.json +1 -0
- package/src/modules/cart/i18n/pl.json +1 -0
- package/src/modules/cart/lib/currentCartRoute.ts +8 -0
- package/src/modules/cart/lib/lineMutationLock.ts +59 -0
- package/src/modules/customer_accounts/api/admin/roles.ts +26 -2
- package/src/modules/customer_accounts/api/admin/users.ts +26 -7
- package/src/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.tsx +96 -13
- package/src/modules/customer_accounts/backend/customer_accounts/users/page.tsx +9 -1
- package/src/modules/customer_accounts/data/validators.ts +1 -0
- package/src/modules/customer_accounts/i18n/de.json +10 -0
- package/src/modules/customer_accounts/i18n/en.json +10 -0
- package/src/modules/customer_accounts/i18n/es.json +10 -0
- package/src/modules/customer_accounts/i18n/ko.json +10 -0
- package/src/modules/customer_accounts/i18n/pl.json +10 -0
- package/src/modules/customer_accounts/lib/adminOrganizationScope.ts +71 -0
- package/src/modules/customers/api/customer-dictionary-entries/route.ts +82 -0
- package/src/modules/customers/api/dictionaries/[kind]/route.ts +29 -6
- package/src/modules/customers/cli.ts +23 -124
- package/src/modules/customers/components/DictionarySettings.tsx +1 -1
- package/src/modules/customers/components/detail/ManageTagsDialog.tsx +1 -1
- package/src/modules/customers/components/detail/TaskDialog.tsx +15 -1
- package/src/modules/customers/components/detail/TaskForm.tsx +1 -17
- package/src/modules/customers/components/detail/TasksSection.tsx +44 -14
- package/src/modules/customers/components/detail/hooks/useCustomerDictionary.ts +14 -8
- package/src/modules/customers/components/detail/hooks/useInteractions.ts +3 -4
- package/src/modules/customers/i18n/de.json +81 -0
- package/src/modules/customers/i18n/en.json +81 -0
- package/src/modules/customers/i18n/es.json +81 -0
- package/src/modules/customers/i18n/ko.json +81 -0
- package/src/modules/customers/i18n/pl.json +81 -0
- package/src/modules/customers/lib/dictionaryDefaults.ts +137 -0
- package/src/modules/customers/lib/dictionaryLabels.ts +73 -0
- package/src/modules/customers/lib/interactionCompatibility.ts +1 -0
- package/src/modules/customers/translations.ts +5 -0
- package/src/modules/eudr/index.ts +13 -0
- package/src/modules/sales/commands/documents.ts +3 -3
- package/src/modules/sales/components/documents/ItemsSection.tsx +82 -23
- package/src/modules/sales/components/documents/lineItemUtils.ts +2 -2
- package/src/modules/sales/i18n/de.json +1 -0
- package/src/modules/sales/i18n/en.json +1 -0
- package/src/modules/sales/i18n/es.json +1 -0
- package/src/modules/sales/i18n/ko.json +1 -0
- package/src/modules/sales/i18n/pl.json +1 -0
- package/src/modules/staff/api/timesheets/access-requests/route.ts +13 -14
- package/src/modules/staff/api/timesheets/time-projects/route.ts +22 -13
- package/src/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.tsx +25 -14
- package/src/modules/staff/backend/staff/time-tracking/projects/[id]/page.tsx +14 -13
- package/src/modules/staff/lib/time-tracking-ui/NoProjectAccess.tsx +30 -8
- package/src/modules/staff/lib/time-tracking-ui/projectAccessScope.ts +102 -0
- package/src/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.ts +15 -0
- package/src/modules/sync_excel/index.ts +10 -0
- 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,
|
|
@@ -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
|
|
650
|
-
// rejects
|
|
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 {
|
|
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
|
-
|
|
936
|
-
|
|
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
|
-
<
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
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
|
-
<
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
-
<
|
|
1029
|
-
|
|
1086
|
+
<IconButton
|
|
1087
|
+
type="button"
|
|
1088
|
+
size="default"
|
|
1030
1089
|
variant="ghost"
|
|
1031
|
-
className="
|
|
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
|
-
</
|
|
1041
|
-
</
|
|
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
|
|
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
|
-
|
|
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:
|
|
86
|
-
organizationId:
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
542
|
-
|
|
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
|
{
|