@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.
Files changed (52) 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/dto/create-task.dto.d.ts +7 -1
  8. package/dist/dto/create-task.dto.d.ts.map +1 -1
  9. package/dist/dto/create-task.dto.js +38 -5
  10. package/dist/dto/create-task.dto.js.map +1 -1
  11. package/dist/dto/list-tasks.dto.d.ts +1 -1
  12. package/dist/dto/list-tasks.dto.d.ts.map +1 -1
  13. package/dist/dto/list-tasks.dto.js +2 -2
  14. package/dist/dto/list-tasks.dto.js.map +1 -1
  15. package/dist/dto/update-task.dto.d.ts +7 -1
  16. package/dist/dto/update-task.dto.d.ts.map +1 -1
  17. package/dist/dto/update-task.dto.js +38 -5
  18. package/dist/dto/update-task.dto.js.map +1 -1
  19. package/dist/operations.service.d.ts +68 -12
  20. package/dist/operations.service.d.ts.map +1 -1
  21. package/dist/operations.service.js +380 -101
  22. package/dist/operations.service.js.map +1 -1
  23. package/hedhog/data/route.yaml +13 -0
  24. package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +44 -44
  25. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +168 -213
  26. package/hedhog/frontend/app/_components/collaborator-select-with-create.tsx.ejs +256 -256
  27. package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +7 -7
  28. package/hedhog/frontend/app/_components/contract-template-form-screen.tsx.ejs +306 -306
  29. package/hedhog/frontend/app/_components/contract-template-select-with-create.tsx.ejs +247 -247
  30. package/hedhog/frontend/app/_components/contract-wizard-sheet.tsx.ejs +3520 -3520
  31. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +1504 -52
  32. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +528 -403
  33. package/hedhog/frontend/app/_components/section-card.tsx.ejs +25 -18
  34. package/hedhog/frontend/app/_components/system-user-select-with-create.tsx.ejs +609 -0
  35. package/hedhog/frontend/app/_lib/types.ts.ejs +5 -0
  36. package/hedhog/frontend/app/_lib/utils/format.ts.ejs +7 -7
  37. package/hedhog/frontend/app/_lib/utils/forms.ts.ejs +48 -1
  38. package/hedhog/frontend/app/collaborator-types/page.tsx.ejs +502 -502
  39. package/hedhog/frontend/app/collaborators/page.tsx.ejs +10 -7
  40. package/hedhog/frontend/app/contracts/page.tsx.ejs +938 -938
  41. package/hedhog/frontend/app/projects/[id]/edit/page.tsx.ejs +1 -1
  42. package/hedhog/frontend/app/projects/page.tsx.ejs +360 -133
  43. package/hedhog/frontend/messages/en.json +27 -4
  44. package/hedhog/frontend/messages/pt.json +27 -4
  45. package/hedhog/table/operations_project.yaml +9 -0
  46. package/hedhog/table/operations_task.yaml +43 -4
  47. package/package.json +5 -5
  48. package/src/controllers/operations-tasks.controller.ts +11 -0
  49. package/src/dto/create-task.dto.ts +47 -7
  50. package/src/dto/list-tasks.dto.ts +3 -3
  51. package/src/dto/update-task.dto.ts +47 -7
  52. package/src/operations.service.ts +556 -88
@@ -1,502 +1,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<'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<'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
+ }