@hed-hog/contact 0.0.295 → 0.0.297

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 (46) hide show
  1. package/dist/contact-type/contact-type.controller.d.ts +1 -1
  2. package/dist/contact-type/contact-type.service.d.ts +1 -1
  3. package/dist/document-type/document-type.controller.d.ts +1 -1
  4. package/dist/document-type/document-type.service.d.ts +1 -1
  5. package/dist/person/dto/reports-query.dto.d.ts +8 -0
  6. package/dist/person/dto/reports-query.dto.d.ts.map +1 -0
  7. package/dist/person/dto/reports-query.dto.js +33 -0
  8. package/dist/person/dto/reports-query.dto.js.map +1 -0
  9. package/dist/person/person.controller.d.ts +67 -10
  10. package/dist/person/person.controller.d.ts.map +1 -1
  11. package/dist/person/person.controller.js +26 -6
  12. package/dist/person/person.controller.js.map +1 -1
  13. package/dist/person/person.service.d.ts +61 -5
  14. package/dist/person/person.service.d.ts.map +1 -1
  15. package/dist/person/person.service.js +656 -298
  16. package/dist/person/person.service.js.map +1 -1
  17. package/dist/person-relation-type/person-relation-type.controller.d.ts +2 -2
  18. package/dist/person-relation-type/person-relation-type.service.d.ts +2 -2
  19. package/hedhog/data/menu.yaml +163 -163
  20. package/hedhog/data/route.yaml +68 -60
  21. package/hedhog/frontend/app/_lib/crm-sections.tsx.ejs +9 -9
  22. package/hedhog/frontend/app/accounts/_components/account-form-sheet.tsx.ejs +573 -573
  23. package/hedhog/frontend/app/accounts/_components/account-types.ts.ejs +9 -9
  24. package/hedhog/frontend/app/accounts/page.tsx.ejs +970 -970
  25. package/hedhog/frontend/app/activities/_components/activity-detail-sheet.tsx.ejs +240 -240
  26. package/hedhog/frontend/app/activities/_components/activity-types.ts.ejs +66 -66
  27. package/hedhog/frontend/app/activities/page.tsx.ejs +460 -460
  28. package/hedhog/frontend/app/dashboard/_components/dashboard-types.ts.ejs +70 -70
  29. package/hedhog/frontend/app/dashboard/page.tsx.ejs +639 -639
  30. package/hedhog/frontend/app/follow-ups/page.tsx.ejs +785 -785
  31. package/hedhog/frontend/app/person/_components/person-interaction-dialog.tsx.ejs +10 -12
  32. package/hedhog/frontend/app/reports/_components/report-types.ts.ejs +84 -0
  33. package/hedhog/frontend/app/reports/page.tsx.ejs +1196 -15
  34. package/hedhog/frontend/messages/en.json +242 -123
  35. package/hedhog/frontend/messages/pt.json +242 -123
  36. package/hedhog/table/crm_activity.yaml +68 -68
  37. package/hedhog/table/crm_stage_history.yaml +34 -0
  38. package/hedhog/table/person_company.yaml +27 -27
  39. package/package.json +9 -9
  40. package/src/person/dto/account.dto.ts +100 -100
  41. package/src/person/dto/activity.dto.ts +54 -54
  42. package/src/person/dto/dashboard-query.dto.ts +25 -25
  43. package/src/person/dto/followup-query.dto.ts +25 -25
  44. package/src/person/dto/reports-query.dto.ts +25 -0
  45. package/src/person/person.controller.ts +176 -159
  46. package/src/person/person.service.ts +4825 -4288
@@ -1,970 +1,970 @@
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 {
12
- AlertDialog,
13
- AlertDialogAction,
14
- AlertDialogCancel,
15
- AlertDialogContent,
16
- AlertDialogDescription,
17
- AlertDialogHeader,
18
- AlertDialogTitle,
19
- } from '@/components/ui/alert-dialog';
20
- import { Avatar, AvatarFallback } from '@/components/ui/avatar';
21
- import { Badge } from '@/components/ui/badge';
22
- import { Button } from '@/components/ui/button';
23
- import { Card, CardContent } from '@/components/ui/card';
24
- import {
25
- DropdownMenu,
26
- DropdownMenuContent,
27
- DropdownMenuItem,
28
- DropdownMenuSeparator,
29
- DropdownMenuTrigger,
30
- } from '@/components/ui/dropdown-menu';
31
- import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
32
- import { Skeleton } from '@/components/ui/skeleton';
33
- import {
34
- Table,
35
- TableBody,
36
- TableCell,
37
- TableHead,
38
- TableHeader,
39
- TableRow,
40
- } from '@/components/ui/table';
41
- import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
42
- import { formatDate } from '@/lib/format-date';
43
- import { cn } from '@/lib/utils';
44
- import { useApp, useQuery } from '@hed-hog/next-app-provider';
45
- import {
46
- type ColumnDef,
47
- type SortingState,
48
- flexRender,
49
- getCoreRowModel,
50
- useReactTable,
51
- } from '@tanstack/react-table';
52
- import {
53
- ArrowUpDown,
54
- Building2,
55
- Globe,
56
- LayoutGrid,
57
- List,
58
- Mail,
59
- MoreHorizontal,
60
- Pencil,
61
- Phone,
62
- Plus,
63
- Trash2,
64
- } from 'lucide-react';
65
- import { useTranslations } from 'next-intl';
66
- import { type ReactNode, useEffect, useMemo, useState } from 'react';
67
- import { toast } from 'sonner';
68
- import { AccountFormSheet } from './_components/account-form-sheet';
69
- import type {
70
- Account,
71
- AccountFormValues,
72
- AccountStats,
73
- PaginatedResult,
74
- UserOption,
75
- } from './_components/account-types';
76
-
77
- const ACCOUNT_VIEW_STORAGE_KEY = 'contact-account-view-mode';
78
-
79
- type AccountViewMode = 'table' | 'cards';
80
-
81
- type RequestError = {
82
- message?: string;
83
- };
84
-
85
- function getAccountInitials(name: string) {
86
- return name
87
- .split(' ')
88
- .filter(Boolean)
89
- .slice(0, 2)
90
- .map((part) => part[0]?.toUpperCase() || '')
91
- .join('');
92
- }
93
-
94
- function getLifecycleStageColor(stage?: string | null) {
95
- switch (stage) {
96
- case 'customer':
97
- return 'border-green-500/20 bg-green-500/10 text-green-600';
98
- case 'prospect':
99
- return 'border-blue-500/20 bg-blue-500/10 text-blue-600';
100
- case 'churned':
101
- return 'border-red-500/20 bg-red-500/10 text-red-600';
102
- case 'inactive':
103
- return 'border-gray-500/20 bg-gray-500/10 text-gray-600';
104
- default:
105
- return 'border-slate-500/20 bg-slate-500/10 text-slate-600';
106
- }
107
- }
108
-
109
- function AccountInfoTile({
110
- icon,
111
- label,
112
- value,
113
- }: {
114
- icon: ReactNode;
115
- label: string;
116
- value: string;
117
- }) {
118
- const hasValue = value.trim() !== '-' && value.trim() !== '';
119
-
120
- return (
121
- <div
122
- className={cn(
123
- 'rounded-xl border border-border/70 bg-muted/25 p-2.5 transition-colors',
124
- hasValue
125
- ? 'text-foreground'
126
- : 'border-dashed bg-muted/40 text-muted-foreground'
127
- )}
128
- >
129
- <div className="mb-2 flex items-center gap-2">
130
- <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border bg-background text-muted-foreground">
131
- {icon}
132
- </div>
133
- <span className="truncate text-[10px] font-semibold tracking-[0.14em] text-muted-foreground uppercase">
134
- {label}
135
- </span>
136
- </div>
137
-
138
- <div
139
- className={cn(
140
- 'line-clamp-2 text-sm font-medium',
141
- hasValue ? 'text-foreground' : 'text-muted-foreground'
142
- )}
143
- >
144
- {hasValue ? value : '-'}
145
- </div>
146
- </div>
147
- );
148
- }
149
-
150
- export default function AccountsPage() {
151
- const t = useTranslations('contact.AccountsPage');
152
- const crmT = useTranslations('contact.CrmMenu');
153
- const { request, currentLocaleCode, getSettingValue } = useApp();
154
-
155
- const [sorting, setSorting] = useState<SortingState>([]);
156
- const [page, setPage] = useState(1);
157
- const [pageSize, setPageSize] = useState(12);
158
- const [searchInput, setSearchInput] = useState('');
159
- const [debouncedSearch, setDebouncedSearch] = useState('');
160
- const [statusFilter, setStatusFilter] = useState('all');
161
- const [lifecycleFilter, setLifecycleFilter] = useState('all');
162
- const [formSheetOpen, setFormSheetOpen] = useState(false);
163
- const [accountToEdit, setAccountToEdit] = useState<Account | null>(null);
164
- const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
165
- const [accountToDelete, setAccountToDelete] = useState<Account | null>(null);
166
- const [isDeleting, setIsDeleting] = useState(false);
167
- const [isSubmitting, setIsSubmitting] = useState(false);
168
- const [viewMode, setViewMode] = useState<AccountViewMode>('table');
169
-
170
- useEffect(() => {
171
- const timeout = setTimeout(() => {
172
- setDebouncedSearch(searchInput.trim());
173
- }, 300);
174
-
175
- return () => clearTimeout(timeout);
176
- }, [searchInput]);
177
-
178
- useEffect(() => {
179
- try {
180
- const savedViewMode = window.localStorage.getItem(
181
- ACCOUNT_VIEW_STORAGE_KEY
182
- );
183
- if (savedViewMode === 'table' || savedViewMode === 'cards') {
184
- setViewMode(savedViewMode);
185
- }
186
- } catch {
187
- // Ignore storage read failures.
188
- }
189
- }, []);
190
-
191
- const currentSort = sorting[0];
192
- const sortField = currentSort?.id === 'created_at' ? 'created_at' : 'name';
193
- const sortOrder = currentSort?.desc ? 'desc' : 'asc';
194
-
195
- const {
196
- data: stats = {
197
- total: 0,
198
- active: 0,
199
- customers: 0,
200
- prospects: 0,
201
- },
202
- refetch: refetchStats,
203
- } = useQuery<AccountStats>({
204
- queryKey: ['contact-accounts-stats', currentLocaleCode],
205
- queryFn: async () => {
206
- const response = await request<AccountStats>({
207
- url: '/person/accounts/stats',
208
- method: 'GET',
209
- });
210
-
211
- return response.data;
212
- },
213
- placeholderData: (previous) =>
214
- previous ?? {
215
- total: 0,
216
- active: 0,
217
- customers: 0,
218
- prospects: 0,
219
- },
220
- });
221
-
222
- const { data: owners = [] } = useQuery<UserOption[]>({
223
- queryKey: ['contact-account-owner-options', currentLocaleCode],
224
- queryFn: async () => {
225
- const response = await request<UserOption[] | { data?: UserOption[] }>({
226
- url: '/person/owner-options',
227
- method: 'GET',
228
- });
229
-
230
- return Array.isArray(response.data)
231
- ? response.data
232
- : response.data?.data || [];
233
- },
234
- placeholderData: (previous) => previous ?? [],
235
- });
236
-
237
- const {
238
- data: paginate = {
239
- data: [],
240
- total: 0,
241
- page: 1,
242
- pageSize: 12,
243
- lastPage: 1,
244
- prev: null,
245
- next: null,
246
- },
247
- isLoading,
248
- refetch: refetchAccounts,
249
- } = useQuery<PaginatedResult<Account>>({
250
- queryKey: [
251
- 'contact-accounts',
252
- page,
253
- pageSize,
254
- debouncedSearch,
255
- statusFilter,
256
- lifecycleFilter,
257
- sortField,
258
- sortOrder,
259
- currentLocaleCode,
260
- ],
261
- queryFn: async () => {
262
- const params = new URLSearchParams();
263
- params.set('page', String(page));
264
- params.set('pageSize', String(pageSize));
265
- params.set('sortField', sortField);
266
- params.set('sortOrder', sortOrder);
267
- if (debouncedSearch) {
268
- params.set('search', debouncedSearch);
269
- }
270
- if (statusFilter !== 'all') {
271
- params.set('status', statusFilter);
272
- }
273
- if (lifecycleFilter !== 'all') {
274
- params.set('lifecycle_stage', lifecycleFilter);
275
- }
276
-
277
- const response = await request<PaginatedResult<Account>>({
278
- url: `/person/accounts?${params.toString()}`,
279
- method: 'GET',
280
- });
281
-
282
- return response.data;
283
- },
284
- placeholderData: (previous) =>
285
- previous ?? {
286
- data: [],
287
- total: 0,
288
- page: 1,
289
- pageSize: 12,
290
- lastPage: 1,
291
- prev: null,
292
- next: null,
293
- },
294
- });
295
-
296
- const totalPages = Math.max(
297
- 1,
298
- paginate.lastPage ?? (Math.ceil((paginate.total || 0) / pageSize) || 1)
299
- );
300
-
301
- useEffect(() => {
302
- if (page > totalPages) {
303
- setPage(totalPages);
304
- }
305
- }, [page, totalPages]);
306
-
307
- const openCreateSheet = () => {
308
- setAccountToEdit(null);
309
- setFormSheetOpen(true);
310
- };
311
-
312
- const openEditSheet = (account: Account) => {
313
- setAccountToEdit(account);
314
- setFormSheetOpen(true);
315
- };
316
-
317
- const handleFormSubmit = async (data: AccountFormValues) => {
318
- try {
319
- setIsSubmitting(true);
320
-
321
- if (accountToEdit) {
322
- await request({
323
- url: `/person/accounts/${accountToEdit.id}`,
324
- method: 'PATCH',
325
- data,
326
- });
327
- toast.success(t('editSuccess'));
328
- } else {
329
- await request({
330
- url: '/person/accounts',
331
- method: 'POST',
332
- data,
333
- });
334
- toast.success(t('createSuccess'));
335
- }
336
-
337
- setFormSheetOpen(false);
338
- setAccountToEdit(null);
339
- await Promise.all([refetchAccounts(), refetchStats()]);
340
- } catch (error: unknown) {
341
- const message =
342
- typeof error === 'object' && error && 'message' in error
343
- ? (error as RequestError).message
344
- : null;
345
- toast.error(message || t('saveError'));
346
- throw error;
347
- } finally {
348
- setIsSubmitting(false);
349
- }
350
- };
351
-
352
- const handleDelete = async () => {
353
- if (!accountToDelete) return;
354
-
355
- try {
356
- setIsDeleting(true);
357
- await request({
358
- url: '/person/accounts',
359
- method: 'DELETE',
360
- data: {
361
- ids: [accountToDelete.id],
362
- },
363
- });
364
-
365
- toast.success(t('deleteSuccess'));
366
- setDeleteDialogOpen(false);
367
- setAccountToDelete(null);
368
- await Promise.all([refetchAccounts(), refetchStats()]);
369
- } catch (error: unknown) {
370
- const message =
371
- typeof error === 'object' && error && 'message' in error
372
- ? (error as RequestError).message
373
- : null;
374
- toast.error(message || t('deleteError'));
375
- } finally {
376
- setIsDeleting(false);
377
- }
378
- };
379
-
380
- const handleViewModeChange = (value: string) => {
381
- if (value !== 'table' && value !== 'cards') {
382
- return;
383
- }
384
-
385
- setViewMode(value);
386
- try {
387
- window.localStorage.setItem(ACCOUNT_VIEW_STORAGE_KEY, value);
388
- } catch {
389
- // Ignore storage write failures.
390
- }
391
- };
392
-
393
- const columns = useMemo<ColumnDef<Account>[]>(
394
- () => [
395
- {
396
- accessorKey: 'name',
397
- header: ({ column }) => (
398
- <Button
399
- variant="ghost"
400
- onClick={() => {
401
- column.toggleSorting(column.getIsSorted() === 'asc');
402
- setPage(1);
403
- }}
404
- className="-ml-4"
405
- >
406
- {t('columnName')}
407
- <ArrowUpDown className="ml-2 h-4 w-4" />
408
- </Button>
409
- ),
410
- cell: ({ row }) => {
411
- const account = row.original;
412
- return (
413
- <div className="flex items-center gap-3">
414
- <Avatar className="h-9 w-9 rounded-lg">
415
- <AvatarFallback className="rounded-lg bg-slate-100 text-xs font-semibold uppercase text-slate-700">
416
- {getAccountInitials(account.name)}
417
- </AvatarFallback>
418
- </Avatar>
419
- <div className="min-w-0">
420
- <div className="truncate font-medium">{account.name}</div>
421
- {account.trade_name && (
422
- <div className="truncate text-xs text-muted-foreground">
423
- {account.trade_name}
424
- </div>
425
- )}
426
- </div>
427
- </div>
428
- );
429
- },
430
- },
431
- {
432
- accessorKey: 'industry',
433
- header: t('columnIndustry'),
434
- cell: ({ row }) => (
435
- <span className="text-sm text-muted-foreground">
436
- {(row.getValue('industry') as string | null) || '-'}
437
- </span>
438
- ),
439
- },
440
- {
441
- accessorKey: 'lifecycle_stage',
442
- header: t('columnStage'),
443
- cell: ({ row }) => {
444
- const stage = row.getValue('lifecycle_stage') as string | null;
445
- return (
446
- <Badge
447
- variant="outline"
448
- className={cn(
449
- 'border px-2.5 py-1 text-xs font-medium',
450
- getLifecycleStageColor(stage)
451
- )}
452
- >
453
- {stage ? t(`stage_${stage}` as never) : '-'}
454
- </Badge>
455
- );
456
- },
457
- },
458
- {
459
- accessorKey: 'status',
460
- header: t('columnStatus'),
461
- cell: ({ row }) => {
462
- const status = row.getValue('status') as Account['status'];
463
- return (
464
- <Badge
465
- variant="outline"
466
- className={cn(
467
- 'border px-2.5 py-1 text-xs font-medium',
468
- status === 'active'
469
- ? 'border-green-500/20 bg-green-500/10 text-green-600'
470
- : 'border-red-500/20 bg-red-500/10 text-red-600'
471
- )}
472
- >
473
- {t(`status_${status}` as never)}
474
- </Badge>
475
- );
476
- },
477
- },
478
- {
479
- accessorKey: 'owner_user',
480
- header: t('columnOwner'),
481
- cell: ({ row }) => {
482
- const account = row.original;
483
- return (
484
- <span className="text-sm">
485
- {account.owner_user?.name || t('unassigned')}
486
- </span>
487
- );
488
- },
489
- },
490
- {
491
- accessorKey: 'city',
492
- header: t('columnCity'),
493
- cell: ({ row }) => (
494
- <span className="text-sm text-muted-foreground">
495
- {(row.getValue('city') as string | null) || '-'}
496
- </span>
497
- ),
498
- },
499
- {
500
- accessorKey: 'created_at',
501
- header: ({ column }) => (
502
- <Button
503
- variant="ghost"
504
- onClick={() => {
505
- column.toggleSorting(column.getIsSorted() === 'asc');
506
- setPage(1);
507
- }}
508
- className="-ml-4"
509
- >
510
- {t('columnCreatedAt')}
511
- <ArrowUpDown className="ml-2 h-4 w-4" />
512
- </Button>
513
- ),
514
- cell: ({ row }) => (
515
- <span className="text-sm text-muted-foreground">
516
- {formatDate(
517
- row.getValue('created_at') as string,
518
- getSettingValue,
519
- currentLocaleCode
520
- )}
521
- </span>
522
- ),
523
- },
524
- {
525
- id: 'actions',
526
- cell: ({ row }) => {
527
- const account = row.original;
528
- return (
529
- <DropdownMenu>
530
- <DropdownMenuTrigger asChild>
531
- <Button variant="ghost" size="icon" className="h-8 w-8">
532
- <MoreHorizontal className="h-4 w-4" />
533
- </Button>
534
- </DropdownMenuTrigger>
535
- <DropdownMenuContent align="end">
536
- <DropdownMenuItem onClick={() => openEditSheet(account)}>
537
- <Pencil className="mr-2 h-4 w-4" />
538
- {t('edit')}
539
- </DropdownMenuItem>
540
- <DropdownMenuSeparator />
541
- <DropdownMenuItem
542
- className="text-red-600"
543
- onClick={() => {
544
- setAccountToDelete(account);
545
- setDeleteDialogOpen(true);
546
- }}
547
- >
548
- <Trash2 className="mr-2 h-4 w-4" />
549
- {t('delete')}
550
- </DropdownMenuItem>
551
- </DropdownMenuContent>
552
- </DropdownMenu>
553
- );
554
- },
555
- },
556
- ],
557
- [currentLocaleCode, getSettingValue, t]
558
- );
559
-
560
- const table = useReactTable({
561
- data: paginate.data,
562
- columns,
563
- state: { sorting },
564
- manualSorting: true,
565
- onSortingChange: setSorting,
566
- getCoreRowModel: getCoreRowModel(),
567
- });
568
-
569
- const accountsRows = table.getRowModel().rows;
570
-
571
- const statsCards = [
572
- {
573
- key: 'total',
574
- title: t('statsTotal'),
575
- value: stats.total,
576
- icon: Building2,
577
- accentClassName: 'from-slate-500/20 via-slate-400/10 to-transparent',
578
- iconContainerClassName: 'bg-slate-100 text-slate-700',
579
- },
580
- {
581
- key: 'active',
582
- title: t('statsActive'),
583
- value: stats.active,
584
- icon: Building2,
585
- accentClassName: 'from-green-500/20 via-emerald-500/10 to-transparent',
586
- iconContainerClassName: 'bg-green-50 text-green-600',
587
- },
588
- {
589
- key: 'customers',
590
- title: t('statsCustomers'),
591
- value: stats.customers,
592
- icon: Building2,
593
- accentClassName: 'from-blue-500/20 via-cyan-500/10 to-transparent',
594
- iconContainerClassName: 'bg-blue-50 text-blue-600',
595
- },
596
- {
597
- key: 'prospects',
598
- title: t('statsProspects'),
599
- value: stats.prospects,
600
- icon: Building2,
601
- accentClassName: 'from-amber-500/20 via-orange-500/10 to-transparent',
602
- iconContainerClassName: 'bg-amber-50 text-amber-600',
603
- },
604
- ];
605
-
606
- const searchControls: SearchBarControl[] = [
607
- {
608
- id: 'lifecycle-filter',
609
- type: 'select',
610
- value: lifecycleFilter,
611
- onChange: (value: string) => {
612
- setLifecycleFilter(value);
613
- setPage(1);
614
- },
615
- placeholder: t('filterByStage'),
616
- options: [
617
- { value: 'all', label: t('allStages') },
618
- { value: 'prospect', label: t('stage_prospect') },
619
- { value: 'customer', label: t('stage_customer') },
620
- { value: 'churned', label: t('stage_churned') },
621
- { value: 'inactive', label: t('stage_inactive') },
622
- ],
623
- },
624
- {
625
- id: 'status-filter',
626
- type: 'select',
627
- value: statusFilter,
628
- onChange: (value: string) => {
629
- setStatusFilter(value);
630
- setPage(1);
631
- },
632
- placeholder: t('filterByStatus'),
633
- options: [
634
- { value: 'all', label: t('allStatuses') },
635
- { value: 'active', label: t('status_active') },
636
- { value: 'inactive', label: t('status_inactive') },
637
- ],
638
- },
639
- ];
640
-
641
- return (
642
- <Page>
643
- <PageHeader
644
- breadcrumbs={[
645
- { label: 'Home', href: '/' },
646
- { label: crmT('breadcrumbs.crm'), href: '/contact/dashboard' },
647
- { label: t('title') },
648
- ]}
649
- title={t('title')}
650
- description={t('description')}
651
- actions={[
652
- {
653
- label: t('newAccount'),
654
- onClick: openCreateSheet,
655
- icon: <Plus className="h-4 w-4" />,
656
- },
657
- ]}
658
- />
659
-
660
- <KpiCardsGrid items={statsCards} />
661
-
662
- <div className="flex flex-col gap-4 xl:flex-row xl:items-center">
663
- <div className="flex-1">
664
- <SearchBar
665
- searchQuery={searchInput}
666
- onSearchChange={(value) => {
667
- setSearchInput(value);
668
- setPage(1);
669
- }}
670
- onSearch={() => setPage(1)}
671
- placeholder={t('searchPlaceholder')}
672
- controls={searchControls}
673
- />
674
- </div>
675
-
676
- <div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap xl:justify-end">
677
- <div className="flex items-center justify-between gap-3 sm:justify-start">
678
- <span className="text-xs font-medium text-muted-foreground">
679
- {t('viewMode')}
680
- </span>
681
- <ToggleGroup
682
- type="single"
683
- value={viewMode}
684
- onValueChange={handleViewModeChange}
685
- variant="outline"
686
- size="sm"
687
- aria-label={t('viewMode')}
688
- >
689
- <ToggleGroupItem
690
- value="table"
691
- className="gap-1.5 px-2.5"
692
- aria-label={t('viewModeTable')}
693
- >
694
- <List className="h-4 w-4" />
695
- <span className="hidden sm:inline">{t('viewModeTable')}</span>
696
- </ToggleGroupItem>
697
- <ToggleGroupItem
698
- value="cards"
699
- className="gap-1.5 px-2.5"
700
- aria-label={t('viewModeCards')}
701
- >
702
- <LayoutGrid className="h-4 w-4" />
703
- <span className="hidden sm:inline">{t('viewModeCards')}</span>
704
- </ToggleGroupItem>
705
- </ToggleGroup>
706
- </div>
707
- </div>
708
- </div>
709
-
710
- {isLoading ? (
711
- viewMode === 'cards' ? (
712
- <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
713
- {Array.from({ length: 6 }).map((_, index) => (
714
- <Card key={index} className="overflow-hidden py-0">
715
- <CardContent className="space-y-3 p-4">
716
- <div className="flex items-center gap-2.5">
717
- <Skeleton className="h-10 w-10 rounded-xl" />
718
- <div className="flex-1 space-y-2">
719
- <Skeleton className="h-3 w-20" />
720
- <Skeleton className="h-4 w-2/3" />
721
- <Skeleton className="h-3 w-1/2" />
722
- </div>
723
- </div>
724
- <div className="grid gap-3 sm:grid-cols-2">
725
- {Array.from({ length: 4 }).map((__, detailIndex) => (
726
- <Skeleton key={detailIndex} className="h-20 rounded-xl" />
727
- ))}
728
- </div>
729
- <Skeleton className="h-10 rounded-xl" />
730
- </CardContent>
731
- </Card>
732
- ))}
733
- </div>
734
- ) : (
735
- <div className="space-y-3 p-4">
736
- {Array.from({ length: 5 }).map((_, index) => (
737
- <Skeleton key={index} className="h-14 w-full" />
738
- ))}
739
- </div>
740
- )
741
- ) : paginate.data.length === 0 ? (
742
- <EmptyState
743
- icon={<Building2 className="h-12 w-12" />}
744
- title={t('emptyStateTitle')}
745
- description={t('emptyStateDescription')}
746
- actionLabel={t('newAccount')}
747
- actionIcon={<Plus className="mr-2 h-4 w-4" />}
748
- onAction={openCreateSheet}
749
- />
750
- ) : (
751
- <>
752
- {viewMode === 'table' ? (
753
- <div className="overflow-x-auto">
754
- <Table>
755
- <TableHeader>
756
- {table.getHeaderGroups().map((headerGroup) => (
757
- <TableRow key={headerGroup.id}>
758
- {headerGroup.headers.map((header) => (
759
- <TableHead key={header.id}>
760
- {header.isPlaceholder
761
- ? null
762
- : flexRender(
763
- header.column.columnDef.header,
764
- header.getContext()
765
- )}
766
- </TableHead>
767
- ))}
768
- </TableRow>
769
- ))}
770
- </TableHeader>
771
- <TableBody>
772
- {accountsRows.map((row) => (
773
- <TableRow
774
- key={row.id}
775
- className="cursor-pointer"
776
- onDoubleClick={() => openEditSheet(row.original)}
777
- >
778
- {row.getVisibleCells().map((cell) => (
779
- <TableCell key={cell.id}>
780
- {flexRender(
781
- cell.column.columnDef.cell,
782
- cell.getContext()
783
- )}
784
- </TableCell>
785
- ))}
786
- </TableRow>
787
- ))}
788
- </TableBody>
789
- </Table>
790
- </div>
791
- ) : (
792
- <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
793
- {paginate.data.map((account) => (
794
- <Card
795
- key={account.id}
796
- className="group h-full overflow-hidden border-border/70 py-0 transition-colors hover:border-border hover:shadow-md"
797
- onDoubleClick={() => openEditSheet(account)}
798
- >
799
- <CardContent className="flex h-full flex-col gap-3 p-4">
800
- <div className="flex items-start justify-between gap-3">
801
- <div className="flex min-w-0 items-start gap-2.5">
802
- <Avatar className="h-10 w-10 shrink-0 rounded-lg border border-slate-500/20">
803
- <AvatarFallback className="rounded-lg bg-slate-500/8 text-sm font-semibold uppercase text-slate-700 dark:text-slate-200">
804
- {getAccountInitials(account.name)}
805
- </AvatarFallback>
806
- </Avatar>
807
-
808
- <div className="min-w-0 space-y-1.5">
809
- <div className="flex flex-wrap gap-1.5">
810
- <Badge
811
- variant="outline"
812
- className={cn(
813
- 'border px-2 py-0.5 text-[11px] font-medium',
814
- getLifecycleStageColor(account.lifecycle_stage)
815
- )}
816
- >
817
- {account.lifecycle_stage
818
- ? t(`stage_${account.lifecycle_stage}` as never)
819
- : '-'}
820
- </Badge>
821
-
822
- <Badge
823
- variant="outline"
824
- className={cn(
825
- 'border px-2 py-0.5 text-[11px] font-medium',
826
- account.status === 'active'
827
- ? 'border-green-500/20 bg-green-500/10 text-green-600'
828
- : 'border-red-500/20 bg-red-500/10 text-red-600'
829
- )}
830
- >
831
- {t(`status_${account.status}` as never)}
832
- </Badge>
833
- </div>
834
-
835
- <div className="min-w-0">
836
- <h3 className="line-clamp-2 text-sm font-semibold text-foreground">
837
- {account.name}
838
- </h3>
839
- {account.trade_name && (
840
- <p className="truncate text-xs text-muted-foreground">
841
- {account.trade_name}
842
- </p>
843
- )}
844
- {account.industry && (
845
- <p className="truncate text-xs text-muted-foreground">
846
- {account.industry}
847
- </p>
848
- )}
849
- </div>
850
- </div>
851
- </div>
852
-
853
- <DropdownMenu>
854
- <DropdownMenuTrigger asChild>
855
- <Button
856
- variant="ghost"
857
- size="icon"
858
- className="h-8 w-8 rounded-lg"
859
- >
860
- <MoreHorizontal className="h-4 w-4" />
861
- </Button>
862
- </DropdownMenuTrigger>
863
- <DropdownMenuContent align="end">
864
- <DropdownMenuItem
865
- onClick={() => openEditSheet(account)}
866
- >
867
- <Pencil className="mr-2 h-4 w-4" />
868
- {t('edit')}
869
- </DropdownMenuItem>
870
- <DropdownMenuSeparator />
871
- <DropdownMenuItem
872
- className="text-red-600"
873
- onClick={() => {
874
- setAccountToDelete(account);
875
- setDeleteDialogOpen(true);
876
- }}
877
- >
878
- <Trash2 className="mr-2 h-4 w-4" />
879
- {t('delete')}
880
- </DropdownMenuItem>
881
- </DropdownMenuContent>
882
- </DropdownMenu>
883
- </div>
884
-
885
- <div className="grid gap-3 sm:grid-cols-2">
886
- <AccountInfoTile
887
- icon={<Mail className="h-3 w-3" />}
888
- label={t('tileEmail')}
889
- value={account.email || '-'}
890
- />
891
- <AccountInfoTile
892
- icon={<Phone className="h-3 w-3" />}
893
- label={t('tilePhone')}
894
- value={account.phone || '-'}
895
- />
896
- <AccountInfoTile
897
- icon={<Globe className="h-3 w-3" />}
898
- label={t('tileWebsite')}
899
- value={account.website || '-'}
900
- />
901
- <AccountInfoTile
902
- icon={<Building2 className="h-3 w-3" />}
903
- label={t('tileIndustry')}
904
- value={account.industry || '-'}
905
- />
906
- </div>
907
-
908
- <div className="border-t pt-2 text-xs text-muted-foreground">
909
- <div className="flex items-center justify-between">
910
- <span>
911
- {t('owner')}:{' '}
912
- {account.owner_user?.name || t('unassigned')}
913
- </span>
914
- </div>
915
- </div>
916
- </CardContent>
917
- </Card>
918
- ))}
919
- </div>
920
- )}
921
-
922
- <PaginationFooter
923
- currentPage={page}
924
- pageSize={pageSize}
925
- totalItems={paginate.total}
926
- onPageChange={setPage}
927
- onPageSizeChange={(nextPageSize) => {
928
- setPageSize(nextPageSize);
929
- setPage(1);
930
- }}
931
- />
932
- </>
933
- )}
934
-
935
- <AccountFormSheet
936
- open={formSheetOpen}
937
- onOpenChange={setFormSheetOpen}
938
- account={accountToEdit}
939
- owners={owners}
940
- onSubmit={handleFormSubmit}
941
- isLoading={isSubmitting}
942
- />
943
-
944
- <AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
945
- <AlertDialogContent>
946
- <AlertDialogHeader>
947
- <AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
948
- <AlertDialogDescription>
949
- {t('deleteDescription', {
950
- name: accountToDelete?.name || '',
951
- })}
952
- </AlertDialogDescription>
953
- </AlertDialogHeader>
954
- <div className="flex justify-end gap-3">
955
- <AlertDialogCancel disabled={isDeleting}>
956
- {t('cancel')}
957
- </AlertDialogCancel>
958
- <AlertDialogAction
959
- onClick={handleDelete}
960
- disabled={isDeleting}
961
- className="bg-red-600 hover:bg-red-700"
962
- >
963
- {isDeleting ? t('deleting') : t('delete')}
964
- </AlertDialogAction>
965
- </div>
966
- </AlertDialogContent>
967
- </AlertDialog>
968
- </Page>
969
- );
970
- }
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 {
12
+ AlertDialog,
13
+ AlertDialogAction,
14
+ AlertDialogCancel,
15
+ AlertDialogContent,
16
+ AlertDialogDescription,
17
+ AlertDialogHeader,
18
+ AlertDialogTitle,
19
+ } from '@/components/ui/alert-dialog';
20
+ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
21
+ import { Badge } from '@/components/ui/badge';
22
+ import { Button } from '@/components/ui/button';
23
+ import { Card, CardContent } from '@/components/ui/card';
24
+ import {
25
+ DropdownMenu,
26
+ DropdownMenuContent,
27
+ DropdownMenuItem,
28
+ DropdownMenuSeparator,
29
+ DropdownMenuTrigger,
30
+ } from '@/components/ui/dropdown-menu';
31
+ import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
32
+ import { Skeleton } from '@/components/ui/skeleton';
33
+ import {
34
+ Table,
35
+ TableBody,
36
+ TableCell,
37
+ TableHead,
38
+ TableHeader,
39
+ TableRow,
40
+ } from '@/components/ui/table';
41
+ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
42
+ import { formatDate } from '@/lib/format-date';
43
+ import { cn } from '@/lib/utils';
44
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
45
+ import {
46
+ type ColumnDef,
47
+ type SortingState,
48
+ flexRender,
49
+ getCoreRowModel,
50
+ useReactTable,
51
+ } from '@tanstack/react-table';
52
+ import {
53
+ ArrowUpDown,
54
+ Building2,
55
+ Globe,
56
+ LayoutGrid,
57
+ List,
58
+ Mail,
59
+ MoreHorizontal,
60
+ Pencil,
61
+ Phone,
62
+ Plus,
63
+ Trash2,
64
+ } from 'lucide-react';
65
+ import { useTranslations } from 'next-intl';
66
+ import { type ReactNode, useEffect, useMemo, useState } from 'react';
67
+ import { toast } from 'sonner';
68
+ import { AccountFormSheet } from './_components/account-form-sheet';
69
+ import type {
70
+ Account,
71
+ AccountFormValues,
72
+ AccountStats,
73
+ PaginatedResult,
74
+ UserOption,
75
+ } from './_components/account-types';
76
+
77
+ const ACCOUNT_VIEW_STORAGE_KEY = 'contact-account-view-mode';
78
+
79
+ type AccountViewMode = 'table' | 'cards';
80
+
81
+ type RequestError = {
82
+ message?: string;
83
+ };
84
+
85
+ function getAccountInitials(name: string) {
86
+ return name
87
+ .split(' ')
88
+ .filter(Boolean)
89
+ .slice(0, 2)
90
+ .map((part) => part[0]?.toUpperCase() || '')
91
+ .join('');
92
+ }
93
+
94
+ function getLifecycleStageColor(stage?: string | null) {
95
+ switch (stage) {
96
+ case 'customer':
97
+ return 'border-green-500/20 bg-green-500/10 text-green-600';
98
+ case 'prospect':
99
+ return 'border-blue-500/20 bg-blue-500/10 text-blue-600';
100
+ case 'churned':
101
+ return 'border-red-500/20 bg-red-500/10 text-red-600';
102
+ case 'inactive':
103
+ return 'border-gray-500/20 bg-gray-500/10 text-gray-600';
104
+ default:
105
+ return 'border-slate-500/20 bg-slate-500/10 text-slate-600';
106
+ }
107
+ }
108
+
109
+ function AccountInfoTile({
110
+ icon,
111
+ label,
112
+ value,
113
+ }: {
114
+ icon: ReactNode;
115
+ label: string;
116
+ value: string;
117
+ }) {
118
+ const hasValue = value.trim() !== '-' && value.trim() !== '';
119
+
120
+ return (
121
+ <div
122
+ className={cn(
123
+ 'rounded-xl border border-border/70 bg-muted/25 p-2.5 transition-colors',
124
+ hasValue
125
+ ? 'text-foreground'
126
+ : 'border-dashed bg-muted/40 text-muted-foreground'
127
+ )}
128
+ >
129
+ <div className="mb-2 flex items-center gap-2">
130
+ <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border bg-background text-muted-foreground">
131
+ {icon}
132
+ </div>
133
+ <span className="truncate text-[10px] font-semibold tracking-[0.14em] text-muted-foreground uppercase">
134
+ {label}
135
+ </span>
136
+ </div>
137
+
138
+ <div
139
+ className={cn(
140
+ 'line-clamp-2 text-sm font-medium',
141
+ hasValue ? 'text-foreground' : 'text-muted-foreground'
142
+ )}
143
+ >
144
+ {hasValue ? value : '-'}
145
+ </div>
146
+ </div>
147
+ );
148
+ }
149
+
150
+ export default function AccountsPage() {
151
+ const t = useTranslations('contact.AccountsPage');
152
+ const crmT = useTranslations('contact.CrmMenu');
153
+ const { request, currentLocaleCode, getSettingValue } = useApp();
154
+
155
+ const [sorting, setSorting] = useState<SortingState>([]);
156
+ const [page, setPage] = useState(1);
157
+ const [pageSize, setPageSize] = useState(12);
158
+ const [searchInput, setSearchInput] = useState('');
159
+ const [debouncedSearch, setDebouncedSearch] = useState('');
160
+ const [statusFilter, setStatusFilter] = useState('all');
161
+ const [lifecycleFilter, setLifecycleFilter] = useState('all');
162
+ const [formSheetOpen, setFormSheetOpen] = useState(false);
163
+ const [accountToEdit, setAccountToEdit] = useState<Account | null>(null);
164
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
165
+ const [accountToDelete, setAccountToDelete] = useState<Account | null>(null);
166
+ const [isDeleting, setIsDeleting] = useState(false);
167
+ const [isSubmitting, setIsSubmitting] = useState(false);
168
+ const [viewMode, setViewMode] = useState<AccountViewMode>('table');
169
+
170
+ useEffect(() => {
171
+ const timeout = setTimeout(() => {
172
+ setDebouncedSearch(searchInput.trim());
173
+ }, 300);
174
+
175
+ return () => clearTimeout(timeout);
176
+ }, [searchInput]);
177
+
178
+ useEffect(() => {
179
+ try {
180
+ const savedViewMode = window.localStorage.getItem(
181
+ ACCOUNT_VIEW_STORAGE_KEY
182
+ );
183
+ if (savedViewMode === 'table' || savedViewMode === 'cards') {
184
+ setViewMode(savedViewMode);
185
+ }
186
+ } catch {
187
+ // Ignore storage read failures.
188
+ }
189
+ }, []);
190
+
191
+ const currentSort = sorting[0];
192
+ const sortField = currentSort?.id === 'created_at' ? 'created_at' : 'name';
193
+ const sortOrder = currentSort?.desc ? 'desc' : 'asc';
194
+
195
+ const {
196
+ data: stats = {
197
+ total: 0,
198
+ active: 0,
199
+ customers: 0,
200
+ prospects: 0,
201
+ },
202
+ refetch: refetchStats,
203
+ } = useQuery<AccountStats>({
204
+ queryKey: ['contact-accounts-stats', currentLocaleCode],
205
+ queryFn: async () => {
206
+ const response = await request<AccountStats>({
207
+ url: '/person/accounts/stats',
208
+ method: 'GET',
209
+ });
210
+
211
+ return response.data;
212
+ },
213
+ placeholderData: (previous) =>
214
+ previous ?? {
215
+ total: 0,
216
+ active: 0,
217
+ customers: 0,
218
+ prospects: 0,
219
+ },
220
+ });
221
+
222
+ const { data: owners = [] } = useQuery<UserOption[]>({
223
+ queryKey: ['contact-account-owner-options', currentLocaleCode],
224
+ queryFn: async () => {
225
+ const response = await request<UserOption[] | { data?: UserOption[] }>({
226
+ url: '/person/owner-options',
227
+ method: 'GET',
228
+ });
229
+
230
+ return Array.isArray(response.data)
231
+ ? response.data
232
+ : response.data?.data || [];
233
+ },
234
+ placeholderData: (previous) => previous ?? [],
235
+ });
236
+
237
+ const {
238
+ data: paginate = {
239
+ data: [],
240
+ total: 0,
241
+ page: 1,
242
+ pageSize: 12,
243
+ lastPage: 1,
244
+ prev: null,
245
+ next: null,
246
+ },
247
+ isLoading,
248
+ refetch: refetchAccounts,
249
+ } = useQuery<PaginatedResult<Account>>({
250
+ queryKey: [
251
+ 'contact-accounts',
252
+ page,
253
+ pageSize,
254
+ debouncedSearch,
255
+ statusFilter,
256
+ lifecycleFilter,
257
+ sortField,
258
+ sortOrder,
259
+ currentLocaleCode,
260
+ ],
261
+ queryFn: async () => {
262
+ const params = new URLSearchParams();
263
+ params.set('page', String(page));
264
+ params.set('pageSize', String(pageSize));
265
+ params.set('sortField', sortField);
266
+ params.set('sortOrder', sortOrder);
267
+ if (debouncedSearch) {
268
+ params.set('search', debouncedSearch);
269
+ }
270
+ if (statusFilter !== 'all') {
271
+ params.set('status', statusFilter);
272
+ }
273
+ if (lifecycleFilter !== 'all') {
274
+ params.set('lifecycle_stage', lifecycleFilter);
275
+ }
276
+
277
+ const response = await request<PaginatedResult<Account>>({
278
+ url: `/person/accounts?${params.toString()}`,
279
+ method: 'GET',
280
+ });
281
+
282
+ return response.data;
283
+ },
284
+ placeholderData: (previous) =>
285
+ previous ?? {
286
+ data: [],
287
+ total: 0,
288
+ page: 1,
289
+ pageSize: 12,
290
+ lastPage: 1,
291
+ prev: null,
292
+ next: null,
293
+ },
294
+ });
295
+
296
+ const totalPages = Math.max(
297
+ 1,
298
+ paginate.lastPage ?? (Math.ceil((paginate.total || 0) / pageSize) || 1)
299
+ );
300
+
301
+ useEffect(() => {
302
+ if (page > totalPages) {
303
+ setPage(totalPages);
304
+ }
305
+ }, [page, totalPages]);
306
+
307
+ const openCreateSheet = () => {
308
+ setAccountToEdit(null);
309
+ setFormSheetOpen(true);
310
+ };
311
+
312
+ const openEditSheet = (account: Account) => {
313
+ setAccountToEdit(account);
314
+ setFormSheetOpen(true);
315
+ };
316
+
317
+ const handleFormSubmit = async (data: AccountFormValues) => {
318
+ try {
319
+ setIsSubmitting(true);
320
+
321
+ if (accountToEdit) {
322
+ await request({
323
+ url: `/person/accounts/${accountToEdit.id}`,
324
+ method: 'PATCH',
325
+ data,
326
+ });
327
+ toast.success(t('editSuccess'));
328
+ } else {
329
+ await request({
330
+ url: '/person/accounts',
331
+ method: 'POST',
332
+ data,
333
+ });
334
+ toast.success(t('createSuccess'));
335
+ }
336
+
337
+ setFormSheetOpen(false);
338
+ setAccountToEdit(null);
339
+ await Promise.all([refetchAccounts(), refetchStats()]);
340
+ } catch (error: unknown) {
341
+ const message =
342
+ typeof error === 'object' && error && 'message' in error
343
+ ? (error as RequestError).message
344
+ : null;
345
+ toast.error(message || t('saveError'));
346
+ throw error;
347
+ } finally {
348
+ setIsSubmitting(false);
349
+ }
350
+ };
351
+
352
+ const handleDelete = async () => {
353
+ if (!accountToDelete) return;
354
+
355
+ try {
356
+ setIsDeleting(true);
357
+ await request({
358
+ url: '/person/accounts',
359
+ method: 'DELETE',
360
+ data: {
361
+ ids: [accountToDelete.id],
362
+ },
363
+ });
364
+
365
+ toast.success(t('deleteSuccess'));
366
+ setDeleteDialogOpen(false);
367
+ setAccountToDelete(null);
368
+ await Promise.all([refetchAccounts(), refetchStats()]);
369
+ } catch (error: unknown) {
370
+ const message =
371
+ typeof error === 'object' && error && 'message' in error
372
+ ? (error as RequestError).message
373
+ : null;
374
+ toast.error(message || t('deleteError'));
375
+ } finally {
376
+ setIsDeleting(false);
377
+ }
378
+ };
379
+
380
+ const handleViewModeChange = (value: string) => {
381
+ if (value !== 'table' && value !== 'cards') {
382
+ return;
383
+ }
384
+
385
+ setViewMode(value);
386
+ try {
387
+ window.localStorage.setItem(ACCOUNT_VIEW_STORAGE_KEY, value);
388
+ } catch {
389
+ // Ignore storage write failures.
390
+ }
391
+ };
392
+
393
+ const columns = useMemo<ColumnDef<Account>[]>(
394
+ () => [
395
+ {
396
+ accessorKey: 'name',
397
+ header: ({ column }) => (
398
+ <Button
399
+ variant="ghost"
400
+ onClick={() => {
401
+ column.toggleSorting(column.getIsSorted() === 'asc');
402
+ setPage(1);
403
+ }}
404
+ className="-ml-4"
405
+ >
406
+ {t('columnName')}
407
+ <ArrowUpDown className="ml-2 h-4 w-4" />
408
+ </Button>
409
+ ),
410
+ cell: ({ row }) => {
411
+ const account = row.original;
412
+ return (
413
+ <div className="flex items-center gap-3">
414
+ <Avatar className="h-9 w-9 rounded-lg">
415
+ <AvatarFallback className="rounded-lg bg-slate-100 text-xs font-semibold uppercase text-slate-700">
416
+ {getAccountInitials(account.name)}
417
+ </AvatarFallback>
418
+ </Avatar>
419
+ <div className="min-w-0">
420
+ <div className="truncate font-medium">{account.name}</div>
421
+ {account.trade_name && (
422
+ <div className="truncate text-xs text-muted-foreground">
423
+ {account.trade_name}
424
+ </div>
425
+ )}
426
+ </div>
427
+ </div>
428
+ );
429
+ },
430
+ },
431
+ {
432
+ accessorKey: 'industry',
433
+ header: t('columnIndustry'),
434
+ cell: ({ row }) => (
435
+ <span className="text-sm text-muted-foreground">
436
+ {(row.getValue('industry') as string | null) || '-'}
437
+ </span>
438
+ ),
439
+ },
440
+ {
441
+ accessorKey: 'lifecycle_stage',
442
+ header: t('columnStage'),
443
+ cell: ({ row }) => {
444
+ const stage = row.getValue('lifecycle_stage') as string | null;
445
+ return (
446
+ <Badge
447
+ variant="outline"
448
+ className={cn(
449
+ 'border px-2.5 py-1 text-xs font-medium',
450
+ getLifecycleStageColor(stage)
451
+ )}
452
+ >
453
+ {stage ? t(`stage_${stage}` as never) : '-'}
454
+ </Badge>
455
+ );
456
+ },
457
+ },
458
+ {
459
+ accessorKey: 'status',
460
+ header: t('columnStatus'),
461
+ cell: ({ row }) => {
462
+ const status = row.getValue('status') as Account['status'];
463
+ return (
464
+ <Badge
465
+ variant="outline"
466
+ className={cn(
467
+ 'border px-2.5 py-1 text-xs font-medium',
468
+ status === 'active'
469
+ ? 'border-green-500/20 bg-green-500/10 text-green-600'
470
+ : 'border-red-500/20 bg-red-500/10 text-red-600'
471
+ )}
472
+ >
473
+ {t(`status_${status}` as never)}
474
+ </Badge>
475
+ );
476
+ },
477
+ },
478
+ {
479
+ accessorKey: 'owner_user',
480
+ header: t('columnOwner'),
481
+ cell: ({ row }) => {
482
+ const account = row.original;
483
+ return (
484
+ <span className="text-sm">
485
+ {account.owner_user?.name || t('unassigned')}
486
+ </span>
487
+ );
488
+ },
489
+ },
490
+ {
491
+ accessorKey: 'city',
492
+ header: t('columnCity'),
493
+ cell: ({ row }) => (
494
+ <span className="text-sm text-muted-foreground">
495
+ {(row.getValue('city') as string | null) || '-'}
496
+ </span>
497
+ ),
498
+ },
499
+ {
500
+ accessorKey: 'created_at',
501
+ header: ({ column }) => (
502
+ <Button
503
+ variant="ghost"
504
+ onClick={() => {
505
+ column.toggleSorting(column.getIsSorted() === 'asc');
506
+ setPage(1);
507
+ }}
508
+ className="-ml-4"
509
+ >
510
+ {t('columnCreatedAt')}
511
+ <ArrowUpDown className="ml-2 h-4 w-4" />
512
+ </Button>
513
+ ),
514
+ cell: ({ row }) => (
515
+ <span className="text-sm text-muted-foreground">
516
+ {formatDate(
517
+ row.getValue('created_at') as string,
518
+ getSettingValue,
519
+ currentLocaleCode
520
+ )}
521
+ </span>
522
+ ),
523
+ },
524
+ {
525
+ id: 'actions',
526
+ cell: ({ row }) => {
527
+ const account = row.original;
528
+ return (
529
+ <DropdownMenu>
530
+ <DropdownMenuTrigger asChild>
531
+ <Button variant="ghost" size="icon" className="h-8 w-8">
532
+ <MoreHorizontal className="h-4 w-4" />
533
+ </Button>
534
+ </DropdownMenuTrigger>
535
+ <DropdownMenuContent align="end">
536
+ <DropdownMenuItem onClick={() => openEditSheet(account)}>
537
+ <Pencil className="mr-2 h-4 w-4" />
538
+ {t('edit')}
539
+ </DropdownMenuItem>
540
+ <DropdownMenuSeparator />
541
+ <DropdownMenuItem
542
+ className="text-red-600"
543
+ onClick={() => {
544
+ setAccountToDelete(account);
545
+ setDeleteDialogOpen(true);
546
+ }}
547
+ >
548
+ <Trash2 className="mr-2 h-4 w-4" />
549
+ {t('delete')}
550
+ </DropdownMenuItem>
551
+ </DropdownMenuContent>
552
+ </DropdownMenu>
553
+ );
554
+ },
555
+ },
556
+ ],
557
+ [currentLocaleCode, getSettingValue, t]
558
+ );
559
+
560
+ const table = useReactTable({
561
+ data: paginate.data,
562
+ columns,
563
+ state: { sorting },
564
+ manualSorting: true,
565
+ onSortingChange: setSorting,
566
+ getCoreRowModel: getCoreRowModel(),
567
+ });
568
+
569
+ const accountsRows = table.getRowModel().rows;
570
+
571
+ const statsCards = [
572
+ {
573
+ key: 'total',
574
+ title: t('statsTotal'),
575
+ value: stats.total,
576
+ icon: Building2,
577
+ accentClassName: 'from-slate-500/20 via-slate-400/10 to-transparent',
578
+ iconContainerClassName: 'bg-slate-100 text-slate-700',
579
+ },
580
+ {
581
+ key: 'active',
582
+ title: t('statsActive'),
583
+ value: stats.active,
584
+ icon: Building2,
585
+ accentClassName: 'from-green-500/20 via-emerald-500/10 to-transparent',
586
+ iconContainerClassName: 'bg-green-50 text-green-600',
587
+ },
588
+ {
589
+ key: 'customers',
590
+ title: t('statsCustomers'),
591
+ value: stats.customers,
592
+ icon: Building2,
593
+ accentClassName: 'from-blue-500/20 via-cyan-500/10 to-transparent',
594
+ iconContainerClassName: 'bg-blue-50 text-blue-600',
595
+ },
596
+ {
597
+ key: 'prospects',
598
+ title: t('statsProspects'),
599
+ value: stats.prospects,
600
+ icon: Building2,
601
+ accentClassName: 'from-amber-500/20 via-orange-500/10 to-transparent',
602
+ iconContainerClassName: 'bg-amber-50 text-amber-600',
603
+ },
604
+ ];
605
+
606
+ const searchControls: SearchBarControl[] = [
607
+ {
608
+ id: 'lifecycle-filter',
609
+ type: 'select',
610
+ value: lifecycleFilter,
611
+ onChange: (value: string) => {
612
+ setLifecycleFilter(value);
613
+ setPage(1);
614
+ },
615
+ placeholder: t('filterByStage'),
616
+ options: [
617
+ { value: 'all', label: t('allStages') },
618
+ { value: 'prospect', label: t('stage_prospect') },
619
+ { value: 'customer', label: t('stage_customer') },
620
+ { value: 'churned', label: t('stage_churned') },
621
+ { value: 'inactive', label: t('stage_inactive') },
622
+ ],
623
+ },
624
+ {
625
+ id: 'status-filter',
626
+ type: 'select',
627
+ value: statusFilter,
628
+ onChange: (value: string) => {
629
+ setStatusFilter(value);
630
+ setPage(1);
631
+ },
632
+ placeholder: t('filterByStatus'),
633
+ options: [
634
+ { value: 'all', label: t('allStatuses') },
635
+ { value: 'active', label: t('status_active') },
636
+ { value: 'inactive', label: t('status_inactive') },
637
+ ],
638
+ },
639
+ ];
640
+
641
+ return (
642
+ <Page>
643
+ <PageHeader
644
+ breadcrumbs={[
645
+ { label: 'Home', href: '/' },
646
+ { label: crmT('breadcrumbs.crm'), href: '/contact/dashboard' },
647
+ { label: t('title') },
648
+ ]}
649
+ title={t('title')}
650
+ description={t('description')}
651
+ actions={[
652
+ {
653
+ label: t('newAccount'),
654
+ onClick: openCreateSheet,
655
+ icon: <Plus className="h-4 w-4" />,
656
+ },
657
+ ]}
658
+ />
659
+
660
+ <KpiCardsGrid items={statsCards} />
661
+
662
+ <div className="flex flex-col gap-4 xl:flex-row xl:items-center">
663
+ <div className="flex-1">
664
+ <SearchBar
665
+ searchQuery={searchInput}
666
+ onSearchChange={(value) => {
667
+ setSearchInput(value);
668
+ setPage(1);
669
+ }}
670
+ onSearch={() => setPage(1)}
671
+ placeholder={t('searchPlaceholder')}
672
+ controls={searchControls}
673
+ />
674
+ </div>
675
+
676
+ <div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap xl:justify-end">
677
+ <div className="flex items-center justify-between gap-3 sm:justify-start">
678
+ <span className="text-xs font-medium text-muted-foreground">
679
+ {t('viewMode')}
680
+ </span>
681
+ <ToggleGroup
682
+ type="single"
683
+ value={viewMode}
684
+ onValueChange={handleViewModeChange}
685
+ variant="outline"
686
+ size="sm"
687
+ aria-label={t('viewMode')}
688
+ >
689
+ <ToggleGroupItem
690
+ value="table"
691
+ className="gap-1.5 px-2.5"
692
+ aria-label={t('viewModeTable')}
693
+ >
694
+ <List className="h-4 w-4" />
695
+ <span className="hidden sm:inline">{t('viewModeTable')}</span>
696
+ </ToggleGroupItem>
697
+ <ToggleGroupItem
698
+ value="cards"
699
+ className="gap-1.5 px-2.5"
700
+ aria-label={t('viewModeCards')}
701
+ >
702
+ <LayoutGrid className="h-4 w-4" />
703
+ <span className="hidden sm:inline">{t('viewModeCards')}</span>
704
+ </ToggleGroupItem>
705
+ </ToggleGroup>
706
+ </div>
707
+ </div>
708
+ </div>
709
+
710
+ {isLoading ? (
711
+ viewMode === 'cards' ? (
712
+ <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
713
+ {Array.from({ length: 6 }).map((_, index) => (
714
+ <Card key={index} className="overflow-hidden py-0">
715
+ <CardContent className="space-y-3 p-4">
716
+ <div className="flex items-center gap-2.5">
717
+ <Skeleton className="h-10 w-10 rounded-xl" />
718
+ <div className="flex-1 space-y-2">
719
+ <Skeleton className="h-3 w-20" />
720
+ <Skeleton className="h-4 w-2/3" />
721
+ <Skeleton className="h-3 w-1/2" />
722
+ </div>
723
+ </div>
724
+ <div className="grid gap-3 sm:grid-cols-2">
725
+ {Array.from({ length: 4 }).map((__, detailIndex) => (
726
+ <Skeleton key={detailIndex} className="h-20 rounded-xl" />
727
+ ))}
728
+ </div>
729
+ <Skeleton className="h-10 rounded-xl" />
730
+ </CardContent>
731
+ </Card>
732
+ ))}
733
+ </div>
734
+ ) : (
735
+ <div className="space-y-3 p-4">
736
+ {Array.from({ length: 5 }).map((_, index) => (
737
+ <Skeleton key={index} className="h-14 w-full" />
738
+ ))}
739
+ </div>
740
+ )
741
+ ) : paginate.data.length === 0 ? (
742
+ <EmptyState
743
+ icon={<Building2 className="h-12 w-12" />}
744
+ title={t('emptyStateTitle')}
745
+ description={t('emptyStateDescription')}
746
+ actionLabel={t('newAccount')}
747
+ actionIcon={<Plus className="mr-2 h-4 w-4" />}
748
+ onAction={openCreateSheet}
749
+ />
750
+ ) : (
751
+ <>
752
+ {viewMode === 'table' ? (
753
+ <div className="overflow-x-auto">
754
+ <Table>
755
+ <TableHeader>
756
+ {table.getHeaderGroups().map((headerGroup) => (
757
+ <TableRow key={headerGroup.id}>
758
+ {headerGroup.headers.map((header) => (
759
+ <TableHead key={header.id}>
760
+ {header.isPlaceholder
761
+ ? null
762
+ : flexRender(
763
+ header.column.columnDef.header,
764
+ header.getContext()
765
+ )}
766
+ </TableHead>
767
+ ))}
768
+ </TableRow>
769
+ ))}
770
+ </TableHeader>
771
+ <TableBody>
772
+ {accountsRows.map((row) => (
773
+ <TableRow
774
+ key={row.id}
775
+ className="cursor-pointer"
776
+ onDoubleClick={() => openEditSheet(row.original)}
777
+ >
778
+ {row.getVisibleCells().map((cell) => (
779
+ <TableCell key={cell.id}>
780
+ {flexRender(
781
+ cell.column.columnDef.cell,
782
+ cell.getContext()
783
+ )}
784
+ </TableCell>
785
+ ))}
786
+ </TableRow>
787
+ ))}
788
+ </TableBody>
789
+ </Table>
790
+ </div>
791
+ ) : (
792
+ <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
793
+ {paginate.data.map((account) => (
794
+ <Card
795
+ key={account.id}
796
+ className="group h-full overflow-hidden border-border/70 py-0 transition-colors hover:border-border hover:shadow-md"
797
+ onDoubleClick={() => openEditSheet(account)}
798
+ >
799
+ <CardContent className="flex h-full flex-col gap-3 p-4">
800
+ <div className="flex items-start justify-between gap-3">
801
+ <div className="flex min-w-0 items-start gap-2.5">
802
+ <Avatar className="h-10 w-10 shrink-0 rounded-lg border border-slate-500/20">
803
+ <AvatarFallback className="rounded-lg bg-slate-500/8 text-sm font-semibold uppercase text-slate-700 dark:text-slate-200">
804
+ {getAccountInitials(account.name)}
805
+ </AvatarFallback>
806
+ </Avatar>
807
+
808
+ <div className="min-w-0 space-y-1.5">
809
+ <div className="flex flex-wrap gap-1.5">
810
+ <Badge
811
+ variant="outline"
812
+ className={cn(
813
+ 'border px-2 py-0.5 text-[11px] font-medium',
814
+ getLifecycleStageColor(account.lifecycle_stage)
815
+ )}
816
+ >
817
+ {account.lifecycle_stage
818
+ ? t(`stage_${account.lifecycle_stage}` as never)
819
+ : '-'}
820
+ </Badge>
821
+
822
+ <Badge
823
+ variant="outline"
824
+ className={cn(
825
+ 'border px-2 py-0.5 text-[11px] font-medium',
826
+ account.status === 'active'
827
+ ? 'border-green-500/20 bg-green-500/10 text-green-600'
828
+ : 'border-red-500/20 bg-red-500/10 text-red-600'
829
+ )}
830
+ >
831
+ {t(`status_${account.status}` as never)}
832
+ </Badge>
833
+ </div>
834
+
835
+ <div className="min-w-0">
836
+ <h3 className="line-clamp-2 text-sm font-semibold text-foreground">
837
+ {account.name}
838
+ </h3>
839
+ {account.trade_name && (
840
+ <p className="truncate text-xs text-muted-foreground">
841
+ {account.trade_name}
842
+ </p>
843
+ )}
844
+ {account.industry && (
845
+ <p className="truncate text-xs text-muted-foreground">
846
+ {account.industry}
847
+ </p>
848
+ )}
849
+ </div>
850
+ </div>
851
+ </div>
852
+
853
+ <DropdownMenu>
854
+ <DropdownMenuTrigger asChild>
855
+ <Button
856
+ variant="ghost"
857
+ size="icon"
858
+ className="h-8 w-8 rounded-lg"
859
+ >
860
+ <MoreHorizontal className="h-4 w-4" />
861
+ </Button>
862
+ </DropdownMenuTrigger>
863
+ <DropdownMenuContent align="end">
864
+ <DropdownMenuItem
865
+ onClick={() => openEditSheet(account)}
866
+ >
867
+ <Pencil className="mr-2 h-4 w-4" />
868
+ {t('edit')}
869
+ </DropdownMenuItem>
870
+ <DropdownMenuSeparator />
871
+ <DropdownMenuItem
872
+ className="text-red-600"
873
+ onClick={() => {
874
+ setAccountToDelete(account);
875
+ setDeleteDialogOpen(true);
876
+ }}
877
+ >
878
+ <Trash2 className="mr-2 h-4 w-4" />
879
+ {t('delete')}
880
+ </DropdownMenuItem>
881
+ </DropdownMenuContent>
882
+ </DropdownMenu>
883
+ </div>
884
+
885
+ <div className="grid gap-3 sm:grid-cols-2">
886
+ <AccountInfoTile
887
+ icon={<Mail className="h-3 w-3" />}
888
+ label={t('tileEmail')}
889
+ value={account.email || '-'}
890
+ />
891
+ <AccountInfoTile
892
+ icon={<Phone className="h-3 w-3" />}
893
+ label={t('tilePhone')}
894
+ value={account.phone || '-'}
895
+ />
896
+ <AccountInfoTile
897
+ icon={<Globe className="h-3 w-3" />}
898
+ label={t('tileWebsite')}
899
+ value={account.website || '-'}
900
+ />
901
+ <AccountInfoTile
902
+ icon={<Building2 className="h-3 w-3" />}
903
+ label={t('tileIndustry')}
904
+ value={account.industry || '-'}
905
+ />
906
+ </div>
907
+
908
+ <div className="border-t pt-2 text-xs text-muted-foreground">
909
+ <div className="flex items-center justify-between">
910
+ <span>
911
+ {t('owner')}:{' '}
912
+ {account.owner_user?.name || t('unassigned')}
913
+ </span>
914
+ </div>
915
+ </div>
916
+ </CardContent>
917
+ </Card>
918
+ ))}
919
+ </div>
920
+ )}
921
+
922
+ <PaginationFooter
923
+ currentPage={page}
924
+ pageSize={pageSize}
925
+ totalItems={paginate.total}
926
+ onPageChange={setPage}
927
+ onPageSizeChange={(nextPageSize) => {
928
+ setPageSize(nextPageSize);
929
+ setPage(1);
930
+ }}
931
+ />
932
+ </>
933
+ )}
934
+
935
+ <AccountFormSheet
936
+ open={formSheetOpen}
937
+ onOpenChange={setFormSheetOpen}
938
+ account={accountToEdit}
939
+ owners={owners}
940
+ onSubmit={handleFormSubmit}
941
+ isLoading={isSubmitting}
942
+ />
943
+
944
+ <AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
945
+ <AlertDialogContent>
946
+ <AlertDialogHeader>
947
+ <AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
948
+ <AlertDialogDescription>
949
+ {t('deleteDescription', {
950
+ name: accountToDelete?.name || '',
951
+ })}
952
+ </AlertDialogDescription>
953
+ </AlertDialogHeader>
954
+ <div className="flex justify-end gap-3">
955
+ <AlertDialogCancel disabled={isDeleting}>
956
+ {t('cancel')}
957
+ </AlertDialogCancel>
958
+ <AlertDialogAction
959
+ onClick={handleDelete}
960
+ disabled={isDeleting}
961
+ className="bg-red-600 hover:bg-red-700"
962
+ >
963
+ {isDeleting ? t('deleting') : t('delete')}
964
+ </AlertDialogAction>
965
+ </div>
966
+ </AlertDialogContent>
967
+ </AlertDialog>
968
+ </Page>
969
+ );
970
+ }