feature-comercial-module-ts #13

Merged
edgar.mendez merged 38 commits from feature-comercial-module-ts into develop 2026-03-04 15:07:09 +00:00
403 changed files with 20459 additions and 34618 deletions

50
.dockerignore Normal file
View File

@ -0,0 +1,50 @@
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Build outputs
dist
dist-ssr
*.local
# Git
.git
.gitignore
# Docker
Dockerfile
docker-compose.yml
.dockerignore
# IDE
.vscode
.idea
*.sw?
*.suo
*.ntvs*
*.njsproj
*.sln
# OS
.DS_Store
Thumbs.db
# Environment
.env
.env.local
.env.*.local
# Documentation
README.md
*.md
# Testing
coverage
.nyc_output
# Logs
logs
*.log

View File

@ -1,13 +1,10 @@
VITE_API_URL=http://localhost:8080
VITE_BASE_URL=http://localhost:3000
VITE_REVERB_APP_ID=
VITE_REVERB_APP_KEY=
VITE_REVERB_APP_SECRET=
VITE_REVERB_HOST="localhost"
VITE_REVERB_PORT=8080
VITE_REVERB_SCHEME=http
VITE_REVERB_ACTIVE=false
# API Configuration
VITE_API_URL=http://localhost:3000/api
# Environment
VITE_APP_ENV=development
APP_PORT=3000
# App Configuration
VITE_APP_NAME=GOLS Control
VITE_APP_VERSION=1.0.0

11
.gitignore vendored
View File

@ -11,9 +11,6 @@ node_modules
dist
dist-ssr
*.local
.env
colors.css
notes.md
# Editor directories and files
.vscode/*
@ -25,3 +22,11 @@ notes.md
*.njsproj
*.sln
*.sw?
# Environment files
.env
.env.local
.env.*.local
# Docker
docker-compose.override.yml

33
Dockerfile Normal file
View File

@ -0,0 +1,33 @@
# Stage 1: Build the application
FROM node:22-alpine AS builder
WORKDIR /app
# Copy package files first to leverage Docker cache
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy the rest of the application code
COPY . .
# Build the application
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build
# Stage 2: Serve the application with Nginx
FROM nginx:alpine AS production
# Copy the built artifacts from the builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy custom Nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port 80
EXPOSE 80
# Start Nginx
CMD ["nginx", "-g", "daemon off;"]

View File

@ -1,5 +1,22 @@
# Vue 3 + Vite
# Estructura del Proyecto - GOLS Control Frontend
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
## Docker (Producción)
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
```bash
# 1. Configurar
cp .env.production .env
# 2. Levantar
docker-compose up -d
# 3. Verificar en http://localhost
```
Ver [DOCKER.md](DOCKER.md) para más detalles.
## Notas
- Los componentes de PrimeVue se auto-importan
- TypeScript configurado con strict mode
- Tailwind CSS v4 integrado
- Variables CSS personalizadas en `main.css`

200
agent.md
View File

@ -1,200 +0,0 @@
# Prompt: Generador de Módulos CRUD siguiendo la Estructura de Users
Eres un agente especializado en crear módulos CRUD completos para aplicaciones Vue 3 + Composition API siguiendo la estructura establecida en el módulo Users como referencia.
## 🏗️ ESTRUCTURA OBLIGATORIA
Cada módulo debe seguir exactamente esta estructura de archivos:
```
ModuleName/
├── Index.vue # Vista principal con listado paginado
├── Create.vue # Formulario de creación
├── Edit.vue # Formulario de edición
├── Form.vue # Componente de formulario compartido
├── Settings.vue # Configuraciones (si aplica)
├── Module.js # Utilidades centralizadas del módulo
├── Modals/
│ └── Show.vue # Modal para mostrar detalles
└── [OtrosArchivos].vue # Archivos específicos del módulo
```
## 📋 ESPECIFICACIONES TÉCNICAS
### 1. **Module.js - Archivo Central** (OBLIGATORIO)
```javascript
import { lang } from '@Lang/i18n';
import { hasPermission } from '@Plugins/RolePermission.js';
// Ruta API
const apiTo = (name, params = {}) => route(`admin.{module}.${name}`, params)
// Ruta visual
const viewTo = ({ name = '', params = {}, query = {} }) => view({
name: `admin.{module}.${name}`, params, query
})
// Obtener traducción del componente
const transl = (str) => lang(`{module}.${str}`)
// Control de permisos
const can = (permission) => hasPermission(`{module}.${permission}`)
export { can, viewTo, apiTo, transl }
```
### 2. **Index.vue - Vista Principal** (OBLIGATORIO)
Debe incluir:
- ✅ `useSearcher` para búsqueda paginada
- ✅ `SearcherHead` con título y botones de acción
- ✅ `Table` component con templates: `#head`, `#body`, `#empty`
- ✅ Modales para `Show` y `Destroy`
- ✅ Control de permisos para cada acción (`can()`)
- ✅ Acciones estándar: Ver, Editar, Eliminar, Settings (si aplica)
### 3. **Create.vue - Creación** (OBLIGATORIO)
```javascript
// Estructura mínima:
const form = useForm({
// campos del formulario
});
function submit() {
form.post(apiTo('store'), {
onSuccess: () => {
Notify.success(Lang('register.create.onSuccess'))
router.push(viewTo({ name: 'index' }));
}
})
}
```
### 4. **Edit.vue - Edición** (OBLIGATORIO)
```javascript
// Estructura mínima:
const form = useForm({
id: null,
// otros campos
});
function submit() {
form.put(apiTo('update', { [resource]: form.id }), {
onSuccess: () => {
Notify.success(Lang('register.edit.onSuccess'))
router.push(viewTo({ name: 'index' }));
},
})
}
onMounted(() => {
api.get(apiTo('show', { [resource]: vroute.params.id }), {
onSuccess: (r) => form.fill(r.[resource])
});
})
```
### 5. **Form.vue - Formulario Compartido** (OBLIGATORIO)
- ✅ Props: `action` (create/update), `form` (objeto de formulario)
- ✅ Emit: `submit` evento
- ✅ Grid responsive: `grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4`
- ✅ Descripción dinámica: `transl(\`\${action}.description\`)`
- ✅ Slot para campos adicionales
- ✅ Botón de submit con estado de carga
### 6. **Modals/Show.vue** (OBLIGATORIO)
- ✅ `ShowModal` base component
- ✅ `defineExpose({ open: (data) => {...} })`
- ✅ Header con información principal
- ✅ Detalles organizados con iconos
- ✅ Enlaces de contacto (email, teléfono)
- ✅ Fechas formateadas con `getDateTime`
## 🎨 PATRONES DE DISEÑO OBLIGATORIOS
### **Imports Estándar**
```javascript
// Vue
import { onMounted, ref } from 'vue';
import { useRouter, useRoute } from 'vue-router';
// Services
import { api, useForm, useSearcher } from '@Services/Api';
// Module
import { apiTo, transl, viewTo, can } from './Module';
// Components
import IconButton from '@Holos/Button/Icon.vue'
import PageHeader from '@Holos/PageHeader.vue';
```
### **Control de Permisos**
```html
<!-- Siempre usar can() del módulo -->
<RouterLink v-if="can('create')" :to="viewTo({ name: 'create' })">
<IconButton v-if="can('destroy')" @click="destroyModal.open(model)">
```
### **Navegación Consistente**
```html
<!-- Header con botón de retorno -->
<PageHeader :title="transl('create.title')">
<RouterLink :to="viewTo({ name: 'index' })">
<IconButton icon="arrow_back" :title="$t('return')" filled />
</RouterLink>
</PageHeader>
```
### **Grid Responsive**
```html
<!-- Formularios siempre con esta estructura -->
<form class="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
```
## 🔧 COMPONENTES REUTILIZABLES OBLIGATORIOS
### **De @Holos:**
- `Button/Icon.vue`, `Button/Primary.vue`
- `Form/Input.vue`, `Form/Selectable.vue`
- `Modal/Template/Destroy.vue`, `Modal/Show.vue`
- `PageHeader.vue`, `Searcher.vue`, `Table.vue`
- `FormSection.vue`, `SectionBorder.vue`
### **De @Services:**
- `useForm`, `useSearcher`, `api`
### **De @Plugins:**
- `hasPermission`, sistema de roles
## 📝 INSTRUCCIONES PARA EL AGENTE
Cuando se te solicite crear un módulo:
1. **PREGUNTA PRIMERO:**
- Nombre del módulo
- Campos principales del formulario
- Relaciones con otros módulos
- Permisos específicos necesarios
- Si requiere configuraciones especiales
2. **GENERA SIEMPRE:**
- Todos los archivos de la estructura obligatoria
- Module.js con las 4 funciones principales
- Permisos específicos del módulo
- Traducciones básicas necesarias
3. **RESPETA SIEMPRE:**
- Los patrones de naming
- La estructura de componentes
- Los imports estándar
- El sistema de permisos
- La navegación consistente
4. **VALIDA QUE:**
- Todos los archivos tengan la estructura correcta
- Los permisos estén implementados
- Las rutas sean consistentes
- Los formularios tengan validación
- Los modales funcionen correctamente
¿Entendido? Responde "ESTRUCTURA CONFIRMADA" y luego solicita los detalles del módulo que debo crear.

View File

@ -1,34 +0,0 @@
@theme {
--color-page: #fff;
--color-page-t: #000;
--color-page-d: #292524;
--color-page-dt: #fff;
--color-primary: #374151;
--color-primary-t: #fff;
--color-primary-d: #1c1917;
--color-primary-dt: #fff;
--color-secondary: #3b82f6;
--color-secondary-t: #fff;
--color-secondary-d: #312e81;
--color-secondary-dt: #fff;
--color-primary-info: #06b6d4;
--color-primary-info-t: #fff;
--color-primary-info-d: #06b6d4;
--color-primary-info-dt: #fff;
--color-secondary-info: #06b6d4;
--color-secondary-info-t: #fff;
--color-secondary-info-d: #06b6d4;
--color-secondary-info-dt: #fff;
--color-success: #22c55e;
--color-success-t: #fff;
--color-success-d: #22c55e;
--color-success-dt: #fff;
--color-danger: #ef4444;
--color-danger-t: #fff;
--color-danger-d: #ef4444;
--color-danger-dt: #fecaca;
--color-warning: #eab308;
--color-warning-t: #fff;
--color-warning-d: #eab308;
--color-warning-dt: #fff;
}

56
components.d.ts vendored Normal file
View File

@ -0,0 +1,56 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AppConfig: typeof import('./src/components/layout/AppConfig.vue')['default']
AppTopbar: typeof import('./src/components/Holos/AppTopbar.vue')['default']
Avatar: typeof import('primevue/avatar')['default']
Badge: typeof import('primevue/badge')['default']
Breadcrumb: typeof import('primevue/breadcrumb')['default']
Button: typeof import('primevue/button')['default']
Card: typeof import('primevue/card')['default']
Checkbox: typeof import('primevue/checkbox')['default']
Chip: typeof import('primevue/chip')['default']
Column: typeof import('primevue/column')['default']
ConfirmDialog: typeof import('primevue/confirmdialog')['default']
DataTable: typeof import('primevue/datatable')['default']
Dialog: typeof import('primevue/dialog')['default']
Dropdown: typeof import('primevue/dropdown')['default']
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
IconField: typeof import('primevue/iconfield')['default']
InputGroup: typeof import('primevue/inputgroup')['default']
InputGroupAddon: typeof import('primevue/inputgroupaddon')['default']
InputIcon: typeof import('primevue/inputicon')['default']
InputNumber: typeof import('primevue/inputnumber')['default']
InputSwitch: typeof import('primevue/inputswitch')['default']
InputText: typeof import('primevue/inputtext')['default']
KpiCard: typeof import('./src/components/shared/KpiCard.vue')['default']
Menu: typeof import('primevue/menu')['default']
Message: typeof import('primevue/message')['default']
Paginator: typeof import('primevue/paginator')['default']
Panel: typeof import('primevue/panel')['default']
ProgressSpinner: typeof import('primevue/progressspinner')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
Select: typeof import('primevue/select')['default']
Sidebar: typeof import('./src/components/layout/Sidebar.vue')['default']
Tag: typeof import('primevue/tag')['default']
Textarea: typeof import('primevue/textarea')['default']
Toast: typeof import('primevue/toast')['default']
Toolbar: typeof import('primevue/toolbar')['default']
TopBar: typeof import('./src/components/layout/TopBar.vue')['default']
}
export interface GlobalDirectives {
StyleClass: typeof import('primevue/styleclass')['default']
Tooltip: typeof import('primevue/tooltip')['default']
}
}

View File

@ -1,19 +1,18 @@
services:
gols-frontend-v1:
controls-front:
build:
context: .
dockerfile: dockerfile
dockerfile: Dockerfile
args:
VITE_API_URL: ${VITE_API_URL}
container_name: front-controls
ports:
- "${APP_PORT}:5173"
volumes:
- .:/var/www/gols-frontend-v1
- frontend-v1:/var/www/gols-frontend-v1/node_modules
- "${APP_PORT}:80"
networks:
- gols-network
mem_limit: 512m
volumes:
frontend-v1:
driver: local
- controls-network
restart: unless-stopped
mem_limit: 512mb
networks:
gols-network:
controls-network:
driver: bridge

View File

@ -1,17 +0,0 @@
FROM node:22-alpine AS build
WORKDIR /var/www/gols-frontend-v1
COPY install.sh /usr/local/bin/install.sh
RUN chmod +x /usr/local/bin/install.sh
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
ENTRYPOINT ["sh","/usr/local/bin/install.sh"]
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

View File

@ -1,14 +1,13 @@
<!doctype html>
<html id="main-page" lang="es">
<html lang="es">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Holos</title>
<title>Golscontrols V1</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/index.js"></script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -1,13 +0,0 @@
#! /bin/bash
if [ ! -f .env ]; then
cp .env.example .env
fi
if [ ! -f colors.css ]; then
cp colors.css.example colors.css
fi
exec "$@"
echo "Done!"

23
nginx.conf Normal file
View File

@ -0,0 +1,23 @@
server {
listen 80;
server_name 127.0.0.1;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
# Optional: Cache static assets for better performance
location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?|eot|ttf|svg|otf)$ {
expires 6M;
access_log off;
add_header Cache-Control "public";
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

3114
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,44 +1,33 @@
{
"name": "notsoweb.frontend",
"copyright": "Notsoweb Software Inc.",
"name": "golscontros-frontend-v1",
"private": true,
"version": "0.9.12",
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@popperjs/core": "^2.11.8",
"@primeuix/themes": "^1.2.5",
"@tailwindcss/postcss": "^4.0.9",
"@tailwindcss/vite": "^4.0.9",
"@vitejs/plugin-vue": "^5.2.1",
"@vuepic/vue-datepicker": "^11.0.2",
"apexcharts": "^5.3.5",
"axios": "^1.8.1",
"laravel-echo": "^2.0.2",
"luxon": "^3.5.0",
"material-symbols": "^0.36.2",
"pdf-lib": "^1.17.1",
"pinia": "^3.0.1",
"@primevue/auto-import-resolver": "^4.4.1",
"@tailwindcss/vite": "^4.1.16",
"@vueuse/core": "^14.0.0",
"axios": "^1.13.2",
"pinia": "^3.0.4",
"primeicons": "^7.0.0",
"primevue": "^4.4.1",
"pusher-js": "^8.4.0",
"tailwindcss": "^4.0",
"toastr": "^2.1.4",
"uuid": "^11.1.0",
"v-calendar": "^3.1.2",
"vite": "^6.2.0",
"vue": "^3.5.13",
"vue-i18n": "^11.1.1",
"vue-multiselect": "^3.2.0",
"vue-router": "^4.5.0",
"vue3-apexcharts": "^1.8.0",
"ziggy-js": "^2.5.2"
"tailwindcss-primeui": "^0.6.1",
"unplugin-vue-components": "^30.0.0",
"vue": "^3.5.22",
"vue-router": "^4.6.3"
},
"devDependencies": {
"autoprefixer": "^10.4.20",
"vite-plugin-html": "^3.2.2"
"@types/node": "^24.6.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"typescript": "~5.9.3",
"vite": "^7.1.7",
"vue-tsc": "^3.1.0"
}
}

View File

@ -1,5 +0,0 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
}

8
src/App.vue Normal file
View File

@ -0,0 +1,8 @@
<script setup lang="ts">
// El router se encarga de manejar las vistas
</script>
<template>
<RouterView />
</template>

80
src/ColorDemo.vue Normal file
View File

@ -0,0 +1,80 @@
<script setup lang="ts">
import TopBar from './components/layout/TopBar.vue';
</script>
<template>
<div class="min-h-screen bg-surface-50 dark:bg-surface-950">
<TopBar />
<div class="p-6">
<div class="max-w-7xl mx-auto">
<h1 class="text-3xl font-bold text-surface-900 dark:text-surface-0 mb-4">
Personalización de Colores
</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Tarjeta de ejemplo 1 -->
<div class="bg-surface-0 dark:bg-surface-900 rounded-lg p-6 border border-surface-200 dark:border-surface-700">
<h2 class="text-xl font-semibold text-surface-900 dark:text-surface-0 mb-3">
Color Primario
</h2>
<p class="text-surface-600 dark:text-surface-400 mb-4">
El color primario azul se usa para botones, enlaces y elementos destacados.
</p>
<button class="px-4 py-2 bg-primary text-white rounded-md hover:bg-primary-600 transition-colors">
Botón Primario
</button>
</div>
<!-- Tarjeta de ejemplo 2 -->
<div class="bg-surface-0 dark:bg-surface-900 rounded-lg p-6 border border-surface-200 dark:border-surface-700">
<h2 class="text-xl font-semibold text-surface-900 dark:text-surface-0 mb-3">
Colores de Superficie
</h2>
<p class="text-surface-600 dark:text-surface-400 mb-4">
Los colores de superficie definen el fondo y los tonos neutros de la aplicación.
</p>
<div class="flex gap-2">
<div class="w-8 h-8 rounded bg-surface-100 dark:bg-surface-800" title="surface-100"></div>
<div class="w-8 h-8 rounded bg-surface-200 dark:bg-surface-700" title="surface-200"></div>
<div class="w-8 h-8 rounded bg-surface-300 dark:bg-surface-600" title="surface-300"></div>
<div class="w-8 h-8 rounded bg-surface-400 dark:bg-surface-500" title="surface-400"></div>
</div>
</div>
<!-- Tarjeta de ejemplo 3 -->
<div class="bg-surface-0 dark:bg-surface-900 rounded-lg p-6 border border-surface-200 dark:border-surface-700">
<h2 class="text-xl font-semibold text-surface-900 dark:text-surface-0 mb-3">
Modo Oscuro
</h2>
<p class="text-surface-600 dark:text-surface-400 mb-4">
Usa el botón en la barra superior para cambiar entre modo claro y oscuro.
</p>
<div class="flex items-center gap-3">
<i class="pi pi-moon text-2xl text-primary"></i>
<span class="text-surface-700 dark:text-surface-300">Tema adaptable</span>
</div>
</div>
<!-- Tarjeta de ejemplo 4 -->
<div class="bg-surface-0 dark:bg-surface-900 rounded-lg p-6 border border-surface-200 dark:border-surface-700">
<h2 class="text-xl font-semibold text-surface-900 dark:text-surface-0 mb-3">
Personalización
</h2>
<p class="text-surface-600 dark:text-surface-400 mb-4">
Haz clic en el ícono de paleta en la barra superior para cambiar el color de superficie.
</p>
<div class="flex items-center gap-3">
<i class="pi pi-palette text-2xl text-primary"></i>
<span class="text-surface-700 dark:text-surface-300">5 colores de superficie</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* Estilos adicionales si es necesario */
</style>

22
src/MainLayout.vue Normal file
View File

@ -0,0 +1,22 @@
<script setup lang="ts">
import TopBar from './components/layout/TopBar.vue';
import Sidebar from './components/layout/Sidebar.vue';
</script>
<template>
<div class="flex min-h-screen bg-surface-50 dark:bg-surface-950">
<!-- Sidebar -->
<Sidebar />
<!-- Main Content -->
<div class="flex-1 flex flex-col">
<!-- TopBar -->
<TopBar />
<!-- Page Content -->
<main class="flex-1 overflow-auto p-4 lg:p-6">
<RouterView />
</main>
</div>
</div>
</template>

View File

@ -0,0 +1,33 @@
@import "tailwindcss";
@import "tailwindcss-primeui";
@import "primeicons/primeicons.css";
@custom-variant dark (&:where(.p-dark, .p-dark *));
:root {
/* Primary Colors - Blue */
--p-primary-50: #eff6ff;
--p-primary-100: #dbeafe;
--p-primary-200: #bfdbfe;
--p-primary-300: #93c5fd;
--p-primary-400: #60a5fa;
--p-primary-500: #3b82f6;
--p-primary-600: #2563eb;
--p-primary-700: #1d4ed8;
--p-primary-800: #1e40af;
--p-primary-900: #1e3a8a;
--p-primary-950: #172554;
/* Surface Colors - Slate */
--p-surface-0: #ffffff;
--p-surface-50: #f8fafc;
--p-surface-100: #f1f5f9;
--p-surface-200: #e2e8f0;
--p-surface-300: #cbd5e1;
--p-surface-400: #94a3b8;
--p-surface-500: #64748b;
--p-surface-600: #475569;
--p-surface-700: #334155;
--p-surface-800: #1e293b;
--p-surface-900: #0f172a;
--p-surface-950: #020617;
}

View File

@ -1,23 +0,0 @@
<script setup>
import { onMounted } from 'vue';
import { useRouter } from 'vue-router';
import useLoader from '@Stores/Loader';
import { hasToken } from '@Services/Api';
/** Definidores */
const router = useRouter();
const loader = useLoader();
/** Ciclos */
onMounted(() => {
if(!hasToken()) {
return router.push({ name: 'auth.index' })
}
loader.boot()
})
</script>
<template>
<router-view />
</template>

View File

@ -0,0 +1,9 @@
<script setup>
</script>
<template>
<Button>
Hola mundo
<!-- Los componentes de PrimeVue se usan directamente sin importar -->
</Button>
</template>

View File

@ -1,22 +0,0 @@
<script setup>
import SectionTitle from './SectionTitle.vue';
</script>
<template>
<div class="md:grid md:grid-cols-3 md:gap-6">
<SectionTitle>
<template #title>
<slot name="title" />
</template>
<template #description>
<slot name="description" />
</template>
</SectionTitle>
<div class="mt-5 md:mt-0 md:col-span-2">
<div class="px-4 py-5 sm:p-6 shadow-sm dark:shadow-xs dark:shadow-white/50 sm:rounded-sm">
<slot name="content" />
</div>
</div>
</div>
</template>

View File

@ -1,5 +0,0 @@
<template>
<nav class="flex items-center py-1 gap-0.5">
<slot />
</nav>
</template>

View File

@ -1,55 +0,0 @@
<script setup>
import GoogleIcon from '@Shared/GoogleIcon.vue'
import { useRouter } from 'vue-router';
/** Definidores */
const router = useRouter();
/** Propiedades */
const props = defineProps({
name: String,
icon: String,
route: Object,
active: Boolean,
})
/** Métodos */
const handleClick = () => {
if (props.active) {
return;
}
router.push(props.route);
}
</script>
<template>
<button
v-if="!active"
:to="route"
class="inline-flex items-center gap-1.5 text-sm duration-300 ease-in p-1 cursor-pointer"
@click="handleClick"
>
<GoogleIcon
:name="icon"
class="text-sm"
/>
<span class="text-sm font-semibold hover:underline" v-text="name" />
</button>
<span v-if="!active" class="inline-block text-sm select-none pointer-events-none opacity-50">
<GoogleIcon
name="arrow_forward_ios"
class="text-xs font-semibold"
/>
</span>
<div
v-if="active"
class="inline-flex items-center gap-1.5 text-sm duration-300 ease-in p-1"
>
<GoogleIcon
:name="icon"
class="text-sm"
/>
<span class="font-semibold text-sm underline" v-text="name" />
</div>
</template>

View File

@ -1,13 +0,0 @@
<script setup>
import GoogleIcon from '@Shared/GoogleIcon.vue'
</script>
<template>
<span class="inline-block mx-1 text-sm select-none pointer-events-none opacity-50">
<GoogleIcon
name="arrow_forward_ios"
class="text-xs font-semibold"
/>
</span>
</template>

View File

@ -1,138 +0,0 @@
<template>
<button :type="type" :disabled="disabled || loading" :class="buttonClasses" @click="handleClick">
<svg v-if="loading" class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
<slot></slot>
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
variant?: 'solid' | 'outline' | 'ghost' | 'smooth';
color?: 'primary' | 'danger' | 'success' | 'info' | 'warning';
size?: 'sm' | 'md' | 'lg';
type?: 'button' | 'submit' | 'reset';
disabled?: boolean;
loading?: boolean;
fullWidth?: boolean;
iconOnly?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
variant: 'solid',
color: 'primary',
size: 'md',
type: 'button',
disabled: false,
loading: false,
fullWidth: false,
iconOnly: false,
});
const emit = defineEmits<{
click: [event: MouseEvent];
}>();
function handleClick(event: MouseEvent) {
if (props.disabled || props.loading) return;
emit('click', event);
}
const buttonClasses = computed(() => {
const baseClasses = [
'inline-flex',
'items-center',
'justify-center',
'font-medium',
'rounded-lg',
'transition-all',
'duration-200',
'focus:outline-none',
'focus:ring-2',
'focus:ring-offset-2',
'disabled:opacity-50',
'disabled:cursor-not-allowed',
];
const sizeClasses = {
sm: ['px-3', 'py-1.5', 'text-sm'],
md: ['px-4', 'py-2', 'text-sm'],
lg: ['px-6', 'py-3', 'text-base'],
};
// Estilos base por tipo
const variantClasses = {
solid: ['shadow-sm'],
outline: ['border', 'bg-white', 'hover:bg-gray-50'],
ghost: ['bg-transparent', 'hover:bg-gray-100'],
smooth: ['bg-opacity-20', 'font-bold', 'uppercase', 'shadow-none'],
};
// Colores por tipo
const colorClasses = {
primary: {
solid: ['bg-primary-600', 'text-white', 'hover:bg-primary-700', 'focus:ring-primary-500'],
outline: ['border-primary-600', 'text-primary-600', 'focus:ring-primary-500'],
ghost: ['text-primary-600', 'focus:ring-primary-500'],
smooth: ['bg-primary-100', 'text-primary-600', 'hover:bg-primary-200', 'focus:ring-primary-300'],
},
danger: {
solid: ['bg-red-600', 'text-white', 'hover:bg-red-700', 'focus:ring-red-500'],
outline: ['border-red-600', 'text-red-600', 'focus:ring-red-500'],
ghost: ['text-red-600', 'focus:ring-red-500'],
smooth: ['bg-red-100', 'text-red-600', 'hover:bg-red-200', 'focus:ring-red-300'],
},
success: {
solid: ['bg-green-600', 'text-white', 'hover:bg-green-700', 'focus:ring-green-500'],
outline: ['border-green-600', 'text-green-600', 'focus:ring-green-500'],
ghost: ['text-green-600', 'focus:ring-green-500'],
smooth: ['bg-green-100', 'text-green-600', 'hover:bg-green-200', 'focus:ring-green-300'],
},
info: {
solid: ['bg-blue-600', 'text-white', 'hover:bg-blue-700', 'focus:ring-blue-500'],
outline: ['border-blue-600', 'text-blue-600', 'focus:ring-blue-500'],
ghost: ['text-blue-600', 'focus:ring-blue-500'],
smooth: ['bg-blue-100', 'text-blue-600', 'hover:bg-blue-200', 'focus:ring-blue-300'],
},
warning: {
solid: ['bg-yellow-500', 'text-white', 'hover:bg-yellow-600', 'focus:ring-yellow-400'],
outline: ['border-yellow-500', 'text-yellow-600', 'focus:ring-yellow-400'],
ghost: ['text-yellow-600', 'focus:ring-yellow-400'],
smooth: ['bg-yellow-100', 'text-yellow-600', 'hover:bg-yellow-200', 'focus:ring-yellow-200'],
},
};
// Asegura que siempre haya un array válido para los estilos
const safeVariant = props.variant && variantClasses[props.variant] ? props.variant : 'solid';
const safeColor = props.color && colorClasses[props.color] ? props.color : 'primary';
const classes = [
...baseClasses,
...sizeClasses[props.size],
...(variantClasses[safeVariant] || []),
...(colorClasses[safeColor]?.[safeVariant] || []),
];
if (props.fullWidth) {
classes.push('w-full');
}
if (props.iconOnly) {
if (props.size === 'sm') classes.push('w-8', 'h-8', 'p-0');
else if (props.size === 'lg') classes.push('w-12', 'h-12', 'p-0');
else classes.push('w-10', 'h-10', 'p-0');
} else if (props.fullWidth) {
classes.push('w-full');
}
return classes;
});
</script>

View File

@ -1,27 +0,0 @@
<script setup>
import GoogleIcon from "@Shared/GoogleIcon.vue";
defineProps({
type: {
default: "button",
type: String,
},
icon: {
default: "add",
type: String,
},
text: {
default: "",
type: String,
}
});
</script>
<template>
<button
:type="type"
class="inline-flex items-center gap-3 bg-[#2563eb] hover:bg-[#1e40af] text-white px-4 py-2 rounded-full shadow-md"
>
<GoogleIcon :name="icon" />
<span>{{ text }}</span>
</button>
</template>

View File

@ -1,18 +0,0 @@
<script setup>
/** Propiedades */
defineProps({
type: {
default: 'submit',
type: String
}
});
</script>
<template>
<button
class="btn bg-danger"
:type="type"
>
<slot />
</button>
</template>

View File

@ -1,32 +0,0 @@
<script setup>
import GoogleIcon from '@Shared/GoogleIcon.vue'
/** Propiedades */
const props = defineProps({
icon: String,
fill: Boolean,
style: {
type: String,
default: 'rounded'
},
title: String,
type: {
type: String,
default: 'button'
}
});
</script>
<template>
<button
class="flex justify-center items-center h-7 w-7 rounded-sm btn-icon"
:title="title"
:type="type"
>
<GoogleIcon
:fill="fill"
:name="icon"
:style="style"
/>
</button>
</template>

View File

@ -1,18 +0,0 @@
<script setup>
/** Propiedades */
defineProps({
type: {
default: 'submit',
type: String
}
});
</script>
<template>
<button
class="btn btn-primary"
:type="type"
>
<slot />
</button>
</template>

View File

@ -1,18 +0,0 @@
<script setup>
/** Propiedades */
defineProps({
type: {
default: 'submit',
type: String
}
});
</script>
<template>
<button
class="btn btn-secondary"
:type="type"
>
<slot />
</button>
</template>

View File

@ -1,191 +0,0 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { Calendar } from 'v-calendar';
import 'v-calendar/style.css';
// Props del componente
const props = defineProps({
// Días de conflicto a pintar en rojo
conflictDays: {
type: Array,
default: () => []
},
// Configuración adicional
locale: {
type: String,
default: 'es'
}
});
// Emits
const emit = defineEmits(['monthChanged']);
// Variables reactivas
const currentMonth = ref(new Date());
const fromPage = ref(null);
// Atributos para el calendario (eventos, vacaciones, etc.)
const attributes = computed(() => {
const attrs = [];
// Agregar días de conflicto (pintarlos en rojo)
if (props.conflictDays && props.conflictDays.length > 0) {
props.conflictDays.forEach((day) => {
const currentYear = currentMonth.value.getFullYear();
const currentMonthNumber = currentMonth.value.getMonth();
const conflictDate = new Date(currentYear, currentMonthNumber, day);
attrs.push({
key: `conflict-${day}`,
dates: conflictDate,
highlight: {
color: 'red',
fillMode: 'solid'
},
popover: {
label: `Día con conflictos de vacaciones`,
visibility: 'hover'
}
});
});
}
return attrs;
});
// Configuración del calendario
const calendarConfig = computed(() => ({
locale: props.locale,
firstDayOfWeek: 2, // Lunes
masks: {
weekdays: 'WWW',
navMonths: 'MMMM',
title: 'MMMM YYYY'
},
theme: {
isDark: false
}
}));
// Métodos
const onPageChanged = (page) => {
// Actualizar el mes actual cuando se cambia de página
currentMonth.value = new Date(page[0].year, page[0].month - 1, 1);
emit('monthChanged', page[0].month);
};
// Watch para re-renderizar cuando cambien los días de conflicto o el mes actual
watch([() => props.conflictDays, currentMonth], () => {
// Los attributes se recalcularán automáticamente al cambiar estas dependencias
}, { deep: true });
// Exponer métodos para uso externo
defineExpose({
currentMonth
});
</script>
<template>
<div class="calendar-container bg-white rounded-lg shadow-md p-4">
<!-- Header del calendario -->
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">Calendario</h3>
</div>
<!-- Calendario principal -->
<div class="calendar-wrapper">
<Calendar
v-model="currentMonth"
:attributes="attributes"
:locale="calendarConfig.locale"
:first-day-of-week="calendarConfig.firstDayOfWeek"
:masks="calendarConfig.masks"
class="custom-calendar"
v-model:from-page="fromPage"
@update:from-page="onPageChanged"
@did-move="onPageChanged"
expanded
/>
</div>
</div>
</template>
<style scoped>
/* Estilos personalizados para el calendario */
.calendar-container {
min-width: 300px;
}
.custom-calendar {
width: 100%;
}
/* Personalización de v-calendar */
:deep(.vc-container) {
--vc-border-radius: 0.5rem;
--vc-weekday-color: #6B7280;
--vc-popover-content-bg: white;
--vc-popover-content-border: 1px solid #E5E7EB;
border: none;
font-family: inherit;
}
:deep(.vc-header) {
padding: 1rem 1rem 0.5rem;
}
:deep(.vc-title) {
font-size: 1rem;
font-weight: 600;
color: #1F2937;
}
:deep(.vc-weekday) {
color: #6B7280;
font-size: 0.75rem;
font-weight: 500;
padding: 0.5rem 0;
}
:deep(.vc-day) {
min-height: 2rem;
}
:deep(.vc-day-content) {
width: 2rem;
height: 2rem;
border-radius: 0.375rem;
border: none;
font-weight: 500;
transition: all 0.2s ease;
}
:deep(.vc-day-content:hover) {
background-color: #EFF6FF;
color: #2563EB;
}
:deep(.vc-day-content.vc-day-content-today) {
background-color: #DBEAFE;
color: #1D4ED8;
font-weight: 600;
}
:deep(.vc-highlights .vc-highlight) {
border-radius: 0.375rem;
}
:deep(.vc-dots) {
margin-bottom: 0.125rem;
}
/* Responsive */
@media (max-width: 640px) {
.calendar-container {
min-width: auto;
margin: 0 -1rem;
border-radius: 0;
}
}
</style>

View File

@ -1,606 +0,0 @@
<script setup>
import { ref, computed, nextTick, watch } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Propiedades */
const props = defineProps({
element: {
type: Object,
required: true
},
isSelected: {
type: Boolean,
default: false
}
});
/** Eventos */
const emit = defineEmits(['select', 'delete', 'update', 'move']);
/** Referencias */
const isEditing = ref(false);
const editValue = ref('');
const editInput = ref(null);
const editTextarea = ref(null);
const elementRef = ref(null);
const isDragging = ref(false);
const isResizing = ref(false);
const resizeDirection = ref(null); // 'corner', 'right', 'bottom'
const dragStart = ref({ x: 0, y: 0 });
const resizeStart = ref({ x: 0, y: 0, width: 0, height: 0 });
const fileInput = ref(null);
/** Propiedades computadas */
const elementStyles = computed(() => ({
left: `${props.element.x}px`,
top: `${props.element.y}px`,
width: `${props.element.width || 200}px`,
height: `${props.element.height || 40}px`
}));
/** Watchers */
watch(() => props.isSelected, (selected) => {
if (selected && isEditing.value) {
nextTick(() => {
if (props.element.type === 'text' && editInput.value) {
editInput.value.focus();
editInput.value.select();
} else if (props.element.type === 'code' && editTextarea.value) {
editTextarea.value.focus();
editTextarea.value.select();
}
});
}
});
/** Métodos */
const handleSelect = (event) => {
event.stopPropagation();
emit('select', props.element.id);
};
const handleDelete = () => {
emit('delete', props.element.id);
};
const startEditing = () => {
if (props.element.type === 'table' && props.element.content) {
// Deep copy para evitar mutaciones directas
editValue.value = JSON.parse(JSON.stringify(props.element.content));
} else if (props.element.type === 'code') {
editValue.value = props.element.content || 'console.log("Hola mundo");';
} else {
editValue.value = props.element.content || getDefaultEditValue();
}
isEditing.value = true;
nextTick(() => {
if (editTextarea.value) editTextarea.value.focus();
if (editInput.value) editInput.value.focus();
});
};
const finishEditing = () => {
if (isEditing.value) {
isEditing.value = false;
// Para tablas, emitir el objeto completo
if (props.element.type === 'table') {
emit('update', {
id: props.element.id,
content: editValue.value
});
} else {
emit('update', {
id: props.element.id,
content: editValue.value
});
}
}
};
const handleKeydown = (event) => {
if (props.element.type === 'text') {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
finishEditing();
} else if (event.key === 'Escape') {
isEditing.value = false;
editValue.value = props.element.content || 'Nuevo texto';
}
} else if (props.element.type === 'code') {
if (event.key === 'Escape') {
isEditing.value = false;
editValue.value = props.element.content || 'console.log("Hola mundo");';
}
// Para código, permitimos Enter normal y usamos Ctrl+Enter para terminar
if (event.key === 'Enter' && event.ctrlKey) {
event.preventDefault();
finishEditing();
}
} else if (props.element.type === 'table') {
if (event.key === 'Escape') {
isEditing.value = false;
// Restaurar el contenido original de la tabla
editValue.value = props.element.content ?
JSON.parse(JSON.stringify(props.element.content)) :
getDefaultEditValue();
}
// Para tablas, Enter normal para nueva línea en celda, Ctrl+Enter para terminar
if (event.key === 'Enter' && event.ctrlKey) {
event.preventDefault();
finishEditing();
}
}
};
// Manejo de archivo de imagen
const handleFileSelect = (event) => {
const file = event.target.files[0];
if (file && file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
emit('update', {
id: props.element.id,
content: e.target.result,
fileName: file.name
});
};
reader.readAsDataURL(file);
}
// Limpiar el input
event.target.value = '';
};
// Funcionalidad de arrastre
const handleMouseDown = (event) => {
if (isEditing.value || isResizing.value) return;
isDragging.value = true;
dragStart.value = {
x: event.clientX - props.element.x,
y: event.clientY - props.element.y
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
event.preventDefault();
};
const handleMouseMove = (event) => {
if (isDragging.value && !isResizing.value) {
const newX = event.clientX - dragStart.value.x;
const newY = event.clientY - dragStart.value.y;
emit('move', {
id: props.element.id,
x: Math.max(0, newX),
y: Math.max(0, newY)
});
} else if (isResizing.value && !isDragging.value) {
handleResizeMove(event);
}
};
const handleMouseUp = () => {
isDragging.value = false;
isResizing.value = false;
resizeDirection.value = null;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
// Funcionalidad de redimensionamiento por esquina
const startResize = (event) => {
event.stopPropagation();
event.preventDefault();
isResizing.value = true;
resizeDirection.value = 'corner';
resizeStart.value = {
x: event.clientX,
y: event.clientY,
width: props.element.width || 200,
height: props.element.height || 40
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
// Funcionalidad de redimensionamiento por bordes
const startResizeEdge = (event, direction) => {
event.stopPropagation();
event.preventDefault();
isResizing.value = true;
resizeDirection.value = direction;
resizeStart.value = {
x: event.clientX,
y: event.clientY,
width: props.element.width || 200,
height: props.element.height || 40
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
const handleResizeMove = (event) => {
if (!isResizing.value) return;
const deltaX = event.clientX - resizeStart.value.x;
const deltaY = event.clientY - resizeStart.value.y;
let newWidth = resizeStart.value.width;
let newHeight = resizeStart.value.height;
// Calcular nuevas dimensiones según la dirección
if (resizeDirection.value === 'corner') {
newWidth = Math.max(getMinWidth(), Math.min(getMaxWidth(), resizeStart.value.width + deltaX));
newHeight = Math.max(getMinHeight(), Math.min(getMaxHeight(), resizeStart.value.height + deltaY));
} else if (resizeDirection.value === 'right') {
newWidth = Math.max(getMinWidth(), Math.min(getMaxWidth(), resizeStart.value.width + deltaX));
} else if (resizeDirection.value === 'bottom') {
newHeight = Math.max(getMinHeight(), Math.min(getMaxHeight(), resizeStart.value.height + deltaY));
}
emit('update', {
id: props.element.id,
width: newWidth,
height: newHeight
});
};
// Obtener tamaños mínimos según el tipo de elemento
const getMinWidth = () => {
switch (props.element.type) {
case 'text':
return 100;
case 'image':
return 100;
case 'table':
return 200;
default:
return 100;
}
};
const getMinHeight = () => {
switch (props.element.type) {
case 'text':
return 30;
case 'image':
return 80;
case 'table':
return 80;
default:
return 30;
}
};
// Obtener tamaños máximos según el tipo de elemento
const getMaxWidth = () => {
return 800; // Máximo general
};
const getMaxHeight = () => {
return 600; // Máximo general
};
</script>
<template>
<div
ref="elementRef"
:style="elementStyles"
@click="handleSelect"
@dblclick="startEditing"
@mousedown="handleMouseDown"
class="absolute group select-none"
:class="{
'ring-2 ring-blue-500 ring-opacity-50': isSelected,
'cursor-move': !isEditing && !isResizing,
'cursor-text': isEditing && (element.type === 'text' || element.type === 'code'),
'cursor-se-resize': isResizing && resizeDirection === 'corner',
'cursor-e-resize': isResizing && resizeDirection === 'right',
'cursor-s-resize': isResizing && resizeDirection === 'bottom'
}"
>
<!-- Input oculto para selección de archivos -->
<input
ref="fileInput"
type="file"
accept="image/*"
@change="handleFileSelect"
class="hidden"
/>
<!-- Elemento de Texto -->
<div
v-if="element.type === 'text'"
class="w-full h-full flex items-center px-3 py-2 bg-blue-100 rounded border border-blue-300 text-blue-800 text-sm font-medium dark:bg-blue-900/30 dark:border-blue-600 dark:text-blue-300"
>
<input
v-if="isEditing"
ref="editInput"
v-model="editValue"
@blur="finishEditing"
@keydown="handleKeydown"
class="w-full bg-transparent outline-none cursor-text"
@mousedown.stop
/>
<span v-else class="truncate pointer-events-none">
{{ element.content || 'Nuevo texto' }}
</span>
</div>
<!-- Elemento de Imagen -->
<div
v-else-if="element.type === 'image'"
class="w-full h-full flex items-center justify-center bg-gray-100 rounded border border-gray-300 dark:bg-primary/10 dark:border-primary/20 overflow-hidden"
>
<!-- Si hay imagen cargada -->
<img
v-if="element.content && element.content.startsWith('data:image')"
:src="element.content"
:alt="element.fileName || 'Imagen'"
class="w-full h-full object-cover pointer-events-none"
/>
<!-- Placeholder para imagen -->
<div v-else class="flex flex-col items-center justify-center text-gray-400 dark:text-primary-dt/50 p-4">
<GoogleIcon name="image" class="text-2xl mb-1" />
<span class="text-xs text-center">Haz doble clic para cargar imagen</span>
</div>
</div>
<!-- Elemento de Código -->
<div
v-else-if="element.type === 'code'"
class="w-full h-full bg-gray-900 rounded border overflow-hidden"
>
<div class="w-full h-6 bg-gray-800 flex items-center px-2 text-xs text-gray-400 border-b border-gray-700">
<div class="flex gap-1">
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
<div class="w-2 h-2 bg-yellow-500 rounded-full"></div>
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
</div>
<span class="ml-2">{{ element.fileName || 'script.js' }}</span>
</div>
<div class="p-2 h-[calc(100%-24px)]">
<textarea
v-if="isEditing"
ref="editTextarea"
v-model="editValue"
@blur="finishEditing"
@keydown="handleKeydown"
class="w-full h-full bg-transparent text-green-400 text-xs font-mono outline-none resize-none cursor-text"
@mousedown.stop
/>
<pre v-else class="text-green-400 text-xs font-mono overflow-auto h-full pointer-events-none whitespace-pre-wrap">{{ element.content || 'console.log("Hola mundo");' }}</pre>
</div>
</div>
<!-- Elemento de Tabla -->
<div
v-else-if="element.type === 'table'"
class="w-full h-full bg-white rounded border overflow-hidden"
>
<div v-if="element.content && element.content.data" class="w-full h-full">
<table class="w-full h-full text-xs border-collapse">
<thead v-if="element.content.data.length > 0">
<tr class="bg-blue-50 dark:bg-blue-900/20">
<th
v-for="(header, colIndex) in element.content.data[0]"
:key="colIndex"
class="border border-gray-300 dark:border-primary/20 px-1 py-1 text-left font-semibold text-blue-800 dark:text-blue-300"
>
<input
v-if="isEditing"
v-model="editValue.data[0][colIndex]"
class="w-full bg-transparent outline-none text-xs"
@mousedown.stop
@click.stop
@focus.stop
/>
<span v-else class="truncate">{{ header }}</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, rowIndex) in element.content.data.slice(1)"
:key="rowIndex"
class="hover:bg-gray-50 dark:hover:bg-primary/5"
>
<td
v-for="(cell, colIndex) in row"
:key="colIndex"
class="border border-gray-300 dark:border-primary/20 px-1 py-1"
>
<input
v-if="isEditing"
v-model="editValue.data[rowIndex + 1][colIndex]"
class="w-full bg-transparent outline-none text-xs"
@mousedown.stop
@click.stop
@focus.stop
/>
<span v-else class="truncate text-gray-700 dark:text-primary-dt">{{ cell }}</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Placeholder para tabla vacía -->
<div v-else class="flex flex-col items-center justify-center text-gray-400 dark:text-primary-dt/50 p-4">
<GoogleIcon name="table_chart" class="text-2xl mb-1" />
<span class="text-xs text-center">Doble clic para editar tabla</span>
</div>
</div>
<!-- Controles del elemento -->
<div
v-if="isSelected && !isEditing"
class="absolute -top-8 right-0 flex gap-1 opacity-100 transition-opacity z-10"
>
<!-- Indicador de tamaño -->
<div class="px-2 py-1 bg-gray-800 text-white text-xs rounded shadow-sm pointer-events-none">
{{ Math.round(element.width || 200) }} × {{ Math.round(element.height || 40) }}
</div>
<!-- Botón para cargar imagen (solo para elementos de imagen) -->
<button
v-if="element.type === 'image'"
@click.stop="() => fileInput.click()"
class="w-6 h-6 bg-blue-500 hover:bg-blue-600 text-white rounded text-xs flex items-center justify-center transition-colors shadow-sm"
title="Cargar imagen"
>
<GoogleIcon name="upload" class="text-xs" />
</button>
<!-- Botón eliminar -->
<button
@click.stop="handleDelete"
class="w-6 h-6 bg-red-500 hover:bg-red-600 text-white rounded text-xs flex items-center justify-center transition-colors shadow-sm"
title="Eliminar"
>
<GoogleIcon name="close" class="text-xs" />
</button>
</div>
<!-- Controles de redimensionamiento mejorados -->
<div v-if="isSelected && !isEditing" class="absolute inset-0 pointer-events-none">
<!-- Esquina inferior derecha -->
<div
@mousedown.stop="startResize"
class="absolute -bottom-1 -right-1 w-2 h-2 bg-blue-500 border border-white cursor-se-resize pointer-events-auto rounded-sm resize-handle-corner"
title="Redimensionar"
></div>
<!-- Lado derecho -->
<div
@mousedown.stop="(event) => startResizeEdge(event, 'right')"
class="absolute top-1 bottom-1 -right-0.5 w-1 bg-blue-500 opacity-0 cursor-e-resize pointer-events-auto resize-handle-edge"
title="Redimensionar ancho"
></div>
<!-- Lado inferior -->
<div
@mousedown.stop="(event) => startResizeEdge(event, 'bottom')"
class="absolute -bottom-0.5 left-1 right-1 h-1 bg-blue-500 opacity-0 cursor-s-resize pointer-events-auto resize-handle-edge"
title="Redimensionar alto"
></div>
<!-- Puntos de agarre visuales en los bordes (solo visuales) -->
<div class="absolute top-1/2 -right-0.5 w-1 h-6 -translate-y-1/2 bg-blue-500 opacity-30 rounded-full pointer-events-none resize-handle-visual"></div>
<div class="absolute -bottom-0.5 left-1/2 w-6 h-1 -translate-x-1/2 bg-blue-500 opacity-30 rounded-full pointer-events-none resize-handle-visual"></div>
</div>
<!-- Indicador de arrastre -->
<div
v-if="isDragging"
class="absolute inset-0 bg-blue-500 opacity-20 rounded pointer-events-none"
></div>
<!-- Indicador de redimensionamiento -->
<div
v-if="isResizing"
class="absolute inset-0 bg-green-500 opacity-20 rounded pointer-events-none"
></div>
<!-- Tooltip con instrucciones -->
<div
v-if="isSelected && !element.content && element.type !== 'image' && !isResizing"
class="absolute -bottom-8 left-0 bg-gray-900 text-white text-xs px-2 py-1 rounded whitespace-nowrap z-10"
>
{{ element.type === 'text' ? 'Doble clic para editar texto' : 'Doble clic para editar código' }}
{{ element.type === 'code' ? ' (Ctrl+Enter para guardar)' : '' }}
</div>
<!-- Tooltip con instrucciones de redimensionamiento -->
<div
v-if="isSelected && !isEditing && element.type === 'image' && !element.content && !isResizing"
class="absolute -bottom-8 left-0 bg-gray-900 text-white text-xs px-2 py-1 rounded whitespace-nowrap z-10"
>
Arrastra las esquinas para redimensionar
</div>
<!-- Botón para terminar edición de tabla -->
<div
v-if="isEditing && element.type === 'table'"
class="absolute -bottom-10 left-0 flex gap-2 z-20"
>
<button
@click="finishEditing"
class="px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-xs rounded shadow-sm transition-colors"
>
Guardar (Ctrl+Enter)
</button>
<button
@click="() => { isEditing = false; editValue = JSON.parse(JSON.stringify(element.content)); }"
class="px-3 py-1 bg-gray-600 hover:bg-gray-700 text-white text-xs rounded shadow-sm transition-colors"
>
Cancelar (Esc)
</button>
</div>
</div>
</template>
<style scoped>
/* Estilos para los controles de redimensionamiento mejorados */
.resize-handle-corner {
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.resize-handle-corner:hover {
background-color: #2563eb;
transform: scale(1.1);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
.resize-handle-edge {
transition: all 0.2s ease;
}
.resize-handle-edge:hover {
opacity: 0.7 !important;
background-color: #2563eb;
}
.resize-handle-visual {
transition: all 0.2s ease;
}
/* Efecto hover para los indicadores visuales */
.group:hover .resize-handle-visual {
opacity: 0.6 !important;
}
.group:hover .resize-handle-edge {
opacity: 0.4 !important;
}
/* Prevenir selección de texto durante el redimensionamiento */
.select-none {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
/* Animación suave para los controles */
@keyframes pulse-resize {
0%, 100% {
opacity: 0.3;
}
50% {
opacity: 0.7;
}
}
.group:hover .resize-handle-visual {
animation: pulse-resize 2s infinite;
}
</style>

View File

@ -1,83 +0,0 @@
<script setup>
import { getDate } from '@Controllers/DateController';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Props */
const props = defineProps({
records: {
type: Array,
default: () => []
}
});
/** Eventos */
const emit = defineEmits(['destroy', 'edit']);
/** Métodos */
function openDestroy(record) {
emit('destroy', record);
}
function openEdit(record) {
emit('edit', record);
}
</script>
<template>
<div class="mt-6">
<h3 class="text-base font-medium text-gray-800 flex items-center gap-2 dark:text-primary-dt">
<GoogleIcon
class="text-black dark:text-primary-dt text-xl"
name="book"
/>
Grados Académicos
</h3>
<div class="mt-4 grid gap-4 grid-cols-1 md:grid-cols-2">
<!-- Item grado dinámico -->
<article
v-for="record in records"
:key="record.id"
class="rounded-lg border border-gray-100 bg-white p-4 relative dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt"
>
<div class="flex items-start justify-between">
<div>
<h4 class="text-sm font-semibold text-gray-800 dark:text-primary-dt">{{ record.title }}</h4>
<p class="text-xs text-gray-500 mt-2 dark:text-primary-dt/70">{{ record.institution }}</p>
<p class="text-xs text-gray-400 mt-2 dark:text-primary-dt/70">Fecha de obtención: {{ record.date_obtained ? getDate(record.date_obtained) : '-' }}</p>
</div>
<div class="flex items-center gap-2">
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-gray-100 text-gray-700 dark:bg-primary/10 dark:text-primary-dt">
{{ record.degree_type_ek }}
</span>
<button
@click="openEdit(record)"
class="p-1 text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
title="Editar grado académico"
>
<GoogleIcon name="edit" class="w-4 h-4" />
</button>
<button
@click="openDestroy(record)"
class="p-1 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
title="Eliminar grado académico"
>
<GoogleIcon name="delete" class="w-4 h-4" />
</button>
</div>
</div>
</article>
<!-- Estado vacío para grados académicos -->
<div v-if="!records || records.length === 0" class="col-span-2 py-8 text-center">
<div class="text-gray-500 dark:text-primary-dt/70">
<GoogleIcon name="school" class="w-12 h-12 mx-auto mb-4 text-gray-400" />
<p class="text-lg font-medium">No se encontraron grados académicos</p>
<p class="text-sm mt-1">Intenta ajustar los filtros de búsqueda</p>
</div>
</div>
</div>
</div>
</template>

View File

@ -1,42 +0,0 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
variant: {
type: String,
default: 'outline',
validator: (value) => ['default', 'outline', 'ghost'].includes(value)
},
className: {
type: String,
default: '',
}
});
const cardClasses = computed(() => {
const baseClasses = [
'rounded-lg',
'shadow-sm',
'transition-all',
'duration-200',
];
const variantClasses = {
default: ['border', 'bg-white', 'dark:bg-gray-800', 'text-gray-900', 'dark:text-gray-100'],
outline: ['border-2', 'border-gray-100', 'dark:border-gray-700', 'bg-transparent'],
ghost: ['shadow-none', 'border-none', 'bg-transparent'],
};
return [
...baseClasses,
...variantClasses[props.variant],
props.className,
].filter(Boolean);
});
</script>
<template>
<div :class="cardClasses">
<slot />
</div>
</template>

View File

@ -1,22 +0,0 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
className: {
type: String,
default: '',
}
});
const contentClasses = computed(() => [
'p-6',
'pt-0',
props.className,
].filter(Boolean));
</script>
<template>
<div :class="contentClasses">
<slot />
</div>
</template>

View File

@ -1,23 +0,0 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
className: {
type: String,
default: '',
}
});
const descriptionClasses = computed(() => [
'text-sm',
'text-gray-600',
'dark:text-gray-400',
props.className,
].filter(Boolean));
</script>
<template>
<p :class="descriptionClasses">
<slot />
</p>
</template>

View File

@ -1,24 +0,0 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
className: {
type: String,
default: '',
}
});
const footerClasses = computed(() => [
'flex',
'items-center',
'p-6',
'pt-0',
props.className,
].filter(Boolean));
</script>
<template>
<div :class="footerClasses">
<slot />
</div>
</template>

View File

@ -1,24 +0,0 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
className: {
type: String,
default: '',
}
});
const headerClasses = computed(() => [
'flex',
'flex-col',
'space-y-1.5',
'p-6',
props.className,
].filter(Boolean));
</script>
<template>
<div :class="headerClasses">
<slot />
</div>
</template>

View File

@ -1,24 +0,0 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
className: {
type: String,
default: '',
}
});
const titleClasses = computed(() => [
'text-2xl',
'font-semibold',
'leading-none',
'tracking-tight',
props.className,
].filter(Boolean));
</script>
<template>
<h3 :class="titleClasses">
<slot />
</h3>
</template>

View File

@ -1,121 +0,0 @@
<script setup>
import GoogleIcon from '@Shared/GoogleIcon.vue';
import { getDate } from '@Controllers/DateController';
/** Props */
const props = defineProps({
certifications: {
type: Array,
default: () => []
}
});
/** Eventos */
const emit = defineEmits(['destroy', 'edit']);
/** Métodos */
function openDestroy(cert) {
emit('destroy', cert);
}
function openEdit(cert) {
emit('edit', cert);
}
/** Métodos */
function getCertificationStatus(cert) {
// Si no tiene fecha de expiración, se considera permanente
if (!cert.date_expiration) {
return {
status: 'permanente',
statusText: 'Permanente',
isExpired: false
};
}
const now = new Date();
const expirationDate = new Date(cert.date_expiration);
// Si la fecha actual es mayor a la fecha de expiración, está vencida
const isExpired = now > expirationDate;
return {
status: isExpired ? 'vencida' : 'vigente',
statusText: isExpired ? 'Vencida' : 'Vigente',
isExpired
};
}
</script>
<template>
<div class="mt-8">
<h3 class="text-base font-medium text-gray-800 flex items-center gap-2 dark:text-primary-dt">
<GoogleIcon
class="text-black dark:text-primary-dt text-xl"
name="license"
/>
Certificaciones
</h3>
<div class="mt-4 grid gap-4 grid-cols-1 md:grid-cols-2">
<!-- Certificación dinámica -->
<article
v-for="cert in certifications"
:key="cert.id"
:class="[
'rounded-lg border p-4 relative',
getCertificationStatus(cert).status === 'vigente'
? 'border-gray-100 bg-green-50/40 dark:bg-success-d/10 dark:border-primary/20 dark:text-primary-dt'
: 'border-gray-100 bg-white dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt'
]"
>
<div class="flex items-start justify-between">
<div>
<h4 class="text-sm font-semibold text-gray-800 dark:text-primary-dt">{{ cert.name }}</h4>
<p class="text-xs text-gray-500 mt-2 dark:text-primary-dt/70">{{ cert.institution }}</p>
<p class="text-xs text-gray-500 mt-1 dark:text-primary-dt/70">Obtenida: {{ cert.date_obtained ? getDate(cert.date_obtained) : '-' }}</p>
<p class="text-xs text-gray-500 dark:text-primary-dt/70">Vigencia: {{ cert.date_expiration ? getDate(cert.date_expiration) : 'Permanente' }}</p>
</div>
<div class="flex items-center gap-2">
<span
:class="[
'inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold',
getCertificationStatus(cert).status === 'vigente'
? 'bg-emerald-100 text-emerald-700 dark:bg-success-d dark:text-success-dt'
: getCertificationStatus(cert).status === 'permanente'
? 'bg-blue-100 text-blue-700 dark:bg-info-d dark:text-info-dt'
: 'bg-amber-100 text-amber-800 dark:bg-warning-d dark:text-warning-dt'
]"
>
{{ getCertificationStatus(cert).statusText }}
</span>
<button
@click="openEdit(cert)"
class="p-1 text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
title="Editar certificación"
>
<GoogleIcon name="edit" class="w-4 h-4" />
</button>
<button
@click="openDestroy(cert)"
class="p-1 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
title="Eliminar certificación"
>
<GoogleIcon name="delete" class="w-4 h-4" />
</button>
</div>
</div>
</article>
<!-- Estado vacío para certificaciones -->
<div v-if="!certifications || certifications.length === 0" class="col-span-2 py-8 text-center">
<div class="text-gray-500 dark:text-primary-dt/70">
<GoogleIcon name="license" class="w-12 h-12 mx-auto mb-4 text-gray-400" />
<p class="text-lg font-medium">No se encontraron certificaciones</p>
<p class="text-sm mt-1">Intenta ajustar los filtros de búsqueda</p>
</div>
</div>
</div>
</div>
</template>

View File

@ -1,34 +0,0 @@
<script setup>
import { RouterLink } from 'vue-router';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Propiedades */
defineProps({
icon: String,
title: String,
to: String,
value: Number
});
</script>
<template>
<RouterLink
class="relative flex-1 flex flex-col gap-2 p-4 rounded-sm -md bg-gray-200 dark:bg-transparent dark:border"
:to="to"
>
<label class="text-base font-semibold tracking-wider">
{{ title }}
</label>
<label class="text-primary dark:text-primary-dt text-4xl font-bold">
{{ value }}
</label>
<div class="absolute bg-primary dark:bg-primary-d rounded-md font-semibold text-xs text-gray-100 p-2 right-4 bottom-4">
<GoogleIcon
class="text-3xl md:text-2xl lg:text-3xl"
:name="icon"
filled
/>
</div>
</RouterLink>
</template>

View File

@ -1,19 +0,0 @@
<script setup>
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Propiedades */
defineProps({
icon: String,
title: String
});
</script>
<template>
<div class="flex flex-col justify-center items-center p-4 bg-primary text-white rounded-md">
<GoogleIcon
class="text-4xl"
:name="icon"
/>
<h4>{{ title }}</h4>
</div>
</template>

View File

@ -1,36 +0,0 @@
<script setup>
import { computed } from 'vue';
const emit = defineEmits(['update:checked']);
const props = defineProps({
checked: {
type: [Array, Boolean],
default: false,
},
value: {
type: String,
default: null,
},
});
const proxyChecked = computed({
get() {
return props.checked;
},
set(val) {
emit('update:checked', val);
},
});
</script>
<template>
<input
v-model="proxyChecked"
type="checkbox"
:value="value"
class="rounded-sm border-gray-300 text-indigo-600 shadow-xs focus:ring-indigo-500"
>
</template>

View File

@ -1,99 +0,0 @@
<script setup>
import { ref, nextTick } from 'vue';
import { useForm } from '@Services/Api';
import Input from './Form/Input.vue';
import DialogModal from './Modal/Elements/Base.vue';
import PrimaryButton from './Button/Primary.vue';
import SecondaryButton from './Button/Secondary.vue';
const emit = defineEmits(['confirmed']);
defineProps({
title: {
type: String,
default: Lang('confirm'),
},
content: {
type: String,
default: Lang('account.password.verify'),
},
button: {
type: String,
default: Lang('confirm'),
},
});
const confirmingPassword = ref(false);
const form = useForm({
password: '',
});
const passwordInput = ref(null);
const startConfirmingPassword = () => {
confirmingPassword.value = true;
};
const confirmPassword = () => {
form.post(route('user.password-confirm'), {
onSuccess: () => {
closeModal();
nextTick(() => emit('confirmed'));
},
onFail: () => {
passwordInput.value.focus();
}
});
};
const closeModal = () => {
confirmingPassword.value = false;
form.password = '';
};
</script>
<template>
<span>
<span @click="startConfirmingPassword">
<slot />
</span>
<DialogModal :show="confirmingPassword" @close="closeModal">
<template #title>
{{ title }}
</template>
<template #content>
{{ content }}
{{ form }}
<div class="mt-4">
<Input
v-model="form.password"
id="password"
type="password"
:onError="form.errors.password"
/>
</div>
</template>
<template #footer>
<SecondaryButton @click="closeModal">
{{ $t('cancel') }}
</SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
@click="confirmPassword"
>
{{ button }}
</PrimaryButton>
</template>
</DialogModal>
</span>
</template>

View File

@ -1,64 +0,0 @@
<script setup>
import { ref } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Propiedades */
const props = defineProps({
type: {
type: String,
required: true,
},
icon: String,
title: String,
description: String,
});
/** Eventos */
const emit = defineEmits(['dragstart']);
/** Referencias */
const isDragging = ref(false);
/** Métodos */
const handleDragStart = (event) => {
isDragging.value = true;
event.dataTransfer.setData('text/plain', JSON.stringify({
type: props.type,
title: props.title
}));
emit('dragstart', props.type);
};
const handleDragEnd = () => {
isDragging.value = false;
};
</script>
<template>
<div
draggable="true"
@dragstart="handleDragStart"
@dragend="handleDragEnd"
class="flex items-center gap-3 p-3 rounded-lg border border-gray-200 bg-white cursor-grab hover:bg-gray-50 hover:border-blue-300 transition-colors dark:bg-primary-d dark:border-primary/20 dark:hover:bg-primary/10"
:class="{
'opacity-50 cursor-grabbing': isDragging,
'shadow-sm hover:shadow-md': !isDragging
}"
>
<div class="flex-shrink-0 w-8 h-8 rounded-md bg-blue-100 flex items-center justify-center dark:bg-blue-900/30">
<GoogleIcon
:name="icon"
class="text-blue-600 dark:text-blue-400 text-lg"
/>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-900 dark:text-primary-dt">
{{ title }}
</div>
<div class="text-xs text-gray-500 dark:text-primary-dt/70 truncate">
{{ description }}
</div>
</div>
</div>
</template>

View File

@ -1,104 +0,0 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
const props = defineProps({
align: {
default: 'right',
type: String
},
contentClasses: {
default: () => [
'pt-1',
'!bg-white dark:!bg-primary-d !text-gray-800 dark:!text-primary-dt'
],
type: Array
},
width: {
default: '48',
type: String
}
});
const open = ref(false);
const closeOnEscape = (e) => {
if (open.value && e.key === 'Escape') {
open.value = false;
}
};
onMounted(() => document.addEventListener('keydown', closeOnEscape));
onUnmounted(() => document.removeEventListener('keydown', closeOnEscape));
const widthClass = computed(() => {
return {
'48': 'w-48',
'52': 'w-52',
'56': 'w-56',
'60': 'w-60',
'64': 'w-64',
'72': 'w-52 md:w-72',
}[props.width.toString()];
});
const alignmentClasses = computed(() => {
if (props.align === 'left') {
return 'origin-top-left left-0';
}
if (props.align === 'right') {
return 'origin-top-right right-0';
}
if (props.align === 'icon') {
const size = {
'48': '-right-20',
'52': '-right-22',
'56': '-right-24',
'60': '-right-26',
'64': '-right-28',
'72': '-right-36',
}[props.width.toString()];
return `origin-top-right ${size}`;
}
return 'origin-top';
});
</script>
<template>
<div class="relative">
<div @click="open = ! open">
<slot name="trigger" />
</div>
<!-- Full Screen Dropdown Overlay -->
<div
v-show="open"
class=" fixed inset-0 z-40"
@click="open = false"
/>
<transition
enter-active-class="transition ease-out duration-200"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<div
v-show="open"
class="absolute z-[1000] mt-2 rounded-t-md shadow-lg"
:class="[widthClass, alignmentClasses]"
style="display: none;"
@click="open = false"
>
<div class="rounded-sm ring-1 ring-black/5" :class="contentClasses">
<slot name="content" />
</div>
</div>
</transition>
</div>
</template>

View File

@ -1,39 +0,0 @@
<script setup>
import { RouterLink } from 'vue-router'
defineProps({
as: String,
to: String
});
const style = 'block px-4 py-2 text-sm leading-5 hover:bg-secondary/80 dark:hover:bg-secondary-d/80 focus:outline-hidden focus:bg-gray-100 cursor-pointer transition';
</script>
<template>
<div>
<button
v-if="as == 'button'"
class="w-full text-left"
:class="style"
type="submit"
>
<slot />
</button>
<a
v-else-if="as =='a'"
:href="href"
:class="style"
>
<slot />
</a>
<RouterLink
v-else
:to="$view({ name: to })"
:class="style"
>
<slot />
</RouterLink>
</div>
</template>

View File

@ -1,44 +0,0 @@
<script setup>
import { computed } from 'vue';
import { v4 as uuidv4 } from 'uuid';
const emit = defineEmits([
'update:modelValue'
]);
const uuid = uuidv4();
const props = defineProps({
title: String,
modelValue: Object | Boolean,
value: Object | Boolean,
});
const vModel = computed({
get() {
return props.modelValue;
},
set(value) {
emit('update:modelValue', value);
},
});
</script>
<template>
<div class="relative w-full h-8">
<input
class="appearance-none rounded-sm bg-primary cursor-pointer h-full w-full checked:bg-secondary dark:checked:bg-secondary-d transition-all duration-200 peer"
type="checkbox"
:id="uuid"
v-model="vModel"
:value="value"
/>
<label
:for="uuid"
class="absolute top-[50%] left-3 text-primary-t dark:text-primary-dt -translate-y-[50%] peer-checked:text-white dark:peer-checked:text-primary-dt transition-all duration-200 select-none"
>
{{ title }}
</label>
</div>
</template>

View File

@ -1,14 +0,0 @@
<script setup>
/** Propiedades */
defineProps({
onError: String | Array
});
</script>
<template>
<p v-if="onError"
class="mt-1 pl-2 text-xs text-red-500 dark:text-red-300"
>
{{ Array.isArray(onError) ? onError[0] : onError }}
</p>
</template>

View File

@ -1,22 +0,0 @@
<script setup>
/** Propiedades */
defineProps({
id: String,
title: String,
required: Boolean
});
</script>
<template>
<label v-if="title"
class="block text-sm font-medium text-page-t dark:text-page-dt"
:for="id"
>
{{ $t(title) }}
<span v-if="required"
class="text-danger dark:text-danger-d"
>
*
</span>
</label>
</template>

View File

@ -1,107 +0,0 @@
<script setup>
import { ref } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue'
import Label from './Elements/Label.vue';
import SecondaryButton from '../Button/Secondary.vue';
/** Eventos */
const emit = defineEmits([
'photoInput'
]);
/** Propiedades */
const props = defineProps({
accept: {
default: 'image/png, image/jpeg',
type: String
},
class: String,
required: Boolean,
title: {
default: 'photo.title',
type: String
}
});
const fileType = ref(null);
const photoInput = ref(null);
const photoPreview = ref(null);
/** Métodos */
const selectNewPhoto = () => {
photoInput.value.click();
};
const updatePhotoPreview = () => {
const image_file = photoInput.value.files[0];
if (! image_file) return;
emit('photoInput', image_file);
fileType.value = image_file.type;
if(image_file.type == "application/pdf"){
photoPreview.value = image_file.name;
}else{
const reader = new FileReader();
reader.onload = (e) => {
photoPreview.value = e.target.result;
};
reader.readAsDataURL(image_file);
}
};
</script>
<template>
<div class="col-span-6">
<input
ref="photoInput"
class="hidden"
type="file"
:accept="accept"
:required="required"
@change="updatePhotoPreview"
>
<Label
id="image_file"
class="dark:text-gray-800"
:required="required"
:title="title"
/>
<div v-show="! photoPreview" class="mt-2">
<!-- si existe una imagen cargada, entonces se muestra en este slot -->
<slot />
</div>
<div v-show="photoPreview" class="mt-2">
<div v-if="fileType == 'application/pdf'" class="flex overflow-hidden max-w-full">
<GoogleIcon
class="text-gray-400"
name="picture_as_pdf"
:title="$t('crud.edit')"
outline
/>
<div class="ml-2 font-bold text-gray-400 flex-1">
{{ photoPreview }}
</div>
</div>
<div v-else>
<span
class="block rounded-lg h-40 bg-cover bg-no-repeat bg-center"
:class="class"
:style="'background-image: url(\'' + photoPreview + '\');'"
/>
</div>
</div>
<SecondaryButton
v-text="$t('photo.new')"
class="mt-2 mr-2"
type="button"
@click.prevent="selectNewPhoto"
/>
</div>
</template>

View File

@ -1,88 +0,0 @@
<script setup>
import { v4 as uuidv4 } from 'uuid';
import { computed, onMounted, ref } from 'vue';
import Error from './Elements/Error.vue';
import Label from './Elements/Label.vue';
/** Opciones */
defineOptions({
inheritAttrs: false
})
/** Eventos */
const emit = defineEmits([
'update:modelValue'
]);
/** Propiedades */
const props = defineProps({
class: String,
disabled: Boolean,
id: String,
modelValue: Number | String,
onError: String | Array,
placeholder: String,
required: Boolean,
title: String,
type: {
default: 'text',
type: String
}
});
const input = ref(null);
/** Propiedades calculadas */
const autoId = computed(() => {
return (props.id)
? props.id
: uuidv4()
})
const autoTitle = computed(() => {
if(props.title) {
return props.title;
}
return props.id;
});
/** Exposiciones */
defineExpose({
focus: () => input.value.focus()
});
/** Ciclos */
onMounted(() => {
if (input.value.hasAttribute('autofocus')) {
input.value.focus();
}
});
</script>
<template>
<div class="w-full">
<Label
:id="autoId"
:required="required"
:title="autoTitle"
/>
<input
v-bind="$attrs"
ref="input"
class="input-primary"
:class="{ 'cursor-not-allowed': disabled }"
:disabled="disabled"
:id="autoId"
:placeholder="placeholder"
:required="required"
:type="type"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
>
<Error
:onError="onError"
/>
</div>
</template>

View File

@ -1,88 +0,0 @@
<script setup>
import { v4 as uuidv4 } from 'uuid';
import { computed, onMounted, ref } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Error from './Elements/Error.vue';
/** Opciones */
defineOptions({
inheritAttrs: false
})
/** Eventos */
const emit = defineEmits([
'update:modelValue'
]);
/** Propiedades */
const props = defineProps({
class: String,
disabled: Boolean,
id: String,
icon: String,
modelValue: Number | String,
onError: String | Array,
placeholder: String,
required: Boolean,
title: String,
type: {
default: 'text',
type: String
}
});
const input = ref(null);
/** Propiedades computadas */
const autoId = computed(() => {
return (props.id)
? props.id
: uuidv4()
})
const value = computed({
get() {
return props.modelValue
},
set(value) {
emit('update:modelValue', value)
}
})
/** Exposiciones */
defineExpose({
focus: () => input.value.focus()
});
/** Ciclos */
onMounted(() => {
if (input.value.hasAttribute('autofocus')) {
input.value.focus();
}
});
</script>
<template>
<div class="mb-4">
<div class="flex items-center border-2 py-2 px-3 rounded-sm">
<GoogleIcon
:name="icon"
/>
<input
ref="input"
v-model="value"
v-bind="$attrs"
class="pl-2 w-full outline-hidden border-none bg-transparent"
:class="{ 'cursor-not-allowed': disabled }"
:disabled="disabled"
:id="autoId"
:placeholder="placeholder"
:type="type"
/>
</div>
<Error
:onError="onError"
/>
</div>
</template>

View File

@ -1,89 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import VueMultiselect from 'vue-multiselect';
import Error from './Elements/Error.vue';
import Label from './Elements/Label.vue';
/** Eventos */
const emit = defineEmits([
'select',
'update:modelValue'
]);
/** Propiedades */
const props = defineProps({
customLabel: String,
disabled: Boolean,
label: {
default: 'name',
type: String
},
modelValue: String | Number,
multiple: Boolean,
onError: String | Array,
options: Object,
placeholder: {
default: 'Buscar ...',
type: String
},
required: Boolean,
trackBy: {
default: 'id',
type: String
},
title: String,
});
const multiselect = ref();
/** Propiedades computadas */
const value = computed({
get() {
return props.modelValue
},
set(value) {
emit('update:modelValue', value)
}
})
/** Exposiciones */
defineExpose({
clean: () => multiselect.value.removeLastElement()
});
</script>
<template>
<div class="flex flex-col">
<Label
:required="required"
:title="title"
/>
<VueMultiselect
ref="multiselect"
v-model="value"
deselectLabel="Remover"
selectedLabel="Seleccionado"
selectLabel="Seleccionar"
:clear-on-select="false"
:close-on-select="true"
:custom-label="customLabel"
:disabled="disabled"
:label="label"
:multiple="multiple"
:options="options"
:placeholder="placeholder"
:preserve-search="true"
:required="required && !value"
:track-by="trackBy"
@select="(x, y) => emit('select', x, y)"
>
<template #noOptions>
{{ $t('noRecords') }}
</template>
</VueMultiselect>
<Error
:onError="onError"
/>
</div>
</template>

View File

@ -1,103 +0,0 @@
<script setup>
import { ref } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue'
import Label from './Elements/Label.vue';
import SecondaryButton from '../Button/Secondary.vue';
/** Eventos */
const emit = defineEmits([
'update:modelValue'
]);
/** Propiedades */
const props = defineProps({
accept: {
default: 'image/png, image/jpeg',
type: String
},
class: String,
modelValue:Object|String,
required: Boolean,
title: {
default: 'photo.title',
type: String
}
});
const fileType = ref(null);
const fileName = ref(null);
const photoInput = ref(null);
const photoPreview = ref(null);
/** Métodos */
const selectNewPhoto = () => {
photoInput.value.click();
};
const updatePhotoPreview = () => {
const image_file = photoInput.value.files[0];
if (! image_file) return;
emit('update:modelValue', image_file);
fileType.value = image_file.type;
fileName.value = image_file.name;
const reader = new FileReader();
reader.onload = (e) => {
photoPreview.value = e.target.result;
};
reader.readAsDataURL(image_file);
};
</script>
<template>
<div class="col-span-6">
<input
ref="photoInput"
type="file"
class="hidden"
:accept="accept"
:required="required"
@change="updatePhotoPreview"
>
<Label
id="image_file"
:title="title"
:required="required"
/>
<div v-show="! photoPreview" class="mt-2">
<!-- si existe una imagen cargada, entonces se muestra en este slot -->
<slot name="previous"/>
</div>
<div v-show="photoPreview" class="mt-2">
<div class="flex overflow-hidden max-w-full">
<GoogleIcon
class="text-gray-400"
name="picture_as_pdf"
:title="$t('crud.edit')"
outline
/>
<div class="ml-2 font-bold text-gray-400 flex-1">
<a
target="_blank"
:href="photoPreview"
>
{{ fileName }}
</a>
</div>
</div>
</div>
<SecondaryButton
v-text="$t('files.select')"
class="mt-2 mr-2"
type="button"
@click.prevent="selectNewPhoto"
/>
</div>
</template>

View File

@ -1,67 +0,0 @@
<script setup>
import { v4 as uuidv4 } from 'uuid';
import { computed } from 'vue';
/** Eventos */
const emit = defineEmits([
'update:checked'
]);
/** Propiedades */
const props = defineProps({
checked: {
default: false,
type: [
Array,
Boolean
]
},
disabled: Boolean,
title: {
default: Lang('active'),
type: String
},
value: {
default: null,
type: String
}
});
const uuid = uuidv4()
/** Propiedades computadas */
const proxyChecked = computed({
get() {
return props.checked;
},
set(val) {
emit('update:checked', val);
},
});
</script>
<template>
<div class="flex items-center">
<div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
<input
v-model="proxyChecked"
class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer"
name="toggle"
type="checkbox"
:id="uuid"
:disabled="disabled"
:value="value"
/>
<label
class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"
:for="uuid"
/>
</div>
<label
class="text-xs text-gray-700"
:for="uuid"
>
{{ $t(title) }}
</label>
</div>
</template>

View File

@ -1,80 +0,0 @@
<script setup>
import { v4 as uuidv4 } from 'uuid';
import { computed, onMounted, ref } from 'vue';
import Error from './Elements/Error.vue';
import Label from './Elements/Label.vue';
/** Opciones */
defineOptions({
inheritAttrs: false
})
/** Eventos */
const emit = defineEmits([
'update:modelValue'
]);
/** Propiedades */
const props = defineProps({
class: String,
id: String,
modelValue: Number | String,
onError: String,
placeholder: String,
required: Boolean,
title: String,
});
const input = ref(null);
/** Exposiciones */
defineExpose({
focus: () => input.value.focus()
});
/** Propiedades computadas */
const autoId = computed(() => {
return (props.id)
? props.id
: uuidv4()
})
const autoTitle = computed(() => {
if(props.title) {
return props.title;
}
return props.id;
});
/** Ciclos */
onMounted(() => {
if (input.value.hasAttribute('autofocus')) {
input.value.focus();
}
});
</script>
<template>
<div class="w-full">
<Label
:id="autoId"
:required="required"
:title="autoTitle"
/>
<textarea
ref="input"
v-bind="$attrs"
class="input-primary"
:id="autoId"
:placeholder="placeholder"
:required="required"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
></textarea>
<Error
:onError="onError"
/>
</div>
</template>

View File

@ -1,166 +0,0 @@
<script setup>
import { computed, onMounted, ref, watch, watchEffect } from 'vue'
import { lang } from '@Lang/i18n';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import PrimaryButton from '../../Button/Primary.vue';
import Input from '../../Form/Input.vue';
import Selectable from '../../Form/Selectable.vue';
/** Eventos */
const emit = defineEmits([
'update:modelValue'
]);
/** Propiedades */
const props = defineProps({
itemATitle: String,
itemBTitle: String,
items: Object,
modelValue: Object,
title: String,
type: {
default: 'text',
type: String
}
})
// Elementos primarios (controlador)
const itemA = ref()
const itemsASelected = ref([]);
const itemsAUnselected = ref([]);
// Elementos secundarios
const itemB = ref();
/** Propiedades computadas */
const values = computed({
get() {
return props.modelValue
},
set(val) {
emit('update:modelValue', val)
}
})
/** Métodos */
function add() {
if (itemA.value) {
if(itemB.value) {
values.value.push({
item: {
_id: itemA.value._id,
name: itemA.value.name,
},
value: itemB.value,
});
let x = itemsAUnselected.value.filter((o) => {
return o._id != itemA.value._id
})
itemsAUnselected.value = x
itemsASelected.value.push({...itemA.value}),
itemA.value = null
itemB.value = null
} else {
Notify.warning(Lang('todo.uniqueSub.b.required', {name:Lang('subclassification')}))
}
} else {
Notify.warning(Lang('todo.uniqueSub.a.required', {name:Lang('classification')}))
}
}
function remove(index, provider) {
itemsAUnselected.value.push({...provider})
itemsASelected.value.splice(itemsASelected.value.indexOf(provider), 1)
values.value.splice(index, 1)
}
/** Exposiciones */
defineExpose({
itemA,
itemB,
add,
})
/** Observadores */
watchEffect(() => {
if(props.items.length > 0) {
itemsAUnselected.value = props.items
}
})
watch(itemA, () => {
emit('updateItemsB', itemA.value?.id)
if(!itemA.value) {
itemB.value = null
}
})
/** Ciclos */
onMounted(() => {
if(values.value) {
values.value.forEach((i) => {
itemsASelected.value.push({...i})
})
}
})
</script>
<template>
<div class="rounded-sm border border-primary dark:border-primary-d p-2">
<p>{{ title }}</p>
<div class="w-full grid gap-2 grid-cols-2 dark:bg-primary-d/50 rounded-md">
<Selectable
v-model="itemA"
:title="itemATitle"
:options="itemsAUnselected"
/>
<Input
v-model="itemB"
:title="itemBTitle"
:type="type"
@keyup.enter="add"
/>
<div class="col-span-2 flex justify-center">
<PrimaryButton
type="button"
@click="add"
>
{{ $t('add') }}
</PrimaryButton>
</div>
<div class="col-span-2 text-sm">
<p><b>{{ $t('items') }}</b> ({{ values.length }})</p>
</div>
<div class="col-span-2 space-y-2 ">
<template v-for="item, index in values">
<div class="relative rounded-sm border border-primary/50">
<div class="grid gap-2 grid-cols-2 w-full items-center p-2 dark:bg-primary-d/50">
<Input
v-model="item.item.name"
:title="itemATitle"
disabled
/>
<Input
v-model="item.value"
:title="itemBTitle"
/>
</div>
<div class="absolute right-1 top-1">
<GoogleIcon
class="btn-icon-primary"
name="close"
@click="remove(index, item.item)"
/>
</div>
</div>
</template>
</div>
<slot />
</div>
</div>
</template>

View File

@ -1,38 +0,0 @@
<script setup>
import { computed, useSlots } from 'vue';
import SectionTitle from './SectionTitle.vue';
defineEmits(['submitted']);
const hasActions = computed(() => !! useSlots().actions);
</script>
<template>
<div class="md:grid md:grid-cols-3 md:gap-6">
<SectionTitle>
<template #title>
<slot name="title" />
</template>
<template #description>
<slot name="description" />
</template>
</SectionTitle>
<div class="mt-5 md:mt-0 md:col-span-2">
<form @submit.prevent="$emit('submitted')">
<div
class="p-4 sm:p-6 shadow-sm dark:shadow-xs dark:shadow-white/50"
:class="hasActions ? 'sm:rounded-tl-md sm:rounded-tr-md' : 'sm:rounded-md'"
>
<div class="grid grid-cols-6 gap-6">
<slot name="form" />
</div>
</div>
<div v-if="hasActions" class="flex items-center justify-end px-4 py-3 text-end sm:px-6 shadow-sm dark:shadow-xs dark:shadow-white/50 sm:rounded-bl-md sm:rounded-br-md">
<slot name="actions" />
</div>
</form>
</div>
</div>
</template>

View File

@ -1,172 +0,0 @@
<script setup>
import { ref } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import InboxItem from './Inbox/Item.vue';
/** Propiedades */
const props = defineProps({
inboxCtl: Object,
items: Object,
searcherCtl: Object,
withMultiSelection: Boolean
});
/** Propiedades */
const filterMessages = ref(false);
/** Métodos */
const selectThisPage = () => props.inboxCtl.onSelectAll(props.items, false);
const unselectThisPage = () => props.inboxCtl.onUnselectAll(props.items)
const search = url => props.searcherCtl.searchWithInboxPagination(url);
</script>
<template>
<div v-if="inboxCtl.inboxNumberSelected.value.length"
class="w-full p-1 rounded-b-md bg-primary text-white"
>
<span>
({{ inboxCtl.inboxNumberSelected.value.length }}) Seleccionados: {{ inboxCtl.inboxNumberSelected.value.join(", ") }}.
</span>
</div>
<div class="w-full flex overflow-x-auto">
<div class="w-fit">
<div v-if="$slots['main-menu']"
class="w-48 xl:w-64"
>
<slot
name="main-menu"
/>
</div>
<div v-if="$slots['menu']"
class="w-48 xl:w-64 pr-2 pb-8 border-r border-gray-300"
:class="{'mt-16':!$slots['main-menu']}"
>
<ul class="space-y-1">
<slot
name="menu"
/>
</ul>
</div>
</div>
<div class="flex-1">
<div v-if="$slots['actions']"
class="h-16 flex items-center justify-between py-2"
>
<div class="flex items-center">
<div v-if="withMultiSelection"
class="relative flex items-center px-0.5 space-x-0.5"
>
<button class="px-2 pt-1" @click="filterMessages = !filterMessages">
<GoogleIcon
class="text-xl"
name="checklist"
outline
/>
</button>
<div
@click.away="filterMessages = false"
class="bg-gray-200 shadow-2xl absolute left-0 top-6 w-32 py-2 text-gray-900 rounded-sm z-10"
:class="{'hidden':!filterMessages}"
>
<button
type="button"
class="inbox-check-all-option"
@click="selectThisPage()"
>
Seleccionar toda esta página
</button>
<button
type="button"
class="inbox-check-all-option"
@click="unselectThisPage()"
>
Deseleccionar toda esta página
</button>
</div>
</div>
<div class="flex items-center">
<slot name="actions" />
</div>
</div>
<template v-if="items.links">
<div v-if="items.links.length > 3"
class="flex w-full justify-end"
>
<div class="flex w-full justify-end flex-wrap space-x-1 -mb-1">
<template v-for="(link, k) in items.links" :key="k">
<div v-if="link.url === null && k == 0"
class="px-2 py-1 text-sm leading-4 text-gray-400 border rounded"
>
<GoogleIcon
name="arrow_back"
/>
</div>
<button v-else-if="k === 0"
class="px-2 py-1 text-sm leading-4 border rounded"
:class="{ 'bg-primary text-white': link.active }"
@click="search(link.url)"
>
<GoogleIcon
name="arrow_back"
/>
</button>
<div v-else-if="link.url === null && k == (items.links.length - 1)"
class="px-2 py-1 text-sm leading-4 text-gray-400 border rounded"
>
<GoogleIcon
name="arrow_forward"
/>
</div>
<button v-else-if="k === (items.links.length - 1)"
class="px-2 py-1 text-sm leading-4 border rounded"
:class="{ 'bg-primary text-white': link.active }"
@click="search(link.url)"
>
<GoogleIcon
name="arrow_forward"
/>
</button>
<button v-else class="px-2 py-1 text-sm leading-4 border rounded"
v-html="link.label"
:class="{ 'bg-primary text-white': link.active }"
@click="search(link.url)"
></button>
</template>
</div>
</div>
</template>
</div>
<div v-else class="w-full mt-4"></div>
<div v-if="items.total > 0"
class="bg-gray-100 "
>
<ul class="ml-1">
<slot
name="head"
:items="items.data"
/>
</ul>
<ul class="ml-1">
<slot
name="items"
:items="items.data"
/>
</ul>
</div>
<template v-else>
<InboxItem>
<template #item>
<span class="w-28 pr-2 truncate">-</span>
<span class="w-96 truncate">Sin resultados</span>
</template>
<template #date>
-
</template>
</InboxItem>
</template>
</div>
</div>
</template>

View File

@ -1,69 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
/** Propiedades */
const props = defineProps({
inboxCtl: Object, //Controller
item: Object,
selecteds: Object
})
const check = ref(false);
const messageHover = ref(false);
/** Métodos */
const select = () => (!check.value)
? props.inboxCtl.onSelectOne(props.item)
: props.inboxCtl.onUnselectOne(props.item);
const selected = computed(() => {
const status = (props.item)
? props.inboxCtl.inboxIdSelected.value.includes(props.item.id)
: false;
check.value = status;
return status;
});
</script>
<template>
<li
class="flex items-center rounded-sm border-y px-2 min-h-[35px] transition duration-300"
:class="{'bg-secondary text-secondary-t':selected, 'bg-primary/50 text-primary-t hover:bg-secondary/50 hover:text-secondary-t':!selected}"
>
<div class="pr-2">
<input
v-model="check"
class="focus:ring-0 border-2 border-gray-400"
type="checkbox"
@click="select"
>
</div>
<div
class="w-full flex items-center justify-between cursor-pointer"
@mouseover="messageHover = true"
@mouseleave="messageHover = false"
>
<div class="flex items-center">
<slot name="item" />
</div>
<div
class="w-36 flex items-center justify-end"
>
<div
class="flex items-center space-x-2"
:class="{'hidden':!messageHover}"
>
<slot name="actions" :check="check" />
</div>
<div
class="flex space-x-4 text-xs"
:class="{'hidden':messageHover}"
>
<slot name="date" />
</div>
</div>
</div>
</li>
</template>

View File

@ -1,11 +0,0 @@
<template>
<li
class="flex items-center rounded-sm border-y px-2 min-h-[35px] transition duration-300"
>
<div class="pl-5 w-full flex items-center justify-between cursor-pointer">
<div class="flex items-center font-semibold">
<slot />
</div>
</div>
</li>
</template>

View File

@ -1,51 +0,0 @@
<script setup>
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Propiedades */
const props = defineProps({
icon: String,
counter: Number,
to: String,
toParam: {
default: {},
type: Object
},
title: String,
});
/** Propiedades computadas */
const classes = computed(() => {
let status = route().current(props.to, props.toParam)
? 'bg-secondary/30'
: 'border-transparent hover:bg-secondary/30';
return ` text-primary flex items-center justify-between py-1.5 px-4 rounded-sm cursor-pointer ${status} transition`
});
</script>
<template>
<li>
<RouterLink
v-if="to"
:class="classes"
:to="to"
>
<span class="flex items-center space-x-2">
<GoogleIcon
class="text-lg"
:name="icon"
outline
/>
<span>
{{ title }}
</span>
</span>
<span v-if="counter > 0" class="bg-primary text-gray-100 font-bold px-2 py-0.5 text-xs rounded-sm">
{{ counter }}
</span>
</RouterLink>
</li>
</template>

View File

@ -1,42 +0,0 @@
<script setup>
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Propiedades */
const props = defineProps({
icon: String,
to: String,
title: String,
type: {
default: 'primary',
type: String
}
});
/** Propiedades computadas */
const classes = computed(() => {
return `inbox-menu-button-${props.type}`;
});
</script>
<template>
<div class="h-16 flex items-center pr-2">
<RouterLink
:class="classes"
:to="to"
>
<span class="flex items-center space-x-2 ">
<GoogleIcon
class="text-lg text-white font-bold"
:name="icon"
outline
/>
<span>
{{ title }}
</span>
</span>
</RouterLink>
</div>
</template>

View File

@ -1,12 +0,0 @@
<script setup>
defineProps({
value: String,
});
</script>
<template>
<label class="block font-medium text-sm text-gray-700">
<span v-if="value">{{ value }}</span>
<span v-else><slot /></span>
</label>
</template>

View File

@ -1,58 +0,0 @@
<script setup>
import { onMounted } from 'vue';
import useDarkMode from '@Stores/DarkMode'
import useLeftSidebar from '@Stores/LeftSidebar'
import useNotificationSidebar from '@Stores/NotificationSidebar'
import useNotifier from '@Stores/Notifier'
import Header from '../Skeleton/Header.vue';
import LeftSidebar from '../Skeleton/Sidebar/Left.vue';
import NotificationSidebar from '../Skeleton/Sidebar/Notification.vue';
/** Definidores */
const darkMode = useDarkMode();
const leftSidebar = useLeftSidebar();
const notificationSidebar = useNotificationSidebar();
const notifier = useNotifier();
/** Propiedades */
defineProps({
title: String,
});
/** Ciclos */
onMounted(() => {
leftSidebar.boot();
darkMode.boot();
notifier.boot();
});
</script>
<template>
<div class="flex w-full h-screen bg-page text-page-t dark:bg-page-d dark:text-page-dt">
<LeftSidebar
@open="leftSidebar.toggle()"
>
<slot name="leftSidebar"/>
</LeftSidebar>
<NotificationSidebar
@open="notificationSidebar.toggle()"
/>
<div
class="flex flex-col w-full transition-all duration-300"
:class="{'md:w-[calc(100vw-rem)] md:ml-64':leftSidebar.isOpened, 'md:w-screen md:ml-0':leftSidebar.isClosed}"
>
<div class="h-2 md:h-14">
<Header
@open="leftSidebar.toggle()"
/>
</div>
<main class="flex h-full justify-center md:p-2">
<div class="mt-14 md:mt-0 w-full shadow-lg dark:shadow-xs md:dark:shadow-white h-[calc(100vh-4.5rem)] px-2 pb-4 md:rounded-sm overflow-y-auto overflow-x-auto transition-colors duration-300">
<slot />
</div>
</main>
</div>
</div>
</template>

View File

@ -1,69 +0,0 @@
<script setup>
import { onMounted } from 'vue'
import { APP_VERSION, APP_COPYRIGHT } from '@/config.js'
import useDarkMode from '@Stores/DarkMode'
import Logo from '@Holos/Logo.vue'
import IconButton from '@Holos/Button/Icon.vue'
/** Definidores */
const darkMode = useDarkMode()
/** Propiedades */
defineProps({
title: String
})
/** Ciclos */
onMounted(() => {
darkMode.boot()
});
</script>
<template>
<div class="h-screen flex bg-primary dark:bg-primary-d">
<div
class="relative flex w-full lg:w-full justify-around items-center with-transition"
:class="{'app-bg-light':darkMode.isLight,'app-bg-dark':darkMode.isDark}"
>
<header class="absolute top-0 flex w-full h-8 px-1 items-center justify-end text-white">
<div>
<IconButton v-if="darkMode.isLight"
icon="light_mode"
:title="$t('app.theme.light')"
@click="darkMode.applyDark()"
/>
<IconButton v-else
icon="dark_mode"
:title="$t('app.theme.dark')"
@click="darkMode.applyLight()"
/>
</div>
</header>
<div class="flex w-full flex-col items-center justify-center space-y-2">
<div class="flex space-x-2 items-center justify-start text-white">
<Logo
class="text-lg inline-flex"
/>
</div>
<main class="bg-white/10 w-full backdrop-blur-xs text-white px-4 py-4 rounded-sm max-w-80">
<RouterView />
</main>
<footer class="absolute bottom-0 flex w-full h-8 px-4 items-center justify-between bg-primary dark:bg-primary-d backdrop-blur-md text-white transition-colors duration-global">
<div>
<span>
&copy;2024 {{ APP_COPYRIGHT }}
</span>
</div>
<div>
<span>
APP {{ APP_VERSION }} API {{ $page.app.version }}
</span>
</div>
</footer>
</div>
</div>
</div>
</template>

View File

@ -1,57 +0,0 @@
<script setup>
import { onMounted } from 'vue';
import useDarkMode from '@Stores/DarkMode'
import useLeftSidebar from '@Stores/LeftSidebar'
import useNotificationSidebar from '@Stores/NotificationSidebar'
import useNotifier from '@Stores/Notifier'
import Header from '../Skeleton/Header.vue';
import RhSidebar from '../Skeleton/RhSidebar.vue';
import NotificationSidebar from '../Skeleton/Sidebar/Notification.vue';
/** Definidores */
const darkMode = useDarkMode();
const leftSidebar = useLeftSidebar();
const notificationSidebar = useNotificationSidebar();
const notifier = useNotifier();
/** Propiedades */
defineProps({
title: String,
});
/** Ciclos */
onMounted(() => {
leftSidebar.boot();
darkMode.boot();
notifier.boot();
});
</script>
<template>
<div class="flex w-full h-screen bg-page text-page-t dark:bg-page-d dark:text-page-dt">
<RhSidebar
@open="leftSidebar.toggle()"
>
<slot name="leftSidebar"/>
</RhSidebar>
<NotificationSidebar
@open="notificationSidebar.toggle()"
/>
<div
class="flex flex-col w-full transition-all duration-300"
:class="{'md:w-[calc(100vw-rem)] md:ml-64':leftSidebar.isOpened, 'md:w-screen md:ml-0':leftSidebar.isClosed}"
>
<div class="h-2 md:h-14">
<Header
@open="leftSidebar.toggle()"
/>
</div>
<main class="flex h-full justify-center md:p-2">
<div class="mt-14 md:mt-0 w-full shadow-lg dark:shadow-xs md:dark:shadow-white h-[calc(100vh-4.5rem)] px-2 pb-4 md:rounded-sm overflow-y-auto overflow-x-auto transition-colors duration-300">
<slot />
</div>
</main>
</div>
</div>
</template>

View File

@ -1,69 +0,0 @@
<script setup>
import { onMounted } from 'vue'
import { APP_VERSION, APP_COPYRIGHT } from '@/config.js'
import useDarkMode from '@Stores/DarkMode'
import IconButton from '../Button/Icon.vue'
import Logo from '../Logo.vue';
/** Definidores */
const darkMode = useDarkMode()
/** Propiedades */
defineProps({
title: String
})
/** Ciclos */
onMounted(() => {
darkMode.boot()
});
</script>
<template>
<div class="min-h-screen flex">
<div
class="relative flex w-full lg:w-full justify-around items-start with-transition"
:class="{'app-bg-light':darkMode.isLight,'app-bg-dark':darkMode.isDark}"
>
<header class="absolute top-0 flex w-full h-8 px-1 items-center justify-end text-white">
<div>
<IconButton v-if="darkMode.isLight"
icon="light_mode"
:title="$t('app.theme.light')"
@click="darkMode.applyDark()"
/>
<IconButton v-else
icon="dark_mode"
:title="$t('app.theme.dark')"
@click="darkMode.applyLight()"
/>
</div>
</header>
<div class="flex w-full flex-col space-y-2">
<div class="flex space-x-2 items-center justify-start text-white">
<Logo
class="text-lg inline-flex"
/>
</div>
<main class="bg-white/10 w-full mx-auto sm:max-w-2xl backdrop-blur-xs text-white px-4 py-8 rounded-md">
<slot />
</main>
<footer class="absolute bottom-0 flex w-full h-8 px-4 items-center justify-between bg-primary dark:bg-primary-d backdrop-blur-xs text-white transition-colors duration-global">
<div>
<span>
&copy;{{ APP_COPYRIGHT }}
</span>
</div>
<div>
<span>
Versión {{ APP_VERSION }}
</span>
</div>
</footer>
</div>
</div>
</div>
</template>

View File

@ -1,24 +0,0 @@
<script setup>
import { hasToken } from '@Services/Api';
import { useRouter } from 'vue-router';
/** Definidores */
const router = useRouter();
/** Métodos */
const home = () => {
if(hasToken()) {
router.push({ name: 'dashboard.index' });
} else {
location.replace('/');
}
}
</script>
<template>
<div
class="flex w-full justify-center items-center space-x-2 cursor-pointer"
@click="home"
>
<img :src="$page.app.logo" class="h-20" />
</div>
</template>

View File

@ -1,70 +0,0 @@
<script setup>
import { ref } from 'vue';
import ModalBase from './Elements/Base.vue';
import DangerButton from '../Button/Danger.vue';
import SecondaryButton from '../Button/Secondary.vue';
/** Eventos */
defineEmits([
'close',
'destroy',
]);
/** Propiedades */
const props = defineProps({
title: {
default: Lang('delete.title'),
type: String
}
});
/** Referencias */
const modalRef = ref(null);
/** Exposiciones */
defineExpose({
open: () => modalRef.value.open(),
close: () => modalRef.value.close()
});
</script>
<template>
<ModalBase
ref="modalRef"
@close="$emit('close')"
>
<template #title>
<p
class="font-bold text-xl"
v-text="title"
/>
</template>
<template #content>
<div class="w-full right-0">
<div class="overflow-hidden space-y-2 shadow-lg">
<slot />
<div class="px-4 pb-2">
<p
class="mt-2 p-1 rounded-md text-justify bg-danger text-danger-t"
v-text="$t('delete.confirm')"
/>
</div>
</div>
</div>
</template>
<template #footer>
<div class="space-x-2">
<slot name="buttons" />
<DangerButton
v-text="$t('delete.title')"
@click="$emit('destroy')"
/>
<SecondaryButton
v-text="$t('cancel')"
@click="modalRef.close()"
/>
</div>
</template>
</ModalBase>
</template>

View File

@ -1,93 +0,0 @@
<script setup>
import { computed, ref, watch } from 'vue';
/** Eventos */
const emit = defineEmits(['close']);
/** Propiedades */
const props = defineProps({
maxWidth: {
default: '2xl',
type: String
}
});
const show = ref(false);
/** Métodos */
const maxWidthClass = computed(() => {
return {
'sm': 'sm:max-w-sm',
'md': 'sm:max-w-md',
'lg': 'sm:max-w-lg',
'xl': 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl',
'3xl': 'sm:max-w-3xl',
'4xl': 'sm:max-w-4xl',
'5xl': 'sm:max-w-5xl',
'6xl': 'sm:max-w-6xl',
'7xl': 'sm:max-w-7xl',
}[props.maxWidth];
});
/** Observadores */
watch(() => show, () => {
if (show.value) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = null;
}
});
/** Exposiciones */
defineExpose({
open: () => show.value = true,
close: () => {
show.value = false;
emit('close');
}
});
</script>
<template>
<teleport to="body">
<transition
enter-active-class="ease-out duration-300"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="ease-in duration-300"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-show="show" class="fixed inset-0 overflow-y-auto px-4 py-6 sm:px-0 bg-primary/50 z-50 transition-all"></div>
</transition>
<transition
enter-active-class="ease-out duration-300"
enter-from-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to-class="opacity-100 translate-y-0 sm:scale-100"
leave-active-class="ease-in duration-300"
leave-from-class="opacity-100 translate-y-0 sm:scale-100"
leave-to-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div v-show="show" class="fixed inset-0 overflow-y-auto px-4 py-6 sm:px-0 z-50 transition-all" scroll-region>
<div
v-show="show"
class="mb-6 bg-page text-page-t dark:bg-page-d dark:text-page-dt rounded-sm overflow-hidden shadow-xl transform transition-all sm:w-full sm:mx-auto"
:class="maxWidthClass"
>
<div class="flex flex-col">
<div class="text-lg px-4 font-medium">
<slot name="title" />
</div>
<div class="text-sm">
<slot name="content" />
</div>
</div>
<div class="flex flex-row justify-center p-2 text-end">
<slot name="footer" />
</div>
</div>
</div>
</transition>
</teleport>
</template>

View File

@ -1,21 +0,0 @@
<script setup>
/** Propiedades */
defineProps({
subtitle: String,
title: String
})
</script>
<template>
<div class="text-center p-6 bg-primary dark:bg-primary-d">
<slot />
<p class="pt-2 text-lg font-bold text-primary-t dark:text-primary-t-d">
{{ title }}
</p>
<p v-if="subtitle"
class="text-sm text-primary-t dark:text-primary-t-d"
>
{{ subtitle }}
</p>
</div>
</template>

View File

@ -1,53 +0,0 @@
<script setup>
import { ref } from 'vue';
import ModalBase from './Elements/Base.vue';
import SecondaryButton from '../Button/Secondary.vue';
/** Eventos */
const emit = defineEmits(['close']);
/** Propiedades */
const props = defineProps({
title: String
});
/** Referencias */
const modalRef = ref(null);
/** Exposiciones */
defineExpose({
open: () => modalRef.value.open(),
close: () => modalRef.value.close()
});
</script>
<template>
<ModalBase
ref="modalRef"
@close="emit('close')"
>
<template #title>
<p
class="font-bold text-xl"
v-text="title ?? $t('details')"
/>
</template>
<template #content>
<div class="w-full right-0">
<div class="overflow-hidden shadow-lg">
<slot />
</div>
</div>
</template>
<template #footer>
<div class="space-x-2">
<slot name="buttons" />
<SecondaryButton
v-text="$t('close')"
@click="modalRef.close()"
/>
</div>
</template>
</ModalBase>
</template>

View File

@ -1,69 +0,0 @@
<script setup>
import { ref } from 'vue';
import { api } from '@Services/Api.js';
import DestroyModal from '../Destroy.vue';
import Header from '../Elements/Header.vue';
/** Eventos */
const emit = defineEmits([
'open',
'close',
'update'
]);
/** Propiedades */
const props = defineProps({
to: Function,
title: {
type: String,
default: 'name'
},
subtitle: {
type: String,
default: 'description'
}
});
const model = ref({});
const modalRef = ref(null);
/** Métodos */
const destroy = () => {
api.delete(props.to(model.value.id), {
onSuccess: () => {
Notify.success(Lang('deleted'));
emit('update');
},
onError: () => {
Notify.info(Lang('notFound'));
},
onFinish: () => {
modalRef.value.close();
}
});
}
/** Exposiciones */
defineExpose({
open: (modelData) => {
model.value = modelData;
modalRef.value.open();
emit('open')
}
});
</script>
<template>
<DestroyModal
ref="modalRef"
@close="$emit('close')"
@destroy="destroy"
>
<Header
:title="model[title]"
:subtitle="model[subtitle]"
/>
</DestroyModal>
</template>

View File

@ -1,151 +0,0 @@
<script setup>
import GoogleIcon from '../Shared/GoogleIcon.vue';
import Loader from '../Shared/Loader.vue';
/** Eventos */
const emit = defineEmits([
'send-pagination'
]);
/** Propiedades */
const props = defineProps({
items: Object,
processing: Boolean
});
</script>
<template>
<div class="bg-white rounded-lg shadow-sm p-6 dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt">
<!-- Tabla -->
<div class="overflow-x-auto">
<table v-if="!processing" class="w-full">
<thead>
<tr class="text-left text-sm text-gray-500 dark:text-primary-dt/70">
<slot name="head" />
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-primary/20 text-sm text-gray-700 dark:text-primary-dt">
<template v-if="items?.total > 0">
<slot
name="body"
:items="items?.data"
/>
</template>
<template v-else>
<tr>
<slot name="empty" />
</tr>
</template>
</tbody>
</table>
<!-- Estado de carga -->
<table v-else class="animate-pulse w-full">
<thead>
<tr>
<th colspan="100%" class="h-8 text-center">
<div class="flex items-center justify-center">
<Loader />
</div>
</th>
</tr>
</thead>
<tbody>
<tr v-for="i in 3" :key="i">
<td colspan="100%" class="table-cell h-16 text-center">
<div class="w-full h-4 bg-secondary/50 rounded-md"></div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Paginación -->
<template v-if="items?.links && items.links.length > 3">
<div class="mt-6 flex w-full justify-end">
<div class="flex w-full justify-end flex-wrap space-x-1 -mb-1">
<template v-for="(link, k) in items.links" :key="k">
<!-- Botón anterior deshabilitado -->
<div v-if="link.url === null && k == 0"
class="px-3 py-2 text-sm leading-4 text-gray-400 border rounded-lg bg-gray-50 dark:bg-primary-d dark:border-primary/20"
>
<GoogleIcon name="arrow_back" class="w-4 h-4" />
</div>
<!-- Botón anterior activo -->
<button v-else-if="k === 0"
class="px-3 py-2 text-sm leading-4 border rounded-lg transition-colors duration-200 hover:bg-gray-50 dark:hover:bg-primary-d/50"
:class="{
'bg-primary text-white border-primary dark:bg-primary-dark dark:border-primary-dark': link.active,
'border-gray-300 dark:border-primary/20 text-gray-700 dark:text-primary-dt': !link.active
}"
@click="emit('send-pagination', link.url)"
>
<GoogleIcon name="arrow_back" class="w-4 h-4" />
</button>
<!-- Botón siguiente deshabilitado -->
<div v-else-if="link.url === null && k == (items.links.length - 1)"
class="px-3 py-2 text-sm leading-4 text-gray-400 border rounded-lg bg-gray-50 dark:bg-primary-d dark:border-primary/20"
>
<GoogleIcon name="arrow_forward" class="w-4 h-4" />
</div>
<!-- Botón siguiente activo -->
<button v-else-if="k === (items.links.length - 1)"
class="px-3 py-2 text-sm leading-4 border rounded-lg transition-colors duration-200 hover:bg-gray-50 dark:hover:bg-primary-d/50"
:class="{
'bg-primary text-white border-primary dark:bg-primary-dark dark:border-primary-dark': link.active,
'border-gray-300 dark:border-primary/20 text-gray-700 dark:text-primary-dt': !link.active
}"
@click="emit('send-pagination', link.url)"
>
<GoogleIcon name="arrow_forward" class="w-4 h-4" />
</button>
<!-- Números de página -->
<button v-else
class="px-3 py-2 text-sm leading-4 border rounded-lg transition-colors duration-200 hover:bg-gray-50 dark:hover:bg-primary-d/50"
:class="{
'bg-primary text-white border-primary dark:bg-primary-dark dark:border-primary-dark': link.active,
'border-gray-300 dark:border-primary/20 text-gray-700 dark:text-primary-dt': !link.active
}"
v-html="link.label"
@click="emit('send-pagination', link.url)"
></button>
</template>
</div>
</div>
</template>
</div>
</template>
<style scoped>
/* Estilos adicionales para mejorar la experiencia */
tbody tr {
transition: background-color 0.2s ease;
}
tbody tr:hover {
background-color: rgba(0, 0, 0, 0.02);
}
.dark tbody tr:hover {
background-color: rgba(255, 255, 255, 0.05);
}
/* Animación suave para los botones de paginación */
button {
transition: all 0.2s ease;
}
button:hover {
transform: translateY(-1px);
}
button:active {
transform: translateY(0);
}
</style>

View File

@ -1,650 +0,0 @@
<script setup>
import { ref, computed, nextTick, watch } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Propiedades */
const props = defineProps({
element: {
type: Object,
required: true
},
isSelected: {
type: Boolean,
default: false
}
});
/** Eventos */
const emit = defineEmits(['select', 'delete', 'update', 'move']);
/** Referencias */
const isEditing = ref(false);
const editValue = ref('');
const editInput = ref(null);
const editTextarea = ref(null);
const elementRef = ref(null);
const isDragging = ref(false);
const isResizing = ref(false);
const resizeDirection = ref(null); // 'corner', 'right', 'bottom'
const dragStart = ref({ x: 0, y: 0 });
const resizeStart = ref({ x: 0, y: 0, width: 0, height: 0 });
const fileInput = ref(null);
/** Propiedades computadas */
const elementStyles = computed(() => {
const baseStyles = {
left: `${props.element.x}px`,
top: `${props.element.y}px`,
width: `${props.element.width || 200}px`,
height: `${props.element.height || 40}px`
};
// Aplicar estilos de formato para elementos de texto
if (props.element.type === 'text' && props.element.formatting) {
const formatting = props.element.formatting;
if (formatting.fontSize) {
baseStyles.fontSize = `${formatting.fontSize}px`;
}
if (formatting.color) {
baseStyles.color = formatting.color;
}
}
return baseStyles;
});
// Propiedades computadas para clases CSS dinámicas
const textContainerClasses = computed(() => {
if (props.element.type !== 'text') return {};
const formatting = props.element.formatting || {};
return {
'font-bold': formatting.bold,
'italic': formatting.italic,
'underline': formatting.underline,
'text-left': !formatting.textAlign || formatting.textAlign === 'left',
'text-center': formatting.textAlign === 'center',
'text-right': formatting.textAlign === 'right',
'justify-start': !formatting.textAlign || formatting.textAlign === 'left',
'justify-center': formatting.textAlign === 'center',
'justify-end': formatting.textAlign === 'right'
};
});
const inputClasses = computed(() => {
if (props.element.type !== 'text') return {};
const formatting = props.element.formatting || {};
return {
'font-bold': formatting.bold,
'italic': formatting.italic,
'underline': formatting.underline,
'text-left': !formatting.textAlign || formatting.textAlign === 'left',
'text-center': formatting.textAlign === 'center',
'text-right': formatting.textAlign === 'right'
};
});
const inputStyles = computed(() => {
if (props.element.type !== 'text') return {};
const formatting = props.element.formatting || {};
const styles = {};
if (formatting.fontSize) {
styles.fontSize = `${formatting.fontSize}px`;
}
if (formatting.color) {
styles.color = formatting.color;
}
return styles;
});
/** Watchers */
watch(() => props.isSelected, (selected) => {
if (selected && isEditing.value) {
nextTick(() => {
if (props.element.type === 'text' && editInput.value) {
editInput.value.focus();
editInput.value.select();
} else if (props.element.type === 'code' && editTextarea.value) {
editTextarea.value.focus();
editTextarea.value.select();
}
});
}
});
/** Métodos */
const handleSelect = (event) => {
event.stopPropagation();
emit('select', props.element.id);
};
const handleDelete = () => {
emit('delete', props.element.id);
};
const startEditing = () => {
if (props.element.type === 'table' && props.element.content) {
// Deep copy para evitar mutaciones directas
editValue.value = JSON.parse(JSON.stringify(props.element.content));
} else if (props.element.type === 'code') {
editValue.value = props.element.content || 'console.log("Hola mundo");';
} else {
editValue.value = props.element.content || getDefaultEditValue();
}
isEditing.value = true;
nextTick(() => {
if (editTextarea.value) editTextarea.value.focus();
if (editInput.value) editInput.value.focus();
});
};
const finishEditing = () => {
if (isEditing.value) {
isEditing.value = false;
// Para tablas, emitir el objeto completo
if (props.element.type === 'table') {
emit('update', {
id: props.element.id,
content: editValue.value
});
} else {
emit('update', {
id: props.element.id,
content: editValue.value
});
}
}
};
const handleKeydown = (event) => {
if (props.element.type === 'text') {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
finishEditing();
} else if (event.key === 'Escape') {
isEditing.value = false;
editValue.value = props.element.content || 'Nuevo texto';
}
} else if (props.element.type === 'code') {
if (event.key === 'Escape') {
isEditing.value = false;
editValue.value = props.element.content || 'console.log("Hola mundo");';
}
// Para código, permitimos Enter normal y usamos Ctrl+Enter para terminar
if (event.key === 'Enter' && event.ctrlKey) {
event.preventDefault();
finishEditing();
}
} else if (props.element.type === 'table') {
if (event.key === 'Escape') {
isEditing.value = false;
// Restaurar el contenido original de la tabla
editValue.value = props.element.content ?
JSON.parse(JSON.stringify(props.element.content)) :
getDefaultEditValue();
}
// Para tablas, Enter normal para nueva línea en celda, Ctrl+Enter para terminar
if (event.key === 'Enter' && event.ctrlKey) {
event.preventDefault();
finishEditing();
}
}
};
// Manejo de archivo de imagen
const handleFileSelect = (event) => {
const file = event.target.files[0];
if (file && file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
emit('update', {
id: props.element.id,
content: e.target.result,
fileName: file.name
});
};
reader.readAsDataURL(file);
}
// Limpiar el input
event.target.value = '';
};
// Funcionalidad de arrastre
const handleMouseDown = (event) => {
if (isEditing.value || isResizing.value) return;
isDragging.value = true;
dragStart.value = {
x: event.clientX - props.element.x,
y: event.clientY - props.element.y
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
event.preventDefault();
};
const handleMouseMove = (event) => {
if (isDragging.value && !isResizing.value) {
const newX = event.clientX - dragStart.value.x;
const newY = event.clientY - dragStart.value.y;
emit('move', {
id: props.element.id,
x: Math.max(0, newX),
y: Math.max(0, newY)
});
} else if (isResizing.value && !isDragging.value) {
handleResizeMove(event);
}
};
const handleMouseUp = () => {
isDragging.value = false;
isResizing.value = false;
resizeDirection.value = null;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
// Funcionalidad de redimensionamiento por esquina
const startResize = (event) => {
event.stopPropagation();
event.preventDefault();
isResizing.value = true;
resizeDirection.value = 'corner';
resizeStart.value = {
x: event.clientX,
y: event.clientY,
width: props.element.width || 200,
height: props.element.height || 40
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
// Funcionalidad de redimensionamiento por bordes
const startResizeEdge = (event, direction) => {
event.stopPropagation();
event.preventDefault();
isResizing.value = true;
resizeDirection.value = direction;
resizeStart.value = {
x: event.clientX,
y: event.clientY,
width: props.element.width || 200,
height: props.element.height || 40
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
const handleResizeMove = (event) => {
if (!isResizing.value) return;
const deltaX = event.clientX - resizeStart.value.x;
const deltaY = event.clientY - resizeStart.value.y;
let newWidth = resizeStart.value.width;
let newHeight = resizeStart.value.height;
// Calcular nuevas dimensiones según la dirección
if (resizeDirection.value === 'corner') {
newWidth = Math.max(getMinWidth(), Math.min(getMaxWidth(), resizeStart.value.width + deltaX));
newHeight = Math.max(getMinHeight(), Math.min(getMaxHeight(), resizeStart.value.height + deltaY));
} else if (resizeDirection.value === 'right') {
newWidth = Math.max(getMinWidth(), Math.min(getMaxWidth(), resizeStart.value.width + deltaX));
} else if (resizeDirection.value === 'bottom') {
newHeight = Math.max(getMinHeight(), Math.min(getMaxHeight(), resizeStart.value.height + deltaY));
}
emit('update', {
id: props.element.id,
width: newWidth,
height: newHeight
});
};
// Obtener tamaños mínimos según el tipo de elemento
const getMinWidth = () => {
switch (props.element.type) {
case 'text':
return 100;
case 'image':
return 100;
case 'table':
return 200;
default:
return 100;
}
};
const getMinHeight = () => {
switch (props.element.type) {
case 'text':
return 30;
case 'image':
return 80;
case 'table':
return 80;
default:
return 30;
}
};
// Obtener tamaños máximos según el tipo de elemento
const getMaxWidth = () => {
return 800; // Máximo general
};
const getMaxHeight = () => {
return 600; // Máximo general
};
</script>
<template>
<div
ref="elementRef"
:style="elementStyles"
@click="handleSelect"
@dblclick="startEditing"
@mousedown="handleMouseDown"
class="absolute group select-none"
:class="{
'ring-2 ring-blue-500 ring-opacity-50': isSelected,
'cursor-move': !isEditing && !isResizing,
'cursor-text': isEditing && (element.type === 'text' || element.type === 'code'),
'cursor-se-resize': isResizing && resizeDirection === 'corner',
'cursor-e-resize': isResizing && resizeDirection === 'right',
'cursor-s-resize': isResizing && resizeDirection === 'bottom',
'z-50': isSelected,
'z-10': !isSelected
}"
>
<!-- Input oculto para selección de archivos -->
<input
ref="fileInput"
type="file"
accept="image/*"
@change="handleFileSelect"
class="hidden"
/>
<!-- Elemento de Texto con formato aplicado -->
<div
v-if="element.type === 'text'"
class="w-full h-full flex items-center px-3 py-2 bg-white rounded border border-gray-300 shadow-sm dark:bg-white dark:border-gray-400"
:class="textContainerClasses"
:style="{
fontSize: element.formatting?.fontSize ? `${element.formatting.fontSize}px` : '14px',
color: element.formatting?.color || '#374151'
}"
>
<input
v-if="isEditing"
ref="editInput"
v-model="editValue"
@blur="finishEditing"
@keydown="handleKeydown"
class="w-full bg-transparent outline-none cursor-text"
:class="inputClasses"
:style="inputStyles"
@mousedown.stop
/>
<span
v-else
class="truncate pointer-events-none w-full"
:class="textContainerClasses"
:style="{
fontSize: element.formatting?.fontSize ? `${element.formatting.fontSize}px` : '14px',
color: element.formatting?.color || '#374151'
}"
>
{{ element.content || 'Nuevo texto' }}
</span>
</div>
<!-- Elemento de Imagen (sin cambios) -->
<div
v-else-if="element.type === 'image'"
class="w-full h-full flex items-center justify-center bg-gray-100 rounded border border-gray-300 dark:bg-primary/10 dark:border-primary/20 overflow-hidden"
>
<!-- Si hay imagen cargada -->
<img
v-if="element.content && element.content.startsWith('data:image')"
:src="element.content"
:alt="element.fileName || 'Imagen'"
class="w-full h-full object-cover pointer-events-none"
/>
<!-- Placeholder para imagen -->
<div v-else class="flex flex-col items-center justify-center text-gray-400 dark:text-primary-dt/50 p-4">
<GoogleIcon name="image" class="text-2xl mb-1" />
<span class="text-xs text-center">Haz doble clic para cargar imagen</span>
</div>
</div>
<!-- Elemento de Tabla (sin cambios en esta parte) -->
<div
v-else-if="element.type === 'table'"
class="w-full h-full bg-white rounded border overflow-hidden"
>
<div v-if="element.content && element.content.data" class="w-full h-full">
<table class="w-full h-full text-xs border-collapse">
<thead v-if="element.content.data.length > 0">
<tr class="bg-blue-50 dark:bg-blue-900/20">
<th
v-for="(header, colIndex) in element.content.data[0]"
:key="colIndex"
class="border border-gray-300 dark:border-primary/20 px-1 py-1 text-left font-semibold text-blue-800 dark:text-blue-300"
>
<input
v-if="isEditing"
v-model="editValue.data[0][colIndex]"
class="w-full bg-transparent outline-none text-xs"
@mousedown.stop
@click.stop
@focus.stop
/>
<span v-else class="truncate">{{ header }}</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, rowIndex) in element.content.data.slice(1)"
:key="rowIndex"
class="hover:bg-gray-50 dark:hover:bg-primary/5"
>
<td
v-for="(cell, colIndex) in row"
:key="colIndex"
class="border border-gray-300 dark:border-primary/20 px-1 py-1"
>
<input
v-if="isEditing"
v-model="editValue.data[rowIndex + 1][colIndex]"
class="w-full bg-transparent outline-none text-xs"
@mousedown.stop
@click.stop
@focus.stop
/>
<span v-else class="truncate text-gray-700 dark:text-primary-dt">{{ cell }}</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Placeholder para tabla vacía -->
<div v-else class="flex flex-col items-center justify-center text-gray-400 dark:text-primary-dt/50 p-4">
<GoogleIcon name="table_chart" class="text-2xl mb-1" />
<span class="text-xs text-center">Doble clic para editar tabla</span>
</div>
</div>
<!-- Controles del elemento con z-index más alto -->
<div
v-if="isSelected && !isEditing"
class="absolute -top-8 right-0 flex gap-1 opacity-100 transition-opacity z-[60]"
>
<!-- Indicador de tamaño -->
<div class="px-2 py-1 bg-gray-800 text-white text-xs rounded shadow-sm pointer-events-none">
{{ Math.round(element.width || 200) }} × {{ Math.round(element.height || 40) }}
</div>
<!-- Botón para cargar imagen (solo para elementos de imagen) -->
<button
v-if="element.type === 'image'"
@click.stop="() => fileInput.click()"
class="w-6 h-6 bg-blue-500 hover:bg-blue-600 text-white rounded text-xs flex items-center justify-center transition-colors shadow-sm"
title="Cargar imagen"
>
<GoogleIcon name="upload" class="text-xs" />
</button>
<!-- Botón eliminar -->
<button
@click.stop="handleDelete"
class="w-6 h-6 bg-red-500 hover:bg-red-600 text-white rounded text-xs flex items-center justify-center transition-colors shadow-sm"
title="Eliminar"
>
<GoogleIcon name="close" class="text-xs" />
</button>
</div>
<!-- Controles de redimensionamiento mejorados -->
<div v-if="isSelected && !isEditing" class="absolute inset-0 pointer-events-none z-[55]">
<!-- Esquina inferior derecha - MÁS GRANDE Y VISIBLE -->
<div
@mousedown.stop="startResize"
class="absolute -bottom-2 -right-2 w-4 h-4 bg-blue-500 border-2 border-white cursor-se-resize pointer-events-auto rounded-sm shadow-md hover:bg-blue-600 transition-all"
title="Redimensionar"
>
<div class="absolute inset-0.5 bg-white/30 rounded-sm"></div>
</div>
<!-- Lado derecho - MÁS VISIBLE -->
<div
@mousedown.stop="(event) => startResizeEdge(event, 'right')"
class="absolute top-2 bottom-2 -right-1 w-2 bg-blue-500 cursor-e-resize pointer-events-auto rounded-sm shadow-sm hover:bg-blue-600 transition-all"
title="Redimensionar ancho"
>
<!-- Indicador visual en el centro -->
<div class="absolute top-1/2 left-1/2 w-0.5 h-4 bg-white/60 -translate-x-1/2 -translate-y-1/2 rounded-full"></div>
</div>
<!-- Lado inferior - MÁS VISIBLE -->
<div
@mousedown.stop="(event) => startResizeEdge(event, 'bottom')"
class="absolute -bottom-1 left-2 right-2 h-2 bg-blue-500 cursor-s-resize pointer-events-auto rounded-sm shadow-sm hover:bg-blue-600 transition-all"
title="Redimensionar alto"
>
<!-- Indicador visual en el centro -->
<div class="absolute top-1/2 left-1/2 w-4 h-0.5 bg-white/60 -translate-x-1/2 -translate-y-1/2 rounded-full"></div>
</div>
<!-- Esquinas adicionales para mejor UX -->
<div
@mousedown.stop="startResize"
class="absolute -top-2 -left-2 w-4 h-4 bg-blue-500 border-2 border-white cursor-nw-resize pointer-events-auto rounded-sm shadow-md hover:bg-blue-600 transition-all"
title="Redimensionar"
>
<div class="absolute inset-0.5 bg-white/30 rounded-sm"></div>
</div>
<div
@mousedown.stop="startResize"
class="absolute -top-2 -right-2 w-4 h-4 bg-blue-500 border-2 border-white cursor-ne-resize pointer-events-auto rounded-sm shadow-md hover:bg-blue-600 transition-all"
title="Redimensionar"
>
<div class="absolute inset-0.5 bg-white/30 rounded-sm"></div>
</div>
<div
@mousedown.stop="startResize"
class="absolute -bottom-2 -left-2 w-4 h-4 bg-blue-500 border-2 border-white cursor-sw-resize pointer-events-auto rounded-sm shadow-md hover:bg-blue-600 transition-all"
title="Redimensionar"
>
<div class="absolute inset-0.5 bg-white/30 rounded-sm"></div>
</div>
</div>
<!-- Indicador de arrastre -->
<div
v-if="isDragging"
class="absolute inset-0 bg-blue-500 opacity-20 rounded pointer-events-none"
></div>
<!-- Indicador de redimensionamiento -->
<div
v-if="isResizing"
class="absolute inset-0 bg-green-500 opacity-20 rounded pointer-events-none"
></div>
<!-- Botón para terminar edición de tabla -->
<div
v-if="isEditing && element.type === 'table'"
class="absolute -bottom-10 left-0 flex gap-2 z-[60]"
>
<button
@click="finishEditing"
class="px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-xs rounded shadow-sm transition-colors"
>
Guardar
</button>
<button
@click="() => { isEditing = false; editValue = JSON.parse(JSON.stringify(element.content)); }"
class="px-3 py-1 bg-gray-600 hover:bg-gray-700 text-white text-xs rounded shadow-sm transition-colors"
>
Cancelar
</button>
</div>
</div>
</template>
<style scoped>
/* Estilos existentes sin cambios... */
.resize-handle-corner {
transition: all 0.2s ease;
}
.resize-handle-corner:hover {
transform: scale(1.1);
}
.resize-handle-edge {
transition: all 0.2s ease;
opacity: 0.7;
}
.resize-handle-edge:hover {
opacity: 1;
transform: scale(1.05);
}
.group:hover .resize-handle-edge {
opacity: 0.8;
}
.select-none {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
</style>

View File

@ -1,64 +0,0 @@
<script setup>
import { ref } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Propiedades */
const props = defineProps({
type: {
type: String,
required: true,
},
icon: String,
title: String,
description: String,
});
/** Eventos */
const emit = defineEmits(['dragstart']);
/** Referencias */
const isDragging = ref(false);
/** Métodos */
const handleDragStart = (event) => {
isDragging.value = true;
event.dataTransfer.setData('text/plain', JSON.stringify({
type: props.type,
title: props.title
}));
emit('dragstart', props.type);
};
const handleDragEnd = () => {
isDragging.value = false;
};
</script>
<template>
<div
draggable="true"
@dragstart="handleDragStart"
@dragend="handleDragEnd"
class="flex items-center gap-3 p-3 rounded-lg border border-gray-200 bg-white cursor-grab hover:bg-gray-50 hover:border-blue-300 transition-colors dark:bg-primary-d dark:border-primary/20 dark:hover:bg-primary/10"
:class="{
'opacity-50 cursor-grabbing': isDragging,
'shadow-sm hover:shadow-md': !isDragging
}"
>
<div class="flex-shrink-0 w-8 h-8 rounded-md bg-blue-100 flex items-center justify-center dark:bg-blue-900/30">
<GoogleIcon
:name="icon"
class="text-blue-600 dark:text-blue-400 text-lg"
/>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-900 dark:text-primary-dt">
{{ title }}
</div>
<div class="text-xs text-gray-500 dark:text-primary-dt/70 truncate">
{{ description }}
</div>
</div>
</div>
</template>

View File

@ -1,353 +0,0 @@
<script setup>
import { ref, computed, nextTick, watch } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import PageSizeSelector from '@Holos/PDF/PageSizeSelector.vue';
/** Propiedades */
const props = defineProps({
pages: {
type: Array,
default: () => [{ id: 1, elements: [] }]
},
selectedElementId: String,
isExporting: Boolean
});
/** Eventos */
const emit = defineEmits(['drop', 'dragover', 'click', 'add-page', 'delete-page', 'page-change', 'page-size-change']);
/** Referencias */
const viewportRef = ref(null);
const currentPage = ref(1);
const pageSize = ref('A4');
/** Tamaños de página */
const pageSizes = {
'A4': { width: 794, height: 1123, label: '210 × 297 mm' },
'A3': { width: 1123, height: 1587, label: '297 × 420 mm' },
'Letter': { width: 816, height: 1056, label: '216 × 279 mm' },
'Legal': { width: 816, height: 1344, label: '216 × 356 mm' },
'Tabloid': { width: 1056, height: 1632, label: '279 × 432 mm' }
};
/** Constantes de diseño ajustadas */
const PAGE_MARGIN = 50;
const ZOOM_LEVEL = 0.65;
/** Propiedades computadas */
const currentPageSize = computed(() => pageSizes[pageSize.value]);
const PAGE_WIDTH = computed(() => currentPageSize.value.width);
const PAGE_HEIGHT = computed(() => currentPageSize.value.height);
const scaledPageWidth = computed(() => PAGE_WIDTH.value * ZOOM_LEVEL);
const scaledPageHeight = computed(() => PAGE_HEIGHT.value * ZOOM_LEVEL);
const totalPages = computed(() => props.pages.length);
/** Watchers */
watch(pageSize, (newSize) => {
emit('page-size-change', {
size: newSize,
dimensions: pageSizes[newSize]
});
});
/** Métodos */
const handleDrop = (event, pageIndex) => {
event.preventDefault();
const pageElement = event.currentTarget;
const rect = pageElement.getBoundingClientRect();
const relativeX = (event.clientX - rect.left) / ZOOM_LEVEL;
const relativeY = (event.clientY - rect.top) / ZOOM_LEVEL;
emit('drop', {
originalEvent: event,
pageIndex,
x: Math.max(0, Math.min(PAGE_WIDTH.value, relativeX)),
y: Math.max(0, Math.min(PAGE_HEIGHT.value, relativeY))
});
};
const handleDragOver = (event) => {
event.preventDefault();
emit('dragover', event);
};
const handleClick = (event, pageIndex) => {
if (event.target.classList.contains('pdf-page')) {
emit('click', { originalEvent: event, pageIndex });
}
};
const handleNextPage = () => {
if (currentPage.value >= totalPages.value) {
addPage();
} else {
setCurrentPage(currentPage.value + 1);
}
};
const addPage = () => {
emit('add-page');
// Solo cambiar a la nueva página cuando se agrega una
nextTick(() => {
const newPageNumber = totalPages.value + 1;
setCurrentPage(newPageNumber);
});
};
const deletePage = (pageIndex) => {
if (totalPages.value > 1) {
emit('delete-page', pageIndex);
}
};
const scrollToPage = (pageNumber) => {
if (viewportRef.value) {
const pageElement = viewportRef.value.querySelector(`[data-page="${pageNumber}"]`);
if (pageElement) {
pageElement.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center'
});
}
}
};
const setCurrentPage = (pageNumber) => {
currentPage.value = pageNumber;
emit('page-change', pageNumber);
// Mantener la página actual centrada
nextTick(() => {
scrollToPage(pageNumber);
});
};
/** Métodos expuestos */
defineExpose({
scrollToPage,
setCurrentPage,
PAGE_WIDTH,
PAGE_HEIGHT,
ZOOM_LEVEL
});
</script>
<template>
<div class="flex-1 flex flex-col bg-gray-100 dark:bg-primary-d/20">
<!-- Toolbar de páginas -->
<div class="flex items-center justify-between px-4 py-3 bg-white dark:bg-primary-d border-b border-gray-200 dark:border-primary/20">
<div class="flex items-center gap-4">
<span class="text-sm font-medium text-gray-700 dark:text-primary-dt">
Página {{ currentPage }} de {{ totalPages }}
</span>
<div class="flex items-center gap-1 border-l border-gray-200 dark:border-primary/20 pl-4">
<button
@click="setCurrentPage(Math.max(1, currentPage - 1))"
:disabled="currentPage <= 1"
class="p-1.5 text-gray-400 hover:text-gray-600 disabled:opacity-50 disabled:cursor-not-allowed dark:text-primary-dt/70 dark:hover:text-primary-dt rounded hover:bg-gray-100 dark:hover:bg-primary/10"
title="Página anterior"
>
<GoogleIcon name="keyboard_arrow_left" class="text-lg" />
</button>
<button
@click="handleNextPage"
:disabled="isExporting"
class="p-1.5 text-gray-400 hover:text-gray-600 disabled:opacity-50 disabled:cursor-not-allowed dark:text-primary-dt/70 dark:hover:text-primary-dt rounded hover:bg-gray-100 dark:hover:bg-primary/10 relative"
:title="currentPage >= totalPages ? 'Crear nueva página' : 'Página siguiente'"
>
<GoogleIcon name="keyboard_arrow_right" class="text-lg" />
<!-- Indicador solo cuando estamos en la última página -->
<GoogleIcon
v-if="currentPage >= totalPages"
name="add"
class="absolute -top-1 -right-1 text-xs text-green-500 bg-white rounded-full"
/>
</button>
</div>
</div>
<div class="flex items-center gap-4">
<!-- Selector de tamaño de página -->
<PageSizeSelector v-model="pageSize" />
<span class="text-xs text-gray-500 dark:text-primary-dt/70 bg-gray-50 dark:bg-primary/10 px-2 py-1 rounded">
{{ Math.round(ZOOM_LEVEL * 100) }}% {{ currentPageSize.label }}
</span>
<button
@click="addPage"
:disabled="isExporting"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors disabled:opacity-50 font-medium"
>
<GoogleIcon name="add" class="text-sm" />
Nueva Página
</button>
</div>
</div>
<!-- Viewport de páginas horizontal -->
<div
ref="viewportRef"
class="flex-1 overflow-auto"
style="background-color: #f8fafc; background-image: radial-gradient(circle, #e2e8f0 1px, transparent 1px); background-size: 24px 24px;"
>
<!-- Contenedor horizontal centrado -->
<div class="flex items-center justify-center min-h-full p-6">
<div class="flex items-center gap-8">
<!-- Páginas -->
<div
v-for="(page, pageIndex) in pages"
:key="page.id"
:data-page="pageIndex + 1"
class="relative group flex-shrink-0"
>
<!-- Header de página -->
<div class="flex flex-col items-center mb-3">
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-gray-600 dark:text-primary-dt/80">
Página {{ pageIndex + 1 }}
</span>
<button
v-if="totalPages > 1"
@click="deletePage(pageIndex)"
:disabled="isExporting"
class="opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-700 disabled:opacity-50 p-1 rounded hover:bg-red-50 transition-all"
title="Eliminar página"
>
<GoogleIcon name="delete" class="text-sm" />
</button>
</div>
<span class="text-xs text-gray-400 dark:text-primary-dt/50">
{{ currentPageSize.label }}
</span>
</div>
<!-- Contenedor de página con sombra -->
<div class="relative">
<!-- Sombra de página -->
<div class="absolute top-2 left-2 w-full h-full bg-gray-400/30 rounded-lg"></div>
<!-- Página PDF -->
<div
class="pdf-page relative bg-white rounded-lg border border-gray-300 dark:border-primary/20 overflow-hidden"
:class="{
'ring-2 ring-blue-500 ring-opacity-50 shadow-lg': currentPage === pageIndex + 1,
'shadow-md hover:shadow-lg': currentPage !== pageIndex + 1,
'opacity-50': isExporting
}"
:style="{
width: `${scaledPageWidth}px`,
height: `${scaledPageHeight}px`
}"
@drop="(e) => handleDrop(e, pageIndex)"
@dragover="handleDragOver"
@click="(e) => handleClick(e, pageIndex)"
>
<!-- Área de contenido con márgenes visuales -->
<div class="relative w-full h-full">
<!-- Guías de margen -->
<div
class="absolute border border-dashed border-blue-300/40 pointer-events-none"
:style="{
top: `${PAGE_MARGIN * ZOOM_LEVEL}px`,
left: `${PAGE_MARGIN * ZOOM_LEVEL}px`,
width: `${(PAGE_WIDTH - (PAGE_MARGIN * 2)) * ZOOM_LEVEL}px`,
height: `${(PAGE_HEIGHT - (PAGE_MARGIN * 2)) * ZOOM_LEVEL}px`
}"
></div>
<!-- Elementos de la página con transformación -->
<div
class="absolute inset-0"
:style="{
transform: `scale(${ZOOM_LEVEL})`,
transformOrigin: 'top left',
width: `${PAGE_WIDTH}px`,
height: `${PAGE_HEIGHT}px`
}"
>
<slot
name="elements"
:page="page"
:pageIndex="pageIndex"
:pageWidth="PAGE_WIDTH"
:pageHeight="PAGE_HEIGHT"
:zoomLevel="ZOOM_LEVEL"
/>
</div>
</div>
<!-- Indicador de página vacía -->
<div
v-if="page.elements.length === 0"
class="absolute inset-0 flex items-center justify-center pointer-events-none z-10"
:style="{ transform: `scale(${1/ZOOM_LEVEL})` }"
>
<div class="text-center text-gray-400 dark:text-primary-dt/50">
<GoogleIcon name="description" class="text-4xl mb-2" />
<p class="text-sm font-medium">Página {{ pageIndex + 1 }}</p>
<p class="text-xs">Arrastra elementos aquí</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Overlay durante exportación -->
<div
v-if="isExporting"
class="absolute inset-0 bg-white/90 dark:bg-primary-d/90 flex items-center justify-center z-50 backdrop-blur-sm"
>
<div class="text-center bg-white dark:bg-primary-d rounded-lg p-6 shadow-lg border border-gray-200 dark:border-primary/20">
<GoogleIcon name="picture_as_pdf" class="text-5xl text-red-600 dark:text-red-400 animate-pulse mb-3" />
<p class="text-lg font-semibold text-gray-900 dark:text-primary-dt mb-1">Generando PDF...</p>
<p class="text-sm text-gray-500 dark:text-primary-dt/70">Procesando {{ totalPages }} página{{ totalPages !== 1 ? 's' : '' }}</p>
</div>
</div>
</div>
</template>
<style scoped>
.pdf-page {
transition: all 0.3s ease;
position: relative;
}
.pdf-page:hover {
transform: translateY(-2px);
}
.pdf-page.ring-2 {
transform: translateY(-4px);
}
.overflow-auto {
scroll-behavior: smooth;
}
.overflow-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-auto::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
.overflow-auto::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.overflow-auto::-webkit-scrollbar {
height: 8px;
width: 8px;
}
</style>

View File

@ -1,141 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Propiedades */
const props = defineProps({
modelValue: {
type: String,
default: 'A4'
}
});
/** Eventos */
const emit = defineEmits(['update:modelValue']);
/** Referencias */
const isOpen = ref(false);
/** Tamaños de página disponibles */
const pageSizes = [
{
name: 'A4',
label: 'A4 (210 x 297 mm)',
width: 794,
height: 1123,
description: 'Estándar internacional'
},
{
name: 'A3',
label: 'A3 (297 x 420 mm)',
width: 1123,
height: 1587,
description: 'Doble de A4'
},
{
name: 'Letter',
label: 'Carta (216 x 279 mm)',
width: 816,
height: 1056,
description: 'Estándar US'
},
{
name: 'Legal',
label: 'Oficio (216 x 356 mm)',
width: 816,
height: 1344,
description: 'Legal US'
},
{
name: 'Tabloid',
label: 'Tabloide (279 x 432 mm)',
width: 1056,
height: 1632,
description: 'Doble carta'
}
];
/** Propiedades computadas */
const selectedSize = computed(() => {
return pageSizes.find(size => size.name === props.modelValue) || pageSizes[0];
});
/** Métodos */
const selectSize = (size) => {
emit('update:modelValue', size.name);
isOpen.value = false;
};
</script>
<template>
<div class="relative">
<!-- Selector principal -->
<button
@click="isOpen = !isOpen"
class="flex items-center gap-2 px-3 py-2 text-sm bg-white dark:bg-primary-d border border-gray-200 dark:border-primary/20 rounded-md hover:bg-gray-50 dark:hover:bg-primary/10 transition-colors"
>
<GoogleIcon name="aspect_ratio" class="text-gray-500 dark:text-primary-dt/70" />
<span class="text-gray-700 dark:text-primary-dt">{{ selectedSize.name }}</span>
<GoogleIcon
name="expand_more"
class="text-gray-400 dark:text-primary-dt/50 transition-transform"
:class="{ 'rotate-180': isOpen }"
/>
</button>
<!-- Dropdown -->
<div
v-if="isOpen"
@click.away="isOpen = false"
class="absolute top-full left-0 mt-1 w-72 bg-white dark:bg-primary-d border border-gray-200 dark:border-primary/20 rounded-lg shadow-lg z-50 py-2"
>
<div class="px-3 py-2 text-xs font-semibold text-gray-500 dark:text-primary-dt/70 uppercase tracking-wider border-b border-gray-100 dark:border-primary/20">
Tamaños de página
</div>
<div class="max-h-64 overflow-y-auto">
<button
v-for="size in pageSizes"
:key="size.name"
@click="selectSize(size)"
class="w-full flex items-center gap-3 px-3 py-3 hover:bg-gray-50 dark:hover:bg-primary/10 transition-colors text-left"
:class="{
'bg-blue-50 dark:bg-blue-900/20': selectedSize.name === size.name
}"
>
<div class="flex-shrink-0">
<div
class="w-8 h-10 border border-gray-300 dark:border-primary/30 rounded-sm bg-white dark:bg-primary-d flex items-center justify-center"
:class="{
'border-blue-500 dark:border-blue-400': selectedSize.name === size.name
}"
>
<div
class="bg-gray-200 dark:bg-primary/20 rounded-sm"
:style="{
width: `${Math.min(20, (size.width / size.height) * 32)}px`,
height: `${Math.min(32, (size.height / size.width) * 20)}px`
}"
:class="{
'bg-blue-200 dark:bg-blue-800': selectedSize.name === size.name
}"
></div>
</div>
</div>
<div class="flex-1 min-w-0">
<div class="font-medium text-gray-900 dark:text-primary-dt">{{ size.label }}</div>
<div class="text-xs text-gray-500 dark:text-primary-dt/70">{{ size.description }}</div>
<div class="text-xs text-gray-400 dark:text-primary-dt/50 mt-1">
{{ size.width }} x {{ size.height }} px
</div>
</div>
<div v-if="selectedSize.name === size.name" class="flex-shrink-0">
<GoogleIcon name="check" class="text-blue-500 dark:text-blue-400" />
</div>
</button>
</div>
</div>
</div>
</template>

View File

@ -1,233 +0,0 @@
<script setup>
import { ref, computed, watch } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Propiedades */
const props = defineProps({
element: {
type: Object,
default: null
},
visible: {
type: Boolean,
default: false
}
});
/** Eventos */
const emit = defineEmits(['update']);
/** Propiedades computadas */
const formatting = computed(() => props.element?.formatting || {});
const hasTextElement = computed(() => props.element?.type === 'text');
/** Métodos */
const toggleBold = () => {
if (!hasTextElement.value) return;
updateFormatting('bold', !formatting.value.bold);
};
const toggleItalic = () => {
if (!hasTextElement.value) return;
updateFormatting('italic', !formatting.value.italic);
};
const toggleUnderline = () => {
if (!hasTextElement.value) return;
updateFormatting('underline', !formatting.value.underline);
};
const updateFontSize = (size) => {
if (!hasTextElement.value) return;
updateFormatting('fontSize', size);
};
const updateTextAlign = (align) => {
if (!hasTextElement.value) return;
updateFormatting('textAlign', align);
};
const updateColor = (color) => {
if (!hasTextElement.value) return;
updateFormatting('color', color);
};
const updateFormatting = (key, value) => {
const newFormatting = { ...formatting.value, [key]: value };
emit('update', {
id: props.element.id,
formatting: newFormatting
});
};
/** Colores predefinidos */
const predefinedColors = [
'#000000', '#333333', '#666666', '#999999',
'#FF0000', '#00FF00', '#0000FF', '#FFFF00',
'#FF00FF', '#00FFFF', '#FFA500', '#800080'
];
/** Tamaños de fuente */
const fontSizes = [8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 72];
</script>
<template>
<div
v-if="visible && hasTextElement"
class="flex items-center gap-6 px-4 py-2 bg-gray-50 dark:bg-primary-d/50 border-b border-gray-200 dark:border-primary/20"
>
<!-- Estilo de texto -->
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-600 dark:text-primary-dt/80">Estilo:</span>
<div class="flex gap-1">
<button
@click="toggleBold"
:class="[
'w-8 h-8 rounded flex items-center justify-center text-sm font-bold transition-colors',
formatting.bold
? 'bg-blue-500 text-white shadow-sm'
: 'bg-white text-gray-700 hover:bg-blue-50 border border-gray-200 dark:bg-primary-d dark:text-primary-dt dark:hover:bg-primary/10 dark:border-primary/20'
]"
title="Negrita (Ctrl+B)"
>
B
</button>
<button
@click="toggleItalic"
:class="[
'w-8 h-8 rounded flex items-center justify-center text-sm italic transition-colors',
formatting.italic
? 'bg-blue-500 text-white shadow-sm'
: 'bg-white text-gray-700 hover:bg-blue-50 border border-gray-200 dark:bg-primary-d dark:text-primary-dt dark:hover:bg-primary/10 dark:border-primary/20'
]"
title="Cursiva (Ctrl+I)"
>
I
</button>
<button
@click="toggleUnderline"
:class="[
'w-8 h-8 rounded flex items-center justify-center text-sm underline transition-colors',
formatting.underline
? 'bg-blue-500 text-white shadow-sm'
: 'bg-white text-gray-700 hover:bg-blue-50 border border-gray-200 dark:bg-primary-d dark:text-primary-dt dark:hover:bg-primary/10 dark:border-primary/20'
]"
title="Subrayado (Ctrl+U)"
>
U
</button>
</div>
</div>
<!-- Separador -->
<div class="w-px h-6 bg-gray-300 dark:bg-primary/30"></div>
<!-- Tamaño de fuente -->
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-600 dark:text-primary-dt/80">Tamaño:</span>
<select
:value="formatting.fontSize || 12"
@change="updateFontSize(parseInt($event.target.value))"
class="px-2 py-1 text-sm border border-gray-200 rounded bg-white dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option v-for="size in fontSizes" :key="size" :value="size">
{{ size }}px
</option>
</select>
</div>
<!-- Separador -->
<div class="w-px h-6 bg-gray-300 dark:bg-primary/30"></div>
<!-- Alineación -->
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-600 dark:text-primary-dt/80">Alinear:</span>
<div class="flex gap-1">
<button
@click="updateTextAlign('left')"
:class="[
'w-8 h-8 rounded flex items-center justify-center transition-colors',
(formatting.textAlign || 'left') === 'left'
? 'bg-blue-500 text-white shadow-sm'
: 'bg-white text-gray-700 hover:bg-blue-50 border border-gray-200 dark:bg-primary-d dark:text-primary-dt dark:hover:bg-primary/10 dark:border-primary/20'
]"
title="Alinear izquierda"
>
<GoogleIcon name="format_align_left" class="text-sm" />
</button>
<button
@click="updateTextAlign('center')"
:class="[
'w-8 h-8 rounded flex items-center justify-center transition-colors',
formatting.textAlign === 'center'
? 'bg-blue-500 text-white shadow-sm'
: 'bg-white text-gray-700 hover:bg-blue-50 border border-gray-200 dark:bg-primary-d dark:text-primary-dt dark:hover:bg-primary/10 dark:border-primary/20'
]"
title="Centrar"
>
<GoogleIcon name="format_align_center" class="text-sm" />
</button>
<button
@click="updateTextAlign('right')"
:class="[
'w-8 h-8 rounded flex items-center justify-center transition-colors',
formatting.textAlign === 'right'
? 'bg-blue-500 text-white shadow-sm'
: 'bg-white text-gray-700 hover:bg-blue-50 border border-gray-200 dark:bg-primary-d dark:text-primary-dt dark:hover:bg-primary/10 dark:border-primary/20'
]"
title="Alinear derecha"
>
<GoogleIcon name="format_align_right" class="text-sm" />
</button>
</div>
</div>
<!-- Separador -->
<div class="w-px h-6 bg-gray-300 dark:bg-primary/30"></div>
<!-- Color de texto -->
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-600 dark:text-primary-dt/80">Color:</span>
<div class="flex items-center gap-2">
<!-- Color actual -->
<div
class="w-8 h-8 rounded border-2 border-gray-300 cursor-pointer relative overflow-hidden"
:style="{ backgroundColor: formatting.color || '#000000' }"
title="Color actual"
>
<input
type="color"
:value="formatting.color || '#000000'"
@input="updateColor($event.target.value)"
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
</div>
<!-- Colores rápidos -->
<div class="flex gap-1">
<button
v-for="color in predefinedColors.slice(0, 6)"
:key="color"
@click="updateColor(color)"
class="w-6 h-6 rounded border border-gray-300 hover:scale-110 transition-transform"
:class="{
'ring-2 ring-blue-500': (formatting.color || '#000000') === color
}"
:style="{ backgroundColor: color }"
:title="color"
></button>
</div>
</div>
</div>
<!-- Información del elemento -->
<div class="ml-auto flex items-center gap-2 text-xs text-gray-500 dark:text-primary-dt/70">
<GoogleIcon name="text_fields" class="text-sm" />
<span>Elemento de texto seleccionado</span>
</div>
</div>
</template>

View File

@ -1,261 +0,0 @@
<script setup>
import { ref, computed, nextTick } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Propiedades */
const props = defineProps({
pages: {
type: Array,
default: () => [{ id: 1, elements: [] }]
},
selectedElementId: String,
isExporting: Boolean
});
/** Eventos */
const emit = defineEmits(['drop', 'dragover', 'click', 'add-page', 'delete-page', 'page-change']);
/** Referencias */
const viewportRef = ref(null);
const currentPage = ref(1);
/** Constantes de diseño */
const PAGE_WIDTH = 794; // A4 width in pixels (210mm @ 96dpi * 3.78)
const PAGE_HEIGHT = 1123; // A4 height in pixels (297mm @ 96dpi * 3.78)
const PAGE_MARGIN = 40;
const ZOOM_LEVEL = 0.8; // Factor de escala para visualización
/** Propiedades computadas */
const scaledPageWidth = computed(() => PAGE_WIDTH * ZOOM_LEVEL);
const scaledPageHeight = computed(() => PAGE_HEIGHT * ZOOM_LEVEL);
const totalPages = computed(() => props.pages.length);
/** Métodos */
const handleDrop = (event, pageIndex) => {
event.preventDefault();
const pageElement = event.currentTarget;
const rect = pageElement.getBoundingClientRect();
// Calcular posición relativa a la página específica
const relativeX = (event.clientX - rect.left) / ZOOM_LEVEL;
const relativeY = (event.clientY - rect.top) / ZOOM_LEVEL;
emit('drop', {
originalEvent: event,
pageIndex,
x: Math.max(PAGE_MARGIN, Math.min(PAGE_WIDTH - PAGE_MARGIN, relativeX)),
y: Math.max(PAGE_MARGIN, Math.min(PAGE_HEIGHT - PAGE_MARGIN, relativeY))
});
};
const handleDragOver = (event) => {
event.preventDefault();
emit('dragover', event);
};
const handleClick = (event, pageIndex) => {
if (event.target.classList.contains('pdf-page')) {
emit('click', { originalEvent: event, pageIndex });
}
};
const addPage = () => {
emit('add-page');
nextTick(() => {
scrollToPage(totalPages.value);
});
};
const deletePage = (pageIndex) => {
if (totalPages.value > 1) {
emit('delete-page', pageIndex);
}
};
const scrollToPage = (pageNumber) => {
if (viewportRef.value) {
const pageElement = viewportRef.value.querySelector(`[data-page="${pageNumber}"]`);
if (pageElement) {
pageElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
};
const setCurrentPage = (pageNumber) => {
currentPage.value = pageNumber;
emit('page-change', pageNumber);
};
/** Métodos expuestos */
defineExpose({
scrollToPage,
setCurrentPage,
PAGE_WIDTH,
PAGE_HEIGHT,
ZOOM_LEVEL
});
</script>
<template>
<div class="flex-1 flex flex-col bg-gray-100 dark:bg-primary-d/20">
<!-- Toolbar de páginas -->
<div class="flex items-center justify-between px-4 py-2 bg-white dark:bg-primary-d border-b border-gray-200 dark:border-primary/20">
<div class="flex items-center gap-3">
<span class="text-sm text-gray-600 dark:text-primary-dt">
Página {{ currentPage }} de {{ totalPages }}
</span>
<div class="flex items-center gap-1">
<button
@click="setCurrentPage(Math.max(1, currentPage - 1))"
:disabled="currentPage <= 1"
class="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-50 disabled:cursor-not-allowed dark:text-primary-dt/70 dark:hover:text-primary-dt"
>
<GoogleIcon name="keyboard_arrow_left" class="text-lg" />
</button>
<button
@click="setCurrentPage(Math.min(totalPages, currentPage + 1))"
:disabled="currentPage >= totalPages"
class="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-50 disabled:cursor-not-allowed dark:text-primary-dt/70 dark:hover:text-primary-dt"
>
<GoogleIcon name="keyboard_arrow_right" class="text-lg" />
</button>
</div>
</div>
<div class="flex items-center gap-2">
<button
@click="addPage"
:disabled="isExporting"
class="flex items-center gap-1 px-2 py-1 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors disabled:opacity-50"
>
<GoogleIcon name="add" class="text-sm" />
Nueva Página
</button>
</div>
</div>
<!-- Viewport de páginas -->
<div
ref="viewportRef"
class="flex-1 overflow-auto p-8"
style="background: linear-gradient(45deg, #f0f0f0 25%, transparent 25%), linear-gradient(-45deg, #f0f0f0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #f0f0f0 75%), linear-gradient(-45deg, transparent 75%, #f0f0f0 75%); background-size: 20px 20px; background-position: 0 0, 0 10px, 10px -10px, -10px 0px;"
>
<div class="flex flex-col items-center gap-8">
<!-- Páginas -->
<div
v-for="(page, pageIndex) in pages"
:key="page.id"
:data-page="pageIndex + 1"
class="relative"
@mouseenter="setCurrentPage(pageIndex + 1)"
>
<!-- Número de página -->
<div class="absolute -top-6 left-0 flex items-center gap-2 text-xs text-gray-500 dark:text-primary-dt/70">
<span>Página {{ pageIndex + 1 }}</span>
<button
v-if="totalPages > 1"
@click="deletePage(pageIndex)"
:disabled="isExporting"
class="text-red-500 hover:text-red-700 disabled:opacity-50"
title="Eliminar página"
>
<GoogleIcon name="delete" class="text-sm" />
</button>
</div>
<!-- Página PDF -->
<div
class="pdf-page relative bg-white rounded-lg shadow-lg border border-gray-300 dark:border-primary/20"
:class="{
'ring-2 ring-blue-500': currentPage === pageIndex + 1,
'opacity-50': isExporting
}"
:style="{
width: `${scaledPageWidth}px`,
height: `${scaledPageHeight}px`,
transform: `scale(${ZOOM_LEVEL})`,
transformOrigin: 'top left'
}"
@drop="(e) => handleDrop(e, pageIndex)"
@dragover="handleDragOver"
@click="(e) => handleClick(e, pageIndex)"
>
<!-- Márgenes visuales -->
<div
class="absolute border border-dashed border-gray-300 dark:border-primary/30 pointer-events-none"
:style="{
top: `${PAGE_MARGIN}px`,
left: `${PAGE_MARGIN}px`,
width: `${PAGE_WIDTH - (PAGE_MARGIN * 2)}px`,
height: `${PAGE_HEIGHT - (PAGE_MARGIN * 2)}px`
}"
></div>
<!-- Elementos de la página -->
<slot
name="elements"
:page="page"
:pageIndex="pageIndex"
:pageWidth="PAGE_WIDTH"
:pageHeight="PAGE_HEIGHT"
/>
<!-- Indicador de página vacía -->
<div
v-if="page.elements.length === 0"
class="absolute inset-0 flex items-center justify-center pointer-events-none"
>
<div class="text-center text-gray-400 dark:text-primary-dt/50">
<GoogleIcon name="description" class="text-4xl mb-2" />
<p class="text-sm">Página {{ pageIndex + 1 }}</p>
<p class="text-xs">Arrastra elementos aquí</p>
</div>
</div>
</div>
<!-- Regla inferior (tamaño de referencia) -->
<div class="mt-2 text-xs text-gray-400 dark:text-primary-dt/50 text-center">
210 × 297 mm (A4)
</div>
</div>
<!-- Botón para agregar página al final -->
<button
@click="addPage"
:disabled="isExporting"
class="flex flex-col items-center justify-center w-40 h-20 border-2 border-dashed border-gray-300 dark:border-primary/30 rounded-lg hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors disabled:opacity-50"
>
<GoogleIcon name="add" class="text-2xl text-gray-400 dark:text-primary-dt/50" />
<span class="text-xs text-gray-500 dark:text-primary-dt/70 mt-1">Nueva Página</span>
</button>
</div>
</div>
<!-- Overlay durante exportación -->
<div
v-if="isExporting"
class="absolute inset-0 bg-white/80 dark:bg-primary-d/80 flex items-center justify-center z-50"
>
<div class="text-center">
<GoogleIcon name="picture_as_pdf" class="text-4xl text-red-600 dark:text-red-400 animate-pulse mb-2" />
<p class="text-sm font-medium text-gray-900 dark:text-primary-dt">Generando PDF...</p>
<p class="text-xs text-gray-500 dark:text-primary-dt/70">Procesando {{ totalPages }} página{{ totalPages !== 1 ? 's' : '' }}</p>
</div>
</div>
</div>
</template>
<style scoped>
.pdf-page {
transition: all 0.2s ease;
}
.pdf-page:hover {
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}
</style>

View File

@ -1,46 +0,0 @@
<script setup>
import { RouterLink, useRoute } from 'vue-router';
import { breadcrumbItem, breadcrumbMeta } from '@Controllers/BreadcrumbController.js';
import IconButton from '@Holos/Button/Icon.vue'
import BreadcrumbContainer from '@Holos/Breadcrumb/Container.vue'
import BreadcrumbItem from '@Holos/Breadcrumb/Item.vue'
/** Definidores */
const vroute = useRoute();
const props = defineProps({
title: String
});
const breadcrumbs = breadcrumbMeta(vroute);
</script>
<template>
<div>
<BreadcrumbContainer>
<template v-for="(item, index) in breadcrumbs" :key="item.name">
<BreadcrumbItem
:name="item.name"
:icon="item.icon"
:route="item.route"
:active="index === breadcrumbs.length - 1"
/>
</template>
</BreadcrumbContainer>
<div class="flex w-full justify-end py-[0.31rem] mb-2 border-y-2 border-page-t dark:border-page-dt">
<div id="buttons" class="flex items-center space-x-1 text-sm">
<slot />
<RouterLink :to="$view({ name: 'index' })">
<IconButton
:title="$t('home')"
class="text-white"
icon="refresh"
filled
/>
</RouterLink>
</div>
</div>
</div>
</template>

View File

@ -1,87 +0,0 @@
<script setup>
import GoogleIcon from '../Shared/GoogleIcon.vue';
import Loader from '../Shared/Loader.vue';
/** Eventos */
const emit = defineEmits([
'send-pagination'
]);
/** Propiedades */
const props = defineProps({
items: Object,
processing: Boolean
});
</script>
<template>
<section class="pb-2">
<div class="w-full overflow-hidden rounded-sm shadow-lg">
<div v-if="!processing" class="w-full overflow-x-auto">
<template v-if="items?.total > 0">
<slot
name="body"
:items="items?.data"
/>
</template>
<template v-else>
<template v-if="$slots.empty">
<slot name="empty" />
</template>
<template v-else>
<div class="flex p-2 items-center justify-center">
<p class="text-center text-page-t dark:text-page-dt">{{ $t('noRecords') }}</p>
</div>
</template>
</template>
</div>
<div v-else class="flex items-center justify-center">
<Loader />
</div>
</div>
</section>
<template v-if="items?.links">
<div v-if="items.links.length > 3" class="flex w-full justify-end">
<div class="flex w-full justify-end flex-wrap space-x-1 -mb-1">
<template v-for="(link, k) in items.links" :key="k">
<div v-if="link.url === null && k == 0"
class="px-2 py-1 text-sm leading-4 text-gray-400 border rounded"
>
<GoogleIcon
name="arrow_back"
/>
</div>
<button v-else-if="k === 0" class="px-2 py-1 text-sm leading-4 border rounded"
:class="{ 'bg-primary dark:bg-primary-dark text-white': link.active }"
@click="$emit('send-pagination', link.url)"
>
<GoogleIcon
name="arrow_back"
/>
</button>
<div v-else-if="link.url === null && k == (items.links.length - 1)"
class="px-2 py-1 text-sm leading-4 text-gray-400 border rounded"
>
<GoogleIcon
name="arrow_forward"
/>
</div>
<button v-else-if="k === (items.links.length - 1)" class="px-2 py-1 text-sm leading-4 border rounded"
:class="{ 'bg-primary dark:bg-primary-dark text-white': link.active }"
@click="$emit('send-pagination', link.url)"
>
<GoogleIcon
name="arrow_forward"
/>
</button>
<button v-else class="px-2 py-1 text-sm leading-4 border rounded"
:class="{ 'bg-primary dark:bg-primary-dark text-white': link.active }"
v-html="link.label"
@click="$emit('send-pagination', link.url)"
></button>
</template>
</div>
</div>
</template>
</template>

View File

@ -1,96 +0,0 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { breadcrumbMeta } from '@Controllers/BreadcrumbController.js';
import IconButton from '@Holos/Button/Icon.vue'
import GoogleIcon from '@Shared/GoogleIcon.vue';
import BreadcrumbContainer from '@Holos/Breadcrumb/Container.vue'
import BreadcrumbItem from '@Holos/Breadcrumb/Item.vue'
/** Definidores */
const vroute = useRoute();
/** Eventos */
const emit = defineEmits([
'search'
]);
/** Propiedades */
const props = defineProps({
title: String,
placeholder: {
default: Lang('search'),
type: String
}
})
const breadcrumbs = breadcrumbMeta(vroute);
const query = ref('');
/** Métodos */
const search = () => {
emit('search', query.value);
}
const clear = () => {
query.value = '';
search();
}
</script>
<template>
<BreadcrumbContainer>
<template v-for="(item, index) in breadcrumbs" :key="item.name">
<BreadcrumbItem
:name="item.name"
:icon="item.icon"
:route="item.route"
:active="index === breadcrumbs.length - 1"
/>
</template>
</BreadcrumbContainer>
<div class="flex w-full justify-between items-center border-y-2 border-page-t dark:border-page-dt">
<div>
<div class="relative py-1 z-0">
<div @click="search" class="absolute inset-y-0 right-2 flex items-center pl-3 cursor-pointer">
<GoogleIcon
:title="$t('search')"
class="text-xl text-gray-700 hover:scale-110 hover:text-danger"
name="search"
/>
<GoogleIcon
v-show="query"
:title="$t('clear')"
class="text-xl text-gray-700 hover:scale-110 hover:text-danger"
name="close"
@click="clear"
/>
</div>
<input
id="search"
class="bg-gray-100 border border-gray-300 text-gray-700 text-sm rounded-sm outline-0 focus:ring-primary focus:border-primary block sm:w-56 md:w-72 lg:w-80 pr-10 px-2.5 py-1"
autocomplete="off"
:placeholder="placeholder"
required
type="text"
v-model="query"
@keyup.enter="search"
/>
</div>
</div>
<div class="flex items-center space-x-1 text-sm" id="buttons">
<slot />
<RouterLink :to="$view({name:'index'})">
<IconButton
:title="$t('home')"
class="text-white !bg-blue-600"
icon="home"
filled
/>
</RouterLink>
</div>
</div>
</template>

View File

@ -1,9 +0,0 @@
<template>
<div class="hidden sm:block">
<div class="py-8">
<div
class="border-t border-gray-200"
/>
</div>
</div>
</template>

View File

@ -1,18 +0,0 @@
<template>
<div class="md:col-span-1 flex justify-between">
<div class="px-4 sm:px-0">
<h3 class="text-3xl font-extrabold text-gray-900 dark:text-primary-dt">
<slot name="title" />
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-primary-dt/70">
<slot name="description" />
</p>
</div>
<div class="px-4 sm:px-0">
<slot name="aside" />
</div>
</div>
</template>

View File

@ -1,123 +0,0 @@
<script setup>
import { users } from '@Plugins/AuthUsers'
import { hasPermission } from '@Plugins/RolePermission'
import { logout } from '@Services/Page';
import useDarkMode from '@Stores/DarkMode'
import useLeftSidebar from '@Stores/LeftSidebar'
import useNotificationSidebar from '@Stores/NotificationSidebar'
import useNotifier from '@Stores/Notifier'
import useLoader from '@Stores/Loader';
import Loader from '@Shared/Loader.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Dropdown from '../Dropdown.vue';
import DropdownLink from '../DropdownLink.vue';
/** Eventos */
const emit = defineEmits([
'open'
]);
/** Definidores */
const darkMode = useDarkMode()
const leftSidebar = useLeftSidebar()
const notificationSidebar = useNotificationSidebar()
const notifier = useNotifier()
const loader = useLoader()
</script>
<template>
<header
class="fixed px-2 w-[calc(100vw)] bg-transparent transition-all duration-300 z-50"
:class="{'md:w-[calc(100vw-16rem)]':leftSidebar.isOpened,'md:w-[calc(100vw)]':!leftSidebar.isClosed}"
>
<div class="my-2 flex px-2 items-center justify-between h-[2.75rem] rounded-sm bg-white dark:bg-primary-d text-gray-700 z-20 ">
<GoogleIcon
class="text-2xl mt-1 z-50"
name="list"
:title="$t('menu')"
@click="emit('open')"
outline
/>
<div class="flex w-fit justify-end items-center h-14 header-right">
<ul class="flex items-center space-x-2">
<li v-if="loader.isProcessing" class="flex items-center">
<Loader />
</li>
<template v-if="notifier.isEnabled">
<li v-if="hasPermission('users.online')">
<RouterLink :to="{ name: 'admin.users.online' }" class="flex items-center">
<GoogleIcon
class="text-xl mt-1"
name="connect_without_contact"
:title="$t('notifications.title')"
/>
<span class="text-xs">{{ users.length - 1 }}</span>
</RouterLink>
</li>
</template>
<li class="flex items-center">
<GoogleIcon
class="text-xl mt-1"
name="notifications"
:title="$t('notifications.title')"
@click="notificationSidebar.toggle()"
/>
<span class="text-xs">{{ notifier.counter }}</span>
</li>
<li v-if="darkMode.isDark">
<GoogleIcon
class="text-xl mt-1"
name="light_mode"
:title="$t('notifications.title')"
@click="darkMode.applyLight()"
/>
</li>
<li v-else>
<GoogleIcon
class="text-xl mt-1"
name="dark_mode"
:title="$t('notifications.title')"
@click="darkMode.applyDark()"
/>
</li>
<li>
<div class="relative">
<Dropdown align="right" width="48">
<template #trigger>
<div class="flex space-x-4">
<button
class="flex items-center space-x-4 text-sm border-2 border-transparent rounded-full focus:outline-hidden cursor-pointer transition"
:title="$t('users.menu')"
>
<img
class="h-8 w-8 rounded-sm object-cover"
:alt="$page.user.name"
:src="$page.user.profile_photo_url"
>
</button>
</div>
</template>
<template #content>
<div class="text-center block px-4 py-2 text-sm border-b truncate">
{{ $page.user.name }}
</div>
<DropdownLink to="profile.show">
{{$t('profile')}}
</DropdownLink>
<div class="border-t border-gray-100" />
<form @submit.prevent="logout">
<DropdownLink as="button">
{{$t('auth.logout')}}
</DropdownLink>
</form>
</template>
</Dropdown>
</div>
</li>
</ul>
</div>
</div>
</header>
</template>

View File

@ -1,54 +0,0 @@
<script setup>
import useLeftSidebar from '@Stores/LeftSidebar'
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Definidores */
const leftSidebar = useLeftSidebar()
/** Eventos */
const emit = defineEmits(['open']);
/** Propiedades */
const props = defineProps({
sidebar: Boolean
});
</script>
<template>
<div
class="fixed top-[3.1rem] md:top-0 w-fit h-[calc(100vh-3.1rem)] md:h-screen transition-all duration-300 z-50"
:class="{'-translate-x-0':leftSidebar.isOpened, '-translate-x-64':leftSidebar.isClosed}"
>
<nav
class="flex md:w-64 h-full transition-all duration-300 border-none"
:class="{'w-64': leftSidebar.isClosed, 'w-screen': leftSidebar.isOpened}"
>
<div class="flex flex-col h-full p-2 md:w-64">
<div class="flex h-full flex-col w-[15.5rem] justify-between rounded-sm overflow-y-auto overflow-x-hidden bg-white dark:bg-primary-d text-page-t dark:text-page-dt border-r border-gray-200 dark:border-primary/20">
<div class="py-4 bg-transparent">
<div class="flex flex-row justify-center items-center w-full px-4 mb-6 gap-2">
<GoogleIcon class="bg-[#2563eb] text-white text-3xl rounded p-1" name="apartment" />
<div class="flex flex-col items-center justify-center gap-1">
<h2 class="text-gray-800 dark:text-primary-dt font-bold text-xl"> HR Manager</h2>
<span class="text-sm text-gray-500 dark:text-primary-dt/70">Sistema de RRHH</span>
</div>
</div>
<ul class="flex h-full flex-col space-y-1 px-2">
<slot />
</ul>
</div>
<div class="mb-4 px-4 py-4 border-t border-gray-200">
<p class="text-center text-xs text-gray-400 dark:text-primary-dt/70">
2025 RH Manager &copy; Golsystems
</p>
</div>
</div>
</div>
<div
class="h-full"
:class="{'w-[calc(100vw-17rem)] dark:bg-black/40 md:w-0 bg-black/20':leftSidebar.isOpened,'md:w-0':leftSidebar.isClosed}"
@click="leftSidebar.toggle()"
></div>
</nav>
</div>
</template>

View File

@ -1,78 +0,0 @@
<script setup>
import { ref, computed } from "vue";
import { RouterLink, useRoute } from "vue-router";
import useLeftSidebar from "@Stores/LeftSidebar";
import GoogleIcon from "@Shared/GoogleIcon.vue";
/** Definidores */
const leftSidebar = useLeftSidebar();
const vroute = useRoute();
/** Propiedades */
const props = defineProps({
name: String,
icon: String,
to: String,
active: {
type: Boolean,
default: false,
},
collapsed: {
type: Boolean,
default: false,
},
});
const isCollapsed = ref(props.collapsed);
const closeSidebar = () => {
if (TwScreen.isDevice("phone") || TwScreen.isDevice("tablet")) {
leftSidebar.close();
}
};
const isActive = computed(() => props.active || props.to === vroute.name);
const classes = computed(() => {
return isActive.value
? "flex items-center px-4 py-2 mx-2 my-1 text-white !bg-blue-600 rounded-lg transition-all duration-200 !border-transparent"
: "flex items-center px-4 py-2 mx-2 my-1 text-gray-600 hover:bg-gray-100 rounded-lg transition-all duration-200";
});
</script>
<template>
<ul>
<li class="hidden md:block">
<div class="flex items-center px-2 py-2 rounded">
<button
class="dropdown-toggle w-full"
@click.stop="isCollapsed = !isCollapsed"
>
<RouterLink
:to="$view({ name: props.to })"
:class="classes"
class="flex items-center justify-between flex-1"
@click="closeSidebar"
>
<div class="flex items-center">
<GoogleIcon v-if="icon" :name="icon" class="text-xl mr-2" />
<span class="text-sm font-medium">{{ name }}</span>
</div>
<GoogleIcon
:name="isCollapsed ? 'expand_more' : 'expand_less'"
class="text-gray-400 text-lg"
/>
</RouterLink>
</button>
</div>
</li>
<div
class="transition-all duration-300 ease-in-out overflow-hidden"
:class="{ 'max-h-0': isCollapsed, 'max-h-96': !isCollapsed }"
>
<slot />
</div>
</ul>
</template>

View File

@ -1,60 +0,0 @@
<script setup>
import { APP_VERSION, APP_COPYRIGHT } from '@/config.js'
import useLeftSidebar from '@Stores/LeftSidebar'
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Definidores */
const leftSidebar = useLeftSidebar()
/** Eventos */
const emit = defineEmits(['open']);
/** Propiedades */
const props = defineProps({
sidebar: Boolean
});
const year = (new Date).getFullYear();
</script>
<template>
<div
class="fixed top-[3.1rem] md:top-0 w-fit h-[calc(100vh-3.1rem)] md:h-screen transition-all duration-300 z-50"
:class="{'-translate-x-0':leftSidebar.isOpened, '-translate-x-64':leftSidebar.isClosed}"
>
<nav
class="flex md:w-64 h-full transition-all duration-300 border-none"
:class="{'w-64': leftSidebar.isClosed, 'w-screen': leftSidebar.isOpened}"
>
<div class="flex flex-col h-full p-2 md:w-64">
<div class="flex h-full flex-col w-[15.5rem] justify-between rounded-sm overflow-y-auto overflow-x-hidden bg-white dark:bg-primary-d text-page-t dark:text-page-dt border-r border-gray-200 dark:border-primary/20">
<div class="py-4 bg-transparent">
<div class="flex flex-row justify-center items-center w-full px-4 mb-6 gap-2">
<GoogleIcon class="bg-[#2563eb] text-white text-3xl rounded p-1" name="apartment" />
<div class="flex flex-col items-center justify-center gap-1">
<h2 class="text-gray-800 dark:text-primary-dt font-bold text-xl"> HR Manager</h2>
<span class="text-sm text-gray-500 dark:text-primary-dt/70">Sistema de RRHH</span>
</div>
</div>
<ul class="flex h-full flex-col space-y-1 px-2">
<slot />
</ul>
</div>
<div class="mb-4 px-4 py-4 border-t border-gray-200">
<p class="text-center text-xs text-gray-400 dark:text-primary-dt/70">
&copy {{ year }} {{ APP_COPYRIGHT }}
</p>
<p class="text-center text-xs text-gray-400 dark:text-primary-dt/70">
APP {{ APP_VERSION }} API {{ $page.app.version }}
</p>
</div>
</div>
</div>
<div
class="h-full"
:class="{'w-[calc(100vw-17rem)] dark:bg-black/40 md:w-0 bg-black/20':leftSidebar.isOpened,'md:w-0':leftSidebar.isClosed}"
@click="leftSidebar.toggle()"
></div>
</nav>
</div>
</template>

View File

@ -1,71 +0,0 @@
<script setup>
import { computed } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
import useLeftSidebar from '@Stores/LeftSidebar';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Definidores */
const leftSidebar = useLeftSidebar();
const vroute = useRoute();
/** Propiedades */
const props = defineProps({
icon: String,
name: String,
to: String,
active: {
type: Boolean,
default: false
}
});
const classes = computed(() => {
let isActive = props.active || props.to === vroute.name;
if (isActive) {
return 'flex items-center px-4 py-3 mx-2 my-1 text-white !bg-blue-600 rounded-lg transition-all duration-200 !border-transparent';
} else {
return 'flex items-center px-4 py-3 mx-2 my-1 text-gray-600 hover:bg-gray-100 rounded-lg transition-all duration-200';
}
});
const iconClasses = computed(() => {
let isActive = props.active || props.to === vroute.name;
return isActive ? 'text-white' : 'text-gray-500';
});
const closeSidebar = () => {
if(TwScreen.isDevice('phone') || TwScreen.isDevice('tablet')) {
leftSidebar.close();
}
};
</script>
<template>
<li @click="closeSidebar()">
<RouterLink
:class="classes"
:to="$view({name:to})"
>
<span
v-if="icon"
class="inline-flex justify-center items-center mr-3"
>
<GoogleIcon
class="text-xl"
:class="iconClasses"
:name="icon"
outline
/>
</span>
<span
v-if="name"
class="text-sm font-medium"
>
{{ name }}
</span>
<slot />
</RouterLink>
</li>
</template>

View File

@ -1,85 +0,0 @@
<script setup>
import { ref } from 'vue';
import { RouterLink } from 'vue-router';
import useNotificationSidebar from '@Stores/NotificationSidebar'
import useNotifier from '@Stores/Notifier'
import GoogleIcon from '@Shared/GoogleIcon.vue';
import PrimaryButton from '@Holos/Button/Primary.vue';
import ShowModal from './Notification/Show.vue';
import Item from './Notification/Item.vue';
/** Eventos */
const emit = defineEmits(['open']);
/** Definidores */
const notifier = useNotifier();
const notificationSidebar = useNotificationSidebar()
/** Propiedades */
const props = defineProps({
sidebar: Boolean
});
const showModal = ref(false);
</script>
<template>
<div
class="fixed top-[3.1rem] right-[0.1rem] md:right-[0.5rem] w-fit h-[calc(100vh-3.2rem)] transition-all duration-300 z-50"
:class="{'translate-x-0':notificationSidebar.isOpened, 'translate-x-64':notificationSidebar.isClosed}"
>
<section
id="notifications"
class="flex md:w-64 h-full transition-all duration-300 border-none"
:class="{'w-64': notificationSidebar.isClosed}"
>
<div class="flex flex-col h-full p-2 md:w-64">
<div class="flex h-full flex-col w-[15.5rem] justify-between rounded-sm overflow-y-auto overflow-x-hidden bg-primary/70 text-primary-t dark:bg-primary-d/70 dark:text-primary-dt">
<div class="flex justify-between px-2 items-center">
<div class="py-1">
<h4 class="text-md font-semibold">
{{ $t('notifications.title') }} <span class="text-xs">({{ notifier.counter }})</span>
</h4>
<h4 class="text-xs font-semibold" v-if="notifier.unreadClosedCounter > 0">
{{ $t('notifications.unreadClosed') }} <span class="text-xs"> ({{ notifier.unreadClosedCounter }})</span>
</h4>
</div>
<GoogleIcon
name="close"
class="text-primary-t dark:text-primary-dt cursor-pointer"
@click="notificationSidebar.close()"
/>
</div>
<div class="flex h-full flex-col space-y-1">
<ul class="px-2 space-y-1 overflow-y-auto"
:class="{
'h-[calc(100vh-10rem)]': notifier.unreadClosedCounter > 0,
'h-[calc(100vh-9rem)]': notifier.unreadClosedCounter === 0
}"
>
<Item v-for="notification in notifier.notifications"
:key="notification.id"
:notification="notification"
@openModal="showModal.open(notification)"
/>
</ul>
</div>
<div class="flex justify-center items-center pb-1">
<RouterLink :to="$view({ name: 'profile.notifications.index' })">
<PrimaryButton type="button" @click="notificationSidebar.close()">
{{ $t('notifications.seeAll') }}
</PrimaryButton>
</RouterLink>
</div>
</div>
</div>
</section>
<ShowModal
ref="showModal"
@reload="notifier.getUpdates()"
/>
</div>
</template>

View File

@ -1,79 +0,0 @@
<script setup>
import { getDateTime } from '@Controllers/DateController';
import useNotifier from '@Stores/Notifier';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Definidores */
const notifier = useNotifier();
/** Eventos */
const emit = defineEmits([
'openModal'
]);
/** Propiedades */
defineProps({
notification: Object,
});
</script>
<template>
<li class="flex flex-col w-full items-center p-2 bg-primary dark:bg-primary-d text-white rounded-sm shadow-md">
<div class="flex w-full justify-between text-gray-400">
<div>
<h6 class="text-[10px]">{{ getDateTime(notification.created_at) }}</h6>
</div>
<div>
<GoogleIcon
name="close"
class="text-xs text-white cursor-pointer"
@click="notifier.closeNotification(notification.id)"
/>
</div>
</div>
<div class="flex w-full cursor-pointer">
<div class="w-10 space-y-0" @click="emit('openModal', notification)">
<template v-if="notification.user">
<div class="w-10 h-10 bg-transparent rounded-full flex items-center justify-center">
<img v-if="notification.user"
class="rounded-full object-cover"
:alt="notification.user.name"
:src="notification.user.profile_photo_url"
>
</div>
</template>
<template v-else>
<div class="w-10 h-10 bg-secondary dark:bg-secondary-d rounded-sm flex items-center justify-center">
<img v-if="notification.user"
class="rounded-full object-cover"
:alt="notification.user.name"
:src="notification.user.profile_photo_url"
>
<GoogleIcon v-else
name="tag"
class="text-white text-2xl"
/>
</div>
</template>
</div>
<div class="ml-3 w-full">
<div
v-text="notification.data.title"
class="text-sm font-medium truncate"
/>
<div
v-text="notification.data.description"
class="text-xs w-40 font-thin truncate"
/>
<div v-if="notification.user"
v-text="`~ ${notification.user.name} ${notification.user.paternal}`"
class="text-xs text-gray-400 truncate"
/>
<div v-else
v-text="$t('system.title')"
class="text-xs text-gray-400 truncate"
/>
</div>
</div>
</li>
</template>

View File

@ -1,80 +0,0 @@
<script setup>
import { ref } from 'vue';
import { getDateTime } from '@Controllers/DateController';
import useNotifier from '@Stores/Notifier';
import Header from '@Holos/Modal/Elements/Header.vue';
import ShowModal from '@Holos/Modal/Show.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Definidores */
const notifier = useNotifier();
/** Eventos */
const emit = defineEmits([
'close',
'reload'
]);
/** Propiedades */
const modalRef = ref(null);
const model = ref({});
/** Métodos */
function readNotification() {
if(!model.value.read_at) {
notifier.readNotification(model.value.id);
}
}
/** Exposiciones */
defineExpose({
open: (data) => {
model.value = data;
modalRef.value.open();
readNotification();
}
});
</script>
<template>
<ShowModal
ref="modalRef"
:title="$t('notification')"
@close="$emit('reload')"
>
<Header
:title="model.data?.title"
>
</Header>
<div class="py-2 border-b">
<div class="flex w-full px-4 py-2">
<GoogleIcon
class="text-xl text-success"
name="contact_mail"
/>
<div class="pl-3">
<p class="font-bold text-lg leading-none pb-2">
{{ $t('details') }}
</p>
<div class="flex flex-col">
<b>{{ $t('description') }}: </b>
{{ model.data?.description }}
</div>
<div v-if="model.data?.message" class="flex flex-col">
<b>{{ $t('message') }}: </b>
{{ model.data?.message }}
</div>
<p>
<b>{{ $t('created_at') }}: </b>
{{ getDateTime(model.created_at) }}
</p>
<p v-if="model.read_at">
<b>{{ $t('read_at') }}: </b>
{{ getDateTime(model.read_at) }}
</p>
</div>
</div>
</div>
</ShowModal>
</template>

View File

@ -1,36 +0,0 @@
<script setup>
import useRightSidebar from '@Stores/RightSidebar'
/** Definidores */
const rightSidebar = useRightSidebar()
/** Eventos */
const emit = defineEmits(['open']);
/** Propiedades */
const props = defineProps({
sidebar: Boolean
});
</script>
<template>
<div
class="fixed top-[3.1rem] right-[0.1rem] md:right-[0.5rem] w-fit h-[calc(100vh-3.1rem)] transition-all duration-300 z-50"
:class="{'translate-x-0':rightSidebar.isOpened, 'translate-x-64':rightSidebar.isClosed}"
>
<nav
class="flex md:w-64 h-full transition-all duration-300 border-none"
:class="{'w-64': rightSidebar.isClosed, 'w-screen': rightSidebar.isOpened}"
>
<div class="flex flex-col h-full p-2 md:w-64">
<div class="flex h-full flex-col w-[15.5rem] justify-between rounded-sm overflow-y-auto overflow-x-hidden bg-primary dark:bg-primary-d text-white">
<div>
<ul class="flex h-full flex-col md:pb-4 space-y-1">
<slot />
</ul>
</div>
</div>
</div>
</nav>
</div>
</template>

View File

@ -1,19 +0,0 @@
<script setup>
/** Propiedades */
const props = defineProps({
name: String,
});
</script>
<template>
<ul v-if="$slots['default']">
<li class="px-5 hidden md:block">
<div class="flex flex-row items-center h-8 cursor-pointer">
<div class="text-sm font-light tracking-wide text-gray-400 uppercase">
{{ name }}
</div>
</div>
</li>
<slot />
</ul>
</template>

View File

@ -1,106 +0,0 @@
<script setup>
import GoogleIcon from '../Shared/GoogleIcon.vue';
import Loader from '../Shared/Loader.vue';
/** Eventos */
const emit = defineEmits([
'send-pagination'
]);
/** Propiedades */
const props = defineProps({
items: Object,
processing: Boolean
});
</script>
<template>
<section class="pb-2">
<div class="w-full overflow-hidden rounded-sm shadow-lg dark:shadow-xs dark:shadow-white">
<div class="w-full overflow-x-auto">
<table v-if="!processing" class="w-full">
<thead class="bg-primary text-primary-t dark:bg-primary-d dark:text-primary-dt">
<tr>
<slot name="head" />
</tr>
</thead>
<tbody>
<template v-if="items?.total > 0">
<slot
name="body"
:items="items?.data"
/>
</template>
<template v-else>
<tr>
<slot name="empty" />
</tr>
</template>
</tbody>
</table>
<table v-else class="animate-pulse w-full">
<thead>
<tr>
<th colspan="100%" class="h-8 text-center">
<div class="flex items-center justify-center">
<Loader />
</div>
</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="100%" class="table-cell h-7 text-center">
<div class="w-full h-4 bg-secondary/50 rounded-md"></div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<template v-if="items?.links">
<div v-if="items.links.length > 3" class="flex w-full justify-end">
<div class="flex w-full justify-end flex-wrap space-x-1 -mb-1">
<template v-for="(link, k) in items.links" :key="k">
<div v-if="link.url === null && k == 0"
class="px-2 py-1 text-sm leading-4 text-gray-400 border rounded"
>
<GoogleIcon
name="arrow_back"
/>
</div>
<button v-else-if="k === 0" class="px-2 py-1 text-sm leading-4 border rounded"
:class="{ 'bg-primary dark:bg-primary-dark text-white': link.active }"
@click="$emit('send-pagination', link.url)"
>
<GoogleIcon
name="arrow_back"
/>
</button>
<div v-else-if="link.url === null && k == (items.links.length - 1)"
class="px-2 py-1 text-sm leading-4 text-gray-400 border rounded"
>
<GoogleIcon
name="arrow_forward"
/>
</div>
<button v-else-if="k === (items.links.length - 1)" class="px-2 py-1 text-sm leading-4 border rounded"
:class="{ 'bg-primary dark:bg-primary-dark text-white': link.active }"
@click="$emit('send-pagination', link.url)"
>
<GoogleIcon
name="arrow_forward"
/>
</button>
<button v-else class="px-2 py-1 text-sm leading-4 border rounded"
:class="{ 'bg-primary dark:bg-primary-dark text-white': link.active }"
v-html="link.label"
@click="$emit('send-pagination', link.url)"
></button>
</template>
</div>
</div>
</template>
</template>

Some files were not shown because too many files have changed in this diff Show More