@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.
@@ -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: ({ value, setValue }) => (
203
- <TenantSelect
204
- id="tenantId"
205
- value={typeof value === 'string' ? value : tenantId}
206
- onChange={(next) => {
207
- const normalized = next ?? null
208
- setTenantId(normalized)
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...",