@innertia-solutions/innertia-nuxt 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/.github/workflows/auto-publish.yml +64 -0
  2. package/.github/workflows/release.yml +59 -0
  3. package/README.md +60 -0
  4. package/app.config.ts +70 -0
  5. package/components/Admin/Base.vue +144 -0
  6. package/components/Admin/Header.vue +32 -0
  7. package/components/Admin/Page.vue +65 -0
  8. package/components/Admin/PageHeader.vue +31 -0
  9. package/components/App/Button.vue +59 -0
  10. package/components/App/DevEnvironmentBar.vue +43 -0
  11. package/components/App/Dropdown.vue +286 -0
  12. package/components/App/EmptyState.vue +433 -0
  13. package/components/App/LoadingState.vue +40 -0
  14. package/components/App/PageLoadingSpinner.vue +118 -0
  15. package/components/App/PreviewDock.vue +64 -0
  16. package/components/App/SwitchColorTheme.vue +51 -0
  17. package/components/App/Tag.vue +193 -0
  18. package/components/DataTable.vue +713 -0
  19. package/components/Forms/DatePicker.vue +255 -0
  20. package/components/Forms/Input.vue +75 -0
  21. package/components/Forms/Select.vue +100 -0
  22. package/components/Forms/SelectServer.vue +726 -0
  23. package/components/Layout/Admin.vue +32 -0
  24. package/components/Layout/Auth.vue +29 -0
  25. package/components/Layout/SidebarWithAppColumn.vue +388 -0
  26. package/components/Layout/TopBar.vue +113 -0
  27. package/components/MobileBlocker.vue +85 -0
  28. package/components/MobileLoginPicker.vue +83 -0
  29. package/components/Modal/Base.vue +29 -0
  30. package/components/Modal/DeleteConfirm.vue +48 -0
  31. package/components/Modal.vue +103 -0
  32. package/components/Nav/Tabs.vue +55 -0
  33. package/components/PermissionsTree.vue +272 -0
  34. package/components/Table/Database.vue +183 -0
  35. package/components/Table/DownloadDropdown.vue +111 -0
  36. package/components/Table/Enterprise.vue +540 -0
  37. package/components/Table/FilterDropdown.vue +226 -0
  38. package/components/Table/Grid.vue +62 -0
  39. package/components/Table/Kanban.vue +188 -0
  40. package/components/Table/List.vue +128 -0
  41. package/components/Table/PreviewTimeline.vue +118 -0
  42. package/components/Table/Standard.vue +1217 -0
  43. package/components/Table/index.vue +974 -0
  44. package/components/TableExportable.vue +172 -0
  45. package/components/TableFilter.vue +93 -0
  46. package/components/Toast/Alert.vue +113 -0
  47. package/components/Toast/Container.vue +34 -0
  48. package/components/Toast/Notification.vue +45 -0
  49. package/components/Toast/Process.vue +88 -0
  50. package/composables/useApi.js +95 -0
  51. package/composables/useApp.ts +46 -0
  52. package/composables/useAuth.js +82 -0
  53. package/composables/useContext.js +44 -0
  54. package/composables/useDate.js +241 -0
  55. package/composables/useDevice.js +21 -0
  56. package/composables/useDockedPreviews.js +56 -0
  57. package/composables/useDownload.js +87 -0
  58. package/composables/useEntity.js +82 -0
  59. package/composables/useForm.js +119 -0
  60. package/composables/useInnertiaMode.ts +25 -0
  61. package/composables/useMobileGuard.ts +81 -0
  62. package/composables/useNotifications.js +22 -0
  63. package/composables/usePermissions.js +23 -0
  64. package/composables/useRealtime.js +123 -0
  65. package/composables/useRequestInterceptors.js +27 -0
  66. package/composables/useRoles.js +53 -0
  67. package/composables/useRutFormatter.js +39 -0
  68. package/composables/useTable.ts +94 -0
  69. package/composables/useTablePreferences.ts +33 -0
  70. package/composables/useTenant.js +27 -0
  71. package/composables/useTimeAgo.js +37 -0
  72. package/composables/useToast.js +69 -0
  73. package/composables/useUserRealtime.js +17 -0
  74. package/composables/useUsers.js +111 -0
  75. package/css/themes/autumn.css +401 -0
  76. package/css/themes/bubblegum.css +408 -0
  77. package/css/themes/cashmere.css +412 -0
  78. package/css/themes/harvest.css +416 -0
  79. package/css/themes/moon.css +140 -0
  80. package/css/themes/ocean.css +273 -0
  81. package/css/themes/olive.css +413 -0
  82. package/css/themes/retro.css +431 -0
  83. package/css/themes/theme.css +725 -0
  84. package/error.vue +78 -0
  85. package/middleware/01.detect-subdomain.global.ts +43 -0
  86. package/middleware/02.validate-tenant.global.ts +67 -0
  87. package/middleware/03.apps.global.ts +88 -0
  88. package/middleware/auth.ts +9 -0
  89. package/middleware/guest.ts +9 -0
  90. package/nuxt.config.ts +42 -0
  91. package/package.json +60 -0
  92. package/pages/tenant-error.vue +50 -0
  93. package/plugins/api-auth.ts +12 -0
  94. package/plugins/api-tenant.client.ts +21 -0
  95. package/plugins/appearance.ts +8 -0
  96. package/plugins/auth-init.ts +34 -0
  97. package/plugins/dark-state.client.ts +29 -0
  98. package/plugins/dockedPreviewsSync.client.js +17 -0
  99. package/plugins/preline.client.ts +68 -0
  100. package/plugins/theme.client.ts +7 -0
  101. package/plugins/vue-query.ts +29 -0
  102. package/public/init-theme.js +15 -0
  103. package/spark.css +721 -0
  104. package/stores/auth.js +130 -0
  105. package/stores/dockedPreviews.js +34 -0
  106. package/stores/notifications.js +24 -0
  107. package/stores/tenant.js +54 -0
  108. package/stores/toast.js +129 -0
@@ -0,0 +1,83 @@
1
+ <script setup lang="ts">
2
+ import * as TablerIcons from '@tabler/icons-vue'
3
+ import { IconArrowRight } from '@tabler/icons-vue'
4
+ import type { AppDefinition } from '~/configs/apps'
5
+
6
+ const props = defineProps<{
7
+ /** Apps a mostrar como opciones de login. */
8
+ options: AppDefinition[]
9
+ /** Título personalizable. */
10
+ title?: string
11
+ }>()
12
+
13
+ const emit = defineEmits<{
14
+ /** Emitido cuando el usuario elige un app — el padre se encarga de navegar y recordar la elección. */
15
+ pick: [app: AppDefinition]
16
+ }>()
17
+
18
+ const heading = computed(() => props.title ?? '¿Cómo quieres ingresar?')
19
+
20
+ function resolveIcon(name?: string) {
21
+ if (!name) return null
22
+ return (TablerIcons as Record<string, unknown>)[name] ?? null
23
+ }
24
+ </script>
25
+
26
+ <template>
27
+ <div class="min-h-screen flex flex-col bg-white dark:bg-slate-950">
28
+ <!-- Header con logo -->
29
+ <div class="flex items-center justify-center px-6 pt-10 pb-8">
30
+ <img src="/isologo-light.png" alt="Asetio" class="h-8 dark:hidden" />
31
+ <img src="/isologo-dark.png" alt="Asetio" class="h-8 hidden dark:block" />
32
+ </div>
33
+
34
+ <!-- Body -->
35
+ <div class="flex-1 flex items-start justify-center px-6">
36
+ <div class="w-full max-w-sm space-y-6">
37
+ <div class="text-center space-y-1.5">
38
+ <h1 class="text-2xl font-bold text-slate-900 dark:text-white">
39
+ {{ heading }}
40
+ </h1>
41
+ <p class="text-sm text-slate-600 dark:text-slate-400">
42
+ Elige el tipo de acceso para continuar.
43
+ </p>
44
+ </div>
45
+
46
+ <!-- Opciones -->
47
+ <div class="space-y-3">
48
+ <button
49
+ v-for="app in options"
50
+ :key="app.path"
51
+ type="button"
52
+ class="group w-full flex items-center gap-4 rounded-2xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 px-4 py-4 text-left hover:border-violet-300 dark:hover:border-violet-700 hover:bg-violet-50/50 dark:hover:bg-violet-900/10 transition-colors"
53
+ @click="emit('pick', app)"
54
+ >
55
+ <div class="shrink-0 size-11 rounded-xl bg-violet-100 dark:bg-violet-900/30 flex items-center justify-center group-hover:bg-violet-200 dark:group-hover:bg-violet-900/50 transition-colors">
56
+ <component
57
+ v-if="resolveIcon(app.icon)"
58
+ :is="resolveIcon(app.icon)"
59
+ class="size-5 text-violet-600 dark:text-violet-400"
60
+ />
61
+ </div>
62
+ <div class="flex-1 min-w-0">
63
+ <div class="text-base font-semibold text-slate-900 dark:text-white">
64
+ {{ app.label }}
65
+ </div>
66
+ <div v-if="app.description" class="text-xs text-slate-500 dark:text-slate-400 mt-0.5 line-clamp-2">
67
+ {{ app.description }}
68
+ </div>
69
+ </div>
70
+ <IconArrowRight class="shrink-0 size-5 text-slate-400 group-hover:text-violet-600 dark:group-hover:text-violet-400 transition-colors" />
71
+ </button>
72
+ </div>
73
+ </div>
74
+ </div>
75
+
76
+ <!-- Footer -->
77
+ <div class="px-6 py-6 text-center">
78
+ <p class="text-xs text-slate-400 dark:text-slate-600">
79
+ © {{ new Date().getFullYear() }} {{ $config.public.appName ?? 'Asetio' }}
80
+ </p>
81
+ </div>
82
+ </div>
83
+ </template>
@@ -0,0 +1,29 @@
1
+ <script setup>
2
+ const props = defineProps({
3
+ modelValue: { type: Boolean, default: false },
4
+ title: { type: String, default: '' },
5
+ size: { type: String, default: 'md' },
6
+ loading: { type: Boolean, default: false },
7
+ closable: { type: Boolean, default: true },
8
+ })
9
+
10
+ const emit = defineEmits(['update:modelValue', 'close'])
11
+ </script>
12
+
13
+ <template>
14
+ <Modal
15
+ :model-value="modelValue"
16
+ :title="title"
17
+ :size="size"
18
+ :closable="closable && !loading"
19
+ :backdrop-dismiss="closable && !loading"
20
+ :show-footer="!!$slots.footer"
21
+ @update:model-value="emit('update:modelValue', $event)"
22
+ @close="emit('close')"
23
+ >
24
+ <slot />
25
+ <template v-if="$slots.footer" #footer>
26
+ <slot name="footer" />
27
+ </template>
28
+ </Modal>
29
+ </template>
@@ -0,0 +1,48 @@
1
+ <script setup>
2
+ const props = defineProps({
3
+ modelValue: { type: Boolean, default: false },
4
+ title: { type: String, default: 'Confirmar eliminación' },
5
+ message: { type: String, default: 'Esta acción es irreversible. ¿Estás seguro de que deseas continuar?' },
6
+ confirmText: { type: String, default: 'Eliminar' },
7
+ cancelText: { type: String, default: 'Cancelar' },
8
+ loading: { type: Boolean, default: false },
9
+ })
10
+
11
+ const emit = defineEmits(['update:modelValue', 'confirm', 'cancel'])
12
+
13
+ const close = () => {
14
+ if (props.loading) return
15
+ emit('update:modelValue', false)
16
+ emit('cancel')
17
+ }
18
+ </script>
19
+
20
+ <template>
21
+ <Modal
22
+ :model-value="modelValue"
23
+ :title="title"
24
+ size="sm"
25
+ :closable="!loading"
26
+ :backdrop-dismiss="!loading"
27
+ @update:model-value="$emit('update:modelValue', $event)"
28
+ >
29
+ <p class="text-sm text-muted-foreground">{{ message }}</p>
30
+
31
+ <div class="flex justify-end gap-2 mt-5">
32
+ <AppButton
33
+ :text="cancelText"
34
+ severity="secondary"
35
+ size="sm"
36
+ :disabled="loading"
37
+ @click="close"
38
+ />
39
+ <AppButton
40
+ :text="confirmText"
41
+ severity="danger"
42
+ size="sm"
43
+ :loading="loading"
44
+ @click="$emit('confirm')"
45
+ />
46
+ </div>
47
+ </Modal>
48
+ </template>
@@ -0,0 +1,103 @@
1
+ <script setup>
2
+ const props = defineProps({
3
+ modelValue: { type: Boolean, default: false },
4
+ title: { type: String, default: '' },
5
+ size: { type: String, default: 'md', validator: v => ['xs','sm','md','lg','xl','2xl','3xl','fullscreen'].includes(v) },
6
+ closable: { type: Boolean, default: true },
7
+ backdropDismiss: { type: Boolean, default: true },
8
+ showHeader: { type: Boolean, default: true },
9
+ showFooter: { type: Boolean, default: false },
10
+ })
11
+
12
+ const emit = defineEmits(['update:modelValue', 'close'])
13
+
14
+ const modalId = `modal-${Math.random().toString(36).slice(2, 9)}`
15
+
16
+ const sizeClass = computed(() => ({
17
+ xs: 'max-w-xs', sm: 'max-w-sm', md: 'max-w-md',
18
+ lg: 'max-w-lg', xl: 'max-w-xl', '2xl': 'max-w-2xl',
19
+ '3xl': 'max-w-3xl', fullscreen: 'max-w-full',
20
+ }[props.size] ?? 'max-w-md'))
21
+
22
+ const close = () => {
23
+ emit('update:modelValue', false)
24
+ emit('close')
25
+ }
26
+
27
+ const onBackdrop = (e) => {
28
+ if (e.target === e.currentTarget && props.backdropDismiss && props.closable) close()
29
+ }
30
+
31
+ const onEsc = (e) => { if (e.key === 'Escape' && props.modelValue && props.closable) close() }
32
+
33
+ watch(() => props.modelValue, v => {
34
+ document.body.style.overflow = v ? 'hidden' : ''
35
+ })
36
+
37
+ onMounted(() => document.addEventListener('keydown', onEsc))
38
+ onUnmounted(() => {
39
+ document.removeEventListener('keydown', onEsc)
40
+ document.body.style.overflow = ''
41
+ })
42
+ </script>
43
+
44
+ <template>
45
+ <Teleport v-if="modelValue" to="body">
46
+ <Transition name="modal" appear>
47
+ <div
48
+ class="fixed inset-0 z-[9999] bg-black/40 dark:bg-black/60 backdrop-blur-sm flex items-center justify-center p-4"
49
+ role="dialog"
50
+ tabindex="-1"
51
+ :aria-labelledby="`${modalId}-label`"
52
+ @click="onBackdrop"
53
+ >
54
+ <div
55
+ :class="['bg-card border border-card-line rounded-xl shadow-xl w-full modal-content', sizeClass]"
56
+ @click.stop
57
+ >
58
+ <!-- Header -->
59
+ <div v-if="showHeader" class="flex items-center justify-between px-5 py-4 border-b border-card-line">
60
+ <h3 :id="`${modalId}-label`" class="text-sm font-semibold text-foreground">
61
+ <slot name="header">{{ title }}</slot>
62
+ </h3>
63
+ <button
64
+ v-if="closable"
65
+ type="button"
66
+ class="size-7 flex items-center justify-center rounded-lg text-muted-foreground hover:text-muted-foreground-1 hover:bg-muted-hover transition-colors"
67
+ @click="close"
68
+ >
69
+ <svg class="size-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
70
+ <path d="M18 6 6 18M6 6l12 12" stroke-linecap="round" stroke-linejoin="round" />
71
+ </svg>
72
+ </button>
73
+ </div>
74
+
75
+ <!-- Body -->
76
+ <div class="p-5">
77
+ <slot />
78
+ </div>
79
+
80
+ <!-- Footer -->
81
+ <div v-if="showFooter" class="flex items-center justify-end gap-2 px-5 py-4 border-t border-card-line">
82
+ <slot name="footer">
83
+ <AppButton v-if="closable" text="Cerrar" severity="secondary" size="sm" @click="close" />
84
+ </slot>
85
+ </div>
86
+ </div>
87
+ </div>
88
+ </Transition>
89
+ </Teleport>
90
+ </template>
91
+
92
+ <style scoped>
93
+ .modal-enter-active,
94
+ .modal-leave-active { transition: opacity 0.15s ease; }
95
+ .modal-enter-from,
96
+ .modal-leave-to { opacity: 0; }
97
+
98
+ .modal-enter-from .modal-content,
99
+ .modal-leave-to .modal-content { transform: scale(0.97) translateY(-8px); opacity: 0; }
100
+ .modal-enter-to .modal-content,
101
+ .modal-leave-from .modal-content { transform: scale(1) translateY(0); opacity: 1; }
102
+ .modal-content { transition: transform 0.15s ease, opacity 0.15s ease; }
103
+ </style>
@@ -0,0 +1,55 @@
1
+ <script setup lang="ts">
2
+ import { useRoute } from 'vue-router'
3
+
4
+ interface Tab {
5
+ label: string
6
+ to: string
7
+ icon?: any
8
+ exact?: boolean
9
+ }
10
+
11
+ const props = withDefaults(defineProps<{
12
+ tabs: Tab[]
13
+ color?: string
14
+ activeClass?: string
15
+ }>(), {
16
+ color: 'blue',
17
+ })
18
+
19
+ const colorTextClass = computed(() => ({
20
+ blue: 'text-blue-600 dark:text-blue-400',
21
+ gray: 'text-foreground',
22
+ slate: 'text-foreground',
23
+ green: 'text-green-600 dark:text-green-400',
24
+ amber: 'text-amber-600 dark:text-amber-400',
25
+ red: 'text-red-600 dark:text-red-400',
26
+ purple: 'text-purple-600 dark:text-purple-400',
27
+ rose: 'text-rose-600 dark:text-rose-400',
28
+ }[props.color] ?? 'text-blue-600 dark:text-blue-400'))
29
+
30
+ const resolvedActiveClass = computed(() =>
31
+ props.activeClass ?? `bg-card shadow-sm ${colorTextClass.value}`
32
+ )
33
+
34
+ const route = useRoute()
35
+
36
+ const isActive = (tab: Tab) =>
37
+ tab.exact ? route.path === tab.to : route.path.startsWith(tab.to)
38
+ </script>
39
+
40
+ <template>
41
+ <div class="flex items-center gap-x-1 p-1 bg-surface border border-card-line rounded-xl w-fit">
42
+ <NuxtLink
43
+ v-for="tab in tabs"
44
+ :key="tab.to"
45
+ :to="tab.to"
46
+ class="flex items-center gap-x-2 px-4 py-2 text-xs font-bold rounded-lg transition-all"
47
+ :class="isActive(tab)
48
+ ? resolvedActiveClass
49
+ : 'text-muted-foreground hover:text-foreground'"
50
+ >
51
+ <component :is="tab.icon" v-if="tab.icon" class="size-4" />
52
+ {{ tab.label }}
53
+ </NuxtLink>
54
+ </div>
55
+ </template>
@@ -0,0 +1,272 @@
1
+ <script setup>
2
+ import { IconSearch, IconFolder, IconFolderOpen, IconKey, IconStack2 } from '@tabler/icons-vue'
3
+
4
+ const props = defineProps({
5
+ apps: { type: Array, default: () => [] }, // [{ app, app_label, groups: [{ category, category_alias, permissions }] }]
6
+ modelValue: { type: Array, default: () => [] }, // selected permission names
7
+ readonly: { type: Boolean, default: false },
8
+ folderColor: { type: String, default: 'gray' },
9
+ })
10
+
11
+ const emit = defineEmits(['update:modelValue'])
12
+
13
+ const folderColorClass = computed(() => ({
14
+ gray: 'text-slate-400 dark:text-slate-500',
15
+ blue: 'text-blue-400 dark:text-blue-500',
16
+ amber: 'text-amber-400 dark:text-amber-500',
17
+ green: 'text-green-400 dark:text-green-500',
18
+ purple: 'text-purple-400 dark:text-purple-500',
19
+ rose: 'text-rose-400 dark:text-rose-500',
20
+ }[props.folderColor] ?? 'text-slate-400 dark:text-slate-500'))
21
+
22
+ const search = ref('')
23
+
24
+ // Flatten all permissions for search
25
+ const filteredApps = computed(() => {
26
+ const q = search.value.trim().toLowerCase()
27
+ if (!q) return props.apps
28
+
29
+ return props.apps
30
+ .map(app => ({
31
+ ...app,
32
+ groups: app.groups
33
+ .map(g => ({
34
+ ...g,
35
+ permissions: g.permissions.filter(p =>
36
+ p.name.toLowerCase().includes(q) || (p.description ?? '').toLowerCase().includes(q)
37
+ ),
38
+ }))
39
+ .filter(g => g.permissions.length > 0),
40
+ }))
41
+ .filter(app => app.groups.length > 0)
42
+ })
43
+
44
+ const visibleApps = computed(() => {
45
+ if (!props.readonly) return filteredApps.value
46
+ return filteredApps.value
47
+ .map(app => ({
48
+ ...app,
49
+ groups: app.groups
50
+ .map(g => ({
51
+ ...g,
52
+ permissions: g.permissions.filter(p => props.modelValue.includes(p.name)),
53
+ }))
54
+ .filter(g => g.permissions.length > 0),
55
+ }))
56
+ .filter(app => app.groups.length > 0)
57
+ })
58
+
59
+ // Collapse state — keyed as "app" or "app::category"
60
+ const collapsed = ref({})
61
+
62
+ watch(search, (q) => {
63
+ if (!q.trim()) return
64
+ filteredApps.value.forEach(app => {
65
+ collapsed.value[app.app] = false
66
+ app.groups.forEach(g => { collapsed.value[`${app.app}::${g.category}`] = false })
67
+ })
68
+ })
69
+
70
+ function isCollapsed(key) { return collapsed.value[key] ?? false }
71
+ function toggle(key) { collapsed.value[key] = !collapsed.value[key] }
72
+
73
+ function isSelected(name) { return props.modelValue.includes(name) }
74
+
75
+ function appPermNames(app) {
76
+ return app.groups.flatMap(g => g.permissions.map(p => p.name))
77
+ }
78
+
79
+ function groupPermNames(group) {
80
+ return group.permissions.map(p => p.name)
81
+ }
82
+
83
+ function appState(app) {
84
+ const names = appPermNames(app)
85
+ const n = names.filter(n => props.modelValue.includes(n)).length
86
+ return n === 0 ? 'none' : n === names.length ? 'all' : 'partial'
87
+ }
88
+
89
+ function groupState(group) {
90
+ const names = groupPermNames(group)
91
+ const n = names.filter(n => props.modelValue.includes(n)).length
92
+ return n === 0 ? 'none' : n === names.length ? 'all' : 'partial'
93
+ }
94
+
95
+ function toggleApp(app) {
96
+ const names = appPermNames(app)
97
+ const next = appState(app) === 'all'
98
+ ? props.modelValue.filter(n => !names.includes(n))
99
+ : [...new Set([...props.modelValue, ...names])]
100
+ emit('update:modelValue', next)
101
+ }
102
+
103
+ function toggleGroup(group) {
104
+ const names = groupPermNames(group)
105
+ const next = groupState(group) === 'all'
106
+ ? props.modelValue.filter(n => !names.includes(n))
107
+ : [...new Set([...props.modelValue, ...names])]
108
+ emit('update:modelValue', next)
109
+ }
110
+
111
+ function togglePermission(name) {
112
+ const next = isSelected(name)
113
+ ? props.modelValue.filter(n => n !== name)
114
+ : [...props.modelValue, name]
115
+ emit('update:modelValue', next)
116
+ }
117
+
118
+ function setIndeterminate(el, state) {
119
+ if (el) el.indeterminate = state === 'partial'
120
+ }
121
+
122
+ function appSelectedCount(app) {
123
+ return appPermNames(app).filter(n => props.modelValue.includes(n)).length
124
+ }
125
+ function appTotalCount(app) { return appPermNames(app).length }
126
+ function groupSelectedCount(group) {
127
+ return groupPermNames(group).filter(n => props.modelValue.includes(n)).length
128
+ }
129
+ </script>
130
+
131
+ <template>
132
+ <div class="w-full space-y-2">
133
+
134
+ <!-- Search -->
135
+ <div class="relative">
136
+ <IconSearch class="absolute left-2.5 top-1/2 -translate-y-1/2 size-3 text-slate-400 pointer-events-none" />
137
+ <input
138
+ v-model="search"
139
+ type="search"
140
+ placeholder="Buscar permiso…"
141
+ class="w-full pl-7 pr-3 py-1.5 text-xs bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-md text-slate-700 dark:text-slate-300 placeholder-slate-400 focus:outline-none focus:ring-1 focus:ring-blue-500/40 focus:border-blue-400"
142
+ />
143
+ </div>
144
+
145
+ <!-- Tree -->
146
+ <div class="rounded-md border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 overflow-hidden text-sm">
147
+
148
+ <div v-if="visibleApps.length === 0" class="px-3 py-6 text-center text-xs text-slate-400 dark:text-slate-500">
149
+ {{ search ? 'Sin resultados.' : readonly ? 'Sin permisos asignados.' : 'Sin permisos disponibles.' }}
150
+ </div>
151
+
152
+ <template v-for="(app, ai) in visibleApps" :key="app.app">
153
+
154
+ <!-- ── App row (level 1) ── -->
155
+ <div
156
+ class="flex items-center gap-1.5 px-2 py-1.5 cursor-pointer select-none bg-slate-100 dark:bg-slate-800 hover:bg-slate-200/60 dark:hover:bg-slate-700/60 transition-colors"
157
+ :class="ai > 0 ? 'border-t border-slate-200 dark:border-slate-700' : ''"
158
+ @click="toggle(app.app)"
159
+ >
160
+ <input
161
+ v-if="!readonly"
162
+ type="checkbox"
163
+ class="size-3 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-0 cursor-pointer shrink-0"
164
+ :checked="appState(app) === 'all'"
165
+ :ref="el => setIndeterminate(el, appState(app))"
166
+ @click.stop
167
+ @change="toggleApp(app)"
168
+ />
169
+ <IconStack2 class="size-3.5 shrink-0" :class="folderColorClass" />
170
+ <span class="font-bold text-slate-700 dark:text-slate-200 flex-1 truncate tracking-wide uppercase text-[11px]">
171
+ {{ app.app_label || app.app }}
172
+ </span>
173
+ <span class="text-[11px] tabular-nums text-slate-400 dark:text-slate-500 shrink-0">
174
+ {{ readonly
175
+ ? appTotalCount(app)
176
+ : `${appSelectedCount(app)}/${appTotalCount(app)}`
177
+ }}
178
+ </span>
179
+ </div>
180
+
181
+ <template v-if="!isCollapsed(app.app)">
182
+ <template v-for="(group, gi) in app.groups" :key="group.category">
183
+
184
+ <!-- ── Category row (level 2) ── -->
185
+ <div
186
+ class="flex items-center gap-1 border-t border-slate-100 dark:border-slate-800 bg-slate-50 dark:bg-slate-800/40 hover:bg-slate-100 dark:hover:bg-slate-800/70 transition-colors cursor-pointer select-none"
187
+ @click="toggle(`${app.app}::${group.category}`)"
188
+ >
189
+ <!-- indent line -->
190
+ <div class="flex items-center shrink-0 pl-3" style="width:32px">
191
+ <div class="flex flex-col items-center h-full" style="width:10px">
192
+ <div class="w-px flex-1 bg-slate-200 dark:bg-slate-700" />
193
+ <div class="w-2.5 h-px bg-slate-200 dark:bg-slate-700" />
194
+ <div class="w-px flex-1 bg-slate-200 dark:bg-slate-700"
195
+ :class="gi === app.groups.length - 1 ? 'opacity-0' : ''" />
196
+ </div>
197
+ </div>
198
+
199
+ <input
200
+ v-if="!readonly"
201
+ type="checkbox"
202
+ class="size-3 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-0 cursor-pointer shrink-0"
203
+ :checked="groupState(group) === 'all'"
204
+ :ref="el => setIndeterminate(el, groupState(group))"
205
+ @click.stop
206
+ @change="toggleGroup(group)"
207
+ />
208
+ <IconFolderOpen v-if="!isCollapsed(`${app.app}::${group.category}`)" class="size-3.5 shrink-0" :class="folderColorClass" />
209
+ <IconFolder v-else class="size-3.5 shrink-0" :class="folderColorClass" />
210
+
211
+ <span class="font-semibold text-slate-600 dark:text-slate-300 flex-1 truncate py-1">
212
+ {{ group.category_alias ?? group.category }}
213
+ </span>
214
+ <span class="text-[11px] tabular-nums text-slate-400 dark:text-slate-500 pr-2 shrink-0">
215
+ {{ readonly
216
+ ? group.permissions.length
217
+ : `${groupSelectedCount(group)}/${group.permissions.length}`
218
+ }}
219
+ </span>
220
+ </div>
221
+
222
+ <!-- ── Permission rows (level 3) ── -->
223
+ <template v-if="!isCollapsed(`${app.app}::${group.category}`)">
224
+ <div
225
+ v-for="(perm, pi) in group.permissions"
226
+ :key="perm.name"
227
+ class="flex items-center gap-1 border-t border-slate-100 dark:border-slate-800 hover:bg-blue-50/50 dark:hover:bg-blue-500/5 transition-colors"
228
+ :class="!readonly ? 'cursor-pointer' : ''"
229
+ @click="!readonly && togglePermission(perm.name)"
230
+ >
231
+ <!-- double indent line -->
232
+ <div class="flex items-center shrink-0 pl-3" style="width:52px">
233
+ <div class="flex flex-col items-center h-full mr-1" style="width:10px">
234
+ <div class="w-px flex-1 bg-slate-200 dark:bg-slate-700" />
235
+ <div class="w-px flex-1 bg-slate-200 dark:bg-slate-700"
236
+ :class="gi === app.groups.length - 1 ? 'opacity-0' : ''" />
237
+ </div>
238
+ <div class="flex flex-col items-center h-full" style="width:10px">
239
+ <div class="w-px flex-1 bg-slate-200 dark:bg-slate-700" />
240
+ <div class="w-2.5 h-px bg-slate-200 dark:bg-slate-700" />
241
+ <div class="w-px flex-1 bg-slate-200 dark:bg-slate-700"
242
+ :class="pi === group.permissions.length - 1 ? 'opacity-0' : ''" />
243
+ </div>
244
+ </div>
245
+
246
+ <div class="shrink-0 flex items-center justify-center w-3.5">
247
+ <input
248
+ v-if="!readonly"
249
+ type="checkbox"
250
+ class="size-3 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-0 cursor-pointer"
251
+ :checked="isSelected(perm.name)"
252
+ @click.stop
253
+ @change="togglePermission(perm.name)"
254
+ />
255
+ <span v-else class="size-1.5 rounded-full bg-green-500 block" />
256
+ </div>
257
+
258
+ <IconKey class="size-3 text-slate-400 dark:text-slate-500 shrink-0" />
259
+ <span class="font-mono text-slate-700 dark:text-slate-300 truncate flex-1 py-1">{{ perm.name }}</span>
260
+ <span class="text-slate-400 dark:text-slate-500 truncate hidden sm:block pr-2" style="max-width:240px">
261
+ {{ perm.description ?? '' }}
262
+ </span>
263
+ </div>
264
+ </template>
265
+
266
+ </template>
267
+ </template>
268
+
269
+ </template>
270
+ </div>
271
+ </div>
272
+ </template>