@open-mercato/core 0.6.5-develop.5382.1.f542de69af → 0.6.5
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/bootstrap.js +46 -6
- package/dist/bootstrap.js.map +2 -2
- package/dist/generated/entities/organization/index.js +2 -0
- package/dist/generated/entities/organization/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +1 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/helpers/integration/crmFixtures.js +4 -0
- package/dist/helpers/integration/crmFixtures.js.map +2 -2
- package/dist/modules/attachments/api/route.js +2 -0
- package/dist/modules/attachments/api/route.js.map +2 -2
- package/dist/modules/attachments/lib/access.js +18 -0
- package/dist/modules/attachments/lib/access.js.map +2 -2
- package/dist/modules/audit_logs/data/entities.js +2 -1
- package/dist/modules/audit_logs/data/entities.js.map +2 -2
- package/dist/modules/audit_logs/migrations/Migration20260611104500.js +13 -0
- package/dist/modules/audit_logs/migrations/Migration20260611104500.js.map +7 -0
- package/dist/modules/audit_logs/services/accessLogService.js +10 -0
- package/dist/modules/audit_logs/services/accessLogService.js.map +2 -2
- package/dist/modules/auth/api/admin/nav.js +9 -0
- package/dist/modules/auth/api/admin/nav.js.map +2 -2
- package/dist/modules/auth/api/login.js +4 -13
- package/dist/modules/auth/api/login.js.map +2 -2
- package/dist/modules/auth/data/entities.js +3 -1
- package/dist/modules/auth/data/entities.js.map +2 -2
- package/dist/modules/auth/lib/backendChrome.js +35 -2
- package/dist/modules/auth/lib/backendChrome.js.map +2 -2
- package/dist/modules/auth/lib/consentIntegrity.js +3 -3
- package/dist/modules/auth/lib/consentIntegrity.js.map +2 -2
- package/dist/modules/auth/migrations/Migration20260611103000.js +15 -0
- package/dist/modules/auth/migrations/Migration20260611103000.js.map +7 -0
- package/dist/modules/auth/services/authService.js +5 -3
- package/dist/modules/auth/services/authService.js.map +2 -2
- package/dist/modules/auth/services/rbacService.js +3 -2
- package/dist/modules/auth/services/rbacService.js.map +2 -2
- package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.js +0 -3
- package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.js.map +2 -2
- package/dist/modules/customers/api/deals/route.js +43 -2
- package/dist/modules/customers/api/deals/route.js.map +2 -2
- package/dist/modules/customers/api/deals/summary/route.js +402 -0
- package/dist/modules/customers/api/deals/summary/route.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealActivities.js +16 -5
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealActivities.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealData.js +22 -5
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealData.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/[id]/page.js +12 -2
- package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/page.js +221 -56
- package/dist/modules/customers/backend/customers/deals/page.js.map +3 -3
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js +1 -1
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +18 -0
- package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
- package/dist/modules/customers/cli.js +15 -9
- package/dist/modules/customers/cli.js.map +2 -2
- package/dist/modules/customers/components/DealsKpiStrip.js +282 -0
- package/dist/modules/customers/components/DealsKpiStrip.js.map +7 -0
- package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js +0 -1
- package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js.map +2 -2
- package/dist/modules/customers/components/detail/DealForm.js +100 -17
- package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
- package/dist/modules/customers/components/detail/PersonDetailTabs.js +11 -3
- package/dist/modules/customers/components/detail/PersonDetailTabs.js.map +2 -2
- package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +1 -2
- package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
- package/dist/modules/customers/components/kpi/PipelineStageBar.js +63 -0
- package/dist/modules/customers/components/kpi/PipelineStageBar.js.map +7 -0
- package/dist/modules/customers/lib/dealsMetrics.js +82 -0
- package/dist/modules/customers/lib/dealsMetrics.js.map +7 -0
- package/dist/modules/directory/api/organization-branding/route.js +214 -0
- package/dist/modules/directory/api/organization-branding/route.js.map +7 -0
- package/dist/modules/directory/api/organizations/route.js +7 -0
- package/dist/modules/directory/api/organizations/route.js.map +3 -3
- package/dist/modules/directory/backend/directory/branding/page.js +214 -0
- package/dist/modules/directory/backend/directory/branding/page.js.map +7 -0
- package/dist/modules/directory/backend/directory/branding/page.meta.js +26 -0
- package/dist/modules/directory/backend/directory/branding/page.meta.js.map +7 -0
- package/dist/modules/directory/commands/organizations.js +8 -1
- package/dist/modules/directory/commands/organizations.js.map +2 -2
- package/dist/modules/directory/data/entities.js +3 -0
- package/dist/modules/directory/data/entities.js.map +2 -2
- package/dist/modules/directory/data/validators.js +9 -0
- package/dist/modules/directory/data/validators.js.map +2 -2
- package/dist/modules/directory/migrations/Migration20260607222259_directory.js +13 -0
- package/dist/modules/directory/migrations/Migration20260607222259_directory.js.map +7 -0
- package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js +2 -1
- package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js.map +2 -2
- package/dist/modules/directory/utils/organizationScope.js +59 -27
- package/dist/modules/directory/utils/organizationScope.js.map +2 -2
- package/dist/modules/entities/api/definitions.batch.js +2 -1
- package/dist/modules/entities/api/definitions.batch.js.map +2 -2
- package/dist/modules/entities/api/entities.js +7 -0
- package/dist/modules/entities/api/entities.js.map +2 -2
- package/dist/modules/entities/api/records.js +26 -15
- package/dist/modules/entities/api/records.js.map +2 -2
- package/dist/modules/entities/backend/entities/user/[entityId]/records/[recordId]/page.js +14 -0
- package/dist/modules/entities/backend/entities/user/[entityId]/records/[recordId]/page.js.map +2 -2
- package/dist/modules/entities/backend/entities/user/[entityId]/records/create/page.js +14 -0
- package/dist/modules/entities/backend/entities/user/[entityId]/records/create/page.js.map +2 -2
- package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js +12 -0
- package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js.map +2 -2
- package/dist/modules/entities/components/useRecordsEntityGuard.js +30 -0
- package/dist/modules/entities/components/useRecordsEntityGuard.js.map +7 -0
- package/dist/modules/query_index/data/entities.js +2 -1
- package/dist/modules/query_index/data/entities.js.map +2 -2
- package/dist/modules/query_index/lib/engine.js +4 -2
- package/dist/modules/query_index/lib/engine.js.map +2 -2
- package/dist/modules/query_index/migrations/Migration20260611103000_query_index.js +16 -0
- package/dist/modules/query_index/migrations/Migration20260611103000_query_index.js.map +7 -0
- package/dist/modules/sales/commands/documents.js +7 -5
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/components/documents/SalesDocumentsTable.js +2 -1
- package/dist/modules/sales/components/documents/SalesDocumentsTable.js.map +2 -2
- package/dist/modules/sales/components/documents/salesDocumentsColumns.js +10 -0
- package/dist/modules/sales/components/documents/salesDocumentsColumns.js.map +7 -0
- package/dist/modules/staff/api/team-members.js +9 -2
- package/dist/modules/staff/api/team-members.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js +24 -1
- package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js.map +2 -2
- package/dist/modules/staff/backend/staff/team-members/[id]/page.js +11 -6
- package/dist/modules/staff/backend/staff/team-members/[id]/page.js.map +2 -2
- package/dist/modules/staff/commands/team-members.js +1 -1
- package/dist/modules/staff/commands/team-members.js.map +2 -2
- package/dist/modules/staff/components/TeamMemberForm.js +1 -1
- package/dist/modules/staff/components/TeamMemberForm.js.map +2 -2
- package/dist/modules/staff/lib/scheduleSwitch.js +23 -0
- package/dist/modules/staff/lib/scheduleSwitch.js.map +7 -0
- package/dist/modules/workflows/backend/definitions/create/page.js +1 -2
- package/dist/modules/workflows/backend/definitions/create/page.js.map +2 -2
- package/dist/modules/workflows/backend/definitions/visual-editor/page.js +1 -2
- package/dist/modules/workflows/backend/definitions/visual-editor/page.js.map +2 -2
- package/dist/modules/workflows/components/DefinitionTriggersEditor.js +1 -2
- package/dist/modules/workflows/components/DefinitionTriggersEditor.js.map +2 -2
- package/dist/modules/workflows/components/NodeEditDialog.js +4 -13
- package/dist/modules/workflows/components/NodeEditDialog.js.map +2 -2
- package/dist/modules/workflows/components/NodeEditDialogCrudForm.js +4 -13
- package/dist/modules/workflows/components/NodeEditDialogCrudForm.js.map +2 -2
- package/dist/modules/workflows/components/WorkflowGraphImpl.js +1 -4
- package/dist/modules/workflows/components/WorkflowGraphImpl.js.map +2 -2
- package/dist/modules/workflows/components/fields/FormFieldArrayEditor.js +2 -5
- package/dist/modules/workflows/components/fields/FormFieldArrayEditor.js.map +2 -2
- package/generated/entities/organization/index.ts +1 -0
- package/generated/entity-fields-registry.ts +1 -0
- package/package.json +11 -12
- package/src/bootstrap.ts +65 -7
- package/src/helpers/integration/crmFixtures.ts +21 -1
- package/src/modules/attachments/AGENTS.md +79 -0
- package/src/modules/attachments/api/route.ts +2 -0
- package/src/modules/attachments/lib/access.ts +36 -0
- package/src/modules/audit_logs/data/entities.ts +1 -0
- package/src/modules/audit_logs/migrations/.snapshot-open-mercato.json +10 -0
- package/src/modules/audit_logs/migrations/Migration20260611104500.ts +13 -0
- package/src/modules/audit_logs/services/accessLogService.ts +15 -0
- package/src/modules/auth/api/admin/nav.ts +9 -0
- package/src/modules/auth/api/login.ts +13 -13
- package/src/modules/auth/data/entities.ts +2 -0
- package/src/modules/auth/i18n/de.json +0 -1
- package/src/modules/auth/i18n/en.json +0 -1
- package/src/modules/auth/i18n/es.json +0 -1
- package/src/modules/auth/i18n/pl.json +0 -1
- package/src/modules/auth/lib/backendChrome.tsx +37 -1
- package/src/modules/auth/lib/consentIntegrity.ts +6 -3
- package/src/modules/auth/migrations/.snapshot-open-mercato.json +20 -0
- package/src/modules/auth/migrations/Migration20260611103000.ts +21 -0
- package/src/modules/auth/services/authService.ts +24 -4
- package/src/modules/auth/services/rbacService.ts +11 -2
- package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.tsx +0 -3
- package/src/modules/customers/api/deals/route.ts +51 -2
- package/src/modules/customers/api/deals/summary/route.ts +496 -0
- package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealActivities.ts +28 -6
- package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealData.ts +33 -6
- package/src/modules/customers/backend/customers/deals/[id]/page.tsx +17 -2
- package/src/modules/customers/backend/customers/deals/page.tsx +254 -66
- package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +1 -2
- package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +18 -0
- package/src/modules/customers/cli.ts +15 -15
- package/src/modules/customers/components/DealsKpiStrip.tsx +389 -0
- package/src/modules/customers/components/detail/ConfirmDealLostDialog.tsx +0 -1
- package/src/modules/customers/components/detail/DealForm.tsx +121 -19
- package/src/modules/customers/components/detail/PersonDetailTabs.tsx +12 -2
- package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +1 -2
- package/src/modules/customers/components/kpi/PipelineStageBar.tsx +77 -0
- package/src/modules/customers/i18n/de.json +43 -0
- package/src/modules/customers/i18n/en.json +43 -0
- package/src/modules/customers/i18n/es.json +43 -0
- package/src/modules/customers/i18n/pl.json +43 -0
- package/src/modules/customers/lib/dealsMetrics.ts +159 -0
- package/src/modules/directory/api/organization-branding/route.ts +238 -0
- package/src/modules/directory/api/organizations/route.ts +7 -0
- package/src/modules/directory/backend/directory/branding/page.meta.ts +24 -0
- package/src/modules/directory/backend/directory/branding/page.tsx +248 -0
- package/src/modules/directory/commands/organizations.ts +9 -1
- package/src/modules/directory/data/entities.ts +3 -0
- package/src/modules/directory/data/validators.ts +12 -0
- package/src/modules/directory/i18n/de.json +21 -0
- package/src/modules/directory/i18n/en.json +21 -0
- package/src/modules/directory/i18n/es.json +21 -0
- package/src/modules/directory/i18n/pl.json +21 -0
- package/src/modules/directory/migrations/.snapshot-open-mercato.json +40 -0
- package/src/modules/directory/migrations/Migration20260607222259_directory.ts +13 -0
- package/src/modules/directory/subscribers/invalidateOrgScopeCache.ts +3 -1
- package/src/modules/directory/utils/organizationScope.ts +85 -30
- package/src/modules/entities/api/definitions.batch.ts +11 -7
- package/src/modules/entities/api/entities.ts +11 -0
- package/src/modules/entities/api/records.ts +46 -25
- package/src/modules/entities/backend/entities/user/[entityId]/records/[recordId]/page.tsx +15 -0
- package/src/modules/entities/backend/entities/user/[entityId]/records/create/page.tsx +15 -0
- package/src/modules/entities/backend/entities/user/[entityId]/records/page.tsx +23 -0
- package/src/modules/entities/components/useRecordsEntityGuard.ts +41 -0
- package/src/modules/entities/i18n/de.json +1 -0
- package/src/modules/entities/i18n/en.json +1 -0
- package/src/modules/entities/i18n/es.json +1 -0
- package/src/modules/entities/i18n/pl.json +1 -0
- package/src/modules/query_index/data/entities.ts +1 -0
- package/src/modules/query_index/lib/engine.ts +11 -5
- package/src/modules/query_index/migrations/.snapshot-open-mercato.json +11 -0
- package/src/modules/query_index/migrations/Migration20260611103000_query_index.ts +29 -0
- package/src/modules/sales/commands/documents.ts +7 -5
- package/src/modules/sales/components/documents/SalesDocumentsTable.tsx +2 -1
- package/src/modules/sales/components/documents/salesDocumentsColumns.ts +6 -0
- package/src/modules/staff/api/team-members.ts +9 -2
- package/src/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.ts +31 -1
- package/src/modules/staff/backend/staff/team-members/[id]/page.tsx +18 -8
- package/src/modules/staff/commands/team-members.ts +5 -2
- package/src/modules/staff/components/TeamMemberForm.tsx +4 -1
- package/src/modules/staff/i18n/de.json +1 -0
- package/src/modules/staff/i18n/en.json +1 -0
- package/src/modules/staff/i18n/es.json +1 -0
- package/src/modules/staff/i18n/pl.json +1 -0
- package/src/modules/staff/lib/scheduleSwitch.ts +46 -0
- package/src/modules/workflows/backend/definitions/create/page.tsx +1 -2
- package/src/modules/workflows/backend/definitions/visual-editor/page.tsx +1 -2
- package/src/modules/workflows/components/DefinitionTriggersEditor.tsx +1 -2
- package/src/modules/workflows/components/NodeEditDialog.tsx +1 -4
- package/src/modules/workflows/components/NodeEditDialogCrudForm.tsx +4 -7
- package/src/modules/workflows/components/WorkflowGraphImpl.tsx +1 -2
- package/src/modules/workflows/components/fields/FormFieldArrayEditor.tsx +2 -3
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { RateResult } from '@open-mercato/core/modules/currencies/services/exchangeRateService'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Quarter / period helpers for the deals KPI summary. Computed in **UTC** so the
|
|
5
|
+
* window boundaries are stable regardless of the server timezone — `expected_close_at`
|
|
6
|
+
* is a bare `Date` (date-only) while `created_at` / `updated_at` are timestamptz, and
|
|
7
|
+
* mixing local-time boundaries would misbucket deals near a quarter edge.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type PeriodWindow = {
|
|
11
|
+
/** Inclusive lower bound (UTC). */
|
|
12
|
+
start: Date
|
|
13
|
+
/** Exclusive upper bound (UTC). */
|
|
14
|
+
end: Date
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type TrailingMonth = {
|
|
18
|
+
/** Inclusive lower bound (UTC) of the month bucket. */
|
|
19
|
+
start: Date
|
|
20
|
+
/** 'YYYY-MM' label for the bucket. */
|
|
21
|
+
label: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type DeltaDirection = 'up' | 'down' | 'unchanged'
|
|
25
|
+
|
|
26
|
+
export type Delta = {
|
|
27
|
+
value: number
|
|
28
|
+
direction: DeltaDirection
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type CurrencySum = {
|
|
32
|
+
currency: string
|
|
33
|
+
total: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type ConvertedSums = {
|
|
37
|
+
total: number
|
|
38
|
+
convertedAll: boolean
|
|
39
|
+
missingRateCurrencies: string[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function startOfQuarterUtc(year: number, quarterStartMonth: number): Date {
|
|
43
|
+
return new Date(Date.UTC(year, quarterStartMonth, 1, 0, 0, 0, 0))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Returns the [start, end) window of the calendar quarter that contains `now`,
|
|
48
|
+
* in UTC. Quarters are fixed 3-month blocks: Jan–Mar, Apr–Jun, Jul–Sep, Oct–Dec.
|
|
49
|
+
* `end` is exclusive (the start of the next quarter).
|
|
50
|
+
*/
|
|
51
|
+
export function getQuarterWindow(now: Date): PeriodWindow {
|
|
52
|
+
const year = now.getUTCFullYear()
|
|
53
|
+
const quarterIndex = Math.floor(now.getUTCMonth() / 3)
|
|
54
|
+
const startMonth = quarterIndex * 3
|
|
55
|
+
const start = startOfQuarterUtc(year, startMonth)
|
|
56
|
+
const end = startOfQuarterUtc(year, startMonth + 3)
|
|
57
|
+
return { start, end }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Returns the [start, end) window of the quarter immediately preceding the one
|
|
62
|
+
* that contains `now`, in UTC. `end` is exclusive and equals the current quarter's start.
|
|
63
|
+
*/
|
|
64
|
+
export function getPreviousQuarterWindow(now: Date): PeriodWindow {
|
|
65
|
+
const current = getQuarterWindow(now)
|
|
66
|
+
const start = startOfQuarterUtc(current.start.getUTCFullYear(), current.start.getUTCMonth() - 3)
|
|
67
|
+
return { start, end: current.start }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function monthLabel(year: number, monthIndex: number): string {
|
|
71
|
+
const month = monthIndex + 1
|
|
72
|
+
return `${year}-${month < 10 ? `0${month}` : month}`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Returns `count` trailing month buckets ending with the month that contains `now`,
|
|
77
|
+
* ordered oldest → newest. Each bucket exposes its UTC start and a 'YYYY-MM' label.
|
|
78
|
+
* Used to drive the win-rate sparkline series.
|
|
79
|
+
*/
|
|
80
|
+
export function getTrailingMonths(now: Date, count: number): TrailingMonth[] {
|
|
81
|
+
const buckets: TrailingMonth[] = []
|
|
82
|
+
const baseYear = now.getUTCFullYear()
|
|
83
|
+
const baseMonth = now.getUTCMonth()
|
|
84
|
+
for (let offset = count - 1; offset >= 0; offset -= 1) {
|
|
85
|
+
const start = new Date(Date.UTC(baseYear, baseMonth - offset, 1, 0, 0, 0, 0))
|
|
86
|
+
buckets.push({ start, label: monthLabel(start.getUTCFullYear(), start.getUTCMonth()) })
|
|
87
|
+
}
|
|
88
|
+
return buckets
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Percentage change of `current` relative to `previous`, rounded to whole percent.
|
|
93
|
+
* When there is no previous-period baseline, avoid reporting artificial growth.
|
|
94
|
+
*/
|
|
95
|
+
export function computeDelta(current: number, previous: number): Delta {
|
|
96
|
+
if (previous === 0) {
|
|
97
|
+
return { value: 0, direction: 'unchanged' }
|
|
98
|
+
}
|
|
99
|
+
const change = ((current - previous) / Math.abs(previous)) * 100
|
|
100
|
+
const value = Math.round(change)
|
|
101
|
+
if (value > 0) return { value, direction: 'up' }
|
|
102
|
+
if (value < 0) return { value, direction: 'down' }
|
|
103
|
+
return { value: 0, direction: 'unchanged' }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function extractRate(result: RateResult | undefined): number | null {
|
|
107
|
+
if (!result || result.rates.length === 0) return null
|
|
108
|
+
const rate = Number(result.rates[0].rate)
|
|
109
|
+
if (!Number.isFinite(rate) || rate <= 0) return null
|
|
110
|
+
return rate
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Converts per-currency sums to the tenant base currency, mirroring the conversion
|
|
115
|
+
* logic in `api/deals/aggregate/route.ts`:
|
|
116
|
+
* - the base currency stays 1:1,
|
|
117
|
+
* - other currencies multiply by the rate from `rates` (keyed `"FROM/BASE"`),
|
|
118
|
+
* - a currency with no usable rate is excluded from `total` and flagged in
|
|
119
|
+
* `missingRateCurrencies` (with `convertedAll: false`).
|
|
120
|
+
*
|
|
121
|
+
* When `baseCode` is null there is no base currency configured, so nothing can be
|
|
122
|
+
* converted: every present currency is reported as missing and `convertedAll` is false.
|
|
123
|
+
*
|
|
124
|
+
* `rates` accepts the `Map<string, RateResult>` shape returned by
|
|
125
|
+
* `exchangeRateService.getRates` so callers can pass its output directly.
|
|
126
|
+
*/
|
|
127
|
+
export function convertSumsToBase(
|
|
128
|
+
perCurrency: CurrencySum[],
|
|
129
|
+
baseCode: string | null,
|
|
130
|
+
rates: Map<string, RateResult>,
|
|
131
|
+
): ConvertedSums {
|
|
132
|
+
if (!baseCode) {
|
|
133
|
+
const missing = Array.from(
|
|
134
|
+
new Set(perCurrency.map((entry) => entry.currency).filter((code): code is string => Boolean(code))),
|
|
135
|
+
)
|
|
136
|
+
return { total: 0, convertedAll: missing.length === 0, missingRateCurrencies: missing }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let total = 0
|
|
140
|
+
let convertedAll = true
|
|
141
|
+
const missingRateCurrencies: string[] = []
|
|
142
|
+
for (const entry of perCurrency) {
|
|
143
|
+
if (!entry.currency) continue
|
|
144
|
+
if (entry.currency === baseCode) {
|
|
145
|
+
total += entry.total
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
const rate = extractRate(rates.get(`${entry.currency}/${baseCode}`))
|
|
149
|
+
if (rate !== null) {
|
|
150
|
+
total += entry.total * rate
|
|
151
|
+
} else {
|
|
152
|
+
convertedAll = false
|
|
153
|
+
if (!missingRateCurrencies.includes(entry.currency)) {
|
|
154
|
+
missingRateCurrencies.push(entry.currency)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { total: Math.round(total), convertedAll, missingRateCurrencies }
|
|
159
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
|
+
import { runWithCacheTenant } from '@open-mercato/cache'
|
|
5
|
+
import type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
|
|
6
|
+
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
7
|
+
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
8
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
9
|
+
import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
10
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
11
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
12
|
+
import { Organization } from '@open-mercato/core/modules/directory/data/entities'
|
|
13
|
+
import { organizationUpdateSchema } from '@open-mercato/core/modules/directory/data/validators'
|
|
14
|
+
import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
15
|
+
import '@open-mercato/core/modules/directory/commands/organizations'
|
|
16
|
+
|
|
17
|
+
export const metadata = {
|
|
18
|
+
GET: { requireAuth: true, requireFeatures: ['directory.organizations.view'] },
|
|
19
|
+
PUT: { requireAuth: true, requireFeatures: ['directory.organizations.manage'] },
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const brandingResponseSchema = z.object({
|
|
23
|
+
organizationId: z.string().uuid(),
|
|
24
|
+
organizationName: z.string(),
|
|
25
|
+
tenantId: z.string().uuid(),
|
|
26
|
+
logoUrl: z.string().nullable(),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const brandingUpdateSchema = z.object({
|
|
30
|
+
logoUrl: organizationUpdateSchema.shape.logoUrl,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const errorSchema = z.object({
|
|
34
|
+
error: z.string(),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
type RequestContainer = Awaited<ReturnType<typeof createRequestContainer>>
|
|
38
|
+
|
|
39
|
+
function buildCommandContext(
|
|
40
|
+
container: RequestContainer,
|
|
41
|
+
auth: NonNullable<Awaited<ReturnType<typeof getAuthFromRequest>>>,
|
|
42
|
+
req: Request,
|
|
43
|
+
organizationId: string,
|
|
44
|
+
tenantId: string,
|
|
45
|
+
): CommandRuntimeContext {
|
|
46
|
+
return {
|
|
47
|
+
container,
|
|
48
|
+
auth,
|
|
49
|
+
organizationScope: {
|
|
50
|
+
selectedId: organizationId,
|
|
51
|
+
filterIds: [organizationId],
|
|
52
|
+
allowedIds: null,
|
|
53
|
+
tenantId,
|
|
54
|
+
},
|
|
55
|
+
selectedOrganizationId: organizationId,
|
|
56
|
+
organizationIds: [organizationId],
|
|
57
|
+
request: req,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function resolveCurrentOrganization(req: Request) {
|
|
62
|
+
const { translate } = await resolveTranslations()
|
|
63
|
+
const auth = await getAuthFromRequest(req)
|
|
64
|
+
if (!auth?.sub) {
|
|
65
|
+
return {
|
|
66
|
+
response: NextResponse.json({ error: translate('api.errors.unauthorized', 'Unauthorized') }, { status: 401 }),
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const container = await createRequestContainer()
|
|
71
|
+
const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })
|
|
72
|
+
const organizationId = scope.selectedId ?? auth.orgId ?? null
|
|
73
|
+
const tenantId = scope.tenantId ?? auth.tenantId ?? null
|
|
74
|
+
if (!organizationId || !tenantId) {
|
|
75
|
+
return {
|
|
76
|
+
response: NextResponse.json(
|
|
77
|
+
{
|
|
78
|
+
error: translate(
|
|
79
|
+
'directory.branding.errors.organizationRequired',
|
|
80
|
+
'Select a single organization before changing sidebar branding.',
|
|
81
|
+
),
|
|
82
|
+
},
|
|
83
|
+
{ status: 400 },
|
|
84
|
+
),
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const em = container.resolve('em') as EntityManager
|
|
89
|
+
const organization = await findOneWithDecryption(
|
|
90
|
+
em,
|
|
91
|
+
Organization,
|
|
92
|
+
{ id: organizationId, tenant: tenantId, deletedAt: null },
|
|
93
|
+
{ populate: ['tenant'] },
|
|
94
|
+
{ tenantId, organizationId },
|
|
95
|
+
)
|
|
96
|
+
if (!organization) {
|
|
97
|
+
return {
|
|
98
|
+
response: NextResponse.json(
|
|
99
|
+
{ error: translate('directory.branding.errors.notFound', 'Organization not found') },
|
|
100
|
+
{ status: 404 },
|
|
101
|
+
),
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { auth, container, organization, organizationId, tenantId, translate }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function toResponsePayload(organization: Organization, tenantId: string) {
|
|
109
|
+
return {
|
|
110
|
+
organizationId: String(organization.id),
|
|
111
|
+
organizationName: organization.name,
|
|
112
|
+
tenantId,
|
|
113
|
+
logoUrl: organization.logoUrl ?? null,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function invalidateSidebarBrandingCache(container: RequestContainer, organizationId: string, tenantId: string) {
|
|
118
|
+
try {
|
|
119
|
+
const cache = container.resolve('cache') as {
|
|
120
|
+
deleteByTags?: (tags: string[]) => Promise<unknown>
|
|
121
|
+
} | null
|
|
122
|
+
await runWithCacheTenant(tenantId, () =>
|
|
123
|
+
cache?.deleteByTags?.([
|
|
124
|
+
`nav:sidebar:organization:${organizationId}`,
|
|
125
|
+
`nav:sidebar:tenant:${tenantId}`,
|
|
126
|
+
]),
|
|
127
|
+
)
|
|
128
|
+
} catch {
|
|
129
|
+
// Cache invalidation is best-effort; the persisted branding is the source of truth.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function GET(req: Request) {
|
|
134
|
+
const resolved = await resolveCurrentOrganization(req)
|
|
135
|
+
if ('response' in resolved) return resolved.response
|
|
136
|
+
|
|
137
|
+
return NextResponse.json(toResponsePayload(resolved.organization, resolved.tenantId))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function PUT(req: Request) {
|
|
141
|
+
const resolved = await resolveCurrentOrganization(req)
|
|
142
|
+
if ('response' in resolved) return resolved.response
|
|
143
|
+
|
|
144
|
+
let body: unknown
|
|
145
|
+
try {
|
|
146
|
+
body = await req.json()
|
|
147
|
+
} catch {
|
|
148
|
+
return NextResponse.json(
|
|
149
|
+
{ error: resolved.translate('directory.branding.errors.invalidLogoUrl', 'Enter a valid image URL.') },
|
|
150
|
+
{ status: 422 },
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
if (!body || typeof body !== 'object' || !Object.prototype.hasOwnProperty.call(body, 'logoUrl')) {
|
|
154
|
+
return NextResponse.json(
|
|
155
|
+
{ error: resolved.translate('directory.branding.errors.invalidLogoUrl', 'Enter a valid image URL.') },
|
|
156
|
+
{ status: 422 },
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const parsed = brandingUpdateSchema.safeParse(body)
|
|
161
|
+
if (!parsed.success) {
|
|
162
|
+
return NextResponse.json(
|
|
163
|
+
{
|
|
164
|
+
error: resolved.translate('directory.branding.errors.invalidLogoUrl', 'Enter a valid image URL.'),
|
|
165
|
+
issues: parsed.error.issues,
|
|
166
|
+
},
|
|
167
|
+
{ status: 422 },
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const commandBus = resolved.container.resolve('commandBus') as CommandBus
|
|
173
|
+
const ctx = buildCommandContext(
|
|
174
|
+
resolved.container,
|
|
175
|
+
resolved.auth,
|
|
176
|
+
req,
|
|
177
|
+
resolved.organizationId,
|
|
178
|
+
resolved.tenantId,
|
|
179
|
+
)
|
|
180
|
+
const { result } = await commandBus.execute<Record<string, unknown>, Organization>(
|
|
181
|
+
'directory.organizations.update',
|
|
182
|
+
{
|
|
183
|
+
input: {
|
|
184
|
+
id: resolved.organizationId,
|
|
185
|
+
tenantId: resolved.tenantId,
|
|
186
|
+
logoUrl: parsed.data.logoUrl ?? null,
|
|
187
|
+
},
|
|
188
|
+
ctx,
|
|
189
|
+
},
|
|
190
|
+
)
|
|
191
|
+
await invalidateSidebarBrandingCache(resolved.container, resolved.organizationId, resolved.tenantId)
|
|
192
|
+
return NextResponse.json(toResponsePayload(result, resolved.tenantId))
|
|
193
|
+
} catch (err) {
|
|
194
|
+
if (isCrudHttpError(err)) {
|
|
195
|
+
return NextResponse.json(err.body, { status: err.status })
|
|
196
|
+
}
|
|
197
|
+
console.error('directory.organization-branding.update failed', err)
|
|
198
|
+
return NextResponse.json(
|
|
199
|
+
{ error: resolved.translate('directory.branding.errors.save', 'Failed to update organization branding.') },
|
|
200
|
+
{ status: 400 },
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export const openApi: OpenApiRouteDoc = {
|
|
206
|
+
tag: 'Directory',
|
|
207
|
+
summary: 'Current organization branding',
|
|
208
|
+
methods: {
|
|
209
|
+
GET: {
|
|
210
|
+
summary: 'Read sidebar branding for the selected organization',
|
|
211
|
+
description: 'Returns the logo URL used by the backend sidebar for the currently selected organization.',
|
|
212
|
+
responses: [
|
|
213
|
+
{ status: 200, description: 'Organization branding', schema: brandingResponseSchema },
|
|
214
|
+
],
|
|
215
|
+
errors: [
|
|
216
|
+
{ status: 400, description: 'A concrete organization scope is required', schema: errorSchema },
|
|
217
|
+
{ status: 401, description: 'Unauthorized', schema: errorSchema },
|
|
218
|
+
{ status: 404, description: 'Organization not found', schema: errorSchema },
|
|
219
|
+
],
|
|
220
|
+
},
|
|
221
|
+
PUT: {
|
|
222
|
+
summary: 'Update sidebar branding for the selected organization',
|
|
223
|
+
description: 'Stores an external image URL or an internal attachment image URL as the selected organization logo.',
|
|
224
|
+
requestBody: {
|
|
225
|
+
contentType: 'application/json',
|
|
226
|
+
schema: brandingUpdateSchema,
|
|
227
|
+
},
|
|
228
|
+
responses: [
|
|
229
|
+
{ status: 200, description: 'Updated organization branding', schema: brandingResponseSchema },
|
|
230
|
+
],
|
|
231
|
+
errors: [
|
|
232
|
+
{ status: 400, description: 'Save failed', schema: errorSchema },
|
|
233
|
+
{ status: 401, description: 'Unauthorized', schema: errorSchema },
|
|
234
|
+
{ status: 422, description: 'Invalid logo URL', schema: errorSchema },
|
|
235
|
+
],
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
}
|
|
@@ -250,6 +250,7 @@ export async function GET(req: Request) {
|
|
|
250
250
|
const items = orgs.map((org) => ({
|
|
251
251
|
id: stringId(org.id),
|
|
252
252
|
name: org.name,
|
|
253
|
+
logoUrl: org.logoUrl ?? null,
|
|
253
254
|
parentId: org.parentId ?? null,
|
|
254
255
|
tenantId: tenantId,
|
|
255
256
|
isActive: !!org.isActive,
|
|
@@ -341,8 +342,10 @@ export async function GET(req: Request) {
|
|
|
341
342
|
}
|
|
342
343
|
|
|
343
344
|
const slugByOrgId = new Map<string, string | null>()
|
|
345
|
+
const logoUrlByOrgId = new Map<string, string | null>()
|
|
344
346
|
for (const org of allOrgs) {
|
|
345
347
|
slugByOrgId.set(String(org.id), org.slug ?? null)
|
|
348
|
+
logoUrlByOrgId.set(String(org.id), org.logoUrl ?? null)
|
|
346
349
|
}
|
|
347
350
|
|
|
348
351
|
const tenantIds = Array.from(byTenant.keys())
|
|
@@ -426,6 +429,7 @@ export async function GET(req: Request) {
|
|
|
426
429
|
id: node.id,
|
|
427
430
|
name: node.name,
|
|
428
431
|
slug: slugByOrgId.get(recordId) ?? null,
|
|
432
|
+
logoUrl: logoUrlByOrgId.get(recordId) ?? null,
|
|
429
433
|
tenantId: tid,
|
|
430
434
|
tenantName: tenantNameMap[tid] ?? tid,
|
|
431
435
|
parentId: node.parentId,
|
|
@@ -467,9 +471,11 @@ export async function GET(req: Request) {
|
|
|
467
471
|
const orgs = await em.find(Organization, orgListFilter, { orderBy: { name: 'ASC' } })
|
|
468
472
|
const hierarchy = computeHierarchyForOrganizations(orgs, tenantId)
|
|
469
473
|
const slugByOrgId = new Map<string, string | null>()
|
|
474
|
+
const logoUrlByOrgId = new Map<string, string | null>()
|
|
470
475
|
const updatedAtByOrgId = new Map<string, string | null>()
|
|
471
476
|
for (const org of orgs) {
|
|
472
477
|
slugByOrgId.set(String(org.id), org.slug ?? null)
|
|
478
|
+
logoUrlByOrgId.set(String(org.id), org.logoUrl ?? null)
|
|
473
479
|
updatedAtByOrgId.set(String(org.id), org.updatedAt instanceof Date ? org.updatedAt.toISOString() : null)
|
|
474
480
|
}
|
|
475
481
|
|
|
@@ -533,6 +539,7 @@ export async function GET(req: Request) {
|
|
|
533
539
|
id: node.id,
|
|
534
540
|
name: node.name,
|
|
535
541
|
slug: slugByOrgId.get(recordId) ?? null,
|
|
542
|
+
logoUrl: logoUrlByOrgId.get(recordId) ?? null,
|
|
536
543
|
updatedAt: updatedAtByOrgId.get(recordId) ?? null,
|
|
537
544
|
tenantId: node.tenantId,
|
|
538
545
|
tenantName,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
const brandingIcon = React.createElement(
|
|
4
|
+
'svg',
|
|
5
|
+
{ width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' },
|
|
6
|
+
React.createElement('rect', { x: 3, y: 5, width: 18, height: 14, rx: 2 }),
|
|
7
|
+
React.createElement('circle', { cx: 8, cy: 10, r: 1.5 }),
|
|
8
|
+
React.createElement('path', { d: 'm21 15-5-5L5 21' }),
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
export const metadata = {
|
|
12
|
+
requireAuth: true,
|
|
13
|
+
requireFeatures: ['directory.organizations.manage'],
|
|
14
|
+
pageTitle: 'Organization branding',
|
|
15
|
+
pageTitleKey: 'directory.branding.nav',
|
|
16
|
+
pageGroup: 'Directory',
|
|
17
|
+
pageGroupKey: 'settings.sections.directory',
|
|
18
|
+
pageOrder: 0,
|
|
19
|
+
icon: brandingIcon,
|
|
20
|
+
pageContext: 'settings' as const,
|
|
21
|
+
breadcrumb: [{ label: 'Organization branding', labelKey: 'directory.branding.nav' }],
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default metadata
|