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

View File

@@ -0,0 +1,42 @@
{
"extends": [
"eslint:recommended",
"@typescript-eslint/recommended",
"prettier"
],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "svelte3"],
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module"
},
"env": {
"browser": true,
"es2017": true,
"node": true
},
"overrides": [
{
"files": ["*.svelte"],
"processor": "svelte3/svelte3"
}
],
"rules": {
"@typescript-eslint/no-unused-vars": [
"error",
{ "argsIgnorePattern": "^_" }
],
"@typescript-eslint/no-explicit-any": "warn",
"no-console": ["warn", { "allow": ["warn", "error"] }],
"prefer-const": "error"
},
"settings": {
"svelte3/typescript": true
},
"ignorePatterns": [
".svelte-kit/**",
"build/**",
"dist/**",
"node_modules/**"
]
}

View File

@@ -0,0 +1,11 @@
.svelte-kit
build
dist
.env
.env.*
!.env.example
node_modules
.DS_Store
*.log
coverage
.nyc_output

View File

@@ -0,0 +1,22 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "none",
"printWidth": 100,
"useTabs": false,
"quoteProps": "as-needed",
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "avoid",
"endOfLine": "lf",
"plugins": ["prettier-plugin-svelte"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}

View File

@@ -0,0 +1,49 @@
{
"name": "@servicemanager/internal-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev --port 3000 --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview --port 3000 --host 0.0.0.0",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write .",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui"
},
"devDependencies": {
"@sveltejs/adapter-node": "^1.3.1",
"@sveltejs/kit": "^1.20.4",
"@types/cookie": "^0.5.1",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"autoprefixer": "^10.4.14",
"eslint": "^8.28.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-svelte": "^2.30.0",
"postcss": "^8.4.24",
"prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.10.1",
"svelte": "^4.0.5",
"svelte-check": "^3.4.3",
"tailwindcss": "^3.3.0",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"vite": "^4.4.2",
"vitest": "^0.34.0"
},
"dependencies": {
"@tailwindcss/forms": "^0.5.4",
"@tailwindcss/typography": "^0.5.9",
"@heroicons/react": "^2.0.18",
"heroicons": "^2.0.18",
"zod": "^3.22.2",
"date-fns": "^2.30.0",
"chart.js": "^4.3.0",
"chartjs-adapter-date-fns": "^3.0.0"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,225 @@
@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;
}
/* Animations */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out forwards;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-slide-up {
animation: slideUp 0.4s ease-out forwards;
}
.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;
}
/* Role badges */
.badge-admin {
@apply badge bg-purple-100 text-purple-800;
}
.badge-support-manager {
@apply badge bg-indigo-100 text-indigo-800;
}
.badge-agent {
@apply badge bg-blue-100 text-blue-800;
}
.badge-auditor {
@apply badge bg-gray-100 text-gray-800;
}
}
/* 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,16 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="description" content="ServiceManager - Mesa de Ayuda Empresarial - Portal Interno" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
<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@400;500;600;700&display=swap" rel="stylesheet">
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,89 @@
<script lang="ts">
import { auth } from '$lib/stores/auth.js';
export let toggleSidebar: () => void;
function handleLogout() {
auth.logout();
}
let isMenuOpen = false;
function toggleMenu() {
isMenuOpen = !isMenuOpen;
}
</script>
<header class="bg-white shadow-sm border-b border-gray-200">
<div class="flex justify-between items-center px-4 py-3">
<!-- Left side -->
<div class="flex items-center space-x-4">
<button
on:click={toggleSidebar}
class="p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-primary-500 lg:hidden"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div class="hidden lg:block">
<h1 class="text-lg font-semibold text-gray-900">ServiceManager Internal</h1>
</div>
</div>
<!-- Right side -->
<div class="flex items-center space-x-4">
<!-- User menu -->
<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>
<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="M19 9l-7 7-7-7" />
</svg>
</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>
</div>
</div>
</header>
<!-- Backdrop for mobile menu -->
{#if isMenuOpen}
<div
class="fixed inset-0 z-40 lg:hidden"
on:click={() => isMenuOpen = false}
></div>
{/if}

View File

@@ -0,0 +1,43 @@
<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',
dashboard: 'M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z',
users: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z',
chart: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
settings: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z'
};
$: 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,44 @@
<script>
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
export let open = false;
export let title = '';
const dispatch = createEventDispatcher();
function close() {
dispatch('close');
}
function handleKeydown(e) {
if (e.key === 'Escape' && open) {
close();
}
}
</script>
<svelte:window on:keydown={handleKeydown}/>
{#if open}
<div class="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" aria-hidden="true" on:click={close}></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div class="inline-block px-4 pt-5 pb-4 overflow-hidden text-left align-bottom transition-all transform bg-white rounded-lg shadow-xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
<h3 class="text-lg leading-6 font-medium text-gray-900" id="modal-title">
{title}
</h3>
<div class="mt-2 text-sm text-gray-500">
<slot />
</div>
</div>
</div>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,153 @@
<script lang="ts">
import { auth } from '$lib/stores/auth.js';
import { page } from '$app/stores';
export let open = false;
// Navigation items based on user role
$: navigation = getNavigationForRole($auth.user?.role);
function getNavigationForRole(role: string | undefined) {
const baseNavigation = [
{
name: 'Dashboard',
href: '/',
icon: 'M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z'
},
{
name: 'Tickets',
href: '/tickets',
icon: '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'
}
];
if (role === 'ADMIN' || role === 'SUPPORT_MANAGER') {
baseNavigation.push(
{
name: 'Clientes',
href: '/tenants',
icon: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4'
},
{
name: 'Usuarios',
href: '/users',
icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z'
},
{
name: 'Categorías',
href: '/categories',
icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10'
},
{
name: 'Sistemas',
href: '/systems',
icon: 'M5 12a1 1 0 102 0V6.414l1.293 1.293a1 1 0 001.414-1.414l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 001.414 1.414L5 6.414V12zm14 0a1 1 0 10-2 0v5.586l-1.293-1.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L19 17.586V12z'
}
);
}
if (role === 'ADMIN') {
baseNavigation.push(
{
name: 'SLA Management',
href: '/sla',
icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z'
},
{
name: 'Reportes',
href: '/reports',
icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z'
},
{
name: 'Auditoría',
href: '/audit',
icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z'
}
);
}
return baseNavigation;
}
function isCurrentPage(href: string) {
return $page.url.pathname === href ||
($page.url.pathname.startsWith(href) && href !== '/');
}
</script>
<!-- Mobile sidebar backdrop -->
{#if open}
<div class="fixed inset-0 z-40 lg:hidden">
<div class="fixed inset-0 bg-gray-600 bg-opacity-75" on:click={() => open = false}></div>
</div>
{/if}
<!-- Sidebar -->
<div class="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg transform {open ? 'translate-x-0' : '-translate-x-full'} transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0">
<div class="flex flex-col h-full">
<!-- Logo -->
<div class="flex items-center justify-between h-16 px-6 bg-primary-600">
<div class="flex items-center space-x-2">
<div class="w-8 h-8 bg-white rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192L5.636 18.364M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-5 0a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</div>
<div class="text-white">
<h1 class="text-sm font-semibold">ServiceManager</h1>
<p class="text-xs text-primary-100">Panel Interno</p>
</div>
</div>
<button
on:click={() => open = false}
class="p-2 rounded-md text-primary-100 hover:text-white hover:bg-primary-500 lg:hidden"
>
<svg class="w-5 h-5" 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>
<!-- Navigation -->
<nav class="flex-1 px-4 py-6 space-y-2">
{#each navigation as item}
<a
href={item.href}
class="flex items-center space-x-3 px-3 py-2 rounded-md text-sm font-medium transition-colors {
isCurrentPage(item.href)
? 'bg-primary-100 text-primary-700'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
}"
on:click={() => open = false}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={item.icon} />
</svg>
<span>{item.name}</span>
</a>
{/each}
</nav>
<!-- User info -->
<div class="px-4 py-4 border-t border-gray-200">
<div class="flex items-center space-x-3">
<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>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">
{$auth.user?.first_name} {$auth.user?.last_name}
</p>
<p class="text-xs text-gray-500 truncate">
{$auth.user?.role === 'ADMIN' ? 'Administrador' :
$auth.user?.role === 'SUPPORT_MANAGER' ? 'Gerente de Soporte' :
$auth.user?.role === 'AGENT' ? 'Agente' : 'Auditor'}
</p>
</div>
</div>
</div>
</div>
</div>

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,215 @@
import { writable } from 'svelte/store';
import type { Writable } from 'svelte/store';
// Types
export interface InternalUser {
id: string;
email: string;
first_name: string;
last_name: string;
role: 'ADMIN' | 'SUPPORT_MANAGER' | 'AGENT' | 'AUDITOR';
is_active: boolean;
is_two_factor_enabled: boolean;
created_at: string;
tenant_id: string;
}
export interface AuthState {
user: InternalUser | null;
token: string | null;
refreshToken: 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;
refresh_token: string;
token_type: string;
expires_in: number;
user: InternalUser;
}
export interface RefreshTokenRequest {
refresh_token: string;
}
export interface TokenResponse {
access_token: string;
token_type: string;
expires_in: number;
}
// Initial state
const initialState: AuthState = {
user: null,
token: null,
refreshToken: null,
isAuthenticated: false,
isLoading: false
};
// Create auth store
function createAuthStore() {
const { subscribe, set, update } = writable<AuthState>(initialState);
return {
subscribe,
// Initialize auth from localStorage
init: () => {
if (typeof window !== 'undefined') {
const token = localStorage.getItem('internal_auth_token');
const refreshToken = localStorage.getItem('internal_auth_refresh_token');
const user = localStorage.getItem('internal_auth_user');
if (token && user) {
try {
const parsedUser = JSON.parse(user);
set({
user: parsedUser,
token,
refreshToken: refreshToken || null,
isAuthenticated: true,
isLoading: false
});
} catch (error) {
console.error('Error parsing stored auth data:', error);
localStorage.removeItem('internal_auth_token');
localStorage.removeItem('internal_auth_refresh_token');
localStorage.removeItem('internal_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('internal_auth_token', data.access_token);
if (data.refresh_token) {
localStorage.setItem('internal_auth_refresh_token', data.refresh_token);
}
localStorage.setItem('internal_auth_user', JSON.stringify(data.user));
}
set({
user: data.user,
token: data.access_token,
refreshToken: data.refresh_token,
isAuthenticated: true,
isLoading: false
});
} catch (error) {
update(state => ({ ...state, isLoading: false }));
throw error;
}
},
// Refresh Session
refreshSession: async (): Promise<void> => {
// Need to get current state to access refresh token, logic simplified
let currentRefreshToken: string | null = null;
if (typeof window !== 'undefined') {
currentRefreshToken = localStorage.getItem('internal_auth_refresh_token');
}
if (!currentRefreshToken) {
throw new Error("No refresh token available");
}
update (state => ({ ...state, isLoading: true }));
try {
const response = await fetch('/api/v1/auth/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refresh_token: currentRefreshToken })
});
if (!response.ok) {
// If refresh fails, logout
if (response.status === 401 || response.status === 403) {
auth.logout();
}
const error = await response.json();
throw new Error(error.detail || 'Refresh failed');
}
const data: TokenResponse = await response.json();
// Update token in storage and state
if (typeof window !== 'undefined') {
localStorage.setItem('internal_auth_token', data.access_token);
}
update(state => ({
...state,
token: data.access_token,
isLoading: false
}));
} catch (error) {
update(state => ({ ...state, isLoading: false }));
throw error;
}
},
// Logout
logout: () => {
if (typeof window !== 'undefined') {
localStorage.removeItem('internal_auth_token');
localStorage.removeItem('internal_auth_refresh_token');
localStorage.removeItem('internal_auth_user');
}
set(initialState);
// Optional: Redirect to login
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
},
// Update user data
updateUser: (user: InternalUser) => {
update(state => ({ ...state, user }));
if (typeof window !== 'undefined') {
localStorage.setItem('internal_auth_user', JSON.stringify(user));
}
},
// Set loading state
setLoading: (isLoading: boolean) => {
update(state => ({ ...state, isLoading }));
}
};
}
export const auth = createAuthStore();

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,73 @@
import { auth } from '$lib/stores/auth';
import { get } from 'svelte/store';
const API_BASE = '/api/v1';
interface RequestOptions extends RequestInit {
params?: Record<string, string>;
}
async function request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
const { params, ...init } = options;
let url = `${API_BASE}${endpoint}`;
if (params) {
const searchParams = new URLSearchParams(params);
url += `?${searchParams.toString()}`;
}
const authState = get(auth);
const token = authState.token || (typeof window !== 'undefined' ? localStorage.getItem('internal_auth_token') : null);
const headers = new Headers(init.headers);
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
const response = await fetch(url, {
...init,
headers
});
if (response.status === 401) {
// Token expired or invalid
if (typeof window !== 'undefined') {
localStorage.removeItem('internal_auth_token');
localStorage.removeItem('internal_auth_user');
window.location.href = '/login';
}
throw new Error('Unauthorized');
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `API error: ${response.statusText}`);
}
// Handle empty responses (like 204 No Content)
if (response.status === 204) {
return {} as T;
}
return response.json();
}
export const api = {
get: <T>(endpoint: string, params?: Record<string, string>) =>
request<T>(endpoint, { method: 'GET', params }),
post: <T>(endpoint: string, body: any) =>
request<T>(endpoint, { method: 'POST', body: JSON.stringify(body) }),
put: <T>(endpoint: string, body: any) =>
request<T>(endpoint, { method: 'PUT', body: JSON.stringify(body) }),
patch: <T>(endpoint: string, body: any) =>
request<T>(endpoint, { method: 'PATCH', body: JSON.stringify(body) }),
delete: <T>(endpoint: string) =>
request<T>(endpoint, { method: 'DELETE' })
};

View File

@@ -0,0 +1,53 @@
<script lang="ts">
import Header from '$lib/components/Header.svelte';
import Sidebar from '$lib/components/Sidebar.svelte';
import Toast from '$lib/components/Toast.svelte';
import { toast } from '$lib/stores/toast.js';
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js';
import '../app.css';
let sidebarOpen = false;
onMount(() => {
auth.init();
});
function toggleSidebar() {
sidebarOpen = !sidebarOpen;
}
</script>
<div class="min-h-screen bg-gray-50">
{#if $auth.isAuthenticated}
<!-- Internal Layout with Sidebar -->
<div class="flex h-screen overflow-hidden">
<!-- Sidebar -->
<Sidebar bind:open={sidebarOpen} />
<!-- Main content -->
<div class="flex-1 flex flex-col overflow-hidden">
<Header {toggleSidebar} />
<main class="flex-1 overflow-auto">
<slot />
</main>
</div>
</div>
{:else}
<!-- Login Layout -->
<main class="flex-1">
<slot />
</main>
{/if}
<!-- 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,98 @@
<script lang="ts">
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js';
import { goto } from '$app/navigation';
import Icon from '$lib/components/Icon.svelte';
onMount(() => {
if (!$auth.isAuthenticated) {
goto('/login');
}
});
const cards = [
{
title: 'Clientes',
description: 'Gestión de organizaciones y tenants',
icon: 'users',
href: '/tenants',
color: 'bg-blue-500'
},
{
title: 'Usuarios',
description: 'Administración de usuarios y roles',
icon: 'user-plus',
href: '/users',
color: 'bg-green-500'
},
{
title: 'Sistemas',
description: 'Catálogo de sistemas soportados',
icon: 'server',
href: '/systems',
color: 'bg-purple-500'
},
{
title: 'Categorías',
description: 'Clasificación de tickets',
icon: 'tag',
href: '/categories',
color: 'bg-orange-500'
}
];
</script>
<svelte:head>
<title>Dashboard Admin - ServiceManager</title>
</svelte:head>
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="md:flex md:items-center md:justify-between">
<div class="flex-1 min-w-0">
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:text-3xl sm:truncate">
Panel de Administración
</h2>
<p class="mt-1 text-sm text-gray-500">
Bienvenido al sistema de gestión interna.
</p>
</div>
</div>
<div class="mt-8 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
{#each cards as card}
<a href={card.href} class="bg-white overflow-hidden shadow rounded-lg hover:shadow-md transition-shadow duration-200 cursor-pointer group">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="{card.color} rounded-md p-3">
<!-- Simple SVG Icon placeholder since Icon component might expect specific names that map to SVGs -->
<svg class="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
{card.title}
</dt>
<dd>
<div class="text-xs text-gray-900 font-light mt-1">
{card.description}
</div>
</dd>
</dl>
</div>
</div>
</div>
<div class="bg-gray-50 px-5 py-3">
<div class="text-sm">
<span class="font-medium text-cyan-700 hover:text-cyan-900">
Ver detalles
</span>
</div>
</div>
</a>
{/each}
</div>
</div>

View File

@@ -0,0 +1,184 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/utils/api';
import { toast } from '$lib/stores/toast';
import Modal from '$lib/components/Modal.svelte';
let categories = [];
let tenants = [];
let isLoading = false;
let showModal = false;
let editingCategory = null;
let formData = {
name: '',
description: '',
tenant_id: '',
is_active: true
};
async function loadData() {
isLoading = true;
try {
const [categoriesData, tenantsData] = await Promise.all([
api.get('/categories/'),
api.get('/tenants/')
]);
categories = categoriesData;
tenants = tenantsData;
} catch (e) {
toast.error('Error cargando datos');
} finally {
isLoading = false;
}
}
function openCreateModal() {
editingCategory = null;
formData = { name: '', description: '', tenant_id: '', is_active: true };
showModal = true;
}
function openEditModal(category) {
editingCategory = category;
formData = {
name: category.name,
description: category.description,
tenant_id: category.tenant_id || '',
is_active: category.is_active
};
showModal = true;
}
async function handleSubmit() {
try {
const payload = { ...formData };
if (!payload.tenant_id) payload.tenant_id = null;
if (editingCategory) {
await api.put(`/categories/${editingCategory.id}`, payload);
toast.success('Categoría actualizada');
} else {
await api.post('/categories/', payload);
toast.success('Categoría creada');
}
showModal = false;
loadData();
} catch (e) {
toast.error(e.message || 'Error guardando categoría');
}
}
function getTenantName(id) {
if (!id) return 'Global';
const t = tenants.find(t => t.id === id);
return t ? t.name : id;
}
onMount(loadData);
</script>
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-xl font-semibold text-gray-900">Categorías de Tickets</h1>
<p class="mt-2 text-sm text-gray-700">Gestión de categorías para clasificación de tickets.</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<button
type="button"
on:click={openCreateModal}
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 sm:w-auto"
>
Nueva Categoría
</button>
</div>
</div>
<div class="mt-8 flex flex-col">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table class="min-w-full divide-y divide-gray-300">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Nombre</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Descripción</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Tipo (Cliente)</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Estado</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">Acciones</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#if isLoading}
<tr><td colspan="5" class="text-center py-4">Cargando...</td></tr>
{:else if categories.length === 0}
<tr><td colspan="5" class="text-center py-4">No hay categorías registradas</td></tr>
{:else}
{#each categories as category}
<tr>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">{category.name}</td>
<td class="px-3 py-4 text-sm text-gray-500 max-w-xs truncate">{category.description || '-'}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<span class:bg-blue-100={!category.tenant_id} class:text-blue-800={!category.tenant_id} class:bg-gray-100={category.tenant_id} class:text-gray-800={category.tenant_id} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
{getTenantName(category.tenant_id)}
</span>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<span class:bg-green-100={category.is_active} class:text-green-800={category.is_active} class:bg-red-100={!category.is_active} class:text-red-800={!category.is_active} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
{category.is_active ? 'Activo' : 'Inactivo'}
</span>
</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<button on:click={() => openEditModal(category)} class="text-indigo-600 hover:text-indigo-900">Editar</button>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<Modal open={showModal} title={editingCategory ? 'Editar Categoría' : 'Nueva Categoría'} on:close={() => showModal = false}>
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Nombre</label>
<input type="text" id="name" bind:value={formData.name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
</div>
<div>
<label for="description" class="block text-sm font-medium text-gray-700">Descripción</label>
<textarea id="description" bind:value={formData.description} rows="3" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"></textarea>
</div>
<div>
<label for="tenant" class="block text-sm font-medium text-gray-700">Cliente (Opcional - Específico para un cliente)</label>
<select id="tenant" bind:value={formData.tenant_id} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
<option value="">-- Global (Para todos) --</option>
{#each tenants as tenant}
<option value={tenant.id}>{tenant.name}</option>
{/each}
</select>
</div>
<div class="flex items-center">
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
<label for="is_active" class="ml-2 block text-sm text-gray-900">Activo</label>
</div>
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm">
Guardar
</button>
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm">
Cancelar
</button>
</div>
</form>
</Modal>

View File

@@ -0,0 +1,211 @@
<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 = '';
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: 'system-admin',
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>
<svelte:head>
<title>Acceso Admin - ServiceManager</title>
</svelte:head>
<div class="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-950 p-4 font-sans">
<div class="w-full max-w-5xl grid grid-cols-1 md:grid-cols-2 bg-white dark:bg-gray-900 rounded-lg shadow-xl overflow-hidden border border-gray-200 dark:border-gray-800">
<!-- Left Side: Internal Branding -->
<div class="hidden md:flex flex-col justify-between p-12 bg-gray-900 text-white relative overflow-hidden">
<!-- Grid pattern overlay -->
<div class="absolute inset-0 opacity-10" style="background-image: radial-gradient(white 1px, transparent 1px); background-size: 30px 30px;"></div>
<div class="relative z-10">
<div class="flex items-center space-x-3 mb-6">
<div class="p-2 bg-blue-500/20 rounded border border-blue-500/30">
<Icon name="server" class="w-6 h-6 text-blue-400" />
</div>
<span class="text-sm font-mono tracking-wider text-blue-400">INTERNAL_ACCESS_V2</span>
</div>
<h1 class="text-3xl font-bold tracking-tight mb-4">
Panel de Administración
</h1>
<p class="text-gray-400 text-sm leading-relaxed max-w-sm">
Plataforma de gestión de servicios, monitoreo de tickets y administración de usuarios. Acceso restringido únicamente a personal autorizado.
</p>
</div>
<div class="relative z-10 mt-12">
<div class="space-y-3">
<div class="flex items-center space-x-3 text-xs text-gray-400 font-mono">
<Icon name="check-circle" class="w-4 h-4 text-green-500" />
<span>System Status: Operational</span>
</div>
<div class="flex items-center space-x-3 text-xs text-gray-400 font-mono">
<Icon name="shield" class="w-4 h-4 text-blue-500" />
<span>256-bit Encryption Enabled</span>
</div>
</div>
</div>
</div>
<!-- Right Side: Login Form -->
<div class="p-8 md:p-12 flex flex-col justify-center">
<div class="max-w-sm mx-auto w-full">
<div class="mb-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-1">Identifíquese</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">Acceso al sistema central</p>
</div>
<form on:submit|preventDefault={handleLogin} class="space-y-5">
{#if errorMessage}
<div class="p-3 rounded-md bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-900 flex items-start gap-3">
<Icon name="alert-triangle" class="w-5 h-5 text-red-600 dark:text-red-500 flex-shrink-0 mt-0.5" />
<p class="text-sm text-red-600 dark:text-red-500">{errorMessage}</p>
</div>
{/if}
{#if !showTwoFactor}
<div class="space-y-4">
<div>
<label for="email" class="block text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">Usuario / Correo</label>
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors">
<Icon name="user" class="w-5 h-5" />
</div>
<input
id="email"
type="email"
bind:value={email}
on:keydown={handleKeyDown}
class="form-input w-full pl-10 py-2.5 bg-gray-50 dark:bg-gray-800 border-gray-300 dark:border-gray-700 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all font-mono text-sm"
placeholder="admin@aduanasoft.com"
required
disabled={isLoading}
/>
</div>
</div>
<div>
<label for="password" class="block text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">Clave de Acceso</label>
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors">
<Icon name="lock" class="w-5 h-5" />
</div>
<input
id="password"
type="password"
bind:value={password}
on:keydown={handleKeyDown}
class="form-input w-full pl-10 py-2.5 bg-gray-50 dark:bg-gray-800 border-gray-300 dark:border-gray-700 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all font-mono text-sm"
placeholder="••••••••••••"
required
disabled={isLoading}
/>
</div>
</div>
</div>
{:else}
<!-- 2FA Input -->
<div class="bg-blue-50 dark:bg-blue-900/10 p-4 rounded-lg border border-blue-100 dark:border-blue-800/30">
<label for="code" class="block text-xs font-semibold uppercase tracking-wider text-blue-800 dark:text-blue-300 mb-2 text-center">Verificación de Seguridad</label>
<div class="relative">
<input
id="code"
type="text"
bind:value={totpCode}
on:keydown={handleKeyDown}
class="form-input w-full py-3 rounded border-blue-300 dark:border-blue-700 focus:ring-blue-500 focus:border-blue-500 text-center tracking-[0.5em] font-mono text-lg bg-white dark:bg-gray-800"
placeholder="000000"
maxlength="6"
required
disabled={isLoading}
autofocus
/>
</div>
<p class="text-xs text-blue-600 dark:text-blue-400 mt-2 text-center">
Consulte su dispositivo autenticador
</p>
</div>
{/if}
<div class="pt-4">
<button
type="submit"
class="w-full flex justify-center py-2.5 px-4 rounded bg-gray-900 dark:bg-gray-700 text-white font-medium hover:bg-gray-800 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
disabled={isLoading}
>
{#if isLoading}
<Icon name="loader" class="w-4 h-4 animate-spin mr-2" />
Autenticando...
{:else}
{showTwoFactor ? 'Verificar Token' : 'Entrar al Panel'}
{/if}
</button>
</div>
</form>
</div>
<div class="mt-8 pt-6 border-t border-gray-100 dark:border-gray-800">
<p class="text-[10px] text-gray-400 text-center uppercase tracking-widest">Aduanasoft Internal Systems © 2024</p>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,147 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/utils/api';
import { toast } from '$lib/stores/toast';
import Modal from '$lib/components/Modal.svelte';
let systems = [];
let isLoading = false;
let showModal = false;
let editingSystem = null;
let formData = {
name: '',
description: '',
is_active: true
};
async function loadSystems() {
isLoading = true;
try {
systems = await api.get('/systems/');
} catch (e) {
toast.error('Error cargando sistemas');
} finally {
isLoading = false;
}
}
function openCreateModal() {
editingSystem = null;
formData = { name: '', description: '', is_active: true };
showModal = true;
}
function openEditModal(system) {
editingSystem = system;
formData = { ...system };
showModal = true;
}
async function handleSubmit() {
try {
if (editingSystem) {
await api.put(`/systems/${editingSystem.id}`, formData);
toast.success('Sistema actualizado');
} else {
await api.post('/systems/', formData);
toast.success('Sistema creado');
}
showModal = false;
loadSystems();
} catch (e) {
toast.error(e.message || 'Error guardando sistema');
}
}
onMount(loadSystems);
</script>
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-xl font-semibold text-gray-900">Sistemas</h1>
<p class="mt-2 text-sm text-gray-700">Catálogo de sistemas informáticos gestionados.</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<button
type="button"
on:click={openCreateModal}
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 sm:w-auto"
>
Nuevo Sistema
</button>
</div>
</div>
<div class="mt-8 flex flex-col">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table class="min-w-full divide-y divide-gray-300">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Nombre</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Descripción</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Estado</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">Acciones</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#if isLoading}
<tr><td colspan="4" class="text-center py-4">Cargando...</td></tr>
{:else if systems.length === 0}
<tr><td colspan="4" class="text-center py-4">No hay sistemas registrados</td></tr>
{:else}
{#each systems as system}
<tr>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">{system.name}</td>
<td class="px-3 py-4 text-sm text-gray-500 max-w-xs truncate">{system.description || '-'}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<span class:bg-green-100={system.is_active} class:text-green-800={system.is_active} class:bg-red-100={!system.is_active} class:text-red-800={!system.is_active} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
{system.is_active ? 'Activo' : 'Inactivo'}
</span>
</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<button on:click={() => openEditModal(system)} class="text-indigo-600 hover:text-indigo-900">Editar</button>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<Modal open={showModal} title={editingSystem ? 'Editar Sistema' : 'Nuevo Sistema'} on:close={() => showModal = false}>
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Nombre</label>
<input type="text" id="name" bind:value={formData.name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
</div>
<div>
<label for="description" class="block text-sm font-medium text-gray-700">Descripción</label>
<textarea id="description" bind:value={formData.description} rows="3" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"></textarea>
</div>
<div class="flex items-center">
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
<label for="is_active" class="ml-2 block text-sm text-gray-900">Activo</label>
</div>
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm">
Guardar
</button>
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm">
Cancelar
</button>
</div>
</form>
</Modal>

View File

@@ -0,0 +1,157 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/utils/api';
import { toast } from '$lib/stores/toast';
import Modal from '$lib/components/Modal.svelte';
import Icon from '$lib/components/Icon.svelte';
let tenants = [];
let isLoading = false;
let showModal = false;
let editingTenant = null;
let formData = {
name: '',
slug: '',
domain: '',
is_active: true
};
async function loadTenants() {
isLoading = true;
try {
tenants = await api.get('/tenants/');
} catch (e) {
toast.error('Error cargando clientes');
} finally {
isLoading = false;
}
}
function openCreateModal() {
editingTenant = null;
formData = { name: '', slug: '', domain: '', is_active: true };
showModal = true;
}
function openEditModal(tenant) {
editingTenant = tenant;
formData = { ...tenant };
showModal = true;
}
async function handleSubmit() {
try {
if (editingTenant) {
await api.put(`/tenants/${editingTenant.id}`, formData);
toast.success('Cliente actualizado');
} else {
await api.post('/tenants/', formData);
toast.success('Cliente creado');
}
showModal = false;
loadTenants();
} catch (e) {
toast.error(e.message || 'Error guardando cliente');
}
}
onMount(loadTenants);
</script>
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-xl font-semibold text-gray-900">Clientes</h1>
<p class="mt-2 text-sm text-gray-700">Lista de todas las organizaciones/clientes registrados en el sistema.</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<button
type="button"
on:click={openCreateModal}
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 sm:w-auto"
>
Nuevo Cliente
</button>
</div>
</div>
<div class="mt-8 flex flex-col">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table class="min-w-full divide-y divide-gray-300">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Nombre</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Slug</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Dominio</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Estado</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">Acciones</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#if isLoading}
<tr><td colspan="5" class="text-center py-4">Cargando...</td></tr>
{:else if tenants.length === 0}
<tr><td colspan="5" class="text-center py-4">No hay clientes registrados</td></tr>
{:else}
{#each tenants as tenant}
<tr>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">{tenant.name}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{tenant.slug}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{tenant.domain || '-'}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<span class:bg-green-100={tenant.is_active} class:text-green-800={tenant.is_active} class:bg-red-100={!tenant.is_active} class:text-red-800={!tenant.is_active} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
{tenant.is_active ? 'Activo' : 'Inactivo'}
</span>
</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<button on:click={() => openEditModal(tenant)} class="text-indigo-600 hover:text-indigo-900">Editar</button>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<Modal open={showModal} title={editingTenant ? 'Editar Cliente' : 'Nuevo Cliente'} on:close={() => showModal = false}>
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Nombre</label>
<input type="text" id="name" bind:value={formData.name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
</div>
<div>
<label for="slug" class="block text-sm font-medium text-gray-700">Slug (Identificador)</label>
<input type="text" id="slug" bind:value={formData.slug} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
<p class="text-xs text-gray-500 mt-1">Usado en URLs y subdominios.</p>
</div>
<div>
<label for="domain" class="block text-sm font-medium text-gray-700">Dominio Personalizado</label>
<input type="text" id="domain" bind:value={formData.domain} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
</div>
<div class="flex items-center">
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
<label for="is_active" class="ml-2 block text-sm text-gray-900">Activo</label>
</div>
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm">
Guardar
</button>
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm">
Cancelar
</button>
</div>
</form>
</Modal>

View File

@@ -0,0 +1,231 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/utils/api';
import { toast } from '$lib/stores/toast';
import Modal from '$lib/components/Modal.svelte';
let users = [];
let tenants = [];
let isLoading = false;
let showModal = false;
let editingUser = null;
let formData = {
email: '',
password: '',
first_name: '',
last_name: '',
role: 'AGENT',
tenant_id: '',
is_active: true
};
const ROLES = [
{ value: 'ADMIN', label: 'Administrador (Global)' },
{ value: 'SUPPORT_MANAGER', label: 'Gerente de Soporte' },
{ value: 'AGENT', label: 'Agente de Soporte' },
{ value: 'AUDITOR', label: 'Auditor' },
{ value: 'CLIENT_ADMIN', label: 'Admin Cliente' },
{ value: 'CLIENT_USER', label: 'Usuario Cliente' }
];
async function loadData() {
isLoading = true;
try {
const [usersData, tenantsData] = await Promise.all([
api.get('/users/'),
api.get('/tenants/')
]);
users = usersData;
tenants = tenantsData;
} catch (e) {
toast.error('Error cargando datos');
} finally {
isLoading = false;
}
}
function openCreateModal() {
editingUser = null;
formData = {
email: '',
password: '',
first_name: '',
last_name: '',
role: 'AGENT',
tenant_id: '',
is_active: true
};
showModal = true;
}
function openEditModal(user) {
editingUser = user;
formData = {
email: user.email,
password: '', // Don't show password
first_name: user.first_name,
last_name: user.last_name,
role: user.role,
tenant_id: user.tenant_id || '',
is_active: user.is_active
};
showModal = true;
}
async function handleSubmit() {
try {
const payload = { ...formData };
if (!payload.password) delete payload.password; // Don't send empty password on edit
if (!payload.tenant_id) payload.tenant_id = null; // Send null if empty string
if (editingUser) {
await api.put(`/users/${editingUser.id}`, payload);
toast.success('Usuario actualizado');
} else {
if (!payload.password) {
toast.error('La contraseña es requerida para nuevos usuarios');
return;
}
await api.post('/users/', payload);
toast.success('Usuario creado');
}
showModal = false;
loadData();
} catch (e) {
toast.error(e.message || 'Error guardando usuario');
}
}
function getTenantName(id) {
if (!id) return '-';
const t = tenants.find(t => t.id === id);
return t ? t.name : id;
}
onMount(loadData);
</script>
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-xl font-semibold text-gray-900">Usuarios</h1>
<p class="mt-2 text-sm text-gray-700">Gestión de usuarios internos y de clientes.</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<button
type="button"
on:click={openCreateModal}
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 sm:w-auto"
>
Nuevo Usuario
</button>
</div>
</div>
<div class="mt-8 flex flex-col">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table class="min-w-full divide-y divide-gray-300">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Usuario</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Rol</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Cliente (Tenant)</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Estado</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">Acciones</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#if isLoading}
<tr><td colspan="5" class="text-center py-4">Cargando...</td></tr>
{:else if users.length === 0}
<tr><td colspan="5" class="text-center py-4">No hay usuarios registrados</td></tr>
{:else}
{#each users as user}
<tr>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm sm:pl-6">
<div class="font-medium text-gray-900">{user.first_name} {user.last_name}</div>
<div class="text-gray-500">{user.email}</div>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{user.role}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{getTenantName(user.tenant_id)}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<span class:bg-green-100={user.is_active} class:text-green-800={user.is_active} class:bg-red-100={!user.is_active} class:text-red-800={!user.is_active} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
{user.is_active ? 'Activo' : 'Inactivo'}
</span>
</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<button on:click={() => openEditModal(user)} class="text-indigo-600 hover:text-indigo-900">Editar</button>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<Modal open={showModal} title={editingUser ? 'Editar Usuario' : 'Nuevo Usuario'} on:close={() => showModal = false}>
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label for="first_name" class="block text-sm font-medium text-gray-700">Nombre</label>
<input type="text" id="first_name" bind:value={formData.first_name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
</div>
<div>
<label for="last_name" class="block text-sm font-medium text-gray-700">Apellido</label>
<input type="text" id="last_name" bind:value={formData.last_name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
</div>
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<input type="email" id="email" bind:value={formData.email} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">Contraseña {editingUser ? '(dejar en blanco para mantener)' : ''}</label>
<input type="password" id="password" bind:value={formData.password} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
</div>
<div>
<label for="role" class="block text-sm font-medium text-gray-700">Rol</label>
<select id="role" bind:value={formData.role} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
{#each ROLES as role}
<option value={role.value}>{role.label}</option>
{/each}
</select>
</div>
<div>
<label for="tenant" class="block text-sm font-medium text-gray-700">Cliente (Opcional - solo para usuarios externos)</label>
<select id="tenant" bind:value={formData.tenant_id} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
<option value="">-- Ninguno (Usuario Interno) --</option>
{#each tenants as tenant}
<option value={tenant.id}>{tenant.name}</option>
{/each}
</select>
</div>
<div class="flex items-center">
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
<label for="is_active" class="ml-2 block text-sm text-gray-900">Activo</label>
</div>
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm">
Guardar
</button>
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm">
Cancelar
</button>
</div>
</form>
</Modal>

View File

@@ -0,0 +1,25 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/kit/vite';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter(),
alias: {
'$components': 'src/lib/components',
'$stores': 'src/lib/stores',
'$utils': 'src/lib/utils',
'$types': 'src/lib/types'
}
}
};
export default config;

View File

@@ -0,0 +1,23 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
}
}
}
},
plugins: [],
}

View File

@@ -0,0 +1,24 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
port: 3000,
host: '0.0.0.0',
proxy: {
'/api/v1': {
target: 'http://servicemanager-backend:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
},
preview: {
port: 3000,
host: '0.0.0.0'
},
build: {
target: 'esnext'
}
});