@hed-hog/operations 0.0.285 → 0.0.291

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.
@@ -1,6 +1,11 @@
1
1
  'use client';
2
2
 
3
- import { Page } from '@/components/entity-list';
3
+ import {
4
+ Page,
5
+ StatsCards,
6
+ type StatCardConfig,
7
+ } from '@/components/entity-list';
8
+ import { Progress } from '@/components/ui/progress';
4
9
  import {
5
10
  Table,
6
11
  TableBody,
@@ -10,7 +15,36 @@ import {
10
15
  TableRow,
11
16
  } from '@/components/ui/table';
12
17
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
18
+ import { cn } from '@/lib/utils';
19
+ import {
20
+ ArrowUpRight,
21
+ CalendarClock,
22
+ CheckCircle2,
23
+ DollarSign,
24
+ Gauge,
25
+ ShieldAlert,
26
+ Target,
27
+ TrendingDown,
28
+ Users,
29
+ } from 'lucide-react';
30
+ import { useTranslations } from 'next-intl';
13
31
  import { useParams } from 'next/navigation';
32
+ import { useMemo } from 'react';
33
+ import {
34
+ Bar,
35
+ BarChart,
36
+ CartesianGrid,
37
+ Cell,
38
+ Legend,
39
+ Line,
40
+ LineChart,
41
+ Pie,
42
+ PieChart,
43
+ ResponsiveContainer,
44
+ Tooltip,
45
+ XAxis,
46
+ YAxis,
47
+ } from 'recharts';
14
48
  import { KanbanBoard } from '../../_components/kanban-board';
15
49
  import { OperationsHeader } from '../../_components/operations-header';
16
50
  import { SectionCard } from '../../_components/section-card';
@@ -26,16 +60,16 @@ import {
26
60
  getApprovalBadgeClasses,
27
61
  getApprovalLabel,
28
62
  getProjectBadgeClasses,
29
- getProjectStatusLabel,
30
63
  } from '../../_lib/utils/status';
31
64
 
32
65
  export default function ProjectDetailsPage() {
66
+ const t = useTranslations('operations.ProjectDetailsPage');
33
67
  const params = useParams<{ id: string }>();
34
68
  const { projects, contracts, users, tasks, timesheets } = useOperationsData();
35
69
  const project = projects.find((item) => item.id === params.id);
36
70
 
37
71
  if (!project) {
38
- return <Page>Project not found.</Page>;
72
+ return <Page>{t('notFound')}</Page>;
39
73
  }
40
74
 
41
75
  const contract = contracts.find((item) => item.id === project.contractId);
@@ -45,140 +79,856 @@ export default function ProjectDetailsPage() {
45
79
  (entry) => entry.projectId === project.id
46
80
  );
47
81
 
82
+ const taskStatusData = useMemo(
83
+ () => [
84
+ {
85
+ status: t('taskStatus.backlog'),
86
+ total: projectTasks.filter((task) => task.status === 'backlog').length,
87
+ },
88
+ {
89
+ status: t('taskStatus.todo'),
90
+ total: projectTasks.filter((task) => task.status === 'todo').length,
91
+ },
92
+ {
93
+ status: t('taskStatus.inProgress'),
94
+ total: projectTasks.filter((task) => task.status === 'in-progress').length,
95
+ },
96
+ {
97
+ status: t('taskStatus.review'),
98
+ total: projectTasks.filter((task) => task.status === 'review').length,
99
+ },
100
+ {
101
+ status: t('taskStatus.done'),
102
+ total: projectTasks.filter((task) => task.status === 'done').length,
103
+ },
104
+ ],
105
+ [projectTasks, t]
106
+ );
107
+
108
+ const approvalsData = useMemo(
109
+ () => [
110
+ {
111
+ name: t('approvals.approved'),
112
+ value: projectTimesheets.filter((entry) => entry.status === 'approved')
113
+ .length,
114
+ },
115
+ {
116
+ name: t('approvals.pending'),
117
+ value: projectTimesheets.filter((entry) => entry.status === 'pending')
118
+ .length,
119
+ },
120
+ {
121
+ name: t('approvals.rejected'),
122
+ value: projectTimesheets.filter((entry) => entry.status === 'rejected')
123
+ .length,
124
+ },
125
+ ],
126
+ [projectTimesheets, t]
127
+ );
128
+
129
+ const chartColors = ['#0ea5e9', '#f59e0b', '#ef4444', '#8b5cf6', '#10b981'];
130
+
131
+ const totalEstimatedHours = projectTasks.reduce(
132
+ (total, task) => total + task.estimatedHours,
133
+ 0
134
+ );
135
+ const approvedHours = projectTimesheets
136
+ .filter((entry) => entry.status === 'approved')
137
+ .reduce((total, entry) => total + entry.hours, 0);
138
+ const pendingHours = projectTimesheets
139
+ .filter((entry) => entry.status === 'pending')
140
+ .reduce((total, entry) => total + entry.hours, 0);
141
+ const rejectedHours = projectTimesheets
142
+ .filter((entry) => entry.status === 'rejected')
143
+ .reduce((total, entry) => total + entry.hours, 0);
144
+
145
+ const actualCost = projectTimesheets.reduce((total, entry) => {
146
+ const user = users.find((item) => item.id === entry.userId);
147
+ return total + entry.hours * (user?.hourlyRate ?? 0);
148
+ }, 0);
149
+
150
+ const budgetConsumption = project.budget
151
+ ? Math.min((actualCost / project.budget) * 100, 100)
152
+ : 0;
153
+ const remainingBudget = Math.max(project.budget - actualCost, 0);
154
+
155
+ const today = new Date();
156
+ const startDate = new Date(project.startDate);
157
+ const endDate = new Date(project.endDate);
158
+ const elapsedDays = Math.max(
159
+ 1,
160
+ Math.floor((today.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24))
161
+ );
162
+ const totalDays = Math.max(
163
+ 1,
164
+ Math.floor((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24))
165
+ );
166
+ const elapsedPercent = Math.min((elapsedDays / totalDays) * 100, 100);
167
+ const scheduleVariance = project.progress - elapsedPercent;
168
+ const doneTasks = projectTasks.filter((task) => task.status === 'done').length;
169
+ const throughput = projectTasks.length
170
+ ? (doneTasks / projectTasks.length) * 100
171
+ : 0;
172
+
173
+ const openRisks = (project.risks ?? []).filter(
174
+ (risk) => risk.status === 'open'
175
+ ).length;
176
+ const monthlyForecast = project.monthlyForecast ?? [];
177
+ const health = project.health ?? {
178
+ overall: 'stable',
179
+ scope: 'stable',
180
+ schedule: 'stable',
181
+ budget: 'stable',
182
+ quality: 'stable',
183
+ };
184
+
185
+ const healthClasses: Record<string, string> = {
186
+ excellent: 'bg-emerald-100 text-emerald-700',
187
+ stable: 'bg-sky-100 text-sky-700',
188
+ attention: 'bg-amber-100 text-amber-700',
189
+ critical: 'bg-rose-100 text-rose-700',
190
+ };
191
+
192
+ const riskSeverityClasses: Record<string, string> = {
193
+ low: 'bg-sky-100 text-sky-700',
194
+ medium: 'bg-amber-100 text-amber-700',
195
+ high: 'bg-rose-100 text-rose-700',
196
+ };
197
+
198
+ const dependencyStatusClasses: Record<string, string> = {
199
+ 'on-track': 'bg-emerald-100 text-emerald-700',
200
+ watch: 'bg-amber-100 text-amber-700',
201
+ blocked: 'bg-rose-100 text-rose-700',
202
+ };
203
+
204
+ const milestoneStatusClasses: Record<string, string> = {
205
+ done: 'bg-emerald-100 text-emerald-700',
206
+ next: 'bg-sky-100 text-sky-700',
207
+ delayed: 'bg-rose-100 text-rose-700',
208
+ };
209
+
210
+ const stats: StatCardConfig[] = [
211
+ {
212
+ title: t('stats.progress'),
213
+ value: `${project.progress}%`,
214
+ icon: <Gauge className="size-5" />,
215
+ iconBgColor: 'bg-sky-100',
216
+ iconColor: 'text-sky-700',
217
+ },
218
+ {
219
+ title: t('stats.actualCost'),
220
+ value: formatCurrency(actualCost),
221
+ icon: <DollarSign className="size-5" />,
222
+ iconBgColor: 'bg-emerald-100',
223
+ iconColor: 'text-emerald-700',
224
+ },
225
+ {
226
+ title: t('stats.remainingBudget'),
227
+ value: formatCurrency(remainingBudget),
228
+ icon: <TrendingDown className="size-5" />,
229
+ iconBgColor: 'bg-amber-100',
230
+ iconColor: 'text-amber-700',
231
+ },
232
+ {
233
+ title: t('stats.throughput'),
234
+ value: `${Math.round(throughput)}%`,
235
+ icon: <Target className="size-5" />,
236
+ iconBgColor: 'bg-violet-100',
237
+ iconColor: 'text-violet-700',
238
+ },
239
+ ];
240
+
241
+ const healthBars = [
242
+ { key: 'scope', label: t('health.scope'), value: health.scope },
243
+ { key: 'schedule', label: t('health.schedule'), value: health.schedule },
244
+ { key: 'budget', label: t('health.budget'), value: health.budget },
245
+ { key: 'quality', label: t('health.quality'), value: health.quality },
246
+ ];
247
+
248
+ const healthToProgress: Record<string, number> = {
249
+ excellent: 95,
250
+ stable: 78,
251
+ attention: 52,
252
+ critical: 28,
253
+ };
254
+
48
255
  return (
49
256
  <Page>
50
257
  <OperationsHeader
51
258
  title={project.name}
52
- description={project.description}
53
- current="Project Details"
259
+ description={t('description', { client: project.client })}
260
+ current={t('breadcrumb')}
54
261
  />
55
262
 
56
- <Tabs defaultValue="overview" className="space-y-4">
57
- <TabsList>
58
- <TabsTrigger value="overview">Overview</TabsTrigger>
59
- <TabsTrigger value="team">Team</TabsTrigger>
60
- <TabsTrigger value="tasks">Tasks</TabsTrigger>
61
- <TabsTrigger value="timesheets">Timesheets</TabsTrigger>
62
- <TabsTrigger value="costs">Costs</TabsTrigger>
63
- </TabsList>
263
+ <div className="relative overflow-hidden rounded-xl border bg-linear-to-r from-slate-900 via-slate-800 to-sky-900 p-5 text-slate-50">
264
+ <div className="absolute -right-10 -top-10 size-40 rounded-full bg-sky-400/20 blur-2xl" />
265
+ <div className="absolute -bottom-12 left-20 size-40 rounded-full bg-emerald-400/20 blur-2xl" />
64
266
 
65
- <TabsContent value="overview">
66
- <SectionCard title="Project Overview">
67
- <div className="grid gap-4 text-sm md:grid-cols-2">
68
- <p>
69
- <span className="font-medium">Status:</span>{' '}
70
- <StatusBadge
71
- label={getProjectStatusLabel(project.status)}
72
- className={getProjectBadgeClasses(project.status)}
73
- />
267
+ <div className="relative flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
268
+ <div className="space-y-3">
269
+ <div className="flex flex-wrap items-center gap-2">
270
+ <StatusBadge
271
+ label={t(`projectStatus.${project.status}`)}
272
+ className={cn('border-transparent', getProjectBadgeClasses(project.status))}
273
+ />
274
+ <StatusBadge
275
+ label={`${t('labels.health')}: ${t(`healthStatus.${health.overall}`)}`}
276
+ className={healthClasses[health.overall]}
277
+ />
278
+ <StatusBadge
279
+ label={`${t('labels.risks')}: ${openRisks}`}
280
+ className={
281
+ openRisks > 0
282
+ ? 'bg-amber-100 text-amber-700'
283
+ : 'bg-emerald-100 text-emerald-700'
284
+ }
285
+ />
286
+ </div>
287
+ <p className="max-w-2xl text-sm text-slate-200">{project.description}</p>
288
+ <div className="flex flex-wrap gap-5 text-xs text-slate-200">
289
+ <p className="inline-flex items-center gap-1.5">
290
+ <CalendarClock className="size-4" />
291
+ {t('labels.period')}: {formatDate(project.startDate)} -{' '}
292
+ {formatDate(project.endDate)}
74
293
  </p>
75
- <p>
76
- <span className="font-medium">Progress:</span> {project.progress}%
294
+ <p className="inline-flex items-center gap-1.5">
295
+ <Users className="size-4" />
296
+ {t('labels.teamSize', { count: team.length })}
77
297
  </p>
78
- <p>
79
- <span className="font-medium">Client:</span> {project.client}
298
+ <p className="inline-flex items-center gap-1.5">
299
+ <Target className="size-4" />
300
+ {t('labels.contract')}: {contract?.name ?? '-'}
80
301
  </p>
81
- <p>
82
- <span className="font-medium">Contract:</span> {contract?.name}
302
+ </div>
303
+ </div>
304
+
305
+ <div className="grid w-full max-w-md grid-cols-2 gap-3 rounded-lg border border-white/10 bg-white/5 p-4">
306
+ <div>
307
+ <p className="text-xs uppercase text-slate-300">{t('hero.progress')}</p>
308
+ <p className="text-xl font-semibold">{project.progress}%</p>
309
+ </div>
310
+ <div>
311
+ <p className="text-xs uppercase text-slate-300">{t('hero.budget')}</p>
312
+ <p className="text-xl font-semibold">{formatCurrency(project.budget)}</p>
313
+ </div>
314
+ <div>
315
+ <p className="text-xs uppercase text-slate-300">{t('hero.hours')}</p>
316
+ <p className="text-xl font-semibold">{formatHours(project.hoursLogged)}</p>
317
+ </div>
318
+ <div>
319
+ <p className="text-xs uppercase text-slate-300">
320
+ {t('hero.scheduleVariance')}
83
321
  </p>
84
- <p className="md:col-span-2">
85
- <span className="font-medium">Description:</span>{' '}
86
- {project.description}
322
+ <p className="text-xl font-semibold">
323
+ {scheduleVariance >= 0 ? '+' : ''}
324
+ {Math.round(scheduleVariance)}%
87
325
  </p>
88
326
  </div>
89
- </SectionCard>
90
- </TabsContent>
327
+ </div>
328
+ </div>
329
+ </div>
91
330
 
92
- <TabsContent value="team">
93
- <SectionCard title="Team Allocation">
94
- <Table>
95
- <TableHeader>
96
- <TableRow>
97
- <TableHead>Member</TableHead>
98
- <TableHead>Role</TableHead>
99
- <TableHead>Hour Rate</TableHead>
100
- <TableHead>Allocation %</TableHead>
101
- </TableRow>
102
- </TableHeader>
103
- <TableBody>
104
- {team.map((member) => (
105
- <TableRow key={member.id}>
106
- <TableCell>{member.name}</TableCell>
107
- <TableCell>{member.role}</TableCell>
108
- <TableCell>{formatCurrency(member.hourlyRate)}</TableCell>
109
- <TableCell>{member.utilizationTarget}%</TableCell>
110
- </TableRow>
331
+ <StatsCards stats={stats} />
332
+
333
+ <Tabs defaultValue="overview" className="space-y-4">
334
+ <TabsList className="h-auto w-full flex-wrap justify-start gap-2 bg-transparent p-0">
335
+ <TabsTrigger value="overview">{t('tabs.overview')}</TabsTrigger>
336
+ <TabsTrigger value="execution">{t('tabs.execution')}</TabsTrigger>
337
+ <TabsTrigger value="team">{t('tabs.team')}</TabsTrigger>
338
+ <TabsTrigger value="finance">{t('tabs.finance')}</TabsTrigger>
339
+ <TabsTrigger value="timesheets">{t('tabs.timesheets')}</TabsTrigger>
340
+ <TabsTrigger value="governance">{t('tabs.governance')}</TabsTrigger>
341
+ </TabsList>
342
+
343
+ <TabsContent value="overview">
344
+ <div className="grid gap-4 xl:grid-cols-5">
345
+ <SectionCard
346
+ className="xl:col-span-3"
347
+ title={t('sections.progressTrend')}
348
+ description={t('sections.progressTrendDescription')}
349
+ >
350
+ <div className="h-72">
351
+ <ResponsiveContainer width="100%" height="100%">
352
+ <LineChart data={monthlyForecast}>
353
+ <CartesianGrid strokeDasharray="3 3" />
354
+ <XAxis dataKey="month" />
355
+ <YAxis />
356
+ <Tooltip />
357
+ <Legend />
358
+ <Line
359
+ type="monotone"
360
+ dataKey="plannedProgress"
361
+ name={t('charts.plannedProgress')}
362
+ stroke="#0ea5e9"
363
+ strokeWidth={2}
364
+ dot={{ r: 3 }}
365
+ />
366
+ <Line
367
+ type="monotone"
368
+ dataKey="actualProgress"
369
+ name={t('charts.actualProgress')}
370
+ stroke="#22c55e"
371
+ strokeWidth={2}
372
+ dot={{ r: 3 }}
373
+ />
374
+ </LineChart>
375
+ </ResponsiveContainer>
376
+ </div>
377
+ </SectionCard>
378
+
379
+ <SectionCard
380
+ className="xl:col-span-2"
381
+ title={t('sections.health')}
382
+ description={t('sections.healthDescription')}
383
+ >
384
+ <div className="space-y-4">
385
+ {healthBars.map((item) => (
386
+ <div key={item.key} className="space-y-2">
387
+ <div className="flex items-center justify-between text-sm">
388
+ <span className="font-medium">{item.label}</span>
389
+ <StatusBadge
390
+ label={t(`healthStatus.${item.value}`)}
391
+ className={healthClasses[item.value]}
392
+ />
393
+ </div>
394
+ <Progress value={healthToProgress[item.value]} />
395
+ </div>
111
396
  ))}
112
- </TableBody>
113
- </Table>
114
- </SectionCard>
115
- </TabsContent>
397
+ </div>
398
+ </SectionCard>
116
399
 
117
- <TabsContent value="tasks">
118
- <SectionCard title="Kanban Preview">
119
- <KanbanBoard tasks={projectTasks} users={users} />
120
- </SectionCard>
121
- </TabsContent>
400
+ <SectionCard
401
+ className="xl:col-span-3"
402
+ title={t('sections.milestones')}
403
+ description={t('sections.milestonesDescription')}
404
+ >
405
+ <div className="space-y-3">
406
+ {(project.milestones ?? []).map((milestone) => (
407
+ <div key={milestone.id} className="rounded-lg border p-3">
408
+ <div className="flex flex-wrap items-center justify-between gap-2">
409
+ <p className="font-medium">{milestone.title}</p>
410
+ <StatusBadge
411
+ label={t(`milestoneStatus.${milestone.status}`)}
412
+ className={milestoneStatusClasses[milestone.status]}
413
+ />
414
+ </div>
415
+ <p className="mt-1 text-xs text-muted-foreground">
416
+ {formatDate(milestone.date)}
417
+ </p>
418
+ <p className="mt-2 text-sm text-muted-foreground">
419
+ {milestone.description}
420
+ </p>
421
+ </div>
422
+ ))}
423
+ </div>
424
+ </SectionCard>
122
425
 
123
- <TabsContent value="timesheets">
124
- <SectionCard title="Project Timesheets">
125
- <Table>
126
- <TableHeader>
127
- <TableRow>
128
- <TableHead>Date</TableHead>
129
- <TableHead>User</TableHead>
130
- <TableHead>Hours</TableHead>
131
- <TableHead>Description</TableHead>
132
- <TableHead>Status</TableHead>
133
- </TableRow>
134
- </TableHeader>
135
- <TableBody>
136
- {projectTimesheets.map((entry) => {
137
- const user = users.find((item) => item.id === entry.userId);
426
+ <SectionCard
427
+ className="xl:col-span-2"
428
+ title={t('sections.risks')}
429
+ description={t('sections.risksDescription')}
430
+ >
431
+ <div className="space-y-3">
432
+ {(project.risks ?? []).map((risk) => {
433
+ const owner = users.find((item) => item.id === risk.ownerUserId);
138
434
 
139
435
  return (
140
- <TableRow key={entry.id}>
141
- <TableCell>{formatDate(entry.date)}</TableCell>
142
- <TableCell>{user?.name}</TableCell>
143
- <TableCell>{formatHours(entry.hours)}</TableCell>
144
- <TableCell>{entry.description}</TableCell>
145
- <TableCell>
436
+ <div key={risk.id} className="rounded-lg border p-3">
437
+ <div className="mb-2 flex items-center gap-2">
438
+ <ShieldAlert className="size-4 text-amber-600" />
439
+ <p className="font-medium">{risk.title}</p>
440
+ </div>
441
+ <div className="mb-2 flex flex-wrap gap-2">
146
442
  <StatusBadge
147
- label={getApprovalLabel(entry.status)}
148
- className={getApprovalBadgeClasses(entry.status)}
443
+ label={t(`riskSeverity.${risk.severity}`)}
444
+ className={riskSeverityClasses[risk.severity]}
149
445
  />
150
- </TableCell>
151
- </TableRow>
446
+ <StatusBadge
447
+ label={t(`riskStatus.${risk.status}`)}
448
+ className={
449
+ risk.status === 'open'
450
+ ? 'bg-rose-100 text-rose-700'
451
+ : 'bg-emerald-100 text-emerald-700'
452
+ }
453
+ />
454
+ </div>
455
+ <p className="text-xs text-muted-foreground">
456
+ {t('labels.owner')}: {owner?.name ?? '-'}
457
+ </p>
458
+ <p className="mt-2 text-xs text-muted-foreground">
459
+ <span className="font-medium text-foreground">
460
+ {t('labels.impact')}:
461
+ </span>{' '}
462
+ {risk.impact}
463
+ </p>
464
+ <p className="mt-1 text-xs text-muted-foreground">
465
+ <span className="font-medium text-foreground">
466
+ {t('labels.mitigation')}:
467
+ </span>{' '}
468
+ {risk.mitigation}
469
+ </p>
470
+ </div>
152
471
  );
153
472
  })}
154
- </TableBody>
155
- </Table>
156
- </SectionCard>
473
+ </div>
474
+ </SectionCard>
475
+ </div>
157
476
  </TabsContent>
158
477
 
159
- <TabsContent value="costs">
160
- <SectionCard title="Costs (Mock)">
161
- <div className="grid gap-3 md:grid-cols-3">
162
- <div className="rounded-lg border p-4">
163
- <p className="text-sm text-muted-foreground">Budget</p>
164
- <p className="text-xl font-semibold">
165
- {formatCurrency(project.budget)}
166
- </p>
478
+ <TabsContent value="execution">
479
+ <div className="grid gap-4 xl:grid-cols-5">
480
+ <SectionCard
481
+ className="xl:col-span-2"
482
+ title={t('sections.taskDistribution')}
483
+ description={t('sections.taskDistributionDescription')}
484
+ >
485
+ <div className="h-72">
486
+ <ResponsiveContainer width="100%" height="100%">
487
+ <BarChart data={taskStatusData}>
488
+ <CartesianGrid strokeDasharray="3 3" />
489
+ <XAxis dataKey="status" />
490
+ <YAxis allowDecimals={false} />
491
+ <Tooltip />
492
+ <Bar dataKey="total" radius={[6, 6, 0, 0]}>
493
+ {taskStatusData.map((entry, index) => (
494
+ <Cell
495
+ key={`${entry.status}-${index}`}
496
+ fill={chartColors[index % chartColors.length]}
497
+ />
498
+ ))}
499
+ </Bar>
500
+ </BarChart>
501
+ </ResponsiveContainer>
167
502
  </div>
168
- <div className="rounded-lg border p-4">
169
- <p className="text-sm text-muted-foreground">Logged Hours</p>
170
- <p className="text-xl font-semibold">
171
- {formatHours(project.hoursLogged)}
503
+ <div className="mt-3 grid grid-cols-2 gap-2 text-xs">
504
+ <p className="rounded-md bg-muted/50 p-2">
505
+ {t('labels.totalTasks')}:{' '}
506
+ <span className="font-semibold">{projectTasks.length}</span>
172
507
  </p>
508
+ <p className="rounded-md bg-muted/50 p-2">
509
+ {t('labels.estimatedHours')}:{' '}
510
+ <span className="font-semibold">
511
+ {formatHours(totalEstimatedHours)}
512
+ </span>
513
+ </p>
514
+ </div>
515
+ </SectionCard>
516
+
517
+ <SectionCard
518
+ className="xl:col-span-3"
519
+ title={t('sections.kanban')}
520
+ description={t('sections.kanbanDescription')}
521
+ >
522
+ <KanbanBoard tasks={projectTasks} users={users} />
523
+ </SectionCard>
524
+
525
+ <SectionCard
526
+ className="xl:col-span-5"
527
+ title={t('sections.taskTable')}
528
+ description={t('sections.taskTableDescription')}
529
+ >
530
+ <Table>
531
+ <TableHeader>
532
+ <TableRow>
533
+ <TableHead>{t('table.task')}</TableHead>
534
+ <TableHead>{t('table.assignee')}</TableHead>
535
+ <TableHead>{t('table.dueDate')}</TableHead>
536
+ <TableHead>{t('table.estimate')}</TableHead>
537
+ <TableHead>{t('table.status')}</TableHead>
538
+ </TableRow>
539
+ </TableHeader>
540
+ <TableBody>
541
+ {projectTasks.map((task) => {
542
+ const assignee = users.find(
543
+ (item) => item.id === task.assignedUserId
544
+ );
545
+
546
+ return (
547
+ <TableRow key={task.id}>
548
+ <TableCell>
549
+ <div>
550
+ <p className="font-medium">{task.title}</p>
551
+ <p className="text-xs text-muted-foreground">
552
+ {task.labels.join(', ')}
553
+ </p>
554
+ </div>
555
+ </TableCell>
556
+ <TableCell>{assignee?.name ?? '-'}</TableCell>
557
+ <TableCell>{formatDate(task.dueDate)}</TableCell>
558
+ <TableCell>{formatHours(task.estimatedHours)}</TableCell>
559
+ <TableCell>
560
+ <StatusBadge
561
+ label={task.status}
562
+ className={
563
+ task.status === 'done'
564
+ ? 'bg-emerald-100 text-emerald-700'
565
+ : task.status === 'in-progress'
566
+ ? 'bg-blue-100 text-blue-700'
567
+ : 'bg-slate-100 text-slate-700'
568
+ }
569
+ />
570
+ </TableCell>
571
+ </TableRow>
572
+ );
573
+ })}
574
+ </TableBody>
575
+ </Table>
576
+ </SectionCard>
577
+ </div>
578
+ </TabsContent>
579
+
580
+ <TabsContent value="team">
581
+ <div className="grid gap-4 xl:grid-cols-5">
582
+ <SectionCard
583
+ className="xl:col-span-3"
584
+ title={t('sections.teamAllocation')}
585
+ description={t('sections.teamAllocationDescription')}
586
+ >
587
+ <Table>
588
+ <TableHeader>
589
+ <TableRow>
590
+ <TableHead>{t('table.member')}</TableHead>
591
+ <TableHead>{t('table.role')}</TableHead>
592
+ <TableHead>{t('table.hourRate')}</TableHead>
593
+ <TableHead>{t('table.utilization')}</TableHead>
594
+ </TableRow>
595
+ </TableHeader>
596
+ <TableBody>
597
+ {team.map((member) => (
598
+ <TableRow key={member.id}>
599
+ <TableCell>
600
+ <div className="flex items-center gap-2">
601
+ <div className="flex size-8 items-center justify-center rounded-full bg-muted text-xs font-semibold">
602
+ {member.name
603
+ .split(' ')
604
+ .map((part) => part[0])
605
+ .slice(0, 2)
606
+ .join('')
607
+ .toUpperCase()}
608
+ </div>
609
+ <div>
610
+ <p className="font-medium">{member.name}</p>
611
+ <p className="text-xs text-muted-foreground">
612
+ {member.department}
613
+ </p>
614
+ </div>
615
+ </div>
616
+ </TableCell>
617
+ <TableCell>{member.role}</TableCell>
618
+ <TableCell>{formatCurrency(member.hourlyRate)}</TableCell>
619
+ <TableCell>
620
+ <div className="space-y-2">
621
+ <div className="text-xs">{member.utilizationTarget}%</div>
622
+ <Progress value={member.utilizationTarget} />
623
+ </div>
624
+ </TableCell>
625
+ </TableRow>
626
+ ))}
627
+ </TableBody>
628
+ </Table>
629
+ </SectionCard>
630
+
631
+ <SectionCard
632
+ className="xl:col-span-2"
633
+ title={t('sections.teamCost')}
634
+ description={t('sections.teamCostDescription')}
635
+ >
636
+ <div className="h-72">
637
+ <ResponsiveContainer width="100%" height="100%">
638
+ <PieChart>
639
+ <Pie
640
+ data={team.map((member) => ({
641
+ name: member.name.split(' ')[0],
642
+ value: member.hourlyRate,
643
+ }))}
644
+ dataKey="value"
645
+ nameKey="name"
646
+ innerRadius={55}
647
+ outerRadius={95}
648
+ paddingAngle={2}
649
+ >
650
+ {team.map((member, index) => (
651
+ <Cell
652
+ key={`${member.id}-${index}`}
653
+ fill={chartColors[index % chartColors.length]}
654
+ />
655
+ ))}
656
+ </Pie>
657
+ <Tooltip />
658
+ <Legend />
659
+ </PieChart>
660
+ </ResponsiveContainer>
661
+ </div>
662
+ </SectionCard>
663
+ </div>
664
+ </TabsContent>
665
+
666
+ <TabsContent value="finance">
667
+ <div className="grid gap-4 xl:grid-cols-5">
668
+ <SectionCard
669
+ className="xl:col-span-2"
670
+ title={t('sections.budgetStatus')}
671
+ description={t('sections.budgetStatusDescription')}
672
+ >
673
+ <div className="space-y-4 text-sm">
674
+ <div className="rounded-lg border p-3">
675
+ <p className="text-muted-foreground">{t('finance.totalBudget')}</p>
676
+ <p className="text-xl font-semibold">
677
+ {formatCurrency(project.budget)}
678
+ </p>
679
+ </div>
680
+ <div className="rounded-lg border p-3">
681
+ <p className="text-muted-foreground">{t('finance.actualCost')}</p>
682
+ <p className="text-xl font-semibold">{formatCurrency(actualCost)}</p>
683
+ </div>
684
+ <div className="rounded-lg border p-3">
685
+ <p className="text-muted-foreground">
686
+ {t('finance.remainingBudget')}
687
+ </p>
688
+ <p className="text-xl font-semibold">
689
+ {formatCurrency(remainingBudget)}
690
+ </p>
691
+ </div>
692
+ <div className="space-y-2">
693
+ <div className="flex items-center justify-between text-xs">
694
+ <span>{t('finance.budgetConsumption')}</span>
695
+ <span>{Math.round(budgetConsumption)}%</span>
696
+ </div>
697
+ <Progress value={budgetConsumption} />
698
+ </div>
699
+ </div>
700
+ </SectionCard>
701
+
702
+ <SectionCard
703
+ className="xl:col-span-3"
704
+ title={t('sections.costForecast')}
705
+ description={t('sections.costForecastDescription')}
706
+ >
707
+ <div className="h-80">
708
+ <ResponsiveContainer width="100%" height="100%">
709
+ <BarChart data={monthlyForecast}>
710
+ <CartesianGrid strokeDasharray="3 3" />
711
+ <XAxis dataKey="month" />
712
+ <YAxis />
713
+ <Tooltip />
714
+ <Legend />
715
+ <Bar
716
+ dataKey="plannedCost"
717
+ name={t('charts.plannedCost')}
718
+ fill="#0ea5e9"
719
+ radius={[6, 6, 0, 0]}
720
+ />
721
+ <Bar
722
+ dataKey="actualCost"
723
+ name={t('charts.actualCost')}
724
+ fill="#22c55e"
725
+ radius={[6, 6, 0, 0]}
726
+ />
727
+ </BarChart>
728
+ </ResponsiveContainer>
173
729
  </div>
174
- <div className="rounded-lg border p-4">
175
- <p className="text-sm text-muted-foreground">Projected End</p>
176
- <p className="text-xl font-semibold">
177
- {formatDate(project.endDate)}
730
+ </SectionCard>
731
+ </div>
732
+ </TabsContent>
733
+
734
+ <TabsContent value="timesheets">
735
+ <div className="grid gap-4 xl:grid-cols-5">
736
+ <SectionCard
737
+ className="xl:col-span-2"
738
+ title={t('sections.approvalsDistribution')}
739
+ description={t('sections.approvalsDistributionDescription')}
740
+ >
741
+ <div className="h-72">
742
+ <ResponsiveContainer width="100%" height="100%">
743
+ <PieChart>
744
+ <Pie
745
+ data={approvalsData}
746
+ dataKey="value"
747
+ nameKey="name"
748
+ innerRadius={55}
749
+ outerRadius={95}
750
+ >
751
+ {approvalsData.map((entry, index) => (
752
+ <Cell
753
+ key={`${entry.name}-${index}`}
754
+ fill={chartColors[index % chartColors.length]}
755
+ />
756
+ ))}
757
+ </Pie>
758
+ <Tooltip />
759
+ <Legend />
760
+ </PieChart>
761
+ </ResponsiveContainer>
762
+ </div>
763
+ <div className="mt-2 grid grid-cols-3 gap-2 text-xs">
764
+ <p className="rounded-md bg-emerald-50 p-2 text-emerald-700">
765
+ {t('approvals.approved')}: {formatHours(approvedHours)}
766
+ </p>
767
+ <p className="rounded-md bg-amber-50 p-2 text-amber-700">
768
+ {t('approvals.pending')}: {formatHours(pendingHours)}
769
+ </p>
770
+ <p className="rounded-md bg-rose-50 p-2 text-rose-700">
771
+ {t('approvals.rejected')}: {formatHours(rejectedHours)}
178
772
  </p>
179
773
  </div>
180
- </div>
181
- </SectionCard>
774
+ </SectionCard>
775
+
776
+ <SectionCard
777
+ className="xl:col-span-3"
778
+ title={t('sections.timesheets')}
779
+ description={t('sections.timesheetsDescription')}
780
+ >
781
+ <Table>
782
+ <TableHeader>
783
+ <TableRow>
784
+ <TableHead>{t('table.date')}</TableHead>
785
+ <TableHead>{t('table.user')}</TableHead>
786
+ <TableHead>{t('table.task')}</TableHead>
787
+ <TableHead>{t('table.hours')}</TableHead>
788
+ <TableHead>{t('table.description')}</TableHead>
789
+ <TableHead>{t('table.status')}</TableHead>
790
+ </TableRow>
791
+ </TableHeader>
792
+ <TableBody>
793
+ {projectTimesheets.map((entry) => {
794
+ const user = users.find((item) => item.id === entry.userId);
795
+ const task = projectTasks.find((item) => item.id === entry.taskId);
796
+
797
+ return (
798
+ <TableRow key={entry.id}>
799
+ <TableCell>{formatDate(entry.date)}</TableCell>
800
+ <TableCell>{user?.name ?? '-'}</TableCell>
801
+ <TableCell>{task?.title ?? '-'}</TableCell>
802
+ <TableCell>{formatHours(entry.hours)}</TableCell>
803
+ <TableCell>{entry.description}</TableCell>
804
+ <TableCell>
805
+ <StatusBadge
806
+ label={getApprovalLabel(entry.status)}
807
+ className={getApprovalBadgeClasses(entry.status)}
808
+ />
809
+ </TableCell>
810
+ </TableRow>
811
+ );
812
+ })}
813
+ </TableBody>
814
+ </Table>
815
+ </SectionCard>
816
+ </div>
817
+ </TabsContent>
818
+
819
+ <TabsContent value="governance">
820
+ <div className="grid gap-4 xl:grid-cols-5">
821
+ <SectionCard
822
+ className="xl:col-span-2"
823
+ title={t('sections.governance')}
824
+ description={t('sections.governanceDescription')}
825
+ >
826
+ <div className="space-y-3 text-sm">
827
+ <div className="rounded-lg border p-3">
828
+ <p className="text-xs uppercase text-muted-foreground">
829
+ {t('governance.client')}
830
+ </p>
831
+ <p className="font-medium">{project.client}</p>
832
+ </div>
833
+ <div className="rounded-lg border p-3">
834
+ <p className="text-xs uppercase text-muted-foreground">
835
+ {t('governance.contract')}
836
+ </p>
837
+ <p className="font-medium">{contract?.name ?? '-'}</p>
838
+ </div>
839
+ <div className="rounded-lg border p-3">
840
+ <p className="text-xs uppercase text-muted-foreground">
841
+ {t('governance.sla')}
842
+ </p>
843
+ <p className="font-medium text-muted-foreground">
844
+ {contract?.sla ?? '-'}
845
+ </p>
846
+ </div>
847
+ <div className="rounded-lg border p-3">
848
+ <p className="text-xs uppercase text-muted-foreground">
849
+ {t('governance.billingRules')}
850
+ </p>
851
+ <ul className="mt-2 space-y-1 text-muted-foreground">
852
+ {(contract?.billingRules ?? []).map((rule) => (
853
+ <li key={rule} className="flex items-start gap-2">
854
+ <ArrowUpRight className="mt-0.5 size-3.5 text-sky-600" />
855
+ <span>{rule}</span>
856
+ </li>
857
+ ))}
858
+ </ul>
859
+ </div>
860
+ </div>
861
+ </SectionCard>
862
+
863
+ <SectionCard
864
+ className="xl:col-span-3"
865
+ title={t('sections.dependencies')}
866
+ description={t('sections.dependenciesDescription')}
867
+ >
868
+ <div className="space-y-3">
869
+ {(project.dependencies ?? []).map((dependency) => (
870
+ <div key={dependency.id} className="rounded-lg border p-3">
871
+ <div className="flex flex-wrap items-center justify-between gap-2">
872
+ <p className="font-medium">{dependency.title}</p>
873
+ <StatusBadge
874
+ label={t(`dependencyStatus.${dependency.status}`)}
875
+ className={dependencyStatusClasses[dependency.status]}
876
+ />
877
+ </div>
878
+ <p className="mt-2 text-xs text-muted-foreground">
879
+ {t('labels.owner')}: {dependency.owner}
880
+ </p>
881
+ <p className="text-xs text-muted-foreground">
882
+ {t('labels.type')}: {t(`dependencyType.${dependency.type}`)}
883
+ </p>
884
+ </div>
885
+ ))}
886
+ </div>
887
+ </SectionCard>
888
+
889
+ <SectionCard
890
+ className="xl:col-span-3"
891
+ title={t('sections.decisions')}
892
+ description={t('sections.decisionsDescription')}
893
+ >
894
+ <div className="space-y-3">
895
+ {(project.decisions ?? []).map((decision) => {
896
+ const owner = users.find((item) => item.id === decision.ownerUserId);
897
+
898
+ return (
899
+ <div key={decision.id} className="rounded-lg border p-3">
900
+ <p className="font-medium">{decision.title}</p>
901
+ <p className="mt-1 text-xs text-muted-foreground">
902
+ {formatDate(decision.date)} • {t('labels.owner')}:{' '}
903
+ {owner?.name ?? '-'}
904
+ </p>
905
+ <p className="mt-2 text-sm text-muted-foreground">
906
+ {decision.summary}
907
+ </p>
908
+ </div>
909
+ );
910
+ })}
911
+ </div>
912
+ </SectionCard>
913
+
914
+ <SectionCard
915
+ className="xl:col-span-2"
916
+ title={t('sections.nextActions')}
917
+ description={t('sections.nextActionsDescription')}
918
+ >
919
+ <div className="space-y-2">
920
+ {(project.nextActions ?? []).map((item) => (
921
+ <div
922
+ key={item}
923
+ className="flex items-start gap-2 rounded-lg border p-3 text-sm"
924
+ >
925
+ <CheckCircle2 className="mt-0.5 size-4 text-emerald-600" />
926
+ <span>{item}</span>
927
+ </div>
928
+ ))}
929
+ </div>
930
+ </SectionCard>
931
+ </div>
182
932
  </TabsContent>
183
933
  </Tabs>
184
934
  </Page>