@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.
- package/.github/workflows/auto-publish.yml +64 -0
- package/.github/workflows/release.yml +59 -0
- package/README.md +60 -0
- package/app.config.ts +70 -0
- package/components/Admin/Base.vue +144 -0
- package/components/Admin/Header.vue +32 -0
- package/components/Admin/Page.vue +65 -0
- package/components/Admin/PageHeader.vue +31 -0
- package/components/App/Button.vue +59 -0
- package/components/App/DevEnvironmentBar.vue +43 -0
- package/components/App/Dropdown.vue +286 -0
- package/components/App/EmptyState.vue +433 -0
- package/components/App/LoadingState.vue +40 -0
- package/components/App/PageLoadingSpinner.vue +118 -0
- package/components/App/PreviewDock.vue +64 -0
- package/components/App/SwitchColorTheme.vue +51 -0
- package/components/App/Tag.vue +193 -0
- package/components/DataTable.vue +713 -0
- package/components/Forms/DatePicker.vue +255 -0
- package/components/Forms/Input.vue +75 -0
- package/components/Forms/Select.vue +100 -0
- package/components/Forms/SelectServer.vue +726 -0
- package/components/Layout/Admin.vue +32 -0
- package/components/Layout/Auth.vue +29 -0
- package/components/Layout/SidebarWithAppColumn.vue +388 -0
- package/components/Layout/TopBar.vue +113 -0
- package/components/MobileBlocker.vue +85 -0
- package/components/MobileLoginPicker.vue +83 -0
- package/components/Modal/Base.vue +29 -0
- package/components/Modal/DeleteConfirm.vue +48 -0
- package/components/Modal.vue +103 -0
- package/components/Nav/Tabs.vue +55 -0
- package/components/PermissionsTree.vue +272 -0
- package/components/Table/Database.vue +183 -0
- package/components/Table/DownloadDropdown.vue +111 -0
- package/components/Table/Enterprise.vue +540 -0
- package/components/Table/FilterDropdown.vue +226 -0
- package/components/Table/Grid.vue +62 -0
- package/components/Table/Kanban.vue +188 -0
- package/components/Table/List.vue +128 -0
- package/components/Table/PreviewTimeline.vue +118 -0
- package/components/Table/Standard.vue +1217 -0
- package/components/Table/index.vue +974 -0
- package/components/TableExportable.vue +172 -0
- package/components/TableFilter.vue +93 -0
- package/components/Toast/Alert.vue +113 -0
- package/components/Toast/Container.vue +34 -0
- package/components/Toast/Notification.vue +45 -0
- package/components/Toast/Process.vue +88 -0
- package/composables/useApi.js +95 -0
- package/composables/useApp.ts +46 -0
- package/composables/useAuth.js +82 -0
- package/composables/useContext.js +44 -0
- package/composables/useDate.js +241 -0
- package/composables/useDevice.js +21 -0
- package/composables/useDockedPreviews.js +56 -0
- package/composables/useDownload.js +87 -0
- package/composables/useEntity.js +82 -0
- package/composables/useForm.js +119 -0
- package/composables/useInnertiaMode.ts +25 -0
- package/composables/useMobileGuard.ts +81 -0
- package/composables/useNotifications.js +22 -0
- package/composables/usePermissions.js +23 -0
- package/composables/useRealtime.js +123 -0
- package/composables/useRequestInterceptors.js +27 -0
- package/composables/useRoles.js +53 -0
- package/composables/useRutFormatter.js +39 -0
- package/composables/useTable.ts +94 -0
- package/composables/useTablePreferences.ts +33 -0
- package/composables/useTenant.js +27 -0
- package/composables/useTimeAgo.js +37 -0
- package/composables/useToast.js +69 -0
- package/composables/useUserRealtime.js +17 -0
- package/composables/useUsers.js +111 -0
- package/css/themes/autumn.css +401 -0
- package/css/themes/bubblegum.css +408 -0
- package/css/themes/cashmere.css +412 -0
- package/css/themes/harvest.css +416 -0
- package/css/themes/moon.css +140 -0
- package/css/themes/ocean.css +273 -0
- package/css/themes/olive.css +413 -0
- package/css/themes/retro.css +431 -0
- package/css/themes/theme.css +725 -0
- package/error.vue +78 -0
- package/middleware/01.detect-subdomain.global.ts +43 -0
- package/middleware/02.validate-tenant.global.ts +67 -0
- package/middleware/03.apps.global.ts +88 -0
- package/middleware/auth.ts +9 -0
- package/middleware/guest.ts +9 -0
- package/nuxt.config.ts +42 -0
- package/package.json +60 -0
- package/pages/tenant-error.vue +50 -0
- package/plugins/api-auth.ts +12 -0
- package/plugins/api-tenant.client.ts +21 -0
- package/plugins/appearance.ts +8 -0
- package/plugins/auth-init.ts +34 -0
- package/plugins/dark-state.client.ts +29 -0
- package/plugins/dockedPreviewsSync.client.js +17 -0
- package/plugins/preline.client.ts +68 -0
- package/plugins/theme.client.ts +7 -0
- package/plugins/vue-query.ts +29 -0
- package/public/init-theme.js +15 -0
- package/spark.css +721 -0
- package/stores/auth.js +130 -0
- package/stores/dockedPreviews.js +34 -0
- package/stores/notifications.js +24 -0
- package/stores/tenant.js +54 -0
- 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>
|