@hed-hog/operations 0.0.296 → 0.0.298

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 +2 -2
@@ -1,689 +1,689 @@
1
- 'use client';
2
-
3
- import { EmptyState, Page } from '@/components/entity-list';
4
- import { Button } from '@/components/ui/button';
5
- import { Checkbox } from '@/components/ui/checkbox';
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 { Textarea } from '@/components/ui/textarea';
16
- import { useApp, useQuery } from '@hed-hog/next-app-provider';
17
- import { ArrowLeft, FolderKanban, Save } from 'lucide-react';
18
- import Link from 'next/link';
19
- import { useRouter } from 'next/navigation';
20
- import { useEffect, useState } from 'react';
21
- import { useTranslations } from 'next-intl';
22
- import { OperationsHeader } from './operations-header';
23
- import { SectionCard } from './section-card';
24
- import { fetchOperations, mutateOperations } from '../_lib/api';
25
- import { useOperationsAccess } from '../_lib/hooks/use-operations-access';
26
- import type {
27
- OperationsCollaborator,
28
- OperationsContract,
29
- OperationsProjectDetails,
30
- } from '../_lib/types';
31
- import { parseNumberInput, trimToNull } from '../_lib/utils/forms';
32
- import { formatEnumLabel } from '../_lib/utils/format';
33
-
34
- type TeamAssignmentState = {
35
- collaboratorId: number;
36
- selected: boolean;
37
- roleLabel: string;
38
- weeklyHours: string;
39
- allocationPercent: string;
40
- isBillable: boolean;
41
- status: string;
42
- };
43
-
44
- type ProjectFormState = {
45
- contractId: string;
46
- managerCollaboratorId: string;
47
- code: string;
48
- name: string;
49
- clientName: string;
50
- summary: string;
51
- status: string;
52
- progressPercent: string;
53
- deliveryModel: string;
54
- budgetAmount: string;
55
- startDate: string;
56
- endDate: string;
57
- billingModel: string;
58
- monthlyHourCap: string;
59
- contractCode: string;
60
- contractName: string;
61
- contractDescription: string;
62
- autoGenerateContractDraft: boolean;
63
- teamAssignments: TeamAssignmentState[];
64
- };
65
-
66
- function buildEmptyForm(
67
- collaborators: OperationsCollaborator[] = []
68
- ): ProjectFormState {
69
- return {
70
- contractId: 'none',
71
- managerCollaboratorId: 'none',
72
- code: '',
73
- name: '',
74
- clientName: '',
75
- summary: '',
76
- status: 'planning',
77
- progressPercent: '',
78
- deliveryModel: 'project_delivery',
79
- budgetAmount: '',
80
- startDate: '',
81
- endDate: '',
82
- billingModel: 'time_and_material',
83
- monthlyHourCap: '',
84
- contractCode: '',
85
- contractName: '',
86
- contractDescription: '',
87
- autoGenerateContractDraft: true,
88
- teamAssignments: collaborators.map((collaborator) => ({
89
- collaboratorId: collaborator.id,
90
- selected: false,
91
- roleLabel: '',
92
- weeklyHours: '',
93
- allocationPercent: '',
94
- isBillable: true,
95
- status: 'active',
96
- })),
97
- };
98
- }
99
-
100
- function toFormState(
101
- project: OperationsProjectDetails,
102
- collaborators: OperationsCollaborator[]
103
- ): ProjectFormState {
104
- const assignments = new Map(
105
- (project.assignments ?? []).map((assignment) => [
106
- assignment.collaboratorId,
107
- assignment,
108
- ])
109
- );
110
-
111
- return {
112
- contractId: project.contractId ? String(project.contractId) : 'none',
113
- managerCollaboratorId: project.managerCollaboratorId
114
- ? String(project.managerCollaboratorId)
115
- : 'none',
116
- code: project.code ?? '',
117
- name: project.name ?? '',
118
- clientName: project.clientName ?? '',
119
- summary: project.summary ?? '',
120
- status: project.status ?? 'planning',
121
- progressPercent:
122
- project.progressPercent !== null && project.progressPercent !== undefined
123
- ? String(project.progressPercent)
124
- : '',
125
- deliveryModel: project.deliveryModel ?? 'project_delivery',
126
- budgetAmount:
127
- project.budgetAmount !== null && project.budgetAmount !== undefined
128
- ? String(project.budgetAmount)
129
- : '',
130
- startDate: project.startDate ?? '',
131
- endDate: project.endDate ?? '',
132
- billingModel:
133
- project.relatedContract?.billingModel ?? 'time_and_material',
134
- monthlyHourCap:
135
- project.relatedContract?.monthlyHourCap !== null &&
136
- project.relatedContract?.monthlyHourCap !== undefined
137
- ? String(project.relatedContract.monthlyHourCap)
138
- : '',
139
- contractCode: project.relatedContract?.code ?? '',
140
- contractName: project.relatedContract?.name ?? '',
141
- contractDescription: project.relatedContract?.description ?? '',
142
- autoGenerateContractDraft: true,
143
- teamAssignments: collaborators.map((collaborator) => {
144
- const assignment = assignments.get(collaborator.id);
145
- return {
146
- collaboratorId: collaborator.id,
147
- selected: Boolean(assignment),
148
- roleLabel: assignment?.roleLabel ?? '',
149
- weeklyHours:
150
- assignment?.weeklyHours !== null && assignment?.weeklyHours !== undefined
151
- ? String(assignment.weeklyHours)
152
- : '',
153
- allocationPercent:
154
- assignment?.allocationPercent !== null &&
155
- assignment?.allocationPercent !== undefined
156
- ? String(assignment.allocationPercent)
157
- : '',
158
- isBillable: assignment?.isBillable ?? true,
159
- status: assignment?.status ?? 'active',
160
- };
161
- }),
162
- };
163
- }
164
-
165
- export function ProjectFormScreen({ projectId }: { projectId?: number }) {
166
- const t = useTranslations('operations.ProjectFormPage');
167
- const commonT = useTranslations('operations.Common');
168
- const { request, showToastHandler, currentLocaleCode } = useApp();
169
- const access = useOperationsAccess();
170
- const router = useRouter();
171
- const [form, setForm] = useState<ProjectFormState>(buildEmptyForm());
172
-
173
- const { data: collaborators = [] } = useQuery<OperationsCollaborator[]>({
174
- queryKey: ['operations-project-form-collaborators', currentLocaleCode],
175
- enabled: access.isDirector,
176
- queryFn: () =>
177
- fetchOperations<OperationsCollaborator[]>(request, '/operations/collaborators'),
178
- });
179
-
180
- const { data: contracts = [] } = useQuery<OperationsContract[]>({
181
- queryKey: ['operations-project-form-contracts', currentLocaleCode],
182
- enabled: access.isDirector,
183
- queryFn: () =>
184
- fetchOperations<OperationsContract[]>(request, '/operations/contracts'),
185
- });
186
-
187
- const { data: project, isLoading: isLoadingProject } =
188
- useQuery<OperationsProjectDetails>({
189
- queryKey: ['operations-project-form', currentLocaleCode, projectId],
190
- enabled: Boolean(projectId),
191
- queryFn: () =>
192
- fetchOperations<OperationsProjectDetails>(
193
- request,
194
- `/operations/projects/${projectId}`
195
- ),
196
- });
197
-
198
- useEffect(() => {
199
- if (!collaborators.length) {
200
- return;
201
- }
202
-
203
- if (project) {
204
- setForm(toFormState(project, collaborators));
205
- return;
206
- }
207
-
208
- setForm((current) =>
209
- current.teamAssignments.length ? current : buildEmptyForm(collaborators)
210
- );
211
- }, [collaborators, project]);
212
-
213
- const updateAssignment = (
214
- collaboratorId: number,
215
- patch: Partial<TeamAssignmentState>
216
- ) => {
217
- setForm((current) => ({
218
- ...current,
219
- teamAssignments: current.teamAssignments.map((assignment) =>
220
- assignment.collaboratorId === collaboratorId
221
- ? { ...assignment, ...patch }
222
- : assignment
223
- ),
224
- }));
225
- };
226
-
227
- const onSubmit = async () => {
228
- if (!form.code.trim() || !form.name.trim() || !form.clientName.trim()) {
229
- showToastHandler?.('error', t('messages.requiredFields'));
230
- return;
231
- }
232
-
233
- const payload = {
234
- contractId: form.contractId === 'none' ? null : parseNumberInput(form.contractId),
235
- managerCollaboratorId:
236
- form.managerCollaboratorId === 'none'
237
- ? null
238
- : parseNumberInput(form.managerCollaboratorId),
239
- code: form.code.trim(),
240
- name: form.name.trim(),
241
- clientName: trimToNull(form.clientName),
242
- summary: trimToNull(form.summary),
243
- status: form.status,
244
- progressPercent: parseNumberInput(form.progressPercent),
245
- deliveryModel: form.deliveryModel,
246
- budgetAmount: parseNumberInput(form.budgetAmount),
247
- startDate: trimToNull(form.startDate),
248
- endDate: trimToNull(form.endDate),
249
- billingModel: form.billingModel,
250
- monthlyHourCap: parseNumberInput(form.monthlyHourCap),
251
- contractCode: trimToNull(form.contractCode),
252
- contractName: trimToNull(form.contractName),
253
- contractDescription: trimToNull(form.contractDescription),
254
- autoGenerateContractDraft: form.contractId === 'none'
255
- ? form.autoGenerateContractDraft
256
- : false,
257
- teamAssignments: form.teamAssignments
258
- .filter((assignment) => assignment.selected)
259
- .map((assignment) => ({
260
- collaboratorId: assignment.collaboratorId,
261
- roleLabel: trimToNull(assignment.roleLabel),
262
- weeklyHours: parseNumberInput(assignment.weeklyHours),
263
- allocationPercent: parseNumberInput(assignment.allocationPercent),
264
- isBillable: assignment.isBillable,
265
- status: assignment.status,
266
- startDate: trimToNull(form.startDate),
267
- endDate: trimToNull(form.endDate),
268
- })),
269
- };
270
-
271
- try {
272
- const response = projectId
273
- ? await mutateOperations<OperationsProjectDetails>(
274
- request,
275
- `/operations/projects/${projectId}`,
276
- 'PATCH',
277
- payload
278
- )
279
- : await mutateOperations<OperationsProjectDetails>(
280
- request,
281
- '/operations/projects',
282
- 'POST',
283
- payload
284
- );
285
-
286
- showToastHandler?.(
287
- 'success',
288
- projectId ? t('messages.updateSuccess') : t('messages.createSuccess')
289
- );
290
- router.push(`/operations/projects/${response.id}`);
291
- } catch {
292
- showToastHandler?.(
293
- 'error',
294
- projectId ? t('messages.updateError') : t('messages.createError')
295
- );
296
- }
297
- };
298
-
299
- if (!access.isDirector && !access.isLoading) {
300
- return (
301
- <Page>
302
- <OperationsHeader
303
- title={t(projectId ? 'editTitle' : 'newTitle')}
304
- description={t('description')}
305
- current={t('breadcrumb')}
306
- />
307
- <EmptyState
308
- icon={<FolderKanban className="size-12" />}
309
- title={commonT('states.noAccessTitle')}
310
- description={t('noAccessDescription')}
311
- actionLabel={commonT('actions.refresh')}
312
- onAction={() => router.refresh()}
313
- />
314
- </Page>
315
- );
316
- }
317
-
318
- return (
319
- <Page>
320
- <OperationsHeader
321
- title={t(projectId ? 'editTitle' : 'newTitle')}
322
- description={t('description')}
323
- current={t('breadcrumb')}
324
- actions={
325
- <div className="flex gap-2">
326
- <Button variant="outline" size="sm" asChild>
327
- <Link
328
- href={projectId ? `/operations/projects/${projectId}` : '/operations/projects'}
329
- >
330
- <ArrowLeft className="size-4" />
331
- {commonT('actions.back')}
332
- </Link>
333
- </Button>
334
- <Button size="sm" onClick={() => void onSubmit()}>
335
- <Save className="size-4" />
336
- {commonT('actions.save')}
337
- </Button>
338
- </div>
339
- }
340
- />
341
-
342
- <div className="grid gap-4 xl:grid-cols-2">
343
- <SectionCard title={t('sections.basicInfo')} description={t('sections.basicInfoDescription')}>
344
- <div className="grid gap-4 md:grid-cols-2">
345
- <div className="space-y-2">
346
- <label className="text-sm font-medium">{t('fields.code')}</label>
347
- <Input
348
- value={form.code}
349
- onChange={(event) =>
350
- setForm((current) => ({ ...current, code: event.target.value }))
351
- }
352
- />
353
- </div>
354
- <div className="space-y-2">
355
- <label className="text-sm font-medium">{t('fields.name')}</label>
356
- <Input
357
- value={form.name}
358
- onChange={(event) =>
359
- setForm((current) => ({ ...current, name: event.target.value }))
360
- }
361
- />
362
- </div>
363
- <div className="space-y-2">
364
- <label className="text-sm font-medium">{t('fields.clientName')}</label>
365
- <Input
366
- value={form.clientName}
367
- onChange={(event) =>
368
- setForm((current) => ({
369
- ...current,
370
- clientName: event.target.value,
371
- }))
372
- }
373
- />
374
- </div>
375
- <div className="space-y-2">
376
- <label className="text-sm font-medium">{t('fields.deliveryModel')}</label>
377
- <Select
378
- value={form.deliveryModel}
379
- onValueChange={(value) =>
380
- setForm((current) => ({ ...current, deliveryModel: value }))
381
- }
382
- >
383
- <SelectTrigger>
384
- <SelectValue />
385
- </SelectTrigger>
386
- <SelectContent>
387
- <SelectItem value="project_delivery">Project Delivery</SelectItem>
388
- <SelectItem value="dedicated_team">Dedicated Team</SelectItem>
389
- <SelectItem value="shared_team">Shared Team</SelectItem>
390
- <SelectItem value="support">Support</SelectItem>
391
- </SelectContent>
392
- </Select>
393
- </div>
394
- <div className="space-y-2 md:col-span-2">
395
- <label className="text-sm font-medium">{t('fields.summary')}</label>
396
- <Textarea
397
- rows={4}
398
- value={form.summary}
399
- onChange={(event) =>
400
- setForm((current) => ({ ...current, summary: event.target.value }))
401
- }
402
- />
403
- </div>
404
- </div>
405
- </SectionCard>
406
-
407
- <SectionCard title={t('sections.governance')} description={t('sections.governanceDescription')}>
408
- <div className="grid gap-4 md:grid-cols-2">
409
- <div className="space-y-2">
410
- <label className="text-sm font-medium">{commonT('labels.manager')}</label>
411
- <Select
412
- value={form.managerCollaboratorId}
413
- onValueChange={(value) =>
414
- setForm((current) => ({
415
- ...current,
416
- managerCollaboratorId: value,
417
- }))
418
- }
419
- >
420
- <SelectTrigger>
421
- <SelectValue />
422
- </SelectTrigger>
423
- <SelectContent>
424
- <SelectItem value="none">{commonT('labels.notAssigned')}</SelectItem>
425
- {collaborators.map((collaborator) => (
426
- <SelectItem key={collaborator.id} value={String(collaborator.id)}>
427
- {collaborator.displayName}
428
- </SelectItem>
429
- ))}
430
- </SelectContent>
431
- </Select>
432
- </div>
433
- <div className="space-y-2">
434
- <label className="text-sm font-medium">{commonT('labels.status')}</label>
435
- <Select
436
- value={form.status}
437
- onValueChange={(value) =>
438
- setForm((current) => ({ ...current, status: value }))
439
- }
440
- >
441
- <SelectTrigger>
442
- <SelectValue />
443
- </SelectTrigger>
444
- <SelectContent>
445
- <SelectItem value="planning">Planning</SelectItem>
446
- <SelectItem value="active">Active</SelectItem>
447
- <SelectItem value="at_risk">At Risk</SelectItem>
448
- <SelectItem value="paused">Paused</SelectItem>
449
- <SelectItem value="completed">Completed</SelectItem>
450
- <SelectItem value="archived">Archived</SelectItem>
451
- </SelectContent>
452
- </Select>
453
- </div>
454
- <div className="space-y-2">
455
- <label className="text-sm font-medium">{commonT('labels.startDate')}</label>
456
- <Input
457
- type="date"
458
- value={form.startDate}
459
- onChange={(event) =>
460
- setForm((current) => ({ ...current, startDate: event.target.value }))
461
- }
462
- />
463
- </div>
464
- <div className="space-y-2">
465
- <label className="text-sm font-medium">{commonT('labels.endDate')}</label>
466
- <Input
467
- type="date"
468
- value={form.endDate}
469
- onChange={(event) =>
470
- setForm((current) => ({ ...current, endDate: event.target.value }))
471
- }
472
- />
473
- </div>
474
- </div>
475
- </SectionCard>
476
- </div>
477
-
478
- <SectionCard title={t('sections.team')} description={t('sections.teamDescription')}>
479
- <div className="space-y-3">
480
- {form.teamAssignments.map((assignment) => {
481
- const collaborator = collaborators.find(
482
- (item) => item.id === assignment.collaboratorId
483
- );
484
- if (!collaborator) {
485
- return null;
486
- }
487
-
488
- return (
489
- <div
490
- key={assignment.collaboratorId}
491
- className="grid gap-3 rounded-lg border p-4 lg:grid-cols-[minmax(220px,1fr)_minmax(180px,1fr)_120px_120px]"
492
- >
493
- <div className="flex items-start gap-3">
494
- <Checkbox
495
- checked={assignment.selected}
496
- onCheckedChange={(checked) =>
497
- updateAssignment(assignment.collaboratorId, {
498
- selected: checked === true,
499
- })
500
- }
501
- />
502
- <div>
503
- <div className="font-medium">{collaborator.displayName}</div>
504
- <div className="text-xs text-muted-foreground">
505
- {[collaborator.department, collaborator.title]
506
- .filter(Boolean)
507
- .join(' • ') || commonT('labels.notAvailable')}
508
- </div>
509
- </div>
510
- </div>
511
- <Input
512
- placeholder={t('fields.roleLabel')}
513
- value={assignment.roleLabel}
514
- disabled={!assignment.selected}
515
- onChange={(event) =>
516
- updateAssignment(assignment.collaboratorId, {
517
- roleLabel: event.target.value,
518
- })
519
- }
520
- />
521
- <Input
522
- type="number"
523
- placeholder={t('fields.weeklyHours')}
524
- value={assignment.weeklyHours}
525
- disabled={!assignment.selected}
526
- onChange={(event) =>
527
- updateAssignment(assignment.collaboratorId, {
528
- weeklyHours: event.target.value,
529
- })
530
- }
531
- />
532
- <Input
533
- type="number"
534
- placeholder={t('fields.allocationPercent')}
535
- value={assignment.allocationPercent}
536
- disabled={!assignment.selected}
537
- onChange={(event) =>
538
- updateAssignment(assignment.collaboratorId, {
539
- allocationPercent: event.target.value,
540
- })
541
- }
542
- />
543
- </div>
544
- );
545
- })}
546
- </div>
547
- </SectionCard>
548
-
549
- <div className="grid gap-4 xl:grid-cols-2">
550
- <SectionCard title={t('sections.financials')} description={t('sections.financialsDescription')}>
551
- <div className="grid gap-4 md:grid-cols-2">
552
- <div className="space-y-2">
553
- <label className="text-sm font-medium">{commonT('labels.budget')}</label>
554
- <Input
555
- type="number"
556
- step="0.01"
557
- value={form.budgetAmount}
558
- onChange={(event) =>
559
- setForm((current) => ({ ...current, budgetAmount: event.target.value }))
560
- }
561
- />
562
- </div>
563
- <div className="space-y-2">
564
- <label className="text-sm font-medium">{commonT('labels.monthlyHourCap')}</label>
565
- <Input
566
- type="number"
567
- step="0.5"
568
- value={form.monthlyHourCap}
569
- onChange={(event) =>
570
- setForm((current) => ({
571
- ...current,
572
- monthlyHourCap: event.target.value,
573
- }))
574
- }
575
- />
576
- </div>
577
- <div className="space-y-2">
578
- <label className="text-sm font-medium">{commonT('labels.billingModel')}</label>
579
- <Select
580
- value={form.billingModel}
581
- onValueChange={(value) =>
582
- setForm((current) => ({ ...current, billingModel: value }))
583
- }
584
- >
585
- <SelectTrigger>
586
- <SelectValue />
587
- </SelectTrigger>
588
- <SelectContent>
589
- <SelectItem value="time_and_material">Time And Material</SelectItem>
590
- <SelectItem value="monthly_retainer">Monthly Retainer</SelectItem>
591
- <SelectItem value="fixed_price">Fixed Price</SelectItem>
592
- </SelectContent>
593
- </Select>
594
- </div>
595
- <div className="space-y-2">
596
- <label className="text-sm font-medium">{commonT('labels.contract')}</label>
597
- <Select
598
- value={form.contractId}
599
- onValueChange={(value) =>
600
- setForm((current) => ({ ...current, contractId: value }))
601
- }
602
- >
603
- <SelectTrigger>
604
- <SelectValue />
605
- </SelectTrigger>
606
- <SelectContent>
607
- <SelectItem value="none">{commonT('labels.notAssigned')}</SelectItem>
608
- {contracts.map((contract) => (
609
- <SelectItem key={contract.id} value={String(contract.id)}>
610
- {contract.name}
611
- </SelectItem>
612
- ))}
613
- </SelectContent>
614
- </Select>
615
- </div>
616
- </div>
617
- </SectionCard>
618
-
619
- <SectionCard title={t('sections.contract')} description={t('sections.contractDescription')}>
620
- <div className="grid gap-4 md:grid-cols-2">
621
- <div className="space-y-2">
622
- <label className="text-sm font-medium">{t('fields.contractCode')}</label>
623
- <Input
624
- value={form.contractCode}
625
- onChange={(event) =>
626
- setForm((current) => ({ ...current, contractCode: event.target.value }))
627
- }
628
- />
629
- </div>
630
- <div className="space-y-2">
631
- <label className="text-sm font-medium">{t('fields.contractName')}</label>
632
- <Input
633
- value={form.contractName}
634
- onChange={(event) =>
635
- setForm((current) => ({ ...current, contractName: event.target.value }))
636
- }
637
- />
638
- </div>
639
- <div className="space-y-2 md:col-span-2">
640
- <label className="text-sm font-medium">{t('fields.contractDescription')}</label>
641
- <Textarea
642
- rows={4}
643
- value={form.contractDescription}
644
- onChange={(event) =>
645
- setForm((current) => ({
646
- ...current,
647
- contractDescription: event.target.value,
648
- }))
649
- }
650
- />
651
- </div>
652
- <div className="flex items-center justify-between rounded-lg border px-4 py-3 md:col-span-2">
653
- <div>
654
- <div className="font-medium">{t('fields.autoGenerateContractDraft')}</div>
655
- <div className="text-sm text-muted-foreground">
656
- {form.contractId === 'none'
657
- ? t('fields.autoGenerateContractDraftDescription')
658
- : t('fields.existingContractSelected')}
659
- </div>
660
- </div>
661
- <Switch
662
- checked={form.contractId === 'none' && form.autoGenerateContractDraft}
663
- disabled={form.contractId !== 'none'}
664
- onCheckedChange={(checked) =>
665
- setForm((current) => ({
666
- ...current,
667
- autoGenerateContractDraft: checked,
668
- }))
669
- }
670
- />
671
- </div>
672
- </div>
673
- </SectionCard>
674
- </div>
675
-
676
- {projectId && isLoadingProject ? (
677
- <div className="text-sm text-muted-foreground">{t('loading')}</div>
678
- ) : null}
679
-
680
- {project ? (
681
- <div className="text-xs text-muted-foreground">
682
- {t('messages.currentContractStatus', {
683
- status: formatEnumLabel(project.contractStatus),
684
- })}
685
- </div>
686
- ) : null}
687
- </Page>
688
- );
689
- }
1
+ 'use client';
2
+
3
+ import { EmptyState, Page } from '@/components/entity-list';
4
+ import { Button } from '@/components/ui/button';
5
+ import { Checkbox } from '@/components/ui/checkbox';
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 { Textarea } from '@/components/ui/textarea';
16
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
17
+ import { ArrowLeft, FolderKanban, Save } from 'lucide-react';
18
+ import Link from 'next/link';
19
+ import { useRouter } from 'next/navigation';
20
+ import { useEffect, useState } from 'react';
21
+ import { useTranslations } from 'next-intl';
22
+ import { OperationsHeader } from './operations-header';
23
+ import { SectionCard } from './section-card';
24
+ import { fetchOperations, mutateOperations } from '../_lib/api';
25
+ import { useOperationsAccess } from '../_lib/hooks/use-operations-access';
26
+ import type {
27
+ OperationsCollaborator,
28
+ OperationsContract,
29
+ OperationsProjectDetails,
30
+ } from '../_lib/types';
31
+ import { parseNumberInput, trimToNull } from '../_lib/utils/forms';
32
+ import { formatEnumLabel } from '../_lib/utils/format';
33
+
34
+ type TeamAssignmentState = {
35
+ collaboratorId: number;
36
+ selected: boolean;
37
+ roleLabel: string;
38
+ weeklyHours: string;
39
+ allocationPercent: string;
40
+ isBillable: boolean;
41
+ status: string;
42
+ };
43
+
44
+ type ProjectFormState = {
45
+ contractId: string;
46
+ managerCollaboratorId: string;
47
+ code: string;
48
+ name: string;
49
+ clientName: string;
50
+ summary: string;
51
+ status: string;
52
+ progressPercent: string;
53
+ deliveryModel: string;
54
+ budgetAmount: string;
55
+ startDate: string;
56
+ endDate: string;
57
+ billingModel: string;
58
+ monthlyHourCap: string;
59
+ contractCode: string;
60
+ contractName: string;
61
+ contractDescription: string;
62
+ autoGenerateContractDraft: boolean;
63
+ teamAssignments: TeamAssignmentState[];
64
+ };
65
+
66
+ function buildEmptyForm(
67
+ collaborators: OperationsCollaborator[] = []
68
+ ): ProjectFormState {
69
+ return {
70
+ contractId: 'none',
71
+ managerCollaboratorId: 'none',
72
+ code: '',
73
+ name: '',
74
+ clientName: '',
75
+ summary: '',
76
+ status: 'planning',
77
+ progressPercent: '',
78
+ deliveryModel: 'project_delivery',
79
+ budgetAmount: '',
80
+ startDate: '',
81
+ endDate: '',
82
+ billingModel: 'time_and_material',
83
+ monthlyHourCap: '',
84
+ contractCode: '',
85
+ contractName: '',
86
+ contractDescription: '',
87
+ autoGenerateContractDraft: true,
88
+ teamAssignments: collaborators.map((collaborator) => ({
89
+ collaboratorId: collaborator.id,
90
+ selected: false,
91
+ roleLabel: '',
92
+ weeklyHours: '',
93
+ allocationPercent: '',
94
+ isBillable: true,
95
+ status: 'active',
96
+ })),
97
+ };
98
+ }
99
+
100
+ function toFormState(
101
+ project: OperationsProjectDetails,
102
+ collaborators: OperationsCollaborator[]
103
+ ): ProjectFormState {
104
+ const assignments = new Map(
105
+ (project.assignments ?? []).map((assignment) => [
106
+ assignment.collaboratorId,
107
+ assignment,
108
+ ])
109
+ );
110
+
111
+ return {
112
+ contractId: project.contractId ? String(project.contractId) : 'none',
113
+ managerCollaboratorId: project.managerCollaboratorId
114
+ ? String(project.managerCollaboratorId)
115
+ : 'none',
116
+ code: project.code ?? '',
117
+ name: project.name ?? '',
118
+ clientName: project.clientName ?? '',
119
+ summary: project.summary ?? '',
120
+ status: project.status ?? 'planning',
121
+ progressPercent:
122
+ project.progressPercent !== null && project.progressPercent !== undefined
123
+ ? String(project.progressPercent)
124
+ : '',
125
+ deliveryModel: project.deliveryModel ?? 'project_delivery',
126
+ budgetAmount:
127
+ project.budgetAmount !== null && project.budgetAmount !== undefined
128
+ ? String(project.budgetAmount)
129
+ : '',
130
+ startDate: project.startDate ?? '',
131
+ endDate: project.endDate ?? '',
132
+ billingModel:
133
+ project.relatedContract?.billingModel ?? 'time_and_material',
134
+ monthlyHourCap:
135
+ project.relatedContract?.monthlyHourCap !== null &&
136
+ project.relatedContract?.monthlyHourCap !== undefined
137
+ ? String(project.relatedContract.monthlyHourCap)
138
+ : '',
139
+ contractCode: project.relatedContract?.code ?? '',
140
+ contractName: project.relatedContract?.name ?? '',
141
+ contractDescription: project.relatedContract?.description ?? '',
142
+ autoGenerateContractDraft: true,
143
+ teamAssignments: collaborators.map((collaborator) => {
144
+ const assignment = assignments.get(collaborator.id);
145
+ return {
146
+ collaboratorId: collaborator.id,
147
+ selected: Boolean(assignment),
148
+ roleLabel: assignment?.roleLabel ?? '',
149
+ weeklyHours:
150
+ assignment?.weeklyHours !== null && assignment?.weeklyHours !== undefined
151
+ ? String(assignment.weeklyHours)
152
+ : '',
153
+ allocationPercent:
154
+ assignment?.allocationPercent !== null &&
155
+ assignment?.allocationPercent !== undefined
156
+ ? String(assignment.allocationPercent)
157
+ : '',
158
+ isBillable: assignment?.isBillable ?? true,
159
+ status: assignment?.status ?? 'active',
160
+ };
161
+ }),
162
+ };
163
+ }
164
+
165
+ export function ProjectFormScreen({ projectId }: { projectId?: number }) {
166
+ const t = useTranslations('operations.ProjectFormPage');
167
+ const commonT = useTranslations('operations.Common');
168
+ const { request, showToastHandler, currentLocaleCode } = useApp();
169
+ const access = useOperationsAccess();
170
+ const router = useRouter();
171
+ const [form, setForm] = useState<ProjectFormState>(buildEmptyForm());
172
+
173
+ const { data: collaborators = [] } = useQuery<OperationsCollaborator[]>({
174
+ queryKey: ['operations-project-form-collaborators', currentLocaleCode],
175
+ enabled: access.isDirector,
176
+ queryFn: () =>
177
+ fetchOperations<OperationsCollaborator[]>(request, '/operations/collaborators'),
178
+ });
179
+
180
+ const { data: contracts = [] } = useQuery<OperationsContract[]>({
181
+ queryKey: ['operations-project-form-contracts', currentLocaleCode],
182
+ enabled: access.isDirector,
183
+ queryFn: () =>
184
+ fetchOperations<OperationsContract[]>(request, '/operations/contracts'),
185
+ });
186
+
187
+ const { data: project, isLoading: isLoadingProject } =
188
+ useQuery<OperationsProjectDetails>({
189
+ queryKey: ['operations-project-form', currentLocaleCode, projectId],
190
+ enabled: Boolean(projectId),
191
+ queryFn: () =>
192
+ fetchOperations<OperationsProjectDetails>(
193
+ request,
194
+ `/operations/projects/${projectId}`
195
+ ),
196
+ });
197
+
198
+ useEffect(() => {
199
+ if (!collaborators.length) {
200
+ return;
201
+ }
202
+
203
+ if (project) {
204
+ setForm(toFormState(project, collaborators));
205
+ return;
206
+ }
207
+
208
+ setForm((current) =>
209
+ current.teamAssignments.length ? current : buildEmptyForm(collaborators)
210
+ );
211
+ }, [collaborators, project]);
212
+
213
+ const updateAssignment = (
214
+ collaboratorId: number,
215
+ patch: Partial<TeamAssignmentState>
216
+ ) => {
217
+ setForm((current) => ({
218
+ ...current,
219
+ teamAssignments: current.teamAssignments.map((assignment) =>
220
+ assignment.collaboratorId === collaboratorId
221
+ ? { ...assignment, ...patch }
222
+ : assignment
223
+ ),
224
+ }));
225
+ };
226
+
227
+ const onSubmit = async () => {
228
+ if (!form.code.trim() || !form.name.trim() || !form.clientName.trim()) {
229
+ showToastHandler?.('error', t('messages.requiredFields'));
230
+ return;
231
+ }
232
+
233
+ const payload = {
234
+ contractId: form.contractId === 'none' ? null : parseNumberInput(form.contractId),
235
+ managerCollaboratorId:
236
+ form.managerCollaboratorId === 'none'
237
+ ? null
238
+ : parseNumberInput(form.managerCollaboratorId),
239
+ code: form.code.trim(),
240
+ name: form.name.trim(),
241
+ clientName: trimToNull(form.clientName),
242
+ summary: trimToNull(form.summary),
243
+ status: form.status,
244
+ progressPercent: parseNumberInput(form.progressPercent),
245
+ deliveryModel: form.deliveryModel,
246
+ budgetAmount: parseNumberInput(form.budgetAmount),
247
+ startDate: trimToNull(form.startDate),
248
+ endDate: trimToNull(form.endDate),
249
+ billingModel: form.billingModel,
250
+ monthlyHourCap: parseNumberInput(form.monthlyHourCap),
251
+ contractCode: trimToNull(form.contractCode),
252
+ contractName: trimToNull(form.contractName),
253
+ contractDescription: trimToNull(form.contractDescription),
254
+ autoGenerateContractDraft: form.contractId === 'none'
255
+ ? form.autoGenerateContractDraft
256
+ : false,
257
+ teamAssignments: form.teamAssignments
258
+ .filter((assignment) => assignment.selected)
259
+ .map((assignment) => ({
260
+ collaboratorId: assignment.collaboratorId,
261
+ roleLabel: trimToNull(assignment.roleLabel),
262
+ weeklyHours: parseNumberInput(assignment.weeklyHours),
263
+ allocationPercent: parseNumberInput(assignment.allocationPercent),
264
+ isBillable: assignment.isBillable,
265
+ status: assignment.status,
266
+ startDate: trimToNull(form.startDate),
267
+ endDate: trimToNull(form.endDate),
268
+ })),
269
+ };
270
+
271
+ try {
272
+ const response = projectId
273
+ ? await mutateOperations<OperationsProjectDetails>(
274
+ request,
275
+ `/operations/projects/${projectId}`,
276
+ 'PATCH',
277
+ payload
278
+ )
279
+ : await mutateOperations<OperationsProjectDetails>(
280
+ request,
281
+ '/operations/projects',
282
+ 'POST',
283
+ payload
284
+ );
285
+
286
+ showToastHandler?.(
287
+ 'success',
288
+ projectId ? t('messages.updateSuccess') : t('messages.createSuccess')
289
+ );
290
+ router.push(`/operations/projects/${response.id}`);
291
+ } catch {
292
+ showToastHandler?.(
293
+ 'error',
294
+ projectId ? t('messages.updateError') : t('messages.createError')
295
+ );
296
+ }
297
+ };
298
+
299
+ if (!access.isDirector && !access.isLoading) {
300
+ return (
301
+ <Page>
302
+ <OperationsHeader
303
+ title={t(projectId ? 'editTitle' : 'newTitle')}
304
+ description={t('description')}
305
+ current={t('breadcrumb')}
306
+ />
307
+ <EmptyState
308
+ icon={<FolderKanban className="size-12" />}
309
+ title={commonT('states.noAccessTitle')}
310
+ description={t('noAccessDescription')}
311
+ actionLabel={commonT('actions.refresh')}
312
+ onAction={() => router.refresh()}
313
+ />
314
+ </Page>
315
+ );
316
+ }
317
+
318
+ return (
319
+ <Page>
320
+ <OperationsHeader
321
+ title={t(projectId ? 'editTitle' : 'newTitle')}
322
+ description={t('description')}
323
+ current={t('breadcrumb')}
324
+ actions={
325
+ <div className="flex gap-2">
326
+ <Button variant="outline" size="sm" asChild>
327
+ <Link
328
+ href={projectId ? `/operations/projects/${projectId}` : '/operations/projects'}
329
+ >
330
+ <ArrowLeft className="size-4" />
331
+ {commonT('actions.back')}
332
+ </Link>
333
+ </Button>
334
+ <Button size="sm" onClick={() => void onSubmit()}>
335
+ <Save className="size-4" />
336
+ {commonT('actions.save')}
337
+ </Button>
338
+ </div>
339
+ }
340
+ />
341
+
342
+ <div className="grid gap-4 xl:grid-cols-2">
343
+ <SectionCard title={t('sections.basicInfo')} description={t('sections.basicInfoDescription')}>
344
+ <div className="grid gap-4 md:grid-cols-2">
345
+ <div className="space-y-2">
346
+ <label className="text-sm font-medium">{t('fields.code')}</label>
347
+ <Input
348
+ value={form.code}
349
+ onChange={(event) =>
350
+ setForm((current) => ({ ...current, code: event.target.value }))
351
+ }
352
+ />
353
+ </div>
354
+ <div className="space-y-2">
355
+ <label className="text-sm font-medium">{t('fields.name')}</label>
356
+ <Input
357
+ value={form.name}
358
+ onChange={(event) =>
359
+ setForm((current) => ({ ...current, name: event.target.value }))
360
+ }
361
+ />
362
+ </div>
363
+ <div className="space-y-2">
364
+ <label className="text-sm font-medium">{t('fields.clientName')}</label>
365
+ <Input
366
+ value={form.clientName}
367
+ onChange={(event) =>
368
+ setForm((current) => ({
369
+ ...current,
370
+ clientName: event.target.value,
371
+ }))
372
+ }
373
+ />
374
+ </div>
375
+ <div className="space-y-2">
376
+ <label className="text-sm font-medium">{t('fields.deliveryModel')}</label>
377
+ <Select
378
+ value={form.deliveryModel}
379
+ onValueChange={(value) =>
380
+ setForm((current) => ({ ...current, deliveryModel: value }))
381
+ }
382
+ >
383
+ <SelectTrigger>
384
+ <SelectValue />
385
+ </SelectTrigger>
386
+ <SelectContent>
387
+ <SelectItem value="project_delivery">Project Delivery</SelectItem>
388
+ <SelectItem value="dedicated_team">Dedicated Team</SelectItem>
389
+ <SelectItem value="shared_team">Shared Team</SelectItem>
390
+ <SelectItem value="support">Support</SelectItem>
391
+ </SelectContent>
392
+ </Select>
393
+ </div>
394
+ <div className="space-y-2 md:col-span-2">
395
+ <label className="text-sm font-medium">{t('fields.summary')}</label>
396
+ <Textarea
397
+ rows={4}
398
+ value={form.summary}
399
+ onChange={(event) =>
400
+ setForm((current) => ({ ...current, summary: event.target.value }))
401
+ }
402
+ />
403
+ </div>
404
+ </div>
405
+ </SectionCard>
406
+
407
+ <SectionCard title={t('sections.governance')} description={t('sections.governanceDescription')}>
408
+ <div className="grid gap-4 md:grid-cols-2">
409
+ <div className="space-y-2">
410
+ <label className="text-sm font-medium">{commonT('labels.manager')}</label>
411
+ <Select
412
+ value={form.managerCollaboratorId}
413
+ onValueChange={(value) =>
414
+ setForm((current) => ({
415
+ ...current,
416
+ managerCollaboratorId: value,
417
+ }))
418
+ }
419
+ >
420
+ <SelectTrigger>
421
+ <SelectValue />
422
+ </SelectTrigger>
423
+ <SelectContent>
424
+ <SelectItem value="none">{commonT('labels.notAssigned')}</SelectItem>
425
+ {collaborators.map((collaborator) => (
426
+ <SelectItem key={collaborator.id} value={String(collaborator.id)}>
427
+ {collaborator.displayName}
428
+ </SelectItem>
429
+ ))}
430
+ </SelectContent>
431
+ </Select>
432
+ </div>
433
+ <div className="space-y-2">
434
+ <label className="text-sm font-medium">{commonT('labels.status')}</label>
435
+ <Select
436
+ value={form.status}
437
+ onValueChange={(value) =>
438
+ setForm((current) => ({ ...current, status: value }))
439
+ }
440
+ >
441
+ <SelectTrigger>
442
+ <SelectValue />
443
+ </SelectTrigger>
444
+ <SelectContent>
445
+ <SelectItem value="planning">Planning</SelectItem>
446
+ <SelectItem value="active">Active</SelectItem>
447
+ <SelectItem value="at_risk">At Risk</SelectItem>
448
+ <SelectItem value="paused">Paused</SelectItem>
449
+ <SelectItem value="completed">Completed</SelectItem>
450
+ <SelectItem value="archived">Archived</SelectItem>
451
+ </SelectContent>
452
+ </Select>
453
+ </div>
454
+ <div className="space-y-2">
455
+ <label className="text-sm font-medium">{commonT('labels.startDate')}</label>
456
+ <Input
457
+ type="date"
458
+ value={form.startDate}
459
+ onChange={(event) =>
460
+ setForm((current) => ({ ...current, startDate: event.target.value }))
461
+ }
462
+ />
463
+ </div>
464
+ <div className="space-y-2">
465
+ <label className="text-sm font-medium">{commonT('labels.endDate')}</label>
466
+ <Input
467
+ type="date"
468
+ value={form.endDate}
469
+ onChange={(event) =>
470
+ setForm((current) => ({ ...current, endDate: event.target.value }))
471
+ }
472
+ />
473
+ </div>
474
+ </div>
475
+ </SectionCard>
476
+ </div>
477
+
478
+ <SectionCard title={t('sections.team')} description={t('sections.teamDescription')}>
479
+ <div className="space-y-3">
480
+ {form.teamAssignments.map((assignment) => {
481
+ const collaborator = collaborators.find(
482
+ (item) => item.id === assignment.collaboratorId
483
+ );
484
+ if (!collaborator) {
485
+ return null;
486
+ }
487
+
488
+ return (
489
+ <div
490
+ key={assignment.collaboratorId}
491
+ className="grid gap-3 rounded-lg border p-4 lg:grid-cols-[minmax(220px,1fr)_minmax(180px,1fr)_120px_120px]"
492
+ >
493
+ <div className="flex items-start gap-3">
494
+ <Checkbox
495
+ checked={assignment.selected}
496
+ onCheckedChange={(checked) =>
497
+ updateAssignment(assignment.collaboratorId, {
498
+ selected: checked === true,
499
+ })
500
+ }
501
+ />
502
+ <div>
503
+ <div className="font-medium">{collaborator.displayName}</div>
504
+ <div className="text-xs text-muted-foreground">
505
+ {[collaborator.department, collaborator.title]
506
+ .filter(Boolean)
507
+ .join(' • ') || commonT('labels.notAvailable')}
508
+ </div>
509
+ </div>
510
+ </div>
511
+ <Input
512
+ placeholder={t('fields.roleLabel')}
513
+ value={assignment.roleLabel}
514
+ disabled={!assignment.selected}
515
+ onChange={(event) =>
516
+ updateAssignment(assignment.collaboratorId, {
517
+ roleLabel: event.target.value,
518
+ })
519
+ }
520
+ />
521
+ <Input
522
+ type="number"
523
+ placeholder={t('fields.weeklyHours')}
524
+ value={assignment.weeklyHours}
525
+ disabled={!assignment.selected}
526
+ onChange={(event) =>
527
+ updateAssignment(assignment.collaboratorId, {
528
+ weeklyHours: event.target.value,
529
+ })
530
+ }
531
+ />
532
+ <Input
533
+ type="number"
534
+ placeholder={t('fields.allocationPercent')}
535
+ value={assignment.allocationPercent}
536
+ disabled={!assignment.selected}
537
+ onChange={(event) =>
538
+ updateAssignment(assignment.collaboratorId, {
539
+ allocationPercent: event.target.value,
540
+ })
541
+ }
542
+ />
543
+ </div>
544
+ );
545
+ })}
546
+ </div>
547
+ </SectionCard>
548
+
549
+ <div className="grid gap-4 xl:grid-cols-2">
550
+ <SectionCard title={t('sections.financials')} description={t('sections.financialsDescription')}>
551
+ <div className="grid gap-4 md:grid-cols-2">
552
+ <div className="space-y-2">
553
+ <label className="text-sm font-medium">{commonT('labels.budget')}</label>
554
+ <Input
555
+ type="number"
556
+ step="0.01"
557
+ value={form.budgetAmount}
558
+ onChange={(event) =>
559
+ setForm((current) => ({ ...current, budgetAmount: event.target.value }))
560
+ }
561
+ />
562
+ </div>
563
+ <div className="space-y-2">
564
+ <label className="text-sm font-medium">{commonT('labels.monthlyHourCap')}</label>
565
+ <Input
566
+ type="number"
567
+ step="0.5"
568
+ value={form.monthlyHourCap}
569
+ onChange={(event) =>
570
+ setForm((current) => ({
571
+ ...current,
572
+ monthlyHourCap: event.target.value,
573
+ }))
574
+ }
575
+ />
576
+ </div>
577
+ <div className="space-y-2">
578
+ <label className="text-sm font-medium">{commonT('labels.billingModel')}</label>
579
+ <Select
580
+ value={form.billingModel}
581
+ onValueChange={(value) =>
582
+ setForm((current) => ({ ...current, billingModel: value }))
583
+ }
584
+ >
585
+ <SelectTrigger>
586
+ <SelectValue />
587
+ </SelectTrigger>
588
+ <SelectContent>
589
+ <SelectItem value="time_and_material">Time And Material</SelectItem>
590
+ <SelectItem value="monthly_retainer">Monthly Retainer</SelectItem>
591
+ <SelectItem value="fixed_price">Fixed Price</SelectItem>
592
+ </SelectContent>
593
+ </Select>
594
+ </div>
595
+ <div className="space-y-2">
596
+ <label className="text-sm font-medium">{commonT('labels.contract')}</label>
597
+ <Select
598
+ value={form.contractId}
599
+ onValueChange={(value) =>
600
+ setForm((current) => ({ ...current, contractId: value }))
601
+ }
602
+ >
603
+ <SelectTrigger>
604
+ <SelectValue />
605
+ </SelectTrigger>
606
+ <SelectContent>
607
+ <SelectItem value="none">{commonT('labels.notAssigned')}</SelectItem>
608
+ {contracts.map((contract) => (
609
+ <SelectItem key={contract.id} value={String(contract.id)}>
610
+ {contract.name}
611
+ </SelectItem>
612
+ ))}
613
+ </SelectContent>
614
+ </Select>
615
+ </div>
616
+ </div>
617
+ </SectionCard>
618
+
619
+ <SectionCard title={t('sections.contract')} description={t('sections.contractDescription')}>
620
+ <div className="grid gap-4 md:grid-cols-2">
621
+ <div className="space-y-2">
622
+ <label className="text-sm font-medium">{t('fields.contractCode')}</label>
623
+ <Input
624
+ value={form.contractCode}
625
+ onChange={(event) =>
626
+ setForm((current) => ({ ...current, contractCode: event.target.value }))
627
+ }
628
+ />
629
+ </div>
630
+ <div className="space-y-2">
631
+ <label className="text-sm font-medium">{t('fields.contractName')}</label>
632
+ <Input
633
+ value={form.contractName}
634
+ onChange={(event) =>
635
+ setForm((current) => ({ ...current, contractName: event.target.value }))
636
+ }
637
+ />
638
+ </div>
639
+ <div className="space-y-2 md:col-span-2">
640
+ <label className="text-sm font-medium">{t('fields.contractDescription')}</label>
641
+ <Textarea
642
+ rows={4}
643
+ value={form.contractDescription}
644
+ onChange={(event) =>
645
+ setForm((current) => ({
646
+ ...current,
647
+ contractDescription: event.target.value,
648
+ }))
649
+ }
650
+ />
651
+ </div>
652
+ <div className="flex items-center justify-between rounded-lg border px-4 py-3 md:col-span-2">
653
+ <div>
654
+ <div className="font-medium">{t('fields.autoGenerateContractDraft')}</div>
655
+ <div className="text-sm text-muted-foreground">
656
+ {form.contractId === 'none'
657
+ ? t('fields.autoGenerateContractDraftDescription')
658
+ : t('fields.existingContractSelected')}
659
+ </div>
660
+ </div>
661
+ <Switch
662
+ checked={form.contractId === 'none' && form.autoGenerateContractDraft}
663
+ disabled={form.contractId !== 'none'}
664
+ onCheckedChange={(checked) =>
665
+ setForm((current) => ({
666
+ ...current,
667
+ autoGenerateContractDraft: checked,
668
+ }))
669
+ }
670
+ />
671
+ </div>
672
+ </div>
673
+ </SectionCard>
674
+ </div>
675
+
676
+ {projectId && isLoadingProject ? (
677
+ <div className="text-sm text-muted-foreground">{t('loading')}</div>
678
+ ) : null}
679
+
680
+ {project ? (
681
+ <div className="text-xs text-muted-foreground">
682
+ {t('messages.currentContractStatus', {
683
+ status: formatEnumLabel(project.contractStatus),
684
+ })}
685
+ </div>
686
+ ) : null}
687
+ </Page>
688
+ );
689
+ }