Feat: add verification capthca and form adjustment

This commit is contained in:
hasyim_kai
2025-11-17 10:38:21 +07:00
parent 53bd8e7f6e
commit 15ab43c1b1
8 changed files with 236 additions and 28 deletions
+1
View File
@@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' width='250' height='30' viewBox='0 0 1000 120'><rect fill='#000000' width='1000' height='120'/><g fill='none' stroke='#222' stroke-width='10' stroke-opacity='1'><path d='M-500 75c0 0 125-30 250-30S0 75 0 75s125 30 250 30s250-30 250-30s125-30 250-30s250 30 250 30s125 30 250 30s250-30 250-30'/><path d='M-500 45c0 0 125-30 250-30S0 45 0 45s125 30 250 30s250-30 250-30s125-30 250-30s250 30 250 30s125 30 250 30s250-30 250-30'/><path d='M-500 105c0 0 125-30 250-30S0 105 0 105s125 30 250 30s250-30 250-30s125-30 250-30s250 30 250 30s125 30 250 30s250-30 250-30'/><path d='M-500 15c0 0 125-30 250-30S0 15 0 15s125 30 250 30s250-30 250-30s125-30 250-30s250 30 250 30s125 30 250 30s250-30 250-30'/><path d='M-500-15c0 0 125-30 250-30S0-15 0-15s125 30 250 30s250-30 250-30s125-30 250-30s250 30 250 30s125 30 250 30s250-30 250-30'/><path d='M-500 135c0 0 125-30 250-30S0 135 0 135s125 30 250 30s250-30 250-30s125-30 250-30s250 30 250 30s125 30 250 30s250-30 250-30'/></g></svg>
@@ -31,11 +31,9 @@ const {
const arrangementTypeOpts = [
{ label: 'KRS', value: "krs" },
{ label: 'MRS', value: "mrs" },
{ label: 'Pindah IGD', value: "pindahIgd" },
{ label: 'Rujuk', value: "rujuk" },
{ label: 'Rujuk Balik', value: "rujukBalik" },
{ label: 'Rujuk Internal', value: "rujukInternal" },
{ label: 'Rujuk External', value: "rujukExternal" },
{ label: 'Meninggal', value: "meninggal" },
{ label: 'Lain Lain', value: "other" },
]
</script>
+19 -14
View File
@@ -19,7 +19,6 @@ import SelectPainScale from './_common/select-pain-scale.vue'
import SelectNationalProgramService from './_common/select-national-program-service.vue'
import SelectNationalProgramServiceStatus from './_common/select-national-program-service-status.vue'
import SelectHospitalLeaveCondition from './_common/select-hospital-leave-condition.vue'
import SelectFollowingArrangement from './_common/select-following-arrangement.vue'
import SelectHospitalLeaveMethod from './_common/select-hospital-leave-method.vue'
const props = defineProps<{
@@ -58,6 +57,12 @@ const DEFAULT_CONSULTATION_VALUE = {
consultation: '',
consultationReply: '',
};
const initialFormValues = {
secondaryDiagnosis: [DEFAULT_SECONDARY_DIAGNOSIS_VALUE],
secondaryOperativeNonOperativeAct: [DEFAULT_SECONDARY_ACTION_VALUE],
consultation: [DEFAULT_CONSULTATION_VALUE],
}
</script>
<template>
@@ -68,7 +73,7 @@ const DEFAULT_CONSULTATION_VALUE = {
:validation-schema="formSchema"
:validate-on-mount="false"
validation-mode="onSubmit"
:initial-values="initialValues ? initialValues : {}">
:initial-values="initialValues ? initialValues : initialFormValues">
<!-- Pasien -->
<h1 class="mb-3 text-base font-medium">Pemeriksaan Pasien</h1>
@@ -295,7 +300,7 @@ const DEFAULT_CONSULTATION_VALUE = {
@click="isFarmacyHistoryOpen = true">
<Icon name="i-lucide-history" class="h-4 w-4" /> Riwayat Data Farmasi</Button>
</div>
<DE.Block class="items-end" :col-count="3" :cell-flex="false">
<DE.Block class="items-end" :col-count="2" :cell-flex="false">
<TextAreaInput field-name="supplementCheckup"
label="Kelainan Khusus Alergi" placeholder="Masukkan Kelainan Khusus Alergi" :errors="errors" />
<TextAreaInput field-name="supplementCheckup"
@@ -405,16 +410,25 @@ const DEFAULT_CONSULTATION_VALUE = {
</DE.Block>
</section>
<DE.Cell :col-span="3"
v-show="resumeArrangementType === `mrs`">
<TextAreaInput
field-name="inpatientIndication"
label="Indikasi Rawat Jalan"
placeholder="Indikasi Rawat Jalan"
:errors="errors" />
</DE.Cell>
<DE.Block :col-count="3" :cell-flex="false">
<SelectFaskes
v-show="resumeArrangementType === `rujuk` || resumeArrangementType === `rujukBalik` "
v-show="resumeArrangementType === `rujukExternal` "
field-name="faskes"
label="Faskes"
placeholder="Pilih Faskes"
:errors="errors"
/>
<InputBase
v-show="resumeArrangementType === `rujuk` || resumeArrangementType === `rujukBalik` "
v-show="resumeArrangementType === `rujukExternal` "
field-name="clinic"
label="Klinik"
placeholder="Masukkan Klinik"
@@ -460,15 +474,6 @@ const DEFAULT_CONSULTATION_VALUE = {
label="Keterangan Sebab Meninggal"
placeholder="Keterangan Sebab Meninggal"
:errors="errors" />
</DE.Cell>
<DE.Cell :col-span="3"
v-show="resumeArrangementType === `other`">
<TextAreaInput
field-name="keterangan"
label="Keterangan"
placeholder="Keterangan"
:errors="errors" />
</DE.Cell>
</DE.Block>
+7 -6
View File
@@ -8,6 +8,7 @@ import Select from '~/components/pub/my-ui/form/select.vue'
import { Form } from '~/components/pub/ui/form'
import InputBase from '~/components/pub/my-ui/form/input-base.vue'
import type { InstallationFormData } from '~/schemas/installation.schema'
import TextCaptcha from '~/components/pub/my-ui/form/text-captcha.vue'
const props = defineProps<{
@@ -21,6 +22,7 @@ const emit = defineEmits<{
}>()
const formSchema = toTypedSchema(props.schema)
const captchaRef = ref<InstanceType<typeof TextCaptcha> | null>(null)
// Form submission handler
function onSubmitForm(values: any, { resetForm }: { resetForm: () => void }) {
@@ -83,12 +85,11 @@ const items = ref([
</Field>
</div>
<InputBase
class="mb-2"
field-name="captha"
label="Captha"
placeholder="Masukkan Captha"
:errors="errors"/>
<TextCaptcha
ref="captchaRef"
:length="5"
:useSpacing="true"
:noiseChars="true"/>
</div>
</div>
+9 -3
View File
@@ -16,6 +16,8 @@ import { getPatients, removePatient } from '~/services/patient.service'
import DetailRow from '~/components/pub/my-ui/form/view/detail-row.vue'
import Dialog from '~/components/pub/my-ui/modal/dialog.vue'
import Confirmation from '~/components/pub/my-ui/confirmation/confirmation.vue'
import type { ExposedForm } from '~/types/form'
import { VerificationSchema } from '~/schemas/verification.schema'
// #endregion
@@ -37,7 +39,7 @@ const refSearchNav: RefSearchNav = {
},
}
const formType = ref<`a` | `b`>(`a`)
const verificationInputForm = ref<ExposedForm<any> | null>(null)
const isVerifyDialogOpen = ref(false)
const isRecordConfirmationOpen = ref(false)
const summaryLoading = ref(false)
@@ -45,6 +47,8 @@ const summaryLoading = ref(false)
const recId = ref<number>(0)
const recAction = ref<string>('')
const recItem = ref<any>(null)
const isCaptchaValid = ref(false)
provide('isCaptchaValid', isCaptchaValid)
const headerPrep: HeaderPrep = {
title: "Resume",
@@ -168,9 +172,11 @@ watch([recId, recAction], () => {
@page-change="handlePageChange"/>
<Dialog v-model:open="isVerifyDialogOpen" title="Verifikasi">
<AppResumeVerifyDialog />
<AppResumeVerifyDialog
ref="verificationInputForm"
:schema="VerificationSchema" />
<div class="flex justify-end">
<Action :enable-draft="false" @click="handleActionClick" />
<Action v-show="isCaptchaValid" :enable-draft="false" @click="handleActionClick" />
</div>
</Dialog>
@@ -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>
+4 -1
View File
@@ -1,7 +1,7 @@
import { z } from 'zod'
import type { CreateDto } from '~/models/consultation'
export type ResumeArrangementType = "krs" | "mrs" | "pindahIgd" | "rujuk" | "rujukBalik" | "meninggal" | "other"
export type ResumeArrangementType = "krs" | "mrs" | "rujukInternal" | "rujukExternal" | "meninggal" | "other"
const SecondaryDiagnosisSchema = z.object({
diagnosis: z.string({ required_error: 'Diagnosis harus diisi' }),
@@ -53,6 +53,9 @@ const ResumeSchema = z.object({
consultation: z.array(ConsultationSchema).optional(),
arrangement: z.custom<ResumeArrangementType>().default("krs"),
inpatientIndication: z.string({ required_error: 'Uraian harus diisi' })
.min(1, 'Uraian minimum 1 karakter')
.max(2048, 'Uraian maksimum 2048 karakter'),
faskes: z.string({ required_error: 'Faskes harus diisi' }).optional(),
clinic: z.string({ required_error: 'Klinik harus diisi' }).optional(),
deathDate: z.string({ required_error: 'Tanggal harus diisi' }).optional(),
+19
View File
@@ -0,0 +1,19 @@
import { z } from 'zod'
const VerificationSchema = z.object({
name: z.string({
required_error: 'Mohon lengkapi Nama Anda',
}),
email: z.string({
required_error: 'Mohon lengkapi email',
}),
password: z.string({
required_error: 'Mohon lengkapi password',
}),
})
type VerificationFormData = z.infer<typeof VerificationSchema>
export { VerificationSchema, }
export type { VerificationFormData, }