@hed-hog/contact 0.0.279 → 0.0.286

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