feat: Improve UI styling in ExpenseDetailsModal and ExpenseList, enhance authentication middleware caching, and optimize PDF generation for receipt fetching
This commit is contained in:
parent
3ba8542e4f
commit
6ebe96bbf4
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue