@finema/finework-layer 0.0.12 → 0.0.14

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.
@@ -0,0 +1,281 @@
1
+ <template>
2
+ <aside
3
+ :class="[`
4
+ flex h-full flex-col overflow-y-auto bg-white
5
+ transition-all duration-300
6
+ `]"
7
+ >
8
+ <div
9
+ :class="['flex h-[72px] w-[260px] items-center pl-5 max-sm:pr-5', {
10
+ 'justify-center': isCollapsed,
11
+ 'justify-between': !isCollapsed,
12
+ }]"
13
+ >
14
+ <NuxtLink :to="routes.home.to">
15
+ <img
16
+ v-if="!isCollapsed"
17
+ src="/logo-mini.png"
18
+ alt="logo_mini_color"
19
+ class="h-[27px]"
20
+ />
21
+ </NuxtLink>
22
+
23
+ <Navbar />
24
+ </div>
25
+ <div class="bg-primary flex items-center justify-between px-7 py-4 font-bold text-white">
26
+ {{ isCollapsed ? '' : label }}
27
+
28
+ <Icon
29
+ v-if="!isCollapsed"
30
+ name="fluent:arrow-circle-left-24-regular"
31
+ class="size-[24px] cursor-pointer"
32
+ @click="$emit('toggle-collapsed')"
33
+ />
34
+ <Icon
35
+ v-else
36
+ name="fluent:arrow-circle-right-24-regular"
37
+ class="size-[24px] cursor-pointer"
38
+ @click="$emit('toggle-collapsed')"
39
+ />
40
+ </div>
41
+
42
+ <div class="flex-1 overflow-y-auto border-r border-gray-200 p-4">
43
+ <div
44
+ v-if="isGroup"
45
+ class="space-y-4 divide-y-1 divide-[#d9d9d9]"
46
+ >
47
+ <div
48
+ v-for="group in navigationItems"
49
+ :key="group.label"
50
+ class="pb-4"
51
+ >
52
+ <div class="mt-2 mb-2 text-[10px] font-semibold text-gray-400">
53
+ {{ group.label }}
54
+ </div>
55
+ <NavigationMenu
56
+ orientation="vertical"
57
+ :items="group.children"
58
+ :collapsed="isCollapsed"
59
+ :popover="isCollapsed"
60
+ :tooltip="isCollapsed"
61
+ :ui="{
62
+ list: 'space-y-2 ',
63
+ label: [
64
+ 'text-sm font-bold text-gray-500 py-[12px] px-[10px] rounded-lg',
65
+ 'hover:text-primary',
66
+ ],
67
+ link: [
68
+ 'cursor-pointer text-sm font-bold text-gray-500 px-[10px] rounded-lg gap-3',
69
+ 'hover:text-primary',
70
+ 'data-active:before:bg-white data-active:before:rounded-lg data-active:text-primary font-semibold',
71
+ ],
72
+ linkLeadingIcon: 'group-data-[state=open]:text-current text-current size-[24px] group-hover:text-primary',
73
+ childList: 'border-none ms-0 pl-8 bg-gray-100 mt-2 py-1 rounded-lg',
74
+ childLink: 'ps-0',
75
+ childItem: 'ps-0',
76
+ }"
77
+ class="w-full justify-center"
78
+ />
79
+ </div>
80
+ </div>
81
+ <NavigationMenu
82
+ v-else
83
+ orientation="vertical"
84
+ :items="navigationItems"
85
+ :collapsed="isCollapsed"
86
+ :popover="isCollapsed"
87
+ :tooltip="isCollapsed"
88
+ :ui="{
89
+ list: 'space-y-2 ',
90
+ label: [
91
+ 'text-sm font-bold text-gray-500 py-[12px] px-[10px] rounded-lg',
92
+ 'hover:text-primary',
93
+ ],
94
+ link: [
95
+ 'cursor-pointer text-sm font-bold text-gray-500 px-[10px] rounded-lg gap-3',
96
+ 'hover:text-primary',
97
+ 'data-active:before:bg-white data-active:before:rounded-lg data-active:text-primary font-semibold',
98
+ ],
99
+ linkLeadingIcon: 'group-data-[state=open]:text-current text-current size-[24px] group-hover:text-primary ',
100
+ childList: 'border-none ms-0 pl-8 bg-gray-100 mt-2 py-1 rounded-lg',
101
+ childLink: 'ps-0',
102
+ childItem: 'ps-0',
103
+ }"
104
+ class="w-full justify-center"
105
+ />
106
+ </div>
107
+ <div
108
+ v-if="isMobile"
109
+ class="border-t border-gray-100 p-3"
110
+ >
111
+ <div class="flex items-center justify-between gap-2">
112
+ <div class="flex min-w-0 flex-1 items-center gap-3">
113
+ <Avatar
114
+ class="border-muted size-[32px] flex-shrink-0 border text-lg"
115
+ icon="ri:user-line"
116
+ :src="auth.me.value?.avatar_url || ''"
117
+ />
118
+ <div class="flex min-w-0 flex-1 flex-col">
119
+ <p class="truncate text-sm font-bold">
120
+ {{ auth.me.value?.display_name || auth.me.value?.full_name }}
121
+ </p>
122
+ <p class="text-muted truncate text-xs">
123
+ {{ auth.me.value?.email || '' }}
124
+ </p>
125
+ </div>
126
+ </div>
127
+ <DropdownMenu
128
+ arrow
129
+ size="xl"
130
+ :items="userMenuItems"
131
+ :ui="{
132
+ content: 'w-48',
133
+ }"
134
+ >
135
+ <Button
136
+ icon="ph:dots-three-outline-vertical-bold"
137
+ variant="ghost"
138
+ color="neutral"
139
+ size="xs"
140
+ />
141
+ </DropdownMenu>
142
+ </div>
143
+ </div>
144
+ </aside>
145
+ </template>
146
+
147
+ <script lang="ts" setup>
148
+ import type { NavigationMenuItem } from '@nuxt/ui'
149
+ import { computed } from 'vue'
150
+ import { useRoute } from 'vue-router'
151
+ import type { Permission, UserModule } from '#imports'
152
+ import Navbar from './Apps.vue'
153
+
154
+ defineEmits<{
155
+ 'toggle-collapsed': []
156
+ }>()
157
+
158
+ const props = defineProps<{
159
+ label: string
160
+ isCollapsed: boolean
161
+ isMobile?: boolean
162
+ items: NavigationMenuItem[]
163
+ isGroup?: boolean
164
+ }>()
165
+
166
+ const route = useRoute()
167
+ const auth = useAuth()
168
+ const userMenuItems = [
169
+
170
+ {
171
+ label: 'View profile',
172
+ icon: 'i-lucide-user',
173
+ to: routes.account.profile.to,
174
+ },
175
+
176
+ {
177
+ label: routes.logout.label,
178
+ icon: 'i-lucide-log-out',
179
+ to: routes.logout.to,
180
+ external: true,
181
+ },
182
+ ]
183
+
184
+ const mappedItems = (items: NavigationMenuItem[]): NavigationMenuItem[] => {
185
+ return items.map((item) => {
186
+ let isAnyChildActive = false
187
+ const mappedChildren = item.children?.map((child) => {
188
+ const isChildCurrentlyActive = route.path === child.to
189
+
190
+ if (isChildCurrentlyActive) {
191
+ isAnyChildActive = true
192
+ }
193
+
194
+ return {
195
+ active: isChildCurrentlyActive,
196
+ class: 'hover:bg-transparent hover:text-gray-700 hover:font-bold py-2 data-active:before:bg-transparent data-active:text-gray-700 data-active:font-bold',
197
+ icon: '',
198
+ ...child,
199
+ }
200
+ })
201
+
202
+ const selfIsActive = item.to ? route.path.startsWith(String(item.to)) : false
203
+
204
+ const itemIsActive = selfIsActive || isAnyChildActive // A root item is active if its own link matches OR if any child is active
205
+
206
+ const itemDefaultOpen = item.children ? isAnyChildActive : false
207
+
208
+ return {
209
+ ...item,
210
+ active: itemIsActive,
211
+ class: itemIsActive
212
+ ? 'before:bg-primary before:rounded-lg text-white'
213
+ : '',
214
+ defaultOpen: itemDefaultOpen || selfIsActive,
215
+ open: itemDefaultOpen || selfIsActive,
216
+ children: mappedChildren,
217
+ to: mappedChildren ? undefined : item.to,
218
+ }
219
+ })
220
+ }
221
+
222
+ // filter items base on auth.hasPermission and nested children , permissions: ['pmo:USER', 'pmo:ADMIN', 'pmo:SUPER']
223
+ const filterItems = (items: NavigationMenuItem[]): NavigationMenuItem[] => {
224
+ return items.filter((item) => {
225
+ if (item.permissions && Array.isArray(item.permissions)) {
226
+ // permissions: ['pmo:USER', 'pmo:ADMIN', 'pmo:SUPER'] => module: 'pmo', permissions: ['USER', 'ADMIN', 'SUPER']
227
+ const permissionStrings = item.permissions as string[]
228
+
229
+ // Extract module from first permission (all should have same module)
230
+ const firstPermission = permissionStrings[0]
231
+
232
+ if (!firstPermission) {
233
+ return true
234
+ }
235
+
236
+ const [moduleStr] = firstPermission.split(':')
237
+
238
+ // Extract all permission levels
239
+ const permissionLevels = permissionStrings.map((p) => p.split(':')[1]) as Permission[]
240
+
241
+ return auth.hasPermission(moduleStr as UserModule, ...permissionLevels)
242
+ }
243
+
244
+ // Recursively filter children
245
+ if (item.children) {
246
+ const filteredChildren = filterItems(item.children as NavigationMenuItem[])
247
+
248
+ // Only include parent if it has visible children or no permission requirement
249
+ return filteredChildren.length > 0
250
+ }
251
+
252
+ return true
253
+ }).map((item) => {
254
+ // Apply filtering to children
255
+ if (item.children) {
256
+ return {
257
+ ...item,
258
+ children: filterItems(item.children as NavigationMenuItem[]),
259
+ }
260
+ }
261
+
262
+ return item
263
+ })
264
+ }
265
+
266
+ const navigationItems = computed<NavigationMenuItem[]>(() => {
267
+ // First filter items based on permissions
268
+ const filteredItems = filterItems(props.items)
269
+
270
+ if (props.isGroup) {
271
+ return filteredItems.map((group) => {
272
+ return {
273
+ ...group,
274
+ children: mappedItems(group.children as NavigationMenuItem[]),
275
+ }
276
+ })
277
+ }
278
+
279
+ return mappedItems(filteredItems)
280
+ })
281
+ </script>
@@ -0,0 +1,220 @@
1
+ <template>
2
+ <div class="relative flex min-h-screen flex-1">
3
+ <div
4
+ :class="[`
5
+ fixed inset-0 z-30 hidden w-auto
6
+ lg:block
7
+ `, {
8
+ 'max-w-[88px]': isCollapsed,
9
+ 'max-w-[260px]': !isCollapsed,
10
+ }]"
11
+ >
12
+ <Sidebar
13
+ :label="label"
14
+ :is-collapsed="isCollapsed"
15
+ :items="sidebarItems"
16
+ :is-group="isSidebarGroup"
17
+ @toggle-collapsed="isCollapsed = !isCollapsed"
18
+ />
19
+ </div>
20
+ <div
21
+ :class="['w-full bg-gray-50', {
22
+ 'lg:pl-[88px]': isCollapsed,
23
+ 'lg:pl-[260px]': !isCollapsed,
24
+ }]"
25
+ >
26
+ <nav
27
+ class="
28
+ fixed top-0 left-0 z-20 flex min-h-[64px] w-screen items-center justify-between
29
+ gap-4 bg-white px-5 lg:min-h-[72px]
30
+ lg:justify-end
31
+ "
32
+ style="box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.04);"
33
+ >
34
+ <div class="flex items-center gap-4">
35
+ <Slideover
36
+ v-model:open="isShowSidebarMobile"
37
+ :ui="{
38
+ content: 'w-[80%] max-w-[260px] lg:hidden',
39
+ overlay: 'lg:hidden',
40
+ }"
41
+ side="left"
42
+ >
43
+ <svg
44
+ class="
45
+ cursor-pointer
46
+ lg:hidden
47
+ "
48
+ width="19"
49
+ height="18"
50
+ viewBox="0 0 19 18"
51
+ fill="none"
52
+ xmlns="http://www.w3.org/2000/svg"
53
+ transform="scale(-1, 1)"
54
+ @click="isShowSidebarMobile = true"
55
+ >
56
+ <path
57
+ d="M10 18L10 16L19 16L19 18L10 18ZM6 2L6 -2.62268e-07L19 -8.30516e-07L19 2L6 2ZM-3.49691e-07 10L-4.37114e-07 8L19 8L19 10L-3.49691e-07 10Z"
58
+ fill="#4B5563"
59
+ />
60
+ </svg>
61
+
62
+ <template #content>
63
+ <Sidebar
64
+ :label="label"
65
+ :is-collapsed="false"
66
+ :is-mobile="true"
67
+ :items="sidebarItems"
68
+ :is-group="isSidebarGroup"
69
+ @toggle-collapsed="isShowSidebarMobile = false"
70
+ />
71
+ </template>
72
+ </Slideover>
73
+ <NuxtLink :to="routes.home.to">
74
+ <img
75
+ src="/logo-mini.png"
76
+ alt="logo_mini_color"
77
+ class="
78
+ w-[126px] min-w-[126px] lg:hidden
79
+ lg:w-[172px] lg:min-w-[172px]
80
+ "
81
+ />
82
+ </NuxtLink>
83
+ <Apps class="lg:hidden" />
84
+ </div>
85
+
86
+ <div class="flex items-center justify-center gap-4">
87
+ <DropdownMenu
88
+ size="xl"
89
+ :items="userMenuItems"
90
+ :content="{
91
+ align: 'end',
92
+ side: 'bottom',
93
+ }"
94
+ :ui="{
95
+ content: 'w-48',
96
+ }"
97
+ >
98
+ <div class="relative flex cursor-pointer items-center gap-2">
99
+ <div
100
+ class="hidden flex-col justify-end text-right lg:flex"
101
+ >
102
+ <p class="font-bold">
103
+ {{ auth.me.value?.display_name || auth.me.value?.full_name }}
104
+ </p>
105
+ <p class="text-muted text-sm">
106
+ {{ auth.me.value?.email || '' }}
107
+ </p>
108
+ </div>
109
+ <Avatar
110
+ class="border-muted size-[40px] border text-2xl"
111
+ icon="ri:user-line"
112
+ :src="auth.me.value?.avatar_url || ''"
113
+ />
114
+ <Icon
115
+ name="i-ph:caret-down-light"
116
+ class="size-5"
117
+ />
118
+ </div>
119
+ </DropdownMenu>
120
+ </div>
121
+ </nav>
122
+ <div class="w-full bg-gray-50 pt-[64px] lg:pt-[72px]">
123
+ <main
124
+ class="
125
+ mx-auto min-h-full w-full flex-1 px-6 py-10 lg:px-8
126
+ "
127
+ >
128
+ <Breadcrumb
129
+ v-if="!app.pageMeta.isHideBreadcrumbs && breadcrumbsItems.length > 1"
130
+ :items="breadcrumbsItems"
131
+ class="mb-6"
132
+ :ui="{
133
+ item: 'max-w-2/3',
134
+ list: 'w-full',
135
+ }"
136
+ />
137
+ <div
138
+ v-if="app.pageMeta.title"
139
+ class="
140
+ mb-4 flex flex-col justify-between gap-1 md:mb-6 md:gap-4
141
+ lg:flex-row lg:items-start
142
+ "
143
+ >
144
+ <div class="flex flex-1 flex-col">
145
+ <h1
146
+ class="
147
+ text-3xl font-bold wrap-break-word
148
+ lg:max-w-2/3
149
+ "
150
+ :title="app.pageMeta.title"
151
+ >
152
+ {{ app.pageMeta.title }}
153
+ <span id="page-title-extra" />
154
+ </h1>
155
+
156
+ <div id="page-subtitle" />
157
+ <p
158
+ v-if="app.pageMeta.sub_title"
159
+ class="text-[#475467]"
160
+ >
161
+ {{ app.pageMeta.sub_title }}
162
+ </p>
163
+ </div>
164
+ <div id="page-header" />
165
+ </div>
166
+ <slot />
167
+ </main>
168
+ </div>
169
+ </div>
170
+ </div>
171
+ </template>
172
+
173
+ <script lang="ts" setup>
174
+ import type { DropdownMenuItem, NavigationMenuItem } from '@nuxt/ui'
175
+ import { computed } from 'vue'
176
+ import Sidebar from './Sidebar.vue'
177
+ import Apps from './Apps.vue'
178
+
179
+ defineProps<{
180
+ label: string
181
+ sidebarItems: NavigationMenuItem[]
182
+ isSidebarGroup?: boolean
183
+ }>()
184
+
185
+ const app = useApp()
186
+ const isShowSidebarMobile = ref(false)
187
+ const auth = useAuth()
188
+ // Cookie to store user preference for desktop
189
+ const isCollapsed = useCookie<boolean>('app.admin.sidebar.isCollapsed', {
190
+ default: () => false,
191
+ path: '/',
192
+ })
193
+
194
+ const userMenuItems: DropdownMenuItem[] = [
195
+ {
196
+ label: 'View profile',
197
+ icon: 'i-lucide-user',
198
+ to: routes.account.profile.to,
199
+ },
200
+
201
+ {
202
+ label: routes.logout.label,
203
+ icon: 'i-lucide-log-out',
204
+ to: routes.logout.to,
205
+ external: true,
206
+ },
207
+ ]
208
+
209
+ const breadcrumbsItems = computed(() => [
210
+ // {
211
+ // label: '',
212
+ // icon: 'ph:house',
213
+ // to: '/',
214
+ // },
215
+ ...(app.pageMeta.breadcrumbs || []).map((item: any) => ({
216
+ ...item,
217
+ icon: '',
218
+ })),
219
+ ])
220
+ </script>
@@ -1,56 +1,56 @@
1
- <template>
2
- <slot v-if="skip" />
3
- <template v-else>
4
- <Card v-if="status.isLoading">
5
- <div class="flex items-center justify-center py-12">
6
- <Loader />
7
- </div>
8
- </Card>
9
-
10
- <!-- Not Found State -->
11
- <Card v-else-if="status.isSuccess && !data || status.isError && status.errorData?.code === 'NOT_FOUND'">
12
- <div class="py-12 text-center">
13
- <Icon
14
- name="lucide:search-x"
15
- class="mx-auto mb-4 text-gray-400"
16
- size="48"
17
- />
18
- <h3 class="mb-2 text-lg font-medium text-gray-900">
19
- ไม่พบข้อมูล
20
- </h3>
21
- <p class="text-gray-600">
22
- ไม่พบข้อมูลที่คุณค้นหา กรุณาลองใหม่อีกครั้ง
23
- </p>
24
- </div>
25
- </Card>
26
- <Card v-else-if="status.isError">
27
- <div class="py-12 text-center">
28
- <Icon
29
- name="lucide:alert-circle"
30
- class="mx-auto mb-4 text-red-500"
31
- size="48"
32
- />
33
- <h3 class="mb-2 text-lg font-medium text-gray-900">
34
- เกิดข้อผิดพลาด
35
- </h3>
36
- <p class="text-gray-600">
37
- ไม่สามารถโหลดข้อมูลได้ กรุณาลองใหม่อีกครั้ง
38
- </p>
39
- </div>
40
- </Card>
41
- <slot
42
- v-else-if="data"
43
- :data="data"
44
- />
45
- </template>
46
- </template>
47
-
48
- <script lang="ts" setup generic="T">
49
- import type { IStatus } from '#core/types/lib'
50
-
51
- defineProps<{
52
- status: IStatus
53
- data: T
54
- skip?: boolean
55
- }>()
56
- </script>
1
+ <template>
2
+ <slot v-if="skip" />
3
+ <template v-else>
4
+ <Card v-if="status.isLoading">
5
+ <div class="flex items-center justify-center py-12">
6
+ <Loader />
7
+ </div>
8
+ </Card>
9
+
10
+ <!-- Not Found State -->
11
+ <Card v-else-if="status.isSuccess && !data || status.isError && status.errorData?.code === 'NOT_FOUND'">
12
+ <div class="py-12 text-center">
13
+ <Icon
14
+ name="lucide:search-x"
15
+ class="mx-auto mb-4 text-gray-400"
16
+ size="48"
17
+ />
18
+ <h3 class="mb-2 text-lg font-medium text-gray-900">
19
+ ไม่พบข้อมูล
20
+ </h3>
21
+ <p class="text-gray-600">
22
+ ไม่พบข้อมูลที่คุณค้นหา กรุณาลองใหม่อีกครั้ง
23
+ </p>
24
+ </div>
25
+ </Card>
26
+ <Card v-else-if="status.isError">
27
+ <div class="py-12 text-center">
28
+ <Icon
29
+ name="lucide:alert-circle"
30
+ class="mx-auto mb-4 text-red-500"
31
+ size="48"
32
+ />
33
+ <h3 class="mb-2 text-lg font-medium text-gray-900">
34
+ เกิดข้อผิดพลาด
35
+ </h3>
36
+ <p class="text-gray-600">
37
+ ไม่สามารถโหลดข้อมูลได้ กรุณาลองใหม่อีกครั้ง
38
+ </p>
39
+ </div>
40
+ </Card>
41
+ <slot
42
+ v-else-if="data"
43
+ :data="data"
44
+ />
45
+ </template>
46
+ </template>
47
+
48
+ <script lang="ts" setup generic="T">
49
+ import type { IStatus } from '#core/types/lib'
50
+
51
+ defineProps<{
52
+ status: IStatus
53
+ data: T
54
+ skip?: boolean
55
+ }>()
56
+ </script>