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

10
.editorconfig Normal file
View File

@@ -0,0 +1,10 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
max_line_length = 80

10
.env.example Normal file
View File

@@ -0,0 +1,10 @@
NUXT_OAUTH_GITHUB_CLIENT_ID=""
NUXT_OAUTH_GITHUB_CLIENT_SECRET=""
NUXT_SESSION_PASSWORD=""
KEYCLOAK_ID="coba-pendaftaran"
KEYCLOAK_SECRET="32HslhZ8Hn97SsbxcmowhXvmNZ9cPGNE"
KEYCLOAK_ISSUER="https://auth.rssa.top/realms/sandbox"
#MONGODB_URL="mongodb://admin:stim*rs54@10.10.123.206:27017/?retryWrites=true&loadBalanced=false&serverSelectionTimeoutMS=5000&connectTimeoutMS=10000&authSource=admin&authMechanism=SCRAM-SHA-1"
MONGODB_URL="mongodb://localhost:27017/simrs_fhir"

35
.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
# Node modules
node_modules/
.nuxt/
dist/
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Environment variables
.env
.env.local
.env.*.local
# Build output
*.tgz
*.zip
# IDE and editor specific files
.vscode/
.idea/
*.sublime-workspace
*.sublime-project
# OS generated files
.DS_Store
Thumbs.db
# Coverage directory used by tools like istanbul
coverage/
# Temporary files
*.tmp
*.temp

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
shamefully-hoist=true

1
.prettierignore Normal file
View File

@@ -0,0 +1 @@
pnpm-lock.yaml

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023-PRESENT Yue JIN <https://github.com/kingyue737>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

146
README.md Normal file
View File

@@ -0,0 +1,146 @@
<p align="center">
<img alt="Vitify - Opinionated Vuetify Admin Starter Template" src="public/vitify-nuxt.svg" width=100px/>
</p>
<h1 align="center">Vitify Nuxt</h1>
<p align="center">
<a href="https://github.com/vuejs/vue">
<img src="https://img.shields.io/badge/nuxt-3-brightgreen.svg" alt="vue">
</a>
<a href="https://github.com/vuetifyjs/vuetify">
<img src="https://img.shields.io/badge/vuetify-3-blue.svg" alt="vuetify">
</a>
<a href="https://github.com/kingyue737/vitify-admin/blob/main/LICENSE">
<img src="https://img.shields.io/github/license/mashape/apistatus.svg" alt="license">
</a>
</p>
<p align='center'>
<b>Vuetify</b> + <b>Nuxt</b>, Opinionated Admin Starter Template<br><br>
</p>
<p align='center'>
<a href="https://vitify-nuxt.netlify.app/">Live Demo<br><br></a>
</p>
## Features
- 💚 [Nuxt](https://nuxt.com/) - SPA, ESR, File-based routing, components auto importing, modules, etc
- 💥 SSR out of the box - powered by [Vuetify Nuxt module](https://github.com/vuetifyjs/nuxt-module)
- ⚡️ [Vite](https://github.com/vitejs/vite), [pnpm](https://pnpm.io/), [ESBuild](https://github.com/evanw/esbuild) - born with fastness
- 🍍 [State Management via Pinia](https://pinia.vuejs.org/)
- 📥 APIs auto importing - for Composition API, VueUse and custom composables
- ☁️ Deploy on [Netlify](https://www.netlify.com/), zero-config
- 🦾 TypeScript 100%
- 🧪 Unit, Component and E2E Testing with [@nuxt/test-utils](https://github.com/nuxt/test-utils)
<br>
### Admin Starter Template
- 🪟 Default layout with drawer, header and footer
- 🧭 Auto-generated navigation drawer and breadcrumbs based on routes
- 🔔 Notification store
- 📉 Data visualization with [nuxt-echarts](https://github.com/kingyue737/nuxt-echarts)
- 🎨 Theme color customization and dark mode
- 📱 Responsive layout
- 🛡️ Authentication backed-in using [nuxt-auth-utils](https://github.com/Atinux/nuxt-auth-utils)
## Variants
- [vitify-next](https://github.com/kingyue737/vitify-next) - Lightweight Vue 3 version without Nuxt
- [vitify-electron](https://github.com/kingyue737/vitify-electron) - Vuetify + Nuxt + Electron starter
- [vitify-admin](https://github.com/kingyue737/vitify-admin) - Vue 2.7 with i18n, browser compatibility and mock server
## Pre-packed
### Nuxt Modules
- [Vuetify Nuxt Module](https://github.com/vuetifyjs/nuxt-module) - Zero-config Nuxt Module for Vuetify
- [VueUse](https://github.com/vueuse/vueuse) - Collection of useful composition APIs
- [Pinia](https://github.com/vuejs/pinia) - Intuitive, type-safe, light and flexible Store for Vue
- [Nuxt Icon](https://github.com/nuxt/icon) - Icon module for Nuxt with 200,000+ ready to use icons from Iconify
- [Nuxt ECharts](https://github.com/kingyue737/nuxt-echarts) - Nuxt module for Apache ECharts™
- [Nuxt Auth Utils](https://github.com/Atinux/nuxt-auth-utils) - Minimalist Authentication module for Nuxt
### Coding Style
- [Prettier](https://prettier.io/), single quotes, no semi
- [ESLint flat config](https://eslint.org/docs/latest/use/configure/configuration-files-new) with adapted [@nuxt/eslint](https://github.com/nuxt/eslint), future-proof
### Dev tools
- [TypeScript](https://www.typescriptlang.org/)
- [pnpm](https://pnpm.js.org/) - Fast, disk space efficient package manager
- [Netlify](https://www.netlify.com/) - zero-config deployment
- [VS Code Extensions](./.vscode/extensions.json)
- [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) - TypeScript support inside Vue SFCs
- [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) - Find and fix problems in your code
- [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) - Code formatter
- [EditorConfig for VS Code](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig)
## Try it now!
### GitHub Template
[Create a repo from this template on GitHub](https://github.com/kingyue737/vitify-nuxt/generate).
### Clone to local
If you prefer to do it manually with the cleaner git history
```bash
npx degit kingyue737/vitify-nuxt my-vitify-app
cd my-vitify-app
pnpm i
```
### Authentication Setup
> You can switch to any [OAuth Providers](https://github.com/Atinux/nuxt-auth-utils#supported-oauth-providers) supported by [Nuxt Auth Utils](https://github.com/Atinux/nuxt-auth-utils) or write your own.
Create a [GitHub OAuth Application](https://github.com/settings/applications/new) with:
- Homepage url: `http://localhost:3000`
- Callback url: `http://localhost:3000/api/auth/github`
Add the variables in the `.env` file:
```bash
NUXT_OAUTH_GITHUB_CLIENT_ID="my-github-oauth-app-id"
NUXT_OAUTH_GITHUB_CLIENT_SECRET="my-github-oauth-app-secret"
```
To create sealed sessions, you also need to add `NUXT_SESSION_SECRET` in the `.env` with at least 32 characters:
```bash
NUXT_SESSION_SECRET=your-super-long-secret-for-session-encryption
```
Nuxt Auth Utils generates one for you when running Nuxt in development the first time if no `NUXT_SESSION_PASSWORD` is set.
### Development
Start the development server on http://localhost:3000
```bash
pnpm run dev
```
## License
[MIT License](./LICENSE)

45
app.vue Normal file
View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
const theme = useTheme()
provide(
THEME_KEY,
computed(() => (theme.current.value.dark ? 'dark' : undefined)),
)
const route = useRoute()
const title = computed(() => {
return route.meta?.title || route.matched[0].meta?.title || ''
})
useHead({
title,
titleTemplate: (t) => (t ? `${t} | Vitify Admin` : 'Vitify Admin'),
htmlAttrs: { lang: 'en' },
link: [{ rel: 'icon', href: '/favicon.ico' }],
})
useSeoMeta({
viewport: 'width=device-width, initial-scale=1, maximum-scale=1',
description: 'Vuetify 3 + Nuxt 3, Opinionated Admin Starter Template',
ogImage: '/social-image.png',
twitterImage: '/social-image.png',
twitterCard: 'summary_large_image',
})
</script>
<template>
<v-app>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</v-app>
</template>
<style scoped>
/* replace padding with margin to limit scrollbar in v-main */
.v-main {
padding-top: 0;
padding-bottom: 0;
margin-top: var(--v-layout-top);
margin-bottom: var(--v-layout-bottom);
height: calc(100vh - var(--v-layout-top) - var(--v-layout-bottom));
overflow-y: auto;
transition-property: padding;
}
</style>

View File

@@ -0,0 +1,47 @@
<svg
class="nuxt-spa-loading"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 37 37"
fill="none"
width="80"
>
<g transform="rotate(180 18.778 13.7)">
<path
d="M24.236 22.006h10.742L25.563 5.822l-8.979 14.31a4 4 0 0 1-3.388 1.874H2.978l16-27.713 6 10.392"
/>
</g>
</svg>
<style>
.nuxt-spa-loading {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.nuxt-spa-loading > g > path {
fill: none;
stroke: #248fe4;
stroke-width: 4px;
stroke-linecap: round;
stroke-linejoin: round;
/* Stroke-dasharray property */
stroke-dasharray: 128;
stroke-dashoffset: 128;
animation: nuxt-spa-loading-move 3s linear infinite;
animation-fill-mode: forwards;
}
@media (prefers-color-scheme: dark) {
html {
background: #121212;
}
}
@keyframes nuxt-spa-loading-move {
100% {
stroke-dashoffset: -128;
}
}
</style>

After

Width:  |  Height:  |  Size: 945 B

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
[
{
"value": "ktp",
"label": "KTP",
"placeholder": "Masukkan 14 Nomor NIK",
"regex": "regex:^\\d{14}$"
},
{
"value": "nip",
"label": "NIP",
"placeholder": "Masukkan Nomor NIP",
"regex": "regex:^\\d{10}$"
},
{
"value": "niptt",
"label": "NIPTT",
"placeholder": "Masukkan NIPTT",
"regex": "regex:^\\d{13}$"
},
{
"value": "sip",
"label": "SIP",
"placeholder": "Masukkan Nomor Surat Ijin Praktek",
"regex": "regex:^\\d{2}$"
},
{
"value": "jkn",
"label": "JKN",
"placeholder": "Masukkan Nomor Surat Ijin Praktek",
"regex": "regex:^\\d{10}$"
},
{
"value": "no_rm",
"label": "NO RM",
"placeholder": "Masukkan Nomor Surat Ijin Praktek",
"regex": "regex:^\\d{2}$"
}
]

23
assets/icons/nustar.svg Normal file
View File

@@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 56 56">
<defs>
<path id="inner-pie" d="M10,26.4A19.5,19.5,0,0,0,26.4,10H10V26.4Z" />
<path id="outer-ring" d="M2,26.7A26.7,26.7,0,0,1,26.7,2v-2A28,28,0,0,0,0,26.7h2Z" />
<radialGradient id="radial-gradient" cx="28" cy="28" r="26.2" gradientUnits="userSpaceOnUse">
<stop offset="0.269" stop-color="currentColor" stop-opacity="0" />
<stop offset="0.567" stop-color="currentColor" stop-opacity="0.8" />
<stop offset="0.857" stop-color="currentColor" />
</radialGradient>
</defs>
<g fill="url(#radial-gradient)">
<use xlink:href="#inner-pie" />
<use xlink:href="#inner-pie" transform="rotate(90, 28, 28)" />
<use xlink:href="#inner-pie" transform="rotate(180, 28, 28)" />
<use xlink:href="#inner-pie" transform="rotate(270, 28, 28)" />
</g>
<g filter="brightness(0.8)">
<use xlink:href="#outer-ring" />
<use xlink:href="#outer-ring" transform="rotate(90, 28, 28)" />
<use xlink:href="#outer-ring" transform="rotate(180, 28, 28)" />
<use xlink:href="#outer-ring" transform="rotate(270, 28, 28)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 37 37" fill="none">
<g transform="rotate(180 18.778 13.7)">
<path stroke="currentColor" fill="none" stroke-width="4px" stroke-linecap="round" stroke-linejoin="round"
d="M24.236 22.006h10.742L25.563 5.822l-8.979 14.31a4 4 0 0 1-3.388 1.874H2.978l16-27.713 6 10.392" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 346 B

64
assets/styles/global.css Normal file
View File

@@ -0,0 +1,64 @@
html {
overflow-y: auto;
&.dark {
color-scheme: dark;
}
}
.v-card--variant-elevated:not(.v-card--flat) {
box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.14);
}
.v-theme--light {
&.v-icon--clickable,
&.v-btn--icon {
color: rgba(0, 0, 0, 0.54);
}
--v-border-opacity: 0.09 !important;
--v-theme-background: 243, 243, 243 !important;
}
.v-app-bar,
.v-navigation-drawer,
.v-footer {
background-color: rgb(var(--v-theme-background)) !important;
}
.v-overlay__content > .v-card {
overflow-y: overlay !important;
}
.v-btn {
text-transform: initial !important;
}
.v-data-table-footer {
.v-field__input {
min-height: 36px;
padding-top: 0;
padding-bottom: 0;
}
}
.v-tab {
border-radius: 0 !important;
}
.v-list-item-title {
text-overflow: unset !important;
}
.v-breadcrumbs-divider {
opacity: 0.5;
}
.v-field--focused {
.v-field__prepend-inner .v-icon {
color: rgb(var(--v-theme-primary));
}
}
.v-icon .iconify {
width: 100%;
height: 100%;
}

2
assets/styles/index.css Normal file
View File

@@ -0,0 +1,2 @@
@import 'global';
@import 'scrollbar';

View File

@@ -0,0 +1,21 @@
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background-color: rgba(149, 149, 149, 0.4);
background-clip: content-box;
min-height: 28px;
border: 2px solid transparent;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(149, 149, 149, 0.4);
border: 1px solid transparent;
}
::-webkit-scrollbar-corner {
background: transparent;
}

30
augmentation.d.ts vendored Normal file
View File

@@ -0,0 +1,30 @@
// See https://github.com/nuxt/nuxt/releases/tag/v3.13.0
import type {
ComponentCustomOptions as _ComponentCustomOptions,
ComponentCustomProperties as _ComponentCustomProperties,
} from 'vue'
declare module '@vue/runtime-core' {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface ComponentCustomProperties extends _ComponentCustomProperties {}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface ComponentCustomOptions extends _ComponentCustomOptions {}
}
declare module '#app' {
interface PageMeta {
icon?: string
title?: string
subtitle?: string
drawerIndex?: number
}
}
declare module '#auth-utils' {
interface User {
login: string
avatar_url: string
}
}
export {}

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>

9
eslint.config.js Normal file
View File

@@ -0,0 +1,9 @@
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt({
rules: {
'vue/valid-v-slot': ['error', { allowModifiers: true }], // allow vuetify slot modifier
'vue/html-self-closing': ['error', { html: { void: 'any' } }], // not conflict with prettier
'@typescript-eslint/no-explicit-any': 'off',
},
})

5
layouts/auth.vue Normal file
View File

@@ -0,0 +1,5 @@
<template>
<!-- <AuthLayout> -->
<slot />
<!-- </AuthLayout> -->
</template>

8
layouts/default.vue Normal file
View File

@@ -0,0 +1,8 @@
<template>
<AppDrawer />
<AppBar />
<v-main>
<slot />
</v-main>
<AppFooter />
</template>

15
lib/prisma.ts Normal file
View File

@@ -0,0 +1,15 @@
import { PrismaClient } from '@prisma/client'
const prismaClientSingleton = () => {
return new PrismaClient()
}
declare const globalThis: {
prismaGlobal: ReturnType<typeof prismaClientSingleton>;
} & typeof global;
const prisma = globalThis.prismaGlobal ?? prismaClientSingleton()
export default prisma
if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma

94
nuxt.config.ts Normal file
View File

@@ -0,0 +1,94 @@
import { aliases } from 'vuetify/iconsets/mdi'
import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
devtools: { enabled: true },
modules: [
'@pinia/nuxt',
'@vueuse/nuxt',
'vuetify-nuxt-module',
// 'nuxt-auth-utils',
'@sidebase/nuxt-auth',
'nuxt-echarts',
'@nuxt/icon',
'@nuxt/eslint',
'@nuxt/test-utils/module',
'@vueform/nuxt',
'@prisma/nuxt',
],
css: ['~/assets/styles/index.css'],
experimental: { typedPages: true },
typescript: { shim: false, strict: true },
vue: { propsDestructure: true },
vueuse: { ssrHandlers: true },
vuetify: {
moduleOptions: {
ssrClientHints: {
viewportSize: true,
prefersColorScheme: true,
prefersColorSchemeOptions: {},
reloadOnFirstRequest: true,
},
},
},
icon: {
clientBundle: {
icons: Object.values(aliases).map((v) =>
(v as string).replace(/^mdi-/, 'mdi:'),
),
scan: true,
// scan all components in the project and include icons
// scan: true,
},
customCollections: [
{
prefix: 'custom',
dir: './assets/icons',
},
],
},
echarts: {
charts: ['LineChart', 'BarChart', 'PieChart', 'RadarChart'],
renderer: 'svg',
components: [
'DataZoomComponent',
'LegendComponent',
'TooltipComponent',
'ToolboxComponent',
'GridComponent',
'TitleComponent',
'DatasetComponent',
'VisualMapComponent',
],
},
build: {
transpile: ['vuetify'],
},
// vite: {
// build: { sourcemap: false },
// },
auth: {
isEnabled: true,
baseURL: process.env.AUTH_ORIGIN,
provider: {
type: 'authjs',
},
globalAppMiddleware: {
isEnabled: true,
},
},
mongoose: {
// uri: 'process.env.MONGODB_URI',
// options: {},
modelsDir: 'models',
devtools: true,
},
compatibilityDate: '2024-08-05',
runtimeConfig: {
public: {
keycloakClient: 'coba-pendaftaran',
keycloakIssuer: 'https://auth.rssa.top/realms/sandbox',
},
},
})

20372
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

48
package.json Normal file
View File

@@ -0,0 +1,48 @@
{
"private": true,
"type": "module",
"packageManager": "pnpm@10.6.2",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev -p 3001 --host",
"generate": "nuxt generate",
"postinstall": "nuxt prepare",
"preview": "nuxt preview",
"typecheck": "nuxt typecheck",
"test": "vitest",
"lint": "eslint . --fix",
"format": "prettier . --write"
},
"devDependencies": {
"@iconify-json/mdi": "^1.2.3",
"@nuxt/eslint": "^1.2.0",
"@nuxt/icon": "^1.11.0",
"@nuxt/test-utils": "^3.17.2",
"@sidebase/nuxt-auth": "^0.10.1",
"@types/node": "^22.13.13",
"@vue/test-utils": "^2.4.6",
"eslint": "^9.22.0",
"happy-dom": "^17.4.4",
"nuxt": "^3.16.0",
"nuxt-echarts": "^0.2.6",
"nuxt-mongoose": "1.0.6",
"playwright-core": "^1.51.0",
"prettier": "^3.5.3",
"vitest": "^3.0.8",
"vue-tsc": "^2.2.8",
"vuetify-nuxt-module": "^0.18.3"
},
"dependencies": {
"@pinia/nuxt": "^0.10.1",
"@prisma/nuxt": "^0.3.0",
"@vueform/nuxt": "^1.11.0",
"@vueuse/core": "^13.0.0",
"@vueuse/integrations": "^13.0.0",
"@vueuse/nuxt": "^13.0.0",
"echarts": "^5.6.0",
"fuse.js": "^7.1.0",
"nuxt-auth-utils": "^0.5.16",
"pinia": "^3.0.1",
"vuetify": "~3.6.15"
}
}

10
pages/[...all].vue Normal file
View File

@@ -0,0 +1,10 @@
<script setup lang="ts"></script>
<template>
<v-empty-state
headline="Whoops, 404"
title="Page not found"
text="The page you were looking for does not exist"
icon="custom:nustar"
/>
</template>

47
pages/auth-info.vue Normal file
View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
definePageMeta({
icon: 'mdi-security',
title: 'Auth',
drawerIndex: 4,
})
const { data, status, getCsrfToken, getProviders, signOut } = useAuth()
const runtimeConfig = useRuntimeConfig();
const providers = await getProviders()
const csrfToken = await getCsrfToken()
const handleLogout = async () => {
try {
// const returnTo = encodeURIComponent('http://localhost:3000/auth/login');
const returnTo = encodeURIComponent(window.location.origin);
const logoutUrl = `${runtimeConfig.public.keycloakIssuer}/protocol/openid-connect/logout?client_id=${runtimeConfig.public.keycloakClient}&post_logout_redirect_uri=${returnTo}`;
window.open(logoutUrl, '_blank'); // Sign out dari aplikasi sebelum redirect
await signOut({ callbackUrl: '/auth/login' });
} catch (error) {
console.error('Logout failed:', error);
}
};
</script>
<template>
<v-card>
<v-card-item>
<v-card-title>Authentication Overview</v-card-title>
<v-card-subtitle>See all available authentication & session information
below</v-card-subtitle>
</v-card-item>
<v-card-text>
<pre v-if="status"><span>Status:</span> {{ status }}</pre>
<pre v-if="data"><span>Data:</span> {{ data }}</pre>
<pre v-if="csrfToken"><span>CSRF Token:</span> {{ csrfToken }}</pre>
<pre v-if="providers"><span>Providers:</span> {{ providers }}</pre>
</v-card-text>
<v-card-actions>
<v-btn text="Logout" @click="handleLogout"></v-btn>
</v-card-actions>
</v-card>
</template>

72
pages/auth/login.vue Normal file
View File

@@ -0,0 +1,72 @@
<template>
<!-- <VMain class="main d-flex align-center justify-center"> -->
<VRow no-gutters class="main">
<VCol cols="6" class="d-flex align-center justify-center">
<VCard class="card" title="Login Area" width="400" rounded="lg">
<VCardText>
<VBtn v-for="provider in providers" :key="provider.id" @click="signIn(provider.id)" class="mb-8" color="blue"
size="large" block>
Sign in with {{ provider.name }}
</VBtn>
</VCardText>
</VCard>
</VCol>
</VRow>
<!-- </VMain> -->
</template>
<script setup>
// import { useField, useForm } from 'vee-validate'
definePageMeta({
layout: 'auth',
auth: {
unauthenticatedOnly: true,
navigateAuthenticatedTo: '/homepage',
},
})
const { signIn, getProviders } = useAuth()
const providers = await getProviders()
// const { handleSubmit, handleReset } = useForm({
// validationSchema: {
// username (value) {
// if (value?.length >= 2) return true
// return 'Name needs to be at least 2 characters.'
// },
// password (value) {
// return true
// if (value?.length > 9 && /[0-9-]+/.test(value)) return true
// return 'Phone number needs to be at least 9 digits.'
// },
// },
// })
// const username = useField('username')
// const password = useField('password')
// const submit = handleSubmit(values => {
// alert(JSON.stringify(values, null, 2))
// })
// const show = ref(false)
</script>
<style scoped>
.main {
min-height: 300px;
background-image: url('/background.jpg');
background-size: cover;
}
.card {
/* From https://css.glass */
background: rgba(0, 0, 0, 0.28);
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(11.1px);
-webkit-backdrop-filter: blur(11.1px);
}
</style>

108
pages/dashboard.vue Normal file
View File

@@ -0,0 +1,108 @@
<script setup lang="ts">
definePageMeta({
icon: 'mdi-monitor-dashboard',
title: 'Dashboard',
drawerIndex: 1,
})
const stats = ref([
{
icon: 'mdi-web',
title: 'Bandwidth',
value: 23,
unit: 'GB',
color: 'primary',
caption: 'Up: 13, Down: 10',
},
{
icon: 'mdi-rss',
title: 'Submissions',
value: 108,
color: 'primary',
caption: 'Too young, too naive',
},
{
icon: 'mdi-send',
title: 'Requests',
value: 1238,
color: 'warning',
caption: 'Limit: 1320',
},
{
icon: 'mdi-bell',
title: 'Messages',
value: 9042,
color: 'primary',
caption: 'Warnings: 300, erros: 47',
},
{
icon: 'mdi-github',
title: 'Github Stars',
value: NaN,
color: 'grey',
caption: 'API has no response',
},
{
icon: 'mdi-currency-cny',
title: 'Total Fee',
value: 2300,
unit: '¥',
color: 'error',
caption: 'Upper Limit: 2000 ¥',
},
])
</script>
<template>
<v-container fluid>
<v-row>
<v-col
v-for="stat in stats"
:key="stat.title"
cols="12"
sm="6"
md="4"
lg="2"
>
<StatsCard
:title="stat.title"
:unit="stat.unit"
:color="stat.color"
:icon="stat.icon"
:value="stat.value"
>
<template #footer>
{{ stat.caption }}
</template>
</StatsCard>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6" lg="12">
<v-card class="pa-2">
<ChartLine />
</v-card>
</v-col>
<v-col cols="12" md="6" lg="4">
<v-card class="pa-2">
<ChartRadar />
</v-card>
</v-col>
<v-col cols="12" md="6" lg="4">
<v-card class="pa-2">
<ChartPie />
</v-card>
</v-col>
<v-col cols="12" md="6" lg="4">
<v-card class="pa-2">
<ChartBar />
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<style scoped>
.v-card:not(.stats-card) {
height: 340px;
}
</style>

10
pages/forms.vue Normal file
View File

@@ -0,0 +1,10 @@
<script setup lang="ts">
definePageMeta({
title: 'Forms Routes',
icon: 'mdi-view-list',
drawerIndex: 4,
})
</script>
<template>
<NuxtPage />
</template>

3
pages/forms/index.vue Normal file
View File

@@ -0,0 +1,3 @@
<template>
<IndexPage />
</template>

25
pages/forms/patient.vue Normal file
View File

@@ -0,0 +1,25 @@
<script>
import { VCard } from 'vuetify/components'
definePageMeta({
title: 'Vue Form',
icon: 'mdi-account-injury-outline',
drawerIndex: 2,
})
</script>
<template>
<v-card
class="mx-auto"
prepend-icon="mdi-account-injury-outline"
subtitle="Isi dan lengkapi dengan data yang sesuai"
width="600"
>
<template v-slot:title>
<span class="font-weight-black">Formulir Pasien Baru</span>
</template>
<v-card-text class="bg-surface-light pt-4">
<FormPatient />
</v-card-text>
</v-card>
</template>

View File

@@ -0,0 +1,25 @@
<script>
import { VCard } from 'vuetify/components'
definePageMeta({
title: 'Practitioner Basic',
icon: 'mdi-account-injury-outline',
drawerIndex: 4,
})
</script>
<template>
<v-card
class="mx-auto"
prepend-icon="mdi-account-injury-outline"
subtitle="Isi dan lengkapi dengan data yang sesuai"
width="80%"
>
<template v-slot:title>
<span class="font-weight-black">Formulir Praktisi Baru</span>
</template>
<v-card-text class="bg-surface-light pt-4">
<FormPractitionerBasic />
</v-card-text>
</v-card>
</template>

101
pages/forms/vue-form.vue Normal file
View File

@@ -0,0 +1,101 @@
<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)
}
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>
<div class="ma-4">
<Vueform
v-model="data"
@change="onChange"
endpoint="/api/practitioner/test"
method="post"
@response="handleResponse"
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"
/>
<ButtonElement
name="submit"
button-label="Submit"
:submits="true"
align="right"
/>
</Vueform>
</div>
<span>{{ data }}</span>
</template>

237
pages/fuse.vue Normal file
View File

@@ -0,0 +1,237 @@
<script setup lang="ts">
import type { UseFuseOptions } from './index'
import { computed, shallowRef, watch } from 'vue'
import { useFuse } from './index'
interface DataItem {
firstName: string
lastName: string
}
const data = shallowRef<DataItem[]>([
{
firstName: 'Roslyn',
lastName: 'Mitchell',
},
{
firstName: 'Cathleen',
lastName: 'Matthews',
},
{
firstName: 'Carleton',
lastName: 'Harrelson',
},
{
firstName: 'Allen',
lastName: 'Moores',
},
{
firstName: 'John',
lastName: 'Washington',
},
{
firstName: 'Brooke',
lastName: 'Colton',
},
{
firstName: 'Mary',
lastName: 'Rennold',
},
{
firstName: 'Nanny',
lastName: 'Field',
},
{
firstName: 'Chasity',
lastName: 'Michael',
},
{
firstName: 'Oakley',
lastName: 'Giles',
},
{
firstName: 'Johanna',
lastName: 'Shepherd',
},
{
firstName: 'Maybelle',
lastName: 'Wilkie',
},
{
firstName: 'Dawson',
lastName: 'Rowntree',
},
{
firstName: 'Manley',
lastName: 'Pond',
},
{
firstName: 'Lula',
lastName: 'Sawyer',
},
{
firstName: 'Hudson',
lastName: 'Hext',
},
{
firstName: 'Alden',
lastName: 'Senior',
},
{
firstName: 'Tory',
lastName: 'Hyland',
},
{
firstName: 'Constance',
lastName: 'Josephs',
},
{
firstName: 'Larry',
lastName: 'Kinsley',
},
])
const search = shallowRef('')
const filterBy = shallowRef('both')
const keys = computed(() => {
if (filterBy.value === 'first') return ['firstName']
else if (filterBy.value === 'last') return ['lastName']
else return ['firstName', 'lastName']
})
const resultLimit = shallowRef<number | undefined>(undefined)
const resultLimitString = shallowRef<string>('')
watch(resultLimitString, () => {
if (resultLimitString.value === '') {
resultLimit.value = undefined
} else {
const float = Number.parseFloat(resultLimitString.value)
if (!Number.isNaN(float)) {
resultLimit.value = Math.round(float)
resultLimitString.value = resultLimit.value.toString()
}
}
})
const exactMatch = shallowRef(false)
const isCaseSensitive = shallowRef(false)
const matchAllWhenSearchEmpty = shallowRef(true)
const options = computed<UseFuseOptions<DataItem>>(() => ({
fuseOptions: {
keys: keys.value,
isCaseSensitive: isCaseSensitive.value,
threshold: exactMatch.value ? 0 : undefined,
},
resultLimit: resultLimit.value,
matchAllWhenSearchEmpty: matchAllWhenSearchEmpty.value,
}))
const { results } = useFuse(search, data, options)
</script>
<template>
<div>
<input
v-model="search"
placeholder="Search for someone..."
type="text"
w-full
/>
<div flex flex-wrap>
<div
bg="dark:(dark-300) light-700"
mr-2
border="1 light-900 dark:(dark-700)"
rounded
relative
flex
items-center
>
<i i-carbon-filter absolute left-2 opacity-70 />
<select v-model="filterBy" px-8 bg-transparent>
<option bg="dark:(dark-300) light-700" value="both">Full Name</option>
<option bg="dark:(dark-300) light-700" value="first">
First Name
</option>
<option bg="dark:(dark-300) light-700" value="last">Last Name</option>
</select>
<i
i-carbon-chevron-down
absolute
right-2
pointer-events-none
opacity-70
/>
</div>
<span flex-1 />
<div flex flex-row flex-wrap gap-x-4>
<label class="checkbox">
<input v-model="exactMatch" type="checkbox" />
<span>Exact Match</span>
</label>
<label class="checkbox">
<input v-model="isCaseSensitive" type="checkbox" />
<span>Case Sensitive</span>
</label>
<label class="checkbox">
<input v-model="matchAllWhenSearchEmpty" type="checkbox" />
<span>Match all when empty</span>
</label>
</div>
</div>
</div>
<div mt-4>
<template v-if="results.length > 0">
<div
v-for="result in results"
:key="result.item.firstName + result.item.lastName"
py-2
>
<div flex flex-col>
<span> {{ result.item.firstName }} {{ result.item.lastName }} </span>
<span text-sm opacity-50> Score Index: {{ result.refIndex }} </span>
</div>
</div>
</template>
<template v-else>
<div text-center pt-8 pb-4 opacity-80>No Results Found</div>
</template>
</div>
</template>
<style scoped lang="postcss">
input {
--tw-ring-offset-width: 1px !important;
--tw-ring-color: #8885 !important;
--tw-ring-offset-color: transparent !important;
}
.checkbox {
@apply inline-flex items-center my-auto cursor-pointer select-none;
}
.checkbox input {
appearance: none;
padding: 0;
-webkit-print-color-adjust: exact;
color-adjust: exact;
display: inline-block;
vertical-align: middle;
background-origin: border-box;
user-select: none;
flex-shrink: 0;
height: 1rem;
width: 1rem;
@apply bg-gray-400/30;
@apply rounded-md h-4 w-4 select-none;
}
.checkbox input:checked {
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
}
.checkbox span {
@apply ml-1.5 text-13px opacity-70;
}
</style>

45
pages/homepage.vue Normal file
View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
const name = ref('')
function sayHi() {
Notify.success(`Hi, ${name.value}!`)
}
function warning() {
Notify.warning(`How dare you refuse me, ${name.value}.`)
}
definePageMeta({
icon: 'mdi-home',
title: 'Homepage',
drawerIndex: 0,
})
// const { user } = useUserSession()
</script>
<template>
<v-container
fluid
class="d-flex align-items-center justify-center fill-height"
>
<div class="text-center">
<!-- <div>Welcome {{ user?.login }}!</div> -->
<v-icon
icon="custom:vitify-nuxt"
size="3em"
color="primary"
class="mb-4"
/>
<p>Opinionated Starter Template</p>
<v-text-field
v-model="name"
max-width="300"
placeholder="Hello World"
label="What's your name?"
class="mt-8"
/>
<v-btn :disabled="!name" class="mr-2" color="primary" @click="sayHi">
Confirm
</v-btn>
<v-btn :disabled="!name" @click="warning"> Cancel </v-btn>
<VIcon icon="i-simple-icons-keycloak" />
</div>
</v-container>
</template>

8
pages/index.vue Normal file
View File

@@ -0,0 +1,8 @@
<template>
<div />
</template>
<script setup lang="ts">
definePageMeta({
redirect: 'homepage',
})
</script>

10
pages/nested.vue Normal file
View File

@@ -0,0 +1,10 @@
<script setup lang="ts">
definePageMeta({
title: 'Nested Routes',
icon: 'mdi-view-list',
drawerIndex: 2,
})
</script>
<template>
<NuxtPage />
</template>

3
pages/nested/index.vue Normal file
View File

@@ -0,0 +1,3 @@
<template>
<IndexPage />
</template>

33
pages/nested/menu1.vue Normal file
View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import { useFormTest } from '~/composables/forms/test'
import { JsonForms } from '@jsonforms/vue'
import { vuetifyRenderers } from '@jsonforms/vue-vuetify'
definePageMeta({
title: 'Test Form',
icon: 'mdi-animation',
})
const { schema, uischema, data } = useFormTest()
const onChange = (event: JsonFormsChangeEvent) => {
data.value = event.data
}
</script>
<template>
<img alt="Vue logo" src="./assets/logo.png" />
<h1>JSON Forms Vue 3</h1>
<div class="myform">
<json-forms
:data="data"
:renderers="vuetifyRenderers"
:schema="schema"
:uischema="uischema"
@change="onChange"
/>
</div>
<pre>{{ data }}</pre>
<!-- <pre>{{ typeof schema }}</pre>
<pre>{{ schema }}</pre> -->
</template>

31
pages/nested/menu2.vue Normal file
View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { JsonForms } from '@jsonforms/vue'
import { vuetifyRenderers } from '@jsonforms/vue-vuetify'
import { useFormBasic } from '~/composables/forms/basic'
definePageMeta({
title: 'Basic Form',
icon: 'mdi-animation',
})
const { schema, uischema, data } = useFormBasic()
const onChange = (event: JsonFormsChangeEvent) => {
data.value = event.data
}
</script>
<template>
<img alt="Vue logo" src="./assets/logo.png" />
<h1>JSON Forms Vue 3</h1>
<div class="myform">
<json-forms
:data="data"
:renderers="vuetifyRenderers"
:schema="schema"
:uischema="uischema"
@change="onChange"
/>
</div>
<pre>{{ data }}</pre>
</template>

7
pages/nested/menu3.vue Normal file
View File

@@ -0,0 +1,7 @@
<script>
definePageMeta({
title: 'Menu 3',
icon: 'mdi-animation',
})
</script>
<template></template>

View File

@@ -0,0 +1,3 @@
<template>
<IndexPage />
</template>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
definePageMeta({
title: 'Menu1 Form',
icon: 'mdi-animation',
})
const api_data = useFetch('/api/forms/basic/data')
const api_i18n = useFetch('/api/forms/basic/i18n')
const api_schema = useFetch('/api/forms/basic/schema')
const api_uischema = useFetch('/api/forms/basic/uischema')
// In Vue 3, markRaw is used directly without needing to import it from a specific package
// const renderers = markRaw([
// ...extendedVuetifyRenderers,
// // here you can add custom renderers
// ]);
// Using ref for reactive data in Vue 3
const data = ref(api_data) // You'll need to replace this with your actual data
const schema = ref(api_schema) // Replace with your actual schema
const uischema = ref(api_uischema) // Replace with your actual UI schema
// Event handlers are defined as functions in the setup
// const onChange = (event) => {
// data.value = event.data;
// };
const demo = {
properties: {
firstName: {
type: 'string',
description: "The person's first name.",
},
lastName: {
type: 'string',
description: "The person's last name.",
},
age: {
description: 'Age in years which must be equal to or greater than zero.',
type: 'integer',
minimum: 0,
},
},
}
var form = document.getElementById('vuetify-json-forms')
// form.setAttribute("schema", JSON.stringify(demo));
// type="module"
// src="https://cdn.jsdelivr.net/npm/@chobantonov/jsonforms-vuetify-webcomponent@3.5.1/dist/vuetify-json-forms.min.js"
</script>
<template>
<vuetify-json-forms id="vuetify-json-forms"></vuetify-json-forms>
<v-container fluid> {{ data.data }} </v-container>
<v-container fluid> {{ api_i18n.data }} </v-container>
<v-container fluid> {{ schema.data }} </v-container>
<v-container fluid> {{ uischema.data }} </v-container>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
definePageMeta({
title: 'Menu 2-2',
icon: 'mdi-animation',
})
</script>
<template>
<v-container fluid> empty page </v-container>
</template>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
definePageMeta({
title: 'Vuetify Form',
icon: 'mdi-animation',
})
import { ref, provide, onBeforeMount } from 'vue'
import { JsonForms } from '@jsonforms/vue'
import {
vuetifyRenderers,
defaultStyles,
mergeStyles,
} from '@jsonforms/vue-vuetify'
const renderers = Object.freeze([
...vuetifyRenderers,
// here you can add custom renderers
])
var uischema = useFetch('/api/forms/example/uischema').data
var schema = useFetch('/api/forms/example/schema').data
console.log(JSON.parse(schema.trim))
// onBeforeMount(async () => {
// uischema = JSON.stringify(useFetch('/api/forms/example/uischema').data)
// console.log(uischema)
// // schema = JSON.stringify(useFetch('/api/forms/example/schema'))
// // console.log(schema)
// })
const data = ref({
name: 'Send email to Adrian',
description: 'Confirm if you have passed the subject\nHereby ...',
done: true,
recurrence: 'Daily',
rating: 3,
})
const onChange = (event: JsonFormsChangeEvent) => {
data.value = event.data
}
// mergeStyles combines all classes from both styles definitions into one
const myStyles = mergeStyles(defaultStyles, { control: { label: 'mylabel' } })
// Provide styles to child components
provide('styles', myStyles)
</script>
<template>
<img alt="Vue logo" src="./assets/logo.png" />
<h1>JSON Forms Vue 3</h1>
<!-- <div class="myform">
<json-forms
:data="data"
:renderers="renderers"
:schema="schema"
:uischema="uischema"
@change="onChange"
/>
</div> -->
<pre>{{ data }}</pre>
<pre>{{ typeof schema }}</pre>
<pre>{{ schema }}</pre>
</template>

10
pages/pasiens.vue Normal file
View File

@@ -0,0 +1,10 @@
<script setup lang="ts">
definePageMeta({
title: 'Pasien',
icon: 'mdi-account-injury-outline',
drawerIndex: 4,
})
</script>
<template>
<NuxtPage />
</template>

View File

@@ -0,0 +1,111 @@
<script>
definePageMeta({
title: 'Data Pasien',
icon: 'mdi-account-injury-outline',
drawerIndex: 0,
})
const randomData = ref(generateRandomDataPasien(10));
const headers = [
{ text: 'No RM' },
{ text: 'Nama' },
{ text: 'tanggal Lahir' },
{ text: 'Alamat' },
{ text: 'No KTP' },
{ text: 'No JKN' },
{ text: 'Kelamin' },
{ text: 'Aksi' },
// { text: 'Aksi' },
];
const searchQuery = ref('');
const filteredData = computed(() => {
if (searchQuery.value.length >= 3) {
return randomData.value.filter(item =>
item.norm.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
item.no_ktp.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
item.nama.toLowerCase().includes(searchQuery.value.toLowerCase())
);
}
return randomData.value;
});
console.log(randomData.value)
console.log(randomData.value.length)
</script>
<template>
<v-container>
<v-card>
<div class="py-4 px-3">
<div class="d-flex justify-space-between mb-6">
<h5 class="text-h5 ">Kunjungan Pasien</h5>
<div class="w-sm-25">
<v-text-field label="Cari" v-model="searchField" variant="outlined" density="compact" type="text"
hide-details color="primary"></v-text-field>
</div>
</div>
<v-row>
<div class="d-flex flex-wrap">
<!-- <v-col v-for="item in filteredData" :key="item.id" cols="12" md="4">
<v-lazy :min-height="200" :options="{ 'threshold': 1 }" transition="fade-transition">
<v-card>
<template v-slot:append>
<h3>{{ item.norm }}</h3>
</template>
<template v-slot:prepend>
<v-avatar size="50" class="rounded-md bg-lightsecondary">
<Icon icon="solar:user-circle-broken" class="text-secondary" height="36" />
</v-avatar>
</template>
<template v-slot:title>
<h4>{{ capitalizeEachWord(item.nama) }}</h4>
</template>
<template v-slot:subtitle>
<p>{{ item.no_ktp }}</p>
</template>
<template v-slot:text>
<p>{{ capitalizeEachWord(item.alamat) }}</p>
</template>
<template v-slot:actions>
<div class="d-flex justify-end">
<v-btn color="primary" @click="editItem(item.norm)">Ubah</v-btn>
<v-btn color="error" @click="deleteItem(item.norm)">Hapus</v-btn>
</div>
</template>
</v-card>
</v-lazy>
</v-col> -->
<div v-for="(data, i) in filteredData" :key="i">
<p>s</p>
</div>
<v-card class="mx-4">
<template v-slot:prepend>
left
</template>
<template v-slot:append>
right
</template>
</v-card>
<v-card>
<template v-slot:prepend>
left
</template>
<template v-slot:append>
right
</template>
</v-card>
</div>
</v-row>
</div>
</v-card>
</v-container>
</template>

3
pages/pasiens/index.vue Normal file
View File

@@ -0,0 +1,3 @@
<template>
<IndexPage />
</template>

View File

@@ -0,0 +1,11 @@
<script>
definePageMeta({
title: 'Kunjungan Pasien',
icon: 'mdi-account-injury-outline',
drawerIndex: 1,
})
</script>
<template>
<pre>dddddddd</pre>
</template>

View File

@@ -0,0 +1,21 @@
<script>
import { VCard } from 'vuetify/components'
definePageMeta({
title: 'Tambah Pasien',
icon: 'mdi-account-injury-outline',
drawerIndex: 2,
})
</script>
<template>
<v-card class="mx-auto" prepend-icon="mdi-account-injury-outline" subtitle="Isi dan lengkapi dengan data yang sesuai"
width="80%">
<template v-slot:title>
<span class="font-weight-black">Formulir Pasien Baru</span>
</template>
<v-card-text class="bg-surface-light pt-4">
<FormPatientCreate />
</v-card-text>
</v-card>
</template>

234
pages/practitioner.vue Normal file
View File

@@ -0,0 +1,234 @@
<script setup lang="ts">
import type { DataTableHeaders } from '~/plugins/vuetify'
definePageMeta({
icon: 'mdi-table',
title: 'Data Praktisi',
drawerIndex: 3,
})
const search = ref('')
const dialogDelete = useTemplateRef('dialogDelete')
function showDialogDelete(name: string) {
dialogDelete.value
?.open('Are you sure you want to delete this dessert?')
.then(async (confirmed: boolean) => {
if (confirmed) {
try {
const index = desserts.value!.findIndex((v) => v.name === name)
desserts.value!.splice(index, 1)
Notify.success('Deleted')
} catch (e) {
Notify.error(e)
}
}
})
}
const practitioners = ref([])
const loading = ref(true)
const error = ref(null)
const snackbar = ref({
show: false,
text: '',
color: 'info',
})
// Mengambil data dari API
const fetchPractitioners = async () => {
try {
loading.value = true
error.value = null
const response = await fetch('/api/practitioner')
if (!response.ok) {
throw new Error(`Error: ${response.status} - ${response.statusText}`)
}
const data = await response.json()
console.log(data)
practitioners.value = data
} catch (err) {
console.error('Failed to fetch practitioners:', err)
error.value = `Gagal memuat data praktisi: ${err.message}`
showSnackbar('Gagal memuat data praktisi', 'error')
} finally {
loading.value = false
}
}
// Mendapatkan nomor telepon praktisi
const getPractitionerPhone = (practitioner) => {
if (!practitioner.telecom || practitioner.telecom.length === 0) {
return 'Tidak ada data'
}
const phone = practitioner.telecom.find((t) => t.system === 'phone')
return phone ? phone.value : 'Tidak ada data'
}
// Mendapatkan email praktisi
const getPractitionerEmail = (practitioner) => {
if (!practitioner.telecom || practitioner.telecom.length === 0) {
return 'Tidak ada data'
}
const email = practitioner.telecom.find((t) => t.system === 'email')
return email ? email.value : 'Tidak ada data'
}
// Menampilkan detail praktisi
const showDetail = (practitioner) => {
console.log('Menampilkan detail praktisi:', getPractitionerName(practitioner))
showSnackbar(`Detail ${getPractitionerName(practitioner)}`, 'primary')
// Di sini bisa ditambahkan navigasi ke halaman detail atau membuka dialog
}
// Menampilkan snackbar
const showSnackbar = (text, color = 'success') => {
snackbar.value = {
show: true,
text,
color,
}
}
// Panggil API saat komponen dimuat
onMounted(() => {
fetchPractitioners()
})
</script>
<template>
<v-container fluid>
<v-row>
<v-col>
<v-card>
<client-only>
<teleport to="#app-bar">
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
label="Search"
single-line
hide-details
density="compact"
class="mr-2"
rounded="xl"
flat
variant="solo"
style="width: 250px"
/>
</teleport>
</client-only>
<v-container>
<h1 class="text-h4 mb-4">Daftar Praktisi</h1>
<v-alert v-if="error" type="error" class="mb-4">
{{ error }}
</v-alert>
<v-progress-circular
v-if="loading"
indeterminate
color="primary"
size="64"
class="my-8 mx-auto d-block"
></v-progress-circular>
<v-row v-else>
<v-col
v-for="(practitioner, index) in practitioners"
:key="index"
cols="12"
sm="6"
md="4"
>
<v-card
class="mx-auto mb-4"
max-width="400"
elevation="3"
shaped
>
<v-card-title class="text-h5">
{{ joinName(practitioner.name, ['official', 'usual']) }}
</v-card-title>
<v-card-text>
<v-row align="center" class="mx-0">
<DivAvatar
size="80"
class="mr-3"
:gender="practitioner.gender"
/>
<div>
<DivIconText
icon="mdi-map-marker"
:text="practitioner.birthPlace || 'Tidak ada data'"
/>
<DivIconText
icon="mdi-calendar"
:text="formatDate(practitioner.birthDate, 'full')"
/>
<DivIconText
icon="mdi-phone"
:text="
getContactPoints(
practitioner.telecom,
'phone',
).join(',')
"
/>
<DivIconText
icon="mdi-email"
:text="
getContactPoints(
practitioner.telecom,
'email',
).join(',')
"
/>
</div>
</v-row>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="primary"
text
@click="showDetail(practitioner)"
>
Detail
<v-icon small class="ml-1">mdi-arrow-right</v-icon>
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
<v-snackbar
v-model="snackbar.show"
:color="snackbar.color"
:timeout="3000"
>
{{ snackbar.text }}
</v-snackbar>
</v-container>
<DialogConfirm ref="dialogDelete" />
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<style scoped>
.v-card {
transition: transform 0.3s;
}
.v-card:hover {
transform: translateY(-5px);
}
</style>

188
pages/table.vue Normal file
View File

@@ -0,0 +1,188 @@
<script setup lang="ts">
import type { DataTableHeaders } from '~/plugins/vuetify'
definePageMeta({
icon: 'mdi-table',
title: 'Data Table',
drawerIndex: 3,
})
const search = ref('')
const dialogDelete = useTemplateRef('dialogDelete')
function showDialogDelete(name: string) {
dialogDelete.value
?.open('Are you sure you want to delete this dessert?')
.then(async (confirmed: boolean) => {
if (confirmed) {
try {
const index = desserts.value!.findIndex((v) => v.name === name)
desserts.value!.splice(index, 1)
Notify.success('Deleted')
} catch (e) {
Notify.error(e)
}
}
})
}
const headers: DataTableHeaders = [
{
title: 'Dessert (100g serving)',
key: 'name',
},
{ title: 'Calories', key: 'calories' },
{ title: 'Fat (g)', key: 'fat' },
{ title: 'Carbs (g)', key: 'carbs' },
{ title: 'Protein (g)', key: 'protein' },
{ title: 'Iron (%)', key: 'iron' },
{ title: 'Actions', key: 'actions', sortable: false },
]
const desserts = ref([
{
name: 'Frozen Yogurt',
calories: 159,
fat: 6.0,
carbs: 24,
protein: 4.0,
iron: '1',
},
{
name: 'Jelly bean',
calories: 375,
fat: 0.0,
carbs: 94,
protein: 0.0,
iron: '0',
},
{
name: 'KitKat',
calories: 518,
fat: 26.0,
carbs: 65,
protein: 7,
iron: '6',
},
{
name: 'Eclair',
calories: 262,
fat: 16.0,
carbs: 23,
protein: 6.0,
iron: '7',
},
{
name: 'Gingerbread',
calories: 356,
fat: 16.0,
carbs: 49,
protein: 3.9,
iron: '16',
},
{
name: 'Ice cream sandwich',
calories: 237,
fat: 9.0,
carbs: 37,
protein: 4.3,
iron: '1',
},
{
name: 'Lollipop',
calories: 392,
fat: 0.2,
carbs: 98,
protein: 0,
iron: '2',
},
{
name: 'Cupcake',
calories: 305,
fat: 3.7,
carbs: 67,
protein: 4.3,
iron: '8',
},
{
name: 'Honeycomb',
calories: 408,
fat: 3.2,
carbs: 87,
protein: 6.5,
iron: '45',
},
{
name: 'Donut',
calories: 452,
fat: 25.0,
carbs: 51,
protein: 4.9,
iron: '22',
},
{
name: 'Donut2',
calories: 452,
fat: 25.0,
carbs: 51,
protein: 4.9,
iron: '22',
},
])
</script>
<template>
<v-container fluid>
<v-row>
<v-col>
<v-card>
<client-only>
<teleport to="#app-bar">
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
label="Search"
single-line
hide-details
density="compact"
class="mr-2"
rounded="xl"
flat
variant="solo"
style="width: 250px"
/>
</teleport>
</client-only>
<v-data-table
:headers="headers"
:items="desserts"
item-value="name"
:search="search"
>
<template #item.actions="{ item }">
<v-defaults-provider
:defaults="{
VBtn: {
size: 20,
rounded: 'sm',
variant: 'text',
class: 'ml-1',
color: '',
},
VIcon: {
size: 20,
},
}"
>
<v-btn
v-tooltip="{ text: 'Delete', location: 'top' }"
icon="mdi-delete-outline"
@click.stop="showDialogDelete(item.name)"
/>
</v-defaults-provider>
</template>
</v-data-table>
<DialogConfirm ref="dialogDelete" />
</v-card>
</v-col>
</v-row>
</v-container>
</template>

29
plugins/vuetify.ts Normal file
View File

@@ -0,0 +1,29 @@
import type { IconProps } from 'vuetify'
import { Icon } from '#components'
import type { VDataTable } from 'vuetify/components'
import { useStorage } from '@vueuse/core'
import { aliases } from 'vuetify/iconsets/mdi'
export type DataTableHeaders = VDataTable['$props']['headers']
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.hook('vuetify:configuration', ({ vuetifyOptions }) => {
vuetifyOptions.icons = {
defaultSet: 'nuxtIcon',
sets: {
nuxtIcon: {
component: ({ icon, tag, ...rest }: IconProps) =>
h(tag, rest, [h(Icon, { name: aliases[icon as string] ?? icon })]),
},
},
aliases,
}
const primary = useStorage('theme-primary', '#1697f6').value
vuetifyOptions.theme = {
themes: {
light: { colors: { primary } },
dark: { colors: { primary } },
},
}
})
})

10178
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

5
prettier.config.js Normal file
View File

@@ -0,0 +1,5 @@
/** @type {import("prettier").Config} */
export default {
semi: false,
singleQuote: true,
}

45
prisma/address.prisma Normal file
View File

@@ -0,0 +1,45 @@
// model Country {
// id String @id @map("_id")
// name String @unique
// }
// model Address {
// id String @id @map("_id")
// name String @unique
// alt_name String?
// latitude Float?
// longitude Float?
// regencies Regency[]
// }
// type Regency {
// id String @map("_id")
// province_id String
// name String
// alt_name String?
// latitude Float?
// longitude Float?
// districts District[]
// }
// type District {
// id String @map("_id")
// regency_id String
// name String
// alt_name String?
// latitude Float?
// longitude Float?
// villages Village[]
// }
// type Village {
// id String @map("_id")
// district_id String
// name String
// }
// model Postal {
// id Int @id @map("_id")
// name String?
// postal Int[]
// }

View File

@@ -0,0 +1,67 @@
// model Practitioner {
// id String @id @default(auto()) @map("_id") @db.ObjectId
// active Boolean @default(true)
// identifier Identifier[]
// // name HumanName[]
// // telecom ContactPoint[]
// birthDate DateTime?
// birthPlace String?
// // deceased Deceased?
// // address AddressType[]
// // communication BackboneElementCommunication[]
// }
// type Identifier {
// name String
// value String
// }
// type HumanName {
// use String @default("usual")
// text String?
// family String?
// given String[]
// prefix String[]
// suffix String[]
// period Period
// }
// type Period {
// start DateTime @default(now())
// end DateTime?
// }
// type ContactPoint {
// system String @default("phone")
// use String @default("home")
// value String
// period Period
// }
// type Deceased {
// deceasedBoolean Boolean?
// deceasedDateTime DateTime?
// }
// type AddressType {
// use String @default("home")
// type String @default("physical")
// text String?
// line String[]
// village String?
// district String?
// city String?
// state String?
// country String?
// postalCode String
// period Period
// }
// type BackboneElementCommunication {
// language CodeableConceptLanguage
// preferred Boolean?
// }
// type CodeableConceptLanguage {
// text String
// }

0
prisma/schema.prisma Normal file
View File

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,240 @@
[
{
"id": "11",
"name": "ACEH",
"alt_name": "ACEH",
"latitude": 4.36855,
"longitude": 97.0253
},
{
"id": "12",
"name": "SUMATERA UTARA",
"alt_name": "SUMATERA UTARA",
"latitude": 2.19235,
"longitude": 99.38122
},
{
"id": "13",
"name": "SUMATERA BARAT",
"alt_name": "SUMATERA BARAT",
"latitude": -1.34225,
"longitude": 100.0761
},
{
"id": "14",
"name": "RIAU",
"alt_name": "RIAU",
"latitude": 0.50041,
"longitude": 101.54758
},
{
"id": "15",
"name": "JAMBI",
"alt_name": "JAMBI",
"latitude": -1.61157,
"longitude": 102.7797
},
{
"id": "16",
"name": "SUMATERA SELATAN",
"alt_name": "SUMATERA SELATAN",
"latitude": -3.12668,
"longitude": 104.09306
},
{
"id": "17",
"name": "BENGKULU",
"alt_name": "BENGKULU",
"latitude": -3.51868,
"longitude": 102.53598
},
{
"id": "18",
"name": "LAMPUNG",
"alt_name": "LAMPUNG",
"latitude": -4.8555,
"longitude": 105.0273
},
{
"id": "19",
"name": "KEPULAUAN BANGKA BELITUNG",
"alt_name": "KEPULAUAN BANGKA BELITUNG",
"latitude": -2.75775,
"longitude": 107.58394
},
{
"id": "21",
"name": "KEPULAUAN RIAU",
"alt_name": "KEPULAUAN RIAU",
"latitude": -0.15478,
"longitude": 104.58037
},
{
"id": "31",
"name": "DKI JAKARTA",
"alt_name": "DKI JAKARTA",
"latitude": 6.1745,
"longitude": 106.8227
},
{
"id": "32",
"name": "JAWA BARAT",
"alt_name": "JAWA BARAT",
"latitude": -6.88917,
"longitude": 107.64047
},
{
"id": "33",
"name": "JAWA TENGAH",
"alt_name": "JAWA TENGAH",
"latitude": -7.30324,
"longitude": 110.00441
},
{
"id": "34",
"name": "DI YOGYAKARTA",
"alt_name": "DI YOGYAKARTA",
"latitude": 7.7956,
"longitude": 110.3695
},
{
"id": "35",
"name": "JAWA TIMUR",
"alt_name": "JAWA TIMUR",
"latitude": -6.96851,
"longitude": 113.98005
},
{
"id": "36",
"name": "BANTEN",
"alt_name": "BANTEN",
"latitude": -6.44538,
"longitude": 106.13756
},
{
"id": "51",
"name": "BALI",
"alt_name": "BALI",
"latitude": -8.23566,
"longitude": 115.12239
},
{
"id": "52",
"name": "NUSA TENGGARA BARAT",
"alt_name": "NUSA TENGGARA BARAT",
"latitude": -8.12179,
"longitude": 117.63696
},
{
"id": "53",
"name": "NUSA TENGGARA TIMUR",
"alt_name": "NUSA TENGGARA TIMUR",
"latitude": -8.56568,
"longitude": 120.69786
},
{
"id": "61",
"name": "KALIMANTAN BARAT",
"alt_name": "KALIMANTAN BARAT",
"latitude": -0.13224,
"longitude": 111.09689
},
{
"id": "62",
"name": "KALIMANTAN TENGAH",
"alt_name": "KALIMANTAN TENGAH",
"latitude": -1.49958,
"longitude": 113.29033
},
{
"id": "63",
"name": "KALIMANTAN SELATAN",
"alt_name": "KALIMANTAN SELATAN",
"latitude": -2.94348,
"longitude": 115.37565
},
{
"id": "64",
"name": "KALIMANTAN TIMUR",
"alt_name": "KALIMANTAN TIMUR",
"latitude": 0.78844,
"longitude": 116.242
},
{
"id": "65",
"name": "KALIMANTAN UTARA",
"alt_name": "KALIMANTAN UTARA",
"latitude": 2.72594,
"longitude": 116.911
},
{
"id": "71",
"name": "SULAWESI UTARA",
"alt_name": "SULAWESI UTARA",
"latitude": 0.65557,
"longitude": 124.09015
},
{
"id": "72",
"name": "SULAWESI TENGAH",
"alt_name": "SULAWESI TENGAH",
"latitude": -1.69378,
"longitude": 120.80886
},
{
"id": "73",
"name": "SULAWESI SELATAN",
"alt_name": "SULAWESI SELATAN",
"latitude": -3.64467,
"longitude": 119.94719
},
{
"id": "74",
"name": "SULAWESI TENGGARA",
"alt_name": "SULAWESI TENGGARA",
"latitude": -3.54912,
"longitude": 121.72796
},
{
"id": "75",
"name": "GORONTALO",
"alt_name": "GORONTALO",
"latitude": 0.71862,
"longitude": 122.45559
},
{
"id": "76",
"name": "SULAWESI BARAT",
"alt_name": "SULAWESI BARAT",
"latitude": -2.49745,
"longitude": 119.3919
},
{
"id": "81",
"name": "MALUKU",
"alt_name": "MALUKU",
"latitude": -3.11884,
"longitude": 129.42078
},
{
"id": "82",
"name": "MALUKU UTARA",
"alt_name": "MALUKU UTARA",
"latitude": 0.63012,
"longitude": 127.97202
},
{
"id": "91",
"name": "PAPUA BARAT",
"alt_name": "PAPUA BARAT",
"latitude": -1.38424,
"longitude": 132.90253
},
{
"id": "94",
"name": "PAPUA",
"alt_name": "PAPUA",
"latitude": -3.98857,
"longitude": 138.34853
}
]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
{
"name": "John Doe",
"vegetarian": false,
"birthDate": "1985-06-02",
"personalData": {
"age": 34
},
"postalCode": "12345"
}

View File

@@ -0,0 +1,126 @@
{
"en": {
"name": {
"label": "Name",
"description": "The name of the person"
},
"vegetarian": {
"label": "Vegetarian",
"description": "Whether the person is a vegetarian"
},
"birth": {
"label": "Birth Date",
"description": ""
},
"nationality": {
"label": "Nationality",
"description": ""
},
"personal-data": {
"age": {
"label": "Age"
},
"driving": {
"label": "Driving Skill",
"description": "Indicating experience level"
}
},
"height": {
"label": "Height"
},
"occupation": {
"label": "Occupation",
"description": ""
},
"postal-code": {
"label": "Postal Code"
},
"error": {
"required": "field is required"
}
},
"de": {
"name": {
"label": "Name",
"description": "Der Name der Person"
},
"vegetarian": {
"label": "Vegetarier",
"description": "Isst die Person vegetarisch?"
},
"birth": {
"label": "Geburtsdatum",
"description": ""
},
"nationality": {
"label": "Nationalität",
"description": "",
"Other": "Andere"
},
"personal-data": {
"age": {
"label": "Alter"
},
"driving": {
"label": "Fahrkenntnisse",
"description": "Fahrerfahrung der Person"
}
},
"height": {
"label": "Größe"
},
"occupation": {
"label": "Beruf",
"description": ""
},
"postal-code": {
"label": "Postleitzahl"
},
"error": {
"required": "Pflichtfeld"
},
"Additional Information": "Zusätzliche Informationen"
},
"bg": {
"name": {
"label": "Име",
"description": "Името на лицето"
},
"vegetarian": {
"label": "Вегетарианец",
"description": "Дали човекът е вегетарианец"
},
"birth": {
"label": "Рождена дата",
"description": ""
},
"nationality": {
"label": "Националност",
"description": ""
},
"personal-data": {
"age": {
"label": "Възраст",
"description": "Моля, въведете вашата възраст."
},
"driving": {
"label": "Шофьорски умения",
"description": "Показва ниво на опит"
}
},
"height": {
"label": "Височина"
},
"occupation": {
"label": "Професия",
"description": ""
},
"postal-code": {
"label": "Пощенски код"
},
"error": {
"required": "полето е задължително"
},
"Additional Information": "Допълнителна информация"
}
}

View File

@@ -0,0 +1,57 @@
{
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 3,
"description": "Please enter your name",
"i18n": "name"
},
"vegetarian": {
"type": "boolean",
"i18n": "vegetarian"
},
"birthDate": {
"type": "string",
"format": "date",
"i18n": "birth"
},
"nationality": {
"type": "string",
"enum": ["DE", "IT", "JP", "US", "RU", "Other"],
"i18n": "nationality"
},
"personalData": {
"type": "object",
"properties": {
"age": {
"type": "integer",
"description": "Please enter your age.",
"i18n": "personal-data.age"
},
"height": {
"type": "number",
"i18n": "height"
},
"drivingSkill": {
"type": "number",
"maximum": 10,
"minimum": 1,
"default": 7,
"i18n": "personal-data.driving"
}
},
"required": ["age", "height"]
},
"occupation": {
"type": "string",
"i18n": "occupation"
},
"postalCode": {
"type": "string",
"maxLength": 5,
"i18n": "postal-code"
}
},
"required": ["occupation", "nationality"]
}

View File

@@ -0,0 +1,55 @@
{
"type": "VerticalLayout",
"elements": [
{
"type": "HorizontalLayout",
"elements": [
{
"type": "Control",
"scope": "#/properties/name"
},
{
"type": "Control",
"scope": "#/properties/personalData/properties/age"
},
{
"type": "Control",
"scope": "#/properties/birthDate"
}
]
},
{
"type": "Label",
"text": "Additional Information"
},
{
"type": "HorizontalLayout",
"elements": [
{
"type": "Control",
"scope": "#/properties/personalData/properties/height"
},
{
"type": "Control",
"scope": "#/properties/nationality"
},
{
"type": "Control",
"scope": "#/properties/occupation",
"options": {
"suggestion": [
"Accountant",
"Engineer",
"Freelancer",
"Journalism",
"Physician",
"Student",
"Teacher",
"Other"
]
}
}
]
}
]
}

BIN
public/social-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

26
public/vitify-nuxt.svg Normal file
View File

@@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 37 37" fill="none">
<g transform="rotate(180 18.778 13.7)">
<path class="spa-loading-path"
d="M24.236 22.006h10.742L25.563 5.822l-8.979 14.31a4 4 0 0 1-3.388 1.874H2.978l16-27.713 6 10.392" />
</g>
<style>
.spa-loading-path {
fill: none;
stroke: #248fe4;
stroke-width: 4px;
stroke-linecap: round;
stroke-linejoin: round;
/* Stroke-dasharray property */
stroke-dasharray: 128;
stroke-dashoffset: 128;
animation: nuxt-spa-loading-move 1.5s linear;
animation-fill-mode: forwards
}
@keyframes nuxt-spa-loading-move {
100% {
stroke-dashoffset: 0;
}
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 729 B

View File

@@ -0,0 +1,24 @@
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const parent = JSON.parse(query.parent)
try {
const cities = await prisma.addressCities.findMany({
where: {
Parent: parent.Code,
},
})
return cities.map((c) => {
return {
Code: c.Code,
Name: c.Name,
}
})
} catch (error) {
// Return error if fetching users fails
return {
status: 500,
body: { message: 'Failed to fetch cities' },
}
}
})

View File

@@ -0,0 +1,31 @@
import { useToNumber, useToString } from '@vueuse/core'
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const limit = useToNumber(useToString(query.limit).value).value || 5
const search = useToString(query.search)
try {
const countries = await prisma.addressCountries.findMany({
// where: {
// Name: {
// startsWith: search.value,
// mode: 'insensitive',
// },
// },
// take: limit,
})
return countries.map((c) => {
return {
Code: c.Code,
Name: c.Name,
}
})
} catch (error) {
// Return error if fetching users fails
return {
status: 500,
body: { message: 'Failed to fetch countries' },
}
}
})

Some files were not shown because too many files have changed in this diff Show More