@hed-hog/operations 0.0.304 → 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.
- package/dist/controllers/operations-projects.controller.d.ts +15 -0
- package/dist/controllers/operations-projects.controller.d.ts.map +1 -1
- package/dist/controllers/operations-tasks.controller.d.ts +41 -10
- package/dist/controllers/operations-tasks.controller.d.ts.map +1 -1
- package/dist/controllers/operations-tasks.controller.js +11 -0
- package/dist/controllers/operations-tasks.controller.js.map +1 -1
- package/dist/dto/create-task.dto.d.ts +7 -1
- package/dist/dto/create-task.dto.d.ts.map +1 -1
- package/dist/dto/create-task.dto.js +38 -5
- package/dist/dto/create-task.dto.js.map +1 -1
- package/dist/dto/list-tasks.dto.d.ts +1 -1
- package/dist/dto/list-tasks.dto.d.ts.map +1 -1
- package/dist/dto/list-tasks.dto.js +2 -2
- package/dist/dto/list-tasks.dto.js.map +1 -1
- package/dist/dto/update-task.dto.d.ts +7 -1
- package/dist/dto/update-task.dto.d.ts.map +1 -1
- package/dist/dto/update-task.dto.js +38 -5
- package/dist/dto/update-task.dto.js.map +1 -1
- package/dist/operations.service.d.ts +68 -12
- package/dist/operations.service.d.ts.map +1 -1
- package/dist/operations.service.js +380 -101
- package/dist/operations.service.js.map +1 -1
- package/hedhog/data/route.yaml +13 -0
- package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +44 -44
- package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +168 -213
- package/hedhog/frontend/app/_components/collaborator-select-with-create.tsx.ejs +256 -256
- package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +7 -7
- package/hedhog/frontend/app/_components/contract-template-form-screen.tsx.ejs +306 -306
- package/hedhog/frontend/app/_components/contract-template-select-with-create.tsx.ejs +247 -247
- package/hedhog/frontend/app/_components/contract-wizard-sheet.tsx.ejs +3520 -3520
- package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +1504 -52
- package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +528 -403
- package/hedhog/frontend/app/_components/section-card.tsx.ejs +25 -18
- package/hedhog/frontend/app/_components/system-user-select-with-create.tsx.ejs +609 -0
- package/hedhog/frontend/app/_lib/types.ts.ejs +5 -0
- package/hedhog/frontend/app/_lib/utils/format.ts.ejs +7 -7
- package/hedhog/frontend/app/_lib/utils/forms.ts.ejs +48 -1
- package/hedhog/frontend/app/collaborator-types/page.tsx.ejs +502 -502
- package/hedhog/frontend/app/collaborators/page.tsx.ejs +10 -7
- package/hedhog/frontend/app/contracts/page.tsx.ejs +938 -938
- package/hedhog/frontend/app/projects/[id]/edit/page.tsx.ejs +1 -1
- package/hedhog/frontend/app/projects/page.tsx.ejs +360 -133
- package/hedhog/frontend/messages/en.json +27 -4
- package/hedhog/frontend/messages/pt.json +27 -4
- package/hedhog/table/operations_project.yaml +9 -0
- package/hedhog/table/operations_task.yaml +43 -4
- package/package.json +5 -5
- package/src/controllers/operations-tasks.controller.ts +11 -0
- package/src/dto/create-task.dto.ts +47 -7
- package/src/dto/list-tasks.dto.ts +3 -3
- package/src/dto/update-task.dto.ts +47 -7
- package/src/operations.service.ts +556 -88
|
@@ -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
|
+
}
|