@hed-hog/contact 0.0.321 → 0.0.325

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.
@@ -40,7 +40,7 @@ export declare class PersonController {
40
40
  total: number;
41
41
  }>;
42
42
  source: Array<{
43
- key: "other" | "website" | "referral" | "social" | "inbound" | "outbound";
43
+ key: "other" | "social" | "website" | "inbound" | "outbound" | "referral";
44
44
  total: number;
45
45
  }>;
46
46
  owner_performance: {
@@ -60,7 +60,7 @@ export declare class PersonController {
60
60
  id: number;
61
61
  name: string;
62
62
  } | null;
63
- source: "other" | "website" | "referral" | "social" | "inbound" | "outbound";
63
+ source: "other" | "social" | "website" | "inbound" | "outbound" | "referral";
64
64
  lifecycle_stage: "new" | "contacted" | "qualified" | "proposal" | "negotiation" | "customer" | "lost";
65
65
  next_action_at?: string;
66
66
  created_at?: string;
@@ -73,7 +73,7 @@ export declare class PersonController {
73
73
  id: number;
74
74
  name: string;
75
75
  } | null;
76
- source: "other" | "website" | "referral" | "social" | "inbound" | "outbound";
76
+ source: "other" | "social" | "website" | "inbound" | "outbound" | "referral";
77
77
  lifecycle_stage: "new" | "contacted" | "qualified" | "proposal" | "negotiation" | "customer" | "lost";
78
78
  next_action_at?: string;
79
79
  created_at?: string;
@@ -104,7 +104,7 @@ export declare class PersonController {
104
104
  })[];
105
105
  breakdowns: {
106
106
  source: Array<{
107
- key: "other" | "website" | "referral" | "social" | "inbound" | "outbound";
107
+ key: "other" | "social" | "website" | "inbound" | "outbound" | "referral";
108
108
  total: number;
109
109
  }>;
110
110
  current_stage: Array<{
@@ -112,7 +112,7 @@ export declare class PersonController {
112
112
  total: number;
113
113
  }>;
114
114
  activity_type: Array<{
115
- key: "email" | "note" | "call" | "meeting" | "whatsapp" | "task";
115
+ key: "email" | "note" | "whatsapp" | "call" | "meeting" | "task";
116
116
  total: number;
117
117
  }>;
118
118
  };
@@ -0,0 +1,950 @@
1
+ 'use client';
2
+
3
+ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
4
+ import { Button } from '@/components/ui/button';
5
+ import {
6
+ Command,
7
+ CommandEmpty,
8
+ CommandGroup,
9
+ CommandInput,
10
+ CommandItem,
11
+ CommandList,
12
+ } from '@/components/ui/command';
13
+ import {
14
+ Form,
15
+ FormControl,
16
+ FormField,
17
+ FormItem,
18
+ FormLabel,
19
+ FormMessage,
20
+ } from '@/components/ui/form';
21
+ import { Input } from '@/components/ui/input';
22
+ import { Label } from '@/components/ui/label';
23
+ import {
24
+ Popover,
25
+ PopoverContent,
26
+ PopoverTrigger,
27
+ } from '@/components/ui/popover';
28
+ import {
29
+ Select,
30
+ SelectContent,
31
+ SelectItem,
32
+ SelectTrigger,
33
+ SelectValue,
34
+ } from '@/components/ui/select';
35
+ import {
36
+ Sheet,
37
+ SheetContent,
38
+ SheetDescription,
39
+ SheetHeader,
40
+ SheetTitle,
41
+ } from '@/components/ui/sheet';
42
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
43
+ import { zodResolver } from '@hookform/resolvers/zod';
44
+ import { ChevronsUpDown, Loader2, Plus, X } from 'lucide-react';
45
+ import { useTranslations } from 'next-intl';
46
+ import { useEffect, useMemo, useRef, useState } from 'react';
47
+ import { useForm } from 'react-hook-form';
48
+ import { z } from 'zod';
49
+
50
+ type PersonOption = {
51
+ id: number | string;
52
+ name: string;
53
+ avatarId?: number | null;
54
+ avatar_id?: number | null;
55
+ };
56
+
57
+ type CreatePersonValues = {
58
+ name: string;
59
+ type: 'individual' | 'company';
60
+ email?: string;
61
+ phone?: string;
62
+ document?: string;
63
+ line1?: string;
64
+ city?: string;
65
+ state?: string;
66
+ };
67
+
68
+ type PersonTypeFilter = 'individual' | 'company' | 'all';
69
+
70
+ type ContactTypeLookup = {
71
+ code: string;
72
+ contact_type_id: number;
73
+ };
74
+
75
+ type DocumentTypeLookup = {
76
+ code: string;
77
+ document_type_id: number;
78
+ };
79
+
80
+ type CreatePersonResponse = {
81
+ id?: number;
82
+ data?: {
83
+ id?: number;
84
+ };
85
+ };
86
+
87
+ type PaginatedResponse<T> = {
88
+ data?: T[];
89
+ };
90
+
91
+ function usePersonFieldWithCreateTranslations() {
92
+ const tRoot = useTranslations();
93
+
94
+ return (
95
+ key: string,
96
+ values?: Record<string, string | number | boolean | null | undefined>
97
+ ) => {
98
+ const contactKey = `contact.PersonFieldWithCreate.${key}`;
99
+ const filteredValues = values
100
+ ? Object.fromEntries(
101
+ Object.entries(values).filter(
102
+ ([, value]) =>
103
+ typeof value === 'string' || typeof value === 'number'
104
+ )
105
+ )
106
+ : undefined;
107
+
108
+ if (tRoot.has(contactKey)) {
109
+ return tRoot(
110
+ contactKey,
111
+ filteredValues as Record<string, string | number> | undefined
112
+ );
113
+ }
114
+
115
+ return tRoot(
116
+ `finance.PersonFieldWithCreate.${key}`,
117
+ filteredValues as Record<string, string | number> | undefined
118
+ );
119
+ };
120
+ }
121
+
122
+ function CreatePersonSheet({
123
+ open,
124
+ onOpenChange,
125
+ onCreated,
126
+ entityLabel,
127
+ defaultCreateType = 'individual',
128
+ lockCreateType = false,
129
+ initialName = '',
130
+ }: {
131
+ open: boolean;
132
+ onOpenChange: (open: boolean) => void;
133
+ onCreated: (person: PersonOption) => void;
134
+ entityLabel: string;
135
+ defaultCreateType?: Exclude<PersonTypeFilter, 'all'>;
136
+ lockCreateType?: boolean;
137
+ initialName?: string;
138
+ }) {
139
+ const { request, showToastHandler, currentLocaleCode, getSettingValue } =
140
+ useApp();
141
+ const t = usePersonFieldWithCreateTranslations();
142
+ const allowCompanyRegistration =
143
+ getSettingValue('contact-allow-company-registration') !== false;
144
+
145
+ const effectiveDefaultType: Exclude<PersonTypeFilter, 'all'> =
146
+ allowCompanyRegistration ? defaultCreateType : 'individual';
147
+
148
+ const createPersonSchema = useMemo(
149
+ () =>
150
+ z.object({
151
+ name: z.string().trim().min(2, 'Nome é obrigatório'),
152
+ type: allowCompanyRegistration
153
+ ? z.enum(['individual', 'company'])
154
+ : z.literal('individual'),
155
+ email: z
156
+ .string()
157
+ .trim()
158
+ .email('E-mail inválido')
159
+ .optional()
160
+ .or(z.literal('')),
161
+ phone: z.string().trim().optional(),
162
+ document: z.string().trim().optional(),
163
+ line1: z.string().optional(),
164
+ city: z.string().optional(),
165
+ state: z.string().optional(),
166
+ }),
167
+ [allowCompanyRegistration]
168
+ );
169
+
170
+ const form = useForm<CreatePersonValues>({
171
+ resolver: zodResolver(createPersonSchema),
172
+ defaultValues: {
173
+ name: '',
174
+ type: effectiveDefaultType,
175
+ email: '',
176
+ phone: '',
177
+ document: '',
178
+ line1: '',
179
+ city: '',
180
+ state: '',
181
+ },
182
+ });
183
+
184
+ const selectedType =
185
+ allowCompanyRegistration && !lockCreateType
186
+ ? form.watch('type')
187
+ : effectiveDefaultType;
188
+
189
+ useEffect(() => {
190
+ if (open && initialName) {
191
+ form.setValue('name', initialName);
192
+ }
193
+ }, [open, initialName, form]);
194
+
195
+ useEffect(() => {
196
+ if (!allowCompanyRegistration || lockCreateType) {
197
+ form.setValue('type', effectiveDefaultType);
198
+ }
199
+ }, [allowCompanyRegistration, effectiveDefaultType, form, lockCreateType]);
200
+
201
+ const { data: contactTypes = [] } = useQuery<ContactTypeLookup[]>({
202
+ queryKey: ['person-picker-contact-types', currentLocaleCode],
203
+ queryFn: async () => {
204
+ const response = await request<PaginatedResponse<ContactTypeLookup>>({
205
+ url: '/person-contact-type?pageSize=100',
206
+ method: 'GET',
207
+ });
208
+ return response?.data?.data || [];
209
+ },
210
+ placeholderData: (old) => old ?? [],
211
+ });
212
+
213
+ const { data: documentTypes = [] } = useQuery<DocumentTypeLookup[]>({
214
+ queryKey: ['person-picker-document-types', currentLocaleCode],
215
+ queryFn: async () => {
216
+ const response = await request<PaginatedResponse<DocumentTypeLookup>>({
217
+ url: '/person-document-type?pageSize=100',
218
+ method: 'GET',
219
+ });
220
+ return response?.data?.data || [];
221
+ },
222
+ placeholderData: (old) => old ?? [],
223
+ });
224
+
225
+ const resolveContactTypeId = (code: string, fallbackIndex = 0) => {
226
+ const found = contactTypes.find(
227
+ (item) => String(item.code).toUpperCase() === code
228
+ );
229
+ return (
230
+ found?.contact_type_id || contactTypes[fallbackIndex]?.contact_type_id
231
+ );
232
+ };
233
+
234
+ const resolveDocumentTypeId = (code: string) => {
235
+ const found = documentTypes.find(
236
+ (item) => String(item.code).toUpperCase() === code
237
+ );
238
+ return found?.document_type_id || documentTypes[0]?.document_type_id;
239
+ };
240
+
241
+ const handleSubmit = async (values: CreatePersonValues) => {
242
+ try {
243
+ const normalizedType =
244
+ allowCompanyRegistration && !lockCreateType
245
+ ? values.type
246
+ : effectiveDefaultType;
247
+
248
+ const createResponse = await request<CreatePersonResponse>({
249
+ url: '/person',
250
+ method: 'POST',
251
+ data: {
252
+ name: values.name,
253
+ type: normalizedType,
254
+ status: 'active',
255
+ },
256
+ });
257
+
258
+ const personId = Number(
259
+ createResponse?.data?.id ?? createResponse?.data?.data?.id
260
+ );
261
+
262
+ if (!personId) {
263
+ throw new Error('Could not identify the created person record');
264
+ }
265
+
266
+ const emailTypeId = resolveContactTypeId('EMAIL', 0);
267
+ const phoneTypeId =
268
+ resolveContactTypeId('PHONE', 1) || resolveContactTypeId('MOBILE', 1);
269
+ const documentTypeId = resolveDocumentTypeId(
270
+ normalizedType === 'individual' ? 'CPF' : 'CNPJ'
271
+ );
272
+
273
+ const contacts = [
274
+ values.email && emailTypeId
275
+ ? {
276
+ value: values.email.trim(),
277
+ is_primary: true,
278
+ contact_type_id: emailTypeId,
279
+ }
280
+ : null,
281
+ values.phone && phoneTypeId
282
+ ? {
283
+ value: values.phone.trim(),
284
+ is_primary: true,
285
+ contact_type_id: phoneTypeId,
286
+ }
287
+ : null,
288
+ ].filter(Boolean);
289
+
290
+ const documents = values.document?.trim()
291
+ ? [
292
+ {
293
+ value: values.document.trim(),
294
+ document_type_id: documentTypeId,
295
+ },
296
+ ]
297
+ : [];
298
+
299
+ const addresses =
300
+ values.line1 && values.city && values.state
301
+ ? [
302
+ {
303
+ line1: values.line1.trim(),
304
+ city: values.city.trim(),
305
+ state: values.state.trim(),
306
+ is_primary: true,
307
+ address_type: 'residential',
308
+ },
309
+ ]
310
+ : [];
311
+
312
+ await request({
313
+ url: `/person/${personId}`,
314
+ method: 'PATCH',
315
+ data: {
316
+ name: values.name,
317
+ type: normalizedType,
318
+ status: 'active',
319
+ contacts,
320
+ documents,
321
+ addresses,
322
+ },
323
+ });
324
+
325
+ onCreated({ id: personId, name: values.name });
326
+ form.reset();
327
+ onOpenChange(false);
328
+ showToastHandler?.(
329
+ 'success',
330
+ t('messages.createdSuccess', { entityLabel })
331
+ );
332
+ } catch {
333
+ showToastHandler?.('error', t('messages.createdError', { entityLabel }));
334
+ }
335
+ };
336
+
337
+ return (
338
+ <Sheet
339
+ open={open}
340
+ onOpenChange={(nextOpen) => {
341
+ onOpenChange(nextOpen);
342
+ if (!nextOpen) {
343
+ form.reset();
344
+ }
345
+ }}
346
+ >
347
+ <SheetContent
348
+ className="w-full overflow-y-auto sm:max-w-xl"
349
+ onCloseAutoFocus={(event) => event.preventDefault()}
350
+ >
351
+ <SheetHeader>
352
+ <SheetTitle>{t('sheet.title', { entityLabel })}</SheetTitle>
353
+ <SheetDescription>
354
+ {allowCompanyRegistration
355
+ ? t('sheet.description')
356
+ : t('sheet.descriptionIndividualOnly')}
357
+ </SheetDescription>
358
+ </SheetHeader>
359
+
360
+ <Form {...form}>
361
+ <div className="space-y-4 p-4">
362
+ <FormField
363
+ control={form.control}
364
+ name="name"
365
+ render={({ field }) => (
366
+ <FormItem>
367
+ <FormLabel>{t('fields.name')}</FormLabel>
368
+ <FormControl>
369
+ <Input
370
+ placeholder={t('placeholders.name', { entityLabel })}
371
+ {...field}
372
+ />
373
+ </FormControl>
374
+ <FormMessage />
375
+ </FormItem>
376
+ )}
377
+ />
378
+
379
+ {allowCompanyRegistration && !lockCreateType ? (
380
+ <FormField
381
+ control={form.control}
382
+ name="type"
383
+ render={({ field }) => (
384
+ <FormItem>
385
+ <FormLabel>{t('fields.type')}</FormLabel>
386
+ <Select value={field.value} onValueChange={field.onChange}>
387
+ <FormControl>
388
+ <SelectTrigger>
389
+ <SelectValue placeholder={t('common.select')} />
390
+ </SelectTrigger>
391
+ </FormControl>
392
+ <SelectContent>
393
+ <SelectItem value="individual">
394
+ {t('types.individual')}
395
+ </SelectItem>
396
+ <SelectItem value="company">
397
+ {t('types.company')}
398
+ </SelectItem>
399
+ </SelectContent>
400
+ </Select>
401
+ <FormMessage />
402
+ </FormItem>
403
+ )}
404
+ />
405
+ ) : null}
406
+
407
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
408
+ <FormField
409
+ control={form.control}
410
+ name="document"
411
+ render={({ field }) => (
412
+ <FormItem>
413
+ <FormLabel>
414
+ {selectedType === 'individual'
415
+ ? t('fields.documentIndividualOptional')
416
+ : t('fields.documentCompanyOptional')}
417
+ </FormLabel>
418
+ <FormControl>
419
+ <Input
420
+ placeholder={
421
+ selectedType === 'individual'
422
+ ? '000.000.000-00'
423
+ : '00.000.000/0000-00'
424
+ }
425
+ {...field}
426
+ value={field.value || ''}
427
+ />
428
+ </FormControl>
429
+ <FormMessage />
430
+ </FormItem>
431
+ )}
432
+ />
433
+
434
+ <FormField
435
+ control={form.control}
436
+ name="email"
437
+ render={({ field }) => (
438
+ <FormItem>
439
+ <FormLabel>{t('fields.emailOptional')}</FormLabel>
440
+ <FormControl>
441
+ <Input
442
+ placeholder={t('placeholders.email', { entityLabel })}
443
+ {...field}
444
+ value={field.value || ''}
445
+ />
446
+ </FormControl>
447
+ <FormMessage />
448
+ </FormItem>
449
+ )}
450
+ />
451
+ </div>
452
+
453
+ <FormField
454
+ control={form.control}
455
+ name="phone"
456
+ render={({ field }) => (
457
+ <FormItem>
458
+ <FormLabel>{t('fields.phoneOptional')}</FormLabel>
459
+ <FormControl>
460
+ <Input
461
+ placeholder={t('placeholders.phone')}
462
+ {...field}
463
+ value={field.value || ''}
464
+ />
465
+ </FormControl>
466
+ <FormMessage />
467
+ </FormItem>
468
+ )}
469
+ />
470
+
471
+ <div className="rounded-md border p-3">
472
+ <p className="mb-3 text-sm font-medium">
473
+ {t('fields.addressOptional')}
474
+ </p>
475
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
476
+ <FormField
477
+ control={form.control}
478
+ name="line1"
479
+ render={({ field }) => (
480
+ <FormItem className="sm:col-span-2">
481
+ <FormControl>
482
+ <Input
483
+ placeholder={t('placeholders.addressLine1')}
484
+ {...field}
485
+ value={field.value || ''}
486
+ />
487
+ </FormControl>
488
+ <FormMessage />
489
+ </FormItem>
490
+ )}
491
+ />
492
+
493
+ <FormField
494
+ control={form.control}
495
+ name="city"
496
+ render={({ field }) => (
497
+ <FormItem>
498
+ <FormControl>
499
+ <Input
500
+ placeholder={t('placeholders.city')}
501
+ {...field}
502
+ value={field.value || ''}
503
+ />
504
+ </FormControl>
505
+ <FormMessage />
506
+ </FormItem>
507
+ )}
508
+ />
509
+
510
+ <FormField
511
+ control={form.control}
512
+ name="state"
513
+ render={({ field }) => (
514
+ <FormItem>
515
+ <FormControl>
516
+ <Input
517
+ placeholder={t('placeholders.state')}
518
+ {...field}
519
+ value={field.value || ''}
520
+ />
521
+ </FormControl>
522
+ <FormMessage />
523
+ </FormItem>
524
+ )}
525
+ />
526
+ </div>
527
+ </div>
528
+
529
+ <div className="flex justify-end gap-2">
530
+ <Button
531
+ type="button"
532
+ variant="outline"
533
+ onClick={() => onOpenChange(false)}
534
+ >
535
+ {t('actions.cancel')}
536
+ </Button>
537
+ <Button
538
+ type="button"
539
+ disabled={form.formState.isSubmitting}
540
+ onClick={() => {
541
+ void form.handleSubmit(handleSubmit)();
542
+ }}
543
+ >
544
+ {t('actions.saveEntity', { entityLabel })}
545
+ </Button>
546
+ </div>
547
+ </div>
548
+ </Form>
549
+ </SheetContent>
550
+ </Sheet>
551
+ );
552
+ }
553
+
554
+ export function PersonPicker({
555
+ label,
556
+ entityLabel,
557
+ value,
558
+ onChange,
559
+ selectPlaceholder,
560
+ personTypeFilter = 'all',
561
+ createType = 'individual',
562
+ lockCreateType = false,
563
+ initialSelectedLabel = '',
564
+ disabled = false,
565
+ }: {
566
+ label: string;
567
+ entityLabel: string;
568
+ value?: string | number | null;
569
+ onChange: (personId: number | null, personName: string) => void;
570
+ selectPlaceholder: string;
571
+ personTypeFilter?: PersonTypeFilter;
572
+ createType?: Exclude<PersonTypeFilter, 'all'>;
573
+ lockCreateType?: boolean;
574
+ initialSelectedLabel?: string;
575
+ disabled?: boolean;
576
+ }) {
577
+ const { request } = useApp();
578
+ const t = usePersonFieldWithCreateTranslations();
579
+ const [personOpen, setPersonOpen] = useState(false);
580
+ const [personSearch, setPersonSearch] = useState('');
581
+ const [debouncedPersonSearch, setDebouncedPersonSearch] = useState('');
582
+ const [createPersonOpen, setCreatePersonOpen] = useState(false);
583
+ const [selectedPersonLabel, setSelectedPersonLabel] =
584
+ useState(initialSelectedLabel);
585
+ const [selectedPersonAvatarId, setSelectedPersonAvatarId] = useState<
586
+ number | null
587
+ >(null);
588
+ const commandInputRef = useRef<HTMLInputElement | null>(null);
589
+ const parentScrollContainerRef = useRef<HTMLElement | null>(null);
590
+ const parentScrollTopRef = useRef(0);
591
+
592
+ useEffect(() => {
593
+ const timeout = setTimeout(() => {
594
+ setDebouncedPersonSearch(personSearch);
595
+ }, 300);
596
+
597
+ return () => clearTimeout(timeout);
598
+ }, [personSearch]);
599
+
600
+ useEffect(() => {
601
+ setSelectedPersonLabel(initialSelectedLabel);
602
+ }, [initialSelectedLabel]);
603
+
604
+ const captureParentScrollPosition = (trigger: HTMLElement) => {
605
+ const parentSheetContent = trigger.closest(
606
+ '[data-radix-dialog-content]'
607
+ ) as HTMLElement | null;
608
+
609
+ if (!parentSheetContent) {
610
+ parentScrollContainerRef.current = null;
611
+ parentScrollTopRef.current = 0;
612
+ return;
613
+ }
614
+
615
+ parentScrollContainerRef.current = parentSheetContent;
616
+ parentScrollTopRef.current = parentSheetContent.scrollTop;
617
+ };
618
+
619
+ const restoreParentScrollPosition = () => {
620
+ const fallbackOpenDialog = (
621
+ Array.from(
622
+ document.querySelectorAll(
623
+ '[data-radix-dialog-content][data-state="open"]'
624
+ )
625
+ ) as HTMLElement[]
626
+ ).at(-1);
627
+
628
+ const container =
629
+ parentScrollContainerRef.current &&
630
+ document.body.contains(parentScrollContainerRef.current)
631
+ ? parentScrollContainerRef.current
632
+ : fallbackOpenDialog || null;
633
+
634
+ if (!container) {
635
+ return;
636
+ }
637
+
638
+ const restore = () => {
639
+ container.scrollTop = parentScrollTopRef.current;
640
+ };
641
+
642
+ requestAnimationFrame(restore);
643
+ setTimeout(restore, 0);
644
+ setTimeout(restore, 120);
645
+ };
646
+
647
+ const { data: personOptionsData = [], isLoading: isLoadingPersons } =
648
+ useQuery<PersonOption[]>({
649
+ queryKey: [
650
+ 'person-picker-autocomplete',
651
+ entityLabel,
652
+ debouncedPersonSearch,
653
+ personTypeFilter,
654
+ ],
655
+ queryFn: async () => {
656
+ const params = new URLSearchParams();
657
+ params.set('page', '1');
658
+ params.set('pageSize', '20');
659
+ if (personTypeFilter !== 'all') {
660
+ params.set('type', personTypeFilter);
661
+ }
662
+ if (debouncedPersonSearch.trim()) {
663
+ params.set('search', debouncedPersonSearch.trim());
664
+ }
665
+
666
+ const response = await request<
667
+ PaginatedResponse<PersonOption> | PersonOption[]
668
+ >({
669
+ url: `/person?${params.toString()}`,
670
+ method: 'GET',
671
+ });
672
+
673
+ const payload = response?.data;
674
+ if (Array.isArray(payload)) {
675
+ return payload as PersonOption[];
676
+ }
677
+
678
+ if (payload && 'data' in payload && Array.isArray(payload.data)) {
679
+ return payload.data.map((p) => ({
680
+ ...p,
681
+ avatarId: (p as any).avatar_id ?? p.avatarId ?? null,
682
+ })) as PersonOption[];
683
+ }
684
+
685
+ return [];
686
+ },
687
+ placeholderData: (old) => old ?? [],
688
+ });
689
+
690
+ const normalizedValue =
691
+ value !== undefined && value !== null && String(value).length > 0
692
+ ? String(value)
693
+ : '';
694
+ const hasValue = normalizedValue.length > 0;
695
+
696
+ const selectedPersonFromList = personOptionsData.find(
697
+ (p) => String(p.id) === normalizedValue
698
+ );
699
+
700
+ useEffect(() => {
701
+ if (selectedPersonFromList) {
702
+ setSelectedPersonAvatarId(
703
+ (selectedPersonFromList.avatarId ??
704
+ selectedPersonFromList.avatar_id ??
705
+ null) as number | null
706
+ );
707
+ }
708
+ }, [selectedPersonFromList]);
709
+
710
+ const displayAvatarId = selectedPersonAvatarId;
711
+ const displayAvatarSrc =
712
+ typeof displayAvatarId === 'number' && displayAvatarId > 0
713
+ ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${displayAvatarId}`
714
+ : undefined;
715
+ const displayLabel = hasValue
716
+ ? (selectedPersonFromList?.name ??
717
+ selectedPersonLabel ??
718
+ `ID #${normalizedValue}`)
719
+ : selectedPersonLabel || selectPlaceholder;
720
+ const displayInitials =
721
+ hasValue || Boolean(selectedPersonLabel)
722
+ ? (displayLabel !== selectPlaceholder ? displayLabel : '')
723
+ .trim()
724
+ .split(' ')
725
+ .filter(Boolean)
726
+ .slice(0, 2)
727
+ .map((w: string) => w[0]!.toUpperCase())
728
+ .join('')
729
+ : '';
730
+ const hasSelection = hasValue || Boolean(selectedPersonLabel);
731
+
732
+ const openCreateWithPrefill = (triggerEl: HTMLElement, prefill: string) => {
733
+ captureParentScrollPosition(triggerEl);
734
+ setPersonOpen(false);
735
+ setCreatePersonOpen(true);
736
+ setPersonSearch(prefill);
737
+ };
738
+
739
+ return (
740
+ <>
741
+ <div className="grid min-w-0 gap-2">
742
+ {label ? <Label>{label}</Label> : null}
743
+
744
+ <div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] gap-2 sm:grid-cols-[minmax(0,1fr)_auto_auto]">
745
+ <Popover
746
+ open={!disabled && personOpen}
747
+ onOpenChange={(open) => {
748
+ if (!disabled) {
749
+ setPersonOpen(open);
750
+ }
751
+ }}
752
+ >
753
+ <PopoverTrigger asChild>
754
+ <Button
755
+ type="button"
756
+ variant="outline"
757
+ role="combobox"
758
+ disabled={disabled}
759
+ className="min-w-0 max-w-full justify-between overflow-hidden"
760
+ >
761
+ {hasSelection && displayLabel !== selectPlaceholder ? (
762
+ <span className="flex min-w-0 flex-1 items-center gap-2 text-left">
763
+ <Avatar className="h-5 w-5 shrink-0">
764
+ {displayAvatarSrc ? (
765
+ <AvatarImage
766
+ src={displayAvatarSrc}
767
+ alt={displayLabel}
768
+ />
769
+ ) : null}
770
+ <AvatarFallback className="text-[8px] font-medium">
771
+ {displayInitials}
772
+ </AvatarFallback>
773
+ </Avatar>
774
+ <span className="min-w-0 truncate">{displayLabel}</span>
775
+ </span>
776
+ ) : (
777
+ <span className="min-w-0 flex-1 truncate text-left text-muted-foreground">
778
+ {displayLabel}
779
+ </span>
780
+ )}
781
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
782
+ </Button>
783
+ </PopoverTrigger>
784
+ <PopoverContent
785
+ className="p-0"
786
+ style={{ width: 'var(--radix-popover-trigger-width)' }}
787
+ onOpenAutoFocus={(e) => {
788
+ e.preventDefault();
789
+ commandInputRef.current?.focus();
790
+ }}
791
+ >
792
+ <Command shouldFilter={false}>
793
+ <CommandInput
794
+ ref={commandInputRef}
795
+ placeholder={t('search.placeholder')}
796
+ value={personSearch}
797
+ onValueChange={(v) => setPersonSearch(v)}
798
+ icon={
799
+ isLoadingPersons ? (
800
+ <Loader2 className="size-4 shrink-0 animate-spin opacity-50" />
801
+ ) : undefined
802
+ }
803
+ onKeyDown={(e) => {
804
+ if (
805
+ e.key === 'Enter' &&
806
+ !isLoadingPersons &&
807
+ personOptionsData.length === 0
808
+ ) {
809
+ e.preventDefault();
810
+ openCreateWithPrefill(
811
+ e.currentTarget,
812
+ personSearch.trim()
813
+ );
814
+ }
815
+ }}
816
+ />
817
+ <CommandList onWheel={(e) => e.stopPropagation()}>
818
+ <CommandEmpty>
819
+ {isLoadingPersons ? (
820
+ t('search.loading')
821
+ ) : (
822
+ <div className="space-y-2 p-2 text-center">
823
+ <p className="text-sm text-muted-foreground">
824
+ {t('search.noResults')}
825
+ </p>
826
+ <Button
827
+ type="button"
828
+ variant="outline"
829
+ className="w-full"
830
+ onClick={(event) => {
831
+ openCreateWithPrefill(
832
+ event.currentTarget,
833
+ personSearch.trim()
834
+ );
835
+ }}
836
+ >
837
+ {t('actions.createNew')}
838
+ </Button>
839
+ </div>
840
+ )}
841
+ </CommandEmpty>
842
+ <CommandGroup>
843
+ {personOptionsData.map((person) => {
844
+ const avatarId =
845
+ person.avatarId ?? person.avatar_id ?? null;
846
+ const avatarSrc =
847
+ typeof avatarId === 'number' && avatarId > 0
848
+ ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${avatarId}`
849
+ : undefined;
850
+ const initials = person.name
851
+ .trim()
852
+ .split(' ')
853
+ .filter(Boolean)
854
+ .slice(0, 2)
855
+ .map((w) => w[0]!.toUpperCase())
856
+ .join('');
857
+ return (
858
+ <CommandItem
859
+ key={String(person.id)}
860
+ value={`${person.name}-${person.id}`}
861
+ onSelect={() => {
862
+ onChange(Number(person.id), person.name);
863
+ setSelectedPersonLabel(person.name);
864
+ setSelectedPersonAvatarId(
865
+ (avatarId as number | null) ?? null
866
+ );
867
+ setPersonOpen(false);
868
+ }}
869
+ >
870
+ <Avatar className="mr-2 h-6 w-6 shrink-0">
871
+ {avatarSrc ? (
872
+ <AvatarImage src={avatarSrc} alt={person.name} />
873
+ ) : null}
874
+ <AvatarFallback className="text-[9px] font-medium">
875
+ {initials}
876
+ </AvatarFallback>
877
+ </Avatar>
878
+ {person.name}
879
+ </CommandItem>
880
+ );
881
+ })}
882
+ </CommandGroup>
883
+ </CommandList>
884
+ </Command>
885
+ </PopoverContent>
886
+ </Popover>
887
+
888
+ {hasSelection ? (
889
+ <Button
890
+ type="button"
891
+ variant="outline"
892
+ size="icon"
893
+ className="shrink-0"
894
+ disabled={disabled}
895
+ onClick={() => {
896
+ onChange(null, '');
897
+ setPersonSearch('');
898
+ setSelectedPersonLabel('');
899
+ setSelectedPersonAvatarId(null);
900
+ setPersonOpen(false);
901
+ }}
902
+ aria-label={t('actions.clearSelection')}
903
+ >
904
+ <X className="h-4 w-4" />
905
+ </Button>
906
+ ) : null}
907
+
908
+ <Button
909
+ type="button"
910
+ variant="outline"
911
+ size="icon"
912
+ className="shrink-0"
913
+ disabled={disabled}
914
+ onClick={(event) => {
915
+ captureParentScrollPosition(event.currentTarget);
916
+ setPersonOpen(false);
917
+ setCreatePersonOpen(true);
918
+ }}
919
+ aria-label={t('actions.createEntityAria', { entityLabel })}
920
+ >
921
+ <Plus className="h-4 w-4" />
922
+ </Button>
923
+ </div>
924
+ </div>
925
+
926
+ <CreatePersonSheet
927
+ open={createPersonOpen}
928
+ onOpenChange={(nextOpen) => {
929
+ setCreatePersonOpen(nextOpen);
930
+ if (!nextOpen) {
931
+ restoreParentScrollPosition();
932
+ }
933
+ }}
934
+ entityLabel={entityLabel}
935
+ defaultCreateType={createType}
936
+ lockCreateType={lockCreateType}
937
+ initialName={personSearch.trim()}
938
+ onCreated={(person) => {
939
+ onChange(Number(person.id), person.name);
940
+ setSelectedPersonLabel(person.name);
941
+ setSelectedPersonAvatarId(
942
+ (person.avatarId ?? person.avatar_id ?? null) as number | null
943
+ );
944
+ setPersonSearch(person.name);
945
+ setCreatePersonOpen(false);
946
+ }}
947
+ />
948
+ </>
949
+ );
950
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hed-hog/contact",
3
- "version": "0.0.321",
3
+ "version": "0.0.325",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "dependencies": {
@@ -11,11 +11,11 @@
11
11
  "@nestjs/mapped-types": "*",
12
12
  "@hed-hog/api": "0.0.8",
13
13
  "@hed-hog/api-locale": "0.0.14",
14
- "@hed-hog/api-prisma": "0.0.6",
15
14
  "@hed-hog/api-pagination": "0.0.7",
15
+ "@hed-hog/api-prisma": "0.0.6",
16
16
  "@hed-hog/api-mail": "0.0.9",
17
- "@hed-hog/core": "0.0.321",
18
- "@hed-hog/address": "0.0.321"
17
+ "@hed-hog/core": "0.0.325",
18
+ "@hed-hog/address": "0.0.325"
19
19
  },
20
20
  "exports": {
21
21
  ".": {