port-nimara-client-portal/components/ReceiptViewerModal.vue

361 lines
9.5 KiB
Vue
Raw Normal View History

<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 {
@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;
}
.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>