|
|
@@ -10,7 +10,8 @@ import {
|
|
|
updatePetProfileApi,
|
|
|
updateGoodsInfoApi,
|
|
|
verifyMaintenanceCodeApi,
|
|
|
- uploadFile
|
|
|
+ uploadFile,
|
|
|
+ fetchRecentScanRecordsApi
|
|
|
} from '@/services/api'
|
|
|
|
|
|
const route = useRoute()
|
|
|
@@ -42,6 +43,9 @@ const showMaintenanceDialog = ref(false)
|
|
|
const isEditing = ref(false)
|
|
|
const showLocationDialog = ref(false)
|
|
|
const showLocationViewDialog = ref(false)
|
|
|
+const showScanRecordsDialog = ref(false)
|
|
|
+const scanRecords = ref(null)
|
|
|
+const loadingScanRecords = ref(false)
|
|
|
|
|
|
const FORM_KEY_MAP = Object.freeze({
|
|
|
person: [
|
|
|
@@ -397,6 +401,50 @@ const openGoogleMaps = () => {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+const handleOpenScanRecords = async () => {
|
|
|
+ if (!qrCode.value) {
|
|
|
+ toast.add({ severity: 'warn', summary: 'Notice', detail: 'Please enter a QR code first.', life: 2400 })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ showScanRecordsDialog.value = true
|
|
|
+ await fetchScanRecords()
|
|
|
+}
|
|
|
+
|
|
|
+const fetchScanRecords = async () => {
|
|
|
+ if (!qrCode.value) return
|
|
|
+ loadingScanRecords.value = true
|
|
|
+ try {
|
|
|
+ const data = await fetchRecentScanRecordsApi(qrCode.value, 10)
|
|
|
+ scanRecords.value = data
|
|
|
+ } catch (error) {
|
|
|
+ toast.add({ severity: 'error', summary: 'Failed', detail: parseError(error), life: 3200 })
|
|
|
+ scanRecords.value = null
|
|
|
+ } finally {
|
|
|
+ loadingScanRecords.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const formatDateTime = (dateString) => {
|
|
|
+ if (!dateString) return '-'
|
|
|
+ const date = new Date(dateString)
|
|
|
+ return date.toLocaleString('en-US', {
|
|
|
+ year: 'numeric',
|
|
|
+ month: '2-digit',
|
|
|
+ day: '2-digit',
|
|
|
+ hour: '2-digit',
|
|
|
+ minute: '2-digit',
|
|
|
+ second: '2-digit'
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+const openRecordLocation = (record) => {
|
|
|
+ if (record.latitude && record.longitude) {
|
|
|
+ window.open(`https://www.google.com/maps?q=${record.latitude},${record.longitude}`, '_blank')
|
|
|
+ } else if (record.address) {
|
|
|
+ window.open(`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(record.address)}`, '_blank')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
onMounted(() => {
|
|
|
if (!qrCode.value) {
|
|
|
setDocumentTitle()
|
|
|
@@ -581,8 +629,14 @@ onMounted(() => {
|
|
|
{{ isPerson ? (profile?.specialNote || 'No extra details') : (profile?.remark || 'No extra details') }}
|
|
|
</p>
|
|
|
</div>
|
|
|
- <!-- Edit button -->
|
|
|
- <div v-if="!isEditing" class="pt-4">
|
|
|
+ <!-- Action buttons -->
|
|
|
+ <div v-if="!isEditing" class="pt-4 space-y-3">
|
|
|
+ <button type="button"
|
|
|
+ class="w-full flex items-center justify-center gap-2 rounded-xl border border-cyan-300 bg-cyan-50 px-5 py-2.5 text-sm font-medium text-cyan-700 transition-all duration-200 hover:border-cyan-400 hover:bg-cyan-100 hover:shadow-md"
|
|
|
+ @click="handleOpenScanRecords">
|
|
|
+ <i class="pi pi-history text-xs" />
|
|
|
+ SCAN RECORDS
|
|
|
+ </button>
|
|
|
<button type="button"
|
|
|
class="w-full flex items-center justify-center gap-2 rounded-xl border border-slate-300 bg-white px-5 py-2.5 text-sm font-medium text-slate-700 transition-all duration-200 hover:border-slate-400 hover:bg-slate-50 hover:shadow-md"
|
|
|
@click="showMaintenanceDialog = true">
|
|
|
@@ -814,6 +868,111 @@ onMounted(() => {
|
|
|
<LocationPicker v-model="showLocationViewDialog" :initial-location="profile?.location" :closable="true"
|
|
|
:readonly="true" />
|
|
|
|
|
|
+ <!-- Scan records dialog -->
|
|
|
+ <Dialog v-model:visible="showScanRecordsDialog" modal :closable="true" :closeOnEscape="true"
|
|
|
+ :dismissableMask="true" :style="{ width: '90vw', maxWidth: '800px' }" :draggable="false">
|
|
|
+ <template #header>
|
|
|
+ <div class="flex items-center gap-3">
|
|
|
+ <div class="flex h-10 w-10 items-center justify-center rounded-full bg-cyan-100">
|
|
|
+ <i class="pi pi-history text-lg text-cyan-600" />
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <h3 class="text-lg font-semibold text-slate-900">Recent Scan Records</h3>
|
|
|
+ <p class="text-sm text-slate-500">
|
|
|
+ QR Code: <span class="font-mono">{{ qrCode }}</span>
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <div class="space-y-4 py-4">
|
|
|
+ <div v-if="loadingScanRecords" class="flex items-center justify-center py-8">
|
|
|
+ <i class="pi pi-spin pi-spinner text-3xl text-slate-400" />
|
|
|
+ <span class="ml-3 text-slate-600">Loading...</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-else-if="scanRecords">
|
|
|
+
|
|
|
+ <div v-if="scanRecords.records && scanRecords.records.length > 0" class="space-y-3 max-h-[60vh] overflow-y-auto">
|
|
|
+ <div v-for="record in scanRecords.records" :key="record.id"
|
|
|
+ class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm hover:shadow-md transition-shadow">
|
|
|
+ <div class="flex items-start justify-between gap-4">
|
|
|
+ <div class="flex-1 space-y-2">
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <i class="pi pi-clock text-sm text-slate-500" />
|
|
|
+ <span class="text-sm font-medium text-slate-700">Scan Time</span>
|
|
|
+ <span class="text-sm text-slate-600">{{ formatDateTime(record.scanTime) }}</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="record.address" class="flex items-start gap-2">
|
|
|
+ <i class="pi pi-map-marker text-sm text-slate-500 mt-0.5" />
|
|
|
+ <div class="flex-1">
|
|
|
+ <span class="text-sm font-medium text-slate-700">Address</span>
|
|
|
+ <p class="text-sm text-slate-600 break-words">{{ record.address }}</p>
|
|
|
+ </div>
|
|
|
+ <button v-if="record.latitude && record.longitude" type="button"
|
|
|
+ class="ml-2 flex-shrink-0 rounded-lg border border-cyan-200 bg-cyan-50 px-2 py-1 text-xs text-cyan-700 hover:bg-cyan-100 transition"
|
|
|
+ @click="openRecordLocation(record)">
|
|
|
+ <i class="pi pi-external-link mr-1" />
|
|
|
+ Map
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="record.latitude && record.longitude" class="flex items-center gap-2">
|
|
|
+ <i class="pi pi-globe text-sm text-slate-500" />
|
|
|
+ <span class="text-sm text-slate-600">
|
|
|
+ Coordinates: {{ record.latitude }}, {{ record.longitude }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="record.ipAddress" class="flex items-center gap-2">
|
|
|
+ <i class="pi pi-desktop text-sm text-slate-500" />
|
|
|
+ <span class="text-sm text-slate-600">IP: {{ record.ipAddress }}</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="record.userAgent" class="flex items-start gap-2">
|
|
|
+ <i class="pi pi-browser text-sm text-slate-500 mt-0.5" />
|
|
|
+ <div class="flex-1">
|
|
|
+ <span class="text-sm font-medium text-slate-700">Device Info</span>
|
|
|
+ <p class="text-xs text-slate-500 break-words mt-0.5">{{ record.userAgent }}</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-else class="rounded-xl border border-slate-200 bg-slate-50 p-8 text-center">
|
|
|
+ <i class="pi pi-inbox text-4xl text-slate-400 mb-3" />
|
|
|
+ <p class="text-sm text-slate-600">No scan records</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-else class="rounded-xl border border-red-200 bg-red-50 p-4">
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <i class="pi pi-exclamation-triangle text-red-600" />
|
|
|
+ <span class="text-sm text-red-700">Failed to load, please try again later</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <template #footer>
|
|
|
+ <div class="flex justify-end gap-3">
|
|
|
+ <button type="button"
|
|
|
+ class="rounded-xl border border-slate-300 px-5 py-2.5 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
|
|
|
+ @click="showScanRecordsDialog = false">
|
|
|
+ Close
|
|
|
+ </button>
|
|
|
+ <button type="button"
|
|
|
+ class="rounded-xl bg-cyan-600 px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-cyan-700 disabled:opacity-50"
|
|
|
+ :disabled="loadingScanRecords" @click="fetchScanRecords">
|
|
|
+ <i class="pi pi-refresh mr-2" :class="{ 'pi-spin': loadingScanRecords }" />
|
|
|
+ Refresh
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </Dialog>
|
|
|
+
|
|
|
<!-- Maintenance code dialog -->
|
|
|
<Dialog v-model:visible="showMaintenanceDialog" modal :closable="!isFirstFill" :closeOnEscape="!isFirstFill"
|
|
|
:dismissableMask="!isFirstFill" :style="{ width: '90vw', maxWidth: '450px' }" :draggable="false">
|