@contractspec/example.crm-pipeline 1.46.1 → 1.48.0

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 (140) hide show
  1. package/.turbo/turbo-build$colon$bundle.log +110 -35
  2. package/.turbo/turbo-build.log +110 -35
  3. package/CHANGELOG.md +63 -0
  4. package/dist/crm-pipeline.feature.d.ts +2 -2
  5. package/dist/crm-pipeline.feature.d.ts.map +1 -1
  6. package/dist/crm-pipeline.feature.js +9 -2
  7. package/dist/crm-pipeline.feature.js.map +1 -1
  8. package/dist/deal/deal.enum.d.ts +3 -3
  9. package/dist/deal/deal.enum.d.ts.map +1 -1
  10. package/dist/deal/deal.operation.d.ts +128 -128
  11. package/dist/deal/deal.operation.d.ts.map +1 -1
  12. package/dist/deal/deal.schema.d.ts +71 -71
  13. package/dist/deal/deal.test-spec.d.ts +8 -0
  14. package/dist/deal/deal.test-spec.d.ts.map +1 -0
  15. package/dist/deal/deal.test-spec.js +65 -0
  16. package/dist/deal/deal.test-spec.js.map +1 -0
  17. package/dist/entities/company.entity.d.ts +28 -28
  18. package/dist/entities/contact.entity.d.ts +32 -32
  19. package/dist/entities/contact.entity.d.ts.map +1 -1
  20. package/dist/entities/deal.entity.d.ts +53 -53
  21. package/dist/entities/deal.entity.d.ts.map +1 -1
  22. package/dist/entities/index.js.map +1 -1
  23. package/dist/entities/task.entity.d.ts +43 -43
  24. package/dist/entities/task.entity.d.ts.map +1 -1
  25. package/dist/events/contact.event.d.ts +8 -8
  26. package/dist/events/contact.event.js +1 -1
  27. package/dist/events/deal.event.d.ts +30 -30
  28. package/dist/events/deal.event.js +1 -1
  29. package/dist/events/task.event.d.ts +8 -8
  30. package/dist/events/task.event.d.ts.map +1 -1
  31. package/dist/events/task.event.js +1 -1
  32. package/dist/example.d.ts +2 -2
  33. package/dist/example.d.ts.map +1 -1
  34. package/dist/example.js +4 -2
  35. package/dist/example.js.map +1 -1
  36. package/dist/handlers/crm.handlers.d.ts +89 -0
  37. package/dist/handlers/crm.handlers.d.ts.map +1 -0
  38. package/dist/handlers/crm.handlers.js +172 -0
  39. package/dist/handlers/crm.handlers.js.map +1 -0
  40. package/dist/handlers/deal.handlers.js.map +1 -1
  41. package/dist/handlers/index.d.ts +2 -1
  42. package/dist/handlers/index.js +2 -1
  43. package/dist/handlers/mock-data.js.map +1 -1
  44. package/dist/index.d.ts +16 -3
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +14 -1
  47. package/dist/index.js.map +1 -1
  48. package/dist/presentations/dashboard.presentation.d.ts +3 -4
  49. package/dist/presentations/dashboard.presentation.d.ts.map +1 -1
  50. package/dist/presentations/dashboard.presentation.js +8 -5
  51. package/dist/presentations/dashboard.presentation.js.map +1 -1
  52. package/dist/presentations/pipeline.presentation.d.ts +5 -6
  53. package/dist/presentations/pipeline.presentation.d.ts.map +1 -1
  54. package/dist/presentations/pipeline.presentation.js +12 -9
  55. package/dist/presentations/pipeline.presentation.js.map +1 -1
  56. package/dist/seeders/index.d.ts +10 -0
  57. package/dist/seeders/index.d.ts.map +1 -0
  58. package/dist/seeders/index.js +47 -0
  59. package/dist/seeders/index.js.map +1 -0
  60. package/dist/shared/overlay-types.d.ts +34 -0
  61. package/dist/shared/overlay-types.d.ts.map +1 -0
  62. package/dist/shared/overlay-types.js +0 -0
  63. package/dist/ui/CrmDashboard.d.ts +7 -0
  64. package/dist/ui/CrmDashboard.d.ts.map +1 -0
  65. package/dist/ui/CrmDashboard.js +304 -0
  66. package/dist/ui/CrmDashboard.js.map +1 -0
  67. package/dist/ui/CrmDealCard.d.ts +15 -0
  68. package/dist/ui/CrmDealCard.d.ts.map +1 -0
  69. package/dist/ui/CrmDealCard.js +49 -0
  70. package/dist/ui/CrmDealCard.js.map +1 -0
  71. package/dist/ui/CrmPipelineBoard.d.ts +23 -0
  72. package/dist/ui/CrmPipelineBoard.d.ts.map +1 -0
  73. package/dist/ui/CrmPipelineBoard.js +98 -0
  74. package/dist/ui/CrmPipelineBoard.js.map +1 -0
  75. package/dist/ui/hooks/index.d.ts +3 -0
  76. package/dist/ui/hooks/index.js +6 -0
  77. package/dist/ui/hooks/useDealList.d.ts +35 -0
  78. package/dist/ui/hooks/useDealList.d.ts.map +1 -0
  79. package/dist/ui/hooks/useDealList.js +94 -0
  80. package/dist/ui/hooks/useDealList.js.map +1 -0
  81. package/dist/ui/hooks/useDealMutations.d.ts +26 -0
  82. package/dist/ui/hooks/useDealMutations.d.ts.map +1 -0
  83. package/dist/ui/hooks/useDealMutations.js +159 -0
  84. package/dist/ui/hooks/useDealMutations.js.map +1 -0
  85. package/dist/ui/index.d.ts +14 -0
  86. package/dist/ui/index.js +15 -0
  87. package/dist/ui/modals/CreateDealModal.d.ts +33 -0
  88. package/dist/ui/modals/CreateDealModal.d.ts.map +1 -0
  89. package/dist/ui/modals/CreateDealModal.js +183 -0
  90. package/dist/ui/modals/CreateDealModal.js.map +1 -0
  91. package/dist/ui/modals/DealActionsModal.d.ts +51 -0
  92. package/dist/ui/modals/DealActionsModal.d.ts.map +1 -0
  93. package/dist/ui/modals/DealActionsModal.js +372 -0
  94. package/dist/ui/modals/DealActionsModal.js.map +1 -0
  95. package/dist/ui/modals/index.d.ts +3 -0
  96. package/dist/ui/modals/index.js +4 -0
  97. package/dist/ui/overlays/demo-overlays.d.ts +19 -0
  98. package/dist/ui/overlays/demo-overlays.d.ts.map +1 -0
  99. package/dist/ui/overlays/demo-overlays.js +68 -0
  100. package/dist/ui/overlays/demo-overlays.js.map +1 -0
  101. package/dist/ui/overlays/index.d.ts +2 -0
  102. package/dist/ui/overlays/index.js +3 -0
  103. package/dist/ui/renderers/index.d.ts +3 -0
  104. package/dist/ui/renderers/index.js +4 -0
  105. package/dist/ui/renderers/pipeline.markdown.d.ts +23 -0
  106. package/dist/ui/renderers/pipeline.markdown.d.ts.map +1 -0
  107. package/dist/ui/renderers/pipeline.markdown.js +118 -0
  108. package/dist/ui/renderers/pipeline.markdown.js.map +1 -0
  109. package/dist/ui/renderers/pipeline.renderer.d.ts +9 -0
  110. package/dist/ui/renderers/pipeline.renderer.d.ts.map +1 -0
  111. package/dist/ui/renderers/pipeline.renderer.js +28 -0
  112. package/dist/ui/renderers/pipeline.renderer.js.map +1 -0
  113. package/package.json +38 -13
  114. package/src/crm-pipeline.feature.ts +3 -3
  115. package/src/deal/deal.test-spec.ts +55 -0
  116. package/src/example.ts +3 -3
  117. package/src/handlers/crm.handlers.ts +415 -0
  118. package/src/handlers/index.ts +3 -0
  119. package/src/index.ts +1 -0
  120. package/src/presentations/dashboard.presentation.ts +5 -6
  121. package/src/presentations/pipeline.presentation.ts +9 -10
  122. package/src/seeders/index.ts +35 -0
  123. package/src/shared/overlay-types.ts +39 -0
  124. package/src/ui/CrmDashboard.tsx +311 -0
  125. package/src/ui/CrmDealCard.tsx +83 -0
  126. package/src/ui/CrmPipelineBoard.tsx +136 -0
  127. package/src/ui/hooks/index.ts +10 -0
  128. package/src/ui/hooks/useDealList.ts +113 -0
  129. package/src/ui/hooks/useDealMutations.ts +174 -0
  130. package/src/ui/index.ts +18 -0
  131. package/src/ui/modals/CreateDealModal.tsx +239 -0
  132. package/src/ui/modals/DealActionsModal.tsx +424 -0
  133. package/src/ui/modals/index.ts +2 -0
  134. package/src/ui/overlays/demo-overlays.ts +68 -0
  135. package/src/ui/overlays/index.ts +1 -0
  136. package/src/ui/renderers/index.ts +6 -0
  137. package/src/ui/renderers/pipeline.markdown.ts +198 -0
  138. package/src/ui/renderers/pipeline.renderer.tsx +35 -0
  139. package/tsconfig.json +1 -1
  140. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,311 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * CRM Dashboard
5
+ *
6
+ * Fully integrated with ContractSpec example handlers
7
+ * and design-system components.
8
+ *
9
+ * Commands wired:
10
+ * - CreateDealContract -> Create Deal button + modal
11
+ * - MoveDealContract -> Move deal between stages
12
+ * - WinDealContract -> Mark deal as won
13
+ * - LoseDealContract -> Mark deal as lost
14
+ */
15
+ import { useCallback, useState } from 'react';
16
+ import {
17
+ Button,
18
+ ErrorState,
19
+ LoaderBlock,
20
+ StatCard,
21
+ StatCardGroup,
22
+ } from '@contractspec/lib.design-system';
23
+ import {
24
+ Tabs,
25
+ TabsContent,
26
+ TabsList,
27
+ TabsTrigger,
28
+ } from '@contractspec/lib.ui-kit-web/ui/tabs';
29
+ import { type Deal, useDealList } from './hooks/useDealList';
30
+ import { useDealMutations } from './hooks/useDealMutations';
31
+ import { CrmPipelineBoard } from './CrmPipelineBoard';
32
+ import { CreateDealModal } from './modals/CreateDealModal';
33
+ import { DealActionsModal } from './modals/DealActionsModal';
34
+
35
+ // type Tab = 'pipeline' | 'list' | 'metrics';
36
+
37
+ function formatCurrency(value: number, currency = 'USD'): string {
38
+ return new Intl.NumberFormat('en-US', {
39
+ style: 'currency',
40
+ currency,
41
+ minimumFractionDigits: 0,
42
+ maximumFractionDigits: 0,
43
+ }).format(value);
44
+ }
45
+
46
+ export function CrmDashboard() {
47
+ const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
48
+ const [selectedDeal, setSelectedDeal] = useState<Deal | null>(null);
49
+ const [isDealActionsOpen, setIsDealActionsOpen] = useState(false);
50
+
51
+ const { data, dealsByStage, stages, loading, error, stats, refetch } =
52
+ useDealList();
53
+
54
+ const mutations = useDealMutations({
55
+ onSuccess: () => {
56
+ refetch();
57
+ },
58
+ });
59
+
60
+ const handleDealClick = useCallback(
61
+ (dealId: string) => {
62
+ // Find deal in data
63
+ const deal = dealsByStage
64
+ ? Object.values(dealsByStage)
65
+ .flat()
66
+ .find((d) => d.id === dealId)
67
+ : null;
68
+
69
+ if (deal) {
70
+ setSelectedDeal(deal);
71
+ setIsDealActionsOpen(true);
72
+ }
73
+ },
74
+ [dealsByStage]
75
+ );
76
+
77
+ const handleDealMove = useCallback(
78
+ async (dealId: string, toStageId: string) => {
79
+ await mutations.moveDeal({ dealId, stageId: toStageId });
80
+ },
81
+ [mutations]
82
+ );
83
+
84
+ if (loading && !data) {
85
+ return <LoaderBlock label="Loading CRM..." />;
86
+ }
87
+
88
+ if (error) {
89
+ return (
90
+ <ErrorState
91
+ title="Failed to load CRM"
92
+ description={error.message}
93
+ onRetry={refetch}
94
+ retryLabel="Retry"
95
+ />
96
+ );
97
+ }
98
+
99
+ return (
100
+ <div className="space-y-6">
101
+ {/* Header with Create Button */}
102
+ <div className="flex items-center justify-between">
103
+ <h2 className="text-2xl font-bold">CRM Pipeline</h2>
104
+ <Button onClick={() => setIsCreateModalOpen(true)}>
105
+ <span className="mr-2">+</span> Create Deal
106
+ </Button>
107
+ </div>
108
+
109
+ {/* Stats Row */}
110
+ {stats && (
111
+ <StatCardGroup>
112
+ <StatCard
113
+ label="Total Pipeline"
114
+ value={formatCurrency(stats.totalValue)}
115
+ hint={`${stats.total} deals`}
116
+ />
117
+ <StatCard
118
+ label="Open Deals"
119
+ value={formatCurrency(stats.openValue)}
120
+ hint={`${stats.openCount} active`}
121
+ />
122
+ <StatCard
123
+ label="Won"
124
+ value={formatCurrency(stats.wonValue)}
125
+ hint={`${stats.wonCount} closed`}
126
+ />
127
+ <StatCard label="Lost" value={stats.lostCount} hint="deals lost" />
128
+ </StatCardGroup>
129
+ )}
130
+
131
+ {/* Tabs */}
132
+ <Tabs defaultValue="pipeline" className="w-full">
133
+ <TabsList>
134
+ <TabsTrigger value="pipeline">
135
+ <span className="mr-2">📊</span>
136
+ Pipeline
137
+ </TabsTrigger>
138
+ <TabsTrigger value="list">
139
+ <span className="mr-2">📋</span>
140
+ All Deals
141
+ </TabsTrigger>
142
+ <TabsTrigger value="metrics">
143
+ <span className="mr-2">📈</span>
144
+ Metrics
145
+ </TabsTrigger>
146
+ </TabsList>
147
+
148
+ <TabsContent value="pipeline" className="min-h-[400px]">
149
+ <CrmPipelineBoard
150
+ dealsByStage={dealsByStage}
151
+ stages={stages}
152
+ onDealClick={handleDealClick}
153
+ onDealMove={handleDealMove}
154
+ />
155
+ </TabsContent>
156
+
157
+ <TabsContent value="list" className="min-h-[400px]">
158
+ <DealListTab data={data} onDealClick={handleDealClick} />
159
+ </TabsContent>
160
+
161
+ <TabsContent value="metrics" className="min-h-[400px]">
162
+ <MetricsTab stats={stats} />
163
+ </TabsContent>
164
+ </Tabs>
165
+
166
+ {/* Create Deal Modal */}
167
+ <CreateDealModal
168
+ isOpen={isCreateModalOpen}
169
+ onClose={() => setIsCreateModalOpen(false)}
170
+ onSubmit={async (input) => {
171
+ await mutations.createDeal(input);
172
+ }}
173
+ stages={stages}
174
+ isLoading={mutations.createState.loading}
175
+ />
176
+
177
+ {/* Deal Actions Modal */}
178
+ <DealActionsModal
179
+ isOpen={isDealActionsOpen}
180
+ deal={selectedDeal}
181
+ stages={stages}
182
+ onClose={() => {
183
+ setIsDealActionsOpen(false);
184
+ setSelectedDeal(null);
185
+ }}
186
+ onWin={async (input) => {
187
+ await mutations.winDeal(input);
188
+ }}
189
+ onLose={async (input) => {
190
+ await mutations.loseDeal(input);
191
+ }}
192
+ onMove={async (input) => {
193
+ await mutations.moveDeal(input);
194
+ refetch();
195
+ }}
196
+ isLoading={mutations.isLoading}
197
+ />
198
+ </div>
199
+ );
200
+ }
201
+
202
+ interface DealListTabProps {
203
+ data: ReturnType<typeof useDealList>['data'];
204
+ onDealClick?: (dealId: string) => void;
205
+ }
206
+
207
+ function DealListTab({ data, onDealClick }: DealListTabProps) {
208
+ if (!data?.deals.length) {
209
+ return (
210
+ <div className="text-muted-foreground flex h-64 items-center justify-center">
211
+ No deals found
212
+ </div>
213
+ );
214
+ }
215
+
216
+ return (
217
+ <div className="border-border rounded-lg border">
218
+ <table className="w-full">
219
+ <thead className="border-border bg-muted/30 border-b">
220
+ <tr>
221
+ <th className="px-4 py-3 text-left text-sm font-medium">Deal</th>
222
+ <th className="px-4 py-3 text-left text-sm font-medium">Value</th>
223
+ <th className="px-4 py-3 text-left text-sm font-medium">Status</th>
224
+ <th className="px-4 py-3 text-left text-sm font-medium">
225
+ Expected Close
226
+ </th>
227
+ <th className="px-4 py-3 text-left text-sm font-medium">Actions</th>
228
+ </tr>
229
+ </thead>
230
+ <tbody className="divide-border divide-y">
231
+ {data.deals.map((deal: Deal) => (
232
+ <tr key={deal.id} className="hover:bg-muted/50">
233
+ <td className="px-4 py-3">
234
+ <div className="font-medium">{deal.name}</div>
235
+ </td>
236
+ <td className="px-4 py-3 font-mono">
237
+ {formatCurrency(deal.value, deal.currency)}
238
+ </td>
239
+ <td className="px-4 py-3">
240
+ <span
241
+ className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${
242
+ deal.status === 'WON'
243
+ ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
244
+ : deal.status === 'LOST'
245
+ ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
246
+ : 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
247
+ }`}
248
+ >
249
+ {deal.status}
250
+ </span>
251
+ </td>
252
+ <td className="text-muted-foreground px-4 py-3">
253
+ {deal.expectedCloseDate?.toLocaleDateString() ?? '-'}
254
+ </td>
255
+ <td className="px-4 py-3">
256
+ <Button
257
+ variant="ghost"
258
+ size="sm"
259
+ onPress={() => onDealClick?.(deal.id)}
260
+ >
261
+ Actions
262
+ </Button>
263
+ </td>
264
+ </tr>
265
+ ))}
266
+ </tbody>
267
+ </table>
268
+ </div>
269
+ );
270
+ }
271
+
272
+ function MetricsTab({
273
+ stats,
274
+ }: {
275
+ stats: ReturnType<typeof useDealList>['stats'];
276
+ }) {
277
+ if (!stats) return null;
278
+
279
+ return (
280
+ <div className="space-y-6">
281
+ <div className="border-border bg-card rounded-xl border p-6">
282
+ <h3 className="mb-4 text-lg font-semibold">Pipeline Overview</h3>
283
+ <dl className="grid gap-4 sm:grid-cols-3">
284
+ <div>
285
+ <dt className="text-muted-foreground text-sm">Win Rate</dt>
286
+ <dd className="text-2xl font-semibold">
287
+ {stats.total > 0
288
+ ? ((stats.wonCount / stats.total) * 100).toFixed(0)
289
+ : 0}
290
+ %
291
+ </dd>
292
+ </div>
293
+ <div>
294
+ <dt className="text-muted-foreground text-sm">Avg Deal Size</dt>
295
+ <dd className="text-2xl font-semibold">
296
+ {formatCurrency(
297
+ stats.total > 0 ? stats.totalValue / stats.total : 0
298
+ )}
299
+ </dd>
300
+ </div>
301
+ <div>
302
+ <dt className="text-muted-foreground text-sm">Conversion</dt>
303
+ <dd className="text-2xl font-semibold">
304
+ {stats.wonCount} / {stats.total}
305
+ </dd>
306
+ </div>
307
+ </dl>
308
+ </div>
309
+ </div>
310
+ );
311
+ }
@@ -0,0 +1,83 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * CRM Deal Card - Individual deal card for kanban board
5
+ */
6
+ import type { Deal } from './hooks/useDealList';
7
+
8
+ interface CrmDealCardProps {
9
+ deal: Deal;
10
+ onClick?: () => void;
11
+ }
12
+
13
+ function formatCurrency(value: number, currency: string): string {
14
+ return new Intl.NumberFormat('en-US', {
15
+ style: 'currency',
16
+ currency,
17
+ minimumFractionDigits: 0,
18
+ maximumFractionDigits: 0,
19
+ }).format(value);
20
+ }
21
+
22
+ export function CrmDealCard({ deal, onClick }: CrmDealCardProps) {
23
+ const daysUntilClose = deal.expectedCloseDate
24
+ ? Math.ceil(
25
+ (deal.expectedCloseDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24)
26
+ )
27
+ : null;
28
+
29
+ return (
30
+ <div
31
+ onClick={onClick}
32
+ className="border-border bg-card cursor-pointer rounded-lg border p-3 shadow-sm transition-shadow hover:shadow-md"
33
+ role="button"
34
+ tabIndex={0}
35
+ onKeyDown={(e) => {
36
+ if (e.key === 'Enter' || e.key === ' ') onClick?.();
37
+ }}
38
+ >
39
+ {/* Deal Name */}
40
+ <h4 className="leading-snug font-medium">{deal.name}</h4>
41
+
42
+ {/* Deal Value */}
43
+ <div className="text-primary mt-2 text-lg font-semibold">
44
+ {formatCurrency(deal.value, deal.currency)}
45
+ </div>
46
+
47
+ {/* Meta Info */}
48
+ <div className="text-muted-foreground mt-3 flex items-center justify-between text-xs">
49
+ {/* Expected Close */}
50
+ {daysUntilClose !== null && (
51
+ <span
52
+ className={
53
+ daysUntilClose < 0
54
+ ? 'text-red-500'
55
+ : daysUntilClose <= 7
56
+ ? 'text-yellow-600 dark:text-yellow-500'
57
+ : ''
58
+ }
59
+ >
60
+ {daysUntilClose < 0
61
+ ? `${Math.abs(daysUntilClose)}d overdue`
62
+ : daysUntilClose === 0
63
+ ? 'Due today'
64
+ : `${daysUntilClose}d left`}
65
+ </span>
66
+ )}
67
+
68
+ {/* Status Badge */}
69
+ <span
70
+ className={`rounded px-1.5 py-0.5 text-xs font-medium ${
71
+ deal.status === 'WON'
72
+ ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
73
+ : deal.status === 'LOST'
74
+ ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
75
+ : 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
76
+ }`}
77
+ >
78
+ {deal.status}
79
+ </span>
80
+ </div>
81
+ </div>
82
+ );
83
+ }
@@ -0,0 +1,136 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * CRM Pipeline Board - Kanban-style deal board
5
+ *
6
+ * Features:
7
+ * - Visual pipeline stages
8
+ * - Deal cards with click actions
9
+ * - Quick move dropdown per card
10
+ * - Drag-and-drop ready (UI only, no lib dependency)
11
+ */
12
+ import { useState } from 'react';
13
+ // import { Button } from '@contractspec/lib.design-system';
14
+ import type { Deal } from './hooks/useDealList';
15
+ import { CrmDealCard } from './CrmDealCard';
16
+
17
+ interface CrmPipelineBoardProps {
18
+ dealsByStage: Record<string, Deal[]>;
19
+ stages: { id: string; name: string; position: number }[];
20
+ onDealClick?: (dealId: string) => void;
21
+ onDealMove?: (dealId: string, toStageId: string) => void;
22
+ }
23
+
24
+ function formatCurrency(value: number): string {
25
+ if (value >= 1000000) return `$${(value / 1000000).toFixed(1)}M`;
26
+ if (value >= 1000) return `$${(value / 1000).toFixed(0)}K`;
27
+ return `$${value}`;
28
+ }
29
+
30
+ export function CrmPipelineBoard({
31
+ dealsByStage,
32
+ stages,
33
+ onDealClick,
34
+ onDealMove,
35
+ }: CrmPipelineBoardProps) {
36
+ // Track which deal has the quick-move dropdown open
37
+ const [quickMoveOpen, setQuickMoveOpen] = useState<string | null>(null);
38
+
39
+ // Sort stages by position
40
+ const sortedStages = [...stages].sort((a, b) => a.position - b.position);
41
+
42
+ const handleQuickMove = (dealId: string, toStageId: string) => {
43
+ onDealMove?.(dealId, toStageId);
44
+ setQuickMoveOpen(null);
45
+ };
46
+
47
+ return (
48
+ <div className="flex gap-4 overflow-x-auto pb-4">
49
+ {sortedStages.map((stage) => {
50
+ const deals = dealsByStage[stage.id] ?? [];
51
+ const stageValue = deals.reduce((sum, d) => sum + d.value, 0);
52
+
53
+ return (
54
+ <div
55
+ key={stage.id}
56
+ className="bg-muted/30 flex w-72 flex-shrink-0 flex-col rounded-lg"
57
+ >
58
+ {/* Stage Header */}
59
+ <div className="border-border flex items-center justify-between border-b px-3 py-2">
60
+ <div>
61
+ <h3 className="font-medium">{stage.name}</h3>
62
+ <p className="text-muted-foreground text-xs">
63
+ {deals.length} deals · {formatCurrency(stageValue)}
64
+ </p>
65
+ </div>
66
+ <span className="bg-muted flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium">
67
+ {deals.length}
68
+ </span>
69
+ </div>
70
+
71
+ {/* Deals Column */}
72
+ <div className="flex flex-1 flex-col gap-2 p-2">
73
+ {deals.length === 0 ? (
74
+ <div className="border-muted-foreground/20 text-muted-foreground flex h-24 items-center justify-center rounded-md border-2 border-dashed text-xs">
75
+ No deals
76
+ </div>
77
+ ) : (
78
+ deals.map((deal) => (
79
+ <div key={deal.id} className="group relative">
80
+ <CrmDealCard
81
+ deal={deal}
82
+ onClick={() => onDealClick?.(deal.id)}
83
+ />
84
+
85
+ {/* Quick Move Button */}
86
+ {deal.status === 'OPEN' && onDealMove && (
87
+ <div className="absolute top-1 right-1 opacity-0 transition-opacity group-hover:opacity-100">
88
+ <button
89
+ type="button"
90
+ onClick={(e) => {
91
+ e.stopPropagation();
92
+ setQuickMoveOpen(
93
+ quickMoveOpen === deal.id ? null : deal.id
94
+ );
95
+ }}
96
+ className="bg-background border-border hover:bg-muted flex h-6 w-6 items-center justify-center rounded border text-xs shadow-sm"
97
+ title="Quick move"
98
+ >
99
+ ➡️
100
+ </button>
101
+
102
+ {/* Quick Move Dropdown */}
103
+ {quickMoveOpen === deal.id && (
104
+ <div className="bg-card border-border absolute top-7 right-0 z-20 min-w-[140px] rounded-lg border py-1 shadow-lg">
105
+ <p className="text-muted-foreground px-3 py-1 text-xs font-medium">
106
+ Move to:
107
+ </p>
108
+ {sortedStages
109
+ .filter((s) => s.id !== deal.stageId)
110
+ .map((s) => (
111
+ <button
112
+ key={s.id}
113
+ type="button"
114
+ onClick={(e) => {
115
+ e.stopPropagation();
116
+ handleQuickMove(deal.id, s.id);
117
+ }}
118
+ className="hover:bg-muted w-full px-3 py-1.5 text-left text-sm"
119
+ >
120
+ {s.name}
121
+ </button>
122
+ ))}
123
+ </div>
124
+ )}
125
+ </div>
126
+ )}
127
+ </div>
128
+ ))
129
+ )}
130
+ </div>
131
+ </div>
132
+ );
133
+ })}
134
+ </div>
135
+ );
136
+ }
@@ -0,0 +1,10 @@
1
+ 'use client';
2
+
3
+ export { useDealList, type UseDealListOptions } from './useDealList';
4
+ export {
5
+ useDealMutations,
6
+ type UseDealMutationsOptions,
7
+ } from './useDealMutations';
8
+
9
+ // Note: For deal types (CreateDealInput, MoveDealInput, etc.), import directly from:
10
+ // import type { Deal, CreateDealInput, etc. } from '@contractspec/example.crm-pipeline/handlers';
@@ -0,0 +1,113 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Hook for fetching and managing deal list data
5
+ *
6
+ * Uses runtime-local database-backed handlers.
7
+ */
8
+ import { useCallback, useEffect, useMemo, useState } from 'react';
9
+ import { useTemplateRuntime } from '@contractspec/lib.example-shared-ui';
10
+ import {
11
+ type CrmHandlers,
12
+ type Deal as RuntimeDeal,
13
+ type ListDealsOutput as RuntimeListDealsOutput,
14
+ type Stage,
15
+ } from '../../handlers/crm.handlers';
16
+
17
+ // Re-export types for convenience
18
+ export type Deal = RuntimeDeal;
19
+ export type ListDealsOutput = RuntimeListDealsOutput;
20
+
21
+ export interface UseDealListOptions {
22
+ pipelineId?: string;
23
+ stageId?: string;
24
+ status?: 'OPEN' | 'WON' | 'LOST' | 'all';
25
+ search?: string;
26
+ limit?: number;
27
+ }
28
+
29
+ export function useDealList(options: UseDealListOptions = {}) {
30
+ const { handlers, projectId } = useTemplateRuntime<{ crm: CrmHandlers }>();
31
+ const { crm } = handlers;
32
+
33
+ const [data, setData] = useState<ListDealsOutput | null>(null);
34
+ const [dealsByStage, setDealsByStage] = useState<Record<string, Deal[]>>({});
35
+ const [stages, setStages] = useState<Stage[]>([]);
36
+ const [loading, setLoading] = useState(true);
37
+ const [error, setError] = useState<Error | null>(null);
38
+ const [page, setPage] = useState(1);
39
+
40
+ const pipelineId = options.pipelineId ?? 'pipeline-1';
41
+
42
+ const fetchData = useCallback(async () => {
43
+ setLoading(true);
44
+ setError(null);
45
+
46
+ try {
47
+ const [dealsResult, stageDealsResult, stagesResult] = await Promise.all([
48
+ crm.listDeals({
49
+ projectId,
50
+ pipelineId,
51
+ stageId: options.stageId,
52
+ status: options.status === 'all' ? undefined : options.status,
53
+ search: options.search,
54
+ limit: options.limit ?? 50,
55
+ offset: (page - 1) * (options.limit ?? 50),
56
+ }),
57
+ crm.getDealsByStage({ projectId, pipelineId }),
58
+ crm.getPipelineStages({ pipelineId }),
59
+ ]);
60
+ setData(dealsResult);
61
+ setDealsByStage(stageDealsResult);
62
+ setStages(stagesResult);
63
+ } catch (err) {
64
+ setError(err instanceof Error ? err : new Error('Unknown error'));
65
+ } finally {
66
+ setLoading(false);
67
+ }
68
+ }, [
69
+ crm,
70
+ projectId,
71
+ pipelineId,
72
+ options.stageId,
73
+ options.status,
74
+ options.search,
75
+ options.limit,
76
+ page,
77
+ ]);
78
+
79
+ useEffect(() => {
80
+ fetchData();
81
+ }, [fetchData]);
82
+
83
+ // Calculate stats
84
+ const stats = useMemo(() => {
85
+ if (!data) return null;
86
+ const open = data.deals.filter((d: Deal) => d.status === 'OPEN');
87
+ const won = data.deals.filter((d: Deal) => d.status === 'WON');
88
+ const lost = data.deals.filter((d: Deal) => d.status === 'LOST');
89
+
90
+ return {
91
+ total: data.total,
92
+ totalValue: data.totalValue,
93
+ openCount: open.length,
94
+ openValue: open.reduce((sum: number, d: Deal) => sum + d.value, 0),
95
+ wonCount: won.length,
96
+ wonValue: won.reduce((sum: number, d: Deal) => sum + d.value, 0),
97
+ lostCount: lost.length,
98
+ };
99
+ }, [data]);
100
+
101
+ return {
102
+ data,
103
+ dealsByStage,
104
+ stages,
105
+ loading,
106
+ error,
107
+ stats,
108
+ page,
109
+ refetch: fetchData,
110
+ nextPage: () => setPage((p) => p + 1),
111
+ prevPage: () => page > 1 && setPage((p) => p - 1),
112
+ };
113
+ }