@hed-hog/operations 0.0.297 → 0.0.299

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 (32) hide show
  1. package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +310 -310
  2. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +631 -631
  3. package/hedhog/frontend/app/_components/contract-details-screen.tsx.ejs +132 -132
  4. package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +558 -558
  5. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +291 -291
  6. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +689 -689
  7. package/hedhog/frontend/app/_lib/api.ts.ejs +32 -32
  8. package/hedhog/frontend/app/_lib/hooks/use-operations-access.ts.ejs +44 -44
  9. package/hedhog/frontend/app/_lib/types.ts.ejs +360 -360
  10. package/hedhog/frontend/app/_lib/utils/format.ts.ejs +129 -129
  11. package/hedhog/frontend/app/_lib/utils/forms.ts.ejs +14 -14
  12. package/hedhog/frontend/app/approvals/page.tsx.ejs +386 -386
  13. package/hedhog/frontend/app/collaborators/[id]/edit/page.tsx.ejs +11 -11
  14. package/hedhog/frontend/app/collaborators/[id]/page.tsx.ejs +11 -11
  15. package/hedhog/frontend/app/collaborators/new/page.tsx.ejs +5 -5
  16. package/hedhog/frontend/app/collaborators/page.tsx.ejs +261 -261
  17. package/hedhog/frontend/app/contracts/[id]/edit/page.tsx.ejs +11 -11
  18. package/hedhog/frontend/app/contracts/[id]/page.tsx.ejs +11 -11
  19. package/hedhog/frontend/app/contracts/new/page.tsx.ejs +17 -17
  20. package/hedhog/frontend/app/contracts/page.tsx.ejs +262 -262
  21. package/hedhog/frontend/app/page.tsx.ejs +319 -319
  22. package/hedhog/frontend/app/projects/[id]/edit/page.tsx.ejs +11 -11
  23. package/hedhog/frontend/app/projects/[id]/page.tsx.ejs +11 -11
  24. package/hedhog/frontend/app/projects/new/page.tsx.ejs +5 -5
  25. package/hedhog/frontend/app/projects/page.tsx.ejs +236 -236
  26. package/hedhog/frontend/app/schedule-adjustments/page.tsx.ejs +418 -418
  27. package/hedhog/frontend/app/team/page.tsx.ejs +339 -339
  28. package/hedhog/frontend/app/time-off/page.tsx.ejs +328 -328
  29. package/hedhog/frontend/app/timesheets/page.tsx.ejs +636 -636
  30. package/hedhog/frontend/messages/en.json +648 -648
  31. package/hedhog/frontend/messages/pt.json +647 -647
  32. package/package.json +4 -4
@@ -1,558 +1,558 @@
1
- 'use client';
2
-
3
- import { EmptyState, Page } from '@/components/entity-list';
4
- import { RichTextEditor } from '@/components/rich-text-editor';
5
- import { Button } from '@/components/ui/button';
6
- import { Input } from '@/components/ui/input';
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
16
- import { Textarea } from '@/components/ui/textarea';
17
- import { useApp, useQuery } from '@hed-hog/next-app-provider';
18
- import { ArrowLeft, FileText, Plus, Save } from 'lucide-react';
19
- import Link from 'next/link';
20
- import { useRouter } from 'next/navigation';
21
- import { useEffect, useState } from 'react';
22
- import { useTranslations } from 'next-intl';
23
- import { OperationsHeader } from './operations-header';
24
- import { SectionCard } from './section-card';
25
- import { fetchOperations, mutateOperations } from '../_lib/api';
26
- import { useOperationsAccess } from '../_lib/hooks/use-operations-access';
27
- import type {
28
- OperationsCollaborator,
29
- OperationsContractDetails,
30
- } from '../_lib/types';
31
- import { parseNumberInput, trimToNull } from '../_lib/utils/forms';
32
-
33
- type PartyState = {
34
- displayName: string;
35
- partyRole: string;
36
- partyType: string;
37
- documentNumber: string;
38
- email: string;
39
- phone: string;
40
- isPrimary: boolean;
41
- };
42
-
43
- type SignatureState = {
44
- signerName: string;
45
- signerRole: string;
46
- signerEmail: string;
47
- status: string;
48
- signedAt: string;
49
- };
50
-
51
- type FinancialTermState = {
52
- label: string;
53
- termType: string;
54
- amount: string;
55
- recurrence: string;
56
- dueDay: string;
57
- notes: string;
58
- };
59
-
60
- type RevisionState = {
61
- title: string;
62
- revisionType: string;
63
- effectiveDate: string;
64
- status: string;
65
- summary: string;
66
- };
67
-
68
- type ContractFormState = {
69
- code: string;
70
- name: string;
71
- clientName: string;
72
- contractCategory: string;
73
- contractType: string;
74
- signatureStatus: string;
75
- isActive: boolean;
76
- billingModel: string;
77
- accountManagerCollaboratorId: string;
78
- relatedCollaboratorId: string;
79
- originType: string;
80
- originId: string;
81
- startDate: string;
82
- endDate: string;
83
- signedAt: string;
84
- effectiveDate: string;
85
- budgetAmount: string;
86
- monthlyHourCap: string;
87
- status: string;
88
- description: string;
89
- contentHtml: string;
90
- parties: PartyState[];
91
- signatures: SignatureState[];
92
- financialTerms: FinancialTermState[];
93
- revisions: RevisionState[];
94
- pdfDocument: {
95
- fileName: string;
96
- mimeType: string;
97
- fileContentBase64: string;
98
- } | null;
99
- };
100
-
101
- function emptyParty(): PartyState {
102
- return {
103
- displayName: '',
104
- partyRole: 'client',
105
- partyType: 'company',
106
- documentNumber: '',
107
- email: '',
108
- phone: '',
109
- isPrimary: false,
110
- };
111
- }
112
-
113
- function emptySignature(): SignatureState {
114
- return {
115
- signerName: '',
116
- signerRole: '',
117
- signerEmail: '',
118
- status: 'pending',
119
- signedAt: '',
120
- };
121
- }
122
-
123
- function emptyFinancialTerm(): FinancialTermState {
124
- return {
125
- label: '',
126
- termType: 'value',
127
- amount: '',
128
- recurrence: 'one_time',
129
- dueDay: '',
130
- notes: '',
131
- };
132
- }
133
-
134
- function emptyRevision(): RevisionState {
135
- return {
136
- title: '',
137
- revisionType: 'revision',
138
- effectiveDate: '',
139
- status: 'draft',
140
- summary: '',
141
- };
142
- }
143
-
144
- function buildEmptyForm(): ContractFormState {
145
- return {
146
- code: '',
147
- name: '',
148
- clientName: '',
149
- contractCategory: 'client',
150
- contractType: 'service_agreement',
151
- signatureStatus: 'not_started',
152
- isActive: true,
153
- billingModel: 'time_and_material',
154
- accountManagerCollaboratorId: 'none',
155
- relatedCollaboratorId: 'none',
156
- originType: 'manual',
157
- originId: '',
158
- startDate: '',
159
- endDate: '',
160
- signedAt: '',
161
- effectiveDate: '',
162
- budgetAmount: '',
163
- monthlyHourCap: '',
164
- status: 'draft',
165
- description: '',
166
- contentHtml: '',
167
- parties: [emptyParty()],
168
- signatures: [emptySignature()],
169
- financialTerms: [emptyFinancialTerm()],
170
- revisions: [],
171
- pdfDocument: null,
172
- };
173
- }
174
-
175
- function toFormState(contract: OperationsContractDetails): ContractFormState {
176
- return {
177
- code: contract.code ?? '',
178
- name: contract.name ?? '',
179
- clientName: contract.clientName ?? '',
180
- contractCategory: contract.contractCategory ?? 'client',
181
- contractType: contract.contractType ?? 'service_agreement',
182
- signatureStatus: contract.signatureStatus ?? 'not_started',
183
- isActive: contract.isActive ?? true,
184
- billingModel: contract.billingModel ?? 'time_and_material',
185
- accountManagerCollaboratorId: contract.accountManagerCollaboratorId
186
- ? String(contract.accountManagerCollaboratorId)
187
- : 'none',
188
- relatedCollaboratorId: contract.relatedCollaboratorId
189
- ? String(contract.relatedCollaboratorId)
190
- : 'none',
191
- originType: contract.originType ?? 'manual',
192
- originId: contract.originId ? String(contract.originId) : '',
193
- startDate: contract.startDate ?? '',
194
- endDate: contract.endDate ?? '',
195
- signedAt: contract.signedAt ?? '',
196
- effectiveDate: contract.effectiveDate ?? '',
197
- budgetAmount:
198
- contract.budgetAmount !== null && contract.budgetAmount !== undefined
199
- ? String(contract.budgetAmount)
200
- : '',
201
- monthlyHourCap:
202
- contract.monthlyHourCap !== null && contract.monthlyHourCap !== undefined
203
- ? String(contract.monthlyHourCap)
204
- : '',
205
- status: contract.status ?? 'draft',
206
- description: contract.description ?? '',
207
- contentHtml: contract.contentHtml ?? '',
208
- parties: contract.parties.length
209
- ? contract.parties.map((party) => ({
210
- displayName: party.displayName ?? '',
211
- partyRole: party.partyRole ?? 'other',
212
- partyType: party.partyType ?? 'company',
213
- documentNumber: party.documentNumber ?? '',
214
- email: party.email ?? '',
215
- phone: party.phone ?? '',
216
- isPrimary: party.isPrimary ?? false,
217
- }))
218
- : [emptyParty()],
219
- signatures: contract.signatures.length
220
- ? contract.signatures.map((signature) => ({
221
- signerName: signature.signerName ?? '',
222
- signerRole: signature.signerRole ?? '',
223
- signerEmail: signature.signerEmail ?? '',
224
- status: signature.status ?? 'pending',
225
- signedAt: signature.signedAt ?? '',
226
- }))
227
- : [emptySignature()],
228
- financialTerms: contract.financialTerms.length
229
- ? contract.financialTerms.map((term) => ({
230
- label: term.label ?? '',
231
- termType: term.termType ?? 'value',
232
- amount: String(term.amount ?? ''),
233
- recurrence: term.recurrence ?? 'one_time',
234
- dueDay:
235
- term.dueDay !== null && term.dueDay !== undefined
236
- ? String(term.dueDay)
237
- : '',
238
- notes: term.notes ?? '',
239
- }))
240
- : [emptyFinancialTerm()],
241
- revisions: contract.revisions.map((revision) => ({
242
- title: revision.title ?? '',
243
- revisionType: revision.revisionType ?? 'revision',
244
- effectiveDate: revision.effectiveDate ?? '',
245
- status: revision.status ?? 'draft',
246
- summary: revision.summary ?? '',
247
- })),
248
- pdfDocument: null,
249
- };
250
- }
251
-
252
- async function fileToBase64(file: File) {
253
- return new Promise<string>((resolve, reject) => {
254
- const reader = new FileReader();
255
- reader.onload = () => {
256
- const result = String(reader.result ?? '');
257
- const [, base64 = ''] = result.split(',');
258
- resolve(base64);
259
- };
260
- reader.onerror = reject;
261
- reader.readAsDataURL(file);
262
- });
263
- }
264
-
265
- export function ContractFormScreen({
266
- contractId,
267
- duplicateFromId,
268
- }: {
269
- contractId?: number;
270
- duplicateFromId?: number;
271
- }) {
272
- const t = useTranslations('operations.ContractFormPage');
273
- const commonT = useTranslations('operations.Common');
274
- const { request, showToastHandler, currentLocaleCode } = useApp();
275
- const access = useOperationsAccess();
276
- const router = useRouter();
277
- const [form, setForm] = useState<ContractFormState>(buildEmptyForm());
278
-
279
- const sourceId = contractId ?? duplicateFromId;
280
-
281
- const { data: collaborators = [] } = useQuery<OperationsCollaborator[]>({
282
- queryKey: ['operations-contract-form-collaborators', currentLocaleCode],
283
- enabled: access.isDirector,
284
- queryFn: () =>
285
- fetchOperations<OperationsCollaborator[]>(request, '/operations/collaborators'),
286
- });
287
-
288
- const { data: contract, isLoading: isLoadingContract } =
289
- useQuery<OperationsContractDetails>({
290
- queryKey: ['operations-contract-form', currentLocaleCode, sourceId],
291
- enabled: Boolean(sourceId),
292
- queryFn: () =>
293
- fetchOperations<OperationsContractDetails>(
294
- request,
295
- `/operations/contracts/${sourceId}`
296
- ),
297
- });
298
-
299
- useEffect(() => {
300
- if (!contract) {
301
- return;
302
- }
303
-
304
- const next = toFormState(contract);
305
- if (duplicateFromId) {
306
- next.code = `${contract.code}-COPY`;
307
- next.name = `${contract.name} Copy`;
308
- next.originType = 'manual';
309
- next.originId = '';
310
- next.status = 'draft';
311
- next.signatureStatus = 'not_started';
312
- next.signedAt = '';
313
- next.isActive = true;
314
- }
315
- setForm(next);
316
- }, [contract, duplicateFromId]);
317
-
318
- const updateArrayItem = <T,>(
319
- key: 'parties' | 'signatures' | 'financialTerms' | 'revisions',
320
- index: number,
321
- patch: Partial<T>
322
- ) => {
323
- setForm((current) => ({
324
- ...current,
325
- [key]: (current[key] as T[]).map((item, itemIndex) =>
326
- itemIndex === index ? { ...item, ...patch } : item
327
- ),
328
- }));
329
- };
330
-
331
- const onSubmit = async () => {
332
- if (!form.code.trim() || !form.name.trim() || !form.clientName.trim()) {
333
- showToastHandler?.('error', t('messages.requiredFields'));
334
- return;
335
- }
336
-
337
- const payload = {
338
- code: form.code.trim(),
339
- name: form.name.trim(),
340
- clientName: form.clientName.trim(),
341
- contractCategory: form.contractCategory,
342
- contractType: form.contractType,
343
- signatureStatus: form.signatureStatus,
344
- isActive: form.isActive,
345
- billingModel: form.billingModel,
346
- accountManagerCollaboratorId:
347
- form.accountManagerCollaboratorId === 'none'
348
- ? null
349
- : parseNumberInput(form.accountManagerCollaboratorId),
350
- relatedCollaboratorId:
351
- form.relatedCollaboratorId === 'none'
352
- ? null
353
- : parseNumberInput(form.relatedCollaboratorId),
354
- originType: form.originType,
355
- originId: parseNumberInput(form.originId),
356
- startDate: form.startDate,
357
- endDate: trimToNull(form.endDate),
358
- signedAt: trimToNull(form.signedAt),
359
- effectiveDate: trimToNull(form.effectiveDate),
360
- budgetAmount: parseNumberInput(form.budgetAmount),
361
- monthlyHourCap: parseNumberInput(form.monthlyHourCap),
362
- status: form.status,
363
- description: trimToNull(form.description),
364
- contentHtml: trimToNull(form.contentHtml),
365
- parties: form.parties
366
- .filter((party) => party.displayName.trim())
367
- .map((party) => ({
368
- displayName: party.displayName.trim(),
369
- partyRole: party.partyRole,
370
- partyType: party.partyType,
371
- documentNumber: trimToNull(party.documentNumber),
372
- email: trimToNull(party.email),
373
- phone: trimToNull(party.phone),
374
- isPrimary: party.isPrimary,
375
- })),
376
- signatures: form.signatures
377
- .filter((signature) => signature.signerName.trim())
378
- .map((signature) => ({
379
- signerName: signature.signerName.trim(),
380
- signerRole: trimToNull(signature.signerRole),
381
- signerEmail: trimToNull(signature.signerEmail),
382
- status: signature.status,
383
- signedAt: trimToNull(signature.signedAt),
384
- })),
385
- financialTerms: form.financialTerms
386
- .filter((term) => term.label.trim())
387
- .map((term) => ({
388
- label: term.label.trim(),
389
- termType: term.termType,
390
- amount: parseNumberInput(term.amount) ?? 0,
391
- recurrence: term.recurrence,
392
- dueDay: parseNumberInput(term.dueDay),
393
- notes: trimToNull(term.notes),
394
- })),
395
- revisions: form.revisions
396
- .filter((revision) => revision.title.trim())
397
- .map((revision) => ({
398
- title: revision.title.trim(),
399
- revisionType: revision.revisionType,
400
- effectiveDate: trimToNull(revision.effectiveDate),
401
- status: revision.status,
402
- summary: trimToNull(revision.summary),
403
- })),
404
- replaceUploadedPdfDocument: form.pdfDocument,
405
- };
406
-
407
- try {
408
- const response = contractId
409
- ? await mutateOperations<OperationsContractDetails>(
410
- request,
411
- `/operations/contracts/${contractId}`,
412
- 'PATCH',
413
- payload
414
- )
415
- : await mutateOperations<OperationsContractDetails>(
416
- request,
417
- '/operations/contracts',
418
- 'POST',
419
- payload
420
- );
421
-
422
- showToastHandler?.(
423
- 'success',
424
- contractId ? t('messages.updateSuccess') : t('messages.createSuccess')
425
- );
426
- router.push(`/operations/contracts/${response.id}`);
427
- } catch {
428
- showToastHandler?.(
429
- 'error',
430
- contractId ? t('messages.updateError') : t('messages.createError')
431
- );
432
- }
433
- };
434
-
435
- if (!access.isDirector && !access.isLoading) {
436
- return (
437
- <Page>
438
- <OperationsHeader title={t(contractId ? 'editTitle' : 'newTitle')} description={t('description')} current={t('breadcrumb')} />
439
- <EmptyState
440
- icon={<FileText className="size-12" />}
441
- title={commonT('states.noAccessTitle')}
442
- description={t('noAccessDescription')}
443
- actionLabel={commonT('actions.refresh')}
444
- onAction={() => router.refresh()}
445
- />
446
- </Page>
447
- );
448
- }
449
-
450
- return (
451
- <Page>
452
- <OperationsHeader
453
- title={duplicateFromId ? t('duplicateTitle') : t(contractId ? 'editTitle' : 'newTitle')}
454
- description={t('description')}
455
- current={t('breadcrumb')}
456
- actions={
457
- <div className="flex gap-2">
458
- <Button variant="outline" size="sm" asChild>
459
- <Link href={contractId ? `/operations/contracts/${contractId}` : '/operations/contracts'}>
460
- <ArrowLeft className="size-4" />
461
- {commonT('actions.back')}
462
- </Link>
463
- </Button>
464
- <Button size="sm" onClick={() => void onSubmit()}>
465
- <Save className="size-4" />
466
- {commonT('actions.save')}
467
- </Button>
468
- </div>
469
- }
470
- />
471
-
472
- <Tabs defaultValue="overview">
473
- <TabsList className="flex-wrap">
474
- <TabsTrigger value="overview">{t('tabs.overview')}</TabsTrigger>
475
- <TabsTrigger value="parties">{t('tabs.parties')}</TabsTrigger>
476
- <TabsTrigger value="signatures">{t('tabs.signatures')}</TabsTrigger>
477
- <TabsTrigger value="financials">{t('tabs.financials')}</TabsTrigger>
478
- <TabsTrigger value="documents">{t('tabs.documents')}</TabsTrigger>
479
- <TabsTrigger value="revisions">{t('tabs.revisions')}</TabsTrigger>
480
- <TabsTrigger value="editor">{t('tabs.editor')}</TabsTrigger>
481
- </TabsList>
482
- <TabsContent value="overview">
483
- <SectionCard title={t('sections.overview')} description={t('sections.overviewDescription')}>
484
- <div className="grid gap-4 md:grid-cols-3">
485
- <Input placeholder={t('fields.code')} value={form.code} onChange={(e) => setForm((c) => ({ ...c, code: e.target.value }))} />
486
- <Input className="md:col-span-2" placeholder={t('fields.name')} value={form.name} onChange={(e) => setForm((c) => ({ ...c, name: e.target.value }))} />
487
- <Input placeholder={t('fields.clientName')} value={form.clientName} onChange={(e) => setForm((c) => ({ ...c, clientName: e.target.value }))} />
488
- <Select value={form.contractCategory} onValueChange={(value) => setForm((c) => ({ ...c, contractCategory: value }))}>
489
- <SelectTrigger><SelectValue placeholder={t('fields.contractCategory')} /></SelectTrigger>
490
- <SelectContent>{['employee','contractor','client','supplier','vendor','partner','internal','other'].map((value) => <SelectItem key={value} value={value}>{value}</SelectItem>)}</SelectContent>
491
- </Select>
492
- <Select value={form.contractType} onValueChange={(value) => setForm((c) => ({ ...c, contractType: value }))}>
493
- <SelectTrigger><SelectValue placeholder={t('fields.contractType')} /></SelectTrigger>
494
- <SelectContent>{['clt','pj','freelancer_agreement','service_agreement','fixed_term','recurring_service','nda','amendment','addendum','other'].map((value) => <SelectItem key={value} value={value}>{value}</SelectItem>)}</SelectContent>
495
- </Select>
496
- <Select value={form.originType} onValueChange={(value) => setForm((c) => ({ ...c, originType: value }))}>
497
- <SelectTrigger><SelectValue placeholder={t('fields.originType')} /></SelectTrigger>
498
- <SelectContent>{['manual','employee_hiring','client_project'].map((value) => <SelectItem key={value} value={value}>{value}</SelectItem>)}</SelectContent>
499
- </Select>
500
- <Input placeholder={t('fields.originId')} value={form.originId} onChange={(e) => setForm((c) => ({ ...c, originId: e.target.value }))} />
501
- <Select value={form.billingModel} onValueChange={(value) => setForm((c) => ({ ...c, billingModel: value }))}>
502
- <SelectTrigger><SelectValue placeholder={commonT('labels.billingModel')} /></SelectTrigger>
503
- <SelectContent>{['time_and_material','monthly_retainer','fixed_price'].map((value) => <SelectItem key={value} value={value}>{value}</SelectItem>)}</SelectContent>
504
- </Select>
505
- <Select value={form.signatureStatus} onValueChange={(value) => setForm((c) => ({ ...c, signatureStatus: value }))}>
506
- <SelectTrigger><SelectValue placeholder={t('fields.signatureStatus')} /></SelectTrigger>
507
- <SelectContent>{['not_started','pending','partially_signed','signed','expired'].map((value) => <SelectItem key={value} value={value}>{value}</SelectItem>)}</SelectContent>
508
- </Select>
509
- <Select value={form.status} onValueChange={(value) => setForm((c) => ({ ...c, status: value }))}>
510
- <SelectTrigger><SelectValue placeholder={commonT('labels.status')} /></SelectTrigger>
511
- <SelectContent>{['draft','under_review','active','renewal','expired','closed','archived'].map((value) => <SelectItem key={value} value={value}>{value}</SelectItem>)}</SelectContent>
512
- </Select>
513
- <Select value={form.accountManagerCollaboratorId} onValueChange={(value) => setForm((c) => ({ ...c, accountManagerCollaboratorId: value }))}>
514
- <SelectTrigger><SelectValue placeholder={commonT('labels.accountManager')} /></SelectTrigger>
515
- <SelectContent>
516
- <SelectItem value="none">{commonT('labels.notAssigned')}</SelectItem>
517
- {collaborators.map((collaborator) => <SelectItem key={collaborator.id} value={String(collaborator.id)}>{collaborator.displayName}</SelectItem>)}
518
- </SelectContent>
519
- </Select>
520
- <Select value={form.relatedCollaboratorId} onValueChange={(value) => setForm((c) => ({ ...c, relatedCollaboratorId: value }))}>
521
- <SelectTrigger><SelectValue placeholder={commonT('labels.collaborator')} /></SelectTrigger>
522
- <SelectContent>
523
- <SelectItem value="none">{commonT('labels.notAssigned')}</SelectItem>
524
- {collaborators.map((collaborator) => <SelectItem key={collaborator.id} value={String(collaborator.id)}>{collaborator.displayName}</SelectItem>)}
525
- </SelectContent>
526
- </Select>
527
- <Input type="date" value={form.startDate} onChange={(e) => setForm((c) => ({ ...c, startDate: e.target.value }))} />
528
- <Input type="date" value={form.endDate} onChange={(e) => setForm((c) => ({ ...c, endDate: e.target.value }))} />
529
- <Input type="date" value={form.effectiveDate} onChange={(e) => setForm((c) => ({ ...c, effectiveDate: e.target.value }))} />
530
- <Input type="date" value={form.signedAt} onChange={(e) => setForm((c) => ({ ...c, signedAt: e.target.value }))} />
531
- <Input type="number" step="0.01" placeholder={t('fields.budgetAmount')} value={form.budgetAmount} onChange={(e) => setForm((c) => ({ ...c, budgetAmount: e.target.value }))} />
532
- <Input type="number" placeholder={t('fields.monthlyHourCap')} value={form.monthlyHourCap} onChange={(e) => setForm((c) => ({ ...c, monthlyHourCap: e.target.value }))} />
533
- <div className="flex items-center justify-between rounded-lg border px-4 py-3 md:col-span-3">
534
- <div>
535
- <div className="font-medium">{t('fields.isActive')}</div>
536
- <div className="text-sm text-muted-foreground">{t('fields.isActiveDescription')}</div>
537
- </div>
538
- <Switch checked={form.isActive} onCheckedChange={(checked) => setForm((c) => ({ ...c, isActive: checked }))} />
539
- </div>
540
- <Textarea className="md:col-span-3" rows={4} placeholder={commonT('labels.description')} value={form.description} onChange={(e) => setForm((c) => ({ ...c, description: e.target.value }))} />
541
- </div>
542
- </SectionCard>
543
- </TabsContent>
544
-
545
- <TabsContent value="parties"><SectionCard title={t('sections.parties')} description={t('sections.partiesDescription')}><div className="space-y-4">{form.parties.map((party, index) => <div key={index} className="grid gap-4 rounded-lg border p-4 md:grid-cols-3"><Input placeholder={t('fields.partyDisplayName')} value={party.displayName} onChange={(e) => updateArrayItem<PartyState>('parties', index, { displayName: e.target.value })} /><Input placeholder={t('fields.partyRole')} value={party.partyRole} onChange={(e) => updateArrayItem<PartyState>('parties', index, { partyRole: e.target.value })} /><Input placeholder={t('fields.partyType')} value={party.partyType} onChange={(e) => updateArrayItem<PartyState>('parties', index, { partyType: e.target.value })} /><Input placeholder={t('fields.documentNumber')} value={party.documentNumber} onChange={(e) => updateArrayItem<PartyState>('parties', index, { documentNumber: e.target.value })} /><Input placeholder={t('fields.email')} value={party.email} onChange={(e) => updateArrayItem<PartyState>('parties', index, { email: e.target.value })} /><Input placeholder={t('fields.phone')} value={party.phone} onChange={(e) => updateArrayItem<PartyState>('parties', index, { phone: e.target.value })} /><div className="flex items-center justify-between rounded-md border px-3 py-2 md:col-span-3"><span className="text-sm">{t('fields.isPrimaryParty')}</span><Switch checked={party.isPrimary} onCheckedChange={(checked) => updateArrayItem<PartyState>('parties', index, { isPrimary: checked })} /></div></div>)}<Button variant="outline" onClick={() => setForm((c) => ({ ...c, parties: [...c.parties, emptyParty()] }))}><Plus className="size-4" />{commonT('actions.addLine')}</Button></div></SectionCard></TabsContent>
546
- <TabsContent value="signatures"><SectionCard title={t('sections.signatures')} description={t('sections.signaturesDescription')}><div className="space-y-4">{form.signatures.map((signature, index) => <div key={index} className="grid gap-4 rounded-lg border p-4 md:grid-cols-2"><Input placeholder={t('fields.signerName')} value={signature.signerName} onChange={(e) => updateArrayItem<SignatureState>('signatures', index, { signerName: e.target.value })} /><Input placeholder={t('fields.signerRole')} value={signature.signerRole} onChange={(e) => updateArrayItem<SignatureState>('signatures', index, { signerRole: e.target.value })} /><Input placeholder={t('fields.signerEmail')} value={signature.signerEmail} onChange={(e) => updateArrayItem<SignatureState>('signatures', index, { signerEmail: e.target.value })} /><Input placeholder={t('fields.signatureItemStatus')} value={signature.status} onChange={(e) => updateArrayItem<SignatureState>('signatures', index, { status: e.target.value })} /><Input type="date" value={signature.signedAt} onChange={(e) => updateArrayItem<SignatureState>('signatures', index, { signedAt: e.target.value })} /></div>)}<Button variant="outline" onClick={() => setForm((c) => ({ ...c, signatures: [...c.signatures, emptySignature()] }))}><Plus className="size-4" />{commonT('actions.addLine')}</Button></div></SectionCard></TabsContent>
547
- <TabsContent value="financials"><SectionCard title={t('sections.financials')} description={t('sections.financialsDescription')}><div className="space-y-4">{form.financialTerms.map((term, index) => <div key={index} className="grid gap-4 rounded-lg border p-4 md:grid-cols-3"><Input placeholder={t('fields.financialLabel')} value={term.label} onChange={(e) => updateArrayItem<FinancialTermState>('financialTerms', index, { label: e.target.value })} /><Input placeholder={t('fields.termType')} value={term.termType} onChange={(e) => updateArrayItem<FinancialTermState>('financialTerms', index, { termType: e.target.value })} /><Input type="number" step="0.01" placeholder={t('fields.amount')} value={term.amount} onChange={(e) => updateArrayItem<FinancialTermState>('financialTerms', index, { amount: e.target.value })} /><Input placeholder={t('fields.recurrence')} value={term.recurrence} onChange={(e) => updateArrayItem<FinancialTermState>('financialTerms', index, { recurrence: e.target.value })} /><Input type="number" placeholder={t('fields.dueDay')} value={term.dueDay} onChange={(e) => updateArrayItem<FinancialTermState>('financialTerms', index, { dueDay: e.target.value })} /><Input placeholder={t('fields.notes')} value={term.notes} onChange={(e) => updateArrayItem<FinancialTermState>('financialTerms', index, { notes: e.target.value })} /></div>)}<Button variant="outline" onClick={() => setForm((c) => ({ ...c, financialTerms: [...c.financialTerms, emptyFinancialTerm()] }))}><Plus className="size-4" />{commonT('actions.addLine')}</Button></div></SectionCard></TabsContent>
548
- <TabsContent value="documents"><SectionCard title={t('sections.documents')} description={t('sections.documentsDescription')}><div className="space-y-4"><input type="file" accept="application/pdf" onChange={async (event) => { const file = event.target.files?.[0]; if (!file) return; const fileContentBase64 = await fileToBase64(file); setForm((current) => ({ ...current, pdfDocument: { fileName: file.name, mimeType: file.type || 'application/pdf', fileContentBase64 } })); }} /><p className="text-sm text-muted-foreground">{form.pdfDocument ? t('messages.pdfReady', { name: form.pdfDocument.fileName }) : t('messages.pdfHint')}</p></div></SectionCard></TabsContent>
549
- <TabsContent value="revisions"><SectionCard title={t('sections.revisions')} description={t('sections.revisionsDescription')}><div className="space-y-4">{form.revisions.map((revision, index) => <div key={index} className="grid gap-4 rounded-lg border p-4 md:grid-cols-2"><Input placeholder={t('fields.revisionTitle')} value={revision.title} onChange={(e) => updateArrayItem<RevisionState>('revisions', index, { title: e.target.value })} /><Input placeholder={t('fields.revisionType')} value={revision.revisionType} onChange={(e) => updateArrayItem<RevisionState>('revisions', index, { revisionType: e.target.value })} /><Input type="date" value={revision.effectiveDate} onChange={(e) => updateArrayItem<RevisionState>('revisions', index, { effectiveDate: e.target.value })} /><Input placeholder={t('fields.revisionStatus')} value={revision.status} onChange={(e) => updateArrayItem<RevisionState>('revisions', index, { status: e.target.value })} /><Textarea className="md:col-span-2" rows={3} placeholder={t('fields.summary')} value={revision.summary} onChange={(e) => updateArrayItem<RevisionState>('revisions', index, { summary: e.target.value })} /></div>)}<Button variant="outline" onClick={() => setForm((c) => ({ ...c, revisions: [...c.revisions, emptyRevision()] }))}><Plus className="size-4" />{commonT('actions.addLine')}</Button></div></SectionCard></TabsContent>
550
- <TabsContent value="editor"><div className="grid gap-4 xl:grid-cols-2"><SectionCard title={t('sections.editor')} description={t('sections.editorDescription')}><RichTextEditor value={form.contentHtml} onChange={(value) => setForm((c) => ({ ...c, contentHtml: value }))} /></SectionCard><SectionCard title={t('sections.preview')} description={t('sections.previewDescription')}><div className="prose prose-sm max-w-none rounded-lg border p-4" dangerouslySetInnerHTML={{ __html: form.contentHtml || '<p>No contract content yet.</p>' }} /></SectionCard></div></TabsContent>
551
- </Tabs>
552
-
553
- {sourceId && isLoadingContract ? (
554
- <div className="text-sm text-muted-foreground">{t('loading')}</div>
555
- ) : null}
556
- </Page>
557
- );
558
- }
1
+ 'use client';
2
+
3
+ import { EmptyState, Page } from '@/components/entity-list';
4
+ import { RichTextEditor } from '@/components/rich-text-editor';
5
+ import { Button } from '@/components/ui/button';
6
+ import { Input } from '@/components/ui/input';
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
16
+ import { Textarea } from '@/components/ui/textarea';
17
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
18
+ import { ArrowLeft, FileText, Plus, Save } from 'lucide-react';
19
+ import Link from 'next/link';
20
+ import { useRouter } from 'next/navigation';
21
+ import { useEffect, useState } from 'react';
22
+ import { useTranslations } from 'next-intl';
23
+ import { OperationsHeader } from './operations-header';
24
+ import { SectionCard } from './section-card';
25
+ import { fetchOperations, mutateOperations } from '../_lib/api';
26
+ import { useOperationsAccess } from '../_lib/hooks/use-operations-access';
27
+ import type {
28
+ OperationsCollaborator,
29
+ OperationsContractDetails,
30
+ } from '../_lib/types';
31
+ import { parseNumberInput, trimToNull } from '../_lib/utils/forms';
32
+
33
+ type PartyState = {
34
+ displayName: string;
35
+ partyRole: string;
36
+ partyType: string;
37
+ documentNumber: string;
38
+ email: string;
39
+ phone: string;
40
+ isPrimary: boolean;
41
+ };
42
+
43
+ type SignatureState = {
44
+ signerName: string;
45
+ signerRole: string;
46
+ signerEmail: string;
47
+ status: string;
48
+ signedAt: string;
49
+ };
50
+
51
+ type FinancialTermState = {
52
+ label: string;
53
+ termType: string;
54
+ amount: string;
55
+ recurrence: string;
56
+ dueDay: string;
57
+ notes: string;
58
+ };
59
+
60
+ type RevisionState = {
61
+ title: string;
62
+ revisionType: string;
63
+ effectiveDate: string;
64
+ status: string;
65
+ summary: string;
66
+ };
67
+
68
+ type ContractFormState = {
69
+ code: string;
70
+ name: string;
71
+ clientName: string;
72
+ contractCategory: string;
73
+ contractType: string;
74
+ signatureStatus: string;
75
+ isActive: boolean;
76
+ billingModel: string;
77
+ accountManagerCollaboratorId: string;
78
+ relatedCollaboratorId: string;
79
+ originType: string;
80
+ originId: string;
81
+ startDate: string;
82
+ endDate: string;
83
+ signedAt: string;
84
+ effectiveDate: string;
85
+ budgetAmount: string;
86
+ monthlyHourCap: string;
87
+ status: string;
88
+ description: string;
89
+ contentHtml: string;
90
+ parties: PartyState[];
91
+ signatures: SignatureState[];
92
+ financialTerms: FinancialTermState[];
93
+ revisions: RevisionState[];
94
+ pdfDocument: {
95
+ fileName: string;
96
+ mimeType: string;
97
+ fileContentBase64: string;
98
+ } | null;
99
+ };
100
+
101
+ function emptyParty(): PartyState {
102
+ return {
103
+ displayName: '',
104
+ partyRole: 'client',
105
+ partyType: 'company',
106
+ documentNumber: '',
107
+ email: '',
108
+ phone: '',
109
+ isPrimary: false,
110
+ };
111
+ }
112
+
113
+ function emptySignature(): SignatureState {
114
+ return {
115
+ signerName: '',
116
+ signerRole: '',
117
+ signerEmail: '',
118
+ status: 'pending',
119
+ signedAt: '',
120
+ };
121
+ }
122
+
123
+ function emptyFinancialTerm(): FinancialTermState {
124
+ return {
125
+ label: '',
126
+ termType: 'value',
127
+ amount: '',
128
+ recurrence: 'one_time',
129
+ dueDay: '',
130
+ notes: '',
131
+ };
132
+ }
133
+
134
+ function emptyRevision(): RevisionState {
135
+ return {
136
+ title: '',
137
+ revisionType: 'revision',
138
+ effectiveDate: '',
139
+ status: 'draft',
140
+ summary: '',
141
+ };
142
+ }
143
+
144
+ function buildEmptyForm(): ContractFormState {
145
+ return {
146
+ code: '',
147
+ name: '',
148
+ clientName: '',
149
+ contractCategory: 'client',
150
+ contractType: 'service_agreement',
151
+ signatureStatus: 'not_started',
152
+ isActive: true,
153
+ billingModel: 'time_and_material',
154
+ accountManagerCollaboratorId: 'none',
155
+ relatedCollaboratorId: 'none',
156
+ originType: 'manual',
157
+ originId: '',
158
+ startDate: '',
159
+ endDate: '',
160
+ signedAt: '',
161
+ effectiveDate: '',
162
+ budgetAmount: '',
163
+ monthlyHourCap: '',
164
+ status: 'draft',
165
+ description: '',
166
+ contentHtml: '',
167
+ parties: [emptyParty()],
168
+ signatures: [emptySignature()],
169
+ financialTerms: [emptyFinancialTerm()],
170
+ revisions: [],
171
+ pdfDocument: null,
172
+ };
173
+ }
174
+
175
+ function toFormState(contract: OperationsContractDetails): ContractFormState {
176
+ return {
177
+ code: contract.code ?? '',
178
+ name: contract.name ?? '',
179
+ clientName: contract.clientName ?? '',
180
+ contractCategory: contract.contractCategory ?? 'client',
181
+ contractType: contract.contractType ?? 'service_agreement',
182
+ signatureStatus: contract.signatureStatus ?? 'not_started',
183
+ isActive: contract.isActive ?? true,
184
+ billingModel: contract.billingModel ?? 'time_and_material',
185
+ accountManagerCollaboratorId: contract.accountManagerCollaboratorId
186
+ ? String(contract.accountManagerCollaboratorId)
187
+ : 'none',
188
+ relatedCollaboratorId: contract.relatedCollaboratorId
189
+ ? String(contract.relatedCollaboratorId)
190
+ : 'none',
191
+ originType: contract.originType ?? 'manual',
192
+ originId: contract.originId ? String(contract.originId) : '',
193
+ startDate: contract.startDate ?? '',
194
+ endDate: contract.endDate ?? '',
195
+ signedAt: contract.signedAt ?? '',
196
+ effectiveDate: contract.effectiveDate ?? '',
197
+ budgetAmount:
198
+ contract.budgetAmount !== null && contract.budgetAmount !== undefined
199
+ ? String(contract.budgetAmount)
200
+ : '',
201
+ monthlyHourCap:
202
+ contract.monthlyHourCap !== null && contract.monthlyHourCap !== undefined
203
+ ? String(contract.monthlyHourCap)
204
+ : '',
205
+ status: contract.status ?? 'draft',
206
+ description: contract.description ?? '',
207
+ contentHtml: contract.contentHtml ?? '',
208
+ parties: contract.parties.length
209
+ ? contract.parties.map((party) => ({
210
+ displayName: party.displayName ?? '',
211
+ partyRole: party.partyRole ?? 'other',
212
+ partyType: party.partyType ?? 'company',
213
+ documentNumber: party.documentNumber ?? '',
214
+ email: party.email ?? '',
215
+ phone: party.phone ?? '',
216
+ isPrimary: party.isPrimary ?? false,
217
+ }))
218
+ : [emptyParty()],
219
+ signatures: contract.signatures.length
220
+ ? contract.signatures.map((signature) => ({
221
+ signerName: signature.signerName ?? '',
222
+ signerRole: signature.signerRole ?? '',
223
+ signerEmail: signature.signerEmail ?? '',
224
+ status: signature.status ?? 'pending',
225
+ signedAt: signature.signedAt ?? '',
226
+ }))
227
+ : [emptySignature()],
228
+ financialTerms: contract.financialTerms.length
229
+ ? contract.financialTerms.map((term) => ({
230
+ label: term.label ?? '',
231
+ termType: term.termType ?? 'value',
232
+ amount: String(term.amount ?? ''),
233
+ recurrence: term.recurrence ?? 'one_time',
234
+ dueDay:
235
+ term.dueDay !== null && term.dueDay !== undefined
236
+ ? String(term.dueDay)
237
+ : '',
238
+ notes: term.notes ?? '',
239
+ }))
240
+ : [emptyFinancialTerm()],
241
+ revisions: contract.revisions.map((revision) => ({
242
+ title: revision.title ?? '',
243
+ revisionType: revision.revisionType ?? 'revision',
244
+ effectiveDate: revision.effectiveDate ?? '',
245
+ status: revision.status ?? 'draft',
246
+ summary: revision.summary ?? '',
247
+ })),
248
+ pdfDocument: null,
249
+ };
250
+ }
251
+
252
+ async function fileToBase64(file: File) {
253
+ return new Promise<string>((resolve, reject) => {
254
+ const reader = new FileReader();
255
+ reader.onload = () => {
256
+ const result = String(reader.result ?? '');
257
+ const [, base64 = ''] = result.split(',');
258
+ resolve(base64);
259
+ };
260
+ reader.onerror = reject;
261
+ reader.readAsDataURL(file);
262
+ });
263
+ }
264
+
265
+ export function ContractFormScreen({
266
+ contractId,
267
+ duplicateFromId,
268
+ }: {
269
+ contractId?: number;
270
+ duplicateFromId?: number;
271
+ }) {
272
+ const t = useTranslations('operations.ContractFormPage');
273
+ const commonT = useTranslations('operations.Common');
274
+ const { request, showToastHandler, currentLocaleCode } = useApp();
275
+ const access = useOperationsAccess();
276
+ const router = useRouter();
277
+ const [form, setForm] = useState<ContractFormState>(buildEmptyForm());
278
+
279
+ const sourceId = contractId ?? duplicateFromId;
280
+
281
+ const { data: collaborators = [] } = useQuery<OperationsCollaborator[]>({
282
+ queryKey: ['operations-contract-form-collaborators', currentLocaleCode],
283
+ enabled: access.isDirector,
284
+ queryFn: () =>
285
+ fetchOperations<OperationsCollaborator[]>(request, '/operations/collaborators'),
286
+ });
287
+
288
+ const { data: contract, isLoading: isLoadingContract } =
289
+ useQuery<OperationsContractDetails>({
290
+ queryKey: ['operations-contract-form', currentLocaleCode, sourceId],
291
+ enabled: Boolean(sourceId),
292
+ queryFn: () =>
293
+ fetchOperations<OperationsContractDetails>(
294
+ request,
295
+ `/operations/contracts/${sourceId}`
296
+ ),
297
+ });
298
+
299
+ useEffect(() => {
300
+ if (!contract) {
301
+ return;
302
+ }
303
+
304
+ const next = toFormState(contract);
305
+ if (duplicateFromId) {
306
+ next.code = `${contract.code}-COPY`;
307
+ next.name = `${contract.name} Copy`;
308
+ next.originType = 'manual';
309
+ next.originId = '';
310
+ next.status = 'draft';
311
+ next.signatureStatus = 'not_started';
312
+ next.signedAt = '';
313
+ next.isActive = true;
314
+ }
315
+ setForm(next);
316
+ }, [contract, duplicateFromId]);
317
+
318
+ const updateArrayItem = <T,>(
319
+ key: 'parties' | 'signatures' | 'financialTerms' | 'revisions',
320
+ index: number,
321
+ patch: Partial<T>
322
+ ) => {
323
+ setForm((current) => ({
324
+ ...current,
325
+ [key]: (current[key] as T[]).map((item, itemIndex) =>
326
+ itemIndex === index ? { ...item, ...patch } : item
327
+ ),
328
+ }));
329
+ };
330
+
331
+ const onSubmit = async () => {
332
+ if (!form.code.trim() || !form.name.trim() || !form.clientName.trim()) {
333
+ showToastHandler?.('error', t('messages.requiredFields'));
334
+ return;
335
+ }
336
+
337
+ const payload = {
338
+ code: form.code.trim(),
339
+ name: form.name.trim(),
340
+ clientName: form.clientName.trim(),
341
+ contractCategory: form.contractCategory,
342
+ contractType: form.contractType,
343
+ signatureStatus: form.signatureStatus,
344
+ isActive: form.isActive,
345
+ billingModel: form.billingModel,
346
+ accountManagerCollaboratorId:
347
+ form.accountManagerCollaboratorId === 'none'
348
+ ? null
349
+ : parseNumberInput(form.accountManagerCollaboratorId),
350
+ relatedCollaboratorId:
351
+ form.relatedCollaboratorId === 'none'
352
+ ? null
353
+ : parseNumberInput(form.relatedCollaboratorId),
354
+ originType: form.originType,
355
+ originId: parseNumberInput(form.originId),
356
+ startDate: form.startDate,
357
+ endDate: trimToNull(form.endDate),
358
+ signedAt: trimToNull(form.signedAt),
359
+ effectiveDate: trimToNull(form.effectiveDate),
360
+ budgetAmount: parseNumberInput(form.budgetAmount),
361
+ monthlyHourCap: parseNumberInput(form.monthlyHourCap),
362
+ status: form.status,
363
+ description: trimToNull(form.description),
364
+ contentHtml: trimToNull(form.contentHtml),
365
+ parties: form.parties
366
+ .filter((party) => party.displayName.trim())
367
+ .map((party) => ({
368
+ displayName: party.displayName.trim(),
369
+ partyRole: party.partyRole,
370
+ partyType: party.partyType,
371
+ documentNumber: trimToNull(party.documentNumber),
372
+ email: trimToNull(party.email),
373
+ phone: trimToNull(party.phone),
374
+ isPrimary: party.isPrimary,
375
+ })),
376
+ signatures: form.signatures
377
+ .filter((signature) => signature.signerName.trim())
378
+ .map((signature) => ({
379
+ signerName: signature.signerName.trim(),
380
+ signerRole: trimToNull(signature.signerRole),
381
+ signerEmail: trimToNull(signature.signerEmail),
382
+ status: signature.status,
383
+ signedAt: trimToNull(signature.signedAt),
384
+ })),
385
+ financialTerms: form.financialTerms
386
+ .filter((term) => term.label.trim())
387
+ .map((term) => ({
388
+ label: term.label.trim(),
389
+ termType: term.termType,
390
+ amount: parseNumberInput(term.amount) ?? 0,
391
+ recurrence: term.recurrence,
392
+ dueDay: parseNumberInput(term.dueDay),
393
+ notes: trimToNull(term.notes),
394
+ })),
395
+ revisions: form.revisions
396
+ .filter((revision) => revision.title.trim())
397
+ .map((revision) => ({
398
+ title: revision.title.trim(),
399
+ revisionType: revision.revisionType,
400
+ effectiveDate: trimToNull(revision.effectiveDate),
401
+ status: revision.status,
402
+ summary: trimToNull(revision.summary),
403
+ })),
404
+ replaceUploadedPdfDocument: form.pdfDocument,
405
+ };
406
+
407
+ try {
408
+ const response = contractId
409
+ ? await mutateOperations<OperationsContractDetails>(
410
+ request,
411
+ `/operations/contracts/${contractId}`,
412
+ 'PATCH',
413
+ payload
414
+ )
415
+ : await mutateOperations<OperationsContractDetails>(
416
+ request,
417
+ '/operations/contracts',
418
+ 'POST',
419
+ payload
420
+ );
421
+
422
+ showToastHandler?.(
423
+ 'success',
424
+ contractId ? t('messages.updateSuccess') : t('messages.createSuccess')
425
+ );
426
+ router.push(`/operations/contracts/${response.id}`);
427
+ } catch {
428
+ showToastHandler?.(
429
+ 'error',
430
+ contractId ? t('messages.updateError') : t('messages.createError')
431
+ );
432
+ }
433
+ };
434
+
435
+ if (!access.isDirector && !access.isLoading) {
436
+ return (
437
+ <Page>
438
+ <OperationsHeader title={t(contractId ? 'editTitle' : 'newTitle')} description={t('description')} current={t('breadcrumb')} />
439
+ <EmptyState
440
+ icon={<FileText className="size-12" />}
441
+ title={commonT('states.noAccessTitle')}
442
+ description={t('noAccessDescription')}
443
+ actionLabel={commonT('actions.refresh')}
444
+ onAction={() => router.refresh()}
445
+ />
446
+ </Page>
447
+ );
448
+ }
449
+
450
+ return (
451
+ <Page>
452
+ <OperationsHeader
453
+ title={duplicateFromId ? t('duplicateTitle') : t(contractId ? 'editTitle' : 'newTitle')}
454
+ description={t('description')}
455
+ current={t('breadcrumb')}
456
+ actions={
457
+ <div className="flex gap-2">
458
+ <Button variant="outline" size="sm" asChild>
459
+ <Link href={contractId ? `/operations/contracts/${contractId}` : '/operations/contracts'}>
460
+ <ArrowLeft className="size-4" />
461
+ {commonT('actions.back')}
462
+ </Link>
463
+ </Button>
464
+ <Button size="sm" onClick={() => void onSubmit()}>
465
+ <Save className="size-4" />
466
+ {commonT('actions.save')}
467
+ </Button>
468
+ </div>
469
+ }
470
+ />
471
+
472
+ <Tabs defaultValue="overview">
473
+ <TabsList className="flex-wrap">
474
+ <TabsTrigger value="overview">{t('tabs.overview')}</TabsTrigger>
475
+ <TabsTrigger value="parties">{t('tabs.parties')}</TabsTrigger>
476
+ <TabsTrigger value="signatures">{t('tabs.signatures')}</TabsTrigger>
477
+ <TabsTrigger value="financials">{t('tabs.financials')}</TabsTrigger>
478
+ <TabsTrigger value="documents">{t('tabs.documents')}</TabsTrigger>
479
+ <TabsTrigger value="revisions">{t('tabs.revisions')}</TabsTrigger>
480
+ <TabsTrigger value="editor">{t('tabs.editor')}</TabsTrigger>
481
+ </TabsList>
482
+ <TabsContent value="overview">
483
+ <SectionCard title={t('sections.overview')} description={t('sections.overviewDescription')}>
484
+ <div className="grid gap-4 md:grid-cols-3">
485
+ <Input placeholder={t('fields.code')} value={form.code} onChange={(e) => setForm((c) => ({ ...c, code: e.target.value }))} />
486
+ <Input className="md:col-span-2" placeholder={t('fields.name')} value={form.name} onChange={(e) => setForm((c) => ({ ...c, name: e.target.value }))} />
487
+ <Input placeholder={t('fields.clientName')} value={form.clientName} onChange={(e) => setForm((c) => ({ ...c, clientName: e.target.value }))} />
488
+ <Select value={form.contractCategory} onValueChange={(value) => setForm((c) => ({ ...c, contractCategory: value }))}>
489
+ <SelectTrigger><SelectValue placeholder={t('fields.contractCategory')} /></SelectTrigger>
490
+ <SelectContent>{['employee','contractor','client','supplier','vendor','partner','internal','other'].map((value) => <SelectItem key={value} value={value}>{value}</SelectItem>)}</SelectContent>
491
+ </Select>
492
+ <Select value={form.contractType} onValueChange={(value) => setForm((c) => ({ ...c, contractType: value }))}>
493
+ <SelectTrigger><SelectValue placeholder={t('fields.contractType')} /></SelectTrigger>
494
+ <SelectContent>{['clt','pj','freelancer_agreement','service_agreement','fixed_term','recurring_service','nda','amendment','addendum','other'].map((value) => <SelectItem key={value} value={value}>{value}</SelectItem>)}</SelectContent>
495
+ </Select>
496
+ <Select value={form.originType} onValueChange={(value) => setForm((c) => ({ ...c, originType: value }))}>
497
+ <SelectTrigger><SelectValue placeholder={t('fields.originType')} /></SelectTrigger>
498
+ <SelectContent>{['manual','employee_hiring','client_project'].map((value) => <SelectItem key={value} value={value}>{value}</SelectItem>)}</SelectContent>
499
+ </Select>
500
+ <Input placeholder={t('fields.originId')} value={form.originId} onChange={(e) => setForm((c) => ({ ...c, originId: e.target.value }))} />
501
+ <Select value={form.billingModel} onValueChange={(value) => setForm((c) => ({ ...c, billingModel: value }))}>
502
+ <SelectTrigger><SelectValue placeholder={commonT('labels.billingModel')} /></SelectTrigger>
503
+ <SelectContent>{['time_and_material','monthly_retainer','fixed_price'].map((value) => <SelectItem key={value} value={value}>{value}</SelectItem>)}</SelectContent>
504
+ </Select>
505
+ <Select value={form.signatureStatus} onValueChange={(value) => setForm((c) => ({ ...c, signatureStatus: value }))}>
506
+ <SelectTrigger><SelectValue placeholder={t('fields.signatureStatus')} /></SelectTrigger>
507
+ <SelectContent>{['not_started','pending','partially_signed','signed','expired'].map((value) => <SelectItem key={value} value={value}>{value}</SelectItem>)}</SelectContent>
508
+ </Select>
509
+ <Select value={form.status} onValueChange={(value) => setForm((c) => ({ ...c, status: value }))}>
510
+ <SelectTrigger><SelectValue placeholder={commonT('labels.status')} /></SelectTrigger>
511
+ <SelectContent>{['draft','under_review','active','renewal','expired','closed','archived'].map((value) => <SelectItem key={value} value={value}>{value}</SelectItem>)}</SelectContent>
512
+ </Select>
513
+ <Select value={form.accountManagerCollaboratorId} onValueChange={(value) => setForm((c) => ({ ...c, accountManagerCollaboratorId: value }))}>
514
+ <SelectTrigger><SelectValue placeholder={commonT('labels.accountManager')} /></SelectTrigger>
515
+ <SelectContent>
516
+ <SelectItem value="none">{commonT('labels.notAssigned')}</SelectItem>
517
+ {collaborators.map((collaborator) => <SelectItem key={collaborator.id} value={String(collaborator.id)}>{collaborator.displayName}</SelectItem>)}
518
+ </SelectContent>
519
+ </Select>
520
+ <Select value={form.relatedCollaboratorId} onValueChange={(value) => setForm((c) => ({ ...c, relatedCollaboratorId: value }))}>
521
+ <SelectTrigger><SelectValue placeholder={commonT('labels.collaborator')} /></SelectTrigger>
522
+ <SelectContent>
523
+ <SelectItem value="none">{commonT('labels.notAssigned')}</SelectItem>
524
+ {collaborators.map((collaborator) => <SelectItem key={collaborator.id} value={String(collaborator.id)}>{collaborator.displayName}</SelectItem>)}
525
+ </SelectContent>
526
+ </Select>
527
+ <Input type="date" value={form.startDate} onChange={(e) => setForm((c) => ({ ...c, startDate: e.target.value }))} />
528
+ <Input type="date" value={form.endDate} onChange={(e) => setForm((c) => ({ ...c, endDate: e.target.value }))} />
529
+ <Input type="date" value={form.effectiveDate} onChange={(e) => setForm((c) => ({ ...c, effectiveDate: e.target.value }))} />
530
+ <Input type="date" value={form.signedAt} onChange={(e) => setForm((c) => ({ ...c, signedAt: e.target.value }))} />
531
+ <Input type="number" step="0.01" placeholder={t('fields.budgetAmount')} value={form.budgetAmount} onChange={(e) => setForm((c) => ({ ...c, budgetAmount: e.target.value }))} />
532
+ <Input type="number" placeholder={t('fields.monthlyHourCap')} value={form.monthlyHourCap} onChange={(e) => setForm((c) => ({ ...c, monthlyHourCap: e.target.value }))} />
533
+ <div className="flex items-center justify-between rounded-lg border px-4 py-3 md:col-span-3">
534
+ <div>
535
+ <div className="font-medium">{t('fields.isActive')}</div>
536
+ <div className="text-sm text-muted-foreground">{t('fields.isActiveDescription')}</div>
537
+ </div>
538
+ <Switch checked={form.isActive} onCheckedChange={(checked) => setForm((c) => ({ ...c, isActive: checked }))} />
539
+ </div>
540
+ <Textarea className="md:col-span-3" rows={4} placeholder={commonT('labels.description')} value={form.description} onChange={(e) => setForm((c) => ({ ...c, description: e.target.value }))} />
541
+ </div>
542
+ </SectionCard>
543
+ </TabsContent>
544
+
545
+ <TabsContent value="parties"><SectionCard title={t('sections.parties')} description={t('sections.partiesDescription')}><div className="space-y-4">{form.parties.map((party, index) => <div key={index} className="grid gap-4 rounded-lg border p-4 md:grid-cols-3"><Input placeholder={t('fields.partyDisplayName')} value={party.displayName} onChange={(e) => updateArrayItem<PartyState>('parties', index, { displayName: e.target.value })} /><Input placeholder={t('fields.partyRole')} value={party.partyRole} onChange={(e) => updateArrayItem<PartyState>('parties', index, { partyRole: e.target.value })} /><Input placeholder={t('fields.partyType')} value={party.partyType} onChange={(e) => updateArrayItem<PartyState>('parties', index, { partyType: e.target.value })} /><Input placeholder={t('fields.documentNumber')} value={party.documentNumber} onChange={(e) => updateArrayItem<PartyState>('parties', index, { documentNumber: e.target.value })} /><Input placeholder={t('fields.email')} value={party.email} onChange={(e) => updateArrayItem<PartyState>('parties', index, { email: e.target.value })} /><Input placeholder={t('fields.phone')} value={party.phone} onChange={(e) => updateArrayItem<PartyState>('parties', index, { phone: e.target.value })} /><div className="flex items-center justify-between rounded-md border px-3 py-2 md:col-span-3"><span className="text-sm">{t('fields.isPrimaryParty')}</span><Switch checked={party.isPrimary} onCheckedChange={(checked) => updateArrayItem<PartyState>('parties', index, { isPrimary: checked })} /></div></div>)}<Button variant="outline" onClick={() => setForm((c) => ({ ...c, parties: [...c.parties, emptyParty()] }))}><Plus className="size-4" />{commonT('actions.addLine')}</Button></div></SectionCard></TabsContent>
546
+ <TabsContent value="signatures"><SectionCard title={t('sections.signatures')} description={t('sections.signaturesDescription')}><div className="space-y-4">{form.signatures.map((signature, index) => <div key={index} className="grid gap-4 rounded-lg border p-4 md:grid-cols-2"><Input placeholder={t('fields.signerName')} value={signature.signerName} onChange={(e) => updateArrayItem<SignatureState>('signatures', index, { signerName: e.target.value })} /><Input placeholder={t('fields.signerRole')} value={signature.signerRole} onChange={(e) => updateArrayItem<SignatureState>('signatures', index, { signerRole: e.target.value })} /><Input placeholder={t('fields.signerEmail')} value={signature.signerEmail} onChange={(e) => updateArrayItem<SignatureState>('signatures', index, { signerEmail: e.target.value })} /><Input placeholder={t('fields.signatureItemStatus')} value={signature.status} onChange={(e) => updateArrayItem<SignatureState>('signatures', index, { status: e.target.value })} /><Input type="date" value={signature.signedAt} onChange={(e) => updateArrayItem<SignatureState>('signatures', index, { signedAt: e.target.value })} /></div>)}<Button variant="outline" onClick={() => setForm((c) => ({ ...c, signatures: [...c.signatures, emptySignature()] }))}><Plus className="size-4" />{commonT('actions.addLine')}</Button></div></SectionCard></TabsContent>
547
+ <TabsContent value="financials"><SectionCard title={t('sections.financials')} description={t('sections.financialsDescription')}><div className="space-y-4">{form.financialTerms.map((term, index) => <div key={index} className="grid gap-4 rounded-lg border p-4 md:grid-cols-3"><Input placeholder={t('fields.financialLabel')} value={term.label} onChange={(e) => updateArrayItem<FinancialTermState>('financialTerms', index, { label: e.target.value })} /><Input placeholder={t('fields.termType')} value={term.termType} onChange={(e) => updateArrayItem<FinancialTermState>('financialTerms', index, { termType: e.target.value })} /><Input type="number" step="0.01" placeholder={t('fields.amount')} value={term.amount} onChange={(e) => updateArrayItem<FinancialTermState>('financialTerms', index, { amount: e.target.value })} /><Input placeholder={t('fields.recurrence')} value={term.recurrence} onChange={(e) => updateArrayItem<FinancialTermState>('financialTerms', index, { recurrence: e.target.value })} /><Input type="number" placeholder={t('fields.dueDay')} value={term.dueDay} onChange={(e) => updateArrayItem<FinancialTermState>('financialTerms', index, { dueDay: e.target.value })} /><Input placeholder={t('fields.notes')} value={term.notes} onChange={(e) => updateArrayItem<FinancialTermState>('financialTerms', index, { notes: e.target.value })} /></div>)}<Button variant="outline" onClick={() => setForm((c) => ({ ...c, financialTerms: [...c.financialTerms, emptyFinancialTerm()] }))}><Plus className="size-4" />{commonT('actions.addLine')}</Button></div></SectionCard></TabsContent>
548
+ <TabsContent value="documents"><SectionCard title={t('sections.documents')} description={t('sections.documentsDescription')}><div className="space-y-4"><input type="file" accept="application/pdf" onChange={async (event) => { const file = event.target.files?.[0]; if (!file) return; const fileContentBase64 = await fileToBase64(file); setForm((current) => ({ ...current, pdfDocument: { fileName: file.name, mimeType: file.type || 'application/pdf', fileContentBase64 } })); }} /><p className="text-sm text-muted-foreground">{form.pdfDocument ? t('messages.pdfReady', { name: form.pdfDocument.fileName }) : t('messages.pdfHint')}</p></div></SectionCard></TabsContent>
549
+ <TabsContent value="revisions"><SectionCard title={t('sections.revisions')} description={t('sections.revisionsDescription')}><div className="space-y-4">{form.revisions.map((revision, index) => <div key={index} className="grid gap-4 rounded-lg border p-4 md:grid-cols-2"><Input placeholder={t('fields.revisionTitle')} value={revision.title} onChange={(e) => updateArrayItem<RevisionState>('revisions', index, { title: e.target.value })} /><Input placeholder={t('fields.revisionType')} value={revision.revisionType} onChange={(e) => updateArrayItem<RevisionState>('revisions', index, { revisionType: e.target.value })} /><Input type="date" value={revision.effectiveDate} onChange={(e) => updateArrayItem<RevisionState>('revisions', index, { effectiveDate: e.target.value })} /><Input placeholder={t('fields.revisionStatus')} value={revision.status} onChange={(e) => updateArrayItem<RevisionState>('revisions', index, { status: e.target.value })} /><Textarea className="md:col-span-2" rows={3} placeholder={t('fields.summary')} value={revision.summary} onChange={(e) => updateArrayItem<RevisionState>('revisions', index, { summary: e.target.value })} /></div>)}<Button variant="outline" onClick={() => setForm((c) => ({ ...c, revisions: [...c.revisions, emptyRevision()] }))}><Plus className="size-4" />{commonT('actions.addLine')}</Button></div></SectionCard></TabsContent>
550
+ <TabsContent value="editor"><div className="grid gap-4 xl:grid-cols-2"><SectionCard title={t('sections.editor')} description={t('sections.editorDescription')}><RichTextEditor value={form.contentHtml} onChange={(value) => setForm((c) => ({ ...c, contentHtml: value }))} /></SectionCard><SectionCard title={t('sections.preview')} description={t('sections.previewDescription')}><div className="prose prose-sm max-w-none rounded-lg border p-4" dangerouslySetInnerHTML={{ __html: form.contentHtml || '<p>No contract content yet.</p>' }} /></SectionCard></div></TabsContent>
551
+ </Tabs>
552
+
553
+ {sourceId && isLoadingContract ? (
554
+ <div className="text-sm text-muted-foreground">{t('loading')}</div>
555
+ ) : null}
556
+ </Page>
557
+ );
558
+ }