Add email integration feature
- Add /api/emails/unread endpoint to fetch unread emails from sales@cardoza.construction - Add 'Review Unread Sales Emails' button in filter buttons section - Implement draggable modal window to display emails - Show unread count badge on email button - Click emails to open in Outlook web - Add permission error handling for Azure AD Mail.Read permission - Position modal with high z-index to ensure visibility
This commit is contained in:
@@ -244,9 +244,22 @@
|
||||
|
||||
<!-- View Buttons Section -->
|
||||
<div class="bg-white rounded-lg shadow-2xl px-4 py-2 mt-2">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div id="viewButtonsContainer" class="flex items-center space-x-2 flex-wrap gap-y-2">
|
||||
<!-- View buttons will be dynamically generated here -->
|
||||
</div>
|
||||
<button
|
||||
id="emailButton"
|
||||
class="px-3 py-1.5 bg-green-600 text-white rounded hover:bg-green-700 text-sm font-medium transition-colors flex items-center gap-1 whitespace-nowrap"
|
||||
onclick="openEmailModal()"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
Review Unread Sales Emails
|
||||
<span id="emailCountBadge" class="hidden bg-red-500 text-white text-xs rounded-full px-2 py-0.5 ml-1"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Section -->
|
||||
@@ -337,6 +350,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email Modal (Hidden by default) -->
|
||||
<div id="emailModal" style="display: none; position: fixed; z-index: 99999; background: white; border: 2px solid #4c51bf; border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,0.3); width: 700px; max-width: 90vw; max-height: 80vh; flex-direction: column;">
|
||||
<div id="emailModalHeader" style="background: linear-gradient(to right, #4c51bf, #5b21b6); color: white; padding: 12px 16px; border-radius: 10px 10px 0 0; cursor: move; user-select: none; display: flex; justify-content: space-between; align-items: center;">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
<span class="font-semibold">Unread Sales Emails</span>
|
||||
<span id="emailModalCount" class="text-sm opacity-90"></span>
|
||||
</div>
|
||||
<button onclick="closeEmailModal()" class="text-white hover:text-gray-200 text-2xl font-bold leading-none">×</button>
|
||||
</div>
|
||||
<div id="emailModalBody" style="flex: 1; overflow-y: auto; padding: 16px;">
|
||||
<div class="flex justify-center items-center py-8">
|
||||
<div class="text-gray-400">Loading emails...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/pocketbase/dist/pocketbase.umd.js"></script>
|
||||
<script>
|
||||
// Suppress QUIC protocol errors (cosmetic browser issue with PocketBase realtime)
|
||||
@@ -978,6 +1010,8 @@
|
||||
: 'px-3 py-1.5 bg-gray-200 text-gray-700 rounded hover:bg-gray-300 text-sm font-medium transition-colors';
|
||||
button.textContent = view.label;
|
||||
|
||||
container.appendChild(button);
|
||||
|
||||
// Generate tooltip showing filter configuration
|
||||
if (view.filters && view.filters.length > 0) {
|
||||
const filterDescriptions = view.filters.map(filter => {
|
||||
@@ -1998,6 +2032,204 @@
|
||||
// Initialize
|
||||
checkAuth();
|
||||
|
||||
// Email Modal Functions
|
||||
let isDraggingModal = false;
|
||||
let modalOffsetX = 0;
|
||||
let modalOffsetY = 0;
|
||||
|
||||
// Make email modal draggable
|
||||
function initEmailModalDragging() {
|
||||
const modal = document.getElementById('emailModal');
|
||||
const header = document.getElementById('emailModalHeader');
|
||||
|
||||
header.addEventListener('mousedown', (e) => {
|
||||
isDraggingModal = true;
|
||||
const rect = modal.getBoundingClientRect();
|
||||
modalOffsetX = e.clientX - rect.left;
|
||||
modalOffsetY = e.clientY - rect.top;
|
||||
header.style.cursor = 'grabbing';
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!isDraggingModal) return;
|
||||
|
||||
const modal = document.getElementById('emailModal');
|
||||
|
||||
// Reset transform when dragging starts
|
||||
if (modal.style.transform) {
|
||||
modal.style.transform = '';
|
||||
}
|
||||
|
||||
let newLeft = e.clientX - modalOffsetX;
|
||||
let newTop = e.clientY - modalOffsetY;
|
||||
|
||||
// Keep modal within viewport
|
||||
const maxLeft = window.innerWidth - modal.offsetWidth;
|
||||
const maxTop = window.innerHeight - modal.offsetHeight;
|
||||
|
||||
newLeft = Math.max(0, Math.min(newLeft, maxLeft));
|
||||
newTop = Math.max(0, Math.min(newTop, maxTop));
|
||||
|
||||
modal.style.left = newLeft + 'px';
|
||||
modal.style.top = newTop + 'px';
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', () => {
|
||||
if (isDraggingModal) {
|
||||
isDraggingModal = false;
|
||||
header.style.cursor = 'move';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initEmailModalDragging();
|
||||
|
||||
async function openEmailModal() {
|
||||
const modal = document.getElementById('emailModal');
|
||||
const body = document.getElementById('emailModalBody');
|
||||
const countSpan = document.getElementById('emailModalCount');
|
||||
|
||||
// Center modal on screen
|
||||
modal.style.top = '50%';
|
||||
modal.style.left = '50%';
|
||||
modal.style.transform = 'translate(-50%, -50%)';
|
||||
|
||||
// Show modal
|
||||
modal.style.display = 'flex';
|
||||
|
||||
// Show loading state
|
||||
body.innerHTML = `
|
||||
<div class="flex justify-center items-center py-8">
|
||||
<div class="text-gray-400">Loading emails...</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/emails/unread', {
|
||||
headers: {
|
||||
'X-PB-Token': pb.authStore.token
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(data.message || 'Failed to fetch emails');
|
||||
error.response = data;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const emails = data.emails || [];
|
||||
|
||||
countSpan.textContent = `(${emails.length})`;
|
||||
|
||||
if (emails.length === 0) {
|
||||
body.innerHTML = `
|
||||
<div class="flex flex-col items-center justify-center py-12 text-gray-500">
|
||||
<svg class="w-16 h-16 mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||
</svg>
|
||||
<p class="text-lg">No unread emails</p>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
body.innerHTML = emails.map(email => {
|
||||
const date = new Date(email.receivedDateTime);
|
||||
const dateStr = date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
return `
|
||||
<div class="email-item" onclick="openEmailInOutlook('${email.webLink}')">
|
||||
<div class="flex justify-between items-start mb-1">
|
||||
<div class="font-semibold text-gray-900 flex-1">${escapeHtml(email.subject)}</div>
|
||||
<div class="text-xs text-gray-500 ml-2 whitespace-nowrap">${dateStr}</div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 mb-2">From: ${escapeHtml(email.from)}</div>
|
||||
<div class="text-sm text-gray-500 line-clamp-2">${escapeHtml(email.bodyPreview)}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Update badge on button
|
||||
updateEmailBadge(emails.length);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching emails:', error);
|
||||
|
||||
let errorMessage = error.message;
|
||||
let errorHint = '';
|
||||
|
||||
// Check if it's a response error with details
|
||||
if (error.response) {
|
||||
errorMessage = error.response.message || error.message;
|
||||
errorHint = error.response.hint || '';
|
||||
}
|
||||
|
||||
body.innerHTML = `
|
||||
<div class="flex flex-col items-center justify-center py-12 text-red-500">
|
||||
<svg class="w-16 h-16 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<p class="text-lg font-semibold">Error loading emails</p>
|
||||
<p class="text-sm text-gray-600 mt-2 max-w-md text-center">${escapeHtml(errorMessage)}</p>
|
||||
${errorHint ? `<p class="text-xs text-gray-500 mt-2 max-w-md text-center italic">${escapeHtml(errorHint)}</p>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function closeEmailModal() {
|
||||
const modal = document.getElementById('emailModal');
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
|
||||
function openEmailInOutlook(webLink) {
|
||||
if (webLink) {
|
||||
window.open(webLink, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
function updateEmailBadge(count) {
|
||||
const badge = document.getElementById('emailCountBadge');
|
||||
if (count > 0) {
|
||||
badge.textContent = count;
|
||||
badge.classList.remove('hidden');
|
||||
} else {
|
||||
badge.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Load email count on startup
|
||||
async function loadEmailCount() {
|
||||
try {
|
||||
const response = await fetch('/api/emails/unread', {
|
||||
headers: {
|
||||
'X-PB-Token': pb.authStore.token
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const count = (data.emails || []).length;
|
||||
updateEmailBadge(count);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading email count:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load email count after auth
|
||||
setTimeout(() => {
|
||||
if (pb.authStore.isValid) {
|
||||
loadEmailCount();
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
// Clean up subscription on unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
try {
|
||||
|
||||
@@ -50,6 +50,76 @@ app.get('/health', (c) => {
|
||||
return c.json({ ok: true, pbDB: process.env.PB_DB });
|
||||
});
|
||||
|
||||
// Email endpoint - fetch unread emails from sales@cardoza.construction
|
||||
app.get('/api/emails/unread', async (c) => {
|
||||
try {
|
||||
const pbToken = c.req.header('X-PB-Token');
|
||||
|
||||
if (!pbToken) {
|
||||
return c.json({ success: false, message: 'Missing pbToken' }, 401);
|
||||
}
|
||||
|
||||
// Validate user token
|
||||
setUserPocketBaseAuth(pbToken);
|
||||
try {
|
||||
await pb.collection(process.env.PB_AUTH_COLLECTION || 'Users').authRefresh();
|
||||
} catch (e) {
|
||||
console.error('auth_validation_failed', { message: (e as Error)?.message });
|
||||
return c.json({ success: false, message: 'Invalid token' }, 401);
|
||||
}
|
||||
|
||||
// Get Graph API token
|
||||
const graphToken = await getGraphToken();
|
||||
|
||||
// Fetch unread emails from sales@cardoza.construction mailbox
|
||||
const mailboxEmail = 'sales@cardoza.construction';
|
||||
const graphUrl = `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(mailboxEmail)}/messages?$filter=isRead eq false&$select=id,subject,from,receivedDateTime,bodyPreview,webLink&$orderby=receivedDateTime desc&$top=50`;
|
||||
|
||||
const response = await fetch(graphUrl, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${graphToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('graph_api_error', { status: response.status, error: errorText });
|
||||
|
||||
// Provide helpful error message
|
||||
if (response.status === 403) {
|
||||
return c.json({
|
||||
success: false,
|
||||
message: 'Permission denied. The application needs Mail.Read permission in Azure AD to access the sales mailbox.',
|
||||
hint: 'Ask your Azure AD administrator to grant "Mail.Read" application permission to this app.',
|
||||
error: errorText
|
||||
}, 403);
|
||||
}
|
||||
|
||||
return c.json({ success: false, message: 'Failed to fetch emails', error: errorText }, response.status);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const emails = data.value || [];
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
emails: emails.map((email: any) => ({
|
||||
id: email.id,
|
||||
subject: email.subject || '(No Subject)',
|
||||
from: email.from?.emailAddress?.name || email.from?.emailAddress?.address || 'Unknown',
|
||||
fromEmail: email.from?.emailAddress?.address || '',
|
||||
receivedDateTime: email.receivedDateTime,
|
||||
bodyPreview: email.bodyPreview || '',
|
||||
webLink: email.webLink || ''
|
||||
}))
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('email_fetch_error', { message: (err as Error)?.message, stack: (err as Error)?.stack });
|
||||
return c.json({ success: false, message: 'Internal error: ' + (err as Error)?.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Submit endpoint
|
||||
app.post('/api/submit', async (c) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user