@hed-hog/operations 0.0.306 → 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.
- package/dist/controllers/operations-approvals.controller.d.ts +114 -1
- package/dist/controllers/operations-approvals.controller.d.ts.map +1 -1
- package/dist/controllers/operations-approvals.controller.js +16 -3
- package/dist/controllers/operations-approvals.controller.js.map +1 -1
- package/dist/controllers/operations-collaborators.controller.d.ts +16 -1
- package/dist/controllers/operations-collaborators.controller.d.ts.map +1 -1
- package/dist/controllers/operations-collaborators.controller.js +16 -3
- package/dist/controllers/operations-collaborators.controller.js.map +1 -1
- package/dist/controllers/operations-contracts.controller.d.ts +14 -453
- package/dist/controllers/operations-contracts.controller.d.ts.map +1 -1
- package/dist/controllers/operations-contracts.controller.js +11 -112
- package/dist/controllers/operations-contracts.controller.js.map +1 -1
- package/dist/controllers/operations-org-structure.controller.d.ts +65 -2
- package/dist/controllers/operations-org-structure.controller.d.ts.map +1 -1
- package/dist/controllers/operations-org-structure.controller.js +18 -5
- package/dist/controllers/operations-org-structure.controller.js.map +1 -1
- package/dist/controllers/operations-projects.controller.d.ts +28 -4
- package/dist/controllers/operations-projects.controller.d.ts.map +1 -1
- package/dist/controllers/operations-projects.controller.js +17 -5
- package/dist/controllers/operations-projects.controller.js.map +1 -1
- package/dist/controllers/operations-timesheets.controller.d.ts +31 -4
- package/dist/controllers/operations-timesheets.controller.d.ts.map +1 -1
- package/dist/controllers/operations-timesheets.controller.js +16 -11
- package/dist/controllers/operations-timesheets.controller.js.map +1 -1
- package/dist/dto/list-approvals.dto.d.ts +6 -0
- package/dist/dto/list-approvals.dto.d.ts.map +1 -0
- package/dist/dto/list-approvals.dto.js +28 -0
- package/dist/dto/list-approvals.dto.js.map +1 -0
- package/dist/dto/list-collaborator-types.dto.d.ts +3 -1
- package/dist/dto/list-collaborator-types.dto.d.ts.map +1 -1
- package/dist/dto/list-collaborator-types.dto.js +7 -1
- package/dist/dto/list-collaborator-types.dto.js.map +1 -1
- package/dist/dto/list-collaborators.dto.d.ts +1 -0
- package/dist/dto/list-collaborators.dto.d.ts.map +1 -1
- package/dist/dto/list-collaborators.dto.js +5 -0
- package/dist/dto/list-collaborators.dto.js.map +1 -1
- package/dist/dto/list-contracts.dto.d.ts +8 -0
- package/dist/dto/list-contracts.dto.d.ts.map +1 -0
- package/dist/dto/list-contracts.dto.js +38 -0
- package/dist/dto/list-contracts.dto.js.map +1 -0
- package/dist/dto/list-departments.dto.d.ts +5 -0
- package/dist/dto/list-departments.dto.d.ts.map +1 -0
- package/dist/dto/list-departments.dto.js +23 -0
- package/dist/dto/list-departments.dto.js.map +1 -0
- package/dist/dto/list-projects.dto.d.ts +5 -0
- package/dist/dto/list-projects.dto.d.ts.map +1 -0
- package/dist/dto/list-projects.dto.js +23 -0
- package/dist/dto/list-projects.dto.js.map +1 -0
- package/dist/dto/list-schedule-adjustments.dto.d.ts +5 -0
- package/dist/dto/list-schedule-adjustments.dto.d.ts.map +1 -0
- package/dist/dto/list-schedule-adjustments.dto.js +23 -0
- package/dist/dto/list-schedule-adjustments.dto.js.map +1 -0
- package/dist/dto/list-time-off-requests.dto.d.ts +5 -0
- package/dist/dto/list-time-off-requests.dto.d.ts.map +1 -0
- package/dist/dto/list-time-off-requests.dto.js +23 -0
- package/dist/dto/list-time-off-requests.dto.js.map +1 -0
- package/dist/dto/list-timesheets.dto.d.ts +5 -0
- package/dist/dto/list-timesheets.dto.d.ts.map +1 -0
- package/dist/dto/list-timesheets.dto.js +23 -0
- package/dist/dto/list-timesheets.dto.js.map +1 -0
- package/dist/dto/reorder-collaborator-types.dto.d.ts +4 -0
- package/dist/dto/reorder-collaborator-types.dto.d.ts.map +1 -0
- package/dist/dto/reorder-collaborator-types.dto.js +25 -0
- package/dist/dto/reorder-collaborator-types.dto.js.map +1 -0
- package/dist/operations.service.d.ts +340 -271
- package/dist/operations.service.d.ts.map +1 -1
- package/dist/operations.service.js +1007 -1043
- package/dist/operations.service.js.map +1 -1
- package/dist/operations.service.spec.js +0 -22
- package/dist/operations.service.spec.js.map +1 -1
- package/hedhog/data/menu.yaml +0 -36
- package/hedhog/data/route.yaml +42 -73
- package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +8 -1
- package/hedhog/frontend/app/_components/collaborator-select-with-create.tsx.ejs +15 -10
- package/hedhog/frontend/app/_components/contract-details-screen.tsx.ejs +108 -213
- package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +251 -2039
- package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +167 -60
- package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +70 -301
- package/hedhog/frontend/app/_components/system-user-select-with-create.tsx.ejs +102 -51
- package/hedhog/frontend/app/_lib/types.ts.ejs +19 -24
- package/hedhog/frontend/app/_lib/utils/format.ts.ejs +14 -9
- package/hedhog/frontend/app/approvals/page.tsx.ejs +842 -150
- package/hedhog/frontend/app/collaborator-types/page.tsx.ejs +445 -153
- package/hedhog/frontend/app/collaborators/page.tsx.ejs +118 -49
- package/hedhog/frontend/app/contracts/[id]/page.tsx.ejs +2 -2
- package/hedhog/frontend/app/contracts/page.tsx.ejs +215 -617
- package/hedhog/frontend/app/departments/page.tsx.ejs +257 -113
- package/hedhog/frontend/app/projects/page.tsx.ejs +90 -51
- package/hedhog/frontend/app/schedule-adjustments/page.tsx.ejs +412 -147
- package/hedhog/frontend/app/time-off/page.tsx.ejs +400 -123
- package/hedhog/frontend/app/timesheets/page.tsx.ejs +460 -365
- package/hedhog/frontend/messages/en.json +143 -14
- package/hedhog/frontend/messages/pt.json +192 -54
- package/hedhog/table/operations_contract.yaml +0 -9
- package/package.json +5 -5
- package/src/controllers/operations-approvals.controller.ts +9 -3
- package/src/controllers/operations-collaborators.controller.ts +15 -2
- package/src/controllers/operations-contracts.controller.ts +8 -92
- package/src/controllers/operations-org-structure.controller.ts +17 -4
- package/src/controllers/operations-projects.controller.ts +10 -4
- package/src/controllers/operations-timesheets.controller.ts +17 -8
- package/src/dto/list-approvals.dto.ts +12 -0
- package/src/dto/list-collaborator-types.dto.ts +7 -2
- package/src/dto/list-collaborators.dto.ts +4 -0
- package/src/dto/list-contracts.dto.ts +20 -0
- package/src/dto/list-departments.dto.ts +8 -0
- package/src/dto/list-projects.dto.ts +8 -0
- package/src/dto/list-schedule-adjustments.dto.ts +8 -0
- package/src/dto/list-time-off-requests.dto.ts +8 -0
- package/src/dto/list-timesheets.dto.ts +8 -0
- package/src/dto/reorder-collaborator-types.dto.ts +10 -0
- package/src/operations.service.spec.ts +0 -30
- package/src/operations.service.ts +1557 -1806
- package/hedhog/frontend/app/_components/contract-creation-wizard.tsx.ejs +0 -631
- package/hedhog/frontend/app/_components/contract-template-form-screen.tsx.ejs +0 -526
- package/hedhog/frontend/app/_components/contract-template-select-with-create.tsx.ejs +0 -247
- package/hedhog/frontend/app/_components/contract-wizard-sheet.tsx.ejs +0 -3520
- package/hedhog/frontend/app/contracts/templates/page.tsx.ejs +0 -380
- package/hedhog/frontend/app/team/page.tsx.ejs +0 -352
- package/hedhog/table/operations_contract_financial_term.yaml +0 -40
- package/hedhog/table/operations_contract_revision.yaml +0 -38
- package/hedhog/table/operations_contract_signature.yaml +0 -38
- package/hedhog/table/operations_contract_template.yaml +0 -58
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
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 {
|
|
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';
|
|
25
50
|
import { useTranslations } from 'next-intl';
|
|
26
|
-
import {
|
|
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:
|
|
116
|
-
|
|
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
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
<
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
</
|
|
286
|
-
</
|
|
287
|
-
<
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
336
|
-
|
|
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
|
-
<
|
|
351
|
-
{
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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) => {
|