@hed-hog/operations 0.0.301 → 0.0.303
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/operations.service.d.ts +1 -0
- package/dist/operations.service.d.ts.map +1 -1
- package/dist/operations.service.js +77 -37
- package/dist/operations.service.js.map +1 -1
- package/hedhog/data/menu.yaml +1 -22
- package/hedhog/frontend/app/contracts/page.tsx.ejs +99 -102
- package/hedhog/frontend/app/contracts/templates/page.tsx.ejs +87 -93
- package/hedhog/frontend/app/departments/page.tsx.ejs +95 -74
- package/hedhog/frontend/app/page.tsx.ejs +3 -341
- package/hedhog/frontend/app/projects/page.tsx.ejs +133 -127
- package/package.json +6 -6
- package/src/operations.service.ts +70 -7
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
import { EmptyState, Page, SearchBar } from '@/components/entity-list';
|
|
4
4
|
import { Button } from '@/components/ui/button';
|
|
5
|
-
import { Card, CardContent } from '@/components/ui/card';
|
|
6
5
|
import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
|
|
7
6
|
import {
|
|
8
7
|
Sheet,
|
|
@@ -51,10 +50,13 @@ export default function OperationsDepartmentsPage() {
|
|
|
51
50
|
const { request, showToastHandler, currentLocaleCode } = useApp();
|
|
52
51
|
const access = useOperationsAccess();
|
|
53
52
|
const [search, setSearch] = useState('');
|
|
54
|
-
const [statusFilter, setStatusFilter] = useState<
|
|
53
|
+
const [statusFilter, setStatusFilter] = useState<
|
|
54
|
+
'all' | 'active' | 'inactive'
|
|
55
|
+
>('all');
|
|
55
56
|
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
|
56
57
|
const [isSaving, setIsSaving] = useState(false);
|
|
57
|
-
const [editingDepartment, setEditingDepartment] =
|
|
58
|
+
const [editingDepartment, setEditingDepartment] =
|
|
59
|
+
useState<OperationsDepartment | null>(null);
|
|
58
60
|
const [form, setForm] = useState<DepartmentFormState>(EMPTY_FORM_STATE);
|
|
59
61
|
|
|
60
62
|
const {
|
|
@@ -65,7 +67,10 @@ export default function OperationsDepartmentsPage() {
|
|
|
65
67
|
queryKey: ['operations-departments-list', currentLocaleCode],
|
|
66
68
|
enabled: access.isDirector,
|
|
67
69
|
queryFn: () =>
|
|
68
|
-
fetchOperations<OperationsDepartment[]>(
|
|
70
|
+
fetchOperations<OperationsDepartment[]>(
|
|
71
|
+
request,
|
|
72
|
+
'/operations/departments'
|
|
73
|
+
),
|
|
69
74
|
});
|
|
70
75
|
|
|
71
76
|
const filteredDepartments = useMemo(
|
|
@@ -210,7 +215,9 @@ export default function OperationsDepartmentsPage() {
|
|
|
210
215
|
current={t('breadcrumb')}
|
|
211
216
|
actions={
|
|
212
217
|
<Button variant="outline" size="sm" asChild>
|
|
213
|
-
<Link href="/operations/collaborators">
|
|
218
|
+
<Link href="/operations/collaborators">
|
|
219
|
+
{commonT('actions.back')}
|
|
220
|
+
</Link>
|
|
214
221
|
</Button>
|
|
215
222
|
}
|
|
216
223
|
/>
|
|
@@ -237,7 +244,9 @@ export default function OperationsDepartmentsPage() {
|
|
|
237
244
|
actions={
|
|
238
245
|
<div className="flex flex-wrap gap-2">
|
|
239
246
|
<Button variant="outline" size="sm" asChild>
|
|
240
|
-
<Link href="/operations/collaborators">
|
|
247
|
+
<Link href="/operations/collaborators">
|
|
248
|
+
{commonT('actions.back')}
|
|
249
|
+
</Link>
|
|
241
250
|
</Button>
|
|
242
251
|
<Button size="sm" onClick={openCreateSheet}>
|
|
243
252
|
{commonT('actions.create')}
|
|
@@ -260,7 +269,9 @@ export default function OperationsDepartmentsPage() {
|
|
|
260
269
|
type: 'select',
|
|
261
270
|
value: statusFilter,
|
|
262
271
|
onChange: (value) =>
|
|
263
|
-
setStatusFilter(
|
|
272
|
+
setStatusFilter(
|
|
273
|
+
(value as 'all' | 'active' | 'inactive') ?? 'all'
|
|
274
|
+
),
|
|
264
275
|
placeholder: commonT('labels.status'),
|
|
265
276
|
options: [
|
|
266
277
|
{ value: 'all', label: commonT('filters.allStatuses') },
|
|
@@ -273,70 +284,70 @@ export default function OperationsDepartmentsPage() {
|
|
|
273
284
|
</div>
|
|
274
285
|
|
|
275
286
|
{isLoading ? (
|
|
276
|
-
<
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
</CardContent>
|
|
280
|
-
</Card>
|
|
287
|
+
<div className="rounded-md border px-4 py-6 text-sm text-muted-foreground">
|
|
288
|
+
{commonT('actions.refresh')}...
|
|
289
|
+
</div>
|
|
281
290
|
) : filteredDepartments.length > 0 ? (
|
|
282
|
-
<
|
|
283
|
-
<
|
|
284
|
-
<
|
|
285
|
-
<
|
|
286
|
-
<
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
291
|
+
<div className="overflow-x-auto rounded-md border">
|
|
292
|
+
<Table>
|
|
293
|
+
<TableHeader>
|
|
294
|
+
<TableRow>
|
|
295
|
+
<TableHead>{t('columns.name')}</TableHead>
|
|
296
|
+
<TableHead>{t('columns.code')}</TableHead>
|
|
297
|
+
<TableHead>{t('columns.description')}</TableHead>
|
|
298
|
+
<TableHead>{t('columns.collaborators')}</TableHead>
|
|
299
|
+
<TableHead>{commonT('labels.status')}</TableHead>
|
|
300
|
+
<TableHead className="text-right">
|
|
301
|
+
{commonT('labels.actions')}
|
|
302
|
+
</TableHead>
|
|
303
|
+
</TableRow>
|
|
304
|
+
</TableHeader>
|
|
305
|
+
<TableBody>
|
|
306
|
+
{filteredDepartments.map((department) => (
|
|
307
|
+
<TableRow key={department.id}>
|
|
308
|
+
<TableCell className="font-medium">
|
|
309
|
+
{department.name}
|
|
310
|
+
</TableCell>
|
|
311
|
+
<TableCell>{department.code || '—'}</TableCell>
|
|
312
|
+
<TableCell className="max-w-md text-sm text-muted-foreground">
|
|
313
|
+
{department.description || commonT('labels.notAvailable')}
|
|
314
|
+
</TableCell>
|
|
315
|
+
<TableCell>
|
|
316
|
+
{Number(department.collaboratorCount ?? 0)}
|
|
317
|
+
</TableCell>
|
|
318
|
+
<TableCell>
|
|
319
|
+
<StatusBadge
|
|
320
|
+
label={formatEnumLabel(department.status)}
|
|
321
|
+
className={getStatusBadgeClass(department.status)}
|
|
322
|
+
/>
|
|
323
|
+
</TableCell>
|
|
324
|
+
<TableCell>
|
|
325
|
+
<div className="flex justify-end gap-2">
|
|
326
|
+
<Button
|
|
327
|
+
variant="outline"
|
|
328
|
+
size="icon"
|
|
329
|
+
className="cursor-pointer"
|
|
330
|
+
onClick={() => openEditSheet(department)}
|
|
331
|
+
>
|
|
332
|
+
<Pencil className="size-4" />
|
|
333
|
+
</Button>
|
|
334
|
+
<Button
|
|
335
|
+
variant="outline"
|
|
336
|
+
size="sm"
|
|
337
|
+
className="cursor-pointer"
|
|
338
|
+
onClick={() => void toggleStatus(department)}
|
|
339
|
+
>
|
|
340
|
+
{department.status === 'inactive'
|
|
341
|
+
? commonT('actions.activate')
|
|
342
|
+
: commonT('actions.deactivate')}
|
|
343
|
+
</Button>
|
|
344
|
+
</div>
|
|
345
|
+
</TableCell>
|
|
346
|
+
</TableRow>
|
|
347
|
+
))}
|
|
348
|
+
</TableBody>
|
|
349
|
+
</Table>
|
|
350
|
+
</div>
|
|
340
351
|
) : (
|
|
341
352
|
<EmptyState
|
|
342
353
|
icon={<Building2 className="size-12" />}
|
|
@@ -351,7 +362,9 @@ export default function OperationsDepartmentsPage() {
|
|
|
351
362
|
<SheetContent className="w-full overflow-y-auto sm:max-w-xl">
|
|
352
363
|
<SheetHeader>
|
|
353
364
|
<SheetTitle>
|
|
354
|
-
{editingDepartment
|
|
365
|
+
{editingDepartment
|
|
366
|
+
? t('sheet.editTitle')
|
|
367
|
+
: t('sheet.createTitle')}
|
|
355
368
|
</SheetTitle>
|
|
356
369
|
<SheetDescription>{t('sheet.description')}</SheetDescription>
|
|
357
370
|
</SheetHeader>
|
|
@@ -390,20 +403,28 @@ export default function OperationsDepartmentsPage() {
|
|
|
390
403
|
</div>
|
|
391
404
|
|
|
392
405
|
<div className="space-y-2">
|
|
393
|
-
<label
|
|
406
|
+
<label
|
|
407
|
+
className="text-sm font-medium"
|
|
408
|
+
htmlFor="department-description"
|
|
409
|
+
>
|
|
394
410
|
{t('form.description')}
|
|
395
411
|
</label>
|
|
396
412
|
<textarea
|
|
397
413
|
id="department-description"
|
|
398
414
|
value={form.description}
|
|
399
|
-
onChange={(event) =>
|
|
415
|
+
onChange={(event) =>
|
|
416
|
+
updateForm('description', event.target.value)
|
|
417
|
+
}
|
|
400
418
|
className="min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
401
419
|
placeholder={t('form.description')}
|
|
402
420
|
/>
|
|
403
421
|
</div>
|
|
404
422
|
|
|
405
423
|
<div className="space-y-2">
|
|
406
|
-
<label
|
|
424
|
+
<label
|
|
425
|
+
className="text-sm font-medium"
|
|
426
|
+
htmlFor="department-status"
|
|
427
|
+
>
|
|
407
428
|
{t('form.status')}
|
|
408
429
|
</label>
|
|
409
430
|
<select
|
|
@@ -1,343 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
import { redirect } from 'next/navigation';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
|
|
6
|
-
import { Skeleton } from '@/components/ui/skeleton';
|
|
7
|
-
import {
|
|
8
|
-
Table,
|
|
9
|
-
TableBody,
|
|
10
|
-
TableCell,
|
|
11
|
-
TableHead,
|
|
12
|
-
TableHeader,
|
|
13
|
-
TableRow,
|
|
14
|
-
} from '@/components/ui/table';
|
|
15
|
-
import { useApp, useQuery } from '@hed-hog/next-app-provider';
|
|
16
|
-
import {
|
|
17
|
-
Building2,
|
|
18
|
-
CheckCircle2,
|
|
19
|
-
ClipboardList,
|
|
20
|
-
FolderKanban,
|
|
21
|
-
Hourglass,
|
|
22
|
-
PlaneTakeoff,
|
|
23
|
-
RefreshCcw,
|
|
24
|
-
Users,
|
|
25
|
-
} from 'lucide-react';
|
|
26
|
-
import { useTranslations } from 'next-intl';
|
|
27
|
-
import Link from 'next/link';
|
|
28
|
-
import { OperationsHeader } from './_components/operations-header';
|
|
29
|
-
import { SectionCard } from './_components/section-card';
|
|
30
|
-
import { StatusBadge } from './_components/status-badge';
|
|
31
|
-
import { fetchOperations } from './_lib/api';
|
|
32
|
-
import { useOperationsAccess } from './_lib/hooks/use-operations-access';
|
|
33
|
-
import type { OperationsDashboard, OperationsTeamOverview } from './_lib/types';
|
|
34
|
-
import {
|
|
35
|
-
formatDateRange,
|
|
36
|
-
formatEnumLabel,
|
|
37
|
-
formatHours,
|
|
38
|
-
getStatusBadgeClass,
|
|
39
|
-
} from './_lib/utils/format';
|
|
40
|
-
|
|
41
|
-
export default function OperationsDashboardPage() {
|
|
42
|
-
const t = useTranslations('operations.DashboardPage');
|
|
43
|
-
const commonT = useTranslations('operations.Common');
|
|
44
|
-
const { request, currentLocaleCode } = useApp();
|
|
45
|
-
const access = useOperationsAccess();
|
|
46
|
-
|
|
47
|
-
const {
|
|
48
|
-
data: dashboard,
|
|
49
|
-
isLoading,
|
|
50
|
-
refetch,
|
|
51
|
-
} = useQuery<OperationsDashboard>({
|
|
52
|
-
queryKey: ['operations-dashboard', currentLocaleCode],
|
|
53
|
-
queryFn: () =>
|
|
54
|
-
fetchOperations<OperationsDashboard>(request, '/operations/dashboard'),
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
const { data: team } = useQuery<OperationsTeamOverview>({
|
|
58
|
-
queryKey: ['operations-team-overview', currentLocaleCode],
|
|
59
|
-
enabled: access.isSupervisor,
|
|
60
|
-
queryFn: () =>
|
|
61
|
-
fetchOperations<OperationsTeamOverview>(request, '/operations/team'),
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
const cards = dashboard
|
|
65
|
-
? [
|
|
66
|
-
{
|
|
67
|
-
key: 'projects',
|
|
68
|
-
title: t('cards.projects'),
|
|
69
|
-
value: dashboard.cards.projectsTotal,
|
|
70
|
-
description: t('cards.projectsDescription', {
|
|
71
|
-
active: dashboard.cards.activeProjects,
|
|
72
|
-
}),
|
|
73
|
-
icon: FolderKanban,
|
|
74
|
-
},
|
|
75
|
-
{
|
|
76
|
-
key: 'timesheets',
|
|
77
|
-
title: t('cards.timesheets'),
|
|
78
|
-
value: dashboard.cards.visibleTimesheets,
|
|
79
|
-
description: t('cards.timesheetsDescription', {
|
|
80
|
-
pending: dashboard.cards.pendingTimesheets,
|
|
81
|
-
}),
|
|
82
|
-
icon: ClipboardList,
|
|
83
|
-
},
|
|
84
|
-
{
|
|
85
|
-
key: 'timeOff',
|
|
86
|
-
title: t('cards.timeOff'),
|
|
87
|
-
value: dashboard.cards.timeOffRequests,
|
|
88
|
-
description: t('cards.timeOffDescription'),
|
|
89
|
-
icon: PlaneTakeoff,
|
|
90
|
-
},
|
|
91
|
-
{
|
|
92
|
-
key: 'approvals',
|
|
93
|
-
title: t('cards.approvals'),
|
|
94
|
-
value: dashboard.cards.pendingApprovals,
|
|
95
|
-
description: t('cards.approvalsDescription'),
|
|
96
|
-
icon: Hourglass,
|
|
97
|
-
},
|
|
98
|
-
]
|
|
99
|
-
: [];
|
|
100
|
-
|
|
101
|
-
return (
|
|
102
|
-
<Page>
|
|
103
|
-
<OperationsHeader
|
|
104
|
-
title={t('title')}
|
|
105
|
-
description={t('description')}
|
|
106
|
-
current={t('breadcrumb')}
|
|
107
|
-
actions={
|
|
108
|
-
<div className="flex flex-wrap gap-2">
|
|
109
|
-
<Button variant="outline" size="sm" onClick={() => void refetch()}>
|
|
110
|
-
<RefreshCcw className="size-4" />
|
|
111
|
-
{commonT('actions.refresh')}
|
|
112
|
-
</Button>
|
|
113
|
-
{access.isDirector ? (
|
|
114
|
-
<Button variant="outline" size="sm" asChild>
|
|
115
|
-
<Link href="/operations/departments">
|
|
116
|
-
<Building2 className="size-4" />
|
|
117
|
-
{commonT('actions.manageDepartments')}
|
|
118
|
-
</Link>
|
|
119
|
-
</Button>
|
|
120
|
-
) : null}
|
|
121
|
-
<Button asChild size="sm">
|
|
122
|
-
<Link href="/operations/timesheets">
|
|
123
|
-
{t('actions.openTimesheets')}
|
|
124
|
-
</Link>
|
|
125
|
-
</Button>
|
|
126
|
-
</div>
|
|
127
|
-
}
|
|
128
|
-
/>
|
|
129
|
-
|
|
130
|
-
{isLoading || access.isLoading ? (
|
|
131
|
-
<div className="space-y-4">
|
|
132
|
-
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
133
|
-
{Array.from({ length: 4 }).map((_, index) => (
|
|
134
|
-
<Skeleton key={index} className="h-32 w-full" />
|
|
135
|
-
))}
|
|
136
|
-
</div>
|
|
137
|
-
<div className="grid gap-4 xl:grid-cols-3">
|
|
138
|
-
<Skeleton className="h-64 w-full xl:col-span-2" />
|
|
139
|
-
<Skeleton className="h-64 w-full" />
|
|
140
|
-
</div>
|
|
141
|
-
</div>
|
|
142
|
-
) : dashboard ? (
|
|
143
|
-
<>
|
|
144
|
-
<KpiCardsGrid items={cards} />
|
|
145
|
-
|
|
146
|
-
<div className="grid gap-4 xl:grid-cols-3">
|
|
147
|
-
<SectionCard
|
|
148
|
-
title={t('scope.title')}
|
|
149
|
-
description={t('scope.description')}
|
|
150
|
-
className="xl:col-span-1"
|
|
151
|
-
>
|
|
152
|
-
<dl className="space-y-3 text-sm">
|
|
153
|
-
<div className="flex items-center justify-between gap-4">
|
|
154
|
-
<dt className="text-muted-foreground">
|
|
155
|
-
{t('scope.roleScope')}
|
|
156
|
-
</dt>
|
|
157
|
-
<dd className="font-medium">
|
|
158
|
-
{formatEnumLabel(dashboard.actor.roleScope)}
|
|
159
|
-
</dd>
|
|
160
|
-
</div>
|
|
161
|
-
<div className="flex items-center justify-between gap-4">
|
|
162
|
-
<dt className="text-muted-foreground">
|
|
163
|
-
{t('scope.collaborator')}
|
|
164
|
-
</dt>
|
|
165
|
-
<dd className="font-medium">
|
|
166
|
-
{dashboard.actor.collaboratorName ??
|
|
167
|
-
commonT('labels.notAvailable')}
|
|
168
|
-
</dd>
|
|
169
|
-
</div>
|
|
170
|
-
<div className="flex items-center justify-between gap-4">
|
|
171
|
-
<dt className="text-muted-foreground">
|
|
172
|
-
{t('scope.teamSize')}
|
|
173
|
-
</dt>
|
|
174
|
-
<dd className="font-medium">{dashboard.actor.teamSize}</dd>
|
|
175
|
-
</div>
|
|
176
|
-
</dl>
|
|
177
|
-
</SectionCard>
|
|
178
|
-
|
|
179
|
-
<SectionCard
|
|
180
|
-
title={t('recentTimesheets.title')}
|
|
181
|
-
description={t('recentTimesheets.description')}
|
|
182
|
-
className="xl:col-span-2"
|
|
183
|
-
>
|
|
184
|
-
{dashboard.recentTimesheets.length === 0 ? (
|
|
185
|
-
<EmptyState
|
|
186
|
-
icon={<ClipboardList className="size-12" />}
|
|
187
|
-
title={commonT('states.emptyTitle')}
|
|
188
|
-
description={t('recentTimesheets.empty')}
|
|
189
|
-
actionLabel={t('actions.openTimesheets')}
|
|
190
|
-
onAction={() => {
|
|
191
|
-
window.location.href = '/operations/timesheets';
|
|
192
|
-
}}
|
|
193
|
-
/>
|
|
194
|
-
) : (
|
|
195
|
-
<div className="overflow-x-auto rounded-md border">
|
|
196
|
-
<Table>
|
|
197
|
-
<TableHeader>
|
|
198
|
-
<TableRow>
|
|
199
|
-
<TableHead>{commonT('labels.collaborator')}</TableHead>
|
|
200
|
-
<TableHead>{commonT('labels.week')}</TableHead>
|
|
201
|
-
<TableHead>{commonT('labels.totalHours')}</TableHead>
|
|
202
|
-
<TableHead>{commonT('labels.status')}</TableHead>
|
|
203
|
-
</TableRow>
|
|
204
|
-
</TableHeader>
|
|
205
|
-
<TableBody>
|
|
206
|
-
{dashboard.recentTimesheets.map((item) => (
|
|
207
|
-
<TableRow key={item.id}>
|
|
208
|
-
<TableCell className="font-medium">
|
|
209
|
-
{item.collaboratorName}
|
|
210
|
-
</TableCell>
|
|
211
|
-
<TableCell>
|
|
212
|
-
{formatDateRange(
|
|
213
|
-
item.weekStartDate,
|
|
214
|
-
item.weekEndDate
|
|
215
|
-
)}
|
|
216
|
-
</TableCell>
|
|
217
|
-
<TableCell>{formatHours(item.totalHours)}</TableCell>
|
|
218
|
-
<TableCell>
|
|
219
|
-
<StatusBadge
|
|
220
|
-
label={formatEnumLabel(item.status)}
|
|
221
|
-
className={getStatusBadgeClass(item.status)}
|
|
222
|
-
/>
|
|
223
|
-
</TableCell>
|
|
224
|
-
</TableRow>
|
|
225
|
-
))}
|
|
226
|
-
</TableBody>
|
|
227
|
-
</Table>
|
|
228
|
-
</div>
|
|
229
|
-
)}
|
|
230
|
-
</SectionCard>
|
|
231
|
-
</div>
|
|
232
|
-
|
|
233
|
-
<div className="grid gap-4 xl:grid-cols-2">
|
|
234
|
-
<SectionCard
|
|
235
|
-
title={t('nextSteps.title')}
|
|
236
|
-
description={t('nextSteps.description')}
|
|
237
|
-
>
|
|
238
|
-
<div className="space-y-3 text-sm">
|
|
239
|
-
<div className="flex items-start gap-3">
|
|
240
|
-
<CheckCircle2 className="mt-0.5 size-4 text-emerald-600" />
|
|
241
|
-
<p>{t('nextSteps.submitTimesheet')}</p>
|
|
242
|
-
</div>
|
|
243
|
-
<div className="flex items-start gap-3">
|
|
244
|
-
<CheckCircle2 className="mt-0.5 size-4 text-emerald-600" />
|
|
245
|
-
<p>{t('nextSteps.requestTimeOff')}</p>
|
|
246
|
-
</div>
|
|
247
|
-
<div className="flex items-start gap-3">
|
|
248
|
-
<CheckCircle2 className="mt-0.5 size-4 text-emerald-600" />
|
|
249
|
-
<p>{t('nextSteps.adjustSchedule')}</p>
|
|
250
|
-
</div>
|
|
251
|
-
</div>
|
|
252
|
-
</SectionCard>
|
|
253
|
-
|
|
254
|
-
<SectionCard
|
|
255
|
-
title={t('team.title')}
|
|
256
|
-
description={t('team.description')}
|
|
257
|
-
>
|
|
258
|
-
{access.isSupervisor && team ? (
|
|
259
|
-
<>
|
|
260
|
-
<div className="mb-4 grid gap-3 md:grid-cols-3">
|
|
261
|
-
<div className="rounded-lg border p-4">
|
|
262
|
-
<div className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
|
263
|
-
{t('team.members')}
|
|
264
|
-
</div>
|
|
265
|
-
<div className="mt-2 text-2xl font-semibold">
|
|
266
|
-
{team.teamMembers.length}
|
|
267
|
-
</div>
|
|
268
|
-
</div>
|
|
269
|
-
<div className="rounded-lg border p-4">
|
|
270
|
-
<div className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
|
271
|
-
{t('team.projects')}
|
|
272
|
-
</div>
|
|
273
|
-
<div className="mt-2 text-2xl font-semibold">
|
|
274
|
-
{team.projectCount}
|
|
275
|
-
</div>
|
|
276
|
-
</div>
|
|
277
|
-
<div className="rounded-lg border p-4">
|
|
278
|
-
<div className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
|
279
|
-
{t('team.pendingApprovals')}
|
|
280
|
-
</div>
|
|
281
|
-
<div className="mt-2 text-2xl font-semibold">
|
|
282
|
-
{team.pendingApprovals}
|
|
283
|
-
</div>
|
|
284
|
-
</div>
|
|
285
|
-
</div>
|
|
286
|
-
|
|
287
|
-
{team.teamMembers.length > 0 ? (
|
|
288
|
-
<div className="space-y-3">
|
|
289
|
-
{team.teamMembers.slice(0, 4).map((member) => (
|
|
290
|
-
<div
|
|
291
|
-
key={member.id}
|
|
292
|
-
className="flex items-center justify-between rounded-lg border px-4 py-3 text-sm"
|
|
293
|
-
>
|
|
294
|
-
<div>
|
|
295
|
-
<div className="font-medium">
|
|
296
|
-
{member.displayName}
|
|
297
|
-
</div>
|
|
298
|
-
<div className="text-muted-foreground">
|
|
299
|
-
{[member.department, member.title]
|
|
300
|
-
.filter(Boolean)
|
|
301
|
-
.join(' • ') || commonT('labels.notAvailable')}
|
|
302
|
-
</div>
|
|
303
|
-
</div>
|
|
304
|
-
<div className="text-right">
|
|
305
|
-
<div className="font-medium">
|
|
306
|
-
{member.activeAssignments ?? 0}{' '}
|
|
307
|
-
{t('team.assignments')}
|
|
308
|
-
</div>
|
|
309
|
-
<div className="text-muted-foreground">
|
|
310
|
-
{member.pendingApprovals ?? 0}{' '}
|
|
311
|
-
{t('team.awaitingReview')}
|
|
312
|
-
</div>
|
|
313
|
-
</div>
|
|
314
|
-
</div>
|
|
315
|
-
))}
|
|
316
|
-
</div>
|
|
317
|
-
) : (
|
|
318
|
-
<p className="text-sm text-muted-foreground">
|
|
319
|
-
{t('team.empty')}
|
|
320
|
-
</p>
|
|
321
|
-
)}
|
|
322
|
-
</>
|
|
323
|
-
) : (
|
|
324
|
-
<div className="flex items-start gap-3 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
|
|
325
|
-
<Users className="mt-0.5 size-4" />
|
|
326
|
-
<p>{t('team.collaboratorMessage')}</p>
|
|
327
|
-
</div>
|
|
328
|
-
)}
|
|
329
|
-
</SectionCard>
|
|
330
|
-
</div>
|
|
331
|
-
</>
|
|
332
|
-
) : (
|
|
333
|
-
<EmptyState
|
|
334
|
-
icon={<FolderKanban className="size-12" />}
|
|
335
|
-
title={commonT('states.emptyTitle')}
|
|
336
|
-
description={commonT('states.emptyDescription')}
|
|
337
|
-
actionLabel={commonT('actions.refresh')}
|
|
338
|
-
onAction={() => void refetch()}
|
|
339
|
-
/>
|
|
340
|
-
)}
|
|
341
|
-
</Page>
|
|
342
|
-
);
|
|
3
|
+
export default function OperationsPage() {
|
|
4
|
+
redirect('/operations/projects');
|
|
343
5
|
}
|