339 lines
9.4 KiB
Vue
339 lines
9.4 KiB
Vue
<template>
|
|
<div class="expense-list">
|
|
<!-- Bulk Selection Header -->
|
|
<div v-if="expenses.length > 0" class="flex items-center justify-between mb-4">
|
|
<div class="flex items-center gap-3">
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
:checked="isAllSelected"
|
|
:indeterminate="isSomeSelected"
|
|
@change="toggleSelectAll"
|
|
class="checkbox checkbox-primary"
|
|
/>
|
|
<span class="text-sm font-medium">
|
|
{{ selectedExpenses.length > 0 ? `${selectedExpenses.length} selected` : 'Select all' }}
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
|
{{ expenses.length }} expenses • €{{ totalAmount.toFixed(2) }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Expense Grid - Mobile optimized -->
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
|
|
<div
|
|
v-for="expense in expenses"
|
|
:key="expense.Id"
|
|
class="expense-card"
|
|
:class="{ 'selected': isSelected(expense.Id) }"
|
|
@click="selectExpense(expense.Id)"
|
|
>
|
|
<!-- Selection Checkbox -->
|
|
<div class="absolute top-3 left-3 z-10">
|
|
<input
|
|
type="checkbox"
|
|
:checked="isSelected(expense.Id)"
|
|
@click.stop="toggleExpense(expense.Id)"
|
|
class="checkbox checkbox-primary checkbox-sm"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Receipt Image -->
|
|
<div class="receipt-container">
|
|
<div
|
|
v-if="expense.Receipt && expense.Receipt.length > 0"
|
|
class="receipt-image-wrapper"
|
|
@click.stop="$emit('expense-clicked', expense)"
|
|
>
|
|
<LazyReceiptImage
|
|
:receipt="expense.Receipt[0]"
|
|
:alt="`Receipt for ${expense['Establishment Name']}`"
|
|
class="receipt-image"
|
|
/>
|
|
|
|
<!-- Multiple receipts indicator -->
|
|
<div v-if="expense.Receipt.length > 1" class="receipt-count-badge">
|
|
+{{ expense.Receipt.length - 1 }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- No receipt placeholder -->
|
|
<div v-else class="no-receipt-placeholder" @click.stop="$emit('expense-clicked', expense)">
|
|
<Icon name="mdi:receipt-outline" class="w-12 h-12 text-gray-400" />
|
|
<span class="text-xs text-gray-500">No receipt</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Expense Details -->
|
|
<div class="expense-details" @click.stop="$emit('expense-clicked', expense)">
|
|
<div class="establishment-name">
|
|
{{ expense['Establishment Name'] || 'Unknown' }}
|
|
</div>
|
|
|
|
<div class="price-amount">
|
|
{{ expense.DisplayPrice || expense.Price }}
|
|
</div>
|
|
|
|
<div class="expense-meta">
|
|
<div class="category-badge" :class="`category-${expense.Category?.toLowerCase()}`">
|
|
{{ expense.Category }}
|
|
</div>
|
|
|
|
<div class="payment-method">
|
|
<Icon
|
|
:name="expense['Payment Method'] === 'Card' ? 'mdi:credit-card' : 'mdi:cash'"
|
|
class="w-4 h-4"
|
|
/>
|
|
{{ expense['Payment Method'] }}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="date-time">
|
|
{{ formatDateTime(expense.Time) }}
|
|
</div>
|
|
|
|
<!-- Contents Preview -->
|
|
<div v-if="expense.Contents" class="contents-preview">
|
|
{{ expense.Contents.length > 50 ? expense.Contents.substring(0, 50) + '...' : expense.Contents }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Quick Actions -->
|
|
<div class="expense-actions">
|
|
<button
|
|
@click.stop="$emit('expense-clicked', expense)"
|
|
class="action-btn"
|
|
title="View Details"
|
|
>
|
|
<Icon name="mdi:eye" class="w-4 h-4" />
|
|
</button>
|
|
|
|
<button
|
|
v-if="expense.Receipt && expense.Receipt.length > 0"
|
|
@click.stop="openReceipt(expense.Receipt[0])"
|
|
class="action-btn"
|
|
title="View Receipt"
|
|
>
|
|
<Icon name="mdi:image" class="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading More Indicator -->
|
|
<div v-if="isLoadingMore" class="flex justify-center py-6">
|
|
<span class="loading loading-spinner loading-md"></span>
|
|
</div>
|
|
|
|
<!-- Empty State -->
|
|
<div v-if="expenses.length === 0" class="text-center py-12">
|
|
<Icon name="mdi:receipt" class="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
|
No expenses found
|
|
</h3>
|
|
<p class="text-gray-600 dark:text-gray-300">
|
|
No expenses match the current filters
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref } 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 openReceipt = (receipt: any) => {
|
|
if (receipt.signedUrl) {
|
|
window.open(receipt.signedUrl, '_blank');
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.expense-list {
|
|
@apply w-full;
|
|
}
|
|
|
|
.expense-card {
|
|
@apply relative bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden cursor-pointer transition-all duration-200 hover:shadow-md hover:border-gray-300 dark:hover:border-gray-600;
|
|
}
|
|
|
|
.expense-card.selected {
|
|
@apply border-primary bg-primary/5 dark:bg-primary/10;
|
|
}
|
|
|
|
.receipt-container {
|
|
@apply relative h-32 bg-gray-50 dark:bg-gray-700;
|
|
}
|
|
|
|
.receipt-image-wrapper {
|
|
@apply relative w-full h-full overflow-hidden;
|
|
}
|
|
|
|
.receipt-image {
|
|
@apply w-full h-full object-cover;
|
|
}
|
|
|
|
.receipt-count-badge {
|
|
@apply absolute top-2 right-2 bg-black/70 text-white text-xs px-2 py-1 rounded-full;
|
|
}
|
|
|
|
.no-receipt-placeholder {
|
|
@apply w-full h-full flex flex-col items-center justify-center text-gray-400;
|
|
}
|
|
|
|
.expense-details {
|
|
@apply p-4 space-y-2;
|
|
}
|
|
|
|
.establishment-name {
|
|
@apply font-semibold text-gray-900 dark:text-white truncate;
|
|
}
|
|
|
|
.price-amount {
|
|
@apply text-xl font-bold text-primary;
|
|
}
|
|
|
|
.expense-meta {
|
|
@apply flex items-center justify-between gap-2;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.payment-method {
|
|
@apply flex items-center gap-1 text-sm text-gray-600 dark:text-gray-300;
|
|
}
|
|
|
|
.date-time {
|
|
@apply text-sm text-gray-500 dark:text-gray-400;
|
|
}
|
|
|
|
.contents-preview {
|
|
@apply text-sm text-gray-600 dark:text-gray-300 line-clamp-2;
|
|
}
|
|
|
|
.expense-actions {
|
|
@apply absolute top-3 right-3 flex gap-1 opacity-0 transition-opacity duration-200;
|
|
}
|
|
|
|
.expense-card:hover .expense-actions {
|
|
@apply opacity-100;
|
|
}
|
|
|
|
.action-btn {
|
|
@apply p-1.5 bg-white dark:bg-gray-800 rounded-full shadow-sm border border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors;
|
|
}
|
|
</style>
|