@hed-hog/operations 0.0.306 → 0.0.310

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 (123) hide show
  1. package/dist/controllers/operations-approvals.controller.d.ts +114 -1
  2. package/dist/controllers/operations-approvals.controller.d.ts.map +1 -1
  3. package/dist/controllers/operations-approvals.controller.js +16 -3
  4. package/dist/controllers/operations-approvals.controller.js.map +1 -1
  5. package/dist/controllers/operations-collaborators.controller.d.ts +16 -1
  6. package/dist/controllers/operations-collaborators.controller.d.ts.map +1 -1
  7. package/dist/controllers/operations-collaborators.controller.js +16 -3
  8. package/dist/controllers/operations-collaborators.controller.js.map +1 -1
  9. package/dist/controllers/operations-contracts.controller.d.ts +14 -453
  10. package/dist/controllers/operations-contracts.controller.d.ts.map +1 -1
  11. package/dist/controllers/operations-contracts.controller.js +11 -112
  12. package/dist/controllers/operations-contracts.controller.js.map +1 -1
  13. package/dist/controllers/operations-org-structure.controller.d.ts +65 -2
  14. package/dist/controllers/operations-org-structure.controller.d.ts.map +1 -1
  15. package/dist/controllers/operations-org-structure.controller.js +18 -5
  16. package/dist/controllers/operations-org-structure.controller.js.map +1 -1
  17. package/dist/controllers/operations-projects.controller.d.ts +28 -4
  18. package/dist/controllers/operations-projects.controller.d.ts.map +1 -1
  19. package/dist/controllers/operations-projects.controller.js +17 -5
  20. package/dist/controllers/operations-projects.controller.js.map +1 -1
  21. package/dist/controllers/operations-timesheets.controller.d.ts +31 -4
  22. package/dist/controllers/operations-timesheets.controller.d.ts.map +1 -1
  23. package/dist/controllers/operations-timesheets.controller.js +16 -11
  24. package/dist/controllers/operations-timesheets.controller.js.map +1 -1
  25. package/dist/dto/list-approvals.dto.d.ts +6 -0
  26. package/dist/dto/list-approvals.dto.d.ts.map +1 -0
  27. package/dist/dto/list-approvals.dto.js +28 -0
  28. package/dist/dto/list-approvals.dto.js.map +1 -0
  29. package/dist/dto/list-collaborator-types.dto.d.ts +3 -1
  30. package/dist/dto/list-collaborator-types.dto.d.ts.map +1 -1
  31. package/dist/dto/list-collaborator-types.dto.js +7 -1
  32. package/dist/dto/list-collaborator-types.dto.js.map +1 -1
  33. package/dist/dto/list-collaborators.dto.d.ts +1 -0
  34. package/dist/dto/list-collaborators.dto.d.ts.map +1 -1
  35. package/dist/dto/list-collaborators.dto.js +5 -0
  36. package/dist/dto/list-collaborators.dto.js.map +1 -1
  37. package/dist/dto/list-contracts.dto.d.ts +8 -0
  38. package/dist/dto/list-contracts.dto.d.ts.map +1 -0
  39. package/dist/dto/list-contracts.dto.js +38 -0
  40. package/dist/dto/list-contracts.dto.js.map +1 -0
  41. package/dist/dto/list-departments.dto.d.ts +5 -0
  42. package/dist/dto/list-departments.dto.d.ts.map +1 -0
  43. package/dist/dto/list-departments.dto.js +23 -0
  44. package/dist/dto/list-departments.dto.js.map +1 -0
  45. package/dist/dto/list-projects.dto.d.ts +5 -0
  46. package/dist/dto/list-projects.dto.d.ts.map +1 -0
  47. package/dist/dto/list-projects.dto.js +23 -0
  48. package/dist/dto/list-projects.dto.js.map +1 -0
  49. package/dist/dto/list-schedule-adjustments.dto.d.ts +5 -0
  50. package/dist/dto/list-schedule-adjustments.dto.d.ts.map +1 -0
  51. package/dist/dto/list-schedule-adjustments.dto.js +23 -0
  52. package/dist/dto/list-schedule-adjustments.dto.js.map +1 -0
  53. package/dist/dto/list-time-off-requests.dto.d.ts +5 -0
  54. package/dist/dto/list-time-off-requests.dto.d.ts.map +1 -0
  55. package/dist/dto/list-time-off-requests.dto.js +23 -0
  56. package/dist/dto/list-time-off-requests.dto.js.map +1 -0
  57. package/dist/dto/list-timesheets.dto.d.ts +5 -0
  58. package/dist/dto/list-timesheets.dto.d.ts.map +1 -0
  59. package/dist/dto/list-timesheets.dto.js +23 -0
  60. package/dist/dto/list-timesheets.dto.js.map +1 -0
  61. package/dist/dto/reorder-collaborator-types.dto.d.ts +4 -0
  62. package/dist/dto/reorder-collaborator-types.dto.d.ts.map +1 -0
  63. package/dist/dto/reorder-collaborator-types.dto.js +25 -0
  64. package/dist/dto/reorder-collaborator-types.dto.js.map +1 -0
  65. package/dist/operations.service.d.ts +340 -271
  66. package/dist/operations.service.d.ts.map +1 -1
  67. package/dist/operations.service.js +1007 -1043
  68. package/dist/operations.service.js.map +1 -1
  69. package/dist/operations.service.spec.js +0 -22
  70. package/dist/operations.service.spec.js.map +1 -1
  71. package/hedhog/data/menu.yaml +0 -36
  72. package/hedhog/data/route.yaml +42 -73
  73. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +8 -1
  74. package/hedhog/frontend/app/_components/collaborator-select-with-create.tsx.ejs +15 -10
  75. package/hedhog/frontend/app/_components/contract-details-screen.tsx.ejs +108 -213
  76. package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +251 -2039
  77. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +167 -60
  78. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +70 -301
  79. package/hedhog/frontend/app/_components/system-user-select-with-create.tsx.ejs +102 -51
  80. package/hedhog/frontend/app/_lib/types.ts.ejs +19 -24
  81. package/hedhog/frontend/app/_lib/utils/format.ts.ejs +14 -9
  82. package/hedhog/frontend/app/approvals/page.tsx.ejs +842 -150
  83. package/hedhog/frontend/app/collaborator-types/page.tsx.ejs +445 -153
  84. package/hedhog/frontend/app/collaborators/page.tsx.ejs +118 -49
  85. package/hedhog/frontend/app/contracts/[id]/page.tsx.ejs +2 -2
  86. package/hedhog/frontend/app/contracts/page.tsx.ejs +215 -617
  87. package/hedhog/frontend/app/departments/page.tsx.ejs +257 -113
  88. package/hedhog/frontend/app/projects/page.tsx.ejs +90 -51
  89. package/hedhog/frontend/app/schedule-adjustments/page.tsx.ejs +412 -147
  90. package/hedhog/frontend/app/time-off/page.tsx.ejs +400 -123
  91. package/hedhog/frontend/app/timesheets/page.tsx.ejs +460 -365
  92. package/hedhog/frontend/messages/en.json +143 -14
  93. package/hedhog/frontend/messages/pt.json +192 -54
  94. package/hedhog/table/operations_contract.yaml +0 -9
  95. package/package.json +4 -4
  96. package/src/controllers/operations-approvals.controller.ts +9 -3
  97. package/src/controllers/operations-collaborators.controller.ts +15 -2
  98. package/src/controllers/operations-contracts.controller.ts +8 -92
  99. package/src/controllers/operations-org-structure.controller.ts +17 -4
  100. package/src/controllers/operations-projects.controller.ts +10 -4
  101. package/src/controllers/operations-timesheets.controller.ts +17 -8
  102. package/src/dto/list-approvals.dto.ts +12 -0
  103. package/src/dto/list-collaborator-types.dto.ts +7 -2
  104. package/src/dto/list-collaborators.dto.ts +4 -0
  105. package/src/dto/list-contracts.dto.ts +20 -0
  106. package/src/dto/list-departments.dto.ts +8 -0
  107. package/src/dto/list-projects.dto.ts +8 -0
  108. package/src/dto/list-schedule-adjustments.dto.ts +8 -0
  109. package/src/dto/list-time-off-requests.dto.ts +8 -0
  110. package/src/dto/list-timesheets.dto.ts +8 -0
  111. package/src/dto/reorder-collaborator-types.dto.ts +10 -0
  112. package/src/operations.service.spec.ts +0 -30
  113. package/src/operations.service.ts +1557 -1806
  114. package/hedhog/frontend/app/_components/contract-creation-wizard.tsx.ejs +0 -631
  115. package/hedhog/frontend/app/_components/contract-template-form-screen.tsx.ejs +0 -526
  116. package/hedhog/frontend/app/_components/contract-template-select-with-create.tsx.ejs +0 -247
  117. package/hedhog/frontend/app/_components/contract-wizard-sheet.tsx.ejs +0 -3520
  118. package/hedhog/frontend/app/contracts/templates/page.tsx.ejs +0 -380
  119. package/hedhog/frontend/app/team/page.tsx.ejs +0 -352
  120. package/hedhog/table/operations_contract_financial_term.yaml +0 -40
  121. package/hedhog/table/operations_contract_revision.yaml +0 -38
  122. package/hedhog/table/operations_contract_signature.yaml +0 -38
  123. package/hedhog/table/operations_contract_template.yaml +0 -58
@@ -1,6 +1,11 @@
1
1
  'use client';
2
2
 
3
- import { EmptyState, Page, SearchBar } from '@/components/entity-list';
3
+ import {
4
+ EmptyState,
5
+ Page,
6
+ PaginationFooter,
7
+ SearchBar,
8
+ } from '@/components/entity-list';
4
9
  import {
5
10
  AlertDialog,
6
11
  AlertDialogAction,
@@ -12,7 +17,6 @@ import {
12
17
  AlertDialogTitle,
13
18
  } from '@/components/ui/alert-dialog';
14
19
  import { Button } from '@/components/ui/button';
15
- import { Card, CardContent } from '@/components/ui/card';
16
20
  import {
17
21
  DropdownMenu,
18
22
  DropdownMenuContent,
@@ -36,24 +40,21 @@ import {
36
40
  TableHeader,
37
41
  TableRow,
38
42
  } from '@/components/ui/table';
39
- import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
40
43
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
41
44
  import {
42
45
  Download,
43
- FileStack,
46
+ Eye,
44
47
  FileText,
45
- LayoutGrid,
46
- List,
47
48
  MoreHorizontal,
48
49
  Pencil,
49
- Sparkles,
50
+ ShieldCheck,
51
+ ShieldOff,
50
52
  Trash2,
51
- Upload,
52
53
  } from 'lucide-react';
53
54
  import { useTranslations } from 'next-intl';
54
55
  import { usePathname, useRouter, useSearchParams } from 'next/navigation';
55
- import { useMemo, useRef, useState } from 'react';
56
- import { ContractWizardSheet } from '../_components/contract-wizard-sheet';
56
+ import { useMemo, useState } from 'react';
57
+ import { ContractFormScreen } from '../_components/contract-form-screen';
57
58
  import { OperationsHeader } from '../_components/operations-header';
58
59
  import { StatusBadge } from '../_components/status-badge';
59
60
  import { fetchOperations, mutateOperations } from '../_lib/api';
@@ -61,13 +62,10 @@ import { useOperationsAccess } from '../_lib/hooks/use-operations-access';
61
62
  import type {
62
63
  OperationsContract,
63
64
  OperationsContractDetails,
65
+ OperationsContractStats,
66
+ PaginatedResponse,
64
67
  } from '../_lib/types';
65
- import {
66
- formatCurrency,
67
- formatDate,
68
- formatEnumLabel,
69
- getStatusBadgeClass,
70
- } from '../_lib/utils/format';
68
+ import { getStatusBadgeClass } from '../_lib/utils/format';
71
69
 
72
70
  function downloadBase64File(
73
71
  fileName: string,
@@ -81,254 +79,142 @@ function downloadBase64File(
81
79
  link.click();
82
80
  }
83
81
 
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;
82
+ function buildStoredFileUrl(fileId?: number | null) {
83
+ if (!fileId) return null;
90
84
  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
- });
85
+ return `${baseUrl}/file/open/${fileId}`;
109
86
  }
110
87
 
111
88
  export default function OperationsContractsPage() {
112
89
  const t = useTranslations('operations.ContractsPage');
113
90
  const commonT = useTranslations('operations.Common');
114
- const formT = useTranslations('operations.ContractFormPage');
91
+ const detailT = useTranslations('operations.ContractDetailsPage');
115
92
  const { request, showToastHandler, currentLocaleCode } = useApp();
116
93
  const access = useOperationsAccess();
117
94
  const router = useRouter();
118
95
  const pathname = usePathname();
119
96
  const searchParams = useSearchParams();
120
97
  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);
98
+ const [isActiveFilter, setIsActiveFilter] = useState('all');
99
+ const [page, setPage] = useState(1);
100
+ const [pageSize, setPageSize] = useState(12);
126
101
  const [isCreateSheetOpen, setIsCreateSheetOpen] = useState(false);
127
102
  const [contractToDelete, setContractToDelete] =
128
103
  useState<OperationsContract | null>(null);
129
104
  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
105
 
151
106
  const editParam = searchParams.get('edit');
152
- const duplicateParam = searchParams.get('duplicate');
153
- const templateParam = searchParams.get('template');
154
107
  const editingContractId =
155
108
  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
109
 
163
- const updateSheetQuery = (options?: {
164
- editId?: number | null;
165
- duplicateId?: number | null;
166
- templateId?: number | null;
167
- }) => {
110
+ const updateSheetQuery = (options?: { editId?: number | null }) => {
168
111
  const params = new URLSearchParams(searchParams.toString());
169
-
170
112
  params.delete('edit');
171
- params.delete('duplicate');
172
- params.delete('template');
173
113
 
174
114
  if (options?.editId && options.editId > 0) {
175
115
  params.set('edit', String(options.editId));
176
116
  }
177
117
 
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
118
  const nextUrl = params.size ? `${pathname}?${params.toString()}` : pathname;
187
119
  router.replace(nextUrl, { scroll: false });
188
120
  };
189
121
 
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
122
  const closeFormSheet = () => {
211
123
  setIsCreateSheetOpen(false);
212
-
213
- if (
214
- editingContractId !== null ||
215
- duplicatingContractId !== null ||
216
- creatingFromTemplateId !== null
217
- ) {
124
+ if (editingContractId !== null) {
218
125
  updateSheetQuery();
219
126
  }
220
127
  };
221
128
 
222
- const { data: contracts = [], refetch } = useQuery<OperationsContract[]>({
223
- queryKey: ['operations-contracts-list', currentLocaleCode],
224
- queryFn: () =>
225
- fetchOperations<OperationsContract[]>(request, '/operations/contracts'),
226
- });
129
+ const { data: contractsResponse, refetch } = useQuery<
130
+ PaginatedResponse<OperationsContract>
131
+ >({
132
+ queryKey: [
133
+ 'operations-contracts-list',
134
+ currentLocaleCode,
135
+ search,
136
+ isActiveFilter,
137
+ page,
138
+ pageSize,
139
+ ],
140
+ queryFn: () => {
141
+ const params = new URLSearchParams({
142
+ page: String(page),
143
+ pageSize: String(pageSize),
144
+ });
227
145
 
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
- );
146
+ if (search.trim()) params.set('search', search.trim());
147
+ if (isActiveFilter !== 'all') {
148
+ params.set('isActive', String(isActiveFilter === 'active'));
149
+ }
260
150
 
261
- const sheetTitle = editingContractId
262
- ? commonT('actions.edit')
263
- : duplicatingContractId
264
- ? t('actions.duplicate')
265
- : isCreateSheetOpen || creatingFromTemplateId
266
- ? commonT('actions.create')
267
- : t('title');
151
+ return fetchOperations<PaginatedResponse<OperationsContract>>(
152
+ request,
153
+ `/operations/contracts?${params.toString()}`
154
+ );
155
+ },
156
+ placeholderData: (previous) => previous,
157
+ });
158
+
159
+ const { data: stats } = useQuery<OperationsContractStats>({
160
+ queryKey: ['operations-contracts-stats', currentLocaleCode],
161
+ queryFn: () =>
162
+ fetchOperations<OperationsContractStats>(request, '/operations/contracts/stats'),
163
+ });
268
164
 
165
+ const rows = contractsResponse?.data ?? [];
269
166
  const statsCards = useMemo(
270
167
  () => [
271
168
  {
272
169
  key: 'total',
273
170
  title: t('cards.total'),
274
- value: contracts.length,
171
+ value: stats?.total ?? 0,
275
172
  icon: FileText,
276
173
  },
277
174
  {
278
175
  key: 'active',
279
176
  title: t('cards.active'),
280
- value: contracts.filter((item) => item.status === 'active').length,
281
- icon: Sparkles,
177
+ value: stats?.active ?? 0,
178
+ icon: ShieldCheck,
282
179
  },
283
180
  {
284
- key: 'underReview',
285
- title: t('cards.underReview'),
286
- value: contracts.filter((item) => item.status === 'under_review')
287
- .length,
288
- icon: Pencil,
181
+ key: 'inactive',
182
+ title: t('cards.inactive'),
183
+ value: stats?.inactive ?? 0,
184
+ icon: ShieldOff,
289
185
  },
290
186
  {
291
- key: 'withTemplate',
292
- title: t('cards.withTemplate'),
293
- value: contracts.filter((item) => Boolean(item.contractTemplateId))
294
- .length,
295
- icon: FileStack,
187
+ key: 'withFile',
188
+ title: t('cards.withFile'),
189
+ value: stats?.withFile ?? 0,
190
+ icon: Download,
296
191
  },
297
192
  ],
298
- [contracts, t]
193
+ [stats, t]
299
194
  );
300
195
 
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) => {
196
+ const downloadContract = async (contractId: number) => {
314
197
  const detail = await fetchOperations<OperationsContractDetails>(
315
198
  request,
316
199
  `/operations/contracts/${contractId}`
317
200
  );
318
- const document = detail.documents.find(
319
- (item) =>
320
- item.isCurrent &&
321
- (item.documentType === 'generated_pdf' ||
322
- item.documentType === 'source_upload')
323
- );
201
+ const document =
202
+ detail.documents.find(
203
+ (item) =>
204
+ item.isCurrent &&
205
+ ['generated_pdf', 'source_upload'].includes(item.documentType)
206
+ ) ?? null;
207
+
324
208
  if (document?.fileId) {
325
- openStoredFile(document.fileId);
209
+ window.open(buildStoredFileUrl(document.fileId) || '', '_blank', 'noopener,noreferrer');
326
210
  return;
327
211
  }
212
+
328
213
  if (!document?.fileContentBase64) {
329
214
  showToastHandler?.('error', t('messages.noPdf'));
330
215
  return;
331
216
  }
217
+
332
218
  downloadBase64File(
333
219
  document.fileName,
334
220
  document.mimeType,
@@ -336,10 +222,6 @@ export default function OperationsContractsPage() {
336
222
  );
337
223
  };
338
224
 
339
- const duplicateContract = (contractId: number) => {
340
- openDuplicateSheet(contractId);
341
- };
342
-
343
225
  const handleDeleteContract = async () => {
344
226
  if (!contractToDelete?.id) {
345
227
  return;
@@ -361,15 +243,16 @@ export default function OperationsContractsPage() {
361
243
  }
362
244
  };
363
245
 
364
- const toggleArchived = async (contract: OperationsContract) => {
246
+ const toggleActive = async (contract: OperationsContract) => {
365
247
  try {
248
+ const nextIsActive = !contract.isActive;
366
249
  await mutateOperations(
367
250
  request,
368
251
  `/operations/contracts/${contract.id}`,
369
252
  'PATCH',
370
253
  {
371
- status: contract.status === 'archived' ? 'active' : 'archived',
372
- isActive: contract.status === 'archived',
254
+ isActive: nextIsActive,
255
+ status: nextIsActive ? 'active' : 'archived',
373
256
  }
374
257
  );
375
258
  showToastHandler?.('success', t('messages.statusSuccess'));
@@ -379,10 +262,7 @@ export default function OperationsContractsPage() {
379
262
  }
380
263
  };
381
264
 
382
- const renderContractActions = (
383
- contract: OperationsContract,
384
- align: 'start' | 'end' = 'end'
385
- ) => (
265
+ const renderActions = (contract: OperationsContract) => (
386
266
  <DropdownMenu>
387
267
  <DropdownMenuTrigger asChild>
388
268
  <Button
@@ -390,47 +270,37 @@ export default function OperationsContractsPage() {
390
270
  size="icon"
391
271
  className="cursor-pointer"
392
272
  aria-label={commonT('labels.actions')}
273
+ onClick={(event) => event.stopPropagation()}
393
274
  >
394
275
  <MoreHorizontal className="size-4" />
395
276
  </Button>
396
277
  </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)}>
278
+ <DropdownMenuContent align="end" className="w-52">
279
+ <DropdownMenuItem
280
+ onSelect={() => router.push(`/operations/contracts/${contract.id}`)}
281
+ >
282
+ <Eye className="size-4" />
283
+ {detailT('actions.preview')}
284
+ </DropdownMenuItem>
285
+ <DropdownMenuItem onSelect={() => void downloadContract(contract.id)}>
405
286
  <Download className="size-4" />
406
287
  {t('actions.downloadPdf')}
407
288
  </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
289
  {access.isDirector ? <DropdownMenuSeparator /> : null}
420
290
  {access.isDirector ? (
421
- <DropdownMenuItem
422
- onSelect={() => void duplicateContract(contract.id)}
423
- >
424
- <FileStack className="size-4" />
425
- {t('actions.duplicate')}
291
+ <DropdownMenuItem onSelect={() => updateSheetQuery({ editId: contract.id })}>
292
+ <Pencil className="size-4" />
293
+ {commonT('actions.edit')}
426
294
  </DropdownMenuItem>
427
295
  ) : null}
428
296
  {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')}
297
+ <DropdownMenuItem onSelect={() => void toggleActive(contract)}>
298
+ {contract.isActive ? (
299
+ <ShieldOff className="size-4" />
300
+ ) : (
301
+ <ShieldCheck className="size-4" />
302
+ )}
303
+ {contract.isActive ? detailT('labels.inactive') : detailT('labels.active')}
434
304
  </DropdownMenuItem>
435
305
  ) : null}
436
306
  {access.isDirector ? <DropdownMenuSeparator /> : null}
@@ -455,376 +325,110 @@ export default function OperationsContractsPage() {
455
325
  current={t('breadcrumb')}
456
326
  actions={
457
327
  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>
328
+ <Button
329
+ size="sm"
330
+ className="cursor-pointer"
331
+ onClick={() => setIsCreateSheetOpen(true)}
332
+ >
333
+ {commonT('actions.create')}
334
+ </Button>
467
335
  ) : undefined
468
336
  }
469
337
  />
470
338
 
471
339
  <KpiCardsGrid items={statsCards} columns={4} />
472
340
 
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
- }
341
+ <SearchBar
342
+ searchQuery={search}
343
+ onSearchChange={(value) => {
344
+ setSearch(value);
345
+ setPage(1);
609
346
  }}
347
+ showSearchButton={false}
348
+ debounceMs={500}
349
+ placeholder={t('searchPlaceholder')}
350
+ controls={[
351
+ {
352
+ id: 'status',
353
+ type: 'select',
354
+ value: isActiveFilter,
355
+ onChange: (value) => {
356
+ setIsActiveFilter(value);
357
+ setPage(1);
358
+ },
359
+ placeholder: commonT('labels.status'),
360
+ options: [
361
+ { value: 'all', label: commonT('filters.allStatuses') },
362
+ { value: 'active', label: detailT('labels.active') },
363
+ { value: 'inactive', label: detailT('labels.inactive') },
364
+ ],
365
+ },
366
+ ]}
610
367
  />
611
368
 
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
369
+ {rows.length ? (
370
+ <div className="overflow-x-auto rounded-md border">
371
+ <Table>
372
+ <TableHeader>
373
+ <TableRow>
374
+ <TableHead>{t('columns.title')}</TableHead>
375
+ <TableHead>{t('columns.file')}</TableHead>
376
+ <TableHead>{commonT('labels.status')}</TableHead>
377
+ <TableHead className="w-32 text-right">
378
+ {commonT('labels.actions')}
379
+ </TableHead>
380
+ </TableRow>
381
+ </TableHeader>
382
+ <TableBody>
383
+ {rows.map((contract) => (
384
+ <TableRow
632
385
  key={contract.id}
633
- className="overflow-hidden border-border/60 py-0 shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md"
386
+ className="cursor-pointer hover:bg-muted/30"
387
+ onClick={() => router.push(`/operations/contracts/${contract.id}`)}
634
388
  >
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')}
389
+ <TableCell>
390
+ <div className="min-w-0">
391
+ <div className="truncate font-medium">
392
+ {contract.name ||
393
+ contract.code ||
394
+ commonT('labels.notAvailable')}
674
395
  </div>
675
- <div>
676
- <span className="font-medium text-foreground">
677
- {t('columns.party')}:
678
- </span>{' '}
396
+ <div className="truncate text-xs text-muted-foreground">
679
397
  {contract.mainRelatedPartyName ||
680
398
  commonT('labels.notAvailable')}
681
399
  </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
400
  </div>
715
-
716
- <div className="flex justify-end">
717
- {renderContractActions(contract, 'end')}
401
+ </TableCell>
402
+ <TableCell>
403
+ <span className="truncate text-sm text-muted-foreground">
404
+ {contract.currentPdfFileName || commonT('labels.notAvailable')}
405
+ </span>
406
+ </TableCell>
407
+ <TableCell>
408
+ <StatusBadge
409
+ label={
410
+ contract.isActive
411
+ ? detailT('labels.active')
412
+ : detailT('labels.inactive')
413
+ }
414
+ className={getStatusBadgeClass(
415
+ contract.isActive ? 'active' : 'archived'
416
+ )}
417
+ />
418
+ </TableCell>
419
+ <TableCell>
420
+ <div
421
+ className="flex justify-end"
422
+ onClick={(event) => event.stopPropagation()}
423
+ >
424
+ {renderActions(contract)}
718
425
  </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>
426
+ </TableCell>
748
427
  </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
- )
428
+ ))}
429
+ </TableBody>
430
+ </Table>
431
+ </div>
828
432
  ) : (
829
433
  <EmptyState
830
434
  icon={<FileText className="size-12" />}
@@ -836,11 +440,25 @@ export default function OperationsContractsPage() {
836
440
  : commonT('actions.refresh')
837
441
  }
838
442
  onAction={
839
- access.isDirector ? () => openCreateSheet() : () => void refetch()
443
+ access.isDirector
444
+ ? () => setIsCreateSheetOpen(true)
445
+ : () => void refetch()
840
446
  }
841
447
  />
842
448
  )}
843
449
 
450
+ <PaginationFooter
451
+ currentPage={page}
452
+ pageSize={pageSize}
453
+ totalItems={contractsResponse?.total ?? 0}
454
+ pageSizeOptions={[12, 24, 48]}
455
+ onPageChange={setPage}
456
+ onPageSizeChange={(size) => {
457
+ setPageSize(size);
458
+ setPage(1);
459
+ }}
460
+ />
461
+
844
462
  <AlertDialog
845
463
  open={contractToDelete !== null}
846
464
  onOpenChange={(open) => {
@@ -882,12 +500,7 @@ export default function OperationsContractsPage() {
882
500
  </AlertDialog>
883
501
 
884
502
  <Sheet
885
- open={
886
- isCreateSheetOpen ||
887
- editingContractId !== null ||
888
- duplicatingContractId !== null ||
889
- creatingFromTemplateId !== null
890
- }
503
+ open={isCreateSheetOpen || editingContractId !== null}
891
504
  onOpenChange={(open) => {
892
505
  if (!open) {
893
506
  closeFormSheet();
@@ -896,39 +509,24 @@ export default function OperationsContractsPage() {
896
509
  >
897
510
  <SheetContent className="flex h-full w-full flex-col overflow-hidden p-0 sm:max-w-xl lg:max-w-4xl">
898
511
  <SheetHeader className="sr-only">
899
- <SheetTitle>{sheetTitle}</SheetTitle>
512
+ <SheetTitle>
513
+ {editingContractId ? commonT('actions.edit') : commonT('actions.create')}
514
+ </SheetTitle>
900
515
  <SheetDescription>{t('description')}</SheetDescription>
901
516
  </SheetHeader>
902
- <ContractWizardSheet
517
+ <ContractFormScreen
903
518
  key={
904
519
  editingContractId
905
520
  ? `edit-${editingContractId}`
906
- : duplicatingContractId
907
- ? `duplicate-${duplicatingContractId}`
908
- : creatingFromTemplateId
909
- ? `template-${creatingFromTemplateId}`
910
- : isCreateSheetOpen
911
- ? 'create-contract'
912
- : 'contract-sheet'
521
+ : isCreateSheetOpen
522
+ ? 'create-contract'
523
+ : 'contract-sheet'
913
524
  }
914
525
  contractId={editingContractId ?? undefined}
915
- duplicateFromId={duplicatingContractId ?? undefined}
916
- initialTemplateId={creatingFromTemplateId ?? undefined}
917
- isCreateFlow={isCreateSheetOpen}
918
526
  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
-
527
+ onSaved={async () => {
931
528
  closeFormSheet();
529
+ await refetch();
932
530
  }}
933
531
  />
934
532
  </SheetContent>