feat: Improve UI styling in ExpenseDetailsModal and ExpenseList, enhance authentication middleware caching, and optimize PDF generation for receipt fetching

This commit is contained in:
Matt 2025-07-10 17:05:08 -04:00
parent 3ba8542e4f
commit 6ebe96bbf4
7 changed files with 58 additions and 86 deletions

View File

@ -28,7 +28,7 @@
{{ expense.DisplayPrice || expense.Price }} {{ expense.DisplayPrice || expense.Price }}
</div> </div>
<div v-if="expense.ConversionRate && expense.ConversionRate !== 1" class="conversion-info"> <div v-if="expense.ConversionRate && expense.ConversionRate !== 1" class="conversion-info">
<span class="text-caption text-grey-darken-1"> <span class="text-caption text-grey-darken-3">
Rate: {{ expense.ConversionRate }} | USD: {{ expense.DisplayPriceUSD }} Rate: {{ expense.ConversionRate }} | USD: {{ expense.DisplayPriceUSD }}
</span> </span>
</div> </div>

View File

@ -64,12 +64,12 @@
<!-- Multiple receipts indicator --> <!-- Multiple receipts indicator -->
<v-chip <v-chip
v-if="expense.Receipt.length > 1"
size="x-small" size="x-small"
color="primary" variant="flat"
class="receipt-count-chip" :color="getCategoryColor(expense.Category)"
class="text-caption text-grey-darken-3"
> >
+{{ expense.Receipt.length - 1 }} {{ expense.Category || 'Other' }}
</v-chip> </v-chip>
</div> </div>

View File

@ -20,14 +20,14 @@ export default defineNuxtRouteMiddleware(async (to) => {
// Use a cached auth state to avoid excessive API calls // Use a cached auth state to avoid excessive API calls
const nuxtApp = useNuxtApp(); const nuxtApp = useNuxtApp();
const cacheKey = 'auth:session:cache'; const cacheKey = 'auth:session:cache';
const cacheExpiry = 5 * 60 * 1000; // 5 minutes cache (increased from 30 seconds) const cacheExpiry = 15 * 60 * 1000; // 15 minutes cache (increased for better UX)
// Check if we have a cached session // Check if we have a cached session
const cachedSession = nuxtApp.payload.data?.[cacheKey]; const cachedSession = nuxtApp.payload.data?.[cacheKey];
const now = Date.now(); const now = Date.now();
if (cachedSession && cachedSession.timestamp && (now - cachedSession.timestamp) < cacheExpiry) { if (cachedSession && cachedSession.timestamp && (now - cachedSession.timestamp) < cacheExpiry) {
console.log('[MIDDLEWARE] Using cached session'); console.log('[MIDDLEWARE] Using cached session (age:', Math.round((now - cachedSession.timestamp) / 1000), 'seconds)');
if (cachedSession.authenticated && cachedSession.user) { if (cachedSession.authenticated && cachedSession.user) {
// Store auth state for components // Store auth state for components
if (!nuxtApp.payload.data) { if (!nuxtApp.payload.data) {

View File

@ -24,7 +24,7 @@
<v-card class="mb-6"> <v-card class="mb-6">
<v-card-text class="pa-6"> <v-card-text class="pa-6">
<v-row align="center" class="mb-0"> <v-row align="center" class="mb-0">
<v-col cols="12" sm="6" md="3"> <v-col cols="12" sm="6" md="2">
<v-text-field <v-text-field
v-model="filters.startDate" v-model="filters.startDate"
type="date" type="date"
@ -32,11 +32,10 @@
variant="outlined" variant="outlined"
density="comfortable" density="comfortable"
hide-details hide-details
@change="fetchExpenses"
/> />
</v-col> </v-col>
<v-col cols="12" sm="6" md="3"> <v-col cols="12" sm="6" md="2">
<v-text-field <v-text-field
v-model="filters.endDate" v-model="filters.endDate"
type="date" type="date"
@ -44,11 +43,10 @@
variant="outlined" variant="outlined"
density="comfortable" density="comfortable"
hide-details hide-details
@change="fetchExpenses"
/> />
</v-col> </v-col>
<v-col cols="12" sm="6" md="3"> <v-col cols="12" sm="6" md="2">
<v-select <v-select
v-model="filters.category" v-model="filters.category"
:items="['', 'Food/Drinks', 'Shop', 'Online', 'Other']" :items="['', 'Food/Drinks', 'Shop', 'Online', 'Other']"
@ -57,11 +55,23 @@
density="comfortable" density="comfortable"
hide-details hide-details
clearable clearable
@update:model-value="fetchExpenses"
/> />
</v-col> </v-col>
<v-col cols="12" sm="6" md="3"> <v-col cols="12" sm="6" md="2">
<v-btn
@click="fetchExpenses"
color="primary"
variant="flat"
size="large"
class="w-100"
prepend-icon="mdi-magnify"
>
Apply
</v-btn>
</v-col>
<v-col cols="12" sm="6" md="2">
<v-btn <v-btn
@click="resetToCurrentMonth" @click="resetToCurrentMonth"
variant="outlined" variant="outlined"
@ -187,7 +197,7 @@
<div class="d-flex flex-wrap align-center"> <div class="d-flex flex-wrap align-center">
<span class="text-subtitle-1 font-weight-medium mr-6">Export Options:</span> <span class="text-subtitle-1 font-weight-medium mr-6">Export Options:</span>
<div class="d-flex gap-4"> <div class="d-flex gap-6">
<v-btn <v-btn
@click="exportCSV" @click="exportCSV"
:disabled="selectedExpenses.length === 0" :disabled="selectedExpenses.length === 0"

View File

@ -116,7 +116,7 @@
</v-card-title> </v-card-title>
<v-divider /> <v-divider />
<v-card-text class="pa-4" style="max-height: 600px; overflow-y: auto;"> <v-card-text class="pa-4" style="max-height: 600px; overflow-y: auto;">
<div class="d-flex flex-column gap-6"> <div class="d-flex flex-column gap-4">
<v-card <v-card
v-for="berth in getBerthsByStatus(status.value)" v-for="berth in getBerthsByStatus(status.value)"
:key="berth.Id" :key="berth.Id"
@ -137,14 +137,24 @@
</div> </div>
<div class="d-flex justify-space-between align-center"> <div class="d-flex justify-space-between align-center">
<span class="text-body-2 font-weight-medium">${{ formatPrice(berth.Price) }}</span> <span class="text-body-2 font-weight-medium">${{ formatPrice(berth.Price) }}</span>
<v-chip <v-tooltip v-if="getInterestedCount(berth)" location="top">
v-if="getInterestedCount(berth)" <template v-slot:activator="{ props }">
size="x-small" <v-chip
color="primary" v-bind="props"
variant="flat" size="x-small"
> color="primary"
{{ getInterestedCount(berth) }} interested variant="flat"
</v-chip> >
{{ getInterestedCount(berth) }} interested
</v-chip>
</template>
<div class="pa-2">
<div class="text-subtitle-2 mb-1">Interested Parties:</div>
<div v-for="party in berth['Interested Parties']" :key="party.Id" class="text-body-2">
{{ party['Full Name'] }}
</div>
</div>
</v-tooltip>
</div> </div>
</v-card-text> </v-card-text>
</v-card> </v-card>

View File

@ -14,8 +14,8 @@ export default defineNuxtPlugin(() => {
refreshTimer = null refreshTimer = null
} }
// Calculate time until refresh (refresh 5 minutes before expiry) // Calculate time until refresh (refresh 10 minutes before expiry for better safety margin)
const refreshBuffer = 5 * 60 * 1000 // 5 minutes in milliseconds const refreshBuffer = 10 * 60 * 1000 // 10 minutes in milliseconds (increased from 5)
const timeUntilRefresh = expiresAt - Date.now() - refreshBuffer const timeUntilRefresh = expiresAt - Date.now() - refreshBuffer
console.log('[AUTH_REFRESH] Scheduling token refresh in:', Math.max(0, timeUntilRefresh), 'ms') console.log('[AUTH_REFRESH] Scheduling token refresh in:', Math.max(0, timeUntilRefresh), 'ms')

View File

@ -748,56 +748,22 @@ async function fetchReceiptImage(receipt: any): Promise<Buffer | null> {
console.log('[expenses/generate-pdf] Detected S3 URL, fetching directly...'); console.log('[expenses/generate-pdf] Detected S3 URL, fetching directly...');
try { try {
// Ensure URL is properly encoded // Use the signed URL directly without modification to preserve AWS signature
let encodedUrl = rawPath; console.log('[expenses/generate-pdf] Fetching from S3 URL (preserving signature):', rawPath);
try {
// Parse and reconstruct URL to ensure proper encoding
const url = new URL(rawPath);
// Re-encode the pathname to handle special characters
url.pathname = url.pathname.split('/').map(segment => encodeURIComponent(decodeURIComponent(segment))).join('/');
encodedUrl = url.toString();
console.log('[expenses/generate-pdf] URL encoded:', encodedUrl);
} catch (urlError) {
console.log('[expenses/generate-pdf] Using original URL (encoding failed):', rawPath);
encodedUrl = rawPath;
}
// Fetch image directly from S3 URL with proper headers // Fetch image directly from S3 URL with minimal headers to avoid signature issues
const response = await fetch(encodedUrl, { const response = await fetch(rawPath, {
method: 'GET', method: 'GET',
headers: { headers: {
'Accept': 'image/*', 'Accept': 'image/*'
'User-Agent': 'PortNimara-Client-Portal/1.0',
'Cache-Control': 'no-cache'
}, },
// Add timeout to prevent hanging // Add timeout to prevent hanging
signal: AbortSignal.timeout(45000) // 45 second timeout signal: AbortSignal.timeout(30000) // 30 second timeout
}); });
if (!response.ok) { if (!response.ok) {
console.error(`[expenses/generate-pdf] Failed to fetch image from S3: ${response.status} ${response.statusText}`); console.error(`[expenses/generate-pdf] Failed to fetch image from S3: ${response.status} ${response.statusText}`);
console.error('[expenses/generate-pdf] Response headers:', Object.fromEntries(response.headers.entries())); console.error('[expenses/generate-pdf] Response headers:', Object.fromEntries(response.headers.entries()));
// Try with the original URL if encoding failed
if (encodedUrl !== rawPath) {
console.log('[expenses/generate-pdf] Retrying with original URL...');
const originalResponse = await fetch(rawPath, {
method: 'GET',
headers: {
'Accept': 'image/*',
'User-Agent': 'PortNimara-Client-Portal/1.0'
},
signal: AbortSignal.timeout(30000)
});
if (originalResponse.ok) {
const arrayBuffer = await originalResponse.arrayBuffer();
const imageBuffer = Buffer.from(arrayBuffer);
console.log('[expenses/generate-pdf] Successfully fetched with original URL, Size:', imageBuffer.length);
return imageBuffer;
}
}
return null; return null;
} }
@ -810,27 +776,13 @@ async function fetchReceiptImage(receipt: any): Promise<Buffer | null> {
} catch (fetchError: any) { } catch (fetchError: any) {
console.error('[expenses/generate-pdf] Error fetching from S3 URL:', fetchError.message); console.error('[expenses/generate-pdf] Error fetching from S3 URL:', fetchError.message);
console.error('[expenses/generate-pdf] Error details:', {
name: fetchError.name,
code: fetchError.code,
message: fetchError.message
});
// If it's a timeout or network error, try one more time with simpler approach // Don't try multiple attempts for signed URLs as they may expire
if (fetchError.name === 'TimeoutError' || fetchError.name === 'AbortError' || fetchError.code === 'ECONNRESET') {
console.log('[expenses/generate-pdf] Network error, trying simplified approach...');
try {
const simpleResponse = await fetch(rawPath, {
method: 'GET',
signal: AbortSignal.timeout(90000) // Extended timeout for final attempt
});
if (simpleResponse.ok) {
const arrayBuffer = await simpleResponse.arrayBuffer();
const imageBuffer = Buffer.from(arrayBuffer);
console.log('[expenses/generate-pdf] Successfully fetched image with simplified approach, Size:', imageBuffer.length);
return imageBuffer;
}
} catch (finalError) {
console.error('[expenses/generate-pdf] Final attempt also failed:', finalError);
}
}
return null; return null;
} }
} }