Initial commit

This commit is contained in:
2026-01-12 08:17:17 -07:00
commit de5b6feef4
104 changed files with 12925 additions and 0 deletions

183
frontend-client/src/app.css Normal file
View File

@@ -0,0 +1,183 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom base styles */
@layer base {
html {
font-family: 'Inter', system-ui, sans-serif;
}
body {
@apply text-gray-900 bg-gray-50;
}
/* Focus styles */
*:focus {
@apply outline-none ring-2 ring-primary-500 ring-offset-2;
}
/* Selection styles */
::selection {
@apply bg-primary-100 text-primary-900;
}
}
/* Custom component styles */
@layer components {
/* Card component */
.card {
@apply bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden;
}
.card-content {
@apply p-6;
}
/* Button variants */
.btn {
@apply inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none px-4 py-2;
}
.btn-primary {
@apply bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800;
}
.btn-secondary {
@apply bg-secondary-100 text-secondary-900 hover:bg-secondary-200 active:bg-secondary-300;
}
.btn-success {
@apply bg-success-600 text-white hover:bg-success-700 active:bg-success-800;
}
.btn-warning {
@apply bg-warning-600 text-white hover:bg-warning-700 active:bg-warning-800;
}
.btn-error {
@apply bg-error-600 text-white hover:bg-error-700 active:bg-error-800;
}
.btn-ghost {
@apply bg-transparent hover:bg-secondary-100 active:bg-secondary-200;
}
/* Card styles */
.card {
@apply bg-white rounded-lg shadow-sm border border-gray-200;
}
.card-header {
@apply p-6 border-b border-gray-200;
}
.card-content {
@apply p-6;
}
.card-footer {
@apply p-6 border-t border-gray-200 bg-gray-50 rounded-b-lg;
}
/* Form styles */
.form-input {
@apply block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm;
}
.form-label {
@apply block text-sm font-medium text-gray-700 mb-1;
}
.form-error {
@apply text-sm text-error-600 mt-1;
}
/* Status badges */
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.badge-new {
@apply badge bg-blue-100 text-blue-800;
}
.badge-in-progress {
@apply badge bg-yellow-100 text-yellow-800;
}
.badge-waiting {
@apply badge bg-orange-100 text-orange-800;
}
.badge-resolved {
@apply badge bg-green-100 text-green-800;
}
.badge-closed {
@apply badge bg-gray-100 text-gray-800;
}
.badge-reopened {
@apply badge bg-red-100 text-red-800;
}
/* Priority badges */
.badge-priority-low {
@apply badge bg-gray-100 text-gray-600;
}
.badge-priority-medium {
@apply badge bg-blue-100 text-blue-700;
}
.badge-priority-high {
@apply badge bg-orange-100 text-orange-700;
}
.badge-priority-urgent {
@apply badge bg-red-100 text-red-700;
}
}
/* Custom utility classes */
@layer utilities {
.text-balance {
text-wrap: balance;
}
/* Loading spinner */
.spinner {
@apply animate-spin rounded-full border-2 border-gray-300 border-t-primary-600;
}
/* Animations */
.animate-fade-in {
animation: fadeIn 0.3s ease-in-out;
}
.animate-slide-up {
animation: slideUp 0.3s ease-out;
}
}
/* Custom keyframes */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#3b82f6" />
<!-- SEO Meta Tags -->
<meta name="description" content="ServiceManager - Portal de Soporte Técnico para Clientes" />
<meta name="keywords" content="soporte técnico, mesa de ayuda, tickets, aduanasoft" />
<meta name="author" content="Aduanasoft" />
<!-- Open Graph Meta Tags -->
<meta property="og:type" content="website" />
<meta property="og:title" content="ServiceManager - Portal Cliente" />
<meta property="og:description" content="Gestiona tus tickets de soporte técnico" />
<meta property="og:site_name" content="ServiceManager" />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" class="min-h-screen bg-gray-50 antialiased">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,130 @@
<script lang="ts">
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js';
import Icon from './Icon.svelte';
export let showLogo = true;
export let showNavigation = true;
let isMenuOpen = false;
onMount(() => {
auth.init();
});
function toggleMenu() {
isMenuOpen = !isMenuOpen;
}
function handleLogout() {
auth.logout();
isMenuOpen = false;
}
</script>
<header class="bg-white shadow-sm border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<!-- Logo -->
{#if showLogo}
<div class="flex items-center">
<a href="/" class="flex items-center space-x-2">
<div class="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
<Icon name="ticket" size="w-5 h-5" className="text-white" />
</div>
<div class="hidden sm:block">
<h1 class="text-xl font-semibold text-gray-900">ServiceManager</h1>
<p class="text-xs text-gray-500">Mesa de Ayuda</p>
</div>
</a>
</div>
{/if}
<!-- Navigation -->
{#if showNavigation && $auth.isAuthenticated}
<nav class="hidden md:flex space-x-8">
<a href="/tickets" class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium">
Mis Tickets
</a>
<a href="/tickets/new" class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium">
Crear Ticket
</a>
</nav>
{/if}
<!-- User menu -->
<div class="flex items-center space-x-4">
{#if $auth.isAuthenticated}
<div class="relative">
<button
on:click={toggleMenu}
class="flex items-center space-x-2 text-gray-700 hover:text-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 rounded-md p-2"
>
<div class="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center">
<span class="text-primary-600 text-sm font-medium">
{$auth.user?.first_name?.[0]}{$auth.user?.last_name?.[0]}
</span>
</div>
<span class="hidden sm:block text-sm">
{$auth.user?.first_name} {$auth.user?.last_name}
</span>
<Icon name="chevronDown" size="w-4 h-4" />
</button>
{#if isMenuOpen}
<div class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg border border-gray-200 z-50">
<div class="py-1">
<div class="px-4 py-2 text-xs text-gray-500 border-b border-gray-200">
{$auth.user?.email}
</div>
<a
href="/profile"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
on:click={() => isMenuOpen = false}
>
Mi Perfil
</a>
<button
on:click={handleLogout}
class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
Cerrar Sesión
</button>
</div>
</div>
{/if}
</div>
{:else}
<a
href="/login"
class="text-gray-700 hover:text-primary-600 text-sm font-medium"
>
Iniciar Sesión
</a>
{/if}
</div>
</div>
<!-- Mobile navigation -->
{#if showNavigation && $auth.isAuthenticated}
<div class="md:hidden border-t border-gray-200 py-2">
<nav class="flex space-x-4">
<a href="/tickets" class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium">
Mis Tickets
</a>
<a href="/tickets/new" class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium">
Crear Ticket
</a>
</nav>
</div>
{/if}
</div>
</header>
<!-- Backdrop for mobile menu -->
{#if isMenuOpen}
<div
class="fixed inset-0 z-40 md:hidden"
on:click={() => isMenuOpen = false}
></div>
{/if}

View File

@@ -0,0 +1,39 @@
<script lang="ts">
export let name: string;
export let size: string = 'w-5 h-5';
export let className: string = '';
const icons: Record<string, string> = {
home: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6',
ticket: 'M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z',
plus: 'M12 4v16m8-8H4',
user: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z',
menu: 'M4 6h16M4 12h16M4 18h16',
x: 'M6 18L18 6M6 6l12 12',
bell: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9',
search: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z',
edit: 'M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z',
trash: 'M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16',
eye: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z',
clock: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
check: 'M5 13l4 4L19 7',
chevronDown: 'M19 9l-7 7-7-7'
};
$: path = icons[name] || icons.home;
</script>
<svg
class="{size} {className}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={path}
/>
</svg>

View File

@@ -0,0 +1,124 @@
<script lang="ts">
export let ticket: import('$lib/stores/tickets').Ticket;
// Status mapping
const statusConfig = {
NEW: { label: 'Nuevo', class: 'badge-new' },
IN_PROGRESS: { label: 'En Progreso', class: 'badge-in-progress' },
WAITING_FOR_CLIENT: { label: 'Esperando Cliente', class: 'badge-waiting' },
RESOLVED: { label: 'Resuelto', class: 'badge-resolved' },
CLOSED: { label: 'Cerrado', class: 'badge-closed' },
REOPENED: { label: 'Reabierto', class: 'badge-reopened' }
};
// Priority mapping
const priorityConfig = {
LOW: { label: 'Baja', class: 'badge-priority-low' },
MEDIUM: { label: 'Media', class: 'badge-priority-medium' },
HIGH: { label: 'Alta', class: 'badge-priority-high' },
URGENT: { label: 'Urgente', class: 'badge-priority-urgent' }
};
// Format date
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('es-ES', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
// Format relative time
function formatRelativeTime(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffInMinutes = Math.floor((now.getTime() - date.getTime()) / (1000 * 60));
if (diffInMinutes < 1) return 'hace un momento';
if (diffInMinutes < 60) return `hace ${diffInMinutes}m`;
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) return `hace ${diffInHours}h`;
const diffInDays = Math.floor(diffInHours / 24);
if (diffInDays < 7) return `hace ${diffInDays}d`;
return formatDate(dateString);
}
</script>
<div class="card hover:shadow-md transition-shadow">
<div class="card-content">
<div class="flex justify-between items-start mb-3">
<h3 class="text-lg font-medium text-gray-900 line-clamp-2">
<a href="/tickets/{ticket.id}" class="hover:text-primary-600">
{ticket.title}
</a>
</h3>
<div class="flex items-center space-x-2 ml-4">
<span class={`${statusConfig[ticket.status].class}`}>
{statusConfig[ticket.status].label}
</span>
<span class={`${priorityConfig[ticket.priority].class}`}>
{priorityConfig[ticket.priority].label}
</span>
</div>
</div>
<p class="text-gray-600 text-sm line-clamp-3 mb-4">
{ticket.description}
</p>
<div class="flex justify-between items-center text-xs text-gray-500">
<div class="flex items-center space-x-4">
<span>#{ticket.id.substring(0, 8)}</span>
{#if ticket.category_name}
<span class="bg-gray-100 text-gray-600 px-2 py-1 rounded">
{ticket.category_name}
</span>
{/if}
{#if ticket.assigned_to_name}
<span class="flex items-center space-x-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span>{ticket.assigned_to_name}</span>
</span>
{/if}
</div>
<div class="flex items-center space-x-3">
{#if ticket.due_date}
<span class="flex items-center space-x-1 {new Date(ticket.due_date) < new Date() ? 'text-red-600' : ''}">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Vence {formatRelativeTime(ticket.due_date)}</span>
</span>
{/if}
<span title={formatDate(ticket.updated_at)}>
Actualizado {formatRelativeTime(ticket.updated_at)}
</span>
</div>
</div>
</div>
</div>
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,117 @@
<script lang="ts">
export let type: 'success' | 'error' | 'warning' | 'info' = 'info';
export let message: string;
export let duration: number = 5000;
export let dismissible: boolean = true;
let visible = true;
let timeoutId: NodeJS.Timeout;
// Auto-dismiss after duration
if (duration > 0) {
timeoutId = setTimeout(() => {
visible = false;
}, duration);
}
function dismiss() {
if (timeoutId) clearTimeout(timeoutId);
visible = false;
}
// Cleanup timeout on destroy
import { onDestroy } from 'svelte';
onDestroy(() => {
if (timeoutId) clearTimeout(timeoutId);
});
// Style mapping
const typeStyles = {
success: {
container: 'bg-success-50 border-success-200 text-success-800',
icon: 'text-success-400'
},
error: {
container: 'bg-error-50 border-error-200 text-error-800',
icon: 'text-error-400'
},
warning: {
container: 'bg-warning-50 border-warning-200 text-warning-800',
icon: 'text-warning-400'
},
info: {
container: 'bg-blue-50 border-blue-200 text-blue-800',
icon: 'text-blue-400'
}
};
// Icon mapping
const typeIcons = {
success: 'M5 13l4 4L19 7',
error: 'M6 18L18 6M6 6l12 12',
warning: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z',
info: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
};
</script>
{#if visible}
<div class="fixed top-4 right-4 max-w-sm w-full z-50 animate-slide-up">
<div class="rounded-lg border p-4 shadow-lg {typeStyles[type].container}">
<div class="flex items-start">
<div class="flex-shrink-0">
<svg
class="h-5 w-5 {typeStyles[type].icon}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={typeIcons[type]}
/>
</svg>
</div>
<div class="ml-3 flex-1">
<p class="text-sm font-medium">
{message}
</p>
</div>
{#if dismissible}
<div class="ml-4 flex-shrink-0">
<button
type="button"
class="inline-flex rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 {typeStyles[type].icon} hover:opacity-75"
on:click={dismiss}
>
<span class="sr-only">Cerrar</span>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/if}
</div>
</div>
</div>
{/if}
<style>
@keyframes slide-up {
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
}
</style>

View File

@@ -0,0 +1,80 @@
import { writable } from 'svelte/store';
import { auth } from './auth.js';
import { get } from 'svelte/store';
import type { Writable } from 'svelte/store';
// Types
export interface Category {
id: string;
name: string;
description: string;
is_active: boolean;
tenant_id: string;
created_at: string;
}
export interface AppState {
categories: Category[];
isLoading: boolean;
error: string | null;
}
// Initial state
const initialState: AppState = {
categories: [],
isLoading: false,
error: null
};
// API helper function
async function apiCall(endpoint: string, options: RequestInit = {}) {
const authState = get(auth);
const response = await fetch(`/api/v1${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authState.token}`,
...options.headers
}
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Request failed');
}
return response.json();
}
// Create app store
function createAppStore() {
const { subscribe, set, update }: Writable<AppState> = writable(initialState);
return {
subscribe,
// Load categories
loadCategories: async () => {
update(state => ({ ...state, isLoading: true, error: null }));
try {
const categories = await apiCall('/categories/');
update(state => ({ ...state, categories, isLoading: false }));
} catch (error) {
update(state => ({
...state,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to load categories'
}));
}
},
// Clear error
clearError: () => {
update(state => ({ ...state, error: null }));
}
};
}
export const app = createAppStore();

View File

@@ -0,0 +1,139 @@
import { writable } from 'svelte/store';
import type { Writable } from 'svelte/store';
// Types
export interface User {
id: string;
email: string;
first_name: string;
last_name: string;
tenant_id: string;
role: 'CLIENT_ADMIN' | 'CLIENT_USER';
is_active: boolean;
is_two_factor_enabled: boolean;
created_at: string;
}
export interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
}
export interface LoginRequest {
email: string;
password: string;
tenant_slug: string;
totp_code?: string;
}
export interface LoginResponse {
access_token: string;
token_type: string;
expires_in: number;
user: User;
}
// Initial state
const initialState: AuthState = {
user: null,
token: null,
isAuthenticated: false,
isLoading: false
};
// Create auth store
function createAuthStore() {
const { subscribe, set, update }: Writable<AuthState> = writable(initialState);
return {
subscribe,
// Initialize auth from localStorage
init: () => {
if (typeof window !== 'undefined') {
const token = localStorage.getItem('auth_token');
const user = localStorage.getItem('auth_user');
if (token && user) {
try {
const parsedUser = JSON.parse(user);
set({
user: parsedUser,
token,
isAuthenticated: true,
isLoading: false
});
} catch (error) {
console.error('Error parsing stored auth data:', error);
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user');
}
}
}
},
// Login
login: async (credentials: LoginRequest): Promise<void> => {
update(state => ({ ...state, isLoading: true }));
try {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(credentials)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Login failed');
}
const data: LoginResponse = await response.json();
// Store auth data
if (typeof window !== 'undefined') {
localStorage.setItem('auth_token', data.access_token);
localStorage.setItem('auth_user', JSON.stringify(data.user));
}
set({
user: data.user,
token: data.access_token,
isAuthenticated: true,
isLoading: false
});
} catch (error) {
update(state => ({ ...state, isLoading: false }));
throw error;
}
},
// Logout
logout: () => {
if (typeof window !== 'undefined') {
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user');
}
set(initialState);
},
// Update user data
updateUser: (user: User) => {
update(state => ({ ...state, user }));
if (typeof window !== 'undefined') {
localStorage.setItem('auth_user', JSON.stringify(user));
}
},
// Set loading state
setLoading: (isLoading: boolean) => {
update(state => ({ ...state, isLoading }));
}
};
}
export const auth = createAuthStore();

View File

@@ -0,0 +1,272 @@
import { writable } from 'svelte/store';
import { auth } from './auth.js';
import { get } from 'svelte/store';
import type { Writable } from 'svelte/store';
// Types
export interface Ticket {
id: string;
title: string;
description: string;
status: 'NEW' | 'IN_PROGRESS' | 'WAITING_FOR_CLIENT' | 'RESOLVED' | 'CLOSED' | 'REOPENED';
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
category_id: string;
category_name?: string;
client_id: string;
assigned_to_id: string | null;
assigned_to_name?: string;
created_at: string;
updated_at: string;
due_date: string | null;
resolution: string | null;
}
export interface TicketComment {
id: string;
ticket_id: string;
user_id: string;
user_name: string;
user_role: string;
content: string;
is_internal: boolean;
created_at: string;
}
export interface TicketAttachment {
id: string;
ticket_id: string;
filename: string;
original_filename: string;
mime_type: string;
size_bytes: number;
uploaded_by_id: string;
uploaded_by_name: string;
uploaded_at: string;
}
export interface CreateTicketRequest {
title: string;
description: string;
category_id: string;
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
}
export interface TicketsState {
tickets: Ticket[];
currentTicket: Ticket | null;
comments: TicketComment[];
attachments: TicketAttachment[];
isLoading: boolean;
error: string | null;
}
// Initial state
const initialState: TicketsState = {
tickets: [],
currentTicket: null,
comments: [],
attachments: [],
isLoading: false,
error: null
};
// API helper function
async function apiCall(endpoint: string, options: RequestInit = {}) {
const authState = get(auth);
const response = await fetch(`/api/v1${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authState.token}`,
...options.headers
}
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Request failed');
}
return response.json();
}
// Create tickets store
function createTicketsStore() {
const { subscribe, set, update }: Writable<TicketsState> = writable(initialState);
return {
subscribe,
// Load user's tickets
loadTickets: async () => {
update(state => ({ ...state, isLoading: true, error: null }));
try {
const tickets = await apiCall('/tickets/');
update(state => ({ ...state, tickets, isLoading: false }));
} catch (error) {
update(state => ({
...state,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to load tickets'
}));
}
},
// Load specific ticket with details
loadTicket: async (ticketId: string) => {
update(state => ({ ...state, isLoading: true, error: null }));
try {
const [ticket, comments, attachments] = await Promise.all([
apiCall(`/tickets/${ticketId}`),
apiCall(`/tickets/${ticketId}/comments`),
apiCall(`/tickets/${ticketId}/attachments`)
]);
update(state => ({
...state,
currentTicket: ticket,
comments,
attachments,
isLoading: false
}));
} catch (error) {
update(state => ({
...state,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to load ticket'
}));
}
},
// Create new ticket
createTicket: async (ticket: CreateTicketRequest) => {
update(state => ({ ...state, isLoading: true, error: null }));
try {
const newTicket = await apiCall('/tickets/', {
method: 'POST',
body: JSON.stringify(ticket)
});
update(state => ({
...state,
tickets: [newTicket, ...state.tickets],
isLoading: false
}));
return newTicket;
} catch (error) {
update(state => ({
...state,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to create ticket'
}));
throw error;
}
},
// Add comment to ticket
addComment: async (ticketId: string, content: string) => {
try {
const comment = await apiCall(`/tickets/${ticketId}/comments`, {
method: 'POST',
body: JSON.stringify({ content })
});
update(state => ({
...state,
comments: [...state.comments, comment]
}));
return comment;
} catch (error) {
update(state => ({
...state,
error: error instanceof Error ? error.message : 'Failed to add comment'
}));
throw error;
}
},
// Upload attachment
uploadAttachment: async (ticketId: string, file: File) => {
try {
const formData = new FormData();
formData.append('file', file);
const authState = get(auth);
const response = await fetch(`/api/v1/tickets/${ticketId}/attachments`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${authState.token}`
},
body: formData
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Upload failed');
}
const attachment = await response.json();
update(state => ({
...state,
attachments: [...state.attachments, attachment]
}));
return attachment;
} catch (error) {
update(state => ({
...state,
error: error instanceof Error ? error.message : 'Failed to upload attachment'
}));
throw error;
}
},
// Close ticket (client can close their own tickets)
closeTicket: async (ticketId: string, resolution?: string) => {
try {
const updatedTicket = await apiCall(`/tickets/${ticketId}/close`, {
method: 'PATCH',
body: JSON.stringify({ resolution })
});
update(state => ({
...state,
currentTicket: state.currentTicket?.id === ticketId ? updatedTicket : state.currentTicket,
tickets: state.tickets.map(t => t.id === ticketId ? updatedTicket : t)
}));
return updatedTicket;
} catch (error) {
update(state => ({
...state,
error: error instanceof Error ? error.message : 'Failed to close ticket'
}));
throw error;
}
},
// Clear error
clearError: () => {
update(state => ({ ...state, error: null }));
},
// Clear current ticket
clearCurrentTicket: () => {
update(state => ({
...state,
currentTicket: null,
comments: [],
attachments: []
}));
}
};
}
export const tickets = createTicketsStore();

View File

@@ -0,0 +1,75 @@
// Toast notification store
import { writable } from 'svelte/store';
import type { Writable } from 'svelte/store';
export interface ToastMessage {
id: string;
type: 'success' | 'error' | 'warning' | 'info';
message: string;
duration?: number;
}
interface ToastState {
toasts: ToastMessage[];
}
const initialState: ToastState = {
toasts: []
};
function createToastStore() {
const { subscribe, update }: Writable<ToastState> = writable(initialState);
return {
subscribe,
show: (type: ToastMessage['type'], message: string, duration = 5000) => {
const id = Math.random().toString(36).substring(2, 9);
const toast: ToastMessage = { id, type, message, duration };
update(state => ({
toasts: [...state.toasts, toast]
}));
// Auto-remove after duration
if (duration > 0) {
setTimeout(() => {
update(state => ({
toasts: state.toasts.filter(t => t.id !== id)
}));
}, duration);
}
return id;
},
dismiss: (id: string) => {
update(state => ({
toasts: state.toasts.filter(t => t.id !== id)
}));
},
clear: () => {
update(() => initialState);
},
// Convenience methods
success: (message: string, duration?: number) => {
return createToastStore().show('success', message, duration);
},
error: (message: string, duration?: number) => {
return createToastStore().show('error', message, duration);
},
warning: (message: string, duration?: number) => {
return createToastStore().show('warning', message, duration);
},
info: (message: string, duration?: number) => {
return createToastStore().show('info', message, duration);
}
};
}
export const toast = createToastStore();

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import Header from '$lib/components/Header.svelte';
import { toast } from '$lib/stores/toast.js';
import Toast from '$lib/components/Toast.svelte';
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js';
import { page } from '$app/stores';
import '../app.css';
onMount(() => {
auth.init();
});
$: showHeader = !$page.url.pathname.startsWith('/login') && !$page.url.pathname.startsWith('/register');
</script>
<div class="min-h-screen bg-gray-50 font-sans">
{#if showHeader}
<Header />
{/if}
<main class="flex-1">
<slot />
</main>
<!-- Toast notifications -->
{#each $toast.toasts as toastMessage (toastMessage.id)}
<Toast
type={toastMessage.type}
message={toastMessage.message}
duration={toastMessage.duration}
on:dismiss={() => toast.dismiss(toastMessage.id)}
/>
{/each}
</div>

View File

@@ -0,0 +1,178 @@
<script lang="ts">
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js';
import { tickets } from '$lib/stores/tickets.js';
import { goto } from '$app/navigation';
import Icon from '$lib/components/Icon.svelte';
onMount(() => {
// Redirect if not authenticated
if (!$auth.isAuthenticated) {
goto('/login');
return;
}
// Load user's tickets
tickets.loadTickets();
});
</script>
<svelte:head>
<title>ServiceManager - Mesa de Ayuda</title>
</svelte:head>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Welcome Section -->
<div class="bg-gradient-to-r from-primary-500 to-primary-600 rounded-lg p-8 text-white mb-8">
<div class="max-w-3xl">
<h1 class="text-3xl font-bold mb-2">
Bienvenido, {$auth.user?.first_name} {$auth.user?.last_name}
</h1>
<p class="text-primary-100 text-lg">
Gestiona tus tickets de soporte de manera eficiente. Crea nuevos tickets,
da seguimiento a los existentes y mantente actualizado con el estado de tus solicitudes.
</p>
</div>
</div>
<!-- Quick Actions -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<a
href="/tickets/new"
class="card hover:shadow-lg transition-shadow group cursor-pointer"
>
<div class="card-content text-center">
<div class="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center mx-auto mb-4 group-hover:bg-primary-200 transition-colors">
<Icon name="plus" size="w-6 h-6" className="text-primary-600" />
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Crear Ticket</h3>
<p class="text-gray-600 text-sm">
Reporta un problema o solicita soporte técnico
</p>
</div>
</a>
<a
href="/tickets"
class="card hover:shadow-lg transition-shadow group cursor-pointer"
>
<div class="card-content text-center">
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mx-auto mb-4 group-hover:bg-blue-200 transition-colors">
<Icon name="ticket" size="w-6 h-6" className="text-blue-600" />
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Mis Tickets</h3>
<p class="text-gray-600 text-sm">
Consulta el estado de todos tus tickets
</p>
</div>
</a>
<a
href="/profile"
class="card hover:shadow-lg transition-shadow group cursor-pointer"
>
<div class="card-content text-center">
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mx-auto mb-4 group-hover:bg-green-200 transition-colors">
<Icon name="user" size="w-6 h-6" className="text-green-600" />
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Mi Perfil</h3>
<p class="text-gray-600 text-sm">
Actualiza tu información personal
</p>
</div>
</a>
</div>
<!-- Recent Tickets -->
<div class="card">
<div class="card-header">
<h2 class="text-xl font-semibold text-gray-900">Tickets Recientes</h2>
<p class="text-gray-600 mt-1">Últimos tickets que has creado o actualizado</p>
</div>
<div class="card-content">
{#if $tickets.isLoading}
<div class="text-center py-8">
<div class="spinner w-8 h-8 mx-auto mb-4"></div>
<p class="text-gray-600">Cargando tickets...</p>
</div>
{:else if $tickets.error}
<div class="text-center py-8">
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center mx-auto mb-4">
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p class="text-gray-600 mb-4">Error al cargar los tickets</p>
<button
on:click={() => tickets.loadTickets()}
class="btn-primary px-4 py-2"
>
Reintentar
</button>
</div>
{:else if $tickets.tickets.length === 0}
<div class="text-center py-8">
<div class="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center mx-auto mb-4">
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
<p class="text-gray-600 mb-4">No tienes tickets creados</p>
<a href="/tickets/new" class="btn-primary px-4 py-2">
Crear tu primer ticket
</a>
</div>
{:else}
<div class="space-y-4">
{#each $tickets.tickets.slice(0, 5) as ticket (ticket.id)}
<div class="border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors">
<div class="flex justify-between items-start">
<div class="flex-1">
<h3 class="font-medium text-gray-900 mb-1">
<a href="/tickets/{ticket.id}" class="hover:text-primary-600">
{ticket.title}
</a>
</h3>
<p class="text-gray-600 text-sm line-clamp-2 mb-2">
{ticket.description}
</p>
<div class="flex items-center space-x-4 text-xs text-gray-500">
<span>#{ticket.id.substring(0, 8)}</span>
<span>{new Date(ticket.created_at).toLocaleDateString('es-ES')}</span>
</div>
</div>
<div class="ml-4">
<span class="badge-{ticket.status.toLowerCase().replace('_', '-')}">
{ticket.status === 'NEW' ? 'Nuevo' :
ticket.status === 'IN_PROGRESS' ? 'En Progreso' :
ticket.status === 'WAITING_FOR_CLIENT' ? 'Esperando Cliente' :
ticket.status === 'RESOLVED' ? 'Resuelto' :
ticket.status === 'CLOSED' ? 'Cerrado' : 'Reabierto'}
</span>
</div>
</div>
</div>
{/each}
{#if $tickets.tickets.length > 5}
<div class="text-center pt-4 border-t border-gray-200">
<a href="/tickets" class="btn-secondary px-4 py-2">
Ver todos los tickets ({$tickets.tickets.length})
</a>
</div>
{/if}
</div>
{/if}
</div>
</div>
</div>
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,244 @@
<script lang="ts">
import { auth } from '$lib/stores/auth.js';
import { toast } from '$lib/stores/toast.js';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import Icon from '$lib/components/Icon.svelte';
let email = '';
let password = '';
let totpCode = '';
let isLoading = false;
let showTwoFactor = false;
let errorMessage = '';
let showPassword = false;
onMount(() => {
// Redirect if already authenticated
if ($auth.isAuthenticated) {
goto('/');
}
});
async function handleLogin() {
if (!email || !password) {
errorMessage = 'Por favor completa todos los campos';
return;
}
isLoading = true;
errorMessage = '';
try {
await auth.login({
email,
password,
tenant_slug: 'aduanasoft', // Default tenant for now
totp_code: totpCode || undefined
});
toast.success('¡Bienvenido! Has iniciado sesión correctamente');
goto('/');
} catch (error: any) {
console.error('Login error:', error);
// Check if 2FA is required
if (error.message.includes('two-factor') || error.message.includes('2FA')) {
showTwoFactor = true;
errorMessage = 'Introduce el código de tu aplicación de autenticación';
} else {
errorMessage = error.message || 'Error al iniciar sesión';
toast.error(errorMessage);
}
} finally {
isLoading = false;
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter') {
handleLogin();
}
}
</script>
<div class="min-h-screen flex font-sans bg-white overflow-hidden">
<!-- Left Side: Hero Image & Overlay (55% width) -->
<div class="hidden lg:flex w-[55%] relative bg-gray-900">
<!-- Background Image -->
<div
class="absolute inset-0 bg-cover bg-center z-0"
style="background-image: url('/images/SOPORTE.webp'); opacity: 1;"
></div>
<!-- Gradient Overlay -->
<div class="absolute inset-0 bg-gradient-to-br from-[#1e3a8a]/75 to-[#172554]/75 z-10"></div>
<div class="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent z-10"></div>
<!-- Content -->
<div class="relative z-20 w-full h-full flex flex-col justify-between p-16 text-white">
<!-- Top Logo (Left) -->
<div class="flex flex-col">
<img src="/images/Logo%20AS%20blanco(1).png" alt="AduanaSoft" class="h-32 w-auto object-contain self-start drop-shadow-lg" />
</div>
<!-- Main Hero Text -->
<div class="space-y-4 mb-12">
<h2 class="text-5xl font-extrabold tracking-tight drop-shadow-xl leading-tight">
Control Total <br/>
de Servicios de TI
</h2>
<p class="text-lg text-blue-100/90 font-light max-w-lg leading-relaxed drop-shadow-md">
Portal de atención a clientes. Genere tickets de soporte técnico para nuestros sistemas y reciba asistencia especializada para garantizar la continuidad de su operación.
</p>
</div>
<!-- Bottom Footer -->
<div class="text-xs font-bold tracking-[0.2em] text-blue-200/60 uppercase">
ServiceManager Enterprise Platform
</div>
</div>
</div>
<!-- Right Side: Login Form (45% width) -->
<div class="w-full lg:w-[45%] flex flex-col justify-center items-center p-8 lg:p-16 bg-white relative">
<div class="w-full max-w-md space-y-8">
<!-- Logo & Header -->
<div class="text-center space-y-2">
<h2 class="text-3xl font-bold text-gray-900">Bienvenido</h2>
<p class="text-gray-500 text-sm">Ingrese a su cuenta corporativa</p>
</div>
<!-- Form -->
<form on:submit|preventDefault={handleLogin} class="space-y-6 mt-8">
{#if errorMessage}
<div class="p-3 rounded-md bg-red-50 border border-red-100 flex items-center gap-3 animate-fade-in text-sm text-red-600">
<Icon name="alert-circle" class="w-4 h-4 flex-shrink-0" />
{errorMessage}
</div>
{/if}
{#if !showTwoFactor}
<div class="space-y-5">
<!-- Email Input -->
<div class="space-y-1.5">
<label for="email" class="block text-sm font-semibold text-gray-700">Correo Electrónico</label>
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon name="mail" class="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors" />
</div>
<input
id="email"
type="email"
bind:value={email}
on:keydown={handleKeyDown}
class="block w-full pl-10 pr-3 py-3 bg-[#fff9c4]/0 hover:bg-gray-50 focus:bg-white border text-gray-900 border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent transition-all duration-200 sm:text-sm"
placeholder="admin@aduanasoft.com"
required
disabled={isLoading}
/>
</div>
</div>
<!-- Password Input -->
<div class="space-y-1.5">
<label for="password" class="block text-sm font-semibold text-gray-700">Contraseña</label>
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon name="lock" class="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors" />
</div>
{#if showPassword}
<input
id="password"
type="text"
bind:value={password}
on:keydown={handleKeyDown}
class="block w-full pl-10 pr-10 py-3 bg-[#fff9c4]/0 hover:bg-gray-50 focus:bg-white border text-gray-900 border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent transition-all duration-200 sm:text-sm"
placeholder="••••••••"
required
disabled={isLoading}
/>
{:else}
<input
id="password"
type="password"
bind:value={password}
on:keydown={handleKeyDown}
class="block w-full pl-10 pr-10 py-3 bg-[#fff9c4]/0 hover:bg-gray-50 focus:bg-white border text-gray-900 border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent transition-all duration-200 sm:text-sm"
placeholder="••••••••"
required
disabled={isLoading}
/>
{/if}
<button
type="button"
class="absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer text-gray-400 hover:text-gray-600 focus:outline-none"
on:click={() => showPassword = !showPassword}
>
<Icon name={showPassword ? 'eye-off' : 'eye'} class="w-5 h-5" />
</button>
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center">
<input id="remember-me" name="remember-me" type="checkbox" class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded cursor-pointer">
<label for="remember-me" class="ml-2 block text-sm text-gray-500 cursor-pointer select-none">Recordar en este equipo</label>
</div>
<a href="/forgot-password" class="text-sm font-medium text-blue-600 hover:text-blue-500">
Olvide mi clave
</a>
</div>
</div>
{:else}
<!-- 2FA Input -->
<div class="space-y-4 animate-slide-up">
<label for="code" class="block text-sm font-medium text-gray-700 text-center">Código de Verificación (2FA)</label>
<p class="text-xs text-center text-gray-500 mb-4">Ingrese el código de 6 dígitos</p>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon name="shield-check" class="w-5 h-5 text-blue-500" />
</div>
<input
id="code"
type="text"
bind:value={totpCode}
on:keydown={handleKeyDown}
class="block w-full pl-10 py-3 text-center tracking-[0.5em] font-mono text-lg border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent"
placeholder="000000"
maxlength="6"
required
disabled={isLoading}
autofocus
/>
</div>
</div>
{/if}
<div class="pt-2">
<button
type="submit"
class="w-full flex justify-center py-3.5 px-4 border border-transparent rounded-lg shadow-sm text-sm font-bold text-white bg-blue-700 hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
disabled={isLoading}
>
{#if isLoading}
<Icon name="loader-2" class="w-5 h-5 animate-spin mr-2" />
Procesando...
{:else}
{showTwoFactor ? 'Verificar Acceso' : 'Acceder al Portal'}
{/if}
</button>
</div>
<div class="mt-8 text-center text-xs text-gray-400">
© 2026 Aduanasoft. Acceso exclusivo autorizado.
</div>
</form>
</div>
</div>
</div>

View File

@@ -0,0 +1,392 @@
<script lang="ts">
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js';
import { toast } from '$lib/stores/toast.js';
import { goto } from '$app/navigation';
let currentPassword = '';
let newPassword = '';
let confirmPassword = '';
let firstName = '';
let lastName = '';
let isUpdatingProfile = false;
let isChangingPassword = false;
let profileErrors: Record<string, string> = {};
let passwordErrors: Record<string, string> = {};
onMount(() => {
// Redirect if not authenticated
if (!$auth.isAuthenticated) {
goto('/login');
return;
}
// Initialize form with user data
if ($auth.user) {
firstName = $auth.user.first_name;
lastName = $auth.user.last_name;
}
});
function validateProfileForm() {
profileErrors = {};
if (!firstName.trim()) {
profileErrors.firstName = 'El nombre es requerido';
}
if (!lastName.trim()) {
profileErrors.lastName = 'El apellido es requerido';
}
return Object.keys(profileErrors).length === 0;
}
function validatePasswordForm() {
passwordErrors = {};
if (!currentPassword) {
passwordErrors.currentPassword = 'La contraseña actual es requerida';
}
if (!newPassword) {
passwordErrors.newPassword = 'La nueva contraseña es requerida';
} else if (newPassword.length < 8) {
passwordErrors.newPassword = 'La contraseña debe tener al menos 8 caracteres';
}
if (!confirmPassword) {
passwordErrors.confirmPassword = 'Confirma la nueva contraseña';
} else if (newPassword !== confirmPassword) {
passwordErrors.confirmPassword = 'Las contraseñas no coinciden';
}
return Object.keys(passwordErrors).length === 0;
}
async function handleProfileUpdate() {
if (!validateProfileForm()) return;
isUpdatingProfile = true;
try {
const response = await fetch('/api/v1/auth/profile', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${$auth.token}`
},
body: JSON.stringify({
first_name: firstName.trim(),
last_name: lastName.trim()
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Error al actualizar perfil');
}
const updatedUser = await response.json();
auth.updateUser(updatedUser);
toast.success('Perfil actualizado exitosamente');
} catch (error: any) {
toast.error(error.message || 'Error al actualizar perfil');
} finally {
isUpdatingProfile = false;
}
}
async function handlePasswordChange() {
if (!validatePasswordForm()) return;
isChangingPassword = true;
try {
const response = await fetch('/api/v1/auth/change-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${$auth.token}`
},
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Error al cambiar contraseña');
}
// Clear form
currentPassword = '';
newPassword = '';
confirmPassword = '';
toast.success('Contraseña cambiada exitosamente');
} catch (error: any) {
toast.error(error.message || 'Error al cambiar contraseña');
} finally {
isChangingPassword = false;
}
}
</script>
<svelte:head>
<title>Mi Perfil - ServiceManager</title>
</svelte:head>
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Mi Perfil</h1>
<p class="text-gray-600 mt-2">
Gestiona tu información personal y configuración de seguridad
</p>
</div>
<div class="space-y-8">
<!-- Profile Information -->
<div class="card">
<div class="card-header">
<h2 class="text-xl font-semibold text-gray-900">Información Personal</h2>
<p class="text-gray-600 mt-1">Actualiza tu información básica</p>
</div>
<div class="card-content">
<form on:submit|preventDefault={handleProfileUpdate} class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="first-name" class="form-label">
Nombre <span class="text-red-500">*</span>
</label>
<input
id="first-name"
type="text"
class="form-input {profileErrors.firstName ? 'border-red-300' : ''}"
bind:value={firstName}
disabled={isUpdatingProfile}
/>
{#if profileErrors.firstName}
<p class="form-error">{profileErrors.firstName}</p>
{/if}
</div>
<div>
<label for="last-name" class="form-label">
Apellido <span class="text-red-500">*</span>
</label>
<input
id="last-name"
type="text"
class="form-input {profileErrors.lastName ? 'border-red-300' : ''}"
bind:value={lastName}
disabled={isUpdatingProfile}
/>
{#if profileErrors.lastName}
<p class="form-error">{profileErrors.lastName}</p>
{/if}
</div>
</div>
<div>
<label for="email" class="form-label">Correo Electrónico</label>
<input
id="email"
type="email"
class="form-input bg-gray-50"
value={$auth.user?.email || ''}
disabled
/>
<p class="text-xs text-gray-500 mt-1">
El correo electrónico no se puede cambiar. Contacta con soporte si necesitas actualizarlo.
</p>
</div>
<div class="flex justify-end">
<button
type="submit"
class="btn-primary px-6 py-2"
disabled={isUpdatingProfile}
>
{#if isUpdatingProfile}
<div class="flex items-center space-x-2">
<div class="spinner w-4 h-4"></div>
<span>Guardando...</span>
</div>
{:else}
Guardar Cambios
{/if}
</button>
</div>
</form>
</div>
</div>
<!-- Account Security -->
<div class="card">
<div class="card-header">
<h2 class="text-xl font-semibold text-gray-900">Seguridad de la Cuenta</h2>
<p class="text-gray-600 mt-1">Gestiona tu contraseña y configuración de seguridad</p>
</div>
<div class="card-content space-y-6">
<!-- Two-Factor Authentication Status -->
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<h3 class="font-medium text-gray-900">Autenticación de dos factores (2FA)</h3>
<p class="text-sm text-gray-600">
{$auth.user?.is_two_factor_enabled
? 'La autenticación de dos factores está habilitada'
: 'Mejora la seguridad habilitando 2FA'}
</p>
</div>
<div>
{#if $auth.user?.is_two_factor_enabled}
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Habilitado
</span>
{:else}
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
Deshabilitado
</span>
{/if}
</div>
</div>
<!-- Change Password Form -->
<form on:submit|preventDefault={handlePasswordChange} class="space-y-6">
<h3 class="text-lg font-medium text-gray-900">Cambiar Contraseña</h3>
<div>
<label for="current-password" class="form-label">
Contraseña Actual <span class="text-red-500">*</span>
</label>
<input
id="current-password"
type="password"
class="form-input {passwordErrors.currentPassword ? 'border-red-300' : ''}"
bind:value={currentPassword}
disabled={isChangingPassword}
/>
{#if passwordErrors.currentPassword}
<p class="form-error">{passwordErrors.currentPassword}</p>
{/if}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="new-password" class="form-label">
Nueva Contraseña <span class="text-red-500">*</span>
</label>
<input
id="new-password"
type="password"
class="form-input {passwordErrors.newPassword ? 'border-red-300' : ''}"
bind:value={newPassword}
disabled={isChangingPassword}
/>
{#if passwordErrors.newPassword}
<p class="form-error">{passwordErrors.newPassword}</p>
{/if}
<p class="text-xs text-gray-500 mt-1">
Mínimo 8 caracteres
</p>
</div>
<div>
<label for="confirm-password" class="form-label">
Confirmar Nueva Contraseña <span class="text-red-500">*</span>
</label>
<input
id="confirm-password"
type="password"
class="form-input {passwordErrors.confirmPassword ? 'border-red-300' : ''}"
bind:value={confirmPassword}
disabled={isChangingPassword}
/>
{#if passwordErrors.confirmPassword}
<p class="form-error">{passwordErrors.confirmPassword}</p>
{/if}
</div>
</div>
<div class="flex justify-end">
<button
type="submit"
class="btn-primary px-6 py-2"
disabled={isChangingPassword}
>
{#if isChangingPassword}
<div class="flex items-center space-x-2">
<div class="spinner w-4 h-4"></div>
<span>Cambiando...</span>
</div>
{:else}
Cambiar Contraseña
{/if}
</button>
</div>
</form>
</div>
</div>
<!-- Account Information -->
<div class="card">
<div class="card-header">
<h2 class="text-xl font-semibold text-gray-900">Información de la Cuenta</h2>
<p class="text-gray-600 mt-1">Detalles sobre tu cuenta y organización</p>
</div>
<div class="card-content">
<dl class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<dt class="text-sm font-medium text-gray-500">ID de Usuario</dt>
<dd class="text-sm text-gray-900 font-mono mt-1">#{$auth.user?.id.substring(0, 8)}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Rol</dt>
<dd class="text-sm text-gray-900 mt-1">
{$auth.user?.role === 'CLIENT_ADMIN' ? 'Administrador de Cliente' : 'Usuario de Cliente'}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Estado de la Cuenta</dt>
<dd class="text-sm mt-1">
{#if $auth.user?.is_active}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Activa
</span>
{:else}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
Inactiva
</span>
{/if}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Miembro desde</dt>
<dd class="text-sm text-gray-900 mt-1">
{$auth.user?.created_at ? new Date($auth.user.created_at).toLocaleDateString('es-ES', {
day: '2-digit',
month: 'long',
year: 'numeric'
}) : 'N/A'}
</dd>
</div>
</dl>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,270 @@
<script lang="ts">
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js';
import { tickets } from '$lib/stores/tickets.js';
import { goto } from '$app/navigation';
import TicketCard from '$lib/components/TicketCard.svelte';
let searchQuery = '';
let statusFilter = '';
let priorityFilter = '';
let filteredTickets: any[] = [];
onMount(() => {
// Redirect if not authenticated
if (!$auth.isAuthenticated) {
goto('/login');
return;
}
// Load tickets
tickets.loadTickets();
});
// Filter tickets based on search and filters
$: {
filteredTickets = $tickets.tickets.filter(ticket => {
const matchesSearch = !searchQuery ||
ticket.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
ticket.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
ticket.id.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus = !statusFilter || ticket.status === statusFilter;
const matchesPriority = !priorityFilter || ticket.priority === priorityFilter;
return matchesSearch && matchesStatus && matchesPriority;
});
}
// Get status counts
$: statusCounts = $tickets.tickets.reduce((acc, ticket) => {
acc[ticket.status] = (acc[ticket.status] || 0) + 1;
return acc;
}, {} as Record<string, number>);
function clearFilters() {
searchQuery = '';
statusFilter = '';
priorityFilter = '';
}
</script>
<svelte:head>
<title>Mis Tickets - ServiceManager</title>
</svelte:head>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="flex justify-between items-center mb-8">
<div>
<h1 class="text-3xl font-bold text-gray-900">Mis Tickets</h1>
<p class="text-gray-600 mt-2">
Gestiona y da seguimiento a todos tus tickets de soporte
</p>
</div>
<a
href="/tickets/new"
class="btn-primary px-4 py-2 inline-flex items-center space-x-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<span>Crear Ticket</span>
</a>
</div>
<!-- Stats -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div class="card">
<div class="card-content">
<div class="flex items-center">
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-500">Total</p>
<p class="text-2xl font-semibold text-gray-900">{$tickets.tickets.length}</p>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-content">
<div class="flex items-center">
<div class="w-8 h-8 bg-yellow-100 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-500">En Progreso</p>
<p class="text-2xl font-semibold text-gray-900">
{statusCounts['IN_PROGRESS'] || 0}
</p>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-content">
<div class="flex items-center">
<div class="w-8 h-8 bg-orange-100 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-500">Esperando</p>
<p class="text-2xl font-semibold text-gray-900">
{statusCounts['WAITING_FOR_CLIENT'] || 0}
</p>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-content">
<div class="flex items-center">
<div class="w-8 h-8 bg-green-100 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-500">Resueltos</p>
<p class="text-2xl font-semibold text-gray-900">
{(statusCounts['RESOLVED'] || 0) + (statusCounts['CLOSED'] || 0)}
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="card mb-8">
<div class="card-content">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label for="search" class="form-label">Buscar</label>
<input
id="search"
type="text"
class="form-input"
placeholder="Buscar por título, descripción o ID..."
bind:value={searchQuery}
/>
</div>
<div>
<label for="status-filter" class="form-label">Estado</label>
<select
id="status-filter"
class="form-input"
bind:value={statusFilter}
>
<option value="">Todos los estados</option>
<option value="NEW">Nuevo</option>
<option value="IN_PROGRESS">En Progreso</option>
<option value="WAITING_FOR_CLIENT">Esperando Cliente</option>
<option value="RESOLVED">Resuelto</option>
<option value="CLOSED">Cerrado</option>
<option value="REOPENED">Reabierto</option>
</select>
</div>
<div>
<label for="priority-filter" class="form-label">Prioridad</label>
<select
id="priority-filter"
class="form-input"
bind:value={priorityFilter}
>
<option value="">Todas las prioridades</option>
<option value="LOW">Baja</option>
<option value="MEDIUM">Media</option>
<option value="HIGH">Alta</option>
<option value="URGENT">Urgente</option>
</select>
</div>
<div class="flex items-end">
<button
on:click={clearFilters}
class="btn-secondary px-4 py-2 w-full"
>
Limpiar Filtros
</button>
</div>
</div>
</div>
</div>
<!-- Tickets List -->
<div class="space-y-6">
{#if $tickets.isLoading}
<div class="text-center py-12">
<div class="spinner w-8 h-8 mx-auto mb-4"></div>
<p class="text-gray-600">Cargando tickets...</p>
</div>
{:else if $tickets.error}
<div class="text-center py-12">
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center mx-auto mb-4">
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Error al cargar tickets</h3>
<p class="text-gray-600 mb-4">{$tickets.error}</p>
<button
on:click={() => tickets.loadTickets()}
class="btn-primary px-4 py-2"
>
Reintentar
</button>
</div>
{:else if filteredTickets.length === 0}
<div class="text-center py-12">
<div class="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center mx-auto mb-4">
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">
{$tickets.tickets.length === 0 ? 'No tienes tickets' : 'No se encontraron tickets'}
</h3>
<p class="text-gray-600 mb-4">
{$tickets.tickets.length === 0
? 'Crea tu primer ticket para comenzar'
: 'Intenta ajustar los filtros de búsqueda'}
</p>
{#if $tickets.tickets.length === 0}
<a href="/tickets/new" class="btn-primary px-4 py-2">
Crear Ticket
</a>
{:else}
<button on:click={clearFilters} class="btn-secondary px-4 py-2">
Limpiar Filtros
</button>
{/if}
</div>
{:else}
<div class="space-y-4">
{#each filteredTickets as ticket (ticket.id)}
<TicketCard {ticket} />
{/each}
</div>
{#if filteredTickets.length !== $tickets.tickets.length}
<div class="text-center py-4 text-sm text-gray-500">
Mostrando {filteredTickets.length} de {$tickets.tickets.length} tickets
</div>
{/if}
{/if}
</div>
</div>

View File

@@ -0,0 +1,501 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { auth } from '$lib/stores/auth.js';
import { tickets } from '$lib/stores/tickets.js';
import { toast } from '$lib/stores/toast.js';
import { goto } from '$app/navigation';
let ticketId: string;
let newComment = '';
let isSubmittingComment = false;
let isClosingTicket = false;
let showCloseDialog = false;
let closeResolution = '';
let fileInput: HTMLInputElement;
let isUploading = false;
onMount(() => {
// Redirect if not authenticated
if (!$auth.isAuthenticated) {
goto('/login');
return;
}
ticketId = $page.params.id;
if (ticketId) {
tickets.loadTicket(ticketId);
}
});
// Format date
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleString('es-ES', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
// Status mapping
const statusConfig = {
NEW: { label: 'Nuevo', class: 'badge-new' },
IN_PROGRESS: { label: 'En Progreso', class: 'badge-in-progress' },
WAITING_FOR_CLIENT: { label: 'Esperando Cliente', class: 'badge-waiting' },
RESOLVED: { label: 'Resuelto', class: 'badge-resolved' },
CLOSED: { label: 'Cerrado', class: 'badge-closed' },
REOPENED: { label: 'Reabierto', class: 'badge-reopened' }
};
// Priority mapping
const priorityConfig = {
LOW: { label: 'Baja', class: 'badge-priority-low' },
MEDIUM: { label: 'Media', class: 'badge-priority-medium' },
HIGH: { label: 'Alta', class: 'badge-priority-high' },
URGENT: { label: 'Urgente', class: 'badge-priority-urgent' }
};
async function handleAddComment() {
if (!newComment.trim()) return;
isSubmittingComment = true;
try {
await tickets.addComment(ticketId, newComment.trim());
newComment = '';
toast.success('Comentario agregado');
} catch (error: any) {
toast.error(error.message || 'Error al agregar comentario');
} finally {
isSubmittingComment = false;
}
}
async function handleFileUpload(event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
// Validate file size (max 10MB)
if (file.size > 10 * 1024 * 1024) {
toast.error('El archivo es demasiado grande. Máximo 10MB');
target.value = '';
return;
}
// Validate file type
const allowedTypes = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf', 'text/plain', 'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
];
if (!allowedTypes.includes(file.type)) {
toast.error('Tipo de archivo no permitido');
target.value = '';
return;
}
isUploading = true;
try {
await tickets.uploadAttachment(ticketId, file);
toast.success('Archivo adjuntado correctamente');
target.value = '';
} catch (error: any) {
toast.error(error.message || 'Error al subir archivo');
} finally {
isUploading = false;
}
}
function handleCloseTicket() {
showCloseDialog = true;
}
async function confirmCloseTicket() {
isClosingTicket = true;
try {
await tickets.closeTicket(ticketId, closeResolution.trim() || undefined);
showCloseDialog = false;
closeResolution = '';
toast.success('Ticket cerrado exitosamente');
} catch (error: any) {
toast.error(error.message || 'Error al cerrar ticket');
} finally {
isClosingTicket = false;
}
}
function cancelCloseTicket() {
showCloseDialog = false;
closeResolution = '';
}
// Check if user can close ticket
$: canClose = $tickets.currentTicket &&
['RESOLVED', 'WAITING_FOR_CLIENT'].includes($tickets.currentTicket.status);
</script>
<svelte:head>
<title>
{$tickets.currentTicket ? `Ticket: ${$tickets.currentTicket.title}` : 'Cargando...'} - ServiceManager
</title>
</svelte:head>
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{#if $tickets.isLoading}
<div class="text-center py-12">
<div class="spinner w-8 h-8 mx-auto mb-4"></div>
<p class="text-gray-600">Cargando ticket...</p>
</div>
{:else if $tickets.error}
<div class="text-center py-12">
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center mx-auto mb-4">
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Error al cargar ticket</h3>
<p class="text-gray-600 mb-4">{$tickets.error}</p>
<button
on:click={() => tickets.loadTicket(ticketId)}
class="btn-primary px-4 py-2"
>
Reintentar
</button>
</div>
{:else if $tickets.currentTicket}
<!-- Breadcrumb -->
<div class="flex items-center space-x-2 text-sm text-gray-500 mb-6">
<a href="/tickets" class="hover:text-primary-600">Mis Tickets</a>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<span>#{$tickets.currentTicket.id.substring(0, 8)}</span>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Main Content -->
<div class="lg:col-span-2 space-y-6">
<!-- Ticket Header -->
<div class="card">
<div class="card-header">
<div class="flex justify-between items-start">
<div class="flex-1">
<h1 class="text-2xl font-bold text-gray-900 mb-2">
{$tickets.currentTicket.title}
</h1>
<div class="flex items-center space-x-3">
<span class={statusConfig[$tickets.currentTicket.status].class}>
{statusConfig[$tickets.currentTicket.status].label}
</span>
<span class={priorityConfig[$tickets.currentTicket.priority].class}>
{priorityConfig[$tickets.currentTicket.priority].label}
</span>
<span class="text-sm text-gray-500">
Creado {formatDate($tickets.currentTicket.created_at)}
</span>
</div>
</div>
{#if canClose}
<button
on:click={handleCloseTicket}
class="btn-success px-4 py-2"
disabled={isClosingTicket}
>
Cerrar Ticket
</button>
{/if}
</div>
</div>
<div class="card-content">
<div class="prose max-w-none">
<p class="whitespace-pre-wrap text-gray-700">
{$tickets.currentTicket.description}
</p>
</div>
{#if $tickets.currentTicket.resolution}
<div class="mt-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<h4 class="font-medium text-green-900 mb-2">Resolución:</h4>
<p class="text-green-800 whitespace-pre-wrap">
{$tickets.currentTicket.resolution}
</p>
</div>
{/if}
</div>
</div>
<!-- Attachments -->
{#if $tickets.attachments.length > 0}
<div class="card">
<div class="card-header">
<h3 class="text-lg font-semibold text-gray-900">Archivos Adjuntos</h3>
</div>
<div class="card-content">
<div class="space-y-3">
{#each $tickets.attachments as attachment}
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-gray-200 rounded flex items-center justify-center">
<svg class="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
</svg>
</div>
<div>
<p class="text-sm font-medium text-gray-900">
{attachment.original_filename}
</p>
<p class="text-xs text-gray-500">
{Math.round(attachment.size_bytes / 1024)} KB •
Subido por {attachment.uploaded_by_name}
{formatDate(attachment.uploaded_at)}
</p>
</div>
</div>
<a
href="/api/v1/tickets/{ticketId}/attachments/{attachment.id}/download"
class="btn-ghost p-2"
target="_blank"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</a>
</div>
{/each}
</div>
</div>
</div>
{/if}
<!-- Comments -->
<div class="card">
<div class="card-header">
<h3 class="text-lg font-semibold text-gray-900">Conversación</h3>
</div>
<div class="card-content">
{#if $tickets.comments.length === 0}
<p class="text-gray-500 text-center py-4">
No hay comentarios aún. ¡Sé el primero en comentar!
</p>
{:else}
<div class="space-y-4">
{#each $tickets.comments as comment}
<div class="flex space-x-3">
<div class="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center flex-shrink-0">
<span class="text-primary-600 text-xs font-medium">
{comment.user_name.split(' ').map(n => n[0]).join('')}
</span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center space-x-2 mb-1">
<span class="text-sm font-medium text-gray-900">
{comment.user_name}
</span>
<span class="text-xs text-gray-500">
{formatDate(comment.created_at)}
</span>
{#if comment.is_internal}
<span class="bg-red-100 text-red-700 text-xs px-2 py-0.5 rounded">
Interno
</span>
{/if}
</div>
<p class="text-gray-700 whitespace-pre-wrap">
{comment.content}
</p>
</div>
</div>
{/each}
</div>
{/if}
<!-- Add Comment Form -->
<div class="mt-6 pt-6 border-t border-gray-200">
<div class="space-y-4">
<textarea
rows="4"
class="form-input"
placeholder="Escribe tu comentario o respuesta..."
bind:value={newComment}
disabled={isSubmittingComment}
></textarea>
<div class="flex justify-between items-center">
<div class="flex items-center space-x-4">
<input
type="file"
bind:this={fileInput}
on:change={handleFileUpload}
class="hidden"
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf,.txt,.doc,.docx,.xls,.xlsx"
disabled={isUploading}
/>
<button
type="button"
on:click={() => fileInput.click()}
class="btn-ghost p-2 flex items-center space-x-2"
disabled={isUploading}
>
{#if isUploading}
<div class="spinner w-4 h-4"></div>
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
</svg>
{/if}
<span class="text-sm">Adjuntar archivo</span>
</button>
</div>
<button
on:click={handleAddComment}
class="btn-primary px-4 py-2"
disabled={isSubmittingComment || !newComment.trim()}
>
{#if isSubmittingComment}
<div class="flex items-center space-x-2">
<div class="spinner w-4 h-4"></div>
<span>Enviando...</span>
</div>
{:else}
Enviar Comentario
{/if}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="space-y-6">
<!-- Ticket Info -->
<div class="card">
<div class="card-header">
<h3 class="text-lg font-semibold text-gray-900">Información</h3>
</div>
<div class="card-content space-y-4">
<div>
<dt class="text-sm font-medium text-gray-500">ID del Ticket</dt>
<dd class="text-sm text-gray-900 font-mono">#{$tickets.currentTicket.id.substring(0, 8)}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Categoría</dt>
<dd class="text-sm text-gray-900">{$tickets.currentTicket.category_name || 'Sin categoría'}</dd>
</div>
{#if $tickets.currentTicket.assigned_to_name}
<div>
<dt class="text-sm font-medium text-gray-500">Asignado a</dt>
<dd class="text-sm text-gray-900">{$tickets.currentTicket.assigned_to_name}</dd>
</div>
{/if}
<div>
<dt class="text-sm font-medium text-gray-500">Creado</dt>
<dd class="text-sm text-gray-900">{formatDate($tickets.currentTicket.created_at)}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Última actualización</dt>
<dd class="text-sm text-gray-900">{formatDate($tickets.currentTicket.updated_at)}</dd>
</div>
{#if $tickets.currentTicket.due_date}
<div>
<dt class="text-sm font-medium text-gray-500">Fecha límite</dt>
<dd class="text-sm text-gray-900 {new Date($tickets.currentTicket.due_date) < new Date() ? 'text-red-600' : ''}">
{formatDate($tickets.currentTicket.due_date)}
{#if new Date($tickets.currentTicket.due_date) < new Date()}
<span class="block text-xs text-red-500">¡Vencido!</span>
{/if}
</dd>
</div>
{/if}
</div>
</div>
</div>
</div>
{/if}
</div>
<!-- Close Ticket Dialog -->
{#if showCloseDialog}
<div class="fixed inset-0 z-50 overflow-y-auto">
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity" on:click={cancelCloseTicket}>
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
</div>
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-green-100 sm:mx-0 sm:h-10 sm:w-10">
<svg class="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 class="text-lg leading-6 font-medium text-gray-900">
Cerrar Ticket
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500">
¿Estás seguro de que quieres cerrar este ticket? Esta acción indica que el problema ha sido resuelto satisfactoriamente.
</p>
</div>
<div class="mt-4">
<label for="close-resolution" class="form-label">
Comentario de cierre (opcional)
</label>
<textarea
id="close-resolution"
rows="3"
class="form-input"
placeholder="Describe cómo se resolvió el problema o agrega comentarios finales..."
bind:value={closeResolution}
disabled={isClosingTicket}
></textarea>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="button"
class="w-full inline-flex justify-center btn-success px-4 py-2 sm:ml-3 sm:w-auto disabled:opacity-50"
disabled={isClosingTicket}
on:click={confirmCloseTicket}
>
{#if isClosingTicket}
<div class="flex items-center space-x-2">
<div class="spinner w-4 h-4"></div>
<span>Cerrando...</span>
</div>
{:else}
Cerrar Ticket
{/if}
</button>
<button
type="button"
class="mt-3 w-full inline-flex justify-center btn-secondary px-4 py-2 sm:mt-0 sm:w-auto"
disabled={isClosingTicket}
on:click={cancelCloseTicket}
>
Cancelar
</button>
</div>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,241 @@
<script lang="ts">
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js';
import { tickets } from '$lib/stores/tickets.js';
import { app } from '$lib/stores/app.js';
import { toast } from '$lib/stores/toast.js';
import { goto } from '$app/navigation';
let title = '';
let description = '';
let categoryId = '';
let priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT' = 'MEDIUM';
let isSubmitting = false;
let errors: Record<string, string> = {};
onMount(() => {
// Redirect if not authenticated
if (!$auth.isAuthenticated) {
goto('/login');
return;
}
// Load categories for the form
app.loadCategories();
});
function validateForm() {
errors = {};
if (!title.trim()) {
errors.title = 'El título es requerido';
} else if (title.trim().length < 10) {
errors.title = 'El título debe tener al menos 10 caracteres';
}
if (!description.trim()) {
errors.description = 'La descripción es requerida';
} else if (description.trim().length < 20) {
errors.description = 'La descripción debe tener al menos 20 caracteres';
}
if (!categoryId) {
errors.categoryId = 'Debes seleccionar una categoría';
}
return Object.keys(errors).length === 0;
}
async function handleSubmit() {
if (!validateForm()) return;
isSubmitting = true;
try {
const newTicket = await tickets.createTicket({
title: title.trim(),
description: description.trim(),
category_id: categoryId,
priority
});
toast.success('Ticket creado exitosamente');
goto(`/tickets/${newTicket.id}`);
} catch (error: any) {
console.error('Create ticket error:', error);
toast.error(error.message || 'Error al crear el ticket');
} finally {
isSubmitting = false;
}
}
</script>
<svelte:head>
<title>Crear Ticket - ServiceManager</title>
</svelte:head>
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="mb-8">
<div class="flex items-center space-x-2 text-sm text-gray-500 mb-4">
<a href="/tickets" class="hover:text-primary-600">Mis Tickets</a>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<span>Crear Ticket</span>
</div>
<h1 class="text-3xl font-bold text-gray-900">Crear Nuevo Ticket</h1>
<p class="text-gray-600 mt-2">
Describe tu problema o solicitud de soporte con el mayor detalle posible
</p>
</div>
<!-- Form -->
<form on:submit|preventDefault={handleSubmit} class="space-y-6">
<div class="card">
<div class="card-content space-y-6">
<!-- Title -->
<div>
<label for="title" class="form-label">
Título del Ticket <span class="text-red-500">*</span>
</label>
<input
id="title"
type="text"
class="form-input {errors.title ? 'border-red-300' : ''}"
placeholder="Describe brevemente el problema..."
bind:value={title}
disabled={isSubmitting}
maxlength="200"
/>
{#if errors.title}
<p class="form-error">{errors.title}</p>
{/if}
<p class="text-xs text-gray-500 mt-1">
{title.length}/200 caracteres
</p>
</div>
<!-- Category -->
<div>
<label for="category" class="form-label">
Categoría <span class="text-red-500">*</span>
</label>
<select
id="category"
class="form-input {errors.categoryId ? 'border-red-300' : ''}"
bind:value={categoryId}
disabled={isSubmitting || $app.isLoading}
>
<option value="">Selecciona una categoría</option>
{#each $app.categories as category}
<option value={category.id}>{category.name}</option>
{/each}
</select>
{#if errors.categoryId}
<p class="form-error">{errors.categoryId}</p>
{/if}
</div>
<!-- Priority -->
<div>
<label for="priority" class="form-label">
Prioridad
</label>
<select
id="priority"
class="form-input"
bind:value={priority}
disabled={isSubmitting}
>
<option value="LOW">Baja - No es urgente, puede esperar</option>
<option value="MEDIUM">Media - Problema normal de trabajo</option>
<option value="HIGH">Alta - Afecta el trabajo significativamente</option>
<option value="URGENT">Urgente - Bloquea el trabajo completamente</option>
</select>
</div>
<!-- Description -->
<div>
<label for="description" class="form-label">
Descripción del Problema <span class="text-red-500">*</span>
</label>
<textarea
id="description"
rows="8"
class="form-input {errors.description ? 'border-red-300' : ''}"
placeholder="Describe el problema con el mayor detalle posible. Incluye:
- Qué estabas haciendo cuando ocurrió el problema
- Qué esperabas que pasara
- Qué pasó en realidad
- Pasos para reproducir el problema
- Cualquier mensaje de error
- Información adicional relevante"
bind:value={description}
disabled={isSubmitting}
maxlength="2000"
></textarea>
{#if errors.description}
<p class="form-error">{errors.description}</p>
{/if}
<p class="text-xs text-gray-500 mt-1">
{description.length}/2000 caracteres
</p>
</div>
</div>
</div>
<!-- Help Tips -->
<div class="card bg-blue-50 border-blue-200">
<div class="card-content">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800">
Tips para un mejor soporte
</h3>
<div class="mt-2 text-sm text-blue-700">
<ul class="list-disc pl-5 space-y-1">
<li>Sé específico y detallado en tu descripción</li>
<li>Incluye capturas de pantalla si es posible (puedes adjuntarlas después)</li>
<li>Menciona qué navegador/sistema operativo estás usando</li>
<li>Indica si el problema es recurrente o fue la primera vez</li>
<li>Si hay mensajes de error, cópialos exactamente</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex justify-between items-center pt-6">
<a
href="/tickets"
class="btn-secondary px-6 py-2"
>
Cancelar
</a>
<button
type="submit"
class="btn-primary px-6 py-2 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isSubmitting}
>
{#if isSubmitting}
<div class="flex items-center space-x-2">
<div class="spinner w-4 h-4"></div>
<span>Creando...</span>
</div>
{:else}
Crear Ticket
{/if}
</button>
</div>
</form>
</div>