@hed-hog/operations 0.0.305 → 0.0.309

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 (138) hide show
  1. package/dist/controllers/operations-approvals.controller.d.ts +114 -1
  2. package/dist/controllers/operations-approvals.controller.d.ts.map +1 -1
  3. package/dist/controllers/operations-approvals.controller.js +16 -3
  4. package/dist/controllers/operations-approvals.controller.js.map +1 -1
  5. package/dist/controllers/operations-collaborators.controller.d.ts +16 -1
  6. package/dist/controllers/operations-collaborators.controller.d.ts.map +1 -1
  7. package/dist/controllers/operations-collaborators.controller.js +16 -3
  8. package/dist/controllers/operations-collaborators.controller.js.map +1 -1
  9. package/dist/controllers/operations-contracts.controller.d.ts +14 -453
  10. package/dist/controllers/operations-contracts.controller.d.ts.map +1 -1
  11. package/dist/controllers/operations-contracts.controller.js +11 -112
  12. package/dist/controllers/operations-contracts.controller.js.map +1 -1
  13. package/dist/controllers/operations-org-structure.controller.d.ts +65 -2
  14. package/dist/controllers/operations-org-structure.controller.d.ts.map +1 -1
  15. package/dist/controllers/operations-org-structure.controller.js +18 -5
  16. package/dist/controllers/operations-org-structure.controller.js.map +1 -1
  17. package/dist/controllers/operations-projects.controller.d.ts +28 -4
  18. package/dist/controllers/operations-projects.controller.d.ts.map +1 -1
  19. package/dist/controllers/operations-projects.controller.js +17 -5
  20. package/dist/controllers/operations-projects.controller.js.map +1 -1
  21. package/dist/controllers/operations-timesheets.controller.d.ts +52 -4
  22. package/dist/controllers/operations-timesheets.controller.d.ts.map +1 -1
  23. package/dist/controllers/operations-timesheets.controller.js +28 -11
  24. package/dist/controllers/operations-timesheets.controller.js.map +1 -1
  25. package/dist/dto/list-approvals.dto.d.ts +6 -0
  26. package/dist/dto/list-approvals.dto.d.ts.map +1 -0
  27. package/dist/dto/list-approvals.dto.js +28 -0
  28. package/dist/dto/list-approvals.dto.js.map +1 -0
  29. package/dist/dto/list-collaborator-types.dto.d.ts +3 -1
  30. package/dist/dto/list-collaborator-types.dto.d.ts.map +1 -1
  31. package/dist/dto/list-collaborator-types.dto.js +7 -1
  32. package/dist/dto/list-collaborator-types.dto.js.map +1 -1
  33. package/dist/dto/list-collaborators.dto.d.ts +1 -0
  34. package/dist/dto/list-collaborators.dto.d.ts.map +1 -1
  35. package/dist/dto/list-collaborators.dto.js +5 -0
  36. package/dist/dto/list-collaborators.dto.js.map +1 -1
  37. package/dist/dto/list-contracts.dto.d.ts +8 -0
  38. package/dist/dto/list-contracts.dto.d.ts.map +1 -0
  39. package/dist/dto/list-contracts.dto.js +38 -0
  40. package/dist/dto/list-contracts.dto.js.map +1 -0
  41. package/dist/dto/list-departments.dto.d.ts +5 -0
  42. package/dist/dto/list-departments.dto.d.ts.map +1 -0
  43. package/dist/dto/list-departments.dto.js +23 -0
  44. package/dist/dto/list-departments.dto.js.map +1 -0
  45. package/dist/dto/list-projects.dto.d.ts +5 -0
  46. package/dist/dto/list-projects.dto.d.ts.map +1 -0
  47. package/dist/dto/list-projects.dto.js +23 -0
  48. package/dist/dto/list-projects.dto.js.map +1 -0
  49. package/dist/dto/list-schedule-adjustments.dto.d.ts +5 -0
  50. package/dist/dto/list-schedule-adjustments.dto.d.ts.map +1 -0
  51. package/dist/dto/list-schedule-adjustments.dto.js +23 -0
  52. package/dist/dto/list-schedule-adjustments.dto.js.map +1 -0
  53. package/dist/dto/list-time-off-requests.dto.d.ts +5 -0
  54. package/dist/dto/list-time-off-requests.dto.d.ts.map +1 -0
  55. package/dist/dto/list-time-off-requests.dto.js +23 -0
  56. package/dist/dto/list-time-off-requests.dto.js.map +1 -0
  57. package/dist/dto/list-timesheets.dto.d.ts +5 -0
  58. package/dist/dto/list-timesheets.dto.d.ts.map +1 -0
  59. package/dist/dto/list-timesheets.dto.js +23 -0
  60. package/dist/dto/list-timesheets.dto.js.map +1 -0
  61. package/dist/dto/reorder-collaborator-types.dto.d.ts +4 -0
  62. package/dist/dto/reorder-collaborator-types.dto.d.ts.map +1 -0
  63. package/dist/dto/reorder-collaborator-types.dto.js +25 -0
  64. package/dist/dto/reorder-collaborator-types.dto.js.map +1 -0
  65. package/dist/dto/update-collaborator-type.dto.d.ts +3 -1
  66. package/dist/dto/update-collaborator-type.dto.d.ts.map +1 -1
  67. package/dist/dto/update-collaborator-type.dto.js +2 -1
  68. package/dist/dto/update-collaborator-type.dto.js.map +1 -1
  69. package/dist/operations.service.d.ts +362 -271
  70. package/dist/operations.service.d.ts.map +1 -1
  71. package/dist/operations.service.js +1195 -1098
  72. package/dist/operations.service.js.map +1 -1
  73. package/dist/operations.service.spec.js +73 -22
  74. package/dist/operations.service.spec.js.map +1 -1
  75. package/hedhog/data/menu.yaml +19 -55
  76. package/hedhog/data/operations_collaborator_type.yaml +76 -76
  77. package/hedhog/data/route.yaml +52 -70
  78. package/hedhog/frontend/app/_components/async-options-combobox.tsx.ejs +5 -3
  79. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +8 -1
  80. package/hedhog/frontend/app/_components/collaborator-select-with-create.tsx.ejs +15 -10
  81. package/hedhog/frontend/app/_components/contract-details-screen.tsx.ejs +108 -213
  82. package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +251 -2039
  83. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +167 -60
  84. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +70 -301
  85. package/hedhog/frontend/app/_components/system-user-select-with-create.tsx.ejs +102 -51
  86. package/hedhog/frontend/app/_components/timesheet-task-create-sheet.tsx.ejs +1 -0
  87. package/hedhog/frontend/app/_lib/types.ts.ejs +19 -24
  88. package/hedhog/frontend/app/_lib/utils/format.ts.ejs +14 -9
  89. package/hedhog/frontend/app/approvals/page.tsx.ejs +843 -151
  90. package/hedhog/frontend/app/collaborator-types/page.tsx.ejs +457 -154
  91. package/hedhog/frontend/app/collaborators/page.tsx.ejs +118 -49
  92. package/hedhog/frontend/app/contracts/[id]/page.tsx.ejs +2 -2
  93. package/hedhog/frontend/app/contracts/page.tsx.ejs +215 -617
  94. package/hedhog/frontend/app/departments/page.tsx.ejs +257 -113
  95. package/hedhog/frontend/app/projects/page.tsx.ejs +90 -51
  96. package/hedhog/frontend/app/schedule-adjustments/page.tsx.ejs +546 -118
  97. package/hedhog/frontend/app/time-off/page.tsx.ejs +400 -123
  98. package/hedhog/frontend/app/timesheets/page.tsx.ejs +647 -342
  99. package/hedhog/frontend/messages/en.json +148 -14
  100. package/hedhog/frontend/messages/pt.json +199 -56
  101. package/hedhog/table/operations_collaborator.yaml +18 -18
  102. package/hedhog/table/operations_collaborator_equity_participation.yaml +43 -43
  103. package/hedhog/table/operations_collaborator_type.yaml +33 -33
  104. package/hedhog/table/operations_contract.yaml +0 -9
  105. package/hedhog/table/operations_contract_document.yaml +33 -33
  106. package/package.json +4 -4
  107. package/src/controllers/operations-approvals.controller.ts +9 -3
  108. package/src/controllers/operations-collaborators.controller.ts +15 -2
  109. package/src/controllers/operations-contracts.controller.ts +8 -92
  110. package/src/controllers/operations-org-structure.controller.ts +17 -4
  111. package/src/controllers/operations-projects.controller.ts +10 -4
  112. package/src/controllers/operations-timesheets.controller.ts +30 -8
  113. package/src/dto/create-collaborator-type.dto.ts +43 -43
  114. package/src/dto/create-collaborator.dto.ts +223 -223
  115. package/src/dto/list-approvals.dto.ts +12 -0
  116. package/src/dto/list-collaborator-types.dto.ts +20 -15
  117. package/src/dto/list-collaborators.dto.ts +34 -30
  118. package/src/dto/list-contracts.dto.ts +20 -0
  119. package/src/dto/list-departments.dto.ts +8 -0
  120. package/src/dto/list-projects.dto.ts +8 -0
  121. package/src/dto/list-schedule-adjustments.dto.ts +8 -0
  122. package/src/dto/list-time-off-requests.dto.ts +8 -0
  123. package/src/dto/list-timesheets.dto.ts +8 -0
  124. package/src/dto/reorder-collaborator-types.dto.ts +10 -0
  125. package/src/dto/update-collaborator-type.dto.ts +4 -3
  126. package/src/dto/update-collaborator.dto.ts +3 -3
  127. package/src/operations.service.spec.ts +96 -30
  128. package/src/operations.service.ts +1738 -1777
  129. package/hedhog/frontend/app/_components/contract-creation-wizard.tsx.ejs +0 -631
  130. package/hedhog/frontend/app/_components/contract-template-form-screen.tsx.ejs +0 -526
  131. package/hedhog/frontend/app/_components/contract-template-select-with-create.tsx.ejs +0 -247
  132. package/hedhog/frontend/app/_components/contract-wizard-sheet.tsx.ejs +0 -3520
  133. package/hedhog/frontend/app/contracts/templates/page.tsx.ejs +0 -380
  134. package/hedhog/frontend/app/team/page.tsx.ejs +0 -352
  135. package/hedhog/table/operations_contract_financial_term.yaml +0 -40
  136. package/hedhog/table/operations_contract_revision.yaml +0 -38
  137. package/hedhog/table/operations_contract_signature.yaml +0 -38
  138. package/hedhog/table/operations_contract_template.yaml +0 -58
@@ -1,7 +1,13 @@
1
1
  'use client';
2
2
 
3
- import { EmptyState, Page, SearchBar } from '@/components/entity-list';
3
+ import {
4
+ EmptyState,
5
+ Page,
6
+ PaginationFooter,
7
+ SearchBar,
8
+ } from '@/components/entity-list';
4
9
  import { Button } from '@/components/ui/button';
10
+ import { Card, CardContent } from '@/components/ui/card';
5
11
  import {
6
12
  Dialog,
7
13
  DialogContent,
@@ -11,6 +17,13 @@ import {
11
17
  DialogTitle,
12
18
  } from '@/components/ui/dialog';
13
19
  import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
20
+ import {
21
+ Sheet,
22
+ SheetContent,
23
+ SheetDescription,
24
+ SheetHeader,
25
+ SheetTitle,
26
+ } from '@/components/ui/sheet';
14
27
  import {
15
28
  Table,
16
29
  TableBody,
@@ -20,23 +33,177 @@ import {
20
33
  TableRow,
21
34
  } from '@/components/ui/table';
22
35
  import { Textarea } from '@/components/ui/textarea';
36
+ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
23
37
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
24
- import { Check, ClipboardCheck, X } from 'lucide-react';
25
- import { useMemo, useState } from 'react';
38
+ import {
39
+ CalendarOff,
40
+ Check,
41
+ ClipboardCheck,
42
+ Clock,
43
+ Eye,
44
+ LayoutGrid,
45
+ List,
46
+ Loader2,
47
+ SlidersHorizontal,
48
+ X,
49
+ } from 'lucide-react';
26
50
  import { useTranslations } from 'next-intl';
51
+ import { useState } from 'react';
27
52
  import { OperationsHeader } from '../_components/operations-header';
28
53
  import { StatusBadge } from '../_components/status-badge';
29
54
  import { fetchOperations } from '../_lib/api';
30
55
  import { useOperationsAccess } from '../_lib/hooks/use-operations-access';
31
- import type { OperationsApproval } from '../_lib/types';
56
+ import type { OperationsApproval, PaginatedResponse } from '../_lib/types';
32
57
  import {
33
58
  formatDate,
34
59
  formatDateRange,
35
60
  formatDateTime,
36
61
  formatEnumLabel,
62
+ formatHours,
63
+ formatWeekdayLabel,
37
64
  getStatusBadgeClass,
38
65
  } from '../_lib/utils/format';
39
66
 
67
+ const WEEKDAY_ORDER = [
68
+ 'monday',
69
+ 'tuesday',
70
+ 'wednesday',
71
+ 'thursday',
72
+ 'friday',
73
+ 'saturday',
74
+ 'sunday',
75
+ ];
76
+
77
+ type ScheduleDay = {
78
+ weekday: string;
79
+ isWorkingDay: boolean;
80
+ startTime?: string | null;
81
+ endTime?: string | null;
82
+ breakMinutes?: number | null;
83
+ };
84
+
85
+ function formatTimeValue(t: string) {
86
+ if (t.includes('T')) return t.split('T')[1]?.slice(0, 5) ?? '--:--';
87
+ return t.slice(0, 5);
88
+ }
89
+
90
+ function groupScheduleLines(days: ScheduleDay[], locale: string) {
91
+ if (!days.length) return [];
92
+ const sorted = [...days].sort(
93
+ (a, b) =>
94
+ WEEKDAY_ORDER.indexOf(a.weekday.toLowerCase()) -
95
+ WEEKDAY_ORDER.indexOf(b.weekday.toLowerCase())
96
+ );
97
+ const dayAbbr = (weekday: string) =>
98
+ formatWeekdayLabel(weekday, locale).slice(0, 3);
99
+ type Group = {
100
+ days: string[];
101
+ time: string | null;
102
+ break: number | null;
103
+ isOff: boolean;
104
+ };
105
+ const groups: Group[] = [];
106
+ for (const day of sorted) {
107
+ const time =
108
+ day.isWorkingDay && day.startTime && day.endTime
109
+ ? `${formatTimeValue(day.startTime)}–${formatTimeValue(day.endTime)}`
110
+ : null;
111
+ const breakMin = day.isWorkingDay ? (day.breakMinutes ?? null) : null;
112
+ const isOff = !day.isWorkingDay;
113
+ const last = groups[groups.length - 1];
114
+ if (
115
+ last &&
116
+ last.isOff === isOff &&
117
+ last.time === time &&
118
+ last.break === breakMin
119
+ ) {
120
+ last.days.push(dayAbbr(day.weekday));
121
+ } else {
122
+ groups.push({
123
+ days: [dayAbbr(day.weekday)],
124
+ time,
125
+ break: breakMin,
126
+ isOff,
127
+ });
128
+ }
129
+ }
130
+ return groups.map((g) => ({
131
+ dayLabel:
132
+ g.days.length > 1
133
+ ? `${g.days[0]}–${g.days[g.days.length - 1]}`
134
+ : g.days[0],
135
+ time: g.time,
136
+ break: g.break,
137
+ isOff: g.isOff,
138
+ }));
139
+ }
140
+
141
+ function SchedulePanel({
142
+ days,
143
+ locale,
144
+ emptyLabel,
145
+ }: {
146
+ days: ScheduleDay[];
147
+ locale: string;
148
+ emptyLabel: string;
149
+ }) {
150
+ const lines = groupScheduleLines(days, locale);
151
+ if (!lines.length)
152
+ return <p className="text-xs text-muted-foreground">{emptyLabel}</p>;
153
+ return (
154
+ <div className="space-y-1">
155
+ {lines.map((line, i) => (
156
+ <div key={i} className="flex items-center gap-2 text-xs">
157
+ <span
158
+ className={`inline-block size-2 shrink-0 rounded-full ${
159
+ line.isOff ? 'bg-muted-foreground/30' : 'bg-emerald-500'
160
+ }`}
161
+ />
162
+ <span className="w-16 shrink-0 font-medium text-foreground">
163
+ {line.dayLabel}
164
+ </span>
165
+ {line.isOff ? (
166
+ <span className="text-muted-foreground">Folga</span>
167
+ ) : (
168
+ <span className="text-foreground">
169
+ {line.time}
170
+ {line.break ? (
171
+ <span className="ml-1 text-muted-foreground">
172
+ ({line.break}min int.)
173
+ </span>
174
+ ) : null}
175
+ </span>
176
+ )}
177
+ </div>
178
+ ))}
179
+ </div>
180
+ );
181
+ }
182
+
183
+ function formatDateLabel(value?: string | null) {
184
+ if (!value) return '—';
185
+ const match = String(value).match(/(\d{4}-\d{2}-\d{2})/);
186
+ if (!match) return value;
187
+ const date = new Date(`${match[1]}T12:00:00`);
188
+ if (Number.isNaN(date.getTime())) return value;
189
+ return new Intl.DateTimeFormat(undefined, {
190
+ day: '2-digit',
191
+ month: 'short',
192
+ year: 'numeric',
193
+ }).format(date);
194
+ }
195
+
196
+ function formatEntryDuration(minutes?: number | null, hours?: number | null) {
197
+ if (typeof minutes === 'number' && Number.isFinite(minutes) && minutes > 0) {
198
+ const h = Math.floor(minutes / 60);
199
+ const m = minutes % 60;
200
+ if (!h) return `${m}m`;
201
+ if (!m) return `${h}h`;
202
+ return `${h}h ${m}m`;
203
+ }
204
+ return formatHours(Number(hours ?? 0));
205
+ }
206
+
40
207
  type PendingDecision = {
41
208
  approval: OperationsApproval;
42
209
  action: 'approve' | 'reject';
@@ -50,9 +217,20 @@ export default function OperationsApprovalsPage() {
50
217
  const [search, setSearch] = useState('');
51
218
  const [statusFilter, setStatusFilter] = useState('all');
52
219
  const [targetFilter, setTargetFilter] = useState('all');
220
+ const [page, setPage] = useState(1);
221
+ const [pageSize, setPageSize] = useState(12);
222
+ const [viewMode, setViewMode] = useState<'table' | 'cards'>(() => {
223
+ if (typeof window === 'undefined') return 'table';
224
+ const saved = window.localStorage.getItem('operations-approvals-view-mode');
225
+ return saved === 'cards' ? 'cards' : 'table';
226
+ });
53
227
  const [decisionNote, setDecisionNote] = useState('');
54
228
  const [pendingDecision, setPendingDecision] =
55
229
  useState<PendingDecision | null>(null);
230
+ const [selectedApproval, setSelectedApproval] =
231
+ useState<OperationsApproval | null>(null);
232
+ const [isDetailsOpen, setIsDetailsOpen] = useState(false);
233
+ const [isDetailLoading, setIsDetailLoading] = useState(false);
56
234
 
57
235
  const getStatusLabel = (value?: string | null) => {
58
236
  if (!value) {
@@ -90,6 +268,32 @@ export default function OperationsApprovalsPage() {
90
268
  return t.has(key) ? t(key) : formatEnumLabel(value);
91
269
  };
92
270
 
271
+ const getTypeColorClass = (targetType: string) => {
272
+ switch (targetType) {
273
+ case 'timesheet':
274
+ return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400';
275
+ case 'time_off_request':
276
+ return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400';
277
+ case 'schedule_adjustment_request':
278
+ return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400';
279
+ default:
280
+ return 'bg-muted text-muted-foreground';
281
+ }
282
+ };
283
+
284
+ const getTypeIcon = (targetType: string) => {
285
+ switch (targetType) {
286
+ case 'timesheet':
287
+ return <Clock className="size-3" />;
288
+ case 'time_off_request':
289
+ return <CalendarOff className="size-3" />;
290
+ case 'schedule_adjustment_request':
291
+ return <SlidersHorizontal className="size-3" />;
292
+ default:
293
+ return null;
294
+ }
295
+ };
296
+
93
297
  const getTargetLabel = (approval: OperationsApproval) => {
94
298
  switch (approval.targetType) {
95
299
  case 'timesheet':
@@ -112,38 +316,44 @@ export default function OperationsApprovalsPage() {
112
316
  }
113
317
  };
114
318
 
115
- const { data: approvals = [], refetch } = useQuery<OperationsApproval[]>({
116
- queryKey: ['operations-approvals', currentLocaleCode],
319
+ const { data: approvalsResponse, refetch } = useQuery<
320
+ PaginatedResponse<OperationsApproval>
321
+ >({
322
+ queryKey: [
323
+ 'operations-approvals',
324
+ currentLocaleCode,
325
+ search,
326
+ statusFilter,
327
+ targetFilter,
328
+ page,
329
+ pageSize,
330
+ ],
117
331
  enabled: access.isSupervisor,
118
- queryFn: () =>
119
- fetchOperations<OperationsApproval[]>(request, '/operations/approvals'),
332
+ queryFn: () => {
333
+ const params = new URLSearchParams({
334
+ page: String(page),
335
+ pageSize: String(pageSize),
336
+ });
337
+ if (search.trim()) params.set('search', search.trim());
338
+ if (statusFilter !== 'all') params.set('status', statusFilter);
339
+ if (targetFilter !== 'all') params.set('targetType', targetFilter);
340
+ return fetchOperations<PaginatedResponse<OperationsApproval>>(
341
+ request,
342
+ `/operations/approvals?${params.toString()}`
343
+ );
344
+ },
345
+ placeholderData: (previous) => previous,
120
346
  });
121
347
 
122
- const filteredRows = useMemo(
123
- () =>
124
- approvals.filter((item) => {
125
- const matchesSearch = !search.trim()
126
- ? true
127
- : [
128
- item.requesterName,
129
- item.approverName,
130
- item.decisionNote,
131
- getTargetLabel(item),
132
- ]
133
- .filter(Boolean)
134
- .some((value) =>
135
- String(value)
136
- .toLowerCase()
137
- .includes(search.trim().toLowerCase())
138
- );
139
- const matchesStatus =
140
- statusFilter === 'all' ? true : item.status === statusFilter;
141
- const matchesTarget =
142
- targetFilter === 'all' ? true : item.targetType === targetFilter;
143
- return matchesSearch && matchesStatus && matchesTarget;
144
- }),
145
- [approvals, search, statusFilter, targetFilter]
146
- );
348
+ const approvals = approvalsResponse?.data ?? [];
349
+
350
+ const handleViewModeChange = (value: string) => {
351
+ if (value !== 'table' && value !== 'cards') return;
352
+ setViewMode(value as 'table' | 'cards');
353
+ if (typeof window !== 'undefined') {
354
+ window.localStorage.setItem('operations-approvals-view-mode', value);
355
+ }
356
+ };
147
357
 
148
358
  const cards = [
149
359
  {
@@ -199,6 +409,28 @@ export default function OperationsApprovalsPage() {
199
409
  }
200
410
  };
201
411
 
412
+ const openDetails = async (approval: OperationsApproval) => {
413
+ setSelectedApproval(approval);
414
+ setIsDetailsOpen(true);
415
+ if (
416
+ approval.targetType === 'timesheet' ||
417
+ approval.targetType === 'schedule_adjustment_request'
418
+ ) {
419
+ setIsDetailLoading(true);
420
+ try {
421
+ const detail = await fetchOperations<OperationsApproval>(
422
+ request,
423
+ `/operations/approvals/${approval.id}`
424
+ );
425
+ setSelectedApproval(detail);
426
+ } catch {
427
+ // keep base data if detail fetch fails
428
+ } finally {
429
+ setIsDetailLoading(false);
430
+ }
431
+ }
432
+ };
433
+
202
434
  if (!access.isSupervisor && !access.isLoading) {
203
435
  return (
204
436
  <Page>
@@ -228,140 +460,292 @@ export default function OperationsApprovalsPage() {
228
460
 
229
461
  <KpiCardsGrid items={cards} columns={3} />
230
462
 
231
- <SearchBar
232
- searchQuery={search}
233
- onSearchChange={setSearch}
234
- onSearch={() => undefined}
235
- placeholder={t('searchPlaceholder')}
236
- controls={[
237
- {
238
- id: 'status',
239
- type: 'select',
240
- value: statusFilter,
241
- onChange: setStatusFilter,
242
- placeholder: commonT('labels.status'),
243
- options: [
244
- { value: 'all', label: commonT('filters.allStatuses') },
245
- { value: 'pending', label: getStatusLabel('pending') },
246
- { value: 'approved', label: getStatusLabel('approved') },
247
- { value: 'rejected', label: getStatusLabel('rejected') },
248
- ],
249
- },
250
- {
251
- id: 'target',
252
- type: 'select',
253
- value: targetFilter,
254
- onChange: setTargetFilter,
255
- placeholder: commonT('labels.requestType'),
256
- options: [
257
- { value: 'all', label: commonT('filters.allTypes') },
258
- { value: 'timesheet', label: getTargetTypeLabel('timesheet') },
463
+ <div className="flex min-w-0 flex-col gap-4 xl:flex-row xl:items-center">
464
+ <div className="flex-1">
465
+ <SearchBar
466
+ searchQuery={search}
467
+ onSearchChange={(value) => {
468
+ setSearch(value);
469
+ setPage(1);
470
+ }}
471
+ showSearchButton={false}
472
+ debounceMs={500}
473
+ placeholder={t('searchPlaceholder')}
474
+ controls={[
259
475
  {
260
- value: 'time_off_request',
261
- label: getTargetTypeLabel('time_off_request'),
476
+ id: 'status',
477
+ type: 'select',
478
+ value: statusFilter,
479
+ onChange: (value) => {
480
+ setStatusFilter(value);
481
+ setPage(1);
482
+ },
483
+ placeholder: commonT('labels.status'),
484
+ options: [
485
+ { value: 'all', label: commonT('filters.allStatuses') },
486
+ { value: 'pending', label: getStatusLabel('pending') },
487
+ { value: 'approved', label: getStatusLabel('approved') },
488
+ { value: 'rejected', label: getStatusLabel('rejected') },
489
+ ],
262
490
  },
263
491
  {
264
- value: 'schedule_adjustment_request',
265
- label: getTargetTypeLabel('schedule_adjustment_request'),
492
+ id: 'target',
493
+ type: 'select',
494
+ value: targetFilter,
495
+ onChange: (value) => {
496
+ setTargetFilter(value);
497
+ setPage(1);
498
+ },
499
+ placeholder: commonT('labels.requestType'),
500
+ options: [
501
+ { value: 'all', label: commonT('filters.allTypes') },
502
+ {
503
+ value: 'timesheet',
504
+ label: getTargetTypeLabel('timesheet'),
505
+ },
506
+ {
507
+ value: 'time_off_request',
508
+ label: getTargetTypeLabel('time_off_request'),
509
+ },
510
+ {
511
+ value: 'schedule_adjustment_request',
512
+ label: getTargetTypeLabel('schedule_adjustment_request'),
513
+ },
514
+ ],
266
515
  },
267
- ],
268
- },
269
- ]}
270
- />
516
+ ]}
517
+ />
518
+ </div>
271
519
 
272
- {filteredRows.length > 0 ? (
273
- <div className="overflow-x-auto rounded-md border">
274
- <Table>
275
- <TableHeader>
276
- <TableRow>
277
- <TableHead>{commonT('labels.requester')}</TableHead>
278
- <TableHead>{commonT('labels.requestType')}</TableHead>
279
- <TableHead>{commonT('labels.submittedAt')}</TableHead>
280
- <TableHead>{commonT('labels.approver')}</TableHead>
281
- <TableHead>{commonT('labels.status')}</TableHead>
282
- <TableHead className="w-45 text-right">
283
- {commonT('labels.actions')}
284
- </TableHead>
285
- </TableRow>
286
- </TableHeader>
287
- <TableBody>
288
- {filteredRows.map((approval) => (
289
- <TableRow key={approval.id}>
290
- <TableCell>
291
- <div className="font-medium">{approval.requesterName}</div>
292
- <div className="text-xs text-muted-foreground">
293
- {approval.decisionNote || commonT('labels.noNotes')}
520
+ <div className="flex items-center justify-between gap-3 xl:justify-end">
521
+ <span className="text-xs font-medium text-muted-foreground">
522
+ {t('viewMode')}
523
+ </span>
524
+ <ToggleGroup
525
+ type="single"
526
+ value={viewMode}
527
+ onValueChange={handleViewModeChange}
528
+ variant="outline"
529
+ size="sm"
530
+ >
531
+ <ToggleGroupItem value="table" className="gap-1.5 px-2.5">
532
+ <List className="h-4 w-4" />
533
+ <span className="hidden sm:inline">{t('viewModeTable')}</span>
534
+ </ToggleGroupItem>
535
+ <ToggleGroupItem value="cards" className="gap-1.5 px-2.5">
536
+ <LayoutGrid className="h-4 w-4" />
537
+ <span className="hidden sm:inline">{t('viewModeCards')}</span>
538
+ </ToggleGroupItem>
539
+ </ToggleGroup>
540
+ </div>
541
+ </div>
542
+
543
+ {approvals.length > 0 ? (
544
+ viewMode === 'cards' ? (
545
+ <div className="grid gap-4 md:grid-cols-2 2xl:grid-cols-3">
546
+ {approvals.map((approval) => (
547
+ <Card
548
+ key={approval.id}
549
+ className="overflow-hidden border-border/60 py-0 shadow-sm"
550
+ >
551
+ <CardContent className="space-y-3 p-4">
552
+ <div className="flex items-start justify-between gap-3">
553
+ <div className="min-w-0">
554
+ <div className="truncate font-semibold">
555
+ {approval.requesterName}
556
+ </div>
557
+ <div className="truncate text-xs text-muted-foreground">
558
+ {getTargetLabel(approval)}
559
+ </div>
294
560
  </div>
295
- </TableCell>
296
- <TableCell>{getTargetLabel(approval)}</TableCell>
297
- <TableCell>
298
- <div>{formatDateTime(approval.submittedAt)}</div>
299
- <div className="text-xs text-muted-foreground">
300
- {formatDate(approval.decidedAt)}
561
+ <StatusBadge
562
+ label={getStatusLabel(approval.status)}
563
+ className={getStatusBadgeClass(approval.status)}
564
+ />
565
+ </div>
566
+ <div className="grid grid-cols-2 gap-2 text-sm text-muted-foreground">
567
+ <div>
568
+ <span className="font-medium text-foreground">
569
+ {commonT('labels.submittedAt')}:
570
+ </span>
571
+ <br />
572
+ {formatDateTime(approval.submittedAt)}
301
573
  </div>
302
- </TableCell>
303
- <TableCell>
304
574
  <div>
575
+ <span className="font-medium text-foreground">
576
+ {commonT('labels.approver')}:
577
+ </span>
578
+ <br />
305
579
  {approval.approverName || commonT('labels.notAssigned')}
306
580
  </div>
307
- <div className="text-xs text-muted-foreground">
308
- {approval.targetType === 'timesheet'
309
- ? [
310
- approval.timesheetProjectNames,
311
- approval.timesheetTotalHours
312
- ? `${approval.timesheetTotalHours}h`
313
- : null,
314
- ]
315
- .filter(Boolean)
316
- .join(' ') || commonT('labels.notAvailable')
317
- : approval.targetType === 'time_off_request'
318
- ? approval.timeOffReason || commonT('labels.noNotes')
319
- : approval.scheduleReason ||
320
- commonT('labels.noNotes')}
581
+ </div>
582
+ {approval.status === 'pending' ? (
583
+ <div className="flex flex-wrap justify-end gap-2 border-t border-border/60 pt-3">
584
+ <Button
585
+ size="sm"
586
+ variant="outline"
587
+ className="cursor-pointer"
588
+ onClick={() => void openDetails(approval)}
589
+ >
590
+ <Eye className="size-4" />
591
+ {t('actions.viewDetails')}
592
+ </Button>
593
+ <Button
594
+ size="sm"
595
+ variant="outline"
596
+ onClick={() => {
597
+ setPendingDecision({ approval, action: 'reject' });
598
+ setDecisionNote('');
599
+ }}
600
+ >
601
+ <X className="size-4" />
602
+ {commonT('actions.reject')}
603
+ </Button>
604
+ <Button
605
+ size="sm"
606
+ onClick={() => {
607
+ setPendingDecision({ approval, action: 'approve' });
608
+ setDecisionNote('');
609
+ }}
610
+ >
611
+ <Check className="size-4" />
612
+ {commonT('actions.approve')}
613
+ </Button>
321
614
  </div>
322
- </TableCell>
323
- <TableCell>
324
- <StatusBadge
325
- label={getStatusLabel(approval.status)}
326
- className={getStatusBadgeClass(approval.status)}
327
- />
328
- </TableCell>
329
- <TableCell>
330
- {approval.status === 'pending' ? (
331
- <div className="flex justify-end gap-2">
615
+ ) : (
616
+ <div className="flex items-center justify-between gap-2 border-t border-border/60 pt-3">
617
+ <Button
618
+ size="sm"
619
+ variant="outline"
620
+ className="cursor-pointer"
621
+ onClick={() => void openDetails(approval)}
622
+ >
623
+ <Eye className="size-4" />
624
+ {t('actions.viewDetails')}
625
+ </Button>
626
+ <div className="text-right text-xs text-muted-foreground">
627
+ {formatDateTime(approval.decidedAt)}
628
+ </div>
629
+ </div>
630
+ )}
631
+ </CardContent>
632
+ </Card>
633
+ ))}
634
+ </div>
635
+ ) : (
636
+ <div className="overflow-x-auto rounded-md border">
637
+ <Table>
638
+ <TableHeader>
639
+ <TableRow>
640
+ <TableHead>{commonT('labels.requester')}</TableHead>
641
+ <TableHead>{commonT('labels.requestType')}</TableHead>
642
+ <TableHead>{commonT('labels.submittedAt')}</TableHead>
643
+ <TableHead>{commonT('labels.approver')}</TableHead>
644
+ <TableHead>{commonT('labels.status')}</TableHead>
645
+ <TableHead className="w-45 text-right">
646
+ {commonT('labels.actions')}
647
+ </TableHead>
648
+ </TableRow>
649
+ </TableHeader>
650
+ <TableBody>
651
+ {approvals.map((approval) => (
652
+ <TableRow key={approval.id}>
653
+ <TableCell>
654
+ <div className="font-medium">
655
+ {approval.requesterName}
656
+ </div>
657
+ <div className="text-xs text-muted-foreground">
658
+ {approval.decisionNote || commonT('labels.noNotes')}
659
+ </div>
660
+ </TableCell>
661
+ <TableCell>{getTargetLabel(approval)}</TableCell>
662
+ <TableCell>
663
+ <div>{formatDateTime(approval.submittedAt)}</div>
664
+ <div className="text-xs text-muted-foreground">
665
+ {formatDate(approval.decidedAt)}
666
+ </div>
667
+ </TableCell>
668
+ <TableCell>
669
+ <div>
670
+ {approval.approverName || commonT('labels.notAssigned')}
671
+ </div>
672
+ <div className="text-xs text-muted-foreground">
673
+ {approval.targetType === 'timesheet'
674
+ ? [
675
+ approval.timesheetProjectNames,
676
+ approval.timesheetTotalHours
677
+ ? `${approval.timesheetTotalHours}h`
678
+ : null,
679
+ ]
680
+ .filter(Boolean)
681
+ .join(' • ') || commonT('labels.notAvailable')
682
+ : approval.targetType === 'time_off_request'
683
+ ? approval.timeOffReason ||
684
+ commonT('labels.noNotes')
685
+ : approval.scheduleReason ||
686
+ commonT('labels.noNotes')}
687
+ </div>
688
+ </TableCell>
689
+ <TableCell>
690
+ <StatusBadge
691
+ label={getStatusLabel(approval.status)}
692
+ className={getStatusBadgeClass(approval.status)}
693
+ />
694
+ </TableCell>
695
+ <TableCell>
696
+ <div className="flex flex-wrap justify-end gap-2">
332
697
  <Button
333
698
  size="sm"
334
699
  variant="outline"
335
- onClick={() => {
336
- setPendingDecision({ approval, action: 'reject' });
337
- setDecisionNote('');
338
- }}
339
- >
340
- <X className="size-4" />
341
- {commonT('actions.reject')}
342
- </Button>
343
- <Button
344
- size="sm"
345
- onClick={() => {
346
- setPendingDecision({ approval, action: 'approve' });
347
- setDecisionNote('');
348
- }}
700
+ className="cursor-pointer"
701
+ onClick={() => void openDetails(approval)}
349
702
  >
350
- <Check className="size-4" />
351
- {commonT('actions.approve')}
703
+ <Eye className="size-4" />
704
+ {t('actions.viewDetails')}
352
705
  </Button>
706
+ {approval.status === 'pending' ? (
707
+ <>
708
+ <Button
709
+ size="sm"
710
+ variant="outline"
711
+ onClick={() => {
712
+ setPendingDecision({
713
+ approval,
714
+ action: 'reject',
715
+ });
716
+ setDecisionNote('');
717
+ }}
718
+ >
719
+ <X className="size-4" />
720
+ {commonT('actions.reject')}
721
+ </Button>
722
+ <Button
723
+ size="sm"
724
+ onClick={() => {
725
+ setPendingDecision({
726
+ approval,
727
+ action: 'approve',
728
+ });
729
+ setDecisionNote('');
730
+ }}
731
+ >
732
+ <Check className="size-4" />
733
+ {commonT('actions.approve')}
734
+ </Button>
735
+ </>
736
+ ) : (
737
+ <div className="flex items-center text-right text-xs text-muted-foreground">
738
+ {formatDateTime(approval.decidedAt)}
739
+ </div>
740
+ )}
353
741
  </div>
354
- ) : (
355
- <div className="text-right text-xs text-muted-foreground">
356
- {formatDateTime(approval.decidedAt)}
357
- </div>
358
- )}
359
- </TableCell>
360
- </TableRow>
361
- ))}
362
- </TableBody>
363
- </Table>
364
- </div>
742
+ </TableCell>
743
+ </TableRow>
744
+ ))}
745
+ </TableBody>
746
+ </Table>
747
+ </div>
748
+ )
365
749
  ) : (
366
750
  <EmptyState
367
751
  icon={<ClipboardCheck className="size-12" />}
@@ -372,6 +756,314 @@ export default function OperationsApprovalsPage() {
372
756
  />
373
757
  )}
374
758
 
759
+ <PaginationFooter
760
+ currentPage={page}
761
+ pageSize={pageSize}
762
+ totalItems={approvalsResponse?.total ?? 0}
763
+ pageSizeOptions={[12, 24, 48]}
764
+ onPageChange={setPage}
765
+ onPageSizeChange={(size) => {
766
+ setPageSize(size);
767
+ setPage(1);
768
+ }}
769
+ />
770
+
771
+ <Sheet open={isDetailsOpen} onOpenChange={setIsDetailsOpen}>
772
+ <SheetContent className="w-full overflow-y-auto sm:max-w-2xl">
773
+ <SheetHeader>
774
+ <SheetTitle>{t('details.title')}</SheetTitle>
775
+ <SheetDescription>{t('details.description')}</SheetDescription>
776
+ </SheetHeader>
777
+
778
+ {selectedApproval ? (
779
+ <div className="mt-6 space-y-5 px-4 pb-8">
780
+ <div className="rounded-lg border bg-muted/20 p-4">
781
+ <div className="flex flex-wrap items-start justify-between gap-3">
782
+ <div>
783
+ <div className="text-lg font-semibold">
784
+ {selectedApproval.requesterName}
785
+ </div>
786
+ <div className="mt-2 flex flex-wrap items-center gap-2">
787
+ <span
788
+ className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ${getTypeColorClass(selectedApproval.targetType)}`}
789
+ >
790
+ {getTypeIcon(selectedApproval.targetType)}
791
+ {getTargetTypeLabel(selectedApproval.targetType)}
792
+ </span>
793
+ <span className="text-sm text-muted-foreground">
794
+ {selectedApproval.targetType === 'timesheet'
795
+ ? formatDateRange(
796
+ selectedApproval.timesheetWeekStartDate,
797
+ selectedApproval.timesheetWeekEndDate
798
+ )
799
+ : selectedApproval.targetType === 'time_off_request'
800
+ ? formatDateRange(
801
+ selectedApproval.timeOffStartDate,
802
+ selectedApproval.timeOffEndDate
803
+ )
804
+ : formatDateRange(
805
+ selectedApproval.scheduleStartDate,
806
+ selectedApproval.scheduleEndDate
807
+ )}
808
+ </span>
809
+ </div>
810
+ </div>
811
+ <StatusBadge
812
+ label={getStatusLabel(selectedApproval.status)}
813
+ className={getStatusBadgeClass(selectedApproval.status)}
814
+ />
815
+ </div>
816
+ </div>
817
+
818
+ <div className="grid gap-4 sm:grid-cols-2">
819
+ <div>
820
+ <div className="text-xs font-medium uppercase text-muted-foreground">
821
+ {commonT('labels.approver')}
822
+ </div>
823
+ <div className="mt-1 text-sm">
824
+ {selectedApproval.approverName ||
825
+ commonT('labels.notAssigned')}
826
+ </div>
827
+ </div>
828
+ <div>
829
+ <div className="text-xs font-medium uppercase text-muted-foreground">
830
+ {commonT('labels.submittedAt')}
831
+ </div>
832
+ <div className="mt-1 text-sm">
833
+ {formatDateTime(selectedApproval.submittedAt)}
834
+ </div>
835
+ </div>
836
+ <div>
837
+ <div className="text-xs font-medium uppercase text-muted-foreground">
838
+ {t('details.decidedAt')}
839
+ </div>
840
+ <div className="mt-1 text-sm">
841
+ {formatDateTime(selectedApproval.decidedAt) ?? '-'}
842
+ </div>
843
+ </div>
844
+ </div>
845
+
846
+ <div className="flex items-center gap-3">
847
+ <div className="flex-1 border-t" />
848
+ <span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
849
+ {selectedApproval.targetType === 'timesheet'
850
+ ? t('details.timesheetSection')
851
+ : selectedApproval.targetType === 'time_off_request'
852
+ ? t('details.timeOffSection')
853
+ : t('details.scheduleSection')}
854
+ </span>
855
+ <div className="flex-1 border-t" />
856
+ </div>
857
+
858
+ {selectedApproval.targetType === 'timesheet' ? (
859
+ <>
860
+ <div className="grid gap-4 sm:grid-cols-2">
861
+ <div>
862
+ <div className="text-xs font-medium uppercase text-muted-foreground">
863
+ {t('details.period')}
864
+ </div>
865
+ <div className="mt-1 text-sm">
866
+ {formatDateRange(
867
+ selectedApproval.timesheetWeekStartDate,
868
+ selectedApproval.timesheetWeekEndDate
869
+ )}
870
+ </div>
871
+ </div>
872
+ <div>
873
+ <div className="text-xs font-medium uppercase text-muted-foreground">
874
+ {commonT('labels.totalHours')}
875
+ </div>
876
+ <div className="mt-1 text-sm">
877
+ {selectedApproval.timesheetTotalHours != null
878
+ ? `${selectedApproval.timesheetTotalHours}h`
879
+ : commonT('labels.notAvailable')}
880
+ </div>
881
+ </div>
882
+ <div className="sm:col-span-2">
883
+ <div className="text-xs font-medium uppercase text-muted-foreground">
884
+ {t('details.projects')}
885
+ </div>
886
+ <div className="mt-1 rounded-lg border p-3 text-sm">
887
+ {selectedApproval.timesheetProjectNames ||
888
+ commonT('labels.notAvailable')}
889
+ </div>
890
+ </div>
891
+ </div>
892
+
893
+ <div>
894
+ <div className="text-xs font-medium uppercase text-muted-foreground">
895
+ {t('details.entriesTitle')}
896
+ </div>
897
+ {isDetailLoading ? (
898
+ <div className="mt-3 flex items-center gap-2 text-sm text-muted-foreground">
899
+ <Loader2 className="size-4 animate-spin" />
900
+ {t('details.loadingEntries')}
901
+ </div>
902
+ ) : (
903
+ <div className="mt-2 space-y-3">
904
+ {(selectedApproval.entries ?? []).length ? (
905
+ (selectedApproval.entries ?? []).map((entry) => (
906
+ <div
907
+ key={entry.id}
908
+ className="rounded-lg border p-3"
909
+ >
910
+ <div className="flex flex-wrap items-start justify-between gap-3">
911
+ <div>
912
+ <div className="font-medium">
913
+ {entry.taskName ||
914
+ entry.activityLabel ||
915
+ commonT('labels.unassigned')}
916
+ </div>
917
+ <div className="text-xs text-muted-foreground">
918
+ {[entry.projectName, entry.roleLabel]
919
+ .filter(Boolean)
920
+ .join(' • ') ||
921
+ commonT('labels.unassigned')}
922
+ </div>
923
+ </div>
924
+ <div className="text-sm text-muted-foreground">
925
+ {formatEntryDuration(
926
+ entry.durationMinutes,
927
+ entry.hours
928
+ )}
929
+ </div>
930
+ </div>
931
+ <div className="mt-2 text-xs text-muted-foreground">
932
+ {formatDateLabel(entry.workDate)}
933
+ </div>
934
+ <div className="mt-2 text-sm">
935
+ {entry.description || commonT('labels.noNotes')}
936
+ </div>
937
+ </div>
938
+ ))
939
+ ) : (
940
+ <div className="rounded-lg border p-3 text-sm text-muted-foreground">
941
+ {t('details.noEntries')}
942
+ </div>
943
+ )}
944
+ </div>
945
+ )}
946
+ </div>
947
+ </>
948
+ ) : null}
949
+
950
+ {selectedApproval.targetType === 'time_off_request' ? (
951
+ <div className="grid gap-4 sm:grid-cols-2">
952
+ <div>
953
+ <div className="text-xs font-medium uppercase text-muted-foreground">
954
+ {t('details.period')}
955
+ </div>
956
+ <div className="mt-1 text-sm">
957
+ {formatDateRange(
958
+ selectedApproval.timeOffStartDate,
959
+ selectedApproval.timeOffEndDate
960
+ )}
961
+ </div>
962
+ </div>
963
+ <div>
964
+ <div className="text-xs font-medium uppercase text-muted-foreground">
965
+ {commonT('labels.requestType')}
966
+ </div>
967
+ <div className="mt-1 text-sm">
968
+ {getTimeOffTypeLabel(selectedApproval.timeOffType)}
969
+ </div>
970
+ </div>
971
+ <div className="sm:col-span-2">
972
+ <div className="text-xs font-medium uppercase text-muted-foreground">
973
+ {commonT('labels.reason')}
974
+ </div>
975
+ <div className="mt-1 rounded-lg border p-3 text-sm">
976
+ {selectedApproval.timeOffReason ||
977
+ commonT('labels.noNotes')}
978
+ </div>
979
+ </div>
980
+ </div>
981
+ ) : null}
982
+
983
+ {selectedApproval.targetType === 'schedule_adjustment_request' ? (
984
+ <>
985
+ <div className="grid gap-4 sm:grid-cols-2">
986
+ <div>
987
+ <div className="text-xs font-medium uppercase text-muted-foreground">
988
+ {t('details.period')}
989
+ </div>
990
+ <div className="mt-1 text-sm">
991
+ {formatDateRange(
992
+ selectedApproval.scheduleStartDate,
993
+ selectedApproval.scheduleEndDate
994
+ )}
995
+ </div>
996
+ </div>
997
+ <div>
998
+ <div className="text-xs font-medium uppercase text-muted-foreground">
999
+ {commonT('labels.requestScope')}
1000
+ </div>
1001
+ <div className="mt-1 text-sm">
1002
+ {getScheduleScopeLabel(
1003
+ selectedApproval.scheduleRequestScope
1004
+ )}
1005
+ </div>
1006
+ </div>
1007
+ <div className="sm:col-span-2">
1008
+ <div className="text-xs font-medium uppercase text-muted-foreground">
1009
+ {commonT('labels.reason')}
1010
+ </div>
1011
+ <div className="mt-1 rounded-lg border p-3 text-sm">
1012
+ {selectedApproval.scheduleReason ||
1013
+ commonT('labels.noNotes')}
1014
+ </div>
1015
+ </div>
1016
+ </div>
1017
+
1018
+ {isDetailLoading ? (
1019
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
1020
+ <Loader2 className="size-4 animate-spin" />
1021
+ {t('details.loadingSchedule')}
1022
+ </div>
1023
+ ) : (
1024
+ <div className="grid gap-4 sm:grid-cols-2">
1025
+ <div>
1026
+ <div className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
1027
+ {t('details.currentSchedule')}
1028
+ </div>
1029
+ <div className="rounded-lg border p-3">
1030
+ <SchedulePanel
1031
+ days={selectedApproval.currentSchedule ?? []}
1032
+ locale={currentLocaleCode}
1033
+ emptyLabel={commonT('labels.notAssigned')}
1034
+ />
1035
+ </div>
1036
+ </div>
1037
+ <div>
1038
+ <div className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
1039
+ {t('details.requestedSchedule')}
1040
+ </div>
1041
+ <div className="rounded-lg border p-3">
1042
+ <SchedulePanel
1043
+ days={selectedApproval.days ?? []}
1044
+ locale={currentLocaleCode}
1045
+ emptyLabel={commonT('labels.notAssigned')}
1046
+ />
1047
+ </div>
1048
+ </div>
1049
+ </div>
1050
+ )}
1051
+ </>
1052
+ ) : null}
1053
+
1054
+ <div>
1055
+ <div className="text-xs font-medium uppercase text-muted-foreground">
1056
+ {commonT('labels.decisionNote')}
1057
+ </div>
1058
+ <div className="mt-1 rounded-lg border p-3 text-sm">
1059
+ {selectedApproval.decisionNote || commonT('labels.noNotes')}
1060
+ </div>
1061
+ </div>
1062
+ </div>
1063
+ ) : null}
1064
+ </SheetContent>
1065
+ </Sheet>
1066
+
375
1067
  <Dialog
376
1068
  open={Boolean(pendingDecision)}
377
1069
  onOpenChange={(open) => {
@@ -381,7 +1073,7 @@ export default function OperationsApprovalsPage() {
381
1073
  }
382
1074
  }}
383
1075
  >
384
- <DialogContent>
1076
+ <DialogContent className="max-w-150">
385
1077
  <DialogHeader>
386
1078
  <DialogTitle>
387
1079
  {pendingDecision?.action === 'approve'