@open-mercato/core 0.6.5-develop.5382.1.f542de69af → 0.6.6-develop.5412.1.e2a52b14f0
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/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/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/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/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/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/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/lib/engine.js +4 -2
- package/dist/modules/query_index/lib/engine.js.map +2 -2
- 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/package.json +8 -8
- 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/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/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/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/subscribers/invalidateOrgScopeCache.ts +3 -1
- package/src/modules/directory/utils/organizationScope.ts +85 -30
- 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/lib/engine.ts +11 -5
- 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
|
@@ -2542,6 +2542,7 @@ export default function DealsKanbanPage(): React.ReactElement {
|
|
|
2542
2542
|
return (
|
|
2543
2543
|
<Page>
|
|
2544
2544
|
<PageBody>
|
|
2545
|
+
<ViewTabsRow active="kanban" className="mb-4" />
|
|
2545
2546
|
<div className="flex flex-col gap-2">
|
|
2546
2547
|
<Breadcrumb>
|
|
2547
2548
|
<BreadcrumbList>
|
|
@@ -2710,8 +2711,6 @@ export default function DealsKanbanPage(): React.ReactElement {
|
|
|
2710
2711
|
) : null}
|
|
2711
2712
|
</div>
|
|
2712
2713
|
|
|
2713
|
-
<ViewTabsRow active="kanban" className="mt-4" />
|
|
2714
|
-
|
|
2715
2714
|
<FilterBarRow
|
|
2716
2715
|
leadingChips={leadingChipsNode}
|
|
2717
2716
|
chips={filterChips}
|
|
@@ -53,7 +53,7 @@ type DictionaryDefault = {
|
|
|
53
53
|
type CustomFieldValuesPayload = Parameters<DataEngine['setCustomFields']>[0]['values']
|
|
54
54
|
type ProgressBarHandle = ReturnType<typeof createProgressBar>
|
|
55
55
|
|
|
56
|
-
const DEAL_STATUS_DEFAULTS: DictionaryDefault[] = [
|
|
56
|
+
export const DEAL_STATUS_DEFAULTS: DictionaryDefault[] = [
|
|
57
57
|
{ value: 'open', label: 'Open', color: '#2563eb', icon: 'lucide:circle' },
|
|
58
58
|
{ value: 'closed', label: 'Closed', color: '#6b7280', icon: 'lucide:check-circle' },
|
|
59
59
|
{ value: 'win', label: 'Win', color: '#22c55e', icon: 'lucide:trophy' },
|
|
@@ -61,7 +61,7 @@ const DEAL_STATUS_DEFAULTS: DictionaryDefault[] = [
|
|
|
61
61
|
{ value: 'in_progress', label: 'In progress', color: '#f59e0b', icon: 'lucide:activity' },
|
|
62
62
|
]
|
|
63
63
|
|
|
64
|
-
const PIPELINE_STAGE_DEFAULTS: DictionaryDefault[] = [
|
|
64
|
+
export const PIPELINE_STAGE_DEFAULTS: DictionaryDefault[] = [
|
|
65
65
|
{ value: 'opportunity', label: 'Opportunity', color: '#38bdf8', icon: 'lucide:target' },
|
|
66
66
|
{ value: 'marketing_qualified_lead', label: 'Marketing Qualified Lead', color: '#a855f7', icon: 'lucide:sparkles' },
|
|
67
67
|
{ value: 'sales_qualified_lead', label: 'Sales Qualified Lead', color: '#f97316', icon: 'lucide:users' },
|
|
@@ -72,14 +72,14 @@ const PIPELINE_STAGE_DEFAULTS: DictionaryDefault[] = [
|
|
|
72
72
|
{ value: 'stalled', label: 'Stalled', color: '#6b7280', icon: 'lucide:alert-circle' },
|
|
73
73
|
]
|
|
74
74
|
|
|
75
|
-
const ENTITY_STATUS_DEFAULTS: DictionaryDefault[] = [
|
|
75
|
+
export const ENTITY_STATUS_DEFAULTS: DictionaryDefault[] = [
|
|
76
76
|
{ value: 'active', label: 'Active', color: '#22c55e', icon: 'lucide:user-check' },
|
|
77
77
|
{ value: 'inactive', label: 'Inactive', color: '#94a3b8', icon: 'lucide:pause-circle' },
|
|
78
78
|
{ value: 'pending', label: 'Pending', color: '#f59e0b', icon: 'lucide:clock' },
|
|
79
79
|
{ value: 'archived', label: 'Archived', color: '#64748b', icon: 'lucide:archive' },
|
|
80
80
|
]
|
|
81
81
|
|
|
82
|
-
const ENTITY_LIFECYCLE_STAGE_DEFAULTS: DictionaryDefault[] = [
|
|
82
|
+
export const ENTITY_LIFECYCLE_STAGE_DEFAULTS: DictionaryDefault[] = [
|
|
83
83
|
{ value: 'lead', label: 'Lead', color: '#3b82f6', icon: 'lucide:sparkles' },
|
|
84
84
|
{ value: 'prospect', label: 'Prospect', color: '#8b5cf6', icon: 'lucide:eye' },
|
|
85
85
|
{ value: 'customer', label: 'Customer', color: '#22c55e', icon: 'lucide:handshake' },
|
|
@@ -88,7 +88,7 @@ const ENTITY_LIFECYCLE_STAGE_DEFAULTS: DictionaryDefault[] = [
|
|
|
88
88
|
{ value: 'other', label: 'Other', color: '#94a3b8', icon: 'lucide:circle' },
|
|
89
89
|
]
|
|
90
90
|
|
|
91
|
-
const ENTITY_SOURCE_DEFAULTS: DictionaryDefault[] = [
|
|
91
|
+
export const ENTITY_SOURCE_DEFAULTS: DictionaryDefault[] = [
|
|
92
92
|
{ value: 'linkedin', label: 'LinkedIn', color: '#0a66c2', icon: 'lucide:linkedin' },
|
|
93
93
|
{ value: 'email', label: 'Email', color: '#3b82f6', icon: 'lucide:mail' },
|
|
94
94
|
{ value: 'web_form', label: 'Web form', color: '#22c55e', icon: 'lucide:globe' },
|
|
@@ -291,7 +291,7 @@ function isoDaysFromNow(days: number, options?: { hour?: number; minute?: number
|
|
|
291
291
|
return base.toISOString()
|
|
292
292
|
}
|
|
293
293
|
|
|
294
|
-
const CUSTOMER_EXAMPLES: ExampleCompany[] = [
|
|
294
|
+
export const CUSTOMER_EXAMPLES: ExampleCompany[] = [
|
|
295
295
|
{
|
|
296
296
|
slug: 'brightside-solar',
|
|
297
297
|
displayName: 'Brightside Solar',
|
|
@@ -307,7 +307,7 @@ const CUSTOMER_EXAMPLES: ExampleCompany[] = [
|
|
|
307
307
|
primaryPhone: '+1 415-555-0148',
|
|
308
308
|
source: 'partner_referral',
|
|
309
309
|
lifecycleStage: 'customer',
|
|
310
|
-
status: '
|
|
310
|
+
status: 'active',
|
|
311
311
|
custom: {
|
|
312
312
|
relationship_health: 'healthy',
|
|
313
313
|
renewal_quarter: 'Q3',
|
|
@@ -364,7 +364,7 @@ const CUSTOMER_EXAMPLES: ExampleCompany[] = [
|
|
|
364
364
|
phone: '+1 628-555-0199',
|
|
365
365
|
timezone: 'America/Los_Angeles',
|
|
366
366
|
linkedInUrl: 'https://www.linkedin.com/in/danielcho-energy/',
|
|
367
|
-
source: '
|
|
367
|
+
source: 'cold_outreach',
|
|
368
368
|
custom: {
|
|
369
369
|
buying_role: 'economic_buyer',
|
|
370
370
|
preferred_pronouns: 'he/him',
|
|
@@ -438,7 +438,7 @@ const CUSTOMER_EXAMPLES: ExampleCompany[] = [
|
|
|
438
438
|
valueCurrency: 'USD',
|
|
439
439
|
expectedCloseAt: isoDaysFromNow(65),
|
|
440
440
|
probability: 40,
|
|
441
|
-
source: '
|
|
441
|
+
source: 'web_form',
|
|
442
442
|
custom: {
|
|
443
443
|
competitive_risk: 'high',
|
|
444
444
|
implementation_complexity: 'complex',
|
|
@@ -513,7 +513,7 @@ const CUSTOMER_EXAMPLES: ExampleCompany[] = [
|
|
|
513
513
|
'Boston-based analytics platform helping consumer brands optimize merchandising decisions.',
|
|
514
514
|
primaryEmail: 'info@harborviewanalytics.com',
|
|
515
515
|
primaryPhone: '+1 617-555-0024',
|
|
516
|
-
source: '
|
|
516
|
+
source: 'event',
|
|
517
517
|
lifecycleStage: 'prospect',
|
|
518
518
|
status: 'active',
|
|
519
519
|
custom: {
|
|
@@ -545,7 +545,7 @@ const CUSTOMER_EXAMPLES: ExampleCompany[] = [
|
|
|
545
545
|
phone: '+1 617-555-0168',
|
|
546
546
|
timezone: 'America/New_York',
|
|
547
547
|
linkedInUrl: 'https://www.linkedin.com/in/arjunpatel-sales/',
|
|
548
|
-
source: '
|
|
548
|
+
source: 'event',
|
|
549
549
|
custom: {
|
|
550
550
|
buying_role: 'economic_buyer',
|
|
551
551
|
preferred_pronouns: 'he/him',
|
|
@@ -563,7 +563,7 @@ const CUSTOMER_EXAMPLES: ExampleCompany[] = [
|
|
|
563
563
|
phone: '+1 617-555-0179',
|
|
564
564
|
timezone: 'America/New_York',
|
|
565
565
|
linkedInUrl: 'https://www.linkedin.com/in/lenaortiz-retail/',
|
|
566
|
-
source: '
|
|
566
|
+
source: 'event',
|
|
567
567
|
custom: {
|
|
568
568
|
buying_role: 'champion',
|
|
569
569
|
preferred_pronouns: 'she/her',
|
|
@@ -582,7 +582,7 @@ const CUSTOMER_EXAMPLES: ExampleCompany[] = [
|
|
|
582
582
|
valueCurrency: 'USD',
|
|
583
583
|
expectedCloseAt: isoDaysFromNow(-25),
|
|
584
584
|
probability: 100,
|
|
585
|
-
source: '
|
|
585
|
+
source: 'event',
|
|
586
586
|
custom: {
|
|
587
587
|
competitive_risk: 'low',
|
|
588
588
|
implementation_complexity: 'standard',
|
|
@@ -637,7 +637,7 @@ const CUSTOMER_EXAMPLES: ExampleCompany[] = [
|
|
|
637
637
|
valueCurrency: 'USD',
|
|
638
638
|
expectedCloseAt: isoDaysFromNow(120),
|
|
639
639
|
probability: 35,
|
|
640
|
-
source: '
|
|
640
|
+
source: 'cold_outreach',
|
|
641
641
|
custom: {
|
|
642
642
|
competitive_risk: 'medium',
|
|
643
643
|
implementation_complexity: 'complex',
|
|
@@ -715,7 +715,7 @@ const CUSTOMER_EXAMPLES: ExampleCompany[] = [
|
|
|
715
715
|
primaryPhone: '+1 512-555-0456',
|
|
716
716
|
source: 'customer_referral',
|
|
717
717
|
lifecycleStage: 'customer',
|
|
718
|
-
status: '
|
|
718
|
+
status: 'active',
|
|
719
719
|
custom: {
|
|
720
720
|
relationship_health: 'healthy',
|
|
721
721
|
renewal_quarter: 'Q1',
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { CheckCircle } from 'lucide-react'
|
|
5
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
6
|
+
import { useT, useLocale } from '@open-mercato/shared/lib/i18n/context'
|
|
7
|
+
import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
|
|
8
|
+
import { KpiCard, DeltaBadge, Sparkline } from '@open-mercato/ui/backend/charts'
|
|
9
|
+
import { Avatar, AvatarStack } from '@open-mercato/ui/primitives/avatar'
|
|
10
|
+
import { Button } from '@open-mercato/ui/primitives/button'
|
|
11
|
+
import { Spinner } from '@open-mercato/ui/primitives/spinner'
|
|
12
|
+
import type { DictionaryMap } from '@open-mercato/core/modules/dictionaries/components/dictionaryAppearance'
|
|
13
|
+
import { PipelineStageBar } from './kpi/PipelineStageBar'
|
|
14
|
+
|
|
15
|
+
type DeltaDirection = 'up' | 'down' | 'unchanged'
|
|
16
|
+
|
|
17
|
+
type SummaryDelta = {
|
|
18
|
+
value: number
|
|
19
|
+
direction: DeltaDirection
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type DealsSummaryResponse = {
|
|
23
|
+
baseCurrencyCode: string | null
|
|
24
|
+
convertedAll: boolean
|
|
25
|
+
missingRateCurrencies: string[]
|
|
26
|
+
pipelineValue: {
|
|
27
|
+
value: number
|
|
28
|
+
delta: SummaryDelta
|
|
29
|
+
stages: { stage: string | null; count: number; value: number }[]
|
|
30
|
+
}
|
|
31
|
+
activeDeals: {
|
|
32
|
+
value: number
|
|
33
|
+
delta: SummaryDelta
|
|
34
|
+
ownersCount: number
|
|
35
|
+
needAttention: number
|
|
36
|
+
owners: { id: string; count: number }[]
|
|
37
|
+
ownersOverflow: number
|
|
38
|
+
}
|
|
39
|
+
wonThisQuarter: {
|
|
40
|
+
value: number
|
|
41
|
+
delta: SummaryDelta
|
|
42
|
+
dealsClosed: number
|
|
43
|
+
avgDeal: number
|
|
44
|
+
}
|
|
45
|
+
winRate: {
|
|
46
|
+
value: number
|
|
47
|
+
deltaPp: number
|
|
48
|
+
direction: DeltaDirection
|
|
49
|
+
previousValue: number
|
|
50
|
+
series: { period: string; rate: number }[]
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type DealsKpiStripProps = {
|
|
55
|
+
ownerNames: Record<string, string>
|
|
56
|
+
stageDictionary: DictionaryMap
|
|
57
|
+
pipelineCount: number
|
|
58
|
+
className?: string
|
|
59
|
+
/** Bumped by the host when the active org scope changes — forces a KPI refetch so the cards never show another org's data. */
|
|
60
|
+
scopeVersion?: number
|
|
61
|
+
/** Bumped by the host on manual refresh / after mutations — forces a KPI refetch so totals stay in sync with the table. */
|
|
62
|
+
reloadToken?: number
|
|
63
|
+
onNeedsAttentionClick?: () => void
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const compactNumberFormatter = new Intl.NumberFormat(undefined, {
|
|
67
|
+
notation: 'compact',
|
|
68
|
+
maximumFractionDigits: 1,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
function formatCompact(value: number): string {
|
|
72
|
+
return compactNumberFormatter.format(value)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function buildCurrencySuffix(code: string | null, convertedAll: boolean): string {
|
|
76
|
+
if (!code) return convertedAll ? '' : '≈'
|
|
77
|
+
return convertedAll ? code : `≈ ${code}`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const KPI_TITLE_CLASS = 'text-xs font-semibold uppercase tracking-wide text-muted-foreground'
|
|
81
|
+
|
|
82
|
+
function DealKpiCard(props: React.ComponentProps<typeof KpiCard>) {
|
|
83
|
+
return <KpiCard titleClassName={KPI_TITLE_CLASS} {...props} />
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function KpiDeltaBadge({
|
|
87
|
+
direction,
|
|
88
|
+
value,
|
|
89
|
+
unit,
|
|
90
|
+
title,
|
|
91
|
+
}: {
|
|
92
|
+
direction: DeltaDirection
|
|
93
|
+
value: number
|
|
94
|
+
unit?: string
|
|
95
|
+
title: string
|
|
96
|
+
}) {
|
|
97
|
+
if (direction === 'unchanged' && value === 0) {
|
|
98
|
+
return (
|
|
99
|
+
<span
|
|
100
|
+
className="inline-flex items-center rounded-md bg-status-neutral-bg px-2 py-0.5 text-xs font-medium text-status-neutral-text"
|
|
101
|
+
title={title}
|
|
102
|
+
>
|
|
103
|
+
--
|
|
104
|
+
</span>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
return <DeltaBadge direction={direction} value={value} unit={unit} title={title} />
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
111
|
+
return typeof value === 'object' && value !== null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Guard the summary payload before rendering: a non-conforming response (an unrelated
|
|
115
|
+
// endpoint mock, an error body, or a future contract drift) must surface the error card,
|
|
116
|
+
// never crash the whole deals page by dereferencing missing sections/arrays.
|
|
117
|
+
function isDealsSummaryResponse(value: unknown): value is DealsSummaryResponse {
|
|
118
|
+
if (!isObject(value)) return false
|
|
119
|
+
const { pipelineValue, activeDeals, wonThisQuarter, winRate } = value
|
|
120
|
+
return (
|
|
121
|
+
isObject(pipelineValue) && Array.isArray(pipelineValue.stages) &&
|
|
122
|
+
isObject(activeDeals) && Array.isArray(activeDeals.owners) &&
|
|
123
|
+
isObject(wonThisQuarter) &&
|
|
124
|
+
isObject(winRate) && Array.isArray(winRate.series)
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function DealsKpiStrip({
|
|
129
|
+
ownerNames,
|
|
130
|
+
stageDictionary,
|
|
131
|
+
pipelineCount,
|
|
132
|
+
className,
|
|
133
|
+
scopeVersion,
|
|
134
|
+
reloadToken,
|
|
135
|
+
onNeedsAttentionClick,
|
|
136
|
+
}: DealsKpiStripProps) {
|
|
137
|
+
const t = useT()
|
|
138
|
+
const locale = useLocale()
|
|
139
|
+
const pluralCat = React.useCallback((count: number): string => {
|
|
140
|
+
try {
|
|
141
|
+
return new Intl.PluralRules(locale).select(count)
|
|
142
|
+
} catch {
|
|
143
|
+
return count === 1 ? 'one' : 'other'
|
|
144
|
+
}
|
|
145
|
+
}, [locale])
|
|
146
|
+
const pf = React.useCallback((base: string, count: number): string => {
|
|
147
|
+
const cat = pluralCat(count)
|
|
148
|
+
const key = `${base}.${cat}`
|
|
149
|
+
const out = t(key, { count })
|
|
150
|
+
return out === key ? t(`${base}.other`, { count }) : out
|
|
151
|
+
}, [t, pluralCat])
|
|
152
|
+
const [data, setData] = React.useState<DealsSummaryResponse | null>(null)
|
|
153
|
+
const [loading, setLoading] = React.useState(true)
|
|
154
|
+
const [error, setError] = React.useState<string | null>(null)
|
|
155
|
+
const [retryToken, setRetryToken] = React.useState(0)
|
|
156
|
+
const previousScopeVersionRef = React.useRef(scopeVersion)
|
|
157
|
+
|
|
158
|
+
const retry = React.useCallback(() => {
|
|
159
|
+
setRetryToken((token) => token + 1)
|
|
160
|
+
}, [])
|
|
161
|
+
|
|
162
|
+
React.useEffect(() => {
|
|
163
|
+
let cancelled = false
|
|
164
|
+
const scopeChanged = previousScopeVersionRef.current !== scopeVersion
|
|
165
|
+
previousScopeVersionRef.current = scopeVersion
|
|
166
|
+
if (scopeChanged) setData(null)
|
|
167
|
+
setLoading(true)
|
|
168
|
+
setError(null)
|
|
169
|
+
apiCall<DealsSummaryResponse>('/api/customers/deals/summary')
|
|
170
|
+
.then((call) => {
|
|
171
|
+
if (cancelled) return
|
|
172
|
+
if (!call.ok || !isDealsSummaryResponse(call.result)) {
|
|
173
|
+
setError(t('customers.deals.list.kpi.error'))
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
setData(call.result)
|
|
177
|
+
})
|
|
178
|
+
.catch(() => {
|
|
179
|
+
if (cancelled) return
|
|
180
|
+
setError(t('customers.deals.list.kpi.error'))
|
|
181
|
+
})
|
|
182
|
+
.finally(() => {
|
|
183
|
+
if (!cancelled) setLoading(false)
|
|
184
|
+
})
|
|
185
|
+
return () => {
|
|
186
|
+
cancelled = true
|
|
187
|
+
}
|
|
188
|
+
}, [t, scopeVersion, reloadToken, retryToken])
|
|
189
|
+
|
|
190
|
+
const wrapperClassName = cn('space-y-2', className)
|
|
191
|
+
const gridClassName = 'grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4'
|
|
192
|
+
|
|
193
|
+
if (loading && !data) {
|
|
194
|
+
return (
|
|
195
|
+
<div className={wrapperClassName}>
|
|
196
|
+
<div className={gridClassName}>
|
|
197
|
+
<DealKpiCard loading title={t('customers.deals.list.kpi.pipelineValue')} value={null} />
|
|
198
|
+
<DealKpiCard loading title={t('customers.deals.list.kpi.activeDeals')} value={null} />
|
|
199
|
+
<DealKpiCard loading title={t('customers.deals.list.kpi.wonThisQuarter')} value={null} />
|
|
200
|
+
<DealKpiCard loading title={t('customers.deals.list.kpi.winRate')} value={null} />
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!data) {
|
|
207
|
+
const errorMessage = error ?? t('customers.deals.list.kpi.error')
|
|
208
|
+
return (
|
|
209
|
+
<div className={wrapperClassName}>
|
|
210
|
+
<div className="flex items-center justify-between gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
|
|
211
|
+
<p className="text-sm text-destructive">{errorMessage}</p>
|
|
212
|
+
<Button type="button" variant="destructive-outline" size="sm" onClick={retry}>
|
|
213
|
+
{t('customers.deals.list.kpi.retry')}
|
|
214
|
+
</Button>
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const currencySuffix = buildCurrencySuffix(data.baseCurrencyCode, data.convertedAll)
|
|
221
|
+
const unassignedLabel = t('customers.deals.list.kpi.unassignedStage')
|
|
222
|
+
const deltaTooltip = t('customers.deals.list.kpi.deltaTooltip')
|
|
223
|
+
const deltaUnavailableTooltip = t('customers.deals.list.kpi.deltaUnavailable')
|
|
224
|
+
const scopeLabel = t('customers.deals.list.kpi.scopeAllPipelinesThisQuarter')
|
|
225
|
+
const unknownOwner = t('customers.deals.list.unknownOwner')
|
|
226
|
+
const currencyHint = !data.convertedAll
|
|
227
|
+
? data.baseCurrencyCode
|
|
228
|
+
? t('customers.deals.list.kpi.currencyApproxMissing', {
|
|
229
|
+
currencies: data.missingRateCurrencies.length ? data.missingRateCurrencies.join(', ') : currencySuffix,
|
|
230
|
+
})
|
|
231
|
+
: t('customers.deals.list.kpi.currencyApproxNoBase')
|
|
232
|
+
: null
|
|
233
|
+
const attentionLabel = pf('customers.deals.list.kpi.frag.needAttention', data.activeDeals.needAttention)
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<div className={wrapperClassName}>
|
|
237
|
+
{error ? (
|
|
238
|
+
<div className="flex items-center justify-between gap-3 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
|
|
239
|
+
<p className="text-xs text-destructive">{error}</p>
|
|
240
|
+
<Button type="button" variant="destructive-outline" size="2xs" onClick={retry}>
|
|
241
|
+
{t('customers.deals.list.kpi.retry')}
|
|
242
|
+
</Button>
|
|
243
|
+
</div>
|
|
244
|
+
) : null}
|
|
245
|
+
{loading ? (
|
|
246
|
+
<div className="flex items-center justify-end gap-2 text-xs text-muted-foreground">
|
|
247
|
+
<Spinner className="h-3 w-3" />
|
|
248
|
+
<span>{t('customers.deals.list.kpi.updating')}</span>
|
|
249
|
+
</div>
|
|
250
|
+
) : null}
|
|
251
|
+
<div className={gridClassName}>
|
|
252
|
+
<DealKpiCard
|
|
253
|
+
title={t('customers.deals.list.kpi.pipelineValue')}
|
|
254
|
+
value={data.pipelineValue.value}
|
|
255
|
+
formatValue={formatCompact}
|
|
256
|
+
suffix={currencySuffix}
|
|
257
|
+
headerAction={
|
|
258
|
+
<KpiDeltaBadge
|
|
259
|
+
direction={data.pipelineValue.delta.direction}
|
|
260
|
+
value={data.pipelineValue.delta.value}
|
|
261
|
+
title={data.pipelineValue.delta.direction === 'unchanged' && data.pipelineValue.delta.value === 0 ? deltaUnavailableTooltip : deltaTooltip}
|
|
262
|
+
/>
|
|
263
|
+
}
|
|
264
|
+
footer={
|
|
265
|
+
<div className="space-y-2">
|
|
266
|
+
<p className="text-xs text-muted-foreground">{scopeLabel}</p>
|
|
267
|
+
<p className="text-xs text-muted-foreground">
|
|
268
|
+
{t('customers.deals.list.kpi.activeAcrossPipelines', {
|
|
269
|
+
deals: pf('customers.deals.list.kpi.frag.activeDeals', data.activeDeals.value),
|
|
270
|
+
pipelines: pf('customers.deals.list.kpi.frag.pipelines', pipelineCount),
|
|
271
|
+
})}
|
|
272
|
+
</p>
|
|
273
|
+
{currencyHint ? <p className="text-xs text-muted-foreground">{currencyHint}</p> : null}
|
|
274
|
+
<PipelineStageBar
|
|
275
|
+
stages={data.pipelineValue.stages}
|
|
276
|
+
stageDictionary={stageDictionary}
|
|
277
|
+
unassignedLabel={unassignedLabel}
|
|
278
|
+
/>
|
|
279
|
+
</div>
|
|
280
|
+
}
|
|
281
|
+
/>
|
|
282
|
+
|
|
283
|
+
<DealKpiCard
|
|
284
|
+
title={t('customers.deals.list.kpi.activeDeals')}
|
|
285
|
+
value={data.activeDeals.value}
|
|
286
|
+
formatValue={formatCompact}
|
|
287
|
+
headerAction={
|
|
288
|
+
<KpiDeltaBadge
|
|
289
|
+
direction={data.activeDeals.delta.direction}
|
|
290
|
+
value={data.activeDeals.delta.value}
|
|
291
|
+
title={data.activeDeals.delta.direction === 'unchanged' && data.activeDeals.delta.value === 0 ? deltaUnavailableTooltip : deltaTooltip}
|
|
292
|
+
/>
|
|
293
|
+
}
|
|
294
|
+
footer={
|
|
295
|
+
<div className="space-y-2">
|
|
296
|
+
<p className="text-xs text-muted-foreground">{scopeLabel}</p>
|
|
297
|
+
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted-foreground">
|
|
298
|
+
<span>{pf('customers.deals.list.kpi.frag.owners', data.activeDeals.ownersCount)}</span>
|
|
299
|
+
<span aria-hidden="true">·</span>
|
|
300
|
+
{onNeedsAttentionClick && data.activeDeals.needAttention > 0 ? (
|
|
301
|
+
<Button
|
|
302
|
+
type="button"
|
|
303
|
+
variant="link"
|
|
304
|
+
size="2xs"
|
|
305
|
+
className="h-auto p-0 text-xs"
|
|
306
|
+
onClick={onNeedsAttentionClick}
|
|
307
|
+
>
|
|
308
|
+
{attentionLabel}
|
|
309
|
+
</Button>
|
|
310
|
+
) : (
|
|
311
|
+
<span>{attentionLabel}</span>
|
|
312
|
+
)}
|
|
313
|
+
</div>
|
|
314
|
+
{data.activeDeals.owners.length > 0 ? (
|
|
315
|
+
<AvatarStack max={4} size="sm" overflowCount={data.activeDeals.ownersOverflow}>
|
|
316
|
+
{data.activeDeals.owners.map((owner) => {
|
|
317
|
+
const ownerLabel = ownerNames[owner.id]?.trim() || unknownOwner
|
|
318
|
+
return <Avatar key={owner.id} label={ownerLabel} size="sm" />
|
|
319
|
+
})}
|
|
320
|
+
</AvatarStack>
|
|
321
|
+
) : null}
|
|
322
|
+
</div>
|
|
323
|
+
}
|
|
324
|
+
/>
|
|
325
|
+
|
|
326
|
+
<DealKpiCard
|
|
327
|
+
title={t('customers.deals.list.kpi.wonThisQuarter')}
|
|
328
|
+
value={data.wonThisQuarter.value}
|
|
329
|
+
formatValue={formatCompact}
|
|
330
|
+
suffix={currencySuffix}
|
|
331
|
+
headerAction={
|
|
332
|
+
<KpiDeltaBadge
|
|
333
|
+
direction={data.wonThisQuarter.delta.direction}
|
|
334
|
+
value={data.wonThisQuarter.delta.value}
|
|
335
|
+
title={data.wonThisQuarter.delta.direction === 'unchanged' && data.wonThisQuarter.delta.value === 0 ? deltaUnavailableTooltip : deltaTooltip}
|
|
336
|
+
/>
|
|
337
|
+
}
|
|
338
|
+
footer={
|
|
339
|
+
<div className="space-y-1">
|
|
340
|
+
<p className="text-xs text-muted-foreground">{scopeLabel}</p>
|
|
341
|
+
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
342
|
+
<CheckCircle className="h-4 w-4 text-status-success-text" aria-hidden />
|
|
343
|
+
<span>
|
|
344
|
+
{pf('customers.deals.list.kpi.frag.dealsClosed', data.wonThisQuarter.dealsClosed)}
|
|
345
|
+
</span>
|
|
346
|
+
</div>
|
|
347
|
+
<p className="text-xs text-muted-foreground">
|
|
348
|
+
{t('customers.deals.list.kpi.avgDeal', {
|
|
349
|
+
value: `${formatCompact(data.wonThisQuarter.avgDeal)}${currencySuffix ? ` ${currencySuffix}` : ''}`,
|
|
350
|
+
})}
|
|
351
|
+
</p>
|
|
352
|
+
{currencyHint ? <p className="text-xs text-muted-foreground">{currencyHint}</p> : null}
|
|
353
|
+
</div>
|
|
354
|
+
}
|
|
355
|
+
/>
|
|
356
|
+
|
|
357
|
+
<DealKpiCard
|
|
358
|
+
title={t('customers.deals.list.kpi.winRate')}
|
|
359
|
+
value={data.winRate.value}
|
|
360
|
+
suffix="%"
|
|
361
|
+
headerAction={
|
|
362
|
+
<KpiDeltaBadge
|
|
363
|
+
direction={data.winRate.direction}
|
|
364
|
+
value={Math.abs(data.winRate.deltaPp)}
|
|
365
|
+
unit="pp"
|
|
366
|
+
title={data.winRate.previousValue === 0 ? deltaUnavailableTooltip : deltaTooltip}
|
|
367
|
+
/>
|
|
368
|
+
}
|
|
369
|
+
footer={
|
|
370
|
+
<div className="space-y-2">
|
|
371
|
+
<p className="text-xs text-muted-foreground">{scopeLabel}</p>
|
|
372
|
+
<p className="text-xs text-muted-foreground">
|
|
373
|
+
{t('customers.deals.list.kpi.fromLastQuarter', { value: data.winRate.previousValue })}
|
|
374
|
+
</p>
|
|
375
|
+
<div className="text-primary">
|
|
376
|
+
<Sparkline
|
|
377
|
+
values={data.winRate.series.map((point) => point.rate)}
|
|
378
|
+
ariaLabel={t('customers.deals.list.kpi.winRate')}
|
|
379
|
+
/>
|
|
380
|
+
</div>
|
|
381
|
+
</div>
|
|
382
|
+
}
|
|
383
|
+
/>
|
|
384
|
+
</div>
|
|
385
|
+
</div>
|
|
386
|
+
)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export default DealsKpiStrip
|
|
@@ -116,7 +116,6 @@ export function ConfirmDealLostDialog({
|
|
|
116
116
|
|
|
117
117
|
<div className="space-y-6 px-7 py-6">
|
|
118
118
|
<Alert variant="warning" className="rounded-md">
|
|
119
|
-
<AlertTriangle className="size-4" />
|
|
120
119
|
<AlertTitle>
|
|
121
120
|
{t('customers.deals.detail.lost.warningTitle', 'This action closes the deal')}
|
|
122
121
|
</AlertTitle>
|