port-nimara-client-portal/components/ExpenseDetailsModal.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-3">
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>