@contractspec/example.crm-pipeline 3.7.7 → 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 (53) hide show
  1. package/.turbo/turbo-build.log +45 -42
  2. package/CHANGELOG.md +36 -0
  3. package/README.md +2 -1
  4. package/dist/browser/docs/crm-pipeline.docblock.js +1 -1
  5. package/dist/browser/docs/index.js +1 -1
  6. package/dist/browser/handlers/crm.handlers.js +13 -2
  7. package/dist/browser/handlers/index.js +13 -2
  8. package/dist/browser/index.js +392 -159
  9. package/dist/browser/ui/CrmDashboard.js +366 -144
  10. package/dist/browser/ui/hooks/index.js +19 -8
  11. package/dist/browser/ui/hooks/useDealList.js +19 -8
  12. package/dist/browser/ui/index.js +391 -158
  13. package/dist/browser/ui/renderers/index.js +32 -10
  14. package/dist/browser/ui/renderers/pipeline.markdown.js +13 -2
  15. package/dist/browser/ui/renderers/pipeline.renderer.js +19 -8
  16. package/dist/browser/ui/tables/DealListTab.js +390 -0
  17. package/dist/docs/crm-pipeline.docblock.js +1 -1
  18. package/dist/docs/index.js +1 -1
  19. package/dist/handlers/crm.handlers.d.ts +2 -0
  20. package/dist/handlers/crm.handlers.js +13 -2
  21. package/dist/handlers/index.js +13 -2
  22. package/dist/index.js +392 -159
  23. package/dist/node/docs/crm-pipeline.docblock.js +1 -1
  24. package/dist/node/docs/index.js +1 -1
  25. package/dist/node/handlers/crm.handlers.js +13 -2
  26. package/dist/node/handlers/index.js +13 -2
  27. package/dist/node/index.js +392 -159
  28. package/dist/node/ui/CrmDashboard.js +366 -144
  29. package/dist/node/ui/hooks/index.js +19 -8
  30. package/dist/node/ui/hooks/useDealList.js +19 -8
  31. package/dist/node/ui/index.js +391 -158
  32. package/dist/node/ui/renderers/index.js +32 -10
  33. package/dist/node/ui/renderers/pipeline.markdown.js +13 -2
  34. package/dist/node/ui/renderers/pipeline.renderer.js +19 -8
  35. package/dist/node/ui/tables/DealListTab.js +390 -0
  36. package/dist/ui/CrmDashboard.js +366 -144
  37. package/dist/ui/hooks/index.js +19 -8
  38. package/dist/ui/hooks/useDealList.d.ts +8 -2
  39. package/dist/ui/hooks/useDealList.js +19 -8
  40. package/dist/ui/index.js +391 -158
  41. package/dist/ui/renderers/index.js +32 -10
  42. package/dist/ui/renderers/pipeline.markdown.js +13 -2
  43. package/dist/ui/renderers/pipeline.renderer.js +19 -8
  44. package/dist/ui/tables/DealListTab.d.ts +20 -0
  45. package/dist/ui/tables/DealListTab.js +391 -0
  46. package/dist/ui/tables/DealListTab.smoke.test.d.ts +1 -0
  47. package/package.json +27 -12
  48. package/src/docs/crm-pipeline.docblock.ts +1 -1
  49. package/src/handlers/crm.handlers.ts +18 -1
  50. package/src/ui/CrmDashboard.tsx +2 -71
  51. package/src/ui/hooks/useDealList.ts +36 -8
  52. package/src/ui/tables/DealListTab.smoke.test.tsx +149 -0
  53. package/src/ui/tables/DealListTab.tsx +276 -0
@@ -76,6 +76,8 @@ export interface ListDealsInput {
76
76
  search?: string;
77
77
  limit?: number;
78
78
  offset?: number;
79
+ sortBy?: 'name' | 'value' | 'status' | 'expectedCloseDate' | 'updatedAt';
80
+ sortDirection?: 'asc' | 'desc';
79
81
  }
80
82
 
81
83
  export interface ListDealsOutput {
@@ -142,6 +144,17 @@ function rowToDeal(row: DealRow): Deal {
142
144
 
143
145
  // ============ Handler Factory ============
144
146
 
147
+ const DEAL_SORT_COLUMNS: Record<
148
+ NonNullable<ListDealsInput['sortBy']>,
149
+ string
150
+ > = {
151
+ name: 'name',
152
+ value: 'value',
153
+ status: 'status',
154
+ expectedCloseDate: 'expectedCloseDate',
155
+ updatedAt: 'updatedAt',
156
+ };
157
+
145
158
  export function createCrmHandlers(db: DatabasePort) {
146
159
  /**
147
160
  * List deals with filtering
@@ -156,6 +169,8 @@ export function createCrmHandlers(db: DatabasePort) {
156
169
  search,
157
170
  limit = 20,
158
171
  offset = 0,
172
+ sortBy = 'value',
173
+ sortDirection = 'desc',
159
174
  } = input;
160
175
 
161
176
  let whereClause = 'WHERE projectId = ?';
@@ -205,9 +220,11 @@ export function createCrmHandlers(db: DatabasePort) {
205
220
  const totalValue = (valueResult[0]?.total as number) ?? 0;
206
221
 
207
222
  // Get paginated deals
223
+ const orderByColumn = DEAL_SORT_COLUMNS[sortBy] ?? DEAL_SORT_COLUMNS.value;
224
+ const orderByDirection = sortDirection === 'asc' ? 'ASC' : 'DESC';
208
225
  const dealRows = (
209
226
  await db.query(
210
- `SELECT * FROM crm_deal ${whereClause} ORDER BY value DESC LIMIT ? OFFSET ?`,
227
+ `SELECT * FROM crm_deal ${whereClause} ORDER BY ${orderByColumn} ${orderByDirection} LIMIT ? OFFSET ?`,
211
228
  [...params, limit, offset]
212
229
  )
213
230
  ).rows as unknown as DealRow[];
@@ -31,6 +31,7 @@ import { type Deal, useDealList } from './hooks/useDealList';
31
31
  import { useDealMutations } from './hooks/useDealMutations';
32
32
  import { CreateDealModal } from './modals/CreateDealModal';
33
33
  import { DealActionsModal } from './modals/DealActionsModal';
34
+ import { DealListTab } from './tables/DealListTab';
34
35
 
35
36
  // type Tab = 'pipeline' | 'list' | 'metrics';
36
37
 
@@ -155,7 +156,7 @@ export function CrmDashboard() {
155
156
  </TabsContent>
156
157
 
157
158
  <TabsContent value="list" className="min-h-[400px]">
158
- <DealListTab data={data} onDealClick={handleDealClick} />
159
+ <DealListTab onDealClick={handleDealClick} />
159
160
  </TabsContent>
160
161
 
161
162
  <TabsContent value="metrics" className="min-h-[400px]">
@@ -199,76 +200,6 @@ export function CrmDashboard() {
199
200
  );
200
201
  }
201
202
 
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="flex h-64 items-center justify-center text-muted-foreground">
211
- No deals found
212
- </div>
213
- );
214
- }
215
-
216
- return (
217
- <div className="rounded-lg border border-border">
218
- <table className="w-full">
219
- <thead className="border-border border-b bg-muted/30">
220
- <tr>
221
- <th className="px-4 py-3 text-left font-medium text-sm">Deal</th>
222
- <th className="px-4 py-3 text-left font-medium text-sm">Value</th>
223
- <th className="px-4 py-3 text-left font-medium text-sm">Status</th>
224
- <th className="px-4 py-3 text-left font-medium text-sm">
225
- Expected Close
226
- </th>
227
- <th className="px-4 py-3 text-left font-medium text-sm">Actions</th>
228
- </tr>
229
- </thead>
230
- <tbody className="divide-y divide-border">
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 font-medium text-xs ${
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="px-4 py-3 text-muted-foreground">
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
203
  function MetricsTab({
273
204
  stats,
274
205
  }: {
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useTemplateRuntime } from '@contractspec/lib.example-shared-ui';
4
+ import type { ContractTableSort } from '@contractspec/lib.presentation-runtime-core';
4
5
  /**
5
6
  * Hook for fetching and managing deal list data
6
7
  *
@@ -24,6 +25,9 @@ export interface UseDealListOptions {
24
25
  status?: 'OPEN' | 'WON' | 'LOST' | 'all';
25
26
  search?: string;
26
27
  limit?: number;
28
+ pageIndex?: number;
29
+ pageSize?: number;
30
+ sorting?: ContractTableSort[];
27
31
  }
28
32
 
29
33
  export function useDealList(options: UseDealListOptions = {}) {
@@ -35,9 +39,14 @@ export function useDealList(options: UseDealListOptions = {}) {
35
39
  const [stages, setStages] = useState<Stage[]>([]);
36
40
  const [loading, setLoading] = useState(true);
37
41
  const [error, setError] = useState<Error | null>(null);
38
- const [page, setPage] = useState(1);
42
+ const [internalPage, setInternalPage] = useState(0);
39
43
 
40
44
  const pipelineId = options.pipelineId ?? 'pipeline-1';
45
+ const pageIndex = options.pageIndex ?? internalPage;
46
+ const pageSize = options.pageSize ?? options.limit ?? 50;
47
+ const [sort] = options.sorting ?? [];
48
+ const sortBy = sort?.id;
49
+ const sortDirection = sort ? (sort.desc ? 'desc' : 'asc') : undefined;
41
50
 
42
51
  const fetchData = useCallback(async () => {
43
52
  setLoading(true);
@@ -51,8 +60,17 @@ export function useDealList(options: UseDealListOptions = {}) {
51
60
  stageId: options.stageId,
52
61
  status: options.status === 'all' ? undefined : options.status,
53
62
  search: options.search,
54
- limit: options.limit ?? 50,
55
- offset: (page - 1) * (options.limit ?? 50),
63
+ limit: pageSize,
64
+ offset: pageIndex * pageSize,
65
+ sortBy:
66
+ sortBy === 'name' ||
67
+ sortBy === 'value' ||
68
+ sortBy === 'status' ||
69
+ sortBy === 'expectedCloseDate' ||
70
+ sortBy === 'updatedAt'
71
+ ? sortBy
72
+ : undefined,
73
+ sortDirection,
56
74
  }),
57
75
  crm.getDealsByStage({ projectId, pipelineId }),
58
76
  crm.getPipelineStages({ pipelineId }),
@@ -72,8 +90,10 @@ export function useDealList(options: UseDealListOptions = {}) {
72
90
  options.stageId,
73
91
  options.status,
74
92
  options.search,
75
- options.limit,
76
- page,
93
+ pageIndex,
94
+ pageSize,
95
+ sortBy,
96
+ sortDirection,
77
97
  ]);
78
98
 
79
99
  useEffect(() => {
@@ -105,9 +125,17 @@ export function useDealList(options: UseDealListOptions = {}) {
105
125
  loading,
106
126
  error,
107
127
  stats,
108
- page,
128
+ page: pageIndex + 1,
129
+ pageIndex,
130
+ pageSize,
109
131
  refetch: fetchData,
110
- nextPage: () => setPage((p) => p + 1),
111
- prevPage: () => page > 1 && setPage((p) => p - 1),
132
+ nextPage:
133
+ options.pageIndex === undefined
134
+ ? () => setInternalPage((page) => page + 1)
135
+ : undefined,
136
+ prevPage:
137
+ options.pageIndex === undefined
138
+ ? () => pageIndex > 0 && setInternalPage((page) => page - 1)
139
+ : undefined,
112
140
  };
113
141
  }
@@ -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
+ });
@@ -0,0 +1,276 @@
1
+ 'use client';
2
+
3
+ import {
4
+ Button,
5
+ DataTable,
6
+ LoaderBlock,
7
+ } from '@contractspec/lib.design-system';
8
+ import type { ContractTableSort } from '@contractspec/lib.presentation-runtime-core';
9
+ import { useContractTable } from '@contractspec/lib.presentation-runtime-react';
10
+ import { Badge } from '@contractspec/lib.ui-kit-web/ui/badge';
11
+ import { HStack, VStack } from '@contractspec/lib.ui-kit-web/ui/stack';
12
+ import { Text } from '@contractspec/lib.ui-kit-web/ui/text';
13
+ import * as React from 'react';
14
+ import { type Deal, useDealList } from '../hooks/useDealList';
15
+
16
+ function formatCurrency(value: number, currency = 'USD') {
17
+ return new Intl.NumberFormat('en-US', {
18
+ style: 'currency',
19
+ currency,
20
+ minimumFractionDigits: 0,
21
+ maximumFractionDigits: 0,
22
+ }).format(value);
23
+ }
24
+
25
+ function statusVariant(status: Deal['status']) {
26
+ switch (status) {
27
+ case 'WON':
28
+ return 'default';
29
+ case 'LOST':
30
+ return 'destructive';
31
+ case 'STALE':
32
+ return 'outline';
33
+ default:
34
+ return 'secondary';
35
+ }
36
+ }
37
+
38
+ export interface DealListDataTableProps {
39
+ deals: Deal[];
40
+ totalItems: number;
41
+ pageIndex: number;
42
+ pageSize: number;
43
+ sorting: ContractTableSort[];
44
+ loading?: boolean;
45
+ onSortingChange: (sorting: ContractTableSort[]) => void;
46
+ onPaginationChange: (pagination: {
47
+ pageIndex: number;
48
+ pageSize: number;
49
+ }) => void;
50
+ onDealClick?: (dealId: string) => void;
51
+ }
52
+
53
+ export function DealListDataTable({
54
+ deals,
55
+ totalItems,
56
+ pageIndex,
57
+ pageSize,
58
+ sorting,
59
+ loading,
60
+ onSortingChange,
61
+ onPaginationChange,
62
+ onDealClick,
63
+ }: DealListDataTableProps) {
64
+ const controller = useContractTable<Deal>({
65
+ data: deals,
66
+ columns: [
67
+ {
68
+ id: 'deal',
69
+ header: 'Deal',
70
+ label: 'Deal',
71
+ accessor: (deal) => deal.name,
72
+ cell: ({ item }) => (
73
+ <VStack gap="xs">
74
+ <Text className="font-medium text-sm">{item.name}</Text>
75
+ <Text className="text-muted-foreground text-xs">
76
+ {item.companyId ?? 'Unassigned company'}
77
+ </Text>
78
+ </VStack>
79
+ ),
80
+ size: 240,
81
+ minSize: 180,
82
+ canSort: true,
83
+ canPin: true,
84
+ canResize: true,
85
+ },
86
+ {
87
+ id: 'value',
88
+ header: 'Value',
89
+ label: 'Value',
90
+ accessorKey: 'value',
91
+ cell: ({ item }) => formatCurrency(item.value, item.currency),
92
+ align: 'right',
93
+ size: 140,
94
+ canSort: true,
95
+ canResize: true,
96
+ },
97
+ {
98
+ id: 'status',
99
+ header: 'Status',
100
+ label: 'Status',
101
+ accessorKey: 'status',
102
+ cell: ({ value }) => (
103
+ <Badge variant={statusVariant(value as Deal['status'])}>
104
+ {String(value)}
105
+ </Badge>
106
+ ),
107
+ size: 130,
108
+ canSort: true,
109
+ canHide: true,
110
+ canPin: true,
111
+ canResize: true,
112
+ },
113
+ {
114
+ id: 'expectedCloseDate',
115
+ header: 'Expected Close',
116
+ label: 'Expected Close',
117
+ accessor: (deal) => deal.expectedCloseDate?.toISOString() ?? '',
118
+ cell: ({ item }) =>
119
+ item.expectedCloseDate?.toLocaleDateString() ?? 'Not scheduled',
120
+ size: 170,
121
+ canSort: true,
122
+ canHide: true,
123
+ canResize: true,
124
+ },
125
+ {
126
+ id: 'updatedAt',
127
+ header: 'Updated',
128
+ label: 'Updated',
129
+ accessor: (deal) => deal.updatedAt.toISOString(),
130
+ cell: ({ item }) => item.updatedAt.toLocaleDateString(),
131
+ size: 140,
132
+ canSort: true,
133
+ canHide: true,
134
+ canResize: true,
135
+ },
136
+ {
137
+ id: 'actions',
138
+ header: 'Actions',
139
+ label: 'Actions',
140
+ accessor: (deal) => deal.id,
141
+ cell: ({ item }) => (
142
+ <Button
143
+ variant="ghost"
144
+ size="sm"
145
+ onPress={() => onDealClick?.(item.id)}
146
+ >
147
+ Actions
148
+ </Button>
149
+ ),
150
+ size: 120,
151
+ canSort: false,
152
+ canHide: false,
153
+ canPin: false,
154
+ canResize: false,
155
+ },
156
+ ],
157
+ executionMode: 'server',
158
+ selectionMode: 'multiple',
159
+ totalItems,
160
+ state: {
161
+ sorting,
162
+ pagination: {
163
+ pageIndex,
164
+ pageSize,
165
+ },
166
+ },
167
+ onSortingChange: onSortingChange,
168
+ onPaginationChange: onPaginationChange,
169
+ initialState: {
170
+ columnVisibility: { updatedAt: false },
171
+ columnPinning: { left: ['deal', 'status'], right: [] },
172
+ },
173
+ renderExpandedContent: (deal) => (
174
+ <VStack gap="sm" className="py-2">
175
+ <HStack justify="between">
176
+ <Text className="font-medium text-sm">Owner</Text>
177
+ <Text className="text-muted-foreground text-sm">{deal.ownerId}</Text>
178
+ </HStack>
179
+ <HStack justify="between">
180
+ <Text className="font-medium text-sm">Contact</Text>
181
+ <Text className="text-muted-foreground text-sm">
182
+ {deal.contactId ?? 'No linked contact'}
183
+ </Text>
184
+ </HStack>
185
+ {deal.wonSource ? (
186
+ <HStack justify="between">
187
+ <Text className="font-medium text-sm">Won Source</Text>
188
+ <Text className="text-muted-foreground text-sm">
189
+ {deal.wonSource}
190
+ </Text>
191
+ </HStack>
192
+ ) : null}
193
+ {deal.lostReason ? (
194
+ <HStack justify="between">
195
+ <Text className="font-medium text-sm">Lost Reason</Text>
196
+ <Text className="text-muted-foreground text-sm">
197
+ {deal.lostReason}
198
+ </Text>
199
+ </HStack>
200
+ ) : null}
201
+ {deal.notes ? (
202
+ <VStack gap="xs">
203
+ <Text className="font-medium text-sm">Notes</Text>
204
+ <Text className="text-muted-foreground text-sm">{deal.notes}</Text>
205
+ </VStack>
206
+ ) : null}
207
+ </VStack>
208
+ ),
209
+ getCanExpand: () => true,
210
+ });
211
+
212
+ return (
213
+ <DataTable
214
+ controller={controller}
215
+ title="All Deals"
216
+ description="Server-mode table using the shared ContractSpec controller."
217
+ loading={loading}
218
+ toolbar={
219
+ <HStack gap="sm" className="flex-wrap">
220
+ <Text className="text-muted-foreground text-sm">
221
+ Selected {controller.selectedRowIds.length}
222
+ </Text>
223
+ <Text className="text-muted-foreground text-sm">
224
+ {totalItems} total deals
225
+ </Text>
226
+ </HStack>
227
+ }
228
+ footer={`Page ${controller.pageIndex + 1} of ${controller.pageCount}`}
229
+ emptyState={
230
+ <div className="rounded-md border border-dashed p-8 text-center text-muted-foreground text-sm">
231
+ No deals found
232
+ </div>
233
+ }
234
+ />
235
+ );
236
+ }
237
+
238
+ export function DealListTab({
239
+ onDealClick,
240
+ }: {
241
+ onDealClick?: (dealId: string) => void;
242
+ }) {
243
+ const [sorting, setSorting] = React.useState<ContractTableSort[]>([
244
+ { id: 'value', desc: true },
245
+ ]);
246
+ const [pagination, setPagination] = React.useState({
247
+ pageIndex: 0,
248
+ pageSize: 3,
249
+ });
250
+ const { data, loading } = useDealList({
251
+ pageIndex: pagination.pageIndex,
252
+ pageSize: pagination.pageSize,
253
+ sorting,
254
+ });
255
+
256
+ if (loading && !data) {
257
+ return <LoaderBlock label="Loading deals..." />;
258
+ }
259
+
260
+ return (
261
+ <DealListDataTable
262
+ deals={data?.deals ?? []}
263
+ totalItems={data?.total ?? 0}
264
+ pageIndex={pagination.pageIndex}
265
+ pageSize={pagination.pageSize}
266
+ sorting={sorting}
267
+ loading={loading}
268
+ onSortingChange={(nextSorting) => {
269
+ setSorting(nextSorting);
270
+ setPagination((current) => ({ ...current, pageIndex: 0 }));
271
+ }}
272
+ onPaginationChange={setPagination}
273
+ onDealClick={onDealClick}
274
+ />
275
+ );
276
+ }