hesabixCore/webUI/src/components/plugins/warranty/SerialDialog.vue

356 lines
9.9 KiB
Vue
Raw Normal View History

2025-08-06 15:16:18 +03:30
<template>
<v-dialog v-model="dialog" max-width="640px" persistent>
2025-08-06 15:16:18 +03:30
<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 #prepend>
<v-tooltip bottom size="small">
<template v-slot:activator="{ props }">
<v-icon v-bind="props" color="primary" @click.stop="showQrScanner = true">mdi-barcode-scan</v-icon>
</template>
<span>اسکن بارکد</span>
</v-tooltip>
</template>
</v-text-field>
2025-08-06 15:16:18 +03:30
</v-col>
2025-08-06 15:16:18 +03:30
<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" />
2025-08-06 15:16:18 +03:30
</v-col>
2025-08-06 15:16:18 +03:30
<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" />
2025-08-06 15:16:18 +03:30
</v-col>
2025-08-06 15:16:18 +03:30
<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" />
2025-08-06 15:16:18 +03:30
</v-col>
2025-08-06 15:16:18 +03:30
<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" />
2025-08-06 15:16:18 +03:30
</v-col>
2025-08-06 15:16:18 +03:30
<v-col cols="12">
<v-textarea v-model="formData.description" label="توضیحات" rows="3" auto-grow variant="outlined"
density="comfortable" hide-details="auto" />
2025-08-06 15:16:18 +03:30
</v-col>
</v-row>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
2025-08-06 15:16:18 +03:30
<v-btn color="grey" @click="close">انصراف</v-btn>
<v-btn color="primary" @click="save" :loading="loading" :disabled="!valid">
2025-08-06 15:16:18 +03:30
{{ isEdit ? 'ویرایش' : 'ذخیره' }}
</v-btn>
</v-card-actions>
</v-card>
<BarcodeScanner v-model="showQrScanner" @detected="handleBarcodeScan" />
<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>
2025-08-06 15:16:18 +03:30
</v-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import Hcommoditysearch from '@/components/forms/Hcommoditysearch.vue'
import BarcodeScanner from '@/components/widgets/BarcodeScanner.vue'
2025-08-06 15:16:18 +03:30
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)
2025-08-06 15:16:18 +03:30
const formData = ref({
serialNumber: '',
commodity_id: null as number | null,
2025-08-06 15:16:18 +03:30
description: '',
warrantyStartDate: '',
warrantyEndDate: '',
status: 'available',
activation: 'deactive',
2025-08-06 15:16:18 +03:30
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 'شماره سریال نمی‌تواند بیشتر از ۵۰ کاراکتر باشد'
2025-08-06 15:16:18 +03:30
return true
},
commodity: (v: any) => !!v || 'انتخاب محصول الزامی است',
date: (v: any) => {
if (!v) return true
const d = new Date(v)
return !isNaN(d.getTime()) || 'تاریخ نامعتبر است'
2025-08-06 15:16:18 +03:30
},
endDate: (v: any, s: any) => {
if (!v || !s) return true
const end = new Date(v); const start = new Date(s)
return end > start || 'تاریخ پایان باید بعد از تاریخ شروع باشد'
2025-08-06 15:16:18 +03:30
}
}
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' }
2025-08-06 15:16:18 +03:30
]
const dialog = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
2025-08-06 15:16:18 +03:30
})
const isEdit = computed(() => !!props.serial)
const isMobile = computed(() => window.innerWidth <= 768)
2025-08-06 15:16:18 +03:30
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 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
2025-08-06 15:16:18 +03:30
try {
loading.value = true
emit('save', { ...formData.value })
2025-08-06 15:16:18 +03:30
} finally {
loading.value = false
}
}
const close = () => {
emit('close')
}
const resetForm = () => {
formData.value = {
serialNumber: '',
commodity_id: null,
2025-08-06 15:16:18 +03:30
description: '',
warrantyStartDate: '',
warrantyEndDate: '',
status: 'available',
activation: 'deactive',
2025-08-06 15:16:18 +03:30
notes: ''
}
commodityModel.value = null
form.value?.resetValidation()
2025-08-06 15:16:18 +03:30
}
const loadSerialData = () => {
if (props.serial) {
formData.value = {
serialNumber: props.serial.serialNumber || '',
commodity_id: Number(props.serial.commodity?.id) || null,
2025-08-06 15:16:18 +03:30
description: props.serial.description || '',
warrantyStartDate: props.serial.warrantyStartDate || '',
warrantyEndDate: props.serial.warrantyEndDate || '',
status: props.serial.status || 'available',
activation: props.serial.activation || 'deactive',
2025-08-06 15:16:18 +03:30
notes: props.serial.notes || ''
}
commodityModel.value = props.commodities.find(c => c.id === formData.value.commodity_id) || null
2025-08-06 15:16:18 +03:30
} else {
resetForm()
}
}
const customFilter = (item: any, q: string) => {
const t = String(item?.name || '').toLowerCase()
const s = String(q || '').toLowerCase()
return t.indexOf(s) > -1
2025-08-06 15:16:18 +03:30
}
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) })
const handleBarcodeScan = (val: string) => {
formData.value.serialNumber = val
showQrScanner.value = false
}
</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
2025-08-06 15:16:18 +03:30
}
}
@media (max-width:480px) {
.qr-reader {
min-height: 220px
}
2025-08-06 15:16:18 +03:30
:deep(#reader video) {
min-height: 180px
2025-08-06 15:16:18 +03:30
}
}
2025-08-06 15:16:18 +03:30
.v-input--density-compact .v-field--variant-outlined {
height: 3rem !important;
}
.mdi-barcode-scan::before {
font-size: 25px !important;
2025-08-06 15:16:18 +03:30
}
</style>