@hed-hog/operations 0.0.285 → 0.0.286
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/hedhog/frontend/app/_components/kanban-board.tsx.ejs +604 -61
- package/hedhog/frontend/app/_lib/mocks/projects.mock.ts.ejs +398 -3
- package/hedhog/frontend/app/_lib/mocks/tasks.mock.ts.ejs +29 -0
- package/hedhog/frontend/app/_lib/types/operations.ts.ejs +67 -6
- package/hedhog/frontend/app/allocations/page.tsx.ejs +2 -1
- package/hedhog/frontend/app/certifications/page.tsx.ejs +2 -1
- package/hedhog/frontend/app/contracts/page.tsx.ejs +2 -1
- package/hedhog/frontend/app/evaluations/page.tsx.ejs +2 -1
- package/hedhog/frontend/app/goals/page.tsx.ejs +2 -1
- package/hedhog/frontend/app/projects/[id]/page.tsx.ejs +857 -107
- package/hedhog/frontend/app/projects/page.tsx.ejs +1044 -81
- package/hedhog/frontend/app/rewards/page.tsx.ejs +2 -1
- package/hedhog/frontend/app/tasks/page.tsx.ejs +968 -16
- package/hedhog/frontend/messages/en.json +306 -4
- package/hedhog/frontend/messages/pt.json +306 -4
- package/package.json +5 -5
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
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>
|
|
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.
|
|
53
|
-
current=
|
|
259
|
+
description={t('description', { client: project.client })}
|
|
260
|
+
current={t('breadcrumb')}
|
|
54
261
|
/>
|
|
55
262
|
|
|
56
|
-
<
|
|
57
|
-
<
|
|
58
|
-
|
|
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
|
-
<
|
|
66
|
-
<
|
|
67
|
-
<div className="
|
|
68
|
-
<
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
|
|
82
|
-
|
|
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="
|
|
85
|
-
|
|
86
|
-
{
|
|
322
|
+
<p className="text-xl font-semibold">
|
|
323
|
+
{scheduleVariance >= 0 ? '+' : ''}
|
|
324
|
+
{Math.round(scheduleVariance)}%
|
|
87
325
|
</p>
|
|
88
326
|
</div>
|
|
89
|
-
</
|
|
90
|
-
</
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
</div>
|
|
91
330
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
</
|
|
113
|
-
</
|
|
114
|
-
</SectionCard>
|
|
115
|
-
</TabsContent>
|
|
397
|
+
</div>
|
|
398
|
+
</SectionCard>
|
|
116
399
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
<
|
|
141
|
-
<
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
<
|
|
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={
|
|
148
|
-
className={
|
|
443
|
+
label={t(`riskSeverity.${risk.severity}`)}
|
|
444
|
+
className={riskSeverityClasses[risk.severity]}
|
|
149
445
|
/>
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
</
|
|
155
|
-
</
|
|
156
|
-
</
|
|
473
|
+
</div>
|
|
474
|
+
</SectionCard>
|
|
475
|
+
</div>
|
|
157
476
|
</TabsContent>
|
|
158
477
|
|
|
159
|
-
<TabsContent value="
|
|
160
|
-
<
|
|
161
|
-
<
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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="
|
|
169
|
-
<p className="
|
|
170
|
-
|
|
171
|
-
{
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
</
|
|
181
|
-
|
|
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>
|