Compare commits
10 Commits
4740040202
...
3ba8542e4f
| Author | SHA1 | Date |
|---|---|---|
|
|
3ba8542e4f | |
|
|
2928d9a7ed | |
|
|
6e99f4f783 | |
|
|
a00b3918be | |
|
|
06500a614d | |
|
|
9d49245efa | |
|
|
893927d4b1 | |
|
|
b6d71faf5f | |
|
|
3f90db0392 | |
|
|
a83895bef3 |
|
|
@ -17,6 +17,10 @@ NUXT_EMAIL_LOGO_URL=https://portnimara.com/Port_Nimara_Logo_2_Colour_New_Transpa
|
||||||
# Documenso Configuration
|
# Documenso Configuration
|
||||||
NUXT_DOCUMENSO_API_KEY=your_documenso_api_key_here
|
NUXT_DOCUMENSO_API_KEY=your_documenso_api_key_here
|
||||||
NUXT_DOCUMENSO_BASE_URL=https://signatures.portnimara.dev
|
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 Configuration for Embedded Signing
|
||||||
WEBHOOK_SECRET_SIGNING=96BQQRiKkTIN2w0rHbqo7yHggV/sT8702HtHih3uNSY=
|
WEBHOOK_SECRET_SIGNING=96BQQRiKkTIN2w0rHbqo7yHggV/sT8702HtHih3uNSY=
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,7 @@
|
||||||
{{ getSignatureStatusText('cc') }}
|
{{ getSignatureStatusText('cc') }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
</v-list-item-title>
|
</v-list-item-title>
|
||||||
<v-list-item-subtitle :class="mobile ? 'text-caption' : ''">Oscar Faragher</v-list-item-subtitle>
|
<v-list-item-subtitle :class="mobile ? 'text-caption' : ''">Approval</v-list-item-subtitle>
|
||||||
<template v-slot:append>
|
<template v-slot:append>
|
||||||
<div class="d-flex gap-1">
|
<div class="d-flex gap-1">
|
||||||
<v-btn
|
<v-btn
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
>
|
>
|
||||||
<v-card>
|
<v-card>
|
||||||
<v-card-title class="d-flex align-center">
|
<v-card-title class="d-flex align-center">
|
||||||
<v-icon class="mr-2">mdi-receipt-text</v-icon>
|
<v-icon class="mr-2">mdi-plus</v-icon>
|
||||||
<span>Add New Expense</span>
|
<span>Add New Expense</span>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn
|
<v-btn
|
||||||
|
|
@ -19,44 +19,35 @@
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
|
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-form ref="form" @submit.prevent="saveExpense">
|
<v-form ref="form" @submit.prevent="handleSubmit">
|
||||||
<v-row>
|
<v-row>
|
||||||
<!-- Merchant/Description -->
|
<!-- Establishment Name -->
|
||||||
<v-col cols="12">
|
<v-col cols="12">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="expense.merchant"
|
v-model="expense.establishmentName"
|
||||||
label="Merchant/Description"
|
label="Establishment Name"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
:rules="[rules.required]"
|
:rules="[rules.required]"
|
||||||
required
|
required
|
||||||
|
placeholder="e.g., Shell, American Airlines, etc."
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
|
||||||
<!-- Amount and Currency -->
|
<!-- Price -->
|
||||||
<v-col cols="8">
|
<v-col cols="12" sm="6">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="expense.amount"
|
v-model="expense.price"
|
||||||
label="Amount"
|
label="Price"
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
:rules="[rules.required, rules.positive]"
|
:rules="[rules.required, rules.price]"
|
||||||
required
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="4">
|
|
||||||
<v-select
|
|
||||||
v-model="expense.currency"
|
|
||||||
:items="currencies"
|
|
||||||
label="Currency"
|
|
||||||
variant="outlined"
|
|
||||||
:rules="[rules.required]"
|
|
||||||
required
|
required
|
||||||
|
placeholder="e.g., 59.95"
|
||||||
|
prepend-inner-icon="mdi-currency-eur"
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
|
||||||
<!-- Category -->
|
<!-- Category -->
|
||||||
<v-col cols="12" md="6">
|
<v-col cols="12" sm="6">
|
||||||
<v-select
|
<v-select
|
||||||
v-model="expense.category"
|
v-model="expense.category"
|
||||||
:items="categories"
|
:items="categories"
|
||||||
|
|
@ -68,60 +59,49 @@
|
||||||
</v-col>
|
</v-col>
|
||||||
|
|
||||||
<!-- Payer -->
|
<!-- Payer -->
|
||||||
<v-col cols="12" md="6">
|
<v-col cols="12" sm="6">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="expense.payer"
|
v-model="expense.payer"
|
||||||
label="Payer"
|
label="Payer"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
:rules="[rules.required]"
|
:rules="[rules.required]"
|
||||||
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>
|
</v-col>
|
||||||
|
|
||||||
<!-- Date -->
|
<!-- Date -->
|
||||||
<v-col cols="12" md="6">
|
<v-col cols="12">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="expense.date"
|
v-model="expense.date"
|
||||||
label="Date"
|
|
||||||
type="date"
|
type="date"
|
||||||
|
label="Date"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
:rules="[rules.required]"
|
:rules="[rules.required]"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
|
||||||
<!-- Time -->
|
<!-- Contents/Description -->
|
||||||
<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-col cols="12">
|
||||||
<v-textarea
|
<v-textarea
|
||||||
v-model="expense.notes"
|
v-model="expense.contents"
|
||||||
label="Notes (Optional)"
|
label="Description (optional)"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
rows="3"
|
rows="3"
|
||||||
auto-grow
|
placeholder="Additional details about the expense..."
|
||||||
/>
|
|
||||||
</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-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|
@ -133,17 +113,19 @@
|
||||||
<v-btn
|
<v-btn
|
||||||
@click="closeModal"
|
@click="closeModal"
|
||||||
variant="text"
|
variant="text"
|
||||||
:disabled="saving"
|
:disabled="creating"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
<v-btn
|
<v-btn
|
||||||
@click="saveExpense"
|
@click="handleSubmit"
|
||||||
|
:disabled="creating"
|
||||||
color="primary"
|
color="primary"
|
||||||
:loading="saving"
|
:loading="creating"
|
||||||
:disabled="!isValid"
|
|
||||||
>
|
>
|
||||||
Add Expense
|
<v-icon v-if="!creating" class="mr-1">mdi-plus</v-icon>
|
||||||
|
Create Expense
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
@ -151,82 +133,81 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, nextTick } from 'vue';
|
import { ref, computed, watch } from 'vue';
|
||||||
import type { Expense } from '@/utils/types';
|
|
||||||
|
|
||||||
|
// Props
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue: boolean;
|
modelValue: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Emits {
|
|
||||||
(e: 'update:modelValue', value: boolean): void;
|
|
||||||
(e: 'created', expense: Expense): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
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({
|
const dialog = computed({
|
||||||
get: () => props.modelValue,
|
get: () => props.modelValue,
|
||||||
set: (value) => emit('update:modelValue', value)
|
set: (value) => emit('update:modelValue', value)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reactive state
|
||||||
const form = ref();
|
const form = ref();
|
||||||
const saving = ref(false);
|
const creating = ref(false);
|
||||||
|
|
||||||
// Form data
|
|
||||||
const expense = ref({
|
const expense = ref({
|
||||||
merchant: '',
|
establishmentName: '',
|
||||||
amount: '',
|
price: '',
|
||||||
currency: 'EUR',
|
|
||||||
category: '',
|
category: '',
|
||||||
payer: '',
|
payer: '',
|
||||||
|
paymentMethod: '',
|
||||||
date: '',
|
date: '',
|
||||||
time: '',
|
contents: ''
|
||||||
notes: '',
|
|
||||||
receipt: null as File[] | null
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Form options
|
// Form options
|
||||||
const currencies = ['EUR', 'USD', 'GBP', 'AUD', 'CAD', 'CHF', 'SEK', 'NOK', 'DKK'];
|
const categories = [
|
||||||
const categories = ['Food/Drinks', 'Shop', 'Online', 'Transportation', 'Accommodation', 'Entertainment', 'Other'];
|
'Food/Drinks',
|
||||||
|
'Shop',
|
||||||
|
'Online',
|
||||||
|
'Other'
|
||||||
|
];
|
||||||
|
|
||||||
|
const paymentMethods = [
|
||||||
|
'Card',
|
||||||
|
'Cash',
|
||||||
|
'N/A'
|
||||||
|
];
|
||||||
|
|
||||||
// Validation rules
|
// Validation rules
|
||||||
const rules = {
|
const rules = {
|
||||||
required: (value: any) => !!value || 'This field is required',
|
required: (value: string) => !!value || 'This field is required',
|
||||||
positive: (value: any) => {
|
price: (value: string) => {
|
||||||
|
if (!value) return 'Price is required';
|
||||||
const num = parseFloat(value);
|
const num = parseFloat(value);
|
||||||
return (!isNaN(num) && num > 0) || 'Amount must be positive';
|
if (isNaN(num) || num <= 0) return 'Please enter a valid price';
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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
|
// Methods
|
||||||
|
const closeModal = () => {
|
||||||
|
dialog.value = false;
|
||||||
|
resetForm();
|
||||||
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
const now = new Date();
|
|
||||||
expense.value = {
|
expense.value = {
|
||||||
merchant: '',
|
establishmentName: '',
|
||||||
amount: '',
|
price: '',
|
||||||
currency: 'EUR',
|
|
||||||
category: '',
|
category: '',
|
||||||
payer: '',
|
payer: '',
|
||||||
date: now.toISOString().slice(0, 10),
|
paymentMethod: '',
|
||||||
time: now.toTimeString().slice(0, 5),
|
date: '',
|
||||||
notes: '',
|
contents: ''
|
||||||
receipt: null
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (form.value) {
|
if (form.value) {
|
||||||
|
|
@ -234,89 +215,56 @@ const resetForm = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeModal = () => {
|
const handleSubmit = async () => {
|
||||||
if (!saving.value) {
|
|
||||||
dialog.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveExpense = async () => {
|
|
||||||
if (!form.value) return;
|
if (!form.value) return;
|
||||||
|
|
||||||
const { valid } = await form.value.validate();
|
const { valid } = await form.value.validate();
|
||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
|
|
||||||
saving.value = true;
|
creating.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Combine date and time for the API
|
// Create expense via API
|
||||||
const dateTime = `${expense.value.date}T${expense.value.time}:00`;
|
const response = await $fetch<{
|
||||||
|
success: boolean;
|
||||||
// Prepare the expense data
|
data?: any;
|
||||||
const expenseData = {
|
message?: string;
|
||||||
"Establishment Name": expense.value.merchant,
|
}>('/api/create-expense', {
|
||||||
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',
|
method: 'POST',
|
||||||
body: expenseData
|
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
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[ExpenseCreateModal] Expense created successfully:', response);
|
if (response.success) {
|
||||||
|
emit('created', response.data);
|
||||||
// Emit the created event
|
closeModal();
|
||||||
emit('created', response);
|
}
|
||||||
|
|
||||||
// Close the modal
|
|
||||||
dialog.value = false;
|
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[ExpenseCreateModal] Error creating expense:', error);
|
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 {
|
} finally {
|
||||||
saving.value = false;
|
creating.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Watch for modal open/close
|
// Watch for modal open to set default date
|
||||||
watch(dialog, (newValue) => {
|
watch(dialog, (isOpen) => {
|
||||||
if (newValue) {
|
if (isOpen && !expense.value.date) {
|
||||||
// Reset form when modal opens
|
expense.value.date = new Date().toISOString().slice(0, 10);
|
||||||
nextTick(() => {
|
|
||||||
resetForm();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize form with current date/time
|
|
||||||
onMounted(() => {
|
|
||||||
resetForm();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.v-dialog > .v-card {
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-form {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-card-actions {
|
.v-card-actions {
|
||||||
border-top: 1px solid rgba(0, 0, 0, 0.12);
|
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -50,15 +50,16 @@ const checkForDuplicates = async () => {
|
||||||
try {
|
try {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
// Check roles with better error handling
|
// Check roles with better error handling - use hasAnyRole for multiple roles
|
||||||
|
const { hasAnyRole, isAdmin, isSalesOrAdmin } = useAuthorization();
|
||||||
let canViewDuplicates = false;
|
let canViewDuplicates = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
canViewDuplicates = await hasRole(['sales', 'admin']);
|
canViewDuplicates = isSalesOrAdmin(); // Use the convenience method
|
||||||
console.log('[InterestDuplicateNotification] Role check result:', canViewDuplicates);
|
console.log('[InterestDuplicateNotification] Role check result:', canViewDuplicates);
|
||||||
} catch (roleError) {
|
} catch (roleError) {
|
||||||
console.error('[InterestDuplicateNotification] Role check failed:', roleError);
|
console.error('[InterestDuplicateNotification] Role check failed:', roleError);
|
||||||
// Try to get user info directly as fallback
|
// Try to get user info directly as fallback
|
||||||
const { isAdmin } = useAuthorization();
|
|
||||||
canViewDuplicates = isAdmin();
|
canViewDuplicates = isAdmin();
|
||||||
console.log('[InterestDuplicateNotification] Fallback admin check:', canViewDuplicates);
|
console.log('[InterestDuplicateNotification] Fallback admin check:', canViewDuplicates);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,19 @@
|
||||||
</template>
|
</template>
|
||||||
</v-checkbox>
|
</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-checkbox
|
||||||
v-model="options.includeProcessingFee"
|
v-model="options.includeProcessingFee"
|
||||||
color="primary"
|
color="primary"
|
||||||
|
|
@ -119,8 +132,21 @@
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-col>
|
</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 -->
|
<!-- Page Format -->
|
||||||
<v-col cols="12">
|
<v-col cols="12" md="6">
|
||||||
<v-select
|
<v-select
|
||||||
v-model="options.pageFormat"
|
v-model="options.pageFormat"
|
||||||
:items="pageFormatOptions"
|
:items="pageFormatOptions"
|
||||||
|
|
@ -204,10 +230,12 @@ interface PDFOptions {
|
||||||
subheader: string;
|
subheader: string;
|
||||||
groupBy: 'none' | 'payer' | 'category' | 'date';
|
groupBy: 'none' | 'payer' | 'category' | 'date';
|
||||||
includeReceipts: boolean;
|
includeReceipts: boolean;
|
||||||
|
includeReceiptContents: boolean;
|
||||||
includeSummary: boolean;
|
includeSummary: boolean;
|
||||||
includeDetails: boolean;
|
includeDetails: boolean;
|
||||||
includeProcessingFee: boolean;
|
includeProcessingFee: boolean;
|
||||||
pageFormat: 'A4' | 'Letter' | 'Legal';
|
pageFormat: 'A4' | 'Letter' | 'Legal';
|
||||||
|
targetCurrency: 'USD' | 'EUR';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Computed dialog model
|
// Computed dialog model
|
||||||
|
|
@ -225,10 +253,12 @@ const options = ref<PDFOptions>({
|
||||||
subheader: '',
|
subheader: '',
|
||||||
groupBy: 'payer',
|
groupBy: 'payer',
|
||||||
includeReceipts: true,
|
includeReceipts: true,
|
||||||
|
includeReceiptContents: true,
|
||||||
includeSummary: true,
|
includeSummary: true,
|
||||||
includeDetails: true,
|
includeDetails: true,
|
||||||
includeProcessingFee: true,
|
includeProcessingFee: true,
|
||||||
pageFormat: 'A4'
|
pageFormat: 'A4',
|
||||||
|
targetCurrency: 'EUR'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Form options
|
// Form options
|
||||||
|
|
@ -245,6 +275,11 @@ const pageFormatOptions = [
|
||||||
{ text: 'Legal (8.5 × 14 in)', value: 'Legal' }
|
{ text: 'Legal (8.5 × 14 in)', value: 'Legal' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const currencyOptions = [
|
||||||
|
{ text: 'Euro (EUR)', value: 'EUR' },
|
||||||
|
{ text: 'US Dollar (USD)', value: 'USD' }
|
||||||
|
];
|
||||||
|
|
||||||
// Validation rules
|
// Validation rules
|
||||||
const rules = {
|
const rules = {
|
||||||
required: (value: string) => !!value || 'This field is required'
|
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
|
// 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 = 30000; // 30 seconds cache
|
const cacheExpiry = 5 * 60 * 1000; // 5 minutes cache (increased from 30 seconds)
|
||||||
|
|
||||||
// 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];
|
||||||
|
|
@ -44,14 +44,17 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check Keycloak authentication via session API with timeout
|
// Check Keycloak authentication via session API with timeout and retries
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 5000); // 5 second timeout
|
const timeout = setTimeout(() => controller.abort(), 10000); // 10 second timeout (increased from 5)
|
||||||
|
|
||||||
const sessionData = await $fetch('/api/auth/session', {
|
const sessionData = await $fetch('/api/auth/session', {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
retry: 1,
|
retry: 2, // Increased retry count
|
||||||
retryDelay: 500
|
retryDelay: 1000, // Increased retry delay
|
||||||
|
onRetry: ({ retries }: { retries: number }) => {
|
||||||
|
console.log(`[MIDDLEWARE] Retrying auth check (attempt ${retries + 1})`)
|
||||||
|
}
|
||||||
}) as any;
|
}) as any;
|
||||||
|
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
@ -100,11 +103,11 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
console.error('[MIDDLEWARE] Auth check failed:', error);
|
console.error('[MIDDLEWARE] Auth check failed:', error);
|
||||||
|
|
||||||
// If it's a network error or timeout, check if we have a recent cached session
|
// If it's a network error or timeout, check if we have a recent cached session
|
||||||
if (error.name === 'AbortError' || error.code === 'ECONNREFUSED') {
|
if (error.name === 'AbortError' || error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') {
|
||||||
console.log('[MIDDLEWARE] Network error, checking for recent cache');
|
console.log('[MIDDLEWARE] Network error, checking for recent cache');
|
||||||
const recentCache = nuxtApp.payload.data?.[cacheKey];
|
const recentCache = nuxtApp.payload.data?.[cacheKey];
|
||||||
if (recentCache && recentCache.timestamp && (now - recentCache.timestamp) < 300000) { // 5 minutes
|
if (recentCache && recentCache.timestamp && (now - recentCache.timestamp) < 30 * 60 * 1000) { // 30 minutes grace period
|
||||||
console.log('[MIDDLEWARE] Using recent cache despite network error');
|
console.log('[MIDDLEWARE] Using recent cache despite network error (age:', Math.round((now - recentCache.timestamp) / 1000), 'seconds)');
|
||||||
if (recentCache.authenticated && recentCache.user) {
|
if (recentCache.authenticated && recentCache.user) {
|
||||||
// Store auth state for components
|
// Store auth state for components
|
||||||
if (!nuxtApp.payload.data) {
|
if (!nuxtApp.payload.data) {
|
||||||
|
|
@ -115,6 +118,13 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
authenticated: recentCache.authenticated,
|
authenticated: recentCache.authenticated,
|
||||||
groups: recentCache.groups || []
|
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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ export default defineNuxtConfig({
|
||||||
workbox: {
|
workbox: {
|
||||||
navigateFallback: '/',
|
navigateFallback: '/',
|
||||||
globPatterns: ['**/*.{js,css,html,png,jpg,jpeg,svg,ico}'],
|
globPatterns: ['**/*.{js,css,html,png,jpg,jpeg,svg,ico}'],
|
||||||
|
navigateFallbackDenylist: [/^\/api\//],
|
||||||
runtimeCaching: [
|
runtimeCaching: [
|
||||||
{
|
{
|
||||||
urlPattern: /^https:\/\/cms\.portnimara\.dev\/.*/i,
|
urlPattern: /^https:\/\/cms\.portnimara\.dev\/.*/i,
|
||||||
|
|
@ -94,7 +95,9 @@ export default defineNuxtConfig({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
skipWaiting: true,
|
||||||
|
clientsClaim: true
|
||||||
},
|
},
|
||||||
client: {
|
client: {
|
||||||
installPrompt: true,
|
installPrompt: true,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
"nodemailer": "^7.0.3",
|
"nodemailer": "^7.0.3",
|
||||||
"nuxt": "^3.15.4",
|
"nuxt": "^3.15.4",
|
||||||
"nuxt-directus": "^5.7.0",
|
"nuxt-directus": "^5.7.0",
|
||||||
|
"pdfkit": "^0.17.1",
|
||||||
"sharp": "^0.34.2",
|
"sharp": "^0.34.2",
|
||||||
"v-phone-input": "^4.4.2",
|
"v-phone-input": "^4.4.2",
|
||||||
"vue": "latest",
|
"vue": "latest",
|
||||||
|
|
@ -33,7 +34,8 @@
|
||||||
"@types/imap": "^0.8.42",
|
"@types/imap": "^0.8.42",
|
||||||
"@types/mailparser": "^3.4.6",
|
"@types/mailparser": "^3.4.6",
|
||||||
"@types/mime-types": "^3.0.1",
|
"@types/mime-types": "^3.0.1",
|
||||||
"@types/nodemailer": "^6.4.17"
|
"@types/nodemailer": "^6.4.17",
|
||||||
|
"@types/pdfkit": "^0.14.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ampproject/remapping": {
|
"node_modules/@ampproject/remapping": {
|
||||||
|
|
@ -4574,6 +4576,16 @@
|
||||||
"integrity": "sha512-LriObC2+KYZD3FzCrgWGv/qufdUy4eXrxcLgQMfYXgPbLIecKIsVBaQgUPmxSSLcjmYbDTQbMgr6qr6l/eb7Bg==",
|
"integrity": "sha512-LriObC2+KYZD3FzCrgWGv/qufdUy4eXrxcLgQMfYXgPbLIecKIsVBaQgUPmxSSLcjmYbDTQbMgr6qr6l/eb7Bg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/resolve": {
|
||||||
"version": "1.20.2",
|
"version": "1.20.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
|
||||||
|
|
@ -6492,6 +6504,12 @@
|
||||||
"uncrypto": "^0.1.3"
|
"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": {
|
"node_modules/crypto-random-string": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
|
||||||
|
|
@ -6796,9 +6814,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.0",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||||
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
|
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
|
|
@ -9769,6 +9787,12 @@
|
||||||
"jiti": "bin/jiti.js"
|
"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": {
|
"node_modules/js-cookie": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
|
||||||
|
|
@ -10015,6 +10039,25 @@
|
||||||
"url": "https://github.com/sponsors/antonk52"
|
"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": {
|
"node_modules/linkify-it": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||||
|
|
@ -11346,6 +11389,19 @@
|
||||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/peberminta": {
|
||||||
"version": "0.9.0",
|
"version": "0.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
|
||||||
|
|
@ -11406,6 +11462,11 @@
|
||||||
"node": ">=4"
|
"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": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
|
||||||
|
|
@ -16976,9 +17037,9 @@
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.18.0",
|
"version": "8.18.3",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||||
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
|
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
"nodemailer": "^7.0.3",
|
"nodemailer": "^7.0.3",
|
||||||
"nuxt": "^3.15.4",
|
"nuxt": "^3.15.4",
|
||||||
"nuxt-directus": "^5.7.0",
|
"nuxt-directus": "^5.7.0",
|
||||||
|
"pdfkit": "^0.17.1",
|
||||||
"sharp": "^0.34.2",
|
"sharp": "^0.34.2",
|
||||||
"v-phone-input": "^4.4.2",
|
"v-phone-input": "^4.4.2",
|
||||||
"vue": "latest",
|
"vue": "latest",
|
||||||
|
|
@ -35,6 +36,7 @@
|
||||||
"@types/imap": "^0.8.42",
|
"@types/imap": "^0.8.42",
|
||||||
"@types/mailparser": "^3.4.6",
|
"@types/mailparser": "^3.4.6",
|
||||||
"@types/mime-types": "^3.0.1",
|
"@types/mime-types": "^3.0.1",
|
||||||
"@types/nodemailer": "^6.4.17"
|
"@types/nodemailer": "^6.4.17",
|
||||||
|
"@types/pdfkit": "^0.14.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -291,6 +291,36 @@
|
||||||
v-model="showCreateModal"
|
v-model="showCreateModal"
|
||||||
@created="handleExpenseCreated"
|
@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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -324,6 +354,7 @@ const showDetailsModal = ref(false);
|
||||||
const showCreateModal = ref(false);
|
const showCreateModal = ref(false);
|
||||||
const selectedExpense = ref<Expense | null>(null);
|
const selectedExpense = ref<Expense | null>(null);
|
||||||
const activeTab = ref<string>('');
|
const activeTab = ref<string>('');
|
||||||
|
const generatingPDF = ref(false);
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
const filters = ref({
|
const filters = ref({
|
||||||
|
|
@ -413,7 +444,17 @@ const fetchExpenses = async () => {
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[expenses] Error fetching expenses:', err);
|
console.error('[expenses] Error fetching expenses:', err);
|
||||||
error.value = err.message || 'Failed to fetch expenses';
|
|
||||||
|
// 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.';
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
|
@ -484,6 +525,9 @@ const exportCSV = async () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const generatePDF = async (options: any) => {
|
const generatePDF = async (options: any) => {
|
||||||
|
generatingPDF.value = true;
|
||||||
|
showPDFModal.value = false; // Close the modal immediately
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[expenses] Generating PDF with options:', options);
|
console.log('[expenses] Generating PDF with options:', options);
|
||||||
|
|
||||||
|
|
@ -504,30 +548,33 @@ const generatePDF = async (options: any) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
// For now, create HTML file instead of PDF since we're generating HTML content
|
// Decode base64 PDF content
|
||||||
const htmlContent = atob(response.data.content); // Decode base64
|
const pdfContent = atob(response.data.content);
|
||||||
const blob = new Blob([htmlContent], { type: 'text/html' });
|
|
||||||
|
// 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' });
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `${options.documentName || 'expenses'}.html`;
|
a.download = response.data.filename;
|
||||||
a.click();
|
a.click();
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
// Also open in new tab for immediate viewing
|
console.log('[expenses] PDF downloaded successfully:', response.data.filename);
|
||||||
const newTab = window.open();
|
|
||||||
if (newTab) {
|
|
||||||
newTab.document.open();
|
|
||||||
newTab.document.write(htmlContent);
|
|
||||||
newTab.document.close();
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
showPDFModal.value = false;
|
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[expenses] Error generating PDF:', err);
|
console.error('[expenses] Error generating PDF:', err);
|
||||||
error.value = err.message || 'Failed to generate PDF';
|
error.value = err.message || 'Failed to generate PDF';
|
||||||
|
} finally {
|
||||||
|
generatingPDF.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ export default defineNuxtPlugin(() => {
|
||||||
|
|
||||||
let refreshTimer: NodeJS.Timeout | null = null
|
let refreshTimer: NodeJS.Timeout | null = null
|
||||||
let isRefreshing = false
|
let isRefreshing = false
|
||||||
|
let retryCount = 0
|
||||||
|
const maxRetries = 3
|
||||||
|
|
||||||
const scheduleTokenRefresh = (expiresAt: number) => {
|
const scheduleTokenRefresh = (expiresAt: number) => {
|
||||||
// Clear existing timer
|
// Clear existing timer
|
||||||
|
|
@ -12,11 +14,13 @@ export default defineNuxtPlugin(() => {
|
||||||
refreshTimer = null
|
refreshTimer = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate time until refresh (refresh 2 minutes before expiry)
|
// Calculate time until refresh (refresh 5 minutes before expiry)
|
||||||
const refreshBuffer = 2 * 60 * 1000 // 2 minutes in milliseconds
|
const refreshBuffer = 5 * 60 * 1000 // 5 minutes in milliseconds
|
||||||
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')
|
||||||
|
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
|
// Only schedule if we have time left
|
||||||
if (timeUntilRefresh > 0) {
|
if (timeUntilRefresh > 0) {
|
||||||
|
|
@ -28,20 +32,37 @@ export default defineNuxtPlugin(() => {
|
||||||
console.log('[AUTH_REFRESH] Attempting automatic token refresh...')
|
console.log('[AUTH_REFRESH] Attempting automatic token refresh...')
|
||||||
|
|
||||||
const response = await $fetch<{ success: boolean; expiresAt?: number }>('/api/auth/refresh', {
|
const response = await $fetch<{ success: boolean; expiresAt?: number }>('/api/auth/refresh', {
|
||||||
method: 'POST'
|
method: 'POST',
|
||||||
|
retry: 2,
|
||||||
|
retryDelay: 1000
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.success && response.expiresAt) {
|
if (response.success && response.expiresAt) {
|
||||||
console.log('[AUTH_REFRESH] Token refresh successful, scheduling next refresh')
|
console.log('[AUTH_REFRESH] Token refresh successful, scheduling next refresh')
|
||||||
|
retryCount = 0 // Reset retry count on success
|
||||||
scheduleTokenRefresh(response.expiresAt)
|
scheduleTokenRefresh(response.expiresAt)
|
||||||
} else {
|
} else {
|
||||||
console.error('[AUTH_REFRESH] Token refresh failed, redirecting to login')
|
console.error('[AUTH_REFRESH] Token refresh failed, redirecting to login')
|
||||||
await navigateTo('/login')
|
await navigateTo('/login')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('[AUTH_REFRESH] Token refresh error:', error)
|
console.error('[AUTH_REFRESH] Token refresh error:', error)
|
||||||
// If refresh fails, redirect to login
|
|
||||||
|
// 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')
|
await navigateTo('/login')
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
}
|
}
|
||||||
|
|
@ -56,11 +77,14 @@ export default defineNuxtPlugin(() => {
|
||||||
console.log('[AUTH_REFRESH] Token expired, attempting immediate refresh...')
|
console.log('[AUTH_REFRESH] Token expired, attempting immediate refresh...')
|
||||||
|
|
||||||
const response = await $fetch<{ success: boolean; expiresAt?: number }>('/api/auth/refresh', {
|
const response = await $fetch<{ success: boolean; expiresAt?: number }>('/api/auth/refresh', {
|
||||||
method: 'POST'
|
method: 'POST',
|
||||||
|
retry: 2,
|
||||||
|
retryDelay: 1000
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.success && response.expiresAt) {
|
if (response.success && response.expiresAt) {
|
||||||
console.log('[AUTH_REFRESH] Immediate refresh successful')
|
console.log('[AUTH_REFRESH] Immediate refresh successful')
|
||||||
|
retryCount = 0 // Reset retry count on success
|
||||||
scheduleTokenRefresh(response.expiresAt)
|
scheduleTokenRefresh(response.expiresAt)
|
||||||
} else {
|
} else {
|
||||||
console.error('[AUTH_REFRESH] Immediate refresh failed, redirecting to login')
|
console.error('[AUTH_REFRESH] Immediate refresh failed, redirecting to login')
|
||||||
|
|
@ -68,7 +92,19 @@ export default defineNuxtPlugin(() => {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[AUTH_REFRESH] Immediate refresh error:', 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 {
|
} finally {
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
}
|
}
|
||||||
|
|
@ -127,11 +163,21 @@ export default defineNuxtPlugin(() => {
|
||||||
|
|
||||||
// Listen for visibility changes to refresh when tab becomes active
|
// Listen for visibility changes to refresh when tab becomes active
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== 'undefined') {
|
||||||
|
let lastVisibilityChange = Date.now()
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (!document.hidden) {
|
if (!document.hidden) {
|
||||||
// Tab became visible, check if we need to refresh
|
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()
|
checkAndScheduleRefresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lastVisibilityChange = now
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,10 @@ export default defineEventHandler(async (event) => {
|
||||||
// Documenso API configuration - moved to top for use throughout
|
// Documenso API configuration - moved to top for use throughout
|
||||||
const documensoApiKey = process.env.NUXT_DOCUMENSO_API_KEY;
|
const documensoApiKey = process.env.NUXT_DOCUMENSO_API_KEY;
|
||||||
const documensoBaseUrl = process.env.NUXT_DOCUMENSO_BASE_URL;
|
const documensoBaseUrl = process.env.NUXT_DOCUMENSO_BASE_URL;
|
||||||
const templateId = '9';
|
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');
|
||||||
|
|
||||||
if (!documensoApiKey || !documensoBaseUrl) {
|
if (!documensoApiKey || !documensoBaseUrl) {
|
||||||
throw createError({
|
throw createError({
|
||||||
|
|
@ -231,7 +234,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`,
|
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",
|
subject: "Your LOI is ready to be signed",
|
||||||
redirectUrl: "https://portnimara.com",
|
redirectUrl: "https://portnimara.com",
|
||||||
distributionMethod: "SEQUENTIAL"
|
distributionMethod: "NONE"
|
||||||
},
|
},
|
||||||
title: `${interest['Full Name']}-EOI-NDA`,
|
title: `${interest['Full Name']}-EOI-NDA`,
|
||||||
externalId: `loi-${interestId}`,
|
externalId: `loi-${interestId}`,
|
||||||
|
|
@ -249,22 +252,22 @@ export default defineEventHandler(async (event) => {
|
||||||
},
|
},
|
||||||
recipients: [
|
recipients: [
|
||||||
{
|
{
|
||||||
id: 155,
|
id: clientRecipientId,
|
||||||
name: interest['Full Name'],
|
name: interest['Full Name'],
|
||||||
role: "SIGNER",
|
role: "SIGNER",
|
||||||
email: interest['Email Address'],
|
email: interest['Email Address'],
|
||||||
signingOrder: 1
|
signingOrder: 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 156,
|
id: davidRecipientId,
|
||||||
name: "David Mizrahi",
|
name: "David Mizrahi",
|
||||||
role: "SIGNER",
|
role: "SIGNER",
|
||||||
email: "dm@portnimara.com",
|
email: "dm@portnimara.com",
|
||||||
signingOrder: 3
|
signingOrder: 3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 157,
|
id: approvalRecipientId,
|
||||||
name: "Oscar Faragher",
|
name: "Approval",
|
||||||
role: "APPROVER",
|
role: "APPROVER",
|
||||||
email: "sales@portnimara.com",
|
email: "sales@portnimara.com",
|
||||||
signingOrder: 2
|
signingOrder: 2
|
||||||
|
|
@ -337,7 +340,7 @@ export default defineEventHandler(async (event) => {
|
||||||
} else if (recipient.email === 'dm@portnimara.com') {
|
} else if (recipient.email === 'dm@portnimara.com') {
|
||||||
signingLinks['David Mizrahi'] = recipient.signingUrl;
|
signingLinks['David Mizrahi'] = recipient.signingUrl;
|
||||||
} else if (recipient.email === 'sales@portnimara.com') {
|
} else if (recipient.email === 'sales@portnimara.com') {
|
||||||
signingLinks['Oscar Faragher'] = recipient.signingUrl;
|
signingLinks['Approval'] = recipient.signingUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -392,11 +395,11 @@ export default defineEventHandler(async (event) => {
|
||||||
updateData['EmbeddedSignatureLinkDeveloper'] = embeddedDevUrl;
|
updateData['EmbeddedSignatureLinkDeveloper'] = embeddedDevUrl;
|
||||||
console.log('[EMBEDDED] Developer URL:', signingLinks['David Mizrahi'], '-> Embedded:', embeddedDevUrl);
|
console.log('[EMBEDDED] Developer URL:', signingLinks['David Mizrahi'], '-> Embedded:', embeddedDevUrl);
|
||||||
}
|
}
|
||||||
if (signingLinks['Oscar Faragher']) {
|
if (signingLinks['Approval']) {
|
||||||
updateData['Signature Link CC'] = signingLinks['Oscar Faragher'];
|
updateData['Signature Link CC'] = signingLinks['Approval'];
|
||||||
const embeddedCCUrl = createEmbeddedSigningUrl(signingLinks['Oscar Faragher'], 'cc');
|
const embeddedCCUrl = createEmbeddedSigningUrl(signingLinks['Approval'], 'cc');
|
||||||
updateData['EmbeddedSignatureLinkCC'] = embeddedCCUrl;
|
updateData['EmbeddedSignatureLinkCC'] = embeddedCCUrl;
|
||||||
console.log('[EMBEDDED] CC URL:', signingLinks['Oscar Faragher'], '-> Embedded:', embeddedCCUrl);
|
console.log('[EMBEDDED] CC URL:', signingLinks['Approval'], '-> Embedded:', embeddedCCUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[EMBEDDED] Final updateData being sent to NocoDB:', updateData);
|
console.log('[EMBEDDED] Final updateData being sent to NocoDB:', updateData);
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
const body = await readBody(event);
|
const body = await readBody(event);
|
||||||
const { interestId } = body;
|
const { interestId } = body;
|
||||||
|
const query = getQuery(event);
|
||||||
|
|
||||||
console.log('[Delete Generated EOI] Interest ID:', interestId);
|
console.log('[Delete Generated EOI] Interest ID:', interestId);
|
||||||
|
|
||||||
|
|
@ -77,7 +78,11 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
console.log('[Delete Generated EOI] Deleting document from Documenso');
|
console.log('[Delete Generated EOI] Deleting document from Documenso');
|
||||||
let documensoDeleteSuccessful = false;
|
let documensoDeleteSuccessful = false;
|
||||||
|
let retryCount = 0;
|
||||||
|
const maxRetries = 3;
|
||||||
|
|
||||||
|
// Retry logic for temporary failures
|
||||||
|
while (!documensoDeleteSuccessful && retryCount < maxRetries) {
|
||||||
try {
|
try {
|
||||||
const deleteResponse = await fetch(`${documensoBaseUrl}/api/v1/documents/${documensoID}`, {
|
const deleteResponse = await fetch(`${documensoBaseUrl}/api/v1/documents/${documensoID}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
|
@ -87,42 +92,119 @@ export default defineEventHandler(async (event) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!deleteResponse.ok) {
|
const responseStatus = deleteResponse.status;
|
||||||
const errorText = await deleteResponse.text();
|
let errorDetails = '';
|
||||||
console.error('[Delete Generated EOI] Documenso deletion failed:', errorText);
|
|
||||||
|
|
||||||
// If it's a 404, the document is already gone, which is what we want
|
try {
|
||||||
if (deleteResponse.status === 404) {
|
errorDetails = await deleteResponse.text();
|
||||||
|
} catch {
|
||||||
|
errorDetails = 'No error details available';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deleteResponse.ok) {
|
||||||
|
console.error(`[Delete Generated EOI] Documenso deletion failed (attempt ${retryCount + 1}/${maxRetries}):`, {
|
||||||
|
status: responseStatus,
|
||||||
|
statusText: deleteResponse.statusText,
|
||||||
|
details: errorDetails
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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');
|
console.log('[Delete Generated EOI] Document already deleted from Documenso (404) - proceeding with database cleanup');
|
||||||
documensoDeleteSuccessful = true;
|
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 {
|
} else {
|
||||||
throw new Error(`Failed to delete document from Documenso: ${deleteResponse.statusText}`);
|
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}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('[Delete Generated EOI] Successfully deleted document from Documenso');
|
console.log('[Delete Generated EOI] Successfully deleted document from Documenso');
|
||||||
documensoDeleteSuccessful = true;
|
documensoDeleteSuccessful = true;
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[Delete Generated EOI] Documenso deletion error:', error);
|
console.error(`[Delete Generated EOI] Documenso deletion error (attempt ${retryCount + 1}/${maxRetries}):`, error);
|
||||||
|
|
||||||
// Check if it's a network error or 404 - in those cases, proceed with cleanup
|
// Network errors - retry if we haven't exceeded retries
|
||||||
if (error.message?.includes('404') || error.status === 404) {
|
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');
|
console.log('[Delete Generated EOI] Document not found in Documenso - proceeding with database cleanup');
|
||||||
documensoDeleteSuccessful = true;
|
documensoDeleteSuccessful = true;
|
||||||
} else {
|
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 createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
statusMessage: `Failed to delete document from Documenso: ${error.message}`,
|
statusMessage: error.message || 'Failed to communicate with Documenso API',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!documensoDeleteSuccessful) {
|
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({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
statusMessage: 'Failed to delete document from Documenso',
|
statusMessage: 'Failed to delete document from Documenso after multiple attempts. You can add ?forceCleanup=true to force database cleanup.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Reset interest fields
|
// Reset interest fields
|
||||||
const updateData = {
|
const updateData = {
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,8 @@ export default defineEventHandler(async (event) => {
|
||||||
* Find duplicate expenses based on multiple criteria
|
* Find duplicate expenses based on multiple criteria
|
||||||
*/
|
*/
|
||||||
function findDuplicateExpenses(expenses: any[]) {
|
function findDuplicateExpenses(expenses: any[]) {
|
||||||
|
console.log('[EXPENSES] Starting duplicate detection for', expenses.length, 'expenses');
|
||||||
|
|
||||||
const duplicateGroups: Array<{
|
const duplicateGroups: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
expenses: any[];
|
expenses: any[];
|
||||||
|
|
@ -87,6 +89,7 @@ function findDuplicateExpenses(expenses: any[]) {
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
const processedIds = new Set<number>();
|
const processedIds = new Set<number>();
|
||||||
|
let comparisons = 0;
|
||||||
|
|
||||||
for (let i = 0; i < expenses.length; i++) {
|
for (let i = 0; i < expenses.length; i++) {
|
||||||
const expense1 = expenses[i];
|
const expense1 = expenses[i];
|
||||||
|
|
@ -102,8 +105,13 @@ function findDuplicateExpenses(expenses: any[]) {
|
||||||
if (processedIds.has(expense2.Id)) continue;
|
if (processedIds.has(expense2.Id)) continue;
|
||||||
|
|
||||||
const similarity = calculateExpenseSimilarity(expense1, expense2);
|
const similarity = calculateExpenseSimilarity(expense1, expense2);
|
||||||
|
comparisons++;
|
||||||
|
|
||||||
if (similarity.score >= 0.8) {
|
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);
|
||||||
matches.push(expense2);
|
matches.push(expense2);
|
||||||
processedIds.add(expense2.Id);
|
processedIds.add(expense2.Id);
|
||||||
similarity.reasons.forEach(r => matchReasons.add(r));
|
similarity.reasons.forEach(r => matchReasons.add(r));
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -7,9 +7,13 @@ export default defineEventHandler(async (event) => {
|
||||||
console.log('[get-expenses] API called with query:', getQuery(event));
|
console.log('[get-expenses] API called with query:', getQuery(event));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check authentication
|
// Set proper headers
|
||||||
|
setHeader(event, 'Cache-Control', 'no-cache');
|
||||||
|
setHeader(event, 'Content-Type', 'application/json');
|
||||||
|
// Check authentication first
|
||||||
try {
|
try {
|
||||||
await requireSalesOrAdmin(event);
|
await requireSalesOrAdmin(event);
|
||||||
|
console.log('[get-expenses] Authentication successful');
|
||||||
} catch (authError: any) {
|
} catch (authError: any) {
|
||||||
console.error('[get-expenses] Authentication failed:', authError);
|
console.error('[get-expenses] Authentication failed:', authError);
|
||||||
|
|
||||||
|
|
@ -127,14 +131,34 @@ export default defineEventHandler(async (event) => {
|
||||||
statusMessage: 'Unable to fetch expense data. Please try again later.'
|
statusMessage: 'Unable to fetch expense data. Please try again later.'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (authError: any) {
|
} catch (error: any) {
|
||||||
if (authError.statusCode === 403) {
|
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')) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 401,
|
||||||
statusMessage: 'Access denied. This feature requires sales team or administrator privileges.'
|
statusMessage: 'Authentication required. Please log in again.'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
throw authError;
|
// 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.'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,9 @@ export default defineEventHandler(async (event) => {
|
||||||
* Find duplicate interests based on multiple criteria
|
* Find duplicate interests based on multiple criteria
|
||||||
*/
|
*/
|
||||||
function findDuplicateInterests(interests: any[], threshold: number = 0.8) {
|
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<{
|
const duplicateGroups: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
interests: any[];
|
interests: any[];
|
||||||
|
|
@ -95,6 +98,7 @@ function findDuplicateInterests(interests: any[], threshold: number = 0.8) {
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
const processedIds = new Set<number>();
|
const processedIds = new Set<number>();
|
||||||
|
let comparisons = 0;
|
||||||
|
|
||||||
for (let i = 0; i < interests.length; i++) {
|
for (let i = 0; i < interests.length; i++) {
|
||||||
const interest1 = interests[i];
|
const interest1 = interests[i];
|
||||||
|
|
@ -109,14 +113,21 @@ function findDuplicateInterests(interests: any[], threshold: number = 0.8) {
|
||||||
if (processedIds.has(interest2.Id)) continue;
|
if (processedIds.has(interest2.Id)) continue;
|
||||||
|
|
||||||
const similarity = calculateSimilarity(interest1, interest2);
|
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) {
|
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);
|
matches.push(interest2);
|
||||||
processedIds.add(interest2.Id);
|
processedIds.add(interest2.Id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matches.length > 1) {
|
if (matches.length > 1) {
|
||||||
|
console.log(`[INTERESTS] Creating duplicate group with ${matches.length} matches`);
|
||||||
|
|
||||||
// Mark all as processed
|
// Mark all as processed
|
||||||
matches.forEach(match => processedIds.add(match.Id));
|
matches.forEach(match => processedIds.add(match.Id));
|
||||||
|
|
||||||
|
|
@ -138,6 +149,7 @@ function findDuplicateInterests(interests: any[], threshold: number = 0.8) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`[INTERESTS] Completed ${comparisons} comparisons, found ${duplicateGroups.length} duplicate groups`);
|
||||||
return duplicateGroups;
|
return duplicateGroups;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,36 +159,67 @@ function findDuplicateInterests(interests: any[], threshold: number = 0.8) {
|
||||||
function calculateSimilarity(interest1: any, interest2: any) {
|
function calculateSimilarity(interest1: any, interest2: any) {
|
||||||
const scores: Array<{ type: string; score: number; weight: number }> = [];
|
const scores: Array<{ type: string; score: number; weight: number }> = [];
|
||||||
|
|
||||||
// Email similarity (highest weight)
|
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
|
||||||
if (interest1['Email Address'] && interest2['Email Address']) {
|
if (interest1['Email Address'] && interest2['Email Address']) {
|
||||||
const emailScore = normalizeEmail(interest1['Email Address']) === normalizeEmail(interest2['Email Address']) ? 1.0 : 0.0;
|
const email1 = normalizeEmail(interest1['Email Address']);
|
||||||
scores.push({ type: 'email', score: emailScore, weight: 0.4 });
|
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}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phone similarity
|
// Phone similarity - exact match on normalized numbers
|
||||||
if (interest1['Phone Number'] && interest2['Phone Number']) {
|
if (interest1['Phone Number'] && interest2['Phone Number']) {
|
||||||
const phone1 = normalizePhone(interest1['Phone Number']);
|
const phone1 = normalizePhone(interest1['Phone Number']);
|
||||||
const phone2 = normalizePhone(interest2['Phone Number']);
|
const phone2 = normalizePhone(interest2['Phone Number']);
|
||||||
const phoneScore = phone1 === phone2 ? 1.0 : 0.0;
|
const phoneScore = phone1 === phone2 && phone1.length >= 8 ? 1.0 : 0.0; // Require at least 8 digits
|
||||||
scores.push({ type: 'phone', score: phoneScore, weight: 0.3 });
|
scores.push({ type: 'phone', score: phoneScore, weight: 0.4 });
|
||||||
|
console.log(`[INTERESTS] Phone comparison: "${phone1}" vs "${phone2}" = ${phoneScore}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Name similarity
|
// Name similarity - fuzzy matching
|
||||||
if (interest1['Full Name'] && interest2['Full Name']) {
|
if (interest1['Full Name'] && interest2['Full Name']) {
|
||||||
const nameScore = calculateNameSimilarity(interest1['Full Name'], interest2['Full Name']);
|
const nameScore = calculateNameSimilarity(interest1['Full Name'], interest2['Full Name']);
|
||||||
scores.push({ type: 'name', score: nameScore, weight: 0.2 });
|
scores.push({ type: 'name', score: nameScore, weight: 0.3 });
|
||||||
|
console.log(`[INTERESTS] Name comparison: "${interest1['Full Name']}" vs "${interest2['Full Name']}" = ${nameScore.toFixed(3)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Address similarity
|
// Address similarity
|
||||||
if (interest1.Address && interest2.Address) {
|
if (interest1.Address && interest2.Address) {
|
||||||
const addressScore = calculateStringSimilarity(interest1.Address, interest2.Address);
|
const addressScore = calculateStringSimilarity(interest1.Address, interest2.Address);
|
||||||
scores.push({ type: 'address', score: addressScore, weight: 0.1 });
|
scores.push({ type: 'address', score: addressScore, weight: 0.2 });
|
||||||
|
console.log(`[INTERESTS] Address comparison: ${addressScore.toFixed(3)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate weighted average
|
// 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
|
||||||
const totalWeight = scores.reduce((sum, s) => sum + s.weight, 0);
|
const totalWeight = scores.reduce((sum, s) => sum + s.weight, 0);
|
||||||
const weightedScore = scores.reduce((sum, s) => sum + (s.score * s.weight), 0) / (totalWeight || 1);
|
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 {
|
return {
|
||||||
score: weightedScore,
|
score: weightedScore,
|
||||||
details: scores
|
details: scores
|
||||||
|
|
|
||||||
|
|
@ -303,6 +303,80 @@ 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
|
* Format price with currency symbol
|
||||||
*/
|
*/
|
||||||
|
|
@ -403,46 +477,160 @@ 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
|
* Enhanced expense processing with currency conversion
|
||||||
*/
|
*/
|
||||||
export const processExpenseWithCurrency = async (expense: any): Promise<any> => {
|
export const processExpenseWithCurrency = async (expense: any, targetCurrency: string = 'EUR'): Promise<any> => {
|
||||||
const processedExpense = { ...expense };
|
const processedExpense = { ...expense };
|
||||||
|
|
||||||
// Parse price number
|
// Parse price number
|
||||||
const priceNumber = parseFloat(expense.Price?.toString().replace(/[^\d.-]/g, '')) || 0;
|
const priceNumber = parseFloat(expense.Price?.toString().replace(/[^\d.-]/g, '')) || 0;
|
||||||
processedExpense.PriceNumber = priceNumber;
|
processedExpense.PriceNumber = priceNumber;
|
||||||
|
|
||||||
// Get currency symbol
|
// Get currency code and symbol
|
||||||
const currencyCode = expense.currency || 'USD';
|
const currencyCode = expense.currency || 'USD';
|
||||||
|
processedExpense.Currency = currencyCode;
|
||||||
processedExpense.CurrencySymbol = getCurrencySymbol(currencyCode);
|
processedExpense.CurrencySymbol = getCurrencySymbol(currencyCode);
|
||||||
|
|
||||||
// Convert to USD if not already USD
|
// Convert to target currency if not already in target
|
||||||
if (currencyCode.toUpperCase() !== 'USD') {
|
const targetCurrencyUpper = targetCurrency.toUpperCase();
|
||||||
const conversion = await convertToUSD(priceNumber, currencyCode);
|
const targetField = `Price${targetCurrencyUpper}`;
|
||||||
|
|
||||||
|
if (currencyCode.toUpperCase() !== targetCurrencyUpper) {
|
||||||
|
const conversion = await convertToTargetCurrency(priceNumber, currencyCode, targetCurrency);
|
||||||
|
|
||||||
if (conversion) {
|
if (conversion) {
|
||||||
processedExpense.PriceUSD = conversion.usdAmount;
|
processedExpense[targetField] = conversion.targetAmount;
|
||||||
processedExpense.ConversionRate = conversion.rate;
|
processedExpense.ConversionRate = conversion.rate;
|
||||||
processedExpense.ConversionDate = conversion.conversionDate;
|
processedExpense.ConversionDate = conversion.conversionDate;
|
||||||
|
processedExpense.TargetCurrency = targetCurrencyUpper;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If already USD, set USD amount to original amount
|
// If already in target currency, set target amount to original amount
|
||||||
processedExpense.PriceUSD = priceNumber;
|
processedExpense[targetField] = priceNumber;
|
||||||
processedExpense.ConversionRate = 1.0;
|
processedExpense.ConversionRate = 1.0;
|
||||||
processedExpense.ConversionDate = new Date().toISOString();
|
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
|
// Create display prices
|
||||||
processedExpense.DisplayPrice = createDisplayPrice(
|
processedExpense.DisplayPrice = formatPriceWithCurrency(priceNumber, currencyCode);
|
||||||
priceNumber,
|
|
||||||
currencyCode,
|
|
||||||
processedExpense.PriceUSD
|
|
||||||
);
|
|
||||||
|
|
||||||
processedExpense.DisplayPriceUSD = formatPriceWithCurrency(
|
// Create display price with target currency conversion
|
||||||
processedExpense.PriceUSD || priceNumber,
|
const targetAmount = processedExpense[targetField];
|
||||||
'USD'
|
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
|
||||||
);
|
);
|
||||||
|
|
||||||
return processedExpense;
|
return processedExpense;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue