@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
|
@@ -27,6 +27,7 @@ import { fetchStuckDealIds } from '../../lib/stuckDeals'
|
|
|
27
27
|
const rawBodySchema = z.object({}).passthrough()
|
|
28
28
|
|
|
29
29
|
const stringOrStringArray = z.union([z.string(), z.array(z.string())])
|
|
30
|
+
const OPEN_DEAL_STATUSES = ['open', 'in_progress'] as const
|
|
30
31
|
const booleanQueryParam = z.preprocess((value) => {
|
|
31
32
|
const parsed = parseBooleanFromUnknown(value)
|
|
32
33
|
return parsed === null ? value : parsed
|
|
@@ -47,6 +48,7 @@ export const dealListQuerySchema = z
|
|
|
47
48
|
expectedCloseAtTo: z.string().optional(),
|
|
48
49
|
isStuck: booleanQueryParam,
|
|
49
50
|
isOverdue: booleanQueryParam,
|
|
51
|
+
needsAttention: booleanQueryParam,
|
|
50
52
|
valueCurrency: stringOrStringArray.optional(),
|
|
51
53
|
sortField: z.string().optional(),
|
|
52
54
|
sortDir: z.enum(['asc', 'desc']).optional(),
|
|
@@ -156,6 +158,43 @@ async function fetchDealIdsMatchingAssociations(
|
|
|
156
158
|
return rows.map((row) => row.id)
|
|
157
159
|
}
|
|
158
160
|
|
|
161
|
+
async function fetchNeedAttentionDealIds(
|
|
162
|
+
em: EntityManager,
|
|
163
|
+
organizationId: string,
|
|
164
|
+
tenantId: string,
|
|
165
|
+
): Promise<string[]> {
|
|
166
|
+
const connection = em.getConnection()
|
|
167
|
+
const overdueRows = await connection.execute<Array<{ id: string }>>(
|
|
168
|
+
`SELECT id FROM customer_deals
|
|
169
|
+
WHERE organization_id = ?
|
|
170
|
+
AND tenant_id = ?
|
|
171
|
+
AND deleted_at IS NULL
|
|
172
|
+
AND status = 'open'
|
|
173
|
+
AND expected_close_at IS NOT NULL
|
|
174
|
+
AND expected_close_at < CURRENT_DATE`,
|
|
175
|
+
[organizationId, tenantId],
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
const attentionIds = new Set(overdueRows.map((row) => row.id))
|
|
179
|
+
const stuckIds = await fetchStuckDealIds(em, organizationId, tenantId)
|
|
180
|
+
if (stuckIds.length > 0) {
|
|
181
|
+
const idPlaceholders = stuckIds.map(() => '?').join(',')
|
|
182
|
+
const statusPlaceholders = OPEN_DEAL_STATUSES.map(() => '?').join(',')
|
|
183
|
+
const openStuckRows = await connection.execute<Array<{ id: string }>>(
|
|
184
|
+
`SELECT id FROM customer_deals
|
|
185
|
+
WHERE organization_id = ?
|
|
186
|
+
AND tenant_id = ?
|
|
187
|
+
AND deleted_at IS NULL
|
|
188
|
+
AND status IN (${statusPlaceholders})
|
|
189
|
+
AND id IN (${idPlaceholders})`,
|
|
190
|
+
[organizationId, tenantId, ...OPEN_DEAL_STATUSES, ...stuckIds],
|
|
191
|
+
)
|
|
192
|
+
for (const row of openStuckRows) attentionIds.add(row.id)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return Array.from(attentionIds)
|
|
196
|
+
}
|
|
197
|
+
|
|
159
198
|
function normalizeCurrencyList(value: unknown): string[] {
|
|
160
199
|
const set = new Set<string>()
|
|
161
200
|
const visit = (entry: unknown) => {
|
|
@@ -302,7 +341,7 @@ export async function buildDealListFilters(query: DealListQuery, ctx?: import('@
|
|
|
302
341
|
filters.expected_close_at = range
|
|
303
342
|
}
|
|
304
343
|
|
|
305
|
-
if (query.isOverdue) {
|
|
344
|
+
if (query.isOverdue && !query.needsAttention) {
|
|
306
345
|
const today = new Date()
|
|
307
346
|
today.setHours(0, 0, 0, 0)
|
|
308
347
|
if (statusList.length === 0) {
|
|
@@ -316,7 +355,7 @@ export async function buildDealListFilters(query: DealListQuery, ctx?: import('@
|
|
|
316
355
|
filters.expected_close_at = existingRange
|
|
317
356
|
}
|
|
318
357
|
|
|
319
|
-
if (query.isStuck && ctx) {
|
|
358
|
+
if (query.isStuck && !query.needsAttention && ctx) {
|
|
320
359
|
const tenantId = ctx.auth?.tenantId
|
|
321
360
|
// CrudCtx.auth carries `orgId` (not `organizationId`). The previous code referenced
|
|
322
361
|
// `organizationId` which is always `undefined`, so the typeof check below silently
|
|
@@ -329,6 +368,16 @@ export async function buildDealListFilters(query: DealListQuery, ctx?: import('@
|
|
|
329
368
|
}
|
|
330
369
|
}
|
|
331
370
|
|
|
371
|
+
if (query.needsAttention && ctx) {
|
|
372
|
+
const tenantId = ctx.auth?.tenantId
|
|
373
|
+
const organizationId = ctx.auth?.orgId
|
|
374
|
+
if (typeof tenantId === 'string' && typeof organizationId === 'string') {
|
|
375
|
+
const em = ctx.container.resolve<EntityManager>('em')
|
|
376
|
+
const attentionIds = await fetchNeedAttentionDealIds(em, organizationId, tenantId)
|
|
377
|
+
intersectIds(attentionIds)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
332
381
|
// Pre-pagination association filter. Must run on the FULL dataset (before pagination),
|
|
333
382
|
// otherwise matching deals on later pages disappear and `total` would be wrong. Read the
|
|
334
383
|
// raw URL too so legacy `?personEntityId=` / `?companyEntityId=` keep working alongside the
|
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import type { EntityManager as CoreEntityManager } from '@mikro-orm/core'
|
|
4
|
+
import type { EntityManager as PgEntityManager } from '@mikro-orm/postgresql'
|
|
5
|
+
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
6
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
7
|
+
import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
8
|
+
import type { ExchangeRateService, RateResult } from '@open-mercato/core/modules/currencies/services/exchangeRateService'
|
|
9
|
+
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
10
|
+
import { fetchStuckDealIds } from '../../../lib/stuckDeals'
|
|
11
|
+
import {
|
|
12
|
+
computeDelta,
|
|
13
|
+
convertSumsToBase,
|
|
14
|
+
getPreviousQuarterWindow,
|
|
15
|
+
getQuarterWindow,
|
|
16
|
+
getTrailingMonths,
|
|
17
|
+
type CurrencySum,
|
|
18
|
+
type Delta,
|
|
19
|
+
} from '../../../lib/dealsMetrics'
|
|
20
|
+
|
|
21
|
+
export const metadata = {
|
|
22
|
+
GET: { requireAuth: true, requireFeatures: ['customers.deals.view'] },
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const OPEN_STATUSES = ['open', 'in_progress'] as const
|
|
26
|
+
const TRAILING_MONTHS = 6
|
|
27
|
+
const TOP_OWNERS = 5
|
|
28
|
+
|
|
29
|
+
const deltaSchema = z.object({
|
|
30
|
+
value: z.number(),
|
|
31
|
+
direction: z.enum(['up', 'down', 'unchanged']),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const stageBreakdownSchema = z.object({
|
|
35
|
+
stage: z.string().nullable(),
|
|
36
|
+
count: z.number(),
|
|
37
|
+
value: z.number(),
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const ownerCountSchema = z.object({
|
|
41
|
+
id: z.string(),
|
|
42
|
+
count: z.number(),
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const winRatePointSchema = z.object({
|
|
46
|
+
period: z.string(),
|
|
47
|
+
rate: z.number(),
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const summaryResponseSchema = z.object({
|
|
51
|
+
baseCurrencyCode: z.string().nullable(),
|
|
52
|
+
convertedAll: z.boolean(),
|
|
53
|
+
missingRateCurrencies: z.array(z.string()),
|
|
54
|
+
pipelineValue: z.object({
|
|
55
|
+
value: z.number(),
|
|
56
|
+
delta: deltaSchema,
|
|
57
|
+
stages: z.array(stageBreakdownSchema),
|
|
58
|
+
}),
|
|
59
|
+
activeDeals: z.object({
|
|
60
|
+
value: z.number(),
|
|
61
|
+
delta: deltaSchema,
|
|
62
|
+
ownersCount: z.number(),
|
|
63
|
+
needAttention: z.number(),
|
|
64
|
+
owners: z.array(ownerCountSchema),
|
|
65
|
+
ownersOverflow: z.number(),
|
|
66
|
+
}),
|
|
67
|
+
wonThisQuarter: z.object({
|
|
68
|
+
value: z.number(),
|
|
69
|
+
delta: deltaSchema,
|
|
70
|
+
dealsClosed: z.number(),
|
|
71
|
+
avgDeal: z.number(),
|
|
72
|
+
}),
|
|
73
|
+
winRate: z.object({
|
|
74
|
+
value: z.number(),
|
|
75
|
+
deltaPp: z.number(),
|
|
76
|
+
direction: z.enum(['up', 'down', 'unchanged']),
|
|
77
|
+
previousValue: z.number(),
|
|
78
|
+
series: z.array(winRatePointSchema),
|
|
79
|
+
}),
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
export type DealsSummaryResponse = z.infer<typeof summaryResponseSchema>
|
|
83
|
+
|
|
84
|
+
const summaryErrorSchema = z.object({
|
|
85
|
+
error: z.string(),
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
export const openApi: OpenApiRouteDoc = {
|
|
89
|
+
tag: 'Customers',
|
|
90
|
+
summary: 'Deals KPI summary',
|
|
91
|
+
methods: {
|
|
92
|
+
GET: {
|
|
93
|
+
summary: 'Pipeline KPI metrics with period-over-period deltas for the deals list',
|
|
94
|
+
description:
|
|
95
|
+
'Returns the four list-level KPI cards (pipeline value, active deals, won this quarter, win rate) with quarter-over-quarter deltas, per-stage open-pipeline breakdown, top owners, and a 6-month win-rate series. Values are converted to the tenant base currency where rates are available; partial conversions are disclosed via convertedAll/missingRateCurrencies.',
|
|
96
|
+
responses: [
|
|
97
|
+
{ status: 200, description: 'Deals KPI summary payload', schema: summaryResponseSchema },
|
|
98
|
+
],
|
|
99
|
+
errors: [
|
|
100
|
+
{ status: 401, description: 'Unauthorized', schema: summaryErrorSchema },
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
type OpenPipelineRow = {
|
|
107
|
+
stage: string | null
|
|
108
|
+
currency: string | null
|
|
109
|
+
total: string | number | null
|
|
110
|
+
count: string | number
|
|
111
|
+
owner_user_id: string | null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
type WindowSumRow = {
|
|
115
|
+
currency: string | null
|
|
116
|
+
current_total: string | number | null
|
|
117
|
+
current_count: string | number
|
|
118
|
+
previous_total: string | number | null
|
|
119
|
+
previous_count: string | number
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
type WinLossRow = {
|
|
123
|
+
current_won: string | number
|
|
124
|
+
current_lost: string | number
|
|
125
|
+
previous_won: string | number
|
|
126
|
+
previous_lost: string | number
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
type WinRateMonthRow = {
|
|
130
|
+
period: string
|
|
131
|
+
won: string | number
|
|
132
|
+
lost: string | number
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
type OwnerCountRow = {
|
|
136
|
+
owner_user_id: string | null
|
|
137
|
+
count: string | number
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function toNumber(value: string | number | null | undefined): number {
|
|
141
|
+
const parsed = Number(value ?? 0)
|
|
142
|
+
return Number.isFinite(parsed) ? parsed : 0
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function winRate(won: number, lost: number): number {
|
|
146
|
+
const denom = won + lost
|
|
147
|
+
if (denom <= 0) return 0
|
|
148
|
+
return Math.round((100 * won) / denom)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function sumsByCurrency(entries: Array<{ currency: string | null; total: number }>): CurrencySum[] {
|
|
152
|
+
const byCurrency = new Map<string, number>()
|
|
153
|
+
for (const entry of entries) {
|
|
154
|
+
const currency = (entry.currency ?? '').toString().trim().toUpperCase()
|
|
155
|
+
if (!currency) continue
|
|
156
|
+
byCurrency.set(currency, (byCurrency.get(currency) ?? 0) + entry.total)
|
|
157
|
+
}
|
|
158
|
+
return Array.from(byCurrency.entries()).map(([currency, total]) => ({ currency, total }))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function GET(req: Request) {
|
|
162
|
+
const auth = await getAuthFromRequest(req)
|
|
163
|
+
if (!auth?.tenantId || !auth.orgId) {
|
|
164
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const container = await createRequestContainer()
|
|
168
|
+
const em = container.resolve<CoreEntityManager>('em')
|
|
169
|
+
|
|
170
|
+
const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })
|
|
171
|
+
const effectiveTenantId = scope.tenantId ?? auth.tenantId
|
|
172
|
+
const orgFilterIds = Array.isArray(scope.filterIds) && scope.filterIds.length > 0
|
|
173
|
+
? scope.filterIds.filter((id) => typeof id === 'string' && id.length > 0)
|
|
174
|
+
: auth.orgId
|
|
175
|
+
? [auth.orgId]
|
|
176
|
+
: []
|
|
177
|
+
if (!effectiveTenantId || orgFilterIds.length === 0) {
|
|
178
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const today = new Date()
|
|
182
|
+
const currentQuarter = getQuarterWindow(today)
|
|
183
|
+
const previousQuarter = getPreviousQuarterWindow(today)
|
|
184
|
+
const trailingMonths = getTrailingMonths(today, TRAILING_MONTHS)
|
|
185
|
+
const seriesStart = trailingMonths[0]?.start ?? currentQuarter.start
|
|
186
|
+
|
|
187
|
+
const connection = em.getConnection()
|
|
188
|
+
|
|
189
|
+
const baseCurrency: Array<{ code: string }> = await connection.execute<Array<{ code: string }>>(
|
|
190
|
+
`SELECT code FROM currencies WHERE tenant_id = ? AND organization_id = ? AND is_base = true AND deleted_at IS NULL LIMIT 1`,
|
|
191
|
+
[effectiveTenantId, orgFilterIds[0]],
|
|
192
|
+
)
|
|
193
|
+
const baseCurrencyCode = baseCurrency[0]?.code ?? null
|
|
194
|
+
|
|
195
|
+
const orgPlaceholders = orgFilterIds.map(() => '?').join(',')
|
|
196
|
+
const scopeWhere = `tenant_id = ? AND organization_id IN (${orgPlaceholders}) AND deleted_at IS NULL`
|
|
197
|
+
const scopeValues: Array<string | number | null> = [effectiveTenantId, ...orgFilterIds]
|
|
198
|
+
const openPlaceholders = OPEN_STATUSES.map(() => '?').join(',')
|
|
199
|
+
|
|
200
|
+
// 1) Open pipeline: per (stage, currency) sums + open-deal owner per row, so we can
|
|
201
|
+
// derive pipeline value (per stage + converted total) and the open owner set in one pass.
|
|
202
|
+
const openRows: OpenPipelineRow[] = await connection.execute<OpenPipelineRow[]>(
|
|
203
|
+
`SELECT
|
|
204
|
+
pipeline_stage AS stage,
|
|
205
|
+
UPPER(COALESCE(value_currency, '')) AS currency,
|
|
206
|
+
COALESCE(SUM(value_amount), 0) AS total,
|
|
207
|
+
COUNT(*) AS count,
|
|
208
|
+
owner_user_id
|
|
209
|
+
FROM customer_deals
|
|
210
|
+
WHERE ${scopeWhere} AND status IN (${openPlaceholders})
|
|
211
|
+
GROUP BY pipeline_stage, UPPER(COALESCE(value_currency, '')), owner_user_id`,
|
|
212
|
+
[...scopeValues, ...OPEN_STATUSES],
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
// 2) Open-deal value created in the current vs previous quarter (pipeline inflow delta).
|
|
216
|
+
const inflowRows: WindowSumRow[] = await connection.execute<WindowSumRow[]>(
|
|
217
|
+
`SELECT
|
|
218
|
+
UPPER(COALESCE(value_currency, '')) AS currency,
|
|
219
|
+
COALESCE(SUM(value_amount) FILTER (WHERE created_at >= ? AND created_at < ?), 0) AS current_total,
|
|
220
|
+
COUNT(*) FILTER (WHERE created_at >= ? AND created_at < ?) AS current_count,
|
|
221
|
+
COALESCE(SUM(value_amount) FILTER (WHERE created_at >= ? AND created_at < ?), 0) AS previous_total,
|
|
222
|
+
COUNT(*) FILTER (WHERE created_at >= ? AND created_at < ?) AS previous_count
|
|
223
|
+
FROM customer_deals
|
|
224
|
+
WHERE ${scopeWhere} AND status IN (${openPlaceholders})
|
|
225
|
+
GROUP BY UPPER(COALESCE(value_currency, ''))`,
|
|
226
|
+
[
|
|
227
|
+
currentQuarter.start.toISOString(), currentQuarter.end.toISOString(),
|
|
228
|
+
currentQuarter.start.toISOString(), currentQuarter.end.toISOString(),
|
|
229
|
+
previousQuarter.start.toISOString(), previousQuarter.end.toISOString(),
|
|
230
|
+
previousQuarter.start.toISOString(), previousQuarter.end.toISOString(),
|
|
231
|
+
...scopeValues, ...OPEN_STATUSES,
|
|
232
|
+
],
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
// 3) Won value per currency for the current vs previous quarter (updated_at in window).
|
|
236
|
+
const wonRows: WindowSumRow[] = await connection.execute<WindowSumRow[]>(
|
|
237
|
+
`SELECT
|
|
238
|
+
UPPER(COALESCE(value_currency, '')) AS currency,
|
|
239
|
+
COALESCE(SUM(value_amount) FILTER (WHERE updated_at >= ? AND updated_at < ?), 0) AS current_total,
|
|
240
|
+
COUNT(*) FILTER (WHERE updated_at >= ? AND updated_at < ?) AS current_count,
|
|
241
|
+
COALESCE(SUM(value_amount) FILTER (WHERE updated_at >= ? AND updated_at < ?), 0) AS previous_total,
|
|
242
|
+
COUNT(*) FILTER (WHERE updated_at >= ? AND updated_at < ?) AS previous_count
|
|
243
|
+
FROM customer_deals
|
|
244
|
+
WHERE ${scopeWhere} AND (status = 'win' OR closure_outcome = 'won')
|
|
245
|
+
GROUP BY UPPER(COALESCE(value_currency, ''))`,
|
|
246
|
+
[
|
|
247
|
+
currentQuarter.start.toISOString(), currentQuarter.end.toISOString(),
|
|
248
|
+
currentQuarter.start.toISOString(), currentQuarter.end.toISOString(),
|
|
249
|
+
previousQuarter.start.toISOString(), previousQuarter.end.toISOString(),
|
|
250
|
+
previousQuarter.start.toISOString(), previousQuarter.end.toISOString(),
|
|
251
|
+
...scopeValues,
|
|
252
|
+
],
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
// 4) Win/lost counts for the current vs previous quarter (win rate + delta-pp).
|
|
256
|
+
const winLossRows: WinLossRow[] = await connection.execute<WinLossRow[]>(
|
|
257
|
+
`SELECT
|
|
258
|
+
COUNT(*) FILTER (WHERE (status = 'win' OR closure_outcome = 'won') AND updated_at >= ? AND updated_at < ?) AS current_won,
|
|
259
|
+
COUNT(*) FILTER (WHERE (status = 'loose' OR closure_outcome = 'lost') AND updated_at >= ? AND updated_at < ?) AS current_lost,
|
|
260
|
+
COUNT(*) FILTER (WHERE (status = 'win' OR closure_outcome = 'won') AND updated_at >= ? AND updated_at < ?) AS previous_won,
|
|
261
|
+
COUNT(*) FILTER (WHERE (status = 'loose' OR closure_outcome = 'lost') AND updated_at >= ? AND updated_at < ?) AS previous_lost
|
|
262
|
+
FROM customer_deals
|
|
263
|
+
WHERE ${scopeWhere}`,
|
|
264
|
+
[
|
|
265
|
+
currentQuarter.start.toISOString(), currentQuarter.end.toISOString(),
|
|
266
|
+
currentQuarter.start.toISOString(), currentQuarter.end.toISOString(),
|
|
267
|
+
previousQuarter.start.toISOString(), previousQuarter.end.toISOString(),
|
|
268
|
+
previousQuarter.start.toISOString(), previousQuarter.end.toISOString(),
|
|
269
|
+
...scopeValues,
|
|
270
|
+
],
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
// 5) Win-rate series over the trailing months (won/lost grouped by updated_at month).
|
|
274
|
+
const seriesRows: WinRateMonthRow[] = await connection.execute<WinRateMonthRow[]>(
|
|
275
|
+
`SELECT
|
|
276
|
+
to_char(date_trunc('month', updated_at AT TIME ZONE 'UTC'), 'YYYY-MM') AS period,
|
|
277
|
+
COUNT(*) FILTER (WHERE status = 'win' OR closure_outcome = 'won') AS won,
|
|
278
|
+
COUNT(*) FILTER (WHERE status = 'loose' OR closure_outcome = 'lost') AS lost
|
|
279
|
+
FROM customer_deals
|
|
280
|
+
WHERE ${scopeWhere} AND updated_at >= ?
|
|
281
|
+
GROUP BY 1`,
|
|
282
|
+
[...scopeValues, seriesStart.toISOString()],
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
// Overdue open deals (id set) + stuck deals (id set) → union count for "need attention".
|
|
286
|
+
const overdueRows: Array<{ id: string }> = await connection.execute<Array<{ id: string }>>(
|
|
287
|
+
`SELECT id FROM customer_deals
|
|
288
|
+
WHERE ${scopeWhere} AND status = 'open' AND expected_close_at IS NOT NULL AND expected_close_at < CURRENT_DATE`,
|
|
289
|
+
[...scopeValues],
|
|
290
|
+
)
|
|
291
|
+
// `fetchStuckDealIds` is single-org; run it for every org in scope so multi-org callers don't
|
|
292
|
+
// undercount stuck deals (the aggregates above already span every org in `orgFilterIds`).
|
|
293
|
+
const stuckIdLists = await Promise.all(
|
|
294
|
+
orgFilterIds.map((orgId) =>
|
|
295
|
+
fetchStuckDealIds(em as unknown as PgEntityManager, orgId, effectiveTenantId)),
|
|
296
|
+
)
|
|
297
|
+
const stuckIdSet = new Set<string>()
|
|
298
|
+
for (const list of stuckIdLists) for (const id of list) stuckIdSet.add(id)
|
|
299
|
+
|
|
300
|
+
// The stuck-deal query does not filter status, so a stuck id can be a won/lost/closed deal.
|
|
301
|
+
// "Need attention" is an active-deal metric — intersect with the open (OPEN_STATUSES) set so
|
|
302
|
+
// terminal deals never inflate the count.
|
|
303
|
+
let openStuckIds: string[] = []
|
|
304
|
+
if (stuckIdSet.size > 0) {
|
|
305
|
+
const stuckIdValues = Array.from(stuckIdSet)
|
|
306
|
+
const stuckPlaceholders = stuckIdValues.map(() => '?').join(',')
|
|
307
|
+
const openStuckRows: Array<{ id: string }> = await connection.execute<Array<{ id: string }>>(
|
|
308
|
+
`SELECT id FROM customer_deals
|
|
309
|
+
WHERE ${scopeWhere} AND status IN (${openPlaceholders}) AND id IN (${stuckPlaceholders})`,
|
|
310
|
+
[...scopeValues, ...OPEN_STATUSES, ...stuckIdValues],
|
|
311
|
+
)
|
|
312
|
+
openStuckIds = openStuckRows.map((row) => row.id)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const attentionIds = new Set<string>()
|
|
316
|
+
for (const row of overdueRows) attentionIds.add(row.id)
|
|
317
|
+
for (const id of openStuckIds) attentionIds.add(id)
|
|
318
|
+
|
|
319
|
+
// Reduce open rows: per-stage sums, distinct owners, owner counts, and a flat
|
|
320
|
+
// per-currency list for the converted pipeline total.
|
|
321
|
+
const stageMap = new Map<string, { stage: string | null; count: number; byCurrency: CurrencySum[] }>()
|
|
322
|
+
const openOwnerCounts = new Map<string, number>()
|
|
323
|
+
const openSums: Array<{ currency: string | null; total: number }> = []
|
|
324
|
+
for (const row of openRows) {
|
|
325
|
+
const stageKey = row.stage ?? '__null__'
|
|
326
|
+
const total = toNumber(row.total)
|
|
327
|
+
const count = toNumber(row.count)
|
|
328
|
+
const currency = (row.currency ?? '').toString().trim().toUpperCase()
|
|
329
|
+
if (!stageMap.has(stageKey)) {
|
|
330
|
+
stageMap.set(stageKey, { stage: row.stage ?? null, count: 0, byCurrency: [] })
|
|
331
|
+
}
|
|
332
|
+
const stageAgg = stageMap.get(stageKey)!
|
|
333
|
+
stageAgg.count += count
|
|
334
|
+
if (currency) stageAgg.byCurrency.push({ currency, total })
|
|
335
|
+
openSums.push({ currency, total })
|
|
336
|
+
if (row.owner_user_id) {
|
|
337
|
+
openOwnerCounts.set(row.owner_user_id, (openOwnerCounts.get(row.owner_user_id) ?? 0) + count)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Collect every distinct non-base currency across all metrics and fetch rates ONCE.
|
|
342
|
+
const distinctCurrencies = new Set<string>()
|
|
343
|
+
const collect = (entries: Array<{ currency: string | null }>) => {
|
|
344
|
+
for (const entry of entries) {
|
|
345
|
+
const currency = (entry.currency ?? '').toString().trim().toUpperCase()
|
|
346
|
+
if (currency && currency !== baseCurrencyCode) distinctCurrencies.add(currency)
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
collect(openSums)
|
|
350
|
+
collect(inflowRows)
|
|
351
|
+
collect(wonRows)
|
|
352
|
+
|
|
353
|
+
let rates = new Map<string, RateResult>()
|
|
354
|
+
if (baseCurrencyCode && distinctCurrencies.size > 0) {
|
|
355
|
+
const exchange = container.resolve('exchangeRateService') as ExchangeRateService | undefined
|
|
356
|
+
if (exchange) {
|
|
357
|
+
const pairs = Array.from(distinctCurrencies).map((code) => ({
|
|
358
|
+
fromCurrencyCode: code,
|
|
359
|
+
toCurrencyCode: baseCurrencyCode,
|
|
360
|
+
}))
|
|
361
|
+
try {
|
|
362
|
+
rates = await exchange.getRates({
|
|
363
|
+
pairs,
|
|
364
|
+
date: today,
|
|
365
|
+
scope: { tenantId: effectiveTenantId, organizationId: orgFilterIds[0] },
|
|
366
|
+
options: { maxDaysBack: 60, autoFetch: false },
|
|
367
|
+
})
|
|
368
|
+
} catch (err) {
|
|
369
|
+
console.warn('[customers.deals.summary] exchange-rate lookup failed; falling back to per-currency totals', err)
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const missingRateCurrencies = new Set<string>()
|
|
375
|
+
const trackMissing = (missing: string[]) => {
|
|
376
|
+
for (const code of missing) missingRateCurrencies.add(code)
|
|
377
|
+
}
|
|
378
|
+
let convertedAll = true
|
|
379
|
+
|
|
380
|
+
// Degraded path: when there is no base currency, fall back to the dominant currency's
|
|
381
|
+
// raw sum so the cards still show a number (mirrors the aggregate route's disclosure).
|
|
382
|
+
const dominantCurrencyTotal = (entries: Array<{ currency: string | null; total: number }>): number => {
|
|
383
|
+
const byCurrency = new Map<string, number>()
|
|
384
|
+
for (const entry of entries) {
|
|
385
|
+
const currency = (entry.currency ?? '').toString().trim().toUpperCase()
|
|
386
|
+
if (!currency) continue
|
|
387
|
+
byCurrency.set(currency, (byCurrency.get(currency) ?? 0) + entry.total)
|
|
388
|
+
}
|
|
389
|
+
let best = 0
|
|
390
|
+
for (const total of byCurrency.values()) {
|
|
391
|
+
if (Math.abs(total) > Math.abs(best)) best = total
|
|
392
|
+
}
|
|
393
|
+
return Math.round(best)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const convert = (entries: Array<{ currency: string | null; total: number }>): number => {
|
|
397
|
+
if (!baseCurrencyCode) {
|
|
398
|
+
convertedAll = false
|
|
399
|
+
trackMissing(sumsByCurrency(entries).map((entry) => entry.currency))
|
|
400
|
+
return dominantCurrencyTotal(entries)
|
|
401
|
+
}
|
|
402
|
+
const result = convertSumsToBase(sumsByCurrency(entries), baseCurrencyCode, rates)
|
|
403
|
+
if (!result.convertedAll) convertedAll = false
|
|
404
|
+
trackMissing(result.missingRateCurrencies)
|
|
405
|
+
return result.total
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Pipeline value (open deals, converted) + per-stage converted breakdown.
|
|
409
|
+
const pipelineValueTotal = convert(openSums)
|
|
410
|
+
const stages = Array.from(stageMap.values()).map((stageAgg) => ({
|
|
411
|
+
stage: stageAgg.stage,
|
|
412
|
+
count: stageAgg.count,
|
|
413
|
+
value: convert(stageAgg.byCurrency),
|
|
414
|
+
}))
|
|
415
|
+
|
|
416
|
+
// Pipeline inflow delta (open value created this vs previous quarter).
|
|
417
|
+
const inflowCurrent = convert(inflowRows.map((row) => ({ currency: row.currency, total: toNumber(row.current_total) })))
|
|
418
|
+
const inflowPrevious = convert(inflowRows.map((row) => ({ currency: row.currency, total: toNumber(row.previous_total) })))
|
|
419
|
+
const pipelineDelta: Delta = computeDelta(inflowCurrent, inflowPrevious)
|
|
420
|
+
|
|
421
|
+
// Active deals: count of open deals, owners, need-attention, top owners.
|
|
422
|
+
const activeDealsCount = openRows.reduce((sum, row) => sum + toNumber(row.count), 0)
|
|
423
|
+
const ownersCount = openOwnerCounts.size
|
|
424
|
+
const inflowCurrentCount = inflowRows.reduce((sum, row) => sum + toNumber(row.current_count), 0)
|
|
425
|
+
const inflowPreviousCount = inflowRows.reduce((sum, row) => sum + toNumber(row.previous_count), 0)
|
|
426
|
+
const activeDelta: Delta = computeDelta(inflowCurrentCount, inflowPreviousCount)
|
|
427
|
+
const sortedOwners = Array.from(openOwnerCounts.entries())
|
|
428
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
429
|
+
const owners = sortedOwners.slice(0, TOP_OWNERS).map(([id, count]) => ({ id, count }))
|
|
430
|
+
const ownersOverflow = Math.max(0, ownersCount - owners.length)
|
|
431
|
+
|
|
432
|
+
// Won this quarter.
|
|
433
|
+
const wonCurrent = convert(wonRows.map((row) => ({ currency: row.currency, total: toNumber(row.current_total) })))
|
|
434
|
+
const wonPrevious = convert(wonRows.map((row) => ({ currency: row.currency, total: toNumber(row.previous_total) })))
|
|
435
|
+
const dealsClosed = wonRows.reduce((sum, row) => sum + toNumber(row.current_count), 0)
|
|
436
|
+
const wonDelta: Delta = computeDelta(wonCurrent, wonPrevious)
|
|
437
|
+
const avgDeal = dealsClosed > 0 ? Math.round(wonCurrent / dealsClosed) : 0
|
|
438
|
+
|
|
439
|
+
// Win rate (current + previous quarter) and pp delta.
|
|
440
|
+
const winLoss = winLossRows[0]
|
|
441
|
+
const currentWon = toNumber(winLoss?.current_won)
|
|
442
|
+
const currentLost = toNumber(winLoss?.current_lost)
|
|
443
|
+
const previousWon = toNumber(winLoss?.previous_won)
|
|
444
|
+
const previousLost = toNumber(winLoss?.previous_lost)
|
|
445
|
+
const winRateValue = winRate(currentWon, currentLost)
|
|
446
|
+
const winRatePrevious = winRate(previousWon, previousLost)
|
|
447
|
+
const deltaPp = winRateValue - winRatePrevious
|
|
448
|
+
const winRateDirection = deltaPp > 0 ? 'up' : deltaPp < 0 ? 'down' : 'unchanged'
|
|
449
|
+
|
|
450
|
+
// Win-rate series over trailing months (fill missing months with 0).
|
|
451
|
+
const seriesByPeriod = new Map<string, { won: number; lost: number }>()
|
|
452
|
+
for (const row of seriesRows) {
|
|
453
|
+
seriesByPeriod.set(row.period, { won: toNumber(row.won), lost: toNumber(row.lost) })
|
|
454
|
+
}
|
|
455
|
+
const series = trailingMonths.map((month) => {
|
|
456
|
+
const point = seriesByPeriod.get(month.label)
|
|
457
|
+
const won = point?.won ?? 0
|
|
458
|
+
const lost = point?.lost ?? 0
|
|
459
|
+
const denom = won + lost
|
|
460
|
+
return { period: month.label, rate: denom > 0 ? won / denom : 0 }
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
const response: DealsSummaryResponse = {
|
|
464
|
+
baseCurrencyCode,
|
|
465
|
+
convertedAll,
|
|
466
|
+
missingRateCurrencies: Array.from(missingRateCurrencies),
|
|
467
|
+
pipelineValue: {
|
|
468
|
+
value: pipelineValueTotal,
|
|
469
|
+
delta: pipelineDelta,
|
|
470
|
+
stages,
|
|
471
|
+
},
|
|
472
|
+
activeDeals: {
|
|
473
|
+
value: activeDealsCount,
|
|
474
|
+
delta: activeDelta,
|
|
475
|
+
ownersCount,
|
|
476
|
+
needAttention: attentionIds.size,
|
|
477
|
+
owners,
|
|
478
|
+
ownersOverflow,
|
|
479
|
+
},
|
|
480
|
+
wonThisQuarter: {
|
|
481
|
+
value: wonCurrent,
|
|
482
|
+
delta: wonDelta,
|
|
483
|
+
dealsClosed,
|
|
484
|
+
avgDeal,
|
|
485
|
+
},
|
|
486
|
+
winRate: {
|
|
487
|
+
value: winRateValue,
|
|
488
|
+
deltaPp,
|
|
489
|
+
direction: winRateDirection,
|
|
490
|
+
previousValue: winRatePrevious,
|
|
491
|
+
series,
|
|
492
|
+
},
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return NextResponse.json(response)
|
|
496
|
+
}
|
|
@@ -4,6 +4,10 @@ import type { InteractionSummary } from '../../../../../components/detail/types'
|
|
|
4
4
|
import { useInteractionMutations } from '../../../../../components/detail/hooks/useInteractionMutations'
|
|
5
5
|
import type { GuardedMutationRunner } from './types'
|
|
6
6
|
|
|
7
|
+
type LoadPlannedActivitiesOptions = {
|
|
8
|
+
cache?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
7
11
|
type UseDealActivitiesOptions = {
|
|
8
12
|
dealId: string
|
|
9
13
|
runMutationWithContext: GuardedMutationRunner
|
|
@@ -12,12 +16,32 @@ type UseDealActivitiesOptions = {
|
|
|
12
16
|
type UseDealActivitiesResult = {
|
|
13
17
|
plannedActivities: InteractionSummary[]
|
|
14
18
|
activityRefreshKey: number
|
|
15
|
-
loadPlannedActivities: () => Promise<void>
|
|
19
|
+
loadPlannedActivities: (options?: LoadPlannedActivitiesOptions) => Promise<void>
|
|
16
20
|
handleActivityCreated: () => Promise<void>
|
|
17
21
|
handleMarkDone: (interactionId: string) => Promise<void>
|
|
18
22
|
handleCancelActivity: (interactionId: string) => Promise<void>
|
|
19
23
|
}
|
|
20
24
|
|
|
25
|
+
type PlannedActivitiesCacheEntry = {
|
|
26
|
+
promise: Promise<InteractionSummary[]>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const plannedActivitiesCache = new Map<string, PlannedActivitiesCacheEntry>()
|
|
30
|
+
|
|
31
|
+
function fetchPlannedActivities(dealId: string, useCache: boolean): Promise<InteractionSummary[]> {
|
|
32
|
+
const url = `/api/customers/interactions?dealId=${encodeURIComponent(dealId)}&status=planned&excludeInteractionType=task&limit=100&sortField=scheduledAt&sortDir=asc`
|
|
33
|
+
const cached = plannedActivitiesCache.get(url)
|
|
34
|
+
if (useCache && cached) return cached.promise
|
|
35
|
+
const entry: PlannedActivitiesCacheEntry = {
|
|
36
|
+
promise: readApiResultOrThrow<{ items?: InteractionSummary[] }>(url)
|
|
37
|
+
.then((result) => (Array.isArray(result.items) ? result.items : [])),
|
|
38
|
+
}
|
|
39
|
+
if (useCache) plannedActivitiesCache.set(url, entry)
|
|
40
|
+
return entry.promise.finally(() => {
|
|
41
|
+
if (plannedActivitiesCache.get(url) === entry) plannedActivitiesCache.delete(url)
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
21
45
|
export function useDealActivities({
|
|
22
46
|
dealId,
|
|
23
47
|
runMutationWithContext,
|
|
@@ -25,13 +49,11 @@ export function useDealActivities({
|
|
|
25
49
|
const [plannedActivities, setPlannedActivities] = React.useState<InteractionSummary[]>([])
|
|
26
50
|
const [activityRefreshKey, setActivityRefreshKey] = React.useState(0)
|
|
27
51
|
|
|
28
|
-
const loadPlannedActivities = React.useCallback(async () => {
|
|
52
|
+
const loadPlannedActivities = React.useCallback(async (options: LoadPlannedActivitiesOptions = {}) => {
|
|
29
53
|
if (!dealId) return
|
|
30
54
|
try {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
)
|
|
34
|
-
setPlannedActivities(Array.isArray(result.items) ? result.items : [])
|
|
55
|
+
const items = await fetchPlannedActivities(dealId, options.cache === true)
|
|
56
|
+
setPlannedActivities(items)
|
|
35
57
|
} catch (err) {
|
|
36
58
|
console.warn('[customers.deals.detail] load planned activities failed', err)
|
|
37
59
|
setPlannedActivities([])
|