1538 lines
60 KiB
Svelte
1538 lines
60 KiB
Svelte
<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>
|
||
|