<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PadelOS — Multi-Role Management Portal</title>
<style>
:root {
--primary: #0e6b56;
--primary-light: #10b981;
--accent: #3b82f6;
--bg: #f8fafc;
--card-bg: #ffffff;
--text: #1e293b;
--text-light: #64748b;
--border: #cbd5e1;
--radius: 12px;
}
* { box-sizing: border-box; margin: 0; padding: 0; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; }
body { background-color: var(--bg); color: var(--text); min-height: 100vh; display: flex; flex-direction: column; align-items: center; padding: 20px; }
.container { width: 100%; max-width: 1200px; background: var(--card-bg); border-radius: var(--radius); box-shadow: 0 10px 25px -5px rgba(0,0,0,0.08); padding: 30px; margin-bottom: 30px; }
header { text-align: center; margin-bottom: 25px; border-bottom: 2px solid var(--border); padding-bottom: 15px; }
header h1 { color: var(--primary); font-size: 1.8rem; margin-bottom: 6px; }
header p { color: var(--text-light); font-size: 1rem; }
.screen { display: none; }
.screen.active { display: block; animation: fadeIn 0.3s ease-in-out; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
.form-group { margin-bottom: 18px; }
label { display: block; font-weight: 700; font-size: 0.9rem; margin-bottom: 6px; color: var(--text); }
input[type="text"], input[type="email"], input[type="password"], textarea, select {
width: 100%; padding: 12px 16px; border: 2px solid var(--border); border-radius: 10px; font-size: 1rem; outline: none; background: var(--card-bg); color: var(--text);
}
.btn { display: inline-block; width: 100%; background: var(--primary); color: white; border: none; padding: 14px 20px; font-size: 1rem; font-weight: 700; border-radius: 10px; cursor: pointer; text-align: center; transition: background 0.2s; margin-top: 10px; }
.btn:hover { background: #094d3e; }
.btn-secondary { background: #64748b; margin-top: 8px; }
.navbar { display: flex; justify-content: space-between; align-items: center; background: var(--primary); color: white; padding: 14px 18px; border-radius: 10px; margin-bottom: 20px; }
.navbar span { font-weight: 700; }
.navbar button { background: rgba(255,255,255,0.2); border: none; color: white; padding: 8px 14px; border-radius: 6px; cursor: pointer; font-weight: 600; }
.menu-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin-bottom: 25px; }
.menu-card { background: var(--bg); border: 2px solid var(--border); padding: 20px; border-radius: 10px; cursor: pointer; transition: all 0.2s; text-align: center; }
.menu-card:hover { border-color: var(--primary-light); transform: translateY(-2px); background: var(--card-bg); }
.menu-card h3 { color: var(--primary); font-size: 1.1rem; margin-bottom: 6px; }
.menu-card p { font-size: 0.9rem; color: var(--text-light); }
/* Alfabetische knoppen grid voor tabbladen */
.sheet-buttons-grid { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 15px; }
.btn-sheet { background: #e2e8f0; color: var(--text); border: none; padding: 8px 14px; border-radius: 6px; font-weight: 600; cursor: pointer; font-size: 0.85rem; }
.btn-sheet:hover, .btn-sheet.active { background: var(--primary); color: white; }
.table-container { width: 100%; overflow-x: auto; margin-top: 15px; max-height: 450px; border: 1px solid var(--border); border-radius: 8px; }
table { width: 100%; border-collapse: collapse; text-align: left; font-size: 0.9rem; }
th, td { padding: 10px 12px; border-bottom: 1px solid var(--border); }
th { background: var(--primary); color: white; position: sticky; top: 0; }
/* WhatsApp chat stijl */
.chat-box { background: #e5ddd5; border-radius: 10px; padding: 15px; height: 300px; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; margin-bottom: 15px; }
.chat-msg { background: #ffffff; padding: 10px 14px; border-radius: 8px; max-width: 75%; box-shadow: 0 1px 2px rgba(0,0,0,0.1); }
.chat-msg.me { background: #dcf8c6; align-self: flex-end; }
.spinner { display: none; text-align: center; margin: 15px 0; color: var(--primary-light); font-weight: 700; }
.alert { padding: 14px; border-radius: 10px; margin-bottom: 16px; font-size: 0.95rem; display: none; font-weight: 600; }
.alert-success { background: #d1fae5; color: #065f46; border: 1px solid #a7f3d0; }
.alert-error { background: #fee2e2; color: #991b1b; border: 1px solid #fecaca; }
footer { text-align: center; margin-top: auto; font-size: 0.85rem; color: var(--text-light); padding: 15px; border-top: 1px solid var(--border); }
</style>
</head>
<body>
<div class="container">
<header>
<h1>🎾 PadelOS Management Portal</h1>
<p>Speler-, Trainer- & Beheersysteem met Clubkoppelingen & Chat</p>
</header>
<div id="alertBox" class="alert"></div>
<div id="spinner" class="spinner">Even geduld...</div>
<!-- SCHERM: INLOGGEN -->
<div id="screen-login" class="screen active">
<h2>Inloggen of Registreren</h2>
<p style="color:var(--text-light); margin-bottom: 20px;">Log in als Speler, Trainer of Administrator via e-mailcode of wachtwoord.</p>
<div class="form-group">
<label>E-mailadres</label>
<input type="email" id="loginEmail" placeholder="jouw@email.nl">
</div>
<div class="form-group">
<label>Wachtwoord (optioneel)</label>
<input type="password" id="loginPassword" placeholder="Laat leeg voor inlogcode per e-mail">
</div>
<button type="button" class="btn" onclick="handleLoginSubmit()">Inloggen / Code Aanvragen</button>
</div>
<!-- SCHERM: VERIFICATIECODE -->
<div id="screen-verify" class="screen">
<h2>Verificatiecode Invoeren</h2>
<p style="color:var(--text-light); margin-bottom: 20px;" id="verifyInfoText">We hebben een 6-cijferige code gestuurd.</p>
<div class="form-group">
<label>Verificatiecode</label>
<input type="text" id="verifyCode" placeholder="123456" maxlength="6">
</div>
<button type="button" class="btn" onclick="handleVerifyCodeSubmit()">Verifiëren & Inloggen</button>
<button type="button" class="btn btn-secondary" onclick="showScreen('screen-login')">Terug</button>
</div>
<!-- SCHERM: DASHBOARD -->
<div id="screen-dashboard" class="screen">
<div class="navbar">
<span id="welcomeUser">Welkom</span>
<button type="button" onclick="handleLogout()">Uitloggen</button>
</div>
<h3 style="margin-bottom: 12px; color: var(--primary);">Jouw Rollen & Modules</h3>
<div class="menu-grid" id="rolesGrid"></div>
<div id="adminSection" style="display:none; margin-top: 25px;">
<h3 style="color: var(--primary); margin-bottom: 10px;">📊 Alle Spreadsheet Tabbladen (Alfabetisch)</h3>
<div class="sheet-buttons-grid" id="sheetButtonsGrid"></div>
<h4 id="currentTableName" style="margin-top:15px; color:var(--primary);">Selecteer een tabblad hierboven</h4>
<div class="table-container" id="tablePreviewContainer">
<p style="padding: 15px; color: var(--text-light);">Klik op een knop om de inhoud te bekijken en te bewerken.</p>
</div>
</div>
<!-- WhatsApp Chat Sectie -->
<div style="margin-top: 30px; background: #f1f5f9; padding: 20px; border-radius: 10px;">
<h3 style="color: var(--primary); margin-bottom: 10px;">💬 Team & Club Communicatie (WhatsApp Stijl)</h3>
<div class="chat-box" id="chatBoxContainer"></div>
<div style="display: flex; gap: 10px;">
<input type="text" id="chatInputMessage" placeholder="Typ je bericht aan het team of club..." style="flex:1;">
<button type="button" class="btn" style="width: auto; margin-top:0;" onclick="sendChatMessage()">Verstuur</button>
</div>
</div>
</div>
</div>
<footer>
<p>© 2026 PadelOS Management — Gabeler.eu & Stichting GODOGOOD</p>
</footer>
<script>
let sessionToken = localStorage.getItem('padelos_token') || '';
let currentEmailForCode = '';
let activeUserData = null;
window.addEventListener('DOMContentLoaded', () => {
if (sessionToken) {
showSpinner(true);
google.script.run
.withSuccessHandler(res => {
showSpinner(false);
if (res && res.authenticated) {
initDashboard(res);
} else {
localStorage.removeItem('padelos_token');
showScreen('screen-login');
}
})
.withFailureHandler(() => {
showSpinner(false);
showScreen('screen-login');
})
.bootstrap(sessionToken);
} else {
showScreen('screen-login');
}
});
function showScreen(id) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById(id).classList.add('active');
hideAlert();
}
function showAlert(msg, type = 'success') {
const box = document.getElementById('alertBox');
box.className = 'alert ' + (type === 'success' ? 'alert-success' : 'alert-error');
box.innerText = msg;
box.style.display = 'block';
}
function hideAlert() { document.getElementById('alertBox').style.display = 'none'; }
function showSpinner(show) { document.getElementById('spinner').style.display = show ? 'block' : 'none'; }
function handleLoginSubmit() {
const email = document.getElementById('loginEmail').value.trim();
const password = document.getElementById('loginPassword').value.trim();
if (!email) { showAlert('Vul een geldig e-mailadres in.', 'error'); return; }
currentEmailForCode = email;
showSpinner(true);
if (password) {
google.script.run
.withSuccessHandler(res => {
showSpinner(false);
if (res && res.authenticated) {
sessionToken = res.sessionToken;
localStorage.setItem('padelos_token', sessionToken);
initDashboard(res);
} else {
showAlert('Inloggen mislukt.', 'error');
}
})
.withFailureHandler(() => {
showSpinner(false);
requestLoginCode(email);
})
.passwordLogin(email, password);
} else {
requestLoginCode(email);
}
}
function requestLoginCode(email) {
google.script.run
.withSuccessHandler(res => {
showSpinner(false);
if (res && res.ok) {
document.getElementById('verifyInfoText').innerText = 'Verificatiecode verzonden naar ' + email;
showScreen('screen-verify');
} else {
showAlert('Kon geen code verzenden.', 'error');
}
})
.withFailureHandler(err => {
showSpinner(false);
showAlert('Fout: ' + err.message, 'error');
})
.requestLoginCode(email);
}
function handleVerifyCodeSubmit() {
const code = document.getElementById('verifyCode').value.trim();
if (!code) { showAlert('Vul de verificatiecode in.', 'error'); return; }
showSpinner(true);
google.script.run
.withSuccessHandler(res => {
showSpinner(false);
if (res && res.authenticated) {
sessionToken = res.sessionToken;
localStorage.setItem('padelos_token', sessionToken);
initDashboard(res);
} else {
showAlert('Onjuiste code.', 'error');
}
})
.withFailureHandler(err => {
showSpinner(false);
showAlert('Fout: ' + err.message, 'error');
})
.verifyLoginCode(currentEmailForCode, code);
}
function initDashboard(res) {
activeUserData = res;
document.getElementById('welcomeUser').innerText = 'Welkom, ' + (res.user.displayName || res.user.email) + ' (' + res.roles.join(', ') + ')';
const grid = document.getElementById('rolesGrid');
let html = '';
res.interfaces.forEach(item => {
html += `
<div class="menu-card" onclick="alert('Rol module: ${item.title}\\n${item.text}')">
<h3>${item.icon} ${item.title}</h3>
<p>${item.text}</p>
</div>
`;
});
grid.innerHTML = html;
// Als gebruiker ADMIN is, toon alle alfabetische tabbladen knoppen
if (res.roles.includes('ADMIN') || res.roles.includes('SUPERADMIN') || res.user.email === 'padelstijn@gmail.com') {
document.getElementById('adminSection').style.display = 'block';
loadAlphabeticalSheetButtons();
}
loadChatMessages();
showScreen('screen-dashboard');
}
function loadAlphabeticalSheetButtons() {
google.script.run
.withSuccessHandler(sheets => {
const grid = document.getElementById('sheetButtonsGrid');
let html = '';
sheets.forEach(s => {
html += `<button type="button" class="btn-sheet" onclick="loadTable('${s}', this)">${s}</button>`;
});
grid.innerHTML = html;
})
.getAllSheetNames(sessionToken);
}
function loadTable(tableName, btnElement) {
document.querySelectorAll('.btn-sheet').forEach(b => b.classList.remove('active'));
if (btnElement) btnElement.classList.add('active');
document.getElementById('currentTableName').innerText = 'Tabblad: ' + tableName;
showSpinner(true);
google.script.run
.withSuccessHandler(res => {
showSpinner(false);
const container = document.getElementById('tablePreviewContainer');
if (!res.rows || res.rows.length === 0) {
container.innerHTML = `<p style="padding:15px; color:var(--text-light);">Tabblad '${tableName}' is leeg.</p>`;
return;
}
let tableHtml = `<table><thead><tr>`;
res.headers.forEach(h => tableHtml += `<th>${h}</th>`);
tableHtml += `</tr></thead><tbody>`;
res.rows.forEach(r => {
tableHtml += `<tr>`;
res.headers.forEach(h => {
tableHtml += `<td>${r.data[h] !== undefined ? r.data[h] : ''}</td>`;
});
tableHtml += `</tr>`;
});
tableHtml += `</tbody></table>`;
container.innerHTML = tableHtml;
})
.withFailureHandler(err => {
showSpinner(false);
showAlert('Fout bij laden tabel: ' + err.message, 'error');
})
.getTableData(sessionToken, tableName);
}
function loadChatMessages() {
google.script.run
.withSuccessHandler(res => {
const box = document.getElementById('chatBoxContainer');
if (!res.messages || res.messages.length === 0) {
box.innerHTML = '<p style="color:var(--text-light); text-align:center;">Geen berichten in de chat. Start hieronder de conversatie!</p>';
return;
}
let html = '';
res.messages.forEach(m => {
const isMe = activeUserData && m.AFZENDER_ID === activeUserData.user.id;
html += `<div class="chat-msg ${isMe ? 'me' : ''}"><small style="display:block; font-size:0.75rem; color:#64748b;">${m.AFZENDER_ID}</small>${m.BERICHT}</div>`;
});
box.innerHTML = html;
box.scrollTop = box.scrollHeight;
})
.getChatMessages(sessionToken, 'TEAM-GENERAL');
}
function sendChatMessage() {
const input = document.getElementById('chatInputMessage');
const text = input.value.trim();
if (!text) return;
google.script.run
.withSuccessHandler(() => {
input.value = '';
loadChatMessages();
})
.sendChatMessage(sessionToken, { gesprekId: 'TEAM-GENERAL', bericht: text });
}
function handleLogout() {
google.script.run.logout(sessionToken);
localStorage.removeItem('padelos_token');
sessionToken = '';
showScreen('screen-login');
}
</script>
</body>
</html>
Code gs
/************************************************************
* PADELOS ENTERPRISE IDENTITY & MANAGEMENT CORE v4.0
* Gekoppeld aan Spreadsheet: 1Rap-6ENmSOwF-lkSZuwxxkJfVWVyfNqRsSD0h9L4uF8
************************************************************/
const P12345 = Object.freeze({
VERSION: '4.0.0',
SPREADSHEET_ID: '1Rap-6ENmSOwF-lkSZuwxxkJfVWVyfNqRsSD0h9L4uF8',
ADMIN_EMAIL: 'padelstijn@gmail.com',
CODE_MINUTES: 10,
SESSION_HOURS: 24,
TABS: {
A1_A2_B: ['FASE_ID','SLAG_ID','NBF_ID','FASE','DOEL','OPDRACHT','WAARNEMEN','KIEZEN','UITVOEREN','SPELREALITEIT','SUCCESCRITERIUM','COACHING_CUE','STATUS','VERSIE'],
ADMIN_TAKEN: ['ID','TYPE','TITEL','OMSCHRIJVING','ENTITY_TYPE','ENTITY_ID','TOEGEWEZEN_AAN','PRIORITEIT','DEADLINE','VOLGENDE_ACTIE','AFGEROND_AT','RESULTAAT','STATUS','CREATED_BY','CREATED_AT','UPDATED_AT'],
AFMELDINGEN: ['ID','LES_ID','SPELER_ID','REDEN','TIJDIG','ACTIE','STATUS','CREATED_AT','UPDATED_AT','TRAINER_ID','TYPE','INHAAL_NODIG','BERICHT'],
AGENDA_EVENTS: ['ID','ENTITY_TYPE','ENTITY_ID','TITEL','START_AT','END_AT','LOCATIE','OMSCHRIJVING','STATUS','CREATED_AT','UPDATED_AT','USER_ID','TEAM_ID','TRAINER_ID','SPELER_ID','TYPE','DOELGROEP','LOCATIE_ID','BAAN_ID','KLEUR','ZICHTBAAR_VOOR'],
AUDIT_LOG: ['ID','USER_ID','ACTION','ENTITY','ENTITY_ID','SUCCESS','MESSAGE','DETAILS_JSON','STATUS','CREATED_AT','UPDATED_AT'],
BAANBOEKINGEN: ['ID','LES_ID','LOCATIE_ID','BAAN_ID','DATUM','STARTTIJD','EINDTIJD','KOSTEN','BOEKINGSREFERENTIE','BEVESTIGD','BETAALSTATUS','OPMERKING','STATUS','CREATED_AT','UPDATED_AT'],
BANEN: ['ID','LOCATIE_ID','NAAM','NUMMER','TYPE','INDOOR','OVERDEKT','STATUS','CREATED_AT','UPDATED_AT'],
BEDOELING: ['BED_ID','BEDOELING','OMSCHRIJVING','VOLGORDE','STATUS','VERSIE'],
BERICHTEN: ['ID','GESPREK_ID','AFZENDER_ID','ONTVANGER_ID','GROEP_ID','LES_ID','ONDERWERP','BERICHT','KANAAL','GELEZEN_AT','STATUS','CREATED_AT','UPDATED_AT','TEAM_ID','BERICHT_TYPE'],
BETALINGEN: ['ID','FACTUUR_ID','KLANT_ID','DATUM','BEDRAG','BETAALWIJZE','REFERENTIE','OPMERKING','STATUS','CREATED_AT','UPDATED_AT'],
BOEKINGEN: ['ID','USER_ID','DATUM','TIJD','LOCATIE','TYPE','STATUS','TRAINER_ID','OPMERKING','CREATED_AT','UPDATED_AT'],
CLUB_BOEKING_KOPPELINGEN: ['CLUB_ID','LOCATIE','DOELGROEP','PLATFORM','BOOKING_TYPE','BOOKING_URL','LOGIN_NODIG','LIDMAATSCHAP_NODIG','LIVE_BESCHIKBAARHEID','LIVE_PRIJS','EMBED_MOGELIJK','DEEPLINK_MOGELIJK','API_STATUS','SOURCE_URL','CHECKED_AT','OPMERKING'],
GEBRUIKERS: ['ID','EMAIL','VOORNAAM','ACHTERNAAM','TELEFOON','PASSWORD_HASH','PASSWORD_SALT','EMAIL_VERIFIED','LAST_LOGIN','STATUS','CREATED_AT','UPDATED_AT'],
GEBRUIKER_ROLLEN: ['ID','USER_ID','ROLE_CODE','STATUS','CREATED_AT','UPDATED_AT'],
LOCATIES: ['ID','NAAM','ADRES','POSTCODE','PLAATS','CONTACTPERSOON','TELEFOON','EMAIL','STATUS','CREATED_AT','UPDATED_AT'],
LOGIN_CODES: ['ID','EMAIL','CODE_HASH','PURPOSE','EXPIRES_AT','ATTEMPTS','USED_AT','STATUS','CREATED_AT','UPDATED_AT'],
PROFIELEN: ['ID','USER_ID','WEERGAVENAAM','FOTO_URL','BIO','TAAL','LETTERGROOTTE','THEMA','BG_KLEUR','KAART_KLEUR','TEKST_KLEUR','ACCENT_KLEUR','QUOTE','SPEELSTERKTE','BONDSNUMMER','SOCIALS','INTERESSES','PARTNER_STATUS','LESSEN','LOCATIES','LIDMAATSCHAPPEN','STATUS','CREATED_AT','UPDATED_AT'],
ROLLEN: ['ID','CODE','NAAM','OMSCHRIJVING','STATUS','CREATED_AT','UPDATED_AT'],
SESSIES: ['ID','USER_ID','TOKEN_HASH','EXPIRES_AT','LAST_USED_AT','STATUS','CREATED_AT','UPDATED_AT'],
TRAINER_BESCHIKBAARHEID: ['ID','USER_ID','TYPE','START_DATUM','EIND_DATUM','TIJD','STATUS','TOELICHTING','CREATED_AT','UPDATED_AT']
}
});
function doGet(e) {
return HtmlService.createTemplateFromFile('Index').evaluate()
.setTitle('PadelOS — Multi-Role Management Portal')
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
.addMetaTag('viewport', 'width=device-width, initial-scale=1');
}
function setupPadelOS12345() {
const lock = LockService.getScriptLock();
lock.waitLock(30000);
try {
Object.entries(P12345.TABS).forEach(([n, h]) => ensureTab_(n, h));
if (!PropertiesService.getScriptProperties().getProperty('PADELOS_SECRET')) {
PropertiesService.getScriptProperties().setProperty('PADELOS_SECRET', token_() + token_());
}
const rollenLijst = [
['SPELER', 'Speler / leerling / student', 'Spelerinterface, boekingen & leerdoelen'],
['TRAINER', 'Padeltrainer', 'Trainerinterface, urenregistratie & lessen'],
['ADMIN', 'Administrator', 'Volledig beheer van alle tabbladen en financiele vrijgave']
];
rollenLijst.forEach(([code, naam, desc]) => seedRole_(code, naam, desc));
let admin = find_('GEBRUIKERS', 'EMAIL', P12345.ADMIN_EMAIL);
if (!admin) {
admin = createUser_(P12345.ADMIN_EMAIL, 'Stijn', 'Gabeler');
}
rollenLijst.forEach(([code]) => ensureUserRole_(admin.ID, code));
return { ok: true, version: P12345.VERSION, message: 'PadelOS volledig geïnitialiseerd!' };
} finally {
lock.releaseLock();
}
}
function bootstrap(sessionToken) {
const auth = auth_(sessionToken);
if (!auth) return { ok: true, version: P12345.VERSION, authenticated: false };
return userState_(auth.userId, sessionToken);
}
function requestLoginCode(email) {
email = normalizeEmail_(email);
if (!validEmail_(email)) throw publicError_('Vul een geldig e-mailadres in.');
const lock = LockService.getScriptLock();
lock.waitLock(15000);
try {
const code = String(Math.floor(100000 + Math.random() * 900000));
const now = new Date();
append_('LOGIN_CODES', {
ID: id_('LOGI'), EMAIL: email, CODE_HASH: hash_('code|' + email + '|' + code),
PURPOSE: 'LOGIN_REGISTER', EXPIRES_AT: new Date(now.getTime() + P12345.CODE_MINUTES * 60000),
ATTEMPTS: 0, USED_AT: '', STATUS: 'ACTIVE', CREATED_AT: now, UPDATED_AT: now
});
MailApp.sendEmail({
to: email,
subject: 'Jouw PadelOS Inlogcode',
name: 'PadelOS Management',
htmlBody: '<div style="font-family:Arial,sans-serif"><h2>🎾 PadelOS Inlogportaal</h2><p>Jouw eenmalige inlogcode:</p><p style="font-size:32px;font-weight:800;letter-spacing:6px;color:#0e6b56">' + code + '</p></div>'
});
return { ok: true, email, message: 'Verificatiecode verzonden per e-mail.' };
} finally {
lock.releaseLock();
}
}
function verifyLoginCode(email, code) {
email = normalizeEmail_(email); code = String(code || '').trim();
const item = rows_('LOGIN_CODES').filter(x => normalizeEmail_(x.data.EMAIL) === email && x.data.STATUS === 'ACTIVE')
.sort((a, b) => dateMs_(b.data.CREATED_AT) - dateMs_(a.data.CREATED_AT))[0];
if (!item || dateMs_(item.data.EXPIRES_AT) <= Date.now()) throw publicError_('Deze code is verlopen of niet gevonden.');
if (!safeEqual_(item.data.CODE_HASH, hash_('code|' + email + '|' + code))) throw publicError_('De code klopt niet.');
updateRow_('LOGIN_CODES', item.row, { USED_AT: new Date(), STATUS: 'USED', UPDATED_AT: new Date() });
let user = find_('GEBRUIKERS', 'EMAIL', email);
if (!user) user = createUser_(email, '', '');
const token = token_();
const now = new Date();
append_('SESSIES', {
ID: id_('SESS'), USER_ID: user.ID, TOKEN_HASH: hash_('session|' + token),
EXPIRES_AT: new Date(now.getTime() + P12345.SESSION_HOURS * 3600000),
LAST_USED_AT: now, STATUS: 'ACTIVE', CREATED_AT: now, UPDATED_AT: now
});
updateBy_('GEBRUIKERS', 'ID', user.ID, { EMAIL_VERIFIED: 'JA', LAST_LOGIN: now, UPDATED_AT: now });
return userState_(user.ID, token);
}
function passwordLogin(email, password) {
email = normalizeEmail_(email);
const u = find_('GEBRUIKERS', 'EMAIL', email);
if (!u) throw publicError_('Gebruiker niet gevonden. Vraag een inlogcode aan.');
const storedHash = u.PASSWORD_HASH || '';
const inputHash = hash_('pwd|' + password + '|' + (u.PASSWORD_SALT || ''));
if (storedHash && storedHash !== inputHash) {
throw publicError_('Onjuist wachtwoord.');
}
const token = token_();
const now = new Date();
append_('SESSIES', {
ID: id_('SESS'), USER_ID: u.ID, TOKEN_HASH: hash_('session|' + token),
EXPIRES_AT: new Date(now.getTime() + P12345.SESSION_HOURS * 3600000),
LAST_USED_AT: now, STATUS: 'ACTIVE', CREATED_AT: now, UPDATED_AT: now
});
updateBy_('GEBRUIKERS', 'ID', u.ID, { LAST_LOGIN: now, UPDATED_AT: now });
return userState_(u.ID, token);
}
function setPassword(sessionToken, newPassword) {
const auth = requireAuth_(sessionToken);
const salt = token_().slice(0, 8);
const pwdHash = hash_('pwd|' + newPassword + '|' + salt);
updateBy_('GEBRUIKERS', 'ID', auth.userId, { PASSWORD_HASH: pwdHash, PASSWORD_SALT: salt, UPDATED_AT: new Date() });
return { ok: true, message: 'Wachtwoord succesvol ingesteld!' };
}
function logout(sessionToken) {
const a = auth_(sessionToken, true);
if (a) updateRow_('SESSIES', a.row, { STATUS: 'REVOKED', UPDATED_AT: new Date() });
return { ok: true };
}
function saveMyProfile(sessionToken, input) {
const a = requireAuth_(sessionToken);
const p = input || {};
const now = new Date();
updateBy_('GEBRUIKERS', 'ID', a.userId, {
VOORNAAM: clean_(p.firstName, 60),
ACHTERNAAM: clean_(p.lastName, 80),
TELEFOON: clean_(p.phone, 40),
UPDATED_AT: now
});
updateBy_('PROFIELEN', 'USER_ID', a.userId, {
WEERGAVENAAM: clean_(p.displayName, 100),
BIO: clean_(p.bio, 500),
QUOTE: clean_(p.quote, 200),
SPEELSTERKTE: clean_(p.speelsterkte, 20),
BONDSNUMMER: clean_(p.bondsnummer, 50),
LOCATIES: clean_(p.locaties, 200),
UPDATED_AT: now
});
return userState_(a.userId, sessionToken);
}
// Haal alfabetisch gesorteerde tabbladen op
function getAllSheetNames(sessionToken) {
requireAuth_(sessionToken);
const ss = db_();
return ss.getSheets().map(s => s.getName()).sort();
}
function getTableData(sessionToken, tableName) {
requireAuth_(sessionToken);
if (!tableName) throw publicError_('Tabelnaam ontbreekt.');
const sh = db_().getSheetByName(tableName);
if (!sh) throw publicError_('Tabblad niet gevonden.');
const v = sh.getDataRange().getValues();
if (v.length === 0) return { ok: true, headers: [], rows: [] };
const headers = v[0].map(String);
const rows = v.slice(1).map((r, i) => ({
row: i + 2,
data: Object.fromEntries(headers.map((k, j) => [k, r[j]]))
}));
return { ok: true, headers, rows };
}
function saveTableRow(sessionToken, tableName, rowId, rowData) {
requireAuth_(sessionToken);
const sh = sheet_(tableName);
const headers = headers_(tableName);
const now = new Date();
if (!rowId) {
rowId = id_(tableName.slice(0, 4).toUpperCase());
rowData.ID = rowId;
if (headers.includes('CREATED_AT')) rowData.CREATED_AT = now;
if (headers.includes('UPDATED_AT')) rowData.UPDATED_AT = now;
if (headers.includes('STATUS')) rowData.STATUS = rowData.STATUS || 'ACTIVE';
append_(tableName, rowData);
} else {
if (headers.includes('UPDATED_AT')) rowData.UPDATED_AT = now;
updateBy_(tableName, 'ID', rowId, rowData);
}
return { ok: true, message: 'Succesvol opgeslagen!' };
}
// Chatfunctie (WhatsApp stijl opgeslagen in BERICHTEN)
function getChatMessages(sessionToken, gesprekId) {
requireAuth_(sessionToken);
const messages = rows_('BERICHTEN').filter(r => !gesprekId || r.data.GESPREK_ID === gesprekId);
return { ok: true, messages: messages.map(m => m.data) };
}
function sendChatMessage(sessionToken, data) {
const auth = requireAuth_(sessionToken);
const now = new Date();
append_('BERICHTEN', {
ID: id_('BERI'),
GESPREK_ID: data.gesprekId || 'TEAM-GENERAL',
AFZENDER_ID: auth.userId,
ONTVANGER_ID: data.ontvangerId || '',
BERICHT: data.bericht || '',
KANAAL: 'PORTAL_CHAT',
STATUS: 'ACTIVE',
CREATED_AT: now,
UPDATED_AT: now
});
return { ok: true, message: 'Bericht verzonden.' };
}
function userState_(userId, rawToken) {
const u = find_('GEBRUIKERS', 'ID', userId);
const p = find_('PROFIELEN', 'USER_ID', userId) || {};
const roles = table_('GEBRUIKER_ROLLEN').filter(r => r.USER_ID === userId && r.STATUS === 'ACTIVE').map(r => r.ROLE_CODE);
return {
ok: true, version: P12345.VERSION, authenticated: true, sessionToken: rawToken,
user: {
id: u.ID, email: u.EMAIL, firstName: u.VOORNAAM || '', lastName: u.ACHTERNAAM || '',
phone: u.TELEFOON || '', displayName: p.WEERGAVENAAM || u.VOORNAAM || u.EMAIL.split('@')[0],
bio: p.BIO || '', quote: p.QUOTE || '', speelsterkte: p.SPEELSTERKTE || '',
bondsnummer: p.BONDSNUMMER || '', locaties: p.LOCATIES || ''
},
roles, interfaces: interfacesFor_(roles)
};
}
function interfacesFor_(roles) {
const registry = {
SPELER: { icon: '🎾', title: 'Speler Portaal', text: 'Boek lessen, bekijk leerdoelen, huiswerk en chat.', table: 'BOEKINGEN' },
TRAINER: { icon: '🧑🏫', title: 'Trainer Portaal', text: 'Beheer lessen, registreer gewerkte uren en communicatie.', table: 'TRAINER_BESCHIKBAARHEID' },
ADMIN: { icon: '⚡', title: 'Administrator Portaal', text: 'Volledig beheer, financiële vrijgave, locaties en alle tabbladen.', table: 'PROFIELEN' }
};
return roles.map(code => Object.assign({ code }, registry[code] || { icon: '🔹', title: code, text: 'PadelOS Module', table: 'PROFIELEN' }));
}
function createUser_(email, first, last) {
const now = new Date(), id = id_('GEBR');
append_('GEBRUIKERS', { ID: id, EMAIL: email, VOORNAAM: first, ACHTERNAAM: last, TELEFOON: '', PASSWORD_HASH: '', PASSWORD_SALT: '', EMAIL_VERIFIED: 'JA', LAST_LOGIN: '', STATUS: 'ACTIVE', CREATED_AT: now, UPDATED_AT: now });
append_('PROFIELEN', { ID: id_('PROF'), USER_ID: id, WEERGAVENAAM: email.split('@')[0], FOTO_URL: '', BIO: '', QUOTE: 'Jij bepaalt je eigen koers.', SPEELSTERKTE: '5', BONDSNUMMER: '', STATUS: 'ACTIVE', CREATED_AT: now, UPDATED_AT: now });
ensureUserRole_(id, 'SPELER');
return find_('GEBRUIKERS', 'ID', id);
}
function ensureUserRole_(uid, role) {
const x = rows_('GEBRUIKER_ROLLEN').find(r => r.data.USER_ID === uid && r.data.ROLE_CODE === role);
if (x) return;
const n = new Date();
append_('GEBRUIKER_ROLLEN', { ID: id_('UROL'), USER_ID: uid, ROLE_CODE: role, STATUS: 'ACTIVE', CREATED_AT: n, UPDATED_AT: n });
}
function seedRole_(code, name, desc) {
if (find_('ROLLEN', 'CODE', code)) return;
const n = new Date();
append_('ROLLEN', { ID: id_('ROLL'), CODE: code, NAAM: name, OMSCHRIJVING: desc, STATUS: 'ACTIVE', CREATED_AT: n, UPDATED_AT: n });
}
function auth_(token, allowExpired) {
token = String(token || '');
if (token.length < 16) return null;
const h = hash_('session|' + token);
const x = rows_('SESSIES').find(r => safeEqual_(r.data.TOKEN_HASH, h));
if (!x || x.data.STATUS !== 'ACTIVE' || (!allowExpired && dateMs_(x.data.EXPIRES_AT) <= Date.now())) return null;
return { userId: x.data.USER_ID, row: x.row };
}
function requireAuth_(t) { const a = auth_(t); if (!a) throw publicError_('Sessie verlopen.'); return a; }
function db_() { return SpreadsheetApp.openById(P12345.SPREADSHEET_ID); }
function ensureTab_(name, headers) {
let sh = db_().getSheetByName(name);
if (!sh) sh = db_().insertSheet(name);
if (sh.getLastRow() === 0 && headers && headers.length > 0) {
sh.getRange(1, 1, 1, headers.length).setValues([headers]);
sh.setFrozenRows(1);
sh.getRange(1, 1, 1, headers.length).setBackground('#0e6b56').setFontColor('#ffffff').setFontWeight('bold');
}
return sh;
}
function sheet_(n) { return db_().getSheetByName(n) || ensureTab_(n, P12345.TABS[n]); }
function headers_(n) {
const sh = sheet_(n);
if (sh.getLastColumn() === 0) return [];
return sh.getRange(1, 1, 1, sh.getLastColumn()).getValues()[0].map(String);
}
function rows_(n) {
const sh = sheet_(n), v = sh.getDataRange().getValues();
if (v.length < 2) return [];
const h = v[0].map(String);
return v.slice(1).map((r, i) => ({ row: i + 2, data: Object.fromEntries(h.map((k, j) => [k, r[j]])) }));
}
function table_(n) { return rows_(n).map(x => x.data); }
function append_(n, o) {
const sh = sheet_(n), h = headers_(n);
sh.appendRow(h.map(k => o[k] === undefined ? '' : o[k]));
}
function updateRow_(n, row, o) {
const sh = sheet_(n), h = headers_(n);
Object.entries(o).forEach(([k, v]) => {
let i = h.indexOf(k);
if (i >= 0) {
sh.getRange(row, i + 1).setValue(v);
} else {
sh.insertColumnAfter(sh.getLastColumn());
const newCol = sh.getLastColumn();
sh.getRange(1, newCol.toString()).setValue(k);
sh.getRange(row, newCol).setValue(v);
}
});
}
function updateBy_(n, k, v, o) {
const x = rows_(n).find(r => String(r.data[k]) === String(v));
if (x) updateRow_(n, x.row, o);
}
function find_(n, k, v) { return table_(n).find(r => String(r[k]).toLowerCase() === String(v).toLowerCase()); }
function hash_(v) {
const secret = PropertiesService.getScriptProperties().getProperty('PADELOS_SECRET') || 'PADELOS';
return Utilities.base64EncodeWebSafe(Utilities.computeHmacSha256Signature(String(v), secret)).replace(/=+$/, '');
}
function token_() { return Utilities.base64EncodeWebSafe(Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, Utilities.getUuid() + Date.now())).replace(/=+$/, ''); }
function safeEqual_(a, b) { a = String(a || ''); b = String(b || ''); if (a.length !== b.length) return false; let x = 0; for (let i = 0; i < a.length; i++) x |= a.charCodeAt(i) ^ b.charCodeAt(i); return x === 0; }
function id_(p) { return p + '-' + Utilities.getUuid().replace(/-/g, '').slice(0, 16).toUpperCase(); }
function dateMs_(v) { return v instanceof Date ? v.getTime() : new Date(v).getTime() || 0; }
function normalizeEmail_(v) { return String(v || '').trim().toLowerCase(); }
function validEmail_(v) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v); }
function clean_(v, max) { return String(v || '').trim().slice(0, max); }
function publicError_(m) { return new Error(m); }