@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
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
 
3
+ import { Button } from '@contractspec/lib.design-system';
3
4
  /**
4
5
  * DealActionsModal - Actions for a specific deal (Win, Lose, Move)
5
6
  *
@@ -7,418 +8,417 @@
7
8
  * via useDealMutations hook.
8
9
  */
9
10
  import { useState } from 'react';
10
- import { Button } from '@contractspec/lib.design-system';
11
11
 
12
12
  // Local type definitions for modal props
13
13
  export interface Deal {
14
- id: string;
15
- name: string;
16
- value: number;
17
- currency: string;
18
- stageId: string;
19
- status: 'OPEN' | 'WON' | 'LOST' | 'STALE';
14
+ id: string;
15
+ name: string;
16
+ value: number;
17
+ currency: string;
18
+ stageId: string;
19
+ status: 'OPEN' | 'WON' | 'LOST' | 'STALE';
20
20
  }
21
21
 
22
22
  export interface WinDealInput {
23
- dealId: string;
24
- wonSource?: string;
25
- notes?: string;
23
+ dealId: string;
24
+ wonSource?: string;
25
+ notes?: string;
26
26
  }
27
27
 
28
28
  export interface LoseDealInput {
29
- dealId: string;
30
- lostReason: string;
31
- notes?: string;
29
+ dealId: string;
30
+ lostReason: string;
31
+ notes?: string;
32
32
  }
33
33
 
34
34
  export interface MoveDealInput {
35
- dealId: string;
36
- stageId: string;
35
+ dealId: string;
36
+ stageId: string;
37
37
  }
38
38
 
39
39
  type ActionMode = 'menu' | 'win' | 'lose' | 'move';
40
40
 
41
41
  interface DealActionsModalProps {
42
- isOpen: boolean;
43
- deal: Deal | null;
44
- stages: { id: string; name: string }[];
45
- onClose: () => void;
46
- onWin: (input: WinDealInput) => Promise<void>;
47
- onLose: (input: LoseDealInput) => Promise<void>;
48
- onMove: (input: MoveDealInput) => Promise<void>;
49
- isLoading?: boolean;
42
+ isOpen: boolean;
43
+ deal: Deal | null;
44
+ stages: { id: string; name: string }[];
45
+ onClose: () => void;
46
+ onWin: (input: WinDealInput) => Promise<void>;
47
+ onLose: (input: LoseDealInput) => Promise<void>;
48
+ onMove: (input: MoveDealInput) => Promise<void>;
49
+ isLoading?: boolean;
50
50
  }
51
51
 
52
52
  function formatCurrency(value: number, currency: string): string {
53
- return new Intl.NumberFormat('en-US', {
54
- style: 'currency',
55
- currency,
56
- minimumFractionDigits: 0,
57
- maximumFractionDigits: 0,
58
- }).format(value);
53
+ return new Intl.NumberFormat('en-US', {
54
+ style: 'currency',
55
+ currency,
56
+ minimumFractionDigits: 0,
57
+ maximumFractionDigits: 0,
58
+ }).format(value);
59
59
  }
60
60
 
61
61
  export function DealActionsModal({
62
- isOpen,
63
- deal,
64
- stages,
65
- onClose,
66
- onWin,
67
- onLose,
68
- onMove,
69
- isLoading = false,
62
+ isOpen,
63
+ deal,
64
+ stages,
65
+ onClose,
66
+ onWin,
67
+ onLose,
68
+ onMove,
69
+ isLoading = false,
70
70
  }: DealActionsModalProps) {
71
- const [mode, setMode] = useState<ActionMode>('menu');
72
- const [wonSource, setWonSource] = useState('');
73
- const [lostReason, setLostReason] = useState('');
74
- const [notes, setNotes] = useState('');
75
- const [selectedStageId, setSelectedStageId] = useState('');
76
- const [error, setError] = useState<string | null>(null);
77
-
78
- const resetForm = () => {
79
- setMode('menu');
80
- setWonSource('');
81
- setLostReason('');
82
- setNotes('');
83
- setSelectedStageId('');
84
- setError(null);
85
- };
86
-
87
- const handleClose = () => {
88
- resetForm();
89
- onClose();
90
- };
91
-
92
- const handleWin = async () => {
93
- if (!deal) return;
94
- setError(null);
95
-
96
- try {
97
- await onWin({
98
- dealId: deal.id,
99
- wonSource: wonSource.trim() || undefined,
100
- notes: notes.trim() || undefined,
101
- });
102
- handleClose();
103
- } catch (err) {
104
- setError(
105
- err instanceof Error ? err.message : 'Failed to mark deal as won'
106
- );
107
- }
108
- };
109
-
110
- const handleLose = async () => {
111
- if (!deal) return;
112
- setError(null);
113
-
114
- if (!lostReason.trim()) {
115
- setError('Please provide a reason for losing the deal');
116
- return;
117
- }
118
-
119
- try {
120
- await onLose({
121
- dealId: deal.id,
122
- lostReason: lostReason.trim(),
123
- notes: notes.trim() || undefined,
124
- });
125
- handleClose();
126
- } catch (err) {
127
- setError(
128
- err instanceof Error ? err.message : 'Failed to mark deal as lost'
129
- );
130
- }
131
- };
132
-
133
- const handleMove = async () => {
134
- if (!deal) return;
135
- setError(null);
136
-
137
- if (!selectedStageId) {
138
- setError('Please select a stage');
139
- return;
140
- }
141
-
142
- if (selectedStageId === deal.stageId) {
143
- setError('Deal is already in this stage');
144
- return;
145
- }
146
-
147
- try {
148
- await onMove({
149
- dealId: deal.id,
150
- stageId: selectedStageId,
151
- });
152
- handleClose();
153
- } catch (err) {
154
- setError(err instanceof Error ? err.message : 'Failed to move deal');
155
- }
156
- };
157
-
158
- if (!isOpen || !deal) return null;
159
-
160
- return (
161
- <div className="fixed inset-0 z-50 flex items-center justify-center">
162
- {/* Backdrop */}
163
- <div
164
- className="bg-background/80 absolute inset-0 backdrop-blur-sm"
165
- onClick={handleClose}
166
- role="button"
167
- tabIndex={0}
168
- onKeyDown={(e) => {
169
- if (e.key === 'Enter' || e.key === ' ') handleClose();
170
- }}
171
- aria-label="Close modal"
172
- />
173
-
174
- {/* Modal */}
175
- <div className="bg-card border-border relative z-10 w-full max-w-md rounded-xl border p-6 shadow-xl">
176
- {/* Deal Header */}
177
- <div className="border-border mb-4 border-b pb-4">
178
- <h2 className="text-xl font-semibold">{deal.name}</h2>
179
- <p className="text-primary text-lg font-medium">
180
- {formatCurrency(deal.value, deal.currency)}
181
- </p>
182
- <span
183
- className={`mt-2 inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${
184
- deal.status === 'WON'
185
- ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
186
- : deal.status === 'LOST'
187
- ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
188
- : 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
189
- }`}
190
- >
191
- {deal.status}
192
- </span>
193
- </div>
194
-
195
- {/* Main Menu */}
196
- {mode === 'menu' && (
197
- <div className="space-y-3">
198
- {deal.status === 'OPEN' && (
199
- <>
200
- <Button
201
- className="w-full justify-start"
202
- variant="ghost"
203
- onPress={() => setMode('win')}
204
- >
205
- <span className="mr-2">🏆</span> Mark as Won
206
- </Button>
207
- <Button
208
- className="w-full justify-start"
209
- variant="ghost"
210
- onPress={() => setMode('lose')}
211
- >
212
- <span className="mr-2">❌</span> Mark as Lost
213
- </Button>
214
- <Button
215
- className="w-full justify-start"
216
- variant="ghost"
217
- onPress={() => {
218
- setSelectedStageId(deal.stageId);
219
- setMode('move');
220
- }}
221
- >
222
- <span className="mr-2">➡️</span> Move to Stage
223
- </Button>
224
- </>
225
- )}
226
- {deal.status !== 'OPEN' && (
227
- <p className="text-muted-foreground py-4 text-center">
228
- This deal is already {deal.status.toLowerCase()}. No actions
229
- available.
230
- </p>
231
- )}
232
- <div className="border-border border-t pt-3">
233
- <Button
234
- className="w-full"
235
- variant="outline"
236
- onPress={handleClose}
237
- >
238
- Close
239
- </Button>
240
- </div>
241
- </div>
242
- )}
243
-
244
- {/* Win Form */}
245
- {mode === 'win' && (
246
- <div className="space-y-4">
247
- <div>
248
- <label
249
- htmlFor="won-source"
250
- className="text-muted-foreground mb-1 block text-sm font-medium"
251
- >
252
- How did you win this deal?
253
- </label>
254
- <select
255
- id="won-source"
256
- value={wonSource}
257
- onChange={(e) => setWonSource(e.target.value)}
258
- className="border-input bg-background focus:ring-ring h-10 w-full rounded-md border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
259
- >
260
- <option value="">Select a source...</option>
261
- <option value="referral">Referral</option>
262
- <option value="cold_outreach">Cold Outreach</option>
263
- <option value="inbound">Inbound Lead</option>
264
- <option value="upsell">Upsell</option>
265
- <option value="other">Other</option>
266
- </select>
267
- </div>
268
-
269
- <div>
270
- <label
271
- htmlFor="win-notes"
272
- className="text-muted-foreground mb-1 block text-sm font-medium"
273
- >
274
- Notes (optional)
275
- </label>
276
- <textarea
277
- id="win-notes"
278
- value={notes}
279
- onChange={(e) => setNotes(e.target.value)}
280
- placeholder="Any additional notes about the win..."
281
- rows={3}
282
- className="border-input bg-background focus:ring-ring w-full rounded-md border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
283
- />
284
- </div>
285
-
286
- {error && (
287
- <div className="bg-destructive/10 text-destructive rounded-md p-3 text-sm">
288
- {error}
289
- </div>
290
- )}
291
-
292
- <div className="flex justify-end gap-3 pt-2">
293
- <Button
294
- variant="ghost"
295
- onPress={() => setMode('menu')}
296
- disabled={isLoading}
297
- >
298
- Back
299
- </Button>
300
- <Button onPress={handleWin} disabled={isLoading}>
301
- {isLoading ? 'Processing...' : '🏆 Confirm Win'}
302
- </Button>
303
- </div>
304
- </div>
305
- )}
306
-
307
- {/* Lose Form */}
308
- {mode === 'lose' && (
309
- <div className="space-y-4">
310
- <div>
311
- <label
312
- htmlFor="lost-reason"
313
- className="text-muted-foreground mb-1 block text-sm font-medium"
314
- >
315
- Why was this deal lost? *
316
- </label>
317
- <select
318
- id="lost-reason"
319
- value={lostReason}
320
- onChange={(e) => setLostReason(e.target.value)}
321
- className="border-input bg-background focus:ring-ring h-10 w-full rounded-md border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
322
- >
323
- <option value="">Select a reason...</option>
324
- <option value="price">Price too high</option>
325
- <option value="competitor">Lost to competitor</option>
326
- <option value="no_budget">No budget</option>
327
- <option value="no_decision">No decision made</option>
328
- <option value="timing">Bad timing</option>
329
- <option value="product_fit">Product not a fit</option>
330
- <option value="other">Other</option>
331
- </select>
332
- </div>
333
-
334
- <div>
335
- <label
336
- htmlFor="lose-notes"
337
- className="text-muted-foreground mb-1 block text-sm font-medium"
338
- >
339
- Notes (optional)
340
- </label>
341
- <textarea
342
- id="lose-notes"
343
- value={notes}
344
- onChange={(e) => setNotes(e.target.value)}
345
- placeholder="Any additional details..."
346
- rows={3}
347
- className="border-input bg-background focus:ring-ring w-full rounded-md border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
348
- />
349
- </div>
350
-
351
- {error && (
352
- <div className="bg-destructive/10 text-destructive rounded-md p-3 text-sm">
353
- {error}
354
- </div>
355
- )}
356
-
357
- <div className="flex justify-end gap-3 pt-2">
358
- <Button
359
- variant="ghost"
360
- onPress={() => setMode('menu')}
361
- disabled={isLoading}
362
- >
363
- Back
364
- </Button>
365
- <Button
366
- variant="destructive"
367
- onPress={handleLose}
368
- disabled={isLoading}
369
- >
370
- {isLoading ? 'Processing...' : '❌ Confirm Loss'}
371
- </Button>
372
- </div>
373
- </div>
374
- )}
375
-
376
- {/* Move Form */}
377
- {mode === 'move' && (
378
- <div className="space-y-4">
379
- <div>
380
- <label
381
- htmlFor="move-stage"
382
- className="text-muted-foreground mb-1 block text-sm font-medium"
383
- >
384
- Move to Stage
385
- </label>
386
- <select
387
- id="move-stage"
388
- value={selectedStageId}
389
- onChange={(e) => setSelectedStageId(e.target.value)}
390
- className="border-input bg-background focus:ring-ring h-10 w-full rounded-md border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
391
- >
392
- {stages.map((stage) => (
393
- <option key={stage.id} value={stage.id}>
394
- {stage.name}
395
- {stage.id === deal.stageId ? ' (current)' : ''}
396
- </option>
397
- ))}
398
- </select>
399
- </div>
400
-
401
- {error && (
402
- <div className="bg-destructive/10 text-destructive rounded-md p-3 text-sm">
403
- {error}
404
- </div>
405
- )}
406
-
407
- <div className="flex justify-end gap-3 pt-2">
408
- <Button
409
- variant="ghost"
410
- onPress={() => setMode('menu')}
411
- disabled={isLoading}
412
- >
413
- Back
414
- </Button>
415
- <Button onPress={handleMove} disabled={isLoading}>
416
- {isLoading ? 'Moving...' : '➡️ Move Deal'}
417
- </Button>
418
- </div>
419
- </div>
420
- )}
421
- </div>
422
- </div>
423
- );
71
+ const [mode, setMode] = useState<ActionMode>('menu');
72
+ const [wonSource, setWonSource] = useState('');
73
+ const [lostReason, setLostReason] = useState('');
74
+ const [notes, setNotes] = useState('');
75
+ const [selectedStageId, setSelectedStageId] = useState('');
76
+ const [error, setError] = useState<string | null>(null);
77
+
78
+ const resetForm = () => {
79
+ setMode('menu');
80
+ setWonSource('');
81
+ setLostReason('');
82
+ setNotes('');
83
+ setSelectedStageId('');
84
+ setError(null);
85
+ };
86
+
87
+ const handleClose = () => {
88
+ resetForm();
89
+ onClose();
90
+ };
91
+
92
+ const handleWin = async () => {
93
+ if (!deal) return;
94
+ setError(null);
95
+
96
+ try {
97
+ await onWin({
98
+ dealId: deal.id,
99
+ wonSource: wonSource.trim() || undefined,
100
+ notes: notes.trim() || undefined,
101
+ });
102
+ handleClose();
103
+ } catch (err) {
104
+ setError(
105
+ err instanceof Error ? err.message : 'Failed to mark deal as won'
106
+ );
107
+ }
108
+ };
109
+
110
+ const handleLose = async () => {
111
+ if (!deal) return;
112
+ setError(null);
113
+
114
+ if (!lostReason.trim()) {
115
+ setError('Please provide a reason for losing the deal');
116
+ return;
117
+ }
118
+
119
+ try {
120
+ await onLose({
121
+ dealId: deal.id,
122
+ lostReason: lostReason.trim(),
123
+ notes: notes.trim() || undefined,
124
+ });
125
+ handleClose();
126
+ } catch (err) {
127
+ setError(
128
+ err instanceof Error ? err.message : 'Failed to mark deal as lost'
129
+ );
130
+ }
131
+ };
132
+
133
+ const handleMove = async () => {
134
+ if (!deal) return;
135
+ setError(null);
136
+
137
+ if (!selectedStageId) {
138
+ setError('Please select a stage');
139
+ return;
140
+ }
141
+
142
+ if (selectedStageId === deal.stageId) {
143
+ setError('Deal is already in this stage');
144
+ return;
145
+ }
146
+
147
+ try {
148
+ await onMove({
149
+ dealId: deal.id,
150
+ stageId: selectedStageId,
151
+ });
152
+ handleClose();
153
+ } catch (err) {
154
+ setError(err instanceof Error ? err.message : 'Failed to move deal');
155
+ }
156
+ };
157
+
158
+ if (!isOpen || !deal) return null;
159
+
160
+ return (
161
+ <div className="fixed inset-0 z-50 flex items-center justify-center">
162
+ {/* Backdrop */}
163
+ <div
164
+ className="absolute inset-0 bg-background/80 backdrop-blur-sm"
165
+ onClick={handleClose}
166
+ role="button"
167
+ tabIndex={0}
168
+ onKeyDown={(e) => {
169
+ if (e.key === 'Enter' || e.key === ' ') handleClose();
170
+ }}
171
+ aria-label="Close modal"
172
+ />
173
+
174
+ {/* Modal */}
175
+ <div className="relative z-10 w-full max-w-md rounded-xl border border-border bg-card p-6 shadow-xl">
176
+ {/* Deal Header */}
177
+ <div className="mb-4 border-border border-b pb-4">
178
+ <h2 className="font-semibold text-xl">{deal.name}</h2>
179
+ <p className="font-medium text-lg text-primary">
180
+ {formatCurrency(deal.value, deal.currency)}
181
+ </p>
182
+ <span
183
+ className={`mt-2 inline-flex rounded-full px-2 py-0.5 font-medium text-xs ${
184
+ deal.status === 'WON'
185
+ ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
186
+ : deal.status === 'LOST'
187
+ ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
188
+ : 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
189
+ }`}
190
+ >
191
+ {deal.status}
192
+ </span>
193
+ </div>
194
+
195
+ {/* Main Menu */}
196
+ {mode === 'menu' && (
197
+ <div className="space-y-3">
198
+ {deal.status === 'OPEN' && (
199
+ <>
200
+ <Button
201
+ className="w-full justify-start"
202
+ variant="ghost"
203
+ onPress={() => setMode('win')}
204
+ >
205
+ <span className="mr-2">🏆</span> Mark as Won
206
+ </Button>
207
+ <Button
208
+ className="w-full justify-start"
209
+ variant="ghost"
210
+ onPress={() => setMode('lose')}
211
+ >
212
+ <span className="mr-2">❌</span> Mark as Lost
213
+ </Button>
214
+ <Button
215
+ className="w-full justify-start"
216
+ variant="ghost"
217
+ onPress={() => {
218
+ setSelectedStageId(deal.stageId);
219
+ setMode('move');
220
+ }}
221
+ >
222
+ <span className="mr-2">➡️</span> Move to Stage
223
+ </Button>
224
+ </>
225
+ )}
226
+ {deal.status !== 'OPEN' && (
227
+ <p className="py-4 text-center text-muted-foreground">
228
+ This deal is already {deal.status.toLowerCase()}. No actions
229
+ available.
230
+ </p>
231
+ )}
232
+ <div className="border-border border-t pt-3">
233
+ <Button
234
+ className="w-full"
235
+ variant="outline"
236
+ onPress={handleClose}
237
+ >
238
+ Close
239
+ </Button>
240
+ </div>
241
+ </div>
242
+ )}
243
+
244
+ {/* Win Form */}
245
+ {mode === 'win' && (
246
+ <div className="space-y-4">
247
+ <div>
248
+ <label
249
+ htmlFor="won-source"
250
+ className="mb-1 block font-medium text-muted-foreground text-sm"
251
+ >
252
+ How did you win this deal?
253
+ </label>
254
+ <select
255
+ id="won-source"
256
+ value={wonSource}
257
+ onChange={(e) => setWonSource(e.target.value)}
258
+ className="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
259
+ >
260
+ <option value="">Select a source...</option>
261
+ <option value="referral">Referral</option>
262
+ <option value="cold_outreach">Cold Outreach</option>
263
+ <option value="inbound">Inbound Lead</option>
264
+ <option value="upsell">Upsell</option>
265
+ <option value="other">Other</option>
266
+ </select>
267
+ </div>
268
+
269
+ <div>
270
+ <label
271
+ htmlFor="win-notes"
272
+ className="mb-1 block font-medium text-muted-foreground text-sm"
273
+ >
274
+ Notes (optional)
275
+ </label>
276
+ <textarea
277
+ id="win-notes"
278
+ value={notes}
279
+ onChange={(e) => setNotes(e.target.value)}
280
+ placeholder="Any additional notes about the win..."
281
+ rows={3}
282
+ className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
283
+ />
284
+ </div>
285
+
286
+ {error && (
287
+ <div className="rounded-md bg-destructive/10 p-3 text-destructive text-sm">
288
+ {error}
289
+ </div>
290
+ )}
291
+
292
+ <div className="flex justify-end gap-3 pt-2">
293
+ <Button
294
+ variant="ghost"
295
+ onPress={() => setMode('menu')}
296
+ disabled={isLoading}
297
+ >
298
+ Back
299
+ </Button>
300
+ <Button onPress={handleWin} disabled={isLoading}>
301
+ {isLoading ? 'Processing...' : '🏆 Confirm Win'}
302
+ </Button>
303
+ </div>
304
+ </div>
305
+ )}
306
+
307
+ {/* Lose Form */}
308
+ {mode === 'lose' && (
309
+ <div className="space-y-4">
310
+ <div>
311
+ <label
312
+ htmlFor="lost-reason"
313
+ className="mb-1 block font-medium text-muted-foreground text-sm"
314
+ >
315
+ Why was this deal lost? *
316
+ </label>
317
+ <select
318
+ id="lost-reason"
319
+ value={lostReason}
320
+ onChange={(e) => setLostReason(e.target.value)}
321
+ className="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
322
+ >
323
+ <option value="">Select a reason...</option>
324
+ <option value="price">Price too high</option>
325
+ <option value="competitor">Lost to competitor</option>
326
+ <option value="no_budget">No budget</option>
327
+ <option value="no_decision">No decision made</option>
328
+ <option value="timing">Bad timing</option>
329
+ <option value="product_fit">Product not a fit</option>
330
+ <option value="other">Other</option>
331
+ </select>
332
+ </div>
333
+
334
+ <div>
335
+ <label
336
+ htmlFor="lose-notes"
337
+ className="mb-1 block font-medium text-muted-foreground text-sm"
338
+ >
339
+ Notes (optional)
340
+ </label>
341
+ <textarea
342
+ id="lose-notes"
343
+ value={notes}
344
+ onChange={(e) => setNotes(e.target.value)}
345
+ placeholder="Any additional details..."
346
+ rows={3}
347
+ className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
348
+ />
349
+ </div>
350
+
351
+ {error && (
352
+ <div className="rounded-md bg-destructive/10 p-3 text-destructive text-sm">
353
+ {error}
354
+ </div>
355
+ )}
356
+
357
+ <div className="flex justify-end gap-3 pt-2">
358
+ <Button
359
+ variant="ghost"
360
+ onPress={() => setMode('menu')}
361
+ disabled={isLoading}
362
+ >
363
+ Back
364
+ </Button>
365
+ <Button
366
+ variant="destructive"
367
+ onPress={handleLose}
368
+ disabled={isLoading}
369
+ >
370
+ {isLoading ? 'Processing...' : '❌ Confirm Loss'}
371
+ </Button>
372
+ </div>
373
+ </div>
374
+ )}
375
+
376
+ {/* Move Form */}
377
+ {mode === 'move' && (
378
+ <div className="space-y-4">
379
+ <div>
380
+ <label
381
+ htmlFor="move-stage"
382
+ className="mb-1 block font-medium text-muted-foreground text-sm"
383
+ >
384
+ Move to Stage
385
+ </label>
386
+ <select
387
+ id="move-stage"
388
+ value={selectedStageId}
389
+ onChange={(e) => setSelectedStageId(e.target.value)}
390
+ className="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
391
+ >
392
+ {stages.map((stage) => (
393
+ <option key={stage.id} value={stage.id}>
394
+ {stage.name}
395
+ {stage.id === deal.stageId ? ' (current)' : ''}
396
+ </option>
397
+ ))}
398
+ </select>
399
+ </div>
400
+
401
+ {error && (
402
+ <div className="rounded-md bg-destructive/10 p-3 text-destructive text-sm">
403
+ {error}
404
+ </div>
405
+ )}
406
+
407
+ <div className="flex justify-end gap-3 pt-2">
408
+ <Button
409
+ variant="ghost"
410
+ onPress={() => setMode('menu')}
411
+ disabled={isLoading}
412
+ >
413
+ Back
414
+ </Button>
415
+ <Button onPress={handleMove} disabled={isLoading}>
416
+ {isLoading ? 'Moving...' : '➡️ Move Deal'}
417
+ </Button>
418
+ </div>
419
+ </div>
420
+ )}
421
+ </div>
422
+ </div>
423
+ );
424
424
  }