Files
Job-List/estimatortable.html
aewing 9ca7d2e5c2 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
2026-01-02 06:07:51 +00:00

2247 lines
86 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Estimator Table</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: '#4c51bf',
secondary: '#5b21b6',
}
}
}
}
</script>
<style>
/* Fixed layout - no outer scroll */
body {
display: flex;
flex-direction: column;
}
/* Container layout - keep header stationary */
#tableContainer {
position: relative;
display: flex;
flex-direction: column;
height: 100vh;
padding: 1rem;
padding-top: 3.5rem; /* Space for user display */
}
/* Fixed header section */
.header-section {
flex-shrink: 0;
margin-bottom: 1rem;
}
/* Scrollable table area with always-visible scrollbars */
.table-container {
position: relative;
overflow: scroll; /* Always show scrollbars */
flex: 1;
border: 1px solid #e5e7eb;
background: white;
border-radius: 0.5rem;
min-height: 0; /* Critical for flex child scrolling */
}
/* Force scrollbars to always be visible */
.table-container::-webkit-scrollbar {
width: 12px;
height: 12px;
}
.table-container::-webkit-scrollbar-track {
background: #f1f1f1;
}
.table-container::-webkit-scrollbar-thumb {
background: #888;
border-radius: 6px;
}
.table-container::-webkit-scrollbar-thumb:hover {
background: #555;
}
.data-table {
border-collapse: separate;
border-spacing: 0;
table-layout: fixed; /* Fixed table layout for consistent column widths */
}
/* Fixed column widths */
.data-table th,
.data-table td {
overflow: hidden;
text-overflow: ellipsis;
border-right: 1px solid #e5e7eb;
border-bottom: 1px solid #e5e7eb;
}
.data-table td {
white-space: nowrap;
}
/* Enforce fixed row heights */
.data-table tbody tr {
height: 40px;
max-height: 40px;
overflow: hidden;
}
.data-table tbody td {
height: 40px;
max-height: 40px;
overflow: hidden;
vertical-align: middle;
}
/* Freeze header row */
.data-table thead th {
position: sticky;
top: 0;
z-index: 20;
background: linear-gradient(to bottom right, #4c51bf, #5b21b6);
color: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
border-right: 1px solid rgba(255,255,255,0.2);
padding: 12px 16px;
}
/* Freeze first column (Job_Number) - fixed 120px width */
.data-table th:nth-child(1),
.data-table td:nth-child(1) {
position: sticky;
left: 0;
z-index: 15;
background: white;
border-right: 2px solid #4c51bf !important;
}
/* First column header - highest z-index */
.data-table thead th:nth-child(1) {
z-index: 35;
background: linear-gradient(to bottom right, #4c51bf, #5b21b6);
box-shadow: 2px 2px 4px rgba(0,0,0,0.2);
border-right: 2px solid rgba(255,255,255,0.3) !important;
}
/* Freeze second column (Job_Full_Name) - fixed 250px width */
.data-table th:nth-child(2),
.data-table td:nth-child(2) {
position: sticky;
left: 120px;
z-index: 15;
background: white;
border-right: 2px solid #4c51bf !important;
}
/* Second column header - highest z-index */
.data-table thead th:nth-child(2) {
z-index: 35;
background: linear-gradient(to bottom right, #4c51bf, #5b21b6);
box-shadow: 2px 2px 4px rgba(0,0,0,0.2);
border-right: 2px solid rgba(255,255,255,0.3) !important;
}
/* Alternating row colors - maintain on frozen columns */
.data-table tbody tr:nth-child(even) td {
background: #f9fafb;
}
.data-table tbody tr:nth-child(even) td:nth-child(1),
.data-table tbody tr:nth-child(even) td:nth-child(2) {
background: #f9fafb;
}
.data-table tbody tr:hover td {
background: #fef3c7;
}
.data-table tbody tr:hover td:nth-child(1),
.data-table tbody tr:hover td:nth-child(2) {
background: #fef3c7;
}
/* Prominent checkbox styling */
.checkbox-cell {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}
.checkbox-cell input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: default;
accent-color: #000;
border: 2px solid #000;
margin: 0;
}
/* Override disabled appearance - keep checkboxes looking active */
.checkbox-cell input[type="checkbox"]:disabled {
opacity: 1;
cursor: default;
}
</style>
</head>
<body class="h-screen overflow-hidden bg-gradient-to-br from-primary to-secondary font-sans">
<!-- Login Container -->
<div id="loginContainer" class="hidden max-w-md mx-auto bg-white rounded-2xl shadow-2xl p-8 mt-20">
<div class="text-center mb-8">
<h1 class="text-4xl font-bold text-gray-800 mb-2">Estimator Table</h1>
<p class="text-gray-600">Job Information</p>
</div>
<div class="space-y-6">
<p class="text-gray-700 text-center">Sign in with your Microsoft account to access the table</p>
<button type="button" id="loginBtn" class="w-full py-3 px-4 bg-gradient-to-br from-primary to-secondary text-white font-semibold rounded-lg hover:opacity-95 transition-opacity shadow-lg hover:shadow-xl">
Login with Microsoft
</button>
</div>
</div>
<!-- Loading Indicator -->
<div id="loadingIndicator" class="hidden text-center text-white">
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
<p class="mt-4">Loading data...</p>
</div>
<!-- Table Container -->
<div id="tableContainer" class="hidden">
<!-- Top-right display name and settings -->
<div id="userDisplay" class="hidden absolute top-4 right-4 flex items-center space-x-3" style="z-index: 1000;">
<span id="userDisplayName" class="text-white text-sm font-semibold drop-shadow"></span>
<button id="settingsBtn" class="text-white hover:text-gray-200 transition-colors" title="Settings">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
<button id="logoutBtn" class="text-white hover:text-red-300 transition-colors" title="Logout">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
<!-- Header Section -->
<div class="header-section bg-white rounded-lg shadow-2xl p-6">
<h1 class="text-3xl font-bold text-gray-800">Estimator Job Table</h1>
<p class="text-gray-600 mt-1">Scroll horizontally and vertically • First two columns are frozen</p>
</div>
<!-- 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 -->
<div class="bg-white rounded-lg shadow-2xl px-4 py-2 mt-2">
<div class="flex justify-end">
<div style="width: 400px;">
<div class="relative">
<input
type="text"
id="searchInput"
placeholder="Search jobs..."
class="w-full px-4 py-1.5 pl-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
<svg class="absolute left-3 top-2 h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
<span id="searchResultCount" class="absolute right-3 top-2 text-sm text-gray-600"></span>
</div>
</div>
</div>
</div>
<!-- Table Section -->
<div class="bg-white rounded-lg shadow-2xl p-4 mt-2" style="display: flex; flex-direction: column; flex: 1; min-height: 0;">
<div class="table-container">
<table class="data-table w-full text-sm">
<thead>
<tr>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Job Number</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Job Full Name</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Active</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Added To Calendar</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Attachment List</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Company Client</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Contact Person</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Docs Uploaded</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Due Date</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Due Date Counter</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Due Date Source</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Due Time</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Email</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Estimate Approved</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Estimate Draft</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Estimate Reviewed</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Estimator</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Flag</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Has Attachments</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Job Address</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Job Codes</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Job Division</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Job Folder Link</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Job Name</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Job Number Parent</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Job QB Link</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Job Status</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Job Type</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Note Ref</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Notes</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Office Rep</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">PSwift Uploaded</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Phone Number</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Project Manager</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">QB Created</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Schedule Confidence</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Start Date</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Submission Date</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Tax Exempt</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Tax Exempt Docs Uploaded</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Voxer Created</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Voxer Link</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Last Note At</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Latest Note Summary</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Note Count</th>
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Note Thread Text</th>
</tr>
</thead>
<tbody id="tableBody">
<!-- Data will be inserted here -->
</tbody>
</table>
</div>
</div>
<!-- Footer -->
<div class="mt-2 flex justify-between items-center text-white text-xs px-4">
<span id="footerEmail"></span>
<span>v1.0.0-alpha6</span>
</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">&times;</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)
const originalConsoleError = console.error;
console.error = function(...args) {
const message = args.join(' ');
if (message.includes('ERR_QUIC_PROTOCOL_ERROR') || message.includes('/api/realtime')) {
return; // Suppress these specific errors
}
originalConsoleError.apply(console, args);
};
// Initialize PocketBase client
const pb = new PocketBase('https://pocketbase.ccllc.pro');
// Column name to position mapping fallback (when no settings found)
const COLUMN_ORDER = [
'Job_Number', 'Job_Full_Name', 'Active', 'Added_To_Calendar', 'Attachment_List',
'Company_Client', 'Contact_Person', 'Docs_Uploaded', 'Due_Date', 'Due_Date_Counter',
'Due_Date_Source', 'Due_Time', 'Email', 'Estimate_Approved', 'Estimate_Draft',
'Estimate_Reviewed', 'Estimator', 'Flag', 'Has_Attachments', 'Job_Address',
'Job_Codes', 'Job_Division', 'Job_Folder_Link', 'Job_Name', 'Job_Number_Parent',
'Job_QB_Link', 'Job_Status', 'Job_Type', 'Note_Ref', 'Notes',
'Office_Rep', 'PSwift_Uploaded', 'Phone_Number', 'Project_Manager', 'QB_Created',
'Schedule_Confidence', 'Start_Date', 'Submission_Date', 'Tax_Exempt', 'Tax_Exempt_Docs_Uploaded',
'Voxer_Created', 'Voxer_Link', 'last_note_at', 'latest_note_summary', 'note_count',
'note_thread_text', 'created'
];
const loginContainer = document.getElementById('loginContainer');
const tableContainer = document.getElementById('tableContainer');
const loadingIndicator = document.getElementById('loadingIndicator');
const userDisplay = document.getElementById('userDisplay');
const userDisplayName = document.getElementById('userDisplayName');
const loginBtn = document.getElementById('loginBtn');
const tableBody = document.getElementById('tableBody');
const APP_NAME = 'Estimator Job List';
let appSettings = null;
let appSettingsRecordId = null;
let appSettingsUnsubscribe = null;
let appViews = null;
let appViewsRecordId = null;
let currentViewFilter = null; // Current active view filter
let currentSort = { column: 'Job_Number', direction: 'desc' }; // Current sort state
let columnFilters = {}; // Column-specific filters { columnName: searchTerm }
let tableReloadTimer = null;
let isLoadingTableData = false;
let allRecords = []; // Store all records for filtering
let currentSearchTerm = '';
function normalizeColumnOrders(columns) {
if (!Array.isArray(columns)) return [];
const normalized = columns.map((col, idx) => {
const orderNum = Number.parseInt(col?.order, 10);
const safeOrder = Number.isFinite(orderNum) ? orderNum : idx + 1;
return { ...col, order: safeOrder };
}).sort((a, b) => a.order - b.order);
return normalized;
}
// Helpers to get columns in current order/visibility
function getVisibleColumns() {
if (appSettings && Array.isArray(appSettings.columns) && appSettings.columns.length) {
return normalizeColumnOrders(appSettings.columns).filter((c) => c.visible !== false);
}
// Fallback to default order when no settings yet
return COLUMN_ORDER.map((name, idx) => ({ name, visible: true, order: idx + 1 }));
}
function prettyLabel(name) {
// Special case for Tax_Exempt_Docs_Uploaded
if (name === 'Tax_Exempt_Docs_Uploaded') {
return 'Tax Exempt Uploads?';
}
return name.replace(/_/g, ' ');
}
function scheduleTableReload() {
if (tableReloadTimer) {
clearTimeout(tableReloadTimer);
}
tableReloadTimer = setTimeout(() => {
if (isLoadingTableData) {
return;
}
loadTableData();
}, 200);
}
// Load app views from PocketBase
async function loadAppViews() {
try {
const records = await pb.collection('app_preferences_settings').getFullList({
filter: `app_name = "Estimator Job List"`
});
if (records.length > 0) {
const viewsData = records[0].app_views_json;
appViewsRecordId = records[0].id;
if (viewsData) {
appViews = typeof viewsData === 'string' ? JSON.parse(viewsData) : viewsData;
console.log('Loaded app views:', appViews);
}
}
} catch (error) {
console.error('Error loading app views:', error);
}
}
// Save app views to PocketBase
async function saveAppViews() {
try {
if (!appViewsRecordId) {
console.error('No views record ID available');
return;
}
const viewsJson = JSON.stringify(appViews);
await pb.collection('app_preferences_settings').update(appViewsRecordId, {
app_views_json: viewsJson
});
console.log('Views saved successfully');
} catch (error) {
console.error('Error saving views:', error);
}
}
// Load app settings from PocketBase (global settings)
async function loadAppSettings() {
try {
const records = await pb.collection('app_preferences_settings').getFullList({
filter: `app_name = "${APP_NAME}"`
});
if (records.length > 0) {
const settingsData = records[0].app_json_prefs;
appSettingsRecordId = records[0].id;
console.log('Settings record ID:', appSettingsRecordId);
if (settingsData) {
appSettings = typeof settingsData === 'string' ? JSON.parse(settingsData) : settingsData;
// Ensure all columns have order field
if (appSettings && Array.isArray(appSettings.columns)) {
const needsOrderSync = appSettings.columns.some(col => col.order === undefined || col.order === null);
if (needsOrderSync) {
console.log('Columns missing order field, syncing...');
appSettings.columns = appSettings.columns.map((col, idx) => ({
...col,
order: col.order ?? idx + 1
}));
}
appSettings.columns = normalizeColumnOrders(appSettings.columns);
}
console.log('Loaded app settings:', appSettings);
applyGlobalSettings();
renderTableHeader();
} else {
console.log('No settings data in record');
}
} else {
console.log('No settings record found, using defaults');
renderTableHeader();
}
} catch (error) {
console.error('Error loading app settings:', error);
renderTableHeader(); // Still render with defaults
}
}
// Watch for live updates to app settings
async function startSettingsWatch() {
// Avoid duplicate subscriptions
if (appSettingsUnsubscribe) return;
try {
appSettingsUnsubscribe = await pb.collection('app_preferences_settings').subscribe('*', (e) => {
const record = e?.record;
if (!record) return;
if (record.app_name !== APP_NAME) return;
// If we didn't have the record id yet, capture it
if (!appSettingsRecordId) appSettingsRecordId = record.id;
// Only respond to the tracked settings record if known
if (appSettingsRecordId && record.id !== appSettingsRecordId) return;
const settingsData = record.app_json_prefs;
appSettings = typeof settingsData === 'string' ? JSON.parse(settingsData || '{}') : (settingsData || {});
if (appSettings && Array.isArray(appSettings.columns)) {
const needsOrderSync = appSettings.columns.some(col => col.order === undefined || col.order === null);
if (needsOrderSync) {
console.log('Realtime update: columns missing order field, syncing...');
appSettings.columns = appSettings.columns.map((col, idx) => ({
...col,
order: col.order ?? idx + 1
}));
}
console.log('Received settings update via realtime, original orders:', appSettings.columns.map(c => ({ name: c.name, order: c.order })).slice(0, 5));
appSettings.columns = normalizeColumnOrders(appSettings.columns);
console.log('After normalize/sort:', appSettings.columns.map(c => ({ name: c.name, order: c.order })).slice(0, 5));
}
console.log('App settings updated via realtime', { action: e.action });
applyGlobalSettings();
scheduleTableReload();
}, { /* Silently handle connection errors */ });
} catch (err) {
// Silently ignore realtime connection errors (QUIC protocol issues are cosmetic)
}
}
// Render table header dynamically based on current settings
function renderTableHeader() {
const headerRow = document.querySelector('.data-table thead tr');
if (!headerRow) {
console.error('Header row not found');
return;
}
const visibleColumns = getVisibleColumns();
console.log('Rendering header with', visibleColumns.length, 'columns');
headerRow.innerHTML = visibleColumns.map(col => {
const colSettings = getColumnSettings(col.name);
const alignment = colSettings?.alignment || 'left';
const wrapClass = colSettings?.headerWrap ? 'whitespace-normal break-words' : 'whitespace-nowrap';
const sortIcon = currentSort.column === col.name
? (currentSort.direction === 'asc' ? ' ▲' : ' ▼')
: '';
const hasFilter = columnFilters[col.name] ? ' 🔍' : '';
return `<th class="px-4 py-3 text-${alignment} font-semibold ${wrapClass} cursor-pointer hover:bg-gray-100" data-column="${col.name}">${prettyLabel(col.name)}${sortIcon}${hasFilter}</th>`;
}).join('');
// Attach click handlers for column menu
document.querySelectorAll('th[data-column]').forEach(th => {
th.addEventListener('click', (e) => {
showColumnMenu(e.currentTarget, e.currentTarget.dataset.column);
});
});
}
// Show column menu popup
function showColumnMenu(headerElement, columnName) {
// Remove any existing menu
const existingMenu = document.getElementById('columnMenu');
if (existingMenu) existingMenu.remove();
const menu = document.createElement('div');
menu.id = 'columnMenu';
menu.className = 'absolute bg-white border border-gray-300 rounded-lg shadow-lg p-3 z-[2000]';
menu.style.minWidth = '250px';
const rect = headerElement.getBoundingClientRect();
menu.style.left = `${rect.left}px`;
menu.style.top = `${rect.bottom + 5}px`;
const currentFilter = columnFilters[columnName] || '';
const isSorted = currentSort.column === columnName;
menu.innerHTML = `
<div class="text-sm font-semibold mb-2 pb-2 border-b border-gray-200">${prettyLabel(columnName)}</div>
<div class="mb-3">
<button class="w-full text-left px-3 py-2 hover:bg-blue-100 rounded text-sm" data-action="sort-asc">
${isSorted && currentSort.direction === 'asc' ? '✓ ' : ''}Sort A → Z
</button>
<button class="w-full text-left px-3 py-2 hover:bg-blue-100 rounded text-sm" data-action="sort-desc">
${isSorted && currentSort.direction === 'desc' ? '✓ ' : ''}Sort Z → A
</button>
${isSorted ? '<button class="w-full text-left px-3 py-2 hover:bg-red-100 rounded text-sm text-red-600" data-action="clear-sort">Clear Sort</button>' : ''}
</div>
<div class="border-t border-gray-200 pt-3">
<label class="block text-xs text-gray-600 mb-1">Filter:</label>
<input type="text"
class="w-full px-2 py-1 border border-gray-300 rounded text-sm focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
placeholder="Search in ${prettyLabel(columnName)}..."
value="${currentFilter}"
id="columnFilterInput"
/>
${currentFilter ? '<div class="mt-2"><button class="w-full px-3 py-1 bg-red-500 text-white rounded text-xs hover:bg-red-600" data-action="clear-filter">Clear Filter</button></div>' : ''}
</div>
`;
document.body.appendChild(menu);
// Focus the filter input
const filterInput = menu.querySelector('#columnFilterInput');
if (filterInput) filterInput.focus();
// Debounce filter application to avoid re-rendering while typing
let filterTimeout;
filterInput.addEventListener('input', (e) => {
clearTimeout(filterTimeout);
filterTimeout = setTimeout(() => {
const value = e.target.value.trim();
if (value) {
columnFilters[columnName] = value;
} else {
delete columnFilters[columnName];
}
// Only re-render table rows, not the header (which would destroy the menu)
renderTableRows();
}, 300); // Wait 300ms after user stops typing
});
// Handle menu actions
menu.addEventListener('click', (e) => {
const action = e.target.dataset.action;
if (!action) return;
switch (action) {
case 'sort-asc':
currentSort.column = columnName;
currentSort.direction = 'asc';
renderTableHeader();
renderTableRows();
menu.remove();
break;
case 'sort-desc':
currentSort.column = columnName;
currentSort.direction = 'desc';
renderTableHeader();
renderTableRows();
menu.remove();
break;
case 'clear-sort':
currentSort.column = 'Job_Number';
currentSort.direction = 'desc';
renderTableHeader();
renderTableRows();
menu.remove();
break;
case 'clear-filter':
delete columnFilters[columnName];
renderTableHeader();
renderTableRows();
menu.remove();
break;
}
});
// Close menu when clicking outside
const closeOnOutsideClick = (e) => {
if (!menu.contains(e.target) && e.target !== headerElement) {
menu.remove();
// Update header to show filter icon after menu closes
renderTableHeader();
document.removeEventListener('click', closeOnOutsideClick);
}
};
setTimeout(() => document.addEventListener('click', closeOnOutsideClick), 0);
}
// Apply global settings to table (styles)
function applyGlobalSettings() {
// Remove any existing dynamic styles first
const existingStyle = document.getElementById('dynamic-column-styles');
if (existingStyle) existingStyle.remove();
const visibleColumns = getVisibleColumns();
let styleContent = '';
// Apply row height
if (appSettings && appSettings.rowHeight) {
const height = appSettings.rowHeight;
const cellVerticalAlign = appSettings.cellVerticalAlign || 'middle';
styleContent += `
.data-table tbody tr {
height: ${height}px !important;
max-height: ${height}px !important;
}
.data-table tbody td {
height: ${height}px !important;
max-height: ${height}px !important;
overflow: hidden !important;
vertical-align: ${cellVerticalAlign} !important;
padding: ${cellVerticalAlign === 'top' ? '4px 16px' : (cellVerticalAlign === 'bottom' ? '0 16px 4px 16px' : '0 16px')} !important;
}
`;
}
// Apply header vertical alignment
if (appSettings && appSettings.headerVerticalAlign) {
const headerAlign = appSettings.headerVerticalAlign;
styleContent += `
.data-table thead th {
vertical-align: ${headerAlign} !important;
padding: ${headerAlign === 'top' ? '8px 16px 4px 16px' : (headerAlign === 'bottom' ? '4px 16px 8px 16px' : '12px 16px')} !important;
}
`;
}
// Apply font size
if (appSettings && appSettings.fontSize) {
styleContent += `.data-table { font-size: ${appSettings.fontSize}px; }\n`;
}
// Apply striped rows setting
if (appSettings && appSettings.stripedRows === false) {
styleContent += `.data-table tbody tr:nth-child(even) td { background: white !important; }\n`;
}
// Apply border settings
if (appSettings) {
const borderColor = appSettings.borderColor || '#e5e7eb';
const borderWidth = appSettings.borderWidth || 1;
const showRowBorders = appSettings.showRowBorders !== false;
const showColumnBorders = appSettings.showColumnBorders !== false;
styleContent += `
.data-table th,
.data-table td {
border-right: ${showColumnBorders ? `${borderWidth}px solid ${borderColor}` : 'none'} !important;
border-bottom: ${showRowBorders ? `${borderWidth}px solid ${borderColor}` : 'none'} !important;
}
`;
// Keep header borders slightly different if they exist
if (showColumnBorders) {
styleContent += `
.data-table thead th {
border-right: ${borderWidth}px solid rgba(255,255,255,0.2) !important;
}
`;
}
}
// Column widths and sticky headers based on visible order
if (visibleColumns.length) {
const col1Width = visibleColumns[0]?.width || 120;
const col2Width = visibleColumns[1]?.width || 250;
styleContent += `
.data-table th:nth-child(1), .data-table td:nth-child(1) {
width: ${col1Width}px !important;
min-width: ${col1Width}px !important;
max-width: ${col1Width}px !important;
}
`;
if (visibleColumns.length > 1) {
styleContent += `
.data-table th:nth-child(2), .data-table td:nth-child(2) {
left: ${col1Width}px !important;
width: ${col2Width}px !important;
min-width: ${col2Width}px !important;
max-width: ${col2Width}px !important;
}
`;
}
let totalWidth = 0;
visibleColumns.forEach((col, idx) => {
const width = col.width || 150;
const colSettings = getColumnSettings(col.name);
const alignment = colSettings?.alignment || 'left';
const headerWrap = colSettings?.headerWrap;
totalWidth += width;
styleContent += `
.data-table th:nth-child(${idx + 1}),
.data-table td:nth-child(${idx + 1}) {
width: ${width}px !important;
min-width: ${width}px !important;
max-width: ${width}px !important;
text-align: ${alignment};
}
`;
// Apply header wrapping if enabled
if (headerWrap) {
styleContent += `
.data-table thead th:nth-child(${idx + 1}) {
white-space: normal !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
}
`;
} else {
styleContent += `
.data-table thead th:nth-child(${idx + 1}) {
white-space: nowrap !important;
}
`;
}
});
styleContent += `
.data-table {
width: ${totalWidth}px !important;
min-width: ${totalWidth}px !important;
}
`;
}
const style = document.createElement('style');
style.id = 'dynamic-column-styles';
style.textContent = styleContent;
document.head.appendChild(style);
// Ensure header reflects current order/visibility
renderTableHeader();
// Re-render body only if data is already loaded
if (allRecords && allRecords.length > 0) {
renderTableRows();
}
// Note: Don't call scheduleTableReload here - it will be called after initial load
}
// Get column settings
function getColumnSettings(columnName) {
if (!appSettings || !appSettings.columns) return null;
return appSettings.columns.find(col => col.name === columnName);
}
// Get choice styling for a value
function getChoiceStyling(columnName, value) {
const colSettings = getColumnSettings(columnName);
if (!colSettings || !colSettings.choices) return null;
const choice = colSettings.choices.find(c => c.value === value);
return choice || null;
}
// Check if user is already authenticated
async function checkAuth() {
if (pb.authStore.isValid) {
// Already logged in
console.log('User authenticated, loading...');
loadingIndicator.classList.remove('hidden');
// If model is missing, try to get current user
if (!pb.authStore.model) {
try {
const user = await pb.collection('users').getOne(pb.authStore.token.split('.')[1] ? JSON.parse(atob(pb.authStore.token.split('.')[1])).id : '');
pb.authStore.save(pb.authStore.token, user);
console.log('Loaded user model:', user);
} catch (err) {
console.error('Failed to load user model:', err);
}
}
await loadAppSettings();
console.log('Settings loaded');
await loadAppViews();
console.log('Views loaded');
await startSettingsWatch();
console.log('Settings watch started');
showTable();
console.log('Table shown');
await loadTableData();
console.log('Data loaded');
} else {
// Need to login
console.log('User not authenticated');
loginContainer.classList.remove('hidden');
}
}
// Login with Microsoft
loginBtn.addEventListener('click', async () => {
try {
loginBtn.disabled = true;
loginBtn.textContent = 'Logging in...';
loadingIndicator.classList.remove('hidden');
const authData = await pb.collection('users').authWithOAuth2({ provider: 'microsoft' });
console.log('Login successful, authData:', authData);
console.log('Auth record:', authData.record);
// Ensure the model is properly saved
if (authData.record) {
pb.authStore.save(pb.authStore.token, authData.record);
console.log('Saved user model to authStore:', authData.record);
}
await loadAppSettings();
await loadAppViews();
await startSettingsWatch();
showTable();
await loadTableData();
} catch (error) {
console.error('Login failed:', error);
alert('Login failed. Please try again.');
loginBtn.disabled = false;
loginBtn.textContent = 'Login with Microsoft';
loadingIndicator.classList.add('hidden');
}
});
function showTable() {
loginContainer.classList.add('hidden');
tableContainer.classList.remove('hidden');
console.log('showTable() called - pb.authStore:', pb.authStore);
console.log('showTable() - pb.authStore.model:', pb.authStore.model);
console.log('showTable() - pb.authStore.isValid:', pb.authStore.isValid);
if (pb.authStore.model) {
const displayName = pb.authStore.model.name || pb.authStore.model.email || 'User';
const email = pb.authStore.model.email || '';
console.log('USER INFO - Display Name:', displayName, '| Email:', email);
userDisplay.classList.remove('hidden');
userDisplayName.textContent = displayName;
// Set footer email
const footerEmail = document.getElementById('footerEmail');
if (footerEmail) {
footerEmail.textContent = email;
}
} else {
console.log('ERROR: pb.authStore.model is null/undefined!');
}
// Setup search listener after table is shown
setupSearchListener();
// Setup view button listeners
setupViewButtons();
}
// Set up view button event handlers
function setupViewButtons() {
if (!appViews || appViews.length === 0) {
console.log('No views loaded');
return;
}
const container = document.getElementById('viewButtonsContainer');
container.innerHTML = '';
// Generate buttons dynamically from appViews
appViews.forEach((view, idx) => {
const button = document.createElement('button');
button.id = `view_${view.id}`;
button.className = idx === 0
? 'px-3 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm font-medium transition-colors'
: '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 => {
const fieldName = filter.field.replace(/_/g, ' ');
let desc = fieldName;
switch (filter.operator) {
case 'equals':
desc += ` = ${filter.value}`;
break;
case 'notEquals':
desc += `${filter.value}`;
break;
case 'contains':
desc += ` contains "${filter.value}"`;
break;
case 'empty':
desc += ` is empty`;
break;
case 'notEmpty':
desc += ` is not empty`;
break;
}
return desc;
});
const logic = view.filterLogic || 'AND';
const tooltip = filterDescriptions.join(`\n${logic} `);
button.title = tooltip;
} else {
button.title = 'Show all records (no filters)';
}
button.addEventListener('click', () => {
// Update active button styling
container.querySelectorAll('button').forEach(btn => {
btn.className = 'px-3 py-1.5 bg-gray-200 text-gray-700 rounded hover:bg-gray-300 text-sm font-medium transition-colors';
});
button.className = 'px-3 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm font-medium transition-colors';
// Create filter function from view definition
if (!view.filters || view.filters.length === 0) {
currentViewFilter = null;
} else {
currentViewFilter = (record) => {
const filterLogic = view.filterLogic || 'AND';
const evaluateFilter = (filter) => {
const fieldValue = record[filter.field];
switch (filter.operator) {
case 'equals':
// Handle boolean comparison
if (filter.value === 'true' || filter.value === true) {
return fieldValue === true;
} else if (filter.value === 'false' || filter.value === false) {
return fieldValue === false;
}
return String(fieldValue) === String(filter.value);
case 'notEquals':
if (filter.value === 'true' || filter.value === true) {
return fieldValue !== true;
} else if (filter.value === 'false' || filter.value === false) {
return fieldValue !== false;
}
return String(fieldValue) !== String(filter.value);
case 'contains':
return fieldValue && String(fieldValue).toLowerCase().includes(String(filter.value).toLowerCase());
case 'empty':
return !fieldValue || String(fieldValue).trim() === '';
case 'notEmpty':
return fieldValue && String(fieldValue).trim() !== '';
default:
return true;
}
};
// Apply AND or OR logic
if (filterLogic === 'OR') {
return view.filters.some(filter => evaluateFilter(filter));
} else {
return view.filters.every(filter => evaluateFilter(filter));
}
};
}
renderTableRows();
});
container.appendChild(button);
});
// Set first view as active filter on initial load
if (appViews.length > 0 && appViews[0].filters && appViews[0].filters.length > 0) {
const firstView = appViews[0];
const filterLogic = firstView.filterLogic || 'AND';
currentViewFilter = (record) => {
const evaluateFilter = (filter) => {
const fieldValue = record[filter.field];
switch (filter.operator) {
case 'equals':
if (filter.value === 'true' || filter.value === true) {
return fieldValue === true;
} else if (filter.value === 'false' || filter.value === false) {
return fieldValue === false;
}
return String(fieldValue) === String(filter.value);
case 'notEquals':
if (filter.value === 'true' || filter.value === true) {
return fieldValue !== true;
} else if (filter.value === 'false' || filter.value === false) {
return fieldValue !== false;
}
return String(fieldValue) !== String(filter.value);
case 'contains':
return fieldValue && String(fieldValue).toLowerCase().includes(String(filter.value).toLowerCase());
case 'empty':
return !fieldValue || String(fieldValue).trim() === '';
case 'notEmpty':
return fieldValue && String(fieldValue).trim() !== '';
default:
return true;
}
};
// Apply AND or OR logic
if (filterLogic === 'OR') {
return firstView.filters.some(filter => evaluateFilter(filter));
} else {
return firstView.filters.every(filter => evaluateFilter(filter));
}
};
}
}
// Settings button handler
document.addEventListener('click', (e) => {
if (e.target.closest('#settingsBtn')) {
window.location.href = '/appsettings.html';
}
if (e.target.closest('#logoutBtn')) {
pb.authStore.clear();
window.location.reload();
}
});
// Format date from UTC to Central Time (MM/dd/yyyy)
function formatDate(dateString, notesField = null) {
// Check for sentinel date (null date representation)
if (!dateString || dateString === '1900-01-01T00:00:00.000Z' || dateString.startsWith('1900-01-01')) {
// If there's a related notes field with content, show "See Notes"
if (notesField && notesField.trim()) {
return 'See Notes';
}
return '';
}
try {
const date = new Date(dateString);
// Format in Central Time
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Chicago',
month: '2-digit',
day: '2-digit',
year: 'numeric'
});
return formatter.format(date);
} catch (error) {
console.error('Date format error:', error, dateString);
return '';
}
}
// Format datetime with time (MM/dd/yyyy h:mm am/pm)
function formatDateTime(dateString) {
if (!dateString || dateString === '1900-01-01T00:00:00.000Z' || dateString.startsWith('1900-01-01')) {
return '';
}
try {
const date = new Date(dateString);
const dateFormatter = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Chicago',
month: '2-digit',
day: '2-digit',
year: 'numeric'
});
const timeFormatter = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Chicago',
hour: 'numeric',
minute: '2-digit',
hour12: true
});
return `${dateFormatter.format(date)} ${timeFormatter.format(date)}`;
} catch (error) {
console.error('DateTime format error:', error, dateString);
return '';
}
}
// Calculate Due_Date_Counter based on .cursorrules logic
function calculateDueDateCounter(record) {
const jobStatus = record.Job_Status;
const dueDate = record.Due_Date;
const dueDateASAP = record.Due_Date_ASAP; // Will be undefined until schema updated
// 1. If Job_Status ≠ "Estimating" → "N/A"
if (jobStatus !== 'Estimating') {
return 'N/A';
}
// 2. If Due_Date_ASAP = true → "ASAP"
if (dueDateASAP === true) {
return 'ASAP';
}
// 3. If Due_Date is blank/sentinel → blank/empty
if (!dueDate || dueDate === '1900-01-01T00:00:00.000Z' || dueDate.startsWith('1900-01-01')) {
return '';
}
try {
const due = new Date(dueDate);
const today = new Date();
// Convert both to Central Time midnight for comparison
const dueMidnight = new Date(due.toLocaleString('en-US', { timeZone: 'America/Chicago' }));
dueMidnight.setHours(0, 0, 0, 0);
const todayMidnight = new Date(today.toLocaleString('en-US', { timeZone: 'America/Chicago' }));
todayMidnight.setHours(0, 0, 0, 0);
const diffTime = dueMidnight - todayMidnight;
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
// 4. If Due_Date < Today → "Passed due"
if (diffDays < 0) {
return 'Passed due';
}
// 5. If Due_Date = Today → "Due today"
if (diffDays === 0) {
return 'Due today';
}
// 6. Else → "[X] day" or "[X] days"
return `${diffDays} ${diffDays === 1 ? 'day' : 'days'}`;
} catch (error) {
console.error('Due date counter calculation error:', error);
return '';
}
}
// Format boolean values as prominent checkboxes (read-only)
function formatBoolean(value) {
return value ? '✓' : '';
}
// Format Active field as read-only checkbox with alert
function formatActiveCheckbox(value, recordId) {
return `<div class="checkbox-cell"><input type="checkbox" ${value ? 'checked' : ''} data-field="Active" data-readonly="true" data-record-id="${recordId}"></div>`;
}
// Format editable boolean as checkbox element
function formatEditableCheckbox(value, fieldName, recordId) {
return `<div class="checkbox-cell"><input type="checkbox" ${value ? 'checked' : ''} data-field="${fieldName}" data-record-id="${recordId}"></div>`;
}
// Per-column classes to keep layout/styling consistent when order changes
const columnClassMap = {
Job_Number: 'px-4 py-3 border-r border-gray-200 whitespace-nowrap font-medium',
Job_Full_Name: 'px-4 py-3 border-r border-gray-200 whitespace-nowrap',
Active: 'px-4 py-3 text-center',
Added_To_Calendar: 'px-4 py-3 text-center',
Docs_Uploaded: 'px-4 py-3 text-center',
Due_Date: 'px-4 py-3 whitespace-nowrap',
Estimate_Approved: 'px-4 py-3 text-center',
Estimate_Draft: 'px-4 py-3 text-center',
Estimate_Reviewed: 'px-4 py-3 text-center',
Flag: 'px-4 py-3 text-center',
Has_Attachments: 'px-4 py-3 text-center',
Notes: 'px-4 py-3 max-w-xs truncate',
PSwift_Uploaded: 'px-4 py-3 text-center',
QB_Created: 'px-4 py-3 text-center',
Start_Date: 'px-4 py-3 whitespace-nowrap',
Submission_Date: 'px-4 py-3 whitespace-nowrap',
Tax_Exempt: 'px-4 py-3 text-center',
Tax_Exempt_Docs_Uploaded: 'px-4 py-3 text-center',
Voxer_Created: 'px-4 py-3 text-center',
last_note_at: 'px-4 py-3 whitespace-nowrap',
latest_note_summary: 'px-4 py-3 max-w-xs truncate',
note_count: 'px-4 py-3 text-center',
note_thread_text: 'px-4 py-3 max-w-xs truncate'
};
// Render cell content for each supported column
function renderCellContent(columnName, record, value) {
switch (columnName) {
case 'Active':
return formatActiveCheckbox(record.Active, record.id);
case 'Added_To_Calendar':
return formatEditableCheckbox(record.Added_To_Calendar, 'Added_To_Calendar', record.id);
case 'Docs_Uploaded':
return formatEditableCheckbox(record.Docs_Uploaded, 'Docs_Uploaded', record.id);
case 'Due_Date':
return formatDate(record.Due_Date, record.Due_Date_Notes);
case 'Due_Date_Counter':
return calculateDueDateCounter(record);
case 'Estimate_Approved':
return formatEditableCheckbox(record.Estimate_Approved, 'Estimate_Approved', record.id);
case 'Estimate_Draft':
return formatEditableCheckbox(record.Estimate_Draft, 'Estimate_Draft', record.id);
case 'Estimate_Reviewed':
return formatEditableCheckbox(record.Estimate_Reviewed, 'Estimate_Reviewed', record.id);
case 'Flag':
return formatEditableCheckbox(record.Flag, 'Flag', record.id);
case 'Has_Attachments':
return formatEditableCheckbox(record.Has_Attachments, 'Has_Attachments', record.id);
case 'Job_Folder_Link':
return value ? `<a href="${value}" target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:text-blue-800 underline">${value}</a>` : '';
case 'Notes':
return record.Notes || '';
case 'PSwift_Uploaded':
return formatEditableCheckbox(record.PSwift_Uploaded, 'PSwift_Uploaded', record.id);
case 'QB_Created':
return formatEditableCheckbox(record.QB_Created, 'QB_Created', record.id);
case 'Start_Date':
return formatDate(record.Start_Date, record.Start_Date_Notes);
case 'Submission_Date':
return formatDate(record.Submission_Date);
case 'Tax_Exempt':
return formatEditableCheckbox(record.Tax_Exempt, 'Tax_Exempt', record.id);
case 'Tax_Exempt_Docs_Uploaded':
return formatEditableCheckbox(record.Tax_Exempt_Docs_Uploaded, 'Tax_Exempt_Docs_Uploaded', record.id);
case 'Voxer_Created':
return formatEditableCheckbox(record.Voxer_Created, 'Voxer_Created', record.id);
case 'last_note_at':
return formatDateTime(record.last_note_at);
default:
return value || '';
}
}
// Title attributes for tooltips on truncated cells
function getCellTitle(columnName, record) {
// Check if column has hover tooltip enabled
const colSettings = getColumnSettings(columnName);
if (colSettings && colSettings.showFullTextOnHover) {
const value = record[columnName];
if (value !== null && value !== undefined) {
return String(value);
}
}
// Default behavior for specific columns
switch (columnName) {
case 'Notes':
return record.Notes || '';
case 'latest_note_summary':
return record.latest_note_summary || '';
case 'note_thread_text':
return record.note_thread_text || '';
default:
return '';
}
}
// Load table data from Redis cache (via API endpoint)
async function loadTableData() {
try {
if (isLoadingTableData) {
console.log('Table data already loading, skipping');
return;
}
isLoadingTableData = true;
loadingIndicator.classList.remove('hidden');
// Fetch from cached API endpoint
const response = await fetch('/api/jobs', {
headers: {
'X-PocketBase-Token': pb.authStore.token
}
});
if (!response.ok) {
throw new Error('Failed to fetch jobs from API');
}
const result = await response.json();
if (!result.success) {
throw new Error(result.message || 'Failed to fetch jobs');
}
allRecords = result.data; // Store all records for filtering
// Sort by Job_Number descending (highest to lowest) by default
allRecords.sort((a, b) => {
const numA = parseInt(a.Job_Number) || 0;
const numB = parseInt(b.Job_Number) || 0;
return numB - numA; // Descending order
});
console.log(`Loaded ${allRecords.length} jobs ${result.cached ? 'from cache' : 'from PocketBase'}`);
console.log('First record:', allRecords[0]);
// Render with current search filter
renderTableRows();
console.log('Render complete');
isLoadingTableData = false;
loadingIndicator.classList.add('hidden');
} catch (error) {
console.error('Error loading table data:', error);
isLoadingTableData = false;
loadingIndicator.classList.add('hidden');
alert('Error loading data. Please refresh the page.');
}
}
// Render table rows with optional filtering
function renderTableRows() {
if (!tableBody) {
console.error('tableBody element not found');
return;
}
if (!allRecords || allRecords.length === 0) {
console.log('No records to render');
tableBody.innerHTML = '<tr><td colspan="50" class="px-4 py-8 text-center text-gray-500">No data available</td></tr>';
return;
}
tableBody.innerHTML = '';
const visibleColumns = getVisibleColumns();
// Filter records based on search term and column filters
let filteredRecords = filterRecords(allRecords, currentSearchTerm);
// Apply sorting
if (currentSort.column) {
filteredRecords = filteredRecords.slice().sort((a, b) => {
let valA = a[currentSort.column];
let valB = b[currentSort.column];
// Handle null/undefined values
if (valA === null || valA === undefined) valA = '';
if (valB === null || valB === undefined) valB = '';
// Handle numeric values
if (!isNaN(valA) && !isNaN(valB)) {
valA = parseFloat(valA) || 0;
valB = parseFloat(valB) || 0;
} else {
// Convert to string for comparison
valA = String(valA).toLowerCase();
valB = String(valB).toLowerCase();
}
if (valA < valB) return currentSort.direction === 'asc' ? -1 : 1;
if (valA > valB) return currentSort.direction === 'asc' ? 1 : -1;
return 0;
});
}
// Update search result count
const searchResultCount = document.getElementById('searchResultCount');
if (searchResultCount) {
if (currentSearchTerm.trim()) {
searchResultCount.textContent = `${filteredRecords.length} of ${allRecords.length}`;
} else {
searchResultCount.textContent = '';
}
}
if (filteredRecords.length === 0) {
tableBody.innerHTML = '<tr><td colspan="50" class="px-4 py-8 text-center text-gray-500">No matching results</td></tr>';
return;
}
filteredRecords.forEach((record, recordIndex) => {
const row = document.createElement('tr');
row.className = 'border-b border-gray-200';
const rowIndex = Array.from(tableBody.children).length;
const cellsHtml = visibleColumns.map((col, idx) => {
const columnName = col.name;
const value = record[columnName];
const titleValue = getCellTitle(columnName, record);
const className = columnClassMap[columnName] || 'px-4 py-3';
let style = '';
const colSettings = getColumnSettings(columnName);
// Apply choice-based styling
if (colSettings && value && colSettings.choices && colSettings.choices.length > 0) {
const choice = colSettings.choices.find(c => c.value === value);
if (choice) {
const bgColor = rowIndex % 2 === 0 ? choice.bgColor : choice.bgColorAlt;
style += `background-color: ${bgColor} !important;`;
if (choice.fontStyle === 'bold') {
style += 'font-weight: bold;';
} else if (choice.fontStyle === 'italic') {
style += 'font-style: italic;';
}
}
}
// Apply boolean-based styling for checkbox fields
else if (colSettings && (value === true || value === false)) {
const bgColor = rowIndex % 2 === 0
? (value ? (colSettings.trueBgColor || '#d1fae5') : (colSettings.falseBgColor || '#fee2e2'))
: (value ? (colSettings.trueBgColorAlt || '#a7f3d0') : (colSettings.falseBgColorAlt || '#fecaca'));
style += `background-color: ${bgColor} !important;`;
}
const styleAttr = style ? `style="${style}"` : '';
const titleAttr = titleValue ? ` title="${titleValue}"` : '';
const content = renderCellContent(columnName, record, value);
// Make cell editable if appropriate
const isEditable = isFieldEditable(columnName, colSettings);
// For date fields, normalize the value to YYYY-MM-DD format for the date input
let dataValue = (value || '').toString().replace(/"/g, '&quot;');
if (isEditable && ['Due_Date', 'Start_Date', 'Submission_Date'].includes(columnName) && value) {
// Convert ISO date to YYYY-MM-DD
try {
const dateObj = new Date(value);
if (!isNaN(dateObj.getTime())) {
const year = dateObj.getFullYear();
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
const day = String(dateObj.getDate()).padStart(2, '0');
dataValue = `${year}-${month}-${day}`;
}
} catch (e) {
// Keep original value if parsing fails
}
}
const editableAttr = isEditable ? ` data-editable="true" data-record-id="${record.id}" data-field="${columnName}" data-value="${dataValue}" style="cursor: pointer; ${style}"` : styleAttr;
return `<td class="${className}" ${editableAttr}${titleAttr}>${content}</td>`;
}).join('');
row.innerHTML = cellsHtml;
tableBody.appendChild(row);
// Focus first Job_Number cell on initial load (first row only)
if (recordIndex === 0 && currentSearchTerm === '') {
setTimeout(() => {
const firstJobNumberCell = tableBody.querySelector('tr:first-child td:first-child');
if (firstJobNumberCell) {
firstJobNumberCell.setAttribute('tabindex', '0');
firstJobNumberCell.focus();
}
}, 100);
}
});
}
// Filter records based on search term
function filterRecords(records, searchTerm) {
let filtered = records;
// Apply view filter first
if (currentViewFilter) {
filtered = filtered.filter(record => {
return currentViewFilter(record);
});
}
// Apply column-specific filters
if (Object.keys(columnFilters).length > 0) {
filtered = filtered.filter(record => {
return Object.entries(columnFilters).every(([column, filterTerm]) => {
const value = record[column];
if (!value && value !== false && value !== 0) return false;
return String(value).toLowerCase().includes(filterTerm.toLowerCase());
});
});
}
// Then apply global search term filter
if (!searchTerm || !searchTerm.trim()) {
return filtered;
}
const term = searchTerm.toLowerCase().trim();
const searchFields = [
'Job_Number',
'Job_Full_Name',
'Job_Address',
'Estimator',
'Office_Rep',
'Project_Manager',
'Company_Client',
'Contact_Person',
'Notes',
'latest_note_summary',
'note_thread_text'
];
return filtered.filter(record => {
return searchFields.some(field => {
const value = record[field];
if (!value) return false;
return String(value).toLowerCase().includes(term);
});
});
}
// Determine if a field is editable
function isFieldEditable(columnName, colSettings) {
// Checkboxes are already handled by existing checkbox handler
const checkboxFields = ['Active', 'Added_To_Calendar', 'Docs_Uploaded', 'Estimate_Approved',
'Estimate_Draft', 'Estimate_Reviewed', 'Flag', 'Has_Attachments',
'PSwift_Uploaded', 'QB_Created', 'Tax_Exempt', 'Tax_Exempt_Docs_Uploaded',
'Voxer_Created'];
if (checkboxFields.includes(columnName)) return false;
// Don't allow editing calculated/display fields
const nonEditableFields = ['Job_Number', 'Job_Full_Name', 'Active', 'Due_Date_Counter',
'last_note_at', 'note_count', 'latest_note_summary', 'note_thread_text'];
if (nonEditableFields.includes(columnName)) return false;
// Allow editing for fields with choices or text fields
const editableFields = ['Job_Type', 'Job_Status', 'Job_Division', 'Estimator', 'Office_Rep',
'Project_Manager', 'Due_Date', 'Start_Date', 'Submission_Date',
'Notes', 'Job_Name', 'Job_Address', 'Company_Client', 'Contact_Person',
'Phone_Number', 'Due_Date_Notes', 'Start_Date_Notes'];
return editableFields.includes(columnName);
}
// Normalize date input to YYYY-MM-DD format
function normalizeDate(input) {
if (!input || input.trim() === '') return null;
try {
// Try parsing the input
const date = new Date(input);
if (isNaN(date.getTime())) return null;
// Format as YYYY-MM-DD
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
} catch (e) {
return null;
}
}
// Create inline confirmation dialog
function createConfirmDialog(message, onConfirm, onCancel, anchorElement) {
const dialog = document.createElement('div');
dialog.className = 'absolute bg-white border-2 border-blue-500 rounded shadow-lg p-3 z-[2000]';
dialog.style.minWidth = '200px';
const messageDiv = document.createElement('div');
messageDiv.className = 'text-sm mb-3 text-gray-800';
messageDiv.textContent = message;
const buttonContainer = document.createElement('div');
buttonContainer.className = 'flex gap-2 justify-end';
const confirmBtn = document.createElement('button');
confirmBtn.textContent = 'Confirm';
confirmBtn.className = 'px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm';
confirmBtn.onclick = () => {
dialog.remove();
onConfirm();
};
const cancelBtn = document.createElement('button');
cancelBtn.textContent = 'Cancel';
cancelBtn.className = 'px-3 py-1 bg-gray-300 text-gray-700 rounded hover:bg-gray-400 text-sm';
cancelBtn.onclick = () => {
dialog.remove();
onCancel();
};
buttonContainer.appendChild(cancelBtn);
buttonContainer.appendChild(confirmBtn);
dialog.appendChild(messageDiv);
dialog.appendChild(buttonContainer);
// Position near the anchor element
document.body.appendChild(dialog);
const rect = anchorElement.getBoundingClientRect();
dialog.style.left = rect.left + 'px';
dialog.style.top = (rect.bottom + 5) + 'px';
// Focus confirm button
confirmBtn.focus();
return dialog;
}
// Handle cell click for inline editing
document.addEventListener('click', async (e) => {
const cell = e.target.closest('td[data-editable="true"]');
if (!cell) return;
// Don't trigger if clicking on a checkbox
if (e.target.type === 'checkbox') return;
const fieldName = cell.dataset.field;
const recordId = cell.dataset.recordId;
const currentValue = cell.dataset.value;
const colSettings = getColumnSettings(fieldName);
// Handle fields with choice dropdowns
if (colSettings && colSettings.choices && colSettings.choices.length > 0) {
await handleDropdownEdit(cell, fieldName, recordId, currentValue, colSettings);
}
// Handle date fields
else if (['Due_Date', 'Start_Date', 'Submission_Date'].includes(fieldName)) {
await handleDateEdit(cell, fieldName, recordId, currentValue);
}
// Handle text fields
else {
await handleTextEdit(cell, fieldName, recordId, currentValue);
}
});
// Handle dropdown field editing
async function handleDropdownEdit(cell, fieldName, recordId, currentValue, colSettings) {
const choices = colSettings.choices.map(c => c.value);
// Create expanded list of choices
const choiceContainer = document.createElement('div');
choiceContainer.className = 'absolute bg-white border border-gray-300 rounded shadow-lg z-[1000] max-h-64 overflow-y-auto';
choiceContainer.style.minWidth = '150px';
// Add empty option
const emptyOption = document.createElement('div');
emptyOption.className = 'px-3 py-2 hover:bg-blue-100 cursor-pointer text-sm ' + (currentValue === '' || !currentValue ? 'bg-blue-50 font-semibold' : '');
emptyOption.textContent = '(None)';
emptyOption.dataset.value = '';
choiceContainer.appendChild(emptyOption);
// Add all choices
choices.forEach(choice => {
const option = document.createElement('div');
option.className = 'px-3 py-2 hover:bg-blue-100 cursor-pointer text-sm ' + (choice === currentValue ? 'bg-blue-50 font-semibold' : '');
option.textContent = choice;
option.dataset.value = choice;
choiceContainer.appendChild(option);
});
// Position near the cell
document.body.appendChild(choiceContainer);
const rect = cell.getBoundingClientRect();
choiceContainer.style.left = rect.left + 'px';
choiceContainer.style.top = (rect.bottom + 2) + 'px';
// Handle choice selection
const handleSelection = (newValue) => {
choiceContainer.remove();
if (newValue !== currentValue) {
createConfirmDialog(
`Change ${fieldName} from "${currentValue || '(empty)'}" to "${newValue || '(empty)'}"?`,
async () => {
await updateField(recordId, fieldName, newValue || null);
},
() => {
// Cancelled - do nothing
},
cell
);
}
};
// Add click handlers to all options
choiceContainer.querySelectorAll('div').forEach(option => {
option.addEventListener('click', () => {
handleSelection(option.dataset.value);
});
});
// Close on outside click
const closeOnOutsideClick = (e) => {
if (!choiceContainer.contains(e.target) && !cell.contains(e.target)) {
choiceContainer.remove();
document.removeEventListener('click', closeOnOutsideClick);
}
};
setTimeout(() => {
document.addEventListener('click', closeOnOutsideClick);
}, 0);
// Close on escape
const handleEscape = (e) => {
if (e.key === 'Escape') {
choiceContainer.remove();
document.removeEventListener('keydown', handleEscape);
}
};
document.addEventListener('keydown', handleEscape);
}
// Handle date field editing
async function handleDateEdit(cell, fieldName, recordId, currentValue) {
const input = document.createElement('input');
input.type = 'date';
input.value = currentValue || '';
input.className = 'w-full px-2 py-1 border border-gray-300 rounded focus:outline-none focus:border-gray-400';
input.style.position = 'relative';
input.style.zIndex = '1000';
const originalHTML = cell.innerHTML;
const originalPosition = cell.style.position;
cell.style.position = 'relative';
cell.innerHTML = '';
cell.appendChild(input);
input.focus();
input.showPicker?.(); // Show date picker if supported
const finishEdit = async () => {
const newValue = input.value.trim();
cell.innerHTML = originalHTML;
cell.style.position = originalPosition;
if (newValue !== currentValue) {
const displayDate = newValue ? new Date(newValue + 'T00:00:00').toLocaleDateString() : '(empty)';
createConfirmDialog(
`Change ${fieldName} to ${displayDate}?`,
async () => {
await updateField(recordId, fieldName, newValue || null);
},
() => {
// Cancelled - do nothing
},
cell
);
}
};
input.addEventListener('blur', finishEdit);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
finishEdit();
} else if (e.key === 'Escape') {
cell.innerHTML = originalHTML;
cell.style.position = originalPosition;
}
});
}
// Handle text field editing
async function handleTextEdit(cell, fieldName, recordId, currentValue) {
const isMultiline = fieldName === 'Notes';
const input = document.createElement(isMultiline ? 'textarea' : 'input');
if (!isMultiline) {
input.type = 'text';
} else {
input.rows = 3;
}
input.value = currentValue || '';
input.className = 'w-full px-2 py-1 border border-gray-300 rounded focus:outline-none focus:border-gray-400';
input.style.position = 'relative';
input.style.zIndex = '1000';
const originalHTML = cell.innerHTML;
const originalPosition = cell.style.position;
cell.style.position = 'relative';
cell.innerHTML = '';
cell.appendChild(input);
input.focus();
input.select();
const finishEdit = async () => {
const newValue = input.value.trim();
cell.innerHTML = originalHTML;
cell.style.position = originalPosition;
if (newValue !== currentValue) {
const preview = newValue.length > 50 ? newValue.substring(0, 50) + '...' : newValue;
createConfirmDialog(
`Change ${fieldName} to "${preview || '(empty)'}"?`,
async () => {
await updateField(recordId, fieldName, newValue || null);
},
() => {
// Cancelled - do nothing
},
cell
);
}
};
input.addEventListener('blur', finishEdit);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !isMultiline) {
e.preventDefault();
finishEdit();
} else if (e.key === 'Escape') {
cell.innerHTML = originalHTML;
cell.style.position = originalPosition;
}
});
}
// Update field in PocketBase
async function updateField(recordId, fieldName, value) {
try {
await pb.collection('Job_Info_TestEnv').update(recordId, {
[fieldName]: value
});
// Reload table data to reflect changes
await loadTableData();
} catch (error) {
console.error('Error updating field:', error);
alert('Failed to update field. Please try again.');
}
}
// Setup search input listener
function setupSearchListener() {
const searchInput = document.getElementById('searchInput');
if (!searchInput) return;
let searchTimeout;
searchInput.addEventListener('input', (e) => {
// Clear existing timeout
if (searchTimeout) {
clearTimeout(searchTimeout);
}
// Debounce search to avoid re-rendering on every keystroke
searchTimeout = setTimeout(() => {
currentSearchTerm = e.target.value;
renderTableRows();
}, 150); // 150ms debounce - feels instant but avoids excessive renders
});
}
// Handle checkbox changes
document.addEventListener('change', async (e) => {
if (e.target.type === 'checkbox' && e.target.dataset.field) {
const checkbox = e.target;
const fieldName = checkbox.dataset.field;
const recordId = checkbox.dataset.recordId;
const isReadOnly = checkbox.dataset.readonly === 'true';
// Handle read-only Active field
if (isReadOnly && fieldName === 'Active') {
// Revert the checkbox state
checkbox.checked = !checkbox.checked;
createConfirmDialog(
'Active status is calculated automatically by selected Job Status values.',
() => {},
() => {},
checkbox.closest('td')
);
return;
}
// Update PocketBase for editable fields
const newValue = checkbox.checked;
const action = newValue ? 'check' : 'uncheck';
// Create confirmation dialog
createConfirmDialog(
`${action.charAt(0).toUpperCase() + action.slice(1)} ${fieldName}?`,
async () => {
// Confirmed - update field
try {
await pb.collection('Job_Info_TestEnv').update(recordId, {
[fieldName]: newValue
});
// Special reminder for Tax Exempt
if (fieldName === 'Tax_Exempt' && newValue) {
setTimeout(() => {
createConfirmDialog(
'Reminder: Please upload tax exempt documents.',
() => {},
() => {},
checkbox.closest('td')
);
}, 100);
}
} catch (error) {
console.error('Error updating field:', error);
checkbox.checked = !checkbox.checked;
createConfirmDialog(
'Failed to update field. Please try again.',
() => {},
() => {},
checkbox.closest('td')
);
}
},
() => {
// Cancelled - revert checkbox
checkbox.checked = !checkbox.checked;
},
checkbox.closest('td')
);
}
});
// 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 {
if (appSettingsUnsubscribe) {
appSettingsUnsubscribe();
appSettingsUnsubscribe = null;
}
} catch (err) {
console.warn('Error while unsubscribing settings watch', err);
}
});
</script>
</body>
</html>