commit b21a11c2eecf9f23a84ec96c74dfc0350db2c7d3
Author: Arturo Corro
Date: Fri Mar 7 00:29:07 2025 -0600
first commit
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..8f0de65
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,18 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_size = 4
+indent_style = space
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.md]
+trim_trailing_whitespace = false
+
+[*.{yml,yaml}]
+indent_size = 2
+
+[docker-compose.yml]
+indent_size = 4
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..90a4e33
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,24 @@
+# Normaliza los saltos de línea en diferentes SO
+* text=auto eol=lf
+
+# Reglas para archivos específicos
+*.blade.php diff=html
+*.css diff=css
+*.html diff=html
+*.md diff=markdown
+*.php diff=php
+
+# Evitar que estos archivos se exporten con Composer create-project
+/.github export-ignore
+/.gitignore export-ignore
+/.git export-ignore
+.gitattributes export-ignore
+.editorconfig export-ignore
+.prettierrc.json export-ignore
+.prettierignore export-ignore
+.eslintrc.json export-ignore
+CHANGELOG.md export-ignore
+CONTRIBUTING.md export-ignore
+README.md export-ignore
+composer.lock export-ignore
+package-lock.json export-ignore
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d07bec2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+/node_modules
+/vendor
+/.vscode
+/.nova
+/.fleet
+/.phpactor.json
+/.phpunit.cache
+/.phpunit.result.cache
+/.zed
+/.idea
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..5d3dfee
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,16 @@
+# Dependencias de Composer y Node.js
+/vendor/
+/node_modules/
+
+# Caché y logs
+/storage/
+*.log
+*.cache
+
+# Archivos del sistema
+.DS_Store
+Thumbs.db
+
+# Configuración de editores
+.idea/
+.vscode/
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 0000000..5f11c9c
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,29 @@
+{
+ "arrowParens": "avoid",
+ "bracketSpacing": true,
+ "bracketSameLine": true,
+ "htmlWhitespaceSensitivity": "css",
+ "insertPragma": false,
+ "jsxSingleQuote": true,
+ "printWidth": 120,
+ "proseWrap": "preserve",
+ "quoteProps": "as-needed",
+ "requirePragma": false,
+ "semi": true,
+ "singleQuote": true,
+ "tabWidth": 4,
+ "trailingComma": "none",
+ "useTabs": false,
+ "endOfLine": "lf",
+ "embeddedLanguageFormatting": "auto",
+ "overrides": [
+ {
+ "files": [
+ "resources/assets/**/*.js"
+ ],
+ "options": {
+ "semi": false
+ }
+ }
+ ]
+}
diff --git a/Actions/Fortify/CreateNewUser.php b/Actions/Fortify/CreateNewUser.php
new file mode 100644
index 0000000..89525c6
--- /dev/null
+++ b/Actions/Fortify/CreateNewUser.php
@@ -0,0 +1,40 @@
+ $input
+ */
+ public function create(array $input): User
+ {
+ Validator::make($input, [
+ 'name' => ['required', 'string', 'max:255'],
+ 'email' => [
+ 'required',
+ 'string',
+ 'email',
+ 'max:255',
+ Rule::unique(User::class),
+ ],
+ 'password' => $this->passwordRules(),
+ ])->validate();
+
+ return User::create([
+ 'name' => $input['name'],
+ 'email' => $input['email'],
+ 'password' => Hash::make($input['password']),
+ ]);
+ }
+}
diff --git a/Actions/Fortify/PasswordValidationRules.php b/Actions/Fortify/PasswordValidationRules.php
new file mode 100644
index 0000000..a2edbce
--- /dev/null
+++ b/Actions/Fortify/PasswordValidationRules.php
@@ -0,0 +1,18 @@
+|string>
+ */
+ protected function passwordRules(): array
+ {
+ return ['required', 'string', Password::default(), 'confirmed'];
+ }
+}
diff --git a/Actions/Fortify/ResetUserPassword.php b/Actions/Fortify/ResetUserPassword.php
new file mode 100644
index 0000000..9017b2c
--- /dev/null
+++ b/Actions/Fortify/ResetUserPassword.php
@@ -0,0 +1,29 @@
+ $input
+ */
+ public function reset(User $user, array $input): void
+ {
+ Validator::make($input, [
+ 'password' => $this->passwordRules(),
+ ])->validate();
+
+ $user->forceFill([
+ 'password' => Hash::make($input['password']),
+ ])->save();
+ }
+}
diff --git a/Actions/Fortify/UpdateUserPassword.php b/Actions/Fortify/UpdateUserPassword.php
new file mode 100644
index 0000000..35b0fc3
--- /dev/null
+++ b/Actions/Fortify/UpdateUserPassword.php
@@ -0,0 +1,32 @@
+ $input
+ */
+ public function update(User $user, array $input): void
+ {
+ Validator::make($input, [
+ 'current_password' => ['required', 'string', 'current_password:web'],
+ 'password' => $this->passwordRules(),
+ ], [
+ 'current_password.current_password' => __('The provided password does not match your current password.'),
+ ])->validateWithBag('updatePassword');
+
+ $user->forceFill([
+ 'password' => Hash::make($input['password']),
+ ])->save();
+ }
+}
diff --git a/Actions/Fortify/UpdateUserProfileInformation.php b/Actions/Fortify/UpdateUserProfileInformation.php
new file mode 100644
index 0000000..2367171
--- /dev/null
+++ b/Actions/Fortify/UpdateUserProfileInformation.php
@@ -0,0 +1,60 @@
+ $input
+ */
+ public function update(User $user, array $input): void
+ {
+ Validator::make($input, [
+ 'name' => ['required', 'string', 'max:255'],
+
+ 'email' => [
+ 'required',
+ 'string',
+ 'email',
+ 'max:255',
+ Rule::unique('users')->ignore($user->id),
+ ],
+ ])->validateWithBag('updateProfileInformation');
+
+ if (
+ $input['email'] !== $user->email &&
+ $user instanceof MustVerifyEmail
+ ) {
+ $this->updateVerifiedUser($user, $input);
+ } else {
+ $user->forceFill([
+ 'name' => $input['name'],
+ 'email' => $input['email'],
+ ])->save();
+ }
+ }
+
+ /**
+ * Update the given verified user's profile information.
+ *
+ * @param array $input
+ */
+ protected function updateVerifiedUser(User $user, array $input): void
+ {
+ $user->forceFill([
+ 'name' => $input['name'],
+ 'email' => $input['email'],
+ 'email_verified_at' => null,
+ ])->save();
+
+ $user->sendEmailVerificationNotification();
+ }
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..0cc0f79
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,41 @@
+# 📜 CHANGELOG - Laravel Vuexy Admin
+
+Este documento sigue el formato [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
+
+## [0.1.0] - ALPHA - 2025-03-05
+
+### ✨ Added (Agregado)
+- 📌 **Integración con los catálogos SAT (CFDI 4.0)**:
+ - `sat_banco`, `sat_clave_prod_serv`, `sat_clave_unidad`, `sat_codigo_postal`, `sat_colonia`, `sat_deduccion`, `sat_estado`, `sat_forma_pago`, `sat_localidad`, `sat_municipio`, `sat_moneda`, `sat_pais`, `sat_percepcion`, `sat_regimen_contratacion`, `sat_regimen_fiscal`, `sat_uso_cfdi`.
+- 🎨 **Interfaz basada en Vuexy Admin** con integración de Laravel Blade + Livewire.
+- 🔑 **Sistema de autenticación y RBAC** con Laravel Fortify y Spatie Permissions.
+- 🔄 **Módulo de tipos de cambio** con integración de la API de Banxico.
+- 📦 **Manejo de almacenamiento y gestión de activos**.
+- 🚀 **Publicación inicial del repositorio en Packagist y Git Tea**.
+
+### 🛠 Changed (Modificado)
+- **Optimización del sistema de permisos y roles** para mayor flexibilidad.
+
+### 🐛 Fixed (Correcciones)
+- Se corrigieron errores en migraciones de catálogos SAT.
+
+---
+
+## 📅 Próximos Cambios Planeados
+- 📊 **Módulo de Reportes** con Laravel Excel y Charts.
+- 🏪 **Módulo de Inventarios y Punto de Venta (POS)**.
+- 📍 **Mejor integración con APIs de geolocalización**.
+
+---
+
+**📌 Nota:** Esta es la primera versión **ALPHA**, aún en desarrollo.
+
+---
+
+## 🔄 Sincronización de Cambios
+Este `CHANGELOG.md` se actualiza primero en nuestro repositorio principal en **[Tea - Koneko Git](https://git.koneko.mx/koneko/laravel-vuexy-admin)** y luego se refleja en GitHub.
+Los cambios recientes pueden verse antes en **Tea** que en **GitHub** debido a la sincronización automática.
+
+---
+
+📅 Última actualización: **2024-03-05**.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..ab16327
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,9 @@
+## 🔐 Acceso al Repositorio Privado
+
+Nuestro servidor Git en **Tea** tiene un registro cerrado. Para contribuir:
+
+1. Abre un **Issue** en [GitHub](https://github.com/koneko-mx/laravel-vuexy-admin/issues) indicando tu interés en contribuir.
+2. Alternativamente, envía un correo a **contacto@koneko.mx** solicitando acceso.
+3. Una vez aprobado, recibirás una invitación para registrarte y clonar el repositorio.
+
+Si solo necesitas acceso de lectura, puedes clonar la versión pública en **GitHub**.
diff --git a/Console/Commands/CleanInitialAvatars.php b/Console/Commands/CleanInitialAvatars.php
new file mode 100644
index 0000000..0f03d7f
--- /dev/null
+++ b/Console/Commands/CleanInitialAvatars.php
@@ -0,0 +1,43 @@
+files($directory);
+
+ foreach ($files as $file) {
+ $lastModified = Storage::disk('public')->lastModified($file);
+
+ // Elimina archivos no accedidos en los últimos 30 días
+ if (now()->timestamp - $lastModified > 30 * 24 * 60 * 60) {
+ Storage::disk('public')->delete($file);
+ }
+ }
+
+ $this->info('Avatares iniciales antiguos eliminados.');
+ }
+}
diff --git a/Console/Commands/SyncRBAC.php b/Console/Commands/SyncRBAC.php
new file mode 100644
index 0000000..45ce8ed
--- /dev/null
+++ b/Console/Commands/SyncRBAC.php
@@ -0,0 +1,26 @@
+argument('action');
+ if ($action === 'import') {
+ RBACService::loadRolesAndPermissions();
+ $this->info('Roles y permisos importados correctamente.');
+ } elseif ($action === 'export') {
+ // Implementación para exportar los roles a JSON
+ $this->info('Exportación de roles y permisos completada.');
+ } else {
+ $this->error('Acción no válida. Usa "import" o "export".');
+ }
+ }
+}
diff --git a/Helpers/CatalogHelper.php b/Helpers/CatalogHelper.php
new file mode 100644
index 0000000..c8375f0
--- /dev/null
+++ b/Helpers/CatalogHelper.php
@@ -0,0 +1,72 @@
+find($id);
+ return response()->json($data);
+ }
+
+ // Aplicar filtros personalizados
+ foreach ($customFilters as $field => $value) {
+ if (!is_null($value)) {
+ $query->where($field, $value);
+ }
+ }
+
+ // Aplicar filtro de búsqueda si hay searchTerm
+ if ($searchTerm) {
+ $query->where($valueField, 'like', '%' . $searchTerm . '%');
+ }
+
+ // Limitar resultados si el límite no es falso
+ if ($limit > 0) {
+ $query->limit($limit);
+ }
+
+ $results = $query->get([$keyField, $valueField]);
+
+ // Devolver según el tipo de respuesta
+ switch ($responseType) {
+ case 'keyValue':
+ $data = $results->pluck($valueField, $keyField)->toArray();
+ break;
+
+ case 'select2':
+ $data = $results->map(function ($item) use ($keyField, $valueField) {
+ return [
+ 'id' => $item->{$keyField},
+ 'text' => $item->{$valueField},
+ ];
+ })->toArray();
+ break;
+
+ default:
+ $data = $results->map(function ($item) use ($keyField, $valueField) {
+ return [
+ 'id' => $item->{$keyField},
+ 'text' => $item->{$valueField},
+ ];
+ })->toArray();
+ break;
+ }
+
+ return response()->json($data);
+ }
+}
diff --git a/Helpers/VuexyHelper.php b/Helpers/VuexyHelper.php
new file mode 100644
index 0000000..847e8b4
--- /dev/null
+++ b/Helpers/VuexyHelper.php
@@ -0,0 +1,209 @@
+ 'vertical',
+ 'myTheme' => 'theme-default',
+ 'myStyle' => 'light',
+ 'myRTLSupport' => false,
+ 'myRTLMode' => true,
+ 'hasCustomizer' => true,
+ 'showDropdownOnHover' => true,
+ 'displayCustomizer' => true,
+ 'contentLayout' => 'compact',
+ 'headerType' => 'fixed',
+ 'navbarType' => 'fixed',
+ 'menuFixed' => true,
+ 'menuCollapsed' => false,
+ 'footerFixed' => false,
+ 'customizerControls' => [
+ 'rtl',
+ 'style',
+ 'headerType',
+ 'contentLayout',
+ 'layoutCollapsed',
+ 'showDropdownOnHover',
+ 'layoutNavbarOptions',
+ 'themes',
+ ],
+ // 'defaultLanguage'=>'en',
+ ];
+
+ // if any key missing of array from custom.php file it will be merge and set a default value from dataDefault array and store in data variable
+ $data = array_merge($DefaultData, $data);
+
+ // All options available in the template
+ $allOptions = [
+ 'myLayout' => ['vertical', 'horizontal', 'blank', 'front'],
+ 'menuCollapsed' => [true, false],
+ 'hasCustomizer' => [true, false],
+ 'showDropdownOnHover' => [true, false],
+ 'displayCustomizer' => [true, false],
+ 'contentLayout' => ['compact', 'wide'],
+ 'headerType' => ['fixed', 'static'],
+ 'navbarType' => ['fixed', 'static', 'hidden'],
+ 'myStyle' => ['light', 'dark', 'system'],
+ 'myTheme' => ['theme-default', 'theme-bordered', 'theme-semi-dark'],
+ 'myRTLSupport' => [true, false],
+ 'myRTLMode' => [true, false],
+ 'menuFixed' => [true, false],
+ 'footerFixed' => [true, false],
+ 'customizerControls' => [],
+ // 'defaultLanguage'=>array('en'=>'en','fr'=>'fr','de'=>'de','ar'=>'ar'),
+ ];
+
+ //if myLayout value empty or not match with default options in custom.php config file then set a default value
+ foreach ($allOptions as $key => $value) {
+ if (array_key_exists($key, $DefaultData)) {
+ if (gettype($DefaultData[$key]) === gettype($data[$key])) {
+ // data key should be string
+ if (is_string($data[$key])) {
+ // data key should not be empty
+ if (isset($data[$key]) && $data[$key] !== null) {
+ // data key should not be exist inside allOptions array's sub array
+ if (!array_key_exists($data[$key], $value)) {
+ // ensure that passed value should be match with any of allOptions array value
+ $result = array_search($data[$key], $value, 'strict');
+ if (empty($result) && $result !== 0) {
+ $data[$key] = $DefaultData[$key];
+ }
+ }
+ } else {
+ // if data key not set or
+ $data[$key] = $DefaultData[$key];
+ }
+ }
+ } else {
+ $data[$key] = $DefaultData[$key];
+ }
+ }
+ }
+ $styleVal = $data['myStyle'] == "dark" ? "dark" : "light";
+ $styleUpdatedVal = $data['myStyle'] == "dark" ? "dark" : $data['myStyle'];
+ // Determine if the layout is admin or front based on cookies
+ $layoutName = $data['myLayout'];
+ $isAdmin = Str::contains($layoutName, 'front') ? false : true;
+
+ $modeCookieName = $isAdmin ? 'admin-mode' : 'front-mode';
+ $colorPrefCookieName = $isAdmin ? 'admin-colorPref' : 'front-colorPref';
+
+ // Determine style based on cookies, only if not 'blank-layout'
+ if ($layoutName !== 'blank') {
+ if (isset($_COOKIE[$modeCookieName])) {
+ $styleVal = $_COOKIE[$modeCookieName];
+ if ($styleVal === 'system') {
+ $styleVal = isset($_COOKIE[$colorPrefCookieName]) ? $_COOKIE[$colorPrefCookieName] : 'light';
+ }
+ $styleUpdatedVal = $_COOKIE[$modeCookieName];
+ }
+ }
+
+ isset($_COOKIE['theme']) ? $themeVal = $_COOKIE['theme'] : $themeVal = $data['myTheme'];
+
+ $directionVal = isset($_COOKIE['direction']) ? ($_COOKIE['direction'] === "true" ? 'rtl' : 'ltr') : $data['myRTLMode'];
+
+ //layout classes
+ $layoutClasses = [
+ 'layout' => $data['myLayout'],
+ 'theme' => $themeVal,
+ 'themeOpt' => $data['myTheme'],
+ 'style' => $styleVal,
+ 'styleOpt' => $data['myStyle'],
+ 'styleOptVal' => $styleUpdatedVal,
+ 'rtlSupport' => $data['myRTLSupport'],
+ 'rtlMode' => $data['myRTLMode'],
+ 'textDirection' => $directionVal, //$data['myRTLMode'],
+ 'menuCollapsed' => $data['menuCollapsed'],
+ 'hasCustomizer' => $data['hasCustomizer'],
+ 'showDropdownOnHover' => $data['showDropdownOnHover'],
+ 'displayCustomizer' => $data['displayCustomizer'],
+ 'contentLayout' => $data['contentLayout'],
+ 'headerType' => $data['headerType'],
+ 'navbarType' => $data['navbarType'],
+ 'menuFixed' => $data['menuFixed'],
+ 'footerFixed' => $data['footerFixed'],
+ 'customizerControls' => $data['customizerControls'],
+ ];
+
+ // sidebar Collapsed
+ if ($layoutClasses['menuCollapsed'] == true) {
+ $layoutClasses['menuCollapsed'] = 'layout-menu-collapsed';
+ }
+
+ // Header Type
+ if ($layoutClasses['headerType'] == 'fixed') {
+ $layoutClasses['headerType'] = 'layout-menu-fixed';
+ }
+ // Navbar Type
+ if ($layoutClasses['navbarType'] == 'fixed') {
+ $layoutClasses['navbarType'] = 'layout-navbar-fixed';
+ } elseif ($layoutClasses['navbarType'] == 'static') {
+ $layoutClasses['navbarType'] = '';
+ } else {
+ $layoutClasses['navbarType'] = 'layout-navbar-hidden';
+ }
+
+ // Menu Fixed
+ if ($layoutClasses['menuFixed'] == true) {
+ $layoutClasses['menuFixed'] = 'layout-menu-fixed';
+ }
+
+ // Footer Fixed
+ if ($layoutClasses['footerFixed'] == true) {
+ $layoutClasses['footerFixed'] = 'layout-footer-fixed';
+ }
+
+ // RTL Supported template
+ if ($layoutClasses['rtlSupport'] == true) {
+ $layoutClasses['rtlSupport'] = '/rtl';
+ }
+
+ // RTL Layout/Mode
+ if ($layoutClasses['rtlMode'] == true) {
+ $layoutClasses['rtlMode'] = 'rtl';
+ $layoutClasses['textDirection'] = isset($_COOKIE['direction']) ? ($_COOKIE['direction'] === "true" ? 'rtl' : 'ltr') : 'rtl';
+ } else {
+ $layoutClasses['rtlMode'] = 'ltr';
+ $layoutClasses['textDirection'] = isset($_COOKIE['direction']) && $_COOKIE['direction'] === "true" ? 'rtl' : 'ltr';
+ }
+
+ // Show DropdownOnHover for Horizontal Menu
+ if ($layoutClasses['showDropdownOnHover'] == true) {
+ $layoutClasses['showDropdownOnHover'] = true;
+ } else {
+ $layoutClasses['showDropdownOnHover'] = false;
+ }
+
+ // To hide/show display customizer UI, not js
+ if ($layoutClasses['displayCustomizer'] == true) {
+ $layoutClasses['displayCustomizer'] = true;
+ } else {
+ $layoutClasses['displayCustomizer'] = false;
+ }
+
+ return $layoutClasses;
+ }
+
+ public static function updatePageConfig($pageConfigs)
+ {
+ $demo = 'custom';
+ if (isset($pageConfigs)) {
+ if (count($pageConfigs) > 0) {
+ foreach ($pageConfigs as $config => $val) {
+ Config::set('vuexy.' . $demo . '.' . $config, $val);
+ }
+ }
+ }
+ }
+}
diff --git a/Http/Controllers/AdminController.php b/Http/Controllers/AdminController.php
new file mode 100644
index 0000000..edd14e7
--- /dev/null
+++ b/Http/Controllers/AdminController.php
@@ -0,0 +1,62 @@
+expectsJson(), 403, __('errors.ajax_only'));
+
+ $VuexyAdminService = app(VuexyAdminService::class);
+
+ return response()->json($VuexyAdminService->getVuexySearchData());
+ }
+
+ public function quickLinksUpdate(Request $request)
+ {
+ abort_if(!request()->expectsJson(), 403, __('errors.ajax_only'));
+
+ $validated = $request->validate([
+ 'action' => 'required|in:update,remove',
+ 'route' => 'required|string',
+ ]);
+
+ $quickLinks = Setting::where('user_id', Auth::user()->id)
+ ->where('key', 'quicklinks')
+ ->first();
+
+ $quickLinks = $quickLinks ? json_decode($quickLinks->value, true) : [];
+
+ if ($validated['action'] === 'update') {
+ // Verificar si ya existe
+ if (!in_array($validated['route'], $quickLinks))
+ $quickLinks[] = $validated['route'];
+ } elseif ($validated['action'] === 'remove') {
+ // Eliminar la ruta si existe
+ $quickLinks = array_filter($quickLinks, function ($route) use ($validated) {
+ return $route !== $validated['route'];
+ });
+ }
+
+ Setting::updateOrCreate(['user_id' => Auth::user()->id, 'key' => 'quicklinks'], ['value' => json_encode($quickLinks)]);
+
+ VuexyAdminService::clearQuickLinksCache();
+ }
+
+ public function generalSettings()
+ {
+ return view('vuexy-admin::admin-settings.webapp-general-settings');
+ }
+
+ public function smtpSettings()
+ {
+ return view('vuexy-admin::admin-settings.smtp-settings');
+ }
+}
diff --git a/Http/Controllers/AuthController.php b/Http/Controllers/AuthController.php
new file mode 100644
index 0000000..49495e4
--- /dev/null
+++ b/Http/Controllers/AuthController.php
@@ -0,0 +1,144 @@
+ 'blank'];
+
+ return view("vuexy-admin::auth.login-{$viewMode}", ['pageConfigs' => $pageConfigs]);
+ }
+
+ public function registerView()
+ {
+ if (!Features::enabled(Features::registration()))
+ abort(403, 'El registro está deshabilitado.');
+
+ $viewMode = config('vuexy.custom.authViewMode');
+ $pageConfigs = ['myLayout' => 'blank'];
+
+ return view("vuexy-admin::auth.register-{$viewMode}", ['pageConfigs' => $pageConfigs]);
+ }
+
+
+
+ public function confirmPasswordView()
+ {
+ if (!Features::enabled(Features::registration()))
+ abort(403, 'El registro está deshabilitado.');
+
+ $viewMode = config('vuexy.custom.authViewMode');
+ $pageConfigs = ['myLayout' => 'blank'];
+
+ return view("vuexy-admin::auth.confirm-password-{$viewMode}", ['pageConfigs' => $pageConfigs]);
+ }
+
+ public function resetPasswordView()
+ {
+ if (!Features::enabled(Features::resetPasswords()))
+ abort(403, 'El registro está deshabilitado.');
+
+ $viewMode = config('vuexy.custom.authViewMode');
+ $pageConfigs = ['myLayout' => 'blank'];
+
+ return view("vuexy-admin::auth.reset-password-{$viewMode}", ['pageConfigs' => $pageConfigs]);
+ }
+
+ public function requestPasswordResetLinkView(Request $request)
+ {
+ if (!Features::enabled(Features::resetPasswords()))
+ abort(403, 'El registro está deshabilitado.');
+
+ $viewMode = config('vuexy.custom.authViewMode');
+ $pageConfigs = ['myLayout' => 'blank'];
+
+ return view("vuexy-admin::auth.reset-password-{$viewMode}", ['pageConfigs' => $pageConfigs, 'request' => $request]);
+ }
+
+
+
+
+
+
+
+
+ public function twoFactorChallengeView()
+ {
+ if (!Features::enabled(Features::registration()))
+ abort(403, 'El registro está deshabilitado.');
+
+ $viewMode = config('vuexy.custom.authViewMode');
+ $pageConfigs = ['myLayout' => 'blank'];
+
+ return view("vuexy-admin::auth.two-factor-challenge-{$viewMode}", ['pageConfigs' => $pageConfigs]);
+ }
+
+ public function twoFactorRecoveryCodesView()
+ {
+ if (!Features::enabled(Features::registration()))
+ abort(403, 'El registro está deshabilitado.');
+
+ $viewMode = config('vuexy.custom.authViewMode');
+ $pageConfigs = ['myLayout' => 'blank'];
+
+ return view("vuexy-admin::auth.register-{$viewMode}", ['pageConfigs' => $pageConfigs]);
+ }
+
+ public function twoFactorAuthenticationView()
+ {
+ if (!Features::enabled(Features::registration()))
+ abort(403, 'El registro está deshabilitado.');
+
+ $viewMode = config('vuexy.custom.authViewMode');
+ $pageConfigs = ['myLayout' => 'blank'];
+
+ return view("vuexy-admin::auth.register-{$viewMode}", ['pageConfigs' => $pageConfigs]);
+ }
+
+
+
+
+
+ public function verifyEmailView()
+ {
+ if (!Features::enabled(Features::registration()))
+ abort(403, 'El registro está deshabilitado.');
+
+ $viewMode = config('vuexy.custom.authViewMode');
+ $pageConfigs = ['myLayout' => 'blank'];
+
+ return view("vuexy-admin::auth.verify-email-{$viewMode}", ['pageConfigs' => $pageConfigs]);
+ }
+
+ public function showEmailVerificationForm()
+ {
+ if (!Features::enabled(Features::registration()))
+ abort(403, 'El registro está deshabilitado.');
+
+ $viewMode = config('vuexy.custom.authViewMode');
+ $pageConfigs = ['myLayout' => 'blank'];
+
+ return view("vuexy-admin::auth.register-{$viewMode}", ['pageConfigs' => $pageConfigs]);
+ }
+
+ public function userProfileView()
+ {
+ if (!Features::enabled(Features::registration()))
+ abort(403, 'El registro está deshabilitado.');
+
+ $viewMode = config('vuexy.custom.authViewMode');
+ $pageConfigs = ['myLayout' => 'blank'];
+
+ return view("vuexy-admin::auth.register-{$viewMode}", ['pageConfigs' => $pageConfigs]);
+ }
+ */
+}
diff --git a/Http/Controllers/CacheController.php b/Http/Controllers/CacheController.php
new file mode 100644
index 0000000..fd365d6
--- /dev/null
+++ b/Http/Controllers/CacheController.php
@@ -0,0 +1,41 @@
+json(['success' => true, 'message' => 'Cache generado correctamente.']);
+ } catch (\Exception $e) {
+ return response()->json(['success' => false, 'message' => 'Error al generar el cache.', 'error' => $e->getMessage()], 500);
+ }
+ }
+
+ public function generateRouteCache()
+ {
+ try {
+ // Lógica para generar cache de rutas
+ Artisan::call('route:cache');
+
+ return response()->json(['success' => true, 'message' => 'Cache de rutas generado correctamente.']);
+ } catch (\Exception $e) {
+ return response()->json(['success' => false, 'message' => 'Error al generar el cache de rutas.', 'error' => $e->getMessage()], 500);
+ }
+ }
+
+ public function cacheManager(CacheConfigService $cacheConfigService)
+ {
+ $configCache = $cacheConfigService->getConfig();
+
+ return view('vuexy-admin::cache-manager.index', compact('configCache'));
+ }
+}
diff --git a/Http/Controllers/HomeController.php b/Http/Controllers/HomeController.php
new file mode 100644
index 0000000..86820df
--- /dev/null
+++ b/Http/Controllers/HomeController.php
@@ -0,0 +1,32 @@
+ 'blank'];
+
+ return view('vuexy-admin::pages.comingsoon', compact('pageConfigs'));
+ }
+
+ public function underMaintenance()
+ {
+ $pageConfigs = ['myLayout' => 'blank'];
+
+ return view('vuexy-admin::pages.under-maintenance', compact('pageConfigs'));
+ }
+}
diff --git a/Http/Controllers/LanguageController.php b/Http/Controllers/LanguageController.php
new file mode 100644
index 0000000..80b4874
--- /dev/null
+++ b/Http/Controllers/LanguageController.php
@@ -0,0 +1,21 @@
+session()->put('locale', $locale);
+ }
+ App::setLocale($locale);
+ return redirect()->back();
+ }
+}
diff --git a/Http/Controllers/PermissionController.php b/Http/Controllers/PermissionController.php
new file mode 100644
index 0000000..87fa3ca
--- /dev/null
+++ b/Http/Controllers/PermissionController.php
@@ -0,0 +1,37 @@
+ajax()) {
+ $permissions = Permission::latest()->get();
+
+ return DataTables::of($permissions)
+ ->addIndexColumn()
+ ->addColumn('assigned_to', function ($row) {
+ return (Arr::pluck($row->roles, ['name']));
+ })
+ ->editColumn('created_at', function ($request) {
+ return $request->created_at->format('Y-m-d h:i:s a');
+ })
+ ->make(true);
+ }
+
+ return view('vuexy-admin::permissions.index');
+ }
+}
diff --git a/Http/Controllers/RoleController.php b/Http/Controllers/RoleController.php
new file mode 100644
index 0000000..f1158e1
--- /dev/null
+++ b/Http/Controllers/RoleController.php
@@ -0,0 +1,38 @@
+input('id');
+ $name = $request->input('name');
+
+ // Verificar si el nombre ya existe en la base de datos
+ $existingRole = Role::where('name', $name)
+ ->whereNot('id', $id)
+ ->first();
+
+ if ($existingRole) {
+ return response()->json(['valid' => false]);
+ }
+
+ return response()->json(['valid' => true]);
+ }
+}
diff --git a/Http/Controllers/RolePermissionController.php b/Http/Controllers/RolePermissionController.php
new file mode 100644
index 0000000..d3d9c78
--- /dev/null
+++ b/Http/Controllers/RolePermissionController.php
@@ -0,0 +1,76 @@
+json([
+ 'roles' => Role::with('permissions')->get(),
+ 'permissions' => Permission::all()
+ ]);
+ }
+
+ public function storeRole(Request $request)
+ {
+ $request->validate(['name' => 'required|string|unique:roles,name']);
+ $role = Role::create(['name' => $request->name]);
+ return response()->json(['message' => 'Rol creado con éxito', 'role' => $role]);
+ }
+
+ public function storePermission(Request $request)
+ {
+ $request->validate(['name' => 'required|string|unique:permissions,name']);
+ $permission = Permission::create(['name' => $request->name]);
+ return response()->json(['message' => 'Permiso creado con éxito', 'permission' => $permission]);
+ }
+
+ public function assignPermissionToRole(Request $request)
+ {
+ $request->validate([
+ 'role_id' => 'required|exists:roles,id',
+ 'permission_id' => 'required|exists:permissions,id'
+ ]);
+
+ $role = Role::findById($request->role_id);
+ $permission = Permission::findById($request->permission_id);
+
+ $role->givePermissionTo($permission->name);
+
+ return response()->json(['message' => 'Permiso asignado con éxito']);
+ }
+
+ public function removePermissionFromRole(Request $request)
+ {
+ $request->validate([
+ 'role_id' => 'required|exists:roles,id',
+ 'permission_id' => 'required|exists:permissions,id'
+ ]);
+
+ $role = Role::findById($request->role_id);
+ $permission = Permission::findById($request->permission_id);
+
+ $role->revokePermissionTo($permission->name);
+
+ return response()->json(['message' => 'Permiso eliminado con éxito']);
+ }
+
+ public function deleteRole($id)
+ {
+ $role = Role::findOrFail($id);
+ $role->delete();
+ return response()->json(['message' => 'Rol eliminado con éxito']);
+ }
+
+ public function deletePermission($id)
+ {
+ $permission = Permission::findOrFail($id);
+ $permission->delete();
+ return response()->json(['message' => 'Permiso eliminado con éxito']);
+ }
+}
diff --git a/Http/Controllers/UserController copy.php b/Http/Controllers/UserController copy.php
new file mode 100644
index 0000000..117d910
--- /dev/null
+++ b/Http/Controllers/UserController copy.php
@@ -0,0 +1,188 @@
+ajax()) {
+ $users = User::when(!Auth::user()->hasRole('SuperAdmin'), function ($query) {
+ $query->where('id', '>', 1);
+ })
+ ->latest()
+ ->get();
+
+ return DataTables::of($users)
+ ->only(['id', 'name', 'email', 'avatar', 'roles', 'status', 'created_at'])
+ ->addIndexColumn()
+ ->addColumn('avatar', function ($user) {
+ return $user->profile_photo_url;
+ })
+ ->addColumn('roles', function ($user) {
+ return (Arr::pluck($user->roles, ['name']));
+ })
+ /*
+ ->addColumn('stores', function ($user) {
+ return (Arr::pluck($user->stores, ['nombre']));
+ })
+ y*/
+ ->editColumn('created_at', function ($user) {
+ return $user->created_at->format('Y-m-d');
+ })
+ ->make(true);
+ }
+
+ return view('vuexy-admin::users.index');
+ }
+
+ /**
+ * Store a newly created resource in storage.
+ *
+ * @param \Illuminate\Http\Request $request
+ * @return \Illuminate\Http\Response
+ */
+ public function store(Request $request)
+ {
+ $validator = Validator::make($request->all(), [
+ 'name' => 'required|max:255',
+ 'email' => 'required|max:255|unique:users',
+ 'photo' => 'nullable|mimes:jpg,jpeg,png|max:1024',
+ 'password' => 'required',
+ ]);
+
+ if ($validator->fails())
+ return response()->json(['errors' => $validator->errors()->all()]);
+
+ // Preparamos los datos
+ $user_request = array_merge_recursive($request->all(), [
+ 'remember_token' => Str::random(10),
+ 'created_by' => Auth::user()->id,
+ ]);
+
+ $user_request['password'] = bcrypt($request->password);
+
+ // Guardamos el nuevo usuario
+ $user = User::create($user_request);
+
+ // Asignmos los permisos
+ $user->assignRole($request->roles);
+
+ // Asignamos Sucursals
+ //$user->stores()->attach($request->stores);
+
+ if ($request->file('photo')){
+ $avatarImageService = new AvatarImageService();
+
+ $avatarImageService->updateProfilePhoto($user, $request->file('photo'));
+ }
+
+ return response()->json(['success' => 'Se agrego correctamente el usuario']);
+ }
+
+ /**
+ * Display the specified resource.
+ *
+ * @param int User $user
+ * @return \Illuminate\Http\Response
+ */
+ public function show(User $user)
+ {
+ return view('vuexy-admin::users.show', compact('user'));
+ }
+
+ /**
+ * Update the specified resource in storage.
+ *
+ * @param \Illuminate\Http\Request $request
+ * @param int User $user
+ * @return \Illuminate\Http\Response
+ */
+ public function updateAjax(Request $request, User $user)
+ {
+ // Validamos los datos
+ $validator = Validator::make($request->all(), [
+ 'name' => 'required|max:191',
+ 'email' => "required|max:191|unique:users,email," . $user->id,
+ 'photo' => 'nullable|mimes:jpg,jpeg,png|max:2048'
+ ]);
+
+ if ($validator->fails())
+ return response()->json(['errors' => $validator->errors()->all()]);
+
+ // Preparamos los datos
+ $user_request = $request->all();
+
+ if ($request->password) {
+ $user_request['password'] = bcrypt($request->password);
+ } else {
+ unset($user_request['password']);
+ }
+
+ // Guardamos los cambios
+ $user->update($user_request);
+
+ // Sincronizamos Roles
+ $user->syncRoles($request->roles);
+
+ // Sincronizamos Sucursals
+ //$user->stores()->sync($request->stores);
+
+ // Actualizamos foto de perfil
+ if ($request->file('photo'))
+ $avatarImageService = new AvatarImageService();
+
+ $avatarImageService->updateProfilePhoto($user, $request->file('photo'));
+
+ return response()->json(['success' => 'Se guardo correctamente los cambios.']);
+ }
+
+
+ public function userSettings(User $user)
+ {
+ return view('vuexy-admin::users.user-settings', compact('user'));
+ }
+
+ public function generateAvatar(Request $request)
+ {
+ // Validación de entrada
+ $request->validate([
+ 'name' => 'nullable|string',
+ 'color' => 'nullable|string|size:6',
+ 'background' => 'nullable|string|size:6',
+ 'size' => 'nullable|integer|min:20|max:1024'
+ ]);
+
+ $name = $request->get('name', 'NA');
+ $color = $request->get('color', '7F9CF5');
+ $background = $request->get('background', 'EBF4FF');
+ $size = $request->get('size', 100);
+
+ return User::getAvatarImage($name, $color, $background, $size);
+
+ try {
+ } catch (\Exception $e) {
+ // String base64 de una imagen PNG transparente de 1x1 píxel
+ $transparentBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==';
+
+ return response()->make(base64_decode($transparentBase64), 200, [
+ 'Content-Type' => 'image/png'
+ ]);
+ }
+ }
+}
diff --git a/Http/Controllers/UserController.php b/Http/Controllers/UserController.php
new file mode 100644
index 0000000..f5af305
--- /dev/null
+++ b/Http/Controllers/UserController.php
@@ -0,0 +1,234 @@
+ajax()) {
+ $bootstrapTableIndexConfig = [
+ 'table' => 'users',
+ 'columns' => [
+ 'users.id',
+ 'users.code',
+ DB::raw("CONCAT_WS(' ', users.name, users.last_name) AS full_name"),
+ 'users.email',
+ 'users.birth_date',
+ 'users.hire_date',
+ 'users.curp',
+ 'users.nss',
+ 'users.job_title',
+ 'users.profile_photo_path',
+ DB::raw("(SELECT GROUP_CONCAT(roles.name SEPARATOR ';') as roles FROM model_has_roles INNER JOIN roles ON (model_has_roles.role_id = roles.id) WHERE model_has_roles.model_id = 1) as roles"),
+ 'users.is_partner',
+ 'users.is_employee',
+ 'users.is_prospect',
+ 'users.is_customer',
+ 'users.is_provider',
+ 'users.is_user',
+ 'users.status',
+ DB::raw("CONCAT_WS(' ', created.name, created.last_name) AS creator"),
+ 'created.email AS creator_email',
+ 'users.created_at',
+ 'users.updated_at',
+ ],
+ 'joins' => [
+ [
+ 'table' => 'users as parent',
+ 'first' => 'users.parent_id',
+ 'second' => 'parent.id',
+ 'type' => 'leftJoin',
+ ],
+ [
+ 'table' => 'users as agent',
+ 'first' => 'users.agent_id',
+ 'second' => 'agent.id',
+ 'type' => 'leftJoin',
+ ],
+ [
+ 'table' => 'users as created',
+ 'first' => 'users.created_by',
+ 'second' => 'created.id',
+ 'type' => 'leftJoin',
+ ],
+ [
+ 'table' => 'sat_codigo_postal',
+ 'first' => 'users.domicilio_fiscal',
+ 'second' => 'sat_codigo_postal.c_codigo_postal',
+ 'type' => 'leftJoin',
+ ],
+ [
+ 'table' => 'sat_estado',
+ 'first' => 'sat_codigo_postal.c_estado',
+ 'second' => 'sat_estado.c_estado',
+ 'type' => 'leftJoin',
+ 'and' => [
+ 'sat_estado.c_pais = "MEX"',
+ ],
+ ],
+ [
+ 'table' => 'sat_localidad',
+ 'first' => 'sat_codigo_postal.c_localidad',
+ 'second' => 'sat_localidad.c_localidad',
+ 'type' => 'leftJoin',
+ 'and' => [
+ 'sat_codigo_postal.c_estado = sat_localidad.c_estado',
+ ],
+ ],
+ [
+ 'table' => 'sat_municipio',
+ 'first' => 'sat_codigo_postal.c_municipio',
+ 'second' => 'sat_municipio.c_municipio',
+ 'type' => 'leftJoin',
+ 'and' => [
+ 'sat_codigo_postal.c_estado = sat_municipio.c_estado',
+ ],
+ ],
+ [
+ 'table' => 'sat_regimen_fiscal',
+ 'first' => 'users.c_regimen_fiscal',
+ 'second' => 'sat_regimen_fiscal.c_regimen_fiscal',
+ 'type' => 'leftJoin',
+ ],
+ [
+ 'table' => 'sat_uso_cfdi',
+ 'first' => 'users.c_uso_cfdi',
+ 'second' => 'sat_uso_cfdi.c_uso_cfdi',
+ 'type' => 'leftJoin',
+ ],
+ ],
+ 'filters' => [
+ 'search' => ['users.name', 'users.email', 'users.code', 'parent.name', 'created.name'],
+ ],
+ 'sort_column' => 'users.name',
+ 'default_sort_order' => 'asc',
+ ];
+
+ return (new GenericQueryBuilder($request, $bootstrapTableIndexConfig))->getJson();
+ }
+
+ return view('vuexy-admin::users.index');
+ }
+
+ /**
+ * Store a newly created resource in storage.
+ *
+ * @param \Illuminate\Http\Request $request
+ * @return \Illuminate\Http\Response
+ */
+ public function store(Request $request)
+ {
+ $validator = Validator::make($request->all(), [
+ 'name' => 'required|max:255',
+ 'email' => 'required|max:255|unique:users',
+ 'photo' => 'nullable|mimes:jpg,jpeg,png|max:1024',
+ 'password' => 'required',
+ ]);
+
+ if ($validator->fails())
+ return response()->json(['errors' => $validator->errors()->all()]);
+
+ // Preparamos los datos
+ $user_request = array_merge_recursive($request->all(), [
+ 'remember_token' => Str::random(10),
+ 'created_by' => Auth::user()->id,
+ ]);
+
+ $user_request['password'] = bcrypt($request->password);
+
+ // Guardamos el nuevo usuario
+ $user = User::create($user_request);
+
+ // Asignmos los permisos
+ $user->assignRole($request->roles);
+
+ // Asignamos Sucursals
+ //$user->stores()->attach($request->stores);
+
+ if ($request->file('photo')){
+ $avatarImageService = new AvatarImageService();
+
+ $avatarImageService->updateProfilePhoto($user, $request->file('photo'));
+ }
+
+ return response()->json(['success' => 'Se agrego correctamente el usuario']);
+ }
+
+ /**
+ * Display the specified resource.
+ *
+ * @param int User $user
+ * @return \Illuminate\Http\Response
+ */
+ public function show(User $user)
+ {
+ return view('vuexy-admin::users.show', compact('user'));
+ }
+
+ /**
+ * Update the specified resource in storage.
+ *
+ * @param \Illuminate\Http\Request $request
+ * @param int User $user
+ * @return \Illuminate\Http\Response
+ */
+ public function updateAjax(Request $request, User $user)
+ {
+ // Validamos los datos
+ $validator = Validator::make($request->all(), [
+ 'name' => 'required|max:191',
+ 'email' => "required|max:191|unique:users,email," . $user->id,
+ 'photo' => 'nullable|mimes:jpg,jpeg,png|max:2048'
+ ]);
+
+ if ($validator->fails())
+ return response()->json(['errors' => $validator->errors()->all()]);
+
+ // Preparamos los datos
+ $user_request = $request->all();
+
+ if ($request->password) {
+ $user_request['password'] = bcrypt($request->password);
+ } else {
+ unset($user_request['password']);
+ }
+
+ // Guardamos los cambios
+ $user->update($user_request);
+
+ // Sincronizamos Roles
+ $user->syncRoles($request->roles);
+
+ // Sincronizamos Sucursals
+ //$user->stores()->sync($request->stores);
+
+ // Actualizamos foto de perfil
+ if ($request->file('photo'))
+ $avatarImageService = new AvatarImageService();
+
+ $avatarImageService->updateProfilePhoto($user, $request->file('photo'));
+
+ return response()->json(['success' => 'Se guardo correctamente los cambios.']);
+ }
+
+
+ public function userSettings(User $user)
+ {
+ return view('vuexy-admin::users.user-settings', compact('user'));
+ }
+
+}
diff --git a/Http/Controllers/UserProfileController.php b/Http/Controllers/UserProfileController.php
new file mode 100644
index 0000000..ac36c68
--- /dev/null
+++ b/Http/Controllers/UserProfileController.php
@@ -0,0 +1,54 @@
+validate([
+ 'name' => 'nullable|string',
+ 'color' => 'nullable|string|size:6',
+ 'background' => 'nullable|string|size:6',
+ 'size' => 'nullable|integer|min:20|max:1024'
+ ]);
+
+ $name = $request->get('name', 'NA');
+ $color = $request->get('color', '7F9CF5');
+ $background = $request->get('background', 'EBF4FF');
+ $size = $request->get('size', 100);
+
+ $avatarService = new AvatarInitialsService();
+
+ try {
+ return $avatarService->getAvatarImage($name, $color, $background, $size);
+
+ } catch (\Exception $e) {
+ // String base64 de una imagen PNG transparente de 1x1 píxel
+ $transparentBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==';
+
+ return response()->make(base64_decode($transparentBase64), 200, [
+ 'Content-Type' => 'image/png'
+ ]);
+ }
+ }
+
+
+}
diff --git a/Http/Middleware/AdminTemplateMiddleware.php b/Http/Middleware/AdminTemplateMiddleware.php
new file mode 100644
index 0000000..b6ce5b6
--- /dev/null
+++ b/Http/Middleware/AdminTemplateMiddleware.php
@@ -0,0 +1,37 @@
+header('Accept'), 'text/html')) {
+ $adminVars = app(AdminTemplateService::class)->getAdminVars();
+ $vuexyAdminService = app(VuexyAdminService::class);
+
+ View::share([
+ '_admin' => $adminVars,
+ 'vuexyMenu' => $vuexyAdminService->getMenu(),
+ 'vuexySearch' => $vuexyAdminService->getSearch(),
+ 'vuexyQuickLinks' => $vuexyAdminService->getQuickLinks(),
+ 'vuexyNotifications' => $vuexyAdminService->getNotifications(),
+ 'vuexyBreadcrumbs' => $vuexyAdminService->getBreadcrumbs(),
+ ]);
+
+ }
+
+ return $next($request);
+ }
+}
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..486d340
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,9 @@
+MIT License
+
+Copyright (c) 2025 koneko
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/Listeners/ClearUserCache.php b/Listeners/ClearUserCache.php
new file mode 100644
index 0000000..41eb55f
--- /dev/null
+++ b/Listeners/ClearUserCache.php
@@ -0,0 +1,25 @@
+user) {
+ VuexyAdminService::clearUserMenuCache();
+ VuexyAdminService::clearSearchMenuCache();
+ VuexyAdminService::clearQuickLinksCache();
+ VuexyAdminService::clearNotificationsCache();
+ }
+ }
+}
diff --git a/Listeners/HandleUserLogin.php b/Listeners/HandleUserLogin.php
new file mode 100644
index 0000000..840669b
--- /dev/null
+++ b/Listeners/HandleUserLogin.php
@@ -0,0 +1,26 @@
+ $event->user->id,
+ 'ip_address' => request()->ip(),
+ 'user_agent' => request()->header('User-Agent'),
+ ]);
+
+ // Actualizar el último login
+ $event->user->update(['last_login_at' => now(), 'last_login_ip' => request()->ip()]);
+
+ // Enviar notificación de inicio de sesión
+ //Mail::to($event->user->email)->send(new LoginNotification($event->user));
+ }
+}
diff --git a/Livewire/AdminSettings/ApplicationSettings.php b/Livewire/AdminSettings/ApplicationSettings.php
new file mode 100644
index 0000000..4c5e131
--- /dev/null
+++ b/Livewire/AdminSettings/ApplicationSettings.php
@@ -0,0 +1,83 @@
+loadSettings();
+ }
+
+ public function loadSettings($clearcache = false)
+ {
+ $this->upload_image_logo = null;
+ $this->upload_image_logo_dark = null;
+
+ $adminTemplateService = app(AdminTemplateService::class);
+
+ if ($clearcache) {
+ $adminTemplateService->clearAdminVarsCache();
+ }
+
+ // Obtener los valores de las configuraciones de la base de datos
+ $settings = $adminTemplateService->getAdminVars();
+
+ $this->admin_app_name = $settings['app_name'];
+ $this->admin_image_logo = $settings['image_logo']['large'];
+ $this->admin_image_logo_dark = $settings['image_logo']['large_dark'];
+ }
+
+ public function save()
+ {
+ $this->validate([
+ 'admin_app_name' => 'required|string|max:255',
+ 'upload_image_logo' => 'nullable|image|mimes:jpeg,png,jpg,svg,webp|max:20480',
+ 'upload_image_logo_dark' => 'nullable|image|mimes:jpeg,png,jpg,svg,webp|max:20480',
+ ]);
+
+ $adminSettingsService = app(AdminSettingsService::class);
+
+ // Guardar título del App en configuraciones
+ $adminSettingsService->updateSetting('admin_app_name', $this->admin_app_name);
+
+ // Procesar favicon si se ha cargado una imagen
+ if ($this->upload_image_logo) {
+ $adminSettingsService->processAndSaveImageLogo($this->upload_image_logo);
+ }
+
+ if ($this->upload_image_logo_dark) {
+ $adminSettingsService->processAndSaveImageLogo($this->upload_image_logo_dark, 'dark');
+ }
+
+ $this->loadSettings(true);
+
+ $this->dispatch(
+ 'notification',
+ target: $this->targetNotify,
+ type: 'success',
+ message: 'Se han guardado los cambios en las configuraciones.'
+ );
+ }
+
+ public function render()
+ {
+ return view('vuexy-admin::livewire.admin-settings.application-settings');
+ }
+}
diff --git a/Livewire/AdminSettings/GeneralSettings.php b/Livewire/AdminSettings/GeneralSettings.php
new file mode 100644
index 0000000..e1a1cf1
--- /dev/null
+++ b/Livewire/AdminSettings/GeneralSettings.php
@@ -0,0 +1,84 @@
+loadSettings();
+ }
+
+ public function loadSettings($clearcache = false)
+ {
+ $this->upload_image_favicon = null;
+
+ $adminTemplateService = app(AdminTemplateService::class);
+
+ if ($clearcache) {
+ $adminTemplateService->clearAdminVarsCache();
+ }
+
+ // Obtener los valores de las configuraciones de la base de datos
+ $settings = $adminTemplateService->getAdminVars();
+
+ $this->admin_title = $settings['title'];
+ $this->admin_favicon_16x16 = $settings['favicon']['16x16'];
+ $this->admin_favicon_76x76 = $settings['favicon']['76x76'];
+ $this->admin_favicon_120x120 = $settings['favicon']['120x120'];
+ $this->admin_favicon_152x152 = $settings['favicon']['152x152'];
+ $this->admin_favicon_180x180 = $settings['favicon']['180x180'];
+ $this->admin_favicon_192x192 = $settings['favicon']['192x192'];
+ }
+
+ public function save()
+ {
+ $this->validate([
+ 'admin_title' => 'required|string|max:255',
+ 'upload_image_favicon' => 'nullable|image|mimes:jpeg,png,jpg,svg,webp|max:20480',
+ ]);
+
+ $adminSettingsService = app(AdminSettingsService::class);
+
+ // Guardar título del sitio en configuraciones
+ $adminSettingsService->updateSetting('admin_title', $this->admin_title);
+
+ // Procesar favicon si se ha cargado una imagen
+ if ($this->upload_image_favicon) {
+ $adminSettingsService->processAndSaveFavicon($this->upload_image_favicon);
+ }
+
+ $this->loadSettings(true);
+
+ $this->dispatch(
+ 'notification',
+ target: $this->targetNotify,
+ type: 'success',
+ message: 'Se han guardado los cambios en las configuraciones.'
+ );
+ }
+
+ public function render()
+ {
+ return view('vuexy-admin::livewire.admin-settings.general-settings');
+ }
+}
diff --git a/Livewire/AdminSettings/InterfaceSettings.php b/Livewire/AdminSettings/InterfaceSettings.php
new file mode 100644
index 0000000..33ea5b1
--- /dev/null
+++ b/Livewire/AdminSettings/InterfaceSettings.php
@@ -0,0 +1,118 @@
+loadSettings();
+ }
+
+
+ public function loadSettings()
+ {
+ $adminTemplateService = app(AdminTemplateService::class);
+
+ // Obtener los valores de las configuraciones de la base de datos
+ $settings = $adminTemplateService->getVuexyCustomizerVars();
+
+ $this->vuexy_myLayout = $settings['myLayout'];
+ $this->vuexy_myTheme = $settings['myTheme'];
+ $this->vuexy_myStyle = $settings['myStyle'];
+ $this->vuexy_hasCustomizer = $settings['hasCustomizer'];
+ $this->vuexy_displayCustomizer = $settings['displayCustomizer'];
+ $this->vuexy_contentLayout = $settings['contentLayout'];
+ $this->vuexy_navbarType = $settings['navbarType'];
+ $this->vuexy_footerFixed = $settings['footerFixed'];
+ $this->vuexy_menuFixed = $settings['menuFixed'];
+ $this->vuexy_menuCollapsed = $settings['menuCollapsed'];
+ $this->vuexy_headerType = $settings['headerType'];
+ $this->vuexy_showDropdownOnHover = $settings['showDropdownOnHover'];
+ $this->vuexy_authViewMode = $settings['authViewMode'];
+ $this->vuexy_maxQuickLinks = $settings['maxQuickLinks'];
+ }
+
+ public function save()
+ {
+ $this->validate([
+ 'vuexy_maxQuickLinks' => 'required|integer|min:2|max:20',
+ ]);
+
+ $globalSettingsService = app(GlobalSettingsService::class);
+
+ // Guardar configuraciones
+ $globalSettingsService->updateSetting('config.vuexy.custom.myLayout', $this->vuexy_myLayout);
+ $globalSettingsService->updateSetting('config.vuexy.custom.myTheme', $this->vuexy_myTheme);
+ $globalSettingsService->updateSetting('config.vuexy.custom.myStyle', $this->vuexy_myStyle);
+ $globalSettingsService->updateSetting('config.vuexy.custom.hasCustomizer', $this->vuexy_hasCustomizer);
+ $globalSettingsService->updateSetting('config.vuexy.custom.displayCustomizer', $this->vuexy_displayCustomizer);
+ $globalSettingsService->updateSetting('config.vuexy.custom.contentLayout', $this->vuexy_contentLayout);
+ $globalSettingsService->updateSetting('config.vuexy.custom.navbarType', $this->vuexy_navbarType);
+ $globalSettingsService->updateSetting('config.vuexy.custom.footerFixed', $this->vuexy_footerFixed);
+ $globalSettingsService->updateSetting('config.vuexy.custom.menuFixed', $this->vuexy_menuFixed);
+ $globalSettingsService->updateSetting('config.vuexy.custom.menuCollapsed', $this->vuexy_menuCollapsed);
+ $globalSettingsService->updateSetting('config.vuexy.custom.headerType', $this->vuexy_headerType);
+ $globalSettingsService->updateSetting('config.vuexy.custom.showDropdownOnHover', $this->vuexy_showDropdownOnHover);
+ $globalSettingsService->updateSetting('config.vuexy.custom.authViewMode', $this->vuexy_authViewMode);
+ $globalSettingsService->updateSetting('config.vuexy.custom.maxQuickLinks', $this->vuexy_maxQuickLinks);
+
+ $globalSettingsService->clearSystemConfigCache();
+
+ // Refrescar el componente actual
+ $this->dispatch('clearLocalStoregeTemplateCustomizer');
+
+ $this->dispatch(
+ 'notification',
+ target: $this->targetNotify,
+ type: 'success',
+ message: 'Se han guardado los cambios en las configuraciones.',
+ deferReload: true
+ );
+ }
+
+ public function clearCustomConfig()
+ {
+ $globalSettingsService = app(GlobalSettingsService::class);
+
+ $globalSettingsService->clearVuexyConfig();
+
+ // Refrescar el componente actual
+ $this->dispatch('clearLocalStoregeTemplateCustomizer');
+
+ $this->dispatch(
+ 'notification',
+ target: $this->targetNotify,
+ type: 'success',
+ message: 'Se han guardado los cambios en las configuraciones.',
+ deferReload: true
+ );
+ }
+
+
+ public function render()
+ {
+ return view('vuexy-admin::livewire.admin-settings.interface-settings');
+ }
+}
diff --git a/Livewire/AdminSettings/MailSenderResponseSettings.php b/Livewire/AdminSettings/MailSenderResponseSettings.php
new file mode 100644
index 0000000..a6a1d35
--- /dev/null
+++ b/Livewire/AdminSettings/MailSenderResponseSettings.php
@@ -0,0 +1,106 @@
+ 'save'];
+
+ const REPLY_EMAIL_CREATOR = 1;
+ const REPLY_EMAIL_SENDER = 2;
+ const REPLY_EMAIL_CUSTOM = 3;
+
+ public $reply_email_options = [
+ self::REPLY_EMAIL_CREATOR => 'Responder al creador del documento',
+ self::REPLY_EMAIL_SENDER => 'Responder a quien envía el documento',
+ self::REPLY_EMAIL_CUSTOM => 'Definir dirección de correo electrónico',
+ ];
+
+
+ public function mount()
+ {
+ $this->loadSettings();
+ }
+
+
+ public function loadSettings()
+ {
+ $globalSettingsService = app(GlobalSettingsService::class);
+
+ // Obtener los valores de las configuraciones de la base de datos
+ $settings = $globalSettingsService->getMailSystemConfig();
+
+ $this->from_address = $settings['from']['address'];
+ $this->from_name = $settings['from']['name'];
+ $this->reply_to_method = $settings['reply_to']['method'];
+ $this->reply_to_email = $settings['reply_to']['email'];
+ $this->reply_to_name = $settings['reply_to']['name'];
+ }
+
+ public function save()
+ {
+ $this->validate([
+ 'from_address' => 'required|email',
+ 'from_name' => 'required|string|max:255',
+ 'reply_to_method' => 'required|string|max:255',
+ ], [
+ 'from_address.required' => 'El campo de correo electrónico es obligatorio.',
+ 'from_address.email' => 'El formato del correo electrónico no es válido.',
+ 'from_name.required' => 'El nombre es obligatorio.',
+ 'from_name.string' => 'El nombre debe ser una cadena de texto.',
+ 'from_name.max' => 'El nombre no puede tener más de 255 caracteres.',
+ 'reply_to_method.required' => 'El método de respuesta es obligatorio.',
+ 'reply_to_method.string' => 'El método de respuesta debe ser una cadena de texto.',
+ 'reply_to_method.max' => 'El método de respuesta no puede tener más de 255 caracteres.',
+ ]);
+
+ if ($this->reply_to_method == self::REPLY_EMAIL_CUSTOM) {
+ $this->validate([
+ 'reply_to_email' => ['required', 'email'],
+ 'reply_to_name' => ['required', 'string', 'max:255'],
+ ], [
+ 'reply_to_email.required' => 'El correo de respuesta es obligatorio.',
+ 'reply_to_email.email' => 'El formato del correo de respuesta no es válido.',
+ 'reply_to_name.required' => 'El nombre de respuesta es obligatorio.',
+ 'reply_to_name.string' => 'El nombre de respuesta debe ser una cadena de texto.',
+ 'reply_to_name.max' => 'El nombre de respuesta no puede tener más de 255 caracteres.',
+ ]);
+ }
+
+ $globalSettingsService = app(GlobalSettingsService::class);
+
+ // Guardar título del App en configuraciones
+ $globalSettingsService->updateSetting('mail.from.address', $this->from_address);
+ $globalSettingsService->updateSetting('mail.from.name', $this->from_name);
+ $globalSettingsService->updateSetting('mail.reply_to.method', $this->reply_to_method);
+ $globalSettingsService->updateSetting('mail.reply_to.email', $this->reply_to_method == self::REPLY_EMAIL_CUSTOM ? $this->reply_to_email : '');
+ $globalSettingsService->updateSetting('mail.reply_to.name', $this->reply_to_method == self::REPLY_EMAIL_CUSTOM ? $this->reply_to_name : '');
+
+ $globalSettingsService->clearMailSystemConfigCache();
+
+ $this->loadSettings();
+
+ $this->dispatch(
+ 'notification',
+ target: $this->targetNotify,
+ type: 'success',
+ message: 'Se han guardado los cambios en las configuraciones.',
+ );
+ }
+
+ public function render()
+ {
+ return view('vuexy-admin::livewire.admin-settings.mail-sender-response-settings');
+ }
+}
diff --git a/Livewire/AdminSettings/MailSmtpSettings.php b/Livewire/AdminSettings/MailSmtpSettings.php
new file mode 100644
index 0000000..3ddc256
--- /dev/null
+++ b/Livewire/AdminSettings/MailSmtpSettings.php
@@ -0,0 +1,175 @@
+ 'SSL (Secure Sockets Layer)',
+ self::SMTP_ENCRYPTION_TLS => 'TLS (Transport Layer Security)',
+ self::SMTP_ENCRYPTION_NONE => 'Sin encriptación (No recomendado)',
+ ];
+
+ public $rules = [
+ [
+ 'host' => 'nullable|string|max:255',
+ 'port' => 'nullable|integer',
+ 'encryption' => 'nullable|string',
+ 'username' => 'nullable|string|max:255',
+ 'password' => 'nullable|string|max:255',
+ ],
+ [
+ 'host.string' => 'El servidor SMTP debe ser una cadena de texto.',
+ 'host.max' => 'El servidor SMTP no puede exceder los 255 caracteres.',
+ 'port.integer' => 'El puerto SMTP debe ser un número entero.',
+ 'encryption.string' => 'El tipo de encriptación SMTP debe ser una cadena de texto.',
+ 'username.string' => 'El nombre de usuario SMTP debe ser una cadena de texto.',
+ 'username.max' => 'El nombre de usuario SMTP no puede exceder los 255 caracteres.',
+ 'password.string' => 'La contraseña SMTP debe ser una cadena de texto.',
+ 'password.max' => 'La contraseña SMTP no puede exceder los 255 caracteres.',
+ ]
+ ];
+
+
+ public function mount()
+ {
+ $this->loadSettings();
+ }
+
+ public function loadSettings()
+ {
+ $globalSettingsService = app(GlobalSettingsService::class);
+
+ // Obtener los valores de las configuraciones de la base de datos
+ $settings = $globalSettingsService->getMailSystemConfig();
+
+ $this->change_smtp_settings = false;
+ $this->save_button_disabled = true;
+
+ $this->host = $settings['mailers']['smtp']['host'];
+ $this->port = $settings['mailers']['smtp']['port'];
+ $this->encryption = $settings['mailers']['smtp']['encryption'];
+ $this->username = $settings['mailers']['smtp']['username'];
+ $this->password = null;
+ }
+
+ public function save()
+ {
+ $this->validate($this->rules[0]);
+
+ $globalSettingsService = app(GlobalSettingsService::class);
+
+ // Guardar título del App en configuraciones
+ $globalSettingsService->updateSetting('mail.mailers.smtp.host', $this->host);
+ $globalSettingsService->updateSetting('mail.mailers.smtp.port', $this->port);
+ $globalSettingsService->updateSetting('mail.mailers.smtp.encryption', $this->encryption);
+ $globalSettingsService->updateSetting('mail.mailers.smtp.username', $this->username);
+ $globalSettingsService->updateSetting('mail.mailers.smtp.password', Crypt::encryptString($this->password));
+
+ $globalSettingsService->clearMailSystemConfigCache();
+
+ $this->loadSettings();
+
+ $this->dispatch(
+ 'notification',
+ target: $this->targetNotify,
+ type: 'success',
+ message: 'Se han guardado los cambios en las configuraciones.'
+ );
+ }
+
+ public function testSmtpConnection()
+ {
+ // Validar los datos del formulario
+ $this->validate($this->rules[0]);
+
+ try {
+ // Verificar la conexión SMTP
+ if ($this->validateSMTPConnection()) {
+ $this->save_button_disabled = false;
+
+ $this->dispatch(
+ 'notification',
+ target: $this->targetNotify,
+ type: 'success',
+ message: 'Conexión SMTP exitosa, se guardó los cambios exitosamente.',
+ );
+ }
+ } catch (\Exception $e) {
+ // Captura y maneja errores de conexión SMTP
+ $this->dispatch(
+ 'notification',
+ target: $this->targetNotify,
+ type: 'danger',
+ message: 'Error en la conexión SMTP: ' . $e->getMessage(),
+ delay: 15000 // Timeout personalizado
+ );
+ }
+ }
+
+ private function validateSMTPConnection()
+ {
+ $dsn = sprintf(
+ 'smtp://%s:%s@%s:%s?encryption=%s',
+ urlencode($this->username), // Codificar nombre de usuario
+ urlencode($this->password), // Codificar contraseña
+ $this->host, // Host SMTP
+ $this->port, // Puerto SMTP
+ $this->encryption // Encriptación (tls o ssl)
+ );
+
+ // Crear el transportador usando el DSN
+ $transport = Transport::fromDsn($dsn);
+
+ // Crear el mailer con el transportador personalizado
+ $mailer = new Mailer($transport);
+
+ // Enviar un correo de prueba
+ $email = (new Email())
+ ->from($this->username) // Dirección de correo del remitente
+ ->to(env('MAIL_SANDBOX')) // Dirección de correo de destino
+ ->subject(Config::get('app.name') . ' - Correo de prueba')
+ ->text('Este es un correo de prueba para verificar la conexión SMTP.');
+
+ // Enviar el correo
+ $mailer->send($email);
+
+ return true;
+ }
+
+
+ public function render()
+ {
+ return view('vuexy-admin::livewire.admin-settings.mail-smtp-settings');
+ }
+}
diff --git a/Livewire/Cache/CacheFunctions.php b/Livewire/Cache/CacheFunctions.php
new file mode 100644
index 0000000..1ec47da
--- /dev/null
+++ b/Livewire/Cache/CacheFunctions.php
@@ -0,0 +1,212 @@
+ 0,
+ 'config' => 0,
+ 'routes' => 0,
+ 'views' => 0,
+ 'events' => 0,
+ ];
+
+ protected $listeners = [
+ 'reloadCacheFunctionsStatsEvent' => 'reloadCacheStats',
+ ];
+
+ public function mount()
+ {
+ $this->reloadCacheStats(false);
+ }
+
+ public function reloadCacheStats($notify = true)
+ {
+ $cacheDriver = config('cache.default'); // Obtiene el driver configurado para caché
+
+ // Caché General
+ switch ($cacheDriver) {
+ case 'memcached':
+ try {
+ $cacheStore = Cache::getStore()->getMemcached();
+ $stats = $cacheStore->getStats();
+
+ $this->cacheCounts['general'] = array_sum(array_column($stats, 'curr_items')); // Total de claves en Memcached
+ } catch (\Exception $e) {
+ $this->cacheCounts['general'] = 'Error obteniendo datos de Memcached';
+ }
+ break;
+
+ case 'redis':
+ try {
+ $prefix = config('cache.prefix'); // Asegúrate de agregar el sufijo correcto si es necesario
+ $keys = Redis::connection('cache')->keys($prefix . '*');
+
+ $this->cacheCounts['general'] = count($keys); // Total de claves en Redis
+ } catch (\Exception $e) {
+ $this->cacheCounts['general'] = 'Error obteniendo datos de Redis';
+ }
+ break;
+
+ case 'database':
+ try {
+ $this->cacheCounts['general'] = DB::table('cache')->count(); // Total de registros en la tabla de caché
+ } catch (\Exception $e) {
+ $this->cacheCounts['general'] = 'Error obteniendo datos de la base de datos';
+ }
+ break;
+
+ case 'file':
+ try {
+ $cachePath = config('cache.stores.file.path');
+ $files = glob($cachePath . '/*');
+
+ $this->cacheCounts['general'] = count($files);
+ } catch (\Exception $e) {
+ $this->cacheCounts['general'] = 'Error obteniendo datos de archivos';
+ }
+ break;
+
+ default:
+ $this->cacheCounts['general'] = 'Driver de caché no soportado';
+ }
+
+ // Configuración
+ $this->cacheCounts['config'] = file_exists(base_path('bootstrap/cache/config.php')) ? 1 : 0;
+
+ // Rutas
+ $this->cacheCounts['routes'] = count(glob(base_path('bootstrap/cache/routes-*.php'))) > 0 ? 1 : 0;
+
+ // Vistas
+ $this->cacheCounts['views'] = count(glob(storage_path('framework/views/*')));
+
+ // Configuración
+ $this->cacheCounts['events'] = file_exists(base_path('bootstrap/cache/events.php')) ? 1 : 0;
+
+ if ($notify) {
+ $this->dispatch(
+ 'notification',
+ target: $this->targetNotify,
+ type: 'success',
+ message: 'Se han recargado los estadísticos de caché.'
+ );
+ }
+ }
+
+
+ public function clearLaravelCache()
+ {
+ Artisan::call('cache:clear');
+
+ sleep(1);
+
+ $this->response('Se han limpiado las cachés de la aplicación.', 'warning');
+ }
+
+ public function clearConfigCache()
+ {
+ Artisan::call('config:clear');
+
+ $this->response('Se ha limpiado la cache de la configuración de Laravel.', 'warning');
+ }
+
+ public function configCache()
+ {
+ Artisan::call('config:cache');
+ }
+
+ public function clearRouteCache()
+ {
+ Artisan::call('route:clear');
+
+ $this->response('Se han limpiado las rutas de Laravel.', 'warning');
+ }
+
+ public function cacheRoutes()
+ {
+ Artisan::call('route:cache');
+ }
+
+ public function clearViewCache()
+ {
+ Artisan::call('view:clear');
+
+ $this->response('Se han limpiado las vistas de Laravel.', 'warning');
+ }
+
+ public function cacheViews()
+ {
+ Artisan::call('view:cache');
+
+ $this->response('Se han cacheado las vistas de Laravel.');
+ }
+
+ public function clearEventCache()
+ {
+ Artisan::call('event:clear');
+
+ $this->response('Se han limpiado los eventos de Laravel.', 'warning');
+ }
+
+ public function cacheEvents()
+ {
+ Artisan::call('event:cache');
+
+ $this->response('Se han cacheado los eventos de Laravel.');
+ }
+
+ public function optimizeClear()
+ {
+ Artisan::call('optimize:clear');
+
+ $this->response('Se han optimizado todos los cachés de Laravel.');
+ }
+
+ public function resetPermissionCache()
+ {
+ Artisan::call('permission:cache-reset');
+
+ $this->response('Se han limpiado los cachés de permisos.', 'warning');
+ }
+
+ public function clearResetTokens()
+ {
+ Artisan::call('auth:clear-resets');
+
+ $this->response('Se han limpiado los tokens de reseteo de contraseña.', 'warning');
+ }
+
+ /**
+ * Genera una respuesta estandarizada.
+ */
+ private function response(string $message, string $type = 'success'): void
+ {
+ $this->reloadCacheStats(false);
+
+ $this->dispatch(
+ 'notification',
+ target: $this->targetNotify,
+ type: $type,
+ message: $message,
+ );
+
+ $this->dispatch('reloadCacheStatsEvent', notify: false);
+ $this->dispatch('reloadSessionStatsEvent', notify: false);
+ $this->dispatch('reloadRedisStatsEvent', notify: false);
+ $this->dispatch('reloadMemcachedStatsEvent', notify: false);
+ }
+
+ public function render()
+ {
+ return view('vuexy-admin::livewire.cache.cache-functions');
+ }
+}
diff --git a/Livewire/Cache/CacheStats.php b/Livewire/Cache/CacheStats.php
new file mode 100644
index 0000000..ab54e66
--- /dev/null
+++ b/Livewire/Cache/CacheStats.php
@@ -0,0 +1,65 @@
+ 'reloadCacheStats'];
+
+ public function mount(CacheConfigService $cacheConfigService)
+ {
+ $this->cacheConfig = $cacheConfigService->getConfig();
+
+ $this->reloadCacheStats(false);
+ }
+
+ public function reloadCacheStats($notify = true)
+ {
+ $cacheManagerService = new CacheManagerService();
+
+ $this->cacheStats = $cacheManagerService->getCacheStats();
+
+ if ($notify) {
+ $this->dispatch(
+ 'notification',
+ target: $this->targetNotify,
+ type: $this->cacheStats['status'],
+ message: $this->cacheStats['message']
+ );
+ }
+ }
+
+ public function clearCache()
+ {
+ $cacheManagerService = new CacheManagerService();
+
+ $message = $cacheManagerService->clearCache();
+
+ $this->reloadCacheStats(false);
+
+ $this->dispatch(
+ 'notification',
+ target: $this->targetNotify,
+ type: $message['status'],
+ message: $message['message'],
+ );
+
+ $this->dispatch('reloadRedisStatsEvent', notify: false);
+ $this->dispatch('reloadMemcachedStatsEvent', notify: false);
+ $this->dispatch('reloadCacheFunctionsStatsEvent', notify: false);
+ }
+
+ public function render()
+ {
+ return view('vuexy-admin::livewire.cache.cache-stats');
+ }
+}
diff --git a/Livewire/Cache/MemcachedStats.php b/Livewire/Cache/MemcachedStats.php
new file mode 100644
index 0000000..456b108
--- /dev/null
+++ b/Livewire/Cache/MemcachedStats.php
@@ -0,0 +1,64 @@
+ 'reloadCacheStats'];
+
+ public function mount()
+ {
+ $this->reloadCacheStats(false);
+ }
+
+ public function reloadCacheStats($notify = true)
+ {
+ $cacheManagerService = new CacheManagerService($this->driver);
+
+ $memcachedStats = $cacheManagerService->getMemcachedStats();
+
+ $this->memcachedStats = $memcachedStats['info'];
+
+ if ($notify) {
+ $this->dispatch(
+ 'notification',
+ target: $this->targetNotify,
+ type: $memcachedStats['status'],
+ message: $memcachedStats['message']
+ );
+ }
+ }
+
+ public function clearCache()
+ {
+ $cacheManagerService = new CacheManagerService($this->driver);
+
+ $message = $cacheManagerService->clearCache();
+
+ $this->reloadCacheStats(false);
+
+ $this->dispatch(
+ 'notification',
+ target: $this->targetNotify,
+ type: $message['status'],
+ message: $message['message'],
+ );
+
+ $this->dispatch('reloadCacheStatsEvent', notify: false);
+ $this->dispatch('reloadSessionStatsEvent', notify: false);
+ $this->dispatch('reloadCacheFunctionsStatsEvent', notify: false);
+ }
+
+ public function render()
+ {
+ return view('vuexy-admin::livewire.cache.memcached-stats');
+ }
+}
diff --git a/Livewire/Cache/RedisStats.php b/Livewire/Cache/RedisStats.php
new file mode 100644
index 0000000..25946b0
--- /dev/null
+++ b/Livewire/Cache/RedisStats.php
@@ -0,0 +1,64 @@
+ 'reloadCacheStats'];
+
+ public function mount()
+ {
+ $this->reloadCacheStats(false);
+ }
+
+ public function reloadCacheStats($notify = true)
+ {
+ $cacheManagerService = new CacheManagerService($this->driver);
+
+ $redisStats = $cacheManagerService->getRedisStats();
+
+ $this->redisStats = $redisStats['info'];
+
+ if ($notify) {
+ $this->dispatch(
+ 'notification',
+ target: $this->targetNotify,
+ type: $redisStats['status'],
+ message: $redisStats['message']
+ );
+ }
+ }
+
+ public function clearCache()
+ {
+ $cacheManagerService = new CacheManagerService($this->driver);
+
+ $message = $cacheManagerService->clearCache();
+
+ $this->reloadCacheStats(false);
+
+ $this->dispatch(
+ 'notification',
+ target: $this->targetNotify,
+ type: $message['status'],
+ message: $message['message'],
+ );
+
+ $this->dispatch('reloadCacheStatsEvent', notify: false);
+ $this->dispatch('reloadSessionStatsEvent', notify: false);
+ $this->dispatch('reloadCacheFunctionsStatsEvent', notify: false);
+ }
+
+ public function render()
+ {
+ return view('vuexy-admin::livewire.cache.redis-stats');
+ }
+}
diff --git a/Livewire/Cache/SessionStats.php b/Livewire/Cache/SessionStats.php
new file mode 100644
index 0000000..c6fb063
--- /dev/null
+++ b/Livewire/Cache/SessionStats.php
@@ -0,0 +1,63 @@
+ 'reloadSessionStats'];
+
+ public function mount(CacheConfigService $cacheConfigService)
+ {
+ $this->cacheConfig = $cacheConfigService->getConfig();
+ $this->reloadSessionStats(false);
+ }
+
+ public function reloadSessionStats($notify = true)
+ {
+ $sessionManagerService = new SessionManagerService();
+
+ $this->sessionStats = $sessionManagerService->getSessionStats();
+
+ if ($notify) {
+ $this->dispatch(
+ 'notification',
+ target: $this->targetNotify,
+ type: $this->sessionStats['status'],
+ message: $this->sessionStats['message']
+ );
+ }
+ }
+
+ public function clearSessions()
+ {
+ $sessionManagerService = new SessionManagerService();
+
+ $message = $sessionManagerService->clearSessions();
+
+ $this->reloadSessionStats(false);
+
+ $this->dispatch(
+ 'notification',
+ target: $this->targetNotify,
+ type: $message['status'],
+ message: $message['message'],
+ );
+
+ $this->dispatch('reloadRedisStatsEvent', notify: false);
+ $this->dispatch('reloadMemcachedStatsEvent', notify: false);
+ }
+
+ public function render()
+ {
+ return view('vuexy-admin::livewire.cache.session-stats');
+ }
+}
diff --git a/Livewire/Form/AbstractFormComponent.php b/Livewire/Form/AbstractFormComponent.php
new file mode 100644
index 0000000..eceaca3
--- /dev/null
+++ b/Livewire/Form/AbstractFormComponent.php
@@ -0,0 +1,515 @@
+uniqueId = uniqid();
+ $this->mode = $mode;
+ $this->id = $id;
+
+ $model = new ($this->model());
+
+ $this->tagName = $model->tagName;
+ $this->columnNameLabel = $model->columnNameLabel;
+ $this->singularName = $model->singularName;
+ $this->formId = Str::camel($model->tagName) .'Form';
+
+ $this->setBtnSubmitText();
+
+ if ($this->mode !== 'create' && $this->id) {
+ // Si no es modo 'create', cargamos el registro desde la BD
+ $record = $this->model()::findOrFail($this->id);
+
+ $this->initializeFormData($record, $mode);
+
+ } else {
+ // Modo 'create', o sin ID: iniciamos datos vacíos
+ $this->initializeFormData(null, $mode);
+ }
+ }
+
+ /**
+ * Configura el texto del botón principal de envío, basado en la propiedad $mode.
+ *
+ * @return void
+ */
+ private function setBtnSubmitText(): void
+ {
+ $this->btnSubmitText = match ($this->mode) {
+ 'create' => 'Crear ' . $this->singularName(),
+ 'edit' => 'Guardar cambios',
+ 'delete' => 'Eliminar ' . $this->singularName(),
+ default => 'Enviar'
+ };
+ }
+
+ /**
+ * Retorna el "singularName" definido en el modelo asociado.
+ * Permite también decidir si se devuelve con la primera letra en mayúscula
+ * o en minúscula.
+ *
+ * @param string $type Puede ser 'uppercase' o 'lowercase'. Por defecto, 'lowercase'.
+ * @return string Nombre en singular del modelo, formateado.
+ */
+ private function singularName($type = 'lowercase'): string
+ {
+ /** @var Model $model */
+ $model = new ($this->model());
+
+ return $type === 'uppercase'
+ ? ucfirst($model->singularName)
+ : lcfirst($model->singularName);
+ }
+
+ /**
+ * Método del ciclo de vida de Livewire que se llama en cada hidratación.
+ * Puedes disparar eventos o manejar lógica que suceda en cada request
+ * una vez que Livewire 'rehidrate' el componente en el servidor.
+ *
+ * @return void
+ */
+ public function hydrate(): void
+ {
+ $this->dispatch($this->dispatches()['on-hydrate']);
+ }
+
+ // ======================================================================
+ // OPERACIONES CRUD
+ // ======================================================================
+
+ /**
+ * Método principal de envío del formulario (submit). Gestiona los flujos
+ * de crear, editar o eliminar un registro dentro de una transacción de BD.
+ *
+ * @return void
+ */
+ public function onSubmit(): void
+ {
+ DB::beginTransaction();
+
+ try {
+ if ($this->mode === 'delete') {
+ $this->delete();
+ } else {
+ $this->save();
+ }
+
+ DB::commit();
+
+ } catch (ValidationException $e) {
+ DB::rollBack();
+ $this->handleValidationException($e);
+
+ } catch (QueryException $e) {
+ DB::rollBack();
+ $this->handleDatabaseException($e);
+
+ } catch (ModelNotFoundException $e) {
+ DB::rollBack();
+ $this->handleException('danger', 'Registro no encontrado.');
+
+ } catch (Exception $e) {
+ DB::rollBack();
+ $this->handleException('danger', 'Error al eliminar el registro: ' . $e->getMessage());
+ }
+ }
+
+ /**
+ * Crea o actualiza un registro en la base de datos,
+ * aplicando validaciones y llamadas a hooks antes y después de guardar.
+ *
+ * @return void
+ * @throws ValidationException
+ */
+ protected function save(): void
+ {
+ // Validamos los datos, con posibles atributos y mensajes personalizados
+ $validatedData = $this->validate(
+ $this->dynamicRules($this->mode),
+ $this->messages(),
+ $this->attributes()
+ );
+
+ // Hook previo (por referencia)
+ $this->beforeSave($validatedData);
+
+ // Ajustamos/convertimos los datos finales
+ $data = $this->prepareData($validatedData);
+ $record = $this->model()::updateOrCreate(['id' => $this->id], $data);
+
+ // Hook posterior
+ $this->afterSave($record);
+
+ // Notificamos éxito
+ $this->handleSuccess('success', $this->singularName('uppercase') . " guardado correctamente.");
+ }
+
+ /**
+ * Elimina un registro de la base de datos (modo 'delete'),
+ * aplicando validaciones y hooks antes y después de la eliminación.
+ *
+ * @return void
+ * @throws ValidationException
+ */
+ protected function delete(): void
+ {
+ $this->validate($this->dynamicRules('delete', $this->messages(), $this->attributes()));
+
+ $record = $this->model()::findOrFail($this->id);
+
+ // Hook antes de la eliminación
+ $this->beforeDelete($record);
+
+ $record->delete();
+
+ // Hook después de la eliminación
+ $this->afterDelete($record);
+
+ $this->handleSuccess('warning', $this->singularName('uppercase') . " eliminado.");
+ }
+
+ // ======================================================================
+ // HOOKS DE ACCIONES
+ // ======================================================================
+
+ /**
+ * Hook que se ejecuta antes de guardar o actualizar un registro.
+ * Puede usarse para ajustar o limpiar datos antes de la operación en base de datos.
+ *
+ * @param array $data Datos validados que se van a guardar.
+ * Se pasa por referencia para permitir cambios.
+ * @return void
+ */
+ protected function beforeSave(array &$data): void {}
+
+ /**
+ * Hook que se ejecuta después de guardar o actualizar un registro.
+ * Puede usarse para acciones como disparar eventos, notificaciones a otros sistemas, etc.
+ *
+ * @param mixed $record Instancia del modelo recién creado o actualizado.
+ * @return void
+ */
+ protected function afterSave($record): void {}
+
+ /**
+ * Hook que se ejecuta antes de eliminar un registro.
+ * Puede emplearse para validaciones adicionales o limpieza de datos relacionados.
+ *
+ * @param mixed $record Instancia del modelo que se eliminará.
+ * @return void
+ */
+ protected function beforeDelete($record): void {}
+
+ /**
+ * Hook que se ejecuta después de eliminar un registro.
+ * Útil para operaciones finales, como remover archivos relacionados o
+ * disparar un evento de "elemento eliminado".
+ *
+ * @param mixed $record Instancia del modelo que se acaba de eliminar.
+ * @return void
+ */
+ protected function afterDelete($record): void {}
+
+ // ======================================================================
+ // MANEJO DE VALIDACIONES Y ERRORES
+ // ======================================================================
+
+ /**
+ * Maneja las excepciones de validación (ValidationException).
+ * Asigna los errores al error bag de Livewire y muestra notificaciones.
+ *
+ * @param ValidationException $e Excepción de validación.
+ * @return void
+ */
+ protected function handleValidationException(ValidationException $e): void
+ {
+ $this->setErrorBag($e->validator->errors());
+ $this->handleException('danger', 'Error en la validación de los datos.');
+ $this->dispatch($this->dispatches()['on-failed-validation']);
+ }
+
+ /**
+ * Maneja las excepciones de base de datos (QueryException).
+ * Incluye casos especiales para claves foráneas y duplicadas.
+ *
+ * @param QueryException $e Excepción de consulta a la base de datos.
+ * @return void
+ */
+ protected function handleDatabaseException(QueryException $e): void
+ {
+ $errorMessage = match ($e->errorInfo[1]) {
+ 1452 => "Una clave foránea no es válida.",
+ 1062 => $this->extractDuplicateField($e->getMessage()),
+ 1451 => "No se puede eliminar el registro porque está en uso.",
+ default => env('APP_DEBUG') ? $e->getMessage() : "Error inesperado en la base de datos.",
+ };
+
+ $this->handleException('danger', $errorMessage, 'form', 120000);
+ }
+
+ /**
+ * Maneja excepciones o errores generales, mostrando una notificación al usuario.
+ *
+ * @param string $type Tipo de notificación (por ejemplo, 'success', 'warning', 'danger').
+ * @param string $message Mensaje que se mostrará en la notificación.
+ * @param string $target Objetivo/área donde se mostrará la notificación ('form', 'index', etc.).
+ * @param int $delay Tiempo en milisegundos que la notificación permanecerá visible.
+ * @return void
+ */
+ protected function handleException($type, $message, $target = 'form', $delay = 9000): void
+ {
+ $this->dispatchNotification($type, $message, $target, $delay);
+ }
+
+ /**
+ * Extrae el campo duplicado de un mensaje de error MySQL, para mostrar un mensaje amigable.
+ *
+ * @param string $errorMessage Mensaje de error completo de la base de datos.
+ * @return string Mensaje simplificado indicando cuál campo está duplicado.
+ */
+ private function extractDuplicateField($errorMessage): string
+ {
+ preg_match("/for key 'unique_(.*?)'/", $errorMessage, $matches);
+
+ return isset($matches[1])
+ ? "El valor ingresado para '" . str_replace('_', ' ', $matches[1]) . "' ya está en uso."
+ : "Ya existe un registro con este valor.";
+ }
+
+ // ======================================================================
+ // NOTIFICACIONES Y REDIRECCIONAMIENTOS
+ // ======================================================================
+
+ /**
+ * Maneja el flujo de notificación y redirección cuando una operación
+ * (guardar, eliminar) finaliza satisfactoriamente.
+ *
+ * @param string $type Tipo de notificación ('success', 'warning', etc.).
+ * @param string $message Mensaje a mostrar.
+ * @return void
+ */
+ protected function handleSuccess($type, $message): void
+ {
+ $this->dispatchNotification($type, $message, 'index');
+ $this->redirectRoute($this->getRedirectRoute());
+ }
+
+ /**
+ * Envía una notificación al navegador (mediante eventos de Livewire)
+ * indicando el tipo, el mensaje y el destino donde debe visualizarse.
+ *
+ * @param string $type Tipo de notificación (success, danger, etc.).
+ * @param string $message Mensaje de la notificación.
+ * @param string $target Destino para mostrarla ('form', 'index', etc.).
+ * @param int $delay Duración de la notificación en milisegundos.
+ * @return void
+ */
+ protected function dispatchNotification($type, $message, $target = 'form', $delay = 9000): void
+ {
+ $this->dispatch(
+ $target == 'index' ? 'store-notification' : 'notification',
+ target: $target === 'index' ? $this->targetNotifies()['index'] : $this->targetNotifies()['form'],
+ type: $type,
+ message: $message,
+ delay: $delay
+ );
+ }
+
+ // ======================================================================
+ // RENDERIZACIÓN
+ // ======================================================================
+
+ /**
+ * Renderiza la vista Blade asociada a este componente.
+ * Retorna un objeto Illuminate\View\View.
+ *
+ * @return View
+ */
+ public function render(): View
+ {
+ return view($this->viewPath());
+ }
+}
diff --git a/Livewire/Form/AbstractFormOffCanvasComponent.php b/Livewire/Form/AbstractFormOffCanvasComponent.php
new file mode 100644
index 0000000..8512a26
--- /dev/null
+++ b/Livewire/Form/AbstractFormOffCanvasComponent.php
@@ -0,0 +1,667 @@
+
+ */
+ protected $casts = [];
+
+ // ===================== MÉTODOS ABSTRACTOS =====================
+
+ /**
+ * Define el modelo Eloquent asociado con el formulario.
+ *
+ * @return string
+ */
+ abstract protected function model(): string;
+
+ /**
+ * Define los campos del formulario.
+ *
+ * @return array
+ */
+ abstract protected function fields(): array;
+
+ /**
+ * Retorna los valores por defecto para los campos del formulario.
+ *
+ * @return array Valores predeterminados.
+ */
+ abstract protected function defaults(): array;
+
+ /**
+ * Campo que se debe enfocar cuando se abra el formulario.
+ *
+ * @return string
+ */
+ abstract protected function focusOnOpen(): string;
+
+ /**
+ * Define reglas de validación dinámicas según el modo del formulario.
+ *
+ * @param string $mode Modo actual del formulario ('create', 'edit', 'delete').
+ * @return array Reglas de validación.
+ */
+ abstract protected function dynamicRules(string $mode): array;
+
+ /**
+ * Devuelve las opciones que se mostrarán en los selectores del formulario.
+ *
+ * @return array Opciones para los campos del formulario.
+ */
+ abstract protected function options(): array;
+
+ /**
+ * Retorna la ruta de la vista asociada al formulario.
+ *
+ * @return string Ruta de la vista Blade.
+ */
+ abstract protected function viewPath(): string;
+
+ // ===================== VALIDACIONES =====================
+
+ protected function attributes(): array
+ {
+ return [];
+ }
+
+ protected function messages(): array
+ {
+ return [];
+ }
+
+ // ===================== INICIALIZACIÓN DEL COMPONENTE =====================
+
+ /**
+ * Se ejecuta cuando el componente se monta por primera vez.
+ *
+ * Inicializa propiedades y carga datos iniciales.
+ *
+ * @return void
+ */
+ public function mount(): void
+ {
+ $this->uniqueId = uniqid();
+
+ $model = new ($this->model());
+
+ $this->tagName = $model->tagName;
+ $this->columnNameLabel = $model->columnNameLabel;
+ $this->singularName = $model->singularName;
+ $this->offcanvasId = 'offcanvas' . ucfirst(Str::camel($model->tagName));
+ $this->formId = Str::camel($model->tagName) .'Form';
+ $this->focusOnOpen = "{$this->focusOnOpen()}_{$this->uniqueId}";
+
+ $this->loadDefaults();
+ $this->loadOptions();
+ }
+
+ // ===================== INICIALIZACIÓN Y CONFIGURACIÓN =====================
+
+ /**
+ * Devuelve los valores por defecto para los campos del formulario.
+ *
+ * @return array Valores por defecto.
+ */
+ private function loadDefaults(): void
+ {
+ $this->defaultValues = $this->defaults();
+ }
+
+ /**
+ * Carga las opciones necesarias para los campos del formulario.
+ *
+ * @return void
+ */
+ private function loadOptions(): void
+ {
+ foreach ($this->options() as $key => $value) {
+ $this->$key = $value;
+ }
+ }
+
+ /**
+ * Carga los datos de un modelo específico en el formulario para su edición.
+ *
+ * @param int $id ID del registro a editar.
+ * @return void
+ */
+ public function loadFormModel(int $id): void
+ {
+ if ($this->loadData($id)) {
+ $this->mode = 'edit';
+
+ $this->dispatch($this->getDispatche('refresh-offcanvas'));
+ }
+ }
+
+ /**
+ * Carga el modelo para confirmar su eliminación.
+ *
+ * @param int $id ID del registro a eliminar.
+ * @return void
+ */
+ public function loadFormModelForDeletion(int $id): void
+ {
+ if ($this->loadData($id)) {
+ $this->mode = 'delete';
+ $this->confirmDeletion = false;
+
+ $this->dispatch($this->getDispatche('refresh-offcanvas'));
+ }
+ }
+
+ private function getDispatche(string $name): string
+ {
+ $model = new ($this->model());
+
+ $dispatches = [
+ 'refresh-offcanvas' => 'refresh-' . Str::kebab($model->tagName) . '-offcanvas',
+ 'reload-table' => 'reload-bt-' . Str::kebab($model->tagName) . 's',
+ ];
+
+ return $dispatches[$name] ?? null;
+ }
+
+
+ /**
+ * Carga los datos del modelo según el ID proporcionado.
+ *
+ * @param int $id ID del modelo.
+ * @return bool True si los datos fueron cargados correctamente.
+ */
+ protected function loadData(int $id): bool
+ {
+ $model = $this->model()::find($id);
+
+ if ($model) {
+ $data = $model->only(['id', ...$this->fields()]);
+
+ $this->applyCasts($data);
+ $this->fill($data);
+
+
+ return true;
+ }
+
+ return false;
+ }
+
+ // ===================== OPERACIONES CRUD =====================
+
+ /**
+ * Método principal para enviar el formulario.
+ *
+ * @return void
+ */
+ public function onSubmit(): void
+ {
+ $this->successProcess = false;
+ $this->validationError = false;
+
+ if(!$this->mode)
+ $this->mode = 'create';
+
+ DB::beginTransaction(); // Iniciar transacción
+
+ try {
+ if($this->mode === 'delete'){
+ $this->delete();
+
+ }else{
+ $this->save();
+ }
+
+ DB::commit();
+
+ } catch (ValidationException $e) {
+ DB::rollBack();
+ $this->handleValidationException($e);
+
+ } catch (QueryException $e) {
+ DB::rollBack();
+ $this->handleDatabaseException($e);
+
+ } catch (ModelNotFoundException $e) {
+ DB::rollBack();
+ $this->handleException('danger', 'Registro no encontrado.');
+
+ } catch (Exception $e) {
+ DB::rollBack(); // Revertir la transacción si ocurre un error
+ $this->handleException('danger', 'Error al eliminar el registro: ' . $e->getMessage());
+ }
+ }
+
+ /**
+ * Guarda o actualiza un registro en la base de datos.
+ *
+ * @return void
+ * @throws ValidationException
+ */
+ protected function save(): void
+ {
+ // Valida incluyendo atributos personalizados
+ $validatedData = $this->validate(
+ $this->dynamicRules($this->mode),
+ $this->messages(),
+ $this->attributes()
+ );
+
+ $this->convertEmptyValuesToNull($validatedData);
+ $this->applyCasts($validatedData);
+
+ $this->beforeSave($validatedData);
+ $record = $this->model()::updateOrCreate(['id' => $this->id], $validatedData);
+ $this->afterSave($record);
+
+ $this->handleSuccess('success', ucfirst($this->singularName) . " guardado correctamente.");
+ }
+
+ /**
+ * Elimina un registro en la base de datos.
+ *
+ * @return void
+ */
+ protected function delete(): void
+ {
+ $this->validate($this->dynamicRules(
+ 'delete',
+ $this->messages(),
+ $this->attributes()
+ ));
+
+ $record = $this->model()::findOrFail($this->id);
+
+ $this->beforeDelete($record);
+ $record->delete();
+ $this->afterDelete($record);
+
+ $this->handleSuccess('warning', ucfirst($this->singularName) . " eliminado.");
+ }
+
+ // ===================== HOOKS DE ACCIONES CRUD =====================
+
+ /**
+ * Hook que se ejecuta antes de guardar datos en la base de datos.
+ *
+ * Este método permite realizar modificaciones o preparar los datos antes de ser validados
+ * y almacenados. Es útil para formatear datos, agregar valores calculados o realizar
+ * operaciones previas a la persistencia.
+ *
+ * @param array $data Datos validados que se almacenarán. Se pasan por referencia,
+ * por lo que cualquier cambio aquí afectará directamente los datos guardados.
+ *
+ * @return void
+ */
+ protected function beforeSave(array &$data): void {}
+
+ /**
+ * Hook que se ejecuta después de guardar o actualizar un registro en la base de datos.
+ *
+ * Ideal para ejecutar tareas posteriores al guardado, como enviar notificaciones,
+ * registrar auditorías o realizar acciones en otros modelos relacionados.
+ *
+ * @param \Illuminate\Database\Eloquent\Model $record El modelo que fue guardado, conteniendo
+ * los datos actualizados.
+ *
+ * @return void
+ */
+ protected function afterSave($record): void {}
+
+ /**
+ * Hook que se ejecuta antes de eliminar un registro de la base de datos.
+ *
+ * Permite validar si el registro puede ser eliminado o realizar tareas previas
+ * como desasociar relaciones, eliminar archivos relacionados o verificar restricciones.
+ *
+ * @param \Illuminate\Database\Eloquent\Model $record El modelo que está por ser eliminado.
+ *
+ * @return void
+ */
+ protected function beforeDelete($record): void {}
+
+ /**
+ * Hook que se ejecuta después de eliminar un registro de la base de datos.
+ *
+ * Útil para realizar acciones adicionales tras la eliminación, como limpiar datos relacionados,
+ * eliminar archivos vinculados o registrar eventos de auditoría.
+ *
+ * @param \Illuminate\Database\Eloquent\Model $record El modelo eliminado. Aunque ya no existe en la base de datos,
+ * se conserva la información del registro en memoria.
+ *
+ * @return void
+ */
+ protected function afterDelete($record): void {}
+
+ // ===================== MANEJO DE VALIDACIONES Y EXCEPCIONES =====================
+
+ /**
+ * Maneja las excepciones de validación.
+ *
+ * Este método captura los errores de validación, los agrega al error bag de Livewire
+ * y dispara un evento para manejar el fallo de validación, útil en formularios modales.
+ *
+ * @param ValidationException $e Excepción de validación capturada.
+ * @return void
+ */
+ protected function handleValidationException(ValidationException $e): void
+ {
+ $this->setErrorBag($e->validator->errors());
+
+ // Notifica al usuario que ocurrió un error de validación
+ $this->handleException('danger', 'Error en la validación de los datos.');
+ }
+
+ /**
+ * Maneja las excepciones relacionadas con la base de datos.
+ *
+ * Analiza el código de error de la base de datos y genera un mensaje de error específico
+ * para la situación. También se encarga de enviar una notificación de error.
+ *
+ * @param QueryException $e Excepción capturada durante la ejecución de una consulta.
+ * @return void
+ */
+ protected function handleDatabaseException(QueryException $e): void
+ {
+ $errorMessage = match ($e->errorInfo[1]) {
+ 1452 => "Una clave foránea no es válida.",
+ 1062 => $this->extractDuplicateField($e->getMessage()),
+ 1451 => "No se puede eliminar el registro porque está en uso.",
+ default => env('APP_DEBUG') ? $e->getMessage() : "Error inesperado en la base de datos.",
+ };
+
+ $this->handleException('danger', $errorMessage, 'form', 120000);
+ }
+
+ /**
+ * Maneja cualquier tipo de excepción general y envía una notificación al usuario.
+ *
+ * @param string $type El tipo de notificación (success, danger, warning).
+ * @param string $message El mensaje que se mostrará al usuario.
+ * @param string $target El contenedor donde se mostrará la notificación (por defecto 'form').
+ * @param int $delay Tiempo en milisegundos que durará la notificación en pantalla.
+ * @return void
+ */
+ protected function handleException($type, $message, $target = 'form', $delay = 9000): void
+ {
+ $this->validationError = true;
+
+ $this->dispatch($this->getDispatche('refresh-offcanvas'));
+ $this->dispatchNotification($type, $message, $target, $delay);
+ }
+
+ /**
+ * Extrae el nombre del campo duplicado de un error de base de datos MySQL.
+ *
+ * Esta función se utiliza para identificar el campo específico que causó un error
+ * de duplicación de clave única, y genera un mensaje personalizado para el usuario.
+ *
+ * @param string $errorMessage El mensaje de error completo proporcionado por MySQL.
+ * @return string Mensaje de error amigable para el usuario.
+ */
+ private function extractDuplicateField($errorMessage): string
+ {
+ preg_match("/for key 'unique_(.*?)'/", $errorMessage, $matches);
+
+ return isset($matches[1])
+ ? "El valor ingresado para '" . str_replace('_', ' ', $matches[1]) . "' ya está en uso."
+ : "Ya existe un registro con este valor.";
+ }
+
+ // ===================== NOTIFICACIONES Y ÉXITO =====================
+
+ /**
+ * Despacha una notificación tras el éxito de una operación.
+ *
+ * @param string $type Tipo de notificación (success, warning, danger)
+ * @param string $message Mensaje a mostrar.
+ * @return void
+ */
+ protected function handleSuccess(string $type, string $message): void
+ {
+ $this->successProcess = true;
+
+ $this->dispatch($this->getDispatche('refresh-offcanvas'));
+ $this->dispatch($this->getDispatche('reload-table'));
+
+ $this->dispatchNotification($type, $message, 'index');
+ }
+
+ /**
+ * Envía una notificación al navegador.
+ *
+ * @param string $type Tipo de notificación (success, danger, etc.)
+ * @param string $message Mensaje de la notificación
+ * @param string $target Destino (form, index)
+ * @param int $delay Duración de la notificación en milisegundos
+ */
+ protected function dispatchNotification($type, $message, $target = 'form', $delay = 9000): void
+ {
+ $model = new ($this->model());
+
+ $this->tagName = $model->tagName;
+ $this->columnNameLabel = $model->columnNameLabel;
+ $this->singularName = $model->singularName;
+
+ $tagOffcanvas = ucfirst(Str::camel($model->tagName));
+
+ $targetNotifies = [
+ "index" => '#bt-' . Str::kebab($model->tagName) . 's .notification-container',
+ "form" => "#offcanvas{$tagOffcanvas} .notification-container",
+ ];
+
+ $this->dispatch(
+ 'notification',
+ target: $target === 'index' ? $targetNotifies['index'] : $targetNotifies['form'],
+ type: $type,
+ message: $message,
+ delay: $delay
+ );
+ }
+
+ // ===================== FORMULARIO Y CONVERSIÓN DE DATOS =====================
+
+ /**
+ * Convierte los valores vacíos a `null` en los campos que son configurados como `nullable`.
+ *
+ * Esta función verifica las reglas de validación actuales y transforma todos los campos vacíos
+ * en valores `null` si las reglas permiten valores nulos. Es útil para evitar insertar cadenas vacías
+ * en la base de datos donde se espera un valor nulo.
+ *
+ * @param array $data Los datos del formulario que se deben procesar.
+ * @return void
+ */
+ protected function convertEmptyValuesToNull(array &$data): void
+ {
+ $nullableFields = array_keys(array_filter($this->dynamicRules($this->mode), function ($rules) {
+ return in_array('nullable', (array) $rules);
+ }));
+
+ foreach ($nullableFields as $field) {
+ if (isset($data[$field]) && $data[$field] === '') {
+ $data[$field] = null;
+ }
+ }
+ }
+
+ /**
+ * Aplica tipos de datos definidos en `$casts` a los campos del formulario.
+ *
+ * Esta función toma los datos de entrada y los transforma en el tipo de datos esperado según
+ * lo definido en la propiedad `$casts`. Es útil para asegurar que los datos se almacenen en
+ * el formato correcto, como convertir cadenas a números enteros o booleanos.
+ *
+ * @param array $data Los datos del formulario que necesitan ser casteados.
+ * @return void
+ */
+ protected function applyCasts(array &$data): void
+ {
+ foreach ($this->casts as $field => $type) {
+ if (array_key_exists($field, $data)) {
+ $data[$field] = $this->castValue($type, $data[$field]);
+ }
+ }
+ }
+
+ /**
+ * Castea un valor a su tipo de dato correspondiente.
+ *
+ * Convierte un valor dado al tipo especificado, manejando adecuadamente los valores vacíos
+ * o nulos. También asegura que valores como `0` o `''` sean tratados correctamente
+ * para evitar errores al almacenarlos en la base de datos.
+ *
+ * @param string $type El tipo de dato al que se debe convertir (`boolean`, `integer`, `float`, `string`, `array`).
+ * @param mixed $value El valor que se debe castear.
+ * @return mixed El valor convertido al tipo especificado.
+ */
+ protected function castValue($type, $value): mixed
+ {
+ // Convertir valores vacíos o cero a null si corresponde
+ if (is_null($value) || $value === '' || $value === '0' || $value === 0.0) {
+ return match ($type) {
+ 'boolean' => false, // No permitir null en booleanos
+ 'integer' => 0, // Valor por defecto para enteros
+ 'float', 'double' => 0.0, // Valor por defecto para decimales
+ 'string' => "", // Convertir cadena vacía en null
+ 'array' => [], // Evitar null en arrays
+ default => null, // Valor por defecto para otros tipos
+ };
+ }
+
+ // Castear el valor si no es null ni vacío
+ return match ($type) {
+ 'boolean' => (bool) $value,
+ 'integer' => (int) $value,
+ 'float', 'double' => (float) $value,
+ 'string' => (string) $value,
+ 'array' => (array) $value,
+ default => $value,
+ };
+ }
+
+
+ // ===================== RENDERIZACIÓN DE VISTA =====================
+
+ /**
+ * Renderiza la vista del formulario.
+ *
+ * @return \Illuminate\View\View
+ */
+ public function render(): View
+ {
+ return view($this->viewPath());
+ }
+}
diff --git a/Livewire/Permissions/PermissionIndex.php b/Livewire/Permissions/PermissionIndex.php
new file mode 100644
index 0000000..2aa71da
--- /dev/null
+++ b/Livewire/Permissions/PermissionIndex.php
@@ -0,0 +1,28 @@
+roles_html_select = "";
+
+ return view('vuexy-admin::livewire.permissions.index');
+ }
+}
diff --git a/Livewire/Permissions/Permissions.php b/Livewire/Permissions/Permissions.php
new file mode 100644
index 0000000..661bc7f
--- /dev/null
+++ b/Livewire/Permissions/Permissions.php
@@ -0,0 +1,35 @@
+validate([
+ 'permissionName' => 'required|unique:permissions,name'
+ ]);
+
+ Permission::create(['name' => $this->permissionName]);
+ session()->flash('message', 'Permiso creado con éxito.');
+ $this->reset('permissionName');
+ }
+
+ public function deletePermission($id)
+ {
+ Permission::find($id)->delete();
+ session()->flash('message', 'Permiso eliminado.');
+ }
+
+ public function render()
+ {
+ return view('livewire.permissions', [
+ 'permissions' => Permission::all()
+ ]);
+ }
+}
diff --git a/Livewire/Roles/RoleCards.php b/Livewire/Roles/RoleCards.php
new file mode 100644
index 0000000..613bf10
--- /dev/null
+++ b/Livewire/Roles/RoleCards.php
@@ -0,0 +1,182 @@
+loadRolesAndPermissions();
+ $this->dispatch('reloadForm');
+ }
+
+ private function loadRolesAndPermissions()
+ {
+ $this->roles = Auth::user()->hasRole('SuperAdmin') ?
+ Role::all() :
+ Role::where('name', '!=', 'SuperAdmin')->get();
+
+ // Obtener todos los permisos
+ $permissions = Permission::all()->map(function ($permission) {
+ $name = $permission->name;
+ $action = substr($name, strrpos($name, '.') + 1);
+
+ return [
+ 'group_name' => $permission->group_name,
+ 'sub_group_name' => $permission->sub_group_name,
+ $action => $name // Agregar la acción directamente al array
+ ];
+ })->groupBy('group_name'); // Agrupar los permisos por grupo
+
+
+ // Procesar los permisos agrupados para cargarlos en el componente
+ $permissionsInputs = [];
+
+ $this->permissions = $permissions->map(function ($groupPermissions) use (&$permissionsInputs) {
+ $permission = [
+ 'group_name' => $groupPermissions[0]['group_name'], // Tomar el grupo del primer permiso del grupo
+ 'sub_group_name' => $groupPermissions[0]['sub_group_name'], // Tomar la descripción del primer permiso del grupo
+ ];
+
+ // Agregar todas las acciones al permissionsInputs y al permission
+ foreach ($groupPermissions as $permissionData) {
+ foreach ($permissionData as $key => $value) {
+ if ($key !== 'sub_group_name' && $key !== 'group_name') {
+ $permissionsInputs[str_replace('.', '_', $value)] = false;
+ $permission[$key] = $value;
+ }
+ }
+ }
+
+ return $permission;
+ });
+
+ $this->permissionsInputs = $permissionsInputs;
+ }
+
+ public function loadRoleData($action, $roleId = false)
+ {
+ $this->resetForm();
+
+ $this->title = 'Agregar un nuevo rol';
+ $this->btn_submit_text = 'Crear nuevo rol';
+
+ if ($roleId) {
+ $role = Role::findOrFail($roleId);
+
+ switch ($action) {
+ case 'view':
+ $this->title = $role->name;
+ $this->name = $role->name;
+ $this->style = $role->style;
+ $this->dispatch('deshabilitarFormulario');
+ break;
+
+ case 'update':
+ $this->title = 'Editar rol';
+ $this->btn_submit_text = 'Guardar cambios';
+ $this->roleId = $roleId;
+ $this->name = $role->name;
+ $this->style = $role->style;
+ $this->dispatch('habilitarFormulario');
+ break;
+
+ case 'clone':
+ $this->style = $role->style;
+ $this->dispatch('habilitarFormulario');
+ break;
+
+ default:
+ break;
+ }
+
+ foreach ($role->permissions as $permission) {
+ $this->permissionsInputs[str_replace('.', '_', $permission->name)] = true;
+ }
+ }
+
+ $this->dispatch('reloadForm');
+ }
+
+ public function loadDestroyRoleData() {}
+
+ public function saveRole()
+ {
+ $permissions = [];
+
+ foreach ($this->permissionsInputs as $permission => $value) {
+ if ($value === true)
+ $permissions[] = str_replace('_', '.', $permission);
+ }
+
+ if ($this->roleId) {
+ $role = Role::find($this->roleId);
+
+ $role->name = $this->name;
+ $role->style = $this->style;
+
+ $role->save();
+
+ $role->syncPermissions($permissions);
+ } else {
+ $role = Role::create([
+ 'name' => $this->name,
+ 'style' => $this->style,
+ ]);
+
+ $role->syncPermissions($permissions);
+ }
+
+ $this->loadRolesAndPermissions();
+
+ $this->dispatch('modalHide');
+ $this->dispatch('reloadForm');
+ }
+
+ public function deleteRole()
+ {
+ $role = Role::find($this->destroyRoleId);
+
+ if ($role)
+ $role->delete();
+
+ $this->loadRolesAndPermissions();
+
+ $this->dispatch('modalDeleteHide');
+ $this->dispatch('reloadForm');
+ }
+
+ private function resetForm()
+ {
+ $this->roleId = '';
+ $this->name = '';
+ $this->style = '';
+
+ foreach ($this->permissionsInputs as $key => $permission) {
+ $this->permissionsInputs[$key] = false;
+ }
+ }
+
+ public function render()
+ {
+ return view('vuexy-admin::livewire.roles.cards');
+ }
+}
diff --git a/Livewire/Roles/RoleIndex.php b/Livewire/Roles/RoleIndex.php
new file mode 100644
index 0000000..10a168d
--- /dev/null
+++ b/Livewire/Roles/RoleIndex.php
@@ -0,0 +1,61 @@
+availablePermissions = Permission::all();
+ }
+
+ public function createRole()
+ {
+ $this->validate([
+ 'roleName' => 'required|unique:roles,name'
+ ]);
+
+ $role = Role::create(['name' => $this->roleName]);
+ $this->reset(['roleName']);
+ session()->flash('message', 'Rol creado con éxito.');
+ }
+
+ public function selectRole($roleId)
+ {
+ $this->selectedRole = Role::find($roleId);
+ $this->permissions = $this->selectedRole->permissions->pluck('id')->toArray();
+ }
+
+ public function updateRolePermissions()
+ {
+ if ($this->selectedRole) {
+ $this->selectedRole->syncPermissions($this->permissions);
+ session()->flash('message', 'Permisos actualizados correctamente.');
+ }
+ }
+
+ public function deleteRole($roleId)
+ {
+ Role::find($roleId)->delete();
+ session()->flash('message', 'Rol eliminado.');
+ }
+
+ public function render()
+ {
+ return view('livewire.roles', [
+ 'index' => Role::paginate(10)
+ ]);
+ }
+}
diff --git a/Livewire/Table/AbstractIndexComponent.php b/Livewire/Table/AbstractIndexComponent.php
new file mode 100644
index 0000000..93bf75f
--- /dev/null
+++ b/Livewire/Table/AbstractIndexComponent.php
@@ -0,0 +1,174 @@
+ 'id', // Campo por defecto para ordenar
+ 'exportFileName' => 'Listado', // Nombre de archivo para exportar
+ 'showFullscreen' => false,
+ 'showPaginationSwitch'=> false,
+ 'showRefresh' => false,
+ 'pagination' => false,
+ // Agrega aquí cualquier otra configuración por defecto que uses
+ ];
+ }
+
+ /**
+ * Se ejecuta al montar el componente Livewire.
+ * Configura $tagName, $singularName, $formId y $bt_datatable.
+ *
+ * @return void
+ */
+ public function mount(): void
+ {
+ // Obtenemos el modelo
+ $model = $this->model();
+ if (is_string($model)) {
+ // Si se retornó la clase en abstract protected function model(),
+ // instanciamos manualmente
+ $model = new $model;
+ }
+
+ // Usamos las propiedades definidas en el modelo
+ // (tagName, singularName, etc.), si existen en el modelo.
+ // Ajusta nombres según tu convención.
+ $this->tagName = $model->tagName ?? Str::snake(class_basename($model));
+ $this->singularName = $model->singularName ?? class_basename($model);
+ $this->formId = Str::kebab($this->tagName) . '-form';
+
+ // Inicia la configuración principal de la tabla
+ $this->setupDataTable();
+ }
+
+ /**
+ * Combina la configuración base de la tabla con las columnas y formatos
+ * definidos en las clases hijas.
+ *
+ * @return void
+ */
+ protected function setupDataTable(): void
+ {
+ $baseConfig = $this->bootstraptableConfig();
+
+ $this->bt_datatable = array_merge($baseConfig, [
+ 'header' => $this->columns(),
+ 'format' => $this->format(),
+ ]);
+ }
+
+ /**
+ * Renderiza la vista definida en viewPath().
+ *
+ * @return \Illuminate\View\View
+ */
+ public function render()
+ {
+ return view($this->viewPath());
+ }
+
+ /**
+ * Ejemplo de método para la lógica de filtrado que podrías sobreescribir en la clase hija.
+ *
+ * @param array $criteria
+ * @return \Illuminate\Database\Eloquent\Builder
+ */
+ protected function applyFilters($criteria = [])
+ {
+ // Aplica tu lógica de filtros, búsquedas, etc.
+ // La clase hija podría sobrescribir este método o llamarlo desde su propia lógica.
+ $query = $this->model()::query();
+
+ // Por ejemplo:
+ /*
+ if (!empty($criteria['store_id'])) {
+ $query->where('store_id', $criteria['store_id']);
+ }
+ */
+
+ return $query;
+ }
+}
diff --git a/Livewire/Users/UserCount.php b/Livewire/Users/UserCount.php
new file mode 100644
index 0000000..0e3eb6b
--- /dev/null
+++ b/Livewire/Users/UserCount.php
@@ -0,0 +1,31 @@
+ 'updateCounts'];
+
+ public function mount()
+ {
+ $this->updateCounts();
+ }
+
+ public function updateCounts()
+ {
+ $this->total = User::count();
+ $this->enabled = User::where('status', User::STATUS_ENABLED)->count();
+ $this->disabled = User::where('status', User::STATUS_DISABLED)->count();
+ }
+
+ public function render()
+ {
+ return view('vuexy-admin::livewire.users.count');
+ }
+}
diff --git a/Livewire/Users/UserForm.php b/Livewire/Users/UserForm.php
new file mode 100644
index 0000000..91f9ca1
--- /dev/null
+++ b/Livewire/Users/UserForm.php
@@ -0,0 +1,306 @@
+id ?? null);
+ }
+
+ /**
+ * Cargar opciones de formularios según el modo actual.
+ *
+ * @param string $mode
+ */
+ private function loadOptions(string $mode): void
+ {
+ $this->manager_id_options = User::getUsersListWithInactive($this->manager_id, ['type' => 'user', 'status' => 1]);
+ $this->c_regimen_fiscal_options = RegimenFiscal::selectList();
+ $this->c_pais_options = Pais::selectList();
+ $this->c_estado_options = Estado::selectList($this->c_pais)->toArray();
+
+ if ($mode !== 'create') {
+ $this->c_localidad_options = Localidad::selectList($this->c_estado)->toArray();
+ $this->c_municipio_options = Municipio::selectList($this->c_estado, $this->c_municipio)->toArray();
+ $this->c_colonia_options = Colonia::selectList($this->c_codigo_postal, $this->c_colonia)->toArray();
+ }
+ }
+
+ // ===================== MÉTODOS OBLIGATORIOS =====================
+
+ /**
+ * Devuelve el modelo Eloquent asociado.
+ *
+ * @return string
+ */
+ protected function model(): string
+ {
+ return Store::class;
+ }
+
+ /**
+ * Reglas de validación dinámicas según el modo actual.
+ *
+ * @param string $mode
+ * @return array
+ */
+ protected function dynamicRules(string $mode): array
+ {
+ switch ($mode) {
+ case 'create':
+ case 'edit':
+ return [
+ 'code' => [
+ 'required', 'string', 'alpha_num', 'max:16',
+ Rule::unique('stores', 'code')->ignore($this->id)
+ ],
+ 'name' => 'required|string|max:96',
+ 'description' => 'nullable|string|max:1024',
+ 'manager_id' => 'nullable|exists:users,id',
+
+ // Información fiscal
+ 'rfc' => ['nullable', 'string', 'regex:/^([A-ZÑ&]{3,4})(\d{6})([A-Z\d]{3})$/i', 'max:13'],
+ 'nombre_fiscal' => 'nullable|string|max:255',
+ 'c_regimen_fiscal' => 'nullable|exists:sat_regimen_fiscal,c_regimen_fiscal',
+ 'domicilio_fiscal' => 'nullable|exists:sat_codigo_postal,c_codigo_postal',
+
+ // Ubicación
+ 'c_pais' => 'nullable|exists:sat_pais,c_pais|string|size:3',
+ 'c_estado' => 'nullable|exists:sat_estado,c_estado|string|min:2|max:3',
+ 'c_municipio' => 'nullable|exists:sat_municipio,c_municipio|integer',
+ 'c_localidad' => 'nullable|integer',
+ 'c_codigo_postal' => 'nullable|exists:sat_codigo_postal,c_codigo_postal|integer',
+ 'c_colonia' => 'nullable|exists:sat_colonia,c_colonia|integer',
+ 'direccion' => 'nullable|string|max:255',
+ 'num_ext' => 'nullable|string|max:50',
+ 'num_int' => 'nullable|string|max:50',
+ 'lat' => 'nullable|numeric|between:-90,90',
+ 'lng' => 'nullable|numeric|between:-180,180',
+
+ // Contacto
+ 'email' => ['nullable', 'email', 'required_if:enable_ecommerce,true'],
+ 'tel' => ['nullable', 'regex:/^[0-9\s\-\+\(\)]+$/', 'max:15'],
+ 'tel2' => ['nullable', 'regex:/^[0-9\s\-\+\(\)]+$/', 'max:15'],
+
+ // Configuración web y estado
+ 'show_on_website' => 'nullable|boolean',
+ 'enable_ecommerce' => 'nullable|boolean',
+ 'status' => 'nullable|boolean',
+ ];
+
+ case 'delete':
+ return [
+ 'confirmDeletion' => 'accepted', // Asegura que el usuario confirme la eliminación
+ ];
+
+ default:
+ return [];
+ }
+ }
+
+ /**
+ * Inicializa los datos del formulario en función del modo.
+ *
+ * @param Store|null $store
+ * @param string $mode
+ */
+ protected function initializeFormData(mixed $store, string $mode): void
+ {
+ if ($store) {
+ $this->code = $store->code;
+ $this->name = $store->name;
+ $this->description = $store->description;
+ $this->manager_id = $store->manager_id;
+ $this->rfc = $store->rfc;
+ $this->nombre_fiscal = $store->nombre_fiscal;
+ $this->c_regimen_fiscal = $store->c_regimen_fiscal;
+ $this->domicilio_fiscal = $store->domicilio_fiscal;
+ $this->c_pais = $store->c_pais;
+ $this->c_estado = $store->c_estado;
+ $this->c_municipio = $store->c_municipio;
+ $this->c_localidad = $store->c_localidad;
+ $this->c_codigo_postal = $store->c_codigo_postal;
+ $this->c_colonia = $store->c_colonia;
+ $this->direccion = $store->direccion;
+ $this->num_ext = $store->num_ext;
+ $this->num_int = $store->num_int;
+ $this->lat = $store->lat;
+ $this->lng = $store->lng;
+ $this->email = $store->email;
+ $this->tel = $store->tel;
+ $this->tel2 = $store->tel2;
+ $this->show_on_website = (bool) $store->show_on_website;
+ $this->enable_ecommerce = (bool) $store->enable_ecommerce;
+ $this->status = (bool) $store->status;
+
+ } else {
+ $this->c_pais = 'MEX';
+ $this->status = true;
+ $this->show_on_website = false;
+ $this->enable_ecommerce = false;
+ }
+
+ $this->loadOptions($mode);
+ }
+
+ /**
+ * Prepara los datos validados para su almacenamiento.
+ *
+ * @param array $validatedData
+ * @return array
+ */
+ protected function prepareData(array $validatedData): array
+ {
+ return [
+ 'code' => $validatedData['code'],
+ 'name' => $validatedData['name'],
+ 'description' => strip_tags($validatedData['description']),
+ 'manager_id' => $validatedData['manager_id'],
+ 'rfc' => $validatedData['rfc'],
+ 'nombre_fiscal' => $validatedData['nombre_fiscal'],
+ 'c_regimen_fiscal' => $validatedData['c_regimen_fiscal'],
+ 'domicilio_fiscal' => $validatedData['domicilio_fiscal'],
+ 'c_codigo_postal' => $validatedData['c_codigo_postal'],
+ 'c_pais' => $validatedData['c_pais'],
+ 'c_estado' => $validatedData['c_estado'],
+ 'c_localidad' => $validatedData['c_localidad'],
+ 'c_municipio' => $validatedData['c_municipio'],
+ 'c_colonia' => $validatedData['c_colonia'],
+ 'direccion' => $validatedData['direccion'],
+ 'num_ext' => $validatedData['num_ext'],
+ 'num_int' => $validatedData['num_int'],
+ 'email' => $validatedData['email'],
+ 'tel' => $validatedData['tel'],
+ 'tel2' => $validatedData['tel2'],
+ 'lat' => $validatedData['lat'],
+ 'lng' => $validatedData['lng'],
+ 'status' => $validatedData['status'],
+ 'show_on_website' => $validatedData['show_on_website'],
+ 'enable_ecommerce' => $validatedData['enable_ecommerce'],
+ ];
+ }
+
+ /**
+ * Definición de los contenedores de notificación.
+ *
+ * @return array
+ */
+ protected function targetNotifies(): array
+ {
+ return [
+ "index" => "#bt-stores .notification-container",
+ "form" => "#store-form .notification-container",
+ ];
+ }
+
+ /**
+ * Ruta de vista asociada al formulario.
+ *
+ * @return \Illuminate\Contracts\View\View
+ */
+ protected function viewPath(): string
+ {
+ return 'vuexy-store-manager::livewire.stores.form';
+ }
+
+ // ===================== VALIDACIONES =====================
+
+ /**
+ * Get custom attributes for validator errors.
+ *
+ * @return array
+ */
+ public function attributes(): array
+ {
+ return [
+ 'code' => 'código de sucursal',
+ 'name' => 'nombre de la sucursal',
+ ];
+ }
+
+ /**
+ * Get the error messages for the defined validation rules.
+ *
+ * @return array
+ */
+ public function messages(): array
+ {
+ return [
+ 'code.required' => 'El código de la sucursal es obligatorio.',
+ 'code.unique' => 'Este código ya está en uso por otra sucursal.',
+ 'name.required' => 'El nombre de la sucursal es obligatorio.',
+ ];
+ }
+
+ // ===================== PREPARACIÓN DE DATOS =====================
+
+ // ===================== NOTIFICACIONES Y EVENTOS =====================
+
+ /**
+ * Definición de los eventos del componente.
+ *
+ * @return array
+ */
+ protected function dispatches(): array
+ {
+ return [
+ 'on-failed-validation' => 'on-failed-validation-store',
+ 'on-hydrate' => 'on-hydrate-store-modal',
+ ];
+ }
+
+ // ===================== REDIRECCIÓN =====================
+
+ /**
+ * Define la ruta de redirección tras guardar o eliminar.
+ *
+ * @return string
+ */
+ protected function getRedirectRoute(): string
+ {
+ return 'admin.core.user.index';
+ }
+
+}
diff --git a/Livewire/Users/UserIndex.copy.php b/Livewire/Users/UserIndex.copy.php
new file mode 100644
index 0000000..9eb2963
--- /dev/null
+++ b/Livewire/Users/UserIndex.copy.php
@@ -0,0 +1,115 @@
+modalTitle = 'Crear usuario nuevo';
+ $this->btnSubmitTxt = 'Crear usuario';
+
+ $this->statuses = [
+ User::STATUS_ENABLED => ['title' => 'Activo', 'class' => 'badge bg-label-' . User::$statusListClass[User::STATUS_ENABLED]],
+ User::STATUS_DISABLED => ['title' => 'Deshabilitado', 'class' => 'badge bg-label-' . User::$statusListClass[User::STATUS_DISABLED]],
+ User::STATUS_REMOVED => ['title' => 'Eliminado', 'class' => 'badge bg-label-' . User::$statusListClass[User::STATUS_REMOVED]],
+ ];
+
+ $roles = Role::whereNotIn('name', ['Patient', 'Doctor'])->get();
+
+ $this->roles_html_select = "";
+
+ $this->status_options = [
+ User::STATUS_ENABLED => User::$statusList[User::STATUS_ENABLED],
+ User::STATUS_DISABLED => User::$statusList[User::STATUS_DISABLED],
+ ];
+ }
+
+ public function countUsers()
+ {
+ $this->total = User::count();
+ $this->enabled = User::where('status', User::STATUS_ENABLED)->count();
+ $this->disabled = User::where('status', User::STATUS_DISABLED)->count();
+ }
+
+
+ public function edit($id)
+ {
+ $user = User::findOrFail($id);
+
+ $this->indexAlert = '';
+ $this->modalTitle = 'Editar usuario: ' . $id;
+ $this->btnSubmitTxt = 'Guardar cambios';
+
+ $this->userId = $user->id;
+ $this->name = $user->name;
+ $this->email = $user->email;
+ $this->password = '';
+ $this->roles = $user->roles->pluck('name')->toArray();
+ $this->src_photo = $user->profile_photo_url;
+ $this->status = $user->status;
+
+ $this->dispatch('openModal');
+ }
+
+ public function delete($id)
+ {
+ $user = User::find($id);
+
+ if ($user) {
+ // Eliminar la imagen de perfil si existe
+ if ($user->profile_photo_path)
+ Storage::disk('public')->delete($user->profile_photo_path);
+
+ // Eliminar el usuario
+ $user->delete();
+
+ $this->indexAlert = 'Se eliminó correctamente el usuario.
';
+
+ $this->dispatch('refreshUserCount');
+ $this->dispatch('afterDelete');
+ } else {
+ $this->indexAlert = 'Usuario no encontrado.
';
+ }
+ }
+
+ public function render()
+ {
+ return view('vuexy-admin::livewire.users.index', [
+ 'users' => User::paginate(10),
+ ]);
+ }
+}
diff --git a/Livewire/Users/UserIndex.php b/Livewire/Users/UserIndex.php
new file mode 100644
index 0000000..49e0e3f
--- /dev/null
+++ b/Livewire/Users/UserIndex.php
@@ -0,0 +1,299 @@
+ 'Acciones',
+ 'code' => 'Código personal',
+ 'full_name' => 'Nombre Completo',
+ 'email' => 'Correo Electrónico',
+ 'parent_name' => 'Responsable',
+ 'parent_email' => 'Correo Responsable',
+ 'company' => 'Empresa',
+ 'birth_date' => 'Fecha de Nacimiento',
+ 'hire_date' => 'Fecha de Contratación',
+ 'curp' => 'CURP',
+ 'nss' => 'NSS',
+ 'job_title' => 'Puesto',
+ 'rfc' => 'RFC',
+ 'nombre_fiscal' => 'Nombre Fiscal',
+ 'profile_photo_path' => 'Foto de Perfil',
+ 'is_partner' => 'Socio',
+ 'is_employee' => 'Empleado',
+ 'is_prospect' => 'Prospecto',
+ 'is_customer' => 'Cliente',
+ 'is_provider' => 'Proveedor',
+ 'is_user' => 'Usuario',
+ 'status' => 'Estatus',
+ 'creator' => 'Creado Por',
+ 'creator_email' => 'Correo Creador',
+ 'created_at' => 'Fecha de Creación',
+ 'updated_at' => 'Última Modificación',
+ ];
+ }
+
+ /**
+ * Retorna el formato (formatter) para cada columna.
+ */
+ protected function format(): array
+ {
+ return [
+ 'action' => [
+ 'formatter' => 'userActionFormatter',
+ 'onlyFormatter' => true,
+ ],
+ 'code' => [
+ 'formatter' => [
+ 'name' => 'dynamicBadgeFormatter',
+ 'params' => ['color' => 'secondary'],
+ ],
+ 'align' => 'center',
+ 'switchable' => false,
+ ],
+ 'full_name' => [
+ 'formatter' => 'userProfileFormatter',
+ ],
+ 'email' => [
+ 'formatter' => 'emailFormatter',
+ 'visible' => false,
+ ],
+ 'parent_name' => [
+ 'formatter' => 'contactParentFormatter',
+ 'visible' => false,
+ ],
+ 'agent_name' => [
+ 'formatter' => 'agentFormatter',
+ 'visible' => false,
+ ],
+ 'company' => [
+ 'formatter' => 'textNowrapFormatter',
+ ],
+ 'curp' => [
+ 'visible' => false,
+ ],
+ 'nss' => [
+ 'visible' => false,
+ ],
+ 'job_title' => [
+ 'formatter' => 'textNowrapFormatter',
+ 'visible' => false,
+ ],
+ 'rfc' => [
+ 'visible' => false,
+ ],
+ 'nombre_fiscal' => [
+ 'formatter' => 'textNowrapFormatter',
+ 'visible' => false,
+ ],
+ 'domicilio_fiscal' => [
+ 'visible' => false,
+ ],
+ 'c_uso_cfdi' => [
+ 'formatter' => 'usoCfdiFormatter',
+ 'visible' => false,
+ ],
+ 'tipo_persona' => [
+ 'formatter' => 'dynamicBadgeFormatter',
+ 'align' => 'center',
+ 'visible' => false,
+ ],
+ 'c_regimen_fiscal' => [
+ 'formatter' => 'regimenFiscalFormatter',
+ 'visible' => false,
+ ],
+ 'birth_date' => [
+ 'align' => 'center',
+ 'visible' => false,
+ ],
+ 'hire_date' => [
+ 'align' => 'center',
+ 'visible' => false,
+ ],
+ 'estado' => [
+ 'formatter' => 'textNowrapFormatter',
+ ],
+ 'municipio' => [
+ 'formatter' => 'textNowrapFormatter',
+ ],
+ 'localidad' => [
+ 'formatter' => 'textNowrapFormatter',
+ 'visible' => false,
+ ],
+ 'is_partner' => [
+ 'formatter' => [
+ 'name' => 'dynamicBooleanFormatter',
+ 'params' => ['tag' => 'checkSI'],
+ ],
+ 'align' => 'center',
+ ],
+ 'is_employee' => [
+ 'formatter' => [
+ 'name' => 'dynamicBooleanFormatter',
+ 'params' => ['tag' => 'checkSI'],
+ ],
+ 'align' => 'center',
+ ],
+ 'is_prospect' => [
+ 'formatter' => [
+ 'name' => 'dynamicBooleanFormatter',
+ 'params' => ['tag' => 'checkSI'],
+ ],
+ 'align' => 'center',
+ ],
+ 'is_customer' => [
+ 'formatter' => [
+ 'name' => 'dynamicBooleanFormatter',
+ 'params' => ['tag' => 'checkSI'],
+ ],
+ 'align' => 'center',
+ ],
+ 'is_provider' => [
+ 'formatter' => [
+ 'name' => 'dynamicBooleanFormatter',
+ 'params' => ['tag' => 'checkSI'],
+ ],
+ 'align' => 'center',
+ ],
+ 'is_user' => [
+ 'formatter' => [
+ 'name' => 'dynamicBooleanFormatter',
+ 'params' => ['tag' => 'checkSI'],
+ ],
+ 'align' => 'center',
+ ],
+ 'status' => [
+ 'formatter' => 'statusIntBadgeBgFormatter',
+ 'align' => 'center',
+ ],
+ 'creator' => [
+ 'formatter' => 'creatorFormatter',
+ 'visible' => false,
+ ],
+ 'created_at' => [
+ 'formatter' => 'textNowrapFormatter',
+ 'align' => 'center',
+ 'visible' => false,
+ ],
+ 'updated_at' => [
+ 'formatter' => 'textNowrapFormatter',
+ 'align' => 'center',
+ 'visible' => false,
+ ],
+ ];
+ }
+
+ /**
+ * Procesa el documento recibido (CFDI XML o Constancia PDF).
+ */
+ public function processDocument()
+ {
+ // Verificamos si el archivo es válido
+ if (!$this->doc_file instanceof UploadedFile) {
+ return $this->addError('doc_file', 'No se pudo recibir el archivo.');
+ }
+
+ try {
+ // Validar tipo de archivo
+ $this->validate([
+ 'doc_file' => 'required|mimes:pdf,xml|max:2048'
+ ]);
+
+
+ // **Detectar el tipo de documento**
+ $extension = strtolower($this->doc_file->getClientOriginalExtension());
+
+ // **Procesar según el tipo de archivo**
+ switch ($extension) {
+ case 'xml':
+ $service = new FacturaXmlService();
+ $data = $service->processUploadedFile($this->doc_file);
+ break;
+
+ case 'pdf':
+ $service = new ConstanciaFiscalService();
+ $data = $service->extractData($this->doc_file);
+ break;
+
+ default:
+ throw new Exception("Formato de archivo no soportado.");
+ }
+
+ dd($data);
+
+ // **Asignar los valores extraídos al formulario**
+ $this->rfc = $data['rfc'] ?? null;
+ $this->name = $data['name'] ?? null;
+ $this->email = $data['email'] ?? null;
+ $this->tel = $data['telefono'] ?? null;
+ //$this->direccion = $data['domicilio_fiscal'] ?? null;
+
+ // Ocultar el Dropzone después de procesar
+ $this->dropzoneVisible = false;
+
+ } catch (ValidationException $e) {
+ $this->handleValidationException($e);
+
+ } catch (QueryException $e) {
+ $this->handleDatabaseException($e);
+
+ } catch (ModelNotFoundException $e) {
+ $this->handleException('danger', 'Registro no encontrado.');
+
+ } catch (Exception $e) {
+ $this->handleException('danger', 'Error al procesar el archivo: ' . $e->getMessage());
+ }
+ }
+
+
+ /**
+ * Montamos el componente y llamamos al parent::mount() para configurar la tabla.
+ */
+ public function mount(): void
+ {
+ parent::mount();
+
+ // Definimos las rutas específicas de este componente
+ $this->routes = [
+ 'admin.user.show' => route('admin.core.users.show', ['user' => ':id']),
+ 'admin.user.edit' => route('admin.core.users.edit', ['user' => ':id']),
+ 'admin.user.delete' => route('admin.core.users.delete', ['user' => ':id']),
+ ];
+ }
+
+ /**
+ * Retorna la vista a renderizar por este componente.
+ */
+ protected function viewPath(): string
+ {
+ return 'vuexy-admin::livewire.users.index';
+ }
+}
diff --git a/Livewire/Users/UserOffCanvasForm.php b/Livewire/Users/UserOffCanvasForm.php
new file mode 100644
index 0000000..d65c74d
--- /dev/null
+++ b/Livewire/Users/UserOffCanvasForm.php
@@ -0,0 +1,295 @@
+ 'loadFormModel',
+ 'confirmDeletionUsers' => 'loadFormModelForDeletion',
+ ];
+
+ /**
+ * Definición de tipos de datos que se deben castear.
+ *
+ * @var array
+ */
+ protected $casts = [
+ 'status' => 'boolean',
+ ];
+
+ /**
+ * Define el modelo Eloquent asociado con el formulario.
+ *
+ * @return string
+ */
+ protected function model(): string
+ {
+ return User::class;
+ }
+
+ /**
+ * Define los campos del formulario.
+ *
+ * @return array
+ */
+ protected function fields(): array
+ {
+ return (new User())->getFillable();
+ }
+
+ /**
+ * Valores por defecto para el formulario.
+ *
+ * @return array
+ */
+ protected function defaults(): array
+ {
+ return [
+ //
+ ];
+ }
+
+ /**
+ * Campo que se debe enfocar cuando se abra el formulario.
+ *
+ * @return string
+ */
+ protected function focusOnOpen(): string
+ {
+ return 'name';
+ }
+
+ /**
+ * Define reglas de validación dinámicas basadas en el modo actual.
+ *
+ * @param string $mode El modo actual del formulario ('create', 'edit', 'delete').
+ * @return array
+ */
+ protected function dynamicRules(string $mode): array
+ {
+ switch ($mode) {
+ case 'create':
+ case 'edit':
+ return [
+ 'code' => ['required', 'string', 'max:16', Rule::unique('contact', 'code')->ignore($this->id)],
+ 'name' => ['required', 'string', 'max:96'],
+ 'notes' => ['nullable', 'string', 'max:1024'],
+ 'tel' => ['nullable', 'regex:/^[0-9+\-\s]+$/', 'max:20'],
+ ];
+
+ case 'delete':
+ return [
+ 'confirmDeletion' => 'accepted', // Asegura que el usuario confirme la eliminación
+ ];
+
+ default:
+ return [];
+ }
+ }
+
+ // ===================== VALIDACIONES =====================
+
+ /**
+ * Get custom attributes for validator errors.
+ *
+ * @return array
+ */
+ protected function attributes(): array
+ {
+ return [
+ 'code' => 'código de usuario',
+ 'name' => 'nombre del usuario',
+ ];
+ }
+
+ /**
+ * Get the error messages for the defined validation rules.
+ *
+ * @return array
+ */
+ protected function messages(): array
+ {
+ return [
+ 'code.unique' => 'Este código ya está en uso por otro usuario.',
+ 'name.required' => 'El nombre del usuario es obligatorio.',
+ ];
+ }
+
+ /**
+ * Carga el formulario con datos del usuario y actualiza las opciones dinámicas.
+ *
+ * @param int $id
+ */
+ public function loadFormModel($id): void
+ {
+ parent::loadFormModel($id);
+
+ $this->work_center_options = $this->store_id
+ ? DB::table('store_work_centers')
+ ->where('store_id', $this->store_id)
+ ->pluck('name', 'id')
+ ->toArray()
+ : [];
+ }
+
+ /**
+ * Carga el formulario para eliminar un usuario, actualizando las opciones necesarias.
+ *
+ * @param int $id
+ */
+ public function loadFormModelForDeletion($id): void
+ {
+ parent::loadFormModelForDeletion($id);
+
+ $this->work_center_options = DB::table('store_work_centers')
+ ->where('store_id', $this->store_id)
+ ->pluck('name', 'id')
+ ->toArray();
+ }
+
+ /**
+ * Define las opciones de los selectores desplegables.
+ *
+ * @return array
+ */
+ protected function options(): array
+ {
+ $storeCatalogService = app(StoreCatalogService::class);
+ $contactCatalogService = app(ContactCatalogService::class);
+
+ return [
+ 'store_options' => $storeCatalogService->searchCatalog('stores', '', ['limit' => -1]),
+ 'manager_options' => $contactCatalogService->searchCatalog('users', '', ['limit' => -1]),
+ ];
+ }
+
+ /**
+ * Procesa el documento recibido (CFDI XML o Constancia PDF).
+ */
+ public function processDocument()
+ {
+ // Verificamos si el archivo es válido
+ if (!$this->doc_file instanceof UploadedFile) {
+ return $this->addError('doc_file', 'No se pudo recibir el archivo.');
+ }
+
+ try {
+ // Validar tipo de archivo
+ $this->validate([
+ 'doc_file' => 'required|mimes:pdf,xml|max:2048'
+ ]);
+
+
+ // **Detectar el tipo de documento**
+ $extension = strtolower($this->doc_file->getClientOriginalExtension());
+
+ // **Procesar según el tipo de archivo**
+ switch ($extension) {
+ case 'xml':
+ $service = new FacturaXmlService();
+ $data = $service->processUploadedFile($this->doc_file);
+ break;
+
+ case 'pdf':
+ $service = new ConstanciaFiscalService();
+ $data = $service->extractData($this->doc_file);
+ break;
+
+ default:
+ throw new Exception("Formato de archivo no soportado.");
+ }
+
+ dd($data);
+
+ // **Asignar los valores extraídos al formulario**
+ $this->rfc = $data['rfc'] ?? null;
+ $this->name = $data['name'] ?? null;
+ $this->email = $data['email'] ?? null;
+ $this->tel = $data['telefono'] ?? null;
+ //$this->direccion = $data['domicilio_fiscal'] ?? null;
+
+ // Ocultar el Dropzone después de procesar
+ $this->dropzoneVisible = false;
+
+ } catch (ValidationException $e) {
+ $this->handleValidationException($e);
+
+ } catch (QueryException $e) {
+ $this->handleDatabaseException($e);
+
+ } catch (ModelNotFoundException $e) {
+ $this->handleException('danger', 'Registro no encontrado.');
+
+ } catch (Exception $e) {
+ $this->handleException('danger', 'Error al procesar el archivo: ' . $e->getMessage());
+ }
+ }
+
+ /**
+ * Ruta de la vista asociada con este formulario.
+ *
+ * @return string
+ */
+ protected function viewPath(): string
+ {
+ return 'vuexy-admin::livewire.users.offcanvas-form';
+ }
+}
diff --git a/Livewire/Users/UserShow.php b/Livewire/Users/UserShow.php
new file mode 100644
index 0000000..de9cd95
--- /dev/null
+++ b/Livewire/Users/UserShow.php
@@ -0,0 +1,283 @@
+ 'nullable|integer',
+ 'name' => 'required|string|min:3|max:255',
+ 'cargo' => 'nullable|string|min:3|max:255',
+ 'is_prospect' => 'nullable|boolean',
+ 'is_customer' => 'nullable|boolean',
+ 'is_provider' => 'nullable|boolean',
+ 'is_user' => 'nullable|boolean',
+ 'pricelist_id' => 'nullable|integer',
+ 'enable_credit' => 'nullable|boolean',
+ 'credit_days' => 'nullable|integer',
+ 'credit_limit' => 'nullable|numeric|min:0|max:9999999.99|regex:/^\d{1,7}(\.\d{1,2})?$/',
+ 'image' => 'nullable|mimes:jpg,png|image|max:20480', // 20MB Max
+ ];
+
+ // Reglas de validación para los campos fiscales
+ protected $rulesFacturacion = [
+ 'rfc' => 'nullable|string|max:13',
+ 'domicilio_fiscal' => [
+ 'nullable',
+ 'regex:/^[0-9]{5}$/',
+ 'exists:sat_codigo_postal,c_codigo_postal'
+ ],
+ 'nombre_fiscal' => 'nullable|string|max:255',
+ 'c_regimen_fiscal' => 'nullable|integer',
+ 'c_uso_cfdi' => 'nullable|string',
+ ];
+
+ public function mount($userId)
+ {
+ $this->user = User::findOrFail($userId);
+
+ $this->reloadUserData();
+
+ $this->pricelists_options = DropdownList::selectList(DropdownList::POS_PRICELIST);
+
+ $this->status_options = [
+ User::STATUS_ENABLED => User::$statusList[User::STATUS_ENABLED],
+ User::STATUS_DISABLED => User::$statusList[User::STATUS_DISABLED],
+ ];
+
+ $this->regimen_fiscal_options = RegimenFiscal::selectList();
+ $this->uso_cfdi_options = UsoCfdi::selectList();
+ }
+
+
+ public function reloadUserData()
+ {
+ $this->tipo_persona = $this->user->tipo_persona;
+ $this->name = $this->user->name;
+ $this->cargo = $this->user->cargo;
+ $this->is_prospect = $this->user->is_prospect? true : false;
+ $this->is_customer = $this->user->is_customer? true : false;
+ $this->is_provider = $this->user->is_provider? true : false;
+ $this->is_user = $this->user->is_user? true : false;
+ $this->pricelist_id = $this->user->pricelist_id;
+ $this->enable_credit = $this->user->enable_credit? true : false;
+ $this->credit_days = $this->user->credit_days;
+ $this->credit_limit = $this->user->credit_limit;
+ $this->profile_photo = $this->user->profile_photo_url;
+ $this->profile_photo_path = $this->user->profile_photo_path;
+ $this->image = null;
+ $this->deleteUserImage = false;
+
+ $this->status = $this->user->status;
+ $this->email = $this->user->email;
+ $this->password = null;
+ $this->password_confirmation = null;
+
+ $this->rfc = $this->user->rfc;
+ $this->domicilio_fiscal = $this->user->domicilio_fiscal;
+ $this->nombre_fiscal = $this->user->nombre_fiscal;
+ $this->c_regimen_fiscal = $this->user->c_regimen_fiscal;
+ $this->c_uso_cfdi = $this->user->c_uso_cfdi;
+
+ $this->cuentaUsuarioAlert = null;
+ $this->accesosAlert = null;
+ $this->facturacionElectronicaAlert = null;
+ }
+
+
+ public function saveCuentaUsuario()
+ {
+ try {
+ // Validar Información de usuario
+ $validatedData = $this->validate($this->rulesUser);
+
+ $validatedData['name'] = trim($validatedData['name']);
+ $validatedData['cargo'] = $validatedData['cargo']? trim($validatedData['cargo']): null;
+ $validatedData['is_prospect'] = $validatedData['is_prospect'] ? 1 : 0;
+ $validatedData['is_customer'] = $validatedData['is_customer'] ? 1 : 0;
+ $validatedData['is_provider'] = $validatedData['is_provider'] ? 1 : 0;
+ $validatedData['is_user'] = $validatedData['is_user'] ? 1 : 0;
+ $validatedData['pricelist_id'] = $validatedData['pricelist_id'] ?: null;
+ $validatedData['enable_credit'] = $validatedData['enable_credit'] ? 1 : 0;
+ $validatedData['credit_days'] = $validatedData['credit_days'] ?: null;
+ $validatedData['credit_limit'] = $validatedData['credit_limit'] ?: null;
+
+ if($this->tipo_persona == User::TIPO_RFC_PUBLICO){
+ $validatedData['cargo'] = null;
+ $validatedData['is_prospect'] = null;
+ $validatedData['is_provider'] = null;
+ $validatedData['is_user'] = null;
+ $validatedData['enable_credit'] = null;
+ $validatedData['credit_days'] = null;
+ $validatedData['credit_limit'] = null;
+ }
+
+ if(!$this->user->is_prospect && !$this->user->is_customer){
+ $validatedData['pricelist_id'] = null;
+ }
+
+ if(!$this->user->is_customer){
+ $validatedData['enable_credit'] = null;
+ $validatedData['credit_days'] = null;
+ $validatedData['credit_limit'] = null;
+ }
+
+ $this->user->update($validatedData);
+
+
+ if($this->deleteUserImage && $this->user->profile_photo_path){
+ $this->user->deleteProfilePhoto();
+
+ // Reiniciar variables después de la eliminación
+ $this->deleteUserImage = false;
+ $this->profile_photo_path = null;
+ $this->profile_photo = $this->user->profile_photo_url;
+
+ }else if ($this->image) {
+ $image = ImageManager::imagick()->read($this->image->getRealPath());
+ $image = $image->scale(520, 520);
+
+ $imageName = $this->image->hashName(); // Genera un nombre único
+
+ $image->save(storage_path('app/public/profile-photos/' . $imageName));
+
+ $this->user->deleteProfilePhoto();
+
+ $this->profile_photo_path = $this->user->profile_photo_path = 'profile-photos/' . $imageName;
+ $this->profile_photo = $this->user->profile_photo_url;
+ $this->user->save();
+
+ unlink($this->image->getRealPath());
+
+ $this->reset('image');
+ }
+
+ // Puedes también devolver un mensaje de éxito si lo deseas
+ $this->setAlert('Se guardó los cambios exitosamente.', 'cuentaUsuarioAlert');
+
+ } catch (\Illuminate\Validation\ValidationException $e) {
+ // Si hay errores de validación, los puedes capturar y manejar aquí
+ $this->setAlert('Ocurrieron errores en la validación: ' . $e->validator->errors()->first(), 'cuentaUsuarioAlert', 'danger');
+ }
+ }
+
+ public function saveAccesos()
+ {
+ try {
+ $validatedData = $this->validate([
+ 'status' => 'integer',
+ 'email' => ['required', 'email', 'unique:users,email,' . $this->user->id],
+ 'password' => ['nullable', 'string', 'min:6', 'max:32', 'confirmed'], // La regla 'confirmed' valida que ambas contraseñas coincidan
+ ], [
+ 'email.required' => 'El correo electrónico es obligatorio.',
+ 'email.email' => 'Debes ingresar un correo electrónico válido.',
+ 'email.unique' => 'Este correo ya está en uso.',
+ 'password.min' => 'La contraseña debe tener al menos 5 caracteres.',
+ 'password.max' => 'La contraseña no puede tener más de 32 caracteres.',
+ 'password.confirmed' => 'Las contraseñas no coinciden.',
+ ]);
+
+ // Si la validación es exitosa, continuar con el procesamiento
+ $validatedData['email'] = trim($this->email);
+
+ if ($this->password)
+ $validatedData['password'] = bcrypt($this->password);
+
+ else
+ unset($validatedData['password']);
+
+ $this->user->update($validatedData);
+
+ $this->password = null;
+ $this->password_confirmation = null;
+
+ // Puedes también devolver un mensaje de éxito si lo deseas
+ $this->setAlert('Se guardó los cambios exitosamente.', 'accesosAlert');
+
+ } catch (\Illuminate\Validation\ValidationException $e) {
+ // Si hay errores de validación, los puedes capturar y manejar aquí
+ $this->setAlert('Ocurrieron errores en la validación: ' . $e->validator->errors()->first(), 'accesosAlert', 'danger');
+ }
+ }
+
+ public function saveFacturacionElectronica()
+ {
+ try {
+ // Validar Información fiscal
+ $validatedData = $this->validate($this->rulesFacturacion);
+
+ $validatedData['rfc'] = strtoupper(trim($validatedData['rfc'])) ?: null;
+ $validatedData['domicilio_fiscal'] = $validatedData['domicilio_fiscal'] ?: null;
+ $validatedData['nombre_fiscal'] = strtoupper(trim($validatedData['nombre_fiscal'])) ?: null;
+ $validatedData['c_regimen_fiscal'] = $validatedData['c_regimen_fiscal'] ?: null;
+ $validatedData['c_uso_cfdi'] = $validatedData['c_uso_cfdi'] ?: null;
+
+ $this->user->update($validatedData);
+
+ // Puedes también devolver un mensaje de éxito si lo deseas
+ $this->setAlert('Se guardó los cambios exitosamente.', 'facturacionElectronicaAlert');
+
+ } catch (\Illuminate\Validation\ValidationException $e) {
+ // Si hay errores de validación, los puedes capturar y manejar aquí
+ $this->setAlert('Ocurrieron errores en la validación: ' . $e->validator->errors()->first(), 'facturacionElectronicaAlert', 'danger');
+ }
+ }
+
+
+ private function setAlert($message, $alertName, $type = 'success')
+ {
+ $this->$alertName = [
+ 'message' => $message,
+ 'type' => $type
+ ];
+ }
+
+ public function render()
+ {
+ return view('livewire.admin.crm.contact-view');
+ }
+}
diff --git a/Models/MediaItem.php b/Models/MediaItem.php
new file mode 100644
index 0000000..ad44908
--- /dev/null
+++ b/Models/MediaItem.php
@@ -0,0 +1,62 @@
+ 'Card',
+ self::TYPE_BANNER => 'Banner',
+ self::TYPE_COVER => 'Cover',
+ self::TYPE_GALLERY => 'Gallery',
+ self::TYPE_BANNER_HOME => 'Banner Home',
+ self::TYPE_CARD2 => 'Card 2',
+ self::TYPE_BANNER2 => 'Banner 2',
+ self::TYPE_COVER2 => 'Cover 2',
+ ];
+
+ /**
+ * Get the parent imageable model (user or post).
+ */
+ public function imageable()
+ {
+ return $this->morphTo();
+ }
+}
diff --git a/Models/Setting.php b/Models/Setting.php
new file mode 100644
index 0000000..7300adf
--- /dev/null
+++ b/Models/Setting.php
@@ -0,0 +1,39 @@
+
+ */
+ protected $fillable = [
+ 'key',
+ 'value',
+ 'user_id',
+ ];
+
+ public $timestamps = false;
+
+ // Relación con el usuario
+ public function user()
+ {
+ return $this->belongsTo(User::class);
+ }
+
+ // Scope para obtener configuraciones de un usuario específico
+ public function scopeForUser($query, $userId)
+ {
+ return $query->where('user_id', $userId);
+ }
+
+ // Configuraciones globales (sin usuario)
+ public function scopeGlobal($query)
+ {
+ return $query->whereNull('user_id');
+ }
+}
diff --git a/Models/User copy.php b/Models/User copy.php
new file mode 100644
index 0000000..cb20a95
--- /dev/null
+++ b/Models/User copy.php
@@ -0,0 +1,377 @@
+ 'Habilitado',
+ self::STATUS_DISABLED => 'Deshabilitado',
+ self::STATUS_REMOVED => 'Eliminado',
+ ];
+
+ /**
+ * List of names for each status.
+ * @var array
+ */
+ public static $statusListClass = [
+ self::STATUS_ENABLED => 'success',
+ self::STATUS_DISABLED => 'warning',
+ self::STATUS_REMOVED => 'danger',
+ ];
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var array
+ */
+ protected $fillable = [
+ 'name',
+ 'last_name',
+ 'email',
+ 'password',
+ 'profile_photo_path',
+ 'status',
+ 'created_by',
+ ];
+
+ /**
+ * The attributes that should be hidden for serialization.
+ *
+ * @var array
+ */
+ protected $hidden = [
+ 'password',
+ 'remember_token',
+ 'two_factor_recovery_codes',
+ 'two_factor_secret',
+ ];
+
+ /**
+ * The accessors to append to the model's array form.
+ *
+ * @var array
+ */
+ protected $appends = [
+ 'profile_photo_url',
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'email_verified_at' => 'datetime',
+ 'password' => 'hashed',
+ ];
+ }
+
+ /**
+ * Attributes to include in the Audit.
+ *
+ * @var array
+ */
+ protected $auditInclude = [
+ 'name',
+ 'email',
+ ];
+
+ public function updateProfilePhoto(UploadedFile $image_avatar)
+ {
+ try {
+ // Verificar si el archivo existe
+ if (!file_exists($image_avatar->getRealPath()))
+ throw new \Exception('El archivo no existe en la ruta especificada.');
+
+ if (!in_array($image_avatar->getClientOriginalExtension(), ['jpg', 'jpeg', 'png']))
+ throw new \Exception('El formato del archivo debe ser JPG o PNG.');
+
+ // Directorio donde se guardarán los avatares
+ $avatarDisk = self::AVATAR_DISK;
+ $avatarPath = self::PROFILE_PHOTO_DIR;
+ $avatarName = uniqid('avatar_') . '.png'; // Nombre único para el avatar
+
+ // Crear la instancia de ImageManager
+ $driver = config('image.driver', 'gd');
+ $manager = new ImageManager($driver);
+
+ // Crear el directorio si no existe
+ if (!Storage::disk($avatarDisk)->exists($avatarPath))
+ Storage::disk($avatarDisk)->makeDirectory($avatarPath);
+
+ // Leer la imagen
+ $image = $manager->read($image_avatar->getRealPath());
+
+ // crop the best fitting 5:3 (600x360) ratio and resize to 600x360 pixel
+ $image->cover(self::AVATAR_WIDTH, self::AVATAR_HEIGHT);
+
+ // Guardar la imagen en el disco de almacenamiento gestionado por Laravel
+ Storage::disk($avatarDisk)->put($avatarPath . '/' . $avatarName, $image->toPng(indexed: true));
+
+ // Elimina el avatar existente si hay uno
+ $this->deleteProfilePhoto();
+
+ // Update the user's profile photo path
+ $this->forceFill([
+ 'profile_photo_path' => $avatarName,
+ ])->save();
+ } catch (\Exception $e) {
+ throw new \Exception('Ocurrió un error al actualizar el avatar. ' . $e->getMessage());
+ }
+ }
+
+ public function deleteProfilePhoto()
+ {
+ if (!empty($this->profile_photo_path)) {
+ $avatarDisk = self::AVATAR_DISK;
+
+ Storage::disk($avatarDisk)->delete($this->profile_photo_path);
+
+ $this->forceFill([
+ 'profile_photo_path' => null,
+ ])->save();
+ }
+ }
+
+ public function getAvatarColor()
+ {
+ // Selecciona un color basado en el id del usuario
+ return self::AVATAR_COLORS[$this->id % count(self::AVATAR_COLORS)];
+ }
+
+ public static function getAvatarImage($name, $color, $background, $size)
+ {
+ $avatarDisk = self::AVATAR_DISK;
+ $directory = self::INITIAL_AVATAR_DIR;
+ $initials = self::getInitials($name);
+
+ $cacheKey = "avatar-{$initials}-{$color}-{$background}-{$size}";
+ $path = "{$directory}/{$cacheKey}.png";
+ $storagePath = storage_path("app/public/{$path}");
+
+ // Verificar si el avatar ya está en caché
+ if (Storage::disk($avatarDisk)->exists($path))
+ return response()->file($storagePath);
+
+ // Crear el avatar
+ $image = self::createAvatarImage($name, $color, $background, $size);
+
+ // Guardar en el directorio de iniciales
+ Storage::disk($avatarDisk)->put($path, $image->toPng(indexed: true));
+
+ // Retornar la imagen directamente
+ return response()->file($storagePath);
+ }
+
+ private static function createAvatarImage($name, $color, $background, $size)
+ {
+ // Usar la configuración del driver de imagen
+ $driver = config('image.driver', 'gd');
+ $manager = new ImageManager($driver);
+
+ $initials = self::getInitials($name);
+
+ // Obtener la ruta correcta de la fuente dentro del paquete
+ $fontPath = __DIR__ . '/../storage/fonts/OpenSans-Bold.ttf';
+
+ // Crear la imagen con fondo
+ $image = $manager->create($size, $size)
+ ->fill($background);
+
+ // Escribir texto en la imagen
+ $image->text(
+ $initials,
+ $size / 2, // Centrar horizontalmente
+ $size / 2, // Centrar verticalmente
+ function (FontFactory $font) use ($color, $size, $fontPath) {
+ $font->file($fontPath);
+ $font->size($size * 0.4);
+ $font->color($color);
+ $font->align('center');
+ $font->valign('middle');
+ }
+ );
+
+ return $image;
+ }
+
+ public static function getInitials($name)
+ {
+ // Manejar casos de nombres vacíos o nulos
+ if (empty($name))
+ return 'NA';
+
+ // Usar array_map para mayor eficiencia
+ $initials = implode('', array_map(function ($word) {
+ return mb_substr($word, 0, 1);
+ }, explode(' ', $name)));
+
+ $initials = substr($initials, 0, self::INITIAL_MAX_LENGTH);
+
+ return strtoupper($initials);
+ }
+
+ public function getProfilePhotoUrlAttribute()
+ {
+ if ($this->profile_photo_path)
+ return Storage::url(self::PROFILE_PHOTO_DIR . '/' . $this->profile_photo_path);
+
+ // Generar URL del avatar por iniciales
+ $name = urlencode($this->fullname);
+ $color = ltrim($this->getAvatarColor(), '#');
+ $background = ltrim(self::AVATAR_BACKGROUND, '#');
+ $size = (self::AVATAR_WIDTH + self::AVATAR_HEIGHT) / 2;
+
+ return url("/admin/usuario/avatar?name={$name}&color={$color}&background={$background}&size={$size}");
+ }
+
+ public function getFullnameAttribute()
+ {
+ return trim($this->name . ' ' . $this->last_name);
+ }
+
+ public function getInitialsAttribute()
+ {
+ return self::getInitials($this->fullname);
+ }
+
+ /**
+ * Envía la notificación de restablecimiento de contraseña.
+ *
+ * @param string $token
+ */
+ public function sendPasswordResetNotification($token)
+ {
+ // Usar la notificación personalizada
+ $this->notify(new CustomResetPasswordNotification($token));
+ }
+
+
+ /**
+ * Obtener usuarios activos con una excepción para incluir un usuario específico desactivado.
+ *
+ * @param array $filters Filtros opcionales como ['type' => 'user', 'status' => 1]
+ * @param int|null $includeUserId ID de usuario específico a incluir aunque esté inactivo
+ * @return array
+ */
+ public static function getUsersListWithInactive(int $includeUserId = null, array $filters = []): array
+ {
+ $query = self::query();
+
+ // Filtro por tipo de usuario
+ if (isset($filters['type'])) {
+ switch ($filters['type']) {
+ case 'partner':
+ $query->where('is_partner', 1);
+ break;
+ case 'employee':
+ $query->where('is_employee', 1);
+ break;
+ case 'prospect':
+ $query->where('is_prospect', 1);
+ break;
+ case 'customer':
+ $query->where('is_customer', 1);
+ break;
+ case 'provider':
+ $query->where('is_provider', 1);
+ break;
+ case 'user':
+ $query->where('is_user', 1);
+ break;
+ }
+ }
+
+ // Incluir usuarios activos o el usuario desactivado seleccionado
+ $query->where(function ($q) use ($filters, $includeUserId) {
+ if (isset($filters['status'])) {
+ $q->where('status', $filters['status']);
+ }
+
+ if ($includeUserId) {
+ $q->orWhere('id', $includeUserId);
+ }
+ });
+
+ // Formatear los datos como id => "Nombre Apellido"
+ return $query->pluck(\DB::raw("CONCAT(name, ' ', IFNULL(last_name, ''))"), 'id')->toArray();
+ }
+
+
+ /**
+ * Relations
+ */
+
+ // User who created this user
+ public function creator()
+ {
+ return $this->belongsTo(self::class, 'created_by');
+ }
+
+ public function isActive()
+ {
+ return $this->status === self::STATUS_ENABLED;
+ }
+
+}
diff --git a/Models/User.php b/Models/User.php
new file mode 100644
index 0000000..b1644d8
--- /dev/null
+++ b/Models/User.php
@@ -0,0 +1,237 @@
+ 'Habilitado',
+ self::STATUS_DISABLED => 'Deshabilitado',
+ self::STATUS_REMOVED => 'Eliminado',
+ ];
+
+ /**
+ * List of names for each status.
+ * @var array
+ */
+ public static $statusListClass = [
+ self::STATUS_ENABLED => 'success',
+ self::STATUS_DISABLED => 'warning',
+ self::STATUS_REMOVED => 'danger',
+ ];
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var array
+ */
+ protected $fillable = [
+ 'name',
+ 'last_name',
+ 'email',
+ 'password',
+ 'profile_photo_path',
+ 'status',
+ 'created_by',
+ ];
+
+ /**
+ * The attributes that should be hidden for serialization.
+ *
+ * @var array
+ */
+ protected $hidden = [
+ 'password',
+ 'remember_token',
+ 'two_factor_recovery_codes',
+ 'two_factor_secret',
+ ];
+
+ /**
+ * The accessors to append to the model's array form.
+ *
+ * @var array
+ */
+ protected $appends = [
+ 'profile_photo_url',
+ ];
+
+ /**
+ * Nombre de la etiqueta para generar Componentes
+ *
+ * @var string
+ */
+ public $tagName = 'User';
+
+ /**
+ * Nombre de la columna que contiee el nombre del registro
+ *
+ * @var string
+ */
+ public $columnNameLabel = 'full_name';
+
+ /**
+ * Nombre singular del registro.
+ *
+ * @var string
+ */
+ public $singularName = 'usuario';
+
+ /**
+ * Nombre plural del registro.
+ *
+ * @var string
+ */
+ public $pluralName = 'usuarios';
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'email_verified_at' => 'datetime',
+ 'password' => 'hashed',
+ ];
+ }
+
+ /**
+ * Attributes to include in the Audit.
+ *
+ * @var array
+ */
+ protected $auditInclude = [
+ 'name',
+ 'email',
+ ];
+
+ /**
+ * Get the full name of the user.
+ *
+ * @return string
+ */
+ public function getFullnameAttribute()
+ {
+ return trim($this->name . ' ' . $this->last_name);
+ }
+
+ /**
+ * Get the initials of the user's full name.
+ *
+ * @return string
+ */
+ public function getInitialsAttribute()
+ {
+ return self::getInitials($this->fullname);
+ }
+
+ /**
+ * Envía la notificación de restablecimiento de contraseña.
+ *
+ * @param string $token
+ */
+ public function sendPasswordResetNotification($token)
+ {
+ // Usar la notificación personalizada
+ $this->notify(new CustomResetPasswordNotification($token));
+ }
+
+ /**
+ * Obtener usuarios activos con una excepción para incluir un usuario específico desactivado.
+ *
+ * @param array $filters Filtros opcionales como ['type' => 'user', 'status' => 1]
+ * @param int|null $includeUserId ID de usuario específico a incluir aunque esté inactivo
+ * @return array
+ */
+ public static function getUsersListWithInactive($includeUserId = null, array $filters = []): array
+ {
+ $query = self::query();
+
+ // Filtro por tipo de usuario dinámico
+ $tipoUsuarios = [
+ 'partner' => 'is_partner',
+ 'employee' => 'is_employee',
+ 'prospect' => 'is_prospect',
+ 'customer' => 'is_customer',
+ 'provider' => 'is_provider',
+ 'user' => 'is_user',
+ ];
+
+ if (isset($filters['type']) && isset($tipoUsuarios[$filters['type']])) {
+ $query->where($tipoUsuarios[$filters['type']], 1);
+ }
+
+ // Filtrar por estado o incluir usuario inactivo
+ $query->where(function ($q) use ($filters, $includeUserId) {
+ if (isset($filters['status'])) {
+ $q->where('status', $filters['status']);
+ }
+
+ if ($includeUserId) {
+ $q->orWhere('id', $includeUserId);
+ }
+ });
+
+ return $query->pluck(\DB::raw("CONCAT(name, ' ', IFNULL(last_name, ''))"), 'id')->toArray();
+ }
+
+ /**
+ * User who created this user
+ */
+ public function creator()
+ {
+ return $this->belongsTo(self::class, 'created_by');
+ }
+
+ /**
+ * Check if the user is active
+ */
+ public function isActive()
+ {
+ return $this->status === self::STATUS_ENABLED;
+ }
+
+}
diff --git a/Models/UserLogin.php b/Models/UserLogin.php
new file mode 100644
index 0000000..3f86d0c
--- /dev/null
+++ b/Models/UserLogin.php
@@ -0,0 +1,14 @@
+token = $token;
+ }
+
+ /**
+ * Configura el canal de la notificación.
+ */
+ public function via($notifiable)
+ {
+ return ['mail'];
+ }
+
+ /**
+ * Configura el mensaje de correo.
+ */
+ public function toMail($notifiable)
+ {
+ try {
+ // Cargar configuración SMTP desde la base de datos
+ $this->loadDynamicMailConfig();
+
+ $resetUrl = url(route('password.reset', [
+ 'token' => $this->token,
+ 'email' => $notifiable->getEmailForPasswordReset()
+ ], false));
+
+ $appTitle = Setting::global()->where('key', 'website_title')->first()->value ?? Config::get('koneko.appTitle');
+ $imageBase64 = 'data:image/png;base64,' . base64_encode(file_get_contents(public_path('/assets/img/logo/koneko-04.png')));
+ $expireMinutes = Config::get('auth.passwords.' . Config::get('auth.defaults.passwords') . '.expire', 60);
+
+ Config::set('app.name', $appTitle);
+
+ return (new MailMessage)
+ ->subject("Restablece tu contraseña - {$appTitle}")
+ ->markdown('vuexy-admin::notifications.email', [ // Usar tu plantilla del módulo
+ 'greeting' => "Hola {$notifiable->name}",
+ 'introLines' => [
+ 'Estás recibiendo este correo porque solicitaste restablecer tu contraseña.',
+ ],
+ 'actionText' => 'Restablecer contraseña',
+ 'actionUrl' => $resetUrl,
+ 'outroLines' => [
+ "Este enlace expirará en {$expireMinutes} minutos.",
+ 'Si no solicitaste este cambio, no se requiere realizar ninguna acción.',
+ ],
+ 'displayableActionUrl' => $resetUrl, // Para el subcopy
+ 'image' => $imageBase64, // Imagen del logo
+ ]);
+
+ /*
+ */
+ } catch (\Exception $e) {
+ // Registrar el error
+ Log::error('Error al enviar el correo de restablecimiento: ' . $e->getMessage());
+
+ // Retornar un mensaje alternativo
+ return (new MailMessage)
+ ->subject('Restablece tu contraseña')
+ ->line('Ocurrió un error al enviar el correo. Por favor, intenta de nuevo más tarde.');
+ }
+ }
+
+ /**
+ * Cargar configuración SMTP desde la base de datos.
+ */
+ protected function loadDynamicMailConfig()
+ {
+ try {
+ $smtpConfig = Setting::where('key', 'LIKE', 'mail_%')
+ ->pluck('value', 'key');
+
+ if ($smtpConfig->isEmpty()) {
+ throw new Exception('No SMTP configuration found in the database.');
+ }
+
+ Config::set('mail.mailers.smtp.host', $smtpConfig['mail_mailers_smtp_host'] ?? null);
+ Config::set('mail.mailers.smtp.port', $smtpConfig['mail_mailers_smtp_port'] ?? null);
+ Config::set('mail.mailers.smtp.username', $smtpConfig['mail_mailers_smtp_username'] ?? null);
+ Config::set(
+ 'mail.mailers.smtp.password',
+ isset($smtpConfig['mail_mailers_smtp_password'])
+ ? Crypt::decryptString($smtpConfig['mail_mailers_smtp_password'])
+ : null
+ );
+ Config::set('mail.mailers.smtp.encryption', $smtpConfig['mail_mailers_smtp_encryption'] ?? null);
+ Config::set('mail.from.address', $smtpConfig['mail_from_address'] ?? null);
+ Config::set('mail.from.name', $smtpConfig['mail_from_name'] ?? null);
+ } catch (Exception $e) {
+ Log::error('SMTP Configuration Error: ' . $e->getMessage());
+ // Opcional: Puedes lanzar la excepción o manejarla de otra manera.
+ throw new Exception('Error al cargar la configuración SMTP.');
+ }
+ }
+}
diff --git a/Providers/ConfigServiceProvider.php b/Providers/ConfigServiceProvider.php
new file mode 100644
index 0000000..d165392
--- /dev/null
+++ b/Providers/ConfigServiceProvider.php
@@ -0,0 +1,31 @@
+mergeConfigFrom(__DIR__.'/../config/vuexy.php', 'vuexy');
+ }
+
+ /**
+ * Bootstrap services.
+ */
+ public function boot(): void
+ {
+ // Cargar configuración del sistema
+ $globalSettingsService = app(GlobalSettingsService::class);
+ $globalSettingsService->loadSystemConfig();
+
+ // Cargar configuración del sistema a través del servicio
+ app(GlobalSettingsService::class)->loadSystemConfig();
+ }
+}
diff --git a/Providers/FortifyServiceProvider.php b/Providers/FortifyServiceProvider.php
new file mode 100644
index 0000000..4b631d9
--- /dev/null
+++ b/Providers/FortifyServiceProvider.php
@@ -0,0 +1,124 @@
+input(Fortify::username())) . '|' . $request->ip());
+
+ return Limit::perMinute(5)->by($throttleKey);
+ });
+
+ RateLimiter::for('two-factor', function (Request $request) {
+ return Limit::perMinute(5)->by($request->session()->get('login.id'));
+ });
+
+ Fortify::authenticateUsing(function (Request $request) {
+ $user = User::where('email', $request->email)
+ ->where('status', User::STATUS_ENABLED)
+ ->first();
+
+ if ($user && Hash::check($request->password, $user->password)) {
+ return $user;
+ }
+ });
+
+ // Simula lo que hace tu middleware y comparte `_admin`
+ $viewMode = Config::get('vuexy.custom.authViewMode');
+ $adminVars = app(AdminTemplateService::class)->getAdminVars();
+
+ // Configurar la vista del login
+ Fortify::loginView(function () use ($viewMode, $adminVars) {
+ $pageConfigs = ['myLayout' => 'blank'];
+
+ view()->share('_admin', $adminVars);
+
+ return view("vuexy-admin::auth.login-{$viewMode}", ['pageConfigs' => $pageConfigs]);
+ });
+
+ // Configurar la vista del registro (si lo necesitas)
+ Fortify::registerView(function () use ($viewMode, $adminVars) {
+ $pageConfigs = ['myLayout' => 'blank'];
+
+ view()->share('_admin', $adminVars);
+
+ return view("vuexy-admin::auth.register-{$viewMode}", ['pageConfigs' => $pageConfigs]);
+ });
+
+ // Configurar la vista de restablecimiento de contraseñas
+ Fortify::requestPasswordResetLinkView(function () use ($viewMode, $adminVars) {
+ $pageConfigs = ['myLayout' => 'blank'];
+
+ view()->share('_admin', $adminVars);
+
+ return view("vuexy-admin::auth.forgot-password-{$viewMode}", ['pageConfigs' => $pageConfigs]);
+ });
+
+ Fortify::resetPasswordView(function ($request) use ($viewMode, $adminVars) {
+ $pageConfigs = ['myLayout' => 'blank'];
+
+ view()->share('_admin', $adminVars);
+
+ return view("vuexy-admin::auth.reset-password-{$viewMode}", ['pageConfigs' => $pageConfigs, 'request' => $request]);
+ });
+
+ // Vista de verificación de correo electrónico
+ Fortify::verifyEmailView(function () use ($viewMode, $adminVars) {
+ view()->share('_admin', $adminVars);
+
+ return view("vuexy-admin::auth.verify-email-{$viewMode}");
+ });
+
+ // Vista de confirmación de contraseña
+ Fortify::confirmPasswordView(function () use ($viewMode, $adminVars) {
+ $pageConfigs = ['myLayout' => 'blank'];
+
+ view()->share('_admin', $adminVars);
+
+ return view("vuexy-admin::auth.confirm-password-{$viewMode}", ['pageConfigs' => $pageConfigs]);
+ });
+
+ // Configurar la vista para la verificación de dos factores
+ Fortify::twoFactorChallengeView(function () use ($viewMode, $adminVars) {
+ $pageConfigs = ['myLayout' => 'blank'];
+
+ view()->share('_admin', $adminVars);
+
+ return view("vuexy-admin::auth.two-factor-challenge-{$viewMode}", ['pageConfigs' => $pageConfigs]);
+ });
+ }
+}
diff --git a/Providers/VuexyAdminServiceProvider.php b/Providers/VuexyAdminServiceProvider.php
new file mode 100644
index 0000000..0040af0
--- /dev/null
+++ b/Providers/VuexyAdminServiceProvider.php
@@ -0,0 +1,132 @@
+mergeConfigFrom(__DIR__.'/../config/koneko.php', 'koneko');
+
+ // Register the module's services and providers
+ $this->app->register(ConfigServiceProvider::class);
+ $this->app->register(FortifyServiceProvider::class);
+ $this->app->register(PermissionServiceProvider::class);
+
+ // Register the module's aliases
+ AliasLoader::getInstance()->alias('Helper', VuexyHelper::class);
+ }
+
+ /**
+ * Bootstrap any application services.
+ */
+ public function boot(): void
+ {
+ if(env('FORCE_HTTPS', false)){
+ URL::forceScheme('https');
+ }
+
+ // Registrar alias del middleware
+ $this->app['router']->aliasMiddleware('admin', AdminTemplateMiddleware::class);
+
+ // Sobrescribir ruta de traducciones para asegurar que se usen las del paquete
+ $this->app->bind('path.lang', function () {
+ return __DIR__ . '/../resources/lang';
+ });
+
+ // Register the module's routes
+ $this->loadRoutesFrom(__DIR__.'/../routes/admin.php');
+
+
+ // Cargar vistas del paquete
+ $this->loadViewsFrom(__DIR__.'/../resources/views', 'vuexy-admin');
+
+ // Registrar Componentes Blade
+ Blade::componentNamespace('VuexyAdmin\\View\\Components', 'vuexy-admin');
+
+
+ // Publicar los archivos necesarios
+ $this->publishes([
+ __DIR__.'/../config/fortify.php' => config_path('fortify.php'),
+ __DIR__.'/../config/image.php' => config_path('image.php'),
+ __DIR__.'/../config/vuexy_menu.php' => config_path('vuexy_menu.php'),
+ ], 'vuexy-admin-config');
+
+ $this->publishes([
+ __DIR__.'/../database/seeders/' => database_path('seeders'),
+ __DIR__.'/../database/data' => database_path('data'),
+ ], 'vuexy-admin-seeders');
+
+ $this->publishes([
+ __DIR__.'/../resources/img' => public_path('vendor/vuexy-admin/img'),
+ ], 'vuexy-admin-images');
+
+
+ // Register the migrations
+ $this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
+
+
+ // Registrar eventos
+ Event::listen(Login::class, HandleUserLogin::class);
+ Event::listen(Logout::class, ClearUserCache::class);
+
+
+ // Registrar comandos de consola
+ if ($this->app->runningInConsole()) {
+ $this->commands([
+ CleanInitialAvatars::class,
+ ]);
+ }
+
+ // Registrar Livewire Components
+ $components = [
+ 'user-index' => UserIndex::class,
+ 'user-show' => UserShow::class,
+ 'user-form' => UserForm::class,
+ 'user-offcanvas-form' => UserOffCanvasForm::class,
+ 'role-index' => RoleIndex::class,
+ 'permission-index' => PermissionIndex::class,
+
+
+ 'general-settings' => GeneralSettings::class,
+ 'application-settings' => ApplicationSettings::class,
+ 'interface-settings' => InterfaceSettings::class,
+ 'mail-smtp-settings' => MailSmtpSettings::class,
+ 'mail-sender-response-settings' => MailSenderResponseSettings::class,
+ 'cache-stats' => CacheStats::class,
+ 'session-stats' => SessionStats::class,
+ 'redis-stats' => RedisStats::class,
+ 'memcached-stats' => MemcachedStats::class,
+ 'cache-functions' => CacheFunctions::class,
+ ];
+
+ foreach ($components as $alias => $component) {
+ Livewire::component($alias, $component);
+ }
+
+ // Registrar auditoría en usuarios
+ User::observe(AuditableObserver::class);
+ }
+}
diff --git a/Queries/BootstrapTableQueryBuilder.php b/Queries/BootstrapTableQueryBuilder.php
new file mode 100644
index 0000000..00984cb
--- /dev/null
+++ b/Queries/BootstrapTableQueryBuilder.php
@@ -0,0 +1,104 @@
+request = $request;
+ $this->config = $config;
+ $this->query = DB::table($config['table']);
+
+ $this->applyJoins();
+ $this->applyFilters();
+ }
+
+ protected function applyJoins()
+ {
+ if (!empty($this->config['joins'])) {
+ foreach ($this->config['joins'] as $join) {
+ $type = $join['type'] ?? 'join';
+
+ $this->query->{$type}($join['table'], function($joinObj) use ($join) {
+ $joinObj->on($join['first'], '=', $join['second']);
+
+ // Soporte para AND en ON, si está definidio
+ if (!empty($join['and'])) {
+ foreach ((array) $join['and'] as $andCondition) {
+ // 'sat_codigo_postal.c_estado = sat_localidad.c_estado'
+ $parts = explode('=', $andCondition);
+
+ if (count($parts) === 2) {
+ $left = trim($parts[0]);
+ $right = trim($parts[1]);
+
+ $joinObj->whereRaw("$left = $right");
+ }
+ }
+ }
+ });
+ }
+ }
+ }
+
+ protected function applyFilters()
+ {
+ if (!empty($this->config['filters'])) {
+ foreach ($this->config['filters'] as $filter => $column) {
+ if ($this->request->filled($filter)) {
+ $this->query->where($column, 'LIKE', '%' . $this->request->input($filter) . '%');
+ }
+ }
+ }
+ }
+
+ protected function applyGrouping()
+ {
+ if (!empty($this->config['group_by'])) {
+ $this->query->groupBy($this->config['group_by']);
+ }
+ }
+
+ public function getJson()
+ {
+ $this->applyGrouping();
+
+ // Calcular total de filas antes de aplicar paginación
+ $total = DB::select("SELECT COUNT(*) as num_rows FROM (" . $this->query->selectRaw('0')->toSql() . ") as items", $this->query->getBindings())[0]->num_rows;
+
+ // Para ver la sentencia SQL (con placeholders ?)
+ //dump($this->query->toSql()); dd($this->query->getBindings());
+
+ // Aplicar orden, paginación y selección de columnas
+ $this->query
+ ->select($this->config['columns'])
+ ->when($this->request->input('sort'), function ($query) {
+ $query->orderBy($this->request->input('sort'), $this->request->input('order', 'asc'));
+ })
+ ->when($this->request->input('offset'), function ($query) {
+ $query->offset($this->request->input('offset'));
+ })
+ ->limit($this->request->input('limit', 10));
+
+ // Obtener resultados y limpiar los datos antes de enviarlos
+ $rows = $this->query->get()->map(function ($item) {
+ return collect($item)
+ ->reject(fn($val) => is_null($val) || $val === '') // Eliminar valores nulos o vacíos
+ ->map(fn($val) => is_numeric($val) ? (float) $val : $val) // Convertir números correctamente
+ ->toArray();
+ });
+
+ return response()->json([
+ "total" => $total,
+ "rows" => $rows,
+ ]);
+ }
+}
diff --git a/Queries/GenericQueryBuilder.php b/Queries/GenericQueryBuilder.php
new file mode 100644
index 0000000..23a38d3
--- /dev/null
+++ b/Queries/GenericQueryBuilder.php
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
+
+
+
+
+---
+
+## 📌 Descripción
+
+**Laravel Vuexy Admin** es un módulo de administración optimizado para México, basado en Laravel 11 y diseñado para integrarse con **Vuexy Admin Template**. Incluye gestión avanzada de usuarios, roles, permisos y auditoría de acciones.
+
+### ✨ Características
+- 🔹 Sistema de autenticación con Laravel Fortify.
+- 🔹 Gestión avanzada de usuarios con Livewire.
+- 🔹 Control de roles y permisos con Spatie Permissions.
+- 🔹 Auditoría de acciones con Laravel Auditing.
+- 🔹 Publicación de configuraciones y vistas.
+- 🔹 Soporte para cache y optimización de rendimiento.
+
+---
+
+## 📦 Instalación
+
+Instalar vía **Composer**:
+
+```bash
+composer require koneko/laravel-vuexy-admin
+```
+
+Publicar archivos de configuración y migraciones:
+
+```bash
+php artisan vendor:publish --tag=vuexy-admin-config
+php artisan migrate
+```
+
+---
+
+## 🚀 Uso básico
+
+```php
+use Koneko\VuexyAdmin\Models\User;
+
+$user = User::create([
+ 'name' => 'Juan Pérez',
+ 'email' => 'juan@example.com',
+ 'password' => bcrypt('secret'),
+]);
+```
+
+---
+
+## 📚 Configuración adicional
+
+Si necesitas personalizar la configuración del módulo, publica el archivo de configuración:
+
+```bash
+php artisan vendor:publish --tag=vuexy-admin-config
+```
+
+Esto generará `config/vuexy_menu.php`, donde puedes modificar valores predeterminados.
+
+---
+
+## 🛠 Dependencias
+
+Este paquete requiere las siguientes dependencias:
+- Laravel 11
+- `laravel/fortify` (autenticación)
+- `spatie/laravel-permission` (gestión de roles y permisos)
+- `owen-it/laravel-auditing` (auditoría de usuarios)
+- `livewire/livewire` (interfaz dinámica)
+
+---
+
+## 📦 Publicación de Assets y Configuraciones
+
+Para publicar configuraciones y seeders:
+
+```bash
+php artisan vendor:publish --tag=vuexy-admin-config
+php artisan vendor:publish --tag=vuexy-admin-seeders
+php artisan migrate --seed
+```
+
+Para publicar imágenes del tema:
+
+```bash
+php artisan vendor:publish --tag=vuexy-admin-images
+```
+
+---
+
+## 🌍 Repositorio Principal y Sincronización
+
+Este repositorio es una **copia sincronizada** del repositorio principal alojado en **[Tea - Koneko Git](https://git.koneko.mx/koneko/laravel-vuexy-admin)**.
+
+### 🔄 Sincronización con GitHub
+- **Repositorio Principal:** [git.koneko.mx](https://git.koneko.mx/koneko/laravel-vuexy-admin)
+- **Repositorio en GitHub:** [github.com/koneko-mx/laravel-vuexy-admin](https://github.com/koneko-mx/laravel-vuexy-admin)
+- **Los cambios pueden reflejarse primero en Tea antes de GitHub.**
+
+### 🤝 Contribuciones
+Si deseas contribuir:
+1. Puedes abrir un **Issue** en [GitHub Issues](https://github.com/koneko-mx/laravel-vuexy-admin/issues).
+2. Para Pull Requests, **preferimos contribuciones en Tea**. Contacta a `admin@koneko.mx` para solicitar acceso.
+
+⚠️ **Nota:** Algunos cambios pueden tardar en reflejarse en GitHub, ya que este repositorio se actualiza automáticamente desde Tea.
+
+---
+
+## 🏅 Licencia
+
+Este paquete es de código abierto bajo la licencia [MIT](LICENSE).
+
+---
+
+
+
+ Hecho con ❤️ por Koneko Soluciones Tecnológicas
+
diff --git a/Rules/NotEmptyHtml.php b/Rules/NotEmptyHtml.php
new file mode 100644
index 0000000..885ec53
--- /dev/null
+++ b/Rules/NotEmptyHtml.php
@@ -0,0 +1,20 @@
+ [180, 180],
+ '192x192' => [192, 192],
+ '152x152' => [152, 152],
+ '120x120' => [120, 120],
+ '76x76' => [76, 76],
+ '16x16' => [16, 16],
+ ];
+
+ private $imageLogoMaxPixels1 = 22500; // Primera versión (px^2)
+ private $imageLogoMaxPixels2 = 75625; // Segunda versión (px^2)
+ private $imageLogoMaxPixels3 = 262144; // Tercera versión (px^2)
+ private $imageLogoMaxPixels4 = 230400; // Tercera versión (px^2) en Base64
+
+ protected $cacheTTL = 60 * 24 * 30; // 30 días en minutos
+
+ public function __construct()
+ {
+ $this->driver = config('image.driver', 'gd');
+ }
+
+ public function updateSetting(string $key, string $value): bool
+ {
+ $setting = Setting::updateOrCreate(
+ ['key' => $key],
+ ['value' => trim($value)]
+ );
+
+ return $setting->save();
+ }
+
+ public function processAndSaveFavicon($image): void
+ {
+ Storage::makeDirectory($this->imageDisk . '/' . $this->favicon_basePath);
+
+ // Eliminar favicons antiguos
+ $this->deleteOldFavicons();
+
+ // Guardar imagen original
+ $imageManager = new ImageManager($this->driver);
+
+ $imageName = uniqid('admin_favicon_');
+
+ $image = $imageManager->read($image->getRealPath());
+
+ foreach ($this->faviconsSizes as $size => [$width, $height]) {
+ $resizedPath = $this->favicon_basePath . $imageName . "_{$size}.png";
+
+ $image->cover($width, $height);
+
+ Storage::disk($this->imageDisk)->put($resizedPath, $image->toPng(indexed: true));
+ }
+
+ $this->updateSetting('admin_favicon_ns', $this->favicon_basePath . $imageName);
+ }
+
+ protected function deleteOldFavicons(): void
+ {
+ // Obtener el favicon actual desde la base de datos
+ $currentFavicon = Setting::where('key', 'admin_favicon_ns')->value('value');
+
+ if ($currentFavicon) {
+ $filePaths = [
+ $this->imageDisk . '/' . $currentFavicon,
+ $this->imageDisk . '/' . $currentFavicon . '_16x16.png',
+ $this->imageDisk . '/' . $currentFavicon . '_76x76.png',
+ $this->imageDisk . '/' . $currentFavicon . '_120x120.png',
+ $this->imageDisk . '/' . $currentFavicon . '_152x152.png',
+ $this->imageDisk . '/' . $currentFavicon . '_180x180.png',
+ $this->imageDisk . '/' . $currentFavicon . '_192x192.png',
+ ];
+
+ foreach ($filePaths as $filePath) {
+ if (Storage::exists($filePath)) {
+ Storage::delete($filePath);
+ }
+ }
+ }
+ }
+
+ public function processAndSaveImageLogo($image, string $type = ''): void
+ {
+ // Crear directorio si no existe
+ Storage::makeDirectory($this->imageDisk . '/' . $this->image_logo_basePath);
+
+ // Eliminar imágenes antiguas
+ $this->deleteOldImageWebapp($type);
+
+ // Leer imagen original
+ $imageManager = new ImageManager($this->driver);
+ $image = $imageManager->read($image->getRealPath());
+
+ // Generar tres versiones con diferentes áreas máximas
+ $this->generateAndSaveImage($image, $type, $this->imageLogoMaxPixels1, 'small'); // Versión 1
+ $this->generateAndSaveImage($image, $type, $this->imageLogoMaxPixels2, 'medium'); // Versión 2
+ $this->generateAndSaveImage($image, $type, $this->imageLogoMaxPixels3); // Versión 3
+ $this->generateAndSaveImageAsBase64($image, $type, $this->imageLogoMaxPixels4); // Versión 3
+ }
+
+ private function generateAndSaveImage($image, string $type, int $maxPixels, string $suffix = ''): void
+ {
+ $imageClone = clone $image;
+
+ // Escalar imagen conservando aspecto
+ $this->resizeImageToMaxPixels($imageClone, $maxPixels);
+
+ $imageName = 'admin_image_logo' . ($suffix ? '_' . $suffix : '') . ($type == 'dark' ? '_dark' : '');
+
+ // Generar nombre y ruta
+ $imageNameUid = uniqid($imageName . '_', ".png");
+ $resizedPath = $this->image_logo_basePath . $imageNameUid;
+
+ // Guardar imagen en PNG
+ Storage::disk($this->imageDisk)->put($resizedPath, $imageClone->toPng(indexed: true));
+
+ // Actualizar configuración
+ $this->updateSetting($imageName, $resizedPath);
+ }
+
+ private function resizeImageToMaxPixels($image, int $maxPixels)
+ {
+ // Obtener dimensiones originales de la imagen
+ $originalWidth = $image->width(); // Método para obtener el ancho
+ $originalHeight = $image->height(); // Método para obtener el alto
+
+ // Calcular el aspecto
+ $aspectRatio = $originalWidth / $originalHeight;
+
+ // Calcular dimensiones redimensionadas conservando aspecto
+ if ($aspectRatio > 1) { // Ancho es dominante
+ $newWidth = sqrt($maxPixels * $aspectRatio);
+ $newHeight = $newWidth / $aspectRatio;
+ } else { // Alto es dominante
+ $newHeight = sqrt($maxPixels / $aspectRatio);
+ $newWidth = $newHeight * $aspectRatio;
+ }
+
+ // Redimensionar la imagen
+ $image->resize(
+ round($newWidth), // Redondear para evitar problemas con números decimales
+ round($newHeight),
+ function ($constraint) {
+ $constraint->aspectRatio();
+ $constraint->upsize();
+ }
+ );
+
+ return $image;
+ }
+
+
+ private function generateAndSaveImageAsBase64($image, string $type, int $maxPixels): void
+ {
+ $imageClone = clone $image;
+
+ // Redimensionar imagen conservando el aspecto
+ $this->resizeImageToMaxPixels($imageClone, $maxPixels);
+
+ // Convertir a Base64
+ $base64Image = (string) $imageClone->toJpg(40)->toDataUri();
+
+ // Guardar como configuración
+ $this->updateSetting(
+ "admin_image_logo_base64" . ($type === 'dark' ? '_dark' : ''),
+ $base64Image // Ya incluye "data:image/png;base64,"
+ );
+ }
+
+ protected function deleteOldImageWebapp(string $type = ''): void
+ {
+ // Determinar prefijo según el tipo (normal o dark)
+ $suffix = $type === 'dark' ? '_dark' : '';
+
+ // Claves relacionadas con las imágenes que queremos limpiar
+ $imageKeys = [
+ "admin_image_logo{$suffix}",
+ "admin_image_logo_small{$suffix}",
+ "admin_image_logo_medium{$suffix}",
+ ];
+
+ // Recuperar las imágenes actuales en una sola consulta
+ $settings = Setting::whereIn('key', $imageKeys)->pluck('value', 'key');
+
+ foreach ($imageKeys as $key) {
+ // Obtener la imagen correspondiente
+ $currentImage = $settings[$key] ?? null;
+
+ if ($currentImage) {
+ // Construir la ruta del archivo y eliminarlo si existe
+ $filePath = $this->imageDisk . '/' . $currentImage;
+ if (Storage::exists($filePath)) {
+ Storage::delete($filePath);
+ }
+
+ // Eliminar la configuración de la base de datos
+ Setting::where('key', $key)->delete();
+ }
+ }
+ }
+}
diff --git a/Services/AdminTemplateService.php b/Services/AdminTemplateService.php
new file mode 100644
index 0000000..b1d177f
--- /dev/null
+++ b/Services/AdminTemplateService.php
@@ -0,0 +1,156 @@
+ $key],
+ ['value' => trim($value)]
+ );
+
+ return $setting->save();
+ }
+
+ public function getAdminVars($adminSetting = false): array
+ {
+ try {
+ // Verificar si el sistema está inicializado (la tabla `migrations` existe)
+ if (!Schema::hasTable('migrations')) {
+ return $this->getDefaultAdminVars($adminSetting);
+ }
+
+ // Cargar desde el caché o la base de datos si está disponible
+ return Cache::remember('admin_settings', $this->cacheTTL, function () use ($adminSetting) {
+ $settings = Setting::global()
+ ->where('key', 'LIKE', 'admin_%')
+ ->pluck('value', 'key')
+ ->toArray();
+
+ $adminSettings = $this->buildAdminVarsArray($settings);
+
+ return $adminSetting
+ ? $adminSettings[$adminSetting]
+ : $adminSettings;
+ });
+ } catch (\Exception $e) {
+ // En caso de error, devolver valores predeterminados
+ return $this->getDefaultAdminVars($adminSetting);
+ }
+ }
+
+ private function getDefaultAdminVars($adminSetting = false): array
+ {
+ $defaultSettings = [
+ 'title' => config('koneko.appTitle', 'Default Title'),
+ 'author' => config('koneko.author', 'Default Author'),
+ 'description' => config('koneko.description', 'Default Description'),
+ 'favicon' => $this->getFaviconPaths([]),
+ 'app_name' => config('koneko.appName', 'Default App Name'),
+ 'image_logo' => $this->getImageLogoPaths([]),
+ ];
+
+ return $adminSetting
+ ? $defaultSettings[$adminSetting] ?? null
+ : $defaultSettings;
+ }
+
+ private function buildAdminVarsArray(array $settings): array
+ {
+ return [
+ 'title' => $settings['admin_title'] ?? config('koneko.appTitle'),
+ 'author' => config('koneko.author'),
+ 'description' => config('koneko.description'),
+ 'favicon' => $this->getFaviconPaths($settings),
+ 'app_name' => $settings['admin_app_name'] ?? config('koneko.appName'),
+ 'image_logo' => $this->getImageLogoPaths($settings),
+ ];
+ }
+
+ public function getVuexyCustomizerVars()
+ {
+ // Obtener valores de la base de datos
+ $settings = Setting::global()
+ ->where('key', 'LIKE', 'vuexy_%')
+ ->pluck('value', 'key')
+ ->toArray();
+
+ // Obtener configuraciones predeterminadas
+ $defaultConfig = Config::get('vuexy.custom', []);
+
+ // Mezclar las configuraciones predeterminadas con las de la base de datos
+ return collect($defaultConfig)
+ ->mapWithKeys(function ($defaultValue, $key) use ($settings) {
+ $vuexyKey = 'vuexy_' . $key; // Convertir clave al formato de la base de datos
+
+ // Obtener valor desde la base de datos o usar el predeterminado
+ $value = $settings[$vuexyKey] ?? $defaultValue;
+
+ // Forzar booleanos para claves específicas
+ if (in_array($key, ['displayCustomizer', 'footerFixed', 'menuFixed', 'menuCollapsed', 'showDropdownOnHover'])) {
+ $value = filter_var($value, FILTER_VALIDATE_BOOLEAN);
+ }
+
+ return [$key => $value];
+ })
+ ->toArray();
+ }
+
+ /**
+ * Obtiene los paths de favicon en distintos tamaños.
+ */
+ private function getFaviconPaths(array $settings): array
+ {
+ $defaultFavicon = config('koneko.appFavicon');
+ $namespace = $settings['admin_favicon_ns'] ?? null;
+
+ return [
+ 'namespace' => $namespace,
+ '16x16' => $namespace ? "{$namespace}_16x16.png" : $defaultFavicon,
+ '76x76' => $namespace ? "{$namespace}_76x76.png" : $defaultFavicon,
+ '120x120' => $namespace ? "{$namespace}_120x120.png" : $defaultFavicon,
+ '152x152' => $namespace ? "{$namespace}_152x152.png" : $defaultFavicon,
+ '180x180' => $namespace ? "{$namespace}_180x180.png" : $defaultFavicon,
+ '192x192' => $namespace ? "{$namespace}_192x192.png" : $defaultFavicon,
+ ];
+ }
+
+ /**
+ * Obtiene los paths de los logos en distintos tamaños.
+ */
+ private function getImageLogoPaths(array $settings): array
+ {
+ $defaultLogo = config('koneko.appLogo');
+
+ return [
+ 'small' => $this->getImagePath($settings, 'admin_image_logo_small', $defaultLogo),
+ 'medium' => $this->getImagePath($settings, 'admin_image_logo_medium', $defaultLogo),
+ 'large' => $this->getImagePath($settings, 'admin_image_logo', $defaultLogo),
+ 'small_dark' => $this->getImagePath($settings, 'admin_image_logo_small_dark', $defaultLogo),
+ 'medium_dark' => $this->getImagePath($settings, 'admin_image_logo_medium_dark', $defaultLogo),
+ 'large_dark' => $this->getImagePath($settings, 'admin_image_logo_dark', $defaultLogo),
+ ];
+ }
+
+ /**
+ * Obtiene un path de imagen o retorna un valor predeterminado.
+ */
+ private function getImagePath(array $settings, string $key, string $default): string
+ {
+ return $settings[$key] ?? $default;
+ }
+
+ public static function clearAdminVarsCache()
+ {
+ Cache::forget("admin_settings");
+ }
+}
diff --git a/Services/AvatarImageService.php b/Services/AvatarImageService.php
new file mode 100644
index 0000000..5efc78f
--- /dev/null
+++ b/Services/AvatarImageService.php
@@ -0,0 +1,76 @@
+getRealPath())) {
+ throw new \Exception('El archivo no existe en la ruta especificada.');
+ }
+
+ if (!in_array($image_avatar->getClientOriginalExtension(), ['jpg', 'jpeg', 'png'])) {
+ throw new \Exception('El formato del archivo debe ser JPG o PNG.');
+ }
+
+ $avatarName = uniqid('avatar_') . '.png';
+ $driver = config('image.driver', 'gd');
+
+ $manager = new ImageManager($driver);
+
+ if (!Storage::disk($this->avatarDisk)->exists($this->profilePhotoDir)) {
+ Storage::disk($this->avatarDisk)->makeDirectory($this->profilePhotoDir);
+ }
+
+ $image = $manager->read($image_avatar->getRealPath());
+ $image->cover($this->avatarWidth, $this->avatarHeight);
+ Storage::disk($this->avatarDisk)->put($this->profilePhotoDir . '/' . $avatarName, $image->toPng(indexed: true));
+
+ // Eliminar avatar existente
+ $this->deleteProfilePhoto($user);
+
+ $user->forceFill([
+ 'profile_photo_path' => $avatarName,
+ ])->save();
+ }
+
+
+ /**
+ * Elimina la foto de perfil actual del usuario.
+ *
+ * @param mixed $user Objeto usuario.
+ *
+ * @return void
+ */
+ public function deleteProfilePhoto($user)
+ {
+ if (!empty($user->profile_photo_path)) {
+ Storage::disk($this->avatarDisk)->delete($user->profile_photo_path);
+
+ $user->forceFill([
+ 'profile_photo_path' => null,
+ ])->save();
+ }
+ }
+}
diff --git a/Services/AvatarInitialsService.php b/Services/AvatarInitialsService.php
new file mode 100644
index 0000000..df045eb
--- /dev/null
+++ b/Services/AvatarInitialsService.php
@@ -0,0 +1,124 @@
+getAvatarColor($name);
+ $background = ltrim(self::AVATAR_BACKGROUND, '#');
+ $size = ($this->avatarWidth + $this->avatarHeight) / 2;
+ $initials = self::getInitials($name);
+ $cacheKey = "avatar-{$initials}-{$color}-{$background}-{$size}";
+ $path = "{$this->initialAvatarDir}/{$cacheKey}.png";
+ $storagePath = storage_path("app/public/{$path}");
+
+ if (Storage::disk($this->avatarDisk)->exists($path)) {
+ return response()->file($storagePath);
+ }
+
+ $image = $this->createAvatarImage($name, $color, self::AVATAR_BACKGROUND, $size);
+ Storage::disk($this->avatarDisk)->put($path, $image->toPng(indexed: true));
+
+ return response()->file($storagePath);
+ }
+
+ /**
+ * Crea la imagen del avatar con las iniciales.
+ *
+ * @param string $name Nombre completo.
+ * @param string $color Color del texto.
+ * @param string $background Color de fondo.
+ * @param int $size Tamaño de la imagen.
+ *
+ * @return \Intervention\Image\Image La imagen generada.
+ */
+ protected function createAvatarImage($name, $color, $background, $size)
+ {
+ $driver = config('image.driver', 'gd');
+ $manager = new ImageManager($driver);
+ $initials = self::getInitials($name);
+ $fontPath = __DIR__ . '/../storage/fonts/OpenSans-Bold.ttf';
+
+ $image = $manager->create($size, $size)
+ ->fill($background);
+
+ $image->text(
+ $initials,
+ $size / 2,
+ $size / 2,
+ function (FontFactory $font) use ($color, $size, $fontPath) {
+ $font->file($fontPath);
+ $font->size($size * 0.4);
+ $font->color($color);
+ $font->align('center');
+ $font->valign('middle');
+ }
+ );
+
+ return $image;
+ }
+
+ /**
+ * Calcula las iniciales a partir del nombre.
+ *
+ * @param string $name Nombre completo.
+ *
+ * @return string Iniciales en mayúsculas.
+ */
+ public static function getInitials($name)
+ {
+ if (empty($name)) {
+ return 'NA';
+ }
+
+ $initials = implode('', array_map(function ($word) {
+ return mb_substr($word, 0, 1);
+ }, explode(' ', $name)));
+
+ return strtoupper(substr($initials, 0, self::INITIAL_MAX_LENGTH));
+ }
+
+ /**
+ * Selecciona un color basado en el nombre.
+ *
+ * @param string $name Nombre del usuario.
+ *
+ * @return string Color seleccionado.
+ */
+ public function getAvatarColor($name)
+ {
+ // Por ejemplo, se puede basar en la suma de los códigos ASCII de las letras del nombre
+ $hash = array_sum(array_map('ord', str_split($name)));
+
+ return self::AVATAR_COLORS[$hash % count(self::AVATAR_COLORS)];
+ }
+}
diff --git a/Services/CacheConfigService.php b/Services/CacheConfigService.php
new file mode 100644
index 0000000..0d16a91
--- /dev/null
+++ b/Services/CacheConfigService.php
@@ -0,0 +1,235 @@
+ $this->getCacheConfig(),
+ 'session' => $this->getSessionConfig(),
+ 'database' => $this->getDatabaseConfig(),
+ 'driver' => $this->getDriverVersion(),
+ 'memcachedInUse' => $this->isDriverInUse('memcached'),
+ 'redisInUse' => $this->isDriverInUse('redis'),
+ ];
+ }
+
+
+ private function getCacheConfig(): array
+ {
+ $cacheConfig = Config::get('cache');
+ $driver = $cacheConfig['default'];
+
+ switch ($driver) {
+ case 'redis':
+ $connection = config('database.redis.cache');
+ $cacheConfig['host'] = $connection['host'] ?? 'localhost';
+ $cacheConfig['database'] = $connection['database'] ?? 'N/A';
+ break;
+
+ case 'database':
+ $connection = config('database.connections.' . config('cache.stores.database.connection'));
+ $cacheConfig['host'] = $connection['host'] ?? 'localhost';
+ $cacheConfig['database'] = $connection['database'] ?? 'N/A';
+ break;
+
+ case 'memcached':
+ $servers = config('cache.stores.memcached.servers');
+ $cacheConfig['host'] = $servers[0]['host'] ?? 'localhost';
+ $cacheConfig['database'] = 'N/A';
+ break;
+
+ case 'file':
+ $cacheConfig['host'] = storage_path('framework/cache/data');
+ $cacheConfig['database'] = 'N/A';
+ break;
+
+ default:
+ $cacheConfig['host'] = 'N/A';
+ $cacheConfig['database'] = 'N/A';
+ break;
+ }
+
+ return $cacheConfig;
+ }
+
+ private function getSessionConfig(): array
+ {
+ $sessionConfig = Config::get('session');
+ $driver = $sessionConfig['driver'];
+
+ switch ($driver) {
+ case 'redis':
+ $connection = config('database.redis.sessions');
+ $sessionConfig['host'] = $connection['host'] ?? 'localhost';
+ $sessionConfig['database'] = $connection['database'] ?? 'N/A';
+ break;
+
+ case 'database':
+ $connection = config('database.connections.' . $sessionConfig['connection']);
+ $sessionConfig['host'] = $connection['host'] ?? 'localhost';
+ $sessionConfig['database'] = $connection['database'] ?? 'N/A';
+ break;
+
+ case 'memcached':
+ $servers = config('cache.stores.memcached.servers');
+ $sessionConfig['host'] = $servers[0]['host'] ?? 'localhost';
+ $sessionConfig['database'] = 'N/A';
+ break;
+
+ case 'file':
+ $sessionConfig['host'] = storage_path('framework/sessions');
+ $sessionConfig['database'] = 'N/A';
+ break;
+
+ default:
+ $sessionConfig['host'] = 'N/A';
+ $sessionConfig['database'] = 'N/A';
+ break;
+ }
+
+ return $sessionConfig;
+ }
+
+ private function getDatabaseConfig(): array
+ {
+ $databaseConfig = Config::get('database');
+ $connection = $databaseConfig['default'];
+
+ $connectionConfig = config('database.connections.' . $connection);
+ $databaseConfig['host'] = $connectionConfig['host'] ?? 'localhost';
+ $databaseConfig['database'] = $connectionConfig['database'] ?? 'N/A';
+
+ return $databaseConfig;
+ }
+
+
+ private function getDriverVersion(): array
+ {
+ $drivers = [];
+ $defaultDatabaseDriver = config('database.default'); // Obtén el driver predeterminado
+
+ switch ($defaultDatabaseDriver) {
+ case 'mysql':
+ case 'mariadb':
+ $drivers['mysql'] = [
+ 'version' => $this->getMySqlVersion(),
+ 'details' => config("database.connections.$defaultDatabaseDriver"),
+ ];
+
+ $drivers['mariadb'] = $drivers['mysql'];
+
+ case 'pgsql':
+ $drivers['pgsql'] = [
+ 'version' => $this->getPgSqlVersion(),
+ 'details' => config("database.connections.pgsql"),
+ ];
+ break;
+
+ case 'sqlsrv':
+ $drivers['sqlsrv'] = [
+ 'version' => $this->getSqlSrvVersion(),
+ 'details' => config("database.connections.sqlsrv"),
+ ];
+ break;
+
+ default:
+ $drivers['unknown'] = [
+ 'version' => 'No disponible',
+ 'details' => 'Driver no identificado',
+ ];
+ break;
+ }
+
+ // Opcional: Agrega detalles de Redis y Memcached si están en uso
+ if ($this->isDriverInUse('redis')) {
+ $drivers['redis'] = [
+ 'version' => $this->getRedisVersion(),
+ ];
+ }
+
+ if ($this->isDriverInUse('memcached')) {
+ $drivers['memcached'] = [
+ 'version' => $this->getMemcachedVersion(),
+ ];
+ }
+
+ return $drivers;
+ }
+
+ private function getMySqlVersion(): string
+ {
+ try {
+ $version = DB::selectOne('SELECT VERSION() as version');
+ return $version->version ?? 'No disponible';
+ } catch (\Exception $e) {
+ return 'Error: ' . $e->getMessage();
+ }
+ }
+
+ private function getPgSqlVersion(): string
+ {
+ try {
+ $version = DB::selectOne("SHOW server_version");
+ return $version->server_version ?? 'No disponible';
+ } catch (\Exception $e) {
+ return 'Error: ' . $e->getMessage();
+ }
+ }
+
+ private function getSqlSrvVersion(): string
+ {
+ try {
+ $version = DB::selectOne("SELECT @@VERSION as version");
+ return $version->version ?? 'No disponible';
+ } catch (\Exception $e) {
+ return 'Error: ' . $e->getMessage();
+ }
+ }
+
+ private function getMemcachedVersion(): string
+ {
+ try {
+ $memcached = new \Memcached();
+ $memcached->addServer(
+ Config::get('cache.stores.memcached.servers.0.host'),
+ Config::get('cache.stores.memcached.servers.0.port')
+ );
+
+ $stats = $memcached->getStats();
+ foreach ($stats as $serverStats) {
+ return $serverStats['version'] ?? 'No disponible';
+ }
+
+ return 'No disponible';
+ } catch (\Exception $e) {
+ return 'Error: ' . $e->getMessage();
+ }
+ }
+
+ private function getRedisVersion(): string
+ {
+ try {
+ $info = Redis::info();
+ return $info['redis_version'] ?? 'No disponible';
+ } catch (\Exception $e) {
+ return 'Error: ' . $e->getMessage();
+ }
+ }
+
+
+ protected function isDriverInUse(string $driver): bool
+ {
+ return in_array($driver, [
+ Config::get('cache.default'),
+ Config::get('session.driver'),
+ Config::get('queue.default'),
+ ]);
+ }
+}
diff --git a/Services/CacheManagerService.php b/Services/CacheManagerService.php
new file mode 100644
index 0000000..6db4419
--- /dev/null
+++ b/Services/CacheManagerService.php
@@ -0,0 +1,389 @@
+driver = $driver ?? config('cache.default');
+ }
+
+ /**
+ * Obtiene estadísticas de caché para el driver especificado.
+ */
+ public function getCacheStats(string $driver = null): array
+ {
+ $driver = $driver ?? $this->driver;
+
+ if (!$this->isSupportedDriver($driver)) {
+ return $this->response('warning', 'Driver no soportado o no configurado.');
+ }
+
+ try {
+ return match ($driver) {
+ 'database' => $this->_getDatabaseStats(),
+ 'file' => $this->_getFilecacheStats(),
+ 'redis' => $this->_getRedisStats(),
+ 'memcached' => $this->_getMemcachedStats(),
+ default => $this->response('info', 'No hay estadísticas disponibles para este driver.'),
+ };
+ } catch (\Exception $e) {
+ return $this->response('danger', 'Error al obtener estadísticas: ' . $e->getMessage());
+ }
+ }
+
+ public function clearCache(string $driver = null): array
+ {
+ $driver = $driver ?? $this->driver;
+
+ if (!$this->isSupportedDriver($driver)) {
+ return $this->response('warning', 'Driver no soportado o no configurado.');
+ }
+
+ try {
+ switch ($driver) {
+ case 'redis':
+ $keysCleared = $this->clearRedisCache();
+
+ return $keysCleared
+ ? $this->response('warning', 'Se ha purgado toda la caché de Redis.')
+ : $this->response('info', 'No se encontraron claves en Redis para eliminar.');
+
+ case 'memcached':
+ $keysCleared = $this->clearMemcachedCache();
+
+ return $keysCleared
+ ? $this->response('warning', 'Se ha purgado toda la caché de Memcached.')
+ : $this->response('info', 'No se encontraron claves en Memcached para eliminar.');
+
+ case 'database':
+ $rowsDeleted = $this->clearDatabaseCache();
+
+ return $rowsDeleted
+ ? $this->response('warning', 'Se ha purgado toda la caché almacenada en la base de datos.')
+ : $this->response('info', 'No se encontraron registros en la caché de la base de datos.');
+
+ case 'file':
+ $filesDeleted = $this->clearFilecache();
+
+ return $filesDeleted
+ ? $this->response('warning', 'Se ha purgado toda la caché de archivos.')
+ : $this->response('info', 'No se encontraron archivos en la caché para eliminar.');
+
+ default:
+ Cache::flush();
+
+ return $this->response('warning', 'Caché purgada.');
+ }
+ } catch (\Exception $e) {
+ return $this->response('danger', 'Error al limpiar la caché: ' . $e->getMessage());
+ }
+ }
+
+ public function getRedisStats()
+ {
+ try {
+ if (!Redis::ping()) {
+ return $this->response('warning', 'No se puede conectar con el servidor Redis.');
+ }
+
+ $info = Redis::info();
+
+ $databases = $this->getRedisDatabases();
+
+ $redisInfo = [
+ 'server' => config('database.redis.default.host'),
+ 'redis_version' => $info['redis_version'] ?? 'N/A',
+ 'os' => $info['os'] ?? 'N/A',
+ 'tcp_port' => $info['tcp_port'] ?? 'N/A',
+ 'connected_clients' => $info['connected_clients'] ?? 'N/A',
+ 'blocked_clients' => $info['blocked_clients'] ?? 'N/A',
+ 'maxmemory' => $info['maxmemory'] ?? 0,
+ 'used_memory_human' => $info['used_memory_human'] ?? 'N/A',
+ 'used_memory_peak' => $info['used_memory_peak'] ?? 'N/A',
+ 'used_memory_peak_human' => $info['used_memory_peak_human'] ?? 'N/A',
+ 'total_system_memory' => $info['total_system_memory'] ?? 0,
+ 'total_system_memory_human' => $info['total_system_memory_human'] ?? 'N/A',
+ 'maxmemory_human' => $info['maxmemory_human'] !== '0B' ? $info['maxmemory_human'] : 'Sin Límite',
+ 'total_connections_received' => number_format($info['total_connections_received']) ?? 'N/A',
+ 'total_commands_processed' => number_format($info['total_commands_processed']) ?? 'N/A',
+ 'maxmemory_policy' => $info['maxmemory_policy'] ?? 'N/A',
+ 'role' => $info['role'] ?? 'N/A',
+ 'cache_database' => '',
+ 'sessions_database' => '',
+ 'general_database' => ',',
+ 'keys' => $databases['total_keys'],
+ 'used_memory' => $info['used_memory'] ?? 0,
+ 'uptime' => gmdate('H\h i\m s\s', $info['uptime_in_seconds'] ?? 0),
+ 'databases' => $databases,
+ ];
+
+ return $this->response('success', 'Se a recargado las estadísticas de Redis.', ['info' => $redisInfo]);
+ } catch (\Exception $e) {
+ return $this->response('danger', 'Error al conectar con el servidor Redis: ' . Redis::getLastError());
+ }
+ }
+
+ public function getMemcachedStats()
+ {
+ try {
+ $memcachedStats = [];
+
+ // Crear instancia del cliente Memcached
+ $memcached = new \Memcached();
+ $memcached->addServer(config('memcached.host'), config('memcached.port'));
+
+ // Obtener estadísticas del servidor
+ $stats = $memcached->getStats();
+
+ foreach ($stats as $server => $data) {
+ $server = explode(':', $server);
+
+ $memcachedStats[] = [
+ 'server' => $server[0],
+ 'tcp_port' => $server[1],
+ 'uptime' => $data['uptime'] ?? 'N/A',
+ 'version' => $data['version'] ?? 'N/A',
+ 'libevent' => $data['libevent'] ?? 'N/A',
+ 'max_connections' => $data['max_connections'] ?? 0,
+ 'total_connections' => $data['total_connections'] ?? 0,
+ 'rejected_connections' => $data['rejected_connections'] ?? 0,
+ 'curr_items' => $data['curr_items'] ?? 0, // Claves almacenadas
+ 'bytes' => $data['bytes'] ?? 0, // Memoria usada
+ 'limit_maxbytes' => $data['limit_maxbytes'] ?? 0, // Memoria máxima
+ 'cmd_get' => $data['cmd_get'] ?? 0, // Comandos GET ejecutados
+ 'cmd_set' => $data['cmd_set'] ?? 0, // Comandos SET ejecutados
+ 'get_hits' => $data['get_hits'] ?? 0, // GET exitosos
+ 'get_misses' => $data['get_misses'] ?? 0, // GET fallidos
+ 'evictions' => $data['evictions'] ?? 0, // Claves expulsadas
+ 'bytes_read' => $data['bytes_read'] ?? 0, // Bytes leídos
+ 'bytes_written' => $data['bytes_written'] ?? 0, // Bytes escritos
+ 'total_items' => $data['total_items'] ?? 0,
+ ];
+ }
+
+ return $this->response('success', 'Se a recargado las estadísticas de Memcached.', ['info' => $memcachedStats]);
+ } catch (\Exception $e) {
+ return $this->response('danger', 'Error al conectar con el servidor Memcached: ' . $e->getMessage());
+ }
+ }
+
+
+ /**
+ * Obtiene estadísticas para caché en base de datos.
+ */
+ private function _getDatabaseStats(): array
+ {
+ try {
+ $recordCount = DB::table('cache')->count();
+ $tableInfo = DB::select("SHOW TABLE STATUS WHERE Name = 'cache'");
+
+ $memory_usage = isset($tableInfo[0]) ? $this->formatBytes($tableInfo[0]->Data_length + $tableInfo[0]->Index_length) : 'N/A';
+
+ return $this->response('success', 'Se ha recargado la información de la caché de base de datos.', ['item_count' => $recordCount, 'memory_usage' => $memory_usage]);
+ } catch (\Exception $e) {
+ return $this->response('danger', 'Error al obtener estadísticas de la base de datos: ' . $e->getMessage());
+ }
+ }
+
+ /**
+ * Obtiene estadísticas para caché en archivos.
+ */
+ private function _getFilecacheStats(): array
+ {
+ try {
+ $cachePath = config('cache.stores.file.path');
+ $files = glob($cachePath . '/*');
+
+ $memory_usage = $this->formatBytes(array_sum(array_map('filesize', $files)));
+
+ return $this->response('success', 'Se ha recargado la información de la caché de archivos.', ['item_count' => count($files), 'memory_usage' => $memory_usage]);
+ } catch (\Exception $e) {
+ return $this->response('danger', 'Error al obtener estadísticas de archivos: ' . $e->getMessage());
+ }
+ }
+
+ private function _getRedisStats()
+ {
+ try {
+ $prefix = config('cache.prefix'); // Asegúrate de agregar el sufijo correcto si es necesario
+
+ $info = Redis::info();
+ $keys = Redis::connection('cache')->keys($prefix . '*');
+
+ $memory_usage = $this->formatBytes($info['used_memory'] ?? 0);
+
+ return $this->response('success', 'Se ha recargado la información de la caché de Redis.', ['item_count' => count($keys), 'memory_usage' => $memory_usage]);
+ } catch (\Exception $e) {
+ return $this->response('danger', 'Error al obtener estadísticas de Redis: ' . $e->getMessage());
+ }
+ }
+
+ public function _getMemcachedStats(): array
+ {
+ try {
+ // Obtener estadísticas generales del servidor
+ $stats = Cache::getStore()->getMemcached()->getStats();
+
+ if (empty($stats)) {
+ return $this->response('error', 'No se pudieron obtener las estadísticas del servidor Memcached.', ['item_count' => 0, 'memory_usage' => 0]);
+ }
+
+ // Usar el primer servidor configurado (en la mayoría de los casos hay uno)
+ $serverStats = array_shift($stats);
+
+ return $this->response(
+ 'success',
+ 'Estadísticas del servidor Memcached obtenidas correctamente.',
+ [
+ 'item_count' => $serverStats['curr_items'] ?? 0, // Número total de claves
+ 'memory_usage' => $this->formatBytes($serverStats['bytes'] ?? 0), // Memoria usada
+ 'max_memory' => $this->formatBytes($serverStats['limit_maxbytes'] ?? 0), // Memoria máxima asignada
+ ]
+ );
+ } catch (\Exception $e) {
+ return $this->response('danger', 'Error al obtener estadísticas de Memcached: ' . $e->getMessage());
+ }
+ }
+
+ private function getRedisDatabases(): array
+ {
+ // Verificar si Redis está en uso
+ $isRedisUsed = collect([
+ config('cache.default'),
+ config('session.driver'),
+ config('queue.default'),
+ ])->contains('redis');
+
+ if (!$isRedisUsed) {
+ return []; // Si Redis no está en uso, devolver un arreglo vacío
+ }
+
+ // Configuraciones de bases de datos de Redis según su uso
+ $databases = [
+ 'default' => config('database.redis.default.database', 0), // REDIS_DB
+ 'cache' => config('database.redis.cache.database', 0), // REDIS_CACHE_DB
+ 'sessions' => config('database.redis.sessions.database', 0), // REDIS_SESSION_DB
+ ];
+
+ $result = [];
+ $totalKeys = 0;
+
+ // Recorrer solo las bases configuradas y activas
+ foreach ($databases as $type => $db) {
+ Redis::select($db); // Seleccionar la base de datos
+
+ $keys = Redis::dbsize(); // Contar las claves en la base
+
+ if ($keys > 0) {
+ $result[$type] = [
+ 'database' => $db,
+ 'keys' => $keys,
+ ];
+
+ $totalKeys += $keys;
+ }
+ }
+
+ if (!empty($result)) {
+ $result['total_keys'] = $totalKeys;
+ }
+
+ return $result;
+ }
+
+
+ private function clearDatabaseCache(): bool
+ {
+ $count = DB::table(config('cache.stores.database.table'))->count();
+
+ if ($count > 0) {
+ DB::table(config('cache.stores.database.table'))->truncate();
+ return true;
+ }
+
+ return false;
+ }
+
+ private function clearFilecache(): bool
+ {
+ $cachePath = config('cache.stores.file.path');
+ $files = glob($cachePath . '/*');
+
+ if (!empty($files)) {
+ File::deleteDirectory($cachePath);
+ return true;
+ }
+
+ return false;
+ }
+
+ private function clearRedisCache(): bool
+ {
+ $prefix = config('cache.prefix', '');
+ $keys = Redis::connection('cache')->keys($prefix . '*');
+
+ if (!empty($keys)) {
+ Redis::connection('cache')->flushdb();
+
+ // Simulate cache clearing delay
+ sleep(1);
+
+ return true;
+ }
+
+ return false;
+ }
+
+ private function clearMemcachedCache(): bool
+ {
+ // Obtener el cliente Memcached directamente
+ $memcached = Cache::store('memcached')->getStore()->getMemcached();
+
+ // Ejecutar flush para eliminar todo
+ if ($memcached->flush()) {
+ // Simulate cache clearing delay
+ sleep(1);
+
+ return true;
+ }
+
+ return false;
+ }
+
+
+ /**
+ * Verifica si un driver es soportado.
+ */
+ private function isSupportedDriver(string $driver): bool
+ {
+ return in_array($driver, ['redis', 'memcached', 'database', 'file']);
+ }
+
+ /**
+ * Convierte bytes en un formato legible.
+ */
+ private function formatBytes($bytes)
+ {
+ $sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
+ $factor = floor((strlen($bytes) - 1) / 3);
+
+ return sprintf('%.2f', $bytes / pow(1024, $factor)) . ' ' . $sizes[$factor];
+ }
+
+ /**
+ * Genera una respuesta estandarizada.
+ */
+ private function response(string $status, string $message, array $data = []): array
+ {
+ return array_merge(compact('status', 'message'), $data);
+ }
+}
diff --git a/Services/GlobalSettingsService.php b/Services/GlobalSettingsService.php
new file mode 100644
index 0000000..404fd66
--- /dev/null
+++ b/Services/GlobalSettingsService.php
@@ -0,0 +1,225 @@
+ $key],
+ ['value' => trim($value)]
+ );
+
+ return $setting->save();
+ }
+
+ /**
+ * Carga y sobrescribe las configuraciones del sistema.
+ */
+ public function loadSystemConfig(): void
+ {
+ try {
+ if (!Schema::hasTable('migrations')) {
+ // Base de datos no inicializada: usar valores predeterminados
+ $config = $this->getDefaultSystemConfig();
+ } else {
+ // Cargar configuración desde la caché o base de datos
+ $config = Cache::remember('global_system_config', $this->cacheTTL, function () {
+ $settings = Setting::global()
+ ->where('key', 'LIKE', 'config.%')
+ ->pluck('value', 'key')
+ ->toArray();
+
+ return [
+ 'servicesFacebook' => $this->buildServiceConfig($settings, 'config.services.facebook.', 'services.facebook'),
+ 'servicesGoogle' => $this->buildServiceConfig($settings, 'config.services.google.', 'services.google'),
+ 'vuexy' => $this->buildVuexyConfig($settings),
+ ];
+ });
+ }
+
+ // Aplicar configuración al sistema
+ Config::set('services.facebook', $config['servicesFacebook']);
+ Config::set('services.google', $config['servicesGoogle']);
+ Config::set('vuexy', $config['vuexy']);
+ } catch (\Exception $e) {
+ // Manejo silencioso de errores para evitar interrupciones
+ Config::set('services.facebook', config('services.facebook', []));
+ Config::set('services.google', config('services.google', []));
+ Config::set('vuexy', config('vuexy', []));
+ }
+ }
+
+ /**
+ * Devuelve una configuración predeterminada si la base de datos no está inicializada.
+ */
+ private function getDefaultSystemConfig(): array
+ {
+ return [
+ 'servicesFacebook' => config('services.facebook', [
+ 'client_id' => '',
+ 'client_secret' => '',
+ 'redirect' => '',
+ ]),
+ 'servicesGoogle' => config('services.google', [
+ 'client_id' => '',
+ 'client_secret' => '',
+ 'redirect' => '',
+ ]),
+ 'vuexy' => config('vuexy', []),
+ ];
+ }
+
+ /**
+ * Verifica si un bloque de configuraciones está presente.
+ */
+ protected function hasBlockConfig(array $settings, string $blockPrefix): bool
+ {
+ return array_key_exists($blockPrefix, array_filter($settings, fn($key) => str_starts_with($key, $blockPrefix), ARRAY_FILTER_USE_KEY));
+ }
+
+ /**
+ * Construye la configuración de un servicio (Facebook, Google, etc.).
+ */
+ protected function buildServiceConfig(array $settings, string $blockPrefix, string $defaultConfigKey): array
+ {
+ if (!$this->hasBlockConfig($settings, $blockPrefix)) {
+ return [];
+ return config($defaultConfigKey);
+ }
+
+ return [
+ 'client_id' => $settings["{$blockPrefix}client_id"] ?? '',
+ 'client_secret' => $settings["{$blockPrefix}client_secret"] ?? '',
+ 'redirect' => $settings["{$blockPrefix}redirect"] ?? '',
+ ];
+ }
+
+ /**
+ * Construye la configuración personalizada de Vuexy.
+ */
+ protected function buildVuexyConfig(array $settings): array
+ {
+ // Configuración predeterminada del sistema
+ $defaultVuexyConfig = config('vuexy', []);
+
+ // Convertimos las claves planas a un array multidimensional
+ $settingsNested = Arr::undot($settings);
+
+ // Navegamos hasta la parte relevante del array desanidado
+ $vuexySettings = $settingsNested['config']['vuexy'] ?? [];
+
+ // Fusionamos la configuración predeterminada con los valores del sistema
+ $mergedConfig = array_replace_recursive($defaultVuexyConfig, $vuexySettings);
+
+ // Normalizamos los valores booleanos
+ return $this->normalizeBooleanFields($mergedConfig);
+ }
+
+ /**
+ * Normaliza los campos booleanos.
+ */
+ protected function normalizeBooleanFields(array $config): array
+ {
+ $booleanFields = [
+ 'myRTLSupport',
+ 'myRTLMode',
+ 'hasCustomizer',
+ 'displayCustomizer',
+ 'footerFixed',
+ 'menuFixed',
+ 'menuCollapsed',
+ 'showDropdownOnHover',
+ ];
+
+ foreach ($booleanFields as $field) {
+ if (isset($config['vuexy'][$field])) {
+ $config['vuexy'][$field] = (bool) $config['vuexy'][$field];
+ }
+ }
+
+ return $config;
+ }
+
+ /**
+ * Limpia el caché de la configuración del sistema.
+ */
+ public static function clearSystemConfigCache(): void
+ {
+ Cache::forget('global_system_config');
+ }
+
+ /**
+ * Elimina las claves config.vuexy.* y limpia global_system_config
+ */
+ public static function clearVuexyConfig(): void
+ {
+ Setting::where('key', 'LIKE', 'config.vuexy.%')->delete();
+ Cache::forget('global_system_config');
+ }
+
+ /**
+ * Obtiene y sobrescribe la configuración de correo electrónico.
+ */
+ public function getMailSystemConfig(): array
+ {
+ return Cache::remember('mail_system_config', $this->cacheTTL, function () {
+ $settings = Setting::global()
+ ->where('key', 'LIKE', 'mail.%')
+ ->pluck('value', 'key')
+ ->toArray();
+
+ $defaultMailersSmtpVars = config('mail.mailers.smtp');
+
+ return [
+ 'mailers' => [
+ 'smtp' => array_merge($defaultMailersSmtpVars, [
+ 'url' => $settings['mail.mailers.smtp.url'] ?? $defaultMailersSmtpVars['url'],
+ 'host' => $settings['mail.mailers.smtp.host'] ?? $defaultMailersSmtpVars['host'],
+ 'port' => $settings['mail.mailers.smtp.port'] ?? $defaultMailersSmtpVars['port'],
+ 'encryption' => $settings['mail.mailers.smtp.encryption'] ?? 'TLS',
+ 'username' => $settings['mail.mailers.smtp.username'] ?? $defaultMailersSmtpVars['username'],
+ 'password' => isset($settings['mail.mailers.smtp.password']) && !empty($settings['mail.mailers.smtp.password'])
+ ? Crypt::decryptString($settings['mail.mailers.smtp.password'])
+ : $defaultMailersSmtpVars['password'],
+ 'timeout' => $settings['mail.mailers.smtp.timeout'] ?? $defaultMailersSmtpVars['timeout'],
+ ]),
+ ],
+ 'from' => [
+ 'address' => $settings['mail.from.address'] ?? config('mail.from.address'),
+ 'name' => $settings['mail.from.name'] ?? config('mail.from.name'),
+ ],
+ 'reply_to' => [
+ 'method' => $settings['mail.reply_to.method'] ?? config('mail.reply_to.method'),
+ 'email' => $settings['mail.reply_to.email'] ?? config('mail.reply_to.email'),
+ 'name' => $settings['mail.reply_to.name'] ?? config('mail.reply_to.name'),
+ ],
+ ];
+ });
+ }
+
+ /**
+ * Limpia el caché de la configuración de correo electrónico.
+ */
+ public static function clearMailSystemConfigCache(): void
+ {
+ Cache::forget('mail_system_config');
+ }
+
+}
diff --git a/Services/RBACService.php b/Services/RBACService.php
new file mode 100644
index 0000000..f38db6b
--- /dev/null
+++ b/Services/RBACService.php
@@ -0,0 +1,28 @@
+ $perm]);
+ }
+
+ foreach ($rbacData['roles'] as $name => $role) {
+ $roleInstance = Role::updateOrCreate(['name' => $name, 'style' => $role['style']]);
+ $roleInstance->syncPermissions($role['permissions']);
+ }
+ }
+}
diff --git a/Services/SessionManagerService.php b/Services/SessionManagerService.php
new file mode 100644
index 0000000..d57d05f
--- /dev/null
+++ b/Services/SessionManagerService.php
@@ -0,0 +1,153 @@
+driver = $driver ?? config('session.driver');
+ }
+
+ public function getSessionStats(string $driver = null): array
+ {
+ $driver = $driver ?? $this->driver;
+
+ if (!$this->isSupportedDriver($driver))
+ return $this->response('warning', 'Driver no soportado o no configurado.', ['session_count' => 0]);
+
+ try {
+ switch ($driver) {
+ case 'redis':
+ return $this->getRedisStats();
+
+ case 'database':
+ return $this->getDatabaseStats();
+
+ case 'file':
+ return $this->getFileStats();
+
+ default:
+ return $this->response('warning', 'Driver no reconocido.', ['session_count' => 0]);
+ }
+ } catch (\Exception $e) {
+ return $this->response('danger', 'Error al obtener estadísticas: ' . $e->getMessage(), ['session_count' => 0]);
+ }
+ }
+
+ public function clearSessions(string $driver = null): array
+ {
+ $driver = $driver ?? $this->driver;
+
+ if (!$this->isSupportedDriver($driver)) {
+ return $this->response('warning', 'Driver no soportado o no configurado.');
+ }
+
+ try {
+ switch ($driver) {
+ case 'redis':
+ return $this->clearRedisSessions();
+
+ case 'memcached':
+ Cache::getStore()->flush();
+ return $this->response('success', 'Se eliminó la memoria caché de sesiones en Memcached.');
+
+ case 'database':
+ DB::table('sessions')->truncate();
+ return $this->response('success', 'Se eliminó la memoria caché de sesiones en la base de datos.');
+
+ case 'file':
+ return $this->clearFileSessions();
+
+ default:
+ return $this->response('warning', 'Driver no reconocido.');
+ }
+ } catch (\Exception $e) {
+ return $this->response('danger', 'Error al limpiar las sesiones: ' . $e->getMessage());
+ }
+ }
+
+
+ private function getRedisStats()
+ {
+ $prefix = config('cache.prefix'); // Asegúrate de agregar el sufijo correcto si es necesario
+ $keys = Redis::connection('sessions')->keys($prefix . '*');
+
+ return $this->response('success', 'Se ha recargado la información de la caché de Redis.', ['session_count' => count($keys)]);
+ }
+
+ private function getDatabaseStats(): array
+ {
+ $sessionCount = DB::table('sessions')->count();
+
+ return $this->response('success', 'Se ha recargado la información de la base de datos.', ['session_count' => $sessionCount]);
+ }
+
+ private function getFileStats(): array
+ {
+ $cachePath = config('session.files');
+ $files = glob($cachePath . '/*');
+
+ return $this->response('success', 'Se ha recargado la información de sesiones de archivos.', ['session_count' => count($files)]);
+ }
+
+
+ /**
+ * Limpia sesiones en Redis.
+ */
+ private function clearRedisSessions(): array
+ {
+ $prefix = config('cache.prefix', '');
+ $keys = Redis::connection('sessions')->keys($prefix . '*');
+
+ if (!empty($keys)) {
+ Redis::connection('sessions')->flushdb();
+
+ // Simulate cache clearing delay
+ sleep(1);
+
+ return $this->response('success', 'Se eliminó la memoria caché de sesiones en Redis.');
+ }
+
+ return $this->response('info', 'No se encontraron claves para eliminar en Redis.');
+ }
+
+ /**
+ * Limpia sesiones en archivos.
+ */
+ private function clearFileSessions(): array
+ {
+ $cachePath = config('session.files');
+ $files = glob($cachePath . '/*');
+
+ if (!empty($files)) {
+ foreach ($files as $file) {
+ unlink($file);
+ }
+
+ return $this->response('success', 'Se eliminó la memoria caché de sesiones en archivos.');
+ }
+
+ return $this->response('info', 'No se encontraron sesiones en archivos para eliminar.');
+ }
+
+
+ private function isSupportedDriver(string $driver): bool
+ {
+ return in_array($driver, ['redis', 'memcached', 'database', 'file']);
+ }
+
+ /**
+ * Genera una respuesta estandarizada.
+ */
+ private function response(string $status, string $message, array $data = []): array
+ {
+ return array_merge(compact('status', 'message'), $data);
+ }
+}
diff --git a/Services/VuexyAdminService.php b/Services/VuexyAdminService.php
new file mode 100644
index 0000000..64d3982
--- /dev/null
+++ b/Services/VuexyAdminService.php
@@ -0,0 +1,623 @@
+ 'Inicio',
+ 'route' => 'admin.core.home.index',
+ ];
+
+ private $user;
+
+ public function __construct()
+ {
+ $this->user = Auth::user();
+ $this->vuexySearch = Auth::user() !== null;
+ $this->orientation = config('vuexy.custom.myLayout');
+ }
+
+ /**
+ * Obtiene el menú según el estado del usuario (autenticado o no).
+ */
+ public function getMenu()
+ {
+ // Obtener el menú desde la caché
+ $menu = $this->user === null
+ ? $this->getGuestMenu()
+ : $this->getUserMenu();
+
+ // Marcar la ruta actual como activa
+ $currentRoute = Route::currentRouteName();
+
+ return $this->markActive($menu, $currentRoute);
+ }
+
+ /**
+ * Menú para usuarios no autenticados.dump
+ */
+ private function getGuestMenu()
+ {
+ return Cache::remember('vuexy_menu_guest', now()->addDays(7), function () {
+ return $this->getMenuArray();
+ });
+ }
+
+ /**
+ * Menú para usuarios autenticados.
+ */
+ private function getUserMenu()
+ {
+ Cache::forget("vuexy_menu_user_{$this->user->id}"); // Borrar la caché anterior para actualizarla
+
+ return Cache::remember("vuexy_menu_user_{$this->user->id}", now()->addHours(24), function () {
+ return $this->getMenuArray();
+ });
+ }
+
+ private function markActive($menu, $currentRoute)
+ {
+ foreach ($menu as &$item) {
+ $item['active'] = false;
+
+ // Check if the route matches
+ if (isset($item['route']) && $item['route'] === $currentRoute)
+ $item['active'] = true;
+
+ // Process submenus recursively
+ if (isset($item['submenu']) && !empty($item['submenu'])) {
+ $item['submenu'] = $this->markActive($item['submenu'], $currentRoute);
+
+ // If any submenu is active, mark the parent as active
+ if (collect($item['submenu'])->contains('active', true))
+ $item['active'] = true;
+ }
+ }
+
+ return $menu;
+ }
+
+ /**
+ * Invalida el cache del menú de un usuario.
+ */
+ public static function clearUserMenuCache()
+ {
+ $user = Auth::user();
+
+ if ($user !== null)
+ Cache::forget("vuexy_menu_user_{$user->id}");
+ }
+
+ /**
+ * Invalida el cache del menú de invitados.
+ */
+ public static function clearGuestMenuCache()
+ {
+ Cache::forget('vuexy_menu_guest');
+ }
+
+
+
+
+ public function getSearch()
+ {
+ return $this->vuexySearch;
+ }
+
+ public function getVuexySearchData()
+ {
+ if ($this->user === null)
+ return null;
+
+ $pages = Cache::remember("vuexy_search_user_{$this->user->id}", now()->addDays(7), function () {
+ return $this->cacheVuexySearchData();
+ });
+
+ // Formatear como JSON esperado
+ return [
+ 'pages' => $pages,
+ ];
+ }
+
+ private function cacheVuexySearchData()
+ {
+ $originalMenu = $this->getUserMenu();
+
+ return $this->getPagesSearchMenu($originalMenu);
+ }
+
+ private function getPagesSearchMenu(array $menu, string $parentPath = '')
+ {
+ $formattedMenu = [];
+
+ foreach ($menu as $name => $item) {
+ // Construir la ruta jerárquica (menu / submenu / submenu)
+ $currentPath = $parentPath ? $parentPath . ' / ' . $name : $name;
+
+ // Verificar si el elemento tiene una URL o una ruta
+ $url = $item['url'] ?? (isset($item['route']) && route::has($item['route']) ? route($item['route']) : null);
+
+ // Agregar el elemento al menú formateado
+ if ($url) {
+ $formattedMenu[] = [
+ 'name' => $currentPath, // Usar la ruta completa
+ 'icon' => $item['icon'] ?? 'ti ti-point',
+ 'url' => $url,
+ ];
+ }
+
+ // Si hay un submenú, procesarlo recursivamente
+ if (isset($item['submenu']) && is_array($item['submenu'])) {
+ $formattedMenu = array_merge(
+ $formattedMenu,
+ $this->getPagesSearchMenu($item['submenu'], $currentPath) // Pasar el path acumulado
+ );
+ }
+ }
+
+ return $formattedMenu;
+ }
+
+ public static function clearSearchMenuCache()
+ {
+ $user = Auth::user();
+
+ if ($user !== null)
+ Cache::forget("vuexy_search_user_{$user->id}");
+ }
+
+
+
+
+ public function getQuickLinks()
+ {
+ if ($this->user === null)
+ return null;
+
+ // Recuperar enlaces desde la caché
+ $quickLinks = Cache::remember("vuexy_quick_links_user_{$this->user->id}", now()->addDays(7), function () {
+ return $this->cacheQuickLinks();
+ });
+
+ // Verificar si la ruta actual está en la lista
+ $currentRoute = Route::currentRouteName();
+ $currentPageInList = $this->isCurrentPageInList($quickLinks, $currentRoute);
+
+ // Agregar la verificación al resultado
+ $quickLinks['current_page_in_list'] = $currentPageInList;
+
+ return $quickLinks;
+ }
+
+ private function cacheQuickLinks()
+ {
+ $originalMenu = $this->getUserMenu();
+
+ $quickLinks = [];
+
+ $quicklinks = Setting::where('user_id', Auth::user()->id)
+ ->where('key', 'quicklinks')
+ ->first();
+
+ $this->quicklinksRouteNames = $quicklinks ? json_decode($quicklinks->value, true) : [];
+
+ // Ordenar y generar los quickLinks según el orden del menú
+ $this->collectQuickLinksFromMenu($originalMenu, $quickLinks);
+
+ $quickLinksData = [
+ 'totalLinks' => count($quickLinks),
+ 'rows' => array_chunk($quickLinks, 2), // Agrupar los atajos en filas de dos
+ ];
+
+ return $quickLinksData;
+ }
+
+ private function collectQuickLinksFromMenu(array $menu, array &$quickLinks, string $parentTitle = null)
+ {
+ foreach ($menu as $title => $item) {
+ // Verificar si el elemento está en la lista de quicklinksRouteNames
+ if (isset($item['route']) && in_array($item['route'], $this->quicklinksRouteNames)) {
+ $quickLinks[] = [
+ 'title' => $title,
+ 'subtitle' => $parentTitle ?? env('APP_NAME'),
+ 'icon' => $item['icon'] ?? 'ti ti-point',
+ 'url' => isset($item['route']) ? route($item['route']) : ($item['url'] ?? '#'),
+ 'route' => $item['route'],
+ ];
+ }
+
+ // Si tiene submenú, procesarlo recursivamente
+ if (isset($item['submenu']) && is_array($item['submenu'])) {
+ $this->collectQuickLinksFromMenu(
+ $item['submenu'],
+ $quickLinks,
+ $title // Pasar el título actual como subtítulo
+ );
+ }
+ }
+ }
+
+ /**
+ * Verifica si la ruta actual existe en la lista de enlaces.
+ */
+ private function isCurrentPageInList(array $quickLinks, string $currentRoute): bool
+ {
+ foreach ($quickLinks['rows'] as $row) {
+ foreach ($row as $link) {
+ if (isset($link['route']) && $link['route'] === $currentRoute) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ public static function clearQuickLinksCache()
+ {
+ $user = Auth::user();
+
+ if ($user !== null)
+ Cache::forget("vuexy_quick_links_user_{$user->id}");
+ }
+
+
+
+
+ public function getNotifications()
+ {
+ if ($this->user === null)
+ return null;
+
+ return Cache::remember("vuexy_notifications_user_{$this->user->id}", now()->addHours(4), function () {
+ return $this->cacheNotifications();
+ });
+ }
+
+ private function cacheNotifications()
+ {
+ return "
+
+
+
+
+
+
+
+ ";
+ }
+
+ public static function clearNotificationsCache()
+ {
+ $user = Auth::user();
+
+ if ($user !== null)
+ Cache::forget("vuexy_notifications_user_{$user->id}");
+ }
+
+
+
+
+ public function getBreadcrumbs()
+ {
+ $originalMenu = $this->user === null
+ ? $this->getGuestMenu()
+ : $this->getUserMenu();
+
+ // Lógica para construir los breadcrumbs
+ $breadcrumbs = $this->findBreadcrumbTrail($originalMenu);
+
+ // Asegurar que el primer elemento siempre sea "Inicio"
+ array_unshift($breadcrumbs, $this->homeRoute);
+
+ return $breadcrumbs;
+ }
+
+ private function findBreadcrumbTrail(array $menu, array $breadcrumbs = []): array
+ {
+ foreach ($menu as $title => $item) {
+ $skipBreadcrumb = isset($item['breadcrumbs']) && $item['breadcrumbs'] === false;
+
+ $itemRoute = isset($item['route']) ? implode('.', array_slice(explode('.', $item['route']), 0, -1)): '';
+ $currentRoute = implode('.', array_slice(explode('.', Route::currentRouteName()), 0, -1));
+
+ if ($itemRoute === $currentRoute) {
+ if (!$skipBreadcrumb) {
+ $breadcrumbs[] = [
+ 'name' => $title,
+ 'active' => true,
+ ];
+ }
+
+ return $breadcrumbs;
+ }
+
+ if (isset($item['submenu']) && is_array($item['submenu'])) {
+ $newBreadcrumbs = $breadcrumbs;
+
+ if (!$skipBreadcrumb)
+ $newBreadcrumbs[] = [
+ 'name' => $title,
+ 'route' => $item['route'] ?? null,
+ ];
+
+ $found = $this->findBreadcrumbTrail($item['submenu'], $newBreadcrumbs);
+
+ if ($found)
+ return $found;
+ }
+ }
+
+ return [];
+ }
+
+
+
+
+ private function getMenuArray()
+ {
+ $configMenu = config('vuexy_menu');
+
+ return $this->filterMenu($configMenu);
+ }
+
+ private function filterMenu(array $menu)
+ {
+ $filteredMenu = [];
+
+ foreach ($menu as $key => $item) {
+ // Evaluar permisos con Spatie y eliminar elementos no autorizados
+ if (isset($item['can']) && !$this->userCan($item['can'])) {
+ continue;
+ }
+
+ if (isset($item['canNot']) && $this->userCannot($item['canNot'])) {
+ continue;
+ }
+
+ // Si tiene un submenú, filtrarlo recursivamente
+ if (isset($item['submenu'])) {
+ $item['submenu'] = $this->filterMenu($item['submenu']);
+
+ // Si el submenú queda vacío, eliminar el menú
+ if (empty($item['submenu'])) {
+ continue;
+ }
+ }
+
+ // Removemos los atributos 'can' y 'canNot' del resultado final
+ unset($item['can'], $item['canNot']);
+
+ if(isset($item['route']) && route::has($item['route'])){
+ $item['url'] = route($item['route'])?? '';
+ }
+
+ // Agregar elemento filtrado al menú resultante
+ $filteredMenu[$key] = $item;
+ }
+
+ return $filteredMenu;
+ }
+
+ private function userCan($permissions)
+ {
+ if (is_array($permissions)) {
+ foreach ($permissions as $permission) {
+ if (Gate::allows($permission)) {
+ return true; // Si tiene al menos un permiso, lo mostramos
+ }
+ }
+ return true;
+ }
+
+ return Gate::allows($permissions);
+ }
+
+ private function userCannot($permissions)
+ {
+ if (is_array($permissions)) {
+ foreach ($permissions as $permission) {
+ if (Gate::denies($permission)) {
+ return true; // Si se le ha denegado al menos un permiso, lo ocultamos
+ }
+ }
+ return false;
+ }
+
+ return Gate::denies($permissions);
+ }
+}
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..7fe74eb
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,41 @@
+{
+ "name": "koneko/laravel-vuexy-admin",
+ "description": "Laravel Vuexy Admin, un modulo de administracion optimizado para México.",
+ "keywords": ["laravel", "koneko", "framework", "vuexy", "admin", "mexico"],
+ "type": "library",
+ "license": "MIT",
+ "require": {
+ "php": "^8.2",
+ "intervention/image-laravel": "^1.4",
+ "laravel/framework": "^11.31",
+ "laravel/fortify": "^1.25",
+ "laravel/sanctum": "^4.0",
+ "livewire/livewire": "^3.5",
+ "owen-it/laravel-auditing": "^13.6",
+ "spatie/laravel-permission": "^6.10"
+ },
+ "autoload": {
+ "psr-4": {
+ "Koneko\\VuexyAdmin\\": ""
+ }
+ },
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Koneko\\VuexyAdmin\\Providers\\VuexyAdminServiceProvider"
+ ]
+ }
+ },
+ "authors": [
+ {
+ "name": "Arturo Corro Pacheco",
+ "email": "arturo@koneko.mx"
+ }
+ ],
+ "support": {
+ "source": "https://github.com/koneko-mx/laravel-vuexy-admin",
+ "issues": "https://github.com/koneko-mx/laravel-vuexy-admin/issues"
+ },
+ "minimum-stability": "stable",
+ "prefer-stable": true
+}
diff --git a/config/fortify.php b/config/fortify.php
new file mode 100644
index 0000000..9aec61d
--- /dev/null
+++ b/config/fortify.php
@@ -0,0 +1,159 @@
+ 'web',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Fortify Password Broker
+ |--------------------------------------------------------------------------
+ |
+ | Here you may specify which password broker Fortify can use when a user
+ | is resetting their password. This configured value should match one
+ | of your password brokers setup in your "auth" configuration file.
+ |
+ */
+
+ 'passwords' => 'users',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Username / Email
+ |--------------------------------------------------------------------------
+ |
+ | This value defines which model attribute should be considered as your
+ | application's "username" field. Typically, this might be the email
+ | address of the users but you are free to change this value here.
+ |
+ | Out of the box, Fortify expects forgot password and reset password
+ | requests to have a field named 'email'. If the application uses
+ | another name for the field you may define it below as needed.
+ |
+ */
+
+ 'username' => 'email',
+
+ 'email' => 'email',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Lowercase Usernames
+ |--------------------------------------------------------------------------
+ |
+ | This value defines whether usernames should be lowercased before saving
+ | them in the database, as some database system string fields are case
+ | sensitive. You may disable this for your application if necessary.
+ |
+ */
+
+ 'lowercase_usernames' => true,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Home Path
+ |--------------------------------------------------------------------------
+ |
+ | Here you may configure the path where users will get redirected during
+ | authentication or password reset when the operations are successful
+ | and the user is authenticated. You are free to change this value.
+ |
+ */
+
+ 'home' => '/admin',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Fortify Routes Prefix / Subdomain
+ |--------------------------------------------------------------------------
+ |
+ | Here you may specify which prefix Fortify will assign to all the routes
+ | that it registers with the application. If necessary, you may change
+ | subdomain under which all of the Fortify routes will be available.
+ |
+ */
+
+ 'prefix' => '',
+
+ 'domain' => null,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Fortify Routes Middleware
+ |--------------------------------------------------------------------------
+ |
+ | Here you may specify which middleware Fortify will assign to the routes
+ | that it registers with the application. If necessary, you may change
+ | these middleware but typically this provided default is preferred.
+ |
+ */
+
+ 'middleware' => ['web'],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Rate Limiting
+ |--------------------------------------------------------------------------
+ |
+ | By default, Fortify will throttle logins to five requests per minute for
+ | every email and IP address combination. However, if you would like to
+ | specify a custom rate limiter to call then you may specify it here.
+ |
+ */
+
+ 'limiters' => [
+ 'login' => 'login',
+ 'two-factor' => 'two-factor',
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Register View Routes
+ |--------------------------------------------------------------------------
+ |
+ | Here you may specify if the routes returning views should be disabled as
+ | you may not need them when building your own application. This may be
+ | especially true if you're writing a custom single-page application.
+ |
+ */
+
+ 'views' => true,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Features
+ |--------------------------------------------------------------------------
+ |
+ | Some of the Fortify features are optional. You may disable the features
+ | by removing them from this array. You're free to only remove some of
+ | these features or you can even remove all of these if you need to.
+ |
+ */
+
+ 'features' => [
+ Features::registration(),
+ Features::resetPasswords(),
+ Features::emailVerification(),
+ Features::updateProfileInformation(),
+ Features::updatePasswords(),
+ Features::twoFactorAuthentication([
+ 'confirm' => true,
+ 'confirmPassword' => true,
+ 'window' => 1,
+ ]),
+ ],
+
+];
diff --git a/config/image.php b/config/image.php
new file mode 100644
index 0000000..c6c0ebb
--- /dev/null
+++ b/config/image.php
@@ -0,0 +1,42 @@
+ \Intervention\Image\Drivers\Imagick\Driver::class,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Configuration Options
+ |--------------------------------------------------------------------------
+ |
+ | These options control the behavior of Intervention Image.
+ |
+ | - "autoOrientation" controls whether an imported image should be
+ | automatically rotated according to any existing Exif data.
+ |
+ | - "decodeAnimation" decides whether a possibly animated image is
+ | decoded as such or whether the animation is discarded.
+ |
+ | - "blendingColor" Defines the default blending color.
+ */
+
+ 'options' => [
+ 'autoOrientation' => true,
+ 'decodeAnimation' => true,
+ 'blendingColor' => 'ffffff',
+ ]
+];
diff --git a/config/koneko.php b/config/koneko.php
new file mode 100644
index 0000000..666842c
--- /dev/null
+++ b/config/koneko.php
@@ -0,0 +1,14 @@
+ "koneko.mx",
+ "appTitle" => "Koneko Soluciones Tecnológicas",
+ "appDescription" => "Koneko Soluciones Tecnológicas",
+ "appLogo" => "../vendor/vuexy-admin/img/logo/koneko-04.png",
+ "appFavicon" => "../vendor/vuexy-admin/img/logo/koneko-04.png",
+ "author" => "arturo@koneko.mx",
+ "creatorName" => "Koneko Soluciones Tecnológicas",
+ "creatorUrl" => "https://koneko.mx",
+ "licenseUrl" => "https://koneko.mx/koneko-admin/licencia",
+ "supportUrl" => "https://koneko.mx/soporte",
+];
diff --git a/config/vuexy.php b/config/vuexy.php
new file mode 100644
index 0000000..ab4980f
--- /dev/null
+++ b/config/vuexy.php
@@ -0,0 +1,36 @@
+ [
+ 'myLayout' => 'horizontal', // Options[String]: vertical(default), horizontal
+ 'myTheme' => 'theme-semi-dark', // Options[String]: theme-default(default), theme-bordered, theme-semi-dark
+ 'myStyle' => 'light', // Options[String]: light(default), dark & system mode
+ 'myRTLSupport' => false, // options[Boolean]: true(default), false // To provide RTLSupport or not
+ 'myRTLMode' => false, // options[Boolean]: false(default), true // To set layout to RTL layout (myRTLSupport must be true for rtl mode)
+ 'hasCustomizer' => true, // options[Boolean]: true(default), false // Display customizer or not THIS WILL REMOVE INCLUDED JS FILE. SO LOCAL STORAGE WON'T WORK
+ 'displayCustomizer' => true, // options[Boolean]: true(default), false // Display customizer UI or not, THIS WON'T REMOVE INCLUDED JS FILE. SO LOCAL STORAGE WILL WORK
+ 'contentLayout' => 'compact', // options[String]: 'compact', 'wide' (compact=container-xxl, wide=container-fluid)
+ 'navbarType' => 'static', // options[String]: 'sticky', 'static', 'hidden' (Only for vertical Layout)
+ 'footerFixed' => false, // options[Boolean]: false(default), true // Footer Fixed
+ 'menuFixed' => false, // options[Boolean]: true(default), false // Layout(menu) Fixed (Only for vertical Layout)
+ 'menuCollapsed' => true, // options[Boolean]: false(default), true // Show menu collapsed, (Only for vertical Layout)
+ 'headerType' => 'static', // options[String]: 'static', 'fixed' (for horizontal layout only)
+ 'showDropdownOnHover' => false, // true, false (for horizontal layout only)
+ 'authViewMode' => 'cover', // Options[String]: cover(default), basic
+ 'maxQuickLinks' => 8, // options[Integer]: 6(default), 8, 10
+ 'customizerControls' => [
+ //'rtl',
+ 'style',
+ 'headerType',
+ 'contentLayout',
+ 'layoutCollapsed',
+ 'layoutNavbarOptions',
+ 'themes',
+ ], // To show/hide customizer options
+ ],
+];
\ No newline at end of file
diff --git a/config/vuexy_menu.php b/config/vuexy_menu.php
new file mode 100644
index 0000000..4fdc3bc
--- /dev/null
+++ b/config/vuexy_menu.php
@@ -0,0 +1,848 @@
+ [
+ 'breadcrumbs' => false,
+ 'icon' => 'menu-icon tf-icons ti ti-home',
+ 'submenu' => [
+ 'Inicio' => [
+ 'route' => 'admin.core.home.index',
+ 'icon' => 'menu-icon tf-icons ti ti-home',
+ ],
+ 'Sitio Web' => [
+ 'url' => env('APP_URL'),
+ 'icon' => 'menu-icon tf-icons ti ti-world-www',
+ ],
+ 'Ajustes' => [
+ 'icon' => 'menu-icon tf-icons ti ti-settings-cog',
+ 'submenu' => [
+ 'Aplicación' => [
+ 'submenu' => [
+ 'Ajustes generales' => [
+ 'route' => 'admin.core.general-settings.index',
+ 'can' => 'admin.core.general-settings.allow',
+ ],
+ 'Ajustes de caché' => [
+ 'route' => 'admin.core.cache-manager.index',
+ 'can' => 'admin.core.cache-manager.view',
+ ],
+ 'Servidor de correo SMTP' => [
+ 'route' => 'admin.core.smtp-settings.index',
+ 'can' => 'admin.core.smtp-settings.allow',
+ ],
+ ],
+ ],
+ 'Empresa' => [
+ 'submenu' => [
+ 'Información general' => [
+ 'route' => 'admin.store-manager.company.index',
+ 'can' => 'admin.store-manager.company.view',
+ ],
+ 'Sucursales' => [
+ 'route' => 'admin.store-manager.stores.index',
+ 'can' => 'admin.store-manager.stores.view',
+ ],
+ 'Centros de trabajo' => [
+ 'route' => 'admin.store-manager.work-centers.index',
+ 'can' => 'admin.store-manager.stores.view',
+ ],
+ ]
+ ],
+ 'BANXICO' => [
+ 'route' => 'admin.finance.banxico.index',
+ 'can' => 'admin.finance.banxico.allow',
+ ],
+ 'Conectividad bancaria' => [
+ 'route' => 'admin.finance.banking.index',
+ 'can' => 'admin.finance.banking.allow',
+ ],
+ 'Punto de venta' => [
+ 'submenu' => [
+ 'Ticket' => [
+ 'route' => 'admin.sales.ticket-config.index',
+ 'can' => 'admin.sales.ticket-config.allow',
+ ],
+ ]
+ ],
+ 'Facturación' => [
+ 'submenu' => [
+ 'Certificados de Sello Digital' => [
+ 'route' => 'admin.billing.csds-settings.index',
+ 'can' => 'admin.billing.csds-settings.allow',
+ ],
+ 'Paquete de timbrado' => [
+ 'route' => 'admin.billing.stamping-package.index',
+ 'can' => 'admin.billing.stamping-package.allow',
+ ],
+ 'Servidor de correo SMTP' => [
+ 'route' => 'admin.billing.smtp-settings.index',
+ 'can' => 'admin.billing.smtp-settings.allow',
+ ],
+ 'Descarga masiva de CFDI' => [
+ 'route' => 'admin.billing.mass-cfdi-download.index',
+ 'can' => 'admin.billing.mass-cfdi-download.allow',
+ ],
+ ]
+ ],
+ ]
+ ],
+ 'Sistema' => [
+ 'icon' => 'menu-icon tf-icons ti ti-user-cog',
+ 'submenu' => [
+ 'Usuarios' => [
+ 'route' => 'admin.core.users.index',
+ 'can' => 'admin.core.users.view',
+ ],
+ 'Roles' => [
+ 'route' => 'admin.core.roles.index',
+ 'can' => 'admin.core.roles.view',
+ ],
+ 'Permisos' => [
+ 'route' => 'admin.core.permissions.index',
+ 'can' => 'admin.core.permissions.view',
+ ]
+ ]
+ ],
+ 'Catálogos' => [
+ 'icon' => 'menu-icon tf-icons ti ti-library',
+ 'submenu' => [
+ 'Importar catálogos SAT' => [
+ 'route' => 'admin.core.sat-catalogs.index',
+ 'can' => 'admin.core.sat-catalogs.allow',
+ ],
+ ]
+ ],
+ 'Configuración de cuenta' => [
+ 'route' => 'admin.core.user-profile.index',
+ 'icon' => 'menu-icon tf-icons ti ti-user-cog',
+ ],
+ 'Acerca de' => [
+ 'route' => 'admin.core.about.index',
+ 'icon' => 'menu-icon tf-icons ti ti-cat',
+ ],
+ ],
+ ],
+ 'Herramientas Avanzadas' => [
+ 'icon' => 'menu-icon tf-icons ti ti-device-ipad-cog',
+ 'submenu' => [
+ 'Asistente AI' => [
+ 'icon' => 'menu-icon tf-icons ti ti-brain',
+ 'submenu' => [
+ 'Panel de IA' => [
+ 'route' => 'admin.ai.dashboard.index',
+ 'can' => 'ai.dashboard.view',
+ ],
+ 'Generación de Contenidos' => [
+ 'route' => 'admin.ai.content.index',
+ 'can' => 'ai.content.create',
+ ],
+ 'Análisis de Datos' => [
+ 'route' => 'admin.ai.analytics.index',
+ 'can' => 'ai.analytics.view',
+ ],
+ ],
+ ],
+ 'Chatbot' => [
+ 'icon' => 'menu-icon tf-icons ti ti-message-chatbot',
+ 'submenu' => [
+ 'Configuración' => [
+ 'route' => 'admin.chatbot.config.index',
+ 'can' => 'chatbot.config.view',
+ ],
+ 'Flujos de Conversación' => [
+ 'route' => 'admin.chatbot.flows.index',
+ 'can' => 'chatbot.flows.manage',
+ ],
+ 'Historial de Interacciones' => [
+ 'route' => 'admin.chatbot.history.index',
+ 'can' => 'chatbot.history.view',
+ ],
+ ],
+ ],
+ 'IoT Box' => [
+ 'icon' => 'menu-icon tf-icons ti ti-cpu',
+ 'submenu' => [
+ 'Dispositivos Conectados' => [
+ 'route' => 'admin.iot.devices.index',
+ 'can' => 'iot.devices.view',
+ ],
+ 'Sensores y Configuración' => [
+ 'route' => 'admin.iot.sensors.index',
+ 'can' => 'iot.sensors.manage',
+ ],
+ 'Monitoreo en Tiempo Real' => [
+ 'route' => 'admin.iot.monitoring.index',
+ 'can' => 'iot.monitoring.view',
+ ],
+ ],
+ ],
+ 'Reconocimiento Facial' => [
+ 'icon' => 'menu-icon tf-icons ti ti-face-id',
+ 'submenu' => [
+ 'Gestión de Perfiles' => [
+ 'route' => 'admin.facial-recognition.profiles.index',
+ 'can' => 'facial-recognition.profiles.manage',
+ ],
+ 'Verificación en Vivo' => [
+ 'route' => 'admin.facial-recognition.live.index',
+ 'can' => 'facial-recognition.live.verify',
+ ],
+ 'Historial de Accesos' => [
+ 'route' => 'admin.facial-recognition.history.index',
+ 'can' => 'facial-recognition.history.view',
+ ],
+ ],
+ ],
+ 'Servidor de Impresión' => [
+ 'icon' => 'menu-icon tf-icons ti ti-printer',
+ 'submenu' => [
+ 'Cola de Impresión' => [
+ 'route' => 'admin.print.queue.index',
+ 'can' => 'print.queue.view',
+ ],
+ 'Historial de Impresiones' => [
+ 'route' => 'admin.print.history.index',
+ 'can' => 'print.history.view',
+ ],
+ 'Configuración de Impresoras' => [
+ 'route' => 'admin.print.settings.index',
+ 'can' => 'print.settings.manage',
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'Sitio Web' => [
+ 'icon' => 'menu-icon tf-icons ti ti-tools',
+ 'submenu' => [
+ 'Ajustes generales' => [
+ 'icon' => 'menu-icon tf-icons ti ti-tools',
+ 'route' => 'admin.website.general-settings.index',
+ 'can' => 'website.general-settings.allow',
+ ],
+ 'Avisos legales' => [
+ 'route' => 'admin.website.legal.index',
+ 'icon' => 'menu-icon tf-icons ti ti-writing-sign',
+ 'can' => 'website.legal.view',
+ ],
+ 'Preguntas frecuentes' => [
+ 'route' => 'admin.website.faq.index',
+ 'icon' => 'menu-icon tf-icons ti ti-bubble-text',
+ 'can' => 'website.faq.view',
+ ],
+ ]
+ ],
+ 'Blog' => [
+ 'icon' => 'menu-icon tf-icons ti ti-news',
+ 'submenu' => [
+ 'Categorias' => [
+ 'route' => 'admin.blog.categories.index',
+ 'icon' => 'menu-icon tf-icons ti ti-category',
+ 'can' => 'blog.categories.view',
+ ],
+ 'Etiquetas' => [
+ 'route' => 'admin.blog.tags.index',
+ 'icon' => 'menu-icon tf-icons ti ti-tags',
+ 'can' => 'blog.tags.view',
+ ],
+ 'Articulos' => [
+ 'route' => 'admin.blog.articles.index',
+ 'icon' => 'menu-icon tf-icons ti ti-news',
+ 'can' => 'blog.articles.view',
+ ],
+ 'Comentarios' => [
+ 'route' => 'admin.blog.comments.index',
+ 'icon' => 'menu-icon tf-icons ti ti-message',
+ 'can' => 'blog.comments.view',
+ ],
+ ]
+ ],
+ 'Contactos' => [
+ 'icon' => 'menu-icon tf-icons ti ti-users',
+ 'submenu' => [
+ 'Contactos' => [
+ 'route' => 'admin.crm.contacts.index',
+ 'icon' => 'menu-icon tf-icons ti ti-users',
+ 'can' => 'crm.contacts.view',
+ ],
+ 'Campañas de marketing' => [
+ 'route' => 'admin.crm.marketing-campaigns.index',
+ 'icon' => 'menu-icon tf-icons ti ti-ad-2',
+ 'can' => 'crm.marketing-campaigns.view',
+ ],
+ 'Oportunidades ' => [
+ 'route' => 'admin.crm.leads.index',
+ 'icon' => 'menu-icon tf-icons ti ti-target-arrow',
+ 'can' => 'crm.leads.view',
+ ],
+ 'Newsletter' => [
+ 'route' => 'admin.crm.newsletter.index',
+ 'icon' => 'menu-icon tf-icons ti ti-notebook',
+ 'can' => 'crm.newsletter.view',
+ ],
+ ]
+ ],
+ 'RRHH' => [
+ 'icon' => 'menu-icon tf-icons ti ti-users-group',
+ 'submenu' => [
+ 'Gestión de Empleados' => [
+ 'icon' => 'menu-icon tf-icons ti ti-id-badge-2',
+ 'submenu' => [
+ 'Lista de Empleados' => [
+ 'route' => 'admin.rrhh.employees.index',
+ 'can' => 'rrhh.employees.view',
+ ],
+ 'Agregar Nuevo Empleado' => [
+ 'route' => 'admin.rrhh.employees.create',
+ 'can' => 'rrhh.employees.create',
+ ],
+ 'Puestos de trabajo' => [
+ 'route' => 'admin.rrhh.jobs.index',
+ 'can' => 'rrhh.jobs.view',
+ ],
+ 'Estructura Organizacional' => [
+ 'route' => 'admin.rrhh.organization.index',
+ 'can' => 'rrhh.organization.view',
+ ],
+ ],
+ ],
+ 'Reclutamiento' => [
+ 'icon' => 'menu-icon tf-icons ti ti-user-search',
+ 'submenu' => [
+ 'Vacantes Disponibles' => [
+ 'route' => 'admin.recruitment.jobs.index',
+ 'can' => 'recruitment.jobs.view',
+ ],
+ 'Seguimiento de Candidatos' => [
+ 'route' => 'admin.recruitment.candidates.index',
+ 'can' => 'recruitment.candidates.view',
+ ],
+ 'Entrevistas y Evaluaciones' => [
+ 'route' => 'admin.recruitment.interviews.index',
+ 'can' => 'recruitment.interviews.view',
+ ],
+ ],
+ ],
+ 'Nómina' => [
+ 'icon' => 'menu-icon tf-icons ti ti-cash',
+ 'submenu' => [
+ 'Contratos' => [
+ 'route' => 'admin.payroll.contracts.index',
+ 'can' => 'payroll.contracts.view',
+ ],
+ 'Procesar Nómina' => [
+ 'route' => 'admin.payroll.process.index',
+ 'can' => 'payroll.process.view',
+ ],
+ 'Recibos de Nómina' => [
+ 'route' => 'admin.payroll.receipts.index',
+ 'can' => 'payroll.receipts.view',
+ ],
+ 'Reportes Financieros' => [
+ 'route' => 'admin.payroll.reports.index',
+ 'can' => 'payroll.reports.view',
+ ],
+ ],
+ ],
+ 'Asistencia' => [
+ 'icon' => 'menu-icon tf-icons ti ti-calendar-exclamation',
+ 'submenu' => [
+ 'Registro de Horarios' => [
+ 'route' => 'admin.attendance.records.index',
+ 'can' => 'attendance.records.view',
+ ],
+ 'Asistencia con Biométricos' => [
+ 'route' => 'admin.attendance.biometric.index',
+ 'can' => 'attendance.biometric.view',
+ ],
+ 'Justificación de Ausencias' => [
+ 'route' => 'admin.attendance.absences.index',
+ 'can' => 'attendance.absences.view',
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'Productos y servicios' => [
+ 'icon' => 'menu-icon tf-icons ti ti-package',
+ 'submenu' => [
+ 'Categorias' => [
+ 'route' => 'admin.inventory.product-categories.index',
+ 'icon' => 'menu-icon tf-icons ti ti-category',
+ 'can' => 'admin.inventory.product-categories.view',
+ ],
+ 'Catálogos' => [
+ 'route' => 'admin.inventory.product-catalogs.index',
+ 'icon' => 'menu-icon tf-icons ti ti-library',
+ 'can' => 'admin.inventory.product-catalogs.view',
+ ],
+ 'Productos y servicios' => [
+ 'route' => 'admin.inventory.products.index',
+ 'icon' => 'menu-icon tf-icons ti ti-packages',
+ 'can' => 'admin.inventory.products.view',
+ ],
+ 'Agregar producto o servicio' => [
+ 'route' => 'admin.inventory.products.create',
+ 'icon' => 'menu-icon tf-icons ti ti-package',
+ 'can' => 'admin.inventory.products.create',
+ ],
+ ]
+ ],
+ 'Ventas' => [
+ 'icon' => 'menu-icon tf-icons ti ti-cash-register',
+ 'submenu' => [
+ 'Tablero' => [
+ 'route' => 'admin.sales.dashboard.index',
+ 'icon' => 'menu-icon tf-icons ti ti-chart-infographic',
+ 'can' => 'admin.sales.dashboard.allow',
+ ],
+ 'Clientes' => [
+ 'route' => 'admin.sales.customers.index',
+ 'icon' => 'menu-icon tf-icons ti ti-users-group',
+ 'can' => 'admin.sales.customers.view',
+ ],
+ 'Lista de precios' => [
+ 'route' => 'admin.sales.pricelist.index',
+ 'icon' => 'menu-icon tf-icons ti ti-report-search',
+ 'can' => 'admin.sales.sales.view',
+ ],
+ 'Cotizaciones' => [
+ 'route' => 'admin.sales.quotes.index',
+ 'icon' => 'menu-icon tf-icons ti ti-file-dollar',
+ 'can' => 'admin.sales.quotes.view',
+ ],
+ 'Ventas' => [
+ 'icon' => 'menu-icon tf-icons ti ti-cash-register',
+ 'submenu' => [
+ 'Crear venta' => [
+ 'route' => 'admin.sales.sales.create',
+ 'can' => 'admin.sales.sales.create',
+ ],
+ 'Ventas' => [
+ 'route' => 'admin.sales.sales.index',
+ 'can' => 'admin.sales.sales.view',
+ ],
+ 'Ventas por producto o servicio' => [
+ 'route' => 'admin.sales.sales-by-product.index',
+ 'can' => 'admin.sales.sales.view',
+ ],
+ ]
+ ],
+ 'Remisiones' => [
+ 'icon' => 'menu-icon tf-icons ti ti-receipt',
+ 'submenu' => [
+ 'Crear remisión' => [
+ 'route' => 'admin.sales.remissions.create',
+ 'can' => 'admin.sales.remissions.create',
+ ],
+ 'Remisiones' => [
+ 'route' => 'admin.sales.remissions.index',
+ 'can' => 'admin.sales.remissions.view',
+ ],
+ 'Remisiones por producto o servicio' => [
+ 'route' => 'admin.sales.remissions-by-product.index',
+ 'can' => 'admin.sales.remissions.view',
+ ],
+ ]
+ ],
+ 'Notas de crédito' => [
+ 'icon' => 'menu-icon tf-icons ti ti-receipt-refund',
+ 'submenu' => [
+ 'Crear nota de crédito' => [
+ 'route' => 'admin.sales.credit-notes.create',
+ 'can' => 'admin.sales.credit-notes.create',
+ ],
+ 'Notas de créditos' => [
+ 'route' => 'admin.sales.credit-notes.index',
+ 'can' => 'admin.sales.credit-notes.view',
+ ],
+ 'Notas de crédito por producto o servicio' => [
+ 'route' => 'admin.sales.credit-notes-by-product.index',
+ 'can' => 'admin.sales.credit-notes.view',
+ ],
+ ]
+ ],
+ ],
+ ],
+ 'Finanzas' => [
+ 'icon' => 'menu-icon tf-icons ti ti-coins',
+ 'submenu' => [
+ 'Tablero Financiero' => [
+ 'route' => 'admin.accounting.dashboard.index',
+ 'icon' => 'menu-icon tf-icons ti ti-chart-infographic',
+ 'can' => 'accounting.dashboard.view',
+ ],
+ 'Contabilidad' => [
+ 'icon' => 'menu-icon tf-icons ti ti-chart-pie',
+ 'submenu' => [
+ 'Cuentas Contables' => [
+ 'route' => 'admin.accounting.charts.index',
+ 'can' => 'accounting.charts.view',
+ ],
+ 'Cuentas por pagar' => [
+ 'route' => 'admin.finance.accounts-payable.index',
+ 'can' => 'finance.accounts-payable.view',
+ ],
+ 'Cuentas por cobrar' => [
+ 'route' => 'admin.finance.accounts-receivable.index',
+ 'can' => 'finance.accounts-receivable.view',
+ ],
+ 'Balance General' => [
+ 'route' => 'admin.accounting.balance.index',
+ 'can' => 'accounting.balance.view',
+ ],
+ 'Estado de Resultados' => [
+ 'route' => 'admin.accounting.income-statement.index',
+ 'can' => 'accounting.income-statement.view',
+ ],
+ 'Libro Mayor' => [
+ 'route' => 'admin.accounting.ledger.index',
+ 'can' => 'accounting.ledger.view',
+ ],
+ 'Registros Contables' => [
+ 'route' => 'admin.accounting.entries.index',
+ 'can' => 'accounting.entries.view',
+ ],
+ ],
+ ],
+ 'Tablero de Gastos' => [
+ 'route' => 'admin.expenses.dashboard.index',
+ 'icon' => 'menu-icon tf-icons ti ti-chart-infographic',
+ 'can' => 'expenses.dashboard.view',
+ ],
+ 'Gestión de Gastos' => [
+ 'icon' => 'menu-icon tf-icons ti ti-receipt-2',
+ 'submenu' => [
+ 'Nuevo gasto' => [
+ 'route' => 'admin.expenses.expenses.create',
+ 'can' => 'expenses.expenses.create',
+ ],
+ 'Gastos' => [
+ 'route' => 'admin.expenses.expenses.index',
+ 'can' => 'expenses.expenses.view',
+ ],
+ 'Categorías de Gastos' => [
+ 'route' => 'admin.expenses.categories.index',
+ 'can' => 'expenses.categories.view',
+ ],
+ 'Historial de Gastos' => [
+ 'route' => 'admin.expenses.history.index',
+ 'can' => 'expenses.history.view',
+ ],
+ ],
+ ],
+ ],
+ ],
+
+
+
+
+ 'Facturación' => [
+ 'icon' => 'menu-icon tf-icons ti ti-rubber-stamp',
+ 'submenu' => [
+ 'Tablero' => [
+ 'route' => 'admin.billing.dashboard.index',
+ 'icon' => 'menu-icon tf-icons ti ti-chart-infographic',
+ 'can' => 'admin.billing.dashboard.allow',
+ ],
+ 'Ingresos' => [
+ 'icon' => 'menu-icon tf-icons ti ti-file-certificate',
+ 'submenu' => [
+ 'Facturar ventas' => [
+ 'route' => 'admin.billing.ingresos-stamp.index',
+ 'can' => 'admin.billing.ingresos.create',
+ ],
+ 'CFDI Ingresos' => [
+ 'route' => 'admin.billing.ingresos.index',
+ 'can' => 'admin.billing.ingresos.view',
+ ],
+ 'CFDI Ingresos por producto o servicio' => [
+ 'route' => 'admin.billing.ingresos-by-product.index',
+ 'can' => 'admin.billing.ingresos.view',
+ ],
+ ]
+ ],
+ 'Egresos' => [
+ 'icon' => 'menu-icon tf-icons ti ti-file-certificate',
+ 'submenu' => [
+ 'Facturar notas de crédito' => [
+ 'route' => 'admin.billing.egresos-stamp.index',
+ 'can' => 'admin.billing.egresos.create',
+ ],
+ 'CFDI Engresos' => [
+ 'route' => 'admin.billing.egresos.index',
+ 'can' => 'admin.billing.egresos.view',
+ ],
+ 'CFDI Engresos por producto o servicio' => [
+ 'route' => 'admin.billing.egresos-by-product.index',
+ 'can' => 'admin.billing.egresos.view',
+ ],
+ ]
+ ],
+ 'Pagos' => [
+ 'icon' => 'menu-icon tf-icons ti ti-file-certificate',
+ 'submenu' => [
+ 'Facturar pagos' => [
+ 'route' => 'admin.billing.pagos-stamp.index',
+ 'can' => 'admin.billing.pagos.created',
+ ],
+ 'CFDI Pagos' => [
+ 'route' => 'admin.billing.pagos.index',
+ 'can' => 'admin.billing.pagos.view',
+ ],
+ ]
+ ],
+ 'CFDI Nómina' => [
+ 'route' => 'admin.billing.nomina.index',
+ 'icon' => 'menu-icon tf-icons ti ti-file-certificate',
+ 'can' => 'admin.billing.nomina.view',
+ ],
+ 'Verificador de CFDI 4.0' => [
+ 'route' => 'admin.billing.verify-cfdi.index',
+ 'icon' => 'menu-icon tf-icons ti ti-rosette-discount-check',
+ 'can' => 'admin.billing.verify-cfdi.allow',
+ ],
+ ]
+ ],
+
+ 'Inventario y Logística' => [
+ 'icon' => 'menu-icon tf-icons ti ti-truck-delivery',
+ 'submenu' => [
+ 'Cadena de Suministro' => [
+ 'icon' => 'menu-icon tf-icons ti ti-chart-dots-3',
+ 'submenu' => [
+ 'Proveedores' => [
+ 'route' => 'admin.inventory.suppliers.index',
+ 'can' => 'admin.inventory.suppliers.view',
+ ],
+ 'Órdenes de Compra' => [
+ 'route' => 'admin.inventory.orders.index',
+ 'can' => 'admin.inventory.orders.view',
+ ],
+ 'Recepción de Productos' => [
+ 'route' => 'admin.inventory.reception.index',
+ 'can' => 'admin.inventory.reception.view',
+ ],
+ 'Gestión de Insumos' => [
+ 'route' => 'admin.inventory.materials.index',
+ 'can' => 'admin.inventory.materials.view',
+ ],
+ ],
+ ],
+ 'Gestión de Almacenes' => [
+ 'icon' => 'menu-icon tf-icons ti ti-building-warehouse',
+ 'submenu' => [
+ 'Almacenes' => [
+ 'route' => 'admin.inventory.warehouse.index',
+ 'can' => 'admin.inventory.warehouse.view',
+ ],
+ 'Stock de Inventario' => [
+ 'route' => 'admin.inventory.stock.index',
+ 'can' => 'admin.inventory.stock.view',
+ ],
+ 'Movimientos de almacenes' => [
+ 'route' => 'admin.inventory.movements.index',
+ 'can' => 'admin.inventory.movements.view',
+ ],
+ 'Transferencias entre Almacenes' => [
+ 'route' => 'admin.inventory.transfers.index',
+ 'can' => 'admin.inventory.transfers.view',
+ ],
+ ],
+ ],
+ 'Envíos y Logística' => [
+ 'icon' => 'menu-icon tf-icons ti ti-truck',
+ 'submenu' => [
+ 'Órdenes de Envío' => [
+ 'route' => 'admin.inventory.shipping-orders.index',
+ 'can' => 'admin.inventory.shipping-orders.view',
+ ],
+ 'Seguimiento de Envíos' => [
+ 'route' => 'admin.inventory.shipping-tracking.index',
+ 'can' => 'admin.inventory.shipping-tracking.view',
+ ],
+ 'Transportistas' => [
+ 'route' => 'admin.inventory.shipping-carriers.index',
+ 'can' => 'admin.inventory.shipping-carriers.view',
+ ],
+ 'Tarifas y Métodos de Envío' => [
+ 'route' => 'admin.inventory.shipping-rates.index',
+ 'can' => 'admin.inventory.shipping-rates.view',
+ ],
+ ],
+ ],
+ 'Gestión de Activos' => [
+ 'icon' => 'menu-icon tf-icons ti ti-tools-kitchen',
+ 'submenu' => [
+ 'Activos Registrados' => [
+ 'route' => 'admin.inventory.asset.index',
+ 'can' => 'admin.inventory.asset.view',
+ ],
+ 'Mantenimiento Preventivo' => [
+ 'route' => 'admin.inventory.asset-maintenance.index',
+ 'can' => 'admin.inventory.asset-maintenance.view',
+ ],
+ 'Control de Vida Útil' => [
+ 'route' => 'admin.inventory.asset-lifecycle.index',
+ 'can' => 'admin.inventory.asset-lifecycle.view',
+ ],
+ 'Asignación de Activos' => [
+ 'route' => 'admin.inventory.asset-assignments.index',
+ 'can' => 'admin.inventory.asset-assignments.view',
+ ],
+ ],
+ ],
+ ],
+ ],
+
+ 'Gestión Empresarial' => [
+ 'icon' => 'menu-icon tf-icons ti ti-briefcase',
+ 'submenu' => [
+ 'Gestión de Proyectos' => [
+ 'icon' => 'menu-icon tf-icons ti ti-layout-kanban',
+ 'submenu' => [
+ 'Tablero de Proyectos' => [
+ 'route' => 'admin.projects.dashboard.index',
+ 'can' => 'projects.dashboard.view',
+ ],
+ 'Proyectos Activos' => [
+ 'route' => 'admin.projects.index',
+ 'can' => 'projects.view',
+ ],
+ 'Crear Proyecto' => [
+ 'route' => 'admin.projects.create',
+ 'can' => 'projects.create',
+ ],
+ 'Gestión de Tareas' => [
+ 'route' => 'admin.projects.tasks.index',
+ 'can' => 'projects.tasks.view',
+ ],
+ 'Historial de Proyectos' => [
+ 'route' => 'admin.projects.history.index',
+ 'can' => 'projects.history.view',
+ ],
+ ],
+ ],
+ 'Producción y Manufactura' => [
+ 'icon' => 'menu-icon tf-icons ti ti-building-factory',
+ 'submenu' => [
+ 'Órdenes de Producción' => [
+ 'route' => 'admin.production.orders.index',
+ 'can' => 'production.orders.view',
+ ],
+ 'Nueva Orden de Producción' => [
+ 'route' => 'admin.production.orders.create',
+ 'can' => 'production.orders.create',
+ ],
+ 'Control de Procesos' => [
+ 'route' => 'admin.production.process.index',
+ 'can' => 'production.process.view',
+ ],
+ 'Historial de Producción' => [
+ 'route' => 'admin.production.history.index',
+ 'can' => 'production.history.view',
+ ],
+ ],
+ ],
+ 'Control de Calidad' => [
+ 'icon' => 'menu-icon tf-icons ti ti-award',
+ 'submenu' => [
+ 'Inspecciones de Calidad' => [
+ 'route' => 'admin.quality.inspections.index',
+ 'can' => 'quality.inspections.view',
+ ],
+ 'Crear Inspección' => [
+ 'route' => 'admin.quality.inspections.create',
+ 'can' => 'quality.inspections.create',
+ ],
+ 'Reportes de Calidad' => [
+ 'route' => 'admin.quality.reports.index',
+ 'can' => 'quality.reports.view',
+ ],
+ 'Historial de Inspecciones' => [
+ 'route' => 'admin.quality.history.index',
+ 'can' => 'quality.history.view',
+ ],
+ ],
+ ],
+ 'Flujos de Trabajo y Automatización' => [
+ 'icon' => 'menu-icon tf-icons ti ti-chart-dots-3',
+ 'submenu' => [
+ 'Gestión de Flujos de Trabajo' => [
+ 'route' => 'admin.workflows.index',
+ 'can' => 'workflows.view',
+ ],
+ 'Crear Flujo de Trabajo' => [
+ 'route' => 'admin.workflows.create',
+ 'can' => 'workflows.create',
+ ],
+ 'Automatizaciones' => [
+ 'route' => 'admin.workflows.automations.index',
+ 'can' => 'workflows.automations.view',
+ ],
+ 'Historial de Flujos' => [
+ 'route' => 'admin.workflows.history.index',
+ 'can' => 'workflows.history.view',
+ ],
+ ],
+ ],
+ ],
+ ],
+
+
+ 'Contratos' => [
+ 'icon' => 'menu-icon tf-icons ti ti-writing-sign',
+ 'submenu' => [
+ 'Mis Contratos' => [
+ 'route' => 'admin.contracts.index',
+ 'icon' => 'menu-icon tf-icons ti ti-file-description',
+ 'can' => 'contracts.view',
+ ],
+ 'Firmar Contrato' => [
+ 'route' => 'admin.contracts.sign',
+ 'icon' => 'menu-icon tf-icons ti ti-signature',
+ 'can' => 'contracts.sign',
+ ],
+ 'Contratos Automatizados' => [
+ 'route' => 'admin.contracts.automated',
+ 'icon' => 'menu-icon tf-icons ti ti-robot',
+ 'can' => 'contracts.automated.view',
+ ],
+ 'Historial de Contratos' => [
+ 'route' => 'admin.contracts.history',
+ 'icon' => 'menu-icon tf-icons ti ti-archive',
+ 'can' => 'contracts.history.view',
+ ],
+ ]
+ ],
+ 'Atención al Cliente' => [
+ 'icon' => 'menu-icon tf-icons ti ti-messages',
+ 'submenu' => [
+ 'Tablero' => [
+ 'route' => 'admin.sales.dashboard.index',
+ 'icon' => 'menu-icon tf-icons ti ti-chart-infographic',
+ 'can' => 'ticketing.dashboard.view',
+ ],
+ 'Mis Tickets' => [
+ 'route' => 'admin.ticketing.tickets.index',
+ 'icon' => 'menu-icon tf-icons ti ti-ticket',
+ 'can' => 'ticketing.tickets.view',
+ ],
+ 'Crear Ticket' => [
+ 'route' => 'admin.ticketing.tickets.create',
+ 'icon' => 'menu-icon tf-icons ti ti-square-plus',
+ 'can' => 'ticketing.tickets.create',
+ ],
+ 'Categorías de Tickets' => [
+ 'route' => 'admin.ticketing.categories.index',
+ 'icon' => 'menu-icon tf-icons ti ti-category',
+ 'can' => 'ticketing.categories.view',
+ ],
+ 'Estadísticas de Atención' => [
+ 'route' => 'admin.ticketing.analytics.index',
+ 'icon' => 'menu-icon tf-icons ti ti-chart-bar',
+ 'can' => 'ticketing.analytics.view',
+ ],
+ ]
+ ],
+];
diff --git a/database/data/rbac-config.json b/database/data/rbac-config.json
new file mode 100644
index 0000000..249d102
--- /dev/null
+++ b/database/data/rbac-config.json
@@ -0,0 +1,510 @@
+{
+ "roles": {
+ "SuperAdmin" : {
+ "style": "dark",
+ "permissions" : [
+ "admin.core.general-settings.allow",
+ "admin.core.cache-manager.view",
+ "admin.core.smtp-settings.allow",
+ "admin.store-manager.company.view",
+ "admin.store-manager.stores.view",
+ "admin.store-manager.stores.view",
+ "admin.finance.banxico.allow",
+ "admin.finance.banking.allow",
+ "admin.sales.ticket-config.allow",
+ "admin.billing.csds-settings.allow",
+ "admin.billing.stamping-package.allow",
+ "admin.billing.smtp-settings.allow",
+ "admin.billing.mass-cfdi-download.allow",
+ "admin.core.users.view",
+ "admin.core.roles.view",
+ "admin.core.permissions.view",
+ "admin.core.import-sat-catalogs.allow",
+ "admin.ai.dashboard.view",
+ "admin.ai.content.create",
+ "admin.ai.analytics.view",
+ "admin.chatbot.config.view",
+ "admin.chatbot.flows.manage",
+ "admin.chatbot.history.view",
+ "admin.iot.devices.view",
+ "admin.iot.sensors.manage",
+ "admin.iot.monitoring.view",
+ "admin.facial-recognition.profiles.manage",
+ "admin.facial-recognition.live.verify",
+ "admin.facial-recognition.history.view",
+ "admin.print.queue.view",
+ "admin.print.history.view",
+ "admin.print.settings.manage",
+ "admin.website.general-settings.allow",
+ "admin.website.legal.view",
+ "admin.website.faq.view",
+ "admin.blog.categories.view",
+ "admin.blog.tags.view",
+ "admin.blog.articles.view",
+ "admin.blog.comments.view",
+ "admin.contacts.contacts.view",
+ "admin.contacts.employees.view",
+ "admin.contacts.employees.create",
+ "admin.rrhh.jobs.view",
+ "admin.rrhh.organization.view",
+ "admin.recruitment.jobs.view",
+ "admin.recruitment.candidates.view",
+ "admin.recruitment.interviews.view",
+ "admin.payroll.contracts.view",
+ "admin.payroll.process.view",
+ "admin.payroll.receipts.view",
+ "admin.payroll.reports.view",
+ "admin.attendance.records.view",
+ "admin.attendance.biometric.view",
+ "admin.attendance.absences.view",
+ "admin.inventory.product-categories.view",
+ "admin.inventory.product-catalogs.view",
+ "admin.inventory.products.view",
+ "admin.inventory.products.create",
+ "admin.sales.dashboard.allow",
+ "admin.contacts.customers.view",
+ "admin.sales.sales.view",
+ "admin.sales.quotes.view",
+ "admin.sales.sales.create",
+ "admin.sales.sales.view",
+ "admin.sales.sales.view",
+ "admin.sales.remissions.create",
+ "admin.sales.remissions.view",
+ "admin.sales.remissions.view",
+ "admin.sales.credit-notes.create",
+ "admin.sales.credit-notes.view",
+ "admin.sales.credit-notes.view",
+ "admin.accounting.dashboard.view",
+ "admin.accounting.charts.view",
+ "admin.finance.accounts-payable.view",
+ "admin.finance.accounts-receivable.view",
+ "admin.accounting.balance.view",
+ "admin.accounting.income-statement.view",
+ "admin.accounting.ledger.view",
+ "admin.accounting.entries.view",
+ "admin.expenses.dashboard.view",
+ "admin.expenses.expenses.create",
+ "admin.expenses.expenses.view",
+ "admin.expenses.categories.view",
+ "admin.expenses.history.view",
+ "admin.billing.dashboard.allow",
+ "admin.billing.ingresos.create",
+ "admin.billing.ingresos.view",
+ "admin.billing.ingresos.view",
+ "admin.billing.egresos.create",
+ "admin.billing.egresos.view",
+ "admin.billing.egresos.view",
+ "admin.billing.pagos.created",
+ "admin.billing.pagos.view",
+ "admin.billing.nomina.view",
+ "admin.billing.verify-cfdi.allow",
+ "admin.contacts.suppliers.view",
+ "admin.inventory.orders.view",
+ "admin.inventory.reception.view",
+ "admin.inventory.materials.view",
+ "admin.inventory.warehouse.view",
+ "admin.inventory.stock.view",
+ "admin.inventory.movements.view",
+ "admin.inventory.transfers.view",
+ "admin.inventory.shipping-orders.view",
+ "admin.inventory.shipping-tracking.view",
+ "admin.inventory.shipping-carriers.view",
+ "admin.inventory.shipping-rates.view",
+ "admin.inventory.assets.view",
+ "admin.inventory.asset-maintenance.view",
+ "admin.inventory.asset-lifecycle.view",
+ "admin.inventory.asset-assignments.view",
+ "admin.projects.dashboard.view",
+ "admin.projects.view",
+ "admin.projects.create",
+ "admin.projects.tasks.view",
+ "admin.projects.history.view",
+ "admin.production.orders.view",
+ "admin.production.orders.create",
+ "admin.production.process.view",
+ "admin.production.history.view",
+ "admin.quality.inspections.view",
+ "admin.quality.inspections.create",
+ "admin.quality.reports.view",
+ "admin.quality.history.view",
+ "admin.workflows.view",
+ "admin.workflows.create",
+ "admin.workflows.automations.view",
+ "admin.workflows.history.view",
+ "admin.contracts.view",
+ "admin.contracts.sign",
+ "admin.contracts.automated.view",
+ "admin.contracts.history.view",
+ "admin.ticketing.dashboard.view",
+ "admin.ticketing.tickets.view",
+ "admin.ticketing.tickets.create",
+ "admin.ticketing.categories.view",
+ "admin.ticketing.analytics.view"
+ ]
+ },
+ "Admin" : {
+ "style": "primary",
+ "permissions" : [
+ "admin.core.general-settings.allow",
+ "admin.core.cache-manager.view",
+ "admin.core.smtp-settings.allow",
+ "admin.website.general-settings.allow",
+ "admin.website.legal.view",
+ "admin.store-manager.company.view",
+ "admin.store-manager.stores.view",
+ "admin.store-manager.stores.view",
+ "admin.core.users.view",
+ "admin.core.roles.view",
+ "admin.core.permissions.view",
+ "admin.core.import-sat-catalogs.allow",
+ "admin.contacts.contacts.view",
+ "admin.contacts.contacts.create",
+ "admin.contacts.employees.view",
+ "admin.contacts.employees.create",
+ "admin.contacts.customers.view",
+ "admin.contacts.customers.create",
+ "admin.rrhh.jobs.view",
+ "admin.rrhh.organization.view",
+ "admin.inventory.product-categories.view",
+ "admin.inventory.product-catalogs.view",
+ "admin.inventory.products.view",
+ "admin.inventory.products.create",
+ "admin.contacts.suppliers.view",
+ "admin.contacts.suppliers.create",
+ "admin.inventory.warehouse.view",
+ "admin.inventory.orders.view",
+ "admin.inventory.reception.view",
+ "admin.inventory.materials.view",
+ "admin.inventory.stock.view",
+ "admin.inventory.movements.view",
+ "admin.inventory.transfers.view",
+ "admin.inventory.assets.view",
+ "admin.inventory.asset-maintenance.view",
+ "admin.inventory.asset-lifecycle.view",
+ "admin.inventory.asset-assignments.view"
+ ]
+ },
+ "Administrador Web" : {
+ "style": "primary",
+ "permissions" : []
+ },
+ "Editor" : {
+ "style": "primary",
+ "permissions" : []
+ },
+ "Almacenista" : {
+ "style": "success",
+ "permissions" : [
+ "admin.inventory.product-categories.view",
+ "admin.inventory.product-catalogs.view",
+ "admin.inventory.products.view",
+ "admin.inventory.products.create",
+ "admin.inventory.warehouse.view",
+ "admin.inventory.stock.view",
+ "admin.inventory.movements.view",
+ "admin.inventory.transfers.view"
+ ]
+ },
+ "Productos y servicios" : {
+ "style": "info",
+ "permissions" : []
+ },
+ "Recursos humanos" : {
+ "style": "success",
+ "permissions" : []
+ },
+ "Nómina" : {
+ "style": "success",
+ "permissions" : []
+ },
+ "Activos fijos" : {
+ "style": "secondary",
+ "permissions" : []
+ },
+ "Compras y gastos" : {
+ "style": "info",
+ "permissions" : []
+ },
+ "CRM" : {
+ "style": "warning",
+ "permissions" : []
+ },
+ "Vendedor" : {
+ "style": "info",
+ "permissions" : []
+ },
+ "Gerente" : {
+ "style": "danger",
+ "permissions" : []
+ },
+ "Facturación" : {
+ "style": "info",
+ "permissions" : []
+ },
+ "Facturación avanzado" : {
+ "style": "danger",
+ "permissions" : []
+ },
+ "Finanzas" : {
+ "style": "info",
+ "permissions" : []
+ },
+ "Auditor" : {
+ "style": "dark",
+ "permissions" : [
+ "admin.core.cache-manager.view",
+ "admin.store-manager.company.view",
+ "admin.store-manager.stores.view",
+ "admin.store-manager.stores.view",
+ "admin.core.users.view",
+ "admin.core.roles.view",
+ "admin.core.permissions.view",
+ "admin.ai.dashboard.view",
+ "admin.ai.analytics.view",
+ "admin.chatbot.config.view",
+ "admin.chatbot.history.view",
+ "admin.iot.devices.view",
+ "admin.iot.monitoring.view",
+ "admin.facial-recognition.history.view",
+ "admin.print.queue.view",
+ "admin.print.history.view",
+ "admin.website.legal.view",
+ "admin.website.faq.view",
+ "admin.blog.categories.view",
+ "admin.blog.tags.view",
+ "admin.blog.articles.view",
+ "admin.blog.comments.view",
+ "admin.contacts.contacts.view",
+ "admin.crm.marketing-campaigns.view",
+ "admin.crm.leads.view",
+ "admin.crm.newsletter.view",
+ "admin.contacts.employees.view",
+ "admin.rrhh.jobs.view",
+ "admin.rrhh.organization.view",
+ "admin.recruitment.jobs.view",
+ "admin.recruitment.candidates.view",
+ "admin.recruitment.interviews.view",
+ "admin.payroll.contracts.view",
+ "admin.payroll.process.view",
+ "admin.payroll.receipts.view",
+ "admin.payroll.reports.view",
+ "admin.attendance.records.view",
+ "admin.attendance.biometric.view",
+ "admin.attendance.absences.view",
+ "admin.inventory.product-categories.view",
+ "admin.inventory.product-catalogs.view",
+ "admin.inventory.products.view",
+ "admin.contacts.customers.view",
+ "admin.sales.sales.view",
+ "admin.sales.quotes.view",
+ "admin.sales.sales.view",
+ "admin.sales.sales.view",
+ "admin.sales.remissions.view",
+ "admin.sales.remissions.view",
+ "admin.sales.credit-notes.view",
+ "admin.sales.credit-notes.view",
+ "admin.accounting.dashboard.view",
+ "admin.accounting.charts.view",
+ "admin.finance.accounts-payable.view",
+ "admin.finance.accounts-receivable.view",
+ "admin.accounting.balance.view",
+ "admin.accounting.income-statement.view",
+ "admin.accounting.ledger.view",
+ "admin.accounting.entries.view",
+ "admin.expenses.dashboard.view",
+ "admin.expenses.expenses.view",
+ "admin.expenses.categories.view",
+ "admin.expenses.history.view",
+ "admin.billing.ingresos.view",
+ "admin.billing.ingresos.view",
+ "admin.billing.egresos.view",
+ "admin.billing.egresos.view",
+ "admin.billing.pagos.view",
+ "admin.billing.nomina.view",
+ "admin.contacts.suppliers.view",
+ "admin.inventory.orders.view",
+ "admin.inventory.reception.view",
+ "admin.inventory.materials.view",
+ "admin.inventory.warehouse.view",
+ "admin.inventory.stock.view",
+ "admin.inventory.movements.view",
+ "admin.inventory.transfers.view",
+ "admin.inventory.shipping-orders.view",
+ "admin.inventory.shipping-tracking.view",
+ "admin.inventory.shipping-carriers.view",
+ "admin.inventory.shipping-rates.view",
+ "admin.inventory.assets.view",
+ "admin.inventory.asset-maintenance.view",
+ "admin.inventory.asset-lifecycle.view",
+ "admin.inventory.asset-assignments.view",
+ "admin.projects.dashboard.view",
+ "admin.projects.view",
+ "admin.projects.tasks.view",
+ "admin.projects.history.view",
+ "admin.production.orders.view",
+ "admin.production.process.view",
+ "admin.production.history.view",
+ "admin.quality.inspections.view",
+ "admin.quality.reports.view",
+ "admin.quality.history.view",
+ "admin.workflows.view",
+ "admin.workflows.automations.view",
+ "admin.workflows.history.view",
+ "admin.contracts.view",
+ "admin.contracts.automated.view",
+ "admin.contracts.history.view",
+ "admin.ticketing.dashboard.view",
+ "admin.ticketing.tickets.view",
+ "admin.ticketing.categories.view",
+ "admin.ticketing.analytics.view"
+ ]
+ }
+ },
+ "permissions": [
+ "admin.core.general-settings.allow",
+ "admin.core.cache-manager.view",
+ "admin.core.smtp-settings.allow",
+ "admin.store-manager.company.view",
+ "admin.store-manager.stores.view",
+ "admin.store-manager.stores.view",
+ "admin.finance.banxico.allow",
+ "admin.finance.banking.allow",
+ "admin.sales.ticket-config.allow",
+ "admin.billing.csds-settings.allow",
+ "admin.billing.stamping-package.allow",
+ "admin.billing.smtp-settings.allow",
+ "admin.billing.mass-cfdi-download.allow",
+ "admin.core.users.view",
+ "admin.core.roles.view",
+ "admin.core.permissions.view",
+ "admin.core.import-sat-catalogs.allow",
+ "admin.ai.dashboard.view",
+ "admin.ai.content.create",
+ "admin.ai.analytics.view",
+ "admin.chatbot.config.view",
+ "admin.chatbot.flows.manage",
+ "admin.chatbot.history.view",
+ "admin.iot.devices.view",
+ "admin.iot.sensors.manage",
+ "admin.iot.monitoring.view",
+ "admin.facial-recognition.profiles.manage",
+ "admin.facial-recognition.live.verify",
+ "admin.facial-recognition.history.view",
+ "admin.print.queue.view",
+ "admin.print.history.view",
+ "admin.print.settings.manage",
+ "admin.website.general-settings.allow",
+ "admin.website.legal.view",
+ "admin.website.faq.view",
+ "admin.blog.categories.view",
+ "admin.blog.tags.view",
+ "admin.blog.articles.view",
+ "admin.blog.comments.view",
+ "admin.contacts.contacts.view",
+ "admin.contacts.contacts.create",
+ "admin.crm.marketing-campaigns.view",
+ "admin.crm.leads.view",
+ "admin.crm.newsletter.view",
+ "admin.contacts.employees.view",
+ "admin.contacts.employees.create",
+ "admin.rrhh.jobs.view",
+ "admin.rrhh.organization.view",
+ "admin.recruitment.jobs.view",
+ "admin.recruitment.candidates.view",
+ "admin.recruitment.interviews.view",
+ "admin.payroll.contracts.view",
+ "admin.payroll.process.view",
+ "admin.payroll.receipts.view",
+ "admin.payroll.reports.view",
+ "admin.attendance.records.view",
+ "admin.attendance.biometric.view",
+ "admin.attendance.absences.view",
+ "admin.inventory.product-categories.view",
+ "admin.inventory.product-catalogs.view",
+ "admin.inventory.products.view",
+ "admin.inventory.products.create",
+ "admin.sales.dashboard.allow",
+ "admin.contacts.customers.view",
+ "admin.contacts.customers.create",
+ "admin.sales.sales.view",
+ "admin.sales.quotes.view",
+ "admin.sales.sales.create",
+ "admin.sales.sales.view",
+ "admin.sales.sales.view",
+ "admin.sales.remissions.create",
+ "admin.sales.remissions.view",
+ "admin.sales.remissions.view",
+ "admin.sales.credit-notes.create",
+ "admin.sales.credit-notes.view",
+ "admin.sales.credit-notes.view",
+ "admin.accounting.dashboard.view",
+ "admin.accounting.charts.view",
+ "admin.finance.accounts-payable.view",
+ "admin.finance.accounts-receivable.view",
+ "admin.accounting.balance.view",
+ "admin.accounting.income-statement.view",
+ "admin.accounting.ledger.view",
+ "admin.accounting.entries.view",
+ "admin.expenses.dashboard.view",
+ "admin.expenses.expenses.create",
+ "admin.expenses.expenses.view",
+ "admin.expenses.categories.view",
+ "admin.expenses.history.view",
+ "admin.billing.dashboard.allow",
+ "admin.billing.ingresos.create",
+ "admin.billing.ingresos.view",
+ "admin.billing.ingresos.view",
+ "admin.billing.egresos.create",
+ "admin.billing.egresos.view",
+ "admin.billing.egresos.view",
+ "admin.billing.pagos.created",
+ "admin.billing.pagos.view",
+ "admin.billing.nomina.view",
+ "admin.billing.verify-cfdi.allow",
+ "admin.contacts.suppliers.view",
+ "admin.contacts.suppliers.create",
+ "admin.inventory.orders.view",
+ "admin.inventory.reception.view",
+ "admin.inventory.materials.view",
+ "admin.inventory.warehouse.view",
+ "admin.inventory.stock.view",
+ "admin.inventory.movements.view",
+ "admin.inventory.transfers.view",
+ "admin.inventory.shipping-orders.view",
+ "admin.inventory.shipping-tracking.view",
+ "admin.inventory.shipping-carriers.view",
+ "admin.inventory.shipping-rates.view",
+ "admin.inventory.assets.view",
+ "admin.inventory.asset-maintenance.view",
+ "admin.inventory.asset-lifecycle.view",
+ "admin.inventory.asset-assignments.view",
+ "admin.projects.dashboard.view",
+ "admin.projects.view",
+ "admin.projects.create",
+ "admin.projects.tasks.view",
+ "admin.projects.history.view",
+ "admin.production.orders.view",
+ "admin.production.orders.create",
+ "admin.production.process.view",
+ "admin.production.history.view",
+ "admin.quality.inspections.view",
+ "admin.quality.inspections.create",
+ "admin.quality.reports.view",
+ "admin.quality.history.view",
+ "admin.workflows.view",
+ "admin.workflows.create",
+ "admin.workflows.automations.view",
+ "admin.workflows.history.view",
+ "admin.contracts.view",
+ "admin.contracts.sign",
+ "admin.contracts.automated.view",
+ "admin.contracts.history.view",
+ "admin.ticketing.dashboard.view",
+ "admin.ticketing.tickets.view",
+ "admin.ticketing.tickets.create",
+ "admin.ticketing.categories.view",
+ "admin.ticketing.analytics.view"
+ ]
+}
+
+
diff --git a/database/data/users.csv b/database/data/users.csv
new file mode 100644
index 0000000..268b3b3
--- /dev/null
+++ b/database/data/users.csv
@@ -0,0 +1,14 @@
+name,email,role,password
+Administrador Web,webadmin@koneko.test,Administrador Web,LAdmin123
+Productos y servicios,productos@koneko.test,Productos y servicios,LAdmin123
+Recursos humanos,rrhh@koneko.test,Recursos humanos,LAdmin123
+Nómina,nomina@koneko.test,Nómina,LAdmin123
+Activos fijos,activos@koneko.test,Activos fijos,LAdmin123
+Compras y gastos,compras@koneko.test,Compras y gastos,LAdmin123
+CRM,crm@koneko.test,CRM,LAdmin123
+Vendedor,vendedor@koneko.test,Vendedor,LAdmin123
+Gerente,gerente@koneko.test,Gerente,LAdmin123
+Facturación,facturacion@koneko.test,Facturación,LAdmin123
+Facturación avanzado,facturacion_avanzado@koneko.test,Facturación avanzado,LAdmin123
+Finanzas,finanzas@koneko.test,Finanzas,LAdmin123
+Almacenista,almacenista@koneko.test,Almacenista,LAdmin123
diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php
new file mode 100644
index 0000000..ba86720
--- /dev/null
+++ b/database/factories/UserFactory.php
@@ -0,0 +1,49 @@
+
+ */
+class UserFactory extends Factory
+{
+ /**
+ * The current password being used by the factory.
+ */
+ protected static ?string $password;
+
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'name' => fake()->name(),
+ 'email' => fake()->unique()->safeEmail(),
+ 'email_verified_at' => now(),
+ 'password' => static::$password ??= Hash::make('password'),
+ 'two_factor_secret' => null,
+ 'two_factor_recovery_codes' => null,
+ 'remember_token' => Str::random(10),
+ 'profile_photo_path' => null,
+ 'status' => fake()->randomElement([User::STATUS_ENABLED, User::STATUS_DISABLED])
+ ];
+ }
+
+ /**
+ * Indicate that the model's email address should be unverified.
+ */
+ public function unverified(): static
+ {
+ return $this->state(fn(array $attributes) => [
+ 'email_verified_at' => null,
+ ]);
+ }
+}
diff --git a/database/migrations/2024_12_14_030215_modify_users_table.php b/database/migrations/2024_12_14_030215_modify_users_table.php
new file mode 100644
index 0000000..f4567b4
--- /dev/null
+++ b/database/migrations/2024_12_14_030215_modify_users_table.php
@@ -0,0 +1,44 @@
+string('last_name', 100)->nullable()->comment('Apellidos')->index()->after('name');
+ $table->string('profile_photo_path', 2048)->nullable()->after('remember_token');
+ $table->unsignedTinyInteger('status')->default(User::STATUS_DISABLED)->after('profile_photo_path');
+ $table->unsignedMediumInteger('created_by')->nullable()->index()->after('status');
+
+ // Definir la relación con created_by
+ $table->foreign('created_by')->references('id')->on('users')->onUpdate('restrict')->onDelete('restrict');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ DB::statement('ALTER TABLE `users` MODIFY `id` MEDIUMINT UNSIGNED NOT NULL;');
+ DB::statement('ALTER TABLE `users` DROP PRIMARY KEY;');
+ DB::statement('ALTER TABLE `users` MODIFY `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, ADD PRIMARY KEY (`id`);');
+
+ Schema::table('users', function (Blueprint $table) {
+ $table->dropColumn(['last_name', 'profile_photo_path', 'status', 'created_by']);
+
+ });
+ }
+};
diff --git a/database/migrations/2024_12_14_035487_create_user_logins_table.php b/database/migrations/2024_12_14_035487_create_user_logins_table.php
new file mode 100644
index 0000000..84f5ba3
--- /dev/null
+++ b/database/migrations/2024_12_14_035487_create_user_logins_table.php
@@ -0,0 +1,36 @@
+integerIncrements('id');
+
+ $table->unsignedMediumInteger('user_id')->nullable()->index();
+ $table->ipAddress('ip_address')->nullable();
+ $table->string('user_agent')->nullable();
+
+ $table->timestamps();
+
+ // Relaciones
+ $table->foreign('user_id')->references('id')->on('users');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ // Elimina tablas solo si existen
+ Schema::dropIfExists('user_logins');
+ }
+};
diff --git a/database/migrations/2024_12_14_073441_create_personal_access_tokens_table.php b/database/migrations/2024_12_14_073441_create_personal_access_tokens_table.php
new file mode 100644
index 0000000..e828ad8
--- /dev/null
+++ b/database/migrations/2024_12_14_073441_create_personal_access_tokens_table.php
@@ -0,0 +1,33 @@
+id();
+ $table->morphs('tokenable');
+ $table->string('name');
+ $table->string('token', 64)->unique();
+ $table->text('abilities')->nullable();
+ $table->timestamp('last_used_at')->nullable();
+ $table->timestamp('expires_at')->nullable();
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('personal_access_tokens');
+ }
+};
diff --git a/database/migrations/2024_12_14_074756_create_permission_tables.php b/database/migrations/2024_12_14_074756_create_permission_tables.php
new file mode 100644
index 0000000..347947f
--- /dev/null
+++ b/database/migrations/2024_12_14_074756_create_permission_tables.php
@@ -0,0 +1,153 @@
+engine('InnoDB');
+ $table->bigIncrements('id'); // permission id
+ $table->string('name'); // For MyISAM use string('name', 225); // (or 166 for InnoDB with Redundant/Compact row format)
+ $table->string('group_name')->nullable()->index();
+ $table->string('sub_group_name')->nullable()->index();
+ $table->string('action')->nullable()->index();
+ $table->string('guard_name'); // For MyISAM use string('guard_name', 25);
+ $table->timestamps();
+
+ $table->unique(['name', 'guard_name']);
+ $table->unique(['group_name', 'sub_group_name', 'action', 'guard_name']);
+ });
+
+ Schema::create($tableNames['roles'], function (Blueprint $table) use ($teams, $columnNames) {
+ //$table->engine('InnoDB');
+ $table->bigIncrements('id'); // role id
+ if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing
+ $table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable();
+ $table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index');
+ }
+ $table->string('name'); // For MyISAM use string('name', 225); // (or 166 for InnoDB with Redundant/Compact row format)
+ $table->string('style')->nullable();
+ $table->string('guard_name'); // For MyISAM use string('guard_name', 25);
+ $table->timestamps();
+ if ($teams || config('permission.testing')) {
+ $table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']);
+ } else {
+ $table->unique(['name', 'guard_name']);
+ }
+ });
+
+ Schema::create($tableNames['model_has_permissions'], function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) {
+ $table->unsignedBigInteger($pivotPermission);
+
+ $table->string('model_type');
+ $table->unsignedBigInteger($columnNames['model_morph_key']);
+ $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index');
+
+ $table->foreign($pivotPermission)
+ ->references('id') // permission id
+ ->on($tableNames['permissions'])
+ ->onDelete('cascade');
+ if ($teams) {
+ $table->unsignedBigInteger($columnNames['team_foreign_key']);
+ $table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index');
+
+ $table->primary(
+ [$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'],
+ 'model_has_permissions_permission_model_type_primary'
+ );
+ } else {
+ $table->primary(
+ [$pivotPermission, $columnNames['model_morph_key'], 'model_type'],
+ 'model_has_permissions_permission_model_type_primary'
+ );
+ }
+ });
+
+ Schema::create($tableNames['model_has_roles'], function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) {
+ $table->unsignedBigInteger($pivotRole);
+
+ $table->string('model_type');
+ $table->unsignedBigInteger($columnNames['model_morph_key']);
+ $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index');
+
+ $table->foreign($pivotRole)
+ ->references('id') // role id
+ ->on($tableNames['roles'])
+ ->onDelete('cascade');
+ if ($teams) {
+ $table->unsignedBigInteger($columnNames['team_foreign_key']);
+ $table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index');
+
+ $table->primary(
+ [$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'],
+ 'model_has_roles_role_model_type_primary'
+ );
+ } else {
+ $table->primary(
+ [$pivotRole, $columnNames['model_morph_key'], 'model_type'],
+ 'model_has_roles_role_model_type_primary'
+ );
+ }
+ });
+
+ Schema::create($tableNames['role_has_permissions'], function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) {
+ $table->unsignedBigInteger($pivotPermission);
+ $table->unsignedBigInteger($pivotRole);
+
+ $table->foreign($pivotPermission)
+ ->references('id') // permission id
+ ->on($tableNames['permissions'])
+ ->onDelete('cascade');
+
+ $table->foreign($pivotRole)
+ ->references('id') // role id
+ ->on($tableNames['roles'])
+ ->onDelete('cascade');
+
+ $table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary');
+ });
+
+ app('cache')
+ ->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null)
+ ->forget(config('permission.cache.key'));
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ $tableNames = config('permission.table_names');
+
+ if (empty($tableNames)) {
+ throw new \Exception('Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.');
+ }
+
+ Schema::drop($tableNames['role_has_permissions']);
+ Schema::drop($tableNames['model_has_roles']);
+ Schema::drop($tableNames['model_has_permissions']);
+ Schema::drop($tableNames['roles']);
+ Schema::drop($tableNames['permissions']);
+ }
+};
diff --git a/database/migrations/2024_12_14_081739_add_two_factor_columns_to_users_table.php b/database/migrations/2024_12_14_081739_add_two_factor_columns_to_users_table.php
new file mode 100644
index 0000000..b490e24
--- /dev/null
+++ b/database/migrations/2024_12_14_081739_add_two_factor_columns_to_users_table.php
@@ -0,0 +1,46 @@
+text('two_factor_secret')
+ ->after('password')
+ ->nullable();
+
+ $table->text('two_factor_recovery_codes')
+ ->after('two_factor_secret')
+ ->nullable();
+
+ if (Fortify::confirmsTwoFactorAuthentication()) {
+ $table->timestamp('two_factor_confirmed_at')
+ ->after('two_factor_recovery_codes')
+ ->nullable();
+ }
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('users', function (Blueprint $table) {
+ $table->dropColumn(array_merge([
+ 'two_factor_secret',
+ 'two_factor_recovery_codes',
+ ], Fortify::confirmsTwoFactorAuthentication() ? [
+ 'two_factor_confirmed_at',
+ ] : []));
+ });
+ }
+};
diff --git a/database/migrations/2024_12_14_082234_create_settings_table.php b/database/migrations/2024_12_14_082234_create_settings_table.php
new file mode 100644
index 0000000..db08618
--- /dev/null
+++ b/database/migrations/2024_12_14_082234_create_settings_table.php
@@ -0,0 +1,37 @@
+mediumIncrements('id');
+
+ $table->string('key')->index();
+ $table->text('value');
+ $table->unsignedMediumInteger('user_id')->nullable()->index();
+
+ // Unique constraints
+ $table->unique(['user_id', 'key']);
+
+ // Relaciones
+ $table->foreign('user_id')->references('id')->on('users');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('settings');
+ }
+
+};
diff --git a/database/migrations/2024_12_14_083409_create_media_items_table.php b/database/migrations/2024_12_14_083409_create_media_items_table.php
new file mode 100644
index 0000000..aa1a5cb
--- /dev/null
+++ b/database/migrations/2024_12_14_083409_create_media_items_table.php
@@ -0,0 +1,48 @@
+mediumIncrements('id');
+
+ // Relación polimórfica
+ $table->unsignedMediumInteger('mediaable_id');
+ $table->string('mediaable_type');
+
+ $table->unsignedTinyInteger('type')->index(); // Tipo de medio: 'image', 'video', 'file', 'youtube'
+ $table->unsignedTinyInteger('sub_type')->index(); // Subtipo de medio: 'thumbnail', 'main', 'additional'
+
+ $table->string('url', 255)->nullable(); // URL del medio
+ $table->string('path')->nullable(); // Ruta del archivo si está almacenado localmente
+
+ $table->string('title')->nullable()->index(); // Título del medio
+ $table->mediumText('description')->nullable(); // Descripción del medio
+ $table->unsignedTinyInteger('order')->nullable(); // Orden de presentación
+
+ // Authoría
+ $table->timestamps();
+
+ // Índices
+ $table->index(['mediaable_type', 'mediaable_id']);
+ $table->index(['mediaable_type', 'mediaable_id', 'type']);
+ });
+
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('images');
+ }
+};
diff --git a/database/migrations/2024_12_14_092026_create_audits_table.php b/database/migrations/2024_12_14_092026_create_audits_table.php
new file mode 100644
index 0000000..709069d
--- /dev/null
+++ b/database/migrations/2024_12_14_092026_create_audits_table.php
@@ -0,0 +1,52 @@
+create($table, function (Blueprint $table) {
+
+ $morphPrefix = config('audit.user.morph_prefix', 'user');
+
+ $table->bigIncrements('id');
+ $table->string($morphPrefix . '_type')->nullable();
+ $table->unsignedBigInteger($morphPrefix . '_id')->nullable();
+ $table->string('event');
+ $table->morphs('auditable');
+ $table->text('old_values')->nullable();
+ $table->text('new_values')->nullable();
+ $table->text('url')->nullable();
+ $table->ipAddress('ip_address')->nullable();
+ $table->string('user_agent', 1023)->nullable();
+ $table->string('tags')->nullable();
+ $table->timestamps();
+
+ $table->index([$morphPrefix . '_id', $morphPrefix . '_type']);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ $connection = config('audit.drivers.database.connection', config('database.default'));
+ $table = config('audit.drivers.database.table', 'audits');
+
+ Schema::connection($connection)->drop($table);
+ }
+};
diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php
new file mode 100644
index 0000000..88a695c
--- /dev/null
+++ b/database/seeders/PermissionSeeder.php
@@ -0,0 +1,14 @@
+ 'Quimiplastic S.A de C.V.',
+ 'app_faviconIcon' => '../assets/img/logo/koneko-02.png',
+ 'app_name' => 'Quimiplastic',
+ 'app_imageLogo' => '../assets/img/logo/koneko-02.png',
+
+ 'app_myLayout' => 'vertical',
+ 'app_myTheme' => 'theme-default',
+ 'app_myStyle' => 'light',
+ 'app_navbarType' => 'sticky',
+ 'app_menuFixed' => true,
+ 'app_menuCollapsed' => false,
+ 'app_headerType' => 'static',
+ 'app_showDropdownOnHover' => false,
+ 'app_authViewMode' => 'cover',
+ 'app_maxQuickLinks' => 5,
+
+
+
+ 'smtp.host' => 'webmail.koneko.mx',
+ 'smtp.port' => 465,
+ 'smtp.encryption' => 'tls',
+ 'smtp.username' => 'no-responder@koneko.mx',
+ 'smtp.password' => null,
+ 'smtp.from_email' => 'no-responder@koneko.mx',
+ 'smtp.from_name' => 'Koneko Soluciones en Tecnología',
+ 'smtp.reply_to_method' => 'smtp',
+ 'smtp.reply_to_email' => null,
+ 'smtp.reply_to_name' => null,
+
+
+
+ 'website.title',
+ 'website.favicon',
+ 'website.description',
+ 'website.image_logo',
+ 'website.image_logoDark',
+
+ 'admin.title',
+ 'admin.favicon',
+ 'admin.description',
+ 'admin.image_logo',
+ 'admin.image_logoDark',
+
+
+ 'favicon.icon' => null,
+
+ 'contact.phone_number' => '(222) 462 0903',
+ 'contact.phone_number_ext' => 'Ext. 5',
+ 'contact.email' => 'virtualcompras@live.com.mx',
+ 'contact.form.email' => 'contacto@conciergetravellife.com',
+ 'contact.form.email_cc' => 'arturo@koneko.mx',
+ 'contact.form.subject' => 'Has recibido un mensaje del formulario de covirsast.com',
+ 'contact.direccion' => '51 PTE 505 loc. 14, Puebla, Pue.',
+ 'contact.horario' => '9am - 7 pm',
+ 'contact.location.lat' => '19.024439',
+ 'contact.location.lng' => '-98.215777',
+
+ 'social.whatsapp' => '',
+ 'social.whatsapp.message' => '👋 Hola! Estoy buscando más información sobre Covirsa Soluciones en Tecnología. ¿Podrías proporcionarme los detalles que necesito? ¡Te lo agradecería mucho! 💻✨',
+
+ 'social.facebook' => 'https://www.facebook.com/covirsast/?locale=es_LA',
+ 'social.Whatsapp' => '2228 200 201',
+ 'social.Whatsapp.message' => '¡Hola! 🌟 Estoy interesado en obtener más información acerca de Concierge Travel. ¿Podrías ayudarme con los detalles? ¡Gracias de antemano! ✈️🏝',
+ 'social.Facebook' => 'test',
+ 'social.Instagram' => 'test',
+ 'social.Linkedin' => 'test',
+ 'social.Tiktok' => 'test',
+ 'social.X_twitter' => 'test',
+ 'social.Google' => 'test',
+ 'social.Pinterest' => 'test',
+ 'social.Youtube' => 'test',
+ 'social.Vimeo' => 'test',
+
+
+ 'chat.provider' => '',
+ 'chat.whatsapp.number' => '',
+ 'chat.whatsapp.message' => '👋 Hola! Estoy buscando más información sobre Covirsa Soluciones en Tecnología. ¿Podrías proporcionarme los detalles que necesito? ¡Te lo agradecería mucho! 💻✨',
+
+ 'webTpl.container' => 'custom-container',
+ */
+ ];
+
+ foreach ($settings_array as $key => $value) {
+ Setting::create([
+ 'key' => $key,
+ 'value' => $value,
+ ]);
+ };
+ }
+}
diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php
new file mode 100644
index 0000000..8565e42
--- /dev/null
+++ b/database/seeders/UserSeeder.php
@@ -0,0 +1,97 @@
+exists($directory))
+ Storage::disk($disk)->deleteDirectory($directory);
+
+ //
+ $avatarImageService = new AvatarImageService();
+
+ // Super admin
+ $user = User::create([
+ 'name' => 'Koneko Admin',
+ 'email' => 'sadmin@koneko.mx',
+ 'email_verified_at' => now(),
+ 'password' => bcrypt('LAdmin123'),
+ 'status' => User::STATUS_ENABLED,
+ ])->assignRole('SuperAdmin');
+
+ // Actualizamos la foto
+ $avatarImageService->updateProfilePhoto($user, new UploadedFile(
+ 'public/vendor/vuexy-admin/img/logo/koneko-02.png',
+ 'koneko-02.png'
+ ));
+
+
+ // admin
+ $user = User::create([
+ 'name' => 'Admin',
+ 'email' => 'admin@koneko.mx',
+ 'email_verified_at' => now(),
+ 'password' => bcrypt('LAdmin123'),
+ 'status' => User::STATUS_ENABLED,
+ ])->assignRole('Admin');
+
+ $avatarImageService->updateProfilePhoto($user, new UploadedFile(
+ 'public/vendor/vuexy-admin/img/logo/koneko-03.png',
+ 'koneko-03.png'
+ ));
+
+ // Auditor
+ $user = User::create([
+ 'name' => 'Auditor',
+ 'email' => 'auditor@koneko.mx',
+ 'email_verified_at' => now(),
+ 'password' => bcrypt('LAdmin123'),
+ 'status' => User::STATUS_ENABLED,
+ ])->assignRole('Auditor');
+
+ $avatarImageService->updateProfilePhoto($user, new UploadedFile(
+ 'public/vendor/vuexy-admin/img/logo/koneko-03.png',
+ 'koneko-03.png'
+ ));
+
+
+ // Usuarios CSV
+ $csvFile = fopen(base_path("database/data/users.csv"), "r");
+
+ $firstline = true;
+
+ while (($data = fgetcsv($csvFile, 2000, ",")) !== FALSE) {
+ if (!$firstline) {
+ User::create([
+ 'name' => $data['0'],
+ 'email' => $data['1'],
+ 'email_verified_at' => now(),
+ 'password' => bcrypt($data['3']),
+ 'status' => User::STATUS_ENABLED,
+ ])->assignRole($data['2']);
+ }
+
+ $firstline = false;
+ }
+
+ fclose($csvFile);
+ }
+}
diff --git a/resources/assets/css/demo.css b/resources/assets/css/demo.css
new file mode 100644
index 0000000..ec996c1
--- /dev/null
+++ b/resources/assets/css/demo.css
@@ -0,0 +1,129 @@
+/*
+* demo.css
+* File include item demo only specific css only
+******************************************************************************/
+
+.light-style .menu .app-brand.demo {
+ height: 64px;
+}
+
+.dark-style .menu .app-brand.demo {
+ height: 64px;
+}
+
+.app-brand-logo.demo {
+ -ms-flex-align: center;
+ align-items: center;
+ -ms-flex-pack: center;
+ justify-content: center;
+ display: -ms-flexbox;
+ display: flex;
+ width: 34px;
+ height: 24px;
+}
+
+.app-brand-logo.demo svg {
+ width: 35px;
+ height: 24px;
+}
+
+.app-brand-text.demo {
+ font-size: 1.375rem;
+}
+
+/* ! For .layout-navbar-fixed added fix padding top tpo .layout-page */
+.layout-navbar-fixed .layout-wrapper:not(.layout-without-menu) .layout-page {
+ padding-top: 64px !important;
+}
+.layout-navbar-fixed .layout-wrapper:not(.layout-horizontal):not(.layout-without-menu) .layout-page {
+ padding-top: 72px !important;
+}
+/* Navbar page z-index issue solution */
+.content-wrapper .navbar {
+ z-index: auto;
+}
+
+/*
+* Content
+******************************************************************************/
+
+.demo-blocks > * {
+ display: block !important;
+}
+
+.demo-inline-spacing > * {
+ margin: 1rem 0.375rem 0 0 !important;
+}
+
+/* ? .demo-vertical-spacing class is used to have vertical margins between elements. To remove margin-top from the first-child, use .demo-only-element class with .demo-vertical-spacing class. For example, we have used this class in forms-input-groups.html file. */
+.demo-vertical-spacing > * {
+ margin-top: 1rem !important;
+ margin-bottom: 0 !important;
+}
+.demo-vertical-spacing.demo-only-element > :first-child {
+ margin-top: 0 !important;
+}
+
+.demo-vertical-spacing-lg > * {
+ margin-top: 1.875rem !important;
+ margin-bottom: 0 !important;
+}
+.demo-vertical-spacing-lg.demo-only-element > :first-child {
+ margin-top: 0 !important;
+}
+
+.demo-vertical-spacing-xl > * {
+ margin-top: 5rem !important;
+ margin-bottom: 0 !important;
+}
+.demo-vertical-spacing-xl.demo-only-element > :first-child {
+ margin-top: 0 !important;
+}
+
+.rtl-only {
+ display: none !important;
+ text-align: left !important;
+ direction: ltr !important;
+}
+
+[dir='rtl'] .rtl-only {
+ display: block !important;
+}
+
+/* Dropdown buttons going out of small screens */
+@media (max-width: 576px) {
+ #dropdown-variation-demo .btn-group .text-truncate {
+ width: 254px;
+ position: relative;
+ }
+ #dropdown-variation-demo .btn-group .text-truncate::after {
+ position: absolute;
+ top: 45%;
+ right: 0.65rem;
+ }
+}
+
+/*
+* Layout demo
+******************************************************************************/
+
+.layout-demo-wrapper {
+ display: -webkit-box;
+ display: -ms-flexbox;
+ display: flex;
+ -webkit-box-align: center;
+ -ms-flex-align: center;
+ align-items: center;
+ -webkit-box-orient: vertical;
+ -webkit-box-direction: normal;
+ -ms-flex-direction: column;
+ flex-direction: column;
+ margin-top: 1rem;
+}
+.layout-demo-placeholder img {
+ width: 900px;
+}
+.layout-demo-info {
+ text-align: center;
+ margin-top: 1rem;
+}
diff --git a/resources/assets/js/bootstrap-table/bootstrapTableManager.js b/resources/assets/js/bootstrap-table/bootstrapTableManager.js
new file mode 100644
index 0000000..9e272c0
--- /dev/null
+++ b/resources/assets/js/bootstrap-table/bootstrapTableManager.js
@@ -0,0 +1,245 @@
+import '../../vendor/libs/bootstrap-table/bootstrap-table';
+import '../notifications/LivewireNotification';
+
+class BootstrapTableManager {
+ constructor(bootstrapTableWrap, config = {}) {
+ const defaultConfig = {
+ header: [],
+ format: [],
+ search_columns: [],
+ actionColumn: false,
+ height: 'auto',
+ minHeight: 300,
+ bottomMargin : 195,
+ search: true,
+ showColumns: true,
+ showColumnsToggleAll: true,
+ showExport: true,
+ exportfileName: 'datatTable',
+ exportWithDatetime: true,
+ showFullscreen: true,
+ showPaginationSwitch: true,
+ showRefresh: true,
+ showToggle: true,
+ /*
+ smartDisplay: false,
+ searchOnEnterKey: true,
+ showHeader: false,
+ showFooter: true,
+ showRefresh: true,
+ showToggle: true,
+ showFullscreen: true,
+ detailView: true,
+ searchAlign: 'right',
+ buttonsAlign: 'right',
+ toolbarAlign: 'left',
+ paginationVAlign: 'bottom',
+ paginationHAlign: 'right',
+ paginationDetailHAlign: 'left',
+ paginationSuccessivelySize: 5,
+ paginationPagesBySide: 3,
+ paginationUseIntermediate: true,
+ */
+ clickToSelect: true,
+ minimumCountColumns: 4,
+ fixedColumns: true,
+ fixedNumber: 1,
+ idField: 'id',
+ pagination: true,
+ pageList: [25, 50, 100, 500, 1000],
+ sortName: 'id',
+ sortOrder: 'asc',
+ cookie: false,
+ cookieExpire: '365d',
+ cookieIdTable: 'myTableCookies', // Nombre único para las cookies de la tabla
+ cookieStorage: 'localStorage',
+ cookiePath: '/',
+ };
+
+ this.$bootstrapTable = $('.bootstrap-table', bootstrapTableWrap);
+ this.$toolbar = $('.bt-toolbar', bootstrapTableWrap);
+ this.$searchColumns = $('.search_columns', bootstrapTableWrap);
+ this.$btnRefresh = $('.btn-refresh', bootstrapTableWrap);
+ this.$btnClearFilters = $('.btn-clear-filters', bootstrapTableWrap);
+
+ this.config = { ...defaultConfig, ...config };
+
+ this.config.toolbar = `${bootstrapTableWrap} .bt-toolbar`;
+ this.config.height = this.config.height == 'auto'? this.getTableHeight(): this.config.height;
+ this.config.cookieIdTable = this.config.exportWithDatetime? this.config.cookieIdTable + '-' + this.getFormattedDateYMDHm(): this.config.cookieIdTable;
+
+ this.tableFormatters = {}; // Mueve la carga de formatters aquí
+
+ this.initTable();
+ }
+
+ /**
+ * Calcula la altura de la tabla.
+ */
+ getTableHeight() {
+ const btHeight = window.innerHeight - this.$toolbar.height() - this.bottomMargin;
+
+ return btHeight < this.config.minHeight ? this.config.minHeight : btHeight;
+ }
+
+ /**
+ * Genera un ID único para la tabla basado en una cookie.
+ */
+ getCookieId() {
+ const generateShortHash = (str) => {
+ let hash = 0;
+
+ for (let i = 0; i < str.length; i++) {
+ const char = str.charCodeAt(i);
+
+ hash = (hash << 5) - hash + char;
+ hash &= hash; // Convertir a entero de 32 bits
+ }
+
+ return Math.abs(hash).toString().substring(0, 12);
+ };
+
+ return `bootstrap-table-cache-${generateShortHash(this.config.title)}`;
+ }
+
+ /**
+ * Carga los formatters dinámicamente
+ */
+ async loadFormatters() {
+ const formattersModules = import.meta.glob('../../../../../**/resources/assets/js/bootstrap-table/*Formatters.js');
+
+ const formatterPromises = Object.entries(formattersModules).map(async ([path, importer]) => {
+ const module = await importer();
+ Object.assign(this.tableFormatters, module);
+ });
+
+ await Promise.all(formatterPromises);
+ }
+
+ btColumns() {
+ const columns = [];
+
+ Object.entries(this.config.header).forEach(([key, value]) => {
+ const columnFormat = this.config.format[key] || {};
+
+ if (typeof columnFormat.formatter === 'object') {
+ const formatterName = columnFormat.formatter.name;
+ const formatterParams = columnFormat.formatter.params || {};
+
+ const formatterFunction = this.tableFormatters[formatterName];
+ if (formatterFunction) {
+ columnFormat.formatter = (value, row, index) => formatterFunction(value, row, index, formatterParams);
+ } else {
+ console.warn(`Formatter "${formatterName}" no encontrado para la columna "${key}"`);
+ }
+ } else if (typeof columnFormat.formatter === 'string') {
+ const formatterFunction = this.tableFormatters[columnFormat.formatter];
+ if (formatterFunction) {
+ columnFormat.formatter = formatterFunction;
+ }
+ }
+
+ if (columnFormat.onlyFormatter) {
+ columns.push({
+ align: 'center',
+ formatter: columnFormat.formatter || (() => ''),
+ forceHide: true,
+ switchable: false,
+ field: key,
+ title: value,
+ });
+ return;
+ }
+
+ const column = {
+ title: value,
+ field: key,
+ sortable: true,
+ };
+
+ columns.push({ ...column, ...columnFormat });
+ });
+
+ return columns;
+ }
+
+
+
+ /**
+ * Petición AJAX para la tabla.
+ */
+ ajaxRequest(params) {
+ const url = `${window.location.href}?${$.param(params.data)}&${$('.bt-toolbar :input').serialize()}`;
+
+ $.get(url).then((res) => params.success(res));
+ }
+
+ toValidFilename(str, extension = 'txt') {
+ return str
+ .normalize("NFD") // 🔹 Normaliza caracteres con tilde
+ .replace(/[\u0300-\u036f]/g, "") // 🔹 Elimina acentos y diacríticos
+ .replace(/[<>:"\/\\|?*\x00-\x1F]/g, '') // 🔹 Elimina caracteres inválidos
+ .replace(/\s+/g, '-') // 🔹 Reemplaza espacios con guiones
+ .replace(/-+/g, '-') // 🔹 Evita múltiples guiones seguidos
+ .replace(/^-+|-+$/g, '') // 🔹 Elimina guiones al inicio y fin
+ .toLowerCase() // 🔹 Convierte a minúsculas
+ + (extension ? '.' + extension.replace(/^\.+/, '') : ''); // 🔹 Asegura la extensión válida
+ }
+
+ getFormattedDateYMDHm(date = new Date()) {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0'); // 🔹 Asegura dos dígitos
+ const day = String(date.getDate()).padStart(2, '0');
+ const hours = String(date.getHours()).padStart(2, '0');
+ const minutes = String(date.getMinutes()).padStart(2, '0');
+
+ return `${year}${month}${day}-${hours}${minutes}`;
+ }
+
+
+ /**
+ * Inicia la tabla después de cargar los formatters
+ */
+ async initTable() {
+ await this.loadFormatters(); // Asegura que los formatters estén listos antes de inicializar
+
+ this.$bootstrapTable
+ .bootstrapTable('destroy').bootstrapTable({
+ height: this.config.height,
+ locale: 'es-MX',
+ ajax: (params) => this.ajaxRequest(params),
+ toolbar: this.config.toolbar,
+ search: this.config.search,
+ showColumns: this.config.showColumns,
+ showColumnsToggleAll: this.config.showColumnsToggleAll,
+ showExport: this.config.showExport,
+ exportTypes: ['csv', 'txt', 'xlsx'],
+ exportOptions: {
+ fileName: this.config.fileName,
+ },
+ showFullscreen: this.config.showFullscreen,
+ showPaginationSwitch: this.config.showPaginationSwitch,
+ showRefresh: this.config.showRefresh,
+ showToggle: this.config.showToggle,
+ clickToSelect: this.config.clickToSelect,
+ minimumCountColumns: this.config.minimumCountColumns,
+ fixedColumns: this.config.fixedColumns,
+ fixedNumber: this.config.fixedNumber,
+ idField: this.config.idField,
+ pagination: this.config.pagination,
+ pageList: this.config.pageList,
+ sidePagination: "server",
+ sortName: this.config.sortName,
+ sortOrder: this.config.sortOrder,
+ mobileResponsive: true,
+ resizable: true,
+ cookie: this.config.cookie,
+ cookieExpire: this.config.cookieExpire,
+ cookieIdTable: this.config.cookieIdTable,
+ columns: this.btColumns(),
+ });
+ }
+
+}
+
+window.BootstrapTableManager = BootstrapTableManager;
diff --git a/resources/assets/js/bootstrap-table/globalConfig.js b/resources/assets/js/bootstrap-table/globalConfig.js
new file mode 100644
index 0000000..7276fd9
--- /dev/null
+++ b/resources/assets/js/bootstrap-table/globalConfig.js
@@ -0,0 +1,132 @@
+const appRoutesElement = document.getElementById('app-routes');
+
+export const routes = appRoutesElement ? JSON.parse(appRoutesElement.textContent) : {};
+
+export const booleanStatusCatalog = {
+ activo: {
+ trueText: 'Activo',
+ falseText: 'Inactivo',
+ trueClass: 'badge bg-label-success',
+ falseClass: 'badge bg-label-danger',
+ },
+ habilitado: {
+ trueText: 'Habilitado',
+ falseText: 'Deshabilitado',
+ trueClass: 'badge bg-label-success',
+ falseClass: 'badge bg-label-danger',
+ trueIcon: 'ti ti-checkup-list',
+ falseIcon: 'ti ti-ban',
+ },
+ checkSI: {
+ trueText: 'SI',
+ falseIcon: '',
+ trueClass: 'badge bg-label-info',
+ falseText: '',
+ },
+ check: {
+ trueIcon: 'ti ti-check',
+ falseIcon: '',
+ trueClass: 'text-green-800',
+ falseClass: '',
+ },
+ checkbox: {
+ trueIcon: 'ti ti-checkbox',
+ falseIcon: '',
+ trueClass: 'text-green-800',
+ falseClass: '',
+ },
+ checklist: {
+ trueIcon: 'ti ti-checklist',
+ falseIcon: '',
+ trueClass: 'text-green-800',
+ falseClass: '',
+ },
+ phone_done: {
+ trueIcon: 'ti ti-phone-done',
+ falseIcon: '',
+ trueClass: 'text-green-800',
+ falseClass: '',
+ },
+ checkup_list: {
+ trueIcon: 'ti ti-checkup-list',
+ falseIcon: '',
+ trueClass: 'text-green-800',
+ falseClass: '',
+ },
+ list_check: {
+ trueIcon: 'ti ti-list-check',
+ falseIcon: '',
+ trueClass: 'text-green-800',
+ falseClass: '',
+ },
+ camera_check: {
+ trueIcon: 'ti ti-camera-check',
+ falseIcon: '',
+ trueClass: 'text-green-800',
+ falseClass: '',
+ },
+ mail_check: {
+ trueIcon: 'ti ti-mail-check',
+ falseIcon: '',
+ trueClass: 'text-green-800',
+ falseClass: '',
+ },
+ clock_check: {
+ trueIcon: 'ti ti-clock-check',
+ falseIcon: '',
+ trueClass: 'text-green-800',
+ falseClass: '',
+ },
+ user_check: {
+ trueIcon: 'ti ti-user-check',
+ falseIcon: '',
+ trueClass: 'text-green-800',
+ falseClass: '',
+ },
+ circle_check: {
+ trueIcon: 'ti ti-circle-check',
+ falseIcon: '',
+ trueClass: 'text-green-800',
+ falseClass: '',
+ },
+ shield_check: {
+ trueIcon: 'ti ti-shield-check',
+ falseIcon: '',
+ trueClass: 'text-green-800',
+ falseClass: '',
+ },
+ calendar_check: {
+ trueIcon: 'ti ti-calendar-check',
+ falseIcon: '',
+ trueClass: 'text-green-800',
+ falseClass: '',
+ }
+};
+
+export const badgeColorCatalog = {
+ primary: { color: 'primary' },
+ secondary: { color: 'secondary' },
+ success: { color: 'success' },
+ danger: { color: 'danger' },
+ warning: { color: 'warning' },
+ info: { color: 'info' },
+ dark: { color: 'dark' },
+ light: { color: 'light', textColor: 'text-dark' }
+};
+
+export const statusIntBadgeBgCatalogCss = {
+ 1: 'warning',
+ 2: 'info',
+ 10: 'success',
+ 12: 'danger',
+ 11: 'warning'
+};
+
+export const statusIntBadgeBgCatalog = {
+ 1: 'Inactivo',
+ 2: 'En proceso',
+ 10: 'Activo',
+ 11: 'Archivado',
+ 12: 'Cancelado',
+};
+
diff --git a/resources/assets/js/bootstrap-table/globalFormatters.js b/resources/assets/js/bootstrap-table/globalFormatters.js
new file mode 100644
index 0000000..909c5ab
--- /dev/null
+++ b/resources/assets/js/bootstrap-table/globalFormatters.js
@@ -0,0 +1,193 @@
+import { booleanStatusCatalog, statusIntBadgeBgCatalogCss, statusIntBadgeBgCatalog } from './globalConfig';
+import {routes} from '../../../../../laravel-vuexy-admin/resources/assets/js/bootstrap-table/globalConfig.js';
+
+export const userActionFormatter = (value, row, index) => {
+ if (!row.id) return '';
+
+ const showUrl = routes['admin.user.show'].replace(':id', row.id);
+ const editUrl = routes['admin.user.edit'].replace(':id', row.id);
+ const deleteUrl = routes['admin.user.delete'].replace(':id', row.id);
+
+ return `
+
+ `.trim();
+};
+
+export const dynamicBooleanFormatter = (value, row, index, options = {}) => {
+ const { tag = 'default', customOptions = {} } = options;
+ const catalogConfig = booleanStatusCatalog[tag] || {};
+
+ const finalOptions = {
+ ...catalogConfig,
+ ...customOptions, // Permite sobreescribir la configuración predeterminada
+ ...options // Permite pasar opciones rápidas
+ };
+
+ const {
+ trueIcon = '',
+ falseIcon = '',
+ trueText = 'Sí',
+ falseText = 'No',
+ trueClass = 'badge bg-label-success',
+ falseClass = 'badge bg-label-danger',
+ iconClass = 'text-green-800'
+ } = finalOptions;
+
+ const trueElement = !trueIcon && !trueText ? '' : `${trueIcon ? ` ` : ''}${trueText}`;
+ const falseElement = !falseIcon && !falseText ? '' : `${falseIcon ? ` ` : ''}${falseText}`;
+
+ return value? trueElement : falseElement;
+};
+
+export const dynamicBadgeFormatter = (value, row, index, options = {}) => {
+ const {
+ color = 'primary', // Valor por defecto
+ textColor = '', // Permite agregar color de texto si es necesario
+ additionalClass = '' // Permite añadir clases adicionales
+ } = options;
+
+ return `${value}`;
+};
+
+export const statusIntBadgeBgFormatter = (value, row, index) => {
+ return value
+ ? `${statusIntBadgeBgCatalog[value]}`
+ : '';
+}
+
+export const textNowrapFormatter = (value, row, index) => {
+ if (!value) return '';
+ return `${value}`;
+}
+
+
+export const toCurrencyFormatter = (value, row, index) => {
+ return isNaN(value) ? '' : Number(value).toCurrency();
+}
+
+export const numberFormatter = (value, row, index) => {
+ return isNaN(value) ? '' : Number(value);
+}
+
+export const monthFormatter = (value, row, index) => {
+ switch (parseInt(value)) {
+ case 1:
+ return 'Enero';
+ case 2:
+ return 'Febrero';
+ case 3:
+ return 'Marzo';
+ case 4:
+ return 'Abril';
+ case 5:
+ return 'Mayo';
+ case 6:
+ return 'Junio';
+ case 7:
+ return 'Julio';
+ case 8:
+ return 'Agosto';
+ case 9:
+ return 'Septiembre';
+ case 10:
+ return 'Octubre';
+ case 11:
+ return 'Noviembre';
+ case 12:
+ return 'Diciembre';
+ }
+}
+
+export const humaneTimeFormatter = (value, row, index) => {
+ return isNaN(value) ? '' : Number(value).humaneTime();
+}
+
+/**
+ * Genera la URL del avatar basado en iniciales o devuelve la foto de perfil si está disponible.
+ * @param {string} fullName - Nombre completo del usuario.
+ * @param {string|null} profilePhoto - Ruta de la foto de perfil.
+ * @returns {string} - URL del avatar.
+ */
+function getAvatarUrl(fullName, profilePhoto) {
+ const baseUrl = window.baseUrl || '';
+
+ if (profilePhoto) {
+ return `${baseUrl}storage/profile-photos/${profilePhoto}`;
+ }
+
+ return `${baseUrl}admin/usuario/avatar/?name=${fullName}`;
+}
+
+/**
+ * Formatea la columna del perfil de usuario con avatar, nombre y correo.
+ */
+export const userProfileFormatter = (value, row, index) => {
+ if (!row.id) return '';
+
+ const profileUrl = routes['admin.user.show'].replace(':id', row.id);
+ const avatar = getAvatarUrl(row.full_name, row.profile_photo_path);
+ const email = row.email ? row.email : 'Sin correo';
+
+ return `
+
+ `;
+};
+
+/**
+ * Formatea la columna del perfil de contacto con avatar, nombre y correo.
+ */
+export const contactProfileFormatter = (value, row, index) => {
+ if (!row.id) return '';
+
+ const profileUrl = routes['admin.contact.show'].replace(':id', row.id);
+ const avatar = getAvatarUrl(row.full_name, row.profile_photo_path);
+ const email = row.email ? row.email : 'Sin correo';
+
+ return `
+
+ `;
+};
+
+
+
+export const creatorFormatter = (value, row, index) => {
+ if (!row.creator) return '';
+
+ const email = row.creator_email || 'Sin correo';
+ const showUrl = routes['admin.user.show'].replace(':id', row.id);
+
+
+ return `
+
+ `;
+};
+
diff --git a/resources/assets/js/config.js b/resources/assets/js/config.js
new file mode 100644
index 0000000..a1316d7
--- /dev/null
+++ b/resources/assets/js/config.js
@@ -0,0 +1,53 @@
+/**
+ * Config
+ * -------------------------------------------------------------------------------------
+ * ! IMPORTANT: Make sure you clear the browser local storage In order to see the config changes in the template.
+ * ! To clear local storage: (https://www.leadshook.com/help/how-to-clear-local-storage-in-google-chrome-browser/).
+ */
+
+'use strict';
+
+// JS global variables
+window.config = {
+ colors: {
+ primary: '#7367f0',
+ secondary: '#808390',
+ success: '#28c76f',
+ info: '#00bad1',
+ warning: '#ff9f43',
+ danger: '#FF4C51',
+ dark: '#4b4b4b',
+ black: '#000',
+ white: '#fff',
+ cardColor: '#fff',
+ bodyBg: '#f8f7fa',
+ bodyColor: '#6d6b77',
+ headingColor: '#444050',
+ textMuted: '#acaab1',
+ borderColor: '#e6e6e8'
+ },
+ colors_label: {
+ primary: '#7367f029',
+ secondary: '#a8aaae29',
+ success: '#28c76f29',
+ info: '#00cfe829',
+ warning: '#ff9f4329',
+ danger: '#ea545529',
+ dark: '#4b4b4b29'
+ },
+ colors_dark: {
+ cardColor: '#2f3349',
+ bodyBg: '#25293c',
+ bodyColor: '#b2b1cb',
+ headingColor: '#cfcce4',
+ textMuted: '#8285a0',
+ borderColor: '#565b79'
+ },
+ enableMenuLocalStorage: true // Enable menu state with local storage support
+};
+
+window.assetsPath = document.documentElement.getAttribute('data-assets-path');
+window.baseUrl = document.documentElement.getAttribute('data-base-url');
+window.quicklinksUpdateUrl = document.documentElement.getAttribute('data-quicklinks-update-url');
+window.templateName = document.documentElement.getAttribute('data-template');
+window.rtlSupport = false; // set true for rtl support (rtl + ltr), false for ltr only.
diff --git a/resources/assets/js/forms/formConvasHelper.js b/resources/assets/js/forms/formConvasHelper.js
new file mode 100644
index 0000000..3dc6b70
--- /dev/null
+++ b/resources/assets/js/forms/formConvasHelper.js
@@ -0,0 +1,477 @@
+/**
+ * FormCanvasHelper
+ *
+ * Clase para orquestar la interacción entre un formulario dentro de un Offcanvas
+ * de Bootstrap y el estado de Livewire (modo create/edit/delete), además de
+ * manipular ciertos componentes externos como Select2.
+ *
+ * Se diseñó teniendo en cuenta que el DOM del Offcanvas puede reconstruirse
+ * (re-render) de manera frecuente, por lo que muchos getters reacceden al DOM
+ * dinámicamente.
+ */
+export default class FormCanvasHelper {
+ /**
+ * @param {string} offcanvasId - ID del elemento Offcanvas en el DOM.
+ * @param {object} liveWireInstance - Instancia de Livewire asociada al formulario.
+ */
+ constructor(offcanvasId, liveWireInstance) {
+ this.offcanvasId = offcanvasId;
+ this.liveWireInstance = liveWireInstance;
+
+ // Validamos referencias mínimas para evitar errores tempranos
+ // Si alguna falta, se mostrará un error en consola.
+ this.validateInitialDomRefs();
+ }
+
+ /**
+ * Verifica la existencia básica de elementos en el DOM.
+ * Muestra errores en consola si faltan elementos críticos.
+ */
+ validateInitialDomRefs() {
+ const offcanvasEl = document.getElementById(this.offcanvasId);
+
+ if (!offcanvasEl) {
+ console.error(`❌ No se encontró el contenedor Offcanvas con ID: ${this.offcanvasId}`);
+ return;
+ }
+
+ const formEl = offcanvasEl.querySelector('form');
+ if (!formEl) {
+ console.error(`❌ No se encontró el formulario dentro de #${this.offcanvasId}`);
+ return;
+ }
+
+ const offcanvasTitle = offcanvasEl.querySelector('.offcanvas-title');
+ const submitButtons = formEl.querySelectorAll('.btn-submit');
+ const resetButtons = formEl.querySelectorAll('.btn-reset');
+
+ if (!offcanvasTitle || !submitButtons.length || !resetButtons.length) {
+ console.error(`❌ Faltan el título, botones de submit o reset dentro de #${this.offcanvasId}`);
+ }
+ }
+
+ /**
+ * Getter para el contenedor Offcanvas actual.
+ * Retorna siempre la referencia más reciente del DOM.
+ */
+ get offcanvasEl() {
+ return document.getElementById(this.offcanvasId);
+ }
+
+ /**
+ * Getter para el formulario dentro del Offcanvas.
+ */
+ get formEl() {
+ return this.offcanvasEl?.querySelector('form') ?? null;
+ }
+
+ /**
+ * Getter para el título del Offcanvas.
+ */
+ get offcanvasTitleEl() {
+ return this.offcanvasEl?.querySelector('.offcanvas-title') ?? null;
+ }
+
+ /**
+ * Getter para la instancia de Bootstrap Offcanvas.
+ * Siempre retorna la instancia más reciente en caso de re-render.
+ */
+ get offcanvasInstance() {
+ if (!this.offcanvasEl) return null;
+ return bootstrap.Offcanvas.getOrCreateInstance(this.offcanvasEl);
+ }
+
+ /**
+ * Retorna todos los botones de submit en el formulario.
+ */
+ get submitButtons() {
+ return this.formEl?.querySelectorAll('.btn-submit') ?? [];
+ }
+
+ /**
+ * Retorna todos los botones de reset en el formulario.
+ */
+ get resetButtons() {
+ return this.formEl?.querySelectorAll('.btn-reset') ?? [];
+ }
+
+ /**
+ * Método principal para manejar la recarga del Offcanvas según los estados en Livewire.
+ * Se encarga de resetear el formulario, limpiar errores y cerrar/abrir el Offcanvas
+ * según sea necesario.
+ *
+ * @param {string|null} triggerMode - Forzar la acción (e.g., 'reset', 'create'). Si no se especifica, se verifica según Livewire.
+ */
+ reloadOffcanvas(triggerMode = null) {
+ setTimeout(() => {
+ const mode = this.liveWireInstance.get('mode');
+ const successProcess = this.liveWireInstance.get('successProcess');
+ const validationError = this.liveWireInstance.get('validationError');
+
+ // Si se completa la acción o triggerMode = 'reset',
+ // reseteamos completamente y cerramos el Offcanvas.
+ if (triggerMode === 'reset' || successProcess) {
+ this.resetFormAndState('create');
+
+ return;
+ }
+
+ // Forzar modo create si se solicita explícitamente
+ if (triggerMode === 'create') {
+ // Evitamos re-reset si ya estamos en 'create'
+ if (mode === 'create') return;
+
+ this.resetFormAndState('create');
+
+ this.focusOnOpen();
+
+ return;
+ }
+
+ // Si no, simplemente preparamos la UI según el modo actual.
+ this.prepareOffcanvasUI(mode);
+
+ // Si hay errores de validación, reabrimos el Offcanvas para mostrarlos.
+ if (validationError) {
+ this.liveWireInstance.set('validationError', null, false);
+
+ return;
+ }
+
+ // Si estamos en edit o delete, solo abrimos el Offcanvas.
+ if (mode === 'edit' || mode === 'delete') {
+ this.clearErrors();
+
+ if(mode === 'edit') {
+ this.focusOnOpen();
+ }
+
+ return;
+ }
+ }, 20);
+ }
+
+ /**
+ * Reabre o fuerza la apertura del Offcanvas si hay errores de validación
+ * o si el modo de Livewire es 'edit' o 'delete'.
+ *
+ * Normalmente se llama cuando hay un dispatch/evento de Livewire,
+ * por ejemplo si el servidor devuelve un error de validación (para mostrarlo)
+ * o si se acaba de cargar un registro para editar o eliminar.
+ *
+ * - Si hay `validationError`, forzamos la reapertura con `toggleOffcanvas(true, true)`
+ * para que se refresque correctamente y el usuario vea los errores.
+ * - Si el modo es 'edit' o 'delete', simplemente mostramos el Offcanvas sin forzar
+ * un refresco de la interfaz.
+ */
+ refresh() {
+ setTimeout(() => {
+ const mode = this.liveWireInstance.get('mode');
+ const successProcess = this.liveWireInstance.get('successProcess');
+ const validationError = this.liveWireInstance.get('validationError');
+
+ // cerramos el Offcanvas.
+ if (successProcess) {
+ this.toggleOffcanvas(false);
+
+ this.resetFormAndState('create');
+
+ return;
+ }
+
+
+ if (validationError) {
+ // Forzamos la reapertura para que se rendericen
+ this.toggleOffcanvas(true, true);
+
+ return;
+ }
+
+ if (mode === 'edit' || mode === 'delete') {
+ // Abrimos el Offcanvas para edición o eliminación
+ this.toggleOffcanvas(true);
+
+ return;
+ }
+ }, 10);
+ }
+
+
+ /**
+ * Prepara la UI del Offcanvas según el modo actual: cambia texto de botones, título,
+ * habilita o deshabilita campos, etc.
+ *
+ * @param {string} mode - Modo actual en Livewire: 'create', 'edit' o 'delete'
+ */
+ prepareOffcanvasUI(mode) {
+ // Configura el texto y estilo de botones
+ this.configureButtons(mode);
+
+ // Ajusta el título del Offcanvas
+ this.configureTitle(mode);
+
+ // Activa o desactiva campos según el modo
+ this.configureReadonlyMode(mode === 'delete');
+ }
+
+ /**
+ * Cierra o muestra el Offcanvas.
+ *
+ * @param {boolean} show - true para mostrar, false para ocultar.
+ * @param {boolean} force - true para forzar el refresco rápido del Offcanvas.
+ */
+ toggleOffcanvas(show = false, force = false) {
+ const instance = this.offcanvasInstance;
+
+ if (!instance) return;
+
+ if (show) {
+ if (force) {
+ // "Force" hace un hide + show para asegurar un nuevo render
+ instance.hide();
+ setTimeout(() => instance.show(), 10);
+
+ } else {
+ instance.show();
+ }
+
+ } else {
+ instance.hide();
+ }
+ }
+
+ /**
+ * Resetea el formulario y el estado en Livewire (modo, id, errores).
+ *
+ * @param {string} targetMode - Modo al que queremos resetear, típicamente 'create'.
+ */
+ resetFormAndState(targetMode) {
+ if (!this.formEl) return;
+
+ // Restablecemos en Livewire
+ this.liveWireInstance.set('successProcess', null, false);
+ this.liveWireInstance.set('validationError', null, false);
+ this.liveWireInstance.set('mode', targetMode, false);
+ this.liveWireInstance.set('id', null, false);
+
+ // Limpiamos el formulario
+ this.formEl.reset();
+ this.clearErrors();
+
+ // Restablecemos valores por defecto del formulario
+ const defaults = this.liveWireInstance.get('defaultValues');
+ if (defaults && typeof defaults === 'object') {
+ Object.entries(defaults).forEach(([key, value]) => {
+ this.liveWireInstance.set(key, value, false);
+ });
+ }
+
+ // Limpiar select2 automáticamente
+ $(this.formEl)
+ .find('select.select2-hidden-accessible')
+ .each(function () {
+ $(this).val(null).trigger('change');
+ });
+
+ // Desactivamos el modo lectura
+ this.configureReadonlyMode(false);
+
+ // Reconfiguramos el Offcanvas UI
+ this.prepareOffcanvasUI(targetMode);
+ }
+
+ /**
+ * Configura el texto y estilo de los botones de submit y reset
+ * según el modo de Livewire.
+ *
+ * @param {string} mode - 'create', 'edit' o 'delete'
+ */
+ configureButtons(mode) {
+ const singularName = this.liveWireInstance.get('singularName');
+
+ // Limpiar clases previas
+ this.submitButtons.forEach(button => {
+ button.classList.remove('btn-danger', 'btn-primary');
+ });
+ this.resetButtons.forEach(button => {
+ button.classList.remove('btn-text-secondary', 'btn-label-secondary');
+ });
+
+ // Configurar botón de submit según el modo
+ this.submitButtons.forEach(button => {
+ switch (mode) {
+ case 'create':
+ button.classList.add('btn-primary');
+ button.textContent = `Crear ${singularName.toLowerCase()}`;
+ break;
+ case 'edit':
+ button.classList.add('btn-primary');
+ button.textContent = `Guardar cambios`;
+ break;
+ case 'delete':
+ button.classList.add('btn-danger');
+ button.textContent = `Eliminar ${singularName.toLowerCase()}`;
+ break;
+ }
+ });
+
+ // Configurar botones de reset según el modo
+ this.resetButtons.forEach(button => {
+ // Cambia la clase dependiendo si se trata de un modo 'delete' o no
+ const buttonClass = (mode === 'delete') ? 'btn-text-secondary' : 'btn-label-secondary';
+ button.classList.add(buttonClass);
+ });
+ }
+
+ /**
+ * Ajusta el título del Offcanvas según el modo y la propiedad configurada en Livewire.
+ *
+ * @param {string} mode - 'create', 'edit' o 'delete'
+ */
+ configureTitle(mode) {
+ if (!this.offcanvasTitleEl) return;
+
+ const capitalizeFirstLetter =(str) => {
+ return str.charAt(0).toUpperCase() + str.slice(1);
+ }
+
+ const singularName = this.liveWireInstance.get('singularName');
+ const columnNameLabel = this.liveWireInstance.get('columnNameLabel');
+ const editName = this.liveWireInstance.get(columnNameLabel);
+
+ switch (mode) {
+ case 'create':
+ this.offcanvasTitleEl.innerHTML = ` ${capitalizeFirstLetter(singularName)} `;
+ break;
+ case 'edit':
+ this.offcanvasTitleEl.innerHTML = `${editName} `;
+ break;
+ case 'delete':
+ this.offcanvasTitleEl.innerHTML = `${editName} `;
+ break;
+ }
+ }
+
+ /**
+ * Configura el modo de solo lectura/edición en los campos del formulario.
+ * Deshabilita inputs y maneja el "readonly" en checkboxes/radios.
+ *
+ * @param {boolean} readOnly - true si queremos modo lectura, false para edición.
+ */
+ configureReadonlyMode(readOnly) {
+ if (!this.formEl) return;
+
+ const inputs = this.formEl.querySelectorAll('input, textarea, select');
+
+ inputs.forEach(el => {
+ // Saltar campos marcados como "data-always-enabled"
+ if (el.hasAttribute('data-always-enabled')) return;
+
+ // Para selects
+ if (el.tagName === 'SELECT') {
+ if ($(el).hasClass('select2-hidden-accessible')) {
+ // Deshabilitar select2
+ $(el).prop('disabled', readOnly).trigger('change.select2');
+ } else {
+ this.toggleSelectReadonly(el, readOnly);
+ }
+ return;
+ }
+
+ // Para checkboxes / radios
+ if (el.type === 'checkbox' || el.type === 'radio') {
+ this.toggleCheckboxReadonly(el, readOnly);
+ return;
+ }
+
+ // Para inputs de texto / textarea
+ el.readOnly = readOnly;
+ });
+ }
+
+ /**
+ * Alterna modo "readonly" en un checkbox/radio simulando la inhabilitación
+ * sin marcarlo como 'disabled' (para mantener su apariencia).
+ *
+ * @param {HTMLElement} checkbox - Elemento checkbox o radio.
+ * @param {boolean} enabled - true si se quiere modo lectura, false en caso contrario.
+ */
+ toggleCheckboxReadonly(checkbox, enabled) {
+ if (enabled) {
+ checkbox.setAttribute('readonly-mode', 'true');
+ checkbox.style.pointerEvents = 'none';
+ checkbox.onclick = function (event) {
+ event.preventDefault();
+ };
+ } else {
+ checkbox.removeAttribute('readonly-mode');
+ checkbox.style.pointerEvents = '';
+ checkbox.onclick = null;
+ }
+ }
+
+ /**
+ * Alterna modo "readonly" para un