461 lines
11 KiB
Vue
461 lines
11 KiB
Vue
<template>
|
|
<div class="expense-list">
|
|
<!-- Bulk Selection Header -->
|
|
<div v-if="expenses.length > 0" class="d-flex align-center justify-space-between mb-4">
|
|
<div class="d-flex align-center">
|
|
<v-checkbox
|
|
:model-value="isAllSelected"
|
|
:indeterminate="isSomeSelected"
|
|
@update:model-value="toggleSelectAll"
|
|
hide-details
|
|
density="compact"
|
|
class="mr-2"
|
|
/>
|
|
<span class="text-body-2 font-weight-medium">
|
|
{{ selectedExpenses.length > 0 ? `${selectedExpenses.length} selected` : 'Select all' }}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="text-body-2 text-grey-darken-1">
|
|
{{ expenses.length }} expenses • €{{ totalAmount.toFixed(2) }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Expense Grid -->
|
|
<v-row class="expense-grid">
|
|
<v-col
|
|
v-for="expense in expenses"
|
|
:key="expense.Id"
|
|
cols="12"
|
|
sm="6"
|
|
lg="4"
|
|
xl="3"
|
|
>
|
|
<v-card
|
|
:class="{ 'selected-card': isSelected(expense.Id) }"
|
|
class="expense-card"
|
|
elevation="2"
|
|
@click="selectExpense(expense.Id)"
|
|
>
|
|
<!-- Selection Checkbox -->
|
|
<div class="card-checkbox">
|
|
<v-checkbox
|
|
:model-value="isSelected(expense.Id)"
|
|
@update:model-value="toggleExpense(expense.Id)"
|
|
@click.stop
|
|
hide-details
|
|
density="compact"
|
|
color="primary"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Receipt Image Section -->
|
|
<div class="receipt-section">
|
|
<div
|
|
v-if="expense.Receipt && expense.Receipt.length > 0"
|
|
class="receipt-image-container"
|
|
@click.stop="$emit('expense-clicked', expense)"
|
|
>
|
|
<LazyReceiptImage
|
|
:receipt="expense.Receipt[0]"
|
|
:alt="`Receipt for ${expense['Establishment Name']}`"
|
|
class="receipt-image"
|
|
/>
|
|
|
|
<!-- Multiple receipts indicator -->
|
|
<v-chip
|
|
v-if="expense.Receipt.length > 1"
|
|
size="x-small"
|
|
color="primary"
|
|
class="receipt-count-chip"
|
|
>
|
|
+{{ expense.Receipt.length - 1 }}
|
|
</v-chip>
|
|
</div>
|
|
|
|
<!-- No receipt placeholder -->
|
|
<div
|
|
v-else
|
|
class="no-receipt-placeholder"
|
|
@click.stop="$emit('expense-clicked', expense)"
|
|
>
|
|
<v-icon size="48" color="grey-lighten-1">mdi-receipt-text-outline</v-icon>
|
|
<span class="text-caption text-grey">No receipt</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Expense Details -->
|
|
<v-card-text @click.stop="$emit('expense-clicked', expense)">
|
|
<!-- Merchant Name -->
|
|
<div class="merchant-name">
|
|
{{ expense['Establishment Name'] || 'Unknown Merchant' }}
|
|
</div>
|
|
|
|
<!-- Price -->
|
|
<div class="price-display">
|
|
{{ expense.Price }}
|
|
<span v-if="expense.PriceUSD && expense.PriceUSD !== expense.PriceNumber" class="converted-price">
|
|
(≈ ${{ expense.PriceUSD.toFixed(2) }})
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Category and Payment Method -->
|
|
<div class="d-flex align-center justify-space-between my-2">
|
|
<v-chip
|
|
:color="getCategoryColor(expense.Category)"
|
|
size="small"
|
|
variant="tonal"
|
|
>
|
|
{{ expense.Category || 'Other' }}
|
|
</v-chip>
|
|
|
|
<div class="d-flex align-center">
|
|
<v-icon
|
|
:icon="expense['Payment Method'] === 'Card' ? 'mdi-credit-card' : 'mdi-cash'"
|
|
size="small"
|
|
class="mr-1"
|
|
/>
|
|
<span class="text-caption">{{ expense['Payment Method'] || 'Unknown' }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Date and Time -->
|
|
<div class="date-display">
|
|
<v-icon size="small" class="mr-1">mdi-calendar-clock</v-icon>
|
|
{{ formatDateTime(expense.Time) }}
|
|
</div>
|
|
|
|
<!-- Payer -->
|
|
<div v-if="expense.Payer" class="payer-display">
|
|
<v-icon size="small" class="mr-1">mdi-account</v-icon>
|
|
{{ expense.Payer }}
|
|
</div>
|
|
|
|
<!-- Contents Preview -->
|
|
<div v-if="expense.Contents" class="contents-preview">
|
|
{{ getContentsPreview(expense) }}
|
|
</div>
|
|
</v-card-text>
|
|
|
|
<!-- Quick Actions -->
|
|
<div class="card-actions">
|
|
<v-btn
|
|
@click.stop="$emit('expense-clicked', expense)"
|
|
icon="mdi-eye"
|
|
size="small"
|
|
variant="elevated"
|
|
color="white"
|
|
class="action-button"
|
|
/>
|
|
|
|
<v-btn
|
|
v-if="expense.Receipt && expense.Receipt.length > 0"
|
|
@click.stop="openReceipt(expense.Receipt[0])"
|
|
icon="mdi-image"
|
|
size="small"
|
|
variant="elevated"
|
|
color="white"
|
|
class="action-button"
|
|
/>
|
|
</div>
|
|
</v-card>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<!-- Loading More Indicator -->
|
|
<div v-if="isLoadingMore" class="text-center py-6">
|
|
<v-progress-circular indeterminate color="primary" />
|
|
</div>
|
|
|
|
<!-- Empty State -->
|
|
<v-card v-if="expenses.length === 0" class="text-center py-12">
|
|
<v-card-text>
|
|
<v-icon size="64" color="grey-lighten-2" class="mb-4">mdi-receipt</v-icon>
|
|
<h3 class="text-h6 mb-2">No expenses found</h3>
|
|
<p class="text-body-2 text-grey-darken-1">
|
|
No expenses match the current filters
|
|
</p>
|
|
</v-card-text>
|
|
</v-card>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed } from 'vue';
|
|
import type { Expense } from '@/utils/types';
|
|
|
|
// Component imports
|
|
const LazyReceiptImage = defineAsyncComponent(() => import('@/components/LazyReceiptImage.vue'));
|
|
|
|
// Props
|
|
interface Props {
|
|
expenses: Expense[];
|
|
selectedExpenses: number[];
|
|
isLoadingMore?: boolean;
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
isLoadingMore: false
|
|
});
|
|
|
|
// Emits
|
|
const emit = defineEmits<{
|
|
'update:selected': [expenseIds: number[]];
|
|
'expense-clicked': [expense: Expense];
|
|
}>();
|
|
|
|
// Computed
|
|
const totalAmount = computed(() => {
|
|
return props.expenses.reduce((sum, expense) => sum + (expense.PriceNumber || 0), 0);
|
|
});
|
|
|
|
const isAllSelected = computed(() => {
|
|
return props.expenses.length > 0 && props.expenses.every(expense =>
|
|
props.selectedExpenses.includes(expense.Id)
|
|
);
|
|
});
|
|
|
|
const isSomeSelected = computed(() => {
|
|
return props.selectedExpenses.length > 0 && !isAllSelected.value;
|
|
});
|
|
|
|
// Methods
|
|
const isSelected = (expenseId: number) => {
|
|
return props.selectedExpenses.includes(expenseId);
|
|
};
|
|
|
|
const selectExpense = (expenseId: number) => {
|
|
// Toggle selection on card click
|
|
toggleExpense(expenseId);
|
|
};
|
|
|
|
const toggleExpense = (expenseId: number) => {
|
|
const currentSelected = [...props.selectedExpenses];
|
|
const index = currentSelected.indexOf(expenseId);
|
|
|
|
if (index > -1) {
|
|
currentSelected.splice(index, 1);
|
|
} else {
|
|
currentSelected.push(expenseId);
|
|
}
|
|
|
|
emit('update:selected', currentSelected);
|
|
};
|
|
|
|
const toggleSelectAll = () => {
|
|
if (isAllSelected.value) {
|
|
// Deselect all
|
|
const currentSelected = props.selectedExpenses.filter(id =>
|
|
!props.expenses.some(expense => expense.Id === id)
|
|
);
|
|
emit('update:selected', currentSelected);
|
|
} else {
|
|
// Select all
|
|
const currentSelected = [...props.selectedExpenses];
|
|
props.expenses.forEach(expense => {
|
|
if (!currentSelected.includes(expense.Id)) {
|
|
currentSelected.push(expense.Id);
|
|
}
|
|
});
|
|
emit('update:selected', currentSelected);
|
|
}
|
|
};
|
|
|
|
const formatDateTime = (dateString: string) => {
|
|
try {
|
|
const date = new Date(dateString);
|
|
return date.toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
} catch {
|
|
return dateString;
|
|
}
|
|
};
|
|
|
|
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 getContentsPreview = (expense: Expense) => {
|
|
const content = expense.Contents || '';
|
|
return content.length > 80 ? content.substring(0, 80) + '...' : content;
|
|
};
|
|
|
|
const openReceipt = (receipt: any) => {
|
|
if (receipt.signedUrl) {
|
|
window.open(receipt.signedUrl, '_blank');
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.expense-list {
|
|
width: 100%;
|
|
}
|
|
|
|
.expense-grid {
|
|
margin: 0;
|
|
}
|
|
|
|
.expense-card {
|
|
position: relative;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
height: 100%;
|
|
}
|
|
|
|
.expense-card:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
|
|
}
|
|
|
|
.selected-card {
|
|
border: 2px solid rgb(var(--v-theme-primary)) !important;
|
|
background-color: rgba(var(--v-theme-primary), 0.05);
|
|
}
|
|
|
|
.card-checkbox {
|
|
position: absolute;
|
|
top: 8px;
|
|
left: 8px;
|
|
z-index: 2;
|
|
background: rgba(255, 255, 255, 0.9);
|
|
border-radius: 4px;
|
|
padding: 2px;
|
|
}
|
|
|
|
.receipt-section {
|
|
position: relative;
|
|
height: 140px;
|
|
background: #f5f5f5;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.receipt-image-container {
|
|
width: 100%;
|
|
height: 100%;
|
|
position: relative;
|
|
}
|
|
|
|
.receipt-image {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.receipt-count-chip {
|
|
position: absolute;
|
|
top: 8px;
|
|
right: 8px;
|
|
}
|
|
|
|
.no-receipt-placeholder {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: #9e9e9e;
|
|
}
|
|
|
|
.merchant-name {
|
|
font-weight: 600;
|
|
font-size: 1rem;
|
|
line-height: 1.2;
|
|
margin-bottom: 8px;
|
|
color: rgb(var(--v-theme-on-surface));
|
|
}
|
|
|
|
.price-display {
|
|
font-size: 1.25rem;
|
|
font-weight: 700;
|
|
color: rgb(var(--v-theme-primary));
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.converted-price {
|
|
font-size: 0.875rem;
|
|
font-weight: 400;
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
margin-left: 4px;
|
|
}
|
|
|
|
.date-display {
|
|
display: flex;
|
|
align-items: center;
|
|
font-size: 0.875rem;
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.payer-display {
|
|
display: flex;
|
|
align-items: center;
|
|
font-size: 0.875rem;
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.contents-preview {
|
|
font-size: 0.875rem;
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
line-height: 1.3;
|
|
overflow: hidden;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
}
|
|
|
|
.card-actions {
|
|
position: absolute;
|
|
top: 8px;
|
|
right: 8px;
|
|
z-index: 2;
|
|
display: flex;
|
|
gap: 4px;
|
|
opacity: 0;
|
|
transition: opacity 0.3s ease;
|
|
}
|
|
|
|
.expense-card:hover .card-actions {
|
|
opacity: 1;
|
|
}
|
|
|
|
.action-button {
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
/* Responsive adjustments */
|
|
@media (max-width: 600px) {
|
|
.expense-grid :deep(.v-col) {
|
|
padding: 6px;
|
|
}
|
|
|
|
.receipt-section {
|
|
height: 120px;
|
|
}
|
|
|
|
.merchant-name {
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.price-display {
|
|
font-size: 1.1rem;
|
|
}
|
|
}
|
|
</style>
|