1006 lines
38 KiB
TypeScript
1006 lines
38 KiB
TypeScript
import type { Interest, Berth, Expense, ExpenseFilters } from "@/utils/types";
|
|
|
|
export interface PageInfo {
|
|
pageSize: number;
|
|
totalRows: number;
|
|
isFirstPage: boolean;
|
|
isLastPage: boolean;
|
|
page: number;
|
|
}
|
|
|
|
export interface InterestsResponse {
|
|
list: Interest[];
|
|
PageInfo: PageInfo;
|
|
}
|
|
|
|
export interface BerthsResponse {
|
|
list: Berth[];
|
|
PageInfo: PageInfo;
|
|
}
|
|
|
|
export interface ExpensesResponse {
|
|
list: Expense[];
|
|
PageInfo: PageInfo;
|
|
}
|
|
|
|
export enum Table {
|
|
Interest = "mbs9hjauug4eseo",
|
|
Berth = "mczgos9hr3oa9qc",
|
|
Expense = "mxfcefkk4dqs6uq", // Expense tracking table
|
|
}
|
|
|
|
/**
|
|
* Convert date from DD-MM-YYYY format to YYYY-MM-DD format for PostgreSQL
|
|
*/
|
|
const convertDateFormat = (dateString: string): string => {
|
|
if (!dateString) return dateString;
|
|
|
|
// If it's already in ISO format or contains 'T', return as is
|
|
if (dateString.includes('T') || dateString.match(/^\d{4}-\d{2}-\d{2}/)) {
|
|
return dateString;
|
|
}
|
|
|
|
// Handle DD-MM-YYYY format
|
|
const ddmmyyyyMatch = dateString.match(/^(\d{1,2})-(\d{1,2})-(\d{4})$/);
|
|
if (ddmmyyyyMatch) {
|
|
const [, day, month, year] = ddmmyyyyMatch;
|
|
const convertedDate = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
|
|
console.log(`[convertDateFormat] Converted ${dateString} to ${convertedDate}`);
|
|
return convertedDate;
|
|
}
|
|
|
|
// Handle DD/MM/YYYY format
|
|
const ddmmyyyySlashMatch = dateString.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
|
if (ddmmyyyySlashMatch) {
|
|
const [, day, month, year] = ddmmyyyySlashMatch;
|
|
const convertedDate = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
|
|
console.log(`[convertDateFormat] Converted ${dateString} to ${convertedDate}`);
|
|
return convertedDate;
|
|
}
|
|
|
|
console.warn(`[convertDateFormat] Could not parse date format: ${dateString}`);
|
|
return dateString;
|
|
};
|
|
|
|
export const getNocoDbConfiguration = () => {
|
|
const config = useRuntimeConfig().nocodb;
|
|
console.log('[nocodb] Configuration URL:', config.url);
|
|
return config;
|
|
};
|
|
|
|
export const createTableUrl = (table: Table) => {
|
|
const url = `${getNocoDbConfiguration().url}/api/v2/tables/${table}/records`;
|
|
console.log('[nocodb] Table URL:', url);
|
|
return url;
|
|
};
|
|
|
|
export const getInterests = async () =>
|
|
$fetch<InterestsResponse>(createTableUrl(Table.Interest), {
|
|
headers: {
|
|
"xc-token": getNocoDbConfiguration().token,
|
|
},
|
|
params: {
|
|
limit: 1000,
|
|
},
|
|
});
|
|
|
|
export const getInterestById = async (id: string) => {
|
|
console.log('[nocodb.getInterestById] Fetching interest ID:', id);
|
|
|
|
const result = await $fetch<Interest>(`${createTableUrl(Table.Interest)}/${id}`, {
|
|
headers: {
|
|
"xc-token": getNocoDbConfiguration().token,
|
|
},
|
|
});
|
|
|
|
console.log('[nocodb.getInterestById] Raw result from NocoDB:', {
|
|
id: result.Id,
|
|
documensoID: result['documensoID'],
|
|
documensoID_type: typeof result['documensoID'],
|
|
documensoID_value: JSON.stringify(result['documensoID']),
|
|
signatureLinks: {
|
|
client: result['Signature Link Client'],
|
|
cc: result['Signature Link CC'],
|
|
developer: result['Signature Link Developer']
|
|
}
|
|
});
|
|
|
|
return result;
|
|
};
|
|
|
|
export const updateInterest = async (id: string, data: Partial<Interest>, retryCount = 0): Promise<Interest> => {
|
|
console.log('[nocodb.updateInterest] Updating interest:', id, 'Retry:', retryCount);
|
|
console.log('[nocodb.updateInterest] Data fields:', Object.keys(data));
|
|
|
|
// First, try to verify the record exists
|
|
if (retryCount === 0) {
|
|
try {
|
|
console.log('[nocodb.updateInterest] Verifying record exists...');
|
|
const existingRecord = await getInterestById(id);
|
|
console.log('[nocodb.updateInterest] Record exists with ID:', existingRecord.Id);
|
|
} catch (verifyError: any) {
|
|
console.error('[nocodb.updateInterest] Failed to verify record:', verifyError);
|
|
if (verifyError.statusCode === 404 || verifyError.status === 404) {
|
|
console.error('[nocodb.updateInterest] Record verification failed - record not found');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create a clean data object that matches the InterestsRequest schema
|
|
// Remove any properties that are not in the schema or shouldn't be sent
|
|
const cleanData: Record<string, any> = {};
|
|
|
|
// Only include fields that are part of the InterestsRequest schema
|
|
// Removed webhook fields: "Request More Information", "Request More Info - To Sales", "EOI Send to Sales"
|
|
const allowedFields = [
|
|
"Full Name",
|
|
"Yacht Name",
|
|
"Length",
|
|
"Address",
|
|
"Email Address",
|
|
"Sales Process Level",
|
|
"Phone Number",
|
|
"Extra Comments",
|
|
"Berth Size Desired",
|
|
"LOI-NDA Document",
|
|
"Date Added",
|
|
"Width",
|
|
"Depth",
|
|
"Created At",
|
|
"Source",
|
|
"Contact Method Preferred",
|
|
"Request Form Sent",
|
|
"Berth Number",
|
|
"EOI Time Sent",
|
|
"Lead Category",
|
|
"Time LOI Sent",
|
|
"EOI Status",
|
|
"Berth Info Sent Status",
|
|
"Contract Sent Status",
|
|
"Deposit 10% Status",
|
|
"Contract Status",
|
|
// Add the EOI link fields
|
|
"EOI Client Link",
|
|
"EOI David Link",
|
|
"EOI Oscar Link",
|
|
"EOI Document",
|
|
// Add the new signature link fields
|
|
"Signature Link Client",
|
|
"Signature Link CC",
|
|
"Signature Link Developer",
|
|
// Add the embedded signature link fields
|
|
"EmbeddedSignatureLinkClient",
|
|
"EmbeddedSignatureLinkCC",
|
|
"EmbeddedSignatureLinkDeveloper",
|
|
// Add the Documenso document ID field
|
|
"documensoID"
|
|
];
|
|
|
|
// Filter the data to only include allowed fields
|
|
for (const field of allowedFields) {
|
|
if (field in data) {
|
|
const value = (data as any)[field];
|
|
|
|
// Skip webhook-type fields and other object fields that shouldn't be sent
|
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
console.log(`[nocodb.updateInterest] Skipping object field: ${field}`, value);
|
|
continue;
|
|
}
|
|
|
|
// Handle clearing fields - NocoDB requires null for clearing, not undefined
|
|
if (value === undefined) {
|
|
cleanData[field] = null;
|
|
console.log(`[nocodb.updateInterest] Converting undefined to null for field: ${field}`);
|
|
} else {
|
|
cleanData[field] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fix date formatting for PostgreSQL
|
|
if (cleanData['Date Added']) {
|
|
cleanData['Date Added'] = convertDateFormat(cleanData['Date Added']);
|
|
}
|
|
if (cleanData['Created At']) {
|
|
cleanData['Created At'] = convertDateFormat(cleanData['Created At']);
|
|
}
|
|
if (cleanData['EOI Time Sent']) {
|
|
cleanData['EOI Time Sent'] = convertDateFormat(cleanData['EOI Time Sent']);
|
|
}
|
|
if (cleanData['Time LOI Sent']) {
|
|
cleanData['Time LOI Sent'] = convertDateFormat(cleanData['Time LOI Sent']);
|
|
}
|
|
|
|
console.log('[nocodb.updateInterest] Clean data fields:', Object.keys(cleanData));
|
|
|
|
// PATCH requires ID in the body (not in URL)
|
|
// Ensure ID is an integer
|
|
cleanData.Id = parseInt(id);
|
|
|
|
const url = createTableUrl(Table.Interest);
|
|
console.log('[nocodb.updateInterest] URL:', url);
|
|
|
|
try {
|
|
console.log('[nocodb.updateInterest] Sending PATCH request with headers:', {
|
|
"xc-token": getNocoDbConfiguration().token ? "***" + getNocoDbConfiguration().token.slice(-4) : "not set"
|
|
});
|
|
console.log('[nocodb.updateInterest] Request body:', JSON.stringify(cleanData, null, 2));
|
|
|
|
// Try sending as a single object first (as shown in the API docs)
|
|
const result = await $fetch<Interest>(url, {
|
|
method: "PATCH",
|
|
headers: {
|
|
"xc-token": getNocoDbConfiguration().token,
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: cleanData
|
|
});
|
|
console.log('[nocodb.updateInterest] Update successful for ID:', id);
|
|
return result;
|
|
} catch (error: any) {
|
|
console.error('[nocodb.updateInterest] Update failed:', error);
|
|
console.error('[nocodb.updateInterest] Error details:', error instanceof Error ? error.message : 'Unknown error');
|
|
|
|
// If it's a 404 error and we haven't retried too many times, wait and retry
|
|
if ((error.statusCode === 404 || error.status === 404) && retryCount < 3) {
|
|
console.error('[nocodb.updateInterest] 404 Error - Record not found. This might be a sync delay.');
|
|
console.error(`Retrying in ${(retryCount + 1) * 1000}ms... (Attempt ${retryCount + 1}/3)`);
|
|
|
|
// Wait with exponential backoff
|
|
await new Promise(resolve => setTimeout(resolve, (retryCount + 1) * 1000));
|
|
|
|
// Retry the update
|
|
return updateInterest(id, data, retryCount + 1);
|
|
}
|
|
|
|
// If it's still a 404 after retries, provide detailed error
|
|
if (error.statusCode === 404 || error.status === 404) {
|
|
console.error('[nocodb.updateInterest] 404 Error - Record not found after 3 retries. This might happen if:');
|
|
console.error('1. The record ID is incorrect');
|
|
console.error('2. The record was deleted');
|
|
console.error('3. There is a synchronization issue with the database');
|
|
console.error('Attempted URL:', url);
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
export const createInterest = async (data: Partial<Interest>) => {
|
|
console.log('[nocodb.createInterest] Creating interest with fields:', Object.keys(data));
|
|
|
|
// Create a clean data object that matches the InterestsRequest schema
|
|
const cleanData: Record<string, any> = {};
|
|
|
|
// Only include fields that are part of the InterestsRequest schema
|
|
const allowedFields = [
|
|
"Full Name",
|
|
"Yacht Name",
|
|
"Length",
|
|
"Address",
|
|
"Email Address",
|
|
"Sales Process Level",
|
|
"Phone Number",
|
|
"Extra Comments",
|
|
"Berth Size Desired",
|
|
"Date Added",
|
|
"Width",
|
|
"Depth",
|
|
"Source",
|
|
"Contact Method Preferred",
|
|
"Lead Category",
|
|
"EOI Status",
|
|
"Berth Info Sent Status",
|
|
"Contract Sent Status",
|
|
"Deposit 10% Status",
|
|
"Contract Status",
|
|
];
|
|
|
|
// Filter the data to only include allowed fields
|
|
for (const field of allowedFields) {
|
|
if (field in data) {
|
|
cleanData[field] = (data as any)[field];
|
|
}
|
|
}
|
|
|
|
// Remove any computed or relation fields that shouldn't be sent
|
|
delete cleanData.Id;
|
|
delete cleanData.Berths;
|
|
delete cleanData["Berth Recommendations"];
|
|
delete cleanData.Berth;
|
|
|
|
// Fix date formatting for PostgreSQL
|
|
if (cleanData['Date Added']) {
|
|
cleanData['Date Added'] = convertDateFormat(cleanData['Date Added']);
|
|
}
|
|
if (cleanData['Created At']) {
|
|
cleanData['Created At'] = convertDateFormat(cleanData['Created At']);
|
|
}
|
|
|
|
console.log('[nocodb.createInterest] Clean data fields:', Object.keys(cleanData));
|
|
const url = createTableUrl(Table.Interest);
|
|
console.log('[nocodb.createInterest] URL:', url);
|
|
|
|
try {
|
|
const result = await $fetch<Interest>(url, {
|
|
method: "POST",
|
|
headers: {
|
|
"xc-token": getNocoDbConfiguration().token,
|
|
},
|
|
body: cleanData,
|
|
});
|
|
console.log('[nocodb.createInterest] Created interest with ID:', result.Id);
|
|
return result;
|
|
} catch (error) {
|
|
console.error('[nocodb.createInterest] Create failed:', error);
|
|
console.error('[nocodb.createInterest] Error details:', error instanceof Error ? error.message : 'Unknown error');
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
export const deleteInterest = async (id: string) => {
|
|
const startTime = Date.now();
|
|
console.log('[nocodb.deleteInterest] =========================');
|
|
console.log('[nocodb.deleteInterest] DELETE operation started at:', new Date().toISOString());
|
|
console.log('[nocodb.deleteInterest] Target ID:', id);
|
|
|
|
const url = createTableUrl(Table.Interest);
|
|
console.log('[nocodb.deleteInterest] URL:', url);
|
|
|
|
const requestBody = {
|
|
"Id": parseInt(id)
|
|
};
|
|
|
|
console.log('[nocodb.deleteInterest] Request configuration:');
|
|
console.log(' Method: DELETE');
|
|
console.log(' URL:', url);
|
|
console.log(' Headers:', {
|
|
"xc-token": getNocoDbConfiguration().token ? "***" + getNocoDbConfiguration().token.slice(-4) : "not set",
|
|
"Content-Type": "application/json"
|
|
});
|
|
console.log(' Body:', JSON.stringify(requestBody, null, 2));
|
|
|
|
try {
|
|
// According to NocoDB API docs, DELETE requires ID in the body
|
|
const result = await $fetch(url, {
|
|
method: "DELETE",
|
|
headers: {
|
|
"xc-token": getNocoDbConfiguration().token,
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: requestBody
|
|
});
|
|
|
|
console.log('[nocodb.deleteInterest] DELETE successful');
|
|
console.log('[nocodb.deleteInterest] Response:', JSON.stringify(result, null, 2));
|
|
console.log('[nocodb.deleteInterest] Duration:', Date.now() - startTime, 'ms');
|
|
console.log('[nocodb.deleteInterest] =========================');
|
|
|
|
return result;
|
|
} catch (error: any) {
|
|
console.error('[nocodb.deleteInterest] =========================');
|
|
console.error('[nocodb.deleteInterest] DELETE FAILED');
|
|
console.error('[nocodb.deleteInterest] Error type:', error.constructor.name);
|
|
console.error('[nocodb.deleteInterest] Error message:', error.message);
|
|
console.error('[nocodb.deleteInterest] Error status:', error.statusCode || error.status || 'unknown');
|
|
console.error('[nocodb.deleteInterest] Error data:', error.data);
|
|
console.error('[nocodb.deleteInterest] Error stack:', error.stack || 'No stack trace');
|
|
console.error('[nocodb.deleteInterest] Full error:', JSON.stringify(error, null, 2));
|
|
console.error('[nocodb.deleteInterest] Duration:', Date.now() - startTime, 'ms');
|
|
console.error('[nocodb.deleteInterest] =========================');
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
export const triggerWebhook = async (url: string, payload: any) =>
|
|
$fetch(url, {
|
|
method: "POST",
|
|
body: payload,
|
|
});
|
|
|
|
export const updateInterestEOIDocument = async (id: string, documentData: any) => {
|
|
console.log('[nocodb.updateInterestEOIDocument] Updating EOI document for interest:', id);
|
|
|
|
// Get existing EOI Document array or create new one
|
|
const interest = await getInterestById(id);
|
|
const existingDocuments = interest['EOI Document'] || [];
|
|
|
|
// Add the new document to the array
|
|
const updatedDocuments = [...existingDocuments, documentData];
|
|
|
|
// Update the interest with the new EOI Document array
|
|
return updateInterest(id, {
|
|
'EOI Document': updatedDocuments
|
|
});
|
|
};
|
|
|
|
export const getInterestByFieldAsync = async (fieldName: string, value: any): Promise<Interest | null> => {
|
|
try {
|
|
const response = await getInterests();
|
|
const interests = response.list || [];
|
|
|
|
// Find interest where the field matches the value
|
|
const interest = interests.find(i => (i as any)[fieldName] === value);
|
|
|
|
return interest || null;
|
|
} catch (error) {
|
|
console.error('Error fetching interest by field:', error);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// Berth functions
|
|
export const getBerths = async () => {
|
|
console.log('[nocodb.getBerths] Fetching berths from NocoDB...');
|
|
|
|
try {
|
|
// First try with basic query - no field expansion to avoid 404
|
|
const result = await $fetch<BerthsResponse>(createTableUrl(Table.Berth), {
|
|
headers: {
|
|
"xc-token": getNocoDbConfiguration().token,
|
|
},
|
|
params: {
|
|
limit: 1000,
|
|
// Start with basic fields only
|
|
fields: '*'
|
|
},
|
|
});
|
|
|
|
console.log('[nocodb.getBerths] Successfully fetched berths, count:', result.list?.length || 0);
|
|
|
|
// Process each berth to populate interested parties details
|
|
if (result.list && Array.isArray(result.list)) {
|
|
console.log('[nocodb.getBerths] Processing berths to populate interested parties...');
|
|
|
|
// Count berths with interested parties (NocoDB returns count as number)
|
|
const berthsWithParties = result.list.filter(b =>
|
|
b['Interested Parties'] && (typeof b['Interested Parties'] === 'number' && b['Interested Parties'] > 0)
|
|
);
|
|
console.log('[nocodb.getBerths] Berths with interested parties:', berthsWithParties.length);
|
|
|
|
// Log first berth with interested parties for debugging
|
|
if (berthsWithParties.length > 0) {
|
|
const firstBerth = berthsWithParties[0];
|
|
if (firstBerth && firstBerth['Interested Parties']) {
|
|
console.log('[nocodb.getBerths] First berth with parties:', {
|
|
id: firstBerth.Id,
|
|
mooringNumber: firstBerth['Mooring Number'],
|
|
partiesCount: firstBerth['Interested Parties'].length,
|
|
firstParty: firstBerth['Interested Parties'][0]
|
|
});
|
|
}
|
|
}
|
|
|
|
await Promise.all(
|
|
result.list.map(async (berth) => {
|
|
// Check if berth has interested parties (as a number count)
|
|
if (berth['Interested Parties'] && typeof berth['Interested Parties'] === 'number' && berth['Interested Parties'] > 0) {
|
|
const partyCount = berth['Interested Parties'] as number;
|
|
console.log(`[nocodb.getBerths] Berth ${berth['Mooring Number']} has ${partyCount} interested parties (as count)`);
|
|
|
|
// When we have a count, fetch the linked records using the links API
|
|
try {
|
|
const config = getNocoDbConfiguration();
|
|
const berthsTableId = "mczgos9hr3oa9qc";
|
|
const interestedPartiesFieldId = "c7q2z2rb27c1cb5";
|
|
|
|
const linkUrl = `${config.url}/api/v2/tables/${berthsTableId}/links/${interestedPartiesFieldId}/records/${berth.Id}`;
|
|
console.log(`[nocodb.getBerths] Fetching linked parties from: ${linkUrl}`);
|
|
|
|
const linkedResponse = await $fetch<any>(linkUrl, {
|
|
headers: {
|
|
"xc-token": config.token,
|
|
},
|
|
params: {
|
|
limit: 100
|
|
}
|
|
});
|
|
|
|
console.log(`[nocodb.getBerths] Linked response for berth ${berth['Mooring Number']}:`, linkedResponse);
|
|
|
|
if (linkedResponse && linkedResponse.list && Array.isArray(linkedResponse.list)) {
|
|
// The links API returns limited data, so we need to fetch full records
|
|
console.log(`[nocodb.getBerths] Got ${linkedResponse.list.length} linked records, fetching full details...`);
|
|
|
|
const fullInterestDetails = await Promise.all(
|
|
linkedResponse.list.map(async (linkedParty: any) => {
|
|
try {
|
|
const partyId = linkedParty.Id || linkedParty.id;
|
|
if (partyId) {
|
|
const fullDetails = await getInterestById(partyId.toString());
|
|
return fullDetails;
|
|
}
|
|
return linkedParty;
|
|
} catch (error) {
|
|
console.error(`[nocodb.getBerths] Failed to fetch full details for party ${linkedParty.Id}:`, error);
|
|
return linkedParty;
|
|
}
|
|
})
|
|
);
|
|
|
|
berth['Interested Parties'] = fullInterestDetails;
|
|
console.log(`[nocodb.getBerths] Successfully fetched full details for ${fullInterestDetails.length} interested parties for berth ${berth['Mooring Number']}`);
|
|
} else {
|
|
// Fallback to placeholders if API call doesn't return expected format
|
|
const placeholderParties = Array.from({ length: partyCount }, (_, index) => ({
|
|
Id: index + 1,
|
|
'Full Name': `Party ${index + 1}`,
|
|
'Sales Process Level': null,
|
|
'EOI Status': null,
|
|
'Contract Status': null
|
|
}));
|
|
berth['Interested Parties'] = placeholderParties as any;
|
|
console.log(`[nocodb.getBerths] Using placeholders for berth ${berth['Mooring Number']}`);
|
|
}
|
|
} catch (linkError) {
|
|
console.error(`[nocodb.getBerths] Failed to fetch linked parties for berth ${berth['Mooring Number']}:`, linkError);
|
|
// Fallback to placeholders on error
|
|
const placeholderParties = Array.from({ length: partyCount }, (_, index) => ({
|
|
Id: index + 1,
|
|
'Full Name': `Party ${index + 1}`,
|
|
'Sales Process Level': null,
|
|
'EOI Status': null,
|
|
'Contract Status': null
|
|
}));
|
|
berth['Interested Parties'] = placeholderParties as any;
|
|
}
|
|
} else if (berth['Interested Parties'] && Array.isArray(berth['Interested Parties']) && berth['Interested Parties'].length > 0) {
|
|
// Handle case where we get an array (this might happen in some cases)
|
|
console.log(`[nocodb.getBerths] Processing ${berth['Interested Parties'].length} parties for berth ${berth['Mooring Number']}`);
|
|
|
|
// Extract IDs from various possible formats
|
|
const partyIds = berth['Interested Parties'].map((party: any) => {
|
|
// Handle different possible formats from NocoDB
|
|
if (typeof party === 'number') return party;
|
|
if (typeof party === 'string') return parseInt(party);
|
|
if (party && typeof party === 'object') {
|
|
// Check various possible ID field names
|
|
return party.Id || party.id || party.ID || party._id || null;
|
|
}
|
|
return null;
|
|
}).filter((id: any) => id !== null && !isNaN(id));
|
|
|
|
console.log(`[nocodb.getBerths] Extracted ${partyIds.length} valid IDs for berth ${berth['Mooring Number']}:`, partyIds);
|
|
|
|
if (partyIds.length > 0) {
|
|
const interestedPartiesDetails = await Promise.all(
|
|
partyIds.map(async (partyId: number) => {
|
|
try {
|
|
console.log(`[nocodb.getBerths] Fetching interest ${partyId} for berth ${berth['Mooring Number']}`);
|
|
const interestDetails = await getInterestById(partyId.toString());
|
|
return interestDetails;
|
|
} catch (error) {
|
|
console.error(`[nocodb.getBerths] Failed to fetch interest ${partyId}:`, error);
|
|
return { Id: partyId, 'Full Name': `Interest #${partyId}` } as any;
|
|
}
|
|
})
|
|
);
|
|
|
|
berth['Interested Parties'] = interestedPartiesDetails;
|
|
console.log(`[nocodb.getBerths] Populated ${interestedPartiesDetails.length} parties for berth ${berth['Mooring Number']}`);
|
|
} else {
|
|
console.log(`[nocodb.getBerths] No valid party IDs found for berth ${berth['Mooring Number']}`);
|
|
}
|
|
}
|
|
})
|
|
);
|
|
|
|
// Sort berths by letter zone and then by number using Mooring Number
|
|
result.list.sort((a, b) => {
|
|
const berthA = a['Mooring Number'] || '';
|
|
const berthB = b['Mooring Number'] || '';
|
|
|
|
// Extract letter and number parts
|
|
const matchA = berthA.match(/^([A-Za-z]+)(\d+)$/);
|
|
const matchB = berthB.match(/^([A-Za-z]+)(\d+)$/);
|
|
|
|
if (matchA && matchB) {
|
|
const [, letterA, numberA] = matchA;
|
|
const [, letterB, numberB] = matchB;
|
|
|
|
// First sort by letter zone
|
|
const letterCompare = letterA.localeCompare(letterB);
|
|
if (letterCompare !== 0) {
|
|
return letterCompare;
|
|
}
|
|
|
|
// Then sort by number within the same letter zone
|
|
return parseInt(numberA) - parseInt(numberB);
|
|
}
|
|
|
|
// Fallback to string comparison if pattern doesn't match
|
|
return berthA.localeCompare(berthB);
|
|
});
|
|
|
|
console.log('[nocodb.getBerths] Berths sorted by zone and number with populated interested parties');
|
|
}
|
|
|
|
return result;
|
|
} catch (error: any) {
|
|
console.error('[nocodb.getBerths] Error fetching berths:', error);
|
|
console.error('[nocodb.getBerths] Error details:', error instanceof Error ? error.message : 'Unknown error');
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
export const getBerthById = async (id: string) => {
|
|
console.log('[nocodb.getBerthById] Fetching berth ID:', id);
|
|
|
|
try {
|
|
// First fetch the basic berth data
|
|
const result = await $fetch<Berth>(`${createTableUrl(Table.Berth)}/${id}`, {
|
|
headers: {
|
|
"xc-token": getNocoDbConfiguration().token,
|
|
},
|
|
params: {
|
|
fields: '*'
|
|
}
|
|
});
|
|
|
|
console.log('[nocodb.getBerthById] Successfully fetched berth:', result.Id);
|
|
console.log('[nocodb.getBerthById] Raw Interested Parties:', JSON.stringify(result['Interested Parties'], null, 2));
|
|
|
|
// Now fetch and populate the interested parties details
|
|
if (result['Interested Parties']) {
|
|
// Handle case where interested parties is a count (number)
|
|
if (typeof result['Interested Parties'] === 'number' && result['Interested Parties'] > 0) {
|
|
const partyCount = result['Interested Parties'] as number;
|
|
console.log(`[nocodb.getBerthById] Berth has ${partyCount} interested parties (as count)`);
|
|
|
|
// Fetch the linked records using the links API
|
|
try {
|
|
const config = getNocoDbConfiguration();
|
|
const berthsTableId = "mczgos9hr3oa9qc";
|
|
const interestedPartiesFieldId = "c7q2z2rb27c1cb5";
|
|
|
|
const linkUrl = `${config.url}/api/v2/tables/${berthsTableId}/links/${interestedPartiesFieldId}/records/${result.Id}`;
|
|
console.log(`[nocodb.getBerthById] Fetching linked parties from: ${linkUrl}`);
|
|
|
|
const linkedResponse = await $fetch<any>(linkUrl, {
|
|
headers: {
|
|
"xc-token": config.token,
|
|
},
|
|
params: {
|
|
limit: 100
|
|
}
|
|
});
|
|
|
|
console.log(`[nocodb.getBerthById] Linked response:`, linkedResponse);
|
|
|
|
if (linkedResponse && linkedResponse.list && Array.isArray(linkedResponse.list)) {
|
|
// The links API returns limited data, so we need to fetch full records
|
|
console.log(`[nocodb.getBerthById] Got ${linkedResponse.list.length} linked records, fetching full details...`);
|
|
|
|
const fullInterestDetails = await Promise.all(
|
|
linkedResponse.list.map(async (linkedParty: any) => {
|
|
try {
|
|
const partyId = linkedParty.Id || linkedParty.id;
|
|
if (partyId) {
|
|
const fullDetails = await getInterestById(partyId.toString());
|
|
return fullDetails;
|
|
}
|
|
return linkedParty;
|
|
} catch (error) {
|
|
console.error(`[nocodb.getBerthById] Failed to fetch full details for party ${linkedParty.Id}:`, error);
|
|
return linkedParty;
|
|
}
|
|
})
|
|
);
|
|
|
|
result['Interested Parties'] = fullInterestDetails;
|
|
console.log(`[nocodb.getBerthById] Successfully fetched full details for ${fullInterestDetails.length} interested parties`);
|
|
} else {
|
|
// Fallback to placeholders if API call doesn't return expected format
|
|
const placeholderParties = Array.from({ length: partyCount }, (_, index) => ({
|
|
Id: index + 1,
|
|
'Full Name': `Party ${index + 1}`,
|
|
'Sales Process Level': null,
|
|
'EOI Status': null,
|
|
'Contract Status': null
|
|
}));
|
|
result['Interested Parties'] = placeholderParties as any;
|
|
console.log(`[nocodb.getBerthById] Using placeholders`);
|
|
}
|
|
} catch (linkError) {
|
|
console.error(`[nocodb.getBerthById] Failed to fetch linked parties:`, linkError);
|
|
// Fallback to placeholders on error
|
|
const placeholderParties = Array.from({ length: partyCount }, (_, index) => ({
|
|
Id: index + 1,
|
|
'Full Name': `Party ${index + 1}`,
|
|
'Sales Process Level': null,
|
|
'EOI Status': null,
|
|
'Contract Status': null
|
|
}));
|
|
result['Interested Parties'] = placeholderParties as any;
|
|
}
|
|
} else if (Array.isArray(result['Interested Parties'])) {
|
|
// Handle case where we get an array (might happen in some cases)
|
|
console.log('[nocodb.getBerthById] Fetching details for interested parties:', result['Interested Parties'].length);
|
|
|
|
// Extract IDs from various possible formats
|
|
const partyIds = result['Interested Parties'].map((party: any) => {
|
|
// Handle different possible formats from NocoDB
|
|
if (typeof party === 'number') return party;
|
|
if (typeof party === 'string') return parseInt(party);
|
|
if (party && typeof party === 'object') {
|
|
// Check various possible ID field names
|
|
return party.Id || party.id || party.ID || party._id || null;
|
|
}
|
|
return null;
|
|
}).filter(id => id !== null && !isNaN(id));
|
|
|
|
console.log('[nocodb.getBerthById] Extracted party IDs:', partyIds);
|
|
|
|
// Fetch full interest records
|
|
if (partyIds.length > 0) {
|
|
const interestedPartiesDetails = await Promise.all(
|
|
partyIds.map(async (partyId: number) => {
|
|
try {
|
|
console.log('[nocodb.getBerthById] Fetching interest details for ID:', partyId);
|
|
const interestDetails = await getInterestById(partyId.toString());
|
|
return interestDetails;
|
|
} catch (error) {
|
|
console.error('[nocodb.getBerthById] Failed to fetch interest details for ID:', partyId, error);
|
|
// Return a placeholder object if fetch fails
|
|
return { Id: partyId, 'Full Name': `Interest #${partyId}` } as any;
|
|
}
|
|
})
|
|
);
|
|
|
|
result['Interested Parties'] = interestedPartiesDetails;
|
|
console.log('[nocodb.getBerthById] Populated interested parties details:', interestedPartiesDetails.length);
|
|
} else {
|
|
console.log('[nocodb.getBerthById] No valid party IDs found to populate');
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
} catch (error: any) {
|
|
console.error('[nocodb.getBerthById] Error fetching berth:', error);
|
|
console.error('[nocodb.getBerthById] Error details:', error instanceof Error ? error.message : 'Unknown error');
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
export const updateBerth = async (id: string, data: Partial<Berth>): Promise<Berth> => {
|
|
console.log('[nocodb.updateBerth] Updating berth:', id);
|
|
console.log('[nocodb.updateBerth] Data fields:', Object.keys(data));
|
|
|
|
// Create a clean data object that matches the Berth schema
|
|
const cleanData: Record<string, any> = {};
|
|
|
|
// Only include fields that are part of the Berth schema (excluding formula fields)
|
|
const allowedFields = [
|
|
"Mooring Number",
|
|
"Area",
|
|
"Status",
|
|
"Nominal Boat Size",
|
|
"Water Depth",
|
|
"Length",
|
|
"Width",
|
|
"Draft", // Changed from "Depth" to "Draft"
|
|
"Side Pontoon",
|
|
"Power Capacity",
|
|
"Voltage",
|
|
"Mooring Type",
|
|
"Access",
|
|
"Cleat Type",
|
|
"Cleat Capacity",
|
|
"Bollard Type",
|
|
"Bollard Capacity",
|
|
"Price",
|
|
"Bow Facing"
|
|
];
|
|
|
|
// Filter the data to only include allowed fields
|
|
for (const field of allowedFields) {
|
|
if (field in data) {
|
|
const value = (data as any)[field];
|
|
|
|
// Handle clearing fields - NocoDB requires null for clearing, not undefined
|
|
if (value === undefined) {
|
|
cleanData[field] = null;
|
|
console.log(`[nocodb.updateBerth] Converting undefined to null for field: ${field}`);
|
|
} else {
|
|
cleanData[field] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log('[nocodb.updateBerth] Clean data fields:', Object.keys(cleanData));
|
|
|
|
// PATCH requires ID in the body (not in URL)
|
|
// Ensure ID is an integer
|
|
cleanData.Id = parseInt(id);
|
|
|
|
const url = createTableUrl(Table.Berth);
|
|
console.log('[nocodb.updateBerth] URL:', url);
|
|
|
|
try {
|
|
console.log('[nocodb.updateBerth] Sending PATCH request');
|
|
|
|
const result = await $fetch<Berth>(url, {
|
|
method: "PATCH",
|
|
headers: {
|
|
"xc-token": getNocoDbConfiguration().token,
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: cleanData
|
|
});
|
|
|
|
console.log('[nocodb.updateBerth] Update successful for ID:', id);
|
|
return result;
|
|
} catch (error: any) {
|
|
console.error('[nocodb.updateBerth] Update failed:', error);
|
|
console.error('[nocodb.updateBerth] Error details:', error instanceof Error ? error.message : 'Unknown error');
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
// Expense functions with resilient HTTP handling
|
|
export const getExpenses = async (filters?: ExpenseFilters) => {
|
|
console.log('[nocodb.getExpenses] Fetching expenses from NocoDB...', filters);
|
|
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
const params: any = { limit: 1000 };
|
|
|
|
// Build filter conditions (fixed date logic)
|
|
if (filters?.startDate && filters?.endDate) {
|
|
// Ensure dates are in YYYY-MM-DD format
|
|
const startDate = filters.startDate.includes('T') ? filters.startDate.split('T')[0] : filters.startDate;
|
|
const endDate = filters.endDate.includes('T') ? filters.endDate.split('T')[0] : filters.endDate;
|
|
|
|
console.log('[nocodb.getExpenses] Date filter:', { startDate, endDate });
|
|
params.where = `(Time,gte,${startDate})~and(Time,lte,${endDate})`;
|
|
} else if (filters?.startDate) {
|
|
const startDate = filters.startDate.includes('T') ? filters.startDate.split('T')[0] : filters.startDate;
|
|
params.where = `(Time,gte,${startDate})`;
|
|
} else if (filters?.endDate) {
|
|
const endDate = filters.endDate.includes('T') ? filters.endDate.split('T')[0] : filters.endDate;
|
|
params.where = `(Time,lte,${endDate})`;
|
|
}
|
|
|
|
// Add payer filter
|
|
if (filters?.payer) {
|
|
const payerFilter = `(Payer,eq,${encodeURIComponent(filters.payer)})`;
|
|
params.where = params.where ? `${params.where}~and${payerFilter}` : payerFilter;
|
|
}
|
|
|
|
// Add category filter
|
|
if (filters?.category) {
|
|
const categoryFilter = `(Category,eq,${encodeURIComponent(filters.category)})`;
|
|
params.where = params.where ? `${params.where}~and${categoryFilter}` : categoryFilter;
|
|
}
|
|
|
|
// Sort by Time descending (newest first)
|
|
params.sort = '-Time';
|
|
|
|
console.log('[nocodb.getExpenses] Request params:', params);
|
|
console.log('[nocodb.getExpenses] Request URL:', createTableUrl(Table.Expense));
|
|
|
|
// Use regular $fetch with better error handling
|
|
const result = await $fetch<ExpensesResponse>(createTableUrl(Table.Expense), {
|
|
headers: {
|
|
"xc-token": getNocoDbConfiguration().token,
|
|
"Content-Type": "application/json"
|
|
},
|
|
params
|
|
});
|
|
|
|
console.log('[nocodb.getExpenses] Successfully fetched expenses, count:', result.list?.length || 0);
|
|
console.log('[nocodb.getExpenses] Request duration:', Date.now() - startTime, 'ms');
|
|
|
|
// Transform expenses to add computed price numbers
|
|
if (result.list && Array.isArray(result.list)) {
|
|
result.list = result.list.map(expense => ({
|
|
...expense,
|
|
// Parse price string to number for calculations
|
|
PriceNumber: parseFloat(expense.Price?.replace(/[€$,]/g, '') || '0') || 0
|
|
}));
|
|
}
|
|
|
|
return result;
|
|
} catch (error: any) {
|
|
console.error('[nocodb.getExpenses] =========================');
|
|
console.error('[nocodb.getExpenses] EXPENSE FETCH FAILED');
|
|
console.error('[nocodb.getExpenses] Duration:', Date.now() - startTime, 'ms');
|
|
console.error('[nocodb.getExpenses] Error type:', error.constructor?.name || 'Unknown');
|
|
console.error('[nocodb.getExpenses] Error status:', error.statusCode || error.status || 'Unknown');
|
|
console.error('[nocodb.getExpenses] Error message:', error.message || 'Unknown error');
|
|
console.error('[nocodb.getExpenses] Error data:', error.data);
|
|
console.error('[nocodb.getExpenses] Full error:', JSON.stringify(error, null, 2));
|
|
console.error('[nocodb.getExpenses] =========================');
|
|
|
|
// Provide more specific error messages
|
|
if (error.statusCode === 401 || error.status === 401) {
|
|
throw createError({
|
|
statusCode: 401,
|
|
statusMessage: 'Authentication failed when accessing expense database. Please check your access permissions.'
|
|
});
|
|
} else if (error.statusCode === 403 || error.status === 403) {
|
|
throw createError({
|
|
statusCode: 403,
|
|
statusMessage: 'Access denied to expense database. This feature requires appropriate privileges.'
|
|
});
|
|
} else if (error.statusCode === 404 || error.status === 404) {
|
|
throw createError({
|
|
statusCode: 404,
|
|
statusMessage: 'Expense database table not found. Please contact your administrator.'
|
|
});
|
|
} else if (error.code === 'NETWORK_ERROR' || error.code === 'TIMEOUT') {
|
|
throw createError({
|
|
statusCode: 503,
|
|
statusMessage: 'Expense database is temporarily unavailable. Please try again in a moment.'
|
|
});
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
export const getExpenseById = async (id: string) => {
|
|
console.log('[nocodb.getExpenseById] Fetching expense ID:', id);
|
|
|
|
try {
|
|
const result = await $fetch<Expense>(`${createTableUrl(Table.Expense)}/${id}`, {
|
|
headers: {
|
|
"xc-token": getNocoDbConfiguration().token,
|
|
},
|
|
});
|
|
|
|
console.log('[nocodb.getExpenseById] Successfully fetched expense:', result.Id);
|
|
|
|
// Add computed price number
|
|
const expenseWithPrice = {
|
|
...result,
|
|
PriceNumber: parseFloat(result.Price.replace(/[€$,]/g, '')) || 0
|
|
};
|
|
|
|
return expenseWithPrice;
|
|
} catch (error: any) {
|
|
console.error('[nocodb.getExpenseById] Error fetching expense:', error);
|
|
console.error('[nocodb.getExpenseById] Error details:', error instanceof Error ? error.message : 'Unknown error');
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
// Helper function to get current month expenses (default view)
|
|
export const getCurrentMonthExpenses = async () => {
|
|
const now = new Date();
|
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
|
|
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10);
|
|
|
|
console.log('[nocodb.getCurrentMonthExpenses] Fetching current month expenses:', startOfMonth, 'to', endOfMonth);
|
|
|
|
return getExpenses({
|
|
startDate: startOfMonth,
|
|
endDate: endOfMonth
|
|
});
|
|
};
|
|
|
|
// Helper function to group expenses by payer
|
|
export const groupExpensesByPayer = (expenses: Expense[]) => {
|
|
const groups = expenses.reduce((acc, expense) => {
|
|
const payer = expense.Payer || 'Unknown';
|
|
if (!acc[payer]) {
|
|
acc[payer] = {
|
|
name: payer,
|
|
expenses: [],
|
|
count: 0,
|
|
total: 0
|
|
};
|
|
}
|
|
|
|
acc[payer].expenses.push(expense);
|
|
acc[payer].count++;
|
|
acc[payer].total += parseFloat(expense.Price.replace(/[€$,]/g, '')) || 0;
|
|
|
|
return acc;
|
|
}, {} as Record<string, { name: string; expenses: Expense[]; count: number; total: number }>);
|
|
|
|
return Object.values(groups);
|
|
};
|