@hed-hog/contact 0.0.329 → 0.0.330

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.
@@ -0,0 +1,1306 @@
1
+ 'use client';
2
+
3
+ import { Button } from '@/components/ui/button';
4
+ import {
5
+ Form,
6
+ FormControl,
7
+ FormField,
8
+ FormItem,
9
+ FormLabel,
10
+ FormMessage,
11
+ } from '@/components/ui/form';
12
+ import { Input } from '@/components/ui/input';
13
+ import { InputMoney } from '@/components/ui/input-money';
14
+ import {
15
+ Select,
16
+ SelectContent,
17
+ SelectItem,
18
+ SelectTrigger,
19
+ SelectValue,
20
+ } from '@/components/ui/select';
21
+ import {
22
+ Sheet,
23
+ SheetContent,
24
+ SheetDescription,
25
+ SheetHeader,
26
+ SheetTitle,
27
+ } from '@/components/ui/sheet';
28
+ import { Textarea } from '@/components/ui/textarea';
29
+ import { useFormDraft } from '@/hooks/use-form-draft';
30
+ import { formatDateTime } from '@/lib/format-date';
31
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
32
+ import { zodResolver } from '@hookform/resolvers/zod';
33
+ import { formatDistanceToNow } from 'date-fns';
34
+ import { enUS, ptBR } from 'date-fns/locale';
35
+ import { FileText, Loader2, Pencil, Plus, Trash2 } from 'lucide-react';
36
+ import { useTranslations } from 'next-intl';
37
+ import { useEffect, useMemo, useState } from 'react';
38
+ import { useFieldArray, useForm, useWatch } from 'react-hook-form';
39
+ import { toast } from 'sonner';
40
+ import { z } from 'zod';
41
+
42
+ import { PersonPicker } from '../../_components/person-picker';
43
+ import { PersonFormSheet } from '../../person/_components/person-form-sheet';
44
+ import type {
45
+ ContactTypeOption,
46
+ DocumentTypeOption,
47
+ Person,
48
+ } from '../../person/_components/person-types';
49
+ import {
50
+ formatEnumLabel,
51
+ getCurrentRevision,
52
+ type ProposalBillingModel,
53
+ type ProposalContractCategory,
54
+ type ProposalContractType,
55
+ type ProposalItemType,
56
+ type ProposalRecord,
57
+ type ProposalRecurrence,
58
+ } from './proposal-types';
59
+
60
+ // ─── Zod schemas ────────────────────────────────────────────────────────────
61
+
62
+ const proposalItemFormSchema = z.object({
63
+ name: z.string().trim().min(1),
64
+ description: z.string().optional(),
65
+ itemType: z.enum(['service', 'product', 'fee', 'discount', 'note', 'other']),
66
+ quantity: z.coerce.number().min(0),
67
+ unitAmount: z.coerce.number().min(0),
68
+ recurrence: z.enum(['one_time', 'monthly', 'quarterly', 'yearly', 'other']),
69
+ startDate: z.string().optional(),
70
+ endDate: z.string().optional(),
71
+ });
72
+
73
+ const proposalFormSchema = z.object({
74
+ code: z.string().max(40).optional(),
75
+ title: z.string().trim().min(1),
76
+ validUntil: z.string().optional(),
77
+ contractCategory: z.enum([
78
+ 'employee',
79
+ 'contractor',
80
+ 'client',
81
+ 'supplier',
82
+ 'vendor',
83
+ 'partner',
84
+ 'internal',
85
+ 'other',
86
+ ]),
87
+ contractType: z.enum([
88
+ 'clt',
89
+ 'pj',
90
+ 'freelancer_agreement',
91
+ 'service_agreement',
92
+ 'fixed_term',
93
+ 'recurring_service',
94
+ 'nda',
95
+ 'amendment',
96
+ 'addendum',
97
+ 'other',
98
+ ]),
99
+ billingModel: z.enum([
100
+ 'time_and_material',
101
+ 'monthly_retainer',
102
+ 'fixed_price',
103
+ ]),
104
+ summary: z.string().optional(),
105
+ notes: z.string().optional(),
106
+ items: z.array(proposalItemFormSchema).min(1),
107
+ });
108
+
109
+ type ProposalFormValues = z.infer<typeof proposalFormSchema>;
110
+ type ProposalFormItemValues = z.infer<typeof proposalItemFormSchema>;
111
+
112
+ type ProposalDraftPayload = {
113
+ personId: number;
114
+ proposalId: number | null;
115
+ mode: 'create' | 'edit';
116
+ values: ProposalFormValues;
117
+ };
118
+
119
+ const PROPOSAL_DRAFT_STORAGE_KEY = 'contact-proposal-form-draft';
120
+
121
+ // ─── Option arrays ───────────────────────────────────────────────────────────
122
+
123
+ const CONTRACT_CATEGORY_OPTIONS: ProposalContractCategory[] = [
124
+ 'client',
125
+ 'supplier',
126
+ 'vendor',
127
+ 'partner',
128
+ 'employee',
129
+ 'contractor',
130
+ 'internal',
131
+ 'other',
132
+ ];
133
+
134
+ const CONTRACT_TYPE_OPTIONS: ProposalContractType[] = [
135
+ 'service_agreement',
136
+ 'recurring_service',
137
+ 'fixed_term',
138
+ 'freelancer_agreement',
139
+ 'pj',
140
+ 'clt',
141
+ 'nda',
142
+ 'amendment',
143
+ 'addendum',
144
+ 'other',
145
+ ];
146
+
147
+ const BILLING_MODEL_OPTIONS: ProposalBillingModel[] = [
148
+ 'fixed_price',
149
+ 'monthly_retainer',
150
+ 'time_and_material',
151
+ ];
152
+
153
+ const ITEM_TYPE_OPTIONS: ProposalItemType[] = [
154
+ 'service',
155
+ 'product',
156
+ 'fee',
157
+ 'discount',
158
+ 'note',
159
+ 'other',
160
+ ];
161
+
162
+ const RECURRENCE_OPTIONS: ProposalRecurrence[] = [
163
+ 'one_time',
164
+ 'monthly',
165
+ 'quarterly',
166
+ 'yearly',
167
+ 'other',
168
+ ];
169
+
170
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
171
+
172
+ function createEmptyProposalItem(
173
+ overrides: Partial<ProposalFormItemValues> = {}
174
+ ): ProposalFormItemValues {
175
+ return {
176
+ name: '',
177
+ description: '',
178
+ itemType: 'service',
179
+ quantity: 1,
180
+ unitAmount: 0,
181
+ recurrence: 'one_time',
182
+ startDate: '',
183
+ endDate: '',
184
+ ...overrides,
185
+ };
186
+ }
187
+
188
+ function createDefaultProposalFormValues(): ProposalFormValues {
189
+ return {
190
+ code: '',
191
+ title: '',
192
+ validUntil: '',
193
+ contractCategory: 'client',
194
+ contractType: 'service_agreement',
195
+ billingModel: 'fixed_price',
196
+ summary: '',
197
+ notes: '',
198
+ items: [createEmptyProposalItem()],
199
+ };
200
+ }
201
+
202
+ function toDateInputValue(value?: string | null) {
203
+ if (!value) return '';
204
+ const parsed = new Date(value);
205
+ if (Number.isNaN(parsed.getTime())) return '';
206
+ return parsed.toISOString().slice(0, 10);
207
+ }
208
+
209
+ function toIsoDateFromInputValue(value?: string | null) {
210
+ if (!value) return null;
211
+ return new Date(`${value}T00:00:00`).toISOString();
212
+ }
213
+
214
+ function mapProposalItemToFormValue(
215
+ item?: ProposalRecord['proposal_revision'] extends Array<infer R>
216
+ ? R extends { proposal_item?: Array<infer I> }
217
+ ? I
218
+ : never
219
+ : never,
220
+ fallback?: {
221
+ name?: string;
222
+ description?: string | null;
223
+ amountInCents?: number | null;
224
+ }
225
+ ): ProposalFormItemValues {
226
+ return createEmptyProposalItem({
227
+ name: (item as any)?.name ?? fallback?.name ?? '',
228
+ description: (item as any)?.description ?? fallback?.description ?? '',
229
+ itemType:
230
+ ((item as any)?.item_type as ProposalItemType | undefined) ?? 'service',
231
+ quantity: Number((item as any)?.quantity ?? 1),
232
+ unitAmount:
233
+ Number(
234
+ (item as any)?.unit_amount_cents ??
235
+ (item as any)?.total_amount_cents ??
236
+ fallback?.amountInCents ??
237
+ 0
238
+ ) / 100,
239
+ recurrence:
240
+ ((item as any)?.recurrence as ProposalRecurrence | undefined) ??
241
+ 'one_time',
242
+ startDate: toDateInputValue((item as any)?.start_date),
243
+ endDate: toDateInputValue((item as any)?.end_date),
244
+ });
245
+ }
246
+
247
+ // ─── Component ───────────────────────────────────────────────────────────────
248
+
249
+ export type ProposalFormSheetProps = {
250
+ proposalId: number | null;
251
+ personId: number;
252
+ open: boolean;
253
+ onClose: () => void;
254
+ onSaved: (savedProposalId: number) => void;
255
+ };
256
+
257
+ export function ProposalFormSheet({
258
+ proposalId,
259
+ personId,
260
+ open,
261
+ onClose,
262
+ onSaved,
263
+ }: ProposalFormSheetProps) {
264
+ const t = useTranslations('contact.CrmPipeline');
265
+ const { request, currentLocaleCode, getSettingValue } = useApp();
266
+ const locale = currentLocaleCode?.startsWith('pt') ? 'pt-BR' : 'en-US';
267
+
268
+ const [isSaving, setIsSaving] = useState(false);
269
+ const [selectedPersonId, setSelectedPersonId] = useState(personId);
270
+ const [selectedPersonName, setSelectedPersonName] = useState('');
271
+ const [editPersonOpen, setEditPersonOpen] = useState(false);
272
+ const [editPersonData, setEditPersonData] = useState<Person | null>(null);
273
+ const [isLoadingEditPerson, setIsLoadingEditPerson] = useState(false);
274
+
275
+ const form = useForm<ProposalFormValues>({
276
+ resolver: zodResolver(proposalFormSchema),
277
+ defaultValues: createDefaultProposalFormValues(),
278
+ });
279
+
280
+ const {
281
+ fields: itemFields,
282
+ append: appendItem,
283
+ remove: removeItem,
284
+ } = useFieldArray({
285
+ control: form.control,
286
+ name: 'items',
287
+ });
288
+
289
+ const watchedValues = useWatch({ control: form.control });
290
+ const watchedItems = useMemo(
291
+ () => watchedValues.items ?? [],
292
+ [watchedValues.items]
293
+ );
294
+
295
+ const {
296
+ clearDraft,
297
+ loadDraft,
298
+ hasDraft,
299
+ savedAt: draftSavedAt,
300
+ } = useFormDraft<ProposalDraftPayload>({
301
+ storageKey: PROPOSAL_DRAFT_STORAGE_KEY,
302
+ value: {
303
+ personId,
304
+ proposalId,
305
+ mode: proposalId ? 'edit' : 'create',
306
+ values: {
307
+ code: watchedValues.code ?? '',
308
+ title: watchedValues.title ?? '',
309
+ validUntil: watchedValues.validUntil ?? '',
310
+ contractCategory: watchedValues.contractCategory ?? 'client',
311
+ contractType: watchedValues.contractType ?? 'service_agreement',
312
+ billingModel: watchedValues.billingModel ?? 'fixed_price',
313
+ summary: watchedValues.summary ?? '',
314
+ notes: watchedValues.notes ?? '',
315
+ items: watchedValues.items?.map((item) =>
316
+ createEmptyProposalItem(item)
317
+ ) ?? [createEmptyProposalItem()],
318
+ },
319
+ },
320
+ hasData:
321
+ (watchedValues.code ?? '').trim().length > 0 ||
322
+ (watchedValues.title ?? '').trim().length > 0 ||
323
+ (watchedValues.validUntil ?? '').trim().length > 0 ||
324
+ (watchedValues.summary ?? '').trim().length > 0 ||
325
+ (watchedValues.notes ?? '').trim().length > 0 ||
326
+ (watchedValues.contractCategory ?? 'client') !== 'client' ||
327
+ (watchedValues.contractType ?? 'service_agreement') !==
328
+ 'service_agreement' ||
329
+ (watchedValues.billingModel ?? 'fixed_price') !== 'fixed_price' ||
330
+ (watchedValues.items ?? []).some(
331
+ (item) =>
332
+ (item.name ?? '').trim().length > 0 ||
333
+ (item.description ?? '').trim().length > 0 ||
334
+ Number(item.quantity ?? 1) !== 1 ||
335
+ Number(item.unitAmount ?? 0) !== 0 ||
336
+ (item.recurrence ?? 'one_time') !== 'one_time' ||
337
+ (item.startDate ?? '').trim().length > 0 ||
338
+ (item.endDate ?? '').trim().length > 0 ||
339
+ (item.itemType ?? 'service') !== 'service'
340
+ ),
341
+ enabled: open,
342
+ });
343
+
344
+ const { data: proposalDetail, isLoading: isLoadingDetail } =
345
+ useQuery<ProposalRecord>({
346
+ queryKey: ['contact-proposal-form-detail', proposalId, currentLocaleCode],
347
+ enabled: open && proposalId !== null,
348
+ queryFn: async () => {
349
+ const response = await request<ProposalRecord>({
350
+ url: `/proposal/${proposalId}`,
351
+ method: 'GET',
352
+ });
353
+ return response.data;
354
+ },
355
+ placeholderData: (previous) => previous,
356
+ });
357
+
358
+ const { data: contactTypes = [] } = useQuery<ContactTypeOption[]>({
359
+ queryKey: ['contact-person-contact-types', currentLocaleCode],
360
+ enabled: open,
361
+ queryFn: async () => {
362
+ const response = await request<{ data: ContactTypeOption[] }>({
363
+ url: '/person-contact-type?pageSize=100',
364
+ method: 'GET',
365
+ });
366
+ return response.data.data || [];
367
+ },
368
+ placeholderData: (previous) => previous ?? [],
369
+ });
370
+
371
+ const { data: documentTypes = [] } = useQuery<DocumentTypeOption[]>({
372
+ queryKey: ['contact-person-document-types', currentLocaleCode],
373
+ enabled: open,
374
+ queryFn: async () => {
375
+ const response = await request<{ data: DocumentTypeOption[] }>({
376
+ url: '/person-document-type?pageSize=100',
377
+ method: 'GET',
378
+ });
379
+ return response.data.data || [];
380
+ },
381
+ placeholderData: (previous) => previous ?? [],
382
+ });
383
+
384
+ const draftStatusContent = useMemo(() => {
385
+ if (!hasDraft || !draftSavedAt) return null;
386
+ const savedDate = new Date(draftSavedAt);
387
+ if (Number.isNaN(savedDate.getTime())) return null;
388
+ const localeValue = currentLocaleCode.startsWith('pt') ? ptBR : enUS;
389
+ const relativeLabel = formatDistanceToNow(savedDate, {
390
+ addSuffix: true,
391
+ locale: localeValue,
392
+ });
393
+ const absoluteLabel = formatDateTime(
394
+ savedDate,
395
+ getSettingValue,
396
+ currentLocaleCode
397
+ );
398
+ return currentLocaleCode.startsWith('pt')
399
+ ? `Rascunho salvo ${relativeLabel} • Último salvamento: ${absoluteLabel}`
400
+ : `Draft saved ${relativeLabel} • Last saved: ${absoluteLabel}`;
401
+ }, [draftSavedAt, currentLocaleCode, getSettingValue, hasDraft]);
402
+
403
+ const pricingSummary = useMemo(() => {
404
+ const subtotalCents = (watchedItems ?? []).reduce((sum, item) => {
405
+ if (item?.itemType === 'discount') return sum;
406
+ return (
407
+ sum +
408
+ Math.round(
409
+ Number(item?.quantity ?? 0) * Number(item?.unitAmount ?? 0) * 100
410
+ )
411
+ );
412
+ }, 0);
413
+ const discountCents = (watchedItems ?? []).reduce((sum, item) => {
414
+ if (item?.itemType !== 'discount') return sum;
415
+ return (
416
+ sum +
417
+ Math.round(
418
+ Number(item?.quantity ?? 0) * Number(item?.unitAmount ?? 0) * 100
419
+ )
420
+ );
421
+ }, 0);
422
+ return {
423
+ subtotalCents,
424
+ discountCents,
425
+ totalCents: Math.max(subtotalCents - discountCents, 0),
426
+ };
427
+ }, [watchedItems]);
428
+
429
+ const formatCurrency = (
430
+ cents?: number | null,
431
+ currencyCode?: string | null
432
+ ) =>
433
+ new Intl.NumberFormat(locale, {
434
+ style: 'currency',
435
+ currency: currencyCode || 'BRL',
436
+ minimumFractionDigits: 2,
437
+ maximumFractionDigits: 2,
438
+ }).format(Number(cents ?? 0) / 100);
439
+
440
+ const getProposalEnumLabel = (
441
+ group:
442
+ | 'contractCategory'
443
+ | 'contractType'
444
+ | 'billingModel'
445
+ | 'itemType'
446
+ | 'recurrence',
447
+ value?: string | null
448
+ ) => {
449
+ if (!value) return '—';
450
+ try {
451
+ return t(`proposals.enum.${group}.${value}` as Parameters<typeof t>[0]);
452
+ } catch {
453
+ return formatEnumLabel(value);
454
+ }
455
+ };
456
+
457
+ // Sync selectedPersonId from prop when sheet opens
458
+ useEffect(() => {
459
+ if (open) {
460
+ setSelectedPersonId(personId);
461
+ }
462
+ }, [open, personId]);
463
+
464
+ // Sync selectedPersonName from proposal detail
465
+ useEffect(() => {
466
+ if (proposalDetail?.person) {
467
+ const name =
468
+ (proposalDetail.person as any).trade_name ||
469
+ (proposalDetail.person as any).name ||
470
+ '';
471
+ setSelectedPersonName(name);
472
+ }
473
+ }, [proposalDetail]);
474
+
475
+ // Populate form when sheet opens or proposalDetail is available
476
+ useEffect(() => {
477
+ if (!open) {
478
+ form.reset(createDefaultProposalFormValues());
479
+ return;
480
+ }
481
+
482
+ const storedDraft = loadDraft();
483
+
484
+ // Create mode
485
+ if (proposalId === null) {
486
+ if (
487
+ storedDraft?.payload.mode === 'create' &&
488
+ storedDraft.payload.personId === personId
489
+ ) {
490
+ form.reset({
491
+ ...createDefaultProposalFormValues(),
492
+ ...storedDraft.payload.values,
493
+ items:
494
+ storedDraft.payload.values.items?.length > 0
495
+ ? storedDraft.payload.values.items.map((item) =>
496
+ createEmptyProposalItem(item)
497
+ )
498
+ : [createEmptyProposalItem()],
499
+ });
500
+ } else {
501
+ form.reset(createDefaultProposalFormValues());
502
+ }
503
+ return;
504
+ }
505
+
506
+ // Edit mode — wait for detail to load
507
+ if (!proposalDetail) return;
508
+
509
+ if (
510
+ storedDraft?.payload.mode === 'edit' &&
511
+ storedDraft.payload.personId === personId &&
512
+ storedDraft.payload.proposalId === proposalId
513
+ ) {
514
+ form.reset({
515
+ ...createDefaultProposalFormValues(),
516
+ ...storedDraft.payload.values,
517
+ items:
518
+ storedDraft.payload.values.items?.length > 0
519
+ ? storedDraft.payload.values.items.map((item) =>
520
+ createEmptyProposalItem(item)
521
+ )
522
+ : [createEmptyProposalItem()],
523
+ });
524
+ return;
525
+ }
526
+
527
+ const currentRevision = getCurrentRevision(proposalDetail);
528
+ const revisionItems = currentRevision?.proposal_item?.length
529
+ ? currentRevision.proposal_item.map((item) =>
530
+ mapProposalItemToFormValue(item as any)
531
+ )
532
+ : [
533
+ mapProposalItemToFormValue(undefined, {
534
+ name: proposalDetail.title,
535
+ description: currentRevision?.summary ?? proposalDetail.notes ?? '',
536
+ amountInCents: proposalDetail.total_amount_cents,
537
+ }),
538
+ ];
539
+
540
+ form.reset({
541
+ code: proposalDetail.code ?? '',
542
+ title: proposalDetail.title,
543
+ validUntil: toDateInputValue(proposalDetail.valid_until),
544
+ contractCategory: proposalDetail.contract_category ?? 'client',
545
+ contractType: proposalDetail.contract_type ?? 'service_agreement',
546
+ billingModel: proposalDetail.billing_model ?? 'fixed_price',
547
+ summary: currentRevision?.summary ?? '',
548
+ notes: proposalDetail.notes ?? '',
549
+ items: revisionItems,
550
+ });
551
+ }, [proposalDetail, open, proposalId, personId, form, loadDraft]);
552
+
553
+ const handleSave = async (values: ProposalFormValues) => {
554
+ const normalizedItems = values.items
555
+ .map((item) => {
556
+ const quantity = Number(item.quantity ?? 0);
557
+ const unitAmount = Number(item.unitAmount ?? 0);
558
+ const unitAmountCents = Math.round(unitAmount * 100);
559
+ const totalAmountCents = Math.round(quantity * unitAmount * 100);
560
+ return {
561
+ name: item.name.trim(),
562
+ description: item.description?.trim() || null,
563
+ quantity,
564
+ unit_amount_cents: unitAmountCents,
565
+ total_amount_cents: totalAmountCents,
566
+ item_type: item.itemType,
567
+ term_type: 'value',
568
+ recurrence: item.recurrence,
569
+ start_date: toIsoDateFromInputValue(item.startDate),
570
+ end_date: toIsoDateFromInputValue(item.endDate),
571
+ };
572
+ })
573
+ .filter((item) => item.name.length > 0);
574
+
575
+ const subtotalAmountCents = normalizedItems.reduce((sum, item) => {
576
+ if (item.item_type === 'discount') return sum;
577
+ return sum + Number(item.total_amount_cents ?? 0);
578
+ }, 0);
579
+
580
+ const discountAmountCents = normalizedItems.reduce((sum, item) => {
581
+ if (item.item_type !== 'discount') return sum;
582
+ return sum + Number(item.total_amount_cents ?? 0);
583
+ }, 0);
584
+
585
+ const totalAmountCents = Math.max(
586
+ subtotalAmountCents - discountAmountCents,
587
+ 0
588
+ );
589
+
590
+ const payload = {
591
+ person_id: selectedPersonId,
592
+ code: values.code?.trim() || undefined,
593
+ title: values.title.trim(),
594
+ contract_category: values.contractCategory,
595
+ contract_type: values.contractType,
596
+ billing_model: values.billingModel,
597
+ currency_code: 'BRL',
598
+ valid_until: toIsoDateFromInputValue(values.validUntil),
599
+ subtotal_amount_cents: subtotalAmountCents,
600
+ discount_amount_cents: discountAmountCents,
601
+ total_amount_cents: totalAmountCents,
602
+ summary: values.summary?.trim() || null,
603
+ notes: values.notes?.trim() || null,
604
+ items: normalizedItems,
605
+ };
606
+
607
+ try {
608
+ setIsSaving(true);
609
+
610
+ if (proposalId) {
611
+ await request({
612
+ url: `/proposal/${proposalId}`,
613
+ method: 'PATCH',
614
+ data: {
615
+ ...payload,
616
+ create_new_revision: true,
617
+ },
618
+ });
619
+ toast.success(t('proposals.toasts.updateSuccess'));
620
+ clearDraft();
621
+ onSaved(proposalId);
622
+ } else {
623
+ const response = await request<ProposalRecord>({
624
+ url: '/proposal',
625
+ method: 'POST',
626
+ data: payload,
627
+ });
628
+ const createdProposal = response.data;
629
+ toast.success(t('proposals.toasts.createSuccess'));
630
+ clearDraft();
631
+ onSaved(createdProposal.id);
632
+ }
633
+ } catch (error) {
634
+ const message =
635
+ error instanceof Error
636
+ ? error.message
637
+ : proposalId
638
+ ? t('proposals.toasts.updateError')
639
+ : t('proposals.toasts.createError');
640
+ toast.error(message);
641
+ } finally {
642
+ setIsSaving(false);
643
+ }
644
+ };
645
+
646
+ const handleOpenEditPerson = async () => {
647
+ if (!selectedPersonId) return;
648
+ try {
649
+ setIsLoadingEditPerson(true);
650
+ const response = await request<Person>({
651
+ url: `/person/${selectedPersonId}`,
652
+ method: 'GET',
653
+ });
654
+ setEditPersonData(response.data);
655
+ setEditPersonOpen(true);
656
+ } catch {
657
+ toast.error('Failed to load person data');
658
+ } finally {
659
+ setIsLoadingEditPerson(false);
660
+ }
661
+ };
662
+
663
+ return (
664
+ <>
665
+ <Sheet
666
+ open={open}
667
+ onOpenChange={(isOpen) => {
668
+ if (!isOpen) onClose();
669
+ }}
670
+ >
671
+ <SheetContent className="flex h-full w-full max-w-[95vw] flex-col overflow-hidden p-0 sm:max-w-2xl xl:max-w-4xl">
672
+ <SheetHeader className="shrink-0 border-b px-5 py-4 text-left">
673
+ <div className="flex items-start gap-3 pr-6">
674
+ <div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted text-muted-foreground">
675
+ <FileText className="size-5" />
676
+ </div>
677
+ <div className="space-y-1">
678
+ <SheetTitle>
679
+ {proposalId
680
+ ? t('proposals.form.editTitle')
681
+ : t('proposals.form.createTitle')}
682
+ </SheetTitle>
683
+ <SheetDescription>
684
+ {proposalId
685
+ ? t('proposals.form.editDescription')
686
+ : t('proposals.form.createDescription')}
687
+ </SheetDescription>
688
+ </div>
689
+ </div>
690
+ </SheetHeader>
691
+
692
+ {open && proposalId !== null && isLoadingDetail ? (
693
+ <div className="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground">
694
+ <Loader2 className="size-4 animate-spin" />
695
+ {t('proposals.loading')}
696
+ </div>
697
+ ) : (
698
+ <Form {...form}>
699
+ <form
700
+ onSubmit={form.handleSubmit(handleSave)}
701
+ className="flex flex-1 flex-col overflow-hidden"
702
+ >
703
+ <div className="flex-1 overflow-y-auto px-5 py-4">
704
+ <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
705
+ <div className="lg:col-span-2">
706
+ <div className="flex items-end gap-2">
707
+ <div className="flex-1">
708
+ <PersonPicker
709
+ label={t('proposals.form.client')}
710
+ entityLabel={t('proposals.form.clientEntity')}
711
+ value={selectedPersonId}
712
+ onChange={(id: number | null, name: string) => {
713
+ if (id !== null) {
714
+ setSelectedPersonId(id);
715
+ setSelectedPersonName(name);
716
+ }
717
+ }}
718
+ selectPlaceholder={t(
719
+ 'proposals.form.clientPlaceholder'
720
+ )}
721
+ clearable={false}
722
+ initialSelectedLabel={selectedPersonName}
723
+ />
724
+ </div>
725
+ {selectedPersonId ? (
726
+ <Button
727
+ type="button"
728
+ variant="outline"
729
+ size="icon"
730
+ disabled={isLoadingEditPerson}
731
+ onClick={() => void handleOpenEditPerson()}
732
+ title={t('proposals.form.editClient')}
733
+ >
734
+ {isLoadingEditPerson ? (
735
+ <Loader2 className="h-4 w-4 animate-spin" />
736
+ ) : (
737
+ <Pencil className="h-4 w-4" />
738
+ )}
739
+ </Button>
740
+ ) : null}
741
+ </div>
742
+ </div>
743
+
744
+ <FormField
745
+ control={form.control}
746
+ name="title"
747
+ render={({ field }) => (
748
+ <FormItem className="lg:col-span-2">
749
+ <FormLabel>{t('proposals.form.title')}</FormLabel>
750
+ <FormControl>
751
+ <Input
752
+ {...field}
753
+ placeholder={t('proposals.form.titlePlaceholder')}
754
+ />
755
+ </FormControl>
756
+ <FormMessage />
757
+ </FormItem>
758
+ )}
759
+ />
760
+
761
+ <FormField
762
+ control={form.control}
763
+ name="code"
764
+ render={({ field }) => (
765
+ <FormItem>
766
+ <FormLabel>{t('proposals.form.code')}</FormLabel>
767
+ <FormControl>
768
+ <Input
769
+ {...field}
770
+ value={field.value ?? ''}
771
+ placeholder={t('proposals.form.codePlaceholder')}
772
+ />
773
+ </FormControl>
774
+ <FormMessage />
775
+ </FormItem>
776
+ )}
777
+ />
778
+
779
+ <div className="space-y-3 lg:col-span-2">
780
+ <div className="flex flex-col gap-3 rounded-xl border border-border/60 bg-muted/10 p-4 sm:flex-row sm:items-start sm:justify-between">
781
+ <div className="space-y-1">
782
+ <p className="text-sm font-medium text-foreground">
783
+ {t('proposals.form.items')}
784
+ </p>
785
+ <p className="text-xs text-muted-foreground">
786
+ {t('proposals.form.itemsDescription')}
787
+ </p>
788
+ </div>
789
+
790
+ <Button
791
+ type="button"
792
+ variant="outline"
793
+ size="sm"
794
+ className="gap-2 self-start"
795
+ onClick={() => appendItem(createEmptyProposalItem())}
796
+ >
797
+ <Plus className="size-4" />
798
+ {t('proposals.form.addItem')}
799
+ </Button>
800
+ </div>
801
+
802
+ <div className="space-y-3">
803
+ {itemFields.map((itemField, index) => {
804
+ const watchedItem = watchedItems?.[index];
805
+ const lineTotalCents = Math.round(
806
+ Number(watchedItem?.quantity ?? 0) *
807
+ Number(watchedItem?.unitAmount ?? 0) *
808
+ 100
809
+ );
810
+ const isDiscountItem =
811
+ watchedItem?.itemType === 'discount';
812
+
813
+ return (
814
+ <div
815
+ key={itemField.id}
816
+ className="rounded-xl border border-border/60 bg-background p-3 shadow-xs"
817
+ >
818
+ <div className="flex flex-wrap items-start justify-between gap-2">
819
+ <div>
820
+ <p className="text-sm font-semibold text-foreground">
821
+ {t('proposals.form.itemLabel', {
822
+ number: index + 1,
823
+ })}
824
+ </p>
825
+ <p className="text-xs text-muted-foreground">
826
+ {isDiscountItem
827
+ ? t('proposals.form.discountHint')
828
+ : t('proposals.form.itemHint')}
829
+ </p>
830
+ </div>
831
+
832
+ <Button
833
+ type="button"
834
+ variant="ghost"
835
+ size="sm"
836
+ className="gap-2 text-muted-foreground"
837
+ onClick={() => removeItem(index)}
838
+ disabled={itemFields.length === 1}
839
+ >
840
+ <Trash2 className="size-4" />
841
+ {t('proposals.form.removeItem')}
842
+ </Button>
843
+ </div>
844
+
845
+ <div className="mt-3 grid grid-cols-1 gap-3 xl:grid-cols-6">
846
+ <FormField
847
+ control={form.control}
848
+ name={`items.${index}.name` as const}
849
+ render={({ field }) => (
850
+ <FormItem className="xl:col-span-2">
851
+ <FormLabel>
852
+ {t('proposals.form.itemName')}
853
+ </FormLabel>
854
+ <FormControl>
855
+ <Input
856
+ {...field}
857
+ value={field.value ?? ''}
858
+ placeholder={t(
859
+ 'proposals.form.itemNamePlaceholder'
860
+ )}
861
+ />
862
+ </FormControl>
863
+ <FormMessage />
864
+ </FormItem>
865
+ )}
866
+ />
867
+
868
+ <FormField
869
+ control={form.control}
870
+ name={`items.${index}.itemType` as const}
871
+ render={({ field }) => (
872
+ <FormItem>
873
+ <FormLabel>
874
+ {t('proposals.form.itemType')}
875
+ </FormLabel>
876
+ <Select
877
+ onValueChange={field.onChange}
878
+ value={field.value}
879
+ >
880
+ <FormControl>
881
+ <SelectTrigger className="w-full">
882
+ <SelectValue />
883
+ </SelectTrigger>
884
+ </FormControl>
885
+ <SelectContent>
886
+ {ITEM_TYPE_OPTIONS.map((option) => (
887
+ <SelectItem
888
+ key={option}
889
+ value={option}
890
+ >
891
+ {getProposalEnumLabel(
892
+ 'itemType',
893
+ option
894
+ )}
895
+ </SelectItem>
896
+ ))}
897
+ </SelectContent>
898
+ </Select>
899
+ <FormMessage />
900
+ </FormItem>
901
+ )}
902
+ />
903
+
904
+ <FormField
905
+ control={form.control}
906
+ name={`items.${index}.recurrence` as const}
907
+ render={({ field }) => (
908
+ <FormItem>
909
+ <FormLabel>
910
+ {t('proposals.form.recurrence')}
911
+ </FormLabel>
912
+ <Select
913
+ onValueChange={field.onChange}
914
+ value={field.value}
915
+ >
916
+ <FormControl>
917
+ <SelectTrigger className="w-full">
918
+ <SelectValue />
919
+ </SelectTrigger>
920
+ </FormControl>
921
+ <SelectContent>
922
+ {RECURRENCE_OPTIONS.map((option) => (
923
+ <SelectItem
924
+ key={option}
925
+ value={option}
926
+ >
927
+ {getProposalEnumLabel(
928
+ 'recurrence',
929
+ option
930
+ )}
931
+ </SelectItem>
932
+ ))}
933
+ </SelectContent>
934
+ </Select>
935
+ <FormMessage />
936
+ </FormItem>
937
+ )}
938
+ />
939
+
940
+ <FormField
941
+ control={form.control}
942
+ name={`items.${index}.quantity` as const}
943
+ render={({ field }) => (
944
+ <FormItem>
945
+ <FormLabel>
946
+ {t('proposals.form.quantity')}
947
+ </FormLabel>
948
+ <FormControl>
949
+ <Input
950
+ {...field}
951
+ type="number"
952
+ min="0"
953
+ step="0.01"
954
+ value={field.value ?? 0}
955
+ onChange={(event) =>
956
+ field.onChange(event.target.value)
957
+ }
958
+ />
959
+ </FormControl>
960
+ <FormMessage />
961
+ </FormItem>
962
+ )}
963
+ />
964
+
965
+ <FormField
966
+ control={form.control}
967
+ name={`items.${index}.unitAmount` as const}
968
+ render={({ field }) => (
969
+ <FormItem>
970
+ <FormLabel>
971
+ {t('proposals.form.unitAmount')}
972
+ </FormLabel>
973
+ <FormControl>
974
+ <InputMoney
975
+ ref={field.ref}
976
+ name={field.name}
977
+ value={field.value ?? 0}
978
+ onBlur={field.onBlur}
979
+ onValueChange={(value) =>
980
+ field.onChange(value ?? 0)
981
+ }
982
+ placeholder="0,00"
983
+ />
984
+ </FormControl>
985
+ <FormMessage />
986
+ </FormItem>
987
+ )}
988
+ />
989
+
990
+ <FormField
991
+ control={form.control}
992
+ name={`items.${index}.description` as const}
993
+ render={({ field }) => (
994
+ <FormItem className="xl:col-span-4">
995
+ <FormLabel>
996
+ {t('proposals.form.itemDescription')}
997
+ </FormLabel>
998
+ <FormControl>
999
+ <Textarea
1000
+ {...field}
1001
+ value={field.value ?? ''}
1002
+ rows={3}
1003
+ placeholder={t(
1004
+ 'proposals.form.itemDescriptionPlaceholder'
1005
+ )}
1006
+ />
1007
+ </FormControl>
1008
+ <FormMessage />
1009
+ </FormItem>
1010
+ )}
1011
+ />
1012
+
1013
+ <FormField
1014
+ control={form.control}
1015
+ name={`items.${index}.startDate` as const}
1016
+ render={({ field }) => (
1017
+ <FormItem>
1018
+ <FormLabel>
1019
+ {t('proposals.form.startDate')}
1020
+ </FormLabel>
1021
+ <FormControl>
1022
+ <Input
1023
+ type="date"
1024
+ {...field}
1025
+ value={field.value ?? ''}
1026
+ />
1027
+ </FormControl>
1028
+ <FormMessage />
1029
+ </FormItem>
1030
+ )}
1031
+ />
1032
+
1033
+ <FormField
1034
+ control={form.control}
1035
+ name={`items.${index}.endDate` as const}
1036
+ render={({ field }) => (
1037
+ <FormItem>
1038
+ <FormLabel>
1039
+ {t('proposals.form.endDate')}
1040
+ </FormLabel>
1041
+ <FormControl>
1042
+ <Input
1043
+ type="date"
1044
+ {...field}
1045
+ value={field.value ?? ''}
1046
+ />
1047
+ </FormControl>
1048
+ <FormMessage />
1049
+ </FormItem>
1050
+ )}
1051
+ />
1052
+ </div>
1053
+
1054
+ <div className="mt-3 flex justify-end">
1055
+ <div className="rounded-lg bg-muted/40 px-3 py-2 text-sm font-medium text-foreground">
1056
+ {t('proposals.form.lineTotal')}:{' '}
1057
+ {isDiscountItem ? '- ' : ''}
1058
+ {formatCurrency(lineTotalCents, 'BRL')}
1059
+ </div>
1060
+ </div>
1061
+ </div>
1062
+ );
1063
+ })}
1064
+ </div>
1065
+
1066
+ <div className="grid gap-2 rounded-xl border border-border/60 bg-muted/15 p-4 sm:grid-cols-3">
1067
+ <div>
1068
+ <p className="text-xs text-muted-foreground">
1069
+ {t('proposals.form.subtotal')}
1070
+ </p>
1071
+ <p className="text-base font-semibold text-foreground">
1072
+ {formatCurrency(
1073
+ pricingSummary.subtotalCents,
1074
+ 'BRL'
1075
+ )}
1076
+ </p>
1077
+ </div>
1078
+ <div>
1079
+ <p className="text-xs text-muted-foreground">
1080
+ {t('proposals.form.discount')}
1081
+ </p>
1082
+ <p className="text-base font-semibold text-foreground">
1083
+ -{' '}
1084
+ {formatCurrency(
1085
+ pricingSummary.discountCents,
1086
+ 'BRL'
1087
+ )}
1088
+ </p>
1089
+ </div>
1090
+ <div>
1091
+ <p className="text-xs text-muted-foreground">
1092
+ {t('proposals.form.calculatedTotal')}
1093
+ </p>
1094
+ <p className="text-base font-semibold text-foreground">
1095
+ {formatCurrency(pricingSummary.totalCents, 'BRL')}
1096
+ </p>
1097
+ </div>
1098
+ </div>
1099
+ </div>
1100
+
1101
+ <FormField
1102
+ control={form.control}
1103
+ name="validUntil"
1104
+ render={({ field }) => (
1105
+ <FormItem>
1106
+ <FormLabel>
1107
+ {t('proposals.form.validUntil')}
1108
+ </FormLabel>
1109
+ <FormControl>
1110
+ <Input
1111
+ type="date"
1112
+ {...field}
1113
+ value={field.value ?? ''}
1114
+ />
1115
+ </FormControl>
1116
+ <FormMessage />
1117
+ </FormItem>
1118
+ )}
1119
+ />
1120
+
1121
+ <FormField
1122
+ control={form.control}
1123
+ name="contractCategory"
1124
+ render={({ field }) => (
1125
+ <FormItem>
1126
+ <FormLabel>
1127
+ {t('proposals.form.contractCategory')}
1128
+ </FormLabel>
1129
+ <Select
1130
+ onValueChange={field.onChange}
1131
+ value={field.value}
1132
+ >
1133
+ <FormControl>
1134
+ <SelectTrigger className="w-full">
1135
+ <SelectValue />
1136
+ </SelectTrigger>
1137
+ </FormControl>
1138
+ <SelectContent>
1139
+ {CONTRACT_CATEGORY_OPTIONS.map((option) => (
1140
+ <SelectItem key={option} value={option}>
1141
+ {getProposalEnumLabel(
1142
+ 'contractCategory',
1143
+ option
1144
+ )}
1145
+ </SelectItem>
1146
+ ))}
1147
+ </SelectContent>
1148
+ </Select>
1149
+ <FormMessage />
1150
+ </FormItem>
1151
+ )}
1152
+ />
1153
+
1154
+ <FormField
1155
+ control={form.control}
1156
+ name="contractType"
1157
+ render={({ field }) => (
1158
+ <FormItem>
1159
+ <FormLabel>
1160
+ {t('proposals.form.contractType')}
1161
+ </FormLabel>
1162
+ <Select
1163
+ onValueChange={field.onChange}
1164
+ value={field.value}
1165
+ >
1166
+ <FormControl>
1167
+ <SelectTrigger className="w-full">
1168
+ <SelectValue />
1169
+ </SelectTrigger>
1170
+ </FormControl>
1171
+ <SelectContent>
1172
+ {CONTRACT_TYPE_OPTIONS.map((option) => (
1173
+ <SelectItem key={option} value={option}>
1174
+ {getProposalEnumLabel('contractType', option)}
1175
+ </SelectItem>
1176
+ ))}
1177
+ </SelectContent>
1178
+ </Select>
1179
+ <FormMessage />
1180
+ </FormItem>
1181
+ )}
1182
+ />
1183
+
1184
+ <FormField
1185
+ control={form.control}
1186
+ name="billingModel"
1187
+ render={({ field }) => (
1188
+ <FormItem className="lg:col-span-2">
1189
+ <FormLabel>
1190
+ {t('proposals.form.billingModel')}
1191
+ </FormLabel>
1192
+ <Select
1193
+ onValueChange={field.onChange}
1194
+ value={field.value}
1195
+ >
1196
+ <FormControl>
1197
+ <SelectTrigger className="w-full">
1198
+ <SelectValue />
1199
+ </SelectTrigger>
1200
+ </FormControl>
1201
+ <SelectContent>
1202
+ {BILLING_MODEL_OPTIONS.map((option) => (
1203
+ <SelectItem key={option} value={option}>
1204
+ {getProposalEnumLabel('billingModel', option)}
1205
+ </SelectItem>
1206
+ ))}
1207
+ </SelectContent>
1208
+ </Select>
1209
+ <FormMessage />
1210
+ </FormItem>
1211
+ )}
1212
+ />
1213
+
1214
+ <FormField
1215
+ control={form.control}
1216
+ name="summary"
1217
+ render={({ field }) => (
1218
+ <FormItem className="lg:col-span-2">
1219
+ <FormLabel>{t('proposals.form.summary')}</FormLabel>
1220
+ <FormControl>
1221
+ <Textarea
1222
+ {...field}
1223
+ value={field.value ?? ''}
1224
+ rows={4}
1225
+ placeholder={t(
1226
+ 'proposals.form.summaryPlaceholder'
1227
+ )}
1228
+ />
1229
+ </FormControl>
1230
+ <FormMessage />
1231
+ </FormItem>
1232
+ )}
1233
+ />
1234
+
1235
+ <FormField
1236
+ control={form.control}
1237
+ name="notes"
1238
+ render={({ field }) => (
1239
+ <FormItem className="lg:col-span-2">
1240
+ <FormLabel>{t('proposals.form.notes')}</FormLabel>
1241
+ <FormControl>
1242
+ <Textarea
1243
+ {...field}
1244
+ value={field.value ?? ''}
1245
+ rows={6}
1246
+ placeholder={t('proposals.form.notesPlaceholder')}
1247
+ />
1248
+ </FormControl>
1249
+ <FormMessage />
1250
+ </FormItem>
1251
+ )}
1252
+ />
1253
+ </div>
1254
+ </div>
1255
+
1256
+ <div className="shrink-0 border-t px-5 py-3">
1257
+ {draftStatusContent ? (
1258
+ <p className="mb-2 text-xs text-muted-foreground">
1259
+ {draftStatusContent}
1260
+ </p>
1261
+ ) : null}
1262
+ <div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
1263
+ <Button
1264
+ type="button"
1265
+ variant="outline"
1266
+ onClick={onClose}
1267
+ disabled={isSaving}
1268
+ >
1269
+ {t('proposals.actions.cancel')}
1270
+ </Button>
1271
+ <Button type="submit" disabled={isSaving} className="gap-2">
1272
+ {isSaving ? (
1273
+ <Loader2 className="size-4 animate-spin" />
1274
+ ) : null}
1275
+ {t('proposals.actions.save')}
1276
+ </Button>
1277
+ </div>
1278
+ </div>
1279
+ </form>
1280
+ </Form>
1281
+ )}
1282
+ </SheetContent>
1283
+ </Sheet>
1284
+
1285
+ <PersonFormSheet
1286
+ open={editPersonOpen}
1287
+ person={editPersonData}
1288
+ contactTypes={contactTypes}
1289
+ documentTypes={documentTypes}
1290
+ onOpenChange={(isOpen: boolean) => {
1291
+ setEditPersonOpen(isOpen);
1292
+ if (!isOpen) setEditPersonData(null);
1293
+ }}
1294
+ onSuccess={async (updatedPerson?: Person) => {
1295
+ if (updatedPerson) {
1296
+ const name =
1297
+ (updatedPerson as any).trade_name || updatedPerson.name || '';
1298
+ setSelectedPersonName(name);
1299
+ }
1300
+ setEditPersonOpen(false);
1301
+ setEditPersonData(null);
1302
+ }}
1303
+ />
1304
+ </>
1305
+ );
1306
+ }