@hed-hog/contact 0.0.303 → 0.0.305

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +225 -17
  2. package/dist/person/dto/account.dto.d.ts +5 -0
  3. package/dist/person/dto/account.dto.d.ts.map +1 -1
  4. package/dist/person/dto/account.dto.js +29 -0
  5. package/dist/person/dto/account.dto.js.map +1 -1
  6. package/dist/person/dto/import-preview.dto.d.ts +7 -0
  7. package/dist/person/dto/import-preview.dto.d.ts.map +1 -0
  8. package/dist/person/dto/import-preview.dto.js +7 -0
  9. package/dist/person/dto/import-preview.dto.js.map +1 -0
  10. package/dist/person/dto/import.dto.d.ts +15 -0
  11. package/dist/person/dto/import.dto.d.ts.map +1 -0
  12. package/dist/person/dto/import.dto.js +51 -0
  13. package/dist/person/dto/import.dto.js.map +1 -0
  14. package/dist/person/person.controller.d.ts +14 -0
  15. package/dist/person/person.controller.d.ts.map +1 -1
  16. package/dist/person/person.controller.js +53 -0
  17. package/dist/person/person.controller.js.map +1 -1
  18. package/dist/person/person.service.d.ts +19 -0
  19. package/dist/person/person.service.d.ts.map +1 -1
  20. package/dist/person/person.service.js +481 -67
  21. package/dist/person/person.service.js.map +1 -1
  22. package/dist/person-relation-type/person-relation-type.controller.d.ts +2 -2
  23. package/dist/person-relation-type/person-relation-type.service.d.ts +2 -2
  24. package/hedhog/data/route.yaml +6 -0
  25. package/hedhog/frontend/app/accounts/_components/account-form-sheet.tsx.ejs +2242 -484
  26. package/hedhog/frontend/app/accounts/_components/account-types.ts.ejs +51 -0
  27. package/hedhog/frontend/app/accounts/page.tsx.ejs +181 -16
  28. package/hedhog/frontend/app/contact-type/page.tsx.ejs +223 -29
  29. package/hedhog/frontend/app/document-type/page.tsx.ejs +248 -37
  30. package/hedhog/frontend/app/follow-ups/page.tsx.ejs +129 -19
  31. package/hedhog/frontend/app/person/_components/person-field-with-create.tsx.ejs +78 -212
  32. package/hedhog/frontend/app/person/_components/person-form-sheet.tsx.ejs +760 -178
  33. package/hedhog/frontend/app/person/_components/person-import-sheet.tsx.ejs +1120 -0
  34. package/hedhog/frontend/app/person/_components/person-interaction-dialog.tsx.ejs +171 -4
  35. package/hedhog/frontend/app/person/page.tsx.ejs +17 -0
  36. package/hedhog/frontend/app/pipeline/_components/lead-proposals-tab.tsx.ejs +696 -82
  37. package/hedhog/frontend/messages/en.json +140 -2
  38. package/hedhog/frontend/messages/pt.json +147 -9
  39. package/package.json +5 -5
  40. package/src/person/dto/account.dto.ts +31 -0
  41. package/src/person/dto/import-preview.dto.ts +6 -0
  42. package/src/person/dto/import.dto.ts +61 -0
  43. package/src/person/person.controller.ts +74 -12
  44. package/src/person/person.service.ts +615 -68
@@ -51,9 +51,13 @@ import {
51
51
  SheetTitle,
52
52
  } from '@/components/ui/sheet';
53
53
  import { Textarea } from '@/components/ui/textarea';
54
+ import { useFormDraft } from '@/hooks/use-form-draft';
55
+ import { formatDateTime } from '@/lib/format-date';
54
56
  import { cn } from '@/lib/utils';
55
57
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
56
58
  import { zodResolver } from '@hookform/resolvers/zod';
59
+ import { formatDistanceToNow } from 'date-fns';
60
+ import { enUS, ptBR } from 'date-fns/locale';
57
61
  import {
58
62
  CheckCircle2,
59
63
  Download,
@@ -68,7 +72,7 @@ import {
68
72
  } from 'lucide-react';
69
73
  import { useTranslations } from 'next-intl';
70
74
  import { useEffect, useMemo, useState } from 'react';
71
- import { useForm } from 'react-hook-form';
75
+ import { useFieldArray, useForm, useWatch } from 'react-hook-form';
72
76
  import { toast } from 'sonner';
73
77
  import { z } from 'zod';
74
78
 
@@ -112,6 +116,21 @@ type ProposalBillingModel =
112
116
  | 'monthly_retainer'
113
117
  | 'fixed_price';
114
118
 
119
+ type ProposalItemType =
120
+ | 'service'
121
+ | 'product'
122
+ | 'fee'
123
+ | 'discount'
124
+ | 'note'
125
+ | 'other';
126
+
127
+ type ProposalRecurrence =
128
+ | 'one_time'
129
+ | 'monthly'
130
+ | 'quarterly'
131
+ | 'yearly'
132
+ | 'other';
133
+
115
134
  type ProposalItem = {
116
135
  id?: number;
117
136
  name: string;
@@ -119,8 +138,12 @@ type ProposalItem = {
119
138
  quantity?: number | null;
120
139
  unit_amount_cents?: number | null;
121
140
  total_amount_cents?: number | null;
122
- item_type?: string | null;
123
- recurrence?: string | null;
141
+ item_type?: ProposalItemType | string | null;
142
+ term_type?: string | null;
143
+ recurrence?: ProposalRecurrence | string | null;
144
+ start_date?: string | null;
145
+ end_date?: string | null;
146
+ due_day?: number | null;
124
147
  };
125
148
 
126
149
  type ProposalRevision = {
@@ -196,10 +219,20 @@ type LeadProposalsTabProps = {
196
219
  onLeadUpdated: (lead: CrmLead) => Promise<void> | void;
197
220
  };
198
221
 
222
+ const proposalItemFormSchema = z.object({
223
+ name: z.string().trim().min(1),
224
+ description: z.string().optional(),
225
+ itemType: z.enum(['service', 'product', 'fee', 'discount', 'note', 'other']),
226
+ quantity: z.coerce.number().min(0),
227
+ unitAmount: z.coerce.number().min(0),
228
+ recurrence: z.enum(['one_time', 'monthly', 'quarterly', 'yearly', 'other']),
229
+ startDate: z.string().optional(),
230
+ endDate: z.string().optional(),
231
+ });
232
+
199
233
  const proposalFormSchema = z.object({
200
234
  code: z.string().max(40).optional(),
201
235
  title: z.string().trim().min(1),
202
- totalAmount: z.coerce.number().min(0),
203
236
  validUntil: z.string().optional(),
204
237
  contractCategory: z.enum([
205
238
  'employee',
@@ -230,9 +263,21 @@ const proposalFormSchema = z.object({
230
263
  ]),
231
264
  summary: z.string().optional(),
232
265
  notes: z.string().optional(),
266
+ items: z.array(proposalItemFormSchema).min(1),
233
267
  });
234
268
 
235
269
  type ProposalFormValues = z.infer<typeof proposalFormSchema>;
270
+ type ProposalFormItemValues = z.infer<typeof proposalItemFormSchema>;
271
+
272
+ type ProposalDraftPayload = {
273
+ leadId: number;
274
+ proposalId: number | null;
275
+ mode: 'create' | 'edit';
276
+ values: ProposalFormValues;
277
+ };
278
+
279
+ const LEAD_PROPOSAL_DRAFT_STORAGE_KEY =
280
+ 'contact-pipeline-lead-proposal-form-draft';
236
281
 
237
282
  const CONTRACT_CATEGORY_OPTIONS: ProposalContractCategory[] = [
238
283
  'client',
@@ -264,6 +309,53 @@ const BILLING_MODEL_OPTIONS: ProposalBillingModel[] = [
264
309
  'time_and_material',
265
310
  ];
266
311
 
312
+ const ITEM_TYPE_OPTIONS: ProposalItemType[] = [
313
+ 'service',
314
+ 'product',
315
+ 'fee',
316
+ 'discount',
317
+ 'note',
318
+ 'other',
319
+ ];
320
+
321
+ const RECURRENCE_OPTIONS: ProposalRecurrence[] = [
322
+ 'one_time',
323
+ 'monthly',
324
+ 'quarterly',
325
+ 'yearly',
326
+ 'other',
327
+ ];
328
+
329
+ function createEmptyProposalItem(
330
+ overrides: Partial<ProposalFormItemValues> = {}
331
+ ): ProposalFormItemValues {
332
+ return {
333
+ name: '',
334
+ description: '',
335
+ itemType: 'service',
336
+ quantity: 1,
337
+ unitAmount: 0,
338
+ recurrence: 'one_time',
339
+ startDate: '',
340
+ endDate: '',
341
+ ...overrides,
342
+ };
343
+ }
344
+
345
+ function createDefaultProposalFormValues(): ProposalFormValues {
346
+ return {
347
+ code: '',
348
+ title: '',
349
+ validUntil: '',
350
+ contractCategory: 'client',
351
+ contractType: 'service_agreement',
352
+ billingModel: 'fixed_price',
353
+ summary: '',
354
+ notes: '',
355
+ items: [createEmptyProposalItem()],
356
+ };
357
+ }
358
+
267
359
  function toDateInputValue(value?: string | null) {
268
360
  if (!value) return '';
269
361
  const parsed = new Date(value);
@@ -281,6 +373,38 @@ function formatEnumLabel(value?: string | null) {
281
373
  .join(' ');
282
374
  }
283
375
 
376
+ function toIsoDateFromInputValue(value?: string | null) {
377
+ if (!value) return null;
378
+ return new Date(`${value}T00:00:00`).toISOString();
379
+ }
380
+
381
+ function mapProposalItemToFormValue(
382
+ item?: ProposalItem | null,
383
+ fallback?: {
384
+ name?: string;
385
+ description?: string | null;
386
+ amountInCents?: number | null;
387
+ }
388
+ ): ProposalFormItemValues {
389
+ return createEmptyProposalItem({
390
+ name: item?.name ?? fallback?.name ?? '',
391
+ description: item?.description ?? fallback?.description ?? '',
392
+ itemType: (item?.item_type as ProposalItemType | undefined) ?? 'service',
393
+ quantity: Number(item?.quantity ?? 1),
394
+ unitAmount:
395
+ Number(
396
+ item?.unit_amount_cents ??
397
+ item?.total_amount_cents ??
398
+ fallback?.amountInCents ??
399
+ 0
400
+ ) / 100,
401
+ recurrence:
402
+ (item?.recurrence as ProposalRecurrence | undefined) ?? 'one_time',
403
+ startDate: toDateInputValue(item?.start_date),
404
+ endDate: toDateInputValue(item?.end_date),
405
+ });
406
+ }
407
+
284
408
  function openStoredFile(fileId?: number | null) {
285
409
  if (!fileId) return;
286
410
  const baseUrl = String(process.env.NEXT_PUBLIC_API_BASE_URL || '');
@@ -306,7 +430,7 @@ export function LeadProposalsTab({
306
430
  onLeadUpdated,
307
431
  }: LeadProposalsTabProps) {
308
432
  const t = useTranslations('contact.CrmPipeline');
309
- const { request, currentLocaleCode } = useApp();
433
+ const { request, currentLocaleCode, getSettingValue } = useApp();
310
434
  const locale = currentLocaleCode?.startsWith('pt') ? 'pt-BR' : 'en-US';
311
435
 
312
436
  const [selectedProposalId, setSelectedProposalId] = useState<number | null>(
@@ -322,19 +446,129 @@ export function LeadProposalsTab({
322
446
 
323
447
  const form = useForm<ProposalFormValues>({
324
448
  resolver: zodResolver(proposalFormSchema),
325
- defaultValues: {
326
- code: '',
327
- title: '',
328
- totalAmount: 0,
329
- validUntil: '',
330
- contractCategory: 'client',
331
- contractType: 'service_agreement',
332
- billingModel: 'fixed_price',
333
- summary: '',
334
- notes: '',
449
+ defaultValues: createDefaultProposalFormValues(),
450
+ });
451
+
452
+ const {
453
+ fields: itemFields,
454
+ append: appendItem,
455
+ remove: removeItem,
456
+ } = useFieldArray({
457
+ control: form.control,
458
+ name: 'items',
459
+ });
460
+
461
+ const watchedValues = useWatch({
462
+ control: form.control,
463
+ });
464
+ const watchedItems = useMemo(
465
+ () => watchedValues.items ?? [],
466
+ [watchedValues.items]
467
+ );
468
+
469
+ const {
470
+ clearDraft,
471
+ loadDraft,
472
+ hasDraft,
473
+ savedAt: draftSavedAt,
474
+ } = useFormDraft<ProposalDraftPayload>({
475
+ storageKey: LEAD_PROPOSAL_DRAFT_STORAGE_KEY,
476
+ value: {
477
+ leadId: lead.id,
478
+ proposalId: editingProposal?.id ?? null,
479
+ mode: editingProposal ? 'edit' : 'create',
480
+ values: {
481
+ code: watchedValues.code ?? '',
482
+ title: watchedValues.title ?? '',
483
+ validUntil: watchedValues.validUntil ?? '',
484
+ contractCategory: watchedValues.contractCategory ?? 'client',
485
+ contractType: watchedValues.contractType ?? 'service_agreement',
486
+ billingModel: watchedValues.billingModel ?? 'fixed_price',
487
+ summary: watchedValues.summary ?? '',
488
+ notes: watchedValues.notes ?? '',
489
+ items: watchedValues.items?.map((item) =>
490
+ createEmptyProposalItem(item)
491
+ ) ?? [createEmptyProposalItem()],
492
+ },
335
493
  },
494
+ hasData:
495
+ (watchedValues.code ?? '').trim().length > 0 ||
496
+ (watchedValues.title ?? '').trim().length > 0 ||
497
+ (watchedValues.validUntil ?? '').trim().length > 0 ||
498
+ (watchedValues.summary ?? '').trim().length > 0 ||
499
+ (watchedValues.notes ?? '').trim().length > 0 ||
500
+ (watchedValues.contractCategory ?? 'client') !== 'client' ||
501
+ (watchedValues.contractType ?? 'service_agreement') !==
502
+ 'service_agreement' ||
503
+ (watchedValues.billingModel ?? 'fixed_price') !== 'fixed_price' ||
504
+ (watchedValues.items ?? []).some(
505
+ (item) =>
506
+ (item.name ?? '').trim().length > 0 ||
507
+ (item.description ?? '').trim().length > 0 ||
508
+ Number(item.quantity ?? 1) !== 1 ||
509
+ Number(item.unitAmount ?? 0) !== 0 ||
510
+ (item.recurrence ?? 'one_time') !== 'one_time' ||
511
+ (item.startDate ?? '').trim().length > 0 ||
512
+ (item.endDate ?? '').trim().length > 0 ||
513
+ (item.itemType ?? 'service') !== 'service'
514
+ ),
515
+ enabled: formOpen,
336
516
  });
337
517
 
518
+ const draftStatusContent = useMemo(() => {
519
+ if (!hasDraft || !draftSavedAt) {
520
+ return null;
521
+ }
522
+
523
+ const savedDate = new Date(draftSavedAt);
524
+ if (Number.isNaN(savedDate.getTime())) {
525
+ return null;
526
+ }
527
+
528
+ const localeValue = currentLocaleCode.startsWith('pt') ? ptBR : enUS;
529
+ const relativeLabel = formatDistanceToNow(savedDate, {
530
+ addSuffix: true,
531
+ locale: localeValue,
532
+ });
533
+ const absoluteLabel = formatDateTime(
534
+ savedDate,
535
+ getSettingValue,
536
+ currentLocaleCode
537
+ );
538
+
539
+ return currentLocaleCode.startsWith('pt')
540
+ ? `Rascunho salvo ${relativeLabel} • Último salvamento: ${absoluteLabel}`
541
+ : `Draft saved ${relativeLabel} • Last saved: ${absoluteLabel}`;
542
+ }, [draftSavedAt, currentLocaleCode, getSettingValue, hasDraft]);
543
+
544
+ const pricingSummary = useMemo(() => {
545
+ const subtotalCents = (watchedItems ?? []).reduce((sum, item) => {
546
+ if (item?.itemType === 'discount') return sum;
547
+ return (
548
+ sum +
549
+ Math.round(
550
+ Number(item?.quantity ?? 0) * Number(item?.unitAmount ?? 0) * 100
551
+ )
552
+ );
553
+ }, 0);
554
+
555
+ const discountCents = (watchedItems ?? []).reduce((sum, item) => {
556
+ if (item?.itemType !== 'discount') return sum;
557
+ return (
558
+ sum +
559
+ Math.round(
560
+ Number(item?.quantity ?? 0) * Number(item?.unitAmount ?? 0) * 100
561
+ )
562
+ );
563
+ }, 0);
564
+
565
+ return {
566
+ subtotalCents,
567
+ discountCents,
568
+ totalCents: Math.max(subtotalCents - discountCents, 0),
569
+ };
570
+ }, [watchedItems]);
571
+
338
572
  const {
339
573
  data: proposalPage = { data: [], total: 0, page: 1, pageSize: 20 },
340
574
  isLoading: isLoadingProposals,
@@ -400,50 +634,81 @@ export function LeadProposalsTab({
400
634
 
401
635
  useEffect(() => {
402
636
  if (!formOpen) {
637
+ form.reset(createDefaultProposalFormValues());
638
+ setEditingProposal(null);
639
+ return;
640
+ }
641
+
642
+ const storedDraft = loadDraft();
643
+
644
+ if (
645
+ !editingProposal &&
646
+ storedDraft?.payload.mode === 'create' &&
647
+ storedDraft.payload.leadId === lead.id
648
+ ) {
403
649
  form.reset({
404
- code: '',
405
- title: '',
406
- totalAmount: 0,
407
- validUntil: '',
408
- contractCategory: 'client',
409
- contractType: 'service_agreement',
410
- billingModel: 'fixed_price',
411
- summary: '',
412
- notes: '',
650
+ ...createDefaultProposalFormValues(),
651
+ ...storedDraft.payload.values,
652
+ items:
653
+ storedDraft.payload.values.items?.length > 0
654
+ ? storedDraft.payload.values.items.map((item) =>
655
+ createEmptyProposalItem(item)
656
+ )
657
+ : [createEmptyProposalItem()],
413
658
  });
414
- setEditingProposal(null);
415
659
  return;
416
660
  }
417
661
 
418
- if (!editingProposal) {
662
+ if (
663
+ editingProposal &&
664
+ storedDraft?.payload.mode === 'edit' &&
665
+ storedDraft.payload.leadId === lead.id &&
666
+ storedDraft.payload.proposalId === editingProposal.id
667
+ ) {
419
668
  form.reset({
420
- code: '',
421
- title: '',
422
- totalAmount: 0,
423
- validUntil: '',
424
- contractCategory: 'client',
425
- contractType: 'service_agreement',
426
- billingModel: 'fixed_price',
427
- summary: '',
428
- notes: '',
669
+ ...createDefaultProposalFormValues(),
670
+ ...storedDraft.payload.values,
671
+ items:
672
+ storedDraft.payload.values.items?.length > 0
673
+ ? storedDraft.payload.values.items.map((item) =>
674
+ createEmptyProposalItem(item)
675
+ )
676
+ : [createEmptyProposalItem()],
429
677
  });
430
678
  return;
431
679
  }
432
680
 
681
+ if (!editingProposal) {
682
+ form.reset(createDefaultProposalFormValues());
683
+ return;
684
+ }
685
+
433
686
  const currentRevision = getCurrentRevision(editingProposal);
687
+ const revisionItems = currentRevision?.proposal_item?.length
688
+ ? currentRevision.proposal_item.map((item) =>
689
+ mapProposalItemToFormValue(item)
690
+ )
691
+ : [
692
+ mapProposalItemToFormValue(undefined, {
693
+ name: editingProposal.title,
694
+ description:
695
+ currentRevision?.summary ?? editingProposal.notes ?? '',
696
+ amountInCents: editingProposal.total_amount_cents,
697
+ }),
698
+ ];
434
699
 
435
700
  form.reset({
436
701
  code: editingProposal.code ?? '',
437
702
  title: editingProposal.title,
438
- totalAmount: Number(editingProposal.total_amount_cents ?? 0) / 100,
439
703
  validUntil: toDateInputValue(editingProposal.valid_until),
440
704
  contractCategory: editingProposal.contract_category ?? 'client',
441
705
  contractType: editingProposal.contract_type ?? 'service_agreement',
442
706
  billingModel: editingProposal.billing_model ?? 'fixed_price',
443
707
  summary: currentRevision?.summary ?? '',
444
708
  notes: editingProposal.notes ?? '',
709
+ items: revisionItems,
445
710
  });
446
- }, [editingProposal, form, formOpen]);
711
+ }, [editingProposal, form, formOpen, lead.id, loadDraft]);
447
712
 
448
713
  const selectedProposal = useMemo(() => {
449
714
  const matchingDetail =
@@ -513,7 +778,12 @@ export function LeadProposalsTab({
513
778
  };
514
779
 
515
780
  const getProposalEnumLabel = (
516
- group: 'contractCategory' | 'contractType' | 'billingModel',
781
+ group:
782
+ | 'contractCategory'
783
+ | 'contractType'
784
+ | 'billingModel'
785
+ | 'itemType'
786
+ | 'recurrence',
517
787
  value?: string | null
518
788
  ) => {
519
789
  if (!value) return '—';
@@ -584,7 +854,43 @@ export function LeadProposalsTab({
584
854
  };
585
855
 
586
856
  const handleSave = async (values: ProposalFormValues) => {
587
- const amountInCents = Math.round(Number(values.totalAmount || 0) * 100);
857
+ const normalizedItems = values.items
858
+ .map((item) => {
859
+ const quantity = Number(item.quantity ?? 0);
860
+ const unitAmount = Number(item.unitAmount ?? 0);
861
+ const unitAmountCents = Math.round(unitAmount * 100);
862
+ const totalAmountCents = Math.round(quantity * unitAmount * 100);
863
+
864
+ return {
865
+ name: item.name.trim(),
866
+ description: item.description?.trim() || null,
867
+ quantity,
868
+ unit_amount_cents: unitAmountCents,
869
+ total_amount_cents: totalAmountCents,
870
+ item_type: item.itemType,
871
+ term_type: 'value',
872
+ recurrence: item.recurrence,
873
+ start_date: toIsoDateFromInputValue(item.startDate),
874
+ end_date: toIsoDateFromInputValue(item.endDate),
875
+ };
876
+ })
877
+ .filter((item) => item.name.length > 0);
878
+
879
+ const subtotalAmountCents = normalizedItems.reduce((sum, item) => {
880
+ if (item.item_type === 'discount') return sum;
881
+ return sum + Number(item.total_amount_cents ?? 0);
882
+ }, 0);
883
+
884
+ const discountAmountCents = normalizedItems.reduce((sum, item) => {
885
+ if (item.item_type !== 'discount') return sum;
886
+ return sum + Number(item.total_amount_cents ?? 0);
887
+ }, 0);
888
+
889
+ const totalAmountCents = Math.max(
890
+ subtotalAmountCents - discountAmountCents,
891
+ 0
892
+ );
893
+
588
894
  const payload = {
589
895
  person_id: lead.id,
590
896
  code: values.code?.trim() || undefined,
@@ -593,29 +899,13 @@ export function LeadProposalsTab({
593
899
  contract_type: values.contractType,
594
900
  billing_model: values.billingModel,
595
901
  currency_code: 'BRL',
596
- valid_until: values.validUntil
597
- ? new Date(`${values.validUntil}T00:00:00`).toISOString()
598
- : null,
599
- subtotal_amount_cents: amountInCents,
600
- total_amount_cents: amountInCents,
902
+ valid_until: toIsoDateFromInputValue(values.validUntil),
903
+ subtotal_amount_cents: subtotalAmountCents,
904
+ discount_amount_cents: discountAmountCents,
905
+ total_amount_cents: totalAmountCents,
601
906
  summary: values.summary?.trim() || null,
602
907
  notes: values.notes?.trim() || null,
603
- items:
604
- amountInCents > 0
605
- ? [
606
- {
607
- name: values.title.trim(),
608
- description:
609
- values.summary?.trim() || values.notes?.trim() || undefined,
610
- quantity: 1,
611
- unit_amount_cents: amountInCents,
612
- total_amount_cents: amountInCents,
613
- item_type: 'service',
614
- term_type: 'value',
615
- recurrence: 'one_time',
616
- },
617
- ]
618
- : [],
908
+ items: normalizedItems,
619
909
  };
620
910
 
621
911
  try {
@@ -647,6 +937,7 @@ export function LeadProposalsTab({
647
937
  await refreshAll(createdProposal.id);
648
938
  }
649
939
 
940
+ clearDraft();
650
941
  setFormOpen(false);
651
942
  } catch (error) {
652
943
  const message =
@@ -1250,8 +1541,33 @@ export function LeadProposalsTab({
1250
1541
  {item.description}
1251
1542
  </p>
1252
1543
  ) : null}
1544
+
1545
+ <div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
1546
+ {item.item_type ? (
1547
+ <Badge variant="secondary">
1548
+ {getProposalEnumLabel(
1549
+ 'itemType',
1550
+ item.item_type
1551
+ )}
1552
+ </Badge>
1553
+ ) : null}
1554
+ {Number(item.quantity ?? 0) > 0 ? (
1555
+ <span>
1556
+ {`${item.quantity} × ${formatCurrency(item.unit_amount_cents, selectedProposal.currency_code)}`}
1557
+ </span>
1558
+ ) : null}
1559
+ {item.recurrence ? (
1560
+ <Badge variant="outline">
1561
+ {getProposalEnumLabel(
1562
+ 'recurrence',
1563
+ item.recurrence
1564
+ )}
1565
+ </Badge>
1566
+ ) : null}
1567
+ </div>
1253
1568
  </div>
1254
1569
  <span className="text-sm font-semibold text-foreground">
1570
+ {item.item_type === 'discount' ? '- ' : ''}
1255
1571
  {formatCurrency(
1256
1572
  item.total_amount_cents,
1257
1573
  selectedProposal.currency_code
@@ -1413,28 +1729,321 @@ export function LeadProposalsTab({
1413
1729
  )}
1414
1730
  />
1415
1731
 
1416
- <FormField
1417
- control={form.control}
1418
- name="totalAmount"
1419
- render={({ field }) => (
1420
- <FormItem>
1421
- <FormLabel>{t('proposals.form.amount')}</FormLabel>
1422
- <FormControl>
1423
- <InputMoney
1424
- ref={field.ref}
1425
- name={field.name}
1426
- value={field.value ?? 0}
1427
- onBlur={field.onBlur}
1428
- onValueChange={(value) =>
1429
- field.onChange(value ?? 0)
1430
- }
1431
- placeholder="0,00"
1432
- />
1433
- </FormControl>
1434
- <FormMessage />
1435
- </FormItem>
1436
- )}
1437
- />
1732
+ <div className="space-y-3 lg:col-span-2">
1733
+ <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">
1734
+ <div className="space-y-1">
1735
+ <p className="text-sm font-medium text-foreground">
1736
+ {t('proposals.form.items')}
1737
+ </p>
1738
+ <p className="text-xs text-muted-foreground">
1739
+ {t('proposals.form.itemsDescription')}
1740
+ </p>
1741
+ </div>
1742
+
1743
+ <Button
1744
+ type="button"
1745
+ variant="outline"
1746
+ size="sm"
1747
+ className="gap-2 self-start"
1748
+ onClick={() => appendItem(createEmptyProposalItem())}
1749
+ >
1750
+ <Plus className="size-4" />
1751
+ {t('proposals.form.addItem')}
1752
+ </Button>
1753
+ </div>
1754
+
1755
+ <div className="space-y-3">
1756
+ {itemFields.map((itemField, index) => {
1757
+ const watchedItem = watchedItems?.[index];
1758
+ const lineTotalCents = Math.round(
1759
+ Number(watchedItem?.quantity ?? 0) *
1760
+ Number(watchedItem?.unitAmount ?? 0) *
1761
+ 100
1762
+ );
1763
+ const isDiscountItem =
1764
+ watchedItem?.itemType === 'discount';
1765
+
1766
+ return (
1767
+ <div
1768
+ key={itemField.id}
1769
+ className="rounded-xl border border-border/60 bg-background p-3 shadow-xs"
1770
+ >
1771
+ <div className="flex flex-wrap items-start justify-between gap-2">
1772
+ <div>
1773
+ <p className="text-sm font-semibold text-foreground">
1774
+ {t('proposals.form.itemLabel', {
1775
+ number: index + 1,
1776
+ })}
1777
+ </p>
1778
+ <p className="text-xs text-muted-foreground">
1779
+ {isDiscountItem
1780
+ ? t('proposals.form.discountHint')
1781
+ : t('proposals.form.itemHint')}
1782
+ </p>
1783
+ </div>
1784
+
1785
+ <Button
1786
+ type="button"
1787
+ variant="ghost"
1788
+ size="sm"
1789
+ className="gap-2 text-muted-foreground"
1790
+ onClick={() => removeItem(index)}
1791
+ disabled={itemFields.length === 1}
1792
+ >
1793
+ <Trash2 className="size-4" />
1794
+ {t('proposals.form.removeItem')}
1795
+ </Button>
1796
+ </div>
1797
+
1798
+ <div className="mt-3 grid grid-cols-1 gap-3 xl:grid-cols-6">
1799
+ <FormField
1800
+ control={form.control}
1801
+ name={`items.${index}.name` as const}
1802
+ render={({ field }) => (
1803
+ <FormItem className="xl:col-span-2">
1804
+ <FormLabel>
1805
+ {t('proposals.form.itemName')}
1806
+ </FormLabel>
1807
+ <FormControl>
1808
+ <Input
1809
+ {...field}
1810
+ value={field.value ?? ''}
1811
+ placeholder={t(
1812
+ 'proposals.form.itemNamePlaceholder'
1813
+ )}
1814
+ />
1815
+ </FormControl>
1816
+ <FormMessage />
1817
+ </FormItem>
1818
+ )}
1819
+ />
1820
+
1821
+ <FormField
1822
+ control={form.control}
1823
+ name={`items.${index}.itemType` as const}
1824
+ render={({ field }) => (
1825
+ <FormItem>
1826
+ <FormLabel>
1827
+ {t('proposals.form.itemType')}
1828
+ </FormLabel>
1829
+ <Select
1830
+ onValueChange={field.onChange}
1831
+ value={field.value}
1832
+ >
1833
+ <FormControl>
1834
+ <SelectTrigger className="w-full">
1835
+ <SelectValue />
1836
+ </SelectTrigger>
1837
+ </FormControl>
1838
+ <SelectContent>
1839
+ {ITEM_TYPE_OPTIONS.map((option) => (
1840
+ <SelectItem
1841
+ key={option}
1842
+ value={option}
1843
+ >
1844
+ {getProposalEnumLabel(
1845
+ 'itemType',
1846
+ option
1847
+ )}
1848
+ </SelectItem>
1849
+ ))}
1850
+ </SelectContent>
1851
+ </Select>
1852
+ <FormMessage />
1853
+ </FormItem>
1854
+ )}
1855
+ />
1856
+
1857
+ <FormField
1858
+ control={form.control}
1859
+ name={`items.${index}.recurrence` as const}
1860
+ render={({ field }) => (
1861
+ <FormItem>
1862
+ <FormLabel>
1863
+ {t('proposals.form.recurrence')}
1864
+ </FormLabel>
1865
+ <Select
1866
+ onValueChange={field.onChange}
1867
+ value={field.value}
1868
+ >
1869
+ <FormControl>
1870
+ <SelectTrigger className="w-full">
1871
+ <SelectValue />
1872
+ </SelectTrigger>
1873
+ </FormControl>
1874
+ <SelectContent>
1875
+ {RECURRENCE_OPTIONS.map((option) => (
1876
+ <SelectItem
1877
+ key={option}
1878
+ value={option}
1879
+ >
1880
+ {getProposalEnumLabel(
1881
+ 'recurrence',
1882
+ option
1883
+ )}
1884
+ </SelectItem>
1885
+ ))}
1886
+ </SelectContent>
1887
+ </Select>
1888
+ <FormMessage />
1889
+ </FormItem>
1890
+ )}
1891
+ />
1892
+
1893
+ <FormField
1894
+ control={form.control}
1895
+ name={`items.${index}.quantity` as const}
1896
+ render={({ field }) => (
1897
+ <FormItem>
1898
+ <FormLabel>
1899
+ {t('proposals.form.quantity')}
1900
+ </FormLabel>
1901
+ <FormControl>
1902
+ <Input
1903
+ {...field}
1904
+ type="number"
1905
+ min="0"
1906
+ step="0.01"
1907
+ value={field.value ?? 0}
1908
+ onChange={(event) =>
1909
+ field.onChange(event.target.value)
1910
+ }
1911
+ />
1912
+ </FormControl>
1913
+ <FormMessage />
1914
+ </FormItem>
1915
+ )}
1916
+ />
1917
+
1918
+ <FormField
1919
+ control={form.control}
1920
+ name={`items.${index}.unitAmount` as const}
1921
+ render={({ field }) => (
1922
+ <FormItem>
1923
+ <FormLabel>
1924
+ {t('proposals.form.unitAmount')}
1925
+ </FormLabel>
1926
+ <FormControl>
1927
+ <InputMoney
1928
+ ref={field.ref}
1929
+ name={field.name}
1930
+ value={field.value ?? 0}
1931
+ onBlur={field.onBlur}
1932
+ onValueChange={(value) =>
1933
+ field.onChange(value ?? 0)
1934
+ }
1935
+ placeholder="0,00"
1936
+ />
1937
+ </FormControl>
1938
+ <FormMessage />
1939
+ </FormItem>
1940
+ )}
1941
+ />
1942
+
1943
+ <FormField
1944
+ control={form.control}
1945
+ name={`items.${index}.description` as const}
1946
+ render={({ field }) => (
1947
+ <FormItem className="xl:col-span-4">
1948
+ <FormLabel>
1949
+ {t('proposals.form.itemDescription')}
1950
+ </FormLabel>
1951
+ <FormControl>
1952
+ <Textarea
1953
+ {...field}
1954
+ value={field.value ?? ''}
1955
+ rows={3}
1956
+ placeholder={t(
1957
+ 'proposals.form.itemDescriptionPlaceholder'
1958
+ )}
1959
+ />
1960
+ </FormControl>
1961
+ <FormMessage />
1962
+ </FormItem>
1963
+ )}
1964
+ />
1965
+
1966
+ <FormField
1967
+ control={form.control}
1968
+ name={`items.${index}.startDate` as const}
1969
+ render={({ field }) => (
1970
+ <FormItem>
1971
+ <FormLabel>
1972
+ {t('proposals.form.startDate')}
1973
+ </FormLabel>
1974
+ <FormControl>
1975
+ <Input
1976
+ type="date"
1977
+ {...field}
1978
+ value={field.value ?? ''}
1979
+ />
1980
+ </FormControl>
1981
+ <FormMessage />
1982
+ </FormItem>
1983
+ )}
1984
+ />
1985
+
1986
+ <FormField
1987
+ control={form.control}
1988
+ name={`items.${index}.endDate` as const}
1989
+ render={({ field }) => (
1990
+ <FormItem>
1991
+ <FormLabel>
1992
+ {t('proposals.form.endDate')}
1993
+ </FormLabel>
1994
+ <FormControl>
1995
+ <Input
1996
+ type="date"
1997
+ {...field}
1998
+ value={field.value ?? ''}
1999
+ />
2000
+ </FormControl>
2001
+ <FormMessage />
2002
+ </FormItem>
2003
+ )}
2004
+ />
2005
+ </div>
2006
+
2007
+ <div className="mt-3 flex justify-end">
2008
+ <div className="rounded-lg bg-muted/40 px-3 py-2 text-sm font-medium text-foreground">
2009
+ {t('proposals.form.lineTotal')}:{' '}
2010
+ {isDiscountItem ? '- ' : ''}
2011
+ {formatCurrency(lineTotalCents, 'BRL')}
2012
+ </div>
2013
+ </div>
2014
+ </div>
2015
+ );
2016
+ })}
2017
+ </div>
2018
+
2019
+ <div className="grid gap-2 rounded-xl border border-border/60 bg-muted/15 p-4 sm:grid-cols-3">
2020
+ <div>
2021
+ <p className="text-xs text-muted-foreground">
2022
+ {t('proposals.form.subtotal')}
2023
+ </p>
2024
+ <p className="text-base font-semibold text-foreground">
2025
+ {formatCurrency(pricingSummary.subtotalCents, 'BRL')}
2026
+ </p>
2027
+ </div>
2028
+ <div>
2029
+ <p className="text-xs text-muted-foreground">
2030
+ {t('proposals.form.discount')}
2031
+ </p>
2032
+ <p className="text-base font-semibold text-foreground">
2033
+ -{' '}
2034
+ {formatCurrency(pricingSummary.discountCents, 'BRL')}
2035
+ </p>
2036
+ </div>
2037
+ <div>
2038
+ <p className="text-xs text-muted-foreground">
2039
+ {t('proposals.form.calculatedTotal')}
2040
+ </p>
2041
+ <p className="text-base font-semibold text-foreground">
2042
+ {formatCurrency(pricingSummary.totalCents, 'BRL')}
2043
+ </p>
2044
+ </div>
2045
+ </div>
2046
+ </div>
1438
2047
 
1439
2048
  <FormField
1440
2049
  control={form.control}
@@ -1588,6 +2197,11 @@ export function LeadProposalsTab({
1588
2197
  </div>
1589
2198
 
1590
2199
  <div className="shrink-0 border-t px-5 py-3">
2200
+ {draftStatusContent ? (
2201
+ <p className="mb-2 text-xs text-muted-foreground">
2202
+ {draftStatusContent}
2203
+ </p>
2204
+ ) : null}
1591
2205
  <div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
1592
2206
  <Button
1593
2207
  type="button"