@hed-hog/operations 0.0.300 → 0.0.302

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