Files
Job-Info/Mode2Svelte/src/routes/+page.svelte
T

1538 lines
60 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { PocketBaseAuth } from '$lib/auth/frontend';
import type { AuthState } from '$lib/auth/types';
interface JobRecord {
id: string;
Job_Number: string;
Job_Name: string;
Job_Full_Name: string;
Company_Client: string;
Contact_Person: string;
Job_Division: string;
Active: boolean | string;
Estimator: string;
Job_Status: string;
Notes?: string;
Job_Folder_Link?: string;
[key: string]: any;
}
let auth: PocketBaseAuth | undefined;
let authState: AuthState = {
isAuthenticated: false,
user: null,
token: null,
};
let jobsCache: JobRecord[] = [];
let visibleJobs: JobRecord[] = [];
let loading = false;
let error = '';
let selectedJob: JobRecord | null = null;
let showDetailModal = false;
let showNotesTab = false;
let showFolderContents = false;
let folderSearchFilter = '';
// File categorization keywords (from Mode6Test)
const categoryKeywords = {
managerInfo: ['manager info', 'manager_info', 'managerinfo'],
contracts: ['contract', 'estimate', 'proposal', 'bid', 'award', 'agreement', 'sow'],
plans: ['plan', 'drawing', 'dwg', 'pdf', 'sheet', 'layout'],
submittals: ['submittal', 'submittals', 'submit'],
};
const categorizeFile = (name: string): 'managerInfo' | 'contracts' | 'plans' | 'submittals' | 'other' => {
const n = name.toLowerCase();
if (categoryKeywords.managerInfo.some(w => n.includes(w))) return 'managerInfo';
if (categoryKeywords.contracts.some(w => n.includes(w))) return 'contracts';
if (categoryKeywords.submittals.some(w => n.includes(w))) return 'submittals';
if (categoryKeywords.plans.some(w => n.includes(w))) return 'plans';
return 'other';
};
const getFileIcon = (name: string): string => {
const n = name.toLowerCase();
if (n.endsWith('.pdf')) return 'pdf';
if (n.endsWith('.doc') || n.endsWith('.docx')) return 'doc';
if (n.endsWith('.xls') || n.endsWith('.xlsx')) return 'sheet';
if (n.endsWith('.ppt') || n.endsWith('.pptx')) return 'presentation';
if (n.match(/\.(jpg|jpeg|png|gif|bmp|tiff|webp)$/i)) return 'image';
return 'file';
};
const getSvgIcon = (iconType: string): string => {
const icons: {[key: string]: string} = {
pdf: `<svg viewBox="0 0 24 24" fill="none" style="width:16px;height:16px;"><rect fill="#D32F2F" width="24" height="24" rx="2"/><text x="12" y="16" font-size="10" font-weight="bold" fill="white" text-anchor="middle">PDF</text></svg>`,
doc: `<svg viewBox="0 0 24 24" fill="none" style="width:16px;height:16px;"><rect fill="#2B579A" width="24" height="24" rx="2"/><text x="12" y="16" font-size="10" font-weight="bold" fill="white" text-anchor="middle">W</text></svg>`,
sheet: `<svg viewBox="0 0 24 24" fill="none" style="width:16px;height:16px;"><rect fill="#217346" width="24" height="24" rx="2"/><text x="12" y="16" font-size="10" font-weight="bold" fill="white" text-anchor="middle">X</text></svg>`,
presentation: `<svg viewBox="0 0 24 24" fill="none" style="width:16px;height:16px;"><rect fill="#D35230" width="24" height="24" rx="2"/><text x="12" y="16" font-size="10" font-weight="bold" fill="white" text-anchor="middle">P</text></svg>`,
image: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:16px;height:16px;color:#6b7280;"><rect x="2" y="2" width="20" height="20" rx="2"/><circle cx="8" cy="8" r="2"/><path d="M21 15l-5-5L5 21"/></svg>`,
zip: `<svg viewBox="0 0 24 24" fill="currentColor" style="width:16px;height:16px;color:#6b7280;"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zm-5-9h-3v4h3v-4zm0-3h-3v2h3V7z"/></svg>`,
text: `<svg viewBox="0 0 24 24" fill="currentColor" style="width:16px;height:16px;color:#6b7280;"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"/><path d="M16 13H8M16 17H8M10 9H8"/></svg>`,
file: `<svg viewBox="0 0 24 24" fill="currentColor" style="width:16px;height:16px;color:#6b7280;"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"/></svg>`,
};
return icons[iconType] || icons.file;
};
const formatFileSize = (bytes: number | undefined): string => {
if (!bytes) return '—';
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}KB`;
return `${Math.round(bytes / (1024 * 1024))}MB`;
};
const formatDate = (dateStr: string | undefined): string => {
if (!dateStr) return '';
try {
return new Date(dateStr).toLocaleDateString();
} catch {
return '';
}
};
const canPreviewInline = (name: string, contentType: string = ''): boolean => {
const ext = (name || '').toLowerCase().split('.').pop();
const type = (contentType || '').toLowerCase();
return ext === 'pdf' || type.includes('pdf') ||
['doc', 'docx'].includes(ext as string) || type.includes('word') ||
['jpg', 'jpeg', 'png', 'gif', 'svg', 'bmp'].includes(ext as string) || type.includes('image');
};
const renderPdfPage = async () => {
if (!currentPdf || !currentPdf.doc) return;
const pageObj = await currentPdf.doc.getPage(currentPdf.page);
const viewport = pageObj.getViewport({ scale: currentPdf.scale });
const canvas = document.getElementById('pdfCanvas') as HTMLCanvasElement;
if (!canvas) return;
const context = canvas.getContext('2d');
if (!context) return;
canvas.height = viewport.height;
canvas.width = viewport.width;
await pageObj.render({
canvasContext: context,
viewport: viewport,
}).promise;
};
const renderImageOnCanvas = () => {
if (!currentPdf?.imageElement) return;
const canvas = document.getElementById('pdfCanvas') as HTMLCanvasElement;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const scale = currentPdf.scale;
canvas.width = (currentPdf.imageWidth || 0) * scale;
canvas.height = (currentPdf.imageHeight || 0) * scale;
ctx.drawImage(currentPdf.imageElement, 0, 0, canvas.width, canvas.height);
};
const updateZoomLabel = () => {
const label = document.getElementById('pdfZoomLabel');
if (label && currentPdf) {
label.textContent = `${Math.round(currentPdf.scale * 100)}%`;
}
};
const setupCanvasEventHandlers = () => {
const canvas = document.getElementById('pdfCanvas') as HTMLCanvasElement;
if (!canvas) return;
// Zoom buttons
const zoomOutBtn = document.getElementById('pdfZoomOut');
const zoomResetBtn = document.getElementById('pdfZoomReset');
const zoomInBtn = document.getElementById('pdfZoomIn');
if (zoomOutBtn) {
zoomOutBtn.addEventListener('click', async () => {
if (!currentPdf) return;
currentPdf.scale = Math.max(MIN_PDF_SCALE, currentPdf.scale - 0.1);
pdfPan = { x: 0, y: 0, dragging: false, startX: 0, startY: 0, baseX: 0, baseY: 0 };
canvas.style.transform = 'translate(0px, 0px)';
if (currentPdf.isImage) {
renderImageOnCanvas();
} else {
await renderPdfPage();
}
updateZoomLabel();
});
}
if (zoomResetBtn) {
zoomResetBtn.addEventListener('click', async () => {
if (!currentPdf) return;
currentPdf.scale = DEFAULT_PDF_SCALE;
pdfPan = { x: 0, y: 0, dragging: false, startX: 0, startY: 0, baseX: 0, baseY: 0 };
canvas.style.transform = 'translate(0px, 0px)';
if (currentPdf.isImage) {
renderImageOnCanvas();
} else {
await renderPdfPage();
}
updateZoomLabel();
});
}
if (zoomInBtn) {
zoomInBtn.addEventListener('click', async () => {
if (!currentPdf) return;
currentPdf.scale = Math.min(MAX_PDF_SCALE, currentPdf.scale + 0.1);
pdfPan = { x: 0, y: 0, dragging: false, startX: 0, startY: 0, baseX: 0, baseY: 0 };
canvas.style.transform = 'translate(0px, 0px)';
if (currentPdf.isImage) {
renderImageOnCanvas();
} else {
await renderPdfPage();
}
updateZoomLabel();
});
}
// Page navigation buttons (if multi-page)
const prevBtn = document.getElementById('pdfPrev');
const nextBtn = document.getElementById('pdfNext');
if (prevBtn) {
prevBtn.addEventListener('click', async () => {
if (!currentPdf || currentPdf.page <= 1) return;
currentPdf.page--;
pdfPan = { x: 0, y: 0, dragging: false, startX: 0, startY: 0, baseX: 0, baseY: 0 };
canvas.style.transform = 'translate(0px, 0px)';
await renderPdfPage();
document.getElementById('pdfPageNum')!.textContent = String(currentPdf.page);
});
}
if (nextBtn) {
nextBtn.addEventListener('click', async () => {
if (!currentPdf || currentPdf.page >= currentPdf.pages) return;
currentPdf.page++;
pdfPan = { x: 0, y: 0, dragging: false, startX: 0, startY: 0, baseX: 0, baseY: 0 };
canvas.style.transform = 'translate(0px, 0px)';
await renderPdfPage();
document.getElementById('pdfPageNum')!.textContent = String(currentPdf.page);
});
}
// Canvas pan (mouse drag)
canvas.addEventListener('mousedown', (e) => {
pdfPan.dragging = true;
pdfPan.startX = e.clientX;
pdfPan.startY = e.clientY;
canvas.style.cursor = 'grabbing';
});
canvas.addEventListener('mousemove', (e) => {
if (!pdfPan.dragging) return;
const dx = e.clientX - pdfPan.startX;
const dy = e.clientY - pdfPan.startY;
pdfPan.x = pdfPan.baseX + dx;
pdfPan.y = pdfPan.baseY + dy;
canvas.style.transform = `translate(${pdfPan.x}px, ${pdfPan.y}px)`;
});
canvas.addEventListener('mouseup', () => {
if (pdfPan.dragging) {
pdfPan.baseX = pdfPan.x;
pdfPan.baseY = pdfPan.y;
pdfPan.dragging = false;
canvas.style.cursor = 'grab';
}
});
canvas.addEventListener('mouseleave', () => {
if (pdfPan.dragging) {
pdfPan.baseX = pdfPan.x;
pdfPan.baseY = pdfPan.y;
pdfPan.dragging = false;
canvas.style.cursor = 'grab';
}
});
// Pinch zoom (touch)
let lastDistance = 0;
canvas.addEventListener('touchstart', (e) => {
if (e.touches.length === 2) {
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
lastDistance = Math.sqrt(dx * dx + dy * dy);
} else if (e.touches.length === 1) {
pdfPan.dragging = true;
pdfPan.startX = e.touches[0].clientX;
pdfPan.startY = e.touches[0].clientY;
}
});
canvas.addEventListener('touchmove', async (e) => {
if (e.touches.length === 2) {
e.preventDefault();
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (lastDistance > 0) {
const scaleDelta = (distance - lastDistance) / 100;
const newScale = currentPdf ? currentPdf.scale + scaleDelta : DEFAULT_PDF_SCALE;
if (currentPdf) {
currentPdf.scale = Math.max(MIN_PDF_SCALE, Math.min(MAX_PDF_SCALE, newScale));
if (currentPdf.isImage) {
renderImageOnCanvas();
} else {
await renderPdfPage();
}
updateZoomLabel();
}
}
lastDistance = distance;
} else if (e.touches.length === 1 && pdfPan.dragging) {
const dx = e.touches[0].clientX - pdfPan.startX;
const dy = e.touches[0].clientY - pdfPan.startY;
pdfPan.x = pdfPan.baseX + dx;
pdfPan.y = pdfPan.baseY + dy;
const canvas = document.getElementById('pdfCanvas') as HTMLCanvasElement;
if (canvas) {
canvas.style.transform = `translate(${pdfPan.x}px, ${pdfPan.y}px)`;
}
}
});
canvas.addEventListener('touchend', () => {
pdfPan.baseX = pdfPan.x;
pdfPan.baseY = pdfPan.y;
pdfPan.dragging = false;
lastDistance = 0;
});
// Wheel zoom (mouse scroll)
canvas.addEventListener('wheel', async (e) => {
e.preventDefault();
if (!currentPdf) return;
const zoomDelta = e.deltaY > 0 ? -0.1 : 0.1;
currentPdf.scale = Math.max(MIN_PDF_SCALE, Math.min(MAX_PDF_SCALE, currentPdf.scale + zoomDelta));
if (currentPdf.isImage) {
renderImageOnCanvas();
} else {
await renderPdfPage();
}
updateZoomLabel();
}, { passive: false });
};
// Setup canvas event handlers for zoom, pan, and pinch
const setupPdfControls = () => {
const zoomInBtn = document.getElementById('pdfZoomInBtn') as HTMLButtonElement;
const zoomOutBtn = document.getElementById('pdfZoomOutBtn') as HTMLButtonElement;
const zoomResetBtn = document.getElementById('pdfZoomResetBtn') as HTMLButtonElement;
const canvas = document.getElementById('pdfCanvas') as HTMLCanvasElement;
if (!canvas) return;
const applyPan = () => {
canvas.style.transform = `translate(${pdfPan.x}px, ${pdfPan.y}px)`;
};
const activePointers = new Map<number, {x: number; y: number}>();
let pinchStartDistance: number | null = null;
let pinchStartScale: number | null = null;
const distanceBetween = () => {
if (activePointers.size < 2) return null;
const pts = Array.from(activePointers.values());
const a = pts[0];
const b = pts[1];
const dx = a.x - b.x;
const dy = a.y - b.y;
return Math.hypot(dx, dy);
};
// Zoom button handlers
if (zoomInBtn) {
zoomInBtn.addEventListener('click', async () => {
if (!currentPdf) return;
currentPdf.scale = Math.min(currentPdf.scale + 0.1, MAX_PDF_SCALE);
if (currentPdf.isImage) {
renderImageOnCanvas();
} else {
await renderPdfPage();
}
});
}
if (zoomOutBtn) {
zoomOutBtn.addEventListener('click', async () => {
if (!currentPdf) return;
currentPdf.scale = Math.max(currentPdf.scale - 0.1, MIN_PDF_SCALE);
if (currentPdf.isImage) {
renderImageOnCanvas();
} else {
await renderPdfPage();
}
});
}
if (zoomResetBtn) {
zoomResetBtn.addEventListener('click', async () => {
if (!currentPdf) return;
currentPdf.scale = currentPdf.isImage ? 1 : DEFAULT_PDF_SCALE;
pdfPan = { x: 0, y: 0, dragging: false, startX: 0, startY: 0, baseX: 0, baseY: 0 };
canvas.style.transform = 'translate(0px, 0px)';
if (currentPdf.isImage) {
renderImageOnCanvas();
} else {
await renderPdfPage();
}
});
}
// Canvas event handlers
canvas.style.touchAction = 'none';
canvas.addEventListener('wheel', async (e) => {
if (!currentPdf) return;
e.preventDefault();
const delta = e.deltaY;
const step = 0.1;
if (delta < 0) {
currentPdf.scale = Math.min(currentPdf.scale + step, MAX_PDF_SCALE);
} else {
currentPdf.scale = Math.max(currentPdf.scale - step, MIN_PDF_SCALE);
}
if (currentPdf.isImage) {
renderImageOnCanvas();
} else {
await renderPdfPage();
}
}, { passive: false });
canvas.addEventListener('pointerdown', (e) => {
if (!currentPdf) return;
activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (activePointers.size === 2) {
pinchStartDistance = distanceBetween();
pinchStartScale = currentPdf.scale;
pdfPan.dragging = false;
} else if (activePointers.size === 1) {
pdfPan.dragging = true;
pdfPan.startX = e.clientX;
pdfPan.startY = e.clientY;
pdfPan.baseX = pdfPan.x;
pdfPan.baseY = pdfPan.y;
canvas.style.cursor = 'grabbing';
}
canvas.setPointerCapture(e.pointerId);
});
canvas.addEventListener('pointermove', (e) => {
if (!currentPdf) return;
if (activePointers.has(e.pointerId)) {
activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
}
if (activePointers.size >= 2 && pinchStartDistance) {
const dist = distanceBetween();
if (dist) {
const scale = pinchStartScale! * (dist / pinchStartDistance);
currentPdf.scale = Math.min(Math.max(scale, MIN_PDF_SCALE), MAX_PDF_SCALE);
if (currentPdf.isImage) {
renderImageOnCanvas();
} else {
renderPdfPage();
}
}
return;
}
if (!pdfPan.dragging) return;
const dx = e.clientX - pdfPan.startX;
const dy = e.clientY - pdfPan.startY;
pdfPan.x = pdfPan.baseX + dx;
pdfPan.y = pdfPan.baseY + dy;
applyPan();
});
const endPan = (e: PointerEvent) => {
if (activePointers.has(e.pointerId)) {
activePointers.delete(e.pointerId);
}
if (activePointers.size < 2) {
pinchStartDistance = null;
pinchStartScale = null;
}
if (activePointers.size === 0) {
pdfPan.dragging = false;
canvas.style.cursor = 'grab';
}
try {
canvas.releasePointerCapture(e.pointerId);
} catch {}
};
canvas.addEventListener('pointerup', endPan);
canvas.addEventListener('pointercancel', endPan);
};
// Search and filters
let searchQuery = '';
let showFiltersPanel = false;
let filterDivisions: string[] = [];
let filterActive: string[] = [];
let filterEstimators: string[] = [];
let filterStatuses: string[] = [];
// Voice search
let isListening = false;
let recognition: any = null;
let hasVoicePermission = false;
// Files
let jobFiles: any[] = [];
let loadingFiles = false;
let filesError = '';
let showFileViewer = false;
let currentViewerFile: any = null;
let graphToken = '';
// PDF/Image viewer state
let currentPreviewUrl: string | null = null;
let currentPdf: {doc: any; page: number; pages: number; scale: number; isImage?: boolean; imageElement?: HTMLImageElement; imageWidth?: number; imageHeight?: number} | null = null;
let pdfPan = { x: 0, y: 0, dragging: false, startX: 0, startY: 0, baseX: 0, baseY: 0 };
let showPdfViewer = false;
const DEFAULT_PDF_SCALE = 0.45;
const MIN_PDF_SCALE = 0.3;
const MAX_PDF_SCALE = 3.0;
let graphTokenExpiry: number | null = null; // Expiration timestamp in ms
let graphTokenRefreshTimer: NodeJS.Timeout | null = null; // Background refresh timer
// New note
let newNoteText = '';
const divisions = ['C#', 'R#', 'S#'];
const activeOptions = ['Yes', 'No'];
const estimators = ['Steve Brewer', 'Beth Cardoza', 'Timothy Cardoza'];
const statuses = ['Estimating', 'In Progress', 'Awarded', 'Billed (In Progress)', 'Not Bidding', 'Est Sent', 'Not Awarded'];
const safeLower = (v: any) => (v || '').toString().trim().toLowerCase();
const toBool = (v: any) => {
if (typeof v === 'boolean') return v;
const s = String(v || '').trim().toLowerCase();
if (s === 'true' || s === 'yes' || s === 'y' || s === '1') return true;
if (s === 'false' || s === 'no' || s === 'n' || s === '0') return false;
return null;
};
onMount(async () => {
try {
const response = await fetch('/api/auth/config');
if (!response.ok) throw new Error('Failed to get config');
const config = await response.json();
auth = new PocketBaseAuth(config, {
onAuthSuccess: (state: AuthState) => {
console.log('📍 onAuthSuccess called with state:', state);
authState = state;
console.log('📍 authState updated, isAuthenticated:', authState.isAuthenticated);
// Delay loading to ensure auth state updates
setTimeout(() => {
console.log('📍 loadInitialJobs starting, auth check:', authState.isAuthenticated);
loadInitialJobs();
}, 100);
},
onAuthFailure: (err: unknown) => {
error = 'Authentication failed: ' + (err instanceof Error ? err.message : 'Unknown error');
},
onTokenUpdate: (state: AuthState) => {
console.log('📍 onTokenUpdate called with state:', state);
authState = state;
console.log('📍 authState updated from token refresh');
// Also load jobs when token is refreshed (e.g., on page reload after login)
setTimeout(() => {
console.log('📍 loadInitialJobs starting from token update');
loadInitialJobs();
}, 100);
},
onUiUpdate: (state: AuthState) => {
authState = state;
},
});
// Setup voice search
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
if (SpeechRecognition) {
recognition = new SpeechRecognition();
recognition.continuous = false;
recognition.interimResults = false;
recognition.language = 'en-US';
recognition.onstart = () => {
isListening = true;
};
recognition.onend = () => {
isListening = false;
};
recognition.onresult = (event: any) => {
let transcript = Array.from(event.results)
.map((result: any) => result[0].transcript)
.join('')
.trim();
// Remove trailing period that speech recognition often adds
transcript = transcript.replace(/\.$/, '');
searchQuery = transcript;
applyFiltersAndRender();
};
hasVoicePermission = true;
}
} catch (err) {
error = err instanceof Error ? err.message : 'Unknown error';
}
});
// Clean up token refresh timer when component is destroyed
onDestroy(() => {
if (graphTokenRefreshTimer) {
clearTimeout(graphTokenRefreshTimer);
console.log('📁 Cleaned up token refresh timer');
}
});
async function loadInitialJobs() {
console.log('📊 loadInitialJobs called');
console.log('📊 authState.isAuthenticated:', authState.isAuthenticated);
if (!authState.isAuthenticated) {
error = 'Not authenticated';
console.error('📊 Load failed: Not authenticated');
return;
}
loading = true;
error = '';
try {
// Load first batch of 100 jobs immediately
console.log('📊 Fetching first batch: /api/jobs/list?page=1&perPage=100');
const response = await fetch('/api/jobs/list?page=1&perPage=100');
console.log('📊 Fetch response status:', response.status);
if (!response.ok) throw new Error('Failed to load jobs');
const data = await response.json();
console.log('📊 Data received, items count:', data.items?.length || 0);
console.log('📊 Total pages:', data.totalPages || 'unknown');
jobsCache = data.items || [];
console.log('📊 jobsCache set, length:', jobsCache.length);
if (jobsCache.length === 0) {
error = 'No jobs found';
}
console.log('📊 Calling applyFiltersAndRender');
applyFiltersAndRender();
console.log('📊 visibleJobs after render:', visibleJobs.length);
// Load remaining pages in background if there are more
if (data.totalPages && data.totalPages > 1) {
console.log('📊 Loading remaining pages in background...');
loadRemainingPages(data.totalPages, 2);
} else {
console.log('📊 All jobs loaded');
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to load jobs';
error = msg;
console.error('📊 Load error:', msg);
jobsCache = [];
visibleJobs = [];
} finally {
loading = false;
console.log('📊 loadInitialJobs complete');
}
}
async function loadRemainingPages(totalPages: number, startPage: number) {
try {
for (let page = startPage; page <= totalPages; page++) {
console.log(`📊 Loading page ${page}/${totalPages}`);
const response = await fetch(`/api/jobs/list?page=${page}&perPage=100`);
if (!response.ok) {
console.warn(`📊 Failed to load page ${page}, stopping background load`);
break;
}
const data = await response.json();
const items = data.items || [];
jobsCache.push(...items);
console.log(`📊 Page ${page} loaded, total jobs now: ${jobsCache.length}`);
// Re-render with updated cache
applyFiltersAndRender();
}
console.log('📊 All pages loaded, total jobs:', jobsCache.length);
} catch (err) {
console.error('📊 Background page load error:', err);
}
}
function applyFiltersAndRender() {
console.log('🔍 applyFiltersAndRender called');
console.log('🔍 jobsCache length:', jobsCache.length);
console.log('🔍 searchQuery:', searchQuery);
let q = safeLower(searchQuery);
const divisionVals = filterDivisions;
const activeVals = filterActive;
const activeWanted = activeVals.map(v => toBool(v)).filter(v => v !== null);
const statusVals = filterStatuses;
const estimatorVals = filterEstimators;
const estimatorValsLower = estimatorVals.map(s => safeLower(s));
console.log('🔍 Filter criteria - divisions:', divisionVals, 'active:', activeWanted, 'status:', statusVals, 'estimators:', estimatorValsLower);
console.log('🔍 Starting filter with', jobsCache.length, 'jobs');
visibleJobs = jobsCache.filter(job => {
const searchable = [
job.Job_Number,
job.Job_Full_Name,
job.Job_Address,
job.Contact_Person,
job.Company_Client,
job.Notes
]
.join(' ')
.toLowerCase();
// Partial match for search
if (q && !searchable.includes(q)) return false;
if (divisionVals.length && !divisionVals.includes(job.Job_Division)) return false;
if (activeWanted.length) {
const b = toBool(job.Active);
if (b === null || !activeWanted.includes(b)) return false;
}
if (statusVals.length && !statusVals.includes(job.Job_Status || '')) return false;
if (estimatorVals.length) {
const je = safeLower(job.Estimator || '');
const ok = estimatorValsLower.some(v => je.includes(v) || v.includes(je));
if (!ok) return false;
}
return true;
});
// Sort by job number descending
visibleJobs.sort((a, b) => {
const numA = parseInt(a.Job_Number || '0', 10);
const numB = parseInt(b.Job_Number || '0', 10);
return numB - numA;
});
console.log('🔍 After filter, visibleJobs length:', visibleJobs.length);
if (visibleJobs.length > 0) {
console.log('🔍 First few jobs:', visibleJobs.slice(0, 3).map(j => `${j.Job_Number}: ${j.Job_Full_Name}`));
}
}
async function selectJob(job: JobRecord) {
try {
const response = await fetch(`/api/jobs/${job.id}`);
if (!response.ok) throw new Error('Failed to load job details');
selectedJob = await response.json();
showDetailModal = true;
showNotesTab = false;
showFolderContents = false;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load job details';
}
}
/**
* Ensure Graph token is valid and fresh
* Returns token or empty string if fetch fails
* Automatically refreshes if expired (60s buffer like backend)
*/
async function ensureGraphToken() {
const now = Date.now();
const bufferMs = 60000; // 60 second buffer (same as backend)
// Token is valid if it exists and has 60+ seconds remaining
if (graphToken && graphTokenExpiry && (graphTokenExpiry - bufferMs) > now) {
console.log('📁 Using cached Graph token (expires in', Math.floor((graphTokenExpiry - now) / 1000), 'seconds)');
return graphToken;
}
if (graphToken) {
console.log('📁 Graph token expired or expiring soon, refreshing...');
}
try {
const response = await fetch('/api/auth/graph/token');
if (!response.ok) {
console.warn('📁 Failed to get Graph token:', response.statusText);
return '';
}
const data = await response.json();
graphToken = data.fullToken || data.token || '';
// Parse expiration from response
if (data.expiresOn) {
graphTokenExpiry = new Date(data.expiresOn).getTime();
const expiresIn = Math.floor((graphTokenExpiry - now) / 1000);
console.log('📁 Graph token obtained, expires in', expiresIn, 'seconds');
// Schedule refresh 5 minutes before expiration (so we refresh before the 60s buffer hits)
scheduleTokenRefresh();
} else {
console.log('📁 Graph token obtained (no expiry info)');
graphTokenExpiry = null;
}
return graphToken;
} catch (err) {
console.error('📁 Error getting Graph token:', err);
return '';
}
}
/**
* Schedule background refresh of Graph token before expiration
* Refreshes 5 minutes before expiration to maintain a buffer
*/
function scheduleTokenRefresh() {
// Clear any existing timer
if (graphTokenRefreshTimer) {
clearTimeout(graphTokenRefreshTimer);
}
if (!graphTokenExpiry) return;
const now = Date.now();
const expiresIn = graphTokenExpiry - now;
const refreshAtMs = expiresIn - (5 * 60 * 1000); // Refresh 5 minutes before expiry
if (refreshAtMs > 0) {
console.log('📁 Scheduled token refresh in', Math.floor(refreshAtMs / 1000), 'seconds');
graphTokenRefreshTimer = setTimeout(() => {
console.log('📁 Background token refresh triggered');
ensureGraphToken(); // This will fetch a new token
}, refreshAtMs);
} else {
// Token expires in less than 5 minutes, refresh immediately
console.log('📁 Token expiring very soon, refreshing immediately');
ensureGraphToken();
}
}
async function loadJobFiles() {
if (!selectedJob?.Job_Folder_Link) {
filesError = 'No folder link available for this job';
return;
}
loadingFiles = true;
filesError = '';
jobFiles = []; folderSearchFilter = ''; // Reset search when loading new folder
try {
// Ensure we have a Graph token
await ensureGraphToken();
if (!graphToken) {
throw new Error('Unable to obtain Graph token for file access');
}
console.log('📁 Loading files for folder:', selectedJob.Job_Folder_Link);
const response = await fetch(`/api/job-files?link=${encodeURIComponent(selectedJob.Job_Folder_Link)}`, {
headers: {
'x-graph-token': graphToken
}
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Failed to load files: ${response.statusText}`);
}
const data = await response.json();
jobFiles = data.items || [];
console.log('📁 Loaded', jobFiles.length, 'files');
if (jobFiles.length === 0) {
filesError = 'No files found in folder';
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to load files';
filesError = msg;
console.error('📁 Error:', msg);
} finally {
loadingFiles = false;
}
}
function openFileInViewer(file: any) {
if (!file.url) {
console.error('No URL for file:', file);
return;
}
console.log('📂 Opening file in viewer:', file.name, file.url);
currentViewerFile = file;
showFileViewer = true;
showDetailModal = false;
// Try to preview in canvas if supported
const ext = (file.name || '').toLowerCase().split('.').pop();
if (canPreviewInline(file.name, file.contentType || '')) {
loadFilePreview(file);
}
}
async function loadFilePreview(file: any) {
if (!file.url) return;
try {
let fetchUrl = '';
// Use backend proxy endpoints instead of direct SharePoint URLs
const ext = (file.name || '').toLowerCase().split('.').pop();
const isWordDoc = ['doc', 'docx'].includes(ext as string) || file.contentType?.includes('word');
const isPdf = ext === 'pdf' || file.contentType?.includes('pdf');
if (file.driveId && file.id) {
// Use proxy endpoints with Graph API
if (isWordDoc) {
fetchUrl = `/api/file-pdf?driveId=${encodeURIComponent(file.driveId)}&itemId=${encodeURIComponent(file.id)}`;
} else {
fetchUrl = `/api/file-content?driveId=${encodeURIComponent(file.driveId)}&itemId=${encodeURIComponent(file.id)}`;
}
} else {
// Fallback to direct URL (for non-SharePoint files)
fetchUrl = file.url;
}
const response = await fetch(fetchUrl, {
headers: graphToken ? { 'x-graph-token': graphToken } : {},
});
if (!response.ok) throw new Error(`Failed to fetch file: ${response.status}`);
const blob = await response.blob();
if (currentPreviewUrl) {
URL.revokeObjectURL(currentPreviewUrl);
}
currentPreviewUrl = URL.createObjectURL(blob);
const isImage = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'bmp'].includes(ext as string) || file.contentType?.includes('image');
if ((isPdf || isWordDoc) && (window as any).pdfjsLib) {
const data = await blob.arrayBuffer();
const pdfjsLib = (window as any).pdfjsLib;
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
const doc = await pdfjsLib.getDocument({ data }).promise;
currentPdf = {
doc,
page: 1,
pages: doc.numPages,
scale: DEFAULT_PDF_SCALE,
};
pdfPan = { x: 0, y: 0, dragging: false, startX: 0, startY: 0, baseX: 0, baseY: 0 };
showPdfViewer = true;
// Render first page and setup controls
await new Promise(resolve => setTimeout(resolve, 100));
await renderPdfPage();
updateZoomLabel();
setupCanvasEventHandlers();
} else if (isImage) {
const img = new Image();
img.onload = async () => {
currentPdf = {
doc: null,
page: 1,
pages: 1,
scale: 1,
isImage: true,
imageElement: img,
imageWidth: img.width,
imageHeight: img.height,
};
pdfPan = { x: 0, y: 0, dragging: false, startX: 0, startY: 0, baseX: 0, baseY: 0 };
showPdfViewer = true;
await new Promise(resolve => setTimeout(resolve, 100));
renderImageOnCanvas();
updateZoomLabel();
setupCanvasEventHandlers();
};
img.src = currentPreviewUrl;
}
} catch (err) {
console.error('Preview load error:', err);
}
}
function closeFileViewer() {
showFileViewer = false;
showPdfViewer = false;
currentViewerFile = null;
currentPdf = null;
if (currentPreviewUrl) {
URL.revokeObjectURL(currentPreviewUrl);
currentPreviewUrl = null;
}
showDetailModal = true;
}
function handleSearch() {
applyFiltersAndRender();
}
function handleFilterChange() {
applyFiltersAndRender();
}
function clearFilters() {
searchQuery = '';
filterDivisions = [];
filterActive = [];
filterEstimators = [];
filterStatuses = [];
applyFiltersAndRender();
}
function toggleFiltersPanel() {
showFiltersPanel = !showFiltersPanel;
}
function startVoiceSearch() {
if (!recognition || isListening) return;
recognition.start();
}
function handleLogout() {
if (auth) {
auth.logout();
authState = {
isAuthenticated: false,
user: null,
token: null,
};
jobsCache = [];
visibleJobs = [];
error = '';
}
}
function toggleFilterCheckbox(filterArray: string[], value: string) {
const idx = filterArray.indexOf(value);
if (idx >= 0) {
filterArray.splice(idx, 1);
} else {
filterArray.push(value);
}
filterArray = filterArray; // Trigger reactivity
handleFilterChange();
}
async function saveNote() {
if (!selectedJob || !newNoteText.trim()) return;
try {
const timestamp = new Date().toLocaleString();
const formatted = `[${timestamp}]\n${newNoteText}`;
const updated = formatted + '\n\n' + (selectedJob.Notes || '');
const response = await fetch(`/api/jobs/${selectedJob.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Notes: updated }),
});
if (!response.ok) throw new Error('Failed to save note');
const updatedJob = await response.json();
// Update selectedJob and cache
selectedJob = updatedJob;
const idx = jobsCache.findIndex(j => j.id === selectedJob.id);
if (idx >= 0) jobsCache[idx] = updatedJob;
newNoteText = '';
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to save note';
}
}
</script>
<div style="min-height: 100vh; background: #f3f4f6; color: #1f2937; display: flex; flex-direction: column; font-family: system-ui, -apple-system, sans-serif;">
{#if !authState.isAuthenticated}
<div style="flex: 1; display: flex; align-items: center; justify-content: center; padding: 24px;">
<div style="background: white; border: 1px solid #e5e7eb; padding: 40px; border-radius: 8px; text-align: center; max-width: 400px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<h1 style="margin: 0 0 16px 0; font-size: 28px; color: #2563eb; font-weight: bold;">Job Info</h1>
<p style="margin: 0 0 24px 0; color: #6b7280; font-size: 16px;">Please authenticate to access job information</p>
<button
id="loginBtn"
style="background: #2563eb; color: white; border: none; padding: 14px 28px; border-radius: 6px; font-weight: bold; font-size: 16px; cursor: pointer; width: 100%;"
>
Login with Microsoft
</button>
</div>
</div>
{:else}
<div style="max-width: 480px; margin: 0 auto; width: 100%; padding: 12px; display: flex; flex-direction: column; height: 100vh; overflow: hidden; gap: 8px;">
<!-- Header -->
<div style="text-align: center; margin-bottom: 8px;">
<h1 style="margin: 0; font-size: 28px; font-weight: bold; color: #2563eb;">Job Info</h1>
</div>
<!-- Search Box -->
<div style="position: relative;">
<input
bind:value={searchQuery}
on:keyup={handleSearch}
placeholder="Search jobs (start typing or use voice)"
style="width: 100%; padding: 10px 40px 10px 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px; box-sizing: border-box;"
/>
{#if hasVoicePermission}
<button
on:click={startVoiceSearch}
disabled={isListening}
style="position: absolute; right: 8px; top: 50%; transform: translateY(-50%); background: transparent; border: none; cursor: pointer; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; opacity: {isListening ? 0.6 : 1};"
title="Voice search"
>
{#if isListening}
<span style="color: #dc2626; font-size: 12px; animation: pulse 1s infinite;"></span>
{:else}
<svg style="width: 20px; height: 20px; color: #6b7280;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"></path>
</svg>
{/if}
</button>
{/if}
</div>
<!-- Filters Toggle & Panel -->
<div style="display: flex; gap: 8px;">
<button
on:click={toggleFiltersPanel}
style="background: #2563eb; color: white; padding: 10px 16px; border: none; border-radius: 6px; font-weight: bold; cursor: pointer; font-size: 14px;"
>
{showFiltersPanel ? 'Hide Filters' : 'Show Filters'}
</button>
</div>
{#if showFiltersPanel}
<div style="background: white; border: 1px solid #e5e7eb; border-radius: 6px; padding: 12px; max-height: 200px; overflow-y: auto;">
<div style="margin-bottom: 12px;">
<strong style="font-size: 13px; color: #374151;">Division</strong>
<div style="display: flex; flex-direction: column; gap: 6px; margin-top: 6px;">
{#each divisions as div}
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 13px; color: #6b7280;">
<input
type="checkbox"
checked={filterDivisions.includes(div)}
on:change={() => toggleFilterCheckbox(filterDivisions, div)}
style="cursor: pointer;"
/>
{div}
</label>
{/each}
</div>
</div>
<div style="margin-bottom: 12px;">
<strong style="font-size: 13px; color: #374151;">Active</strong>
<div style="display: flex; flex-direction: column; gap: 6px; margin-top: 6px;">
{#each activeOptions as opt}
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 13px; color: #6b7280;">
<input
type="checkbox"
checked={filterActive.includes(opt)}
on:change={() => toggleFilterCheckbox(filterActive, opt)}
style="cursor: pointer;"
/>
{opt}
</label>
{/each}
</div>
</div>
<div style="margin-bottom: 12px;">
<strong style="font-size: 13px; color: #374151;">Estimator</strong>
<div style="display: flex; flex-direction: column; gap: 6px; margin-top: 6px;">
{#each estimators as est}
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 13px; color: #6b7280;">
<input
type="checkbox"
checked={filterEstimators.includes(est)}
on:change={() => toggleFilterCheckbox(filterEstimators, est)}
style="cursor: pointer;"
/>
{est}
</label>
{/each}
</div>
</div>
<div style="margin-bottom: 12px;">
<strong style="font-size: 13px; color: #374151;">Status</strong>
<div style="display: flex; flex-direction: column; gap: 6px; margin-top: 6px;">
{#each statuses as stat}
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 13px; color: #6b7280;">
<input
type="checkbox"
checked={filterStatuses.includes(stat)}
on:change={() => toggleFilterCheckbox(filterStatuses, stat)}
style="cursor: pointer;"
/>
{stat}
</label>
{/each}
</div>
</div>
<button
on:click={clearFilters}
style="background: #6b7280; color: white; border: none; padding: 8px 12px; border-radius: 4px; font-size: 13px; cursor: pointer; width: 100%;"
>
Clear Filters
</button>
</div>
{/if}
<!-- Error Display -->
{#if error}
<div style="background: #fee2e2; border: 1px solid #fca5a5; color: #991b1b; padding: 12px; border-radius: 6px; font-size: 13px;">
{error}
</div>
{/if}
<!-- Job Results -->
<div style="flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; padding: 0;">
{#if loading}
<div style="text-align: center; padding: 20px; color: #9ca3af;">Loading jobs...</div>
{:else if visibleJobs.length === 0}
<div style="text-align: center; padding: 20px; color: #9ca3af;">No jobs found</div>
{:else}
{#each visibleJobs as job (job.id)}
<button
on:click={() => selectJob(job)}
style="background: white; border: 1px solid #dbeafe; border-radius: 6px; padding: 12px; text-align: left; cursor: pointer; box-shadow: 0 1px 2px rgba(0,0,0,0.05); position: relative; transition: background 0.2s;"
>
<div style="display: flex; justify-content: space-between; gap: 8px; margin-bottom: 6px;">
<strong style="font-size: 16px; color: #1f2937;">{job.Job_Number}</strong>
<div
style="width: 12px; height: 12px; border-radius: 50%; border: 2px solid white; box-shadow: 0 0 2px rgba(0,0,0,0.1); background: {toBool(job.Active) === true ? '#22c55e' : toBool(job.Active) === false ? '#ef4444' : '#cbd5e1'};"
></div>
</div>
<div style="font-size: 14px; color: #374151; margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
{job.Job_Full_Name}
</div>
<div style="font-size: 12px; color: #6b7280; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
{job.Contact_Person}
</div>
</button>
{/each}
{/if}
</div>
<!-- Footer -->
<div style="border-top: 1px solid #e5e7eb; padding-top: 8px; font-size: 11px; color: #9ca3af; text-align: center;">
<div style="margin-bottom: 4px;">{authState.user?.email}</div>
<button
on:click={handleLogout}
style="background: #f3f4f6; border: 1px solid #d1d5db; color: #374151; padding: 4px 8px; border-radius: 4px; font-size: 10px; cursor: pointer;"
>
Sign out
</button>
</div>
</div>
{/if}
</div>
<!-- Detail Modal -->
{#if showDetailModal && selectedJob}
<div style="position: fixed; inset: 0; background: rgba(0, 0, 0, 0.4); display: flex; align-items: flex-start; justify-content: center; padding-top: 20px; z-index: 999; overflow-y: auto;">
<div style="background: white; border-radius: 8px; width: 90%; max-width: 380px; max-height: 80vh; overflow: hidden; display: flex; flex-direction: column; box-shadow: 0 20px 25px rgba(0,0,0,0.15);">
<!-- Tab Buttons -->
<div style="display: flex; border-bottom: 1px solid #e5e7eb; background: #f9fafb;">
<button
on:click={() => { showNotesTab = false; showFolderContents = false; }}
style="flex: 1; padding: 12px; border: none; background: {!showNotesTab && !showFolderContents ? '#2563eb' : '#f9fafb'}; color: {!showNotesTab && !showFolderContents ? 'white' : '#6b7280'}; font-weight: bold; cursor: pointer; font-size: 13px;"
>
INFO
</button>
<button
on:click={() => { showFolderContents = false; showNotesTab = true; }}
style="flex: 1; padding: 12px; border: none; background: {showNotesTab && !showFolderContents ? '#ea580c' : '#f9fafb'}; color: {showNotesTab && !showFolderContents ? 'white' : '#6b7280'}; font-weight: bold; cursor: pointer; font-size: 13px;"
>
NOTES
</button>
</div>
{#if !showNotesTab && !showFolderContents}
<!-- INFO Tab -->
<div style="flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column;">
<div style="flex: 1; overflow-y: auto; margin-bottom: 12px;">
{#each Object.entries(selectedJob) as [key, value]}
{#if key !== 'id' && value !== null && value !== undefined && !key.startsWith('collectionId') && key !== 'collectionName' && key !== 'Job_Folder_Link'}
<div style="margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid #f3f4f6;">
<div style="font-size: 11px; font-weight: bold; color: #9ca3af; text-transform: uppercase; margin-bottom: 4px;">
{key}
</div>
<div style="font-size: 13px; color: #1f2937; word-break: break-word;">
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
</div>
</div>
{/if}
{/each}
</div>
{#if selectedJob.Job_Folder_Link}
<button
on:click={() => { showFolderContents = true; loadJobFiles(); }}
style="width: 100%; padding: 12px; background: #059669; color: white; border: none; border-radius: 6px; font-weight: bold; text-align: center; cursor: pointer; font-size: 13px;"
>
📁 Show Folder
</button>
{/if}
</div>
{:else if showFolderContents}
<!-- Folder Contents with Grouping -->
<div style="flex: 1; overflow-y: auto; display: flex; flex-direction: column; padding: 12px;">
{#if loadingFiles}
<div style="text-align: center; padding: 20px; color: #9ca3af;">
<div style="margin-bottom: 12px;">Loading files...</div>
</div>
{:else if filesError}
<div style="background: #fee2e2; border: 1px solid #fca5a5; color: #991b1b; padding: 12px; border-radius: 6px; font-size: 13px; margin-bottom: 12px;">
{filesError}
</div>
<button
on:click={() => showFolderContents = false}
style="width: 100%; padding: 10px; background: #6b7280; color: white; border: none; border-radius: 4px; font-weight: bold; cursor: pointer; font-size: 12px;"
>
Back to Info
</button>
{:else if jobFiles.length === 0}
<div style="text-align: center; padding: 20px; color: #9ca3af; margin-bottom: 12px;">No files found in folder</div>
<button
on:click={() => showFolderContents = false}
style="width: 100%; padding: 10px; background: #6b7280; color: white; border: none; border-radius: 4px; font-weight: bold; cursor: pointer; font-size: 12px;"
>
Back to Info
</button>
{:else}
<!-- Search Filter -->
<input
bind:value={folderSearchFilter}
placeholder="Search files..."
style="width: 100%; padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 12px; margin-bottom: 12px; box-sizing: border-box;"
/>
<!-- Grouped Files -->
<div style="display: flex; flex-direction: column; gap: 12px; flex: 1; overflow-y: auto; margin-bottom: 12px;">
{#each ['managerInfo', 'contracts', 'plans', 'submittals', 'other'] as category}
{@const categoryFiles = jobFiles
.filter(f => !f.isFolder && categorizeFile(f.name) === category)
.filter(f => folderSearchFilter === '' || f.name.toLowerCase().includes(folderSearchFilter.toLowerCase()))
}
{@const categoryTitles = {
managerInfo: 'Manager Info',
contracts: 'Contracts / Estimates',
plans: 'Plans',
submittals: 'Submittals',
other: 'Other'
}}
{#if categoryFiles.length > 0}
<div>
<div style="font-weight: bold; color: #1f2937; font-size: 13px; margin-bottom: 6px;">
{categoryTitles[category]} ({categoryFiles.length})
</div>
<div style="border: 1px solid #e5e7eb; border-radius: 6px; overflow: hidden; background: white;">
{#each categoryFiles as file, idx (file.id || file.name)}
<button
on:click={() => openFileInViewer(file)}
style="width: 100%; padding: 10px 12px; text-align: left; border: none; background: white; cursor: pointer; border-bottom: {idx < categoryFiles.length - 1 ? '1px solid #e5e7eb' : 'none'}; transition: background 0.2s;"
on:mouseenter={(e) => e.target.style.background = '#f9fafb'}
on:mouseleave={(e) => e.target.style.background = 'white'}
>
<div style="display: flex; align-items: center; gap: 8px;">
<div style="flex-shrink: 0; font-size: 16px; color: #6b7280; display: flex; align-items: center; justify-content: center;">
{@html getSvgIcon(getFileIcon(file.name))}
</div>
<div style="flex: 1; min-width: 0;">
<div style="font-weight: 600; color: #1f2937; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
{file.name}
</div>
<div style="font-size: 11px; color: #6b7280; margin-top: 2px;">
{formatFileSize(file.size)}
{#if file.modified}
{formatDate(file.modified)}
{/if}
</div>
</div>
</div>
</button>
{/each}
</div>
</div>
{/if}
{/each}
</div>
<button
on:click={() => showFolderContents = false}
style="width: 100%; padding: 10px; background: #6b7280; color: white; border: none; border-radius: 4px; font-weight: bold; cursor: pointer; font-size: 12px;"
>
Back to Info
</button>
{/if}
</div>
{:else}
<!-- NOTES Tab -->
<div style="flex: 1; overflow-y: auto; display: flex; flex-direction: column; padding: 12px;">
<div
style="min-height: 120px; max-height: 200px; overflow-y: auto; padding: 8px; border: 1px solid #d1d5db; border-radius: 4px; margin-bottom: 12px; font-size: 12px; line-height: 1.5; background: white; white-space: pre-wrap; word-break: break-word; color: #6b7280;"
>
{selectedJob.Notes || '(No notes)'}
</div>
<div style="margin-bottom: 12px;">
<textarea
bind:value={newNoteText}
placeholder="Add a new note..."
style="width: 100%; min-height: 80px; padding: 8px; border: 1px solid #d1d5db; border-radius: 4px; font-size: 12px; font-family: inherit; resize: vertical;"
></textarea>
</div>
<button
on:click={saveNote}
disabled={!newNoteText.trim()}
style="background: #2563eb; color: white; border: none; padding: 10px; border-radius: 4px; font-weight: bold; cursor: pointer; opacity: {!newNoteText.trim() ? 0.5 : 1};"
>
Save Note
</button>
</div>
{/if}
<!-- Close Button -->
<div style="padding: 12px; border-top: 1px solid #e5e7eb; background: #f9fafb;">
<button
on:click={() => (showDetailModal = false)}
style="width: 100%; background: #ef4444; color: white; border: none; padding: 10px; border-radius: 4px; font-weight: bold; cursor: pointer;"
>
Close
</button>
</div>
</div>
</div>
{/if}
<!-- File Viewer Modal -->
{#if showFileViewer && currentViewerFile}
<div style="position: fixed; inset: 0; background: #000; z-index: 1000; display: flex; flex-direction: column;">
<!-- Header -->
<div style="background: #1f2937; color: white; padding: 12px; display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; box-shadow: 0 2px 4px rgba(0,0,0,0.2);">
<button
on:click={closeFileViewer}
style="background: #2563eb; color: white; border: none; padding: 8px 12px; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 12px; display: flex; align-items: center; gap: 6px;"
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:16px;height:16px;"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path></svg>
Back
</button>
<div style="flex: 1; text-center; padding: 0 12px; min-width: 0;">
<div style="font-size: 13px; font-weight: bold; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
{currentViewerFile.name || 'File Preview'}
</div>
</div>
<button
on:click={closeFileViewer}
style="background: #ef4444; color: white; border: none; padding: 8px 12px; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 12px; display: flex; align-items: center; gap: 6px;"
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:16px;height:16px;"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
Close
</button>
</div>
<!-- Viewer Content -->
<div style="flex: 1; overflow: auto; background: #000; display: flex; align-items: center; justify-content: center; flex-direction: column;">
{#if showPdfViewer && currentPdf}
<!-- PDF/Image Controls -->
<div style="background: #1f2937; color: white; width: 100%; padding: 8px 12px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid #374151; flex-shrink: 0;">
<div style="display: flex; align-items: center; gap: 6px;">
<button
id="pdfZoomOutBtn"
style="padding: 4px 8px; background: #6b7280; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 11px; font-weight: bold;"
>
</button>
<button
id="pdfZoomResetBtn"
style="padding: 4px 8px; background: #6b7280; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 11px; font-weight: bold;"
>
{Math.round(currentPdf.scale * 100)}%
</button>
<button
id="pdfZoomInBtn"
style="padding: 4px 8px; background: #6b7280; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 11px; font-weight: bold;"
>
+
</button>
<span style="font-size: 11px; color: #d1d5db; margin-left: 6px;">
{Math.round(currentPdf.scale * 100)}%
</span>
</div>
{#if !currentPdf.isImage && currentPdf.pages > 1}
<div style="display: flex; align-items: center; gap: 6px; margin-left: 12px;">
<button style="padding: 4px 8px; background: #6b7280; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 11px;"></button>
<button style="padding: 4px 8px; background: #6b7280; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 11px;"></button>
<span style="font-size: 11px; color: #d1d5db;">Page <span>{currentPdf.page}</span> / <span>{currentPdf.pages}</span></span>
</div>
{/if}
</div>
<!-- Canvas Viewer -->
<div style="flex: 1; overflow: hidden; background: #111; display: flex; align-items: center; justify-content: center; width: 100%;">
<canvas
id="pdfCanvas"
style="max-width: 100%; max-height: 100%; object-fit: contain; background: white; cursor: grab; touch-action: none;"
></canvas>
</div>
{:else if currentViewerFile.url}
<iframe
src={currentViewerFile.url}
style="width: 100%; height: 100%; border: none;"
title={currentViewerFile.name}
sandbox="allow-same-origin allow-scripts allow-popups allow-downloads"
></iframe>
{:else}
<div style="color: white; text-align: center;">
<div style="font-size: 16px; margin-bottom: 12px;">Unable to preview file</div>
<div style="font-size: 12px; color: #aaa;">No URL available</div>
</div>
{/if}
</div>
</div>
{/if}
<style>
:global(body) {
margin: 0;
padding: 0;
}
</style>