@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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
<
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
<
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
<
|
|
1435
|
-
|
|
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.
|
|
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": {
|