@hed-hog/contact 0.0.302 → 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.
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
 
3
+ import { EmptyState } from '@/components/entity-list';
3
4
  import {
4
5
  AlertDialog,
5
6
  AlertDialogAction,
@@ -67,7 +68,7 @@ import {
67
68
  } from 'lucide-react';
68
69
  import { useTranslations } from 'next-intl';
69
70
  import { useEffect, useMemo, useState } from 'react';
70
- import { useForm } from 'react-hook-form';
71
+ import { useFieldArray, useForm } from 'react-hook-form';
71
72
  import { toast } from 'sonner';
72
73
  import { z } from 'zod';
73
74
 
@@ -111,6 +112,21 @@ type ProposalBillingModel =
111
112
  | 'monthly_retainer'
112
113
  | 'fixed_price';
113
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
+
114
130
  type ProposalItem = {
115
131
  id?: number;
116
132
  name: string;
@@ -118,8 +134,12 @@ type ProposalItem = {
118
134
  quantity?: number | null;
119
135
  unit_amount_cents?: number | null;
120
136
  total_amount_cents?: number | null;
121
- item_type?: string | null;
122
- 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;
123
143
  };
124
144
 
125
145
  type ProposalRevision = {
@@ -195,10 +215,20 @@ type LeadProposalsTabProps = {
195
215
  onLeadUpdated: (lead: CrmLead) => Promise<void> | void;
196
216
  };
197
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
+
198
229
  const proposalFormSchema = z.object({
199
230
  code: z.string().max(40).optional(),
200
231
  title: z.string().trim().min(1),
201
- totalAmount: z.coerce.number().min(0),
202
232
  validUntil: z.string().optional(),
203
233
  contractCategory: z.enum([
204
234
  'employee',
@@ -229,9 +259,11 @@ const proposalFormSchema = z.object({
229
259
  ]),
230
260
  summary: z.string().optional(),
231
261
  notes: z.string().optional(),
262
+ items: z.array(proposalItemFormSchema).min(1),
232
263
  });
233
264
 
234
265
  type ProposalFormValues = z.infer<typeof proposalFormSchema>;
266
+ type ProposalFormItemValues = z.infer<typeof proposalItemFormSchema>;
235
267
 
236
268
  const CONTRACT_CATEGORY_OPTIONS: ProposalContractCategory[] = [
237
269
  'client',
@@ -263,6 +295,39 @@ const BILLING_MODEL_OPTIONS: ProposalBillingModel[] = [
263
295
  'time_and_material',
264
296
  ];
265
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
+
266
331
  function toDateInputValue(value?: string | null) {
267
332
  if (!value) return '';
268
333
  const parsed = new Date(value);
@@ -280,6 +345,38 @@ function formatEnumLabel(value?: string | null) {
280
345
  .join(' ');
281
346
  }
282
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
+
283
380
  function openStoredFile(fileId?: number | null) {
284
381
  if (!fileId) return;
285
382
  const baseUrl = String(process.env.NEXT_PUBLIC_API_BASE_URL || '');
@@ -324,16 +421,54 @@ export function LeadProposalsTab({
324
421
  defaultValues: {
325
422
  code: '',
326
423
  title: '',
327
- totalAmount: 0,
328
424
  validUntil: '',
329
425
  contractCategory: 'client',
330
426
  contractType: 'service_agreement',
331
427
  billingModel: 'fixed_price',
332
428
  summary: '',
333
429
  notes: '',
430
+ items: [createEmptyProposalItem()],
334
431
  },
335
432
  });
336
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
+
337
472
  const {
338
473
  data: proposalPage = { data: [], total: 0, page: 1, pageSize: 20 },
339
474
  isLoading: isLoadingProposals,
@@ -402,13 +537,13 @@ export function LeadProposalsTab({
402
537
  form.reset({
403
538
  code: '',
404
539
  title: '',
405
- totalAmount: 0,
406
540
  validUntil: '',
407
541
  contractCategory: 'client',
408
542
  contractType: 'service_agreement',
409
543
  billingModel: 'fixed_price',
410
544
  summary: '',
411
545
  notes: '',
546
+ items: [createEmptyProposalItem()],
412
547
  });
413
548
  setEditingProposal(null);
414
549
  return;
@@ -418,29 +553,41 @@ export function LeadProposalsTab({
418
553
  form.reset({
419
554
  code: '',
420
555
  title: '',
421
- totalAmount: 0,
422
556
  validUntil: '',
423
557
  contractCategory: 'client',
424
558
  contractType: 'service_agreement',
425
559
  billingModel: 'fixed_price',
426
560
  summary: '',
427
561
  notes: '',
562
+ items: [createEmptyProposalItem()],
428
563
  });
429
564
  return;
430
565
  }
431
566
 
432
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
+ ];
433
580
 
434
581
  form.reset({
435
582
  code: editingProposal.code ?? '',
436
583
  title: editingProposal.title,
437
- totalAmount: Number(editingProposal.total_amount_cents ?? 0) / 100,
438
584
  validUntil: toDateInputValue(editingProposal.valid_until),
439
585
  contractCategory: editingProposal.contract_category ?? 'client',
440
586
  contractType: editingProposal.contract_type ?? 'service_agreement',
441
587
  billingModel: editingProposal.billing_model ?? 'fixed_price',
442
588
  summary: currentRevision?.summary ?? '',
443
589
  notes: editingProposal.notes ?? '',
590
+ items: revisionItems,
444
591
  });
445
592
  }, [editingProposal, form, formOpen]);
446
593
 
@@ -512,7 +659,12 @@ export function LeadProposalsTab({
512
659
  };
513
660
 
514
661
  const getProposalEnumLabel = (
515
- group: 'contractCategory' | 'contractType' | 'billingModel',
662
+ group:
663
+ | 'contractCategory'
664
+ | 'contractType'
665
+ | 'billingModel'
666
+ | 'itemType'
667
+ | 'recurrence',
516
668
  value?: string | null
517
669
  ) => {
518
670
  if (!value) return '—';
@@ -583,7 +735,43 @@ export function LeadProposalsTab({
583
735
  };
584
736
 
585
737
  const handleSave = async (values: ProposalFormValues) => {
586
- 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
+
587
775
  const payload = {
588
776
  person_id: lead.id,
589
777
  code: values.code?.trim() || undefined,
@@ -592,29 +780,13 @@ export function LeadProposalsTab({
592
780
  contract_type: values.contractType,
593
781
  billing_model: values.billingModel,
594
782
  currency_code: 'BRL',
595
- valid_until: values.validUntil
596
- ? new Date(`${values.validUntil}T00:00:00`).toISOString()
597
- : null,
598
- subtotal_amount_cents: amountInCents,
599
- 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,
600
787
  summary: values.summary?.trim() || null,
601
788
  notes: values.notes?.trim() || null,
602
- items:
603
- amountInCents > 0
604
- ? [
605
- {
606
- name: values.title.trim(),
607
- description:
608
- values.summary?.trim() || values.notes?.trim() || undefined,
609
- quantity: 1,
610
- unit_amount_cents: amountInCents,
611
- total_amount_cents: amountInCents,
612
- item_type: 'service',
613
- term_type: 'value',
614
- recurrence: 'one_time',
615
- },
616
- ]
617
- : [],
789
+ items: normalizedItems,
618
790
  };
619
791
 
620
792
  try {
@@ -810,25 +982,15 @@ export function LeadProposalsTab({
810
982
  {t('proposals.loading')}
811
983
  </div>
812
984
  ) : proposals.length === 0 ? (
813
- <Card className="border-dashed shadow-none">
814
- <CardHeader className="space-y-2">
815
- <div className="flex size-10 items-center justify-center rounded-xl bg-muted text-muted-foreground">
816
- <FileText className="size-5" />
817
- </div>
818
- <CardTitle className="text-base">
819
- {t('proposals.emptyTitle')}
820
- </CardTitle>
821
- <CardDescription>
822
- {t('proposals.emptyDescription')}
823
- </CardDescription>
824
- </CardHeader>
825
- <CardContent>
826
- <Button size="sm" className="gap-2" onClick={openCreateSheet}>
827
- <Plus className="size-4" />
828
- {t('proposals.emptyAction')}
829
- </Button>
830
- </CardContent>
831
- </Card>
985
+ <EmptyState
986
+ className="min-h-60"
987
+ icon={<FileText className="size-5" />}
988
+ title={t('proposals.emptyTitle')}
989
+ description={t('proposals.emptyDescription')}
990
+ actionLabel={t('proposals.emptyAction')}
991
+ actionIcon={<Plus className="mr-2 size-4" />}
992
+ onAction={openCreateSheet}
993
+ />
832
994
  ) : (
833
995
  <div className="grid gap-4 xl:grid-cols-[minmax(0,340px)_minmax(0,1fr)]">
834
996
  <Card className="min-w-0 border-border/60 shadow-sm">
@@ -1259,8 +1421,33 @@ export function LeadProposalsTab({
1259
1421
  {item.description}
1260
1422
  </p>
1261
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>
1262
1448
  </div>
1263
1449
  <span className="text-sm font-semibold text-foreground">
1450
+ {item.item_type === 'discount' ? '- ' : ''}
1264
1451
  {formatCurrency(
1265
1452
  item.total_amount_cents,
1266
1453
  selectedProposal.currency_code
@@ -1422,28 +1609,321 @@ export function LeadProposalsTab({
1422
1609
  )}
1423
1610
  />
1424
1611
 
1425
- <FormField
1426
- control={form.control}
1427
- name="totalAmount"
1428
- render={({ field }) => (
1429
- <FormItem>
1430
- <FormLabel>{t('proposals.form.amount')}</FormLabel>
1431
- <FormControl>
1432
- <InputMoney
1433
- ref={field.ref}
1434
- name={field.name}
1435
- value={field.value ?? 0}
1436
- onBlur={field.onBlur}
1437
- onValueChange={(value) =>
1438
- field.onChange(value ?? 0)
1439
- }
1440
- placeholder="0,00"
1441
- />
1442
- </FormControl>
1443
- <FormMessage />
1444
- </FormItem>
1445
- )}
1446
- />
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>
1447
1927
 
1448
1928
  <FormField
1449
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.302",
3
+ "version": "0.0.304",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "dependencies": {
@@ -11,10 +11,10 @@
11
11
  "@nestjs/mapped-types": "*",
12
12
  "@hed-hog/api-prisma": "0.0.6",
13
13
  "@hed-hog/api-locale": "0.0.14",
14
+ "@hed-hog/address": "0.0.304",
14
15
  "@hed-hog/api-mail": "0.0.9",
15
- "@hed-hog/core": "0.0.302",
16
- "@hed-hog/address": "0.0.302",
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": {