@hed-hog/contact 0.0.303 → 0.0.304

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.
@@ -68,7 +68,7 @@ import {
68
68
  } from 'lucide-react';
69
69
  import { useTranslations } from 'next-intl';
70
70
  import { useEffect, useMemo, useState } from 'react';
71
- import { useForm } from 'react-hook-form';
71
+ import { useFieldArray, useForm } from 'react-hook-form';
72
72
  import { toast } from 'sonner';
73
73
  import { z } from 'zod';
74
74
 
@@ -112,6 +112,21 @@ type ProposalBillingModel =
112
112
  | 'monthly_retainer'
113
113
  | 'fixed_price';
114
114
 
115
+ type ProposalItemType =
116
+ | 'service'
117
+ | 'product'
118
+ | 'fee'
119
+ | 'discount'
120
+ | 'note'
121
+ | 'other';
122
+
123
+ type ProposalRecurrence =
124
+ | 'one_time'
125
+ | 'monthly'
126
+ | 'quarterly'
127
+ | 'yearly'
128
+ | 'other';
129
+
115
130
  type ProposalItem = {
116
131
  id?: number;
117
132
  name: string;
@@ -119,8 +134,12 @@ type ProposalItem = {
119
134
  quantity?: number | null;
120
135
  unit_amount_cents?: number | null;
121
136
  total_amount_cents?: number | null;
122
- item_type?: string | null;
123
- recurrence?: string | null;
137
+ item_type?: ProposalItemType | string | null;
138
+ term_type?: string | null;
139
+ recurrence?: ProposalRecurrence | string | null;
140
+ start_date?: string | null;
141
+ end_date?: string | null;
142
+ due_day?: number | null;
124
143
  };
125
144
 
126
145
  type ProposalRevision = {
@@ -196,10 +215,20 @@ type LeadProposalsTabProps = {
196
215
  onLeadUpdated: (lead: CrmLead) => Promise<void> | void;
197
216
  };
198
217
 
218
+ const proposalItemFormSchema = z.object({
219
+ name: z.string().trim().min(1),
220
+ description: z.string().optional(),
221
+ itemType: z.enum(['service', 'product', 'fee', 'discount', 'note', 'other']),
222
+ quantity: z.coerce.number().min(0),
223
+ unitAmount: z.coerce.number().min(0),
224
+ recurrence: z.enum(['one_time', 'monthly', 'quarterly', 'yearly', 'other']),
225
+ startDate: z.string().optional(),
226
+ endDate: z.string().optional(),
227
+ });
228
+
199
229
  const proposalFormSchema = z.object({
200
230
  code: z.string().max(40).optional(),
201
231
  title: z.string().trim().min(1),
202
- totalAmount: z.coerce.number().min(0),
203
232
  validUntil: z.string().optional(),
204
233
  contractCategory: z.enum([
205
234
  'employee',
@@ -230,9 +259,11 @@ const proposalFormSchema = z.object({
230
259
  ]),
231
260
  summary: z.string().optional(),
232
261
  notes: z.string().optional(),
262
+ items: z.array(proposalItemFormSchema).min(1),
233
263
  });
234
264
 
235
265
  type ProposalFormValues = z.infer<typeof proposalFormSchema>;
266
+ type ProposalFormItemValues = z.infer<typeof proposalItemFormSchema>;
236
267
 
237
268
  const CONTRACT_CATEGORY_OPTIONS: ProposalContractCategory[] = [
238
269
  'client',
@@ -264,6 +295,39 @@ const BILLING_MODEL_OPTIONS: ProposalBillingModel[] = [
264
295
  'time_and_material',
265
296
  ];
266
297
 
298
+ const ITEM_TYPE_OPTIONS: ProposalItemType[] = [
299
+ 'service',
300
+ 'product',
301
+ 'fee',
302
+ 'discount',
303
+ 'note',
304
+ 'other',
305
+ ];
306
+
307
+ const RECURRENCE_OPTIONS: ProposalRecurrence[] = [
308
+ 'one_time',
309
+ 'monthly',
310
+ 'quarterly',
311
+ 'yearly',
312
+ 'other',
313
+ ];
314
+
315
+ function createEmptyProposalItem(
316
+ overrides: Partial<ProposalFormItemValues> = {}
317
+ ): ProposalFormItemValues {
318
+ return {
319
+ name: '',
320
+ description: '',
321
+ itemType: 'service',
322
+ quantity: 1,
323
+ unitAmount: 0,
324
+ recurrence: 'one_time',
325
+ startDate: '',
326
+ endDate: '',
327
+ ...overrides,
328
+ };
329
+ }
330
+
267
331
  function toDateInputValue(value?: string | null) {
268
332
  if (!value) return '';
269
333
  const parsed = new Date(value);
@@ -281,6 +345,38 @@ function formatEnumLabel(value?: string | null) {
281
345
  .join(' ');
282
346
  }
283
347
 
348
+ function toIsoDateFromInputValue(value?: string | null) {
349
+ if (!value) return null;
350
+ return new Date(`${value}T00:00:00`).toISOString();
351
+ }
352
+
353
+ function mapProposalItemToFormValue(
354
+ item?: ProposalItem | null,
355
+ fallback?: {
356
+ name?: string;
357
+ description?: string | null;
358
+ amountInCents?: number | null;
359
+ }
360
+ ): ProposalFormItemValues {
361
+ return createEmptyProposalItem({
362
+ name: item?.name ?? fallback?.name ?? '',
363
+ description: item?.description ?? fallback?.description ?? '',
364
+ itemType: (item?.item_type as ProposalItemType | undefined) ?? 'service',
365
+ quantity: Number(item?.quantity ?? 1),
366
+ unitAmount:
367
+ Number(
368
+ item?.unit_amount_cents ??
369
+ item?.total_amount_cents ??
370
+ fallback?.amountInCents ??
371
+ 0
372
+ ) / 100,
373
+ recurrence:
374
+ (item?.recurrence as ProposalRecurrence | undefined) ?? 'one_time',
375
+ startDate: toDateInputValue(item?.start_date),
376
+ endDate: toDateInputValue(item?.end_date),
377
+ });
378
+ }
379
+
284
380
  function openStoredFile(fileId?: number | null) {
285
381
  if (!fileId) return;
286
382
  const baseUrl = String(process.env.NEXT_PUBLIC_API_BASE_URL || '');
@@ -325,16 +421,54 @@ export function LeadProposalsTab({
325
421
  defaultValues: {
326
422
  code: '',
327
423
  title: '',
328
- totalAmount: 0,
329
424
  validUntil: '',
330
425
  contractCategory: 'client',
331
426
  contractType: 'service_agreement',
332
427
  billingModel: 'fixed_price',
333
428
  summary: '',
334
429
  notes: '',
430
+ items: [createEmptyProposalItem()],
335
431
  },
336
432
  });
337
433
 
434
+ const {
435
+ fields: itemFields,
436
+ append: appendItem,
437
+ remove: removeItem,
438
+ } = useFieldArray({
439
+ control: form.control,
440
+ name: 'items',
441
+ });
442
+
443
+ const watchedItems = form.watch('items');
444
+ const pricingSummary = useMemo(() => {
445
+ const subtotalCents = (watchedItems ?? []).reduce((sum, item) => {
446
+ if (item?.itemType === 'discount') return sum;
447
+ return (
448
+ sum +
449
+ Math.round(
450
+ Number(item?.quantity ?? 0) * Number(item?.unitAmount ?? 0) * 100
451
+ )
452
+ );
453
+ }, 0);
454
+
455
+ const discountCents = (watchedItems ?? []).reduce((sum, item) => {
456
+ if (item?.itemType !== 'discount') return sum;
457
+ return (
458
+ sum +
459
+ Math.round(
460
+ Number(item?.quantity ?? 0) * Number(item?.unitAmount ?? 0) * 100
461
+ )
462
+ );
463
+ }, 0);
464
+
465
+ return {
466
+ subtotalCents,
467
+ discountCents,
468
+ totalCents: Math.max(subtotalCents - discountCents, 0),
469
+ };
470
+ }, [watchedItems]);
471
+
338
472
  const {
339
473
  data: proposalPage = { data: [], total: 0, page: 1, pageSize: 20 },
340
474
  isLoading: isLoadingProposals,
@@ -403,13 +537,13 @@ export function LeadProposalsTab({
403
537
  form.reset({
404
538
  code: '',
405
539
  title: '',
406
- totalAmount: 0,
407
540
  validUntil: '',
408
541
  contractCategory: 'client',
409
542
  contractType: 'service_agreement',
410
543
  billingModel: 'fixed_price',
411
544
  summary: '',
412
545
  notes: '',
546
+ items: [createEmptyProposalItem()],
413
547
  });
414
548
  setEditingProposal(null);
415
549
  return;
@@ -419,29 +553,41 @@ export function LeadProposalsTab({
419
553
  form.reset({
420
554
  code: '',
421
555
  title: '',
422
- totalAmount: 0,
423
556
  validUntil: '',
424
557
  contractCategory: 'client',
425
558
  contractType: 'service_agreement',
426
559
  billingModel: 'fixed_price',
427
560
  summary: '',
428
561
  notes: '',
562
+ items: [createEmptyProposalItem()],
429
563
  });
430
564
  return;
431
565
  }
432
566
 
433
567
  const currentRevision = getCurrentRevision(editingProposal);
568
+ const revisionItems = currentRevision?.proposal_item?.length
569
+ ? currentRevision.proposal_item.map((item) =>
570
+ mapProposalItemToFormValue(item)
571
+ )
572
+ : [
573
+ mapProposalItemToFormValue(undefined, {
574
+ name: editingProposal.title,
575
+ description:
576
+ currentRevision?.summary ?? editingProposal.notes ?? '',
577
+ amountInCents: editingProposal.total_amount_cents,
578
+ }),
579
+ ];
434
580
 
435
581
  form.reset({
436
582
  code: editingProposal.code ?? '',
437
583
  title: editingProposal.title,
438
- totalAmount: Number(editingProposal.total_amount_cents ?? 0) / 100,
439
584
  validUntil: toDateInputValue(editingProposal.valid_until),
440
585
  contractCategory: editingProposal.contract_category ?? 'client',
441
586
  contractType: editingProposal.contract_type ?? 'service_agreement',
442
587
  billingModel: editingProposal.billing_model ?? 'fixed_price',
443
588
  summary: currentRevision?.summary ?? '',
444
589
  notes: editingProposal.notes ?? '',
590
+ items: revisionItems,
445
591
  });
446
592
  }, [editingProposal, form, formOpen]);
447
593
 
@@ -513,7 +659,12 @@ export function LeadProposalsTab({
513
659
  };
514
660
 
515
661
  const getProposalEnumLabel = (
516
- group: 'contractCategory' | 'contractType' | 'billingModel',
662
+ group:
663
+ | 'contractCategory'
664
+ | 'contractType'
665
+ | 'billingModel'
666
+ | 'itemType'
667
+ | 'recurrence',
517
668
  value?: string | null
518
669
  ) => {
519
670
  if (!value) return '—';
@@ -584,7 +735,43 @@ export function LeadProposalsTab({
584
735
  };
585
736
 
586
737
  const handleSave = async (values: ProposalFormValues) => {
587
- const amountInCents = Math.round(Number(values.totalAmount || 0) * 100);
738
+ const normalizedItems = values.items
739
+ .map((item) => {
740
+ const quantity = Number(item.quantity ?? 0);
741
+ const unitAmount = Number(item.unitAmount ?? 0);
742
+ const unitAmountCents = Math.round(unitAmount * 100);
743
+ const totalAmountCents = Math.round(quantity * unitAmount * 100);
744
+
745
+ return {
746
+ name: item.name.trim(),
747
+ description: item.description?.trim() || null,
748
+ quantity,
749
+ unit_amount_cents: unitAmountCents,
750
+ total_amount_cents: totalAmountCents,
751
+ item_type: item.itemType,
752
+ term_type: 'value',
753
+ recurrence: item.recurrence,
754
+ start_date: toIsoDateFromInputValue(item.startDate),
755
+ end_date: toIsoDateFromInputValue(item.endDate),
756
+ };
757
+ })
758
+ .filter((item) => item.name.length > 0);
759
+
760
+ const subtotalAmountCents = normalizedItems.reduce((sum, item) => {
761
+ if (item.item_type === 'discount') return sum;
762
+ return sum + Number(item.total_amount_cents ?? 0);
763
+ }, 0);
764
+
765
+ const discountAmountCents = normalizedItems.reduce((sum, item) => {
766
+ if (item.item_type !== 'discount') return sum;
767
+ return sum + Number(item.total_amount_cents ?? 0);
768
+ }, 0);
769
+
770
+ const totalAmountCents = Math.max(
771
+ subtotalAmountCents - discountAmountCents,
772
+ 0
773
+ );
774
+
588
775
  const payload = {
589
776
  person_id: lead.id,
590
777
  code: values.code?.trim() || undefined,
@@ -593,29 +780,13 @@ export function LeadProposalsTab({
593
780
  contract_type: values.contractType,
594
781
  billing_model: values.billingModel,
595
782
  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,
783
+ valid_until: toIsoDateFromInputValue(values.validUntil),
784
+ subtotal_amount_cents: subtotalAmountCents,
785
+ discount_amount_cents: discountAmountCents,
786
+ total_amount_cents: totalAmountCents,
601
787
  summary: values.summary?.trim() || null,
602
788
  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
- : [],
789
+ items: normalizedItems,
619
790
  };
620
791
 
621
792
  try {
@@ -1250,8 +1421,33 @@ export function LeadProposalsTab({
1250
1421
  {item.description}
1251
1422
  </p>
1252
1423
  ) : null}
1424
+
1425
+ <div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
1426
+ {item.item_type ? (
1427
+ <Badge variant="secondary">
1428
+ {getProposalEnumLabel(
1429
+ 'itemType',
1430
+ item.item_type
1431
+ )}
1432
+ </Badge>
1433
+ ) : null}
1434
+ {Number(item.quantity ?? 0) > 0 ? (
1435
+ <span>
1436
+ {`${item.quantity} × ${formatCurrency(item.unit_amount_cents, selectedProposal.currency_code)}`}
1437
+ </span>
1438
+ ) : null}
1439
+ {item.recurrence ? (
1440
+ <Badge variant="outline">
1441
+ {getProposalEnumLabel(
1442
+ 'recurrence',
1443
+ item.recurrence
1444
+ )}
1445
+ </Badge>
1446
+ ) : null}
1447
+ </div>
1253
1448
  </div>
1254
1449
  <span className="text-sm font-semibold text-foreground">
1450
+ {item.item_type === 'discount' ? '- ' : ''}
1255
1451
  {formatCurrency(
1256
1452
  item.total_amount_cents,
1257
1453
  selectedProposal.currency_code
@@ -1413,28 +1609,321 @@ export function LeadProposalsTab({
1413
1609
  )}
1414
1610
  />
1415
1611
 
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
- />
1612
+ <div className="space-y-3 lg:col-span-2">
1613
+ <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">
1614
+ <div className="space-y-1">
1615
+ <p className="text-sm font-medium text-foreground">
1616
+ {t('proposals.form.items')}
1617
+ </p>
1618
+ <p className="text-xs text-muted-foreground">
1619
+ {t('proposals.form.itemsDescription')}
1620
+ </p>
1621
+ </div>
1622
+
1623
+ <Button
1624
+ type="button"
1625
+ variant="outline"
1626
+ size="sm"
1627
+ className="gap-2 self-start"
1628
+ onClick={() => appendItem(createEmptyProposalItem())}
1629
+ >
1630
+ <Plus className="size-4" />
1631
+ {t('proposals.form.addItem')}
1632
+ </Button>
1633
+ </div>
1634
+
1635
+ <div className="space-y-3">
1636
+ {itemFields.map((itemField, index) => {
1637
+ const watchedItem = watchedItems?.[index];
1638
+ const lineTotalCents = Math.round(
1639
+ Number(watchedItem?.quantity ?? 0) *
1640
+ Number(watchedItem?.unitAmount ?? 0) *
1641
+ 100
1642
+ );
1643
+ const isDiscountItem =
1644
+ watchedItem?.itemType === 'discount';
1645
+
1646
+ return (
1647
+ <div
1648
+ key={itemField.id}
1649
+ className="rounded-xl border border-border/60 bg-background p-3 shadow-xs"
1650
+ >
1651
+ <div className="flex flex-wrap items-start justify-between gap-2">
1652
+ <div>
1653
+ <p className="text-sm font-semibold text-foreground">
1654
+ {t('proposals.form.itemLabel', {
1655
+ number: index + 1,
1656
+ })}
1657
+ </p>
1658
+ <p className="text-xs text-muted-foreground">
1659
+ {isDiscountItem
1660
+ ? t('proposals.form.discountHint')
1661
+ : t('proposals.form.itemHint')}
1662
+ </p>
1663
+ </div>
1664
+
1665
+ <Button
1666
+ type="button"
1667
+ variant="ghost"
1668
+ size="sm"
1669
+ className="gap-2 text-muted-foreground"
1670
+ onClick={() => removeItem(index)}
1671
+ disabled={itemFields.length === 1}
1672
+ >
1673
+ <Trash2 className="size-4" />
1674
+ {t('proposals.form.removeItem')}
1675
+ </Button>
1676
+ </div>
1677
+
1678
+ <div className="mt-3 grid grid-cols-1 gap-3 xl:grid-cols-6">
1679
+ <FormField
1680
+ control={form.control}
1681
+ name={`items.${index}.name` as const}
1682
+ render={({ field }) => (
1683
+ <FormItem className="xl:col-span-2">
1684
+ <FormLabel>
1685
+ {t('proposals.form.itemName')}
1686
+ </FormLabel>
1687
+ <FormControl>
1688
+ <Input
1689
+ {...field}
1690
+ value={field.value ?? ''}
1691
+ placeholder={t(
1692
+ 'proposals.form.itemNamePlaceholder'
1693
+ )}
1694
+ />
1695
+ </FormControl>
1696
+ <FormMessage />
1697
+ </FormItem>
1698
+ )}
1699
+ />
1700
+
1701
+ <FormField
1702
+ control={form.control}
1703
+ name={`items.${index}.itemType` as const}
1704
+ render={({ field }) => (
1705
+ <FormItem>
1706
+ <FormLabel>
1707
+ {t('proposals.form.itemType')}
1708
+ </FormLabel>
1709
+ <Select
1710
+ onValueChange={field.onChange}
1711
+ value={field.value}
1712
+ >
1713
+ <FormControl>
1714
+ <SelectTrigger className="w-full">
1715
+ <SelectValue />
1716
+ </SelectTrigger>
1717
+ </FormControl>
1718
+ <SelectContent>
1719
+ {ITEM_TYPE_OPTIONS.map((option) => (
1720
+ <SelectItem
1721
+ key={option}
1722
+ value={option}
1723
+ >
1724
+ {getProposalEnumLabel(
1725
+ 'itemType',
1726
+ option
1727
+ )}
1728
+ </SelectItem>
1729
+ ))}
1730
+ </SelectContent>
1731
+ </Select>
1732
+ <FormMessage />
1733
+ </FormItem>
1734
+ )}
1735
+ />
1736
+
1737
+ <FormField
1738
+ control={form.control}
1739
+ name={`items.${index}.recurrence` as const}
1740
+ render={({ field }) => (
1741
+ <FormItem>
1742
+ <FormLabel>
1743
+ {t('proposals.form.recurrence')}
1744
+ </FormLabel>
1745
+ <Select
1746
+ onValueChange={field.onChange}
1747
+ value={field.value}
1748
+ >
1749
+ <FormControl>
1750
+ <SelectTrigger className="w-full">
1751
+ <SelectValue />
1752
+ </SelectTrigger>
1753
+ </FormControl>
1754
+ <SelectContent>
1755
+ {RECURRENCE_OPTIONS.map((option) => (
1756
+ <SelectItem
1757
+ key={option}
1758
+ value={option}
1759
+ >
1760
+ {getProposalEnumLabel(
1761
+ 'recurrence',
1762
+ option
1763
+ )}
1764
+ </SelectItem>
1765
+ ))}
1766
+ </SelectContent>
1767
+ </Select>
1768
+ <FormMessage />
1769
+ </FormItem>
1770
+ )}
1771
+ />
1772
+
1773
+ <FormField
1774
+ control={form.control}
1775
+ name={`items.${index}.quantity` as const}
1776
+ render={({ field }) => (
1777
+ <FormItem>
1778
+ <FormLabel>
1779
+ {t('proposals.form.quantity')}
1780
+ </FormLabel>
1781
+ <FormControl>
1782
+ <Input
1783
+ {...field}
1784
+ type="number"
1785
+ min="0"
1786
+ step="0.01"
1787
+ value={field.value ?? 0}
1788
+ onChange={(event) =>
1789
+ field.onChange(event.target.value)
1790
+ }
1791
+ />
1792
+ </FormControl>
1793
+ <FormMessage />
1794
+ </FormItem>
1795
+ )}
1796
+ />
1797
+
1798
+ <FormField
1799
+ control={form.control}
1800
+ name={`items.${index}.unitAmount` as const}
1801
+ render={({ field }) => (
1802
+ <FormItem>
1803
+ <FormLabel>
1804
+ {t('proposals.form.unitAmount')}
1805
+ </FormLabel>
1806
+ <FormControl>
1807
+ <InputMoney
1808
+ ref={field.ref}
1809
+ name={field.name}
1810
+ value={field.value ?? 0}
1811
+ onBlur={field.onBlur}
1812
+ onValueChange={(value) =>
1813
+ field.onChange(value ?? 0)
1814
+ }
1815
+ placeholder="0,00"
1816
+ />
1817
+ </FormControl>
1818
+ <FormMessage />
1819
+ </FormItem>
1820
+ )}
1821
+ />
1822
+
1823
+ <FormField
1824
+ control={form.control}
1825
+ name={`items.${index}.description` as const}
1826
+ render={({ field }) => (
1827
+ <FormItem className="xl:col-span-4">
1828
+ <FormLabel>
1829
+ {t('proposals.form.itemDescription')}
1830
+ </FormLabel>
1831
+ <FormControl>
1832
+ <Textarea
1833
+ {...field}
1834
+ value={field.value ?? ''}
1835
+ rows={3}
1836
+ placeholder={t(
1837
+ 'proposals.form.itemDescriptionPlaceholder'
1838
+ )}
1839
+ />
1840
+ </FormControl>
1841
+ <FormMessage />
1842
+ </FormItem>
1843
+ )}
1844
+ />
1845
+
1846
+ <FormField
1847
+ control={form.control}
1848
+ name={`items.${index}.startDate` as const}
1849
+ render={({ field }) => (
1850
+ <FormItem>
1851
+ <FormLabel>
1852
+ {t('proposals.form.startDate')}
1853
+ </FormLabel>
1854
+ <FormControl>
1855
+ <Input
1856
+ type="date"
1857
+ {...field}
1858
+ value={field.value ?? ''}
1859
+ />
1860
+ </FormControl>
1861
+ <FormMessage />
1862
+ </FormItem>
1863
+ )}
1864
+ />
1865
+
1866
+ <FormField
1867
+ control={form.control}
1868
+ name={`items.${index}.endDate` as const}
1869
+ render={({ field }) => (
1870
+ <FormItem>
1871
+ <FormLabel>
1872
+ {t('proposals.form.endDate')}
1873
+ </FormLabel>
1874
+ <FormControl>
1875
+ <Input
1876
+ type="date"
1877
+ {...field}
1878
+ value={field.value ?? ''}
1879
+ />
1880
+ </FormControl>
1881
+ <FormMessage />
1882
+ </FormItem>
1883
+ )}
1884
+ />
1885
+ </div>
1886
+
1887
+ <div className="mt-3 flex justify-end">
1888
+ <div className="rounded-lg bg-muted/40 px-3 py-2 text-sm font-medium text-foreground">
1889
+ {t('proposals.form.lineTotal')}:{' '}
1890
+ {isDiscountItem ? '- ' : ''}
1891
+ {formatCurrency(lineTotalCents, 'BRL')}
1892
+ </div>
1893
+ </div>
1894
+ </div>
1895
+ );
1896
+ })}
1897
+ </div>
1898
+
1899
+ <div className="grid gap-2 rounded-xl border border-border/60 bg-muted/15 p-4 sm:grid-cols-3">
1900
+ <div>
1901
+ <p className="text-xs text-muted-foreground">
1902
+ {t('proposals.form.subtotal')}
1903
+ </p>
1904
+ <p className="text-base font-semibold text-foreground">
1905
+ {formatCurrency(pricingSummary.subtotalCents, 'BRL')}
1906
+ </p>
1907
+ </div>
1908
+ <div>
1909
+ <p className="text-xs text-muted-foreground">
1910
+ {t('proposals.form.discount')}
1911
+ </p>
1912
+ <p className="text-base font-semibold text-foreground">
1913
+ -{' '}
1914
+ {formatCurrency(pricingSummary.discountCents, 'BRL')}
1915
+ </p>
1916
+ </div>
1917
+ <div>
1918
+ <p className="text-xs text-muted-foreground">
1919
+ {t('proposals.form.calculatedTotal')}
1920
+ </p>
1921
+ <p className="text-base font-semibold text-foreground">
1922
+ {formatCurrency(pricingSummary.totalCents, 'BRL')}
1923
+ </p>
1924
+ </div>
1925
+ </div>
1926
+ </div>
1438
1927
 
1439
1928
  <FormField
1440
1929
  control={form.control}
@@ -866,6 +866,21 @@
866
866
  "time_and_material": "Time and material",
867
867
  "monthly_retainer": "Monthly retainer",
868
868
  "fixed_price": "Fixed price"
869
+ },
870
+ "itemType": {
871
+ "service": "Service",
872
+ "product": "Product",
873
+ "fee": "Fee",
874
+ "discount": "Discount",
875
+ "note": "Note",
876
+ "other": "Other"
877
+ },
878
+ "recurrence": {
879
+ "one_time": "One-time",
880
+ "monthly": "Monthly",
881
+ "quarterly": "Quarterly",
882
+ "yearly": "Yearly",
883
+ "other": "Other"
869
884
  }
870
885
  },
871
886
  "actions": {
@@ -909,6 +924,27 @@
909
924
  "contractCategory": "Contract category",
910
925
  "contractType": "Contract type",
911
926
  "billingModel": "Billing model",
927
+ "items": "Proposal items",
928
+ "itemsDescription": "Add the services, products, fees, or discounts that make up this commercial proposal.",
929
+ "addItem": "Add item",
930
+ "itemLabel": "Item {number}",
931
+ "itemHint": "Describe the scope, recurrence, and pricing for this item.",
932
+ "discountHint": "Use positive values; the system will apply this item as a discount.",
933
+ "removeItem": "Remove item",
934
+ "itemName": "Item name",
935
+ "itemNamePlaceholder": "E.g. Implementation, monthly license, or commercial discount",
936
+ "itemType": "Item type",
937
+ "recurrence": "Recurrence",
938
+ "quantity": "Quantity",
939
+ "unitAmount": "Unit amount",
940
+ "itemDescription": "Item description",
941
+ "itemDescriptionPlaceholder": "Describe what is included in this item...",
942
+ "startDate": "Start date",
943
+ "endDate": "End date",
944
+ "lineTotal": "Line total",
945
+ "subtotal": "Subtotal",
946
+ "discount": "Discounts",
947
+ "calculatedTotal": "Calculated total",
912
948
  "summary": "Summary",
913
949
  "summaryPlaceholder": "Write a short commercial summary for this proposal...",
914
950
  "notes": "Internal notes",
@@ -865,6 +865,21 @@
865
865
  "time_and_material": "Tempo e material",
866
866
  "monthly_retainer": "Retainer mensal",
867
867
  "fixed_price": "Preço fixo"
868
+ },
869
+ "itemType": {
870
+ "service": "Serviço",
871
+ "product": "Produto",
872
+ "fee": "Taxa",
873
+ "discount": "Desconto",
874
+ "note": "Observação",
875
+ "other": "Outro"
876
+ },
877
+ "recurrence": {
878
+ "one_time": "Pontual",
879
+ "monthly": "Mensal",
880
+ "quarterly": "Trimestral",
881
+ "yearly": "Anual",
882
+ "other": "Outra"
868
883
  }
869
884
  },
870
885
  "actions": {
@@ -908,6 +923,27 @@
908
923
  "contractCategory": "Categoria do contrato",
909
924
  "contractType": "Tipo do contrato",
910
925
  "billingModel": "Modelo de cobrança",
926
+ "items": "Itens da proposta",
927
+ "itemsDescription": "Adicione serviços, produtos, taxas ou descontos que compõem a proposta comercial.",
928
+ "addItem": "Adicionar item",
929
+ "itemLabel": "Item {number}",
930
+ "itemHint": "Detalhe o escopo, a recorrência e os valores deste item.",
931
+ "discountHint": "Use valores positivos; o sistema aplicará este item como desconto.",
932
+ "removeItem": "Remover item",
933
+ "itemName": "Nome do item",
934
+ "itemNamePlaceholder": "Ex: Implantação, licença mensal ou desconto comercial",
935
+ "itemType": "Tipo do item",
936
+ "recurrence": "Recorrência",
937
+ "quantity": "Quantidade",
938
+ "unitAmount": "Valor unitário",
939
+ "itemDescription": "Descrição do item",
940
+ "itemDescriptionPlaceholder": "Detalhe o que está incluído neste item...",
941
+ "startDate": "Início",
942
+ "endDate": "Fim",
943
+ "lineTotal": "Total da linha",
944
+ "subtotal": "Subtotal",
945
+ "discount": "Descontos",
946
+ "calculatedTotal": "Total calculado",
911
947
  "summary": "Resumo",
912
948
  "summaryPlaceholder": "Escreva um resumo comercial desta proposta...",
913
949
  "notes": "Notas internas",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hed-hog/contact",
3
- "version": "0.0.303",
3
+ "version": "0.0.304",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "dependencies": {
@@ -10,11 +10,11 @@
10
10
  "@nestjs/jwt": "^11",
11
11
  "@nestjs/mapped-types": "*",
12
12
  "@hed-hog/api-prisma": "0.0.6",
13
- "@hed-hog/core": "0.0.303",
14
13
  "@hed-hog/api-locale": "0.0.14",
14
+ "@hed-hog/address": "0.0.304",
15
15
  "@hed-hog/api-mail": "0.0.9",
16
- "@hed-hog/address": "0.0.303",
17
16
  "@hed-hog/api": "0.0.6",
17
+ "@hed-hog/core": "0.0.304",
18
18
  "@hed-hog/api-pagination": "0.0.7"
19
19
  },
20
20
  "exports": {