@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.
- package/.turbo/turbo-build.log +45 -42
- package/CHANGELOG.md +36 -0
- package/README.md +2 -1
- package/dist/browser/docs/crm-pipeline.docblock.js +1 -1
- package/dist/browser/docs/index.js +1 -1
- package/dist/browser/handlers/crm.handlers.js +13 -2
- package/dist/browser/handlers/index.js +13 -2
- package/dist/browser/index.js +392 -159
- package/dist/browser/ui/CrmDashboard.js +366 -144
- package/dist/browser/ui/hooks/index.js +19 -8
- package/dist/browser/ui/hooks/useDealList.js +19 -8
- package/dist/browser/ui/index.js +391 -158
- package/dist/browser/ui/renderers/index.js +32 -10
- package/dist/browser/ui/renderers/pipeline.markdown.js +13 -2
- package/dist/browser/ui/renderers/pipeline.renderer.js +19 -8
- package/dist/browser/ui/tables/DealListTab.js +390 -0
- package/dist/docs/crm-pipeline.docblock.js +1 -1
- package/dist/docs/index.js +1 -1
- package/dist/handlers/crm.handlers.d.ts +2 -0
- package/dist/handlers/crm.handlers.js +13 -2
- package/dist/handlers/index.js +13 -2
- package/dist/index.js +392 -159
- package/dist/node/docs/crm-pipeline.docblock.js +1 -1
- package/dist/node/docs/index.js +1 -1
- package/dist/node/handlers/crm.handlers.js +13 -2
- package/dist/node/handlers/index.js +13 -2
- package/dist/node/index.js +392 -159
- package/dist/node/ui/CrmDashboard.js +366 -144
- package/dist/node/ui/hooks/index.js +19 -8
- package/dist/node/ui/hooks/useDealList.js +19 -8
- package/dist/node/ui/index.js +391 -158
- package/dist/node/ui/renderers/index.js +32 -10
- package/dist/node/ui/renderers/pipeline.markdown.js +13 -2
- package/dist/node/ui/renderers/pipeline.renderer.js +19 -8
- package/dist/node/ui/tables/DealListTab.js +390 -0
- package/dist/ui/CrmDashboard.js +366 -144
- package/dist/ui/hooks/index.js +19 -8
- package/dist/ui/hooks/useDealList.d.ts +8 -2
- package/dist/ui/hooks/useDealList.js +19 -8
- package/dist/ui/index.js +391 -158
- package/dist/ui/renderers/index.js +32 -10
- package/dist/ui/renderers/pipeline.markdown.js +13 -2
- package/dist/ui/renderers/pipeline.renderer.js +19 -8
- package/dist/ui/tables/DealListTab.d.ts +20 -0
- package/dist/ui/tables/DealListTab.js +391 -0
- package/dist/ui/tables/DealListTab.smoke.test.d.ts +1 -0
- package/package.json +27 -12
- package/src/docs/crm-pipeline.docblock.ts +1 -1
- package/src/handlers/crm.handlers.ts +18 -1
- package/src/ui/CrmDashboard.tsx +2 -71
- package/src/ui/hooks/useDealList.ts +36 -8
- package/src/ui/tables/DealListTab.smoke.test.tsx +149 -0
- 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
|
|
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[];
|
package/src/ui/CrmDashboard.tsx
CHANGED
|
@@ -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
|
|
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 [
|
|
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:
|
|
55
|
-
offset:
|
|
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
|
-
|
|
76
|
-
|
|
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:
|
|
111
|
-
|
|
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
|
+
}
|