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:
@@ -106,6 +106,15 @@
|
|||||||
let criandoTemplates = $state(false);
|
let criandoTemplates = $state(false);
|
||||||
let templatesFeedback = $state<string | null>(null);
|
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;
|
let carregamentoToken = 0;
|
||||||
|
|
||||||
// Carregar chamados quando filtros mudarem
|
// Carregar chamados quando filtros mudarem
|
||||||
@@ -191,12 +200,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verificar se é um objeto com propriedade data (como em outros lugares do código)
|
// 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 (typeof usuariosQuery === 'object' && usuariosQuery !== null) {
|
||||||
if ('data' in usuariosQuery && Array.isArray(usuariosQuery.data)) {
|
if ('data' in usuariosQuery && Array.isArray(usuariosQuery.data)) {
|
||||||
usuarios = usuariosQuery.data;
|
usuarios = usuariosQuery.data as Usuario[];
|
||||||
} else if (Array.isArray(usuariosQuery)) {
|
} else if (Array.isArray(usuariosQuery)) {
|
||||||
usuarios = usuariosQuery;
|
usuarios = usuariosQuery as Usuario[];
|
||||||
} else {
|
} else {
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
console.log("🔍 [usuariosTI] Formato inesperado:", typeof usuariosQuery, usuariosQuery);
|
console.log("🔍 [usuariosTI] Formato inesperado:", typeof usuariosQuery, usuariosQuery);
|
||||||
@@ -204,7 +213,7 @@
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
} else if (Array.isArray(usuariosQuery)) {
|
} else if (Array.isArray(usuariosQuery)) {
|
||||||
usuarios = usuariosQuery;
|
usuarios = usuariosQuery as Usuario[];
|
||||||
} else {
|
} else {
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
console.log("🔍 [usuariosTI] Tipo inesperado:", typeof usuariosQuery, usuariosQuery);
|
console.log("🔍 [usuariosTI] Tipo inesperado:", typeof usuariosQuery, usuariosQuery);
|
||||||
@@ -219,7 +228,7 @@
|
|||||||
return [];
|
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)
|
// Verificar se o usuário tem setor "TI" no role (case-insensitive)
|
||||||
const setor = usuario.role?.setor;
|
const setor = usuario.role?.setor;
|
||||||
const temSetorTI = setor && setor.toUpperCase() === "TI";
|
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)
|
// Debug: ver templates carregados (remover em produção)
|
||||||
// $effect(() => {
|
// $effect(() => {
|
||||||
@@ -634,7 +795,7 @@
|
|||||||
<td>
|
<td>
|
||||||
<span class={getStatusBadge(ticket.status)}>{getStatusLabel(ticket.status)}</span>
|
<span class={getStatusBadge(ticket.status)}>{getStatusLabel(ticket.status)}</span>
|
||||||
</td>
|
</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-sm capitalize">{ticket.prioridade}</td>
|
||||||
<td class="text-xs text-base-content/70">
|
<td class="text-xs text-base-content/70">
|
||||||
{ticket.prazoConclusao ? prazoRestante(ticket.prazoConclusao) : "--"}
|
{ticket.prazoConclusao ? prazoRestante(ticket.prazoConclusao) : "--"}
|
||||||
@@ -681,6 +842,90 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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">
|
<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>
|
<h3 class="text-lg font-semibold text-base-content">Atribuir responsável</h3>
|
||||||
<div class="mt-4 space-y-3">
|
<div class="mt-4 space-y-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user