@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,526 @@
1
+ 'use client';
2
+
3
+ import { EmptyState, Page } from '@/components/entity-list';
4
+ import { Button } from '@/components/ui/button';
5
+ import { FormActions } from '@/components/ui/form-actions';
6
+ import { Input } from '@/components/ui/input';
7
+ import { Label } from '@/components/ui/label';
8
+ import {
9
+ Select,
10
+ SelectContent,
11
+ SelectItem,
12
+ SelectTrigger,
13
+ SelectValue,
14
+ } from '@/components/ui/select';
15
+ import { Switch } from '@/components/ui/switch';
16
+ import { Textarea } from '@/components/ui/textarea';
17
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
18
+ import { ArrowLeft, Files, Save } from 'lucide-react';
19
+ import { useTranslations } from 'next-intl';
20
+ import Link from 'next/link';
21
+ import { useRouter } from 'next/navigation';
22
+ import { useEffect, useState } from 'react';
23
+ import { fetchOperations, mutateOperations } from '../_lib/api';
24
+ import { useOperationsAccess } from '../_lib/hooks/use-operations-access';
25
+ import type { OperationsContractTemplate } from '../_lib/types';
26
+ import { formatEnumLabel } from '../_lib/utils/format';
27
+ import { trimToNull } from '../_lib/utils/forms';
28
+ import { ContractContentEditor } from './contract-content-editor';
29
+ import { OperationsHeader } from './operations-header';
30
+
31
+ type ContractTemplateFormState = {
32
+ code: string;
33
+ name: string;
34
+ description: string;
35
+ contractCategory: string;
36
+ contractType: string;
37
+ billingModel: string;
38
+ signatureStatus: string;
39
+ isActive: boolean;
40
+ status: string;
41
+ contentHtml: string;
42
+ };
43
+
44
+ function buildEmptyForm(): ContractTemplateFormState {
45
+ return {
46
+ code: '',
47
+ name: '',
48
+ description: '',
49
+ contractCategory: 'client',
50
+ contractType: 'service_agreement',
51
+ billingModel: 'time_and_material',
52
+ signatureStatus: 'not_started',
53
+ isActive: true,
54
+ status: 'active',
55
+ contentHtml: '',
56
+ };
57
+ }
58
+
59
+ function toFormState(
60
+ template: OperationsContractTemplate
61
+ ): ContractTemplateFormState {
62
+ return {
63
+ code: template.code ?? '',
64
+ name: template.name ?? '',
65
+ description: template.description ?? '',
66
+ contractCategory: template.contractCategory ?? 'client',
67
+ contractType: template.contractType ?? 'service_agreement',
68
+ billingModel: template.billingModel ?? 'time_and_material',
69
+ signatureStatus: template.signatureStatus ?? 'not_started',
70
+ isActive: template.isActive ?? true,
71
+ status: template.status ?? 'active',
72
+ contentHtml: template.contentHtml ?? '',
73
+ };
74
+ }
75
+
76
+ type ContractTemplateFormScreenProps = {
77
+ templateId?: number;
78
+ onSaved?: (template: OperationsContractTemplate) => void | Promise<void>;
79
+ onCancel?: () => void;
80
+ };
81
+
82
+ export function ContractTemplateFormScreen({
83
+ templateId,
84
+ onSaved,
85
+ onCancel,
86
+ }: ContractTemplateFormScreenProps) {
87
+ const t = useTranslations('operations.ContractTemplateFormPage');
88
+ const commonT = useTranslations('operations.Common');
89
+ const { request, showToastHandler, currentLocaleCode } = useApp();
90
+ const access = useOperationsAccess();
91
+ const router = useRouter();
92
+ const [form, setForm] = useState<ContractTemplateFormState>(buildEmptyForm());
93
+ const isSheetMode = Boolean(onCancel);
94
+ const [sheetStep, setSheetStep] = useState(0);
95
+ const nextLabel = currentLocaleCode?.toLowerCase().startsWith('pt')
96
+ ? 'Proximo'
97
+ : 'Next';
98
+
99
+ const { data: template, isLoading } = useQuery<OperationsContractTemplate>({
100
+ queryKey: [
101
+ 'operations-contract-template-form',
102
+ currentLocaleCode,
103
+ templateId,
104
+ ],
105
+ enabled: Boolean(templateId) && access.isDirector,
106
+ queryFn: () =>
107
+ fetchOperations<OperationsContractTemplate>(
108
+ request,
109
+ `/operations/contract-templates/${templateId}`
110
+ ),
111
+ });
112
+
113
+ useEffect(() => {
114
+ if (template) {
115
+ // eslint-disable-next-line react-hooks/set-state-in-effect
116
+ setForm(toFormState(template));
117
+ return;
118
+ }
119
+
120
+ if (!templateId) {
121
+ setForm(buildEmptyForm());
122
+ }
123
+ }, [template, templateId]);
124
+
125
+ useEffect(() => {
126
+ if (!isSheetMode) return;
127
+ setSheetStep(0);
128
+ }, [isSheetMode, templateId]);
129
+
130
+ const validateOverviewStep = () => {
131
+ if (!form.name.trim()) {
132
+ showToastHandler?.('error', t('messages.requiredFields'));
133
+ return false;
134
+ }
135
+
136
+ return true;
137
+ };
138
+
139
+ const onSubmit = async () => {
140
+ if (!form.name.trim()) {
141
+ showToastHandler?.('error', t('messages.requiredFields'));
142
+ return;
143
+ }
144
+
145
+ const normalizedStatus = form.isActive
146
+ ? form.status === 'inactive' || form.status === 'archived'
147
+ ? 'active'
148
+ : form.status
149
+ : form.status === 'archived'
150
+ ? 'archived'
151
+ : 'inactive';
152
+
153
+ const payload = {
154
+ code: trimToNull(form.code)?.toUpperCase() ?? null,
155
+ name: form.name.trim(),
156
+ description: trimToNull(form.description),
157
+ contractCategory: form.contractCategory,
158
+ contractType: form.contractType,
159
+ billingModel: form.billingModel,
160
+ signatureStatus: form.signatureStatus,
161
+ isActive: form.isActive,
162
+ status: normalizedStatus,
163
+ contentHtml: trimToNull(form.contentHtml),
164
+ };
165
+
166
+ try {
167
+ const response = templateId
168
+ ? await mutateOperations<OperationsContractTemplate>(
169
+ request,
170
+ `/operations/contract-templates/${templateId}`,
171
+ 'PATCH',
172
+ payload
173
+ )
174
+ : await mutateOperations<OperationsContractTemplate>(
175
+ request,
176
+ '/operations/contract-templates',
177
+ 'POST',
178
+ payload
179
+ );
180
+
181
+ showToastHandler?.(
182
+ 'success',
183
+ templateId ? t('messages.updateSuccess') : t('messages.createSuccess')
184
+ );
185
+
186
+ if (onSaved) {
187
+ await onSaved(response);
188
+ return;
189
+ }
190
+
191
+ router.push('/operations/contracts/templates');
192
+ } catch {
193
+ showToastHandler?.(
194
+ 'error',
195
+ templateId ? t('messages.updateError') : t('messages.createError')
196
+ );
197
+ }
198
+ };
199
+
200
+ const noAccessState = (
201
+ <EmptyState
202
+ icon={<Files className="size-12" />}
203
+ title={commonT('states.noAccessTitle')}
204
+ description={t('noAccessDescription')}
205
+ actionLabel={commonT('actions.refresh')}
206
+ onAction={() => router.refresh()}
207
+ />
208
+ );
209
+
210
+ if (!access.isDirector && !access.isLoading) {
211
+ if (isSheetMode) {
212
+ return <div className="pt-4">{noAccessState}</div>;
213
+ }
214
+
215
+ return (
216
+ <Page>
217
+ <OperationsHeader
218
+ title={t(templateId ? 'editTitle' : 'newTitle')}
219
+ description={t('description')}
220
+ current={t('breadcrumb')}
221
+ />
222
+ {noAccessState}
223
+ </Page>
224
+ );
225
+ }
226
+
227
+ const overviewSection = (
228
+ <section className="space-y-6">
229
+ <div className="space-y-1">
230
+ <h2 className="text-xl font-semibold">{t('sections.overview')}</h2>
231
+ <p className="text-sm text-muted-foreground">
232
+ {t('sections.overviewDescription')}
233
+ </p>
234
+ </div>
235
+
236
+ <div className="grid gap-4 md:grid-cols-12">
237
+ <div className="space-y-2 md:col-span-3">
238
+ <Label htmlFor="contract-template-code">{t('fields.code')}</Label>
239
+ <Input
240
+ id="contract-template-code"
241
+ value={form.code}
242
+ placeholder={t('placeholders.code')}
243
+ onChange={(event) =>
244
+ setForm((current) => ({ ...current, code: event.target.value }))
245
+ }
246
+ />
247
+ </div>
248
+ <div className="space-y-2 md:col-span-6">
249
+ <Label htmlFor="contract-template-name">{t('fields.name')}</Label>
250
+ <Input
251
+ id="contract-template-name"
252
+ value={form.name}
253
+ placeholder={t('placeholders.name')}
254
+ onChange={(event) =>
255
+ setForm((current) => ({ ...current, name: event.target.value }))
256
+ }
257
+ />
258
+ </div>
259
+ <div className="space-y-2 md:col-span-3">
260
+ <Label>{commonT('labels.status')}</Label>
261
+ <Select
262
+ value={form.status}
263
+ onValueChange={(value) =>
264
+ setForm((current) => ({ ...current, status: value }))
265
+ }
266
+ >
267
+ <SelectTrigger className="w-full">
268
+ <SelectValue />
269
+ </SelectTrigger>
270
+ <SelectContent>
271
+ {['active', 'draft', 'inactive', 'archived'].map((value) => (
272
+ <SelectItem key={value} value={value}>
273
+ {formatEnumLabel(value)}
274
+ </SelectItem>
275
+ ))}
276
+ </SelectContent>
277
+ </Select>
278
+ </div>
279
+
280
+ <div className="space-y-2 md:col-span-3">
281
+ <Label>{t('fields.contractCategory')}</Label>
282
+ <Select
283
+ value={form.contractCategory}
284
+ onValueChange={(value) =>
285
+ setForm((current) => ({ ...current, contractCategory: value }))
286
+ }
287
+ >
288
+ <SelectTrigger className="w-full">
289
+ <SelectValue />
290
+ </SelectTrigger>
291
+ <SelectContent>
292
+ {[
293
+ 'employee',
294
+ 'contractor',
295
+ 'client',
296
+ 'supplier',
297
+ 'vendor',
298
+ 'partner',
299
+ 'internal',
300
+ 'other',
301
+ ].map((value) => (
302
+ <SelectItem key={value} value={value}>
303
+ {formatEnumLabel(value)}
304
+ </SelectItem>
305
+ ))}
306
+ </SelectContent>
307
+ </Select>
308
+ </div>
309
+ <div className="space-y-2 md:col-span-3">
310
+ <Label>{t('fields.contractType')}</Label>
311
+ <Select
312
+ value={form.contractType}
313
+ onValueChange={(value) =>
314
+ setForm((current) => ({ ...current, contractType: value }))
315
+ }
316
+ >
317
+ <SelectTrigger className="w-full">
318
+ <SelectValue />
319
+ </SelectTrigger>
320
+ <SelectContent>
321
+ {[
322
+ 'clt',
323
+ 'pj',
324
+ 'freelancer_agreement',
325
+ 'service_agreement',
326
+ 'fixed_term',
327
+ 'recurring_service',
328
+ 'nda',
329
+ 'amendment',
330
+ 'addendum',
331
+ 'other',
332
+ ].map((value) => (
333
+ <SelectItem key={value} value={value}>
334
+ {formatEnumLabel(value)}
335
+ </SelectItem>
336
+ ))}
337
+ </SelectContent>
338
+ </Select>
339
+ </div>
340
+ <div className="space-y-2 md:col-span-3">
341
+ <Label>{commonT('labels.billingModel')}</Label>
342
+ <Select
343
+ value={form.billingModel}
344
+ onValueChange={(value) =>
345
+ setForm((current) => ({ ...current, billingModel: value }))
346
+ }
347
+ >
348
+ <SelectTrigger className="w-full">
349
+ <SelectValue />
350
+ </SelectTrigger>
351
+ <SelectContent>
352
+ {['time_and_material', 'monthly_retainer', 'fixed_price'].map(
353
+ (value) => (
354
+ <SelectItem key={value} value={value}>
355
+ {formatEnumLabel(value)}
356
+ </SelectItem>
357
+ )
358
+ )}
359
+ </SelectContent>
360
+ </Select>
361
+ </div>
362
+ <div className="space-y-2 md:col-span-3">
363
+ <Label>{t('fields.signatureStatus')}</Label>
364
+ <Select
365
+ value={form.signatureStatus}
366
+ onValueChange={(value) =>
367
+ setForm((current) => ({ ...current, signatureStatus: value }))
368
+ }
369
+ >
370
+ <SelectTrigger className="w-full">
371
+ <SelectValue />
372
+ </SelectTrigger>
373
+ <SelectContent>
374
+ {[
375
+ 'not_started',
376
+ 'pending',
377
+ 'partially_signed',
378
+ 'signed',
379
+ 'expired',
380
+ ].map((value) => (
381
+ <SelectItem key={value} value={value}>
382
+ {formatEnumLabel(value)}
383
+ </SelectItem>
384
+ ))}
385
+ </SelectContent>
386
+ </Select>
387
+ </div>
388
+
389
+ <div className="space-y-2 md:col-span-12">
390
+ <Label htmlFor="contract-template-description">
391
+ {t('fields.description')}
392
+ </Label>
393
+ <Textarea
394
+ id="contract-template-description"
395
+ rows={3}
396
+ value={form.description}
397
+ placeholder={t('placeholders.description')}
398
+ onChange={(event) =>
399
+ setForm((current) => ({
400
+ ...current,
401
+ description: event.target.value,
402
+ }))
403
+ }
404
+ />
405
+ </div>
406
+
407
+ <div className="flex items-center justify-between rounded-lg border px-4 py-4 md:col-span-12">
408
+ <div className="space-y-1">
409
+ <div className="text-sm font-medium">{t('fields.isActive')}</div>
410
+ <div className="text-xs text-muted-foreground">
411
+ {t('fields.isActiveDescription')}
412
+ </div>
413
+ </div>
414
+ <Switch
415
+ checked={form.isActive}
416
+ onCheckedChange={(checked) =>
417
+ setForm((current) => ({ ...current, isActive: checked }))
418
+ }
419
+ />
420
+ </div>
421
+ </div>
422
+ </section>
423
+ );
424
+
425
+ const contentSection = (
426
+ <section className="space-y-6">
427
+ <ContractContentEditor
428
+ value={form.contentHtml}
429
+ onChange={(value) =>
430
+ setForm((current) => ({ ...current, contentHtml: value }))
431
+ }
432
+ editorTitle={t('sections.editor')}
433
+ editorDescription={t('sections.editorDescription')}
434
+ previewTitle={t('sections.preview')}
435
+ previewDescription={t('sections.previewDescription')}
436
+ promptContext={{
437
+ name: form.name,
438
+ code: form.code,
439
+ description: form.description,
440
+ contract_type: form.contractType,
441
+ billing_model: form.billingModel,
442
+ }}
443
+ showPreview={false}
444
+ chrome="plain"
445
+ />
446
+ </section>
447
+ );
448
+
449
+ const formBody = isSheetMode ? (
450
+ <div className="space-y-6">
451
+ {sheetStep === 0 ? overviewSection : contentSection}
452
+ {templateId && isLoading ? (
453
+ <div className="text-sm text-muted-foreground">{t('loading')}</div>
454
+ ) : null}
455
+ </div>
456
+ ) : (
457
+ <div className="space-y-8">
458
+ {overviewSection}
459
+ {contentSection}
460
+ {templateId && isLoading ? (
461
+ <div className="text-sm text-muted-foreground">{t('loading')}</div>
462
+ ) : null}
463
+ </div>
464
+ );
465
+
466
+ if (isSheetMode) {
467
+ return (
468
+ <div className="mt-4 space-y-6 pb-6">
469
+ {formBody}
470
+
471
+ <FormActions
472
+ sheet
473
+ cancelLabel={
474
+ sheetStep === 0 ? commonT('actions.cancel') : commonT('actions.back')
475
+ }
476
+ onCancel={
477
+ sheetStep === 0 ? onCancel : () => setSheetStep((current) => current - 1)
478
+ }
479
+ onSubmit={() => {
480
+ if (sheetStep === 0) {
481
+ if (!validateOverviewStep()) return;
482
+ setSheetStep(1);
483
+ return;
484
+ }
485
+
486
+ void onSubmit();
487
+ }}
488
+ submitIcon={sheetStep === 0 ? undefined : <Save className="size-4" />}
489
+ submitLabel={sheetStep === 0 ? nextLabel : commonT('actions.save')}
490
+ submitSize="lg"
491
+ />
492
+ </div>
493
+ );
494
+ }
495
+
496
+ return (
497
+ <Page>
498
+ <OperationsHeader
499
+ title={t(templateId ? 'editTitle' : 'newTitle')}
500
+ description={t('description')}
501
+ current={t('breadcrumb')}
502
+ actions={
503
+ <Button variant="outline" size="sm" asChild>
504
+ <Link href="/operations/contracts/templates">
505
+ <ArrowLeft className="size-4" />
506
+ {commonT('actions.back')}
507
+ </Link>
508
+ </Button>
509
+ }
510
+ />
511
+
512
+ {formBody}
513
+
514
+ <div className="pt-2">
515
+ <FormActions
516
+ cancelLabel={commonT('actions.back')}
517
+ onCancel={() => router.push('/operations/contracts/templates')}
518
+ onSubmit={() => void onSubmit()}
519
+ submitIcon={<Save className="size-4" />}
520
+ submitLabel={commonT('actions.save')}
521
+ submitSize="lg"
522
+ />
523
+ </div>
524
+ </Page>
525
+ );
526
+ }