@hed-hog/operations 0.0.319 → 0.0.321
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-contracts.controller.d.ts +9 -9
- package/dist/operations.service.js +110 -110
- package/hedhog/data/operations_cost_type.yaml +95 -95
- 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/_lib/types.ts.ejs +178 -178
- package/hedhog/frontend/app/reports/collaborators/page.tsx.ejs +771 -771
- package/hedhog/frontend/app/reports/projects/page.tsx.ejs +809 -809
- package/package.json +4 -4
- package/src/controllers/operations-reports.controller.ts +32 -32
- package/src/dto/list-reports.dto.ts +51 -51
- package/src/operations.module.ts +5 -5
- package/src/operations.service.ts +610 -610
|
@@ -1,771 +1,771 @@
|
|
|
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
|
-
AlertTriangle,
|
|
34
|
-
Banknote,
|
|
35
|
-
BriefcaseBusiness,
|
|
36
|
-
CalendarClock,
|
|
37
|
-
Clock3,
|
|
38
|
-
Route,
|
|
39
|
-
Target,
|
|
40
|
-
TrendingUp,
|
|
41
|
-
UserPlus,
|
|
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
|
-
Pie,
|
|
56
|
-
PieChart,
|
|
57
|
-
PolarAngleAxis,
|
|
58
|
-
PolarGrid,
|
|
59
|
-
Radar,
|
|
60
|
-
RadarChart,
|
|
61
|
-
ResponsiveContainer,
|
|
62
|
-
Tooltip,
|
|
63
|
-
XAxis,
|
|
64
|
-
YAxis,
|
|
65
|
-
} from 'recharts';
|
|
66
|
-
import { OperationsHeader } from '../../_components/operations-header';
|
|
67
|
-
import { fetchOperations } from '../../_lib/api';
|
|
68
|
-
import type {
|
|
69
|
-
OperationsCollaboratorsReport,
|
|
70
|
-
OperationsReportScenario,
|
|
71
|
-
} from '../../_lib/types';
|
|
72
|
-
import { formatCurrency, formatHours } from '../../_lib/utils/format';
|
|
73
|
-
|
|
74
|
-
const CHART_COLORS = {
|
|
75
|
-
revenue: '#2563eb',
|
|
76
|
-
cost: '#dc2626',
|
|
77
|
-
profit: '#16a34a',
|
|
78
|
-
margin: '#7c3aed',
|
|
79
|
-
billable: '#0891b2',
|
|
80
|
-
internal: '#f59e0b',
|
|
81
|
-
free: '#94a3b8',
|
|
82
|
-
overload: '#e11d48',
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
const scenarioLabels: Record<OperationsReportScenario, string> = {
|
|
86
|
-
base: 'Base',
|
|
87
|
-
growth: 'Crescimento',
|
|
88
|
-
conservative: 'Conservador',
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
const planningIcons: LucideIcon[] = [
|
|
92
|
-
UserPlus,
|
|
93
|
-
Route,
|
|
94
|
-
AlertTriangle,
|
|
95
|
-
TrendingUp,
|
|
96
|
-
];
|
|
97
|
-
|
|
98
|
-
function getDefaultRange() {
|
|
99
|
-
const year = new Date().getFullYear();
|
|
100
|
-
return { from: `${year}-01-01`, to: `${year}-12-31` };
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function formatPercent(value: number) {
|
|
104
|
-
return `${Math.round(Number(value || 0))}%`;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function getTotalCost(row: OperationsCollaboratorsReport['rows'][number]) {
|
|
108
|
-
return row.salaryCost + row.benefitsCost + row.taxesCost + row.toolsCost;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function getAllocationStatus(allocation: number) {
|
|
112
|
-
if (allocation >= 98) {
|
|
113
|
-
return {
|
|
114
|
-
label: 'Sobrecarga',
|
|
115
|
-
className: 'border-rose-200 bg-rose-50 text-rose-700',
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
if (allocation < 75) {
|
|
119
|
-
return {
|
|
120
|
-
label: 'Ocioso',
|
|
121
|
-
className: 'border-amber-200 bg-amber-50 text-amber-700',
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
return {
|
|
125
|
-
label: 'Saudável',
|
|
126
|
-
className: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function renderCurrencyTooltip({
|
|
131
|
-
active,
|
|
132
|
-
payload,
|
|
133
|
-
label,
|
|
134
|
-
}: TooltipProps<number, string>) {
|
|
135
|
-
if (!active || !payload?.length) return null;
|
|
136
|
-
return (
|
|
137
|
-
<div className="min-w-52 rounded-md border bg-popover px-3 py-2 text-sm text-popover-foreground shadow-xl">
|
|
138
|
-
<p className="mb-1 font-semibold">{label}</p>
|
|
139
|
-
{payload.map((entry) => (
|
|
140
|
-
<div
|
|
141
|
-
key={String(entry.dataKey ?? entry.name)}
|
|
142
|
-
className="flex items-center justify-between gap-3 py-0.5"
|
|
143
|
-
>
|
|
144
|
-
<span className="flex items-center gap-2 text-muted-foreground">
|
|
145
|
-
<span
|
|
146
|
-
className="size-2.5 rounded-full"
|
|
147
|
-
style={{ backgroundColor: entry.color }}
|
|
148
|
-
/>
|
|
149
|
-
{entry.name}
|
|
150
|
-
</span>
|
|
151
|
-
<span className="font-semibold">
|
|
152
|
-
{formatCurrency(Number(entry.value ?? 0))}
|
|
153
|
-
</span>
|
|
154
|
-
</div>
|
|
155
|
-
))}
|
|
156
|
-
</div>
|
|
157
|
-
);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function renderNumberTooltip({
|
|
161
|
-
active,
|
|
162
|
-
payload,
|
|
163
|
-
label,
|
|
164
|
-
}: TooltipProps<number, string>) {
|
|
165
|
-
if (!active || !payload?.length) return null;
|
|
166
|
-
return (
|
|
167
|
-
<div className="min-w-44 rounded-md border bg-popover px-3 py-2 text-sm text-popover-foreground shadow-xl">
|
|
168
|
-
<p className="mb-1 font-semibold">{label}</p>
|
|
169
|
-
{payload.map((entry) => (
|
|
170
|
-
<div
|
|
171
|
-
key={String(entry.dataKey ?? entry.name)}
|
|
172
|
-
className="flex items-center justify-between gap-3 py-0.5"
|
|
173
|
-
>
|
|
174
|
-
<span className="flex items-center gap-2 text-muted-foreground">
|
|
175
|
-
<span
|
|
176
|
-
className="size-2.5 rounded-full"
|
|
177
|
-
style={{ backgroundColor: entry.color }}
|
|
178
|
-
/>
|
|
179
|
-
{entry.name}
|
|
180
|
-
</span>
|
|
181
|
-
<span className="font-semibold">{Number(entry.value ?? 0)}</span>
|
|
182
|
-
</div>
|
|
183
|
-
))}
|
|
184
|
-
</div>
|
|
185
|
-
);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
export default function OperationsCollaboratorReportsPage() {
|
|
189
|
-
const defaults = getDefaultRange();
|
|
190
|
-
const { request } = useApp();
|
|
191
|
-
const [startDate, setStartDate] = useState(defaults.from);
|
|
192
|
-
const [endDate, setEndDate] = useState(defaults.to);
|
|
193
|
-
const [departmentFilter, setDepartmentFilter] = useState('all');
|
|
194
|
-
const [contractFilter, setContractFilter] = useState('all');
|
|
195
|
-
const [scenario, setScenario] = useState<OperationsReportScenario>('base');
|
|
196
|
-
|
|
197
|
-
const { data } = useQuery<OperationsCollaboratorsReport>({
|
|
198
|
-
queryKey: [
|
|
199
|
-
'operations-collaborators-report',
|
|
200
|
-
startDate,
|
|
201
|
-
endDate,
|
|
202
|
-
departmentFilter,
|
|
203
|
-
contractFilter,
|
|
204
|
-
scenario,
|
|
205
|
-
],
|
|
206
|
-
queryFn: () => {
|
|
207
|
-
const params = new URLSearchParams({
|
|
208
|
-
from: startDate,
|
|
209
|
-
to: endDate,
|
|
210
|
-
department: departmentFilter,
|
|
211
|
-
contractType: contractFilter,
|
|
212
|
-
scenario,
|
|
213
|
-
});
|
|
214
|
-
return fetchOperations<OperationsCollaboratorsReport>(
|
|
215
|
-
request,
|
|
216
|
-
`/operations/reports/collaborators?${params.toString()}`
|
|
217
|
-
);
|
|
218
|
-
},
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
const summary = data?.summary ?? {
|
|
222
|
-
cost: 0,
|
|
223
|
-
salary: 0,
|
|
224
|
-
benefits: 0,
|
|
225
|
-
taxes: 0,
|
|
226
|
-
tools: 0,
|
|
227
|
-
billableValue: 0,
|
|
228
|
-
profit: 0,
|
|
229
|
-
margin: 0,
|
|
230
|
-
availableHours: 0,
|
|
231
|
-
allocatedHours: 0,
|
|
232
|
-
billableHours: 0,
|
|
233
|
-
internalHours: 0,
|
|
234
|
-
overtimeHours: 0,
|
|
235
|
-
freeHours: 0,
|
|
236
|
-
allocation: 0,
|
|
237
|
-
utilization: 0,
|
|
238
|
-
overloadCount: 0,
|
|
239
|
-
hourlyCost: 0,
|
|
240
|
-
};
|
|
241
|
-
|
|
242
|
-
return (
|
|
243
|
-
<Page>
|
|
244
|
-
<OperationsHeader
|
|
245
|
-
title="Relatório de Colaboradores"
|
|
246
|
-
description="Dashboard executivo com custo, lucro, capacidade atual e planejamento dos próximos 12 meses."
|
|
247
|
-
current="Relatórios / Colaboradores"
|
|
248
|
-
/>
|
|
249
|
-
|
|
250
|
-
<div className="grid gap-3 rounded-lg border bg-card p-3 md:grid-cols-[1fr_1fr_180px_160px_180px_auto]">
|
|
251
|
-
<div className="space-y-1">
|
|
252
|
-
<span className="text-xs font-medium text-muted-foreground">
|
|
253
|
-
Início
|
|
254
|
-
</span>
|
|
255
|
-
<Input
|
|
256
|
-
type="date"
|
|
257
|
-
value={startDate}
|
|
258
|
-
onChange={(event) => setStartDate(event.target.value)}
|
|
259
|
-
/>
|
|
260
|
-
</div>
|
|
261
|
-
<div className="space-y-1">
|
|
262
|
-
<span className="text-xs font-medium text-muted-foreground">Fim</span>
|
|
263
|
-
<Input
|
|
264
|
-
type="date"
|
|
265
|
-
value={endDate}
|
|
266
|
-
onChange={(event) => setEndDate(event.target.value)}
|
|
267
|
-
/>
|
|
268
|
-
</div>
|
|
269
|
-
<div className="space-y-1">
|
|
270
|
-
<span className="text-xs font-medium text-muted-foreground">
|
|
271
|
-
Departamento
|
|
272
|
-
</span>
|
|
273
|
-
<Select value={departmentFilter} onValueChange={setDepartmentFilter}>
|
|
274
|
-
<SelectTrigger className="w-full">
|
|
275
|
-
<SelectValue />
|
|
276
|
-
</SelectTrigger>
|
|
277
|
-
<SelectContent>
|
|
278
|
-
<SelectItem value="all">Todos</SelectItem>
|
|
279
|
-
{(data?.filters.departments ?? []).map((department) => (
|
|
280
|
-
<SelectItem key={department} value={department}>
|
|
281
|
-
{department}
|
|
282
|
-
</SelectItem>
|
|
283
|
-
))}
|
|
284
|
-
</SelectContent>
|
|
285
|
-
</Select>
|
|
286
|
-
</div>
|
|
287
|
-
<div className="space-y-1">
|
|
288
|
-
<span className="text-xs font-medium text-muted-foreground">
|
|
289
|
-
Vínculo
|
|
290
|
-
</span>
|
|
291
|
-
<Select value={contractFilter} onValueChange={setContractFilter}>
|
|
292
|
-
<SelectTrigger className="w-full">
|
|
293
|
-
<SelectValue />
|
|
294
|
-
</SelectTrigger>
|
|
295
|
-
<SelectContent>
|
|
296
|
-
<SelectItem value="all">Todos</SelectItem>
|
|
297
|
-
{(data?.filters.contractTypes ?? []).map((contractType) => (
|
|
298
|
-
<SelectItem key={contractType} value={contractType}>
|
|
299
|
-
{contractType}
|
|
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
|
-
setDepartmentFilter('all');
|
|
333
|
-
setContractFilter('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: 'headcount',
|
|
347
|
-
title: 'Headcount',
|
|
348
|
-
value: data?.rows.length ?? 0,
|
|
349
|
-
description: 'Colaboradores no recorte',
|
|
350
|
-
icon: UsersRound,
|
|
351
|
-
accentClassName: 'from-sky-500/20 via-cyan-400/10 to-transparent',
|
|
352
|
-
iconContainerClassName: 'bg-sky-50 text-sky-700',
|
|
353
|
-
},
|
|
354
|
-
{
|
|
355
|
-
key: 'cost',
|
|
356
|
-
title: 'Custo total',
|
|
357
|
-
value: formatCurrency(summary.cost),
|
|
358
|
-
description: `${formatCurrency(summary.hourlyCost)} por hora alocada`,
|
|
359
|
-
icon: Banknote,
|
|
360
|
-
accentClassName: 'from-rose-500/20 via-red-400/10 to-transparent',
|
|
361
|
-
iconContainerClassName: 'bg-rose-50 text-rose-700',
|
|
362
|
-
},
|
|
363
|
-
{
|
|
364
|
-
key: 'revenue',
|
|
365
|
-
title: 'Valor gerado',
|
|
366
|
-
value: formatCurrency(summary.billableValue),
|
|
367
|
-
description: `${formatHours(summary.billableHours)} faturáveis`,
|
|
368
|
-
icon: BriefcaseBusiness,
|
|
369
|
-
accentClassName:
|
|
370
|
-
'from-emerald-500/20 via-green-400/10 to-transparent',
|
|
371
|
-
iconContainerClassName: 'bg-emerald-50 text-emerald-700',
|
|
372
|
-
},
|
|
373
|
-
{
|
|
374
|
-
key: 'margin',
|
|
375
|
-
title: 'Margem',
|
|
376
|
-
value: formatPercent(summary.margin),
|
|
377
|
-
description: formatCurrency(summary.profit),
|
|
378
|
-
icon: Target,
|
|
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: 'utilization',
|
|
385
|
-
title: 'Utilização',
|
|
386
|
-
value: formatPercent(summary.utilization),
|
|
387
|
-
description: 'Horas faturáveis sobre capacidade',
|
|
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: 'free',
|
|
396
|
-
title: 'Capacidade livre',
|
|
397
|
-
value: formatHours(summary.freeHours),
|
|
398
|
-
description: `${formatPercent(100 - summary.allocation)} disponível`,
|
|
399
|
-
icon: CalendarClock,
|
|
400
|
-
layout: 'compact',
|
|
401
|
-
accentClassName:
|
|
402
|
-
'from-slate-500/20 via-slate-400/10 to-transparent',
|
|
403
|
-
iconContainerClassName: 'bg-slate-100 text-slate-700',
|
|
404
|
-
},
|
|
405
|
-
{
|
|
406
|
-
key: 'overtime',
|
|
407
|
-
title: 'Horas extras',
|
|
408
|
-
value: formatHours(summary.overtimeHours),
|
|
409
|
-
description: `${summary.overloadCount} pessoas em sobrecarga`,
|
|
410
|
-
icon: AlertTriangle,
|
|
411
|
-
layout: 'compact',
|
|
412
|
-
accentClassName:
|
|
413
|
-
'from-amber-500/20 via-orange-400/10 to-transparent',
|
|
414
|
-
iconContainerClassName: 'bg-amber-50 text-amber-700',
|
|
415
|
-
},
|
|
416
|
-
{
|
|
417
|
-
key: 'scenario',
|
|
418
|
-
title: 'Cenário 12 meses',
|
|
419
|
-
value: scenarioLabels[scenario],
|
|
420
|
-
description: 'Projeção calculada no backend',
|
|
421
|
-
icon: TrendingUp,
|
|
422
|
-
layout: 'compact',
|
|
423
|
-
accentClassName:
|
|
424
|
-
'from-indigo-500/20 via-blue-400/10 to-transparent',
|
|
425
|
-
iconContainerClassName: 'bg-indigo-50 text-indigo-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 custo, valor e margem</CardTitle>
|
|
434
|
-
<CardDescription>
|
|
435
|
-
Simulação mensal para planejamento de capacidade 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="Valor gerado"
|
|
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 custo</CardTitle>
|
|
481
|
-
<CardDescription>
|
|
482
|
-
Distribuição dos custos usados no cálculo da margem.
|
|
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.profit,
|
|
505
|
-
CHART_COLORS.internal,
|
|
506
|
-
CHART_COLORS.margin,
|
|
507
|
-
][index % 4]
|
|
508
|
-
}
|
|
509
|
-
stroke="hsl(var(--background))"
|
|
510
|
-
strokeWidth={2}
|
|
511
|
-
/>
|
|
512
|
-
))}
|
|
513
|
-
</Pie>
|
|
514
|
-
<Legend iconType="circle" wrapperStyle={{ fontSize: 12 }} />
|
|
515
|
-
<Tooltip content={renderCurrencyTooltip} />
|
|
516
|
-
</PieChart>
|
|
517
|
-
</ResponsiveContainer>
|
|
518
|
-
</CardContent>
|
|
519
|
-
</Card>
|
|
520
|
-
</div>
|
|
521
|
-
|
|
522
|
-
<div className="grid gap-4 xl:grid-cols-[1fr_1fr]">
|
|
523
|
-
<Card>
|
|
524
|
-
<CardHeader>
|
|
525
|
-
<CardTitle>Capacidade por departamento</CardTitle>
|
|
526
|
-
<CardDescription>
|
|
527
|
-
Horas faturáveis, internas, livres e acima da capacidade.
|
|
528
|
-
</CardDescription>
|
|
529
|
-
</CardHeader>
|
|
530
|
-
<CardContent>
|
|
531
|
-
<ResponsiveContainer width="100%" height={320}>
|
|
532
|
-
<BarChart data={data?.capacityByDepartment ?? []}>
|
|
533
|
-
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
|
534
|
-
<XAxis dataKey="department" tick={{ fontSize: 12 }} />
|
|
535
|
-
<YAxis tick={{ fontSize: 12 }} />
|
|
536
|
-
<Tooltip content={renderNumberTooltip} />
|
|
537
|
-
<Legend />
|
|
538
|
-
<Bar
|
|
539
|
-
dataKey="Faturável"
|
|
540
|
-
stackId="capacity"
|
|
541
|
-
fill={CHART_COLORS.billable}
|
|
542
|
-
/>
|
|
543
|
-
<Bar
|
|
544
|
-
dataKey="Interno"
|
|
545
|
-
stackId="capacity"
|
|
546
|
-
fill={CHART_COLORS.internal}
|
|
547
|
-
/>
|
|
548
|
-
<Bar
|
|
549
|
-
dataKey="Livre"
|
|
550
|
-
stackId="capacity"
|
|
551
|
-
fill={CHART_COLORS.free}
|
|
552
|
-
/>
|
|
553
|
-
<Bar
|
|
554
|
-
dataKey="Sobrecarga"
|
|
555
|
-
stackId="capacity"
|
|
556
|
-
fill={CHART_COLORS.overload}
|
|
557
|
-
radius={[6, 6, 0, 0]}
|
|
558
|
-
/>
|
|
559
|
-
</BarChart>
|
|
560
|
-
</ResponsiveContainer>
|
|
561
|
-
</CardContent>
|
|
562
|
-
</Card>
|
|
563
|
-
|
|
564
|
-
<Card>
|
|
565
|
-
<CardHeader>
|
|
566
|
-
<CardTitle>Saúde por departamento</CardTitle>
|
|
567
|
-
<CardDescription>
|
|
568
|
-
Combina margem, alocação, utilização e risco operacional.
|
|
569
|
-
</CardDescription>
|
|
570
|
-
</CardHeader>
|
|
571
|
-
<CardContent>
|
|
572
|
-
<ResponsiveContainer width="100%" height={320}>
|
|
573
|
-
<RadarChart data={data?.health ?? []}>
|
|
574
|
-
<PolarGrid />
|
|
575
|
-
<PolarAngleAxis dataKey="department" tick={{ fontSize: 12 }} />
|
|
576
|
-
<Tooltip content={renderNumberTooltip} />
|
|
577
|
-
<Legend />
|
|
578
|
-
<Radar
|
|
579
|
-
dataKey="margem"
|
|
580
|
-
name="Margem"
|
|
581
|
-
stroke={CHART_COLORS.profit}
|
|
582
|
-
fill={CHART_COLORS.profit}
|
|
583
|
-
fillOpacity={0.18}
|
|
584
|
-
/>
|
|
585
|
-
<Radar
|
|
586
|
-
dataKey="alocacao"
|
|
587
|
-
name="Alocação"
|
|
588
|
-
stroke={CHART_COLORS.margin}
|
|
589
|
-
fill={CHART_COLORS.margin}
|
|
590
|
-
fillOpacity={0.12}
|
|
591
|
-
/>
|
|
592
|
-
<Radar
|
|
593
|
-
dataKey="saude"
|
|
594
|
-
name="Saúde"
|
|
595
|
-
stroke={CHART_COLORS.revenue}
|
|
596
|
-
fill={CHART_COLORS.revenue}
|
|
597
|
-
fillOpacity={0.1}
|
|
598
|
-
/>
|
|
599
|
-
</RadarChart>
|
|
600
|
-
</ResponsiveContainer>
|
|
601
|
-
</CardContent>
|
|
602
|
-
</Card>
|
|
603
|
-
</div>
|
|
604
|
-
|
|
605
|
-
<div className="grid gap-4 xl:grid-cols-[1fr_1fr]">
|
|
606
|
-
<Card>
|
|
607
|
-
<CardHeader>
|
|
608
|
-
<CardTitle>Ranking de lucro e custo</CardTitle>
|
|
609
|
-
<CardDescription>
|
|
610
|
-
Colaboradores ordenados por contribuição estimada no período.
|
|
611
|
-
</CardDescription>
|
|
612
|
-
</CardHeader>
|
|
613
|
-
<CardContent>
|
|
614
|
-
<ResponsiveContainer width="100%" height={340}>
|
|
615
|
-
<BarChart
|
|
616
|
-
data={data?.ranking ?? []}
|
|
617
|
-
layout="vertical"
|
|
618
|
-
margin={{ left: 18, right: 16 }}
|
|
619
|
-
>
|
|
620
|
-
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
|
621
|
-
<XAxis
|
|
622
|
-
type="number"
|
|
623
|
-
tick={{ fontSize: 12 }}
|
|
624
|
-
tickFormatter={(value) => `${Number(value) / 1000}k`}
|
|
625
|
-
/>
|
|
626
|
-
<YAxis dataKey="name" type="category" width={72} />
|
|
627
|
-
<Tooltip content={renderCurrencyTooltip} />
|
|
628
|
-
<Legend />
|
|
629
|
-
<Bar dataKey="Lucro" fill={CHART_COLORS.profit} radius={6} />
|
|
630
|
-
<Bar dataKey="Custo" fill={CHART_COLORS.cost} radius={6} />
|
|
631
|
-
</BarChart>
|
|
632
|
-
</ResponsiveContainer>
|
|
633
|
-
</CardContent>
|
|
634
|
-
</Card>
|
|
635
|
-
|
|
636
|
-
<div className="grid gap-4 md:grid-cols-2">
|
|
637
|
-
{(data?.planningCards ?? []).map((card, index) => {
|
|
638
|
-
const Icon =
|
|
639
|
-
planningIcons[index % planningIcons.length] ?? UserPlus;
|
|
640
|
-
const colors = [
|
|
641
|
-
'bg-sky-50 text-sky-700',
|
|
642
|
-
'bg-emerald-50 text-emerald-700',
|
|
643
|
-
'bg-amber-50 text-amber-700',
|
|
644
|
-
'bg-violet-50 text-violet-700',
|
|
645
|
-
];
|
|
646
|
-
return (
|
|
647
|
-
<Card key={card.title}>
|
|
648
|
-
<CardContent className="space-y-3 p-4">
|
|
649
|
-
<div className="flex items-start justify-between gap-3">
|
|
650
|
-
<div>
|
|
651
|
-
<p className="text-sm font-medium text-muted-foreground">
|
|
652
|
-
{card.title}
|
|
653
|
-
</p>
|
|
654
|
-
<p className="mt-1 text-2xl font-semibold">
|
|
655
|
-
{card.value}
|
|
656
|
-
</p>
|
|
657
|
-
</div>
|
|
658
|
-
<div className={`rounded-xl p-2 ${colors[index % colors.length]}`}>
|
|
659
|
-
<Icon className="size-5" />
|
|
660
|
-
</div>
|
|
661
|
-
</div>
|
|
662
|
-
<p className="text-sm leading-relaxed text-muted-foreground">
|
|
663
|
-
{card.description}
|
|
664
|
-
</p>
|
|
665
|
-
</CardContent>
|
|
666
|
-
</Card>
|
|
667
|
-
);
|
|
668
|
-
})}
|
|
669
|
-
</div>
|
|
670
|
-
</div>
|
|
671
|
-
|
|
672
|
-
<Card>
|
|
673
|
-
<CardHeader>
|
|
674
|
-
<CardTitle>Detalhamento por colaborador</CardTitle>
|
|
675
|
-
<CardDescription>
|
|
676
|
-
Custo, valor gerado, lucro, alocação e recomendação executiva.
|
|
677
|
-
</CardDescription>
|
|
678
|
-
</CardHeader>
|
|
679
|
-
<CardContent className="overflow-x-auto">
|
|
680
|
-
<Table>
|
|
681
|
-
<TableHeader>
|
|
682
|
-
<TableRow>
|
|
683
|
-
<TableHead>Colaborador</TableHead>
|
|
684
|
-
<TableHead>Custo</TableHead>
|
|
685
|
-
<TableHead>Valor gerado</TableHead>
|
|
686
|
-
<TableHead>Lucro</TableHead>
|
|
687
|
-
<TableHead>Horas</TableHead>
|
|
688
|
-
<TableHead>Alocação</TableHead>
|
|
689
|
-
<TableHead>Risco</TableHead>
|
|
690
|
-
<TableHead>Recomendação</TableHead>
|
|
691
|
-
</TableRow>
|
|
692
|
-
</TableHeader>
|
|
693
|
-
<TableBody>
|
|
694
|
-
{(data?.rows ?? []).map((row) => {
|
|
695
|
-
const cost = getTotalCost(row);
|
|
696
|
-
const profit = row.billableValue - cost;
|
|
697
|
-
const allocation = row.availableHours
|
|
698
|
-
? (row.allocatedHours / row.availableHours) * 100
|
|
699
|
-
: 0;
|
|
700
|
-
const utilization = row.availableHours
|
|
701
|
-
? (row.billableHours / row.availableHours) * 100
|
|
702
|
-
: 0;
|
|
703
|
-
const status = getAllocationStatus(allocation);
|
|
704
|
-
|
|
705
|
-
return (
|
|
706
|
-
<TableRow key={row.id}>
|
|
707
|
-
<TableCell className="min-w-64">
|
|
708
|
-
<div className="font-medium">{row.name}</div>
|
|
709
|
-
<div className="text-xs text-muted-foreground">
|
|
710
|
-
{row.role} · {row.seniority} · {row.department} ·{' '}
|
|
711
|
-
{row.contractType}
|
|
712
|
-
</div>
|
|
713
|
-
</TableCell>
|
|
714
|
-
<TableCell>{formatCurrency(cost)}</TableCell>
|
|
715
|
-
<TableCell>{formatCurrency(row.billableValue)}</TableCell>
|
|
716
|
-
<TableCell
|
|
717
|
-
className={
|
|
718
|
-
profit >= 0 ? 'text-emerald-700' : 'text-rose-700'
|
|
719
|
-
}
|
|
720
|
-
>
|
|
721
|
-
{formatCurrency(profit)}
|
|
722
|
-
</TableCell>
|
|
723
|
-
<TableCell>
|
|
724
|
-
<div className="min-w-36 space-y-1">
|
|
725
|
-
<div className="flex items-center justify-between text-xs">
|
|
726
|
-
<span>{formatHours(row.billableHours)} fat.</span>
|
|
727
|
-
<span>{formatPercent(utilization)}</span>
|
|
728
|
-
</div>
|
|
729
|
-
<Progress value={Math.min(utilization, 100)} />
|
|
730
|
-
</div>
|
|
731
|
-
</TableCell>
|
|
732
|
-
<TableCell>
|
|
733
|
-
<div className="min-w-32 space-y-1">
|
|
734
|
-
<div className="flex items-center justify-between gap-2">
|
|
735
|
-
<Badge variant="outline" className={status.className}>
|
|
736
|
-
{status.label}
|
|
737
|
-
</Badge>
|
|
738
|
-
<span className="text-xs">
|
|
739
|
-
{formatPercent(allocation)}
|
|
740
|
-
</span>
|
|
741
|
-
</div>
|
|
742
|
-
<Progress value={Math.min(allocation, 100)} />
|
|
743
|
-
</div>
|
|
744
|
-
</TableCell>
|
|
745
|
-
<TableCell>
|
|
746
|
-
<Badge
|
|
747
|
-
variant="outline"
|
|
748
|
-
className={
|
|
749
|
-
row.risk === 'alto'
|
|
750
|
-
? 'border-rose-200 bg-rose-50 text-rose-700'
|
|
751
|
-
: row.risk === 'médio'
|
|
752
|
-
? 'border-amber-200 bg-amber-50 text-amber-700'
|
|
753
|
-
: 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
|
754
|
-
}
|
|
755
|
-
>
|
|
756
|
-
{row.risk}
|
|
757
|
-
</Badge>
|
|
758
|
-
</TableCell>
|
|
759
|
-
<TableCell className="min-w-72 text-sm text-muted-foreground">
|
|
760
|
-
{row.recommendation}
|
|
761
|
-
</TableCell>
|
|
762
|
-
</TableRow>
|
|
763
|
-
);
|
|
764
|
-
})}
|
|
765
|
-
</TableBody>
|
|
766
|
-
</Table>
|
|
767
|
-
</CardContent>
|
|
768
|
-
</Card>
|
|
769
|
-
</Page>
|
|
770
|
-
);
|
|
771
|
-
}
|
|
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
|
+
AlertTriangle,
|
|
34
|
+
Banknote,
|
|
35
|
+
BriefcaseBusiness,
|
|
36
|
+
CalendarClock,
|
|
37
|
+
Clock3,
|
|
38
|
+
Route,
|
|
39
|
+
Target,
|
|
40
|
+
TrendingUp,
|
|
41
|
+
UserPlus,
|
|
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
|
+
Pie,
|
|
56
|
+
PieChart,
|
|
57
|
+
PolarAngleAxis,
|
|
58
|
+
PolarGrid,
|
|
59
|
+
Radar,
|
|
60
|
+
RadarChart,
|
|
61
|
+
ResponsiveContainer,
|
|
62
|
+
Tooltip,
|
|
63
|
+
XAxis,
|
|
64
|
+
YAxis,
|
|
65
|
+
} from 'recharts';
|
|
66
|
+
import { OperationsHeader } from '../../_components/operations-header';
|
|
67
|
+
import { fetchOperations } from '../../_lib/api';
|
|
68
|
+
import type {
|
|
69
|
+
OperationsCollaboratorsReport,
|
|
70
|
+
OperationsReportScenario,
|
|
71
|
+
} from '../../_lib/types';
|
|
72
|
+
import { formatCurrency, formatHours } from '../../_lib/utils/format';
|
|
73
|
+
|
|
74
|
+
const CHART_COLORS = {
|
|
75
|
+
revenue: '#2563eb',
|
|
76
|
+
cost: '#dc2626',
|
|
77
|
+
profit: '#16a34a',
|
|
78
|
+
margin: '#7c3aed',
|
|
79
|
+
billable: '#0891b2',
|
|
80
|
+
internal: '#f59e0b',
|
|
81
|
+
free: '#94a3b8',
|
|
82
|
+
overload: '#e11d48',
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const scenarioLabels: Record<OperationsReportScenario, string> = {
|
|
86
|
+
base: 'Base',
|
|
87
|
+
growth: 'Crescimento',
|
|
88
|
+
conservative: 'Conservador',
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const planningIcons: LucideIcon[] = [
|
|
92
|
+
UserPlus,
|
|
93
|
+
Route,
|
|
94
|
+
AlertTriangle,
|
|
95
|
+
TrendingUp,
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
function getDefaultRange() {
|
|
99
|
+
const year = new Date().getFullYear();
|
|
100
|
+
return { from: `${year}-01-01`, to: `${year}-12-31` };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function formatPercent(value: number) {
|
|
104
|
+
return `${Math.round(Number(value || 0))}%`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function getTotalCost(row: OperationsCollaboratorsReport['rows'][number]) {
|
|
108
|
+
return row.salaryCost + row.benefitsCost + row.taxesCost + row.toolsCost;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function getAllocationStatus(allocation: number) {
|
|
112
|
+
if (allocation >= 98) {
|
|
113
|
+
return {
|
|
114
|
+
label: 'Sobrecarga',
|
|
115
|
+
className: 'border-rose-200 bg-rose-50 text-rose-700',
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
if (allocation < 75) {
|
|
119
|
+
return {
|
|
120
|
+
label: 'Ocioso',
|
|
121
|
+
className: 'border-amber-200 bg-amber-50 text-amber-700',
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
label: 'Saudável',
|
|
126
|
+
className: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function renderCurrencyTooltip({
|
|
131
|
+
active,
|
|
132
|
+
payload,
|
|
133
|
+
label,
|
|
134
|
+
}: TooltipProps<number, string>) {
|
|
135
|
+
if (!active || !payload?.length) return null;
|
|
136
|
+
return (
|
|
137
|
+
<div className="min-w-52 rounded-md border bg-popover px-3 py-2 text-sm text-popover-foreground shadow-xl">
|
|
138
|
+
<p className="mb-1 font-semibold">{label}</p>
|
|
139
|
+
{payload.map((entry) => (
|
|
140
|
+
<div
|
|
141
|
+
key={String(entry.dataKey ?? entry.name)}
|
|
142
|
+
className="flex items-center justify-between gap-3 py-0.5"
|
|
143
|
+
>
|
|
144
|
+
<span className="flex items-center gap-2 text-muted-foreground">
|
|
145
|
+
<span
|
|
146
|
+
className="size-2.5 rounded-full"
|
|
147
|
+
style={{ backgroundColor: entry.color }}
|
|
148
|
+
/>
|
|
149
|
+
{entry.name}
|
|
150
|
+
</span>
|
|
151
|
+
<span className="font-semibold">
|
|
152
|
+
{formatCurrency(Number(entry.value ?? 0))}
|
|
153
|
+
</span>
|
|
154
|
+
</div>
|
|
155
|
+
))}
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function renderNumberTooltip({
|
|
161
|
+
active,
|
|
162
|
+
payload,
|
|
163
|
+
label,
|
|
164
|
+
}: TooltipProps<number, string>) {
|
|
165
|
+
if (!active || !payload?.length) return null;
|
|
166
|
+
return (
|
|
167
|
+
<div className="min-w-44 rounded-md border bg-popover px-3 py-2 text-sm text-popover-foreground shadow-xl">
|
|
168
|
+
<p className="mb-1 font-semibold">{label}</p>
|
|
169
|
+
{payload.map((entry) => (
|
|
170
|
+
<div
|
|
171
|
+
key={String(entry.dataKey ?? entry.name)}
|
|
172
|
+
className="flex items-center justify-between gap-3 py-0.5"
|
|
173
|
+
>
|
|
174
|
+
<span className="flex items-center gap-2 text-muted-foreground">
|
|
175
|
+
<span
|
|
176
|
+
className="size-2.5 rounded-full"
|
|
177
|
+
style={{ backgroundColor: entry.color }}
|
|
178
|
+
/>
|
|
179
|
+
{entry.name}
|
|
180
|
+
</span>
|
|
181
|
+
<span className="font-semibold">{Number(entry.value ?? 0)}</span>
|
|
182
|
+
</div>
|
|
183
|
+
))}
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export default function OperationsCollaboratorReportsPage() {
|
|
189
|
+
const defaults = getDefaultRange();
|
|
190
|
+
const { request } = useApp();
|
|
191
|
+
const [startDate, setStartDate] = useState(defaults.from);
|
|
192
|
+
const [endDate, setEndDate] = useState(defaults.to);
|
|
193
|
+
const [departmentFilter, setDepartmentFilter] = useState('all');
|
|
194
|
+
const [contractFilter, setContractFilter] = useState('all');
|
|
195
|
+
const [scenario, setScenario] = useState<OperationsReportScenario>('base');
|
|
196
|
+
|
|
197
|
+
const { data } = useQuery<OperationsCollaboratorsReport>({
|
|
198
|
+
queryKey: [
|
|
199
|
+
'operations-collaborators-report',
|
|
200
|
+
startDate,
|
|
201
|
+
endDate,
|
|
202
|
+
departmentFilter,
|
|
203
|
+
contractFilter,
|
|
204
|
+
scenario,
|
|
205
|
+
],
|
|
206
|
+
queryFn: () => {
|
|
207
|
+
const params = new URLSearchParams({
|
|
208
|
+
from: startDate,
|
|
209
|
+
to: endDate,
|
|
210
|
+
department: departmentFilter,
|
|
211
|
+
contractType: contractFilter,
|
|
212
|
+
scenario,
|
|
213
|
+
});
|
|
214
|
+
return fetchOperations<OperationsCollaboratorsReport>(
|
|
215
|
+
request,
|
|
216
|
+
`/operations/reports/collaborators?${params.toString()}`
|
|
217
|
+
);
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const summary = data?.summary ?? {
|
|
222
|
+
cost: 0,
|
|
223
|
+
salary: 0,
|
|
224
|
+
benefits: 0,
|
|
225
|
+
taxes: 0,
|
|
226
|
+
tools: 0,
|
|
227
|
+
billableValue: 0,
|
|
228
|
+
profit: 0,
|
|
229
|
+
margin: 0,
|
|
230
|
+
availableHours: 0,
|
|
231
|
+
allocatedHours: 0,
|
|
232
|
+
billableHours: 0,
|
|
233
|
+
internalHours: 0,
|
|
234
|
+
overtimeHours: 0,
|
|
235
|
+
freeHours: 0,
|
|
236
|
+
allocation: 0,
|
|
237
|
+
utilization: 0,
|
|
238
|
+
overloadCount: 0,
|
|
239
|
+
hourlyCost: 0,
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
return (
|
|
243
|
+
<Page>
|
|
244
|
+
<OperationsHeader
|
|
245
|
+
title="Relatório de Colaboradores"
|
|
246
|
+
description="Dashboard executivo com custo, lucro, capacidade atual e planejamento dos próximos 12 meses."
|
|
247
|
+
current="Relatórios / Colaboradores"
|
|
248
|
+
/>
|
|
249
|
+
|
|
250
|
+
<div className="grid gap-3 rounded-lg border bg-card p-3 md:grid-cols-[1fr_1fr_180px_160px_180px_auto]">
|
|
251
|
+
<div className="space-y-1">
|
|
252
|
+
<span className="text-xs font-medium text-muted-foreground">
|
|
253
|
+
Início
|
|
254
|
+
</span>
|
|
255
|
+
<Input
|
|
256
|
+
type="date"
|
|
257
|
+
value={startDate}
|
|
258
|
+
onChange={(event) => setStartDate(event.target.value)}
|
|
259
|
+
/>
|
|
260
|
+
</div>
|
|
261
|
+
<div className="space-y-1">
|
|
262
|
+
<span className="text-xs font-medium text-muted-foreground">Fim</span>
|
|
263
|
+
<Input
|
|
264
|
+
type="date"
|
|
265
|
+
value={endDate}
|
|
266
|
+
onChange={(event) => setEndDate(event.target.value)}
|
|
267
|
+
/>
|
|
268
|
+
</div>
|
|
269
|
+
<div className="space-y-1">
|
|
270
|
+
<span className="text-xs font-medium text-muted-foreground">
|
|
271
|
+
Departamento
|
|
272
|
+
</span>
|
|
273
|
+
<Select value={departmentFilter} onValueChange={setDepartmentFilter}>
|
|
274
|
+
<SelectTrigger className="w-full">
|
|
275
|
+
<SelectValue />
|
|
276
|
+
</SelectTrigger>
|
|
277
|
+
<SelectContent>
|
|
278
|
+
<SelectItem value="all">Todos</SelectItem>
|
|
279
|
+
{(data?.filters.departments ?? []).map((department) => (
|
|
280
|
+
<SelectItem key={department} value={department}>
|
|
281
|
+
{department}
|
|
282
|
+
</SelectItem>
|
|
283
|
+
))}
|
|
284
|
+
</SelectContent>
|
|
285
|
+
</Select>
|
|
286
|
+
</div>
|
|
287
|
+
<div className="space-y-1">
|
|
288
|
+
<span className="text-xs font-medium text-muted-foreground">
|
|
289
|
+
Vínculo
|
|
290
|
+
</span>
|
|
291
|
+
<Select value={contractFilter} onValueChange={setContractFilter}>
|
|
292
|
+
<SelectTrigger className="w-full">
|
|
293
|
+
<SelectValue />
|
|
294
|
+
</SelectTrigger>
|
|
295
|
+
<SelectContent>
|
|
296
|
+
<SelectItem value="all">Todos</SelectItem>
|
|
297
|
+
{(data?.filters.contractTypes ?? []).map((contractType) => (
|
|
298
|
+
<SelectItem key={contractType} value={contractType}>
|
|
299
|
+
{contractType}
|
|
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
|
+
setDepartmentFilter('all');
|
|
333
|
+
setContractFilter('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: 'headcount',
|
|
347
|
+
title: 'Headcount',
|
|
348
|
+
value: data?.rows.length ?? 0,
|
|
349
|
+
description: 'Colaboradores no recorte',
|
|
350
|
+
icon: UsersRound,
|
|
351
|
+
accentClassName: 'from-sky-500/20 via-cyan-400/10 to-transparent',
|
|
352
|
+
iconContainerClassName: 'bg-sky-50 text-sky-700',
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
key: 'cost',
|
|
356
|
+
title: 'Custo total',
|
|
357
|
+
value: formatCurrency(summary.cost),
|
|
358
|
+
description: `${formatCurrency(summary.hourlyCost)} por hora alocada`,
|
|
359
|
+
icon: Banknote,
|
|
360
|
+
accentClassName: 'from-rose-500/20 via-red-400/10 to-transparent',
|
|
361
|
+
iconContainerClassName: 'bg-rose-50 text-rose-700',
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
key: 'revenue',
|
|
365
|
+
title: 'Valor gerado',
|
|
366
|
+
value: formatCurrency(summary.billableValue),
|
|
367
|
+
description: `${formatHours(summary.billableHours)} faturáveis`,
|
|
368
|
+
icon: BriefcaseBusiness,
|
|
369
|
+
accentClassName:
|
|
370
|
+
'from-emerald-500/20 via-green-400/10 to-transparent',
|
|
371
|
+
iconContainerClassName: 'bg-emerald-50 text-emerald-700',
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
key: 'margin',
|
|
375
|
+
title: 'Margem',
|
|
376
|
+
value: formatPercent(summary.margin),
|
|
377
|
+
description: formatCurrency(summary.profit),
|
|
378
|
+
icon: Target,
|
|
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: 'utilization',
|
|
385
|
+
title: 'Utilização',
|
|
386
|
+
value: formatPercent(summary.utilization),
|
|
387
|
+
description: 'Horas faturáveis sobre capacidade',
|
|
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: 'free',
|
|
396
|
+
title: 'Capacidade livre',
|
|
397
|
+
value: formatHours(summary.freeHours),
|
|
398
|
+
description: `${formatPercent(100 - summary.allocation)} disponível`,
|
|
399
|
+
icon: CalendarClock,
|
|
400
|
+
layout: 'compact',
|
|
401
|
+
accentClassName:
|
|
402
|
+
'from-slate-500/20 via-slate-400/10 to-transparent',
|
|
403
|
+
iconContainerClassName: 'bg-slate-100 text-slate-700',
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
key: 'overtime',
|
|
407
|
+
title: 'Horas extras',
|
|
408
|
+
value: formatHours(summary.overtimeHours),
|
|
409
|
+
description: `${summary.overloadCount} pessoas em sobrecarga`,
|
|
410
|
+
icon: AlertTriangle,
|
|
411
|
+
layout: 'compact',
|
|
412
|
+
accentClassName:
|
|
413
|
+
'from-amber-500/20 via-orange-400/10 to-transparent',
|
|
414
|
+
iconContainerClassName: 'bg-amber-50 text-amber-700',
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
key: 'scenario',
|
|
418
|
+
title: 'Cenário 12 meses',
|
|
419
|
+
value: scenarioLabels[scenario],
|
|
420
|
+
description: 'Projeção calculada no backend',
|
|
421
|
+
icon: TrendingUp,
|
|
422
|
+
layout: 'compact',
|
|
423
|
+
accentClassName:
|
|
424
|
+
'from-indigo-500/20 via-blue-400/10 to-transparent',
|
|
425
|
+
iconContainerClassName: 'bg-indigo-50 text-indigo-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 custo, valor e margem</CardTitle>
|
|
434
|
+
<CardDescription>
|
|
435
|
+
Simulação mensal para planejamento de capacidade 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="Valor gerado"
|
|
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 custo</CardTitle>
|
|
481
|
+
<CardDescription>
|
|
482
|
+
Distribuição dos custos usados no cálculo da margem.
|
|
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.profit,
|
|
505
|
+
CHART_COLORS.internal,
|
|
506
|
+
CHART_COLORS.margin,
|
|
507
|
+
][index % 4]
|
|
508
|
+
}
|
|
509
|
+
stroke="hsl(var(--background))"
|
|
510
|
+
strokeWidth={2}
|
|
511
|
+
/>
|
|
512
|
+
))}
|
|
513
|
+
</Pie>
|
|
514
|
+
<Legend iconType="circle" wrapperStyle={{ fontSize: 12 }} />
|
|
515
|
+
<Tooltip content={renderCurrencyTooltip} />
|
|
516
|
+
</PieChart>
|
|
517
|
+
</ResponsiveContainer>
|
|
518
|
+
</CardContent>
|
|
519
|
+
</Card>
|
|
520
|
+
</div>
|
|
521
|
+
|
|
522
|
+
<div className="grid gap-4 xl:grid-cols-[1fr_1fr]">
|
|
523
|
+
<Card>
|
|
524
|
+
<CardHeader>
|
|
525
|
+
<CardTitle>Capacidade por departamento</CardTitle>
|
|
526
|
+
<CardDescription>
|
|
527
|
+
Horas faturáveis, internas, livres e acima da capacidade.
|
|
528
|
+
</CardDescription>
|
|
529
|
+
</CardHeader>
|
|
530
|
+
<CardContent>
|
|
531
|
+
<ResponsiveContainer width="100%" height={320}>
|
|
532
|
+
<BarChart data={data?.capacityByDepartment ?? []}>
|
|
533
|
+
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
|
534
|
+
<XAxis dataKey="department" tick={{ fontSize: 12 }} />
|
|
535
|
+
<YAxis tick={{ fontSize: 12 }} />
|
|
536
|
+
<Tooltip content={renderNumberTooltip} />
|
|
537
|
+
<Legend />
|
|
538
|
+
<Bar
|
|
539
|
+
dataKey="Faturável"
|
|
540
|
+
stackId="capacity"
|
|
541
|
+
fill={CHART_COLORS.billable}
|
|
542
|
+
/>
|
|
543
|
+
<Bar
|
|
544
|
+
dataKey="Interno"
|
|
545
|
+
stackId="capacity"
|
|
546
|
+
fill={CHART_COLORS.internal}
|
|
547
|
+
/>
|
|
548
|
+
<Bar
|
|
549
|
+
dataKey="Livre"
|
|
550
|
+
stackId="capacity"
|
|
551
|
+
fill={CHART_COLORS.free}
|
|
552
|
+
/>
|
|
553
|
+
<Bar
|
|
554
|
+
dataKey="Sobrecarga"
|
|
555
|
+
stackId="capacity"
|
|
556
|
+
fill={CHART_COLORS.overload}
|
|
557
|
+
radius={[6, 6, 0, 0]}
|
|
558
|
+
/>
|
|
559
|
+
</BarChart>
|
|
560
|
+
</ResponsiveContainer>
|
|
561
|
+
</CardContent>
|
|
562
|
+
</Card>
|
|
563
|
+
|
|
564
|
+
<Card>
|
|
565
|
+
<CardHeader>
|
|
566
|
+
<CardTitle>Saúde por departamento</CardTitle>
|
|
567
|
+
<CardDescription>
|
|
568
|
+
Combina margem, alocação, utilização e risco operacional.
|
|
569
|
+
</CardDescription>
|
|
570
|
+
</CardHeader>
|
|
571
|
+
<CardContent>
|
|
572
|
+
<ResponsiveContainer width="100%" height={320}>
|
|
573
|
+
<RadarChart data={data?.health ?? []}>
|
|
574
|
+
<PolarGrid />
|
|
575
|
+
<PolarAngleAxis dataKey="department" tick={{ fontSize: 12 }} />
|
|
576
|
+
<Tooltip content={renderNumberTooltip} />
|
|
577
|
+
<Legend />
|
|
578
|
+
<Radar
|
|
579
|
+
dataKey="margem"
|
|
580
|
+
name="Margem"
|
|
581
|
+
stroke={CHART_COLORS.profit}
|
|
582
|
+
fill={CHART_COLORS.profit}
|
|
583
|
+
fillOpacity={0.18}
|
|
584
|
+
/>
|
|
585
|
+
<Radar
|
|
586
|
+
dataKey="alocacao"
|
|
587
|
+
name="Alocação"
|
|
588
|
+
stroke={CHART_COLORS.margin}
|
|
589
|
+
fill={CHART_COLORS.margin}
|
|
590
|
+
fillOpacity={0.12}
|
|
591
|
+
/>
|
|
592
|
+
<Radar
|
|
593
|
+
dataKey="saude"
|
|
594
|
+
name="Saúde"
|
|
595
|
+
stroke={CHART_COLORS.revenue}
|
|
596
|
+
fill={CHART_COLORS.revenue}
|
|
597
|
+
fillOpacity={0.1}
|
|
598
|
+
/>
|
|
599
|
+
</RadarChart>
|
|
600
|
+
</ResponsiveContainer>
|
|
601
|
+
</CardContent>
|
|
602
|
+
</Card>
|
|
603
|
+
</div>
|
|
604
|
+
|
|
605
|
+
<div className="grid gap-4 xl:grid-cols-[1fr_1fr]">
|
|
606
|
+
<Card>
|
|
607
|
+
<CardHeader>
|
|
608
|
+
<CardTitle>Ranking de lucro e custo</CardTitle>
|
|
609
|
+
<CardDescription>
|
|
610
|
+
Colaboradores ordenados por contribuição estimada no período.
|
|
611
|
+
</CardDescription>
|
|
612
|
+
</CardHeader>
|
|
613
|
+
<CardContent>
|
|
614
|
+
<ResponsiveContainer width="100%" height={340}>
|
|
615
|
+
<BarChart
|
|
616
|
+
data={data?.ranking ?? []}
|
|
617
|
+
layout="vertical"
|
|
618
|
+
margin={{ left: 18, right: 16 }}
|
|
619
|
+
>
|
|
620
|
+
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
|
621
|
+
<XAxis
|
|
622
|
+
type="number"
|
|
623
|
+
tick={{ fontSize: 12 }}
|
|
624
|
+
tickFormatter={(value) => `${Number(value) / 1000}k`}
|
|
625
|
+
/>
|
|
626
|
+
<YAxis dataKey="name" type="category" width={72} />
|
|
627
|
+
<Tooltip content={renderCurrencyTooltip} />
|
|
628
|
+
<Legend />
|
|
629
|
+
<Bar dataKey="Lucro" fill={CHART_COLORS.profit} radius={6} />
|
|
630
|
+
<Bar dataKey="Custo" fill={CHART_COLORS.cost} radius={6} />
|
|
631
|
+
</BarChart>
|
|
632
|
+
</ResponsiveContainer>
|
|
633
|
+
</CardContent>
|
|
634
|
+
</Card>
|
|
635
|
+
|
|
636
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
637
|
+
{(data?.planningCards ?? []).map((card, index) => {
|
|
638
|
+
const Icon =
|
|
639
|
+
planningIcons[index % planningIcons.length] ?? UserPlus;
|
|
640
|
+
const colors = [
|
|
641
|
+
'bg-sky-50 text-sky-700',
|
|
642
|
+
'bg-emerald-50 text-emerald-700',
|
|
643
|
+
'bg-amber-50 text-amber-700',
|
|
644
|
+
'bg-violet-50 text-violet-700',
|
|
645
|
+
];
|
|
646
|
+
return (
|
|
647
|
+
<Card key={card.title}>
|
|
648
|
+
<CardContent className="space-y-3 p-4">
|
|
649
|
+
<div className="flex items-start justify-between gap-3">
|
|
650
|
+
<div>
|
|
651
|
+
<p className="text-sm font-medium text-muted-foreground">
|
|
652
|
+
{card.title}
|
|
653
|
+
</p>
|
|
654
|
+
<p className="mt-1 text-2xl font-semibold">
|
|
655
|
+
{card.value}
|
|
656
|
+
</p>
|
|
657
|
+
</div>
|
|
658
|
+
<div className={`rounded-xl p-2 ${colors[index % colors.length]}`}>
|
|
659
|
+
<Icon className="size-5" />
|
|
660
|
+
</div>
|
|
661
|
+
</div>
|
|
662
|
+
<p className="text-sm leading-relaxed text-muted-foreground">
|
|
663
|
+
{card.description}
|
|
664
|
+
</p>
|
|
665
|
+
</CardContent>
|
|
666
|
+
</Card>
|
|
667
|
+
);
|
|
668
|
+
})}
|
|
669
|
+
</div>
|
|
670
|
+
</div>
|
|
671
|
+
|
|
672
|
+
<Card>
|
|
673
|
+
<CardHeader>
|
|
674
|
+
<CardTitle>Detalhamento por colaborador</CardTitle>
|
|
675
|
+
<CardDescription>
|
|
676
|
+
Custo, valor gerado, lucro, alocação e recomendação executiva.
|
|
677
|
+
</CardDescription>
|
|
678
|
+
</CardHeader>
|
|
679
|
+
<CardContent className="overflow-x-auto">
|
|
680
|
+
<Table>
|
|
681
|
+
<TableHeader>
|
|
682
|
+
<TableRow>
|
|
683
|
+
<TableHead>Colaborador</TableHead>
|
|
684
|
+
<TableHead>Custo</TableHead>
|
|
685
|
+
<TableHead>Valor gerado</TableHead>
|
|
686
|
+
<TableHead>Lucro</TableHead>
|
|
687
|
+
<TableHead>Horas</TableHead>
|
|
688
|
+
<TableHead>Alocação</TableHead>
|
|
689
|
+
<TableHead>Risco</TableHead>
|
|
690
|
+
<TableHead>Recomendação</TableHead>
|
|
691
|
+
</TableRow>
|
|
692
|
+
</TableHeader>
|
|
693
|
+
<TableBody>
|
|
694
|
+
{(data?.rows ?? []).map((row) => {
|
|
695
|
+
const cost = getTotalCost(row);
|
|
696
|
+
const profit = row.billableValue - cost;
|
|
697
|
+
const allocation = row.availableHours
|
|
698
|
+
? (row.allocatedHours / row.availableHours) * 100
|
|
699
|
+
: 0;
|
|
700
|
+
const utilization = row.availableHours
|
|
701
|
+
? (row.billableHours / row.availableHours) * 100
|
|
702
|
+
: 0;
|
|
703
|
+
const status = getAllocationStatus(allocation);
|
|
704
|
+
|
|
705
|
+
return (
|
|
706
|
+
<TableRow key={row.id}>
|
|
707
|
+
<TableCell className="min-w-64">
|
|
708
|
+
<div className="font-medium">{row.name}</div>
|
|
709
|
+
<div className="text-xs text-muted-foreground">
|
|
710
|
+
{row.role} · {row.seniority} · {row.department} ·{' '}
|
|
711
|
+
{row.contractType}
|
|
712
|
+
</div>
|
|
713
|
+
</TableCell>
|
|
714
|
+
<TableCell>{formatCurrency(cost)}</TableCell>
|
|
715
|
+
<TableCell>{formatCurrency(row.billableValue)}</TableCell>
|
|
716
|
+
<TableCell
|
|
717
|
+
className={
|
|
718
|
+
profit >= 0 ? 'text-emerald-700' : 'text-rose-700'
|
|
719
|
+
}
|
|
720
|
+
>
|
|
721
|
+
{formatCurrency(profit)}
|
|
722
|
+
</TableCell>
|
|
723
|
+
<TableCell>
|
|
724
|
+
<div className="min-w-36 space-y-1">
|
|
725
|
+
<div className="flex items-center justify-between text-xs">
|
|
726
|
+
<span>{formatHours(row.billableHours)} fat.</span>
|
|
727
|
+
<span>{formatPercent(utilization)}</span>
|
|
728
|
+
</div>
|
|
729
|
+
<Progress value={Math.min(utilization, 100)} />
|
|
730
|
+
</div>
|
|
731
|
+
</TableCell>
|
|
732
|
+
<TableCell>
|
|
733
|
+
<div className="min-w-32 space-y-1">
|
|
734
|
+
<div className="flex items-center justify-between gap-2">
|
|
735
|
+
<Badge variant="outline" className={status.className}>
|
|
736
|
+
{status.label}
|
|
737
|
+
</Badge>
|
|
738
|
+
<span className="text-xs">
|
|
739
|
+
{formatPercent(allocation)}
|
|
740
|
+
</span>
|
|
741
|
+
</div>
|
|
742
|
+
<Progress value={Math.min(allocation, 100)} />
|
|
743
|
+
</div>
|
|
744
|
+
</TableCell>
|
|
745
|
+
<TableCell>
|
|
746
|
+
<Badge
|
|
747
|
+
variant="outline"
|
|
748
|
+
className={
|
|
749
|
+
row.risk === 'alto'
|
|
750
|
+
? 'border-rose-200 bg-rose-50 text-rose-700'
|
|
751
|
+
: row.risk === 'médio'
|
|
752
|
+
? 'border-amber-200 bg-amber-50 text-amber-700'
|
|
753
|
+
: 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
|
754
|
+
}
|
|
755
|
+
>
|
|
756
|
+
{row.risk}
|
|
757
|
+
</Badge>
|
|
758
|
+
</TableCell>
|
|
759
|
+
<TableCell className="min-w-72 text-sm text-muted-foreground">
|
|
760
|
+
{row.recommendation}
|
|
761
|
+
</TableCell>
|
|
762
|
+
</TableRow>
|
|
763
|
+
);
|
|
764
|
+
})}
|
|
765
|
+
</TableBody>
|
|
766
|
+
</Table>
|
|
767
|
+
</CardContent>
|
|
768
|
+
</Card>
|
|
769
|
+
</Page>
|
|
770
|
+
);
|
|
771
|
+
}
|