@contractspec/example.crm-pipeline 3.7.6 → 3.7.10

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.
Files changed (130) hide show
  1. package/.turbo/turbo-build.log +45 -42
  2. package/AGENTS.md +51 -33
  3. package/CHANGELOG.md +36 -0
  4. package/README.md +67 -148
  5. package/dist/browser/docs/crm-pipeline.docblock.js +1 -1
  6. package/dist/browser/docs/index.js +1 -1
  7. package/dist/browser/events/contact.event.js +1 -1
  8. package/dist/browser/events/deal.event.js +1 -1
  9. package/dist/browser/events/index.js +3 -3
  10. package/dist/browser/events/task.event.js +1 -1
  11. package/dist/browser/handlers/crm.handlers.js +13 -2
  12. package/dist/browser/handlers/index.js +13 -2
  13. package/dist/browser/index.js +680 -447
  14. package/dist/browser/ui/CrmDashboard.js +574 -352
  15. package/dist/browser/ui/CrmDealCard.js +5 -5
  16. package/dist/browser/ui/CrmPipelineBoard.js +13 -13
  17. package/dist/browser/ui/hooks/index.js +21 -10
  18. package/dist/browser/ui/hooks/useDealList.js +20 -9
  19. package/dist/browser/ui/hooks/useDealMutations.js +1 -1
  20. package/dist/browser/ui/index.js +683 -450
  21. package/dist/browser/ui/modals/CreateDealModal.js +12 -12
  22. package/dist/browser/ui/modals/DealActionsModal.js +21 -21
  23. package/dist/browser/ui/modals/index.js +33 -33
  24. package/dist/browser/ui/renderers/index.js +140 -118
  25. package/dist/browser/ui/renderers/pipeline.markdown.js +13 -2
  26. package/dist/browser/ui/renderers/pipeline.renderer.js +108 -97
  27. package/dist/browser/ui/tables/DealListTab.js +390 -0
  28. package/dist/deal/index.d.ts +2 -2
  29. package/dist/docs/crm-pipeline.docblock.js +1 -1
  30. package/dist/docs/index.js +1 -1
  31. package/dist/events/contact.event.js +1 -1
  32. package/dist/events/deal.event.js +1 -1
  33. package/dist/events/index.js +3 -3
  34. package/dist/events/task.event.js +1 -1
  35. package/dist/handlers/crm.handlers.d.ts +2 -0
  36. package/dist/handlers/crm.handlers.js +13 -2
  37. package/dist/handlers/index.d.ts +2 -2
  38. package/dist/handlers/index.js +13 -2
  39. package/dist/index.d.ts +3 -3
  40. package/dist/index.js +680 -447
  41. package/dist/node/docs/crm-pipeline.docblock.js +1 -1
  42. package/dist/node/docs/index.js +1 -1
  43. package/dist/node/events/contact.event.js +1 -1
  44. package/dist/node/events/deal.event.js +1 -1
  45. package/dist/node/events/index.js +3 -3
  46. package/dist/node/events/task.event.js +1 -1
  47. package/dist/node/handlers/crm.handlers.js +13 -2
  48. package/dist/node/handlers/index.js +13 -2
  49. package/dist/node/index.js +680 -447
  50. package/dist/node/ui/CrmDashboard.js +574 -352
  51. package/dist/node/ui/CrmDealCard.js +5 -5
  52. package/dist/node/ui/CrmPipelineBoard.js +13 -13
  53. package/dist/node/ui/hooks/index.js +21 -10
  54. package/dist/node/ui/hooks/useDealList.js +20 -9
  55. package/dist/node/ui/hooks/useDealMutations.js +1 -1
  56. package/dist/node/ui/index.js +683 -450
  57. package/dist/node/ui/modals/CreateDealModal.js +12 -12
  58. package/dist/node/ui/modals/DealActionsModal.js +21 -21
  59. package/dist/node/ui/modals/index.js +33 -33
  60. package/dist/node/ui/renderers/index.js +140 -118
  61. package/dist/node/ui/renderers/pipeline.markdown.js +13 -2
  62. package/dist/node/ui/renderers/pipeline.renderer.js +108 -97
  63. package/dist/node/ui/tables/DealListTab.js +390 -0
  64. package/dist/operations/index.d.ts +1 -1
  65. package/dist/ui/CrmDashboard.js +574 -352
  66. package/dist/ui/CrmDealCard.js +5 -5
  67. package/dist/ui/CrmPipelineBoard.js +13 -13
  68. package/dist/ui/hooks/index.d.ts +2 -2
  69. package/dist/ui/hooks/index.js +21 -10
  70. package/dist/ui/hooks/useDealList.d.ts +8 -2
  71. package/dist/ui/hooks/useDealList.js +20 -9
  72. package/dist/ui/hooks/useDealMutations.d.ts +9 -0
  73. package/dist/ui/hooks/useDealMutations.js +1 -1
  74. package/dist/ui/index.d.ts +3 -3
  75. package/dist/ui/index.js +683 -450
  76. package/dist/ui/modals/CreateDealModal.js +12 -12
  77. package/dist/ui/modals/DealActionsModal.js +21 -21
  78. package/dist/ui/modals/index.js +33 -33
  79. package/dist/ui/renderers/index.d.ts +1 -1
  80. package/dist/ui/renderers/index.js +140 -118
  81. package/dist/ui/renderers/pipeline.markdown.js +13 -2
  82. package/dist/ui/renderers/pipeline.renderer.d.ts +1 -1
  83. package/dist/ui/renderers/pipeline.renderer.js +108 -97
  84. package/dist/ui/tables/DealListTab.d.ts +20 -0
  85. package/dist/ui/tables/DealListTab.js +391 -0
  86. package/dist/ui/tables/DealListTab.smoke.test.d.ts +1 -0
  87. package/package.json +29 -14
  88. package/src/crm-pipeline.feature.ts +86 -86
  89. package/src/deal/deal.enum.ts +8 -8
  90. package/src/deal/deal.operation.ts +255 -255
  91. package/src/deal/deal.schema.ts +92 -92
  92. package/src/deal/deal.test-spec.ts +48 -48
  93. package/src/deal/index.ts +17 -19
  94. package/src/docs/crm-pipeline.docblock.ts +44 -44
  95. package/src/entities/company.entity.ts +52 -52
  96. package/src/entities/contact.entity.ts +67 -67
  97. package/src/entities/deal.entity.ts +134 -134
  98. package/src/entities/index.ts +27 -27
  99. package/src/entities/task.entity.ts +105 -105
  100. package/src/events/contact.event.ts +22 -22
  101. package/src/events/deal.event.ts +77 -77
  102. package/src/events/task.event.ts +19 -19
  103. package/src/example.ts +32 -32
  104. package/src/handlers/crm.handlers.ts +375 -357
  105. package/src/handlers/deal.handlers.ts +179 -179
  106. package/src/handlers/index.ts +18 -19
  107. package/src/handlers/mock-data.ts +167 -167
  108. package/src/index.ts +11 -11
  109. package/src/operations/index.ts +16 -16
  110. package/src/presentations/dashboard.presentation.ts +45 -45
  111. package/src/presentations/pipeline.presentation.ts +90 -90
  112. package/src/seeders/index.ts +26 -26
  113. package/src/shared/overlay-types.ts +23 -23
  114. package/src/ui/CrmDashboard.tsx +210 -279
  115. package/src/ui/CrmDealCard.tsx +64 -64
  116. package/src/ui/CrmPipelineBoard.tsx +105 -105
  117. package/src/ui/hooks/index.ts +3 -3
  118. package/src/ui/hooks/useDealList.ts +113 -85
  119. package/src/ui/hooks/useDealMutations.ts +151 -150
  120. package/src/ui/index.ts +5 -10
  121. package/src/ui/modals/CreateDealModal.tsx +217 -217
  122. package/src/ui/modals/DealActionsModal.tsx +390 -390
  123. package/src/ui/overlays/demo-overlays.ts +43 -43
  124. package/src/ui/renderers/index.ts +4 -3
  125. package/src/ui/renderers/pipeline.markdown.ts +165 -165
  126. package/src/ui/renderers/pipeline.renderer.tsx +17 -16
  127. package/src/ui/tables/DealListTab.smoke.test.tsx +149 -0
  128. package/src/ui/tables/DealListTab.tsx +276 -0
  129. package/tsconfig.json +7 -8
  130. package/tsdown.config.js +7 -3
@@ -10,59 +10,59 @@ import type { OverlayDefinition } from '../../shared/overlay-types';
10
10
  * Demo user overlay - sample data mode
11
11
  */
12
12
  export const crmDemoOverlay: OverlayDefinition = {
13
- overlayId: 'crm-pipeline.demo-user',
14
- version: '1.0.0',
15
- description: 'Demo mode with sample data',
16
- appliesTo: {
17
- feature: 'crm-pipeline',
18
- role: 'demo',
19
- },
20
- modifications: [
21
- {
22
- type: 'hideField',
23
- field: 'importButton',
24
- reason: 'Not available in demo',
25
- },
26
- {
27
- type: 'hideField',
28
- field: 'exportButton',
29
- reason: 'Not available in demo',
30
- },
31
- {
32
- type: 'addBadge',
33
- position: 'header',
34
- label: 'Demo Mode',
35
- variant: 'warning',
36
- },
37
- ],
13
+ overlayId: 'crm-pipeline.demo-user',
14
+ version: '1.0.0',
15
+ description: 'Demo mode with sample data',
16
+ appliesTo: {
17
+ feature: 'crm-pipeline',
18
+ role: 'demo',
19
+ },
20
+ modifications: [
21
+ {
22
+ type: 'hideField',
23
+ field: 'importButton',
24
+ reason: 'Not available in demo',
25
+ },
26
+ {
27
+ type: 'hideField',
28
+ field: 'exportButton',
29
+ reason: 'Not available in demo',
30
+ },
31
+ {
32
+ type: 'addBadge',
33
+ position: 'header',
34
+ label: 'Demo Mode',
35
+ variant: 'warning',
36
+ },
37
+ ],
38
38
  };
39
39
 
40
40
  /**
41
41
  * Sales rep overlay - focused view for sales
42
42
  */
43
43
  export const crmSalesRepOverlay: OverlayDefinition = {
44
- overlayId: 'crm-pipeline.sales-rep',
45
- version: '1.0.0',
46
- description: 'Sales rep focused view',
47
- appliesTo: {
48
- feature: 'crm-pipeline',
49
- role: 'sales-rep',
50
- },
51
- modifications: [
52
- {
53
- type: 'hideField',
54
- field: 'teamMetrics',
55
- reason: 'Team metrics for managers only',
56
- },
57
- { type: 'hideField', field: 'pipelineSettings', reason: 'Admin only' },
58
- { type: 'renameLabel', field: 'deals', newLabel: 'My Deals' },
59
- ],
44
+ overlayId: 'crm-pipeline.sales-rep',
45
+ version: '1.0.0',
46
+ description: 'Sales rep focused view',
47
+ appliesTo: {
48
+ feature: 'crm-pipeline',
49
+ role: 'sales-rep',
50
+ },
51
+ modifications: [
52
+ {
53
+ type: 'hideField',
54
+ field: 'teamMetrics',
55
+ reason: 'Team metrics for managers only',
56
+ },
57
+ { type: 'hideField', field: 'pipelineSettings', reason: 'Admin only' },
58
+ { type: 'renameLabel', field: 'deals', newLabel: 'My Deals' },
59
+ ],
60
60
  };
61
61
 
62
62
  /**
63
63
  * All overlays for crm-pipeline
64
64
  */
65
65
  export const crmOverlays: OverlayDefinition[] = [
66
- crmDemoOverlay,
67
- crmSalesRepOverlay,
66
+ crmDemoOverlay,
67
+ crmSalesRepOverlay,
68
68
  ];
@@ -1,6 +1,7 @@
1
1
  // CRM Pipeline renderers
2
- export { crmPipelineReactRenderer } from './pipeline.renderer';
2
+
3
3
  export {
4
- crmPipelineMarkdownRenderer,
5
- crmDashboardMarkdownRenderer,
4
+ crmDashboardMarkdownRenderer,
5
+ crmPipelineMarkdownRenderer,
6
6
  } from './pipeline.markdown';
7
+ export { crmPipelineReactRenderer } from './pipeline.renderer';
@@ -5,31 +5,31 @@
5
5
  */
6
6
  import type { PresentationRenderer } from '@contractspec/lib.contracts-spec/presentations/transform-engine';
7
7
  import {
8
- mockListDealsHandler,
9
- mockGetPipelineStagesHandler,
8
+ mockGetPipelineStagesHandler,
9
+ mockListDealsHandler,
10
10
  } from '../../handlers';
11
11
 
12
12
  interface DealItem {
13
- id: string;
14
- name: string;
15
- value: number;
16
- currency: string;
17
- stageId: string;
18
- status: string;
13
+ id: string;
14
+ name: string;
15
+ value: number;
16
+ currency: string;
17
+ stageId: string;
18
+ status: string;
19
19
  }
20
20
 
21
21
  interface StageItem {
22
- id: string;
23
- name: string;
24
- position: number;
22
+ id: string;
23
+ name: string;
24
+ position: number;
25
25
  }
26
26
 
27
27
  function formatCurrency(value: number, currency = 'USD'): string {
28
- return new Intl.NumberFormat('en-US', {
29
- style: 'currency',
30
- currency,
31
- minimumFractionDigits: 0,
32
- }).format(value);
28
+ return new Intl.NumberFormat('en-US', {
29
+ style: 'currency',
30
+ currency,
31
+ minimumFractionDigits: 0,
32
+ }).format(value);
33
33
  }
34
34
 
35
35
  /**
@@ -37,73 +37,73 @@ function formatCurrency(value: number, currency = 'USD'): string {
37
37
  * Only handles PipelineKanbanView component
38
38
  */
39
39
  export const crmPipelineMarkdownRenderer: PresentationRenderer<{
40
- mimeType: string;
41
- body: string;
40
+ mimeType: string;
41
+ body: string;
42
42
  }> = {
43
- target: 'markdown',
44
- render: async (desc, _ctx) => {
45
- // Only handle PipelineKanbanView
46
- if (
47
- desc.source.type !== 'component' ||
48
- desc.source.componentKey !== 'PipelineKanbanView'
49
- ) {
50
- throw new Error('crmPipelineMarkdownRenderer: not PipelineKanbanView');
51
- }
52
-
53
- const pipelineId = 'pipeline-1';
54
- const [dealsResult, stages] = await Promise.all([
55
- mockListDealsHandler({ pipelineId, limit: 50 }),
56
- mockGetPipelineStagesHandler({ pipelineId }),
57
- ]);
58
-
59
- const deals = dealsResult.deals as DealItem[];
60
- const stageList = stages as StageItem[];
61
-
62
- // Group deals by stage
63
- const dealsByStage: Record<string, DealItem[]> = {};
64
- for (const stage of stageList) {
65
- dealsByStage[stage.id] = deals.filter(
66
- (d) => d.stageId === stage.id && d.status === 'OPEN'
67
- );
68
- }
69
-
70
- // Build Markdown
71
- const lines: string[] = [
72
- '# CRM Pipeline',
73
- '',
74
- `**Total Value**: ${formatCurrency(dealsResult.totalValue)}`,
75
- `**Total Deals**: ${dealsResult.total}`,
76
- '',
77
- ];
78
-
79
- for (const stage of stageList.sort((a, b) => a.position - b.position)) {
80
- const stageDeals = dealsByStage[stage.id] ?? [];
81
- const stageValue = stageDeals.reduce((sum, d) => sum + d.value, 0);
82
-
83
- lines.push(`## ${stage.name}`);
84
- lines.push(
85
- `_${stageDeals.length} deals · ${formatCurrency(stageValue)}_`
86
- );
87
- lines.push('');
88
-
89
- if (stageDeals.length === 0) {
90
- lines.push('_No deals_');
91
- } else {
92
- for (const deal of stageDeals) {
93
- lines.push(
94
- `- **${deal.name}** - ${formatCurrency(deal.value, deal.currency)}`
95
- );
96
- }
97
- }
98
-
99
- lines.push('');
100
- }
101
-
102
- return {
103
- mimeType: 'text/markdown',
104
- body: lines.join('\n'),
105
- };
106
- },
43
+ target: 'markdown',
44
+ render: async (desc, _ctx) => {
45
+ // Only handle PipelineKanbanView
46
+ if (
47
+ desc.source.type !== 'component' ||
48
+ desc.source.componentKey !== 'PipelineKanbanView'
49
+ ) {
50
+ throw new Error('crmPipelineMarkdownRenderer: not PipelineKanbanView');
51
+ }
52
+
53
+ const pipelineId = 'pipeline-1';
54
+ const [dealsResult, stages] = await Promise.all([
55
+ mockListDealsHandler({ pipelineId, limit: 50 }),
56
+ mockGetPipelineStagesHandler({ pipelineId }),
57
+ ]);
58
+
59
+ const deals = dealsResult.deals as DealItem[];
60
+ const stageList = stages as StageItem[];
61
+
62
+ // Group deals by stage
63
+ const dealsByStage: Record<string, DealItem[]> = {};
64
+ for (const stage of stageList) {
65
+ dealsByStage[stage.id] = deals.filter(
66
+ (d) => d.stageId === stage.id && d.status === 'OPEN'
67
+ );
68
+ }
69
+
70
+ // Build Markdown
71
+ const lines: string[] = [
72
+ '# CRM Pipeline',
73
+ '',
74
+ `**Total Value**: ${formatCurrency(dealsResult.totalValue)}`,
75
+ `**Total Deals**: ${dealsResult.total}`,
76
+ '',
77
+ ];
78
+
79
+ for (const stage of stageList.sort((a, b) => a.position - b.position)) {
80
+ const stageDeals = dealsByStage[stage.id] ?? [];
81
+ const stageValue = stageDeals.reduce((sum, d) => sum + d.value, 0);
82
+
83
+ lines.push(`## ${stage.name}`);
84
+ lines.push(
85
+ `_${stageDeals.length} deals · ${formatCurrency(stageValue)}_`
86
+ );
87
+ lines.push('');
88
+
89
+ if (stageDeals.length === 0) {
90
+ lines.push('_No deals_');
91
+ } else {
92
+ for (const deal of stageDeals) {
93
+ lines.push(
94
+ `- **${deal.name}** - ${formatCurrency(deal.value, deal.currency)}`
95
+ );
96
+ }
97
+ }
98
+
99
+ lines.push('');
100
+ }
101
+
102
+ return {
103
+ mimeType: 'text/markdown',
104
+ body: lines.join('\n'),
105
+ };
106
+ },
107
107
  };
108
108
 
109
109
  /**
@@ -111,88 +111,88 @@ export const crmPipelineMarkdownRenderer: PresentationRenderer<{
111
111
  * Only handles CrmDashboard component
112
112
  */
113
113
  export const crmDashboardMarkdownRenderer: PresentationRenderer<{
114
- mimeType: string;
115
- body: string;
114
+ mimeType: string;
115
+ body: string;
116
116
  }> = {
117
- target: 'markdown',
118
- render: async (desc, _ctx) => {
119
- // Only handle CrmDashboard
120
- if (
121
- desc.source.type !== 'component' ||
122
- desc.source.componentKey !== 'CrmDashboard'
123
- ) {
124
- throw new Error('crmDashboardMarkdownRenderer: not CrmDashboard');
125
- }
126
-
127
- const pipelineId = 'pipeline-1';
128
- const [dealsResult, stages] = await Promise.all([
129
- mockListDealsHandler({ pipelineId, limit: 100 }),
130
- mockGetPipelineStagesHandler({ pipelineId }),
131
- ]);
132
-
133
- const deals = dealsResult.deals as DealItem[];
134
- const stageList = stages as StageItem[];
135
-
136
- // Calculate stats
137
- const openDeals = deals.filter((d) => d.status === 'OPEN');
138
- const wonDeals = deals.filter((d) => d.status === 'WON');
139
- const lostDeals = deals.filter((d) => d.status === 'LOST');
140
- const openValue = openDeals.reduce((sum, d) => sum + d.value, 0);
141
- const wonValue = wonDeals.reduce((sum, d) => sum + d.value, 0);
142
-
143
- // Build dashboard markdown
144
- const lines: string[] = [
145
- '# CRM Dashboard',
146
- '',
147
- '> Sales pipeline overview and key metrics',
148
- '',
149
- '## Summary',
150
- '',
151
- '| Metric | Value |',
152
- '|--------|-------|',
153
- `| Total Deals | ${dealsResult.total} |`,
154
- `| Pipeline Value | ${formatCurrency(dealsResult.totalValue)} |`,
155
- `| Open Deals | ${openDeals.length} (${formatCurrency(openValue)}) |`,
156
- `| Won Deals | ${wonDeals.length} (${formatCurrency(wonValue)}) |`,
157
- `| Lost Deals | ${lostDeals.length} |`,
158
- '',
159
- '## Pipeline Stages',
160
- '',
161
- ];
162
-
163
- // Stage summary table
164
- lines.push('| Stage | Deals | Value |');
165
- lines.push('|-------|-------|-------|');
166
- for (const stage of stageList.sort((a, b) => a.position - b.position)) {
167
- const stageDeals = openDeals.filter((d) => d.stageId === stage.id);
168
- const stageValue = stageDeals.reduce((sum, d) => sum + d.value, 0);
169
- lines.push(
170
- `| ${stage.name} | ${stageDeals.length} | ${formatCurrency(stageValue)} |`
171
- );
172
- }
173
-
174
- lines.push('');
175
- lines.push('## Recent Deals');
176
- lines.push('');
177
-
178
- // Top 10 recent deals
179
- const recentDeals = deals.slice(0, 10);
180
- if (recentDeals.length === 0) {
181
- lines.push('_No deals yet._');
182
- } else {
183
- lines.push('| Deal | Value | Stage | Status |');
184
- lines.push('|------|-------|-------|--------|');
185
- for (const deal of recentDeals) {
186
- const stage = stageList.find((s) => s.id === deal.stageId);
187
- lines.push(
188
- `| ${deal.name} | ${formatCurrency(deal.value, deal.currency)} | ${stage?.name ?? '-'} | ${deal.status} |`
189
- );
190
- }
191
- }
192
-
193
- return {
194
- mimeType: 'text/markdown',
195
- body: lines.join('\n'),
196
- };
197
- },
117
+ target: 'markdown',
118
+ render: async (desc, _ctx) => {
119
+ // Only handle CrmDashboard
120
+ if (
121
+ desc.source.type !== 'component' ||
122
+ desc.source.componentKey !== 'CrmDashboard'
123
+ ) {
124
+ throw new Error('crmDashboardMarkdownRenderer: not CrmDashboard');
125
+ }
126
+
127
+ const pipelineId = 'pipeline-1';
128
+ const [dealsResult, stages] = await Promise.all([
129
+ mockListDealsHandler({ pipelineId, limit: 100 }),
130
+ mockGetPipelineStagesHandler({ pipelineId }),
131
+ ]);
132
+
133
+ const deals = dealsResult.deals as DealItem[];
134
+ const stageList = stages as StageItem[];
135
+
136
+ // Calculate stats
137
+ const openDeals = deals.filter((d) => d.status === 'OPEN');
138
+ const wonDeals = deals.filter((d) => d.status === 'WON');
139
+ const lostDeals = deals.filter((d) => d.status === 'LOST');
140
+ const openValue = openDeals.reduce((sum, d) => sum + d.value, 0);
141
+ const wonValue = wonDeals.reduce((sum, d) => sum + d.value, 0);
142
+
143
+ // Build dashboard markdown
144
+ const lines: string[] = [
145
+ '# CRM Dashboard',
146
+ '',
147
+ '> Sales pipeline overview and key metrics',
148
+ '',
149
+ '## Summary',
150
+ '',
151
+ '| Metric | Value |',
152
+ '|--------|-------|',
153
+ `| Total Deals | ${dealsResult.total} |`,
154
+ `| Pipeline Value | ${formatCurrency(dealsResult.totalValue)} |`,
155
+ `| Open Deals | ${openDeals.length} (${formatCurrency(openValue)}) |`,
156
+ `| Won Deals | ${wonDeals.length} (${formatCurrency(wonValue)}) |`,
157
+ `| Lost Deals | ${lostDeals.length} |`,
158
+ '',
159
+ '## Pipeline Stages',
160
+ '',
161
+ ];
162
+
163
+ // Stage summary table
164
+ lines.push('| Stage | Deals | Value |');
165
+ lines.push('|-------|-------|-------|');
166
+ for (const stage of stageList.sort((a, b) => a.position - b.position)) {
167
+ const stageDeals = openDeals.filter((d) => d.stageId === stage.id);
168
+ const stageValue = stageDeals.reduce((sum, d) => sum + d.value, 0);
169
+ lines.push(
170
+ `| ${stage.name} | ${stageDeals.length} | ${formatCurrency(stageValue)} |`
171
+ );
172
+ }
173
+
174
+ lines.push('');
175
+ lines.push('## Recent Deals');
176
+ lines.push('');
177
+
178
+ // Top 10 recent deals
179
+ const recentDeals = deals.slice(0, 10);
180
+ if (recentDeals.length === 0) {
181
+ lines.push('_No deals yet._');
182
+ } else {
183
+ lines.push('| Deal | Value | Stage | Status |');
184
+ lines.push('|------|-------|-------|--------|');
185
+ for (const deal of recentDeals) {
186
+ const stage = stageList.find((s) => s.id === deal.stageId);
187
+ lines.push(
188
+ `| ${deal.name} | ${formatCurrency(deal.value, deal.currency)} | ${stage?.name ?? '-'} | ${deal.status} |`
189
+ );
190
+ }
191
+ }
192
+
193
+ return {
194
+ mimeType: 'text/markdown',
195
+ body: lines.join('\n'),
196
+ };
197
+ },
198
198
  };
@@ -4,8 +4,9 @@
4
4
  * Renders the CRM pipeline board component.
5
5
  * Data is fetched via the CrmPipelineBoard component's internal hooks.
6
6
  */
7
- import * as React from 'react';
7
+
8
8
  import type { PresentationRenderer } from '@contractspec/lib.contracts-spec/presentations/transform-engine';
9
+ import * as React from 'react';
9
10
  import { CrmPipelineBoard } from '../CrmPipelineBoard';
10
11
  import { useDealList } from '../hooks/useDealList';
11
12
 
@@ -13,23 +14,23 @@ import { useDealList } from '../hooks/useDealList';
13
14
  * Wrapper component that provides data to CrmPipelineBoard
14
15
  */
15
16
  function CrmPipelineBoardWrapper() {
16
- const { dealsByStage, stages } = useDealList();
17
- return <CrmPipelineBoard dealsByStage={dealsByStage} stages={stages} />;
17
+ const { dealsByStage, stages } = useDealList();
18
+ return <CrmPipelineBoard dealsByStage={dealsByStage} stages={stages} />;
18
19
  }
19
20
 
20
21
  export const crmPipelineReactRenderer: PresentationRenderer<React.ReactElement> =
21
- {
22
- target: 'react',
23
- render: async (desc, _ctx) => {
24
- if (desc.source.type !== 'component') {
25
- throw new Error('Invalid source type');
26
- }
22
+ {
23
+ target: 'react',
24
+ render: async (desc, _ctx) => {
25
+ if (desc.source.type !== 'component') {
26
+ throw new Error('Invalid source type');
27
+ }
27
28
 
28
- if (desc.source.componentKey !== 'CrmPipelineView') {
29
- throw new Error(`Unknown component: ${desc.source.componentKey}`);
30
- }
29
+ if (desc.source.componentKey !== 'CrmPipelineView') {
30
+ throw new Error(`Unknown component: ${desc.source.componentKey}`);
31
+ }
31
32
 
32
- // Note: The wrapper component will fetch data internally
33
- return <CrmPipelineBoardWrapper />;
34
- },
35
- };
33
+ // Note: The wrapper component will fetch data internally
34
+ return <CrmPipelineBoardWrapper />;
35
+ },
36
+ };
@@ -0,0 +1,149 @@
1
+ import { afterEach, beforeAll, describe, expect, test } from 'bun:test';
2
+ import Window from 'happy-dom/lib/window/Window.js';
3
+ import * as React from 'react';
4
+ import { act } from 'react';
5
+ import { createRoot, type Root } from 'react-dom/client';
6
+ import { MOCK_DEALS } from '../../handlers/mock-data';
7
+ import type { Deal } from '../hooks/useDealList';
8
+ import { DealListDataTable } from './DealListTab';
9
+
10
+ const TEST_DEALS: Deal[] = MOCK_DEALS.map((deal) => ({
11
+ ...deal,
12
+ projectId: 'project-1',
13
+ }));
14
+
15
+ beforeAll(() => {
16
+ const windowInstance = new Window({
17
+ url: 'https://sandbox.contractspec.local/sandbox?template=crm-pipeline',
18
+ });
19
+ Object.defineProperty(windowInstance, 'SyntaxError', {
20
+ value: SyntaxError,
21
+ configurable: true,
22
+ });
23
+ Object.assign(globalThis, {
24
+ window: windowInstance,
25
+ document: windowInstance.document,
26
+ navigator: windowInstance.navigator,
27
+ HTMLElement: windowInstance.HTMLElement,
28
+ HTMLButtonElement: windowInstance.HTMLButtonElement,
29
+ Node: windowInstance.Node,
30
+ Event: windowInstance.Event,
31
+ MouseEvent: windowInstance.MouseEvent,
32
+ MutationObserver: windowInstance.MutationObserver,
33
+ getComputedStyle: windowInstance.getComputedStyle.bind(windowInstance),
34
+ requestAnimationFrame: (callback: FrameRequestCallback) =>
35
+ setTimeout(() => callback(Date.now()), 0),
36
+ cancelAnimationFrame: (id: number) => clearTimeout(id),
37
+ IS_REACT_ACT_ENVIRONMENT: true,
38
+ });
39
+ });
40
+
41
+ afterEach(() => {
42
+ document.body.innerHTML = '';
43
+ });
44
+
45
+ function sortDeals(
46
+ pageIndex: number,
47
+ pageSize: number,
48
+ sorting: { id: string; desc: boolean }[]
49
+ ) {
50
+ const [sort] = sorting;
51
+ const sorted = [...TEST_DEALS].sort((left, right) => {
52
+ const leftValue =
53
+ sort?.id === 'deal'
54
+ ? left.name
55
+ : sort?.id === 'status'
56
+ ? left.status
57
+ : sort?.id === 'expectedCloseDate'
58
+ ? (left.expectedCloseDate?.toISOString() ?? '')
59
+ : sort?.id === 'updatedAt'
60
+ ? left.updatedAt.toISOString()
61
+ : left.value;
62
+ const rightValue =
63
+ sort?.id === 'deal'
64
+ ? right.name
65
+ : sort?.id === 'status'
66
+ ? right.status
67
+ : sort?.id === 'expectedCloseDate'
68
+ ? (right.expectedCloseDate?.toISOString() ?? '')
69
+ : sort?.id === 'updatedAt'
70
+ ? right.updatedAt.toISOString()
71
+ : right.value;
72
+ if (leftValue === rightValue) return 0;
73
+ const comparison = leftValue > rightValue ? 1 : -1;
74
+ return sort?.desc ? comparison * -1 : comparison;
75
+ });
76
+ return sorted.slice(pageIndex * pageSize, pageIndex * pageSize + pageSize);
77
+ }
78
+
79
+ function Harness() {
80
+ const [sorting, setSorting] = React.useState([{ id: 'value', desc: true }]);
81
+ const [pagination, setPagination] = React.useState({
82
+ pageIndex: 0,
83
+ pageSize: 3,
84
+ });
85
+ return (
86
+ <DealListDataTable
87
+ deals={sortDeals(pagination.pageIndex, pagination.pageSize, sorting)}
88
+ totalItems={TEST_DEALS.length}
89
+ pageIndex={pagination.pageIndex}
90
+ pageSize={pagination.pageSize}
91
+ sorting={sorting}
92
+ onSortingChange={setSorting}
93
+ onPaginationChange={setPagination}
94
+ />
95
+ );
96
+ }
97
+
98
+ async function renderTable() {
99
+ const container = document.createElement('div');
100
+ document.body.append(container);
101
+ const root: Root = createRoot(container);
102
+
103
+ await act(async () => {
104
+ root.render(<Harness />);
105
+ });
106
+
107
+ return { container, root };
108
+ }
109
+
110
+ async function click(element: Element | null | undefined) {
111
+ if (!element) {
112
+ throw new Error('Expected clickable element.');
113
+ }
114
+ await act(async () => {
115
+ if ('click' in element && typeof element.click === 'function') {
116
+ element.click();
117
+ return;
118
+ }
119
+ element.dispatchEvent(new MouseEvent('click', { bubbles: true }));
120
+ });
121
+ }
122
+
123
+ describe('DealListDataTable', () => {
124
+ test('renders the shared table and supports selection plus expansion', async () => {
125
+ const { container, root } = await renderTable();
126
+
127
+ expect(container.textContent).toContain('All Deals');
128
+ expect(container.textContent).toContain('6 total deals');
129
+
130
+ await click(container.querySelector('[aria-label="Select row deal-5"]'));
131
+ expect(container.textContent).toContain('Selected 1');
132
+
133
+ await click(container.querySelector('[aria-label="Expand row deal-5"]'));
134
+ expect(container.textContent).toContain('user-1');
135
+
136
+ await click(
137
+ [...container.getElementsByTagName('button')].find(
138
+ (button) => button.textContent?.trim() === '2'
139
+ )
140
+ );
141
+ expect(container.textContent).toContain(
142
+ 'Affichage de 4 à 6 sur 6 résultats'
143
+ );
144
+
145
+ await act(async () => {
146
+ root.unmount();
147
+ });
148
+ });
149
+ });