@hed-hog/operations 0.0.303 → 0.0.305

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 (178) hide show
  1. package/README.md +200 -43
  2. package/dist/controllers/operations-approvals.controller.d.ts +9 -0
  3. package/dist/controllers/operations-approvals.controller.d.ts.map +1 -0
  4. package/dist/controllers/operations-approvals.controller.js +64 -0
  5. package/dist/controllers/operations-approvals.controller.js.map +1 -0
  6. package/dist/controllers/operations-collaborators.controller.d.ts +223 -0
  7. package/dist/controllers/operations-collaborators.controller.d.ts.map +1 -0
  8. package/dist/controllers/operations-collaborators.controller.js +96 -0
  9. package/dist/controllers/operations-collaborators.controller.js.map +1 -0
  10. package/dist/controllers/operations-contracts.controller.d.ts +683 -0
  11. package/dist/controllers/operations-contracts.controller.d.ts.map +1 -0
  12. package/dist/controllers/operations-contracts.controller.js +198 -0
  13. package/dist/controllers/operations-contracts.controller.js.map +1 -0
  14. package/dist/controllers/operations-org-structure.controller.d.ts +108 -0
  15. package/dist/controllers/operations-org-structure.controller.d.ts.map +1 -0
  16. package/dist/controllers/operations-org-structure.controller.js +143 -0
  17. package/dist/controllers/operations-org-structure.controller.js.map +1 -0
  18. package/dist/controllers/operations-projects.controller.d.ts +184 -0
  19. package/dist/controllers/operations-projects.controller.d.ts.map +1 -0
  20. package/dist/controllers/operations-projects.controller.js +87 -0
  21. package/dist/controllers/operations-projects.controller.js.map +1 -0
  22. package/dist/controllers/operations-tasks.controller.d.ts +85 -0
  23. package/dist/controllers/operations-tasks.controller.d.ts.map +1 -0
  24. package/dist/controllers/operations-tasks.controller.js +90 -0
  25. package/dist/controllers/operations-tasks.controller.js.map +1 -0
  26. package/dist/controllers/operations-timesheets.controller.d.ts +99 -0
  27. package/dist/controllers/operations-timesheets.controller.d.ts.map +1 -0
  28. package/dist/controllers/operations-timesheets.controller.js +154 -0
  29. package/dist/controllers/operations-timesheets.controller.js.map +1 -0
  30. package/dist/dto/create-collaborator-type.dto.d.ts +10 -0
  31. package/dist/dto/create-collaborator-type.dto.d.ts.map +1 -0
  32. package/dist/dto/create-collaborator-type.dto.js +56 -0
  33. package/dist/dto/create-collaborator-type.dto.js.map +1 -0
  34. package/dist/dto/create-collaborator.dto.d.ts +42 -0
  35. package/dist/dto/create-collaborator.dto.d.ts.map +1 -0
  36. package/dist/dto/create-collaborator.dto.js +228 -0
  37. package/dist/dto/create-collaborator.dto.js.map +1 -0
  38. package/dist/dto/create-schedule-adjustment-request.dto.d.ts +17 -0
  39. package/dist/dto/create-schedule-adjustment-request.dto.d.ts.map +1 -0
  40. package/dist/dto/create-schedule-adjustment-request.dto.js +89 -0
  41. package/dist/dto/create-schedule-adjustment-request.dto.js.map +1 -0
  42. package/dist/dto/create-task.dto.d.ts +14 -0
  43. package/dist/dto/create-task.dto.d.ts.map +1 -0
  44. package/dist/dto/create-task.dto.js +83 -0
  45. package/dist/dto/create-task.dto.js.map +1 -0
  46. package/dist/dto/create-time-off-request.dto.d.ts +9 -0
  47. package/dist/dto/create-time-off-request.dto.d.ts.map +1 -0
  48. package/dist/dto/create-time-off-request.dto.js +54 -0
  49. package/dist/dto/create-time-off-request.dto.js.map +1 -0
  50. package/dist/dto/create-timesheet-entry.dto.d.ts +12 -0
  51. package/dist/dto/create-timesheet-entry.dto.d.ts.map +1 -0
  52. package/dist/dto/create-timesheet-entry.dto.js +75 -0
  53. package/dist/dto/create-timesheet-entry.dto.js.map +1 -0
  54. package/dist/dto/list-collaborator-types.dto.d.ts +4 -0
  55. package/dist/dto/list-collaborator-types.dto.d.ts.map +1 -0
  56. package/dist/dto/list-collaborator-types.dto.js +29 -0
  57. package/dist/dto/list-collaborator-types.dto.js.map +1 -0
  58. package/dist/dto/list-collaborators.dto.d.ts +8 -0
  59. package/dist/dto/list-collaborators.dto.d.ts.map +1 -0
  60. package/dist/dto/list-collaborators.dto.js +42 -0
  61. package/dist/dto/list-collaborators.dto.js.map +1 -0
  62. package/dist/dto/list-project-options.dto.d.ts +4 -0
  63. package/dist/dto/list-project-options.dto.d.ts.map +1 -0
  64. package/dist/dto/list-project-options.dto.js +8 -0
  65. package/dist/dto/list-project-options.dto.js.map +1 -0
  66. package/dist/dto/list-tasks.dto.d.ts +7 -0
  67. package/dist/dto/list-tasks.dto.d.ts.map +1 -0
  68. package/dist/dto/list-tasks.dto.js +38 -0
  69. package/dist/dto/list-tasks.dto.js.map +1 -0
  70. package/dist/dto/list-timesheet-entries.dto.d.ts +10 -0
  71. package/dist/dto/list-timesheet-entries.dto.d.ts.map +1 -0
  72. package/dist/dto/list-timesheet-entries.dto.js +54 -0
  73. package/dist/dto/list-timesheet-entries.dto.js.map +1 -0
  74. package/dist/dto/update-collaborator-type.dto.d.ts +4 -0
  75. package/dist/dto/update-collaborator-type.dto.d.ts.map +1 -0
  76. package/dist/dto/update-collaborator-type.dto.js +8 -0
  77. package/dist/dto/update-collaborator-type.dto.js.map +1 -0
  78. package/dist/dto/update-collaborator.dto.d.ts +4 -0
  79. package/dist/dto/update-collaborator.dto.d.ts.map +1 -0
  80. package/dist/dto/update-collaborator.dto.js +8 -0
  81. package/dist/dto/update-collaborator.dto.js.map +1 -0
  82. package/dist/dto/update-task.dto.d.ts +14 -0
  83. package/dist/dto/update-task.dto.d.ts.map +1 -0
  84. package/dist/dto/update-task.dto.js +84 -0
  85. package/dist/dto/update-task.dto.js.map +1 -0
  86. package/dist/operations.controller.d.ts +0 -1045
  87. package/dist/operations.controller.d.ts.map +1 -1
  88. package/dist/operations.controller.js +0 -429
  89. package/dist/operations.controller.js.map +1 -1
  90. package/dist/operations.module.d.ts.map +1 -1
  91. package/dist/operations.module.js +23 -2
  92. package/dist/operations.module.js.map +1 -1
  93. package/dist/operations.service.d.ts +429 -8
  94. package/dist/operations.service.d.ts.map +1 -1
  95. package/dist/operations.service.js +1931 -165
  96. package/dist/operations.service.js.map +1 -1
  97. package/dist/operations.service.spec.js +315 -1
  98. package/dist/operations.service.spec.js.map +1 -1
  99. package/dist/services/shared/operations-access.service.d.ts +16 -0
  100. package/dist/services/shared/operations-access.service.d.ts.map +1 -0
  101. package/dist/services/shared/operations-access.service.js +48 -0
  102. package/dist/services/shared/operations-access.service.js.map +1 -0
  103. package/hedhog/data/dashboard.yaml +20 -0
  104. package/hedhog/data/dashboard_component.yaml +274 -0
  105. package/hedhog/data/dashboard_component_role.yaml +174 -0
  106. package/hedhog/data/dashboard_item.yaml +299 -0
  107. package/hedhog/data/dashboard_role.yaml +20 -0
  108. package/hedhog/data/menu.yaml +30 -13
  109. package/hedhog/data/operations_collaborator_type.yaml +76 -0
  110. package/hedhog/data/route.yaml +196 -0
  111. package/hedhog/frontend/app/_components/async-options-combobox.tsx.ejs +231 -0
  112. package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +125 -40
  113. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +740 -106
  114. package/hedhog/frontend/app/_components/collaborator-select-with-create.tsx.ejs +256 -256
  115. package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +7 -7
  116. package/hedhog/frontend/app/_components/contract-template-form-screen.tsx.ejs +306 -306
  117. package/hedhog/frontend/app/_components/contract-template-select-with-create.tsx.ejs +247 -247
  118. package/hedhog/frontend/app/_components/contract-wizard-sheet.tsx.ejs +3520 -3520
  119. package/hedhog/frontend/app/_components/department-select-with-create.tsx.ejs +38 -16
  120. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +1504 -52
  121. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +1017 -649
  122. package/hedhog/frontend/app/_components/section-card.tsx.ejs +25 -18
  123. package/hedhog/frontend/app/_components/system-user-select-with-create.tsx.ejs +609 -0
  124. package/hedhog/frontend/app/_components/timesheet-task-create-sheet.tsx.ejs +213 -0
  125. package/hedhog/frontend/app/_lib/api.ts.ejs +30 -1
  126. package/hedhog/frontend/app/_lib/types.ts.ejs +147 -39
  127. package/hedhog/frontend/app/_lib/utils/format.ts.ejs +40 -9
  128. package/hedhog/frontend/app/_lib/utils/forms.ts.ejs +48 -1
  129. package/hedhog/frontend/app/approvals/page.tsx.ejs +116 -98
  130. package/hedhog/frontend/app/collaborator-types/page.tsx.ejs +502 -0
  131. package/hedhog/frontend/app/collaborators/page.tsx.ejs +116 -72
  132. package/hedhog/frontend/app/contracts/page.tsx.ejs +938 -938
  133. package/hedhog/frontend/app/contracts/templates/page.tsx.ejs +11 -9
  134. package/hedhog/frontend/app/departments/page.tsx.ejs +1 -1
  135. package/hedhog/frontend/app/projects/[id]/edit/page.tsx.ejs +1 -1
  136. package/hedhog/frontend/app/projects/page.tsx.ejs +364 -133
  137. package/hedhog/frontend/app/schedule-adjustments/page.tsx.ejs +244 -120
  138. package/hedhog/frontend/app/team/page.tsx.ejs +15 -2
  139. package/hedhog/frontend/app/time-off/page.tsx.ejs +158 -82
  140. package/hedhog/frontend/app/timesheets/page.tsx.ejs +814 -357
  141. package/hedhog/frontend/messages/en.json +268 -53
  142. package/hedhog/frontend/messages/pt.json +484 -271
  143. package/hedhog/table/operations_collaborator.yaml +26 -13
  144. package/hedhog/table/operations_collaborator_equity_participation.yaml +43 -0
  145. package/hedhog/table/operations_collaborator_type.yaml +33 -0
  146. package/hedhog/table/operations_job_title.yaml +24 -0
  147. package/hedhog/table/operations_project.yaml +9 -0
  148. package/hedhog/table/operations_project_assignment.yaml +9 -0
  149. package/hedhog/table/operations_project_role.yaml +39 -0
  150. package/hedhog/table/operations_task.yaml +69 -0
  151. package/hedhog/table/operations_timesheet_entry.yaml +12 -0
  152. package/package.json +6 -6
  153. package/src/controllers/operations-approvals.controller.ts +24 -0
  154. package/src/controllers/operations-collaborators.controller.ts +60 -0
  155. package/src/controllers/operations-contracts.controller.ts +138 -0
  156. package/src/controllers/operations-org-structure.controller.ts +92 -0
  157. package/src/controllers/operations-projects.controller.ts +50 -0
  158. package/src/controllers/operations-tasks.controller.ts +63 -0
  159. package/src/controllers/operations-timesheets.controller.ts +100 -0
  160. package/src/dto/create-collaborator-type.dto.ts +43 -0
  161. package/src/dto/create-collaborator.dto.ts +223 -0
  162. package/src/dto/create-schedule-adjustment-request.dto.ts +91 -0
  163. package/src/dto/create-task.dto.ts +75 -0
  164. package/src/dto/create-time-off-request.dto.ts +53 -0
  165. package/src/dto/create-timesheet-entry.dto.ts +67 -0
  166. package/src/dto/list-collaborator-types.dto.ts +15 -0
  167. package/src/dto/list-collaborators.dto.ts +30 -0
  168. package/src/dto/list-project-options.dto.ts +3 -0
  169. package/src/dto/list-tasks.dto.ts +25 -0
  170. package/src/dto/list-timesheet-entries.dto.ts +40 -0
  171. package/src/dto/update-collaborator-type.dto.ts +3 -0
  172. package/src/dto/update-collaborator.dto.ts +3 -0
  173. package/src/dto/update-task.dto.ts +76 -0
  174. package/src/operations.controller.ts +1 -278
  175. package/src/operations.module.ts +23 -2
  176. package/src/operations.service.spec.ts +450 -0
  177. package/src/operations.service.ts +4507 -1561
  178. package/src/services/shared/operations-access.service.ts +52 -0
@@ -1,938 +1,938 @@
1
- 'use client';
2
-
3
- import { EmptyState, Page, SearchBar } from '@/components/entity-list';
4
- import {
5
- AlertDialog,
6
- AlertDialogAction,
7
- AlertDialogCancel,
8
- AlertDialogContent,
9
- AlertDialogDescription,
10
- AlertDialogFooter,
11
- AlertDialogHeader,
12
- AlertDialogTitle,
13
- } from '@/components/ui/alert-dialog';
14
- import { Button } from '@/components/ui/button';
15
- import { Card, CardContent } from '@/components/ui/card';
16
- import {
17
- DropdownMenu,
18
- DropdownMenuContent,
19
- DropdownMenuItem,
20
- DropdownMenuSeparator,
21
- DropdownMenuTrigger,
22
- } from '@/components/ui/dropdown-menu';
23
- import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
24
- import {
25
- Sheet,
26
- SheetContent,
27
- SheetDescription,
28
- SheetHeader,
29
- SheetTitle,
30
- } from '@/components/ui/sheet';
31
- import {
32
- Table,
33
- TableBody,
34
- TableCell,
35
- TableHead,
36
- TableHeader,
37
- TableRow,
38
- } from '@/components/ui/table';
39
- import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
40
- import { useApp, useQuery } from '@hed-hog/next-app-provider';
41
- import {
42
- Download,
43
- FileStack,
44
- FileText,
45
- LayoutGrid,
46
- List,
47
- MoreHorizontal,
48
- Pencil,
49
- Sparkles,
50
- Trash2,
51
- Upload,
52
- } from 'lucide-react';
53
- import { useTranslations } from 'next-intl';
54
- import { usePathname, useRouter, useSearchParams } from 'next/navigation';
55
- import { useMemo, useRef, useState } from 'react';
56
- import { ContractWizardSheet } from '../_components/contract-wizard-sheet';
57
- import { OperationsHeader } from '../_components/operations-header';
58
- import { StatusBadge } from '../_components/status-badge';
59
- import { fetchOperations, mutateOperations } from '../_lib/api';
60
- import { useOperationsAccess } from '../_lib/hooks/use-operations-access';
61
- import type {
62
- OperationsContract,
63
- OperationsContractDetails,
64
- } from '../_lib/types';
65
- import {
66
- formatCurrency,
67
- formatDate,
68
- formatEnumLabel,
69
- getStatusBadgeClass,
70
- } from '../_lib/utils/format';
71
-
72
- function downloadBase64File(
73
- fileName: string,
74
- mimeType: string,
75
- base64: string
76
- ) {
77
- const href = `data:${mimeType};base64,${base64}`;
78
- const link = document.createElement('a');
79
- link.href = href;
80
- link.download = fileName;
81
- link.click();
82
- }
83
-
84
- const CONTRACT_VIEW_STORAGE_KEY = 'operations-contracts-view-mode';
85
-
86
- type ContractViewMode = 'table' | 'cards';
87
-
88
- function openStoredFile(fileId?: number | null) {
89
- if (!fileId) return;
90
- const baseUrl = String(process.env.NEXT_PUBLIC_API_BASE_URL || '');
91
- window.open(
92
- `${baseUrl}/file/open/${fileId}`,
93
- '_blank',
94
- 'noopener,noreferrer'
95
- );
96
- }
97
-
98
- async function fileToBase64(file: File) {
99
- return new Promise<string>((resolve, reject) => {
100
- const reader = new FileReader();
101
- reader.onload = () => {
102
- const result = String(reader.result ?? '');
103
- const [, base64 = ''] = result.split(',');
104
- resolve(base64);
105
- };
106
- reader.onerror = reject;
107
- reader.readAsDataURL(file);
108
- });
109
- }
110
-
111
- export default function OperationsContractsPage() {
112
- const t = useTranslations('operations.ContractsPage');
113
- const commonT = useTranslations('operations.Common');
114
- const formT = useTranslations('operations.ContractFormPage');
115
- const { request, showToastHandler, currentLocaleCode } = useApp();
116
- const access = useOperationsAccess();
117
- const router = useRouter();
118
- const pathname = usePathname();
119
- const searchParams = useSearchParams();
120
- const [search, setSearch] = useState('');
121
- const [statusFilter, setStatusFilter] = useState('all');
122
- const [categoryFilter, setCategoryFilter] = useState('all');
123
- const [originFilter, setOriginFilter] = useState('all');
124
- const uploadTargetRef = useRef<number | null>(null);
125
- const fileInputRef = useRef<HTMLInputElement>(null);
126
- const [isCreateSheetOpen, setIsCreateSheetOpen] = useState(false);
127
- const [contractToDelete, setContractToDelete] =
128
- useState<OperationsContract | null>(null);
129
- const [isDeletingContract, setIsDeletingContract] = useState(false);
130
- const [viewMode, setViewMode] = useState<ContractViewMode>(() => {
131
- if (typeof window === 'undefined') {
132
- return 'table';
133
- }
134
-
135
- const savedViewMode = window.localStorage.getItem(
136
- CONTRACT_VIEW_STORAGE_KEY
137
- );
138
-
139
- return savedViewMode === 'cards' ? 'cards' : 'table';
140
- });
141
-
142
- const getContractOptionLabel = (group: string, value?: string | null) => {
143
- if (!value) {
144
- return '-';
145
- }
146
-
147
- const key = `options.${group}.${value}`;
148
- return formT.has(key) ? formT(key) : formatEnumLabel(value);
149
- };
150
-
151
- const editParam = searchParams.get('edit');
152
- const duplicateParam = searchParams.get('duplicate');
153
- const templateParam = searchParams.get('template');
154
- const editingContractId =
155
- editParam && /^\d+$/.test(editParam) ? Number(editParam) : null;
156
- const duplicatingContractId =
157
- duplicateParam && /^\d+$/.test(duplicateParam)
158
- ? Number(duplicateParam)
159
- : null;
160
- const creatingFromTemplateId =
161
- templateParam && /^\d+$/.test(templateParam) ? Number(templateParam) : null;
162
-
163
- const updateSheetQuery = (options?: {
164
- editId?: number | null;
165
- duplicateId?: number | null;
166
- templateId?: number | null;
167
- }) => {
168
- const params = new URLSearchParams(searchParams.toString());
169
-
170
- params.delete('edit');
171
- params.delete('duplicate');
172
- params.delete('template');
173
-
174
- if (options?.editId && options.editId > 0) {
175
- params.set('edit', String(options.editId));
176
- }
177
-
178
- if (options?.duplicateId && options.duplicateId > 0) {
179
- params.set('duplicate', String(options.duplicateId));
180
- }
181
-
182
- if (options?.templateId && options.templateId > 0) {
183
- params.set('template', String(options.templateId));
184
- }
185
-
186
- const nextUrl = params.size ? `${pathname}?${params.toString()}` : pathname;
187
- router.replace(nextUrl, { scroll: false });
188
- };
189
-
190
- const openCreateSheet = (templateId?: number | null) => {
191
- if (templateId && templateId > 0) {
192
- updateSheetQuery({ templateId });
193
- return;
194
- }
195
-
196
- setIsCreateSheetOpen(true);
197
- updateSheetQuery();
198
- };
199
-
200
- const openEditSheet = (contractId: number) => {
201
- setIsCreateSheetOpen(false);
202
- updateSheetQuery({ editId: contractId });
203
- };
204
-
205
- const openDuplicateSheet = (contractId: number) => {
206
- setIsCreateSheetOpen(false);
207
- updateSheetQuery({ duplicateId: contractId });
208
- };
209
-
210
- const closeFormSheet = () => {
211
- setIsCreateSheetOpen(false);
212
-
213
- if (
214
- editingContractId !== null ||
215
- duplicatingContractId !== null ||
216
- creatingFromTemplateId !== null
217
- ) {
218
- updateSheetQuery();
219
- }
220
- };
221
-
222
- const { data: contracts = [], refetch } = useQuery<OperationsContract[]>({
223
- queryKey: ['operations-contracts-list', currentLocaleCode],
224
- queryFn: () =>
225
- fetchOperations<OperationsContract[]>(request, '/operations/contracts'),
226
- });
227
-
228
- const filteredRows = useMemo(
229
- () =>
230
- contracts.filter((item) => {
231
- const matchesSearch = !search.trim()
232
- ? true
233
- : [
234
- item.name,
235
- item.code,
236
- item.mainRelatedPartyName,
237
- item.clientName,
238
- item.contractType,
239
- ]
240
- .filter(Boolean)
241
- .some((value) =>
242
- String(value)
243
- .toLowerCase()
244
- .includes(search.trim().toLowerCase())
245
- );
246
- const matchesStatus =
247
- statusFilter === 'all' ? true : item.status === statusFilter;
248
- const matchesCategory =
249
- categoryFilter === 'all'
250
- ? true
251
- : item.contractCategory === categoryFilter;
252
- const matchesOrigin =
253
- originFilter === 'all' ? true : item.originType === originFilter;
254
- return (
255
- matchesSearch && matchesStatus && matchesCategory && matchesOrigin
256
- );
257
- }),
258
- [contracts, search, statusFilter, categoryFilter, originFilter]
259
- );
260
-
261
- const sheetTitle = editingContractId
262
- ? commonT('actions.edit')
263
- : duplicatingContractId
264
- ? t('actions.duplicate')
265
- : isCreateSheetOpen || creatingFromTemplateId
266
- ? commonT('actions.create')
267
- : t('title');
268
-
269
- const statsCards = useMemo(
270
- () => [
271
- {
272
- key: 'total',
273
- title: t('cards.total'),
274
- value: contracts.length,
275
- icon: FileText,
276
- },
277
- {
278
- key: 'active',
279
- title: t('cards.active'),
280
- value: contracts.filter((item) => item.status === 'active').length,
281
- icon: Sparkles,
282
- },
283
- {
284
- key: 'underReview',
285
- title: t('cards.underReview'),
286
- value: contracts.filter((item) => item.status === 'under_review')
287
- .length,
288
- icon: Pencil,
289
- },
290
- {
291
- key: 'withTemplate',
292
- title: t('cards.withTemplate'),
293
- value: contracts.filter((item) => Boolean(item.contractTemplateId))
294
- .length,
295
- icon: FileStack,
296
- },
297
- ],
298
- [contracts, t]
299
- );
300
-
301
- const handleViewModeChange = (value: string) => {
302
- if (value !== 'table' && value !== 'cards') {
303
- return;
304
- }
305
-
306
- setViewMode(value);
307
-
308
- if (typeof window !== 'undefined') {
309
- window.localStorage.setItem(CONTRACT_VIEW_STORAGE_KEY, value);
310
- }
311
- };
312
-
313
- const downloadPdf = async (contractId: number) => {
314
- const detail = await fetchOperations<OperationsContractDetails>(
315
- request,
316
- `/operations/contracts/${contractId}`
317
- );
318
- const document = detail.documents.find(
319
- (item) =>
320
- item.isCurrent &&
321
- (item.documentType === 'generated_pdf' ||
322
- item.documentType === 'source_upload')
323
- );
324
- if (document?.fileId) {
325
- openStoredFile(document.fileId);
326
- return;
327
- }
328
- if (!document?.fileContentBase64) {
329
- showToastHandler?.('error', t('messages.noPdf'));
330
- return;
331
- }
332
- downloadBase64File(
333
- document.fileName,
334
- document.mimeType,
335
- document.fileContentBase64
336
- );
337
- };
338
-
339
- const duplicateContract = (contractId: number) => {
340
- openDuplicateSheet(contractId);
341
- };
342
-
343
- const handleDeleteContract = async () => {
344
- if (!contractToDelete?.id) {
345
- return;
346
- }
347
-
348
- try {
349
- setIsDeletingContract(true);
350
- await request({
351
- url: `/operations/contracts/${contractToDelete.id}`,
352
- method: 'DELETE',
353
- });
354
- showToastHandler?.('success', t('messages.deleteSuccess'));
355
- setContractToDelete(null);
356
- await refetch();
357
- } catch {
358
- showToastHandler?.('error', t('messages.deleteError'));
359
- } finally {
360
- setIsDeletingContract(false);
361
- }
362
- };
363
-
364
- const toggleArchived = async (contract: OperationsContract) => {
365
- try {
366
- await mutateOperations(
367
- request,
368
- `/operations/contracts/${contract.id}`,
369
- 'PATCH',
370
- {
371
- status: contract.status === 'archived' ? 'active' : 'archived',
372
- isActive: contract.status === 'archived',
373
- }
374
- );
375
- showToastHandler?.('success', t('messages.statusSuccess'));
376
- await refetch();
377
- } catch {
378
- showToastHandler?.('error', t('messages.statusError'));
379
- }
380
- };
381
-
382
- const renderContractActions = (
383
- contract: OperationsContract,
384
- align: 'start' | 'end' = 'end'
385
- ) => (
386
- <DropdownMenu>
387
- <DropdownMenuTrigger asChild>
388
- <Button
389
- variant="outline"
390
- size="icon"
391
- className="cursor-pointer"
392
- aria-label={commonT('labels.actions')}
393
- >
394
- <MoreHorizontal className="size-4" />
395
- </Button>
396
- </DropdownMenuTrigger>
397
- <DropdownMenuContent align={align} className="w-52">
398
- {access.isDirector ? (
399
- <DropdownMenuItem onSelect={() => openEditSheet(contract.id)}>
400
- <Pencil className="size-4" />
401
- {commonT('actions.edit')}
402
- </DropdownMenuItem>
403
- ) : null}
404
- <DropdownMenuItem onSelect={() => void downloadPdf(contract.id)}>
405
- <Download className="size-4" />
406
- {t('actions.downloadPdf')}
407
- </DropdownMenuItem>
408
- {access.isDirector ? (
409
- <DropdownMenuItem
410
- onSelect={() => {
411
- uploadTargetRef.current = contract.id;
412
- fileInputRef.current?.click();
413
- }}
414
- >
415
- <Upload className="size-4" />
416
- {t('actions.uploadPdf')}
417
- </DropdownMenuItem>
418
- ) : null}
419
- {access.isDirector ? <DropdownMenuSeparator /> : null}
420
- {access.isDirector ? (
421
- <DropdownMenuItem
422
- onSelect={() => void duplicateContract(contract.id)}
423
- >
424
- <FileStack className="size-4" />
425
- {t('actions.duplicate')}
426
- </DropdownMenuItem>
427
- ) : null}
428
- {access.isDirector ? (
429
- <DropdownMenuItem onSelect={() => void toggleArchived(contract)}>
430
- <Sparkles className="size-4" />
431
- {contract.status === 'archived'
432
- ? commonT('actions.activate')
433
- : t('actions.archive')}
434
- </DropdownMenuItem>
435
- ) : null}
436
- {access.isDirector ? <DropdownMenuSeparator /> : null}
437
- {access.isDirector ? (
438
- <DropdownMenuItem
439
- onSelect={() => setContractToDelete(contract)}
440
- className="text-destructive focus:text-destructive"
441
- >
442
- <Trash2 className="size-4 text-destructive" />
443
- {t('actions.delete')}
444
- </DropdownMenuItem>
445
- ) : null}
446
- </DropdownMenuContent>
447
- </DropdownMenu>
448
- );
449
-
450
- return (
451
- <Page>
452
- <OperationsHeader
453
- title={t('title')}
454
- description={t('description')}
455
- current={t('breadcrumb')}
456
- actions={
457
- access.isDirector ? (
458
- <div className="flex flex-wrap gap-2">
459
- <Button
460
- size="sm"
461
- className="cursor-pointer"
462
- onClick={() => openCreateSheet()}
463
- >
464
- {commonT('actions.create')}
465
- </Button>
466
- </div>
467
- ) : undefined
468
- }
469
- />
470
-
471
- <KpiCardsGrid items={statsCards} columns={4} />
472
-
473
- <div className="flex min-w-0 flex-col gap-4 xl:flex-row xl:items-center">
474
- <div className="flex-1">
475
- <SearchBar
476
- searchQuery={search}
477
- onSearchChange={setSearch}
478
- onSearch={() => undefined}
479
- placeholder={t('searchPlaceholder')}
480
- controls={[
481
- {
482
- id: 'category',
483
- type: 'select',
484
- value: categoryFilter,
485
- onChange: setCategoryFilter,
486
- placeholder: commonT('labels.contractCategory'),
487
- options: [
488
- { value: 'all', label: commonT('filters.allTypes') },
489
- ...[
490
- 'employee',
491
- 'contractor',
492
- 'client',
493
- 'supplier',
494
- 'vendor',
495
- 'partner',
496
- 'internal',
497
- 'other',
498
- ].map((value) => ({
499
- value,
500
- label: getContractOptionLabel('contractCategories', value),
501
- })),
502
- ],
503
- },
504
- {
505
- id: 'origin',
506
- type: 'select',
507
- value: originFilter,
508
- onChange: setOriginFilter,
509
- placeholder: t('filters.originType'),
510
- options: [
511
- { value: 'all', label: commonT('filters.allTypes') },
512
- ...['manual', 'employee_hiring', 'client_project'].map(
513
- (value) => ({
514
- value,
515
- label: getContractOptionLabel('originTypes', value),
516
- })
517
- ),
518
- ],
519
- },
520
- {
521
- id: 'status',
522
- type: 'select',
523
- value: statusFilter,
524
- onChange: setStatusFilter,
525
- placeholder: commonT('labels.status'),
526
- options: [
527
- { value: 'all', label: commonT('filters.allStatuses') },
528
- ...[
529
- 'draft',
530
- 'under_review',
531
- 'active',
532
- 'renewal',
533
- 'expired',
534
- 'closed',
535
- 'archived',
536
- ].map((value) => ({
537
- value,
538
- label: getContractOptionLabel('statuses', value),
539
- })),
540
- ],
541
- },
542
- ]}
543
- />
544
- </div>
545
-
546
- <div className="flex items-center justify-between gap-3 xl:justify-end">
547
- <span className="text-xs font-medium text-muted-foreground">
548
- {t('viewMode')}
549
- </span>
550
- <ToggleGroup
551
- type="single"
552
- value={viewMode}
553
- onValueChange={handleViewModeChange}
554
- variant="outline"
555
- size="sm"
556
- aria-label={t('viewMode')}
557
- >
558
- <ToggleGroupItem
559
- value="table"
560
- className="gap-1.5 px-2.5"
561
- aria-label={t('viewModeTable')}
562
- >
563
- <List className="h-4 w-4" />
564
- <span className="hidden sm:inline">{t('viewModeTable')}</span>
565
- </ToggleGroupItem>
566
- <ToggleGroupItem
567
- value="cards"
568
- className="gap-1.5 px-2.5"
569
- aria-label={t('viewModeCards')}
570
- >
571
- <LayoutGrid className="h-4 w-4" />
572
- <span className="hidden sm:inline">{t('viewModeCards')}</span>
573
- </ToggleGroupItem>
574
- </ToggleGroup>
575
- </div>
576
- </div>
577
-
578
- <input
579
- ref={fileInputRef}
580
- type="file"
581
- accept="application/pdf"
582
- className="hidden"
583
- onChange={async (event) => {
584
- const file = event.target.files?.[0];
585
- const contractId = uploadTargetRef.current;
586
- if (!file || !contractId) return;
587
- try {
588
- const fileContentBase64 = await fileToBase64(file);
589
- await mutateOperations(
590
- request,
591
- `/operations/contracts/${contractId}`,
592
- 'PATCH',
593
- {
594
- replaceUploadedPdfDocument: {
595
- fileName: file.name,
596
- mimeType: file.type || 'application/pdf',
597
- fileContentBase64,
598
- },
599
- }
600
- );
601
- showToastHandler?.('success', t('messages.uploadSuccess'));
602
- await refetch();
603
- } catch {
604
- showToastHandler?.('error', t('messages.uploadError'));
605
- } finally {
606
- uploadTargetRef.current = null;
607
- event.target.value = '';
608
- }
609
- }}
610
- />
611
-
612
- {filteredRows.length ? (
613
- viewMode === 'cards' ? (
614
- <div className="grid gap-4 md:grid-cols-2 2xl:grid-cols-3">
615
- {filteredRows.map((contract) => {
616
- const financialTotal =
617
- (contract.valueAmount ?? 0) + (contract.revenueAmount ?? 0);
618
- const contractTitle =
619
- contract.name ||
620
- contract.code ||
621
- commonT('labels.notAvailable');
622
- const secondaryLine = [
623
- contract.code,
624
- getContractOptionLabel('contractTypes', contract.contractType),
625
- getContractOptionLabel('originTypes', contract.originType),
626
- ]
627
- .filter(Boolean)
628
- .join(' • ');
629
-
630
- return (
631
- <Card
632
- key={contract.id}
633
- className="overflow-hidden border-border/60 py-0 shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md"
634
- >
635
- <CardContent className="space-y-4 p-4">
636
- <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
637
- <div className="min-w-0">
638
- <div className="truncate font-semibold">
639
- {contractTitle}
640
- </div>
641
- <div className="truncate text-xs text-muted-foreground">
642
- {secondaryLine || commonT('labels.notAvailable')}
643
- </div>
644
- </div>
645
- <StatusBadge
646
- label={getContractOptionLabel(
647
- 'statuses',
648
- contract.status
649
- )}
650
- className={getStatusBadgeClass(contract.status)}
651
- />
652
- </div>
653
-
654
- <div className="flex flex-wrap gap-2">
655
- <span className="inline-flex items-center rounded-full bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary">
656
- {getContractOptionLabel(
657
- 'contractCategories',
658
- contract.contractCategory
659
- )}
660
- </span>
661
- {contract.contractTemplateName ? (
662
- <span className="inline-flex items-center rounded-full bg-muted px-2.5 py-1 text-xs font-medium text-muted-foreground">
663
- {contract.contractTemplateName}
664
- </span>
665
- ) : null}
666
- </div>
667
-
668
- <div className="grid gap-2 text-sm text-muted-foreground lg:grid-cols-2">
669
- <div>
670
- <span className="font-medium text-foreground">
671
- {commonT('labels.client')}:
672
- </span>{' '}
673
- {contract.clientName || commonT('labels.notAvailable')}
674
- </div>
675
- <div>
676
- <span className="font-medium text-foreground">
677
- {t('columns.party')}:
678
- </span>{' '}
679
- {contract.mainRelatedPartyName ||
680
- commonT('labels.notAvailable')}
681
- </div>
682
- <div className="flex items-center gap-2">
683
- <span className="font-medium text-foreground">
684
- {t('columns.signatureStatus')}:
685
- </span>
686
- <StatusBadge
687
- label={getContractOptionLabel(
688
- 'signatureStatuses',
689
- contract.signatureStatus
690
- )}
691
- className={getStatusBadgeClass(
692
- contract.signatureStatus
693
- )}
694
- />
695
- </div>
696
- <div>
697
- <span className="font-medium text-foreground">
698
- {t('columns.financials')}:
699
- </span>{' '}
700
- {formatCurrency(financialTotal)}
701
- </div>
702
- <div>
703
- <span className="font-medium text-foreground">
704
- {commonT('labels.startDate')}:
705
- </span>{' '}
706
- {formatDate(contract.startDate)}
707
- </div>
708
- <div>
709
- <span className="font-medium text-foreground">
710
- {commonT('labels.endDate')}:
711
- </span>{' '}
712
- {formatDate(contract.endDate)}
713
- </div>
714
- </div>
715
-
716
- <div className="flex justify-end">
717
- {renderContractActions(contract, 'end')}
718
- </div>
719
- </CardContent>
720
- </Card>
721
- );
722
- })}
723
- </div>
724
- ) : (
725
- <div className="overflow-x-auto rounded-md border">
726
- <Table className="table-fixed">
727
- <TableHeader>
728
- <TableRow>
729
- <TableHead className="w-[34%]">
730
- {t('columns.title')}
731
- </TableHead>
732
- <TableHead>{commonT('labels.status')}</TableHead>
733
- <TableHead className="hidden md:table-cell">
734
- {t('columns.signatureStatus')}
735
- </TableHead>
736
- <TableHead className="hidden lg:table-cell">
737
- {t('columns.type')}
738
- </TableHead>
739
- <TableHead className="hidden xl:table-cell">
740
- {commonT('labels.client')}
741
- </TableHead>
742
- <TableHead className="hidden 2xl:table-cell">
743
- {t('columns.financials')}
744
- </TableHead>
745
- <TableHead className="w-30 text-right sm:w-42.5">
746
- {commonT('labels.actions')}
747
- </TableHead>
748
- </TableRow>
749
- </TableHeader>
750
- <TableBody>
751
- {filteredRows.map((contract) => {
752
- const financialTotal =
753
- (contract.valueAmount ?? 0) + (contract.revenueAmount ?? 0);
754
-
755
- return (
756
- <TableRow key={contract.id} className="hover:bg-muted/30">
757
- <TableCell>
758
- <div className="min-w-0">
759
- <div className="truncate font-medium">
760
- {contract.name ||
761
- contract.code ||
762
- commonT('labels.notAvailable')}
763
- </div>
764
- <div className="truncate text-xs text-muted-foreground">
765
- {[
766
- contract.code,
767
- contract.contractTemplateName,
768
- contract.mainRelatedPartyName,
769
- getContractOptionLabel(
770
- 'originTypes',
771
- contract.originType
772
- ),
773
- ]
774
- .filter(Boolean)
775
- .join(' • ') || commonT('labels.notAvailable')}
776
- </div>
777
- </div>
778
- </TableCell>
779
- <TableCell>
780
- <StatusBadge
781
- label={getContractOptionLabel(
782
- 'statuses',
783
- contract.status
784
- )}
785
- className={getStatusBadgeClass(contract.status)}
786
- />
787
- </TableCell>
788
- <TableCell className="hidden md:table-cell">
789
- <StatusBadge
790
- label={getContractOptionLabel(
791
- 'signatureStatuses',
792
- contract.signatureStatus
793
- )}
794
- className={getStatusBadgeClass(
795
- contract.signatureStatus
796
- )}
797
- />
798
- </TableCell>
799
- <TableCell className="hidden lg:table-cell">
800
- <div className="truncate">
801
- {getContractOptionLabel(
802
- 'contractTypes',
803
- contract.contractType
804
- )}
805
- </div>
806
- </TableCell>
807
- <TableCell className="hidden xl:table-cell">
808
- <div className="truncate">
809
- {contract.clientName ||
810
- commonT('labels.notAvailable')}
811
- </div>
812
- </TableCell>
813
- <TableCell className="hidden 2xl:table-cell">
814
- {formatCurrency(financialTotal)}
815
- </TableCell>
816
- <TableCell>
817
- <div className="flex justify-end">
818
- {renderContractActions(contract, 'end')}
819
- </div>
820
- </TableCell>
821
- </TableRow>
822
- );
823
- })}
824
- </TableBody>
825
- </Table>
826
- </div>
827
- )
828
- ) : (
829
- <EmptyState
830
- icon={<FileText className="size-12" />}
831
- title={commonT('states.emptyTitle')}
832
- description={t('emptyDescription')}
833
- actionLabel={
834
- access.isDirector
835
- ? commonT('actions.create')
836
- : commonT('actions.refresh')
837
- }
838
- onAction={
839
- access.isDirector ? () => openCreateSheet() : () => void refetch()
840
- }
841
- />
842
- )}
843
-
844
- <AlertDialog
845
- open={contractToDelete !== null}
846
- onOpenChange={(open) => {
847
- if (!open && !isDeletingContract) {
848
- setContractToDelete(null);
849
- }
850
- }}
851
- >
852
- <AlertDialogContent>
853
- <AlertDialogHeader>
854
- <AlertDialogTitle>
855
- {t('messages.confirmDeleteTitle')}
856
- </AlertDialogTitle>
857
- <AlertDialogDescription>
858
- {t('messages.confirmDeleteDescription', {
859
- name:
860
- contractToDelete?.name ||
861
- contractToDelete?.code ||
862
- commonT('labels.notAvailable'),
863
- })}
864
- </AlertDialogDescription>
865
- </AlertDialogHeader>
866
- <AlertDialogFooter>
867
- <AlertDialogCancel disabled={isDeletingContract}>
868
- {commonT('actions.cancel')}
869
- </AlertDialogCancel>
870
- <AlertDialogAction
871
- disabled={isDeletingContract}
872
- className="bg-destructive text-white hover:bg-destructive/90"
873
- onClick={(event) => {
874
- event.preventDefault();
875
- void handleDeleteContract();
876
- }}
877
- >
878
- {t('actions.delete')}
879
- </AlertDialogAction>
880
- </AlertDialogFooter>
881
- </AlertDialogContent>
882
- </AlertDialog>
883
-
884
- <Sheet
885
- open={
886
- isCreateSheetOpen ||
887
- editingContractId !== null ||
888
- duplicatingContractId !== null ||
889
- creatingFromTemplateId !== null
890
- }
891
- onOpenChange={(open) => {
892
- if (!open) {
893
- closeFormSheet();
894
- }
895
- }}
896
- >
897
- <SheetContent className="flex h-full w-full flex-col overflow-hidden p-0 sm:max-w-xl lg:max-w-4xl">
898
- <SheetHeader className="sr-only">
899
- <SheetTitle>{sheetTitle}</SheetTitle>
900
- <SheetDescription>{t('description')}</SheetDescription>
901
- </SheetHeader>
902
- <ContractWizardSheet
903
- key={
904
- editingContractId
905
- ? `edit-${editingContractId}`
906
- : duplicatingContractId
907
- ? `duplicate-${duplicatingContractId}`
908
- : creatingFromTemplateId
909
- ? `template-${creatingFromTemplateId}`
910
- : isCreateSheetOpen
911
- ? 'create-contract'
912
- : 'contract-sheet'
913
- }
914
- contractId={editingContractId ?? undefined}
915
- duplicateFromId={duplicatingContractId ?? undefined}
916
- initialTemplateId={creatingFromTemplateId ?? undefined}
917
- isCreateFlow={isCreateSheetOpen}
918
- onCancel={closeFormSheet}
919
- onSaved={async (contract) => {
920
- await refetch();
921
-
922
- if (
923
- isCreateSheetOpen ||
924
- duplicatingContractId !== null ||
925
- creatingFromTemplateId !== null
926
- ) {
927
- openEditSheet(contract.id);
928
- return;
929
- }
930
-
931
- closeFormSheet();
932
- }}
933
- />
934
- </SheetContent>
935
- </Sheet>
936
- </Page>
937
- );
938
- }
1
+ 'use client';
2
+
3
+ import { EmptyState, Page, SearchBar } from '@/components/entity-list';
4
+ import {
5
+ AlertDialog,
6
+ AlertDialogAction,
7
+ AlertDialogCancel,
8
+ AlertDialogContent,
9
+ AlertDialogDescription,
10
+ AlertDialogFooter,
11
+ AlertDialogHeader,
12
+ AlertDialogTitle,
13
+ } from '@/components/ui/alert-dialog';
14
+ import { Button } from '@/components/ui/button';
15
+ import { Card, CardContent } from '@/components/ui/card';
16
+ import {
17
+ DropdownMenu,
18
+ DropdownMenuContent,
19
+ DropdownMenuItem,
20
+ DropdownMenuSeparator,
21
+ DropdownMenuTrigger,
22
+ } from '@/components/ui/dropdown-menu';
23
+ import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
24
+ import {
25
+ Sheet,
26
+ SheetContent,
27
+ SheetDescription,
28
+ SheetHeader,
29
+ SheetTitle,
30
+ } from '@/components/ui/sheet';
31
+ import {
32
+ Table,
33
+ TableBody,
34
+ TableCell,
35
+ TableHead,
36
+ TableHeader,
37
+ TableRow,
38
+ } from '@/components/ui/table';
39
+ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
40
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
41
+ import {
42
+ Download,
43
+ FileStack,
44
+ FileText,
45
+ LayoutGrid,
46
+ List,
47
+ MoreHorizontal,
48
+ Pencil,
49
+ Sparkles,
50
+ Trash2,
51
+ Upload,
52
+ } from 'lucide-react';
53
+ import { useTranslations } from 'next-intl';
54
+ import { usePathname, useRouter, useSearchParams } from 'next/navigation';
55
+ import { useMemo, useRef, useState } from 'react';
56
+ import { ContractWizardSheet } from '../_components/contract-wizard-sheet';
57
+ import { OperationsHeader } from '../_components/operations-header';
58
+ import { StatusBadge } from '../_components/status-badge';
59
+ import { fetchOperations, mutateOperations } from '../_lib/api';
60
+ import { useOperationsAccess } from '../_lib/hooks/use-operations-access';
61
+ import type {
62
+ OperationsContract,
63
+ OperationsContractDetails,
64
+ } from '../_lib/types';
65
+ import {
66
+ formatCurrency,
67
+ formatDate,
68
+ formatEnumLabel,
69
+ getStatusBadgeClass,
70
+ } from '../_lib/utils/format';
71
+
72
+ function downloadBase64File(
73
+ fileName: string,
74
+ mimeType: string,
75
+ base64: string
76
+ ) {
77
+ const href = `data:${mimeType};base64,${base64}`;
78
+ const link = document.createElement('a');
79
+ link.href = href;
80
+ link.download = fileName;
81
+ link.click();
82
+ }
83
+
84
+ const CONTRACT_VIEW_STORAGE_KEY = 'operations-contracts-view-mode';
85
+
86
+ type ContractViewMode = 'table' | 'cards';
87
+
88
+ function openStoredFile(fileId?: number | null) {
89
+ if (!fileId) return;
90
+ const baseUrl = String(process.env.NEXT_PUBLIC_API_BASE_URL || '');
91
+ window.open(
92
+ `${baseUrl}/file/open/${fileId}`,
93
+ '_blank',
94
+ 'noopener,noreferrer'
95
+ );
96
+ }
97
+
98
+ async function fileToBase64(file: File) {
99
+ return new Promise<string>((resolve, reject) => {
100
+ const reader = new FileReader();
101
+ reader.onload = () => {
102
+ const result = String(reader.result ?? '');
103
+ const [, base64 = ''] = result.split(',');
104
+ resolve(base64);
105
+ };
106
+ reader.onerror = reject;
107
+ reader.readAsDataURL(file);
108
+ });
109
+ }
110
+
111
+ export default function OperationsContractsPage() {
112
+ const t = useTranslations('operations.ContractsPage');
113
+ const commonT = useTranslations('operations.Common');
114
+ const formT = useTranslations('operations.ContractFormPage');
115
+ const { request, showToastHandler, currentLocaleCode } = useApp();
116
+ const access = useOperationsAccess();
117
+ const router = useRouter();
118
+ const pathname = usePathname();
119
+ const searchParams = useSearchParams();
120
+ const [search, setSearch] = useState('');
121
+ const [statusFilter, setStatusFilter] = useState('all');
122
+ const [categoryFilter, setCategoryFilter] = useState('all');
123
+ const [originFilter, setOriginFilter] = useState('all');
124
+ const uploadTargetRef = useRef<number | null>(null);
125
+ const fileInputRef = useRef<HTMLInputElement>(null);
126
+ const [isCreateSheetOpen, setIsCreateSheetOpen] = useState(false);
127
+ const [contractToDelete, setContractToDelete] =
128
+ useState<OperationsContract | null>(null);
129
+ const [isDeletingContract, setIsDeletingContract] = useState(false);
130
+ const [viewMode, setViewMode] = useState<ContractViewMode>(() => {
131
+ if (typeof window === 'undefined') {
132
+ return 'table';
133
+ }
134
+
135
+ const savedViewMode = window.localStorage.getItem(
136
+ CONTRACT_VIEW_STORAGE_KEY
137
+ );
138
+
139
+ return savedViewMode === 'cards' ? 'cards' : 'table';
140
+ });
141
+
142
+ const getContractOptionLabel = (group: string, value?: string | null) => {
143
+ if (!value) {
144
+ return '-';
145
+ }
146
+
147
+ const key = `options.${group}.${value}`;
148
+ return formT.has(key) ? formT(key) : formatEnumLabel(value);
149
+ };
150
+
151
+ const editParam = searchParams.get('edit');
152
+ const duplicateParam = searchParams.get('duplicate');
153
+ const templateParam = searchParams.get('template');
154
+ const editingContractId =
155
+ editParam && /^\d+$/.test(editParam) ? Number(editParam) : null;
156
+ const duplicatingContractId =
157
+ duplicateParam && /^\d+$/.test(duplicateParam)
158
+ ? Number(duplicateParam)
159
+ : null;
160
+ const creatingFromTemplateId =
161
+ templateParam && /^\d+$/.test(templateParam) ? Number(templateParam) : null;
162
+
163
+ const updateSheetQuery = (options?: {
164
+ editId?: number | null;
165
+ duplicateId?: number | null;
166
+ templateId?: number | null;
167
+ }) => {
168
+ const params = new URLSearchParams(searchParams.toString());
169
+
170
+ params.delete('edit');
171
+ params.delete('duplicate');
172
+ params.delete('template');
173
+
174
+ if (options?.editId && options.editId > 0) {
175
+ params.set('edit', String(options.editId));
176
+ }
177
+
178
+ if (options?.duplicateId && options.duplicateId > 0) {
179
+ params.set('duplicate', String(options.duplicateId));
180
+ }
181
+
182
+ if (options?.templateId && options.templateId > 0) {
183
+ params.set('template', String(options.templateId));
184
+ }
185
+
186
+ const nextUrl = params.size ? `${pathname}?${params.toString()}` : pathname;
187
+ router.replace(nextUrl, { scroll: false });
188
+ };
189
+
190
+ const openCreateSheet = (templateId?: number | null) => {
191
+ if (templateId && templateId > 0) {
192
+ updateSheetQuery({ templateId });
193
+ return;
194
+ }
195
+
196
+ setIsCreateSheetOpen(true);
197
+ updateSheetQuery();
198
+ };
199
+
200
+ const openEditSheet = (contractId: number) => {
201
+ setIsCreateSheetOpen(false);
202
+ updateSheetQuery({ editId: contractId });
203
+ };
204
+
205
+ const openDuplicateSheet = (contractId: number) => {
206
+ setIsCreateSheetOpen(false);
207
+ updateSheetQuery({ duplicateId: contractId });
208
+ };
209
+
210
+ const closeFormSheet = () => {
211
+ setIsCreateSheetOpen(false);
212
+
213
+ if (
214
+ editingContractId !== null ||
215
+ duplicatingContractId !== null ||
216
+ creatingFromTemplateId !== null
217
+ ) {
218
+ updateSheetQuery();
219
+ }
220
+ };
221
+
222
+ const { data: contracts = [], refetch } = useQuery<OperationsContract[]>({
223
+ queryKey: ['operations-contracts-list', currentLocaleCode],
224
+ queryFn: () =>
225
+ fetchOperations<OperationsContract[]>(request, '/operations/contracts'),
226
+ });
227
+
228
+ const filteredRows = useMemo(
229
+ () =>
230
+ contracts.filter((item) => {
231
+ const matchesSearch = !search.trim()
232
+ ? true
233
+ : [
234
+ item.name,
235
+ item.code,
236
+ item.mainRelatedPartyName,
237
+ item.clientName,
238
+ item.contractType,
239
+ ]
240
+ .filter(Boolean)
241
+ .some((value) =>
242
+ String(value)
243
+ .toLowerCase()
244
+ .includes(search.trim().toLowerCase())
245
+ );
246
+ const matchesStatus =
247
+ statusFilter === 'all' ? true : item.status === statusFilter;
248
+ const matchesCategory =
249
+ categoryFilter === 'all'
250
+ ? true
251
+ : item.contractCategory === categoryFilter;
252
+ const matchesOrigin =
253
+ originFilter === 'all' ? true : item.originType === originFilter;
254
+ return (
255
+ matchesSearch && matchesStatus && matchesCategory && matchesOrigin
256
+ );
257
+ }),
258
+ [contracts, search, statusFilter, categoryFilter, originFilter]
259
+ );
260
+
261
+ const sheetTitle = editingContractId
262
+ ? commonT('actions.edit')
263
+ : duplicatingContractId
264
+ ? t('actions.duplicate')
265
+ : isCreateSheetOpen || creatingFromTemplateId
266
+ ? commonT('actions.create')
267
+ : t('title');
268
+
269
+ const statsCards = useMemo(
270
+ () => [
271
+ {
272
+ key: 'total',
273
+ title: t('cards.total'),
274
+ value: contracts.length,
275
+ icon: FileText,
276
+ },
277
+ {
278
+ key: 'active',
279
+ title: t('cards.active'),
280
+ value: contracts.filter((item) => item.status === 'active').length,
281
+ icon: Sparkles,
282
+ },
283
+ {
284
+ key: 'underReview',
285
+ title: t('cards.underReview'),
286
+ value: contracts.filter((item) => item.status === 'under_review')
287
+ .length,
288
+ icon: Pencil,
289
+ },
290
+ {
291
+ key: 'withTemplate',
292
+ title: t('cards.withTemplate'),
293
+ value: contracts.filter((item) => Boolean(item.contractTemplateId))
294
+ .length,
295
+ icon: FileStack,
296
+ },
297
+ ],
298
+ [contracts, t]
299
+ );
300
+
301
+ const handleViewModeChange = (value: string) => {
302
+ if (value !== 'table' && value !== 'cards') {
303
+ return;
304
+ }
305
+
306
+ setViewMode(value);
307
+
308
+ if (typeof window !== 'undefined') {
309
+ window.localStorage.setItem(CONTRACT_VIEW_STORAGE_KEY, value);
310
+ }
311
+ };
312
+
313
+ const downloadPdf = async (contractId: number) => {
314
+ const detail = await fetchOperations<OperationsContractDetails>(
315
+ request,
316
+ `/operations/contracts/${contractId}`
317
+ );
318
+ const document = detail.documents.find(
319
+ (item) =>
320
+ item.isCurrent &&
321
+ (item.documentType === 'generated_pdf' ||
322
+ item.documentType === 'source_upload')
323
+ );
324
+ if (document?.fileId) {
325
+ openStoredFile(document.fileId);
326
+ return;
327
+ }
328
+ if (!document?.fileContentBase64) {
329
+ showToastHandler?.('error', t('messages.noPdf'));
330
+ return;
331
+ }
332
+ downloadBase64File(
333
+ document.fileName,
334
+ document.mimeType,
335
+ document.fileContentBase64
336
+ );
337
+ };
338
+
339
+ const duplicateContract = (contractId: number) => {
340
+ openDuplicateSheet(contractId);
341
+ };
342
+
343
+ const handleDeleteContract = async () => {
344
+ if (!contractToDelete?.id) {
345
+ return;
346
+ }
347
+
348
+ try {
349
+ setIsDeletingContract(true);
350
+ await request({
351
+ url: `/operations/contracts/${contractToDelete.id}`,
352
+ method: 'DELETE',
353
+ });
354
+ showToastHandler?.('success', t('messages.deleteSuccess'));
355
+ setContractToDelete(null);
356
+ await refetch();
357
+ } catch {
358
+ showToastHandler?.('error', t('messages.deleteError'));
359
+ } finally {
360
+ setIsDeletingContract(false);
361
+ }
362
+ };
363
+
364
+ const toggleArchived = async (contract: OperationsContract) => {
365
+ try {
366
+ await mutateOperations(
367
+ request,
368
+ `/operations/contracts/${contract.id}`,
369
+ 'PATCH',
370
+ {
371
+ status: contract.status === 'archived' ? 'active' : 'archived',
372
+ isActive: contract.status === 'archived',
373
+ }
374
+ );
375
+ showToastHandler?.('success', t('messages.statusSuccess'));
376
+ await refetch();
377
+ } catch {
378
+ showToastHandler?.('error', t('messages.statusError'));
379
+ }
380
+ };
381
+
382
+ const renderContractActions = (
383
+ contract: OperationsContract,
384
+ align: 'start' | 'end' = 'end'
385
+ ) => (
386
+ <DropdownMenu>
387
+ <DropdownMenuTrigger asChild>
388
+ <Button
389
+ variant="outline"
390
+ size="icon"
391
+ className="cursor-pointer"
392
+ aria-label={commonT('labels.actions')}
393
+ >
394
+ <MoreHorizontal className="size-4" />
395
+ </Button>
396
+ </DropdownMenuTrigger>
397
+ <DropdownMenuContent align={align} className="w-52">
398
+ {access.isDirector ? (
399
+ <DropdownMenuItem onSelect={() => openEditSheet(contract.id)}>
400
+ <Pencil className="size-4" />
401
+ {commonT('actions.edit')}
402
+ </DropdownMenuItem>
403
+ ) : null}
404
+ <DropdownMenuItem onSelect={() => void downloadPdf(contract.id)}>
405
+ <Download className="size-4" />
406
+ {t('actions.downloadPdf')}
407
+ </DropdownMenuItem>
408
+ {access.isDirector ? (
409
+ <DropdownMenuItem
410
+ onSelect={() => {
411
+ uploadTargetRef.current = contract.id;
412
+ fileInputRef.current?.click();
413
+ }}
414
+ >
415
+ <Upload className="size-4" />
416
+ {t('actions.uploadPdf')}
417
+ </DropdownMenuItem>
418
+ ) : null}
419
+ {access.isDirector ? <DropdownMenuSeparator /> : null}
420
+ {access.isDirector ? (
421
+ <DropdownMenuItem
422
+ onSelect={() => void duplicateContract(contract.id)}
423
+ >
424
+ <FileStack className="size-4" />
425
+ {t('actions.duplicate')}
426
+ </DropdownMenuItem>
427
+ ) : null}
428
+ {access.isDirector ? (
429
+ <DropdownMenuItem onSelect={() => void toggleArchived(contract)}>
430
+ <Sparkles className="size-4" />
431
+ {contract.status === 'archived'
432
+ ? commonT('actions.activate')
433
+ : t('actions.archive')}
434
+ </DropdownMenuItem>
435
+ ) : null}
436
+ {access.isDirector ? <DropdownMenuSeparator /> : null}
437
+ {access.isDirector ? (
438
+ <DropdownMenuItem
439
+ onSelect={() => setContractToDelete(contract)}
440
+ className="text-destructive focus:text-destructive"
441
+ >
442
+ <Trash2 className="size-4 text-destructive" />
443
+ {t('actions.delete')}
444
+ </DropdownMenuItem>
445
+ ) : null}
446
+ </DropdownMenuContent>
447
+ </DropdownMenu>
448
+ );
449
+
450
+ return (
451
+ <Page>
452
+ <OperationsHeader
453
+ title={t('title')}
454
+ description={t('description')}
455
+ current={t('breadcrumb')}
456
+ actions={
457
+ access.isDirector ? (
458
+ <div className="flex flex-wrap gap-2">
459
+ <Button
460
+ size="sm"
461
+ className="cursor-pointer"
462
+ onClick={() => openCreateSheet()}
463
+ >
464
+ {commonT('actions.create')}
465
+ </Button>
466
+ </div>
467
+ ) : undefined
468
+ }
469
+ />
470
+
471
+ <KpiCardsGrid items={statsCards} columns={4} />
472
+
473
+ <div className="flex min-w-0 flex-col gap-4 xl:flex-row xl:items-center">
474
+ <div className="flex-1">
475
+ <SearchBar
476
+ searchQuery={search}
477
+ onSearchChange={setSearch}
478
+ onSearch={() => undefined}
479
+ placeholder={t('searchPlaceholder')}
480
+ controls={[
481
+ {
482
+ id: 'category',
483
+ type: 'select',
484
+ value: categoryFilter,
485
+ onChange: setCategoryFilter,
486
+ placeholder: commonT('labels.contractCategory'),
487
+ options: [
488
+ { value: 'all', label: commonT('filters.allTypes') },
489
+ ...[
490
+ 'employee',
491
+ 'contractor',
492
+ 'client',
493
+ 'supplier',
494
+ 'vendor',
495
+ 'partner',
496
+ 'internal',
497
+ 'other',
498
+ ].map((value) => ({
499
+ value,
500
+ label: getContractOptionLabel('contractCategories', value),
501
+ })),
502
+ ],
503
+ },
504
+ {
505
+ id: 'origin',
506
+ type: 'select',
507
+ value: originFilter,
508
+ onChange: setOriginFilter,
509
+ placeholder: t('filters.originType'),
510
+ options: [
511
+ { value: 'all', label: commonT('filters.allTypes') },
512
+ ...['manual', 'employee_hiring', 'client_project'].map(
513
+ (value) => ({
514
+ value,
515
+ label: getContractOptionLabel('originTypes', value),
516
+ })
517
+ ),
518
+ ],
519
+ },
520
+ {
521
+ id: 'status',
522
+ type: 'select',
523
+ value: statusFilter,
524
+ onChange: setStatusFilter,
525
+ placeholder: commonT('labels.status'),
526
+ options: [
527
+ { value: 'all', label: commonT('filters.allStatuses') },
528
+ ...[
529
+ 'draft',
530
+ 'under_review',
531
+ 'active',
532
+ 'renewal',
533
+ 'expired',
534
+ 'closed',
535
+ 'archived',
536
+ ].map((value) => ({
537
+ value,
538
+ label: getContractOptionLabel('statuses', value),
539
+ })),
540
+ ],
541
+ },
542
+ ]}
543
+ />
544
+ </div>
545
+
546
+ <div className="flex items-center justify-between gap-3 xl:justify-end">
547
+ <span className="text-xs font-medium text-muted-foreground">
548
+ {t('viewMode')}
549
+ </span>
550
+ <ToggleGroup
551
+ type="single"
552
+ value={viewMode}
553
+ onValueChange={handleViewModeChange}
554
+ variant="outline"
555
+ size="sm"
556
+ aria-label={t('viewMode')}
557
+ >
558
+ <ToggleGroupItem
559
+ value="table"
560
+ className="gap-1.5 px-2.5"
561
+ aria-label={t('viewModeTable')}
562
+ >
563
+ <List className="h-4 w-4" />
564
+ <span className="hidden sm:inline">{t('viewModeTable')}</span>
565
+ </ToggleGroupItem>
566
+ <ToggleGroupItem
567
+ value="cards"
568
+ className="gap-1.5 px-2.5"
569
+ aria-label={t('viewModeCards')}
570
+ >
571
+ <LayoutGrid className="h-4 w-4" />
572
+ <span className="hidden sm:inline">{t('viewModeCards')}</span>
573
+ </ToggleGroupItem>
574
+ </ToggleGroup>
575
+ </div>
576
+ </div>
577
+
578
+ <input
579
+ ref={fileInputRef}
580
+ type="file"
581
+ accept="application/pdf"
582
+ className="hidden"
583
+ onChange={async (event) => {
584
+ const file = event.target.files?.[0];
585
+ const contractId = uploadTargetRef.current;
586
+ if (!file || !contractId) return;
587
+ try {
588
+ const fileContentBase64 = await fileToBase64(file);
589
+ await mutateOperations(
590
+ request,
591
+ `/operations/contracts/${contractId}`,
592
+ 'PATCH',
593
+ {
594
+ replaceUploadedPdfDocument: {
595
+ fileName: file.name,
596
+ mimeType: file.type || 'application/pdf',
597
+ fileContentBase64,
598
+ },
599
+ }
600
+ );
601
+ showToastHandler?.('success', t('messages.uploadSuccess'));
602
+ await refetch();
603
+ } catch {
604
+ showToastHandler?.('error', t('messages.uploadError'));
605
+ } finally {
606
+ uploadTargetRef.current = null;
607
+ event.target.value = '';
608
+ }
609
+ }}
610
+ />
611
+
612
+ {filteredRows.length ? (
613
+ viewMode === 'cards' ? (
614
+ <div className="grid gap-4 md:grid-cols-2 2xl:grid-cols-3">
615
+ {filteredRows.map((contract) => {
616
+ const financialTotal =
617
+ (contract.valueAmount ?? 0) + (contract.revenueAmount ?? 0);
618
+ const contractTitle =
619
+ contract.name ||
620
+ contract.code ||
621
+ commonT('labels.notAvailable');
622
+ const secondaryLine = [
623
+ contract.code,
624
+ getContractOptionLabel('contractTypes', contract.contractType),
625
+ getContractOptionLabel('originTypes', contract.originType),
626
+ ]
627
+ .filter(Boolean)
628
+ .join(' • ');
629
+
630
+ return (
631
+ <Card
632
+ key={contract.id}
633
+ className="overflow-hidden border-border/60 py-0 shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md"
634
+ >
635
+ <CardContent className="space-y-4 p-4">
636
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
637
+ <div className="min-w-0">
638
+ <div className="truncate font-semibold">
639
+ {contractTitle}
640
+ </div>
641
+ <div className="truncate text-xs text-muted-foreground">
642
+ {secondaryLine || commonT('labels.notAvailable')}
643
+ </div>
644
+ </div>
645
+ <StatusBadge
646
+ label={getContractOptionLabel(
647
+ 'statuses',
648
+ contract.status
649
+ )}
650
+ className={getStatusBadgeClass(contract.status)}
651
+ />
652
+ </div>
653
+
654
+ <div className="flex flex-wrap gap-2">
655
+ <span className="inline-flex items-center rounded-full bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary">
656
+ {getContractOptionLabel(
657
+ 'contractCategories',
658
+ contract.contractCategory
659
+ )}
660
+ </span>
661
+ {contract.contractTemplateName ? (
662
+ <span className="inline-flex items-center rounded-full bg-muted px-2.5 py-1 text-xs font-medium text-muted-foreground">
663
+ {contract.contractTemplateName}
664
+ </span>
665
+ ) : null}
666
+ </div>
667
+
668
+ <div className="grid gap-2 text-sm text-muted-foreground lg:grid-cols-2">
669
+ <div>
670
+ <span className="font-medium text-foreground">
671
+ {commonT('labels.client')}:
672
+ </span>{' '}
673
+ {contract.clientName || commonT('labels.notAvailable')}
674
+ </div>
675
+ <div>
676
+ <span className="font-medium text-foreground">
677
+ {t('columns.party')}:
678
+ </span>{' '}
679
+ {contract.mainRelatedPartyName ||
680
+ commonT('labels.notAvailable')}
681
+ </div>
682
+ <div className="flex items-center gap-2">
683
+ <span className="font-medium text-foreground">
684
+ {t('columns.signatureStatus')}:
685
+ </span>
686
+ <StatusBadge
687
+ label={getContractOptionLabel(
688
+ 'signatureStatuses',
689
+ contract.signatureStatus
690
+ )}
691
+ className={getStatusBadgeClass(
692
+ contract.signatureStatus
693
+ )}
694
+ />
695
+ </div>
696
+ <div>
697
+ <span className="font-medium text-foreground">
698
+ {t('columns.financials')}:
699
+ </span>{' '}
700
+ {formatCurrency(financialTotal)}
701
+ </div>
702
+ <div>
703
+ <span className="font-medium text-foreground">
704
+ {commonT('labels.startDate')}:
705
+ </span>{' '}
706
+ {formatDate(contract.startDate)}
707
+ </div>
708
+ <div>
709
+ <span className="font-medium text-foreground">
710
+ {commonT('labels.endDate')}:
711
+ </span>{' '}
712
+ {formatDate(contract.endDate)}
713
+ </div>
714
+ </div>
715
+
716
+ <div className="flex justify-end">
717
+ {renderContractActions(contract, 'end')}
718
+ </div>
719
+ </CardContent>
720
+ </Card>
721
+ );
722
+ })}
723
+ </div>
724
+ ) : (
725
+ <div className="overflow-x-auto rounded-md border">
726
+ <Table className="table-fixed">
727
+ <TableHeader>
728
+ <TableRow>
729
+ <TableHead className="w-[34%]">
730
+ {t('columns.title')}
731
+ </TableHead>
732
+ <TableHead>{commonT('labels.status')}</TableHead>
733
+ <TableHead className="hidden md:table-cell">
734
+ {t('columns.signatureStatus')}
735
+ </TableHead>
736
+ <TableHead className="hidden lg:table-cell">
737
+ {t('columns.type')}
738
+ </TableHead>
739
+ <TableHead className="hidden xl:table-cell">
740
+ {commonT('labels.client')}
741
+ </TableHead>
742
+ <TableHead className="hidden 2xl:table-cell">
743
+ {t('columns.financials')}
744
+ </TableHead>
745
+ <TableHead className="w-30 text-right sm:w-42.5">
746
+ {commonT('labels.actions')}
747
+ </TableHead>
748
+ </TableRow>
749
+ </TableHeader>
750
+ <TableBody>
751
+ {filteredRows.map((contract) => {
752
+ const financialTotal =
753
+ (contract.valueAmount ?? 0) + (contract.revenueAmount ?? 0);
754
+
755
+ return (
756
+ <TableRow key={contract.id} className="hover:bg-muted/30">
757
+ <TableCell>
758
+ <div className="min-w-0">
759
+ <div className="truncate font-medium">
760
+ {contract.name ||
761
+ contract.code ||
762
+ commonT('labels.notAvailable')}
763
+ </div>
764
+ <div className="truncate text-xs text-muted-foreground">
765
+ {[
766
+ contract.code,
767
+ contract.contractTemplateName,
768
+ contract.mainRelatedPartyName,
769
+ getContractOptionLabel(
770
+ 'originTypes',
771
+ contract.originType
772
+ ),
773
+ ]
774
+ .filter(Boolean)
775
+ .join(' • ') || commonT('labels.notAvailable')}
776
+ </div>
777
+ </div>
778
+ </TableCell>
779
+ <TableCell>
780
+ <StatusBadge
781
+ label={getContractOptionLabel(
782
+ 'statuses',
783
+ contract.status
784
+ )}
785
+ className={getStatusBadgeClass(contract.status)}
786
+ />
787
+ </TableCell>
788
+ <TableCell className="hidden md:table-cell">
789
+ <StatusBadge
790
+ label={getContractOptionLabel(
791
+ 'signatureStatuses',
792
+ contract.signatureStatus
793
+ )}
794
+ className={getStatusBadgeClass(
795
+ contract.signatureStatus
796
+ )}
797
+ />
798
+ </TableCell>
799
+ <TableCell className="hidden lg:table-cell">
800
+ <div className="truncate">
801
+ {getContractOptionLabel(
802
+ 'contractTypes',
803
+ contract.contractType
804
+ )}
805
+ </div>
806
+ </TableCell>
807
+ <TableCell className="hidden xl:table-cell">
808
+ <div className="truncate">
809
+ {contract.clientName ||
810
+ commonT('labels.notAvailable')}
811
+ </div>
812
+ </TableCell>
813
+ <TableCell className="hidden 2xl:table-cell">
814
+ {formatCurrency(financialTotal)}
815
+ </TableCell>
816
+ <TableCell>
817
+ <div className="flex justify-end">
818
+ {renderContractActions(contract, 'end')}
819
+ </div>
820
+ </TableCell>
821
+ </TableRow>
822
+ );
823
+ })}
824
+ </TableBody>
825
+ </Table>
826
+ </div>
827
+ )
828
+ ) : (
829
+ <EmptyState
830
+ icon={<FileText className="size-12" />}
831
+ title={commonT('states.emptyTitle')}
832
+ description={t('emptyDescription')}
833
+ actionLabel={
834
+ access.isDirector
835
+ ? commonT('actions.create')
836
+ : commonT('actions.refresh')
837
+ }
838
+ onAction={
839
+ access.isDirector ? () => openCreateSheet() : () => void refetch()
840
+ }
841
+ />
842
+ )}
843
+
844
+ <AlertDialog
845
+ open={contractToDelete !== null}
846
+ onOpenChange={(open) => {
847
+ if (!open && !isDeletingContract) {
848
+ setContractToDelete(null);
849
+ }
850
+ }}
851
+ >
852
+ <AlertDialogContent>
853
+ <AlertDialogHeader>
854
+ <AlertDialogTitle>
855
+ {t('messages.confirmDeleteTitle')}
856
+ </AlertDialogTitle>
857
+ <AlertDialogDescription>
858
+ {t('messages.confirmDeleteDescription', {
859
+ name:
860
+ contractToDelete?.name ||
861
+ contractToDelete?.code ||
862
+ commonT('labels.notAvailable'),
863
+ })}
864
+ </AlertDialogDescription>
865
+ </AlertDialogHeader>
866
+ <AlertDialogFooter>
867
+ <AlertDialogCancel disabled={isDeletingContract}>
868
+ {commonT('actions.cancel')}
869
+ </AlertDialogCancel>
870
+ <AlertDialogAction
871
+ disabled={isDeletingContract}
872
+ className="bg-destructive text-white hover:bg-destructive/90"
873
+ onClick={(event) => {
874
+ event.preventDefault();
875
+ void handleDeleteContract();
876
+ }}
877
+ >
878
+ {t('actions.delete')}
879
+ </AlertDialogAction>
880
+ </AlertDialogFooter>
881
+ </AlertDialogContent>
882
+ </AlertDialog>
883
+
884
+ <Sheet
885
+ open={
886
+ isCreateSheetOpen ||
887
+ editingContractId !== null ||
888
+ duplicatingContractId !== null ||
889
+ creatingFromTemplateId !== null
890
+ }
891
+ onOpenChange={(open) => {
892
+ if (!open) {
893
+ closeFormSheet();
894
+ }
895
+ }}
896
+ >
897
+ <SheetContent className="flex h-full w-full flex-col overflow-hidden p-0 sm:max-w-xl lg:max-w-4xl">
898
+ <SheetHeader className="sr-only">
899
+ <SheetTitle>{sheetTitle}</SheetTitle>
900
+ <SheetDescription>{t('description')}</SheetDescription>
901
+ </SheetHeader>
902
+ <ContractWizardSheet
903
+ key={
904
+ editingContractId
905
+ ? `edit-${editingContractId}`
906
+ : duplicatingContractId
907
+ ? `duplicate-${duplicatingContractId}`
908
+ : creatingFromTemplateId
909
+ ? `template-${creatingFromTemplateId}`
910
+ : isCreateSheetOpen
911
+ ? 'create-contract'
912
+ : 'contract-sheet'
913
+ }
914
+ contractId={editingContractId ?? undefined}
915
+ duplicateFromId={duplicatingContractId ?? undefined}
916
+ initialTemplateId={creatingFromTemplateId ?? undefined}
917
+ isCreateFlow={isCreateSheetOpen}
918
+ onCancel={closeFormSheet}
919
+ onSaved={async (contract) => {
920
+ await refetch();
921
+
922
+ if (
923
+ isCreateSheetOpen ||
924
+ duplicatingContractId !== null ||
925
+ creatingFromTemplateId !== null
926
+ ) {
927
+ openEditSheet(contract.id);
928
+ return;
929
+ }
930
+
931
+ closeFormSheet();
932
+ }}
933
+ />
934
+ </SheetContent>
935
+ </Sheet>
936
+ </Page>
937
+ );
938
+ }