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

361 lines
9.5 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>
<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>