feat: enhance cybersecurity features and add ticket management components
- Introduced new components for managing tickets, including TicketForm, TicketCard, and TicketTimeline, to streamline the ticketing process. - Added a new SlaChart component for visualizing SLA data. - Implemented a CybersecurityWizcard component for enhanced security monitoring and reporting. - Updated routing to replace the "Solicitar Acesso" page with "Abrir Chamado" for improved user navigation. - Integrated rate limiting functionality to enhance security measures. - Added a comprehensive test report for the cybersecurity system, detailing various attack simulations and their outcomes. - Included new scripts for security testing and environment setup to facilitate automated security assessments.
This commit is contained in:
@@ -1,74 +1,116 @@
|
||||
<script lang="ts">
|
||||
import { useConvexClient } from "convex-svelte";
|
||||
import {
|
||||
ExternalLink,
|
||||
FileText,
|
||||
File as FileIcon,
|
||||
Upload,
|
||||
Trash2,
|
||||
Eye,
|
||||
RefreshCw,
|
||||
} from "lucide-svelte";
|
||||
import { useConvexClient } from 'convex-svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import {
|
||||
ExternalLink,
|
||||
FileText,
|
||||
File as FileIcon,
|
||||
Upload,
|
||||
Trash2,
|
||||
Eye,
|
||||
RefreshCw
|
||||
} from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
helpUrl?: string;
|
||||
value?: string; // storageId
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
onUpload: (file: globalThis.File) => Promise<void>;
|
||||
onRemove: () => Promise<void>;
|
||||
}
|
||||
interface Props {
|
||||
label: string;
|
||||
helpUrl?: string;
|
||||
value?: string; // storageId
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
onUpload: (file: globalThis.File) => Promise<void>;
|
||||
onRemove: () => Promise<void>;
|
||||
}
|
||||
|
||||
let {
|
||||
label,
|
||||
helpUrl,
|
||||
value = $bindable(),
|
||||
disabled = false,
|
||||
required = false,
|
||||
onUpload,
|
||||
onRemove,
|
||||
}: Props = $props();
|
||||
let {
|
||||
label,
|
||||
helpUrl,
|
||||
value = $bindable(),
|
||||
disabled = false,
|
||||
required = false,
|
||||
onUpload,
|
||||
onRemove
|
||||
}: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
const client = useConvexClient() as unknown as {
|
||||
storage: {
|
||||
getUrl: (id: string) => Promise<string | null>;
|
||||
};
|
||||
};
|
||||
|
||||
let fileInput: HTMLInputElement;
|
||||
let uploading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let fileName = $state<string>("");
|
||||
let fileType = $state<string>("");
|
||||
let previewUrl = $state<string | null>(null);
|
||||
let fileUrl = $state<string | null>(null);
|
||||
let fileInput: HTMLInputElement | null = null;
|
||||
let uploading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let fileName = $state<string>('');
|
||||
let fileType = $state<string>('');
|
||||
let previewUrl = $state<string | null>(null);
|
||||
let fileUrl = $state<string | null>(null);
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const ALLOWED_TYPES = [
|
||||
"application/pdf",
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
];
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const ALLOWED_TYPES = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png'];
|
||||
|
||||
// Buscar URL do arquivo quando houver um storageId
|
||||
$effect(() => {
|
||||
if (value && !fileName) {
|
||||
// Tem storageId mas não é um upload recente
|
||||
loadExistingFile(value);
|
||||
void loadExistingFile(value);
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const storageId = value;
|
||||
|
||||
if (!storageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const url = await client.storage.getUrl(storageId);
|
||||
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);
|
||||
const url = await client.storage.getUrl(storageId);
|
||||
if (url) {
|
||||
async function handleFileSelect(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
fileUrl = url;
|
||||
|
||||
// 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
|
||||
// Para imagens, a URL serve como preview
|
||||
previewUrl = url;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -76,111 +118,141 @@
|
||||
}
|
||||
}
|
||||
|
||||
error = null;
|
||||
async function handleFileSelect(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
|
||||
// Validate file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
error = "Arquivo muito grande. Tamanho máximo: 10MB";
|
||||
target.value = "";
|
||||
return;
|
||||
}
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
error = "Tipo de arquivo não permitido. Use PDF ou imagens (JPG, PNG)";
|
||||
target.value = "";
|
||||
return;
|
||||
}
|
||||
error = null;
|
||||
|
||||
try {
|
||||
uploading = true;
|
||||
fileName = file.name;
|
||||
fileType = file.type;
|
||||
// Validate file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
error = 'Arquivo muito grande. Tamanho máximo: 10MB';
|
||||
target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Create preview for images
|
||||
if (file.type.startsWith("image/")) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
previewUrl = e.target?.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
// Validate file type
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
error = 'Tipo de arquivo não permitido. Use PDF ou imagens (JPG, PNG)';
|
||||
target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
await onUpload(file);
|
||||
} catch (err: any) {
|
||||
error = err?.message || "Erro ao fazer upload do arquivo";
|
||||
previewUrl = null;
|
||||
} finally {
|
||||
uploading = false;
|
||||
target.value = "";
|
||||
}
|
||||
}
|
||||
try {
|
||||
uploading = true;
|
||||
fileName = file.name;
|
||||
fileType = file.type;
|
||||
|
||||
async function handleRemove() {
|
||||
if (!confirm("Tem certeza que deseja remover este arquivo?")) {
|
||||
return;
|
||||
}
|
||||
// Create preview for images
|
||||
if (file.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const result = e.target?.result;
|
||||
if (typeof result === 'string') {
|
||||
previewUrl = result;
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
previewUrl = null;
|
||||
}
|
||||
|
||||
try {
|
||||
uploading = true;
|
||||
await onRemove();
|
||||
fileName = "";
|
||||
fileType = "";
|
||||
previewUrl = null;
|
||||
fileUrl = null;
|
||||
} catch (err: any) {
|
||||
error = err?.message || "Erro ao remover arquivo";
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
await onUpload(file);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
error = err.message || 'Erro ao fazer upload do arquivo';
|
||||
} else {
|
||||
error = 'Erro ao fazer upload do arquivo';
|
||||
}
|
||||
previewUrl = null;
|
||||
} finally {
|
||||
uploading = false;
|
||||
target.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function handleView() {
|
||||
if (fileUrl) {
|
||||
window.open(fileUrl, "_blank");
|
||||
}
|
||||
}
|
||||
async function handleRemove() {
|
||||
if (!confirm('Tem certeza que deseja remover este arquivo?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
function openFileDialog() {
|
||||
fileInput?.click();
|
||||
}
|
||||
try {
|
||||
uploading = true;
|
||||
await onRemove();
|
||||
fileName = '';
|
||||
fileType = '';
|
||||
previewUrl = null;
|
||||
fileUrl = null;
|
||||
error = null;
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
error = err.message || 'Erro ao remover arquivo';
|
||||
} else {
|
||||
error = 'Erro ao remover arquivo';
|
||||
}
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleView() {
|
||||
if (fileUrl) {
|
||||
window.open(fileUrl, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
function openFileDialog() {
|
||||
fileInput?.click();
|
||||
}
|
||||
|
||||
function setFileInput(node: HTMLInputElement) {
|
||||
fileInput = node;
|
||||
return {
|
||||
destroy() {
|
||||
if (fileInput === node) {
|
||||
fileInput = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="file-upload-input">
|
||||
<span class="label-text font-medium flex items-center gap-2">
|
||||
{label}
|
||||
{#if required}
|
||||
<span class="text-error">*</span>
|
||||
{/if}
|
||||
{#if helpUrl}
|
||||
<div
|
||||
class="tooltip tooltip-right"
|
||||
data-tip="Clique para acessar o link"
|
||||
>
|
||||
<a
|
||||
href={helpUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary hover:text-primary-focus transition-colors"
|
||||
aria-label="Acessar link"
|
||||
>
|
||||
<ExternalLink class="h-4 w-4" strokeWidth={2} />
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
<label class="label" for="file-upload-input">
|
||||
<span class="label-text flex items-center gap-2 font-medium">
|
||||
{label}
|
||||
{#if required}
|
||||
<span class="text-error">*</span>
|
||||
{/if}
|
||||
{#if helpUrl}
|
||||
<div class="tooltip tooltip-right" data-tip="Clique para acessar o link">
|
||||
<a
|
||||
href={helpUrl ?? '/'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary hover:text-primary-focus transition-colors"
|
||||
aria-label="Acessar link"
|
||||
>
|
||||
<ExternalLink class="h-4 w-4" strokeWidth={2} />
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="file-upload-input"
|
||||
type="file"
|
||||
bind:this={fileInput}
|
||||
onchange={handleFileSelect}
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
class="hidden"
|
||||
{disabled}
|
||||
/>
|
||||
<input
|
||||
id="file-upload-input"
|
||||
type="file"
|
||||
use:setFileInput
|
||||
onchange={handleFileSelect}
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
class="hidden"
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
{#if value || fileName}
|
||||
<div class="border-base-300 bg-base-100 flex items-center gap-2 rounded-lg border p-3">
|
||||
@@ -194,78 +266,78 @@
|
||||
</div>
|
||||
{:else}
|
||||
<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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- File info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate">
|
||||
{fileName || "Arquivo anexado"}
|
||||
</p>
|
||||
<p class="text-xs text-base-content/60">
|
||||
{#if uploading}
|
||||
Carregando...
|
||||
{:else}
|
||||
Enviado com sucesso
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<!-- File info -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium">
|
||||
{fileName || 'Arquivo anexado'}
|
||||
</p>
|
||||
<p class="text-base-content/60 text-xs">
|
||||
{#if uploading}
|
||||
Carregando...
|
||||
{:else}
|
||||
Enviado com sucesso
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2">
|
||||
{#if fileUrl}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleView}
|
||||
class="btn btn-sm btn-ghost text-info"
|
||||
disabled={uploading || disabled}
|
||||
title="Visualizar arquivo"
|
||||
>
|
||||
<Eye class="h-4 w-4" strokeWidth={2} />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={openFileDialog}
|
||||
class="btn btn-sm btn-ghost"
|
||||
disabled={uploading || disabled}
|
||||
title="Substituir arquivo"
|
||||
>
|
||||
<RefreshCw class="h-4 w-4" strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleRemove}
|
||||
class="btn btn-sm btn-ghost text-error"
|
||||
disabled={uploading || disabled}
|
||||
title="Remover arquivo"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={openFileDialog}
|
||||
class="btn btn-outline btn-block justify-start gap-2"
|
||||
disabled={uploading || disabled}
|
||||
>
|
||||
{#if uploading}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Carregando...
|
||||
{:else}
|
||||
<Upload class="h-5 w-5" strokeWidth={2} />
|
||||
Selecionar arquivo (PDF ou imagem, máx. 10MB)
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2">
|
||||
{#if fileUrl}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleView}
|
||||
class="btn btn-sm btn-ghost text-info"
|
||||
disabled={uploading || disabled}
|
||||
title="Visualizar arquivo"
|
||||
>
|
||||
<Eye class="h-4 w-4" strokeWidth={2} />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={openFileDialog}
|
||||
class="btn btn-sm btn-ghost"
|
||||
disabled={uploading || disabled}
|
||||
title="Substituir arquivo"
|
||||
>
|
||||
<RefreshCw class="h-4 w-4" strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleRemove}
|
||||
class="btn btn-sm btn-ghost text-error"
|
||||
disabled={uploading || disabled}
|
||||
title="Remover arquivo"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={openFileDialog}
|
||||
class="btn btn-outline btn-block justify-start gap-2"
|
||||
disabled={uploading || disabled}
|
||||
>
|
||||
{#if uploading}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Carregando...
|
||||
{:else}
|
||||
<Upload class="h-5 w-5" strokeWidth={2} />
|
||||
Selecionar arquivo (PDF ou imagem, máx. 10MB)
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if error}
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -52,8 +52,8 @@
|
||||
});
|
||||
|
||||
function verificarPermissoes() {
|
||||
// Dashboard e Solicitar Acesso são públicos
|
||||
if (menuPath === "/" || menuPath === "/solicitar-acesso") {
|
||||
// Dashboard e abertura de chamados são públicos
|
||||
if (menuPath === "/" || menuPath === "/abrir-chamado") {
|
||||
verificando = false;
|
||||
temPermissao = true;
|
||||
return;
|
||||
|
||||
@@ -1,80 +1,77 @@
|
||||
<script lang="ts">
|
||||
import { useQuery } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import { onMount } from "svelte";
|
||||
import { page } from "$app/stores";
|
||||
import type { Snippet } from "svelte";
|
||||
import { useQuery } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { onMount } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
children,
|
||||
requireAuth = true,
|
||||
allowedRoles = [],
|
||||
maxLevel = 3,
|
||||
redirectTo = "/",
|
||||
}: {
|
||||
children: Snippet;
|
||||
requireAuth?: boolean;
|
||||
allowedRoles?: string[];
|
||||
maxLevel?: number;
|
||||
redirectTo?: string;
|
||||
} = $props();
|
||||
let {
|
||||
children,
|
||||
requireAuth = true,
|
||||
allowedRoles = [],
|
||||
maxLevel = 3,
|
||||
redirectTo = '/'
|
||||
}: {
|
||||
children: Snippet;
|
||||
requireAuth?: boolean;
|
||||
allowedRoles?: string[];
|
||||
maxLevel?: number;
|
||||
redirectTo?: string;
|
||||
} = $props();
|
||||
|
||||
let isChecking = $state(true);
|
||||
let hasAccess = $state(false);
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
let isChecking = $state(true);
|
||||
let hasAccess = $state(false);
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
|
||||
onMount(() => {
|
||||
checkAccess();
|
||||
});
|
||||
onMount(() => {
|
||||
checkAccess();
|
||||
});
|
||||
|
||||
function checkAccess() {
|
||||
isChecking = true;
|
||||
function checkAccess() {
|
||||
isChecking = true;
|
||||
|
||||
// Aguardar um pouco para o authStore carregar do localStorage
|
||||
setTimeout(() => {
|
||||
// Verificar autenticação
|
||||
if (requireAuth && !currentUser?.data) {
|
||||
const currentPath = window.location.pathname;
|
||||
window.location.href = `${redirectTo}?error=auth_required&redirect=${encodeURIComponent(currentPath)}`;
|
||||
return;
|
||||
}
|
||||
// Aguardar um pouco para o authStore carregar do localStorage
|
||||
setTimeout(() => {
|
||||
// Verificar autenticação
|
||||
if (requireAuth && !currentUser?.data) {
|
||||
const currentPath = window.location.pathname;
|
||||
window.location.href = `${redirectTo}?error=auth_required&redirect=${encodeURIComponent(currentPath)}`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar roles
|
||||
if (allowedRoles.length > 0 && currentUser?.data) {
|
||||
const hasRole = allowedRoles.includes(
|
||||
currentUser.data.role?.nome ?? "",
|
||||
);
|
||||
if (!hasRole) {
|
||||
const currentPath = window.location.pathname;
|
||||
window.location.href = `${redirectTo}?error=access_denied&route=${encodeURIComponent(currentPath)}`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Verificar roles
|
||||
if (allowedRoles.length > 0 && currentUser?.data) {
|
||||
const hasRole = allowedRoles.includes(currentUser.data.role?.nome ?? '');
|
||||
if (!hasRole) {
|
||||
const currentPath = window.location.pathname;
|
||||
window.location.href = `${redirectTo}?error=access_denied&route=${encodeURIComponent(currentPath)}`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Verificar nível
|
||||
if (
|
||||
currentUser?.data &&
|
||||
currentUser.data.role?.nivel &&
|
||||
currentUser.data.role.nivel > maxLevel
|
||||
) {
|
||||
const currentPath = window.location.pathname;
|
||||
window.location.href = `${redirectTo}?error=access_denied&route=${encodeURIComponent(currentPath)}`;
|
||||
return;
|
||||
}
|
||||
// Verificar nível
|
||||
if (
|
||||
currentUser?.data &&
|
||||
currentUser.data.role?.nivel &&
|
||||
currentUser.data.role.nivel > maxLevel
|
||||
) {
|
||||
const currentPath = window.location.pathname;
|
||||
window.location.href = `${redirectTo}?error=access_denied&route=${encodeURIComponent(currentPath)}`;
|
||||
return;
|
||||
}
|
||||
|
||||
hasAccess = true;
|
||||
isChecking = false;
|
||||
}, 100);
|
||||
}
|
||||
hasAccess = true;
|
||||
isChecking = false;
|
||||
}, 100);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isChecking}
|
||||
<div class="flex justify-center items-center min-h-screen">
|
||||
<div class="text-center">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
<p class="mt-4 text-base-content/70">Verificando permissões...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex min-h-screen items-center justify-center">
|
||||
<div class="text-center">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
<p class="text-base-content/70 mt-4">Verificando permissões...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if hasAccess}
|
||||
{@render children()}
|
||||
{@render children()}
|
||||
{/if}
|
||||
|
||||
@@ -376,11 +376,11 @@
|
||||
{/each}
|
||||
<li class="mt-auto rounded-xl">
|
||||
<a
|
||||
href={resolve('/solicitar-acesso')}
|
||||
class={getSolicitarClasses(currentPath === '/solicitar-acesso')}
|
||||
href={resolve('/abrir-chamado')}
|
||||
class={getSolicitarClasses(currentPath === '/abrir-chamado')}
|
||||
>
|
||||
<UserPlus class="h-5 w-5" strokeWidth={2} />
|
||||
<span>Solicitar acesso</span>
|
||||
<span>Abrir Chamado</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -460,11 +460,11 @@
|
||||
</div>
|
||||
<div class="mt-4 space-y-2 text-center">
|
||||
<a
|
||||
href={resolve('/solicitar-acesso')}
|
||||
href={resolve('/abrir-chamado')}
|
||||
class="link link-primary block text-sm"
|
||||
onclick={closeLoginModal}
|
||||
>
|
||||
Não tem acesso? Solicite aqui
|
||||
Abrir Chamado
|
||||
</a>
|
||||
<a
|
||||
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>
|
||||
|
||||
2744
apps/web/src/lib/components/ti/CybersecurityWizcard.svelte
Normal file
2744
apps/web/src/lib/components/ti/CybersecurityWizcard.svelte
Normal file
File diff suppressed because it is too large
Load Diff
@@ -30,15 +30,25 @@ export function useConvexWithAuth() {
|
||||
const clientWithAuth = client as ConvexClientWithAuth;
|
||||
|
||||
// Configurar token se disponível
|
||||
if (clientWithAuth && typeof clientWithAuth.setAuth === "function" && token) {
|
||||
if (clientWithAuth && token) {
|
||||
try {
|
||||
clientWithAuth.setAuth(token);
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("✅ [useConvexWithAuth] Token configurado:", token.substring(0, 20) + "...");
|
||||
// Tentar setAuth se disponível
|
||||
if (typeof clientWithAuth.setAuth === "function") {
|
||||
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) {
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user