2025-07-03 21:29:42 +02:00
|
|
|
|
<template>
|
|
|
|
|
|
<div v-if="modelValue" class="modal-overlay" @click.self="closeModal">
|
|
|
|
|
|
<div class="modal-content">
|
|
|
|
|
|
<div class="modal-header">
|
|
|
|
|
|
<h3 class="modal-title">
|
|
|
|
|
|
Receipt {{ currentIndex + 1 }} of {{ receipts.length }}
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<button @click="closeModal" class="btn-close">
|
|
|
|
|
|
<Icon name="mdi:close" class="w-5 h-5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="modal-body">
|
|
|
|
|
|
<div v-if="currentReceipt" class="receipt-viewer">
|
|
|
|
|
|
<!-- Receipt Image -->
|
|
|
|
|
|
<div class="receipt-image-container">
|
|
|
|
|
|
<img
|
|
|
|
|
|
:src="currentReceipt.signedUrl || currentReceipt.url"
|
|
|
|
|
|
:alt="currentReceipt.title || `Receipt ${currentIndex + 1}`"
|
|
|
|
|
|
class="receipt-image"
|
|
|
|
|
|
@error="handleImageError"
|
|
|
|
|
|
@load="handleImageLoad"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Loading overlay -->
|
|
|
|
|
|
<div v-if="imageLoading" class="loading-overlay">
|
|
|
|
|
|
<span class="loading loading-spinner loading-lg"></span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Error overlay -->
|
|
|
|
|
|
<div v-if="imageError" class="error-overlay">
|
|
|
|
|
|
<Icon name="mdi:image-broken" class="w-12 h-12 text-red-400 mb-2" />
|
|
|
|
|
|
<p class="text-red-500 text-sm">Failed to load image</p>
|
|
|
|
|
|
<button @click="retryLoad" class="btn btn-sm btn-outline mt-2">
|
|
|
|
|
|
Retry
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Receipt Info -->
|
|
|
|
|
|
<div class="receipt-info">
|
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
|
<span class="info-label">File Name:</span>
|
|
|
|
|
|
<span class="info-value">{{ currentReceipt.title || 'Unknown' }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
|
<span class="info-label">File Size:</span>
|
|
|
|
|
|
<span class="info-value">{{ formatFileSize(currentReceipt.size) }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
|
<span class="info-label">Type:</span>
|
|
|
|
|
|
<span class="info-value">{{ currentReceipt.mimetype || 'Unknown' }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="currentReceipt.width && currentReceipt.height" class="info-item">
|
|
|
|
|
|
<span class="info-label">Dimensions:</span>
|
|
|
|
|
|
<span class="info-value">{{ currentReceipt.width }} × {{ currentReceipt.height }}px</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Navigation Controls -->
|
|
|
|
|
|
<div v-if="receipts.length > 1" class="navigation-controls">
|
|
|
|
|
|
<button
|
|
|
|
|
|
@click="previousReceipt"
|
|
|
|
|
|
:disabled="currentIndex === 0"
|
|
|
|
|
|
class="nav-btn"
|
|
|
|
|
|
title="Previous receipt"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Icon name="mdi:chevron-left" class="w-5 h-5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="receipt-dots">
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-for="(receipt, index) in receipts"
|
|
|
|
|
|
:key="receipt.id || index"
|
|
|
|
|
|
@click="goToReceipt(index)"
|
|
|
|
|
|
:class="['dot', { active: index === currentIndex }]"
|
|
|
|
|
|
:title="`Go to receipt ${index + 1}`"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
|
@click="nextReceipt"
|
|
|
|
|
|
:disabled="currentIndex === receipts.length - 1"
|
|
|
|
|
|
class="nav-btn"
|
|
|
|
|
|
title="Next receipt"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Icon name="mdi:chevron-right" class="w-5 h-5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="modal-footer">
|
|
|
|
|
|
<button @click="closeModal" class="btn btn-ghost">
|
|
|
|
|
|
Close
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-if="currentReceipt"
|
|
|
|
|
|
@click="downloadReceipt"
|
|
|
|
|
|
class="btn btn-outline"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Icon name="mdi:download" class="w-4 h-4" />
|
|
|
|
|
|
Download
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-if="currentReceipt"
|
|
|
|
|
|
@click="openInNewTab"
|
|
|
|
|
|
class="btn btn-primary"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Icon name="mdi:open-in-new" class="w-4 h-4" />
|
|
|
|
|
|
Open in New Tab
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { ref, computed, watch } from 'vue';
|
|
|
|
|
|
import type { ExpenseReceipt } from '@/utils/types';
|
|
|
|
|
|
|
|
|
|
|
|
// Props
|
|
|
|
|
|
interface Props {
|
|
|
|
|
|
modelValue: boolean;
|
|
|
|
|
|
receipts: ExpenseReceipt[];
|
|
|
|
|
|
currentIndex?: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
|
|
|
|
currentIndex: 0
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Emits
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
|
'update:modelValue': [value: boolean];
|
|
|
|
|
|
'update:currentIndex': [index: number];
|
|
|
|
|
|
}>();
|
|
|
|
|
|
|
|
|
|
|
|
// Reactive state
|
|
|
|
|
|
const imageLoading = ref(true);
|
|
|
|
|
|
const imageError = ref(false);
|
|
|
|
|
|
|
|
|
|
|
|
// Computed
|
|
|
|
|
|
const currentReceipt = computed(() => {
|
|
|
|
|
|
return props.receipts[props.currentIndex] || null;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Methods
|
|
|
|
|
|
const closeModal = () => {
|
|
|
|
|
|
emit('update:modelValue', false);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleImageLoad = () => {
|
|
|
|
|
|
imageLoading.value = false;
|
|
|
|
|
|
imageError.value = false;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleImageError = () => {
|
|
|
|
|
|
imageLoading.value = false;
|
|
|
|
|
|
imageError.value = true;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const retryLoad = () => {
|
|
|
|
|
|
imageLoading.value = true;
|
|
|
|
|
|
imageError.value = false;
|
|
|
|
|
|
|
|
|
|
|
|
// Force reload by updating the image src
|
|
|
|
|
|
const img = document.querySelector('.receipt-image') as HTMLImageElement;
|
|
|
|
|
|
if (img && currentReceipt.value) {
|
|
|
|
|
|
const url = currentReceipt.value.signedUrl || currentReceipt.value.url;
|
|
|
|
|
|
img.src = url + '?t=' + Date.now(); // Add timestamp to force reload
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const previousReceipt = () => {
|
|
|
|
|
|
if (props.currentIndex > 0) {
|
|
|
|
|
|
emit('update:currentIndex', props.currentIndex - 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const nextReceipt = () => {
|
|
|
|
|
|
if (props.currentIndex < props.receipts.length - 1) {
|
|
|
|
|
|
emit('update:currentIndex', props.currentIndex + 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const goToReceipt = (index: number) => {
|
|
|
|
|
|
emit('update:currentIndex', index);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const formatFileSize = (bytes: number) => {
|
|
|
|
|
|
if (!bytes) return 'Unknown size';
|
|
|
|
|
|
|
|
|
|
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
|
|
|
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
|
|
|
|
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const downloadReceipt = () => {
|
|
|
|
|
|
if (!currentReceipt.value?.signedUrl) return;
|
|
|
|
|
|
|
|
|
|
|
|
const link = document.createElement('a');
|
|
|
|
|
|
link.href = currentReceipt.value.signedUrl;
|
|
|
|
|
|
link.download = currentReceipt.value.title || `receipt-${props.currentIndex + 1}`;
|
|
|
|
|
|
link.target = '_blank';
|
|
|
|
|
|
document.body.appendChild(link);
|
|
|
|
|
|
link.click();
|
|
|
|
|
|
document.body.removeChild(link);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const openInNewTab = () => {
|
|
|
|
|
|
if (!currentReceipt.value?.signedUrl) return;
|
|
|
|
|
|
|
|
|
|
|
|
window.open(currentReceipt.value.signedUrl, '_blank');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Watch for receipt changes
|
|
|
|
|
|
watch(() => props.currentIndex, () => {
|
|
|
|
|
|
imageLoading.value = true;
|
|
|
|
|
|
imageError.value = false;
|
|
|
|
|
|
}, { immediate: true });
|
|
|
|
|
|
|
|
|
|
|
|
// Keyboard navigation
|
|
|
|
|
|
const handleKeydown = (event: KeyboardEvent) => {
|
|
|
|
|
|
if (!props.modelValue) return;
|
|
|
|
|
|
|
|
|
|
|
|
switch (event.key) {
|
|
|
|
|
|
case 'Escape':
|
|
|
|
|
|
closeModal();
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'ArrowLeft':
|
|
|
|
|
|
previousReceipt();
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'ArrowRight':
|
|
|
|
|
|
nextReceipt();
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Add keyboard event listener when modal is open
|
|
|
|
|
|
watch(() => props.modelValue, (isOpen) => {
|
|
|
|
|
|
if (isOpen) {
|
|
|
|
|
|
document.addEventListener('keydown', handleKeydown);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
document.removeEventListener('keydown', handleKeydown);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.modal-overlay {
|
|
|
|
|
|
@apply fixed inset-0 bg-black/80 flex items-center justify-center p-4 z-50;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.modal-content {
|
2025-07-04 16:44:42 +02:00
|
|
|
|
@apply bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden mx-2 sm:mx-4;
|
2025-07-03 21:29:42 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.modal-header {
|
|
|
|
|
|
@apply flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.modal-title {
|
|
|
|
|
|
@apply text-lg font-semibold text-gray-900 dark:text-white;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn-close {
|
|
|
|
|
|
@apply p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.modal-body {
|
|
|
|
|
|
@apply p-4 overflow-y-auto;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.receipt-viewer {
|
|
|
|
|
|
@apply space-y-4;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.receipt-image-container {
|
|
|
|
|
|
@apply relative bg-gray-100 dark:bg-gray-700 rounded-lg overflow-hidden;
|
|
|
|
|
|
min-height: 400px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.receipt-image {
|
|
|
|
|
|
@apply w-full h-auto max-h-[60vh] object-contain;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.loading-overlay {
|
|
|
|
|
|
@apply absolute inset-0 flex items-center justify-center bg-gray-100 dark:bg-gray-700;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.error-overlay {
|
|
|
|
|
|
@apply absolute inset-0 flex flex-col items-center justify-center bg-gray-100 dark:bg-gray-700 text-center p-8;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.receipt-info {
|
|
|
|
|
|
@apply grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.info-item {
|
|
|
|
|
|
@apply space-y-1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.info-label {
|
|
|
|
|
|
@apply text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.info-value {
|
|
|
|
|
|
@apply text-sm text-gray-900 dark:text-white;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.navigation-controls {
|
|
|
|
|
|
@apply flex items-center justify-center gap-4 p-4;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.nav-btn {
|
|
|
|
|
|
@apply p-2 rounded-full bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.receipt-dots {
|
|
|
|
|
|
@apply flex items-center gap-2;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.dot {
|
|
|
|
|
|
@apply w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.dot.active {
|
|
|
|
|
|
@apply bg-primary;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.modal-footer {
|
|
|
|
|
|
@apply flex items-center justify-end gap-3 p-4 border-t border-gray-200 dark:border-gray-700;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn {
|
|
|
|
|
|
@apply inline-flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn-ghost {
|
|
|
|
|
|
@apply text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn-outline {
|
|
|
|
|
|
@apply border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn-primary {
|
|
|
|
|
|
@apply bg-primary text-white hover:bg-primary/90;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn-sm {
|
|
|
|
|
|
@apply px-3 py-1 text-sm;
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|