feat: implement area charts for total days by type and monthly trends in the employee leave dashboard, enhancing data visualization and user insights

This commit is contained in:
2025-11-30 00:30:38 -03:00
parent 298326e264
commit b85021d924

View File

@@ -10,6 +10,7 @@
import FileUpload from '$lib/components/FileUpload.svelte';
import ErrorModal from '$lib/components/ErrorModal.svelte';
import CalendarioAfastamentos from '$lib/components/CalendarioAfastamentos.svelte';
import AreaChart from '$lib/components/ti/charts/AreaChart.svelte';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
@@ -141,6 +142,86 @@
};
}
// Dados para gráfico de área - Total de Dias por Tipo (Layerchart)
const chartDataTotalDiasPorTipo = $derived.by(() => {
if (!graficosQuery?.data?.totalDiasPorTipo) {
return {
labels: [],
datasets: []
};
}
const dados = graficosQuery.data.totalDiasPorTipo;
const cores = ['#ef4444', '#f97316', '#ec4899', '#3b82f6', '#10b981'];
return {
labels: dados.map((d) => d.tipo),
datasets: [
{
label: 'Total de Dias',
data: dados.map((d) => d.dias),
backgroundColor: dados.map((_, i) => `${cores[i % cores.length]}80`),
borderColor: dados.map((_, i) => cores[i % cores.length]),
borderWidth: 3,
pointBackgroundColor: dados.map((_, i) => cores[i % cores.length]),
pointBorderColor: '#ffffff',
pointBorderWidth: 2,
pointRadius: 6,
pointHoverRadius: 8,
pointHoverBackgroundColor: dados.map((_, i) => cores[i % cores.length]),
pointHoverBorderColor: '#ffffff',
pointHoverBorderWidth: 3,
fill: true,
tension: 0.4,
spanGaps: false
}
]
};
});
// Dados para gráfico de área - Tendências Mensais (Layerchart empilhado)
const chartDataTendenciasMensais = $derived.by(() => {
if (!graficosQuery?.data?.tendenciasMensais) {
return {
labels: [],
datasets: []
};
}
const tendencias = graficosQuery.data.tendenciasMensais;
const tipos = [
'atestado_medico',
'declaracao_comparecimento',
'maternidade',
'paternidade',
'ferias'
];
const cores = ['#ef4444', '#f97316', '#ec4899', '#3b82f6', '#10b981'];
const nomes = ['Atestado Médico', 'Declaração', 'Maternidade', 'Paternidade', 'Férias'];
return {
labels: tendencias.map((t) => t.mes),
datasets: tipos.map((tipo, idx) => ({
label: nomes[idx],
data: tendencias.map((t) => (t[tipo as keyof typeof t] as number) || 0),
backgroundColor: `${cores[idx]}60`,
borderColor: cores[idx],
borderWidth: 2,
pointBackgroundColor: cores[idx],
pointBorderColor: '#ffffff',
pointBorderWidth: 2,
pointRadius: 4,
pointHoverRadius: 6,
pointHoverBackgroundColor: cores[idx],
pointHoverBorderColor: '#ffffff',
pointHoverBorderWidth: 3,
fill: true,
tension: 0.4,
spanGaps: false
}))
};
});
// Salvar Atestado Médico
async function salvarAtestadoMedico() {
if (
@@ -1643,282 +1724,34 @@
<!-- Gráficos -->
{#if graficosQuery?.data}
{@const dados = graficosQuery.data.totalDiasPorTipo}
{@const maxDias = Math.max(...dados.map((d) => d.dias), 1)}
{@const chartWidth = 800}
{@const chartHeight = 350}
{@const padding = { top: 20, right: 40, bottom: 80, left: 70 }}
{@const barWidth = (chartWidth - padding.left - padding.right) / dados.length - 10}
{@const innerHeight = chartHeight - padding.top - padding.bottom}
{@const tendencias = graficosQuery.data.tendenciasMensais}
{@const tipos = [
'atestado_medico',
'declaracao_comparecimento',
'maternidade',
'paternidade',
'ferias'
]}
{@const cores = ['#ef4444', '#f97316', '#ec4899', '#3b82f6', '#10b981']}
{@const nomes = ['Atestado Médico', 'Declaração', 'Maternidade', 'Paternidade', 'Férias']}
{@const maxValor = Math.max(
...tendencias.flatMap((t) => tipos.map((tipo) => t[tipo as keyof typeof t] as number)),
1
)}
{@const chartWidth2 = 900}
{@const chartHeight2 = 400}
{@const padding2 = { top: 20, right: 40, bottom: 80, left: 70 }}
{@const innerWidth = chartWidth2 - padding2.left - padding2.right}
{@const innerHeight2 = chartHeight2 - padding2.top - padding2.bottom}
<!-- Gráfico 1: Total de Dias por Tipo (Gráfico de Barras) -->
<!-- Gráfico 1: Total de Dias por Tipo (Layerchart) -->
<div class="card bg-base-100 mb-6 shadow-xl">
<div class="card-body">
<h2 class="card-title mb-4">Total de Dias por Tipo</h2>
<div class="bg-base-200/30 w-full overflow-x-auto rounded-xl p-4">
<svg
width={chartWidth}
height={chartHeight}
class="w-full"
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
>
<!-- Grid lines -->
{#each [0, 1, 2, 3, 4, 5] as t}
{@const val = Math.round((maxDias / 5) * t)}
{@const y = chartHeight - padding.bottom - (val / maxDias) * innerHeight}
<line
x1={padding.left}
y1={y}
x2={chartWidth - padding.right}
y2={y}
stroke="currentColor"
stroke-opacity="0.1"
stroke-dasharray="4,4"
/>
<text x={padding.left - 8} y={y + 4} text-anchor="end" class="text-xs opacity-70">
{val}
</text>
{/each}
<!-- Eixos -->
<line
x1={padding.left}
y1={chartHeight - padding.bottom}
x2={chartWidth - padding.right}
y2={chartHeight - padding.bottom}
stroke="currentColor"
stroke-opacity="0.3"
stroke-width="2"
/>
<line
x1={padding.left}
y1={padding.top}
x2={padding.left}
y2={chartHeight - padding.bottom}
stroke="currentColor"
stroke-opacity="0.3"
stroke-width="2"
/>
<!-- Barras -->
{#each dados as item, i}
{@const x = padding.left + i * (barWidth + 10) + 5}
{@const height = (item.dias / maxDias) * innerHeight}
{@const y = chartHeight - padding.bottom - height}
{@const colors = ['#ef4444', '#f97316', '#ec4899', '#3b82f6', '#10b981']}
<!-- Gradiente da barra -->
<defs>
<linearGradient id="gradient-{i}" x1="0%" y1="0%" x2="0%" y2="100%">
<stop
offset="0%"
style="stop-color:{colors[i % colors.length]};stop-opacity:0.9"
/>
<stop
offset="100%"
style="stop-color:{colors[i % colors.length]};stop-opacity:0.5"
/>
</linearGradient>
</defs>
<!-- Barra -->
<rect
{x}
{y}
width={barWidth}
{height}
fill="url(#gradient-{i})"
rx="4"
class="cursor-pointer transition-opacity hover:opacity-80"
/>
<!-- Valor no topo da barra -->
{#if item.dias > 0}
<text
x={x + barWidth / 2}
y={y - 8}
text-anchor="middle"
class="fill-base-content text-xs font-semibold"
>
{item.dias}
</text>
{/if}
<!-- Label do eixo X -->
<foreignObject
x={x - 30}
y={chartHeight - padding.bottom + 15}
width="80"
height="60"
>
<div class="flex items-center justify-center text-center">
<span
class="text-base-content/80 text-xs leading-tight font-medium"
style="word-wrap: break-word; hyphens: auto;"
>
{item.tipo}
</span>
</div>
</foreignObject>
{/each}
</svg>
{#if chartDataTotalDiasPorTipo.labels.length === 0}
<div class="flex h-96 items-center justify-center">
<p class="text-base-content/60">Sem dados registrados até o momento.</p>
</div>
{:else}
<AreaChart data={chartDataTotalDiasPorTipo} height={400} />
{/if}
</div>
</div>
</div>
<!-- Gráfico 2: Tendências Mensais (Gráfico de Linha em Camadas) -->
<!-- Gráfico 2: Tendências Mensais (Layerchart empilhado) -->
<div class="card bg-base-100 mb-6 shadow-xl">
<div class="card-body">
<h2 class="card-title mb-4">Tendências Mensais (Últimos 6 Meses)</h2>
<div class="bg-base-200/30 w-full overflow-x-auto rounded-xl p-4">
<svg
width={chartWidth2}
height={chartHeight2}
class="w-full"
viewBox={`0 0 ${chartWidth2} ${chartHeight2}`}
>
<!-- Grid lines -->
{#each [0, 1, 2, 3, 4, 5] as t}
{@const val = Math.round((maxValor / 5) * t)}
{@const y = chartHeight2 - padding2.bottom - (val / maxValor) * innerHeight2}
<line
x1={padding2.left}
y1={y}
x2={chartWidth2 - padding2.right}
y2={y}
stroke="currentColor"
stroke-opacity="0.1"
stroke-dasharray="4,4"
/>
<text x={padding2.left - 8} y={y + 4} text-anchor="end" class="text-xs opacity-70">
{val}
</text>
{/each}
<!-- Eixos -->
<line
x1={padding2.left}
y1={chartHeight2 - padding2.bottom}
x2={chartWidth2 - padding2.right}
y2={chartHeight2 - padding2.bottom}
stroke="currentColor"
stroke-opacity="0.3"
stroke-width="2"
/>
<line
x1={padding2.left}
y1={padding2.top}
x2={padding2.left}
y2={chartHeight2 - padding2.bottom}
stroke="currentColor"
stroke-opacity="0.3"
stroke-width="2"
/>
<!-- Linhas para cada tipo (em camadas) -->
{#each tipos as tipo, tipoIdx}
{@const cor = cores[tipoIdx]}
<!-- Área preenchida (camada) -->
<defs>
<linearGradient id="gradient-{tipo}" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:{cor};stop-opacity:0.4" />
<stop offset="100%" style="stop-color:{cor};stop-opacity:0.05" />
</linearGradient>
</defs>
{@const pontos = tendencias.map((t, i) => {
const x = padding2.left + (i / (tendencias.length - 1 || 1)) * innerWidth;
const valor = t[tipo as keyof typeof t] as number;
const y = chartHeight2 - padding2.bottom - (valor / maxValor) * innerHeight2;
return { x, y, valor };
})}
<!-- Área -->
{#if pontos.length > 0}
{@const pathArea =
`M ${pontos[0].x} ${chartHeight2 - padding2.bottom} ` +
pontos.map((p) => `L ${p.x} ${p.y}`).join(' ') +
` L ${pontos[pontos.length - 1].x} ${chartHeight2 - padding2.bottom} Z`}
<path d={pathArea} fill="url(#gradient-{tipo})" />
{/if}
<!-- Linha -->
{#if pontos.length > 1}
<polyline
points={pontos.map((p) => `${p.x},${p.y}`).join(' ')}
fill="none"
stroke={cor}
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
/>
{/if}
<!-- Pontos -->
{#each pontos as ponto, pontoIdx}
<circle
cx={ponto.x}
cy={ponto.y}
r="5"
fill={cor}
stroke="white"
stroke-width="2"
class="hover:r-7 cursor-pointer transition-all"
/>
<!-- Tooltip no hover -->
<title
>{nomes[tipoIdx]}: {ponto.valor} dias em {tendencias[pontoIdx]?.mes ||
''}</title
>
{/each}
{/each}
<!-- Labels do eixo X -->
{#each tendencias as t, i}
{@const x = padding2.left + (i / (tendencias.length - 1 || 1)) * innerWidth}
<foreignObject
x={x - 40}
y={chartHeight2 - padding2.bottom + 15}
width="80"
height="60"
>
<div class="flex items-center justify-center text-center">
<span class="text-base-content/80 text-xs font-medium">
{t.mes}
</span>
</div>
</foreignObject>
{/each}
</svg>
<!-- Legenda -->
<div class="border-base-300 mt-4 flex flex-wrap justify-center gap-4 border-t pt-4">
{#each tipos as tipo, idx}
<div class="flex items-center gap-2">
<div class="h-4 w-4 rounded" style="background-color: {cores[idx]}"></div>
<span class="text-base-content/70 text-sm">{nomes[idx]}</span>
</div>
{/each}
</div>
{#if chartDataTendenciasMensais.labels.length === 0}
<div class="flex h-96 items-center justify-center">
<p class="text-base-content/60">Sem dados registrados até o momento.</p>
</div>
{:else}
<AreaChart data={chartDataTendenciasMensais} height={400} />
{/if}
</div>
</div>
</div>