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 FileUpload from '$lib/components/FileUpload.svelte';
|
||||||
import ErrorModal from '$lib/components/ErrorModal.svelte';
|
import ErrorModal from '$lib/components/ErrorModal.svelte';
|
||||||
import CalendarioAfastamentos from '$lib/components/CalendarioAfastamentos.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 type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
import jsPDF from 'jspdf';
|
import jsPDF from 'jspdf';
|
||||||
import autoTable from 'jspdf-autotable';
|
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
|
// Salvar Atestado Médico
|
||||||
async function salvarAtestadoMedico() {
|
async function salvarAtestadoMedico() {
|
||||||
if (
|
if (
|
||||||
@@ -1643,282 +1724,34 @@
|
|||||||
|
|
||||||
<!-- Gráficos -->
|
<!-- Gráficos -->
|
||||||
{#if graficosQuery?.data}
|
{#if graficosQuery?.data}
|
||||||
{@const dados = graficosQuery.data.totalDiasPorTipo}
|
<!-- Gráfico 1: Total de Dias por Tipo (Layerchart) -->
|
||||||
{@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) -->
|
|
||||||
<div class="card bg-base-100 mb-6 shadow-xl">
|
<div class="card bg-base-100 mb-6 shadow-xl">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title mb-4">Total de Dias por Tipo</h2>
|
<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">
|
<div class="bg-base-200/30 w-full overflow-x-auto rounded-xl p-4">
|
||||||
<svg
|
{#if chartDataTotalDiasPorTipo.labels.length === 0}
|
||||||
width={chartWidth}
|
<div class="flex h-96 items-center justify-center">
|
||||||
height={chartHeight}
|
<p class="text-base-content/60">Sem dados registrados até o momento.</p>
|
||||||
class="w-full"
|
</div>
|
||||||
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
|
{:else}
|
||||||
>
|
<AreaChart data={chartDataTotalDiasPorTipo} height={400} />
|
||||||
<!-- Grid lines -->
|
{/if}
|
||||||
{#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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 bg-base-100 mb-6 shadow-xl">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title mb-4">Tendências Mensais (Últimos 6 Meses)</h2>
|
<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">
|
<div class="bg-base-200/30 w-full overflow-x-auto rounded-xl p-4">
|
||||||
<svg
|
{#if chartDataTendenciasMensais.labels.length === 0}
|
||||||
width={chartWidth2}
|
<div class="flex h-96 items-center justify-center">
|
||||||
height={chartHeight2}
|
<p class="text-base-content/60">Sem dados registrados até o momento.</p>
|
||||||
class="w-full"
|
</div>
|
||||||
viewBox={`0 0 ${chartWidth2} ${chartHeight2}`}
|
{:else}
|
||||||
>
|
<AreaChart data={chartDataTendenciasMensais} height={400} />
|
||||||
<!-- Grid lines -->
|
{/if}
|
||||||
{#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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user