@hed-hog/operations 0.0.319 → 0.0.322
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/controllers/operations-tasks.controller.d.ts +22 -0
- package/dist/controllers/operations-tasks.controller.d.ts.map +1 -1
- package/dist/controllers/operations-tasks.controller.js +37 -0
- package/dist/controllers/operations-tasks.controller.js.map +1 -1
- package/dist/dto/create-task.dto.d.ts.map +1 -1
- package/dist/dto/create-task.dto.js +0 -1
- package/dist/dto/create-task.dto.js.map +1 -1
- package/dist/dto/update-task.dto.d.ts.map +1 -1
- package/dist/dto/update-task.dto.js +0 -1
- package/dist/dto/update-task.dto.js.map +1 -1
- package/dist/operations.service.d.ts +22 -0
- package/dist/operations.service.d.ts.map +1 -1
- package/dist/operations.service.js +187 -132
- package/dist/operations.service.js.map +1 -1
- package/hedhog/data/operations_cost_type.yaml +95 -95
- package/hedhog/data/route.yaml +39 -0
- package/hedhog/frontend/app/_components/collaborator-costs-section.tsx.ejs +884 -884
- package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +23 -23
- package/hedhog/frontend/app/_components/person-select-with-create.tsx.ejs +49 -22
- package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +2968 -624
- package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +62 -68
- package/hedhog/frontend/app/_components/task-file-attachments.tsx.ejs +388 -0
- package/hedhog/frontend/app/_lib/types.ts.ejs +179 -178
- package/hedhog/frontend/app/my-tasks/page.tsx.ejs +121 -11
- package/hedhog/frontend/app/projects/page.tsx.ejs +105 -22
- package/hedhog/frontend/app/reports/collaborators/page.tsx.ejs +771 -771
- package/hedhog/frontend/app/reports/projects/page.tsx.ejs +809 -809
- package/hedhog/frontend/messages/en.json +143 -2
- package/hedhog/frontend/messages/pt.json +143 -2
- package/hedhog/table/operations_task_file.yaml +23 -0
- package/package.json +5 -5
- package/src/controllers/operations-reports.controller.ts +32 -32
- package/src/controllers/operations-tasks.controller.ts +43 -9
- package/src/dto/create-task.dto.ts +0 -1
- package/src/dto/list-reports.dto.ts +51 -51
- package/src/dto/update-task.dto.ts +0 -1
- package/src/operations.module.ts +5 -5
- package/src/operations.service.ts +754 -632
|
@@ -1,809 +1,809 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { Page } from '@/components/entity-list';
|
|
4
|
-
import { Badge } from '@/components/ui/badge';
|
|
5
|
-
import { Button } from '@/components/ui/button';
|
|
6
|
-
import {
|
|
7
|
-
Card,
|
|
8
|
-
CardContent,
|
|
9
|
-
CardDescription,
|
|
10
|
-
CardHeader,
|
|
11
|
-
CardTitle,
|
|
12
|
-
} from '@/components/ui/card';
|
|
13
|
-
import { Input } from '@/components/ui/input';
|
|
14
|
-
import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
|
|
15
|
-
import { Progress } from '@/components/ui/progress';
|
|
16
|
-
import {
|
|
17
|
-
Select,
|
|
18
|
-
SelectContent,
|
|
19
|
-
SelectItem,
|
|
20
|
-
SelectTrigger,
|
|
21
|
-
SelectValue,
|
|
22
|
-
} from '@/components/ui/select';
|
|
23
|
-
import {
|
|
24
|
-
Table,
|
|
25
|
-
TableBody,
|
|
26
|
-
TableCell,
|
|
27
|
-
TableHead,
|
|
28
|
-
TableHeader,
|
|
29
|
-
TableRow,
|
|
30
|
-
} from '@/components/ui/table';
|
|
31
|
-
import { useApp, useQuery } from '@hed-hog/next-app-provider';
|
|
32
|
-
import {
|
|
33
|
-
Banknote,
|
|
34
|
-
CalendarClock,
|
|
35
|
-
Clock3,
|
|
36
|
-
FolderKanban,
|
|
37
|
-
Gauge,
|
|
38
|
-
Handshake,
|
|
39
|
-
Route,
|
|
40
|
-
Target,
|
|
41
|
-
TrendingUp,
|
|
42
|
-
UsersRound,
|
|
43
|
-
} from 'lucide-react';
|
|
44
|
-
import type { LucideIcon } from 'lucide-react';
|
|
45
|
-
import { useState } from 'react';
|
|
46
|
-
import type { TooltipProps } from 'recharts';
|
|
47
|
-
import {
|
|
48
|
-
Area,
|
|
49
|
-
AreaChart,
|
|
50
|
-
Bar,
|
|
51
|
-
BarChart,
|
|
52
|
-
CartesianGrid,
|
|
53
|
-
Cell,
|
|
54
|
-
Legend,
|
|
55
|
-
Line,
|
|
56
|
-
LineChart,
|
|
57
|
-
Pie,
|
|
58
|
-
PieChart,
|
|
59
|
-
PolarAngleAxis,
|
|
60
|
-
PolarGrid,
|
|
61
|
-
Radar,
|
|
62
|
-
RadarChart,
|
|
63
|
-
ResponsiveContainer,
|
|
64
|
-
Tooltip,
|
|
65
|
-
XAxis,
|
|
66
|
-
YAxis,
|
|
67
|
-
} from 'recharts';
|
|
68
|
-
import { OperationsHeader } from '../../_components/operations-header';
|
|
69
|
-
import { fetchOperations } from '../../_lib/api';
|
|
70
|
-
import type {
|
|
71
|
-
OperationsProjectsReport,
|
|
72
|
-
OperationsReportScenario,
|
|
73
|
-
} from '../../_lib/types';
|
|
74
|
-
import { formatCurrency, formatHours } from '../../_lib/utils/format';
|
|
75
|
-
|
|
76
|
-
const CHART_COLORS = {
|
|
77
|
-
revenue: '#2563eb',
|
|
78
|
-
cost: '#dc2626',
|
|
79
|
-
profit: '#16a34a',
|
|
80
|
-
margin: '#7c3aed',
|
|
81
|
-
billable: '#0891b2',
|
|
82
|
-
internal: '#f59e0b',
|
|
83
|
-
rework: '#e11d48',
|
|
84
|
-
free: '#94a3b8',
|
|
85
|
-
planned: '#0f766e',
|
|
86
|
-
actual: '#ea580c',
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
const statusLabels: Record<string, string> = {
|
|
90
|
-
on_track: 'No prazo',
|
|
91
|
-
attention: 'Atenção',
|
|
92
|
-
late: 'Atrasado',
|
|
93
|
-
paused: 'Pausado',
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
const statusClasses: Record<string, string> = {
|
|
97
|
-
on_track: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
|
98
|
-
attention: 'border-amber-200 bg-amber-50 text-amber-700',
|
|
99
|
-
late: 'border-rose-200 bg-rose-50 text-rose-700',
|
|
100
|
-
paused: 'border-slate-200 bg-slate-100 text-slate-700',
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
const contractLabels: Record<string, string> = {
|
|
104
|
-
fixed_price: 'Preço fechado',
|
|
105
|
-
time_materials: 'Hora / Material',
|
|
106
|
-
retainer: 'Recorrente',
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
const scenarioLabels: Record<OperationsReportScenario, string> = {
|
|
110
|
-
base: 'Base',
|
|
111
|
-
growth: 'Crescimento',
|
|
112
|
-
conservative: 'Conservador',
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
const planningIcons: LucideIcon[] = [
|
|
116
|
-
CalendarClock,
|
|
117
|
-
Handshake,
|
|
118
|
-
Route,
|
|
119
|
-
Target,
|
|
120
|
-
];
|
|
121
|
-
|
|
122
|
-
function getDefaultRange() {
|
|
123
|
-
const year = new Date().getFullYear();
|
|
124
|
-
return { from: `${year}-01-01`, to: `${year}-12-31` };
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function formatPercent(value: number) {
|
|
128
|
-
return `${Math.round(Number(value || 0))}%`;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function renderCurrencyTooltip({
|
|
132
|
-
active,
|
|
133
|
-
payload,
|
|
134
|
-
label,
|
|
135
|
-
}: TooltipProps<number, string>) {
|
|
136
|
-
if (!active || !payload?.length) return null;
|
|
137
|
-
|
|
138
|
-
return (
|
|
139
|
-
<div className="min-w-52 rounded-md border bg-popover px-3 py-2 text-sm text-popover-foreground shadow-xl">
|
|
140
|
-
<p className="mb-1 font-semibold">{label}</p>
|
|
141
|
-
{payload.map((entry) => (
|
|
142
|
-
<div
|
|
143
|
-
key={String(entry.dataKey ?? entry.name)}
|
|
144
|
-
className="flex items-center justify-between gap-3 py-0.5"
|
|
145
|
-
>
|
|
146
|
-
<span className="flex items-center gap-2 text-muted-foreground">
|
|
147
|
-
<span
|
|
148
|
-
className="size-2.5 rounded-full"
|
|
149
|
-
style={{ backgroundColor: entry.color }}
|
|
150
|
-
/>
|
|
151
|
-
{entry.name}
|
|
152
|
-
</span>
|
|
153
|
-
<span className="font-semibold">
|
|
154
|
-
{formatCurrency(Number(entry.value ?? 0))}
|
|
155
|
-
</span>
|
|
156
|
-
</div>
|
|
157
|
-
))}
|
|
158
|
-
</div>
|
|
159
|
-
);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function renderNumberTooltip({
|
|
163
|
-
active,
|
|
164
|
-
payload,
|
|
165
|
-
label,
|
|
166
|
-
}: TooltipProps<number, string>) {
|
|
167
|
-
if (!active || !payload?.length) return null;
|
|
168
|
-
|
|
169
|
-
return (
|
|
170
|
-
<div className="min-w-44 rounded-md border bg-popover px-3 py-2 text-sm text-popover-foreground shadow-xl">
|
|
171
|
-
<p className="mb-1 font-semibold">{label}</p>
|
|
172
|
-
{payload.map((entry) => (
|
|
173
|
-
<div
|
|
174
|
-
key={String(entry.dataKey ?? entry.name)}
|
|
175
|
-
className="flex items-center justify-between gap-3 py-0.5"
|
|
176
|
-
>
|
|
177
|
-
<span className="flex items-center gap-2 text-muted-foreground">
|
|
178
|
-
<span
|
|
179
|
-
className="size-2.5 rounded-full"
|
|
180
|
-
style={{ backgroundColor: entry.color }}
|
|
181
|
-
/>
|
|
182
|
-
{entry.name}
|
|
183
|
-
</span>
|
|
184
|
-
<span className="font-semibold">{Number(entry.value ?? 0)}</span>
|
|
185
|
-
</div>
|
|
186
|
-
))}
|
|
187
|
-
</div>
|
|
188
|
-
);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
export default function OperationsProjectReportsPage() {
|
|
192
|
-
const defaults = getDefaultRange();
|
|
193
|
-
const { request } = useApp();
|
|
194
|
-
const [startDate, setStartDate] = useState(defaults.from);
|
|
195
|
-
const [endDate, setEndDate] = useState(defaults.to);
|
|
196
|
-
const [statusFilter, setStatusFilter] = useState('all');
|
|
197
|
-
const [clientFilter, setClientFilter] = useState('all');
|
|
198
|
-
const [scenario, setScenario] = useState<OperationsReportScenario>('base');
|
|
199
|
-
|
|
200
|
-
const { data } = useQuery<OperationsProjectsReport>({
|
|
201
|
-
queryKey: [
|
|
202
|
-
'operations-projects-report',
|
|
203
|
-
startDate,
|
|
204
|
-
endDate,
|
|
205
|
-
statusFilter,
|
|
206
|
-
clientFilter,
|
|
207
|
-
scenario,
|
|
208
|
-
],
|
|
209
|
-
queryFn: () => {
|
|
210
|
-
const params = new URLSearchParams({
|
|
211
|
-
from: startDate,
|
|
212
|
-
to: endDate,
|
|
213
|
-
status: statusFilter,
|
|
214
|
-
client: clientFilter,
|
|
215
|
-
scenario,
|
|
216
|
-
});
|
|
217
|
-
return fetchOperations<OperationsProjectsReport>(
|
|
218
|
-
request,
|
|
219
|
-
`/operations/reports/projects?${params.toString()}`
|
|
220
|
-
);
|
|
221
|
-
},
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
const summary = data?.summary ?? {
|
|
225
|
-
contractedRevenue: 0,
|
|
226
|
-
recognizedRevenue: 0,
|
|
227
|
-
realizedCost: 0,
|
|
228
|
-
forecastCost: 0,
|
|
229
|
-
profit: 0,
|
|
230
|
-
margin: 0,
|
|
231
|
-
plannedHours: 0,
|
|
232
|
-
actualHours: 0,
|
|
233
|
-
billableHours: 0,
|
|
234
|
-
reworkHours: 0,
|
|
235
|
-
backlogValue: 0,
|
|
236
|
-
avgDeadline: 0,
|
|
237
|
-
avgAllocation: 0,
|
|
238
|
-
atRisk: 0,
|
|
239
|
-
burnRate: 0,
|
|
240
|
-
};
|
|
241
|
-
const clients = data?.filters.clients ?? [];
|
|
242
|
-
|
|
243
|
-
return (
|
|
244
|
-
<Page>
|
|
245
|
-
<OperationsHeader
|
|
246
|
-
title="Relatório de Projetos"
|
|
247
|
-
description="Dashboard executivo com receita, custo, lucro, prazo, risco e planejamento dos próximos 12 meses."
|
|
248
|
-
current="Relatórios / Projetos"
|
|
249
|
-
/>
|
|
250
|
-
|
|
251
|
-
<div className="grid gap-3 rounded-lg border bg-card p-3 md:grid-cols-[1fr_1fr_180px_180px_180px_auto]">
|
|
252
|
-
<div className="space-y-1">
|
|
253
|
-
<span className="text-xs font-medium text-muted-foreground">
|
|
254
|
-
Início
|
|
255
|
-
</span>
|
|
256
|
-
<Input
|
|
257
|
-
type="date"
|
|
258
|
-
value={startDate}
|
|
259
|
-
onChange={(event) => setStartDate(event.target.value)}
|
|
260
|
-
/>
|
|
261
|
-
</div>
|
|
262
|
-
<div className="space-y-1">
|
|
263
|
-
<span className="text-xs font-medium text-muted-foreground">Fim</span>
|
|
264
|
-
<Input
|
|
265
|
-
type="date"
|
|
266
|
-
value={endDate}
|
|
267
|
-
onChange={(event) => setEndDate(event.target.value)}
|
|
268
|
-
/>
|
|
269
|
-
</div>
|
|
270
|
-
<div className="space-y-1">
|
|
271
|
-
<span className="text-xs font-medium text-muted-foreground">
|
|
272
|
-
Status
|
|
273
|
-
</span>
|
|
274
|
-
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
275
|
-
<SelectTrigger className="w-full">
|
|
276
|
-
<SelectValue />
|
|
277
|
-
</SelectTrigger>
|
|
278
|
-
<SelectContent>
|
|
279
|
-
<SelectItem value="all">Todos</SelectItem>
|
|
280
|
-
<SelectItem value="on_track">No prazo</SelectItem>
|
|
281
|
-
<SelectItem value="attention">Atenção</SelectItem>
|
|
282
|
-
<SelectItem value="late">Atrasado</SelectItem>
|
|
283
|
-
<SelectItem value="paused">Pausado</SelectItem>
|
|
284
|
-
</SelectContent>
|
|
285
|
-
</Select>
|
|
286
|
-
</div>
|
|
287
|
-
<div className="space-y-1">
|
|
288
|
-
<span className="text-xs font-medium text-muted-foreground">
|
|
289
|
-
Cliente
|
|
290
|
-
</span>
|
|
291
|
-
<Select value={clientFilter} onValueChange={setClientFilter}>
|
|
292
|
-
<SelectTrigger className="w-full">
|
|
293
|
-
<SelectValue />
|
|
294
|
-
</SelectTrigger>
|
|
295
|
-
<SelectContent>
|
|
296
|
-
<SelectItem value="all">Todos</SelectItem>
|
|
297
|
-
{clients.map((client) => (
|
|
298
|
-
<SelectItem key={client} value={client}>
|
|
299
|
-
{client}
|
|
300
|
-
</SelectItem>
|
|
301
|
-
))}
|
|
302
|
-
</SelectContent>
|
|
303
|
-
</Select>
|
|
304
|
-
</div>
|
|
305
|
-
<div className="space-y-1">
|
|
306
|
-
<span className="text-xs font-medium text-muted-foreground">
|
|
307
|
-
Cenário
|
|
308
|
-
</span>
|
|
309
|
-
<Select
|
|
310
|
-
value={scenario}
|
|
311
|
-
onValueChange={(value) =>
|
|
312
|
-
setScenario(value as OperationsReportScenario)
|
|
313
|
-
}
|
|
314
|
-
>
|
|
315
|
-
<SelectTrigger className="w-full">
|
|
316
|
-
<SelectValue />
|
|
317
|
-
</SelectTrigger>
|
|
318
|
-
<SelectContent>
|
|
319
|
-
<SelectItem value="base">Base</SelectItem>
|
|
320
|
-
<SelectItem value="growth">Crescimento</SelectItem>
|
|
321
|
-
<SelectItem value="conservative">Conservador</SelectItem>
|
|
322
|
-
</SelectContent>
|
|
323
|
-
</Select>
|
|
324
|
-
</div>
|
|
325
|
-
<div className="flex items-end">
|
|
326
|
-
<Button
|
|
327
|
-
type="button"
|
|
328
|
-
variant="outline"
|
|
329
|
-
onClick={() => {
|
|
330
|
-
setStartDate(defaults.from);
|
|
331
|
-
setEndDate(defaults.to);
|
|
332
|
-
setStatusFilter('all');
|
|
333
|
-
setClientFilter('all');
|
|
334
|
-
setScenario('base');
|
|
335
|
-
}}
|
|
336
|
-
>
|
|
337
|
-
Limpar
|
|
338
|
-
</Button>
|
|
339
|
-
</div>
|
|
340
|
-
</div>
|
|
341
|
-
|
|
342
|
-
<KpiCardsGrid
|
|
343
|
-
columns={4}
|
|
344
|
-
items={[
|
|
345
|
-
{
|
|
346
|
-
key: 'active',
|
|
347
|
-
title: 'Projetos',
|
|
348
|
-
value: data?.rows.length ?? 0,
|
|
349
|
-
description: `${summary.atRisk} em risco operacional`,
|
|
350
|
-
icon: FolderKanban,
|
|
351
|
-
accentClassName: 'from-sky-500/20 via-cyan-400/10 to-transparent',
|
|
352
|
-
iconContainerClassName: 'bg-sky-50 text-sky-700',
|
|
353
|
-
},
|
|
354
|
-
{
|
|
355
|
-
key: 'revenue',
|
|
356
|
-
title: 'Receita reconhecida',
|
|
357
|
-
value: formatCurrency(summary.recognizedRevenue),
|
|
358
|
-
description: `${formatCurrency(summary.contractedRevenue)} contratado`,
|
|
359
|
-
icon: Banknote,
|
|
360
|
-
accentClassName:
|
|
361
|
-
'from-emerald-500/20 via-green-400/10 to-transparent',
|
|
362
|
-
iconContainerClassName: 'bg-emerald-50 text-emerald-700',
|
|
363
|
-
},
|
|
364
|
-
{
|
|
365
|
-
key: 'cost',
|
|
366
|
-
title: 'Custo realizado',
|
|
367
|
-
value: formatCurrency(summary.realizedCost),
|
|
368
|
-
description: `${formatPercent(summary.burnRate)} de burn rate`,
|
|
369
|
-
icon: Gauge,
|
|
370
|
-
accentClassName: 'from-rose-500/20 via-red-400/10 to-transparent',
|
|
371
|
-
iconContainerClassName: 'bg-rose-50 text-rose-700',
|
|
372
|
-
},
|
|
373
|
-
{
|
|
374
|
-
key: 'profit',
|
|
375
|
-
title: 'Lucro',
|
|
376
|
-
value: formatCurrency(summary.profit),
|
|
377
|
-
description: `${formatPercent(summary.margin)} de margem`,
|
|
378
|
-
icon: TrendingUp,
|
|
379
|
-
accentClassName:
|
|
380
|
-
'from-violet-500/20 via-fuchsia-400/10 to-transparent',
|
|
381
|
-
iconContainerClassName: 'bg-violet-50 text-violet-700',
|
|
382
|
-
},
|
|
383
|
-
{
|
|
384
|
-
key: 'hours',
|
|
385
|
-
title: 'Horas consumidas',
|
|
386
|
-
value: formatHours(summary.actualHours),
|
|
387
|
-
description: `${formatHours(summary.billableHours)} faturáveis`,
|
|
388
|
-
icon: Clock3,
|
|
389
|
-
layout: 'compact',
|
|
390
|
-
accentClassName:
|
|
391
|
-
'from-cyan-500/20 via-blue-400/10 to-transparent',
|
|
392
|
-
iconContainerClassName: 'bg-cyan-50 text-cyan-700',
|
|
393
|
-
},
|
|
394
|
-
{
|
|
395
|
-
key: 'deadline',
|
|
396
|
-
title: 'Avanço médio',
|
|
397
|
-
value: formatPercent(summary.avgDeadline),
|
|
398
|
-
description: 'Progresso físico dos projetos',
|
|
399
|
-
icon: CalendarClock,
|
|
400
|
-
layout: 'compact',
|
|
401
|
-
accentClassName:
|
|
402
|
-
'from-amber-500/20 via-yellow-400/10 to-transparent',
|
|
403
|
-
iconContainerClassName: 'bg-amber-50 text-amber-700',
|
|
404
|
-
},
|
|
405
|
-
{
|
|
406
|
-
key: 'allocation',
|
|
407
|
-
title: 'Alocação média',
|
|
408
|
-
value: formatPercent(summary.avgAllocation),
|
|
409
|
-
description: 'Acima de 95% exige atenção',
|
|
410
|
-
icon: UsersRound,
|
|
411
|
-
layout: 'compact',
|
|
412
|
-
accentClassName:
|
|
413
|
-
'from-indigo-500/20 via-blue-400/10 to-transparent',
|
|
414
|
-
iconContainerClassName: 'bg-indigo-50 text-indigo-700',
|
|
415
|
-
},
|
|
416
|
-
{
|
|
417
|
-
key: 'backlog',
|
|
418
|
-
title: 'Backlog futuro',
|
|
419
|
-
value: formatCurrency(summary.backlogValue),
|
|
420
|
-
description: `Cenário ${scenarioLabels[scenario]}`,
|
|
421
|
-
icon: Target,
|
|
422
|
-
layout: 'compact',
|
|
423
|
-
accentClassName:
|
|
424
|
-
'from-slate-500/20 via-slate-400/10 to-transparent',
|
|
425
|
-
iconContainerClassName: 'bg-slate-100 text-slate-700',
|
|
426
|
-
},
|
|
427
|
-
]}
|
|
428
|
-
/>
|
|
429
|
-
|
|
430
|
-
<div className="grid gap-4 xl:grid-cols-[1.4fr_0.9fr]">
|
|
431
|
-
<Card>
|
|
432
|
-
<CardHeader>
|
|
433
|
-
<CardTitle>Projeção de receita, custo e lucro</CardTitle>
|
|
434
|
-
<CardDescription>
|
|
435
|
-
Simulação mensal para planejamento de portfólio nos próximos 12
|
|
436
|
-
meses.
|
|
437
|
-
</CardDescription>
|
|
438
|
-
</CardHeader>
|
|
439
|
-
<CardContent>
|
|
440
|
-
<ResponsiveContainer width="100%" height={360}>
|
|
441
|
-
<AreaChart data={data?.forecast ?? []}>
|
|
442
|
-
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
|
443
|
-
<XAxis dataKey="month" tick={{ fontSize: 12 }} />
|
|
444
|
-
<YAxis
|
|
445
|
-
tick={{ fontSize: 12 }}
|
|
446
|
-
tickFormatter={(value) => `${Number(value) / 1000}k`}
|
|
447
|
-
/>
|
|
448
|
-
<Tooltip content={renderCurrencyTooltip} />
|
|
449
|
-
<Legend />
|
|
450
|
-
<Area
|
|
451
|
-
dataKey="revenue"
|
|
452
|
-
name="Receita"
|
|
453
|
-
stroke={CHART_COLORS.revenue}
|
|
454
|
-
strokeWidth={3}
|
|
455
|
-
fill={CHART_COLORS.revenue}
|
|
456
|
-
fillOpacity={0.12}
|
|
457
|
-
/>
|
|
458
|
-
<Area
|
|
459
|
-
dataKey="cost"
|
|
460
|
-
name="Custo"
|
|
461
|
-
stroke={CHART_COLORS.cost}
|
|
462
|
-
strokeWidth={3}
|
|
463
|
-
fill={CHART_COLORS.cost}
|
|
464
|
-
fillOpacity={0.08}
|
|
465
|
-
/>
|
|
466
|
-
<Area
|
|
467
|
-
dataKey="profit"
|
|
468
|
-
name="Lucro"
|
|
469
|
-
stroke={CHART_COLORS.profit}
|
|
470
|
-
strokeWidth={2}
|
|
471
|
-
fill="transparent"
|
|
472
|
-
/>
|
|
473
|
-
</AreaChart>
|
|
474
|
-
</ResponsiveContainer>
|
|
475
|
-
</CardContent>
|
|
476
|
-
</Card>
|
|
477
|
-
|
|
478
|
-
<Card>
|
|
479
|
-
<CardHeader>
|
|
480
|
-
<CardTitle>Composição de custos</CardTitle>
|
|
481
|
-
<CardDescription>
|
|
482
|
-
Equipe, infraestrutura, licenças, terceiros e retrabalho.
|
|
483
|
-
</CardDescription>
|
|
484
|
-
</CardHeader>
|
|
485
|
-
<CardContent>
|
|
486
|
-
<ResponsiveContainer width="100%" height={300}>
|
|
487
|
-
<PieChart>
|
|
488
|
-
<Pie
|
|
489
|
-
data={(data?.costComposition ?? []).filter(
|
|
490
|
-
(item) => item.value > 0
|
|
491
|
-
)}
|
|
492
|
-
dataKey="value"
|
|
493
|
-
nameKey="name"
|
|
494
|
-
innerRadius={58}
|
|
495
|
-
outerRadius={98}
|
|
496
|
-
paddingAngle={3}
|
|
497
|
-
>
|
|
498
|
-
{(data?.costComposition ?? []).map((entry, index) => (
|
|
499
|
-
<Cell
|
|
500
|
-
key={entry.name}
|
|
501
|
-
fill={
|
|
502
|
-
[
|
|
503
|
-
CHART_COLORS.revenue,
|
|
504
|
-
CHART_COLORS.internal,
|
|
505
|
-
CHART_COLORS.margin,
|
|
506
|
-
CHART_COLORS.billable,
|
|
507
|
-
CHART_COLORS.rework,
|
|
508
|
-
][index % 5]
|
|
509
|
-
}
|
|
510
|
-
stroke="hsl(var(--background))"
|
|
511
|
-
strokeWidth={2}
|
|
512
|
-
/>
|
|
513
|
-
))}
|
|
514
|
-
</Pie>
|
|
515
|
-
<Legend iconType="circle" wrapperStyle={{ fontSize: 12 }} />
|
|
516
|
-
<Tooltip content={renderCurrencyTooltip} />
|
|
517
|
-
</PieChart>
|
|
518
|
-
</ResponsiveContainer>
|
|
519
|
-
</CardContent>
|
|
520
|
-
</Card>
|
|
521
|
-
</div>
|
|
522
|
-
|
|
523
|
-
<div className="grid gap-4 xl:grid-cols-[1fr_1fr]">
|
|
524
|
-
<Card>
|
|
525
|
-
<CardHeader>
|
|
526
|
-
<CardTitle>Horas por projeto</CardTitle>
|
|
527
|
-
<CardDescription>
|
|
528
|
-
Separação entre horas faturáveis, internas, retrabalho e saldo.
|
|
529
|
-
</CardDescription>
|
|
530
|
-
</CardHeader>
|
|
531
|
-
<CardContent>
|
|
532
|
-
<ResponsiveContainer width="100%" height={330}>
|
|
533
|
-
<BarChart data={data?.hoursByProject ?? []}>
|
|
534
|
-
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
|
535
|
-
<XAxis dataKey="project" tick={{ fontSize: 12 }} />
|
|
536
|
-
<YAxis tick={{ fontSize: 12 }} />
|
|
537
|
-
<Tooltip content={renderNumberTooltip} />
|
|
538
|
-
<Legend />
|
|
539
|
-
<Bar
|
|
540
|
-
dataKey="Faturável"
|
|
541
|
-
stackId="hours"
|
|
542
|
-
fill={CHART_COLORS.billable}
|
|
543
|
-
/>
|
|
544
|
-
<Bar
|
|
545
|
-
dataKey="Interno"
|
|
546
|
-
stackId="hours"
|
|
547
|
-
fill={CHART_COLORS.internal}
|
|
548
|
-
/>
|
|
549
|
-
<Bar
|
|
550
|
-
dataKey="Retrabalho"
|
|
551
|
-
stackId="hours"
|
|
552
|
-
fill={CHART_COLORS.rework}
|
|
553
|
-
/>
|
|
554
|
-
<Bar
|
|
555
|
-
dataKey="Livre"
|
|
556
|
-
stackId="hours"
|
|
557
|
-
fill={CHART_COLORS.free}
|
|
558
|
-
radius={[6, 6, 0, 0]}
|
|
559
|
-
/>
|
|
560
|
-
</BarChart>
|
|
561
|
-
</ResponsiveContainer>
|
|
562
|
-
</CardContent>
|
|
563
|
-
</Card>
|
|
564
|
-
|
|
565
|
-
<Card>
|
|
566
|
-
<CardHeader>
|
|
567
|
-
<CardTitle>Saúde dos projetos</CardTitle>
|
|
568
|
-
<CardDescription>
|
|
569
|
-
Combina margem, prazo, alocação e risco por projeto.
|
|
570
|
-
</CardDescription>
|
|
571
|
-
</CardHeader>
|
|
572
|
-
<CardContent>
|
|
573
|
-
<ResponsiveContainer width="100%" height={330}>
|
|
574
|
-
<RadarChart data={data?.health ?? []}>
|
|
575
|
-
<PolarGrid />
|
|
576
|
-
<PolarAngleAxis dataKey="project" tick={{ fontSize: 12 }} />
|
|
577
|
-
<Tooltip content={renderNumberTooltip} />
|
|
578
|
-
<Legend />
|
|
579
|
-
<Radar
|
|
580
|
-
dataKey="margem"
|
|
581
|
-
name="Margem"
|
|
582
|
-
stroke={CHART_COLORS.profit}
|
|
583
|
-
fill={CHART_COLORS.profit}
|
|
584
|
-
fillOpacity={0.16}
|
|
585
|
-
/>
|
|
586
|
-
<Radar
|
|
587
|
-
dataKey="prazo"
|
|
588
|
-
name="Prazo"
|
|
589
|
-
stroke={CHART_COLORS.revenue}
|
|
590
|
-
fill={CHART_COLORS.revenue}
|
|
591
|
-
fillOpacity={0.12}
|
|
592
|
-
/>
|
|
593
|
-
<Radar
|
|
594
|
-
dataKey="saude"
|
|
595
|
-
name="Saúde"
|
|
596
|
-
stroke={CHART_COLORS.margin}
|
|
597
|
-
fill={CHART_COLORS.margin}
|
|
598
|
-
fillOpacity={0.1}
|
|
599
|
-
/>
|
|
600
|
-
</RadarChart>
|
|
601
|
-
</ResponsiveContainer>
|
|
602
|
-
</CardContent>
|
|
603
|
-
</Card>
|
|
604
|
-
</div>
|
|
605
|
-
|
|
606
|
-
<div className="grid gap-4 xl:grid-cols-[1fr_1fr]">
|
|
607
|
-
<Card>
|
|
608
|
-
<CardHeader>
|
|
609
|
-
<CardTitle>Ranking de lucro e custo</CardTitle>
|
|
610
|
-
<CardDescription>
|
|
611
|
-
Projetos ordenados por contribuição estimada no período.
|
|
612
|
-
</CardDescription>
|
|
613
|
-
</CardHeader>
|
|
614
|
-
<CardContent>
|
|
615
|
-
<ResponsiveContainer width="100%" height={340}>
|
|
616
|
-
<BarChart
|
|
617
|
-
data={data?.ranking ?? []}
|
|
618
|
-
layout="vertical"
|
|
619
|
-
margin={{ left: 56, right: 16 }}
|
|
620
|
-
>
|
|
621
|
-
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
|
622
|
-
<XAxis
|
|
623
|
-
type="number"
|
|
624
|
-
tick={{ fontSize: 12 }}
|
|
625
|
-
tickFormatter={(value) => `${Number(value) / 1000}k`}
|
|
626
|
-
/>
|
|
627
|
-
<YAxis dataKey="name" type="category" width={120} />
|
|
628
|
-
<Tooltip content={renderCurrencyTooltip} />
|
|
629
|
-
<Legend />
|
|
630
|
-
<Bar dataKey="Lucro" fill={CHART_COLORS.profit} radius={6} />
|
|
631
|
-
<Bar dataKey="Custo" fill={CHART_COLORS.cost} radius={6} />
|
|
632
|
-
</BarChart>
|
|
633
|
-
</ResponsiveContainer>
|
|
634
|
-
</CardContent>
|
|
635
|
-
</Card>
|
|
636
|
-
|
|
637
|
-
<Card>
|
|
638
|
-
<CardHeader>
|
|
639
|
-
<CardTitle>Planejado vs realizado</CardTitle>
|
|
640
|
-
<CardDescription>
|
|
641
|
-
Tendência mensal de consumo de horas frente ao plano.
|
|
642
|
-
</CardDescription>
|
|
643
|
-
</CardHeader>
|
|
644
|
-
<CardContent>
|
|
645
|
-
<ResponsiveContainer width="100%" height={340}>
|
|
646
|
-
<LineChart data={data?.progress ?? []}>
|
|
647
|
-
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
|
648
|
-
<XAxis dataKey="month" tick={{ fontSize: 12 }} />
|
|
649
|
-
<YAxis tick={{ fontSize: 12 }} />
|
|
650
|
-
<Tooltip content={renderNumberTooltip} />
|
|
651
|
-
<Legend />
|
|
652
|
-
<Line
|
|
653
|
-
type="monotone"
|
|
654
|
-
dataKey="Planejado"
|
|
655
|
-
stroke={CHART_COLORS.planned}
|
|
656
|
-
strokeWidth={3}
|
|
657
|
-
dot={false}
|
|
658
|
-
/>
|
|
659
|
-
<Line
|
|
660
|
-
type="monotone"
|
|
661
|
-
dataKey="Realizado"
|
|
662
|
-
stroke={CHART_COLORS.actual}
|
|
663
|
-
strokeWidth={3}
|
|
664
|
-
dot={false}
|
|
665
|
-
/>
|
|
666
|
-
</LineChart>
|
|
667
|
-
</ResponsiveContainer>
|
|
668
|
-
</CardContent>
|
|
669
|
-
</Card>
|
|
670
|
-
</div>
|
|
671
|
-
|
|
672
|
-
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
673
|
-
{(data?.planningCards ?? []).map((card, index) => {
|
|
674
|
-
const Icon =
|
|
675
|
-
planningIcons[index % planningIcons.length] ?? CalendarClock;
|
|
676
|
-
const colors = [
|
|
677
|
-
'bg-amber-50 text-amber-700',
|
|
678
|
-
'bg-sky-50 text-sky-700',
|
|
679
|
-
'bg-emerald-50 text-emerald-700',
|
|
680
|
-
'bg-violet-50 text-violet-700',
|
|
681
|
-
];
|
|
682
|
-
return (
|
|
683
|
-
<Card key={card.title}>
|
|
684
|
-
<CardContent className="space-y-3 p-4">
|
|
685
|
-
<div className="flex items-start justify-between gap-3">
|
|
686
|
-
<div>
|
|
687
|
-
<p className="text-sm font-medium text-muted-foreground">
|
|
688
|
-
{card.title}
|
|
689
|
-
</p>
|
|
690
|
-
<p className="mt-1 text-2xl font-semibold">
|
|
691
|
-
{card.value}
|
|
692
|
-
</p>
|
|
693
|
-
</div>
|
|
694
|
-
<div className={`rounded-xl p-2 ${colors[index % colors.length]}`}>
|
|
695
|
-
<Icon className="size-5" />
|
|
696
|
-
</div>
|
|
697
|
-
</div>
|
|
698
|
-
<p className="text-sm leading-relaxed text-muted-foreground">
|
|
699
|
-
{card.description}
|
|
700
|
-
</p>
|
|
701
|
-
</CardContent>
|
|
702
|
-
</Card>
|
|
703
|
-
);
|
|
704
|
-
})}
|
|
705
|
-
</div>
|
|
706
|
-
|
|
707
|
-
<Card>
|
|
708
|
-
<CardHeader>
|
|
709
|
-
<CardTitle>Detalhamento por projeto</CardTitle>
|
|
710
|
-
<CardDescription>
|
|
711
|
-
Receita, custo, lucro, horas, prazo, risco e recomendação executiva.
|
|
712
|
-
</CardDescription>
|
|
713
|
-
</CardHeader>
|
|
714
|
-
<CardContent className="overflow-x-auto">
|
|
715
|
-
<Table>
|
|
716
|
-
<TableHeader>
|
|
717
|
-
<TableRow>
|
|
718
|
-
<TableHead>Projeto</TableHead>
|
|
719
|
-
<TableHead>Status</TableHead>
|
|
720
|
-
<TableHead>Receita</TableHead>
|
|
721
|
-
<TableHead>Custo</TableHead>
|
|
722
|
-
<TableHead>Lucro</TableHead>
|
|
723
|
-
<TableHead>Horas</TableHead>
|
|
724
|
-
<TableHead>Prazo</TableHead>
|
|
725
|
-
<TableHead>Risco</TableHead>
|
|
726
|
-
<TableHead>Recomendação</TableHead>
|
|
727
|
-
</TableRow>
|
|
728
|
-
</TableHeader>
|
|
729
|
-
<TableBody>
|
|
730
|
-
{(data?.rows ?? []).map((row) => {
|
|
731
|
-
const profit = row.recognizedRevenue - row.realizedCost;
|
|
732
|
-
const margin = row.recognizedRevenue
|
|
733
|
-
? (profit / row.recognizedRevenue) * 100
|
|
734
|
-
: 0;
|
|
735
|
-
const hourUsage = row.plannedHours
|
|
736
|
-
? (row.actualHours / row.plannedHours) * 100
|
|
737
|
-
: 0;
|
|
738
|
-
return (
|
|
739
|
-
<TableRow key={row.id}>
|
|
740
|
-
<TableCell className="min-w-72">
|
|
741
|
-
<div className="font-medium">{row.name}</div>
|
|
742
|
-
<div className="text-xs text-muted-foreground">
|
|
743
|
-
{row.client} · {row.manager} · {row.squad} ·{' '}
|
|
744
|
-
{contractLabels[row.contractType]}
|
|
745
|
-
</div>
|
|
746
|
-
</TableCell>
|
|
747
|
-
<TableCell>
|
|
748
|
-
<Badge
|
|
749
|
-
variant="outline"
|
|
750
|
-
className={statusClasses[row.status]}
|
|
751
|
-
>
|
|
752
|
-
{statusLabels[row.status]}
|
|
753
|
-
</Badge>
|
|
754
|
-
</TableCell>
|
|
755
|
-
<TableCell>{formatCurrency(row.recognizedRevenue)}</TableCell>
|
|
756
|
-
<TableCell>{formatCurrency(row.realizedCost)}</TableCell>
|
|
757
|
-
<TableCell
|
|
758
|
-
className={
|
|
759
|
-
profit >= 0 ? 'text-emerald-700' : 'text-rose-700'
|
|
760
|
-
}
|
|
761
|
-
>
|
|
762
|
-
<div>{formatCurrency(profit)}</div>
|
|
763
|
-
<div className="text-xs">{formatPercent(margin)}</div>
|
|
764
|
-
</TableCell>
|
|
765
|
-
<TableCell>
|
|
766
|
-
<div className="min-w-36 space-y-1">
|
|
767
|
-
<div className="flex items-center justify-between text-xs">
|
|
768
|
-
<span>{formatHours(row.actualHours)}</span>
|
|
769
|
-
<span>{formatPercent(hourUsage)}</span>
|
|
770
|
-
</div>
|
|
771
|
-
<Progress value={Math.min(hourUsage, 100)} />
|
|
772
|
-
</div>
|
|
773
|
-
</TableCell>
|
|
774
|
-
<TableCell>
|
|
775
|
-
<div className="min-w-32 space-y-1">
|
|
776
|
-
<div className="flex items-center justify-between text-xs">
|
|
777
|
-
<span>Físico</span>
|
|
778
|
-
<span>{formatPercent(row.physicalProgress)}</span>
|
|
779
|
-
</div>
|
|
780
|
-
<Progress value={row.physicalProgress} />
|
|
781
|
-
</div>
|
|
782
|
-
</TableCell>
|
|
783
|
-
<TableCell>
|
|
784
|
-
<Badge
|
|
785
|
-
variant="outline"
|
|
786
|
-
className={
|
|
787
|
-
row.risk === 'alto'
|
|
788
|
-
? 'border-rose-200 bg-rose-50 text-rose-700'
|
|
789
|
-
: row.risk === 'médio'
|
|
790
|
-
? 'border-amber-200 bg-amber-50 text-amber-700'
|
|
791
|
-
: 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
|
792
|
-
}
|
|
793
|
-
>
|
|
794
|
-
{row.risk}
|
|
795
|
-
</Badge>
|
|
796
|
-
</TableCell>
|
|
797
|
-
<TableCell className="min-w-72 text-sm text-muted-foreground">
|
|
798
|
-
{row.recommendation}
|
|
799
|
-
</TableCell>
|
|
800
|
-
</TableRow>
|
|
801
|
-
);
|
|
802
|
-
})}
|
|
803
|
-
</TableBody>
|
|
804
|
-
</Table>
|
|
805
|
-
</CardContent>
|
|
806
|
-
</Card>
|
|
807
|
-
</Page>
|
|
808
|
-
);
|
|
809
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Page } from '@/components/entity-list';
|
|
4
|
+
import { Badge } from '@/components/ui/badge';
|
|
5
|
+
import { Button } from '@/components/ui/button';
|
|
6
|
+
import {
|
|
7
|
+
Card,
|
|
8
|
+
CardContent,
|
|
9
|
+
CardDescription,
|
|
10
|
+
CardHeader,
|
|
11
|
+
CardTitle,
|
|
12
|
+
} from '@/components/ui/card';
|
|
13
|
+
import { Input } from '@/components/ui/input';
|
|
14
|
+
import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
|
|
15
|
+
import { Progress } from '@/components/ui/progress';
|
|
16
|
+
import {
|
|
17
|
+
Select,
|
|
18
|
+
SelectContent,
|
|
19
|
+
SelectItem,
|
|
20
|
+
SelectTrigger,
|
|
21
|
+
SelectValue,
|
|
22
|
+
} from '@/components/ui/select';
|
|
23
|
+
import {
|
|
24
|
+
Table,
|
|
25
|
+
TableBody,
|
|
26
|
+
TableCell,
|
|
27
|
+
TableHead,
|
|
28
|
+
TableHeader,
|
|
29
|
+
TableRow,
|
|
30
|
+
} from '@/components/ui/table';
|
|
31
|
+
import { useApp, useQuery } from '@hed-hog/next-app-provider';
|
|
32
|
+
import {
|
|
33
|
+
Banknote,
|
|
34
|
+
CalendarClock,
|
|
35
|
+
Clock3,
|
|
36
|
+
FolderKanban,
|
|
37
|
+
Gauge,
|
|
38
|
+
Handshake,
|
|
39
|
+
Route,
|
|
40
|
+
Target,
|
|
41
|
+
TrendingUp,
|
|
42
|
+
UsersRound,
|
|
43
|
+
} from 'lucide-react';
|
|
44
|
+
import type { LucideIcon } from 'lucide-react';
|
|
45
|
+
import { useState } from 'react';
|
|
46
|
+
import type { TooltipProps } from 'recharts';
|
|
47
|
+
import {
|
|
48
|
+
Area,
|
|
49
|
+
AreaChart,
|
|
50
|
+
Bar,
|
|
51
|
+
BarChart,
|
|
52
|
+
CartesianGrid,
|
|
53
|
+
Cell,
|
|
54
|
+
Legend,
|
|
55
|
+
Line,
|
|
56
|
+
LineChart,
|
|
57
|
+
Pie,
|
|
58
|
+
PieChart,
|
|
59
|
+
PolarAngleAxis,
|
|
60
|
+
PolarGrid,
|
|
61
|
+
Radar,
|
|
62
|
+
RadarChart,
|
|
63
|
+
ResponsiveContainer,
|
|
64
|
+
Tooltip,
|
|
65
|
+
XAxis,
|
|
66
|
+
YAxis,
|
|
67
|
+
} from 'recharts';
|
|
68
|
+
import { OperationsHeader } from '../../_components/operations-header';
|
|
69
|
+
import { fetchOperations } from '../../_lib/api';
|
|
70
|
+
import type {
|
|
71
|
+
OperationsProjectsReport,
|
|
72
|
+
OperationsReportScenario,
|
|
73
|
+
} from '../../_lib/types';
|
|
74
|
+
import { formatCurrency, formatHours } from '../../_lib/utils/format';
|
|
75
|
+
|
|
76
|
+
const CHART_COLORS = {
|
|
77
|
+
revenue: '#2563eb',
|
|
78
|
+
cost: '#dc2626',
|
|
79
|
+
profit: '#16a34a',
|
|
80
|
+
margin: '#7c3aed',
|
|
81
|
+
billable: '#0891b2',
|
|
82
|
+
internal: '#f59e0b',
|
|
83
|
+
rework: '#e11d48',
|
|
84
|
+
free: '#94a3b8',
|
|
85
|
+
planned: '#0f766e',
|
|
86
|
+
actual: '#ea580c',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const statusLabels: Record<string, string> = {
|
|
90
|
+
on_track: 'No prazo',
|
|
91
|
+
attention: 'Atenção',
|
|
92
|
+
late: 'Atrasado',
|
|
93
|
+
paused: 'Pausado',
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const statusClasses: Record<string, string> = {
|
|
97
|
+
on_track: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
|
98
|
+
attention: 'border-amber-200 bg-amber-50 text-amber-700',
|
|
99
|
+
late: 'border-rose-200 bg-rose-50 text-rose-700',
|
|
100
|
+
paused: 'border-slate-200 bg-slate-100 text-slate-700',
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const contractLabels: Record<string, string> = {
|
|
104
|
+
fixed_price: 'Preço fechado',
|
|
105
|
+
time_materials: 'Hora / Material',
|
|
106
|
+
retainer: 'Recorrente',
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const scenarioLabels: Record<OperationsReportScenario, string> = {
|
|
110
|
+
base: 'Base',
|
|
111
|
+
growth: 'Crescimento',
|
|
112
|
+
conservative: 'Conservador',
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const planningIcons: LucideIcon[] = [
|
|
116
|
+
CalendarClock,
|
|
117
|
+
Handshake,
|
|
118
|
+
Route,
|
|
119
|
+
Target,
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
function getDefaultRange() {
|
|
123
|
+
const year = new Date().getFullYear();
|
|
124
|
+
return { from: `${year}-01-01`, to: `${year}-12-31` };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function formatPercent(value: number) {
|
|
128
|
+
return `${Math.round(Number(value || 0))}%`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function renderCurrencyTooltip({
|
|
132
|
+
active,
|
|
133
|
+
payload,
|
|
134
|
+
label,
|
|
135
|
+
}: TooltipProps<number, string>) {
|
|
136
|
+
if (!active || !payload?.length) return null;
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<div className="min-w-52 rounded-md border bg-popover px-3 py-2 text-sm text-popover-foreground shadow-xl">
|
|
140
|
+
<p className="mb-1 font-semibold">{label}</p>
|
|
141
|
+
{payload.map((entry) => (
|
|
142
|
+
<div
|
|
143
|
+
key={String(entry.dataKey ?? entry.name)}
|
|
144
|
+
className="flex items-center justify-between gap-3 py-0.5"
|
|
145
|
+
>
|
|
146
|
+
<span className="flex items-center gap-2 text-muted-foreground">
|
|
147
|
+
<span
|
|
148
|
+
className="size-2.5 rounded-full"
|
|
149
|
+
style={{ backgroundColor: entry.color }}
|
|
150
|
+
/>
|
|
151
|
+
{entry.name}
|
|
152
|
+
</span>
|
|
153
|
+
<span className="font-semibold">
|
|
154
|
+
{formatCurrency(Number(entry.value ?? 0))}
|
|
155
|
+
</span>
|
|
156
|
+
</div>
|
|
157
|
+
))}
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function renderNumberTooltip({
|
|
163
|
+
active,
|
|
164
|
+
payload,
|
|
165
|
+
label,
|
|
166
|
+
}: TooltipProps<number, string>) {
|
|
167
|
+
if (!active || !payload?.length) return null;
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<div className="min-w-44 rounded-md border bg-popover px-3 py-2 text-sm text-popover-foreground shadow-xl">
|
|
171
|
+
<p className="mb-1 font-semibold">{label}</p>
|
|
172
|
+
{payload.map((entry) => (
|
|
173
|
+
<div
|
|
174
|
+
key={String(entry.dataKey ?? entry.name)}
|
|
175
|
+
className="flex items-center justify-between gap-3 py-0.5"
|
|
176
|
+
>
|
|
177
|
+
<span className="flex items-center gap-2 text-muted-foreground">
|
|
178
|
+
<span
|
|
179
|
+
className="size-2.5 rounded-full"
|
|
180
|
+
style={{ backgroundColor: entry.color }}
|
|
181
|
+
/>
|
|
182
|
+
{entry.name}
|
|
183
|
+
</span>
|
|
184
|
+
<span className="font-semibold">{Number(entry.value ?? 0)}</span>
|
|
185
|
+
</div>
|
|
186
|
+
))}
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export default function OperationsProjectReportsPage() {
|
|
192
|
+
const defaults = getDefaultRange();
|
|
193
|
+
const { request } = useApp();
|
|
194
|
+
const [startDate, setStartDate] = useState(defaults.from);
|
|
195
|
+
const [endDate, setEndDate] = useState(defaults.to);
|
|
196
|
+
const [statusFilter, setStatusFilter] = useState('all');
|
|
197
|
+
const [clientFilter, setClientFilter] = useState('all');
|
|
198
|
+
const [scenario, setScenario] = useState<OperationsReportScenario>('base');
|
|
199
|
+
|
|
200
|
+
const { data } = useQuery<OperationsProjectsReport>({
|
|
201
|
+
queryKey: [
|
|
202
|
+
'operations-projects-report',
|
|
203
|
+
startDate,
|
|
204
|
+
endDate,
|
|
205
|
+
statusFilter,
|
|
206
|
+
clientFilter,
|
|
207
|
+
scenario,
|
|
208
|
+
],
|
|
209
|
+
queryFn: () => {
|
|
210
|
+
const params = new URLSearchParams({
|
|
211
|
+
from: startDate,
|
|
212
|
+
to: endDate,
|
|
213
|
+
status: statusFilter,
|
|
214
|
+
client: clientFilter,
|
|
215
|
+
scenario,
|
|
216
|
+
});
|
|
217
|
+
return fetchOperations<OperationsProjectsReport>(
|
|
218
|
+
request,
|
|
219
|
+
`/operations/reports/projects?${params.toString()}`
|
|
220
|
+
);
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const summary = data?.summary ?? {
|
|
225
|
+
contractedRevenue: 0,
|
|
226
|
+
recognizedRevenue: 0,
|
|
227
|
+
realizedCost: 0,
|
|
228
|
+
forecastCost: 0,
|
|
229
|
+
profit: 0,
|
|
230
|
+
margin: 0,
|
|
231
|
+
plannedHours: 0,
|
|
232
|
+
actualHours: 0,
|
|
233
|
+
billableHours: 0,
|
|
234
|
+
reworkHours: 0,
|
|
235
|
+
backlogValue: 0,
|
|
236
|
+
avgDeadline: 0,
|
|
237
|
+
avgAllocation: 0,
|
|
238
|
+
atRisk: 0,
|
|
239
|
+
burnRate: 0,
|
|
240
|
+
};
|
|
241
|
+
const clients = data?.filters.clients ?? [];
|
|
242
|
+
|
|
243
|
+
return (
|
|
244
|
+
<Page>
|
|
245
|
+
<OperationsHeader
|
|
246
|
+
title="Relatório de Projetos"
|
|
247
|
+
description="Dashboard executivo com receita, custo, lucro, prazo, risco e planejamento dos próximos 12 meses."
|
|
248
|
+
current="Relatórios / Projetos"
|
|
249
|
+
/>
|
|
250
|
+
|
|
251
|
+
<div className="grid gap-3 rounded-lg border bg-card p-3 md:grid-cols-[1fr_1fr_180px_180px_180px_auto]">
|
|
252
|
+
<div className="space-y-1">
|
|
253
|
+
<span className="text-xs font-medium text-muted-foreground">
|
|
254
|
+
Início
|
|
255
|
+
</span>
|
|
256
|
+
<Input
|
|
257
|
+
type="date"
|
|
258
|
+
value={startDate}
|
|
259
|
+
onChange={(event) => setStartDate(event.target.value)}
|
|
260
|
+
/>
|
|
261
|
+
</div>
|
|
262
|
+
<div className="space-y-1">
|
|
263
|
+
<span className="text-xs font-medium text-muted-foreground">Fim</span>
|
|
264
|
+
<Input
|
|
265
|
+
type="date"
|
|
266
|
+
value={endDate}
|
|
267
|
+
onChange={(event) => setEndDate(event.target.value)}
|
|
268
|
+
/>
|
|
269
|
+
</div>
|
|
270
|
+
<div className="space-y-1">
|
|
271
|
+
<span className="text-xs font-medium text-muted-foreground">
|
|
272
|
+
Status
|
|
273
|
+
</span>
|
|
274
|
+
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
275
|
+
<SelectTrigger className="w-full">
|
|
276
|
+
<SelectValue />
|
|
277
|
+
</SelectTrigger>
|
|
278
|
+
<SelectContent>
|
|
279
|
+
<SelectItem value="all">Todos</SelectItem>
|
|
280
|
+
<SelectItem value="on_track">No prazo</SelectItem>
|
|
281
|
+
<SelectItem value="attention">Atenção</SelectItem>
|
|
282
|
+
<SelectItem value="late">Atrasado</SelectItem>
|
|
283
|
+
<SelectItem value="paused">Pausado</SelectItem>
|
|
284
|
+
</SelectContent>
|
|
285
|
+
</Select>
|
|
286
|
+
</div>
|
|
287
|
+
<div className="space-y-1">
|
|
288
|
+
<span className="text-xs font-medium text-muted-foreground">
|
|
289
|
+
Cliente
|
|
290
|
+
</span>
|
|
291
|
+
<Select value={clientFilter} onValueChange={setClientFilter}>
|
|
292
|
+
<SelectTrigger className="w-full">
|
|
293
|
+
<SelectValue />
|
|
294
|
+
</SelectTrigger>
|
|
295
|
+
<SelectContent>
|
|
296
|
+
<SelectItem value="all">Todos</SelectItem>
|
|
297
|
+
{clients.map((client) => (
|
|
298
|
+
<SelectItem key={client} value={client}>
|
|
299
|
+
{client}
|
|
300
|
+
</SelectItem>
|
|
301
|
+
))}
|
|
302
|
+
</SelectContent>
|
|
303
|
+
</Select>
|
|
304
|
+
</div>
|
|
305
|
+
<div className="space-y-1">
|
|
306
|
+
<span className="text-xs font-medium text-muted-foreground">
|
|
307
|
+
Cenário
|
|
308
|
+
</span>
|
|
309
|
+
<Select
|
|
310
|
+
value={scenario}
|
|
311
|
+
onValueChange={(value) =>
|
|
312
|
+
setScenario(value as OperationsReportScenario)
|
|
313
|
+
}
|
|
314
|
+
>
|
|
315
|
+
<SelectTrigger className="w-full">
|
|
316
|
+
<SelectValue />
|
|
317
|
+
</SelectTrigger>
|
|
318
|
+
<SelectContent>
|
|
319
|
+
<SelectItem value="base">Base</SelectItem>
|
|
320
|
+
<SelectItem value="growth">Crescimento</SelectItem>
|
|
321
|
+
<SelectItem value="conservative">Conservador</SelectItem>
|
|
322
|
+
</SelectContent>
|
|
323
|
+
</Select>
|
|
324
|
+
</div>
|
|
325
|
+
<div className="flex items-end">
|
|
326
|
+
<Button
|
|
327
|
+
type="button"
|
|
328
|
+
variant="outline"
|
|
329
|
+
onClick={() => {
|
|
330
|
+
setStartDate(defaults.from);
|
|
331
|
+
setEndDate(defaults.to);
|
|
332
|
+
setStatusFilter('all');
|
|
333
|
+
setClientFilter('all');
|
|
334
|
+
setScenario('base');
|
|
335
|
+
}}
|
|
336
|
+
>
|
|
337
|
+
Limpar
|
|
338
|
+
</Button>
|
|
339
|
+
</div>
|
|
340
|
+
</div>
|
|
341
|
+
|
|
342
|
+
<KpiCardsGrid
|
|
343
|
+
columns={4}
|
|
344
|
+
items={[
|
|
345
|
+
{
|
|
346
|
+
key: 'active',
|
|
347
|
+
title: 'Projetos',
|
|
348
|
+
value: data?.rows.length ?? 0,
|
|
349
|
+
description: `${summary.atRisk} em risco operacional`,
|
|
350
|
+
icon: FolderKanban,
|
|
351
|
+
accentClassName: 'from-sky-500/20 via-cyan-400/10 to-transparent',
|
|
352
|
+
iconContainerClassName: 'bg-sky-50 text-sky-700',
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
key: 'revenue',
|
|
356
|
+
title: 'Receita reconhecida',
|
|
357
|
+
value: formatCurrency(summary.recognizedRevenue),
|
|
358
|
+
description: `${formatCurrency(summary.contractedRevenue)} contratado`,
|
|
359
|
+
icon: Banknote,
|
|
360
|
+
accentClassName:
|
|
361
|
+
'from-emerald-500/20 via-green-400/10 to-transparent',
|
|
362
|
+
iconContainerClassName: 'bg-emerald-50 text-emerald-700',
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
key: 'cost',
|
|
366
|
+
title: 'Custo realizado',
|
|
367
|
+
value: formatCurrency(summary.realizedCost),
|
|
368
|
+
description: `${formatPercent(summary.burnRate)} de burn rate`,
|
|
369
|
+
icon: Gauge,
|
|
370
|
+
accentClassName: 'from-rose-500/20 via-red-400/10 to-transparent',
|
|
371
|
+
iconContainerClassName: 'bg-rose-50 text-rose-700',
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
key: 'profit',
|
|
375
|
+
title: 'Lucro',
|
|
376
|
+
value: formatCurrency(summary.profit),
|
|
377
|
+
description: `${formatPercent(summary.margin)} de margem`,
|
|
378
|
+
icon: TrendingUp,
|
|
379
|
+
accentClassName:
|
|
380
|
+
'from-violet-500/20 via-fuchsia-400/10 to-transparent',
|
|
381
|
+
iconContainerClassName: 'bg-violet-50 text-violet-700',
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
key: 'hours',
|
|
385
|
+
title: 'Horas consumidas',
|
|
386
|
+
value: formatHours(summary.actualHours),
|
|
387
|
+
description: `${formatHours(summary.billableHours)} faturáveis`,
|
|
388
|
+
icon: Clock3,
|
|
389
|
+
layout: 'compact',
|
|
390
|
+
accentClassName:
|
|
391
|
+
'from-cyan-500/20 via-blue-400/10 to-transparent',
|
|
392
|
+
iconContainerClassName: 'bg-cyan-50 text-cyan-700',
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
key: 'deadline',
|
|
396
|
+
title: 'Avanço médio',
|
|
397
|
+
value: formatPercent(summary.avgDeadline),
|
|
398
|
+
description: 'Progresso físico dos projetos',
|
|
399
|
+
icon: CalendarClock,
|
|
400
|
+
layout: 'compact',
|
|
401
|
+
accentClassName:
|
|
402
|
+
'from-amber-500/20 via-yellow-400/10 to-transparent',
|
|
403
|
+
iconContainerClassName: 'bg-amber-50 text-amber-700',
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
key: 'allocation',
|
|
407
|
+
title: 'Alocação média',
|
|
408
|
+
value: formatPercent(summary.avgAllocation),
|
|
409
|
+
description: 'Acima de 95% exige atenção',
|
|
410
|
+
icon: UsersRound,
|
|
411
|
+
layout: 'compact',
|
|
412
|
+
accentClassName:
|
|
413
|
+
'from-indigo-500/20 via-blue-400/10 to-transparent',
|
|
414
|
+
iconContainerClassName: 'bg-indigo-50 text-indigo-700',
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
key: 'backlog',
|
|
418
|
+
title: 'Backlog futuro',
|
|
419
|
+
value: formatCurrency(summary.backlogValue),
|
|
420
|
+
description: `Cenário ${scenarioLabels[scenario]}`,
|
|
421
|
+
icon: Target,
|
|
422
|
+
layout: 'compact',
|
|
423
|
+
accentClassName:
|
|
424
|
+
'from-slate-500/20 via-slate-400/10 to-transparent',
|
|
425
|
+
iconContainerClassName: 'bg-slate-100 text-slate-700',
|
|
426
|
+
},
|
|
427
|
+
]}
|
|
428
|
+
/>
|
|
429
|
+
|
|
430
|
+
<div className="grid gap-4 xl:grid-cols-[1.4fr_0.9fr]">
|
|
431
|
+
<Card>
|
|
432
|
+
<CardHeader>
|
|
433
|
+
<CardTitle>Projeção de receita, custo e lucro</CardTitle>
|
|
434
|
+
<CardDescription>
|
|
435
|
+
Simulação mensal para planejamento de portfólio nos próximos 12
|
|
436
|
+
meses.
|
|
437
|
+
</CardDescription>
|
|
438
|
+
</CardHeader>
|
|
439
|
+
<CardContent>
|
|
440
|
+
<ResponsiveContainer width="100%" height={360}>
|
|
441
|
+
<AreaChart data={data?.forecast ?? []}>
|
|
442
|
+
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
|
443
|
+
<XAxis dataKey="month" tick={{ fontSize: 12 }} />
|
|
444
|
+
<YAxis
|
|
445
|
+
tick={{ fontSize: 12 }}
|
|
446
|
+
tickFormatter={(value) => `${Number(value) / 1000}k`}
|
|
447
|
+
/>
|
|
448
|
+
<Tooltip content={renderCurrencyTooltip} />
|
|
449
|
+
<Legend />
|
|
450
|
+
<Area
|
|
451
|
+
dataKey="revenue"
|
|
452
|
+
name="Receita"
|
|
453
|
+
stroke={CHART_COLORS.revenue}
|
|
454
|
+
strokeWidth={3}
|
|
455
|
+
fill={CHART_COLORS.revenue}
|
|
456
|
+
fillOpacity={0.12}
|
|
457
|
+
/>
|
|
458
|
+
<Area
|
|
459
|
+
dataKey="cost"
|
|
460
|
+
name="Custo"
|
|
461
|
+
stroke={CHART_COLORS.cost}
|
|
462
|
+
strokeWidth={3}
|
|
463
|
+
fill={CHART_COLORS.cost}
|
|
464
|
+
fillOpacity={0.08}
|
|
465
|
+
/>
|
|
466
|
+
<Area
|
|
467
|
+
dataKey="profit"
|
|
468
|
+
name="Lucro"
|
|
469
|
+
stroke={CHART_COLORS.profit}
|
|
470
|
+
strokeWidth={2}
|
|
471
|
+
fill="transparent"
|
|
472
|
+
/>
|
|
473
|
+
</AreaChart>
|
|
474
|
+
</ResponsiveContainer>
|
|
475
|
+
</CardContent>
|
|
476
|
+
</Card>
|
|
477
|
+
|
|
478
|
+
<Card>
|
|
479
|
+
<CardHeader>
|
|
480
|
+
<CardTitle>Composição de custos</CardTitle>
|
|
481
|
+
<CardDescription>
|
|
482
|
+
Equipe, infraestrutura, licenças, terceiros e retrabalho.
|
|
483
|
+
</CardDescription>
|
|
484
|
+
</CardHeader>
|
|
485
|
+
<CardContent>
|
|
486
|
+
<ResponsiveContainer width="100%" height={300}>
|
|
487
|
+
<PieChart>
|
|
488
|
+
<Pie
|
|
489
|
+
data={(data?.costComposition ?? []).filter(
|
|
490
|
+
(item) => item.value > 0
|
|
491
|
+
)}
|
|
492
|
+
dataKey="value"
|
|
493
|
+
nameKey="name"
|
|
494
|
+
innerRadius={58}
|
|
495
|
+
outerRadius={98}
|
|
496
|
+
paddingAngle={3}
|
|
497
|
+
>
|
|
498
|
+
{(data?.costComposition ?? []).map((entry, index) => (
|
|
499
|
+
<Cell
|
|
500
|
+
key={entry.name}
|
|
501
|
+
fill={
|
|
502
|
+
[
|
|
503
|
+
CHART_COLORS.revenue,
|
|
504
|
+
CHART_COLORS.internal,
|
|
505
|
+
CHART_COLORS.margin,
|
|
506
|
+
CHART_COLORS.billable,
|
|
507
|
+
CHART_COLORS.rework,
|
|
508
|
+
][index % 5]
|
|
509
|
+
}
|
|
510
|
+
stroke="hsl(var(--background))"
|
|
511
|
+
strokeWidth={2}
|
|
512
|
+
/>
|
|
513
|
+
))}
|
|
514
|
+
</Pie>
|
|
515
|
+
<Legend iconType="circle" wrapperStyle={{ fontSize: 12 }} />
|
|
516
|
+
<Tooltip content={renderCurrencyTooltip} />
|
|
517
|
+
</PieChart>
|
|
518
|
+
</ResponsiveContainer>
|
|
519
|
+
</CardContent>
|
|
520
|
+
</Card>
|
|
521
|
+
</div>
|
|
522
|
+
|
|
523
|
+
<div className="grid gap-4 xl:grid-cols-[1fr_1fr]">
|
|
524
|
+
<Card>
|
|
525
|
+
<CardHeader>
|
|
526
|
+
<CardTitle>Horas por projeto</CardTitle>
|
|
527
|
+
<CardDescription>
|
|
528
|
+
Separação entre horas faturáveis, internas, retrabalho e saldo.
|
|
529
|
+
</CardDescription>
|
|
530
|
+
</CardHeader>
|
|
531
|
+
<CardContent>
|
|
532
|
+
<ResponsiveContainer width="100%" height={330}>
|
|
533
|
+
<BarChart data={data?.hoursByProject ?? []}>
|
|
534
|
+
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
|
535
|
+
<XAxis dataKey="project" tick={{ fontSize: 12 }} />
|
|
536
|
+
<YAxis tick={{ fontSize: 12 }} />
|
|
537
|
+
<Tooltip content={renderNumberTooltip} />
|
|
538
|
+
<Legend />
|
|
539
|
+
<Bar
|
|
540
|
+
dataKey="Faturável"
|
|
541
|
+
stackId="hours"
|
|
542
|
+
fill={CHART_COLORS.billable}
|
|
543
|
+
/>
|
|
544
|
+
<Bar
|
|
545
|
+
dataKey="Interno"
|
|
546
|
+
stackId="hours"
|
|
547
|
+
fill={CHART_COLORS.internal}
|
|
548
|
+
/>
|
|
549
|
+
<Bar
|
|
550
|
+
dataKey="Retrabalho"
|
|
551
|
+
stackId="hours"
|
|
552
|
+
fill={CHART_COLORS.rework}
|
|
553
|
+
/>
|
|
554
|
+
<Bar
|
|
555
|
+
dataKey="Livre"
|
|
556
|
+
stackId="hours"
|
|
557
|
+
fill={CHART_COLORS.free}
|
|
558
|
+
radius={[6, 6, 0, 0]}
|
|
559
|
+
/>
|
|
560
|
+
</BarChart>
|
|
561
|
+
</ResponsiveContainer>
|
|
562
|
+
</CardContent>
|
|
563
|
+
</Card>
|
|
564
|
+
|
|
565
|
+
<Card>
|
|
566
|
+
<CardHeader>
|
|
567
|
+
<CardTitle>Saúde dos projetos</CardTitle>
|
|
568
|
+
<CardDescription>
|
|
569
|
+
Combina margem, prazo, alocação e risco por projeto.
|
|
570
|
+
</CardDescription>
|
|
571
|
+
</CardHeader>
|
|
572
|
+
<CardContent>
|
|
573
|
+
<ResponsiveContainer width="100%" height={330}>
|
|
574
|
+
<RadarChart data={data?.health ?? []}>
|
|
575
|
+
<PolarGrid />
|
|
576
|
+
<PolarAngleAxis dataKey="project" tick={{ fontSize: 12 }} />
|
|
577
|
+
<Tooltip content={renderNumberTooltip} />
|
|
578
|
+
<Legend />
|
|
579
|
+
<Radar
|
|
580
|
+
dataKey="margem"
|
|
581
|
+
name="Margem"
|
|
582
|
+
stroke={CHART_COLORS.profit}
|
|
583
|
+
fill={CHART_COLORS.profit}
|
|
584
|
+
fillOpacity={0.16}
|
|
585
|
+
/>
|
|
586
|
+
<Radar
|
|
587
|
+
dataKey="prazo"
|
|
588
|
+
name="Prazo"
|
|
589
|
+
stroke={CHART_COLORS.revenue}
|
|
590
|
+
fill={CHART_COLORS.revenue}
|
|
591
|
+
fillOpacity={0.12}
|
|
592
|
+
/>
|
|
593
|
+
<Radar
|
|
594
|
+
dataKey="saude"
|
|
595
|
+
name="Saúde"
|
|
596
|
+
stroke={CHART_COLORS.margin}
|
|
597
|
+
fill={CHART_COLORS.margin}
|
|
598
|
+
fillOpacity={0.1}
|
|
599
|
+
/>
|
|
600
|
+
</RadarChart>
|
|
601
|
+
</ResponsiveContainer>
|
|
602
|
+
</CardContent>
|
|
603
|
+
</Card>
|
|
604
|
+
</div>
|
|
605
|
+
|
|
606
|
+
<div className="grid gap-4 xl:grid-cols-[1fr_1fr]">
|
|
607
|
+
<Card>
|
|
608
|
+
<CardHeader>
|
|
609
|
+
<CardTitle>Ranking de lucro e custo</CardTitle>
|
|
610
|
+
<CardDescription>
|
|
611
|
+
Projetos ordenados por contribuição estimada no período.
|
|
612
|
+
</CardDescription>
|
|
613
|
+
</CardHeader>
|
|
614
|
+
<CardContent>
|
|
615
|
+
<ResponsiveContainer width="100%" height={340}>
|
|
616
|
+
<BarChart
|
|
617
|
+
data={data?.ranking ?? []}
|
|
618
|
+
layout="vertical"
|
|
619
|
+
margin={{ left: 56, right: 16 }}
|
|
620
|
+
>
|
|
621
|
+
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
|
622
|
+
<XAxis
|
|
623
|
+
type="number"
|
|
624
|
+
tick={{ fontSize: 12 }}
|
|
625
|
+
tickFormatter={(value) => `${Number(value) / 1000}k`}
|
|
626
|
+
/>
|
|
627
|
+
<YAxis dataKey="name" type="category" width={120} />
|
|
628
|
+
<Tooltip content={renderCurrencyTooltip} />
|
|
629
|
+
<Legend />
|
|
630
|
+
<Bar dataKey="Lucro" fill={CHART_COLORS.profit} radius={6} />
|
|
631
|
+
<Bar dataKey="Custo" fill={CHART_COLORS.cost} radius={6} />
|
|
632
|
+
</BarChart>
|
|
633
|
+
</ResponsiveContainer>
|
|
634
|
+
</CardContent>
|
|
635
|
+
</Card>
|
|
636
|
+
|
|
637
|
+
<Card>
|
|
638
|
+
<CardHeader>
|
|
639
|
+
<CardTitle>Planejado vs realizado</CardTitle>
|
|
640
|
+
<CardDescription>
|
|
641
|
+
Tendência mensal de consumo de horas frente ao plano.
|
|
642
|
+
</CardDescription>
|
|
643
|
+
</CardHeader>
|
|
644
|
+
<CardContent>
|
|
645
|
+
<ResponsiveContainer width="100%" height={340}>
|
|
646
|
+
<LineChart data={data?.progress ?? []}>
|
|
647
|
+
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
|
648
|
+
<XAxis dataKey="month" tick={{ fontSize: 12 }} />
|
|
649
|
+
<YAxis tick={{ fontSize: 12 }} />
|
|
650
|
+
<Tooltip content={renderNumberTooltip} />
|
|
651
|
+
<Legend />
|
|
652
|
+
<Line
|
|
653
|
+
type="monotone"
|
|
654
|
+
dataKey="Planejado"
|
|
655
|
+
stroke={CHART_COLORS.planned}
|
|
656
|
+
strokeWidth={3}
|
|
657
|
+
dot={false}
|
|
658
|
+
/>
|
|
659
|
+
<Line
|
|
660
|
+
type="monotone"
|
|
661
|
+
dataKey="Realizado"
|
|
662
|
+
stroke={CHART_COLORS.actual}
|
|
663
|
+
strokeWidth={3}
|
|
664
|
+
dot={false}
|
|
665
|
+
/>
|
|
666
|
+
</LineChart>
|
|
667
|
+
</ResponsiveContainer>
|
|
668
|
+
</CardContent>
|
|
669
|
+
</Card>
|
|
670
|
+
</div>
|
|
671
|
+
|
|
672
|
+
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
673
|
+
{(data?.planningCards ?? []).map((card, index) => {
|
|
674
|
+
const Icon =
|
|
675
|
+
planningIcons[index % planningIcons.length] ?? CalendarClock;
|
|
676
|
+
const colors = [
|
|
677
|
+
'bg-amber-50 text-amber-700',
|
|
678
|
+
'bg-sky-50 text-sky-700',
|
|
679
|
+
'bg-emerald-50 text-emerald-700',
|
|
680
|
+
'bg-violet-50 text-violet-700',
|
|
681
|
+
];
|
|
682
|
+
return (
|
|
683
|
+
<Card key={card.title}>
|
|
684
|
+
<CardContent className="space-y-3 p-4">
|
|
685
|
+
<div className="flex items-start justify-between gap-3">
|
|
686
|
+
<div>
|
|
687
|
+
<p className="text-sm font-medium text-muted-foreground">
|
|
688
|
+
{card.title}
|
|
689
|
+
</p>
|
|
690
|
+
<p className="mt-1 text-2xl font-semibold">
|
|
691
|
+
{card.value}
|
|
692
|
+
</p>
|
|
693
|
+
</div>
|
|
694
|
+
<div className={`rounded-xl p-2 ${colors[index % colors.length]}`}>
|
|
695
|
+
<Icon className="size-5" />
|
|
696
|
+
</div>
|
|
697
|
+
</div>
|
|
698
|
+
<p className="text-sm leading-relaxed text-muted-foreground">
|
|
699
|
+
{card.description}
|
|
700
|
+
</p>
|
|
701
|
+
</CardContent>
|
|
702
|
+
</Card>
|
|
703
|
+
);
|
|
704
|
+
})}
|
|
705
|
+
</div>
|
|
706
|
+
|
|
707
|
+
<Card>
|
|
708
|
+
<CardHeader>
|
|
709
|
+
<CardTitle>Detalhamento por projeto</CardTitle>
|
|
710
|
+
<CardDescription>
|
|
711
|
+
Receita, custo, lucro, horas, prazo, risco e recomendação executiva.
|
|
712
|
+
</CardDescription>
|
|
713
|
+
</CardHeader>
|
|
714
|
+
<CardContent className="overflow-x-auto">
|
|
715
|
+
<Table>
|
|
716
|
+
<TableHeader>
|
|
717
|
+
<TableRow>
|
|
718
|
+
<TableHead>Projeto</TableHead>
|
|
719
|
+
<TableHead>Status</TableHead>
|
|
720
|
+
<TableHead>Receita</TableHead>
|
|
721
|
+
<TableHead>Custo</TableHead>
|
|
722
|
+
<TableHead>Lucro</TableHead>
|
|
723
|
+
<TableHead>Horas</TableHead>
|
|
724
|
+
<TableHead>Prazo</TableHead>
|
|
725
|
+
<TableHead>Risco</TableHead>
|
|
726
|
+
<TableHead>Recomendação</TableHead>
|
|
727
|
+
</TableRow>
|
|
728
|
+
</TableHeader>
|
|
729
|
+
<TableBody>
|
|
730
|
+
{(data?.rows ?? []).map((row) => {
|
|
731
|
+
const profit = row.recognizedRevenue - row.realizedCost;
|
|
732
|
+
const margin = row.recognizedRevenue
|
|
733
|
+
? (profit / row.recognizedRevenue) * 100
|
|
734
|
+
: 0;
|
|
735
|
+
const hourUsage = row.plannedHours
|
|
736
|
+
? (row.actualHours / row.plannedHours) * 100
|
|
737
|
+
: 0;
|
|
738
|
+
return (
|
|
739
|
+
<TableRow key={row.id}>
|
|
740
|
+
<TableCell className="min-w-72">
|
|
741
|
+
<div className="font-medium">{row.name}</div>
|
|
742
|
+
<div className="text-xs text-muted-foreground">
|
|
743
|
+
{row.client} · {row.manager} · {row.squad} ·{' '}
|
|
744
|
+
{contractLabels[row.contractType]}
|
|
745
|
+
</div>
|
|
746
|
+
</TableCell>
|
|
747
|
+
<TableCell>
|
|
748
|
+
<Badge
|
|
749
|
+
variant="outline"
|
|
750
|
+
className={statusClasses[row.status]}
|
|
751
|
+
>
|
|
752
|
+
{statusLabels[row.status]}
|
|
753
|
+
</Badge>
|
|
754
|
+
</TableCell>
|
|
755
|
+
<TableCell>{formatCurrency(row.recognizedRevenue)}</TableCell>
|
|
756
|
+
<TableCell>{formatCurrency(row.realizedCost)}</TableCell>
|
|
757
|
+
<TableCell
|
|
758
|
+
className={
|
|
759
|
+
profit >= 0 ? 'text-emerald-700' : 'text-rose-700'
|
|
760
|
+
}
|
|
761
|
+
>
|
|
762
|
+
<div>{formatCurrency(profit)}</div>
|
|
763
|
+
<div className="text-xs">{formatPercent(margin)}</div>
|
|
764
|
+
</TableCell>
|
|
765
|
+
<TableCell>
|
|
766
|
+
<div className="min-w-36 space-y-1">
|
|
767
|
+
<div className="flex items-center justify-between text-xs">
|
|
768
|
+
<span>{formatHours(row.actualHours)}</span>
|
|
769
|
+
<span>{formatPercent(hourUsage)}</span>
|
|
770
|
+
</div>
|
|
771
|
+
<Progress value={Math.min(hourUsage, 100)} />
|
|
772
|
+
</div>
|
|
773
|
+
</TableCell>
|
|
774
|
+
<TableCell>
|
|
775
|
+
<div className="min-w-32 space-y-1">
|
|
776
|
+
<div className="flex items-center justify-between text-xs">
|
|
777
|
+
<span>Físico</span>
|
|
778
|
+
<span>{formatPercent(row.physicalProgress)}</span>
|
|
779
|
+
</div>
|
|
780
|
+
<Progress value={row.physicalProgress} />
|
|
781
|
+
</div>
|
|
782
|
+
</TableCell>
|
|
783
|
+
<TableCell>
|
|
784
|
+
<Badge
|
|
785
|
+
variant="outline"
|
|
786
|
+
className={
|
|
787
|
+
row.risk === 'alto'
|
|
788
|
+
? 'border-rose-200 bg-rose-50 text-rose-700'
|
|
789
|
+
: row.risk === 'médio'
|
|
790
|
+
? 'border-amber-200 bg-amber-50 text-amber-700'
|
|
791
|
+
: 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
|
792
|
+
}
|
|
793
|
+
>
|
|
794
|
+
{row.risk}
|
|
795
|
+
</Badge>
|
|
796
|
+
</TableCell>
|
|
797
|
+
<TableCell className="min-w-72 text-sm text-muted-foreground">
|
|
798
|
+
{row.recommendation}
|
|
799
|
+
</TableCell>
|
|
800
|
+
</TableRow>
|
|
801
|
+
);
|
|
802
|
+
})}
|
|
803
|
+
</TableBody>
|
|
804
|
+
</Table>
|
|
805
|
+
</CardContent>
|
|
806
|
+
</Card>
|
|
807
|
+
</Page>
|
|
808
|
+
);
|
|
809
|
+
}
|