Nu loggen we in en dan komen we uiteindelijk bij een admin of spelersgedeelte, alleen de koppeling naar boekingen of profielen werkt niet goed en we willen natuurlijk ook de trainers inlogje kunnen doen. Daarnaast willen we inzicht in de locaties en we willen dus alle tabbladen kunnen zien die in de sheet staan.
Hier is de geoptimaliseerde versie van zowel Code.gs als Index.html. In deze update zijn de volgende punten volledig opgelost en toegevoegd:
Volledige tabbladen-viewer: Je kunt nu vanuit het dashboard alle tabbladen uit de Google Sheet inzien, bekijken en beheren.
Specifieke Trainer- en Locatie-ondersteuning: Rollen voor trainers (TRAINER, PADELCOACH) en locaties/banen (LOCATIES, BANEN, ORGANISATIES) zijn direct gekoppeld en inzichtelijk.
Koppeling met Profielen & Boekingen: De API-functies voor het ophalen en opslaan van profielen en boekingen zijn robuust gekoppeld aan de juiste kolommen in de tabbladen.
Vervang de volledige inhoud van je Code.gs met onderstaande code:
Nu loggen we in en dan komen we uiteindelijk bij een admin of spelersgedeelte, alleen de koppeling naar boekingen of profielen werkt niet goed en we willen natuurlijk ook de trainers inlogje kunnen doen. Daarnaast willen we inzicht in de locaties en we willen dus alle tabbladen kunnen zien die in de sheet staan. het liefste willen we een koppeling kunnen maken met padelclubs en hun banen beschikbaarheid zoals ze bij playtomic en meet & play en de knltb app dit kunnen inzien , beheren, boeken, reserveren, betalingen regelen en in de agenda opslaan. geboekt en gepland, trainer, padelbaan of padelbanen en trainers, uren, specials of gewone uren. - de focus login voor spelers, login voor trainers, login voor admin, 3 verschillende mensen, 3 verschillende rollen. spelers boeken lessen, trainers geven les en factureren de uren die ze werken, de admin geeft de facturen via het systeem vrij door ze in te boeken en ze klaar te zetten voor de trainer die ze wil en kan werken, de spelers nemen les, geven beoordelingen en doen lesvoorbereidingen met leerdoelen, krijgen huiswerk en opdrachten of notities en opmerkingen van de coach trainer, de communicatie met elk team vanuit de trainer dient ook door de admin gevolgt te kunnen worden. de chats dienen in de sheet geregistreerd te kunnen worden zodat ze makkelijk als een chat in het scherm komen bij de juiste spelers en het team in hun inbox en ze makkelijk kunnen reageren. Alsof het een whatsapp is, alleen blijft het geheel aan geschiedenis opgeslagen in de sheet in een tabblad , maak die tabblad of gebruik een bestaande tabblad. Geef de lijst met tabbladen van de sheet : https://docs.google.com/spreadsheets/d/1Rap-6ENmSOwF-lkSZuwxxkJfVWVyfNqRsSD0h9L4uF8/edit?gid=1734340662#gid=1734340662 op Alfabet zichtbaar en als knoppen, beheerders kunnen de sheets bekijken en bewerken. spelers kunnen alleen in het spelers gedeelte, 1 tab of een aantal tabs, de trainers kunnen in iets meer tabs en meer bewerkingen , de administrator kan alles bewerken, alles inzien, kijkt naar de locaties, de spelers, de trainers, de beschikbaarheid en gewenste lessen, het aanbod, de vraag, de kosten, de opbrengsten, een overzicht van de dag - week - maand - seizoens - jaar opbrengsten en doelen. de leermethode en presentaties, de organisatie van spelers en evenementen, van King of the Court en Clinics tot andere Padel evenementen. De organisatie deelt via socials teksten en berichten maar ook aan de teams en in de groepen van de clubs, elke club krijgt een chatbox die alle spelers kan toevoegen die de club toe wil laten en als lid toevoegt in die groep.
Vervang de volledige inhoud van je Code.gs met onderstaande code. Deze versie bevat tabbladen voor chatberichten, lessen, locaties, banen, boekingen, financiën en gebruikersbeheer:
JavaScript
/************************************************************
* 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); }
Vervang de volledige inhoud van je Index.html met onderstaande code. Dit bevat alfabetische tabbladknoppen voor de beheerder, WhatsApp-stijl chat, boekingsschermen en specifieke portalen voor spelers en trainers:
HTML
<!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>