Merge branch 'dev' into feat/micro-lab-order-50
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { type TabItem } from '../comp-tab/type'
|
||||
|
||||
const props = defineProps<{
|
||||
initialActiveMenu: string
|
||||
data: TabItem[]
|
||||
}>()
|
||||
|
||||
const activeMenu = ref(props.initialActiveMenu)
|
||||
const emit = defineEmits<{
|
||||
changeMenu: [value: string]
|
||||
}>()
|
||||
|
||||
function changeMenu(value: string) {
|
||||
activeMenu.value = value
|
||||
emit('changeMenu', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-4 flex gap-4">
|
||||
<!-- Menu Sidebar -->
|
||||
<div v-if="data.length > 0" class="w-72 flex-shrink-0 rounded-md border bg-white shadow-sm dark:bg-neutral-950">
|
||||
<div class="max-h-[calc(100vh-12rem)] overflow-y-auto p-2">
|
||||
<button
|
||||
v-for="menu in data"
|
||||
:key="menu.value"
|
||||
:data-active="activeMenu === menu.value"
|
||||
class="w-full rounded-md px-4 py-3 text-left text-sm transition-colors data-[active=false]:text-gray-700 data-[active=false]:hover:bg-gray-100 data-[active=true]:bg-primary data-[active=true]:text-white dark:data-[active=false]:text-gray-300 dark:data-[active=false]:hover:bg-neutral-800"
|
||||
@click="changeMenu(menu.value)"
|
||||
>
|
||||
{{ menu.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Menu Content -->
|
||||
<div v-if="data.find((m) => m.value === activeMenu)?.component" class="flex-1 rounded-md border bg-white p-4 shadow-sm dark:bg-neutral-950">
|
||||
<component
|
||||
:is="data.find((m) => m.value === activeMenu)?.component"
|
||||
v-bind="data.find((m) => m.value === activeMenu)?.props"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,5 +3,7 @@ export interface TabItem {
|
||||
label: string
|
||||
component?: any
|
||||
groups?: string[]
|
||||
classCode?: string[]
|
||||
subClassCode?: string[]
|
||||
props?: Record<string, any>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import type { Config } from './index'
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
|
||||
const props = defineProps<Config>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:searchModelValue': [value: string]
|
||||
'search': [value: string]
|
||||
}>()
|
||||
|
||||
// Quick search
|
||||
const search = ref(props.quickSearchNav?.modelValue || props.refSearchNav?.modelValue || '')
|
||||
const debouncedSearch = refDebounced(search, props.quickSearchNav?.debounceDuration || 500)
|
||||
|
||||
// Computed search model for v-model
|
||||
const searchModel = computed({
|
||||
get: () => search.value,
|
||||
set: (value: string) => {
|
||||
search.value = value
|
||||
emit('update:searchModelValue', value)
|
||||
},
|
||||
})
|
||||
|
||||
// Watch for external changes to modelValue
|
||||
watch(() => props.quickSearchNav?.modelValue, (newValue) => {
|
||||
if (newValue !== props.quickSearchNav?.modelValue) {
|
||||
search.value = newValue || ''
|
||||
}
|
||||
})
|
||||
|
||||
// Watch debounced search and emit search event
|
||||
watch(debouncedSearch, (newValue) => {
|
||||
const minLength = props.quickSearchNav?.minLength || 3
|
||||
// Only search if meets minimum length or empty (to clear search)
|
||||
if (newValue.length === 0 || newValue.length >= minLength) {
|
||||
emit('search', newValue)
|
||||
props.refSearchNav?.onInput(newValue)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle clear search
|
||||
function clearSearch() {
|
||||
searchModel.value = ''
|
||||
props.quickSearchNav?.onClear()
|
||||
props.refSearchNav?.onClear()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between pb-4 2xl:pb-5 ">
|
||||
<div class="flex items-center">
|
||||
<div class="ml-3 text-lg font-semibold text-gray-900">
|
||||
<Icon v-if="icon" :name="icon" class="mr-2 size-4 md:size-6 align-middle" />
|
||||
{{ title }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center [&>*]:ms-2">
|
||||
<!-- Slot -->
|
||||
<slot />
|
||||
|
||||
<!-- Search Section -->
|
||||
<div v-if="quickSearchNav || refSearchNav" class="relative">
|
||||
<Input
|
||||
v-model="searchModel"
|
||||
name="search"
|
||||
type="text"
|
||||
:class="quickSearchNav?.inputClass || refSearchNav?.inputClass"
|
||||
:placeholder="quickSearchNav?.placeholder || refSearchNav?.placeholder || 'Cari (min. 3 karakter)...'"
|
||||
/>
|
||||
|
||||
<button
|
||||
v-if="search.length > 0"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
type="button"
|
||||
@click="clearSearch"
|
||||
>
|
||||
<Icon name="i-lucide-x" class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="quickSearchNav && quickSearchNav.showValidationFeedback !== false && searchModel.length > 0 && searchModel.length < (quickSearchNav.minLength || 3)"
|
||||
class="absolute -bottom-6 left-0 text-xs text-amber-600 whitespace-nowrap"
|
||||
>
|
||||
Minimal {{ quickSearchNav.minLength || 3 }} karakter untuk mencari
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Button -->
|
||||
<div v-if="addNav" class="flex items-center">
|
||||
<Button
|
||||
class="rounded-md border border-gray-300 px-4 py-2 text-white sm:text-sm"
|
||||
:class="addNav.classVal"
|
||||
:variant="(addNav.variant as any) || 'default'"
|
||||
@click="addNav?.onClick"
|
||||
>
|
||||
<Icon :name="addNav.icon || 'i-lucide-plus'" class="mr-2 h-4 w-4 align-middle" />
|
||||
{{ addNav.label }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Filter Button -->
|
||||
<div v-if="filterNav" class="flex items-center">
|
||||
<Button
|
||||
class="rounded-md border border-gray-300 px-4 py-2 sm:text-sm"
|
||||
:class="filterNav.classVal"
|
||||
:variant="(filterNav.variant as any) || 'default'"
|
||||
@click="filterNav?.onClick"
|
||||
>
|
||||
<Icon :name="filterNav.icon || 'i-lucide-filter'" class="mr-2 h-4 w-4 align-middle" />
|
||||
{{ filterNav.label }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Print Button -->
|
||||
<div v-if="printNav" class="flex items-center">
|
||||
<Button
|
||||
class="rounded-md border border-gray-300 px-4 py-2 sm:text-sm"
|
||||
:class="printNav.classVal"
|
||||
:variant="(printNav.variant as any) || 'default'"
|
||||
@click="printNav?.onClick"
|
||||
>
|
||||
<Icon :name="printNav.icon || 'i-lucide-printer'" class="mr-2 h-4 w-4 align-middle" />
|
||||
{{ printNav.label }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,59 @@
|
||||
export type ComponentWithProps = { component: Component, props: Record<string, any> }
|
||||
|
||||
export interface ButtonNav {
|
||||
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'
|
||||
classVal?: string
|
||||
classValExt?: string
|
||||
icon?: string
|
||||
label: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
// can type directly
|
||||
export interface QuickSearchNav {
|
||||
modelValue?: string
|
||||
placeholder?: string
|
||||
inputClass?: string
|
||||
inputPlaceHolder?: string
|
||||
minLength?: number
|
||||
btnClass?: string
|
||||
btnIcon?: string
|
||||
btnLabel?: string
|
||||
showValidationFeedback?: boolean
|
||||
debounceDuration?: number
|
||||
searchParams: object
|
||||
onSubmit?: (searchParams: object) => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
// callback on event
|
||||
export interface RefSearchNav {
|
||||
modelValue?: string
|
||||
placeholder?: string
|
||||
inputClass?: string
|
||||
inputPlaceHolder?: string
|
||||
btnClass?: string
|
||||
btnIcon?: string
|
||||
onInput: (val: string) => void
|
||||
onClick: () => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
export interface RefExportNav {
|
||||
onExportPdf?: () => void
|
||||
onExportCsv?: () => void
|
||||
onExportExcel?: () => void
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
title?: string
|
||||
icon?: string
|
||||
components?: ComponentWithProps[]
|
||||
quickSearchNav?: QuickSearchNav
|
||||
refSearchNav?: RefSearchNav // either ref or quick
|
||||
filterNav?: ButtonNav
|
||||
addNav?: ButtonNav
|
||||
printNav?: ButtonNav
|
||||
}
|
||||
|
||||
export { default as ContentHeader } from './content-header.vue'
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
height?: number
|
||||
activeTab?: 1 | 2
|
||||
}>()
|
||||
|
||||
const activeTab = ref(props.activeTab || 1)
|
||||
|
||||
function handleClick(value: 1 | 2) {
|
||||
activeTab.value = value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="content-switcher" :style="`height: ${height || 200}px`">
|
||||
<div :class="`${activeTab === 1 ? 'active' : 'inactive'}`">
|
||||
<div class="content-wrapper">
|
||||
<div>
|
||||
<slot name="content1" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-nav">
|
||||
<button @click="handleClick(1)">
|
||||
<Icon name="i-lucide-chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="`${activeTab === 2 ? 'active' : 'inactive'}`">
|
||||
<div class="content-nav">
|
||||
<button @click="handleClick(2)">
|
||||
<Icon name="i-lucide-chevron-left" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="content-wrapper">
|
||||
<div>
|
||||
<slot name="content2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.content-switcher {
|
||||
@apply flex overflow-hidden gap-3
|
||||
}
|
||||
.content-switcher > * {
|
||||
@apply border border-slate-300 rounded-md flex overflow-hidden
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
@apply p-4 2xl:p-5 overflow-hidden grow
|
||||
}
|
||||
.inactive .content-wrapper {
|
||||
@apply p-0 w-0
|
||||
}
|
||||
|
||||
.content-nav {
|
||||
@apply h-full flex flex-row items-center justify-center content-center !text-2xl overflow-hidden
|
||||
}
|
||||
|
||||
.content-nav button {
|
||||
@apply pt-2 px-2 h-full w-full
|
||||
}
|
||||
|
||||
/* .content-switcher .inactive > .content-wrapper {
|
||||
@apply w-0 p-0 opacity-0 transition-all duration-500 ease-in-out
|
||||
} */
|
||||
.content-switcher .inactive {
|
||||
@apply w-16 transition-all duration-500 ease-in-out
|
||||
}
|
||||
.content-switcher .inactive > .content-nav {
|
||||
@apply w-full transition-all duration-100 ease-in-out
|
||||
}
|
||||
|
||||
.content-switcher .active {
|
||||
@apply grow transition-all duration-500 ease-in-out
|
||||
}
|
||||
.content-switcher .active > .content-nav {
|
||||
@apply w-0 transition-all duration-100 ease-in-out
|
||||
}
|
||||
/* .content-switcher .active > .content-wrapper {
|
||||
@apply w-full delay-1000 transition-all duration-1000 ease-in-out
|
||||
} */
|
||||
|
||||
</style>
|
||||
@@ -85,7 +85,7 @@ function handleActionCellClick(event: Event, _cellRef: string) {
|
||||
v-for="(th, idx) in headers[hrIdx]"
|
||||
:key="`head-${idx}`"
|
||||
:class="`border ${th.classVal || ''}`"
|
||||
:style="{ width: cols[idx]?.width ? `${cols[idx].width}px` : undefined }"
|
||||
:style="{ width: cols[idx]?.width ? `${cols[idx].width}${cols[idx].widthUnit ?? 'px'}` : '' }"
|
||||
>
|
||||
{{ th.label }}
|
||||
</TableHead>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import type { LinkItem, ListItemDto } from './types'
|
||||
import { ActionEvents } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
rec: ListItemDto
|
||||
}>()
|
||||
|
||||
const recId = inject<Ref<number>>('rec_id')!
|
||||
const recAction = inject<Ref<string>>('rec_action')!
|
||||
const recItem = inject<Ref<any>>('rec_item')!
|
||||
const timestamp = inject<Ref<number>>('timestamp')!
|
||||
const activeKey = ref<string | null>(null)
|
||||
const linkItems: LinkItem[] = [
|
||||
{
|
||||
label: 'Detail',
|
||||
onClick: () => {
|
||||
detail()
|
||||
},
|
||||
icon: 'i-lucide-eye',
|
||||
},
|
||||
{
|
||||
label: 'Hapus',
|
||||
onClick: () => {
|
||||
del()
|
||||
},
|
||||
icon: 'i-lucide-trash',
|
||||
},
|
||||
]
|
||||
|
||||
function detail() {
|
||||
recId.value = props.rec.id || 0
|
||||
recAction.value = ActionEvents.showDetail
|
||||
recItem.value = props.rec
|
||||
timestamp.value = Date.now()
|
||||
}
|
||||
|
||||
function del() {
|
||||
recId.value = props.rec.id || 0
|
||||
recAction.value = ActionEvents.showConfirmDelete
|
||||
recItem.value = props.rec
|
||||
timestamp.value = Date.now()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
class="data-[state=open]:text-sidebar-accent-foreground data-[state=open]:bg-white dark:data-[state=open]:bg-slate-800"
|
||||
>
|
||||
<Icon
|
||||
name="i-lucide-chevrons-up-down"
|
||||
class="ml-auto size-4"
|
||||
/>
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
class="w-[--radix-dropdown-menu-trigger-width] min-w-40 rounded-lg border border-slate-200 bg-white text-black dark:border-slate-700 dark:bg-slate-800 dark:text-white"
|
||||
align="end"
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
v-for="item in linkItems"
|
||||
:key="item.label"
|
||||
class="hover:bg-gray-100 dark:hover:bg-slate-700"
|
||||
@click="item.onClick"
|
||||
@mouseenter="activeKey = item.label"
|
||||
@mouseleave="activeKey = null"
|
||||
>
|
||||
<Icon :name="item.icon ?? ''" />
|
||||
<span :class="activeKey === item.label ? 'text-sidebar-accent-foreground' : ''">{{ item.label }}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,14 +2,9 @@
|
||||
import type { LinkItem, ListItemDto } from './types'
|
||||
import { ActionEvents } from './types'
|
||||
|
||||
interface Props {
|
||||
const props = defineProps<{
|
||||
rec: ListItemDto
|
||||
size?: 'default' | 'sm' | 'lg'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 'lg',
|
||||
})
|
||||
}>()
|
||||
|
||||
const recId = inject<Ref<number>>('rec_id')!
|
||||
const recAction = inject<Ref<string>>('rec_action')!
|
||||
@@ -63,7 +58,7 @@ function del() {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<SidebarMenuButton
|
||||
:size="size"
|
||||
size="lg"
|
||||
class="data-[state=open]:text-sidebar-accent-foreground data-[state=open]:bg-white dark:data-[state=open]:bg-slate-800"
|
||||
>
|
||||
<Icon
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import type { LinkItem, ListItemDto } from './types'
|
||||
import { ActionEvents } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
rec: ListItemDto
|
||||
}>()
|
||||
|
||||
const recId = inject<Ref<number>>('rec_id')!
|
||||
const recAction = inject<Ref<string>>('rec_action')!
|
||||
const recItem = inject<Ref<any>>('rec_item')!
|
||||
const activeKey = ref<string | null>(null)
|
||||
const linkItems: LinkItem[] = [
|
||||
{
|
||||
label: 'Detail',
|
||||
onClick: () => {
|
||||
detail()
|
||||
},
|
||||
icon: 'i-lucide-eye',
|
||||
},
|
||||
{
|
||||
label: 'Verifikasi',
|
||||
onClick: () => {
|
||||
verify()
|
||||
},
|
||||
icon: 'i-lucide-check',
|
||||
},
|
||||
{
|
||||
label: 'Validasi',
|
||||
onClick: () => {
|
||||
validate()
|
||||
},
|
||||
icon: 'i-lucide-check-check',
|
||||
},
|
||||
{
|
||||
label: 'Print',
|
||||
onClick: () => {
|
||||
print()
|
||||
},
|
||||
icon: 'i-lucide-printer',
|
||||
},
|
||||
]
|
||||
|
||||
function detail() {
|
||||
recId.value = props.rec.id || 0
|
||||
recAction.value = ActionEvents.showDetail
|
||||
recItem.value = props.rec
|
||||
}
|
||||
|
||||
function verify() {
|
||||
recId.value = props.rec.id || 0
|
||||
recAction.value = ActionEvents.showVerify
|
||||
recItem.value = props.rec
|
||||
}
|
||||
|
||||
function validate() {
|
||||
recId.value = props.rec.id || 0
|
||||
recAction.value = ActionEvents.showValidate
|
||||
recItem.value = props.rec
|
||||
}
|
||||
|
||||
function print() {
|
||||
recId.value = props.rec.id || 0
|
||||
recAction.value = ActionEvents.showPrint
|
||||
recItem.value = props.rec
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
class="data-[state=open]:text-sidebar-accent-foreground data-[state=open]:bg-white dark:data-[state=open]:bg-slate-800"
|
||||
>
|
||||
<Icon
|
||||
name="i-lucide-chevrons-up-down"
|
||||
class="ml-auto size-4"
|
||||
/>
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
class="w-[--radix-dropdown-menu-trigger-width] min-w-40 rounded-lg border border-slate-200 bg-white text-black dark:border-slate-700 dark:bg-slate-800 dark:text-white"
|
||||
align="end"
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
v-for="item in linkItems"
|
||||
:key="item.label"
|
||||
class="hover:bg-gray-100 dark:hover:bg-slate-700"
|
||||
@click="item.onClick"
|
||||
@mouseenter="activeKey = item.label"
|
||||
@mouseleave="activeKey = null"
|
||||
>
|
||||
<Icon :name="item.icon ?? ''" />
|
||||
<span :class="activeKey === item.label ? 'text-sidebar-accent-foreground' : ''">{{ item.label }}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</template>
|
||||
@@ -42,6 +42,12 @@ export interface RefSearchNav {
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
export interface RefExportNav {
|
||||
onExportPdf?: () => void
|
||||
onExportCsv?: () => void
|
||||
onExportExcel?: () => void
|
||||
}
|
||||
|
||||
// prepared header for relatively common usage
|
||||
export interface HeaderPrep {
|
||||
title?: string
|
||||
@@ -65,6 +71,7 @@ export interface KeyNames {
|
||||
|
||||
export interface LinkItem {
|
||||
label: string
|
||||
value?: string
|
||||
icon?: string
|
||||
href?: string // to cover the needs of stating full external origins full url
|
||||
action?: string // for local paths
|
||||
@@ -78,6 +85,10 @@ export const ActionEvents = {
|
||||
showEdit: 'showEdit',
|
||||
showDetail: 'showDetail',
|
||||
showProcess: 'showProcess',
|
||||
showCancel: 'showCancel',
|
||||
showVerify: 'showVerify',
|
||||
showValidate: 'showValidate',
|
||||
showPrint: 'showPrint',
|
||||
}
|
||||
|
||||
export interface DataTableLoader {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const { class: classProp } = defineProps<{
|
||||
class?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-5 text-center">:</div>
|
||||
<div :class="`w-4 ps-1 ${classProp}`">:</div>
|
||||
</template>
|
||||
|
||||
@@ -62,7 +62,7 @@ async function onFileChange(event: Event, handleChange: (value: any) => void) {
|
||||
@change="onFileChange($event, handleChange)"
|
||||
type="file"
|
||||
:disabled="isDisabled"
|
||||
v-bind="componentField"
|
||||
v-bind="{ onBlur: componentField.onBlur }"
|
||||
:placeholder="placeholder"
|
||||
:class="cn('focus:border-primary focus:ring-2 focus:ring-primary focus:ring-offset-0')"
|
||||
/>
|
||||
|
||||
@@ -63,14 +63,14 @@ function handleInput(event: Event) {
|
||||
v-slot="{ componentField }"
|
||||
:name="fieldName"
|
||||
>
|
||||
<FormItem :class="`relative`">
|
||||
<FormItem :class="cn(`relative`,)">
|
||||
<FormControl>
|
||||
<Input
|
||||
:disabled="isDisabled"
|
||||
v-bind="componentField"
|
||||
:placeholder="placeholder"
|
||||
:maxlength="maxLength"
|
||||
:class="cn('focus:border-primary focus:ring-2 focus:ring-primary focus:ring-offset-0')"
|
||||
:class="cn('focus:border-primary focus:ring-2 focus:ring-primary focus:ring-offset-0', props.class)"
|
||||
autocomplete="off"
|
||||
aria-autocomplete="none"
|
||||
autocorrect="off"
|
||||
@@ -84,6 +84,6 @@ function handleInput(event: Event) {
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</DE.Field>
|
||||
<p v-show="bottomLabel" class="text-gray-400">{{ bottomLabel }}</p>
|
||||
<p v-show="bottomLabel" class="text-gray-400 mt-1">{{ bottomLabel }}</p>
|
||||
</DE.Cell>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormErrors } from '~/types/error'
|
||||
import FieldGroup from '~/components/pub/my-ui/form/field-group.vue'
|
||||
import Field from '~/components/pub/my-ui/form/field.vue'
|
||||
import Label from '~/components/pub/my-ui/form/label.vue'
|
||||
import { Input } from '~/components/pub/ui/input'
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
import * as DE from '~/components/pub/my-ui/doc-entry'
|
||||
|
||||
const props = defineProps<{
|
||||
fieldName: string
|
||||
placeholder: string
|
||||
label: string
|
||||
errors?: FormErrors
|
||||
class?: string
|
||||
colSpan?: number
|
||||
numericOnly?: boolean
|
||||
maxLength?: number
|
||||
isRequired?: boolean
|
||||
isDisabled?: boolean
|
||||
}>()
|
||||
|
||||
function handleInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
let value = target.value
|
||||
|
||||
// Filter numeric only jika diperlukan
|
||||
if (props.numericOnly) {
|
||||
value = value.replace(/\D/g, '')
|
||||
}
|
||||
|
||||
// Batasi panjang maksimal jika diperlukan
|
||||
if (props.maxLength && value.length > props.maxLength) {
|
||||
value = value.slice(0, props.maxLength)
|
||||
}
|
||||
|
||||
// Update value jika ada perubahan
|
||||
if (target.value !== value) {
|
||||
target.value = value
|
||||
// Trigger input event untuk update form
|
||||
target.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DE.Cell :col-span="colSpan || 1">
|
||||
<DE.Label
|
||||
class="mb-1"
|
||||
v-if="label !== ''"
|
||||
:label-for="fieldName"
|
||||
:is-required="isRequired && !isDisabled"
|
||||
>
|
||||
{{ label }}
|
||||
</DE.Label>
|
||||
<DE.Field
|
||||
:id="fieldName"
|
||||
:errors="errors"
|
||||
>
|
||||
<FormField
|
||||
v-slot="{ componentField }"
|
||||
:name="fieldName"
|
||||
>
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
:disabled="isDisabled"
|
||||
v-bind="componentField"
|
||||
:placeholder="placeholder"
|
||||
:maxlength="maxLength"
|
||||
:class="cn('focus:border-primary focus:ring-2 focus:ring-primary focus:ring-offset-0')"
|
||||
autocomplete="off"
|
||||
aria-autocomplete="none"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
@input="handleInput"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</DE.Field>
|
||||
</DE.Cell>
|
||||
</template>
|
||||
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, defineEmits, defineProps, onMounted, nextTick, defineExpose } from 'vue'
|
||||
import Input from '~/components/pub/ui/input/Input.vue';
|
||||
import Button from '~/components/pub/ui/button/Button.vue';
|
||||
import waveyFingerprint from '~/assets/svg/wavey-fingerprint.svg'
|
||||
|
||||
/**
|
||||
* TextCaptcha props:
|
||||
* - length: number of characters in the core captcha
|
||||
* - caseSensitive: whether validation is case sensitive
|
||||
* - useSpacing: show spaced-out characters (visual obfuscation only)
|
||||
* - noiseChars: include random noise characters visually (not required to type)
|
||||
*/
|
||||
const props = defineProps({
|
||||
length: { type: Number, default: 6 },
|
||||
caseSensitive: { type: Boolean, default: false },
|
||||
useSpacing: { type: Boolean, default: true },
|
||||
noiseChars: { type: Boolean, default: false }, // adds random noise characters to display
|
||||
refreshCooldownMs: { type: Number, default: 500 }, // guard repeated refresh
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:valid', valid: boolean): void
|
||||
(e: 'validated', valid: boolean): void
|
||||
(e: 'change', value: string): void
|
||||
}>()
|
||||
|
||||
// Internal state
|
||||
const raw = ref('') // the canonical captcha value (what user must match, ignoring visual noise)
|
||||
const display = ref('') // randomized visual representation (may include spacing/noise)
|
||||
const input = ref('') // user typed value
|
||||
const lastRefresh = ref(0)
|
||||
const valid = inject('isCaptchaValid') as Ref<boolean>
|
||||
const errorMessage = ref('')
|
||||
|
||||
/** Characters excluding ambiguous ones: 0/O, 1/l/I etc. */
|
||||
const CHARS = '23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
|
||||
|
||||
function randomChar() {
|
||||
return CHARS.charAt(Math.floor(Math.random() * CHARS.length))
|
||||
}
|
||||
|
||||
/** Generate the canonical captcha string */
|
||||
function genRaw(len = props.length) {
|
||||
let s = ''
|
||||
for (let i = 0; i < len; i++) s += randomChar()
|
||||
return s
|
||||
}
|
||||
|
||||
/** Create a visually obfuscated display string (spacing, noise, random case) */
|
||||
function genDisplay(base: string) {
|
||||
const arr: string[] = []
|
||||
for (const ch of base) {
|
||||
// toggle case randomly (only for letters)
|
||||
const c = /[A-Za-z]/.test(ch) && Math.random() > 0.5 ? (Math.random() > 0.5 ? ch.toLowerCase() : ch.toUpperCase()) : ch
|
||||
arr.push(c)
|
||||
if (props.useSpacing && Math.random() > 0.3) arr.push(' ') // random space
|
||||
}
|
||||
return arr.join('')
|
||||
}
|
||||
|
||||
/** Refresh captcha */
|
||||
function refresh() {
|
||||
const now = Date.now()
|
||||
if (now - lastRefresh.value < props.refreshCooldownMs) return
|
||||
lastRefresh.value = now
|
||||
|
||||
raw.value = genRaw(props.length)
|
||||
display.value = genDisplay(raw.value)
|
||||
input.value = ''
|
||||
valid.value = false
|
||||
errorMessage.value = ''
|
||||
// emit change so parent knows new value (but we don't send the raw canonical in production)
|
||||
emit('change', display.value)
|
||||
}
|
||||
|
||||
/** Normalize input and canonical for comparison */
|
||||
function normalizeForCompare(s: string) {
|
||||
const normalized = s.replace(/\s+/g, '') // strip spaces
|
||||
return props.caseSensitive ? normalized : normalized.toLowerCase()
|
||||
}
|
||||
|
||||
/** Validate the current input */
|
||||
function validate() {
|
||||
const left = normalizeForCompare(input.value)
|
||||
const right = normalizeForCompare(raw.value)
|
||||
if (!input.value) {
|
||||
valid.value = false
|
||||
errorMessage.value = 'Please enter the captcha text.'
|
||||
} else if (left === right) {
|
||||
valid.value = true
|
||||
errorMessage.value = ''
|
||||
} else {
|
||||
valid.value = false
|
||||
errorMessage.value = 'Captcha does not match.'
|
||||
}
|
||||
emit('update:valid', valid.value)
|
||||
emit('validated', valid.value)
|
||||
return valid.value
|
||||
}
|
||||
|
||||
// expose a refresh method to parent via ref
|
||||
defineExpose({ refresh, validate, isValid: computed(() => valid.value) })
|
||||
|
||||
// generate on mount
|
||||
onMounted(() => refresh())
|
||||
|
||||
// // re-validate whenever input changes (lightweight)
|
||||
// watch(input, () => {
|
||||
// // we don't auto-pass until the user explicitly validate (but we can optionally live-validate)
|
||||
// // Here we perform live feedback but still emit validated only when called
|
||||
// const left = normalizeForCompare(input.value)
|
||||
// const right = normalizeForCompare(raw.value)
|
||||
// valid.value = !!input.value && left === right
|
||||
// // emit a live update so the parent can disable submit accordingly
|
||||
// emit('update:valid', valid.value)
|
||||
// })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2 w-full max-w-sm">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<!-- Captcha visual box -->
|
||||
<div
|
||||
role="img"
|
||||
aria-label="Text captcha, type the characters shown"
|
||||
tabindex="0"
|
||||
class="select-none p-3 rounded-md border border-gray-200 text-white text-xl font-mono tracking-wider text-center w-full"
|
||||
>
|
||||
<span class="inline-block" v-html="display"></span>
|
||||
</div>
|
||||
|
||||
<!-- Refresh -->
|
||||
<div class="flex-shrink-0">
|
||||
<Button variant="ghost" type="button" @click="refresh" title="Refresh captcha">
|
||||
<Icon name="i-lucide-refresh-cw" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input -->
|
||||
<div class="flex gap-3 items-start">
|
||||
<div class="flex-grow">
|
||||
<Input
|
||||
v-model="input"
|
||||
:aria-invalid="valid ? 'false' : 'true'"
|
||||
inputmode="text"
|
||||
placeholder="Type the captcha text"
|
||||
@keyup.enter="validate"
|
||||
/>
|
||||
<p v-if="errorMessage" class="text-xs text-red-500 mt-1">{{ errorMessage }}</p>
|
||||
<p v-else-if="valid" class="text-xs text-green-500 mt-1">Correct</p>
|
||||
<p v-else class="text-xs text-gray-500 mt-1">Not case-sensitive</p>
|
||||
</div>
|
||||
<Button variant="outline" type="button" @click="validate" title="Validate"
|
||||
class="border-orange-400">
|
||||
<Icon name="i-lucide-check" class="text-orange-400" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* small nicety: make noise/spaced display look irregular */
|
||||
div[role="img"] {
|
||||
background: url('~/assets/svg/wavey-fingerprint.svg') repeat center;
|
||||
}
|
||||
|
||||
div[role="img"] span {
|
||||
letter-spacing: 0.12em;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { type EncounterItem } from "~/handlers/encounter-init.handler";
|
||||
|
||||
const props = defineProps<{
|
||||
initialActiveMenu: string
|
||||
data: EncounterItem[]
|
||||
}>()
|
||||
|
||||
const activeMenu = ref(props.initialActiveMenu)
|
||||
const emit = defineEmits<{
|
||||
changeMenu: [value: string]
|
||||
}>()
|
||||
|
||||
function changeMenu(value: string) {
|
||||
activeMenu.value = value
|
||||
emit('changeMenu', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex">
|
||||
<!-- Menu Sidebar -->
|
||||
<div v-if="data.length > 0" class="w-56 2xl:w-64 flex-shrink-0 rounded-md border bg-white dark:bg-slate-800 shadow-sm">
|
||||
<div class="max-h-[calc(100vh-12rem)] overflow-y-auto px-2 py-3">
|
||||
<button
|
||||
v-for="menu in data"
|
||||
:key="menu.id"
|
||||
:data-active="activeMenu === menu.id"
|
||||
class="w-full rounded-md px-4 py-3 text-left text-xs 2xl:text-sm transition-colors data-[active=false]:text-gray-700 data-[active=false]:hover:bg-gray-100 data-[active=true]:bg-primary data-[active=true]:text-white dark:data-[active=false]:text-gray-300 dark:data-[active=false]:hover:bg-neutral-800"
|
||||
@click="changeMenu(menu.id)">
|
||||
{{ menu.title }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Active Menu Content -->
|
||||
<div class="p-4 2xl:p-5 flex-grow">
|
||||
<div v-if="data.find((m) => m.id === activeMenu)?.component"
|
||||
class="flex-1 rounded-md border bg-white p-4 shadow-sm dark:bg-neutral-950">
|
||||
<component
|
||||
:is="data.find((m) => m.id === activeMenu)?.component"
|
||||
v-bind="data.find((m) => m.id === activeMenu)?.props" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
const props = defineProps<{
|
||||
link: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
// submit: [values: InstallationFormData, resetForm: () => void]
|
||||
// cancel: [resetForm: () => void]
|
||||
}>()
|
||||
|
||||
// Form cancel handler
|
||||
// function onCancelForm({ resetForm }: { resetForm: () => void }) {
|
||||
// emit('cancel', resetForm)
|
||||
// }
|
||||
function onExternalLink() {
|
||||
navigateTo(props.link, {external: true,open: { target: "_blank" },});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button variant="link" class="absolute top-4 right-16" @click="onExternalLink">
|
||||
Open in Browser
|
||||
<Icon name="i-lucide-external-link" class="h-4 w-4" />
|
||||
</Button>
|
||||
<div class="border border-gray-400 rounded-lg w-full h-[80vh] overflow-hidden">
|
||||
<embed style="width: 100%; height: 100%" :src="props.link" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -20,7 +20,7 @@ function onClick(type: ClickType) {
|
||||
@click="onClick('draft')"
|
||||
class="flex items-center gap-2 rounded-full border border-orange-400 bg-orange-50 px-3 py-1 text-sm font-medium text-orange-600 hover:bg-orange-100"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{{ props.label }}
|
||||
|
||||
@@ -30,8 +30,8 @@ function onClick(type: ClickType) {
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button v-show="enableDraft" variant="secondary" type="button" @click="onClick('draft')">
|
||||
<div v-show="props.enableDraft">
|
||||
<Button variant="secondary" type="button" @click="onClick('draft')">
|
||||
<Icon name="i-lucide-file" />
|
||||
Draft
|
||||
</Button>
|
||||
@@ -43,4 +43,4 @@ function onClick(type: ClickType) {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
@@ -37,6 +37,10 @@ function btnClick() {
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<!-- For components as slots -->
|
||||
<slot />
|
||||
|
||||
<!-- For components passed by props -->
|
||||
<div v-if="prep.components">
|
||||
<template v-for="cwp in prep.components">
|
||||
<component
|
||||
|
||||
@@ -32,7 +32,7 @@ function btnClick() {
|
||||
/>
|
||||
</div>
|
||||
<div v-if="prep.addNav" class="m-2 flex items-center">
|
||||
<Button size="md" class="rounded-md border border-gray-300 px-4 py-2 text-white sm:text-sm" @click="btnClick">
|
||||
<Button size="default" class="rounded-md border border-gray-300 px-4 py-2 text-white sm:text-sm" @click="btnClick">
|
||||
<Icon name="i-lucide-plus" class="mr-2 h-4 w-4 align-middle" />
|
||||
{{ prep.addNav.label }}
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user