@hed-hog/operations 0.0.300 → 0.0.301

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 +490 -46
  17. package/dist/operations.service.d.ts.map +1 -1
  18. package/dist/operations.service.js +2442 -119
  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 +34 -0
  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 +36 -12
  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 +6 -4
  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 +3934 -212
@@ -1,8 +1,25 @@
1
1
  'use client';
2
2
 
3
3
  import { EmptyState, Page } from '@/components/entity-list';
4
+ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
4
5
  import { Button } from '@/components/ui/button';
6
+ import {
7
+ Command,
8
+ CommandEmpty,
9
+ CommandGroup,
10
+ CommandInput,
11
+ CommandItem,
12
+ CommandList,
13
+ } from '@/components/ui/command';
14
+ import { FormActions } from '@/components/ui/form-actions';
5
15
  import { Input } from '@/components/ui/input';
16
+ import { InputMoney } from '@/components/ui/input-money';
17
+ import { Label } from '@/components/ui/label';
18
+ import {
19
+ Popover,
20
+ PopoverContent,
21
+ PopoverTrigger,
22
+ } from '@/components/ui/popover';
6
23
  import {
7
24
  Select,
8
25
  SelectContent,
@@ -12,23 +29,44 @@ import {
12
29
  } from '@/components/ui/select';
13
30
  import { Switch } from '@/components/ui/switch';
14
31
  import { Textarea } from '@/components/ui/textarea';
32
+ import {
33
+ Tooltip,
34
+ TooltipContent,
35
+ TooltipTrigger,
36
+ } from '@/components/ui/tooltip';
15
37
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
16
- import { ArrowLeft, Save, UserRound } from 'lucide-react';
38
+ import {
39
+ ArrowLeft,
40
+ Check,
41
+ ChevronsUpDown,
42
+ Info,
43
+ Save,
44
+ UserRound,
45
+ } from 'lucide-react';
46
+ import { useTranslations } from 'next-intl';
17
47
  import Link from 'next/link';
18
48
  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';
49
+ import { useEffect, useMemo, useState } from 'react';
23
50
  import { fetchOperations, mutateOperations } from '../_lib/api';
24
51
  import { useOperationsAccess } from '../_lib/hooks/use-operations-access';
25
52
  import type {
26
53
  OperationsCollaborator,
27
54
  OperationsCollaboratorDetails,
55
+ OperationsDepartment,
28
56
  OperationsWeeklyScheduleDay,
29
57
  } from '../_lib/types';
58
+ import {
59
+ formatDate,
60
+ formatDateRange,
61
+ formatEnumLabel,
62
+ formatHours,
63
+ getStatusBadgeClass,
64
+ } from '../_lib/utils/format';
30
65
  import { parseNumberInput, trimToNull } from '../_lib/utils/forms';
31
- import { formatEnumLabel } from '../_lib/utils/format';
66
+ import { DepartmentSelectWithCreate } from './department-select-with-create';
67
+ import { OperationsHeader } from './operations-header';
68
+ import { PersonSelectWithCreate } from './person-select-with-create';
69
+ import { StatusBadge } from './status-badge';
32
70
 
33
71
  const weekdays = [
34
72
  'monday',
@@ -40,11 +78,55 @@ const weekdays = [
40
78
  'sunday',
41
79
  ] as const;
42
80
 
81
+ const SUPERVISOR_PAGE_SIZE = 10;
82
+
83
+ function getPersonAvatarUrl(avatarId?: number | null) {
84
+ return typeof avatarId === 'number' && avatarId > 0
85
+ ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${avatarId}`
86
+ : '/placeholder.png';
87
+ }
88
+
89
+ function getInitials(value?: string | null) {
90
+ const parts = String(value ?? '')
91
+ .trim()
92
+ .split(/\s+/)
93
+ .filter(Boolean)
94
+ .slice(0, 2);
95
+
96
+ if (!parts.length) {
97
+ return '??';
98
+ }
99
+
100
+ return parts.map((part) => part[0]?.toUpperCase() ?? '').join('');
101
+ }
102
+
103
+ function normalizeTimeValue(value?: string | null, fallback = '') {
104
+ if (!value) {
105
+ return fallback;
106
+ }
107
+
108
+ const directMatch = String(value).match(/(\d{2}:\d{2})(?::\d{2})?/);
109
+
110
+ if (directMatch?.[1]) {
111
+ return directMatch[1];
112
+ }
113
+
114
+ const parsedDate = new Date(value);
115
+
116
+ if (!Number.isNaN(parsedDate.getTime())) {
117
+ return parsedDate.toISOString().slice(11, 16);
118
+ }
119
+
120
+ return fallback || String(value);
121
+ }
122
+
43
123
  type CollaboratorFormState = {
44
124
  userId: string;
125
+ personId: string;
45
126
  code: string;
46
127
  displayName: string;
47
128
  collaboratorType: string;
129
+ departmentId: string;
48
130
  department: string;
49
131
  title: string;
50
132
  levelLabel: string;
@@ -79,9 +161,11 @@ function defaultSchedule(): CollaboratorFormState['weeklySchedule'] {
79
161
  function buildEmptyForm(): CollaboratorFormState {
80
162
  return {
81
163
  userId: '',
164
+ personId: '',
82
165
  code: '',
83
166
  displayName: '',
84
167
  collaboratorType: 'clt',
168
+ departmentId: '',
85
169
  department: '',
86
170
  title: '',
87
171
  levelLabel: '',
@@ -110,9 +194,10 @@ function normalizeSchedule(
110
194
 
111
195
  return {
112
196
  weekday,
113
- isWorkingDay: item?.isWorkingDay ?? !['saturday', 'sunday'].includes(weekday),
114
- startTime: item?.startTime ?? '09:00',
115
- endTime: item?.endTime ?? '18:00',
197
+ isWorkingDay:
198
+ item?.isWorkingDay ?? !['saturday', 'sunday'].includes(weekday),
199
+ startTime: normalizeTimeValue(item?.startTime, '09:00'),
200
+ endTime: normalizeTimeValue(item?.endTime, '18:00'),
116
201
  breakMinutes:
117
202
  item?.breakMinutes !== null && item?.breakMinutes !== undefined
118
203
  ? String(item.breakMinutes)
@@ -126,9 +211,13 @@ function toFormState(
126
211
  ): CollaboratorFormState {
127
212
  return {
128
213
  userId: collaborator.userId ? String(collaborator.userId) : '',
214
+ personId: collaborator.personId ? String(collaborator.personId) : '',
129
215
  code: collaborator.code ?? '',
130
216
  displayName: collaborator.displayName ?? '',
131
217
  collaboratorType: collaborator.collaboratorType ?? 'other',
218
+ departmentId: collaborator.departmentId
219
+ ? String(collaborator.departmentId)
220
+ : '',
132
221
  department: collaborator.department ?? '',
133
222
  title: collaborator.title ?? '',
134
223
  levelLabel: collaborator.levelLabel ?? '',
@@ -155,23 +244,220 @@ function toFormState(
155
244
  };
156
245
  }
157
246
 
247
+ type SupervisorAutocompleteProps = {
248
+ label: string;
249
+ value: string;
250
+ options: OperationsCollaborator[];
251
+ placeholder: string;
252
+ emptyLabel: string;
253
+ onChange: (value: string) => void;
254
+ };
255
+
256
+ function SupervisorAutocomplete({
257
+ label,
258
+ value,
259
+ options,
260
+ placeholder,
261
+ emptyLabel,
262
+ onChange,
263
+ }: SupervisorAutocompleteProps) {
264
+ const commonT = useTranslations('operations.Common');
265
+ const [open, setOpen] = useState(false);
266
+ const [search, setSearch] = useState('');
267
+ const [visibleCount, setVisibleCount] = useState(SUPERVISOR_PAGE_SIZE);
268
+
269
+ const filteredOptions = useMemo(() => {
270
+ const normalizedSearch = search.trim().toLowerCase();
271
+
272
+ if (!normalizedSearch) {
273
+ return options;
274
+ }
275
+
276
+ return options.filter((option) =>
277
+ [option.displayName, option.code, option.department, option.title]
278
+ .filter(Boolean)
279
+ .some((field) => String(field).toLowerCase().includes(normalizedSearch))
280
+ );
281
+ }, [options, search]);
282
+
283
+ const selectedOption = options.find((option) => String(option.id) === value);
284
+ const visibleOptions = filteredOptions.slice(0, visibleCount);
285
+
286
+ return (
287
+ <div className="grid gap-2">
288
+ <Label>{label}</Label>
289
+
290
+ <Popover
291
+ open={open}
292
+ onOpenChange={(nextOpen) => {
293
+ setOpen(nextOpen);
294
+ setVisibleCount(SUPERVISOR_PAGE_SIZE);
295
+
296
+ if (!nextOpen) {
297
+ setSearch('');
298
+ }
299
+ }}
300
+ >
301
+ <PopoverTrigger asChild>
302
+ <Button
303
+ type="button"
304
+ variant="outline"
305
+ role="combobox"
306
+ className="w-full justify-between overflow-hidden"
307
+ >
308
+ <span className="truncate text-left">
309
+ {selectedOption?.displayName ?? emptyLabel}
310
+ </span>
311
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
312
+ </Button>
313
+ </PopoverTrigger>
314
+ <PopoverContent
315
+ className="p-0"
316
+ style={{ width: 'var(--radix-popover-trigger-width)' }}
317
+ >
318
+ <Command shouldFilter={false}>
319
+ <CommandInput
320
+ placeholder={placeholder}
321
+ value={search}
322
+ onValueChange={(value) => {
323
+ setSearch(value);
324
+ setVisibleCount(SUPERVISOR_PAGE_SIZE);
325
+ }}
326
+ />
327
+ <CommandList>
328
+ <CommandEmpty>
329
+ <div className="p-2 text-sm text-muted-foreground">
330
+ {commonT('states.emptyDescription')}
331
+ </div>
332
+ </CommandEmpty>
333
+
334
+ <CommandGroup>
335
+ <CommandItem
336
+ value="none"
337
+ onSelect={() => {
338
+ onChange('none');
339
+ setOpen(false);
340
+ }}
341
+ >
342
+ {value === 'none' ? (
343
+ <Check className="mr-2 h-4 w-4" />
344
+ ) : (
345
+ <span className="mr-2 h-4 w-4" />
346
+ )}
347
+ {emptyLabel}
348
+ </CommandItem>
349
+
350
+ {visibleOptions.map((option) => (
351
+ <CommandItem
352
+ key={option.id}
353
+ value={`${option.displayName} ${option.code ?? ''} ${option.department ?? ''} ${option.title ?? ''}`}
354
+ onSelect={() => {
355
+ onChange(String(option.id));
356
+ setOpen(false);
357
+ }}
358
+ >
359
+ {String(option.id) === value ? (
360
+ <Check className="mr-2 h-4 w-4" />
361
+ ) : (
362
+ <span className="mr-2 h-4 w-4" />
363
+ )}
364
+ <div className="min-w-0">
365
+ <div className="truncate">{option.displayName}</div>
366
+ <div className="truncate text-xs text-muted-foreground">
367
+ {[option.code, option.department, option.title]
368
+ .filter(Boolean)
369
+ .join(' • ') || commonT('labels.notAvailable')}
370
+ </div>
371
+ </div>
372
+ </CommandItem>
373
+ ))}
374
+ </CommandGroup>
375
+
376
+ {filteredOptions.length > visibleCount ? (
377
+ <div className="border-t p-2">
378
+ <Button
379
+ type="button"
380
+ variant="ghost"
381
+ className="w-full"
382
+ onClick={() =>
383
+ setVisibleCount(
384
+ (current) => current + SUPERVISOR_PAGE_SIZE
385
+ )
386
+ }
387
+ >
388
+ {commonT('actions.loadMore')}
389
+ </Button>
390
+ </div>
391
+ ) : null}
392
+ </CommandList>
393
+ </Command>
394
+ </PopoverContent>
395
+ </Popover>
396
+ </div>
397
+ );
398
+ }
399
+
158
400
  type CollaboratorFormScreenProps = {
159
401
  collaboratorId?: number;
402
+ onSaved?: (
403
+ collaborator: OperationsCollaboratorDetails
404
+ ) => void | Promise<void>;
405
+ onCancel?: () => void;
160
406
  };
161
407
 
162
408
  export function CollaboratorFormScreen({
163
409
  collaboratorId,
410
+ onSaved,
411
+ onCancel,
164
412
  }: CollaboratorFormScreenProps) {
165
413
  const t = useTranslations('operations.CollaboratorFormPage');
166
414
  const commonT = useTranslations('operations.Common');
415
+ const detailsT = useTranslations('operations.CollaboratorDetailsPage');
167
416
  const { request, showToastHandler, currentLocaleCode } = useApp();
168
417
  const access = useOperationsAccess();
169
418
  const router = useRouter();
170
419
  const [form, setForm] = useState<CollaboratorFormState>(buildEmptyForm());
420
+ const isSheetMode = Boolean(onCancel);
421
+
422
+ const getCollaboratorTypeLabel = (value?: string | null) => {
423
+ switch (value) {
424
+ case 'clt':
425
+ return t('options.collaboratorTypes.clt');
426
+ case 'pj':
427
+ return t('options.collaboratorTypes.pj');
428
+ case 'freelancer':
429
+ return t('options.collaboratorTypes.freelancer');
430
+ case 'intern':
431
+ return t('options.collaboratorTypes.intern');
432
+ case 'other':
433
+ return t('options.collaboratorTypes.other');
434
+ default:
435
+ return formatEnumLabel(value);
436
+ }
437
+ };
438
+
439
+ const getStatusLabel = (value?: string | null) => {
440
+ switch (value) {
441
+ case 'active':
442
+ return t('options.statuses.active');
443
+ case 'on_leave':
444
+ return t('options.statuses.on_leave');
445
+ case 'inactive':
446
+ return t('options.statuses.inactive');
447
+ case 'draft':
448
+ return t('options.statuses.draft');
449
+ default:
450
+ return formatEnumLabel(value);
451
+ }
452
+ };
171
453
 
172
454
  const { data: collaborator, isLoading: isLoadingCollaborator } =
173
455
  useQuery<OperationsCollaboratorDetails>({
174
- queryKey: ['operations-collaborator-form', currentLocaleCode, collaboratorId],
456
+ queryKey: [
457
+ 'operations-collaborator-form',
458
+ currentLocaleCode,
459
+ collaboratorId,
460
+ ],
175
461
  enabled: Boolean(collaboratorId),
176
462
  queryFn: () =>
177
463
  fetchOperations<OperationsCollaboratorDetails>(
@@ -184,45 +470,117 @@ export function CollaboratorFormScreen({
184
470
  queryKey: ['operations-collaborator-form-supervisors', currentLocaleCode],
185
471
  enabled: access.isDirector,
186
472
  queryFn: () =>
187
- fetchOperations<OperationsCollaborator[]>(request, '/operations/collaborators'),
473
+ fetchOperations<OperationsCollaborator[]>(
474
+ request,
475
+ '/operations/collaborators'
476
+ ),
477
+ });
478
+
479
+ const { data: departments = [] } = useQuery<OperationsDepartment[]>({
480
+ queryKey: ['operations-collaborator-form-departments', currentLocaleCode],
481
+ enabled: access.isDirector,
482
+ queryFn: () =>
483
+ fetchOperations<OperationsDepartment[]>(
484
+ request,
485
+ '/operations/departments'
486
+ ),
188
487
  });
189
488
 
190
489
  useEffect(() => {
191
490
  if (collaborator) {
491
+ // eslint-disable-next-line react-hooks/set-state-in-effect
192
492
  setForm(toFormState(collaborator));
193
493
  }
194
494
  }, [collaborator]);
195
495
 
496
+ const departmentOptions = useMemo(() => {
497
+ const selectedDepartment = trimToNull(form.department);
498
+ const options: Array<{
499
+ id?: number | null;
500
+ name: string;
501
+ code?: string | null;
502
+ description?: string | null;
503
+ }> = departments
504
+ .filter(
505
+ (item) => item.status === 'active' || item.name === selectedDepartment
506
+ )
507
+ .map((item) => ({
508
+ id: item.id,
509
+ name: item.name,
510
+ code: item.code ?? null,
511
+ description: item.description ?? null,
512
+ }));
513
+
514
+ if (selectedDepartment) {
515
+ const alreadyIncluded = options.some(
516
+ (item) => item.name === selectedDepartment
517
+ );
518
+
519
+ if (!alreadyIncluded) {
520
+ options.push({
521
+ id: form.departmentId ? Number(form.departmentId) : undefined,
522
+ name: selectedDepartment,
523
+ code: null,
524
+ description: null,
525
+ });
526
+ }
527
+ }
528
+
529
+ return options.sort((left, right) => left.name.localeCompare(right.name));
530
+ }, [departments, form.department, form.departmentId]);
531
+
196
532
  const updateScheduleDay = (
197
533
  weekday: string,
198
534
  patch: Partial<CollaboratorFormState['weeklySchedule'][number]>
199
535
  ) => {
200
536
  setForm((current) => ({
201
537
  ...current,
202
- weeklySchedule: current.weeklySchedule.map((day) =>
203
- day.weekday === weekday ? { ...day, ...patch } : day
204
- ),
538
+ weeklySchedule: current.weeklySchedule.map((day) => {
539
+ if (day.weekday !== weekday) {
540
+ return day;
541
+ }
542
+
543
+ const nextIsWorkingDay = patch.isWorkingDay ?? day.isWorkingDay;
544
+
545
+ return {
546
+ ...day,
547
+ ...patch,
548
+ startTime:
549
+ patch.startTime !== undefined
550
+ ? normalizeTimeValue(patch.startTime)
551
+ : nextIsWorkingDay
552
+ ? normalizeTimeValue(day.startTime, '09:00')
553
+ : normalizeTimeValue(day.startTime),
554
+ endTime:
555
+ patch.endTime !== undefined
556
+ ? normalizeTimeValue(patch.endTime)
557
+ : nextIsWorkingDay
558
+ ? normalizeTimeValue(day.endTime, '18:00')
559
+ : normalizeTimeValue(day.endTime),
560
+ };
561
+ }),
205
562
  }));
206
563
  };
207
564
 
208
565
  const onSubmit = async () => {
209
566
  const userId = parseNumberInput(form.userId);
210
- if (!collaboratorId && !userId) {
211
- showToastHandler?.('error', t('messages.userRequired'));
212
- return;
213
- }
567
+ const personId = parseNumberInput(form.personId);
214
568
 
215
- if (!form.code.trim() || !form.displayName.trim()) {
216
- showToastHandler?.('error', t('messages.requiredFields'));
569
+ if (!personId) {
570
+ showToastHandler?.('error', t('messages.personRequired'));
217
571
  return;
218
572
  }
219
573
 
574
+ const departmentId = parseNumberInput(form.departmentId);
575
+
220
576
  const payload = {
221
577
  userId: userId ?? undefined,
222
- code: form.code.trim(),
223
- displayName: form.displayName.trim(),
578
+ personId: personId ?? undefined,
579
+ code: trimToNull(form.code) ?? undefined,
580
+ displayName: trimToNull(form.displayName),
224
581
  collaboratorType: form.collaboratorType,
225
- department: trimToNull(form.department),
582
+ departmentId: departmentId ?? undefined,
583
+ department: departmentId ? undefined : trimToNull(form.department),
226
584
  title: trimToNull(form.title),
227
585
  levelLabel: trimToNull(form.levelLabel),
228
586
  weeklyCapacityHours: parseNumberInput(form.weeklyCapacityHours),
@@ -240,8 +598,12 @@ export function CollaboratorFormScreen({
240
598
  weeklySchedule: form.weeklySchedule.map((day) => ({
241
599
  weekday: day.weekday,
242
600
  isWorkingDay: day.isWorkingDay,
243
- startTime: day.isWorkingDay ? trimToNull(day.startTime) : null,
244
- endTime: day.isWorkingDay ? trimToNull(day.endTime) : null,
601
+ startTime: day.isWorkingDay
602
+ ? trimToNull(normalizeTimeValue(day.startTime, '09:00'))
603
+ : null,
604
+ endTime: day.isWorkingDay
605
+ ? trimToNull(normalizeTimeValue(day.endTime, '18:00'))
606
+ : null,
245
607
  breakMinutes: day.isWorkingDay ? parseNumberInput(day.breakMinutes) : 0,
246
608
  })),
247
609
  };
@@ -263,8 +625,16 @@ export function CollaboratorFormScreen({
263
625
 
264
626
  showToastHandler?.(
265
627
  'success',
266
- collaboratorId ? t('messages.updateSuccess') : t('messages.createSuccess')
628
+ collaboratorId
629
+ ? t('messages.updateSuccess')
630
+ : t('messages.createSuccess')
267
631
  );
632
+
633
+ if (onSaved) {
634
+ await onSaved(response);
635
+ return;
636
+ }
637
+
268
638
  router.push(`/operations/collaborators/${response.id}`);
269
639
  } catch {
270
640
  showToastHandler?.(
@@ -274,7 +644,99 @@ export function CollaboratorFormScreen({
274
644
  }
275
645
  };
276
646
 
647
+ const noAccessState = (
648
+ <EmptyState
649
+ icon={<UserRound className="size-12" />}
650
+ title={commonT('states.noAccessTitle')}
651
+ description={t('noAccessDescription')}
652
+ actionLabel={commonT('actions.refresh')}
653
+ onAction={() => router.refresh()}
654
+ />
655
+ );
656
+
657
+ const collaboratorSummary =
658
+ collaborator && isSheetMode ? (
659
+ <div className="mx-4 rounded-xl border bg-muted/30 px-4 py-3">
660
+ <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
661
+ <div className="flex min-w-0 items-center gap-3">
662
+ <Avatar className="h-12 w-12 border border-border/60 bg-background">
663
+ <AvatarImage
664
+ src={getPersonAvatarUrl(collaborator.personAvatarId)}
665
+ alt={collaborator.displayName}
666
+ />
667
+ <AvatarFallback className="bg-muted text-sm font-semibold text-foreground">
668
+ {getInitials(collaborator.displayName)}
669
+ </AvatarFallback>
670
+ </Avatar>
671
+ <div className="min-w-0">
672
+ <div className="truncate text-base font-semibold">
673
+ {collaborator.displayName}
674
+ </div>
675
+ <div className="truncate text-xs text-muted-foreground">
676
+ {[
677
+ collaborator.code,
678
+ collaborator.department,
679
+ collaborator.title,
680
+ ]
681
+ .filter(Boolean)
682
+ .join(' • ') || commonT('labels.notAvailable')}
683
+ </div>
684
+ </div>
685
+ </div>
686
+
687
+ <div className="flex flex-wrap gap-2">
688
+ <StatusBadge
689
+ label={getStatusLabel(collaborator.status)}
690
+ className={getStatusBadgeClass(collaborator.status)}
691
+ />
692
+ <span className="inline-flex items-center rounded-full bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary">
693
+ {getCollaboratorTypeLabel(collaborator.collaboratorType)}
694
+ </span>
695
+ </div>
696
+ </div>
697
+
698
+ <div className="mt-3 grid gap-2 text-sm md:grid-cols-2 xl:grid-cols-4">
699
+ <div>
700
+ <div className="text-[11px] uppercase tracking-wide text-muted-foreground">
701
+ {commonT('labels.supervisor')}
702
+ </div>
703
+ <div className="font-medium">
704
+ {collaborator.supervisorName || commonT('labels.notAssigned')}
705
+ </div>
706
+ </div>
707
+ <div>
708
+ <div className="text-[11px] uppercase tracking-wide text-muted-foreground">
709
+ {commonT('labels.weeklyCapacity')}
710
+ </div>
711
+ <div className="font-medium">
712
+ {formatHours(collaborator.weeklyCapacityHours)}
713
+ </div>
714
+ </div>
715
+ <div>
716
+ <div className="text-[11px] uppercase tracking-wide text-muted-foreground">
717
+ {commonT('labels.startDate')}
718
+ </div>
719
+ <div className="font-medium">
720
+ {formatDate(collaborator.joinedAt)}
721
+ </div>
722
+ </div>
723
+ <div>
724
+ <div className="text-[11px] uppercase tracking-wide text-muted-foreground">
725
+ {commonT('labels.projectCount')}
726
+ </div>
727
+ <div className="font-medium">
728
+ {collaborator.assignedProjects.length}
729
+ </div>
730
+ </div>
731
+ </div>
732
+ </div>
733
+ ) : null;
734
+
277
735
  if (!access.isDirector && !access.isLoading) {
736
+ if (isSheetMode) {
737
+ return <div className="pt-4">{noAccessState}</div>;
738
+ }
739
+
278
740
  return (
279
741
  <Page>
280
742
  <OperationsHeader
@@ -282,334 +744,411 @@ export function CollaboratorFormScreen({
282
744
  description={t('description')}
283
745
  current={t('breadcrumb')}
284
746
  />
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
- />
747
+ {noAccessState}
292
748
  </Page>
293
749
  );
294
750
  }
295
751
 
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>
752
+ const basicInfoSection = (
753
+ <div className="space-y-2">
754
+ <div className="space-y-0.5">
755
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
756
+ {t('sections.basicInfo')}
757
+ </h3>
758
+ <p className="text-[11px] text-muted-foreground/80">
759
+ {t('sections.basicInfoDescription')}
760
+ </p>
761
+ </div>
762
+ <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
763
+ <div className="space-y-2 md:col-span-2 xl:col-span-4">
764
+ <PersonSelectWithCreate
765
+ label={t('fields.person')}
766
+ entityLabel={t('fields.personEntityLabel')}
767
+ value={form.personId ? Number(form.personId) : null}
768
+ initialSelectedLabel={form.displayName}
769
+ selectPlaceholder={t('placeholders.person')}
770
+ onChange={(personId, personName) =>
771
+ setForm((current) => ({
772
+ ...current,
773
+ personId: personId ? String(personId) : '',
774
+ displayName: personName,
775
+ }))
776
+ }
777
+ />
778
+ </div>
779
+ <div className="space-y-2">
780
+ <Label>{t('fields.code')}</Label>
781
+ <Input
782
+ className="h-10"
783
+ value={form.code}
784
+ placeholder="COL-001"
785
+ onChange={(event) =>
786
+ setForm((current) => ({
787
+ ...current,
788
+ code: event.target.value,
789
+ }))
790
+ }
791
+ />
792
+ </div>
793
+ <div className="space-y-2">
794
+ <Label>{t('fields.levelLabel')}</Label>
795
+ <Input
796
+ className="h-10"
797
+ value={form.levelLabel}
798
+ onChange={(event) =>
799
+ setForm((current) => ({
800
+ ...current,
801
+ levelLabel: event.target.value,
802
+ }))
803
+ }
804
+ />
805
+ </div>
806
+ <div className="space-y-2 xl:col-span-1">
807
+ <DepartmentSelectWithCreate
808
+ label={t('fields.department')}
809
+ value={form.department}
810
+ options={departmentOptions}
811
+ selectPlaceholder={t('placeholders.department')}
812
+ createDescription={t('fields.departmentDescription')}
813
+ createPlaceholder={t('placeholders.departmentCreate')}
814
+ onChange={(department) =>
815
+ setForm((current) => ({
816
+ ...current,
817
+ departmentId: department.id ? String(department.id) : '',
818
+ department: department.name,
819
+ }))
820
+ }
821
+ />
822
+ </div>
823
+ <div className="space-y-2 xl:col-span-1">
824
+ <Label>{t('fields.title')}</Label>
825
+ <Input
826
+ className="h-10"
827
+ value={form.title}
828
+ onChange={(event) =>
829
+ setForm((current) => ({
830
+ ...current,
831
+ title: event.target.value,
832
+ }))
833
+ }
834
+ />
835
+ </div>
836
+ </div>
837
+ </div>
838
+ );
839
+
840
+ const employmentInfoSection = (
841
+ <div className="space-y-2">
842
+ <div className="space-y-0.5">
843
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
844
+ {t('sections.employmentInfo')}
845
+ </h3>
846
+ <p className="text-[11px] text-muted-foreground/80">
847
+ {t('sections.employmentInfoDescription')}
848
+ </p>
849
+ </div>
850
+ <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
851
+ <div className="space-y-2">
852
+ <label className="text-sm font-medium">
853
+ {t('fields.collaboratorType')}
854
+ </label>
855
+ <Select
856
+ value={form.collaboratorType}
857
+ onValueChange={(value) =>
858
+ setForm((current) => ({
859
+ ...current,
860
+ collaboratorType: value,
861
+ }))
862
+ }
863
+ >
864
+ <SelectTrigger className="w-full">
865
+ <SelectValue />
866
+ </SelectTrigger>
867
+ <SelectContent>
868
+ <SelectItem value="clt">
869
+ {t('options.collaboratorTypes.clt')}
870
+ </SelectItem>
871
+ <SelectItem value="pj">
872
+ {t('options.collaboratorTypes.pj')}
873
+ </SelectItem>
874
+ <SelectItem value="freelancer">
875
+ {t('options.collaboratorTypes.freelancer')}
876
+ </SelectItem>
877
+ <SelectItem value="intern">
878
+ {t('options.collaboratorTypes.intern')}
879
+ </SelectItem>
880
+ <SelectItem value="other">
881
+ {t('options.collaboratorTypes.other')}
882
+ </SelectItem>
883
+ </SelectContent>
884
+ </Select>
885
+ </div>
886
+ <div className="space-y-2">
887
+ <label className="text-sm font-medium">
888
+ {commonT('labels.status')}
889
+ </label>
890
+ <Select
891
+ value={form.status}
892
+ onValueChange={(value) =>
893
+ setForm((current) => ({ ...current, status: value }))
894
+ }
895
+ >
896
+ <SelectTrigger className="w-full">
897
+ <SelectValue />
898
+ </SelectTrigger>
899
+ <SelectContent>
900
+ <SelectItem value="active">
901
+ {t('options.statuses.active')}
902
+ </SelectItem>
903
+ <SelectItem value="on_leave">
904
+ {t('options.statuses.on_leave')}
905
+ </SelectItem>
906
+ <SelectItem value="inactive">
907
+ {t('options.statuses.inactive')}
908
+ </SelectItem>
909
+ <SelectItem value="draft">
910
+ {t('options.statuses.draft')}
911
+ </SelectItem>
912
+ </SelectContent>
913
+ </Select>
914
+ </div>
915
+ <div className="space-y-2">
916
+ <label className="text-sm font-medium">
917
+ {commonT('labels.startDate')}
918
+ </label>
919
+ <Input
920
+ type="date"
921
+ value={form.joinedAt}
922
+ onChange={(event) =>
923
+ setForm((current) => ({
924
+ ...current,
925
+ joinedAt: event.target.value,
926
+ }))
927
+ }
928
+ />
929
+ </div>
930
+ <div className="space-y-2">
931
+ <label className="text-sm font-medium">
932
+ {commonT('labels.endDate')}
933
+ </label>
934
+ <Input
935
+ type="date"
936
+ value={form.leftAt}
937
+ onChange={(event) =>
938
+ setForm((current) => ({
939
+ ...current,
940
+ leftAt: event.target.value,
941
+ }))
942
+ }
943
+ />
944
+ </div>
945
+ </div>
946
+ </div>
947
+ );
948
+
949
+ const supervisorSection = (
950
+ <div className="space-y-2">
951
+ <div className="space-y-0.5">
952
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
953
+ {t('sections.supervisor')}
954
+ </h3>
955
+ <p className="text-[11px] text-muted-foreground/80">
956
+ {t('sections.supervisorDescription')}
957
+ </p>
958
+ </div>
959
+ <SupervisorAutocomplete
960
+ label={commonT('labels.supervisor')}
961
+ value={form.supervisorCollaboratorId}
962
+ options={collaborators.filter((item) => item.id !== collaboratorId)}
963
+ placeholder={t('placeholders.supervisor')}
964
+ emptyLabel={commonT('labels.notAssigned')}
965
+ onChange={(value) =>
966
+ setForm((current) => ({
967
+ ...current,
968
+ supervisorCollaboratorId: value,
969
+ }))
321
970
  }
322
971
  />
972
+ </div>
973
+ );
323
974
 
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
- />
975
+ const contractSection = (
976
+ <div className="space-y-2">
977
+ <div className="space-y-0.5">
978
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
979
+ {t('sections.contract')}
980
+ </h3>
981
+ <p className="text-[11px] text-muted-foreground/80">
982
+ {t('sections.contractDescription')}
983
+ </p>
984
+ </div>
985
+ <div className="grid gap-3 md:grid-cols-2">
986
+ <div className="space-y-2">
987
+ <label className="text-sm font-medium">
988
+ {t('fields.weeklyCapacityHours')}
989
+ </label>
990
+ <Input
991
+ type="number"
992
+ step="0.5"
993
+ value={form.weeklyCapacityHours}
994
+ onChange={(event) =>
995
+ setForm((current) => ({
996
+ ...current,
997
+ weeklyCapacityHours: event.target.value,
998
+ }))
999
+ }
1000
+ />
1001
+ </div>
1002
+ <div className="space-y-2">
1003
+ <label className="text-sm font-medium">
1004
+ {t('fields.compensationAmount')}
1005
+ </label>
1006
+ <InputMoney
1007
+ step="0.01"
1008
+ value={form.compensationAmount}
1009
+ onChange={(event) =>
1010
+ setForm((current) => ({
1011
+ ...current,
1012
+ compensationAmount: event.target.value,
1013
+ }))
1014
+ }
1015
+ />
1016
+ </div>
1017
+ <div className="space-y-2 md:col-span-2">
1018
+ <label className="text-sm font-medium">
1019
+ {t('fields.contractDescription')}
1020
+ </label>
1021
+ <Textarea
1022
+ rows={4}
1023
+ value={form.contractDescription}
1024
+ onChange={(event) =>
1025
+ setForm((current) => ({
1026
+ ...current,
1027
+ contractDescription: event.target.value,
1028
+ }))
1029
+ }
1030
+ />
1031
+ </div>
1032
+ <div className="flex items-center justify-between rounded-lg border px-4 py-3 md:col-span-2">
1033
+ <div>
1034
+ <div className="font-medium">
1035
+ {t('fields.autoGenerateContractDraft')}
466
1036
  </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>
1037
+ <div className="text-sm text-muted-foreground">
1038
+ {t('fields.autoGenerateContractDraftDescription')}
500
1039
  </div>
501
1040
  </div>
502
- </SectionCard>
1041
+ <Switch
1042
+ checked={form.autoGenerateContractDraft}
1043
+ onCheckedChange={(checked) =>
1044
+ setForm((current) => ({
1045
+ ...current,
1046
+ autoGenerateContractDraft: checked,
1047
+ }))
1048
+ }
1049
+ />
1050
+ </div>
1051
+ </div>
1052
+ </div>
1053
+ );
503
1054
 
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>
1055
+ const scheduleSection = (
1056
+ <div className="space-y-2">
1057
+ <div className="space-y-0.5">
1058
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
1059
+ {t('sections.schedule')}
1060
+ </h3>
1061
+ <p className="text-[11px] text-muted-foreground/80">
1062
+ {t('sections.scheduleDescription')}
1063
+ </p>
1064
+ </div>
1065
+ <div className="space-y-1.5">
1066
+ {form.weeklySchedule.map((day) => (
1067
+ <div
1068
+ key={day.weekday}
1069
+ className="grid gap-2 rounded-md border px-3 py-2 md:grid-cols-[minmax(108px,0.9fr)_auto_repeat(3,minmax(88px,1fr))] md:items-center"
1070
+ >
1071
+ <div className="space-y-0.5 md:pr-1">
1072
+ <div className="text-sm font-medium leading-none">
1073
+ {formatEnumLabel(day.weekday)}
556
1074
  </div>
1075
+ <div className="text-[10px] leading-none text-muted-foreground">
1076
+ {day.isWorkingDay
1077
+ ? commonT('labels.workingDay')
1078
+ : commonT('labels.dayOff')}
1079
+ </div>
1080
+ </div>
1081
+ <div className="flex items-center justify-between gap-2 rounded-md bg-muted/30 px-2 py-1.5 md:justify-center md:bg-transparent md:p-0">
1082
+ <span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground md:hidden">
1083
+ {commonT('labels.workingDay')}
1084
+ </span>
557
1085
  <Switch
558
- checked={form.autoGenerateContractDraft}
1086
+ checked={day.isWorkingDay}
559
1087
  onCheckedChange={(checked) =>
560
- setForm((current) => ({
561
- ...current,
562
- autoGenerateContractDraft: checked,
563
- }))
1088
+ updateScheduleDay(day.weekday, { isWorkingDay: checked })
564
1089
  }
565
1090
  />
566
1091
  </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
- />
1092
+ <div className="space-y-0.5">
1093
+ <div className="text-[10px] uppercase tracking-wide text-muted-foreground">
1094
+ {t('fields.startTime')}
594
1095
  </div>
595
1096
  <Input
1097
+ className="h-8"
596
1098
  type="time"
597
- value={day.startTime}
1099
+ value={normalizeTimeValue(
1100
+ day.startTime,
1101
+ day.isWorkingDay ? '09:00' : ''
1102
+ )}
598
1103
  disabled={!day.isWorkingDay}
599
1104
  onChange={(event) =>
600
- updateScheduleDay(day.weekday, { startTime: event.target.value })
1105
+ updateScheduleDay(day.weekday, {
1106
+ startTime: event.target.value,
1107
+ })
601
1108
  }
602
1109
  />
1110
+ </div>
1111
+ <div className="space-y-0.5">
1112
+ <div className="text-[10px] uppercase tracking-wide text-muted-foreground">
1113
+ {t('fields.endTime')}
1114
+ </div>
603
1115
  <Input
1116
+ className="h-8"
604
1117
  type="time"
605
- value={day.endTime}
1118
+ value={normalizeTimeValue(
1119
+ day.endTime,
1120
+ day.isWorkingDay ? '18:00' : ''
1121
+ )}
606
1122
  disabled={!day.isWorkingDay}
607
1123
  onChange={(event) =>
608
- updateScheduleDay(day.weekday, { endTime: event.target.value })
1124
+ updateScheduleDay(day.weekday, {
1125
+ endTime: event.target.value,
1126
+ })
609
1127
  }
610
1128
  />
1129
+ </div>
1130
+ <div className="space-y-0.5">
1131
+ <div className="flex items-center gap-1 text-[10px] uppercase tracking-wide text-muted-foreground">
1132
+ <span>{t('fields.breakMinutes')}</span>
1133
+ <Tooltip>
1134
+ <TooltipTrigger asChild>
1135
+ <span className="inline-flex cursor-help">
1136
+ <Info className="h-3.5 w-3.5" />
1137
+ </span>
1138
+ </TooltipTrigger>
1139
+ <TooltipContent>
1140
+ <p>{t('fields.breakMinutesDescription')}</p>
1141
+ </TooltipContent>
1142
+ </Tooltip>
1143
+ </div>
611
1144
  <Input
1145
+ className="h-8"
612
1146
  type="number"
1147
+ min="0"
1148
+ step="5"
1149
+ placeholder="60"
1150
+ title={t('fields.breakMinutesDescription')}
1151
+ aria-label={t('fields.breakMinutes')}
613
1152
  value={day.breakMinutes}
614
1153
  disabled={!day.isWorkingDay}
615
1154
  onChange={(event) =>
@@ -619,13 +1158,310 @@ export function CollaboratorFormScreen({
619
1158
  }
620
1159
  />
621
1160
  </div>
622
- ))}
1161
+ </div>
1162
+ ))}
1163
+ </div>
1164
+
1165
+ <div className="space-y-2 pt-1">
1166
+ <Label>{t('fields.notes')}</Label>
1167
+ <Textarea
1168
+ rows={4}
1169
+ value={form.notes}
1170
+ onChange={(event) =>
1171
+ setForm((current) => ({
1172
+ ...current,
1173
+ notes: event.target.value,
1174
+ }))
1175
+ }
1176
+ />
1177
+ </div>
1178
+ </div>
1179
+ );
1180
+
1181
+ const activitySection = collaborator ? (
1182
+ <div className="space-y-4">
1183
+ <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
1184
+ <div className="rounded-xl border bg-muted/20 px-4 py-3">
1185
+ <div className="text-[11px] uppercase tracking-wide text-muted-foreground">
1186
+ {detailsT('cards.timesheets')}
1187
+ </div>
1188
+ <div className="mt-1 text-xl font-semibold">
1189
+ {collaborator.timesheetSummary.totalTimesheets}
1190
+ </div>
1191
+ <p className="text-xs text-muted-foreground">
1192
+ {detailsT('cards.timesheetsDescription', {
1193
+ pending: collaborator.timesheetSummary.pendingTimesheets,
1194
+ })}
1195
+ </p>
1196
+ </div>
1197
+
1198
+ <div className="rounded-xl border bg-muted/20 px-4 py-3">
1199
+ <div className="text-[11px] uppercase tracking-wide text-muted-foreground">
1200
+ {detailsT('cards.loggedHours')}
1201
+ </div>
1202
+ <div className="mt-1 text-xl font-semibold">
1203
+ {formatHours(collaborator.timesheetSummary.totalHours)}
1204
+ </div>
1205
+ <p className="text-xs text-muted-foreground">
1206
+ {detailsT('cards.loggedHoursDescription')}
1207
+ </p>
1208
+ </div>
1209
+
1210
+ <div className="rounded-xl border bg-muted/20 px-4 py-3">
1211
+ <div className="text-[11px] uppercase tracking-wide text-muted-foreground">
1212
+ {detailsT('cards.timeOff')}
1213
+ </div>
1214
+ <div className="mt-1 text-xl font-semibold">
1215
+ {collaborator.timeOffSummary.totalRequests}
1216
+ </div>
1217
+ <p className="text-xs text-muted-foreground">
1218
+ {detailsT('cards.timeOffDescription', {
1219
+ pending: collaborator.timeOffSummary.pendingRequests,
1220
+ })}
1221
+ </p>
1222
+ </div>
1223
+
1224
+ <div className="rounded-xl border bg-muted/20 px-4 py-3">
1225
+ <div className="text-[11px] uppercase tracking-wide text-muted-foreground">
1226
+ {detailsT('sections.scheduleAdjustments')}
1227
+ </div>
1228
+ <div className="mt-1 text-xl font-semibold">
1229
+ {collaborator.scheduleAdjustmentRequests.length}
1230
+ </div>
1231
+ <p className="text-xs text-muted-foreground">
1232
+ {detailsT('sections.scheduleAdjustmentsDescription')}
1233
+ </p>
1234
+ </div>
1235
+ </div>
1236
+
1237
+ <div className="grid gap-4 xl:grid-cols-2">
1238
+ <div className="rounded-xl border px-4 py-3">
1239
+ <div className="mb-3 space-y-0.5">
1240
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
1241
+ {detailsT('sections.projects')}
1242
+ </h3>
1243
+ <p className="text-[11px] text-muted-foreground/80">
1244
+ {detailsT('sections.projectsDescription')}
1245
+ </p>
1246
+ </div>
1247
+
1248
+ {collaborator.assignedProjects.length > 0 ? (
1249
+ <div className="space-y-2">
1250
+ {collaborator.assignedProjects.slice(0, 6).map((project) => (
1251
+ <div
1252
+ key={project.id}
1253
+ className="flex flex-col gap-2 rounded-lg border px-3 py-2.5"
1254
+ >
1255
+ <div className="flex items-start justify-between gap-3">
1256
+ <div className="min-w-0">
1257
+ <div className="truncate font-medium">{project.name}</div>
1258
+ <div className="truncate text-xs text-muted-foreground">
1259
+ {[
1260
+ project.code,
1261
+ project.roleLabel || commonT('labels.notAssigned'),
1262
+ project.weeklyHours !== null &&
1263
+ project.weeklyHours !== undefined
1264
+ ? formatHours(project.weeklyHours)
1265
+ : null,
1266
+ project.allocationPercent !== null &&
1267
+ project.allocationPercent !== undefined
1268
+ ? `${project.allocationPercent}%`
1269
+ : null,
1270
+ ]
1271
+ .filter(Boolean)
1272
+ .join(' • ')}
1273
+ </div>
1274
+ </div>
1275
+ <StatusBadge
1276
+ label={formatEnumLabel(project.status)}
1277
+ className={getStatusBadgeClass(project.status)}
1278
+ />
1279
+ </div>
1280
+ <div className="text-xs text-muted-foreground">
1281
+ {formatDateRange(project.startDate, project.endDate)}
1282
+ </div>
1283
+ </div>
1284
+ ))}
1285
+ </div>
1286
+ ) : (
1287
+ <p className="text-sm text-muted-foreground">
1288
+ {detailsT('noProjects')}
1289
+ </p>
1290
+ )}
1291
+ </div>
1292
+
1293
+ <div className="space-y-4">
1294
+ <div className="rounded-xl border px-4 py-3">
1295
+ <div className="mb-3 space-y-0.5">
1296
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
1297
+ {detailsT('sections.primaryContract')}
1298
+ </h3>
1299
+ <p className="text-[11px] text-muted-foreground/80">
1300
+ {t('sections.contractDescription')}
1301
+ </p>
1302
+ </div>
1303
+
1304
+ {collaborator.relatedContracts.length > 0 ? (
1305
+ <div className="space-y-2">
1306
+ {collaborator.relatedContracts.slice(0, 3).map((contract) => (
1307
+ <Link
1308
+ key={contract.id}
1309
+ href={`/operations/contracts?edit=${contract.id}`}
1310
+ className="flex cursor-pointer items-center justify-between gap-3 rounded-lg border px-3 py-2.5 transition-colors hover:bg-muted/20"
1311
+ >
1312
+ <div className="min-w-0">
1313
+ <div className="truncate font-medium">
1314
+ {contract.name || contract.code}
1315
+ </div>
1316
+ <div className="truncate text-xs text-muted-foreground">
1317
+ {[
1318
+ contract.code,
1319
+ formatEnumLabel(contract.contractCategory),
1320
+ ]
1321
+ .filter(Boolean)
1322
+ .join(' • ')}
1323
+ </div>
1324
+ </div>
1325
+ <StatusBadge
1326
+ label={formatEnumLabel(contract.status)}
1327
+ className={getStatusBadgeClass(contract.status)}
1328
+ />
1329
+ </Link>
1330
+ ))}
1331
+ </div>
1332
+ ) : (
1333
+ <p className="text-sm text-muted-foreground">
1334
+ {detailsT('noContracts')}
1335
+ </p>
1336
+ )}
1337
+ </div>
1338
+
1339
+ <div className="rounded-xl border px-4 py-3">
1340
+ <div className="mb-3 space-y-0.5">
1341
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
1342
+ {detailsT('sections.scheduleAdjustments')}
1343
+ </h3>
1344
+ <p className="text-[11px] text-muted-foreground/80">
1345
+ {detailsT('sections.scheduleAdjustmentsDescription')}
1346
+ </p>
1347
+ </div>
1348
+
1349
+ {collaborator.scheduleAdjustmentRequests.length > 0 ? (
1350
+ <div className="space-y-2">
1351
+ {collaborator.scheduleAdjustmentRequests
1352
+ .slice(0, 4)
1353
+ .map((request) => (
1354
+ <div
1355
+ key={request.id}
1356
+ className="flex items-center justify-between gap-3 rounded-lg border px-3 py-2.5"
1357
+ >
1358
+ <div className="min-w-0">
1359
+ <div className="font-medium">
1360
+ {formatEnumLabel(request.requestScope)}
1361
+ </div>
1362
+ <div className="truncate text-xs text-muted-foreground">
1363
+ {formatDateRange(
1364
+ request.effectiveStartDate,
1365
+ request.effectiveEndDate
1366
+ )}
1367
+ </div>
1368
+ </div>
1369
+ <StatusBadge
1370
+ label={formatEnumLabel(request.status)}
1371
+ className={getStatusBadgeClass(request.status)}
1372
+ />
1373
+ </div>
1374
+ ))}
1375
+ </div>
1376
+ ) : (
1377
+ <p className="text-sm text-muted-foreground">
1378
+ {detailsT('noScheduleAdjustments')}
1379
+ </p>
1380
+ )}
1381
+ </div>
623
1382
  </div>
624
- </SectionCard>
1383
+ </div>
1384
+ </div>
1385
+ ) : null;
1386
+
1387
+ const profileContent = (
1388
+ <div className="space-y-4">
1389
+ {basicInfoSection}
1390
+ {employmentInfoSection}
1391
+ {supervisorSection}
1392
+ </div>
1393
+ );
1394
+
1395
+ const formContent = isSheetMode ? (
1396
+ <div className="space-y-4 px-4">
1397
+ {profileContent}
1398
+ {contractSection}
1399
+ {scheduleSection}
1400
+ {activitySection}
1401
+ </div>
1402
+ ) : (
1403
+ <div className="space-y-4 px-4">
1404
+ {profileContent}
1405
+ {contractSection}
1406
+ {scheduleSection}
1407
+ </div>
1408
+ );
1409
+
1410
+ const loadingState =
1411
+ collaboratorId && isLoadingCollaborator ? (
1412
+ <div className="text-sm text-muted-foreground">{t('loading')}</div>
1413
+ ) : null;
1414
+
1415
+ if (isSheetMode) {
1416
+ return (
1417
+ <div className="mt-6 space-y-4 pb-6">
1418
+ {collaboratorSummary}
1419
+ {formContent}
1420
+ {loadingState}
1421
+
1422
+ <FormActions
1423
+ sheet
1424
+ cancelLabel={commonT('actions.cancel')}
1425
+ onCancel={onCancel}
1426
+ onSubmit={() => void onSubmit()}
1427
+ submitIcon={<Save className="size-4" />}
1428
+ submitLabel={commonT('actions.save')}
1429
+ submitSize="lg"
1430
+ />
1431
+ </div>
1432
+ );
1433
+ }
1434
+
1435
+ return (
1436
+ <Page>
1437
+ <OperationsHeader
1438
+ title={t(collaboratorId ? 'editTitle' : 'newTitle')}
1439
+ description={t('description')}
1440
+ current={t('breadcrumb')}
1441
+ actions={
1442
+ <div className="flex gap-2">
1443
+ <Button variant="outline" size="sm" asChild>
1444
+ <Link
1445
+ href={
1446
+ collaboratorId
1447
+ ? `/operations/collaborators/${collaboratorId}`
1448
+ : '/operations/collaborators'
1449
+ }
1450
+ >
1451
+ <ArrowLeft className="size-4" />
1452
+ {commonT('actions.back')}
1453
+ </Link>
1454
+ </Button>
1455
+ <Button size="sm" onClick={() => void onSubmit()}>
1456
+ <Save className="size-4" />
1457
+ {commonT('actions.save')}
1458
+ </Button>
1459
+ </div>
1460
+ }
1461
+ />
625
1462
 
626
- {collaboratorId && isLoadingCollaborator ? (
627
- <div className="text-sm text-muted-foreground">{t('loading')}</div>
628
- ) : null}
1463
+ {formContent}
1464
+ {loadingState}
629
1465
  </Page>
630
1466
  );
631
1467
  }