תגובה | דוח ניתוח האזנה
-
בנוגע למה שעשה @ע.ג. בדוח נתוני האזנה.
עשיתי שינויים קלים לצרכיי, ואני משתף את זה.
הוספה;- שמות השלוחות.
- חיפוש חופשי (לא יודע אם זה נוגע לכולם, אבל לי אישית זה הועיל).
- קבצים/מאזינים מובילים.
- במידה ומישהו שמע כמה פעמים קובץ, יש אפשרות לראות את מספר הפעמים.
- חלוקה של הדוח לדפים (ברירת מחדל 20 שורות בדף, ניתן לשינוי)
<!DOCTYPE html> <html lang="he" dir="rtl"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>מערכת ניתוח נתוני האזנה - Pro Dashboard</title> <!-- Tailwind CSS --> <script src="https://cdn.tailwindcss.com"></script> <!-- XLSX Library --> <script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script> <!-- Lucide Icons --> <script src="https://unpkg.com/lucide@latest"></script> <style> @import url('https://fonts.googleapis.com/css2?family=Assistant:wght@300;400;600;700&display=swap'); body { font-family: 'Assistant', sans-serif; background-color: #f8fafc; color: #1e293b; } .glass-card { background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, 0.2); box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); } .custom-scrollbar::-webkit-scrollbar { width: 6px; height: 6px; } .custom-scrollbar::-webkit-scrollbar-track { background: #f1f5f9; } .custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; } .custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #94a3b8; } .animate-fade-in { animation: fadeIn 0.5s ease-out forwards; } @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } .progress-fill { transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1); } .bg-green-gradient { background: linear-gradient(90deg, #10b981, #34d399); } .bg-orange-gradient { background: linear-gradient(90deg, #f59e0b, #fbbf24); } .bg-red-gradient { background: linear-gradient(90deg, #ef4444, #f87171); } .filter-row input, .filter-row select { background: #ffffff; border: 1px solid #e2e8f0; font-size: 11px; padding: 4px 6px; border-radius: 4px; width: 100%; outline: none; transition: all 0.2s; } .filter-row input:focus { border-color: #6366f1; box-shadow: 0 0 0 1px #6366f1; } .page-btn { padding-left: 0.75rem; padding-right: 0.75rem; padding-top: 0.25rem; padding-bottom: 0.25rem; border-radius: 0.5rem; border-width: 1px; border-color: #e2e8f0; font-size: 0.875rem; line-height: 1.25rem; font-weight: 500; transition-property: all; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; } .page-btn:hover { background-color: #f8fafc; } .page-btn:disabled { opacity: 0.5; } .page-btn.active { background-color: #4f46e5; color: #ffffff; border-color: #4f46e5; } .detail-row { background-color: #f8fafc; } </style> </head> <body class="min-h-screen pb-12"> <!-- Header --> <nav class="sticky top-0 z-50 bg-white/80 backdrop-blur-md border-b border-slate-200"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="flex justify-between h-16 items-center"> <div class="flex items-center gap-3"> <div class="bg-indigo-600 p-2 rounded-lg shadow-lg shadow-indigo-200"> <i data-lucide="bar-chart-3" class="text-white w-6 h-6"></i> </div> <h1 class="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-indigo-600 to-violet-600"> מנתח נתוני האזנה Pro </h1> </div> <div id="statusIndicator" class="text-sm font-medium text-slate-500 flex items-center gap-2"> <span class="w-2 h-2 rounded-full bg-slate-300"></span> ממתין לפעולה </div> </div> </div> </nav> <main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-8 space-y-8"> <!-- Info & Setup Section --> <div class="grid grid-cols-1 lg:grid-cols-3 gap-8"> <!-- Setup Panel --> <div class="lg:col-span-2 glass-card rounded-2xl p-6 animate-fade-in"> <div class="flex items-center gap-2 mb-6"> <i data-lucide="settings" class="text-indigo-600 w-5 h-5"></i> <h2 class="text-lg font-semibold">הגדרות שליפה</h2> </div> <div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="space-y-2"> <label class="text-sm font-medium text-slate-700">מתאריך</label> <input type="date" id="from-date" class="w-full px-4 py-2 rounded-xl border border-slate-200 focus:ring-2 focus:ring-indigo-500 outline-none"> </div> <div class="space-y-2"> <label class="text-sm font-medium text-slate-700">עד תאריך</label> <input type="date" id="to-date" class="w-full px-4 py-2 rounded-xl border border-slate-200 focus:ring-2 focus:ring-indigo-500 outline-none"> </div> <div class="md:col-span-2 space-y-2"> <label class="text-sm font-medium text-slate-700">טוקן API</label> <div class="flex gap-2"> <div class="relative flex-1"> <input type="password" id="api-token" placeholder="הזן טוקן..." class="w-full px-4 py-2 pr-10 rounded-xl border border-slate-200 focus:ring-2 focus:ring-indigo-500 outline-none"> <i data-lucide="key" class="absolute left-3 top-2.5 text-slate-400 w-4 h-4"></i> </div> <button onclick="clearToken()" class="px-3 py-2 text-rose-500 hover:bg-rose-50 rounded-xl transition-colors" title="נקה טוקן"> <i data-lucide="trash-2" class="w-5 h-5"></i> </button> </div> </div> </div> <div class="mt-8 flex flex-wrap gap-4"> <button onclick="fetchData()" id="fetch-btn" class="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-700 text-white px-8 py-3 rounded-xl font-semibold transition-all shadow-lg shadow-indigo-200 active:scale-95"> <i data-lucide="download" class="w-5 h-5"></i> משוך נתונים </button> <button onclick="exportToExcel()" id="export-btn" class="flex items-center gap-2 bg-emerald-600 hover:bg-emerald-700 text-white px-6 py-3 rounded-xl font-semibold transition-all shadow-lg shadow-emerald-200 active:scale-95 hidden"> <i data-lucide="file-spreadsheet" class="w-5 h-5"></i> ייצא לאקסל </button> </div> </div> <!-- Important Info Box --> <div class="glass-card rounded-2xl p-6 bg-indigo-50/50 border-indigo-100 animate-fade-in" style="animation-delay: 0.1s"> <div class="flex items-center gap-2 mb-4"> <i data-lucide="info" class="text-indigo-600 w-5 h-5"></i> <h2 class="text-lg font-semibold text-indigo-900">מידע חשוב</h2> </div> <div class="text-xs text-indigo-800 space-y-4 leading-relaxed"> <div class="bg-white/80 p-3 rounded-lg border border-indigo-100 shadow-sm"> <p class="font-bold mb-1 text-indigo-900">דרישות מערכת:</p> <p>המערכת מיועדת למערכות בהן מוגדר שמירת דוח יומי. יש לוודא שבקובץ <code class="bg-indigo-100 px-1 rounded">ivr.ini</code> מופיעה ההגדרה:</p> <code class="block mt-1 font-mono text-indigo-600 bg-indigo-100/50 p-1 rounded text-center font-bold">log_playback_play_stop=yes</code> </div> <div class="space-y-2 opacity-90"> <p class="flex items-start gap-2"> <span class="mt-1 w-1.5 h-1.5 rounded-full bg-indigo-400 shrink-0"></span> <span>המערכת משקללת רצף האזנה אמיתי ומסננת כפילויות האזנה או דילוגים.</span> </p> <p class="flex items-start gap-2"> <span class="mt-1 w-1.5 h-1.5 rounded-full bg-indigo-400 shrink-0"></span> <span>התוצאה משקפת זמן האזנה בפועל לתוכן הקובץ.</span> </p> <p class="flex items-start gap-2"> <span class="mt-1 w-1.5 h-1.5 rounded-full bg-indigo-400 shrink-0"></span> <span>לא מומלץ להפיק דוחות בין 4 ל-5 לפנות בוקר (זמן עדכון שרתים).</span> </p> </div> </div> </div> </div> <!-- Stats Grid --> <div id="statsContainer" class="grid grid-cols-1 md:grid-cols-4 gap-6 hidden animate-fade-in"> <div class="glass-card rounded-2xl p-5 border-r-4 border-indigo-500"> <p class="text-sm text-slate-500 font-medium">סה"כ רשומות</p> <h3 id="statTotal" class="text-2xl font-bold mt-1">0</h3> </div> <div class="glass-card rounded-2xl p-5 border-r-4 border-emerald-500"> <p class="text-sm text-slate-500 font-medium">ממוצע האזנה</p> <h3 id="statAvg" class="text-2xl font-bold mt-1">0%</h3> </div> <div class="glass-card rounded-2xl p-5 border-r-4 border-amber-500"> <p class="text-sm text-slate-500 font-medium">האזנה נטו (שעות)</p> <h3 id="statNetHours" class="text-2xl font-bold mt-1">0</h3> </div> <div class="glass-card rounded-2xl p-5 border-r-4 border-rose-500"> <p class="text-sm text-slate-500 font-medium">האזנה ברוטו (שעות)</p> <h3 id="statGrossHours" class="text-2xl font-bold mt-1">0</h3> </div> </div> <!-- Top Files & Top Listeners --> <div id="insightsContainer" class="grid grid-cols-1 lg:grid-cols-2 gap-8 hidden animate-fade-in"> <!-- Top Files --> <div class="glass-card rounded-2xl p-6"> <div class="flex items-center gap-2 mb-6"> <i data-lucide="trending-up" class="text-indigo-600 w-5 h-5"></i> <h2 class="text-lg font-semibold">קבצים מובילים בהאזנה</h2> </div> <div id="topFilesList" class="space-y-4"> <!-- Top files will be injected here --> </div> </div> <!-- Top Listeners --> <div class="glass-card rounded-2xl p-6"> <div class="flex items-center gap-2 mb-6"> <i data-lucide="award" class="text-amber-500 w-5 h-5"></i> <h2 class="text-lg font-semibold text-slate-800">מאזינים מובילים (נטו)</h2> </div> <div id="topListenersList" class="space-y-4"> <!-- Top listeners will be injected here --> </div> </div> </div> <!-- Main Table Area --> <div class="glass-card rounded-2xl overflow-hidden animate-fade-in" style="animation-delay: 0.2s"> <div class="p-6 border-b border-slate-100 bg-slate-50/50 flex flex-wrap gap-4 items-end justify-between"> <div class="flex flex-wrap gap-4 items-end"> <div class="w-64 space-y-2"> <label class="text-xs font-bold text-slate-500 uppercase tracking-wider">חיפוש חופשי</label> <div class="relative"> <input type="text" id="searchInput" oninput="applyMultiFilter()" placeholder="שם, טלפון, קובץ..." class="w-full px-4 py-2 pr-10 rounded-xl border border-slate-200 outline-none focus:ring-2 focus:ring-indigo-500"> <i data-lucide="search" class="absolute left-3 top-2.5 text-slate-400 w-4 h-4"></i> </div> </div> <div class="w-32 space-y-2"> <label class="text-xs font-bold text-slate-500 uppercase tracking-wider">אחוז מינימלי</label> <input type="number" id="filter-percent" oninput="applyMultiFilter()" class="w-full px-4 py-2 rounded-xl border border-slate-200 outline-none focus:ring-2 focus:ring-indigo-500" placeholder="0"> </div> </div> <div id="statFiltered" class="text-xs font-bold text-indigo-600 bg-indigo-50 px-3 py-1 rounded-full">0 רשומות מוצגות</div> </div> <div class="custom-scrollbar overflow-x-auto"> <table id="results-table" class="w-full text-right border-collapse hidden"> <thead> <tr id="header-row" class="bg-slate-50 text-slate-500 text-[11px] font-bold uppercase tracking-wider"> <!-- Dynamic Headers --> </tr> <tr id="filter-row" class="filter-row bg-white border-b border-slate-100"> <!-- Dynamic Filters --> </tr> </thead> <tbody id="table-body" class="divide-y divide-slate-100 text-sm"> <!-- Results --> </tbody> </table> <div id="emptyState" class="p-20 text-center text-slate-400"> <div class="flex flex-col items-center gap-4"> <i data-lucide="database" class="w-12 h-12 opacity-20"></i> <p class="text-lg">טרם נטענו נתונים</p> </div> </div> </div> <!-- Pagination --> <div id="pagination-container" class="px-6 py-4 bg-slate-50 border-t border-slate-200 flex flex-wrap items-center justify-between gap-4 hidden"> <div class="flex items-center gap-4"> <span class="text-sm text-slate-500">הצג</span> <select id="rows-per-page" onchange="updateRowsPerPage()" class="px-2 py-1 rounded border border-slate-200 text-sm outline-none"> <option value="10">10</option> <option value="20" selected>20</option> <option value="50">50</option> <option value="100">100</option> </select> </div> <div class="flex items-center gap-2" id="page-numbers"></div> <div class="text-sm text-slate-500" id="pagination-info"></div> </div> </div> </main> <!-- Loading Overlay --> <div id="loadingOverlay" class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm z-[100] flex items-center justify-center hidden"> <div class="bg-white rounded-2xl p-8 flex flex-col items-center gap-4 shadow-2xl max-w-sm w-full mx-4"> <div class="w-16 h-16 border-4 border-indigo-100 border-t-indigo-600 rounded-full animate-spin"></div> <div class="text-center"> <h3 class="text-lg font-bold text-slate-800">מעבד נתונים...</h3> <p id="loadingStatus" class="text-sm text-slate-500 mt-1">מתחבר לשרת</p> </div> <div class="w-full bg-slate-100 rounded-full h-2 mt-2 overflow-hidden"> <div id="loadingProgress" class="bg-indigo-600 h-full transition-all duration-300" style="width: 0%"></div> </div> </div> </div> <script> // --- Initialization --- lucide.createIcons(); // Helper: Remove Hebrew Nikud (diacritics) function removeNikud(str) { if (!str) return ""; return str.replace(/[\u0591-\u05C7]/g, ""); } // Default dates: From 7 days ago to today const todayDate = new Date(); const lastWeekDate = new Date(); lastWeekDate.setDate(todayDate.getDate() - 7); document.getElementById('from-date').value = lastWeekDate.toISOString().split('T')[0]; document.getElementById('to-date').value = todayDate.toISOString().split('T')[0]; const savedToken = localStorage.getItem('api_token_cached'); if (savedToken) document.getElementById('api-token').value = savedToken; let currentData = []; let filteredData = []; let sortDirections = Array(12).fill(true); let hasEnterId = true; let currentPage = 1; let rowsPerPage = 20; let expandedRows = new Set(); const extensionTitlesCache = {}; // --- Core Logic --- function clearToken() { if (confirm("האם למחוק את הטוקן השמור?")) { localStorage.removeItem('api_token_cached'); document.getElementById('api-token').value = ''; } } async function fetchData() { const fromDate = document.getElementById('from-date').value; const toDate = document.getElementById('to-date').value; const token = document.getElementById('api-token').value; if (!fromDate || !toDate || !token) { alert("נא להזין את כל הפרטים"); return; } localStorage.setItem('api_token_cached', token); const overlay = document.getElementById('loadingOverlay'); const statusText = document.getElementById('loadingStatus'); const progressBar = document.getElementById('loadingProgress'); overlay.classList.remove('hidden'); const dates = getDatesRange(new Date(fromDate), new Date(toDate)); const dataMap = {}; hasEnterId = true; try { for (let i = 0; i < dates.length; i++) { const date = dates[i]; statusText.innerText = `מושך נתונים: ${date}`; progressBar.style.width = `${Math.round(((i + 1) / dates.length) * 100)}%`; const url = `https://www.call2all.co.il/ym/api/RenderYMGRFile?token=${token}&wath=ivr2:/Log/LogPlaybackPlayStop/LogPlaybackPlayStop.${date}.ymgr&convertType=csv¬LoadLang=1`; const response = await fetch(url); if (response.ok) { const csv = await response.text(); processCsvSegment(csv, dataMap, date); } } // Fetch Extension Titles const uniquePaths = [...new Set(Object.values(dataMap).map(item => item.info.Folder))]; await fetchExtensionTitles(uniquePaths, token); finalizeData(dataMap); } catch (err) { alert(`שגיאה: ${err.message}`); } finally { overlay.classList.add('hidden'); } } async function fetchExtensionTitles(paths, token) { const statusText = document.getElementById('loadingStatus'); const parents = new Set(); paths.forEach(p => { if (!p) return; const parts = p.split('/').filter(x => x); if (parts.length === 0) return; if (parts.length === 1) { parents.add("/"); } else { parts.pop(); parents.add(parts.join('/')); } }); const parentPaths = Array.from(parents); for (let i = 0; i < parentPaths.length; i++) { const pPath = parentPaths[i]; statusText.innerText = `מעדכן שמות שלוחות: ${pPath}`; try { const url = `https://www.call2all.co.il/ym/api/GetIVR2Dir?token=${token}&path=${pPath}`; const response = await fetch(url); if (response.ok) { const res = await response.json(); if (res.dirs && Array.isArray(res.dirs)) { res.dirs.forEach(d => { const fullPath = (pPath === "/" || pPath === "") ? d.name : `${pPath}/${d.name}`; if (d.extTitle) { extensionTitlesCache[fullPath] = d.extTitle; } }); } } } catch (e) { console.error(`Error fetching titles for ${pPath}:`, e); } } } function processCsvSegment(text, dataMap, date) { const rows = text.replace(/^\uFEFF/, "").trim().split(/\r?\n/).map(r => r.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/)); if (rows.length < 2) return; const headers = rows[0].map(h => h.replace(/"/g, '').trim()); if (!headers.includes("EnterId")) hasEnterId = false; for (let i = 1; i < rows.length; i++) { let row = {}; headers.forEach((h, idx) => row[h] = (rows[i][idx] || "").replace(/"/g, '').trim()); if (!row["FileLength"] || row["FileLength"] === "0:0:0") continue; // Normalize folder path row["Folder"] = (row["Folder"] || "").replace(/^\/+|\/+$/g, ''); const flenSec = timeToSeconds(row["FileLength"]); const identityKey = hasEnterId ? row["EnterId"] : row["Phone"]; const key = `${identityKey}_${row["Folder"]}_${row["Current"]}_${row["FileLength"]}`; if (!dataMap[key]) { dataMap[key] = { info: row, intervals: [], flenSec: flenSec, rawSessions: [] }; } let start = (parseInt(row["PositionPlay"]) || 0) / 1000; let stop = (isNaN(parseInt(row["PositionStop"])) || parseInt(row["PositionStop"]) === -1) ? flenSec : parseInt(row["PositionStop"]) / 1000; dataMap[key].intervals.push([start, stop]); dataMap[key].rawSessions.push({ date: date, start: start, stop: stop, duration: stop - start, time: row["Time"] || "" }); } } function finalizeData(dataMap) { currentData = Object.values(dataMap).map(item => { const netSec = mergeIntervals(item.intervals); const grossSec = item.rawSessions.reduce((acc, s) => acc + s.duration, 0); // Find last session const last = item.rawSessions.reduce((prev, current) => { const prevDt = `${prev.date} ${prev.time}`; const currDt = `${current.date} ${current.time}`; return currDt > prevDt ? current : prev; }); return { ...item, netSec: netSec, grossSec: grossSec, lastDate: last.date, lastTime: last.time, percent: item.flenSec > 0 ? Math.min(100, Math.round((netSec / item.flenSec) * 100)) : 0 }; }); if (currentData.length > 0) { setupTableStructure(); updateStats(); updateInsights(); applyMultiFilter(); document.getElementById('results-table').classList.remove('hidden'); document.getElementById('emptyState').classList.add('hidden'); document.getElementById('statsContainer').classList.remove('hidden'); document.getElementById('insightsContainer').classList.remove('hidden'); document.getElementById('export-btn').classList.remove('hidden'); document.getElementById('pagination-container').classList.remove('hidden'); document.getElementById('statusIndicator').innerHTML = `<span class="w-2 h-2 rounded-full bg-emerald-500"></span> נתונים נטענו`; } else { alert("לא נמצאו נתונים לטווח זה"); } } function updateInsights() { // Top Files const fileStats = {}; currentData.forEach(d => { const key = `${d.info.Folder} > ${d.info.Current}`; if (!fileStats[key]) fileStats[key] = { name: d.info.Current, folder: d.info.Folder, totalNet: 0, count: 0 }; fileStats[key].totalNet += d.netSec; fileStats[key].count++; }); const topFiles = Object.values(fileStats).sort((a, b) => b.totalNet - a.totalNet).slice(0, 5); const maxFileNet = topFiles[0]?.totalNet || 1; document.getElementById('topFilesList').innerHTML = topFiles.map(f => { const folderTitle = extensionTitlesCache[f.folder] ? ` (${extensionTitlesCache[f.folder]})` : ''; return ` <div class="space-y-1"> <div class="flex justify-between text-sm"> <span class="font-bold text-slate-700 truncate ml-4">${f.name}</span> <span class="text-slate-400 font-mono text-xs">${formatSeconds(f.totalNet)}</span> </div> <div class="w-full bg-slate-100 h-2 rounded-full overflow-hidden"> <div class="bg-indigo-500 h-full" style="width: ${(f.totalNet / maxFileNet) * 100}%"></div> </div> <div class="text-[10px] text-slate-400">${f.folder}${folderTitle} | ${f.count} מאזינים</div> </div> `}).join(''); // Top Listeners const listenerStats = {}; currentData.forEach(d => { const id = hasEnterId ? d.info.EnterId : d.info.Phone; const name = hasEnterId ? d.info.ValName : d.info.Phone; if (!listenerStats[id]) listenerStats[id] = { name: name, id: id, totalNet: 0 }; listenerStats[id].totalNet += d.netSec; }); const topListeners = Object.values(listenerStats).sort((a, b) => b.totalNet - a.totalNet).slice(0, 5); const maxListenerNet = topListeners[0]?.totalNet || 1; document.getElementById('topListenersList').innerHTML = topListeners.map(l => ` <div class="space-y-1"> <div class="flex justify-between text-sm"> <div class="flex flex-col"> <span class="font-bold text-slate-700">${l.name}</span> <span class="text-[10px] text-slate-400 font-mono">${l.id}</span> </div> <span class="text-amber-600 font-mono text-xs font-bold">${formatSeconds(l.totalNet)}</span> </div> <div class="w-full bg-slate-50 h-2 rounded-full overflow-hidden"> <div class="bg-amber-400 h-full" style="width: ${(l.totalNet / maxListenerNet) * 100}%"></div> </div> </div> `).join(''); } function setupTableStructure() { const headerRow = document.getElementById('header-row'); const filterRow = document.getElementById('filter-row'); const commonHeaders = ` <th class="px-4 py-3 cursor-pointer" onclick="sortTable('folder')">שלוחה ↕</th> <th class="px-4 py-3 cursor-pointer" onclick="sortTable('file')">קובץ ↕</th> <th class="px-4 py-3 text-center cursor-pointer" onclick="sortTable('last')">האזנה אחרונה ↕</th> <th class="px-4 py-3 text-center cursor-pointer" onclick="sortTable('length')">אורך ↕</th> <th class="px-4 py-3 text-center cursor-pointer" onclick="sortTable('net')">נטו ↕</th> <th class="px-4 py-3 text-center cursor-pointer" onclick="sortTable('gross')">ברוטו ↕</th> <th class="px-4 py-3 text-center cursor-pointer" onclick="sortTable('percent')">אחוז ↕</th> <th class="px-4 py-3 text-center">פעולות</th> `; const commonFilters = ` <th class="px-2 py-2"><input type="text" id="f-folder" oninput="applyMultiFilter()" placeholder="סנן שלוחה..."></th> <th class="px-2 py-2"><input type="text" id="f-file" oninput="applyMultiFilter()" placeholder="סנן קובץ..."></th> <th></th><th></th><th></th><th></th><th></th><th></th> `; if (hasEnterId) { headerRow.innerHTML = `<th class="px-4 py-3 cursor-pointer" onclick="sortTable('school')">כיתה ↕</th><th class="px-4 py-3 cursor-pointer" onclick="sortTable('name')">שם וזיהוי ↕</th>` + commonHeaders; filterRow.innerHTML = `<th class="px-2 py-2"><input type="text" id="f-school" oninput="applyMultiFilter()" placeholder="סנן כיתה..."></th><th class="px-2 py-2"><input type="text" id="f-name" oninput="applyMultiFilter()" placeholder="סנן שם או זיהוי..."></th>` + commonFilters; } else { headerRow.innerHTML = `<th class="px-4 py-3 cursor-pointer" onclick="sortTable('name')">טלפון ↕</th>` + commonHeaders; filterRow.innerHTML = `<th class="px-2 py-2"><input type="text" id="f-name" oninput="applyMultiFilter()" placeholder="סנן טלפון..."></th>` + commonFilters; } } function renderTable() { const tbody = document.getElementById('table-body'); const totalItems = filteredData.length; const startIdx = (currentPage - 1) * rowsPerPage; const pageData = filteredData.slice(startIdx, startIdx + rowsPerPage); let html = ''; pageData.forEach((item, idx) => { const colorClass = item.percent > 80 ? 'bg-green-gradient' : (item.percent > 40 ? 'bg-orange-gradient' : 'bg-red-gradient'); const textClass = item.percent > 80 ? 'text-emerald-600' : (item.percent > 40 ? 'text-amber-600' : 'text-rose-600'); const rowId = `row-${startIdx + idx}`; const isExpanded = expandedRows.has(rowId); const hasDuplicates = item.rawSessions.length > 1; let identityCell = ''; if (hasEnterId) { identityCell = `<td class="px-4 py-3 text-slate-500">${item.info.School || '-'}</td> <td class="px-4 py-3"> <div class="font-bold text-slate-800">${item.info.ValName}</div> <div class="text-[10px] text-slate-400">${item.info.EnterId}</div> </td>`; } else { identityCell = `<td class="px-4 py-3 font-bold text-slate-800">${item.info.Phone}</td>`; } html += ` <tr class="hover:bg-slate-50 transition-colors"> ${identityCell} <td class="px-4 py-3"> <div class="flex flex-col"> <span class="bg-slate-100 px-2 py-1 rounded text-[10px] font-bold text-slate-500 w-fit">${item.info.Folder}</span> ${extensionTitlesCache[item.info.Folder] ? `<span class="text-[10px] text-indigo-600 font-medium mt-1 leading-tight">${extensionTitlesCache[item.info.Folder]}</span>` : ''} </div> </td> <td class="px-4 py-3 text-slate-600 truncate max-w-[150px]">${item.info.Current}</td> <td class="px-4 py-3 text-center"> <div class="text-[11px] font-bold text-slate-700">${item.lastDate}</div> <div class="text-[10px] text-slate-400 font-mono">${item.lastTime}</div> </td> <td class="px-4 py-3 text-center font-mono text-slate-400">${formatSeconds(item.flenSec)}</td> <td class="px-4 py-3 text-center font-mono text-indigo-600 font-bold">${formatSeconds(item.netSec)}</td> <td class="px-4 py-3 text-center font-mono text-slate-400 text-xs">${formatSeconds(item.grossSec)}</td> <td class="px-4 py-3"> <div class="flex items-center gap-3 min-w-[100px]"> <div class="flex-1 bg-slate-100 rounded-full h-1.5 overflow-hidden"> <div class="progress-fill h-full ${colorClass}" style="width: ${item.percent}%"></div> </div> <span class="text-xs font-bold ${textClass}">${item.percent}%</span> </div> </td> <td class="px-4 py-3 text-center"> <button onclick="toggleRow('${rowId}')" class="flex items-center gap-1 mx-auto px-3 py-1.5 rounded-lg text-[10px] font-bold transition-all shadow-sm ${hasDuplicates ? 'bg-indigo-600 text-white hover:bg-indigo-700' : 'bg-slate-100 text-slate-300 cursor-default'}"> <i data-lucide="${isExpanded ? 'chevron-up' : 'list-plus'}" class="w-3.5 h-3.5"></i> ${isExpanded ? 'סגור' : 'פירוט האזנות'} </button> </td> </tr> `; if (isExpanded) { html += ` <tr class="detail-row animate-fade-in"> <td colspan="12" class="px-8 py-4"> <div class="bg-white rounded-xl border border-indigo-100 shadow-sm overflow-hidden"> <div class="bg-indigo-50 px-4 py-2 text-[10px] font-bold text-indigo-600 border-b border-indigo-100">פירוט האזנות כפולות / חוזרות</div> <table class="w-full text-[11px] text-right"> <thead class="bg-slate-50 text-slate-400 font-bold uppercase"> <tr> <th class="px-4 py-2">תאריך</th> <th class="px-4 py-2">שעה</th> <th class="px-4 py-2">התחלה בקובץ</th> <th class="px-4 py-2">סיום בקובץ</th> <th class="px-4 py-2">משך האזנה</th> </tr> </thead> <tbody class="divide-y divide-slate-50"> ${item.rawSessions.map(s => ` <tr> <td class="px-4 py-2">${s.date}</td> <td class="px-4 py-2">${s.time}</td> <td class="px-4 py-2 font-mono">${formatSeconds(s.start)}</td> <td class="px-4 py-2 font-mono">${formatSeconds(s.stop)}</td> <td class="px-4 py-2 font-bold text-indigo-600">${formatSeconds(s.duration)}</td> </tr> `).join('')} </tbody> </table> </div> </td> </tr> `; } }); tbody.innerHTML = html || `<tr><td colspan="12" class="p-10 text-center text-slate-400">אין תוצאות</td></tr>`; renderPaginationControls(totalItems, Math.ceil(totalItems / rowsPerPage)); lucide.createIcons(); } function toggleRow(id) { if (expandedRows.has(id)) expandedRows.delete(id); else expandedRows.add(id); renderTable(); } function applyMultiFilter() { const search = removeNikud(document.getElementById('searchInput').value.toLowerCase()); const minP = parseInt(document.getElementById('filter-percent').value) || 0; const fSchool = removeNikud(document.getElementById('f-school')?.value.toLowerCase() || ""); const fName = removeNikud(document.getElementById('f-name')?.value.toLowerCase() || ""); const fFolder = removeNikud(document.getElementById('f-folder')?.value.toLowerCase() || ""); const fFile = removeNikud(document.getElementById('f-file')?.value.toLowerCase() || ""); filteredData = currentData.filter(item => { const tSchool = removeNikud((item.info.School || "").toLowerCase()); const tName = removeNikud((hasEnterId ? (item.info.ValName || "") : item.info.Phone).toLowerCase()); const tFolder = removeNikud(item.info.Folder.toLowerCase()); const tFolderTitle = removeNikud((extensionTitlesCache[item.info.Folder] || "").toLowerCase()); const tFile = removeNikud(item.info.Current.toLowerCase()); const tId = removeNikud((item.info.EnterId || "").toLowerCase()); const matchSearch = tName.includes(search) || tFolder.includes(search) || tFile.includes(search) || tId.includes(search) || tFolderTitle.includes(search); const matchPercent = item.percent >= minP; const matchSpecific = tSchool.includes(fSchool) && (tName.includes(fName) || tId.includes(fName)) && (tFolder.includes(fFolder) || tFolderTitle.includes(fFolder)) && tFile.includes(fFile); return matchSearch && matchPercent && matchSpecific; }); document.getElementById('statFiltered').innerText = `${filteredData.length.toLocaleString()} רשומות מוצגות`; currentPage = 1; renderTable(); } function updateStats() { const total = currentData.length; const avg = total > 0 ? Math.round(currentData.reduce((s, d) => s + d.percent, 0) / total) : 0; const netSec = currentData.reduce((s, d) => s + d.netSec, 0); const grossSec = currentData.reduce((s, d) => s + d.grossSec, 0); document.getElementById('statTotal').innerText = total.toLocaleString(); document.getElementById('statAvg').innerText = `${avg}%`; document.getElementById('statNetHours').innerText = Math.round(netSec / 3600).toLocaleString(); document.getElementById('statGrossHours').innerText = Math.round(grossSec / 3600).toLocaleString(); } function renderPaginationControls(totalItems, totalPages) { const container = document.getElementById('page-numbers'); const info = document.getElementById('pagination-info'); if (totalItems === 0) { container.innerHTML = ''; info.innerText = ''; return; } info.innerText = `מציג ${((currentPage-1)*rowsPerPage)+1}-${Math.min(currentPage*rowsPerPage, totalItems)} מתוך ${totalItems}`; let btns = `<button onclick="changePage(${currentPage-1})" class="page-btn" ${currentPage===1?'disabled':''}><i data-lucide="chevron-right" class="w-4 h-4"></i></button>`; for (let i = 1; i <= totalPages; i++) { if (i === 1 || i === totalPages || (i >= currentPage - 2 && i <= currentPage + 2)) { btns += `<button onclick="changePage(${i})" class="page-btn ${i===currentPage?'active':''}">${i}</button>`; } else if (i === currentPage - 3 || i === currentPage + 3) { btns += `<span class="px-1 text-slate-300">...</span>`; } } btns += `<button onclick="changePage(${currentPage+1})" class="page-btn" ${currentPage===totalPages?'disabled':''}><i data-lucide="chevron-left" class="w-4 h-4"></i></button>`; container.innerHTML = btns; } function changePage(p) { currentPage = p; renderTable(); } function updateRowsPerPage() { rowsPerPage = parseInt(document.getElementById('rows-per-page').value); currentPage = 1; renderTable(); } function sortTable(key) { const idx = ['school','name','folder','file','last','length','net','gross','percent'].indexOf(key); sortDirections[idx] = !sortDirections[idx]; const dir = sortDirections[idx] ? 1 : -1; currentData.sort((a, b) => { let v1, v2; if (key === 'school') { v1 = a.info.School || ""; v2 = b.info.School || ""; } else if (key === 'name') { v1 = (hasEnterId ? a.info.ValName : a.info.Phone) || ""; v2 = (hasEnterId ? b.info.ValName : b.info.Phone) || ""; } else if (key === 'folder') { v1 = a.info.Folder; v2 = b.info.Folder; } else if (key === 'file') { v1 = a.info.Current; v2 = b.info.Current; } else if (key === 'last') { v1 = `${a.lastDate} ${a.lastTime}`; v2 = `${b.lastDate} ${b.lastTime}`; } else if (key === 'length') { v1 = a.flenSec; v2 = b.flenSec; } else if (key === 'net') { v1 = a.netSec; v2 = b.netSec; } else if (key === 'gross') { v1 = a.grossSec; v2 = b.grossSec; } else if (key === 'percent') { v1 = a.percent; v2 = b.percent; } return v1 < v2 ? -1 * dir : (v1 > v2 ? 1 * dir : 0); }); applyMultiFilter(); } function timeToSeconds(t){ const p = t.trim().split(':').map(Number); return p.length === 3 ? p[0]*3600 + p[1]*60 + p[2] : (p.length === 2 ? p[0]*60 + p[1] : 0); } function formatSeconds(s){ let h=Math.floor(s/3600), m=Math.floor((s%3600)/60), sec=Math.floor(s%60); return `${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}:${sec.toString().padStart(2,'0')}`; } function mergeIntervals(arr){ if(!arr.length) return 0; arr.sort((a,b) => a[0]-b[0]); let total = 0, [start, end] = arr[0]; for(let i=1; i<arr.length; i++){ if(arr[i][0] <= end) end = Math.max(end, arr[i][1]); else { total += (end - start); [start, end] = arr[i]; } } return total + (end - start); } function getDatesRange(s,e){ const d=[]; let c=new Date(s); while(c<=e){ d.push(c.toISOString().split('T')[0]); c.setDate(c.getDate()+1); } return d; } function exportToExcel() { const exportedData = filteredData.map(item => { let obj = {}; if (hasEnterId) { obj["כיתה"] = item.info.School || '-'; obj["שם תלמיד"] = item.info.ValName || ''; obj["זיהוי"] = item.info.EnterId || ''; obj["שלוחה"] = item.info.Folder + (extensionTitlesCache[item.info.Folder] ? ` (${extensionTitlesCache[item.info.Folder]})` : ''); obj["שם הקובץ"] = item.info.Current; obj["תאריך האזנה אחרונה"] = item.lastDate; obj["שעת האזנה אחרונה"] = item.lastTime; obj["אורך קובץ"] = formatSeconds(item.flenSec); obj["האזנה נטו"] = formatSeconds(item.netSec); obj["האזנה ברוטו"] = formatSeconds(item.grossSec); obj["אחוז האזנה"] = item.percent + '%'; } else { obj["טלפון"] = item.info.Phone; obj["שלוחה"] = item.info.Folder + (extensionTitlesCache[item.info.Folder] ? ` (${extensionTitlesCache[item.info.Folder]})` : ''); obj["שם הקובץ"] = item.info.Current; obj["תאריך האזנה אחרונה"] = item.lastDate; obj["שעת האזנה אחרונה"] = item.lastTime; obj["אורך קובץ"] = formatSeconds(item.flenSec); obj["האזנה נטו"] = formatSeconds(item.netSec); obj["האזנה ברוטו"] = formatSeconds(item.grossSec); obj["אחוז האזנה"] = item.percent + '%'; } return obj; }); const ws = XLSX.utils.json_to_sheet(exportedData); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, "דוח האזנה"); XLSX.writeFile(wb, `דוח_האזנה_${new Date().toISOString().slice(0,10)}.xlsx`); } </script> </body> </html>