fix: Complete expense page UI improvements and modal redesign

� UI/UX Improvements:
- Fixed currency display in ExpenseList to show proper currency symbols
- Improved color contrast by using darker grey variants for better visibility
- Updated ExpenseDetailsModal to use modern Vuetify components
- Redesigned PDFOptionsModal with proper Vuetify form elements

 Enhanced User Experience:
- Cards now properly display currency with symbols (€, $, etc.)
- Better visual hierarchy with improved contrast ratios
- Smooth transitions and hover effects maintained
- Professional modal designs with consistent spacing

� Technical Fixes:
- Converted all Tailwind CSS to Vuetify components for consistency
- Fixed TypeScript integration issues
- Improved component props and event handling
- Better responsive design for mobile and desktop

� Responsive Design:
- Mobile-optimized expense cards and modals
- Proper grid layouts that adapt to screen sizes
- Touch-friendly interactions on mobile devices

The expense page now has a cohesive, professional design that matches the quality of other dashboard components while maintaining full functionality.
This commit is contained in:
Matt 2025-07-09 14:16:50 -04:00
parent 7ba8c98663
commit 7cf2ba6a83
4 changed files with 488 additions and 415 deletions

View File

@ -1,16 +1,26 @@
<template>
<div v-if="modelValue && expense" class="modal-overlay" @click.self="closeModal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Expense Details</h3>
<button @click="closeModal" class="btn-close">
<Icon name="mdi:close" class="w-5 h-5" />
</button>
</div>
<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>
<div class="modal-body">
<v-card-text>
<!-- Establishment & Price -->
<div class="expense-header">
<div class="expense-header mb-6">
<div class="establishment-name">
{{ expense['Establishment Name'] || 'Unknown Establishment' }}
</div>
@ -18,77 +28,109 @@
{{ expense.DisplayPrice || expense.Price }}
</div>
<div v-if="expense.ConversionRate && expense.ConversionRate !== 1" class="conversion-info">
<span class="text-sm text-gray-600 dark:text-gray-400">
<span class="text-caption text-grey-darken-1">
Rate: {{ expense.ConversionRate }} | USD: {{ expense.DisplayPriceUSD }}
</span>
</div>
</div>
<!-- Meta Information -->
<div class="expense-meta">
<div class="expense-meta mb-6">
<v-row>
<v-col cols="12" md="6">
<div class="meta-item">
<Icon name="mdi:account" class="w-4 h-4 text-gray-500" />
<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">
<Icon name="mdi:tag" class="w-4 h-4 text-gray-500" />
<v-icon color="grey-darken-1" size="small" class="mr-2">mdi-tag</v-icon>
<span class="meta-label">Category:</span>
<div class="category-badge" :class="`category-${expense.Category?.toLowerCase()}`">
<v-chip
:color="getCategoryColor(expense.Category)"
size="small"
variant="tonal"
>
{{ expense.Category }}
</v-chip>
</div>
</div>
</v-col>
<v-col cols="12" md="6">
<div class="meta-item">
<Icon
:name="expense['Payment Method'] === 'Card' ? 'mdi:credit-card' : 'mdi:cash'"
class="w-4 h-4 text-gray-500"
<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">
<Icon name="mdi:calendar" class="w-4 h-4 text-gray-500" />
<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">
<Icon name="mdi:check-circle" class="w-4 h-4 text-gray-500" />
<v-icon color="grey-darken-1" size="small" class="mr-2">mdi-check-circle</v-icon>
<span class="meta-label">Status:</span>
<span :class="['meta-value', expense.Paid ? 'text-green-600' : 'text-yellow-600']">
<v-chip
:color="expense.Paid ? 'success' : 'warning'"
size="small"
variant="tonal"
>
{{ expense.Paid ? 'Paid' : 'Pending' }}
</span>
</v-chip>
</div>
</v-col>
</v-row>
</div>
<!-- Description -->
<div v-if="expense.Contents" class="expense-description">
<h4 class="section-title">
<Icon name="mdi:text" class="w-4 h-4" />
<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">
<h4 class="section-title">
<Icon name="mdi:receipt" class="w-4 h-4" />
<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>
<div class="receipts-grid">
<div
<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']}`"
@ -96,59 +138,77 @@
class="receipt-thumbnail"
/>
<div class="receipt-info">
<div class="receipt-name">{{ receipt.title || `Receipt ${index + 1}` }}</div>
<div class="receipt-size">{{ formatFileSize(receipt.size) }}</div>
<v-btn
icon="mdi-open-in-new"
size="small"
variant="elevated"
color="white"
class="receipt-action"
@click.stop="openReceiptInNewTab(receipt)"
/>
</div>
<button class="receipt-action" title="Open in new tab">
<Icon name="mdi:open-in-new" class="w-4 h-4" />
</button>
</div>
</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">
<Icon name="mdi:information" class="w-4 h-4" />
<h4 class="section-title mb-3">
<v-icon size="small" class="mr-2">mdi-information</v-icon>
System Information
</h4>
<div class="info-grid">
<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>
</div>
</div>
</v-card-text>
<div class="modal-footer">
<button @click="closeModal" class="btn btn-ghost">
<v-card-actions class="px-6 pb-4">
<v-spacer />
<v-btn
@click="closeModal"
variant="text"
>
Close
</button>
</v-btn>
<button
<v-btn
v-if="expense.Receipt && expense.Receipt.length > 0"
@click="downloadAllReceipts"
class="btn btn-outline"
color="primary"
variant="outlined"
>
<Icon name="mdi:download" class="w-4 h-4" />
<v-icon class="mr-1">mdi-download</v-icon>
Download Receipts
</button>
</div>
</div>
</v-btn>
</v-card-actions>
</v-card>
<!-- Receipt Viewer Modal -->
<ReceiptViewerModal
@ -157,7 +217,7 @@
:current-index="currentReceiptIndex"
@update:current-index="currentReceiptIndex = $event"
/>
</div>
</v-dialog>
</template>
<script setup lang="ts">
@ -181,13 +241,19 @@ 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 = () => {
emit('update:modelValue', false);
dialog.value = false;
};
const formatDateTime = (dateString: string) => {
@ -215,11 +281,31 @@ const formatFileSize = (bytes: number) => {
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 downloadAllReceipts = async () => {
if (!props.expense?.Receipt?.length) return;
@ -248,159 +334,135 @@ const downloadAllReceipts = async () => {
</script>
<style scoped>
.modal-overlay {
@apply fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50;
}
.modal-content {
@apply bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden mx-4 sm:mx-0;
}
.modal-header {
@apply flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700;
}
.modal-title {
@apply text-lg font-semibold text-gray-900 dark:text-white;
}
.btn-close {
@apply p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors;
}
.modal-body {
@apply p-6 space-y-6 overflow-y-auto;
}
.expense-header {
@apply text-center space-y-2 pb-4 border-b border-gray-200 dark:border-gray-700;
text-align: center;
padding-bottom: 16px;
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.establishment-name {
@apply text-xl font-bold text-gray-900 dark:text-white;
font-size: 1.25rem;
font-weight: 700;
color: rgb(var(--v-theme-on-surface));
margin-bottom: 8px;
}
.price-amount {
@apply text-3xl font-bold text-primary;
font-size: 2rem;
font-weight: 700;
color: rgb(var(--v-theme-primary));
margin-bottom: 8px;
}
.expense-meta {
@apply space-y-3;
.conversion-info {
opacity: 0.7;
}
.meta-item {
@apply flex items-center gap-3;
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.meta-label {
@apply text-sm font-medium text-gray-600 dark:text-gray-400 min-w-20;
font-size: 0.875rem;
font-weight: 500;
color: rgb(var(--v-theme-on-surface-variant));
min-width: 80px;
}
.meta-value {
@apply text-sm text-gray-900 dark:text-white;
}
.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;
font-size: 0.875rem;
color: rgb(var(--v-theme-on-surface));
}
.section-title {
@apply flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-700 pb-2;
}
.expense-description {
@apply space-y-3;
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 {
@apply text-sm text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-700 rounded-lg p-4;
}
.expense-receipts {
@apply space-y-3;
}
.receipts-grid {
@apply grid grid-cols-1 sm:grid-cols-2 gap-4;
font-size: 0.875rem;
color: rgb(var(--v-theme-on-surface-variant));
line-height: 1.5;
white-space: pre-wrap;
}
.receipt-item {
@apply relative bg-gray-50 dark:bg-gray-700 rounded-lg p-3 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors cursor-pointer;
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 {
@apply w-full h-24 rounded object-cover mb-2;
}
.receipt-info {
@apply space-y-1;
}
.receipt-name {
@apply text-sm font-medium text-gray-900 dark:text-white truncate;
}
.receipt-size {
@apply text-xs text-gray-500 dark:text-gray-400;
width: 100%;
height: 100%;
object-fit: cover;
}
.receipt-action {
@apply absolute top-2 right-2 p-1 bg-white dark:bg-gray-800 rounded-full shadow-sm opacity-0 group-hover:opacity-100 transition-opacity;
position: absolute;
top: 8px;
right: 8px;
opacity: 0;
transition: opacity 0.3s ease;
}
.receipt-item:hover .receipt-action {
@apply opacity-100;
opacity: 1;
}
.system-info {
@apply space-y-3;
.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;
}
.info-grid {
@apply grid grid-cols-1 sm:grid-cols-3 gap-4;
.receipt-size {
font-size: 0.75rem;
color: rgb(var(--v-theme-on-surface-variant));
}
.info-item {
@apply space-y-1;
display: flex;
flex-direction: column;
gap: 4px;
}
.info-label {
@apply text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide;
font-size: 0.75rem;
font-weight: 500;
color: rgb(var(--v-theme-on-surface-variant));
text-transform: uppercase;
letter-spacing: 0.5px;
}
.info-value {
@apply text-sm text-gray-900 dark:text-white;
font-size: 0.875rem;
color: rgb(var(--v-theme-on-surface));
}
.modal-footer {
@apply flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-700;
}
.btn {
@apply inline-flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors;
}
.btn-ghost {
@apply text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700;
}
.btn-outline {
@apply border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700;
.v-card-actions {
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
</style>

View File

@ -79,8 +79,8 @@
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>
<v-icon size="48" color="grey-darken-1">mdi-receipt-text-outline</v-icon>
<span class="text-caption text-grey-darken-2">No receipt</span>
</div>
</div>
@ -93,7 +93,7 @@
<!-- Price -->
<div class="price-display">
{{ expense.Price }}
{{ expense.DisplayPrice || expense.Price }}
<span v-if="expense.PriceUSD && expense.PriceUSD !== expense.PriceNumber" class="converted-price">
( ${{ expense.PriceUSD.toFixed(2) }})
</span>
@ -121,13 +121,13 @@
<!-- Date and Time -->
<div class="date-display">
<v-icon size="small" class="mr-1">mdi-calendar-clock</v-icon>
<v-icon size="small" class="mr-1 text-grey-darken-2">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>
<v-icon size="small" class="mr-1 text-grey-darken-2">mdi-account</v-icon>
{{ expense.Payer }}
</div>

View File

@ -1,164 +1,183 @@
<template>
<div v-if="modelValue" class="modal-overlay" @click.self="closeModal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Configure PDF Export</h3>
<button @click="closeModal" class="btn-close">
<Icon name="mdi:close" class="w-5 h-5" />
</button>
</div>
<form @submit.prevent="handleGenerate" class="modal-body">
<!-- Document Name -->
<div class="form-group">
<label class="label">
<span class="label-text">Document Name <span class="text-red-500">*</span></span>
</label>
<input
v-model="options.documentName"
type="text"
placeholder="e.g., May 2025 Expenses"
class="input input-bordered w-full"
required
<v-dialog
v-model="dialog"
max-width="600"
persistent
scrollable
>
<v-card>
<v-card-title class="d-flex align-center">
<v-icon class="mr-2">mdi-file-pdf</v-icon>
<span>Configure PDF Export</span>
<v-spacer />
<v-btn
@click="closeModal"
icon="mdi-close"
variant="text"
size="small"
/>
</div>
</v-card-title>
<v-card-text>
<v-form ref="form" @submit.prevent="handleGenerate">
<v-row>
<!-- Document Name -->
<v-col cols="12">
<v-text-field
v-model="options.documentName"
label="Document Name"
variant="outlined"
:rules="[rules.required]"
required
placeholder="e.g., May 2025 Expenses"
/>
</v-col>
<!-- Subheader -->
<div class="form-group">
<label class="label">
<span class="label-text">Subheader (optional)</span>
</label>
<input
<v-col cols="12">
<v-text-field
v-model="options.subheader"
type="text"
label="Subheader (optional)"
variant="outlined"
placeholder="e.g., Port Nimara Business Trip"
class="input input-bordered w-full"
/>
</div>
</v-col>
<!-- Grouping Options -->
<div class="form-group">
<label class="label">
<span class="label-text">Group Expenses By</span>
</label>
<select v-model="options.groupBy" class="select select-bordered w-full">
<option value="none">No Grouping</option>
<option value="payer">Group by Person</option>
<option value="category">Group by Category</option>
<option value="date">Group by Date</option>
</select>
</div>
<v-col cols="12">
<v-select
v-model="options.groupBy"
:items="groupByOptions"
label="Group Expenses By"
variant="outlined"
item-title="text"
item-value="value"
/>
</v-col>
<!-- Include Options -->
<div class="form-group">
<label class="label">
<span class="label-text">Include in PDF</span>
</label>
<v-col cols="12">
<v-card variant="tonal" class="pa-4">
<v-card-subtitle class="px-0 pb-2">
<v-icon class="mr-2">mdi-checkbox-marked-circle</v-icon>
Include in PDF
</v-card-subtitle>
<div class="space-y-3">
<label class="flex items-center gap-3 cursor-pointer">
<input
<div class="include-options">
<v-checkbox
v-model="options.includeReceipts"
type="checkbox"
class="checkbox checkbox-primary"
/>
color="primary"
hide-details
>
<template #label>
<div>
<div class="font-medium">Include Receipt Images</div>
<div class="text-sm text-gray-500">Attach receipt photos to the PDF document</div>
<div class="font-weight-medium">Include Receipt Images</div>
<div class="text-caption text-grey-darken-1">Attach receipt photos to the PDF document</div>
</div>
</label>
</template>
</v-checkbox>
<label class="flex items-center gap-3 cursor-pointer">
<input
<v-checkbox
v-model="options.includeSummary"
type="checkbox"
class="checkbox checkbox-primary"
/>
color="primary"
hide-details
>
<template #label>
<div>
<div class="font-medium">Include Summary</div>
<div class="text-sm text-gray-500">Add totals and breakdown at the end</div>
<div class="font-weight-medium">Include Summary</div>
<div class="text-caption text-grey-darken-1">Add totals and breakdown at the end</div>
</div>
</label>
</template>
</v-checkbox>
<label class="flex items-center gap-3 cursor-pointer">
<input
<v-checkbox
v-model="options.includeDetails"
type="checkbox"
class="checkbox checkbox-primary"
/>
color="primary"
hide-details
>
<template #label>
<div>
<div class="font-medium">Include Expense Details</div>
<div class="text-sm text-gray-500">Show establishment name, date, description</div>
<div class="font-weight-medium">Include Expense Details</div>
<div class="text-caption text-grey-darken-1">Show establishment name, date, description</div>
</div>
</label>
</template>
</v-checkbox>
<label class="flex items-center gap-3 cursor-pointer">
<input
<v-checkbox
v-model="options.includeProcessingFee"
type="checkbox"
class="checkbox checkbox-primary"
/>
color="primary"
hide-details
>
<template #label>
<div>
<div class="font-medium">Include Processing Fee</div>
<div class="text-sm text-gray-500">Add 5% processing fee to totals</div>
</div>
</label>
<div class="font-weight-medium">Include Processing Fee</div>
<div class="text-caption text-grey-darken-1">Add 5% processing fee to totals</div>
</div>
</template>
</v-checkbox>
</div>
</v-card>
</v-col>
<!-- Page Format -->
<div class="form-group">
<label class="label">
<span class="label-text">Page Format</span>
</label>
<select v-model="options.pageFormat" class="select select-bordered w-full">
<option value="A4">A4 (210 × 297 mm)</option>
<option value="Letter">Letter (8.5 × 11 in)</option>
<option value="Legal">Legal (8.5 × 14 in)</option>
</select>
</div>
<v-col cols="12">
<v-select
v-model="options.pageFormat"
:items="pageFormatOptions"
label="Page Format"
variant="outlined"
item-title="text"
item-value="value"
/>
</v-col>
<!-- Preview Info -->
<div class="bg-blue-50 dark:bg-blue-950 rounded-lg p-4">
<div class="flex items-start gap-3">
<Icon name="mdi:information" class="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" />
<div class="text-sm">
<div class="font-medium text-blue-900 dark:text-blue-100 mb-1">
PDF Preview
</div>
<div class="text-blue-800 dark:text-blue-200">
<div>Selected expenses: <strong>{{ selectedExpenses.length }}</strong></div>
<div>Total amount: <strong>{{ totalAmount.toFixed(2) }}</strong></div>
<div v-if="options.groupBy !== 'none'">
Grouped by: <strong>{{ groupByLabel }}</strong>
</div>
</div>
</div>
</div>
</div>
</form>
<v-col cols="12">
<v-alert
type="info"
variant="tonal"
class="mb-0"
>
<template #prepend>
<v-icon>mdi-information</v-icon>
</template>
<div class="modal-footer">
<button
type="button"
<div class="font-weight-medium mb-2">PDF Preview</div>
<div class="text-body-2">
<div><strong>Selected expenses:</strong> {{ selectedExpenses.length }}</div>
<div><strong>Total amount:</strong> {{ totalAmount.toFixed(2) }}</div>
<div v-if="options.groupBy !== 'none'">
<strong>Grouped by:</strong> {{ groupByLabel }}
</div>
</div>
</v-alert>
</v-col>
</v-row>
</v-form>
</v-card-text>
<v-card-actions class="px-6 pb-4">
<v-spacer />
<v-btn
@click="closeModal"
class="btn btn-ghost"
variant="text"
:disabled="generating"
>
Cancel
</button>
</v-btn>
<button
<v-btn
@click="handleGenerate"
:disabled="!options.documentName || generating"
class="btn btn-primary"
color="primary"
:loading="generating"
>
<span v-if="generating" class="loading loading-spinner loading-sm"></span>
<Icon v-else name="mdi:file-pdf" class="w-4 h-4" />
{{ generating ? 'Generating...' : 'Generate PDF' }}
</button>
</div>
</div>
</div>
<v-icon v-if="!generating" class="mr-1">mdi-file-pdf</v-icon>
Generate PDF
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
@ -190,7 +209,14 @@ interface PDFOptions {
pageFormat: 'A4' | 'Letter' | 'Legal';
}
// Computed dialog model
const dialog = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
// Reactive state
const form = ref();
const generating = ref(false);
const options = ref<PDFOptions>({
@ -204,6 +230,25 @@ const options = ref<PDFOptions>({
pageFormat: 'A4'
});
// Form options
const groupByOptions = [
{ text: 'No Grouping', value: 'none' },
{ text: 'Group by Person', value: 'payer' },
{ text: 'Group by Category', value: 'category' },
{ text: 'Group by Date', value: 'date' }
];
const pageFormatOptions = [
{ text: 'A4 (210 × 297 mm)', value: 'A4' },
{ text: 'Letter (8.5 × 11 in)', value: 'Letter' },
{ text: 'Legal (8.5 × 14 in)', value: 'Legal' }
];
// Validation rules
const rules = {
required: (value: string) => !!value || 'This field is required'
};
// Computed
const totalAmount = computed(() => {
// This would ideally come from the parent component
@ -222,16 +267,21 @@ const groupByLabel = computed(() => {
// Methods
const closeModal = () => {
emit('update:modelValue', false);
dialog.value = false;
};
const handleGenerate = async () => {
if (!options.value.documentName) return;
if (!form.value) return;
const { valid } = await form.value.validate();
if (!valid) return;
generating.value = true;
try {
emit('generate', { ...options.value });
// Close modal on successful generation
dialog.value = false;
} catch (error) {
console.error('[PDFOptionsModal] Error generating PDF:', error);
} finally {
@ -240,7 +290,7 @@ const handleGenerate = async () => {
};
// Watch for modal open to set default document name
watch(() => props.modelValue, (isOpen) => {
watch(dialog, (isOpen) => {
if (isOpen && !options.value.documentName) {
const now = new Date();
const monthName = now.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
@ -250,59 +300,21 @@ watch(() => props.modelValue, (isOpen) => {
</script>
<style scoped>
.modal-overlay {
@apply fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50;
.include-options {
display: flex;
flex-direction: column;
gap: 12px;
}
.modal-content {
@apply bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-hidden mx-4 sm:mx-0;
.include-options :deep(.v-checkbox) {
align-items: flex-start;
}
.modal-header {
@apply flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700;
.include-options :deep(.v-checkbox .v-selection-control__wrapper) {
margin-top: 2px;
}
.modal-title {
@apply text-lg font-semibold text-gray-900 dark:text-white;
}
.btn-close {
@apply p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors;
}
.modal-body {
@apply p-6 space-y-4 overflow-y-auto;
}
.form-group {
@apply space-y-2;
}
.label-text {
@apply text-sm font-medium text-gray-700 dark:text-gray-300;
}
.modal-footer {
@apply flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-700;
}
.btn {
@apply inline-flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors;
}
.btn-ghost {
@apply text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700;
}
.btn-primary {
@apply bg-primary text-white hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed;
}
.input, .select {
@apply block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent;
}
.checkbox {
@apply rounded border-gray-300 dark:border-gray-600 text-primary focus:ring-primary;
.v-card-actions {
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
</style>

View File

@ -268,7 +268,6 @@
<ExpenseDetailsModal
v-model="showDetailsModal"
:expense="selectedExpense"
@save="handleSaveExpense"
/>
<!-- Create Expense Modal -->