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:
@@ -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 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}
|
||||
|
||||
<!-- 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>
|
||||
</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 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}
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user