407 lines
11 KiB
Vue
407 lines
11 KiB
Vue
<template>
|
|
<div v-if="modelValue && expense" class="modal-overlay" @click.self="closeModal">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h3 class="modal-title">Expense Details</h3>
|
|
<button @click="closeModal" class="btn-close">
|
|
<Icon name="mdi:close" class="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div class="modal-body">
|
|
<!-- Establishment & Price -->
|
|
<div class="expense-header">
|
|
<div class="establishment-name">
|
|
{{ expense['Establishment Name'] || 'Unknown Establishment' }}
|
|
</div>
|
|
<div class="price-amount">
|
|
{{ expense.DisplayPrice || expense.Price }}
|
|
</div>
|
|
<div v-if="expense.ConversionRate && expense.ConversionRate !== 1" class="conversion-info">
|
|
<span class="text-sm text-gray-600 dark:text-gray-400">
|
|
Rate: {{ expense.ConversionRate }} | USD: {{ expense.DisplayPriceUSD }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Meta Information -->
|
|
<div class="expense-meta">
|
|
<div class="meta-item">
|
|
<Icon name="mdi:account" class="w-4 h-4 text-gray-500" />
|
|
<span class="meta-label">Paid by:</span>
|
|
<span class="meta-value">{{ expense.Payer || 'Unknown' }}</span>
|
|
</div>
|
|
|
|
<div class="meta-item">
|
|
<Icon name="mdi:tag" class="w-4 h-4 text-gray-500" />
|
|
<span class="meta-label">Category:</span>
|
|
<div class="category-badge" :class="`category-${expense.Category?.toLowerCase()}`">
|
|
{{ expense.Category }}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="meta-item">
|
|
<Icon
|
|
:name="expense['Payment Method'] === 'Card' ? 'mdi:credit-card' : 'mdi:cash'"
|
|
class="w-4 h-4 text-gray-500"
|
|
/>
|
|
<span class="meta-label">Payment:</span>
|
|
<span class="meta-value">{{ expense['Payment Method'] }}</span>
|
|
</div>
|
|
|
|
<div class="meta-item">
|
|
<Icon name="mdi:calendar" class="w-4 h-4 text-gray-500" />
|
|
<span class="meta-label">Date:</span>
|
|
<span class="meta-value">{{ formatDateTime(expense.Time) }}</span>
|
|
</div>
|
|
|
|
<div class="meta-item">
|
|
<Icon name="mdi:check-circle" class="w-4 h-4 text-gray-500" />
|
|
<span class="meta-label">Status:</span>
|
|
<span :class="['meta-value', expense.Paid ? 'text-green-600' : 'text-yellow-600']">
|
|
{{ expense.Paid ? 'Paid' : 'Pending' }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Description -->
|
|
<div v-if="expense.Contents" class="expense-description">
|
|
<h4 class="section-title">
|
|
<Icon name="mdi:text" class="w-4 h-4" />
|
|
Description
|
|
</h4>
|
|
<div class="description-content">
|
|
{{ expense.Contents }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Receipt Images -->
|
|
<div v-if="expense.Receipt && expense.Receipt.length > 0" class="expense-receipts">
|
|
<h4 class="section-title">
|
|
<Icon name="mdi:receipt" class="w-4 h-4" />
|
|
Receipt{{ expense.Receipt.length > 1 ? 's' : '' }} ({{ expense.Receipt.length }})
|
|
</h4>
|
|
|
|
<div class="receipts-grid">
|
|
<div
|
|
v-for="(receipt, index) in expense.Receipt"
|
|
:key="receipt.id || index"
|
|
class="receipt-item"
|
|
@click="openReceiptModal(receipt, index)"
|
|
>
|
|
<LazyReceiptImage
|
|
:receipt="receipt"
|
|
:alt="`Receipt ${index + 1} for ${expense['Establishment Name']}`"
|
|
:use-small="true"
|
|
class="receipt-thumbnail"
|
|
/>
|
|
|
|
<div class="receipt-info">
|
|
<div class="receipt-name">{{ receipt.title || `Receipt ${index + 1}` }}</div>
|
|
<div class="receipt-size">{{ formatFileSize(receipt.size) }}</div>
|
|
</div>
|
|
|
|
<button class="receipt-action" title="Open in new tab">
|
|
<Icon name="mdi:open-in-new" class="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- System Information -->
|
|
<div class="system-info">
|
|
<h4 class="section-title">
|
|
<Icon name="mdi:information" class="w-4 h-4" />
|
|
System Information
|
|
</h4>
|
|
|
|
<div class="info-grid">
|
|
<div class="info-item">
|
|
<span class="info-label">Expense ID:</span>
|
|
<span class="info-value">{{ expense.Id }}</span>
|
|
</div>
|
|
|
|
<div class="info-item">
|
|
<span class="info-label">Created:</span>
|
|
<span class="info-value">{{ formatDateTime(expense.CreatedAt) }}</span>
|
|
</div>
|
|
|
|
<div class="info-item">
|
|
<span class="info-label">Updated:</span>
|
|
<span class="info-value">{{ formatDateTime(expense.UpdatedAt) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-footer">
|
|
<button @click="closeModal" class="btn btn-ghost">
|
|
Close
|
|
</button>
|
|
|
|
<button
|
|
v-if="expense.Receipt && expense.Receipt.length > 0"
|
|
@click="downloadAllReceipts"
|
|
class="btn btn-outline"
|
|
>
|
|
<Icon name="mdi:download" class="w-4 h-4" />
|
|
Download Receipts
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Receipt Viewer Modal -->
|
|
<ReceiptViewerModal
|
|
v-model="showReceiptViewer"
|
|
:receipts="expense?.Receipt || []"
|
|
:current-index="currentReceiptIndex"
|
|
@update:current-index="currentReceiptIndex = $event"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed } from 'vue';
|
|
import type { Expense, ExpenseReceipt } from '@/utils/types';
|
|
|
|
// Component imports
|
|
const LazyReceiptImage = defineAsyncComponent(() => import('@/components/LazyReceiptImage.vue'));
|
|
const ReceiptViewerModal = defineAsyncComponent(() => import('@/components/ReceiptViewerModal.vue'));
|
|
|
|
// Props
|
|
interface Props {
|
|
modelValue: boolean;
|
|
expense: Expense | null;
|
|
}
|
|
|
|
const props = defineProps<Props>();
|
|
|
|
// Emits
|
|
const emit = defineEmits<{
|
|
'update:modelValue': [value: boolean];
|
|
}>();
|
|
|
|
// Reactive state
|
|
const showReceiptViewer = ref(false);
|
|
const currentReceiptIndex = ref(0);
|
|
|
|
// Methods
|
|
const closeModal = () => {
|
|
emit('update:modelValue', false);
|
|
};
|
|
|
|
const formatDateTime = (dateString: string) => {
|
|
if (!dateString) return 'N/A';
|
|
|
|
try {
|
|
const date = new Date(dateString);
|
|
return date.toLocaleString('en-US', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
} catch {
|
|
return dateString;
|
|
}
|
|
};
|
|
|
|
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 openReceiptModal = (receipt: ExpenseReceipt, index: number) => {
|
|
currentReceiptIndex.value = index;
|
|
showReceiptViewer.value = true;
|
|
};
|
|
|
|
const downloadAllReceipts = async () => {
|
|
if (!props.expense?.Receipt?.length) return;
|
|
|
|
try {
|
|
for (const [index, receipt] of props.expense.Receipt.entries()) {
|
|
if (receipt.signedUrl) {
|
|
// Create download link
|
|
const link = document.createElement('a');
|
|
link.href = receipt.signedUrl;
|
|
link.download = receipt.title || `receipt-${props.expense.Id}-${index + 1}`;
|
|
link.target = '_blank';
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
|
|
// Small delay between downloads
|
|
if (index < props.expense.Receipt.length - 1) {
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('[ExpenseDetailsModal] Error downloading receipts:', error);
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.modal-overlay {
|
|
@apply fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50;
|
|
}
|
|
|
|
.modal-content {
|
|
@apply bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden mx-4 sm:mx-0;
|
|
}
|
|
|
|
.modal-header {
|
|
@apply flex items-center justify-between p-6 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-6 space-y-6 overflow-y-auto;
|
|
}
|
|
|
|
.expense-header {
|
|
@apply text-center space-y-2 pb-4 border-b border-gray-200 dark:border-gray-700;
|
|
}
|
|
|
|
.establishment-name {
|
|
@apply text-xl font-bold text-gray-900 dark:text-white;
|
|
}
|
|
|
|
.price-amount {
|
|
@apply text-3xl font-bold text-primary;
|
|
}
|
|
|
|
.expense-meta {
|
|
@apply space-y-3;
|
|
}
|
|
|
|
.meta-item {
|
|
@apply flex items-center gap-3;
|
|
}
|
|
|
|
.meta-label {
|
|
@apply text-sm font-medium text-gray-600 dark:text-gray-400 min-w-20;
|
|
}
|
|
|
|
.meta-value {
|
|
@apply text-sm text-gray-900 dark:text-white;
|
|
}
|
|
|
|
.category-badge {
|
|
@apply px-2 py-1 rounded-full text-xs font-medium;
|
|
}
|
|
|
|
.category-food\/drinks {
|
|
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200;
|
|
}
|
|
|
|
.category-shop {
|
|
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200;
|
|
}
|
|
|
|
.category-online {
|
|
@apply bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200;
|
|
}
|
|
|
|
.category-other {
|
|
@apply bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200;
|
|
}
|
|
|
|
.section-title {
|
|
@apply flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-700 pb-2;
|
|
}
|
|
|
|
.expense-description {
|
|
@apply space-y-3;
|
|
}
|
|
|
|
.description-content {
|
|
@apply text-sm text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-700 rounded-lg p-4;
|
|
}
|
|
|
|
.expense-receipts {
|
|
@apply space-y-3;
|
|
}
|
|
|
|
.receipts-grid {
|
|
@apply grid grid-cols-1 sm:grid-cols-2 gap-4;
|
|
}
|
|
|
|
.receipt-item {
|
|
@apply relative bg-gray-50 dark:bg-gray-700 rounded-lg p-3 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors cursor-pointer;
|
|
}
|
|
|
|
.receipt-thumbnail {
|
|
@apply w-full h-24 rounded object-cover mb-2;
|
|
}
|
|
|
|
.receipt-info {
|
|
@apply space-y-1;
|
|
}
|
|
|
|
.receipt-name {
|
|
@apply text-sm font-medium text-gray-900 dark:text-white truncate;
|
|
}
|
|
|
|
.receipt-size {
|
|
@apply text-xs text-gray-500 dark:text-gray-400;
|
|
}
|
|
|
|
.receipt-action {
|
|
@apply absolute top-2 right-2 p-1 bg-white dark:bg-gray-800 rounded-full shadow-sm opacity-0 group-hover:opacity-100 transition-opacity;
|
|
}
|
|
|
|
.receipt-item:hover .receipt-action {
|
|
@apply opacity-100;
|
|
}
|
|
|
|
.system-info {
|
|
@apply space-y-3;
|
|
}
|
|
|
|
.info-grid {
|
|
@apply grid grid-cols-1 sm:grid-cols-3 gap-4;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.modal-footer {
|
|
@apply flex items-center justify-end gap-3 p-6 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;
|
|
}
|
|
</style>
|