hesabixCore/webUI/src/components/plugins/warranty/SerialDialog.vue
2025-08-19 20:51:48 +00:00

460 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<v-dialog v-model="dialog" max-width="640px" persistent>
<v-card>
<v-card-title class="d-flex align-center p-3 gap-2">
<v-icon class="mr-3" color="primary">mdi-shield-check</v-icon>
<span>{{ isEdit ? 'ویرایش سریال گارانتی' : 'افزودن سریال گارانتی جدید' }}</span>
</v-card-title>
<v-card-text>
<v-form ref="form" v-model="valid">
<v-row>
<v-col cols="12" md="6">
<v-text-field v-model="formData.serialNumber" label="شماره سریال *" :rules="[rules.serialNumber]" required
:disabled="isEdit" variant="outlined" density="comfortable" hide-details="auto" maxlength="50" counter>
<template #append>
<v-btn icon small @click="openScanner" :disabled="isEdit" color="primary" variant="text">
<v-icon size="20">mdi-qrcode-scan</v-icon>
</v-btn>
</template>
</v-text-field>
</v-col>
<v-col cols="12" md="6">
<Hcommoditysearch
:model-value="formData.commodity_id ?? undefined"
@update:modelValue="(val: number | Record<string, any>) => { formData.commodity_id = typeof val === 'number' ? val : (val as any)?.id ?? null }"
:return-object="false"
label="محصول *"
:rules="[rules.commodity]"
required
class="serial-commodity"
/>
</v-col>
<v-col cols="12" md="6">
<h-date-picker v-model="formData.warrantyStartDate" label="تاریخ شروع گارانتی" :rules="[rules.date]" :ignore-year-range="true" dense
outlined hide-details="auto" />
</v-col>
<v-col cols="12" md="6">
<h-date-picker v-model="formData.warrantyEndDate" label="تاریخ پایان گارانتی"
:rules="[(v: any) => rules.endDate(v, formData.warrantyStartDate)]" :ignore-year-range="true" dense outlined
hide-details="auto" />
</v-col>
<v-col cols="12" md="6">
<v-select v-model="formData.status" label="وضعیت" :items="statusOptions" item-title="title"
item-value="value" variant="outlined" density="comfortable" hide-details="auto" />
</v-col>
<v-col v-if="isEdit" cols="12" md="6">
<v-select v-model="formData.activation" label="وضعیت فعال‌سازی" :items="activationOptions"
item-title="title" item-value="value" variant="outlined" density="comfortable" hide-details="auto" />
</v-col>
<v-col cols="12">
<v-textarea v-model="formData.description" label="توضیحات" rows="3" auto-grow variant="outlined"
density="comfortable" hide-details="auto" />
</v-col>
</v-row>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn color="grey" @click="close">انصراف</v-btn>
<v-btn color="primary" @click="save" :loading="loading" :disabled="!valid">
{{ isEdit ? 'ویرایش' : 'ذخیره' }}
</v-btn>
</v-card-actions>
</v-card>
<v-dialog v-model="showQrScanner" :max-width="isMobile ? '95vw' : 560" persistent>
<v-card class="qr-card">
<v-card-title class="qr-title">
<v-icon left color="primary">mdi-qrcode-scan</v-icon>
اسکن کد QR/بارکد
</v-card-title>
<v-card-text>
<div class="qr-wrap">
<div id="reader" ref="readerRef" class="qr-reader"></div>
</div>
<div class="qr-status">
<v-alert v-if="scanError" type="error" variant="tonal" density="comfortable">
{{ scanError }}
</v-alert>
<v-progress-circular v-if="loadingScan" indeterminate size="28" class="mt-3" color="primary" />
</div>
</v-card-text>
<v-card-actions class="qr-actions">
<v-btn :disabled="loadingScan" variant="outlined" color="primary" prepend-icon="mdi-camera-switch"
@click="switchCamera">
تغییر دوربین
</v-btn>
<v-spacer />
<v-btn variant="text" @click="closeScanner">انصراف</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar v-model="showNotification" :color="notificationColor" :timeout="3000" location="top">
{{ notificationText }}
<template #actions>
<v-btn color="white" text @click="showNotification = false">بستن</v-btn>
</template>
</v-snackbar>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
import { Html5Qrcode, Html5QrcodeSupportedFormats, Html5QrcodeScannerState } from 'html5-qrcode'
import Hcommoditysearch from '@/components/forms/Hcommoditysearch.vue'
const props = defineProps<{
modelValue: boolean
serial?: any
commodities: any[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
save: [data: any]
close: []
}>()
const loading = ref(false)
const valid = ref(false)
const form = ref()
const commodityModel = ref<any>(null)
const formData = ref({
serialNumber: '',
commodity_id: null as number | null,
description: '',
warrantyStartDate: '',
warrantyEndDate: '',
status: 'available',
activation: 'deactive',
notes: ''
})
const rules = {
required: (v: any) => !!v || 'این فیلد الزامی است',
serialNumber: (v: any) => {
if (!v) return 'شماره سریال الزامی است'
if (!/^[A-Za-z0-9\-_.]+$/.test(v)) return 'شماره سریال نامعتبر است'
if (v.length < 3) return 'شماره سریال باید حداقل ۳ کاراکتر باشد'
if (v.length > 50) return 'شماره سریال نمی‌تواند بیشتر از ۵۰ کاراکتر باشد'
return true
},
commodity: (v: any) => !!v || 'انتخاب محصول الزامی است',
date: (v: any) => {
if (!v) return true
const d = new Date(v)
return !isNaN(d.getTime()) || 'تاریخ نامعتبر است'
},
endDate: (v: any, s: any) => {
if (!v || !s) return true
const end = new Date(v); const start = new Date(s)
return end > start || 'تاریخ پایان باید بعد از تاریخ شروع باشد'
}
}
const statusOptions = [
{ title: 'آزاد', value: 'available' },
{ title: 'تخصیص یافته', value: 'allocated' },
{ title: 'تأیید شده', value: 'verified' },
{ title: 'متصل', value: 'bound' },
{ title: 'مصرف شده', value: 'consumed' },
{ title: 'باطل', value: 'void' }
]
const activationOptions = [
{ title: 'غیرفعال', value: 'deactive' },
{ title: 'فعال', value: 'active' }
]
const dialog = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
})
const isEdit = computed(() => !!props.serial)
const isMobile = computed(() => window.innerWidth <= 768)
const showNotification = ref(false)
const notificationText = ref('')
const notificationColor = ref<'success' | 'error' | 'warning' | 'info'>('success')
const showNotify = (t: string, c: 'success' | 'error' | 'warning' | 'info' = 'success') => {
notificationText.value = t
notificationColor.value = c
showNotification.value = true
}
const showQrScanner = ref(false)
const readerRef = ref<HTMLElement | null>(null)
let qr: Html5Qrcode | null = null
const loadingScan = ref(false)
const scanError = ref('')
const cameras = ref<{ id: string; label: string }[]>([])
const currentCamIndex = ref(0)
const qrboxSize = ref(240)
const computeQrBox = () => {
const el = readerRef.value
if (!el) return 260
const w = Math.max(320, Math.floor(el.clientWidth))
const size = Math.max(220, Math.min(380, Math.floor(w * 0.66)))
return size
}
const openScanner = async () => {
showQrScanner.value = true
await nextTick()
await startScanner()
}
const startScanner = async () => {
try {
loadingScan.value = true
scanError.value = ''
qrboxSize.value = computeQrBox()
const devices = await Html5Qrcode.getCameras()
if (!devices.length) { throw new Error('دوربین یافت نشد') }
cameras.value = devices.map(d => ({ id: d.id, label: d.label }))
if (currentCamIndex.value >= cameras.value.length) currentCamIndex.value = 0
if (qr && (qr.getState?.() === Html5QrcodeScannerState.SCANNING)) await stopScanner()
if (!qr) qr = new Html5Qrcode('reader', {
verbose: false,
formatsToSupport: [
Html5QrcodeSupportedFormats.QR_CODE,
Html5QrcodeSupportedFormats.CODE_128,
Html5QrcodeSupportedFormats.CODE_39,
Html5QrcodeSupportedFormats.EAN_13,
Html5QrcodeSupportedFormats.UPC_A,
Html5QrcodeSupportedFormats.DATA_MATRIX
],
experimentalFeatures: { useBarCodeDetectorIfSupported: true }
})
await qr.start(
{ deviceId: { exact: cameras.value[currentCamIndex.value].id } },
{ fps: 12, qrbox: { width: qrboxSize.value, height: qrboxSize.value }, aspectRatio: 1.333 },
(decodedText: string) => {
if (decodedText) {
formData.value.serialNumber = decodedText.trim()
showNotify('کد با موفقیت اسکن شد', 'success')
closeScanner()
}
},
(_err: string) => { }
)
} catch (e: any) {
scanError.value = e?.message || 'خطا در راه‌اندازی دوربین'
} finally {
loadingScan.value = false
}
}
const stopScanner = async () => {
if (!qr) return
try {
const state = qr.getState?.()
if (state === Html5QrcodeScannerState.SCANNING) await qr.stop()
await qr.clear()
} catch { }
}
const closeScanner = async () => {
await stopScanner()
showQrScanner.value = false
}
const switchCamera = async () => {
if (!cameras.value.length) return
currentCamIndex.value = (currentCamIndex.value + 1) % cameras.value.length
await stopScanner()
await nextTick()
await startScanner()
}
const save = async () => {
const res = await form.value?.validate()
const ok = typeof res === 'object' ? res.valid : !!res
if (!ok) return
if (!formData.value.serialNumber || !formData.value.commodity_id) return
try {
loading.value = true
emit('save', { ...formData.value })
} finally {
loading.value = false
}
}
const close = () => {
closeScanner()
emit('close')
}
const resetForm = () => {
formData.value = {
serialNumber: '',
commodity_id: null,
description: '',
warrantyStartDate: '',
warrantyEndDate: '',
status: 'available',
activation: 'deactive',
notes: ''
}
commodityModel.value = null
form.value?.resetValidation()
}
const loadSerialData = () => {
if (props.serial) {
formData.value = {
serialNumber: props.serial.serialNumber || '',
commodity_id: Number(props.serial.commodity?.id) || null,
description: props.serial.description || '',
warrantyStartDate: props.serial.warrantyStartDate || '',
warrantyEndDate: props.serial.warrantyEndDate || '',
status: props.serial.status || 'available',
activation: props.serial.activation || 'deactive',
notes: props.serial.notes || ''
}
commodityModel.value = props.commodities.find(c => c.id === formData.value.commodity_id) || null
} else {
resetForm()
}
}
const customFilter = (item: any, q: string) => {
const t = String(item?.name || '').toLowerCase()
const s = String(q || '').toLowerCase()
return t.indexOf(s) > -1
}
const handleCommoditySelect = (c: any) => {
formData.value.commodity_id = c?.id ? Number(c.id) : null
}
watch(() => props.serial, () => nextTick(loadSerialData), { immediate: true })
watch(() => props.modelValue, v => { if (v) nextTick(loadSerialData) })
onBeforeUnmount(() => { closeScanner() })
</script>
<style>
/* normalize Hcommoditysearch height with other inputs */
.serial-commodity :deep(.v-field) { min-height: 56px; }
.serial-commodity :deep(.v-field__input) { padding-top: 14px; padding-bottom: 14px; }
#qr-shaded-region {
display: none !important;
}
video {
border-radius: 12px;
padding: 2px;
}
.v-dialog {
direction: rtl
}
.qr-card {
max-width: 95vw;
border-radius: 16px
}
.qr-title {
text-align: center;
padding: 14px 16px
}
.qr-wrap {
position: relative;
width: 100%;
max-width: 560px;
margin: 0 auto
}
.qr-reader {
width: 100%;
min-height: 320px;
border-radius: 12px;
border: 1px solid #e5e7eb;
overflow: hidden;
background: #000;
}
/* ویدئو تولیدی کتابخانه */
:deep(#reader video) {
width: 100% !important;
height: auto !important;
display: block !important;
object-fit: cover;
min-height: 240px;
}
/* کانتینر داخلی کتابخانه راست به چپ نشه */
:deep(#reader div) {
direction: ltr
}
/* وضعیت‌ها */
.qr-status {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 12px
}
.qr-actions {
padding: 12px 16px
}
/* ریسپانسیو */
@media (max-width:1024px) {
.qr-reader {
min-height: 300px
}
:deep(#reader video) {
min-height: 220px
}
}
@media (max-width:768px) {
.qr-card {
max-width: 95vw
}
.qr-reader {
min-height: 260px
}
:deep(#reader video) {
min-height: 200px
}
}
@media (max-width:480px) {
.qr-reader {
min-height: 220px
}
:deep(#reader video) {
min-height: 180px
}
}
.v-input.v-input--horizontal.v-input--center-affix.v-input--density-compact.v-theme--light.v-locale--is-rtl.v-input--error.v-text-field.my-0 {
height: 3rem;
}
</style>