Created
February 2, 2026 21:54
-
-
Save dzigner/2e53d5d25d2e5f16a4d3050daea39dc7 to your computer and use it in GitHub Desktop.
Shehrullah Dashboard
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| function getAdminData() { | |
| const ss = SpreadsheetApp.openById(DATA_SHEET_ID); | |
| // Stats & Behnos from AvailableCapacity - Filter out empty rows | |
| // Ensure Quotas are fresh before returning stats | |
| const calcResult = forceRecalculate(); | |
| const calculatedQuotas = calcResult.quotas || { daily: 0, lf: 0 }; | |
| // Re-fetch updated rows after calculation | |
| const fresh = fetchTabRows(ss, TABS.AVAILABLE_CAPACITY); | |
| let behnos = fresh.rows.filter(r => (r.Email || r.email || '').trim() !== ''); | |
| // Sort by HOFIDs (Family Grouping) - Ensure families stay together | |
| // Secondary sort: preserve sheet order (sign up order) | |
| behnos = behnos.map((r, i) => ({...r, _originalIndex: i})); | |
| behnos.sort((a, b) => { | |
| const idA = parseInt(String(a.HOFIDs || a.hofids || '0').replace(/\D/g, '')) || 0; | |
| const idB = parseInt(String(b.HOFIDs || b.hofids || '0').replace(/\D/g, '')) || 0; | |
| // Primary Sort: Group by Family (HOFID) | |
| if (idA !== idB) { | |
| // If one has ID and other doesn't (0), decide priority? | |
| // User Rule: "Families together". | |
| // Simple numeric sort groups non-families (0) at top or bottom. | |
| // Let's keep 0s at the end or treated as their own group? | |
| // Standard integer sort matches HOFIDs. | |
| return idA - idB; | |
| } | |
| // Secondary Sort: Signup Order (Original Index) | |
| // If same HOFID (family members), OR both are 0 (individuals), | |
| // sort by who occurs first in the sheet. | |
| return a._originalIndex - b._originalIndex; | |
| }); | |
| // Cleanup temp property | |
| behnos.forEach(b => delete b._originalIndex); | |
| const totalBehnos = behnos.length; | |
| // Use the REAL calculated quotas (Standard) instead of naive averages | |
| const dailyQuota = calculatedQuotas.daily; | |
| const lfQuota = calculatedQuotas.lf; | |
| return { | |
| stats: { | |
| total: totalBehnos, | |
| dailyQuota: dailyQuota, | |
| lfQuota: lfQuota | |
| }, | |
| behnos: behnos, | |
| /* ADDED BY ANTIGRAVITY: Including current active phase for UI feedback */ | |
| activePhase: PropertiesService.getScriptProperties().getProperty('ACTIVE_PHASE') || '', | |
| phaseSchedule: getPhaseSchedule(), /* ADDED: Include Schedule */ | |
| scriptUrl: ScriptApp.getService().getUrl(), | |
| currentUserEmail: Session.getActiveUser().getEmail(), /* ADDED: For navigation persistence */ | |
| /* END OF ADDITION */ | |
| dailyJagah: getJagahData('Daily'), | |
| lfJagah: getJagahData('LF') | |
| }; | |
| } | |
| function getJagahData(type) { | |
| const ss = SpreadsheetApp.openById(DATA_SHEET_ID); | |
| const jagahTab = (type === 'Daily') ? TABS.DAILY_JAGAH : TABS.LF_JAGAH; | |
| const jagahSheet = ss.getSheetByName(jagahTab); | |
| if (!jagahSheet) return { seats: [], dates: [], mapping: {} }; | |
| const allData = jagahSheet.getDataRange().getDisplayValues(); | |
| if (allData.length < 1) return { seats: [], dates: [], mapping: {} }; | |
| // 1. Get Seats from Row 1 (Columns B-O, index 1-14) | |
| const headers = allData[0]; | |
| const seats = []; | |
| headers.forEach((h, i) => { | |
| if (i > 0 && i <= 14 && String(h).trim()) { | |
| seats.push(String(h).trim()); | |
| } | |
| }); | |
| // 2. Get Dates from Column A (Filter based on type/phase) | |
| const dates = []; | |
| const startRow = 1; | |
| const endRow = allData.length; | |
| for (let r = startRow; r < endRow; r++) { | |
| const dVal = String(allData[r][0]).trim(); | |
| if (dVal && dVal !== 'null') { | |
| dates.push(dVal); | |
| } | |
| } | |
| // 3. Mapping assignments: { "Date|Seat": "Name" } | |
| const mapping = {}; | |
| for (let r = 1; r < allData.length; r++) { | |
| const dStr = String(allData[r][0]).trim(); | |
| if (!dStr) continue; | |
| seats.forEach((s, idx) => { | |
| // s is seat name, but we need colIndex. | |
| // seatColumns are index 1 to 14. | |
| // The 'seats' array contains names. We can find colIndex. | |
| const colIdx = headers.indexOf(s); | |
| if (colIdx > -1) { | |
| const val = String(allData[r][colIdx]).trim(); | |
| if (val) mapping[dStr + "|" + s] = val; | |
| } | |
| }); | |
| } | |
| return { | |
| seats: seats, | |
| dates: dates, | |
| mapping: mapping | |
| }; | |
| } | |
| function updateAdminValue(email, field, value) { | |
| const ss = SpreadsheetApp.openById(DATA_SHEET_ID); | |
| const sheet = ss.getSheetByName(TABS.AVAILABLE_CAPACITY); | |
| const data = sheet.getDataRange().getValues(); | |
| const headers = data[0]; | |
| const emailIdx = headers.indexOf('Email'); | |
| if (emailIdx === -1) throw new Error("Email column not found"); | |
| const rowIdx = data.slice(1).findIndex(r => String(r[emailIdx]).toLowerCase() === String(email).toLowerCase()); | |
| if (rowIdx === -1) throw new Error("User not found"); | |
| const colIdx = headers.indexOf(field); | |
| if (colIdx === -1) throw new Error("Column " + field + " not found"); | |
| // Update the value | |
| sheet.getRange(rowIdx + 2, colIdx + 1).setValue(value); | |
| // If updating AdminDaily or AdminLF, recalculate quotas for all users | |
| if (field.toLowerCase().includes('admin')) { | |
| recalculateQuotas(sheet, headers); | |
| } | |
| return { success: true }; | |
| } | |
| function recalculateQuotas(sheet, headers) { | |
| const allData = sheet.getDataRange().getValues(); | |
| const hIdx = findHeaderRow(allData); | |
| const headerRow = allData[hIdx].map(h => String(h).trim().toLowerCase()); | |
| const emailIdx = headerRow.indexOf('email'); | |
| if (emailIdx === -1) return; | |
| const validRows = []; // { data: row, rowIndex: relativeIndexFromHeader } | |
| // 1. Identify valid rows (skip header) | |
| for (let i = hIdx + 1; i < allData.length; i++) { | |
| const row = allData[i]; | |
| if (row[emailIdx] && String(row[emailIdx]).trim() !== '') { | |
| validRows.push({ row: row, index: i }); | |
| } | |
| } | |
| if (validRows.length === 0) return; | |
| const findC = (list) => { | |
| for (let t of list) { | |
| let i = headerRow.indexOf(t.toLowerCase()); | |
| if (i !== -1) return i; | |
| } | |
| return -1; | |
| }; | |
| const dailyQuotaIdx = findC(['DailyQuota', 'QuotaDaily', 'Daily Quota']); | |
| const dailyUsedIdx = findC(['DailyUsed', 'UsedDaily']); | |
| const dailyLeftIdx = findC(['DailyLeft', 'LeftDaily']); | |
| const adminDailyIdx = findC(['DailyAdmin', 'AdminDaily', 'Admin Daily']); | |
| const lfQuotaIdx = findC(['LFQuota', 'QuotaLF', 'LF Quota']); | |
| const lfUsedIdx = findC(['LFUsed', 'UsedLF']); | |
| const lfLeftIdx = findC(['LeftLF', 'LFLeft']); | |
| const adminLFIdx = findC(['LFAdmin', 'AdminLF', 'Admin LF']); | |
| if (dailyQuotaIdx === -1 || lfQuotaIdx === -1) return; | |
| // 1. Fetch Used Assignments | |
| const getUsedMap = (tab) => { | |
| const s = sheet.getParent().getSheetByName(tab); | |
| if (!s) return {}; | |
| const d = s.getDataRange().getDisplayValues(); // Display values for safety | |
| const h = (d[0] || []).map(c => String(c).toLowerCase().trim()); // Assume row 1 header for Grids as per Seats.gs | |
| // Find email col in grid? No, grids usually have Name. | |
| // Wait, earlier logic used Name to match. Now we switched to Email? | |
| // "seats.gs" writes UserName to grid. | |
| // So we must match by UserName (Normalised). | |
| // BUT "UserMaster" or "AvailableCapacity" defines the Name. | |
| // Let's assume the name in AvailableCapacity matches what's written to Grid. | |
| const map = {}; | |
| for(let r=1; r<d.length; r++) { | |
| for(let c=1; c<d[0].length; c++) { | |
| const val = String(d[r][c]).trim().toLowerCase(); // Normalize check | |
| if(val) map[val] = (map[val] || 0) + 1; | |
| } | |
| } | |
| return map; | |
| }; | |
| const dailyUsedMap = getUsedMap(TABS.DAILY_JAGAH); | |
| const lfUsedMap = getUsedMap(TABS.LF_JAGAH); | |
| const totalDailyPots = 14 * 24; | |
| const totalLFPots = 14 * 7; | |
| // 2. Calculate Stats | |
| let totalAdminDaily = 0; | |
| let totalAdminLF = 0; | |
| let usersWithoutAdminDaily = 0; | |
| let usersWithoutAdminLF = 0; | |
| validRows.forEach(item => { | |
| const row = item.row; | |
| const adminDaily = (adminDailyIdx > -1) ? row[adminDailyIdx] : ''; | |
| const adminLF = (adminLFIdx > -1) ? row[adminLFIdx] : ''; | |
| if (adminDaily !== '' && adminDaily !== null && adminDaily !== undefined) totalAdminDaily += Number(adminDaily); | |
| else usersWithoutAdminDaily++; | |
| if (adminLF !== '' && adminLF !== null && adminLF !== undefined) totalAdminLF += Number(adminLF); | |
| else usersWithoutAdminLF++; | |
| }); | |
| /* REMOVED: Duplicate definition that caused SyntaxError. | |
| The constants were already defined at lines 194-195. */ | |
| // LOGIC FIX: If everyone has admin quota, don't divide by 0. | |
| // If no one has admin quota, divide total by count. | |
| // If count is 0? (No eligible users). | |
| let adjustedDailyQuota = 0; | |
| if (usersWithoutAdminDaily > 0) { | |
| const availablePool = Math.max(0, totalDailyPots - totalAdminDaily); | |
| adjustedDailyQuota = Math.floor(availablePool / usersWithoutAdminDaily); | |
| } | |
| let adjustedLFQuota = 0; | |
| if (usersWithoutAdminLF > 0) { | |
| const availablePool = Math.max(0, totalLFPots - totalAdminLF); | |
| adjustedLFQuota = Math.floor(availablePool / usersWithoutAdminLF); | |
| } | |
| // Safeguard: Ensure at least 1 if pots available and user count low? | |
| // No, strict math is better. | |
| Logger.log(`Recalc: Users=${validRows.length}, NoAdminDaily=${usersWithoutAdminDaily}, AdjDaily=${adjustedDailyQuota}`); | |
| // 3. Prepare Updates | |
| // We will update the sheet row by row to be safe with disjointed rows, | |
| // OR we can read the whole range and write back whole range. | |
| // Writing back whole range is better but might overwrite concurrent edits if not careful. | |
| // Given we are recalculating logic columns, let's write back logic columns only? No, bulk read/write all is easiest. | |
| // Actually, 'allData' is already read. We can update 'allData' in memory and write back. | |
| // BUT validRows only modifies specific rows. | |
| // Let's iterate validRows and use setValues on specific ranges if we want to be safe, or modify 'allData' and write all. | |
| // Writing ALL overwrites user flags if changed in between. | |
| // Safe approach: Iterate validRows and Update ONLY the calculated columns. | |
| // To optimize, we can group updates if rows are contiguous, but simple loop with setValues is sturdy enough for <500 rows. | |
| // Better: Create a 2D array for just the columns we are touching? No, columns are scattered. | |
| // Let's write row-by-row for now to guarantee correctness of index. | |
| const nameIdx = findC(['Name','FullName', 'name', 'fullname']); // For matching Used Map | |
| validRows.forEach(item => { | |
| const row = item.row; | |
| const rowNum = item.index + 1; // 1-based row number | |
| const adminDaily = (adminDailyIdx > -1) ? row[adminDailyIdx] : ''; | |
| const dQ = (adminDaily !== '' && adminDaily !== null && adminDaily !== undefined) ? Number(adminDaily) : adjustedDailyQuota; | |
| const adminLF = (adminLFIdx > -1) ? row[adminLFIdx] : ''; | |
| const lfQ = (adminLF !== '' && adminLF !== null && adminLF !== undefined) ? Number(adminLF) : adjustedLFQuota; | |
| // Usage | |
| // We match by Name (from AvailableCapacity row) against the Grid Map keys | |
| const nameVal = (nameIdx > -1) ? String(row[nameIdx]).trim().toLowerCase() : ''; | |
| const emailVal = String(row[emailIdx]).trim().toLowerCase(); | |
| // Try matching by Name first (as Grid has Names). | |
| // If Grid has IDs/Emails, switch to that. | |
| // seats.gs writes 'userName'. so we use nameVal. | |
| const dUsed = dailyUsedMap[nameVal] || dailyUsedMap[emailVal] || 0; | |
| const lUsed = lfUsedMap[nameVal] || lfUsedMap[emailVal] || 0; | |
| // Update Sheet | |
| // We update: DailyQuota, DailyUsed, DailyLeft, LFQuota, LFUsed, LFLeft | |
| // We can do this in one setValues call if columns are adjacent? Likely not. | |
| // Individual setValue is slow. | |
| // OPTIMIZATION: Write to 'allData' array, then dump the entire 'allData' back to sheet? | |
| // Risk: Overwriting other columns if user edited them while script ran. | |
| // Compromise: Update 'allData' and write back. It's standard for recalculations. | |
| if (dailyQuotaIdx > -1) allData[item.index][dailyQuotaIdx] = dQ; | |
| if (dailyUsedIdx > -1) allData[item.index][dailyUsedIdx] = dUsed; | |
| if (dailyLeftIdx > -1) allData[item.index][dailyLeftIdx] = dQ - dUsed; | |
| if (lfQuotaIdx > -1) allData[item.index][lfQuotaIdx] = lfQ; | |
| if (lfUsedIdx > -1) allData[item.index][lfUsedIdx] = lUsed; | |
| if (lfLeftIdx > -1) allData[item.index][lfLeftIdx] = lfQ - lUsed; | |
| }); | |
| // Write back the whole block from stats start? | |
| // Let's write back the whole sheet data to ensure consistency. | |
| sheet.getRange(1, 1, allData.length, allData[0].length).setValues(allData); | |
| Logger.log("Recalculate Complete. Processed " + validRows.length + " rows."); | |
| return { daily: adjustedDailyQuota, lf: adjustedLFQuota }; | |
| } | |
| function forceRecalculate() { | |
| const ss = SpreadsheetApp.openById(DATA_SHEET_ID); | |
| const sheet = ss.getSheetByName(TABS.AVAILABLE_CAPACITY); | |
| const allData = sheet.getDataRange().getValues(); | |
| const hIdx = findHeaderRow(allData); | |
| const headers = allData[hIdx]; | |
| const quotas = recalculateQuotas(sheet, headers); | |
| return { success: true, quotas: quotas }; | |
| } | |
| /* ADDED BY ANTIGRAVITY: Phase Activation and Dynamic Options Logic */ | |
| function activatePhase(phaseNum) { | |
| if (!phaseNum) throw new Error("No phase number provided"); | |
| PropertiesService.getScriptProperties().setProperty('ACTIVE_PHASE', String(phaseNum)); | |
| return { success: true, activePhase: phaseNum }; | |
| } | |
| function deactivateAllPhases() { | |
| PropertiesService.getScriptProperties().deleteProperty('ACTIVE_PHASE'); | |
| return { success: true, message: 'All phases deactivated. Seat selection closed for all users.' }; | |
| } | |
| function getPhaseOptions() { | |
| const ss = SpreadsheetApp.openById(DATA_SHEET_ID); | |
| const jagahSheet = ss.getSheetByName(TABS.DAILY_JAGAH); | |
| if (!jagahSheet) return []; | |
| const allData = jagahSheet.getDataRange().getDisplayValues(); | |
| const phases = []; | |
| // Phase mapping (Strict row ranges) | |
| const CONFIG = [ | |
| { id: 1, start: 1, end: 8 }, // A2 to A9 (indices 1-8) | |
| { id: 2, start: 9, end: 16 }, // A10 to A17 | |
| { id: 3, start: 17, end: 24 } // A18 to A25 | |
| ]; | |
| CONFIG.forEach(cfg => { | |
| if (allData[cfg.start] && allData[cfg.start][0]) { | |
| const startLabel = String(allData[cfg.start][0]).trim(); | |
| const endLabel = String(allData[cfg.end] ? allData[cfg.end][0] : allData[allData.length-1][0]).trim(); | |
| phases.push({ | |
| id: cfg.id, | |
| name: "Phase " + cfg.id, | |
| range: startLabel + " - " + endLabel | |
| }); | |
| } | |
| }); | |
| return phases; | |
| } | |
| /* END OF ADDITION */ | |
| /* ADDED BY ANTIGRAVITY: Phase Schedule Storage */ | |
| function savePhaseSchedule(schedule) { | |
| if (!schedule) throw new Error("No schedule data provided"); | |
| PropertiesService.getScriptProperties().setProperty('PHASE_SCHEDULE', JSON.stringify(schedule)); | |
| return { success: true, message: 'Schedule updated successfully' }; | |
| } | |
| function getPhaseSchedule() { | |
| const json = PropertiesService.getScriptProperties().getProperty('PHASE_SCHEDULE'); | |
| try { | |
| return json ? JSON.parse(json) : { p1: '', p2: '', p3: '' }; | |
| } catch (e) { | |
| return { p1: '', p2: '', p3: '' }; | |
| } | |
| } | |
| /* END OF ADDITION */ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <base target="_top"> | |
| <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600&display=swap" rel="stylesheet"> | |
| <?!= include('styles'); ?> | |
| </head> | |
| <body class="admin-layout"> | |
| <!-- Modal System (Styles in styles.html) --> | |
| <div id="modal-overlay" class="modal-overlay"> | |
| <div id="modal-box" class="modal-box"> | |
| <div id="modal-title" class="modal-title">Notice</div> | |
| <div id="modal-message" class="modal-message"></div> | |
| <button class="modal-btn" onclick="closeModal()">OK</button> | |
| </div> | |
| </div> | |
| <script> | |
| let modalCallback = null; | |
| function showModal(title, message, type = 'info', callback = null) { | |
| const overlay = document.getElementById('modal-overlay'); | |
| const box = document.getElementById('modal-box'); | |
| const titleEl = document.getElementById('modal-title'); | |
| const messageEl = document.getElementById('modal-message'); | |
| modalCallback = callback; // Store callback | |
| box.className = 'modal-box'; | |
| if (type === 'error') box.classList.add('modal-error'); | |
| if (type === 'success') box.classList.add('modal-success'); | |
| titleEl.innerText = title; | |
| messageEl.innerHTML = message; | |
| overlay.classList.add('flex'); | |
| overlay.style.display = 'flex'; | |
| } | |
| function closeModal() { | |
| document.getElementById('modal-overlay').style.display = 'none'; | |
| document.getElementById('modal-overlay').classList.remove('flex'); | |
| if (modalCallback) { | |
| modalCallback(); | |
| modalCallback = null; | |
| } | |
| } | |
| function doAdminLogout() { | |
| // Clear any saved data | |
| localStorage.removeItem('shehrullah_email'); | |
| sessionStorage.removeItem('shehrullah_pref_view'); | |
| // Redirect to user dashboard page (index.html) with logout parameter | |
| // Use window.open with _top target to ensure we fully break out of Admin context | |
| google.script.run.withSuccessHandler(function (url) { | |
| const targetUrl = url + '?logout=true'; | |
| window.open(targetUrl, '_top'); | |
| }).getScriptUrl(); | |
| } | |
| </script> | |
| <div class="nav-panel"> | |
| <div class="brand" style="text-align: center; padding: 15px 10px;"> | |
| <img src="https://i.imgur.com/UbBWvAo.png" alt="Shehrullah Header" | |
| style="max-width: 100%; height: auto; display: block; margin: 0 auto;"> | |
| </div> | |
| <div class="nav-item active" onclick="showSection('dashboard', this)"> | |
| 📊 | |
| Dashboard | |
| </div> | |
| <div class="nav-item" onclick="showSection('behnos', this)"> | |
| <img src="https://i.imgur.com/DMNKoCB.png" alt="Behno" | |
| style="width: 20px; height: 20px; object-fit: contain;"> | |
| Behno | |
| </div> | |
| <div class="nav-item" onclick="showSection('daily-jagah', this)"> | |
| 📅 | |
| Daily Jagah | |
| </div> | |
| <div id="nav-lf-jagah" class="nav-item" onclick="showSection('lf-jagah', this)"> | |
| 🌙 | |
| LF Jagah | |
| </div> | |
| <div style="flex:1"></div> | |
| <div class="nav-item" onclick="goToUserDashboard()"> | |
| 👤 | |
| User Dashboard | |
| </div> | |
| <div class="nav-item" onclick="doAdminLogout()"> | |
| 🚪 | |
| Logout | |
| </div> | |
| </div> | |
| <div class="content"> | |
| <!-- Dashboard Section --> | |
| <div id="dashboard" class="section active"> | |
| <!-- ADDED BY ANTIGRAVITY: Redundant header removed as requested --> | |
| <!-- <h1>Dashboard Overview</h1> --> | |
| <!-- END OF REDUCTION --> | |
| <div class="stats-row"> | |
| <div class="stat-card"> | |
| <div class="stat-label">Total Behno</div> | |
| <div class="stat-value" id="stat-total">-</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-label">Daily Quota</div> | |
| <div class="stat-value" id="stat-daily">-</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-label">Layali Fadela Quota</div> | |
| <div class="stat-value" id="stat-lf">-</div> | |
| </div> | |
| </div> | |
| <h2>Phase Schedule</h2> | |
| <div | |
| style="background:#fff; padding:20px; border-radius:12px; box-shadow:0 2px 4px rgba(0,0,0,0.05); margin-bottom: 30px;"> | |
| <p style="margin-bottom:15px; color:#666; font-size:0.95rem;">Set the dates visible to users. This does | |
| NOT automatically activate phases (use buttons below).</p> | |
| <div style="display:grid; grid-template-columns: 1fr 1fr 1fr; gap:15px; margin-bottom:15px;"> | |
| <div> | |
| <label style="font-size:0.85rem; font-weight:600; color:#555;">Phase 1 Start</label> | |
| <input type="datetime-local" id="sched-p1-input" class="search-bar"> | |
| </div> | |
| <div> | |
| <label style="font-size:0.85rem; font-weight:600; color:#555;">Phase 2 Start</label> | |
| <input type="datetime-local" id="sched-p2-input" class="search-bar"> | |
| </div> | |
| <div> | |
| <label style="font-size:0.85rem; font-weight:600; color:#555;">Phase 3 Start</label> | |
| <input type="datetime-local" id="sched-p3-input" class="search-bar"> | |
| </div> | |
| </div> | |
| <button class="btn" style="padding:8px 20px; font-size:0.9rem;" onclick="saveSchedule()">Save Schedule | |
| Dates</button> | |
| </div> | |
| <h2>Phase Management</h2> | |
| <div style="background:#fff; padding:20px; border-radius:12px; box-shadow:0 2px 4px rgba(0,0,0,0.05);"> | |
| <p style="margin-bottom:15px; color:#666; font-size:0.95rem;">Select a phase to activate seat bookings | |
| for all eligible members.</p> | |
| <select id="phase-select" style="width:100%; max-width:500px; margin-bottom:15px; display:block;" | |
| onchange="checkPhaseStatus()"> | |
| <option value="">Select Phase to Activate</option> | |
| <!-- ADDED BY ANTIGRAVITY: Commented out hard-coded dates as requested --> | |
| <!-- | |
| <option value="1">Phase 1: Mon, 02/16/2026 - Mon, 02/23/2026</option> | |
| <option value="2">Phase 2: Tue, 02/24/2026 - Tue, 03/03/2026</option> | |
| <option value="3">Phase 3: Thu, 03/05/2026 - Mon, 03/16/2026</option> | |
| --> | |
| <!-- END OF ADDITION --> | |
| </select> | |
| <div id="phase-action-container" style="margin-top: 15px;"> | |
| <!-- Dynamic button will appear here based on selection --> | |
| </div> | |
| <div id="current-phase-status" | |
| style="margin-top: 15px; padding: 10px; background: #f0f8ff; border-left: 4px solid var(--color-accent); border-radius: 4px; font-size: 0.9rem;"> | |
| <strong>Currently Active Phase:</strong> <span id="active-phase-display" | |
| style="color: var(--color-accent); font-weight: 600;">None</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Shukran Section --> | |
| <div id="shukran" class="section"> | |
| <div style="display: flex; justify-content: center; align-items: center; min-height: calc(100vh - 50px);"> | |
| <div | |
| style="background: #fff; padding: 40px 60px; border-radius: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05); text-align: center; width: 100%; max-width: 450px;"> | |
| <h1 style="color: var(--color-accent); font-size: 3.5rem; margin:0;">Shukran</h1> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Behnos Section --> | |
| <div id="behnos" class="section"> | |
| <h1>Behno</h1> | |
| <div class="table-container"> | |
| <div class="table-controls"> | |
| <input type="text" id="search" class="search-bar" placeholder="Search by name or email..." | |
| onkeyup="filterTable()" style="max-width: 300px;"> | |
| <!-- ADDED BY ANTIGRAVITY: Recalculate Quotas button removed as requested --> | |
| <!-- | |
| <button class="btn" style="padding:6px 12px; font-size:0.85rem;" | |
| onclick="doRecalculate()">Recalculate Quotas</button> | |
| --> | |
| <!-- END OF REDUCTION --> | |
| <label style="cursor:pointer; white-space: nowrap;"> | |
| <input type="checkbox" id="preflock-all"> Lock All Preferences | |
| </label> | |
| </div> | |
| <table id="behno-table"> | |
| <thead> | |
| <tr> | |
| <th>Name</th> | |
| <th>Attnd</th> | |
| <th>QuotaDaily</th> | |
| <th>AdminDaily</th> | |
| <th>UsedDaily</th> | |
| <th>LeftDaily</th> | |
| <th>QuotaLF</th> | |
| <th>AdminLF</th> | |
| <th>UsedLF</th> | |
| <th>LeftLF</th> | |
| <th>Lock</th> | |
| <th>Notes</th> | |
| </tr> | |
| </thead> | |
| <tbody id="behno-body"></tbody> | |
| </table> | |
| <div class="pagination" id="pagination"></div> | |
| </div> | |
| </div> | |
| <!-- Daily Jagah Section --> | |
| <div id="daily-jagah" class="section"> | |
| <h1>Daily Jagah</h1> | |
| <div class="table-container"> | |
| <div id="daily-jagah-content"></div> | |
| <div class="pagination" id="daily-jagah-pagination"></div> | |
| </div> | |
| </div> | |
| <!-- LF Jagah Section --> | |
| <div id="lf-jagah" class="section"> | |
| <h1>Layali Fadela Jagah</h1> | |
| <div class="table-container" id="lf-content-wrapper"> | |
| <div id="lf-jagah-content"></div> | |
| <div class="pagination" id="lf-jagah-pagination"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| let adminData = null; | |
| let behnoData = []; | |
| let filteredData = []; | |
| let behnoPage = 1; | |
| let dailyJagahPage = 1; | |
| let lfJagahPage = 1; | |
| const rowsPerPage = 10; | |
| const jagahRowsPerPage = 10; | |
| window.onload = function () { | |
| // Show loading state | |
| document.getElementById('stat-total').innerText = 'Loading...'; | |
| document.getElementById('stat-daily').innerText = 'Loading...'; | |
| document.getElementById('stat-lf').innerText = 'Loading...'; | |
| loadData(); | |
| /* ADDED BY ANTIGRAVITY: Load Phase Options on init */ | |
| loadPhaseOptions(); | |
| /* END OF ADDITION */ | |
| }; | |
| function loadData() { | |
| google.script.run | |
| .withSuccessHandler(render) | |
| .withFailureHandler(function (error) { | |
| console.error('Failed to load admin data:', error); | |
| showModal('Error Loading Dashboard', | |
| 'Failed to load admin dashboard data. ' + error.message + | |
| '<br><br>Click OK to return to login page.', | |
| 'error', | |
| function () { | |
| google.script.run.withSuccessHandler(function (url) { | |
| window.open(url, '_top'); | |
| }).getScriptUrl(); | |
| }); | |
| }) | |
| .getAdminData(); | |
| } | |
| function render(data) { | |
| adminData = data; | |
| /* ADDED BY ANTIGRAVITY: Storing active phase for button state */ | |
| window.activePhaseId = data.activePhase || ''; | |
| /* END OF ADDITION */ | |
| /* ADDED BY ANTIGRAVITY: Update Display Text */ | |
| const activeDisplay = document.getElementById('active-phase-display'); | |
| if (activeDisplay) { | |
| activeDisplay.innerText = data.activePhase ? ("Phase " + data.activePhase) : "None"; | |
| } | |
| // Auto-set Admin Preference since we are on Admin Dashboard | |
| sessionStorage.setItem('shehrullah_pref_view', 'admin'); | |
| /* END OF ADDITION */ | |
| document.getElementById('stat-total').innerText = data.stats.total; | |
| document.getElementById('stat-daily').innerText = data.stats.dailyQuota; | |
| document.getElementById('stat-lf').innerText = data.stats.lfQuota; | |
| document.getElementById('stat-lf').innerText = data.stats.lfQuota; | |
| // CONDITIONAL VISIBILITY FOR LF JAGAH | |
| const activePhase = parseInt(data.activePhase) || 0; | |
| const lfSection = document.getElementById('lf-jagah'); | |
| const lfNav = document.getElementById('nav-lf-jagah'); | |
| if (activePhase < 2) { | |
| // ADDED BY ANTIGRAVITY: Removed hiding logic for Admin view as requested | |
| /* | |
| // COMPLETELY HIDE UI ELEMENTS | |
| if (lfNav) lfNav.style.display = 'none'; | |
| if (lfSection) lfSection.style.display = 'none'; | |
| // If currently viewing LF Jagah, switch back to dashboard | |
| if (lfSection && lfSection.classList.contains('active')) { | |
| showSection('dashboard', document.querySelector('.nav-item.active')); | |
| } | |
| */ | |
| } else { | |
| if (lfNav) lfNav.style.display = 'flex'; // Restore nav item | |
| // Section display is handled by showSection class toggling, so don't force block here | |
| if (lfSection) lfSection.style.display = ''; | |
| document.getElementById('lf-content-wrapper').style.display = 'block'; | |
| } | |
| behnoData = data.behnos; | |
| filteredData = behnoData; | |
| renderBehnoTable(); | |
| renderJagahGrid('Daily'); | |
| // Always render LF grid for Admin | |
| renderJagahGrid('LF'); | |
| // Populate Schedule Inputs | |
| if (data.phaseSchedule) { | |
| document.getElementById('sched-p1-input').value = data.phaseSchedule.p1 || ''; | |
| document.getElementById('sched-p2-input').value = data.phaseSchedule.p2 || ''; | |
| document.getElementById('sched-p3-input').value = data.phaseSchedule.p3 || ''; | |
| } | |
| } | |
| function renderBehnoTable() { | |
| const tbody = document.getElementById('behno-body'); | |
| tbody.innerHTML = ''; | |
| let rowsPerPage = 20; // UPDATED: Show 20 names per page | |
| const start = (behnoPage - 1) * rowsPerPage; | |
| const end = start + rowsPerPage; | |
| const pageData = filteredData.slice(start, end); | |
| // Helper to render 0 correctly instead of blank | |
| const safeVal = (v) => (v !== undefined && v !== null && v !== '') ? v : ''; | |
| // Helper to render numeric 0 correctly (allow 0, but empty string becomes empty) | |
| const numVal = (v) => (v !== undefined && v !== null && v !== '') ? v : ''; | |
| pageData.forEach(row => { | |
| const tr = document.createElement('tr'); | |
| const name = row.Name || row.name || ''; | |
| const email = row.Email || row.email || ''; | |
| // Get raw values trying both cases | |
| const dq = row.DailyQuota ?? row.dailyquota; | |
| const da = row.DailyAdmin ?? row.dailyadmin; | |
| const du = row.DailyUsed ?? row.dailyused; | |
| const dl = row.DailyLeft ?? row.dailyleft; | |
| const lfq = row.LFQuota ?? row.lfquota; | |
| const lfa = row.LFAdmin ?? row.lfadmin; | |
| const lfu = row.LFUsed ?? row.lfused; | |
| const lfl = row.LFLeft ?? row.lfleft; | |
| tr.innerHTML = ` | |
| <td data-label="Name">${name}</td> | |
| <td data-label="Attnd">${row.Attendance || row.attendance || ''}</td> | |
| <td data-label="QuotaDaily">${numVal(dq)}</td> | |
| <td data-label="AdminDaily"><input class="edit-cell" type="number" value="${numVal(da)}" onchange="updateVal('${email}', 'DailyAdmin', this.value)"></td> | |
| <td data-label="UsedDaily">${numVal(du)}</td> | |
| <td data-label="LeftDaily">${numVal(dl)}</td> | |
| <td data-label="QuotaLF">${numVal(lfq)}</td> | |
| <td data-label="AdminLF"><input class="edit-cell" type="number" value="${numVal(lfa)}" onchange="updateVal('${email}', 'LFAdmin', this.value)"></td> | |
| <td data-label="UsedLF">${numVal(lfu)}</td> | |
| <td data-label="LeftLF">${numVal(lfl)}</td> | |
| <td data-label="Lock"><input type="checkbox" ${row.PrefLock || row.preflock ? 'checked' : ''} onchange="updateVal('${email}', 'PrefLock', this.checked)"></td> | |
| <td data-label="Notes"><input class="edit-cell" type="text" value="${row.Notes || row.notes || ''}" onchange="updateVal('${email}', 'Notes', this.value)" style="width:100px;"></td> | |
| `; | |
| tbody.appendChild(tr); | |
| }); | |
| renderPagination('pagination', filteredData.length, rowsPerPage, behnoPage, 'behnoPage'); | |
| } | |
| function renderJagahGrid(type) { | |
| const data = (type === 'Daily') ? adminData.dailyJagah : adminData.lfJagah; | |
| const container = (type === 'Daily') ? document.getElementById('daily-jagah-content') : document.getElementById('lf-jagah-content'); | |
| if (!data || !data.dates || data.dates.length === 0) { | |
| container.innerHTML = '<p>No data found.</p>'; | |
| return; | |
| } | |
| // LF shows all dates (no pagination) | |
| const isLF = (type === 'LF'); | |
| const page = isLF ? 1 : dailyJagahPage; | |
| const perPage = isLF ? 999 : jagahRowsPerPage; | |
| const start = (page - 1) * perPage; | |
| const end = start + perPage; | |
| const pageDates = data.dates.slice(start, end); | |
| let html = `<table><thead><tr><th>Date</th>`; | |
| data.seats.forEach(s => html += `<th>${s}</th>`); | |
| html += `</tr></thead><tbody>`; | |
| pageDates.forEach(date => { | |
| let rowHtml = ''; | |
| let filledCount = 0; | |
| data.seats.forEach(seat => { | |
| const occupant = data.mapping[date + "|" + seat]; | |
| const bgColor = occupant ? '' : (type === 'Daily' ? '#727AC0' : '#F7EBF2'); | |
| if (occupant) filledCount++; | |
| // UPDATED: Color #3e7876 and Bold for Occupied Seats | |
| const cellStyle = occupant ? 'color:#3e7876; font-weight:700;' : 'color:#333;'; | |
| rowHtml += `<td data-label="${seat}" style="background-color:${bgColor}; ${cellStyle} font-size:0.75rem;">${occupant || '-'}</td>`; | |
| }); | |
| const isFullyBooked = (filledCount === data.seats.length); | |
| const rowStyle = isFullyBooked ? 'style="background-color:#9CF4DF;"' : ''; | |
| html += `<tr ${rowStyle}><td data-label="Date">${date}</td>${rowHtml}</tr>`; | |
| }); | |
| html += `</tbody></table>`; | |
| container.innerHTML = html; | |
| if (!isLF) { | |
| renderPagination('daily-jagah-pagination', data.dates.length, jagahRowsPerPage, page, 'dailyJagahPage'); | |
| } else { | |
| document.getElementById('lf-jagah-pagination').innerHTML = ''; | |
| } | |
| } | |
| function renderPagination(containerId, totalRows, perPage, current, pageVarName) { | |
| const pagination = document.getElementById(containerId); | |
| const totalPages = Math.ceil(totalRows / perPage); | |
| let html = ''; | |
| for (let i = 1; i <= totalPages; i++) { | |
| html += `<button class="page-btn ${i === current ? 'active' : ''}" onclick="${pageVarName} = ${i}; ${(pageVarName === 'behnoPage' ? 'renderBehnoTable()' : 'renderJagahGrid(\'' + (pageVarName.includes('daily') ? 'Daily' : 'LF') + '\')')}">${i}</button>`; | |
| } | |
| pagination.innerHTML = html; | |
| } | |
| function goToPage(page) { | |
| behnoPage = page; | |
| renderBehnoTable(); | |
| } | |
| function updateVal(email, field, val) { | |
| // Find by exact email match (case insensitive) | |
| const row = behnoData.find(r => (r.Email || r.email || '').toLowerCase() === email.toLowerCase()); | |
| if (row) row[field] = val; | |
| google.script.run | |
| .withSuccessHandler(res => { | |
| loadData(); // Still reload to ensure server-side quotas are synced | |
| }) | |
| .updateAdminValue(email, field, val); | |
| renderBehnoTable(); // Reactive update | |
| } | |
| function filterTable() { | |
| const q = document.getElementById('search').value.toLowerCase(); | |
| filteredData = behnoData.filter(row => { | |
| const name = (row.Name || row.name || '').toLowerCase(); | |
| const email = (row.Email || row.email || '').toLowerCase(); | |
| return name.includes(q) || email.includes(q); | |
| }); | |
| behnoPage = 1; | |
| renderBehnoTable(); | |
| } | |
| function showSection(id, el) { | |
| document.querySelectorAll('.section').forEach(s => s.classList.remove('active')); | |
| document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); | |
| document.getElementById(id).classList.add('active'); | |
| if (el) el.classList.add('active'); | |
| } | |
| function goToUserDashboard() { | |
| // Get the user's email to pass in URL for auto-login | |
| // FIX: Use currentUserEmail (backend property) instead of userEmail (incorrect) | |
| const userEmail = (adminData && adminData.currentUserEmail) || localStorage.getItem('shehrullah_email') || ''; | |
| console.log('goToUserDashboard called'); | |
| console.log('adminData:', adminData); | |
| console.log('localStorage email:', localStorage.getItem('shehrullah_email')); | |
| console.log('userEmail to pass:', userEmail); | |
| if (adminData && adminData.scriptUrl) { | |
| // Ensure we append forceUser=true and email correctly | |
| const baseUrl = adminData.scriptUrl.split('?')[0]; | |
| const targetUrl = baseUrl + '?forceUser=true&email=' + encodeURIComponent(userEmail); | |
| window.open(targetUrl, '_top'); | |
| } else { | |
| google.script.run.withSuccessHandler(url => { | |
| const baseUrl = url.split('?')[0]; | |
| const targetUrl = baseUrl + '?forceUser=true&email=' + encodeURIComponent(userEmail); | |
| window.open(targetUrl, '_top'); | |
| }).getScriptUrl(); | |
| } | |
| } | |
| function doLogout() { | |
| localStorage.removeItem('shehrullah_email'); | |
| showSection('shukran'); | |
| // Remove navigation to prevent going back | |
| document.querySelector('.nav-panel').style.display = 'none'; | |
| } | |
| function activatePhase() { | |
| const phase = document.getElementById('phase-select').value; | |
| if (!phase) { | |
| showModal("Selection Required", "Please select a phase to activate.", "info"); | |
| return; | |
| } | |
| /* ADDED BY ANTIGRAVITY: Linked phase activation to backend */ | |
| const btn = document.querySelector('button[onclick="activatePhase()"]'); | |
| const originalText = btn.innerText; | |
| btn.innerText = "Activating..."; | |
| btn.disabled = true; | |
| google.script.run | |
| .withSuccessHandler(res => { | |
| btn.innerText = originalText; | |
| btn.disabled = false; | |
| /* ADDED BY ANTIGRAVITY: Update local state and button text on success */ | |
| window.activePhaseId = phase; | |
| checkPhaseStatus(); | |
| // Update Text Display | |
| const activeDisplay = document.getElementById('active-phase-display'); | |
| if (activeDisplay) { | |
| activeDisplay.innerText = "Phase " + phase; | |
| } | |
| /* END OF ADDITION */ | |
| showModal("Success", "Phase " + phase + " is now active for all users!", "success"); | |
| }) | |
| .withFailureHandler(e => { | |
| btn.innerText = originalText; | |
| btn.disabled = false; | |
| showModal("Error", e.message, "error"); | |
| }) | |
| .activatePhase(phase); | |
| /* END OF ADDITION */ | |
| } | |
| /* ADDED BY ANTIGRAVITY: Logic to update button text based on status */ | |
| function checkPhaseStatus() { | |
| const select = document.getElementById('phase-select'); | |
| const btn = document.querySelector('button[onclick="activatePhase()"]'); | |
| const val = select.value; | |
| if (val && val === window.activePhaseId) { | |
| btn.innerText = 'Activated'; | |
| btn.classList.add('btn-success'); // Assuming a styling preference or just changing text | |
| btn.style.background = 'var(--color-accent)'; | |
| } else { | |
| btn.innerText = 'Activate Selected Phase'; | |
| btn.style.background = 'var(--color-primary)'; | |
| } | |
| } | |
| /* END OF ADDITION */ | |
| /* ADDED BY ANTIGRAVITY: Logic to populate Phase Dropdown */ | |
| function loadPhaseOptions() { | |
| const select = document.getElementById('phase-select'); | |
| google.script.run | |
| .withSuccessHandler(phases => { | |
| // Keep the default option | |
| const defaultOpt = select.options[0]; | |
| select.innerHTML = ''; | |
| select.appendChild(defaultOpt); | |
| phases.forEach(p => { | |
| const opt = document.createElement('option'); | |
| opt.value = p.id; | |
| opt.innerText = p.name + ": " + p.range; | |
| select.appendChild(opt); | |
| }); | |
| }) | |
| .getPhaseOptions(); | |
| } | |
| /* END OF ADDITION */ | |
| function doRecalculate() { | |
| const btn = document.querySelector('button[onclick="doRecalculate()"]'); | |
| const originalText = btn.innerText; | |
| btn.innerText = "Processing..."; | |
| btn.disabled = true; | |
| google.script.run | |
| .withSuccessHandler(() => { | |
| showModal("Success", "Quotas recalculated successfully.", "success"); | |
| btn.innerText = originalText; | |
| btn.disabled = false; | |
| // Refresh data | |
| google.script.run.withSuccessHandler(render).getAdminData(); | |
| }) | |
| .withFailureHandler(e => { | |
| showModal("Error", e.message, "error"); | |
| btn.innerText = originalText; | |
| btn.disabled = false; | |
| }) | |
| .forceRecalculate(); | |
| } | |
| function goToUserDashboard() { | |
| // Simplified Logic: Go to Main URL -> Triggers Admin Choice Page (per user request) | |
| const scriptUrl = window.adminData ? window.adminData.scriptUrl : ''; | |
| const email = (window.adminData && window.adminData.currentUserEmail) ? window.adminData.currentUserEmail : ''; | |
| // Clear any user preference so Index logic treats this as a fresh Admin login -> Shows Choice | |
| sessionStorage.removeItem('shehrullah_pref_view'); | |
| // Construct safe URL | |
| let targetUrl = scriptUrl ? scriptUrl.split('?')[0] : null; | |
| // Only append email if it exists and is valid | |
| if (targetUrl && email) { | |
| targetUrl += '?email=' + encodeURIComponent(email); | |
| } | |
| if (targetUrl) { | |
| // Open clean URL in top window | |
| window.open(targetUrl, '_top'); | |
| } else { | |
| // Fallback if scriptUrl wasn't in adminData (e.g. data load failed) | |
| google.script.run.withSuccessHandler(function (url) { | |
| const baseUrl = url.split('?')[0]; | |
| // Here we might miss the email if adminData failed, but at least we go to the page | |
| window.open(baseUrl, '_top'); | |
| }).getScriptUrl(); | |
| } | |
| } | |
| /* | |
| function doAdminLogout() { | |
| sessionStorage.removeItem('shehrullah_pref_view'); | |
| showSection('shukran-admin', null); | |
| } | |
| */ | |
| // Schedule Functions | |
| function saveSchedule() { | |
| const schedule = { | |
| p1: document.getElementById('sched-p1-input').value, | |
| p2: document.getElementById('sched-p2-input').value, | |
| p3: document.getElementById('sched-p3-input').value | |
| }; | |
| const btn = document.querySelector('button[onclick="saveSchedule()"]'); | |
| const originalText = btn.innerText; | |
| btn.innerText = "Saving..."; | |
| btn.disabled = true; | |
| google.script.run | |
| .withSuccessHandler(res => { | |
| showModal("Success", "Schedule dates updated.", "success"); | |
| btn.innerText = originalText; | |
| btn.disabled = false; | |
| }) | |
| .withFailureHandler(e => { | |
| showModal("Error", e.message, "error"); | |
| btn.innerText = originalText; | |
| btn.disabled = false; | |
| }) | |
| .savePhaseSchedule(schedule); | |
| } | |
| // Phase Management Functions | |
| function checkPhaseStatus() { | |
| const select = document.getElementById('phase-select'); | |
| const selectedPhase = select.value; | |
| const activePhase = window.activePhaseId || ''; | |
| const container = document.getElementById('phase-action-container'); | |
| const statusDisplay = document.getElementById('active-phase-display'); | |
| // Update active phase display | |
| if (activePhase) { | |
| const activeOption = Array.from(select.options).find(opt => opt.value === activePhase); | |
| statusDisplay.textContent = activeOption ? activeOption.text : `Phase ${activePhase}`; | |
| } else { | |
| statusDisplay.textContent = 'None'; | |
| } | |
| // Show appropriate button based on selection | |
| if (!selectedPhase) { | |
| container.innerHTML = ''; | |
| return; | |
| } | |
| if (selectedPhase === activePhase) { | |
| // Selected phase is currently active - show Deactivate button | |
| container.innerHTML = ` | |
| <button class="btn" style="background:#E2727C; color:#fff; border-radius:8px; padding:10px 20px;" | |
| onclick="deactivateCurrentPhase()"> | |
| Deactivate This Phase | |
| </button> | |
| `; | |
| } else { | |
| // Selected phase is not active - show Activate button | |
| container.innerHTML = ` | |
| <button class="btn btn-primary" style="background:var(--color-primary); color:#fff; border-radius:8px; padding:10px 20px;" | |
| onclick="activatePhase()"> | |
| Activate This Phase | |
| </button> | |
| `; | |
| } | |
| } | |
| function activatePhase() { | |
| const select = document.getElementById('phase-select'); | |
| const phaseNum = select.value; | |
| if (!phaseNum) { | |
| showModal('Error', 'Please select a phase to activate', 'error'); | |
| return; | |
| } | |
| google.script.run | |
| .withSuccessHandler(function (result) { | |
| showModal('Success', `Phase ${phaseNum} activated successfully! Users can now select seats.`, 'success'); | |
| window.activePhaseId = phaseNum; | |
| checkPhaseStatus(); // Update button display | |
| }) | |
| .withFailureHandler(function (error) { | |
| showModal('Error', 'Failed to activate phase: ' + error.message, 'error'); | |
| }) | |
| .activatePhase(phaseNum); | |
| } | |
| function deactivateCurrentPhase() { | |
| google.script.run | |
| .withSuccessHandler(function (result) { | |
| showModal('Success', result.message || 'Phase deactivated. Seat selection closed for all users.', 'success'); | |
| window.activePhaseId = ''; | |
| document.getElementById('phase-select').value = ''; | |
| checkPhaseStatus(); // Update button display | |
| }) | |
| .withFailureHandler(function (error) { | |
| showModal('Error', 'Failed to deactivate phase: ' + error.message, 'error'); | |
| }) | |
| .deactivateAllPhases(); | |
| } | |
| </script> | |
| </body> | |
| </html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| const DATA_SHEET_ID = '1g5KcJXInI3myNSTTT__XXM1mUkrSTxSL1R56KOFFSoU'; | |
| //const DATA_SHEET_ID = '1hijdA9iY4jq9PoLsTs2c50L0hbDpIfUy2oXl63Mv6q8'; sfjamaat a/c | |
| //const GRID_SHEET_ID = '1trjTMXlpMFcajq9riRACAmE6Ex5r3QqeK2Zn5OrTXBM'; | |
| const VERSION = "V4.9.0 (Refactored Codebase - Extracted CSS & Preferences)"; | |
| const TABS = { | |
| DATA: 'data', | |
| DATES: 'Dates', | |
| SEATS: 'Seats', | |
| USER_MASTER: 'UserMaster', | |
| AVAILABLE_CAPACITY: 'AvailableCapacity', | |
| DAILY_JAGAH: 'DailyJagah', | |
| LF_JAGAH: 'LFJagah' | |
| }; | |
| function doGet(e) { | |
| const page = e.parameter.page || 'index'; | |
| const template = HtmlService.createTemplateFromFile(page === 'AdminDashboard' ? 'AdminDashboard' : 'index'); | |
| template.urlParams = e.parameter; | |
| return template.evaluate() | |
| .setTitle(page === 'AdminDashboard' ? 'Shehrullah Admin Dashboard' : 'Shehrullah Dashboard') | |
| .addMetaTag('viewport', 'width=device-width, initial-scale=1') | |
| .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL); | |
| } | |
| function include(filename) { | |
| return HtmlService.createHtmlOutputFromFile(filename).getContent(); | |
| } | |
| function getScriptUrl() { | |
| return ScriptApp.getService().getUrl(); | |
| } | |
| /** | |
| * Robustly maps a row to an object using case-insensitive/trimmed headers | |
| */ | |
| function mapRowToObj(rawHeaders, row) { | |
| const obj = {}; | |
| rawHeaders.forEach((h, i) => { | |
| if (!h) return; | |
| const key = String(h).trim(); | |
| obj[key] = row[i]; | |
| obj[key.toLowerCase()] = row[i]; | |
| }); | |
| return obj; | |
| } | |
| // Normalizer for keys (Lower, no spaces, no special chars) | |
| const kNorm = (v) => String(v || '').toLowerCase().replace(/[^a-z0-9]/g, '').trim(); | |
| /** | |
| * Finds the header row index (0-indexed) based on critical keywords | |
| */ | |
| function findHeaderRow(allData) { | |
| for (let i = 0; i < Math.min(allData.length, 10); i++) { | |
| const row = allData[i].map(c => String(c).trim().toLowerCase()); | |
| // Require at least 2 critical headers to be sure | |
| const hits = ['email', 'fullname', 'hofid', 'misaq', 'misaaq', 'age', 'seat', 'attendancemode'].filter(k => row.some(cell => cell.includes(k))); | |
| if (hits.length >= 2) return i; | |
| } | |
| // Second pass: if only one matches but it's very distinct | |
| for (let i = 0; i < Math.min(allData.length, 10); i++) { | |
| const row = allData[i].map(c => String(c).trim().toLowerCase()); | |
| if (row.includes('email') && row.includes('hofid')) return i; | |
| } | |
| return 0; // Fallback | |
| } | |
| /** | |
| * Maps all data rows to objects using robust header detection | |
| */ | |
| function fetchTabRows(ss, tabName) { | |
| const sheet = ss.getSheetByName(tabName); | |
| if (!sheet) return { rows: [], headers: [], hIdx: -1 }; | |
| const allData = sheet.getDataRange().getValues(); | |
| if (allData.length === 0) return { rows: [], headers: [], hIdx: -1 }; | |
| const hIdx = findHeaderRow(allData); | |
| const rawHeaders = allData[hIdx]; | |
| const rows = allData.slice(hIdx + 1).map((r, i) => { | |
| const obj = mapRowToObj(rawHeaders, r); | |
| obj._rowNum = hIdx + i + 2; // 1-indexed spreadsheet row | |
| return obj; | |
| }); | |
| return { rows, headers: rawHeaders, hIdx }; | |
| } | |
| /* UPDATE by Antigravity: Strictly enforce eligibility and data flow */ | |
| function loginUser(email) { | |
| if (!email) throw new Error("Please enter an email."); | |
| const ss = SpreadsheetApp.openById(DATA_SHEET_ID); | |
| const sheet = ss.getSheetByName(TABS.DATA); | |
| if (!sheet) throw new Error("System Error: 'data' tab not found."); | |
| const allData = sheet.getDataRange().getValues(); | |
| if (allData.length < 2) throw new Error("System Error: 'data' tab is empty."); | |
| const { rows, headers: rawHeaders, hIdx } = fetchTabRows(ss, TABS.DATA); | |
| const headers = rawHeaders.map(h => String(h).trim().toLowerCase()); | |
| // Locate Columns | |
| const findCol = (targets) => { | |
| for(let t of targets) { | |
| const idx = headers.indexOf(t.toLowerCase()); | |
| if (idx !== -1) return idx; | |
| } | |
| return headers.findIndex(h => targets.some(t => h.includes(t.toLowerCase()))); | |
| }; | |
| const emailIdx = findCol(['Email']); | |
| const hofIdIdx = findCol(['HOFIDs', 'HOFID']); | |
| const ageIdx = findCol(['Age']); | |
| // Eligibility Columns | |
| let misaqIdx = headers.indexOf('misaq'); | |
| if (misaqIdx === -1) misaqIdx = headers.indexOf('misaaq'); // Explicit check | |
| if (misaqIdx === -1) misaqIdx = headers.findIndex(h => h.includes('misaq')); // Fuzzy | |
| const dkrIdx = findCol(['Dkr', 'Status']); | |
| const adminIdx = headers.indexOf('admin'); | |
| const nameIdx = findCol(['Name', 'FullName']); | |
| const primaryIdx = findCol(['Primary', 'IsPrimary']); | |
| const searchEmail = String(email).trim().toLowerCase(); | |
| // 1. Locate User Row(s) | |
| const recordsWithEmail = rows.filter(r => String(r[rawHeaders[emailIdx]]).trim().toLowerCase() === searchEmail); | |
| if (recordsWithEmail.length === 0) throw new Error("Email not found in database. Contact Admin."); | |
| // Choose canonical user (highest age or first) | |
| const userObj = recordsWithEmail.sort((a,b) => (b.Age || b.age || 0) - (a.Age || a.age || 0))[0]; | |
| const userUid = userObj._rowNum; | |
| const rawUserRow = allData[userUid - 1]; | |
| const userData = mapRowToObj(rawHeaders, rawUserRow); | |
| // 3. Find HOFID from ANY record with this email | |
| let myHofId = ''; | |
| for (let r of recordsWithEmail) { | |
| const h = String(r[rawHeaders[hofIdIdx]] || '').trim(); | |
| if (h && h !== '-' && h !== '0') { myHofId = h; break; } | |
| } | |
| // Helper: Eligibility Value Check (Case-insensitive) | |
| const isOk = (val) => ['done', 'yes', 'y', 'ok', 'true'].includes(String(val || '').trim().toLowerCase()); | |
| // 4. Strict Eligibility Check (Primary Login) | |
| if (misaqIdx > -1) { | |
| const val = String(rawUserRow[misaqIdx] || '').trim().toLowerCase(); | |
| if (!isOk(val)) throw new Error("Misaaq status not valid (" + (val||'Empty') + "). Contact Admin."); | |
| } | |
| if (dkrIdx > -1) { | |
| const val = String(rawUserRow[dkrIdx] || '').trim().toUpperCase(); | |
| if (val === 'B') throw new Error("Status is restricted (B). Please contact Admin."); | |
| } | |
| // Group Family by HOFID | |
| const familyGroup = rows.filter(r => String(r[rawHeaders[hofIdIdx]]).trim() === myHofId).map(f => { | |
| const r = allData[f._rowNum - 1]; | |
| return { | |
| ...f, | |
| raw: r, | |
| age: parseFloat(String(r[ageIdx] || '0').replace(/[^\d.]/g, '')) || 0, | |
| isPrimaryFlag: (primaryIdx > -1 && ['yes', 'y'].includes(String(r[primaryIdx]).trim().toLowerCase())) | |
| }; | |
| }); | |
| // ... (Primary Logic preserved) ... | |
| // 5. Determine Primary Status | |
| const primaryColIdx = findCol(['Primary', 'IsPrimary']); | |
| let primaryUser = familyGroup.find(f => f.isPrimaryFlag); | |
| if (!primaryUser) { | |
| const sorted = [...familyGroup].sort((a,b) => b.age - a.age); | |
| if (sorted.length > 0) primaryUser = sorted[0]; | |
| } | |
| let activeUser = userData; | |
| let activeUid = userUid; | |
| let isPrimary = false; | |
| if (primaryUser) { | |
| const primaryEmail = String(primaryUser.raw[emailIdx] || '').trim().toLowerCase(); | |
| if (primaryEmail === searchEmail) { | |
| isPrimary = true; | |
| activeUser = mapRowToObj(rawHeaders, primaryUser.raw); | |
| activeUid = primaryUser._rowNum; | |
| } | |
| } | |
| activeUser.uid = activeUid; | |
| const isAdminUser = adminIdx > -1 ? ['yes', 'y'].includes(String(activeUser[String(rawHeaders[adminIdx]).trim()]||'').toLowerCase()) : false; | |
| const allPrefsMap = getUserPreferencesMap(); | |
| const myNormName = kNorm(activeUser.FullName || activeUser.Name || ''); | |
| // 7. Map family data | |
| const familyMembers = familyGroup.map(f => { | |
| const mObj = mapRowToObj(rawHeaders, f.raw); | |
| const memEmail = (mObj.Email || mObj.email || searchEmail).toLowerCase(); | |
| const prefKey = memEmail + "|" + kNorm(mObj.Name || mObj.FullName || ''); | |
| // STRICT Eligibility Check for Family | |
| // Relaxed slightly to accept 'Yes'/'Y' etc. | |
| const fMisaq = misaqIdx > -1 ? String(f.raw[misaqIdx] || '').trim().toLowerCase() : ''; | |
| const fDkr = dkrIdx > -1 ? String(f.raw[dkrIdx] || '').trim().toUpperCase() : ''; | |
| // NEW LOGIC: 'Done' or 'Yes' is valid. Blank is INVALID. | |
| const isMisaqDone = isOk(fMisaq); | |
| const isStatusOk = (fDkr !== 'B'); | |
| const isEligible = (isMisaqDone && isStatusOk); | |
| const seatStat = getFamilySeatStatus(mObj.Email); | |
| const nameCandidates = [ | |
| mObj.Name, mObj.name, | |
| mObj.FullName, mObj.fullname, | |
| mObj['Full Name'], mObj['full name'], | |
| f.Name, f.FullName | |
| ].filter(v => v); | |
| let foundPrefs = null; | |
| // 1. Try Email + Name combo | |
| for (let n of nameCandidates) { | |
| const key = memEmail + "|" + kNorm(n); | |
| if (allPrefsMap[key]) { foundPrefs = allPrefsMap[key]; break; } | |
| } | |
| // 2. Try Name only fallback | |
| if (!foundPrefs) { | |
| for (let n of nameCandidates) { | |
| const key = kNorm(n); | |
| if (allPrefsMap[key]) { foundPrefs = allPrefsMap[key]; break; } | |
| } | |
| } | |
| return { | |
| uid: f._rowNum, | |
| Name: mObj.Name || mObj.FullName || 'User', | |
| FullName: mObj.FullName || mObj.Name || 'User', | |
| Email: mObj.Email || '', | |
| 'Cell#': mObj['Cell#'] || mObj.Phone || "", | |
| preferences: foundPrefs, | |
| isEligible: isEligible, | |
| seatStatus: { hasSeat: (seatStat.bookingCount > 0) } | |
| }; | |
| }); | |
| const loginNormName = kNorm(activeUser.FullName || activeUser.Name || ''); | |
| const userCandidates = [ | |
| activeUser.FullName, activeUser.fullname, | |
| activeUser.Name, activeUser.name, | |
| activeUser['Full Name'], activeUser['full name'] | |
| ].filter(v => v); | |
| let userFoundPrefs = null; | |
| for (let n of userCandidates) { | |
| const key = searchEmail + "|" + kNorm(n); | |
| if (allPrefsMap[key]) { userFoundPrefs = allPrefsMap[key]; break; } | |
| } | |
| if (!userFoundPrefs) { | |
| for (let n of userCandidates) { | |
| const key = kNorm(n); | |
| if (allPrefsMap[key]) { userFoundPrefs = allPrefsMap[key]; break; } | |
| } | |
| } | |
| return { | |
| success: true, | |
| user: activeUser, // Return the Resolved Primary Identity | |
| isAdmin: isAdminUser, | |
| isPrimary: isPrimary, | |
| familyMembers: familyMembers, | |
| userQuotas: getUserQuotas(searchEmail), | |
| userPreferences: userFoundPrefs, | |
| myBookings: getUserBookings(searchEmail), | |
| seatLabels: getGlobalSeatLabels(), | |
| activePhase: PropertiesService.getScriptProperties().getProperty('ACTIVE_PHASE') || '', | |
| phaseSchedule: getPhaseSchedule(), // Return Schedule | |
| scriptUrl: getScriptUrl(), // Return URL for instant navigation | |
| version: VERSION | |
| }; | |
| } | |
| function getAdminContacts() { | |
| const ss = SpreadsheetApp.openById(DATA_SHEET_ID); | |
| const { rows, headers: rawHeaders } = fetchTabRows(ss, TABS.DATA); | |
| const headers = rawHeaders.map(h => String(h).trim().toLowerCase()); | |
| const adminIdx = headers.indexOf('admin'); | |
| const nameIdx = headers.indexOf('name'); | |
| const phoneIdx = headers.indexOf('cell#'); | |
| if (adminIdx === -1) return "System Error: Admin column not found."; | |
| const admins = rows.filter(r => { | |
| const val = String(r[rawHeaders[adminIdx]] || '').trim().toLowerCase(); | |
| return ['yes', 'y', 'true'].includes(val); | |
| }).map(r => { | |
| const name = String(r[rawHeaders[nameIdx]] || r.FullName || r.fullname || 'Admin').trim(); | |
| const phone = String(r[rawHeaders[phoneIdx]] || '').trim(); | |
| return `• <b>${name}:</b> ${phone}`; | |
| }); | |
| return admins.join('<br>'); | |
| } | |
| function getUserPreferencesMap() { | |
| const ss = SpreadsheetApp.openById(DATA_SHEET_ID); | |
| const sheet = ss.getSheetByName(TABS.USER_MASTER); | |
| if (!sheet) return {}; | |
| const data = sheet.getDataRange().getValues(); | |
| if (data.length < 2) return {}; | |
| const rawHeaders = data[0]; | |
| const headers = rawHeaders.map(h => String(h).trim().toLowerCase()); | |
| const emailIdx = headers.indexOf('email'); | |
| const findN = (ts) => headers.findIndex(h => ts.some(t => h.includes(t.toLowerCase()))); | |
| const nameIdx = findN(['fullname', 'name', 'user']); | |
| if (emailIdx === -1 || nameIdx === -1) return {}; | |
| const map = {}; | |
| data.slice(1).forEach(row => { | |
| const email = String(row[emailIdx] || '').trim().toLowerCase(); | |
| const name = kNorm(row[nameIdx] || ''); | |
| const obj = mapRowToObj(rawHeaders, row); | |
| if (email) { | |
| map[email + "|" + name] = obj; | |
| } | |
| // Fallback: also store by name only if not already present or if email is blank | |
| if (name && !map[name]) { | |
| map[name] = obj; | |
| } | |
| }); | |
| return map; | |
| } | |
| function submitPreferences(form) { | |
| const ss = SpreadsheetApp.openById(DATA_SHEET_ID); | |
| const umSheet = ss.getSheetByName(TABS.USER_MASTER); | |
| const umData = umSheet.getDataRange().getValues(); | |
| const rawHeaders = umData[0]; | |
| const headers = rawHeaders.map(h => String(h).trim().toLowerCase()); | |
| const emailIdx = headers.indexOf('email'); | |
| const nameIdx = headers.findIndex(h => h === 'name' || h === 'fullname'); | |
| const dataTabUser = getUserFromDataTab(form.email, form.uid); | |
| const searchEmail = String(form.email).trim().toLowerCase(); | |
| const searchName = kNorm(dataTabUser.FullName || dataTabUser.fullname || dataTabUser.Name || dataTabUser.name || ''); | |
| // Find row by BOTH Email and Name | |
| let rowIndex = -1; | |
| if (umData.length > 1) { | |
| rowIndex = umData.slice(1).findIndex(r => { | |
| const rowEmail = String(r[emailIdx]).trim().toLowerCase(); | |
| const rowName = kNorm(r[nameIdx] || ''); | |
| return rowEmail === searchEmail && rowName === searchName; | |
| }); | |
| } | |
| let attendance = []; | |
| if (form.att_daily) attendance.push('Daily'); | |
| if (form.att_wkd) attendance.push('Weekends'); | |
| if (form.att_lf) attendance.push('Layali Fadela'); | |
| if (form.att_lq) attendance.push('Lailatul Qadr'); | |
| const attStr = attendance.join(', '); | |
| // Ensure we don't wipe formulas in Name, FName, MName, LName | |
| const existingRow = (rowIndex > -1) ? umData[rowIndex + 1] : rawHeaders.map(() => ''); | |
| const newRow = rawHeaders.map((header, i) => { | |
| const h = String(header).trim().toLowerCase(); | |
| if (h === 'email') return form.email; | |
| // AUTO-FILL from Data Logic (User Request) | |
| if (h === 'fullname') return dataTabUser.FullName || dataTabUser.fullname || dataTabUser.Name || dataTabUser.name || ''; | |
| if (h === 'name') return dataTabUser.Name || dataTabUser.name || ''; // Auto-fill Name | |
| if (h === 'hofids') return dataTabUser.HOFIDs || dataTabUser.hofids || ''; // Auto-fill HOFIDs | |
| if (h === 'phone') return dataTabUser['Cell#'] || dataTabUser.phone || ''; | |
| // RULE: Preserve these columns (filled via formula) - REMOVED 'name' from list | |
| if (['fname','mname','lname'].includes(h)) return existingRow[i]; | |
| if (h === 'attendance') return attStr; | |
| if (h === 'chair') return form.chair; | |
| if (String(header).trim() === 'Farzando=<awwala' || h === 'farzando') return form.farzando; | |
| if (h.includes('names(age)') && !h.includes('dikri')) return form.farzando_details || ''; | |
| if (String(header).trim() === 'Dikrio=>sania(non-misaq)' || h === 'dikrio') return form.dikrio; | |
| if (h.includes('dikrinames')) return form.dikrio_details || ''; | |
| if (h === 'jaali') return form.jaali; | |
| if (h === 'mehmaan') return form.mehmaan; | |
| if (h === 'mhname') return form.mehmaan_name || ''; | |
| if (h === 'mhemail') return form.mehmaan_email || ''; | |
| if (h === 'mhphone') return form.mehmaan_phone || ''; | |
| return ''; | |
| }); | |
| if (rowIndex > -1) { | |
| umSheet.getRange(rowIndex + 2, 1, 1, newRow.length).setValues([newRow]); | |
| } else { | |
| const lastRow = umSheet.getLastRow(); | |
| let tRow = lastRow + 1; | |
| // Finding first truly empty row to avoid gaps | |
| const colData = umSheet.getRange(1, emailIdx + 1, lastRow || 1).getValues(); | |
| for (let i = (lastRow || 1) - 1; i >= 0; i--) { | |
| if (String(colData[i][0]).trim() !== '') { tRow = i + 2; break; } | |
| } | |
| if (tRow > umSheet.getMaxRows()) umSheet.insertRowAfter(umSheet.getMaxRows()); | |
| umSheet.getRange(tRow, 1, 1, newRow.length).setValues([newRow]); | |
| } | |
| syncAvailableCapacity(form.email, form.jaali, dataTabUser, attStr); | |
| return loginUser(form.email); | |
| } | |
| function getUserFromDataTab(email, uid) { | |
| const ss = SpreadsheetApp.openById(DATA_SHEET_ID); | |
| const { rows, headers } = fetchTabRows(ss, TABS.DATA); | |
| // Try UID first (direct lookup) | |
| if (uid) { | |
| const user = rows.find(r => r._rowNum === Number(uid)); | |
| if (user) return user; | |
| } | |
| // Fallback to email | |
| const searchEmail = String(email).trim().toLowerCase(); | |
| const rawHeaders = headers; | |
| const emailProp = rawHeaders.find(h => String(h).trim().toLowerCase() === 'email'); | |
| const user = rows.find(r => String(r[emailProp]).trim().toLowerCase() === searchEmail); | |
| return user || {}; | |
| } | |
| /** | |
| * RECONCILIATION: Rebuilds AvailableCapacity from scratch using UserMaster data. | |
| * Useful for testing and manual data cleanup. | |
| */ | |
| function syncAllAvailableCapacity() { | |
| const ss = SpreadsheetApp.openById(DATA_SHEET_ID); | |
| // 1. Get UserMaster rows | |
| const { rows: umRows, headers: umHeaders } = fetchTabRows(ss, TABS.USER_MASTER); | |
| if (umRows.length === 0) return "UserMaster is empty."; | |
| // 2. Filter for Jaali: Yes | |
| const jaaliUsers = umRows.filter(r => { | |
| const jaaliVal = String(r.Jaali || r.jaali || '').trim().toLowerCase(); | |
| return jaaliVal === 'yes'; | |
| }); | |
| // 3. Get AvailableCapacity Sheet & Headers | |
| const capSheet = ss.getSheetByName(TABS.AVAILABLE_CAPACITY); | |
| const capAll = capSheet.getDataRange().getValues(); | |
| const capHIdx = findHeaderRow(capAll); | |
| const capRawHeaders = capAll[capHIdx]; | |
| const currentCapRows = fetchTabRows(ss, TABS.AVAILABLE_CAPACITY).rows; | |
| const capHeaders = capRawHeaders.map(h => String(h).trim().toLowerCase()); | |
| Logger.log(`Found ${jaaliUsers.length} Jaali users in UserMaster.`); | |
| // 4. Transform Jaali users into AvailableCapacity rows | |
| const newCapRows = jaaliUsers.map(u => { | |
| const email = (u.Email || u.email || '').toLowerCase(); | |
| const existing = currentCapRows.find(e => (e.Email || e.email || '').toLowerCase() === email); | |
| return capRawHeaders.map((h, i) => { | |
| const head = String(h).trim().toLowerCase(); | |
| // Sync Requested Fields | |
| if (head === 'fullname') return u.FullName || u.fullname || ''; | |
| if (head === 'name') return u.Name || u.name || ''; | |
| if (head === 'email') return u.Email || u.email || ''; | |
| if (head === 'phone') return u.Phone || u.phone || u['Cell#'] || ''; | |
| if (head === 'attendance' || head === 'attendancemode') return u.Attendance || u.attendance || ''; | |
| if (head === 'hofids') return u.HOFIDs || u.hofids || ''; // Added HOFIDs | |
| const cleanKey = String(h).trim(); | |
| const lowerKey = cleanKey.toLowerCase(); | |
| if (lowerKey.includes('admin') || lowerKey.includes('used') || lowerKey.includes('left') || lowerKey.includes('quota') || lowerKey === 'preflock' || lowerKey === 'notes') { | |
| return (existing && (existing[cleanKey] !== undefined || existing[lowerKey] !== undefined)) ? (existing[cleanKey] ?? existing[lowerKey]) : ''; | |
| } | |
| return ''; | |
| }); | |
| }); | |
| // 5. Clear old data and write new | |
| const lastRow = capSheet.getLastRow(); | |
| if (lastRow > capHIdx + 1) { | |
| capSheet.getRange(capHIdx + 2, 1, lastRow - capHIdx - 1, capRawHeaders.length).clearContent(); | |
| } | |
| if (newCapRows.length > 0) { | |
| capSheet.getRange(capHIdx + 2, 1, newCapRows.length, capRawHeaders.length).setValues(newCapRows); | |
| } | |
| Logger.log(`Synced ${newCapRows.length} users to AvailableCapacity.`); | |
| // 6. Recalculate Quotas | |
| if (typeof recalculateQuotas === 'function') { | |
| recalculateQuotas(capSheet, capRawHeaders); | |
| } | |
| return `Synced ${newCapRows.length} users to AvailableCapacity.`; | |
| } | |
| /** | |
| * Diagnostic/Test runner for the full sync | |
| */ | |
| function testSyncAll() { | |
| try { | |
| const result = syncAllAvailableCapacity(); | |
| Logger.log(result); | |
| return result; | |
| } catch (e) { | |
| Logger.log("Error in syncAll: " + e.message); | |
| throw e; | |
| } | |
| } | |
| function syncAvailableCapacity(email, jaali, userData, attendanceStr) { | |
| Logger.log('=== syncAvailableCapacity START ==='); | |
| Logger.log('Email: ' + email); | |
| Logger.log('Jaali: ' + jaali); | |
| Logger.log('UserData: ' + JSON.stringify(userData)); | |
| Logger.log('Attendance: ' + attendanceStr); | |
| const ss = SpreadsheetApp.openById(DATA_SHEET_ID); | |
| const capSheet = ss.getSheetByName(TABS.AVAILABLE_CAPACITY); | |
| if (!capSheet) { | |
| Logger.log('ERROR: AvailableCapacity sheet not found!'); | |
| return; | |
| } | |
| const { rows, headers: rawHeaders } = fetchTabRows(ss, TABS.AVAILABLE_CAPACITY); | |
| Logger.log('Headers: ' + JSON.stringify(rawHeaders)); | |
| Logger.log('Existing rows count: ' + rows.length); | |
| const searchEmail = String(email).trim().toLowerCase(); | |
| const existing = rows.find(r => { | |
| const emailHead = rawHeaders.find(h => String(h).toLowerCase().trim() === 'email'); | |
| return String(r[emailHead]).trim().toLowerCase() === searchEmail; | |
| }); | |
| const rowIndex = existing ? existing._rowNum : -1; | |
| Logger.log('Existing row index: ' + rowIndex); | |
| const existingRowValues = (rowIndex > -1) ? capSheet.getRange(rowIndex, 1, 1, rawHeaders.length).getValues()[0] : rawHeaders.map(() => ''); | |
| const jaaliLower = String(jaali).trim().toLowerCase(); | |
| Logger.log('Jaali (normalized): "' + jaaliLower + '"'); | |
| if (jaaliLower === 'yes') { | |
| Logger.log('User is eligible (Jaali=Yes) - Adding to AvailableCapacity'); | |
| const newRowData = rawHeaders.map((h, i) => { | |
| const head = String(h).trim().toLowerCase(); | |
| if (head === 'fullname') return userData.FullName || userData.fullname || ''; | |
| if (head === 'name') return userData.Name || userData.name || ''; | |
| if (head === 'email') return email; | |
| if (head === 'phone') return userData['Cell#'] || userData.phone || ''; | |
| if (head === 'attendance') return attendanceStr || ''; | |
| if (head === 'hofids') return userData.HOFIDs || userData.hofids || ''; // Added HOFIDs | |
| const val = (existingRowValues[i] !== undefined && existingRowValues[i] !== null) ? existingRowValues[i] : ''; | |
| return val; | |
| }); | |
| Logger.log('New row data: ' + JSON.stringify(newRowData)); | |
| if (rowIndex > -1) { | |
| Logger.log('Updating existing row at index: ' + rowIndex); | |
| capSheet.getRange(rowIndex, 1, 1, newRowData.length).setValues([newRowData]); | |
| Logger.log('Row updated successfully'); | |
| } else { | |
| Logger.log('Adding new row'); | |
| // Find first empty row to avoid gaps | |
| const emailIdx = rawHeaders.findIndex(h => String(h).trim().toLowerCase() === 'email'); | |
| const checkCol = (emailIdx > -1) ? emailIdx + 1 : 1; // 1-based column index | |
| const lastRow = capSheet.getLastRow(); | |
| let targetRow = lastRow + 1; | |
| Logger.log('Last row: ' + lastRow + ', Check column: ' + checkCol); | |
| // Scan for empty slot | |
| const colData = capSheet.getRange(1, checkCol, lastRow > 0 ? lastRow : 1).getValues(); | |
| for (let r = 1; r < colData.length; r++) { | |
| if (String(colData[r][0]).trim() === '') { | |
| targetRow = r + 1; | |
| Logger.log('Found empty slot at row: ' + targetRow); | |
| break; | |
| } | |
| } | |
| Logger.log('Writing to row: ' + targetRow); | |
| capSheet.getRange(targetRow, 1, 1, newRowData.length).setValues([newRowData]); | |
| Logger.log('New row added successfully at row ' + targetRow); | |
| } | |
| Logger.log('Calling recalculateQuotas...'); | |
| if (typeof recalculateQuotas === 'function') { | |
| recalculateQuotas(capSheet, rawHeaders); | |
| Logger.log('recalculateQuotas completed'); | |
| } else { | |
| Logger.log('WARNING: recalculateQuotas function not found'); | |
| } | |
| } else if (rowIndex > -1) { | |
| Logger.log('User is NOT eligible (Jaali != Yes) - Removing from AvailableCapacity'); | |
| capSheet.deleteRow(rowIndex); | |
| Logger.log('Row deleted at index: ' + rowIndex); | |
| if (typeof recalculateQuotas === 'function') { | |
| recalculateQuotas(capSheet, rawHeaders); | |
| Logger.log('recalculateQuotas completed after deletion'); | |
| } | |
| } else { | |
| Logger.log('User is NOT eligible (Jaali != Yes) and not in AvailableCapacity - No action needed'); | |
| } | |
| Logger.log('=== syncAvailableCapacity END ==='); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <!-- VERSION: V4.9.0 (Refactored Codebase - Extracted CSS & Preferences) --> | |
| <html> | |
| <head> | |
| <base target="_top"> | |
| <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600&display=swap" rel="stylesheet"> | |
| <?!= include('styles'); ?> | |
| <script> | |
| /* | |
| REMOVED SAFETY TIMEOUT (4s) | |
| To prevent "Refresh -> Login Page" loop on slow connections. | |
| */ | |
| /* Modal Logic */ | |
| function showModal(title, message, type) { | |
| document.getElementById('ui-modal-title').innerText = title || 'Notification'; | |
| const body = document.getElementById('ui-modal-body'); | |
| if (body) body.innerHTML = message; | |
| const overlay = document.getElementById('ui-modal-overlay'); | |
| if (overlay) overlay.classList.remove('hidden'); | |
| // Optional: Color code title based on type | |
| const titleEl = document.getElementById('ui-modal-title'); | |
| if (titleEl) { | |
| if (type === 'error') titleEl.style.color = '#D32F2F'; | |
| else if (type === 'success') titleEl.style.color = '#388E3C'; | |
| else titleEl.style.color = 'var(--color-primary)'; | |
| } | |
| } | |
| function closeModal() { | |
| const overlay = document.getElementById('ui-modal-overlay'); | |
| if (overlay) overlay.classList.add('hidden'); | |
| const confirmBtn = document.getElementById('modal-confirm-btn'); | |
| if (confirmBtn) confirmBtn.classList.add('hidden'); | |
| } | |
| function showConfirm(title, message, onConfirm) { | |
| showModal(title, message, 'info'); | |
| const confirmBtn = document.getElementById('modal-confirm-btn'); | |
| if (confirmBtn) { | |
| confirmBtn.classList.remove('hidden'); | |
| confirmBtn.onclick = function () { | |
| closeModal(); | |
| onConfirm(); | |
| }; | |
| } | |
| } | |
| let currentUser = null; | |
| let adminDashboardUrl = ''; | |
| var urlParams = {}; | |
| window.onload = function () { | |
| // Populate urlParams from window.location | |
| window.location.search.substring(1).split('&').forEach(pair => { | |
| const [k, v] = pair.split('='); | |
| if (k) urlParams[k] = decodeURIComponent(v || ''); | |
| }); | |
| // EMERGENCY RESET: If ?reset=true is in URL, clear everything and show login | |
| if (urlParams.reset === 'true') { | |
| localStorage.removeItem('shehrullah_email'); | |
| sessionStorage.removeItem('shehrullah_pref_view'); | |
| currentUser = null; | |
| console.log('Emergency reset triggered - cleared all saved data'); | |
| // Remove reset param from URL | |
| window.history.replaceState({}, document.title, window.location.pathname); | |
| return; // Don't auto-login | |
| } | |
| // LOGOUT: If ?logout=true is in URL, show Shukran page | |
| if (urlParams.logout === 'true') { | |
| localStorage.removeItem('shehrullah_email'); | |
| sessionStorage.removeItem('shehrullah_pref_view'); | |
| currentUser = null; | |
| console.log('Logout triggered - showing Shukran page'); | |
| // Show Shukran view | |
| switchView('view-shukran'); | |
| // Remove logout param from URL | |
| window.history.replaceState({}, document.title, window.location.pathname); | |
| return; // Don't auto-login | |
| } | |
| // CRITICAL: Initialize views to correct state FIRST | |
| // Ensure login view is visible and other views are hidden on page load | |
| console.log('Initializing views on page load'); | |
| const viewLogin = document.getElementById('view-login'); | |
| const viewDash = document.getElementById('view-dashboard'); | |
| const viewShukran = document.getElementById('view-shukran'); | |
| // Default state: show login, hide everything else | |
| if (viewLogin) viewLogin.classList.remove('hidden'); | |
| if (viewDash) viewDash.classList.add('hidden'); | |
| if (viewShukran) viewShukran.classList.add('hidden'); | |
| const savedEmail = localStorage.getItem('shehrullah_email'); | |
| const emailInput = document.getElementById('email'); | |
| const form = document.getElementById('login-form'); // CRITICAL: Define here, not just inside doLogin | |
| const loader = document.getElementById('loader'); // CRITICAL: Define here, not just inside doLogin | |
| // Handle email from URL params (for Admin -> User dashboard navigation) | |
| if (urlParams.email && emailInput) { | |
| console.log("Email from URL params:", urlParams.email); | |
| emailInput.value = urlParams.email; | |
| // Clean URL to avoid leaking email in address bar | |
| window.history.replaceState({}, document.title, window.location.pathname + (urlParams.forceUser ? '?forceUser=true' : '')); | |
| } | |
| // 0. CHECK SAVED STATE FIRST | |
| const emailToUse = urlParams.email || savedEmail; | |
| // Sanitization | |
| const isValidEmail = (emailToUse && emailToUse !== 'undefined' && emailToUse !== 'null'); | |
| if (isValidEmail && emailInput) { | |
| // AUTO-LOGIN PATH: Keep form hidden, show loader immediately | |
| emailInput.value = emailToUse; | |
| if (form) form.classList.add('hidden'); | |
| if (loader) loader.style.display = 'block'; | |
| console.log('Auto-login initiated for:', emailToUse); | |
| // Fail-safe timeout | |
| window.loginTimeoutId = setTimeout(() => { | |
| console.warn('Auto-login timeout - forcing login form'); | |
| if (loader) loader.style.display = 'none'; | |
| if (form) form.classList.remove('hidden'); | |
| }, 7000); | |
| // Add slight delay to ensure DOM is ready? No, run immediately for speed. | |
| // Actually, slight delay helps UI render the loader first before main thread blocks | |
| setTimeout(() => doLogin(), 100); | |
| } else { | |
| // NO SAVED DATA: Show Login Form | |
| console.log('No saved email - showing login form'); | |
| if (form) form.classList.remove('hidden'); | |
| if (loader) loader.style.display = 'none'; | |
| } | |
| }; | |
| // Clear any auto-login timeouts if they exist (cleanup) | |
| if (window.loginTimeoutId) clearTimeout(window.loginTimeoutId); | |
| function doLogin() { | |
| const emailEl = document.getElementById('email'); | |
| const form = document.getElementById('login-form'); | |
| const loader = document.getElementById('loader'); | |
| if (!emailEl) return; | |
| const email = emailEl.value.trim(); | |
| // Clear previous validation errors | |
| const validationError = document.getElementById('email-validation-error'); | |
| if (validationError) validationError.style.display = 'none'; | |
| // Validate email is not empty | |
| if (!email) { | |
| if (validationError) { | |
| validationError.innerText = 'Please enter your email address'; | |
| validationError.style.display = 'block'; | |
| } | |
| // CRITICAL: Ensure form is visible if validation fails | |
| if (form) form.classList.remove('hidden'); | |
| if (loader) loader.style.display = 'none'; | |
| return; | |
| } | |
| // Validate email format | |
| const emailRegex = /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/; | |
| if (!emailRegex.test(email)) { | |
| if (validationError) { | |
| validationError.innerText = 'Please enter a valid email address (e.g., name@example.com)'; | |
| validationError.style.display = 'block'; | |
| } | |
| emailEl.focus(); | |
| // CRITICAL: Ensure form is visible if validation fails | |
| if (form) form.classList.remove('hidden'); | |
| if (loader) loader.style.display = 'none'; | |
| return; | |
| } | |
| const errorEl = document.getElementById('login-error'); | |
| if (loader) loader.style.display = 'block'; | |
| if (errorEl) errorEl.innerText = ''; | |
| google.script.run | |
| .withSuccessHandler(res => { | |
| // Clear auto-login timeout if it exists | |
| if (window.loginTimeoutId) { | |
| clearTimeout(window.loginTimeoutId); | |
| window.loginTimeoutId = null; | |
| } | |
| // Save email to localStorage for session persistence | |
| localStorage.setItem('shehrullah_email', email); | |
| onLogin(res); | |
| }) | |
| .withFailureHandler(e => { | |
| // Clear auto-login timeout if it exists | |
| if (window.loginTimeoutId) { | |
| clearTimeout(window.loginTimeoutId); | |
| window.loginTimeoutId = null; | |
| } | |
| if (loader) loader.style.display = 'none'; | |
| const form = document.getElementById('login-form'); | |
| if (form) form.classList.remove('hidden'); | |
| if (errorEl) { | |
| google.script.run.withSuccessHandler(adminHtml => { | |
| errorEl.innerHTML = `<div style="color: #d32f2f; margin-top: 20px; text-align: left; background: #ffebeb; padding: 15px; border-radius: 8px; font-size: 0.9rem;"> | |
| <b>Login Error:</b> ${e.message} | |
| <br><br> | |
| Please contact an Admin for assistance:<br> | |
| ${adminHtml || 'Admin contact info unavailable.'} | |
| </div>`; | |
| }).getAdminContacts(); | |
| } | |
| }) | |
| .loginUser(email); | |
| } | |
| function onLogin(res) { | |
| const loader = document.getElementById('loader'); | |
| if (loader) loader.style.display = 'none'; | |
| try { | |
| currentUser = res; | |
| console.log("DASHBOARD_DATA:", res); | |
| if (res.version) { | |
| console.log("VERSION_ACTIVE:", res.version); | |
| } | |
| if (res.isAdmin) { | |
| const isAlreadyOnDash = (document.getElementById('view-dashboard').classList.contains('hidden') === false); | |
| const hasForcedUser = (urlParams && (urlParams.forceUser === 'true' || urlParams.forceUser === true)); | |
| // Check Session Storage for preference (Independent of current view state) | |
| const preferredView = sessionStorage.getItem('shehrullah_pref_view'); | |
| if (hasForcedUser) { | |
| // URL explicitly says to force user dashboard | |
| console.log('forceUser=true detected, calling showUserDashboard()'); | |
| showUserDashboard(); | |
| } else if (preferredView === 'user') { | |
| // Persistence: User was previously viewing User Dashboard -> Restore it | |
| console.log('restoring user dashboard preference'); | |
| initDashboard(); // Admin viewing as User | |
| } else if (preferredView === 'user') { | |
| // Persistence: User was previously viewing User Dashboard -> Restore it | |
| console.log('restoring user dashboard preference'); | |
| initDashboard(); // Admin viewing as User | |
| } else if (preferredView === 'admin' && res.isAdmin && res.scriptUrl) { | |
| // Persistence: Admin was on Admin Dashboard -> Restore it | |
| console.log('restoring admin dashboard preference'); | |
| window.location.href = res.scriptUrl + '?page=AdminDashboard'; | |
| } else { | |
| // Fresh login / Admin Choice State | |
| // Clear any old session preference to show admin choice | |
| sessionStorage.removeItem('shehrullah_pref_view'); | |
| if (res.scriptUrl) { | |
| adminDashboardUrl = res.scriptUrl + '?page=AdminDashboard'; | |
| const choice = document.getElementById('admin-choice'); | |
| const loginView = document.getElementById('view-login'); | |
| const normalButtons = document.getElementById('normal-buttons'); | |
| if (choice) choice.classList.remove('hidden'); | |
| // Hide normal login/logout buttons when admin choice is shown | |
| if (normalButtons) normalButtons.style.display = 'none'; | |
| // CRITICAL FIX: Ensure the parent container is visible! | |
| if (loginView) loginView.classList.remove('hidden'); | |
| } else { | |
| console.error("Script URL missing in login response"); | |
| alert("System validation error. Please try logging in again."); | |
| // Ensure login view is visible to retry | |
| switchView('view-login'); | |
| } | |
| } | |
| } else { | |
| initDashboard(); | |
| } | |
| } catch (e) { | |
| console.error("Critical error during login processing:", e); | |
| showModal("System Error", "Failed to load dashboard data. Please try again.", "error"); | |
| // Fallback to login view | |
| switchView('view-login'); | |
| const form = document.getElementById('login-form'); | |
| if (form) form.classList.remove('hidden'); | |
| } | |
| } | |
| function showUserDashboard() { | |
| console.log('showUserDashboard called'); | |
| sessionStorage.setItem('shehrullah_pref_view', 'user'); | |
| const loginView = document.getElementById('view-login'); | |
| const dashView = document.getElementById('view-dashboard'); | |
| if (loginView) loginView.classList.add('hidden'); | |
| // Ensure dashboard view is visible | |
| if (dashView) { | |
| dashView.classList.remove('hidden'); | |
| console.log('Dashboard view should now be visible'); | |
| } | |
| // Add error handling | |
| try { | |
| initDashboard(); | |
| } catch (err) { | |
| console.error('Dashboard initialization failed:', err); | |
| showModal('Error', 'Failed to load dashboard. Please refresh the page.', 'error'); | |
| if (loginView) loginView.classList.remove('hidden'); | |
| if (dashView) dashView.classList.add('hidden'); | |
| } | |
| } | |
| // Safety fallback: If page is blank after 8 seconds, force login view | |
| // REMOVED BY ANTIGRAVITY: This might be causing the refresh loop if loading takes longer or if logic is racy. | |
| // Let's rely on the Loader -> Login Error path instead. | |
| /* | |
| setTimeout(function () { | |
| const dash = document.getElementById('view-dashboard'); | |
| const login = document.getElementById('view-login'); | |
| const shukran = document.getElementById('view-shukran'); | |
| // Check if all are hidden (with null checks) | |
| if (dash && login && shukran && | |
| dash.classList.contains('hidden') && | |
| login.classList.contains('hidden') && | |
| shukran.classList.contains('hidden')) { | |
| console.warn("Blank page detected. Forcing login view."); | |
| switchView('view-login'); | |
| } | |
| }, 8000); | |
| */ | |
| function openAdminLink() { | |
| // Open in SAME WINDOW as requested | |
| if (!adminDashboardUrl) { | |
| google.script.run.withSuccessHandler(u => { | |
| window.location.href = u + '?page=AdminDashboard'; | |
| }).getScriptUrl(); | |
| } else { | |
| window.location.href = adminDashboardUrl; | |
| } | |
| } | |
| function switchView(viewId) { | |
| console.log('switchView called with:', viewId); | |
| // Hide all views | |
| const views = ['view-login', 'view-dashboard', 'view-shukran']; | |
| views.forEach(id => { | |
| const el = document.getElementById(id); | |
| if (el) { | |
| el.classList.add('hidden'); | |
| console.log('Hiding:', id); | |
| } | |
| }); | |
| // Show the requested view | |
| const targetView = document.getElementById(viewId); | |
| if (targetView) { | |
| targetView.classList.remove('hidden'); | |
| console.log('Showing:', viewId); | |
| } else { | |
| console.error('Target view not found:', viewId); | |
| // Fallback: show login if target doesn't exist | |
| const loginView = document.getElementById('view-login'); | |
| if (loginView) loginView.classList.remove('hidden'); | |
| } | |
| } | |
| function doLogout() { | |
| console.log('doLogout called'); | |
| // Clear user data | |
| localStorage.removeItem('shehrullah_email'); | |
| sessionStorage.removeItem('shehrullah_pref_view'); | |
| currentUser = null; | |
| // Switch to Shukran view | |
| switchView('view-shukran'); | |
| } | |
| function initDashboard() { | |
| try { | |
| // Validate currentUser exists | |
| if (!currentUser || !currentUser.user) { | |
| console.error('initDashboard: currentUser is invalid', currentUser); | |
| throw new Error('User data not available. Please log in again.'); | |
| } | |
| switchView('view-dashboard'); | |
| const user = currentUser.user; | |
| const userName = user.FullName || user.fullname || user.Name || user.name || "User"; | |
| const userRawEmail = user.Email || user.email || ""; | |
| const userPhone = user['Cell#'] || user['cell#'] || user.Phone || user.phone || ""; | |
| const detailsEl = document.getElementById('user-details'); | |
| if (detailsEl) { | |
| detailsEl.innerHTML = ` | |
| <div style="font-weight:600; font-size:1.1rem; margin-top:5px; color:#333;">${userName}</div> | |
| <div style="font-size:0.85rem; color:#666;">${userRawEmail} | ${userPhone}</div>`; | |
| } | |
| const adminBtn = document.getElementById('btn-admin-dash'); | |
| if (currentUser.isAdmin && adminBtn) adminBtn.classList.remove('hidden'); | |
| // 1. Render Quota Stats | |
| try { | |
| console.log('currentUser.userQuotas:', currentUser.userQuotas); | |
| if (currentUser.userQuotas) { | |
| updateDashboardStats('daily', currentUser.userQuotas.daily); | |
| updateDashboardStats('lf', currentUser.userQuotas.lf); | |
| } | |
| } catch (e) { console.error('Error rendering quotas:', e); } | |
| // 2. Render My Bookings | |
| try { | |
| if (currentUser.myBookings) { | |
| renderBookingSummary(currentUser.myBookings, currentUser.seatLabels); | |
| } | |
| } catch (e) { console.error('Error rendering bookings:', e); } | |
| // 3. Auto-Refresh Active Modular Grid | |
| try { | |
| if (window.activeModularType) { | |
| loadSeatSelectionModular(window.activeModularType); | |
| } | |
| } catch (e) { console.error('Error loading modular grid:', e); } | |
| // 4. Render Preferences | |
| try { | |
| const prefs = currentUser.userPreferences; | |
| if (prefs && (prefs.Attendance || prefs.attendance)) { | |
| renderPreferencesInline(prefs); | |
| } else { | |
| renderPreferencesForm({}); | |
| } | |
| } catch (e) { console.error('Error rendering prefs:', e); } | |
| // 5. Gate Seat Selection Visibility | |
| try { | |
| const seatNav = document.getElementById('seat-nav-section'); | |
| const prefs = currentUser.userPreferences || {}; // Safety fallback | |
| const hasJaali = (prefs && (prefs.Jaali === 'Yes' || prefs.jaali === 'Yes')); | |
| const hasActivePhase = currentUser.activePhase && currentUser.activePhase.trim() !== ''; | |
| // LF VISIBILITY CHECK (User Side) | |
| const lfUserSection = document.getElementById('user-section-lf'); | |
| const activePhaseNum = parseInt(currentUser.activePhase) || 0; | |
| if (lfUserSection) { | |
| if (activePhaseNum < 2) { | |
| lfUserSection.classList.add('hidden'); | |
| } else { | |
| lfUserSection.classList.remove('hidden'); | |
| } | |
| } | |
| if (seatNav) { | |
| if (hasJaali && hasActivePhase) { | |
| seatNav.classList.remove('hidden'); | |
| } else { | |
| seatNav.classList.add('hidden'); | |
| } | |
| } | |
| } catch (e) { console.error('Error gating visibility:', e); } | |
| // 6. Render Family Members | |
| try { | |
| const famSec = document.getElementById('family-section'); | |
| const list = document.getElementById('family-list'); | |
| if (currentUser.isPrimary && currentUser.familyMembers && currentUser.familyMembers.length > 0) { | |
| // ... existing family render logic ... | |
| // We need to keep the inner logic intact or it's too much code to replace blindly. | |
| // Let's rely on the outer try-catch for the complex family logic for now, | |
| // OR better: copy the family logic since I don't want to break it. | |
| // Actually, the ReplacementChunk needs to match EXACT text. | |
| // Since I cannot see the full family logic in the view_file output above (it was truncated?), | |
| // I should be careful. | |
| // The view_file output showed lines 500-581. I can replace that block safely. | |
| renderFamilySectionSafe(famSec, list, currentUser); | |
| } else { | |
| if (famSec) famSec.classList.add('hidden'); | |
| } | |
| } catch (e) { console.error('Error rendering family:', e); } | |
| } catch (error) { | |
| console.error('Dashboard initialization failed:', error); | |
| // Clear saved email and show login | |
| localStorage.removeItem('shehrullah_email'); | |
| currentUser = null; | |
| const loader = document.getElementById('loader'); | |
| if (loader) loader.style.display = 'none'; | |
| const form = document.getElementById('login-form'); | |
| if (form) form.classList.remove('hidden'); | |
| switchView('view-login'); | |
| showModal('Dashboard Error', | |
| 'Failed to load dashboard: ' + error.message + '<br><br>Please log in again.', | |
| 'error'); | |
| } | |
| } | |
| function updateDashboardStats(typeKey, qData) { | |
| const box = document.getElementById('dash-stats-' + typeKey); | |
| if (!box) { | |
| console.warn('updateDashboardStats: box not found for', typeKey); | |
| return; | |
| } | |
| if (!qData) { | |
| console.warn('updateDashboardStats: no quota data for', typeKey); | |
| return; | |
| } | |
| console.log('updateDashboardStats:', typeKey, qData); | |
| // Handle multiple possible data structures | |
| // Try different property name variations (case-insensitive) | |
| const lim = qData.limit ?? qData.Limit ?? qData.dailyquota ?? qData.DailyQuota ?? qData.lfquota ?? qData.LFQuota ?? qData.quota ?? qData.Quota ?? 0; | |
| const usd = qData.used ?? qData.Used ?? qData.dailyused ?? qData.DailyUsed ?? qData.lfused ?? qData.LFUsed ?? 0; | |
| const lft = qData.left ?? qData.Left ?? qData.dailyleft ?? qData.DailyLeft ?? qData.lfleft ?? qData.LFLeft ?? (lim - usd); // Calculate if not provided | |
| const limitEl = box.querySelector('.stat-limit'); | |
| const usedEl = box.querySelector('.stat-used'); | |
| const leftEl = box.querySelector('.stat-left'); | |
| if (limitEl) limitEl.innerText = lim; | |
| if (usedEl) usedEl.innerText = usd; | |
| if (leftEl) { | |
| leftEl.innerText = lft; | |
| if (lft <= 0) leftEl.style.color = 'red'; | |
| else leftEl.style.color = 'var(--color-accent)'; | |
| } | |
| } | |
| function renderBookingSummary(bookings, labels) { | |
| const legendBox = document.getElementById('dashboard-seat-legend'); | |
| const dailyContainer = document.getElementById('booking-list-daily'); | |
| const lfContainer = document.getElementById('booking-list-lf'); | |
| const dailyBox = document.getElementById('table-daily-container'); | |
| const lfBox = document.getElementById('table-lf-container'); | |
| // 1. Dynamic Legend Logic | |
| if (labels && legendBox) { | |
| const usedCodes = new Set(); | |
| bookings.forEach(b => { | |
| // Robust match: Extract leading letters (e.g. "BRHN" from "BRHN-12" or "BRHN12") | |
| const match = String(b.seat).match(/^([A-Z]+)/i); | |
| const code = match ? match[1].toUpperCase() : ''; | |
| if (labels[code]) { | |
| usedCodes.add(code); | |
| } | |
| }); | |
| const legendParts = []; | |
| // Use the order in 'labels' but only include used ones | |
| for (let code in labels) { | |
| if (usedCodes.has(code)) { | |
| legendParts.push(`<b>${code}</b>: ${labels[code]}`); | |
| } | |
| } | |
| if (legendParts.length > 0) { | |
| legendBox.innerHTML = `<b>Legend: </b> ` + legendParts.join(' | '); | |
| legendBox.style.display = 'block'; | |
| legendBox.style.marginBottom = '10px'; | |
| } else { | |
| legendBox.style.display = 'none'; | |
| } | |
| } | |
| // 2. Filter Bookings | |
| const dailyBookings = bookings.filter(b => b.type === 'Daily'); | |
| const lfBookings = bookings.filter(b => b.type === 'LF'); | |
| // 3. Render Helper (Updated for Bulk Delete) | |
| const renderTable = (list, container, parentBox, typeTag) => { | |
| if (!container || !parentBox) return; | |
| // Clear previous content including button if it exists | |
| container.innerHTML = ''; | |
| if (list.length === 0) { | |
| container.innerHTML = '<div style="font-size:0.85rem; color:#999; font-style:italic; padding:10px;">No seats selected yet.</div>'; | |
| parentBox.classList.remove('hidden'); | |
| // Hide Delete Button if it acts globally, or ensure we don't append it | |
| return; | |
| } | |
| parentBox.classList.remove('hidden'); | |
| // Create Master Control Div | |
| const controlDiv = document.createElement('div'); | |
| controlDiv.style.marginBottom = "5px"; | |
| controlDiv.style.textAlign = "right"; | |
| const deleteBtn = document.createElement('button'); | |
| deleteBtn.className = "btn"; | |
| deleteBtn.style.background = "#FFE5E5"; | |
| deleteBtn.style.color = "#D32F2F"; | |
| deleteBtn.style.fontSize = "0.75rem"; | |
| deleteBtn.style.padding = "4px 8px"; | |
| deleteBtn.style.border = "none"; | |
| deleteBtn.style.borderRadius = "4px"; | |
| deleteBtn.style.display = "none"; // Hidden by default | |
| deleteBtn.innerText = "Delete Selected"; | |
| deleteBtn.onclick = () => confirmBulkDelete(typeTag, list); | |
| controlDiv.appendChild(deleteBtn); | |
| container.appendChild(controlDiv); | |
| let html = ` | |
| <div class="table-container" style="overflow-x:auto;"> | |
| <table style="width:100%; min-width:300px; margin:0 auto; border-collapse:collapse; background:#fff; font-size:0.85rem; border:1px solid #eee;"> | |
| <thead style="background:#f9f9f9; position:sticky; top:0; z-index:10;"> | |
| <tr> | |
| <th style="text-align:left; padding:8px; border-bottom:2px solid #eee; width:50%;">Date</th> | |
| <th style="text-align:left; padding:8px; border-bottom:2px solid #eee; width:35%;">Seat</th> | |
| <th style="text-align:center; padding:8px; border-bottom:2px solid #eee; width:15%;"> | |
| <!-- Select All Header Logic --> | |
| </th> | |
| </tr> | |
| </thead> | |
| <tbody>`; | |
| list.forEach((b, idx) => { | |
| // Unique ID for Checkbox | |
| const chkId = `chk-${typeTag}-${idx}`; | |
| html += ` | |
| <tr> | |
| <td style="padding:8px; border-bottom:1px solid #eee; white-space:nowrap;"> | |
| <label for="${chkId}" style="cursor:pointer; display:block;">${b.label}</label> | |
| </td> | |
| <td style="padding:8px; border-bottom:1px solid #eee;"><b>${b.seat}</b></td> | |
| <td style="padding:8px; border-bottom:1px solid #eee; text-align:center;"> | |
| <input type="checkbox" id="${chkId}" class="chk-delete-${typeTag}" | |
| data-date="${b.date}" data-seat="${b.seat}" | |
| style="transform:scale(1.2); cursor:pointer;" | |
| onchange="toggleDeleteButton('${typeTag}')"> | |
| </td> | |
| </tr> | |
| `; | |
| }); | |
| html += `</tbody></table></div>`; | |
| const tableWrapper = document.createElement('div'); | |
| tableWrapper.innerHTML = html; | |
| container.appendChild(tableWrapper); | |
| }; | |
| // 4. Execute Render | |
| renderTable(dailyBookings, document.getElementById('booking-list-daily'), document.getElementById('table-daily-container'), 'Daily'); | |
| renderTable(lfBookings, document.getElementById('booking-list-lf'), document.getElementById('table-lf-container'), 'LF'); | |
| // 5. Update Button Text (Keep existing login) | |
| const btnDaily = document.getElementById('btn-action-daily'); | |
| if (btnDaily) { | |
| if (dailyBookings.length > 0) { | |
| btnDaily.innerText = "Modify Daily Jagah"; | |
| } else { | |
| btnDaily.innerText = "Select Daily Jagah"; | |
| } | |
| btnDaily.onclick = function () { | |
| loadSeatSelectionModular('Daily'); | |
| }; | |
| } | |
| const btnLF = document.getElementById('btn-action-lf'); | |
| if (btnLF) { | |
| const currentPhase = parseInt(currentUser.activePhase || '0'); | |
| const isLFAllowed = currentPhase >= 2; | |
| if (lfBookings.length > 0) { | |
| btnLF.innerText = "Modify LF Jagah"; | |
| btnLF.disabled = !isLFAllowed; | |
| } else { | |
| btnLF.innerText = isLFAllowed ? "Select LF Jagah" : "Select LF Jagah (Starts Phase 2)"; | |
| btnLF.disabled = !isLFAllowed; | |
| } | |
| if (!isLFAllowed) { | |
| btnLF.style.opacity = '0.6'; | |
| btnLF.style.cursor = 'not-allowed'; | |
| btnLF.title = "Layali Fadela selection starts in Phase 2"; | |
| } else { | |
| btnLF.style.opacity = '1'; | |
| btnLF.style.cursor = 'pointer'; | |
| btnLF.title = ""; | |
| } | |
| btnLF.onclick = function () { | |
| if (!isLFAllowed) { | |
| showModal("Not Available Yet", "Layali Fadela seat selection will begin with Phase 2.", "info"); | |
| return; | |
| } | |
| loadSeatSelectionModular('LF'); | |
| }; | |
| } | |
| } | |
| function toggleDeleteButton(typeTag) { | |
| // Find if any checkbox is checked | |
| const anyChecked = document.querySelector(`.chk-delete-${typeTag}:checked`); | |
| // Find the button (It's the first child of the container in my render logic, but need to be robust) | |
| // Actually, the button is inside 'controlDiv' which is the first child of container. | |
| const containerId = (typeTag === 'Daily') ? 'booking-list-daily' : 'booking-list-lf'; | |
| const container = document.getElementById(containerId); | |
| if (container) { | |
| const btn = container.querySelector('button'); | |
| if (btn) { | |
| btn.style.display = anyChecked ? 'inline-block' : 'none'; | |
| btn.innerText = "Delete Selected"; | |
| } | |
| } | |
| } | |
| function confirmBulkDelete(typeTag, fullList) { | |
| const chks = document.querySelectorAll(`.chk-delete-${typeTag}:checked`); | |
| const valid = []; | |
| const locked = []; | |
| const now = new Date(); | |
| // 1. Validate 48h Lock | |
| for (let chk of chks) { | |
| const dStr = chk.getAttribute('data-date'); | |
| const seat = chk.getAttribute('data-seat'); | |
| let isLocked = false; | |
| try { | |
| const d = new Date(dStr); | |
| if (!isNaN(d.getTime())) { | |
| const diffHrs = (d.getTime() - now.getTime()) / (1000 * 60 * 60); | |
| if (diffHrs < 48) isLocked = true; | |
| } | |
| } catch (e) { console.warn("Date parse error", e); } | |
| if (isLocked) locked.push({ date: dStr, seat: seat }); | |
| else valid.push({ date: dStr, seat: seat }); | |
| } | |
| // Case A: Everything is locked | |
| if (valid.length === 0 && locked.length > 0) { | |
| showModal("Seat Locked", | |
| `To help us plan better, seats become locked and cancellations are disabled during the 48 hours of the selected date.<br><br><b>Locked Seats:</b><br>${locked.map(l => l.seat + ' (' + l.date + ')').join('<br>')}`, | |
| "error"); | |
| return; | |
| } | |
| // Case B: Nothing selected | |
| if (valid.length === 0) return; | |
| // Case C: Mixed or All Valid | |
| let msg = `Are you sure you want to delete these ${valid.length} seats?`; | |
| if (locked.length > 0) { | |
| msg += `<br><br><div style="background:#fff3cd; padding:10px; border-left:4px solid #ffc107; text-align:left; font-size:0.9rem;"> | |
| <b>Warning:</b> ${locked.length} seat(s) are within the 48-hour lock period and will <u>NOT</u> be deleted: | |
| <ul style="margin:5px 0 0 15px; padding:0;">${locked.map(l => '<li>' + l.date + '</li>').join('')}</ul> | |
| </div>`; | |
| } | |
| // Valid List | |
| let listHtml = `<ul style="text-align:left; max-height:150px; overflow-y:auto; margin:10px 0; padding-left:20px;">`; | |
| valid.forEach(s => { | |
| listHtml += `<li><b>${s.date}</b>: ${s.seat}</li>`; | |
| }); | |
| listHtml += `</ul>`; | |
| msg += `<br><b>Deleting:</b>${listHtml}`; | |
| showConfirm("Confirm Deletion", msg, function () { | |
| const email = (currentUser.user.Email || currentUser.user.email); | |
| google.script.run | |
| .withSuccessHandler(res => { | |
| if (res.success) { | |
| // Partial success message if we had locked items? | |
| // The backend only removed valid ones. | |
| let successMsg = `Deleted ${res.count} seats successfully.`; | |
| if (locked.length > 0) { | |
| successMsg += `<br><br><span style="color:#e67e22; font-size:0.85rem;">(${locked.length} locked seats were skipped)</span>`; | |
| } | |
| showModal("Success", successMsg, "success"); | |
| doLogin(); // Refresh | |
| } else { | |
| showModal("Error", res.message, "error"); | |
| } | |
| }) | |
| .withFailureHandler(e => showModal("System Error", e.message, "error")) | |
| .cancelBookingsBatch(email, typeTag, valid); | |
| }); | |
| } | |
| // Keep legacy simple cancel for compatibility or remove? | |
| // User asked to replace it. So I removed the cancelFromDash function call from HTML generation above. | |
| // But I will leave the definition here just in case, or overwrite it. | |
| // Actually, I am overwriting the renderTable closure which used it. | |
| // The cancelFromDash function definition (lines 778-795) is separate. I can leave it or remove it. | |
| // Since I'm replacing lines 689-775, I'll just leave cancelFromDash below line 796 untouched but unused. | |
| // ADDED BY ANTIGRAVITY: Missing function causing crash | |
| function renderFamilySectionSafe(container, list, currentUser) { | |
| if (!container || !list) return; | |
| container.classList.remove('hidden'); | |
| list.innerHTML = ''; | |
| const members = currentUser.familyMembers || []; | |
| if (members.length === 0) { | |
| container.classList.add('hidden'); | |
| return; | |
| } | |
| let html = ''; | |
| members.forEach((fam, idx) => { | |
| // Robust data access | |
| const name = fam.Name || fam.name || fam.FullName || fam.fullname || 'Family Member'; | |
| const hasId = (fam.EJamaatID || fam.ejamaatid) ? true : false; | |
| const idDisplay = hasId ? `(${fam.EJamaatID || fam.ejamaatid})` : ''; | |
| // Calculate status? For now simple list. | |
| // If we have booking info in 'fam', use it. | |
| // But currentUser.familyMembers usually just has profile data. | |
| // The booking summary logic elsewhere handles their seats. | |
| html += `<li style="padding:8px 0; border-bottom:1px solid #eee;"> | |
| <div style="font-weight:500;">${name} <span style="font-size:0.8rem; color:#888;">${idDisplay}</span></div> | |
| </li>`; | |
| }); | |
| list.innerHTML = html; | |
| } | |
| function cancelFromDash(type, date, seat) { | |
| showConfirm("Confirm Cancellation", `Are you sure you want to cancel ${type} seat (${seat}) on ${date}?`, function () { | |
| const email = (currentUser.user.Email || currentUser.user.email); | |
| showModal("Cancelling...", "Please wait.", "info"); | |
| google.script.run | |
| .withSuccessHandler(res => { | |
| if (res.success) { | |
| showModal("Cancelled", "Seat successfully released.", "success"); | |
| doLogin(); // Refresh dashboard | |
| } else { | |
| showModal("Error", res.message, "error"); | |
| } | |
| }) | |
| .withFailureHandler(e => showModal("System Error", e.message, "error")) | |
| .cancelBooking(email, type, date, seat); | |
| }); | |
| } | |
| function renderFamilySectionSafe(famSec, list, currentUser) { | |
| famSec.classList.remove('hidden'); | |
| list.innerHTML = ''; | |
| let foundOthersCount = 0; | |
| const myUid = Number(currentUser.user.uid); | |
| // Update Phase Schedule Display | |
| if (currentUser.phaseSchedule) { | |
| const fmt = (d) => { | |
| if (!d) return 'To be announced'; | |
| try { | |
| const date = new Date(d); | |
| if (isNaN(date.getTime())) return d; // Return original text if not a valid date | |
| return date.toLocaleString('en-US', { | |
| weekday: 'short', | |
| month: 'short', | |
| day: 'numeric', | |
| hour: 'numeric', | |
| minute: '2-digit' | |
| }); | |
| } catch (e) { return d; } | |
| }; | |
| const p1 = document.getElementById('sched-p1'); | |
| const p2 = document.getElementById('sched-p2'); | |
| const p3 = document.getElementById('sched-p3'); | |
| if (p1) p1.innerText = fmt(currentUser.phaseSchedule.p1); | |
| if (p2) p2.innerText = fmt(currentUser.phaseSchedule.p2); | |
| if (p3) p3.innerText = fmt(currentUser.phaseSchedule.p3); | |
| } | |
| currentUser.familyMembers.forEach(mem => { | |
| if (Number(mem.uid) === myUid) return; | |
| foundOthersCount++; | |
| let statusString = ''; | |
| if (mem.isEligible) { | |
| if (mem.preferences) { | |
| const fullPrefStr = getFormattedPreferences(mem.preferences); | |
| statusString = `<div class="prefs" style="margin-top:5px; padding:8px; background:#f9f9f9; border-radius:6px; font-size:0.85rem;">${fullPrefStr}</div>`; | |
| // Only show seat status if they're eligible for Jaali (Chair=No AND Farzando=No) | |
| const p = mem.preferences; | |
| const chair = (p.Chair || p.chair || '').toLowerCase(); | |
| const farzando = (p['Farzando=<awwala'] || p.Farzando || p.farzando || '').toLowerCase(); | |
| const jaali = (p.Jaali || p.jaali || '').toLowerCase(); | |
| const isJaaliEligible = (chair === 'no' && farzando === 'no') || jaali === 'yes'; | |
| if (isJaaliEligible) { | |
| // NEW LOGIC: Only show pending status if a phase is actually active! | |
| const isPhaseActive = currentUser.activePhase && currentUser.activePhase.trim() !== ''; | |
| const s = mem.seatStatus || { hasSeat: false }; | |
| if (s.hasSeat) { | |
| statusString += `<div class="status-success">Daily Seats selected</div>`; | |
| } else if (isPhaseActive) { | |
| statusString += `<div class="status-pending">Seat selection pending</div>`; | |
| } | |
| } | |
| } else { | |
| statusString = '<div class="status-pending">Preferences not filled</div>'; | |
| } | |
| } | |
| const div = document.createElement('div'); | |
| div.className = 'family-card'; | |
| div.innerHTML = ` | |
| <div class="name">${mem.FullName || mem.fullname || mem.Name || mem.name || 'User'}</div> | |
| <div style="font-size:0.8rem; color:#777;">${mem.Email} | ${mem['Cell#']}</div> | |
| ${statusString} | |
| `; | |
| list.appendChild(div); | |
| }); | |
| if (foundOthersCount === 0) famSec.classList.add('hidden'); | |
| } | |
| </script> | |
| <?!= include('preferences'); ?> | |
| <script> | |
| function switchView(id) { | |
| console.log("Switching view to:", id); | |
| ['view-login', 'view-dashboard', 'view-shukran'].forEach(v => { | |
| const el = document.getElementById(v); | |
| if (el) el.classList.add('hidden'); | |
| }); | |
| const target = document.getElementById(id); | |
| if (target) { | |
| target.classList.remove('hidden'); | |
| } else { | |
| console.error("View not found:", id); | |
| alert("System Error: View '" + id + "' missing."); | |
| } | |
| } | |
| function doLogout() { | |
| localStorage.removeItem('shehrullah_email'); | |
| currentUser = null; | |
| document.getElementById('email').value = ''; | |
| const ch = document.getElementById('admin-choice'); | |
| if (ch) ch.classList.add('hidden'); | |
| switchView('view-shukran'); | |
| } | |
| </script> | |
| </head> | |
| <body class="user-layout"> | |
| <!-- Login --> | |
| <div id="view-login" class="container"> | |
| <!-- Logo Section moved above title --> | |
| <div style="text-align: center; margin-bottom: 20px;"> | |
| <img id="login-header-logo" src="https://i.imgur.com/DfSwMGd.png" alt="Header Logo" | |
| style="max-width: 250px; height: auto;"> | |
| </div> | |
| <h2 style="text-align: center; margin-bottom: 20px;">Shehrullah Dashboard</h2> | |
| <div id="login-form"> | |
| <input type="email" id="email" placeholder="name@example.com" autocomplete="off" required | |
| pattern="[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}" | |
| title="Please enter a valid email address (e.g., name@example.com)"> | |
| <div id="email-validation-error" style="color: #d32f2f; font-size: 0.85rem; margin-top: 5px; display: none;"> | |
| </div> | |
| <!-- Login and Logout buttons side by side (normal state) --> | |
| <div id="normal-buttons" style="display: flex; gap: 10px; justify-content: center; margin-top: 20px;"> | |
| <button class="btn btn-primary" onclick="doLogin()">Login</button> | |
| <button class="btn" style="background: transparent; border: 1px solid var(--color-primary); font-size: 0.85rem;" | |
| onclick="doLogout()">Logout</button> | |
| </div> | |
| <!-- Admin choice buttons (hidden by default) --> | |
| <div id="admin-choice" class="hidden" | |
| style="margin-top: 20px; display: flex; gap: 10px; justify-content: center; flex-direction: column;"> | |
| <div style="display: flex; gap: 10px;"> | |
| <button class="btn btn-primary" style="flex: 1; padding: 10px; font-size: 0.9rem;" | |
| onclick="showUserDashboard()">User Dashboard</button> | |
| <button class="btn" | |
| style="flex: 1; padding: 10px; font-size: 0.9rem; background: var(--color-accent); color: #fff;" | |
| onclick="openAdminLink()">Admin Dashboard</button> | |
| </div> | |
| <!-- Logout button moves here for admin users --> | |
| <button class="btn" | |
| style="background: transparent; border: 1px solid var(--color-primary); font-size: 0.85rem; margin-top: 5px;" | |
| onclick="doLogout()">Logout</button> | |
| </div> | |
| </div> | |
| <div class="loader" id="loader"></div> | |
| <div id="login-error"></div> | |
| </div> | |
| <!-- Dashboard View --> | |
| <div id="view-dashboard" class="hidden fade-in"> | |
| <div class="card dashboard-header" style="display:flex; justify-content:space-between; align-items:flex-start;"> | |
| <div style="text-align:left;"> | |
| <h2 style="margin:0 0 5px 0; color:var(--color-primary);">My Dashboard</h2> | |
| <div id="user-details"> | |
| <!-- User info populated by JS --> | |
| </div> | |
| </div> | |
| <div style="text-align:right;"> | |
| <button id="btn-admin-dash" class="btn hidden" onclick="openAdminLink()" style="margin-bottom:5px;">Admin | |
| Dashboard</button> | |
| <div> | |
| <button class="btn" style="background:transparent; border:1px solid var(--color-primary);" | |
| onclick="doLogout()">Logout</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="pref-section"> | |
| <h3>My Preferences</h3> | |
| <div id="my-pref-content"></div> | |
| </div> | |
| <!-- Phase Schedule Information (Moved Here) --> | |
| <div id="phase-schedule-container" | |
| style="background: linear-gradient(135deg, #fdfbfb 0%, #ebedee 100%); padding: 15px; border-radius: 8px; margin-bottom: 20px; border-left: 5px solid var(--color-primary); box-shadow: 0 2px 5px rgba(0,0,0,0.05);"> | |
| <h3 | |
| style="margin-top:0; color:var(--color-primary); font-size:1.1rem; border-bottom:1px solid #ddd; padding-bottom:5px;"> | |
| Phases and Selection Schedule</h3> | |
| <div id="phase-schedule-list" style="font-size: 0.9rem; line-height: 1.6;"> | |
| <div style="margin-bottom:4px;"><strong>Phase 1:</strong> <span id="sched-p1">Loading...</span></div> | |
| <div style="margin-bottom:4px;"><strong>Phase 2:</strong> <span id="sched-p2">Loading...</span> <span | |
| style="color:#d35400; font-weight:600; font-size:0.8rem;">(Layali Fadela selection starts here)</span></div> | |
| <div><strong>Phase 3:</strong> <span id="sched-p3">Loading...</span></div> | |
| </div> | |
| </div> | |
| <!-- ADDED BY ANTIGRAVITY: Seat Selection Nav & Summary (Split View) --> | |
| <div id="seat-nav-section" class="hidden" style="margin-bottom:20px; text-align:center;"> | |
| <h3 | |
| style="margin-bottom:12px; font-size:1.25rem; color:var(--color-primary); font-weight:700; padding-left:12px; display:inline-block;"> | |
| My Jaali Jagah</h3> | |
| <!-- New Legend Container (Dynamic) --> | |
| <div id="dashboard-seat-legend" | |
| style="margin-bottom:15px; font-size:0.85rem; color:#555; background:#f9f9f9; padding:8px; border-radius:6px; border:1px solid #eee; display:none;"> | |
| </div> | |
| <!-- Section 1: Daily Jagah --> | |
| <div style="background:#fafafa; border:1px solid #eee; border-radius:12px; padding:20px; margin-bottom:20px;"> | |
| <h3 | |
| style="margin-bottom:15px; color:#555; border-bottom:1px solid #eee; padding-bottom:5px; font-size:1.05rem; font-weight:600;"> | |
| Daily Jagah</h3> | |
| <div | |
| style="display:flex; justify-content:center; align-items:center; flex-wrap:wrap; gap:20px; margin-bottom:15px;"> | |
| <!-- Stats Box --> | |
| <div id="dash-stats-daily" | |
| style="background:#f4f7f6; padding:8px 15px; border-radius:15px; border:1px solid #e0e6e4; display:flex; flex-direction:column; align-items:flex-start; gap:5px; box-shadow:0 2px 4px rgba(0,0,0,0.02); width:max-content; min-width:260px;"> | |
| <div | |
| style="display:flex; justify-content:space-between; width:100%; align-items:center; border-bottom:1px solid #ddd; padding-bottom:5px;"> | |
| <div style="font-size:0.8rem; font-weight:600; color:#666;">Quota</div> | |
| <div style="display:flex; gap:10px; font-size:0.85rem; color:#444; align-items:center;"> | |
| <span style="color:#ccc;">|</span> | |
| <div style="display:flex; gap:5px;"><span>Limit:</span><b class="stat-limit">-</b></div> | |
| <span style="color:#ccc;">|</span> | |
| <div style="display:flex; gap:5px;"><span>Used:</span><b class="stat-used">-</b></div> | |
| <span style="color:#ccc;">|</span> | |
| <div style="display:flex; gap:5px;"><span>Left:</span><b class="stat-left" | |
| style="color:var(--color-accent);">-</b></div> | |
| <span style="color:#ccc;">|</span> | |
| </div> | |
| </div> | |
| <div style="width:100%; text-align:center; font-size:0.75rem; color:#888;"> | |
| Monthly Limit (all 3 phases) | |
| </div> | |
| </div> | |
| <!-- Action Button --> | |
| <div> | |
| <button id="btn-action-daily" class="btn btn-primary" onclick="loadSeatSelectionModular('Daily')">Select | |
| Daily Jagah</button> | |
| </div> | |
| </div> | |
| <!-- Inline Selection Anchor (Daily) --> | |
| <div id="selection-daily-anchor"></div> | |
| <!-- Selected Seats Table (Daily) --> | |
| <div id="table-daily-container" class="hidden" style="margin-top:10px;"> | |
| <h4 style="font-size:0.85rem; color:#666; margin-bottom:5px;">Your Selections:</h4> | |
| <div id="booking-list-daily"></div> | |
| </div> | |
| </div> | |
| <!-- Section 2: LF Jagah --> | |
| <div id="user-section-lf" | |
| style="background:#fafafa; border:1px solid #eee; border-radius:12px; padding:20px; margin-bottom:20px;"> | |
| <h3 | |
| style="margin-bottom:15px; color:#555; border-bottom:1px solid #eee; padding-bottom:5px; font-size:1.05rem; font-weight:600;"> | |
| Layali Fadela Jagah</h3> | |
| <div | |
| style="display:flex; justify-content:center; align-items:center; flex-wrap:wrap; gap:20px; margin-bottom:15px;"> | |
| <!-- Stats Box --> | |
| <div id="dash-stats-lf" | |
| style="background:#f4f7f6; padding:8px 15px; border-radius:15px; border:1px solid #e0e6e4; display:flex; flex-direction:column; align-items:flex-start; gap:5px; box-shadow:0 2px 4px rgba(0,0,0,0.02); width:max-content; min-width:260px;"> | |
| <div | |
| style="display:flex; justify-content:space-between; width:100%; align-items:center; border-bottom:1px solid #ddd; padding-bottom:5px;"> | |
| <div style="font-size:0.8rem; font-weight:600; color:#666;">Quota</div> | |
| <div style="display:flex; gap:10px; font-size:0.85rem; color:#444; align-items:center;"> | |
| <span style="color:#ccc;">|</span> | |
| <div style="display:flex; gap:5px;"><span>Limit:</span><b class="stat-limit">-</b></div> | |
| <span style="color:#ccc;">|</span> | |
| <div style="display:flex; gap:5px;"><span>Used:</span><b class="stat-used">-</b></div> | |
| <span style="color:#ccc;">|</span> | |
| <div style="display:flex; gap:5px;"><span>Left:</span><b class="stat-left" | |
| style="color:var(--color-accent);">-</b></div> | |
| <span style="color:#ccc;">|</span> | |
| </div> | |
| </div> | |
| <div style="width:100%; text-align:center; font-size:0.75rem; color:#888;"> | |
| Monthly Limit (all 3 phases) | |
| </div> | |
| </div> | |
| <!-- Action Button --> | |
| <div> | |
| <button id="btn-action-lf" class="btn btn-primary" onclick="loadSeatSelectionModular('LF')">Select LF | |
| Jagah</button> | |
| </div> | |
| </div> | |
| <!-- Inline Selection Anchor (LF) --> | |
| <div id="selection-lf-anchor"></div> | |
| <!-- Selected Seats Table (LF) --> | |
| <div id="table-lf-container" class="hidden" style="margin-top:10px;"> | |
| <h4 style="font-size:0.85rem; color:#666; margin-bottom:5px;">Your Selections:</h4> | |
| <div id="booking-list-lf"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Modular Seat Selection View Injection --> | |
| <?!= include('seatselect') ?> | |
| <!-- END OF ADDITION --> | |
| <div id="family-section" class="hidden"> | |
| <h3>Family Members</h3> | |
| <div id="family-list"></div> | |
| </div> | |
| </div> | |
| <!-- Shukran/Thank You View --> | |
| <div id="view-shukran" class="container hidden fade-in"> | |
| <div style="text-align: center; padding: 40px 20px;"> | |
| <img src="https://i.imgur.com/DfgOAol.png" alt="Shukran" style="max-width: 400px; width: 100%; height: auto;"> | |
| <div style="margin-top: 30px;"> | |
| <button class="btn btn-primary" onclick="forceBackToLogin()">Back to Login</button> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| function forceBackToLogin() { | |
| // Clear everything to be safe | |
| localStorage.removeItem('shehrullah_email'); | |
| sessionStorage.clear(); | |
| // CRITICAL: Reset UI state before reload to prevent "hidden buttons" bug | |
| const normalButtons = document.getElementById('normal-buttons'); | |
| const adminChoice = document.getElementById('admin-choice'); | |
| const loginForm = document.getElementById('login-form'); | |
| if (normalButtons) normalButtons.style.display = 'flex'; | |
| if (adminChoice) adminChoice.classList.add('hidden'); | |
| if (loginForm) loginForm.classList.remove('hidden'); | |
| // Force reload to initial state (use replace to prevent back-button issues) | |
| window.top.location.replace(window.top.location.href.split('?')[0]); | |
| } | |
| </script> | |
| <!-- Reusable Modal (Added by Antigravity) --> | |
| <div id="ui-modal-overlay" class="hidden" | |
| style="position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.5); z-index:1000; display:flex; justify-content:center; align-items:center;"> | |
| <div | |
| style="background:#fff; padding:20px; border-radius:12px; width:90%; max-width:400px; box-shadow:0 10px 25px rgba(0,0,0,0.2);"> | |
| <h3 id="ui-modal-title" style="margin-top:0; color:var(--color-primary);">Message</h3> | |
| <div id="ui-modal-body" style="margin:15px 0; line-height:1.5; color:#444;"></div> | |
| <div style="text-align:right; display:flex; gap:10px; margin-top:20px;"> | |
| <button class="btn btn-primary" style="flex:1;" onclick="closeModal()">Close</button> | |
| <button id="modal-confirm-btn" class="btn hidden" | |
| style="flex:1; background:var(--color-accent); color:#fff;">Confirm Action</button> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| function forceBackToLogin() { | |
| // Clear data but keep localStorage email if possible? No, user requested reset. | |
| localStorage.removeItem('shehrullah_email'); | |
| sessionStorage.removeItem('shehrullah_pref_view'); | |
| currentUser = null; | |
| // Ensure all views are hidden first | |
| document.querySelectorAll('.container').forEach(el => el.classList.add('hidden')); | |
| // Reset view | |
| const loginView = document.getElementById('view-login'); | |
| if (loginView) loginView.classList.remove('hidden'); | |
| // Clear forms | |
| const emailInput = document.getElementById('email'); | |
| if (emailInput) emailInput.value = ''; | |
| const form = document.getElementById('login-form'); | |
| if (form) form.classList.remove('hidden'); | |
| const loader = document.getElementById('loader'); | |
| if (loader) loader.style.display = 'none'; | |
| console.log('Force reset back to login executed'); | |
| } | |
| </script> | |
| </body> | |
| </html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <script> | |
| function getFormattedPreferences(data) { | |
| if (!data) return "No preferences saved yet."; | |
| let parts = []; | |
| const getVal = (f1, f2, f3) => data[f1] || data[f2] || data[f3] || ''; | |
| const att = getVal('Attendance', 'attendance'); | |
| if (att) parts.push(`<b>Attendance:</b> ${att}`); | |
| const chr = getVal('Chair', 'chair'); | |
| if (chr) parts.push(`<b>Chair:</b> ${chr}`); | |
| const fVal = getVal('Farzando=<awwala', 'Farzando', 'farzando'); | |
| if (fVal) { | |
| let farz = `<b>Farzando:</b> ${fVal}`; | |
| const fDetails = getVal('Names(age)', 'Names(AgE)', 'names(age)'); | |
| if (fDetails) farz += ` (${fDetails})`; | |
| parts.push(farz); | |
| } | |
| const dVal = getVal('Dikrio=>sania(non-misaq)', 'Dikrio', 'dikrio'); | |
| if (dVal) { | |
| let dik = `<b>Dikrio:</b> ${dVal}`; | |
| const dDetails = getVal('Dikrinames(age)', 'Dikrinames(AgE)', 'dikrinames(age)'); | |
| if (dDetails) dik += ` (${dDetails})`; | |
| parts.push(dik); | |
| } | |
| const mVal = getVal('Mehmaan', 'mehmaan'); | |
| if (mVal) { | |
| let meh = `<b>Mehmaan:</b> ${mVal}`; | |
| const mName = getVal('MHName', 'mhname'); | |
| if (mName) meh += ` (${mName})`; | |
| parts.push(meh); | |
| } | |
| const jal = getVal('Jaali', 'jaali'); | |
| if (jal) parts.push(`<b>Jaali:</b> ${jal}`); | |
| return parts.length > 0 ? parts.join(' | ') : "No preferences saved yet."; | |
| } | |
| function renderPreferencesInline(data) { | |
| const container = document.getElementById('my-pref-content'); | |
| if (!container) return; | |
| const html = ` | |
| <div style="line-height:1.6; margin-bottom:15px; font-size:1rem; color:#333; padding:10px; background:#f9f9f9; border-radius:8px;"> | |
| ${getFormattedPreferences(data)} | |
| </div> | |
| <div style="text-align: right;"> | |
| <button class="btn btn-primary" onclick="editPreferences()">Edit Preferences</button> | |
| </div> | |
| `; | |
| container.innerHTML = html; | |
| } | |
| function editPreferences() { | |
| renderPreferencesForm(currentUser.userPreferences || {}); | |
| } | |
| function renderPreferencesForm(data) { | |
| const container = document.getElementById('my-pref-content'); | |
| if (!container) return; | |
| const getVal = (f1, f2, f3) => data[f1] || data[f2] || data[f3] || ''; | |
| const chk = (f1, f2, val) => (getVal(f1, f2) === val) ? 'checked' : ''; | |
| const chkInc = (f1, f2, val) => (String(getVal(f1, f2)).includes(val)) ? 'checked' : ''; | |
| const isFarzandoYes = (getVal('Farzando=<awwala', 'Farzando', 'farzando') === 'Yes'); | |
| const isDikrioYes = (getVal('Dikrio=>sania(non-misaq)', 'Dikrio', 'dikrio') === 'Yes'); | |
| const isMehmaanYes = (getVal('Mehmaan', 'mehmaan') === 'Yes'); | |
| const html = ` | |
| <form id="prefForm"> | |
| <div class="pref-item"> | |
| <div class="pref-label">Attendance</div> | |
| <div class="pref-options vertical"> | |
| <label><input type="checkbox" name="att_daily" ${chkInc('Attendance', 'attendance', 'Daily')}> Daily</label> | |
| <label><input type="checkbox" name="att_wkd" ${chkInc('Attendance', 'attendance', 'Weekends')}> Weekends</label> | |
| <label><input type="checkbox" name="att_lf" ${chkInc('Attendance', 'attendance', 'Layali Fadela')}> Layali Fadela</label> | |
| <label><input type="checkbox" name="att_lq" ${chkInc('Attendance', 'attendance', 'Lailatul Qadr')}> Lailatul Qadr</label> | |
| </div> | |
| </div> | |
| <div class="pref-item"> | |
| <div class="pref-label">Chair</div> | |
| <div class="pref-options"> | |
| <label><input type="radio" name="chair" value="Yes" ${chk('Chair', 'chair', 'Yes')} onchange="checkJaaliVisibility()"> Yes</label> | |
| <label><input type="radio" name="chair" value="No" ${chk('Chair', 'chair', 'No')} onchange="checkJaaliVisibility()"> No</label> | |
| </div> | |
| </div> | |
| <div class="pref-item" style="flex-direction: column; align-items: flex-start;"> | |
| <div style="display: flex; width: 100%; align-items: flex-start;"> | |
| <div class="pref-label">Farzando (<=awwala)</div> | |
| <div class="pref-options"> | |
| <label><input type="radio" name="farzando" value="Yes" ${chk('Farzando=<awwala', 'farzando', 'Yes')} onchange="toggle('div-farzando', true); checkJaaliVisibility()"> Yes</label> | |
| <label><input type="radio" name="farzando" value="No" ${chk('Farzando=<awwala', 'farzando', 'No')} onchange="toggle('div-farzando', false); checkJaaliVisibility()"> No</label> | |
| </div> | |
| </div> | |
| <div id="div-farzando" class="${getVal('Farzando=<awwala', 'farzando') === 'Yes' ? '' : 'hidden'} pref-sub"> | |
| <input type="text" name="farzando_details" placeholder="Names(age)..." value="${getVal('Names(age)', 'Names(AgE)', 'names(age)')}"> | |
| </div> | |
| </div> | |
| <div class="pref-item" style="flex-direction: column; align-items: flex-start;"> | |
| <div style="display: flex; width: 100%; align-items: flex-start;"> | |
| <div class="pref-label">Dikrio (=>sania, non-misaq)</div> | |
| <div class="pref-options"> | |
| <label><input type="radio" name="dikrio" value="Yes" ${chk('Dikrio=>sania(non-misaq)', 'dikrio', 'Yes')} onchange="toggle('div-dikrio', true)"> Yes</label> | |
| <label><input type="radio" name="dikrio" value="No" ${chk('Dikrio=>sania(non-misaq)', 'dikrio', 'No')} onchange="toggle('div-dikrio', false)"> No</label> | |
| </div> | |
| </div> | |
| <div id="div-dikrio" class="${getVal('Dikrio=>sania(non-misaq)', 'dikrio') === 'Yes' ? '' : 'hidden'} pref-sub"> | |
| <input type="text" name="dikrio_details" placeholder="Dikrinames(age)..." value="${getVal('Dikrinames(age)', 'Dikrinames(AgE)', 'dikrinames(age)')}"> | |
| </div> | |
| </div> | |
| <div class="pref-item" style="flex-direction: column; align-items: flex-start;"> | |
| <div style="display: flex; width: 100%; align-items: flex-start;"> | |
| <div class="pref-label">Mehmaan</div> | |
| <div class="pref-options"> | |
| <label><input type="radio" name="mehmaan" value="Yes" ${chk('Mehmaan', 'mehmaan', 'Yes')} onchange="toggle('div-mehmaan', true)"> Yes</label> | |
| <label><input type="radio" name="mehmaan" value="No" ${chk('Mehmaan', 'mehmaan', 'No')} onchange="toggle('div-mehmaan', false)"> No</label> | |
| </div> | |
| </div> | |
| <div id="div-mehmaan" class="${getVal('Mehmaan', 'mehmaan') === 'Yes' ? '' : 'hidden'} pref-sub"> | |
| <input type="text" name="mehmaan_name" placeholder="MehmaanName" value="${data.MHName || data.mhname || ''}"> | |
| <input type="text" name="mehmaan_email" placeholder="MehmaanEmail" value="${data.MHEmail || data.mhemail || ''}"> | |
| <input type="text" name="mehmaan_phone" placeholder="MehmaanPhone" value="${data.MHPhone || data.mhphone || ''}"> | |
| </div> | |
| </div> | |
| <div class="pref-item hidden" id="div-jaali"> | |
| <div class="pref-label">Jaali</div> | |
| <div class="pref-options"> | |
| <label><input type="radio" name="jaali" value="Yes" ${chk('Jaali', 'jaali', 'Yes')}> Yes</label> | |
| <label><input type="radio" name="jaali" value="No" ${chk('Jaali', 'jaali', 'No')}> No</label> | |
| </div> | |
| </div> | |
| <div style="text-align: right; margin-top: 20px; border-top: 1px solid #eee; padding-top: 15px;"> | |
| <button type="button" class="btn btn-primary" onclick="submitForm()">Submit Preferences</button> | |
| </div> | |
| </form> | |
| `; | |
| container.innerHTML = html; | |
| checkJaaliVisibility(); | |
| } | |
| function toggle(id, show) { | |
| const el = document.getElementById(id); | |
| if (el) { | |
| if (show) el.classList.remove('hidden'); | |
| else el.classList.add('hidden'); | |
| } | |
| } | |
| function checkJaaliVisibility() { | |
| const form = document.getElementById('prefForm'); | |
| if (!form) return; | |
| const chrNo = form.querySelector('input[name="chair"][value="No"]'); | |
| const frzNo = form.querySelector('input[name="farzando"][value="No"]'); | |
| const div = document.getElementById('div-jaali'); | |
| if (div) { | |
| if (chrNo && chrNo.checked && frzNo && frzNo.checked) div.classList.remove('hidden'); | |
| else div.classList.add('hidden'); | |
| } | |
| } | |
| function submitForm() { | |
| const form = document.getElementById('prefForm'); | |
| if (!form) return; | |
| const userEmail = currentUser.user.Email || currentUser.user.email; | |
| const formData = { | |
| email: userEmail, | |
| uid: currentUser.user.uid, | |
| att_daily: form.att_daily.checked, | |
| att_wkd: form.att_wkd.checked, | |
| att_lf: form.att_lf.checked, | |
| att_lq: form.att_lq.checked, | |
| chair: form.querySelector('input[name="chair"]:checked') ? form.querySelector('input[name="chair"]:checked').value : '', | |
| farzando: form.querySelector('input[name="farzando"]:checked') ? form.querySelector('input[name="farzando"]:checked').value : '', | |
| farzando_details: form.farzando_details.value, | |
| dikrio: form.querySelector('input[name="dikrio"]:checked') ? form.querySelector('input[name="dikrio"]:checked').value : '', | |
| dikrio_details: form.dikrio_details.value, | |
| mehmaan: form.querySelector('input[name="mehmaan"]:checked') ? form.querySelector('input[name="mehmaan"]:checked').value : '', | |
| mehmaan_name: form.mehmaan_name.value, | |
| mehmaan_email: form.mehmaan_email.value, | |
| mehmaan_phone: form.mehmaan_phone.value, | |
| jaali: form.querySelector('input[name="jaali"]:checked') ? form.querySelector('input[name="jaali"]:checked').value : '' | |
| }; | |
| const btn = form.querySelector('button'); | |
| if (btn) { | |
| btn.innerText = 'Saving...'; | |
| btn.disabled = true; | |
| } | |
| google.script.run | |
| .withSuccessHandler(res => { | |
| currentUser = res; | |
| // After submission, as per convention, we can show the dashboard with updated values | |
| // Or we show Shukran. I'll stick to initDashboard but ensure views switch cleanly. | |
| initDashboard(); | |
| }) | |
| .withFailureHandler(e => { | |
| if (btn) { | |
| btn.innerText = 'Submit Preferences'; | |
| btn.disabled = false; | |
| } | |
| alert("Error: " + e.message); | |
| }) | |
| .submitPreferences(formData); | |
| } | |
| </script> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // --- seats.gs --- | |
| // Handles Daily Jagah and Layali Fadela Jagah Logic | |
| /* UPDATE BY ANTIGRAVITY: Phase Logic & Seat Configuration */ | |
| /* Shared Label Logic */ | |
| function getGlobalSeatLabels() { | |
| return { | |
| 'BRHN': 'Burhani', | |
| 'MHMD': 'Mohammedi', | |
| 'SAF': 'Saifee', | |
| 'SEH': 'Sahen', | |
| 'MAW': 'Mawaid', | |
| 'MAW2': 'MawaidBH' | |
| }; | |
| } | |
| function getSeatConfig(type, email) { | |
| const ss = SpreadsheetApp.openById(DATA_SHEET_ID); | |
| // 1. Determine Target Sheet & Phase | |
| const jagahTab = (type === 'Daily') ? TABS.DAILY_JAGAH : TABS.LF_JAGAH; | |
| const jagahSheet = ss.getSheetByName(jagahTab); | |
| if (!jagahSheet) return { success: false, message: "Seat data unavailable." }; | |
| const allData = jagahSheet.getDataRange().getDisplayValues(); | |
| if (allData.length < 1) return { success: false, message: "No seat configuration found." }; | |
| const headers = allData[0]; // Row 1 | |
| const seatColumns = []; | |
| // Mapping of codes to labels (Dynamic Legend) | |
| const LABELS = getGlobalSeatLabels(); | |
| headers.forEach((h, i) => { | |
| if (i === 0 || i > 14) return; // Skip Date (A) & Limit to O | |
| let label = h; | |
| for (let code in LABELS) { | |
| if (h.includes(code)) { label = LABELS[code]; break; } | |
| } | |
| seatColumns.push({ id: h, label: label, colIndex: i }); | |
| }); | |
| // Get User Name for "Own Seat" detection | |
| const { rows: dataRows } = fetchTabRows(ss, TABS.DATA); | |
| const dataUser = dataRows.find(r => (r.Email || r.email || '').toLowerCase() === String(email).toLowerCase()); | |
| const userName = dataUser ? (dataUser.Name || dataUser.name || '') : ''; | |
| const userNorm = kNorm(userName); | |
| let targetRows = []; | |
| if (type === 'Daily') { | |
| const activePhaseRaw = PropertiesService.getScriptProperties().getProperty('ACTIVE_PHASE') || ''; | |
| const activePhaseNum = parseInt(activePhaseRaw.replace(/\D/g, '')) || 0; | |
| if (activePhaseNum < 1 || activePhaseNum > 3) { | |
| return { success: false, message: "No active phase deployed." }; | |
| } | |
| const pStart = (activePhaseNum === 1) ? 1 : (activePhaseNum === 2) ? 9 : 17; | |
| const pEnd = pStart + 8; | |
| const allRawData = jagahSheet.getDataRange().getValues(); // Get real Dates | |
| for (let r = pStart; r < pEnd; r++) { | |
| if (allData[r] && allData[r][0]) { | |
| targetRows.push({ | |
| dateKey: String(allData[r][0]).trim(), | |
| rowIdx: r, | |
| dateObj: allRawData[r][0] | |
| }); | |
| } | |
| } | |
| } else { | |
| const allRawData = jagahSheet.getDataRange().getValues(); | |
| for (let r = 1; r < allData.length; r++) { | |
| const dVal = String(allData[r][0]).trim(); | |
| if (dVal) targetRows.push({ | |
| dateKey: dVal, | |
| rowIdx: r, | |
| dateObj: allRawData[r][0] | |
| }); | |
| } | |
| } | |
| const grid = targetRows.map(rowInfo => { | |
| const gridRowCells = allData[rowInfo.rowIdx]; | |
| const items = []; | |
| let filledCount = 0; | |
| seatColumns.forEach(sc => { | |
| const occupant = String(gridRowCells[sc.colIndex] || '').trim(); | |
| const isMe = (occupant !== '' && (occupant === userName || kNorm(occupant) === userNorm)); | |
| const isFull = (occupant !== '' && !isMe); | |
| if (isFull) filledCount++; | |
| items.push({ | |
| id: sc.id, | |
| label: sc.label, | |
| occupant: isMe ? 'YOU' : (isFull ? 'Full' : ''), | |
| isAvailable: !isFull, // Your own seat is "available" for swap/re-select | |
| isMe: isMe | |
| }); | |
| }); | |
| const curDate = new Date(); | |
| const mySeatObj = items.find(s => s.isMe); | |
| return { | |
| date: rowInfo.dateKey, | |
| label: rowInfo.dateKey, | |
| isFull: (filledCount === seatColumns.length), | |
| isLocked: (rowInfo.dateObj instanceof Date && (rowInfo.dateObj.getTime() - curDate.getTime()) / (1000 * 60 * 60) < 48), | |
| mySeat: mySeatObj ? mySeatObj.id : null, | |
| seats: items | |
| }; | |
| }); | |
| return { success: true, type: type, seatLabels: LABELS, dates: grid }; | |
| } | |
| /** VERSION: V4.2.2 (Emergency Login Fix) **/ | |
| function getUserBookings(email) { | |
| const ss = SpreadsheetApp.openById(DATA_SHEET_ID); | |
| const { rows: dataRows } = fetchTabRows(ss, TABS.DATA); | |
| const searchEmail = String(email).trim().toLowerCase(); | |
| const recordsWithEmail = dataRows.filter(r => (r.Email || r.email || '').toLowerCase() === searchEmail); | |
| if (recordsWithEmail.length === 0) return []; | |
| let myHofId = ''; | |
| for (let r of recordsWithEmail) { | |
| const h = String(r.hofid || r.HOFID || '').trim(); | |
| if (h && h !== '-' && h !== '0') { myHofId = h; break; } | |
| } | |
| let familyGroup = myHofId ? dataRows.filter(r => String(r.hofid || r.HOFID || '').trim() === myHofId) : recordsWithEmail; | |
| const myNames = familyGroup.map(u => String(u.Name || u.name || u.FullName || u.fullname || '').trim()).filter(n => n !== ''); | |
| const myNorms = myNames.map(n => kNorm(n)); | |
| const bookings = []; | |
| const scan = (tab, typeTag) => { | |
| const s = ss.getSheetByName(tab); | |
| if (!s) return; | |
| const data = s.getDataRange().getDisplayValues(); | |
| const headers = data[0]; | |
| for (let r = 1; r < data.length; r++) { | |
| const dateKey = String(data[r][0]).trim(); | |
| if (!dateKey) continue; | |
| for (let c = 1; c < data[r].length; c++) { | |
| const val = String(data[r][c]).trim(); | |
| if (!val) continue; | |
| const valNorm = kNorm(val); | |
| // Strict match on Normalized name only - avoids partial word matches like "Fatema" vs "Fatema Kapasi" | |
| let isMatch = myNorms.includes(valNorm); | |
| if (isMatch) { | |
| bookings.push({ | |
| type: typeTag, | |
| date: dateKey, | |
| label: dateKey, | |
| seat: String(headers[c] || '').trim(), | |
| occupant: val | |
| }); | |
| } | |
| } | |
| } | |
| }; | |
| scan(TABS.DAILY_JAGAH, 'Daily'); | |
| scan(TABS.LF_JAGAH, 'LF'); | |
| return bookings; | |
| } | |
| function submitSeatSelection(email, payload) { | |
| const lock = LockService.getScriptLock(); | |
| try { lock.waitLock(10000); } catch (e) { return { success: false, message: "System busy." }; } | |
| const ss = SpreadsheetApp.openById(DATA_SHEET_ID); | |
| try { | |
| const { rows: capRows, headers: capHeaders } = fetchTabRows(ss, TABS.AVAILABLE_CAPACITY); | |
| const capRowObj = capRows.find(r => String(r.Email || r.email).toLowerCase() === String(email).toLowerCase()); | |
| if (!capRowObj) { lock.releaseLock(); return { success: false, message: "User quota not found." }; } | |
| const capSheet = ss.getSheetByName(TABS.AVAILABLE_CAPACITY); | |
| const liveVals = capSheet.getRange(capRowObj._rowNum, 1, 1, capHeaders.length).getValues()[0]; | |
| const findI = (ks) => capHeaders.findIndex(h => ks.some(k => String(h).toLowerCase().includes(k.toLowerCase()))); | |
| const qIdx = (payload.type === 'Daily') ? findI(['DailyQuota']) : findI(['LFQuota']); | |
| const uIdx = (payload.type === 'Daily') ? findI(['DailyUsed']) : findI(['LFUsed']); | |
| const lIdx = (payload.type === 'Daily') ? findI(['DailyLeft']) : findI(['LFLeft']); | |
| const limit = Number(liveVals[qIdx]); | |
| const used = Number(liveVals[uIdx]); | |
| const { rows: dataRows } = fetchTabRows(ss, TABS.DATA); | |
| const dataUser = dataRows.find(r => (r.Email || r.email || '').toLowerCase() === String(email).toLowerCase()); | |
| const userName = dataUser ? (dataUser.Name || dataUser.name || '') : 'User'; | |
| const jagahTab = (payload.type === 'Daily') ? TABS.DAILY_JAGAH : TABS.LF_JAGAH; | |
| const gridSheet = ss.getSheetByName(jagahTab); | |
| const gridData = gridSheet.getDataRange().getDisplayValues(); | |
| const gridHeaders = gridData[0]; | |
| // Pre-validate Quota limits | |
| // Calculate how many *new* slots this request will consume | |
| const newSlotsNeeded = payload.selections.filter(sel => { | |
| const rIdx = gridData.findIndex(row => String(row[0]) === sel.date); | |
| const cIdx = gridHeaders.indexOf(sel.seat); | |
| if (rIdx === -1 || cIdx === -1) return false; | |
| const cellVal = gridData[rIdx][cIdx]; | |
| // Only counts as new usage if I don't already own it | |
| const isMine = (cellVal === userName || kNorm(cellVal) === kNorm(userName)); | |
| return !isMine; | |
| }).length; | |
| if (used + newSlotsNeeded > limit) { | |
| lock.releaseLock(); | |
| return { success: false, message: `Quota exceeded. You have ${limit - used} slots left, but selected ${newSlotsNeeded}.` }; | |
| } | |
| const successList = []; | |
| payload.selections.forEach(sel => { | |
| const rIdx = gridData.findIndex(row => String(row[0]) === sel.date); | |
| const cIdx = gridHeaders.indexOf(sel.seat); | |
| if (rIdx > -1 && cIdx > -1) { | |
| const cell = gridSheet.getRange(rIdx + 1, cIdx + 1); | |
| const cur = cell.getValue(); | |
| // If it's already mine, skip or overwrite | |
| if (cur === '' || cur === userName || kNorm(cur) === kNorm(userName)) { | |
| if (cur === '') successList.push(sel); | |
| cell.setValue(userName); | |
| } | |
| } | |
| }); | |
| if (successList.length > 0) { | |
| const newUsed = used + successList.length; | |
| capSheet.getRange(capRowObj._rowNum, uIdx + 1).setValue(newUsed); | |
| if (lIdx > -1) capSheet.getRange(capRowObj._rowNum, lIdx + 1).setValue(limit - newUsed); | |
| } | |
| lock.releaseLock(); | |
| return { success: true, booked: successList.length }; | |
| } catch (e) { lock.releaseLock(); return { success: false, message: e.message }; } | |
| } | |
| function cancelBooking(email, type, dateStr, seat) { | |
| const ss = SpreadsheetApp.openById(DATA_SHEET_ID); | |
| const jagahTab = (type === 'Daily') ? TABS.DAILY_JAGAH : TABS.LF_JAGAH; | |
| const sheet = ss.getSheetByName(jagahTab); | |
| const data = sheet.getDataRange().getValues(); | |
| const displayData = sheet.getDataRange().getDisplayValues(); | |
| let rIdx = -1; | |
| const today = new Date(); | |
| for (let i = 0; i < data.length; i++) { | |
| if (String(displayData[i][0]).trim() === String(dateStr).trim()) { | |
| rIdx = i; | |
| const dVal = data[i][0]; | |
| if (dVal instanceof Date) { | |
| const diffHrs = (dVal.getTime() - today.getTime()) / (1000 * 60 * 60); | |
| if (diffHrs < 48) return { success: false, message: "Action locked within 48h." }; | |
| } | |
| break; | |
| } | |
| } | |
| const cIdx = displayData[0].findIndex(h => String(h).trim().toLowerCase() === String(seat).trim().toLowerCase()); | |
| const lock = LockService.getScriptLock(); | |
| try { lock.waitLock(5000); } catch(e) { return { success: false, message: "System busy (lock timeout)." }; } | |
| Logger.log(`Cancel request: Email=${email}, Type=${type}, Date=${dateStr}, Seat=${seat}`); | |
| Logger.log(`Found rIdx=${rIdx}, cIdx=${cIdx}`); | |
| if (rIdx === -1) { lock.releaseLock(); return { success: false, message: `Row for date '${dateStr}' not found.` }; } | |
| if (cIdx === -1) { lock.releaseLock(); return { success: false, message: `Seat header '${seat}' not found.` }; } | |
| const cell = sheet.getRange(rIdx + 1, cIdx + 1); | |
| const currentValue = cell.getValue(); | |
| Logger.log(`Current cell value: '${currentValue}'`); | |
| cell.setValue(""); | |
| SpreadsheetApp.flush(); // Force immediate write | |
| Logger.log(`Cell cleared successfully`); | |
| const { rows: capRows, headers: capHeaders } = fetchTabRows(ss, TABS.AVAILABLE_CAPACITY); | |
| const capRowObj = capRows.find(r => String(r.Email || r.email || '').toLowerCase() === String(email).toLowerCase()); | |
| if (capRowObj) { | |
| const capSheet = ss.getSheetByName(TABS.AVAILABLE_CAPACITY); | |
| const findI = (ks) => capHeaders.findIndex(h => { | |
| if (!h) return false; | |
| const hStr = String(h).toLowerCase(); | |
| return ks.some(k => hStr.includes(k.toLowerCase())); | |
| }); | |
| const uIdx = (type === 'Daily') ? findI(['DailyUsed']) : findI(['LFUsed']); | |
| const qIdx = (type === 'Daily') ? findI(['DailyQuota']) : findI(['LFQuota']); | |
| const lIdx = (type === 'Daily') ? findI(['DailyLeft']) : findI(['LFLeft']); | |
| const curU = Number(capSheet.getRange(capRowObj._rowNum, uIdx + 1).getValue()) || 0; | |
| const curQ = Number(capSheet.getRange(capRowObj._rowNum, qIdx + 1).getValue()) || 0; | |
| const nextU = Math.max(0, curU - 1); | |
| capSheet.getRange(capRowObj._rowNum, uIdx + 1).setValue(nextU); | |
| if (lIdx > -1) capSheet.getRange(capRowObj._rowNum, lIdx + 1).setValue(curQ - nextU); | |
| } | |
| lock.releaseLock(); | |
| return { success: true }; | |
| } | |
| function getUserQuotas(email) { | |
| const ss = SpreadsheetApp.openById(DATA_SHEET_ID); | |
| const { rows } = fetchTabRows(ss, TABS.AVAILABLE_CAPACITY); | |
| const row = rows.find(r => String(r.Email || r.email || '').toLowerCase() === String(email).toLowerCase()); | |
| if (!row) return null; | |
| const dQ = Number(row.dailyquota || 0); | |
| const dU = Number(row.dailyused || 0); | |
| const lQ = Number(row.lfquota || 0); | |
| const lU = Number(row.lfused || 0); | |
| return { | |
| daily: { limit: dQ, used: dU, left: Math.max(0, dQ - dU) }, | |
| lf: { limit: lQ, used: lU, left: Math.max(0, lQ - lU) } | |
| }; | |
| } | |
| function getFamilySeatStatus(email) { | |
| const bookings = getUserBookings(email); | |
| return { hasSelectedDaily: bookings.some(b => b.type === 'Daily'), hasSelectedLF: bookings.some(b => b.type === 'LF'), bookingCount: bookings.length }; | |
| } | |
| function cancelBookingsBatch(email, type, cancellations) { | |
| const lock = LockService.getScriptLock(); | |
| try { lock.waitLock(10000); } catch (e) { return { success: false, message: "System busy." }; } | |
| const ss = SpreadsheetApp.openById(DATA_SHEET_ID); | |
| const jagahTab = (type === 'Daily') ? TABS.DAILY_JAGAH : TABS.LF_JAGAH; | |
| const sheet = ss.getSheetByName(jagahTab); | |
| const data = sheet.getDataRange().getValues(); | |
| const displayData = sheet.getDataRange().getDisplayValues(); // For string matching | |
| let successCount = 0; | |
| const today = new Date(); | |
| // 1. Process deletions | |
| // We can optimize by mapping dates to row indices first | |
| const dateRowMap = {}; | |
| for (let i = 0; i < displayData.length; i++) { | |
| const dKey = String(displayData[i][0]).trim(); | |
| if (dKey) dateRowMap[dKey] = { index: i, dateObj: data[i][0] }; | |
| } | |
| // Headers for column lookup | |
| const headers = displayData[0].map(h => String(h).trim().toLowerCase()); | |
| cancellations.forEach(item => { | |
| const rowInfo = dateRowMap[String(item.date).trim()]; | |
| if (!rowInfo) return; // Date not found | |
| // 48h Lock check | |
| if (rowInfo.dateObj instanceof Date) { | |
| const diffHrs = (rowInfo.dateObj.getTime() - today.getTime()) / (1000 * 60 * 60); | |
| if (diffHrs < 48) return; // Locked | |
| } | |
| const cIdx = headers.indexOf(String(item.seat).trim().toLowerCase()); | |
| if (cIdx === -1) return; // Seat not found | |
| sheet.getRange(rowInfo.index + 1, cIdx + 1).setValue(""); | |
| successCount++; | |
| }); | |
| if (successCount > 0) { | |
| SpreadsheetApp.flush(); | |
| // 2. Update Quota | |
| const { rows: capRows, headers: capHeaders } = fetchTabRows(ss, TABS.AVAILABLE_CAPACITY); | |
| const capRowObj = capRows.find(r => String(r.Email || r.email || '').toLowerCase() === String(email).toLowerCase()); | |
| if (capRowObj) { | |
| const capSheet = ss.getSheetByName(TABS.AVAILABLE_CAPACITY); | |
| const findI = (ks) => capHeaders.findIndex(h => { | |
| if (!h) return false; | |
| const hStr = String(h).toLowerCase(); | |
| return ks.some(k => hStr.includes(k.toLowerCase())); | |
| }); | |
| const uIdx = (type === 'Daily') ? findI(['DailyUsed']) : findI(['LFUsed']); | |
| const qIdx = (type === 'Daily') ? findI(['DailyQuota']) : findI(['LFQuota']); | |
| const lIdx = (type === 'Daily') ? findI(['DailyLeft']) : findI(['LFLeft']); | |
| const curU = Number(capSheet.getRange(capRowObj._rowNum, uIdx + 1).getValue()) || 0; | |
| const curQ = Number(capSheet.getRange(capRowObj._rowNum, qIdx + 1).getValue()) || 0; | |
| const nextU = Math.max(0, curU - successCount); | |
| capSheet.getRange(capRowObj._rowNum, uIdx + 1).setValue(nextU); | |
| if (lIdx > -1) capSheet.getRange(capRowObj._rowNum, lIdx + 1).setValue(curQ - nextU); | |
| } | |
| } | |
| lock.releaseLock(); | |
| return { success: true, count: successCount }; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!-- VERSION: V4.3.0 (Compact & Robust) --> | |
| <!-- ADDED BY ANTIGRAVITY: Modular Selection System (Inline) --> | |
| <div id="seat-selection-view" class="hidden" | |
| style="margin:15px 0; border:1px solid var(--color-accent); background:#fff; padding:15px; border-radius:12px; box-shadow:0 2px 8px rgba(0,0,0,0.05);"> | |
| <div class="header" | |
| style="border-bottom:1px solid #eee; margin-bottom:15px; padding-bottom:10px; display:flex; justify-content:space-between; align-items:center;"> | |
| <h2 id="seatSelectionTitle" style="margin:0; font-size:1.1rem; color:var(--color-primary);">Seat Selection</h2> | |
| <button class="btn" style="background:transparent; border:none; color:#999; font-size:1.2rem; padding:0 10px;" | |
| onclick="closeSeatSelection()">×</button> | |
| </div> | |
| <div> | |
| <div id="selection-quota-header" style="margin-bottom:15px;"></div> | |
| <div id="seat-selection-inner-content"> | |
| <!-- Populated via renderSeatGridModular --> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| function loadSeatSelectionModular(type) { | |
| console.log("Loading modular selection for:", type); | |
| const email = currentUser.user.Email || currentUser.user.email; | |
| const inner = document.getElementById('seat-selection-inner-content'); | |
| const box = document.getElementById('seat-selection-view'); | |
| const anchorId = (type === 'LF') ? 'selection-lf-anchor' : 'selection-daily-anchor'; | |
| const otherAnchorId = (type === 'LF') ? 'selection-daily-anchor' : 'selection-lf-anchor'; | |
| const anchor = document.getElementById(anchorId); | |
| const otherAnchor = document.getElementById(otherAnchorId); | |
| if (anchor) { | |
| anchor.appendChild(box); | |
| box.classList.remove('hidden'); | |
| } | |
| if (otherAnchor) otherAnchor.innerHTML = ''; | |
| inner.innerHTML = '<div class="loader" style="display:block;"></div>'; | |
| document.getElementById('seatSelectionTitle').innerText = `${type} Jagah Selection`; | |
| google.script.run | |
| .withSuccessHandler(res => { | |
| try { | |
| console.log("Seat Config Res:", res); | |
| if (!res || !res.success) { | |
| showModal("Notice", (res && res.message) ? res.message : "No seats available.", "info"); | |
| closeSeatSelection(); | |
| return; | |
| } | |
| renderSeatGridModular(res, type); | |
| } catch (err) { | |
| console.error("Render Error:", err); | |
| showModal("Render Error", "Client failed to render seat grid: " + err.message, "error"); | |
| // closeSeatSelection(); // Keep open to see error? | |
| } | |
| }) | |
| .withFailureHandler(e => { | |
| showModal("System Error", e.message, "error"); | |
| closeSeatSelection(); | |
| }) | |
| .getSeatConfig(type, email); | |
| } | |
| function renderSeatGridModular(res, type) { | |
| const inner = document.getElementById('seat-selection-inner-content'); | |
| let html = ` | |
| <div class="table-container" style="max-height:60vh; overflow-y:auto; overflow-x:auto;"> | |
| <table style="width:100%; min-width:100%; border-collapse:collapse; background:#fff; font-size:0.85rem; border:1px solid #eee;"> | |
| <thead style="background:#f9f9f9; position:sticky; top:0; z-index:20;"> | |
| <tr> | |
| <th style="text-align:left; padding:8px; border-bottom:2px solid #eee; position:sticky; left:0; background:#f9f9f9; z-index:21; border-right:2px solid #eee;">Date</th> | |
| <th style="text-align:left; padding:8px; border-bottom:2px solid #eee;">Selection</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| `; | |
| res.dates.forEach(row => { | |
| const isRowLocked = row.isLocked; | |
| const rowStyle = row.isFull ? 'style="background-color:#9CF4DF;"' : ''; | |
| // Fix: Explicitly calculate variables | |
| const mySeat = row.seats.find(s => s.isMe); | |
| const hadSeat = !!mySeat; | |
| // Fix: Generate cell content explicitly | |
| // LOGIC CHANGE: 48h Lock only prevents CANCELLATION/CHANGE of existing seat. | |
| // It does NOT prevent NEW selection. | |
| let cellContent = ''; | |
| if (isRowLocked && hadSeat) { | |
| // User HAS a seat, but it's locked (cannot cancel/change) | |
| cellContent = `<span style="color:#D32F2F; font-size:0.8rem; font-weight:600; display:block;">Locked<br><span style="font-weight:400; color:#666;">(Time limit)</span></span> | |
| <input type="hidden" id="mod-sel-${row.date}" value="${mySeat.id}" data-had-seat="true" disabled>`; | |
| // Hidden input ensuring we track it but can't change it? | |
| // Actually, if we just don't render the select, the 'submit' logic needs to handle missing inputs. | |
| // Submit logic checks 'querySelectorAll' - so if it's not a select, it won't be picked up. | |
| // That's fine, "Locked" means no change submitted for this date. | |
| } else if (row.isFull && !hadSeat) { | |
| // Full and I don't have a seat | |
| cellContent = '<span style="color:#727AC0; font-weight:bold;">Full</span>'; | |
| } else { | |
| // Available OR Locked-but-I-dont-have-a-seat-yet (So I can book) | |
| const optionsHtml = row.seats.filter(s => s.isAvailable).map(s => { | |
| const isCurrent = s.isMe; | |
| return `<option value="${s.id}" data-is-me="${isCurrent}" ${isCurrent ? 'selected' : ''}>${s.id}</option>`; | |
| }).join(''); | |
| cellContent = ` | |
| <select id="mod-sel-${row.date}" data-had-seat="${hadSeat}" onchange="updateLiveQuota('${type}')" style="margin:0; flex:1; font-size:0.85rem; min-width:140px;"> | |
| <option value="" data-is-me="false">-- Select --</option> | |
| ${optionsHtml} | |
| </select> | |
| `; | |
| } | |
| html += ` | |
| <tr ${rowStyle}> | |
| <td style="padding:8px; border-bottom:1px solid #eee; position:sticky; left:0; background:${row.isFull ? '#9CF4DF' : '#fff'}; z-index:10; border-right:2px solid #eee;"> | |
| <b>${row.label}</b> | |
| <div style="font-size:0.75rem; color:#888; white-space:nowrap;">${row.date}</div> | |
| </td> | |
| <td style="padding:8px; border-bottom:1px solid #eee;"> | |
| ${cellContent} | |
| </td> | |
| </tr> | |
| `; | |
| }); | |
| html += ` | |
| </tbody> | |
| </table> | |
| </div> | |
| <div style="display:flex; gap:10px; justify-content:center; margin-top:20px; border-top:1px solid #eee; padding-top:15px;"> | |
| <button class="btn" style="min-width:120px; background:transparent; border:1px solid #ccc; font-size:0.9rem;" onclick="closeSeatSelection()">Cancel</button> | |
| <button id="btn-mod-submit" class="btn btn-primary" style="min-width:120px; font-size:0.9rem;" onclick="submitModularSelection('${type}')">Confirm</button> | |
| </div> | |
| `; | |
| inner.innerHTML = html; | |
| updateLiveQuota(type); | |
| } | |
| function updateLiveQuota(type) { | |
| const typeKey = type.toLowerCase(); | |
| const quotaInfo = currentUser.userQuotas[typeKey]; | |
| // Base numbers from backend (snapshot at load) | |
| const limit = quotaInfo ? parseInt(quotaInfo.limit || 0) : 0; | |
| const initialUsed = quotaInfo ? parseInt(quotaInfo.used || 0) : 0; | |
| let delta = 0; | |
| let newSelectionsCount = 0; | |
| document.querySelectorAll('select[id^="mod-sel-"]').forEach(sel => { | |
| const hadSeat = (sel.getAttribute('data-had-seat') === 'true'); | |
| const hasSelectionNow = !!sel.value; | |
| if (hasSelectionNow) { | |
| newSelectionsCount++; // Just for visual "X selected" | |
| } | |
| // Logic: | |
| // 1. Had Seat -> No Selection Now = Freed (-1) | |
| // 2. No Seat -> Has Selection Now = Consumed (+1) | |
| // 3. Had Seat -> Has Selection Now = Swap (0) | |
| // 4. No Seat -> No Selection Now = No Change (0) | |
| if (hadSeat && !hasSelectionNow) { | |
| delta--; | |
| } else if (!hadSeat && hasSelectionNow) { | |
| delta++; | |
| } | |
| }); | |
| const projectedUsed = initialUsed + delta; | |
| const projectedLeft = Math.max(0, limit - projectedUsed); | |
| const isOver = (projectedUsed > limit); | |
| const btn = document.getElementById('btn-mod-submit'); | |
| const modalBox = document.getElementById('selection-quota-header'); | |
| if (btn) { | |
| btn.disabled = isOver; | |
| btn.style.opacity = isOver ? '0.5' : '1'; | |
| btn.style.cursor = isOver ? 'not-allowed' : 'pointer'; | |
| btn.innerText = isOver ? `Quota Exceeded (${projectedUsed}/${limit})` : "Confirm"; | |
| } | |
| if (modalBox) { | |
| modalBox.style.borderRadius = "30px"; | |
| modalBox.style.padding = "8px 15px"; | |
| modalBox.style.background = isOver ? "#fff0f0" : "#f0f7ff"; | |
| modalBox.style.border = isOver ? "1px solid #ffcccc" : "1px solid #d0e1f9"; | |
| let msgColor = isOver ? "#e67e22" : "var(--color-primary)"; | |
| modalBox.innerHTML = ` | |
| <div style="display:flex; justify-content:space-between; align-items:center; font-size:0.85rem;"> | |
| <span style="font-weight:600; color:var(--color-primary);">Monthly Limit: ${projectedUsed} / ${limit}</span> | |
| <span style="color:#555;">Selected: <b style="color:${msgColor};">${newSelectionsCount}</b></span> | |
| </div> | |
| `; | |
| } | |
| } | |
| function submitModularSelection(type) { | |
| const btn = document.getElementById('btn-mod-submit'); | |
| const email = currentUser.user.Email || currentUser.user.email; | |
| const selections = []; | |
| document.querySelectorAll('select[id^="mod-sel-"]').forEach(sel => { | |
| if (sel.value) { | |
| selections.push({ | |
| date: sel.id.replace('mod-sel-', ''), | |
| seat: sel.value | |
| }); | |
| } | |
| }); | |
| if (selections.length === 0) { | |
| showModal("Selection Required", "Please select at least one seat.", "info"); | |
| return; | |
| } | |
| btn.innerText = "Processing..."; | |
| btn.disabled = true; | |
| google.script.run | |
| .withSuccessHandler(res => { | |
| if (res.success) { | |
| showModal("Success!", `Successfully updated selections.`, "success"); | |
| closeSeatSelection(); | |
| doLogin(); | |
| } else { | |
| showModal("Error", res.message, "error"); | |
| btn.innerText = "Confirm"; | |
| btn.disabled = false; | |
| } | |
| }) | |
| .withFailureHandler(e => { | |
| showModal("System Error", e.message, "error"); | |
| btn.innerText = "Confirm"; | |
| btn.disabled = false; | |
| }) | |
| .submitSeatSelection(email, { type: type, selections: selections }); | |
| } | |
| function closeSeatSelection() { | |
| window.activeModularType = null; | |
| document.getElementById('seat-selection-view').classList.add('hidden'); | |
| } | |
| </script> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <style> | |
| :root { | |
| --color-primary: #594346; | |
| --color-secondary: #e9e2e5; | |
| --color-bg: #F9F5F7; | |
| --color-accent: #29ABB1; | |
| --color-highlight: #9CF4DF; | |
| --color-button-bg: #F7EBF2; | |
| } | |
| * { | |
| box-sizing: border-box; | |
| margin: 0; | |
| padding: 0; | |
| } | |
| body { | |
| font-family: 'Outfit', sans-serif; | |
| background: var(--color-bg); | |
| margin: 0; | |
| } | |
| /* User / Login Layout */ | |
| body.user-layout { | |
| padding: 15px; | |
| color: var(--color-primary); | |
| min-height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| } | |
| /* Admin Layout */ | |
| body.admin-layout { | |
| display: flex; | |
| height: 100vh; | |
| overflow: hidden; | |
| padding: 0; | |
| } | |
| .container { | |
| width: 100%; | |
| max-width: 450px; | |
| background: #fff; | |
| padding: 25px; | |
| border-radius: 12px; | |
| box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05); | |
| margin-top: 15px; | |
| } | |
| .container-wide { | |
| max-width: 900px; | |
| } | |
| .hidden { | |
| display: none !important; | |
| } | |
| .btn { | |
| background: var(--color-button-bg); | |
| color: var(--color-primary); | |
| border: none; | |
| padding: 8px 18px; | |
| border-radius: 24px; | |
| cursor: pointer; | |
| font-weight: 600; | |
| font-size: 0.9rem; | |
| margin: 3px; | |
| transition: all 0.2s; | |
| } | |
| .btn:hover { | |
| background: #e5d6df; | |
| transform: translateY(-1px); | |
| } | |
| .btn-primary { | |
| background: var(--color-primary); | |
| color: #fff; | |
| } | |
| input:not([type="checkbox"]):not([type="radio"]), | |
| select { | |
| width: 100%; | |
| padding: 8px; | |
| margin: 3px 0; | |
| border: 2px solid #eee; | |
| border-radius: 8px; | |
| font-family: inherit; | |
| font-size: 0.9rem; | |
| } | |
| .loader { | |
| border: 4px solid #f3f3f3; | |
| border-top: 4px solid var(--color-primary); | |
| border-radius: 50%; | |
| width: 30px; | |
| height: 30px; | |
| animation: spin 1s linear infinite; | |
| margin: 15px auto; | |
| display: none; | |
| } | |
| @keyframes spin { | |
| 100% { | |
| transform: rotate(360deg); | |
| } | |
| } | |
| .header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| border-bottom: 2px solid #eee; | |
| padding-bottom: 12px; | |
| margin-bottom: 15px; | |
| flex-wrap: wrap; | |
| gap: 10px; | |
| } | |
| .pref-section { | |
| background: #fafafa; | |
| padding: 15px; | |
| border-radius: 12px; | |
| margin-bottom: 15px; | |
| } | |
| .pref-section h3 { | |
| margin-bottom: 12px; | |
| font-size: 1.1rem; | |
| } | |
| .pref-item { | |
| display: flex; | |
| align-items: flex-start; | |
| margin-bottom: 10px; | |
| line-height: 1.6; | |
| } | |
| .pref-label { | |
| min-width: 200px; | |
| font-weight: 600; | |
| font-size: 0.95rem; | |
| padding-right: 10px; | |
| } | |
| .pref-options { | |
| display: flex; | |
| gap: 15px; | |
| align-items: flex-start; | |
| } | |
| .pref-options.vertical { | |
| flex-direction: column; | |
| gap: 8px; | |
| } | |
| .pref-options label { | |
| cursor: pointer; | |
| font-size: 0.9rem; | |
| display: flex; | |
| align-items: center; | |
| white-space: nowrap; | |
| } | |
| .pref-options input[type="checkbox"], | |
| .pref-options input[type="radio"] { | |
| width: 18px !important; | |
| height: 18px !important; | |
| margin: 0 6px 0 0 !important; | |
| } | |
| .pref-sub { | |
| margin-left: 210px; | |
| margin-top: 5px; | |
| width: calc(100% - 210px); | |
| } | |
| .family-card { | |
| background: #fff; | |
| padding: 12px; | |
| border-radius: 8px; | |
| border-left: 4px solid var(--color-accent); | |
| margin-bottom: 10px; | |
| font-size: 0.9rem; | |
| box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); | |
| } | |
| .family-card .name { | |
| font-weight: 600; | |
| font-size: 1rem; | |
| margin-bottom: 4px; | |
| } | |
| .family-card .prefs { | |
| color: #555; | |
| line-height: 1.4; | |
| margin-top: 6px; | |
| padding-top: 6px; | |
| border-top: 1px dotted #eee; | |
| } | |
| @media (max-width: 600px) { | |
| body { | |
| padding: 10px; | |
| } | |
| .container { | |
| padding: 15px; | |
| max-width: 100%; | |
| } | |
| .pref-item { | |
| flex-direction: column; | |
| } | |
| .pref-label { | |
| min-width: 100%; | |
| margin-bottom: 8px; | |
| } | |
| .pref-options { | |
| gap: 10px; | |
| flex-direction: column; | |
| align-items: flex-start; | |
| } | |
| .pref-options label { | |
| white-space: normal; | |
| } | |
| .pref-sub { | |
| margin-left: 0; | |
| width: 100%; | |
| } | |
| .header { | |
| flex-direction: column; | |
| align-items: flex-start; | |
| gap: 10px; | |
| } | |
| .header>div { | |
| width: 100%; | |
| } | |
| .header button { | |
| width: 100%; | |
| margin-bottom: 5px; | |
| } | |
| /* Make tables scrollable on mobile */ | |
| table { | |
| display: block; | |
| overflow-x: auto; | |
| white-space: nowrap; | |
| } | |
| /* Stack admin choice buttons vertically on mobile */ | |
| #admin-choice { | |
| flex-direction: column !important; | |
| } | |
| #admin-choice button { | |
| width: 100% !important; | |
| } | |
| /* Make emergency back button smaller on mobile */ | |
| #emergency-back-btn { | |
| bottom: 10px; | |
| right: 10px; | |
| } | |
| #emergency-back-btn button { | |
| padding: 8px 12px; | |
| font-size: 0.8rem; | |
| } | |
| /* Make quota stats boxes stack on mobile */ | |
| #dash-stats-daily, | |
| #dash-stats-lf { | |
| flex-direction: column; | |
| gap: 8px; | |
| padding: 10px; | |
| } | |
| #dash-stats-daily>div, | |
| #dash-stats-lf>div { | |
| border-right: none !important; | |
| padding-right: 0 !important; | |
| border-bottom: 1px solid #ddd; | |
| padding-bottom: 8px; | |
| } | |
| #dash-stats-daily>div:last-child, | |
| #dash-stats-lf>div:last-child { | |
| border-bottom: none; | |
| padding-bottom: 0; | |
| } | |
| } | |
| </style> | |
| <style> | |
| /* ========================================= | |
| ADMIN DASHBOARD STYLES | |
| ========================================= */ | |
| /* Left Navigation */ | |
| .nav-panel { | |
| width: 200px; | |
| background: var(--color-primary); | |
| color: #fff; | |
| padding: 20px; | |
| display: flex; | |
| flex-direction: column; | |
| overflow-y: auto; | |
| } | |
| .brand { | |
| font-size: 1.3rem; | |
| font-weight: 600; | |
| margin-bottom: 30px; | |
| } | |
| .nav-item { | |
| padding: 12px 10px; | |
| cursor: pointer; | |
| margin-bottom: 5px; | |
| transition: all 0.2s; | |
| font-size: 1rem; | |
| color: #ecf0f1; | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| .nav-item:hover, | |
| .nav-item.active { | |
| background: rgba(255, 255, 255, 0.1); | |
| border-radius: 8px; | |
| color: #fff; | |
| } | |
| /* Right Content */ | |
| .content { | |
| flex: 1; | |
| padding: 25px; | |
| overflow-y: auto; | |
| background: var(--color-bg); | |
| } | |
| .section { | |
| display: none; | |
| } | |
| .section.active { | |
| display: block; | |
| } | |
| h1 { | |
| color: var(--color-primary); | |
| margin-bottom: 20px; | |
| font-size: 1.8rem; | |
| } | |
| h2 { | |
| color: var(--color-primary); | |
| margin: 20px 0 10px 0; | |
| font-size: 1.3rem; | |
| } | |
| /* Stats */ | |
| .stats-row { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); | |
| gap: 15px; | |
| margin-bottom: 25px; | |
| } | |
| .stat-card { | |
| background: #fff; | |
| padding: 20px; | |
| border-radius: 10px; | |
| box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); | |
| } | |
| .stat-label { | |
| font-size: 0.9rem; | |
| color: #666; | |
| margin-bottom: 8px; | |
| } | |
| .stat-value { | |
| font-size: 2rem; | |
| font-weight: 600; | |
| color: var(--color-primary); | |
| } | |
| /* Tables */ | |
| .table-container { | |
| background: #fff; | |
| padding: 20px; | |
| border-radius: 10px; | |
| margin-bottom: 20px; | |
| overflow-x: auto; | |
| } | |
| .table-controls { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 15px; | |
| gap: 10px; | |
| } | |
| .search-bar { | |
| padding: 8px 12px; | |
| border: 2px solid #eee; | |
| border-radius: 8px; | |
| width: 300px; | |
| font-family: inherit; | |
| } | |
| table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| min-width: 1000px; | |
| } | |
| th { | |
| background: var(--color-accent); | |
| color: #fff; | |
| padding: 10px 8px; | |
| text-align: left; | |
| position: sticky; | |
| top: 0; | |
| font-size: 0.85rem; | |
| border: 1px solid #fff; | |
| } | |
| td { | |
| padding: 8px; | |
| border: 1px solid #ddd; | |
| font-size: 0.8rem; | |
| } | |
| tr:hover { | |
| background: #f9f9f9; | |
| /* Fix for blue background on hover overriding selection */ | |
| } | |
| input.edit-cell { | |
| width: 45px !important; | |
| padding: 4px !important; | |
| text-align: center; | |
| border: 1px solid #ddd; | |
| border-radius: 4px; | |
| font-size: 0.8rem; | |
| } | |
| input[type="checkbox"] { | |
| width: auto; | |
| cursor: pointer; | |
| } | |
| .pagination { | |
| display: flex; | |
| gap: 5px; | |
| justify-content: center; | |
| margin-top: 15px; | |
| } | |
| .page-btn { | |
| padding: 5px 10px; | |
| border: 1px solid var(--color-primary); | |
| background: #fff; | |
| cursor: pointer; | |
| border-radius: 4px; | |
| font-size: 0.85rem; | |
| } | |
| .page-btn.active { | |
| background: var(--color-primary); | |
| color: #fff; | |
| } | |
| /* Responsive Admin */ | |
| @media (max-width: 768px) { | |
| .nav-panel { | |
| width: 150px; | |
| padding: 15px; | |
| } | |
| .content { | |
| padding: 15px; | |
| } | |
| .search-bar { | |
| width: 200px; | |
| } | |
| } | |
| /* ========================================= | |
| MODAL STYLES (Consolidated) | |
| ========================================= */ | |
| .modal-overlay { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: rgba(0, 0, 0, 0.4); | |
| display: none; | |
| justify-content: center; | |
| align-items: center; | |
| z-index: 9999; | |
| backdrop-filter: blur(4px); | |
| } | |
| /* Flex display override when active */ | |
| .modal-overlay.flex { | |
| display: flex; | |
| } | |
| .modal-box { | |
| background: #fff; | |
| padding: 30px; | |
| border-radius: 16px; | |
| width: 90%; | |
| max-width: 450px; | |
| box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15); | |
| text-align: center; | |
| animation: modalPop 0.3s ease-out; | |
| } | |
| @keyframes modalPop { | |
| 0% { | |
| transform: scale(0.9); | |
| opacity: 0; | |
| } | |
| 100% { | |
| transform: scale(1); | |
| opacity: 1; | |
| } | |
| } | |
| .modal-title { | |
| font-size: 1.5rem; | |
| font-weight: 600; | |
| color: #727AC0; | |
| margin-bottom: 15px; | |
| } | |
| .modal-message { | |
| font-size: 1rem; | |
| color: #444; | |
| line-height: 1.5; | |
| margin-bottom: 25px; | |
| } | |
| .modal-btn { | |
| background: #727AC0; | |
| color: #fff; | |
| border: none; | |
| padding: 10px 30px; | |
| border-radius: 8px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| } | |
| .modal-btn:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1); | |
| } | |
| .modal-error .modal-title { | |
| color: #E2727C; | |
| } | |
| .modal-success .modal-title { | |
| color: #29ABB1; | |
| } | |
| /* Status Text Formatting */ | |
| .status-pending { | |
| font-size: 0.85rem; | |
| color: #e67e22; | |
| font-style: italic; | |
| margin-top: 2px; | |
| } | |
| .status-success { | |
| font-size: 0.85rem; | |
| color: green; | |
| font-weight: bold; | |
| margin-top: 2px; | |
| } | |
| /* --- ADMIN DASHBOARD RESPONSIVE STYLES --- */ | |
| /* --- GLOBAL TABLE BORDER FIX --- */ | |
| table, | |
| th, | |
| td, | |
| .admin-layout table, | |
| .admin-layout th, | |
| .admin-layout td, | |
| #seat-selection-view table, | |
| #seat-selection-view th, | |
| #seat-selection-view td { | |
| border-color: #1f2a44 !important; | |
| } | |
| /* --- GLOBAL TABLE BORDER FIX --- */ | |
| table, | |
| th, | |
| td { | |
| border: 1px solid #1f2a44 !important; | |
| } | |
| /* --- GLOBAL TABLE HEADER FIX --- */ | |
| /* Ensure all table headers, especially first columns, are readable */ | |
| th, | |
| .admin-layout th, | |
| #seat-selection-view th, | |
| #table-daily-container th, | |
| #table-lf-container th, | |
| #daily-jagah-content th, | |
| #lf-jagah-content th, | |
| #behno-table th { | |
| background-color: var(--color-accent) !important; | |
| color: #fff !important; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| padding: 8px !important; | |
| } | |
| /* Ensure sticky columns match */ | |
| th:first-child, | |
| td:first-child { | |
| /* ensure proper background for readability if sticky */ | |
| } | |
| /* COMPACT TABLE STYLES (Desktop) */ | |
| .admin-layout th, | |
| .admin-layout td { | |
| padding: 3px 4px !important; | |
| font-size: 0.85rem !important; | |
| line-height: 1.1 !important; | |
| text-align: left !important; | |
| text-align: left !important; | |
| text-align: left !important; | |
| white-space: normal !important; | |
| /* Allow wrapping */ | |
| word-wrap: break-word; | |
| max-width: 150px; | |
| /* Force wrapping for long names */ | |
| } | |
| /* Specific narrow width for Attnd column to force wrapping */ | |
| td[data-label="Attnd"] { | |
| max-width: 100px; | |
| } | |
| /* Fix Jagah Table Widths & Scrolling */ | |
| .admin-layout .table-container { | |
| width: 100%; | |
| overflow-x: auto; | |
| -webkit-overflow-scrolling: touch; | |
| margin-bottom: 20px; | |
| border: 1px solid #eee; | |
| } | |
| /* Strict wrapping for Daily/LF Jagah Names */ | |
| #daily-jagah td, | |
| #lf-jagah td { | |
| white-space: normal !important; | |
| word-wrap: break-word !important; | |
| overflow-wrap: break-word !important; | |
| max-width: 85px !important; | |
| min-width: 60px !important; | |
| vertical-align: top; | |
| } | |
| #daily-jagah table, | |
| #lf-jagah table { | |
| width: max-content; | |
| /* Allow table to spill over */ | |
| min-width: auto; | |
| border-collapse: collapse; | |
| } | |
| /* Sticky First Column (Date) for Desktop */ | |
| .admin-layout th:first-child, | |
| .admin-layout td:first-child { | |
| position: sticky; | |
| left: 0; | |
| background-color: #fff; | |
| /* Opaque background over scrolling content */ | |
| z-index: 10; | |
| border-right: 2px solid #eee; | |
| } | |
| .admin-layout th:first-child { | |
| z-index: 11; | |
| /* Higher than td */ | |
| background-color: var(--color-accent); | |
| /* Match header color so text is visible */ | |
| } | |
| /* MOBILE CARD VIEW - NO SCROLLING */ | |
| @media (max-width: 768px) { | |
| .admin-layout .table-container { | |
| overflow-x: hidden !important; | |
| } | |
| .admin-layout table, | |
| .admin-layout thead, | |
| .admin-layout tbody, | |
| .admin-layout th, | |
| .admin-layout td, | |
| .admin-layout tr { | |
| display: block; | |
| } | |
| .admin-layout thead tr { | |
| position: absolute; | |
| top: -9999px; | |
| left: -9999px; | |
| } | |
| .admin-layout tr { | |
| border: 1px solid #eee; | |
| margin-bottom: 10px; | |
| background: #fff; | |
| border-radius: 8px; | |
| box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); | |
| } | |
| .admin-layout td { | |
| border: none; | |
| border-bottom: 1px solid #eee; | |
| position: relative; | |
| padding-left: 50% !important; | |
| text-align: right !important; | |
| white-space: normal !important; | |
| word-break: break-word; | |
| } | |
| .admin-layout td:before { | |
| position: absolute; | |
| top: 6px; | |
| left: 6px; | |
| width: 45%; | |
| padding-right: 10px; | |
| white-space: nowrap; | |
| text-align: left; | |
| font-weight: bold; | |
| color: #554346; | |
| content: attr(data-label); | |
| } | |
| } | |
| </style> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment