ad4a4fdf4b
- Add hover tooltip column setting for truncated text - Update Tax_Exempt_Docs_Uploaded label to 'Tax Exempt Uploads?'
1631 lines
62 KiB
HTML
1631 lines
62 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>
|
|
|
|
<!-- 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-alpha5</span>
|
|
</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 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 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';
|
|
return `<th class="px-4 py-3 text-${alignment} font-semibold ${wrapClass}">${prettyLabel(col.name)}</th>`;
|
|
}).join('');
|
|
}
|
|
|
|
// 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 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 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();
|
|
}
|
|
|
|
// 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
|
|
const filteredRecords = filterRecords(allRecords, currentSearchTerm);
|
|
|
|
// 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, '"');
|
|
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) {
|
|
if (!searchTerm || !searchTerm.trim()) {
|
|
return records;
|
|
}
|
|
|
|
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 records.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();
|
|
|
|
// 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>
|