@hed-hog/operations 0.0.300 → 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 (73) hide show
  1. package/dist/operations.controller.d.ts +713 -31
  2. package/dist/operations.controller.d.ts.map +1 -1
  3. package/dist/operations.controller.js +157 -0
  4. package/dist/operations.controller.js.map +1 -1
  5. package/dist/operations.module.d.ts.map +1 -1
  6. package/dist/operations.module.js +5 -1
  7. package/dist/operations.module.js.map +1 -1
  8. package/dist/operations.proposal.subscriber.d.ts +11 -0
  9. package/dist/operations.proposal.subscriber.d.ts.map +1 -0
  10. package/dist/operations.proposal.subscriber.js +80 -0
  11. package/dist/operations.proposal.subscriber.js.map +1 -0
  12. package/dist/operations.proposal.subscriber.spec.d.ts +2 -0
  13. package/dist/operations.proposal.subscriber.spec.d.ts.map +1 -0
  14. package/dist/operations.proposal.subscriber.spec.js +88 -0
  15. package/dist/operations.proposal.subscriber.spec.js.map +1 -0
  16. package/dist/operations.service.d.ts +490 -46
  17. package/dist/operations.service.d.ts.map +1 -1
  18. package/dist/operations.service.js +2442 -119
  19. package/dist/operations.service.js.map +1 -1
  20. package/dist/operations.service.spec.d.ts +2 -0
  21. package/dist/operations.service.spec.d.ts.map +1 -0
  22. package/dist/operations.service.spec.js +159 -0
  23. package/dist/operations.service.spec.js.map +1 -0
  24. package/hedhog/data/menu.yaml +34 -0
  25. package/hedhog/data/role_route.yaml +39 -0
  26. package/hedhog/data/route.yaml +130 -0
  27. package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +8 -6
  28. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +1163 -327
  29. package/hedhog/frontend/app/_components/collaborator-select-with-create.tsx.ejs +256 -0
  30. package/hedhog/frontend/app/_components/contract-content-editor.tsx.ejs +258 -0
  31. package/hedhog/frontend/app/_components/contract-creation-wizard.tsx.ejs +631 -0
  32. package/hedhog/frontend/app/_components/contract-details-screen.tsx.ejs +353 -27
  33. package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +1926 -87
  34. package/hedhog/frontend/app/_components/contract-template-form-screen.tsx.ejs +526 -0
  35. package/hedhog/frontend/app/_components/contract-template-select-with-create.tsx.ejs +247 -0
  36. package/hedhog/frontend/app/_components/contract-wizard-sheet.tsx.ejs +3520 -0
  37. package/hedhog/frontend/app/_components/department-select-with-create.tsx.ejs +370 -0
  38. package/hedhog/frontend/app/_components/person-select-with-create.tsx.ejs +826 -0
  39. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +1251 -364
  40. package/hedhog/frontend/app/_components/section-card.tsx.ejs +48 -13
  41. package/hedhog/frontend/app/_lib/api.ts.ejs +2 -5
  42. package/hedhog/frontend/app/_lib/types.ts.ejs +76 -33
  43. package/hedhog/frontend/app/_lib/utils/format.ts.ejs +85 -8
  44. package/hedhog/frontend/app/approvals/page.tsx.ejs +90 -54
  45. package/hedhog/frontend/app/collaborators/[id]/edit/page.tsx.ejs +2 -2
  46. package/hedhog/frontend/app/collaborators/[id]/page.tsx.ejs +2 -2
  47. package/hedhog/frontend/app/collaborators/page.tsx.ejs +597 -140
  48. package/hedhog/frontend/app/contracts/[id]/edit/page.tsx.ejs +2 -2
  49. package/hedhog/frontend/app/contracts/[id]/page.tsx.ejs +2 -2
  50. package/hedhog/frontend/app/contracts/page.tsx.ejs +941 -262
  51. package/hedhog/frontend/app/contracts/templates/page.tsx.ejs +384 -0
  52. package/hedhog/frontend/app/departments/page.tsx.ejs +442 -0
  53. package/hedhog/frontend/app/page.tsx.ejs +36 -12
  54. package/hedhog/frontend/app/projects/[id]/edit/page.tsx.ejs +2 -2
  55. package/hedhog/frontend/app/projects/new/page.tsx.ejs +2 -2
  56. package/hedhog/frontend/app/projects/page.tsx.ejs +264 -102
  57. package/hedhog/frontend/app/schedule-adjustments/page.tsx.ejs +50 -28
  58. package/hedhog/frontend/app/time-off/page.tsx.ejs +57 -31
  59. package/hedhog/frontend/app/timesheets/page.tsx.ejs +85 -42
  60. package/hedhog/frontend/messages/en.json +473 -12
  61. package/hedhog/frontend/messages/pt.json +528 -66
  62. package/hedhog/table/operations_collaborator.yaml +20 -0
  63. package/hedhog/table/operations_contract.yaml +22 -1
  64. package/hedhog/table/operations_contract_document.yaml +33 -16
  65. package/hedhog/table/operations_contract_template.yaml +58 -0
  66. package/hedhog/table/operations_department.yaml +24 -0
  67. package/package.json +6 -4
  68. package/src/operations.controller.ts +122 -0
  69. package/src/operations.module.ts +6 -2
  70. package/src/operations.proposal.subscriber.spec.ts +121 -0
  71. package/src/operations.proposal.subscriber.ts +86 -0
  72. package/src/operations.service.spec.ts +210 -0
  73. package/src/operations.service.ts +3934 -212
@@ -0,0 +1,3520 @@
1
+ 'use client';
2
+
3
+ import { Button } from '@/components/ui/button';
4
+ import { Input } from '@/components/ui/input';
5
+ import { InputMoney } from '@/components/ui/input-money';
6
+ import { Label } from '@/components/ui/label';
7
+ import {
8
+ Select,
9
+ SelectContent,
10
+ SelectItem,
11
+ SelectTrigger,
12
+ SelectValue,
13
+ } from '@/components/ui/select';
14
+ import { Switch } from '@/components/ui/switch';
15
+ import { Textarea } from '@/components/ui/textarea';
16
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
17
+ import {
18
+ BadgeDollarSign,
19
+ BriefcaseBusiness,
20
+ CheckCircle2,
21
+ ChevronLeft,
22
+ ChevronRight,
23
+ CopyPlus,
24
+ Download,
25
+ FileSpreadsheet,
26
+ FileText,
27
+ LayoutTemplate,
28
+ LoaderCircle,
29
+ NotebookText,
30
+ Save,
31
+ Sparkles,
32
+ Trash2,
33
+ Upload,
34
+ UserRound,
35
+ Users,
36
+ } from 'lucide-react';
37
+ import { useTranslations } from 'next-intl';
38
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
39
+ import { fetchOperations, mutateOperations } from '../_lib/api';
40
+ import type {
41
+ OperationsCollaborator,
42
+ OperationsContract,
43
+ OperationsContractDetails,
44
+ OperationsContractTemplate,
45
+ } from '../_lib/types';
46
+ import {
47
+ formatCurrency,
48
+ formatDate,
49
+ formatEnumLabel,
50
+ } from '../_lib/utils/format';
51
+ import { parseNumberInput, trimToNull } from '../_lib/utils/forms';
52
+ import { CollaboratorSelectWithCreate } from './collaborator-select-with-create';
53
+ import { ContractContentEditor } from './contract-content-editor';
54
+ import { ContractTemplateSelectWithCreate } from './contract-template-select-with-create';
55
+ import { PersonSelectWithCreate } from './person-select-with-create';
56
+ import { SectionCard } from './section-card';
57
+
58
+ type CreationMode = 'blank' | 'template' | 'upload' | 'duplicate' | 'ai_prompt';
59
+
60
+ type PartyFieldMode = 'existing' | 'new';
61
+
62
+ type PersonValueOption = {
63
+ id?: number;
64
+ value: string;
65
+ label: string;
66
+ normalizedValue: string;
67
+ isPrimary: boolean;
68
+ };
69
+
70
+ type PersonContactRecord = {
71
+ id?: number;
72
+ value?: string | null;
73
+ is_primary?: boolean;
74
+ contact_type_id?: number;
75
+ contact_type?: {
76
+ id?: number;
77
+ code?: string;
78
+ } | null;
79
+ };
80
+
81
+ type PersonDocumentRecord = {
82
+ id?: number;
83
+ value?: string | null;
84
+ document_type_id?: number;
85
+ document_type?: {
86
+ id?: number;
87
+ code?: string;
88
+ } | null;
89
+ };
90
+
91
+ type PersonAddressRecord = {
92
+ id?: number;
93
+ line1?: string | null;
94
+ line2?: string | null;
95
+ city?: string | null;
96
+ state?: string | null;
97
+ is_primary?: boolean;
98
+ address_type?: string | null;
99
+ postal_code?: string | null;
100
+ country_code?: string | null;
101
+ };
102
+
103
+ type PersonDetailsRecord = {
104
+ id: number;
105
+ name: string;
106
+ type?: string | null;
107
+ status?: string | null;
108
+ contact?: PersonContactRecord[];
109
+ document?: PersonDocumentRecord[];
110
+ address?: PersonAddressRecord[];
111
+ };
112
+
113
+ type ContactTypeLookup = {
114
+ code: string;
115
+ contact_type_id: number;
116
+ };
117
+
118
+ type DocumentTypeLookup = {
119
+ code: string;
120
+ document_type_id: number;
121
+ };
122
+
123
+ type PartyState = {
124
+ displayName: string;
125
+ partyRole: string;
126
+ partyType: string;
127
+ documentNumber: string;
128
+ email: string;
129
+ phone: string;
130
+ isPrimary: boolean;
131
+ personId: number | null;
132
+ availableDocuments: PersonValueOption[];
133
+ availableEmails: PersonValueOption[];
134
+ availablePhones: PersonValueOption[];
135
+ documentMode: PartyFieldMode;
136
+ emailMode: PartyFieldMode;
137
+ phoneMode: PartyFieldMode;
138
+ };
139
+
140
+ type SignatureState = {
141
+ signerName: string;
142
+ signerRole: string;
143
+ signerEmail: string;
144
+ status: string;
145
+ signedAt: string;
146
+ };
147
+
148
+ type FinancialTermState = {
149
+ label: string;
150
+ termType: string;
151
+ amount: string;
152
+ recurrence: string;
153
+ dueDay: string;
154
+ notes: string;
155
+ };
156
+
157
+ type RevisionState = {
158
+ title: string;
159
+ revisionType: string;
160
+ effectiveDate: string;
161
+ status: string;
162
+ summary: string;
163
+ };
164
+
165
+ type DocumentState = {
166
+ id?: number;
167
+ fileId?: number | null;
168
+ fileName: string;
169
+ mimeType: string;
170
+ extractionStatus?: string | null;
171
+ extractionSummary?: string | null;
172
+ };
173
+
174
+ type ContractWizardState = {
175
+ code: string;
176
+ name: string;
177
+ clientName: string;
178
+ contractTemplateId: string;
179
+ contractCategory: string;
180
+ contractType: string;
181
+ signatureStatus: string;
182
+ isActive: boolean;
183
+ billingModel: string;
184
+ accountManagerCollaboratorId: string;
185
+ relatedCollaboratorId: string;
186
+ originType: string;
187
+ originId: string;
188
+ startDate: string;
189
+ endDate: string;
190
+ signedAt: string;
191
+ effectiveDate: string;
192
+ budgetAmount: string;
193
+ monthlyHourCap: string;
194
+ status: string;
195
+ description: string;
196
+ contentHtml: string;
197
+ creationMode: CreationMode;
198
+ wizardStep: number;
199
+ parties: PartyState[];
200
+ signatures: SignatureState[];
201
+ financialTerms: FinancialTermState[];
202
+ revisions: RevisionState[];
203
+ sourceDocument: DocumentState | null;
204
+ generatedPdfDocument: DocumentState | null;
205
+ };
206
+
207
+ type ExtractedDraft = Partial<ContractWizardState> & {
208
+ summary?: string;
209
+ missingFields?: string[];
210
+ warnings?: string[];
211
+ };
212
+
213
+ type ContractAiReviewState = {
214
+ summary?: string;
215
+ missingFields: string[];
216
+ warnings: string[];
217
+ checklist: string[];
218
+ status?: string;
219
+ reviewedAt?: string;
220
+ };
221
+
222
+ type ContractWizardSheetProps = {
223
+ contractId?: number;
224
+ duplicateFromId?: number;
225
+ initialTemplateId?: number;
226
+ isCreateFlow?: boolean;
227
+ onCancel?: () => void;
228
+ onSaved?: (contract: OperationsContractDetails) => void | Promise<void>;
229
+ };
230
+
231
+ const allSteps = [
232
+ { key: 'origin', label: 'Origem', icon: Sparkles },
233
+ { key: 'identification', label: 'Identificacao', icon: BriefcaseBusiness },
234
+ { key: 'parties', label: 'Partes', icon: Users },
235
+ { key: 'terms', label: 'Condicoes', icon: BadgeDollarSign },
236
+ { key: 'content', label: 'Conteudo', icon: NotebookText },
237
+ { key: 'review', label: 'Revisao', icon: CheckCircle2 },
238
+ ] as const;
239
+
240
+ const createFlowSteps = allSteps.filter((step) => step.key !== 'content');
241
+
242
+ const contractDateFields: Array<{
243
+ key: 'startDate' | 'endDate' | 'signedAt' | 'effectiveDate';
244
+ label: string;
245
+ }> = [
246
+ { key: 'startDate', label: 'Inicio' },
247
+ { key: 'endDate', label: 'Fim' },
248
+ { key: 'signedAt', label: 'Assinado em' },
249
+ { key: 'effectiveDate', label: 'Vigencia' },
250
+ ];
251
+
252
+ const PERSON_FIELD_NEW_VALUE = '__new__';
253
+ const EMAIL_CONTACT_TYPE_ID = 2;
254
+ const PHONE_CONTACT_TYPE_IDS = new Set([1, 3, 4, 5]);
255
+ const PHONE_CONTACT_TYPE_CODES = new Set([
256
+ 'PHONE',
257
+ 'FAX',
258
+ 'WHATSAPP',
259
+ 'TELEGRAM',
260
+ 'MOBILE',
261
+ ]);
262
+
263
+ function emptyParty(): PartyState {
264
+ return {
265
+ displayName: '',
266
+ partyRole: 'client',
267
+ partyType: 'company',
268
+ documentNumber: '',
269
+ email: '',
270
+ phone: '',
271
+ isPrimary: false,
272
+ personId: null,
273
+ availableDocuments: [],
274
+ availableEmails: [],
275
+ availablePhones: [],
276
+ documentMode: 'new',
277
+ emailMode: 'new',
278
+ phoneMode: 'new',
279
+ };
280
+ }
281
+
282
+ function emptySignature(): SignatureState {
283
+ return {
284
+ signerName: '',
285
+ signerRole: '',
286
+ signerEmail: '',
287
+ status: 'pending',
288
+ signedAt: '',
289
+ };
290
+ }
291
+
292
+ function emptyFinancialTerm(): FinancialTermState {
293
+ return {
294
+ label: '',
295
+ termType: 'value',
296
+ amount: '',
297
+ recurrence: 'one_time',
298
+ dueDay: '',
299
+ notes: '',
300
+ };
301
+ }
302
+
303
+ function buildEmptyForm(mode: CreationMode = 'blank'): ContractWizardState {
304
+ return {
305
+ code: '',
306
+ name: '',
307
+ clientName: '',
308
+ contractTemplateId: 'none',
309
+ contractCategory: 'client',
310
+ contractType: 'service_agreement',
311
+ signatureStatus: 'not_started',
312
+ isActive: true,
313
+ billingModel: 'time_and_material',
314
+ accountManagerCollaboratorId: 'none',
315
+ relatedCollaboratorId: 'none',
316
+ originType: 'manual',
317
+ originId: '',
318
+ startDate: '',
319
+ endDate: '',
320
+ signedAt: '',
321
+ effectiveDate: '',
322
+ budgetAmount: '',
323
+ monthlyHourCap: '',
324
+ status: 'draft',
325
+ description: '',
326
+ contentHtml: '',
327
+ creationMode: mode,
328
+ wizardStep: 0,
329
+ parties: [emptyParty()],
330
+ signatures: [emptySignature()],
331
+ financialTerms: [emptyFinancialTerm()],
332
+ revisions: [],
333
+ sourceDocument: null,
334
+ generatedPdfDocument: null,
335
+ };
336
+ }
337
+
338
+ function toTextValue(value: unknown) {
339
+ return typeof value === 'string' ? value : value == null ? '' : String(value);
340
+ }
341
+
342
+ function asRecord(value: unknown): Record<string, unknown> {
343
+ return value && typeof value === 'object'
344
+ ? (value as Record<string, unknown>)
345
+ : {};
346
+ }
347
+
348
+ function normalizePartyFieldValue(
349
+ kind: 'document' | 'email' | 'phone',
350
+ value?: string | null
351
+ ) {
352
+ const normalized = String(value ?? '').trim();
353
+
354
+ if (!normalized) {
355
+ return '';
356
+ }
357
+
358
+ if (kind === 'email') {
359
+ return normalized.toLowerCase();
360
+ }
361
+
362
+ return normalized.replace(/\D/g, '');
363
+ }
364
+
365
+ function dedupePersonValueOptions(options: PersonValueOption[]) {
366
+ const seen = new Set<string>();
367
+
368
+ return options.filter((option) => {
369
+ if (!option.normalizedValue || seen.has(option.normalizedValue)) {
370
+ return false;
371
+ }
372
+
373
+ seen.add(option.normalizedValue);
374
+ return true;
375
+ });
376
+ }
377
+
378
+ function getDefaultPersonOptionValue(options: PersonValueOption[]) {
379
+ return (
380
+ options.find((option) => option.isPrimary)?.value ?? options[0]?.value ?? ''
381
+ );
382
+ }
383
+
384
+ function resolvePartyFieldMode(
385
+ kind: 'document' | 'email' | 'phone',
386
+ value: string,
387
+ options: PersonValueOption[]
388
+ ): PartyFieldMode {
389
+ if (!options.length) {
390
+ return 'new';
391
+ }
392
+
393
+ const normalizedValue = normalizePartyFieldValue(kind, value);
394
+ return normalizedValue &&
395
+ options.some((option) => option.normalizedValue === normalizedValue)
396
+ ? 'existing'
397
+ : 'new';
398
+ }
399
+
400
+ function buildPersonValueOptions(person: PersonDetailsRecord) {
401
+ const documents = dedupePersonValueOptions(
402
+ (person.document ?? []).flatMap((document) => {
403
+ const value = String(document.value ?? '').trim();
404
+ if (!value) {
405
+ return [];
406
+ }
407
+
408
+ const code = String(document.document_type?.code ?? 'documento').trim();
409
+ return [
410
+ {
411
+ id: document.id,
412
+ value,
413
+ label: `${formatEnumLabel(code)} • ${value}`,
414
+ normalizedValue: normalizePartyFieldValue('document', value),
415
+ isPrimary: false,
416
+ },
417
+ ];
418
+ })
419
+ );
420
+
421
+ const emails = dedupePersonValueOptions(
422
+ (person.contact ?? [])
423
+ .filter((contact) => {
424
+ const code = String(contact.contact_type?.code ?? '').toUpperCase();
425
+ const typeId = Number(
426
+ contact.contact_type?.id ?? contact.contact_type_id ?? 0
427
+ );
428
+
429
+ return code === 'EMAIL' || typeId === EMAIL_CONTACT_TYPE_ID;
430
+ })
431
+ .flatMap((contact) => {
432
+ const value = String(contact.value ?? '').trim();
433
+ if (!value) {
434
+ return [];
435
+ }
436
+
437
+ const code = String(contact.contact_type?.code ?? 'email').trim();
438
+ return [
439
+ {
440
+ id: contact.id,
441
+ value,
442
+ label: `${contact.is_primary ? 'Principal' : formatEnumLabel(code)} • ${value}`,
443
+ normalizedValue: normalizePartyFieldValue('email', value),
444
+ isPrimary: Boolean(contact.is_primary),
445
+ },
446
+ ];
447
+ })
448
+ );
449
+
450
+ const phones = dedupePersonValueOptions(
451
+ (person.contact ?? [])
452
+ .filter((contact) => {
453
+ const code = String(contact.contact_type?.code ?? '').toUpperCase();
454
+ const typeId = Number(
455
+ contact.contact_type?.id ?? contact.contact_type_id ?? 0
456
+ );
457
+
458
+ return (
459
+ PHONE_CONTACT_TYPE_CODES.has(code) ||
460
+ PHONE_CONTACT_TYPE_IDS.has(typeId)
461
+ );
462
+ })
463
+ .flatMap((contact) => {
464
+ const value = String(contact.value ?? '').trim();
465
+ if (!value) {
466
+ return [];
467
+ }
468
+
469
+ const code = String(contact.contact_type?.code ?? 'telefone').trim();
470
+ return [
471
+ {
472
+ id: contact.id,
473
+ value,
474
+ label: `${contact.is_primary ? 'Principal' : formatEnumLabel(code)} • ${value}`,
475
+ normalizedValue: normalizePartyFieldValue('phone', value),
476
+ isPrimary: Boolean(contact.is_primary),
477
+ },
478
+ ];
479
+ })
480
+ );
481
+
482
+ return {
483
+ documents,
484
+ emails,
485
+ phones,
486
+ };
487
+ }
488
+
489
+ function applyPersonDetailsToParty(
490
+ party: PartyState,
491
+ personId: number,
492
+ personName: string,
493
+ person: PersonDetailsRecord
494
+ ): PartyState {
495
+ const { documents, emails, phones } = buildPersonValueOptions(person);
496
+ const nextDocumentValue = getDefaultPersonOptionValue(documents);
497
+ const nextEmailValue = getDefaultPersonOptionValue(emails);
498
+ const nextPhoneValue = getDefaultPersonOptionValue(phones);
499
+
500
+ return {
501
+ ...party,
502
+ displayName: personName || person.name || party.displayName,
503
+ partyType:
504
+ person.type === 'individual' || person.type === 'company'
505
+ ? person.type
506
+ : party.partyType,
507
+ personId,
508
+ availableDocuments: documents,
509
+ availableEmails: emails,
510
+ availablePhones: phones,
511
+ documentNumber: nextDocumentValue || party.documentNumber,
512
+ email: nextEmailValue || party.email,
513
+ phone: nextPhoneValue || party.phone,
514
+ documentMode: documents.length ? 'existing' : 'new',
515
+ emailMode: emails.length ? 'existing' : 'new',
516
+ phoneMode: phones.length ? 'existing' : 'new',
517
+ };
518
+ }
519
+
520
+ function clearPersonDetailsFromParty(
521
+ party: PartyState,
522
+ personName = ''
523
+ ): PartyState {
524
+ return {
525
+ ...party,
526
+ displayName: personName,
527
+ personId: null,
528
+ availableDocuments: [],
529
+ availableEmails: [],
530
+ availablePhones: [],
531
+ documentMode: 'new',
532
+ emailMode: 'new',
533
+ phoneMode: 'new',
534
+ };
535
+ }
536
+
537
+ function getPrimaryPartyIndex(parties: PartyState[]) {
538
+ const primaryIndex = parties.findIndex((party) => party.isPrimary);
539
+ return primaryIndex >= 0 ? primaryIndex : 0;
540
+ }
541
+
542
+ function preservePartySelectionMetadata(
543
+ nextForm: ContractWizardState,
544
+ currentForm: ContractWizardState
545
+ ): ContractWizardState {
546
+ return {
547
+ ...nextForm,
548
+ parties: nextForm.parties.map((party, index) => {
549
+ const currentParty = currentForm.parties[index];
550
+ if (!currentParty?.personId) {
551
+ return party;
552
+ }
553
+
554
+ const availableDocuments = currentParty.availableDocuments ?? [];
555
+ const availableEmails = currentParty.availableEmails ?? [];
556
+ const availablePhones = currentParty.availablePhones ?? [];
557
+
558
+ return {
559
+ ...party,
560
+ personId: currentParty.personId,
561
+ availableDocuments,
562
+ availableEmails,
563
+ availablePhones,
564
+ documentMode: resolvePartyFieldMode(
565
+ 'document',
566
+ party.documentNumber,
567
+ availableDocuments
568
+ ),
569
+ emailMode: resolvePartyFieldMode('email', party.email, availableEmails),
570
+ phoneMode: resolvePartyFieldMode('phone', party.phone, availablePhones),
571
+ };
572
+ }),
573
+ };
574
+ }
575
+
576
+ function PersonDataChoiceField({
577
+ label,
578
+ value,
579
+ options,
580
+ mode,
581
+ kind,
582
+ inputType = 'text',
583
+ placeholder,
584
+ onModeChange,
585
+ onValueChange,
586
+ }: {
587
+ label: string;
588
+ value: string;
589
+ options: PersonValueOption[];
590
+ mode: PartyFieldMode;
591
+ kind: 'document' | 'email' | 'phone';
592
+ inputType?: 'text' | 'email' | 'tel';
593
+ placeholder: string;
594
+ onModeChange: (mode: PartyFieldMode) => void;
595
+ onValueChange: (value: string) => void;
596
+ }) {
597
+ const hasOptions = options.length > 0;
598
+ const normalizedValue = normalizePartyFieldValue(kind, value);
599
+ const matchesExisting = options.some(
600
+ (option) => option.normalizedValue === normalizedValue
601
+ );
602
+ const selectValue =
603
+ hasOptions && mode === 'existing' && matchesExisting
604
+ ? (options.find((option) => option.normalizedValue === normalizedValue)
605
+ ?.value ?? PERSON_FIELD_NEW_VALUE)
606
+ : PERSON_FIELD_NEW_VALUE;
607
+
608
+ return (
609
+ <div className="space-y-1.5">
610
+ <Label className="text-xs">{label}</Label>
611
+
612
+ {hasOptions ? (
613
+ <Select
614
+ value={selectValue}
615
+ onValueChange={(selectedValue) => {
616
+ if (selectedValue === PERSON_FIELD_NEW_VALUE) {
617
+ onModeChange('new');
618
+ if (mode !== 'new' && matchesExisting) {
619
+ onValueChange('');
620
+ }
621
+ return;
622
+ }
623
+
624
+ onModeChange('existing');
625
+ onValueChange(selectedValue);
626
+ }}
627
+ >
628
+ <SelectTrigger className="w-full">
629
+ <SelectValue placeholder={placeholder} />
630
+ </SelectTrigger>
631
+ <SelectContent>
632
+ {options.map((option) => (
633
+ <SelectItem
634
+ key={`${option.label}-${option.normalizedValue}`}
635
+ value={option.value}
636
+ >
637
+ {option.label}
638
+ </SelectItem>
639
+ ))}
640
+ <SelectItem value={PERSON_FIELD_NEW_VALUE}>Digitar novo</SelectItem>
641
+ </SelectContent>
642
+ </Select>
643
+ ) : null}
644
+
645
+ {!hasOptions || mode === 'new' ? (
646
+ <Input
647
+ type={inputType}
648
+ value={value}
649
+ placeholder={placeholder}
650
+ onChange={(event) => onValueChange(event.target.value)}
651
+ />
652
+ ) : null}
653
+
654
+ <p className="text-[11px] text-muted-foreground">
655
+ {hasOptions
656
+ ? mode === 'existing'
657
+ ? 'Usando um dado já cadastrado na pessoa selecionada.'
658
+ : 'O novo valor será salvo também no cadastro da pessoa ao salvar o contrato.'
659
+ : 'Sem dados cadastrados para essa pessoa. O que você digitar será salvo junto ao contrato.'}
660
+ </p>
661
+ </div>
662
+ );
663
+ }
664
+
665
+ function openStoredFile(fileId?: number | null) {
666
+ if (!fileId) return;
667
+ const baseUrl = String(process.env.NEXT_PUBLIC_API_BASE_URL || '');
668
+ window.open(
669
+ `${baseUrl}/file/open/${fileId}`,
670
+ '_blank',
671
+ 'noopener,noreferrer'
672
+ );
673
+ }
674
+
675
+ function toFormState(contract: OperationsContractDetails): ContractWizardState {
676
+ const sourceDocument =
677
+ contract.documents.find(
678
+ (document) =>
679
+ document.isCurrent && document.documentType === 'source_upload'
680
+ ) ?? null;
681
+ const generatedPdfDocument =
682
+ contract.documents.find(
683
+ (document) =>
684
+ document.isCurrent && document.documentType === 'generated_pdf'
685
+ ) ?? null;
686
+
687
+ return {
688
+ code: contract.code ?? '',
689
+ name: contract.name ?? '',
690
+ clientName: contract.clientName ?? '',
691
+ contractTemplateId: contract.contractTemplateId
692
+ ? String(contract.contractTemplateId)
693
+ : 'none',
694
+ contractCategory: contract.contractCategory ?? 'client',
695
+ contractType: contract.contractType ?? 'service_agreement',
696
+ signatureStatus: contract.signatureStatus ?? 'not_started',
697
+ isActive: contract.isActive ?? true,
698
+ billingModel: contract.billingModel ?? 'time_and_material',
699
+ accountManagerCollaboratorId: contract.accountManagerCollaboratorId
700
+ ? String(contract.accountManagerCollaboratorId)
701
+ : 'none',
702
+ relatedCollaboratorId: contract.relatedCollaboratorId
703
+ ? String(contract.relatedCollaboratorId)
704
+ : 'none',
705
+ originType: contract.originType ?? 'manual',
706
+ originId:
707
+ contract.originId !== null && contract.originId !== undefined
708
+ ? String(contract.originId)
709
+ : '',
710
+ startDate: contract.startDate ?? '',
711
+ endDate: contract.endDate ?? '',
712
+ signedAt: contract.signedAt ?? '',
713
+ effectiveDate: contract.effectiveDate ?? '',
714
+ budgetAmount:
715
+ contract.budgetAmount !== null && contract.budgetAmount !== undefined
716
+ ? String(contract.budgetAmount)
717
+ : '',
718
+ monthlyHourCap:
719
+ contract.monthlyHourCap !== null && contract.monthlyHourCap !== undefined
720
+ ? String(contract.monthlyHourCap)
721
+ : '',
722
+ status: contract.status ?? 'draft',
723
+ description: contract.description ?? '',
724
+ contentHtml: contract.contentHtml ?? '',
725
+ creationMode: (contract.creationMode as CreationMode) ?? 'blank',
726
+ wizardStep: contract.wizardStep ?? 0,
727
+ parties: contract.parties.length
728
+ ? contract.parties.map((party) => ({
729
+ displayName: party.displayName ?? '',
730
+ partyRole: party.partyRole ?? 'client',
731
+ partyType: party.partyType ?? 'company',
732
+ documentNumber: party.documentNumber ?? '',
733
+ email: party.email ?? '',
734
+ phone: party.phone ?? '',
735
+ isPrimary: party.isPrimary ?? false,
736
+ personId: null,
737
+ availableDocuments: [],
738
+ availableEmails: [],
739
+ availablePhones: [],
740
+ documentMode: 'new',
741
+ emailMode: 'new',
742
+ phoneMode: 'new',
743
+ }))
744
+ : [emptyParty()],
745
+ signatures: contract.signatures.length
746
+ ? contract.signatures.map((signature) => ({
747
+ signerName: signature.signerName ?? '',
748
+ signerRole: signature.signerRole ?? '',
749
+ signerEmail: signature.signerEmail ?? '',
750
+ status: signature.status ?? 'pending',
751
+ signedAt: signature.signedAt ?? '',
752
+ }))
753
+ : [emptySignature()],
754
+ financialTerms: contract.financialTerms.length
755
+ ? contract.financialTerms.map((term) => ({
756
+ label: term.label ?? '',
757
+ termType: term.termType ?? 'value',
758
+ amount:
759
+ term.amount !== null && term.amount !== undefined
760
+ ? String(term.amount)
761
+ : '',
762
+ recurrence: term.recurrence ?? 'one_time',
763
+ dueDay:
764
+ term.dueDay !== null && term.dueDay !== undefined
765
+ ? String(term.dueDay)
766
+ : '',
767
+ notes: term.notes ?? '',
768
+ }))
769
+ : [emptyFinancialTerm()],
770
+ revisions: contract.revisions.length
771
+ ? contract.revisions.map((revision) => ({
772
+ title: revision.title ?? '',
773
+ revisionType: revision.revisionType ?? 'revision',
774
+ effectiveDate: revision.effectiveDate ?? '',
775
+ status: revision.status ?? 'draft',
776
+ summary: revision.summary ?? '',
777
+ }))
778
+ : [],
779
+ sourceDocument: sourceDocument
780
+ ? {
781
+ id: sourceDocument.id,
782
+ fileId: sourceDocument.fileId ?? null,
783
+ fileName: sourceDocument.fileName,
784
+ mimeType: sourceDocument.mimeType,
785
+ extractionStatus: sourceDocument.extractionStatus ?? null,
786
+ extractionSummary: sourceDocument.extractionSummary ?? null,
787
+ }
788
+ : null,
789
+ generatedPdfDocument: generatedPdfDocument
790
+ ? {
791
+ id: generatedPdfDocument.id,
792
+ fileId: generatedPdfDocument.fileId ?? null,
793
+ fileName: generatedPdfDocument.fileName,
794
+ mimeType: generatedPdfDocument.mimeType,
795
+ extractionStatus: generatedPdfDocument.extractionStatus ?? null,
796
+ extractionSummary: generatedPdfDocument.extractionSummary ?? null,
797
+ }
798
+ : null,
799
+ };
800
+ }
801
+
802
+ function normalizeAiReview(
803
+ review?: Partial<ContractAiReviewState> | null
804
+ ): ContractAiReviewState | null {
805
+ if (!review) {
806
+ return null;
807
+ }
808
+
809
+ return {
810
+ summary: typeof review.summary === 'string' ? review.summary : undefined,
811
+ missingFields: Array.isArray(review.missingFields)
812
+ ? review.missingFields.filter(Boolean).map(String)
813
+ : [],
814
+ warnings: Array.isArray(review.warnings)
815
+ ? review.warnings.filter(Boolean).map(String)
816
+ : [],
817
+ checklist: Array.isArray(review.checklist)
818
+ ? review.checklist.filter(Boolean).map(String)
819
+ : [],
820
+ status: typeof review.status === 'string' ? review.status : undefined,
821
+ reviewedAt:
822
+ typeof review.reviewedAt === 'string' ? review.reviewedAt : undefined,
823
+ };
824
+ }
825
+
826
+ function parseStoredAiReview(
827
+ history?: OperationsContractDetails['history']
828
+ ): ContractAiReviewState | null {
829
+ if (!Array.isArray(history)) {
830
+ return null;
831
+ }
832
+
833
+ for (const item of history) {
834
+ if (item.action !== 'legal_reviewed' || !item.metadataJson) {
835
+ continue;
836
+ }
837
+
838
+ try {
839
+ const parsed = JSON.parse(
840
+ item.metadataJson
841
+ ) as Partial<ContractAiReviewState>;
842
+ return normalizeAiReview({
843
+ ...parsed,
844
+ reviewedAt:
845
+ typeof parsed.reviewedAt === 'string'
846
+ ? parsed.reviewedAt
847
+ : item.createdAt,
848
+ });
849
+ } catch {
850
+ continue;
851
+ }
852
+ }
853
+
854
+ return null;
855
+ }
856
+
857
+ function mergeExtractedDraft(
858
+ current: ContractWizardState,
859
+ draft: ExtractedDraft
860
+ ): ContractWizardState {
861
+ const next = { ...current };
862
+ const assignIfEmpty = (
863
+ key: keyof ContractWizardState,
864
+ value: unknown,
865
+ fallback?: string
866
+ ) => {
867
+ const normalized = toTextValue(value || fallback).trim();
868
+ if (!normalized) return;
869
+ if (!toTextValue(next[key]).trim()) {
870
+ (next[key] as string) = normalized;
871
+ }
872
+ };
873
+
874
+ assignIfEmpty('code', draft.code);
875
+ assignIfEmpty('name', draft.name);
876
+ assignIfEmpty('clientName', draft.clientName);
877
+ assignIfEmpty('startDate', draft.startDate);
878
+ assignIfEmpty('endDate', draft.endDate);
879
+ assignIfEmpty('signedAt', draft.signedAt);
880
+ assignIfEmpty('effectiveDate', draft.effectiveDate);
881
+ assignIfEmpty('budgetAmount', draft.budgetAmount);
882
+ assignIfEmpty('monthlyHourCap', draft.monthlyHourCap);
883
+ assignIfEmpty('description', draft.description, draft.summary);
884
+
885
+ if (!next.contentHtml.trim() && toTextValue(draft.contentHtml).trim()) {
886
+ next.contentHtml = toTextValue(draft.contentHtml).trim();
887
+ }
888
+
889
+ next.contractCategory = draft.contractCategory || next.contractCategory;
890
+ next.contractType = draft.contractType || next.contractType;
891
+ next.signatureStatus = draft.signatureStatus || next.signatureStatus;
892
+ next.billingModel = draft.billingModel || next.billingModel;
893
+ next.status = draft.status || next.status;
894
+ next.originType = draft.originType || next.originType;
895
+ next.isActive = draft.isActive ?? next.isActive;
896
+
897
+ const extractedParties = Array.isArray(draft.parties)
898
+ ? draft.parties
899
+ .map((party) => {
900
+ const value = asRecord(party);
901
+ return {
902
+ displayName: toTextValue(value.displayName),
903
+ partyRole: toTextValue(value.partyRole || 'client'),
904
+ partyType: toTextValue(value.partyType || 'company'),
905
+ documentNumber: toTextValue(value.documentNumber),
906
+ email: toTextValue(value.email),
907
+ phone: toTextValue(value.phone),
908
+ isPrimary: Boolean(value.isPrimary),
909
+ personId: null,
910
+ availableDocuments: [],
911
+ availableEmails: [],
912
+ availablePhones: [],
913
+ documentMode: 'new' as const,
914
+ emailMode: 'new' as const,
915
+ phoneMode: 'new' as const,
916
+ };
917
+ })
918
+ .filter((party) => party.displayName)
919
+ : [];
920
+
921
+ if (
922
+ extractedParties.length &&
923
+ current.parties.every((party) => !party.displayName.trim())
924
+ ) {
925
+ next.parties = extractedParties;
926
+ }
927
+
928
+ const extractedSignatures = Array.isArray(draft.signatures)
929
+ ? draft.signatures
930
+ .map((signature) => {
931
+ const value = asRecord(signature);
932
+ return {
933
+ signerName: toTextValue(value.signerName),
934
+ signerRole: toTextValue(value.signerRole),
935
+ signerEmail: toTextValue(value.signerEmail),
936
+ status: toTextValue(value.status || 'pending'),
937
+ signedAt: toTextValue(value.signedAt),
938
+ };
939
+ })
940
+ .filter((signature) => signature.signerName)
941
+ : [];
942
+
943
+ if (
944
+ extractedSignatures.length &&
945
+ current.signatures.every((signature) => !signature.signerName.trim())
946
+ ) {
947
+ next.signatures = extractedSignatures;
948
+ }
949
+
950
+ const extractedTerms = Array.isArray(draft.financialTerms)
951
+ ? draft.financialTerms
952
+ .map((term) => {
953
+ const value = asRecord(term);
954
+ return {
955
+ label: toTextValue(value.label),
956
+ termType: toTextValue(value.termType || 'value'),
957
+ amount: toTextValue(value.amount),
958
+ recurrence: toTextValue(value.recurrence || 'one_time'),
959
+ dueDay: toTextValue(value.dueDay),
960
+ notes: toTextValue(value.notes),
961
+ };
962
+ })
963
+ .filter((term) => term.label)
964
+ : [];
965
+
966
+ if (
967
+ extractedTerms.length &&
968
+ current.financialTerms.every((term) => !term.label.trim())
969
+ ) {
970
+ next.financialTerms = extractedTerms;
971
+ }
972
+
973
+ return next;
974
+ }
975
+
976
+ export function ContractWizardSheet({
977
+ contractId,
978
+ duplicateFromId,
979
+ initialTemplateId,
980
+ isCreateFlow = false,
981
+ onCancel,
982
+ onSaved,
983
+ }: ContractWizardSheetProps) {
984
+ const t = useTranslations('operations.ContractFormPage');
985
+ const { request, showToastHandler, currentLocaleCode, getSettingValue } =
986
+ useApp();
987
+ const wizardSteps = isCreateFlow ? createFlowSteps : allSteps;
988
+ const contentStepIndex = wizardSteps.findIndex(
989
+ (step) => step.key === 'content'
990
+ );
991
+ const reviewStepIndex = wizardSteps.findIndex(
992
+ (step) => step.key === 'review'
993
+ );
994
+ const defaultEditStep =
995
+ contentStepIndex >= 0 ? contentStepIndex : reviewStepIndex;
996
+ const [form, setForm] = useState<ContractWizardState>(buildEmptyForm());
997
+ const [draftId, setDraftId] = useState<number | null>(contractId ?? null);
998
+ const [activeStep, setActiveStep] = useState(
999
+ contractId ? defaultEditStep : 0
1000
+ );
1001
+ const [creationMode, setCreationMode] = useState<CreationMode>(
1002
+ duplicateFromId ? 'duplicate' : initialTemplateId ? 'template' : 'blank'
1003
+ );
1004
+ const [selectedTemplateId, setSelectedTemplateId] = useState<number | null>(
1005
+ initialTemplateId ?? null
1006
+ );
1007
+ const [selectedDuplicateId, setSelectedDuplicateId] = useState<number | null>(
1008
+ duplicateFromId ?? null
1009
+ );
1010
+ const [sourceFile, setSourceFile] = useState<File | null>(null);
1011
+ const [aiStartPrompt, setAiStartPrompt] = useState('');
1012
+ const [isBusy, setIsBusy] = useState(false);
1013
+ const [isExtracting, setIsExtracting] = useState(false);
1014
+ const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
1015
+ const [aiReview, setAiReview] = useState<ContractAiReviewState | null>(null);
1016
+ const autoStartedRef = useRef(false);
1017
+
1018
+ const { data: contractTemplates = [] } = useQuery<
1019
+ OperationsContractTemplate[]
1020
+ >({
1021
+ queryKey: ['operations-contract-wizard-templates', currentLocaleCode],
1022
+ queryFn: () =>
1023
+ fetchOperations<OperationsContractTemplate[]>(
1024
+ request,
1025
+ '/operations/contract-templates'
1026
+ ),
1027
+ placeholderData: (old) => old ?? [],
1028
+ });
1029
+
1030
+ const { data: collaborators = [] } = useQuery<OperationsCollaborator[]>({
1031
+ queryKey: ['operations-contract-wizard-collaborators', currentLocaleCode],
1032
+ queryFn: () =>
1033
+ fetchOperations<OperationsCollaborator[]>(
1034
+ request,
1035
+ '/operations/collaborators'
1036
+ ),
1037
+ placeholderData: (old) => old ?? [],
1038
+ });
1039
+
1040
+ const { data: contracts = [] } = useQuery<OperationsContract[]>({
1041
+ queryKey: ['operations-contract-wizard-contracts', currentLocaleCode],
1042
+ queryFn: () =>
1043
+ fetchOperations<OperationsContract[]>(request, '/operations/contracts'),
1044
+ placeholderData: (old) => old ?? [],
1045
+ });
1046
+
1047
+ const { data: initialContract } = useQuery<OperationsContractDetails>({
1048
+ queryKey: [
1049
+ 'operations-contract-wizard-detail',
1050
+ currentLocaleCode,
1051
+ contractId,
1052
+ ],
1053
+ enabled: Boolean(contractId),
1054
+ queryFn: () =>
1055
+ fetchOperations<OperationsContractDetails>(
1056
+ request,
1057
+ `/operations/contracts/${contractId}`
1058
+ ),
1059
+ });
1060
+
1061
+ const { data: personContactTypes = [] } = useQuery<ContactTypeLookup[]>({
1062
+ queryKey: ['operations-contract-person-contact-types', currentLocaleCode],
1063
+ queryFn: async () => {
1064
+ const response = await request<{ data?: ContactTypeLookup[] }>({
1065
+ url: '/person-contact-type?pageSize=100',
1066
+ method: 'GET',
1067
+ });
1068
+
1069
+ return Array.isArray(response?.data?.data) ? response.data.data : [];
1070
+ },
1071
+ placeholderData: (old) => old ?? [],
1072
+ });
1073
+
1074
+ const { data: personDocumentTypes = [] } = useQuery<DocumentTypeLookup[]>({
1075
+ queryKey: ['operations-contract-person-document-types', currentLocaleCode],
1076
+ queryFn: async () => {
1077
+ const response = await request<{ data?: DocumentTypeLookup[] }>({
1078
+ url: '/person-document-type?pageSize=100',
1079
+ method: 'GET',
1080
+ });
1081
+
1082
+ return Array.isArray(response?.data?.data) ? response.data.data : [];
1083
+ },
1084
+ placeholderData: (old) => old ?? [],
1085
+ });
1086
+
1087
+ useEffect(() => {
1088
+ if (!initialContract) return;
1089
+ setDraftId(initialContract.id);
1090
+ setCreationMode((initialContract.creationMode as CreationMode) ?? 'blank');
1091
+ setForm((current) =>
1092
+ preservePartySelectionMetadata(toFormState(initialContract), current)
1093
+ );
1094
+ setAiReview(parseStoredAiReview(initialContract.history));
1095
+ setActiveStep(defaultEditStep);
1096
+ }, [defaultEditStep, initialContract]);
1097
+
1098
+ const hasOpenAi = Boolean(getSettingValue('ai-openai-api-key-enabled'));
1099
+ const hasGemini = Boolean(getSettingValue('ai-gemini-api-key-enabled'));
1100
+ const aiProvider = hasOpenAi ? 'openai' : hasGemini ? 'gemini' : null;
1101
+ const aiProviderLabel = aiProvider === 'openai' ? 'OpenAI' : 'Gemini';
1102
+
1103
+ useEffect(() => {
1104
+ if (!aiProvider && creationMode === 'ai_prompt') {
1105
+ setCreationMode('blank');
1106
+ }
1107
+ }, [aiProvider, creationMode]);
1108
+
1109
+ const getOptionLabel = (group: string, value?: string | null) => {
1110
+ if (!value) {
1111
+ return '-';
1112
+ }
1113
+
1114
+ const key = `options.${group}.${value}`;
1115
+ return t.has(key) ? t(key) : formatEnumLabel(value);
1116
+ };
1117
+
1118
+ const selectedTemplate =
1119
+ contractTemplates.find((item) => item.id === selectedTemplateId) ?? null;
1120
+ const primaryPartyIndex = getPrimaryPartyIndex(form.parties);
1121
+ const primaryParty = form.parties[primaryPartyIndex] ?? null;
1122
+
1123
+ const updatePartyAt = (
1124
+ index: number,
1125
+ updater: (party: PartyState) => PartyState,
1126
+ options?: { syncClientName?: boolean }
1127
+ ) => {
1128
+ setForm((current) => {
1129
+ const nextParties = current.parties.map((item, itemIndex) =>
1130
+ itemIndex === index ? updater(item) : item
1131
+ );
1132
+ const nextPrimaryParty = nextParties[getPrimaryPartyIndex(nextParties)];
1133
+
1134
+ return {
1135
+ ...current,
1136
+ clientName:
1137
+ options?.syncClientName && nextPrimaryParty?.displayName
1138
+ ? nextPrimaryParty.displayName
1139
+ : current.clientName,
1140
+ parties: nextParties,
1141
+ };
1142
+ });
1143
+ };
1144
+
1145
+ const handlePartyPersonChange = async (
1146
+ index: number,
1147
+ personId: number | null,
1148
+ personName: string,
1149
+ options?: { syncClientName?: boolean; markAsPrimary?: boolean }
1150
+ ) => {
1151
+ const syncClientName = Boolean(options?.syncClientName);
1152
+ const markAsPrimary = Boolean(options?.markAsPrimary);
1153
+
1154
+ if (!personId) {
1155
+ setForm((current) => ({
1156
+ ...current,
1157
+ clientName: syncClientName ? personName : current.clientName,
1158
+ parties: current.parties.map((item, itemIndex) => {
1159
+ if (itemIndex !== index) {
1160
+ return markAsPrimary ? { ...item, isPrimary: false } : item;
1161
+ }
1162
+
1163
+ return {
1164
+ ...clearPersonDetailsFromParty(item, personName),
1165
+ isPrimary: markAsPrimary ? true : item.isPrimary,
1166
+ };
1167
+ }),
1168
+ }));
1169
+ return;
1170
+ }
1171
+
1172
+ try {
1173
+ const person = await fetchOperations<PersonDetailsRecord>(
1174
+ request,
1175
+ `/person/${personId}`
1176
+ );
1177
+
1178
+ setForm((current) => ({
1179
+ ...current,
1180
+ clientName: syncClientName
1181
+ ? personName || person.name || current.clientName
1182
+ : current.clientName,
1183
+ parties: current.parties.map((item, itemIndex) => {
1184
+ if (itemIndex !== index) {
1185
+ return markAsPrimary ? { ...item, isPrimary: false } : item;
1186
+ }
1187
+
1188
+ return {
1189
+ ...applyPersonDetailsToParty(item, personId, personName, person),
1190
+ isPrimary: markAsPrimary ? true : item.isPrimary,
1191
+ };
1192
+ }),
1193
+ }));
1194
+ } catch {
1195
+ setForm((current) => ({
1196
+ ...current,
1197
+ clientName: syncClientName ? personName : current.clientName,
1198
+ parties: current.parties.map((item, itemIndex) => {
1199
+ if (itemIndex !== index) {
1200
+ return markAsPrimary ? { ...item, isPrimary: false } : item;
1201
+ }
1202
+
1203
+ return {
1204
+ ...item,
1205
+ displayName: personName,
1206
+ personId,
1207
+ availableDocuments: [],
1208
+ availableEmails: [],
1209
+ availablePhones: [],
1210
+ documentMode: 'new',
1211
+ emailMode: 'new',
1212
+ phoneMode: 'new',
1213
+ isPrimary: markAsPrimary ? true : item.isPrimary,
1214
+ };
1215
+ }),
1216
+ }));
1217
+ showToastHandler?.(
1218
+ 'error',
1219
+ 'Nao foi possivel carregar os documentos, e-mails e telefones da pessoa selecionada.'
1220
+ );
1221
+ }
1222
+ };
1223
+
1224
+ const syncSelectedPeopleDetails = async () => {
1225
+ const pendingByPerson = new Map<
1226
+ number,
1227
+ {
1228
+ documents: Set<string>;
1229
+ emails: Set<string>;
1230
+ phones: Set<string>;
1231
+ }
1232
+ >();
1233
+
1234
+ for (const party of form.parties) {
1235
+ if (!party.personId) {
1236
+ continue;
1237
+ }
1238
+
1239
+ const nextDocument = trimToNull(party.documentNumber);
1240
+ const nextEmail = trimToNull(party.email);
1241
+ const nextPhone = trimToNull(party.phone);
1242
+ const hasPendingValues =
1243
+ (party.documentMode === 'new' && nextDocument) ||
1244
+ (party.emailMode === 'new' && nextEmail) ||
1245
+ (party.phoneMode === 'new' && nextPhone);
1246
+
1247
+ if (!hasPendingValues) {
1248
+ continue;
1249
+ }
1250
+
1251
+ const bucket = pendingByPerson.get(party.personId) ?? {
1252
+ documents: new Set<string>(),
1253
+ emails: new Set<string>(),
1254
+ phones: new Set<string>(),
1255
+ };
1256
+
1257
+ if (party.documentMode === 'new' && nextDocument) {
1258
+ bucket.documents.add(nextDocument);
1259
+ }
1260
+
1261
+ if (party.emailMode === 'new' && nextEmail) {
1262
+ bucket.emails.add(nextEmail);
1263
+ }
1264
+
1265
+ if (party.phoneMode === 'new' && nextPhone) {
1266
+ bucket.phones.add(nextPhone);
1267
+ }
1268
+
1269
+ pendingByPerson.set(party.personId, bucket);
1270
+ }
1271
+
1272
+ if (!pendingByPerson.size) {
1273
+ return;
1274
+ }
1275
+
1276
+ const resolveContactTypeId = (
1277
+ preferredCodes: string[],
1278
+ fallbackId: number
1279
+ ) => {
1280
+ for (const preferredCode of preferredCodes) {
1281
+ const found = personContactTypes.find(
1282
+ (item) => String(item.code).toUpperCase() === preferredCode
1283
+ );
1284
+
1285
+ if (found?.contact_type_id) {
1286
+ return found.contact_type_id;
1287
+ }
1288
+ }
1289
+
1290
+ return fallbackId;
1291
+ };
1292
+
1293
+ const emailTypeId = resolveContactTypeId(['EMAIL'], EMAIL_CONTACT_TYPE_ID);
1294
+ const phoneTypeId = resolveContactTypeId(
1295
+ ['PHONE', 'WHATSAPP', 'TELEGRAM', 'FAX', 'MOBILE'],
1296
+ 1
1297
+ );
1298
+
1299
+ for (const [personId, pending] of pendingByPerson.entries()) {
1300
+ const person = await fetchOperations<PersonDetailsRecord>(
1301
+ request,
1302
+ `/person/${personId}`
1303
+ );
1304
+
1305
+ const contacts: Array<{
1306
+ id?: number;
1307
+ value: string;
1308
+ is_primary: boolean;
1309
+ contact_type_id: number;
1310
+ }> = (person.contact ?? [])
1311
+ .map((contact) => ({
1312
+ id: contact.id,
1313
+ value: String(contact.value ?? '').trim(),
1314
+ is_primary: Boolean(contact.is_primary),
1315
+ contact_type_id: Number(
1316
+ contact.contact_type_id ?? contact.contact_type?.id ?? 0
1317
+ ),
1318
+ }))
1319
+ .filter((contact) => contact.value && contact.contact_type_id > 0);
1320
+
1321
+ const documents: Array<{
1322
+ id?: number;
1323
+ value: string;
1324
+ document_type_id: number;
1325
+ }> = (person.document ?? [])
1326
+ .map((document) => ({
1327
+ id: document.id,
1328
+ value: String(document.value ?? '').trim(),
1329
+ document_type_id: Number(
1330
+ document.document_type_id ?? document.document_type?.id ?? 0
1331
+ ),
1332
+ }))
1333
+ .filter((document) => document.value && document.document_type_id > 0);
1334
+
1335
+ const addresses = (person.address ?? []).map((address) => ({
1336
+ id: address.id,
1337
+ line1: String(address.line1 ?? '').trim(),
1338
+ line2: trimToNull(address.line2 ?? null),
1339
+ city: String(address.city ?? '').trim(),
1340
+ state: String(address.state ?? '').trim(),
1341
+ country_code: trimToNull(address.country_code ?? null),
1342
+ postal_code: trimToNull(address.postal_code ?? null),
1343
+ is_primary: Boolean(address.is_primary),
1344
+ address_type: String(address.address_type ?? 'residential'),
1345
+ }));
1346
+
1347
+ const existingEmails = new Set(
1348
+ contacts
1349
+ .filter((contact) => contact.contact_type_id === emailTypeId)
1350
+ .map((contact) => normalizePartyFieldValue('email', contact.value))
1351
+ );
1352
+ const existingPhones = new Set(
1353
+ contacts
1354
+ .filter((contact) => contact.contact_type_id !== emailTypeId)
1355
+ .map((contact) => normalizePartyFieldValue('phone', contact.value))
1356
+ );
1357
+ const existingDocuments = new Set(
1358
+ documents.map((document) =>
1359
+ normalizePartyFieldValue('document', document.value)
1360
+ )
1361
+ );
1362
+
1363
+ let hasChanges = false;
1364
+
1365
+ for (const email of pending.emails) {
1366
+ const normalizedEmail = normalizePartyFieldValue('email', email);
1367
+ if (!normalizedEmail || existingEmails.has(normalizedEmail)) {
1368
+ continue;
1369
+ }
1370
+
1371
+ contacts.push({
1372
+ value: email.trim(),
1373
+ is_primary: !contacts.some(
1374
+ (contact) =>
1375
+ contact.contact_type_id === emailTypeId && contact.is_primary
1376
+ ),
1377
+ contact_type_id: emailTypeId,
1378
+ });
1379
+ existingEmails.add(normalizedEmail);
1380
+ hasChanges = true;
1381
+ }
1382
+
1383
+ for (const phone of pending.phones) {
1384
+ const normalizedPhone = normalizePartyFieldValue('phone', phone);
1385
+ if (!normalizedPhone || existingPhones.has(normalizedPhone)) {
1386
+ continue;
1387
+ }
1388
+
1389
+ contacts.push({
1390
+ value: phone.trim(),
1391
+ is_primary: !contacts.some(
1392
+ (contact) =>
1393
+ contact.contact_type_id === phoneTypeId && contact.is_primary
1394
+ ),
1395
+ contact_type_id: phoneTypeId,
1396
+ });
1397
+ existingPhones.add(normalizedPhone);
1398
+ hasChanges = true;
1399
+ }
1400
+
1401
+ if (pending.documents.size) {
1402
+ const defaultDocumentTypeId =
1403
+ documents[0]?.document_type_id ||
1404
+ personDocumentTypes.find(
1405
+ (item) =>
1406
+ String(item.code).toUpperCase() ===
1407
+ (person.type === 'company' ? 'CNPJ' : 'CPF')
1408
+ )?.document_type_id ||
1409
+ personDocumentTypes[0]?.document_type_id;
1410
+
1411
+ if (!defaultDocumentTypeId) {
1412
+ throw new Error('Missing person document type lookup');
1413
+ }
1414
+
1415
+ for (const documentValue of pending.documents) {
1416
+ const normalizedDocument = normalizePartyFieldValue(
1417
+ 'document',
1418
+ documentValue
1419
+ );
1420
+ if (
1421
+ !normalizedDocument ||
1422
+ existingDocuments.has(normalizedDocument)
1423
+ ) {
1424
+ continue;
1425
+ }
1426
+
1427
+ documents.push({
1428
+ value: documentValue.trim(),
1429
+ document_type_id: defaultDocumentTypeId,
1430
+ });
1431
+ existingDocuments.add(normalizedDocument);
1432
+ hasChanges = true;
1433
+ }
1434
+ }
1435
+
1436
+ if (!hasChanges) {
1437
+ continue;
1438
+ }
1439
+
1440
+ await request({
1441
+ url: `/person/${personId}`,
1442
+ method: 'PATCH',
1443
+ data: {
1444
+ name: person.name,
1445
+ type: person.type ?? 'individual',
1446
+ status: person.status ?? 'active',
1447
+ contacts,
1448
+ documents,
1449
+ addresses,
1450
+ },
1451
+ });
1452
+ }
1453
+ };
1454
+
1455
+ const reloadContract = useCallback(
1456
+ async (nextId: number) => {
1457
+ const detail = await fetchOperations<OperationsContractDetails>(
1458
+ request,
1459
+ `/operations/contracts/${nextId}`
1460
+ );
1461
+ setDraftId(detail.id);
1462
+ setCreationMode(
1463
+ creationMode === 'ai_prompt'
1464
+ ? 'ai_prompt'
1465
+ : ((detail.creationMode as CreationMode) ?? creationMode)
1466
+ );
1467
+ setForm((current) =>
1468
+ preservePartySelectionMetadata(toFormState(detail), current)
1469
+ );
1470
+ setAiReview(parseStoredAiReview(detail.history));
1471
+ return detail;
1472
+ },
1473
+ [creationMode, request]
1474
+ );
1475
+
1476
+ const uploadSourceDocument = useCallback(
1477
+ async (file: File) => {
1478
+ const formData = new FormData();
1479
+ formData.append('file', file);
1480
+ formData.append('destination', 'operations/contracts/source');
1481
+
1482
+ const response = await request<{ id?: number }>({
1483
+ url: '/file',
1484
+ method: 'POST',
1485
+ data: formData,
1486
+ headers: {
1487
+ 'Content-Type': 'multipart/form-data',
1488
+ },
1489
+ } as never);
1490
+
1491
+ const nextFileId = Number(response?.data?.id);
1492
+ if (!nextFileId) {
1493
+ throw new Error('Nao foi possivel enviar o arquivo.');
1494
+ }
1495
+
1496
+ return {
1497
+ fileId: nextFileId,
1498
+ fileName: file.name,
1499
+ mimeType: file.type || 'application/pdf',
1500
+ };
1501
+ },
1502
+ [request]
1503
+ );
1504
+
1505
+ const startDraft = useCallback(async () => {
1506
+ if (draftId) return draftId;
1507
+
1508
+ setIsBusy(true);
1509
+ try {
1510
+ let sourcePayload:
1511
+ | { fileId: number; fileName: string; mimeType: string }
1512
+ | undefined;
1513
+
1514
+ if (creationMode === 'upload') {
1515
+ if (!sourceFile) {
1516
+ showToastHandler?.(
1517
+ 'error',
1518
+ 'Selecione um PDF ou Word para continuar.'
1519
+ );
1520
+ return null;
1521
+ }
1522
+ sourcePayload = await uploadSourceDocument(sourceFile);
1523
+ }
1524
+
1525
+ if (creationMode === 'ai_prompt' && !aiStartPrompt.trim()) {
1526
+ showToastHandler?.('error', 'Descreva o contrato para continuar.');
1527
+ return null;
1528
+ }
1529
+
1530
+ if (creationMode === 'template' && !selectedTemplateId) {
1531
+ showToastHandler?.('error', 'Selecione um template para continuar.');
1532
+ return null;
1533
+ }
1534
+
1535
+ if (creationMode === 'duplicate' && !selectedDuplicateId) {
1536
+ showToastHandler?.('error', 'Selecione um contrato para duplicar.');
1537
+ return null;
1538
+ }
1539
+
1540
+ const response = await mutateOperations<OperationsContractDetails>(
1541
+ request,
1542
+ '/operations/contracts/drafts',
1543
+ 'POST',
1544
+ {
1545
+ creationMode: creationMode === 'ai_prompt' ? 'blank' : creationMode,
1546
+ templateId: selectedTemplateId ?? null,
1547
+ duplicateFromId: selectedDuplicateId ?? null,
1548
+ sourceFileId: sourcePayload?.fileId ?? null,
1549
+ sourceFileName: sourcePayload?.fileName ?? null,
1550
+ sourceMimeType: sourcePayload?.mimeType ?? null,
1551
+ }
1552
+ );
1553
+
1554
+ setDraftId(response.id);
1555
+ setCreationMode(
1556
+ creationMode === 'ai_prompt'
1557
+ ? 'ai_prompt'
1558
+ : ((response.creationMode as CreationMode) ?? creationMode)
1559
+ );
1560
+ setForm((current) =>
1561
+ preservePartySelectionMetadata(toFormState(response), current)
1562
+ );
1563
+ showToastHandler?.('success', 'Rascunho criado com sucesso.');
1564
+ return response.id;
1565
+ } catch {
1566
+ showToastHandler?.('error', 'Nao foi possivel criar o rascunho.');
1567
+ return null;
1568
+ } finally {
1569
+ setIsBusy(false);
1570
+ }
1571
+ }, [
1572
+ aiStartPrompt,
1573
+ creationMode,
1574
+ draftId,
1575
+ request,
1576
+ selectedDuplicateId,
1577
+ selectedTemplateId,
1578
+ showToastHandler,
1579
+ sourceFile,
1580
+ uploadSourceDocument,
1581
+ ]);
1582
+
1583
+ useEffect(() => {
1584
+ if (autoStartedRef.current || contractId) return;
1585
+
1586
+ if (duplicateFromId || initialTemplateId) {
1587
+ autoStartedRef.current = true;
1588
+ void startDraft();
1589
+ }
1590
+ }, [contractId, duplicateFromId, initialTemplateId, startDraft]);
1591
+
1592
+ const buildPayload = (step = activeStep) => ({
1593
+ code: trimToNull(form.code)?.toUpperCase() ?? null,
1594
+ name: trimToNull(form.name),
1595
+ clientName: trimToNull(form.clientName),
1596
+ contractCategory: form.contractCategory,
1597
+ contractType: form.contractType,
1598
+ signatureStatus: form.signatureStatus,
1599
+ isActive: form.isActive,
1600
+ billingModel: form.billingModel,
1601
+ accountManagerCollaboratorId:
1602
+ form.accountManagerCollaboratorId === 'none'
1603
+ ? null
1604
+ : parseNumberInput(form.accountManagerCollaboratorId),
1605
+ relatedCollaboratorId:
1606
+ form.relatedCollaboratorId === 'none'
1607
+ ? null
1608
+ : parseNumberInput(form.relatedCollaboratorId),
1609
+ contractTemplateId:
1610
+ form.contractTemplateId === 'none'
1611
+ ? null
1612
+ : parseNumberInput(form.contractTemplateId),
1613
+ originType: form.originType,
1614
+ originId: parseNumberInput(form.originId),
1615
+ startDate: trimToNull(form.startDate),
1616
+ endDate: trimToNull(form.endDate),
1617
+ signedAt: trimToNull(form.signedAt),
1618
+ effectiveDate: trimToNull(form.effectiveDate),
1619
+ budgetAmount: parseNumberInput(form.budgetAmount),
1620
+ monthlyHourCap: parseNumberInput(form.monthlyHourCap),
1621
+ status: form.status,
1622
+ creationMode: creationMode === 'ai_prompt' ? 'blank' : creationMode,
1623
+ wizardStep: step,
1624
+ description: trimToNull(form.description),
1625
+ contentHtml: trimToNull(form.contentHtml),
1626
+ parties: form.parties
1627
+ .filter((party) => party.displayName.trim())
1628
+ .map((party) => ({
1629
+ displayName: party.displayName.trim(),
1630
+ partyRole: party.partyRole,
1631
+ partyType: party.partyType,
1632
+ documentNumber: trimToNull(party.documentNumber),
1633
+ email: trimToNull(party.email),
1634
+ phone: trimToNull(party.phone),
1635
+ isPrimary: party.isPrimary,
1636
+ })),
1637
+ signatures: form.signatures
1638
+ .filter((signature) => signature.signerName.trim())
1639
+ .map((signature) => ({
1640
+ signerName: signature.signerName.trim(),
1641
+ signerRole: trimToNull(signature.signerRole),
1642
+ signerEmail: trimToNull(signature.signerEmail),
1643
+ status: signature.status,
1644
+ signedAt: trimToNull(signature.signedAt),
1645
+ })),
1646
+ financialTerms: form.financialTerms
1647
+ .filter((term) => term.label.trim())
1648
+ .map((term) => ({
1649
+ label: term.label.trim(),
1650
+ termType: term.termType,
1651
+ amount: parseNumberInput(term.amount) ?? 0,
1652
+ recurrence: term.recurrence,
1653
+ dueDay: parseNumberInput(term.dueDay),
1654
+ notes: trimToNull(term.notes),
1655
+ })),
1656
+ revisions: form.revisions
1657
+ .filter((revision) => revision.title.trim())
1658
+ .map((revision) => ({
1659
+ title: revision.title.trim(),
1660
+ revisionType: revision.revisionType,
1661
+ effectiveDate: trimToNull(revision.effectiveDate),
1662
+ status: revision.status,
1663
+ summary: trimToNull(revision.summary),
1664
+ })),
1665
+ });
1666
+
1667
+ const saveDraft = async (step = activeStep) => {
1668
+ const nextDraftId = draftId ?? (await startDraft());
1669
+ if (!nextDraftId) return null;
1670
+
1671
+ setIsBusy(true);
1672
+ try {
1673
+ await syncSelectedPeopleDetails();
1674
+
1675
+ const response = await mutateOperations<OperationsContractDetails>(
1676
+ request,
1677
+ `/operations/contracts/${nextDraftId}`,
1678
+ 'PATCH',
1679
+ buildPayload(step)
1680
+ );
1681
+ setDraftId(response.id);
1682
+ setCreationMode(
1683
+ creationMode === 'ai_prompt'
1684
+ ? 'ai_prompt'
1685
+ : ((response.creationMode as CreationMode) ?? creationMode)
1686
+ );
1687
+ setForm((current) =>
1688
+ preservePartySelectionMetadata(toFormState(response), current)
1689
+ );
1690
+ showToastHandler?.('success', 'Rascunho salvo.');
1691
+ return response;
1692
+ } catch {
1693
+ showToastHandler?.(
1694
+ 'error',
1695
+ 'Nao foi possivel salvar o rascunho ou sincronizar os dados da pessoa selecionada.'
1696
+ );
1697
+ return null;
1698
+ } finally {
1699
+ setIsBusy(false);
1700
+ }
1701
+ };
1702
+
1703
+ const handleExtractSource = async () => {
1704
+ const nextDraftId = draftId ?? (await startDraft());
1705
+ if (!nextDraftId) return;
1706
+ if (!aiProvider) {
1707
+ showToastHandler?.(
1708
+ 'error',
1709
+ 'Configure OpenAI ou Gemini para extrair o contrato.'
1710
+ );
1711
+ return;
1712
+ }
1713
+
1714
+ setIsExtracting(true);
1715
+ try {
1716
+ const extracted = await mutateOperations<ExtractedDraft>(
1717
+ request,
1718
+ `/operations/contracts/${nextDraftId}/extract-source`,
1719
+ 'POST',
1720
+ {
1721
+ provider: aiProvider,
1722
+ promptMessage:
1723
+ 'Analyze the attached contract and extract a structured draft for the contract form.',
1724
+ }
1725
+ );
1726
+ setAiReview(
1727
+ normalizeAiReview({
1728
+ summary: extracted.summary,
1729
+ missingFields: Array.isArray(extracted.missingFields)
1730
+ ? extracted.missingFields.filter(Boolean)
1731
+ : [],
1732
+ warnings: Array.isArray(extracted.warnings)
1733
+ ? extracted.warnings.filter(Boolean)
1734
+ : [],
1735
+ checklist: [],
1736
+ })
1737
+ );
1738
+ setForm((current) => {
1739
+ const merged = mergeExtractedDraft(current, extracted);
1740
+ return {
1741
+ ...merged,
1742
+ sourceDocument: merged.sourceDocument
1743
+ ? {
1744
+ ...merged.sourceDocument,
1745
+ extractionStatus: 'completed',
1746
+ extractionSummary:
1747
+ extracted.summary ||
1748
+ extracted.description ||
1749
+ extracted.name ||
1750
+ null,
1751
+ }
1752
+ : merged.sourceDocument,
1753
+ };
1754
+ });
1755
+ showToastHandler?.(
1756
+ 'success',
1757
+ `Dados extraidos com ${aiProviderLabel}. Revise antes de concluir.`
1758
+ );
1759
+ } catch {
1760
+ showToastHandler?.(
1761
+ 'error',
1762
+ 'Nao foi possivel extrair os dados do documento.'
1763
+ );
1764
+ } finally {
1765
+ setIsExtracting(false);
1766
+ }
1767
+ };
1768
+
1769
+ const handleNext = async () => {
1770
+ if (activeStep === 0 && !draftId) {
1771
+ if (creationMode === 'ai_prompt' || creationMode === 'duplicate') {
1772
+ setActiveStep(1);
1773
+ return;
1774
+ }
1775
+
1776
+ const nextDraftId = await startDraft();
1777
+ if (nextDraftId && creationMode !== 'upload') {
1778
+ setActiveStep(1);
1779
+ }
1780
+ return;
1781
+ }
1782
+
1783
+ if (activeStep === 1 && !draftId) {
1784
+ if (creationMode === 'ai_prompt' && aiStartPrompt.trim()) {
1785
+ setForm((current) => ({
1786
+ ...current,
1787
+ description: current.description || aiStartPrompt.trim(),
1788
+ }));
1789
+ }
1790
+
1791
+ if (creationMode === 'duplicate' || creationMode === 'ai_prompt') {
1792
+ const nextDraftId = await startDraft();
1793
+ if (!nextDraftId) return;
1794
+
1795
+ setDraftId(nextDraftId);
1796
+ return;
1797
+ }
1798
+ }
1799
+
1800
+ const response = await saveDraft(
1801
+ Math.min(activeStep + 1, wizardSteps.length - 1)
1802
+ );
1803
+ if (response) {
1804
+ setActiveStep(Math.min(activeStep + 1, wizardSteps.length - 1));
1805
+ }
1806
+ };
1807
+
1808
+ const handleGeneratePdf = async () => {
1809
+ const nextDraftId = draftId ?? (await startDraft());
1810
+ if (!nextDraftId) return;
1811
+
1812
+ const saved = await saveDraft(activeStep);
1813
+ if (!saved) return;
1814
+
1815
+ setIsGeneratingPdf(true);
1816
+ try {
1817
+ const response = await mutateOperations<{ fileId?: number }>(
1818
+ request,
1819
+ `/operations/contracts/${nextDraftId}/generate-pdf`,
1820
+ 'POST'
1821
+ );
1822
+ await reloadContract(nextDraftId);
1823
+ showToastHandler?.('success', 'PDF gerado com sucesso.');
1824
+ if (response.fileId) {
1825
+ openStoredFile(response.fileId);
1826
+ }
1827
+ } catch {
1828
+ showToastHandler?.(
1829
+ 'error',
1830
+ 'Nao foi possivel gerar o PDF. Verifique a configuracao do servidor.'
1831
+ );
1832
+ } finally {
1833
+ setIsGeneratingPdf(false);
1834
+ }
1835
+ };
1836
+
1837
+ const handleRunLegalReview = useCallback(
1838
+ async (targetContractId?: number | null) => {
1839
+ if (!targetContractId) {
1840
+ return null;
1841
+ }
1842
+
1843
+ setIsBusy(true);
1844
+ try {
1845
+ const review = await mutateOperations<ContractAiReviewState>(
1846
+ request,
1847
+ `/operations/contracts/${targetContractId}/legal-review`,
1848
+ 'POST',
1849
+ {
1850
+ provider: aiProvider ?? undefined,
1851
+ promptMessage:
1852
+ 'Review this contract draft and produce an advisory legal checklist for revision.',
1853
+ }
1854
+ );
1855
+
1856
+ const normalized = normalizeAiReview(review);
1857
+ setAiReview(normalized);
1858
+ showToastHandler?.(
1859
+ 'success',
1860
+ 'Checklist juridico orientativo atualizado.'
1861
+ );
1862
+ return normalized;
1863
+ } catch {
1864
+ showToastHandler?.(
1865
+ 'error',
1866
+ 'Nao foi possivel atualizar a revisao juridica orientativa.'
1867
+ );
1868
+ return null;
1869
+ } finally {
1870
+ setIsBusy(false);
1871
+ }
1872
+ },
1873
+ [aiProvider, request, showToastHandler]
1874
+ );
1875
+
1876
+ const runPostCreateAutomation = useCallback(
1877
+ async (contract: OperationsContractDetails) => {
1878
+ let latestContract = contract;
1879
+ let generationSucceeded = false;
1880
+ let reviewSucceeded = false;
1881
+
1882
+ setIsBusy(true);
1883
+ try {
1884
+ latestContract = await mutateOperations<OperationsContractDetails>(
1885
+ request,
1886
+ `/operations/contracts/${contract.id}/generate-content`,
1887
+ 'POST',
1888
+ {
1889
+ provider: aiProvider ?? undefined,
1890
+ promptMessage:
1891
+ trimToNull(aiStartPrompt) ?? trimToNull(form.description),
1892
+ overwrite: !trimToNull(contract.contentHtml),
1893
+ }
1894
+ );
1895
+
1896
+ generationSucceeded = true;
1897
+ setDraftId(latestContract.id);
1898
+ setForm((current) =>
1899
+ preservePartySelectionMetadata(toFormState(latestContract), current)
1900
+ );
1901
+ } catch {
1902
+ showToastHandler?.(
1903
+ 'error',
1904
+ 'Nao foi possivel gerar automaticamente o conteudo inicial do contrato.'
1905
+ );
1906
+ }
1907
+
1908
+ try {
1909
+ const review = await mutateOperations<ContractAiReviewState>(
1910
+ request,
1911
+ `/operations/contracts/${contract.id}/legal-review`,
1912
+ 'POST',
1913
+ {
1914
+ provider: aiProvider ?? undefined,
1915
+ promptMessage:
1916
+ 'Review this generated contract draft and return an advisory legal checklist for revision.',
1917
+ }
1918
+ );
1919
+
1920
+ reviewSucceeded = true;
1921
+ setAiReview(normalizeAiReview(review));
1922
+ } catch {
1923
+ showToastHandler?.(
1924
+ 'error',
1925
+ 'Nao foi possivel montar o checklist juridico orientativo automaticamente.'
1926
+ );
1927
+ } finally {
1928
+ setIsBusy(false);
1929
+ }
1930
+
1931
+ if (generationSucceeded || reviewSucceeded) {
1932
+ showToastHandler?.(
1933
+ 'success',
1934
+ 'Criacao concluida. O conteudo inicial e a revisao orientativa ja estao prontos para edicao.'
1935
+ );
1936
+ }
1937
+
1938
+ if (generationSucceeded || reviewSucceeded) {
1939
+ try {
1940
+ latestContract = await reloadContract(contract.id);
1941
+ } catch {
1942
+ // noop: the editor can still open with the latest saved response
1943
+ }
1944
+ }
1945
+
1946
+ return latestContract;
1947
+ },
1948
+ [
1949
+ aiProvider,
1950
+ aiStartPrompt,
1951
+ form.description,
1952
+ reloadContract,
1953
+ request,
1954
+ showToastHandler,
1955
+ ]
1956
+ );
1957
+
1958
+ const handleComplete = async () => {
1959
+ const response = await saveDraft(reviewStepIndex);
1960
+ if (!response) return;
1961
+
1962
+ const finalResponse = isCreateFlow
1963
+ ? await runPostCreateAutomation(response)
1964
+ : response;
1965
+
1966
+ await onSaved?.(finalResponse);
1967
+ };
1968
+
1969
+ const modeCards = useMemo(
1970
+ () => [
1971
+ {
1972
+ value: 'upload' as CreationMode,
1973
+ title: 'Enviar contrato',
1974
+ description:
1975
+ 'Anexe PDF ou Word, relacione o arquivo ao contrato e deixe a IA preencher o rascunho.',
1976
+ icon: Upload,
1977
+ color: 'text-sky-600 bg-sky-500/10',
1978
+ },
1979
+ {
1980
+ value: 'template' as CreationMode,
1981
+ title: 'Usar template',
1982
+ description:
1983
+ 'Comece com um modelo reutilizavel e ajuste apenas os campos necessarios.',
1984
+ icon: LayoutTemplate,
1985
+ color: 'text-amber-600 bg-amber-500/10',
1986
+ },
1987
+ {
1988
+ value: 'ai_prompt' as CreationMode,
1989
+ title: 'Prompt com IA',
1990
+ description: aiProvider
1991
+ ? 'Descreva o contrato em linguagem natural e deixe a IA montar um rascunho inicial.'
1992
+ : 'Configure OpenAI ou Gemini para habilitar este modo.',
1993
+ icon: Sparkles,
1994
+ color: 'text-fuchsia-600 bg-fuchsia-500/10',
1995
+ disabled: !aiProvider,
1996
+ },
1997
+ {
1998
+ value: 'blank' as CreationMode,
1999
+ title: 'Do zero',
2000
+ description: 'Crie um rascunho enxuto e preencha os dados aos poucos.',
2001
+ icon: FileText,
2002
+ color: 'text-emerald-600 bg-emerald-500/10',
2003
+ },
2004
+ {
2005
+ value: 'duplicate' as CreationMode,
2006
+ title: 'Duplicar',
2007
+ description:
2008
+ 'Reaproveite um contrato existente como base para um novo registro.',
2009
+ icon: CopyPlus,
2010
+ color: 'text-violet-600 bg-violet-500/10',
2011
+ },
2012
+ ],
2013
+ [aiProvider]
2014
+ );
2015
+
2016
+ const currentStep = wizardSteps[activeStep];
2017
+
2018
+ const stepContent = (() => {
2019
+ if (activeStep === 0) {
2020
+ return (
2021
+ <div className="space-y-4">
2022
+ <SectionCard
2023
+ title="Escolha como deseja iniciar"
2024
+ description="O contrato nasce como rascunho e voce pode completar tudo depois."
2025
+ compact
2026
+ descriptionMode="tooltip"
2027
+ >
2028
+ <div className="grid gap-3">
2029
+ {modeCards.map((card) => {
2030
+ const Icon = card.icon;
2031
+ const isSelected = creationMode === card.value;
2032
+ const isDisabled = Boolean(card.disabled);
2033
+ return (
2034
+ <button
2035
+ key={card.value}
2036
+ type="button"
2037
+ disabled={isDisabled}
2038
+ className={`rounded-xl border p-4 text-left transition-all ${
2039
+ isSelected
2040
+ ? 'border-primary bg-accent/30 ring-1 ring-primary/20'
2041
+ : isDisabled
2042
+ ? 'cursor-not-allowed opacity-50'
2043
+ : 'cursor-pointer hover:border-primary/30 hover:bg-accent/20'
2044
+ }`}
2045
+ onClick={() => {
2046
+ if (isDisabled) return;
2047
+ setCreationMode(card.value);
2048
+ setForm((current) => ({
2049
+ ...current,
2050
+ creationMode: card.value,
2051
+ }));
2052
+ }}
2053
+ >
2054
+ <div className="flex items-start gap-3">
2055
+ <div className={`rounded-xl p-2.5 ${card.color}`}>
2056
+ <Icon className="size-5" />
2057
+ </div>
2058
+ <div className="space-y-1">
2059
+ <div className="font-medium">{card.title}</div>
2060
+ <p className="text-sm text-muted-foreground">
2061
+ {card.description}
2062
+ </p>
2063
+ </div>
2064
+ </div>
2065
+ </button>
2066
+ );
2067
+ })}
2068
+ </div>
2069
+ </SectionCard>
2070
+
2071
+ {creationMode === 'upload' ? (
2072
+ <SectionCard
2073
+ title="Documento fonte"
2074
+ description="O arquivo fica vinculado ao contrato e pode ser usado pela IA para preencher o cadastro."
2075
+ compact
2076
+ descriptionMode="tooltip"
2077
+ >
2078
+ <div className="space-y-4">
2079
+ <label className="flex cursor-pointer flex-col items-center justify-center gap-3 rounded-2xl border-2 border-dashed px-4 py-8 text-center transition-colors hover:border-primary/40 hover:bg-accent/20">
2080
+ <FileSpreadsheet className="size-7 text-primary" />
2081
+ <div className="space-y-1">
2082
+ <div className="font-medium">
2083
+ Selecionar PDF, DOC ou DOCX
2084
+ </div>
2085
+ <p className="text-sm text-muted-foreground">
2086
+ O arquivo sera guardado como documento fonte do contrato.
2087
+ </p>
2088
+ </div>
2089
+ <input
2090
+ type="file"
2091
+ className="hidden"
2092
+ accept=".pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
2093
+ onChange={(event) => {
2094
+ setSourceFile(event.target.files?.[0] ?? null);
2095
+ }}
2096
+ />
2097
+ </label>
2098
+
2099
+ {sourceFile ? (
2100
+ <div className="rounded-xl border bg-muted/20 px-4 py-3 text-sm">
2101
+ <div className="font-medium">{sourceFile.name}</div>
2102
+ <div className="text-muted-foreground">
2103
+ {Math.max(sourceFile.size / 1024, 1).toFixed(0)} KB
2104
+ </div>
2105
+ </div>
2106
+ ) : null}
2107
+
2108
+ {form.sourceDocument ? (
2109
+ <div className="rounded-xl border px-4 py-3 text-sm">
2110
+ <div className="font-medium">
2111
+ {form.sourceDocument.fileName}
2112
+ </div>
2113
+ <div className="text-muted-foreground">
2114
+ Status da extracao:{' '}
2115
+ {formatEnumLabel(form.sourceDocument.extractionStatus)}
2116
+ </div>
2117
+ </div>
2118
+ ) : null}
2119
+
2120
+ <div className="flex flex-wrap gap-2">
2121
+ <Button
2122
+ type="button"
2123
+ variant="outline"
2124
+ className="cursor-pointer"
2125
+ disabled={(!draftId && !sourceFile) || isExtracting}
2126
+ onClick={() => void handleExtractSource()}
2127
+ >
2128
+ {isExtracting ? (
2129
+ <LoaderCircle className="size-4 animate-spin" />
2130
+ ) : (
2131
+ <Sparkles className="size-4" />
2132
+ )}
2133
+ Extrair com {aiProviderLabel || 'IA'}
2134
+ </Button>
2135
+ {form.sourceDocument?.fileId ? (
2136
+ <Button
2137
+ type="button"
2138
+ variant="outline"
2139
+ className="cursor-pointer"
2140
+ onClick={() =>
2141
+ openStoredFile(form.sourceDocument?.fileId)
2142
+ }
2143
+ >
2144
+ <Download className="size-4" />
2145
+ Abrir documento
2146
+ </Button>
2147
+ ) : null}
2148
+ </div>
2149
+
2150
+ {aiReview ? (
2151
+ <div className="space-y-3 rounded-2xl border bg-muted/20 p-4">
2152
+ {aiReview.summary ? (
2153
+ <p className="text-sm">{aiReview.summary}</p>
2154
+ ) : null}
2155
+ {aiReview.missingFields.length ? (
2156
+ <div className="space-y-1 text-sm">
2157
+ <div className="font-medium">Campos faltantes</div>
2158
+ <ul className="list-disc space-y-1 pl-5 text-muted-foreground">
2159
+ {aiReview.missingFields.map((item) => (
2160
+ <li key={item}>{item}</li>
2161
+ ))}
2162
+ </ul>
2163
+ </div>
2164
+ ) : null}
2165
+ {aiReview.warnings.length ? (
2166
+ <div className="space-y-1 text-sm">
2167
+ <div className="font-medium">Avisos</div>
2168
+ <ul className="list-disc space-y-1 pl-5 text-muted-foreground">
2169
+ {aiReview.warnings.map((item) => (
2170
+ <li key={item}>{item}</li>
2171
+ ))}
2172
+ </ul>
2173
+ </div>
2174
+ ) : null}
2175
+ </div>
2176
+ ) : null}
2177
+ </div>
2178
+ </SectionCard>
2179
+ ) : null}
2180
+
2181
+ {creationMode === 'template' ? (
2182
+ <ContractTemplateSelectWithCreate
2183
+ label="Template do contrato"
2184
+ value={selectedTemplateId}
2185
+ initialSelectedLabel={selectedTemplate?.name ?? ''}
2186
+ placeholder="Selecione um template"
2187
+ onChange={(template) => {
2188
+ setSelectedTemplateId(template?.id ?? null);
2189
+ setForm((current) => ({
2190
+ ...current,
2191
+ contractTemplateId: template ? String(template.id) : 'none',
2192
+ }));
2193
+ }}
2194
+ />
2195
+ ) : null}
2196
+ </div>
2197
+ );
2198
+ }
2199
+
2200
+ if (activeStep === 1) {
2201
+ if (creationMode === 'ai_prompt' && !draftId) {
2202
+ return (
2203
+ <div className="space-y-4">
2204
+ <SectionCard
2205
+ title="Prompt inicial para a IA"
2206
+ description="Descreva o tipo de contrato e os principais pontos para gerar um primeiro rascunho."
2207
+ compact
2208
+ descriptionMode="tooltip"
2209
+ >
2210
+ <div className="space-y-3">
2211
+ <Textarea
2212
+ rows={6}
2213
+ value={aiStartPrompt}
2214
+ placeholder="Ex: Gere um contrato de prestacao de servicos mensais com vigencia de 12 meses, pagamento recorrente, confidencialidade e clausula de rescisao."
2215
+ onChange={(event) => setAiStartPrompt(event.target.value)}
2216
+ />
2217
+ <p className="text-sm text-muted-foreground">
2218
+ Ao concluir o cadastro, a IA vai gerar um texto inicial para
2219
+ voce revisar e ajustar no modo de edicao.
2220
+ </p>
2221
+ </div>
2222
+ </SectionCard>
2223
+ </div>
2224
+ );
2225
+ }
2226
+
2227
+ if (creationMode === 'duplicate' && !draftId) {
2228
+ return (
2229
+ <div className="space-y-4">
2230
+ <SectionCard
2231
+ title="Contrato base"
2232
+ description="Selecione o contrato que sera usado para duplicar este novo rascunho. Este campo e obrigatorio para continuar."
2233
+ compact
2234
+ descriptionMode="tooltip"
2235
+ >
2236
+ <div className="space-y-3">
2237
+ <Select
2238
+ value={
2239
+ selectedDuplicateId ? String(selectedDuplicateId) : 'none'
2240
+ }
2241
+ onValueChange={(value) =>
2242
+ setSelectedDuplicateId(
2243
+ value === 'none' ? null : Number(value)
2244
+ )
2245
+ }
2246
+ >
2247
+ <SelectTrigger className="w-full">
2248
+ <SelectValue placeholder="Selecione um contrato" />
2249
+ </SelectTrigger>
2250
+ <SelectContent>
2251
+ <SelectItem value="none">Selecione um contrato</SelectItem>
2252
+ {contracts.map((contract) => (
2253
+ <SelectItem key={contract.id} value={String(contract.id)}>
2254
+ {(
2255
+ contract.name ||
2256
+ contract.code ||
2257
+ `Contrato ${contract.id}`
2258
+ ).trim()}
2259
+ </SelectItem>
2260
+ ))}
2261
+ </SelectContent>
2262
+ </Select>
2263
+
2264
+ <p className="text-sm text-muted-foreground">
2265
+ Se preferir, volte e escolha outra opcao para iniciar.
2266
+ </p>
2267
+ </div>
2268
+ </SectionCard>
2269
+ </div>
2270
+ );
2271
+ }
2272
+
2273
+ return (
2274
+ <div className="space-y-4">
2275
+ <SectionCard
2276
+ title="Informacoes principais"
2277
+ description="Preencha os dados basicos para identificar o contrato e seus relacionamentos."
2278
+ compact
2279
+ descriptionMode="tooltip"
2280
+ >
2281
+ <div className="grid gap-3 md:grid-cols-2">
2282
+ <div className="space-y-1.5">
2283
+ <Label className="text-xs">Codigo</Label>
2284
+ <Input
2285
+ value={form.code}
2286
+ onChange={(event) =>
2287
+ setForm((current) => ({
2288
+ ...current,
2289
+ code: event.target.value,
2290
+ }))
2291
+ }
2292
+ />
2293
+ </div>
2294
+ <div className="space-y-1.5">
2295
+ <Label className="text-xs">Titulo do contrato</Label>
2296
+ <Input
2297
+ value={form.name}
2298
+ onChange={(event) =>
2299
+ setForm((current) => ({
2300
+ ...current,
2301
+ name: event.target.value,
2302
+ }))
2303
+ }
2304
+ />
2305
+ </div>
2306
+ <div className="space-y-3 md:col-span-2">
2307
+ <PersonSelectWithCreate
2308
+ label="Cliente ou parte principal"
2309
+ entityLabel="cliente"
2310
+ value={primaryParty?.personId ?? null}
2311
+ initialSelectedLabel={form.clientName}
2312
+ selectPlaceholder="Selecionar pessoa ou empresa"
2313
+ personTypeFilter="all"
2314
+ onChange={(personId, personName) => {
2315
+ void handlePartyPersonChange(
2316
+ primaryPartyIndex,
2317
+ personId,
2318
+ personName,
2319
+ {
2320
+ syncClientName: true,
2321
+ markAsPrimary: true,
2322
+ }
2323
+ );
2324
+ }}
2325
+ />
2326
+
2327
+ <div className="space-y-3 rounded-2xl border bg-muted/20 p-3">
2328
+ <div className="space-y-1">
2329
+ <div className="text-sm font-medium">
2330
+ Dados usados na parte principal
2331
+ </div>
2332
+ <p className="text-xs text-muted-foreground">
2333
+ Escolha quais dados do cadastro dessa pessoa devem entrar
2334
+ no contrato. Se faltar algo, digite um novo valor.
2335
+ </p>
2336
+ </div>
2337
+
2338
+ <div className="grid gap-3 md:grid-cols-3">
2339
+ <PersonDataChoiceField
2340
+ label="Documento"
2341
+ value={primaryParty?.documentNumber ?? ''}
2342
+ options={primaryParty?.availableDocuments ?? []}
2343
+ mode={primaryParty?.documentMode ?? 'new'}
2344
+ kind="document"
2345
+ placeholder="Selecionar ou digitar documento"
2346
+ onModeChange={(mode) =>
2347
+ updatePartyAt(primaryPartyIndex, (party) => ({
2348
+ ...party,
2349
+ documentMode: mode,
2350
+ }))
2351
+ }
2352
+ onValueChange={(value) =>
2353
+ updatePartyAt(primaryPartyIndex, (party) => ({
2354
+ ...party,
2355
+ documentNumber: value,
2356
+ }))
2357
+ }
2358
+ />
2359
+ <PersonDataChoiceField
2360
+ label="E-mail"
2361
+ value={primaryParty?.email ?? ''}
2362
+ options={primaryParty?.availableEmails ?? []}
2363
+ mode={primaryParty?.emailMode ?? 'new'}
2364
+ kind="email"
2365
+ inputType="email"
2366
+ placeholder="Selecionar ou digitar e-mail"
2367
+ onModeChange={(mode) =>
2368
+ updatePartyAt(primaryPartyIndex, (party) => ({
2369
+ ...party,
2370
+ emailMode: mode,
2371
+ }))
2372
+ }
2373
+ onValueChange={(value) =>
2374
+ updatePartyAt(primaryPartyIndex, (party) => ({
2375
+ ...party,
2376
+ email: value,
2377
+ }))
2378
+ }
2379
+ />
2380
+ <PersonDataChoiceField
2381
+ label="Telefone"
2382
+ value={primaryParty?.phone ?? ''}
2383
+ options={primaryParty?.availablePhones ?? []}
2384
+ mode={primaryParty?.phoneMode ?? 'new'}
2385
+ kind="phone"
2386
+ inputType="tel"
2387
+ placeholder="Selecionar ou digitar telefone"
2388
+ onModeChange={(mode) =>
2389
+ updatePartyAt(primaryPartyIndex, (party) => ({
2390
+ ...party,
2391
+ phoneMode: mode,
2392
+ }))
2393
+ }
2394
+ onValueChange={(value) =>
2395
+ updatePartyAt(primaryPartyIndex, (party) => ({
2396
+ ...party,
2397
+ phone: value,
2398
+ }))
2399
+ }
2400
+ />
2401
+ </div>
2402
+ </div>
2403
+ </div>
2404
+ <ContractTemplateSelectWithCreate
2405
+ label="Template vinculado"
2406
+ value={
2407
+ form.contractTemplateId !== 'none'
2408
+ ? Number(form.contractTemplateId)
2409
+ : null
2410
+ }
2411
+ initialSelectedLabel={
2412
+ contractTemplates.find(
2413
+ (template) =>
2414
+ String(template.id) === form.contractTemplateId
2415
+ )?.name ?? ''
2416
+ }
2417
+ placeholder="Selecionar template"
2418
+ onChange={(template) => {
2419
+ setSelectedTemplateId(template?.id ?? null);
2420
+ setForm((current) => ({
2421
+ ...current,
2422
+ contractTemplateId: template ? String(template.id) : 'none',
2423
+ contractCategory:
2424
+ template?.contractCategory ?? current.contractCategory,
2425
+ contractType:
2426
+ template?.contractType ?? current.contractType,
2427
+ billingModel:
2428
+ template?.billingModel ?? current.billingModel,
2429
+ signatureStatus:
2430
+ template?.signatureStatus ?? current.signatureStatus,
2431
+ name: current.name || template?.name || '',
2432
+ description:
2433
+ current.description || template?.description || '',
2434
+ contentHtml:
2435
+ current.contentHtml || template?.contentHtml || '',
2436
+ }));
2437
+ }}
2438
+ />
2439
+ <CollaboratorSelectWithCreate
2440
+ label="Gestor responsavel"
2441
+ value={
2442
+ form.accountManagerCollaboratorId !== 'none'
2443
+ ? Number(form.accountManagerCollaboratorId)
2444
+ : null
2445
+ }
2446
+ initialSelectedLabel={
2447
+ collaborators.find(
2448
+ (item) =>
2449
+ String(item.id) === form.accountManagerCollaboratorId
2450
+ )?.displayName ?? ''
2451
+ }
2452
+ placeholder="Selecionar colaborador"
2453
+ onChange={(option) =>
2454
+ setForm((current) => ({
2455
+ ...current,
2456
+ accountManagerCollaboratorId: option
2457
+ ? String(option.id)
2458
+ : 'none',
2459
+ }))
2460
+ }
2461
+ />
2462
+ <CollaboratorSelectWithCreate
2463
+ label="Colaborador relacionado"
2464
+ value={
2465
+ form.relatedCollaboratorId !== 'none'
2466
+ ? Number(form.relatedCollaboratorId)
2467
+ : null
2468
+ }
2469
+ initialSelectedLabel={
2470
+ collaborators.find(
2471
+ (item) => String(item.id) === form.relatedCollaboratorId
2472
+ )?.displayName ?? ''
2473
+ }
2474
+ placeholder="Selecionar colaborador"
2475
+ onChange={(option) =>
2476
+ setForm((current) => ({
2477
+ ...current,
2478
+ relatedCollaboratorId: option ? String(option.id) : 'none',
2479
+ }))
2480
+ }
2481
+ />
2482
+ <div className="space-y-1.5">
2483
+ <Label className="text-xs">Categoria</Label>
2484
+ <Select
2485
+ value={form.contractCategory}
2486
+ onValueChange={(value) =>
2487
+ setForm((current) => ({
2488
+ ...current,
2489
+ contractCategory: value,
2490
+ }))
2491
+ }
2492
+ >
2493
+ <SelectTrigger className="w-full">
2494
+ <SelectValue />
2495
+ </SelectTrigger>
2496
+ <SelectContent>
2497
+ {[
2498
+ 'employee',
2499
+ 'contractor',
2500
+ 'client',
2501
+ 'supplier',
2502
+ 'vendor',
2503
+ 'partner',
2504
+ 'internal',
2505
+ 'other',
2506
+ ].map((value) => (
2507
+ <SelectItem key={value} value={value}>
2508
+ {getOptionLabel('contractCategories', value)}
2509
+ </SelectItem>
2510
+ ))}
2511
+ </SelectContent>
2512
+ </Select>
2513
+ </div>
2514
+ <div className="space-y-1.5">
2515
+ <Label className="text-xs">Tipo</Label>
2516
+ <Select
2517
+ value={form.contractType}
2518
+ onValueChange={(value) =>
2519
+ setForm((current) => ({ ...current, contractType: value }))
2520
+ }
2521
+ >
2522
+ <SelectTrigger className="w-full">
2523
+ <SelectValue />
2524
+ </SelectTrigger>
2525
+ <SelectContent>
2526
+ {[
2527
+ 'clt',
2528
+ 'pj',
2529
+ 'freelancer_agreement',
2530
+ 'service_agreement',
2531
+ 'fixed_term',
2532
+ 'recurring_service',
2533
+ 'nda',
2534
+ 'amendment',
2535
+ 'addendum',
2536
+ 'other',
2537
+ ].map((value) => (
2538
+ <SelectItem key={value} value={value}>
2539
+ {getOptionLabel('contractTypes', value)}
2540
+ </SelectItem>
2541
+ ))}
2542
+ </SelectContent>
2543
+ </Select>
2544
+ </div>
2545
+ </div>
2546
+ </SectionCard>
2547
+ </div>
2548
+ );
2549
+ }
2550
+
2551
+ if (activeStep === 2) {
2552
+ return (
2553
+ <div className="space-y-4">
2554
+ <SectionCard
2555
+ title="Partes relacionadas"
2556
+ description="Adicione apenas o essencial agora. O restante pode ser completado depois."
2557
+ compact
2558
+ descriptionMode="tooltip"
2559
+ >
2560
+ <div className="space-y-3">
2561
+ {form.parties.map((party, index) => (
2562
+ <div key={`party-${index}`} className="border-t pt-3">
2563
+ <div className="grid gap-3 md:grid-cols-2">
2564
+ <PersonSelectWithCreate
2565
+ label={`Parte ${index + 1}`}
2566
+ entityLabel="parte"
2567
+ value={party.personId}
2568
+ initialSelectedLabel={party.displayName}
2569
+ selectPlaceholder="Selecionar pessoa ou empresa"
2570
+ personTypeFilter="all"
2571
+ onChange={(personId, personName) =>
2572
+ void handlePartyPersonChange(
2573
+ index,
2574
+ personId,
2575
+ personName,
2576
+ {
2577
+ syncClientName: party.isPrimary,
2578
+ }
2579
+ )
2580
+ }
2581
+ />
2582
+ <div className="space-y-1.5">
2583
+ <Label className="text-xs">Papel</Label>
2584
+ <Select
2585
+ value={party.partyRole}
2586
+ onValueChange={(value) =>
2587
+ setForm((current) => ({
2588
+ ...current,
2589
+ parties: current.parties.map((item, itemIndex) =>
2590
+ itemIndex === index
2591
+ ? { ...item, partyRole: value }
2592
+ : item
2593
+ ),
2594
+ }))
2595
+ }
2596
+ >
2597
+ <SelectTrigger className="w-full">
2598
+ <SelectValue />
2599
+ </SelectTrigger>
2600
+ <SelectContent>
2601
+ {[
2602
+ 'employee',
2603
+ 'employer',
2604
+ 'client',
2605
+ 'supplier',
2606
+ 'vendor',
2607
+ 'partner',
2608
+ 'witness',
2609
+ 'internal_owner',
2610
+ 'other',
2611
+ ].map((value) => (
2612
+ <SelectItem key={value} value={value}>
2613
+ {getOptionLabel('partyRoles', value)}
2614
+ </SelectItem>
2615
+ ))}
2616
+ </SelectContent>
2617
+ </Select>
2618
+ </div>
2619
+ <PersonDataChoiceField
2620
+ label="Documento"
2621
+ value={party.documentNumber}
2622
+ options={party.availableDocuments}
2623
+ mode={party.documentMode}
2624
+ kind="document"
2625
+ placeholder="Selecionar ou digitar documento"
2626
+ onModeChange={(mode) =>
2627
+ updatePartyAt(index, (item) => ({
2628
+ ...item,
2629
+ documentMode: mode,
2630
+ }))
2631
+ }
2632
+ onValueChange={(value) =>
2633
+ updatePartyAt(index, (item) => ({
2634
+ ...item,
2635
+ documentNumber: value,
2636
+ }))
2637
+ }
2638
+ />
2639
+ <PersonDataChoiceField
2640
+ label="E-mail"
2641
+ value={party.email}
2642
+ options={party.availableEmails}
2643
+ mode={party.emailMode}
2644
+ kind="email"
2645
+ inputType="email"
2646
+ placeholder="Selecionar ou digitar e-mail"
2647
+ onModeChange={(mode) =>
2648
+ updatePartyAt(index, (item) => ({
2649
+ ...item,
2650
+ emailMode: mode,
2651
+ }))
2652
+ }
2653
+ onValueChange={(value) =>
2654
+ updatePartyAt(index, (item) => ({
2655
+ ...item,
2656
+ email: value,
2657
+ }))
2658
+ }
2659
+ />
2660
+ <PersonDataChoiceField
2661
+ label="Telefone"
2662
+ value={party.phone}
2663
+ options={party.availablePhones}
2664
+ mode={party.phoneMode}
2665
+ kind="phone"
2666
+ inputType="tel"
2667
+ placeholder="Selecionar ou digitar telefone"
2668
+ onModeChange={(mode) =>
2669
+ updatePartyAt(index, (item) => ({
2670
+ ...item,
2671
+ phoneMode: mode,
2672
+ }))
2673
+ }
2674
+ onValueChange={(value) =>
2675
+ updatePartyAt(index, (item) => ({
2676
+ ...item,
2677
+ phone: value,
2678
+ }))
2679
+ }
2680
+ />
2681
+ <div className="flex items-center gap-2 pt-6">
2682
+ <Switch
2683
+ checked={party.isPrimary}
2684
+ onCheckedChange={(checked) =>
2685
+ setForm((current) => {
2686
+ const nextParties = current.parties.map(
2687
+ (item, itemIndex) => ({
2688
+ ...item,
2689
+ isPrimary:
2690
+ itemIndex === index
2691
+ ? checked
2692
+ : checked
2693
+ ? false
2694
+ : item.isPrimary,
2695
+ })
2696
+ );
2697
+
2698
+ return {
2699
+ ...current,
2700
+ clientName:
2701
+ checked && nextParties[index]?.displayName
2702
+ ? nextParties[index].displayName
2703
+ : current.clientName,
2704
+ parties: nextParties,
2705
+ };
2706
+ })
2707
+ }
2708
+ />
2709
+ <span className="text-sm text-muted-foreground">
2710
+ Parte principal
2711
+ </span>
2712
+ </div>
2713
+ {form.parties.length > 1 ? (
2714
+ <div className="flex justify-end md:col-span-2">
2715
+ <Button
2716
+ type="button"
2717
+ variant="destructive"
2718
+ className="cursor-pointer"
2719
+ onClick={() =>
2720
+ setForm((current) => ({
2721
+ ...current,
2722
+ parties: current.parties.filter(
2723
+ (_, itemIndex) => itemIndex !== index
2724
+ ),
2725
+ }))
2726
+ }
2727
+ >
2728
+ <Trash2 className="size-4" />
2729
+ Remover parte
2730
+ </Button>
2731
+ </div>
2732
+ ) : null}
2733
+ </div>
2734
+ </div>
2735
+ ))}
2736
+
2737
+ <div className="flex gap-2">
2738
+ <Button
2739
+ type="button"
2740
+ variant="outline"
2741
+ className="cursor-pointer"
2742
+ onClick={() =>
2743
+ setForm((current) => ({
2744
+ ...current,
2745
+ parties: [...current.parties, emptyParty()],
2746
+ }))
2747
+ }
2748
+ >
2749
+ <Users className="size-4" />
2750
+ Adicionar parte
2751
+ </Button>
2752
+ </div>
2753
+ </div>
2754
+ </SectionCard>
2755
+ <SectionCard
2756
+ title="Assinaturas"
2757
+ description="Liste apenas quem precisa aparecer no fluxo de assinatura."
2758
+ compact
2759
+ descriptionMode="tooltip"
2760
+ >
2761
+ <div className="space-y-3">
2762
+ {form.signatures.map((signature, index) => (
2763
+ <div
2764
+ key={`signature-${index}`}
2765
+ className="grid gap-3 md:grid-cols-2 border-t pt-3"
2766
+ >
2767
+ <div className="space-y-1.5">
2768
+ <Label className="text-xs">Nome do signatario</Label>
2769
+ <Input
2770
+ value={signature.signerName}
2771
+ onChange={(event) =>
2772
+ setForm((current) => ({
2773
+ ...current,
2774
+ signatures: current.signatures.map(
2775
+ (item, itemIndex) =>
2776
+ itemIndex === index
2777
+ ? { ...item, signerName: event.target.value }
2778
+ : item
2779
+ ),
2780
+ }))
2781
+ }
2782
+ />
2783
+ </div>
2784
+ <div className="space-y-1.5">
2785
+ <Label className="text-xs">Papel</Label>
2786
+ <Input
2787
+ value={signature.signerRole}
2788
+ onChange={(event) =>
2789
+ setForm((current) => ({
2790
+ ...current,
2791
+ signatures: current.signatures.map(
2792
+ (item, itemIndex) =>
2793
+ itemIndex === index
2794
+ ? { ...item, signerRole: event.target.value }
2795
+ : item
2796
+ ),
2797
+ }))
2798
+ }
2799
+ />
2800
+ </div>
2801
+ <div className="space-y-1.5">
2802
+ <Label className="text-xs">Email</Label>
2803
+ <Input
2804
+ value={signature.signerEmail}
2805
+ onChange={(event) =>
2806
+ setForm((current) => ({
2807
+ ...current,
2808
+ signatures: current.signatures.map(
2809
+ (item, itemIndex) =>
2810
+ itemIndex === index
2811
+ ? { ...item, signerEmail: event.target.value }
2812
+ : item
2813
+ ),
2814
+ }))
2815
+ }
2816
+ />
2817
+ </div>
2818
+ <div className="space-y-1.5">
2819
+ <Label className="text-xs">Status</Label>
2820
+ <Select
2821
+ value={signature.status}
2822
+ onValueChange={(value) =>
2823
+ setForm((current) => ({
2824
+ ...current,
2825
+ signatures: current.signatures.map(
2826
+ (item, itemIndex) =>
2827
+ itemIndex === index
2828
+ ? { ...item, status: value }
2829
+ : item
2830
+ ),
2831
+ }))
2832
+ }
2833
+ >
2834
+ <SelectTrigger className="w-full">
2835
+ <SelectValue />
2836
+ </SelectTrigger>
2837
+ <SelectContent>
2838
+ {['pending', 'signed', 'rejected'].map((value) => (
2839
+ <SelectItem key={value} value={value}>
2840
+ {getOptionLabel('signatureStatuses', value)}
2841
+ </SelectItem>
2842
+ ))}
2843
+ </SelectContent>
2844
+ </Select>
2845
+ </div>
2846
+ {form.signatures.length > 1 ? (
2847
+ <div className="flex justify-end md:col-span-2">
2848
+ <Button
2849
+ type="button"
2850
+ variant="destructive"
2851
+ className="cursor-pointer"
2852
+ onClick={() =>
2853
+ setForm((current) => ({
2854
+ ...current,
2855
+ signatures: current.signatures.filter(
2856
+ (_, itemIndex) => itemIndex !== index
2857
+ ),
2858
+ }))
2859
+ }
2860
+ >
2861
+ <Trash2 className="size-4" />
2862
+ Remover signatario
2863
+ </Button>
2864
+ </div>
2865
+ ) : null}
2866
+ </div>
2867
+ ))}
2868
+ <Button
2869
+ type="button"
2870
+ variant="outline"
2871
+ className="cursor-pointer"
2872
+ onClick={() =>
2873
+ setForm((current) => ({
2874
+ ...current,
2875
+ signatures: [...current.signatures, emptySignature()],
2876
+ }))
2877
+ }
2878
+ >
2879
+ <UserRound className="size-4" />
2880
+ Adicionar signatario
2881
+ </Button>
2882
+ </div>
2883
+ </SectionCard>
2884
+ </div>
2885
+ );
2886
+ }
2887
+
2888
+ if (activeStep === 3) {
2889
+ return (
2890
+ <div className="space-y-4">
2891
+ <SectionCard
2892
+ title="Condicoes e vigencia"
2893
+ description="Deixe o contrato facil de criar. Tudo aqui pode ser ajustado na edicao."
2894
+ compact
2895
+ descriptionMode="tooltip"
2896
+ >
2897
+ <div className="grid gap-3 md:grid-cols-2">
2898
+ {contractDateFields.map(({ key, label }) => (
2899
+ <div key={key} className="space-y-1.5">
2900
+ <Label className="text-xs">{label}</Label>
2901
+ <Input
2902
+ type="date"
2903
+ value={form[key]}
2904
+ onChange={(event) =>
2905
+ setForm((current) => ({
2906
+ ...current,
2907
+ [key]: event.target.value,
2908
+ }))
2909
+ }
2910
+ />
2911
+ </div>
2912
+ ))}
2913
+ <div className="space-y-1.5">
2914
+ <Label className="text-xs">Modelo de cobranca</Label>
2915
+ <Select
2916
+ value={form.billingModel}
2917
+ onValueChange={(value) =>
2918
+ setForm((current) => ({ ...current, billingModel: value }))
2919
+ }
2920
+ >
2921
+ <SelectTrigger className="w-full">
2922
+ <SelectValue />
2923
+ </SelectTrigger>
2924
+ <SelectContent>
2925
+ {[
2926
+ 'time_and_material',
2927
+ 'monthly_retainer',
2928
+ 'fixed_price',
2929
+ ].map((value) => (
2930
+ <SelectItem key={value} value={value}>
2931
+ {getOptionLabel('billingModels', value)}
2932
+ </SelectItem>
2933
+ ))}
2934
+ </SelectContent>
2935
+ </Select>
2936
+ </div>
2937
+ <div className="space-y-1.5">
2938
+ <Label className="text-xs">Status</Label>
2939
+ <Select
2940
+ value={form.status}
2941
+ onValueChange={(value) =>
2942
+ setForm((current) => ({ ...current, status: value }))
2943
+ }
2944
+ >
2945
+ <SelectTrigger className="w-full">
2946
+ <SelectValue />
2947
+ </SelectTrigger>
2948
+ <SelectContent>
2949
+ {[
2950
+ 'draft',
2951
+ 'under_review',
2952
+ 'active',
2953
+ 'renewal',
2954
+ 'expired',
2955
+ 'closed',
2956
+ 'archived',
2957
+ ].map((value) => (
2958
+ <SelectItem key={value} value={value}>
2959
+ {getOptionLabel('statuses', value)}
2960
+ </SelectItem>
2961
+ ))}
2962
+ </SelectContent>
2963
+ </Select>
2964
+ </div>
2965
+ <div className="space-y-1.5">
2966
+ <Label className="text-xs">Orcamento</Label>
2967
+ <Input
2968
+ value={form.budgetAmount}
2969
+ onChange={(event) =>
2970
+ setForm((current) => ({
2971
+ ...current,
2972
+ budgetAmount: event.target.value,
2973
+ }))
2974
+ }
2975
+ />
2976
+ </div>
2977
+ <div className="space-y-1.5">
2978
+ <Label className="text-xs">Limite mensal de horas</Label>
2979
+ <Input
2980
+ value={form.monthlyHourCap}
2981
+ onChange={(event) =>
2982
+ setForm((current) => ({
2983
+ ...current,
2984
+ monthlyHourCap: event.target.value,
2985
+ }))
2986
+ }
2987
+ />
2988
+ </div>
2989
+ <div className="flex items-center gap-2 pt-6">
2990
+ <Switch
2991
+ checked={form.isActive}
2992
+ onCheckedChange={(checked) =>
2993
+ setForm((current) => ({ ...current, isActive: checked }))
2994
+ }
2995
+ />
2996
+ <span className="text-sm text-muted-foreground">
2997
+ Contrato ativo
2998
+ </span>
2999
+ </div>
3000
+ </div>
3001
+ </SectionCard>
3002
+
3003
+ <SectionCard
3004
+ title="Termos financeiros"
3005
+ description="Use somente os termos que fazem sentido para este rascunho."
3006
+ compact
3007
+ descriptionMode="tooltip"
3008
+ >
3009
+ <div className="space-y-3">
3010
+ {form.financialTerms.map((term, index) => (
3011
+ <div
3012
+ key={`term-${index}`}
3013
+ className="grid gap-3 border-t pt-3 md:grid-cols-2"
3014
+ >
3015
+ <div className="space-y-1.5">
3016
+ <Label className="text-xs">Rotulo</Label>
3017
+ <Input
3018
+ value={term.label}
3019
+ onChange={(event) =>
3020
+ setForm((current) => ({
3021
+ ...current,
3022
+ financialTerms: current.financialTerms.map(
3023
+ (item, itemIndex) =>
3024
+ itemIndex === index
3025
+ ? { ...item, label: event.target.value }
3026
+ : item
3027
+ ),
3028
+ }))
3029
+ }
3030
+ />
3031
+ </div>
3032
+ <div className="space-y-1.5">
3033
+ <Label className="text-xs">Tipo</Label>
3034
+ <Select
3035
+ value={term.termType}
3036
+ onValueChange={(value) =>
3037
+ setForm((current) => ({
3038
+ ...current,
3039
+ financialTerms: current.financialTerms.map(
3040
+ (item, itemIndex) =>
3041
+ itemIndex === index
3042
+ ? { ...item, termType: value }
3043
+ : item
3044
+ ),
3045
+ }))
3046
+ }
3047
+ >
3048
+ <SelectTrigger className="w-full">
3049
+ <SelectValue />
3050
+ </SelectTrigger>
3051
+ <SelectContent>
3052
+ {['value', 'payment', 'revenue', 'fine', 'other'].map(
3053
+ (value) => (
3054
+ <SelectItem key={value} value={value}>
3055
+ {getOptionLabel('financialTermTypes', value)}
3056
+ </SelectItem>
3057
+ )
3058
+ )}
3059
+ </SelectContent>
3060
+ </Select>
3061
+ </div>
3062
+ <div className="space-y-1.5">
3063
+ <Label className="text-xs">Valor</Label>
3064
+ <InputMoney
3065
+ value={term.amount}
3066
+ onChange={(event) =>
3067
+ setForm((current) => ({
3068
+ ...current,
3069
+ financialTerms: current.financialTerms.map(
3070
+ (item, itemIndex) =>
3071
+ itemIndex === index
3072
+ ? { ...item, amount: event.target.value }
3073
+ : item
3074
+ ),
3075
+ }))
3076
+ }
3077
+ />
3078
+ </div>
3079
+ <div className="space-y-1.5">
3080
+ <Label className="text-xs">Recorrencia</Label>
3081
+ <Select
3082
+ value={term.recurrence}
3083
+ onValueChange={(value) =>
3084
+ setForm((current) => ({
3085
+ ...current,
3086
+ financialTerms: current.financialTerms.map(
3087
+ (item, itemIndex) =>
3088
+ itemIndex === index
3089
+ ? { ...item, recurrence: value }
3090
+ : item
3091
+ ),
3092
+ }))
3093
+ }
3094
+ >
3095
+ <SelectTrigger className="w-full">
3096
+ <SelectValue />
3097
+ </SelectTrigger>
3098
+ <SelectContent>
3099
+ {[
3100
+ 'one_time',
3101
+ 'monthly',
3102
+ 'quarterly',
3103
+ 'yearly',
3104
+ 'other',
3105
+ ].map((value) => (
3106
+ <SelectItem key={value} value={value}>
3107
+ {getOptionLabel('recurrences', value)}
3108
+ </SelectItem>
3109
+ ))}
3110
+ </SelectContent>
3111
+ </Select>
3112
+ </div>
3113
+ {form.financialTerms.length > 1 ? (
3114
+ <div className="flex justify-end md:col-span-2">
3115
+ <Button
3116
+ type="button"
3117
+ variant="outline"
3118
+ className="cursor-pointer"
3119
+ onClick={() =>
3120
+ setForm((current) => ({
3121
+ ...current,
3122
+ financialTerms: current.financialTerms.filter(
3123
+ (_, itemIndex) => itemIndex !== index
3124
+ ),
3125
+ }))
3126
+ }
3127
+ >
3128
+ Remover termo
3129
+ </Button>
3130
+ </div>
3131
+ ) : null}
3132
+ </div>
3133
+ ))}
3134
+ <Button
3135
+ type="button"
3136
+ variant="outline"
3137
+ className="cursor-pointer"
3138
+ onClick={() =>
3139
+ setForm((current) => ({
3140
+ ...current,
3141
+ financialTerms: [
3142
+ ...current.financialTerms,
3143
+ emptyFinancialTerm(),
3144
+ ],
3145
+ }))
3146
+ }
3147
+ >
3148
+ <BadgeDollarSign className="size-4" />
3149
+ Adicionar termo financeiro
3150
+ </Button>
3151
+ </div>
3152
+ </SectionCard>
3153
+ </div>
3154
+ );
3155
+ }
3156
+
3157
+ if (contentStepIndex >= 0 && activeStep === contentStepIndex) {
3158
+ return (
3159
+ <div className="space-y-4">
3160
+ <SectionCard
3161
+ title="Revisao juridica orientativa"
3162
+ description="Use o checklist como apoio para a revisao do texto. Ele nao bloqueia a edicao nem substitui a validacao juridica final."
3163
+ compact
3164
+ descriptionMode="tooltip"
3165
+ >
3166
+ <div className="space-y-3">
3167
+ <div className="flex flex-wrap items-center justify-between gap-2">
3168
+ <p className="text-sm text-muted-foreground">
3169
+ {aiReview?.reviewedAt
3170
+ ? `Ultima atualizacao: ${formatDate(aiReview.reviewedAt)}`
3171
+ : 'Atualize a revisao sempre que mudar clausulas sensiveis.'}
3172
+ </p>
3173
+ <Button
3174
+ type="button"
3175
+ variant="outline"
3176
+ className="cursor-pointer"
3177
+ disabled={isBusy || !draftId}
3178
+ onClick={() => void handleRunLegalReview(draftId)}
3179
+ >
3180
+ {isBusy ? (
3181
+ <LoaderCircle className="size-4 animate-spin" />
3182
+ ) : (
3183
+ <Sparkles className="size-4" />
3184
+ )}
3185
+ Atualizar checklist
3186
+ </Button>
3187
+ </div>
3188
+
3189
+ {aiReview ? (
3190
+ <div className="space-y-3 rounded-2xl border bg-muted/20 p-4 text-sm">
3191
+ {aiReview.summary ? <p>{aiReview.summary}</p> : null}
3192
+
3193
+ {aiReview.checklist.length ? (
3194
+ <div className="space-y-1">
3195
+ <div className="font-medium">Checklist</div>
3196
+ <ul className="list-disc space-y-1 pl-5 text-muted-foreground">
3197
+ {aiReview.checklist.map((item) => (
3198
+ <li key={item}>{item}</li>
3199
+ ))}
3200
+ </ul>
3201
+ </div>
3202
+ ) : null}
3203
+
3204
+ {aiReview.missingFields.length ? (
3205
+ <div className="space-y-1">
3206
+ <div className="font-medium">Pontos faltantes</div>
3207
+ <ul className="list-disc space-y-1 pl-5 text-muted-foreground">
3208
+ {aiReview.missingFields.map((item) => (
3209
+ <li key={item}>{item}</li>
3210
+ ))}
3211
+ </ul>
3212
+ </div>
3213
+ ) : null}
3214
+
3215
+ {aiReview.warnings.length ? (
3216
+ <div className="space-y-1">
3217
+ <div className="font-medium">Alertas</div>
3218
+ <ul className="list-disc space-y-1 pl-5 text-muted-foreground">
3219
+ {aiReview.warnings.map((item) => (
3220
+ <li key={item}>{item}</li>
3221
+ ))}
3222
+ </ul>
3223
+ </div>
3224
+ ) : null}
3225
+ </div>
3226
+ ) : (
3227
+ <p className="text-sm text-muted-foreground">
3228
+ Gere ou atualize o checklist para receber uma revisao juridica
3229
+ orientativa do contrato em edicao.
3230
+ </p>
3231
+ )}
3232
+ </div>
3233
+ </SectionCard>
3234
+
3235
+ <ContractContentEditor
3236
+ value={form.contentHtml}
3237
+ onChange={(value) =>
3238
+ setForm((current) => ({ ...current, contentHtml: value }))
3239
+ }
3240
+ editorTitle="Texto do contrato"
3241
+ editorDescription="Edite o texto final em RichText."
3242
+ previewTitle="Preview"
3243
+ previewDescription="Visualize como o contrato esta ficando."
3244
+ previewFallbackHtml={
3245
+ form.description
3246
+ ? `<p>${form.description.replace(/\n/g, '<br />')}</p>`
3247
+ : undefined
3248
+ }
3249
+ promptContext={{
3250
+ name: form.name,
3251
+ client_name: form.clientName,
3252
+ start_date: form.startDate,
3253
+ end_date: form.endDate,
3254
+ budget_amount: form.budgetAmount,
3255
+ monthly_hour_cap: form.monthlyHourCap,
3256
+ }}
3257
+ compact
3258
+ descriptionMode="tooltip"
3259
+ layout="tabs"
3260
+ />
3261
+ </div>
3262
+ );
3263
+ }
3264
+
3265
+ return (
3266
+ <div className="space-y-4">
3267
+ <SectionCard
3268
+ title="Resumo do rascunho"
3269
+ description="Veja o que ja foi preenchido e conclua o contrato quando estiver satisfeito."
3270
+ compact
3271
+ descriptionMode="tooltip"
3272
+ >
3273
+ <div className="grid gap-3 md:grid-cols-2">
3274
+ {[
3275
+ ['Codigo', form.code || 'Nao informado'],
3276
+ ['Titulo', form.name || 'Nao informado'],
3277
+ ['Cliente', form.clientName || 'Nao informado'],
3278
+ [
3279
+ 'Vigencia',
3280
+ form.startDate
3281
+ ? `${formatDate(form.startDate)} - ${formatDate(form.endDate)}`
3282
+ : 'Nao informada',
3283
+ ],
3284
+ [
3285
+ 'Financeiro',
3286
+ form.budgetAmount
3287
+ ? formatCurrency(Number(form.budgetAmount || 0))
3288
+ : 'Opcional',
3289
+ ],
3290
+ ['Modo', getOptionLabel('creationModes', creationMode)],
3291
+ ].map(([label, value]) => (
3292
+ <div key={label} className="rounded-2xl border bg-muted/20 p-3">
3293
+ <div className="text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground">
3294
+ {label}
3295
+ </div>
3296
+ <div className="mt-1 text-sm font-medium">{value}</div>
3297
+ </div>
3298
+ ))}
3299
+ </div>
3300
+ </SectionCard>
3301
+
3302
+ {isCreateFlow ? (
3303
+ <SectionCard
3304
+ title="Proximo passo apos concluir"
3305
+ description="Ao concluir o cadastro, o sistema gera automaticamente o conteudo inicial do contrato e um checklist juridico orientativo para voce revisar no modo de edicao."
3306
+ compact
3307
+ descriptionMode="tooltip"
3308
+ >
3309
+ <p className="text-sm text-muted-foreground">
3310
+ O fluxo de criacao termina aqui. O texto do contrato fica
3311
+ concentrado na etapa de edicao, com apoio de IA para revisao e
3312
+ ajustes.
3313
+ </p>
3314
+ </SectionCard>
3315
+ ) : null}
3316
+
3317
+ {form.sourceDocument ? (
3318
+ <SectionCard
3319
+ title="Documento fonte"
3320
+ description="O documento original permanece relacionado ao contrato."
3321
+ compact
3322
+ descriptionMode="tooltip"
3323
+ >
3324
+ <div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border p-4">
3325
+ <div>
3326
+ <div className="font-medium">
3327
+ {form.sourceDocument.fileName}
3328
+ </div>
3329
+ <div className="text-sm text-muted-foreground">
3330
+ Extracao:{' '}
3331
+ {getOptionLabel(
3332
+ 'extractionStatuses',
3333
+ form.sourceDocument.extractionStatus
3334
+ )}
3335
+ </div>
3336
+ </div>
3337
+ {form.sourceDocument.fileId ? (
3338
+ <Button
3339
+ type="button"
3340
+ variant="outline"
3341
+ className="cursor-pointer"
3342
+ onClick={() => openStoredFile(form.sourceDocument?.fileId)}
3343
+ >
3344
+ <Download className="size-4" />
3345
+ Abrir fonte
3346
+ </Button>
3347
+ ) : null}
3348
+ </div>
3349
+ </SectionCard>
3350
+ ) : null}
3351
+
3352
+ {form.generatedPdfDocument ? (
3353
+ <SectionCard
3354
+ title="PDF gerado"
3355
+ description="O PDF timbrado mais recente fica salvo junto ao contrato."
3356
+ compact
3357
+ descriptionMode="tooltip"
3358
+ >
3359
+ <div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border p-4">
3360
+ <div>
3361
+ <div className="font-medium">
3362
+ {form.generatedPdfDocument.fileName}
3363
+ </div>
3364
+ <div className="text-sm text-muted-foreground">
3365
+ Pronto para download.
3366
+ </div>
3367
+ </div>
3368
+ {form.generatedPdfDocument.fileId ? (
3369
+ <Button
3370
+ type="button"
3371
+ variant="outline"
3372
+ className="cursor-pointer"
3373
+ onClick={() =>
3374
+ openStoredFile(form.generatedPdfDocument?.fileId)
3375
+ }
3376
+ >
3377
+ <Download className="size-4" />
3378
+ Baixar PDF
3379
+ </Button>
3380
+ ) : null}
3381
+ </div>
3382
+ </SectionCard>
3383
+ ) : null}
3384
+ </div>
3385
+ );
3386
+ })();
3387
+
3388
+ return (
3389
+ <div className="flex h-full flex-col">
3390
+ <div className="border-b px-4 py-4">
3391
+ <div className="flex items-start gap-3">
3392
+ <div className="rounded-2xl bg-primary/10 p-2.5 text-primary">
3393
+ {currentStep ? <currentStep.icon className="size-5" /> : null}
3394
+ </div>
3395
+ <div className="min-w-0 flex-1">
3396
+ <div className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">
3397
+ Contratos
3398
+ </div>
3399
+ <h2 className="text-lg font-semibold">
3400
+ {draftId
3401
+ ? form.name || form.code || 'Rascunho de contrato'
3402
+ : isCreateFlow
3403
+ ? 'Novo contrato'
3404
+ : 'Contrato'}
3405
+ </h2>
3406
+ </div>
3407
+ </div>
3408
+
3409
+ <div className="mt-4 grid grid-cols-3 gap-2 sm:grid-cols-6">
3410
+ {wizardSteps.map((step, index) => {
3411
+ const Icon = step.icon;
3412
+ const isCurrent = index === activeStep;
3413
+ const isEnabled = draftId !== null || index === 0 || contractId;
3414
+ return (
3415
+ <button
3416
+ key={step.key}
3417
+ type="button"
3418
+ disabled={!isEnabled}
3419
+ className={`flex items-center gap-2 rounded-xl border px-3 py-2 text-left text-xs transition-all ${
3420
+ isCurrent
3421
+ ? 'border-primary bg-primary/10 text-primary'
3422
+ : 'text-muted-foreground hover:border-primary/30 hover:text-foreground'
3423
+ } ${!isEnabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
3424
+ onClick={() => {
3425
+ if (!isEnabled) return;
3426
+ setActiveStep(index);
3427
+ }}
3428
+ >
3429
+ <Icon className="size-3.5" />
3430
+ <span className="truncate">{step.label}</span>
3431
+ </button>
3432
+ );
3433
+ })}
3434
+ </div>
3435
+ </div>
3436
+
3437
+ <div className="flex-1 overflow-y-auto px-4 py-4">{stepContent}</div>
3438
+
3439
+ <div className="border-t bg-background px-4 py-3">
3440
+ <div className="flex flex-wrap items-center justify-between gap-2">
3441
+ <Button
3442
+ type="button"
3443
+ variant="outline"
3444
+ className="cursor-pointer"
3445
+ onClick={
3446
+ activeStep === 0
3447
+ ? onCancel
3448
+ : () => setActiveStep((current) => Math.max(current - 1, 0))
3449
+ }
3450
+ >
3451
+ <ChevronLeft className="size-4" />
3452
+ {activeStep === 0 ? 'Cancelar' : 'Voltar'}
3453
+ </Button>
3454
+
3455
+ <div className="flex flex-wrap gap-2">
3456
+ {draftId ? (
3457
+ <Button
3458
+ type="button"
3459
+ variant="outline"
3460
+ className="cursor-pointer"
3461
+ disabled={isBusy}
3462
+ onClick={() => void saveDraft(activeStep)}
3463
+ >
3464
+ {isBusy ? (
3465
+ <LoaderCircle className="size-4 animate-spin" />
3466
+ ) : (
3467
+ <Save className="size-4" />
3468
+ )}
3469
+ Salvar rascunho
3470
+ </Button>
3471
+ ) : null}
3472
+
3473
+ {draftId &&
3474
+ Boolean(form.contentHtml?.trim()) &&
3475
+ (contentStepIndex < 0 || activeStep >= contentStepIndex) ? (
3476
+ <Button
3477
+ type="button"
3478
+ variant="outline"
3479
+ className="cursor-pointer"
3480
+ disabled={isGeneratingPdf}
3481
+ onClick={() => void handleGeneratePdf()}
3482
+ >
3483
+ {isGeneratingPdf ? (
3484
+ <LoaderCircle className="size-4 animate-spin" />
3485
+ ) : (
3486
+ <Download className="size-4" />
3487
+ )}
3488
+ Gerar PDF
3489
+ </Button>
3490
+ ) : null}
3491
+
3492
+ {activeStep === wizardSteps.length - 1 ? (
3493
+ <Button
3494
+ type="button"
3495
+ className="cursor-pointer"
3496
+ disabled={isBusy}
3497
+ onClick={() => void handleComplete()}
3498
+ >
3499
+ Concluir
3500
+ </Button>
3501
+ ) : (
3502
+ <Button
3503
+ type="button"
3504
+ className="cursor-pointer"
3505
+ disabled={isBusy || isExtracting}
3506
+ onClick={() => void handleNext()}
3507
+ >
3508
+ {isBusy ? (
3509
+ <LoaderCircle className="size-4 animate-spin" />
3510
+ ) : null}
3511
+ Proximo
3512
+ <ChevronRight className="size-4" />
3513
+ </Button>
3514
+ )}
3515
+ </div>
3516
+ </div>
3517
+ </div>
3518
+ </div>
3519
+ );
3520
+ }