Compare commits

...

71 Commits

Author SHA1 Message Date
Juan Felipe Zapata Moreno
5a2944663d feat: agregar gestión de depreciaciones de activos fijos y detalles de activos 2026-03-24 11:51:53 -06:00
Juan Felipe Zapata Moreno
c85200ed64 feat: mejora gestión de activos fijos con edición y asignaciones
- Se agregó edición de activos fijos en FixedAssetForm.vue.
- Se mejoró el manejo de carga y errores al obtener detalles del activo.
- Se actualizaron formularios de asignación usando IDs numéricos.
- Se mejoró la gestión de asignaciones con carga dinámica de activos y empleados.
- Refactor de componentes de asignación para mejorar manejo de datos y UX.
- Se agregaron métodos API para asignación y devolución de activos.
- Se actualizaron rutas para edición y baja (offboarding) de asignaciones.
2026-03-23 17:44:34 -06:00
Juan Felipe Zapata Moreno
29e4497ff1 Merge branch 'qa' of git.golsystems.mx:juan.zapata/golscontrol-frontend-v1 into activos 2026-03-23 09:55:24 -06:00
Juan Felipe Zapata Moreno
d6d91aeaf9 wip: activos 2026-03-23 09:48:43 -06:00
6d3adcc8e5 Merge pull request 'CTL-51-DEVELOP' (#19) from CTL-51-DEVELOP into qa
Reviewed-on: #19
2026-03-23 15:01:11 +00:00
edgar.mendez
47cc7cdb8e feat(units): add permission checks for unit management actions in Units component 2026-03-21 20:49:01 -06:00
edgar.mendez
1189b7b02e feat(auth): enhance authentication service with error handling and session management
- Added methods to normalize permissions and roles from API responses.
- Implemented a centralized error handling method for authentication errors.
- Updated API endpoints for login, registration, and user profile management.
- Introduced session refresh functionality to retrieve user roles and permissions.

feat(catalog): improve companies and units management with permissions and filters

- Integrated permission checks for creating, updating, and deleting companies.
- Added user role and permission checks to the Companies component.
- Enhanced the Units component with search and status filters.
- Refactored unit creation and update logic to handle validation errors.

fix(catalog): update unit measure services and mapping logic

- Improved API service methods for fetching, creating, and updating units of measure.
- Added mapping functions to convert API responses to internal data structures.
- Enhanced error handling in unit measure services.

chore(auth): refactor authentication storage utilities

- Created utility functions for managing authentication tokens and user data in local storage.
- Updated API interceptor to use new storage utility functions for session management.

style: clean up code formatting and improve readability across components and services
2026-03-21 20:04:40 -06:00
edgar.mendez
93a2527e60 feat: update company management components and services
- Upgrade vue-tsc to version 3.2.6 in package.json.
- Refactor Companies.vue to replace address column with domains_count and update company service references.
- Modify CompaniesForm.vue to include new fields for email, primary domain, and certificate files, and adjust validation logic.
- Revamp companies.service.ts to implement new API endpoints for tenant management and improve error handling.
- Introduce companies.mapper.ts for payload transformation between form data and API requirements.
- Update companies.types.ts to reflect changes in data structure and types for better type safety.
2026-03-21 18:04:08 -06:00
Juan Felipe Zapata Moreno
ee3d0e1134 refactor: elimina componentes y servicios de estructuras de activos fijos
- Se eliminaron componentes relacionados a estructuras de activos fijos y su servicio.
- Se actualizó fixedAsset.ts con la nueva estructura y campos adicionales.
- Se agregó fixedAssetsService.ts para gestionar activos fijos.
- Se eliminaron rutas relacionadas en el router.
2026-03-18 18:05:25 -06:00
6fe7c82c6d Merge pull request 'activos-fijos' (#18) from activos-fijos into qa
Reviewed-on: #18
2026-03-10 23:16:42 +00:00
0463191414 Merge pull request 'feat: add departments and employees management components' (#17) from feature-comercial-module-ts into qa
Reviewed-on: #17
2026-03-10 23:15:05 +00:00
a8ccb20f94 FIX:Errores de ts 2026-03-10 16:17:10 -06:00
ad264107f6 feat: add departments and employees management components
- Implement DepartmentsService for CRUD operations on departments.
- Create Employees.vue for managing employee listings, including viewing, editing, and deleting employees.
- Add EmployeesForm.vue for creating and editing employee details with validation.
- Introduce employees.interfaces.ts to define employee-related TypeScript interfaces.
- Implement EmployeesService for API interactions related to employees.
- Add positions.interface.ts and positions.services.ts for managing job positions.
2026-03-10 16:13:26 -06:00
318afd56c1 maquetación activos fijos 2026-03-10 16:04:35 -06:00
2fb87c7811 Merge pull request 'feat: add Companies management module with CRUD functionality and routing' (#15) from feature-comercial-module-ts into qa
Reviewed-on: #15
2026-03-10 17:58:23 +00:00
ecc053c138 feat: add Companies management module with CRUD functionality and routing 2026-03-10 11:36:27 -06:00
40f614226e Merge pull request 'feat: implement technical and financial approval workflows for requisitions' (#14) from feature-comercial-module-ts into qa
Reviewed-on: #14
2026-03-04 22:47:18 +00:00
8abf849306 feat: implement technical and financial approval workflows for requisitions
- Added approval and rejection dialogs for technical and financial requisitions in Requisitions.vue.
- Updated requisition statuses to include 'pending_technical', 'rejected_technical', 'pending_financial', and 'rejected_financial'.
- Enhanced requisition store to handle approval and rejection logic, including saving to localStorage.
- Modified requisition interface to include approval and rejection records.
- Updated initial requisition data to reflect new approval statuses and added comments field for requisition items.
2026-03-04 16:44:25 -06:00
585ac6bf4a Merge pull request 'feat: add WarehouseOutInventory component and related services' (#12) from feature-comercial-module-ts into qa
Reviewed-on: #12
2026-03-04 15:06:24 +00:00
b55c6c1ef0 feat: add WarehouseOutInventory component and related services
- Created a new component for managing warehouse inventory exits (WarehouseOutInventory.vue).
- Implemented inventory movement services to handle API requests for inventory movements.
- Added new interfaces for inventory movements and stock management.
- Updated routing to include the new inventory exit page.
- Enhanced existing services to support inventory exit functionality.
- Added validation and user feedback for inventory exit operations.
2026-03-04 09:04:39 -06:00
3b682872e5 Merge pull request 'feature-comercial-module-ts' (#11) from feature-comercial-module-ts into qa
Reviewed-on: #11
2026-02-27 19:37:23 +00:00
983c3265bc feat: remove index.html and restructure RH components
- Deleted the index.html file from the RH components.
- Created new DepartmentForm.vue and Departments.vue components for managing departments.
- Updated the router to reflect the new path for Departments component.
- Added DepartmentsService for API interactions related to departments.
- Introduced types for departments in departments.interface.ts.
- Updated WarehouseAddInventory.vue to change background color classes.
- Configured TypeScript paths in tsconfig.app.json for easier imports.
- Enhanced Vite configuration to support aliasing for src directory.
2026-02-27 13:33:59 -06:00
2bdccbe6c6 feat: enhance warehouse inventory management by adding product selection modal and manual entry mode 2026-02-27 12:10:04 -06:00
0071b7f4dc feat: add commercial classifications management and integrate SAT code products in product form 2026-02-26 17:24:39 -06:00
7bd247f0c5 feat: implement units of measure management with CRUD operations and SAT unit integration 2026-02-25 17:39:44 -06:00
3cb7264b0a feat: update supplier interface to include contact email and phone number 2026-02-24 16:03:10 -06:00
2f3a4d7da4 feat: implement requisition management module with CRUD operations
- Added Requisitions.vue component for managing requisitions, including search, filter, and pagination functionalities.
- Created requisitionStore.ts for state management using Pinia, including actions for fetching, creating, updating, and canceling requisitions.
- Defined requisition interfaces in requisition.interfaces.ts to structure requisition data.
- Integrated PrimeVue components for UI elements such as DataTable, Dropdown, and Dialogs.
- Implemented cancelation logic with user confirmation and validation for cancelation reasons.
2026-02-24 14:39:57 -06:00
522235d441 Refactor supplier and unit of measure components and services
- Updated SupplierModal.vue to include new fields for supplier information and improved form validation.
- Enhanced Suppliers.vue to handle loading states and improved supplier data fetching logic.
- Removed old supplierServices and unitOfMeasureService files, replacing them with updated service files that align with new interfaces.
- Created new interfaces for suppliers and unit of measure to standardize data handling across the application.
- Adjusted the store files to reference the new service files and interfaces.
- Improved error handling and logging in service methods for better debugging.
2026-02-24 09:08:44 -06:00
df0b707064 CTL-51: feat(catalog): implement model document service and store
- Added model-document.services.ts for handling API interactions related to model documents.
- Created modelDocumentStore.ts using Pinia for state management of model documents, including actions for fetching, creating, updating, and deleting documents.
- Defined model document types in modelDocument.interface.ts for better type safety and clarity.
- Removed obsolete index.html file from warehouse components.
- Updated router to include a new route for model documents.
2026-02-16 14:34:18 -06:00
48b28f55ca Merge pull request 'feature-comercial-module-ts' (#10) from feature-comercial-module-ts into qa
Reviewed-on: #10
2026-02-13 19:52:10 +00:00
d1c203cd0e feat(warehouse): add inventory management features
- Implemented getWarehouseById method in warehouseService to fetch warehouse details by ID.
- Added new types for warehouse inventory management in warehouse.d.ts and warehouse.inventory.d.ts.
- Created WarehouseAddInventory.vue component for handling inventory entries with serial number management.
- Developed inventoryWarehouseServices for adding inventory through API.
- Updated router to include the new inventory management component.
- Added Docker configuration files for production deployment.
- Created Nginx configuration for serving the application.
- Added .dockerignore and .env.production for environment-specific settings.
2026-02-13 13:49:41 -06:00
71454dda61 feat(purchases): add purchase details, form, and listing components
- Implemented PurchaseDetails.vue for displaying detailed purchase information.
- Created PurchaseForm.vue for submitting new purchase requests with supplier and item management.
- Developed Purchases.vue for listing all purchase orders with actions for approval, rejection, and conversion.
- Added purchaseServices.ts for API interactions related to purchases.
- Defined types for purchase forms and purchases in respective TypeScript files.
- Integrated PrimeVue components for UI consistency and functionality.
2026-02-10 02:02:58 -06:00
4a624f490c feat: add supplier management module with CRUD operations and UI components 2026-02-04 15:15:45 -06:00
9661275bc5 feat: add modal for batch inventory item addition with responsive design and Tailwind CSS 2026-02-03 16:33:41 -06:00
19753a0f48 feat: add modal for batch inventory item addition with responsive design and Tailwind CSS 2026-01-30 15:38:06 -06:00
Edgar Mendez Mendoza
730cae825c feat: Implement Store Details page with navigation and display of store information 2026-01-30 10:50:11 -06:00
Edgar Mendez Mendoza
fa161a9e0e feat: update sidebar roles link, refactor role navigation, and add user management component
- Changed the sidebar link for roles from '/users/roles' to '/roles'.
- Updated the navigation in RoleForm component to redirect to 'RoleIndex' instead of 'Roles'.
- Introduced a new UserIndex component for user management, including user listing, filtering, and actions.
- Modified the index.html structure to accommodate the new UserIndex component and improved layout for user management.
- Updated router configuration to include the new UserIndex route and adjusted roles routing structure.
2025-11-13 09:20:53 -06:00
Edgar Mendez Mendoza
29d4f5c9c7 feat: Enhance Role Management with Permissions Loading and State Handling 2025-11-12 15:28:33 -06:00
Edgar Mendez Mendoza
59ddae0d46 feat: Add user roles management with CRUD operations and permissions handling 2025-11-12 13:08:15 -06:00
Edgar Mendez Mendoza
1465f065b1 feat: Implement store management with CRUD operations, including dialog for creating and editing stores 2025-11-12 10:27:29 -06:00
Edgar Mendez Mendoza
c6eaa2ef75 feat: Implement Stores management page with search, filters, and data table 2025-11-11 11:31:42 -06:00
Edgar Mendez Mendoza
1b64767aac feat: Add Stores management component and routing to sidebar 2025-11-11 11:26:06 -06:00
Edgar Mendez Mendoza
f98c2ba580 feat: Add is_active field and classifications management to product and commercial classification components 2025-11-10 17:11:11 -06:00
Edgar Mendez Mendoza
73fb017ca6 feat: Implement product management module with CRUD functionality
- Added ProductsIndex.vue for displaying and managing products.
- Created index.html for the product management interface.
- Developed productService.ts for API interactions related to products.
- Established productStore.ts using Pinia for state management of products.
- Defined product types in product.d.ts for TypeScript support.
- Integrated toast notifications and confirmation dialogs for user feedback.
- Implemented pagination and search functionality in the product table.
- Added form for creating and editing products with validation.
2025-11-10 13:11:38 -06:00
Edgar Mendez Mendoza
498a15efd4 feat: add commercial classification management with CRUD operations and routing 2025-11-08 11:23:17 -06:00
Edgar Mendez Mendoza
aeea112abd feat: add unit of measure management with CRUD operations and routing 2025-11-08 09:39:19 -06:00
Edgar Mendez Mendoza
d134db42b6 fix: add missing newline at end of WarehouseForm.vue template 2025-11-07 16:55:43 -06:00
Edgar Mendez Mendoza
eeead68189 feat: integrate classification store and enhance warehouse management components 2025-11-07 12:45:32 -06:00
Edgar Mendez Mendoza
3bea03f9db feat: add warehouse management components and services
- Implemented WarehouseForm.vue for creating new warehouses with form validation and category assignment.
- Developed WarehouseIndex.vue for displaying a list of warehouses with search and filter functionalities.
- Created warehouseClasificationService.ts for handling warehouse classification API interactions.
- Defined types for warehouse classifications in warehouse.clasification.d.ts.
- Established a Pinia store (warehouseStore.ts) for managing warehouse state and actions.
- Added an index.html file for the warehouse management interface layout.
2025-11-07 12:14:40 -06:00
Edgar Mendez Mendoza
857c149b87 feat: enhance authentication flow with improved user data handling and API integration 2025-11-06 12:51:09 -06:00
Edgar Mendez Mendoza
e1521ef9c7 feat: refactor authentication module and integrate axios for API calls
- Added axios as a dependency for handling HTTP requests.
- Refactored the authentication logic by moving it to a new module structure.
- Replaced the old auth store with a composable useAuth for better state management.
- Created a new Login.vue component for the login page with improved UI.
- Implemented an AuthService for handling authentication-related API calls.
- Removed the old Login.vue and uth.ts files to clean up the codebase.
- Updated router to use the new login component and auth composable.
- Added interceptors to handle token management and error responses globally.
2025-11-06 12:16:25 -06:00
Edgar Mendez Mendoza
83835c22a5 feat: implement authentication flow with login page and router setup 2025-11-06 11:46:13 -06:00
Edgar Mendez Mendoza
8941568e08 feat: add blank module component with customizable card layout 2025-11-06 10:56:40 -06:00
Edgar Mendez Mendoza
f151070db0 feat: enhance layout and sidebar with new menu items and animations 2025-11-06 10:18:57 -06:00
Edgar Mendez Mendoza
06c212821a feat: restructure project for GOLS Control Frontend
- Updated README.md to reflect new project structure and conventions.
- Refactored component imports and paths to align with new layout.
- Removed legacy components (AppConfig.vue, AppTopbar.vue) and created new layout components (MainLayout.vue, Sidebar.vue, TopBar.vue).
- Implemented warehouse module with components for inventory management (WarehouseDashboard.vue, InventoryTable.vue).
- Added composables and services for warehouse logic and API interactions.
- Introduced shared components (KpiCard.vue) for KPI display.
- Enhanced API service for handling HTTP requests.
- Defined TypeScript types for warehouse entities and global application types.
2025-11-06 09:30:47 -06:00
Edgar Mendez Mendoza
47e43ae84e feat: update color scheme to use blue primary palette and remove unused styles 2025-11-05 22:40:59 -06:00
Edgar Mendez Mendoza
dd9ae71bd6 feat: integrate PrimeVue and TailwindCSS for enhanced UI components
- Added dependencies for PrimeVue, PrimeUI themes, and TailwindCSS in package.json.
- Replaced HelloWorld component with ColorDemo in App.vue to showcase color customization.
- Updated HelloWorld component to use PrimeVue Button component.
- Configured main.ts to set up PrimeVue with a custom theme and dark mode support.
- Enhanced vite.config.ts to include TailwindCSS and auto-import for PrimeVue components.
- Created ColorDemo.vue for color customization interface.
- Added main.css for global styles, including Tailwind and PrimeUI styles.
- Implemented AppConfig.vue and AppTopbar.vue for layout and theme configuration.
- Developed useLayout composable for managing color themes and dark mode toggle.
2025-11-05 22:14:50 -06:00
Edgar Mendez Mendoza
d058a27882 WIP 2025-11-05 21:41:29 -06:00
855f43d3a6 WIP 2025-11-05 21:23:45 -06:00
7031389edc Update Details and Index component on module Warehouses 2025-10-31 00:27:17 -06:00
617c4a3a65 WIP 2025-10-15 12:08:31 -06:00
c222b66cef Add: Punto de venta y UX para productos 2025-10-08 12:45:48 -06:00
83f0abff13 ADD: Administrador de productos(WIP) 2025-10-04 09:23:04 -06:00
2bd5d00827 Add: Administración de clasificaciones comerciales 2025-10-02 16:16:12 -06:00
fa1bb4b20b Merge pull request 'feature-warehouse-model' (#8) from feature-warehouse-model into develop
Reviewed-on: #8
2025-09-30 21:27:30 +00:00
7c7dcc229c courses (#7)
Reviewed-on: #7
2025-09-30 21:27:17 +00:00
6b7f80d53a Add: Feature warehouse 2025-09-30 10:28:36 -06:00
1fef53f558 historial-academico (#6)
Reviewed-on: #6
2025-09-27 15:45:13 +00:00
efcad3fe1d Add: Módulo catalogo de clasificaciones de almacenes 2025-09-25 15:44:54 -06:00
68cde3dcea vacations (#5)
Reviewed-on: #5
2025-09-24 21:37:30 +00:00
c95d40787d WIP 2025-09-23 16:18:45 -06:00
343 changed files with 27183 additions and 21859 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,12 +1,10 @@
VITE_API_URL=http://backend.holos.test:8080 # API Configuration
VITE_BASE_URL=http://frontend.holos.test VITE_API_URL=http://localhost:3000/api
VITE_REVERB_APP_ID=
VITE_REVERB_APP_KEY=
VITE_REVERB_APP_SECRET=
VITE_REVERB_HOST="backend.holos.test"
VITE_REVERB_PORT=8080
VITE_REVERB_SCHEME=http
VITE_REVERB_ACTIVE=false
# Environment
VITE_APP_ENV=development
APP_PORT=3000 APP_PORT=3000
# App Configuration
VITE_APP_NAME=GOLS Control
VITE_APP_VERSION=1.0.0

12
.gitignore vendored
View File

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

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`

47
components.d.ts vendored Normal file
View File

@ -0,0 +1,47 @@
/* 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']
Avatar: typeof import('primevue/avatar')['default']
Breadcrumb: typeof import('primevue/breadcrumb')['default']
Button: typeof import('primevue/button')['default']
Card: typeof import('primevue/card')['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']
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']
Password: typeof import('primevue/password')['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']
Toast: typeof import('primevue/toast')['default']
TopBar: typeof import('./src/components/layout/TopBar.vue')['default']
}
export interface GlobalDirectives {
Tooltip: typeof import('primevue/tooltip')['default']
}
}

View File

@ -1,18 +1,18 @@
services: services:
gols-frontend-v1: controls-front:
build: build:
context: . context: .
dockerfile: dockerfile dockerfile: Dockerfile
args:
VITE_API_URL: ${VITE_API_URL}
container_name: front-controls
ports: ports:
- "${APP_PORT}:5173" - "${APP_PORT}:80"
volumes:
- .:/var/www/gols-frontend-v1
- frontend-v1:/var/www/gols-frontend-v1/node_modules
networks: networks:
- gols-network - controls-network
volumes: restart: unless-stopped
frontend-v1: mem_limit: 512mb
driver: local
networks: networks:
gols-network: controls-network:
driver: bridge 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> <!doctype html>
<html id="main-page" lang="es"> <html lang="es">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Holos</title> <title>Golscontrols V1</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/index.js"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </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;
}
}

3115
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,41 +1,33 @@
{ {
"name": "notsoweb.frontend", "name": "golscontros-frontend-v1",
"copyright": "Notsoweb Software Inc.",
"private": true, "private": true,
"version": "0.9.12", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vue-tsc -b && vite build",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@popperjs/core": "^2.11.8", "@primeuix/themes": "^1.2.5",
"@tailwindcss/postcss": "^4.0.9", "@primevue/auto-import-resolver": "^4.4.1",
"@tailwindcss/vite": "^4.0.9", "@tailwindcss/vite": "^4.1.16",
"@vitejs/plugin-vue": "^5.2.1", "@vueuse/core": "^14.0.0",
"@vuepic/vue-datepicker": "^11.0.2", "axios": "^1.13.2",
"apexcharts": "^5.3.5", "pinia": "^3.0.4",
"axios": "^1.8.1", "primeicons": "^7.0.0",
"laravel-echo": "^2.0.2", "primevue": "^4.4.1",
"luxon": "^3.5.0", "tailwindcss-primeui": "^0.6.1",
"pdf-lib": "^1.17.1", "unplugin-vue-components": "^30.0.0",
"pinia": "^3.0.1", "vue": "^3.5.22",
"pusher-js": "^8.4.0", "vue-router": "^4.6.3"
"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"
}, },
"devDependencies": { "devDependencies": {
"autoprefixer": "^10.4.20", "@types/node": "^24.6.0",
"vite-plugin-html": "^3.2.2" "@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"typescript": "~5.9.3",
"vite": "^7.1.7",
"vue-tsc": "^3.2.6"
} }
} }

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,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,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,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,17 +0,0 @@
<template>
<div class="md:col-span-1 flex justify-between">
<div class="px-4 sm:px-0">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
<slot name="title" />
</h3>
<p class="mt-1 text-sm text-gray-600 dark:text-white/50">
<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>

View File

@ -1,33 +0,0 @@
<script setup>
/** Eventos */
const emit = defineEmits([
'send-pagination'
]);
/** Propiedades */
const props = defineProps({
items: Object,
});
</script>
<template>
<section class="py-4">
<div class="w-full overflow-hidden rounded-md shadow-lg">
<div class="w-full overflow-x-auto">
<table 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 class="">
<slot
name="body"
:items="items"
/>
</tbody>
</table>
</div>
</div>
</section>
</template>

View File

@ -1,94 +0,0 @@
<script setup>
import { computed } from 'vue';
import { getDate, getTime } from '@Controllers/DateController';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Eventos */
const emit = defineEmits([
'show',
]);
/** Propiedades */
const props = defineProps({
event: Object,
});
const icons = {
created: 'add',
updated: 'edit',
deleted: 'delete',
restored: 'restore',
};
const colors = {
created: 'primary',
updated: 'primary',
deleted: 'danger',
restored: 'primary',
};
/** Propiedades computadas */
const eventType = computed(() => {
return props.event.event.split('.')[1];
});
const bgColor = computed(() => {
return `bg-${colors[eventType.value]} dark:bg-${colors[eventType.value]}-d text-${colors[eventType.value]}-t dark:text-${colors[eventType.value]}-t`;
});
const borderColor = computed(() => {
return `border-${colors[eventType.value]} dark:border-${colors[eventType.value]}-d`;
});
</script>
<template>
<li class="border-l-2" :class="borderColor">
<div class="relative flex w-full">
<div class="absolute -left-3.5 top-7 h-0.5 w-8" :class="bgColor"></div>
<div
class="absolute -mt-3 -left-3.5 top-7 w-6 h-6 flex items-center justify-center rounded-sm"
:class="bgColor"
@click="emit('show', event.data)"
>
<GoogleIcon
:name="icons[eventType]"
/>
</div>
<div class="w-full rounded-sm shadow-xl dark:shadow-page-dt dark:shadow-xs my-2 mx-4">
<div class="flex justify-between p-2 rounded-t-sm" :class="bgColor">
<span
class="font-medium text-sm cursor-pointer"
@click="emit('show', event.data)"
>
{{ $t('event')}}: <i class="underline">{{ event.event }}</i>
</span>
<span class="font-medium text-sm">
{{ getDate(event.created_at) }}, {{ getTime(event.created_at) }}
</span>
</div>
<div class="p-2">
<div class="flex flex-col justify-center items-center md:flex-row md:justify-start md:space-x-4">
<div v-if="event.user" class="w-32">
<div class="flex flex-col w-full justify-center items-center space-y-2">
<img :src="event.user?.profile_photo_url" alt="Photo" class="w-24 h-24 rounded-sm">
</div>
</div>
<div class="flex w-full flex-col justify-start space-y-2">
<div>
<h4 class="font-semibold">{{ $t('description') }}:</h4>
<p>{{ event.description }}.</p>
</div>
<div>
<h4 class="font-semibold">{{ $t('author') }}:</h4>
<p>{{ event.user?.full_name ?? $t('system.title') }} <span v-if="event.user?.deleted_at" class="text-xs text-gray-500">({{ $t('deleted') }})</span></p>
</div>
</div>
</div>
</div>
</div>
</div>
</li>
</template>

View File

@ -1,30 +0,0 @@
<script setup>
import { computed } from 'vue';
/** Propiedades */
const props = defineProps({
name: String,
fill: Boolean,
style: {
type: String,
default: 'rounded' // outlined, rounded, sharp
},
title: String
})
/** Propiedades computadas */
const classes = computed(() => {
return props.fill
? `font-google-icon-${props.style}-fill`
: `font-google-icon-${props.style}`
});
</script>
<template>
<span
v-text="name"
class="material-symbols cursor-pointer"
:class="classes"
translate="no"
/>
</template>

View File

@ -1,6 +0,0 @@
<template>
<svg class="animate-spin -ml-1 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>
</template>

View File

@ -0,0 +1,40 @@
<script setup lang="ts">
import { useLayout } from "../../composables/useLayout";
const { surfaces, surface, updateColors } = useLayout();
</script>
<template>
<div
class="absolute top-16 right-0 w-64 p-4 bg-white dark:bg-surface-900 rounded-md shadow-lg border border-surface-200 dark:border-surface-700 origin-top z-50 hidden"
>
<div class="flex flex-col gap-4">
<div>
<span class="text-sm text-surface-600 dark:text-surface-400 font-semibold">Color de Superficie</span>
<div class="pt-2 flex gap-2 flex-wrap justify-between">
<button
v-for="s of surfaces"
:key="s.name"
type="button"
:title="s.name"
:class="[
'border border-surface-200 dark:border-surface-700 w-5 h-5 rounded-full p-0 cursor-pointer focus:outline-none focus:ring-2 focus:ring-offset-2',
{
'ring-2 ring-offset-2 ring-surface-950 dark:ring-surface-0': surface === s.name,
},
]"
:style="{ backgroundColor: s.palette['500'] }"
@click="updateColors('surface', s.name)"
/>
</div>
</div>
</div>
</div>
</template>
<style scoped>
button:focus-visible {
outline: 2px solid var(--p-primary-500);
outline-offset: 2px;
}
</style>

View File

@ -0,0 +1,395 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useAuth } from '../../modules/auth/composables/useAuth';
const router = useRouter();
const route = useRoute();
const { hasPermission } = useAuth();
interface MenuItem {
label: string;
icon: string;
to?: string;
items?: MenuItem[];
permission?: string | string[];
}
const menuItems = ref<MenuItem[]>([
{
label: 'Dashboard',
icon: 'pi pi-chart-line',
to: '/'
},
{
label: 'Catálogo',
icon: 'pi pi-book',
items: [
{
label: 'Unidades de Medida',
icon: 'pi pi-calculator',
to: '/catalog/units-of-measure',
permission: [
'units-of-measure.index',
'units-of-measure.show',
'units-of-measure.store',
'units-of-measure.update',
'units-of-measure.destroy',
],
},
{ label: 'Clasificaciones Comerciales', icon: 'pi pi-tags', to: '/catalog/classifications-comercial' },
{ label: 'Proveedores', icon: 'pi pi-briefcase', to: '/catalog/suppliers' },
{ label: 'Documentos del Modelo', icon: 'pi pi-file', to: '/catalog/model-documents' },
{
label: 'Empresas',
icon: 'pi pi-building',
to: '/catalog/companies',
permission: [
'companies.index',
'companies.show',
'companies.store',
'companies.update',
'companies.destroy',
],
}
]
},
{
label: 'Productos',
icon: 'pi pi-shopping-cart',
to: '/products'
},
{
label: 'Requisiciones',
icon: 'pi pi-file-edit',
items: [
{ label: 'Requisiciones', icon: 'pi pi-file', to: '/requisitions/request' },
{ label: 'Crear Requisición', icon: 'pi pi-plus', to: '/requisitions/create' }
]
},
{
label: 'Compras',
icon: 'pi pi-shopping-bag',
items: [
{ label: 'Solicitudes de Compra', icon: 'pi pi-shopping-cart', to: '/purchases/requests' },
{ label: 'Órdenes de Compra', icon: 'pi pi-file', to: '/purchases/orders' },
{ label: 'Recepciones de Compra', icon: 'pi pi-inbox', to: '/purchases/receipts' }
]
},
{
label: 'Almacén',
icon: 'pi pi-box',
items: [
{ label: 'Almacenes', icon: 'pi pi-warehouse', to: '/warehouse' },
{ label: 'Administrar Clasificaciones', icon: 'pi pi-sitemap', to: '/warehouse/classifications' }
]
},
{
label: 'Recursos humanos',
icon: 'pi pi-users',
items: [
{ label: 'Puestos laborales', icon: 'pi pi-user', to: '/rh/positions' },
{ label: 'Departamentos', icon: 'pi pi-briefcase', to: '/rh/departments' },
{ label: 'Empleados', icon: 'pi pi-id-card', to: '/rh/employees' }
]
},
{
label: 'Puntos de venta',
icon: 'pi pi-cog',
to: '/stores'
},
{
label: 'Activos Fijos',
icon: 'pi pi-building',
items: [
{
label: 'Registro de Activos',
icon: 'pi pi-building',
to: '/fixed-assets',
permission: ['assets.index', 'assets.show', 'assets.store', 'assets.update', 'assets.destroy'],
},
{
label: 'Asignacion a Empleado',
icon: 'pi pi-send',
to: '/fixed-assets/assignments',
permission: ['assets.assignments.index', 'assets.assignments.store', 'assets.assignments.returnAsset'],
},
],
},
{
label: 'Configuración',
icon: 'pi pi-cog',
to: '/configuracion'
},
{
label: 'Usuarios y Roles',
icon: 'pi pi-users',
items: [
{ label: 'Usuarios', icon: 'pi pi-user', to: '/users' },
{ label: 'Roles', icon: 'pi pi-shield', to: '/roles' }
]
}
]);
const sidebarVisible = ref(true);
const openItems = ref<string[]>([]);
const canAccessItem = (item: MenuItem): boolean => {
if (!item.permission) {
return true;
}
return hasPermission(item.permission);
};
const filterMenuItems = (items: MenuItem[]): MenuItem[] => {
return items
.map((item) => {
if (item.items && item.items.length > 0) {
const children = filterMenuItems(item.items);
if (children.length === 0) {
return null;
}
return {
...item,
items: children,
};
}
return canAccessItem(item) ? item : null;
})
.filter((item): item is MenuItem => item !== null);
};
const visibleMenuItems = computed(() => filterMenuItems(menuItems.value));
const toggleSidebar = () => {
sidebarVisible.value = !sidebarVisible.value;
};
const toggleItem = (label: string) => {
const index = openItems.value.indexOf(label);
if (index > -1) {
openItems.value.splice(index, 1);
} else {
openItems.value.push(label);
}
};
const isItemOpen = (label: string) => {
return openItems.value.includes(label);
};
const isRouteActive = (to: string | undefined) => {
if (!to) return false;
// Coincidencia exacta
if (route.path === to) {
return true;
}
// Para la ruta raíz, solo coincidencia exacta
if (to === '/') {
return false;
}
// Si la ruta actual es hija (ej: /warehouse/create)
// y el item es el padre (ej: /warehouse)
// SOLO marcar activo si la ruta hija NO está explícitamente en el menú
if (route.path.startsWith(to + '/')) {
// Verificar si la ruta actual está definida como un item del menú
const isExplicitRoute = visibleMenuItems.value.some(item => {
if (item.items) {
return item.items.some(subItem => subItem.to === route.path);
}
return item.to === route.path;
});
// Si NO está explícitamente en el menú, entonces es una ruta hija (create, edit, etc)
return !isExplicitRoute;
}
return false;
};
// Navegar usando Vue Router
const navigateTo = (to: string) => {
router.push(to);
};
// Funciones de animación para los submenús
const onEnter = (el: Element) => {
const element = el as HTMLElement;
element.style.height = '0';
element.offsetHeight; // Force reflow
element.style.height = element.scrollHeight + 'px';
};
const onLeave = (el: Element) => {
const element = el as HTMLElement;
element.style.height = element.scrollHeight + 'px';
element.offsetHeight; // Force reflow
element.style.height = '0';
};
defineExpose({ toggleSidebar });
</script>
<template>
<aside :class="[
'bg-surface-0 dark:bg-surface-900 border-r border-surface-200 dark:border-surface-700 transition-all duration-300',
sidebarVisible ? 'w-64' : 'w-20'
]">
<div class="flex flex-col h-full">
<!-- Logo / Brand -->
<div class="p-4">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary transition-all"
:class="sidebarVisible ? 'bg-primary' : 'bg-primary/90'">
<i class="pi pi-chart-line text-xl text-white"></i>
</div>
<div v-if="sidebarVisible" class="flex flex-col">
<h1 class="text-xl font-bold text-surface-900 dark:text-surface-0">
Golscontrols
</h1>
<p class="text-xs text-surface-500 dark:text-surface-400">
Sistema ERP
</p>
</div>
</div>
</div>
<!-- Navigation Menu -->
<nav class="flex-1 overflow-y-auto p-3">
<div v-if="visibleMenuItems.length === 0" class="px-3 py-6 text-center">
<i class="pi pi-lock text-2xl text-surface-400 dark:text-surface-500"></i>
<p v-if="sidebarVisible" class="mt-3 text-sm text-surface-500 dark:text-surface-400">
No tienes permisos para ver módulos.
</p>
</div>
<ul class="space-y-1">
<li v-for="item in visibleMenuItems" :key="item.label">
<!-- Item sin subitems -->
<a v-if="!item.items" :href="item.to" @click.prevent="item.to && navigateTo(item.to)" :class="[
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-all cursor-pointer',
isRouteActive(item.to)
? 'bg-primary text-white shadow-sm'
: 'text-surface-700 dark:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-800'
]" :title="!sidebarVisible ? item.label : ''">
<i :class="[item.icon, 'shrink-0']"></i>
<span v-if="sidebarVisible">{{ item.label }}</span>
</a>
<!-- Item con subitems (Collapsible) -->
<div v-else>
<button @click="toggleItem(item.label)" :class="[
'w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm font-medium transition-colors',
'text-surface-700 dark:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-800'
]" :title="!sidebarVisible ? item.label : ''">
<div class="flex items-center gap-3">
<i :class="[item.icon, 'shrink-0']"></i>
<span v-if="sidebarVisible">{{ item.label }}</span>
</div>
<i v-if="sidebarVisible" :class="[
'pi pi-chevron-down text-xs transition-transform duration-200',
isItemOpen(item.label) && 'rotate-180'
]"></i>
</button>
<!-- Subitems con animación -->
<Transition name="submenu" @enter="onEnter" @leave="onLeave">
<ul v-if="sidebarVisible && isItemOpen(item.label)"
class="ml-6 mt-1 space-y-1 overflow-hidden">
<li v-for="subItem in item.items" :key="subItem.label">
<a :href="subItem.to" @click.prevent="subItem.to && navigateTo(subItem.to)"
:class="[
'flex items-center gap-2 px-3 py-2 pl-6 rounded-lg text-sm font-medium transition-all cursor-pointer',
isRouteActive(subItem.to)
? 'bg-primary text-white shadow-sm'
: 'text-surface-600 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800'
]">
<i :class="[subItem.icon, 'text-xs shrink-0']"></i>
<span>{{ subItem.label }}</span>
</a>
</li>
</ul>
</Transition>
</div>
</li>
</ul>
</nav>
<!-- Toggle Button -->
<div class="p-3 border-t border-surface-200 dark:border-surface-700">
<button @click="toggleSidebar"
class="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-surface-700 dark:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
:title="sidebarVisible ? 'Contraer sidebar' : 'Expandir sidebar'">
<i :class="sidebarVisible ? 'pi pi-angle-left' : 'pi pi-angle-right'"></i>
<span v-if="sidebarVisible" class="text-sm font-medium">Contraer</span>
</button>
</div>
</div>
</aside>
</template>
<style scoped>
aside {
height: 100vh;
position: sticky;
top: 0;
z-index: 40;
}
/* Animación para submenús */
.submenu-enter-active,
.submenu-leave-active {
transition: height 0.3s ease, opacity 0.3s ease;
overflow: hidden;
}
.submenu-enter-from,
.submenu-leave-to {
height: 0;
opacity: 0;
}
.submenu-enter-to,
.submenu-leave-from {
opacity: 1;
}
/* Animación de rotación para el chevron */
.rotate-180 {
transform: rotate(180deg);
}
/* Efecto de sombra en item activo */
.shadow-sm {
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
/* Scroll suave */
nav {
scrollbar-width: thin;
scrollbar-color: rgba(155, 155, 155, 0.5) transparent;
}
nav::-webkit-scrollbar {
width: 6px;
}
nav::-webkit-scrollbar-track {
background: transparent;
}
nav::-webkit-scrollbar-thumb {
background-color: rgba(155, 155, 155, 0.5);
border-radius: 3px;
}
nav::-webkit-scrollbar-thumb:hover {
background-color: rgba(155, 155, 155, 0.7);
}
</style>

View File

@ -0,0 +1,206 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useLayout } from "../../composables/useLayout";
import { useAuth } from "../../modules/auth/composables/useAuth";
const router = useRouter();
const { isDarkMode, toggleDarkMode } = useLayout();
const { user, logout } = useAuth();
// Referencia al menú de usuario
const userMenu = ref();
// Función para toggle del menú
const toggleUserMenu = (event: Event) => {
userMenu.value.toggle(event);
};
// Función de logout
const handleLogout = async () => {
await logout();
router.push('/login');
};
// Opciones del menú de usuario
const userMenuItems = ref([
{
label: 'Mi Perfil',
icon: 'pi pi-user',
command: () => {
router.push('/profile');
}
},
{
label: 'Configuración',
icon: 'pi pi-cog',
command: () => {
router.push('/settings');
}
},
{
separator: true
},
{
label: 'Cerrar Sesión',
icon: 'pi pi-sign-out',
command: () => {
handleLogout();
}
}
]);
</script>
<template>
<header class="bg-surface-0 dark:bg-surface-900 border-b border-surface-200 dark:border-surface-700 px-6 py-4">
<div class="flex items-center justify-between">
<!-- Left Section: Branding -->
<div class="flex items-center gap-3">
<!-- <i class="pi pi-box text-2xl text-primary"></i>
<div class="flex flex-col">
<span class="text-lg font-bold text-surface-900 dark:text-surface-0">
GOLS Control
</span>
<span class="text-xs text-surface-500 dark:text-surface-400">
Sistema de Gestión
</span>
</div> -->
</div>
<!-- Right Section: Actions -->
<div class="flex items-center gap-2">
<!-- Search Button (opcional) -->
<!-- <button
type="button"
class="w-10 h-10 flex items-center justify-center rounded-full hover:bg-surface-100 dark:hover:bg-surface-800 transition-all text-surface-700 dark:text-surface-200"
title="Buscar"
>
<i class="pi pi-search"></i>
</button> -->
<!-- Notifications Button (opcional) -->
<!-- <button
type="button"
class="w-10 h-10 flex items-center justify-center rounded-full hover:bg-surface-100 dark:hover:bg-surface-800 transition-all text-surface-700 dark:text-surface-200 relative"
title="Notificaciones"
>
<i class="pi pi-bell"></i>
<span class="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
</button> -->
<!-- Dark Mode Toggle -->
<button
type="button"
class="w-10 h-10 flex items-center justify-center rounded-full hover:bg-surface-100 dark:hover:bg-surface-800 transition-all text-surface-900 dark:text-surface-0"
@click="toggleDarkMode"
:title="isDarkMode ? 'Modo claro' : 'Modo oscuro'"
>
<i :class="['pi', isDarkMode ? 'pi-sun' : 'pi-moon']" />
</button>
<!-- Settings/Config Button -->
<!-- <div class="relative">
<button
v-styleclass="{
selector: '@next',
enterFromClass: 'hidden',
enterActiveClass: 'animate-scalein',
leaveToClass: 'hidden',
leaveActiveClass: 'animate-fadeout',
hideOnOutsideClick: true,
}"
type="button"
class="w-10 h-10 flex items-center justify-center rounded-full hover:bg-surface-100 dark:hover:bg-surface-800 transition-all text-surface-700 dark:text-surface-200"
title="Configuración"
>
<i class="pi pi-cog" />
</button>
<AppConfig />
</div> -->
<!-- User Profile Button with Menu -->
<div class="relative">
<button
type="button"
class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-surface-100 dark:hover:bg-surface-800 transition-all"
title="Perfil"
@click="toggleUserMenu"
aria-haspopup="true"
aria-controls="user_menu"
>
<div class="w-8 h-8 rounded-full bg-primary flex items-center justify-center text-white text-sm font-semibold">
{{ user?.name?.charAt(0).toUpperCase() || 'U' }}
</div>
<span class="text-sm font-medium text-surface-900 dark:text-surface-0 hidden md:block">
{{ user?.full_name || user?.name || 'Usuario' }}
</span>
<i class="pi pi-angle-down text-sm text-surface-500 hidden md:block"></i>
</button>
<!-- Menu de Usuario -->
<Menu
ref="userMenu"
id="user_menu"
:model="userMenuItems"
:popup="true"
class="w-56"
>
<template #start>
<div class="px-4 py-3 border-b border-surface-200 dark:border-surface-700">
<p class="text-sm font-semibold text-surface-900 dark:text-surface-0">{{ user?.full_name || user?.name }}</p>
<p class="text-xs text-surface-500 dark:text-surface-400">{{ user?.email }}</p>
</div>
</template>
<template #item="{ item, props }">
<a
v-bind="props.action"
class="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
:class="{ 'text-red-600 dark:text-red-400': item.label === 'Cerrar Sesión' }"
>
<i :class="item.icon"></i>
<span class="text-sm font-medium">{{ item.label }}</span>
</a>
</template>
<template #end>
<div class="px-4 py-2 border-t border-surface-200 dark:border-surface-700">
<p class="text-xs text-surface-400 dark:text-surface-500">Versión 1.0.0</p>
</div>
</template>
</Menu>
</div>
</div>
</div>
</header>
</template>
<style scoped>
.animate-scalein {
animation: scalein 0.15s ease-in;
}
.animate-fadeout {
animation: fadeout 0.15s ease-out;
}
@keyframes scalein {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes fadeout {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
</style>

View File

@ -0,0 +1,68 @@
<script setup lang="ts">
interface Props {
title: string;
value: string | number;
icon?: string;
trend?: {
value: number;
isPositive: boolean;
};
color?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
}
withDefaults(defineProps<Props>(), {
color: 'primary'
});
const colorClasses: Record<string, string> = {
primary: 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400',
success: 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400',
warning: 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400',
danger: 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400',
info: 'bg-cyan-50 dark:bg-cyan-900/20 text-cyan-600 dark:text-cyan-400'
};
</script>
<template>
<div class="bg-surface-0 dark:bg-surface-900 rounded-lg p-6 border border-surface-200 dark:border-surface-700">
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="text-sm text-surface-600 dark:text-surface-400 mb-2">
{{ title }}
</p>
<h3 class="text-3xl font-bold text-surface-900 dark:text-surface-0">
{{ value }}
</h3>
<!-- Trend indicator -->
<div v-if="trend" class="flex items-center gap-1 mt-2">
<i
:class="[
'text-sm',
trend.isPositive ? 'pi pi-arrow-up text-green-500' : 'pi pi-arrow-down text-red-500'
]"
></i>
<span
:class="[
'text-sm font-medium',
trend.isPositive ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
]"
>
{{ Math.abs(trend.value) }}%
</span>
</div>
</div>
<!-- Icon -->
<div
v-if="icon"
:class="[
'w-12 h-12 rounded-lg flex items-center justify-center',
colorClasses[color]
]"
>
<i :class="[icon, 'text-xl']"></i>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,194 @@
import { updatePrimaryPalette, updateSurfacePalette } from "@primeuix/themes";
import { computed, ref } from "vue";
const appState = ref({
primary: "blue",
surface: "slate",
darkMode: false
});
const primaryColors = ref([
{
name: "blue",
palette: {
50: "#eff6ff",
100: "#dbeafe",
200: "#bfdbfe",
300: "#93c5fd",
400: "#60a5fa",
500: "#3b82f6",
600: "#2563eb",
700: "#1d4ed8",
800: "#1e40af",
900: "#1e3a8a",
950: "#172554"
}
},
]);
const surfaces = ref([
{
name: "slate",
palette: {
0: "#ffffff",
50: "#f8fafc",
100: "#f1f5f9",
200: "#e2e8f0",
300: "#cbd5e1",
400: "#94a3b8",
500: "#64748b",
600: "#475569",
700: "#334155",
800: "#1e293b",
900: "#0f172a",
950: "#020617"
}
},
{
name: "gray",
palette: {
0: "#ffffff",
50: "#f9fafb",
100: "#f3f4f6",
200: "#e5e7eb",
300: "#d1d5db",
400: "#9ca3af",
500: "#6b7280",
600: "#4b5563",
700: "#374151",
800: "#1f2937",
900: "#111827",
950: "#030712"
}
},
{
name: "zinc",
palette: {
0: "#ffffff",
50: "#fafafa",
100: "#f4f4f5",
200: "#e4e4e7",
300: "#d4d4d8",
400: "#a1a1aa",
500: "#71717a",
600: "#52525b",
700: "#3f3f46",
800: "#27272a",
900: "#18181b",
950: "#09090b"
}
},
{
name: "neutral",
palette: {
0: "#ffffff",
50: "#fafafa",
100: "#f5f5f5",
200: "#e5e5e5",
300: "#d4d4d4",
400: "#a3a3a3",
500: "#737373",
600: "#525252",
700: "#404040",
800: "#262626",
900: "#171717",
950: "#0a0a0a"
}
},
{
name: "stone",
palette: {
0: "#ffffff",
50: "#fafaf9",
100: "#f5f5f4",
200: "#e7e5e4",
300: "#d6d3d1",
400: "#a8a29e",
500: "#78716c",
600: "#57534e",
700: "#44403c",
800: "#292524",
900: "#1c1917",
950: "#0c0a09"
}
}
]);
// Inicializar el color azul al cargar
const bluePalette = {
50: "#eff6ff",
100: "#dbeafe",
200: "#bfdbfe",
300: "#93c5fd",
400: "#60a5fa",
500: "#3b82f6",
600: "#2563eb",
700: "#1d4ed8",
800: "#1e40af",
900: "#1e3a8a",
950: "#172554"
};
const slatePalette = {
0: "#ffffff",
50: "#f8fafc",
100: "#f1f5f9",
200: "#e2e8f0",
300: "#cbd5e1",
400: "#94a3b8",
500: "#64748b",
600: "#475569",
700: "#334155",
800: "#1e293b",
900: "#0f172a",
950: "#020617"
};
// Aplicar los colores por defecto inmediatamente
updatePrimaryPalette(bluePalette);
updateSurfacePalette(slatePalette);
export function useLayout() {
function setPrimary(value: string) {
appState.value.primary = value;
}
function setSurface(value: string) {
appState.value.surface = value;
}
function toggleDarkMode() {
appState.value.darkMode = !appState.value.darkMode;
document.documentElement.classList.toggle("p-dark");
}
function updateColors(type: "primary" | "surface", colorName: string) {
if (type === "primary") {
setPrimary(colorName);
const color = primaryColors.value.find((c) => c.name === colorName);
if (color) {
updatePrimaryPalette(color.palette);
}
} else if (type === "surface") {
setSurface(colorName);
const surfaceColor = surfaces.value.find((s) => s.name === colorName);
if (surfaceColor) {
updateSurfacePalette(surfaceColor.palette);
}
}
}
const primary = computed(() => appState.value.primary);
const surface = computed(() => appState.value.surface);
const isDarkMode = computed(() => appState.value.darkMode);
return {
primaryColors,
surfaces,
primary,
surface,
isDarkMode,
toggleDarkMode,
updateColors
};
}

View File

@ -1,11 +0,0 @@
import config from '../package.json'
const APP_COPYRIGHT = config.copyright
const APP_NAME = import.meta.env.VITE_APP_NAME
const APP_VERSION = config.version
export {
APP_NAME,
APP_VERSION,
APP_COPYRIGHT
}

View File

@ -1,19 +0,0 @@
/** Breadcrumb */
const breadcrumbItem = (name, icon, route = {name:'index'}) => ({ name, icon, route })
const breadcrumbMeta = (route) => {
let breadcrumbs = [];
route.matched.forEach(match => {
if(match.name && match.meta?.title) {
breadcrumbs.push(breadcrumbItem(match.meta.title, match.meta.icon, {name: match.name}));
}
});
return breadcrumbs;
}
export {
breadcrumbItem,
breadcrumbMeta,
}

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