Compare commits
7 Commits
emp-perfis
...
feat-centr
| Author | SHA1 | Date | |
|---|---|---|---|
| 55847e2a77 | |||
| 5ef6ef8550 | |||
| fb784d6f7e | |||
| 24b8eb6a14 | |||
| 118051ad56 | |||
| 9b3b095c01 | |||
| 731f95d0b5 |
186
RELATORIO_TESTES.md
Normal file
186
RELATORIO_TESTES.md
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
# Relatório de Testes - Sistema de Central de Chamados
|
||||||
|
|
||||||
|
**Data:** 16 de novembro de 2025
|
||||||
|
**Testador:** Sistema Automatizado
|
||||||
|
**Página Testada:** `/ti/central-chamados`
|
||||||
|
|
||||||
|
## Resumo Executivo
|
||||||
|
|
||||||
|
Foram realizados testes completos na página de Central de Chamados do sistema SGSE. A maioria das funcionalidades está funcionando corretamente, mas foram identificados alguns problemas que precisam ser corrigidos.
|
||||||
|
|
||||||
|
## Testes Realizados
|
||||||
|
|
||||||
|
### ✅ Testes Bem-Sucedidos
|
||||||
|
|
||||||
|
1. **Login no Sistema**
|
||||||
|
- Status: ✅ PASSOU
|
||||||
|
- Usuário logado: Deyvison (dfw@poli.br)
|
||||||
|
|
||||||
|
2. **Visualização de SLAs Configurados**
|
||||||
|
- Status: ✅ PASSOU
|
||||||
|
- Tabela de SLAs exibe 7 SLAs ativos corretamente
|
||||||
|
- Resumo mostra: 4 Baixa, 2 Média, 1 Alta/Crítica
|
||||||
|
- Detalhes completos (tempos, prioridades) são exibidos corretamente
|
||||||
|
|
||||||
|
3. **Cards de Prioridade**
|
||||||
|
- Status: ✅ PASSOU
|
||||||
|
- Cards mostram corretamente "Configurado" ou "Não configurado"
|
||||||
|
- Botão "Configurar" funciona corretamente
|
||||||
|
- Detalhes dos SLAs configurados são exibidos nos cards
|
||||||
|
|
||||||
|
4. **Criação de SLA**
|
||||||
|
- Status: ✅ PASSOU
|
||||||
|
- SLA criado com sucesso para prioridade "Alta"
|
||||||
|
- Formulário preenche corretamente quando clica em "Configurar"
|
||||||
|
- Tabela atualiza automaticamente após criação
|
||||||
|
- Card de prioridade atualiza para "Configurado"
|
||||||
|
|
||||||
|
5. **Edição de SLA**
|
||||||
|
- Status: ✅ PASSOU
|
||||||
|
- Botão "Editar" abre formulário com dados corretos
|
||||||
|
- Atualização funciona corretamente
|
||||||
|
|
||||||
|
6. **Lista de Chamados**
|
||||||
|
- Status: ✅ PASSOU
|
||||||
|
- 4 chamados sendo exibidos corretamente
|
||||||
|
- Filtros funcionando (status, responsável, setor)
|
||||||
|
- Detalhes do chamado são exibidos ao selecionar
|
||||||
|
|
||||||
|
7. **Atribuição de Responsável**
|
||||||
|
- Status: ✅ PASSOU
|
||||||
|
- Dropdown mostra 2 usuários TI: Deyvison e Suporte_TI
|
||||||
|
- Formulário está funcional
|
||||||
|
|
||||||
|
8. **Prorrogação de Prazo**
|
||||||
|
- Status: ✅ PASSOU
|
||||||
|
- Dropdown de tickets carrega corretamente (4 tickets)
|
||||||
|
- Formulário permite selecionar tipo de prazo e horas
|
||||||
|
- Botão habilita quando todos os campos estão preenchidos
|
||||||
|
|
||||||
|
### ⚠️ Problemas Identificados
|
||||||
|
|
||||||
|
#### 1. Templates de Email - Listagem Após Criação
|
||||||
|
|
||||||
|
- **Status:** ⚠️ PROBLEMA
|
||||||
|
- **Descrição:** Templates são criados com sucesso (mensagem "Templates padrão criados com sucesso" aparece), mas não são listados na interface após criação
|
||||||
|
- **Ação Realizada:** Botão "Criar templates padrão" foi clicado e retornou sucesso
|
||||||
|
- **Comportamento Esperado:** Templates deveriam aparecer em uma lista após criação
|
||||||
|
- **Comportamento Atual:** Seção continua mostrando "Nenhum template encontrado"
|
||||||
|
- **Severidade:** MÉDIA
|
||||||
|
- **Impacto:** Usuários não conseguem visualizar/editar templates de email após criação
|
||||||
|
- **Possível Causa:** Query de templates pode não estar sendo atualizada após criação, ou filtro pode estar excluindo templates de chamados
|
||||||
|
|
||||||
|
#### 2. Warning no Console - Token de Autenticação
|
||||||
|
|
||||||
|
- **Status:** ⚠️ AVISO (Não crítico)
|
||||||
|
- **Descrição:** `⚠️ [useConvexWithAuth] Token não disponível` aparece no console durante carregamento inicial
|
||||||
|
- **Severidade:** BAIXA
|
||||||
|
- **Impacto:** Não afeta funcionalidade (autenticação funciona corretamente após carregamento)
|
||||||
|
- **Observação:** Parece ser um problema de timing durante inicialização da página
|
||||||
|
|
||||||
|
#### 3. Warning no Console - Formato de Query
|
||||||
|
|
||||||
|
- **Status:** ⚠️ AVISO (Não crítico)
|
||||||
|
- **Descrição:** `🔍 [usuariosTI] Formato inesperado: object {data: undefined, isLoading: undefined, error: undefined, isStale: undefined}` aparece no console
|
||||||
|
- **Severidade:** BAIXA
|
||||||
|
- **Impacto:** Não afeta funcionalidade (usuários são carregados corretamente - 2 usuários TI encontrados)
|
||||||
|
- **Observação:** Indica possível inconsistência no formato de retorno da query durante carregamento inicial
|
||||||
|
|
||||||
|
## Detalhes dos Testes
|
||||||
|
|
||||||
|
### Teste de Criação de SLA
|
||||||
|
|
||||||
|
- **Prioridade Testada:** Alta
|
||||||
|
- **Valores Inseridos:**
|
||||||
|
- Nome: "SLA - Alta - Teste"
|
||||||
|
- Tempo de Resposta: 2h
|
||||||
|
- Tempo de Conclusão: 8h
|
||||||
|
- Auto-encerramento: 24h
|
||||||
|
- Alerta: 2h antes
|
||||||
|
- **Resultado:** ✅ SLA criado e exibido na tabela e no card
|
||||||
|
|
||||||
|
### Teste de Edição de SLA
|
||||||
|
|
||||||
|
- **SLA Editado:** Prioridade Baixa
|
||||||
|
- **Alterações:**
|
||||||
|
- Nome: "SLA Baixa - Editado em Teste"
|
||||||
|
- Tempo de Resposta: 6h
|
||||||
|
- **Resultado:** ✅ Atualização bem-sucedida
|
||||||
|
|
||||||
|
### Teste de Prorrogação
|
||||||
|
|
||||||
|
- **Ticket Selecionado:** SGSE-202511-3750
|
||||||
|
- **Prazo:** Conclusão
|
||||||
|
- **Horas Adicionais:** 24h
|
||||||
|
- **Motivo:** "Teste de prorrogação de prazo - necessário mais tempo para análise"
|
||||||
|
- **Resultado:** ✅ Formulário preenchido corretamente, botão habilitado
|
||||||
|
|
||||||
|
## Lista de Erros Encontrados
|
||||||
|
|
||||||
|
### Erros Críticos
|
||||||
|
|
||||||
|
- **Nenhum erro crítico encontrado**
|
||||||
|
|
||||||
|
### Erros de Funcionalidade
|
||||||
|
|
||||||
|
1. **Templates de Email não aparecem após criação**
|
||||||
|
- Localização: Seção "Templates de Email - Chamados"
|
||||||
|
- Ação necessária: Verificar query de templates e atualização reativa após criação
|
||||||
|
|
||||||
|
### Avisos (Warnings)
|
||||||
|
|
||||||
|
1. **Token de autenticação não disponível durante carregamento inicial**
|
||||||
|
- Localização: Console do navegador
|
||||||
|
- Ação necessária: Melhorar timing de inicialização de autenticação
|
||||||
|
|
||||||
|
2. **Formato inesperado de query durante carregamento**
|
||||||
|
- Localização: Console do navegador (usuariosTI)
|
||||||
|
- Ação necessária: Verificar formato de retorno de useQuery do convex-svelte
|
||||||
|
|
||||||
|
## Recomendações
|
||||||
|
|
||||||
|
### Prioridade ALTA
|
||||||
|
|
||||||
|
1. **Corrigir listagem de templates de email após criação**
|
||||||
|
- Verificar se a query `templatesChamados` está sendo atualizada após criação
|
||||||
|
- Verificar se o filtro de templates está correto (deve incluir templates de chamados)
|
||||||
|
- Adicionar refresh automático após criação de templates
|
||||||
|
|
||||||
|
### Prioridade MÉDIA
|
||||||
|
|
||||||
|
2. **Investigar e corrigir warnings no console**
|
||||||
|
- Melhorar timing de autenticação para evitar warning inicial
|
||||||
|
- Padronizar formato de retorno de queries do convex-svelte
|
||||||
|
|
||||||
|
### Prioridade BAIXA
|
||||||
|
|
||||||
|
3. **Melhorar logs de debug**
|
||||||
|
- Reduzir verbosidade de logs informativos
|
||||||
|
- Manter apenas logs de erro e warnings importantes
|
||||||
|
|
||||||
|
## Conclusão
|
||||||
|
|
||||||
|
O sistema está **funcionalmente operacional**, com a maioria das funcionalidades testadas funcionando corretamente:
|
||||||
|
|
||||||
|
✅ **Funcionalidades Testadas e Funcionando:**
|
||||||
|
|
||||||
|
- Login e autenticação
|
||||||
|
- Visualização de SLAs (tabela e cards)
|
||||||
|
- Criação de SLAs
|
||||||
|
- Edição de SLAs
|
||||||
|
- Lista de chamados
|
||||||
|
- Atribuição de responsável
|
||||||
|
- Prorrogação de prazo (formulário funcional)
|
||||||
|
- Criação de templates (backend funciona, frontend não atualiza)
|
||||||
|
|
||||||
|
⚠️ **Problemas Identificados:**
|
||||||
|
|
||||||
|
- Templates não aparecem na lista após criação (problema de atualização reativa)
|
||||||
|
- Warnings no console (não afetam funcionalidade)
|
||||||
|
|
||||||
|
**Status Geral:** ✅ **OPERACIONAL COM PEQUENOS AJUSTES NECESSÁRIOS**
|
||||||
|
|
||||||
|
**Próximos Passos:**
|
||||||
|
|
||||||
|
1. Corrigir atualização reativa de templates após criação
|
||||||
|
2. Investigar e resolver warnings do console (opcional, não crítico)
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
|
|
||||||
let fileInput: HTMLInputElement;
|
let fileInput: HTMLInputElement | null = null;
|
||||||
let uploading = $state(false);
|
let uploading = $state(false);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let fileName = $state<string>("");
|
let fileName = $state<string>("");
|
||||||
@@ -50,31 +50,52 @@
|
|||||||
|
|
||||||
// Buscar URL do arquivo quando houver um storageId
|
// Buscar URL do arquivo quando houver um storageId
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (value && !fileName) {
|
if (!value || fileName) {
|
||||||
// Tem storageId mas não é um upload recente
|
return;
|
||||||
loadExistingFile(value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const storageId = value;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const url = await client.storage.getUrl(storageId as any);
|
||||||
|
if (!url || cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileUrl = url;
|
||||||
|
|
||||||
|
const path = url.split('?')[0] ?? '';
|
||||||
|
const nameFromUrl = path.split('/').pop() ?? 'arquivo';
|
||||||
|
fileName = decodeURIComponent(nameFromUrl);
|
||||||
|
|
||||||
|
const extension = fileName.toLowerCase().split('.').pop();
|
||||||
|
const isPdf =
|
||||||
|
extension === 'pdf' || url.includes('.pdf') || url.includes('application/pdf');
|
||||||
|
|
||||||
|
if (isPdf) {
|
||||||
|
fileType = 'application/pdf';
|
||||||
|
previewUrl = null;
|
||||||
|
} else {
|
||||||
|
fileType = 'image/jpeg';
|
||||||
|
previewUrl = url;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
console.error('Erro ao carregar arquivo existente:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadExistingFile(storageId: string) {
|
|
||||||
try {
|
|
||||||
const url = await client.storage.getUrl(storageId as any);
|
|
||||||
if (url) {
|
|
||||||
async function handleFileSelect(event: Event) {
|
async function handleFileSelect(event: Event) {
|
||||||
const target = event.target as HTMLInputElement;
|
const target = event.target as HTMLInputElement;
|
||||||
const file = target.files?.[0];
|
const file = target.files?.[0];
|
||||||
// Detectar tipo pelo URL ou assumir PDF
|
|
||||||
if (url.includes('.pdf') || url.includes('application/pdf')) {
|
|
||||||
fileType = 'application/pdf';
|
|
||||||
} else {
|
|
||||||
fileType = 'image/jpeg';
|
|
||||||
previewUrl = url; // Para imagens, a URL serve como preview
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Erro ao carregar arquivo existente:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
@@ -144,6 +165,17 @@
|
|||||||
function openFileDialog() {
|
function openFileDialog() {
|
||||||
fileInput?.click();
|
fileInput?.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setFileInput(node: HTMLInputElement) {
|
||||||
|
fileInput = node;
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
if (fileInput === node) {
|
||||||
|
fileInput = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="form-control w-full">
|
<div class="form-control w-full">
|
||||||
@@ -175,7 +207,7 @@
|
|||||||
<input
|
<input
|
||||||
id="file-upload-input"
|
id="file-upload-input"
|
||||||
type="file"
|
type="file"
|
||||||
bind:this={fileInput}
|
use:setFileInput
|
||||||
onchange={handleFileSelect}
|
onchange={handleFileSelect}
|
||||||
accept=".pdf,.jpg,.jpeg,.png"
|
accept=".pdf,.jpg,.jpeg,.png"
|
||||||
class="hidden"
|
class="hidden"
|
||||||
@@ -194,7 +226,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="bg-success/10 flex h-12 w-12 items-center justify-center rounded">
|
<div class="bg-success/10 flex h-12 w-12 items-center justify-center rounded">
|
||||||
<File class="text-success h-6 w-6" strokeWidth={2} />
|
<FileIcon class="text-success h-6 w-6" strokeWidth={2} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -52,8 +52,8 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function verificarPermissoes() {
|
function verificarPermissoes() {
|
||||||
// Dashboard e Solicitar Acesso são públicos
|
// Dashboard e abertura de chamados são públicos
|
||||||
if (menuPath === "/" || menuPath === "/solicitar-acesso") {
|
if (menuPath === "/" || menuPath === "/abrir-chamado") {
|
||||||
verificando = false;
|
verificando = false;
|
||||||
temPermissao = true;
|
temPermissao = true;
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -376,11 +376,11 @@
|
|||||||
{/each}
|
{/each}
|
||||||
<li class="mt-auto rounded-xl">
|
<li class="mt-auto rounded-xl">
|
||||||
<a
|
<a
|
||||||
href={resolve('/solicitar-acesso')}
|
href={resolve('/abrir-chamado')}
|
||||||
class={getSolicitarClasses(currentPath === '/solicitar-acesso')}
|
class={getSolicitarClasses(currentPath === '/abrir-chamado')}
|
||||||
>
|
>
|
||||||
<UserPlus class="h-5 w-5" strokeWidth={2} />
|
<UserPlus class="h-5 w-5" strokeWidth={2} />
|
||||||
<span>Solicitar acesso</span>
|
<span>Abrir Chamado</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -460,11 +460,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-4 space-y-2 text-center">
|
<div class="mt-4 space-y-2 text-center">
|
||||||
<a
|
<a
|
||||||
href={resolve('/solicitar-acesso')}
|
href={resolve('/abrir-chamado')}
|
||||||
class="link link-primary block text-sm"
|
class="link link-primary block text-sm"
|
||||||
onclick={closeLoginModal}
|
onclick={closeLoginModal}
|
||||||
>
|
>
|
||||||
Não tem acesso? Solicite aqui
|
Abrir Chamado
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={resolve('/esqueci-senha')}
|
href={resolve('/esqueci-senha')}
|
||||||
|
|||||||
183
apps/web/src/lib/components/chamados/SlaChart.svelte
Normal file
183
apps/web/src/lib/components/chamados/SlaChart.svelte
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { Chart, registerables } from 'chart.js';
|
||||||
|
|
||||||
|
Chart.register(...registerables);
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
dadosSla: {
|
||||||
|
statusSla: {
|
||||||
|
dentroPrazo: number;
|
||||||
|
proximoVencimento: number;
|
||||||
|
vencido: number;
|
||||||
|
semPrazo: number;
|
||||||
|
};
|
||||||
|
porPrioridade: {
|
||||||
|
baixa: { dentroPrazo: number; proximoVencimento: number; vencido: number; total: number };
|
||||||
|
media: { dentroPrazo: number; proximoVencimento: number; vencido: number; total: number };
|
||||||
|
alta: { dentroPrazo: number; proximoVencimento: number; vencido: number; total: number };
|
||||||
|
critica: { dentroPrazo: number; proximoVencimento: number; vencido: number; total: number };
|
||||||
|
};
|
||||||
|
taxaCumprimento: number;
|
||||||
|
totalComPrazo: number;
|
||||||
|
atualizadoEm: number;
|
||||||
|
};
|
||||||
|
height?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { dadosSla, height = 400 }: Props = $props();
|
||||||
|
|
||||||
|
let canvas: HTMLCanvasElement;
|
||||||
|
let chart: Chart | null = null;
|
||||||
|
|
||||||
|
function prepararDados() {
|
||||||
|
const prioridades = ['Baixa', 'Média', 'Alta', 'Crítica'];
|
||||||
|
const cores = {
|
||||||
|
dentroPrazo: 'rgba(34, 197, 94, 0.8)', // verde
|
||||||
|
proximoVencimento: 'rgba(251, 191, 36, 0.8)', // amarelo
|
||||||
|
vencido: 'rgba(239, 68, 68, 0.8)', // vermelho
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: prioridades,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Dentro do Prazo',
|
||||||
|
data: [
|
||||||
|
dadosSla.porPrioridade.baixa.dentroPrazo,
|
||||||
|
dadosSla.porPrioridade.media.dentroPrazo,
|
||||||
|
dadosSla.porPrioridade.alta.dentroPrazo,
|
||||||
|
dadosSla.porPrioridade.critica.dentroPrazo,
|
||||||
|
],
|
||||||
|
backgroundColor: cores.dentroPrazo,
|
||||||
|
borderColor: 'rgba(34, 197, 94, 1)',
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Próximo ao Vencimento',
|
||||||
|
data: [
|
||||||
|
dadosSla.porPrioridade.baixa.proximoVencimento,
|
||||||
|
dadosSla.porPrioridade.media.proximoVencimento,
|
||||||
|
dadosSla.porPrioridade.alta.proximoVencimento,
|
||||||
|
dadosSla.porPrioridade.critica.proximoVencimento,
|
||||||
|
],
|
||||||
|
backgroundColor: cores.proximoVencimento,
|
||||||
|
borderColor: 'rgba(251, 191, 36, 1)',
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Vencido',
|
||||||
|
data: [
|
||||||
|
dadosSla.porPrioridade.baixa.vencido,
|
||||||
|
dadosSla.porPrioridade.media.vencido,
|
||||||
|
dadosSla.porPrioridade.alta.vencido,
|
||||||
|
dadosSla.porPrioridade.critica.vencido,
|
||||||
|
],
|
||||||
|
backgroundColor: cores.vencido,
|
||||||
|
borderColor: 'rgba(239, 68, 68, 1)',
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (canvas) {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (ctx) {
|
||||||
|
const chartData = prepararDados();
|
||||||
|
chart = new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: chartData,
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: true,
|
||||||
|
position: 'top',
|
||||||
|
labels: {
|
||||||
|
color: '#a6adbb',
|
||||||
|
font: {
|
||||||
|
size: 12,
|
||||||
|
family: "'Inter', sans-serif",
|
||||||
|
},
|
||||||
|
usePointStyle: true,
|
||||||
|
padding: 15,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||||
|
titleColor: '#fff',
|
||||||
|
bodyColor: '#fff',
|
||||||
|
borderColor: '#570df8',
|
||||||
|
borderWidth: 1,
|
||||||
|
padding: 12,
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
const label = context.dataset.label || '';
|
||||||
|
const value = context.parsed.y;
|
||||||
|
const prioridade = context.label;
|
||||||
|
return `${label}: ${value} chamado(s)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
stacked: true,
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.05)',
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: '#a6adbb',
|
||||||
|
font: {
|
||||||
|
size: 11,
|
||||||
|
weight: '500',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
stacked: true,
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.05)',
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: '#a6adbb',
|
||||||
|
font: {
|
||||||
|
size: 11,
|
||||||
|
},
|
||||||
|
stepSize: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
duration: 800,
|
||||||
|
easing: 'easeInOutQuart'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (chart && dadosSla) {
|
||||||
|
const chartData = prepararDados();
|
||||||
|
chart.data = chartData;
|
||||||
|
chart.update('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (chart) {
|
||||||
|
chart.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div style="height: {height}px; position: relative;">
|
||||||
|
<canvas bind:this={canvas}></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
107
apps/web/src/lib/components/chamados/TicketCard.svelte
Normal file
107
apps/web/src/lib/components/chamados/TicketCard.svelte
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Doc, Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||||
|
import {
|
||||||
|
corPrazo,
|
||||||
|
formatarData,
|
||||||
|
getStatusBadge,
|
||||||
|
getStatusDescription,
|
||||||
|
getStatusLabel,
|
||||||
|
prazoRestante,
|
||||||
|
} from "$lib/utils/chamados";
|
||||||
|
import { createEventDispatcher } from "svelte";
|
||||||
|
|
||||||
|
type Ticket = Doc<"tickets">;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
ticket: Ticket;
|
||||||
|
selected?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{ select: { ticketId: Id<"tickets"> } }>();
|
||||||
|
const props = $props<Props>();
|
||||||
|
const ticket = $derived(props.ticket);
|
||||||
|
const selected = $derived(props.selected ?? false);
|
||||||
|
|
||||||
|
const prioridadeClasses: Record<string, string> = {
|
||||||
|
baixa: "badge badge-sm bg-base-200 text-base-content/70",
|
||||||
|
media: "badge badge-sm badge-info badge-outline",
|
||||||
|
alta: "badge badge-sm badge-warning",
|
||||||
|
critica: "badge badge-sm badge-error",
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleSelect() {
|
||||||
|
dispatch("select", { ticketId: ticket._id });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrazoBadges() {
|
||||||
|
const badges: Array<{ label: string; classe: string }> = [];
|
||||||
|
if (ticket.prazoResposta) {
|
||||||
|
const cor = corPrazo(ticket.prazoResposta);
|
||||||
|
badges.push({
|
||||||
|
label: `Resposta ${prazoRestante(ticket.prazoResposta) ?? ""}`,
|
||||||
|
classe: `badge badge-xs ${
|
||||||
|
cor === "error" ? "badge-error" : cor === "warning" ? "badge-warning" : "badge-success"
|
||||||
|
}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (ticket.prazoConclusao) {
|
||||||
|
const cor = corPrazo(ticket.prazoConclusao);
|
||||||
|
badges.push({
|
||||||
|
label: `Conclusão ${prazoRestante(ticket.prazoConclusao) ?? ""}`,
|
||||||
|
classe: `badge badge-xs ${
|
||||||
|
cor === "error" ? "badge-error" : cor === "warning" ? "badge-warning" : "badge-success"
|
||||||
|
}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return badges;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<article
|
||||||
|
class={`rounded-2xl border p-4 transition-all duration-200 ${
|
||||||
|
selected
|
||||||
|
? "border-primary bg-primary/5 shadow-lg"
|
||||||
|
: "border-base-200 bg-base-100/70 hover:border-primary/40 hover:shadow-md"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button class="w-full text-left" type="button" onclick={handleSelect}>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase tracking-wide text-base-content/50">
|
||||||
|
Ticket {ticket.numero}
|
||||||
|
</p>
|
||||||
|
<h3 class="text-lg font-semibold text-base-content">{ticket.titulo}</h3>
|
||||||
|
</div>
|
||||||
|
<span class={getStatusBadge(ticket.status)}>{getStatusLabel(ticket.status)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-base-content/60 mt-2 text-sm line-clamp-2">{ticket.descricao}</p>
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-wrap items-center gap-2 text-xs text-base-content/60">
|
||||||
|
<span class={prioridadeClasses[ticket.prioridade] ?? "badge badge-sm"}>
|
||||||
|
Prioridade {ticket.prioridade}
|
||||||
|
</span>
|
||||||
|
<span class="badge badge-xs badge-outline">
|
||||||
|
{ticket.tipo.charAt(0).toUpperCase() + ticket.tipo.slice(1)}
|
||||||
|
</span>
|
||||||
|
{#if ticket.setorResponsavel}
|
||||||
|
<span class="badge badge-xs badge-outline badge-ghost">
|
||||||
|
{ticket.setorResponsavel}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 space-y-1 text-xs text-base-content/50">
|
||||||
|
<p>
|
||||||
|
Última interação: {formatarData(ticket.ultimaInteracaoEm)}
|
||||||
|
</p>
|
||||||
|
<p>{getStatusDescription(ticket.status)}</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each getPrazoBadges() as badge (badge.label)}
|
||||||
|
<span class={badge.classe}>{badge.label}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
|
||||||
222
apps/web/src/lib/components/chamados/TicketForm.svelte
Normal file
222
apps/web/src/lib/components/chamados/TicketForm.svelte
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Doc } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||||
|
import { createEventDispatcher } from "svelte";
|
||||||
|
|
||||||
|
interface FormValues {
|
||||||
|
titulo: string;
|
||||||
|
descricao: string;
|
||||||
|
tipo: Doc<"tickets">["tipo"];
|
||||||
|
prioridade: Doc<"tickets">["prioridade"];
|
||||||
|
categoria: string;
|
||||||
|
canalOrigem?: string;
|
||||||
|
anexos: File[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{ submit: { values: FormValues } }>();
|
||||||
|
const props = $props<Props>();
|
||||||
|
const loading = $derived(props.loading ?? false);
|
||||||
|
|
||||||
|
let titulo = $state("");
|
||||||
|
let descricao = $state("");
|
||||||
|
let tipo = $state<Doc<"tickets">["tipo"]>("chamado");
|
||||||
|
let prioridade = $state<Doc<"tickets">["prioridade"]>("media");
|
||||||
|
let categoria = $state("");
|
||||||
|
let canalOrigem = $state("Portal SGSE");
|
||||||
|
let anexos = $state<Array<File>>([]);
|
||||||
|
let errors = $state<Record<string, string>>({});
|
||||||
|
function validate(): boolean {
|
||||||
|
const novoErros: Record<string, string> = {};
|
||||||
|
if (!titulo.trim()) novoErros.titulo = "Informe um título para o chamado.";
|
||||||
|
if (!descricao.trim()) novoErros.descricao = "Descrição é obrigatória.";
|
||||||
|
if (!categoria.trim()) novoErros.categoria = "Informe uma categoria.";
|
||||||
|
errors = novoErros;
|
||||||
|
return Object.keys(novoErros).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFiles(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
const files = Array.from(target.files ?? []);
|
||||||
|
anexos = files.slice(0, 5); // limitar para 5 anexos
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFile(index: number) {
|
||||||
|
anexos = anexos.filter((_, idx) => idx !== index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
titulo = "";
|
||||||
|
descricao = "";
|
||||||
|
categoria = "";
|
||||||
|
tipo = "chamado";
|
||||||
|
prioridade = "media";
|
||||||
|
anexos = [];
|
||||||
|
errors = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!validate()) return;
|
||||||
|
|
||||||
|
dispatch("submit", {
|
||||||
|
values: {
|
||||||
|
titulo: titulo.trim(),
|
||||||
|
descricao: descricao.trim(),
|
||||||
|
tipo,
|
||||||
|
prioridade,
|
||||||
|
categoria: categoria.trim(),
|
||||||
|
canalOrigem,
|
||||||
|
anexos,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form class="space-y-8" onsubmit={handleSubmit}>
|
||||||
|
<section class="grid gap-6 md:grid-cols-2">
|
||||||
|
<div class="form-control md:col-span-2">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Título do chamado</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-primary w-full"
|
||||||
|
placeholder="Ex: Erro ao acessar o módulo de licitações"
|
||||||
|
bind:value={titulo}
|
||||||
|
/>
|
||||||
|
{#if errors.titulo}
|
||||||
|
<span class="text-error mt-1 text-sm">{errors.titulo}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Tipo de solicitação</span>
|
||||||
|
</label>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
{#each ["chamado", "reclamacao", "elogio", "sugestao"] as opcao}
|
||||||
|
<label class="btn btn-outline btn-sm justify-start gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="tipo"
|
||||||
|
class="radio radio-primary"
|
||||||
|
value={opcao}
|
||||||
|
checked={tipo === opcao}
|
||||||
|
onclick={() => (tipo = opcao as typeof tipo)}
|
||||||
|
/>
|
||||||
|
{opcao.charAt(0).toUpperCase() + opcao.slice(1)}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Prioridade</span>
|
||||||
|
</label>
|
||||||
|
<select class="select select-bordered w-full" bind:value={prioridade}>
|
||||||
|
<option value="baixa">Baixa</option>
|
||||||
|
<option value="media">Média</option>
|
||||||
|
<option value="alta">Alta</option>
|
||||||
|
<option value="critica">Crítica</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Categoria</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
placeholder="Ex: Infraestrutura, Sistemas, Acesso"
|
||||||
|
bind:value={categoria}
|
||||||
|
/>
|
||||||
|
{#if errors.categoria}
|
||||||
|
<span class="text-error mt-1 text-sm">{errors.categoria}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Descrição detalhada</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
class="textarea textarea-bordered textarea-lg min-h-[180px]"
|
||||||
|
placeholder="Descreva o problema, erro ou sugestão com o máximo de detalhes possível."
|
||||||
|
bind:value={descricao}
|
||||||
|
></textarea>
|
||||||
|
{#if errors.descricao}
|
||||||
|
<span class="text-error mt-1 text-sm">{errors.descricao}</span>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-base-content">Anexos (opcional)</p>
|
||||||
|
<p class="text-base-content/60 text-sm">
|
||||||
|
Suporte a PDF e imagens (máx. 10MB por arquivo)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label class="btn btn-outline btn-sm">
|
||||||
|
Selecionar arquivos
|
||||||
|
<input type="file" class="hidden" multiple accept=".pdf,.png,.jpg,.jpeg" onchange={handleFiles} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if anexos.length > 0}
|
||||||
|
<div class="space-y-2 rounded-2xl border border-base-200 bg-base-100/70 p-4">
|
||||||
|
{#each anexos as file, index (file.name + index)}
|
||||||
|
<div class="flex items-center justify-between gap-3 rounded-xl border border-base-200 bg-base-100 px-3 py-2">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium">{file.name}</p>
|
||||||
|
<p class="text-xs text-base-content/60">
|
||||||
|
{(file.size / 1024 / 1024).toFixed(2)} MB • {file.type}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-sm text-error"
|
||||||
|
onclick={() => removeFile(index)}
|
||||||
|
>
|
||||||
|
Remover
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="rounded-2xl border border-dashed border-base-300 bg-base-100/50 p-6 text-center text-sm text-base-content/60">
|
||||||
|
Nenhum arquivo selecionado.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary flex-1 min-w-[200px]"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{#if loading}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
Enviando...
|
||||||
|
{:else}
|
||||||
|
Registrar chamado
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost"
|
||||||
|
onclick={resetForm}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Limpar
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
|
||||||
86
apps/web/src/lib/components/chamados/TicketTimeline.svelte
Normal file
86
apps/web/src/lib/components/chamados/TicketTimeline.svelte
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Doc } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||||
|
import {
|
||||||
|
formatarData,
|
||||||
|
formatarTimelineEtapa,
|
||||||
|
prazoRestante,
|
||||||
|
timelineStatus,
|
||||||
|
} from "$lib/utils/chamados";
|
||||||
|
|
||||||
|
type Ticket = Doc<"tickets">;
|
||||||
|
type TimelineEntry = NonNullable<Ticket["timeline"]>[number];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
timeline?: Array<TimelineEntry>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = $props<Props>();
|
||||||
|
const timeline = $derived<Array<TimelineEntry>>(props.timeline ?? []);
|
||||||
|
|
||||||
|
const badgeClasses: Record<string, string> = {
|
||||||
|
success: "bg-success/20 text-success border-success/40",
|
||||||
|
warning: "bg-warning/20 text-warning border-warning/40",
|
||||||
|
error: "bg-error/20 text-error border-error/40",
|
||||||
|
info: "bg-info/20 text-info border-info/40",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getBadgeClass(entry: TimelineEntry) {
|
||||||
|
const status = timelineStatus(entry);
|
||||||
|
return badgeClasses[status] ?? badgeClasses.info;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(entry: TimelineEntry) {
|
||||||
|
if (entry.status === "concluido") return "Concluído";
|
||||||
|
if (entry.status === "em_andamento") return "Em andamento";
|
||||||
|
if (entry.status === "vencido") return "Vencido";
|
||||||
|
return "Pendente";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrazoDescricao(entry: TimelineEntry) {
|
||||||
|
if (entry.status === "concluido" && entry.concluidoEm) {
|
||||||
|
return `Concluído em ${formatarData(entry.concluidoEm)}`;
|
||||||
|
}
|
||||||
|
if (!entry.prazo) return "Sem prazo definido";
|
||||||
|
return `${formatarData(entry.prazo)} • ${prazoRestante(entry.prazo) ?? ""}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#if timeline.length === 0}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<span>Nenhuma etapa registrada ainda.</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each timeline as entry (entry.etapa + entry.prazo)}
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<div class="relative flex flex-col items-center">
|
||||||
|
<div class={`badge border ${getBadgeClass(entry)}`}>
|
||||||
|
{formatarTimelineEtapa(entry.etapa)}
|
||||||
|
</div>
|
||||||
|
{#if entry !== timeline[timeline.length - 1]}
|
||||||
|
<div class="bg-base-200/80 mt-2 h-full w-px flex-1"></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 rounded-2xl border border-base-200 bg-base-100/80 p-4 shadow-sm">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="text-sm font-semibold text-base-content">
|
||||||
|
{getStatusLabel(entry)}
|
||||||
|
</span>
|
||||||
|
{#if entry.status !== "concluido" && entry.prazo}
|
||||||
|
<span class="badge badge-sm badge-outline">
|
||||||
|
{prazoRestante(entry.prazo)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if entry.observacao}
|
||||||
|
<p class="text-base-content/70 mt-2 text-sm">{entry.observacao}</p>
|
||||||
|
{/if}
|
||||||
|
<p class="text-base-content/50 mt-3 text-xs uppercase tracking-wide">
|
||||||
|
{getPrazoDescricao(entry)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
@@ -30,15 +30,25 @@ export function useConvexWithAuth() {
|
|||||||
const clientWithAuth = client as ConvexClientWithAuth;
|
const clientWithAuth = client as ConvexClientWithAuth;
|
||||||
|
|
||||||
// Configurar token se disponível
|
// Configurar token se disponível
|
||||||
if (clientWithAuth && typeof clientWithAuth.setAuth === "function" && token) {
|
if (clientWithAuth && token) {
|
||||||
try {
|
try {
|
||||||
clientWithAuth.setAuth(token);
|
// Tentar setAuth se disponível
|
||||||
if (import.meta.env.DEV) {
|
if (typeof clientWithAuth.setAuth === "function") {
|
||||||
console.log("✅ [useConvexWithAuth] Token configurado:", token.substring(0, 20) + "...");
|
clientWithAuth.setAuth(token);
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.log("✅ [useConvexWithAuth] Token configurado via setAuth:", token.substring(0, 20) + "...");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Se setAuth não estiver disponível, o token deve ser passado via createSvelteAuthClient
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.log("ℹ️ [useConvexWithAuth] Token disponível, autenticação gerenciada por createSvelteAuthClient");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("⚠️ [useConvexWithAuth] Erro ao configurar token:", e);
|
console.warn("⚠️ [useConvexWithAuth] Erro ao configurar token:", e);
|
||||||
}
|
}
|
||||||
|
} else if (!token && import.meta.env.DEV) {
|
||||||
|
console.warn("⚠️ [useConvexWithAuth] Token não disponível");
|
||||||
}
|
}
|
||||||
|
|
||||||
return client;
|
return client;
|
||||||
|
|||||||
53
apps/web/src/lib/stores/chamados.ts
Normal file
53
apps/web/src/lib/stores/chamados.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { writable } from "svelte/store";
|
||||||
|
import type { Doc, Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||||
|
|
||||||
|
export type TicketDetalhe = {
|
||||||
|
ticket: Doc<"tickets">;
|
||||||
|
interactions: Doc<"ticketInteractions">[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function createChamadosStore() {
|
||||||
|
const tickets = writable<Array<Doc<"tickets">>>([]);
|
||||||
|
const detalhes = writable<Record<string, TicketDetalhe>>({});
|
||||||
|
const carregando = writable(false);
|
||||||
|
|
||||||
|
function setTickets(lista: Array<Doc<"tickets">>) {
|
||||||
|
tickets.set(lista);
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertTicket(ticket: Doc<"tickets">) {
|
||||||
|
tickets.update((current) => {
|
||||||
|
const existente = current.findIndex((t) => t._id === ticket._id);
|
||||||
|
if (existente >= 0) {
|
||||||
|
const copia = [...current];
|
||||||
|
copia[existente] = ticket;
|
||||||
|
return copia;
|
||||||
|
}
|
||||||
|
return [ticket, ...current];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDetalhe(ticketId: Id<"tickets">, detalhe: TicketDetalhe) {
|
||||||
|
detalhes.update((mapa) => ({
|
||||||
|
...mapa,
|
||||||
|
[ticketId]: detalhe,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCarregando(flag: boolean) {
|
||||||
|
carregando.set(flag);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tickets,
|
||||||
|
detalhes,
|
||||||
|
carregando,
|
||||||
|
setTickets,
|
||||||
|
upsertTicket,
|
||||||
|
setDetalhe,
|
||||||
|
setCarregando,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const chamadosStore = createChamadosStore();
|
||||||
|
|
||||||
123
apps/web/src/lib/utils/chamados.ts
Normal file
123
apps/web/src/lib/utils/chamados.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import type { Doc } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||||
|
|
||||||
|
type Ticket = Doc<"tickets">;
|
||||||
|
type TicketStatus = Ticket["status"];
|
||||||
|
type TimelineEntry = NonNullable<Ticket["timeline"]>[number];
|
||||||
|
|
||||||
|
const UM_DIA_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const statusConfig: Record<
|
||||||
|
TicketStatus,
|
||||||
|
{
|
||||||
|
label: string;
|
||||||
|
badge: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
aberto: {
|
||||||
|
label: "Aberto",
|
||||||
|
badge: "badge badge-info badge-outline",
|
||||||
|
description: "Chamado recebido e aguardando triagem.",
|
||||||
|
},
|
||||||
|
em_andamento: {
|
||||||
|
label: "Em andamento",
|
||||||
|
badge: "badge badge-primary",
|
||||||
|
description: "Equipe de TI trabalhando no chamado.",
|
||||||
|
},
|
||||||
|
aguardando_usuario: {
|
||||||
|
label: "Aguardando usuário",
|
||||||
|
badge: "badge badge-warning",
|
||||||
|
description: "Aguardando retorno ou aprovação do solicitante.",
|
||||||
|
},
|
||||||
|
resolvido: {
|
||||||
|
label: "Resolvido",
|
||||||
|
badge: "badge badge-success badge-outline",
|
||||||
|
description: "Solução aplicada, aguardando confirmação.",
|
||||||
|
},
|
||||||
|
encerrado: {
|
||||||
|
label: "Encerrado",
|
||||||
|
badge: "badge badge-success",
|
||||||
|
description: "Chamado finalizado.",
|
||||||
|
},
|
||||||
|
cancelado: {
|
||||||
|
label: "Cancelado",
|
||||||
|
badge: "badge badge-neutral",
|
||||||
|
description: "Chamado cancelado.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getStatusLabel(status: TicketStatus): string {
|
||||||
|
return statusConfig[status]?.label ?? status;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatusBadge(status: TicketStatus): string {
|
||||||
|
return statusConfig[status]?.badge ?? "badge";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatusDescription(status: TicketStatus): string {
|
||||||
|
return statusConfig[status]?.description ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatarData(timestamp?: number | null) {
|
||||||
|
if (!timestamp) return "--";
|
||||||
|
return new Date(timestamp).toLocaleString("pt-BR", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prazoRestante(timestamp?: number | null) {
|
||||||
|
if (!timestamp) return null;
|
||||||
|
const diff = timestamp - Date.now();
|
||||||
|
const dias = Math.floor(diff / UM_DIA_MS);
|
||||||
|
const horas = Math.floor((diff % UM_DIA_MS) / (60 * 60 * 1000));
|
||||||
|
|
||||||
|
if (diff < 0) {
|
||||||
|
return `Vencido há ${Math.abs(dias)}d ${Math.abs(horas)}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dias === 0 && horas >= 0) {
|
||||||
|
return `Vence em ${horas}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Vence em ${dias}d ${Math.abs(horas)}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function corPrazo(timestamp?: number | null) {
|
||||||
|
if (!timestamp) return "info";
|
||||||
|
const diff = timestamp - Date.now();
|
||||||
|
if (diff < 0) return "error";
|
||||||
|
if (diff <= UM_DIA_MS) return "warning";
|
||||||
|
return "success";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timelineStatus(entry: TimelineEntry) {
|
||||||
|
if (entry.status === "concluido") {
|
||||||
|
return "success";
|
||||||
|
}
|
||||||
|
if (!entry.prazo) {
|
||||||
|
return "info";
|
||||||
|
}
|
||||||
|
const diff = entry.prazo - Date.now();
|
||||||
|
if (diff < 0) {
|
||||||
|
return "error";
|
||||||
|
}
|
||||||
|
if (diff <= UM_DIA_MS) {
|
||||||
|
return "warning";
|
||||||
|
}
|
||||||
|
return "info";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatarTimelineEtapa(etapa: string) {
|
||||||
|
const mapa: Record<string, string> = {
|
||||||
|
abertura: "Registro",
|
||||||
|
resposta_inicial: "Resposta inicial",
|
||||||
|
conclusao: "Conclusão",
|
||||||
|
encerramento: "Encerramento",
|
||||||
|
};
|
||||||
|
|
||||||
|
return mapa[etapa] ?? etapa;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
// Resolver recurso/ação a partir da rota
|
// Resolver recurso/ação a partir da rota
|
||||||
const routeAction = $derived.by(() => {
|
const routeAction = $derived.by(() => {
|
||||||
const p = page.url.pathname;
|
const p = page.url.pathname;
|
||||||
if (p === '/' || p === '/solicitar-acesso') return null;
|
if (p === '/' || p === '/abrir-chamado') return null;
|
||||||
|
|
||||||
// Funcionários
|
// Funcionários
|
||||||
if (p.startsWith('/recursos-humanos/funcionarios')) {
|
if (p.startsWith('/recursos-humanos/funcionarios')) {
|
||||||
|
|||||||
@@ -146,13 +146,13 @@
|
|||||||
<p class="text-sm">{alertData.message}</p>
|
<p class="text-sm">{alertData.message}</p>
|
||||||
{#if alertType === "access_denied"}
|
{#if alertType === "access_denied"}
|
||||||
<div class="mt-3 flex gap-2">
|
<div class="mt-3 flex gap-2">
|
||||||
<a href={resolve("/solicitar-acesso")} class="btn btn-sm btn-primary">
|
<a href={resolve("/abrir-chamado")} class="btn btn-sm btn-primary">
|
||||||
<svelte:component
|
<svelte:component
|
||||||
this={UserPlus}
|
this={UserPlus}
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
/>
|
/>
|
||||||
Solicitar Acesso
|
Abrir Chamado
|
||||||
</a>
|
</a>
|
||||||
<a href={resolve("/ti")} class="btn btn-sm btn-ghost">
|
<a href={resolve("/ti")} class="btn btn-sm btn-ghost">
|
||||||
<svelte:component this={Mail} class="h-4 w-4" strokeWidth={2} />
|
<svelte:component this={Mail} class="h-4 w-4" strokeWidth={2} />
|
||||||
|
|||||||
194
apps/web/src/routes/(dashboard)/abrir-chamado/+page.svelte
Normal file
194
apps/web/src/routes/(dashboard)/abrir-chamado/+page.svelte
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { useConvexClient } from "convex-svelte";
|
||||||
|
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||||
|
import type { Doc, Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||||
|
import TicketForm from "$lib/components/chamados/TicketForm.svelte";
|
||||||
|
import TicketTimeline from "$lib/components/chamados/TicketTimeline.svelte";
|
||||||
|
import { chamadosStore } from "$lib/stores/chamados";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
|
import { useConvexWithAuth } from "$lib/hooks/useConvexWithAuth";
|
||||||
|
|
||||||
|
type Ticket = Doc<"tickets">;
|
||||||
|
|
||||||
|
const client = useConvexClient();
|
||||||
|
|
||||||
|
let submitLoading = $state(false);
|
||||||
|
let resetSignal = $state(0);
|
||||||
|
let feedback = $state<{ tipo: "success" | "error"; mensagem: string; numero?: string } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const exemploTimeline = $state<NonNullable<Ticket["timeline"]>>([
|
||||||
|
{
|
||||||
|
etapa: "abertura",
|
||||||
|
status: "concluido",
|
||||||
|
prazo: Date.now(),
|
||||||
|
concluidoEm: Date.now(),
|
||||||
|
observacao: "Chamado criado",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
etapa: "resposta_inicial",
|
||||||
|
status: "pendente",
|
||||||
|
prazo: Date.now() + 4 * 60 * 60 * 1000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
etapa: "conclusao",
|
||||||
|
status: "pendente",
|
||||||
|
prazo: Date.now() + 24 * 60 * 60 * 1000,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Garante que o cliente Convex use o token do usuário logado
|
||||||
|
useConvexWithAuth();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function uploadArquivo(file: File) {
|
||||||
|
const uploadUrl = await client.mutation(api.chamados.generateUploadUrl, {});
|
||||||
|
|
||||||
|
const response = await fetch(uploadUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": file.type },
|
||||||
|
body: file,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data?.storageId) {
|
||||||
|
throw new Error("Falha ao enviar arquivo. Tente novamente.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
arquivoId: data.storageId as Id<"_storage">,
|
||||||
|
nome: file.name,
|
||||||
|
tipo: file.type,
|
||||||
|
tamanho: file.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(event: CustomEvent<{ values: any }>) {
|
||||||
|
const { values } = event.detail;
|
||||||
|
try {
|
||||||
|
submitLoading = true;
|
||||||
|
feedback = null;
|
||||||
|
|
||||||
|
const anexos = [];
|
||||||
|
for (const file of values.anexos ?? []) {
|
||||||
|
const uploaded = await uploadArquivo(file);
|
||||||
|
anexos.push(uploaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultado = await client.mutation(api.chamados.abrirChamado, {
|
||||||
|
titulo: values.titulo,
|
||||||
|
descricao: values.descricao,
|
||||||
|
tipo: values.tipo,
|
||||||
|
categoria: values.categoria,
|
||||||
|
prioridade: values.prioridade,
|
||||||
|
canalOrigem: values.canalOrigem,
|
||||||
|
anexos,
|
||||||
|
});
|
||||||
|
|
||||||
|
feedback = {
|
||||||
|
tipo: "success",
|
||||||
|
mensagem: "Chamado registrado com sucesso! Você pode acompanhar pelo seu perfil.",
|
||||||
|
numero: resultado.numero,
|
||||||
|
};
|
||||||
|
resetSignal = resetSignal + 1;
|
||||||
|
|
||||||
|
// Atualizar store local
|
||||||
|
const novoTicket = await client.query(api.chamados.obterChamado, {
|
||||||
|
ticketId: resultado.ticketId,
|
||||||
|
});
|
||||||
|
if (novoTicket?.ticket) {
|
||||||
|
chamadosStore.upsertTicket(novoTicket.ticket);
|
||||||
|
chamadosStore.setDetalhe(resultado.ticketId, novoTicket);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const mensagem =
|
||||||
|
error instanceof Error ? error.message : "Erro ao enviar o chamado. Tente novamente.";
|
||||||
|
feedback = {
|
||||||
|
tipo: "error",
|
||||||
|
mensagem,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
submitLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="mx-auto w-full max-w-6xl space-y-10 px-4 py-8">
|
||||||
|
<section
|
||||||
|
class="relative overflow-hidden rounded-3xl border border-primary/30 bg-linear-to-br from-primary/10 via-base-100 to-secondary/20 p-10 shadow-2xl"
|
||||||
|
>
|
||||||
|
<div class="absolute -left-16 top-0 h-52 w-52 rounded-full bg-primary/20 blur-3xl"></div>
|
||||||
|
<div class="absolute -bottom-20 right-0 h-64 w-64 rounded-full bg-secondary/20 blur-3xl"></div>
|
||||||
|
|
||||||
|
<div class="relative z-10 space-y-4">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-2 rounded-full border border-primary/40 bg-primary/10 px-4 py-1 text-xs font-semibold uppercase tracking-[0.28em] text-primary"
|
||||||
|
>
|
||||||
|
Central de Chamados
|
||||||
|
</span>
|
||||||
|
<div class="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div class="max-w-3xl space-y-4">
|
||||||
|
<h1 class="text-4xl font-black leading-tight text-base-content sm:text-5xl">
|
||||||
|
Abrir novo chamado
|
||||||
|
</h1>
|
||||||
|
<p class="text-base text-base-content/70 sm:text-lg">
|
||||||
|
Registre reclamações, sugestões, elogios ou chamados técnicos. Toda interação gera
|
||||||
|
notificações automáticas via e-mail e chat com a assinatura do SGSE.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-3 text-sm text-base-content/70">
|
||||||
|
<span class="badge badge-success badge-sm">Resposta ágil</span>
|
||||||
|
<span class="badge badge-info badge-sm">Timeline com SLA</span>
|
||||||
|
<span class="badge badge-warning badge-sm">Alertas de vencimento</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href={resolve("/perfil/chamados")} class="btn btn-outline btn-sm">
|
||||||
|
Acompanhar meus chamados
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if feedback}
|
||||||
|
<div class={`alert ${feedback.tipo === "success" ? "alert-success" : "alert-error"} shadow-lg`}>
|
||||||
|
<div>
|
||||||
|
<span class="font-semibold">{feedback.mensagem}</span>
|
||||||
|
{#if feedback.numero}
|
||||||
|
<p class="text-sm">Número do ticket: {feedback.numero}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="grid gap-8 lg:grid-cols-3">
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<div class="rounded-3xl border border-base-200 bg-base-100/90 p-6 shadow-xl">
|
||||||
|
<h2 class="text-xl font-semibold text-base-content">Formulário</h2>
|
||||||
|
<p class="text-base-content/60 text-sm">
|
||||||
|
Informe os detalhes para que nossa equipe possa priorizar o atendimento.
|
||||||
|
</p>
|
||||||
|
<div class="mt-6">
|
||||||
|
{#if resetSignal % 2 === 0}
|
||||||
|
<TicketForm loading={submitLoading} on:submit={handleSubmit} />
|
||||||
|
{:else}
|
||||||
|
<TicketForm loading={submitLoading} on:submit={handleSubmit} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="space-y-6">
|
||||||
|
<div class="rounded-3xl border border-base-200 bg-base-100/90 p-6 shadow-lg">
|
||||||
|
<h3 class="font-semibold text-base-content">Como funciona a timeline</h3>
|
||||||
|
<p class="text-sm text-base-content/60 mb-4">
|
||||||
|
Todas as etapas do ticket são monitoradas automaticamente. Os prazos mudam de cor conforme
|
||||||
|
o SLA.
|
||||||
|
</p>
|
||||||
|
<TicketTimeline timeline={exemploTimeline} />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { FileText, ClipboardCopy, Plus, Users, FileDoc } from "lucide-svelte";
|
import { FileText, ClipboardCopy, Plus, Users, File } from "lucide-svelte";
|
||||||
import { resolve } from "$app/paths";
|
import { resolve } from "$app/paths";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<FileDoc class="h-24 w-24 text-base-content/20" strokeWidth={1.5} />
|
<File class="h-24 w-24 text-base-content/20" strokeWidth={1.5} />
|
||||||
</div>
|
</div>
|
||||||
<h2 class="text-2xl font-bold mb-2">Módulo em Desenvolvimento</h2>
|
<h2 class="text-2xl font-bold mb-2">Módulo em Desenvolvimento</h2>
|
||||||
<p class="text-base-content/70 max-w-md mb-6">
|
<p class="text-base-content/70 max-w-md mb-6">
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="flex items-center gap-3 mb-2">
|
<div class="flex items-center gap-3 mb-2">
|
||||||
<div class="p-2 bg-primary/10 rounded-lg">
|
<div class="p-2 bg-primary/10 rounded-lg">
|
||||||
<FileDoc class="h-6 w-6 text-primary" strokeWidth={2} />
|
<File class="h-6 w-6 text-primary" strokeWidth={2} />
|
||||||
</div>
|
</div>
|
||||||
<h4 class="font-semibold">Documentação</h4>
|
<h4 class="font-semibold">Documentação</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,28 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import AprovarFerias from '$lib/components/AprovarFerias.svelte';
|
import AprovarFerias from '$lib/components/AprovarFerias.svelte';
|
||||||
import WizardSolicitacaoFerias from '$lib/components/ferias/WizardSolicitacaoFerias.svelte';
|
import WizardSolicitacaoFerias from '$lib/components/ferias/WizardSolicitacaoFerias.svelte';
|
||||||
import WizardSolicitacaoAusencia from '$lib/components/ausencias/WizardSolicitacaoAusencia.svelte';
|
import WizardSolicitacaoAusencia from '$lib/components/ausencias/WizardSolicitacaoAusencia.svelte';
|
||||||
import AprovarAusencias from '$lib/components/AprovarAusencias.svelte';
|
import AprovarAusencias from '$lib/components/AprovarAusencias.svelte';
|
||||||
import CalendarioAusencias from '$lib/components/ausencias/CalendarioAusencias.svelte';
|
import CalendarioAusencias from '$lib/components/ausencias/CalendarioAusencias.svelte';
|
||||||
import { generateAvatarGallery } from '$lib/utils/avatars';
|
import { generateAvatarGallery } from '$lib/utils/avatars';
|
||||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
|
import type { FunctionReturnType } from 'convex/server';
|
||||||
import { X, Calendar } from 'lucide-svelte';
|
import { X, Calendar } from 'lucide-svelte';
|
||||||
import type { FunctionReturnType } from 'convex/server';
|
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||||
|
|
||||||
|
type FuncionarioAtual = FunctionReturnType<typeof api.funcionarios.getCurrent>;
|
||||||
|
type TimeAtual = FunctionReturnType<typeof api.times.obterTimeFuncionario>;
|
||||||
|
type MinhasSolicitacoes = FunctionReturnType<typeof api.ferias.listarMinhasSolicitacoes>;
|
||||||
|
type MinhasAusencias = FunctionReturnType<typeof api.ausencias.listarMinhasSolicitacoes>;
|
||||||
|
let funcionarioEstavel = $state<FuncionarioAtual | null>(null);
|
||||||
|
let meuTimeEstavel = $state<TimeAtual | null>(null);
|
||||||
|
let minhasSolicitacoesEstaveis = $state<MinhasSolicitacoes>([]);
|
||||||
|
let minhasAusenciasEstaveis = $state<MinhasAusencias>([]);
|
||||||
|
|
||||||
let abaAtiva = $state<
|
let abaAtiva = $state<
|
||||||
'meu-perfil' | 'minhas-ferias' | 'minhas-ausencias' | 'aprovar-ferias' | 'aprovar-ausencias'
|
'meu-perfil' | 'minhas-ferias' | 'minhas-ausencias' | 'aprovar-ferias' | 'aprovar-ausencias'
|
||||||
@@ -44,126 +54,101 @@
|
|||||||
// FuncionarioId disponível diretamente do usuário atual
|
// FuncionarioId disponível diretamente do usuário atual
|
||||||
const funcionarioIdDisponivel = $derived(currentUser?.data?.funcionarioId ?? null);
|
const funcionarioIdDisponivel = $derived(currentUser?.data?.funcionarioId ?? null);
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
const funcionarioQuery = $derived(
|
const funcionarioQuery = $derived(
|
||||||
currentUser?.data?.funcionarioId ? useQuery(api.funcionarios.getCurrent, {}) : { data: null }
|
currentUser?.data?.funcionarioId ? useQuery(api.funcionarios.getCurrent, {}) : { data: null }
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (funcionarioQuery?.data) {
|
||||||
|
funcionarioEstavel = funcionarioQuery.data;
|
||||||
|
} else if (!currentUser?.data?.funcionarioId) {
|
||||||
|
funcionarioEstavel = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const solicitacoesSubordinadosQuery = $derived(
|
||||||
|
currentUser?.data?._id
|
||||||
|
? useQuery(api.ferias.listarSolicitacoesSubordinados, {
|
||||||
|
gestorId: currentUser.data._id as Id<'usuarios'>
|
||||||
|
})
|
||||||
|
: { data: [] }
|
||||||
|
);
|
||||||
|
|
||||||
|
const ausenciasSubordinadosQuery = $derived(
|
||||||
|
currentUser?.data?._id
|
||||||
|
? useQuery(api.ausencias.listarSolicitacoesSubordinados, {
|
||||||
|
gestorId: currentUser.data._id as Id<'usuarios'>
|
||||||
|
})
|
||||||
|
: { data: [] }
|
||||||
|
);
|
||||||
|
|
||||||
|
const minhasSolicitacoesQuery = $derived(
|
||||||
|
funcionarioEstavel?._id
|
||||||
|
? useQuery(api.ferias.listarMinhasSolicitacoes, {
|
||||||
|
funcionarioId: funcionarioEstavel._id
|
||||||
|
})
|
||||||
|
: { data: [] }
|
||||||
|
);
|
||||||
|
|
||||||
|
const minhasAusenciasQuery = $derived(
|
||||||
|
funcionarioEstavel?._id
|
||||||
|
? useQuery(api.ausencias.listarMinhasSolicitacoes, {
|
||||||
|
funcionarioId: funcionarioEstavel._id
|
||||||
|
})
|
||||||
|
: { data: [] }
|
||||||
|
);
|
||||||
|
|
||||||
|
const meuTimeQuery = $derived(
|
||||||
|
funcionarioEstavel?._id
|
||||||
|
? useQuery(api.times.obterTimeFuncionario, {
|
||||||
|
funcionarioId: funcionarioEstavel._id
|
||||||
|
})
|
||||||
|
: { data: null }
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (meuTimeQuery?.data) {
|
||||||
|
meuTimeEstavel = meuTimeQuery.data;
|
||||||
|
} else if (!funcionarioEstavel?._id) {
|
||||||
|
meuTimeEstavel = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const funcionario = $derived(funcionarioEstavel);
|
||||||
|
const solicitacoesSubordinados = $derived(solicitacoesSubordinadosQuery?.data || []);
|
||||||
|
const ausenciasSubordinados = $derived(ausenciasSubordinadosQuery?.data || []);
|
||||||
|
const meuTime = $derived(meuTimeEstavel);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (Array.isArray(minhasSolicitacoesQuery?.data)) {
|
||||||
|
minhasSolicitacoesEstaveis = minhasSolicitacoesQuery.data;
|
||||||
|
} else if (!funcionarioEstavel?._id) {
|
||||||
|
minhasSolicitacoesEstaveis = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (Array.isArray(minhasAusenciasQuery?.data)) {
|
||||||
|
minhasAusenciasEstaveis = minhasAusenciasQuery.data;
|
||||||
|
} else if (!funcionarioEstavel?._id) {
|
||||||
|
minhasAusenciasEstaveis = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const minhasSolicitacoes = $derived(minhasSolicitacoesEstaveis);
|
||||||
|
const minhasAusencias = $derived(minhasAusenciasEstaveis);
|
||||||
|
|
||||||
|
const timesSubordinadosQuery = $derived(useQuery(api.times.listarSubordinadosDoGestorAtual, {}));
|
||||||
|
const timesSubordinados = $derived(timesSubordinadosQuery?.data || []);
|
||||||
|
// Times gerenciados usam a query que já infere o gestor logado
|
||||||
|
const meusTimesGestor = $derived(timesSubordinados);
|
||||||
|
|
||||||
|
const rolePermiteAprovacao = $derived(
|
||||||
|
currentUser?.data?.role?.nome === 'TI_MASTER' || currentUser?.data?.role?.nome === 'TI_ADMIN'
|
||||||
);
|
);
|
||||||
|
|
||||||
const solicitacoesSubordinadosQuery = $derived(
|
const ehGestor = $derived((timesSubordinados || []).length > 0 || rolePermiteAprovacao);
|
||||||
currentUser?.data?._id
|
|
||||||
? useQuery(api.ferias.listarSolicitacoesSubordinados, {
|
|
||||||
gestorId: currentUser.data._id as Id<'usuarios'>
|
|
||||||
})
|
|
||||||
: { data: [] }
|
|
||||||
);
|
|
||||||
|
|
||||||
const ausenciasSubordinadosQuery = $derived(
|
|
||||||
currentUser?.data?._id
|
|
||||||
? useQuery(api.ausencias.listarSolicitacoesSubordinados, {
|
|
||||||
gestorId: currentUser.data._id as Id<'usuarios'>
|
|
||||||
})
|
|
||||||
: { data: [] }
|
|
||||||
);
|
|
||||||
|
|
||||||
const minhasSolicitacoesQuery = $derived(
|
|
||||||
funcionarioQuery.data
|
|
||||||
? useQuery(api.ferias.listarMinhasSolicitacoes, {
|
|
||||||
funcionarioId: funcionarioQuery.data._id
|
|
||||||
})
|
|
||||||
: { data: [] }
|
|
||||||
);
|
|
||||||
|
|
||||||
const minhasAusenciasQuery = $derived(
|
|
||||||
funcionarioQuery.data
|
|
||||||
? useQuery(api.ausencias.listarMinhasSolicitacoes, {
|
|
||||||
funcionarioId: funcionarioQuery.data._id
|
|
||||||
})
|
|
||||||
: { data: [] }
|
|
||||||
);
|
|
||||||
|
|
||||||
const meuTimeQuery = $derived(
|
|
||||||
funcionarioQuery.data
|
|
||||||
? useQuery(api.times.obterTimeFuncionario, {
|
|
||||||
funcionarioId: funcionarioQuery.data._id
|
|
||||||
})
|
|
||||||
: { data: null }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Query para times onde o usuário é gestor - usando $derived para garantir reatividade
|
|
||||||
const meusTimesGestorQuery = $derived(
|
|
||||||
currentUser?.data?._id
|
|
||||||
? useQuery(api.times.listarPorGestor, {
|
|
||||||
gestorId: currentUser.data._id as Id<'usuarios'>
|
|
||||||
})
|
|
||||||
: { data: [] }
|
|
||||||
);
|
|
||||||
|
|
||||||
const funcionario = $derived(funcionarioQuery.data);
|
|
||||||
const solicitacoesSubordinados = $derived(solicitacoesSubordinadosQuery?.data || []);
|
|
||||||
const ausenciasSubordinados = $derived(ausenciasSubordinadosQuery?.data || []);
|
|
||||||
const minhasSolicitacoes = $derived(minhasSolicitacoesQuery?.data || []);
|
|
||||||
const minhasAusencias = $derived(minhasAusenciasQuery?.data || []);
|
|
||||||
const meuTime = $derived(meuTimeQuery?.data);
|
|
||||||
|
|
||||||
// Extração de meusTimesGestor
|
|
||||||
const meusTimesGestor = $derived(meusTimesGestorQuery?.data || []);
|
|
||||||
|
|
||||||
// Estado estável para controlar se é gestor (evita desaparecimento das abas)
|
|
||||||
let ehGestorEstavel = $state(false);
|
|
||||||
let ultimaVerificacaoGestor = $state<number | null>(null);
|
|
||||||
|
|
||||||
// Calcular se é gestor - com lógica mais robusta (sem atualizar estado aqui)
|
|
||||||
const ehGestorCalculado = $derived.by(() => {
|
|
||||||
// Verificar se tem times como gestor
|
|
||||||
const temTimesComoGestor = (meusTimesGestor || []).length > 0;
|
|
||||||
|
|
||||||
// Verificar se tem role de TI que pode aprovar (TI_MASTER, TI_ADMIN)
|
|
||||||
const rolePermiteAprovacao = currentUser?.data?.role?.nome === 'TI_MASTER' ||
|
|
||||||
currentUser?.data?.role?.nome === 'TI_ADMIN';
|
|
||||||
|
|
||||||
// Verificar se tem solicitações de subordinados (indica que é gestor)
|
|
||||||
const temSolicitacoesSubordinados = (solicitacoesSubordinados || []).length > 0 ||
|
|
||||||
(ausenciasSubordinados || []).length > 0;
|
|
||||||
|
|
||||||
// É gestor se: tem times OU tem role de TI OU tem solicitações de subordinados
|
|
||||||
return temTimesComoGestor || rolePermiteAprovacao || temSolicitacoesSubordinados;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Efeito para atualizar o estado estável (evita desaparecimento das abas)
|
|
||||||
$effect(() => {
|
|
||||||
const resultado = ehGestorCalculado;
|
|
||||||
|
|
||||||
// Log para depuração (apenas quando mudar)
|
|
||||||
if (import.meta.env.DEV && resultado !== ehGestorEstavel) {
|
|
||||||
console.log('🔍 [Perfil] Status de gestor mudou:', {
|
|
||||||
temTimesComoGestor: (meusTimesGestor || []).length > 0,
|
|
||||||
rolePermiteAprovacao: currentUser?.data?.role?.nome === 'TI_MASTER' || currentUser?.data?.role?.nome === 'TI_ADMIN',
|
|
||||||
role: currentUser?.data?.role?.nome,
|
|
||||||
temSolicitacoesSubordinados: (solicitacoesSubordinados || []).length > 0 || (ausenciasSubordinados || []).length > 0,
|
|
||||||
resultado,
|
|
||||||
meusTimesGestor: meusTimesGestor?.length || 0,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Atualizar estado estável apenas se o resultado for true (para manter as abas visíveis)
|
|
||||||
// ou se já passou tempo suficiente desde a última verificação
|
|
||||||
const agora = Date.now();
|
|
||||||
if (resultado) {
|
|
||||||
ehGestorEstavel = true;
|
|
||||||
ultimaVerificacaoGestor = agora;
|
|
||||||
} else if (ultimaVerificacaoGestor === null || (agora - ultimaVerificacaoGestor) > 5000) {
|
|
||||||
// Só atualiza para false se passou mais de 5 segundos desde a última verificação positiva
|
|
||||||
// Isso evita que as abas desapareçam durante atualizações temporárias das queries
|
|
||||||
ehGestorEstavel = resultado;
|
|
||||||
if (!resultado) {
|
|
||||||
ultimaVerificacaoGestor = agora;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Valor final usado para renderizar as abas
|
|
||||||
const ehGestor = $derived(ehGestorEstavel);
|
|
||||||
|
|
||||||
// Filtrar minhas solicitações
|
// Filtrar minhas solicitações
|
||||||
const solicitacoesFiltradas = $derived(
|
const solicitacoesFiltradas = $derived(
|
||||||
@@ -195,7 +180,8 @@
|
|||||||
total: minhasSolicitacoes.length,
|
total: minhasSolicitacoes.length,
|
||||||
aguardando: minhasSolicitacoes.filter((s) => s.status === 'aguardando_aprovacao').length,
|
aguardando: minhasSolicitacoes.filter((s) => s.status === 'aguardando_aprovacao').length,
|
||||||
aprovadas: minhasSolicitacoes.filter(
|
aprovadas: minhasSolicitacoes.filter(
|
||||||
(s) => s.status === 'aprovado' || s.status === 'data_ajustada_aprovada' || s.status === 'EmFérias'
|
(s) =>
|
||||||
|
s.status === 'aprovado' || s.status === 'data_ajustada_aprovada' || s.status === 'EmFérias'
|
||||||
).length,
|
).length,
|
||||||
reprovadas: minhasSolicitacoes.filter((s) => s.status === 'reprovado').length,
|
reprovadas: minhasSolicitacoes.filter((s) => s.status === 'reprovado').length,
|
||||||
emFerias: funcionario?.statusFerias === 'em_ferias' ? 1 : 0
|
emFerias: funcionario?.statusFerias === 'em_ferias' ? 1 : 0
|
||||||
@@ -618,6 +604,29 @@
|
|||||||
Minhas Ausências
|
Minhas Ausências
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<a
|
||||||
|
role="tab"
|
||||||
|
href={resolve('/perfil/chamados')}
|
||||||
|
class="tab tab-lg font-semibold transition-all duration-300 hover:bg-base-100"
|
||||||
|
aria-label="Meus Chamados"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="mr-2 h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M3 7h18M3 12h12M3 17h18"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Meus Chamados
|
||||||
|
</a>
|
||||||
|
|
||||||
{#if ehGestor}
|
{#if ehGestor}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1307,29 +1316,35 @@
|
|||||||
class="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center"
|
class="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center"
|
||||||
>
|
>
|
||||||
<h2 class="card-title text-lg">Filtros</h2>
|
<h2 class="card-title text-lg">Filtros</h2>
|
||||||
{#if funcionario}
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
class="btn btn-primary gap-2"
|
||||||
class="btn btn-primary gap-2"
|
onclick={() => {
|
||||||
onclick={() => (mostrarWizard = true)}
|
if (funcionarioIdDisponivel) {
|
||||||
|
mostrarWizard = true;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!funcionarioIdDisponivel}
|
||||||
|
title={funcionarioIdDisponivel
|
||||||
|
? 'Clique para agendar suas férias'
|
||||||
|
: 'Estamos validando seus dados de funcionário...'}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
>
|
>
|
||||||
<svg
|
<path
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
stroke-linecap="round"
|
||||||
class="h-5 w-5"
|
stroke-linejoin="round"
|
||||||
fill="none"
|
stroke-width="2"
|
||||||
viewBox="0 0 24 24"
|
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"
|
||||||
stroke="currentColor"
|
/>
|
||||||
>
|
</svg>
|
||||||
<path
|
Agendar Férias
|
||||||
stroke-linecap="round"
|
</button>
|
||||||
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>
|
|
||||||
Agendar Férias
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-1">
|
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-1">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
@@ -1390,7 +1405,9 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>{periodo.anoReferencia}</td>
|
<td>{periodo.anoReferencia}</td>
|
||||||
<td>
|
<td>
|
||||||
{formatarDataString(periodo.dataInicio)} - {formatarDataString(periodo.dataFim)}
|
{formatarDataString(periodo.dataInicio)} - {formatarDataString(
|
||||||
|
periodo.dataFim
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td class="font-bold">{periodo.diasFerias} dias</td>
|
<td class="font-bold">{periodo.diasFerias} dias</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -1767,9 +1784,8 @@
|
|||||||
{#if periodo.time}
|
{#if periodo.time}
|
||||||
<div
|
<div
|
||||||
class="badge badge-lg font-semibold"
|
class="badge badge-lg font-semibold"
|
||||||
style="background-color: {periodo.time
|
style="background-color: {periodo.time.cor}20; border-color: {periodo
|
||||||
.cor}20; border-color: {periodo.time.cor}; color: {periodo.time
|
.time.cor}; color: {periodo.time.cor}"
|
||||||
.cor}"
|
|
||||||
>
|
>
|
||||||
{periodo.time.nome}
|
{periodo.time.nome}
|
||||||
</div>
|
</div>
|
||||||
@@ -1777,7 +1793,9 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="font-semibold">{periodo.anoReferencia}</td>
|
<td class="font-semibold">{periodo.anoReferencia}</td>
|
||||||
<td class="font-semibold">
|
<td class="font-semibold">
|
||||||
{formatarDataString(periodo.dataInicio)} - {formatarDataString(periodo.dataFim)}
|
{formatarDataString(periodo.dataInicio)} - {formatarDataString(
|
||||||
|
periodo.dataFim
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-lg font-bold">{periodo.diasFerias}</td>
|
<td class="text-lg font-bold">{periodo.diasFerias}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -2280,9 +2298,18 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<div
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
class="modal-backdrop"
|
||||||
<div class="modal-backdrop" onclick={() => (mostrarWizard = false)}></div>
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onclick={() => (mostrarWizard = false)}
|
||||||
|
onkeydown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
mostrarWizard = false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
</dialog>
|
</dialog>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
326
apps/web/src/routes/(dashboard)/perfil/chamados/+page.svelte
Normal file
326
apps/web/src/routes/(dashboard)/perfil/chamados/+page.svelte
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { useConvexClient } from "convex-svelte";
|
||||||
|
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||||
|
import type { Doc, Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||||
|
import TicketCard from "$lib/components/chamados/TicketCard.svelte";
|
||||||
|
import TicketTimeline from "$lib/components/chamados/TicketTimeline.svelte";
|
||||||
|
import { chamadosStore } from "$lib/stores/chamados";
|
||||||
|
import {
|
||||||
|
formatarData,
|
||||||
|
getStatusBadge,
|
||||||
|
getStatusDescription,
|
||||||
|
getStatusLabel,
|
||||||
|
prazoRestante,
|
||||||
|
} from "$lib/utils/chamados";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
|
import { useConvexWithAuth } from "$lib/hooks/useConvexWithAuth";
|
||||||
|
|
||||||
|
type Ticket = Doc<"tickets">;
|
||||||
|
|
||||||
|
const client = useConvexClient();
|
||||||
|
const ticketsStore = chamadosStore.tickets;
|
||||||
|
const detalhesStore = chamadosStore.detalhes;
|
||||||
|
|
||||||
|
let carregandoLista = $state(true);
|
||||||
|
let carregandoDetalhe = $state(false);
|
||||||
|
let filtroStatus = $state<"todos" | Ticket["status"]>("todos");
|
||||||
|
let filtroTipo = $state<"todos" | Ticket["tipo"]>("todos");
|
||||||
|
let selectedTicketId = $state<Id<"tickets"> | null>(null);
|
||||||
|
let mensagem = $state("");
|
||||||
|
let erroMensagem = $state<string | null>(null);
|
||||||
|
let sucessoMensagem = $state<string | null>(null);
|
||||||
|
|
||||||
|
const listaChamados = $derived($ticketsStore);
|
||||||
|
const detalheAtual = $derived(
|
||||||
|
selectedTicketId ? ($detalhesStore[selectedTicketId] ?? null) : null
|
||||||
|
);
|
||||||
|
const ticketsFiltrados = $derived(
|
||||||
|
listaChamados.filter((ticket) => {
|
||||||
|
if (filtroStatus !== "todos" && ticket.status !== filtroStatus) return false;
|
||||||
|
if (filtroTipo !== "todos" && ticket.tipo !== filtroTipo) return false;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
carregarChamados();
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
// Configura o token de autenticação no cliente Convex
|
||||||
|
useConvexWithAuth();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function carregarChamados() {
|
||||||
|
try {
|
||||||
|
carregandoLista = true;
|
||||||
|
const data = await client.query(api.chamados.listarChamadosUsuario, {});
|
||||||
|
chamadosStore.setTickets(data ?? []);
|
||||||
|
if (!selectedTicketId && data && data.length > 0) {
|
||||||
|
selecionarChamado(data[0]._id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao carregar chamados:", error);
|
||||||
|
} finally {
|
||||||
|
carregandoLista = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selecionarChamado(ticketId: Id<"tickets">) {
|
||||||
|
selectedTicketId = ticketId;
|
||||||
|
if (!$detalhesStore[ticketId]) {
|
||||||
|
try {
|
||||||
|
carregandoDetalhe = true;
|
||||||
|
const detalhe = await client.query(api.chamados.obterChamado, { ticketId });
|
||||||
|
if (detalhe) {
|
||||||
|
chamadosStore.setDetalhe(ticketId, detalhe);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao carregar detalhe:", error);
|
||||||
|
} finally {
|
||||||
|
carregandoDetalhe = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enviarMensagem() {
|
||||||
|
if (!selectedTicketId || !mensagem.trim()) {
|
||||||
|
erroMensagem = "Informe uma mensagem para atualizar o chamado.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
erroMensagem = null;
|
||||||
|
sucessoMensagem = null;
|
||||||
|
await client.mutation(api.chamados.registrarAtualizacao, {
|
||||||
|
ticketId: selectedTicketId,
|
||||||
|
conteudo: mensagem.trim(),
|
||||||
|
visibilidade: "publico",
|
||||||
|
});
|
||||||
|
mensagem = "";
|
||||||
|
sucessoMensagem = "Atualização registrada com sucesso.";
|
||||||
|
await selecionarChamado(selectedTicketId);
|
||||||
|
await carregarChamados();
|
||||||
|
} catch (error) {
|
||||||
|
const mensagemErro =
|
||||||
|
error instanceof Error ? error.message : "Erro ao enviar atualização. Tente novamente.";
|
||||||
|
erroMensagem = mensagemErro;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusAlertas(ticket: Ticket) {
|
||||||
|
const alertas: Array<{ label: string; tipo: "success" | "warning" | "error" }> = [];
|
||||||
|
if (ticket.prazoResposta) {
|
||||||
|
const diff = ticket.prazoResposta - Date.now();
|
||||||
|
if (diff < 0) alertas.push({ label: "Prazo de resposta vencido", tipo: "error" });
|
||||||
|
else if (diff <= 4 * 60 * 60 * 1000)
|
||||||
|
alertas.push({ label: "Resposta vence em breve", tipo: "warning" });
|
||||||
|
}
|
||||||
|
if (ticket.prazoConclusao) {
|
||||||
|
const diff = ticket.prazoConclusao - Date.now();
|
||||||
|
if (diff < 0) alertas.push({ label: "Prazo de conclusão vencido", tipo: "error" });
|
||||||
|
else if (diff <= 24 * 60 * 60 * 1000)
|
||||||
|
alertas.push({ label: "Conclusão vence em breve", tipo: "warning" });
|
||||||
|
}
|
||||||
|
return alertas;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="mx-auto w-full max-w-7xl space-y-8 px-4 py-8">
|
||||||
|
<section
|
||||||
|
class="rounded-3xl border border-base-200 bg-base-100/90 p-8 shadow-xl"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase tracking-[0.25em] text-primary">Meu Perfil</p>
|
||||||
|
<h1 class="text-3xl font-black text-base-content">Meus Chamados</h1>
|
||||||
|
<p class="text-base-content/70 mt-2 text-sm">
|
||||||
|
Acompanhe o status, interaja com a equipe de TI e visualize a timeline de SLA em tempo real.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<a href={resolve("/abrir-chamado")} class="btn btn-primary">Abrir novo chamado</a>
|
||||||
|
<button class="btn btn-ghost" type="button" onclick={carregarChamados}>
|
||||||
|
Atualizar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="grid gap-6 lg:grid-cols-[340px,1fr]">
|
||||||
|
<aside class="rounded-3xl border border-base-200 bg-base-100/80 p-4 shadow-lg">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-base-content">Meus tickets</h2>
|
||||||
|
{#if carregandoLista}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 space-y-3">
|
||||||
|
<select class="select select-sm select-bordered w-full" bind:value={filtroStatus}>
|
||||||
|
<option value="todos">Todos os status</option>
|
||||||
|
<option value="aberto">Aberto</option>
|
||||||
|
<option value="em_andamento">Em andamento</option>
|
||||||
|
<option value="aguardando_usuario">Aguardando usuário</option>
|
||||||
|
<option value="resolvido">Resolvido</option>
|
||||||
|
<option value="encerrado">Encerrado</option>
|
||||||
|
<option value="cancelado">Cancelado</option>
|
||||||
|
</select>
|
||||||
|
<select class="select select-sm select-bordered w-full" bind:value={filtroTipo}>
|
||||||
|
<option value="todos">Todos os tipos</option>
|
||||||
|
<option value="chamado">Chamados técnicos</option>
|
||||||
|
<option value="reclamacao">Reclamações</option>
|
||||||
|
<option value="elogio">Elogios</option>
|
||||||
|
<option value="sugestao">Sugestões</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-3 overflow-y-auto pr-1" style="max-height: calc(100vh - 260px);">
|
||||||
|
{#if ticketsFiltrados.length === 0}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<span>Nenhum chamado encontrado.</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each ticketsFiltrados as ticket (ticket._id)}
|
||||||
|
<TicketCard
|
||||||
|
{ticket}
|
||||||
|
selected={ticket._id === selectedTicketId}
|
||||||
|
on:select={({ detail }) => selecionarChamado(detail.ticketId)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="rounded-3xl border border-base-200 bg-base-100/90 p-6 shadow-xl">
|
||||||
|
{#if !selectedTicketId || !detalheAtual}
|
||||||
|
<div class="flex min-h-[400px] items-center justify-center text-base-content/60">
|
||||||
|
{#if carregandoDetalhe}
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
{:else}
|
||||||
|
<p>Selecione um chamado para visualizar os detalhes.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase text-base-content/60">Ticket {detalheAtual.ticket.numero}</p>
|
||||||
|
<h2 class="text-2xl font-bold text-base-content">{detalheAtual.ticket.titulo}</h2>
|
||||||
|
<p class="text-base-content/70 mt-1 text-sm">{detalheAtual.ticket.descricao}</p>
|
||||||
|
</div>
|
||||||
|
<span class={getStatusBadge(detalheAtual.ticket.status)}>
|
||||||
|
{getStatusLabel(detalheAtual.ticket.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-wrap gap-3 text-sm text-base-content/70">
|
||||||
|
<span class="badge badge-outline badge-sm">
|
||||||
|
Tipo: {detalheAtual.ticket.tipo.charAt(0).toUpperCase() + detalheAtual.ticket.tipo.slice(1)}
|
||||||
|
</span>
|
||||||
|
<span class="badge badge-outline badge-sm">
|
||||||
|
Prioridade: {detalheAtual.ticket.prioridade}
|
||||||
|
</span>
|
||||||
|
<span class="badge badge-outline badge-sm">
|
||||||
|
Última interação: {formatarData(detalheAtual.ticket.ultimaInteracaoEm)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if statusAlertas(detalheAtual.ticket).length > 0}
|
||||||
|
<div class="mt-4 space-y-2">
|
||||||
|
{#each statusAlertas(detalheAtual.ticket) as alerta (alerta.label)}
|
||||||
|
<div
|
||||||
|
class={`alert ${
|
||||||
|
alerta.tipo === "error"
|
||||||
|
? "alert-error"
|
||||||
|
: alerta.tipo === "warning"
|
||||||
|
? "alert-warning"
|
||||||
|
: "alert-success"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{alerta.label}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-6 grid gap-6 lg:grid-cols-2">
|
||||||
|
<div class="rounded-2xl border border-base-200 bg-base-100/80 p-4">
|
||||||
|
<h3 class="font-semibold text-base-content">Timeline e SLA</h3>
|
||||||
|
<p class="text-xs text-base-content/60">
|
||||||
|
Etapas monitoradas com indicadores de prazo.
|
||||||
|
</p>
|
||||||
|
<div class="mt-4">
|
||||||
|
<TicketTimeline timeline={detalheAtual.ticket.timeline ?? []} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-base-200 bg-base-100/80 p-4">
|
||||||
|
<h3 class="font-semibold text-base-content">Responsabilidade</h3>
|
||||||
|
<p class="text-sm text-base-content/60">
|
||||||
|
{detalheAtual.ticket.responsavelId
|
||||||
|
? `Responsável: ${detalheAtual.ticket.setorResponsavel ?? "Equipe TI"}`
|
||||||
|
: "Aguardando atribuição"}
|
||||||
|
</p>
|
||||||
|
<div class="mt-4 space-y-2 text-sm text-base-content/70">
|
||||||
|
<p>Prazo resposta: {prazoRestante(detalheAtual.ticket.prazoResposta) ?? "--"}</p>
|
||||||
|
<p>Prazo conclusão: {prazoRestante(detalheAtual.ticket.prazoConclusao) ?? "--"}</p>
|
||||||
|
<p>Prazo encerramento: {prazoRestante(detalheAtual.ticket.prazoEncerramento) ?? "--"}</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-base-content/50 mt-2">
|
||||||
|
{getStatusDescription(detalheAtual.ticket.status)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 grid gap-6 lg:grid-cols-2">
|
||||||
|
<div class="rounded-2xl border border-base-200 bg-base-100/70 p-4">
|
||||||
|
<h3 class="font-semibold text-base-content">Interações</h3>
|
||||||
|
<div class="mt-4 space-y-3 max-h-[360px] overflow-y-auto pr-2">
|
||||||
|
{#if detalheAtual.interactions.length === 0}
|
||||||
|
<p class="text-sm text-base-content/60">
|
||||||
|
Nenhuma interação registrada ainda.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
{#each detalheAtual.interactions as interacao (interacao._id)}
|
||||||
|
<div class="rounded-2xl border border-base-200 bg-base-100/90 p-3">
|
||||||
|
<div class="flex items-center justify-between text-xs text-base-content/60">
|
||||||
|
<span>{interacao.origem === "usuario" ? "Você" : interacao.origem}</span>
|
||||||
|
<span>{formatarData(interacao.criadoEm)}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-base-content mt-2 whitespace-pre-wrap">
|
||||||
|
{interacao.conteudo}
|
||||||
|
</p>
|
||||||
|
{#if interacao.statusNovo && interacao.statusNovo !== interacao.statusAnterior}
|
||||||
|
<span class="badge badge-xs badge-outline mt-2">
|
||||||
|
Status: {getStatusLabel(interacao.statusNovo)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-base-200 bg-base-100/70 p-4">
|
||||||
|
<h3 class="font-semibold text-base-content">Enviar atualização</h3>
|
||||||
|
<textarea
|
||||||
|
class="textarea textarea-bordered mt-3 min-h-[140px] w-full"
|
||||||
|
placeholder="Compartilhe informações adicionais, aprovações ou anexos enviados por outros canais."
|
||||||
|
bind:value={mensagem}
|
||||||
|
></textarea>
|
||||||
|
{#if erroMensagem}
|
||||||
|
<p class="text-error mt-2 text-sm">{erroMensagem}</p>
|
||||||
|
{/if}
|
||||||
|
{#if sucessoMensagem}
|
||||||
|
<p class="text-success mt-2 text-sm">{sucessoMensagem}</p>
|
||||||
|
{/if}
|
||||||
|
<button type="button" class="btn btn-primary mt-3 w-full" onclick={enviarMensagem}>
|
||||||
|
Enviar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { resolve } from '$app/paths';
|
|
||||||
import { useConvexClient } from 'convex-svelte';
|
|
||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
|
||||||
import { createForm } from '@tanstack/svelte-form';
|
|
||||||
import z from 'zod';
|
|
||||||
|
|
||||||
const convex = useConvexClient();
|
|
||||||
|
|
||||||
// Estado para mensagens
|
|
||||||
let notice = $state<{ type: 'success' | 'error'; message: string } | null>(null);
|
|
||||||
|
|
||||||
// Schema de validação
|
|
||||||
const formSchema = z.object({
|
|
||||||
nome: z.string().min(3, 'Nome deve ter no mínimo 3 caracteres'),
|
|
||||||
matricula: z.string().min(1, 'Matrícula é obrigatória'),
|
|
||||||
email: z.string().email('E-mail inválido'),
|
|
||||||
telefone: z.string().min(14, 'Telefone inválido')
|
|
||||||
});
|
|
||||||
|
|
||||||
// Criar o formulário
|
|
||||||
const form = createForm(() => ({
|
|
||||||
defaultValues: {
|
|
||||||
nome: '',
|
|
||||||
matricula: '',
|
|
||||||
email: '',
|
|
||||||
telefone: ''
|
|
||||||
},
|
|
||||||
onSubmit: async ({ value }) => {
|
|
||||||
try {
|
|
||||||
notice = null;
|
|
||||||
await convex.mutation(api.solicitacoesAcesso.create, {
|
|
||||||
nome: value.nome,
|
|
||||||
matricula: value.matricula,
|
|
||||||
email: value.email,
|
|
||||||
telefone: value.telefone
|
|
||||||
});
|
|
||||||
notice = {
|
|
||||||
type: 'success',
|
|
||||||
message: 'Solicitação de acesso enviada com sucesso! Aguarde a análise da equipe de TI.'
|
|
||||||
};
|
|
||||||
// Limpar o formulário
|
|
||||||
form.reset();
|
|
||||||
// Redirecionar após 3 segundos
|
|
||||||
setTimeout(() => {
|
|
||||||
goto(resolve('/'));
|
|
||||||
}, 3000);
|
|
||||||
} catch (error: any) {
|
|
||||||
notice = {
|
|
||||||
type: 'error',
|
|
||||||
message: error.message || 'Erro ao enviar solicitação. Tente novamente.'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Máscaras
|
|
||||||
function maskTelefone(value: string): string {
|
|
||||||
const cleaned = value.replace(/\D/g, '');
|
|
||||||
if (cleaned.length <= 10) {
|
|
||||||
return cleaned.replace(/^(\d{2})(\d)/, '($1) $2').replace(/(\d{4})(\d)/, '$1-$2');
|
|
||||||
}
|
|
||||||
return cleaned.replace(/^(\d{2})(\d)/, '($1) $2').replace(/(\d{5})(\d)/, '$1-$2');
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCancel() {
|
|
||||||
goto(resolve('/'));
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<main class="mx-auto w-full max-w-4xl space-y-8 px-4 py-10">
|
|
||||||
<!-- Cabeçalho Estilizado -->
|
|
||||||
<section
|
|
||||||
class="border-primary/25 from-primary/10 via-base-100 to-secondary/20 relative overflow-hidden rounded-3xl border bg-linear-to-br p-8 shadow-2xl"
|
|
||||||
>
|
|
||||||
<div class="bg-primary/20 absolute top-10 -left-10 h-40 w-40 rounded-full blur-3xl"></div>
|
|
||||||
<div class="bg-secondary/20 absolute right-0 -bottom-16 h-56 w-56 rounded-full blur-3xl"></div>
|
|
||||||
<div class="relative z-10 space-y-4">
|
|
||||||
<span
|
|
||||||
class="border-primary/40 bg-primary/10 text-primary inline-flex w-fit items-center gap-2 rounded-full border px-4 py-1 text-xs font-semibold tracking-[0.28em] uppercase"
|
|
||||||
>
|
|
||||||
Acesso ao Sistema
|
|
||||||
</span>
|
|
||||||
<h1 class="text-base-content text-4xl leading-tight font-black sm:text-5xl">
|
|
||||||
Solicitar Acesso ao SGSE
|
|
||||||
</h1>
|
|
||||||
<p class="text-base-content/70 text-base leading-relaxed sm:text-lg">
|
|
||||||
Preencha o formulário abaixo para solicitar acesso ao Sistema de Gerenciamento da Secretaria
|
|
||||||
de Esportes. Sua solicitação será analisada pela equipe de Tecnologia da Informação.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Alertas -->
|
|
||||||
{#if notice}
|
|
||||||
<div class="alert {notice.type === 'success' ? 'alert-success' : 'alert-error'} shadow-xl">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-6 w-6 shrink-0 stroke-current"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
{#if notice.type === 'success'}
|
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</svg>
|
|
||||||
<span class="font-semibold">{notice.message}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Formulário -->
|
|
||||||
<section
|
|
||||||
class="border-base-200 bg-base-100/90 relative overflow-hidden rounded-3xl border p-8 shadow-2xl"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="from-base-200/40 absolute inset-x-6 top-0 h-24 rounded-b-full bg-linear-to-b to-transparent opacity-50"
|
|
||||||
></div>
|
|
||||||
<div class="relative z-10">
|
|
||||||
<form
|
|
||||||
onsubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
form.handleSubmit();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
|
||||||
<!-- Nome -->
|
|
||||||
<form.Field name="nome" validators={{ onChange: formSchema.shape.nome }}>
|
|
||||||
{#snippet children(field)}
|
|
||||||
<div class="form-control md:col-span-2">
|
|
||||||
<label class="label" for="nome">
|
|
||||||
<span class="label-text font-semibold">Nome Completo</span>
|
|
||||||
<span class="text-error">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="nome"
|
|
||||||
type="text"
|
|
||||||
placeholder="Digite seu nome completo"
|
|
||||||
class="input input-bordered focus:input-primary w-full transition-colors duration-300"
|
|
||||||
value={field.state.value}
|
|
||||||
onblur={field.handleBlur}
|
|
||||||
oninput={(e) => field.handleChange(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
{#if field.state.meta.errors.length > 0}
|
|
||||||
<label class="label">
|
|
||||||
<span class="text-error mt-1 text-sm">{field.state.meta.errors[0]}</span>
|
|
||||||
</label>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
</form.Field>
|
|
||||||
|
|
||||||
<!-- Matrícula -->
|
|
||||||
<form.Field name="matricula" validators={{ onChange: formSchema.shape.matricula }}>
|
|
||||||
{#snippet children(field)}
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label" for="matricula">
|
|
||||||
<span class="label-text font-semibold">Matrícula</span>
|
|
||||||
<span class="text-error">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="matricula"
|
|
||||||
type="text"
|
|
||||||
placeholder="Digite sua matrícula"
|
|
||||||
class="input input-bordered focus:input-primary w-full transition-colors duration-300"
|
|
||||||
value={field.state.value}
|
|
||||||
onblur={field.handleBlur}
|
|
||||||
oninput={(e) => field.handleChange(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
{#if field.state.meta.errors.length > 0}
|
|
||||||
<label class="label">
|
|
||||||
<span class="text-error mt-1 text-sm">{field.state.meta.errors[0]}</span>
|
|
||||||
</label>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
</form.Field>
|
|
||||||
|
|
||||||
<!-- E-mail -->
|
|
||||||
<form.Field name="email" validators={{ onChange: formSchema.shape.email }}>
|
|
||||||
{#snippet children(field)}
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label" for="email">
|
|
||||||
<span class="label-text font-semibold">E-mail</span>
|
|
||||||
<span class="text-error">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
placeholder="seu@email.com"
|
|
||||||
class="input input-bordered focus:input-primary w-full transition-colors duration-300"
|
|
||||||
value={field.state.value}
|
|
||||||
onblur={field.handleBlur}
|
|
||||||
oninput={(e) => field.handleChange(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
{#if field.state.meta.errors.length > 0}
|
|
||||||
<label class="label">
|
|
||||||
<span class="text-error mt-1 text-sm">{field.state.meta.errors[0]}</span>
|
|
||||||
</label>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
</form.Field>
|
|
||||||
|
|
||||||
<!-- Telefone -->
|
|
||||||
<form.Field name="telefone" validators={{ onChange: formSchema.shape.telefone }}>
|
|
||||||
{#snippet children(field)}
|
|
||||||
<div class="form-control md:col-span-2">
|
|
||||||
<label class="label" for="telefone">
|
|
||||||
<span class="label-text font-semibold">Telefone</span>
|
|
||||||
<span class="text-error">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="telefone"
|
|
||||||
type="text"
|
|
||||||
placeholder="(00) 00000-0000"
|
|
||||||
class="input input-bordered focus:input-primary w-full transition-colors duration-300"
|
|
||||||
value={field.state.value}
|
|
||||||
onblur={field.handleBlur}
|
|
||||||
oninput={(e) => {
|
|
||||||
const masked = maskTelefone(e.currentTarget.value);
|
|
||||||
e.currentTarget.value = masked;
|
|
||||||
field.handleChange(masked);
|
|
||||||
}}
|
|
||||||
maxlength="15"
|
|
||||||
/>
|
|
||||||
{#if field.state.meta.errors.length > 0}
|
|
||||||
<label class="label">
|
|
||||||
<span class="text-error mt-1 text-sm">{field.state.meta.errors[0]}</span>
|
|
||||||
</label>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
</form.Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Botões de Ação -->
|
|
||||||
<div class="border-base-300 mt-8 flex justify-end gap-4 border-t pt-6">
|
|
||||||
<button type="button" class="btn btn-md" onclick={handleCancel}>
|
|
||||||
<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="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Cancelar
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="btn btn-primary btn-md hover:shadow-primary/40 shadow-md transition-all duration-200"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
Solicitar Acesso
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Informações Importantes -->
|
|
||||||
<div class="alert alert-info border-info/30 border shadow-xl">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
class="h-6 w-6 shrink-0 stroke-current"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<h3 class="mb-2 text-lg font-bold">Informações Importantes</h3>
|
|
||||||
<div class="space-y-1 text-sm">
|
|
||||||
<div class="flex items-start gap-2">
|
|
||||||
<span class="text-info font-bold">•</span>
|
|
||||||
<span>Todos os campos marcados com * são obrigatórios</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-start gap-2">
|
|
||||||
<span class="text-info font-bold">•</span>
|
|
||||||
<span>Sua solicitação será analisada pela equipe de TI em até 48 horas úteis</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-start gap-2">
|
|
||||||
<span class="text-info font-bold">•</span>
|
|
||||||
<span>Você receberá um e-mail com o resultado da análise</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-start gap-2">
|
|
||||||
<span class="text-info font-bold">•</span>
|
|
||||||
<span>Em caso de dúvidas, entre em contato com o suporte técnico</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
@keyframes fadeInUp {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
section {
|
|
||||||
animation: fadeInUp 0.5s ease-out;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -198,6 +198,19 @@
|
|||||||
palette: "primary",
|
palette: "primary",
|
||||||
icon: "control",
|
icon: "control",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Central de Chamados",
|
||||||
|
description:
|
||||||
|
"Monitore tickets, configure SLA, atribua responsáveis e acompanhe alertas de prazos.",
|
||||||
|
ctaLabel: "Abrir Central",
|
||||||
|
href: "/ti/central-chamados",
|
||||||
|
palette: "info",
|
||||||
|
icon: "support",
|
||||||
|
highlightBadges: [
|
||||||
|
{ label: "SLA", variant: "solid" },
|
||||||
|
{ label: "Alertas", variant: "outline" },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Suporte Técnico",
|
title: "Suporte Técnico",
|
||||||
description:
|
description:
|
||||||
|
|||||||
1098
apps/web/src/routes/(dashboard)/ti/central-chamados/+page.svelte
Normal file
1098
apps/web/src/routes/(dashboard)/ti/central-chamados/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,9 @@ const config = {
|
|||||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||||
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||||
adapter: adapter(),
|
adapter: adapter(),
|
||||||
|
alias: {
|
||||||
|
'@sgse-app/backend': '../../packages/backend',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
2
packages/backend/convex/_generated/api.d.ts
vendored
2
packages/backend/convex/_generated/api.d.ts
vendored
@@ -17,6 +17,7 @@ import type * as atestadosLicencas from "../atestadosLicencas.js";
|
|||||||
import type * as ausencias from "../ausencias.js";
|
import type * as ausencias from "../ausencias.js";
|
||||||
import type * as auth from "../auth.js";
|
import type * as auth from "../auth.js";
|
||||||
import type * as auth_utils from "../auth/utils.js";
|
import type * as auth_utils from "../auth/utils.js";
|
||||||
|
import type * as chamados from "../chamados.js";
|
||||||
import type * as chat from "../chat.js";
|
import type * as chat from "../chat.js";
|
||||||
import type * as configuracaoEmail from "../configuracaoEmail.js";
|
import type * as configuracaoEmail from "../configuracaoEmail.js";
|
||||||
import type * as crons from "../crons.js";
|
import type * as crons from "../crons.js";
|
||||||
@@ -64,6 +65,7 @@ declare const fullApi: ApiFromModules<{
|
|||||||
ausencias: typeof ausencias;
|
ausencias: typeof ausencias;
|
||||||
auth: typeof auth;
|
auth: typeof auth;
|
||||||
"auth/utils": typeof auth_utils;
|
"auth/utils": typeof auth_utils;
|
||||||
|
chamados: typeof chamados;
|
||||||
chat: typeof chat;
|
chat: typeof chat;
|
||||||
configuracaoEmail: typeof configuracaoEmail;
|
configuracaoEmail: typeof configuracaoEmail;
|
||||||
crons: typeof crons;
|
crons: typeof crons;
|
||||||
|
|||||||
816
packages/backend/convex/chamados.ts
Normal file
816
packages/backend/convex/chamados.ts
Normal file
@@ -0,0 +1,816 @@
|
|||||||
|
import { v } from "convex/values";
|
||||||
|
import { mutation, query } from "./_generated/server";
|
||||||
|
import type { MutationCtx } from "./_generated/server";
|
||||||
|
import { api } from "./_generated/api";
|
||||||
|
import { getCurrentUserFunction } from "./auth";
|
||||||
|
import type { Doc, Id } from "./_generated/dataModel";
|
||||||
|
|
||||||
|
const ticketStatusValidator = v.union(
|
||||||
|
v.literal("aberto"),
|
||||||
|
v.literal("em_andamento"),
|
||||||
|
v.literal("aguardando_usuario"),
|
||||||
|
v.literal("resolvido"),
|
||||||
|
v.literal("encerrado"),
|
||||||
|
v.literal("cancelado")
|
||||||
|
);
|
||||||
|
|
||||||
|
const ticketTipoValidator = v.union(
|
||||||
|
v.literal("reclamacao"),
|
||||||
|
v.literal("elogio"),
|
||||||
|
v.literal("sugestao"),
|
||||||
|
v.literal("chamado")
|
||||||
|
);
|
||||||
|
|
||||||
|
const prioridadeValidator = v.union(
|
||||||
|
v.literal("baixa"),
|
||||||
|
v.literal("media"),
|
||||||
|
v.literal("alta"),
|
||||||
|
v.literal("critica")
|
||||||
|
);
|
||||||
|
|
||||||
|
const arquivoValidator = v.object({
|
||||||
|
arquivoId: v.id("_storage"),
|
||||||
|
nome: v.optional(v.string()),
|
||||||
|
tipo: v.optional(v.string()),
|
||||||
|
tamanho: v.optional(v.number()),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function assertAuth(ctx: Parameters<typeof getCurrentUserFunction>[0]) {
|
||||||
|
const usuario = await getCurrentUserFunction(ctx);
|
||||||
|
if (!usuario) {
|
||||||
|
throw new Error("Usuário não autenticado");
|
||||||
|
}
|
||||||
|
return usuario;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TicketDoc = Doc<"tickets">;
|
||||||
|
type SlaDoc = Doc<"slaConfigs"> | null;
|
||||||
|
|
||||||
|
function gerarNumeroTicket(): string {
|
||||||
|
const agora = new Date();
|
||||||
|
const ano = agora.getFullYear();
|
||||||
|
const mes = String(agora.getMonth() + 1).padStart(2, "0");
|
||||||
|
const sequencia = Math.floor(Math.random() * 9000 + 1000);
|
||||||
|
return `SGSE-${ano}${mes}-${sequencia}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcularPrazos(base: number, sla: SlaDoc) {
|
||||||
|
const horaMs = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const tempoResposta = sla?.tempoRespostaHoras ?? 4;
|
||||||
|
const tempoConclusao = sla?.tempoConclusaoHoras ?? 24;
|
||||||
|
const tempoEncerramento = sla?.tempoEncerramentoHoras ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
resposta: base + tempoResposta * horaMs,
|
||||||
|
conclusao: base + tempoConclusao * horaMs,
|
||||||
|
encerramento: tempoEncerramento ? base + tempoEncerramento * horaMs : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function montarTimeline(base: number, prazos: ReturnType<typeof calcularPrazos>) {
|
||||||
|
const timeline: NonNullable<TicketDoc["timeline"]> = [
|
||||||
|
{
|
||||||
|
etapa: "abertura",
|
||||||
|
status: "concluido",
|
||||||
|
prazo: base,
|
||||||
|
concluidoEm: base,
|
||||||
|
observacao: "Chamado registrado com sucesso",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
etapa: "resposta_inicial",
|
||||||
|
status: "pendente",
|
||||||
|
prazo: prazos.resposta,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
etapa: "conclusao",
|
||||||
|
status: "pendente",
|
||||||
|
prazo: prazos.conclusao,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (prazos.encerramento) {
|
||||||
|
timeline.push({
|
||||||
|
etapa: "encerramento",
|
||||||
|
status: "pendente",
|
||||||
|
prazo: prazos.encerramento,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return timeline;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selecionarSlaConfig(
|
||||||
|
ctx: Parameters<typeof getCurrentUserFunction>[0],
|
||||||
|
prioridade: "baixa" | "media" | "alta" | "critica"
|
||||||
|
): Promise<Doc<"slaConfigs"> | null> {
|
||||||
|
return await ctx.db
|
||||||
|
.query("slaConfigs")
|
||||||
|
.withIndex("by_prioridade", (q) => q.eq("prioridade", prioridade).eq("ativo", true))
|
||||||
|
.first();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registrarNotificacoes(
|
||||||
|
ctx: MutationCtx,
|
||||||
|
params: {
|
||||||
|
ticket: Doc<"tickets">;
|
||||||
|
titulo: string;
|
||||||
|
mensagem: string;
|
||||||
|
usuarioEvento: Id<"usuarios">;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const { ticket, titulo, mensagem, usuarioEvento } = params;
|
||||||
|
|
||||||
|
// Notificar solicitante
|
||||||
|
if (ticket.solicitanteEmail) {
|
||||||
|
await ctx.runMutation(api.email.enfileirarEmail, {
|
||||||
|
destinatario: ticket.solicitanteEmail,
|
||||||
|
destinatarioId: ticket.solicitanteId,
|
||||||
|
assunto: `${titulo} - Chamado ${ticket.numero}`,
|
||||||
|
corpo: `${mensagem}\n\n---\nCentral de Chamados SGSE`,
|
||||||
|
enviadoPor: usuarioEvento,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.insert("notificacoes", {
|
||||||
|
usuarioId: ticket.solicitanteId,
|
||||||
|
tipo: "nova_mensagem",
|
||||||
|
...(ticket.conversaId ? { conversaId: ticket.conversaId } : {}),
|
||||||
|
remetenteId: usuarioEvento,
|
||||||
|
titulo,
|
||||||
|
descricao: mensagem.length > 120 ? `${mensagem.slice(0, 117)}...` : mensagem,
|
||||||
|
lida: false,
|
||||||
|
criadaEm: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notificar responsável (se houver)
|
||||||
|
if (ticket.responsavelId && ticket.responsavelId !== ticket.solicitanteId) {
|
||||||
|
const responsavel = await ctx.db.get(ticket.responsavelId);
|
||||||
|
if (responsavel?.email) {
|
||||||
|
await ctx.runMutation(api.email.enfileirarEmail, {
|
||||||
|
destinatario: responsavel.email,
|
||||||
|
destinatarioId: ticket.responsavelId,
|
||||||
|
assunto: `${titulo} - Chamado ${ticket.numero}`,
|
||||||
|
corpo: `${mensagem}\n\n---\nCentral de Chamados SGSE`,
|
||||||
|
enviadoPor: usuarioEvento,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.insert("notificacoes", {
|
||||||
|
usuarioId: ticket.responsavelId,
|
||||||
|
tipo: "nova_mensagem",
|
||||||
|
...(ticket.conversaId ? { conversaId: ticket.conversaId } : {}),
|
||||||
|
remetenteId: usuarioEvento,
|
||||||
|
titulo,
|
||||||
|
descricao: mensagem.length > 120 ? `${mensagem.slice(0, 117)}...` : mensagem,
|
||||||
|
lida: false,
|
||||||
|
criadaEm: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registrarInteracao(
|
||||||
|
ctx: MutationCtx,
|
||||||
|
params: {
|
||||||
|
ticketId: Id<"tickets">;
|
||||||
|
autorId: Id<"usuarios"> | null;
|
||||||
|
origem: "usuario" | "ti" | "sistema";
|
||||||
|
tipo: "mensagem" | "status" | "anexo" | "alerta";
|
||||||
|
conteudo: string;
|
||||||
|
visibilidade?: "publico" | "interno";
|
||||||
|
anexos?: Array<{
|
||||||
|
arquivoId: Id<"_storage">;
|
||||||
|
nome?: string;
|
||||||
|
tipo?: string;
|
||||||
|
tamanho?: number;
|
||||||
|
}>;
|
||||||
|
statusAnterior?: TicketDoc["status"];
|
||||||
|
statusNovo?: TicketDoc["status"];
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return await ctx.db.insert("ticketInteractions", {
|
||||||
|
ticketId: params.ticketId,
|
||||||
|
autorId: params.autorId || undefined,
|
||||||
|
origem: params.origem,
|
||||||
|
tipo: params.tipo,
|
||||||
|
conteudo: params.conteudo,
|
||||||
|
visibilidade: params.visibilidade ?? "publico",
|
||||||
|
anexos: params.anexos,
|
||||||
|
statusAnterior: params.statusAnterior,
|
||||||
|
statusNovo: params.statusNovo,
|
||||||
|
criadoEm: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const abrirChamado = mutation({
|
||||||
|
args: {
|
||||||
|
titulo: v.string(),
|
||||||
|
descricao: v.string(),
|
||||||
|
tipo: ticketTipoValidator,
|
||||||
|
categoria: v.optional(v.string()),
|
||||||
|
prioridade: prioridadeValidator,
|
||||||
|
anexos: v.optional(v.array(arquivoValidator)),
|
||||||
|
canalOrigem: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
returns: v.object({
|
||||||
|
ticketId: v.id("tickets"),
|
||||||
|
numero: v.string(),
|
||||||
|
}),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const usuario = await assertAuth(ctx);
|
||||||
|
const agora = Date.now();
|
||||||
|
const sla = await selecionarSlaConfig(ctx, args.prioridade);
|
||||||
|
const prazos = calcularPrazos(agora, sla);
|
||||||
|
const timeline = montarTimeline(agora, prazos);
|
||||||
|
|
||||||
|
const ticketId = await ctx.db.insert("tickets", {
|
||||||
|
numero: gerarNumeroTicket(),
|
||||||
|
titulo: args.titulo.trim(),
|
||||||
|
descricao: args.descricao.trim(),
|
||||||
|
tipo: args.tipo,
|
||||||
|
categoria: args.categoria,
|
||||||
|
status: "aberto",
|
||||||
|
prioridade: args.prioridade,
|
||||||
|
solicitanteId: usuario._id,
|
||||||
|
solicitanteNome: usuario.nome,
|
||||||
|
solicitanteEmail: usuario.email,
|
||||||
|
responsavelId: undefined,
|
||||||
|
setorResponsavel: undefined,
|
||||||
|
slaConfigId: sla?._id,
|
||||||
|
conversaId: undefined,
|
||||||
|
prazoResposta: prazos.resposta,
|
||||||
|
prazoConclusao: prazos.conclusao,
|
||||||
|
prazoEncerramento: prazos.encerramento ?? undefined,
|
||||||
|
timeline,
|
||||||
|
alertasEmitidos: [],
|
||||||
|
anexos: args.anexos,
|
||||||
|
tags: undefined,
|
||||||
|
canalOrigem: args.canalOrigem,
|
||||||
|
ultimaInteracaoEm: agora,
|
||||||
|
criadoEm: agora,
|
||||||
|
atualizadoEm: agora,
|
||||||
|
});
|
||||||
|
|
||||||
|
await registrarInteracao(ctx, {
|
||||||
|
ticketId,
|
||||||
|
autorId: usuario._id,
|
||||||
|
origem: "usuario",
|
||||||
|
tipo: "mensagem",
|
||||||
|
conteudo: args.descricao,
|
||||||
|
anexos: args.anexos,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ticket = await ctx.db.get(ticketId);
|
||||||
|
if (ticket) {
|
||||||
|
await registrarNotificacoes(ctx, {
|
||||||
|
ticket,
|
||||||
|
titulo: "Chamado registrado",
|
||||||
|
mensagem: "Recebemos sua solicitação e iniciaremos o atendimento em breve.",
|
||||||
|
usuarioEvento: usuario._id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ticketId,
|
||||||
|
numero: ticket ? ticket.numero : "",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listarChamadosUsuario = query({
|
||||||
|
args: {},
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const usuario = await assertAuth(ctx);
|
||||||
|
|
||||||
|
const tickets = await ctx.db
|
||||||
|
.query("tickets")
|
||||||
|
.withIndex("by_solicitante", (q) => q.eq("solicitanteId", usuario._id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
tickets.sort((a, b) => b.criadoEm - a.criadoEm);
|
||||||
|
return tickets;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listarChamadosTI = query({
|
||||||
|
args: {
|
||||||
|
status: v.optional(ticketStatusValidator),
|
||||||
|
responsavelId: v.optional(v.id("usuarios")),
|
||||||
|
setor: v.optional(v.string()),
|
||||||
|
limite: v.optional(v.number()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Permitir apenas usuários autenticados (regras detalhadas devem ser aplicadas no frontend)
|
||||||
|
await assertAuth(ctx);
|
||||||
|
|
||||||
|
let tickets: Array<Doc<"tickets">> = [];
|
||||||
|
|
||||||
|
if (args.responsavelId) {
|
||||||
|
tickets = await ctx.db
|
||||||
|
.query("tickets")
|
||||||
|
.withIndex("by_responsavel", (q) =>
|
||||||
|
q.eq("responsavelId", args.responsavelId).eq("status", args.status ?? "aberto")
|
||||||
|
)
|
||||||
|
.collect();
|
||||||
|
} else if (args.status) {
|
||||||
|
tickets = await ctx.db
|
||||||
|
.query("tickets")
|
||||||
|
.withIndex("by_status", (q) => q.eq("status", args.status!))
|
||||||
|
.collect();
|
||||||
|
} else {
|
||||||
|
tickets = await ctx.db.query("tickets").collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtrados = tickets.filter((ticket) => {
|
||||||
|
if (args.setor && ticket.setorResponsavel !== args.setor) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enriquecer tickets com nome do responsável
|
||||||
|
const ticketsEnriquecidos = await Promise.all(
|
||||||
|
filtrados.map(async (ticket) => {
|
||||||
|
let responsavelNome: string | undefined = undefined;
|
||||||
|
if (ticket.responsavelId) {
|
||||||
|
const responsavel = await ctx.db.get(ticket.responsavelId);
|
||||||
|
responsavelNome = responsavel?.nome;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...ticket,
|
||||||
|
responsavelNome,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
ticketsEnriquecidos.sort((a, b) => b.atualizadoEm - a.atualizadoEm);
|
||||||
|
return args.limite ? ticketsEnriquecidos.slice(0, args.limite) : ticketsEnriquecidos;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const obterChamado = query({
|
||||||
|
args: {
|
||||||
|
ticketId: v.id("tickets"),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const usuario = await assertAuth(ctx);
|
||||||
|
const ticket = await ctx.db.get(args.ticketId);
|
||||||
|
if (!ticket) {
|
||||||
|
throw new Error("Chamado não encontrado");
|
||||||
|
}
|
||||||
|
|
||||||
|
const podeVer =
|
||||||
|
ticket.solicitanteId === usuario._id ||
|
||||||
|
ticket.responsavelId === usuario._id ||
|
||||||
|
ticket.setorResponsavel === usuario.setor;
|
||||||
|
|
||||||
|
if (!podeVer) {
|
||||||
|
throw new Error("Acesso negado ao chamado");
|
||||||
|
}
|
||||||
|
|
||||||
|
const interactions = await ctx.db
|
||||||
|
.query("ticketInteractions")
|
||||||
|
.withIndex("by_ticket", (q) => q.eq("ticketId", args.ticketId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
interactions.sort((a, b) => a.criadoEm - b.criadoEm);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ticket,
|
||||||
|
interactions,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const registrarAtualizacao = mutation({
|
||||||
|
args: {
|
||||||
|
ticketId: v.id("tickets"),
|
||||||
|
conteudo: v.string(),
|
||||||
|
anexos: v.optional(v.array(arquivoValidator)),
|
||||||
|
visibilidade: v.optional(v.union(v.literal("publico"), v.literal("interno"))),
|
||||||
|
proximoStatus: v.optional(ticketStatusValidator),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const usuario = await assertAuth(ctx);
|
||||||
|
const ticket = await ctx.db.get(args.ticketId);
|
||||||
|
if (!ticket) {
|
||||||
|
throw new Error("Chamado não encontrado");
|
||||||
|
}
|
||||||
|
|
||||||
|
const agora = Date.now();
|
||||||
|
let novoStatus = ticket.status;
|
||||||
|
|
||||||
|
if (args.proximoStatus && args.proximoStatus !== ticket.status) {
|
||||||
|
novoStatus = args.proximoStatus;
|
||||||
|
await ctx.db.patch(ticket._id, {
|
||||||
|
status: novoStatus,
|
||||||
|
atualizadoEm: agora,
|
||||||
|
ultimaInteracaoEm: agora,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await ctx.db.patch(ticket._id, {
|
||||||
|
atualizadoEm: agora,
|
||||||
|
ultimaInteracaoEm: agora,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await registrarInteracao(ctx, {
|
||||||
|
ticketId: ticket._id,
|
||||||
|
autorId: usuario._id,
|
||||||
|
origem: "ti",
|
||||||
|
tipo: args.proximoStatus ? "status" : "mensagem",
|
||||||
|
conteudo: args.conteudo,
|
||||||
|
visibilidade: args.visibilidade,
|
||||||
|
anexos: args.anexos,
|
||||||
|
statusAnterior: args.proximoStatus ? ticket.status : undefined,
|
||||||
|
statusNovo: args.proximoStatus ? novoStatus : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ticketAtualizado = await ctx.db.get(ticket._id);
|
||||||
|
if (ticketAtualizado) {
|
||||||
|
await registrarNotificacoes(ctx, {
|
||||||
|
ticket: ticketAtualizado,
|
||||||
|
titulo: `Atualização no chamado ${ticketAtualizado.numero}`,
|
||||||
|
mensagem: args.conteudo,
|
||||||
|
usuarioEvento: usuario._id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: novoStatus };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const atribuirResponsavel = mutation({
|
||||||
|
args: {
|
||||||
|
ticketId: v.id("tickets"),
|
||||||
|
responsavelId: v.id("usuarios"),
|
||||||
|
motivo: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const usuario = await assertAuth(ctx);
|
||||||
|
const ticket = await ctx.db.get(args.ticketId);
|
||||||
|
if (!ticket) {
|
||||||
|
throw new Error("Chamado não encontrado");
|
||||||
|
}
|
||||||
|
|
||||||
|
const responsavel = await ctx.db.get(args.responsavelId);
|
||||||
|
if (!responsavel) {
|
||||||
|
throw new Error("Responsável inválido");
|
||||||
|
}
|
||||||
|
|
||||||
|
const agora = Date.now();
|
||||||
|
|
||||||
|
await ctx.db.patch(ticket._id, {
|
||||||
|
responsavelId: args.responsavelId,
|
||||||
|
setorResponsavel: responsavel.setor,
|
||||||
|
atualizadoEm: agora,
|
||||||
|
});
|
||||||
|
|
||||||
|
const assignmentsAtivos = await ctx.db
|
||||||
|
.query("ticketAssignments")
|
||||||
|
.withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id).eq("ativo", true))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (const assignment of assignmentsAtivos) {
|
||||||
|
await ctx.db.patch(assignment._id, {
|
||||||
|
ativo: false,
|
||||||
|
encerradoEm: agora,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.insert("ticketAssignments", {
|
||||||
|
ticketId: ticket._id,
|
||||||
|
responsavelId: args.responsavelId,
|
||||||
|
atribuidoPor: usuario._id,
|
||||||
|
motivo: args.motivo,
|
||||||
|
ativo: true,
|
||||||
|
criadoEm: agora,
|
||||||
|
encerradoEm: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await registrarInteracao(ctx, {
|
||||||
|
ticketId: ticket._id,
|
||||||
|
autorId: usuario._id,
|
||||||
|
origem: "ti",
|
||||||
|
tipo: "status",
|
||||||
|
conteudo: `Chamado atribuído para ${responsavel.nome}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ticketAtualizado = await ctx.db.get(ticket._id);
|
||||||
|
if (ticketAtualizado) {
|
||||||
|
await registrarNotificacoes(ctx, {
|
||||||
|
ticket: ticketAtualizado,
|
||||||
|
titulo: "Chamado atribuído",
|
||||||
|
mensagem: `Seu chamado agora está com ${responsavel.nome}.`,
|
||||||
|
usuarioEvento: usuario._id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { responsavelId: args.responsavelId };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listarSlaConfigs = query({
|
||||||
|
args: {},
|
||||||
|
handler: async (ctx) => {
|
||||||
|
await assertAuth(ctx);
|
||||||
|
const slaConfigs = await ctx.db.query("slaConfigs").collect();
|
||||||
|
slaConfigs.sort((a, b) => b.criadoEm - a.criadoEm);
|
||||||
|
return slaConfigs;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const obterEstatisticasChamados = query({
|
||||||
|
args: {},
|
||||||
|
handler: async (ctx) => {
|
||||||
|
await assertAuth(ctx);
|
||||||
|
const todosTickets = await ctx.db.query("tickets").collect();
|
||||||
|
|
||||||
|
const total = todosTickets.length;
|
||||||
|
const abertos = todosTickets.filter((t) => t.status === "aberto").length;
|
||||||
|
const emAndamento = todosTickets.filter((t) => t.status === "em_andamento").length;
|
||||||
|
const vencidos = todosTickets.filter(
|
||||||
|
(t) => (t.prazoConclusao && t.prazoConclusao < Date.now()) || t.status === "cancelado"
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return { total, abertos, emAndamento, vencidos };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const obterDadosSlaGrafico = query({
|
||||||
|
args: {},
|
||||||
|
handler: async (ctx) => {
|
||||||
|
await assertAuth(ctx);
|
||||||
|
const agora = Date.now();
|
||||||
|
const todosTickets = await ctx.db.query("tickets").collect();
|
||||||
|
const slaConfigs = await ctx.db.query("slaConfigs").collect();
|
||||||
|
|
||||||
|
// Agrupar SLAs por prioridade
|
||||||
|
const slaPorPrioridade = new Map<string, Doc<"slaConfigs">>();
|
||||||
|
slaConfigs.filter(s => s.ativo).forEach(sla => {
|
||||||
|
if (sla.prioridade) {
|
||||||
|
slaPorPrioridade.set(sla.prioridade, sla);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calcular status de SLA para cada ticket
|
||||||
|
const statusSla = {
|
||||||
|
dentroPrazo: 0,
|
||||||
|
proximoVencimento: 0,
|
||||||
|
vencido: 0,
|
||||||
|
semPrazo: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const porPrioridade = {
|
||||||
|
baixa: { dentroPrazo: 0, proximoVencimento: 0, vencido: 0, total: 0 },
|
||||||
|
media: { dentroPrazo: 0, proximoVencimento: 0, vencido: 0, total: 0 },
|
||||||
|
alta: { dentroPrazo: 0, proximoVencimento: 0, vencido: 0, total: 0 },
|
||||||
|
critica: { dentroPrazo: 0, proximoVencimento: 0, vencido: 0, total: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
todosTickets.forEach(ticket => {
|
||||||
|
if (!ticket.prazoConclusao) {
|
||||||
|
statusSla.semPrazo++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prazoConclusao = ticket.prazoConclusao;
|
||||||
|
const horasRestantes = (prazoConclusao - agora) / (1000 * 60 * 60);
|
||||||
|
const sla = slaPorPrioridade.get(ticket.prioridade);
|
||||||
|
const alertaHoras = sla?.alertaAntecedenciaHoras ?? 2;
|
||||||
|
|
||||||
|
if (prazoConclusao < agora) {
|
||||||
|
statusSla.vencido++;
|
||||||
|
if (ticket.prioridade && porPrioridade[ticket.prioridade as keyof typeof porPrioridade]) {
|
||||||
|
porPrioridade[ticket.prioridade as keyof typeof porPrioridade].vencido++;
|
||||||
|
porPrioridade[ticket.prioridade as keyof typeof porPrioridade].total++;
|
||||||
|
}
|
||||||
|
} else if (horasRestantes <= alertaHoras) {
|
||||||
|
statusSla.proximoVencimento++;
|
||||||
|
if (ticket.prioridade && porPrioridade[ticket.prioridade as keyof typeof porPrioridade]) {
|
||||||
|
porPrioridade[ticket.prioridade as keyof typeof porPrioridade].proximoVencimento++;
|
||||||
|
porPrioridade[ticket.prioridade as keyof typeof porPrioridade].total++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
statusSla.dentroPrazo++;
|
||||||
|
if (ticket.prioridade && porPrioridade[ticket.prioridade as keyof typeof porPrioridade]) {
|
||||||
|
porPrioridade[ticket.prioridade as keyof typeof porPrioridade].dentroPrazo++;
|
||||||
|
porPrioridade[ticket.prioridade as keyof typeof porPrioridade].total++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calcular taxa de cumprimento
|
||||||
|
const totalComPrazo = statusSla.dentroPrazo + statusSla.proximoVencimento + statusSla.vencido;
|
||||||
|
const taxaCumprimento = totalComPrazo > 0
|
||||||
|
? Math.round((statusSla.dentroPrazo / totalComPrazo) * 100)
|
||||||
|
: 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusSla,
|
||||||
|
porPrioridade,
|
||||||
|
taxaCumprimento,
|
||||||
|
totalComPrazo,
|
||||||
|
atualizadoEm: agora,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const salvarSlaConfig = mutation({
|
||||||
|
args: {
|
||||||
|
slaId: v.optional(v.id("slaConfigs")),
|
||||||
|
nome: v.string(),
|
||||||
|
descricao: v.optional(v.string()),
|
||||||
|
prioridade: prioridadeValidator,
|
||||||
|
tempoRespostaHoras: v.number(),
|
||||||
|
tempoConclusaoHoras: v.number(),
|
||||||
|
tempoEncerramentoHoras: v.optional(v.number()),
|
||||||
|
alertaAntecedenciaHoras: v.number(),
|
||||||
|
ativo: v.boolean(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const usuario = await assertAuth(ctx);
|
||||||
|
const agora = Date.now();
|
||||||
|
|
||||||
|
if (args.slaId) {
|
||||||
|
await ctx.db.patch(args.slaId, {
|
||||||
|
nome: args.nome,
|
||||||
|
descricao: args.descricao,
|
||||||
|
prioridade: args.prioridade,
|
||||||
|
tempoRespostaHoras: args.tempoRespostaHoras,
|
||||||
|
tempoConclusaoHoras: args.tempoConclusaoHoras,
|
||||||
|
tempoEncerramentoHoras: args.tempoEncerramentoHoras,
|
||||||
|
alertaAntecedenciaHoras: args.alertaAntecedenciaHoras,
|
||||||
|
ativo: args.ativo,
|
||||||
|
atualizadoPor: usuario._id,
|
||||||
|
atualizadoEm: agora,
|
||||||
|
});
|
||||||
|
return args.slaId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ctx.db.insert("slaConfigs", {
|
||||||
|
nome: args.nome,
|
||||||
|
descricao: args.descricao,
|
||||||
|
prioridade: args.prioridade,
|
||||||
|
tempoRespostaHoras: args.tempoRespostaHoras,
|
||||||
|
tempoConclusaoHoras: args.tempoConclusaoHoras,
|
||||||
|
tempoEncerramentoHoras: args.tempoEncerramentoHoras,
|
||||||
|
alertaAntecedenciaHoras: args.alertaAntecedenciaHoras,
|
||||||
|
ativo: args.ativo,
|
||||||
|
criadoPor: usuario._id,
|
||||||
|
atualizadoPor: usuario._id,
|
||||||
|
criadoEm: agora,
|
||||||
|
atualizadoEm: agora,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const excluirSlaConfig = mutation({
|
||||||
|
args: {
|
||||||
|
slaId: v.id("slaConfigs"),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await assertAuth(ctx);
|
||||||
|
const sla = await ctx.db.get(args.slaId);
|
||||||
|
if (!sla) {
|
||||||
|
throw new Error("Configuração de SLA não encontrada");
|
||||||
|
}
|
||||||
|
await ctx.db.delete(args.slaId);
|
||||||
|
return { sucesso: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const prorrogarChamado = mutation({
|
||||||
|
args: {
|
||||||
|
ticketId: v.id("tickets"),
|
||||||
|
horasAdicionais: v.number(),
|
||||||
|
prazo: v.union(v.literal("resposta"), v.literal("conclusao")),
|
||||||
|
motivo: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const usuario = await assertAuth(ctx);
|
||||||
|
const ticket = await ctx.db.get(args.ticketId);
|
||||||
|
if (!ticket) {
|
||||||
|
throw new Error("Chamado não encontrado");
|
||||||
|
}
|
||||||
|
|
||||||
|
const agora = Date.now();
|
||||||
|
const horasMs = args.horasAdicionais * 60 * 60 * 1000;
|
||||||
|
let novoPrazoResposta = ticket.prazoResposta;
|
||||||
|
let novoPrazoConclusao = ticket.prazoConclusao;
|
||||||
|
let prazoExtendido: number;
|
||||||
|
|
||||||
|
if (args.prazo === "resposta") {
|
||||||
|
prazoExtendido = (ticket.prazoResposta || agora) + horasMs;
|
||||||
|
novoPrazoResposta = prazoExtendido;
|
||||||
|
// Se o prazo de conclusão é antes do novo prazo de resposta, ajuste-o também
|
||||||
|
if (ticket.prazoConclusao && ticket.prazoConclusao < prazoExtendido) {
|
||||||
|
novoPrazoConclusao = prazoExtendido + (ticket.prazoConclusao - (ticket.prazoResposta || agora));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
prazoExtendido = (ticket.prazoConclusao || agora) + horasMs;
|
||||||
|
novoPrazoConclusao = prazoExtendido;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atualizar timeline
|
||||||
|
const timelineAtualizada = ticket.timeline?.map((etapa) => {
|
||||||
|
if (args.prazo === "resposta" && etapa.etapa === "resposta_inicial") {
|
||||||
|
return {
|
||||||
|
...etapa,
|
||||||
|
prazo: prazoExtendido,
|
||||||
|
status: prazoExtendido > agora ? "pendente" : etapa.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (args.prazo === "conclusao" && etapa.etapa === "conclusao") {
|
||||||
|
return {
|
||||||
|
...etapa,
|
||||||
|
prazo: prazoExtendido,
|
||||||
|
status: prazoExtendido > agora ? "pendente" : etapa.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return etapa;
|
||||||
|
}) || ticket.timeline;
|
||||||
|
|
||||||
|
await ctx.db.patch(ticket._id, {
|
||||||
|
prazoResposta: novoPrazoResposta,
|
||||||
|
prazoConclusao: novoPrazoConclusao,
|
||||||
|
timeline: timelineAtualizada,
|
||||||
|
atualizadoEm: agora,
|
||||||
|
ultimaInteracaoEm: agora,
|
||||||
|
});
|
||||||
|
|
||||||
|
await registrarInteracao(ctx, {
|
||||||
|
ticketId: ticket._id,
|
||||||
|
autorId: usuario._id,
|
||||||
|
origem: "ti",
|
||||||
|
tipo: "status",
|
||||||
|
conteudo: `Prazo ${args.prazo === "resposta" ? "de resposta" : "de conclusão"} prorrogado em ${args.horasAdicionais}h. Motivo: ${args.motivo}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ticketAtualizado = await ctx.db.get(ticket._id);
|
||||||
|
if (ticketAtualizado) {
|
||||||
|
await registrarNotificacoes(ctx, {
|
||||||
|
ticket: ticketAtualizado,
|
||||||
|
titulo: `Prazo prorrogado - Chamado ${ticketAtualizado.numero}`,
|
||||||
|
mensagem: `O prazo ${args.prazo === "resposta" ? "de resposta" : "de conclusão"} foi prorrogado em ${args.horasAdicionais} horas. Motivo: ${args.motivo}`,
|
||||||
|
usuarioEvento: usuario._id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sucesso: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const emitirAlertaPrazo = mutation({
|
||||||
|
args: {
|
||||||
|
ticketId: v.id("tickets"),
|
||||||
|
tipo: v.union(
|
||||||
|
v.literal("resposta"),
|
||||||
|
v.literal("conclusao"),
|
||||||
|
v.literal("encerramento")
|
||||||
|
),
|
||||||
|
mensagem: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const usuario = await assertAuth(ctx);
|
||||||
|
const ticket = await ctx.db.get(args.ticketId);
|
||||||
|
if (!ticket) {
|
||||||
|
throw new Error("Chamado não encontrado");
|
||||||
|
}
|
||||||
|
|
||||||
|
const atualizado = [
|
||||||
|
...(ticket.alertasEmitidos || []),
|
||||||
|
{ tipo: args.tipo, emitidoEm: Date.now() },
|
||||||
|
];
|
||||||
|
|
||||||
|
await ctx.db.patch(ticket._id, {
|
||||||
|
alertasEmitidos: atualizado,
|
||||||
|
});
|
||||||
|
|
||||||
|
await registrarInteracao(ctx, {
|
||||||
|
ticketId: ticket._id,
|
||||||
|
autorId: usuario._id,
|
||||||
|
origem: "sistema",
|
||||||
|
tipo: "alerta",
|
||||||
|
conteudo: args.mensagem,
|
||||||
|
});
|
||||||
|
|
||||||
|
await registrarNotificacoes(ctx, {
|
||||||
|
ticket,
|
||||||
|
titulo: `Alerta de SLA (${args.tipo})`,
|
||||||
|
mensagem: args.mensagem,
|
||||||
|
usuarioEvento: usuario._id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { sucesso: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const generateUploadUrl = mutation({
|
||||||
|
args: {},
|
||||||
|
handler: async (ctx) => {
|
||||||
|
await assertAuth(ctx);
|
||||||
|
return await ctx.storage.generateUploadUrl();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
@@ -299,9 +299,12 @@ export default defineSchema({
|
|||||||
nome: v.string(),
|
nome: v.string(),
|
||||||
descricao: v.optional(v.string()),
|
descricao: v.optional(v.string()),
|
||||||
gestorId: v.id("usuarios"),
|
gestorId: v.id("usuarios"),
|
||||||
|
gestorSuperiorId: v.optional(v.id("usuarios")),
|
||||||
ativo: v.boolean(),
|
ativo: v.boolean(),
|
||||||
cor: v.optional(v.string()), // Cor para identificação visual
|
cor: v.optional(v.string()), // Cor para identificação visual
|
||||||
}).index("by_gestor", ["gestorId"]),
|
})
|
||||||
|
.index("by_gestor", ["gestorId"])
|
||||||
|
.index("by_gestor_superior", ["gestorSuperiorId"]),
|
||||||
|
|
||||||
timesMembros: defineTable({
|
timesMembros: defineTable({
|
||||||
timeId: v.id("times"),
|
timeId: v.id("times"),
|
||||||
@@ -766,4 +769,181 @@ export default defineSchema({
|
|||||||
.index("by_timestamp", ["timestamp"])
|
.index("by_timestamp", ["timestamp"])
|
||||||
.index("by_status", ["status"])
|
.index("by_status", ["status"])
|
||||||
.index("by_config", ["configId", "timestamp"]),
|
.index("by_config", ["configId", "timestamp"]),
|
||||||
|
|
||||||
|
tickets: defineTable({
|
||||||
|
numero: v.string(),
|
||||||
|
titulo: v.string(),
|
||||||
|
descricao: v.string(),
|
||||||
|
tipo: v.union(
|
||||||
|
v.literal("reclamacao"),
|
||||||
|
v.literal("elogio"),
|
||||||
|
v.literal("sugestao"),
|
||||||
|
v.literal("chamado")
|
||||||
|
),
|
||||||
|
categoria: v.optional(v.string()),
|
||||||
|
status: v.union(
|
||||||
|
v.literal("aberto"),
|
||||||
|
v.literal("em_andamento"),
|
||||||
|
v.literal("aguardando_usuario"),
|
||||||
|
v.literal("resolvido"),
|
||||||
|
v.literal("encerrado"),
|
||||||
|
v.literal("cancelado")
|
||||||
|
),
|
||||||
|
prioridade: v.union(
|
||||||
|
v.literal("baixa"),
|
||||||
|
v.literal("media"),
|
||||||
|
v.literal("alta"),
|
||||||
|
v.literal("critica")
|
||||||
|
),
|
||||||
|
solicitanteId: v.id("usuarios"),
|
||||||
|
solicitanteNome: v.string(),
|
||||||
|
solicitanteEmail: v.string(),
|
||||||
|
responsavelId: v.optional(v.id("usuarios")),
|
||||||
|
setorResponsavel: v.optional(v.string()),
|
||||||
|
slaConfigId: v.optional(v.id("slaConfigs")),
|
||||||
|
conversaId: v.optional(v.id("conversas")),
|
||||||
|
prazoResposta: v.optional(v.number()),
|
||||||
|
prazoConclusao: v.optional(v.number()),
|
||||||
|
prazoEncerramento: v.optional(v.number()),
|
||||||
|
timeline: v.optional(
|
||||||
|
v.array(
|
||||||
|
v.object({
|
||||||
|
etapa: v.string(),
|
||||||
|
status: v.union(
|
||||||
|
v.literal("pendente"),
|
||||||
|
v.literal("em_andamento"),
|
||||||
|
v.literal("concluido"),
|
||||||
|
v.literal("vencido")
|
||||||
|
),
|
||||||
|
prazo: v.optional(v.number()),
|
||||||
|
concluidoEm: v.optional(v.number()),
|
||||||
|
observacao: v.optional(v.string()),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
|
alertasEmitidos: v.optional(
|
||||||
|
v.array(
|
||||||
|
v.object({
|
||||||
|
tipo: v.union(
|
||||||
|
v.literal("resposta"),
|
||||||
|
v.literal("conclusao"),
|
||||||
|
v.literal("encerramento")
|
||||||
|
),
|
||||||
|
emitidoEm: v.number(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
|
anexos: v.optional(
|
||||||
|
v.array(
|
||||||
|
v.object({
|
||||||
|
arquivoId: v.id("_storage"),
|
||||||
|
nome: v.optional(v.string()),
|
||||||
|
tipo: v.optional(v.string()),
|
||||||
|
tamanho: v.optional(v.number()),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
|
tags: v.optional(v.array(v.string())),
|
||||||
|
canalOrigem: v.optional(v.string()),
|
||||||
|
ultimaInteracaoEm: v.number(),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
atualizadoEm: v.number(),
|
||||||
|
})
|
||||||
|
.index("by_numero", ["numero"])
|
||||||
|
.index("by_status", ["status"])
|
||||||
|
.index("by_solicitante", ["solicitanteId", "status"])
|
||||||
|
.index("by_responsavel", ["responsavelId", "status"])
|
||||||
|
.index("by_setor", ["setorResponsavel", "status"]),
|
||||||
|
|
||||||
|
ticketInteractions: defineTable({
|
||||||
|
ticketId: v.id("tickets"),
|
||||||
|
autorId: v.optional(v.id("usuarios")),
|
||||||
|
origem: v.union(
|
||||||
|
v.literal("usuario"),
|
||||||
|
v.literal("ti"),
|
||||||
|
v.literal("sistema")
|
||||||
|
),
|
||||||
|
tipo: v.union(
|
||||||
|
v.literal("mensagem"),
|
||||||
|
v.literal("status"),
|
||||||
|
v.literal("anexo"),
|
||||||
|
v.literal("alerta")
|
||||||
|
),
|
||||||
|
conteudo: v.string(),
|
||||||
|
anexos: v.optional(
|
||||||
|
v.array(
|
||||||
|
v.object({
|
||||||
|
arquivoId: v.id("_storage"),
|
||||||
|
nome: v.optional(v.string()),
|
||||||
|
tipo: v.optional(v.string()),
|
||||||
|
tamanho: v.optional(v.number()),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
|
statusAnterior: v.optional(
|
||||||
|
v.union(
|
||||||
|
v.literal("aberto"),
|
||||||
|
v.literal("em_andamento"),
|
||||||
|
v.literal("aguardando_usuario"),
|
||||||
|
v.literal("resolvido"),
|
||||||
|
v.literal("encerrado"),
|
||||||
|
v.literal("cancelado")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
statusNovo: v.optional(
|
||||||
|
v.union(
|
||||||
|
v.literal("aberto"),
|
||||||
|
v.literal("em_andamento"),
|
||||||
|
v.literal("aguardando_usuario"),
|
||||||
|
v.literal("resolvido"),
|
||||||
|
v.literal("encerrado"),
|
||||||
|
v.literal("cancelado")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
visibilidade: v.union(
|
||||||
|
v.literal("publico"),
|
||||||
|
v.literal("interno")
|
||||||
|
),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
})
|
||||||
|
.index("by_ticket", ["ticketId"])
|
||||||
|
.index("by_ticket_type", ["ticketId", "tipo"])
|
||||||
|
.index("by_autor", ["autorId"]),
|
||||||
|
|
||||||
|
slaConfigs: defineTable({
|
||||||
|
nome: v.string(),
|
||||||
|
descricao: v.optional(v.string()),
|
||||||
|
prioridade: v.optional(
|
||||||
|
v.union(
|
||||||
|
v.literal("baixa"),
|
||||||
|
v.literal("media"),
|
||||||
|
v.literal("alta"),
|
||||||
|
v.literal("critica")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
tempoRespostaHoras: v.number(),
|
||||||
|
tempoConclusaoHoras: v.number(),
|
||||||
|
tempoEncerramentoHoras: v.optional(v.number()),
|
||||||
|
alertaAntecedenciaHoras: v.number(),
|
||||||
|
ativo: v.boolean(),
|
||||||
|
criadoPor: v.id("usuarios"),
|
||||||
|
atualizadoPor: v.optional(v.id("usuarios")),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
atualizadoEm: v.number(),
|
||||||
|
})
|
||||||
|
.index("by_ativo", ["ativo"])
|
||||||
|
.index("by_prioridade", ["prioridade", "ativo"])
|
||||||
|
.index("by_nome", ["nome"]),
|
||||||
|
|
||||||
|
ticketAssignments: defineTable({
|
||||||
|
ticketId: v.id("tickets"),
|
||||||
|
responsavelId: v.id("usuarios"),
|
||||||
|
atribuidoPor: v.id("usuarios"),
|
||||||
|
motivo: v.optional(v.string()),
|
||||||
|
ativo: v.boolean(),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
encerradoEm: v.optional(v.number()),
|
||||||
|
})
|
||||||
|
.index("by_ticket", ["ticketId", "ativo"])
|
||||||
|
.index("by_responsavel", ["responsavelId", "ativo"]),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -287,6 +287,115 @@ export const criarTemplatesPadrao = mutation({
|
|||||||
+ "</div></body></html>",
|
+ "</div></body></html>",
|
||||||
variaveis: ["remetente", "mensagem", "conversaId", "urlSistema"],
|
variaveis: ["remetente", "mensagem", "conversaId", "urlSistema"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
codigo: "chamado_registrado",
|
||||||
|
nome: "Chamado Registrado",
|
||||||
|
titulo: "Chamado {{numeroTicket}} registrado",
|
||||||
|
corpo: "<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>"
|
||||||
|
+ "<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>"
|
||||||
|
+ "<h2 style='color: #2563EB;'>Chamado registrado com sucesso!</h2>"
|
||||||
|
+ "<p>Olá <strong>{{solicitante}}</strong>,</p>"
|
||||||
|
+ "<p>Recebemos sua solicitação e iniciaremos o atendimento em breve.</p>"
|
||||||
|
+ "<div style='background-color: #EFF6FF; border-left: 4px solid #2563EB; padding: 15px; border-radius: 8px; margin: 20px 0;'>"
|
||||||
|
+ "<p style='margin: 0;'><strong>Ticket:</strong> {{numeroTicket}}</p>"
|
||||||
|
+ "<p style='margin: 5px 0 0 0;'><strong>Prioridade:</strong> {{prioridade}}</p>"
|
||||||
|
+ "<p style='margin: 5px 0 0 0;'><strong>Categoria:</strong> {{categoria}}</p>"
|
||||||
|
+ "</div>"
|
||||||
|
+ "<p style='margin-top: 30px;'>"
|
||||||
|
+ "<a href='{{urlSistema}}/perfil/chamados' "
|
||||||
|
+ "style='background-color: #2563EB; color: white; padding: 12px 24px; "
|
||||||
|
+ "text-decoration: none; border-radius: 6px; display: inline-block;'>"
|
||||||
|
+ "Acompanhar chamado"
|
||||||
|
+ "</a>"
|
||||||
|
+ "</p>"
|
||||||
|
+ "<p style='color: #6B7280; font-size: 12px; margin-top: 30px;'>"
|
||||||
|
+ "Central de Chamados SGSE"
|
||||||
|
+ "</p>"
|
||||||
|
+ "</div></body></html>",
|
||||||
|
variaveis: ["solicitante", "numeroTicket", "prioridade", "categoria", "urlSistema"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
codigo: "chamado_atualizado",
|
||||||
|
nome: "Atualização no Chamado",
|
||||||
|
titulo: "Atualização no chamado {{numeroTicket}}",
|
||||||
|
corpo: "<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>"
|
||||||
|
+ "<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>"
|
||||||
|
+ "<h2 style='color: #2563EB;'>Nova atualização no seu chamado</h2>"
|
||||||
|
+ "<p>Olá <strong>{{solicitante}}</strong>,</p>"
|
||||||
|
+ "<p>Há uma nova atualização no seu chamado:</p>"
|
||||||
|
+ "<div style='background-color: #EFF6FF; border-left: 4px solid #2563EB; padding: 15px; border-radius: 8px; margin: 20px 0;'>"
|
||||||
|
+ "<p style='margin: 0;'><strong>Ticket:</strong> {{numeroTicket}}</p>"
|
||||||
|
+ "<p style='margin: 5px 0 0 0;'><strong>Mensagem:</strong></p>"
|
||||||
|
+ "<p style='margin: 10px 0 0 0;'>{{mensagem}}</p>"
|
||||||
|
+ "</div>"
|
||||||
|
+ "<p style='margin-top: 30px;'>"
|
||||||
|
+ "<a href='{{urlSistema}}/perfil/chamados' "
|
||||||
|
+ "style='background-color: #2563EB; color: white; padding: 12px 24px; "
|
||||||
|
+ "text-decoration: none; border-radius: 6px; display: inline-block;'>"
|
||||||
|
+ "Ver detalhes"
|
||||||
|
+ "</a>"
|
||||||
|
+ "</p>"
|
||||||
|
+ "<p style='color: #6B7280; font-size: 12px; margin-top: 30px;'>"
|
||||||
|
+ "Central de Chamados SGSE"
|
||||||
|
+ "</p>"
|
||||||
|
+ "</div></body></html>",
|
||||||
|
variaveis: ["solicitante", "numeroTicket", "mensagem", "urlSistema"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
codigo: "chamado_atribuido",
|
||||||
|
nome: "Chamado Atribuído",
|
||||||
|
titulo: "Chamado {{numeroTicket}} atribuído",
|
||||||
|
corpo: "<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>"
|
||||||
|
+ "<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>"
|
||||||
|
+ "<h2 style='color: #059669;'>Chamado atribuído</h2>"
|
||||||
|
+ "<p>Olá <strong>{{responsavel}}</strong>,</p>"
|
||||||
|
+ "<p>Um novo chamado foi atribuído para você:</p>"
|
||||||
|
+ "<div style='background-color: #ECFDF5; border-left: 4px solid #059669; padding: 15px; border-radius: 8px; margin: 20px 0;'>"
|
||||||
|
+ "<p style='margin: 0;'><strong>Ticket:</strong> {{numeroTicket}}</p>"
|
||||||
|
+ "<p style='margin: 5px 0 0 0;'><strong>Solicitante:</strong> {{solicitante}}</p>"
|
||||||
|
+ "<p style='margin: 5px 0 0 0;'><strong>Prioridade:</strong> {{prioridade}}</p>"
|
||||||
|
+ "<p style='margin: 5px 0 0 0;'><strong>Descrição:</strong> {{descricao}}</p>"
|
||||||
|
+ "</div>"
|
||||||
|
+ "<p style='margin-top: 30px;'>"
|
||||||
|
+ "<a href='{{urlSistema}}/ti/central-chamados' "
|
||||||
|
+ "style='background-color: #059669; color: white; padding: 12px 24px; "
|
||||||
|
+ "text-decoration: none; border-radius: 6px; display: inline-block;'>"
|
||||||
|
+ "Acessar chamado"
|
||||||
|
+ "</a>"
|
||||||
|
+ "</p>"
|
||||||
|
+ "<p style='color: #6B7280; font-size: 12px; margin-top: 30px;'>"
|
||||||
|
+ "Central de Chamados SGSE"
|
||||||
|
+ "</p>"
|
||||||
|
+ "</div></body></html>",
|
||||||
|
variaveis: ["responsavel", "numeroTicket", "solicitante", "prioridade", "descricao", "urlSistema"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
codigo: "chamado_alerta_prazo",
|
||||||
|
nome: "Alerta de Prazo do Chamado",
|
||||||
|
titulo: "⚠️ Alerta de prazo - Chamado {{numeroTicket}}",
|
||||||
|
corpo: "<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>"
|
||||||
|
+ "<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>"
|
||||||
|
+ "<h2 style='color: #DC2626;'>⚠️ Alerta de prazo</h2>"
|
||||||
|
+ "<p>Olá <strong>{{destinatario}}</strong>,</p>"
|
||||||
|
+ "<p>O chamado abaixo está próximo do prazo de {{tipoPrazo}}:</p>"
|
||||||
|
+ "<div style='background-color: #FEF2F2; border-left: 4px solid #DC2626; padding: 15px; border-radius: 8px; margin: 20px 0;'>"
|
||||||
|
+ "<p style='margin: 0;'><strong>Ticket:</strong> {{numeroTicket}}</p>"
|
||||||
|
+ "<p style='margin: 5px 0 0 0;'><strong>Prazo de {{tipoPrazo}}:</strong> {{prazo}}</p>"
|
||||||
|
+ "<p style='margin: 5px 0 0 0;'><strong>Status:</strong> {{status}}</p>"
|
||||||
|
+ "</div>"
|
||||||
|
+ "<p style='margin-top: 30px;'>"
|
||||||
|
+ "<a href='{{urlSistema}}{{rotaAcesso}}' "
|
||||||
|
+ "style='background-color: #DC2626; color: white; padding: 12px 24px; "
|
||||||
|
+ "text-decoration: none; border-radius: 6px; display: inline-block;'>"
|
||||||
|
+ "Ver chamado"
|
||||||
|
+ "</a>"
|
||||||
|
+ "</p>"
|
||||||
|
+ "<p style='color: #6B7280; font-size: 12px; margin-top: 30px;'>"
|
||||||
|
+ "Central de Chamados SGSE"
|
||||||
|
+ "</p>"
|
||||||
|
+ "</div></body></html>",
|
||||||
|
variaveis: ["destinatario", "numeroTicket", "tipoPrazo", "prazo", "status", "urlSistema", "rotaAcesso"],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const template of templatesPadrao) {
|
for (const template of templatesPadrao) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
import { mutation, query } from "./_generated/server";
|
import { mutation, query } from "./_generated/server";
|
||||||
import { Id, Doc } from "./_generated/dataModel";
|
import { getCurrentUserFunction } from "./auth";
|
||||||
|
|
||||||
// Query: Listar todos os times
|
// Query: Listar todos os times
|
||||||
// Tipo inferido automaticamente pelo Convex
|
// Tipo inferido automaticamente pelo Convex
|
||||||
@@ -127,12 +127,67 @@ export const listarPorGestor = query({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const listarSubordinadosDoGestorAtual = query({
|
||||||
|
args: {},
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const usuario = await getCurrentUserFunction(ctx);
|
||||||
|
if (!usuario) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const timesGestor = await ctx.db
|
||||||
|
.query("times")
|
||||||
|
.withIndex("by_gestor", (q) => q.eq("gestorId", usuario._id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
const timesComoSuperior = await ctx.db
|
||||||
|
.query("times")
|
||||||
|
.withIndex("by_gestor_superior", (q) => q.eq("gestorSuperiorId", usuario._id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
const timesMap = new Map(
|
||||||
|
[...timesGestor, ...timesComoSuperior]
|
||||||
|
.filter((time) => time.ativo)
|
||||||
|
.map((time) => [time._id, time])
|
||||||
|
);
|
||||||
|
|
||||||
|
const resultado = [];
|
||||||
|
|
||||||
|
for (const time of timesMap.values()) {
|
||||||
|
const membrosRelacoes = await ctx.db
|
||||||
|
.query("timesMembros")
|
||||||
|
.withIndex("by_time_and_ativo", (q) => q.eq("timeId", time._id).eq("ativo", true))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
const membros = [];
|
||||||
|
for (const rel of membrosRelacoes) {
|
||||||
|
const funcionario = await ctx.db.get(rel.funcionarioId);
|
||||||
|
if (funcionario) {
|
||||||
|
membros.push({
|
||||||
|
relacaoId: rel._id,
|
||||||
|
funcionario,
|
||||||
|
dataEntrada: rel.dataEntrada,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resultado.push({
|
||||||
|
...time,
|
||||||
|
membros,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return resultado;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Mutation: Criar time
|
// Mutation: Criar time
|
||||||
export const criar = mutation({
|
export const criar = mutation({
|
||||||
args: {
|
args: {
|
||||||
nome: v.string(),
|
nome: v.string(),
|
||||||
descricao: v.optional(v.string()),
|
descricao: v.optional(v.string()),
|
||||||
gestorId: v.id("usuarios"),
|
gestorId: v.id("usuarios"),
|
||||||
|
gestorSuperiorId: v.optional(v.id("usuarios")),
|
||||||
cor: v.optional(v.string()),
|
cor: v.optional(v.string()),
|
||||||
},
|
},
|
||||||
returns: v.id("times"),
|
returns: v.id("times"),
|
||||||
@@ -141,6 +196,7 @@ export const criar = mutation({
|
|||||||
nome: args.nome,
|
nome: args.nome,
|
||||||
descricao: args.descricao,
|
descricao: args.descricao,
|
||||||
gestorId: args.gestorId,
|
gestorId: args.gestorId,
|
||||||
|
gestorSuperiorId: args.gestorSuperiorId ?? args.gestorId,
|
||||||
ativo: true,
|
ativo: true,
|
||||||
cor: args.cor || "#3B82F6",
|
cor: args.cor || "#3B82F6",
|
||||||
});
|
});
|
||||||
@@ -156,12 +212,16 @@ export const atualizar = mutation({
|
|||||||
nome: v.string(),
|
nome: v.string(),
|
||||||
descricao: v.optional(v.string()),
|
descricao: v.optional(v.string()),
|
||||||
gestorId: v.id("usuarios"),
|
gestorId: v.id("usuarios"),
|
||||||
|
gestorSuperiorId: v.optional(v.id("usuarios")),
|
||||||
cor: v.optional(v.string()),
|
cor: v.optional(v.string()),
|
||||||
},
|
},
|
||||||
returns: v.null(),
|
returns: v.null(),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const { id, ...dados } = args;
|
const { id, ...dados } = args;
|
||||||
await ctx.db.patch(id, dados);
|
await ctx.db.patch(id, {
|
||||||
|
...dados,
|
||||||
|
gestorSuperiorId: dados.gestorSuperiorId ?? dados.gestorId,
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -185,6 +245,8 @@ export const desativar = mutation({
|
|||||||
ativo: false,
|
ativo: false,
|
||||||
dataSaida: Date.now(),
|
dataSaida: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await ctx.db.patch(membro.funcionarioId, { gestorId: undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -210,12 +272,19 @@ export const adicionarMembro = mutation({
|
|||||||
throw new Error("Funcionário já está em um time ativo");
|
throw new Error("Funcionário já está em um time ativo");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const time = await ctx.db.get(args.timeId);
|
||||||
|
if (!time || !time.ativo) {
|
||||||
|
throw new Error("Time inválido ou inativo");
|
||||||
|
}
|
||||||
|
|
||||||
const membroId = await ctx.db.insert("timesMembros", {
|
const membroId = await ctx.db.insert("timesMembros", {
|
||||||
timeId: args.timeId,
|
timeId: args.timeId,
|
||||||
funcionarioId: args.funcionarioId,
|
funcionarioId: args.funcionarioId,
|
||||||
dataEntrada: Date.now(),
|
dataEntrada: Date.now(),
|
||||||
ativo: true,
|
ativo: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await ctx.db.patch(args.funcionarioId, { gestorId: time.gestorId });
|
||||||
|
|
||||||
return membroId;
|
return membroId;
|
||||||
},
|
},
|
||||||
@@ -226,10 +295,16 @@ export const removerMembro = mutation({
|
|||||||
args: { membroId: v.id("timesMembros") },
|
args: { membroId: v.id("timesMembros") },
|
||||||
returns: v.null(),
|
returns: v.null(),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
|
const membro = await ctx.db.get(args.membroId);
|
||||||
|
if (!membro) {
|
||||||
|
throw new Error("Membro não encontrado");
|
||||||
|
}
|
||||||
await ctx.db.patch(args.membroId, {
|
await ctx.db.patch(args.membroId, {
|
||||||
ativo: false,
|
ativo: false,
|
||||||
dataSaida: Date.now(),
|
dataSaida: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await ctx.db.patch(membro.funcionarioId, { gestorId: undefined });
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -257,12 +332,19 @@ export const transferirMembro = mutation({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Adicionar ao novo time
|
// Adicionar ao novo time
|
||||||
|
const novoTime = await ctx.db.get(args.novoTimeId);
|
||||||
|
if (!novoTime || !novoTime.ativo) {
|
||||||
|
throw new Error("Novo time inválido ou inativo");
|
||||||
|
}
|
||||||
|
|
||||||
await ctx.db.insert("timesMembros", {
|
await ctx.db.insert("timesMembros", {
|
||||||
timeId: args.novoTimeId,
|
timeId: args.novoTimeId,
|
||||||
funcionarioId: args.funcionarioId,
|
funcionarioId: args.funcionarioId,
|
||||||
dataEntrada: Date.now(),
|
dataEntrada: Date.now(),
|
||||||
ativo: true,
|
ativo: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await ctx.db.patch(args.funcionarioId, { gestorId: novoTime.gestorId });
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user