Merge branch 'master' into feat-many-fixes
This commit is contained in:
@@ -1,414 +1,426 @@
|
||||
<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';
|
||||
import ErrorModal from './ErrorModal.svelte';
|
||||
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";
|
||||
import ErrorModal from "./ErrorModal.svelte";
|
||||
|
||||
type SolicitacaoAusencia = Doc<'solicitacoesAusencias'> & {
|
||||
funcionario?: Doc<'funcionarios'> | null;
|
||||
gestor?: Doc<'usuarios'> | null;
|
||||
time?: Doc<'times'> | null;
|
||||
};
|
||||
type SolicitacaoAusencia = Doc<"solicitacoesAusencias"> & {
|
||||
funcionario?: Doc<"funcionarios"> | null;
|
||||
gestor?: Doc<"usuarios"> | null;
|
||||
time?: Doc<"times"> | null;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
solicitacao: SolicitacaoAusencia;
|
||||
gestorId: Id<'usuarios'>;
|
||||
onSucesso?: () => void;
|
||||
onCancelar?: () => void;
|
||||
}
|
||||
interface Props {
|
||||
solicitacao: SolicitacaoAusencia;
|
||||
gestorId: Id<"usuarios">;
|
||||
onSucesso?: () => void;
|
||||
onCancelar?: () => void;
|
||||
}
|
||||
|
||||
let { solicitacao, gestorId, onSucesso, onCancelar }: Props = $props();
|
||||
let { solicitacao, gestorId, onSucesso, onCancelar }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
const client = useConvexClient();
|
||||
|
||||
let motivoReprovacao = $state('');
|
||||
let processando = $state(false);
|
||||
let erro = $state('');
|
||||
let mostrarModalErro = $state(false);
|
||||
let mensagemErroModal = $state('');
|
||||
let motivoReprovacao = $state("");
|
||||
let processando = $state(false);
|
||||
let erro = $state("");
|
||||
let mostrarModalErro = $state(false);
|
||||
let mensagemErroModal = $state("");
|
||||
|
||||
function calcularDias(dataInicio: string, dataFim: string): number {
|
||||
const inicio = new Date(dataInicio);
|
||||
const fim = new Date(dataFim);
|
||||
const diff = fim.getTime() - inicio.getTime();
|
||||
return Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
|
||||
}
|
||||
function calcularDias(dataInicio: string, dataFim: string): number {
|
||||
const inicio = new Date(dataInicio);
|
||||
const fim = new Date(dataFim);
|
||||
const diff = fim.getTime() - inicio.getTime();
|
||||
return Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
|
||||
}
|
||||
|
||||
const totalDias = $derived(calcularDias(solicitacao.dataInicio, solicitacao.dataFim));
|
||||
const totalDias = $derived(
|
||||
calcularDias(solicitacao.dataInicio, solicitacao.dataFim),
|
||||
);
|
||||
|
||||
async function aprovar() {
|
||||
try {
|
||||
processando = true;
|
||||
erro = '';
|
||||
mostrarModalErro = false;
|
||||
async function aprovar() {
|
||||
try {
|
||||
processando = true;
|
||||
erro = "";
|
||||
mostrarModalErro = false;
|
||||
|
||||
await client.mutation(api.ausencias.aprovar, {
|
||||
solicitacaoId: solicitacao._id,
|
||||
gestorId: gestorId
|
||||
});
|
||||
await client.mutation(api.ausencias.aprovar, {
|
||||
solicitacaoId: solicitacao._id,
|
||||
gestorId: gestorId,
|
||||
});
|
||||
|
||||
if (onSucesso) onSucesso();
|
||||
} catch (e) {
|
||||
const mensagemErro = e instanceof Error ? e.message : String(e);
|
||||
if (onSucesso) onSucesso();
|
||||
} catch (e) {
|
||||
const mensagemErro = e instanceof Error ? e.message : String(e);
|
||||
|
||||
// Verificar se é erro de permissão
|
||||
if (
|
||||
mensagemErro.includes('permissão') ||
|
||||
mensagemErro.includes('permission') ||
|
||||
mensagemErro.includes('Você não tem permissão')
|
||||
) {
|
||||
mensagemErroModal =
|
||||
'Você não tem permissão para aprovar esta solicitação de ausência. Apenas o gestor responsável pelo time do funcionário pode realizar esta ação.';
|
||||
mostrarModalErro = true;
|
||||
} else {
|
||||
erro = mensagemErro;
|
||||
}
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
// Verificar se é erro de permissão
|
||||
if (
|
||||
mensagemErro.includes("permissão") ||
|
||||
mensagemErro.includes("permission") ||
|
||||
mensagemErro.includes("Você não tem permissão")
|
||||
) {
|
||||
mensagemErroModal =
|
||||
"Você não tem permissão para aprovar esta solicitação de ausência. Apenas o gestor responsável pelo time do funcionário pode realizar esta ação.";
|
||||
mostrarModalErro = true;
|
||||
} else {
|
||||
erro = mensagemErro;
|
||||
}
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function reprovar() {
|
||||
if (!motivoReprovacao.trim()) {
|
||||
erro = 'Informe o motivo da reprovação';
|
||||
return;
|
||||
}
|
||||
async function reprovar() {
|
||||
if (!motivoReprovacao.trim()) {
|
||||
erro = "Informe o motivo da reprovação";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
processando = true;
|
||||
erro = '';
|
||||
mostrarModalErro = false;
|
||||
try {
|
||||
processando = true;
|
||||
erro = "";
|
||||
mostrarModalErro = false;
|
||||
|
||||
await client.mutation(api.ausencias.reprovar, {
|
||||
solicitacaoId: solicitacao._id,
|
||||
gestorId: gestorId,
|
||||
motivoReprovacao: motivoReprovacao.trim()
|
||||
});
|
||||
await client.mutation(api.ausencias.reprovar, {
|
||||
solicitacaoId: solicitacao._id,
|
||||
gestorId: gestorId,
|
||||
motivoReprovacao: motivoReprovacao.trim(),
|
||||
});
|
||||
|
||||
if (onSucesso) onSucesso();
|
||||
} catch (e) {
|
||||
const mensagemErro = e instanceof Error ? e.message : String(e);
|
||||
if (onSucesso) onSucesso();
|
||||
} catch (e) {
|
||||
const mensagemErro = e instanceof Error ? e.message : String(e);
|
||||
|
||||
// Verificar se é erro de permissão
|
||||
if (
|
||||
mensagemErro.includes('permissão') ||
|
||||
mensagemErro.includes('permission') ||
|
||||
mensagemErro.includes('Você não tem permissão')
|
||||
) {
|
||||
mensagemErroModal =
|
||||
'Você não tem permissão para reprovar esta solicitação de ausência. Apenas o gestor responsável pelo time do funcionário pode realizar esta ação.';
|
||||
mostrarModalErro = true;
|
||||
} else {
|
||||
erro = mensagemErro;
|
||||
}
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
// Verificar se é erro de permissão
|
||||
if (
|
||||
mensagemErro.includes("permissão") ||
|
||||
mensagemErro.includes("permission") ||
|
||||
mensagemErro.includes("Você não tem permissão")
|
||||
) {
|
||||
mensagemErroModal =
|
||||
"Você não tem permissão para reprovar esta solicitação de ausência. Apenas o gestor responsável pelo time do funcionário pode realizar esta ação.";
|
||||
mostrarModalErro = true;
|
||||
} else {
|
||||
erro = mensagemErro;
|
||||
}
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
|
||||
function fecharModalErro() {
|
||||
mostrarModalErro = false;
|
||||
mensagemErroModal = '';
|
||||
}
|
||||
function fecharModalErro() {
|
||||
mostrarModalErro = false;
|
||||
mensagemErroModal = "";
|
||||
}
|
||||
|
||||
function getStatusBadge(status: string) {
|
||||
const badges: Record<string, string> = {
|
||||
aguardando_aprovacao: 'badge-warning',
|
||||
aprovado: 'badge-success',
|
||||
reprovado: 'badge-error'
|
||||
};
|
||||
return badges[status] || 'badge-neutral';
|
||||
}
|
||||
function getStatusBadge(status: string) {
|
||||
const badges: Record<string, string> = {
|
||||
aguardando_aprovacao: "badge-warning",
|
||||
aprovado: "badge-success",
|
||||
reprovado: "badge-error",
|
||||
};
|
||||
return badges[status] || "badge-neutral";
|
||||
}
|
||||
|
||||
function getStatusTexto(status: string) {
|
||||
const textos: Record<string, string> = {
|
||||
aguardando_aprovacao: 'Aguardando Aprovação',
|
||||
aprovado: 'Aprovado',
|
||||
reprovado: 'Reprovado'
|
||||
};
|
||||
return textos[status] || status;
|
||||
}
|
||||
function getStatusTexto(status: string) {
|
||||
const textos: Record<string, string> = {
|
||||
aguardando_aprovacao: "Aguardando Aprovação",
|
||||
aprovado: "Aprovado",
|
||||
reprovado: "Reprovado",
|
||||
};
|
||||
return textos[status] || status;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="aprovar-ausencia">
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h2 class="text-primary mb-2 text-3xl font-bold">Aprovar/Reprovar Ausência</h2>
|
||||
<p class="text-base-content/70">Analise a solicitação e tome uma decisão</p>
|
||||
</div>
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h2 class="text-3xl font-bold text-primary mb-2">
|
||||
Aprovar/Reprovar Ausência
|
||||
</h2>
|
||||
<p class="text-base-content/70">Analise a solicitação e tome uma decisão</p>
|
||||
</div>
|
||||
|
||||
<!-- Card Principal -->
|
||||
<div class="card bg-base-100 border-t-4 border-orange-500 shadow-2xl">
|
||||
<div class="card-body">
|
||||
<!-- Informações do Funcionário -->
|
||||
<div class="mb-6">
|
||||
<h3 class="mb-4 flex items-center gap-2 text-xl font-bold">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-primary h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
Funcionário
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p class="text-base-content/70 text-sm">Nome</p>
|
||||
<p class="text-lg font-bold">
|
||||
{solicitacao.funcionario?.nome || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
{#if solicitacao.time}
|
||||
<div>
|
||||
<p class="text-base-content/70 text-sm">Time</p>
|
||||
<div
|
||||
class="badge badge-lg font-semibold"
|
||||
style="background-color: {solicitacao.time.cor}20; border-color: {solicitacao.time
|
||||
.cor}; color: {solicitacao.time.cor}"
|
||||
>
|
||||
{solicitacao.time.nome}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Card Principal -->
|
||||
<div class="card bg-base-100 shadow-2xl border-t-4 border-orange-500">
|
||||
<div class="card-body">
|
||||
<!-- Informações do Funcionário -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-xl font-bold mb-4 flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-primary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
Funcionário
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-sm text-base-content/70">Nome</p>
|
||||
<p class="font-bold text-lg">
|
||||
{solicitacao.funcionario?.nome || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
{#if solicitacao.time}
|
||||
<div>
|
||||
<p class="text-sm text-base-content/70">Time</p>
|
||||
<div
|
||||
class="badge badge-lg font-semibold"
|
||||
style="background-color: {solicitacao.time
|
||||
.cor}20; border-color: {solicitacao.time
|
||||
.cor}; color: {solicitacao.time.cor}"
|
||||
>
|
||||
{solicitacao.time.nome}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Período da Ausência -->
|
||||
<div class="mb-6">
|
||||
<h3 class="mb-4 flex items-center gap-2 text-xl font-bold">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-primary h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
Período da Ausência
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div
|
||||
class="stat rounded-xl border-2 border-orange-500/30 bg-linear-to-br from-orange-50 to-amber-50 dark:from-orange-950 dark:to-amber-950"
|
||||
>
|
||||
<div class="stat-title">Data Início</div>
|
||||
<div class="stat-value text-2xl text-orange-600 dark:text-orange-400">
|
||||
{new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="stat rounded-xl border-2 border-orange-500/30 bg-linear-to-br from-orange-50 to-amber-50 dark:from-orange-950 dark:to-amber-950"
|
||||
>
|
||||
<div class="stat-title">Data Fim</div>
|
||||
<div class="stat-value text-2xl text-orange-600 dark:text-orange-400">
|
||||
{new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="stat rounded-xl border-2 border-orange-500/30 bg-linear-to-br from-orange-50 to-amber-50 dark:from-orange-950 dark:to-amber-950"
|
||||
>
|
||||
<div class="stat-title">Total de Dias</div>
|
||||
<div class="stat-value text-3xl text-orange-600 dark:text-orange-400">
|
||||
{totalDias}
|
||||
</div>
|
||||
<div class="stat-desc">dias corridos</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Período da Ausência -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-xl font-bold mb-4 flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-primary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
Período da Ausência
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div
|
||||
class="stat bg-linear-to-br from-orange-50 to-amber-50 dark:from-orange-950 dark:to-amber-950 rounded-xl border-2 border-orange-500/30"
|
||||
>
|
||||
<div class="stat-title">Data Início</div>
|
||||
<div
|
||||
class="stat-value text-orange-600 dark:text-orange-400 text-2xl"
|
||||
>
|
||||
{new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="stat bg-linear-to-br from-orange-50 to-amber-50 dark:from-orange-950 dark:to-amber-950 rounded-xl border-2 border-orange-500/30"
|
||||
>
|
||||
<div class="stat-title">Data Fim</div>
|
||||
<div
|
||||
class="stat-value text-orange-600 dark:text-orange-400 text-2xl"
|
||||
>
|
||||
{new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="stat bg-linear-to-br from-orange-50 to-amber-50 dark:from-orange-950 dark:to-amber-950 rounded-xl border-2 border-orange-500/30"
|
||||
>
|
||||
<div class="stat-title">Total de Dias</div>
|
||||
<div
|
||||
class="stat-value text-orange-600 dark:text-orange-400 text-3xl"
|
||||
>
|
||||
{totalDias}
|
||||
</div>
|
||||
<div class="stat-desc">dias corridos</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Motivo -->
|
||||
<div class="mb-6">
|
||||
<h3 class="mb-4 flex items-center gap-2 text-xl font-bold">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-primary h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
Motivo da Ausência
|
||||
</h3>
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<p class="whitespace-pre-wrap">{solicitacao.motivo}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Motivo -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-xl font-bold mb-4 flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-primary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
Motivo da Ausência
|
||||
</h3>
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<p class="whitespace-pre-wrap">{solicitacao.motivo}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Atual -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-semibold">Status:</span>
|
||||
<div class={`badge badge-lg ${getStatusBadge(solicitacao.status)}`}>
|
||||
{getStatusTexto(solicitacao.status)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Status Atual -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-semibold">Status:</span>
|
||||
<div class={`badge badge-lg ${getStatusBadge(solicitacao.status)}`}>
|
||||
{getStatusTexto(solicitacao.status)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Erro -->
|
||||
{#if erro}
|
||||
<div class="alert alert-error mb-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}
|
||||
<!-- Erro -->
|
||||
{#if erro}
|
||||
<div class="alert alert-error mb-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="stroke-current shrink-0 h-6 w-6"
|
||||
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}
|
||||
|
||||
<!-- Ações -->
|
||||
{#if solicitacao.status === 'aguardando_aprovacao'}
|
||||
<div class="card-actions mt-6 justify-end gap-4">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error btn-lg gap-2"
|
||||
onclick={reprovar}
|
||||
disabled={processando}
|
||||
>
|
||||
{#if processando}
|
||||
<span class="loading loading-spinner"></span>
|
||||
{:else}
|
||||
<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="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
Reprovar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success btn-lg gap-2"
|
||||
onclick={aprovar}
|
||||
disabled={processando}
|
||||
>
|
||||
{#if processando}
|
||||
<span class="loading loading-spinner"></span>
|
||||
{:else}
|
||||
<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="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
Aprovar
|
||||
</button>
|
||||
</div>
|
||||
<!-- Ações -->
|
||||
{#if solicitacao.status === "aguardando_aprovacao"}
|
||||
<div class="card-actions justify-end gap-4 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error btn-lg gap-2"
|
||||
onclick={reprovar}
|
||||
disabled={processando}
|
||||
>
|
||||
{#if processando}
|
||||
<span class="loading loading-spinner"></span>
|
||||
{:else}
|
||||
<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="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
Reprovar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success btn-lg gap-2"
|
||||
onclick={aprovar}
|
||||
disabled={processando}
|
||||
>
|
||||
{#if processando}
|
||||
<span class="loading loading-spinner"></span>
|
||||
{:else}
|
||||
<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="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
Aprovar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal de Reprovação -->
|
||||
{#if motivoReprovacao !== undefined}
|
||||
<div class="mt-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="motivo-reprovacao">
|
||||
<span class="label-text font-bold">Motivo da Reprovação</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="motivo-reprovacao"
|
||||
class="textarea textarea-bordered h-24"
|
||||
placeholder="Informe o motivo da reprovação..."
|
||||
bind:value={motivoReprovacao}
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<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>
|
||||
<span>Esta solicitação já foi processada.</span>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Modal de Reprovação -->
|
||||
{#if motivoReprovacao !== undefined}
|
||||
<div class="mt-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="motivo-reprovacao">
|
||||
<span class="label-text font-bold">Motivo da Reprovação</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="motivo-reprovacao"
|
||||
class="textarea textarea-bordered h-24"
|
||||
placeholder="Informe o motivo da reprovação..."
|
||||
bind:value={motivoReprovacao}
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="alert alert-info">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="stroke-current shrink-0 w-6 h-6"
|
||||
>
|
||||
<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á foi processada.</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Botão Cancelar -->
|
||||
<div class="mt-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
onclick={() => {
|
||||
if (onCancelar) onCancelar();
|
||||
}}
|
||||
disabled={processando}
|
||||
>
|
||||
Fechar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Botão Cancelar -->
|
||||
<div class="mt-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost"
|
||||
onclick={() => {
|
||||
if (onCancelar) onCancelar();
|
||||
}}
|
||||
disabled={processando}
|
||||
>
|
||||
Fechar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de Erro -->
|
||||
<ErrorModal
|
||||
open={mostrarModalErro}
|
||||
title="Erro de Permissão"
|
||||
message={mensagemErroModal || 'Você não tem permissão para realizar esta ação.'}
|
||||
onClose={fecharModalErro}
|
||||
open={mostrarModalErro}
|
||||
title="Erro de Permissão"
|
||||
message={mensagemErroModal ||
|
||||
"Você não tem permissão para realizar esta ação."}
|
||||
onClose={fecharModalErro}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.aprovar-ausencia {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.aprovar-ausencia {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,462 +1,384 @@
|
||||
<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';
|
||||
|
||||
interface Periodo {
|
||||
dataInicio: string;
|
||||
dataFim: string;
|
||||
diasCorridos: number;
|
||||
}
|
||||
|
||||
type SolicitacaoFerias = Doc<'solicitacoesFerias'> & {
|
||||
funcionario?: Doc<'funcionarios'> | null;
|
||||
gestor?: Doc<'usuarios'> | null;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
solicitacao: SolicitacaoFerias;
|
||||
gestorId: Id<'usuarios'>;
|
||||
onSucesso?: () => void;
|
||||
onCancelar?: () => void;
|
||||
}
|
||||
|
||||
let { solicitacao, gestorId, onSucesso, onCancelar }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
let modoAjuste = $state(false);
|
||||
let periodos = $state<Periodo[]>([]);
|
||||
let motivoReprovacao = $state('');
|
||||
let processando = $state(false);
|
||||
let erro = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (modoAjuste && periodos.length === 0) {
|
||||
periodos = solicitacao.periodos.map((p) => ({ ...p }));
|
||||
}
|
||||
});
|
||||
|
||||
function calcularDias(periodo: Periodo) {
|
||||
if (!periodo.dataInicio || !periodo.dataFim) {
|
||||
periodo.diasCorridos = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const inicio = new Date(periodo.dataInicio);
|
||||
const fim = new Date(periodo.dataFim);
|
||||
|
||||
if (fim < inicio) {
|
||||
erro = 'Data final não pode ser anterior à data inicial';
|
||||
periodo.diasCorridos = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const diff = fim.getTime() - inicio.getTime();
|
||||
const dias = Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
|
||||
periodo.diasCorridos = dias;
|
||||
erro = '';
|
||||
}
|
||||
|
||||
async function aprovar() {
|
||||
try {
|
||||
processando = true;
|
||||
erro = '';
|
||||
|
||||
await client.mutation(api.ferias.aprovar, {
|
||||
solicitacaoId: solicitacao._id,
|
||||
gestorId: gestorId
|
||||
});
|
||||
|
||||
if (onSucesso) onSucesso();
|
||||
} catch (e) {
|
||||
erro = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function reprovar() {
|
||||
if (!motivoReprovacao.trim()) {
|
||||
erro = 'Informe o motivo da reprovação';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
processando = true;
|
||||
erro = '';
|
||||
|
||||
await client.mutation(api.ferias.reprovar, {
|
||||
solicitacaoId: solicitacao._id,
|
||||
gestorId: gestorId,
|
||||
motivoReprovacao
|
||||
});
|
||||
|
||||
if (onSucesso) onSucesso();
|
||||
} catch (e) {
|
||||
erro = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ajustarEAprovar() {
|
||||
try {
|
||||
processando = true;
|
||||
erro = '';
|
||||
|
||||
await client.mutation(api.ferias.ajustarEAprovar, {
|
||||
solicitacaoId: solicitacao._id,
|
||||
gestorId: gestorId,
|
||||
novosPeriodos: periodos
|
||||
});
|
||||
|
||||
if (onSucesso) onSucesso();
|
||||
} catch (e) {
|
||||
erro = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function formatarData(data: number) {
|
||||
return new Date(data).toLocaleString('pt-BR');
|
||||
}
|
||||
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";
|
||||
|
||||
interface Periodo {
|
||||
dataInicio: string;
|
||||
dataFim: string;
|
||||
diasCorridos: number;
|
||||
}
|
||||
|
||||
type SolicitacaoFerias = Doc<"solicitacoesFerias"> & {
|
||||
funcionario?: Doc<"funcionarios"> | null;
|
||||
gestor?: Doc<"usuarios"> | null;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
solicitacao: SolicitacaoFerias;
|
||||
gestorId: Id<"usuarios">;
|
||||
onSucesso?: () => void;
|
||||
onCancelar?: () => void;
|
||||
}
|
||||
|
||||
let { solicitacao, gestorId, onSucesso, onCancelar }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
let modoAjuste = $state(false);
|
||||
let periodos = $state<Periodo[]>([]);
|
||||
let motivoReprovacao = $state("");
|
||||
let processando = $state(false);
|
||||
let erro = $state("");
|
||||
|
||||
$effect(() => {
|
||||
if (modoAjuste && periodos.length === 0) {
|
||||
periodos = solicitacao.periodos.map((p) => ({...p}));
|
||||
}
|
||||
});
|
||||
|
||||
function calcularDias(periodo: Periodo) {
|
||||
if (!periodo.dataInicio || !periodo.dataFim) {
|
||||
periodo.diasCorridos = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const inicio = new Date(periodo.dataInicio);
|
||||
const fim = new Date(periodo.dataFim);
|
||||
|
||||
if (fim < inicio) {
|
||||
erro = "Data final não pode ser anterior à data inicial";
|
||||
periodo.diasCorridos = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const diff = fim.getTime() - inicio.getTime();
|
||||
const dias = Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
|
||||
periodo.diasCorridos = dias;
|
||||
erro = "";
|
||||
}
|
||||
|
||||
async function aprovar() {
|
||||
try {
|
||||
processando = true;
|
||||
erro = "";
|
||||
|
||||
await client.mutation(api.ferias.aprovar, {
|
||||
solicitacaoId: solicitacao._id,
|
||||
gestorId: gestorId,
|
||||
});
|
||||
|
||||
if (onSucesso) onSucesso();
|
||||
} catch (e) {
|
||||
erro = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function reprovar() {
|
||||
if (!motivoReprovacao.trim()) {
|
||||
erro = "Informe o motivo da reprovação";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
processando = true;
|
||||
erro = "";
|
||||
|
||||
await client.mutation(api.ferias.reprovar, {
|
||||
solicitacaoId: solicitacao._id,
|
||||
gestorId: gestorId,
|
||||
motivoReprovacao,
|
||||
});
|
||||
|
||||
if (onSucesso) onSucesso();
|
||||
} catch (e) {
|
||||
erro = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ajustarEAprovar() {
|
||||
try {
|
||||
processando = true;
|
||||
erro = "";
|
||||
|
||||
await client.mutation(api.ferias.ajustarEAprovar, {
|
||||
solicitacaoId: solicitacao._id,
|
||||
gestorId: gestorId,
|
||||
novosPeriodos: periodos,
|
||||
});
|
||||
|
||||
if (onSucesso) onSucesso();
|
||||
} catch (e) {
|
||||
erro = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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}
|
||||
<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}
|
||||
<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ções (apenas para status aguardando_aprovacao) -->
|
||||
{#if solicitacao.status === 'aguardando_aprovacao'}
|
||||
<div class="divider mt-6"></div>
|
||||
|
||||
{#if !modoAjuste}
|
||||
<!-- Modo Normal -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success gap-2"
|
||||
onclick={aprovar}
|
||||
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="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Aprovar
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-info gap-2"
|
||||
onclick={() => (modoAjuste = true)}
|
||||
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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
Ajustar Datas e Aprovar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Reprovar -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body p-4">
|
||||
<h4 class="mb-2 text-sm font-semibold">Reprovar Solicitação</h4>
|
||||
<textarea
|
||||
class="textarea textarea-bordered textarea-sm mb-2"
|
||||
placeholder="Motivo da reprovação..."
|
||||
bind:value={motivoReprovacao}
|
||||
rows="2"
|
||||
></textarea>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error btn-sm gap-2"
|
||||
onclick={reprovar}
|
||||
disabled={processando || !motivoReprovacao.trim()}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
Reprovar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Modo Ajuste -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="font-semibold">Ajustar Períodos</h4>
|
||||
{#each periodos as periodo, index}
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="mb-2 font-medium">Período {index + 1}</h5>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="form-control">
|
||||
<label class="label" for={`ajuste-inicio-${index}`}>
|
||||
<span class="label-text text-xs">Início</span>
|
||||
</label>
|
||||
<input
|
||||
id={`ajuste-inicio-${index}`}
|
||||
type="date"
|
||||
class="input input-bordered input-sm"
|
||||
bind:value={periodo.dataInicio}
|
||||
onchange={() => calcularDias(periodo)}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for={`ajuste-fim-${index}`}>
|
||||
<span class="label-text text-xs">Fim</span>
|
||||
</label>
|
||||
<input
|
||||
id={`ajuste-fim-${index}`}
|
||||
type="date"
|
||||
class="input input-bordered input-sm"
|
||||
bind:value={periodo.dataFim}
|
||||
onchange={() => calcularDias(periodo)}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for={`ajuste-dias-${index}`}>
|
||||
<span class="label-text text-xs">Dias</span>
|
||||
</label>
|
||||
<div
|
||||
id={`ajuste-dias-${index}`}
|
||||
class="bg-base-300 flex h-9 items-center rounded-lg px-3"
|
||||
role="textbox"
|
||||
aria-readonly="true"
|
||||
>
|
||||
<span class="font-bold">{periodo.diasCorridos}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm"
|
||||
onclick={() => (modoAjuste = false)}
|
||||
disabled={processando}
|
||||
>
|
||||
Cancelar Ajuste
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm gap-2"
|
||||
onclick={ajustarEAprovar}
|
||||
disabled={processando}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Confirmar e Aprovar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/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}>
|
||||
Fechar
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="card-title text-2xl">
|
||||
{solicitacao.funcionario?.nome || "Funcionário"}
|
||||
</h2>
|
||||
<p class="text-sm text-base-content/70 mt-1">
|
||||
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="font-semibold text-lg mb-3">Períodos Solicitados</h3>
|
||||
<div class="space-y-2">
|
||||
{#each solicitacao.periodos as periodo, index}
|
||||
<div class="flex items-center gap-4 p-3 bg-base-200 rounded-lg">
|
||||
<div class="badge badge-primary">{index + 1}</div>
|
||||
<div class="flex-1 grid grid-cols-3 gap-2 text-sm">
|
||||
<div>
|
||||
<span class="text-base-content/70">Início:</span>
|
||||
<span class="font-semibold ml-1">{new Date(periodo.dataInicio).toLocaleDateString("pt-BR")}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-base-content/70">Fim:</span>
|
||||
<span class="font-semibold ml-1">{new Date(periodo.dataFim).toLocaleDateString("pt-BR")}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-base-content/70">Dias:</span>
|
||||
<span class="font-bold ml-1 text-primary">{periodo.diasCorridos}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Observações -->
|
||||
{#if solicitacao.observacao}
|
||||
<div class="mt-4">
|
||||
<h3 class="font-semibold mb-2">Observações</h3>
|
||||
<div class="p-3 bg-base-200 rounded-lg text-sm">
|
||||
{solicitacao.observacao}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Histórico -->
|
||||
{#if solicitacao.historicoAlteracoes && solicitacao.historicoAlteracoes.length > 0}
|
||||
<div class="mt-4">
|
||||
<h3 class="font-semibold mb-2">Histórico</h3>
|
||||
<div class="space-y-1">
|
||||
{#each solicitacao.historicoAlteracoes as hist}
|
||||
<div class="text-xs text-base-content/70 flex items-center gap-2">
|
||||
<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ções (apenas para status aguardando_aprovacao) -->
|
||||
{#if solicitacao.status === "aguardando_aprovacao"}
|
||||
<div class="divider mt-6"></div>
|
||||
|
||||
{#if !modoAjuste}
|
||||
<!-- Modo Normal -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success gap-2"
|
||||
onclick={aprovar}
|
||||
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="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Aprovar
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-info gap-2"
|
||||
onclick={() => modoAjuste = true}
|
||||
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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
Ajustar Datas e Aprovar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Reprovar -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body p-4">
|
||||
<h4 class="font-semibold text-sm mb-2">Reprovar Solicitação</h4>
|
||||
<textarea
|
||||
class="textarea textarea-bordered textarea-sm mb-2"
|
||||
placeholder="Motivo da reprovação..."
|
||||
bind:value={motivoReprovacao}
|
||||
rows="2"
|
||||
></textarea>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error btn-sm gap-2"
|
||||
onclick={reprovar}
|
||||
disabled={processando || !motivoReprovacao.trim()}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Reprovar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Modo Ajuste -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="font-semibold">Ajustar Períodos</h4>
|
||||
{#each periodos as periodo, index}
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="font-medium mb-2">Período {index + 1}</h5>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="form-control">
|
||||
<label class="label" for={`ajuste-inicio-${index}`}>
|
||||
<span class="label-text text-xs">Início</span>
|
||||
</label>
|
||||
<input
|
||||
id={`ajuste-inicio-${index}`}
|
||||
type="date"
|
||||
class="input input-bordered input-sm"
|
||||
bind:value={periodo.dataInicio}
|
||||
onchange={() => calcularDias(periodo)}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for={`ajuste-fim-${index}`}>
|
||||
<span class="label-text text-xs">Fim</span>
|
||||
</label>
|
||||
<input
|
||||
id={`ajuste-fim-${index}`}
|
||||
type="date"
|
||||
class="input input-bordered input-sm"
|
||||
bind:value={periodo.dataFim}
|
||||
onchange={() => calcularDias(periodo)}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for={`ajuste-dias-${index}`}>
|
||||
<span class="label-text text-xs">Dias</span>
|
||||
</label>
|
||||
<div id={`ajuste-dias-${index}`} class="flex items-center h-9 px-3 bg-base-300 rounded-lg" role="textbox" aria-readonly="true">
|
||||
<span class="font-bold">{periodo.diasCorridos}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick={() => modoAjuste = false}
|
||||
disabled={processando}
|
||||
>
|
||||
Cancelar Ajuste
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm gap-2"
|
||||
onclick={ajustarEAprovar}
|
||||
disabled={processando}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Confirmar e Aprovar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/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="stroke-current shrink-0 h-6 w-6" 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="stroke-current shrink-0 h-6 w-6" 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 justify-end mt-4">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost"
|
||||
onclick={onCancelar}
|
||||
disabled={processando}
|
||||
>
|
||||
Fechar
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,73 +1,82 @@
|
||||
<script lang="ts">
|
||||
import { AlertCircle, X } from 'lucide-svelte';
|
||||
import { AlertCircle, X } from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
message: string;
|
||||
details?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
message: string;
|
||||
details?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
let {
|
||||
open = $bindable(false),
|
||||
title = "Erro",
|
||||
message,
|
||||
details,
|
||||
onClose,
|
||||
}: Props = $props();
|
||||
|
||||
let { open = $bindable(false), title = 'Erro', message, details, onClose }: Props = $props();
|
||||
let modalRef: HTMLDialogElement;
|
||||
|
||||
let modalRef: HTMLDialogElement;
|
||||
function handleClose() {
|
||||
open = false;
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
open = false;
|
||||
onClose();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open && modalRef) {
|
||||
modalRef.showModal();
|
||||
} else if (!open && modalRef) {
|
||||
modalRef.close();
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
if (open && modalRef) {
|
||||
modalRef.showModal();
|
||||
} else if (!open && modalRef) {
|
||||
modalRef.close();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<dialog
|
||||
bind:this={modalRef}
|
||||
class="modal"
|
||||
onclick={(e) => e.target === e.currentTarget && handleClose()}
|
||||
>
|
||||
<div class="modal-box max-w-2xl" onclick={(e) => e.stopPropagation()}>
|
||||
<!-- Header -->
|
||||
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4">
|
||||
<h2 id="modal-title" class="text-error flex items-center gap-2 text-xl font-bold">
|
||||
<AlertCircle class="h-5 w-5" strokeWidth={2} />
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle"
|
||||
onclick={handleClose}
|
||||
aria-label="Fechar"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<dialog
|
||||
bind:this={modalRef}
|
||||
class="modal"
|
||||
onclick={(e) => e.target === e.currentTarget && handleClose()}
|
||||
>
|
||||
<div class="modal-box max-w-2xl" onclick={(e) => e.stopPropagation()}>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-base-300">
|
||||
<h2 id="modal-title" class="text-xl font-bold flex items-center gap-2 text-error">
|
||||
<AlertCircle class="w-5 h-5" strokeWidth={2} />
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm btn-circle"
|
||||
onclick={handleClose}
|
||||
aria-label="Fechar"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="px-6 py-6">
|
||||
<p class="text-base-content mb-4">{message}</p>
|
||||
{#if details}
|
||||
<div class="bg-base-200 mb-4 rounded-lg p-4">
|
||||
<p class="text-base-content/70 text-sm whitespace-pre-line">{details}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div class="px-6 py-6">
|
||||
<p class="text-base-content mb-4">{message}</p>
|
||||
{#if details}
|
||||
<div class="bg-base-200 rounded-lg p-4 mb-4">
|
||||
<p class="text-sm text-base-content/70 whitespace-pre-line">{details}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="modal-action px-6 pb-6">
|
||||
<button class="btn btn-primary" onclick={handleClose}> Fechar </button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<div class="modal-action px-6 pb-6">
|
||||
<button class="btn btn-primary" onclick={handleClose}>
|
||||
Fechar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button type="button" onclick={handleClose}>fechar</button>
|
||||
</form>
|
||||
</dialog>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button type="button" onclick={handleClose}>fechar</button>
|
||||
</form>
|
||||
</dialog>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -1,187 +1,189 @@
|
||||
<script lang="ts">
|
||||
import { useQuery } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { useQuery } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
|
||||
interface Props {
|
||||
value?: string; // Id do funcionário selecionado
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
}
|
||||
interface Props {
|
||||
value?: string; // Id do funcionário selecionado
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(),
|
||||
placeholder = 'Selecione um funcionário',
|
||||
disabled = false,
|
||||
required = false
|
||||
}: Props = $props();
|
||||
let {
|
||||
value = $bindable(),
|
||||
placeholder = "Selecione um funcionário",
|
||||
disabled = false,
|
||||
required = false,
|
||||
}: Props = $props();
|
||||
|
||||
let busca = $state('');
|
||||
let mostrarDropdown = $state(false);
|
||||
let busca = $state("");
|
||||
let mostrarDropdown = $state(false);
|
||||
|
||||
// Buscar funcionários
|
||||
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
|
||||
// Buscar funcionários
|
||||
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
|
||||
|
||||
const funcionarios = $derived(funcionariosQuery?.data?.filter((f) => !f.desligamentoData) || []);
|
||||
const funcionarios = $derived(
|
||||
funcionariosQuery?.data?.filter((f) => !f.desligamentoData) || []
|
||||
);
|
||||
|
||||
// Filtrar funcionários baseado na busca
|
||||
const funcionariosFiltrados = $derived.by(() => {
|
||||
if (!busca.trim()) return funcionarios;
|
||||
// Filtrar funcionários baseado na busca
|
||||
const funcionariosFiltrados = $derived.by(() => {
|
||||
if (!busca.trim()) return funcionarios;
|
||||
|
||||
const termo = busca.toLowerCase().trim();
|
||||
return funcionarios.filter((f) => {
|
||||
const nomeMatch = f.nome?.toLowerCase().includes(termo);
|
||||
const matriculaMatch = f.matricula?.toLowerCase().includes(termo);
|
||||
const cpfMatch = f.cpf?.replace(/\D/g, '').includes(termo.replace(/\D/g, ''));
|
||||
return nomeMatch || matriculaMatch || cpfMatch;
|
||||
});
|
||||
});
|
||||
const termo = busca.toLowerCase().trim();
|
||||
return funcionarios.filter((f) => {
|
||||
const nomeMatch = f.nome?.toLowerCase().includes(termo);
|
||||
const matriculaMatch = f.matricula?.toLowerCase().includes(termo);
|
||||
const cpfMatch = f.cpf?.replace(/\D/g, "").includes(termo.replace(/\D/g, ""));
|
||||
return nomeMatch || matriculaMatch || cpfMatch;
|
||||
});
|
||||
});
|
||||
|
||||
// Funcionário selecionado
|
||||
const funcionarioSelecionado = $derived.by(() => {
|
||||
if (!value) return null;
|
||||
return funcionarios.find((f) => f._id === value);
|
||||
});
|
||||
// Funcionário selecionado
|
||||
const funcionarioSelecionado = $derived.by(() => {
|
||||
if (!value) return null;
|
||||
return funcionarios.find((f) => f._id === value);
|
||||
});
|
||||
|
||||
function selecionarFuncionario(funcionarioId: string) {
|
||||
value = funcionarioId;
|
||||
const funcionario = funcionarios.find((f) => f._id === funcionarioId);
|
||||
busca = funcionario?.nome || '';
|
||||
mostrarDropdown = false;
|
||||
}
|
||||
function selecionarFuncionario(funcionarioId: string) {
|
||||
value = funcionarioId;
|
||||
const funcionario = funcionarios.find((f) => f._id === funcionarioId);
|
||||
busca = funcionario?.nome || "";
|
||||
mostrarDropdown = false;
|
||||
}
|
||||
|
||||
function limpar() {
|
||||
value = undefined;
|
||||
busca = '';
|
||||
mostrarDropdown = false;
|
||||
}
|
||||
function limpar() {
|
||||
value = undefined;
|
||||
busca = "";
|
||||
mostrarDropdown = false;
|
||||
}
|
||||
|
||||
// Atualizar busca quando funcionário selecionado mudar externamente
|
||||
$effect(() => {
|
||||
if (value && !busca) {
|
||||
const funcionario = funcionarios.find((f) => f._id === value);
|
||||
busca = funcionario?.nome || '';
|
||||
}
|
||||
});
|
||||
// Atualizar busca quando funcionário selecionado mudar externamente
|
||||
$effect(() => {
|
||||
if (value && !busca) {
|
||||
const funcionario = funcionarios.find((f) => f._id === value);
|
||||
busca = funcionario?.nome || "";
|
||||
}
|
||||
});
|
||||
|
||||
function handleFocus() {
|
||||
if (!disabled) {
|
||||
mostrarDropdown = true;
|
||||
}
|
||||
}
|
||||
function handleFocus() {
|
||||
if (!disabled) {
|
||||
mostrarDropdown = true;
|
||||
}
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
// Delay para permitir click no dropdown
|
||||
setTimeout(() => {
|
||||
mostrarDropdown = false;
|
||||
}, 200);
|
||||
}
|
||||
function handleBlur() {
|
||||
// Delay para permitir click no dropdown
|
||||
setTimeout(() => {
|
||||
mostrarDropdown = false;
|
||||
}, 200);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="form-control relative w-full">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">
|
||||
Funcionário
|
||||
{#if required}
|
||||
<span class="text-error">*</span>
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
<div class="form-control w-full relative">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">
|
||||
Funcionário
|
||||
{#if required}
|
||||
<span class="text-error">*</span>
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={busca}
|
||||
{placeholder}
|
||||
{disabled}
|
||||
onfocus={handleFocus}
|
||||
onblur={handleBlur}
|
||||
class="input input-bordered w-full pr-10"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={busca}
|
||||
{placeholder}
|
||||
{disabled}
|
||||
onfocus={handleFocus}
|
||||
onblur={handleBlur}
|
||||
class="input input-bordered w-full pr-10"
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
{#if value}
|
||||
<button
|
||||
type="button"
|
||||
onclick={limpar}
|
||||
class="btn btn-xs btn-circle absolute top-1/2 right-2 -translate-y-1/2"
|
||||
{disabled}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-base-content/40 h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
{#if value}
|
||||
<button
|
||||
type="button"
|
||||
onclick={limpar}
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-circle"
|
||||
disabled={disabled}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-base-content/40"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if mostrarDropdown && funcionariosFiltrados.length > 0}
|
||||
<div
|
||||
class="bg-base-100 border-base-300 absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border shadow-lg"
|
||||
>
|
||||
{#each funcionariosFiltrados as funcionario}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selecionarFuncionario(funcionario._id)}
|
||||
class="hover:bg-base-200 border-base-200 w-full border-b px-4 py-3 text-left transition-colors last:border-b-0"
|
||||
>
|
||||
<div class="font-medium">{funcionario.nome}</div>
|
||||
<div class="text-base-content/60 text-sm">
|
||||
{#if funcionario.matricula}
|
||||
Matrícula: {funcionario.matricula}
|
||||
{/if}
|
||||
{#if funcionario.descricaoCargo}
|
||||
{funcionario.matricula ? ' • ' : ''}
|
||||
{funcionario.descricaoCargo}
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if mostrarDropdown && funcionariosFiltrados.length > 0}
|
||||
<div
|
||||
class="absolute z-50 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto"
|
||||
>
|
||||
{#each funcionariosFiltrados as funcionario}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selecionarFuncionario(funcionario._id)}
|
||||
class="w-full text-left px-4 py-3 hover:bg-base-200 transition-colors border-b border-base-200 last:border-b-0"
|
||||
>
|
||||
<div class="font-medium">{funcionario.nome}</div>
|
||||
<div class="text-sm text-base-content/60">
|
||||
{#if funcionario.matricula}
|
||||
Matrícula: {funcionario.matricula}
|
||||
{/if}
|
||||
{#if funcionario.descricaoCargo}
|
||||
{funcionario.matricula ? " • " : ""}
|
||||
{funcionario.descricaoCargo}
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if mostrarDropdown && busca && funcionariosFiltrados.length === 0}
|
||||
<div
|
||||
class="bg-base-100 border-base-300 text-base-content/60 absolute z-50 mt-1 w-full rounded-lg border p-4 text-center shadow-lg"
|
||||
>
|
||||
Nenhum funcionário encontrado
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if mostrarDropdown && busca && funcionariosFiltrados.length === 0}
|
||||
<div
|
||||
class="absolute z-50 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg p-4 text-center text-base-content/60"
|
||||
>
|
||||
Nenhum funcionário encontrado
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if funcionarioSelecionado}
|
||||
<div class="text-base-content/60 mt-1 text-xs">
|
||||
Selecionado: {funcionarioSelecionado.nome}
|
||||
{#if funcionarioSelecionado.matricula}
|
||||
- {funcionarioSelecionado.matricula}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if funcionarioSelecionado}
|
||||
<div class="text-xs text-base-content/60 mt-1">
|
||||
Selecionado: {funcionarioSelecionado.nome}
|
||||
{#if funcionarioSelecionado.matricula}
|
||||
- {funcionarioSelecionado.matricula}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
<script lang="ts">
|
||||
import jsPDF from 'jspdf';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
import { maskCPF, maskCEP, maskPhone } from '$lib/utils/masks';
|
||||
import {
|
||||
SEXO_OPTIONS,
|
||||
ESTADO_CIVIL_OPTIONS,
|
||||
GRAU_INSTRUCAO_OPTIONS,
|
||||
GRUPO_SANGUINEO_OPTIONS,
|
||||
FATOR_RH_OPTIONS,
|
||||
APOSENTADO_OPTIONS
|
||||
} from '$lib/utils/constants';
|
||||
import logoGovPE from '$lib/assets/logo_governo_PE.png';
|
||||
import { CheckCircle2, X, Printer } from 'lucide-svelte';
|
||||
import jsPDF from 'jspdf';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
import { maskCPF, maskCEP, maskPhone } from "$lib/utils/masks";
|
||||
import {
|
||||
SEXO_OPTIONS, ESTADO_CIVIL_OPTIONS, GRAU_INSTRUCAO_OPTIONS,
|
||||
GRUPO_SANGUINEO_OPTIONS, FATOR_RH_OPTIONS, APOSENTADO_OPTIONS
|
||||
} from "$lib/utils/constants";
|
||||
import logoGovPE from "$lib/assets/logo_governo_PE.png";
|
||||
import { CheckCircle2, X, Printer } from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
funcionario: any;
|
||||
onClose: () => void;
|
||||
}
|
||||
interface Props {
|
||||
funcionario: any;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { funcionario, onClose }: Props = $props();
|
||||
let { funcionario, onClose }: Props = $props();
|
||||
|
||||
let modalRef: HTMLDialogElement;
|
||||
let generating = $state(false);
|
||||
let modalRef: HTMLDialogElement;
|
||||
let generating = $state(false);
|
||||
|
||||
// Seções selecionáveis
|
||||
let sections = $state({
|
||||
@@ -61,98 +57,127 @@
|
||||
});
|
||||
}
|
||||
|
||||
function deselectAll() {
|
||||
Object.keys(sections).forEach((key) => {
|
||||
sections[key as keyof typeof sections] = false;
|
||||
});
|
||||
}
|
||||
function deselectAll() {
|
||||
Object.keys(sections).forEach(key => {
|
||||
sections[key as keyof typeof sections] = false;
|
||||
});
|
||||
}
|
||||
|
||||
async function gerarPDF() {
|
||||
try {
|
||||
generating = true;
|
||||
async function gerarPDF() {
|
||||
try {
|
||||
generating = true;
|
||||
|
||||
const doc = new jsPDF();
|
||||
const doc = new jsPDF();
|
||||
|
||||
// Logo no canto superior esquerdo (proporcional)
|
||||
let yPosition = 20;
|
||||
try {
|
||||
const logoImg = new Image();
|
||||
logoImg.src = logoGovPE;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
logoImg.onload = () => resolve();
|
||||
logoImg.onerror = () => reject();
|
||||
setTimeout(() => reject(), 3000); // timeout após 3s
|
||||
});
|
||||
// Logo no canto superior esquerdo (proporcional)
|
||||
let yPosition = 20;
|
||||
try {
|
||||
const logoImg = new Image();
|
||||
logoImg.src = logoGovPE;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
logoImg.onload = () => resolve();
|
||||
logoImg.onerror = () => reject();
|
||||
setTimeout(() => reject(), 3000); // timeout após 3s
|
||||
});
|
||||
|
||||
// Logo proporcional: largura 25mm, altura ajustada automaticamente
|
||||
const logoWidth = 25;
|
||||
const aspectRatio = logoImg.height / logoImg.width;
|
||||
const logoHeight = logoWidth * aspectRatio;
|
||||
|
||||
doc.addImage(logoImg, 'PNG', 15, 10, logoWidth, logoHeight);
|
||||
|
||||
// Ajustar posição inicial do texto para ficar ao lado da logo
|
||||
yPosition = Math.max(20, 10 + logoHeight / 2);
|
||||
} catch (err) {
|
||||
console.warn('Não foi possível carregar a logo:', err);
|
||||
}
|
||||
|
||||
// Logo proporcional: largura 25mm, altura ajustada automaticamente
|
||||
const logoWidth = 25;
|
||||
const aspectRatio = logoImg.height / logoImg.width;
|
||||
const logoHeight = logoWidth * aspectRatio;
|
||||
// Cabeçalho (alinhado com a logo)
|
||||
doc.setFontSize(16);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('Secretaria de Esportes', 50, yPosition);
|
||||
doc.setFontSize(12);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('Governo de Pernambuco', 50, yPosition + 7);
|
||||
|
||||
yPosition = Math.max(45, yPosition + 25);
|
||||
|
||||
// Título da ficha
|
||||
doc.setFontSize(18);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('FICHA CADASTRAL DE FUNCIONÁRIO', 105, yPosition, { align: 'center' });
|
||||
|
||||
yPosition += 8;
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(`Gerado em: ${new Date().toLocaleString('pt-BR')}`, 105, yPosition, { align: 'center' });
|
||||
|
||||
yPosition += 12;
|
||||
|
||||
doc.addImage(logoImg, 'PNG', 15, 10, logoWidth, logoHeight);
|
||||
// Dados Pessoais
|
||||
if (sections.dadosPessoais) {
|
||||
const dadosPessoais: any[] = [
|
||||
['Nome', funcionario.nome],
|
||||
['Matrícula', funcionario.matricula],
|
||||
['CPF', maskCPF(funcionario.cpf)],
|
||||
['RG', funcionario.rg],
|
||||
['Data Nascimento', funcionario.nascimento],
|
||||
];
|
||||
|
||||
// Ajustar posição inicial do texto para ficar ao lado da logo
|
||||
yPosition = Math.max(20, 10 + logoHeight / 2);
|
||||
} catch (err) {
|
||||
console.warn('Não foi possível carregar a logo:', err);
|
||||
}
|
||||
if (funcionario.rgOrgaoExpedidor) dadosPessoais.push(['Órgão Expedidor RG', funcionario.rgOrgaoExpedidor]);
|
||||
if (funcionario.rgDataEmissao) dadosPessoais.push(['Data Emissão RG', funcionario.rgDataEmissao]);
|
||||
if (funcionario.sexo) dadosPessoais.push(['Sexo', getLabelFromOptions(funcionario.sexo, SEXO_OPTIONS)]);
|
||||
if (funcionario.estadoCivil) dadosPessoais.push(['Estado Civil', getLabelFromOptions(funcionario.estadoCivil, ESTADO_CIVIL_OPTIONS)]);
|
||||
if (funcionario.nacionalidade) dadosPessoais.push(['Nacionalidade', funcionario.nacionalidade]);
|
||||
|
||||
// Cabeçalho (alinhado com a logo)
|
||||
doc.setFontSize(16);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('Secretaria de Esportes', 50, yPosition);
|
||||
doc.setFontSize(12);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('Governo de Pernambuco', 50, yPosition + 7);
|
||||
autoTable(doc, {
|
||||
startY: yPosition,
|
||||
head: [['DADOS PESSOAIS', '']],
|
||||
body: dadosPessoais,
|
||||
theme: 'grid',
|
||||
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
||||
styles: { fontSize: 9 },
|
||||
});
|
||||
|
||||
yPosition = Math.max(45, yPosition + 25);
|
||||
yPosition = (doc as any).lastAutoTable.finalY + 10;
|
||||
}
|
||||
|
||||
// Título da ficha
|
||||
doc.setFontSize(18);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('FICHA CADASTRAL DE FUNCIONÁRIO', 105, yPosition, { align: 'center' });
|
||||
// Filiação
|
||||
if (sections.filiacao && (funcionario.nomePai || funcionario.nomeMae)) {
|
||||
const filiacao: any[] = [];
|
||||
if (funcionario.nomePai) filiacao.push(['Nome do Pai', funcionario.nomePai]);
|
||||
if (funcionario.nomeMae) filiacao.push(['Nome da Mãe', funcionario.nomeMae]);
|
||||
|
||||
yPosition += 8;
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(`Gerado em: ${new Date().toLocaleString('pt-BR')}`, 105, yPosition, {
|
||||
align: 'center'
|
||||
});
|
||||
autoTable(doc, {
|
||||
startY: yPosition,
|
||||
head: [['FILIAÇÃO', '']],
|
||||
body: filiacao,
|
||||
theme: 'grid',
|
||||
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
||||
styles: { fontSize: 9 },
|
||||
});
|
||||
|
||||
yPosition += 12;
|
||||
yPosition = (doc as any).lastAutoTable.finalY + 10;
|
||||
}
|
||||
|
||||
// Dados Pessoais
|
||||
if (sections.dadosPessoais) {
|
||||
const dadosPessoais: any[] = [
|
||||
['Nome', funcionario.nome],
|
||||
['Matrícula', funcionario.matricula],
|
||||
['CPF', maskCPF(funcionario.cpf)],
|
||||
['RG', funcionario.rg],
|
||||
['Data Nascimento', funcionario.nascimento]
|
||||
];
|
||||
// Naturalidade
|
||||
if (sections.naturalidade && (funcionario.naturalidade || funcionario.naturalidadeUF)) {
|
||||
const naturalidade: any[] = [];
|
||||
if (funcionario.naturalidade) naturalidade.push(['Cidade', funcionario.naturalidade]);
|
||||
if (funcionario.naturalidadeUF) naturalidade.push(['UF', funcionario.naturalidadeUF]);
|
||||
|
||||
if (funcionario.rgOrgaoExpedidor)
|
||||
dadosPessoais.push(['Órgão Expedidor RG', funcionario.rgOrgaoExpedidor]);
|
||||
if (funcionario.rgDataEmissao)
|
||||
dadosPessoais.push(['Data Emissão RG', funcionario.rgDataEmissao]);
|
||||
if (funcionario.sexo)
|
||||
dadosPessoais.push(['Sexo', getLabelFromOptions(funcionario.sexo, SEXO_OPTIONS)]);
|
||||
if (funcionario.estadoCivil)
|
||||
dadosPessoais.push([
|
||||
'Estado Civil',
|
||||
getLabelFromOptions(funcionario.estadoCivil, ESTADO_CIVIL_OPTIONS)
|
||||
]);
|
||||
if (funcionario.nacionalidade)
|
||||
dadosPessoais.push(['Nacionalidade', funcionario.nacionalidade]);
|
||||
autoTable(doc, {
|
||||
startY: yPosition,
|
||||
head: [['NATURALIDADE', '']],
|
||||
body: naturalidade,
|
||||
theme: 'grid',
|
||||
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
||||
styles: { fontSize: 9 },
|
||||
});
|
||||
|
||||
autoTable(doc, {
|
||||
startY: yPosition,
|
||||
head: [['DADOS PESSOAIS', '']],
|
||||
body: dadosPessoais,
|
||||
theme: 'grid',
|
||||
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
||||
styles: { fontSize: 9 }
|
||||
});
|
||||
yPosition = (doc as any).lastAutoTable.finalY + 10;
|
||||
}
|
||||
|
||||
// Documentos
|
||||
if (sections.documentos) {
|
||||
@@ -175,124 +200,108 @@
|
||||
}
|
||||
if (funcionario.pisNumero) documentosData.push(['PIS/PASEP', funcionario.pisNumero]);
|
||||
|
||||
// Filiação
|
||||
if (sections.filiacao && (funcionario.nomePai || funcionario.nomeMae)) {
|
||||
const filiacao: any[] = [];
|
||||
if (funcionario.nomePai) filiacao.push(['Nome do Pai', funcionario.nomePai]);
|
||||
if (funcionario.nomeMae) filiacao.push(['Nome da Mãe', funcionario.nomeMae]);
|
||||
if (documentosData.length > 0) {
|
||||
autoTable(doc, {
|
||||
startY: yPosition,
|
||||
head: [['DOCUMENTOS', '']],
|
||||
body: documentosData,
|
||||
theme: 'grid',
|
||||
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
||||
styles: { fontSize: 9 },
|
||||
});
|
||||
|
||||
autoTable(doc, {
|
||||
startY: yPosition,
|
||||
head: [['FILIAÇÃO', '']],
|
||||
body: filiacao,
|
||||
theme: 'grid',
|
||||
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
||||
styles: { fontSize: 9 }
|
||||
});
|
||||
yPosition = (doc as any).lastAutoTable.finalY + 10;
|
||||
}
|
||||
}
|
||||
|
||||
yPosition = (doc as any).lastAutoTable.finalY + 10;
|
||||
}
|
||||
// Formação
|
||||
if (sections.formacao && (funcionario.grauInstrucao || funcionario.formacao)) {
|
||||
const formacaoData: any[] = [];
|
||||
if (funcionario.grauInstrucao) formacaoData.push(['Grau Instrução', getLabelFromOptions(funcionario.grauInstrucao, GRAU_INSTRUCAO_OPTIONS)]);
|
||||
if (funcionario.formacao) formacaoData.push(['Formação', funcionario.formacao]);
|
||||
if (funcionario.formacaoRegistro) formacaoData.push(['Registro Nº', funcionario.formacaoRegistro]);
|
||||
|
||||
// Naturalidade
|
||||
if (sections.naturalidade && (funcionario.naturalidade || funcionario.naturalidadeUF)) {
|
||||
const naturalidade: any[] = [];
|
||||
if (funcionario.naturalidade) naturalidade.push(['Cidade', funcionario.naturalidade]);
|
||||
if (funcionario.naturalidadeUF) naturalidade.push(['UF', funcionario.naturalidadeUF]);
|
||||
autoTable(doc, {
|
||||
startY: yPosition,
|
||||
head: [['FORMAÇÃO', '']],
|
||||
body: formacaoData,
|
||||
theme: 'grid',
|
||||
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
||||
styles: { fontSize: 9 },
|
||||
});
|
||||
|
||||
autoTable(doc, {
|
||||
startY: yPosition,
|
||||
head: [['NATURALIDADE', '']],
|
||||
body: naturalidade,
|
||||
theme: 'grid',
|
||||
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
||||
styles: { fontSize: 9 }
|
||||
});
|
||||
yPosition = (doc as any).lastAutoTable.finalY + 10;
|
||||
}
|
||||
|
||||
yPosition = (doc as any).lastAutoTable.finalY + 10;
|
||||
}
|
||||
// Saúde
|
||||
if (sections.saude && (funcionario.grupoSanguineo || funcionario.fatorRH)) {
|
||||
const saudeData: any[] = [];
|
||||
if (funcionario.grupoSanguineo) saudeData.push(['Grupo Sanguíneo', funcionario.grupoSanguineo]);
|
||||
if (funcionario.fatorRH) saudeData.push(['Fator RH', getLabelFromOptions(funcionario.fatorRH, FATOR_RH_OPTIONS)]);
|
||||
|
||||
// Documentos
|
||||
if (sections.documentos) {
|
||||
const documentosData: any[] = [];
|
||||
autoTable(doc, {
|
||||
startY: yPosition,
|
||||
head: [['SAÚDE', '']],
|
||||
body: saudeData,
|
||||
theme: 'grid',
|
||||
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
||||
styles: { fontSize: 9 },
|
||||
});
|
||||
|
||||
if (funcionario.carteiraProfissionalNumero) {
|
||||
documentosData.push([
|
||||
'Cart. Profissional',
|
||||
`Nº ${funcionario.carteiraProfissionalNumero}${funcionario.carteiraProfissionalSerie ? ' - Série: ' + funcionario.carteiraProfissionalSerie : ''}`
|
||||
]);
|
||||
}
|
||||
if (funcionario.reservistaNumero) {
|
||||
documentosData.push([
|
||||
'Reservista',
|
||||
`Nº ${funcionario.reservistaNumero}${funcionario.reservistaSerie ? ' - Série: ' + funcionario.reservistaSerie : ''}`
|
||||
]);
|
||||
}
|
||||
if (funcionario.tituloEleitorNumero) {
|
||||
let titulo = `Nº ${funcionario.tituloEleitorNumero}`;
|
||||
if (funcionario.tituloEleitorZona) titulo += ` - Zona: ${funcionario.tituloEleitorZona}`;
|
||||
if (funcionario.tituloEleitorSecao)
|
||||
titulo += ` - Seção: ${funcionario.tituloEleitorSecao}`;
|
||||
documentosData.push(['Título Eleitor', titulo]);
|
||||
}
|
||||
if (funcionario.pisNumero) documentosData.push(['PIS/PASEP', funcionario.pisNumero]);
|
||||
yPosition = (doc as any).lastAutoTable.finalY + 10;
|
||||
}
|
||||
|
||||
if (documentosData.length > 0) {
|
||||
autoTable(doc, {
|
||||
startY: yPosition,
|
||||
head: [['DOCUMENTOS', '']],
|
||||
body: documentosData,
|
||||
theme: 'grid',
|
||||
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
||||
styles: { fontSize: 9 }
|
||||
});
|
||||
// Endereço
|
||||
if (sections.endereco) {
|
||||
const enderecoData: any[] = [
|
||||
['Endereço', funcionario.endereco],
|
||||
['Cidade', funcionario.cidade],
|
||||
['UF', funcionario.uf],
|
||||
['CEP', maskCEP(funcionario.cep)],
|
||||
];
|
||||
|
||||
yPosition = (doc as any).lastAutoTable.finalY + 10;
|
||||
}
|
||||
}
|
||||
autoTable(doc, {
|
||||
startY: yPosition,
|
||||
head: [['ENDEREÇO', '']],
|
||||
body: enderecoData,
|
||||
theme: 'grid',
|
||||
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
||||
styles: { fontSize: 9 },
|
||||
});
|
||||
|
||||
// Formação
|
||||
if (sections.formacao && (funcionario.grauInstrucao || funcionario.formacao)) {
|
||||
const formacaoData: any[] = [];
|
||||
if (funcionario.grauInstrucao)
|
||||
formacaoData.push([
|
||||
'Grau Instrução',
|
||||
getLabelFromOptions(funcionario.grauInstrucao, GRAU_INSTRUCAO_OPTIONS)
|
||||
]);
|
||||
if (funcionario.formacao) formacaoData.push(['Formação', funcionario.formacao]);
|
||||
if (funcionario.formacaoRegistro)
|
||||
formacaoData.push(['Registro Nº', funcionario.formacaoRegistro]);
|
||||
yPosition = (doc as any).lastAutoTable.finalY + 10;
|
||||
}
|
||||
|
||||
autoTable(doc, {
|
||||
startY: yPosition,
|
||||
head: [['FORMAÇÃO', '']],
|
||||
body: formacaoData,
|
||||
theme: 'grid',
|
||||
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
||||
styles: { fontSize: 9 }
|
||||
});
|
||||
// Contato
|
||||
if (sections.contato) {
|
||||
const contatoData: any[] = [
|
||||
['E-mail', funcionario.email],
|
||||
['Telefone', maskPhone(funcionario.telefone)],
|
||||
];
|
||||
|
||||
yPosition = (doc as any).lastAutoTable.finalY + 10;
|
||||
}
|
||||
autoTable(doc, {
|
||||
startY: yPosition,
|
||||
head: [['CONTATO', '']],
|
||||
body: contatoData,
|
||||
theme: 'grid',
|
||||
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
||||
styles: { fontSize: 9 },
|
||||
});
|
||||
|
||||
// Saúde
|
||||
if (sections.saude && (funcionario.grupoSanguineo || funcionario.fatorRH)) {
|
||||
const saudeData: any[] = [];
|
||||
if (funcionario.grupoSanguineo)
|
||||
saudeData.push(['Grupo Sanguíneo', funcionario.grupoSanguineo]);
|
||||
if (funcionario.fatorRH)
|
||||
saudeData.push(['Fator RH', getLabelFromOptions(funcionario.fatorRH, FATOR_RH_OPTIONS)]);
|
||||
yPosition = (doc as any).lastAutoTable.finalY + 10;
|
||||
}
|
||||
|
||||
autoTable(doc, {
|
||||
startY: yPosition,
|
||||
head: [['SAÚDE', '']],
|
||||
body: saudeData,
|
||||
theme: 'grid',
|
||||
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
||||
styles: { fontSize: 9 }
|
||||
});
|
||||
// Nova página para cargo
|
||||
if (yPosition > 200) {
|
||||
doc.addPage();
|
||||
yPosition = 20;
|
||||
}
|
||||
|
||||
yPosition = (doc as any).lastAutoTable.finalY + 10;
|
||||
}
|
||||
// Cargo e Vínculo
|
||||
if (sections.cargo) {
|
||||
const cargoData: any[] = [
|
||||
['Tipo', funcionario.simboloTipo === 'cargo_comissionado' ? 'Cargo Comissionado' : 'Função Gratificada'],
|
||||
];
|
||||
|
||||
const simboloInfo = funcionario.simbolo ?? funcionario.simboloDetalhes ?? funcionario.simboloDados;
|
||||
if (simboloInfo) {
|
||||
@@ -311,17 +320,17 @@
|
||||
cargoData.push(['Aposentado', getLabelFromOptions(funcionario.aposentado, APOSENTADO_OPTIONS)]);
|
||||
}
|
||||
|
||||
autoTable(doc, {
|
||||
startY: yPosition,
|
||||
head: [['ENDEREÇO', '']],
|
||||
body: enderecoData,
|
||||
theme: 'grid',
|
||||
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
||||
styles: { fontSize: 9 }
|
||||
});
|
||||
autoTable(doc, {
|
||||
startY: yPosition,
|
||||
head: [['CARGO E VÍNCULO', '']],
|
||||
body: cargoData,
|
||||
theme: 'grid',
|
||||
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
||||
styles: { fontSize: 9 },
|
||||
});
|
||||
|
||||
yPosition = (doc as any).lastAutoTable.finalY + 10;
|
||||
}
|
||||
yPosition = (doc as any).lastAutoTable.finalY + 10;
|
||||
}
|
||||
|
||||
// Dados Financeiros
|
||||
if (sections.financeiro && funcionario.simbolo) {
|
||||
@@ -360,199 +369,111 @@
|
||||
];
|
||||
if (funcionario.contaBradescoAgencia) bancarioData.push(['Agência', funcionario.contaBradescoAgencia]);
|
||||
|
||||
autoTable(doc, {
|
||||
startY: yPosition,
|
||||
head: [['CONTATO', '']],
|
||||
body: contatoData,
|
||||
theme: 'grid',
|
||||
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
||||
styles: { fontSize: 9 }
|
||||
});
|
||||
autoTable(doc, {
|
||||
startY: yPosition,
|
||||
head: [['DADOS BANCÁRIOS - BRADESCO', '']],
|
||||
body: bancarioData,
|
||||
theme: 'grid',
|
||||
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
||||
styles: { fontSize: 9 },
|
||||
});
|
||||
|
||||
yPosition = (doc as any).lastAutoTable.finalY + 10;
|
||||
}
|
||||
yPosition = (doc as any).lastAutoTable.finalY + 10;
|
||||
}
|
||||
|
||||
// Nova página para cargo
|
||||
if (yPosition > 200) {
|
||||
doc.addPage();
|
||||
yPosition = 20;
|
||||
}
|
||||
// Adicionar rodapé em todas as páginas
|
||||
const pageCount = (doc as any).internal.getNumberOfPages();
|
||||
for (let i = 1; i <= pageCount; i++) {
|
||||
doc.setPage(i);
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(128, 128, 128);
|
||||
doc.text('SGSE - Sistema de Gerenciamento da Secretaria de Esportes', 105, 285, { align: 'center' });
|
||||
doc.text(`Página ${i} de ${pageCount}`, 195, 285, { align: 'right' });
|
||||
}
|
||||
|
||||
// Cargo e Vínculo
|
||||
if (sections.cargo) {
|
||||
const cargoData: any[] = [
|
||||
[
|
||||
'Tipo',
|
||||
funcionario.simboloTipo === 'cargo_comissionado'
|
||||
? 'Cargo Comissionado'
|
||||
: 'Função Gratificada'
|
||||
]
|
||||
];
|
||||
// Salvar PDF
|
||||
doc.save(`Ficha_${funcionario.nome.replace(/ /g, '_')}_${new Date().getTime()}.pdf`);
|
||||
|
||||
if (funcionario.simbolo) {
|
||||
cargoData.push(['Símbolo', funcionario.simbolo.nome]);
|
||||
}
|
||||
if (funcionario.descricaoCargo) cargoData.push(['Descrição', funcionario.descricaoCargo]);
|
||||
if (funcionario.admissaoData) cargoData.push(['Data Admissão', funcionario.admissaoData]);
|
||||
if (funcionario.nomeacaoPortaria)
|
||||
cargoData.push(['Portaria', funcionario.nomeacaoPortaria]);
|
||||
if (funcionario.nomeacaoData) cargoData.push(['Data Nomeação', funcionario.nomeacaoData]);
|
||||
if (funcionario.nomeacaoDOE) cargoData.push(['DOE', funcionario.nomeacaoDOE]);
|
||||
if (funcionario.pertenceOrgaoPublico) {
|
||||
cargoData.push(['Pertence Órgão Público', 'Sim']);
|
||||
if (funcionario.orgaoOrigem) cargoData.push(['Órgão Origem', funcionario.orgaoOrigem]);
|
||||
}
|
||||
if (funcionario.aposentado && funcionario.aposentado !== 'nao') {
|
||||
cargoData.push([
|
||||
'Aposentado',
|
||||
getLabelFromOptions(funcionario.aposentado, APOSENTADO_OPTIONS)
|
||||
]);
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Erro ao gerar PDF:', error);
|
||||
alert('Erro ao gerar PDF. Verifique o console para mais detalhes.');
|
||||
} finally {
|
||||
generating = false;
|
||||
}
|
||||
}
|
||||
|
||||
autoTable(doc, {
|
||||
startY: yPosition,
|
||||
head: [['CARGO E VÍNCULO', '']],
|
||||
body: cargoData,
|
||||
theme: 'grid',
|
||||
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
||||
styles: { fontSize: 9 }
|
||||
});
|
||||
|
||||
yPosition = (doc as any).lastAutoTable.finalY + 10;
|
||||
}
|
||||
|
||||
// Dados Bancários
|
||||
if (sections.bancario && funcionario.contaBradescoNumero) {
|
||||
const bancarioData: any[] = [
|
||||
[
|
||||
'Conta',
|
||||
`${funcionario.contaBradescoNumero}${funcionario.contaBradescoDV ? '-' + funcionario.contaBradescoDV : ''}`
|
||||
]
|
||||
];
|
||||
if (funcionario.contaBradescoAgencia)
|
||||
bancarioData.push(['Agência', funcionario.contaBradescoAgencia]);
|
||||
|
||||
autoTable(doc, {
|
||||
startY: yPosition,
|
||||
head: [['DADOS BANCÁRIOS - BRADESCO', '']],
|
||||
body: bancarioData,
|
||||
theme: 'grid',
|
||||
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
||||
styles: { fontSize: 9 }
|
||||
});
|
||||
|
||||
yPosition = (doc as any).lastAutoTable.finalY + 10;
|
||||
}
|
||||
|
||||
// Adicionar rodapé em todas as páginas
|
||||
const pageCount = (doc as any).internal.getNumberOfPages();
|
||||
for (let i = 1; i <= pageCount; i++) {
|
||||
doc.setPage(i);
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(128, 128, 128);
|
||||
doc.text('SGSE - Sistema de Gerenciamento da Secretaria de Esportes', 105, 285, {
|
||||
align: 'center'
|
||||
});
|
||||
doc.text(`Página ${i} de ${pageCount}`, 195, 285, { align: 'right' });
|
||||
}
|
||||
|
||||
// Salvar PDF
|
||||
doc.save(`Ficha_${funcionario.nome.replace(/ /g, '_')}_${new Date().getTime()}.pdf`);
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Erro ao gerar PDF:', error);
|
||||
alert('Erro ao gerar PDF. Verifique o console para mais detalhes.');
|
||||
} finally {
|
||||
generating = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (modalRef) {
|
||||
modalRef.showModal();
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
if (modalRef) {
|
||||
modalRef.showModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<dialog bind:this={modalRef} class="modal">
|
||||
<div class="modal-box max-w-4xl">
|
||||
<h3 class="mb-4 text-2xl font-bold">Imprimir Ficha Cadastral</h3>
|
||||
<p class="text-base-content/70 mb-6 text-sm">Selecione as seções que deseja incluir no PDF</p>
|
||||
<div class="modal-box max-w-4xl">
|
||||
<h3 class="font-bold text-2xl mb-4">Imprimir Ficha Cadastral</h3>
|
||||
<p class="text-sm text-base-content/70 mb-6">Selecione as seções que deseja incluir no PDF</p>
|
||||
|
||||
<!-- Botões de seleção -->
|
||||
<div class="mb-6 flex gap-2">
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick={selectAll}>
|
||||
<CheckCircle2 class="h-4 w-4" strokeWidth={2} />
|
||||
Selecionar Todos
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick={deselectAll}>
|
||||
<X class="h-4 w-4" strokeWidth={2} />
|
||||
Desmarcar Todos
|
||||
</button>
|
||||
</div>
|
||||
<!-- Botões de seleção -->
|
||||
<div class="flex gap-2 mb-6">
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick={selectAll}>
|
||||
<CheckCircle2 class="h-4 w-4" strokeWidth={2} />
|
||||
Selecionar Todos
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick={deselectAll}>
|
||||
<X class="h-4 w-4" strokeWidth={2} />
|
||||
Desmarcar Todos
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Grid de checkboxes -->
|
||||
<div
|
||||
class="bg-base-200 mb-6 grid max-h-96 grid-cols-2 gap-4 overflow-y-auto rounded-lg border p-2 md:grid-cols-3"
|
||||
>
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary"
|
||||
bind:checked={sections.dadosPessoais}
|
||||
/>
|
||||
<span class="label-text">Dados Pessoais</span>
|
||||
</label>
|
||||
<!-- Grid de checkboxes -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6 max-h-96 overflow-y-auto p-2 border rounded-lg bg-base-200">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.dadosPessoais} />
|
||||
<span class="label-text">Dados Pessoais</span>
|
||||
</label>
|
||||
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.filiacao} />
|
||||
<span class="label-text">Filiação</span>
|
||||
</label>
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.filiacao} />
|
||||
<span class="label-text">Filiação</span>
|
||||
</label>
|
||||
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary"
|
||||
bind:checked={sections.naturalidade}
|
||||
/>
|
||||
<span class="label-text">Naturalidade</span>
|
||||
</label>
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.naturalidade} />
|
||||
<span class="label-text">Naturalidade</span>
|
||||
</label>
|
||||
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary"
|
||||
bind:checked={sections.documentos}
|
||||
/>
|
||||
<span class="label-text">Documentos</span>
|
||||
</label>
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.documentos} />
|
||||
<span class="label-text">Documentos</span>
|
||||
</label>
|
||||
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.formacao} />
|
||||
<span class="label-text">Formação</span>
|
||||
</label>
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.formacao} />
|
||||
<span class="label-text">Formação</span>
|
||||
</label>
|
||||
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.saude} />
|
||||
<span class="label-text">Saúde</span>
|
||||
</label>
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.saude} />
|
||||
<span class="label-text">Saúde</span>
|
||||
</label>
|
||||
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.endereco} />
|
||||
<span class="label-text">Endereço</span>
|
||||
</label>
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.endereco} />
|
||||
<span class="label-text">Endereço</span>
|
||||
</label>
|
||||
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.contato} />
|
||||
<span class="label-text">Contato</span>
|
||||
</label>
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.contato} />
|
||||
<span class="label-text">Contato</span>
|
||||
</label>
|
||||
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.cargo} />
|
||||
<span class="label-text">Cargo e Vínculo</span>
|
||||
</label>
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.cargo} />
|
||||
<span class="label-text">Cargo e Vínculo</span>
|
||||
</label>
|
||||
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.financeiro} />
|
||||
@@ -565,22 +486,25 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick={onClose} disabled={generating}> Cancelar </button>
|
||||
<button type="button" class="btn btn-primary gap-2" onclick={gerarPDF} disabled={generating}>
|
||||
{#if generating}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Gerando PDF...
|
||||
{:else}
|
||||
<Printer class="h-5 w-5" strokeWidth={2} />
|
||||
Gerar PDF
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Ações -->
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn btn-ghost" onclick={onClose} disabled={generating}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary gap-2" onclick={gerarPDF} disabled={generating}>
|
||||
{#if generating}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Gerando PDF...
|
||||
{:else}
|
||||
<Printer class="h-5 w-5" strokeWidth={2} />
|
||||
Gerar PDF
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button type="button" onclick={onClose}>fechar</button>
|
||||
</form>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button type="button" onclick={onClose}>fechar</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,353 +1,304 @@
|
||||
<script lang="ts">
|
||||
import { useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
|
||||
interface Periodo {
|
||||
id: string;
|
||||
dataInicio: string;
|
||||
dataFim: string;
|
||||
diasCorridos: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
funcionarioId: string;
|
||||
onSucesso?: () => void;
|
||||
onCancelar?: () => void;
|
||||
}
|
||||
|
||||
let { funcionarioId, onSucesso, onCancelar }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
let anoReferencia = $state(new Date().getFullYear());
|
||||
let observacao = $state('');
|
||||
let periodos = $state<Periodo[]>([]);
|
||||
let processando = $state(false);
|
||||
let erro = $state('');
|
||||
|
||||
// Adicionar primeiro período ao carregar
|
||||
$effect(() => {
|
||||
if (periodos.length === 0) {
|
||||
adicionarPeriodo();
|
||||
}
|
||||
});
|
||||
|
||||
function adicionarPeriodo() {
|
||||
if (periodos.length >= 3) {
|
||||
erro = 'Máximo de 3 períodos permitidos';
|
||||
return;
|
||||
}
|
||||
|
||||
periodos.push({
|
||||
id: crypto.randomUUID(),
|
||||
dataInicio: '',
|
||||
dataFim: '',
|
||||
diasCorridos: 0
|
||||
});
|
||||
}
|
||||
|
||||
function removerPeriodo(id: string) {
|
||||
periodos = periodos.filter((p) => p.id !== id);
|
||||
}
|
||||
|
||||
function calcularDias(periodo: Periodo) {
|
||||
if (!periodo.dataInicio || !periodo.dataFim) {
|
||||
periodo.diasCorridos = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const inicio = new Date(periodo.dataInicio);
|
||||
const fim = new Date(periodo.dataFim);
|
||||
|
||||
if (fim < inicio) {
|
||||
erro = 'Data final não pode ser anterior à data inicial';
|
||||
periodo.diasCorridos = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const diff = fim.getTime() - inicio.getTime();
|
||||
const dias = Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
|
||||
periodo.diasCorridos = dias;
|
||||
erro = '';
|
||||
}
|
||||
|
||||
function validarPeriodos(): boolean {
|
||||
if (periodos.length === 0) {
|
||||
erro = 'Adicione pelo menos 1 período';
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const periodo of periodos) {
|
||||
if (!periodo.dataInicio || !periodo.dataFim) {
|
||||
erro = 'Preencha as datas de todos os períodos';
|
||||
return false;
|
||||
}
|
||||
|
||||
if (periodo.diasCorridos <= 0) {
|
||||
erro = 'Todos os períodos devem ter pelo menos 1 dia';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Verificar sobreposição de períodos
|
||||
for (let i = 0; i < periodos.length; i++) {
|
||||
for (let j = i + 1; j < periodos.length; j++) {
|
||||
const p1Inicio = new Date(periodos[i].dataInicio);
|
||||
const p1Fim = new Date(periodos[i].dataFim);
|
||||
const p2Inicio = new Date(periodos[j].dataInicio);
|
||||
const p2Fim = new Date(periodos[j].dataFim);
|
||||
|
||||
if (
|
||||
(p2Inicio >= p1Inicio && p2Inicio <= p1Fim) ||
|
||||
(p2Fim >= p1Inicio && p2Fim <= p1Fim) ||
|
||||
(p1Inicio >= p2Inicio && p1Inicio <= p2Fim)
|
||||
) {
|
||||
erro = 'Os períodos não podem se sobrepor';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function enviarSolicitacao() {
|
||||
if (!validarPeriodos()) return;
|
||||
|
||||
try {
|
||||
processando = true;
|
||||
erro = '';
|
||||
|
||||
await client.mutation(api.ferias.criarSolicitacao, {
|
||||
funcionarioId: funcionarioId as any,
|
||||
anoReferencia,
|
||||
periodos: periodos.map((p) => ({
|
||||
dataInicio: p.dataInicio,
|
||||
dataFim: p.dataFim,
|
||||
diasCorridos: p.diasCorridos
|
||||
})),
|
||||
observacao: observacao || undefined
|
||||
});
|
||||
|
||||
if (onSucesso) onSucesso();
|
||||
} catch (e: any) {
|
||||
erro = e.message || 'Erro ao enviar solicitação';
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
periodos.forEach((p) => calcularDias(p));
|
||||
});
|
||||
import { useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
|
||||
interface Periodo {
|
||||
id: string;
|
||||
dataInicio: string;
|
||||
dataFim: string;
|
||||
diasCorridos: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
funcionarioId: string;
|
||||
onSucesso?: () => void;
|
||||
onCancelar?: () => void;
|
||||
}
|
||||
|
||||
let { funcionarioId, onSucesso, onCancelar }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
let anoReferencia = $state(new Date().getFullYear());
|
||||
let observacao = $state("");
|
||||
let periodos = $state<Periodo[]>([]);
|
||||
let processando = $state(false);
|
||||
let erro = $state("");
|
||||
|
||||
// Adicionar primeiro período ao carregar
|
||||
$effect(() => {
|
||||
if (periodos.length === 0) {
|
||||
adicionarPeriodo();
|
||||
}
|
||||
});
|
||||
|
||||
function adicionarPeriodo() {
|
||||
if (periodos.length >= 3) {
|
||||
erro = "Máximo de 3 períodos permitidos";
|
||||
return;
|
||||
}
|
||||
|
||||
periodos.push({
|
||||
id: crypto.randomUUID(),
|
||||
dataInicio: "",
|
||||
dataFim: "",
|
||||
diasCorridos: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function removerPeriodo(id: string) {
|
||||
periodos = periodos.filter(p => p.id !== id);
|
||||
}
|
||||
|
||||
function calcularDias(periodo: Periodo) {
|
||||
if (!periodo.dataInicio || !periodo.dataFim) {
|
||||
periodo.diasCorridos = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const inicio = new Date(periodo.dataInicio);
|
||||
const fim = new Date(periodo.dataFim);
|
||||
|
||||
if (fim < inicio) {
|
||||
erro = "Data final não pode ser anterior à data inicial";
|
||||
periodo.diasCorridos = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const diff = fim.getTime() - inicio.getTime();
|
||||
const dias = Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
|
||||
periodo.diasCorridos = dias;
|
||||
erro = "";
|
||||
}
|
||||
|
||||
function validarPeriodos(): boolean {
|
||||
if (periodos.length === 0) {
|
||||
erro = "Adicione pelo menos 1 período";
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const periodo of periodos) {
|
||||
if (!periodo.dataInicio || !periodo.dataFim) {
|
||||
erro = "Preencha as datas de todos os períodos";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (periodo.diasCorridos <= 0) {
|
||||
erro = "Todos os períodos devem ter pelo menos 1 dia";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Verificar sobreposição de períodos
|
||||
for (let i = 0; i < periodos.length; i++) {
|
||||
for (let j = i + 1; j < periodos.length; j++) {
|
||||
const p1Inicio = new Date(periodos[i].dataInicio);
|
||||
const p1Fim = new Date(periodos[i].dataFim);
|
||||
const p2Inicio = new Date(periodos[j].dataInicio);
|
||||
const p2Fim = new Date(periodos[j].dataFim);
|
||||
|
||||
if (
|
||||
(p2Inicio >= p1Inicio && p2Inicio <= p1Fim) ||
|
||||
(p2Fim >= p1Inicio && p2Fim <= p1Fim) ||
|
||||
(p1Inicio >= p2Inicio && p1Inicio <= p2Fim)
|
||||
) {
|
||||
erro = "Os períodos não podem se sobrepor";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function enviarSolicitacao() {
|
||||
if (!validarPeriodos()) return;
|
||||
|
||||
try {
|
||||
processando = true;
|
||||
erro = "";
|
||||
|
||||
await client.mutation(api.ferias.criarSolicitacao, {
|
||||
funcionarioId: funcionarioId as any,
|
||||
anoReferencia,
|
||||
periodos: periodos.map(p => ({
|
||||
dataInicio: p.dataInicio,
|
||||
dataFim: p.dataFim,
|
||||
diasCorridos: p.diasCorridos,
|
||||
})),
|
||||
observacao: observacao || undefined,
|
||||
});
|
||||
|
||||
if (onSucesso) onSucesso();
|
||||
} catch (e: any) {
|
||||
erro = e.message || "Erro ao enviar solicitação";
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
periodos.forEach(p => calcularDias(p));
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4 text-2xl">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
Solicitar Férias
|
||||
</h2>
|
||||
|
||||
<!-- Ano de Referência -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="ano-referencia">
|
||||
<span class="label-text font-semibold">Ano de Referência</span>
|
||||
</label>
|
||||
<input
|
||||
id="ano-referencia"
|
||||
type="number"
|
||||
class="input input-bordered w-full max-w-xs"
|
||||
bind:value={anoReferencia}
|
||||
min={new Date().getFullYear()}
|
||||
max={new Date().getFullYear() + 2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Períodos -->
|
||||
<div class="mt-6">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold">Períodos ({periodos.length}/3)</h3>
|
||||
{#if periodos.length < 3}
|
||||
<button type="button" class="btn btn-sm btn-primary gap-2" onclick={adicionarPeriodo}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Adicionar Período
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#each periodos as periodo, index}
|
||||
<div class="card bg-base-200 border-base-300 border">
|
||||
<div class="card-body p-4">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h4 class="font-medium">Período {index + 1}</h4>
|
||||
{#if periodos.length > 1}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-error"
|
||||
aria-label="Remover período"
|
||||
onclick={() => removerPeriodo(periodo.id)}
|
||||
>
|
||||
<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="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div class="form-control">
|
||||
<label class="label" for={`inicio-${periodo.id}`}>
|
||||
<span class="label-text">Data Início</span>
|
||||
</label>
|
||||
<input
|
||||
id={`inicio-${periodo.id}`}
|
||||
type="date"
|
||||
class="input input-bordered input-sm"
|
||||
bind:value={periodo.dataInicio}
|
||||
onchange={() => calcularDias(periodo)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for={`fim-${periodo.id}`}>
|
||||
<span class="label-text">Data Fim</span>
|
||||
</label>
|
||||
<input
|
||||
id={`fim-${periodo.id}`}
|
||||
type="date"
|
||||
class="input input-bordered input-sm"
|
||||
bind:value={periodo.dataFim}
|
||||
onchange={() => calcularDias(periodo)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for={`dias-${periodo.id}`}>
|
||||
<span class="label-text">Dias Corridos</span>
|
||||
</label>
|
||||
<div
|
||||
id={`dias-${periodo.id}`}
|
||||
class="bg-base-300 flex h-9 items-center rounded-lg px-3"
|
||||
role="textbox"
|
||||
aria-readonly="true"
|
||||
>
|
||||
<span class="text-lg font-bold">{periodo.diasCorridos}</span>
|
||||
<span class="ml-1 text-sm">dias</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Observações -->
|
||||
<div class="form-control mt-6">
|
||||
<label class="label" for="observacao">
|
||||
<span class="label-text font-semibold">Observações (opcional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="observacao"
|
||||
class="textarea textarea-bordered h-24"
|
||||
placeholder="Adicione observações sobre sua solicitação..."
|
||||
bind:value={observacao}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 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}
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="card-actions mt-6 justify-end">
|
||||
{#if onCancelar}
|
||||
<button type="button" class="btn" onclick={onCancelar} disabled={processando}>
|
||||
Cancelar
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary gap-2"
|
||||
onclick={enviarSolicitacao}
|
||||
disabled={processando}
|
||||
>
|
||||
{#if processando}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Enviando...
|
||||
{:else}
|
||||
<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="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Enviar Solicitação
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-2xl mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Solicitar Férias
|
||||
</h2>
|
||||
|
||||
<!-- Ano de Referência -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="ano-referencia">
|
||||
<span class="label-text font-semibold">Ano de Referência</span>
|
||||
</label>
|
||||
<input
|
||||
id="ano-referencia"
|
||||
type="number"
|
||||
class="input input-bordered w-full max-w-xs"
|
||||
bind:value={anoReferencia}
|
||||
min={new Date().getFullYear()}
|
||||
max={new Date().getFullYear() + 2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Períodos -->
|
||||
<div class="mt-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="font-semibold text-lg">Períodos ({periodos.length}/3)</h3>
|
||||
{#if periodos.length < 3}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-primary gap-2"
|
||||
onclick={adicionarPeriodo}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Adicionar Período
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#each periodos as periodo, index}
|
||||
<div class="card bg-base-200 border border-base-300">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h4 class="font-medium">Período {index + 1}</h4>
|
||||
{#if periodos.length > 1}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-error btn-square"
|
||||
aria-label="Remover período"
|
||||
onclick={() => removerPeriodo(periodo.id)}
|
||||
>
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for={`inicio-${periodo.id}`}>
|
||||
<span class="label-text">Data Início</span>
|
||||
</label>
|
||||
<input
|
||||
id={`inicio-${periodo.id}`}
|
||||
type="date"
|
||||
class="input input-bordered input-sm"
|
||||
bind:value={periodo.dataInicio}
|
||||
onchange={() => calcularDias(periodo)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for={`fim-${periodo.id}`}>
|
||||
<span class="label-text">Data Fim</span>
|
||||
</label>
|
||||
<input
|
||||
id={`fim-${periodo.id}`}
|
||||
type="date"
|
||||
class="input input-bordered input-sm"
|
||||
bind:value={periodo.dataFim}
|
||||
onchange={() => calcularDias(periodo)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for={`dias-${periodo.id}`}>
|
||||
<span class="label-text">Dias Corridos</span>
|
||||
</label>
|
||||
<div id={`dias-${periodo.id}`} class="flex items-center h-9 px-3 bg-base-300 rounded-lg" role="textbox" aria-readonly="true">
|
||||
<span class="font-bold text-lg">{periodo.diasCorridos}</span>
|
||||
<span class="ml-1 text-sm">dias</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Observações -->
|
||||
<div class="form-control mt-6">
|
||||
<label class="label" for="observacao">
|
||||
<span class="label-text font-semibold">Observações (opcional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="observacao"
|
||||
class="textarea textarea-bordered h-24"
|
||||
placeholder="Adicione observações sobre sua solicitação..."
|
||||
bind:value={observacao}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Erro -->
|
||||
{#if erro}
|
||||
<div class="alert alert-error mt-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" 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}
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="card-actions justify-end mt-6">
|
||||
{#if onCancelar}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost"
|
||||
onclick={onCancelar}
|
||||
disabled={processando}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary gap-2"
|
||||
onclick={enviarSolicitacao}
|
||||
disabled={processando}
|
||||
>
|
||||
{#if processando}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Enviando...
|
||||
{:else}
|
||||
<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="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Enviar Solicitação
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,486 +1,503 @@
|
||||
<script lang="ts">
|
||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import CalendarioAusencias from './CalendarioAusencias.svelte';
|
||||
import ErrorModal from '../ErrorModal.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { useConvexClient, useQuery } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import CalendarioAusencias from "./CalendarioAusencias.svelte";
|
||||
import ErrorModal from "../ErrorModal.svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
|
||||
interface Props {
|
||||
funcionarioId: Id<'funcionarios'>;
|
||||
onSucesso?: () => void;
|
||||
onCancelar?: () => void;
|
||||
}
|
||||
interface Props {
|
||||
funcionarioId: Id<"funcionarios">;
|
||||
onSucesso?: () => void;
|
||||
onCancelar?: () => void;
|
||||
}
|
||||
|
||||
let { funcionarioId, onSucesso, onCancelar }: Props = $props();
|
||||
let { funcionarioId, onSucesso, onCancelar }: Props = $props();
|
||||
|
||||
// Cliente Convex
|
||||
const client = useConvexClient();
|
||||
// Cliente Convex
|
||||
const client = useConvexClient();
|
||||
|
||||
// Estado do wizard
|
||||
let passoAtual = $state(1);
|
||||
const totalPassos = 2;
|
||||
// Estado do wizard
|
||||
let passoAtual = $state(1);
|
||||
const totalPassos = 2;
|
||||
|
||||
// Dados da solicitação
|
||||
let dataInicio = $state<string>('');
|
||||
let dataFim = $state<string>('');
|
||||
let motivo = $state('');
|
||||
let processando = $state(false);
|
||||
// Dados da solicitação
|
||||
let dataInicio = $state<string>("");
|
||||
let dataFim = $state<string>("");
|
||||
let motivo = $state("");
|
||||
let processando = $state(false);
|
||||
|
||||
// Estados para modal de erro
|
||||
let mostrarModalErro = $state(false);
|
||||
let mensagemErroModal = $state('');
|
||||
let detalhesErroModal = $state('');
|
||||
// Estados para modal de erro
|
||||
let mostrarModalErro = $state(false);
|
||||
let mensagemErroModal = $state("");
|
||||
let detalhesErroModal = $state("");
|
||||
|
||||
// Buscar ausências existentes para exibir no calendário
|
||||
const ausenciasExistentesQuery = useQuery(api.ausencias.listarMinhasSolicitacoes, {
|
||||
funcionarioId
|
||||
});
|
||||
// Buscar ausências existentes para exibir no calendário
|
||||
const ausenciasExistentesQuery = useQuery(
|
||||
api.ausencias.listarMinhasSolicitacoes,
|
||||
{
|
||||
funcionarioId,
|
||||
},
|
||||
);
|
||||
|
||||
// Filtrar apenas ausências aprovadas ou aguardando aprovação (que bloqueiam novas solicitações)
|
||||
const ausenciasExistentes = $derived(
|
||||
(ausenciasExistentesQuery?.data || [])
|
||||
.filter((a) => a.status === 'aprovado' || a.status === 'aguardando_aprovacao')
|
||||
.map((a) => ({
|
||||
dataInicio: a.dataInicio,
|
||||
dataFim: a.dataFim,
|
||||
status: a.status as 'aguardando_aprovacao' | 'aprovado'
|
||||
}))
|
||||
);
|
||||
// Filtrar apenas ausências aprovadas ou aguardando aprovação (que bloqueiam novas solicitações)
|
||||
const ausenciasExistentes = $derived(
|
||||
(ausenciasExistentesQuery?.data || [])
|
||||
.filter(
|
||||
(a) => a.status === "aprovado" || a.status === "aguardando_aprovacao",
|
||||
)
|
||||
.map((a) => ({
|
||||
dataInicio: a.dataInicio,
|
||||
dataFim: a.dataFim,
|
||||
status: a.status as "aguardando_aprovacao" | "aprovado",
|
||||
})),
|
||||
);
|
||||
|
||||
// Calcular dias selecionados
|
||||
function calcularDias(inicio: string, fim: string): number {
|
||||
if (!inicio || !fim) return 0;
|
||||
const dInicio = new Date(inicio);
|
||||
const dFim = new Date(fim);
|
||||
const diffTime = Math.abs(dFim.getTime() - dInicio.getTime());
|
||||
return Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
||||
}
|
||||
// Calcular dias selecionados
|
||||
function calcularDias(inicio: string, fim: string): number {
|
||||
if (!inicio || !fim) return 0;
|
||||
const dInicio = new Date(inicio);
|
||||
const dFim = new Date(fim);
|
||||
const diffTime = Math.abs(dFim.getTime() - dInicio.getTime());
|
||||
return Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
||||
}
|
||||
|
||||
const totalDias = $derived(calcularDias(dataInicio, dataFim));
|
||||
const totalDias = $derived(calcularDias(dataInicio, dataFim));
|
||||
|
||||
// Funções de navegação
|
||||
function proximoPasso() {
|
||||
if (passoAtual === 1) {
|
||||
if (!dataInicio || !dataFim) {
|
||||
toast.error('Selecione o período de ausência no calendário');
|
||||
return;
|
||||
}
|
||||
// Funções de navegação
|
||||
function proximoPasso() {
|
||||
if (passoAtual === 1) {
|
||||
if (!dataInicio || !dataFim) {
|
||||
toast.error("Selecione o período de ausência no calendário");
|
||||
return;
|
||||
}
|
||||
|
||||
const hoje = new Date();
|
||||
hoje.setHours(0, 0, 0, 0);
|
||||
const inicio = new Date(dataInicio);
|
||||
const hoje = new Date();
|
||||
hoje.setHours(0, 0, 0, 0);
|
||||
const inicio = new Date(dataInicio);
|
||||
|
||||
if (inicio < hoje) {
|
||||
toast.error('A data de início não pode ser no passado');
|
||||
return;
|
||||
}
|
||||
if (inicio < hoje) {
|
||||
toast.error("A data de início não pode ser no passado");
|
||||
return;
|
||||
}
|
||||
|
||||
if (new Date(dataFim) < new Date(dataInicio)) {
|
||||
toast.error('A data de fim deve ser maior ou igual à data de início');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (new Date(dataFim) < new Date(dataInicio)) {
|
||||
toast.error("A data de fim deve ser maior ou igual à data de início");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (passoAtual < totalPassos) {
|
||||
passoAtual++;
|
||||
}
|
||||
}
|
||||
if (passoAtual < totalPassos) {
|
||||
passoAtual++;
|
||||
}
|
||||
}
|
||||
|
||||
function passoAnterior() {
|
||||
if (passoAtual > 1) {
|
||||
passoAtual--;
|
||||
}
|
||||
}
|
||||
function passoAnterior() {
|
||||
if (passoAtual > 1) {
|
||||
passoAtual--;
|
||||
}
|
||||
}
|
||||
|
||||
async function enviarSolicitacao() {
|
||||
if (!dataInicio || !dataFim) {
|
||||
toast.error('Selecione o período de ausência');
|
||||
return;
|
||||
}
|
||||
async function enviarSolicitacao() {
|
||||
if (!dataInicio || !dataFim) {
|
||||
toast.error("Selecione o período de ausência");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!motivo.trim() || motivo.trim().length < 10) {
|
||||
toast.error('O motivo deve ter no mínimo 10 caracteres');
|
||||
return;
|
||||
}
|
||||
if (!motivo.trim() || motivo.trim().length < 10) {
|
||||
toast.error("O motivo deve ter no mínimo 10 caracteres");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
processando = true;
|
||||
mostrarModalErro = false;
|
||||
mensagemErroModal = '';
|
||||
try {
|
||||
processando = true;
|
||||
mostrarModalErro = false;
|
||||
mensagemErroModal = "";
|
||||
|
||||
await client.mutation(api.ausencias.criarSolicitacao, {
|
||||
funcionarioId,
|
||||
dataInicio,
|
||||
dataFim,
|
||||
motivo: motivo.trim()
|
||||
});
|
||||
await client.mutation(api.ausencias.criarSolicitacao, {
|
||||
funcionarioId,
|
||||
dataInicio,
|
||||
dataFim,
|
||||
motivo: motivo.trim(),
|
||||
});
|
||||
|
||||
toast.success('Solicitação de ausência criada com sucesso!');
|
||||
toast.success("Solicitação de ausência criada com sucesso!");
|
||||
|
||||
if (onSucesso) {
|
||||
onSucesso();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao criar solicitação:', error);
|
||||
const mensagemErro = error instanceof Error ? error.message : String(error);
|
||||
if (onSucesso) {
|
||||
onSucesso();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao criar solicitação:", error);
|
||||
const mensagemErro =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Verificar se é erro de sobreposição de período
|
||||
if (
|
||||
mensagemErro.includes('Já existe uma solicitação') ||
|
||||
mensagemErro.includes('já existe') ||
|
||||
mensagemErro.includes('solicitação aprovada ou pendente')
|
||||
) {
|
||||
mensagemErroModal = 'Não é possível criar esta solicitação.';
|
||||
detalhesErroModal = `Já existe uma solicitação aprovada ou pendente para o período selecionado:\n\nPeríodo selecionado: ${new Date(dataInicio).toLocaleDateString('pt-BR')} até ${new Date(dataFim).toLocaleDateString('pt-BR')}\n\nPor favor, escolha um período diferente ou aguarde a resposta da solicitação existente.`;
|
||||
mostrarModalErro = true;
|
||||
} else {
|
||||
// Outros erros continuam usando toast
|
||||
toast.error(mensagemErro);
|
||||
}
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
// Verificar se é erro de sobreposição de período
|
||||
if (
|
||||
mensagemErro.includes("Já existe uma solicitação") ||
|
||||
mensagemErro.includes("já existe") ||
|
||||
mensagemErro.includes("solicitação aprovada ou pendente")
|
||||
) {
|
||||
mensagemErroModal = "Não é possível criar esta solicitação.";
|
||||
detalhesErroModal = `Já existe uma solicitação aprovada ou pendente para o período selecionado:\n\nPeríodo selecionado: ${new Date(dataInicio).toLocaleDateString("pt-BR")} até ${new Date(dataFim).toLocaleDateString("pt-BR")}\n\nPor favor, escolha um período diferente ou aguarde a resposta da solicitação existente.`;
|
||||
mostrarModalErro = true;
|
||||
} else {
|
||||
// Outros erros continuam usando toast
|
||||
toast.error(mensagemErro);
|
||||
}
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
|
||||
function fecharModalErro() {
|
||||
mostrarModalErro = false;
|
||||
mensagemErroModal = '';
|
||||
detalhesErroModal = '';
|
||||
}
|
||||
function fecharModalErro() {
|
||||
mostrarModalErro = false;
|
||||
mensagemErroModal = "";
|
||||
detalhesErroModal = "";
|
||||
}
|
||||
|
||||
function handlePeriodoSelecionado(periodo: { dataInicio: string; dataFim: string }) {
|
||||
dataInicio = periodo.dataInicio;
|
||||
dataFim = periodo.dataFim;
|
||||
}
|
||||
function handlePeriodoSelecionado(periodo: {
|
||||
dataInicio: string;
|
||||
dataFim: string;
|
||||
}) {
|
||||
dataInicio = periodo.dataInicio;
|
||||
dataFim = periodo.dataFim;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="wizard-ausencia">
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<p class="text-base-content/70">Solicite uma ausência para assuntos particulares</p>
|
||||
</div>
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<p class="text-base-content/70">
|
||||
Solicite uma ausência para assuntos particulares
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Indicador de progresso -->
|
||||
<div class="steps mb-8">
|
||||
<div class="step {passoAtual >= 1 ? 'step-primary' : ''}">
|
||||
<div class="step-item">
|
||||
<div class="step-marker">
|
||||
{#if passoAtual > 1}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
{passoAtual}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">Selecionar Período</div>
|
||||
<div class="step-description">Escolha as datas no calendário</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step {passoAtual >= 2 ? 'step-primary' : ''}">
|
||||
<div class="step-item">
|
||||
<div class="step-marker">
|
||||
{#if passoAtual > 2}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
2
|
||||
{/if}
|
||||
</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">Informar Motivo</div>
|
||||
<div class="step-description">Descreva o motivo da ausência</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Indicador de progresso -->
|
||||
<div class="steps mb-8">
|
||||
<div class="step {passoAtual >= 1 ? 'step-primary' : ''}">
|
||||
<div class="step-item">
|
||||
<div class="step-marker">
|
||||
{#if passoAtual > 1}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
{passoAtual}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">Selecionar Período</div>
|
||||
<div class="step-description">Escolha as datas no calendário</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step {passoAtual >= 2 ? 'step-primary' : ''}">
|
||||
<div class="step-item">
|
||||
<div class="step-marker">
|
||||
{#if passoAtual > 2}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
2
|
||||
{/if}
|
||||
</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">Informar Motivo</div>
|
||||
<div class="step-description">Descreva o motivo da ausência</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo dos passos -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
{#if passoAtual === 1}
|
||||
<!-- Passo 1: Selecionar Período -->
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h3 class="mb-2 text-2xl font-bold">Selecione o Período</h3>
|
||||
<p class="text-base-content/70">
|
||||
Clique e arraste no calendário para selecionar o período de ausência
|
||||
</p>
|
||||
</div>
|
||||
<!-- Conteúdo dos passos -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
{#if passoAtual === 1}
|
||||
<!-- Passo 1: Selecionar Período -->
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-2xl font-bold mb-2">Selecione o Período</h3>
|
||||
<p class="text-base-content/70">
|
||||
Clique e arraste no calendário para selecionar o período de
|
||||
ausência
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if ausenciasExistentesQuery === undefined}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
<span class="text-base-content/70 ml-4">Carregando ausências existentes...</span>
|
||||
</div>
|
||||
{:else}
|
||||
<CalendarioAusencias
|
||||
{dataInicio}
|
||||
{dataFim}
|
||||
{ausenciasExistentes}
|
||||
onPeriodoSelecionado={handlePeriodoSelecionado}
|
||||
/>
|
||||
{/if}
|
||||
{#if ausenciasExistentesQuery === undefined}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
<span class="ml-4 text-base-content/70"
|
||||
>Carregando ausências existentes...</span
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<CalendarioAusencias
|
||||
{dataInicio}
|
||||
{dataFim}
|
||||
{ausenciasExistentes}
|
||||
onPeriodoSelecionado={handlePeriodoSelecionado}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if dataInicio && dataFim}
|
||||
<div class="alert alert-success shadow-lg">
|
||||
<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<h4 class="font-bold">Período selecionado!</h4>
|
||||
<p>
|
||||
De {new Date(dataInicio).toLocaleDateString('pt-BR')} até{' '}
|
||||
{new Date(dataFim).toLocaleDateString('pt-BR')} ({totalDias} dias)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if passoAtual === 2}
|
||||
<!-- Passo 2: Informar Motivo -->
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h3 class="mb-2 text-2xl font-bold">Informe o Motivo</h3>
|
||||
<p class="text-base-content/70">
|
||||
Descreva o motivo da sua solicitação de ausência (mínimo 10 caracteres)
|
||||
</p>
|
||||
</div>
|
||||
{#if dataInicio && dataFim}
|
||||
<div class="alert alert-success shadow-lg">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="stroke-current shrink-0 h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<h4 class="font-bold">Período selecionado!</h4>
|
||||
<p>
|
||||
De {new Date(dataInicio).toLocaleDateString("pt-BR")} até{" "}
|
||||
{new Date(dataFim).toLocaleDateString("pt-BR")} ({totalDias} dias)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if passoAtual === 2}
|
||||
<!-- Passo 2: Informar Motivo -->
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-2xl font-bold mb-2">Informe o Motivo</h3>
|
||||
<p class="text-base-content/70">
|
||||
Descreva o motivo da sua solicitação de ausência (mínimo 10
|
||||
caracteres)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Resumo do período -->
|
||||
{#if dataInicio && dataFim}
|
||||
<div
|
||||
class="card border-2 border-orange-500/30 bg-linear-to-br from-orange-50 to-amber-50 dark:from-orange-950 dark:to-amber-950"
|
||||
>
|
||||
<div class="card-body">
|
||||
<h4 class="card-title text-orange-700 dark:text-orange-400">
|
||||
<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="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
Resumo do Período
|
||||
</h4>
|
||||
<div class="mt-2 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<p class="text-base-content/70 text-sm">Data Início</p>
|
||||
<p class="font-bold">
|
||||
{new Date(dataInicio).toLocaleDateString('pt-BR')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-base-content/70 text-sm">Data Fim</p>
|
||||
<p class="font-bold">
|
||||
{new Date(dataFim).toLocaleDateString('pt-BR')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-base-content/70 text-sm">Total de Dias</p>
|
||||
<p class="text-xl font-bold text-orange-600 dark:text-orange-400">
|
||||
{totalDias} dias
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Resumo do período -->
|
||||
{#if dataInicio && dataFim}
|
||||
<div
|
||||
class="card bg-linear-to-br from-orange-50 to-amber-50 dark:from-orange-950 dark:to-amber-950 border-2 border-orange-500/30"
|
||||
>
|
||||
<div class="card-body">
|
||||
<h4 class="card-title text-orange-700 dark:text-orange-400">
|
||||
<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="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
Resumo do Período
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-2">
|
||||
<div>
|
||||
<p class="text-sm text-base-content/70">Data Início</p>
|
||||
<p class="font-bold">
|
||||
{new Date(dataInicio).toLocaleDateString("pt-BR")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-base-content/70">Data Fim</p>
|
||||
<p class="font-bold">
|
||||
{new Date(dataFim).toLocaleDateString("pt-BR")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-base-content/70">Total de Dias</p>
|
||||
<p
|
||||
class="font-bold text-xl text-orange-600 dark:text-orange-400"
|
||||
>
|
||||
{totalDias} dias
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Campo de motivo -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="motivo">
|
||||
<span class="label-text font-bold">Motivo da Ausência</span>
|
||||
<span class="label-text-alt">
|
||||
{motivo.trim().length}/10 caracteres mínimos
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="motivo"
|
||||
class="textarea textarea-bordered h-32 text-lg"
|
||||
placeholder="Descreva o motivo da sua solicitação de ausência..."
|
||||
bind:value={motivo}
|
||||
maxlength={500}
|
||||
></textarea>
|
||||
<label class="label">
|
||||
<span class="label-text-alt text-base-content/70">
|
||||
Mínimo 10 caracteres. Seja claro e objetivo.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<!-- Campo de motivo -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="motivo">
|
||||
<span class="label-text font-bold">Motivo da Ausência</span>
|
||||
<span class="label-text-alt">
|
||||
{motivo.trim().length}/10 caracteres mínimos
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="motivo"
|
||||
class="textarea textarea-bordered h-32 text-lg"
|
||||
placeholder="Descreva o motivo da sua solicitação de ausência..."
|
||||
bind:value={motivo}
|
||||
maxlength={500}
|
||||
></textarea>
|
||||
<label class="label">
|
||||
<span class="label-text-alt text-base-content/70">
|
||||
Mínimo 10 caracteres. Seja claro e objetivo.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if motivo.trim().length > 0 && motivo.trim().length < 10}
|
||||
<div class="alert alert-warning shadow-lg">
|
||||
<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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<span>O motivo deve ter no mínimo 10 caracteres</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if motivo.trim().length > 0 && motivo.trim().length < 10}
|
||||
<div class="alert alert-warning shadow-lg">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="stroke-current shrink-0 h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<span>O motivo deve ter no mínimo 10 caracteres</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Botões de navegação -->
|
||||
<div class="card-actions mt-6 justify-between">
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
onclick={passoAnterior}
|
||||
disabled={passoAtual === 1 || processando}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-2 h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Voltar
|
||||
</button>
|
||||
<!-- Botões de navegação -->
|
||||
<div class="card-actions justify-between mt-6">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost"
|
||||
onclick={passoAnterior}
|
||||
disabled={passoAtual === 1 || processando}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 mr-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Voltar
|
||||
</button>
|
||||
|
||||
{#if passoAtual < totalPassos}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onclick={proximoPasso}
|
||||
disabled={processando}
|
||||
>
|
||||
Próximo
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="ml-2 h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success"
|
||||
onclick={enviarSolicitacao}
|
||||
disabled={processando || motivo.trim().length < 10}
|
||||
>
|
||||
{#if processando}
|
||||
<span class="loading loading-spinner"></span>
|
||||
Enviando...
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-2 h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Enviar Solicitação
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if passoAtual < totalPassos}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onclick={proximoPasso}
|
||||
disabled={processando}
|
||||
>
|
||||
Próximo
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 ml-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success"
|
||||
onclick={enviarSolicitacao}
|
||||
disabled={processando || motivo.trim().length < 10}
|
||||
>
|
||||
{#if processando}
|
||||
<span class="loading loading-spinner"></span>
|
||||
Enviando...
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 mr-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Enviar Solicitação
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Botão cancelar -->
|
||||
<div class="mt-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm"
|
||||
onclick={() => {
|
||||
if (onCancelar) onCancelar();
|
||||
}}
|
||||
disabled={processando}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Botão cancelar -->
|
||||
<div class="mt-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick={() => {
|
||||
if (onCancelar) onCancelar();
|
||||
}}
|
||||
disabled={processando}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de Erro -->
|
||||
<ErrorModal
|
||||
open={mostrarModalErro}
|
||||
title="Período Indisponível"
|
||||
message={mensagemErroModal || 'Já existe uma solicitação para este período.'}
|
||||
details={detalhesErroModal}
|
||||
onClose={fecharModalErro}
|
||||
open={mostrarModalErro}
|
||||
title="Período Indisponível"
|
||||
message={mensagemErroModal || "Já existe uma solicitação para este período."}
|
||||
details={detalhesErroModal}
|
||||
onClose={fecharModalErro}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.wizard-ausencia {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.wizard-ausencia {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,448 +1,514 @@
|
||||
<script lang="ts">
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { abrirConversa } from '$lib/stores/chatStore';
|
||||
import UserStatusBadge from './UserStatusBadge.svelte';
|
||||
import UserAvatar from './UserAvatar.svelte';
|
||||
import NewConversationModal from './NewConversationModal.svelte';
|
||||
import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { useQuery, useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import { abrirConversa } from "$lib/stores/chatStore";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import UserStatusBadge from "./UserStatusBadge.svelte";
|
||||
import UserAvatar from "./UserAvatar.svelte";
|
||||
import NewConversationModal from "./NewConversationModal.svelte";
|
||||
|
||||
const client = useConvexClient();
|
||||
const client = useConvexClient();
|
||||
|
||||
// Buscar todos os usuários para o chat
|
||||
const usuarios = useQuery(api.usuarios.listarParaChat, {});
|
||||
// Buscar todos os usuários para o chat
|
||||
const usuarios = useQuery(api.usuarios.listarParaChat, {});
|
||||
|
||||
// Buscar o perfil do usuário logado
|
||||
const meuPerfil = useQuery(api.usuarios.obterPerfil, {});
|
||||
// Buscar o perfil do usuário logado
|
||||
const meuPerfil = useQuery(api.usuarios.obterPerfil, {});
|
||||
|
||||
// Buscar conversas (grupos e salas de reunião)
|
||||
const conversas = useQuery(api.chat.listarConversas, {});
|
||||
// Buscar conversas (grupos e salas de reunião)
|
||||
const conversas = useQuery(api.chat.listarConversas, {});
|
||||
|
||||
let searchQuery = $state('');
|
||||
let activeTab = $state<'usuarios' | 'conversas'>('usuarios');
|
||||
let searchQuery = $state("");
|
||||
let activeTab = $state<"usuarios" | "conversas">("usuarios");
|
||||
|
||||
const usuariosFiltrados = $derived.by(() => {
|
||||
if (!usuarios?.data || !Array.isArray(usuarios.data)) return [];
|
||||
// Debug: monitorar carregamento de dados
|
||||
$effect(() => {
|
||||
console.log(
|
||||
"📊 [ChatList] Usuários carregados:",
|
||||
usuarios?.data?.length || 0,
|
||||
);
|
||||
console.log(
|
||||
"👤 [ChatList] Meu perfil:",
|
||||
meuPerfil?.data?.nome || "Carregando...",
|
||||
);
|
||||
console.log(
|
||||
"🆔 [ChatList] Meu ID:",
|
||||
meuPerfil?.data?._id || "Não encontrado",
|
||||
);
|
||||
if (usuarios?.data) {
|
||||
const meuId = meuPerfil?.data?._id;
|
||||
const meusDadosNaLista = usuarios.data.find((u: any) => u._id === meuId);
|
||||
if (meusDadosNaLista) {
|
||||
console.warn(
|
||||
"⚠️ [ChatList] ATENÇÃO: Meu usuário está na lista do backend!",
|
||||
meusDadosNaLista.nome,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Se não temos o perfil ainda, retornar lista vazia para evitar mostrar usuários incorretos
|
||||
if (!meuPerfil?.data) {
|
||||
console.log('⏳ [ChatList] Aguardando perfil do usuário...');
|
||||
return [];
|
||||
}
|
||||
const usuariosFiltrados = $derived.by(() => {
|
||||
if (!usuarios?.data || !Array.isArray(usuarios.data)) return [];
|
||||
|
||||
const meuId = meuPerfil.data._id;
|
||||
// Se não temos o perfil ainda, retornar lista vazia para evitar mostrar usuários incorretos
|
||||
if (!meuPerfil?.data) {
|
||||
console.log("⏳ [ChatList] Aguardando perfil do usuário...");
|
||||
return [];
|
||||
}
|
||||
|
||||
// Filtrar o próprio usuário da lista (filtro de segurança no frontend)
|
||||
let listaFiltrada = usuarios.data.filter((u) => u._id !== meuId);
|
||||
const meuId = meuPerfil.data._id;
|
||||
|
||||
// Log se ainda estiver na lista após filtro (não deveria acontecer)
|
||||
const aindaNaLista = listaFiltrada.find((u) => u._id === meuId);
|
||||
if (aindaNaLista) {
|
||||
console.error('❌ [ChatList] ERRO: Meu usuário ainda está na lista após filtro!');
|
||||
}
|
||||
// Filtrar o próprio usuário da lista (filtro de segurança no frontend)
|
||||
let listaFiltrada = usuarios.data.filter((u: any) => u._id !== meuId);
|
||||
|
||||
// Aplicar busca por nome/email/matrícula
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
listaFiltrada = listaFiltrada.filter(
|
||||
(u) =>
|
||||
u.nome?.toLowerCase().includes(query) ||
|
||||
u.email?.toLowerCase().includes(query) ||
|
||||
u.matricula?.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
// Log se ainda estiver na lista após filtro (não deveria acontecer)
|
||||
const aindaNaLista = listaFiltrada.find((u: any) => u._id === meuId);
|
||||
if (aindaNaLista) {
|
||||
console.error(
|
||||
"❌ [ChatList] ERRO: Meu usuário ainda está na lista após filtro!",
|
||||
);
|
||||
}
|
||||
|
||||
// Ordenar: Online primeiro, depois por nome
|
||||
return listaFiltrada.sort((a, b) => {
|
||||
const statusOrder = {
|
||||
online: 0,
|
||||
ausente: 1,
|
||||
externo: 2,
|
||||
em_reuniao: 3,
|
||||
offline: 4
|
||||
};
|
||||
const statusA = statusOrder[a.statusPresenca as keyof typeof statusOrder] ?? 4;
|
||||
const statusB = statusOrder[b.statusPresenca as keyof typeof statusOrder] ?? 4;
|
||||
// Aplicar busca por nome/email/matrícula
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
listaFiltrada = listaFiltrada.filter(
|
||||
(u: any) =>
|
||||
u.nome?.toLowerCase().includes(query) ||
|
||||
u.email?.toLowerCase().includes(query) ||
|
||||
u.matricula?.toLowerCase().includes(query),
|
||||
);
|
||||
}
|
||||
|
||||
if (statusA !== statusB) return statusA - statusB;
|
||||
return a.nome.localeCompare(b.nome);
|
||||
});
|
||||
});
|
||||
// Ordenar: Online primeiro, depois por nome
|
||||
return listaFiltrada.sort((a: any, b: any) => {
|
||||
const statusOrder = {
|
||||
online: 0,
|
||||
ausente: 1,
|
||||
externo: 2,
|
||||
em_reuniao: 3,
|
||||
offline: 4,
|
||||
};
|
||||
const statusA =
|
||||
statusOrder[a.statusPresenca as keyof typeof statusOrder] ?? 4;
|
||||
const statusB =
|
||||
statusOrder[b.statusPresenca as keyof typeof statusOrder] ?? 4;
|
||||
|
||||
let processando = $state(false);
|
||||
let showNewConversationModal = $state(false);
|
||||
if (statusA !== statusB) return statusA - statusB;
|
||||
return a.nome.localeCompare(b.nome);
|
||||
});
|
||||
});
|
||||
|
||||
async function handleClickUsuario(usuario: {
|
||||
_id: Id<'usuarios'>;
|
||||
nome: string;
|
||||
email: string;
|
||||
matricula: string | undefined;
|
||||
avatar: string | undefined;
|
||||
fotoPerfil: Id<'_storage'> | undefined;
|
||||
fotoPerfilUrl: string | null;
|
||||
statusPresenca: 'online' | 'offline' | 'ausente' | 'externo' | 'em_reuniao';
|
||||
statusMensagem: string | undefined;
|
||||
ultimaAtividade: number | undefined;
|
||||
}) {
|
||||
if (processando) {
|
||||
console.log('⏳ Já está processando uma ação, aguarde...');
|
||||
return;
|
||||
}
|
||||
function formatarTempo(timestamp: number | undefined): string {
|
||||
if (!timestamp) return "";
|
||||
try {
|
||||
return formatDistanceToNow(new Date(timestamp), {
|
||||
addSuffix: true,
|
||||
locale: ptBR,
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
processando = true;
|
||||
console.log('🔄 Clicou no usuário:', usuario.nome, 'ID:', usuario._id);
|
||||
let processando = $state(false);
|
||||
let showNewConversationModal = $state(false);
|
||||
|
||||
// Criar ou buscar conversa individual com este usuário
|
||||
console.log('📞 Chamando mutation criarOuBuscarConversaIndividual...');
|
||||
const conversaId = await client.mutation(api.chat.criarOuBuscarConversaIndividual, {
|
||||
outroUsuarioId: usuario._id
|
||||
});
|
||||
async function handleClickUsuario(usuario: any) {
|
||||
if (processando) {
|
||||
console.log("⏳ Já está processando uma ação, aguarde...");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('✅ Conversa criada/encontrada. ID:', conversaId);
|
||||
try {
|
||||
processando = true;
|
||||
console.log("🔄 Clicou no usuário:", usuario.nome, "ID:", usuario._id);
|
||||
|
||||
// Abrir a conversa
|
||||
console.log('📂 Abrindo conversa...');
|
||||
abrirConversa(conversaId);
|
||||
// Criar ou buscar conversa individual com este usuário
|
||||
console.log("📞 Chamando mutation criarOuBuscarConversaIndividual...");
|
||||
const conversaId = await client.mutation(
|
||||
api.chat.criarOuBuscarConversaIndividual,
|
||||
{
|
||||
outroUsuarioId: usuario._id,
|
||||
},
|
||||
);
|
||||
|
||||
console.log('✅ Conversa aberta com sucesso!');
|
||||
} catch (error) {
|
||||
console.error('❌ Erro ao abrir conversa:', error);
|
||||
console.error('Detalhes do erro:', {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
usuario: usuario
|
||||
});
|
||||
alert(`Erro ao abrir conversa: ${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
console.log("✅ Conversa criada/encontrada. ID:", conversaId);
|
||||
|
||||
function getStatusLabel(status: string | undefined): string {
|
||||
const labels: Record<string, string> = {
|
||||
online: 'Online',
|
||||
offline: 'Offline',
|
||||
ausente: 'Ausente',
|
||||
externo: 'Externo',
|
||||
em_reuniao: 'Em Reunião'
|
||||
};
|
||||
return labels[status || 'offline'] || 'Offline';
|
||||
}
|
||||
// Abrir a conversa
|
||||
console.log("📂 Abrindo conversa...");
|
||||
abrirConversa(conversaId as any);
|
||||
|
||||
// Filtrar conversas por tipo e busca
|
||||
const conversasFiltradas = $derived(() => {
|
||||
if (!conversas?.data) return [];
|
||||
console.log("✅ Conversa aberta com sucesso!");
|
||||
} catch (error) {
|
||||
console.error("❌ Erro ao abrir conversa:", error);
|
||||
console.error("Detalhes do erro:", {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
usuario: usuario,
|
||||
});
|
||||
alert(
|
||||
`Erro ao abrir conversa: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
|
||||
let lista = conversas.data.filter((c) => c.tipo === 'grupo' || c.tipo === 'sala_reuniao');
|
||||
function getStatusLabel(status: string | undefined): string {
|
||||
const labels: Record<string, string> = {
|
||||
online: "Online",
|
||||
offline: "Offline",
|
||||
ausente: "Ausente",
|
||||
externo: "Externo",
|
||||
em_reuniao: "Em Reunião",
|
||||
};
|
||||
return labels[status || "offline"] || "Offline";
|
||||
}
|
||||
|
||||
// Aplicar busca
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
lista = lista.filter((c) => c.nome?.toLowerCase().includes(query));
|
||||
}
|
||||
// Filtrar conversas por tipo e busca
|
||||
const conversasFiltradas = $derived(() => {
|
||||
if (!conversas?.data) return [];
|
||||
|
||||
return lista;
|
||||
});
|
||||
let lista = conversas.data.filter(
|
||||
(c: any) => c.tipo === "grupo" || c.tipo === "sala_reuniao",
|
||||
);
|
||||
|
||||
function handleClickConversa(conversa: Doc<'conversas'>) {
|
||||
if (processando) return;
|
||||
try {
|
||||
processando = true;
|
||||
abrirConversa(conversa._id);
|
||||
} catch (error) {
|
||||
console.error('Erro ao abrir conversa:', error);
|
||||
alert(`Erro ao abrir conversa: ${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
// Aplicar busca
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
lista = lista.filter((c: any) => c.nome?.toLowerCase().includes(query));
|
||||
}
|
||||
|
||||
return lista;
|
||||
});
|
||||
|
||||
function handleClickConversa(conversa: any) {
|
||||
if (processando) return;
|
||||
try {
|
||||
processando = true;
|
||||
abrirConversa(conversa._id);
|
||||
} catch (error) {
|
||||
console.error("Erro ao abrir conversa:", error);
|
||||
alert(
|
||||
`Erro ao abrir conversa: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
<!-- Search bar -->
|
||||
<div class="border-base-300 border-b p-4">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar usuários (nome, email, matrícula)..."
|
||||
class="input input-bordered w-full pl-10"
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="text-base-content/50 absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Search bar -->
|
||||
<div class="p-4 border-b border-base-300">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar usuários (nome, email, matrícula)..."
|
||||
class="input input-bordered w-full pl-10"
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-base-content/50"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs e Título -->
|
||||
<div class="border-base-300 bg-base-200 border-b">
|
||||
<!-- Tabs -->
|
||||
<div class="tabs tabs-boxed p-2">
|
||||
<button
|
||||
type="button"
|
||||
class={`tab flex-1 ${activeTab === 'usuarios' ? 'tab-active' : ''}`}
|
||||
onclick={() => (activeTab = 'usuarios')}
|
||||
>
|
||||
👥 Usuários ({usuariosFiltrados.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`tab flex-1 ${activeTab === 'conversas' ? 'tab-active' : ''}`}
|
||||
onclick={() => (activeTab = 'conversas')}
|
||||
>
|
||||
💬 Conversas ({conversasFiltradas().length})
|
||||
</button>
|
||||
</div>
|
||||
<!-- Tabs e Título -->
|
||||
<div class="border-b border-base-300 bg-base-200">
|
||||
<!-- Tabs -->
|
||||
<div class="tabs tabs-boxed p-2">
|
||||
<button
|
||||
type="button"
|
||||
class={`tab flex-1 ${activeTab === "usuarios" ? "tab-active" : ""}`}
|
||||
onclick={() => (activeTab = "usuarios")}
|
||||
>
|
||||
👥 Usuários ({usuariosFiltrados.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`tab flex-1 ${activeTab === "conversas" ? "tab-active" : ""}`}
|
||||
onclick={() => (activeTab = "conversas")}
|
||||
>
|
||||
💬 Conversas ({conversasFiltradas().length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Botão Nova Conversa -->
|
||||
<div class="flex justify-end px-4 pb-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
onclick={() => (showNewConversationModal = true)}
|
||||
title="Nova conversa (grupo ou sala de reunião)"
|
||||
aria-label="Nova conversa"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="mr-1 h-4 w-4"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Nova Conversa
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Botão Nova Conversa -->
|
||||
<div class="px-4 pb-2 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
onclick={() => (showNewConversationModal = true)}
|
||||
title="Nova conversa (grupo ou sala de reunião)"
|
||||
aria-label="Nova conversa"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="w-4 h-4 mr-1"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 4.5v15m7.5-7.5h-15"
|
||||
/>
|
||||
</svg>
|
||||
Nova Conversa
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lista de conteúdo -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#if activeTab === 'usuarios'}
|
||||
<!-- Lista de usuários -->
|
||||
{#if usuarios?.data && usuariosFiltrados.length > 0}
|
||||
{#each usuariosFiltrados as usuario (usuario._id)}
|
||||
<button
|
||||
type="button"
|
||||
class="hover:bg-base-200 border-base-300 flex w-full items-center gap-3 border-b px-4 py-3 text-left transition-colors {processando
|
||||
? 'cursor-wait opacity-50'
|
||||
: 'cursor-pointer'}"
|
||||
onclick={() => handleClickUsuario(usuario)}
|
||||
disabled={processando}
|
||||
>
|
||||
<!-- Ícone de mensagem -->
|
||||
<div
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl transition-all duration-300 hover:scale-110"
|
||||
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%); border: 1px solid rgba(102, 126, 234, 0.2);"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="text-primary h-5 w-5"
|
||||
>
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
<path d="M9 10h.01M15 10h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Lista de conteúdo -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#if activeTab === "usuarios"}
|
||||
<!-- Lista de usuários -->
|
||||
{#if usuarios?.data && usuariosFiltrados.length > 0}
|
||||
{#each usuariosFiltrados as usuario (usuario._id)}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-4 py-3 hover:bg-base-200 border-b border-base-300 transition-colors flex items-center gap-3 {processando
|
||||
? 'opacity-50 cursor-wait'
|
||||
: 'cursor-pointer'}"
|
||||
onclick={() => handleClickUsuario(usuario)}
|
||||
disabled={processando}
|
||||
>
|
||||
<!-- Ícone de mensagem -->
|
||||
<div
|
||||
class="shrink-0 w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 hover:scale-110"
|
||||
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%); border: 1px solid rgba(102, 126, 234, 0.2);"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-5 h-5 text-primary"
|
||||
>
|
||||
<path
|
||||
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
|
||||
/>
|
||||
<path d="M9 10h.01M15 10h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Avatar -->
|
||||
<div class="relative shrink-0">
|
||||
<UserAvatar
|
||||
avatar={usuario.avatar}
|
||||
fotoPerfilUrl={usuario.fotoPerfilUrl}
|
||||
nome={usuario.nome}
|
||||
size="md"
|
||||
/>
|
||||
<!-- Status badge -->
|
||||
<div class="absolute right-0 bottom-0">
|
||||
<UserStatusBadge status={usuario.statusPresenca || 'offline'} size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Avatar -->
|
||||
<div class="relative shrink-0">
|
||||
<UserAvatar
|
||||
avatar={usuario.avatar}
|
||||
fotoPerfilUrl={usuario.fotoPerfilUrl}
|
||||
nome={usuario.nome}
|
||||
size="md"
|
||||
/>
|
||||
<!-- Status badge -->
|
||||
<div class="absolute bottom-0 right-0">
|
||||
<UserStatusBadge
|
||||
status={usuario.statusPresenca || "offline"}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<p class="text-base-content truncate font-semibold">
|
||||
{usuario.nome}
|
||||
</p>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs {usuario.statusPresenca === 'online'
|
||||
? 'bg-success/20 text-success'
|
||||
: usuario.statusPresenca === 'ausente'
|
||||
? 'bg-warning/20 text-warning'
|
||||
: usuario.statusPresenca === 'em_reuniao'
|
||||
? 'bg-error/20 text-error'
|
||||
: 'bg-base-300 text-base-content/50'}"
|
||||
>
|
||||
{getStatusLabel(usuario.statusPresenca)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-base-content/70 truncate text-sm">
|
||||
{usuario.statusMensagem || usuario.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{:else if !usuarios?.data}
|
||||
<!-- Loading -->
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Nenhum usuário encontrado -->
|
||||
<div class="flex h-full flex-col items-center justify-center px-4 text-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="text-base-content/30 mb-4 h-16 w-16"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-base-content/70">Nenhum usuário encontrado</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Lista de conversas (grupos e salas) -->
|
||||
{#if conversas?.data && conversasFiltradas().length > 0}
|
||||
{#each conversasFiltradas() as conversa (conversa._id)}
|
||||
<button
|
||||
type="button"
|
||||
class="hover:bg-base-200 border-base-300 flex w-full items-center gap-3 border-b px-4 py-3 text-left transition-colors {processando
|
||||
? 'cursor-wait opacity-50'
|
||||
: 'cursor-pointer'}"
|
||||
onclick={() => handleClickConversa(conversa)}
|
||||
disabled={processando}
|
||||
>
|
||||
<!-- Ícone de grupo/sala -->
|
||||
<div
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl transition-all duration-300 hover:scale-110 {conversa.tipo ===
|
||||
'sala_reuniao'
|
||||
? 'border border-blue-300/30 bg-linear-to-br from-blue-500/20 to-purple-500/20'
|
||||
: 'from-primary/20 to-secondary/20 border-primary/30 border bg-linear-to-br'}"
|
||||
>
|
||||
{#if conversa.tipo === 'sala_reuniao'}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="h-5 w-5 text-blue-500"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="text-primary h-5 w-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Conteúdo -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<p class="font-semibold text-base-content truncate">
|
||||
{usuario.nome}
|
||||
</p>
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full {usuario.statusPresenca ===
|
||||
'online'
|
||||
? 'bg-success/20 text-success'
|
||||
: usuario.statusPresenca === 'ausente'
|
||||
? 'bg-warning/20 text-warning'
|
||||
: usuario.statusPresenca === 'em_reuniao'
|
||||
? 'bg-error/20 text-error'
|
||||
: 'bg-base-300 text-base-content/50'}"
|
||||
>
|
||||
{getStatusLabel(usuario.statusPresenca)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm text-base-content/70 truncate">
|
||||
{usuario.statusMensagem || usuario.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{:else if !usuarios?.data}
|
||||
<!-- Loading -->
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Nenhum usuário encontrado -->
|
||||
<div
|
||||
class="flex flex-col items-center justify-center h-full text-center px-4"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-16 h-16 text-base-content/30 mb-4"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-base-content/70">Nenhum usuário encontrado</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Lista de conversas (grupos e salas) -->
|
||||
{#if conversas?.data && conversasFiltradas().length > 0}
|
||||
{#each conversasFiltradas() as conversa (conversa._id)}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-4 py-3 hover:bg-base-200 border-b border-base-300 transition-colors flex items-center gap-3 {processando
|
||||
? 'opacity-50 cursor-wait'
|
||||
: 'cursor-pointer'}"
|
||||
onclick={() => handleClickConversa(conversa)}
|
||||
disabled={processando}
|
||||
>
|
||||
<!-- Ícone de grupo/sala -->
|
||||
<div
|
||||
class="shrink-0 w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 hover:scale-110 {conversa.tipo ===
|
||||
'sala_reuniao'
|
||||
? 'bg-linear-to-br from-blue-500/20 to-purple-500/20 border border-blue-300/30'
|
||||
: 'bg-linear-to-br from-primary/20 to-secondary/20 border border-primary/30'}"
|
||||
>
|
||||
{#if conversa.tipo === "sala_reuniao"}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5 text-blue-500"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5 text-primary"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<p class="text-base-content truncate font-semibold">
|
||||
{conversa.nome ||
|
||||
(conversa.tipo === 'sala_reuniao' ? 'Sala sem nome' : 'Grupo sem nome')}
|
||||
</p>
|
||||
{#if conversa.naoLidas > 0}
|
||||
<span class="badge badge-primary badge-sm">{conversa.naoLidas}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs {conversa.tipo === 'sala_reuniao'
|
||||
? 'bg-blue-500/20 text-blue-500'
|
||||
: 'bg-primary/20 text-primary'}"
|
||||
>
|
||||
{conversa.tipo === 'sala_reuniao' ? '👑 Sala de Reunião' : '👥 Grupo'}
|
||||
</span>
|
||||
{#if conversa.participantesInfo}
|
||||
<span class="text-base-content/50 text-xs">
|
||||
{conversa.participantesInfo.length} participante{conversa.participantesInfo
|
||||
.length !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{:else if !conversas?.data}
|
||||
<!-- Loading -->
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Nenhuma conversa encontrada -->
|
||||
<div class="flex h-full flex-col items-center justify-center px-4 text-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="text-base-content/30 mb-4 h-16 w-16"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-base-content/70 mb-2 font-medium">Nenhuma conversa encontrada</p>
|
||||
<p class="text-base-content/50 text-sm">Crie um grupo ou sala de reunião para começar</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Conteúdo -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<p class="font-semibold text-base-content truncate">
|
||||
{conversa.nome ||
|
||||
(conversa.tipo === "sala_reuniao"
|
||||
? "Sala sem nome"
|
||||
: "Grupo sem nome")}
|
||||
</p>
|
||||
{#if conversa.naoLidas > 0}
|
||||
<span class="badge badge-primary badge-sm"
|
||||
>{conversa.naoLidas}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full {conversa.tipo ===
|
||||
'sala_reuniao'
|
||||
? 'bg-blue-500/20 text-blue-500'
|
||||
: 'bg-primary/20 text-primary'}"
|
||||
>
|
||||
{conversa.tipo === "sala_reuniao"
|
||||
? "👑 Sala de Reunião"
|
||||
: "👥 Grupo"}
|
||||
</span>
|
||||
{#if conversa.participantesInfo}
|
||||
<span class="text-xs text-base-content/50">
|
||||
{conversa.participantesInfo.length} participante{conversa
|
||||
.participantesInfo.length !== 1
|
||||
? "s"
|
||||
: ""}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{:else if !conversas?.data}
|
||||
<!-- Loading -->
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Nenhuma conversa encontrada -->
|
||||
<div
|
||||
class="flex flex-col items-center justify-center h-full text-center px-4"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-16 h-16 text-base-content/30 mb-4"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-base-content/70 font-medium mb-2">
|
||||
Nenhuma conversa encontrada
|
||||
</p>
|
||||
<p class="text-sm text-base-content/50">
|
||||
Crie um grupo ou sala de reunião para começar
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de Nova Conversa -->
|
||||
{#if showNewConversationModal}
|
||||
<NewConversationModal onClose={() => (showNewConversationModal = false)} />
|
||||
<NewConversationModal onClose={() => (showNewConversationModal = false)} />
|
||||
{/if}
|
||||
|
||||
@@ -34,8 +34,8 @@
|
||||
if (!usuario) return null;
|
||||
|
||||
// Prioridade: fotoPerfilUrl > avatar > fallback com nome
|
||||
if (usuario.fotoPerfilUrl) {
|
||||
return usuario.fotoPerfilUrl;
|
||||
if (usuario.fotoPerfil) {
|
||||
return usuario.fotoPerfil;
|
||||
}
|
||||
if (usuario.avatar) {
|
||||
return getAvatarUrl(usuario.avatar);
|
||||
@@ -768,14 +768,14 @@
|
||||
type="button"
|
||||
class="group fixed border-0 backdrop-blur-xl"
|
||||
style="
|
||||
z-index: 99999 !important;
|
||||
width: 4.5rem;
|
||||
height: 4.5rem;
|
||||
z-index: 99999 !important;
|
||||
width: 4.5rem;
|
||||
height: 4.5rem;
|
||||
bottom: {bottomPos};
|
||||
right: {rightPos};
|
||||
position: fixed !important;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
0 20px 60px -10px rgba(102, 126, 234, 0.5),
|
||||
0 10px 30px -5px rgba(118, 75, 162, 0.4),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1) inset;
|
||||
@@ -839,7 +839,7 @@
|
||||
class="absolute -top-1 -right-1 z-20 flex h-8 w-8 items-center justify-center rounded-full text-xs font-black text-white"
|
||||
style="
|
||||
background: linear-gradient(135deg, #ff416c, #ff4b2b);
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
0 8px 24px -4px rgba(255, 65, 108, 0.6),
|
||||
0 4px 12px -2px rgba(255, 75, 43, 0.4),
|
||||
0 0 0 3px rgba(255, 255, 255, 0.3),
|
||||
@@ -883,7 +883,7 @@
|
||||
position: fixed !important;
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.95) 0%, rgba(249,250,251,0.98) 100%);
|
||||
border-radius: 24px;
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
0 32px 64px -12px rgba(0, 0, 0, 0.15),
|
||||
0 16px 32px -8px rgba(0, 0, 0, 0.1),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.05),
|
||||
|
||||
@@ -1,495 +1,545 @@
|
||||
<script lang="ts">
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { voltarParaLista } from '$lib/stores/chatStore';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import MessageList from './MessageList.svelte';
|
||||
import MessageInput from './MessageInput.svelte';
|
||||
import UserStatusBadge from './UserStatusBadge.svelte';
|
||||
import UserAvatar from './UserAvatar.svelte';
|
||||
import ScheduleMessageModal from './ScheduleMessageModal.svelte';
|
||||
import SalaReuniaoManager from './SalaReuniaoManager.svelte';
|
||||
import { getAvatarUrl } from '$lib/utils/avatarGenerator';
|
||||
import { Bell, X, ArrowLeft, LogOut, MoreVertical, Users, Clock, XCircle } from 'lucide-svelte';
|
||||
import { useQuery, useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import { voltarParaLista } from "$lib/stores/chatStore";
|
||||
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
import MessageList from "./MessageList.svelte";
|
||||
import MessageInput from "./MessageInput.svelte";
|
||||
import UserStatusBadge from "./UserStatusBadge.svelte";
|
||||
import UserAvatar from "./UserAvatar.svelte";
|
||||
import ScheduleMessageModal from "./ScheduleMessageModal.svelte";
|
||||
import SalaReuniaoManager from "./SalaReuniaoManager.svelte";
|
||||
import { getAvatarUrl } from "$lib/utils/avatarGenerator";
|
||||
import {
|
||||
Bell,
|
||||
X,
|
||||
ArrowLeft,
|
||||
LogOut,
|
||||
MoreVertical,
|
||||
Users,
|
||||
Clock,
|
||||
XCircle,
|
||||
} from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
conversaId: string;
|
||||
}
|
||||
interface Props {
|
||||
conversaId: string;
|
||||
}
|
||||
|
||||
let { conversaId }: Props = $props();
|
||||
let { conversaId }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
const client = useConvexClient();
|
||||
|
||||
// Token é passado automaticamente via interceptadores em +layout.svelte
|
||||
// Token é passado automaticamente via interceptadores em +layout.svelte
|
||||
|
||||
let showScheduleModal = $state(false);
|
||||
let showSalaManager = $state(false);
|
||||
let showAdminMenu = $state(false);
|
||||
let showNotificacaoModal = $state(false);
|
||||
let showScheduleModal = $state(false);
|
||||
let showSalaManager = $state(false);
|
||||
let showAdminMenu = $state(false);
|
||||
let showNotificacaoModal = $state(false);
|
||||
|
||||
const conversas = useQuery(api.chat.listarConversas, {});
|
||||
const isAdmin = useQuery(api.chat.verificarSeEhAdmin, {
|
||||
conversaId: conversaId as Id<'conversas'>
|
||||
});
|
||||
const conversas = useQuery(api.chat.listarConversas, {});
|
||||
const isAdmin = useQuery(api.chat.verificarSeEhAdmin, {
|
||||
conversaId: conversaId as Id<"conversas">,
|
||||
});
|
||||
|
||||
const conversa = $derived(() => {
|
||||
console.log('🔍 [ChatWindow] Buscando conversa ID:', conversaId);
|
||||
console.log('📋 [ChatWindow] Conversas disponíveis:', conversas?.data);
|
||||
const conversa = $derived(() => {
|
||||
console.log("🔍 [ChatWindow] Buscando conversa ID:", conversaId);
|
||||
console.log("📋 [ChatWindow] Conversas disponíveis:", conversas?.data);
|
||||
|
||||
if (!conversas?.data || !Array.isArray(conversas.data)) {
|
||||
console.log('⚠️ [ChatWindow] conversas.data não é um array ou está vazio');
|
||||
return null;
|
||||
}
|
||||
if (!conversas?.data || !Array.isArray(conversas.data)) {
|
||||
console.log(
|
||||
"⚠️ [ChatWindow] conversas.data não é um array ou está vazio",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const encontrada = conversas.data.find((c: { _id: string }) => c._id === conversaId);
|
||||
console.log('✅ [ChatWindow] Conversa encontrada:', encontrada);
|
||||
return encontrada;
|
||||
});
|
||||
const encontrada = conversas.data.find(
|
||||
(c: { _id: string }) => c._id === conversaId,
|
||||
);
|
||||
console.log("✅ [ChatWindow] Conversa encontrada:", encontrada);
|
||||
return encontrada;
|
||||
});
|
||||
|
||||
function getNomeConversa(): string {
|
||||
const c = conversa();
|
||||
if (!c) return 'Carregando...';
|
||||
if (c.tipo === 'grupo' || c.tipo === 'sala_reuniao') {
|
||||
return c.nome || (c.tipo === 'sala_reuniao' ? 'Sala sem nome' : 'Grupo sem nome');
|
||||
}
|
||||
return c.outroUsuario?.nome || 'Usuário';
|
||||
}
|
||||
function getNomeConversa(): string {
|
||||
const c = conversa();
|
||||
if (!c) return "Carregando...";
|
||||
if (c.tipo === "grupo" || c.tipo === "sala_reuniao") {
|
||||
return (
|
||||
c.nome ||
|
||||
(c.tipo === "sala_reuniao" ? "Sala sem nome" : "Grupo sem nome")
|
||||
);
|
||||
}
|
||||
return c.outroUsuario?.nome || "Usuário";
|
||||
}
|
||||
|
||||
function getAvatarConversa(): string {
|
||||
const c = conversa();
|
||||
if (!c) return '💬';
|
||||
if (c.tipo === 'grupo') {
|
||||
return c.avatar || '👥';
|
||||
}
|
||||
if (c.outroUsuario?.avatar) {
|
||||
return c.outroUsuario.avatar;
|
||||
}
|
||||
return '👤';
|
||||
}
|
||||
function getAvatarConversa(): string {
|
||||
const c = conversa();
|
||||
if (!c) return "💬";
|
||||
if (c.tipo === "grupo") {
|
||||
return c.avatar || "👥";
|
||||
}
|
||||
if (c.outroUsuario?.avatar) {
|
||||
return c.outroUsuario.avatar;
|
||||
}
|
||||
return "👤";
|
||||
}
|
||||
|
||||
function getStatusConversa(): 'online' | 'offline' | 'ausente' | 'externo' | 'em_reuniao' | null {
|
||||
const c = conversa();
|
||||
if (c && c.tipo === 'individual' && c.outroUsuario) {
|
||||
return (
|
||||
(c.outroUsuario.statusPresenca as
|
||||
| 'online'
|
||||
| 'offline'
|
||||
| 'ausente'
|
||||
| 'externo'
|
||||
| 'em_reuniao') || 'offline'
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function getStatusConversa():
|
||||
| "online"
|
||||
| "offline"
|
||||
| "ausente"
|
||||
| "externo"
|
||||
| "em_reuniao"
|
||||
| null {
|
||||
const c = conversa();
|
||||
if (c && c.tipo === "individual" && c.outroUsuario) {
|
||||
return (
|
||||
(c.outroUsuario.statusPresenca as
|
||||
| "online"
|
||||
| "offline"
|
||||
| "ausente"
|
||||
| "externo"
|
||||
| "em_reuniao") || "offline"
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getStatusMensagem(): string | null {
|
||||
const c = conversa();
|
||||
if (c && c.tipo === 'individual' && c.outroUsuario) {
|
||||
return c.outroUsuario.statusMensagem || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function getStatusMensagem(): string | null {
|
||||
const c = conversa();
|
||||
if (c && c.tipo === "individual" && c.outroUsuario) {
|
||||
return c.outroUsuario.statusMensagem || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleSairGrupoOuSala() {
|
||||
const c = conversa();
|
||||
if (!c || (c.tipo !== 'grupo' && c.tipo !== 'sala_reuniao')) return;
|
||||
async function handleSairGrupoOuSala() {
|
||||
const c = conversa();
|
||||
if (!c || (c.tipo !== "grupo" && c.tipo !== "sala_reuniao")) return;
|
||||
|
||||
const tipoTexto = c.tipo === 'sala_reuniao' ? 'sala de reunião' : 'grupo';
|
||||
if (!confirm(`Tem certeza que deseja sair da ${tipoTexto} "${c.nome || 'Sem nome'}"?`)) {
|
||||
return;
|
||||
}
|
||||
const tipoTexto = c.tipo === "sala_reuniao" ? "sala de reunião" : "grupo";
|
||||
if (
|
||||
!confirm(
|
||||
`Tem certeza que deseja sair da ${tipoTexto} "${c.nome || "Sem nome"}"?`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resultado = await client.mutation(api.chat.sairGrupoOuSala, {
|
||||
conversaId: conversaId as Id<'conversas'>
|
||||
});
|
||||
try {
|
||||
const resultado = await client.mutation(api.chat.sairGrupoOuSala, {
|
||||
conversaId: conversaId as Id<"conversas">,
|
||||
});
|
||||
|
||||
if (resultado.sucesso) {
|
||||
voltarParaLista();
|
||||
} else {
|
||||
alert(resultado.erro || 'Erro ao sair da conversa');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao sair da conversa:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Erro ao sair da conversa';
|
||||
alert(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDocumentClick() {
|
||||
if (showAdminMenu) showAdminMenu = false;
|
||||
}
|
||||
if (resultado.sucesso) {
|
||||
voltarParaLista();
|
||||
} else {
|
||||
alert(resultado.erro || "Erro ao sair da conversa");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao sair da conversa:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Erro ao sair da conversa";
|
||||
alert(errorMessage);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleDocumentClick} />
|
||||
<div class="flex flex-col h-full" onclick={() => (showAdminMenu = false)}>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center gap-3 px-4 py-3 border-b border-base-300 bg-base-200"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Botão Voltar -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm btn-circle hover:bg-primary/20 transition-all duration-200 hover:scale-110"
|
||||
onclick={voltarParaLista}
|
||||
aria-label="Voltar"
|
||||
title="Voltar para lista de conversas"
|
||||
>
|
||||
<ArrowLeft class="w-6 h-6 text-primary" strokeWidth={2.5} />
|
||||
</button>
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
<!-- Header -->
|
||||
<div class="border-base-300 bg-base-200 flex items-center gap-3 border-b px-4 py-3">
|
||||
<!-- Botão Voltar -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-circle hover:bg-primary/20 transition-all duration-200 hover:scale-110"
|
||||
onclick={voltarParaLista}
|
||||
aria-label="Voltar"
|
||||
title="Voltar para lista de conversas"
|
||||
>
|
||||
<ArrowLeft class="text-primary h-6 w-6" />
|
||||
</button>
|
||||
<!-- Avatar e Info -->
|
||||
<div class="relative shrink-0">
|
||||
{#if conversa() && conversa()?.tipo === "individual" && conversa()?.outroUsuario}
|
||||
<UserAvatar
|
||||
avatar={conversa()?.outroUsuario?.avatar}
|
||||
fotoPerfilUrl={conversa()?.outroUsuario?.fotoPerfilUrl}
|
||||
nome={conversa()?.outroUsuario?.nome || "Usuário"}
|
||||
size="md"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-xl"
|
||||
>
|
||||
{getAvatarConversa()}
|
||||
</div>
|
||||
{/if}
|
||||
{#if getStatusConversa()}
|
||||
<div class="absolute bottom-0 right-0">
|
||||
<UserStatusBadge status={getStatusConversa()} size="sm" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Avatar e Info -->
|
||||
<div class="relative shrink-0">
|
||||
{#if conversa() && conversa()?.tipo === 'individual' && conversa()?.outroUsuario}
|
||||
<UserAvatar
|
||||
avatar={conversa()?.outroUsuario?.avatar}
|
||||
fotoPerfilUrl={conversa()?.outroUsuario?.fotoPerfilUrl}
|
||||
nome={conversa()?.outroUsuario?.nome || 'Usuário'}
|
||||
size="md"
|
||||
/>
|
||||
{:else}
|
||||
<div class="bg-primary/20 flex h-10 w-10 items-center justify-center rounded-full text-xl">
|
||||
{getAvatarConversa()}
|
||||
</div>
|
||||
{/if}
|
||||
{#if getStatusConversa()}
|
||||
<div class="absolute right-0 bottom-0">
|
||||
<UserStatusBadge status={getStatusConversa()} size="sm" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-semibold text-base-content truncate">
|
||||
{getNomeConversa()}
|
||||
</p>
|
||||
{#if getStatusMensagem()}
|
||||
<p class="text-xs text-base-content/60 truncate">
|
||||
{getStatusMensagem()}
|
||||
</p>
|
||||
{:else if getStatusConversa()}
|
||||
<p class="text-xs text-base-content/60">
|
||||
{getStatusConversa() === "online"
|
||||
? "Online"
|
||||
: getStatusConversa() === "ausente"
|
||||
? "Ausente"
|
||||
: getStatusConversa() === "em_reuniao"
|
||||
? "Em reunião"
|
||||
: getStatusConversa() === "externo"
|
||||
? "Externo"
|
||||
: "Offline"}
|
||||
</p>
|
||||
{:else if conversa() && (conversa()?.tipo === "grupo" || conversa()?.tipo === "sala_reuniao")}
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<p class="text-xs text-base-content/60">
|
||||
{conversa()?.participantesInfo?.length || 0}
|
||||
{conversa()?.participantesInfo?.length === 1
|
||||
? "participante"
|
||||
: "participantes"}
|
||||
</p>
|
||||
{#if conversa()?.participantesInfo && conversa()?.participantesInfo.length > 0}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex -space-x-2">
|
||||
{#each conversa()?.participantesInfo.slice(0, 5) as participante (participante._id)}
|
||||
<div
|
||||
class="relative w-5 h-5 rounded-full border-2 border-base-200 overflow-hidden bg-base-200"
|
||||
title={participante.nome}
|
||||
>
|
||||
{#if participante.fotoPerfilUrl}
|
||||
<img
|
||||
src={participante.fotoPerfilUrl}
|
||||
alt={participante.nome}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
{:else if participante.avatar}
|
||||
<img
|
||||
src={getAvatarUrl(participante.avatar)}
|
||||
alt={participante.nome}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<img
|
||||
src={getAvatarUrl(participante.nome)}
|
||||
alt={participante.nome}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if conversa()?.participantesInfo.length > 5}
|
||||
<div
|
||||
class="w-5 h-5 rounded-full border-2 border-base-200 bg-base-300 flex items-center justify-center text-[8px] font-semibold text-base-content/70"
|
||||
title={`+${conversa()?.participantesInfo.length - 5} mais`}
|
||||
>
|
||||
+{conversa()?.participantesInfo.length - 5}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if conversa()?.tipo === "sala_reuniao" && isAdmin?.data}
|
||||
<span
|
||||
class="text-[10px] text-primary font-semibold ml-1 whitespace-nowrap"
|
||||
title="Você é administrador desta sala">• Admin</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-base-content truncate font-semibold">
|
||||
{getNomeConversa()}
|
||||
</p>
|
||||
{#if getStatusMensagem()}
|
||||
<p class="text-base-content/60 truncate text-xs">
|
||||
{getStatusMensagem()}
|
||||
</p>
|
||||
{:else if getStatusConversa()}
|
||||
<p class="text-base-content/60 text-xs">
|
||||
{getStatusConversa() === 'online'
|
||||
? 'Online'
|
||||
: getStatusConversa() === 'ausente'
|
||||
? 'Ausente'
|
||||
: getStatusConversa() === 'em_reuniao'
|
||||
? 'Em reunião'
|
||||
: getStatusConversa() === 'externo'
|
||||
? 'Externo'
|
||||
: 'Offline'}
|
||||
</p>
|
||||
{:else if conversa() && (conversa()?.tipo === 'grupo' || conversa()?.tipo === 'sala_reuniao')}
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<p class="text-base-content/60 text-xs">
|
||||
{conversa()?.participantesInfo?.length || 0}
|
||||
{conversa()?.participantesInfo?.length === 1 ? 'participante' : 'participantes'}
|
||||
</p>
|
||||
{#if conversa()?.participantesInfo && conversa()?.participantesInfo.length > 0}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex -space-x-2">
|
||||
{#each conversa()?.participantesInfo.slice(0, 5) as participante (participante._id)}
|
||||
<div
|
||||
class="border-base-200 bg-base-200 relative h-5 w-5 overflow-hidden rounded-full border-2"
|
||||
title={participante.nome}
|
||||
>
|
||||
{#if participante.fotoPerfilUrl}
|
||||
<img
|
||||
src={participante.fotoPerfilUrl}
|
||||
alt={participante.nome}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{:else if participante.avatar}
|
||||
<img
|
||||
src={getAvatarUrl(participante.avatar)}
|
||||
alt={participante.nome}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<img
|
||||
src={getAvatarUrl(participante.nome)}
|
||||
alt={participante.nome}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if conversa()?.participantesInfo.length > 5}
|
||||
<div
|
||||
class="border-base-200 bg-base-300 text-base-content/70 flex h-5 w-5 items-center justify-center rounded-full border-2 text-[8px] font-semibold"
|
||||
title={`+${conversa()?.participantesInfo.length - 5} mais`}
|
||||
>
|
||||
+{conversa()?.participantesInfo.length - 5}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if conversa()?.tipo === 'sala_reuniao' && isAdmin?.data}
|
||||
<span
|
||||
class="text-primary ml-1 text-[10px] font-semibold whitespace-nowrap"
|
||||
title="Você é administrador desta sala">• Admin</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Botões de ação -->
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Botão Sair (apenas para grupos e salas de reunião) -->
|
||||
{#if conversa() && (conversa()?.tipo === "grupo" || conversa()?.tipo === "sala_reuniao")}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-300 group relative overflow-hidden"
|
||||
style="background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.2);"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSairGrupoOuSala();
|
||||
}}
|
||||
aria-label="Sair"
|
||||
title="Sair da conversa"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-red-500/0 group-hover:bg-red-500/10 transition-colors duration-300"
|
||||
></div>
|
||||
<LogOut
|
||||
class="w-5 h-5 text-red-500 relative z-10 group-hover:scale-110 transition-transform"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Botões de ação -->
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Botão Sair (apenas para grupos e salas de reunião) -->
|
||||
{#if conversa() && (conversa()?.tipo === 'grupo' || conversa()?.tipo === 'sala_reuniao')}
|
||||
<button
|
||||
type="button"
|
||||
class="group relative flex h-9 w-9 items-center justify-center overflow-hidden rounded-lg transition-all duration-300"
|
||||
style="background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.2);"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSairGrupoOuSala();
|
||||
}}
|
||||
aria-label="Sair"
|
||||
title="Sair da conversa"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-red-500/0 transition-colors duration-300 group-hover:bg-red-500/10"
|
||||
></div>
|
||||
<LogOut
|
||||
class="relative z-10 h-5 w-5 text-red-500 transition-transform group-hover:scale-110"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</button>
|
||||
{/if}
|
||||
<!-- Botão Menu Administrativo (apenas para salas de reunião e apenas para admins) -->
|
||||
{#if conversa()?.tipo === "sala_reuniao" && isAdmin?.data}
|
||||
<div class="relative admin-menu-container">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-300 group relative overflow-hidden"
|
||||
style="background: rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.2);"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
showAdminMenu = !showAdminMenu;
|
||||
}}
|
||||
aria-label="Menu administrativo"
|
||||
title="Recursos administrativos"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-blue-500/0 group-hover:bg-blue-500/10 transition-colors duration-300"
|
||||
></div>
|
||||
<MoreVertical
|
||||
class="w-5 h-5 text-blue-500 relative z-10 group-hover:scale-110 transition-transform"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</button>
|
||||
{#if showAdminMenu}
|
||||
<ul
|
||||
class="absolute right-0 top-full mt-2 bg-base-100 rounded-lg shadow-xl border border-base-300 w-56 z-[100] overflow-hidden"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-4 py-3 hover:bg-base-200 transition-colors flex items-center gap-2"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
showSalaManager = true;
|
||||
showAdminMenu = false;
|
||||
}}
|
||||
>
|
||||
<Users class="w-4 h-4" strokeWidth={2} />
|
||||
Gerenciar Participantes
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-4 py-3 hover:bg-base-200 transition-colors flex items-center gap-2"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
showNotificacaoModal = true;
|
||||
showAdminMenu = false;
|
||||
}}
|
||||
>
|
||||
<Bell class="w-4 h-4" strokeWidth={2} />
|
||||
Enviar Notificação
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-4 py-3 hover:bg-error/10 transition-colors flex items-center gap-2 text-error"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
(async () => {
|
||||
if (
|
||||
!confirm(
|
||||
"Tem certeza que deseja encerrar esta reunião? Todos os participantes serão removidos.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
const resultado = await client.mutation(
|
||||
api.chat.encerrarReuniao,
|
||||
{
|
||||
conversaId: conversaId as Id<"conversas">,
|
||||
},
|
||||
);
|
||||
if (resultado.sucesso) {
|
||||
alert("Reunião encerrada com sucesso!");
|
||||
voltarParaLista();
|
||||
} else {
|
||||
alert(resultado.erro || "Erro ao encerrar reunião");
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Erro ao encerrar reunião";
|
||||
alert(errorMessage);
|
||||
}
|
||||
showAdminMenu = false;
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<XCircle class="w-4 h-4" strokeWidth={2} />
|
||||
Encerrar Reunião
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Botão Menu Administrativo (apenas para salas de reunião e apenas para admins) -->
|
||||
{#if conversa()?.tipo === 'sala_reuniao' && isAdmin?.data}
|
||||
<div class="admin-menu-container relative">
|
||||
<button
|
||||
type="button"
|
||||
class="group relative flex h-9 w-9 items-center justify-center overflow-hidden rounded-lg transition-all duration-300"
|
||||
style="background: rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.2);"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
showAdminMenu = !showAdminMenu;
|
||||
}}
|
||||
aria-label="Menu administrativo"
|
||||
title="Recursos administrativos"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-blue-500/0 transition-colors duration-300 group-hover:bg-blue-500/10"
|
||||
></div>
|
||||
<MoreVertical
|
||||
class="relative z-10 h-5 w-5 text-blue-500 transition-transform group-hover:scale-110"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</button>
|
||||
{#if showAdminMenu}
|
||||
<ul
|
||||
class="bg-base-100 border-base-300 absolute top-full right-0 z-[100] mt-2 w-56 overflow-hidden rounded-lg border shadow-xl"
|
||||
>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:bg-base-200 flex w-full items-center gap-2 px-4 py-3 text-left transition-colors"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
showSalaManager = true;
|
||||
showAdminMenu = false;
|
||||
}}
|
||||
>
|
||||
<Users class="h-4 w-4" strokeWidth={2} />
|
||||
Gerenciar Participantes
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:bg-base-200 flex w-full items-center gap-2 px-4 py-3 text-left transition-colors"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
showNotificacaoModal = true;
|
||||
showAdminMenu = false;
|
||||
}}
|
||||
>
|
||||
<Bell class="h-4 w-4" strokeWidth={2} />
|
||||
Enviar Notificação
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:bg-error/10 text-error flex w-full items-center gap-2 px-4 py-3 text-left transition-colors"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
(async () => {
|
||||
if (
|
||||
!confirm(
|
||||
'Tem certeza que deseja encerrar esta reunião? Todos os participantes serão removidos.'
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
const resultado = await client.mutation(api.chat.encerrarReuniao, {
|
||||
conversaId: conversaId as Id<'conversas'>
|
||||
});
|
||||
if (resultado.sucesso) {
|
||||
alert('Reunião encerrada com sucesso!');
|
||||
voltarParaLista();
|
||||
} else {
|
||||
alert(resultado.erro || 'Erro ao encerrar reunião');
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Erro ao encerrar reunião';
|
||||
alert(errorMessage);
|
||||
}
|
||||
showAdminMenu = false;
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<XCircle class="h-4 w-4" strokeWidth={2} />
|
||||
Encerrar Reunião
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Botão Agendar MODERNO -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-300 group relative overflow-hidden"
|
||||
style="background: rgba(139, 92, 246, 0.1); border: 1px solid rgba(139, 92, 246, 0.2);"
|
||||
onclick={() => (showScheduleModal = true)}
|
||||
aria-label="Agendar mensagem"
|
||||
title="Agendar mensagem"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-purple-500/0 group-hover:bg-purple-500/10 transition-colors duration-300"
|
||||
></div>
|
||||
<Clock
|
||||
class="w-5 h-5 text-purple-500 relative z-10 group-hover:scale-110 transition-transform"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botão Agendar MODERNO -->
|
||||
<button
|
||||
type="button"
|
||||
class="group relative flex h-9 w-9 items-center justify-center overflow-hidden rounded-lg transition-all duration-300"
|
||||
style="background: rgba(139, 92, 246, 0.1); border: 1px solid rgba(139, 92, 246, 0.2);"
|
||||
onclick={() => (showScheduleModal = true)}
|
||||
aria-label="Agendar mensagem"
|
||||
title="Agendar mensagem"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-purple-500/0 transition-colors duration-300 group-hover:bg-purple-500/10"
|
||||
></div>
|
||||
<Clock
|
||||
class="relative z-10 h-5 w-5 text-purple-500 transition-transform group-hover:scale-110"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mensagens -->
|
||||
<div class="flex-1 overflow-hidden min-h-0">
|
||||
<MessageList conversaId={conversaId as Id<"conversas">} />
|
||||
</div>
|
||||
|
||||
<!-- Mensagens -->
|
||||
<div class="min-h-0 flex-1 overflow-hidden">
|
||||
<MessageList conversaId={conversaId as Id<'conversas'>} />
|
||||
</div>
|
||||
|
||||
<!-- Input -->
|
||||
<div class="border-base-300 shrink-0 border-t">
|
||||
<MessageInput conversaId={conversaId as Id<'conversas'>} />
|
||||
</div>
|
||||
<!-- Input -->
|
||||
<div class="border-t border-base-300 shrink-0">
|
||||
<MessageInput conversaId={conversaId as Id<"conversas">} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de Agendamento -->
|
||||
{#if showScheduleModal}
|
||||
<ScheduleMessageModal
|
||||
conversaId={conversaId as Id<'conversas'>}
|
||||
onClose={() => (showScheduleModal = false)}
|
||||
/>
|
||||
<ScheduleMessageModal
|
||||
conversaId={conversaId as Id<"conversas">}
|
||||
onClose={() => (showScheduleModal = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Modal de Gerenciamento de Sala -->
|
||||
{#if showSalaManager && conversa()?.tipo === 'sala_reuniao'}
|
||||
<SalaReuniaoManager
|
||||
conversaId={conversaId as Id<'conversas'>}
|
||||
isAdmin={isAdmin?.data ?? false}
|
||||
onClose={() => (showSalaManager = false)}
|
||||
/>
|
||||
{#if showSalaManager && conversa()?.tipo === "sala_reuniao"}
|
||||
<SalaReuniaoManager
|
||||
conversaId={conversaId as Id<"conversas">}
|
||||
isAdmin={isAdmin?.data ?? false}
|
||||
onClose={() => (showSalaManager = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Modal de Enviar Notificação -->
|
||||
{#if showNotificacaoModal && conversa()?.tipo === 'sala_reuniao' && isAdmin?.data}
|
||||
<dialog
|
||||
class="modal modal-open"
|
||||
onclick={(e) => e.target === e.currentTarget && (showNotificacaoModal = false)}
|
||||
>
|
||||
<div class="modal-box max-w-md" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4">
|
||||
<h2 class="flex items-center gap-2 text-xl font-semibold">
|
||||
<Bell class="text-primary h-5 w-5" />
|
||||
Enviar Notificação
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle"
|
||||
onclick={() => (showNotificacaoModal = false)}
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form
|
||||
onsubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const titulo = formData.get('titulo') as string;
|
||||
const mensagem = formData.get('mensagem') as string;
|
||||
{#if showNotificacaoModal && conversa()?.tipo === "sala_reuniao" && isAdmin?.data}
|
||||
<dialog
|
||||
class="modal modal-open"
|
||||
onclick={(e) =>
|
||||
e.target === e.currentTarget && (showNotificacaoModal = false)}
|
||||
>
|
||||
<div class="modal-box max-w-md" onclick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
class="flex items-center justify-between px-6 py-4 border-b border-base-300"
|
||||
>
|
||||
<h2 class="text-xl font-semibold flex items-center gap-2">
|
||||
<Bell class="w-5 h-5 text-primary" />
|
||||
Enviar Notificação
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm btn-circle"
|
||||
onclick={() => (showNotificacaoModal = false)}
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form
|
||||
onsubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const titulo = formData.get("titulo") as string;
|
||||
const mensagem = formData.get("mensagem") as string;
|
||||
|
||||
if (!titulo.trim() || !mensagem.trim()) {
|
||||
alert('Preencha todos os campos');
|
||||
return;
|
||||
}
|
||||
if (!titulo.trim() || !mensagem.trim()) {
|
||||
alert("Preencha todos os campos");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resultado = await client.mutation(api.chat.enviarNotificacaoReuniao, {
|
||||
conversaId: conversaId as Id<'conversas'>,
|
||||
titulo: titulo.trim(),
|
||||
mensagem: mensagem.trim()
|
||||
});
|
||||
try {
|
||||
const resultado = await client.mutation(
|
||||
api.chat.enviarNotificacaoReuniao,
|
||||
{
|
||||
conversaId: conversaId as Id<"conversas">,
|
||||
titulo: titulo.trim(),
|
||||
mensagem: mensagem.trim(),
|
||||
},
|
||||
);
|
||||
|
||||
if (resultado.sucesso) {
|
||||
alert('Notificação enviada com sucesso!');
|
||||
showNotificacaoModal = false;
|
||||
} else {
|
||||
alert(resultado.erro || 'Erro ao enviar notificação');
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Erro ao enviar notificação';
|
||||
alert(errorMessage);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Título</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="titulo"
|
||||
placeholder="Título da notificação"
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Mensagem</span>
|
||||
</label>
|
||||
<textarea
|
||||
name="mensagem"
|
||||
placeholder="Mensagem da notificação"
|
||||
class="textarea textarea-bordered w-full"
|
||||
rows="4"
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="btn flex-1" onclick={() => (showNotificacaoModal = false)}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary flex-1"> Enviar </button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button type="button" onclick={() => (showNotificacaoModal = false)}>fechar</button>
|
||||
</form>
|
||||
</dialog>
|
||||
if (resultado.sucesso) {
|
||||
alert("Notificação enviada com sucesso!");
|
||||
showNotificacaoModal = false;
|
||||
} else {
|
||||
alert(resultado.erro || "Erro ao enviar notificação");
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Erro ao enviar notificação";
|
||||
alert(errorMessage);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Título</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="titulo"
|
||||
placeholder="Título da notificação"
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Mensagem</span>
|
||||
</label>
|
||||
<textarea
|
||||
name="mensagem"
|
||||
placeholder="Mensagem da notificação"
|
||||
class="textarea textarea-bordered w-full"
|
||||
rows="4"
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost flex-1"
|
||||
onclick={() => (showNotificacaoModal = false)}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary flex-1">
|
||||
Enviar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button type="button" onclick={() => (showNotificacaoModal = false)}
|
||||
>fechar</button
|
||||
>
|
||||
</form>
|
||||
</dialog>
|
||||
{/if}
|
||||
|
||||
@@ -1,423 +1,454 @@
|
||||
<script lang="ts">
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { abrirConversa } from '$lib/stores/chatStore';
|
||||
import UserStatusBadge from './UserStatusBadge.svelte';
|
||||
import UserAvatar from './UserAvatar.svelte';
|
||||
import {
|
||||
MessageSquare,
|
||||
User,
|
||||
Users,
|
||||
Video,
|
||||
X,
|
||||
Search,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
UserX
|
||||
} from 'lucide-svelte';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { useQuery, useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import { abrirConversa } from "$lib/stores/chatStore";
|
||||
import UserStatusBadge from "./UserStatusBadge.svelte";
|
||||
import UserAvatar from "./UserAvatar.svelte";
|
||||
import {
|
||||
MessageSquare,
|
||||
User,
|
||||
Users,
|
||||
Video,
|
||||
X,
|
||||
Search,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
UserX,
|
||||
} from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { onClose }: Props = $props();
|
||||
let { onClose }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
const usuarios = useQuery(api.usuarios.listarParaChat, {});
|
||||
const meuPerfil = useQuery(api.usuarios.obterPerfil, {});
|
||||
// Usuário atual
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
const client = useConvexClient();
|
||||
const usuarios = useQuery(api.usuarios.listarParaChat, {});
|
||||
const meuPerfil = useQuery(api.usuarios.obterPerfil, {});
|
||||
// Usuário atual
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
|
||||
let activeTab = $state<'individual' | 'grupo' | 'sala_reuniao'>('individual');
|
||||
let searchQuery = $state('');
|
||||
let selectedUsers = $state<Id<'usuarios'>[]>([]);
|
||||
let groupName = $state('');
|
||||
let salaReuniaoName = $state('');
|
||||
let loading = $state(false);
|
||||
let activeTab = $state<"individual" | "grupo" | "sala_reuniao">("individual");
|
||||
let searchQuery = $state("");
|
||||
let selectedUsers = $state<string[]>([]);
|
||||
let groupName = $state("");
|
||||
let salaReuniaoName = $state("");
|
||||
let loading = $state(false);
|
||||
|
||||
const usuariosFiltrados = $derived(() => {
|
||||
if (!usuarios?.data) return [];
|
||||
const usuariosFiltrados = $derived(() => {
|
||||
if (!usuarios?.data) return [];
|
||||
|
||||
// Filtrar o próprio usuário
|
||||
const meuId = currentUser?.data?._id || meuPerfil?.data?._id;
|
||||
let lista = usuarios.data.filter((u) => u._id !== meuId);
|
||||
// Filtrar o próprio usuário
|
||||
const meuId = currentUser?.data?._id || meuPerfil?.data?._id;
|
||||
let lista = usuarios.data.filter((u: any) => u._id !== meuId);
|
||||
|
||||
// Aplicar busca
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
lista = lista.filter(
|
||||
(u) =>
|
||||
u.nome?.toLowerCase().includes(query) ||
|
||||
u.email?.toLowerCase().includes(query) ||
|
||||
u.matricula?.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
// Aplicar busca
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
lista = lista.filter(
|
||||
(u: any) =>
|
||||
u.nome?.toLowerCase().includes(query) ||
|
||||
u.email?.toLowerCase().includes(query) ||
|
||||
u.matricula?.toLowerCase().includes(query),
|
||||
);
|
||||
}
|
||||
|
||||
// Ordenar: online primeiro, depois por nome
|
||||
return lista.sort((a, b) => {
|
||||
const statusOrder = {
|
||||
online: 0,
|
||||
ausente: 1,
|
||||
externo: 2,
|
||||
em_reuniao: 3,
|
||||
offline: 4
|
||||
};
|
||||
const statusA = statusOrder[a.statusPresenca as keyof typeof statusOrder] ?? 4;
|
||||
const statusB = statusOrder[b.statusPresenca as keyof typeof statusOrder] ?? 4;
|
||||
// Ordenar: online primeiro, depois por nome
|
||||
return lista.sort((a: any, b: any) => {
|
||||
const statusOrder = {
|
||||
online: 0,
|
||||
ausente: 1,
|
||||
externo: 2,
|
||||
em_reuniao: 3,
|
||||
offline: 4,
|
||||
};
|
||||
const statusA =
|
||||
statusOrder[a.statusPresenca as keyof typeof statusOrder] ?? 4;
|
||||
const statusB =
|
||||
statusOrder[b.statusPresenca as keyof typeof statusOrder] ?? 4;
|
||||
|
||||
if (statusA !== statusB) return statusA - statusB;
|
||||
return (a.nome || '').localeCompare(b.nome || '');
|
||||
});
|
||||
});
|
||||
if (statusA !== statusB) return statusA - statusB;
|
||||
return (a.nome || "").localeCompare(b.nome || "");
|
||||
});
|
||||
});
|
||||
|
||||
function toggleUserSelection(userId: Id<'usuarios'>) {
|
||||
if (selectedUsers.includes(userId)) {
|
||||
selectedUsers = selectedUsers.filter((id) => id !== userId);
|
||||
} else {
|
||||
selectedUsers = [...selectedUsers, userId];
|
||||
}
|
||||
}
|
||||
function toggleUserSelection(userId: string) {
|
||||
if (selectedUsers.includes(userId)) {
|
||||
selectedUsers = selectedUsers.filter((id) => id !== userId);
|
||||
} else {
|
||||
selectedUsers = [...selectedUsers, userId];
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCriarIndividual(userId: Id<'usuarios'>) {
|
||||
try {
|
||||
loading = true;
|
||||
const conversaId = await client.mutation(api.chat.criarConversa, {
|
||||
tipo: 'individual',
|
||||
participantes: [userId]
|
||||
});
|
||||
abrirConversa(conversaId);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Erro ao criar conversa:', error);
|
||||
alert('Erro ao criar conversa');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
async function handleCriarIndividual(userId: string) {
|
||||
try {
|
||||
loading = true;
|
||||
const conversaId = await client.mutation(api.chat.criarConversa, {
|
||||
tipo: "individual",
|
||||
participantes: [userId as any],
|
||||
});
|
||||
abrirConversa(conversaId);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Erro ao criar conversa:", error);
|
||||
alert("Erro ao criar conversa");
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCriarGrupo() {
|
||||
console.log('selectedUsers', selectedUsers);
|
||||
async function handleCriarGrupo() {
|
||||
if (selectedUsers.length < 2) {
|
||||
alert("Selecione pelo menos 2 participantes");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedUsers.length < 2) {
|
||||
alert('Selecione pelo menos 2 participantes');
|
||||
return;
|
||||
}
|
||||
if (!groupName.trim()) {
|
||||
alert("Digite um nome para o grupo");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!groupName.trim()) {
|
||||
alert('Digite um nome para o grupo');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
loading = true;
|
||||
const conversaId = await client.mutation(api.chat.criarConversa, {
|
||||
tipo: "grupo",
|
||||
participantes: selectedUsers as any,
|
||||
nome: groupName.trim(),
|
||||
});
|
||||
abrirConversa(conversaId);
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
console.error("Erro ao criar grupo:", error);
|
||||
const mensagem =
|
||||
error?.message || error?.data || "Erro desconhecido ao criar grupo";
|
||||
alert(`Erro ao criar grupo: ${mensagem}`);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
loading = true;
|
||||
const conversaId = await client.mutation(api.chat.criarConversa, {
|
||||
tipo: 'grupo',
|
||||
participantes: selectedUsers,
|
||||
nome: groupName.trim()
|
||||
});
|
||||
abrirConversa(conversaId);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Erro ao criar grupo:', error);
|
||||
alert('Erro ao criar grupo');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
async function handleCriarSalaReuniao() {
|
||||
if (selectedUsers.length < 1) {
|
||||
alert("Selecione pelo menos 1 participante");
|
||||
return;
|
||||
}
|
||||
|
||||
async function handleCriarSalaReuniao() {
|
||||
if (selectedUsers.length < 1) {
|
||||
alert('Selecione pelo menos 1 participante');
|
||||
return;
|
||||
}
|
||||
if (!salaReuniaoName.trim()) {
|
||||
alert("Digite um nome para a sala de reunião");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!salaReuniaoName.trim()) {
|
||||
alert('Digite um nome para a sala de reunião');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
loading = true;
|
||||
const conversaId = await client.mutation(api.chat.criarSalaReuniao, {
|
||||
nome: salaReuniaoName.trim(),
|
||||
participantes: selectedUsers
|
||||
});
|
||||
abrirConversa(conversaId);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Erro ao criar sala de reunião:', error);
|
||||
alert('Erro ao criar sala de reunião');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
try {
|
||||
loading = true;
|
||||
const conversaId = await client.mutation(api.chat.criarSalaReuniao, {
|
||||
nome: salaReuniaoName.trim(),
|
||||
participantes: selectedUsers as any,
|
||||
});
|
||||
abrirConversa(conversaId);
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
console.error("Erro ao criar sala de reunião:", error);
|
||||
const mensagem =
|
||||
error?.message ||
|
||||
error?.data ||
|
||||
"Erro desconhecido ao criar sala de reunião";
|
||||
alert(`Erro ao criar sala de reunião: ${mensagem}`);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog class="modal modal-open">
|
||||
<div class="modal-box flex max-h-[85vh] max-w-2xl flex-col p-0">
|
||||
<!-- Header -->
|
||||
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4">
|
||||
<h2 class="flex items-center gap-2 text-2xl font-bold">
|
||||
<MessageSquare class="text-primary h-6 w-6" />
|
||||
Nova Conversa
|
||||
</h2>
|
||||
<button type="button" class="btn btn-sm btn-circle" onclick={onClose} aria-label="Fechar">
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<dialog
|
||||
class="modal modal-open"
|
||||
onclick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div
|
||||
class="modal-box max-w-2xl max-h-[85vh] flex flex-col p-0"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center justify-between px-6 py-4 border-b border-base-300"
|
||||
>
|
||||
<h2 class="text-2xl font-bold flex items-center gap-2">
|
||||
<MessageSquare class="w-6 h-6 text-primary" />
|
||||
Nova Conversa
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm btn-circle"
|
||||
onclick={onClose}
|
||||
aria-label="Fechar"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs melhoradas -->
|
||||
<div class="tabs tabs-boxed bg-base-200/50 p-4">
|
||||
<button
|
||||
type="button"
|
||||
class={`tab flex items-center gap-2 transition-all duration-200 ${
|
||||
activeTab === 'individual'
|
||||
? 'tab-active bg-primary text-primary-content font-semibold'
|
||||
: 'hover:bg-base-300'
|
||||
}`}
|
||||
onclick={() => {
|
||||
activeTab = 'individual';
|
||||
selectedUsers = [];
|
||||
searchQuery = '';
|
||||
}}
|
||||
>
|
||||
<User class="h-4 w-4" />
|
||||
Individual
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`tab flex items-center gap-2 transition-all duration-200 ${
|
||||
activeTab === 'grupo'
|
||||
? 'tab-active bg-primary text-primary-content font-semibold'
|
||||
: 'hover:bg-base-300'
|
||||
}`}
|
||||
onclick={() => {
|
||||
activeTab = 'grupo';
|
||||
selectedUsers = [];
|
||||
searchQuery = '';
|
||||
}}
|
||||
>
|
||||
<Users class="h-4 w-4" />
|
||||
Grupo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`tab flex items-center gap-2 transition-all duration-200 ${
|
||||
activeTab === 'sala_reuniao'
|
||||
? 'tab-active bg-primary text-primary-content font-semibold'
|
||||
: 'hover:bg-base-300'
|
||||
}`}
|
||||
onclick={() => {
|
||||
activeTab = 'sala_reuniao';
|
||||
selectedUsers = [];
|
||||
searchQuery = '';
|
||||
}}
|
||||
>
|
||||
<Video class="h-4 w-4" />
|
||||
Sala de Reunião
|
||||
</button>
|
||||
</div>
|
||||
<!-- Tabs melhoradas -->
|
||||
<div class="tabs tabs-boxed p-4 bg-base-200/50">
|
||||
<button
|
||||
type="button"
|
||||
class={`tab flex items-center gap-2 transition-all duration-200 ${
|
||||
activeTab === "individual"
|
||||
? "tab-active bg-primary text-primary-content font-semibold"
|
||||
: "hover:bg-base-300"
|
||||
}`}
|
||||
onclick={() => {
|
||||
activeTab = "individual";
|
||||
selectedUsers = [];
|
||||
searchQuery = "";
|
||||
}}
|
||||
>
|
||||
<User class="w-4 h-4" />
|
||||
Individual
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`tab flex items-center gap-2 transition-all duration-200 ${
|
||||
activeTab === "grupo"
|
||||
? "tab-active bg-primary text-primary-content font-semibold"
|
||||
: "hover:bg-base-300"
|
||||
}`}
|
||||
onclick={() => {
|
||||
activeTab = "grupo";
|
||||
selectedUsers = [];
|
||||
searchQuery = "";
|
||||
}}
|
||||
>
|
||||
<Users class="w-4 h-4" />
|
||||
Grupo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`tab flex items-center gap-2 transition-all duration-200 ${
|
||||
activeTab === "sala_reuniao"
|
||||
? "tab-active bg-primary text-primary-content font-semibold"
|
||||
: "hover:bg-base-300"
|
||||
}`}
|
||||
onclick={() => {
|
||||
activeTab = "sala_reuniao";
|
||||
selectedUsers = [];
|
||||
searchQuery = "";
|
||||
}}
|
||||
>
|
||||
<Video class="w-4 h-4" />
|
||||
Sala de Reunião
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4">
|
||||
{#if activeTab === 'grupo'}
|
||||
<!-- Criar Grupo -->
|
||||
<div class="mb-4">
|
||||
<div class="label pb-2">
|
||||
<span class="label-text font-semibold">Nome do Grupo</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Digite o nome do grupo..."
|
||||
class="input input-bordered focus:input-primary w-full transition-colors"
|
||||
bind:value={groupName}
|
||||
maxlength="50"
|
||||
/>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4">
|
||||
{#if activeTab === "grupo"}
|
||||
<!-- Criar Grupo -->
|
||||
<div class="mb-4">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-semibold">Nome do Grupo</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Digite o nome do grupo..."
|
||||
class="input input-bordered w-full focus:input-primary transition-colors"
|
||||
bind:value={groupName}
|
||||
maxlength="50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="label pb-2">
|
||||
<span class="label-text font-semibold">
|
||||
Participantes {selectedUsers.length > 0
|
||||
? `(${selectedUsers.length} selecionado${selectedUsers.length > 1 ? 's' : ''})`
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if activeTab === 'sala_reuniao'}
|
||||
<!-- Criar Sala de Reunião -->
|
||||
<div class="mb-4">
|
||||
<div class="label pb-2">
|
||||
<span class="label-text font-semibold">Nome da Sala de Reunião</span>
|
||||
<span class="label-text-alt text-primary font-medium">👑 Você será o administrador</span
|
||||
>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Digite o nome da sala de reunião..."
|
||||
class="input input-bordered focus:input-primary w-full transition-colors"
|
||||
bind:value={salaReuniaoName}
|
||||
maxlength="50"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-semibold">
|
||||
Participantes {selectedUsers.length > 0
|
||||
? `(${selectedUsers.length} selecionado${selectedUsers.length > 1 ? "s" : ""})`
|
||||
: ""}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{:else if activeTab === "sala_reuniao"}
|
||||
<!-- Criar Sala de Reunião -->
|
||||
<div class="mb-4">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-semibold">Nome da Sala de Reunião</span
|
||||
>
|
||||
<span class="label-text-alt text-primary font-medium"
|
||||
>👑 Você será o administrador</span
|
||||
>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Digite o nome da sala de reunião..."
|
||||
class="input input-bordered w-full focus:input-primary transition-colors"
|
||||
bind:value={salaReuniaoName}
|
||||
maxlength="50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="label pb-2">
|
||||
<span class="label-text font-semibold">
|
||||
Participantes {selectedUsers.length > 0
|
||||
? `(${selectedUsers.length} selecionado${selectedUsers.length > 1 ? 's' : ''})`
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mb-3">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-semibold">
|
||||
Participantes {selectedUsers.length > 0
|
||||
? `(${selectedUsers.length} selecionado${selectedUsers.length > 1 ? "s" : ""})`
|
||||
: ""}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Search melhorado -->
|
||||
<div class="relative mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar usuários por nome, email ou matrícula..."
|
||||
class="input input-bordered focus:input-primary w-full pl-10 transition-colors"
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
<Search class="text-base-content/40 absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2" />
|
||||
</div>
|
||||
<!-- Search melhorado -->
|
||||
<div class="mb-4 relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar usuários por nome, email ou matrícula..."
|
||||
class="input input-bordered w-full pl-10 focus:input-primary transition-colors"
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
<Search
|
||||
class="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-base-content/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Lista de usuários -->
|
||||
<div class="space-y-2">
|
||||
{#if usuarios?.data && usuariosFiltrados().length > 0}
|
||||
{#each usuariosFiltrados() as usuario (usuario._id)}
|
||||
{@const isSelected = selectedUsers.includes(usuario._id)}
|
||||
<button
|
||||
type="button"
|
||||
class={`flex w-full items-center gap-3 rounded-xl border-2 px-4 py-3 text-left transition-all duration-200 ${
|
||||
isSelected
|
||||
? 'border-primary bg-primary/10 scale-[1.02] shadow-md'
|
||||
: 'border-base-300 hover:bg-base-200 hover:border-primary/30 hover:shadow-sm'
|
||||
} ${loading ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
|
||||
onclick={() => {
|
||||
if (loading) return;
|
||||
if (activeTab === 'individual') {
|
||||
handleCriarIndividual(usuario._id);
|
||||
} else {
|
||||
toggleUserSelection(usuario._id);
|
||||
}
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div class="relative shrink-0">
|
||||
<UserAvatar
|
||||
avatar={usuario.avatar}
|
||||
fotoPerfilUrl={usuario.fotoPerfilUrl}
|
||||
nome={usuario.nome}
|
||||
size="md"
|
||||
/>
|
||||
<div class="absolute -right-1 -bottom-1">
|
||||
<UserStatusBadge status={usuario.statusPresenca || 'offline'} size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Lista de usuários -->
|
||||
<div class="space-y-2">
|
||||
{#if usuarios?.data && usuariosFiltrados().length > 0}
|
||||
{#each usuariosFiltrados() as usuario (usuario._id)}
|
||||
{@const isSelected = selectedUsers.includes(usuario._id)}
|
||||
<button
|
||||
type="button"
|
||||
class={`w-full text-left px-4 py-3 rounded-xl border-2 transition-all duration-200 flex items-center gap-3 ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/10 shadow-md scale-[1.02]"
|
||||
: "border-base-300 hover:bg-base-200 hover:border-primary/30 hover:shadow-sm"
|
||||
} ${loading ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
onclick={() => {
|
||||
if (loading) return;
|
||||
if (activeTab === "individual") {
|
||||
handleCriarIndividual(usuario._id);
|
||||
} else {
|
||||
toggleUserSelection(usuario._id);
|
||||
}
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div class="relative shrink-0">
|
||||
<UserAvatar
|
||||
avatar={usuario.avatar}
|
||||
fotoPerfilUrl={usuario.fotoPerfilUrl}
|
||||
nome={usuario.nome}
|
||||
size="md"
|
||||
/>
|
||||
<div class="absolute -bottom-1 -right-1">
|
||||
<UserStatusBadge
|
||||
status={usuario.statusPresenca || "offline"}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-base-content truncate font-semibold">
|
||||
{usuario.nome}
|
||||
</p>
|
||||
<p class="text-base-content/60 truncate text-sm">
|
||||
{usuario.email || usuario.matricula || 'Sem informações'}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-semibold text-base-content truncate">
|
||||
{usuario.nome}
|
||||
</p>
|
||||
<p class="text-sm text-base-content/60 truncate">
|
||||
{usuario.setor ||
|
||||
usuario.email ||
|
||||
usuario.matricula ||
|
||||
"Sem informações"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Checkbox melhorado (para grupo e sala de reunião) -->
|
||||
{#if activeTab === 'grupo' || activeTab === 'sala_reuniao'}
|
||||
<div class="shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary checkbox-lg"
|
||||
checked={isSelected}
|
||||
tabindex="-1"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!loading) toggleUserSelection(usuario._id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Ícone de seta para individual -->
|
||||
<ChevronRight class="text-base-content/40 h-5 w-5" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{:else if !usuarios?.data}
|
||||
<div class="flex flex-col items-center justify-center py-12">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
<p class="text-base-content/60 mt-4">Carregando usuários...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<UserX class="text-base-content/30 mb-4 h-16 w-16" />
|
||||
<p class="text-base-content/70 font-medium">
|
||||
{searchQuery.trim() ? 'Nenhum usuário encontrado' : 'Nenhum usuário disponível'}
|
||||
</p>
|
||||
{#if searchQuery.trim()}
|
||||
<p class="text-base-content/50 mt-2 text-sm">
|
||||
Tente buscar por nome, email ou matrícula
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Checkbox melhorado (para grupo e sala de reunião) -->
|
||||
{#if activeTab === "grupo" || activeTab === "sala_reuniao"}
|
||||
<div class="shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary checkbox-lg"
|
||||
checked={isSelected}
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Ícone de seta para individual -->
|
||||
<ChevronRight class="w-5 h-5 text-base-content/40" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{:else if !usuarios?.data}
|
||||
<div class="flex flex-col items-center justify-center py-12">
|
||||
<span class="loading loading-spinner loading-lg text-primary"
|
||||
></span>
|
||||
<p class="mt-4 text-base-content/60">Carregando usuários...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="flex flex-col items-center justify-center py-12 text-center"
|
||||
>
|
||||
<UserX class="w-16 h-16 text-base-content/30 mb-4" />
|
||||
<p class="text-base-content/70 font-medium">
|
||||
{searchQuery.trim()
|
||||
? "Nenhum usuário encontrado"
|
||||
: "Nenhum usuário disponível"}
|
||||
</p>
|
||||
{#if searchQuery.trim()}
|
||||
<p class="text-sm text-base-content/50 mt-2">
|
||||
Tente buscar por nome, email ou matrícula
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer (para grupo e sala de reunião) -->
|
||||
{#if activeTab === 'grupo'}
|
||||
<div class="border-base-300 bg-base-200/50 border-t px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-block btn-lg font-semibold shadow-lg transition-all duration-200 hover:shadow-xl"
|
||||
onclick={handleCriarGrupo}
|
||||
disabled={loading || selectedUsers.length < 2 || !groupName.trim()}
|
||||
>
|
||||
{#if loading}
|
||||
<span class="loading loading-spinner"></span>
|
||||
Criando grupo...
|
||||
{:else}
|
||||
<Plus class="h-5 w-5" />
|
||||
Criar Grupo
|
||||
{/if}
|
||||
</button>
|
||||
{#if selectedUsers.length < 2 && activeTab === 'grupo'}
|
||||
<p class="text-base-content/50 mt-2 text-center text-xs">
|
||||
Selecione pelo menos 2 participantes
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if activeTab === 'sala_reuniao'}
|
||||
<div class="border-base-300 bg-base-200/50 border-t px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-block btn-lg font-semibold shadow-lg transition-all duration-200 hover:shadow-xl"
|
||||
onclick={handleCriarSalaReuniao}
|
||||
disabled={loading || selectedUsers.length < 1 || !salaReuniaoName.trim()}
|
||||
>
|
||||
{#if loading}
|
||||
<span class="loading loading-spinner"></span>
|
||||
Criando sala...
|
||||
{:else}
|
||||
<Plus class="h-5 w-5" />
|
||||
Criar Sala de Reunião
|
||||
{/if}
|
||||
</button>
|
||||
{#if selectedUsers.length < 1 && activeTab === 'sala_reuniao'}
|
||||
<p class="text-base-content/50 mt-2 text-center text-xs">
|
||||
Selecione pelo menos 1 participante
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button type="button" onclick={onClose}>fechar</button>
|
||||
</form>
|
||||
<!-- Footer (para grupo e sala de reunião) -->
|
||||
{#if activeTab === "grupo"}
|
||||
<div class="px-6 py-4 border-t border-base-300 bg-base-200/50">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-block btn-lg font-semibold shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
onclick={handleCriarGrupo}
|
||||
disabled={loading || selectedUsers.length < 2 || !groupName.trim()}
|
||||
>
|
||||
{#if loading}
|
||||
<span class="loading loading-spinner"></span>
|
||||
Criando grupo...
|
||||
{:else}
|
||||
<Plus class="w-5 h-5" />
|
||||
Criar Grupo
|
||||
{/if}
|
||||
</button>
|
||||
{#if selectedUsers.length < 2 && activeTab === "grupo"}
|
||||
<p class="text-xs text-base-content/50 text-center mt-2">
|
||||
Selecione pelo menos 2 participantes
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if activeTab === "sala_reuniao"}
|
||||
<div class="px-6 py-4 border-t border-base-300 bg-base-200/50">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-block btn-lg font-semibold shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
onclick={handleCriarSalaReuniao}
|
||||
disabled={loading ||
|
||||
selectedUsers.length < 1 ||
|
||||
!salaReuniaoName.trim()}
|
||||
>
|
||||
{#if loading}
|
||||
<span class="loading loading-spinner"></span>
|
||||
Criando sala...
|
||||
{:else}
|
||||
<Plus class="w-5 h-5" />
|
||||
Criar Sala de Reunião
|
||||
{/if}
|
||||
</button>
|
||||
{#if selectedUsers.length < 1 && activeTab === "sala_reuniao"}
|
||||
<p class="text-xs text-base-content/50 text-center mt-2">
|
||||
Selecione pelo menos 1 participante
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button type="button" onclick={onClose}>fechar</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
@@ -1,435 +1,487 @@
|
||||
<script lang="ts">
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import UserAvatar from './UserAvatar.svelte';
|
||||
import UserStatusBadge from './UserStatusBadge.svelte';
|
||||
import { X, Users, UserPlus, ArrowUp, ArrowDown, Trash2, Search } from 'lucide-svelte';
|
||||
import { useQuery, useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
import UserAvatar from "./UserAvatar.svelte";
|
||||
import UserStatusBadge from "./UserStatusBadge.svelte";
|
||||
import {
|
||||
X,
|
||||
Users,
|
||||
UserPlus,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Trash2,
|
||||
Search,
|
||||
} from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
conversaId: Id<'conversas'>;
|
||||
isAdmin: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
interface Props {
|
||||
conversaId: Id<"conversas">;
|
||||
isAdmin: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { conversaId, isAdmin, onClose }: Props = $props();
|
||||
let { conversaId, isAdmin, onClose }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
const conversas = useQuery(api.chat.listarConversas, {});
|
||||
const todosUsuariosQuery = useQuery(api.chat.listarTodosUsuarios, {});
|
||||
const client = useConvexClient();
|
||||
const conversas = useQuery(api.chat.listarConversas, {});
|
||||
const todosUsuariosQuery = useQuery(api.chat.listarTodosUsuarios, {});
|
||||
|
||||
let activeTab = $state<'participantes' | 'adicionar'>('participantes');
|
||||
let searchQuery = $state('');
|
||||
let loading = $state<string | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
let activeTab = $state<"participantes" | "adicionar">("participantes");
|
||||
let searchQuery = $state("");
|
||||
let loading = $state<string | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const conversa = $derived(() => {
|
||||
if (!conversas?.data) return null;
|
||||
return conversas.data.find((c: any) => c._id === conversaId);
|
||||
});
|
||||
const conversa = $derived(() => {
|
||||
if (!conversas?.data) return null;
|
||||
return conversas.data.find((c: any) => c._id === conversaId);
|
||||
});
|
||||
|
||||
const todosUsuarios = $derived(() => {
|
||||
return todosUsuariosQuery?.data || [];
|
||||
});
|
||||
const todosUsuarios = $derived(() => {
|
||||
return todosUsuariosQuery?.data || [];
|
||||
});
|
||||
|
||||
const participantes = $derived(() => {
|
||||
try {
|
||||
const conv = conversa();
|
||||
const usuarios = todosUsuarios();
|
||||
if (!conv || !usuarios || usuarios.length === 0) return [];
|
||||
const participantes = $derived(() => {
|
||||
try {
|
||||
const conv = conversa();
|
||||
const usuarios = todosUsuarios();
|
||||
if (!conv || !usuarios || usuarios.length === 0) return [];
|
||||
|
||||
const participantesInfo = conv.participantesInfo || [];
|
||||
if (!Array.isArray(participantesInfo) || participantesInfo.length === 0) return [];
|
||||
const participantesInfo = conv.participantesInfo || [];
|
||||
if (!Array.isArray(participantesInfo) || participantesInfo.length === 0)
|
||||
return [];
|
||||
|
||||
return participantesInfo
|
||||
.map((p: any) => {
|
||||
try {
|
||||
// p pode ser um objeto com _id ou apenas um ID
|
||||
const participanteId = p?._id || p;
|
||||
if (!participanteId) return null;
|
||||
return participantesInfo
|
||||
.map((p: any) => {
|
||||
try {
|
||||
// p pode ser um objeto com _id ou apenas um ID
|
||||
const participanteId = p?._id || p;
|
||||
if (!participanteId) return null;
|
||||
|
||||
const usuario = usuarios.find((u: any) => {
|
||||
try {
|
||||
return String(u?._id) === String(participanteId);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (!usuario) return null;
|
||||
const usuario = usuarios.find((u: any) => {
|
||||
try {
|
||||
return String(u?._id) === String(participanteId);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (!usuario) return null;
|
||||
|
||||
// Combinar dados do usuário com dados do participante (se p for objeto)
|
||||
return {
|
||||
...usuario,
|
||||
...(typeof p === 'object' && p !== null && p !== undefined ? p : {}),
|
||||
// Garantir que _id existe e priorizar o do usuario
|
||||
_id: usuario._id
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Erro ao processar participante:', err, p);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((p: any) => p !== null && p._id);
|
||||
} catch (err) {
|
||||
console.error('Erro ao calcular participantes:', err);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
// Combinar dados do usuário com dados do participante (se p for objeto)
|
||||
return {
|
||||
...usuario,
|
||||
...(typeof p === "object" && p !== null && p !== undefined
|
||||
? p
|
||||
: {}),
|
||||
// Garantir que _id existe e priorizar o do usuario
|
||||
_id: usuario._id,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("Erro ao processar participante:", err, p);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((p: any) => p !== null && p._id);
|
||||
} catch (err) {
|
||||
console.error("Erro ao calcular participantes:", err);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const administradoresIds = $derived(() => {
|
||||
return conversa()?.administradores || [];
|
||||
});
|
||||
const administradoresIds = $derived(() => {
|
||||
return conversa()?.administradores || [];
|
||||
});
|
||||
|
||||
const usuariosDisponiveis = $derived(() => {
|
||||
const usuarios = todosUsuarios();
|
||||
if (!usuarios || usuarios.length === 0) return [];
|
||||
const participantesIds = conversa()?.participantes || [];
|
||||
return usuarios.filter(
|
||||
(u: any) => !participantesIds.some((pid: any) => String(pid) === String(u._id))
|
||||
);
|
||||
});
|
||||
const usuariosDisponiveis = $derived(() => {
|
||||
const usuarios = todosUsuarios();
|
||||
if (!usuarios || usuarios.length === 0) return [];
|
||||
const participantesIds = conversa()?.participantes || [];
|
||||
return usuarios.filter(
|
||||
(u: any) =>
|
||||
!participantesIds.some((pid: any) => String(pid) === String(u._id)),
|
||||
);
|
||||
});
|
||||
|
||||
const usuariosFiltrados = $derived(() => {
|
||||
const disponiveis = usuariosDisponiveis();
|
||||
if (!searchQuery.trim()) return disponiveis;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return disponiveis.filter(
|
||||
(u: any) =>
|
||||
(u.nome || '').toLowerCase().includes(query) ||
|
||||
(u.email || '').toLowerCase().includes(query) ||
|
||||
(u.matricula || '').toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
const usuariosFiltrados = $derived(() => {
|
||||
const disponiveis = usuariosDisponiveis();
|
||||
if (!searchQuery.trim()) return disponiveis;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return disponiveis.filter(
|
||||
(u: any) =>
|
||||
(u.nome || "").toLowerCase().includes(query) ||
|
||||
(u.email || "").toLowerCase().includes(query) ||
|
||||
(u.matricula || "").toLowerCase().includes(query),
|
||||
);
|
||||
});
|
||||
|
||||
function isParticipanteAdmin(usuarioId: string): boolean {
|
||||
const admins = administradoresIds();
|
||||
return admins.some((adminId: any) => String(adminId) === String(usuarioId));
|
||||
}
|
||||
function isParticipanteAdmin(usuarioId: string): boolean {
|
||||
const admins = administradoresIds();
|
||||
return admins.some((adminId: any) => String(adminId) === String(usuarioId));
|
||||
}
|
||||
|
||||
function isCriador(usuarioId: string): boolean {
|
||||
const criadoPor = conversa()?.criadoPor;
|
||||
return criadoPor ? String(criadoPor) === String(usuarioId) : false;
|
||||
}
|
||||
function isCriador(usuarioId: string): boolean {
|
||||
const criadoPor = conversa()?.criadoPor;
|
||||
return criadoPor ? String(criadoPor) === String(usuarioId) : false;
|
||||
}
|
||||
|
||||
async function removerParticipante(participanteId: string) {
|
||||
if (!confirm('Tem certeza que deseja remover este participante?')) return;
|
||||
async function removerParticipante(participanteId: string) {
|
||||
if (!confirm("Tem certeza que deseja remover este participante?")) return;
|
||||
|
||||
try {
|
||||
loading = `remover-${participanteId}`;
|
||||
error = null;
|
||||
const resultado = await client.mutation(api.chat.removerParticipanteSala, {
|
||||
conversaId,
|
||||
participanteId: participanteId as any
|
||||
});
|
||||
try {
|
||||
loading = `remover-${participanteId}`;
|
||||
error = null;
|
||||
const resultado = await client.mutation(
|
||||
api.chat.removerParticipanteSala,
|
||||
{
|
||||
conversaId,
|
||||
participanteId: participanteId as any,
|
||||
},
|
||||
);
|
||||
|
||||
if (!resultado.sucesso) {
|
||||
error = resultado.erro || 'Erro ao remover participante';
|
||||
}
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Erro ao remover participante';
|
||||
} finally {
|
||||
loading = null;
|
||||
}
|
||||
}
|
||||
if (!resultado.sucesso) {
|
||||
error = resultado.erro || "Erro ao remover participante";
|
||||
}
|
||||
} catch (err: any) {
|
||||
error = err.message || "Erro ao remover participante";
|
||||
} finally {
|
||||
loading = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function promoverAdmin(participanteId: string) {
|
||||
if (!confirm('Promover este participante a administrador?')) return;
|
||||
async function promoverAdmin(participanteId: string) {
|
||||
if (!confirm("Promover este participante a administrador?")) return;
|
||||
|
||||
try {
|
||||
loading = `promover-${participanteId}`;
|
||||
error = null;
|
||||
const resultado = await client.mutation(api.chat.promoverAdministrador, {
|
||||
conversaId,
|
||||
participanteId: participanteId as any
|
||||
});
|
||||
try {
|
||||
loading = `promover-${participanteId}`;
|
||||
error = null;
|
||||
const resultado = await client.mutation(api.chat.promoverAdministrador, {
|
||||
conversaId,
|
||||
participanteId: participanteId as any,
|
||||
});
|
||||
|
||||
if (!resultado.sucesso) {
|
||||
error = resultado.erro || 'Erro ao promover administrador';
|
||||
}
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Erro ao promover administrador';
|
||||
} finally {
|
||||
loading = null;
|
||||
}
|
||||
}
|
||||
if (!resultado.sucesso) {
|
||||
error = resultado.erro || "Erro ao promover administrador";
|
||||
}
|
||||
} catch (err: any) {
|
||||
error = err.message || "Erro ao promover administrador";
|
||||
} finally {
|
||||
loading = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function rebaixarAdmin(participanteId: string) {
|
||||
if (!confirm('Rebaixar este administrador a participante?')) return;
|
||||
async function rebaixarAdmin(participanteId: string) {
|
||||
if (!confirm("Rebaixar este administrador a participante?")) return;
|
||||
|
||||
try {
|
||||
loading = `rebaixar-${participanteId}`;
|
||||
error = null;
|
||||
const resultado = await client.mutation(api.chat.rebaixarAdministrador, {
|
||||
conversaId,
|
||||
participanteId: participanteId as any
|
||||
});
|
||||
try {
|
||||
loading = `rebaixar-${participanteId}`;
|
||||
error = null;
|
||||
const resultado = await client.mutation(api.chat.rebaixarAdministrador, {
|
||||
conversaId,
|
||||
participanteId: participanteId as any,
|
||||
});
|
||||
|
||||
if (!resultado.sucesso) {
|
||||
error = resultado.erro || 'Erro ao rebaixar administrador';
|
||||
}
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Erro ao rebaixar administrador';
|
||||
} finally {
|
||||
loading = null;
|
||||
}
|
||||
}
|
||||
if (!resultado.sucesso) {
|
||||
error = resultado.erro || "Erro ao rebaixar administrador";
|
||||
}
|
||||
} catch (err: any) {
|
||||
error = err.message || "Erro ao rebaixar administrador";
|
||||
} finally {
|
||||
loading = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function adicionarParticipante(usuarioId: string) {
|
||||
try {
|
||||
loading = `adicionar-${usuarioId}`;
|
||||
error = null;
|
||||
const resultado = await client.mutation(api.chat.adicionarParticipanteSala, {
|
||||
conversaId,
|
||||
participanteId: usuarioId as any
|
||||
});
|
||||
async function adicionarParticipante(usuarioId: string) {
|
||||
try {
|
||||
loading = `adicionar-${usuarioId}`;
|
||||
error = null;
|
||||
const resultado = await client.mutation(
|
||||
api.chat.adicionarParticipanteSala,
|
||||
{
|
||||
conversaId,
|
||||
participanteId: usuarioId as any,
|
||||
},
|
||||
);
|
||||
|
||||
if (!resultado.sucesso) {
|
||||
error = resultado.erro || 'Erro ao adicionar participante';
|
||||
} else {
|
||||
searchQuery = '';
|
||||
}
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Erro ao adicionar participante';
|
||||
} finally {
|
||||
loading = null;
|
||||
}
|
||||
}
|
||||
if (!resultado.sucesso) {
|
||||
error = resultado.erro || "Erro ao adicionar participante";
|
||||
} else {
|
||||
searchQuery = "";
|
||||
}
|
||||
} catch (err: any) {
|
||||
error = err.message || "Erro ao adicionar participante";
|
||||
} finally {
|
||||
loading = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog class="modal modal-open" onclick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div
|
||||
class="modal-box flex max-h-[80vh] max-w-2xl flex-col p-0"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4">
|
||||
<div>
|
||||
<h2 class="flex items-center gap-2 text-xl font-semibold">
|
||||
<Users class="text-primary h-5 w-5" />
|
||||
Gerenciar Sala de Reunião
|
||||
</h2>
|
||||
<p class="text-base-content/60 text-sm">
|
||||
{conversa()?.nome || 'Sem nome'}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-circle" onclick={onClose} aria-label="Fechar">
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<dialog
|
||||
class="modal modal-open"
|
||||
onclick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div
|
||||
class="modal-box max-w-2xl max-h-[80vh] flex flex-col p-0"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center justify-between px-6 py-4 border-b border-base-300"
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold flex items-center gap-2">
|
||||
<Users class="w-5 h-5 text-primary" />
|
||||
Gerenciar Sala de Reunião
|
||||
</h2>
|
||||
<p class="text-sm text-base-content/60">
|
||||
{conversa()?.nome || "Sem nome"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm btn-circle"
|
||||
onclick={onClose}
|
||||
aria-label="Fechar"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
{#if isAdmin}
|
||||
<div class="tabs tabs-boxed p-4">
|
||||
<button
|
||||
type="button"
|
||||
class={`tab flex items-center gap-2 ${activeTab === 'participantes' ? 'tab-active' : ''}`}
|
||||
onclick={() => (activeTab = 'participantes')}
|
||||
>
|
||||
<Users class="h-4 w-4" />
|
||||
Participantes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`tab flex items-center gap-2 ${activeTab === 'adicionar' ? 'tab-active' : ''}`}
|
||||
onclick={() => (activeTab = 'adicionar')}
|
||||
>
|
||||
<UserPlus class="h-4 w-4" />
|
||||
Adicionar Participante
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Tabs -->
|
||||
{#if isAdmin}
|
||||
<div class="tabs tabs-boxed p-4">
|
||||
<button
|
||||
type="button"
|
||||
class={`tab flex items-center gap-2 ${activeTab === "participantes" ? "tab-active" : ""}`}
|
||||
onclick={() => (activeTab = "participantes")}
|
||||
>
|
||||
<Users class="w-4 h-4" />
|
||||
Participantes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`tab flex items-center gap-2 ${activeTab === "adicionar" ? "tab-active" : ""}`}
|
||||
onclick={() => (activeTab = "adicionar")}
|
||||
>
|
||||
<UserPlus class="w-4 h-4" />
|
||||
Adicionar Participante
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error Message -->
|
||||
{#if error}
|
||||
<div class="alert alert-error mx-6 mt-2">
|
||||
<span>{error}</span>
|
||||
<button type="button" class="btn btn-sm btn-ghost" onclick={() => (error = null)}>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Error Message -->
|
||||
{#if error}
|
||||
<div class="mx-6 mt-2 alert alert-error">
|
||||
<span>{error}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-ghost"
|
||||
onclick={() => (error = null)}
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto px-6">
|
||||
{#if !conversas?.data}
|
||||
<!-- Loading conversas -->
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
<span class="text-base-content/60 ml-2 text-sm">Carregando conversa...</span>
|
||||
</div>
|
||||
{:else if !todosUsuariosQuery?.data}
|
||||
<!-- Loading usuários -->
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
<span class="text-base-content/60 ml-2 text-sm">Carregando usuários...</span>
|
||||
</div>
|
||||
{:else if activeTab === 'participantes'}
|
||||
<!-- Lista de Participantes -->
|
||||
<div class="space-y-2 py-2">
|
||||
{#if participantes().length > 0}
|
||||
{#each participantes() as participante (String(participante._id))}
|
||||
{@const participanteId = String(participante._id)}
|
||||
{@const ehAdmin = isParticipanteAdmin(participanteId)}
|
||||
{@const ehCriador = isCriador(participanteId)}
|
||||
{@const isLoading = loading?.includes(participanteId)}
|
||||
<div
|
||||
class="border-base-300 hover:bg-base-200 flex items-center gap-3 rounded-lg border p-3 transition-colors"
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div class="relative shrink-0">
|
||||
<UserAvatar
|
||||
avatar={participante.avatar}
|
||||
fotoPerfilUrl={participante.fotoPerfilUrl || participante.fotoPerfil}
|
||||
nome={participante.nome || 'Usuário'}
|
||||
size="sm"
|
||||
/>
|
||||
<div class="absolute right-0 bottom-0">
|
||||
<UserStatusBadge status={participante.statusPresenca || 'offline'} size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto px-6">
|
||||
{#if !conversas?.data}
|
||||
<!-- Loading conversas -->
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
<span class="ml-2 text-sm text-base-content/60"
|
||||
>Carregando conversa...</span
|
||||
>
|
||||
</div>
|
||||
{:else if !todosUsuariosQuery?.data}
|
||||
<!-- Loading usuários -->
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
<span class="ml-2 text-sm text-base-content/60"
|
||||
>Carregando usuários...</span
|
||||
>
|
||||
</div>
|
||||
{:else if activeTab === "participantes"}
|
||||
<!-- Lista de Participantes -->
|
||||
<div class="space-y-2 py-2">
|
||||
{#if participantes().length > 0}
|
||||
{#each participantes() as participante (String(participante._id))}
|
||||
{@const participanteId = String(participante._id)}
|
||||
{@const ehAdmin = isParticipanteAdmin(participanteId)}
|
||||
{@const ehCriador = isCriador(participanteId)}
|
||||
{@const isLoading = loading?.includes(participanteId)}
|
||||
<div
|
||||
class="flex items-center gap-3 p-3 rounded-lg border border-base-300 hover:bg-base-200 transition-colors"
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div class="relative shrink-0">
|
||||
<UserAvatar
|
||||
avatar={participante.avatar}
|
||||
fotoPerfilUrl={participante.fotoPerfilUrl ||
|
||||
participante.fotoPerfil}
|
||||
nome={participante.nome || "Usuário"}
|
||||
size="sm"
|
||||
/>
|
||||
<div class="absolute bottom-0 right-0">
|
||||
<UserStatusBadge
|
||||
status={participante.statusPresenca || "offline"}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-base-content truncate font-medium">
|
||||
{participante.nome || 'Usuário'}
|
||||
</p>
|
||||
{#if ehAdmin}
|
||||
<span class="badge badge-primary badge-sm">Admin</span>
|
||||
{/if}
|
||||
{#if ehCriador}
|
||||
<span class="badge badge-secondary badge-sm">Criador</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-base-content/60 truncate text-sm">
|
||||
{participante.setor || participante.email || ''}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="font-medium text-base-content truncate">
|
||||
{participante.nome || "Usuário"}
|
||||
</p>
|
||||
{#if ehAdmin}
|
||||
<span class="badge badge-primary badge-sm">Admin</span>
|
||||
{/if}
|
||||
{#if ehCriador}
|
||||
<span class="badge badge-secondary badge-sm">Criador</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm text-base-content/60 truncate">
|
||||
{participante.setor || participante.email || ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Ações (apenas para admins) -->
|
||||
{#if isAdmin && !ehCriador}
|
||||
<div class="flex items-center gap-1">
|
||||
{#if ehAdmin}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-ghost"
|
||||
onclick={() => rebaixarAdmin(participanteId)}
|
||||
disabled={isLoading}
|
||||
title="Rebaixar administrador"
|
||||
>
|
||||
{#if isLoading && loading?.includes('rebaixar')}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<ArrowDown class="h-4 w-4" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-ghost"
|
||||
onclick={() => promoverAdmin(participanteId)}
|
||||
disabled={isLoading}
|
||||
title="Promover a administrador"
|
||||
>
|
||||
{#if isLoading && loading?.includes('promover')}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<ArrowUp class="h-4 w-4" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-error btn-ghost"
|
||||
onclick={() => removerParticipante(participanteId)}
|
||||
disabled={isLoading}
|
||||
title="Remover participante"
|
||||
>
|
||||
{#if isLoading && loading?.includes('remover')}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<Trash2 class="h-4 w-4" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="text-base-content/50 py-8 text-center">Nenhum participante encontrado</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if activeTab === 'adicionar' && isAdmin}
|
||||
<!-- Adicionar Participante -->
|
||||
<div class="relative mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar usuários..."
|
||||
class="input input-bordered w-full pl-10"
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
<Search class="text-base-content/40 absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2" />
|
||||
</div>
|
||||
<!-- Ações (apenas para admins) -->
|
||||
{#if isAdmin && !ehCriador}
|
||||
<div class="flex items-center gap-1">
|
||||
{#if ehAdmin}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-ghost"
|
||||
onclick={() => rebaixarAdmin(participanteId)}
|
||||
disabled={isLoading}
|
||||
title="Rebaixar administrador"
|
||||
>
|
||||
{#if isLoading && loading?.includes("rebaixar")}
|
||||
<span class="loading loading-spinner loading-xs"
|
||||
></span>
|
||||
{:else}
|
||||
<ArrowDown class="w-4 h-4" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-ghost"
|
||||
onclick={() => promoverAdmin(participanteId)}
|
||||
disabled={isLoading}
|
||||
title="Promover a administrador"
|
||||
>
|
||||
{#if isLoading && loading?.includes("promover")}
|
||||
<span class="loading loading-spinner loading-xs"
|
||||
></span>
|
||||
{:else}
|
||||
<ArrowUp class="w-4 h-4" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-error btn-ghost"
|
||||
onclick={() => removerParticipante(participanteId)}
|
||||
disabled={isLoading}
|
||||
title="Remover participante"
|
||||
>
|
||||
{#if isLoading && loading?.includes("remover")}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<Trash2 class="w-4 h-4" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="text-center py-8 text-base-content/50">
|
||||
Nenhum participante encontrado
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if activeTab === "adicionar" && isAdmin}
|
||||
<!-- Adicionar Participante -->
|
||||
<div class="mb-4 relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar usuários..."
|
||||
class="input input-bordered w-full pl-10"
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
<Search
|
||||
class="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-base-content/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#if usuariosFiltrados().length > 0}
|
||||
{#each usuariosFiltrados() as usuario (String(usuario._id))}
|
||||
{@const usuarioId = String(usuario._id)}
|
||||
{@const isLoading = loading?.includes(usuarioId)}
|
||||
<button
|
||||
type="button"
|
||||
class="border-base-300 hover:bg-base-200 flex w-full items-center gap-3 rounded-lg border px-4 py-3 text-left transition-colors"
|
||||
onclick={() => adicionarParticipante(usuarioId)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div class="relative shrink-0">
|
||||
<UserAvatar
|
||||
avatar={usuario.avatar}
|
||||
fotoPerfilUrl={usuario.fotoPerfilUrl || usuario.fotoPerfil}
|
||||
nome={usuario.nome || 'Usuário'}
|
||||
size="sm"
|
||||
/>
|
||||
<div class="absolute right-0 bottom-0">
|
||||
<UserStatusBadge status={usuario.statusPresenca || 'offline'} size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{#if usuariosFiltrados().length > 0}
|
||||
{#each usuariosFiltrados() as usuario (String(usuario._id))}
|
||||
{@const usuarioId = String(usuario._id)}
|
||||
{@const isLoading = loading?.includes(usuarioId)}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-4 py-3 rounded-lg border border-base-300 hover:bg-base-200 transition-colors flex items-center gap-3"
|
||||
onclick={() => adicionarParticipante(usuarioId)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div class="relative shrink-0">
|
||||
<UserAvatar
|
||||
avatar={usuario.avatar}
|
||||
fotoPerfilUrl={usuario.fotoPerfilUrl || usuario.fotoPerfil}
|
||||
nome={usuario.nome || "Usuário"}
|
||||
size="sm"
|
||||
/>
|
||||
<div class="absolute bottom-0 right-0">
|
||||
<UserStatusBadge
|
||||
status={usuario.statusPresenca || "offline"}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-base-content truncate font-medium">
|
||||
{usuario.nome || 'Usuário'}
|
||||
</p>
|
||||
<p class="text-base-content/60 truncate text-sm">
|
||||
{usuario.setor || usuario.email || ''}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-base-content truncate">
|
||||
{usuario.nome || "Usuário"}
|
||||
</p>
|
||||
<p class="text-sm text-base-content/60 truncate">
|
||||
{usuario.setor || usuario.email || ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Botão Adicionar -->
|
||||
{#if isLoading}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{:else}
|
||||
<UserPlus class="text-primary h-5 w-5" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="text-base-content/50 py-8 text-center">
|
||||
{searchQuery.trim()
|
||||
? 'Nenhum usuário encontrado'
|
||||
: 'Todos os usuários já são participantes'}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Botão Adicionar -->
|
||||
{#if isLoading}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{:else}
|
||||
<UserPlus class="w-5 h-5 text-primary" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="text-center py-8 text-base-content/50">
|
||||
{searchQuery.trim()
|
||||
? "Nenhum usuário encontrado"
|
||||
: "Todos os usuários já são participantes"}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="border-base-300 border-t px-6 py-4">
|
||||
<button type="button" class="btn btn-block" onclick={onClose}> Fechar </button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button type="button" onclick={onClose}>fechar</button>
|
||||
</form>
|
||||
<!-- Footer -->
|
||||
<div class="px-6 py-4 border-t border-base-300">
|
||||
<button type="button" class="btn btn-block" onclick={onClose}>
|
||||
Fechar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button type="button" onclick={onClose}>fechar</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
@@ -1,269 +1,288 @@
|
||||
<script lang="ts">
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import { Clock, X, Trash2 } from 'lucide-svelte';
|
||||
import { useQuery, useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
import { format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { Clock, X, Trash2 } from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
conversaId: Id<'conversas'>;
|
||||
onClose: () => void;
|
||||
}
|
||||
interface Props {
|
||||
conversaId: Id<"conversas">;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { conversaId, onClose }: Props = $props();
|
||||
let { conversaId, onClose }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
const mensagensAgendadas = useQuery(api.chat.obterMensagensAgendadas, {
|
||||
conversaId
|
||||
});
|
||||
const client = useConvexClient();
|
||||
const mensagensAgendadas = useQuery(api.chat.obterMensagensAgendadas, {
|
||||
conversaId,
|
||||
});
|
||||
|
||||
let mensagem = $state('');
|
||||
let data = $state('');
|
||||
let hora = $state('');
|
||||
let loading = $state(false);
|
||||
let mensagem = $state("");
|
||||
let data = $state("");
|
||||
let hora = $state("");
|
||||
let loading = $state(false);
|
||||
|
||||
// Rastrear mudanças nas mensagens agendadas
|
||||
$effect(() => {
|
||||
console.log('📅 [ScheduleModal] Mensagens agendadas atualizadas:', mensagensAgendadas?.data);
|
||||
});
|
||||
// Rastrear mudanças nas mensagens agendadas
|
||||
$effect(() => {
|
||||
console.log(
|
||||
"📅 [ScheduleModal] Mensagens agendadas atualizadas:",
|
||||
mensagensAgendadas?.data,
|
||||
);
|
||||
});
|
||||
|
||||
// Definir data/hora mínima (agora)
|
||||
const now = new Date();
|
||||
const minDate = format(now, 'yyyy-MM-dd');
|
||||
const minTime = format(now, 'HH:mm');
|
||||
// Definir data/hora mínima (agora)
|
||||
const now = new Date();
|
||||
const minDate = format(now, "yyyy-MM-dd");
|
||||
const minTime = format(now, "HH:mm");
|
||||
|
||||
function getPreviewText(): string {
|
||||
if (!data || !hora) return '';
|
||||
function getPreviewText(): string {
|
||||
if (!data || !hora) return "";
|
||||
|
||||
try {
|
||||
const dataHora = new Date(`${data}T${hora}`);
|
||||
return `Será enviada em ${format(dataHora, "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
try {
|
||||
const dataHora = new Date(`${data}T${hora}`);
|
||||
return `Será enviada em ${format(dataHora, "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })}`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAgendar() {
|
||||
if (!mensagem.trim() || !data || !hora) {
|
||||
alert('Preencha todos os campos');
|
||||
return;
|
||||
}
|
||||
async function handleAgendar() {
|
||||
if (!mensagem.trim() || !data || !hora) {
|
||||
alert("Preencha todos os campos");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
loading = true;
|
||||
const dataHora = new Date(`${data}T${hora}`);
|
||||
try {
|
||||
loading = true;
|
||||
const dataHora = new Date(`${data}T${hora}`);
|
||||
|
||||
// Validar data futura
|
||||
if (dataHora.getTime() <= Date.now()) {
|
||||
alert('A data e hora devem ser futuras');
|
||||
return;
|
||||
}
|
||||
// Validar data futura
|
||||
if (dataHora.getTime() <= Date.now()) {
|
||||
alert("A data e hora devem ser futuras");
|
||||
return;
|
||||
}
|
||||
|
||||
await client.mutation(api.chat.agendarMensagem, {
|
||||
conversaId,
|
||||
conteudo: mensagem.trim(),
|
||||
agendadaPara: dataHora.getTime()
|
||||
});
|
||||
await client.mutation(api.chat.agendarMensagem, {
|
||||
conversaId,
|
||||
conteudo: mensagem.trim(),
|
||||
agendadaPara: dataHora.getTime(),
|
||||
});
|
||||
|
||||
mensagem = '';
|
||||
data = '';
|
||||
hora = '';
|
||||
mensagem = "";
|
||||
data = "";
|
||||
hora = "";
|
||||
|
||||
// Dar tempo para o Convex processar e recarregar a lista
|
||||
setTimeout(() => {
|
||||
alert('Mensagem agendada com sucesso!');
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error('Erro ao agendar mensagem:', error);
|
||||
alert('Erro ao agendar mensagem');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
// Dar tempo para o Convex processar e recarregar a lista
|
||||
setTimeout(() => {
|
||||
alert("Mensagem agendada com sucesso!");
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error("Erro ao agendar mensagem:", error);
|
||||
alert("Erro ao agendar mensagem");
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancelar(mensagemId: string) {
|
||||
if (!confirm('Deseja cancelar esta mensagem agendada?')) return;
|
||||
async function handleCancelar(mensagemId: string) {
|
||||
if (!confirm("Deseja cancelar esta mensagem agendada?")) return;
|
||||
|
||||
try {
|
||||
await client.mutation(api.chat.cancelarMensagemAgendada, {
|
||||
mensagemId: mensagemId as any
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erro ao cancelar mensagem:', error);
|
||||
alert('Erro ao cancelar mensagem');
|
||||
}
|
||||
}
|
||||
try {
|
||||
await client.mutation(api.chat.cancelarMensagemAgendada, {
|
||||
mensagemId: mensagemId as any,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erro ao cancelar mensagem:", error);
|
||||
alert("Erro ao cancelar mensagem");
|
||||
}
|
||||
}
|
||||
|
||||
function formatarDataHora(timestamp: number): string {
|
||||
try {
|
||||
return format(new Date(timestamp), "dd/MM/yyyy 'às' HH:mm", {
|
||||
locale: ptBR
|
||||
});
|
||||
} catch {
|
||||
return 'Data inválida';
|
||||
}
|
||||
}
|
||||
function formatarDataHora(timestamp: number): string {
|
||||
try {
|
||||
return format(new Date(timestamp), "dd/MM/yyyy 'às' HH:mm", {
|
||||
locale: ptBR,
|
||||
});
|
||||
} catch {
|
||||
return "Data inválida";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog class="modal modal-open" onclick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div
|
||||
class="modal-box flex max-h-[90vh] max-w-2xl flex-col p-0"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4">
|
||||
<h2 id="modal-title" class="flex items-center gap-2 text-xl font-bold">
|
||||
<Clock class="text-primary h-5 w-5" />
|
||||
Agendar Mensagem
|
||||
</h2>
|
||||
<button type="button" class="btn btn-sm btn-circle" onclick={onClose} aria-label="Fechar">
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<dialog
|
||||
class="modal modal-open"
|
||||
onclick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div
|
||||
class="modal-box max-w-2xl max-h-[90vh] flex flex-col p-0"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center justify-between px-6 py-4 border-b border-base-300"
|
||||
>
|
||||
<h2 id="modal-title" class="text-xl font-bold flex items-center gap-2">
|
||||
<Clock class="w-5 h-5 text-primary" />
|
||||
Agendar Mensagem
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm btn-circle"
|
||||
onclick={onClose}
|
||||
aria-label="Fechar"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 space-y-6 overflow-y-auto p-6">
|
||||
<!-- Formulário de Agendamento -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-lg">Nova Mensagem Agendada</h3>
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
<!-- Formulário de Agendamento -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-lg">Nova Mensagem Agendada</h3>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="mensagem-input">
|
||||
<span class="label-text">Mensagem</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="mensagem-input"
|
||||
class="textarea textarea-bordered h-24"
|
||||
placeholder="Digite a mensagem..."
|
||||
bind:value={mensagem}
|
||||
maxlength="500"
|
||||
aria-describedby="char-count"
|
||||
></textarea>
|
||||
<div class="label">
|
||||
<span id="char-count" class="label-text-alt">{mensagem.length}/500</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="mensagem-input">
|
||||
<span class="label-text">Mensagem</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="mensagem-input"
|
||||
class="textarea textarea-bordered h-24"
|
||||
placeholder="Digite a mensagem..."
|
||||
bind:value={mensagem}
|
||||
maxlength="500"
|
||||
aria-describedby="char-count"
|
||||
></textarea>
|
||||
<div class="label">
|
||||
<span id="char-count" class="label-text-alt"
|
||||
>{mensagem.length}/500</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="form-control">
|
||||
<label class="label" for="data-input">
|
||||
<span class="label-text">Data</span>
|
||||
</label>
|
||||
<input
|
||||
id="data-input"
|
||||
type="date"
|
||||
class="input input-bordered"
|
||||
bind:value={data}
|
||||
min={minDate}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="data-input">
|
||||
<span class="label-text">Data</span>
|
||||
</label>
|
||||
<input
|
||||
id="data-input"
|
||||
type="date"
|
||||
class="input input-bordered"
|
||||
bind:value={data}
|
||||
min={minDate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="hora-input">
|
||||
<span class="label-text">Hora</span>
|
||||
</label>
|
||||
<input
|
||||
id="hora-input"
|
||||
type="time"
|
||||
class="input input-bordered"
|
||||
bind:value={hora}
|
||||
min={data === minDate ? minTime : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="hora-input">
|
||||
<span class="label-text">Hora</span>
|
||||
</label>
|
||||
<input
|
||||
id="hora-input"
|
||||
type="time"
|
||||
class="input input-bordered"
|
||||
bind:value={hora}
|
||||
min={data === minDate ? minTime : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if getPreviewText()}
|
||||
<div class="alert alert-info">
|
||||
<Clock class="h-6 w-6" />
|
||||
<span>{getPreviewText()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if getPreviewText()}
|
||||
<div class="alert alert-info">
|
||||
<Clock class="w-6 h-6" />
|
||||
<span>{getPreviewText()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="card-actions justify-end">
|
||||
<!-- Botão AGENDAR ultra moderno -->
|
||||
<button
|
||||
type="button"
|
||||
class="group relative overflow-hidden rounded-xl px-6 py-3 font-bold text-white transition-all duration-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 8px 24px -4px rgba(102, 126, 234, 0.4);"
|
||||
onclick={handleAgendar}
|
||||
disabled={loading || !mensagem.trim() || !data || !hora}
|
||||
>
|
||||
<!-- Efeito de brilho no hover -->
|
||||
<div
|
||||
class="absolute inset-0 bg-white/0 transition-colors duration-300 group-hover:bg-white/10"
|
||||
></div>
|
||||
<div class="card-actions justify-end">
|
||||
<!-- Botão AGENDAR ultra moderno -->
|
||||
<button
|
||||
type="button"
|
||||
class="relative px-6 py-3 rounded-xl font-bold text-white overflow-hidden transition-all duration-300 group disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 8px 24px -4px rgba(102, 126, 234, 0.4);"
|
||||
onclick={handleAgendar}
|
||||
disabled={loading || !mensagem.trim() || !data || !hora}
|
||||
>
|
||||
<!-- Efeito de brilho no hover -->
|
||||
<div
|
||||
class="absolute inset-0 bg-white/0 group-hover:bg-white/10 transition-colors duration-300"
|
||||
></div>
|
||||
|
||||
<div class="relative z-10 flex items-center gap-2">
|
||||
{#if loading}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<span>Agendando...</span>
|
||||
{:else}
|
||||
<Clock class="h-5 w-5 transition-transform group-hover:scale-110" />
|
||||
<span class="transition-transform group-hover:scale-105">Agendar</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative z-10 flex items-center gap-2">
|
||||
{#if loading}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<span>Agendando...</span>
|
||||
{:else}
|
||||
<Clock
|
||||
class="w-5 h-5 group-hover:scale-110 transition-transform"
|
||||
/>
|
||||
<span class="group-hover:scale-105 transition-transform"
|
||||
>Agendar</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lista de Mensagens Agendadas -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-lg">Mensagens Agendadas</h3>
|
||||
<!-- Lista de Mensagens Agendadas -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-lg">Mensagens Agendadas</h3>
|
||||
|
||||
{#if mensagensAgendadas?.data && mensagensAgendadas.data.length > 0}
|
||||
<div class="space-y-3">
|
||||
{#each mensagensAgendadas.data as msg (msg._id)}
|
||||
<div class="bg-base-100 flex items-start gap-3 rounded-lg p-3">
|
||||
<div class="mt-1 shrink-0">
|
||||
<Clock class="text-primary h-5 w-5" />
|
||||
</div>
|
||||
{#if mensagensAgendadas?.data && mensagensAgendadas.data.length > 0}
|
||||
<div class="space-y-3">
|
||||
{#each mensagensAgendadas.data as msg (msg._id)}
|
||||
<div class="flex items-start gap-3 p-3 bg-base-100 rounded-lg">
|
||||
<div class="shrink-0 mt-1">
|
||||
<Clock class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-base-content/80 text-sm font-medium">
|
||||
{formatarDataHora(msg.agendadaPara || 0)}
|
||||
</p>
|
||||
<p class="text-base-content mt-1 line-clamp-2 text-sm">
|
||||
{msg.conteudo}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-base-content/80">
|
||||
{formatarDataHora(msg.agendadaPara || 0)}
|
||||
</p>
|
||||
<p class="text-sm text-base-content mt-1 line-clamp-2">
|
||||
{msg.conteudo}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Botão cancelar moderno -->
|
||||
<button
|
||||
type="button"
|
||||
class="group relative flex h-9 w-9 items-center justify-center overflow-hidden rounded-lg transition-all duration-300"
|
||||
style="background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.2);"
|
||||
onclick={() => handleCancelar(msg._id)}
|
||||
aria-label="Cancelar"
|
||||
>
|
||||
<div
|
||||
class="bg-error/0 group-hover:bg-error/20 absolute inset-0 transition-colors duration-300"
|
||||
></div>
|
||||
<Trash2
|
||||
class="text-error relative z-10 h-5 w-5 transition-transform group-hover:scale-110"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if !mensagensAgendadas?.data}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-base-content/50 py-8 text-center">
|
||||
<Clock class="mx-auto mb-2 h-12 w-12 opacity-50" />
|
||||
<p class="text-sm">Nenhuma mensagem agendada</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button type="button" onclick={onClose}>fechar</button>
|
||||
</form>
|
||||
<!-- Botão cancelar moderno -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-300 group relative overflow-hidden"
|
||||
style="background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.2);"
|
||||
onclick={() => handleCancelar(msg._id)}
|
||||
aria-label="Cancelar"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-error/0 group-hover:bg-error/20 transition-colors duration-300"
|
||||
></div>
|
||||
<Trash2
|
||||
class="w-5 h-5 text-error relative z-10 group-hover:scale-110 transition-transform"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if !mensagensAgendadas?.data}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-center py-8 text-base-content/50">
|
||||
<Clock class="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p class="text-sm">Nenhuma mensagem agendada</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button type="button" onclick={onClose}>fechar</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
@@ -1,36 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { getAvatarUrl as generateAvatarUrl } from '$lib/utils/avatarGenerator';
|
||||
import { getAvatarUrl as generateAvatarUrl } from "$lib/utils/avatarGenerator";
|
||||
|
||||
interface Props {
|
||||
avatar?: string;
|
||||
fotoPerfilUrl?: string | null;
|
||||
nome: string;
|
||||
size?: "xs" | "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
interface Props {
|
||||
avatar?: string;
|
||||
fotoPerfilUrl?: string | null;
|
||||
nome: string;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
}
|
||||
let { avatar, fotoPerfilUrl, nome, size = "md" }: Props = $props();
|
||||
|
||||
let { avatar, fotoPerfilUrl, nome, size = 'md' }: Props = $props();
|
||||
const sizeClasses = {
|
||||
xs: "w-8 h-8",
|
||||
sm: "w-10 h-10",
|
||||
md: "w-12 h-12",
|
||||
lg: "w-16 h-16",
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'w-8 h-8',
|
||||
sm: 'w-10 h-10',
|
||||
md: 'w-12 h-12',
|
||||
lg: 'w-16 h-16'
|
||||
};
|
||||
function getAvatarUrl(avatarId: string): string {
|
||||
// Usar gerador local ao invés da API externa
|
||||
return generateAvatarUrl(avatarId);
|
||||
}
|
||||
|
||||
function getAvatarUrl(avatarId: string): string {
|
||||
// Usar gerador local ao invés da API externa
|
||||
return generateAvatarUrl(avatarId);
|
||||
}
|
||||
|
||||
const avatarUrlToShow = $derived(() => {
|
||||
if (fotoPerfilUrl) return fotoPerfilUrl;
|
||||
if (avatar) return getAvatarUrl(avatar);
|
||||
return getAvatarUrl(nome); // Fallback usando o nome
|
||||
});
|
||||
const avatarUrlToShow = $derived(() => {
|
||||
if (fotoPerfilUrl) return fotoPerfilUrl;
|
||||
if (avatar) return getAvatarUrl(avatar);
|
||||
return getAvatarUrl(nome); // Fallback usando o nome
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="avatar">
|
||||
<div class={`${sizeClasses[size]} bg-base-200 overflow-hidden rounded-full`}>
|
||||
<img src={avatarUrlToShow()} alt={`Avatar de ${nome}`} class="h-full w-full object-cover" />
|
||||
</div>
|
||||
<div class={`${sizeClasses[size]} rounded-full bg-base-200 overflow-hidden`}>
|
||||
<img
|
||||
src={avatarUrlToShow()}
|
||||
alt={`Avatar de ${nome}`}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,481 +1,502 @@
|
||||
<script lang="ts">
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { useQuery, useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
|
||||
let { onClose }: { onClose: () => void } = $props();
|
||||
let { onClose }: { onClose: () => void } = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
const alertas = useQuery(api.monitoramento.listarAlertas, {});
|
||||
const client = useConvexClient();
|
||||
const alertasQuery = useQuery(api.monitoramento.listarAlertas, {});
|
||||
const alertas = $derived.by(() => {
|
||||
if (!alertasQuery) return [];
|
||||
// O useQuery pode retornar o array diretamente ou em .data
|
||||
if (Array.isArray(alertasQuery)) return alertasQuery;
|
||||
return alertasQuery.data ?? [];
|
||||
});
|
||||
|
||||
$inspect(alertas);
|
||||
// Estado para novo alerta
|
||||
let editingAlertId = $state<Id<"alertConfigurations"> | null>(null);
|
||||
let metricName = $state("cpuUsage");
|
||||
let threshold = $state(80);
|
||||
let operator = $state<">" | "<" | ">=" | "<=" | "==">(">");
|
||||
let enabled = $state(true);
|
||||
let notifyByEmail = $state(false);
|
||||
let notifyByChat = $state(true);
|
||||
let saving = $state(false);
|
||||
let showForm = $state(false);
|
||||
|
||||
// Estado para novo alerta
|
||||
let editingAlertId = $state<Id<'alertConfigurations'> | null>(null);
|
||||
let metricName = $state('cpuUsage');
|
||||
let threshold = $state(80);
|
||||
let operator = $state<'>' | '<' | '>=' | '<=' | '=='>('>');
|
||||
let enabled = $state(true);
|
||||
let notifyByEmail = $state(false);
|
||||
let notifyByChat = $state(true);
|
||||
let saving = $state(false);
|
||||
let showForm = $state(false);
|
||||
const metricOptions = [
|
||||
{ value: "cpuUsage", label: "Uso de CPU (%)" },
|
||||
{ value: "memoryUsage", label: "Uso de Memória (%)" },
|
||||
{ value: "networkLatency", label: "Latência de Rede (ms)" },
|
||||
{ value: "storageUsed", label: "Armazenamento Usado (%)" },
|
||||
{ value: "usuariosOnline", label: "Usuários Online" },
|
||||
{ value: "mensagensPorMinuto", label: "Mensagens por Minuto" },
|
||||
{ value: "tempoRespostaMedio", label: "Tempo de Resposta (ms)" },
|
||||
{ value: "errosCount", label: "Contagem de Erros" },
|
||||
];
|
||||
|
||||
const metricOptions = [
|
||||
{ value: 'cpuUsage', label: 'Uso de CPU (%)' },
|
||||
{ value: 'memoryUsage', label: 'Uso de Memória (%)' },
|
||||
{ value: 'networkLatency', label: 'Latência de Rede (ms)' },
|
||||
{ value: 'storageUsed', label: 'Armazenamento Usado (%)' },
|
||||
{ value: 'usuariosOnline', label: 'Usuários Online' },
|
||||
{ value: 'mensagensPorMinuto', label: 'Mensagens por Minuto' },
|
||||
{ value: 'tempoRespostaMedio', label: 'Tempo de Resposta (ms)' },
|
||||
{ value: 'errosCount', label: 'Contagem de Erros' }
|
||||
];
|
||||
const operatorOptions = [
|
||||
{ value: ">", label: "Maior que (>)" },
|
||||
{ value: ">=", label: "Maior ou igual (≥)" },
|
||||
{ value: "<", label: "Menor que (<)" },
|
||||
{ value: "<=", label: "Menor ou igual (≤)" },
|
||||
{ value: "==", label: "Igual a (=)" },
|
||||
];
|
||||
|
||||
const operatorOptions = [
|
||||
{ value: '>', label: 'Maior que (>)' },
|
||||
{ value: '>=', label: 'Maior ou igual (≥)' },
|
||||
{ value: '<', label: 'Menor que (<)' },
|
||||
{ value: '<=', label: 'Menor ou igual (≤)' },
|
||||
{ value: '==', label: 'Igual a (=)' }
|
||||
];
|
||||
function resetForm() {
|
||||
editingAlertId = null;
|
||||
metricName = "cpuUsage";
|
||||
threshold = 80;
|
||||
operator = ">";
|
||||
enabled = true;
|
||||
notifyByEmail = false;
|
||||
notifyByChat = true;
|
||||
showForm = false;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
editingAlertId = null;
|
||||
metricName = 'cpuUsage';
|
||||
threshold = 80;
|
||||
operator = '>';
|
||||
enabled = true;
|
||||
notifyByEmail = false;
|
||||
notifyByChat = true;
|
||||
showForm = false;
|
||||
}
|
||||
function editAlert(alert: any) {
|
||||
editingAlertId = alert._id;
|
||||
metricName = alert.metricName;
|
||||
threshold = alert.threshold;
|
||||
operator = alert.operator;
|
||||
enabled = alert.enabled;
|
||||
notifyByEmail = alert.notifyByEmail;
|
||||
notifyByChat = alert.notifyByChat;
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
function editAlert(alert: any) {
|
||||
editingAlertId = alert._id;
|
||||
metricName = alert.metricName;
|
||||
threshold = alert.threshold;
|
||||
operator = alert.operator;
|
||||
enabled = alert.enabled;
|
||||
notifyByEmail = alert.notifyByEmail;
|
||||
notifyByChat = alert.notifyByChat;
|
||||
showForm = true;
|
||||
}
|
||||
async function saveAlert() {
|
||||
saving = true;
|
||||
try {
|
||||
await client.mutation(api.monitoramento.configurarAlerta, {
|
||||
alertId: editingAlertId || undefined,
|
||||
metricName,
|
||||
threshold,
|
||||
operator,
|
||||
enabled,
|
||||
notifyByEmail,
|
||||
notifyByChat,
|
||||
});
|
||||
|
||||
async function saveAlert() {
|
||||
saving = true;
|
||||
try {
|
||||
await client.mutation(api.monitoramento.configurarAlerta, {
|
||||
alertId: editingAlertId || undefined,
|
||||
metricName,
|
||||
threshold,
|
||||
operator,
|
||||
enabled,
|
||||
notifyByEmail,
|
||||
notifyByChat
|
||||
});
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
console.error("Erro ao salvar alerta:", error);
|
||||
alert("Erro ao salvar alerta. Tente novamente.");
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
console.error('Erro ao salvar alerta:', error);
|
||||
alert('Erro ao salvar alerta. Tente novamente.');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
async function deleteAlert(alertId: Id<"alertConfigurations">) {
|
||||
if (!confirm("Tem certeza que deseja deletar este alerta?")) return;
|
||||
|
||||
async function deleteAlert(alertId: Id<'alertConfigurations'>) {
|
||||
if (!confirm('Tem certeza que deseja deletar este alerta?')) return;
|
||||
try {
|
||||
await client.mutation(api.monitoramento.deletarAlerta, { alertId });
|
||||
} catch (error) {
|
||||
console.error("Erro ao deletar alerta:", error);
|
||||
alert("Erro ao deletar alerta. Tente novamente.");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await client.mutation(api.monitoramento.deletarAlerta, { alertId });
|
||||
} catch (error) {
|
||||
console.error('Erro ao deletar alerta:', error);
|
||||
alert('Erro ao deletar alerta. Tente novamente.');
|
||||
}
|
||||
}
|
||||
function getMetricLabel(metricName: string): string {
|
||||
return (
|
||||
metricOptions.find((m) => m.value === metricName)?.label || metricName
|
||||
);
|
||||
}
|
||||
|
||||
function getMetricLabel(metricName: string): string {
|
||||
return metricOptions.find((m) => m.value === metricName)?.label || metricName;
|
||||
}
|
||||
|
||||
function getOperatorLabel(op: string): string {
|
||||
return operatorOptions.find((o) => o.value === op)?.label || op;
|
||||
}
|
||||
function getOperatorLabel(op: string): string {
|
||||
return operatorOptions.find((o) => o.value === op)?.label || op;
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog class="modal modal-open">
|
||||
<div class="modal-box from-base-100 to-base-200 max-w-4xl bg-linear-to-br">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle btn-ghost absolute top-2 right-2"
|
||||
onclick={onClose}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<div class="modal-box max-w-4xl bg-linear-to-br from-base-100 to-base-200">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
|
||||
onclick={onClose}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<h3 class="text-primary mb-2 text-3xl font-bold">⚙️ Configuração de Alertas</h3>
|
||||
<p class="text-base-content/60 mb-6">
|
||||
Configure alertas personalizados para monitoramento do sistema
|
||||
</p>
|
||||
<h3 class="font-bold text-3xl text-primary mb-2">
|
||||
⚙️ Configuração de Alertas
|
||||
</h3>
|
||||
<p class="text-base-content/60 mb-6">
|
||||
Configure alertas personalizados para monitoramento do sistema
|
||||
</p>
|
||||
|
||||
<!-- Botão Novo Alerta -->
|
||||
{#if !showForm}
|
||||
<button type="button" class="btn btn-primary mb-6" onclick={() => (showForm = true)}>
|
||||
<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="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Novo Alerta
|
||||
</button>
|
||||
{/if}
|
||||
<!-- Botão Novo Alerta -->
|
||||
{#if !showForm}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary mb-6"
|
||||
onclick={() => (showForm = true)}
|
||||
>
|
||||
<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="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Novo Alerta
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Formulário de Alerta -->
|
||||
{#if showForm}
|
||||
<div class="card bg-base-100 border-primary/20 mb-6 border-2 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title text-xl">
|
||||
{editingAlertId ? 'Editar Alerta' : 'Novo Alerta'}
|
||||
</h4>
|
||||
<!-- Formulário de Alerta -->
|
||||
{#if showForm}
|
||||
<div class="card bg-base-100 shadow-xl mb-6 border-2 border-primary/20">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title text-xl">
|
||||
{editingAlertId ? "Editar Alerta" : "Novo Alerta"}
|
||||
</h4>
|
||||
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<!-- Métrica -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="metric">
|
||||
<span class="label-text font-semibold">Métrica</span>
|
||||
</label>
|
||||
<select
|
||||
id="metric"
|
||||
class="select select-bordered select-primary"
|
||||
bind:value={metricName}
|
||||
>
|
||||
{#each metricOptions as option (option.value)}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<!-- Métrica -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="metric">
|
||||
<span class="label-text font-semibold">Métrica</span>
|
||||
</label>
|
||||
<select
|
||||
id="metric"
|
||||
class="select select-bordered select-primary"
|
||||
bind:value={metricName}
|
||||
>
|
||||
{#each metricOptions as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Operador -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="operator">
|
||||
<span class="label-text font-semibold">Condição</span>
|
||||
</label>
|
||||
<select
|
||||
id="operator"
|
||||
class="select select-bordered select-primary"
|
||||
bind:value={operator}
|
||||
>
|
||||
{#each operatorOptions as option (option.value)}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<!-- Operador -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="operator">
|
||||
<span class="label-text font-semibold">Condição</span>
|
||||
</label>
|
||||
<select
|
||||
id="operator"
|
||||
class="select select-bordered select-primary"
|
||||
bind:value={operator}
|
||||
>
|
||||
{#each operatorOptions as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Threshold -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="threshold">
|
||||
<span class="label-text font-semibold">Valor Limite</span>
|
||||
</label>
|
||||
<input
|
||||
id="threshold"
|
||||
type="number"
|
||||
class="input input-bordered input-primary"
|
||||
bind:value={threshold}
|
||||
min="0"
|
||||
step="1"
|
||||
/>
|
||||
</div>
|
||||
<!-- Threshold -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="threshold">
|
||||
<span class="label-text font-semibold">Valor Limite</span>
|
||||
</label>
|
||||
<input
|
||||
id="threshold"
|
||||
type="number"
|
||||
class="input input-bordered input-primary"
|
||||
bind:value={threshold}
|
||||
min="0"
|
||||
step="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Ativo -->
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-4">
|
||||
<span class="label-text font-semibold">Alerta Ativo</span>
|
||||
<input type="checkbox" class="toggle toggle-primary" bind:checked={enabled} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Ativo -->
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-4">
|
||||
<span class="label-text font-semibold">Alerta Ativo</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
bind:checked={enabled}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notificações -->
|
||||
<div class="divider">Método de Notificação</div>
|
||||
<div class="flex gap-6">
|
||||
<label class="label cursor-pointer gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary"
|
||||
bind:checked={notifyByChat}
|
||||
/>
|
||||
<span class="label-text">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-2 inline h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
|
||||
/>
|
||||
</svg>
|
||||
Notificar por Chat
|
||||
</span>
|
||||
</label>
|
||||
<!-- Notificações -->
|
||||
<div class="divider">Método de Notificação</div>
|
||||
<div class="flex gap-6">
|
||||
<label class="label cursor-pointer gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary"
|
||||
bind:checked={notifyByChat}
|
||||
/>
|
||||
<span class="label-text">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 inline mr-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
|
||||
/>
|
||||
</svg>
|
||||
Notificar por Chat
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="label cursor-pointer gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-secondary"
|
||||
bind:checked={notifyByEmail}
|
||||
/>
|
||||
<span class="label-text">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-2 inline h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
Notificar por E-mail
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<label class="label cursor-pointer gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-secondary"
|
||||
bind:checked={notifyByEmail}
|
||||
/>
|
||||
<span class="label-text">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 inline mr-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
Notificar por E-mail
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div class="alert alert-info mt-4">
|
||||
<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>
|
||||
<h4 class="font-bold">Preview do Alerta:</h4>
|
||||
<p class="text-sm">
|
||||
Alertar quando <strong>{getMetricLabel(metricName)}</strong> for
|
||||
<strong>{getOperatorLabel(operator)}</strong> a
|
||||
<strong>{threshold}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Preview -->
|
||||
<div class="alert alert-info mt-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="stroke-current shrink-0 w-6 h-6"
|
||||
>
|
||||
<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>
|
||||
<h4 class="font-bold">Preview do Alerta:</h4>
|
||||
<p class="text-sm">
|
||||
Alertar quando <strong>{getMetricLabel(metricName)}</strong> for
|
||||
<strong>{getOperatorLabel(operator)}</strong> a
|
||||
<strong>{threshold}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botões -->
|
||||
<div class="card-actions mt-4 justify-end">
|
||||
<button type="button" class="btn" onclick={resetForm} disabled={saving}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onclick={saveAlert}
|
||||
disabled={saving || (!notifyByChat && !notifyByEmail)}
|
||||
>
|
||||
{#if saving}
|
||||
<span class="loading loading-spinner"></span>
|
||||
Salvando...
|
||||
{:else}
|
||||
<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="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Salvar Alerta
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Botões -->
|
||||
<div class="card-actions justify-end mt-4">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost"
|
||||
onclick={resetForm}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onclick={saveAlert}
|
||||
disabled={saving || (!notifyByChat && !notifyByEmail)}
|
||||
>
|
||||
{#if saving}
|
||||
<span class="loading loading-spinner"></span>
|
||||
Salvando...
|
||||
{:else}
|
||||
<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="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Salvar Alerta
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Lista de Alertas -->
|
||||
<div class="divider">Alertas Configurados</div>
|
||||
<!-- Lista de Alertas -->
|
||||
<div class="divider">Alertas Configurados</div>
|
||||
|
||||
{#if alertas.data && alertas.data.length > 0}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table-zebra table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Métrica</th>
|
||||
<th>Condição</th>
|
||||
<th>Status</th>
|
||||
<th>Notificações</th>
|
||||
<th>Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each alertas.data as alerta (alerta._id)}
|
||||
<tr class={!alerta.enabled ? 'opacity-50' : ''}>
|
||||
<td>
|
||||
<div class="font-semibold">
|
||||
{getMetricLabel(alerta.metricName)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="badge badge-outline">
|
||||
{getOperatorLabel(alerta.operator)}
|
||||
{alerta.threshold}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{#if alerta.enabled}
|
||||
<div class="badge badge-success gap-2">
|
||||
<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
Ativo
|
||||
</div>
|
||||
{:else}
|
||||
<div class="badge badge-ghost gap-2">
|
||||
<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="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
Inativo
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-1">
|
||||
{#if alerta.notifyByChat}
|
||||
<div class="badge badge-primary badge-sm">Chat</div>
|
||||
{/if}
|
||||
{#if alerta.notifyByEmail}
|
||||
<div class="badge badge-secondary badge-sm">Email</div>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
title="Editar Alerta"
|
||||
type="button"
|
||||
class="btn btn-xs"
|
||||
onclick={() => editAlert(alerta)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
title="Deletar Alerta"
|
||||
type="button"
|
||||
class="btn btn-xs text-error"
|
||||
onclick={() => deleteAlert(alerta._id)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else}
|
||||
<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>Nenhum alerta configurado. Clique em "Novo Alerta" para criar um.</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if alertas.length > 0}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Métrica</th>
|
||||
<th>Condição</th>
|
||||
<th>Status</th>
|
||||
<th>Notificações</th>
|
||||
<th>Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each alertas as alerta}
|
||||
<tr class={!alerta.enabled ? "opacity-50" : ""}>
|
||||
<td>
|
||||
<div class="font-semibold">
|
||||
{getMetricLabel(alerta.metricName)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="badge badge-outline">
|
||||
{getOperatorLabel(alerta.operator)}
|
||||
{alerta.threshold}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{#if alerta.enabled}
|
||||
<div class="badge badge-success gap-2">
|
||||
<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
Ativo
|
||||
</div>
|
||||
{:else}
|
||||
<div class="badge badge-ghost gap-2">
|
||||
<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="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
Inativo
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-1">
|
||||
{#if alerta.notifyByChat}
|
||||
<div class="badge badge-primary badge-sm">Chat</div>
|
||||
{/if}
|
||||
{#if alerta.notifyByEmail}
|
||||
<div class="badge badge-secondary badge-sm">Email</div>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
onclick={() => editAlert(alerta)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
onclick={() => deleteAlert(alerta._id)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="alert">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="stroke-info shrink-0 w-6 h-6"
|
||||
>
|
||||
<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
|
||||
>Nenhum alerta configurado. Clique em "Novo Alerta" para criar um.</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn btn-lg" onclick={onClose}>Fechar</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<form method="dialog" class="modal-backdrop" onclick={onClose}>
|
||||
<button type="button">close</button>
|
||||
</form>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn btn-lg" onclick={onClose}>Fechar</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<form method="dialog" class="modal-backdrop" onclick={onClose}>
|
||||
<button type="button">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
Reference in New Issue
Block a user