refactor: enhance vacation management components and add status update functionality

- Improved the vacation request component with better loading states and error handling.
- Added a new mutation to update the status of vacation requests, allowing transitions between different states.
- Enhanced the calendar display for vacation periods and integrated a 3D bar chart for visualizing vacation data.
- Refactored the code for better readability and maintainability, ensuring a smoother user experience.
This commit is contained in:
2025-11-13 05:51:55 -03:00
parent 0b7f1ad621
commit ebde59c6d2
6 changed files with 1503 additions and 721 deletions

View File

@@ -0,0 +1,277 @@
<script lang="ts">
import { useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
type SolicitacaoFerias = Doc<'solicitacoesFerias'> & {
funcionario?: Doc<'funcionarios'> | null;
gestor?: Doc<'usuarios'> | null;
};
interface Props {
solicitacao: SolicitacaoFerias;
usuarioId: Id<'usuarios'>;
onSucesso?: () => void;
onCancelar?: () => void;
}
let { solicitacao, usuarioId, onSucesso, onCancelar }: Props = $props();
const client = useConvexClient();
let processando = $state(false);
let erro = $state('');
function getStatusBadge(status: string) {
const badges: Record<string, string> = {
aguardando_aprovacao: 'badge-warning',
aprovado: 'badge-success',
reprovado: 'badge-error',
data_ajustada_aprovada: 'badge-info'
};
return badges[status] || 'badge-neutral';
}
function getStatusTexto(status: string) {
const textos: Record<string, string> = {
aguardando_aprovacao: 'Aguardando Aprovação',
aprovado: 'Aprovado',
reprovado: 'Reprovado',
data_ajustada_aprovada: 'Data Ajustada e Aprovada'
};
return textos[status] || status;
}
async function voltarParaAguardando() {
try {
processando = true;
erro = '';
await client.mutation(api.ferias.atualizarStatus, {
solicitacaoId: solicitacao._id,
novoStatus: 'aguardando_aprovacao',
usuarioId: usuarioId
});
if (onSucesso) onSucesso();
} catch (e) {
erro = e instanceof Error ? e.message : String(e);
} finally {
processando = false;
}
}
function formatarData(data: number) {
return new Date(data).toLocaleString('pt-BR');
}
</script>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="mb-4 flex items-start justify-between">
<div>
<h2 class="card-title text-2xl">
{solicitacao.funcionario?.nome || 'Funcionário'}
</h2>
<p class="text-base-content/70 mt-1 text-sm">
Ano de Referência: {solicitacao.anoReferencia}
</p>
</div>
<div class={`badge ${getStatusBadge(solicitacao.status)} badge-lg`}>
{getStatusTexto(solicitacao.status)}
</div>
</div>
<!-- Períodos Solicitados -->
<div class="mt-4">
<h3 class="mb-3 text-lg font-semibold">Períodos Solicitados</h3>
<div class="space-y-2">
{#each solicitacao.periodos as periodo, index (index)}
<div class="bg-base-200 flex items-center gap-4 rounded-lg p-3">
<div class="badge badge-primary">{index + 1}</div>
<div class="grid flex-1 grid-cols-3 gap-2 text-sm">
<div>
<span class="text-base-content/70">Início:</span>
<span class="ml-1 font-semibold"
>{new Date(periodo.dataInicio).toLocaleDateString('pt-BR')}</span
>
</div>
<div>
<span class="text-base-content/70">Fim:</span>
<span class="ml-1 font-semibold"
>{new Date(periodo.dataFim).toLocaleDateString('pt-BR')}</span
>
</div>
<div>
<span class="text-base-content/70">Dias:</span>
<span class="text-primary ml-1 font-bold">{periodo.diasCorridos}</span>
</div>
</div>
</div>
{/each}
</div>
</div>
<!-- Observações -->
{#if solicitacao.observacao}
<div class="mt-4">
<h3 class="mb-2 font-semibold">Observações</h3>
<div class="bg-base-200 rounded-lg p-3 text-sm">
{solicitacao.observacao}
</div>
</div>
{/if}
<!-- Histórico -->
{#if solicitacao.historicoAlteracoes && solicitacao.historicoAlteracoes.length > 0}
<div class="mt-4">
<h3 class="mb-2 font-semibold">Histórico</h3>
<div class="space-y-1">
{#each solicitacao.historicoAlteracoes as hist (hist.data)}
<div class="text-base-content/70 flex items-center gap-2 text-xs">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{formatarData(hist.data)}</span>
<span>-</span>
<span>{hist.acao}</span>
</div>
{/each}
</div>
</div>
{/if}
<!-- Ação: Voltar para Aguardando Aprovação -->
{#if solicitacao.status !== 'aguardando_aprovacao'}
<div class="divider mt-6"></div>
<div class="alert alert-info">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="h-6 w-6 shrink-0 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<div>
<h3 class="font-bold">Alterar Status</h3>
<div class="text-sm">
Ao voltar para "Aguardando Aprovação", a solicitação ficará disponível para aprovação ou
reprovação pelo gestor.
</div>
</div>
</div>
<div class="card-actions mt-4 justify-end">
<button
type="button"
class="btn btn-warning gap-2"
onclick={voltarParaAguardando}
disabled={processando}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Voltar para Aguardando Aprovação
</button>
</div>
{:else}
<div class="divider mt-6"></div>
<div class="alert">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-info h-6 w-6 shrink-0"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span>Esta solicitação já está aguardando aprovação.</span>
</div>
{/if}
<!-- Motivo Reprovação (se reprovado) -->
{#if solicitacao.status === 'reprovado' && solicitacao.motivoReprovacao}
<div class="alert alert-error mt-4">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div>
<div class="font-bold">Motivo da Reprovação:</div>
<div class="text-sm">{solicitacao.motivoReprovacao}</div>
</div>
</div>
{/if}
<!-- Erro -->
{#if erro}
<div class="alert alert-error mt-4">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{erro}</span>
</div>
{/if}
<!-- Botão Fechar -->
{#if onCancelar}
<div class="card-actions mt-4 justify-end">
<button type="button" class="btn" onclick={onCancelar} disabled={processando}>
Cancelar
</button>
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,372 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);
type Props = {
data: {
labels: string[];
datasets: Array<{
label: string;
data: number[];
backgroundColor?: string | string[];
borderColor?: string | string[];
borderWidth?: number;
}>;
};
title?: string;
height?: number;
stacked?: boolean;
};
let { data, title = '', height = 400, stacked = false }: Props = $props();
let canvas: HTMLCanvasElement;
let chart: Chart | null = null;
// Função para clarear cor
function lightenColor(color: string, percent: number): string {
const num = parseInt(color.replace('#', ''), 16);
const amt = Math.round(2.55 * percent);
const R = Math.min(255, (num >> 16) + amt);
const G = Math.min(255, ((num >> 8) & 0x00ff) + amt);
const B = Math.min(255, (num & 0x0000ff) + amt);
return `#${(0x1000000 + R * 0x10000 + G * 0x100 + B).toString(16).slice(1)}`;
}
// Função para escurecer cor
function darkenColor(color: string, percent: number): string {
const num = parseInt(color.replace('#', ''), 16);
const amt = Math.round(2.55 * percent);
const R = Math.max(0, (num >> 16) - amt);
const G = Math.max(0, ((num >> 8) & 0x00ff) - amt);
const B = Math.max(0, (num & 0x0000ff) - amt);
return `#${(0x1000000 + R * 0x10000 + G * 0x100 + B).toString(16).slice(1)}`;
}
// Criar gradientes 3D para cada cor
function create3DGradientColors(colors: string[]): string[] {
// Retornar cores com sombra 3D aplicada (usando cores mais claras e escuras)
return colors.map((color) => {
// Criar gradiente simulando 3D usando múltiplas cores
return color; // Por enquanto retornar cor original, gradiente será aplicado via plugin
});
}
onMount(() => {
if (canvas) {
const ctx = canvas.getContext('2d');
if (ctx) {
// Preparar dados com cores 3D
const processedData = {
labels: data.labels,
datasets: data.datasets.map((dataset) => {
// Processar cores de background
let backgroundColor: string[];
if (Array.isArray(dataset.backgroundColor)) {
backgroundColor = dataset.backgroundColor;
} else if (dataset.backgroundColor) {
backgroundColor = data.labels.map(() => dataset.backgroundColor as string);
} else {
backgroundColor = data.labels.map(() => '#3b82f6');
}
// Processar cores de borda
let borderColor: string[];
if (Array.isArray(dataset.borderColor)) {
borderColor = dataset.borderColor;
} else if (dataset.borderColor) {
borderColor = data.labels.map(() => dataset.borderColor as string);
} else {
borderColor = backgroundColor.map((color) => darkenColor(color, 15));
}
return {
...dataset,
backgroundColor,
borderColor,
borderWidth: dataset.borderWidth || 2,
borderRadius: {
topLeft: 10,
topRight: 10,
bottomLeft: 10,
bottomRight: 10
},
borderSkipped: false,
barThickness: 'flex',
maxBarThickness: 60
};
})
};
chart = new Chart(ctx, {
type: 'bar',
data: processedData,
options: {
indexAxis: 'x',
responsive: true,
maintainAspectRatio: false,
layout: {
padding: {
top: 15,
right: 15,
bottom: 15,
left: 15
}
},
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: '#374151', // Cinza escuro para melhor legibilidade
font: {
size: 13,
family: "'Inter', sans-serif",
weight: '600'
},
usePointStyle: false,
padding: 18,
boxWidth: 18,
boxHeight: 14,
generateLabels: function (chart: any) {
const datasets = chart.data.datasets;
return datasets.map((dataset: any, datasetIndex: number) => {
// Priorizar cor da legenda se disponível, senão usar a cor do background
let backgroundColor: string;
if (dataset.legendColor) {
// Se há uma cor específica para a legenda, usar ela
backgroundColor = dataset.legendColor;
} else if (Array.isArray(dataset.backgroundColor)) {
// Se todas as cores são iguais, usar a primeira
const firstColor = dataset.backgroundColor[0];
if (dataset.backgroundColor.every((c: string) => c === firstColor)) {
backgroundColor = firstColor;
} else {
// Para múltiplas cores diferentes, usar a primeira como representativa
backgroundColor = firstColor;
}
} else {
backgroundColor = dataset.backgroundColor || '#3b82f6';
}
// Cor da borda para a legenda
let borderColor: string;
if (Array.isArray(dataset.borderColor)) {
borderColor = dataset.borderColor[0] || backgroundColor;
} else {
borderColor = dataset.borderColor || backgroundColor;
}
return {
text: dataset.label || `Dataset ${datasetIndex + 1}`,
fillStyle: backgroundColor,
strokeStyle: borderColor,
lineWidth: dataset.borderWidth || 2,
hidden: !chart.isDatasetVisible(datasetIndex),
index: datasetIndex
};
});
}
}
},
title: {
display: !!title,
text: title,
color: '#1f2937',
font: {
size: 18,
weight: 'bold',
family: "'Inter', sans-serif"
},
padding: {
top: 10,
bottom: 25
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.9)',
titleColor: '#fff',
bodyColor: '#fff',
borderColor: '#3b82f6',
borderWidth: 2,
padding: 14,
cornerRadius: 10,
displayColors: true,
titleFont: {
size: 14,
weight: 'bold',
family: "'Inter', sans-serif"
},
bodyFont: {
size: 13,
family: "'Inter', sans-serif"
},
callbacks: {
label: function (context: any) {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null && context.parsed.y !== undefined) {
label += context.parsed.y.toLocaleString('pt-BR');
// Verificar se é número de solicitações ou dias
if (label.includes('Solicitações')) {
label += ' solicitação(ões)';
} else {
label += ' dia(s)';
}
}
return label;
}
}
}
},
scales: {
x: {
stacked: stacked,
grid: {
display: false
},
ticks: {
color: '#6b7280',
font: {
size: 12,
family: "'Inter', sans-serif",
weight: '500'
},
maxRotation: 45,
minRotation: 0
},
border: {
display: true,
color: '#e5e7eb',
width: 2
}
},
y: {
stacked: stacked,
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.06)',
lineWidth: 1,
drawBorder: false
},
ticks: {
color: '#6b7280',
font: {
size: 11,
family: "'Inter', sans-serif",
weight: '500'
},
callback: function (value: any) {
if (typeof value === 'number') {
return value.toLocaleString('pt-BR');
}
return value;
}
},
border: {
display: true,
color: '#e5e7eb',
width: 2
}
}
},
animation: {
duration: 1200,
easing: 'easeInOutQuart'
},
interaction: {
mode: 'index',
intersect: false
},
// Plugin customizado para aplicar gradiente 3D
onHover: (event: any, activeElements: any[]) => {
if (event.native) {
const target = event.native.target as HTMLElement;
if (activeElements.length > 0) {
target.style.cursor = 'pointer';
} else {
target.style.cursor = 'default';
}
}
}
},
plugins: [
{
id: 'gradient3D',
beforeDraw: (chart: any) => {
const ctx = chart.ctx;
const chartArea = chart.chartArea;
chart.data.datasets.forEach((dataset: any, datasetIndex: number) => {
const meta = chart.getDatasetMeta(datasetIndex);
if (!meta || !meta.data) return;
meta.data.forEach((bar: any, index: number) => {
if (!bar || bar.hidden) return;
const backgroundColor = Array.isArray(dataset.backgroundColor)
? dataset.backgroundColor[index]
: dataset.backgroundColor;
if (!backgroundColor || typeof backgroundColor !== 'string') return;
// Criar gradiente 3D para a barra
const gradient = ctx.createLinearGradient(
bar.x - bar.width / 2,
bar.y,
bar.x + bar.width / 2,
bar.base
);
// Aplicar gradiente com efeito 3D
const lightColor = lightenColor(backgroundColor, 25);
const darkColor = darkenColor(backgroundColor, 15);
gradient.addColorStop(0, lightColor);
gradient.addColorStop(0.3, backgroundColor);
gradient.addColorStop(0.7, backgroundColor);
gradient.addColorStop(1, darkColor);
// Redesenhar a barra com gradiente
ctx.save();
ctx.fillStyle = gradient;
ctx.fillRect(
bar.x - bar.width / 2,
bar.y,
bar.width,
bar.base - bar.y
);
ctx.restore();
});
});
}
}
]
} as any);
}
}
});
$effect(() => {
if (chart && data) {
// Atualizar dados do gráfico
chart.data = data;
chart.update('active');
}
});
onDestroy(() => {
if (chart) {
chart.destroy();
}
});
</script>
<div style="height: {height}px; position: relative;">
<canvas bind:this={canvas}></canvas>
</div>