@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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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
|
-
<
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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
|
-
<
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
<
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
<
|
|
1444
|
-
|
|
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.
|
|
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": {
|