first commit
This commit is contained in:
82
components/App/AppBar.vue
Normal file
82
components/App/AppBar.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { mergeProps } from 'vue'
|
||||
|
||||
const theme = useTheme()
|
||||
const drawer = useState('drawer')
|
||||
const route = useRoute()
|
||||
const breadcrumbs = computed(() => {
|
||||
return route!.matched
|
||||
.filter((item) => item.meta && item.meta.title)
|
||||
.map((r) => ({
|
||||
title: r.meta.title!,
|
||||
disabled: r.path === route.path || false,
|
||||
to: r.path,
|
||||
}))
|
||||
})
|
||||
const isDark = computed({
|
||||
get() {
|
||||
return theme.global.name.value === 'dark' ? true : false
|
||||
},
|
||||
set(v) {
|
||||
theme.global.name.value = v ? 'dark' : 'light'
|
||||
},
|
||||
})
|
||||
// const { loggedIn, clear, user } = useUserSession()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-app-bar flat>
|
||||
<v-app-bar-nav-icon @click="drawer = !drawer" />
|
||||
<v-breadcrumbs :items="breadcrumbs" />
|
||||
<v-spacer />
|
||||
<div id="app-bar" />
|
||||
<v-switch
|
||||
v-model="isDark"
|
||||
color=""
|
||||
hide-details
|
||||
density="compact"
|
||||
inset
|
||||
false-icon="mdi-white-balance-sunny"
|
||||
true-icon="mdi-weather-night"
|
||||
class="opacity-80"
|
||||
/>
|
||||
<v-btn
|
||||
icon
|
||||
href="https://github.com/kingyue737/vitify-nuxt"
|
||||
size="small"
|
||||
class="ml-2"
|
||||
target="_blank"
|
||||
>
|
||||
<v-icon size="30" icon="mdi-github" />
|
||||
</v-btn>
|
||||
<v-menu location="bottom">
|
||||
<template #activator="{ props: menu }">
|
||||
<v-tooltip location="bottom">
|
||||
<template #activator="{ props: tooltip }">
|
||||
<v-btn icon v-bind="mergeProps(menu, tooltip)" class="ml-1">
|
||||
<!-- <v-icon v-if="!loggedIn" icon="mdi-account-circle" size="36" /> -->
|
||||
<!-- <v-avatar v-else color="primary" size="36"> -->
|
||||
<!-- <v-img :src="user?.avatar_url" /> -->
|
||||
<!-- </v-avatar> -->
|
||||
</v-btn>
|
||||
</template>
|
||||
<!-- <span>{{ loggedIn ? user!.login : 'User' }}</span> -->
|
||||
</v-tooltip>
|
||||
</template>
|
||||
<v-list>
|
||||
<!-- <v-list-item
|
||||
v-if="!loggedIn"
|
||||
title="Login"
|
||||
prepend-icon="mdi-github"
|
||||
href="/api/auth/github"
|
||||
/> -->
|
||||
<!-- <v-list-item
|
||||
v-else
|
||||
title="Logout"
|
||||
prepend-icon="mdi-logout"
|
||||
@click="clear"
|
||||
/> -->
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-app-bar>
|
||||
</template>
|
||||
135
components/App/AppDrawer.vue
Normal file
135
components/App/AppDrawer.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
const router = useRouter()
|
||||
const routes = router.getRoutes().filter((r) => r.path.lastIndexOf('/') === 0)
|
||||
const drawerState = useState('drawer', () => true)
|
||||
|
||||
const { mobile, lgAndUp, width } = useDisplay()
|
||||
const drawer = computed({
|
||||
get() {
|
||||
return drawerState.value || !mobile.value
|
||||
},
|
||||
set(val: boolean) {
|
||||
drawerState.value = val
|
||||
},
|
||||
})
|
||||
const rail = computed(() => !drawerState.value && !mobile.value)
|
||||
routes.sort((a, b) => (a.meta?.drawerIndex ?? 99) - (b.meta?.drawerIndex ?? 98))
|
||||
|
||||
drawerState.value = lgAndUp.value && width.value !== 1280
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-navigation-drawer
|
||||
v-model="drawer"
|
||||
:expand-on-hover="rail"
|
||||
:rail="rail"
|
||||
floating
|
||||
>
|
||||
<template #prepend>
|
||||
<v-list>
|
||||
<v-list-item class="pa-1">
|
||||
<template #prepend>
|
||||
<v-icon
|
||||
icon="custom:vitify-nuxt"
|
||||
size="x-large"
|
||||
class="drawer-header-icon"
|
||||
color="primary"
|
||||
/>
|
||||
</template>
|
||||
<v-list-item-title
|
||||
class="text-h5 font-weight-bold"
|
||||
style="line-height: 2rem"
|
||||
>
|
||||
Vitify <span class="text-primary">Admin</span>
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
<v-list nav density="compact">
|
||||
<AppDrawerItem v-for="route in routes" :key="route.name" :item="route" />
|
||||
</v-list>
|
||||
<v-spacer />
|
||||
<template #append>
|
||||
<v-list-item class="drawer-footer px-0 d-flex flex-column justify-center">
|
||||
<div class="text-caption pt-6 pt-md-0 text-center text-no-wrap">
|
||||
© Copyright 2023
|
||||
<a
|
||||
href="https://github.com/kingyue737"
|
||||
class="font-weight-bold text-primary"
|
||||
target="_blank"
|
||||
>Yue JIN</a
|
||||
>
|
||||
<span> & </span>
|
||||
<a
|
||||
href="https://www.nustarnuclear.com/"
|
||||
class="font-weight-bold text-primary"
|
||||
target="_blank"
|
||||
>NuStar</a
|
||||
>
|
||||
</div>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-navigation-drawer>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.v-navigation-drawer {
|
||||
transition-property:
|
||||
box-shadow, transform, visibility, width, height, left, right, top, bottom,
|
||||
border-radius !important;
|
||||
overflow: hidden;
|
||||
&.v-navigation-drawer--rail {
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
&.v-navigation-drawer--is-hovering {
|
||||
border-top-right-radius: 15px;
|
||||
border-bottom-right-radius: 15px;
|
||||
box-shadow:
|
||||
0px 1px 2px 0px rgb(0 0 0 / 30%),
|
||||
0px 1px 3px 1px rgb(0 0 0 / 15%);
|
||||
}
|
||||
&:not(.v-navigation-drawer--is-hovering) {
|
||||
.drawer-footer {
|
||||
transform: translateX(-160px);
|
||||
}
|
||||
.drawer-header-icon {
|
||||
height: 1em !important;
|
||||
width: 1em !important;
|
||||
}
|
||||
.v-list-group {
|
||||
--list-indent-size: 0px;
|
||||
--prepend-width: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.v-navigation-drawer__content {
|
||||
overflow-y: hidden;
|
||||
@supports (scrollbar-gutter: stable) {
|
||||
scrollbar-gutter: stable;
|
||||
> .v-list--nav {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
overflow-y: overlay;
|
||||
}
|
||||
}
|
||||
.drawer-footer {
|
||||
transition: all 0.2s;
|
||||
min-height: 30px;
|
||||
}
|
||||
.drawer-header-icon {
|
||||
opacity: 1 !important;
|
||||
height: 1.2em !important;
|
||||
width: 1.2em !important;
|
||||
transition: all 0.2s;
|
||||
margin-right: -10px;
|
||||
}
|
||||
.v-list-group {
|
||||
--prepend-width: 10px;
|
||||
}
|
||||
.v-list-item {
|
||||
transition: all 0.2s;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
40
components/App/AppDrawerItem.vue
Normal file
40
components/App/AppDrawerItem.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
const { item } = defineProps<{
|
||||
item: RouteRecordRaw
|
||||
}>()
|
||||
const visibleChildren = computed(() =>
|
||||
item.children
|
||||
?.filter((child) => child.meta?.icon)
|
||||
.sort((a, b) => (a.meta?.drawerIndex ?? 99) - (b.meta?.drawerIndex ?? 98)),
|
||||
)
|
||||
const visibleChildrenNum = computed(() => visibleChildren.value?.length || 0)
|
||||
const isItem = computed(() => !item.children || visibleChildrenNum.value <= 1)
|
||||
const title = toRef(() => item.meta?.title)
|
||||
const icon = toRef(() => item.meta?.icon)
|
||||
// @ts-expect-error unknown type miss match
|
||||
const to = computed<RouteRecordRaw>(() => ({
|
||||
name: item.name || visibleChildren.value?.[0].name,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-list-item
|
||||
v-if="isItem && icon"
|
||||
:to="to"
|
||||
:prepend-icon="icon"
|
||||
active-class="text-primary"
|
||||
:title="title"
|
||||
/>
|
||||
<v-list-group v-else-if="icon" :prepend-icon="icon" color="primary">
|
||||
<template #activator="{ props: vProps }">
|
||||
<v-list-item :title="title" v-bind="vProps" />
|
||||
</template>
|
||||
<AppDrawerItem
|
||||
v-for="child in visibleChildren"
|
||||
:key="child.name"
|
||||
:item="child"
|
||||
/>
|
||||
</v-list-group>
|
||||
</template>
|
||||
22
components/App/AppFooter.vue
Normal file
22
components/App/AppFooter.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<v-footer app>
|
||||
<v-spacer />
|
||||
<v-defaults-provider
|
||||
:defaults="{ VBtn: { variant: 'text', size: 'x-small' } }"
|
||||
>
|
||||
<AppNotification />
|
||||
<AppSettings />
|
||||
</v-defaults-provider>
|
||||
</v-footer>
|
||||
</template>
|
||||
<style>
|
||||
.v-footer {
|
||||
padding: 0px 10px !important;
|
||||
> .v-btn--icon {
|
||||
.v-icon {
|
||||
height: 1.25em;
|
||||
width: 1.25em;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
125
components/App/AppNotification.vue
Normal file
125
components/App/AppNotification.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
const notificationStore = useNotificationStore()
|
||||
const { notifications } = storeToRefs(notificationStore)
|
||||
const notificationsShown = computed(() =>
|
||||
notifications.value.filter((notification) => notification.show).reverse(),
|
||||
)
|
||||
const showAll = ref(false)
|
||||
const timeout = computed(() => (showAll.value ? -1 : 5000))
|
||||
function deleteNotification(id: number) {
|
||||
notificationStore.delNotification(id)
|
||||
}
|
||||
function emptyNotifications() {
|
||||
notificationStore.$reset()
|
||||
}
|
||||
function toggleAll() {
|
||||
showAll.value = !showAll.value
|
||||
notifications.value.forEach((m) => {
|
||||
m.show = showAll.value
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-btn
|
||||
v-tooltip="{ text: 'Notification' }"
|
||||
:icon="notifications.length ? 'mdi-bell-badge-outline' : 'mdi-bell-outline'"
|
||||
:rounded="0"
|
||||
@click="toggleAll"
|
||||
/>
|
||||
<ClientOnly>
|
||||
<teleport to="body">
|
||||
<v-card
|
||||
elevation="6"
|
||||
width="400"
|
||||
class="d-flex flex-column notification-card"
|
||||
:class="{ 'notification-card--open': showAll }"
|
||||
>
|
||||
<v-toolbar flat density="compact">
|
||||
<v-toolbar-title
|
||||
class="font-weight-light text-body-1"
|
||||
:text="
|
||||
notifications.length ? 'Notification' : 'No New Notifications'
|
||||
"
|
||||
/>
|
||||
<v-btn
|
||||
v-tooltip="{ text: 'Clear All Notifications' }"
|
||||
size="small"
|
||||
icon="mdi-bell-remove"
|
||||
@click="emptyNotifications"
|
||||
/>
|
||||
<v-btn
|
||||
v-tooltip="{ text: 'Hide Notifications' }"
|
||||
class="mr-0"
|
||||
size="small"
|
||||
icon="$expand"
|
||||
@click="toggleAll"
|
||||
/>
|
||||
</v-toolbar>
|
||||
<v-slide-y-reverse-transition
|
||||
tag="div"
|
||||
class="d-flex flex-column notification-box"
|
||||
group
|
||||
hide-on-leave
|
||||
>
|
||||
<div
|
||||
v-for="notification in notificationsShown"
|
||||
:key="notification.id"
|
||||
class="notification-item-wrapper"
|
||||
>
|
||||
<AppNotificationItem
|
||||
v-model="notification.show"
|
||||
:notification="notification"
|
||||
:timeout="timeout"
|
||||
class="notification-item"
|
||||
@close="deleteNotification(notification.id)"
|
||||
/>
|
||||
</div>
|
||||
</v-slide-y-reverse-transition>
|
||||
</v-card>
|
||||
</teleport>
|
||||
</ClientOnly>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notification-item {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
}
|
||||
.notification-card {
|
||||
z-index: 1;
|
||||
position: fixed;
|
||||
right: 15px;
|
||||
bottom: 48px;
|
||||
max-height: 100vh;
|
||||
overflow: visible;
|
||||
visibility: hidden;
|
||||
&.notification-card--open {
|
||||
visibility: visible;
|
||||
overflow: hidden;
|
||||
max-height: calc(100vh - 200px);
|
||||
.notification-box {
|
||||
overflow-y: overlay;
|
||||
pointer-events: auto;
|
||||
.notification-item-wrapper {
|
||||
transition: none !important;
|
||||
.notification-item {
|
||||
margin-bottom: 0;
|
||||
border-radius: 0;
|
||||
border-top: 1px solid #5656563d !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.notification-box {
|
||||
overflow-y: visible;
|
||||
visibility: visible;
|
||||
pointer-events: none;
|
||||
.notification-item {
|
||||
pointer-events: initial;
|
||||
user-select: initial;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
41
components/App/AppNotificationItem.vue
Normal file
41
components/App/AppNotificationItem.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import type { Notification } from '~/stores/notification'
|
||||
|
||||
const props = defineProps<{
|
||||
timeout: number
|
||||
notification: Notification
|
||||
}>()
|
||||
const emit = defineEmits(['close'])
|
||||
const isShow = defineModel<boolean>({ default: false })
|
||||
const timeout = toRef(props, 'timeout')
|
||||
const { start, stop } = useTimeoutFn(() => (isShow.value = false), timeout, {
|
||||
immediate: false,
|
||||
})
|
||||
watch(timeout, (v) => (v !== -1 ? start() : stop()), { immediate: true })
|
||||
const variant = computed(() => timeout.value === -1)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-alert
|
||||
:border="variant ? 'start' : false"
|
||||
:variant="variant ? 'outlined' : undefined"
|
||||
:density="variant ? 'compact' : undefined"
|
||||
:theme="variant ? undefined : 'dark'"
|
||||
:elevation="variant ? 0 : 3"
|
||||
:type="notification.type"
|
||||
:text="notification.text"
|
||||
:title="notification.time.toLocaleString()"
|
||||
>
|
||||
<template #close>
|
||||
<v-btn icon="$close" @click="emit('close')" />
|
||||
</template>
|
||||
</v-alert>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.v-alert-title) {
|
||||
line-height: 1.25rem;
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
}
|
||||
</style>
|
||||
60
components/App/AppSettings.vue
Normal file
60
components/App/AppSettings.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { mergeProps } from 'vue'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
|
||||
const theme = useTheme()
|
||||
const primary = useStorage('theme-primary', '#1697f6')
|
||||
const color = computed({
|
||||
get() {
|
||||
return theme.themes.value.light.colors.primary
|
||||
},
|
||||
set(val: string) {
|
||||
primary.value = val
|
||||
theme.themes.value.light.colors.primary = val
|
||||
theme.themes.value.dark.colors.primary = val
|
||||
},
|
||||
})
|
||||
const colors = [
|
||||
['#1697f6', '#ff9800'],
|
||||
['#4CAF50', '#FF5252'],
|
||||
['#9C27b0', '#E91E63'],
|
||||
['#304156', '#3f51b5'],
|
||||
['#002FA7', '#492d22'],
|
||||
]
|
||||
const menuShow = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-menu
|
||||
v-model="menuShow"
|
||||
:close-on-content-click="false"
|
||||
location="top right"
|
||||
offset="15"
|
||||
>
|
||||
<template #activator="{ props: menu }">
|
||||
<v-tooltip location="top" text="Theme Palette">
|
||||
<template #activator="{ props: tooltip }">
|
||||
<v-btn
|
||||
icon="mdi-palette-outline"
|
||||
v-bind="mergeProps(menu, tooltip)"
|
||||
:rounded="0"
|
||||
/>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
<v-card width="320">
|
||||
<v-card-text class="text-center">
|
||||
<v-label class="mb-3"> Theme Palette </v-label>
|
||||
<v-color-picker
|
||||
v-model="color"
|
||||
show-swatches
|
||||
elevation="0"
|
||||
width="288"
|
||||
mode="rgb"
|
||||
:modes="['rgb', 'hex', 'hsl']"
|
||||
:swatches="colors"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</template>
|
||||
62
components/Chart/ChartBar.vue
Normal file
62
components/Chart/ChartBar.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
const option: ECOption = {
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow',
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
top: 20,
|
||||
left: '2%',
|
||||
right: '2%',
|
||||
bottom: '3%',
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: [
|
||||
{
|
||||
type: 'category',
|
||||
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
|
||||
axisTick: {
|
||||
alignWithLabel: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: 'pageA',
|
||||
type: 'bar',
|
||||
stack: 'vistors',
|
||||
barWidth: '60%',
|
||||
data: [79, 52, 200, 334, 390, 330, 220],
|
||||
},
|
||||
{
|
||||
name: 'pageB',
|
||||
type: 'bar',
|
||||
stack: 'vistors',
|
||||
barWidth: '60%',
|
||||
data: [80, 52, 200, 334, 390, 330, 220],
|
||||
},
|
||||
{
|
||||
name: 'pageC',
|
||||
type: 'bar',
|
||||
stack: 'vistors',
|
||||
barWidth: '60%',
|
||||
data: [30, 52, 200, 334, 390, 330, 220],
|
||||
},
|
||||
],
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-chart :option="option" autoresize />
|
||||
</template>
|
||||
95
components/Chart/ChartLine.vue
Normal file
95
components/Chart/ChartLine.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
const data = [
|
||||
['2022-06-05', 116],
|
||||
['2022-06-06', 129],
|
||||
['2022-06-07', 135],
|
||||
['2022-06-08', 86],
|
||||
['2022-06-09', 73],
|
||||
['2022-06-10', 85],
|
||||
['2022-06-11', 73],
|
||||
['2022-06-12', 68],
|
||||
['2022-06-13', 92],
|
||||
['2022-06-14', 130],
|
||||
['2022-06-15', 245],
|
||||
['2022-06-16', 139],
|
||||
['2022-06-17', 115],
|
||||
['2022-06-18', 111],
|
||||
['2022-06-19', 309],
|
||||
['2022-06-20', 206],
|
||||
['2022-06-21', 137],
|
||||
['2022-06-22', 128],
|
||||
['2022-06-23', 85],
|
||||
['2022-06-24', 94],
|
||||
['2022-06-25', 71],
|
||||
['2022-06-26', 106],
|
||||
['2022-06-27', 84],
|
||||
['2022-06-28', 93],
|
||||
['2022-06-29', 85],
|
||||
['2022-06-30', 73],
|
||||
['2022-07-01', 83],
|
||||
['2022-07-02', 125],
|
||||
['2022-07-03', 107],
|
||||
['2022-07-04', 82],
|
||||
['2022-07-05', 44],
|
||||
['2022-07-06', 72],
|
||||
['2022-07-07', 106],
|
||||
['2022-07-08', 107],
|
||||
['2022-07-09', 66],
|
||||
['2022-07-10', 91],
|
||||
['2022-07-11', 92],
|
||||
['2022-07-12', 113],
|
||||
['2022-07-13', 107],
|
||||
['2022-07-14', 131],
|
||||
['2022-07-15', 111],
|
||||
['2022-07-16', 64],
|
||||
['2022-07-17', 69],
|
||||
['2022-07-18', 88],
|
||||
['2022-07-19', 77],
|
||||
['2022-07-20', 83],
|
||||
['2022-07-21', 111],
|
||||
['2022-07-22', 57],
|
||||
['2022-07-23', 55],
|
||||
['2022-07-24', 60],
|
||||
]
|
||||
|
||||
const option: ECOption = {
|
||||
backgroundColor: 'transparent',
|
||||
dataset: { source: data },
|
||||
visualMap: {
|
||||
show: false,
|
||||
type: 'continuous',
|
||||
min: 0,
|
||||
max: 400,
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
},
|
||||
grid: {
|
||||
top: 20,
|
||||
left: '2%',
|
||||
right: '2%',
|
||||
bottom: '3%',
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'time',
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'value',
|
||||
type: 'line',
|
||||
showSymbol: false,
|
||||
lineStyle: {
|
||||
width: 4,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-chart :option="option" autoresize />
|
||||
</template>
|
||||
34
components/Chart/ChartPie.vue
Normal file
34
components/Chart/ChartPie.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
const option: ECOption = {
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
},
|
||||
legend: {
|
||||
left: 'center',
|
||||
bottom: '10',
|
||||
data: ['Industries', 'Technology', 'Forex', 'Gold', 'Forecasts'],
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'WEEKLY WRITE ARTICLES',
|
||||
type: 'pie',
|
||||
roseType: 'radius',
|
||||
radius: [15, 95],
|
||||
center: ['50%', '38%'],
|
||||
data: [
|
||||
{ value: 320, name: 'Industries' },
|
||||
{ value: 240, name: 'Technology' },
|
||||
{ value: 149, name: 'Forex' },
|
||||
{ value: 100, name: 'Gold' },
|
||||
{ value: 59, name: 'Forecasts' },
|
||||
],
|
||||
animationEasing: 'cubicInOut',
|
||||
},
|
||||
],
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-chart :option="option" autoresize />
|
||||
</template>
|
||||
64
components/Chart/ChartRadar.vue
Normal file
64
components/Chart/ChartRadar.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
const option: ECOption = {
|
||||
backgroundColor: 'transparent',
|
||||
radar: {
|
||||
radius: '66%',
|
||||
center: ['50%', '42%'],
|
||||
splitNumber: 8,
|
||||
splitArea: {
|
||||
areaStyle: {
|
||||
color: 'rgba(127,95,132,.3)',
|
||||
opacity: 1,
|
||||
shadowBlur: 45,
|
||||
shadowColor: 'rgba(0,0,0,.5)',
|
||||
shadowOffsetX: 0,
|
||||
shadowOffsetY: 15,
|
||||
},
|
||||
},
|
||||
indicator: [
|
||||
{ name: 'Sales' },
|
||||
{ name: 'Administration' },
|
||||
{ name: 'Technology' },
|
||||
{ name: 'Customer Support' },
|
||||
{ name: 'Development' },
|
||||
{ name: 'Marketing' },
|
||||
],
|
||||
},
|
||||
legend: {
|
||||
left: 'center',
|
||||
bottom: '10',
|
||||
data: ['Allocated Budget', 'Expected Spending', 'Actual Spending'],
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'radar',
|
||||
symbolSize: 0,
|
||||
areaStyle: {
|
||||
shadowBlur: 13,
|
||||
shadowColor: 'rgba(0,0,0,.2)',
|
||||
shadowOffsetX: 0,
|
||||
shadowOffsetY: 10,
|
||||
opacity: 1,
|
||||
},
|
||||
data: [
|
||||
{
|
||||
value: [5000, 7000, 12000, 11000, 15000, 14000],
|
||||
name: 'Allocated Budget',
|
||||
},
|
||||
{
|
||||
value: [4000, 9000, 15000, 15000, 13000, 11000],
|
||||
name: 'Expected Spending',
|
||||
},
|
||||
{
|
||||
value: [5500, 5000, 12000, 15000, 8000, 6000],
|
||||
name: 'Actual Spending',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-chart :option="option" autoresize />
|
||||
</template>
|
||||
45
components/DialogConfirm.vue
Normal file
45
components/DialogConfirm.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
const dialog = ref(false)
|
||||
const confirmed = ref(false)
|
||||
let resolve: (value: boolean) => void
|
||||
const message = ref('')
|
||||
watch(dialog, (v) => {
|
||||
if (!v) {
|
||||
resolve(confirmed.value)
|
||||
}
|
||||
})
|
||||
function open(text: string) {
|
||||
confirmed.value = false
|
||||
dialog.value = true
|
||||
message.value = text
|
||||
return new Promise<boolean>((resolveFn) => {
|
||||
resolve = resolveFn
|
||||
})
|
||||
}
|
||||
function confirm() {
|
||||
confirmed.value = true
|
||||
dialog.value = false
|
||||
}
|
||||
function cancel() {
|
||||
confirmed.value = false
|
||||
dialog.value = false
|
||||
}
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-dialog v-model="dialog" max-width="400px">
|
||||
<v-card>
|
||||
<v-card-text class="font-weight-bold d-flex">
|
||||
<v-icon class="mr-2" color="warning" icon="$warning" />
|
||||
<span>{{ message }}</span>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn color="primary" @click="cancel"> Cancel </v-btn>
|
||||
<v-btn color="primary" @click="confirm"> Confirm </v-btn>
|
||||
<v-spacer />
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
68
components/Div/Avatar.vue
Normal file
68
components/Div/Avatar.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
size: {
|
||||
type: [String, Number],
|
||||
default: 40,
|
||||
},
|
||||
gender: {
|
||||
type: String,
|
||||
default: '',
|
||||
validator: (value) => ['male', 'female', ''].includes(value.toLowerCase()),
|
||||
},
|
||||
profileImageUrl: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
class: {
|
||||
type: [String, Object, Array],
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const avatarSource = computed(() => {
|
||||
if (props.profileImageUrl) {
|
||||
return props.profileImageUrl
|
||||
}
|
||||
|
||||
switch (props.gender.toLowerCase()) {
|
||||
case 'male':
|
||||
return null // Gunakan slot default untuk v-icon
|
||||
case 'female':
|
||||
return null // Gunakan slot default untuk v-icon
|
||||
default:
|
||||
return null // Gunakan slot default untuk v-icon
|
||||
}
|
||||
})
|
||||
|
||||
const avatarIcon = computed(() => {
|
||||
if (!props.profileImageUrl) {
|
||||
switch (props.gender.toLowerCase()) {
|
||||
case 'male':
|
||||
return 'mdi-face-man'
|
||||
case 'female':
|
||||
return 'mdi-face-woman'
|
||||
default:
|
||||
return 'mdi-emoticon-confused'
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-avatar :size="size" color="primary" :class="class">
|
||||
<img v-if="avatarSource" :src="avatarSource" :alt="`Avatar`" />
|
||||
<v-icon
|
||||
v-else-if="avatarIcon"
|
||||
:size="Math.max(Number(size) * 0.66, 20)"
|
||||
color="white"
|
||||
>
|
||||
{{ avatarIcon }}
|
||||
</v-icon>
|
||||
<slot v-else name="fallback">
|
||||
<v-icon :size="Math.max(Number(size) * 0.66, 20)" color="white">
|
||||
mdi-account-circle
|
||||
</v-icon>
|
||||
</slot>
|
||||
</v-avatar>
|
||||
</template>
|
||||
19
components/Div/IconText.vue
Normal file
19
components/Div/IconText.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
text: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: 'mdi-information',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-row align="center" no-gutters>
|
||||
<v-icon small class="mr-2">{{ icon }}</v-icon>
|
||||
<span class="text-body-2">{{ text }}</span>
|
||||
</v-row>
|
||||
</template>
|
||||
268
components/Form/Lib/Address.vue
Normal file
268
components/Form/Lib/Address.vue
Normal file
@@ -0,0 +1,268 @@
|
||||
<script setup>
|
||||
const delay = ref(null)
|
||||
delay.value = 10
|
||||
|
||||
async function getItemsSearch(query, el$) {
|
||||
const { data } = await useFetch('/api/address?search=' + query, {
|
||||
lazy: true,
|
||||
server: false,
|
||||
})
|
||||
return data.value.map((d) => {
|
||||
return {
|
||||
value: d,
|
||||
label: d.display,
|
||||
}
|
||||
})
|
||||
// const addressesString = data.value.map((value) => {
|
||||
// var result = value.villages ? 'Desa/Kel ' + value.villages + ', ' : ''
|
||||
// result += value.districts ? 'Kec ' + value.districts + ', ' : ''
|
||||
// result += value.cities ? 'Kota/Kab ' + value.cities + ', ' : ''
|
||||
// result += value.states ? 'Prov ' + value.states + ', ' : ''
|
||||
// result += value.countries ? value.countries : ''
|
||||
// return { value: value, label: result }
|
||||
// })
|
||||
// return addressesString
|
||||
}
|
||||
|
||||
async function getPostal(query) {
|
||||
const { data } = await useFetch('/api/address?search=' + query, {
|
||||
lazy: true,
|
||||
server: false,
|
||||
})
|
||||
return data.value
|
||||
}
|
||||
|
||||
function onSelectSearch(option, el$) {
|
||||
const postal = getPostal(option.value.states.regencies.districts)
|
||||
el$.$parent.$parent.children$.country.update(option.value)
|
||||
el$.$parent.$parent.children$.state.update(option.value.states)
|
||||
el$.$parent.$parent.children$.city.update(option.value.states.regencies)
|
||||
el$.$parent.$parent.children$.district.update(
|
||||
option.value.states.regencies.districts,
|
||||
)
|
||||
el$.$parent.$parent.children$.village.update(
|
||||
option.value.states.regencies.districts.villages,
|
||||
)
|
||||
el$.$parent.$parent.children$.postalCode.update(postal[0])
|
||||
}
|
||||
|
||||
function onSelectCountry(option, el$) {
|
||||
el$.$parent.$parent.children$.state.clear()
|
||||
el$.$parent.$parent.children$.city.clear()
|
||||
el$.$parent.$parent.children$.district.clear()
|
||||
el$.$parent.$parent.children$.village.clear()
|
||||
el$.$parent.$parent.children$.postalCode.clear()
|
||||
}
|
||||
|
||||
function onSelectState(option, el$) {
|
||||
el$.$parent.$parent.children$.city.clear()
|
||||
el$.$parent.$parent.children$.district.clear()
|
||||
el$.$parent.$parent.children$.village.clear()
|
||||
el$.$parent.$parent.children$.postalCode.clear()
|
||||
}
|
||||
|
||||
function onSelectCity(option, el$) {
|
||||
el$.$parent.$parent.children$.district.clear()
|
||||
el$.$parent.$parent.children$.village.clear()
|
||||
el$.$parent.$parent.children$.postalCode.clear()
|
||||
}
|
||||
|
||||
function onSelectDistrict(option, el$) {
|
||||
el$.$parent.$parent.children$.village.clear()
|
||||
el$.$parent.$parent.children$.postalCode.clear()
|
||||
console.log(el$)
|
||||
}
|
||||
|
||||
function onSelectVillage(option, el$) {
|
||||
el$.$parent.$parent.children$.postalCode.clear()
|
||||
}
|
||||
|
||||
async function onChange(input, el$) {
|
||||
delay.value = input.length > 0 ? 5000 : 10
|
||||
}
|
||||
|
||||
function formatData(name, value) {
|
||||
return { [name]: value.name }
|
||||
}
|
||||
function formatLine(name, value) {
|
||||
return { [name]: value.split('\n') }
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<ListElement name="address">
|
||||
<template #default="{ index }">
|
||||
<ObjectElement
|
||||
:name="index"
|
||||
:columns="{
|
||||
sm: {
|
||||
container: 12,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<TextareaElement
|
||||
name="line"
|
||||
placeholder="Alamat"
|
||||
:format-data="formatLine"
|
||||
/>
|
||||
<SelectElement
|
||||
name="search"
|
||||
@select="onSelectSearch"
|
||||
:search="true"
|
||||
:native="false"
|
||||
input-type="search"
|
||||
autocomplete="off"
|
||||
placeholder="🔎 Cari Alamat"
|
||||
:resolve-on-load="false"
|
||||
:delay="20"
|
||||
:items="getItemsSearch"
|
||||
:submit="false"
|
||||
no-results-text="Alamat Tidak Ditemukan"
|
||||
:object="true"
|
||||
:filter-results="false"
|
||||
:caret="false"
|
||||
/>
|
||||
<SelectElement
|
||||
name="country"
|
||||
allow-absent
|
||||
@select="onSelectCountry"
|
||||
items="/api/address/countries"
|
||||
:format-data="formatData"
|
||||
:search="true"
|
||||
:native="false"
|
||||
:object="true"
|
||||
:resolve-on-load="true"
|
||||
autocomplete="on"
|
||||
value-prop="_id"
|
||||
label-prop="name"
|
||||
input-type="search"
|
||||
placeholder="Negara"
|
||||
:conditions="[
|
||||
[
|
||||
['address.*.line', 'not_empty'],
|
||||
['address.*.search', 'not_empty'],
|
||||
],
|
||||
]"
|
||||
:columns="{
|
||||
sm: {
|
||||
container: 6,
|
||||
},
|
||||
}"
|
||||
/>
|
||||
<SelectElement
|
||||
name="state"
|
||||
allow-absent
|
||||
@select="onSelectState"
|
||||
@search-change="onChange"
|
||||
items="/api/address/states?parent={address.*.country}"
|
||||
:format-data="formatData"
|
||||
:search="true"
|
||||
:native="false"
|
||||
:object="true"
|
||||
:delay="delay"
|
||||
:resolve-on-load="false"
|
||||
autocomplete="on"
|
||||
value-prop="_id"
|
||||
label-prop="name"
|
||||
input-type="search"
|
||||
placeholder="Provinsi"
|
||||
:conditions="[['address.*.country', 'not_empty']]"
|
||||
:columns="{
|
||||
sm: {
|
||||
container: 6,
|
||||
},
|
||||
}"
|
||||
/>
|
||||
<SelectElement
|
||||
name="city"
|
||||
allow-absent
|
||||
@select="onSelectCity"
|
||||
@search-change="onChange"
|
||||
items="/api/address/cities?parent={address.*.state}"
|
||||
:format-data="formatData"
|
||||
:search="true"
|
||||
:native="false"
|
||||
:object="true"
|
||||
:delay="delay"
|
||||
:resolve-on-load="false"
|
||||
autocomplete="on"
|
||||
value-prop="_id"
|
||||
label-prop="name"
|
||||
input-type="search"
|
||||
placeholder="Kota / Kabupaten"
|
||||
:conditions="[['address.*.state', 'not_empty']]"
|
||||
:columns="{
|
||||
sm: {
|
||||
container: 6,
|
||||
},
|
||||
}"
|
||||
/>
|
||||
<SelectElement
|
||||
name="district"
|
||||
allow-absent
|
||||
@select="onSelectDistrict"
|
||||
@search-change="onChange"
|
||||
items="/api/address/districts?parent={address.*.city}"
|
||||
:format-data="formatData"
|
||||
:search="true"
|
||||
:native="false"
|
||||
:object="true"
|
||||
:delay="delay"
|
||||
:resolve-on-load="false"
|
||||
autocomplete="on"
|
||||
value-prop="_id"
|
||||
label-prop="name"
|
||||
input-type="search"
|
||||
placeholder="Kecamatan"
|
||||
:conditions="[['address.*.city', 'not_empty']]"
|
||||
:columns="{
|
||||
sm: {
|
||||
container: 6,
|
||||
},
|
||||
}"
|
||||
/>
|
||||
<SelectElement
|
||||
name="village"
|
||||
allow-absent
|
||||
@select="onSelectVillage"
|
||||
@search-change="onChange"
|
||||
items="/api/address/villages?parent={address.*.district}"
|
||||
:format-data="formatData"
|
||||
:search="true"
|
||||
:native="false"
|
||||
:object="true"
|
||||
:delay="delay"
|
||||
:resolve-on-load="false"
|
||||
autocomplete="on"
|
||||
value-prop="_id"
|
||||
label-prop="name"
|
||||
input-type="search"
|
||||
placeholder="Desa / Kelurahan"
|
||||
:conditions="[['address.*.district', 'not_empty']]"
|
||||
:columns="{
|
||||
sm: {
|
||||
container: 6,
|
||||
},
|
||||
}"
|
||||
/>
|
||||
<SelectElement
|
||||
name="postalCode"
|
||||
allow-absent
|
||||
@search-change="onChange"
|
||||
items="/api/address/postal?parent={address.*.district}"
|
||||
:native="false"
|
||||
:object="false"
|
||||
:delay="delay"
|
||||
:resolve-on-load="false"
|
||||
autocomplete="off"
|
||||
placeholder="Kode Pos"
|
||||
:conditions="[['address.*.district', 'not_empty']]"
|
||||
:columns="{
|
||||
sm: {
|
||||
container: 6,
|
||||
},
|
||||
}"
|
||||
/>
|
||||
</ObjectElement>
|
||||
</template>
|
||||
</ListElement>
|
||||
</template>
|
||||
56
components/Form/Lib/Communication.vue
Normal file
56
components/Form/Lib/Communication.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<ListElement name="communication">
|
||||
<template #default="{ index }">
|
||||
<ObjectElement :name="index">
|
||||
<GroupElement name="container2_2">
|
||||
<GroupElement name="column1" :columns="{
|
||||
container: 1,
|
||||
}">
|
||||
<ToggleElement name="preferred" :labels="{
|
||||
on: 'Aktif',
|
||||
off: 'Pasif',
|
||||
}" :default="true" size="lg" align="right" />
|
||||
</GroupElement>
|
||||
<GroupElement name="column2" :columns="{
|
||||
container: 11,
|
||||
}">
|
||||
<ListElement name="language" :controls="{
|
||||
add: false,
|
||||
remove: false,
|
||||
}">
|
||||
<template #default="{ index }">
|
||||
<ObjectElement :name="index">
|
||||
<SelectElement name="text" :items="[
|
||||
{
|
||||
value: 'Indonesia',
|
||||
label: 'Indonesia',
|
||||
},
|
||||
{
|
||||
value: 'Inggris',
|
||||
label: 'Inggris',
|
||||
},
|
||||
{
|
||||
value: 'jawa',
|
||||
label: 'Jawa',
|
||||
},
|
||||
{
|
||||
value: 'Sunda',
|
||||
label: 'Sunda',
|
||||
},
|
||||
]" :search="true" :native="false" input-type="search" autocomplete="off" placeholder="Bahasa"
|
||||
:create="true" :append-new-option="false" />
|
||||
</ObjectElement>
|
||||
</template>
|
||||
</ListElement>
|
||||
</GroupElement>
|
||||
</GroupElement>
|
||||
</ObjectElement>
|
||||
</template>
|
||||
</ListElement>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
77
components/Form/Lib/HumanName.vue
Normal file
77
components/Form/Lib/HumanName.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<script setup>
|
||||
const parsedName = ref({})
|
||||
const onChange = (newValue, oldValue, el$) => {
|
||||
const i = parseInt(el$.$parent.$parent.path.split('.')[1])
|
||||
parsedName.value[i] = parseName(el$.$parent.$parent.children$.text.value)
|
||||
el$.$parent.$parent.children$.prefix.update(parsedName.value[i].prefix)
|
||||
el$.$parent.$parent.children$.given.update(parsedName.value[i].given)
|
||||
el$.$parent.$parent.children$.family.update(parsedName.value[i].family)
|
||||
el$.$parent.$parent.children$.suffix.update(parsedName.value[i].suffix)
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<ListElement
|
||||
name="name"
|
||||
:controls="{
|
||||
add: true,
|
||||
remove: false,
|
||||
}"
|
||||
:rules="['min:1']"
|
||||
:min="1"
|
||||
:max="1"
|
||||
:initial="1"
|
||||
>
|
||||
<template #default="{ index }">
|
||||
<ObjectElement
|
||||
:name="index"
|
||||
:columns="{
|
||||
sm: {
|
||||
container: 12,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<TextElement
|
||||
@change="onChange"
|
||||
name="text"
|
||||
placeholder="Nama Lengkap"
|
||||
/>
|
||||
<HiddenElement name="prefix" :meta="true" />
|
||||
<HiddenElement name="given" :meta="true" />
|
||||
<HiddenElement name="family" :meta="true" />
|
||||
<HiddenElement name="suffix" :meta="true" />
|
||||
<!-- <FormLibParsedName :name="parsedName" :path="'address.0'" /> -->
|
||||
<StaticElement name="parsed-name" size="m">
|
||||
<div v-if="parsedName[index]" class="d-flex flex-row">
|
||||
<v-chip
|
||||
v-for="(prefix, index) in parsedName[index].prefix"
|
||||
:key="`prefix-${index}`"
|
||||
size="x-small"
|
||||
class="mr-1 bg-indigo-lighten-3"
|
||||
>{{ prefix }}</v-chip
|
||||
>
|
||||
<v-chip
|
||||
v-for="(given, index) in parsedName[index].given"
|
||||
:key="`given-${index}`"
|
||||
size="x-small"
|
||||
class="mr-1 bg-blue-darken-3"
|
||||
>{{ given }}</v-chip
|
||||
>
|
||||
<v-chip
|
||||
v-show="parsedName[index].family"
|
||||
size="x-small"
|
||||
class="mr-1 bg-blue"
|
||||
>{{ parsedName[index].family }}</v-chip
|
||||
>
|
||||
<v-chip
|
||||
v-for="(suffix, index) in parsedName[index].suffix"
|
||||
:key="`suffix-${index}`"
|
||||
size="x-small"
|
||||
class="mr-1 bg-indigo-lighten-3"
|
||||
>{{ suffix }}</v-chip
|
||||
>
|
||||
</div>
|
||||
</StaticElement>
|
||||
</ObjectElement>
|
||||
</template>
|
||||
</ListElement>
|
||||
</template>
|
||||
30
components/Form/Lib/Identifier.vue
Normal file
30
components/Form/Lib/Identifier.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
import items from '~/assets/data/identifier/practitioner.json'
|
||||
|
||||
function onChange(oldValue, newValue, el$) {
|
||||
el$.$parent.$parent.children$.value.update(el$.value)
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<ListElement name="identifier" :rules="['min:1']">
|
||||
<template #default="{ index }">
|
||||
<ObjectElement :name="index">
|
||||
<GroupElement name="type">
|
||||
<SelectElement name="name" :search="true" :items="items" data-key="option" :native="false" default="ktp"
|
||||
:columns="{
|
||||
container: 3,
|
||||
}" :can-clear="false" :can-deselect="false" />
|
||||
|
||||
<TextElement v-for="item in items" :conditions="[['identifier.*.type.name', item.value]]" name="value_element"
|
||||
@change="onChange" :rules="['nullable', item.regex]" :messages="{
|
||||
regex: 'Format Nomor ID salah!',
|
||||
}" :placeholder="item.placeholder" :columns="{
|
||||
container: 9,
|
||||
}" :submit="false" />
|
||||
|
||||
<HiddenElement name="value" :meta="true" />
|
||||
</GroupElement>
|
||||
</ObjectElement>
|
||||
</template>
|
||||
</ListElement>
|
||||
</template>
|
||||
52
components/Form/Lib/ParsedName.vue
Normal file
52
components/Form/Lib/ParsedName.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup>
|
||||
// Define props with validation
|
||||
const props = defineProps({
|
||||
path: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: Object,
|
||||
required: true,
|
||||
validator: (value) => {
|
||||
// Basic validation to ensure data has a name property
|
||||
return value && value.name
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<StaticElement name="parsed-name" size="m">
|
||||
<div v-show="name" class="d-flex flex-row">
|
||||
<span> {{ index }}</span>
|
||||
<v-chip
|
||||
v-for="(prefix, index) in name.value[path].prefix"
|
||||
:key="`prefix-${index}`"
|
||||
size="x-small"
|
||||
class="mr-1 bg-indigo-lighten-3"
|
||||
>{{ prefix }}</v-chip
|
||||
>
|
||||
<v-chip
|
||||
v-for="(given, index) in name.value[path].given"
|
||||
:key="`given-${index}`"
|
||||
size="x-small"
|
||||
class="mr-1 bg-blue-darken-3"
|
||||
>{{ given }}</v-chip
|
||||
>
|
||||
<v-chip
|
||||
v-show="name.value[path].family"
|
||||
size="x-small"
|
||||
class="mr-1 bg-blue"
|
||||
>{{ name.family }}</v-chip
|
||||
>
|
||||
<v-chip
|
||||
v-for="(suffix, index) in name.value[path].suffix"
|
||||
:key="`suffix-${index}`"
|
||||
size="x-small"
|
||||
class="mr-1 bg-indigo-lighten-3"
|
||||
>{{ suffix }}</v-chip
|
||||
>
|
||||
</div>
|
||||
</StaticElement>
|
||||
</template>
|
||||
72
components/Form/Lib/telecom.vue
Normal file
72
components/Form/Lib/telecom.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<ListElement name="telecom">
|
||||
<template #default="{ index }">
|
||||
<ObjectElement :name="index">
|
||||
<SelectElement name="system" :items="[
|
||||
{
|
||||
value: 'phone',
|
||||
label: '📞',
|
||||
},
|
||||
{
|
||||
value: 'email',
|
||||
label: '📧',
|
||||
},
|
||||
{
|
||||
value: 'url',
|
||||
label: '🌐',
|
||||
},
|
||||
]" :search="true" :native="false" input-type="search" autocomplete="off" :can-deselect="false"
|
||||
:can-clear="false" default="phone" :columns="{
|
||||
default: {
|
||||
container: 2,
|
||||
},
|
||||
sm: {
|
||||
container: 1,
|
||||
},
|
||||
}" :caret="false" />
|
||||
<SelectElement name="use" :items="[
|
||||
{
|
||||
value: 'home',
|
||||
label: '🏠',
|
||||
},
|
||||
{
|
||||
value: 'mobile',
|
||||
label: '📱',
|
||||
},
|
||||
{
|
||||
value: 'work',
|
||||
label: '🏢',
|
||||
},
|
||||
]" :search="true" :native="false" input-type="search" autocomplete="off" :columns="{
|
||||
default: {
|
||||
container: 2,
|
||||
},
|
||||
sm: {
|
||||
container: 1,
|
||||
},
|
||||
}" :caret="false" :can-deselect="false" :can-clear="false" default="home" />
|
||||
<GroupElement name="value" :columns="{
|
||||
default: {
|
||||
container: 8,
|
||||
},
|
||||
sm: {
|
||||
container: 10,
|
||||
},
|
||||
}">
|
||||
<PhoneElement name="phone" :allow-incomplete="true" :unmask="true" default="+62"
|
||||
:conditions="[['telecom.*.system', 'in', ['phone']]]" />
|
||||
<TextElement name="email" input-type="email" :rules="['nullable', 'email']" placeholder="eg. example@mail.com"
|
||||
:conditions="[['telecom.*.system', 'in', ['email']]]" />
|
||||
<TextElement name="url" input-type="url" :rules="['nullable', 'url']" placeholder="eg. http(s)://domain.com"
|
||||
:floating="false" :conditions="[['telecom.*.system', 'in', ['url']]]" />
|
||||
</GroupElement>
|
||||
</ObjectElement>
|
||||
</template>
|
||||
</ListElement>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
80
components/Form/Patient.vue
Normal file
80
components/Form/Patient.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
definePageMeta({
|
||||
title: 'Vue Form',
|
||||
icon: 'mdi-checkbox-blank-off-outline',
|
||||
drawerIndex: 0,
|
||||
})
|
||||
|
||||
const data = ref({})
|
||||
const humanName = ref({})
|
||||
const onChange = () => {
|
||||
humanName.value = parseName(data.value.nama)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ma-4">
|
||||
<Vueform v-model="data" @change="onChange" sync>
|
||||
<TextElement
|
||||
name="nama"
|
||||
placeholder="Nama Lengkap"
|
||||
:rules="['required', 'min:3', 'max:100']"
|
||||
/>
|
||||
<StaticElement name="parsed-name" size="sm">
|
||||
<div class="d-flex flex-row">
|
||||
<v-chip
|
||||
v-for="prefix in humanName.prefix"
|
||||
size="x-small"
|
||||
class="mr-1 bg-indigo-lighten-3"
|
||||
>{{ prefix }}</v-chip
|
||||
><v-chip
|
||||
v-for="given in humanName.given"
|
||||
size="x-small"
|
||||
class="mr-1 bg-blue-darken-3"
|
||||
>{{ given }}</v-chip
|
||||
>
|
||||
<v-chip
|
||||
v-show="humanName.family"
|
||||
size="x-small"
|
||||
class="mr-1 bg-blue"
|
||||
>{{ humanName.family }}</v-chip
|
||||
>
|
||||
<v-chip
|
||||
v-for="suffix in humanName.suffix"
|
||||
size="x-small"
|
||||
class="mr-1 bg-indigo-lighten-3"
|
||||
>{{ suffix }}</v-chip
|
||||
>
|
||||
</div>
|
||||
</StaticElement>
|
||||
<RadiogroupElement
|
||||
name="gender"
|
||||
view="tabs"
|
||||
label="Jenis Kelamin"
|
||||
:items="[
|
||||
{
|
||||
value: 'unknown',
|
||||
label: '⭕',
|
||||
},
|
||||
{
|
||||
value: 'male',
|
||||
label: '♂️ Laki-laki',
|
||||
},
|
||||
{
|
||||
value: 'female',
|
||||
label: '♀️ Perempuan',
|
||||
},
|
||||
{
|
||||
value: 'other',
|
||||
label: '⚧️ Lainnya',
|
||||
},
|
||||
]"
|
||||
default="unknown"
|
||||
/>
|
||||
</Vueform>
|
||||
</div>
|
||||
<span>{{ data }}</span>
|
||||
<span>{{ humanName }}</span>
|
||||
</template>
|
||||
222
components/Form/Patient/Create.vue
Normal file
222
components/Form/Patient/Create.vue
Normal file
@@ -0,0 +1,222 @@
|
||||
<script>
|
||||
import { FormLibAddress, FormLibHumanName, FormLibTelecom } from '#components'
|
||||
import Identifier from '../Lib/Identifier.vue'
|
||||
|
||||
const data = ref('')
|
||||
const handleResponse = (response, form$) => {
|
||||
console.log(response) // axios response
|
||||
console.log(response.status) // HTTP status code
|
||||
console.log(response.data) // response data
|
||||
|
||||
console.log(form$) // <Vueform> instance
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Vueform v-model="data" validate-on="change|step" endpoint="/api/patient/create" method="post"
|
||||
@response="handleResponse">
|
||||
<StaticElement name="identifierTitle" tag="h2" content="Nomor Identitas" />
|
||||
<FormLibIdentifier />
|
||||
<StaticElement name="divider" tag="hr" />
|
||||
<StaticElement name="personalInfoTitle" tag="h2" content="Personal Info" />
|
||||
<FormLibHumanName />
|
||||
<!-- <ListElement name="nestedList" :controls="{
|
||||
add: false,
|
||||
remove: false,
|
||||
}">
|
||||
<template #default="{ index }">
|
||||
<ObjectElement :name="index">
|
||||
<SelectElement name="humanNameUse" :items="[
|
||||
{
|
||||
value: 'official',
|
||||
label: '👨💼 Resmi',
|
||||
},
|
||||
{
|
||||
value: 'usual',
|
||||
label: '👨🦱 Biasa',
|
||||
},
|
||||
{
|
||||
value: 'temp',
|
||||
label: '🦲 Sementara',
|
||||
},
|
||||
// {
|
||||
// value: 'nickname',
|
||||
// label: '🙋♂️ Panggilan',
|
||||
// },
|
||||
// {
|
||||
// value: 'anonymous',
|
||||
// label: '👤 Anonim',
|
||||
// },
|
||||
// {
|
||||
// value: 'old',
|
||||
// label: '💇♂️ Nama Lama',
|
||||
// },
|
||||
// {
|
||||
// value: 'maiden',
|
||||
// label: '👧 Nama Gadis',
|
||||
// },
|
||||
]" :columns="{
|
||||
default: {
|
||||
container: 5,
|
||||
},
|
||||
sm: {
|
||||
container: 3,
|
||||
},
|
||||
lg: {
|
||||
container: 2,
|
||||
},
|
||||
}" :rules="['required']" :native="false" :can-deselect="false" :can-clear="false" :close-on-select="false"
|
||||
:caret="false" default="official" />
|
||||
<TextElement name="text" :columns="{
|
||||
default: {
|
||||
container: 7,
|
||||
},
|
||||
sm: {
|
||||
container: 9,
|
||||
},
|
||||
lg: {
|
||||
container: 10,
|
||||
},
|
||||
}" placeholder="Nama Lengkap" />
|
||||
</ObjectElement>
|
||||
</template>
|
||||
</ListElement> -->
|
||||
<GroupElement name="container2">
|
||||
<GroupElement name="column1" :columns="{
|
||||
container: 6,
|
||||
}">
|
||||
<RadiogroupElement name="gender" view="tabs" label="Jenis Kelamin" :items="[
|
||||
{
|
||||
value: 'male',
|
||||
label: 'Laki-laki',
|
||||
},
|
||||
{
|
||||
value: 'female',
|
||||
label: 'Perempuan',
|
||||
},
|
||||
// {
|
||||
// value: 'other',
|
||||
// label: 'Lainnya',
|
||||
// },
|
||||
// {
|
||||
// value: 'unknown',
|
||||
// label: ' ',
|
||||
// },
|
||||
]" default="unknown" />
|
||||
</GroupElement>
|
||||
<GroupElement name="column_2" :columns="{
|
||||
container: 6,
|
||||
}">
|
||||
<ListElement name="maritalStatus" :controls="{
|
||||
add: false,
|
||||
remove: false,
|
||||
}">
|
||||
<template #default="{ index }">
|
||||
<ObjectElement :name="index">
|
||||
<ListElement name="coding" :controls="{
|
||||
add: false,
|
||||
remove: false,
|
||||
}">
|
||||
<template #default="{ index }">
|
||||
<ObjectElement :name="index">
|
||||
<SelectElement name="select" :items="[
|
||||
{
|
||||
value: 'UNK',
|
||||
label: 'Tidak Tahu',
|
||||
},
|
||||
{
|
||||
value: 'U',
|
||||
label: 'Belum Menikah',
|
||||
},
|
||||
{
|
||||
value: 'M',
|
||||
label: 'Menikah',
|
||||
},
|
||||
{
|
||||
value: 'D',
|
||||
label: 'Cerai Hidup',
|
||||
},
|
||||
{
|
||||
value: 'W',
|
||||
label: 'Cerai Mati',
|
||||
},
|
||||
]" :search="true" :native="false" label="Status Perkawinan" input-type="search" autocomplete="off"
|
||||
:can-deselect="false" :can-clear="false" default="UNK" />
|
||||
<HiddenElement name="system" default="http://terminology.hl7.org/CodeSystem/v3-MaritalStatus" />
|
||||
</ObjectElement>
|
||||
</template>
|
||||
</ListElement>
|
||||
</ObjectElement>
|
||||
</template>
|
||||
</ListElement>
|
||||
</GroupElement>
|
||||
</GroupElement>
|
||||
<SelectElement name="birthPlace" :search="true" :native="false" input-type="search" autocomplete="on" :items="[
|
||||
{
|
||||
value: 'malang',
|
||||
label: 'Malang',
|
||||
},
|
||||
{
|
||||
value: 'surabaya',
|
||||
label: 'Surabaya',
|
||||
},
|
||||
]" placeholder="Tempat Lahir" :columns="{
|
||||
sm: {
|
||||
container: 6,
|
||||
},
|
||||
lg: {
|
||||
container: 6,
|
||||
},
|
||||
}" />
|
||||
<DateElement name="birthDate" placeholder="Tanggal Lahir" :columns="{
|
||||
sm: {
|
||||
container: 6,
|
||||
},
|
||||
lg: {
|
||||
container: 6,
|
||||
},
|
||||
}" />
|
||||
<StaticElement name="divider_1" tag="hr" />
|
||||
<StaticElement name="addressTitle" tag="h2" content="Alamat" />
|
||||
<FormLibAddress />
|
||||
|
||||
<StaticElement name="divider_2" tag="hr" />
|
||||
<StaticElement name="contactTitle" tag="h4" content="Kontak" />
|
||||
<FormLibTelecom />
|
||||
<StaticElement name="divider_3" tag="hr" />
|
||||
<StaticElement name="communicationTitle" tag="h2" content="Komunikasi" />
|
||||
<FormLibCommunication />
|
||||
<GroupElement name="container" :columns="{
|
||||
default: {
|
||||
container: 7,
|
||||
},
|
||||
sm: {
|
||||
container: 8,
|
||||
},
|
||||
lg: {
|
||||
container: 9,
|
||||
},
|
||||
}" />
|
||||
<GroupElement name="container2_3" :columns="{
|
||||
default: {
|
||||
container: 5,
|
||||
},
|
||||
sm: {
|
||||
container: 4,
|
||||
},
|
||||
lg: {
|
||||
container: 3,
|
||||
},
|
||||
}">
|
||||
<GroupElement name="column1" :columns="{
|
||||
container: 6,
|
||||
}">
|
||||
<ButtonElement name="secondaryButton" button-label="Batal" :secondary="true" align="center" size="lg" />
|
||||
</GroupElement>
|
||||
<GroupElement name="column2" :columns="{
|
||||
container: 6,
|
||||
}">
|
||||
<ButtonElement name="submit" button-label="Simpan" :submits="true" align="center" size="lg" />
|
||||
</GroupElement>
|
||||
</GroupElement>
|
||||
</Vueform>
|
||||
</template>
|
||||
72
components/Form/PatientV2.vue
Normal file
72
components/Form/PatientV2.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
definePageMeta({
|
||||
title: 'Vue Form',
|
||||
icon: 'mdi-checkbox-blank-off-outline',
|
||||
drawerIndex: 0,
|
||||
})
|
||||
|
||||
const data = ref({})
|
||||
const humanName = ref({})
|
||||
const onChange = () => {
|
||||
humanName.value = parseName(data.value.nama)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ma-4">
|
||||
<Vueform v-model="data" @change="onChange" sync>
|
||||
<TextElement
|
||||
name="nama"
|
||||
placeholder="Nama Lengkap"
|
||||
:rules="['required', 'min:3', 'max:100']"
|
||||
/>
|
||||
<StaticElement name="parsed-name" size="sm">
|
||||
<div class="d-flex flex-row">
|
||||
<v-chip
|
||||
v-for="prefix in humanName.prefix"
|
||||
size="x-small"
|
||||
class="mr-1 bg-indigo-lighten-3"
|
||||
>{{ prefix }}</v-chip
|
||||
><v-chip
|
||||
v-for="given in humanName.given"
|
||||
size="x-small"
|
||||
class="mr-1 bg-blue-darken-3"
|
||||
>{{ given }}</v-chip
|
||||
>
|
||||
<v-chip
|
||||
v-show="humanName.family"
|
||||
size="x-small"
|
||||
class="mr-1 bg-blue"
|
||||
>{{ humanName.family }}</v-chip
|
||||
>
|
||||
<v-chip
|
||||
v-for="suffix in humanName.suffix"
|
||||
size="x-small"
|
||||
class="mr-1 bg-indigo-lighten-3"
|
||||
>{{ suffix }}</v-chip
|
||||
>
|
||||
</div>
|
||||
</StaticElement>
|
||||
<RadiogroupElement
|
||||
name="gender"
|
||||
view="tabs"
|
||||
label="Jenis Kelamin"
|
||||
:items="[
|
||||
{
|
||||
value: 'male',
|
||||
label: '♂️ Laki-laki',
|
||||
},
|
||||
{
|
||||
value: 'female',
|
||||
label: '♀️ Perempuan',
|
||||
},
|
||||
]"
|
||||
default="unknown"
|
||||
/>
|
||||
</Vueform>
|
||||
</div>
|
||||
<span>{{ data }}</span>
|
||||
<span>{{ humanName }}</span>
|
||||
</template>
|
||||
146
components/Form/Practitioner/Basic.vue
Normal file
146
components/Form/Practitioner/Basic.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const data = ref({ name: '' })
|
||||
|
||||
const handleResponse = (response, form$) => {
|
||||
console.log(response) // axios response
|
||||
console.log(response.status) // HTTP status code
|
||||
console.log(response.data) // response data
|
||||
|
||||
console.log(form$) // <Vueform> instance
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Vueform v-model="data" validate-on="change|step" endpoint="/api/practitioner/basic" method="post"
|
||||
@response="handleResponse">
|
||||
<StaticElement name="identifierTitle" tag="h3" content="Nomor Identitas" />
|
||||
<FormLibIdentifier />
|
||||
<StaticElement name="divider" tag="hr" />
|
||||
<StaticElement name="nameTitle" tag="h4" content="Personal Info" />
|
||||
<FormLibHumanName />
|
||||
<RadiogroupElement name="gender" view="tabs" :items="[
|
||||
{
|
||||
value: 'unknown',
|
||||
label: '⭕',
|
||||
},
|
||||
{
|
||||
value: 'male',
|
||||
label: '♂️',
|
||||
},
|
||||
{
|
||||
value: 'female',
|
||||
label: '♀️',
|
||||
},
|
||||
{
|
||||
value: 'other',
|
||||
label: '⚧️',
|
||||
},
|
||||
]" default="unknown" />
|
||||
<SelectElement name="birthPlace" :search="true" :native="false" input-type="search" autocomplete="on"
|
||||
placeholder="Tempat Lahir" :columns="{
|
||||
sm: {
|
||||
container: 6,
|
||||
},
|
||||
}" items="/jsondata/birth-place.json" value-prop="name" label-prop="name" search-param="name" />
|
||||
<DateElement name="birthDate" placeholder="Tanggal Lahir" :columns="{
|
||||
sm: {
|
||||
container: 6,
|
||||
},
|
||||
}" />
|
||||
<FileElement name="photo" accept="image/*" view="image" :rules="[
|
||||
'mimetypes:image/jpeg,image/png,image/gif,image/webp,image/svg+xml,image/tiff',
|
||||
]" :urls="{}" :drop="true" label="Pas Foto" />
|
||||
<StaticElement name="divider_1" tag="hr" />
|
||||
<StaticElement name="addressTitle" tag="h4" content="Alamat" />
|
||||
<FormLibAddress />
|
||||
<StaticElement name="divider_2" tag="hr" />
|
||||
<StaticElement name="contactTitle" tag="h4" content="Kontak" />
|
||||
<ListElement name="telecom">
|
||||
<template #default="{ index }">
|
||||
<ObjectElement :name="index">
|
||||
<SelectElement name="system" :items="[
|
||||
{
|
||||
value: 'phone',
|
||||
label: '📞',
|
||||
},
|
||||
{
|
||||
value: 'email',
|
||||
label: '📧',
|
||||
},
|
||||
{
|
||||
value: 'url',
|
||||
label: '🌐',
|
||||
},
|
||||
]" :search="true" :native="false" input-type="search" autocomplete="off" :can-deselect="false"
|
||||
:can-clear="false" default="phone" :columns="{
|
||||
default: {
|
||||
container: 2,
|
||||
},
|
||||
sm: {
|
||||
container: 1,
|
||||
},
|
||||
}" :caret="false" />
|
||||
<SelectElement name="use" :items="[
|
||||
{
|
||||
value: 'home',
|
||||
label: '🏠',
|
||||
},
|
||||
{
|
||||
value: 'mobile',
|
||||
label: '📱',
|
||||
},
|
||||
{
|
||||
value: 'work',
|
||||
label: '🏢',
|
||||
},
|
||||
]" :search="true" :native="false" input-type="search" autocomplete="off" :columns="{
|
||||
default: {
|
||||
container: 2,
|
||||
},
|
||||
sm: {
|
||||
container: 1,
|
||||
},
|
||||
}" :caret="false" :can-deselect="false" :can-clear="false" default="home" />
|
||||
<GroupElement name="value" :columns="{
|
||||
default: {
|
||||
container: 8,
|
||||
},
|
||||
sm: {
|
||||
container: 10,
|
||||
},
|
||||
}">
|
||||
<PhoneElement name="phone" :allow-incomplete="true" :unmask="true" default="+62"
|
||||
:conditions="[['telecom.*.system', 'in', ['phone']]]" />
|
||||
<TextElement name="email" input-type="email" :rules="['nullable', 'email']"
|
||||
placeholder="eg. example@mail.com" :conditions="[['telecom.*.system', 'in', ['email']]]" />
|
||||
<TextElement name="url" input-type="url" :rules="['nullable', 'url']" placeholder="eg. http(s)://domain.com"
|
||||
:floating="false" :conditions="[['telecom.*.system', 'in', ['url']]]" />
|
||||
</GroupElement>
|
||||
</ObjectElement>
|
||||
</template>
|
||||
</ListElement>
|
||||
<StaticElement name="divider_3" tag="hr" />
|
||||
<StaticElement name="communicationTitle" tag="h4" content="Komunikasi" />
|
||||
<ListElement name="communication">
|
||||
<template #default="{ index }">
|
||||
<ObjectElement :name="index">
|
||||
<ObjectElement name="language">
|
||||
<SelectElement name="text" :items="[
|
||||
{
|
||||
value: 'INDONESIA',
|
||||
label: 'INDONESIA',
|
||||
},
|
||||
]" :search="true" :native="false" input-type="search" autocomplete="off" placeholder="Bahasa" />
|
||||
</ObjectElement>
|
||||
<HiddenElement name="preferred" :default="true" :meta="true" />
|
||||
</ObjectElement>
|
||||
</template>
|
||||
</ListElement>
|
||||
<ToggleElement name="active" text="Catatan praktisi ini digunakan secara aktif" :default="true" />
|
||||
|
||||
<ButtonElement name="submit" button-label="Submit" :submits="true" align="right" />
|
||||
</Vueform>
|
||||
<p>{{ data }}</p>
|
||||
<!-- <p>{{ data.value.name }}</p> -->
|
||||
</template>
|
||||
5
components/Form/Practitioner/Test.vue
Normal file
5
components/Form/Practitioner/Test.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<ObjectElement name="test">
|
||||
<TextElement name="text" placeholder="Test Element" />
|
||||
</ObjectElement>
|
||||
</template>
|
||||
30
components/IndexPage.vue
Normal file
30
components/IndexPage.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
|
||||
const items = computed(() =>
|
||||
route.matched
|
||||
.filter((v) => v.path === route.path)[0]
|
||||
.children.filter((c) => c.path)
|
||||
.toSorted(
|
||||
(a, b) => (a.meta?.drawerIndex ?? 99) - (b.meta?.drawerIndex ?? 98),
|
||||
)
|
||||
.map((c) => ({
|
||||
title: c.meta?.title,
|
||||
to: c.name ? c : `${route.path}/${c.path}`,
|
||||
prependIcon: c.meta?.icon,
|
||||
subtitle: c.meta?.subtitle,
|
||||
})),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-container>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-card v-for="item in items" :key="item.title" class="mb-1">
|
||||
<v-list-item v-bind="item" append-icon="mdi-chevron-right" :ripple="false" class="py-4" />
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</template>
|
||||
82
components/StatsCard.vue
Normal file
82
components/StatsCard.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
icon: string
|
||||
iconClass?: string
|
||||
color: string
|
||||
title: string
|
||||
value: number | null
|
||||
unit?: string
|
||||
formatter?: (v: number) => string
|
||||
}>(),
|
||||
{
|
||||
iconClass: '',
|
||||
value: null,
|
||||
unit: '',
|
||||
formatter: (v: number) => v.toString(),
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card class="stats-card v-alert--border-top">
|
||||
<v-icon
|
||||
size="x-large"
|
||||
class="stats-icon"
|
||||
:color="color"
|
||||
:class="iconClass"
|
||||
:icon="icon"
|
||||
/>
|
||||
<div class="card-title ml-auto text-right">
|
||||
<span
|
||||
class="card-title--name font-weight-bold"
|
||||
:class="`text-${color}`"
|
||||
v-text="title"
|
||||
/>
|
||||
<h3
|
||||
class="font-weight-regular d-inline-block ml-2"
|
||||
style="font-size: 18px"
|
||||
>
|
||||
{{ value != null ? formatter(value) : '' }}
|
||||
<small v-if="unit">{{ unit }}</small>
|
||||
</h3>
|
||||
<v-divider />
|
||||
</div>
|
||||
<div class="v-alert__border" :class="`text-${color}`" />
|
||||
<div
|
||||
v-if="$slots.footer"
|
||||
class="text-grey text-right stats-footer text-caption"
|
||||
>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stats-card {
|
||||
padding: 5px;
|
||||
padding-top: 10px;
|
||||
.card-title {
|
||||
width: fit-content;
|
||||
.card-title--name {
|
||||
display: inline-block;
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
}
|
||||
.caption {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.stats-icon {
|
||||
position: absolute;
|
||||
opacity: 0.3;
|
||||
}
|
||||
.stats-footer {
|
||||
:deep(span) {
|
||||
display: inline-block;
|
||||
font-size: 12px !important;
|
||||
letter-spacing: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user