Merge branch 'dev' into feat/medicine-form-167

This commit is contained in:
2025-11-21 08:17:43 +07:00
66 changed files with 4473 additions and 37 deletions
@@ -8,6 +8,8 @@ interface ConfirmationProps {
message?: string
confirmText?: string
cancelText?: string
noTrueSlot?: boolean
skipClosingMessage?: boolean
variant?: 'default' | 'destructive' | 'warning'
size?: 'sm' | 'md' | 'lg' | 'xl'
}
@@ -71,20 +73,22 @@ function handleCancel() {
<Dialog v-model:open="isOpen" :title="title" :size="size">
<div class="space-y-4">
<!-- Icon dan pesan -->
<div class="flex items-center gap-3">
<div :class="[variantClasses.icon, variantClasses.iconColor]" class="w-6 h-6 mt-1 flex-shrink-0" />
<div class="flex items-start gap-3">
<div :class="[variantClasses.icon, variantClasses.iconColor]" class="w-4 h-4 2xl:h-5 2xl:h-6 flex-shrink-0" />
<div class="flex-1">
<p class="text-sm text-muted-foreground leading-relaxed">
{{ message }}
</p>
{{ message }} {{ !noTrueSlot ? ' dengan informasi sebagai berikut:' : '.' }}
</div>
</div>
<!-- Slot untuk konten custom -->
<div v-if="$slots.default">
<div>
<slot />
</div>
<div v-if="!skipClosingMessage" class="">
Lanjutkan Proses?
</div>
<!-- Footer buttons -->
<div class="flex justify-end gap-3 pt-4">
<Button variant="outline" @click="handleCancel">
@@ -46,31 +46,31 @@ const actionConfig = computed(() => {
const configs = {
delete: {
title: 'Hapus Data',
message: 'Apakah Anda yakin ingin menghapus data ini? Tindakan ini tidak dapat dibatalkan.',
message: 'Akan dilakukan penghapusan data',
confirmText: 'Hapus',
variant: 'destructive' as const,
},
deactivate: {
title: 'Nonaktifkan Data',
message: 'Apakah Anda yakin ingin menonaktifkan data ini?',
message: 'Akan dilakukan peng-nonaktifkan data',
confirmText: 'Nonaktifkan',
variant: 'warning' as const,
},
activate: {
title: 'Aktifkan Data',
message: 'Apakah Anda yakin ingin mengaktifkan data ini?',
message: 'Akan dilakukan pengaktifkan data',
confirmText: 'Aktifkan',
variant: 'default' as const,
},
archive: {
title: 'Arsipkan Data',
message: 'Apakah Anda yakin ingin mengarsipkan data ini?',
message: 'Akan dilakukan pengarsipan data',
confirmText: 'Arsipkan',
variant: 'warning' as const,
},
restore: {
title: 'Pulihkan Data',
message: 'Apakah Anda yakin ingin memulihkan data ini?',
message: 'Akan dilakukan pemulihan data',
confirmText: 'Pulihkan',
variant: 'default' as const,
},
@@ -107,6 +107,8 @@ const finalCancelText = computed(() => {
function handleConfirm() {
if (props.record) {
emit('confirm', props.record, props.action)
} else {
emit('confirm', { id: 0 }, 'confirmed')
}
emit('update:open', false)
}
@@ -119,11 +121,13 @@ function handleCancel() {
<template>
<Confirmation
v-model:open="isOpen" :title="finalTitle" :message="finalMessage" :confirm-text="finalConfirmText"
:cancel-text="finalCancelText" :variant="actionConfig.variant" size="md" @confirm="handleConfirm"
v-model:open="isOpen" :variant="actionConfig.variant" size="md"
:title="finalTitle" :message="finalMessage"
:confirm-text="finalConfirmText" :cancel-text="finalCancelText"
:no-true-slot="$slots.default ? false : true"
@confirm="handleConfirm"
@cancel="handleCancel"
>
<!-- Slot untuk informasi tambahan record -->
>
<div v-if="record && $slots.default" class="mt-4 p-3 bg-muted rounded-md">
<slot :record="record" :action="action" />
</div>
@@ -0,0 +1,95 @@
<script setup lang="ts">
import type { LinkItem, ListItemDto } from './types'
import { ActionEvents } from './types'
interface Props {
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')!
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: 'Submit',
onClick: () => {
submit()
},
icon: 'i-lucide-check',
},
{
label: 'Hapus',
onClick: () => {
del()
},
icon: 'i-lucide-trash',
},
]
function detail() {
recId.value = props.rec.id || 0
recAction.value = ActionEvents.showDetail
recItem.value = props.rec
}
function submit() {
recId.value = props.rec.id || 0
recAction.value = ActionEvents.showConfirmSubmit
recItem.value = props.rec
}
function del() {
recId.value = props.rec.id || 0
recAction.value = ActionEvents.showConfirmDelete
recItem.value = props.rec
}
</script>
<template>
<div>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
:size="size"
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>
@@ -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>
+4 -6
View File
@@ -42,12 +42,6 @@ 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
@@ -79,10 +73,14 @@ export interface LinkItem {
}
export const ActionEvents = {
showConfirmSubmit: 'showConfirmSubmit',
showConfirmDelete: 'showConfirmDel',
showEdit: 'showEdit',
showDetail: 'showDetail',
showProcess: 'showProcess',
showVerify: 'showVerify',
showValidate: 'showValidate',
showPrint: 'showPrint',
}
export interface DataTableLoader {
+1 -1
View File
@@ -64,7 +64,7 @@ const settingClass = computed(() => {
}
cls += ' [&:not(.preview)_.height-default]:pt-2 [&:not(.preview)_.height-default]:2xl:!pt-1.5 [&:not(.preview)_.height-compact]:!pt-1 '
cls += '[&_textarea]:md:text-xs [&_textarea]:2xl:!text-sm '
cls += '[&_label]:md:text-xs [&_label]:md:text-xs [&_label]:2xl:!text-sm'
cls += '[&_label]:md:text-xs [&_label]:2xl:!text-sm '
return cls
})
</script>
@@ -1,4 +1,5 @@
export { default as Block } from './block.vue'
export { default as Colon } from './colon.vue'
export { default as Cell } from './cell.vue'
export { default as Label } from './label.vue'
export { default as Field } from './field.vue'
+3 -3
View File
@@ -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>
+3 -3
View File
@@ -52,8 +52,8 @@ const isOpen = computed({
>
<DialogHeader>
<DialogTitle :class="`text-sm 2xl:text-base font-semibold flex ${titleClass || ''}`">
<div class="me-2 pt-0.5">
<Icon v-if="props.titleIcon" :name="props.titleIcon" :class="`!pt-2`" />
<div v-if="props.titleIcon" class="me-2 pt-0.5">
<Icon :name="props.titleIcon" :class="`!pt-2`" />
</div>
<div>
{{ props.title }}
@@ -61,7 +61,7 @@ const isOpen = computed({
</DialogTitle>
<DialogDescription v-if="props.description">{{ props.description }}</DialogDescription>
</DialogHeader>
<DialogDescription :class="sizeClass">
<DialogDescription :class="`${sizeClass} md:text-xs 2xl:text-sm`">
<slot />
</DialogDescription>
</DialogContent>
@@ -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>
@@ -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>