feat: implement response management for tickets in central chamados

- Added functionality to respond to tickets, including text input and file attachment options.
- Implemented methods for handling file uploads and managing response state, including feedback messages for users.
- Enhanced the UI to allow users to select a ticket and provide a response, with options to mark tickets as completed.
- Improved type safety by specifying types for user and ticket data throughout the component.
This commit is contained in:
2025-11-17 19:15:50 -03:00
parent 0e5a26b5fd
commit 7c8be8a818

View File

@@ -106,6 +106,15 @@
let criandoTemplates = $state(false);
let templatesFeedback = $state<string | null>(null);
// Estados para responder chamado
let respostaTexto = $state("");
let respostaAnexo = $state<File | null>(null);
let respostaAnexoId = $state<Id<"_storage"> | null>(null);
let respostaAnexoNome = $state<string>("");
let marcandoConcluido = $state(false);
let respostaFeedback = $state<string | null>(null);
let enviandoResposta = $state(false);
let carregamentoToken = 0;
// Carregar chamados quando filtros mudarem
@@ -191,12 +200,12 @@
}
// Verificar se é um objeto com propriedade data (como em outros lugares do código)
let usuarios: any[] = [];
let usuarios: Usuario[] = [];
if (typeof usuariosQuery === 'object' && usuariosQuery !== null) {
if ('data' in usuariosQuery && Array.isArray(usuariosQuery.data)) {
usuarios = usuariosQuery.data;
usuarios = usuariosQuery.data as Usuario[];
} else if (Array.isArray(usuariosQuery)) {
usuarios = usuariosQuery;
usuarios = usuariosQuery as Usuario[];
} else {
if (import.meta.env.DEV) {
console.log("🔍 [usuariosTI] Formato inesperado:", typeof usuariosQuery, usuariosQuery);
@@ -204,7 +213,7 @@
return [];
}
} else if (Array.isArray(usuariosQuery)) {
usuarios = usuariosQuery;
usuarios = usuariosQuery as Usuario[];
} else {
if (import.meta.env.DEV) {
console.log("🔍 [usuariosTI] Tipo inesperado:", typeof usuariosQuery, usuariosQuery);
@@ -219,7 +228,7 @@
return [];
}
const usuariosFiltrados = usuarios.filter((usuario: any) => {
const usuariosFiltrados = usuarios.filter((usuario: Usuario) => {
// Verificar se o usuário tem setor "TI" no role (case-insensitive)
const setor = usuario.role?.setor;
const temSetorTI = setor && setor.toUpperCase() === "TI";
@@ -457,6 +466,158 @@
}
}
async function handleAnexoChange(event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) {
respostaAnexo = null;
respostaAnexoId = null;
respostaAnexoNome = "";
return;
}
respostaAnexo = file;
respostaAnexoNome = file.name;
try {
// Gerar URL de upload
const uploadUrl = await client.mutation(api.chamados.generateUploadUrl, {});
// Fazer upload do arquivo
const response = await fetch(uploadUrl, {
method: "POST",
headers: { "Content-Type": file.type },
body: file,
});
const result = await response.json();
respostaAnexoId = result.storageId as Id<"_storage">;
} catch (error) {
console.error("Erro ao fazer upload do anexo:", error);
respostaFeedback = "Erro ao fazer upload do anexo. Tente novamente.";
respostaAnexo = null;
respostaAnexoId = null;
respostaAnexoNome = "";
}
}
function removerAnexo() {
respostaAnexo = null;
respostaAnexoId = null;
respostaAnexoNome = "";
// Resetar o input de arquivo
const fileInput = document.getElementById("respostaAnexoInput") as HTMLInputElement;
if (fileInput) {
fileInput.value = "";
}
}
async function responderChamado() {
if (!ticketSelecionado) {
respostaFeedback = "Selecione um chamado primeiro";
return;
}
if (!respostaTexto.trim() && !respostaAnexoId) {
respostaFeedback = "Digite uma resposta ou anexe um documento";
return;
}
try {
enviandoResposta = true;
respostaFeedback = null;
const anexos = respostaAnexoId
? [
{
arquivoId: respostaAnexoId,
nome: respostaAnexoNome || undefined,
tipo: respostaAnexo?.type || undefined,
tamanho: respostaAnexo?.size || undefined,
},
]
: undefined;
await client.mutation(api.chamados.registrarAtualizacao, {
ticketId: ticketSelecionado,
conteudo: respostaTexto.trim() || "Anexo enviado",
anexos,
visibilidade: "publico",
});
respostaFeedback = "Resposta enviada com sucesso";
respostaTexto = "";
removerAnexo();
// Recarregar chamados para atualizar a lista
await carregarChamados({
status: filtroStatus === "todos" ? undefined : filtroStatus,
responsavelId: filtroResponsavel === "todos" ? undefined : filtroResponsavel,
setor: filtroSetor === "todos" ? undefined : filtroSetor,
});
if (ticketSelecionado) {
selecionarTicket(ticketSelecionado);
}
} catch (error) {
console.error("Erro ao responder chamado:", error);
respostaFeedback = error instanceof Error ? error.message : "Erro ao enviar resposta";
} finally {
enviandoResposta = false;
}
}
async function marcarComoConcluido() {
if (!ticketSelecionado) {
respostaFeedback = "Selecione um chamado primeiro";
return;
}
try {
marcandoConcluido = true;
respostaFeedback = null;
const anexos = respostaAnexoId
? [
{
arquivoId: respostaAnexoId,
nome: respostaAnexoNome || undefined,
tipo: respostaAnexo?.type || undefined,
tamanho: respostaAnexo?.size || undefined,
},
]
: undefined;
await client.mutation(api.chamados.registrarAtualizacao, {
ticketId: ticketSelecionado,
conteudo: respostaTexto.trim() || "Chamado marcado como concluído",
anexos,
visibilidade: "publico",
proximoStatus: "resolvido",
});
respostaFeedback = "Chamado marcado como concluído com sucesso";
respostaTexto = "";
removerAnexo();
// Recarregar chamados para atualizar a lista
await carregarChamados({
status: filtroStatus === "todos" ? undefined : filtroStatus,
responsavelId: filtroResponsavel === "todos" ? undefined : filtroResponsavel,
setor: filtroSetor === "todos" ? undefined : filtroSetor,
});
if (ticketSelecionado) {
selecionarTicket(ticketSelecionado);
}
} catch (error) {
console.error("Erro ao marcar como concluído:", error);
respostaFeedback = error instanceof Error ? error.message : "Erro ao marcar como concluído";
} finally {
marcandoConcluido = false;
}
}
// Debug: ver templates carregados (remover em produção)
// $effect(() => {
@@ -634,7 +795,7 @@
<td>
<span class={getStatusBadge(ticket.status)}>{getStatusLabel(ticket.status)}</span>
</td>
<td class="text-sm">{(ticket as any).responsavelNome ?? ticket.setorResponsavel ?? "—"}</td>
<td class="text-sm">{(ticket as Ticket & { responsavelNome?: string }).responsavelNome ?? ticket.setorResponsavel ?? "—"}</td>
<td class="text-sm capitalize">{ticket.prioridade}</td>
<td class="text-xs text-base-content/70">
{ticket.prazoConclusao ? prazoRestante(ticket.prazoConclusao) : "--"}
@@ -681,6 +842,90 @@
{/if}
</div>
<!-- Nova seção: Responder e Gerenciar Chamado -->
<div class="rounded-3xl border border-base-200 bg-base-100/80 p-6 shadow-lg">
<h3 class="text-lg font-semibold text-base-content">Responder chamado</h3>
{#if !detalheSelecionado}
<p class="text-sm text-base-content/60 mt-4">Selecione um chamado na tabela para responder.</p>
{:else}
<div class="mt-4 space-y-4">
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Resposta *</span>
</label>
<textarea
class="textarea textarea-bordered w-full"
rows="5"
placeholder="Digite sua resposta ao chamado..."
bind:value={respostaTexto}
disabled={enviandoResposta || marcandoConcluido}
></textarea>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Anexar documento (opcional)</span>
</label>
<input
id="respostaAnexoInput"
type="file"
class="file-input file-input-bordered w-full"
accept=".pdf,.doc,.docx,.jpg,.jpeg,.png"
onchange={handleAnexoChange}
disabled={enviandoResposta || marcandoConcluido}
/>
{#if respostaAnexoNome}
<div class="mt-2 flex items-center gap-2">
<span class="text-sm text-base-content/70">{respostaAnexoNome}</span>
<button
type="button"
class="btn btn-xs btn-ghost"
onclick={removerAnexo}
disabled={enviandoResposta || marcandoConcluido}
>
Remover
</button>
</div>
{/if}
</div>
{#if respostaFeedback}
<div class={`alert ${respostaFeedback.includes('sucesso') ? 'alert-success' : 'alert-error'} text-sm`}>
{respostaFeedback}
</div>
{/if}
<div class="flex gap-2">
<button
class="btn btn-primary flex-1"
type="button"
onclick={responderChamado}
disabled={enviandoResposta || marcandoConcluido || (!respostaTexto.trim() && !respostaAnexoId)}
>
{#if enviandoResposta}
<span class="loading loading-spinner loading-sm"></span>
Enviando...
{:else}
Enviar resposta
{/if}
</button>
<button
class="btn btn-success"
type="button"
onclick={marcarComoConcluido}
disabled={enviandoResposta || marcandoConcluido || detalheSelecionado?.status === "resolvido" || detalheSelecionado?.status === "encerrado"}
>
{#if marcandoConcluido}
<span class="loading loading-spinner loading-sm"></span>
{:else}
Marcar como concluído
{/if}
</button>
</div>
</div>
{/if}
</div>
<div class="rounded-3xl border border-base-200 bg-base-100/80 p-6 shadow-lg">
<h3 class="text-lg font-semibold text-base-content">Atribuir responsável</h3>
<div class="mt-4 space-y-3">