port-nimara-client-portal/components/ExpenseList.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>