first commit

This commit is contained in:
2025-04-22 10:56:56 +07:00
commit af123c091b
147 changed files with 778063 additions and 0 deletions

82
components/App/AppBar.vue Normal file
View 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>

View 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">
&copy; 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,5 @@
<template>
<ObjectElement name="test">
<TextElement name="text" placeholder="Test Element" />
</ObjectElement>
</template>

30
components/IndexPage.vue Normal file
View 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
View 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>