תגובה | דוח ניתוח האזנה
-
בנוגע למה שעשה @ע.ג. בדוח נתוני האזנה.
עשיתי שינויים קלים לצרכיי, ואני משתף את זה.
הוספה;- שמות השלוחות.
- חיפוש חופשי (לא יודע אם זה נוגע לכולם, אבל לי אישית זה הועיל).
- קבצים/מאזינים מובילים.
- במידה ומישהו שמע כמה פעמים קובץ, יש אפשרות לראות את מספר הפעמים.
- חלוקה של הדוח לדפים (ברירת מחדל 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>
שלום! נראה שהשיחה הזו מעניינת אותך, אבל עדיין אין לך חשבון.
נמאס לכם לגלול בין אותם הפוסטים בכל ביקור? כשנרשמים לחשבון, תמיד תחזרו בדיוק למקום שבו הייתם קודם, ותוכלו לבחור לקבל התראות על תגובות חדשות (בין אם במייל, ובין אם בהתראת פוש). תוכלו גם לשמור סימניות ולפרגן ב-upvote לפוסטים כדי להביע הערכה לחברי קהילה אחרים.
בעזרת התרומה שלך, הפוסט הזה יכול להיות אפילו טוב יותר 💗
הרשמה התחברות