@hed-hog/operations 0.0.297 → 0.0.299

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 (32) hide show
  1. package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +310 -310
  2. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +631 -631
  3. package/hedhog/frontend/app/_components/contract-details-screen.tsx.ejs +132 -132
  4. package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +558 -558
  5. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +291 -291
  6. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +689 -689
  7. package/hedhog/frontend/app/_lib/api.ts.ejs +32 -32
  8. package/hedhog/frontend/app/_lib/hooks/use-operations-access.ts.ejs +44 -44
  9. package/hedhog/frontend/app/_lib/types.ts.ejs +360 -360
  10. package/hedhog/frontend/app/_lib/utils/format.ts.ejs +129 -129
  11. package/hedhog/frontend/app/_lib/utils/forms.ts.ejs +14 -14
  12. package/hedhog/frontend/app/approvals/page.tsx.ejs +386 -386
  13. package/hedhog/frontend/app/collaborators/[id]/edit/page.tsx.ejs +11 -11
  14. package/hedhog/frontend/app/collaborators/[id]/page.tsx.ejs +11 -11
  15. package/hedhog/frontend/app/collaborators/new/page.tsx.ejs +5 -5
  16. package/hedhog/frontend/app/collaborators/page.tsx.ejs +261 -261
  17. package/hedhog/frontend/app/contracts/[id]/edit/page.tsx.ejs +11 -11
  18. package/hedhog/frontend/app/contracts/[id]/page.tsx.ejs +11 -11
  19. package/hedhog/frontend/app/contracts/new/page.tsx.ejs +17 -17
  20. package/hedhog/frontend/app/contracts/page.tsx.ejs +262 -262
  21. package/hedhog/frontend/app/page.tsx.ejs +319 -319
  22. package/hedhog/frontend/app/projects/[id]/edit/page.tsx.ejs +11 -11
  23. package/hedhog/frontend/app/projects/[id]/page.tsx.ejs +11 -11
  24. package/hedhog/frontend/app/projects/new/page.tsx.ejs +5 -5
  25. package/hedhog/frontend/app/projects/page.tsx.ejs +236 -236
  26. package/hedhog/frontend/app/schedule-adjustments/page.tsx.ejs +418 -418
  27. package/hedhog/frontend/app/team/page.tsx.ejs +339 -339
  28. package/hedhog/frontend/app/time-off/page.tsx.ejs +328 -328
  29. package/hedhog/frontend/app/timesheets/page.tsx.ejs +636 -636
  30. package/hedhog/frontend/messages/en.json +648 -648
  31. package/hedhog/frontend/messages/pt.json +647 -647
  32. package/package.json +4 -4
@@ -1,631 +1,631 @@
1
- 'use client';
2
-
3
- import { EmptyState, Page } from '@/components/entity-list';
4
- import { Button } from '@/components/ui/button';
5
- import { Input } from '@/components/ui/input';
6
- import {
7
- Select,
8
- SelectContent,
9
- SelectItem,
10
- SelectTrigger,
11
- SelectValue,
12
- } from '@/components/ui/select';
13
- import { Switch } from '@/components/ui/switch';
14
- import { Textarea } from '@/components/ui/textarea';
15
- import { useApp, useQuery } from '@hed-hog/next-app-provider';
16
- import { ArrowLeft, Save, UserRound } from 'lucide-react';
17
- import Link from 'next/link';
18
- import { useRouter } from 'next/navigation';
19
- import { useEffect, useState } from 'react';
20
- import { useTranslations } from 'next-intl';
21
- import { OperationsHeader } from './operations-header';
22
- import { SectionCard } from './section-card';
23
- import { fetchOperations, mutateOperations } from '../_lib/api';
24
- import { useOperationsAccess } from '../_lib/hooks/use-operations-access';
25
- import type {
26
- OperationsCollaborator,
27
- OperationsCollaboratorDetails,
28
- OperationsWeeklyScheduleDay,
29
- } from '../_lib/types';
30
- import { parseNumberInput, trimToNull } from '../_lib/utils/forms';
31
- import { formatEnumLabel } from '../_lib/utils/format';
32
-
33
- const weekdays = [
34
- 'monday',
35
- 'tuesday',
36
- 'wednesday',
37
- 'thursday',
38
- 'friday',
39
- 'saturday',
40
- 'sunday',
41
- ] as const;
42
-
43
- type CollaboratorFormState = {
44
- userId: string;
45
- code: string;
46
- displayName: string;
47
- collaboratorType: string;
48
- department: string;
49
- title: string;
50
- levelLabel: string;
51
- weeklyCapacityHours: string;
52
- status: string;
53
- joinedAt: string;
54
- leftAt: string;
55
- supervisorCollaboratorId: string;
56
- compensationAmount: string;
57
- contractDescription: string;
58
- autoGenerateContractDraft: boolean;
59
- notes: string;
60
- weeklySchedule: Array<{
61
- weekday: string;
62
- isWorkingDay: boolean;
63
- startTime: string;
64
- endTime: string;
65
- breakMinutes: string;
66
- }>;
67
- };
68
-
69
- function defaultSchedule(): CollaboratorFormState['weeklySchedule'] {
70
- return weekdays.map((weekday) => ({
71
- weekday,
72
- isWorkingDay: !['saturday', 'sunday'].includes(weekday),
73
- startTime: '09:00',
74
- endTime: '18:00',
75
- breakMinutes: '60',
76
- }));
77
- }
78
-
79
- function buildEmptyForm(): CollaboratorFormState {
80
- return {
81
- userId: '',
82
- code: '',
83
- displayName: '',
84
- collaboratorType: 'clt',
85
- department: '',
86
- title: '',
87
- levelLabel: '',
88
- weeklyCapacityHours: '40',
89
- status: 'active',
90
- joinedAt: '',
91
- leftAt: '',
92
- supervisorCollaboratorId: 'none',
93
- compensationAmount: '',
94
- contractDescription: '',
95
- autoGenerateContractDraft: true,
96
- notes: '',
97
- weeklySchedule: defaultSchedule(),
98
- };
99
- }
100
-
101
- function normalizeSchedule(
102
- weeklySchedule?: OperationsWeeklyScheduleDay[]
103
- ): CollaboratorFormState['weeklySchedule'] {
104
- const byWeekday = new Map(
105
- (weeklySchedule ?? []).map((item) => [item.weekday, item])
106
- );
107
-
108
- return weekdays.map((weekday) => {
109
- const item = byWeekday.get(weekday);
110
-
111
- return {
112
- weekday,
113
- isWorkingDay: item?.isWorkingDay ?? !['saturday', 'sunday'].includes(weekday),
114
- startTime: item?.startTime ?? '09:00',
115
- endTime: item?.endTime ?? '18:00',
116
- breakMinutes:
117
- item?.breakMinutes !== null && item?.breakMinutes !== undefined
118
- ? String(item.breakMinutes)
119
- : '60',
120
- };
121
- });
122
- }
123
-
124
- function toFormState(
125
- collaborator: OperationsCollaboratorDetails
126
- ): CollaboratorFormState {
127
- return {
128
- userId: collaborator.userId ? String(collaborator.userId) : '',
129
- code: collaborator.code ?? '',
130
- displayName: collaborator.displayName ?? '',
131
- collaboratorType: collaborator.collaboratorType ?? 'other',
132
- department: collaborator.department ?? '',
133
- title: collaborator.title ?? '',
134
- levelLabel: collaborator.levelLabel ?? '',
135
- weeklyCapacityHours:
136
- collaborator.weeklyCapacityHours !== null &&
137
- collaborator.weeklyCapacityHours !== undefined
138
- ? String(collaborator.weeklyCapacityHours)
139
- : '',
140
- status: collaborator.status ?? 'active',
141
- joinedAt: collaborator.joinedAt ?? '',
142
- leftAt: collaborator.leftAt ?? '',
143
- supervisorCollaboratorId: collaborator.supervisorId
144
- ? String(collaborator.supervisorId)
145
- : 'none',
146
- compensationAmount:
147
- collaborator.relatedContracts?.[0]?.budgetAmount !== null &&
148
- collaborator.relatedContracts?.[0]?.budgetAmount !== undefined
149
- ? String(collaborator.relatedContracts[0].budgetAmount)
150
- : '',
151
- contractDescription: collaborator.relatedContracts?.[0]?.description ?? '',
152
- autoGenerateContractDraft: true,
153
- notes: collaborator.notes ?? '',
154
- weeklySchedule: normalizeSchedule(collaborator.weeklySchedule),
155
- };
156
- }
157
-
158
- type CollaboratorFormScreenProps = {
159
- collaboratorId?: number;
160
- };
161
-
162
- export function CollaboratorFormScreen({
163
- collaboratorId,
164
- }: CollaboratorFormScreenProps) {
165
- const t = useTranslations('operations.CollaboratorFormPage');
166
- const commonT = useTranslations('operations.Common');
167
- const { request, showToastHandler, currentLocaleCode } = useApp();
168
- const access = useOperationsAccess();
169
- const router = useRouter();
170
- const [form, setForm] = useState<CollaboratorFormState>(buildEmptyForm());
171
-
172
- const { data: collaborator, isLoading: isLoadingCollaborator } =
173
- useQuery<OperationsCollaboratorDetails>({
174
- queryKey: ['operations-collaborator-form', currentLocaleCode, collaboratorId],
175
- enabled: Boolean(collaboratorId),
176
- queryFn: () =>
177
- fetchOperations<OperationsCollaboratorDetails>(
178
- request,
179
- `/operations/collaborators/${collaboratorId}`
180
- ),
181
- });
182
-
183
- const { data: collaborators = [] } = useQuery<OperationsCollaborator[]>({
184
- queryKey: ['operations-collaborator-form-supervisors', currentLocaleCode],
185
- enabled: access.isDirector,
186
- queryFn: () =>
187
- fetchOperations<OperationsCollaborator[]>(request, '/operations/collaborators'),
188
- });
189
-
190
- useEffect(() => {
191
- if (collaborator) {
192
- setForm(toFormState(collaborator));
193
- }
194
- }, [collaborator]);
195
-
196
- const updateScheduleDay = (
197
- weekday: string,
198
- patch: Partial<CollaboratorFormState['weeklySchedule'][number]>
199
- ) => {
200
- setForm((current) => ({
201
- ...current,
202
- weeklySchedule: current.weeklySchedule.map((day) =>
203
- day.weekday === weekday ? { ...day, ...patch } : day
204
- ),
205
- }));
206
- };
207
-
208
- const onSubmit = async () => {
209
- const userId = parseNumberInput(form.userId);
210
- if (!collaboratorId && !userId) {
211
- showToastHandler?.('error', t('messages.userRequired'));
212
- return;
213
- }
214
-
215
- if (!form.code.trim() || !form.displayName.trim()) {
216
- showToastHandler?.('error', t('messages.requiredFields'));
217
- return;
218
- }
219
-
220
- const payload = {
221
- userId: userId ?? undefined,
222
- code: form.code.trim(),
223
- displayName: form.displayName.trim(),
224
- collaboratorType: form.collaboratorType,
225
- department: trimToNull(form.department),
226
- title: trimToNull(form.title),
227
- levelLabel: trimToNull(form.levelLabel),
228
- weeklyCapacityHours: parseNumberInput(form.weeklyCapacityHours),
229
- status: form.status,
230
- joinedAt: trimToNull(form.joinedAt),
231
- leftAt: trimToNull(form.leftAt),
232
- supervisorCollaboratorId:
233
- form.supervisorCollaboratorId === 'none'
234
- ? null
235
- : parseNumberInput(form.supervisorCollaboratorId),
236
- compensationAmount: parseNumberInput(form.compensationAmount),
237
- contractDescription: trimToNull(form.contractDescription),
238
- autoGenerateContractDraft: form.autoGenerateContractDraft,
239
- notes: trimToNull(form.notes),
240
- weeklySchedule: form.weeklySchedule.map((day) => ({
241
- weekday: day.weekday,
242
- isWorkingDay: day.isWorkingDay,
243
- startTime: day.isWorkingDay ? trimToNull(day.startTime) : null,
244
- endTime: day.isWorkingDay ? trimToNull(day.endTime) : null,
245
- breakMinutes: day.isWorkingDay ? parseNumberInput(day.breakMinutes) : 0,
246
- })),
247
- };
248
-
249
- try {
250
- const response = collaboratorId
251
- ? await mutateOperations<OperationsCollaboratorDetails>(
252
- request,
253
- `/operations/collaborators/${collaboratorId}`,
254
- 'PATCH',
255
- payload
256
- )
257
- : await mutateOperations<OperationsCollaboratorDetails>(
258
- request,
259
- '/operations/collaborators',
260
- 'POST',
261
- payload
262
- );
263
-
264
- showToastHandler?.(
265
- 'success',
266
- collaboratorId ? t('messages.updateSuccess') : t('messages.createSuccess')
267
- );
268
- router.push(`/operations/collaborators/${response.id}`);
269
- } catch {
270
- showToastHandler?.(
271
- 'error',
272
- collaboratorId ? t('messages.updateError') : t('messages.createError')
273
- );
274
- }
275
- };
276
-
277
- if (!access.isDirector && !access.isLoading) {
278
- return (
279
- <Page>
280
- <OperationsHeader
281
- title={t(collaboratorId ? 'editTitle' : 'newTitle')}
282
- description={t('description')}
283
- current={t('breadcrumb')}
284
- />
285
- <EmptyState
286
- icon={<UserRound className="size-12" />}
287
- title={commonT('states.noAccessTitle')}
288
- description={t('noAccessDescription')}
289
- actionLabel={commonT('actions.refresh')}
290
- onAction={() => router.refresh()}
291
- />
292
- </Page>
293
- );
294
- }
295
-
296
- return (
297
- <Page>
298
- <OperationsHeader
299
- title={t(collaboratorId ? 'editTitle' : 'newTitle')}
300
- description={t('description')}
301
- current={t('breadcrumb')}
302
- actions={
303
- <div className="flex gap-2">
304
- <Button variant="outline" size="sm" asChild>
305
- <Link
306
- href={
307
- collaboratorId
308
- ? `/operations/collaborators/${collaboratorId}`
309
- : '/operations/collaborators'
310
- }
311
- >
312
- <ArrowLeft className="size-4" />
313
- {commonT('actions.back')}
314
- </Link>
315
- </Button>
316
- <Button size="sm" onClick={() => void onSubmit()}>
317
- <Save className="size-4" />
318
- {commonT('actions.save')}
319
- </Button>
320
- </div>
321
- }
322
- />
323
-
324
- <div className="grid gap-4 xl:grid-cols-2">
325
- <SectionCard title={t('sections.basicInfo')} description={t('sections.basicInfoDescription')}>
326
- <div className="grid gap-4 md:grid-cols-2">
327
- <div className="space-y-2">
328
- <label className="text-sm font-medium">{t('fields.userId')}</label>
329
- <Input
330
- value={form.userId}
331
- disabled={Boolean(collaboratorId)}
332
- onChange={(event) =>
333
- setForm((current) => ({ ...current, userId: event.target.value }))
334
- }
335
- />
336
- </div>
337
- <div className="space-y-2">
338
- <label className="text-sm font-medium">{t('fields.code')}</label>
339
- <Input
340
- value={form.code}
341
- onChange={(event) =>
342
- setForm((current) => ({ ...current, code: event.target.value }))
343
- }
344
- />
345
- </div>
346
- <div className="space-y-2">
347
- <label className="text-sm font-medium">{t('fields.displayName')}</label>
348
- <Input
349
- value={form.displayName}
350
- onChange={(event) =>
351
- setForm((current) => ({
352
- ...current,
353
- displayName: event.target.value,
354
- }))
355
- }
356
- />
357
- </div>
358
- <div className="space-y-2">
359
- <label className="text-sm font-medium">{t('fields.department')}</label>
360
- <Input
361
- value={form.department}
362
- onChange={(event) =>
363
- setForm((current) => ({
364
- ...current,
365
- department: event.target.value,
366
- }))
367
- }
368
- />
369
- </div>
370
- <div className="space-y-2">
371
- <label className="text-sm font-medium">{t('fields.title')}</label>
372
- <Input
373
- value={form.title}
374
- onChange={(event) =>
375
- setForm((current) => ({ ...current, title: event.target.value }))
376
- }
377
- />
378
- </div>
379
- <div className="space-y-2">
380
- <label className="text-sm font-medium">{t('fields.levelLabel')}</label>
381
- <Input
382
- value={form.levelLabel}
383
- onChange={(event) =>
384
- setForm((current) => ({
385
- ...current,
386
- levelLabel: event.target.value,
387
- }))
388
- }
389
- />
390
- </div>
391
- <div className="space-y-2 md:col-span-2">
392
- <label className="text-sm font-medium">{t('fields.notes')}</label>
393
- <Textarea
394
- rows={4}
395
- value={form.notes}
396
- onChange={(event) =>
397
- setForm((current) => ({ ...current, notes: event.target.value }))
398
- }
399
- />
400
- </div>
401
- </div>
402
- </SectionCard>
403
-
404
- <SectionCard
405
- title={t('sections.employmentInfo')}
406
- description={t('sections.employmentInfoDescription')}
407
- >
408
- <div className="grid gap-4 md:grid-cols-2">
409
- <div className="space-y-2">
410
- <label className="text-sm font-medium">{t('fields.collaboratorType')}</label>
411
- <Select
412
- value={form.collaboratorType}
413
- onValueChange={(value) =>
414
- setForm((current) => ({ ...current, collaboratorType: value }))
415
- }
416
- >
417
- <SelectTrigger>
418
- <SelectValue />
419
- </SelectTrigger>
420
- <SelectContent>
421
- <SelectItem value="clt">CLT</SelectItem>
422
- <SelectItem value="pj">PJ</SelectItem>
423
- <SelectItem value="freelancer">Freelancer</SelectItem>
424
- <SelectItem value="intern">Intern</SelectItem>
425
- <SelectItem value="other">Other</SelectItem>
426
- </SelectContent>
427
- </Select>
428
- </div>
429
- <div className="space-y-2">
430
- <label className="text-sm font-medium">{commonT('labels.status')}</label>
431
- <Select
432
- value={form.status}
433
- onValueChange={(value) =>
434
- setForm((current) => ({ ...current, status: value }))
435
- }
436
- >
437
- <SelectTrigger>
438
- <SelectValue />
439
- </SelectTrigger>
440
- <SelectContent>
441
- <SelectItem value="active">Active</SelectItem>
442
- <SelectItem value="on_leave">On Leave</SelectItem>
443
- <SelectItem value="inactive">Inactive</SelectItem>
444
- </SelectContent>
445
- </Select>
446
- </div>
447
- <div className="space-y-2">
448
- <label className="text-sm font-medium">{commonT('labels.startDate')}</label>
449
- <Input
450
- type="date"
451
- value={form.joinedAt}
452
- onChange={(event) =>
453
- setForm((current) => ({ ...current, joinedAt: event.target.value }))
454
- }
455
- />
456
- </div>
457
- <div className="space-y-2">
458
- <label className="text-sm font-medium">{commonT('labels.endDate')}</label>
459
- <Input
460
- type="date"
461
- value={form.leftAt}
462
- onChange={(event) =>
463
- setForm((current) => ({ ...current, leftAt: event.target.value }))
464
- }
465
- />
466
- </div>
467
- </div>
468
- </SectionCard>
469
-
470
- <SectionCard
471
- title={t('sections.supervisor')}
472
- description={t('sections.supervisorDescription')}
473
- >
474
- <div className="grid gap-4 md:grid-cols-2">
475
- <div className="space-y-2">
476
- <label className="text-sm font-medium">{commonT('labels.supervisor')}</label>
477
- <Select
478
- value={form.supervisorCollaboratorId}
479
- onValueChange={(value) =>
480
- setForm((current) => ({
481
- ...current,
482
- supervisorCollaboratorId: value,
483
- }))
484
- }
485
- >
486
- <SelectTrigger>
487
- <SelectValue />
488
- </SelectTrigger>
489
- <SelectContent>
490
- <SelectItem value="none">{commonT('labels.notAssigned')}</SelectItem>
491
- {collaborators
492
- .filter((item) => item.id !== collaboratorId)
493
- .map((item) => (
494
- <SelectItem key={item.id} value={String(item.id)}>
495
- {item.displayName}
496
- </SelectItem>
497
- ))}
498
- </SelectContent>
499
- </Select>
500
- </div>
501
- </div>
502
- </SectionCard>
503
-
504
- <SectionCard
505
- title={t('sections.contract')}
506
- description={t('sections.contractDescription')}
507
- >
508
- <div className="grid gap-4 md:grid-cols-2">
509
- <div className="space-y-2">
510
- <label className="text-sm font-medium">{t('fields.weeklyCapacityHours')}</label>
511
- <Input
512
- type="number"
513
- step="0.5"
514
- value={form.weeklyCapacityHours}
515
- onChange={(event) =>
516
- setForm((current) => ({
517
- ...current,
518
- weeklyCapacityHours: event.target.value,
519
- }))
520
- }
521
- />
522
- </div>
523
- <div className="space-y-2">
524
- <label className="text-sm font-medium">{t('fields.compensationAmount')}</label>
525
- <Input
526
- type="number"
527
- step="0.01"
528
- value={form.compensationAmount}
529
- onChange={(event) =>
530
- setForm((current) => ({
531
- ...current,
532
- compensationAmount: event.target.value,
533
- }))
534
- }
535
- />
536
- </div>
537
- <div className="space-y-2 md:col-span-2">
538
- <label className="text-sm font-medium">{t('fields.contractDescription')}</label>
539
- <Textarea
540
- rows={4}
541
- value={form.contractDescription}
542
- onChange={(event) =>
543
- setForm((current) => ({
544
- ...current,
545
- contractDescription: event.target.value,
546
- }))
547
- }
548
- />
549
- </div>
550
- <div className="flex items-center justify-between rounded-lg border px-4 py-3 md:col-span-2">
551
- <div>
552
- <div className="font-medium">{t('fields.autoGenerateContractDraft')}</div>
553
- <div className="text-sm text-muted-foreground">
554
- {t('fields.autoGenerateContractDraftDescription')}
555
- </div>
556
- </div>
557
- <Switch
558
- checked={form.autoGenerateContractDraft}
559
- onCheckedChange={(checked) =>
560
- setForm((current) => ({
561
- ...current,
562
- autoGenerateContractDraft: checked,
563
- }))
564
- }
565
- />
566
- </div>
567
- </div>
568
- </SectionCard>
569
- </div>
570
-
571
- <SectionCard
572
- title={t('sections.schedule')}
573
- description={t('sections.scheduleDescription')}
574
- >
575
- <div className="space-y-3">
576
- {form.weeklySchedule.map((day) => (
577
- <div
578
- key={day.weekday}
579
- className="grid gap-3 rounded-lg border p-4 lg:grid-cols-[1fr_auto_1fr_1fr_1fr]"
580
- >
581
- <div>
582
- <div className="font-medium">{formatEnumLabel(day.weekday)}</div>
583
- <div className="text-xs text-muted-foreground">
584
- {day.isWorkingDay ? commonT('labels.workingDay') : commonT('labels.dayOff')}
585
- </div>
586
- </div>
587
- <div className="flex items-center gap-2">
588
- <Switch
589
- checked={day.isWorkingDay}
590
- onCheckedChange={(checked) =>
591
- updateScheduleDay(day.weekday, { isWorkingDay: checked })
592
- }
593
- />
594
- </div>
595
- <Input
596
- type="time"
597
- value={day.startTime}
598
- disabled={!day.isWorkingDay}
599
- onChange={(event) =>
600
- updateScheduleDay(day.weekday, { startTime: event.target.value })
601
- }
602
- />
603
- <Input
604
- type="time"
605
- value={day.endTime}
606
- disabled={!day.isWorkingDay}
607
- onChange={(event) =>
608
- updateScheduleDay(day.weekday, { endTime: event.target.value })
609
- }
610
- />
611
- <Input
612
- type="number"
613
- value={day.breakMinutes}
614
- disabled={!day.isWorkingDay}
615
- onChange={(event) =>
616
- updateScheduleDay(day.weekday, {
617
- breakMinutes: event.target.value,
618
- })
619
- }
620
- />
621
- </div>
622
- ))}
623
- </div>
624
- </SectionCard>
625
-
626
- {collaboratorId && isLoadingCollaborator ? (
627
- <div className="text-sm text-muted-foreground">{t('loading')}</div>
628
- ) : null}
629
- </Page>
630
- );
631
- }
1
+ 'use client';
2
+
3
+ import { EmptyState, Page } from '@/components/entity-list';
4
+ import { Button } from '@/components/ui/button';
5
+ import { Input } from '@/components/ui/input';
6
+ import {
7
+ Select,
8
+ SelectContent,
9
+ SelectItem,
10
+ SelectTrigger,
11
+ SelectValue,
12
+ } from '@/components/ui/select';
13
+ import { Switch } from '@/components/ui/switch';
14
+ import { Textarea } from '@/components/ui/textarea';
15
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
16
+ import { ArrowLeft, Save, UserRound } from 'lucide-react';
17
+ import Link from 'next/link';
18
+ import { useRouter } from 'next/navigation';
19
+ import { useEffect, useState } from 'react';
20
+ import { useTranslations } from 'next-intl';
21
+ import { OperationsHeader } from './operations-header';
22
+ import { SectionCard } from './section-card';
23
+ import { fetchOperations, mutateOperations } from '../_lib/api';
24
+ import { useOperationsAccess } from '../_lib/hooks/use-operations-access';
25
+ import type {
26
+ OperationsCollaborator,
27
+ OperationsCollaboratorDetails,
28
+ OperationsWeeklyScheduleDay,
29
+ } from '../_lib/types';
30
+ import { parseNumberInput, trimToNull } from '../_lib/utils/forms';
31
+ import { formatEnumLabel } from '../_lib/utils/format';
32
+
33
+ const weekdays = [
34
+ 'monday',
35
+ 'tuesday',
36
+ 'wednesday',
37
+ 'thursday',
38
+ 'friday',
39
+ 'saturday',
40
+ 'sunday',
41
+ ] as const;
42
+
43
+ type CollaboratorFormState = {
44
+ userId: string;
45
+ code: string;
46
+ displayName: string;
47
+ collaboratorType: string;
48
+ department: string;
49
+ title: string;
50
+ levelLabel: string;
51
+ weeklyCapacityHours: string;
52
+ status: string;
53
+ joinedAt: string;
54
+ leftAt: string;
55
+ supervisorCollaboratorId: string;
56
+ compensationAmount: string;
57
+ contractDescription: string;
58
+ autoGenerateContractDraft: boolean;
59
+ notes: string;
60
+ weeklySchedule: Array<{
61
+ weekday: string;
62
+ isWorkingDay: boolean;
63
+ startTime: string;
64
+ endTime: string;
65
+ breakMinutes: string;
66
+ }>;
67
+ };
68
+
69
+ function defaultSchedule(): CollaboratorFormState['weeklySchedule'] {
70
+ return weekdays.map((weekday) => ({
71
+ weekday,
72
+ isWorkingDay: !['saturday', 'sunday'].includes(weekday),
73
+ startTime: '09:00',
74
+ endTime: '18:00',
75
+ breakMinutes: '60',
76
+ }));
77
+ }
78
+
79
+ function buildEmptyForm(): CollaboratorFormState {
80
+ return {
81
+ userId: '',
82
+ code: '',
83
+ displayName: '',
84
+ collaboratorType: 'clt',
85
+ department: '',
86
+ title: '',
87
+ levelLabel: '',
88
+ weeklyCapacityHours: '40',
89
+ status: 'active',
90
+ joinedAt: '',
91
+ leftAt: '',
92
+ supervisorCollaboratorId: 'none',
93
+ compensationAmount: '',
94
+ contractDescription: '',
95
+ autoGenerateContractDraft: true,
96
+ notes: '',
97
+ weeklySchedule: defaultSchedule(),
98
+ };
99
+ }
100
+
101
+ function normalizeSchedule(
102
+ weeklySchedule?: OperationsWeeklyScheduleDay[]
103
+ ): CollaboratorFormState['weeklySchedule'] {
104
+ const byWeekday = new Map(
105
+ (weeklySchedule ?? []).map((item) => [item.weekday, item])
106
+ );
107
+
108
+ return weekdays.map((weekday) => {
109
+ const item = byWeekday.get(weekday);
110
+
111
+ return {
112
+ weekday,
113
+ isWorkingDay: item?.isWorkingDay ?? !['saturday', 'sunday'].includes(weekday),
114
+ startTime: item?.startTime ?? '09:00',
115
+ endTime: item?.endTime ?? '18:00',
116
+ breakMinutes:
117
+ item?.breakMinutes !== null && item?.breakMinutes !== undefined
118
+ ? String(item.breakMinutes)
119
+ : '60',
120
+ };
121
+ });
122
+ }
123
+
124
+ function toFormState(
125
+ collaborator: OperationsCollaboratorDetails
126
+ ): CollaboratorFormState {
127
+ return {
128
+ userId: collaborator.userId ? String(collaborator.userId) : '',
129
+ code: collaborator.code ?? '',
130
+ displayName: collaborator.displayName ?? '',
131
+ collaboratorType: collaborator.collaboratorType ?? 'other',
132
+ department: collaborator.department ?? '',
133
+ title: collaborator.title ?? '',
134
+ levelLabel: collaborator.levelLabel ?? '',
135
+ weeklyCapacityHours:
136
+ collaborator.weeklyCapacityHours !== null &&
137
+ collaborator.weeklyCapacityHours !== undefined
138
+ ? String(collaborator.weeklyCapacityHours)
139
+ : '',
140
+ status: collaborator.status ?? 'active',
141
+ joinedAt: collaborator.joinedAt ?? '',
142
+ leftAt: collaborator.leftAt ?? '',
143
+ supervisorCollaboratorId: collaborator.supervisorId
144
+ ? String(collaborator.supervisorId)
145
+ : 'none',
146
+ compensationAmount:
147
+ collaborator.relatedContracts?.[0]?.budgetAmount !== null &&
148
+ collaborator.relatedContracts?.[0]?.budgetAmount !== undefined
149
+ ? String(collaborator.relatedContracts[0].budgetAmount)
150
+ : '',
151
+ contractDescription: collaborator.relatedContracts?.[0]?.description ?? '',
152
+ autoGenerateContractDraft: true,
153
+ notes: collaborator.notes ?? '',
154
+ weeklySchedule: normalizeSchedule(collaborator.weeklySchedule),
155
+ };
156
+ }
157
+
158
+ type CollaboratorFormScreenProps = {
159
+ collaboratorId?: number;
160
+ };
161
+
162
+ export function CollaboratorFormScreen({
163
+ collaboratorId,
164
+ }: CollaboratorFormScreenProps) {
165
+ const t = useTranslations('operations.CollaboratorFormPage');
166
+ const commonT = useTranslations('operations.Common');
167
+ const { request, showToastHandler, currentLocaleCode } = useApp();
168
+ const access = useOperationsAccess();
169
+ const router = useRouter();
170
+ const [form, setForm] = useState<CollaboratorFormState>(buildEmptyForm());
171
+
172
+ const { data: collaborator, isLoading: isLoadingCollaborator } =
173
+ useQuery<OperationsCollaboratorDetails>({
174
+ queryKey: ['operations-collaborator-form', currentLocaleCode, collaboratorId],
175
+ enabled: Boolean(collaboratorId),
176
+ queryFn: () =>
177
+ fetchOperations<OperationsCollaboratorDetails>(
178
+ request,
179
+ `/operations/collaborators/${collaboratorId}`
180
+ ),
181
+ });
182
+
183
+ const { data: collaborators = [] } = useQuery<OperationsCollaborator[]>({
184
+ queryKey: ['operations-collaborator-form-supervisors', currentLocaleCode],
185
+ enabled: access.isDirector,
186
+ queryFn: () =>
187
+ fetchOperations<OperationsCollaborator[]>(request, '/operations/collaborators'),
188
+ });
189
+
190
+ useEffect(() => {
191
+ if (collaborator) {
192
+ setForm(toFormState(collaborator));
193
+ }
194
+ }, [collaborator]);
195
+
196
+ const updateScheduleDay = (
197
+ weekday: string,
198
+ patch: Partial<CollaboratorFormState['weeklySchedule'][number]>
199
+ ) => {
200
+ setForm((current) => ({
201
+ ...current,
202
+ weeklySchedule: current.weeklySchedule.map((day) =>
203
+ day.weekday === weekday ? { ...day, ...patch } : day
204
+ ),
205
+ }));
206
+ };
207
+
208
+ const onSubmit = async () => {
209
+ const userId = parseNumberInput(form.userId);
210
+ if (!collaboratorId && !userId) {
211
+ showToastHandler?.('error', t('messages.userRequired'));
212
+ return;
213
+ }
214
+
215
+ if (!form.code.trim() || !form.displayName.trim()) {
216
+ showToastHandler?.('error', t('messages.requiredFields'));
217
+ return;
218
+ }
219
+
220
+ const payload = {
221
+ userId: userId ?? undefined,
222
+ code: form.code.trim(),
223
+ displayName: form.displayName.trim(),
224
+ collaboratorType: form.collaboratorType,
225
+ department: trimToNull(form.department),
226
+ title: trimToNull(form.title),
227
+ levelLabel: trimToNull(form.levelLabel),
228
+ weeklyCapacityHours: parseNumberInput(form.weeklyCapacityHours),
229
+ status: form.status,
230
+ joinedAt: trimToNull(form.joinedAt),
231
+ leftAt: trimToNull(form.leftAt),
232
+ supervisorCollaboratorId:
233
+ form.supervisorCollaboratorId === 'none'
234
+ ? null
235
+ : parseNumberInput(form.supervisorCollaboratorId),
236
+ compensationAmount: parseNumberInput(form.compensationAmount),
237
+ contractDescription: trimToNull(form.contractDescription),
238
+ autoGenerateContractDraft: form.autoGenerateContractDraft,
239
+ notes: trimToNull(form.notes),
240
+ weeklySchedule: form.weeklySchedule.map((day) => ({
241
+ weekday: day.weekday,
242
+ isWorkingDay: day.isWorkingDay,
243
+ startTime: day.isWorkingDay ? trimToNull(day.startTime) : null,
244
+ endTime: day.isWorkingDay ? trimToNull(day.endTime) : null,
245
+ breakMinutes: day.isWorkingDay ? parseNumberInput(day.breakMinutes) : 0,
246
+ })),
247
+ };
248
+
249
+ try {
250
+ const response = collaboratorId
251
+ ? await mutateOperations<OperationsCollaboratorDetails>(
252
+ request,
253
+ `/operations/collaborators/${collaboratorId}`,
254
+ 'PATCH',
255
+ payload
256
+ )
257
+ : await mutateOperations<OperationsCollaboratorDetails>(
258
+ request,
259
+ '/operations/collaborators',
260
+ 'POST',
261
+ payload
262
+ );
263
+
264
+ showToastHandler?.(
265
+ 'success',
266
+ collaboratorId ? t('messages.updateSuccess') : t('messages.createSuccess')
267
+ );
268
+ router.push(`/operations/collaborators/${response.id}`);
269
+ } catch {
270
+ showToastHandler?.(
271
+ 'error',
272
+ collaboratorId ? t('messages.updateError') : t('messages.createError')
273
+ );
274
+ }
275
+ };
276
+
277
+ if (!access.isDirector && !access.isLoading) {
278
+ return (
279
+ <Page>
280
+ <OperationsHeader
281
+ title={t(collaboratorId ? 'editTitle' : 'newTitle')}
282
+ description={t('description')}
283
+ current={t('breadcrumb')}
284
+ />
285
+ <EmptyState
286
+ icon={<UserRound className="size-12" />}
287
+ title={commonT('states.noAccessTitle')}
288
+ description={t('noAccessDescription')}
289
+ actionLabel={commonT('actions.refresh')}
290
+ onAction={() => router.refresh()}
291
+ />
292
+ </Page>
293
+ );
294
+ }
295
+
296
+ return (
297
+ <Page>
298
+ <OperationsHeader
299
+ title={t(collaboratorId ? 'editTitle' : 'newTitle')}
300
+ description={t('description')}
301
+ current={t('breadcrumb')}
302
+ actions={
303
+ <div className="flex gap-2">
304
+ <Button variant="outline" size="sm" asChild>
305
+ <Link
306
+ href={
307
+ collaboratorId
308
+ ? `/operations/collaborators/${collaboratorId}`
309
+ : '/operations/collaborators'
310
+ }
311
+ >
312
+ <ArrowLeft className="size-4" />
313
+ {commonT('actions.back')}
314
+ </Link>
315
+ </Button>
316
+ <Button size="sm" onClick={() => void onSubmit()}>
317
+ <Save className="size-4" />
318
+ {commonT('actions.save')}
319
+ </Button>
320
+ </div>
321
+ }
322
+ />
323
+
324
+ <div className="grid gap-4 xl:grid-cols-2">
325
+ <SectionCard title={t('sections.basicInfo')} description={t('sections.basicInfoDescription')}>
326
+ <div className="grid gap-4 md:grid-cols-2">
327
+ <div className="space-y-2">
328
+ <label className="text-sm font-medium">{t('fields.userId')}</label>
329
+ <Input
330
+ value={form.userId}
331
+ disabled={Boolean(collaboratorId)}
332
+ onChange={(event) =>
333
+ setForm((current) => ({ ...current, userId: event.target.value }))
334
+ }
335
+ />
336
+ </div>
337
+ <div className="space-y-2">
338
+ <label className="text-sm font-medium">{t('fields.code')}</label>
339
+ <Input
340
+ value={form.code}
341
+ onChange={(event) =>
342
+ setForm((current) => ({ ...current, code: event.target.value }))
343
+ }
344
+ />
345
+ </div>
346
+ <div className="space-y-2">
347
+ <label className="text-sm font-medium">{t('fields.displayName')}</label>
348
+ <Input
349
+ value={form.displayName}
350
+ onChange={(event) =>
351
+ setForm((current) => ({
352
+ ...current,
353
+ displayName: event.target.value,
354
+ }))
355
+ }
356
+ />
357
+ </div>
358
+ <div className="space-y-2">
359
+ <label className="text-sm font-medium">{t('fields.department')}</label>
360
+ <Input
361
+ value={form.department}
362
+ onChange={(event) =>
363
+ setForm((current) => ({
364
+ ...current,
365
+ department: event.target.value,
366
+ }))
367
+ }
368
+ />
369
+ </div>
370
+ <div className="space-y-2">
371
+ <label className="text-sm font-medium">{t('fields.title')}</label>
372
+ <Input
373
+ value={form.title}
374
+ onChange={(event) =>
375
+ setForm((current) => ({ ...current, title: event.target.value }))
376
+ }
377
+ />
378
+ </div>
379
+ <div className="space-y-2">
380
+ <label className="text-sm font-medium">{t('fields.levelLabel')}</label>
381
+ <Input
382
+ value={form.levelLabel}
383
+ onChange={(event) =>
384
+ setForm((current) => ({
385
+ ...current,
386
+ levelLabel: event.target.value,
387
+ }))
388
+ }
389
+ />
390
+ </div>
391
+ <div className="space-y-2 md:col-span-2">
392
+ <label className="text-sm font-medium">{t('fields.notes')}</label>
393
+ <Textarea
394
+ rows={4}
395
+ value={form.notes}
396
+ onChange={(event) =>
397
+ setForm((current) => ({ ...current, notes: event.target.value }))
398
+ }
399
+ />
400
+ </div>
401
+ </div>
402
+ </SectionCard>
403
+
404
+ <SectionCard
405
+ title={t('sections.employmentInfo')}
406
+ description={t('sections.employmentInfoDescription')}
407
+ >
408
+ <div className="grid gap-4 md:grid-cols-2">
409
+ <div className="space-y-2">
410
+ <label className="text-sm font-medium">{t('fields.collaboratorType')}</label>
411
+ <Select
412
+ value={form.collaboratorType}
413
+ onValueChange={(value) =>
414
+ setForm((current) => ({ ...current, collaboratorType: value }))
415
+ }
416
+ >
417
+ <SelectTrigger>
418
+ <SelectValue />
419
+ </SelectTrigger>
420
+ <SelectContent>
421
+ <SelectItem value="clt">CLT</SelectItem>
422
+ <SelectItem value="pj">PJ</SelectItem>
423
+ <SelectItem value="freelancer">Freelancer</SelectItem>
424
+ <SelectItem value="intern">Intern</SelectItem>
425
+ <SelectItem value="other">Other</SelectItem>
426
+ </SelectContent>
427
+ </Select>
428
+ </div>
429
+ <div className="space-y-2">
430
+ <label className="text-sm font-medium">{commonT('labels.status')}</label>
431
+ <Select
432
+ value={form.status}
433
+ onValueChange={(value) =>
434
+ setForm((current) => ({ ...current, status: value }))
435
+ }
436
+ >
437
+ <SelectTrigger>
438
+ <SelectValue />
439
+ </SelectTrigger>
440
+ <SelectContent>
441
+ <SelectItem value="active">Active</SelectItem>
442
+ <SelectItem value="on_leave">On Leave</SelectItem>
443
+ <SelectItem value="inactive">Inactive</SelectItem>
444
+ </SelectContent>
445
+ </Select>
446
+ </div>
447
+ <div className="space-y-2">
448
+ <label className="text-sm font-medium">{commonT('labels.startDate')}</label>
449
+ <Input
450
+ type="date"
451
+ value={form.joinedAt}
452
+ onChange={(event) =>
453
+ setForm((current) => ({ ...current, joinedAt: event.target.value }))
454
+ }
455
+ />
456
+ </div>
457
+ <div className="space-y-2">
458
+ <label className="text-sm font-medium">{commonT('labels.endDate')}</label>
459
+ <Input
460
+ type="date"
461
+ value={form.leftAt}
462
+ onChange={(event) =>
463
+ setForm((current) => ({ ...current, leftAt: event.target.value }))
464
+ }
465
+ />
466
+ </div>
467
+ </div>
468
+ </SectionCard>
469
+
470
+ <SectionCard
471
+ title={t('sections.supervisor')}
472
+ description={t('sections.supervisorDescription')}
473
+ >
474
+ <div className="grid gap-4 md:grid-cols-2">
475
+ <div className="space-y-2">
476
+ <label className="text-sm font-medium">{commonT('labels.supervisor')}</label>
477
+ <Select
478
+ value={form.supervisorCollaboratorId}
479
+ onValueChange={(value) =>
480
+ setForm((current) => ({
481
+ ...current,
482
+ supervisorCollaboratorId: value,
483
+ }))
484
+ }
485
+ >
486
+ <SelectTrigger>
487
+ <SelectValue />
488
+ </SelectTrigger>
489
+ <SelectContent>
490
+ <SelectItem value="none">{commonT('labels.notAssigned')}</SelectItem>
491
+ {collaborators
492
+ .filter((item) => item.id !== collaboratorId)
493
+ .map((item) => (
494
+ <SelectItem key={item.id} value={String(item.id)}>
495
+ {item.displayName}
496
+ </SelectItem>
497
+ ))}
498
+ </SelectContent>
499
+ </Select>
500
+ </div>
501
+ </div>
502
+ </SectionCard>
503
+
504
+ <SectionCard
505
+ title={t('sections.contract')}
506
+ description={t('sections.contractDescription')}
507
+ >
508
+ <div className="grid gap-4 md:grid-cols-2">
509
+ <div className="space-y-2">
510
+ <label className="text-sm font-medium">{t('fields.weeklyCapacityHours')}</label>
511
+ <Input
512
+ type="number"
513
+ step="0.5"
514
+ value={form.weeklyCapacityHours}
515
+ onChange={(event) =>
516
+ setForm((current) => ({
517
+ ...current,
518
+ weeklyCapacityHours: event.target.value,
519
+ }))
520
+ }
521
+ />
522
+ </div>
523
+ <div className="space-y-2">
524
+ <label className="text-sm font-medium">{t('fields.compensationAmount')}</label>
525
+ <Input
526
+ type="number"
527
+ step="0.01"
528
+ value={form.compensationAmount}
529
+ onChange={(event) =>
530
+ setForm((current) => ({
531
+ ...current,
532
+ compensationAmount: event.target.value,
533
+ }))
534
+ }
535
+ />
536
+ </div>
537
+ <div className="space-y-2 md:col-span-2">
538
+ <label className="text-sm font-medium">{t('fields.contractDescription')}</label>
539
+ <Textarea
540
+ rows={4}
541
+ value={form.contractDescription}
542
+ onChange={(event) =>
543
+ setForm((current) => ({
544
+ ...current,
545
+ contractDescription: event.target.value,
546
+ }))
547
+ }
548
+ />
549
+ </div>
550
+ <div className="flex items-center justify-between rounded-lg border px-4 py-3 md:col-span-2">
551
+ <div>
552
+ <div className="font-medium">{t('fields.autoGenerateContractDraft')}</div>
553
+ <div className="text-sm text-muted-foreground">
554
+ {t('fields.autoGenerateContractDraftDescription')}
555
+ </div>
556
+ </div>
557
+ <Switch
558
+ checked={form.autoGenerateContractDraft}
559
+ onCheckedChange={(checked) =>
560
+ setForm((current) => ({
561
+ ...current,
562
+ autoGenerateContractDraft: checked,
563
+ }))
564
+ }
565
+ />
566
+ </div>
567
+ </div>
568
+ </SectionCard>
569
+ </div>
570
+
571
+ <SectionCard
572
+ title={t('sections.schedule')}
573
+ description={t('sections.scheduleDescription')}
574
+ >
575
+ <div className="space-y-3">
576
+ {form.weeklySchedule.map((day) => (
577
+ <div
578
+ key={day.weekday}
579
+ className="grid gap-3 rounded-lg border p-4 lg:grid-cols-[1fr_auto_1fr_1fr_1fr]"
580
+ >
581
+ <div>
582
+ <div className="font-medium">{formatEnumLabel(day.weekday)}</div>
583
+ <div className="text-xs text-muted-foreground">
584
+ {day.isWorkingDay ? commonT('labels.workingDay') : commonT('labels.dayOff')}
585
+ </div>
586
+ </div>
587
+ <div className="flex items-center gap-2">
588
+ <Switch
589
+ checked={day.isWorkingDay}
590
+ onCheckedChange={(checked) =>
591
+ updateScheduleDay(day.weekday, { isWorkingDay: checked })
592
+ }
593
+ />
594
+ </div>
595
+ <Input
596
+ type="time"
597
+ value={day.startTime}
598
+ disabled={!day.isWorkingDay}
599
+ onChange={(event) =>
600
+ updateScheduleDay(day.weekday, { startTime: event.target.value })
601
+ }
602
+ />
603
+ <Input
604
+ type="time"
605
+ value={day.endTime}
606
+ disabled={!day.isWorkingDay}
607
+ onChange={(event) =>
608
+ updateScheduleDay(day.weekday, { endTime: event.target.value })
609
+ }
610
+ />
611
+ <Input
612
+ type="number"
613
+ value={day.breakMinutes}
614
+ disabled={!day.isWorkingDay}
615
+ onChange={(event) =>
616
+ updateScheduleDay(day.weekday, {
617
+ breakMinutes: event.target.value,
618
+ })
619
+ }
620
+ />
621
+ </div>
622
+ ))}
623
+ </div>
624
+ </SectionCard>
625
+
626
+ {collaboratorId && isLoadingCollaborator ? (
627
+ <div className="text-sm text-muted-foreground">{t('loading')}</div>
628
+ ) : null}
629
+ </Page>
630
+ );
631
+ }