@open-mercato/core 0.6.7-develop.6766.1.40c9011995 → 0.6.7-develop.6770.1.b52298cf20
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/auth/frontend/login.js +3 -0
- package/dist/modules/auth/frontend/login.js.map +2 -2
- package/dist/modules/customers/api/deals/summary/route.js +9 -115
- package/dist/modules/customers/api/deals/summary/route.js.map +2 -2
- package/dist/modules/customers/lib/dealsSummaryQueries.js +144 -0
- package/dist/modules/customers/lib/dealsSummaryQueries.js.map +7 -0
- package/dist/modules/directory/backend/directory/organizations/[id]/edit/page.js +7 -16
- package/dist/modules/directory/backend/directory/organizations/[id]/edit/page.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/auth/frontend/login.tsx +3 -0
- package/src/modules/customers/api/deals/summary/route.ts +10 -158
- package/src/modules/customers/lib/dealsSummaryQueries.ts +211 -0
- package/src/modules/directory/backend/directory/organizations/[id]/edit/page.tsx +10 -14
- package/src/modules/directory/i18n/de.json +1 -0
- package/src/modules/directory/i18n/en.json +1 -0
- package/src/modules/directory/i18n/es.json +1 -0
- package/src/modules/directory/i18n/pl.json +1 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import type { EntityManager as CoreEntityManager } from '@mikro-orm/core'
|
|
2
|
+
import type { EntityManager as PgEntityManager } from '@mikro-orm/postgresql'
|
|
3
|
+
import { fetchStuckDealIds } from './stuckDeals'
|
|
4
|
+
|
|
5
|
+
type DateWindow = {
|
|
6
|
+
start: Date
|
|
7
|
+
end: Date
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type OpenPipelineRow = {
|
|
11
|
+
stage: string | null
|
|
12
|
+
currency: string | null
|
|
13
|
+
total: string | number | null
|
|
14
|
+
count: string | number
|
|
15
|
+
owner_user_id: string | null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type WindowSumRow = {
|
|
19
|
+
currency: string | null
|
|
20
|
+
current_total: string | number | null
|
|
21
|
+
current_count: string | number
|
|
22
|
+
previous_total: string | number | null
|
|
23
|
+
previous_count: string | number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type WinLossRow = {
|
|
27
|
+
current_won: string | number
|
|
28
|
+
current_lost: string | number
|
|
29
|
+
previous_won: string | number
|
|
30
|
+
previous_lost: string | number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type WinRateMonthRow = {
|
|
34
|
+
period: string
|
|
35
|
+
won: string | number
|
|
36
|
+
lost: string | number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type DealsSummaryQueryRows = {
|
|
40
|
+
openRows: OpenPipelineRow[]
|
|
41
|
+
inflowRows: WindowSumRow[]
|
|
42
|
+
wonRows: WindowSumRow[]
|
|
43
|
+
winLossRows: WinLossRow[]
|
|
44
|
+
seriesRows: WinRateMonthRow[]
|
|
45
|
+
overdueRows: Array<{ id: string }>
|
|
46
|
+
openStuckIds: string[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type DealsSummaryQueryInput = {
|
|
50
|
+
em: CoreEntityManager
|
|
51
|
+
tenantId: string
|
|
52
|
+
organizationIds: string[]
|
|
53
|
+
currentQuarter: DateWindow
|
|
54
|
+
previousQuarter: DateWindow
|
|
55
|
+
seriesStart: Date
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type SqlPredicate = {
|
|
59
|
+
clause: string
|
|
60
|
+
values: string[]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function buildOpenDealPredicate(statuses: readonly string[]): SqlPredicate {
|
|
64
|
+
if (statuses.length === 0) {
|
|
65
|
+
throw new Error('[internal] Open-deal predicate requires at least one status')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const placeholders = statuses.map(() => '?').join(',')
|
|
69
|
+
return {
|
|
70
|
+
clause: `status IN (${placeholders}) AND closure_outcome IS NULL`,
|
|
71
|
+
values: [...statuses],
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function loadDealsSummaryQueryRows(input: DealsSummaryQueryInput): Promise<DealsSummaryQueryRows> {
|
|
76
|
+
if (input.organizationIds.length === 0) {
|
|
77
|
+
throw new Error('[internal] Deals summary query requires an organization scope')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const connection = input.em.getConnection()
|
|
81
|
+
const organizationPlaceholders = input.organizationIds.map(() => '?').join(',')
|
|
82
|
+
const scopeWhere = `tenant_id = ? AND organization_id IN (${organizationPlaceholders}) AND deleted_at IS NULL`
|
|
83
|
+
const scopeValues: Array<string | number | null> = [input.tenantId, ...input.organizationIds]
|
|
84
|
+
const openDealsPredicate = buildOpenDealPredicate(['open', 'in_progress'])
|
|
85
|
+
|
|
86
|
+
const openRows = await connection.execute<OpenPipelineRow[]>(
|
|
87
|
+
`SELECT
|
|
88
|
+
pipeline_stage AS stage,
|
|
89
|
+
UPPER(COALESCE(value_currency, '')) AS currency,
|
|
90
|
+
COALESCE(SUM(value_amount), 0) AS total,
|
|
91
|
+
COUNT(*) AS count,
|
|
92
|
+
owner_user_id
|
|
93
|
+
FROM customer_deals
|
|
94
|
+
WHERE ${scopeWhere} AND ${openDealsPredicate.clause}
|
|
95
|
+
GROUP BY pipeline_stage, UPPER(COALESCE(value_currency, '')), owner_user_id`,
|
|
96
|
+
[...scopeValues, ...openDealsPredicate.values],
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
const inflowRows = await connection.execute<WindowSumRow[]>(
|
|
100
|
+
`SELECT
|
|
101
|
+
UPPER(COALESCE(value_currency, '')) AS currency,
|
|
102
|
+
COALESCE(SUM(value_amount) FILTER (WHERE created_at >= ? AND created_at < ?), 0) AS current_total,
|
|
103
|
+
COUNT(*) FILTER (WHERE created_at >= ? AND created_at < ?) AS current_count,
|
|
104
|
+
COALESCE(SUM(value_amount) FILTER (WHERE created_at >= ? AND created_at < ?), 0) AS previous_total,
|
|
105
|
+
COUNT(*) FILTER (WHERE created_at >= ? AND created_at < ?) AS previous_count
|
|
106
|
+
FROM customer_deals
|
|
107
|
+
WHERE ${scopeWhere} AND ${openDealsPredicate.clause}
|
|
108
|
+
GROUP BY UPPER(COALESCE(value_currency, ''))`,
|
|
109
|
+
[
|
|
110
|
+
input.currentQuarter.start.toISOString(),
|
|
111
|
+
input.currentQuarter.end.toISOString(),
|
|
112
|
+
input.currentQuarter.start.toISOString(),
|
|
113
|
+
input.currentQuarter.end.toISOString(),
|
|
114
|
+
input.previousQuarter.start.toISOString(),
|
|
115
|
+
input.previousQuarter.end.toISOString(),
|
|
116
|
+
input.previousQuarter.start.toISOString(),
|
|
117
|
+
input.previousQuarter.end.toISOString(),
|
|
118
|
+
...scopeValues,
|
|
119
|
+
...openDealsPredicate.values,
|
|
120
|
+
],
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
const wonRows = await connection.execute<WindowSumRow[]>(
|
|
124
|
+
`SELECT
|
|
125
|
+
UPPER(COALESCE(value_currency, '')) AS currency,
|
|
126
|
+
COALESCE(SUM(value_amount) FILTER (WHERE updated_at >= ? AND updated_at < ?), 0) AS current_total,
|
|
127
|
+
COUNT(*) FILTER (WHERE updated_at >= ? AND updated_at < ?) AS current_count,
|
|
128
|
+
COALESCE(SUM(value_amount) FILTER (WHERE updated_at >= ? AND updated_at < ?), 0) AS previous_total,
|
|
129
|
+
COUNT(*) FILTER (WHERE updated_at >= ? AND updated_at < ?) AS previous_count
|
|
130
|
+
FROM customer_deals
|
|
131
|
+
WHERE ${scopeWhere} AND (status = 'win' OR closure_outcome = 'won')
|
|
132
|
+
GROUP BY UPPER(COALESCE(value_currency, ''))`,
|
|
133
|
+
[
|
|
134
|
+
input.currentQuarter.start.toISOString(),
|
|
135
|
+
input.currentQuarter.end.toISOString(),
|
|
136
|
+
input.currentQuarter.start.toISOString(),
|
|
137
|
+
input.currentQuarter.end.toISOString(),
|
|
138
|
+
input.previousQuarter.start.toISOString(),
|
|
139
|
+
input.previousQuarter.end.toISOString(),
|
|
140
|
+
input.previousQuarter.start.toISOString(),
|
|
141
|
+
input.previousQuarter.end.toISOString(),
|
|
142
|
+
...scopeValues,
|
|
143
|
+
],
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
const winLossRows = await connection.execute<WinLossRow[]>(
|
|
147
|
+
`SELECT
|
|
148
|
+
COUNT(*) FILTER (WHERE (status = 'win' OR closure_outcome = 'won') AND updated_at >= ? AND updated_at < ?) AS current_won,
|
|
149
|
+
COUNT(*) FILTER (WHERE (status = 'loose' OR closure_outcome = 'lost') AND updated_at >= ? AND updated_at < ?) AS current_lost,
|
|
150
|
+
COUNT(*) FILTER (WHERE (status = 'win' OR closure_outcome = 'won') AND updated_at >= ? AND updated_at < ?) AS previous_won,
|
|
151
|
+
COUNT(*) FILTER (WHERE (status = 'loose' OR closure_outcome = 'lost') AND updated_at >= ? AND updated_at < ?) AS previous_lost
|
|
152
|
+
FROM customer_deals
|
|
153
|
+
WHERE ${scopeWhere}`,
|
|
154
|
+
[
|
|
155
|
+
input.currentQuarter.start.toISOString(),
|
|
156
|
+
input.currentQuarter.end.toISOString(),
|
|
157
|
+
input.currentQuarter.start.toISOString(),
|
|
158
|
+
input.currentQuarter.end.toISOString(),
|
|
159
|
+
input.previousQuarter.start.toISOString(),
|
|
160
|
+
input.previousQuarter.end.toISOString(),
|
|
161
|
+
input.previousQuarter.start.toISOString(),
|
|
162
|
+
input.previousQuarter.end.toISOString(),
|
|
163
|
+
...scopeValues,
|
|
164
|
+
],
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
const seriesRows = await connection.execute<WinRateMonthRow[]>(
|
|
168
|
+
`SELECT
|
|
169
|
+
to_char(date_trunc('month', updated_at AT TIME ZONE 'UTC'), 'YYYY-MM') AS period,
|
|
170
|
+
COUNT(*) FILTER (WHERE status = 'win' OR closure_outcome = 'won') AS won,
|
|
171
|
+
COUNT(*) FILTER (WHERE status = 'loose' OR closure_outcome = 'lost') AS lost
|
|
172
|
+
FROM customer_deals
|
|
173
|
+
WHERE ${scopeWhere} AND updated_at >= ?
|
|
174
|
+
GROUP BY 1`,
|
|
175
|
+
[...scopeValues, input.seriesStart.toISOString()],
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
const overdueDealsPredicate = buildOpenDealPredicate(['open'])
|
|
179
|
+
const overdueRows = await connection.execute<Array<{ id: string }>>(
|
|
180
|
+
`SELECT id FROM customer_deals
|
|
181
|
+
WHERE ${scopeWhere} AND ${overdueDealsPredicate.clause} AND expected_close_at IS NOT NULL AND expected_close_at < CURRENT_DATE`,
|
|
182
|
+
[...scopeValues, ...overdueDealsPredicate.values],
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
const stuckIdLists = await Promise.all(
|
|
186
|
+
input.organizationIds.map((organizationId) =>
|
|
187
|
+
fetchStuckDealIds(input.em as unknown as PgEntityManager, organizationId, input.tenantId),
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
const stuckIdValues = Array.from(new Set(stuckIdLists.flat()))
|
|
191
|
+
let openStuckIds: string[] = []
|
|
192
|
+
if (stuckIdValues.length > 0) {
|
|
193
|
+
const stuckPlaceholders = stuckIdValues.map(() => '?').join(',')
|
|
194
|
+
const openStuckRows: Array<{ id: string }> = await connection.execute<Array<{ id: string }>>(
|
|
195
|
+
`SELECT id FROM customer_deals
|
|
196
|
+
WHERE ${scopeWhere} AND ${openDealsPredicate.clause} AND id IN (${stuckPlaceholders})`,
|
|
197
|
+
[...scopeValues, ...openDealsPredicate.values, ...stuckIdValues],
|
|
198
|
+
)
|
|
199
|
+
openStuckIds = openStuckRows.map((row) => row.id)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
openRows,
|
|
204
|
+
inflowRows,
|
|
205
|
+
wonRows,
|
|
206
|
+
winLossRows,
|
|
207
|
+
seriesRows,
|
|
208
|
+
overdueRows,
|
|
209
|
+
openStuckIds,
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -5,7 +5,6 @@ import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
|
5
5
|
import { extractCustomFieldEntries } from '@open-mercato/shared/lib/crud/custom-fields-client'
|
|
6
6
|
import { E } from '#generated/entities.ids.generated'
|
|
7
7
|
import { OrganizationSelect } from '@open-mercato/core/modules/directory/components/OrganizationSelect'
|
|
8
|
-
import { TenantSelect } from '@open-mercato/core/modules/directory/components/TenantSelect'
|
|
9
8
|
import {
|
|
10
9
|
buildOrganizationTreeOptions,
|
|
11
10
|
type OrganizationTreeNode,
|
|
@@ -52,6 +51,7 @@ export default function EditOrganizationPage({ params }: { params?: { id?: strin
|
|
|
52
51
|
const [initialValues, setInitialValues] = React.useState<Record<string, unknown> | null>(null)
|
|
53
52
|
const [pathLabel, setPathLabel] = React.useState<string>('')
|
|
54
53
|
const [tenantId, setTenantId] = React.useState<string | null>(null)
|
|
54
|
+
const [tenantName, setTenantName] = React.useState<string | null>(null)
|
|
55
55
|
const [loading, setLoading] = React.useState(true)
|
|
56
56
|
const [error, setError] = React.useState<string | null>(null)
|
|
57
57
|
const [isNotFound, setIsNotFound] = React.useState(false)
|
|
@@ -133,6 +133,7 @@ export default function EditOrganizationPage({ params }: { params?: { id?: strin
|
|
|
133
133
|
}
|
|
134
134
|
const resolvedTenantId = record.tenantId || null
|
|
135
135
|
setTenantId(resolvedTenantId)
|
|
136
|
+
setTenantName(record.tenantName ?? null)
|
|
136
137
|
const baseTree = await loadParentTree(resolvedTenantId, record.descendantIds ?? [])
|
|
137
138
|
const fullTree = buildOrganizationTreeOptions(baseTree)
|
|
138
139
|
const nodeMap = new Map(fullTree.map((opt) => [opt.value, opt]))
|
|
@@ -199,18 +200,13 @@ export default function EditOrganizationPage({ params }: { params?: { id?: strin
|
|
|
199
200
|
id: 'tenantId',
|
|
200
201
|
label: t('directory.organizations.form.field.tenant', 'Tenant'),
|
|
201
202
|
type: 'custom',
|
|
202
|
-
component: (
|
|
203
|
-
<
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
setValue(normalized)
|
|
210
|
-
}}
|
|
211
|
-
includeEmptyOption={false}
|
|
212
|
-
className="w-full h-9 rounded border px-2 text-sm"
|
|
213
|
-
/>
|
|
203
|
+
component: () => (
|
|
204
|
+
<div>
|
|
205
|
+
<p className="text-sm">{tenantName ?? '—'}</p>
|
|
206
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
207
|
+
{t('directory.organizations.form.field.tenantReadonlyDescription', 'Tenant cannot be changed for an existing organization.')}
|
|
208
|
+
</p>
|
|
209
|
+
</div>
|
|
214
210
|
),
|
|
215
211
|
} as CrudField,
|
|
216
212
|
] : []),
|
|
@@ -260,7 +256,7 @@ export default function EditOrganizationPage({ params }: { params?: { id?: strin
|
|
|
260
256
|
},
|
|
261
257
|
},
|
|
262
258
|
{ id: 'isActive', label: t('directory.organizations.form.field.isActive', 'Active'), type: 'checkbox' },
|
|
263
|
-
], [actorIsSuperAdmin, childSummary, parentTree, t, tenantId])
|
|
259
|
+
], [actorIsSuperAdmin, childSummary, parentTree, t, tenantId, tenantName])
|
|
264
260
|
|
|
265
261
|
const detailFields = React.useMemo(() => (
|
|
266
262
|
actorIsSuperAdmin
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"directory.organizations.form.field.slug": "Slug",
|
|
53
53
|
"directory.organizations.form.field.slug.description": "URL-sicherer Bezeichner für das Kundenportal (Kleinbuchstaben, Zahlen, Bindestriche, Unterstriche).",
|
|
54
54
|
"directory.organizations.form.field.tenant": "Mandant",
|
|
55
|
+
"directory.organizations.form.field.tenantReadonlyDescription": "Der Mandant kann für eine bestehende Organisation nicht geändert werden.",
|
|
55
56
|
"directory.organizations.form.group.customFields": "Benutzerdefinierte Daten",
|
|
56
57
|
"directory.organizations.form.group.details": "Details",
|
|
57
58
|
"directory.organizations.form.loading": "Organisation wird geladen...",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"directory.organizations.form.field.slug": "Slug",
|
|
53
53
|
"directory.organizations.form.field.slug.description": "URL-safe identifier used for the customer portal (lowercase letters, numbers, hyphens, underscores).",
|
|
54
54
|
"directory.organizations.form.field.tenant": "Tenant",
|
|
55
|
+
"directory.organizations.form.field.tenantReadonlyDescription": "Tenant cannot be changed for an existing organization.",
|
|
55
56
|
"directory.organizations.form.group.customFields": "Custom Data",
|
|
56
57
|
"directory.organizations.form.group.details": "Details",
|
|
57
58
|
"directory.organizations.form.loading": "Loading organization...",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"directory.organizations.form.field.slug": "Slug",
|
|
53
53
|
"directory.organizations.form.field.slug.description": "Identificador seguro para URL usado en el portal de clientes (minúsculas, números, guiones, guiones bajos).",
|
|
54
54
|
"directory.organizations.form.field.tenant": "Inquilino",
|
|
55
|
+
"directory.organizations.form.field.tenantReadonlyDescription": "El inquilino no se puede cambiar para una organización existente.",
|
|
55
56
|
"directory.organizations.form.group.customFields": "Datos personalizados",
|
|
56
57
|
"directory.organizations.form.group.details": "Detalles",
|
|
57
58
|
"directory.organizations.form.loading": "Cargando organización...",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"directory.organizations.form.field.slug": "Identyfikator URL",
|
|
53
53
|
"directory.organizations.form.field.slug.description": "Identyfikator używany w adresie portalu klienta (małe litery, cyfry, myślniki, podkreślenia).",
|
|
54
54
|
"directory.organizations.form.field.tenant": "Najemca",
|
|
55
|
+
"directory.organizations.form.field.tenantReadonlyDescription": "Najemcy nie można zmienić dla istniejącej organizacji.",
|
|
55
56
|
"directory.organizations.form.group.customFields": "Dane niestandardowe",
|
|
56
57
|
"directory.organizations.form.group.details": "Szczegóły",
|
|
57
58
|
"directory.organizations.form.loading": "Ładowanie organizacji...",
|