@hed-hog/operations 0.0.299 → 0.0.301

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