Compare commits
No commits in common. "3ba8542e4f4a2b038b309b908afba550f0294977" and "47400402022c5a90dda783f3f85131a12e28734c" have entirely different histories.
3ba8542e4f
...
4740040202
|
|
@ -17,10 +17,6 @@ NUXT_EMAIL_LOGO_URL=https://portnimara.com/Port_Nimara_Logo_2_Colour_New_Transpa
|
|||
# Documenso Configuration
|
||||
NUXT_DOCUMENSO_API_KEY=your_documenso_api_key_here
|
||||
NUXT_DOCUMENSO_BASE_URL=https://signatures.portnimara.dev
|
||||
NUXT_DOCUMENSO_TEMPLATE_ID=1
|
||||
NUXT_DOCUMENSO_CLIENT_RECIPIENT_ID=1
|
||||
NUXT_DOCUMENSO_DAVID_RECIPIENT_ID=2
|
||||
NUXT_DOCUMENSO_APPROVAL_RECIPIENT_ID=3
|
||||
|
||||
# Webhook Configuration for Embedded Signing
|
||||
WEBHOOK_SECRET_SIGNING=96BQQRiKkTIN2w0rHbqo7yHggV/sT8702HtHih3uNSY=
|
||||
|
|
|
|||
|
|
@ -198,7 +198,7 @@
|
|||
{{ getSignatureStatusText('cc') }}
|
||||
</v-chip>
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle :class="mobile ? 'text-caption' : ''">Approval</v-list-item-subtitle>
|
||||
<v-list-item-subtitle :class="mobile ? 'text-caption' : ''">Oscar Faragher</v-list-item-subtitle>
|
||||
<template v-slot:append>
|
||||
<div class="d-flex gap-1">
|
||||
<v-btn
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
>
|
||||
<v-card>
|
||||
<v-card-title class="d-flex align-center">
|
||||
<v-icon class="mr-2">mdi-plus</v-icon>
|
||||
<v-icon class="mr-2">mdi-receipt-text</v-icon>
|
||||
<span>Add New Expense</span>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
|
|
@ -19,35 +19,44 @@
|
|||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<v-form ref="form" @submit.prevent="handleSubmit">
|
||||
<v-form ref="form" @submit.prevent="saveExpense">
|
||||
<v-row>
|
||||
<!-- Establishment Name -->
|
||||
<!-- Merchant/Description -->
|
||||
<v-col cols="12">
|
||||
<v-text-field
|
||||
v-model="expense.establishmentName"
|
||||
label="Establishment Name"
|
||||
v-model="expense.merchant"
|
||||
label="Merchant/Description"
|
||||
variant="outlined"
|
||||
:rules="[rules.required]"
|
||||
required
|
||||
placeholder="e.g., Shell, American Airlines, etc."
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<!-- Price -->
|
||||
<v-col cols="12" sm="6">
|
||||
<!-- Amount and Currency -->
|
||||
<v-col cols="8">
|
||||
<v-text-field
|
||||
v-model="expense.price"
|
||||
label="Price"
|
||||
v-model="expense.amount"
|
||||
label="Amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
variant="outlined"
|
||||
:rules="[rules.required, rules.price]"
|
||||
:rules="[rules.required, rules.positive]"
|
||||
required
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="4">
|
||||
<v-select
|
||||
v-model="expense.currency"
|
||||
:items="currencies"
|
||||
label="Currency"
|
||||
variant="outlined"
|
||||
:rules="[rules.required]"
|
||||
required
|
||||
placeholder="e.g., 59.95"
|
||||
prepend-inner-icon="mdi-currency-eur"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<!-- Category -->
|
||||
<v-col cols="12" sm="6">
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-model="expense.category"
|
||||
:items="categories"
|
||||
|
|
@ -59,49 +68,60 @@
|
|||
</v-col>
|
||||
|
||||
<!-- Payer -->
|
||||
<v-col cols="12" sm="6">
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="expense.payer"
|
||||
label="Payer"
|
||||
variant="outlined"
|
||||
:rules="[rules.required]"
|
||||
required
|
||||
placeholder="e.g., John, Mary, etc."
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<!-- Payment Method -->
|
||||
<v-col cols="12" sm="6">
|
||||
<v-select
|
||||
v-model="expense.paymentMethod"
|
||||
:items="paymentMethods"
|
||||
label="Payment Method"
|
||||
variant="outlined"
|
||||
:rules="[rules.required]"
|
||||
required
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<!-- Date -->
|
||||
<v-col cols="12">
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="expense.date"
|
||||
type="date"
|
||||
label="Date"
|
||||
type="date"
|
||||
variant="outlined"
|
||||
:rules="[rules.required]"
|
||||
required
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<!-- Contents/Description -->
|
||||
<!-- Time -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="expense.time"
|
||||
label="Time"
|
||||
type="time"
|
||||
variant="outlined"
|
||||
:rules="[rules.required]"
|
||||
required
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<!-- Notes -->
|
||||
<v-col cols="12">
|
||||
<v-textarea
|
||||
v-model="expense.contents"
|
||||
label="Description (optional)"
|
||||
v-model="expense.notes"
|
||||
label="Notes (Optional)"
|
||||
variant="outlined"
|
||||
rows="3"
|
||||
placeholder="Additional details about the expense..."
|
||||
auto-grow
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<!-- Receipt Upload -->
|
||||
<v-col cols="12">
|
||||
<v-file-input
|
||||
v-model="expense.receipt"
|
||||
label="Receipt Image (Optional)"
|
||||
accept="image/*"
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-camera"
|
||||
show-size
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
|
@ -113,19 +133,17 @@
|
|||
<v-btn
|
||||
@click="closeModal"
|
||||
variant="text"
|
||||
:disabled="creating"
|
||||
:disabled="saving"
|
||||
>
|
||||
Cancel
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
@click="handleSubmit"
|
||||
:disabled="creating"
|
||||
@click="saveExpense"
|
||||
color="primary"
|
||||
:loading="creating"
|
||||
:loading="saving"
|
||||
:disabled="!isValid"
|
||||
>
|
||||
<v-icon v-if="!creating" class="mr-1">mdi-plus</v-icon>
|
||||
Create Expense
|
||||
Add Expense
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
|
|
@ -133,81 +151,82 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import type { Expense } from '@/utils/types';
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'created', expense: Expense): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
'created': [expense: any];
|
||||
}>();
|
||||
|
||||
// Computed dialog model
|
||||
const dialog = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
});
|
||||
|
||||
// Reactive state
|
||||
const form = ref();
|
||||
const creating = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
// Form data
|
||||
const expense = ref({
|
||||
establishmentName: '',
|
||||
price: '',
|
||||
merchant: '',
|
||||
amount: '',
|
||||
currency: 'EUR',
|
||||
category: '',
|
||||
payer: '',
|
||||
paymentMethod: '',
|
||||
date: '',
|
||||
contents: ''
|
||||
time: '',
|
||||
notes: '',
|
||||
receipt: null as File[] | null
|
||||
});
|
||||
|
||||
// Form options
|
||||
const categories = [
|
||||
'Food/Drinks',
|
||||
'Shop',
|
||||
'Online',
|
||||
'Other'
|
||||
];
|
||||
|
||||
const paymentMethods = [
|
||||
'Card',
|
||||
'Cash',
|
||||
'N/A'
|
||||
];
|
||||
const currencies = ['EUR', 'USD', 'GBP', 'AUD', 'CAD', 'CHF', 'SEK', 'NOK', 'DKK'];
|
||||
const categories = ['Food/Drinks', 'Shop', 'Online', 'Transportation', 'Accommodation', 'Entertainment', 'Other'];
|
||||
|
||||
// Validation rules
|
||||
const rules = {
|
||||
required: (value: string) => !!value || 'This field is required',
|
||||
price: (value: string) => {
|
||||
if (!value) return 'Price is required';
|
||||
required: (value: any) => !!value || 'This field is required',
|
||||
positive: (value: any) => {
|
||||
const num = parseFloat(value);
|
||||
if (isNaN(num) || num <= 0) return 'Please enter a valid price';
|
||||
return true;
|
||||
return (!isNaN(num) && num > 0) || 'Amount must be positive';
|
||||
}
|
||||
};
|
||||
|
||||
// Methods
|
||||
const closeModal = () => {
|
||||
dialog.value = false;
|
||||
resetForm();
|
||||
};
|
||||
// Computed properties
|
||||
const isValid = computed(() => {
|
||||
return !!(
|
||||
expense.value.merchant &&
|
||||
expense.value.amount &&
|
||||
expense.value.currency &&
|
||||
expense.value.category &&
|
||||
expense.value.payer &&
|
||||
expense.value.date &&
|
||||
expense.value.time &&
|
||||
parseFloat(expense.value.amount) > 0
|
||||
);
|
||||
});
|
||||
|
||||
// Methods
|
||||
const resetForm = () => {
|
||||
const now = new Date();
|
||||
expense.value = {
|
||||
establishmentName: '',
|
||||
price: '',
|
||||
merchant: '',
|
||||
amount: '',
|
||||
currency: 'EUR',
|
||||
category: '',
|
||||
payer: '',
|
||||
paymentMethod: '',
|
||||
date: '',
|
||||
contents: ''
|
||||
date: now.toISOString().slice(0, 10),
|
||||
time: now.toTimeString().slice(0, 5),
|
||||
notes: '',
|
||||
receipt: null
|
||||
};
|
||||
|
||||
if (form.value) {
|
||||
|
|
@ -215,56 +234,89 @@ const resetForm = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const closeModal = () => {
|
||||
if (!saving.value) {
|
||||
dialog.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveExpense = async () => {
|
||||
if (!form.value) return;
|
||||
|
||||
const { valid } = await form.value.validate();
|
||||
if (!valid) return;
|
||||
|
||||
creating.value = true;
|
||||
saving.value = true;
|
||||
|
||||
try {
|
||||
// Create expense via API
|
||||
const response = await $fetch<{
|
||||
success: boolean;
|
||||
data?: any;
|
||||
message?: string;
|
||||
}>('/api/create-expense', {
|
||||
// Combine date and time for the API
|
||||
const dateTime = `${expense.value.date}T${expense.value.time}:00`;
|
||||
|
||||
// Prepare the expense data
|
||||
const expenseData = {
|
||||
"Establishment Name": expense.value.merchant,
|
||||
Price: `${expense.value.currency}${expense.value.amount}`,
|
||||
Category: expense.value.category,
|
||||
Payer: expense.value.payer,
|
||||
Time: dateTime,
|
||||
Contents: expense.value.notes || null,
|
||||
"Payment Method": "Card", // Default to Card for now
|
||||
Paid: false,
|
||||
currency: expense.value.currency
|
||||
};
|
||||
|
||||
console.log('[ExpenseCreateModal] Creating expense:', expenseData);
|
||||
|
||||
// Call API to create expense
|
||||
const response = await $fetch<Expense>('/api/create-expense', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
'Establishment Name': expense.value.establishmentName,
|
||||
'Price': expense.value.price,
|
||||
'Category': expense.value.category,
|
||||
'Payer': expense.value.payer,
|
||||
'Payment Method': expense.value.paymentMethod,
|
||||
'Time': expense.value.date,
|
||||
'Contents': expense.value.contents
|
||||
}
|
||||
body: expenseData
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
emit('created', response.data);
|
||||
closeModal();
|
||||
}
|
||||
console.log('[ExpenseCreateModal] Expense created successfully:', response);
|
||||
|
||||
// Emit the created event
|
||||
emit('created', response);
|
||||
|
||||
// Close the modal
|
||||
dialog.value = false;
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[ExpenseCreateModal] Error creating expense:', error);
|
||||
// Handle error display here if needed
|
||||
|
||||
// Show error message (you might want to use a toast notification here)
|
||||
alert('Failed to create expense. Please try again.');
|
||||
} finally {
|
||||
creating.value = false;
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for modal open to set default date
|
||||
watch(dialog, (isOpen) => {
|
||||
if (isOpen && !expense.value.date) {
|
||||
expense.value.date = new Date().toISOString().slice(0, 10);
|
||||
// Watch for modal open/close
|
||||
watch(dialog, (newValue) => {
|
||||
if (newValue) {
|
||||
// Reset form when modal opens
|
||||
nextTick(() => {
|
||||
resetForm();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize form with current date/time
|
||||
onMounted(() => {
|
||||
resetForm();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-dialog > .v-card {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.v-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.v-card-actions {
|
||||
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -50,16 +50,15 @@ const checkForDuplicates = async () => {
|
|||
try {
|
||||
loading.value = true;
|
||||
|
||||
// Check roles with better error handling - use hasAnyRole for multiple roles
|
||||
const { hasAnyRole, isAdmin, isSalesOrAdmin } = useAuthorization();
|
||||
// Check roles with better error handling
|
||||
let canViewDuplicates = false;
|
||||
|
||||
try {
|
||||
canViewDuplicates = isSalesOrAdmin(); // Use the convenience method
|
||||
canViewDuplicates = await hasRole(['sales', 'admin']);
|
||||
console.log('[InterestDuplicateNotification] Role check result:', canViewDuplicates);
|
||||
} catch (roleError) {
|
||||
console.error('[InterestDuplicateNotification] Role check failed:', roleError);
|
||||
// Try to get user info directly as fallback
|
||||
const { isAdmin } = useAuthorization();
|
||||
canViewDuplicates = isAdmin();
|
||||
console.log('[InterestDuplicateNotification] Fallback admin check:', canViewDuplicates);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,19 +103,6 @@
|
|||
</template>
|
||||
</v-checkbox>
|
||||
|
||||
<v-checkbox
|
||||
v-model="options.includeReceiptContents"
|
||||
color="primary"
|
||||
hide-details
|
||||
>
|
||||
<template #label>
|
||||
<div>
|
||||
<div class="font-weight-medium">Include Receipt Contents</div>
|
||||
<div class="text-caption text-grey-darken-1">Show receipt description/contents in detail table</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-checkbox>
|
||||
|
||||
<v-checkbox
|
||||
v-model="options.includeProcessingFee"
|
||||
color="primary"
|
||||
|
|
@ -132,21 +119,8 @@
|
|||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<!-- Currency Selection -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-model="options.targetCurrency"
|
||||
:items="currencyOptions"
|
||||
label="Export Currency"
|
||||
variant="outlined"
|
||||
item-title="text"
|
||||
item-value="value"
|
||||
prepend-inner-icon="mdi-currency-usd"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<!-- Page Format -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-col cols="12">
|
||||
<v-select
|
||||
v-model="options.pageFormat"
|
||||
:items="pageFormatOptions"
|
||||
|
|
@ -230,12 +204,10 @@ interface PDFOptions {
|
|||
subheader: string;
|
||||
groupBy: 'none' | 'payer' | 'category' | 'date';
|
||||
includeReceipts: boolean;
|
||||
includeReceiptContents: boolean;
|
||||
includeSummary: boolean;
|
||||
includeDetails: boolean;
|
||||
includeProcessingFee: boolean;
|
||||
pageFormat: 'A4' | 'Letter' | 'Legal';
|
||||
targetCurrency: 'USD' | 'EUR';
|
||||
}
|
||||
|
||||
// Computed dialog model
|
||||
|
|
@ -253,12 +225,10 @@ const options = ref<PDFOptions>({
|
|||
subheader: '',
|
||||
groupBy: 'payer',
|
||||
includeReceipts: true,
|
||||
includeReceiptContents: true,
|
||||
includeSummary: true,
|
||||
includeDetails: true,
|
||||
includeProcessingFee: true,
|
||||
pageFormat: 'A4',
|
||||
targetCurrency: 'EUR'
|
||||
pageFormat: 'A4'
|
||||
});
|
||||
|
||||
// Form options
|
||||
|
|
@ -275,11 +245,6 @@ const pageFormatOptions = [
|
|||
{ text: 'Legal (8.5 × 14 in)', value: 'Legal' }
|
||||
];
|
||||
|
||||
const currencyOptions = [
|
||||
{ text: 'Euro (EUR)', value: 'EUR' },
|
||||
{ text: 'US Dollar (USD)', value: 'USD' }
|
||||
];
|
||||
|
||||
// Validation rules
|
||||
const rules = {
|
||||
required: (value: string) => !!value || 'This field is required'
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||
// Use a cached auth state to avoid excessive API calls
|
||||
const nuxtApp = useNuxtApp();
|
||||
const cacheKey = 'auth:session:cache';
|
||||
const cacheExpiry = 5 * 60 * 1000; // 5 minutes cache (increased from 30 seconds)
|
||||
const cacheExpiry = 30000; // 30 seconds cache
|
||||
|
||||
// Check if we have a cached session
|
||||
const cachedSession = nuxtApp.payload.data?.[cacheKey];
|
||||
|
|
@ -44,17 +44,14 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||
}
|
||||
|
||||
try {
|
||||
// Check Keycloak authentication via session API with timeout and retries
|
||||
// Check Keycloak authentication via session API with timeout
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000); // 10 second timeout (increased from 5)
|
||||
const timeout = setTimeout(() => controller.abort(), 5000); // 5 second timeout
|
||||
|
||||
const sessionData = await $fetch('/api/auth/session', {
|
||||
signal: controller.signal,
|
||||
retry: 2, // Increased retry count
|
||||
retryDelay: 1000, // Increased retry delay
|
||||
onRetry: ({ retries }: { retries: number }) => {
|
||||
console.log(`[MIDDLEWARE] Retrying auth check (attempt ${retries + 1})`)
|
||||
}
|
||||
retry: 1,
|
||||
retryDelay: 500
|
||||
}) as any;
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
|
@ -103,11 +100,11 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||
console.error('[MIDDLEWARE] Auth check failed:', error);
|
||||
|
||||
// If it's a network error or timeout, check if we have a recent cached session
|
||||
if (error.name === 'AbortError' || error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') {
|
||||
if (error.name === 'AbortError' || error.code === 'ECONNREFUSED') {
|
||||
console.log('[MIDDLEWARE] Network error, checking for recent cache');
|
||||
const recentCache = nuxtApp.payload.data?.[cacheKey];
|
||||
if (recentCache && recentCache.timestamp && (now - recentCache.timestamp) < 30 * 60 * 1000) { // 30 minutes grace period
|
||||
console.log('[MIDDLEWARE] Using recent cache despite network error (age:', Math.round((now - recentCache.timestamp) / 1000), 'seconds)');
|
||||
if (recentCache && recentCache.timestamp && (now - recentCache.timestamp) < 300000) { // 5 minutes
|
||||
console.log('[MIDDLEWARE] Using recent cache despite network error');
|
||||
if (recentCache.authenticated && recentCache.user) {
|
||||
// Store auth state for components
|
||||
if (!nuxtApp.payload.data) {
|
||||
|
|
@ -118,13 +115,6 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||
authenticated: recentCache.authenticated,
|
||||
groups: recentCache.groups || []
|
||||
};
|
||||
|
||||
// Show a warning toast if cache is older than 10 minutes
|
||||
if ((now - recentCache.timestamp) > 10 * 60 * 1000) {
|
||||
const toast = useToast();
|
||||
toast.warning('Network connectivity issue - using cached authentication');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,7 +79,6 @@ export default defineNuxtConfig({
|
|||
workbox: {
|
||||
navigateFallback: '/',
|
||||
globPatterns: ['**/*.{js,css,html,png,jpg,jpeg,svg,ico}'],
|
||||
navigateFallbackDenylist: [/^\/api\//],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https:\/\/cms\.portnimara\.dev\/.*/i,
|
||||
|
|
@ -95,9 +94,7 @@ export default defineNuxtConfig({
|
|||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
skipWaiting: true,
|
||||
clientsClaim: true
|
||||
]
|
||||
},
|
||||
client: {
|
||||
installPrompt: true,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@
|
|||
"nodemailer": "^7.0.3",
|
||||
"nuxt": "^3.15.4",
|
||||
"nuxt-directus": "^5.7.0",
|
||||
"pdfkit": "^0.17.1",
|
||||
"sharp": "^0.34.2",
|
||||
"v-phone-input": "^4.4.2",
|
||||
"vue": "latest",
|
||||
|
|
@ -34,8 +33,7 @@
|
|||
"@types/imap": "^0.8.42",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/pdfkit": "^0.14.0"
|
||||
"@types/nodemailer": "^6.4.17"
|
||||
}
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
|
|
@ -4576,16 +4574,6 @@
|
|||
"integrity": "sha512-LriObC2+KYZD3FzCrgWGv/qufdUy4eXrxcLgQMfYXgPbLIecKIsVBaQgUPmxSSLcjmYbDTQbMgr6qr6l/eb7Bg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/pdfkit": {
|
||||
"version": "0.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.14.0.tgz",
|
||||
"integrity": "sha512-X94hoZVr9dNfV23roeXRm57AWS+AOMak3gq2wZvn4TXiLvXE8+TrYaM5IkMyZbGRw49jEqI49rP/UVL3+C3Svg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/resolve": {
|
||||
"version": "1.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
|
||||
|
|
@ -6504,12 +6492,6 @@
|
|||
"uncrypto": "^0.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/crypto-js": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
|
||||
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crypto-random-string": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
|
||||
|
|
@ -6814,9 +6796,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
||||
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
|
|
@ -9787,12 +9769,6 @@
|
|||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jpeg-exif": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
|
||||
"integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-cookie": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
|
||||
|
|
@ -10039,25 +10015,6 @@
|
|||
"url": "https://github.com/sponsors/antonk52"
|
||||
}
|
||||
},
|
||||
"node_modules/linebreak": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "0.0.8",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linebreak/node_modules/base64-js": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
||||
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
|
|
@ -11389,19 +11346,6 @@
|
|||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdfkit": {
|
||||
"version": "0.17.1",
|
||||
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.1.tgz",
|
||||
"integrity": "sha512-Kkf1I9no14O/uo593DYph5u3QwiMfby7JsBSErN1WqeyTgCBNJE3K4pXBn3TgkdKUIVu+buSl4bYUNC+8Up4xg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"crypto-js": "^4.2.0",
|
||||
"fontkit": "^2.0.4",
|
||||
"jpeg-exif": "^1.1.4",
|
||||
"linebreak": "^1.1.0",
|
||||
"png-js": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/peberminta": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
|
||||
|
|
@ -11462,11 +11406,6 @@
|
|||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/png-js": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz",
|
||||
"integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g=="
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
|
||||
|
|
@ -17037,9 +16976,9 @@
|
|||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
|
||||
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@
|
|||
"nodemailer": "^7.0.3",
|
||||
"nuxt": "^3.15.4",
|
||||
"nuxt-directus": "^5.7.0",
|
||||
"pdfkit": "^0.17.1",
|
||||
"sharp": "^0.34.2",
|
||||
"v-phone-input": "^4.4.2",
|
||||
"vue": "latest",
|
||||
|
|
@ -36,7 +35,6 @@
|
|||
"@types/imap": "^0.8.42",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/pdfkit": "^0.14.0"
|
||||
"@types/nodemailer": "^6.4.17"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -291,36 +291,6 @@
|
|||
v-model="showCreateModal"
|
||||
@created="handleExpenseCreated"
|
||||
/>
|
||||
|
||||
<!-- PDF Generation Loading Overlay -->
|
||||
<v-overlay
|
||||
:model-value="generatingPDF"
|
||||
persistent
|
||||
class="align-center justify-center"
|
||||
>
|
||||
<v-card
|
||||
color="surface"
|
||||
class="pa-8"
|
||||
width="400"
|
||||
>
|
||||
<div class="text-center">
|
||||
<v-progress-circular
|
||||
:size="70"
|
||||
:width="7"
|
||||
color="primary"
|
||||
indeterminate
|
||||
/>
|
||||
|
||||
<h3 class="text-h6 mt-4 mb-2">Generating PDF...</h3>
|
||||
<p class="text-body-2 text-grey-darken-1">
|
||||
Your expense report is being generated with receipt images
|
||||
</p>
|
||||
<p class="text-caption text-grey-darken-1 mt-2">
|
||||
This may take a moment for large reports
|
||||
</p>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-overlay>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -354,7 +324,6 @@ const showDetailsModal = ref(false);
|
|||
const showCreateModal = ref(false);
|
||||
const selectedExpense = ref<Expense | null>(null);
|
||||
const activeTab = ref<string>('');
|
||||
const generatingPDF = ref(false);
|
||||
|
||||
// Filters
|
||||
const filters = ref({
|
||||
|
|
@ -444,17 +413,7 @@ const fetchExpenses = async () => {
|
|||
|
||||
} catch (err: any) {
|
||||
console.error('[expenses] Error fetching expenses:', err);
|
||||
|
||||
// Better error messages based on status codes
|
||||
if (err.statusCode === 401) {
|
||||
error.value = 'Authentication required. Please refresh the page and log in again.';
|
||||
} else if (err.statusCode === 403) {
|
||||
error.value = 'Access denied. You need proper permissions to view expenses.';
|
||||
} else if (err.statusCode === 503) {
|
||||
error.value = 'Service temporarily unavailable. Please try again in a few moments.';
|
||||
} else {
|
||||
error.value = err.data?.message || err.message || 'Failed to fetch expenses. Please check your connection and try again.';
|
||||
}
|
||||
error.value = err.message || 'Failed to fetch expenses';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
|
@ -525,9 +484,6 @@ const exportCSV = async () => {
|
|||
};
|
||||
|
||||
const generatePDF = async (options: any) => {
|
||||
generatingPDF.value = true;
|
||||
showPDFModal.value = false; // Close the modal immediately
|
||||
|
||||
try {
|
||||
console.log('[expenses] Generating PDF with options:', options);
|
||||
|
||||
|
|
@ -548,33 +504,30 @@ const generatePDF = async (options: any) => {
|
|||
});
|
||||
|
||||
if (response.success && response.data) {
|
||||
// Decode base64 PDF content
|
||||
const pdfContent = atob(response.data.content);
|
||||
|
||||
// Convert to byte array
|
||||
const byteNumbers = new Array(pdfContent.length);
|
||||
for (let i = 0; i < pdfContent.length; i++) {
|
||||
byteNumbers[i] = pdfContent.charCodeAt(i);
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
|
||||
// Create PDF blob and download
|
||||
const blob = new Blob([byteArray], { type: 'application/pdf' });
|
||||
// For now, create HTML file instead of PDF since we're generating HTML content
|
||||
const htmlContent = atob(response.data.content); // Decode base64
|
||||
const blob = new Blob([htmlContent], { type: 'text/html' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = response.data.filename;
|
||||
a.download = `${options.documentName || 'expenses'}.html`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
console.log('[expenses] PDF downloaded successfully:', response.data.filename);
|
||||
// Also open in new tab for immediate viewing
|
||||
const newTab = window.open();
|
||||
if (newTab) {
|
||||
newTab.document.open();
|
||||
newTab.document.write(htmlContent);
|
||||
newTab.document.close();
|
||||
}
|
||||
}
|
||||
|
||||
showPDFModal.value = false;
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('[expenses] Error generating PDF:', err);
|
||||
error.value = err.message || 'Failed to generate PDF';
|
||||
} finally {
|
||||
generatingPDF.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ export default defineNuxtPlugin(() => {
|
|||
|
||||
let refreshTimer: NodeJS.Timeout | null = null
|
||||
let isRefreshing = false
|
||||
let retryCount = 0
|
||||
const maxRetries = 3
|
||||
|
||||
const scheduleTokenRefresh = (expiresAt: number) => {
|
||||
// Clear existing timer
|
||||
|
|
@ -14,13 +12,11 @@ export default defineNuxtPlugin(() => {
|
|||
refreshTimer = null
|
||||
}
|
||||
|
||||
// Calculate time until refresh (refresh 5 minutes before expiry)
|
||||
const refreshBuffer = 5 * 60 * 1000 // 5 minutes in milliseconds
|
||||
// Calculate time until refresh (refresh 2 minutes before expiry)
|
||||
const refreshBuffer = 2 * 60 * 1000 // 2 minutes in milliseconds
|
||||
const timeUntilRefresh = expiresAt - Date.now() - refreshBuffer
|
||||
|
||||
console.log('[AUTH_REFRESH] Scheduling token refresh in:', Math.max(0, timeUntilRefresh), 'ms')
|
||||
console.log('[AUTH_REFRESH] Token expires at:', new Date(expiresAt))
|
||||
console.log('[AUTH_REFRESH] Will refresh at:', new Date(expiresAt - refreshBuffer))
|
||||
|
||||
// Only schedule if we have time left
|
||||
if (timeUntilRefresh > 0) {
|
||||
|
|
@ -32,37 +28,20 @@ export default defineNuxtPlugin(() => {
|
|||
console.log('[AUTH_REFRESH] Attempting automatic token refresh...')
|
||||
|
||||
const response = await $fetch<{ success: boolean; expiresAt?: number }>('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
retry: 2,
|
||||
retryDelay: 1000
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
if (response.success && response.expiresAt) {
|
||||
console.log('[AUTH_REFRESH] Token refresh successful, scheduling next refresh')
|
||||
retryCount = 0 // Reset retry count on success
|
||||
scheduleTokenRefresh(response.expiresAt)
|
||||
} else {
|
||||
console.error('[AUTH_REFRESH] Token refresh failed, redirecting to login')
|
||||
await navigateTo('/login')
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error('[AUTH_REFRESH] Token refresh error:', error)
|
||||
|
||||
// Implement exponential backoff retry
|
||||
if (retryCount < maxRetries) {
|
||||
retryCount++
|
||||
const retryDelay = Math.min(1000 * Math.pow(2, retryCount), 10000) // Max 10 seconds
|
||||
console.log(`[AUTH_REFRESH] Retrying refresh in ${retryDelay}ms (attempt ${retryCount}/${maxRetries})`)
|
||||
|
||||
setTimeout(() => {
|
||||
if (!isRefreshing) {
|
||||
scheduleTokenRefresh(expiresAt)
|
||||
}
|
||||
}, retryDelay)
|
||||
} else {
|
||||
console.error('[AUTH_REFRESH] Max retries reached, redirecting to login')
|
||||
await navigateTo('/login')
|
||||
}
|
||||
// If refresh fails, redirect to login
|
||||
await navigateTo('/login')
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
|
|
@ -77,14 +56,11 @@ export default defineNuxtPlugin(() => {
|
|||
console.log('[AUTH_REFRESH] Token expired, attempting immediate refresh...')
|
||||
|
||||
const response = await $fetch<{ success: boolean; expiresAt?: number }>('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
retry: 2,
|
||||
retryDelay: 1000
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
if (response.success && response.expiresAt) {
|
||||
console.log('[AUTH_REFRESH] Immediate refresh successful')
|
||||
retryCount = 0 // Reset retry count on success
|
||||
scheduleTokenRefresh(response.expiresAt)
|
||||
} else {
|
||||
console.error('[AUTH_REFRESH] Immediate refresh failed, redirecting to login')
|
||||
|
|
@ -92,19 +68,7 @@ export default defineNuxtPlugin(() => {
|
|||
}
|
||||
} catch (error) {
|
||||
console.error('[AUTH_REFRESH] Immediate refresh error:', error)
|
||||
|
||||
// Try one more time before giving up
|
||||
if (retryCount === 0) {
|
||||
retryCount++
|
||||
console.log('[AUTH_REFRESH] Retrying immediate refresh once more...')
|
||||
setTimeout(() => {
|
||||
if (!isRefreshing) {
|
||||
scheduleTokenRefresh(Date.now() - 1) // Force immediate refresh
|
||||
}
|
||||
}, 2000)
|
||||
} else {
|
||||
await navigateTo('/login')
|
||||
}
|
||||
await navigateTo('/login')
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
|
|
@ -163,20 +127,10 @@ export default defineNuxtPlugin(() => {
|
|||
|
||||
// Listen for visibility changes to refresh when tab becomes active
|
||||
if (typeof document !== 'undefined') {
|
||||
let lastVisibilityChange = Date.now()
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) {
|
||||
const now = Date.now()
|
||||
const timeSinceLastCheck = now - lastVisibilityChange
|
||||
|
||||
// If tab was hidden for more than 1 minute, check auth status
|
||||
if (timeSinceLastCheck > 60000) {
|
||||
console.log('[AUTH_REFRESH] Tab became visible after', Math.round(timeSinceLastCheck / 1000), 'seconds, checking auth status')
|
||||
checkAndScheduleRefresh()
|
||||
}
|
||||
|
||||
lastVisibilityChange = now
|
||||
// Tab became visible, check if we need to refresh
|
||||
checkAndScheduleRefresh()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,10 +57,7 @@ export default defineEventHandler(async (event) => {
|
|||
// Documenso API configuration - moved to top for use throughout
|
||||
const documensoApiKey = process.env.NUXT_DOCUMENSO_API_KEY;
|
||||
const documensoBaseUrl = process.env.NUXT_DOCUMENSO_BASE_URL;
|
||||
const templateId = process.env.NUXT_DOCUMENSO_TEMPLATE_ID || '1';
|
||||
const clientRecipientId = parseInt(process.env.NUXT_DOCUMENSO_CLIENT_RECIPIENT_ID || '1');
|
||||
const davidRecipientId = parseInt(process.env.NUXT_DOCUMENSO_DAVID_RECIPIENT_ID || '2');
|
||||
const approvalRecipientId = parseInt(process.env.NUXT_DOCUMENSO_APPROVAL_RECIPIENT_ID || '3');
|
||||
const templateId = '9';
|
||||
|
||||
if (!documensoApiKey || !documensoBaseUrl) {
|
||||
throw createError({
|
||||
|
|
@ -234,7 +231,7 @@ export default defineEventHandler(async (event) => {
|
|||
message: `Dear ${interest['Full Name']},\n\nThank you for your interest in a berth at Port Nimara. Please click the link above to sign your LOI.\n\nBest Regards,\nPort Nimara Team`,
|
||||
subject: "Your LOI is ready to be signed",
|
||||
redirectUrl: "https://portnimara.com",
|
||||
distributionMethod: "NONE"
|
||||
distributionMethod: "SEQUENTIAL"
|
||||
},
|
||||
title: `${interest['Full Name']}-EOI-NDA`,
|
||||
externalId: `loi-${interestId}`,
|
||||
|
|
@ -252,22 +249,22 @@ export default defineEventHandler(async (event) => {
|
|||
},
|
||||
recipients: [
|
||||
{
|
||||
id: clientRecipientId,
|
||||
id: 155,
|
||||
name: interest['Full Name'],
|
||||
role: "SIGNER",
|
||||
email: interest['Email Address'],
|
||||
signingOrder: 1
|
||||
},
|
||||
{
|
||||
id: davidRecipientId,
|
||||
id: 156,
|
||||
name: "David Mizrahi",
|
||||
role: "SIGNER",
|
||||
email: "dm@portnimara.com",
|
||||
signingOrder: 3
|
||||
},
|
||||
{
|
||||
id: approvalRecipientId,
|
||||
name: "Approval",
|
||||
id: 157,
|
||||
name: "Oscar Faragher",
|
||||
role: "APPROVER",
|
||||
email: "sales@portnimara.com",
|
||||
signingOrder: 2
|
||||
|
|
@ -340,7 +337,7 @@ export default defineEventHandler(async (event) => {
|
|||
} else if (recipient.email === 'dm@portnimara.com') {
|
||||
signingLinks['David Mizrahi'] = recipient.signingUrl;
|
||||
} else if (recipient.email === 'sales@portnimara.com') {
|
||||
signingLinks['Approval'] = recipient.signingUrl;
|
||||
signingLinks['Oscar Faragher'] = recipient.signingUrl;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -395,11 +392,11 @@ export default defineEventHandler(async (event) => {
|
|||
updateData['EmbeddedSignatureLinkDeveloper'] = embeddedDevUrl;
|
||||
console.log('[EMBEDDED] Developer URL:', signingLinks['David Mizrahi'], '-> Embedded:', embeddedDevUrl);
|
||||
}
|
||||
if (signingLinks['Approval']) {
|
||||
updateData['Signature Link CC'] = signingLinks['Approval'];
|
||||
const embeddedCCUrl = createEmbeddedSigningUrl(signingLinks['Approval'], 'cc');
|
||||
if (signingLinks['Oscar Faragher']) {
|
||||
updateData['Signature Link CC'] = signingLinks['Oscar Faragher'];
|
||||
const embeddedCCUrl = createEmbeddedSigningUrl(signingLinks['Oscar Faragher'], 'cc');
|
||||
updateData['EmbeddedSignatureLinkCC'] = embeddedCCUrl;
|
||||
console.log('[EMBEDDED] CC URL:', signingLinks['Approval'], '-> Embedded:', embeddedCCUrl);
|
||||
console.log('[EMBEDDED] CC URL:', signingLinks['Oscar Faragher'], '-> Embedded:', embeddedCCUrl);
|
||||
}
|
||||
|
||||
console.log('[EMBEDDED] Final updateData being sent to NocoDB:', updateData);
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ export default defineEventHandler(async (event) => {
|
|||
try {
|
||||
const body = await readBody(event);
|
||||
const { interestId } = body;
|
||||
const query = getQuery(event);
|
||||
|
||||
console.log('[Delete Generated EOI] Interest ID:', interestId);
|
||||
|
||||
|
|
@ -78,132 +77,51 @@ export default defineEventHandler(async (event) => {
|
|||
|
||||
console.log('[Delete Generated EOI] Deleting document from Documenso');
|
||||
let documensoDeleteSuccessful = false;
|
||||
let retryCount = 0;
|
||||
const maxRetries = 3;
|
||||
|
||||
// Retry logic for temporary failures
|
||||
while (!documensoDeleteSuccessful && retryCount < maxRetries) {
|
||||
try {
|
||||
const deleteResponse = await fetch(`${documensoBaseUrl}/api/v1/documents/${documensoID}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${documensoApiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const responseStatus = deleteResponse.status;
|
||||
let errorDetails = '';
|
||||
|
||||
try {
|
||||
errorDetails = await deleteResponse.text();
|
||||
} catch {
|
||||
errorDetails = 'No error details available';
|
||||
try {
|
||||
const deleteResponse = await fetch(`${documensoBaseUrl}/api/v1/documents/${documensoID}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${documensoApiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!deleteResponse.ok) {
|
||||
console.error(`[Delete Generated EOI] Documenso deletion failed (attempt ${retryCount + 1}/${maxRetries}):`, {
|
||||
status: responseStatus,
|
||||
statusText: deleteResponse.statusText,
|
||||
details: errorDetails
|
||||
});
|
||||
if (!deleteResponse.ok) {
|
||||
const errorText = await deleteResponse.text();
|
||||
console.error('[Delete Generated EOI] Documenso deletion failed:', errorText);
|
||||
|
||||
// Handle specific status codes
|
||||
switch (responseStatus) {
|
||||
case 404:
|
||||
// Document already deleted - this is fine
|
||||
console.log('[Delete Generated EOI] Document already deleted from Documenso (404) - proceeding with database cleanup');
|
||||
documensoDeleteSuccessful = true;
|
||||
break;
|
||||
|
||||
case 403:
|
||||
// Permission denied - document might be in a protected state
|
||||
console.warn('[Delete Generated EOI] Permission denied (403) - document may be in a protected state');
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Cannot delete document - it may be fully signed or in a protected state',
|
||||
});
|
||||
|
||||
case 500:
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
// Server errors - retry if we haven't exceeded retries
|
||||
if (retryCount < maxRetries - 1) {
|
||||
console.log(`[Delete Generated EOI] Server error (${responseStatus}) - retrying in ${(retryCount + 1) * 2} seconds...`);
|
||||
await new Promise(resolve => setTimeout(resolve, (retryCount + 1) * 2000)); // Exponential backoff
|
||||
retryCount++;
|
||||
continue;
|
||||
} else {
|
||||
console.error('[Delete Generated EOI] Max retries exceeded for server error');
|
||||
// Allow proceeding with cleanup for server errors after retries
|
||||
if (query.forceCleanup === 'true') {
|
||||
console.warn('[Delete Generated EOI] Force cleanup enabled - proceeding despite Documenso error');
|
||||
documensoDeleteSuccessful = true;
|
||||
break;
|
||||
}
|
||||
throw new Error(`Documenso server error after ${maxRetries} attempts (${responseStatus}): ${errorDetails}`);
|
||||
}
|
||||
|
||||
default:
|
||||
// Other errors - don't retry
|
||||
throw new Error(`Documenso API error (${responseStatus}): ${errorDetails || deleteResponse.statusText}`);
|
||||
}
|
||||
// If it's a 404, the document is already gone, which is what we want
|
||||
if (deleteResponse.status === 404) {
|
||||
console.log('[Delete Generated EOI] Document already deleted from Documenso (404) - proceeding with database cleanup');
|
||||
documensoDeleteSuccessful = true;
|
||||
} else {
|
||||
console.log('[Delete Generated EOI] Successfully deleted document from Documenso');
|
||||
documensoDeleteSuccessful = true;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`[Delete Generated EOI] Documenso deletion error (attempt ${retryCount + 1}/${maxRetries}):`, error);
|
||||
|
||||
// Network errors - retry if we haven't exceeded retries
|
||||
if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') {
|
||||
if (retryCount < maxRetries - 1) {
|
||||
console.log(`[Delete Generated EOI] Network error - retrying in ${(retryCount + 1) * 2} seconds...`);
|
||||
await new Promise(resolve => setTimeout(resolve, (retryCount + 1) * 2000));
|
||||
retryCount++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a 404 error wrapped in another error
|
||||
if (error.message?.includes('404') || error.status === 404 || error.statusCode === 404) {
|
||||
console.log('[Delete Generated EOI] Document not found in Documenso - proceeding with database cleanup');
|
||||
documensoDeleteSuccessful = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if force cleanup is enabled
|
||||
const query = getQuery(event);
|
||||
if (query.forceCleanup === 'true') {
|
||||
console.warn('[Delete Generated EOI] Force cleanup enabled - proceeding despite Documenso error:', error.message);
|
||||
documensoDeleteSuccessful = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Don't wrap error messages multiple times
|
||||
if (error.statusCode) {
|
||||
throw error;
|
||||
throw new Error(`Failed to delete document from Documenso: ${deleteResponse.statusText}`);
|
||||
}
|
||||
} else {
|
||||
console.log('[Delete Generated EOI] Successfully deleted document from Documenso');
|
||||
documensoDeleteSuccessful = true;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('[Delete Generated EOI] Documenso deletion error:', error);
|
||||
|
||||
// Check if it's a network error or 404 - in those cases, proceed with cleanup
|
||||
if (error.message?.includes('404') || error.status === 404) {
|
||||
console.log('[Delete Generated EOI] Document not found in Documenso - proceeding with database cleanup');
|
||||
documensoDeleteSuccessful = true;
|
||||
} else {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: error.message || 'Failed to communicate with Documenso API',
|
||||
statusMessage: `Failed to delete document from Documenso: ${error.message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!documensoDeleteSuccessful) {
|
||||
const query = getQuery(event);
|
||||
if (query.forceCleanup === 'true') {
|
||||
console.warn('[Delete Generated EOI] Force cleanup enabled - proceeding with database cleanup despite Documenso failure');
|
||||
documensoDeleteSuccessful = true;
|
||||
} else {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to delete document from Documenso after multiple attempts. You can add ?forceCleanup=true to force database cleanup.',
|
||||
});
|
||||
}
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to delete document from Documenso',
|
||||
});
|
||||
}
|
||||
|
||||
// Reset interest fields
|
||||
|
|
|
|||
|
|
@ -78,8 +78,6 @@ export default defineEventHandler(async (event) => {
|
|||
* Find duplicate expenses based on multiple criteria
|
||||
*/
|
||||
function findDuplicateExpenses(expenses: any[]) {
|
||||
console.log('[EXPENSES] Starting duplicate detection for', expenses.length, 'expenses');
|
||||
|
||||
const duplicateGroups: Array<{
|
||||
id: string;
|
||||
expenses: any[];
|
||||
|
|
@ -89,7 +87,6 @@ function findDuplicateExpenses(expenses: any[]) {
|
|||
}> = [];
|
||||
|
||||
const processedIds = new Set<number>();
|
||||
let comparisons = 0;
|
||||
|
||||
for (let i = 0; i < expenses.length; i++) {
|
||||
const expense1 = expenses[i];
|
||||
|
|
@ -105,13 +102,8 @@ function findDuplicateExpenses(expenses: any[]) {
|
|||
if (processedIds.has(expense2.Id)) continue;
|
||||
|
||||
const similarity = calculateExpenseSimilarity(expense1, expense2);
|
||||
comparisons++;
|
||||
|
||||
console.log(`[EXPENSES] Comparing ${expense1.Id} vs ${expense2.Id}: score=${similarity.score.toFixed(3)}, threshold=0.7`);
|
||||
|
||||
if (similarity.score >= 0.7) { // Lower threshold for expenses
|
||||
console.log(`[EXPENSES] MATCH FOUND! ${expense1.Id} vs ${expense2.Id} (score: ${similarity.score.toFixed(3)})`);
|
||||
console.log('[EXPENSES] Match reasons:', similarity.reasons);
|
||||
if (similarity.score >= 0.8) {
|
||||
matches.push(expense2);
|
||||
processedIds.add(expense2.Id);
|
||||
similarity.reasons.forEach(r => matchReasons.add(r));
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -7,13 +7,9 @@ export default defineEventHandler(async (event) => {
|
|||
console.log('[get-expenses] API called with query:', getQuery(event));
|
||||
|
||||
try {
|
||||
// Set proper headers
|
||||
setHeader(event, 'Cache-Control', 'no-cache');
|
||||
setHeader(event, 'Content-Type', 'application/json');
|
||||
// Check authentication first
|
||||
// Check authentication
|
||||
try {
|
||||
await requireSalesOrAdmin(event);
|
||||
console.log('[get-expenses] Authentication successful');
|
||||
} catch (authError: any) {
|
||||
console.error('[get-expenses] Authentication failed:', authError);
|
||||
|
||||
|
|
@ -131,34 +127,14 @@ export default defineEventHandler(async (event) => {
|
|||
statusMessage: 'Unable to fetch expense data. Please try again later.'
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('[get-expenses] Top-level error:', error);
|
||||
|
||||
// If it's already a proper H3 error, re-throw it
|
||||
if (error.statusCode) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Handle authentication errors specifically
|
||||
if (error.message?.includes('authentication') || error.message?.includes('auth')) {
|
||||
} catch (authError: any) {
|
||||
if (authError.statusCode === 403) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Authentication required. Please log in again.'
|
||||
statusCode: 403,
|
||||
statusMessage: 'Access denied. This feature requires sales team or administrator privileges.'
|
||||
});
|
||||
}
|
||||
|
||||
// Handle database connection errors
|
||||
if (error.message?.includes('database') || error.message?.includes('connection')) {
|
||||
throw createError({
|
||||
statusCode: 503,
|
||||
statusMessage: 'Database temporarily unavailable. Please try again later.'
|
||||
});
|
||||
}
|
||||
|
||||
// Generic server error for anything else
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'An unexpected error occurred. Please try again later.'
|
||||
});
|
||||
throw authError;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -86,9 +86,6 @@ export default defineEventHandler(async (event) => {
|
|||
* Find duplicate interests based on multiple criteria
|
||||
*/
|
||||
function findDuplicateInterests(interests: any[], threshold: number = 0.8) {
|
||||
console.log('[INTERESTS] Starting duplicate detection with threshold:', threshold);
|
||||
console.log('[INTERESTS] Total interests to analyze:', interests.length);
|
||||
|
||||
const duplicateGroups: Array<{
|
||||
id: string;
|
||||
interests: any[];
|
||||
|
|
@ -98,7 +95,6 @@ function findDuplicateInterests(interests: any[], threshold: number = 0.8) {
|
|||
}> = [];
|
||||
|
||||
const processedIds = new Set<number>();
|
||||
let comparisons = 0;
|
||||
|
||||
for (let i = 0; i < interests.length; i++) {
|
||||
const interest1 = interests[i];
|
||||
|
|
@ -113,21 +109,14 @@ function findDuplicateInterests(interests: any[], threshold: number = 0.8) {
|
|||
if (processedIds.has(interest2.Id)) continue;
|
||||
|
||||
const similarity = calculateSimilarity(interest1, interest2);
|
||||
comparisons++;
|
||||
|
||||
console.log(`[INTERESTS] Comparing ${interest1.Id} vs ${interest2.Id}: score=${similarity.score.toFixed(3)}, threshold=${threshold}`);
|
||||
|
||||
if (similarity.score >= threshold) {
|
||||
console.log(`[INTERESTS] MATCH FOUND! ${interest1.Id} vs ${interest2.Id} (score: ${similarity.score.toFixed(3)})`);
|
||||
console.log('[INTERESTS] Match details:', similarity.details);
|
||||
matches.push(interest2);
|
||||
processedIds.add(interest2.Id);
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length > 1) {
|
||||
console.log(`[INTERESTS] Creating duplicate group with ${matches.length} matches`);
|
||||
|
||||
// Mark all as processed
|
||||
matches.forEach(match => processedIds.add(match.Id));
|
||||
|
||||
|
|
@ -149,7 +138,6 @@ function findDuplicateInterests(interests: any[], threshold: number = 0.8) {
|
|||
}
|
||||
}
|
||||
|
||||
console.log(`[INTERESTS] Completed ${comparisons} comparisons, found ${duplicateGroups.length} duplicate groups`);
|
||||
return duplicateGroups;
|
||||
}
|
||||
|
||||
|
|
@ -159,67 +147,36 @@ function findDuplicateInterests(interests: any[], threshold: number = 0.8) {
|
|||
function calculateSimilarity(interest1: any, interest2: any) {
|
||||
const scores: Array<{ type: string; score: number; weight: number }> = [];
|
||||
|
||||
console.log(`[INTERESTS] Calculating similarity between:`, {
|
||||
id1: interest1.Id,
|
||||
name1: interest1['Full Name'],
|
||||
email1: interest1['Email Address'],
|
||||
phone1: interest1['Phone Number'],
|
||||
id2: interest2.Id,
|
||||
name2: interest2['Full Name'],
|
||||
email2: interest2['Email Address'],
|
||||
phone2: interest2['Phone Number']
|
||||
});
|
||||
|
||||
// Email similarity (highest weight) - exact match required
|
||||
// Email similarity (highest weight)
|
||||
if (interest1['Email Address'] && interest2['Email Address']) {
|
||||
const email1 = normalizeEmail(interest1['Email Address']);
|
||||
const email2 = normalizeEmail(interest2['Email Address']);
|
||||
const emailScore = email1 === email2 ? 1.0 : 0.0;
|
||||
scores.push({ type: 'email', score: emailScore, weight: 0.5 });
|
||||
console.log(`[INTERESTS] Email comparison: "${email1}" vs "${email2}" = ${emailScore}`);
|
||||
const emailScore = normalizeEmail(interest1['Email Address']) === normalizeEmail(interest2['Email Address']) ? 1.0 : 0.0;
|
||||
scores.push({ type: 'email', score: emailScore, weight: 0.4 });
|
||||
}
|
||||
|
||||
// Phone similarity - exact match on normalized numbers
|
||||
// Phone similarity
|
||||
if (interest1['Phone Number'] && interest2['Phone Number']) {
|
||||
const phone1 = normalizePhone(interest1['Phone Number']);
|
||||
const phone2 = normalizePhone(interest2['Phone Number']);
|
||||
const phoneScore = phone1 === phone2 && phone1.length >= 8 ? 1.0 : 0.0; // Require at least 8 digits
|
||||
scores.push({ type: 'phone', score: phoneScore, weight: 0.4 });
|
||||
console.log(`[INTERESTS] Phone comparison: "${phone1}" vs "${phone2}" = ${phoneScore}`);
|
||||
const phoneScore = phone1 === phone2 ? 1.0 : 0.0;
|
||||
scores.push({ type: 'phone', score: phoneScore, weight: 0.3 });
|
||||
}
|
||||
|
||||
// Name similarity - fuzzy matching
|
||||
// Name similarity
|
||||
if (interest1['Full Name'] && interest2['Full Name']) {
|
||||
const nameScore = calculateNameSimilarity(interest1['Full Name'], interest2['Full Name']);
|
||||
scores.push({ type: 'name', score: nameScore, weight: 0.3 });
|
||||
console.log(`[INTERESTS] Name comparison: "${interest1['Full Name']}" vs "${interest2['Full Name']}" = ${nameScore.toFixed(3)}`);
|
||||
scores.push({ type: 'name', score: nameScore, weight: 0.2 });
|
||||
}
|
||||
|
||||
// Address similarity
|
||||
if (interest1.Address && interest2.Address) {
|
||||
const addressScore = calculateStringSimilarity(interest1.Address, interest2.Address);
|
||||
scores.push({ type: 'address', score: addressScore, weight: 0.2 });
|
||||
console.log(`[INTERESTS] Address comparison: ${addressScore.toFixed(3)}`);
|
||||
scores.push({ type: 'address', score: addressScore, weight: 0.1 });
|
||||
}
|
||||
|
||||
// Special case: if we have exact email OR phone match, give high score regardless of other fields
|
||||
const hasExactEmailMatch = scores.find(s => s.type === 'email' && s.score === 1.0);
|
||||
const hasExactPhoneMatch = scores.find(s => s.type === 'phone' && s.score === 1.0);
|
||||
|
||||
if (hasExactEmailMatch || hasExactPhoneMatch) {
|
||||
console.log('[INTERESTS] Exact email or phone match found - high confidence');
|
||||
return {
|
||||
score: 0.95, // High confidence for exact email/phone match
|
||||
details: scores
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate weighted average for other cases
|
||||
// Calculate weighted average
|
||||
const totalWeight = scores.reduce((sum, s) => sum + s.weight, 0);
|
||||
const weightedScore = scores.reduce((sum, s) => sum + (s.score * s.weight), 0) / (totalWeight || 1);
|
||||
|
||||
console.log(`[INTERESTS] Weighted score: ${weightedScore.toFixed(3)} (weights: ${totalWeight})`);
|
||||
|
||||
return {
|
||||
score: weightedScore,
|
||||
details: scores
|
||||
|
|
|
|||
|
|
@ -303,80 +303,6 @@ export const convertToUSD = async (amount: number, fromCurrency: string): Promis
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert amount from one currency to EUR
|
||||
*/
|
||||
export const convertToEUR = async (amount: number, fromCurrency: string): Promise<{
|
||||
eurAmount: number;
|
||||
rate: number;
|
||||
conversionDate: string;
|
||||
} | null> => {
|
||||
// If already EUR, no conversion needed
|
||||
if (fromCurrency.toUpperCase() === 'EUR') {
|
||||
return {
|
||||
eurAmount: amount,
|
||||
rate: 1.0,
|
||||
conversionDate: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const rateCache = await getExchangeRates();
|
||||
|
||||
if (!rateCache) {
|
||||
console.error('[currency] No exchange rates available for conversion');
|
||||
return null;
|
||||
}
|
||||
|
||||
const fromCurrencyUpper = fromCurrency.toUpperCase();
|
||||
|
||||
// Get USD -> EUR rate
|
||||
const usdToEurRate = rateCache.rates['EUR'];
|
||||
|
||||
if (!usdToEurRate) {
|
||||
console.error('[currency] EUR rate not available');
|
||||
return null;
|
||||
}
|
||||
|
||||
// If converting from USD to EUR
|
||||
if (fromCurrencyUpper === 'USD') {
|
||||
const eurAmount = amount * usdToEurRate;
|
||||
console.log(`[currency] Converted ${amount} USD to ${eurAmount.toFixed(2)} EUR (rate: ${usdToEurRate.toFixed(4)})`);
|
||||
|
||||
return {
|
||||
eurAmount: parseFloat(eurAmount.toFixed(2)),
|
||||
rate: parseFloat(usdToEurRate.toFixed(4)),
|
||||
conversionDate: rateCache.lastUpdated
|
||||
};
|
||||
}
|
||||
|
||||
// For other currencies, convert through USD first
|
||||
const usdToSourceRate = rateCache.rates[fromCurrencyUpper];
|
||||
|
||||
if (!usdToSourceRate) {
|
||||
console.error(`[currency] Currency ${fromCurrencyUpper} not supported`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate: Source -> USD -> EUR
|
||||
// Source -> USD: amount / usdToSourceRate
|
||||
// USD -> EUR: (amount / usdToSourceRate) * usdToEurRate
|
||||
const sourceToEurRate = usdToEurRate / usdToSourceRate;
|
||||
const eurAmount = amount * sourceToEurRate;
|
||||
|
||||
console.log(`[currency] Converted ${amount} ${fromCurrencyUpper} to ${eurAmount.toFixed(2)} EUR (rate: ${sourceToEurRate.toFixed(4)})`);
|
||||
|
||||
return {
|
||||
eurAmount: parseFloat(eurAmount.toFixed(2)),
|
||||
rate: parseFloat(sourceToEurRate.toFixed(4)),
|
||||
conversionDate: rateCache.lastUpdated
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[currency] Error during EUR conversion:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Format price with currency symbol
|
||||
*/
|
||||
|
|
@ -477,160 +403,46 @@ export const getCacheStatus = async (): Promise<{
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert amount from any currency to target currency
|
||||
*/
|
||||
export const convertToTargetCurrency = async (
|
||||
amount: number,
|
||||
fromCurrency: string,
|
||||
targetCurrency: string
|
||||
): Promise<{
|
||||
targetAmount: number;
|
||||
rate: number;
|
||||
conversionDate: string;
|
||||
} | null> => {
|
||||
// If same currency, no conversion needed
|
||||
if (fromCurrency.toUpperCase() === targetCurrency.toUpperCase()) {
|
||||
return {
|
||||
targetAmount: amount,
|
||||
rate: 1.0,
|
||||
conversionDate: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
// Use existing functions for specific conversions
|
||||
if (targetCurrency.toUpperCase() === 'USD') {
|
||||
const result = await convertToUSD(amount, fromCurrency);
|
||||
if (result) {
|
||||
return {
|
||||
targetAmount: result.usdAmount,
|
||||
rate: result.rate,
|
||||
conversionDate: result.conversionDate
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (targetCurrency.toUpperCase() === 'EUR') {
|
||||
const result = await convertToEUR(amount, fromCurrency);
|
||||
if (result) {
|
||||
return {
|
||||
targetAmount: result.eurAmount,
|
||||
rate: result.rate,
|
||||
conversionDate: result.conversionDate
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// For other currencies, convert through USD
|
||||
try {
|
||||
const rateCache = await getExchangeRates();
|
||||
|
||||
if (!rateCache) {
|
||||
console.error('[currency] No exchange rates available for conversion');
|
||||
return null;
|
||||
}
|
||||
|
||||
const fromCurrencyUpper = fromCurrency.toUpperCase();
|
||||
const targetCurrencyUpper = targetCurrency.toUpperCase();
|
||||
|
||||
// Get rates
|
||||
const usdToFromRate = rateCache.rates[fromCurrencyUpper];
|
||||
const usdToTargetRate = rateCache.rates[targetCurrencyUpper];
|
||||
|
||||
if (!usdToFromRate || !usdToTargetRate) {
|
||||
console.error(`[currency] Currency not supported: ${!usdToFromRate ? fromCurrencyUpper : targetCurrencyUpper}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate: Source -> USD -> Target
|
||||
const fromToTargetRate = usdToTargetRate / usdToFromRate;
|
||||
const targetAmount = amount * fromToTargetRate;
|
||||
|
||||
console.log(`[currency] Converted ${amount} ${fromCurrencyUpper} to ${targetAmount.toFixed(2)} ${targetCurrencyUpper} (rate: ${fromToTargetRate.toFixed(4)})`);
|
||||
|
||||
return {
|
||||
targetAmount: parseFloat(targetAmount.toFixed(2)),
|
||||
rate: parseFloat(fromToTargetRate.toFixed(4)),
|
||||
conversionDate: rateCache.lastUpdated
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[currency] Error during currency conversion:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Enhanced expense processing with currency conversion
|
||||
*/
|
||||
export const processExpenseWithCurrency = async (expense: any, targetCurrency: string = 'EUR'): Promise<any> => {
|
||||
export const processExpenseWithCurrency = async (expense: any): Promise<any> => {
|
||||
const processedExpense = { ...expense };
|
||||
|
||||
// Parse price number
|
||||
const priceNumber = parseFloat(expense.Price?.toString().replace(/[^\d.-]/g, '')) || 0;
|
||||
processedExpense.PriceNumber = priceNumber;
|
||||
|
||||
// Get currency code and symbol
|
||||
// Get currency symbol
|
||||
const currencyCode = expense.currency || 'USD';
|
||||
processedExpense.Currency = currencyCode;
|
||||
processedExpense.CurrencySymbol = getCurrencySymbol(currencyCode);
|
||||
|
||||
// Convert to target currency if not already in target
|
||||
const targetCurrencyUpper = targetCurrency.toUpperCase();
|
||||
const targetField = `Price${targetCurrencyUpper}`;
|
||||
|
||||
if (currencyCode.toUpperCase() !== targetCurrencyUpper) {
|
||||
const conversion = await convertToTargetCurrency(priceNumber, currencyCode, targetCurrency);
|
||||
// Convert to USD if not already USD
|
||||
if (currencyCode.toUpperCase() !== 'USD') {
|
||||
const conversion = await convertToUSD(priceNumber, currencyCode);
|
||||
|
||||
if (conversion) {
|
||||
processedExpense[targetField] = conversion.targetAmount;
|
||||
processedExpense.PriceUSD = conversion.usdAmount;
|
||||
processedExpense.ConversionRate = conversion.rate;
|
||||
processedExpense.ConversionDate = conversion.conversionDate;
|
||||
processedExpense.TargetCurrency = targetCurrencyUpper;
|
||||
}
|
||||
} else {
|
||||
// If already in target currency, set target amount to original amount
|
||||
processedExpense[targetField] = priceNumber;
|
||||
// If already USD, set USD amount to original amount
|
||||
processedExpense.PriceUSD = priceNumber;
|
||||
processedExpense.ConversionRate = 1.0;
|
||||
processedExpense.ConversionDate = new Date().toISOString();
|
||||
processedExpense.TargetCurrency = targetCurrencyUpper;
|
||||
}
|
||||
|
||||
// Also convert to USD and EUR for compatibility
|
||||
if (currencyCode.toUpperCase() !== 'USD') {
|
||||
const usdConversion = await convertToUSD(priceNumber, currencyCode);
|
||||
if (usdConversion) {
|
||||
processedExpense.PriceUSD = usdConversion.usdAmount;
|
||||
}
|
||||
} else {
|
||||
processedExpense.PriceUSD = priceNumber;
|
||||
}
|
||||
|
||||
if (currencyCode.toUpperCase() !== 'EUR') {
|
||||
const eurConversion = await convertToEUR(priceNumber, currencyCode);
|
||||
if (eurConversion) {
|
||||
processedExpense.PriceEUR = eurConversion.eurAmount;
|
||||
}
|
||||
} else {
|
||||
processedExpense.PriceEUR = priceNumber;
|
||||
}
|
||||
|
||||
// Create display prices
|
||||
processedExpense.DisplayPrice = formatPriceWithCurrency(priceNumber, currencyCode);
|
||||
processedExpense.DisplayPrice = createDisplayPrice(
|
||||
priceNumber,
|
||||
currencyCode,
|
||||
processedExpense.PriceUSD
|
||||
);
|
||||
|
||||
// Create display price with target currency conversion
|
||||
const targetAmount = processedExpense[targetField];
|
||||
if (currencyCode.toUpperCase() !== targetCurrencyUpper && targetAmount) {
|
||||
const targetSymbol = getCurrencySymbol(targetCurrency);
|
||||
processedExpense.DisplayPriceWithTarget = `${formatPriceWithCurrency(priceNumber, currencyCode)} (${targetSymbol}${targetAmount.toFixed(2)})`;
|
||||
} else {
|
||||
processedExpense.DisplayPriceWithTarget = formatPriceWithCurrency(priceNumber, currencyCode);
|
||||
}
|
||||
|
||||
processedExpense.DisplayPriceTarget = formatPriceWithCurrency(
|
||||
targetAmount || priceNumber,
|
||||
targetCurrency
|
||||
processedExpense.DisplayPriceUSD = formatPriceWithCurrency(
|
||||
processedExpense.PriceUSD || priceNumber,
|
||||
'USD'
|
||||
);
|
||||
|
||||
return processedExpense;
|
||||
|
|
|
|||
Loading…
Reference in New Issue