485 lines
13 KiB
Vue
485 lines
13 KiB
Vue
<template>
|
|
<v-dialog
|
|
v-model="dialog"
|
|
max-width="800"
|
|
persistent
|
|
scrollable
|
|
>
|
|
<v-card v-if="expense">
|
|
<v-card-title class="d-flex align-center">
|
|
<v-icon class="mr-2">mdi-receipt-text</v-icon>
|
|
<span>Expense Details</span>
|
|
<v-spacer />
|
|
<v-btn
|
|
@click="closeModal"
|
|
icon="mdi-close"
|
|
variant="text"
|
|
size="small"
|
|
/>
|
|
</v-card-title>
|
|
|
|
<v-card-text>
|
|
<!-- Establishment & Price -->
|
|
<div class="expense-header mb-6">
|
|
<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-caption text-grey-darken-1">
|
|
Rate: {{ expense.ConversionRate }} | USD: {{ expense.DisplayPriceUSD }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Meta Information -->
|
|
<div class="expense-meta mb-6">
|
|
<v-row>
|
|
<v-col cols="12" md="6">
|
|
<div class="meta-item">
|
|
<v-icon color="grey-darken-1" size="small" class="mr-2">mdi-account</v-icon>
|
|
<span class="meta-label">Paid by:</span>
|
|
<span class="meta-value">{{ expense.Payer || 'Unknown' }}</span>
|
|
</div>
|
|
</v-col>
|
|
|
|
<v-col cols="12" md="6">
|
|
<div class="meta-item">
|
|
<v-icon color="grey-darken-1" size="small" class="mr-2">mdi-tag</v-icon>
|
|
<span class="meta-label">Category:</span>
|
|
<v-chip
|
|
:color="getCategoryColor(expense.Category)"
|
|
size="small"
|
|
variant="tonal"
|
|
>
|
|
{{ expense.Category }}
|
|
</v-chip>
|
|
</div>
|
|
</v-col>
|
|
|
|
<v-col cols="12" md="6">
|
|
<div class="meta-item">
|
|
<v-icon
|
|
:icon="expense['Payment Method'] === 'Card' ? 'mdi-credit-card' : 'mdi-cash'"
|
|
color="grey-darken-1"
|
|
size="small"
|
|
class="mr-2"
|
|
/>
|
|
<span class="meta-label">Payment:</span>
|
|
<span class="meta-value">{{ expense['Payment Method'] }}</span>
|
|
</div>
|
|
</v-col>
|
|
|
|
<v-col cols="12" md="6">
|
|
<div class="meta-item">
|
|
<v-icon color="grey-darken-1" size="small" class="mr-2">mdi-calendar</v-icon>
|
|
<span class="meta-label">Date:</span>
|
|
<span class="meta-value">{{ formatDateTime(expense.Time) }}</span>
|
|
</div>
|
|
</v-col>
|
|
|
|
<v-col cols="12" md="6">
|
|
<div class="meta-item">
|
|
<v-icon color="grey-darken-1" size="small" class="mr-2">mdi-check-circle</v-icon>
|
|
<span class="meta-label">Status:</span>
|
|
<v-chip
|
|
:color="expense.Paid ? 'success' : 'warning'"
|
|
size="small"
|
|
variant="tonal"
|
|
>
|
|
{{ expense.Paid ? 'Paid' : 'Pending' }}
|
|
</v-chip>
|
|
</div>
|
|
</v-col>
|
|
</v-row>
|
|
</div>
|
|
|
|
<!-- Description -->
|
|
<div v-if="expense.Contents" class="expense-description mb-6">
|
|
<h4 class="section-title mb-3">
|
|
<v-icon size="small" class="mr-2">mdi-text</v-icon>
|
|
Description
|
|
</h4>
|
|
|
|
<v-card variant="tonal" class="pa-4">
|
|
<div class="description-content">
|
|
{{ expense.Contents }}
|
|
</div>
|
|
</v-card>
|
|
</div>
|
|
|
|
<!-- Receipt Images -->
|
|
<div v-if="expense.Receipt && expense.Receipt.length > 0" class="expense-receipts mb-6">
|
|
<h4 class="section-title mb-3">
|
|
<v-icon size="small" class="mr-2">mdi-receipt</v-icon>
|
|
Receipt{{ expense.Receipt.length > 1 ? 's' : '' }} ({{ expense.Receipt.length }})
|
|
</h4>
|
|
|
|
<v-row>
|
|
<v-col
|
|
v-for="(receipt, index) in expense.Receipt"
|
|
:key="receipt.id || index"
|
|
cols="12"
|
|
sm="6"
|
|
md="4"
|
|
>
|
|
<v-card
|
|
class="receipt-item"
|
|
@click="openReceiptModal(receipt, index)"
|
|
hover
|
|
>
|
|
<div class="receipt-image-container">
|
|
<LazyReceiptImage
|
|
:receipt="receipt"
|
|
:alt="`Receipt ${index + 1} for ${expense['Establishment Name']}`"
|
|
:use-small="true"
|
|
class="receipt-thumbnail"
|
|
/>
|
|
|
|
<v-btn
|
|
icon="mdi-open-in-new"
|
|
size="small"
|
|
variant="elevated"
|
|
color="white"
|
|
class="receipt-action"
|
|
@click.stop="openReceiptInNewTab(receipt)"
|
|
/>
|
|
</div>
|
|
|
|
<v-card-text>
|
|
<div class="receipt-name">{{ receipt.title || `Receipt ${index + 1}` }}</div>
|
|
<div class="receipt-size">{{ formatFileSize(receipt.size) }}</div>
|
|
</v-card-text>
|
|
</v-card>
|
|
</v-col>
|
|
</v-row>
|
|
</div>
|
|
|
|
<!-- System Information -->
|
|
<div class="system-info">
|
|
<h4 class="section-title mb-3">
|
|
<v-icon size="small" class="mr-2">mdi-information</v-icon>
|
|
System Information
|
|
</h4>
|
|
|
|
<v-row>
|
|
<v-col cols="12" md="4">
|
|
<div class="info-item">
|
|
<span class="info-label">Expense ID:</span>
|
|
<span class="info-value">{{ expense.Id }}</span>
|
|
</div>
|
|
</v-col>
|
|
|
|
<v-col cols="12" md="4">
|
|
<div class="info-item">
|
|
<span class="info-label">Created:</span>
|
|
<span class="info-value">{{ formatDateTime(expense.CreatedAt) }}</span>
|
|
</div>
|
|
</v-col>
|
|
|
|
<v-col cols="12" md="4">
|
|
<div class="info-item">
|
|
<span class="info-label">Updated:</span>
|
|
<span class="info-value">{{ formatDateTime(expense.UpdatedAt) }}</span>
|
|
</div>
|
|
</v-col>
|
|
</v-row>
|
|
</div>
|
|
</v-card-text>
|
|
|
|
<v-card-actions class="px-6 pb-4">
|
|
<v-btn
|
|
@click="editExpense"
|
|
color="primary"
|
|
variant="outlined"
|
|
>
|
|
<v-icon class="mr-1">mdi-pencil</v-icon>
|
|
Edit Expense
|
|
</v-btn>
|
|
|
|
<v-spacer />
|
|
|
|
<v-btn
|
|
@click="closeModal"
|
|
variant="text"
|
|
>
|
|
Close
|
|
</v-btn>
|
|
|
|
<v-btn
|
|
v-if="expense.Receipt && expense.Receipt.length > 0"
|
|
@click="downloadAllReceipts"
|
|
color="primary"
|
|
variant="outlined"
|
|
>
|
|
<v-icon class="mr-1">mdi-download</v-icon>
|
|
Download Receipts
|
|
</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
|
|
<!-- Receipt Viewer Modal -->
|
|
<ReceiptViewerModal
|
|
v-model="showReceiptViewer"
|
|
:receipts="expense?.Receipt || []"
|
|
:current-index="currentReceiptIndex"
|
|
@update:current-index="currentReceiptIndex = $event"
|
|
/>
|
|
</v-dialog>
|
|
</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];
|
|
}>();
|
|
|
|
// Computed
|
|
const dialog = computed({
|
|
get: () => props.modelValue,
|
|
set: (value) => emit('update:modelValue', value)
|
|
});
|
|
|
|
// Reactive state
|
|
const showReceiptViewer = ref(false);
|
|
const currentReceiptIndex = ref(0);
|
|
|
|
// Methods
|
|
const closeModal = () => {
|
|
dialog.value = 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 getCategoryColor = (category: string) => {
|
|
const categoryColors: Record<string, string> = {
|
|
'Food/Drinks': 'green',
|
|
'Shop': 'blue',
|
|
'Online': 'purple',
|
|
'Transportation': 'orange',
|
|
'Accommodation': 'teal',
|
|
'Entertainment': 'pink',
|
|
'Other': 'grey'
|
|
};
|
|
|
|
return categoryColors[category] || 'grey';
|
|
};
|
|
|
|
const openReceiptModal = (receipt: ExpenseReceipt, index: number) => {
|
|
currentReceiptIndex.value = index;
|
|
showReceiptViewer.value = true;
|
|
};
|
|
|
|
const openReceiptInNewTab = (receipt: ExpenseReceipt) => {
|
|
if (receipt.signedUrl) {
|
|
window.open(receipt.signedUrl, '_blank');
|
|
}
|
|
};
|
|
|
|
const editExpense = () => {
|
|
// For now, just show a message that editing is not yet implemented
|
|
// In a real implementation, this would open an edit modal or switch to edit mode
|
|
alert('Expense editing functionality is coming soon! Please contact support if you need to make changes.');
|
|
};
|
|
|
|
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>
|
|
.expense-header {
|
|
text-align: center;
|
|
padding-bottom: 16px;
|
|
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
|
}
|
|
|
|
.establishment-name {
|
|
font-size: 1.25rem;
|
|
font-weight: 700;
|
|
color: rgb(var(--v-theme-on-surface));
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.price-amount {
|
|
font-size: 2rem;
|
|
font-weight: 700;
|
|
color: rgb(var(--v-theme-primary));
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.conversion-info {
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.meta-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.meta-label {
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
min-width: 80px;
|
|
}
|
|
|
|
.meta-value {
|
|
font-size: 0.875rem;
|
|
color: rgb(var(--v-theme-on-surface));
|
|
}
|
|
|
|
.section-title {
|
|
display: flex;
|
|
align-items: center;
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
color: rgb(var(--v-theme-on-surface));
|
|
padding-bottom: 8px;
|
|
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
|
}
|
|
|
|
.description-content {
|
|
font-size: 0.875rem;
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
line-height: 1.5;
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
.receipt-item {
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
height: 100%;
|
|
}
|
|
|
|
.receipt-item:hover {
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.receipt-image-container {
|
|
position: relative;
|
|
height: 120px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.receipt-thumbnail {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.receipt-action {
|
|
position: absolute;
|
|
top: 8px;
|
|
right: 8px;
|
|
opacity: 0;
|
|
transition: opacity 0.3s ease;
|
|
}
|
|
|
|
.receipt-item:hover .receipt-action {
|
|
opacity: 1;
|
|
}
|
|
|
|
.receipt-name {
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
color: rgb(var(--v-theme-on-surface));
|
|
margin-bottom: 4px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.receipt-size {
|
|
font-size: 0.75rem;
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
}
|
|
|
|
.info-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.info-label {
|
|
font-size: 0.75rem;
|
|
font-weight: 500;
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.info-value {
|
|
font-size: 0.875rem;
|
|
color: rgb(var(--v-theme-on-surface));
|
|
}
|
|
|
|
.v-card-actions {
|
|
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
|
}
|
|
</style>
|