@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/api_keys/api/keys/route.js +15 -1
- package/dist/modules/api_keys/api/keys/route.js.map +2 -2
- package/dist/modules/auth/api/users/route.js +16 -1
- package/dist/modules/auth/api/users/route.js.map +2 -2
- package/dist/modules/auth/backend/users/page.js +13 -2
- package/dist/modules/auth/backend/users/page.js.map +2 -2
- package/dist/modules/catalog/commands/products.js +4 -2
- package/dist/modules/catalog/commands/products.js.map +2 -2
- 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/directory/api/organization-branding/route.js +6 -9
- package/dist/modules/directory/api/organization-branding/route.js.map +2 -2
- package/dist/modules/directory/backend/directory/branding/page.js +15 -3
- package/dist/modules/directory/backend/directory/branding/page.js.map +2 -2
- package/dist/modules/eudr/index.js +13 -0
- package/dist/modules/eudr/index.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/dist/modules/wms/api/listEnrichers.js +3 -1
- package/dist/modules/wms/api/listEnrichers.js.map +2 -2
- package/dist/modules/wms/index.js +1 -1
- package/dist/modules/wms/index.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/api_keys/api/keys/route.ts +15 -1
- package/src/modules/api_keys/i18n/de.json +1 -0
- package/src/modules/api_keys/i18n/en.json +1 -0
- package/src/modules/api_keys/i18n/es.json +1 -0
- package/src/modules/api_keys/i18n/ko.json +1 -0
- package/src/modules/api_keys/i18n/pl.json +1 -0
- package/src/modules/auth/api/users/route.ts +18 -1
- package/src/modules/auth/backend/users/page.tsx +14 -2
- package/src/modules/auth/i18n/de.json +1 -0
- package/src/modules/auth/i18n/en.json +1 -0
- package/src/modules/auth/i18n/es.json +1 -0
- package/src/modules/auth/i18n/ko.json +1 -0
- package/src/modules/auth/i18n/pl.json +1 -0
- package/src/modules/catalog/commands/products.ts +4 -1
- 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/directory/api/organization-branding/route.ts +5 -8
- package/src/modules/directory/backend/directory/branding/page.tsx +15 -3
- package/src/modules/eudr/index.ts +13 -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
- package/src/modules/wms/api/listEnrichers.ts +5 -2
- package/src/modules/wms/index.ts +1 -1
|
@@ -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,
|
|
@@ -17,7 +17,7 @@ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
|
17
17
|
import { Organization } from '@open-mercato/core/modules/directory/data/entities'
|
|
18
18
|
import { organizationUpdateSchema } from '@open-mercato/core/modules/directory/data/validators'
|
|
19
19
|
import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
20
|
-
import {
|
|
20
|
+
import { isExplicitlyEmptyOrganizationScope } from '@open-mercato/shared/lib/auth/organizationScope'
|
|
21
21
|
import '@open-mercato/core/modules/directory/commands/organizations'
|
|
22
22
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
23
23
|
import { getCommandInterceptorHttpRejection } from '@open-mercato/shared/lib/commands/errors'
|
|
@@ -82,15 +82,12 @@ async function resolveCurrentOrganization(req: Request) {
|
|
|
82
82
|
|
|
83
83
|
const container = await createRequestContainer()
|
|
84
84
|
const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
} catch (error) {
|
|
89
|
-
if (isCrudHttpError(error)) {
|
|
90
|
-
return { response: NextResponse.json(error.body, { status: error.status }) }
|
|
85
|
+
if (isExplicitlyEmptyOrganizationScope(scope)) {
|
|
86
|
+
return {
|
|
87
|
+
response: NextResponse.json({ error: translate('api.errors.forbidden', 'Forbidden') }, { status: 403 }),
|
|
91
88
|
}
|
|
92
|
-
throw error
|
|
93
89
|
}
|
|
90
|
+
const organizationId = scope.selectedId
|
|
94
91
|
const tenantId = scope.tenantId ?? auth.tenantId ?? null
|
|
95
92
|
if (!organizationId || !tenantId) {
|
|
96
93
|
return {
|
|
@@ -15,7 +15,8 @@ import { Button } from '@open-mercato/ui/primitives/button'
|
|
|
15
15
|
import { Input } from '@open-mercato/ui/primitives/input'
|
|
16
16
|
import { SwitchField } from '@open-mercato/ui/primitives/switch-field'
|
|
17
17
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
18
|
-
import { useOrganizationScopeDetail } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
|
|
18
|
+
import { useOrganizationScopeDetail, useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
|
|
19
|
+
import { getCurrentOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/organizationEvents'
|
|
19
20
|
|
|
20
21
|
type BrandingPayload = {
|
|
21
22
|
organizationId: string
|
|
@@ -42,6 +43,7 @@ export default function OrganizationBrandingPage() {
|
|
|
42
43
|
const t = useT()
|
|
43
44
|
const queryClient = useQueryClient()
|
|
44
45
|
const { organizationId, tenantId } = useOrganizationScopeDetail()
|
|
46
|
+
const scopeVersion = useOrganizationScopeVersion()
|
|
45
47
|
const [logoUrl, setLogoUrl] = React.useState('')
|
|
46
48
|
const [logoPreserveAspectRatio, setLogoPreserveAspectRatio] = React.useState(false)
|
|
47
49
|
const [selectedFile, setSelectedFile] = React.useState<File | null>(null)
|
|
@@ -67,7 +69,8 @@ export default function OrganizationBrandingPage() {
|
|
|
67
69
|
setLogoUrl(data?.logoUrl ?? '')
|
|
68
70
|
setLogoPreserveAspectRatio(data?.logoPreserveAspectRatio ?? false)
|
|
69
71
|
setSelectedFile(null)
|
|
70
|
-
|
|
72
|
+
if (fileInputRef.current) fileInputRef.current.value = ''
|
|
73
|
+
}, [scopeVersion, organizationId, tenantId, data?.organizationId, data?.logoPreserveAspectRatio, data?.logoUrl])
|
|
71
74
|
|
|
72
75
|
React.useEffect(() => {
|
|
73
76
|
if (!selectedFile || typeof URL === 'undefined') {
|
|
@@ -111,7 +114,13 @@ export default function OrganizationBrandingPage() {
|
|
|
111
114
|
try {
|
|
112
115
|
await runMutation({
|
|
113
116
|
operation: async () => {
|
|
117
|
+
if (getCurrentOrganizationScopeVersion() !== scopeVersion) {
|
|
118
|
+
throw new Error('[internal] Organization scope changed during branding save')
|
|
119
|
+
}
|
|
114
120
|
const uploadedLogoUrl = shouldUpload ? await uploadLogo(data.organizationId) : null
|
|
121
|
+
if (getCurrentOrganizationScopeVersion() !== scopeVersion) {
|
|
122
|
+
throw new Error('[internal] Organization scope changed during branding save')
|
|
123
|
+
}
|
|
115
124
|
const resolvedLogoUrl = uploadedLogoUrl ?? nextLogoUrl ?? logoUrl.trim()
|
|
116
125
|
const response = await withScopedApiRequestHeaders(
|
|
117
126
|
buildOptimisticLockHeader(data.updatedAt),
|
|
@@ -142,12 +151,15 @@ export default function OrganizationBrandingPage() {
|
|
|
142
151
|
hasUpload: shouldUpload,
|
|
143
152
|
},
|
|
144
153
|
})
|
|
154
|
+
if (getCurrentOrganizationScopeVersion() !== scopeVersion) return
|
|
145
155
|
await queryClient.invalidateQueries({ queryKey: ['directory-organization-branding'] })
|
|
156
|
+
if (getCurrentOrganizationScopeVersion() !== scopeVersion) return
|
|
146
157
|
window.dispatchEvent(new Event('om:refresh-sidebar'))
|
|
147
158
|
setSelectedFile(null)
|
|
148
159
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
|
149
160
|
flash(t('directory.branding.flash.saved', 'Organization branding updated'), 'success')
|
|
150
161
|
} catch (err: unknown) {
|
|
162
|
+
if (getCurrentOrganizationScopeVersion() !== scopeVersion) return
|
|
151
163
|
if (surfaceRecordConflict(err, t)) return
|
|
152
164
|
const fallback = t('directory.branding.errors.save', 'Failed to update organization branding')
|
|
153
165
|
const message = err instanceof Error ? err.message : fallback
|
|
@@ -155,7 +167,7 @@ export default function OrganizationBrandingPage() {
|
|
|
155
167
|
} finally {
|
|
156
168
|
setSaving(false)
|
|
157
169
|
}
|
|
158
|
-
}, [data, logoPreserveAspectRatio, logoUrl, queryClient, runMutation, selectedFile, t, uploadLogo])
|
|
170
|
+
}, [data, logoPreserveAspectRatio, logoUrl, queryClient, runMutation, scopeVersion, selectedFile, t, uploadLogo])
|
|
159
171
|
|
|
160
172
|
const handleSubmit = React.useCallback((event: React.FormEvent<HTMLFormElement>) => {
|
|
161
173
|
event.preventDefault()
|
|
@@ -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
|
|
|
@@ -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
|
{
|
|
@@ -15,6 +15,8 @@ import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
|
15
15
|
import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
|
|
16
16
|
import { DEFAULT_BUDGET_WARN_AT_PERCENT, type ProjectBudgetKind } from '../../../../../../lib/time-tracking-ui/ProjectFormSections'
|
|
17
17
|
import { NoProjectAccess } from '../../../../../../lib/time-tracking-ui/NoProjectAccess'
|
|
18
|
+
import { useProjectAccessScope } from '../../../../../../lib/time-tracking-ui/projectAccessScope'
|
|
19
|
+
import { readResolvedProjectAccessScope, type ResolvedProjectAccessScope } from '../../../../../../lib/time-tracking-ui/projectAccessScopeHeaders'
|
|
18
20
|
import {
|
|
19
21
|
buildProjectPayload,
|
|
20
22
|
createProjectFormFields,
|
|
@@ -36,7 +38,7 @@ const NO_PROJECT_ACCESS_REASON = 'no_project_access'
|
|
|
36
38
|
|
|
37
39
|
/** Thrown by the loader so the caller can branch on the guard state. */
|
|
38
40
|
class ProjectAccessDeniedError extends Error {
|
|
39
|
-
constructor() {
|
|
41
|
+
constructor(readonly resolvedScope: ResolvedProjectAccessScope | null) {
|
|
40
42
|
super('[internal] no project access')
|
|
41
43
|
this.name = 'ProjectAccessDeniedError'
|
|
42
44
|
}
|
|
@@ -106,25 +108,28 @@ export default function TimeTrackingProjectEditPage({ params }: { params?: { id?
|
|
|
106
108
|
const t = useT()
|
|
107
109
|
const router = useRouter()
|
|
108
110
|
const scopeVersion = useOrganizationScopeVersion()
|
|
111
|
+
const { scopeKey: projectAccessScopeKey, resolvedScope, resolveScope } = useProjectAccessScope(projectId)
|
|
109
112
|
|
|
110
113
|
const [initialValues, setInitialValues] = React.useState<ProjectFormValues | null>(null)
|
|
111
114
|
const [loading, setLoading] = React.useState(true)
|
|
112
115
|
const [error, setError] = React.useState<string | null>(null)
|
|
113
116
|
const [isNotFound, setIsNotFound] = React.useState(false)
|
|
114
|
-
const [
|
|
117
|
+
const [accessDeniedScopeKey, setAccessDeniedScopeKey] = React.useState<string | null>(null)
|
|
118
|
+
const [loadedScopeKey, setLoadedScopeKey] = React.useState<string | null>(null)
|
|
119
|
+
const accessDenied = accessDeniedScopeKey === projectAccessScopeKey
|
|
115
120
|
|
|
116
|
-
const fetchProject = React.useCallback(async (): Promise<ProjectRecord | null> => {
|
|
121
|
+
const fetchProject = React.useCallback(async (): Promise<{ record: ProjectRecord | null; scope: ResolvedProjectAccessScope | null }> => {
|
|
117
122
|
const call = await apiCall<{ items?: ProjectRecord[]; reason?: unknown }>(
|
|
118
123
|
`/api/staff/timesheets/time-projects?ids=${projectId}&pageSize=1`,
|
|
119
124
|
)
|
|
120
125
|
if (call.status === 404 && call.result?.reason === NO_PROJECT_ACCESS_REASON) {
|
|
121
|
-
throw new ProjectAccessDeniedError()
|
|
126
|
+
throw new ProjectAccessDeniedError(readResolvedProjectAccessScope(call.response))
|
|
122
127
|
}
|
|
123
128
|
if (!call.ok) {
|
|
124
129
|
throw new Error(t('staff.timesheets.projects.errors.load', 'Failed to load project.'))
|
|
125
130
|
}
|
|
126
131
|
const record = Array.isArray(call.result?.items) ? call.result.items[0] : null
|
|
127
|
-
return record ?? null
|
|
132
|
+
return { record: record ?? null, scope: readResolvedProjectAccessScope(call.response) }
|
|
128
133
|
}, [projectId, t])
|
|
129
134
|
|
|
130
135
|
/**
|
|
@@ -137,7 +142,7 @@ export default function TimeTrackingProjectEditPage({ params }: { params?: { id?
|
|
|
137
142
|
const handleCurrencyChanged = React.useCallback(() => {
|
|
138
143
|
if (!projectId) return
|
|
139
144
|
void fetchProject()
|
|
140
|
-
.then((record) => { if (record) setInitialValues(toValues(record)) })
|
|
145
|
+
.then(({ record }) => { if (record) setInitialValues(toValues(record)) })
|
|
141
146
|
.catch(() => {
|
|
142
147
|
// Best effort: the optimistic-lock conflict bar remains the backstop.
|
|
143
148
|
})
|
|
@@ -159,25 +164,30 @@ export default function TimeTrackingProjectEditPage({ params }: { params?: { id?
|
|
|
159
164
|
// it was loaded for. The decision looks at the load this one replaces,
|
|
160
165
|
// settled or not: a version-0 load cancelled by a quick second switch must
|
|
161
166
|
// not let that switch through.
|
|
162
|
-
const lastLoadRef = React.useRef<{ projectId: string; scopeVersion: number } | null>(null)
|
|
167
|
+
const lastLoadRef = React.useRef<{ projectId: string; scopeVersion: number; scopeKey: string } | null>(null)
|
|
163
168
|
React.useEffect(() => {
|
|
164
169
|
if (!projectId) return
|
|
165
170
|
let cancelled = false
|
|
166
171
|
const replaced = lastLoadRef.current
|
|
167
|
-
lastLoadRef.current = { projectId, scopeVersion }
|
|
168
|
-
const keepScreen = replaced !== null
|
|
172
|
+
lastLoadRef.current = { projectId, scopeVersion, scopeKey: projectAccessScopeKey }
|
|
173
|
+
const keepScreen = replaced !== null
|
|
174
|
+
&& replaced.projectId === projectId
|
|
175
|
+
&& replaced.scopeVersion === 0
|
|
176
|
+
&& replaced.scopeKey === projectAccessScopeKey
|
|
169
177
|
async function load() {
|
|
170
178
|
if (!keepScreen) setLoading(true)
|
|
171
179
|
const settle = (outcome: { values?: ProjectFormValues; accessDenied?: boolean; isNotFound?: boolean; error?: string }) => {
|
|
172
180
|
if (cancelled) return
|
|
173
|
-
|
|
181
|
+
setAccessDeniedScopeKey(outcome.accessDenied === true ? projectAccessScopeKey : null)
|
|
174
182
|
setIsNotFound(outcome.isNotFound === true)
|
|
175
183
|
setError(outcome.error ?? null)
|
|
176
184
|
if (outcome.values) setInitialValues(outcome.values)
|
|
185
|
+
setLoadedScopeKey(projectAccessScopeKey)
|
|
177
186
|
setLoading(false)
|
|
178
187
|
}
|
|
179
188
|
try {
|
|
180
|
-
const record = await fetchProject()
|
|
189
|
+
const { record, scope } = await fetchProject()
|
|
190
|
+
if (!cancelled) resolveScope(scope)
|
|
181
191
|
if (!record) {
|
|
182
192
|
settle({ isNotFound: true })
|
|
183
193
|
return
|
|
@@ -185,6 +195,7 @@ export default function TimeTrackingProjectEditPage({ params }: { params?: { id?
|
|
|
185
195
|
settle({ values: toValues(record) })
|
|
186
196
|
} catch (err) {
|
|
187
197
|
if (err instanceof ProjectAccessDeniedError) {
|
|
198
|
+
if (!cancelled) resolveScope(err.resolvedScope)
|
|
188
199
|
settle({ accessDenied: true })
|
|
189
200
|
return
|
|
190
201
|
}
|
|
@@ -197,16 +208,16 @@ export default function TimeTrackingProjectEditPage({ params }: { params?: { id?
|
|
|
197
208
|
}
|
|
198
209
|
load()
|
|
199
210
|
return () => { cancelled = true }
|
|
200
|
-
}, [projectId, fetchProject, scopeVersion, t])
|
|
211
|
+
}, [projectId, fetchProject, scopeVersion, t, projectAccessScopeKey, resolveScope])
|
|
201
212
|
|
|
202
|
-
if (loading) {
|
|
213
|
+
if (!accessDenied && (loading || loadedScopeKey !== projectAccessScopeKey)) {
|
|
203
214
|
return <Page><PageBody><LoadingMessage label={t('staff.timesheets.projects.loading', 'Loading project...')} /></PageBody></Page>
|
|
204
215
|
}
|
|
205
216
|
|
|
206
217
|
// Screen 17 — the caller may not open this project. Same guard state the
|
|
207
218
|
// detail page renders; it names neither the customer nor the project.
|
|
208
219
|
if (accessDenied) {
|
|
209
|
-
return <NoProjectAccess timeProjectId={projectId} />
|
|
220
|
+
return <NoProjectAccess timeProjectId={projectId} resolvedScope={resolvedScope} />
|
|
210
221
|
}
|
|
211
222
|
|
|
212
223
|
if (isNotFound) {
|