@hed-hog/contact 0.0.294 → 0.0.296
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/dist/person/dto/account.dto.d.ts +28 -0
- package/dist/person/dto/account.dto.d.ts.map +1 -0
- package/dist/person/dto/account.dto.js +123 -0
- package/dist/person/dto/account.dto.js.map +1 -0
- package/dist/person/dto/activity.dto.d.ts +15 -0
- package/dist/person/dto/activity.dto.d.ts.map +1 -0
- package/dist/person/dto/activity.dto.js +65 -0
- package/dist/person/dto/activity.dto.js.map +1 -0
- package/dist/person/dto/dashboard-query.dto.d.ts +9 -0
- package/dist/person/dto/dashboard-query.dto.d.ts.map +1 -0
- package/dist/person/dto/dashboard-query.dto.js +40 -0
- package/dist/person/dto/dashboard-query.dto.js.map +1 -0
- package/dist/person/dto/followup-query.dto.d.ts +10 -0
- package/dist/person/dto/followup-query.dto.d.ts.map +1 -0
- package/dist/person/dto/followup-query.dto.js +45 -0
- package/dist/person/dto/followup-query.dto.js.map +1 -0
- package/dist/person/dto/reports-query.dto.d.ts +8 -0
- package/dist/person/dto/reports-query.dto.d.ts.map +1 -0
- package/dist/person/dto/reports-query.dto.js +33 -0
- package/dist/person/dto/reports-query.dto.js.map +1 -0
- package/dist/person/person.controller.d.ts +266 -5
- package/dist/person/person.controller.d.ts.map +1 -1
- package/dist/person/person.controller.js +164 -6
- package/dist/person/person.controller.js.map +1 -1
- package/dist/person/person.service.d.ts +295 -5
- package/dist/person/person.service.d.ts.map +1 -1
- package/dist/person/person.service.js +1752 -27
- package/dist/person/person.service.js.map +1 -1
- package/dist/person-relation-type/person-relation-type.controller.d.ts +2 -2
- package/dist/person-relation-type/person-relation-type.service.d.ts +2 -2
- package/hedhog/data/route.yaml +68 -19
- package/hedhog/frontend/app/_lib/crm-sections.tsx.ejs +9 -9
- package/hedhog/frontend/app/accounts/_components/account-form-sheet.tsx.ejs +573 -477
- package/hedhog/frontend/app/accounts/_components/account-types.ts.ejs +9 -6
- package/hedhog/frontend/app/accounts/page.tsx.ejs +970 -892
- package/hedhog/frontend/app/activities/_components/activity-detail-sheet.tsx.ejs +240 -0
- package/hedhog/frontend/app/activities/_components/activity-types.ts.ejs +66 -0
- package/hedhog/frontend/app/activities/page.tsx.ejs +460 -812
- package/hedhog/frontend/app/dashboard/_components/dashboard-types.ts.ejs +70 -0
- package/hedhog/frontend/app/dashboard/page.tsx.ejs +639 -491
- package/hedhog/frontend/app/follow-ups/page.tsx.ejs +785 -696
- package/hedhog/frontend/app/person/_components/person-interaction-dialog.tsx.ejs +10 -12
- package/hedhog/frontend/app/reports/_components/report-types.ts.ejs +84 -0
- package/hedhog/frontend/app/reports/page.tsx.ejs +1196 -15
- package/hedhog/frontend/messages/en.json +242 -38
- package/hedhog/frontend/messages/pt.json +242 -38
- package/hedhog/table/crm_activity.yaml +68 -0
- package/hedhog/table/crm_stage_history.yaml +34 -0
- package/hedhog/table/person_company.yaml +27 -5
- package/package.json +9 -9
- package/src/person/dto/account.dto.ts +100 -0
- package/src/person/dto/activity.dto.ts +54 -0
- package/src/person/dto/dashboard-query.dto.ts +25 -0
- package/src/person/dto/followup-query.dto.ts +25 -0
- package/src/person/dto/reports-query.dto.ts +25 -0
- package/src/person/person.controller.ts +176 -43
- package/src/person/person.service.ts +4825 -2226
|
@@ -1,812 +1,460 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
EmptyState,
|
|
5
|
-
Page,
|
|
6
|
-
PageHeader,
|
|
7
|
-
PaginationFooter,
|
|
8
|
-
SearchBar,
|
|
9
|
-
type SearchBarControl,
|
|
10
|
-
} from '@/components/entity-list';
|
|
11
|
-
import { Badge } from '@/components/ui/badge';
|
|
12
|
-
import { Button } from '@/components/ui/button';
|
|
13
|
-
import { Card, CardContent } from '@/components/ui/card';
|
|
14
|
-
import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
import {
|
|
47
|
-
import {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
type
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
{
|
|
183
|
-
id:
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
toast.success(t('toasts.markedAsCompleted'));
|
|
462
|
-
};
|
|
463
|
-
|
|
464
|
-
const handleView = (subject: string) => {
|
|
465
|
-
toast.info(t('toasts.openDetails', { subject }));
|
|
466
|
-
};
|
|
467
|
-
|
|
468
|
-
const handleViewModeChange = (value: string) => {
|
|
469
|
-
if (value !== 'table' && value !== 'timeline') {
|
|
470
|
-
return;
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
setViewMode(value);
|
|
474
|
-
|
|
475
|
-
try {
|
|
476
|
-
window.localStorage.setItem(ACTIVITIES_VIEW_STORAGE_KEY, value);
|
|
477
|
-
} catch {
|
|
478
|
-
// Ignore storage write failures.
|
|
479
|
-
}
|
|
480
|
-
};
|
|
481
|
-
|
|
482
|
-
return (
|
|
483
|
-
<Page>
|
|
484
|
-
<PageHeader
|
|
485
|
-
breadcrumbs={[
|
|
486
|
-
{ label: 'Home', href: '/' },
|
|
487
|
-
{ label: crmT('breadcrumbs.crm'), href: '/contact/dashboard' },
|
|
488
|
-
{ label: t('title') },
|
|
489
|
-
]}
|
|
490
|
-
title={t('title')}
|
|
491
|
-
description={t('description')}
|
|
492
|
-
/>
|
|
493
|
-
|
|
494
|
-
<div className="space-y-6">
|
|
495
|
-
<KpiCardsGrid items={statsCards} />
|
|
496
|
-
|
|
497
|
-
<SearchBar
|
|
498
|
-
searchQuery={searchInput}
|
|
499
|
-
onSearchChange={(value) => {
|
|
500
|
-
setSearchInput(value);
|
|
501
|
-
setPage(1);
|
|
502
|
-
}}
|
|
503
|
-
onSearch={() => setPage(1)}
|
|
504
|
-
placeholder={t('filters.searchPlaceholder')}
|
|
505
|
-
controls={controls}
|
|
506
|
-
/>
|
|
507
|
-
|
|
508
|
-
<div className="flex items-center justify-end gap-3">
|
|
509
|
-
<span className="text-xs font-medium text-muted-foreground">
|
|
510
|
-
{t('viewMode')}
|
|
511
|
-
</span>
|
|
512
|
-
<ToggleGroup
|
|
513
|
-
type="single"
|
|
514
|
-
value={viewMode}
|
|
515
|
-
onValueChange={handleViewModeChange}
|
|
516
|
-
variant="outline"
|
|
517
|
-
size="sm"
|
|
518
|
-
aria-label={t('viewMode')}
|
|
519
|
-
>
|
|
520
|
-
<ToggleGroupItem
|
|
521
|
-
value="table"
|
|
522
|
-
className="gap-1.5 px-2.5"
|
|
523
|
-
aria-label={t('viewModeTable')}
|
|
524
|
-
>
|
|
525
|
-
<List className="h-4 w-4" />
|
|
526
|
-
<span className="hidden sm:inline">{t('viewModeTable')}</span>
|
|
527
|
-
</ToggleGroupItem>
|
|
528
|
-
<ToggleGroupItem
|
|
529
|
-
value="timeline"
|
|
530
|
-
className="gap-1.5 px-2.5"
|
|
531
|
-
aria-label={t('viewModeTimeline')}
|
|
532
|
-
>
|
|
533
|
-
<LayoutGrid className="h-4 w-4" />
|
|
534
|
-
<span className="hidden sm:inline">{t('viewModeTimeline')}</span>
|
|
535
|
-
</ToggleGroupItem>
|
|
536
|
-
</ToggleGroup>
|
|
537
|
-
</div>
|
|
538
|
-
|
|
539
|
-
{pageData.length === 0 ? (
|
|
540
|
-
<EmptyState
|
|
541
|
-
icon={<CalendarClock className="h-12 w-12" />}
|
|
542
|
-
title={t('empty.title')}
|
|
543
|
-
description={t('empty.description')}
|
|
544
|
-
actionLabel={t('empty.resetFilters')}
|
|
545
|
-
onAction={() => {
|
|
546
|
-
setSearchInput('');
|
|
547
|
-
setDebouncedSearch('');
|
|
548
|
-
setStatusFilter('all');
|
|
549
|
-
setTypeFilter('all');
|
|
550
|
-
setPriorityFilter('all');
|
|
551
|
-
setPage(1);
|
|
552
|
-
}}
|
|
553
|
-
/>
|
|
554
|
-
) : viewMode === 'table' ? (
|
|
555
|
-
<div className="overflow-x-auto rounded-md border">
|
|
556
|
-
<Table>
|
|
557
|
-
<TableHeader>
|
|
558
|
-
<TableRow>
|
|
559
|
-
<TableHead>{t('table.activity')}</TableHead>
|
|
560
|
-
<TableHead>{t('table.person')}</TableHead>
|
|
561
|
-
<TableHead>{t('table.owner')}</TableHead>
|
|
562
|
-
<TableHead>{t('table.dueAt')}</TableHead>
|
|
563
|
-
<TableHead>{t('table.status')}</TableHead>
|
|
564
|
-
<TableHead>{t('table.priority')}</TableHead>
|
|
565
|
-
<TableHead className="text-right">
|
|
566
|
-
{t('table.actions')}
|
|
567
|
-
</TableHead>
|
|
568
|
-
</TableRow>
|
|
569
|
-
</TableHeader>
|
|
570
|
-
<TableBody>
|
|
571
|
-
{pageData.map((item) => {
|
|
572
|
-
const TypeIcon = getTypeIcon(item.type);
|
|
573
|
-
const completed = item.status === 'completed';
|
|
574
|
-
|
|
575
|
-
return (
|
|
576
|
-
<TableRow key={item.id}>
|
|
577
|
-
<TableCell>
|
|
578
|
-
<div className="min-w-[260px] space-y-1">
|
|
579
|
-
<div className="flex items-center gap-2">
|
|
580
|
-
<TypeIcon className="h-4 w-4 text-muted-foreground" />
|
|
581
|
-
<p className="font-medium">{item.subject}</p>
|
|
582
|
-
</div>
|
|
583
|
-
<p className="line-clamp-2 text-xs text-muted-foreground">
|
|
584
|
-
{item.notes}
|
|
585
|
-
</p>
|
|
586
|
-
<div className="text-[11px] text-muted-foreground">
|
|
587
|
-
{t('table.createdAt')}:{' '}
|
|
588
|
-
{formatDateTime(
|
|
589
|
-
item.createdAt,
|
|
590
|
-
getSettingValue,
|
|
591
|
-
currentLocaleCode
|
|
592
|
-
)}
|
|
593
|
-
</div>
|
|
594
|
-
</div>
|
|
595
|
-
</TableCell>
|
|
596
|
-
<TableCell>
|
|
597
|
-
<div className="inline-flex items-center gap-2">
|
|
598
|
-
<Users className="h-3.5 w-3.5 text-muted-foreground" />
|
|
599
|
-
<span>{item.personName}</span>
|
|
600
|
-
</div>
|
|
601
|
-
</TableCell>
|
|
602
|
-
<TableCell>
|
|
603
|
-
<div className="inline-flex items-center gap-2">
|
|
604
|
-
<User className="h-3.5 w-3.5 text-muted-foreground" />
|
|
605
|
-
<span>{item.ownerName}</span>
|
|
606
|
-
</div>
|
|
607
|
-
</TableCell>
|
|
608
|
-
<TableCell>
|
|
609
|
-
{formatDateTime(
|
|
610
|
-
item.dueAt,
|
|
611
|
-
getSettingValue,
|
|
612
|
-
currentLocaleCode
|
|
613
|
-
)}
|
|
614
|
-
</TableCell>
|
|
615
|
-
<TableCell>
|
|
616
|
-
<Badge
|
|
617
|
-
variant="outline"
|
|
618
|
-
className={cn(
|
|
619
|
-
'border',
|
|
620
|
-
statusBadgeClass(item.status)
|
|
621
|
-
)}
|
|
622
|
-
>
|
|
623
|
-
{t(`status.${item.status}`)}
|
|
624
|
-
</Badge>
|
|
625
|
-
</TableCell>
|
|
626
|
-
<TableCell>
|
|
627
|
-
<Badge
|
|
628
|
-
variant="outline"
|
|
629
|
-
className={cn(
|
|
630
|
-
'border',
|
|
631
|
-
priorityBadgeClass(item.priority)
|
|
632
|
-
)}
|
|
633
|
-
>
|
|
634
|
-
{t(`priority.${item.priority}`)}
|
|
635
|
-
</Badge>
|
|
636
|
-
</TableCell>
|
|
637
|
-
<TableCell className="text-right">
|
|
638
|
-
<div className="inline-flex items-center gap-2">
|
|
639
|
-
<Button
|
|
640
|
-
type="button"
|
|
641
|
-
variant="outline"
|
|
642
|
-
size="sm"
|
|
643
|
-
onClick={() => handleView(item.subject)}
|
|
644
|
-
>
|
|
645
|
-
<Eye className="mr-2 h-3.5 w-3.5" />
|
|
646
|
-
{t('actions.view')}
|
|
647
|
-
</Button>
|
|
648
|
-
|
|
649
|
-
<Button
|
|
650
|
-
type="button"
|
|
651
|
-
variant="outline"
|
|
652
|
-
size="sm"
|
|
653
|
-
onClick={() => handleComplete(item.id)}
|
|
654
|
-
disabled={completed}
|
|
655
|
-
className={cn(
|
|
656
|
-
completed && 'cursor-not-allowed opacity-60'
|
|
657
|
-
)}
|
|
658
|
-
>
|
|
659
|
-
<CheckCircle2 className="mr-2 h-3.5 w-3.5" />
|
|
660
|
-
{t('actions.complete')}
|
|
661
|
-
</Button>
|
|
662
|
-
</div>
|
|
663
|
-
</TableCell>
|
|
664
|
-
</TableRow>
|
|
665
|
-
);
|
|
666
|
-
})}
|
|
667
|
-
</TableBody>
|
|
668
|
-
</Table>
|
|
669
|
-
</div>
|
|
670
|
-
) : (
|
|
671
|
-
<div className="space-y-4">
|
|
672
|
-
<div className="rounded-md border border-dashed border-border/70 bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
|
|
673
|
-
{t('timeline.description')}
|
|
674
|
-
</div>
|
|
675
|
-
|
|
676
|
-
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
|
677
|
-
{pageData.map((item) => {
|
|
678
|
-
const TypeIcon = getTypeIcon(item.type);
|
|
679
|
-
const completed = item.status === 'completed';
|
|
680
|
-
|
|
681
|
-
return (
|
|
682
|
-
<Card
|
|
683
|
-
key={item.id}
|
|
684
|
-
className="h-full overflow-hidden border-border/70 py-0"
|
|
685
|
-
>
|
|
686
|
-
<CardContent className="space-y-3 p-4">
|
|
687
|
-
<div className="flex items-start justify-between gap-3">
|
|
688
|
-
<div className="min-w-0 space-y-1">
|
|
689
|
-
<div className="flex items-center gap-2">
|
|
690
|
-
<TypeIcon className="h-4 w-4 text-muted-foreground" />
|
|
691
|
-
<p className="line-clamp-2 text-sm font-semibold">
|
|
692
|
-
{item.subject}
|
|
693
|
-
</p>
|
|
694
|
-
</div>
|
|
695
|
-
<p className="line-clamp-2 text-xs text-muted-foreground">
|
|
696
|
-
{item.notes}
|
|
697
|
-
</p>
|
|
698
|
-
</div>
|
|
699
|
-
<Badge
|
|
700
|
-
variant="outline"
|
|
701
|
-
className={cn(
|
|
702
|
-
'shrink-0 border',
|
|
703
|
-
statusBadgeClass(item.status)
|
|
704
|
-
)}
|
|
705
|
-
>
|
|
706
|
-
{t(`status.${item.status}`)}
|
|
707
|
-
</Badge>
|
|
708
|
-
</div>
|
|
709
|
-
|
|
710
|
-
<div className="grid gap-2 rounded-md border border-border/70 bg-background px-3 py-2 text-xs">
|
|
711
|
-
<div className="flex items-center justify-between gap-2">
|
|
712
|
-
<span className="text-muted-foreground">
|
|
713
|
-
{t('timeline.dueLabel')}
|
|
714
|
-
</span>
|
|
715
|
-
<span className="font-medium text-foreground">
|
|
716
|
-
{formatDateTime(
|
|
717
|
-
item.dueAt,
|
|
718
|
-
getSettingValue,
|
|
719
|
-
currentLocaleCode
|
|
720
|
-
)}
|
|
721
|
-
</span>
|
|
722
|
-
</div>
|
|
723
|
-
<div className="flex items-center justify-between gap-2">
|
|
724
|
-
<span className="text-muted-foreground">
|
|
725
|
-
{t('timeline.createdLabel')}
|
|
726
|
-
</span>
|
|
727
|
-
<span className="font-medium text-foreground">
|
|
728
|
-
{formatDateTime(
|
|
729
|
-
item.createdAt,
|
|
730
|
-
getSettingValue,
|
|
731
|
-
currentLocaleCode
|
|
732
|
-
)}
|
|
733
|
-
</span>
|
|
734
|
-
</div>
|
|
735
|
-
<div className="flex items-center justify-between gap-2">
|
|
736
|
-
<span className="text-muted-foreground">
|
|
737
|
-
{t('table.person')}
|
|
738
|
-
</span>
|
|
739
|
-
<span className="truncate font-medium text-foreground">
|
|
740
|
-
{item.personName}
|
|
741
|
-
</span>
|
|
742
|
-
</div>
|
|
743
|
-
<div className="flex items-center justify-between gap-2">
|
|
744
|
-
<span className="text-muted-foreground">
|
|
745
|
-
{t('table.owner')}
|
|
746
|
-
</span>
|
|
747
|
-
<span className="truncate font-medium text-foreground">
|
|
748
|
-
{item.ownerName}
|
|
749
|
-
</span>
|
|
750
|
-
</div>
|
|
751
|
-
</div>
|
|
752
|
-
|
|
753
|
-
<div className="flex items-center justify-between gap-2">
|
|
754
|
-
<Badge
|
|
755
|
-
variant="outline"
|
|
756
|
-
className={cn(
|
|
757
|
-
'border',
|
|
758
|
-
priorityBadgeClass(item.priority)
|
|
759
|
-
)}
|
|
760
|
-
>
|
|
761
|
-
{t(`priority.${item.priority}`)}
|
|
762
|
-
</Badge>
|
|
763
|
-
|
|
764
|
-
<div className="inline-flex items-center gap-2">
|
|
765
|
-
<Button
|
|
766
|
-
type="button"
|
|
767
|
-
variant="outline"
|
|
768
|
-
size="sm"
|
|
769
|
-
onClick={() => handleView(item.subject)}
|
|
770
|
-
>
|
|
771
|
-
<Eye className="mr-2 h-3.5 w-3.5" />
|
|
772
|
-
{t('actions.view')}
|
|
773
|
-
</Button>
|
|
774
|
-
<Button
|
|
775
|
-
type="button"
|
|
776
|
-
variant="outline"
|
|
777
|
-
size="sm"
|
|
778
|
-
onClick={() => handleComplete(item.id)}
|
|
779
|
-
disabled={completed}
|
|
780
|
-
className={cn(
|
|
781
|
-
completed && 'cursor-not-allowed opacity-60'
|
|
782
|
-
)}
|
|
783
|
-
>
|
|
784
|
-
<CheckCircle2 className="mr-2 h-3.5 w-3.5" />
|
|
785
|
-
{t('actions.complete')}
|
|
786
|
-
</Button>
|
|
787
|
-
</div>
|
|
788
|
-
</div>
|
|
789
|
-
</CardContent>
|
|
790
|
-
</Card>
|
|
791
|
-
);
|
|
792
|
-
})}
|
|
793
|
-
</div>
|
|
794
|
-
</div>
|
|
795
|
-
)}
|
|
796
|
-
|
|
797
|
-
<div className="border-t p-4">
|
|
798
|
-
<PaginationFooter
|
|
799
|
-
currentPage={page}
|
|
800
|
-
pageSize={pageSize}
|
|
801
|
-
totalItems={filteredActivities.length}
|
|
802
|
-
onPageChange={setPage}
|
|
803
|
-
onPageSizeChange={(nextPageSize) => {
|
|
804
|
-
setPageSize(nextPageSize);
|
|
805
|
-
setPage(1);
|
|
806
|
-
}}
|
|
807
|
-
/>
|
|
808
|
-
</div>
|
|
809
|
-
</div>
|
|
810
|
-
</Page>
|
|
811
|
-
);
|
|
812
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
EmptyState,
|
|
5
|
+
Page,
|
|
6
|
+
PageHeader,
|
|
7
|
+
PaginationFooter,
|
|
8
|
+
SearchBar,
|
|
9
|
+
type SearchBarControl,
|
|
10
|
+
} from '@/components/entity-list';
|
|
11
|
+
import { Badge } from '@/components/ui/badge';
|
|
12
|
+
import { Button } from '@/components/ui/button';
|
|
13
|
+
import { Card, CardContent } from '@/components/ui/card';
|
|
14
|
+
import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
|
|
15
|
+
import { Skeleton } from '@/components/ui/skeleton';
|
|
16
|
+
import {
|
|
17
|
+
Table,
|
|
18
|
+
TableBody,
|
|
19
|
+
TableCell,
|
|
20
|
+
TableHead,
|
|
21
|
+
TableHeader,
|
|
22
|
+
TableRow,
|
|
23
|
+
} from '@/components/ui/table';
|
|
24
|
+
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
|
25
|
+
import { formatDateTime } from '@/lib/format-date';
|
|
26
|
+
import { cn } from '@/lib/utils';
|
|
27
|
+
import { useApp, useQuery } from '@hed-hog/next-app-provider';
|
|
28
|
+
import {
|
|
29
|
+
Activity,
|
|
30
|
+
CalendarCheck,
|
|
31
|
+
CalendarClock,
|
|
32
|
+
CheckCircle2,
|
|
33
|
+
CircleAlert,
|
|
34
|
+
Clock3,
|
|
35
|
+
Eye,
|
|
36
|
+
LayoutGrid,
|
|
37
|
+
List,
|
|
38
|
+
Loader2,
|
|
39
|
+
Mail,
|
|
40
|
+
MessageCircle,
|
|
41
|
+
NotebookPen,
|
|
42
|
+
Phone,
|
|
43
|
+
User,
|
|
44
|
+
Users,
|
|
45
|
+
} from 'lucide-react';
|
|
46
|
+
import { useTranslations } from 'next-intl';
|
|
47
|
+
import { useEffect, useState } from 'react';
|
|
48
|
+
import { toast } from 'sonner';
|
|
49
|
+
import { ActivityDetailSheet } from './_components/activity-detail-sheet';
|
|
50
|
+
import type {
|
|
51
|
+
ActivityListItem,
|
|
52
|
+
ActivityPriority,
|
|
53
|
+
ActivityStats,
|
|
54
|
+
ActivityStatus,
|
|
55
|
+
ActivityType,
|
|
56
|
+
ActivityViewMode,
|
|
57
|
+
PaginatedResult,
|
|
58
|
+
} from './_components/activity-types';
|
|
59
|
+
|
|
60
|
+
const ACTIVITIES_VIEW_STORAGE_KEY = 'contact-activities-view-mode';
|
|
61
|
+
|
|
62
|
+
function getTypeIcon(type: ActivityType) {
|
|
63
|
+
if (type === 'call') return Phone;
|
|
64
|
+
if (type === 'email') return Mail;
|
|
65
|
+
if (type === 'meeting') return Users;
|
|
66
|
+
if (type === 'whatsapp') return MessageCircle;
|
|
67
|
+
if (type === 'note') return NotebookPen;
|
|
68
|
+
return Activity;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function getStatusBadgeClass(status: ActivityStatus) {
|
|
72
|
+
if (status === 'completed') return 'border-emerald-500/25 bg-emerald-500/10 text-emerald-700';
|
|
73
|
+
if (status === 'overdue') return 'border-red-500/25 bg-red-500/10 text-red-700';
|
|
74
|
+
return 'border-amber-500/25 bg-amber-500/10 text-amber-700';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getPriorityBadgeClass(priority: ActivityPriority) {
|
|
78
|
+
if (priority === 'high') return 'border-red-500/25 bg-red-500/10 text-red-700';
|
|
79
|
+
if (priority === 'medium') return 'border-sky-500/25 bg-sky-500/10 text-sky-700';
|
|
80
|
+
return 'border-slate-500/25 bg-slate-500/10 text-slate-700';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export default function CrmActivitiesPage() {
|
|
84
|
+
const t = useTranslations('contact.CrmActivities');
|
|
85
|
+
const crmT = useTranslations('contact.CrmMenu');
|
|
86
|
+
const { request, currentLocaleCode, getSettingValue } = useApp();
|
|
87
|
+
|
|
88
|
+
const [searchInput, setSearchInput] = useState('');
|
|
89
|
+
const [debouncedSearch, setDebouncedSearch] = useState('');
|
|
90
|
+
const [statusFilter, setStatusFilter] = useState('all');
|
|
91
|
+
const [typeFilter, setTypeFilter] = useState('all');
|
|
92
|
+
const [priorityFilter, setPriorityFilter] = useState('all');
|
|
93
|
+
const [page, setPage] = useState(1);
|
|
94
|
+
const [pageSize, setPageSize] = useState(12);
|
|
95
|
+
const [viewMode, setViewMode] = useState<ActivityViewMode>('table');
|
|
96
|
+
const [selectedActivityId, setSelectedActivityId] = useState<number | null>(null);
|
|
97
|
+
const [detailOpen, setDetailOpen] = useState(false);
|
|
98
|
+
const [completingActivityId, setCompletingActivityId] = useState<number | null>(null);
|
|
99
|
+
const [detailRefreshKey, setDetailRefreshKey] = useState(0);
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
const timeout = setTimeout(() => setDebouncedSearch(searchInput.trim()), 300);
|
|
103
|
+
return () => clearTimeout(timeout);
|
|
104
|
+
}, [searchInput]);
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
try {
|
|
108
|
+
const saved = window.localStorage.getItem(ACTIVITIES_VIEW_STORAGE_KEY);
|
|
109
|
+
if (saved === 'table' || saved === 'timeline') setViewMode(saved);
|
|
110
|
+
} catch {
|
|
111
|
+
// Ignore storage read failures.
|
|
112
|
+
}
|
|
113
|
+
}, []);
|
|
114
|
+
|
|
115
|
+
const { data: stats = { total: 0, pending: 0, overdue: 0, completed: 0 }, refetch: refetchStats } =
|
|
116
|
+
useQuery<ActivityStats>({
|
|
117
|
+
queryKey: ['contact-activities-stats', currentLocaleCode],
|
|
118
|
+
queryFn: async () => {
|
|
119
|
+
const response = await request<ActivityStats>({
|
|
120
|
+
url: '/person/activities/stats',
|
|
121
|
+
method: 'GET',
|
|
122
|
+
});
|
|
123
|
+
return response.data;
|
|
124
|
+
},
|
|
125
|
+
placeholderData: (previous) =>
|
|
126
|
+
previous ?? { total: 0, pending: 0, overdue: 0, completed: 0 },
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const {
|
|
130
|
+
data: paginate = {
|
|
131
|
+
data: [],
|
|
132
|
+
total: 0,
|
|
133
|
+
page: 1,
|
|
134
|
+
pageSize: 12,
|
|
135
|
+
lastPage: 1,
|
|
136
|
+
prev: null,
|
|
137
|
+
next: null,
|
|
138
|
+
},
|
|
139
|
+
isLoading,
|
|
140
|
+
refetch: refetchActivities,
|
|
141
|
+
} = useQuery<PaginatedResult<ActivityListItem>>({
|
|
142
|
+
queryKey: [
|
|
143
|
+
'contact-activities',
|
|
144
|
+
page,
|
|
145
|
+
pageSize,
|
|
146
|
+
debouncedSearch,
|
|
147
|
+
statusFilter,
|
|
148
|
+
typeFilter,
|
|
149
|
+
priorityFilter,
|
|
150
|
+
currentLocaleCode,
|
|
151
|
+
],
|
|
152
|
+
queryFn: async () => {
|
|
153
|
+
const params = new URLSearchParams({
|
|
154
|
+
page: String(page),
|
|
155
|
+
pageSize: String(pageSize),
|
|
156
|
+
});
|
|
157
|
+
if (debouncedSearch) params.set('search', debouncedSearch);
|
|
158
|
+
if (statusFilter !== 'all') params.set('status', statusFilter);
|
|
159
|
+
if (typeFilter !== 'all') params.set('type', typeFilter);
|
|
160
|
+
if (priorityFilter !== 'all') params.set('priority', priorityFilter);
|
|
161
|
+
|
|
162
|
+
const response = await request<PaginatedResult<ActivityListItem>>({
|
|
163
|
+
url: `/person/activities?${params.toString()}`,
|
|
164
|
+
method: 'GET',
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
return response.data;
|
|
168
|
+
},
|
|
169
|
+
placeholderData: (previous) =>
|
|
170
|
+
previous ?? {
|
|
171
|
+
data: [],
|
|
172
|
+
total: 0,
|
|
173
|
+
page: 1,
|
|
174
|
+
pageSize: 12,
|
|
175
|
+
lastPage: 1,
|
|
176
|
+
prev: null,
|
|
177
|
+
next: null,
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const controls: SearchBarControl[] = [
|
|
182
|
+
{
|
|
183
|
+
id: 'activities-status-filter',
|
|
184
|
+
type: 'select',
|
|
185
|
+
value: statusFilter,
|
|
186
|
+
onChange: (value) => {
|
|
187
|
+
setStatusFilter(value);
|
|
188
|
+
setPage(1);
|
|
189
|
+
},
|
|
190
|
+
placeholder: t('filters.statusPlaceholder'),
|
|
191
|
+
options: [
|
|
192
|
+
{ value: 'all', label: t('filters.allStatuses') },
|
|
193
|
+
{ value: 'pending', label: t('status.pending') },
|
|
194
|
+
{ value: 'overdue', label: t('status.overdue') },
|
|
195
|
+
{ value: 'completed', label: t('status.completed') },
|
|
196
|
+
],
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
id: 'activities-type-filter',
|
|
200
|
+
type: 'select',
|
|
201
|
+
value: typeFilter,
|
|
202
|
+
onChange: (value) => {
|
|
203
|
+
setTypeFilter(value);
|
|
204
|
+
setPage(1);
|
|
205
|
+
},
|
|
206
|
+
placeholder: t('filters.typePlaceholder'),
|
|
207
|
+
options: [
|
|
208
|
+
{ value: 'all', label: t('filters.allTypes') },
|
|
209
|
+
{ value: 'call', label: t('type.call') },
|
|
210
|
+
{ value: 'email', label: t('type.email') },
|
|
211
|
+
{ value: 'meeting', label: t('type.meeting') },
|
|
212
|
+
{ value: 'whatsapp', label: t('type.whatsapp') },
|
|
213
|
+
{ value: 'task', label: t('type.task') },
|
|
214
|
+
{ value: 'note', label: t('type.note') },
|
|
215
|
+
],
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
id: 'activities-priority-filter',
|
|
219
|
+
type: 'select',
|
|
220
|
+
value: priorityFilter,
|
|
221
|
+
onChange: (value) => {
|
|
222
|
+
setPriorityFilter(value);
|
|
223
|
+
setPage(1);
|
|
224
|
+
},
|
|
225
|
+
placeholder: t('filters.priorityPlaceholder'),
|
|
226
|
+
options: [
|
|
227
|
+
{ value: 'all', label: t('filters.allPriorities') },
|
|
228
|
+
{ value: 'high', label: t('priority.high') },
|
|
229
|
+
{ value: 'medium', label: t('priority.medium') },
|
|
230
|
+
{ value: 'low', label: t('priority.low') },
|
|
231
|
+
],
|
|
232
|
+
},
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
const statsCards = [
|
|
236
|
+
{ key: 'total', title: t('stats.total'), value: stats.total, icon: Activity, accentClassName: 'from-violet-500/20 via-fuchsia-500/10 to-transparent', iconContainerClassName: 'bg-violet-500/10 text-violet-700' },
|
|
237
|
+
{ key: 'pending', title: t('stats.pending'), value: stats.pending, icon: Clock3, accentClassName: 'from-amber-500/20 via-yellow-500/10 to-transparent', iconContainerClassName: 'bg-amber-500/10 text-amber-700' },
|
|
238
|
+
{ key: 'overdue', title: t('stats.overdue'), value: stats.overdue, icon: CircleAlert, accentClassName: 'from-red-500/20 via-rose-500/10 to-transparent', iconContainerClassName: 'bg-red-500/10 text-red-700' },
|
|
239
|
+
{ key: 'completed', title: t('stats.completed'), value: stats.completed, icon: CalendarCheck, accentClassName: 'from-emerald-500/20 via-green-500/10 to-transparent', iconContainerClassName: 'bg-emerald-500/10 text-emerald-700' },
|
|
240
|
+
];
|
|
241
|
+
|
|
242
|
+
const handleViewModeChange = (value: string) => {
|
|
243
|
+
if (value !== 'table' && value !== 'timeline') return;
|
|
244
|
+
setViewMode(value);
|
|
245
|
+
try {
|
|
246
|
+
window.localStorage.setItem(ACTIVITIES_VIEW_STORAGE_KEY, value);
|
|
247
|
+
} catch {
|
|
248
|
+
// Ignore storage write failures.
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const handleComplete = async (activityId: number) => {
|
|
253
|
+
setCompletingActivityId(activityId);
|
|
254
|
+
try {
|
|
255
|
+
await request({ url: `/person/activities/${activityId}/complete`, method: 'POST' });
|
|
256
|
+
setDetailRefreshKey((current) => current + 1);
|
|
257
|
+
await Promise.all([refetchActivities(), refetchStats()]);
|
|
258
|
+
toast.success(t('toasts.markedAsCompleted'));
|
|
259
|
+
} catch {
|
|
260
|
+
toast.error(t('toasts.completeError'));
|
|
261
|
+
} finally {
|
|
262
|
+
setCompletingActivityId(null);
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<Page>
|
|
268
|
+
<PageHeader
|
|
269
|
+
breadcrumbs={[
|
|
270
|
+
{ label: 'Home', href: '/' },
|
|
271
|
+
{ label: crmT('breadcrumbs.crm'), href: '/contact/dashboard' },
|
|
272
|
+
{ label: t('title') },
|
|
273
|
+
]}
|
|
274
|
+
title={t('title')}
|
|
275
|
+
description={t('description')}
|
|
276
|
+
/>
|
|
277
|
+
|
|
278
|
+
<div className="space-y-6">
|
|
279
|
+
<KpiCardsGrid items={statsCards} />
|
|
280
|
+
|
|
281
|
+
<SearchBar
|
|
282
|
+
searchQuery={searchInput}
|
|
283
|
+
onSearchChange={(value) => {
|
|
284
|
+
setSearchInput(value);
|
|
285
|
+
setPage(1);
|
|
286
|
+
}}
|
|
287
|
+
onSearch={() => setPage(1)}
|
|
288
|
+
placeholder={t('filters.searchPlaceholder')}
|
|
289
|
+
controls={controls}
|
|
290
|
+
/>
|
|
291
|
+
|
|
292
|
+
<div className="flex items-center justify-end gap-3">
|
|
293
|
+
<span className="text-xs font-medium text-muted-foreground">{t('viewMode')}</span>
|
|
294
|
+
<ToggleGroup type="single" value={viewMode} onValueChange={handleViewModeChange} variant="outline" size="sm" aria-label={t('viewMode')}>
|
|
295
|
+
<ToggleGroupItem value="table" className="gap-1.5 px-2.5" aria-label={t('viewModeTable')}>
|
|
296
|
+
<List className="h-4 w-4" />
|
|
297
|
+
<span className="hidden sm:inline">{t('viewModeTable')}</span>
|
|
298
|
+
</ToggleGroupItem>
|
|
299
|
+
<ToggleGroupItem value="timeline" className="gap-1.5 px-2.5" aria-label={t('viewModeTimeline')}>
|
|
300
|
+
<LayoutGrid className="h-4 w-4" />
|
|
301
|
+
<span className="hidden sm:inline">{t('viewModeTimeline')}</span>
|
|
302
|
+
</ToggleGroupItem>
|
|
303
|
+
</ToggleGroup>
|
|
304
|
+
</div>
|
|
305
|
+
|
|
306
|
+
{isLoading ? (
|
|
307
|
+
<div className="space-y-3">
|
|
308
|
+
<Skeleton className="h-14 w-full" />
|
|
309
|
+
<Skeleton className="h-14 w-full" />
|
|
310
|
+
<Skeleton className="h-14 w-full" />
|
|
311
|
+
</div>
|
|
312
|
+
) : paginate.data.length === 0 ? (
|
|
313
|
+
<EmptyState
|
|
314
|
+
icon={<CalendarClock className="h-12 w-12" />}
|
|
315
|
+
title={t('empty.title')}
|
|
316
|
+
description={t('empty.description')}
|
|
317
|
+
actionLabel={t('empty.resetFilters')}
|
|
318
|
+
onAction={() => {
|
|
319
|
+
setSearchInput('');
|
|
320
|
+
setDebouncedSearch('');
|
|
321
|
+
setStatusFilter('all');
|
|
322
|
+
setTypeFilter('all');
|
|
323
|
+
setPriorityFilter('all');
|
|
324
|
+
setPage(1);
|
|
325
|
+
}}
|
|
326
|
+
/>
|
|
327
|
+
) : viewMode === 'table' ? (
|
|
328
|
+
<div className="overflow-x-auto rounded-md border">
|
|
329
|
+
<Table>
|
|
330
|
+
<TableHeader>
|
|
331
|
+
<TableRow>
|
|
332
|
+
<TableHead>{t('table.activity')}</TableHead>
|
|
333
|
+
<TableHead>{t('table.person')}</TableHead>
|
|
334
|
+
<TableHead>{t('table.owner')}</TableHead>
|
|
335
|
+
<TableHead>{t('table.dueAt')}</TableHead>
|
|
336
|
+
<TableHead>{t('table.status')}</TableHead>
|
|
337
|
+
<TableHead>{t('table.priority')}</TableHead>
|
|
338
|
+
<TableHead className="text-right">{t('table.actions')}</TableHead>
|
|
339
|
+
</TableRow>
|
|
340
|
+
</TableHeader>
|
|
341
|
+
<TableBody>
|
|
342
|
+
{paginate.data.map((item) => {
|
|
343
|
+
const TypeIcon = getTypeIcon(item.type);
|
|
344
|
+
const isCompleted = item.status === 'completed';
|
|
345
|
+
const isCompleting = completingActivityId === item.id;
|
|
346
|
+
|
|
347
|
+
return (
|
|
348
|
+
<TableRow key={item.id}>
|
|
349
|
+
<TableCell>
|
|
350
|
+
<div className="min-w-[260px] space-y-1">
|
|
351
|
+
<div className="flex items-center gap-2">
|
|
352
|
+
<TypeIcon className="h-4 w-4 text-muted-foreground" />
|
|
353
|
+
<p className="font-medium">{item.subject}</p>
|
|
354
|
+
</div>
|
|
355
|
+
<p className="line-clamp-2 text-xs text-muted-foreground">{item.notes || t('detail.emptyNotes')}</p>
|
|
356
|
+
<div className="text-[11px] text-muted-foreground">
|
|
357
|
+
{t('table.createdAt')}: {formatDateTime(item.created_at, getSettingValue, currentLocaleCode)}
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
</TableCell>
|
|
361
|
+
<TableCell><div className="inline-flex items-center gap-2"><Users className="h-3.5 w-3.5 text-muted-foreground" /><span>{item.person.name}</span></div></TableCell>
|
|
362
|
+
<TableCell><div className="inline-flex items-center gap-2"><User className="h-3.5 w-3.5 text-muted-foreground" /><span>{item.owner_user?.name || t('unassignedOwner')}</span></div></TableCell>
|
|
363
|
+
<TableCell>{formatDateTime(item.due_at, getSettingValue, currentLocaleCode)}</TableCell>
|
|
364
|
+
<TableCell><Badge variant="outline" className={cn('border', getStatusBadgeClass(item.status))}>{t(`status.${item.status}`)}</Badge></TableCell>
|
|
365
|
+
<TableCell><Badge variant="outline" className={cn('border', getPriorityBadgeClass(item.priority))}>{t(`priority.${item.priority}`)}</Badge></TableCell>
|
|
366
|
+
<TableCell className="text-right">
|
|
367
|
+
<div className="inline-flex items-center gap-2">
|
|
368
|
+
<Button type="button" variant="outline" size="sm" onClick={() => { setSelectedActivityId(item.id); setDetailOpen(true); }}>
|
|
369
|
+
<Eye className="mr-2 h-3.5 w-3.5" />
|
|
370
|
+
{t('actions.view')}
|
|
371
|
+
</Button>
|
|
372
|
+
<Button type="button" variant="outline" size="sm" onClick={() => handleComplete(item.id)} disabled={isCompleted || isCompleting} className={cn((isCompleted || isCompleting) && 'cursor-not-allowed opacity-60')}>
|
|
373
|
+
{isCompleting ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="mr-2 h-3.5 w-3.5" />}
|
|
374
|
+
{t('actions.complete')}
|
|
375
|
+
</Button>
|
|
376
|
+
</div>
|
|
377
|
+
</TableCell>
|
|
378
|
+
</TableRow>
|
|
379
|
+
);
|
|
380
|
+
})}
|
|
381
|
+
</TableBody>
|
|
382
|
+
</Table>
|
|
383
|
+
</div>
|
|
384
|
+
) : (
|
|
385
|
+
<div className="space-y-4">
|
|
386
|
+
<div className="rounded-md border border-dashed border-border/70 bg-muted/20 px-4 py-3 text-sm text-muted-foreground">{t('timeline.description')}</div>
|
|
387
|
+
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
|
388
|
+
{paginate.data.map((item) => {
|
|
389
|
+
const TypeIcon = getTypeIcon(item.type);
|
|
390
|
+
const isCompleted = item.status === 'completed';
|
|
391
|
+
const isCompleting = completingActivityId === item.id;
|
|
392
|
+
|
|
393
|
+
return (
|
|
394
|
+
<Card key={item.id} className="h-full overflow-hidden border-border/70 py-0">
|
|
395
|
+
<CardContent className="space-y-3 p-4">
|
|
396
|
+
<div className="flex items-start justify-between gap-3">
|
|
397
|
+
<div className="min-w-0 space-y-1">
|
|
398
|
+
<div className="flex items-center gap-2">
|
|
399
|
+
<TypeIcon className="h-4 w-4 text-muted-foreground" />
|
|
400
|
+
<p className="line-clamp-2 text-sm font-semibold">{item.subject}</p>
|
|
401
|
+
</div>
|
|
402
|
+
<p className="line-clamp-2 text-xs text-muted-foreground">{item.notes || t('detail.emptyNotes')}</p>
|
|
403
|
+
</div>
|
|
404
|
+
<Badge variant="outline" className={cn('shrink-0 border', getStatusBadgeClass(item.status))}>{t(`status.${item.status}`)}</Badge>
|
|
405
|
+
</div>
|
|
406
|
+
<div className="grid gap-2 rounded-md border border-border/70 bg-background px-3 py-2 text-xs">
|
|
407
|
+
<div className="flex items-center justify-between gap-2"><span className="text-muted-foreground">{t('timeline.dueLabel')}</span><span className="font-medium text-foreground">{formatDateTime(item.due_at, getSettingValue, currentLocaleCode)}</span></div>
|
|
408
|
+
<div className="flex items-center justify-between gap-2"><span className="text-muted-foreground">{t('timeline.createdLabel')}</span><span className="font-medium text-foreground">{formatDateTime(item.created_at, getSettingValue, currentLocaleCode)}</span></div>
|
|
409
|
+
<div className="flex items-center justify-between gap-2"><span className="text-muted-foreground">{t('table.person')}</span><span className="truncate font-medium text-foreground">{item.person.name}</span></div>
|
|
410
|
+
<div className="flex items-center justify-between gap-2"><span className="text-muted-foreground">{t('table.owner')}</span><span className="truncate font-medium text-foreground">{item.owner_user?.name || t('unassignedOwner')}</span></div>
|
|
411
|
+
</div>
|
|
412
|
+
<div className="flex items-center justify-between gap-2">
|
|
413
|
+
<Badge variant="outline" className={cn('border', getPriorityBadgeClass(item.priority))}>{t(`priority.${item.priority}`)}</Badge>
|
|
414
|
+
<div className="inline-flex items-center gap-2">
|
|
415
|
+
<Button type="button" variant="outline" size="sm" onClick={() => { setSelectedActivityId(item.id); setDetailOpen(true); }}>
|
|
416
|
+
<Eye className="mr-2 h-3.5 w-3.5" />
|
|
417
|
+
{t('actions.view')}
|
|
418
|
+
</Button>
|
|
419
|
+
<Button type="button" variant="outline" size="sm" onClick={() => handleComplete(item.id)} disabled={isCompleted || isCompleting} className={cn((isCompleted || isCompleting) && 'cursor-not-allowed opacity-60')}>
|
|
420
|
+
{isCompleting ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="mr-2 h-3.5 w-3.5" />}
|
|
421
|
+
{t('actions.complete')}
|
|
422
|
+
</Button>
|
|
423
|
+
</div>
|
|
424
|
+
</div>
|
|
425
|
+
</CardContent>
|
|
426
|
+
</Card>
|
|
427
|
+
);
|
|
428
|
+
})}
|
|
429
|
+
</div>
|
|
430
|
+
</div>
|
|
431
|
+
)}
|
|
432
|
+
|
|
433
|
+
<div className="border-t p-4">
|
|
434
|
+
<PaginationFooter
|
|
435
|
+
currentPage={paginate.page}
|
|
436
|
+
pageSize={paginate.pageSize}
|
|
437
|
+
totalItems={paginate.total}
|
|
438
|
+
onPageChange={setPage}
|
|
439
|
+
onPageSizeChange={(nextPageSize) => {
|
|
440
|
+
setPageSize(nextPageSize);
|
|
441
|
+
setPage(1);
|
|
442
|
+
}}
|
|
443
|
+
/>
|
|
444
|
+
</div>
|
|
445
|
+
</div>
|
|
446
|
+
|
|
447
|
+
<ActivityDetailSheet
|
|
448
|
+
activityId={selectedActivityId}
|
|
449
|
+
open={detailOpen}
|
|
450
|
+
refreshKey={detailRefreshKey}
|
|
451
|
+
isCompleting={selectedActivityId === completingActivityId}
|
|
452
|
+
onOpenChange={(open) => {
|
|
453
|
+
setDetailOpen(open);
|
|
454
|
+
if (!open) setSelectedActivityId(null);
|
|
455
|
+
}}
|
|
456
|
+
onComplete={handleComplete}
|
|
457
|
+
/>
|
|
458
|
+
</Page>
|
|
459
|
+
);
|
|
460
|
+
}
|