@hed-hog/contact 0.0.303 → 0.0.305

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 (44) hide show
  1. package/README.md +225 -17
  2. package/dist/person/dto/account.dto.d.ts +5 -0
  3. package/dist/person/dto/account.dto.d.ts.map +1 -1
  4. package/dist/person/dto/account.dto.js +29 -0
  5. package/dist/person/dto/account.dto.js.map +1 -1
  6. package/dist/person/dto/import-preview.dto.d.ts +7 -0
  7. package/dist/person/dto/import-preview.dto.d.ts.map +1 -0
  8. package/dist/person/dto/import-preview.dto.js +7 -0
  9. package/dist/person/dto/import-preview.dto.js.map +1 -0
  10. package/dist/person/dto/import.dto.d.ts +15 -0
  11. package/dist/person/dto/import.dto.d.ts.map +1 -0
  12. package/dist/person/dto/import.dto.js +51 -0
  13. package/dist/person/dto/import.dto.js.map +1 -0
  14. package/dist/person/person.controller.d.ts +14 -0
  15. package/dist/person/person.controller.d.ts.map +1 -1
  16. package/dist/person/person.controller.js +53 -0
  17. package/dist/person/person.controller.js.map +1 -1
  18. package/dist/person/person.service.d.ts +19 -0
  19. package/dist/person/person.service.d.ts.map +1 -1
  20. package/dist/person/person.service.js +481 -67
  21. package/dist/person/person.service.js.map +1 -1
  22. package/dist/person-relation-type/person-relation-type.controller.d.ts +2 -2
  23. package/dist/person-relation-type/person-relation-type.service.d.ts +2 -2
  24. package/hedhog/data/route.yaml +6 -0
  25. package/hedhog/frontend/app/accounts/_components/account-form-sheet.tsx.ejs +2242 -484
  26. package/hedhog/frontend/app/accounts/_components/account-types.ts.ejs +51 -0
  27. package/hedhog/frontend/app/accounts/page.tsx.ejs +181 -16
  28. package/hedhog/frontend/app/contact-type/page.tsx.ejs +223 -29
  29. package/hedhog/frontend/app/document-type/page.tsx.ejs +248 -37
  30. package/hedhog/frontend/app/follow-ups/page.tsx.ejs +129 -19
  31. package/hedhog/frontend/app/person/_components/person-field-with-create.tsx.ejs +78 -212
  32. package/hedhog/frontend/app/person/_components/person-form-sheet.tsx.ejs +760 -178
  33. package/hedhog/frontend/app/person/_components/person-import-sheet.tsx.ejs +1120 -0
  34. package/hedhog/frontend/app/person/_components/person-interaction-dialog.tsx.ejs +171 -4
  35. package/hedhog/frontend/app/person/page.tsx.ejs +17 -0
  36. package/hedhog/frontend/app/pipeline/_components/lead-proposals-tab.tsx.ejs +696 -82
  37. package/hedhog/frontend/messages/en.json +140 -2
  38. package/hedhog/frontend/messages/pt.json +147 -9
  39. package/package.json +5 -5
  40. package/src/person/dto/account.dto.ts +31 -0
  41. package/src/person/dto/import-preview.dto.ts +6 -0
  42. package/src/person/dto/import.dto.ts +61 -0
  43. package/src/person/person.controller.ts +74 -12
  44. package/src/person/person.service.ts +615 -68
@@ -0,0 +1,1120 @@
1
+ 'use client';
2
+
3
+ import { Alert, AlertDescription } from '@/components/ui/alert';
4
+ import { Badge } from '@/components/ui/badge';
5
+ import { Button } from '@/components/ui/button';
6
+ import { EntityPicker } from '@/components/ui/entity-picker';
7
+ import { Input } from '@/components/ui/input';
8
+ import { Label } from '@/components/ui/label';
9
+ import {
10
+ Select,
11
+ SelectContent,
12
+ SelectItem,
13
+ SelectTrigger,
14
+ SelectValue,
15
+ } from '@/components/ui/select';
16
+ import {
17
+ Sheet,
18
+ SheetContent,
19
+ SheetDescription,
20
+ SheetHeader,
21
+ SheetTitle,
22
+ } from '@/components/ui/sheet';
23
+ import { Skeleton } from '@/components/ui/skeleton';
24
+ import {
25
+ Table,
26
+ TableBody,
27
+ TableCell,
28
+ TableHead,
29
+ TableHeader,
30
+ TableRow,
31
+ } from '@/components/ui/table';
32
+ import { cn } from '@/lib/utils';
33
+ import { useApp } from '@hed-hog/next-app-provider';
34
+ import {
35
+ AlertTriangle,
36
+ CheckCircle2,
37
+ ChevronLeft,
38
+ ChevronRight,
39
+ FileSpreadsheet,
40
+ FileText,
41
+ Loader2,
42
+ Upload,
43
+ X,
44
+ XCircle,
45
+ } from 'lucide-react';
46
+ import { useTranslations } from 'next-intl';
47
+ import { useCallback, useMemo, useRef, useState } from 'react';
48
+
49
+ // ─── Types ──────────────────────────────────────────────────────────────────
50
+
51
+ type ImportPreview = {
52
+ fileName: string;
53
+ totalEstimated: number;
54
+ columns: string[];
55
+ preview: Record<string, string>[];
56
+ };
57
+
58
+ type ImportResult = {
59
+ imported: number;
60
+ skipped: number;
61
+ errors: Array<{ row: number; message: string }>;
62
+ };
63
+
64
+ type ColumnMapping = Record<string, string>;
65
+
66
+ type CompanyOption = {
67
+ id: number;
68
+ name: string;
69
+ trade_name?: string | null;
70
+ };
71
+
72
+ type WizardStep = 'upload' | 'preview' | 'mapping' | 'confirm' | 'result';
73
+
74
+ // ─── CRM Field Definitions ───────────────────────────────────────────────────
75
+
76
+ type CrmFieldDef = {
77
+ value: string;
78
+ labelKey: string;
79
+ allowMultiple?: boolean;
80
+ };
81
+
82
+ const CRM_FIELDS: CrmFieldDef[] = [
83
+ { value: '_ignore', labelKey: 'importMappingIgnore' },
84
+ { value: 'name', labelKey: 'importFieldName' },
85
+ { value: 'type', labelKey: 'importFieldType' },
86
+ { value: 'status', labelKey: 'importFieldStatus' },
87
+ { value: 'email', labelKey: 'importFieldEmail' },
88
+ { value: 'phone', labelKey: 'importFieldPhone' },
89
+ { value: 'mobile', labelKey: 'importFieldMobile' },
90
+ { value: 'cpf', labelKey: 'importFieldCpf' },
91
+ { value: 'cnpj', labelKey: 'importFieldCnpj' },
92
+ { value: 'job_title', labelKey: 'importFieldJobTitle' },
93
+ { value: 'company_name', labelKey: 'importFieldCompanyName' },
94
+ { value: 'trade_name', labelKey: 'importFieldTradeName' },
95
+ { value: 'website', labelKey: 'importFieldWebsite' },
96
+ { value: 'notes', labelKey: 'importFieldNotes' },
97
+ { value: 'source', labelKey: 'importFieldSource' },
98
+ { value: 'address_street', labelKey: 'importFieldAddressStreet' },
99
+ { value: 'address_city', labelKey: 'importFieldAddressCity' },
100
+ { value: 'address_state', labelKey: 'importFieldAddressState' },
101
+ { value: 'address_zip', labelKey: 'importFieldAddressZip' },
102
+ { value: 'address_country', labelKey: 'importFieldAddressCountry' },
103
+ ];
104
+
105
+ // Fields that should not be duplicated (mapped to more than one CSV column)
106
+ const UNIQUE_FIELDS = CRM_FIELDS.filter((f) => f.value !== '_ignore').map(
107
+ (f) => f.value
108
+ );
109
+
110
+ // ─── Step indicator ──────────────────────────────────────────────────────────
111
+
112
+ const STEPS: { key: WizardStep; labelKey: string; icon: React.ElementType }[] =
113
+ [
114
+ { key: 'upload', labelKey: 'importStepUpload', icon: Upload },
115
+ { key: 'preview', labelKey: 'importStepPreview', icon: FileText },
116
+ { key: 'mapping', labelKey: 'importStepMapping', icon: FileSpreadsheet },
117
+ { key: 'confirm', labelKey: 'importStepConfirm', icon: CheckCircle2 },
118
+ { key: 'result', labelKey: 'importStepResult', icon: CheckCircle2 },
119
+ ];
120
+
121
+ function StepIndicator({ current }: { current: WizardStep }) {
122
+ const t = useTranslations('contact.ContactPage');
123
+ const currentIndex = STEPS.findIndex((s) => s.key === current);
124
+
125
+ return (
126
+ <div className="grid grid-cols-5 gap-1.5 px-4 py-3 border-b bg-muted/30">
127
+ {STEPS.map((step, index) => {
128
+ const Icon = step.icon;
129
+ const isCurrent = step.key === current;
130
+ const isPast = index < currentIndex;
131
+
132
+ return (
133
+ <div
134
+ key={step.key}
135
+ className={cn(
136
+ 'flex flex-col items-center gap-1 rounded-xl border px-1.5 py-2 text-center transition-colors',
137
+ isCurrent
138
+ ? 'border-primary bg-primary/10 text-primary'
139
+ : isPast
140
+ ? 'border-green-500/30 bg-green-500/10 text-green-600'
141
+ : 'border-border/50 bg-transparent text-muted-foreground'
142
+ )}
143
+ >
144
+ {isPast ? (
145
+ <CheckCircle2 className="size-3.5" />
146
+ ) : (
147
+ <Icon className="size-3.5" />
148
+ )}
149
+ <span className="text-[10px] font-medium leading-tight hidden sm:block">
150
+ {t(step.labelKey as any)}
151
+ </span>
152
+ </div>
153
+ );
154
+ })}
155
+ </div>
156
+ );
157
+ }
158
+
159
+ // ─── Step 0: Upload ──────────────────────────────────────────────────────────
160
+
161
+ function UploadStep({
162
+ file,
163
+ onFileChange,
164
+ error,
165
+ }: {
166
+ file: File | null;
167
+ onFileChange: (f: File | null) => void;
168
+ error: string | null;
169
+ }) {
170
+ const t = useTranslations('contact.ContactPage');
171
+ const inputRef = useRef<HTMLInputElement>(null);
172
+
173
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
174
+ const selected = e.target.files?.[0] ?? null;
175
+ if (selected) {
176
+ const ext = selected.name.split('.').pop()?.toLowerCase();
177
+ if (ext !== 'csv') {
178
+ onFileChange(null);
179
+ return;
180
+ }
181
+ }
182
+ onFileChange(selected);
183
+ e.target.value = '';
184
+ };
185
+
186
+ return (
187
+ <div className="space-y-4">
188
+ <label
189
+ className={cn(
190
+ 'flex cursor-pointer flex-col items-center justify-center gap-3 rounded-2xl border-2 border-dashed px-4 py-10 text-center transition-colors',
191
+ file
192
+ ? 'border-primary/40 bg-primary/5'
193
+ : 'border-border hover:border-primary/40 hover:bg-accent/20'
194
+ )}
195
+ >
196
+ <div
197
+ className={cn(
198
+ 'flex h-12 w-12 items-center justify-center rounded-xl border',
199
+ file ? 'border-primary/30 bg-primary/10' : 'border-border bg-muted'
200
+ )}
201
+ >
202
+ <FileSpreadsheet
203
+ className={cn(
204
+ 'size-6',
205
+ file ? 'text-primary' : 'text-muted-foreground'
206
+ )}
207
+ />
208
+ </div>
209
+
210
+ {file ? (
211
+ <div className="space-y-1">
212
+ <p className="text-sm font-semibold text-foreground">{file.name}</p>
213
+ <p className="text-xs text-muted-foreground">
214
+ {t('importFileSelected')}
215
+ </p>
216
+ </div>
217
+ ) : (
218
+ <div className="space-y-1">
219
+ <p className="text-sm font-semibold text-foreground">
220
+ {t('importDropzoneLabel')}
221
+ </p>
222
+ <p className="text-xs text-muted-foreground">
223
+ {t('importDropzoneHint')}
224
+ </p>
225
+ </div>
226
+ )}
227
+
228
+ {file && (
229
+ <Button
230
+ type="button"
231
+ variant="outline"
232
+ size="sm"
233
+ className="h-7 rounded-lg text-xs"
234
+ onClick={(e) => {
235
+ e.preventDefault();
236
+ inputRef.current?.click();
237
+ }}
238
+ >
239
+ <Upload className="mr-1.5 h-3 w-3" />
240
+ {t('importDropzoneChange')}
241
+ </Button>
242
+ )}
243
+
244
+ <input
245
+ ref={inputRef}
246
+ type="file"
247
+ accept=".csv,text/csv"
248
+ className="hidden"
249
+ onChange={handleFileChange}
250
+ />
251
+ </label>
252
+
253
+ {error && (
254
+ <Alert variant="destructive" className="py-2">
255
+ <AlertTriangle className="h-4 w-4" />
256
+ <AlertDescription className="text-xs">{error}</AlertDescription>
257
+ </Alert>
258
+ )}
259
+ </div>
260
+ );
261
+ }
262
+
263
+ // ─── Step 1: Preview ─────────────────────────────────────────────────────────
264
+
265
+ function PreviewStep({
266
+ preview,
267
+ isLoading,
268
+ error,
269
+ }: {
270
+ preview: ImportPreview | null;
271
+ isLoading: boolean;
272
+ error: string | null;
273
+ }) {
274
+ const t = useTranslations('contact.ContactPage');
275
+ if (isLoading) {
276
+ return (
277
+ <div className="space-y-3">
278
+ <Skeleton className="h-5 w-32" />
279
+ <Skeleton className="h-40 w-full rounded-xl" />
280
+ </div>
281
+ );
282
+ }
283
+
284
+ if (error) {
285
+ return (
286
+ <Alert variant="destructive" className="py-2">
287
+ <AlertTriangle className="h-4 w-4" />
288
+ <AlertDescription className="text-xs">{error}</AlertDescription>
289
+ </Alert>
290
+ );
291
+ }
292
+
293
+ if (!preview) return null;
294
+
295
+ return (
296
+ <div className="space-y-4">
297
+ <div className="flex flex-wrap gap-3">
298
+ <div className="flex items-center gap-2 rounded-xl border border-border/70 bg-muted/30 px-3 py-2">
299
+ <FileText className="h-4 w-4 text-muted-foreground" />
300
+ <div>
301
+ <p className="text-[10px] font-medium tracking-widest text-muted-foreground uppercase">
302
+ {t('importConfirmFile')}
303
+ </p>
304
+ <p className="text-xs font-semibold text-foreground truncate max-w-40">
305
+ {preview.fileName}
306
+ </p>
307
+ </div>
308
+ </div>
309
+ <div className="flex items-center gap-2 rounded-xl border border-border/70 bg-muted/30 px-3 py-2">
310
+ <FileSpreadsheet className="h-4 w-4 text-muted-foreground" />
311
+ <div>
312
+ <p className="text-[10px] font-medium tracking-widest text-muted-foreground uppercase">
313
+ {t('importTotalEstimated')}
314
+ </p>
315
+ <p className="text-xs font-semibold text-foreground">
316
+ {preview.totalEstimated.toLocaleString()}
317
+ </p>
318
+ </div>
319
+ </div>
320
+ <div className="flex items-center gap-2 rounded-xl border border-border/70 bg-muted/30 px-3 py-2">
321
+ <CheckCircle2 className="h-4 w-4 text-muted-foreground" />
322
+ <div>
323
+ <p className="text-[10px] font-medium tracking-widest text-muted-foreground uppercase">
324
+ {t('importColumnsDetected')}
325
+ </p>
326
+ <p className="text-xs font-semibold text-foreground">
327
+ {preview.columns.length}
328
+ </p>
329
+ </div>
330
+ </div>
331
+ </div>
332
+
333
+ <div>
334
+ <p className="mb-2 text-xs font-medium text-muted-foreground">
335
+ {t('importPreviewDescription')}
336
+ </p>
337
+ <div className="overflow-x-auto rounded-xl border border-border/70">
338
+ <Table>
339
+ <TableHeader>
340
+ <TableRow className="bg-muted/40">
341
+ {preview.columns.map((col) => (
342
+ <TableHead
343
+ key={col}
344
+ className="whitespace-nowrap text-xs font-semibold"
345
+ >
346
+ {col}
347
+ </TableHead>
348
+ ))}
349
+ </TableRow>
350
+ </TableHeader>
351
+ <TableBody>
352
+ {preview.preview.slice(0, 5).map((row, i) => (
353
+ <TableRow key={i} className="text-xs">
354
+ {preview.columns.map((col) => (
355
+ <TableCell key={col} className="max-w-32 truncate py-1.5">
356
+ {row[col] || (
357
+ <span className="text-muted-foreground/50">—</span>
358
+ )}
359
+ </TableCell>
360
+ ))}
361
+ </TableRow>
362
+ ))}
363
+ </TableBody>
364
+ </Table>
365
+ </div>
366
+ </div>
367
+ </div>
368
+ );
369
+ }
370
+
371
+ // ─── Step 2: Mapping ─────────────────────────────────────────────────────────
372
+
373
+ function MappingStep({
374
+ columns,
375
+ mapping,
376
+ onMappingChange,
377
+ validationError,
378
+ }: {
379
+ columns: string[];
380
+ mapping: ColumnMapping;
381
+ onMappingChange: (mapping: ColumnMapping) => void;
382
+ validationError: string | null;
383
+ }) {
384
+ const t = useTranslations('contact.ContactPage');
385
+ const mappedValues = useMemo(
386
+ () => Object.values(mapping).filter((v) => v !== '_ignore'),
387
+ [mapping]
388
+ );
389
+
390
+ const duplicateFields = useMemo(() => {
391
+ const counts: Record<string, number> = {};
392
+ for (const val of mappedValues) {
393
+ counts[val] = (counts[val] ?? 0) + 1;
394
+ }
395
+ return Object.entries(counts)
396
+ .filter(([, count]) => count > 1)
397
+ .map(([field]) => field);
398
+ }, [mappedValues]);
399
+
400
+ const handleChange = (csvCol: string, crmField: string) => {
401
+ onMappingChange({ ...mapping, [csvCol]: crmField });
402
+ };
403
+
404
+ return (
405
+ <div className="space-y-3">
406
+ {validationError && (
407
+ <Alert variant="destructive" className="py-2">
408
+ <AlertTriangle className="h-4 w-4" />
409
+ <AlertDescription className="text-xs">
410
+ {validationError}
411
+ </AlertDescription>
412
+ </Alert>
413
+ )}
414
+
415
+ {duplicateFields.length > 0 && (
416
+ <Alert className="border-amber-500/30 bg-amber-500/10 py-2">
417
+ <AlertTriangle className="h-4 w-4 text-amber-600" />
418
+ <AlertDescription className="text-xs text-amber-700">
419
+ {duplicateFields
420
+ .map((field) => {
421
+ const fieldDef = CRM_FIELDS.find((f) => f.value === field);
422
+ const label = fieldDef ? t(fieldDef.labelKey as any) : field;
423
+ return t('importMappingDuplicateWarning', { field: label });
424
+ })
425
+ .join(' ')}
426
+ </AlertDescription>
427
+ </Alert>
428
+ )}
429
+
430
+ <div className="rounded-xl border border-border/70 overflow-hidden">
431
+ <div className="grid grid-cols-2 gap-0 bg-muted/40 px-3 py-2 border-b text-xs font-semibold text-muted-foreground uppercase tracking-widest">
432
+ <span>{t('importMappingColumnLabel')}</span>
433
+ <span>{t('importMappingFieldLabel')}</span>
434
+ </div>
435
+ <div className="divide-y divide-border/50">
436
+ {columns.map((col) => {
437
+ const currentValue = mapping[col] ?? '_ignore';
438
+ const isDuplicate =
439
+ currentValue !== '_ignore' &&
440
+ UNIQUE_FIELDS.includes(currentValue) &&
441
+ duplicateFields.includes(currentValue);
442
+
443
+ return (
444
+ <div
445
+ key={col}
446
+ className={cn(
447
+ 'grid grid-cols-2 items-center gap-3 px-3 py-2',
448
+ isDuplicate && 'bg-amber-500/5'
449
+ )}
450
+ >
451
+ <div className="flex items-center gap-2 min-w-0">
452
+ {isDuplicate && (
453
+ <AlertTriangle className="h-3 w-3 shrink-0 text-amber-500" />
454
+ )}
455
+ <span className="truncate text-sm font-medium">{col}</span>
456
+ {currentValue === '_ignore' && (
457
+ <Badge
458
+ variant="outline"
459
+ className="shrink-0 border-border/50 text-[10px] text-muted-foreground px-1.5 py-0"
460
+ >
461
+ {t('importMappingIgnore')}
462
+ </Badge>
463
+ )}
464
+ </div>
465
+ <Select
466
+ value={currentValue}
467
+ onValueChange={(val) => handleChange(col, val)}
468
+ >
469
+ <SelectTrigger
470
+ className={cn(
471
+ 'h-8 text-xs w-full',
472
+ isDuplicate && 'border-amber-500/50 bg-amber-500/5'
473
+ )}
474
+ >
475
+ <SelectValue />
476
+ </SelectTrigger>
477
+ <SelectContent>
478
+ {CRM_FIELDS.map((field) => (
479
+ <SelectItem
480
+ key={field.value}
481
+ value={field.value}
482
+ className="text-xs"
483
+ >
484
+ {t(field.labelKey as any)}
485
+ </SelectItem>
486
+ ))}
487
+ </SelectContent>
488
+ </Select>
489
+ </div>
490
+ );
491
+ })}
492
+ </div>
493
+ </div>
494
+ </div>
495
+ );
496
+ }
497
+
498
+ // ─── Step 3: Confirm ─────────────────────────────────────────────────────────
499
+
500
+ function ConfirmStep({
501
+ preview,
502
+ mapping,
503
+ companyId,
504
+ onCompanyChange,
505
+ }: {
506
+ preview: ImportPreview;
507
+ mapping: ColumnMapping;
508
+ companyId: number | null;
509
+ onCompanyChange: (id: number | null) => void;
510
+ }) {
511
+ const t = useTranslations('contact.ContactPage');
512
+ const { request } = useApp();
513
+ const mappedFields = Object.entries(mapping).filter(
514
+ ([, v]) => v !== '_ignore'
515
+ );
516
+
517
+ return (
518
+ <div className="space-y-4">
519
+ {/* Summary cards */}
520
+ <div className="space-y-2">
521
+ <div className="flex items-center justify-between rounded-xl border border-border/70 bg-muted/30 px-3 py-2">
522
+ <span className="text-xs text-muted-foreground">
523
+ {t('importConfirmFile')}
524
+ </span>
525
+ <span className="text-xs font-semibold truncate max-w-48">
526
+ {preview.fileName}
527
+ </span>
528
+ </div>
529
+ <div className="flex items-center justify-between rounded-xl border border-border/70 bg-muted/30 px-3 py-2">
530
+ <span className="text-xs text-muted-foreground">
531
+ {t('importTotalEstimated')}
532
+ </span>
533
+ <span className="text-xs font-semibold">
534
+ {preview.totalEstimated.toLocaleString()}
535
+ </span>
536
+ </div>
537
+ <div className="flex items-center justify-between rounded-xl border border-border/70 bg-muted/30 px-3 py-2">
538
+ <span className="text-xs text-muted-foreground">
539
+ {t('importConfirmMappedFields')}
540
+ </span>
541
+ <span className="text-xs font-semibold">{mappedFields.length}</span>
542
+ </div>
543
+ </div>
544
+
545
+ {/* Mapping summary */}
546
+ <div className="rounded-xl border border-border/70 overflow-hidden">
547
+ <div className="bg-muted/40 px-3 py-2 border-b">
548
+ <p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
549
+ {t('importMappingTitle')}
550
+ </p>
551
+ </div>
552
+ <div className="divide-y divide-border/50 max-h-36 overflow-y-auto">
553
+ {mappedFields.map(([col, field]) => {
554
+ const fieldDef = CRM_FIELDS.find((f) => f.value === field);
555
+ return (
556
+ <div
557
+ key={col}
558
+ className="flex items-center justify-between px-3 py-1.5"
559
+ >
560
+ <span className="text-xs text-muted-foreground truncate">
561
+ {col}
562
+ </span>
563
+ <Badge
564
+ variant="outline"
565
+ className="text-[10px] border-primary/30 bg-primary/5 text-primary px-1.5 py-0"
566
+ >
567
+ {fieldDef ? t(fieldDef.labelKey as any) : field}
568
+ </Badge>
569
+ </div>
570
+ );
571
+ })}
572
+ </div>
573
+ </div>
574
+
575
+ {/* Optional company */}
576
+ <div className="space-y-2">
577
+ <p className="text-xs font-medium text-foreground">
578
+ {t('importConfirmCompanyLabel')}
579
+ </p>
580
+ <EntityPicker
581
+ placeholder={t('importConfirmCompanyPlaceholder')}
582
+ value={companyId}
583
+ clearable
584
+ allowEmptySelection
585
+ emptySelectionLabel={t('importConfirmNoCompany')}
586
+ createTitle={t('importCreateCompanyTitle')}
587
+ createDescription={t('importCreateCompanyDescription')}
588
+ onChange={(val) => {
589
+ onCompanyChange(val ? Number(val) : null);
590
+ }}
591
+ getOptionValue={(opt) => (opt as any).id}
592
+ getOptionLabel={(opt) => (opt as any).name ?? ''}
593
+ getOptionDescription={(opt) => (opt as any).trade_name ?? undefined}
594
+ loadOptions={async ({ page, pageSize, search }) => {
595
+ const params = new URLSearchParams({
596
+ page: String(page),
597
+ pageSize: String(pageSize),
598
+ type: 'company',
599
+ });
600
+ if (search) params.set('search', search);
601
+ const res = await request<{
602
+ data: CompanyOption[];
603
+ total: number;
604
+ }>({ url: `/person?${params}`, method: 'GET' });
605
+ return {
606
+ items: res.data.data ?? [],
607
+ hasMore: (res.data.total ?? 0) > page * pageSize,
608
+ };
609
+ }}
610
+ mapSearchToCreateValues={(search) => ({ name: search })}
611
+ onCreate={async (values) => {
612
+ const res = await request<CompanyOption>({
613
+ url: '/person/accounts',
614
+ method: 'POST',
615
+ data: {
616
+ name: values.name,
617
+ trade_name: values.trade_name || null,
618
+ status: 'active',
619
+ },
620
+ });
621
+ return res.data as unknown as CompanyOption &
622
+ Record<string, unknown>;
623
+ }}
624
+ renderCreateContent={(ctx) => (
625
+ <div className="mt-6 space-y-4">
626
+ <div className="space-y-2">
627
+ <Label htmlFor="new-company-name">
628
+ {t('importCreateCompanyName')}
629
+ <span className="ml-1 text-destructive">*</span>
630
+ </Label>
631
+ <Input
632
+ id="new-company-name"
633
+ value={ctx.values.name ?? ''}
634
+ placeholder={t('importCreateCompanyNamePlaceholder')}
635
+ onChange={(e) => ctx.setValue('name', e.target.value)}
636
+ onKeyDown={(e) => {
637
+ if (e.key === 'Enter' && ctx.values.name?.trim()) {
638
+ e.preventDefault();
639
+ void ctx.submitCreate();
640
+ }
641
+ }}
642
+ />
643
+ </div>
644
+ <div className="space-y-2">
645
+ <Label htmlFor="new-company-trade-name">
646
+ {t('importCreateCompanyTradeName')}
647
+ </Label>
648
+ <Input
649
+ id="new-company-trade-name"
650
+ value={ctx.values.trade_name ?? ''}
651
+ placeholder={t('importCreateCompanyTradeNamePlaceholder')}
652
+ onChange={(e) => ctx.setValue('trade_name', e.target.value)}
653
+ />
654
+ </div>
655
+ <div className="flex justify-end gap-2 pt-2">
656
+ <Button
657
+ type="button"
658
+ variant="outline"
659
+ onClick={ctx.closeCreate}
660
+ >
661
+ {t('cancel')}
662
+ </Button>
663
+ <Button
664
+ type="button"
665
+ disabled={ctx.isCreating || !ctx.values.name?.trim()}
666
+ onClick={() => void ctx.submitCreate()}
667
+ >
668
+ {ctx.isCreating && (
669
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
670
+ )}
671
+ {t('importCreateCompanySave')}
672
+ </Button>
673
+ </div>
674
+ </div>
675
+ )}
676
+ />
677
+ </div>
678
+ </div>
679
+ );
680
+ }
681
+
682
+ // ─── Step 4: Result ──────────────────────────────────────────────────────────
683
+
684
+ function ResultStep({
685
+ result,
686
+ isLoading,
687
+ error,
688
+ }: {
689
+ result: ImportResult | null;
690
+ isLoading: boolean;
691
+ error: string | null;
692
+ }) {
693
+ const t = useTranslations('contact.ContactPage');
694
+ if (isLoading) {
695
+ return (
696
+ <div className="flex flex-col items-center justify-center gap-4 py-12 text-center">
697
+ <Loader2 className="h-10 w-10 animate-spin text-primary" />
698
+ <p className="text-sm font-medium text-muted-foreground">
699
+ {t('importStart')}…
700
+ </p>
701
+ </div>
702
+ );
703
+ }
704
+
705
+ if (error) {
706
+ return (
707
+ <Alert variant="destructive" className="py-2">
708
+ <XCircle className="h-4 w-4" />
709
+ <AlertDescription className="text-xs">{error}</AlertDescription>
710
+ </Alert>
711
+ );
712
+ }
713
+
714
+ if (!result) return null;
715
+
716
+ const hasErrors = result.errors.length > 0;
717
+
718
+ return (
719
+ <div className="space-y-4">
720
+ <div
721
+ className={cn(
722
+ 'flex items-center gap-3 rounded-xl border px-4 py-3',
723
+ !hasErrors
724
+ ? 'border-green-500/30 bg-green-500/10'
725
+ : 'border-amber-500/30 bg-amber-500/10'
726
+ )}
727
+ >
728
+ {!hasErrors ? (
729
+ <CheckCircle2 className="h-5 w-5 shrink-0 text-green-600" />
730
+ ) : (
731
+ <AlertTriangle className="h-5 w-5 shrink-0 text-amber-600" />
732
+ )}
733
+ <p
734
+ className={cn(
735
+ 'text-sm font-semibold',
736
+ !hasErrors ? 'text-green-700' : 'text-amber-700'
737
+ )}
738
+ >
739
+ {!hasErrors
740
+ ? t('importResultSuccess')
741
+ : t('importResultPartial', {
742
+ imported: result.imported,
743
+ errors: result.errors.length,
744
+ })}
745
+ </p>
746
+ </div>
747
+
748
+ <div className="grid grid-cols-3 gap-2">
749
+ <div className="flex flex-col items-center rounded-xl border border-green-500/20 bg-green-500/10 px-3 py-3 text-center">
750
+ <span className="text-xl font-bold text-green-700">
751
+ {result.imported}
752
+ </span>
753
+ <span className="text-[11px] text-green-600">
754
+ {t('importResultImported')}
755
+ </span>
756
+ </div>
757
+ <div className="flex flex-col items-center rounded-xl border border-slate-500/20 bg-slate-500/10 px-3 py-3 text-center">
758
+ <span className="text-xl font-bold text-slate-600">
759
+ {result.skipped}
760
+ </span>
761
+ <span className="text-[11px] text-slate-500">
762
+ {t('importResultSkipped')}
763
+ </span>
764
+ </div>
765
+ <div className="flex flex-col items-center rounded-xl border border-red-500/20 bg-red-500/10 px-3 py-3 text-center">
766
+ <span className="text-xl font-bold text-red-600">
767
+ {result.errors.length}
768
+ </span>
769
+ <span className="text-[11px] text-red-500">
770
+ {t('importResultErrors')}
771
+ </span>
772
+ </div>
773
+ </div>
774
+
775
+ {hasErrors && (
776
+ <div className="space-y-1.5">
777
+ <p className="text-xs font-semibold text-muted-foreground uppercase tracking-widest">
778
+ {t('importResultErrorsLabel')}
779
+ </p>
780
+ <div className="max-h-40 overflow-y-auto rounded-xl border border-red-500/20 divide-y divide-border/50">
781
+ {result.errors.slice(0, 50).map((err) => (
782
+ <div key={err.row} className="flex items-start gap-2 px-3 py-1.5">
783
+ <span className="shrink-0 text-[10px] font-semibold text-red-500 mt-0.5">
784
+ {t('importResultRow', { row: err.row })}
785
+ </span>
786
+ <span className="text-xs text-muted-foreground">
787
+ {err.message}
788
+ </span>
789
+ </div>
790
+ ))}
791
+ </div>
792
+ </div>
793
+ )}
794
+ </div>
795
+ );
796
+ }
797
+
798
+ // ─── Main Sheet ──────────────────────────────────────────────────────────────
799
+
800
+ export type PersonImportSheetProps = {
801
+ open: boolean;
802
+ onOpenChange: (open: boolean) => void;
803
+ onSuccess: () => void;
804
+ initialCompanyId?: number | null;
805
+ };
806
+
807
+ export function PersonImportSheet({
808
+ open,
809
+ onOpenChange,
810
+ onSuccess,
811
+ initialCompanyId = null,
812
+ }: PersonImportSheetProps) {
813
+ const t = useTranslations('contact.ContactPage');
814
+ const { request } = useApp();
815
+
816
+ // Wizard state
817
+ const [step, setStep] = useState<WizardStep>('upload');
818
+
819
+ // Step 0: Upload
820
+ const [file, setFile] = useState<File | null>(null);
821
+ const [uploadError, setUploadError] = useState<string | null>(null);
822
+
823
+ // Step 1: Preview
824
+ const [preview, setPreview] = useState<ImportPreview | null>(null);
825
+ const [previewLoading, setPreviewLoading] = useState(false);
826
+ const [previewError, setPreviewError] = useState<string | null>(null);
827
+
828
+ // Step 2: Mapping
829
+ const [mapping, setMapping] = useState<ColumnMapping>({});
830
+ const [mappingError, setMappingError] = useState<string | null>(null);
831
+
832
+ // Step 3: Confirm
833
+ const [companyId, setCompanyId] = useState<number | null>(initialCompanyId);
834
+
835
+ // Step 4: Result
836
+ const [result, setResult] = useState<ImportResult | null>(null);
837
+ const [importLoading, setImportLoading] = useState(false);
838
+ const [importError, setImportError] = useState<string | null>(null);
839
+
840
+ // ── Reset on open ──
841
+ const handleOpenChange = useCallback(
842
+ (nextOpen: boolean) => {
843
+ if (!nextOpen) {
844
+ // Reset all state when closing
845
+ setStep('upload');
846
+ setFile(null);
847
+ setUploadError(null);
848
+ setPreview(null);
849
+ setPreviewError(null);
850
+ setPreviewLoading(false);
851
+ setMapping({});
852
+ setMappingError(null);
853
+ setCompanyId(initialCompanyId);
854
+ setResult(null);
855
+ setImportError(null);
856
+ setImportLoading(false);
857
+ }
858
+ onOpenChange(nextOpen);
859
+ },
860
+ [onOpenChange]
861
+ );
862
+
863
+ // ── Auto-initialise mapping from columns ──
864
+ const initMapping = useCallback((columns: string[]) => {
865
+ const initial: ColumnMapping = {};
866
+ columns.forEach((col) => {
867
+ initial[col] = '_ignore';
868
+ });
869
+ setMapping(initial);
870
+ }, []);
871
+
872
+ // ── Navigation ──
873
+ const canGoNext = (): boolean => {
874
+ if (step === 'upload') return !!file;
875
+ if (step === 'preview') return !!preview && !previewError;
876
+ if (step === 'mapping') {
877
+ const hasName = Object.values(mapping).includes('name');
878
+ return hasName;
879
+ }
880
+ if (step === 'confirm') return true;
881
+ return false;
882
+ };
883
+
884
+ const handleNext = async () => {
885
+ if (step === 'upload') {
886
+ if (!file) {
887
+ setUploadError(t('importErrorFileRequired'));
888
+ return;
889
+ }
890
+ const MAX_FILE_SIZE = 10 * 1024 * 1024;
891
+ if (file.size > MAX_FILE_SIZE) {
892
+ setUploadError(t('importErrorFileTooLarge'));
893
+ return;
894
+ }
895
+ setUploadError(null);
896
+ await fetchPreview();
897
+ } else if (step === 'preview') {
898
+ setStep('mapping');
899
+ } else if (step === 'mapping') {
900
+ const hasName = Object.values(mapping).includes('name');
901
+ if (!hasName) {
902
+ setMappingError(t('importMappingNameRequired'));
903
+ return;
904
+ }
905
+ setMappingError(null);
906
+ setStep('confirm');
907
+ } else if (step === 'confirm') {
908
+ await runImport();
909
+ }
910
+ };
911
+
912
+ const handleBack = () => {
913
+ if (step === 'preview') setStep('upload');
914
+ else if (step === 'mapping') setStep('preview');
915
+ else if (step === 'confirm') setStep('mapping');
916
+ };
917
+
918
+ // ── Fetch preview ──
919
+ const fetchPreview = async () => {
920
+ if (!file) return;
921
+ setPreviewLoading(true);
922
+ setPreviewError(null);
923
+ setStep('preview');
924
+
925
+ try {
926
+ const formData = new FormData();
927
+ formData.append('file', file);
928
+
929
+ const res = await request<ImportPreview>({
930
+ url: '/person/import/preview',
931
+ method: 'POST',
932
+ data: formData,
933
+ headers: { 'Content-Type': 'multipart/form-data' },
934
+ });
935
+
936
+ setPreview(res.data);
937
+ initMapping(res.data.columns);
938
+ } catch (err: any) {
939
+ const msg =
940
+ err?.response?.data?.message ?? err?.message ?? t('importErrorGeneric');
941
+ setPreviewError(msg);
942
+ } finally {
943
+ setPreviewLoading(false);
944
+ }
945
+ };
946
+
947
+ // ── Run import ──
948
+ const runImport = async () => {
949
+ if (!file) return;
950
+ setImportLoading(true);
951
+ setImportError(null);
952
+ setStep('result');
953
+
954
+ try {
955
+ const formData = new FormData();
956
+ formData.append('file', file);
957
+ formData.append('mapping', JSON.stringify(mapping));
958
+ if (companyId) formData.append('company_id', String(companyId));
959
+
960
+ const res = await request<ImportResult>({
961
+ url: '/person/import',
962
+ method: 'POST',
963
+ data: formData,
964
+ headers: { 'Content-Type': 'multipart/form-data' },
965
+ });
966
+
967
+ setResult(res.data);
968
+ onSuccess();
969
+ } catch (err: any) {
970
+ const msg =
971
+ err?.response?.data?.message ?? err?.message ?? t('importErrorGeneric');
972
+ setImportError(msg);
973
+ } finally {
974
+ setImportLoading(false);
975
+ }
976
+ };
977
+
978
+ const isLastActionStep = step === 'confirm';
979
+ const showBack = step !== 'upload' && step !== 'result';
980
+
981
+ return (
982
+ <Sheet open={open} onOpenChange={handleOpenChange}>
983
+ <SheetContent className="flex h-full w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl md:max-w-3xl lg:max-w-4xl xl:max-w-5xl">
984
+ {/* Header */}
985
+ <SheetHeader className="border-b px-4 py-4 text-left">
986
+ <div className="flex items-center gap-3">
987
+ <div className="flex h-9 w-9 items-center justify-center rounded-xl border border-primary/20 bg-primary/10">
988
+ <Upload className="h-4 w-4 text-primary" />
989
+ </div>
990
+ <div>
991
+ <SheetTitle className="text-base">
992
+ {t('importSheetTitle')}
993
+ </SheetTitle>
994
+ <SheetDescription className="text-xs">
995
+ {t('importSheetDescription')}
996
+ </SheetDescription>
997
+ </div>
998
+ </div>
999
+ </SheetHeader>
1000
+
1001
+ {/* Step indicator */}
1002
+ <StepIndicator current={step} />
1003
+
1004
+ {/* Content */}
1005
+ <div className="flex-1 overflow-y-auto px-4 py-4">
1006
+ {step === 'upload' && (
1007
+ <UploadStep
1008
+ file={file}
1009
+ onFileChange={(f) => {
1010
+ setFile(f);
1011
+ setUploadError(null);
1012
+ }}
1013
+ error={uploadError}
1014
+ />
1015
+ )}
1016
+
1017
+ {step === 'preview' && (
1018
+ <PreviewStep
1019
+ preview={preview}
1020
+ isLoading={previewLoading}
1021
+ error={previewError}
1022
+ />
1023
+ )}
1024
+
1025
+ {step === 'mapping' && preview && (
1026
+ <MappingStep
1027
+ columns={preview.columns}
1028
+ mapping={mapping}
1029
+ onMappingChange={(m) => {
1030
+ setMapping(m);
1031
+ setMappingError(null);
1032
+ }}
1033
+ validationError={mappingError}
1034
+ />
1035
+ )}
1036
+
1037
+ {step === 'confirm' && preview && (
1038
+ <ConfirmStep
1039
+ preview={preview}
1040
+ mapping={mapping}
1041
+ companyId={companyId}
1042
+ onCompanyChange={(id) => setCompanyId(id)}
1043
+ />
1044
+ )}
1045
+
1046
+ {step === 'result' && (
1047
+ <ResultStep
1048
+ result={result}
1049
+ isLoading={importLoading}
1050
+ error={importError}
1051
+ />
1052
+ )}
1053
+ </div>
1054
+
1055
+ {/* Footer */}
1056
+ <div className="flex items-center justify-between border-t px-4 py-3">
1057
+ <div>
1058
+ {showBack && (
1059
+ <Button
1060
+ type="button"
1061
+ variant="ghost"
1062
+ size="sm"
1063
+ onClick={handleBack}
1064
+ disabled={previewLoading || importLoading}
1065
+ >
1066
+ <ChevronLeft className="mr-1 h-4 w-4" />
1067
+ {t('importBack')}
1068
+ </Button>
1069
+ )}
1070
+ </div>
1071
+
1072
+ <div className="flex items-center gap-2">
1073
+ {step === 'result' ? (
1074
+ <Button
1075
+ type="button"
1076
+ size="sm"
1077
+ onClick={() => handleOpenChange(false)}
1078
+ >
1079
+ {t('importClose')}
1080
+ </Button>
1081
+ ) : (
1082
+ <>
1083
+ <Button
1084
+ type="button"
1085
+ variant="outline"
1086
+ size="sm"
1087
+ onClick={() => handleOpenChange(false)}
1088
+ disabled={previewLoading || importLoading}
1089
+ >
1090
+ <X className="mr-1 h-4 w-4" />
1091
+ {t('cancel')}
1092
+ </Button>
1093
+ <Button
1094
+ type="button"
1095
+ size="sm"
1096
+ onClick={handleNext}
1097
+ disabled={!canGoNext() || previewLoading || importLoading}
1098
+ >
1099
+ {previewLoading || importLoading ? (
1100
+ <Loader2 className="mr-1 h-4 w-4 animate-spin" />
1101
+ ) : isLastActionStep ? null : (
1102
+ <ChevronRight className="ml-1 h-4 w-4 order-last" />
1103
+ )}
1104
+ {isLastActionStep ? (
1105
+ <>
1106
+ <Upload className="mr-1.5 h-4 w-4" />
1107
+ {t('importStart')}
1108
+ </>
1109
+ ) : (
1110
+ t('importNext')
1111
+ )}
1112
+ </Button>
1113
+ </>
1114
+ )}
1115
+ </div>
1116
+ </div>
1117
+ </SheetContent>
1118
+ </Sheet>
1119
+ );
1120
+ }