@hed-hog/operations 0.0.304 → 0.0.306

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 (81) hide show
  1. package/dist/controllers/operations-projects.controller.d.ts +15 -0
  2. package/dist/controllers/operations-projects.controller.d.ts.map +1 -1
  3. package/dist/controllers/operations-tasks.controller.d.ts +41 -10
  4. package/dist/controllers/operations-tasks.controller.d.ts.map +1 -1
  5. package/dist/controllers/operations-tasks.controller.js +11 -0
  6. package/dist/controllers/operations-tasks.controller.js.map +1 -1
  7. package/dist/controllers/operations-timesheets.controller.d.ts +21 -0
  8. package/dist/controllers/operations-timesheets.controller.d.ts.map +1 -1
  9. package/dist/controllers/operations-timesheets.controller.js +12 -0
  10. package/dist/controllers/operations-timesheets.controller.js.map +1 -1
  11. package/dist/dto/create-task.dto.d.ts +7 -1
  12. package/dist/dto/create-task.dto.d.ts.map +1 -1
  13. package/dist/dto/create-task.dto.js +38 -5
  14. package/dist/dto/create-task.dto.js.map +1 -1
  15. package/dist/dto/list-tasks.dto.d.ts +1 -1
  16. package/dist/dto/list-tasks.dto.d.ts.map +1 -1
  17. package/dist/dto/list-tasks.dto.js +2 -2
  18. package/dist/dto/list-tasks.dto.js.map +1 -1
  19. package/dist/dto/update-collaborator-type.dto.d.ts +3 -1
  20. package/dist/dto/update-collaborator-type.dto.d.ts.map +1 -1
  21. package/dist/dto/update-collaborator-type.dto.js +2 -1
  22. package/dist/dto/update-collaborator-type.dto.js.map +1 -1
  23. package/dist/dto/update-task.dto.d.ts +7 -1
  24. package/dist/dto/update-task.dto.d.ts.map +1 -1
  25. package/dist/dto/update-task.dto.js +38 -5
  26. package/dist/dto/update-task.dto.js.map +1 -1
  27. package/dist/operations.service.d.ts +90 -12
  28. package/dist/operations.service.d.ts.map +1 -1
  29. package/dist/operations.service.js +560 -148
  30. package/dist/operations.service.js.map +1 -1
  31. package/dist/operations.service.spec.js +73 -0
  32. package/dist/operations.service.spec.js.map +1 -1
  33. package/hedhog/data/menu.yaml +26 -26
  34. package/hedhog/data/operations_collaborator_type.yaml +76 -76
  35. package/hedhog/data/route.yaml +26 -0
  36. package/hedhog/frontend/app/_components/async-options-combobox.tsx.ejs +5 -3
  37. package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +44 -44
  38. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +168 -213
  39. package/hedhog/frontend/app/_components/collaborator-select-with-create.tsx.ejs +256 -256
  40. package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +7 -7
  41. package/hedhog/frontend/app/_components/contract-template-form-screen.tsx.ejs +306 -306
  42. package/hedhog/frontend/app/_components/contract-template-select-with-create.tsx.ejs +247 -247
  43. package/hedhog/frontend/app/_components/contract-wizard-sheet.tsx.ejs +3520 -3520
  44. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +1504 -52
  45. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +528 -403
  46. package/hedhog/frontend/app/_components/section-card.tsx.ejs +25 -18
  47. package/hedhog/frontend/app/_components/system-user-select-with-create.tsx.ejs +609 -0
  48. package/hedhog/frontend/app/_components/timesheet-task-create-sheet.tsx.ejs +1 -0
  49. package/hedhog/frontend/app/_lib/types.ts.ejs +5 -0
  50. package/hedhog/frontend/app/_lib/utils/format.ts.ejs +7 -7
  51. package/hedhog/frontend/app/_lib/utils/forms.ts.ejs +48 -1
  52. package/hedhog/frontend/app/approvals/page.tsx.ejs +2 -2
  53. package/hedhog/frontend/app/collaborator-types/page.tsx.ejs +513 -502
  54. package/hedhog/frontend/app/collaborators/page.tsx.ejs +10 -7
  55. package/hedhog/frontend/app/contracts/page.tsx.ejs +938 -938
  56. package/hedhog/frontend/app/projects/[id]/edit/page.tsx.ejs +1 -1
  57. package/hedhog/frontend/app/projects/page.tsx.ejs +360 -133
  58. package/hedhog/frontend/app/schedule-adjustments/page.tsx.ejs +235 -72
  59. package/hedhog/frontend/app/timesheets/page.tsx.ejs +344 -134
  60. package/hedhog/frontend/messages/en.json +32 -4
  61. package/hedhog/frontend/messages/pt.json +34 -6
  62. package/hedhog/table/operations_collaborator.yaml +18 -18
  63. package/hedhog/table/operations_collaborator_equity_participation.yaml +43 -43
  64. package/hedhog/table/operations_collaborator_type.yaml +33 -33
  65. package/hedhog/table/operations_contract_document.yaml +33 -33
  66. package/hedhog/table/operations_project.yaml +9 -0
  67. package/hedhog/table/operations_task.yaml +43 -4
  68. package/package.json +6 -6
  69. package/src/controllers/operations-tasks.controller.ts +11 -0
  70. package/src/controllers/operations-timesheets.controller.ts +13 -0
  71. package/src/dto/create-collaborator-type.dto.ts +43 -43
  72. package/src/dto/create-collaborator.dto.ts +223 -223
  73. package/src/dto/create-task.dto.ts +47 -7
  74. package/src/dto/list-collaborator-types.dto.ts +15 -15
  75. package/src/dto/list-collaborators.dto.ts +30 -30
  76. package/src/dto/list-tasks.dto.ts +3 -3
  77. package/src/dto/update-collaborator-type.dto.ts +4 -3
  78. package/src/dto/update-collaborator.dto.ts +3 -3
  79. package/src/dto/update-task.dto.ts +47 -7
  80. package/src/operations.service.spec.ts +96 -0
  81. package/src/operations.service.ts +813 -135
@@ -1,502 +1,513 @@
1
- 'use client';
2
-
3
- import { EmptyState, Page, SearchBar } from '@/components/entity-list';
4
- import { Button } from '@/components/ui/button';
5
- import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
6
- import {
7
- Sheet,
8
- SheetContent,
9
- SheetDescription,
10
- SheetHeader,
11
- SheetTitle,
12
- } from '@/components/ui/sheet';
13
- import {
14
- Table,
15
- TableBody,
16
- TableCell,
17
- TableHead,
18
- TableHeader,
19
- TableRow,
20
- } from '@/components/ui/table';
21
- import { useApp, useQuery } from '@hed-hog/next-app-provider';
22
- import { Briefcase, Pencil, Users } from 'lucide-react';
23
- import { useTranslations } from 'next-intl';
24
- import Link from 'next/link';
25
- import { useMemo, useState } from 'react';
26
- import { OperationsHeader } from '../_components/operations-header';
27
- import { StatusBadge } from '../_components/status-badge';
28
- import { fetchOperations, mutateOperations } from '../_lib/api';
29
- import { useOperationsAccess } from '../_lib/hooks/use-operations-access';
30
- import type { OperationsCollaboratorType } from '../_lib/types';
31
- import { formatEnumLabel, getStatusBadgeClass } from '../_lib/utils/format';
32
-
33
- type CollaboratorTypeFormState = {
34
- name: string;
35
- slug: string;
36
- description: string;
37
- category: string;
38
- sortOrder: string;
39
- status: 'active' | 'inactive';
40
- };
41
-
42
- const EMPTY_FORM_STATE: CollaboratorTypeFormState = {
43
- name: '',
44
- slug: '',
45
- description: '',
46
- category: '',
47
- sortOrder: '0',
48
- status: 'active',
49
- };
50
-
51
- export default function OperationsCollaboratorTypesPage() {
52
- const t = useTranslations('operations.CollaboratorTypesPage');
53
- const commonT = useTranslations('operations.Common');
54
- const { request, showToastHandler, currentLocaleCode } = useApp();
55
- const access = useOperationsAccess();
56
- const [search, setSearch] = useState('');
57
- const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>(
58
- 'all'
59
- );
60
- const [isSheetOpen, setIsSheetOpen] = useState(false);
61
- const [isSaving, setIsSaving] = useState(false);
62
- const [editingType, setEditingType] = useState<OperationsCollaboratorType | null>(
63
- null
64
- );
65
- const [form, setForm] = useState<CollaboratorTypeFormState>(EMPTY_FORM_STATE);
66
-
67
- const {
68
- data: collaboratorTypes = [],
69
- isLoading,
70
- refetch,
71
- } = useQuery<OperationsCollaboratorType[]>({
72
- queryKey: ['operations-collaborator-types-page', currentLocaleCode],
73
- enabled: access.isDirector,
74
- queryFn: () =>
75
- fetchOperations<OperationsCollaboratorType[]>(
76
- request,
77
- '/operations/collaborator-types'
78
- ),
79
- });
80
-
81
- const filteredTypes = useMemo(
82
- () =>
83
- collaboratorTypes.filter((collaboratorType) => {
84
- const matchesSearch = !search.trim()
85
- ? true
86
- : [
87
- collaboratorType.name,
88
- collaboratorType.slug,
89
- collaboratorType.category,
90
- collaboratorType.description,
91
- ]
92
- .filter(Boolean)
93
- .some((value) =>
94
- String(value)
95
- .toLowerCase()
96
- .includes(search.trim().toLowerCase())
97
- );
98
-
99
- const matchesStatus =
100
- statusFilter === 'all' ? true : collaboratorType.status === statusFilter;
101
-
102
- return matchesSearch && matchesStatus;
103
- }),
104
- [collaboratorTypes, search, statusFilter]
105
- );
106
-
107
- const statsCards = useMemo(
108
- () => [
109
- {
110
- key: 'total',
111
- title: t('cards.total'),
112
- value: collaboratorTypes.length,
113
- icon: Briefcase,
114
- },
115
- {
116
- key: 'active',
117
- title: t('cards.active'),
118
- value: collaboratorTypes.filter((item) => item.status === 'active').length,
119
- icon: Briefcase,
120
- },
121
- {
122
- key: 'linkedCollaborators',
123
- title: t('cards.linkedCollaborators'),
124
- value: collaboratorTypes.reduce(
125
- (total, item) => total + Number(item.collaboratorCount ?? 0),
126
- 0
127
- ),
128
- icon: Users,
129
- },
130
- {
131
- key: 'inactive',
132
- title: t('cards.inactive'),
133
- value: collaboratorTypes.filter((item) => item.status === 'inactive').length,
134
- icon: Briefcase,
135
- },
136
- ],
137
- [collaboratorTypes, t]
138
- );
139
-
140
- const updateForm = <K extends keyof CollaboratorTypeFormState>(
141
- field: K,
142
- value: CollaboratorTypeFormState[K]
143
- ) => {
144
- setForm((current) => ({
145
- ...current,
146
- [field]: value,
147
- }));
148
- };
149
-
150
- const openCreateSheet = () => {
151
- setEditingType(null);
152
- setForm(EMPTY_FORM_STATE);
153
- setIsSheetOpen(true);
154
- };
155
-
156
- const openEditSheet = (collaboratorType: OperationsCollaboratorType) => {
157
- setEditingType(collaboratorType);
158
- setForm({
159
- name: collaboratorType.name ?? '',
160
- slug: collaboratorType.slug ?? '',
161
- description: collaboratorType.description ?? '',
162
- category: collaboratorType.category ?? '',
163
- sortOrder: String(collaboratorType.sortOrder ?? 0),
164
- status: collaboratorType.status ?? 'active',
165
- });
166
- setIsSheetOpen(true);
167
- };
168
-
169
- const saveCollaboratorType = async () => {
170
- if (!form.name.trim()) {
171
- showToastHandler?.('error', t('messages.requiredFields'));
172
- return;
173
- }
174
-
175
- setIsSaving(true);
176
-
177
- try {
178
- await mutateOperations(
179
- request,
180
- editingType
181
- ? `/operations/collaborator-types/${editingType.id}`
182
- : '/operations/collaborator-types',
183
- editingType ? 'PATCH' : 'POST',
184
- {
185
- name: form.name.trim(),
186
- slug: form.slug.trim() || null,
187
- description: form.description.trim() || null,
188
- category: form.category.trim() || null,
189
- sortOrder: Number(form.sortOrder || '0'),
190
- status: form.status,
191
- isActive: form.status === 'active',
192
- }
193
- );
194
-
195
- showToastHandler?.('success', t('messages.saveSuccess'));
196
- setIsSheetOpen(false);
197
- setEditingType(null);
198
- setForm(EMPTY_FORM_STATE);
199
- await refetch();
200
- } catch {
201
- showToastHandler?.('error', t('messages.saveError'));
202
- } finally {
203
- setIsSaving(false);
204
- }
205
- };
206
-
207
- const toggleStatus = async (collaboratorType: OperationsCollaboratorType) => {
208
- const nextStatus =
209
- collaboratorType.status === 'inactive' ? 'active' : 'inactive';
210
-
211
- try {
212
- await mutateOperations(
213
- request,
214
- `/operations/collaborator-types/${collaboratorType.id}`,
215
- 'PATCH',
216
- { status: nextStatus, isActive: nextStatus === 'active' }
217
- );
218
- showToastHandler?.('success', t('messages.statusSuccess'));
219
- await refetch();
220
- } catch {
221
- showToastHandler?.('error', t('messages.statusError'));
222
- }
223
- };
224
-
225
- if (!access.isLoading && !access.isDirector) {
226
- return (
227
- <Page>
228
- <OperationsHeader
229
- title={t('title')}
230
- description={t('description')}
231
- current={t('breadcrumb')}
232
- actions={
233
- <Button variant="outline" size="sm" asChild>
234
- <Link href="/operations/collaborators">
235
- {commonT('actions.back')}
236
- </Link>
237
- </Button>
238
- }
239
- />
240
-
241
- <EmptyState
242
- icon={<Briefcase className="size-12" />}
243
- title={commonT('states.noAccessTitle')}
244
- description={t('noAccessDescription')}
245
- actionLabel={commonT('actions.back')}
246
- onAction={() => {
247
- window.location.href = '/operations';
248
- }}
249
- />
250
- </Page>
251
- );
252
- }
253
-
254
- return (
255
- <Page>
256
- <OperationsHeader
257
- title={t('title')}
258
- description={t('description')}
259
- current={t('breadcrumb')}
260
- actions={
261
- <div className="flex flex-wrap gap-2">
262
- <Button variant="outline" size="sm" asChild>
263
- <Link href="/operations/collaborators">
264
- {commonT('actions.back')}
265
- </Link>
266
- </Button>
267
- <Button size="sm" onClick={openCreateSheet}>
268
- {commonT('actions.create')}
269
- </Button>
270
- </div>
271
- }
272
- />
273
-
274
- <KpiCardsGrid items={statsCards} columns={4} />
275
-
276
- <div className="flex flex-col gap-4">
277
- <SearchBar
278
- searchQuery={search}
279
- onSearchChange={setSearch}
280
- onSearch={() => undefined}
281
- placeholder={t('searchPlaceholder')}
282
- controls={[
283
- {
284
- id: 'status',
285
- type: 'select',
286
- value: statusFilter,
287
- onChange: (value) =>
288
- setStatusFilter(
289
- (value as 'all' | 'active' | 'inactive') ?? 'all'
290
- ),
291
- placeholder: commonT('labels.status'),
292
- options: [
293
- { value: 'all', label: commonT('filters.allStatuses') },
294
- { value: 'active', label: formatEnumLabel('active') },
295
- { value: 'inactive', label: formatEnumLabel('inactive') },
296
- ],
297
- },
298
- ]}
299
- />
300
- </div>
301
-
302
- {isLoading ? (
303
- <div className="rounded-md border px-4 py-6 text-sm text-muted-foreground">
304
- {commonT('actions.refresh')}...
305
- </div>
306
- ) : filteredTypes.length > 0 ? (
307
- <div className="overflow-x-auto rounded-md border">
308
- <Table>
309
- <TableHeader>
310
- <TableRow>
311
- <TableHead>{t('columns.name')}</TableHead>
312
- <TableHead>{t('columns.slug')}</TableHead>
313
- <TableHead>{t('columns.category')}</TableHead>
314
- <TableHead>{t('columns.description')}</TableHead>
315
- <TableHead>{t('columns.sortOrder')}</TableHead>
316
- <TableHead>{t('columns.collaborators')}</TableHead>
317
- <TableHead>{commonT('labels.status')}</TableHead>
318
- <TableHead className="text-right">
319
- {commonT('labels.actions')}
320
- </TableHead>
321
- </TableRow>
322
- </TableHeader>
323
- <TableBody>
324
- {filteredTypes.map((collaboratorType) => (
325
- <TableRow key={collaboratorType.id}>
326
- <TableCell className="font-medium">
327
- {collaboratorType.name}
328
- </TableCell>
329
- <TableCell>{collaboratorType.slug}</TableCell>
330
- <TableCell>
331
- {collaboratorType.category || commonT('labels.notAvailable')}
332
- </TableCell>
333
- <TableCell className="max-w-md text-sm text-muted-foreground">
334
- {collaboratorType.description || commonT('labels.notAvailable')}
335
- </TableCell>
336
- <TableCell>{Number(collaboratorType.sortOrder ?? 0)}</TableCell>
337
- <TableCell>
338
- {Number(collaboratorType.collaboratorCount ?? 0)}
339
- </TableCell>
340
- <TableCell>
341
- <StatusBadge
342
- label={formatEnumLabel(collaboratorType.status)}
343
- className={getStatusBadgeClass(collaboratorType.status)}
344
- />
345
- </TableCell>
346
- <TableCell>
347
- <div className="flex justify-end gap-2">
348
- <Button
349
- variant="outline"
350
- size="icon"
351
- className="cursor-pointer"
352
- onClick={() => openEditSheet(collaboratorType)}
353
- >
354
- <Pencil className="size-4" />
355
- </Button>
356
- <Button
357
- variant="outline"
358
- size="sm"
359
- className="cursor-pointer"
360
- onClick={() => void toggleStatus(collaboratorType)}
361
- >
362
- {collaboratorType.status === 'inactive'
363
- ? commonT('actions.activate')
364
- : commonT('actions.deactivate')}
365
- </Button>
366
- </div>
367
- </TableCell>
368
- </TableRow>
369
- ))}
370
- </TableBody>
371
- </Table>
372
- </div>
373
- ) : (
374
- <EmptyState
375
- icon={<Briefcase className="size-12" />}
376
- title={commonT('states.emptyTitle')}
377
- description={t('emptyDescription')}
378
- actionLabel={commonT('actions.create')}
379
- onAction={openCreateSheet}
380
- />
381
- )}
382
-
383
- <Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
384
- <SheetContent className="w-full overflow-y-auto sm:max-w-xl">
385
- <SheetHeader>
386
- <SheetTitle>
387
- {editingType ? t('sheet.editTitle') : t('sheet.createTitle')}
388
- </SheetTitle>
389
- <SheetDescription>{t('sheet.description')}</SheetDescription>
390
- </SheetHeader>
391
-
392
- <form
393
- className="mt-6 space-y-4"
394
- onSubmit={(event) => {
395
- event.preventDefault();
396
- void saveCollaboratorType();
397
- }}
398
- >
399
- <div className="space-y-2">
400
- <label className="text-sm font-medium" htmlFor="type-name">
401
- {t('form.name')}
402
- </label>
403
- <input
404
- id="type-name"
405
- value={form.name}
406
- onChange={(event) => updateForm('name', event.target.value)}
407
- className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
408
- placeholder={t('form.name')}
409
- />
410
- </div>
411
-
412
- <div className="space-y-2">
413
- <label className="text-sm font-medium" htmlFor="type-slug">
414
- {t('form.slug')}
415
- </label>
416
- <input
417
- id="type-slug"
418
- value={form.slug}
419
- onChange={(event) => updateForm('slug', event.target.value)}
420
- className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
421
- placeholder={t('form.slug')}
422
- />
423
- </div>
424
-
425
- <div className="space-y-2">
426
- <label className="text-sm font-medium" htmlFor="type-category">
427
- {t('form.category')}
428
- </label>
429
- <input
430
- id="type-category"
431
- value={form.category}
432
- onChange={(event) => updateForm('category', event.target.value)}
433
- className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
434
- placeholder={t('form.category')}
435
- />
436
- </div>
437
-
438
- <div className="space-y-2">
439
- <label className="text-sm font-medium" htmlFor="type-description">
440
- {t('form.description')}
441
- </label>
442
- <textarea
443
- id="type-description"
444
- value={form.description}
445
- onChange={(event) => updateForm('description', event.target.value)}
446
- className="min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
447
- placeholder={t('form.description')}
448
- />
449
- </div>
450
-
451
- <div className="space-y-2">
452
- <label className="text-sm font-medium" htmlFor="type-sort-order">
453
- {t('form.sortOrder')}
454
- </label>
455
- <input
456
- id="type-sort-order"
457
- type="number"
458
- value={form.sortOrder}
459
- onChange={(event) => updateForm('sortOrder', event.target.value)}
460
- className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
461
- placeholder="0"
462
- />
463
- </div>
464
-
465
- <div className="space-y-2">
466
- <label className="text-sm font-medium" htmlFor="type-status">
467
- {t('form.status')}
468
- </label>
469
- <select
470
- id="type-status"
471
- value={form.status}
472
- onChange={(event) =>
473
- updateForm(
474
- 'status',
475
- event.target.value === 'inactive' ? 'inactive' : 'active'
476
- )
477
- }
478
- className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
479
- >
480
- <option value="active">{formatEnumLabel('active')}</option>
481
- <option value="inactive">{formatEnumLabel('inactive')}</option>
482
- </select>
483
- </div>
484
-
485
- <div className="flex justify-end gap-2 pt-2">
486
- <Button
487
- type="button"
488
- variant="outline"
489
- onClick={() => setIsSheetOpen(false)}
490
- >
491
- {commonT('actions.cancel')}
492
- </Button>
493
- <Button type="submit" disabled={isSaving}>
494
- {commonT('actions.save')}
495
- </Button>
496
- </div>
497
- </form>
498
- </SheetContent>
499
- </Sheet>
500
- </Page>
501
- );
502
- }
1
+ 'use client';
2
+
3
+ import { EmptyState, Page, SearchBar } from '@/components/entity-list';
4
+ import { Button } from '@/components/ui/button';
5
+ import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
6
+ import {
7
+ Sheet,
8
+ SheetContent,
9
+ SheetDescription,
10
+ SheetHeader,
11
+ SheetTitle,
12
+ } from '@/components/ui/sheet';
13
+ import {
14
+ Table,
15
+ TableBody,
16
+ TableCell,
17
+ TableHead,
18
+ TableHeader,
19
+ TableRow,
20
+ } from '@/components/ui/table';
21
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
22
+ import { Briefcase, Pencil, Users } from 'lucide-react';
23
+ import { useTranslations } from 'next-intl';
24
+ import Link from 'next/link';
25
+ import { useMemo, useState } from 'react';
26
+ import { OperationsHeader } from '../_components/operations-header';
27
+ import { StatusBadge } from '../_components/status-badge';
28
+ import { fetchOperations, mutateOperations } from '../_lib/api';
29
+ import { useOperationsAccess } from '../_lib/hooks/use-operations-access';
30
+ import type { OperationsCollaboratorType } from '../_lib/types';
31
+ import { formatEnumLabel, getStatusBadgeClass } from '../_lib/utils/format';
32
+
33
+ type CollaboratorTypeFormState = {
34
+ name: string;
35
+ slug: string;
36
+ description: string;
37
+ category: string;
38
+ sortOrder: string;
39
+ status: 'active' | 'inactive';
40
+ };
41
+
42
+ const EMPTY_FORM_STATE: CollaboratorTypeFormState = {
43
+ name: '',
44
+ slug: '',
45
+ description: '',
46
+ category: '',
47
+ sortOrder: '0',
48
+ status: 'active',
49
+ };
50
+
51
+ export default function OperationsCollaboratorTypesPage() {
52
+ const t = useTranslations('operations.CollaboratorTypesPage');
53
+ const commonT = useTranslations('operations.Common');
54
+ const { request, showToastHandler, currentLocaleCode } = useApp();
55
+ const access = useOperationsAccess();
56
+ const [search, setSearch] = useState('');
57
+ const [statusFilter, setStatusFilter] = useState<
58
+ 'all' | 'active' | 'inactive'
59
+ >('all');
60
+ const [isSheetOpen, setIsSheetOpen] = useState(false);
61
+ const [isSaving, setIsSaving] = useState(false);
62
+ const [editingType, setEditingType] =
63
+ useState<OperationsCollaboratorType | null>(null);
64
+ const [form, setForm] = useState<CollaboratorTypeFormState>(EMPTY_FORM_STATE);
65
+
66
+ const {
67
+ data: collaboratorTypes = [],
68
+ isLoading,
69
+ refetch,
70
+ } = useQuery<OperationsCollaboratorType[]>({
71
+ queryKey: ['operations-collaborator-types-page', currentLocaleCode],
72
+ enabled: access.isDirector,
73
+ queryFn: () =>
74
+ fetchOperations<OperationsCollaboratorType[]>(
75
+ request,
76
+ '/operations/collaborator-types'
77
+ ),
78
+ });
79
+
80
+ const filteredTypes = useMemo(
81
+ () =>
82
+ collaboratorTypes.filter((collaboratorType) => {
83
+ const matchesSearch = !search.trim()
84
+ ? true
85
+ : [
86
+ collaboratorType.name,
87
+ collaboratorType.slug,
88
+ collaboratorType.category,
89
+ collaboratorType.description,
90
+ ]
91
+ .filter(Boolean)
92
+ .some((value) =>
93
+ String(value)
94
+ .toLowerCase()
95
+ .includes(search.trim().toLowerCase())
96
+ );
97
+
98
+ const matchesStatus =
99
+ statusFilter === 'all'
100
+ ? true
101
+ : collaboratorType.status === statusFilter;
102
+
103
+ return matchesSearch && matchesStatus;
104
+ }),
105
+ [collaboratorTypes, search, statusFilter]
106
+ );
107
+
108
+ const statsCards = useMemo(
109
+ () => [
110
+ {
111
+ key: 'total',
112
+ title: t('cards.total'),
113
+ value: collaboratorTypes.length,
114
+ icon: Briefcase,
115
+ },
116
+ {
117
+ key: 'active',
118
+ title: t('cards.active'),
119
+ value: collaboratorTypes.filter((item) => item.status === 'active')
120
+ .length,
121
+ icon: Briefcase,
122
+ },
123
+ {
124
+ key: 'linkedCollaborators',
125
+ title: t('cards.linkedCollaborators'),
126
+ value: collaboratorTypes.reduce(
127
+ (total, item) => total + Number(item.collaboratorCount ?? 0),
128
+ 0
129
+ ),
130
+ icon: Users,
131
+ },
132
+ {
133
+ key: 'inactive',
134
+ title: t('cards.inactive'),
135
+ value: collaboratorTypes.filter((item) => item.status === 'inactive')
136
+ .length,
137
+ icon: Briefcase,
138
+ },
139
+ ],
140
+ [collaboratorTypes, t]
141
+ );
142
+
143
+ const updateForm = <K extends keyof CollaboratorTypeFormState>(
144
+ field: K,
145
+ value: CollaboratorTypeFormState[K]
146
+ ) => {
147
+ setForm((current) => ({
148
+ ...current,
149
+ [field]: value,
150
+ }));
151
+ };
152
+
153
+ const openCreateSheet = () => {
154
+ setEditingType(null);
155
+ setForm(EMPTY_FORM_STATE);
156
+ setIsSheetOpen(true);
157
+ };
158
+
159
+ const openEditSheet = (collaboratorType: OperationsCollaboratorType) => {
160
+ setEditingType(collaboratorType);
161
+ setForm({
162
+ name: collaboratorType.name ?? '',
163
+ slug: collaboratorType.slug ?? '',
164
+ description: collaboratorType.description ?? '',
165
+ category: collaboratorType.category ?? '',
166
+ sortOrder: String(collaboratorType.sortOrder ?? 0),
167
+ status: collaboratorType.status ?? 'active',
168
+ });
169
+ setIsSheetOpen(true);
170
+ };
171
+
172
+ const saveCollaboratorType = async () => {
173
+ if (!form.name.trim()) {
174
+ showToastHandler?.('error', t('messages.requiredFields'));
175
+ return;
176
+ }
177
+
178
+ setIsSaving(true);
179
+
180
+ try {
181
+ await mutateOperations(
182
+ request,
183
+ editingType
184
+ ? `/operations/collaborator-types/${editingType.id}`
185
+ : '/operations/collaborator-types',
186
+ editingType ? 'PATCH' : 'POST',
187
+ {
188
+ name: form.name.trim(),
189
+ slug: form.slug.trim() || null,
190
+ description: form.description.trim() || null,
191
+ category: form.category.trim() || null,
192
+ sortOrder: Number(form.sortOrder || '0'),
193
+ status: form.status,
194
+ isActive: form.status === 'active',
195
+ }
196
+ );
197
+
198
+ showToastHandler?.('success', t('messages.saveSuccess'));
199
+ setIsSheetOpen(false);
200
+ setEditingType(null);
201
+ setForm(EMPTY_FORM_STATE);
202
+ await refetch();
203
+ } catch {
204
+ showToastHandler?.('error', t('messages.saveError'));
205
+ } finally {
206
+ setIsSaving(false);
207
+ }
208
+ };
209
+
210
+ const toggleStatus = async (collaboratorType: OperationsCollaboratorType) => {
211
+ const nextStatus =
212
+ collaboratorType.status === 'inactive' ? 'active' : 'inactive';
213
+
214
+ try {
215
+ await mutateOperations(
216
+ request,
217
+ `/operations/collaborator-types/${collaboratorType.id}`,
218
+ 'PATCH',
219
+ { status: nextStatus, isActive: nextStatus === 'active' }
220
+ );
221
+ showToastHandler?.('success', t('messages.statusSuccess'));
222
+ await refetch();
223
+ } catch {
224
+ showToastHandler?.('error', t('messages.statusError'));
225
+ }
226
+ };
227
+
228
+ if (!access.isLoading && !access.isDirector) {
229
+ return (
230
+ <Page>
231
+ <OperationsHeader
232
+ title={t('title')}
233
+ description={t('description')}
234
+ current={t('breadcrumb')}
235
+ actions={
236
+ <Button variant="outline" size="sm" asChild>
237
+ <Link href="/operations/collaborators">
238
+ {commonT('actions.back')}
239
+ </Link>
240
+ </Button>
241
+ }
242
+ />
243
+
244
+ <EmptyState
245
+ icon={<Briefcase className="size-12" />}
246
+ title={commonT('states.noAccessTitle')}
247
+ description={t('noAccessDescription')}
248
+ actionLabel={commonT('actions.back')}
249
+ onAction={() => {
250
+ window.location.href = '/operations';
251
+ }}
252
+ />
253
+ </Page>
254
+ );
255
+ }
256
+
257
+ return (
258
+ <Page>
259
+ <OperationsHeader
260
+ title={t('title')}
261
+ description={t('description')}
262
+ current={t('breadcrumb')}
263
+ actions={
264
+ <div className="flex flex-wrap gap-2">
265
+ <Button variant="outline" size="sm" asChild>
266
+ <Link href="/operations/collaborators">
267
+ {commonT('actions.back')}
268
+ </Link>
269
+ </Button>
270
+ <Button size="sm" onClick={openCreateSheet}>
271
+ {commonT('actions.create')}
272
+ </Button>
273
+ </div>
274
+ }
275
+ />
276
+
277
+ <KpiCardsGrid items={statsCards} columns={4} />
278
+
279
+ <div className="flex flex-col gap-4">
280
+ <SearchBar
281
+ searchQuery={search}
282
+ onSearchChange={setSearch}
283
+ onSearch={() => undefined}
284
+ placeholder={t('searchPlaceholder')}
285
+ controls={[
286
+ {
287
+ id: 'status',
288
+ type: 'select',
289
+ value: statusFilter,
290
+ onChange: (value) =>
291
+ setStatusFilter(
292
+ (value as 'all' | 'active' | 'inactive') ?? 'all'
293
+ ),
294
+ placeholder: commonT('labels.status'),
295
+ options: [
296
+ { value: 'all', label: commonT('filters.allStatuses') },
297
+ { value: 'active', label: formatEnumLabel('active') },
298
+ { value: 'inactive', label: formatEnumLabel('inactive') },
299
+ ],
300
+ },
301
+ ]}
302
+ />
303
+ </div>
304
+
305
+ {isLoading ? (
306
+ <div className="rounded-md border px-4 py-6 text-sm text-muted-foreground">
307
+ {commonT('actions.refresh')}...
308
+ </div>
309
+ ) : filteredTypes.length > 0 ? (
310
+ <div className="overflow-x-auto rounded-md border">
311
+ <Table>
312
+ <TableHeader>
313
+ <TableRow>
314
+ <TableHead>{t('columns.name')}</TableHead>
315
+ <TableHead>{t('columns.slug')}</TableHead>
316
+ <TableHead>{t('columns.category')}</TableHead>
317
+ <TableHead>{t('columns.description')}</TableHead>
318
+ <TableHead>{t('columns.sortOrder')}</TableHead>
319
+ <TableHead>{t('columns.collaborators')}</TableHead>
320
+ <TableHead>{commonT('labels.status')}</TableHead>
321
+ <TableHead className="text-right">
322
+ {commonT('labels.actions')}
323
+ </TableHead>
324
+ </TableRow>
325
+ </TableHeader>
326
+ <TableBody>
327
+ {filteredTypes.map((collaboratorType) => (
328
+ <TableRow key={collaboratorType.id}>
329
+ <TableCell className="font-medium">
330
+ {collaboratorType.name}
331
+ </TableCell>
332
+ <TableCell>{collaboratorType.slug}</TableCell>
333
+ <TableCell>
334
+ {collaboratorType.category ||
335
+ commonT('labels.notAvailable')}
336
+ </TableCell>
337
+ <TableCell className="max-w-md text-sm text-muted-foreground">
338
+ {collaboratorType.description ||
339
+ commonT('labels.notAvailable')}
340
+ </TableCell>
341
+ <TableCell>
342
+ {Number(collaboratorType.sortOrder ?? 0)}
343
+ </TableCell>
344
+ <TableCell>
345
+ {Number(collaboratorType.collaboratorCount ?? 0)}
346
+ </TableCell>
347
+ <TableCell>
348
+ <StatusBadge
349
+ label={formatEnumLabel(collaboratorType.status)}
350
+ className={getStatusBadgeClass(collaboratorType.status)}
351
+ />
352
+ </TableCell>
353
+ <TableCell>
354
+ <div className="flex justify-end gap-2">
355
+ <Button
356
+ variant="outline"
357
+ size="icon"
358
+ className="cursor-pointer"
359
+ onClick={() => openEditSheet(collaboratorType)}
360
+ >
361
+ <Pencil className="size-4" />
362
+ </Button>
363
+ <Button
364
+ variant="outline"
365
+ size="sm"
366
+ className="cursor-pointer"
367
+ onClick={() => void toggleStatus(collaboratorType)}
368
+ >
369
+ {collaboratorType.status === 'inactive'
370
+ ? commonT('actions.activate')
371
+ : commonT('actions.deactivate')}
372
+ </Button>
373
+ </div>
374
+ </TableCell>
375
+ </TableRow>
376
+ ))}
377
+ </TableBody>
378
+ </Table>
379
+ </div>
380
+ ) : (
381
+ <EmptyState
382
+ icon={<Briefcase className="size-12" />}
383
+ title={commonT('states.emptyTitle')}
384
+ description={t('emptyDescription')}
385
+ actionLabel={commonT('actions.create')}
386
+ onAction={openCreateSheet}
387
+ />
388
+ )}
389
+
390
+ <Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
391
+ <SheetContent className="w-full overflow-y-auto sm:max-w-xl">
392
+ <SheetHeader>
393
+ <SheetTitle>
394
+ {editingType ? t('sheet.editTitle') : t('sheet.createTitle')}
395
+ </SheetTitle>
396
+ <SheetDescription>{t('sheet.description')}</SheetDescription>
397
+ </SheetHeader>
398
+
399
+ <form
400
+ className="mt-6 px-6 space-y-4"
401
+ onSubmit={(event) => {
402
+ event.preventDefault();
403
+ void saveCollaboratorType();
404
+ }}
405
+ >
406
+ <div className="space-y-2">
407
+ <label className="text-sm font-medium" htmlFor="type-name">
408
+ {t('form.name')}
409
+ </label>
410
+ <input
411
+ id="type-name"
412
+ value={form.name}
413
+ onChange={(event) => updateForm('name', event.target.value)}
414
+ className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
415
+ placeholder={t('form.name')}
416
+ />
417
+ </div>
418
+
419
+ <div className="space-y-2">
420
+ <label className="text-sm font-medium" htmlFor="type-slug">
421
+ {t('form.slug')}
422
+ </label>
423
+ <input
424
+ id="type-slug"
425
+ value={form.slug}
426
+ onChange={(event) => updateForm('slug', event.target.value)}
427
+ className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
428
+ placeholder={t('form.slug')}
429
+ />
430
+ </div>
431
+
432
+ <div className="space-y-2">
433
+ <label className="text-sm font-medium" htmlFor="type-category">
434
+ {t('form.category')}
435
+ </label>
436
+ <input
437
+ id="type-category"
438
+ value={form.category}
439
+ onChange={(event) => updateForm('category', event.target.value)}
440
+ className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
441
+ placeholder={t('form.category')}
442
+ />
443
+ </div>
444
+
445
+ <div className="space-y-2">
446
+ <label className="text-sm font-medium" htmlFor="type-description">
447
+ {t('form.description')}
448
+ </label>
449
+ <textarea
450
+ id="type-description"
451
+ value={form.description}
452
+ onChange={(event) =>
453
+ updateForm('description', event.target.value)
454
+ }
455
+ className="min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
456
+ placeholder={t('form.description')}
457
+ />
458
+ </div>
459
+
460
+ <div className="space-y-2">
461
+ <label className="text-sm font-medium" htmlFor="type-sort-order">
462
+ {t('form.sortOrder')}
463
+ </label>
464
+ <input
465
+ id="type-sort-order"
466
+ type="number"
467
+ value={form.sortOrder}
468
+ onChange={(event) =>
469
+ updateForm('sortOrder', event.target.value)
470
+ }
471
+ className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
472
+ placeholder="0"
473
+ />
474
+ </div>
475
+
476
+ <div className="space-y-2">
477
+ <label className="text-sm font-medium" htmlFor="type-status">
478
+ {t('form.status')}
479
+ </label>
480
+ <select
481
+ id="type-status"
482
+ value={form.status}
483
+ onChange={(event) =>
484
+ updateForm(
485
+ 'status',
486
+ event.target.value === 'inactive' ? 'inactive' : 'active'
487
+ )
488
+ }
489
+ className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
490
+ >
491
+ <option value="active">{formatEnumLabel('active')}</option>
492
+ <option value="inactive">{formatEnumLabel('inactive')}</option>
493
+ </select>
494
+ </div>
495
+
496
+ <div className="flex justify-end gap-2 pt-2">
497
+ <Button
498
+ type="button"
499
+ variant="outline"
500
+ onClick={() => setIsSheetOpen(false)}
501
+ >
502
+ {commonT('actions.cancel')}
503
+ </Button>
504
+ <Button type="submit" disabled={isSaving}>
505
+ {commonT('actions.save')}
506
+ </Button>
507
+ </div>
508
+ </form>
509
+ </SheetContent>
510
+ </Sheet>
511
+ </Page>
512
+ );
513
+ }