@hed-hog/contact 0.0.299 → 0.0.301

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/dist/contact.module.d.ts.map +1 -1
  2. package/dist/contact.module.js +2 -0
  3. package/dist/contact.module.js.map +1 -1
  4. package/dist/index.d.ts +3 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +3 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/person/person.service.js +350 -350
  9. package/dist/proposal/dto/proposal.dto.d.ts +152 -0
  10. package/dist/proposal/dto/proposal.dto.d.ts.map +1 -0
  11. package/dist/proposal/dto/proposal.dto.js +396 -0
  12. package/dist/proposal/dto/proposal.dto.js.map +1 -0
  13. package/dist/proposal/proposal-contract.subscriber.d.ts +11 -0
  14. package/dist/proposal/proposal-contract.subscriber.d.ts.map +1 -0
  15. package/dist/proposal/proposal-contract.subscriber.js +51 -0
  16. package/dist/proposal/proposal-contract.subscriber.js.map +1 -0
  17. package/dist/proposal/proposal-event.types.d.ts +122 -0
  18. package/dist/proposal/proposal-event.types.d.ts.map +1 -0
  19. package/dist/proposal/proposal-event.types.js +13 -0
  20. package/dist/proposal/proposal-event.types.js.map +1 -0
  21. package/dist/proposal/proposal.controller.d.ts +56 -0
  22. package/dist/proposal/proposal.controller.d.ts.map +1 -0
  23. package/dist/proposal/proposal.controller.js +191 -0
  24. package/dist/proposal/proposal.controller.js.map +1 -0
  25. package/dist/proposal/proposal.module.d.ts +3 -0
  26. package/dist/proposal/proposal.module.d.ts.map +1 -0
  27. package/dist/proposal/proposal.module.js +32 -0
  28. package/dist/proposal/proposal.module.js.map +1 -0
  29. package/dist/proposal/proposal.service.d.ts +95 -0
  30. package/dist/proposal/proposal.service.d.ts.map +1 -0
  31. package/dist/proposal/proposal.service.js +1914 -0
  32. package/dist/proposal/proposal.service.js.map +1 -0
  33. package/dist/proposal/proposal.service.spec.d.ts +2 -0
  34. package/dist/proposal/proposal.service.spec.d.ts.map +1 -0
  35. package/dist/proposal/proposal.service.spec.js +187 -0
  36. package/dist/proposal/proposal.service.spec.js.map +1 -0
  37. package/hedhog/data/dashboard.yaml +6 -0
  38. package/hedhog/data/dashboard_component.yaml +87 -0
  39. package/hedhog/data/dashboard_component_role.yaml +55 -0
  40. package/hedhog/data/dashboard_item.yaml +95 -0
  41. package/hedhog/data/dashboard_role.yaml +6 -0
  42. package/hedhog/data/route.yaml +112 -68
  43. package/hedhog/frontend/app/dashboard/_components/dashboard-widgets.tsx.ejs +508 -0
  44. package/hedhog/frontend/app/dashboard/_components/use-crm-dashboard-data.ts.ejs +104 -0
  45. package/hedhog/frontend/app/dashboard/page.tsx.ejs +37 -431
  46. package/hedhog/frontend/app/pipeline/_components/lead-detail-sheet.tsx.ejs +252 -209
  47. package/hedhog/frontend/app/pipeline/_components/lead-proposals-tab.tsx.ejs +1584 -0
  48. package/hedhog/frontend/messages/en.json +136 -42
  49. package/hedhog/frontend/messages/pt.json +135 -41
  50. package/hedhog/frontend/widgets/next-actions.tsx.ejs +40 -0
  51. package/hedhog/frontend/widgets/overview-kpis.tsx.ejs +47 -0
  52. package/hedhog/frontend/widgets/owner-performance.tsx.ejs +42 -0
  53. package/hedhog/frontend/widgets/quick-access.tsx.ejs +29 -0
  54. package/hedhog/frontend/widgets/source-breakdown.tsx.ejs +40 -0
  55. package/hedhog/frontend/widgets/stage-distribution.tsx.ejs +40 -0
  56. package/hedhog/frontend/widgets/top-owners.tsx.ejs +42 -0
  57. package/hedhog/frontend/widgets/unattended.tsx.ejs +40 -0
  58. package/hedhog/table/crm_activity.yaml +68 -68
  59. package/hedhog/table/crm_stage_history.yaml +34 -34
  60. package/hedhog/table/person_company.yaml +27 -27
  61. package/hedhog/table/proposal.yaml +112 -0
  62. package/hedhog/table/proposal_approval.yaml +63 -0
  63. package/hedhog/table/proposal_document.yaml +77 -0
  64. package/hedhog/table/proposal_item.yaml +64 -0
  65. package/hedhog/table/proposal_revision.yaml +78 -0
  66. package/package.json +6 -5
  67. package/src/contact.module.ts +2 -0
  68. package/src/index.ts +3 -0
  69. package/src/person/dto/account.dto.ts +100 -100
  70. package/src/person/dto/activity.dto.ts +54 -54
  71. package/src/person/dto/dashboard-query.dto.ts +25 -25
  72. package/src/person/dto/followup-query.dto.ts +25 -25
  73. package/src/person/dto/reports-query.dto.ts +25 -25
  74. package/src/person/person.controller.ts +176 -176
  75. package/src/person/person.service.ts +4825 -4825
  76. package/src/proposal/dto/proposal.dto.ts +341 -0
  77. package/src/proposal/proposal-contract.subscriber.ts +43 -0
  78. package/src/proposal/proposal-event.types.ts +130 -0
  79. package/src/proposal/proposal.controller.ts +168 -0
  80. package/src/proposal/proposal.module.ts +19 -0
  81. package/src/proposal/proposal.service.ts +2525 -0
@@ -0,0 +1,1584 @@
1
+ 'use client';
2
+
3
+ import {
4
+ AlertDialog,
5
+ AlertDialogAction,
6
+ AlertDialogCancel,
7
+ AlertDialogContent,
8
+ AlertDialogDescription,
9
+ AlertDialogFooter,
10
+ AlertDialogHeader,
11
+ AlertDialogTitle,
12
+ } from '@/components/ui/alert-dialog';
13
+ import { Badge } from '@/components/ui/badge';
14
+ import { Button } from '@/components/ui/button';
15
+ import {
16
+ Card,
17
+ CardContent,
18
+ CardDescription,
19
+ CardHeader,
20
+ CardTitle,
21
+ } from '@/components/ui/card';
22
+ import {
23
+ Dialog,
24
+ DialogContent,
25
+ DialogDescription,
26
+ DialogFooter,
27
+ DialogHeader,
28
+ DialogTitle,
29
+ } from '@/components/ui/dialog';
30
+ import {
31
+ DropdownMenu,
32
+ DropdownMenuContent,
33
+ DropdownMenuItem,
34
+ DropdownMenuSeparator,
35
+ DropdownMenuTrigger,
36
+ } from '@/components/ui/dropdown-menu';
37
+ import {
38
+ Form,
39
+ FormControl,
40
+ FormField,
41
+ FormItem,
42
+ FormLabel,
43
+ FormMessage,
44
+ } from '@/components/ui/form';
45
+ import { Input } from '@/components/ui/input';
46
+ import {
47
+ Select,
48
+ SelectContent,
49
+ SelectItem,
50
+ SelectTrigger,
51
+ SelectValue,
52
+ } from '@/components/ui/select';
53
+ import { Separator } from '@/components/ui/separator';
54
+ import { Textarea } from '@/components/ui/textarea';
55
+ import { cn } from '@/lib/utils';
56
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
57
+ import { zodResolver } from '@hookform/resolvers/zod';
58
+ import {
59
+ CheckCircle2,
60
+ Download,
61
+ FileText,
62
+ Loader2,
63
+ MoreHorizontal,
64
+ Pencil,
65
+ Plus,
66
+ Send,
67
+ Trash2,
68
+ XCircle,
69
+ } from 'lucide-react';
70
+ import { useTranslations } from 'next-intl';
71
+ import { useEffect, useMemo, useState } from 'react';
72
+ import { useForm } from 'react-hook-form';
73
+ import { toast } from 'sonner';
74
+ import { z } from 'zod';
75
+
76
+ import type { CrmLead } from '../../_lib/crm-mocks';
77
+ import type { PaginatedResult } from '../../person/_components/person-types';
78
+
79
+ type ProposalStatus =
80
+ | 'draft'
81
+ | 'pending_approval'
82
+ | 'approved'
83
+ | 'rejected'
84
+ | 'cancelled'
85
+ | 'expired'
86
+ | 'contract_generated';
87
+
88
+ type ProposalContractCategory =
89
+ | 'employee'
90
+ | 'contractor'
91
+ | 'client'
92
+ | 'supplier'
93
+ | 'vendor'
94
+ | 'partner'
95
+ | 'internal'
96
+ | 'other';
97
+
98
+ type ProposalContractType =
99
+ | 'clt'
100
+ | 'pj'
101
+ | 'freelancer_agreement'
102
+ | 'service_agreement'
103
+ | 'fixed_term'
104
+ | 'recurring_service'
105
+ | 'nda'
106
+ | 'amendment'
107
+ | 'addendum'
108
+ | 'other';
109
+
110
+ type ProposalBillingModel =
111
+ | 'time_and_material'
112
+ | 'monthly_retainer'
113
+ | 'fixed_price';
114
+
115
+ type ProposalItem = {
116
+ id?: number;
117
+ name: string;
118
+ description?: string | null;
119
+ quantity?: number | null;
120
+ unit_amount_cents?: number | null;
121
+ total_amount_cents?: number | null;
122
+ item_type?: string | null;
123
+ recurrence?: string | null;
124
+ };
125
+
126
+ type ProposalRevision = {
127
+ id: number;
128
+ revision_number: number;
129
+ is_current?: boolean;
130
+ title: string;
131
+ summary?: string | null;
132
+ valid_until?: string | null;
133
+ status?: ProposalStatus | string;
134
+ proposal_item?: ProposalItem[];
135
+ };
136
+
137
+ type ProposalApproval = {
138
+ id: number;
139
+ status?: string | null;
140
+ decision_note?: string | null;
141
+ submitted_at?: string | null;
142
+ decided_at?: string | null;
143
+ };
144
+
145
+ type ProposalDocument = {
146
+ id: number;
147
+ file_id?: number | null;
148
+ file_name?: string | null;
149
+ mime_type?: string | null;
150
+ document_type?: string | null;
151
+ is_current?: boolean | null;
152
+ created_at?: string | null;
153
+ };
154
+
155
+ type GenerateProposalDocumentResponse = {
156
+ fileId?: number | null;
157
+ fileName?: string | null;
158
+ downloadUrl?: string | null;
159
+ };
160
+
161
+ type ProposalIntegrationLink = {
162
+ targetModule?: string;
163
+ targetEntityType?: string;
164
+ targetEntityId?: string;
165
+ };
166
+
167
+ type ProposalRecord = {
168
+ id: number;
169
+ person_id: number;
170
+ code?: string | null;
171
+ title: string;
172
+ status: ProposalStatus;
173
+ contract_category?: ProposalContractCategory | null;
174
+ contract_type?: ProposalContractType | null;
175
+ billing_model?: ProposalBillingModel | null;
176
+ currency_code?: string | null;
177
+ subtotal_amount_cents?: number | null;
178
+ discount_amount_cents?: number | null;
179
+ tax_amount_cents?: number | null;
180
+ total_amount_cents?: number | null;
181
+ valid_until?: string | null;
182
+ current_revision_number?: number | null;
183
+ notes?: string | null;
184
+ created_at: string;
185
+ updated_at?: string | null;
186
+ approved_at?: string | null;
187
+ proposal_revision?: ProposalRevision[];
188
+ proposal_approval?: ProposalApproval[];
189
+ proposal_document?: ProposalDocument[];
190
+ integration_links?: ProposalIntegrationLink[];
191
+ };
192
+
193
+ type LeadProposalsTabProps = {
194
+ lead: CrmLead;
195
+ active: boolean;
196
+ onLeadUpdated: (lead: CrmLead) => Promise<void> | void;
197
+ };
198
+
199
+ const proposalFormSchema = z.object({
200
+ code: z.string().max(40).optional(),
201
+ title: z.string().trim().min(1),
202
+ totalAmount: z.coerce.number().min(0),
203
+ validUntil: z.string().optional(),
204
+ contractCategory: z.enum([
205
+ 'employee',
206
+ 'contractor',
207
+ 'client',
208
+ 'supplier',
209
+ 'vendor',
210
+ 'partner',
211
+ 'internal',
212
+ 'other',
213
+ ]),
214
+ contractType: z.enum([
215
+ 'clt',
216
+ 'pj',
217
+ 'freelancer_agreement',
218
+ 'service_agreement',
219
+ 'fixed_term',
220
+ 'recurring_service',
221
+ 'nda',
222
+ 'amendment',
223
+ 'addendum',
224
+ 'other',
225
+ ]),
226
+ billingModel: z.enum([
227
+ 'time_and_material',
228
+ 'monthly_retainer',
229
+ 'fixed_price',
230
+ ]),
231
+ summary: z.string().optional(),
232
+ notes: z.string().optional(),
233
+ });
234
+
235
+ type ProposalFormValues = z.infer<typeof proposalFormSchema>;
236
+
237
+ const CONTRACT_CATEGORY_OPTIONS: ProposalContractCategory[] = [
238
+ 'client',
239
+ 'supplier',
240
+ 'vendor',
241
+ 'partner',
242
+ 'employee',
243
+ 'contractor',
244
+ 'internal',
245
+ 'other',
246
+ ];
247
+
248
+ const CONTRACT_TYPE_OPTIONS: ProposalContractType[] = [
249
+ 'service_agreement',
250
+ 'recurring_service',
251
+ 'fixed_term',
252
+ 'freelancer_agreement',
253
+ 'pj',
254
+ 'clt',
255
+ 'nda',
256
+ 'amendment',
257
+ 'addendum',
258
+ 'other',
259
+ ];
260
+
261
+ const BILLING_MODEL_OPTIONS: ProposalBillingModel[] = [
262
+ 'fixed_price',
263
+ 'monthly_retainer',
264
+ 'time_and_material',
265
+ ];
266
+
267
+ function toDateInputValue(value?: string | null) {
268
+ if (!value) return '';
269
+ const parsed = new Date(value);
270
+ if (Number.isNaN(parsed.getTime())) return '';
271
+ return parsed.toISOString().slice(0, 10);
272
+ }
273
+
274
+ function formatEnumLabel(value?: string | null) {
275
+ if (!value) return '—';
276
+
277
+ return value
278
+ .split('_')
279
+ .filter(Boolean)
280
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
281
+ .join(' ');
282
+ }
283
+
284
+ function openStoredFile(fileId?: number | null) {
285
+ if (!fileId) return;
286
+ const baseUrl = String(process.env.NEXT_PUBLIC_API_BASE_URL || '');
287
+ window.open(
288
+ `${baseUrl}/file/open/${fileId}`,
289
+ '_blank',
290
+ 'noopener,noreferrer'
291
+ );
292
+ }
293
+
294
+ function getCurrentRevision(proposal?: ProposalRecord | null) {
295
+ if (!proposal?.proposal_revision?.length) return null;
296
+
297
+ return (
298
+ proposal.proposal_revision.find((revision) => revision.is_current) ??
299
+ proposal.proposal_revision[0]
300
+ );
301
+ }
302
+
303
+ export function LeadProposalsTab({
304
+ lead,
305
+ active,
306
+ onLeadUpdated,
307
+ }: LeadProposalsTabProps) {
308
+ const t = useTranslations('contact.CrmPipeline');
309
+ const { request, currentLocaleCode } = useApp();
310
+ const locale = currentLocaleCode?.startsWith('pt') ? 'pt-BR' : 'en-US';
311
+
312
+ const [selectedProposalId, setSelectedProposalId] = useState<number | null>(
313
+ null
314
+ );
315
+ const [formOpen, setFormOpen] = useState(false);
316
+ const [editingProposal, setEditingProposal] = useState<ProposalRecord | null>(
317
+ null
318
+ );
319
+ const [deleteTarget, setDeleteTarget] = useState<ProposalRecord | null>(null);
320
+ const [isSaving, setIsSaving] = useState(false);
321
+ const [actionKey, setActionKey] = useState<string | null>(null);
322
+
323
+ const form = useForm<ProposalFormValues>({
324
+ resolver: zodResolver(proposalFormSchema),
325
+ defaultValues: {
326
+ code: '',
327
+ title: '',
328
+ totalAmount: 0,
329
+ validUntil: '',
330
+ contractCategory: 'client',
331
+ contractType: 'service_agreement',
332
+ billingModel: 'fixed_price',
333
+ summary: '',
334
+ notes: '',
335
+ },
336
+ });
337
+
338
+ const {
339
+ data: proposalPage = { data: [], total: 0, page: 1, pageSize: 20 },
340
+ isLoading: isLoadingProposals,
341
+ refetch: refetchProposals,
342
+ } = useQuery<PaginatedResult<ProposalRecord>>({
343
+ queryKey: ['contact-lead-proposals', lead.id, currentLocaleCode],
344
+ enabled: active && !!lead.id,
345
+ queryFn: async () => {
346
+ const params = new URLSearchParams();
347
+ params.set('person_id', String(lead.id));
348
+ params.set('page', '1');
349
+ params.set('pageSize', '20');
350
+
351
+ const response = await request<PaginatedResult<ProposalRecord>>({
352
+ url: `/proposal?${params.toString()}`,
353
+ method: 'GET',
354
+ });
355
+
356
+ return response.data;
357
+ },
358
+ placeholderData: (previous) =>
359
+ previous ?? { data: [], total: 0, page: 1, pageSize: 20 },
360
+ });
361
+
362
+ const proposals = useMemo(() => proposalPage.data ?? [], [proposalPage.data]);
363
+
364
+ const {
365
+ data: proposalDetail,
366
+ isLoading: isLoadingDetail,
367
+ refetch: refetchProposalDetail,
368
+ } = useQuery<ProposalRecord>({
369
+ queryKey: [
370
+ 'contact-lead-proposal-detail',
371
+ selectedProposalId,
372
+ currentLocaleCode,
373
+ ],
374
+ enabled: active && !!selectedProposalId,
375
+ queryFn: async () => {
376
+ const response = await request<ProposalRecord>({
377
+ url: `/proposal/${selectedProposalId}`,
378
+ method: 'GET',
379
+ });
380
+
381
+ return response.data;
382
+ },
383
+ placeholderData: (previous) => previous,
384
+ });
385
+
386
+ useEffect(() => {
387
+ if (!proposals.length) {
388
+ setSelectedProposalId(null);
389
+ return;
390
+ }
391
+
392
+ setSelectedProposalId((current) => {
393
+ if (current && proposals.some((proposal) => proposal.id === current)) {
394
+ return current;
395
+ }
396
+
397
+ return proposals[0]?.id ?? null;
398
+ });
399
+ }, [proposals]);
400
+
401
+ useEffect(() => {
402
+ if (!formOpen) {
403
+ form.reset({
404
+ code: '',
405
+ title: '',
406
+ totalAmount: 0,
407
+ validUntil: '',
408
+ contractCategory: 'client',
409
+ contractType: 'service_agreement',
410
+ billingModel: 'fixed_price',
411
+ summary: '',
412
+ notes: '',
413
+ });
414
+ setEditingProposal(null);
415
+ return;
416
+ }
417
+
418
+ if (!editingProposal) {
419
+ form.reset({
420
+ code: '',
421
+ title: '',
422
+ totalAmount: 0,
423
+ validUntil: '',
424
+ contractCategory: 'client',
425
+ contractType: 'service_agreement',
426
+ billingModel: 'fixed_price',
427
+ summary: '',
428
+ notes: '',
429
+ });
430
+ return;
431
+ }
432
+
433
+ const currentRevision = getCurrentRevision(editingProposal);
434
+
435
+ form.reset({
436
+ code: editingProposal.code ?? '',
437
+ title: editingProposal.title,
438
+ totalAmount: Number(editingProposal.total_amount_cents ?? 0) / 100,
439
+ validUntil: toDateInputValue(editingProposal.valid_until),
440
+ contractCategory: editingProposal.contract_category ?? 'client',
441
+ contractType: editingProposal.contract_type ?? 'service_agreement',
442
+ billingModel: editingProposal.billing_model ?? 'fixed_price',
443
+ summary: currentRevision?.summary ?? '',
444
+ notes: editingProposal.notes ?? '',
445
+ });
446
+ }, [editingProposal, form, formOpen]);
447
+
448
+ const selectedProposal = useMemo(() => {
449
+ const matchingDetail =
450
+ proposalDetail?.id === selectedProposalId ? proposalDetail : null;
451
+
452
+ return (
453
+ matchingDetail ??
454
+ proposals.find((proposal) => proposal.id === selectedProposalId) ??
455
+ null
456
+ );
457
+ }, [proposalDetail, proposals, selectedProposalId]);
458
+
459
+ const selectedRevision = getCurrentRevision(selectedProposal);
460
+ const selectedItems = selectedRevision?.proposal_item ?? [];
461
+ const currentGeneratedPdf = useMemo(() => {
462
+ if (!selectedProposal?.proposal_document?.length) {
463
+ return null;
464
+ }
465
+
466
+ return (
467
+ selectedProposal.proposal_document.find(
468
+ (document) =>
469
+ document.document_type === 'generated_pdf' && document.is_current
470
+ ) ??
471
+ selectedProposal.proposal_document.find(
472
+ (document) => document.document_type === 'generated_pdf'
473
+ ) ??
474
+ null
475
+ );
476
+ }, [selectedProposal]);
477
+ const statusCounts = useMemo(() => {
478
+ return proposals.reduce(
479
+ (acc, proposal) => {
480
+ acc[proposal.status] = (acc[proposal.status] ?? 0) + 1;
481
+ return acc;
482
+ },
483
+ {} as Partial<Record<ProposalStatus, number>>
484
+ );
485
+ }, [proposals]);
486
+
487
+ const formatCurrency = (
488
+ cents?: number | null,
489
+ currencyCode?: string | null
490
+ ) => {
491
+ return new Intl.NumberFormat(locale, {
492
+ style: 'currency',
493
+ currency: currencyCode || 'BRL',
494
+ minimumFractionDigits: 2,
495
+ maximumFractionDigits: 2,
496
+ }).format(Number(cents ?? 0) / 100);
497
+ };
498
+
499
+ const formatDate = (value?: string | null) => {
500
+ if (!value) return t('proposals.info.noValidity');
501
+
502
+ return new Intl.DateTimeFormat(locale, {
503
+ day: '2-digit',
504
+ month: 'short',
505
+ year: 'numeric',
506
+ }).format(new Date(value));
507
+ };
508
+
509
+ const getStatusLabel = (status?: string | null) => {
510
+ if (!status) return '—';
511
+
512
+ return t(`proposals.status.${status}` as Parameters<typeof t>[0]);
513
+ };
514
+
515
+ const getStatusBadgeClassName = (status?: string | null) => {
516
+ switch (status) {
517
+ case 'draft':
518
+ return 'border-slate-500/20 bg-slate-500/10 text-slate-700';
519
+ case 'pending_approval':
520
+ return 'border-amber-500/20 bg-amber-500/10 text-amber-700';
521
+ case 'approved':
522
+ return 'border-emerald-500/20 bg-emerald-500/10 text-emerald-700';
523
+ case 'rejected':
524
+ return 'border-red-500/20 bg-red-500/10 text-red-700';
525
+ case 'contract_generated':
526
+ return 'border-sky-500/20 bg-sky-500/10 text-sky-700';
527
+ case 'expired':
528
+ return 'border-orange-500/20 bg-orange-500/10 text-orange-700';
529
+ default:
530
+ return 'border-border bg-muted/50 text-foreground';
531
+ }
532
+ };
533
+
534
+ const canEditProposal = (status?: ProposalStatus | null) =>
535
+ status !== 'approved' &&
536
+ status !== 'pending_approval' &&
537
+ status !== 'contract_generated';
538
+
539
+ const canSubmitProposal = (status?: ProposalStatus | null) =>
540
+ status !== 'approved' &&
541
+ status !== 'pending_approval' &&
542
+ status !== 'contract_generated';
543
+
544
+ const canGeneratePdf = (proposal?: ProposalRecord | null) => {
545
+ if (!proposal) return false;
546
+
547
+ const revision = getCurrentRevision(proposal);
548
+ const items = revision?.proposal_item ?? [];
549
+ return items.length > 0 || Number(proposal.total_amount_cents ?? 0) > 0;
550
+ };
551
+
552
+ const openCreateDialog = () => {
553
+ setEditingProposal(null);
554
+ setFormOpen(true);
555
+ };
556
+
557
+ const openEditDialog = (proposal: ProposalRecord) => {
558
+ setEditingProposal(proposal);
559
+ setSelectedProposalId(proposal.id);
560
+ setFormOpen(true);
561
+ };
562
+
563
+ const refreshAll = async (proposalId?: number | null) => {
564
+ await Promise.all([
565
+ refetchProposals(),
566
+ proposalId && proposalId === selectedProposalId
567
+ ? refetchProposalDetail()
568
+ : Promise.resolve(),
569
+ Promise.resolve(onLeadUpdated(lead)),
570
+ ]);
571
+ };
572
+
573
+ const handleSave = async (values: ProposalFormValues) => {
574
+ const amountInCents = Math.round(Number(values.totalAmount || 0) * 100);
575
+ const payload = {
576
+ person_id: lead.id,
577
+ code: values.code?.trim() || undefined,
578
+ title: values.title.trim(),
579
+ contract_category: values.contractCategory,
580
+ contract_type: values.contractType,
581
+ billing_model: values.billingModel,
582
+ currency_code: 'BRL',
583
+ valid_until: values.validUntil
584
+ ? new Date(`${values.validUntil}T00:00:00`).toISOString()
585
+ : null,
586
+ subtotal_amount_cents: amountInCents,
587
+ total_amount_cents: amountInCents,
588
+ summary: values.summary?.trim() || null,
589
+ notes: values.notes?.trim() || null,
590
+ items:
591
+ amountInCents > 0
592
+ ? [
593
+ {
594
+ name: values.title.trim(),
595
+ description:
596
+ values.summary?.trim() || values.notes?.trim() || undefined,
597
+ quantity: 1,
598
+ unit_amount_cents: amountInCents,
599
+ total_amount_cents: amountInCents,
600
+ item_type: 'service',
601
+ term_type: 'value',
602
+ recurrence: 'one_time',
603
+ },
604
+ ]
605
+ : [],
606
+ };
607
+
608
+ try {
609
+ setIsSaving(true);
610
+
611
+ if (editingProposal) {
612
+ await request({
613
+ url: `/proposal/${editingProposal.id}`,
614
+ method: 'PATCH',
615
+ data: {
616
+ ...payload,
617
+ create_new_revision: true,
618
+ },
619
+ });
620
+
621
+ toast.success(t('proposals.toasts.updateSuccess'));
622
+ setSelectedProposalId(editingProposal.id);
623
+ await refreshAll(editingProposal.id);
624
+ } else {
625
+ const response = await request<ProposalRecord>({
626
+ url: '/proposal',
627
+ method: 'POST',
628
+ data: payload,
629
+ });
630
+
631
+ const createdProposal = response.data;
632
+ setSelectedProposalId(createdProposal.id);
633
+ toast.success(t('proposals.toasts.createSuccess'));
634
+ await refreshAll(createdProposal.id);
635
+ }
636
+
637
+ setFormOpen(false);
638
+ } catch (error) {
639
+ const message =
640
+ error instanceof Error
641
+ ? error.message
642
+ : editingProposal
643
+ ? t('proposals.toasts.updateError')
644
+ : t('proposals.toasts.createError');
645
+
646
+ toast.error(message);
647
+ } finally {
648
+ setIsSaving(false);
649
+ }
650
+ };
651
+
652
+ const handleStatusAction = async (
653
+ proposal: ProposalRecord,
654
+ action: 'submit' | 'approve' | 'reject'
655
+ ) => {
656
+ try {
657
+ setActionKey(`${action}-${proposal.id}`);
658
+
659
+ await request({
660
+ url: `/proposal/${proposal.id}/${action}`,
661
+ method: 'POST',
662
+ data: {},
663
+ });
664
+
665
+ toast.success(
666
+ t(`proposals.toasts.${action}Success` as Parameters<typeof t>[0])
667
+ );
668
+ setSelectedProposalId(proposal.id);
669
+ await refreshAll(proposal.id);
670
+ } catch (error) {
671
+ const message =
672
+ error instanceof Error
673
+ ? error.message
674
+ : t(`proposals.toasts.${action}Error` as Parameters<typeof t>[0]);
675
+
676
+ toast.error(message);
677
+ } finally {
678
+ setActionKey(null);
679
+ }
680
+ };
681
+
682
+ const handleGeneratePdf = async (proposal: ProposalRecord) => {
683
+ try {
684
+ setActionKey(`generate-pdf-${proposal.id}`);
685
+
686
+ const response = await request<GenerateProposalDocumentResponse>({
687
+ url: `/proposal/${proposal.id}/generate-pdf`,
688
+ method: 'POST',
689
+ data: {},
690
+ });
691
+
692
+ toast.success(t('proposals.toasts.generatePdfSuccess'));
693
+ setSelectedProposalId(proposal.id);
694
+ await refreshAll(proposal.id);
695
+
696
+ const fileId = Number(response.data?.fileId ?? 0) || null;
697
+ if (fileId) {
698
+ openStoredFile(fileId);
699
+ }
700
+ } catch (error) {
701
+ const message =
702
+ error instanceof Error
703
+ ? error.message
704
+ : t('proposals.toasts.generatePdfError');
705
+
706
+ toast.error(message);
707
+ } finally {
708
+ setActionKey(null);
709
+ }
710
+ };
711
+
712
+ const handleDelete = async () => {
713
+ if (!deleteTarget) return;
714
+
715
+ try {
716
+ setActionKey(`delete-${deleteTarget.id}`);
717
+
718
+ await request({
719
+ url: '/proposal',
720
+ method: 'DELETE',
721
+ data: {
722
+ ids: [deleteTarget.id],
723
+ },
724
+ });
725
+
726
+ toast.success(t('proposals.toasts.deleteSuccess'));
727
+ setDeleteTarget(null);
728
+ await Promise.all([
729
+ refetchProposals(),
730
+ Promise.resolve(onLeadUpdated(lead)),
731
+ ]);
732
+ } catch (error) {
733
+ const message =
734
+ error instanceof Error
735
+ ? error.message
736
+ : t('proposals.toasts.deleteError');
737
+
738
+ toast.error(message);
739
+ } finally {
740
+ setActionKey(null);
741
+ }
742
+ };
743
+
744
+ return (
745
+ <div className="flex h-full flex-col overflow-hidden px-5 py-4">
746
+ <div className="flex flex-wrap items-start justify-between gap-3">
747
+ <div>
748
+ <p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
749
+ {t('detail.tabs.proposals')}
750
+ </p>
751
+ <p className="mt-1 text-sm text-muted-foreground">
752
+ {t('proposals.selectHint')}
753
+ </p>
754
+ </div>
755
+
756
+ <Button size="sm" className="gap-2" onClick={openCreateDialog}>
757
+ <Plus className="size-4" />
758
+ {t('proposals.new')}
759
+ </Button>
760
+ </div>
761
+
762
+ <div className="mt-4 flex flex-wrap gap-2">
763
+ <Badge variant="secondary">
764
+ {t('proposals.count', { count: proposals.length })}
765
+ </Badge>
766
+ {statusCounts.draft ? (
767
+ <Badge variant="outline" className={getStatusBadgeClassName('draft')}>
768
+ {getStatusLabel('draft')}: {statusCounts.draft}
769
+ </Badge>
770
+ ) : null}
771
+ {statusCounts.pending_approval ? (
772
+ <Badge
773
+ variant="outline"
774
+ className={getStatusBadgeClassName('pending_approval')}
775
+ >
776
+ {getStatusLabel('pending_approval')}:{' '}
777
+ {statusCounts.pending_approval}
778
+ </Badge>
779
+ ) : null}
780
+ {statusCounts.approved ? (
781
+ <Badge
782
+ variant="outline"
783
+ className={getStatusBadgeClassName('approved')}
784
+ >
785
+ {getStatusLabel('approved')}: {statusCounts.approved}
786
+ </Badge>
787
+ ) : null}
788
+ </div>
789
+
790
+ <div className="mt-4 flex-1 overflow-y-auto pr-1">
791
+ {isLoadingProposals ? (
792
+ <div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
793
+ {t('proposals.loading')}
794
+ </div>
795
+ ) : proposals.length === 0 ? (
796
+ <Card className="border-dashed">
797
+ <CardHeader>
798
+ <CardTitle className="text-base">
799
+ {t('proposals.emptyTitle')}
800
+ </CardTitle>
801
+ <CardDescription>
802
+ {t('proposals.emptyDescription')}
803
+ </CardDescription>
804
+ </CardHeader>
805
+ <CardContent>
806
+ <Button size="sm" className="gap-2" onClick={openCreateDialog}>
807
+ <Plus className="size-4" />
808
+ {t('proposals.emptyAction')}
809
+ </Button>
810
+ </CardContent>
811
+ </Card>
812
+ ) : (
813
+ <div className="space-y-4">
814
+ <div className="space-y-2.5">
815
+ {proposals.map((proposal) => {
816
+ const currentRevision = getCurrentRevision(proposal);
817
+ const isSelected = proposal.id === selectedProposalId;
818
+
819
+ return (
820
+ <div
821
+ key={proposal.id}
822
+ role="button"
823
+ tabIndex={0}
824
+ onClick={() => setSelectedProposalId(proposal.id)}
825
+ onKeyDown={(event) => {
826
+ if (event.key === 'Enter' || event.key === ' ') {
827
+ event.preventDefault();
828
+ setSelectedProposalId(proposal.id);
829
+ }
830
+ }}
831
+ className={cn(
832
+ 'cursor-pointer rounded-xl border border-border/60 bg-muted/10 p-3 transition-colors',
833
+ isSelected && 'border-primary bg-primary/5'
834
+ )}
835
+ >
836
+ <div className="flex items-start justify-between gap-3">
837
+ <div className="min-w-0 space-y-1">
838
+ <div className="flex flex-wrap items-center gap-2">
839
+ <p className="text-sm font-semibold text-foreground">
840
+ {proposal.title}
841
+ </p>
842
+ <Badge
843
+ variant="outline"
844
+ className={getStatusBadgeClassName(proposal.status)}
845
+ >
846
+ {getStatusLabel(proposal.status)}
847
+ </Badge>
848
+ </div>
849
+ <p className="text-xs text-muted-foreground">
850
+ {proposal.code || `#${proposal.id}`}
851
+ </p>
852
+ </div>
853
+
854
+ <DropdownMenu>
855
+ <DropdownMenuTrigger asChild>
856
+ <Button
857
+ variant="ghost"
858
+ size="icon"
859
+ className="size-8"
860
+ >
861
+ <MoreHorizontal className="size-4" />
862
+ </Button>
863
+ </DropdownMenuTrigger>
864
+ <DropdownMenuContent align="end">
865
+ {canEditProposal(proposal.status) ? (
866
+ <DropdownMenuItem
867
+ onClick={() => openEditDialog(proposal)}
868
+ >
869
+ <Pencil className="mr-2 size-4" />
870
+ {t('proposals.actions.edit')}
871
+ </DropdownMenuItem>
872
+ ) : null}
873
+
874
+ {canSubmitProposal(proposal.status) ? (
875
+ <DropdownMenuItem
876
+ onClick={() =>
877
+ handleStatusAction(proposal, 'submit')
878
+ }
879
+ >
880
+ <Send className="mr-2 size-4" />
881
+ {t('proposals.actions.submit')}
882
+ </DropdownMenuItem>
883
+ ) : null}
884
+
885
+ {proposal.status === 'pending_approval' ? (
886
+ <>
887
+ <DropdownMenuItem
888
+ onClick={() =>
889
+ handleStatusAction(proposal, 'approve')
890
+ }
891
+ >
892
+ <CheckCircle2 className="mr-2 size-4" />
893
+ {t('proposals.actions.approve')}
894
+ </DropdownMenuItem>
895
+ <DropdownMenuItem
896
+ onClick={() =>
897
+ handleStatusAction(proposal, 'reject')
898
+ }
899
+ >
900
+ <XCircle className="mr-2 size-4" />
901
+ {t('proposals.actions.reject')}
902
+ </DropdownMenuItem>
903
+ </>
904
+ ) : null}
905
+
906
+ <DropdownMenuItem
907
+ disabled={
908
+ !canGeneratePdf(proposal) ||
909
+ actionKey === `generate-pdf-${proposal.id}`
910
+ }
911
+ onClick={() => handleGeneratePdf(proposal)}
912
+ >
913
+ {actionKey === `generate-pdf-${proposal.id}` ? (
914
+ <Loader2 className="mr-2 size-4 animate-spin" />
915
+ ) : (
916
+ <FileText className="mr-2 size-4" />
917
+ )}
918
+ {t('proposals.actions.generatePdf')}
919
+ </DropdownMenuItem>
920
+
921
+ <DropdownMenuSeparator />
922
+ <DropdownMenuItem
923
+ className="text-red-600 focus:text-red-700"
924
+ onClick={() => setDeleteTarget(proposal)}
925
+ >
926
+ <Trash2 className="mr-2 size-4" />
927
+ {t('proposals.actions.delete')}
928
+ </DropdownMenuItem>
929
+ </DropdownMenuContent>
930
+ </DropdownMenu>
931
+ </div>
932
+
933
+ <div className="mt-3 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
934
+ <span>
935
+ {t('proposals.info.revision', {
936
+ number:
937
+ currentRevision?.revision_number ||
938
+ proposal.current_revision_number ||
939
+ 1,
940
+ })}
941
+ </span>
942
+ <span>
943
+ {formatCurrency(
944
+ proposal.total_amount_cents,
945
+ proposal.currency_code
946
+ )}
947
+ </span>
948
+ <span>{formatDate(proposal.valid_until)}</span>
949
+ </div>
950
+ </div>
951
+ );
952
+ })}
953
+ </div>
954
+
955
+ <Separator />
956
+
957
+ <Card>
958
+ <CardHeader className="gap-3">
959
+ <div className="flex flex-wrap items-start justify-between gap-3">
960
+ <div className="space-y-1">
961
+ <CardTitle className="text-base">
962
+ {selectedProposal?.title || t('proposals.detailTitle')}
963
+ </CardTitle>
964
+ <CardDescription>
965
+ {selectedProposal?.code || t('proposals.notSelected')}
966
+ </CardDescription>
967
+ </div>
968
+
969
+ {selectedProposal ? (
970
+ <div className="flex flex-wrap gap-2">
971
+ {canEditProposal(selectedProposal.status) ? (
972
+ <Button
973
+ variant="outline"
974
+ size="sm"
975
+ className="gap-2"
976
+ onClick={() => openEditDialog(selectedProposal)}
977
+ >
978
+ <Pencil className="size-4" />
979
+ {t('proposals.actions.edit')}
980
+ </Button>
981
+ ) : null}
982
+
983
+ {canSubmitProposal(selectedProposal.status) ? (
984
+ <Button
985
+ size="sm"
986
+ className="gap-2"
987
+ onClick={() =>
988
+ handleStatusAction(selectedProposal, 'submit')
989
+ }
990
+ disabled={
991
+ actionKey === `submit-${selectedProposal.id}`
992
+ }
993
+ >
994
+ {actionKey === `submit-${selectedProposal.id}` ? (
995
+ <Loader2 className="size-4 animate-spin" />
996
+ ) : (
997
+ <Send className="size-4" />
998
+ )}
999
+ {t('proposals.actions.submit')}
1000
+ </Button>
1001
+ ) : null}
1002
+
1003
+ {selectedProposal.status === 'pending_approval' ? (
1004
+ <>
1005
+ <Button
1006
+ size="sm"
1007
+ className="gap-2"
1008
+ onClick={() =>
1009
+ handleStatusAction(selectedProposal, 'approve')
1010
+ }
1011
+ disabled={
1012
+ actionKey === `approve-${selectedProposal.id}`
1013
+ }
1014
+ >
1015
+ {actionKey === `approve-${selectedProposal.id}` ? (
1016
+ <Loader2 className="size-4 animate-spin" />
1017
+ ) : (
1018
+ <CheckCircle2 className="size-4" />
1019
+ )}
1020
+ {t('proposals.actions.approve')}
1021
+ </Button>
1022
+ <Button
1023
+ variant="outline"
1024
+ size="sm"
1025
+ className="gap-2"
1026
+ onClick={() =>
1027
+ handleStatusAction(selectedProposal, 'reject')
1028
+ }
1029
+ disabled={
1030
+ actionKey === `reject-${selectedProposal.id}`
1031
+ }
1032
+ >
1033
+ {actionKey === `reject-${selectedProposal.id}` ? (
1034
+ <Loader2 className="size-4 animate-spin" />
1035
+ ) : (
1036
+ <XCircle className="size-4" />
1037
+ )}
1038
+ {t('proposals.actions.reject')}
1039
+ </Button>
1040
+ </>
1041
+ ) : null}
1042
+
1043
+ <Button
1044
+ variant="outline"
1045
+ size="sm"
1046
+ className="gap-2"
1047
+ onClick={() => handleGeneratePdf(selectedProposal)}
1048
+ disabled={
1049
+ !canGeneratePdf(selectedProposal) ||
1050
+ actionKey === `generate-pdf-${selectedProposal.id}`
1051
+ }
1052
+ >
1053
+ {actionKey === `generate-pdf-${selectedProposal.id}` ? (
1054
+ <Loader2 className="size-4 animate-spin" />
1055
+ ) : (
1056
+ <FileText className="size-4" />
1057
+ )}
1058
+ {t('proposals.actions.generatePdf')}
1059
+ </Button>
1060
+
1061
+ {currentGeneratedPdf?.file_id ? (
1062
+ <Button
1063
+ variant="outline"
1064
+ size="sm"
1065
+ className="gap-2"
1066
+ onClick={() => openStoredFile(currentGeneratedPdf.file_id)}
1067
+ >
1068
+ <Download className="size-4" />
1069
+ {t('proposals.actions.openPdf')}
1070
+ </Button>
1071
+ ) : null}
1072
+ </div>
1073
+ ) : null}
1074
+ </div>
1075
+ </CardHeader>
1076
+
1077
+ <CardContent className="space-y-4">
1078
+ {!selectedProposal ? (
1079
+ <p className="text-sm text-muted-foreground">
1080
+ {t('proposals.notSelected')}
1081
+ </p>
1082
+ ) : isLoadingDetail ? (
1083
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
1084
+ <Loader2 className="size-4 animate-spin" />
1085
+ {t('proposals.loading')}
1086
+ </div>
1087
+ ) : (
1088
+ <>
1089
+ <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
1090
+ <div className="rounded-xl border border-border/60 bg-muted/20 p-3">
1091
+ <p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
1092
+ {t('card.dealValue')}
1093
+ </p>
1094
+ <p className="mt-2 text-sm font-semibold text-foreground">
1095
+ {formatCurrency(
1096
+ selectedProposal.total_amount_cents,
1097
+ selectedProposal.currency_code
1098
+ )}
1099
+ </p>
1100
+ </div>
1101
+ <div className="rounded-xl border border-border/60 bg-muted/20 p-3">
1102
+ <p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
1103
+ {t('proposals.fields.status')}
1104
+ </p>
1105
+ <div className="mt-2">
1106
+ <Badge
1107
+ variant="outline"
1108
+ className={getStatusBadgeClassName(
1109
+ selectedProposal.status
1110
+ )}
1111
+ >
1112
+ {getStatusLabel(selectedProposal.status)}
1113
+ </Badge>
1114
+ </div>
1115
+ </div>
1116
+ <div className="rounded-xl border border-border/60 bg-muted/20 p-3">
1117
+ <p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
1118
+ {t('proposals.fields.validUntil')}
1119
+ </p>
1120
+ <p className="mt-2 text-sm font-semibold text-foreground">
1121
+ {formatDate(selectedProposal.valid_until)}
1122
+ </p>
1123
+ </div>
1124
+ <div className="rounded-xl border border-border/60 bg-muted/20 p-3">
1125
+ <p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
1126
+ {t('proposals.fields.revision')}
1127
+ </p>
1128
+ <p className="mt-2 text-sm font-semibold text-foreground">
1129
+ {t('proposals.info.revision', {
1130
+ number:
1131
+ selectedRevision?.revision_number ||
1132
+ selectedProposal.current_revision_number ||
1133
+ 1,
1134
+ })}
1135
+ </p>
1136
+ </div>
1137
+ </div>
1138
+
1139
+ <div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
1140
+ <div className="rounded-lg border border-border/50 bg-background p-3 text-sm">
1141
+ <span className="text-xs text-muted-foreground">
1142
+ {t('proposals.fields.contractCategory')}
1143
+ </span>
1144
+ <p className="mt-1 font-medium text-foreground">
1145
+ {formatEnumLabel(selectedProposal.contract_category)}
1146
+ </p>
1147
+ </div>
1148
+ <div className="rounded-lg border border-border/50 bg-background p-3 text-sm">
1149
+ <span className="text-xs text-muted-foreground">
1150
+ {t('proposals.fields.contractType')}
1151
+ </span>
1152
+ <p className="mt-1 font-medium text-foreground">
1153
+ {formatEnumLabel(selectedProposal.contract_type)}
1154
+ </p>
1155
+ </div>
1156
+ <div className="rounded-lg border border-border/50 bg-background p-3 text-sm">
1157
+ <span className="text-xs text-muted-foreground">
1158
+ {t('proposals.fields.billingModel')}
1159
+ </span>
1160
+ <p className="mt-1 font-medium text-foreground">
1161
+ {formatEnumLabel(selectedProposal.billing_model)}
1162
+ </p>
1163
+ </div>
1164
+ </div>
1165
+
1166
+ {selectedRevision?.summary ? (
1167
+ <div className="space-y-1">
1168
+ <p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
1169
+ {t('proposals.info.summary')}
1170
+ </p>
1171
+ <div className="rounded-xl border border-border/50 bg-muted/15 p-3 text-sm text-foreground">
1172
+ {selectedRevision.summary}
1173
+ </div>
1174
+ </div>
1175
+ ) : null}
1176
+
1177
+ {selectedProposal.notes ? (
1178
+ <div className="space-y-1">
1179
+ <p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
1180
+ {t('proposals.info.notes')}
1181
+ </p>
1182
+ <div className="rounded-xl border border-border/50 bg-muted/15 p-3 text-sm text-foreground">
1183
+ {selectedProposal.notes}
1184
+ </div>
1185
+ </div>
1186
+ ) : null}
1187
+
1188
+ <div className="space-y-2">
1189
+ <p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
1190
+ {t('proposals.info.lineItems')}
1191
+ </p>
1192
+
1193
+ {selectedItems.length === 0 ? (
1194
+ <div className="rounded-xl border border-dashed p-3 text-sm text-muted-foreground">
1195
+ {t('proposals.info.noItems')}
1196
+ </div>
1197
+ ) : (
1198
+ <div className="space-y-2">
1199
+ {selectedItems.map((item, index) => (
1200
+ <div
1201
+ key={`${selectedProposal.id}-${item.name}-${index}`}
1202
+ className="rounded-xl border border-border/50 bg-muted/15 p-3"
1203
+ >
1204
+ <div className="flex items-start justify-between gap-3">
1205
+ <div>
1206
+ <p className="text-sm font-medium text-foreground">
1207
+ {item.name}
1208
+ </p>
1209
+ {item.description ? (
1210
+ <p className="mt-1 text-xs text-muted-foreground">
1211
+ {item.description}
1212
+ </p>
1213
+ ) : null}
1214
+ </div>
1215
+ <span className="text-sm font-semibold text-foreground">
1216
+ {formatCurrency(
1217
+ item.total_amount_cents,
1218
+ selectedProposal.currency_code
1219
+ )}
1220
+ </span>
1221
+ </div>
1222
+ </div>
1223
+ ))}
1224
+ </div>
1225
+ )}
1226
+ </div>
1227
+
1228
+ <div className="space-y-2">
1229
+ <p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
1230
+ {t('proposals.info.linkedRecords')}
1231
+ </p>
1232
+
1233
+ {selectedProposal.integration_links?.length ? (
1234
+ <div className="flex flex-wrap gap-2">
1235
+ {selectedProposal.integration_links.map(
1236
+ (link, index) => (
1237
+ <Badge
1238
+ key={`${link.targetModule}-${link.targetEntityId}-${index}`}
1239
+ variant="secondary"
1240
+ >
1241
+ {`${formatEnumLabel(link.targetModule)} ${formatEnumLabel(link.targetEntityType)} #${link.targetEntityId}`}
1242
+ </Badge>
1243
+ )
1244
+ )}
1245
+ </div>
1246
+ ) : (
1247
+ <div className="rounded-xl border border-dashed p-3 text-sm text-muted-foreground">
1248
+ {t('proposals.info.noLinkedRecords')}
1249
+ </div>
1250
+ )}
1251
+ </div>
1252
+
1253
+ {selectedProposal.proposal_approval?.[0]?.decision_note ? (
1254
+ <div className="space-y-1">
1255
+ <p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
1256
+ {t('proposals.info.approvalNote')}
1257
+ </p>
1258
+ <div className="rounded-xl border border-border/50 bg-muted/15 p-3 text-sm text-foreground">
1259
+ {selectedProposal.proposal_approval[0].decision_note}
1260
+ </div>
1261
+ </div>
1262
+ ) : null}
1263
+
1264
+ <div className="space-y-2">
1265
+ <p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
1266
+ {t('proposals.info.generatedDocument')}
1267
+ </p>
1268
+
1269
+ {currentGeneratedPdf ? (
1270
+ <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border/50 bg-muted/15 p-3">
1271
+ <div className="min-w-0">
1272
+ <p className="text-sm font-medium text-foreground">
1273
+ {currentGeneratedPdf.file_name ||
1274
+ t('proposals.info.generatedDocument')}
1275
+ </p>
1276
+ <p className="mt-1 text-xs text-muted-foreground">
1277
+ {t('proposals.info.documents', {
1278
+ count: selectedProposal.proposal_document?.length ?? 1,
1279
+ })}
1280
+ </p>
1281
+ </div>
1282
+
1283
+ {currentGeneratedPdf.file_id ? (
1284
+ <Button
1285
+ variant="outline"
1286
+ size="sm"
1287
+ className="gap-2"
1288
+ onClick={() =>
1289
+ openStoredFile(currentGeneratedPdf.file_id)
1290
+ }
1291
+ >
1292
+ <Download className="size-4" />
1293
+ {t('proposals.actions.openPdf')}
1294
+ </Button>
1295
+ ) : null}
1296
+ </div>
1297
+ ) : (
1298
+ <div className="rounded-xl border border-dashed p-3 text-sm text-muted-foreground">
1299
+ {t('proposals.info.noGeneratedDocument')}
1300
+ </div>
1301
+ )}
1302
+ </div>
1303
+ </>
1304
+ )}
1305
+ </CardContent>
1306
+ </Card>
1307
+ </div>
1308
+ )}
1309
+ </div>
1310
+
1311
+ <Dialog open={formOpen} onOpenChange={setFormOpen}>
1312
+ <DialogContent className="sm:max-w-2xl">
1313
+ <DialogHeader>
1314
+ <DialogTitle>
1315
+ {editingProposal
1316
+ ? t('proposals.form.editTitle')
1317
+ : t('proposals.form.createTitle')}
1318
+ </DialogTitle>
1319
+ <DialogDescription>
1320
+ {editingProposal
1321
+ ? t('proposals.form.editDescription')
1322
+ : t('proposals.form.createDescription')}
1323
+ </DialogDescription>
1324
+ </DialogHeader>
1325
+
1326
+ <Form {...form}>
1327
+ <form
1328
+ onSubmit={form.handleSubmit(handleSave)}
1329
+ className="space-y-4"
1330
+ >
1331
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
1332
+ <FormField
1333
+ control={form.control}
1334
+ name="title"
1335
+ render={({ field }) => (
1336
+ <FormItem className="sm:col-span-2">
1337
+ <FormLabel>{t('proposals.form.title')}</FormLabel>
1338
+ <FormControl>
1339
+ <Input
1340
+ {...field}
1341
+ placeholder={t('proposals.form.titlePlaceholder')}
1342
+ />
1343
+ </FormControl>
1344
+ <FormMessage />
1345
+ </FormItem>
1346
+ )}
1347
+ />
1348
+
1349
+ <FormField
1350
+ control={form.control}
1351
+ name="code"
1352
+ render={({ field }) => (
1353
+ <FormItem>
1354
+ <FormLabel>{t('proposals.form.code')}</FormLabel>
1355
+ <FormControl>
1356
+ <Input
1357
+ {...field}
1358
+ value={field.value ?? ''}
1359
+ placeholder={t('proposals.form.codePlaceholder')}
1360
+ />
1361
+ </FormControl>
1362
+ <FormMessage />
1363
+ </FormItem>
1364
+ )}
1365
+ />
1366
+
1367
+ <FormField
1368
+ control={form.control}
1369
+ name="totalAmount"
1370
+ render={({ field }) => (
1371
+ <FormItem>
1372
+ <FormLabel>{t('proposals.form.amount')}</FormLabel>
1373
+ <FormControl>
1374
+ <Input type="number" min="0" step="0.01" {...field} />
1375
+ </FormControl>
1376
+ <FormMessage />
1377
+ </FormItem>
1378
+ )}
1379
+ />
1380
+
1381
+ <FormField
1382
+ control={form.control}
1383
+ name="validUntil"
1384
+ render={({ field }) => (
1385
+ <FormItem>
1386
+ <FormLabel>{t('proposals.form.validUntil')}</FormLabel>
1387
+ <FormControl>
1388
+ <Input
1389
+ type="date"
1390
+ {...field}
1391
+ value={field.value ?? ''}
1392
+ />
1393
+ </FormControl>
1394
+ <FormMessage />
1395
+ </FormItem>
1396
+ )}
1397
+ />
1398
+
1399
+ <FormField
1400
+ control={form.control}
1401
+ name="contractCategory"
1402
+ render={({ field }) => (
1403
+ <FormItem>
1404
+ <FormLabel>
1405
+ {t('proposals.form.contractCategory')}
1406
+ </FormLabel>
1407
+ <Select
1408
+ onValueChange={field.onChange}
1409
+ value={field.value}
1410
+ >
1411
+ <FormControl>
1412
+ <SelectTrigger className="w-full">
1413
+ <SelectValue />
1414
+ </SelectTrigger>
1415
+ </FormControl>
1416
+ <SelectContent>
1417
+ {CONTRACT_CATEGORY_OPTIONS.map((option) => (
1418
+ <SelectItem key={option} value={option}>
1419
+ {formatEnumLabel(option)}
1420
+ </SelectItem>
1421
+ ))}
1422
+ </SelectContent>
1423
+ </Select>
1424
+ <FormMessage />
1425
+ </FormItem>
1426
+ )}
1427
+ />
1428
+
1429
+ <FormField
1430
+ control={form.control}
1431
+ name="contractType"
1432
+ render={({ field }) => (
1433
+ <FormItem>
1434
+ <FormLabel>{t('proposals.form.contractType')}</FormLabel>
1435
+ <Select
1436
+ onValueChange={field.onChange}
1437
+ value={field.value}
1438
+ >
1439
+ <FormControl>
1440
+ <SelectTrigger className="w-full">
1441
+ <SelectValue />
1442
+ </SelectTrigger>
1443
+ </FormControl>
1444
+ <SelectContent>
1445
+ {CONTRACT_TYPE_OPTIONS.map((option) => (
1446
+ <SelectItem key={option} value={option}>
1447
+ {formatEnumLabel(option)}
1448
+ </SelectItem>
1449
+ ))}
1450
+ </SelectContent>
1451
+ </Select>
1452
+ <FormMessage />
1453
+ </FormItem>
1454
+ )}
1455
+ />
1456
+
1457
+ <FormField
1458
+ control={form.control}
1459
+ name="billingModel"
1460
+ render={({ field }) => (
1461
+ <FormItem className="sm:col-span-2">
1462
+ <FormLabel>{t('proposals.form.billingModel')}</FormLabel>
1463
+ <Select
1464
+ onValueChange={field.onChange}
1465
+ value={field.value}
1466
+ >
1467
+ <FormControl>
1468
+ <SelectTrigger className="w-full">
1469
+ <SelectValue />
1470
+ </SelectTrigger>
1471
+ </FormControl>
1472
+ <SelectContent>
1473
+ {BILLING_MODEL_OPTIONS.map((option) => (
1474
+ <SelectItem key={option} value={option}>
1475
+ {formatEnumLabel(option)}
1476
+ </SelectItem>
1477
+ ))}
1478
+ </SelectContent>
1479
+ </Select>
1480
+ <FormMessage />
1481
+ </FormItem>
1482
+ )}
1483
+ />
1484
+
1485
+ <FormField
1486
+ control={form.control}
1487
+ name="summary"
1488
+ render={({ field }) => (
1489
+ <FormItem className="sm:col-span-2">
1490
+ <FormLabel>{t('proposals.form.summary')}</FormLabel>
1491
+ <FormControl>
1492
+ <Textarea
1493
+ {...field}
1494
+ value={field.value ?? ''}
1495
+ rows={3}
1496
+ placeholder={t('proposals.form.summaryPlaceholder')}
1497
+ />
1498
+ </FormControl>
1499
+ <FormMessage />
1500
+ </FormItem>
1501
+ )}
1502
+ />
1503
+
1504
+ <FormField
1505
+ control={form.control}
1506
+ name="notes"
1507
+ render={({ field }) => (
1508
+ <FormItem className="sm:col-span-2">
1509
+ <FormLabel>{t('proposals.form.notes')}</FormLabel>
1510
+ <FormControl>
1511
+ <Textarea
1512
+ {...field}
1513
+ value={field.value ?? ''}
1514
+ rows={4}
1515
+ placeholder={t('proposals.form.notesPlaceholder')}
1516
+ />
1517
+ </FormControl>
1518
+ <FormMessage />
1519
+ </FormItem>
1520
+ )}
1521
+ />
1522
+ </div>
1523
+
1524
+ <DialogFooter>
1525
+ <Button
1526
+ type="button"
1527
+ variant="outline"
1528
+ onClick={() => setFormOpen(false)}
1529
+ disabled={isSaving}
1530
+ >
1531
+ {t('proposals.actions.cancel')}
1532
+ </Button>
1533
+ <Button type="submit" disabled={isSaving} className="gap-2">
1534
+ {isSaving ? (
1535
+ <Loader2 className="size-4 animate-spin" />
1536
+ ) : null}
1537
+ {t('proposals.actions.save')}
1538
+ </Button>
1539
+ </DialogFooter>
1540
+ </form>
1541
+ </Form>
1542
+ </DialogContent>
1543
+ </Dialog>
1544
+
1545
+ <AlertDialog
1546
+ open={!!deleteTarget}
1547
+ onOpenChange={(open) => {
1548
+ if (!open) setDeleteTarget(null);
1549
+ }}
1550
+ >
1551
+ <AlertDialogContent>
1552
+ <AlertDialogHeader>
1553
+ <AlertDialogTitle>
1554
+ {t('proposals.dialogDeleteTitle')}
1555
+ </AlertDialogTitle>
1556
+ <AlertDialogDescription>
1557
+ {t('proposals.dialogDeleteDescription', {
1558
+ title: deleteTarget?.title || '',
1559
+ })}
1560
+ </AlertDialogDescription>
1561
+ </AlertDialogHeader>
1562
+ <AlertDialogFooter>
1563
+ <AlertDialogCancel disabled={actionKey?.startsWith('delete-')}>
1564
+ {t('proposals.actions.cancel')}
1565
+ </AlertDialogCancel>
1566
+ <AlertDialogAction
1567
+ onClick={(event) => {
1568
+ event.preventDefault();
1569
+ void handleDelete();
1570
+ }}
1571
+ disabled={actionKey?.startsWith('delete-')}
1572
+ className="bg-red-600 hover:bg-red-700"
1573
+ >
1574
+ {actionKey?.startsWith('delete-') ? (
1575
+ <Loader2 className="mr-2 size-4 animate-spin" />
1576
+ ) : null}
1577
+ {t('proposals.actions.delete')}
1578
+ </AlertDialogAction>
1579
+ </AlertDialogFooter>
1580
+ </AlertDialogContent>
1581
+ </AlertDialog>
1582
+ </div>
1583
+ );
1584
+ }