@hed-hog/contact 0.0.266 → 0.0.270

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 (77) hide show
  1. package/dist/address-type.enum.d.ts +10 -0
  2. package/dist/address-type.enum.d.ts.map +1 -0
  3. package/dist/address-type.enum.js +14 -0
  4. package/dist/address-type.enum.js.map +1 -0
  5. package/dist/contact.module.d.ts.map +1 -1
  6. package/dist/contact.module.js +0 -2
  7. package/dist/contact.module.js.map +1 -1
  8. package/dist/contact.service.d.ts +19 -22
  9. package/dist/contact.service.d.ts.map +1 -1
  10. package/dist/contact.service.js +22 -2
  11. package/dist/contact.service.js.map +1 -1
  12. package/dist/index.d.ts +5 -8
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +1 -4
  15. package/dist/index.js.map +1 -1
  16. package/dist/person/dto/create.dto.d.ts +14 -0
  17. package/dist/person/dto/create.dto.d.ts.map +1 -1
  18. package/dist/person/dto/create.dto.js +52 -1
  19. package/dist/person/dto/create.dto.js.map +1 -1
  20. package/dist/person/dto/update.dto.d.ts +17 -1
  21. package/dist/person/dto/update.dto.d.ts.map +1 -1
  22. package/dist/person/dto/update.dto.js +79 -3
  23. package/dist/person/dto/update.dto.js.map +1 -1
  24. package/dist/person/person.controller.d.ts +33 -8
  25. package/dist/person/person.controller.d.ts.map +1 -1
  26. package/dist/person/person.controller.js +19 -3
  27. package/dist/person/person.controller.js.map +1 -1
  28. package/dist/person/person.service.d.ts +59 -10
  29. package/dist/person/person.service.d.ts.map +1 -1
  30. package/dist/person/person.service.js +710 -107
  31. package/dist/person/person.service.js.map +1 -1
  32. package/dist/person-relation-type/person-relation-type.controller.d.ts +13 -9
  33. package/dist/person-relation-type/person-relation-type.controller.d.ts.map +1 -1
  34. package/dist/person-relation-type/person-relation-type.service.d.ts +16 -20
  35. package/dist/person-relation-type/person-relation-type.service.d.ts.map +1 -1
  36. package/dist/person-relation-type/person-relation-type.service.js +48 -41
  37. package/dist/person-relation-type/person-relation-type.service.js.map +1 -1
  38. package/hedhog/data/menu.yaml +2 -16
  39. package/hedhog/data/route.yaml +2 -21
  40. package/hedhog/data/setting_group.yaml +21 -0
  41. package/hedhog/frontend/app/person/_components/delete-person-dialog.tsx.ejs +59 -0
  42. package/hedhog/frontend/app/person/_components/person-field-with-create.tsx.ejs +831 -0
  43. package/hedhog/frontend/app/person/_components/person-form-sheet.tsx.ejs +1997 -0
  44. package/hedhog/frontend/app/person/_components/person-types.ts.ejs +115 -0
  45. package/hedhog/frontend/app/person/page.tsx.ejs +1209 -1530
  46. package/hedhog/frontend/messages/en.json +114 -4
  47. package/hedhog/frontend/messages/pt.json +114 -4
  48. package/hedhog/table/person.yaml +7 -0
  49. package/hedhog/table/person_address.yaml +18 -0
  50. package/hedhog/table/person_company.yaml +26 -11
  51. package/hedhog/table/person_individual.yaml +17 -13
  52. package/hedhog/table/person_individual_relation.yaml +39 -0
  53. package/package.json +6 -5
  54. package/src/address-type.enum.ts +9 -0
  55. package/src/contact.module.ts +46 -48
  56. package/src/contact.service.ts +28 -13
  57. package/src/index.ts +6 -13
  58. package/src/language/en.json +13 -6
  59. package/src/language/pt.json +14 -6
  60. package/src/person/dto/create.dto.ts +62 -14
  61. package/src/person/dto/update.dto.ts +147 -75
  62. package/src/person/person.controller.ts +39 -27
  63. package/src/person/person.service.ts +1071 -239
  64. package/src/person-relation-type/person-relation-type.service.ts +84 -76
  65. package/hedhog/data/address_type.yaml +0 -28
  66. package/hedhog/frontend/app/address-type/page.tsx.ejs +0 -480
  67. package/hedhog/query/add-unique-address-type-locale.sql +0 -3
  68. package/hedhog/table/address.yaml +0 -28
  69. package/hedhog/table/address_type.yaml +0 -11
  70. package/hedhog/table/person_relation.yaml +0 -20
  71. package/hedhog/table/person_relation_type.yaml +0 -6
  72. package/src/address-type/address-type.controller.ts +0 -55
  73. package/src/address-type/address-type.enum.ts +0 -9
  74. package/src/address-type/address-type.module.ts +0 -18
  75. package/src/address-type/address-type.service.ts +0 -121
  76. package/src/address-type/dto/create.dto.ts +0 -19
  77. package/src/address-type/dto/update.dto.ts +0 -9
@@ -1,1530 +1,1209 @@
1
- 'use client';
2
- import { CopyButton } from '@/components/copy-button';
3
- import {
4
- PageHeader,
5
- PaginationFooter,
6
- SearchBar,
7
- StatsCards,
8
- } from '@/components/entity-list';
9
- import { Badge } from '@/components/ui/badge';
10
- import { Button } from '@/components/ui/button';
11
- import { Card, CardContent } from '@/components/ui/card';
12
- import {
13
- Dialog,
14
- DialogContent,
15
- DialogDescription,
16
- DialogHeader,
17
- DialogTitle,
18
- } from '@/components/ui/dialog';
19
- import {
20
- DropdownMenu,
21
- DropdownMenuContent,
22
- DropdownMenuItem,
23
- DropdownMenuLabel,
24
- DropdownMenuSeparator,
25
- DropdownMenuTrigger,
26
- } from '@/components/ui/dropdown-menu';
27
- import {
28
- Form,
29
- FormControl,
30
- FormField,
31
- FormItem,
32
- FormLabel,
33
- FormMessage,
34
- } from '@/components/ui/form';
35
- import { Input } from '@/components/ui/input';
36
- import {
37
- Select,
38
- SelectContent,
39
- SelectItem,
40
- SelectTrigger,
41
- SelectValue,
42
- } from '@/components/ui/select';
43
- import {
44
- Sheet,
45
- SheetContent,
46
- SheetDescription,
47
- SheetHeader,
48
- SheetTitle,
49
- } from '@/components/ui/sheet';
50
- import {
51
- Table,
52
- TableBody,
53
- TableCell,
54
- TableHead,
55
- TableHeader,
56
- TableRow,
57
- } from '@/components/ui/table';
58
- import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
59
- import { COUNTRIES } from '@/constants/countries';
60
- import { formatDate } from '@/lib/format-date';
61
- import { useApp, useQuery } from '@hed-hog/next-app-provider';
62
- import { zodResolver } from '@hookform/resolvers/zod';
63
- import {
64
- Building2,
65
- Edit2,
66
- FileText,
67
- Loader2,
68
- Mail,
69
- MapPin,
70
- MoreHorizontal,
71
- Phone,
72
- Trash2,
73
- User,
74
- UserPlus,
75
- Users,
76
- } from 'lucide-react';
77
- import { useTranslations } from 'next-intl';
78
- import { useState } from 'react';
79
- import { useForm } from 'react-hook-form';
80
- import { toast } from 'sonner';
81
- import { z } from 'zod';
82
-
83
- type PaginatedResult<T> = {
84
- data: T[];
85
- total: number;
86
- page: number;
87
- pageSize: number;
88
- };
89
-
90
- type Person = {
91
- id: number;
92
- name: string;
93
- type: 'individual' | 'company';
94
- status: 'active' | 'inactive';
95
- created_at: string;
96
- contact?: Array<{
97
- id: number;
98
- value: string;
99
- is_primary: boolean;
100
- contact_type_id: number;
101
- contact_type?: {
102
- id: number;
103
- code: string;
104
- };
105
- }>;
106
- address?: Array<{
107
- id: number;
108
- line1: string;
109
- city: string;
110
- state: string;
111
- is_primary: boolean;
112
- address_type_id: number;
113
- postal_code: string;
114
- country_code: string;
115
- address_type?: {
116
- id: number;
117
- code: string;
118
- };
119
- }>;
120
- document?: Array<{
121
- id: number;
122
- value: string;
123
- document_type_id: number;
124
- document_type?: {
125
- id: number;
126
- code: string;
127
- };
128
- }>;
129
- };
130
-
131
- export default function ContactPage() {
132
- const t = useTranslations('contact.ContactPage');
133
-
134
- const personSchema = z.object({
135
- name: z.string().min(1, t('nameRequired')),
136
- type: z.enum(['individual', 'company']),
137
- status: z.enum(['active', 'inactive']).optional(),
138
- });
139
-
140
- const { request, currentLocaleCode, getSettingValue } = useApp();
141
-
142
- const [searchQuery, setSearchQuery] = useState('');
143
- const [isDialogOpen, setIsDialogOpen] = useState(false);
144
- const [isSheetOpen, setIsSheetOpen] = useState(false);
145
- const [editingPerson, setEditingPerson] = useState<Person | null>(null);
146
- const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
147
- const [personToDelete, setPersonToDelete] = useState<Person | null>(null);
148
-
149
- const [page, setPage] = useState(1);
150
- const [pageSize, setPageSize] = useState(12);
151
- const [typeFilter, setTypeFilter] = useState('all');
152
- const [statusFilter, setStatusFilter] = useState('all');
153
-
154
- const { data: contactTypes = [] } = useQuery({
155
- queryKey: ['contact-types-all', currentLocaleCode],
156
- queryFn: async () => {
157
- const response = await request<{
158
- data: Array<{
159
- id: number;
160
- code: string;
161
- contact_type_id: number;
162
- name: string;
163
- }>;
164
- }>({
165
- url: '/person-contact-type?pageSize=100',
166
- method: 'GET',
167
- });
168
- return response.data.data || [];
169
- },
170
- });
171
-
172
- const { data: addressTypes = [] } = useQuery({
173
- queryKey: ['address-types-all', currentLocaleCode],
174
- queryFn: async () => {
175
- const response = await request<{
176
- data: Array<{
177
- id: number;
178
- address_type_id: number;
179
- code: string;
180
- name: string;
181
- }>;
182
- }>({
183
- url: '/person-address-type?pageSize=100',
184
- method: 'GET',
185
- });
186
- return response.data.data || [];
187
- },
188
- });
189
-
190
- const { data: documentTypes = [] } = useQuery({
191
- queryKey: ['document-types-all', currentLocaleCode],
192
- queryFn: async () => {
193
- const response = await request<{
194
- data: Array<{
195
- id: number;
196
- document_type_id: number;
197
- code: string;
198
- name: string;
199
- }>;
200
- }>({
201
- url: '/person-document-type?pageSize=100',
202
- method: 'GET',
203
- });
204
- return response.data.data || [];
205
- },
206
- });
207
-
208
- const {
209
- data: stats = {
210
- total: 0,
211
- individual: 0,
212
- company: 0,
213
- active: 0,
214
- inactive: 0,
215
- },
216
- refetch: refetchStats,
217
- } = useQuery({
218
- queryKey: ['person-stats', currentLocaleCode],
219
- queryFn: async () => {
220
- const response = await request<{
221
- total: number;
222
- individual: number;
223
- company: number;
224
- active: number;
225
- inactive: number;
226
- }>({
227
- url: '/person/stats',
228
- method: 'GET',
229
- });
230
- return response.data;
231
- },
232
- });
233
-
234
- const {
235
- data: paginate = { data: [], total: 0, page: 1, pageSize: 12 },
236
- isLoading,
237
- refetch,
238
- } = useQuery<PaginatedResult<Person>>({
239
- queryKey: [
240
- 'persons',
241
- page,
242
- pageSize,
243
- searchQuery,
244
- typeFilter,
245
- statusFilter,
246
- currentLocaleCode,
247
- ],
248
- queryFn: async () => {
249
- const params = new URLSearchParams();
250
- params.set('page', String(page));
251
- params.set('pageSize', String(pageSize));
252
- if (searchQuery) params.set('search', searchQuery);
253
- if (typeFilter && typeFilter !== 'all') params.set('type', typeFilter);
254
- if (statusFilter && statusFilter !== 'all')
255
- params.set('status', statusFilter);
256
-
257
- const response = await request<PaginatedResult<Person>>({
258
- url: `/person?${params.toString()}`,
259
- method: 'GET',
260
- });
261
-
262
- return response.data;
263
- },
264
- });
265
-
266
- const form = useForm<z.infer<typeof personSchema>>({
267
- resolver: zodResolver(personSchema),
268
- defaultValues: {
269
- name: '',
270
- type: 'individual',
271
- status: 'active',
272
- },
273
- });
274
-
275
- async function fetchViaCep(cep: string) {
276
- try {
277
- const response = await fetch(`https://viacep.com.br/ws/${cep}/json/`);
278
- if (!response.ok) return null;
279
- const data = await response.json();
280
- if (data.erro) return null;
281
- return data;
282
- } catch {
283
- return null;
284
- }
285
- }
286
-
287
- const editForm = useForm<z.infer<typeof personSchema>>({
288
- resolver: zodResolver(personSchema),
289
- });
290
- const [contacts, setContacts] = useState<any[]>([]);
291
- const [addresses, setAddresses] = useState<any[]>([]);
292
- const [documents, setDocuments] = useState<any[]>([]);
293
- const [loadingCEP, setLoadingCEP] = useState<{ [key: number]: boolean }>({});
294
-
295
- const openEditSheet = (person: Person) => {
296
- setEditingPerson(person);
297
- editForm.reset({
298
- name: person.name,
299
- type: person.type,
300
- status: person.status,
301
- });
302
- setContacts(person.contact ? [...person.contact] : []);
303
- setAddresses(person.address ? [...person.address] : []);
304
- setDocuments(person.document ? [...person.document] : []);
305
- setIsSheetOpen(true);
306
- };
307
-
308
- const handleContactChange = (idx: number, field: string, value: any) => {
309
- setContacts((prev) =>
310
- prev.map((c, i) => (i === idx ? { ...c, [field]: value } : c))
311
- );
312
- };
313
- const handleAddContact = () => {
314
- setContacts((prev) => [
315
- ...prev,
316
- {
317
- id: undefined,
318
- value: '',
319
- is_primary: false,
320
- contact_type_id: contactTypes[0]?.contact_type_id || 1,
321
- },
322
- ]);
323
- };
324
- const handleRemoveContact = (idx: number) => {
325
- setContacts((prev) => prev.filter((_, i) => i !== idx));
326
- };
327
-
328
- const handleAddressChange = (idx: number, field: string, value: any) => {
329
- setAddresses((prev) =>
330
- prev.map((a, i) => (i === idx ? { ...a, [field]: value } : a))
331
- );
332
- };
333
- const handleAddAddress = () => {
334
- setAddresses((prev) => [
335
- ...prev,
336
- {
337
- id: undefined,
338
- line1: '',
339
- line2: '',
340
- city: '',
341
- state: '',
342
- is_primary: false,
343
- address_type_id: addressTypes[0]?.address_type_id || 1,
344
- country_code: 'BRA',
345
- postal_code: '',
346
- },
347
- ]);
348
- };
349
- const handleRemoveAddress = (idx: number) => {
350
- setAddresses((prev) => prev.filter((_, i) => i !== idx));
351
- };
352
-
353
- const handleDocumentChange = (idx: number, field: string, value: any) => {
354
- setDocuments((prev) =>
355
- prev.map((d, i) => (i === idx ? { ...d, [field]: value } : d))
356
- );
357
- };
358
- const handleAddDocument = () => {
359
- setDocuments((prev) => [
360
- ...prev,
361
- {
362
- id: undefined,
363
- value: '',
364
- document_type_id: documentTypes[0]?.document_type_id || 1,
365
- },
366
- ]);
367
- };
368
- const handleRemoveDocument = (idx: number) => {
369
- setDocuments((prev) => prev.filter((_, i) => i !== idx));
370
- };
371
-
372
- const handleCEP = async (e: any, idx: number) => {
373
- let v = e.target.value.replace(/\D/g, '');
374
- if (v.length > 5) v = v.slice(0, 5) + '-' + v.slice(5, 8);
375
- handleAddressChange(idx, 'postal_code', v);
376
- const rawCep = v.replace(/\D/g, '');
377
- if (rawCep.length === 8) {
378
- setLoadingCEP((prev) => ({ ...prev, [idx]: true }));
379
- const data = await fetchViaCep(rawCep);
380
- if (data) {
381
- handleAddressChange(idx, 'line1', data.logradouro || '');
382
- handleAddressChange(idx, 'city', data.localidade || '');
383
- handleAddressChange(idx, 'state', data.uf || '');
384
- }
385
- setLoadingCEP((prev) => ({ ...prev, [idx]: false }));
386
- }
387
- };
388
-
389
- const onEdit = async (values: z.infer<typeof personSchema>) => {
390
- if (!editingPerson) return;
391
- try {
392
- const addressesWithLine2 = addresses.map((a) => ({
393
- ...a,
394
- line2: a.line2 || '',
395
- }));
396
- await request({
397
- url: `/person/${editingPerson.id}`,
398
- method: 'PATCH',
399
- data: {
400
- name: values.name,
401
- type: values.type,
402
- status: values.status,
403
- contacts: contacts,
404
- addresses: addressesWithLine2,
405
- documents: documents,
406
- },
407
- });
408
- toast.success(t('updateSuccess'));
409
- setIsSheetOpen(false);
410
- setEditingPerson(null);
411
- editForm.reset();
412
- refetch();
413
- } catch (error: any) {
414
- toast.error(error?.message || t('updateError'));
415
- }
416
- };
417
-
418
- const onSubmit = async (values: z.infer<typeof personSchema>) => {
419
- try {
420
- await request({
421
- url: '/person',
422
- method: 'POST',
423
- data: values,
424
- });
425
- toast.success(t('createSuccess'));
426
- setIsDialogOpen(false);
427
- form.reset();
428
- refetch();
429
- refetchStats();
430
- } catch (error: any) {
431
- toast.error(error?.message || t('createError'));
432
- }
433
- };
434
-
435
- const handleDelete = async () => {
436
- if (!personToDelete) return;
437
- try {
438
- await request({
439
- url: '/person',
440
- method: 'DELETE',
441
- data: { ids: [personToDelete.id] },
442
- });
443
- toast.success(t('deleteSuccess'));
444
- setDeleteDialogOpen(false);
445
- setPersonToDelete(null);
446
- refetch();
447
- refetchStats();
448
- } catch (error: any) {
449
- toast.error(error?.message || t('deleteError'));
450
- }
451
- };
452
-
453
- const statsCards = [
454
- {
455
- title: t('statsTotal'),
456
- value: stats.total,
457
- icon: <Users className="h-4 w-4" />,
458
- },
459
- {
460
- title: t('statsIndividual'),
461
- value: stats.individual,
462
- icon: <User className="h-4 w-4" />,
463
- },
464
- {
465
- title: t('statsCompany'),
466
- value: stats.company,
467
- icon: <Building2 className="h-4 w-4" />,
468
- },
469
- {
470
- title: t('statsActive'),
471
- value: stats.active,
472
- icon: <UserPlus className="h-4 w-4" />,
473
- },
474
- ];
475
-
476
- const getPrimaryContact = (person: Person, typeCode: string) => {
477
- if (!person.contact?.length) return '-';
478
- const code = typeCode.toUpperCase();
479
- const type = contactTypes.find((ct: any) => ct.code === code);
480
- if (!type) return '-';
481
- const contact = person.contact.find(
482
- (c) => c.contact_type_id === type.contact_type_id && c.is_primary
483
- );
484
- if (!contact?.value) return '-';
485
-
486
- if (code === 'PHONE') {
487
- const digits = contact.value.replace(/\D/g, '');
488
- if (digits.length === 11)
489
- return `(${digits.slice(0, 2)}) ${digits.slice(2, 7)}-${digits.slice(7)}`;
490
- if (digits.length === 10)
491
- return `(${digits.slice(0, 2)}) ${digits.slice(2, 6)}-${digits.slice(6)}`;
492
- }
493
- return contact.value;
494
- };
495
-
496
- const getPrimaryAddress = (person: Person) => {
497
- const address = person.address?.find((a) => a.is_primary);
498
- return address ? `${address.city}, ${address.state}` : '-';
499
- };
500
-
501
- const getContactTypeName = (contactTypeId: number) => {
502
- const contactType = contactTypes.find(
503
- (ct: any) => ct.contact_type_id === contactTypeId
504
- );
505
- return contactType?.name || t('unknown');
506
- };
507
-
508
- const getAddressTypeName = (addressTypeId: number) => {
509
- const addressType = addressTypes.find(
510
- (at: any) => at.address_type_id === addressTypeId
511
- );
512
- return addressType?.name || t('unknown');
513
- };
514
-
515
- const getDocumentTypeName = (documentTypeId: number) => {
516
- const documentType = documentTypes.find(
517
- (dt: any) => dt.document_type_id === documentTypeId
518
- );
519
- return documentType?.name || t('unknown');
520
- };
521
-
522
- return (
523
- <div className="flex flex-col h-screen px-4">
524
- <PageHeader
525
- breadcrumbs={[
526
- { label: 'Home', href: '/' },
527
- { label: t('description') },
528
- ]}
529
- actions={[
530
- {
531
- label: t('addNew'),
532
- onClick: () => setIsDialogOpen(true),
533
- variant: 'default',
534
- },
535
- ]}
536
- title={t('title')}
537
- description={t('description')}
538
- />
539
-
540
- <StatsCards stats={statsCards} />
541
-
542
- <div className="mb-4 flex flex-col gap-4 md:flex-row mt-4">
543
- <SearchBar
544
- searchQuery={searchQuery}
545
- onSearchChange={setSearchQuery}
546
- onSearch={() => refetch()}
547
- placeholder={t('searchPlaceholder')}
548
- />
549
- <Select value={typeFilter} onValueChange={setTypeFilter}>
550
- <SelectTrigger className="w-full md:w-[200px]">
551
- <SelectValue placeholder={t('filterByType')} />
552
- </SelectTrigger>
553
- <SelectContent>
554
- <SelectItem value="all">{t('allTypes')}</SelectItem>
555
- <SelectItem value="individual">{t('individual')}</SelectItem>
556
- <SelectItem value="company">{t('company')}</SelectItem>
557
- </SelectContent>
558
- </Select>
559
- <Select value={statusFilter} onValueChange={setStatusFilter}>
560
- <SelectTrigger className="w-full md:w-[200px]">
561
- <SelectValue placeholder={t('filterByStatus')} />
562
- </SelectTrigger>
563
- <SelectContent>
564
- <SelectItem value="all">{t('allStatuses')}</SelectItem>
565
- <SelectItem value="active">{t('active')}</SelectItem>
566
- <SelectItem value="inactive">{t('inactive')}</SelectItem>
567
- </SelectContent>
568
- </Select>
569
- </div>
570
-
571
- <div className="rounded-md border mb-4">
572
- <Table>
573
- <TableHeader>
574
- <TableRow>
575
- <TableHead>{t('columnId')}</TableHead>
576
- <TableHead>{t('columnName')}</TableHead>
577
- <TableHead>{t('columnType')}</TableHead>
578
- <TableHead>{t('columnStatus')}</TableHead>
579
- <TableHead>{t('columnEmail')}</TableHead>
580
- <TableHead>{t('columnPhone')}</TableHead>
581
- <TableHead>{t('columnPlace')}</TableHead>
582
- <TableHead className="text-right">{t('columnActions')}</TableHead>
583
- </TableRow>
584
- </TableHeader>
585
- <TableBody>
586
- {isLoading ? (
587
- <TableRow>
588
- <TableCell colSpan={7} className="text-center">
589
- {t('loading')}
590
- </TableCell>
591
- </TableRow>
592
- ) : paginate?.data?.length === 0 ? (
593
- <TableRow>
594
- <TableCell colSpan={7} className="text-center">
595
- {t('emptyState')}
596
- </TableCell>
597
- </TableRow>
598
- ) : (
599
- paginate?.data?.map((person: Person) => (
600
- <TableRow
601
- key={person.id}
602
- onDoubleClick={() => openEditSheet(person)}
603
- className="cursor-pointer"
604
- >
605
- <TableCell className="font-medium">#{person.id}</TableCell>
606
- <TableCell className="font-medium">
607
- <div className="flex items-center gap-2">
608
- {person.name || '-'}
609
- {person.name && <CopyButton value={person.name} />}
610
- </div>
611
- </TableCell>
612
-
613
- <TableCell>
614
- <Badge
615
- variant={
616
- person.type === 'individual' ? 'default' : 'secondary'
617
- }
618
- >
619
- {person.type === 'individual' ? (
620
- <>
621
- <User className="mr-1 h-3 w-3" />
622
- {t('individual')}
623
- </>
624
- ) : (
625
- <>
626
- <Building2 className="mr-1 h-3 w-3" />
627
- {t('company')}
628
- </>
629
- )}
630
- </Badge>
631
- </TableCell>
632
- <TableCell>
633
- <Badge
634
- variant={
635
- person.status === 'active' ? 'default' : 'outline'
636
- }
637
- >
638
- {person.status === 'active' ? t('active') : t('inactive')}
639
- </Badge>
640
- </TableCell>
641
- <TableCell>
642
- <div className="flex items-center gap-2">
643
- <Mail className="h-4 w-4 text-muted-foreground" />
644
- {getPrimaryContact(person, 'email')}
645
- {getPrimaryContact(person, 'email') &&
646
- getPrimaryContact(person, 'email') !== '-' && (
647
- <CopyButton
648
- value={getPrimaryContact(person, 'email')}
649
- />
650
- )}
651
- </div>
652
- </TableCell>
653
- <TableCell>
654
- <div className="flex items-center gap-2">
655
- <Phone className="h-4 w-4 text-muted-foreground" />
656
- {getPrimaryContact(person, 'phone')}
657
- {getPrimaryContact(person, 'phone') &&
658
- getPrimaryContact(person, 'phone') !== '-' && (
659
- <CopyButton
660
- value={getPrimaryContact(person, 'phone')}
661
- />
662
- )}
663
- </div>
664
- </TableCell>
665
- <TableCell>
666
- <div className="flex items-center gap-2">
667
- <MapPin className="h-4 w-4 text-muted-foreground" />
668
- {getPrimaryAddress(person)}
669
- {getPrimaryAddress(person) &&
670
- getPrimaryAddress(person) !== '-' && (
671
- <CopyButton value={getPrimaryAddress(person)} />
672
- )}
673
- </div>
674
- </TableCell>
675
- <TableCell className="text-right">
676
- <DropdownMenu>
677
- <DropdownMenuTrigger asChild>
678
- <Button variant="ghost" size="icon">
679
- <MoreHorizontal className="h-4 w-4" />
680
- </Button>
681
- </DropdownMenuTrigger>
682
- <DropdownMenuContent align="end">
683
- <DropdownMenuLabel>{t('actions')}</DropdownMenuLabel>
684
- <DropdownMenuSeparator />
685
- <DropdownMenuItem onClick={() => openEditSheet(person)}>
686
- <Edit2 className="mr-2 h-4 w-4" />
687
- {t('buttonEditUser')}
688
- </DropdownMenuItem>
689
- <DropdownMenuItem
690
- onClick={(e) => {
691
- e.preventDefault();
692
- e.stopPropagation();
693
- setPersonToDelete(person);
694
- setDeleteDialogOpen(true);
695
- }}
696
- className="text-destructive"
697
- >
698
- <Trash2 className="mr-2 h-4 w-4" />
699
- {t('delete')}
700
- </DropdownMenuItem>
701
- {/* Dialog de confirmação de exclusão */}
702
- <Dialog open={deleteDialogOpen}>
703
- <DialogContent className="max-w-md">
704
- <DialogHeader>
705
- <DialogTitle>
706
- {t('deleteConfirmTitle')}
707
- </DialogTitle>
708
- <DialogDescription>
709
- {t('deleteConfirmDescription')}
710
- </DialogDescription>
711
- </DialogHeader>
712
- <div className="flex justify-end gap-2 pt-4">
713
- <Button
714
- variant="outline"
715
- onClick={() => {
716
- setDeleteDialogOpen(false);
717
- setPersonToDelete(null);
718
- }}
719
- >
720
- {t('cancel')}
721
- </Button>
722
- <Button
723
- variant="destructive"
724
- onClick={handleDelete}
725
- >
726
- {t('delete')}
727
- </Button>
728
- </div>
729
- </DialogContent>
730
- </Dialog>
731
- </DropdownMenuContent>
732
- </DropdownMenu>
733
- </TableCell>
734
- </TableRow>
735
- ))
736
- )}
737
- </TableBody>
738
- </Table>
739
- </div>
740
-
741
- <PaginationFooter
742
- currentPage={page}
743
- pageSize={pageSize}
744
- totalItems={paginate?.total || 0}
745
- onPageChange={setPage}
746
- onPageSizeChange={setPageSize}
747
- pageSizeOptions={[10, 20, 30, 40, 50]}
748
- />
749
-
750
- <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
751
- <DialogContent className="max-w-md">
752
- <DialogHeader>
753
- <DialogTitle>{t('dialogCreateTitle')}</DialogTitle>
754
- <DialogDescription>
755
- {t('dialogCreateDescription')}
756
- </DialogDescription>
757
- </DialogHeader>
758
- <Form {...form}>
759
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
760
- <FormField
761
- control={form.control}
762
- name="type"
763
- render={({ field }) => (
764
- <FormItem>
765
- <FormLabel>{t('typeOfPerson')}</FormLabel>
766
- <Select
767
- onValueChange={field.onChange}
768
- defaultValue={field.value}
769
- >
770
- <FormControl>
771
- <SelectTrigger className="w-full">
772
- <SelectValue placeholder={t('selectType')} />
773
- </SelectTrigger>
774
- </FormControl>
775
- <SelectContent>
776
- <SelectItem value="individual">
777
- <div className="flex items-center gap-2">
778
- <User className="h-4 w-4" />
779
- {t('individual')}
780
- </div>
781
- </SelectItem>
782
- <SelectItem value="company">
783
- <div className="flex items-center gap-2">
784
- <Building2 className="h-4 w-4" />
785
- {t('company')}
786
- </div>
787
- </SelectItem>
788
- </SelectContent>
789
- </Select>
790
- <FormMessage />
791
- </FormItem>
792
- )}
793
- />
794
-
795
- <FormField
796
- control={form.control}
797
- name="name"
798
- render={({ field }) => (
799
- <FormItem>
800
- <FormLabel>{t('name')}</FormLabel>
801
- <FormControl>
802
- <Input {...field} placeholder={t('namePlaceholder')} />
803
- </FormControl>
804
- <FormMessage />
805
- </FormItem>
806
- )}
807
- />
808
-
809
- <FormField
810
- control={form.control}
811
- name="status"
812
- render={({ field }) => (
813
- <FormItem>
814
- <FormLabel>{t('status')}</FormLabel>
815
- <Select
816
- onValueChange={field.onChange}
817
- defaultValue={field.value}
818
- >
819
- <FormControl>
820
- <SelectTrigger className="w-full">
821
- <SelectValue placeholder={t('selectStatus')} />
822
- </SelectTrigger>
823
- </FormControl>
824
- <SelectContent>
825
- <SelectItem value="active">{t('active')}</SelectItem>
826
- <SelectItem value="inactive">
827
- {t('inactive')}
828
- </SelectItem>
829
- </SelectContent>
830
- </Select>
831
- <FormMessage />
832
- </FormItem>
833
- )}
834
- />
835
-
836
- <div className="flex justify-end gap-2">
837
- <Button
838
- type="button"
839
- variant="outline"
840
- onClick={() => setIsDialogOpen(false)}
841
- >
842
- {t('cancel')}
843
- </Button>
844
- <Button type="submit">{t('createPerson')}</Button>
845
- </div>
846
- </form>
847
- </Form>
848
- </DialogContent>
849
- </Dialog>
850
-
851
- <Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
852
- <SheetContent className="sm:max-w-lg">
853
- <SheetHeader>
854
- <SheetTitle>{t('editPerson')}</SheetTitle>
855
- <SheetDescription>{t('editPersonDescription')}</SheetDescription>
856
- </SheetHeader>
857
- <Form {...editForm}>
858
- <form
859
- onSubmit={editForm.handleSubmit(onEdit)}
860
- className="mt-4 px-4 space-y-0"
861
- >
862
- <Tabs defaultValue="detalhes" className="w-full">
863
- <TabsList className="w-full">
864
- <TabsTrigger value="detalhes">
865
- {t('editTabDetails')}
866
- </TabsTrigger>
867
- <TabsTrigger value="dados">
868
- {t('editTabBaseData')}
869
- </TabsTrigger>
870
- <TabsTrigger value="contatos">
871
- {t('editTabContacts')}
872
- </TabsTrigger>
873
- <TabsTrigger value="enderecos">
874
- {t('editTabAddresses')}
875
- </TabsTrigger>
876
- <TabsTrigger value="documentos">
877
- {t('editTabDocuments')}
878
- </TabsTrigger>
879
- </TabsList>
880
- <TabsContent value="detalhes">
881
- <Card className="mt-4">
882
- <CardContent className="space-y-6 overflow-y-auto max-h-[62vh]">
883
- <div>
884
- <h3 className="mb-3 text-lg font-semibold">
885
- {t('dialogBasicInformationTitle')}
886
- </h3>
887
- <div className="grid gap-3 rounded-lg border p-4">
888
- <div className="flex items-center justify-between">
889
- <span className="text-sm text-muted-foreground">
890
- {t('type')}:
891
- </span>
892
- <Badge variant="outline">
893
- {editingPerson?.type === 'individual' ? (
894
- <>
895
- <User className="mr-1 h-3 w-3" />
896
- {t('individual')}
897
- </>
898
- ) : (
899
- <>
900
- <Building2 className="mr-1 h-3 w-3" />
901
- {t('company')}
902
- </>
903
- )}
904
- </Badge>
905
- </div>
906
- <div className="flex items-center justify-between">
907
- <span className="text-sm text-muted-foreground">
908
- {t('name')}:
909
- </span>
910
- <div className="flex items-center gap-2">
911
- <span className="text-sm">
912
- {editingPerson?.name || '-'}
913
- </span>
914
- {editingPerson?.name && (
915
- <CopyButton value={editingPerson.name} />
916
- )}
917
- </div>
918
- </div>
919
- <div className="flex items-center justify-between">
920
- <span className="text-sm text-muted-foreground">
921
- {t('status')}:
922
- </span>
923
- <Badge variant="outline">
924
- {editingPerson?.status === 'active'
925
- ? t('active')
926
- : t('inactive')}
927
- </Badge>
928
- </div>
929
- <div className="flex items-center justify-between">
930
- <span className="text-sm text-muted-foreground">
931
- {t('createdAt')}:
932
- </span>
933
- <span className="text-sm">
934
- {editingPerson?.created_at &&
935
- formatDate(
936
- editingPerson.created_at,
937
- getSettingValue,
938
- currentLocaleCode
939
- )}
940
- </span>
941
- </div>
942
- </div>
943
- </div>
944
-
945
- <div>
946
- <h3 className="mb-3 flex items-center gap-2 text-lg font-semibold">
947
- <Mail className="h-5 w-5" />
948
- {t('tabContacts')}
949
- </h3>
950
- {contacts && contacts.length > 0 ? (
951
- <div className="space-y-2 rounded-lg border p-4">
952
- {contacts.map((contact, idx) => (
953
- <div
954
- key={idx}
955
- className="flex items-center justify-between rounded-md border p-3"
956
- >
957
- <div className="flex items-center gap-3">
958
- <Badge variant="outline">
959
- {getContactTypeName(
960
- contact.contact_type_id
961
- )}
962
- </Badge>
963
- <span className="font-medium">
964
- {contact.value}
965
- </span>
966
- <CopyButton value={contact.value} />
967
- </div>
968
- {contact.is_primary && (
969
- <Badge variant="default">Principal</Badge>
970
- )}
971
- </div>
972
- ))}
973
- </div>
974
- ) : (
975
- <p className="rounded-lg border p-4 text-center text-sm text-muted-foreground">
976
- {t('noContacts')}
977
- </p>
978
- )}
979
- </div>
980
-
981
- <div>
982
- <h3 className="mb-3 flex items-center gap-2 text-lg font-semibold">
983
- <MapPin className="h-5 w-5" />
984
- {t('tabAddresses')}
985
- </h3>
986
- {addresses && addresses.length > 0 ? (
987
- <div className="space-y-2 rounded-lg border p-4">
988
- {addresses.map((address, idx) => (
989
- <div
990
- key={idx}
991
- className="flex flex-col gap-2 rounded-md border p-3"
992
- >
993
- <div className="flex items-center justify-between">
994
- <div className="flex items-center gap-2">
995
- <Badge variant="outline">
996
- {getAddressTypeName(
997
- address.address_type_id
998
- )}
999
- </Badge>
1000
- <CopyButton
1001
- value={`${address.line1}, ${address.city}, ${address.state}`}
1002
- />
1003
- </div>
1004
- {address.is_primary && (
1005
- <Badge variant="default">Principal</Badge>
1006
- )}
1007
- </div>
1008
- <div className="text-sm">
1009
- <p className="font-medium">{address.line1}</p>
1010
- <p className="text-muted-foreground">
1011
- {address.city}, {address.state}
1012
- </p>
1013
- </div>
1014
- </div>
1015
- ))}
1016
- </div>
1017
- ) : (
1018
- <p className="rounded-lg border p-4 text-center text-sm text-muted-foreground">
1019
- {t('noAddresses')}
1020
- </p>
1021
- )}
1022
- </div>
1023
-
1024
- <div>
1025
- <h3 className="mb-3 flex items-center gap-2 text-lg font-semibold">
1026
- <FileText className="h-5 w-5" />
1027
- {t('tabDocuments')}
1028
- </h3>
1029
- {documents && documents.length > 0 ? (
1030
- <div className="space-y-2 rounded-lg border p-4">
1031
- {documents.map((document, idx) => (
1032
- <div
1033
- key={idx}
1034
- className="flex items-center justify-between rounded-md border p-3"
1035
- >
1036
- <div className="flex items-center gap-3">
1037
- <Badge variant="outline">
1038
- {getDocumentTypeName(
1039
- document.document_type_id
1040
- )}
1041
- </Badge>
1042
- <span className="font-mono font-medium">
1043
- {document.value}
1044
- </span>
1045
- </div>
1046
- </div>
1047
- ))}
1048
- </div>
1049
- ) : (
1050
- <p className="rounded-lg border p-4 text-center text-sm text-muted-foreground">
1051
- {t('noDocuments')}
1052
- </p>
1053
- )}
1054
- </div>
1055
- </CardContent>
1056
- </Card>
1057
- </TabsContent>
1058
- <TabsContent value="dados">
1059
- <Card className="mt-4">
1060
- <CardContent className="space-y-4">
1061
- <FormField
1062
- control={editForm.control}
1063
- name="type"
1064
- render={({ field }) => (
1065
- <FormItem>
1066
- <FormLabel>{t('typeOfPerson')}</FormLabel>
1067
- <Select
1068
- onValueChange={field.onChange}
1069
- defaultValue={field.value}
1070
- >
1071
- <FormControl>
1072
- <SelectTrigger className="w-full">
1073
- <SelectValue placeholder={t('selectType')} />
1074
- </SelectTrigger>
1075
- </FormControl>
1076
- <SelectContent>
1077
- <SelectItem value="individual">
1078
- {t('individual')}
1079
- </SelectItem>
1080
- <SelectItem value="company">
1081
- {t('company')}
1082
- </SelectItem>
1083
- </SelectContent>
1084
- </Select>
1085
- <FormMessage />
1086
- </FormItem>
1087
- )}
1088
- />
1089
- <FormField
1090
- control={editForm.control}
1091
- name="name"
1092
- render={({ field }) => (
1093
- <FormItem>
1094
- <FormLabel>{t('name')}</FormLabel>
1095
- <FormControl>
1096
- <Input
1097
- {...field}
1098
- placeholder={t('namePlaceholder')}
1099
- />
1100
- </FormControl>
1101
- <FormMessage />
1102
- </FormItem>
1103
- )}
1104
- />
1105
- <FormField
1106
- control={editForm.control}
1107
- name="status"
1108
- render={({ field }) => (
1109
- <FormItem>
1110
- <FormLabel>{t('status')}</FormLabel>
1111
- <Select
1112
- onValueChange={field.onChange}
1113
- defaultValue={field.value}
1114
- >
1115
- <FormControl>
1116
- <SelectTrigger className="w-full">
1117
- <SelectValue
1118
- placeholder={t('selectStatus')}
1119
- />
1120
- </SelectTrigger>
1121
- </FormControl>
1122
- <SelectContent>
1123
- <SelectItem value="active">
1124
- {t('active')}
1125
- </SelectItem>
1126
- <SelectItem value="inactive">
1127
- {t('inactive')}
1128
- </SelectItem>
1129
- </SelectContent>
1130
- </Select>
1131
- <FormMessage />
1132
- </FormItem>
1133
- )}
1134
- />
1135
- </CardContent>
1136
- </Card>
1137
- </TabsContent>
1138
- <TabsContent value="contatos">
1139
- <div className="space-y-2 pt-4 max-h-[70vh] overflow-y-auto">
1140
- <div className="flex mb-6 items-center justify-between">
1141
- <h4 className="font-semibold">{t('tabContacts')}</h4>
1142
- <Button
1143
- type="button"
1144
- size="sm"
1145
- onClick={handleAddContact}
1146
- >
1147
- {t('addContact')}
1148
- </Button>
1149
- </div>
1150
- {contacts.length === 0 && (
1151
- <p className="text-muted-foreground text-sm">
1152
- {t('noContacts')}
1153
- </p>
1154
- )}
1155
- {contacts.map((c, idx) => (
1156
- <div
1157
- key={idx}
1158
- className="rounded-md border p-4 mb-4 bg-muted/40 flex flex-col gap-3"
1159
- >
1160
- <div>
1161
- <label className="block text-xs font-medium mb-1">
1162
- {t('contactType')}
1163
- </label>
1164
- <Select
1165
- value={String(c.contact_type_id)}
1166
- onValueChange={(v) =>
1167
- handleContactChange(
1168
- idx,
1169
- 'contact_type_id',
1170
- Number(v)
1171
- )
1172
- }
1173
- >
1174
- <SelectTrigger className="w-full">
1175
- <SelectValue
1176
- placeholder={t('selectContactType')}
1177
- />
1178
- </SelectTrigger>
1179
- <SelectContent>
1180
- {contactTypes.map((ct: any) => (
1181
- <SelectItem
1182
- key={ct.contact_type_id}
1183
- value={String(ct.contact_type_id)}
1184
- >
1185
- {ct.name}
1186
- </SelectItem>
1187
- ))}
1188
- </SelectContent>
1189
- </Select>
1190
- </div>
1191
- <div>
1192
- <label className="block text-xs font-medium mb-1">
1193
- {t('contactValue')}
1194
- </label>
1195
- <Input
1196
- className="w-full"
1197
- value={c.value}
1198
- onChange={(e) =>
1199
- handleContactChange(idx, 'value', e.target.value)
1200
- }
1201
- />
1202
- </div>
1203
- <div className="flex items-center gap-3 justify-between mt-2">
1204
- <label className="flex items-center gap-1 text-xs">
1205
- <input
1206
- type="checkbox"
1207
- checked={!!c.is_primary}
1208
- onChange={(e) =>
1209
- handleContactChange(
1210
- idx,
1211
- 'is_primary',
1212
- e.target.checked
1213
- )
1214
- }
1215
- />{' '}
1216
- {t('main')}
1217
- </label>
1218
- <Button
1219
- type="button"
1220
- size="icon"
1221
- variant="ghost"
1222
- onClick={() => handleRemoveContact(idx)}
1223
- >
1224
- <Trash2 className="w-4 h-4" />
1225
- </Button>
1226
- </div>
1227
- </div>
1228
- ))}
1229
- </div>
1230
- </TabsContent>
1231
- <TabsContent value="enderecos">
1232
- <div className="space-y-2 pt-4 max-h-[70vh] overflow-y-auto">
1233
- <div className="flex mb-6 items-center justify-between">
1234
- <h4 className="font-semibold">{t('editTabAddresses')}</h4>
1235
- <Button
1236
- type="button"
1237
- size="sm"
1238
- onClick={handleAddAddress}
1239
- >
1240
- {t('addAddress')}
1241
- </Button>
1242
- </div>
1243
- {addresses.length === 0 && (
1244
- <p className="text-muted-foreground text-sm">
1245
- {t('noAddresses')}
1246
- </p>
1247
- )}
1248
- {addresses.map((a, idx) => (
1249
- <div
1250
- key={idx}
1251
- className="rounded-md border p-4 mb-4 bg-muted/40 flex flex-col gap-3"
1252
- >
1253
- <div>
1254
- <label className="block text-xs font-medium mb-1">
1255
- {t('addressType')}
1256
- </label>
1257
- <Select
1258
- value={String(a.address_type_id)}
1259
- onValueChange={(v) =>
1260
- handleAddressChange(
1261
- idx,
1262
- 'address_type_id',
1263
- Number(v)
1264
- )
1265
- }
1266
- >
1267
- <SelectTrigger className="w-full">
1268
- <SelectValue
1269
- placeholder={t('selectAddressType')}
1270
- />
1271
- </SelectTrigger>
1272
- <SelectContent>
1273
- {addressTypes.map((at: any) => (
1274
- <SelectItem
1275
- key={at.address_type_id}
1276
- value={String(at.address_type_id)}
1277
- >
1278
- {at.name}
1279
- </SelectItem>
1280
- ))}
1281
- </SelectContent>
1282
- </Select>
1283
- </div>
1284
- <div>
1285
- <label className="block text-xs font-medium mb-1">
1286
- {t('zipCode')}
1287
- </label>
1288
- <Input
1289
- className="w-full"
1290
- value={a.postal_code || ''}
1291
- maxLength={9}
1292
- onChange={(e) => handleCEP(e, idx)}
1293
- placeholder={t('zipCodePlaceholder')}
1294
- />
1295
- </div>
1296
- <div>
1297
- <label className="block text-xs font-medium mb-1">
1298
- {t('address')}
1299
- </label>
1300
- <div className="relative">
1301
- <Input
1302
- className="w-full"
1303
- value={a.line1}
1304
- onChange={(e) =>
1305
- handleAddressChange(
1306
- idx,
1307
- 'line1',
1308
- e.target.value
1309
- )
1310
- }
1311
- placeholder={t('addressPlaceholder')}
1312
- disabled={loadingCEP[idx]}
1313
- />
1314
- {loadingCEP[idx] && (
1315
- <Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
1316
- )}
1317
- </div>
1318
- </div>
1319
- <div>
1320
- <label className="block text-xs font-medium mb-1">
1321
- {t('addressComplement')}
1322
- </label>
1323
- <Input
1324
- className="w-full"
1325
- value={a.line2}
1326
- onChange={(e) =>
1327
- handleAddressChange(idx, 'line2', e.target.value)
1328
- }
1329
- placeholder={t('addressComplementPlaceholder')}
1330
- />
1331
- </div>
1332
- <div className="flex flex-col md:flex-row gap-3">
1333
- <div className="flex-1">
1334
- <label className="block text-xs font-medium mb-1">
1335
- {t('addressCity')}
1336
- </label>
1337
- <div className="relative">
1338
- <Input
1339
- className="w-full"
1340
- value={a.city}
1341
- onChange={(e) =>
1342
- handleAddressChange(
1343
- idx,
1344
- 'city',
1345
- e.target.value
1346
- )
1347
- }
1348
- placeholder={t('addressCityPlaceholder')}
1349
- disabled={loadingCEP[idx]}
1350
- />
1351
- {loadingCEP[idx] && (
1352
- <Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
1353
- )}
1354
- </div>
1355
- </div>
1356
- <div className="w-full md:w-32">
1357
- <label className="block text-xs font-medium mb-1">
1358
- {t('addressState')}
1359
- </label>
1360
- <div className="relative">
1361
- <Input
1362
- className="w-full"
1363
- value={a.state}
1364
- onChange={(e) =>
1365
- handleAddressChange(
1366
- idx,
1367
- 'state',
1368
- e.target.value
1369
- )
1370
- }
1371
- placeholder={t('addressStatePlaceholder')}
1372
- disabled={loadingCEP[idx]}
1373
- />
1374
- {loadingCEP[idx] && (
1375
- <Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
1376
- )}
1377
- </div>
1378
- </div>
1379
- </div>
1380
- <div className="w-full">
1381
- <label className="block text-xs font-medium mb-1">
1382
- {t('addressCountry')}
1383
- </label>
1384
- <Select
1385
- value={a.country_code || ''}
1386
- onValueChange={(v) =>
1387
- handleAddressChange(idx, 'country_code', v)
1388
- }
1389
- >
1390
- <SelectTrigger className="w-full">
1391
- <SelectValue
1392
- placeholder={t('addressCountryPlaceholder')}
1393
- />
1394
- </SelectTrigger>
1395
- <SelectContent>
1396
- {COUNTRIES.map((country) => (
1397
- <SelectItem
1398
- key={country.code}
1399
- value={country.code}
1400
- >
1401
- {country.name}
1402
- </SelectItem>
1403
- ))}
1404
- </SelectContent>
1405
- </Select>
1406
- </div>
1407
- <div className="flex items-center gap-3 justify-between mt-2">
1408
- <label className="flex items-center gap-1 text-xs">
1409
- <input
1410
- type="checkbox"
1411
- checked={!!a.is_primary}
1412
- onChange={(e) =>
1413
- handleAddressChange(
1414
- idx,
1415
- 'is_primary',
1416
- e.target.checked
1417
- )
1418
- }
1419
- />{' '}
1420
- {t('main')}
1421
- </label>
1422
- <Button
1423
- type="button"
1424
- size="icon"
1425
- variant="ghost"
1426
- onClick={() => handleRemoveAddress(idx)}
1427
- >
1428
- <Trash2 className="w-4 h-4" />
1429
- </Button>
1430
- </div>
1431
- </div>
1432
- ))}
1433
- </div>
1434
- </TabsContent>
1435
- <TabsContent value="documentos">
1436
- <div className="space-y-2 pt-4 max-h-[70vh] overflow-y-auto">
1437
- <div className="flex mb-6 items-center justify-between">
1438
- <h4 className="font-semibold">{t('editTabDocuments')}</h4>
1439
- <Button
1440
- type="button"
1441
- size="sm"
1442
- onClick={handleAddDocument}
1443
- >
1444
- {t('addDocument')}
1445
- </Button>
1446
- </div>
1447
- {documents.length === 0 && (
1448
- <p className="text-muted-foreground text-sm">
1449
- {t('noDocuments')}
1450
- </p>
1451
- )}
1452
- {documents.map((d, idx) => (
1453
- <div
1454
- key={idx}
1455
- className="rounded-md border p-4 mb-4 bg-muted/40 flex flex-col gap-3"
1456
- >
1457
- <div>
1458
- <label className="block text-xs font-medium mb-1">
1459
- {t('documentType')}
1460
- </label>
1461
- <Select
1462
- value={String(d.document_type_id)}
1463
- onValueChange={(v) =>
1464
- handleDocumentChange(
1465
- idx,
1466
- 'document_type_id',
1467
- Number(v)
1468
- )
1469
- }
1470
- >
1471
- <SelectTrigger className="w-full">
1472
- <SelectValue placeholder="Selecione o tipo" />
1473
- </SelectTrigger>
1474
- <SelectContent>
1475
- {documentTypes.map((dt: any) => (
1476
- <SelectItem
1477
- key={dt.document_type_id}
1478
- value={String(dt.document_type_id)}
1479
- >
1480
- {dt.name}
1481
- </SelectItem>
1482
- ))}
1483
- </SelectContent>
1484
- </Select>
1485
- </div>
1486
- <div>
1487
- <label className="block text-xs font-medium mb-1">
1488
- {t('documentValue')}
1489
- </label>
1490
- <Input
1491
- className="w-full"
1492
- value={d.value}
1493
- onChange={(e) =>
1494
- handleDocumentChange(idx, 'value', e.target.value)
1495
- }
1496
- placeholder={t('documentValuePlaceholder')}
1497
- />
1498
- </div>
1499
- <div className="flex items-center justify-end mt-2">
1500
- <Button
1501
- type="button"
1502
- size="icon"
1503
- variant="ghost"
1504
- onClick={() => handleRemoveDocument(idx)}
1505
- >
1506
- <Trash2 className="w-4 h-4" />
1507
- </Button>
1508
- </div>
1509
- </div>
1510
- ))}
1511
- </div>
1512
- </TabsContent>
1513
- </Tabs>
1514
- <div className="flex justify-end gap-2 pt-6">
1515
- <Button
1516
- type="button"
1517
- variant="outline"
1518
- onClick={() => setIsSheetOpen(false)}
1519
- >
1520
- {t('cancel')}
1521
- </Button>
1522
- <Button type="submit">{t('saveChanges')}</Button>
1523
- </div>
1524
- </form>
1525
- </Form>
1526
- </SheetContent>
1527
- </Sheet>
1528
- </div>
1529
- );
1530
- }
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
+ }