@hed-hog/contact 0.0.270 → 0.0.275

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.
@@ -1,1209 +1,1353 @@
1
- 'use client';
2
-
3
- import { CopyButton } from '@/components/copy-button';
4
- import { Page, PageHeader } from '@/components/entity-list';
5
- import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
6
- import { Badge } from '@/components/ui/badge';
7
- import { Button } from '@/components/ui/button';
8
- import { Card, CardContent } from '@/components/ui/card';
9
- import {
10
- DropdownMenu,
11
- DropdownMenuContent,
12
- DropdownMenuItem,
13
- DropdownMenuSeparator,
14
- DropdownMenuTrigger,
15
- } from '@/components/ui/dropdown-menu';
16
- import { Input } from '@/components/ui/input';
17
- import {
18
- Select,
19
- SelectContent,
20
- SelectItem,
21
- SelectTrigger,
22
- SelectValue,
23
- } from '@/components/ui/select';
24
- import { Skeleton } from '@/components/ui/skeleton';
25
- import {
26
- Table,
27
- TableBody,
28
- TableCell,
29
- TableHead,
30
- TableHeader,
31
- TableRow,
32
- } from '@/components/ui/table';
33
- import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
34
- import { formatDate } from '@/lib/format-date';
35
- import { cn } from '@/lib/utils';
36
- import { useApp, useQuery } from '@hed-hog/next-app-provider';
37
- import {
38
- type ColumnDef,
39
- type SortingState,
40
- flexRender,
41
- getCoreRowModel,
42
- getSortedRowModel,
43
- useReactTable,
44
- } from '@tanstack/react-table';
45
- import {
46
- ArrowUpDown,
47
- Building2,
48
- CalendarDays,
49
- CheckCircle2,
50
- ChevronLeft,
51
- ChevronRight,
52
- ChevronsLeft,
53
- ChevronsRight,
54
- FileText,
55
- LayoutGrid,
56
- List,
57
- Mail,
58
- MapPin,
59
- MoreHorizontal,
60
- Pencil,
61
- Phone,
62
- Plus,
63
- Search,
64
- Trash2,
65
- User,
66
- Users,
67
- XCircle,
68
- } from 'lucide-react';
69
- import { useTranslations } from 'next-intl';
70
- import { type ReactNode, useEffect, useMemo, useState } from 'react';
71
- import { toast } from 'sonner';
72
-
73
- import { DeletePersonDialog } from './_components/delete-person-dialog';
74
- import { PersonFormSheet } from './_components/person-form-sheet';
75
- import type {
76
- ContactTypeOption,
77
- DocumentTypeOption,
78
- PaginatedResult,
79
- Person,
80
- } from './_components/person-types';
81
-
82
- type RequestError = {
83
- message?: string;
84
- };
85
-
86
- const PERSON_VIEW_STORAGE_KEY = 'contact-person-view-mode';
87
-
88
- type PersonViewMode = 'table' | 'cards';
89
-
90
- function formatPhone(value: string) {
91
- const digits = value.replace(/\D/g, '');
92
- if (digits.length === 11) {
93
- return `(${digits.slice(0, 2)}) ${digits.slice(2, 7)}-${digits.slice(7)}`;
94
- }
95
-
96
- if (digits.length === 10) {
97
- return `(${digits.slice(0, 2)}) ${digits.slice(2, 6)}-${digits.slice(6)}`;
98
- }
99
-
100
- return value;
101
- }
102
-
103
- function getPrimaryContact(
104
- person: Person,
105
- codes: string[],
106
- availableContactTypes: ContactTypeOption[]
107
- ) {
108
- const upperCodes = codes.map((code) => code.toUpperCase());
109
- const contactTypeIds = availableContactTypes
110
- .filter((contactType) =>
111
- upperCodes.includes(String(contactType.code).toUpperCase())
112
- )
113
- .map((contactType) => contactType.contact_type_id);
114
-
115
- const contactMatches =
116
- person.contact?.filter((contact) => {
117
- const nestedCode = String(contact.contact_type?.code || '').toUpperCase();
118
- return (
119
- contactTypeIds.includes(contact.contact_type_id) ||
120
- upperCodes.includes(nestedCode)
121
- );
122
- }) ?? [];
123
-
124
- const exactMatch =
125
- contactMatches.find((contact) => {
126
- const nestedCode = String(contact.contact_type?.code || '').toUpperCase();
127
- return (
128
- (contactTypeIds.includes(contact.contact_type_id) ||
129
- upperCodes.includes(nestedCode)) &&
130
- contact.is_primary
131
- );
132
- }) ?? contactMatches[0];
133
-
134
- if (!exactMatch?.value) {
135
- return '-';
136
- }
137
-
138
- return upperCodes.some((code) => ['PHONE', 'MOBILE'].includes(code))
139
- ? formatPhone(exactMatch.value)
140
- : exactMatch.value;
141
- }
142
-
143
- function getPrimaryDisplay(
144
- person: Person,
145
- availableContactTypes: ContactTypeOption[]
146
- ) {
147
- const email = getPrimaryContact(person, ['EMAIL'], availableContactTypes);
148
- if (email !== '-') {
149
- return { icon: <Mail className="h-3 w-3" />, value: email };
150
- }
151
-
152
- const phone = getPrimaryContact(
153
- person,
154
- ['PHONE', 'MOBILE'],
155
- availableContactTypes
156
- );
157
- if (phone !== '-') {
158
- return { icon: <Phone className="h-3 w-3" />, value: phone };
159
- }
160
-
161
- return null;
162
- }
163
-
164
- function getPrimaryDocument(
165
- person: Person,
166
- availableDocumentTypes: DocumentTypeOption[]
167
- ) {
168
- const documents = person.document ?? [];
169
- if (documents.length === 0) {
170
- return null;
171
- }
172
-
173
- const preferredCodes = ['CPF', 'CNPJ', 'RG', 'IE'];
174
- const primaryDocument =
175
- documents.find((document) =>
176
- preferredCodes.includes(
177
- String(document.document_type?.code || '').toUpperCase()
178
- )
179
- ) ?? documents[0];
180
-
181
- if (!primaryDocument?.value) {
182
- return null;
183
- }
184
-
185
- const documentType = availableDocumentTypes.find(
186
- (option) =>
187
- option.document_type_id === primaryDocument.document_type_id ||
188
- String(option.code).toUpperCase() ===
189
- String(primaryDocument.document_type?.code || '').toUpperCase()
190
- );
191
-
192
- return {
193
- label: documentType?.name || primaryDocument.document_type?.code || 'ID',
194
- value: primaryDocument.value,
195
- };
196
- }
197
-
198
- function getPrimaryAddress(person: Person) {
199
- const addresses = person.address ?? [];
200
- return (
201
- addresses.find((address) => address.is_primary) ?? addresses[0] ?? null
202
- );
203
- }
204
-
205
- function getAddressSummary(address: NonNullable<Person['address']>[number]) {
206
- const cityState = [address.city, address.state].filter(Boolean).join(' - ');
207
- return cityState || address.line1 || '-';
208
- }
209
-
210
- function getAddressCopyValue(address: NonNullable<Person['address']>[number]) {
211
- return [
212
- address.line1,
213
- address.line2,
214
- [address.city, address.state].filter(Boolean).join(' - '),
215
- address.postal_code,
216
- address.country_code,
217
- ]
218
- .filter(Boolean)
219
- .join(', ');
220
- }
221
-
222
- function getPersonSecondaryLabel(
223
- person: Person,
224
- allowCompanyRegistration: boolean
225
- ) {
226
- if (!allowCompanyRegistration) {
227
- return person.job_title || null;
228
- }
229
-
230
- if (person.type === 'company') {
231
- return person.trade_name || null;
232
- }
233
-
234
- if (person.job_title) {
235
- return person.employer_company?.name
236
- ? `${person.job_title} · ${person.employer_company.name}`
237
- : person.job_title;
238
- }
239
-
240
- return person.employer_company?.name || null;
241
- }
242
-
243
- function getPersonInitials(name: string) {
244
- return name
245
- .split(' ')
246
- .filter(Boolean)
247
- .slice(0, 2)
248
- .map((part) => part[0]?.toUpperCase() || '')
249
- .join('');
250
- }
251
-
252
- function getPersonAvatarUrl(avatarId?: number | null) {
253
- return typeof avatarId === 'number' && avatarId > 0
254
- ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${avatarId}`
255
- : undefined;
256
- }
257
-
258
- function PersonInfoTile({
259
- icon,
260
- label,
261
- value,
262
- description,
263
- copyValue,
264
- }: {
265
- icon: ReactNode;
266
- label: string;
267
- value: string;
268
- description?: string | null;
269
- copyValue?: string | null;
270
- }) {
271
- const hasValue = value.trim() !== '-' && value.trim() !== '';
272
-
273
- return (
274
- <div
275
- className={cn(
276
- 'rounded-xl border border-border/70 bg-muted/25 p-2.5 transition-colors',
277
- hasValue
278
- ? 'text-foreground'
279
- : 'border-dashed bg-muted/40 text-muted-foreground'
280
- )}
281
- >
282
- <div className="mb-2 flex items-start justify-between gap-2">
283
- <div className="flex min-w-0 items-center gap-2">
284
- <div
285
- className={cn(
286
- 'flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border bg-background text-muted-foreground'
287
- )}
288
- >
289
- {icon}
290
- </div>
291
- <span className="truncate text-[10px] font-semibold tracking-[0.14em] text-muted-foreground uppercase">
292
- {label}
293
- </span>
294
- </div>
295
-
296
- {hasValue && copyValue ? (
297
- <CopyButton
298
- value={copyValue}
299
- className="h-7 w-7 shrink-0 rounded-lg text-muted-foreground hover:text-foreground"
300
- />
301
- ) : null}
302
- </div>
303
-
304
- <div
305
- className={cn(
306
- 'line-clamp-2 text-sm font-medium',
307
- hasValue ? 'text-foreground' : 'text-muted-foreground'
308
- )}
309
- >
310
- {hasValue ? value : '-'}
311
- </div>
312
-
313
- {hasValue && description ? (
314
- <div className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
315
- {description}
316
- </div>
317
- ) : null}
318
- </div>
319
- );
320
- }
321
-
322
- export default function PeoplePage() {
323
- const t = useTranslations('contact.ContactPage');
324
- const { request, currentLocaleCode, getSettingValue } = useApp();
325
- const allowCompanyRegistration =
326
- getSettingValue('contact-allow-company-registration') !== false;
327
-
328
- const [sorting, setSorting] = useState<SortingState>([]);
329
- const [page, setPage] = useState(1);
330
- const [pageSize, setPageSize] = useState(12);
331
- const [searchInput, setSearchInput] = useState('');
332
- const [debouncedSearch, setDebouncedSearch] = useState('');
333
- const [typeFilter, setTypeFilter] = useState('all');
334
- const [statusFilter, setStatusFilter] = useState('all');
335
- const [formSheetOpen, setFormSheetOpen] = useState(false);
336
- const [personToEdit, setPersonToEdit] = useState<Person | null>(null);
337
- const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
338
- const [personToDelete, setPersonToDelete] = useState<Person | null>(null);
339
- const [isDeleting, setIsDeleting] = useState(false);
340
- const [viewMode, setViewMode] = useState<PersonViewMode>('table');
341
-
342
- useEffect(() => {
343
- const timeout = setTimeout(() => {
344
- setDebouncedSearch(searchInput.trim());
345
- }, 300);
346
-
347
- return () => clearTimeout(timeout);
348
- }, [searchInput]);
349
-
350
- useEffect(() => {
351
- try {
352
- const savedViewMode = window.localStorage.getItem(
353
- PERSON_VIEW_STORAGE_KEY
354
- );
355
- if (savedViewMode === 'table' || savedViewMode === 'cards') {
356
- setViewMode(savedViewMode);
357
- }
358
- } catch {
359
- // Ignore storage read failures and keep the default view.
360
- }
361
- }, []);
362
-
363
- useEffect(() => {
364
- if (!allowCompanyRegistration && typeFilter === 'company') {
365
- setTypeFilter('all');
366
- }
367
- }, [allowCompanyRegistration, typeFilter]);
368
-
369
- const { data: contactTypes = [] } = useQuery<ContactTypeOption[]>({
370
- queryKey: ['contact-person-contact-types', currentLocaleCode],
371
- queryFn: async () => {
372
- const response = await request<{ data: ContactTypeOption[] }>({
373
- url: '/person-contact-type?pageSize=100',
374
- method: 'GET',
375
- });
376
-
377
- return response.data.data || [];
378
- },
379
- placeholderData: (previous) => previous ?? [],
380
- });
381
-
382
- const { data: documentTypes = [] } = useQuery<DocumentTypeOption[]>({
383
- queryKey: ['contact-person-document-types', currentLocaleCode],
384
- queryFn: async () => {
385
- const response = await request<{ data: DocumentTypeOption[] }>({
386
- url: '/person-document-type?pageSize=100',
387
- method: 'GET',
388
- });
389
-
390
- return response.data.data || [];
391
- },
392
- placeholderData: (previous) => previous ?? [],
393
- });
394
-
395
- const {
396
- data: paginate = { data: [], total: 0, page: 1, pageSize: 12 },
397
- isLoading,
398
- refetch,
399
- } = useQuery<PaginatedResult<Person>>({
400
- queryKey: [
401
- 'contact-persons',
402
- page,
403
- pageSize,
404
- debouncedSearch,
405
- typeFilter,
406
- statusFilter,
407
- currentLocaleCode,
408
- ],
409
- queryFn: async () => {
410
- const params = new URLSearchParams();
411
- params.set('page', String(page));
412
- params.set('pageSize', String(pageSize));
413
- if (debouncedSearch) params.set('search', debouncedSearch);
414
- if (allowCompanyRegistration && typeFilter !== 'all') {
415
- params.set('type', typeFilter);
416
- }
417
- if (statusFilter !== 'all') params.set('status', statusFilter);
418
-
419
- const response = await request<PaginatedResult<Person>>({
420
- url: `/person?${params.toString()}`,
421
- method: 'GET',
422
- });
423
-
424
- return response.data;
425
- },
426
- placeholderData: (previous) =>
427
- previous ?? { data: [], total: 0, page: 1, pageSize: 12 },
428
- });
429
-
430
- const pageCount = Math.max(1, Math.ceil((paginate.total || 0) / pageSize));
431
-
432
- useEffect(() => {
433
- if (page > pageCount) {
434
- setPage(pageCount);
435
- }
436
- }, [page, pageCount]);
437
-
438
- const openCreateSheet = () => {
439
- setPersonToEdit(null);
440
- setFormSheetOpen(true);
441
- };
442
-
443
- const openEditSheet = (person: Person) => {
444
- setPersonToEdit(person);
445
- setFormSheetOpen(true);
446
- };
447
-
448
- const handleDelete = async () => {
449
- if (!personToDelete) return;
450
-
451
- try {
452
- setIsDeleting(true);
453
- await request({
454
- url: '/person',
455
- method: 'DELETE',
456
- data: { ids: [personToDelete.id] },
457
- });
458
- toast.success(t('deleteSuccess'));
459
- setDeleteDialogOpen(false);
460
- setPersonToDelete(null);
461
- await refetch();
462
- } catch (error: unknown) {
463
- const message =
464
- typeof error === 'object' && error && 'message' in error
465
- ? (error as RequestError).message
466
- : null;
467
- toast.error(message || t('deleteError'));
468
- } finally {
469
- setIsDeleting(false);
470
- }
471
- };
472
-
473
- const handleViewModeChange = (value: string) => {
474
- if (value !== 'table' && value !== 'cards') {
475
- return;
476
- }
477
-
478
- setViewMode(value);
479
-
480
- try {
481
- window.localStorage.setItem(PERSON_VIEW_STORAGE_KEY, value);
482
- } catch {
483
- // Ignore storage write failures and keep the in-memory selection.
484
- }
485
- };
486
-
487
- const columns = useMemo<ColumnDef<Person>[]>(
488
- () => {
489
- const baseColumns: ColumnDef<Person>[] = [
490
- {
491
- accessorKey: 'name',
492
- header: ({ column }) => (
493
- <Button
494
- variant="ghost"
495
- onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
496
- className="-ml-4"
497
- >
498
- {t('columnName')}
499
- <ArrowUpDown className="ml-2 h-4 w-4" />
500
- </Button>
501
- ),
502
- cell: ({ row }) => {
503
- const person = row.original;
504
- const primaryDisplay = getPrimaryDisplay(person, contactTypes);
505
-
506
- return (
507
- <div className="flex items-center gap-3">
508
- <Avatar className="h-9 w-9">
509
- <AvatarImage src={getPersonAvatarUrl(person.avatar_id)} />
510
- <AvatarFallback className="bg-muted">
511
- {allowCompanyRegistration && person.type === 'company' ? (
512
- <Building2 className="h-4 w-4 text-muted-foreground" />
513
- ) : (
514
- <span className="text-xs font-semibold uppercase text-muted-foreground">
515
- {getPersonInitials(person.name) || 'NA'}
516
- </span>
517
- )}
518
- </AvatarFallback>
519
- </Avatar>
520
- <div className="min-w-0">
521
- <div className="truncate font-medium">{person.name}</div>
522
- {primaryDisplay ? (
523
- <div className="flex items-center gap-1 text-xs text-muted-foreground">
524
- {primaryDisplay.icon}
525
- <span className="truncate">{primaryDisplay.value}</span>
526
- </div>
527
- ) : null}
528
- </div>
529
- </div>
530
- );
531
- },
532
- },
533
- {
534
- accessorKey: 'status',
535
- header: t('columnStatus'),
536
- cell: ({ row }) => {
537
- const status = row.getValue('status') as Person['status'];
538
-
539
- return (
540
- <Badge
541
- variant="outline"
542
- className={cn(
543
- 'border px-2.5 py-1 text-xs font-medium',
544
- status === 'active'
545
- ? 'border-green-500/20 bg-green-500/10 text-green-600'
546
- : 'border-red-500/20 bg-red-500/10 text-red-600'
547
- )}
548
- >
549
- {status === 'active' ? (
550
- <>
551
- <CheckCircle2 className="mr-1 h-3 w-3" />
552
- {t('active')}
553
- </>
554
- ) : (
555
- <>
556
- <XCircle className="mr-1 h-3 w-3" />
557
- {t('inactive')}
558
- </>
559
- )}
560
- </Badge>
561
- );
562
- },
563
- },
564
- {
565
- accessorKey: 'created_at',
566
- header: ({ column }) => (
567
- <Button
568
- variant="ghost"
569
- onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
570
- className="-ml-4"
571
- >
572
- {t('createdAt')}
573
- <ArrowUpDown className="ml-2 h-4 w-4" />
574
- </Button>
575
- ),
576
- cell: ({ row }) => (
577
- <span className="text-sm text-muted-foreground">
578
- {formatDate(
579
- row.getValue('created_at') as string,
580
- getSettingValue,
581
- currentLocaleCode
582
- )}
583
- </span>
584
- ),
585
- },
586
- {
587
- id: 'actions',
588
- cell: ({ row }) => {
589
- const person = row.original;
590
-
591
- return (
592
- <DropdownMenu>
593
- <DropdownMenuTrigger asChild>
594
- <Button variant="ghost" size="icon" className="h-8 w-8">
595
- <MoreHorizontal className="h-4 w-4" />
596
- </Button>
597
- </DropdownMenuTrigger>
598
- <DropdownMenuContent align="end">
599
- <DropdownMenuItem onClick={() => openEditSheet(person)}>
600
- <Pencil className="mr-2 h-4 w-4" />
601
- {t('buttonEditUser')}
602
- </DropdownMenuItem>
603
- <DropdownMenuSeparator />
604
- <DropdownMenuItem
605
- className="text-red-600"
606
- onClick={() => {
607
- setPersonToDelete(person);
608
- setDeleteDialogOpen(true);
609
- }}
610
- >
611
- <Trash2 className="mr-2 h-4 w-4" />
612
- {t('delete')}
613
- </DropdownMenuItem>
614
- </DropdownMenuContent>
615
- </DropdownMenu>
616
- );
617
- },
618
- },
619
- ];
620
-
621
- if (allowCompanyRegistration) {
622
- baseColumns.splice(1, 0, {
623
- accessorKey: 'type',
624
- header: t('columnType'),
625
- cell: ({ row }) => {
626
- const type = row.getValue('type') as Person['type'];
627
-
628
- return (
629
- <Badge
630
- variant="outline"
631
- className={cn(
632
- 'border px-2.5 py-1 text-xs font-medium',
633
- type === 'individual'
634
- ? 'border-blue-500/20 bg-blue-500/10 text-blue-600'
635
- : 'border-amber-500/20 bg-amber-500/10 text-amber-600'
636
- )}
637
- >
638
- {type === 'individual' ? (
639
- <>
640
- <User className="mr-1 h-3 w-3" />
641
- {t('individual')}
642
- </>
643
- ) : (
644
- <>
645
- <Building2 className="mr-1 h-3 w-3" />
646
- {t('company')}
647
- </>
648
- )}
649
- </Badge>
650
- );
651
- },
652
- });
653
- }
654
-
655
- return baseColumns;
656
- },
657
- [
658
- allowCompanyRegistration,
659
- contactTypes,
660
- currentLocaleCode,
661
- getSettingValue,
662
- t,
663
- ]
664
- );
665
-
666
- const table = useReactTable({
667
- data: paginate.data || [],
668
- columns,
669
- state: { sorting },
670
- onSortingChange: setSorting,
671
- getCoreRowModel: getCoreRowModel(),
672
- getSortedRowModel: getSortedRowModel(),
673
- });
674
-
675
- const peopleRows = table.getRowModel().rows;
676
-
677
- return (
678
- <Page>
679
- <PageHeader
680
- breadcrumbs={[
681
- { label: 'Home', href: '/' },
682
- { label: allowCompanyRegistration ? t('title') : t('titleIndividualOnly') },
683
- ]}
684
- title={allowCompanyRegistration ? t('title') : t('titleIndividualOnly')}
685
- description={
686
- allowCompanyRegistration
687
- ? t('heroDescription')
688
- : t('heroDescriptionIndividualOnly')
689
- }
690
- actions={[
691
- {
692
- label: t('newPerson'),
693
- onClick: openCreateSheet,
694
- icon: <Plus className="h-4 w-4" />,
695
- },
696
- ]}
697
- />
698
-
699
- <div className="flex flex-col gap-4 xl:flex-row xl:items-center">
700
- <div className="relative flex-1">
701
- <Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
702
- <Input
703
- value={searchInput}
704
- onChange={(event) => {
705
- setSearchInput(event.target.value);
706
- setPage(1);
707
- }}
708
- placeholder={t('searchByNamePlaceholder')}
709
- className="pl-9"
710
- />
711
- </div>
712
-
713
- <div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap xl:justify-end">
714
- {allowCompanyRegistration ? (
715
- <Select
716
- value={typeFilter}
717
- onValueChange={(value) => {
718
- setTypeFilter(value);
719
- setPage(1);
720
- }}
721
- >
722
- <SelectTrigger className="w-full sm:w-44">
723
- <SelectValue placeholder={t('filterByType')} />
724
- </SelectTrigger>
725
- <SelectContent>
726
- <SelectItem value="all">{t('allTypes')}</SelectItem>
727
- <SelectItem value="individual">{t('individual')}</SelectItem>
728
- <SelectItem value="company">{t('company')}</SelectItem>
729
- </SelectContent>
730
- </Select>
731
- ) : null}
732
-
733
- <Select
734
- value={statusFilter}
735
- onValueChange={(value) => {
736
- setStatusFilter(value);
737
- setPage(1);
738
- }}
739
- >
740
- <SelectTrigger className="w-full sm:w-36">
741
- <SelectValue placeholder={t('filterByStatus')} />
742
- </SelectTrigger>
743
- <SelectContent>
744
- <SelectItem value="all">{t('allStatuses')}</SelectItem>
745
- <SelectItem value="active">{t('active')}</SelectItem>
746
- <SelectItem value="inactive">{t('inactive')}</SelectItem>
747
- </SelectContent>
748
- </Select>
749
-
750
- <div className="flex items-center justify-between gap-3 sm:justify-start">
751
- <span className="text-xs font-medium text-muted-foreground">
752
- {t('viewMode')}
753
- </span>
754
- <ToggleGroup
755
- type="single"
756
- value={viewMode}
757
- onValueChange={handleViewModeChange}
758
- variant="outline"
759
- size="sm"
760
- aria-label={t('viewMode')}
761
- >
762
- <ToggleGroupItem
763
- value="table"
764
- className="gap-1.5 px-2.5"
765
- aria-label={t('viewModeTable')}
766
- >
767
- <List className="h-4 w-4" />
768
- <span className="hidden sm:inline">{t('viewModeTable')}</span>
769
- </ToggleGroupItem>
770
- <ToggleGroupItem
771
- value="cards"
772
- className="gap-1.5 px-2.5"
773
- aria-label={t('viewModeCards')}
774
- >
775
- <LayoutGrid className="h-4 w-4" />
776
- <span className="hidden sm:inline">{t('viewModeCards')}</span>
777
- </ToggleGroupItem>
778
- </ToggleGroup>
779
- </div>
780
- </div>
781
- </div>
782
-
783
- {isLoading ? (
784
- viewMode === 'cards' ? (
785
- <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
786
- {Array.from({ length: 6 }).map((_, index) => (
787
- <Card key={index} className="overflow-hidden py-0">
788
- <CardContent className="space-y-3 p-4">
789
- <div className="flex items-center gap-2.5">
790
- <Skeleton className="h-10 w-10 rounded-xl" />
791
- <div className="flex-1 space-y-2">
792
- <Skeleton className="h-3 w-20" />
793
- <Skeleton className="h-4 w-2/3" />
794
- <Skeleton className="h-3 w-1/2" />
795
- </div>
796
- </div>
797
- <div className="grid gap-3 sm:grid-cols-2">
798
- {Array.from({ length: 4 }).map((__, detailIndex) => (
799
- <Skeleton key={detailIndex} className="h-20 rounded-xl" />
800
- ))}
801
- </div>
802
- <Skeleton className="h-10 rounded-xl" />
803
- </CardContent>
804
- </Card>
805
- ))}
806
- </div>
807
- ) : (
808
- <div className="space-y-3 p-4">
809
- {Array.from({ length: 5 }).map((_, index) => (
810
- <Skeleton key={index} className="h-14 w-full" />
811
- ))}
812
- </div>
813
- )
814
- ) : paginate.data.length === 0 ? (
815
- <div className="flex flex-col items-center justify-center py-16 text-center">
816
- <Users className="mb-4 h-12 w-12 text-muted-foreground/50" />
817
- <h3 className="text-lg font-medium">{t('emptyStateTitle')}</h3>
818
- <p className="mb-4 max-w-sm text-sm text-muted-foreground">
819
- {t('emptyStateDescription')}
820
- </p>
821
- <Button onClick={openCreateSheet}>
822
- <Plus className="mr-2 h-4 w-4" />
823
- {t('newPerson')}
824
- </Button>
825
- </div>
826
- ) : (
827
- <>
828
- {viewMode === 'table' ? (
829
- <div className="overflow-x-auto">
830
- <Table>
831
- <TableHeader>
832
- {table.getHeaderGroups().map((headerGroup) => (
833
- <TableRow key={headerGroup.id}>
834
- {headerGroup.headers.map((header) => (
835
- <TableHead key={header.id}>
836
- {header.isPlaceholder
837
- ? null
838
- : flexRender(
839
- header.column.columnDef.header,
840
- header.getContext()
841
- )}
842
- </TableHead>
843
- ))}
844
- </TableRow>
845
- ))}
846
- </TableHeader>
847
- <TableBody>
848
- {peopleRows.map((row) => (
849
- <TableRow
850
- key={row.id}
851
- className="cursor-pointer"
852
- onDoubleClick={() => openEditSheet(row.original)}
853
- >
854
- {row.getVisibleCells().map((cell) => (
855
- <TableCell key={cell.id}>
856
- {flexRender(
857
- cell.column.columnDef.cell,
858
- cell.getContext()
859
- )}
860
- </TableCell>
861
- ))}
862
- </TableRow>
863
- ))}
864
- </TableBody>
865
- </Table>
866
- </div>
867
- ) : (
868
- <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
869
- {peopleRows.map((row) => {
870
- const person = row.original;
871
- const primaryEmail = getPrimaryContact(
872
- person,
873
- ['EMAIL'],
874
- contactTypes
875
- );
876
- const primaryPhone = getPrimaryContact(
877
- person,
878
- ['PHONE', 'MOBILE'],
879
- contactTypes
880
- );
881
- const primaryDocument = getPrimaryDocument(
882
- person,
883
- documentTypes
884
- );
885
- const primaryAddress = getPrimaryAddress(person);
886
- const secondaryLabel = getPersonSecondaryLabel(
887
- person,
888
- allowCompanyRegistration
889
- );
890
-
891
- return (
892
- <Card
893
- key={person.id}
894
- className={cn(
895
- 'group h-full overflow-hidden border-border/70 py-0 transition-colors hover:border-border hover:shadow-md'
896
- )}
897
- onDoubleClick={() => openEditSheet(person)}
898
- >
899
- <CardContent className="flex h-full flex-col gap-3 p-4">
900
- <div className="flex items-start justify-between gap-3">
901
- <div className="flex min-w-0 items-start gap-2.5">
902
- <Avatar
903
- className={cn(
904
- 'h-10 w-10 shrink-0 rounded-xl border',
905
- allowCompanyRegistration
906
- ? person.type === 'individual'
907
- ? 'border-sky-500/20'
908
- : 'border-amber-500/20'
909
- : 'border-sky-500/20'
910
- )}
911
- >
912
- <AvatarImage src={getPersonAvatarUrl(person.avatar_id)} />
913
- <AvatarFallback
914
- className={cn(
915
- 'rounded-xl text-sm font-semibold uppercase',
916
- allowCompanyRegistration
917
- ? person.type === 'individual'
918
- ? 'bg-sky-500/8 text-sky-700 dark:text-sky-200'
919
- : 'bg-amber-500/8 text-amber-700 dark:text-amber-200'
920
- : 'bg-sky-500/8 text-sky-700 dark:text-sky-200'
921
- )}
922
- >
923
- {allowCompanyRegistration && person.type === 'company' ? (
924
- <Building2 className="h-4 w-4" />
925
- ) : (
926
- getPersonInitials(person.name) || 'NA'
927
- )}
928
- </AvatarFallback>
929
- </Avatar>
930
-
931
- <div className="min-w-0 space-y-1.5">
932
- <div className="flex flex-wrap gap-1.5">
933
- {allowCompanyRegistration ? (
934
- <Badge
935
- variant="outline"
936
- className={cn(
937
- 'border px-2 py-0.5 text-[11px] font-medium',
938
- person.type === 'individual'
939
- ? 'border-blue-500/20 bg-blue-500/10 text-blue-600'
940
- : 'border-amber-500/20 bg-amber-500/10 text-amber-600'
941
- )}
942
- >
943
- {person.type === 'individual' ? (
944
- <>
945
- <User className="mr-1 h-3 w-3" />
946
- {t('individual')}
947
- </>
948
- ) : (
949
- <>
950
- <Building2 className="mr-1 h-3 w-3" />
951
- {t('company')}
952
- </>
953
- )}
954
- </Badge>
955
- ) : null}
956
-
957
- <Badge
958
- variant="outline"
959
- className={cn(
960
- 'border px-2 py-0.5 text-[11px] font-medium',
961
- person.status === 'active'
962
- ? 'border-green-500/20 bg-green-500/10 text-green-600'
963
- : 'border-red-500/20 bg-red-500/10 text-red-600'
964
- )}
965
- >
966
- {person.status === 'active' ? (
967
- <>
968
- <CheckCircle2 className="mr-1 h-3 w-3" />
969
- {t('active')}
970
- </>
971
- ) : (
972
- <>
973
- <XCircle className="mr-1 h-3 w-3" />
974
- {t('inactive')}
975
- </>
976
- )}
977
- </Badge>
978
- </div>
979
-
980
- <div className="min-w-0">
981
- <h3 className="line-clamp-2 text-sm font-semibold text-foreground">
982
- {person.name}
983
- </h3>
984
- {secondaryLabel ? (
985
- <p className="truncate text-xs text-muted-foreground">
986
- {secondaryLabel}
987
- </p>
988
- ) : (
989
- <p className="flex items-center gap-1.5 text-xs text-muted-foreground">
990
- <span className="inline-flex h-5 w-5 items-center justify-center rounded-full border bg-background text-[9px] font-semibold uppercase text-foreground">
991
- {getPersonInitials(person.name) || 'NA'}
992
- </span>
993
- {t('personDetails')}
994
- </p>
995
- )}
996
- </div>
997
- </div>
998
- </div>
999
-
1000
- <DropdownMenu>
1001
- <DropdownMenuTrigger asChild>
1002
- <Button
1003
- variant="ghost"
1004
- size="icon"
1005
- className="h-8 w-8 rounded-lg"
1006
- >
1007
- <MoreHorizontal className="h-4 w-4" />
1008
- </Button>
1009
- </DropdownMenuTrigger>
1010
- <DropdownMenuContent align="end">
1011
- <DropdownMenuItem
1012
- onClick={() => openEditSheet(person)}
1013
- >
1014
- <Pencil className="mr-2 h-4 w-4" />
1015
- {t('buttonEditUser')}
1016
- </DropdownMenuItem>
1017
- <DropdownMenuSeparator />
1018
- <DropdownMenuItem
1019
- className="text-red-600"
1020
- onClick={() => {
1021
- setPersonToDelete(person);
1022
- setDeleteDialogOpen(true);
1023
- }}
1024
- >
1025
- <Trash2 className="mr-2 h-4 w-4" />
1026
- {t('delete')}
1027
- </DropdownMenuItem>
1028
- </DropdownMenuContent>
1029
- </DropdownMenu>
1030
- </div>
1031
-
1032
- <div className="grid gap-2 sm:grid-cols-2">
1033
- <PersonInfoTile
1034
- icon={<Mail className="h-4 w-4" />}
1035
- label={t('columnEmail')}
1036
- value={primaryEmail}
1037
- copyValue={primaryEmail}
1038
- />
1039
- <PersonInfoTile
1040
- icon={<Phone className="h-4 w-4" />}
1041
- label={t('columnPhone')}
1042
- value={primaryPhone}
1043
- copyValue={primaryPhone}
1044
- />
1045
- <PersonInfoTile
1046
- icon={<FileText className="h-4 w-4" />}
1047
- label={primaryDocument?.label || t('documentValue')}
1048
- value={primaryDocument?.value || '-'}
1049
- copyValue={primaryDocument?.value}
1050
- />
1051
- <PersonInfoTile
1052
- icon={<MapPin className="h-4 w-4" />}
1053
- label={t('address')}
1054
- value={
1055
- primaryAddress
1056
- ? getAddressSummary(primaryAddress)
1057
- : '-'
1058
- }
1059
- description={primaryAddress?.line1}
1060
- copyValue={
1061
- primaryAddress
1062
- ? getAddressCopyValue(primaryAddress)
1063
- : null
1064
- }
1065
- />
1066
- </div>
1067
-
1068
- <div className="mt-auto flex items-center justify-between gap-2 rounded-xl border border-border/60 bg-muted/30 px-2.5 py-2">
1069
- <div className="min-w-0">
1070
- <div className="flex items-center gap-1.5 text-[10px] font-medium tracking-[0.14em] text-muted-foreground uppercase">
1071
- <CalendarDays className="h-3.5 w-3.5 text-muted-foreground" />
1072
- {t('createdAt')}
1073
- </div>
1074
- <div className="truncate text-xs font-medium text-foreground">
1075
- {formatDate(
1076
- person.created_at,
1077
- getSettingValue,
1078
- currentLocaleCode
1079
- )}
1080
- </div>
1081
- </div>
1082
-
1083
- <Button
1084
- variant="outline"
1085
- size="sm"
1086
- onClick={() => openEditSheet(person)}
1087
- className="h-8 shrink-0 rounded-lg px-2.5 text-xs"
1088
- >
1089
- <Pencil className="mr-2 h-4 w-4" />
1090
- {t('buttonEditUser')}
1091
- </Button>
1092
- </div>
1093
- </CardContent>
1094
- </Card>
1095
- );
1096
- })}
1097
- </div>
1098
- )}
1099
-
1100
- <div className="flex flex-col gap-4 border-t p-4 lg:flex-row lg:items-center lg:justify-between">
1101
- <div className="text-sm text-muted-foreground">
1102
- {t('resultsCount', { count: paginate.total })}
1103
- </div>
1104
-
1105
- <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
1106
- <div className="flex items-center gap-2">
1107
- <span className="text-sm text-muted-foreground">
1108
- {t('rowsPerPage')}
1109
- </span>
1110
- <Select
1111
- value={String(pageSize)}
1112
- onValueChange={(value) => {
1113
- setPageSize(Number(value));
1114
- setPage(1);
1115
- }}
1116
- >
1117
- <SelectTrigger className="w-20">
1118
- <SelectValue />
1119
- </SelectTrigger>
1120
- <SelectContent>
1121
- {[12, 20, 30, 40, 50].map((size) => (
1122
- <SelectItem key={size} value={String(size)}>
1123
- {size}
1124
- </SelectItem>
1125
- ))}
1126
- </SelectContent>
1127
- </Select>
1128
- </div>
1129
-
1130
- <div className="flex items-center gap-2">
1131
- <Button
1132
- variant="outline"
1133
- size="icon"
1134
- onClick={() => setPage(1)}
1135
- disabled={page <= 1}
1136
- >
1137
- <ChevronsLeft className="h-4 w-4" />
1138
- </Button>
1139
- <Button
1140
- variant="outline"
1141
- size="icon"
1142
- onClick={() =>
1143
- setPage((previous) => Math.max(1, previous - 1))
1144
- }
1145
- disabled={page <= 1}
1146
- >
1147
- <ChevronLeft className="h-4 w-4" />
1148
- </Button>
1149
- <span className="min-w-32 text-center text-sm">
1150
- {t('paginationLabel', {
1151
- current: page,
1152
- total: pageCount,
1153
- })}
1154
- </span>
1155
- <Button
1156
- variant="outline"
1157
- size="icon"
1158
- onClick={() =>
1159
- setPage((previous) => Math.min(pageCount, previous + 1))
1160
- }
1161
- disabled={page >= pageCount}
1162
- >
1163
- <ChevronRight className="h-4 w-4" />
1164
- </Button>
1165
- <Button
1166
- variant="outline"
1167
- size="icon"
1168
- onClick={() => setPage(pageCount)}
1169
- disabled={page >= pageCount}
1170
- >
1171
- <ChevronsRight className="h-4 w-4" />
1172
- </Button>
1173
- </div>
1174
- </div>
1175
- </div>
1176
- </>
1177
- )}
1178
-
1179
- <DeletePersonDialog
1180
- open={deleteDialogOpen}
1181
- personName={personToDelete?.name}
1182
- isDeleting={isDeleting}
1183
- onOpenChange={(open) => {
1184
- setDeleteDialogOpen(open);
1185
- if (!open) {
1186
- setPersonToDelete(null);
1187
- }
1188
- }}
1189
- onConfirm={handleDelete}
1190
- />
1191
-
1192
- <PersonFormSheet
1193
- open={formSheetOpen}
1194
- person={personToEdit}
1195
- contactTypes={contactTypes}
1196
- documentTypes={documentTypes}
1197
- onOpenChange={(open) => {
1198
- setFormSheetOpen(open);
1199
- if (!open) {
1200
- setPersonToEdit(null);
1201
- }
1202
- }}
1203
- onSuccess={() => {
1204
- void refetch();
1205
- }}
1206
- />
1207
- </Page>
1208
- );
1209
- }
1
+ 'use client';
2
+
3
+ import { CopyButton } from '@/components/copy-button';
4
+ import { Page, PageHeader } from '@/components/entity-list';
5
+ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
6
+ import { Badge } from '@/components/ui/badge';
7
+ import { Button } from '@/components/ui/button';
8
+ import { Card, CardContent } from '@/components/ui/card';
9
+ import {
10
+ DropdownMenu,
11
+ DropdownMenuContent,
12
+ DropdownMenuItem,
13
+ DropdownMenuSeparator,
14
+ DropdownMenuTrigger,
15
+ } from '@/components/ui/dropdown-menu';
16
+ import { Input } from '@/components/ui/input';
17
+ import {
18
+ Select,
19
+ SelectContent,
20
+ SelectItem,
21
+ SelectTrigger,
22
+ SelectValue,
23
+ } from '@/components/ui/select';
24
+ import { Skeleton } from '@/components/ui/skeleton';
25
+ import {
26
+ Table,
27
+ TableBody,
28
+ TableCell,
29
+ TableHead,
30
+ TableHeader,
31
+ TableRow,
32
+ } from '@/components/ui/table';
33
+ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
34
+ import { formatDate } from '@/lib/format-date';
35
+ import { cn } from '@/lib/utils';
36
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
37
+ import {
38
+ type ColumnDef,
39
+ type SortingState,
40
+ flexRender,
41
+ getCoreRowModel,
42
+ getSortedRowModel,
43
+ useReactTable,
44
+ } from '@tanstack/react-table';
45
+ import {
46
+ ArrowUpDown,
47
+ Building2,
48
+ CalendarDays,
49
+ CheckCircle2,
50
+ ChevronLeft,
51
+ ChevronRight,
52
+ ChevronsLeft,
53
+ ChevronsRight,
54
+ FileText,
55
+ LayoutGrid,
56
+ List,
57
+ Mail,
58
+ MapPin,
59
+ MoreHorizontal,
60
+ Pencil,
61
+ Phone,
62
+ Plus,
63
+ Search,
64
+ Trash2,
65
+ User,
66
+ UserCheck,
67
+ Users,
68
+ XCircle,
69
+ } from 'lucide-react';
70
+ import { useTranslations } from 'next-intl';
71
+ import { type ReactNode, useEffect, useMemo, useState } from 'react';
72
+ import { toast } from 'sonner';
73
+
74
+ import { DeletePersonDialog } from './_components/delete-person-dialog';
75
+ import { PersonFormSheet } from './_components/person-form-sheet';
76
+ import { PersonInteractionDialog } from './_components/person-interaction-dialog';
77
+ import type {
78
+ ContactTypeOption,
79
+ DocumentTypeOption,
80
+ PaginatedResult,
81
+ Person,
82
+ PersonStats,
83
+ } from './_components/person-types';
84
+
85
+ type RequestError = {
86
+ message?: string;
87
+ };
88
+
89
+ const PERSON_VIEW_STORAGE_KEY = 'contact-person-view-mode';
90
+
91
+ type PersonViewMode = 'table' | 'cards';
92
+
93
+ function formatPhone(value: string) {
94
+ const digits = value.replace(/\D/g, '');
95
+ if (digits.length === 11) {
96
+ return `(${digits.slice(0, 2)}) ${digits.slice(2, 7)}-${digits.slice(7)}`;
97
+ }
98
+
99
+ if (digits.length === 10) {
100
+ return `(${digits.slice(0, 2)}) ${digits.slice(2, 6)}-${digits.slice(6)}`;
101
+ }
102
+
103
+ return value;
104
+ }
105
+
106
+ function getPrimaryContact(
107
+ person: Person,
108
+ codes: string[],
109
+ availableContactTypes: ContactTypeOption[]
110
+ ) {
111
+ const upperCodes = codes.map((code) => code.toUpperCase());
112
+ const contactTypeIds = availableContactTypes
113
+ .filter((contactType) =>
114
+ upperCodes.includes(String(contactType.code).toUpperCase())
115
+ )
116
+ .map((contactType) => contactType.contact_type_id);
117
+
118
+ const contactMatches =
119
+ person.contact?.filter((contact) => {
120
+ const nestedCode = String(contact.contact_type?.code || '').toUpperCase();
121
+ return (
122
+ contactTypeIds.includes(contact.contact_type_id) ||
123
+ upperCodes.includes(nestedCode)
124
+ );
125
+ }) ?? [];
126
+
127
+ const exactMatch =
128
+ contactMatches.find((contact) => {
129
+ const nestedCode = String(contact.contact_type?.code || '').toUpperCase();
130
+ return (
131
+ (contactTypeIds.includes(contact.contact_type_id) ||
132
+ upperCodes.includes(nestedCode)) &&
133
+ contact.is_primary
134
+ );
135
+ }) ?? contactMatches[0];
136
+
137
+ if (!exactMatch?.value) {
138
+ return '-';
139
+ }
140
+
141
+ return upperCodes.some((code) => ['PHONE', 'MOBILE'].includes(code))
142
+ ? formatPhone(exactMatch.value)
143
+ : exactMatch.value;
144
+ }
145
+
146
+ function getPrimaryDisplay(
147
+ person: Person,
148
+ availableContactTypes: ContactTypeOption[]
149
+ ) {
150
+ const email = getPrimaryContact(person, ['EMAIL'], availableContactTypes);
151
+ if (email !== '-') {
152
+ return { icon: <Mail className="h-3 w-3" />, value: email };
153
+ }
154
+
155
+ const phone = getPrimaryContact(
156
+ person,
157
+ ['PHONE', 'MOBILE'],
158
+ availableContactTypes
159
+ );
160
+ if (phone !== '-') {
161
+ return { icon: <Phone className="h-3 w-3" />, value: phone };
162
+ }
163
+
164
+ return null;
165
+ }
166
+
167
+ function getPrimaryDocument(
168
+ person: Person,
169
+ availableDocumentTypes: DocumentTypeOption[]
170
+ ) {
171
+ const documents = person.document ?? [];
172
+ if (documents.length === 0) {
173
+ return null;
174
+ }
175
+
176
+ const preferredCodes = ['CPF', 'CNPJ', 'RG', 'IE'];
177
+ const primaryDocument =
178
+ documents.find((document) =>
179
+ preferredCodes.includes(
180
+ String(document.document_type?.code || '').toUpperCase()
181
+ )
182
+ ) ?? documents[0];
183
+
184
+ if (!primaryDocument?.value) {
185
+ return null;
186
+ }
187
+
188
+ const documentType = availableDocumentTypes.find(
189
+ (option) =>
190
+ option.document_type_id === primaryDocument.document_type_id ||
191
+ String(option.code).toUpperCase() ===
192
+ String(primaryDocument.document_type?.code || '').toUpperCase()
193
+ );
194
+
195
+ return {
196
+ label: documentType?.name || primaryDocument.document_type?.code || 'ID',
197
+ value: primaryDocument.value,
198
+ };
199
+ }
200
+
201
+ function getPrimaryAddress(person: Person) {
202
+ const addresses = person.address ?? [];
203
+ return (
204
+ addresses.find((address) => address.is_primary) ?? addresses[0] ?? null
205
+ );
206
+ }
207
+
208
+ function getAddressSummary(address: NonNullable<Person['address']>[number]) {
209
+ const cityState = [address.city, address.state].filter(Boolean).join(' - ');
210
+ return cityState || address.line1 || '-';
211
+ }
212
+
213
+ function getAddressCopyValue(address: NonNullable<Person['address']>[number]) {
214
+ return [
215
+ address.line1,
216
+ address.line2,
217
+ [address.city, address.state].filter(Boolean).join(' - '),
218
+ address.postal_code,
219
+ address.country_code,
220
+ ]
221
+ .filter(Boolean)
222
+ .join(', ');
223
+ }
224
+
225
+ function getPersonSecondaryLabel(
226
+ person: Person,
227
+ allowCompanyRegistration: boolean
228
+ ) {
229
+ if (!allowCompanyRegistration) {
230
+ return person.job_title || null;
231
+ }
232
+
233
+ if (person.type === 'company') {
234
+ return person.trade_name || null;
235
+ }
236
+
237
+ if (person.job_title) {
238
+ return person.employer_company?.name
239
+ ? `${person.job_title} · ${person.employer_company.name}`
240
+ : person.job_title;
241
+ }
242
+
243
+ return person.employer_company?.name || null;
244
+ }
245
+
246
+ function getPersonInitials(name: string) {
247
+ return name
248
+ .split(' ')
249
+ .filter(Boolean)
250
+ .slice(0, 2)
251
+ .map((part) => part[0]?.toUpperCase() || '')
252
+ .join('');
253
+ }
254
+
255
+ function getPersonAvatarUrl(avatarId?: number | null) {
256
+ return typeof avatarId === 'number' && avatarId > 0
257
+ ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${avatarId}`
258
+ : undefined;
259
+ }
260
+
261
+ function PersonInfoTile({
262
+ icon,
263
+ label,
264
+ value,
265
+ description,
266
+ copyValue,
267
+ }: {
268
+ icon: ReactNode;
269
+ label: string;
270
+ value: string;
271
+ description?: string | null;
272
+ copyValue?: string | null;
273
+ }) {
274
+ const hasValue = value.trim() !== '-' && value.trim() !== '';
275
+
276
+ return (
277
+ <div
278
+ className={cn(
279
+ 'rounded-xl border border-border/70 bg-muted/25 p-2.5 transition-colors',
280
+ hasValue
281
+ ? 'text-foreground'
282
+ : 'border-dashed bg-muted/40 text-muted-foreground'
283
+ )}
284
+ >
285
+ <div className="mb-2 flex items-start justify-between gap-2">
286
+ <div className="flex min-w-0 items-center gap-2">
287
+ <div
288
+ className={cn(
289
+ 'flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border bg-background text-muted-foreground'
290
+ )}
291
+ >
292
+ {icon}
293
+ </div>
294
+ <span className="truncate text-[10px] font-semibold tracking-[0.14em] text-muted-foreground uppercase">
295
+ {label}
296
+ </span>
297
+ </div>
298
+
299
+ {hasValue && copyValue ? (
300
+ <CopyButton
301
+ value={copyValue}
302
+ className="h-7 w-7 shrink-0 rounded-lg text-muted-foreground hover:text-foreground"
303
+ />
304
+ ) : null}
305
+ </div>
306
+
307
+ <div
308
+ className={cn(
309
+ 'line-clamp-2 text-sm font-medium',
310
+ hasValue ? 'text-foreground' : 'text-muted-foreground'
311
+ )}
312
+ >
313
+ {hasValue ? value : '-'}
314
+ </div>
315
+
316
+ {hasValue && description ? (
317
+ <div className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
318
+ {description}
319
+ </div>
320
+ ) : null}
321
+ </div>
322
+ );
323
+ }
324
+
325
+ export default function PeoplePage() {
326
+ const t = useTranslations('contact.ContactPage');
327
+ const { request, currentLocaleCode, getSettingValue } = useApp();
328
+ const allowCompanyRegistration =
329
+ getSettingValue('contact-allow-company-registration') !== false;
330
+
331
+ const [sorting, setSorting] = useState<SortingState>([]);
332
+ const [page, setPage] = useState(1);
333
+ const [pageSize, setPageSize] = useState(12);
334
+ const [searchInput, setSearchInput] = useState('');
335
+ const [debouncedSearch, setDebouncedSearch] = useState('');
336
+ const [typeFilter, setTypeFilter] = useState('all');
337
+ const [statusFilter, setStatusFilter] = useState('all');
338
+ const [formSheetOpen, setFormSheetOpen] = useState(false);
339
+ const [personToEdit, setPersonToEdit] = useState<Person | null>(null);
340
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
341
+ const [personToDelete, setPersonToDelete] = useState<Person | null>(null);
342
+ const [isDeleting, setIsDeleting] = useState(false);
343
+ const [viewMode, setViewMode] = useState<PersonViewMode>('table');
344
+ const [interactionDialogOpen, setInteractionDialogOpen] = useState(false);
345
+ const [interactionMode, setInteractionMode] = useState<
346
+ 'interaction' | 'followup'
347
+ >('interaction');
348
+ const [personForInteraction, setPersonForInteraction] =
349
+ useState<Person | null>(null);
350
+
351
+ useEffect(() => {
352
+ const timeout = setTimeout(() => {
353
+ setDebouncedSearch(searchInput.trim());
354
+ }, 300);
355
+
356
+ return () => clearTimeout(timeout);
357
+ }, [searchInput]);
358
+
359
+ useEffect(() => {
360
+ try {
361
+ const savedViewMode = window.localStorage.getItem(
362
+ PERSON_VIEW_STORAGE_KEY
363
+ );
364
+ if (savedViewMode === 'table' || savedViewMode === 'cards') {
365
+ setViewMode(savedViewMode);
366
+ }
367
+ } catch {
368
+ // Ignore storage read failures and keep the default view.
369
+ }
370
+ }, []);
371
+
372
+ useEffect(() => {
373
+ if (!allowCompanyRegistration && typeFilter === 'company') {
374
+ setTypeFilter('all');
375
+ }
376
+ }, [allowCompanyRegistration, typeFilter]);
377
+
378
+ const { data: contactTypes = [] } = useQuery<ContactTypeOption[]>({
379
+ queryKey: ['contact-person-contact-types', currentLocaleCode],
380
+ queryFn: async () => {
381
+ const response = await request<{ data: ContactTypeOption[] }>({
382
+ url: '/person-contact-type?pageSize=100',
383
+ method: 'GET',
384
+ });
385
+
386
+ return response.data.data || [];
387
+ },
388
+ placeholderData: (previous) => previous ?? [],
389
+ });
390
+
391
+ const { data: documentTypes = [] } = useQuery<DocumentTypeOption[]>({
392
+ queryKey: ['contact-person-document-types', currentLocaleCode],
393
+ queryFn: async () => {
394
+ const response = await request<{ data: DocumentTypeOption[] }>({
395
+ url: '/person-document-type?pageSize=100',
396
+ method: 'GET',
397
+ });
398
+
399
+ return response.data.data || [];
400
+ },
401
+ placeholderData: (previous) => previous ?? [],
402
+ });
403
+
404
+ const {
405
+ data: stats = {
406
+ total: 0,
407
+ individual: 0,
408
+ company: 0,
409
+ active: 0,
410
+ inactive: 0,
411
+ },
412
+ } = useQuery<PersonStats>({
413
+ queryKey: ['contact-person-stats', currentLocaleCode],
414
+ queryFn: async () => {
415
+ const response = await request<PersonStats>({
416
+ url: '/person/stats',
417
+ method: 'GET',
418
+ });
419
+ return response.data;
420
+ },
421
+ placeholderData: (previous: PersonStats | undefined) =>
422
+ previous ?? {
423
+ total: 0,
424
+ individual: 0,
425
+ company: 0,
426
+ active: 0,
427
+ inactive: 0,
428
+ },
429
+ });
430
+
431
+ const {
432
+ data: paginate = { data: [], total: 0, page: 1, pageSize: 12 },
433
+ isLoading,
434
+ refetch,
435
+ } = useQuery<PaginatedResult<Person>>({
436
+ queryKey: [
437
+ 'contact-persons',
438
+ page,
439
+ pageSize,
440
+ debouncedSearch,
441
+ typeFilter,
442
+ statusFilter,
443
+ currentLocaleCode,
444
+ ],
445
+ queryFn: async () => {
446
+ const params = new URLSearchParams();
447
+ params.set('page', String(page));
448
+ params.set('pageSize', String(pageSize));
449
+ if (debouncedSearch) params.set('search', debouncedSearch);
450
+ if (allowCompanyRegistration && typeFilter !== 'all') {
451
+ params.set('type', typeFilter);
452
+ }
453
+ if (statusFilter !== 'all') params.set('status', statusFilter);
454
+
455
+ const response = await request<PaginatedResult<Person>>({
456
+ url: `/person?${params.toString()}`,
457
+ method: 'GET',
458
+ });
459
+
460
+ return response.data;
461
+ },
462
+ placeholderData: (previous) =>
463
+ previous ?? { data: [], total: 0, page: 1, pageSize: 12 },
464
+ });
465
+
466
+ const pageCount = Math.max(1, Math.ceil((paginate.total || 0) / pageSize));
467
+
468
+ useEffect(() => {
469
+ if (page > pageCount) {
470
+ setPage(pageCount);
471
+ }
472
+ }, [page, pageCount]);
473
+
474
+ const openCreateSheet = () => {
475
+ setPersonToEdit(null);
476
+ setFormSheetOpen(true);
477
+ };
478
+
479
+ const openEditSheet = (person: Person) => {
480
+ setPersonToEdit(person);
481
+ setFormSheetOpen(true);
482
+ };
483
+
484
+ const openInteractionDialog = (
485
+ person: Person,
486
+ mode: 'interaction' | 'followup'
487
+ ) => {
488
+ setPersonForInteraction(person);
489
+ setInteractionMode(mode);
490
+ setInteractionDialogOpen(true);
491
+ };
492
+
493
+ const handleDelete = async () => {
494
+ if (!personToDelete) return;
495
+
496
+ try {
497
+ setIsDeleting(true);
498
+ await request({
499
+ url: '/person',
500
+ method: 'DELETE',
501
+ data: { ids: [personToDelete.id] },
502
+ });
503
+ toast.success(t('deleteSuccess'));
504
+ setDeleteDialogOpen(false);
505
+ setPersonToDelete(null);
506
+ await refetch();
507
+ } catch (error: unknown) {
508
+ const message =
509
+ typeof error === 'object' && error && 'message' in error
510
+ ? (error as RequestError).message
511
+ : null;
512
+ toast.error(message || t('deleteError'));
513
+ } finally {
514
+ setIsDeleting(false);
515
+ }
516
+ };
517
+
518
+ const handleViewModeChange = (value: string) => {
519
+ if (value !== 'table' && value !== 'cards') {
520
+ return;
521
+ }
522
+
523
+ setViewMode(value);
524
+
525
+ try {
526
+ window.localStorage.setItem(PERSON_VIEW_STORAGE_KEY, value);
527
+ } catch {
528
+ // Ignore storage write failures and keep the in-memory selection.
529
+ }
530
+ };
531
+
532
+ const columns = useMemo<ColumnDef<Person>[]>(() => {
533
+ const baseColumns: ColumnDef<Person>[] = [
534
+ {
535
+ accessorKey: 'name',
536
+ header: ({ column }) => (
537
+ <Button
538
+ variant="ghost"
539
+ onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
540
+ className="-ml-4"
541
+ >
542
+ {t('columnName')}
543
+ <ArrowUpDown className="ml-2 h-4 w-4" />
544
+ </Button>
545
+ ),
546
+ cell: ({ row }) => {
547
+ const person = row.original;
548
+ const primaryDisplay = getPrimaryDisplay(person, contactTypes);
549
+
550
+ return (
551
+ <div className="flex items-center gap-3">
552
+ <Avatar className="h-9 w-9">
553
+ <AvatarImage src={getPersonAvatarUrl(person.avatar_id)} />
554
+ <AvatarFallback className="bg-muted">
555
+ {allowCompanyRegistration && person.type === 'company' ? (
556
+ <Building2 className="h-4 w-4 text-muted-foreground" />
557
+ ) : (
558
+ <span className="text-xs font-semibold uppercase text-muted-foreground">
559
+ {getPersonInitials(person.name) || 'NA'}
560
+ </span>
561
+ )}
562
+ </AvatarFallback>
563
+ </Avatar>
564
+ <div className="min-w-0">
565
+ <div className="truncate font-medium">{person.name}</div>
566
+ {primaryDisplay ? (
567
+ <div className="flex items-center gap-1 text-xs text-muted-foreground">
568
+ {primaryDisplay.icon}
569
+ <span className="truncate">{primaryDisplay.value}</span>
570
+ </div>
571
+ ) : null}
572
+ </div>
573
+ </div>
574
+ );
575
+ },
576
+ },
577
+ {
578
+ accessorKey: 'status',
579
+ header: t('columnStatus'),
580
+ cell: ({ row }) => {
581
+ const status = row.getValue('status') as Person['status'];
582
+
583
+ return (
584
+ <Badge
585
+ variant="outline"
586
+ className={cn(
587
+ 'border px-2.5 py-1 text-xs font-medium',
588
+ status === 'active'
589
+ ? 'border-green-500/20 bg-green-500/10 text-green-600'
590
+ : 'border-red-500/20 bg-red-500/10 text-red-600'
591
+ )}
592
+ >
593
+ {status === 'active' ? (
594
+ <>
595
+ <CheckCircle2 className="mr-1 h-3 w-3" />
596
+ {t('active')}
597
+ </>
598
+ ) : (
599
+ <>
600
+ <XCircle className="mr-1 h-3 w-3" />
601
+ {t('inactive')}
602
+ </>
603
+ )}
604
+ </Badge>
605
+ );
606
+ },
607
+ },
608
+ {
609
+ accessorKey: 'created_at',
610
+ header: ({ column }) => (
611
+ <Button
612
+ variant="ghost"
613
+ onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
614
+ className="-ml-4"
615
+ >
616
+ {t('createdAt')}
617
+ <ArrowUpDown className="ml-2 h-4 w-4" />
618
+ </Button>
619
+ ),
620
+ cell: ({ row }) => (
621
+ <span className="text-sm text-muted-foreground">
622
+ {formatDate(
623
+ row.getValue('created_at') as string,
624
+ getSettingValue,
625
+ currentLocaleCode
626
+ )}
627
+ </span>
628
+ ),
629
+ },
630
+ {
631
+ id: 'actions',
632
+ cell: ({ row }) => {
633
+ const person = row.original;
634
+
635
+ return (
636
+ <DropdownMenu>
637
+ <DropdownMenuTrigger asChild>
638
+ <Button variant="ghost" size="icon" className="h-8 w-8">
639
+ <MoreHorizontal className="h-4 w-4" />
640
+ </Button>
641
+ </DropdownMenuTrigger>
642
+ <DropdownMenuContent align="end">
643
+ <DropdownMenuItem onClick={() => openEditSheet(person)}>
644
+ <Pencil className="mr-2 h-4 w-4" />
645
+ {t('buttonEditUser')}
646
+ </DropdownMenuItem>
647
+ <DropdownMenuSeparator />
648
+ <DropdownMenuItem
649
+ className="text-red-600"
650
+ onClick={() => {
651
+ setPersonToDelete(person);
652
+ setDeleteDialogOpen(true);
653
+ }}
654
+ >
655
+ <Trash2 className="mr-2 h-4 w-4" />
656
+ {t('delete')}
657
+ </DropdownMenuItem>
658
+ </DropdownMenuContent>
659
+ </DropdownMenu>
660
+ );
661
+ },
662
+ },
663
+ ];
664
+
665
+ if (allowCompanyRegistration) {
666
+ baseColumns.splice(1, 0, {
667
+ accessorKey: 'type',
668
+ header: t('columnType'),
669
+ cell: ({ row }) => {
670
+ const type = row.getValue('type') as Person['type'];
671
+
672
+ return (
673
+ <Badge
674
+ variant="outline"
675
+ className={cn(
676
+ 'border px-2.5 py-1 text-xs font-medium',
677
+ type === 'individual'
678
+ ? 'border-blue-500/20 bg-blue-500/10 text-blue-600'
679
+ : 'border-amber-500/20 bg-amber-500/10 text-amber-600'
680
+ )}
681
+ >
682
+ {type === 'individual' ? (
683
+ <>
684
+ <User className="mr-1 h-3 w-3" />
685
+ {t('individual')}
686
+ </>
687
+ ) : (
688
+ <>
689
+ <Building2 className="mr-1 h-3 w-3" />
690
+ {t('company')}
691
+ </>
692
+ )}
693
+ </Badge>
694
+ );
695
+ },
696
+ });
697
+ }
698
+
699
+ return baseColumns;
700
+ }, [
701
+ allowCompanyRegistration,
702
+ contactTypes,
703
+ currentLocaleCode,
704
+ getSettingValue,
705
+ t,
706
+ ]);
707
+
708
+ const table = useReactTable({
709
+ data: paginate.data || [],
710
+ columns,
711
+ state: { sorting },
712
+ onSortingChange: setSorting,
713
+ getCoreRowModel: getCoreRowModel(),
714
+ getSortedRowModel: getSortedRowModel(),
715
+ });
716
+
717
+ const peopleRows = table.getRowModel().rows;
718
+
719
+ return (
720
+ <Page>
721
+ <PageHeader
722
+ breadcrumbs={[
723
+ { label: 'Home', href: '/' },
724
+ {
725
+ label: allowCompanyRegistration
726
+ ? t('title')
727
+ : t('titleIndividualOnly'),
728
+ },
729
+ ]}
730
+ title={allowCompanyRegistration ? t('title') : t('titleIndividualOnly')}
731
+ description={
732
+ allowCompanyRegistration
733
+ ? t('heroDescription')
734
+ : t('heroDescriptionIndividualOnly')
735
+ }
736
+ actions={[
737
+ {
738
+ label: t('newPerson'),
739
+ onClick: openCreateSheet,
740
+ icon: <Plus className="h-4 w-4" />,
741
+ },
742
+ ]}
743
+ />
744
+
745
+ <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
746
+ <Card className="py-0">
747
+ <CardContent className="flex items-center justify-between p-4">
748
+ <div>
749
+ <p className="text-xs text-muted-foreground">{t('statsTotal')}</p>
750
+ <p className="text-xl font-semibold">{stats.total}</p>
751
+ </div>
752
+ <Users className="h-5 w-5 text-muted-foreground" />
753
+ </CardContent>
754
+ </Card>
755
+ <Card className="py-0">
756
+ <CardContent className="flex items-center justify-between p-4">
757
+ <div>
758
+ <p className="text-xs text-muted-foreground">
759
+ {t('statsActive')}
760
+ </p>
761
+ <p className="text-xl font-semibold">{stats.active}</p>
762
+ </div>
763
+ <CheckCircle2 className="h-5 w-5 text-green-600" />
764
+ </CardContent>
765
+ </Card>
766
+ <Card className="py-0">
767
+ <CardContent className="flex items-center justify-between p-4">
768
+ <div>
769
+ <p className="text-xs text-muted-foreground">
770
+ {t('statsIndividual')}
771
+ </p>
772
+ <p className="text-xl font-semibold">{stats.individual}</p>
773
+ </div>
774
+ <UserCheck className="h-5 w-5 text-blue-600" />
775
+ </CardContent>
776
+ </Card>
777
+ <Card className="py-0">
778
+ <CardContent className="flex items-center justify-between p-4">
779
+ <div>
780
+ <p className="text-xs text-muted-foreground">
781
+ {t('statsCompany')}
782
+ </p>
783
+ <p className="text-xl font-semibold">{stats.company}</p>
784
+ </div>
785
+ <Building2 className="h-5 w-5 text-amber-600" />
786
+ </CardContent>
787
+ </Card>
788
+ </div>
789
+
790
+ <div className="flex flex-col gap-4 xl:flex-row xl:items-center">
791
+ <div className="relative flex-1">
792
+ <Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
793
+ <Input
794
+ value={searchInput}
795
+ onChange={(event) => {
796
+ setSearchInput(event.target.value);
797
+ setPage(1);
798
+ }}
799
+ placeholder={t('searchByNamePlaceholder')}
800
+ className="pl-9"
801
+ />
802
+ </div>
803
+
804
+ <div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap xl:justify-end">
805
+ {allowCompanyRegistration ? (
806
+ <Select
807
+ value={typeFilter}
808
+ onValueChange={(value) => {
809
+ setTypeFilter(value);
810
+ setPage(1);
811
+ }}
812
+ >
813
+ <SelectTrigger className="w-full sm:w-44">
814
+ <SelectValue placeholder={t('filterByType')} />
815
+ </SelectTrigger>
816
+ <SelectContent>
817
+ <SelectItem value="all">{t('allTypes')}</SelectItem>
818
+ <SelectItem value="individual">{t('individual')}</SelectItem>
819
+ <SelectItem value="company">{t('company')}</SelectItem>
820
+ </SelectContent>
821
+ </Select>
822
+ ) : null}
823
+
824
+ <Select
825
+ value={statusFilter}
826
+ onValueChange={(value) => {
827
+ setStatusFilter(value);
828
+ setPage(1);
829
+ }}
830
+ >
831
+ <SelectTrigger className="w-full sm:w-36">
832
+ <SelectValue placeholder={t('filterByStatus')} />
833
+ </SelectTrigger>
834
+ <SelectContent>
835
+ <SelectItem value="all">{t('allStatuses')}</SelectItem>
836
+ <SelectItem value="active">{t('active')}</SelectItem>
837
+ <SelectItem value="inactive">{t('inactive')}</SelectItem>
838
+ </SelectContent>
839
+ </Select>
840
+
841
+ <div className="flex items-center justify-between gap-3 sm:justify-start">
842
+ <span className="text-xs font-medium text-muted-foreground">
843
+ {t('viewMode')}
844
+ </span>
845
+ <ToggleGroup
846
+ type="single"
847
+ value={viewMode}
848
+ onValueChange={handleViewModeChange}
849
+ variant="outline"
850
+ size="sm"
851
+ aria-label={t('viewMode')}
852
+ >
853
+ <ToggleGroupItem
854
+ value="table"
855
+ className="gap-1.5 px-2.5"
856
+ aria-label={t('viewModeTable')}
857
+ >
858
+ <List className="h-4 w-4" />
859
+ <span className="hidden sm:inline">{t('viewModeTable')}</span>
860
+ </ToggleGroupItem>
861
+ <ToggleGroupItem
862
+ value="cards"
863
+ className="gap-1.5 px-2.5"
864
+ aria-label={t('viewModeCards')}
865
+ >
866
+ <LayoutGrid className="h-4 w-4" />
867
+ <span className="hidden sm:inline">{t('viewModeCards')}</span>
868
+ </ToggleGroupItem>
869
+ </ToggleGroup>
870
+ </div>
871
+ </div>
872
+ </div>
873
+
874
+ {isLoading ? (
875
+ viewMode === 'cards' ? (
876
+ <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
877
+ {Array.from({ length: 6 }).map((_, index) => (
878
+ <Card key={index} className="overflow-hidden py-0">
879
+ <CardContent className="space-y-3 p-4">
880
+ <div className="flex items-center gap-2.5">
881
+ <Skeleton className="h-10 w-10 rounded-xl" />
882
+ <div className="flex-1 space-y-2">
883
+ <Skeleton className="h-3 w-20" />
884
+ <Skeleton className="h-4 w-2/3" />
885
+ <Skeleton className="h-3 w-1/2" />
886
+ </div>
887
+ </div>
888
+ <div className="grid gap-3 sm:grid-cols-2">
889
+ {Array.from({ length: 4 }).map((__, detailIndex) => (
890
+ <Skeleton key={detailIndex} className="h-20 rounded-xl" />
891
+ ))}
892
+ </div>
893
+ <Skeleton className="h-10 rounded-xl" />
894
+ </CardContent>
895
+ </Card>
896
+ ))}
897
+ </div>
898
+ ) : (
899
+ <div className="space-y-3 p-4">
900
+ {Array.from({ length: 5 }).map((_, index) => (
901
+ <Skeleton key={index} className="h-14 w-full" />
902
+ ))}
903
+ </div>
904
+ )
905
+ ) : paginate.data.length === 0 ? (
906
+ <div className="flex flex-col items-center justify-center py-16 text-center">
907
+ <Users className="mb-4 h-12 w-12 text-muted-foreground/50" />
908
+ <h3 className="text-lg font-medium">{t('emptyStateTitle')}</h3>
909
+ <p className="mb-4 max-w-sm text-sm text-muted-foreground">
910
+ {t('emptyStateDescription')}
911
+ </p>
912
+ <Button onClick={openCreateSheet}>
913
+ <Plus className="mr-2 h-4 w-4" />
914
+ {t('newPerson')}
915
+ </Button>
916
+ </div>
917
+ ) : (
918
+ <>
919
+ {viewMode === 'table' ? (
920
+ <div className="overflow-x-auto">
921
+ <Table>
922
+ <TableHeader>
923
+ {table.getHeaderGroups().map((headerGroup) => (
924
+ <TableRow key={headerGroup.id}>
925
+ {headerGroup.headers.map((header) => (
926
+ <TableHead key={header.id}>
927
+ {header.isPlaceholder
928
+ ? null
929
+ : flexRender(
930
+ header.column.columnDef.header,
931
+ header.getContext()
932
+ )}
933
+ </TableHead>
934
+ ))}
935
+ </TableRow>
936
+ ))}
937
+ </TableHeader>
938
+ <TableBody>
939
+ {peopleRows.map((row) => (
940
+ <TableRow
941
+ key={row.id}
942
+ className="cursor-pointer"
943
+ onDoubleClick={() => openEditSheet(row.original)}
944
+ >
945
+ {row.getVisibleCells().map((cell) => (
946
+ <TableCell key={cell.id}>
947
+ {flexRender(
948
+ cell.column.columnDef.cell,
949
+ cell.getContext()
950
+ )}
951
+ </TableCell>
952
+ ))}
953
+ </TableRow>
954
+ ))}
955
+ </TableBody>
956
+ </Table>
957
+ </div>
958
+ ) : (
959
+ <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
960
+ {peopleRows.map((row) => {
961
+ const person = row.original;
962
+ const primaryEmail = getPrimaryContact(
963
+ person,
964
+ ['EMAIL'],
965
+ contactTypes
966
+ );
967
+ const primaryPhone = getPrimaryContact(
968
+ person,
969
+ ['PHONE', 'MOBILE'],
970
+ contactTypes
971
+ );
972
+ const primaryDocument = getPrimaryDocument(
973
+ person,
974
+ documentTypes
975
+ );
976
+ const primaryAddress = getPrimaryAddress(person);
977
+ const secondaryLabel = getPersonSecondaryLabel(
978
+ person,
979
+ allowCompanyRegistration
980
+ );
981
+
982
+ return (
983
+ <Card
984
+ key={person.id}
985
+ className={cn(
986
+ 'group h-full overflow-hidden border-border/70 py-0 transition-colors hover:border-border hover:shadow-md'
987
+ )}
988
+ onDoubleClick={() => openEditSheet(person)}
989
+ >
990
+ <CardContent className="flex h-full flex-col gap-3 p-4">
991
+ <div className="flex items-start justify-between gap-3">
992
+ <div className="flex min-w-0 items-start gap-2.5">
993
+ <Avatar
994
+ className={cn(
995
+ 'h-10 w-10 shrink-0 rounded-xl border',
996
+ allowCompanyRegistration
997
+ ? person.type === 'individual'
998
+ ? 'border-sky-500/20'
999
+ : 'border-amber-500/20'
1000
+ : 'border-sky-500/20'
1001
+ )}
1002
+ >
1003
+ <AvatarImage
1004
+ src={getPersonAvatarUrl(person.avatar_id)}
1005
+ />
1006
+ <AvatarFallback
1007
+ className={cn(
1008
+ 'rounded-xl text-sm font-semibold uppercase',
1009
+ allowCompanyRegistration
1010
+ ? person.type === 'individual'
1011
+ ? 'bg-sky-500/8 text-sky-700 dark:text-sky-200'
1012
+ : 'bg-amber-500/8 text-amber-700 dark:text-amber-200'
1013
+ : 'bg-sky-500/8 text-sky-700 dark:text-sky-200'
1014
+ )}
1015
+ >
1016
+ {allowCompanyRegistration &&
1017
+ person.type === 'company' ? (
1018
+ <Building2 className="h-4 w-4" />
1019
+ ) : (
1020
+ getPersonInitials(person.name) || 'NA'
1021
+ )}
1022
+ </AvatarFallback>
1023
+ </Avatar>
1024
+
1025
+ <div className="min-w-0 space-y-1.5">
1026
+ <div className="flex flex-wrap gap-1.5">
1027
+ {allowCompanyRegistration ? (
1028
+ <Badge
1029
+ variant="outline"
1030
+ className={cn(
1031
+ 'border px-2 py-0.5 text-[11px] font-medium',
1032
+ person.type === 'individual'
1033
+ ? 'border-blue-500/20 bg-blue-500/10 text-blue-600'
1034
+ : 'border-amber-500/20 bg-amber-500/10 text-amber-600'
1035
+ )}
1036
+ >
1037
+ {person.type === 'individual' ? (
1038
+ <>
1039
+ <User className="mr-1 h-3 w-3" />
1040
+ {t('individual')}
1041
+ </>
1042
+ ) : (
1043
+ <>
1044
+ <Building2 className="mr-1 h-3 w-3" />
1045
+ {t('company')}
1046
+ </>
1047
+ )}
1048
+ </Badge>
1049
+ ) : null}
1050
+
1051
+ <Badge
1052
+ variant="outline"
1053
+ className={cn(
1054
+ 'border px-2 py-0.5 text-[11px] font-medium',
1055
+ person.status === 'active'
1056
+ ? 'border-green-500/20 bg-green-500/10 text-green-600'
1057
+ : 'border-red-500/20 bg-red-500/10 text-red-600'
1058
+ )}
1059
+ >
1060
+ {person.status === 'active' ? (
1061
+ <>
1062
+ <CheckCircle2 className="mr-1 h-3 w-3" />
1063
+ {t('active')}
1064
+ </>
1065
+ ) : (
1066
+ <>
1067
+ <XCircle className="mr-1 h-3 w-3" />
1068
+ {t('inactive')}
1069
+ </>
1070
+ )}
1071
+ </Badge>
1072
+ </div>
1073
+
1074
+ <div className="min-w-0">
1075
+ <h3 className="line-clamp-2 text-sm font-semibold text-foreground">
1076
+ {person.name}
1077
+ </h3>
1078
+ {secondaryLabel ? (
1079
+ <p className="truncate text-xs text-muted-foreground">
1080
+ {secondaryLabel}
1081
+ </p>
1082
+ ) : (
1083
+ <p className="flex items-center gap-1.5 text-xs text-muted-foreground">
1084
+ <span className="inline-flex h-5 w-5 items-center justify-center rounded-full border bg-background text-[9px] font-semibold uppercase text-foreground">
1085
+ {getPersonInitials(person.name) || 'NA'}
1086
+ </span>
1087
+ {t('personDetails')}
1088
+ </p>
1089
+ )}
1090
+ </div>
1091
+ </div>
1092
+ </div>
1093
+
1094
+ <DropdownMenu>
1095
+ <DropdownMenuTrigger asChild>
1096
+ <Button
1097
+ variant="ghost"
1098
+ size="icon"
1099
+ className="h-8 w-8 rounded-lg"
1100
+ >
1101
+ <MoreHorizontal className="h-4 w-4" />
1102
+ </Button>
1103
+ </DropdownMenuTrigger>
1104
+ <DropdownMenuContent align="end">
1105
+ <DropdownMenuItem
1106
+ onClick={() =>
1107
+ openInteractionDialog(person, 'interaction')
1108
+ }
1109
+ >
1110
+ <Phone className="mr-2 h-4 w-4" />
1111
+ {t('registerInteraction')}
1112
+ </DropdownMenuItem>
1113
+ <DropdownMenuItem
1114
+ onClick={() =>
1115
+ openInteractionDialog(person, 'followup')
1116
+ }
1117
+ >
1118
+ <CalendarDays className="mr-2 h-4 w-4" />
1119
+ {t('scheduleFollowup')}
1120
+ </DropdownMenuItem>
1121
+ <DropdownMenuSeparator />
1122
+ <DropdownMenuItem
1123
+ onClick={() => openEditSheet(person)}
1124
+ >
1125
+ <Pencil className="mr-2 h-4 w-4" />
1126
+ {t('buttonEditUser')}
1127
+ </DropdownMenuItem>
1128
+ <DropdownMenuSeparator />
1129
+ <DropdownMenuItem
1130
+ className="text-red-600"
1131
+ onClick={() => {
1132
+ setPersonToDelete(person);
1133
+ setDeleteDialogOpen(true);
1134
+ }}
1135
+ >
1136
+ <Trash2 className="mr-2 h-4 w-4" />
1137
+ {t('delete')}
1138
+ </DropdownMenuItem>
1139
+ </DropdownMenuContent>
1140
+ </DropdownMenu>
1141
+ </div>
1142
+
1143
+ <div className="grid gap-2 sm:grid-cols-2">
1144
+ <PersonInfoTile
1145
+ icon={<Mail className="h-4 w-4" />}
1146
+ label={t('columnEmail')}
1147
+ value={primaryEmail}
1148
+ copyValue={primaryEmail}
1149
+ />
1150
+ <PersonInfoTile
1151
+ icon={<Phone className="h-4 w-4" />}
1152
+ label={t('columnPhone')}
1153
+ value={primaryPhone}
1154
+ copyValue={primaryPhone}
1155
+ />
1156
+ <PersonInfoTile
1157
+ icon={<FileText className="h-4 w-4" />}
1158
+ label={primaryDocument?.label || t('documentValue')}
1159
+ value={primaryDocument?.value || '-'}
1160
+ copyValue={primaryDocument?.value}
1161
+ />
1162
+ <PersonInfoTile
1163
+ icon={<MapPin className="h-4 w-4" />}
1164
+ label={t('address')}
1165
+ value={
1166
+ primaryAddress
1167
+ ? getAddressSummary(primaryAddress)
1168
+ : '-'
1169
+ }
1170
+ description={primaryAddress?.line1}
1171
+ copyValue={
1172
+ primaryAddress
1173
+ ? getAddressCopyValue(primaryAddress)
1174
+ : null
1175
+ }
1176
+ />
1177
+ </div>
1178
+
1179
+ <div className="mt-auto flex items-center justify-between gap-2 rounded-xl border border-border/60 bg-muted/30 px-2.5 py-2">
1180
+ <div className="min-w-0">
1181
+ <div className="flex items-center gap-1.5 text-[10px] font-medium tracking-[0.14em] text-muted-foreground uppercase">
1182
+ <CalendarDays className="h-3.5 w-3.5 text-muted-foreground" />
1183
+ {t('createdAt')}
1184
+ </div>
1185
+ <div className="truncate text-xs font-medium text-foreground">
1186
+ {formatDate(
1187
+ person.created_at,
1188
+ getSettingValue,
1189
+ currentLocaleCode
1190
+ )}
1191
+ </div>
1192
+ <div className="truncate text-[11px] text-muted-foreground">
1193
+ {t('owner')}:{' '}
1194
+ {person.owner_user?.name || t('unassigned')}
1195
+ </div>
1196
+ </div>
1197
+
1198
+ <div className="flex items-center gap-2">
1199
+ <Button
1200
+ variant="outline"
1201
+ size="sm"
1202
+ onClick={() =>
1203
+ openInteractionDialog(person, 'interaction')
1204
+ }
1205
+ className="h-8 shrink-0 rounded-lg px-2.5 text-xs"
1206
+ >
1207
+ <Phone className="mr-2 h-4 w-4" />
1208
+ {t('register')}
1209
+ </Button>
1210
+ <Button
1211
+ variant="outline"
1212
+ size="sm"
1213
+ onClick={() => openEditSheet(person)}
1214
+ className="h-8 shrink-0 rounded-lg px-2.5 text-xs"
1215
+ >
1216
+ <Pencil className="mr-2 h-4 w-4" />
1217
+ {t('buttonEditUser')}
1218
+ </Button>
1219
+ </div>
1220
+ </div>
1221
+ </CardContent>
1222
+ </Card>
1223
+ );
1224
+ })}
1225
+ </div>
1226
+ )}
1227
+
1228
+ <div className="flex flex-col gap-4 border-t p-4 lg:flex-row lg:items-center lg:justify-between">
1229
+ <div className="text-sm text-muted-foreground">
1230
+ {t('resultsCount', { count: paginate.total })}
1231
+ </div>
1232
+
1233
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
1234
+ <div className="flex items-center gap-2">
1235
+ <span className="text-sm text-muted-foreground">
1236
+ {t('rowsPerPage')}
1237
+ </span>
1238
+ <Select
1239
+ value={String(pageSize)}
1240
+ onValueChange={(value) => {
1241
+ setPageSize(Number(value));
1242
+ setPage(1);
1243
+ }}
1244
+ >
1245
+ <SelectTrigger className="w-20">
1246
+ <SelectValue />
1247
+ </SelectTrigger>
1248
+ <SelectContent>
1249
+ {[12, 20, 30, 40, 50].map((size) => (
1250
+ <SelectItem key={size} value={String(size)}>
1251
+ {size}
1252
+ </SelectItem>
1253
+ ))}
1254
+ </SelectContent>
1255
+ </Select>
1256
+ </div>
1257
+
1258
+ <div className="flex items-center gap-2">
1259
+ <Button
1260
+ variant="outline"
1261
+ size="icon"
1262
+ onClick={() => setPage(1)}
1263
+ disabled={page <= 1}
1264
+ >
1265
+ <ChevronsLeft className="h-4 w-4" />
1266
+ </Button>
1267
+ <Button
1268
+ variant="outline"
1269
+ size="icon"
1270
+ onClick={() =>
1271
+ setPage((previous) => Math.max(1, previous - 1))
1272
+ }
1273
+ disabled={page <= 1}
1274
+ >
1275
+ <ChevronLeft className="h-4 w-4" />
1276
+ </Button>
1277
+ <span className="min-w-32 text-center text-sm">
1278
+ {t('paginationLabel', {
1279
+ current: page,
1280
+ total: pageCount,
1281
+ })}
1282
+ </span>
1283
+ <Button
1284
+ variant="outline"
1285
+ size="icon"
1286
+ onClick={() =>
1287
+ setPage((previous) => Math.min(pageCount, previous + 1))
1288
+ }
1289
+ disabled={page >= pageCount}
1290
+ >
1291
+ <ChevronRight className="h-4 w-4" />
1292
+ </Button>
1293
+ <Button
1294
+ variant="outline"
1295
+ size="icon"
1296
+ onClick={() => setPage(pageCount)}
1297
+ disabled={page >= pageCount}
1298
+ >
1299
+ <ChevronsRight className="h-4 w-4" />
1300
+ </Button>
1301
+ </div>
1302
+ </div>
1303
+ </div>
1304
+ </>
1305
+ )}
1306
+
1307
+ <DeletePersonDialog
1308
+ open={deleteDialogOpen}
1309
+ personName={personToDelete?.name}
1310
+ isDeleting={isDeleting}
1311
+ onOpenChange={(open) => {
1312
+ setDeleteDialogOpen(open);
1313
+ if (!open) {
1314
+ setPersonToDelete(null);
1315
+ }
1316
+ }}
1317
+ onConfirm={handleDelete}
1318
+ />
1319
+
1320
+ <PersonFormSheet
1321
+ open={formSheetOpen}
1322
+ person={personToEdit}
1323
+ contactTypes={contactTypes}
1324
+ documentTypes={documentTypes}
1325
+ onOpenChange={(open) => {
1326
+ setFormSheetOpen(open);
1327
+ if (!open) {
1328
+ setPersonToEdit(null);
1329
+ }
1330
+ }}
1331
+ onSuccess={() => {
1332
+ void refetch();
1333
+ }}
1334
+ />
1335
+
1336
+ <PersonInteractionDialog
1337
+ open={interactionDialogOpen}
1338
+ person={personForInteraction}
1339
+ mode={interactionMode}
1340
+ onOpenChange={(open) => {
1341
+ setInteractionDialogOpen(open);
1342
+ if (!open) {
1343
+ setPersonForInteraction(null);
1344
+ setInteractionMode('interaction');
1345
+ }
1346
+ }}
1347
+ onSuccess={() => {
1348
+ void refetch();
1349
+ }}
1350
+ />
1351
+ </Page>
1352
+ );
1353
+ }