@hed-hog/contact 0.0.279 → 0.0.285

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 (70) hide show
  1. package/README.md +2 -0
  2. package/dist/person/dto/create-followup.dto.d.ts +5 -0
  3. package/dist/person/dto/create-followup.dto.d.ts.map +1 -0
  4. package/dist/person/dto/create-followup.dto.js +31 -0
  5. package/dist/person/dto/create-followup.dto.js.map +1 -0
  6. package/dist/person/dto/create-interaction.dto.d.ts +12 -0
  7. package/dist/person/dto/create-interaction.dto.d.ts.map +1 -0
  8. package/dist/person/dto/create-interaction.dto.js +39 -0
  9. package/dist/person/dto/create-interaction.dto.js.map +1 -0
  10. package/dist/person/dto/create.dto.d.ts +24 -0
  11. package/dist/person/dto/create.dto.d.ts.map +1 -1
  12. package/dist/person/dto/create.dto.js +56 -1
  13. package/dist/person/dto/create.dto.js.map +1 -1
  14. package/dist/person/dto/duplicates-query.dto.d.ts +8 -0
  15. package/dist/person/dto/duplicates-query.dto.d.ts.map +1 -0
  16. package/dist/person/dto/duplicates-query.dto.js +45 -0
  17. package/dist/person/dto/duplicates-query.dto.js.map +1 -0
  18. package/dist/person/dto/merge.dto.d.ts +6 -0
  19. package/dist/person/dto/merge.dto.d.ts.map +1 -0
  20. package/dist/person/dto/merge.dto.js +35 -0
  21. package/dist/person/dto/merge.dto.js.map +1 -0
  22. package/dist/person/dto/update-lifecycle-stage.dto.d.ts +13 -0
  23. package/dist/person/dto/update-lifecycle-stage.dto.d.ts.map +1 -0
  24. package/dist/person/dto/update-lifecycle-stage.dto.js +34 -0
  25. package/dist/person/dto/update-lifecycle-stage.dto.js.map +1 -0
  26. package/dist/person/dto/update.dto.d.ts +8 -1
  27. package/dist/person/dto/update.dto.d.ts.map +1 -1
  28. package/dist/person/dto/update.dto.js +36 -0
  29. package/dist/person/dto/update.dto.js.map +1 -1
  30. package/dist/person/person.controller.d.ts +57 -1
  31. package/dist/person/person.controller.d.ts.map +1 -1
  32. package/dist/person/person.controller.js +85 -3
  33. package/dist/person/person.controller.js.map +1 -1
  34. package/dist/person/person.service.d.ts +79 -0
  35. package/dist/person/person.service.d.ts.map +1 -1
  36. package/dist/person/person.service.js +730 -9
  37. package/dist/person/person.service.js.map +1 -1
  38. package/hedhog/data/route.yaml +18 -0
  39. package/hedhog/frontend/app/_components/crm-coming-soon.tsx.ejs +110 -110
  40. package/hedhog/frontend/app/_components/crm-nav.tsx.ejs +73 -73
  41. package/hedhog/frontend/app/_lib/crm-mocks.ts.ejs +498 -256
  42. package/hedhog/frontend/app/_lib/crm-sections.tsx.ejs +81 -81
  43. package/hedhog/frontend/app/accounts/_components/account-form-sheet.tsx.ejs +477 -0
  44. package/hedhog/frontend/app/accounts/_components/account-types.ts.ejs +62 -0
  45. package/hedhog/frontend/app/accounts/page.tsx.ejs +886 -15
  46. package/hedhog/frontend/app/activities/page.tsx.ejs +15 -15
  47. package/hedhog/frontend/app/contact-type/page.tsx.ejs +105 -91
  48. package/hedhog/frontend/app/dashboard/page.tsx.ejs +506 -573
  49. package/hedhog/frontend/app/document-type/page.tsx.ejs +105 -91
  50. package/hedhog/frontend/app/follow-ups/page.tsx.ejs +15 -15
  51. package/hedhog/frontend/app/page.tsx.ejs +5 -5
  52. package/hedhog/frontend/app/person/_components/person-form-sheet.tsx.ejs +1440 -1103
  53. package/hedhog/frontend/app/person/_components/person-interaction-dialog.tsx.ejs +4 -3
  54. package/hedhog/frontend/app/person/_components/person-types.ts.ejs +14 -0
  55. package/hedhog/frontend/app/person/page.tsx.ejs +108 -190
  56. package/hedhog/frontend/app/pipeline/_components/lead-detail-sheet.tsx.ejs +599 -0
  57. package/hedhog/frontend/app/pipeline/page.tsx.ejs +1074 -299
  58. package/hedhog/frontend/app/reports/page.tsx.ejs +15 -15
  59. package/hedhog/frontend/messages/en.json +107 -0
  60. package/hedhog/frontend/messages/pt.json +106 -0
  61. package/package.json +6 -6
  62. package/src/person/dto/create-followup.dto.ts +15 -0
  63. package/src/person/dto/create-interaction.dto.ts +23 -0
  64. package/src/person/dto/create.dto.ts +50 -0
  65. package/src/person/dto/duplicates-query.dto.ts +34 -0
  66. package/src/person/dto/merge.dto.ts +15 -0
  67. package/src/person/dto/update-lifecycle-stage.dto.ts +19 -0
  68. package/src/person/dto/update.dto.ts +31 -1
  69. package/src/person/person.controller.ts +63 -2
  70. package/src/person/person.service.ts +1096 -7
@@ -1,15 +1,886 @@
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
+ StatsCards,
11
+ } from '@/components/entity-list';
12
+ import {
13
+ AlertDialog,
14
+ AlertDialogAction,
15
+ AlertDialogCancel,
16
+ AlertDialogContent,
17
+ AlertDialogDescription,
18
+ AlertDialogHeader,
19
+ AlertDialogTitle,
20
+ } from '@/components/ui/alert-dialog';
21
+ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
22
+ import { Badge } from '@/components/ui/badge';
23
+ import { Button } from '@/components/ui/button';
24
+ import { Card, CardContent } from '@/components/ui/card';
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
+ title: t('statsTotal'),
487
+ value: stats.total,
488
+ icon: <Building2 className="h-5 w-5" />,
489
+ iconBgColor: 'bg-slate-100',
490
+ iconColor: 'text-slate-700',
491
+ },
492
+ {
493
+ title: t('statsActive'),
494
+ value: stats.active,
495
+ icon: <Building2 className="h-5 w-5" />,
496
+ iconBgColor: 'bg-green-50',
497
+ iconColor: 'text-green-600',
498
+ },
499
+ {
500
+ title: t('statsCustomers'),
501
+ value: stats.customers,
502
+ icon: <Building2 className="h-5 w-5" />,
503
+ iconBgColor: 'bg-blue-50',
504
+ iconColor: 'text-blue-600',
505
+ },
506
+ {
507
+ title: t('statsProspects'),
508
+ value: stats.prospects,
509
+ icon: <Building2 className="h-5 w-5" />,
510
+ iconBgColor: 'bg-amber-50',
511
+ iconColor: 'text-amber-600',
512
+ },
513
+ ];
514
+
515
+ const searchControls: SearchBarControl[] = [
516
+ {
517
+ id: 'lifecycle-filter',
518
+ type: 'select' as const,
519
+ value: lifecycleFilter,
520
+ onChange: (value: string) => {
521
+ setLifecycleFilter(value);
522
+ setPage(1);
523
+ },
524
+ placeholder: t('filterByStage'),
525
+ options: [
526
+ { value: 'all', label: t('allStages') },
527
+ { value: 'prospect', label: t('stage_prospect') },
528
+ { value: 'customer', label: t('stage_customer') },
529
+ { value: 'churned', label: t('stage_churned') },
530
+ { value: 'inactive', label: t('stage_inactive') },
531
+ ],
532
+ },
533
+ {
534
+ id: 'status-filter',
535
+ type: 'select',
536
+ value: statusFilter,
537
+ onChange: (value: string) => {
538
+ setStatusFilter(value);
539
+ setPage(1);
540
+ },
541
+ placeholder: t('filterByStatus'),
542
+ options: [
543
+ { value: 'all', label: t('allStatuses') },
544
+ { value: 'active', label: t('status_active') },
545
+ { value: 'inactive', label: t('status_inactive') },
546
+ ],
547
+ },
548
+ ];
549
+
550
+ return (
551
+ <Page>
552
+ <PageHeader
553
+ breadcrumbs={[
554
+ { label: 'Home', href: '/' },
555
+ { label: crmT('breadcrumbs.crm'), href: '/contact/dashboard' },
556
+ { label: t('title') },
557
+ ]}
558
+ title={t('title')}
559
+ description={t('description')}
560
+ actions={[
561
+ {
562
+ label: t('newAccount'),
563
+ onClick: openCreateSheet,
564
+ icon: <Plus className="h-4 w-4" />,
565
+ },
566
+ ]}
567
+ />
568
+
569
+ <StatsCards stats={statsCards} />
570
+
571
+ <div className="flex flex-col gap-4 xl:flex-row xl:items-center">
572
+ <div className="flex-1">
573
+ <SearchBar
574
+ searchQuery={searchInput}
575
+ onSearchChange={(value) => {
576
+ setSearchInput(value);
577
+ setPage(1);
578
+ }}
579
+ onSearch={() => {
580
+ setPage(1);
581
+ }}
582
+ placeholder={t('searchPlaceholder')}
583
+ controls={searchControls}
584
+ />
585
+ </div>
586
+
587
+ <div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap xl:justify-end">
588
+ <div className="flex items-center justify-between gap-3 sm:justify-start">
589
+ <span className="text-xs font-medium text-muted-foreground">
590
+ {t('viewMode')}
591
+ </span>
592
+ <ToggleGroup
593
+ type="single"
594
+ value={viewMode}
595
+ onValueChange={handleViewModeChange}
596
+ variant="outline"
597
+ size="sm"
598
+ aria-label={t('viewMode')}
599
+ >
600
+ <ToggleGroupItem
601
+ value="table"
602
+ className="gap-1.5 px-2.5"
603
+ aria-label={t('viewModeTable')}
604
+ >
605
+ <List className="h-4 w-4" />
606
+ <span className="hidden sm:inline">{t('viewModeTable')}</span>
607
+ </ToggleGroupItem>
608
+ <ToggleGroupItem
609
+ value="cards"
610
+ className="gap-1.5 px-2.5"
611
+ aria-label={t('viewModeCards')}
612
+ >
613
+ <LayoutGrid className="h-4 w-4" />
614
+ <span className="hidden sm:inline">{t('viewModeCards')}</span>
615
+ </ToggleGroupItem>
616
+ </ToggleGroup>
617
+ </div>
618
+ </div>
619
+ </div>
620
+
621
+ {isLoading ? (
622
+ viewMode === 'cards' ? (
623
+ <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
624
+ {Array.from({ length: 6 }).map((_, index) => (
625
+ <Card key={index} className="overflow-hidden py-0">
626
+ <CardContent className="space-y-3 p-4">
627
+ <div className="flex items-center gap-2.5">
628
+ <Skeleton className="h-10 w-10 rounded-xl" />
629
+ <div className="flex-1 space-y-2">
630
+ <Skeleton className="h-3 w-20" />
631
+ <Skeleton className="h-4 w-2/3" />
632
+ <Skeleton className="h-3 w-1/2" />
633
+ </div>
634
+ </div>
635
+ <div className="grid gap-3 sm:grid-cols-2">
636
+ {Array.from({ length: 4 }).map((__, detailIndex) => (
637
+ <Skeleton key={detailIndex} className="h-20 rounded-xl" />
638
+ ))}
639
+ </div>
640
+ <Skeleton className="h-10 rounded-xl" />
641
+ </CardContent>
642
+ </Card>
643
+ ))}
644
+ </div>
645
+ ) : (
646
+ <div className="space-y-3 p-4">
647
+ {Array.from({ length: 5 }).map((_, index) => (
648
+ <Skeleton key={index} className="h-14 w-full" />
649
+ ))}
650
+ </div>
651
+ )
652
+ ) : paginatedAccounts.length === 0 ? (
653
+ <EmptyState
654
+ icon={<Building2 className="h-12 w-12" />}
655
+ title={t('emptyStateTitle')}
656
+ description={t('emptyStateDescription')}
657
+ actionLabel={t('newAccount')}
658
+ actionIcon={<Plus className="mr-2 h-4 w-4" />}
659
+ onAction={openCreateSheet}
660
+ />
661
+ ) : (
662
+ <>
663
+ {viewMode === 'table' ? (
664
+ <div className="overflow-x-auto">
665
+ <Table>
666
+ <TableHeader>
667
+ {table.getHeaderGroups().map((headerGroup) => (
668
+ <TableRow key={headerGroup.id}>
669
+ {headerGroup.headers.map((header) => (
670
+ <TableHead key={header.id}>
671
+ {header.isPlaceholder
672
+ ? null
673
+ : flexRender(
674
+ header.column.columnDef.header,
675
+ header.getContext()
676
+ )}
677
+ </TableHead>
678
+ ))}
679
+ </TableRow>
680
+ ))}
681
+ </TableHeader>
682
+ <TableBody>
683
+ {accountsRows.map((row) => (
684
+ <TableRow
685
+ key={row.id}
686
+ className="cursor-pointer"
687
+ onDoubleClick={() => openEditSheet(row.original)}
688
+ >
689
+ {row.getVisibleCells().map((cell) => (
690
+ <TableCell key={cell.id}>
691
+ {flexRender(
692
+ cell.column.columnDef.cell,
693
+ cell.getContext()
694
+ )}
695
+ </TableCell>
696
+ ))}
697
+ </TableRow>
698
+ ))}
699
+ </TableBody>
700
+ </Table>
701
+ </div>
702
+ ) : (
703
+ <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
704
+ {accountsRows.map((row) => {
705
+ const account = row.original;
706
+
707
+ return (
708
+ <Card
709
+ key={account.id}
710
+ className={cn(
711
+ 'group h-full overflow-hidden border-border/70 py-0 transition-colors hover:border-border hover:shadow-md'
712
+ )}
713
+ onDoubleClick={() => openEditSheet(account)}
714
+ >
715
+ <CardContent className="flex h-full flex-col gap-3 p-4">
716
+ <div className="flex items-start justify-between gap-3">
717
+ <div className="flex min-w-0 items-start gap-2.5">
718
+ <Avatar className="h-10 w-10 shrink-0 rounded-lg border border-slate-500/20">
719
+ <AvatarFallback className="rounded-lg bg-slate-500/8 text-sm font-semibold uppercase text-slate-700 dark:text-slate-200">
720
+ {getAccountInitials(account.name)}
721
+ </AvatarFallback>
722
+ </Avatar>
723
+
724
+ <div className="min-w-0 space-y-1.5">
725
+ <div className="flex flex-wrap gap-1.5">
726
+ <Badge
727
+ variant="outline"
728
+ className={cn(
729
+ 'border px-2 py-0.5 text-[11px] font-medium',
730
+ getLifecycleStageColor(
731
+ account.lifecycle_stage
732
+ )
733
+ )}
734
+ >
735
+ {t(`stage_${account.lifecycle_stage}` as any) ||
736
+ account.lifecycle_stage}
737
+ </Badge>
738
+
739
+ <Badge
740
+ variant="outline"
741
+ className={cn(
742
+ 'border px-2 py-0.5 text-[11px] font-medium',
743
+ account.status === 'active'
744
+ ? 'border-green-500/20 bg-green-500/10 text-green-600'
745
+ : 'border-red-500/20 bg-red-500/10 text-red-600'
746
+ )}
747
+ >
748
+ {t(`status_${account.status}` as any) ||
749
+ account.status}
750
+ </Badge>
751
+ </div>
752
+
753
+ <div className="min-w-0">
754
+ <h3 className="line-clamp-2 text-sm font-semibold text-foreground">
755
+ {account.name}
756
+ </h3>
757
+ {account.trade_name && (
758
+ <p className="truncate text-xs text-muted-foreground">
759
+ {account.trade_name}
760
+ </p>
761
+ )}
762
+ {account.industry && (
763
+ <p className="truncate text-xs text-muted-foreground">
764
+ {account.industry}
765
+ </p>
766
+ )}
767
+ </div>
768
+ </div>
769
+ </div>
770
+
771
+ <DropdownMenu>
772
+ <DropdownMenuTrigger asChild>
773
+ <Button
774
+ variant="ghost"
775
+ size="icon"
776
+ className="h-8 w-8 rounded-lg"
777
+ >
778
+ <MoreHorizontal className="h-4 w-4" />
779
+ </Button>
780
+ </DropdownMenuTrigger>
781
+ <DropdownMenuContent align="end">
782
+ <DropdownMenuItem
783
+ onClick={() => openEditSheet(account)}
784
+ >
785
+ <Pencil className="mr-2 h-4 w-4" />
786
+ {t('edit')}
787
+ </DropdownMenuItem>
788
+ <DropdownMenuSeparator />
789
+ <DropdownMenuItem
790
+ className="text-red-600"
791
+ onClick={() => {
792
+ setAccountToDelete(account);
793
+ setDeleteDialogOpen(true);
794
+ }}
795
+ >
796
+ <Trash2 className="mr-2 h-4 w-4" />
797
+ {t('delete')}
798
+ </DropdownMenuItem>
799
+ </DropdownMenuContent>
800
+ </DropdownMenu>
801
+ </div>
802
+
803
+ <div className="grid gap-3 sm:grid-cols-2">
804
+ <AccountInfoTile
805
+ icon={<Mail className="h-3 w-3" />}
806
+ label={t('tileEmail')}
807
+ value={account.email || '-'}
808
+ />
809
+ <AccountInfoTile
810
+ icon={<Phone className="h-3 w-3" />}
811
+ label={t('tilePhone')}
812
+ value={account.phone || '-'}
813
+ />
814
+ <AccountInfoTile
815
+ icon={<Globe className="h-3 w-3" />}
816
+ label={t('tileWebsite')}
817
+ value={account.website || '-'}
818
+ />
819
+ <AccountInfoTile
820
+ icon={<Building2 className="h-3 w-3" />}
821
+ label={t('tileIndustry')}
822
+ value={account.industry || '-'}
823
+ />
824
+ </div>
825
+
826
+ <div className="border-t pt-2 text-xs text-muted-foreground">
827
+ <div className="flex items-center justify-between">
828
+ <span>
829
+ {t('owner')}:{' '}
830
+ {account.owner_user?.name || t('unassigned')}
831
+ </span>
832
+ </div>
833
+ </div>
834
+ </CardContent>
835
+ </Card>
836
+ );
837
+ })}
838
+ </div>
839
+ )}
840
+
841
+ <PaginationFooter
842
+ table={table}
843
+ pageSize={pageSize}
844
+ setPageSize={setPageSize}
845
+ currentPage={page}
846
+ totalPages={pageCount}
847
+ onPageChange={setPage}
848
+ />
849
+ </>
850
+ )}
851
+
852
+ <AccountFormSheet
853
+ open={formSheetOpen}
854
+ onOpenChange={setFormSheetOpen}
855
+ account={accountToEdit}
856
+ owners={crmOwners}
857
+ onSubmit={handleFormSubmit}
858
+ />
859
+
860
+ <AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
861
+ <AlertDialogContent>
862
+ <AlertDialogHeader>
863
+ <AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
864
+ <AlertDialogDescription>
865
+ {t('deleteDescription', {
866
+ name: accountToDelete?.name || '',
867
+ })}
868
+ </AlertDialogDescription>
869
+ </AlertDialogHeader>
870
+ <div className="flex justify-end gap-3">
871
+ <AlertDialogCancel disabled={isDeleting}>
872
+ {t('cancel')}
873
+ </AlertDialogCancel>
874
+ <AlertDialogAction
875
+ onClick={handleDelete}
876
+ disabled={isDeleting}
877
+ className="bg-red-600 hover:bg-red-700"
878
+ >
879
+ {isDeleting ? t('deleting') : t('delete')}
880
+ </AlertDialogAction>
881
+ </div>
882
+ </AlertDialogContent>
883
+ </AlertDialog>
884
+ </Page>
885
+ );
886
+ }