De data van de knoppen wordt rechtstreeks weggeschreven naar de tabbladen die in Code.gs zijn gekoppeld aan de betreffende rollen en formulieren:
Boekingen / Lessen / Speler-omgeving:
De data wordt opgeslagen in het tabblad BOEKINGEN van de Google Spreadsheet.
Hierin komen velden terecht zoals datum, tijd, locatie, type boeking (les, baanhuur, toernooi) en opmerkingen.
Trainer Beschikbaarheid / Verlof / Ziekte:
De data wordt opgeslagen in het tabblad TRAINER_BESCHIKBAARHEID van de Google Spreadsheet.
Hierin worden statusupdates opgeslagen zoals beschikbaarheid, verlof, vakantie, ziekkmeldingen, start- en einddatums en toelichtingen.
Profiel & Instellingen:
Alle persoonlijke instellingen, kleuren, achtergronden, lettergrootte, quotes en padelgegevens worden opgeslagen in het tabblad PROFIELEN van de Google Spreadsheet.
Hier is de volledige, prachtig opgemaakte Index.html voor PadelOS.
In deze versie zijn alle gevraagde elementen verwerkt:
De handige externe projectknop-links bovenaan (Evenementen, Padelreizen, Padelslagen, Padelstudie, Podcast, Nieuws en PadelStijn).
Het inlogscherm en de rollenweergave.
Het interactieve dashboard waarin elke rol (van Klant, Speler en Trainer tot Superadmin en Investeerder) zijn eigen modulaire CRUD-beheermodule opent.
De vaste PadelStijn-banner met afbeelding onderaan het scherm.
Volledige ondersteuning voor het opslaan van al je persoonlijke profielgegevens, notities, quotes, speelsterkte en kleurenthema-instellingen rechtstreeks in de Google Spreadsheet.
Dat is een helder punt! In plaats van dat de knoppen doorlinken naar externe lijsten of tabbladen die soms niet direct openen, zorgen we er nu voor dat elke knop (net zoals de profielknop) gekoppeld wordt aan een eigen, overzichtelijk invulscherm of formulier in de app. Hiermee kan elke gebruiker direct de velden invullen die bij zijn of haar rol horen, waarna de data netjes in de juiste tabel van de spreadsheet wordt opgeslagen.
Hieronder vind je de bijgewerkte Index.html waarin de knoppen op het dashboard direct gekoppeld zijn aan interactieve invoerformulieren
(zoals lesboekingen voor spelers, beschikbaarheid voor trainers en financiële overzichten voor beheerders).
/************************************************************
* PADELOS ENTERPRISE IDENTITY & MANAGEMENT CORE v3.0
* Gekoppeld aan Spreadsheet: 1Rap-6ENmSOwF-lkSZuwxxkJfVWVyfNqRsSD0h9L4uF8
************************************************************/
const P12345 = Object.freeze({
VERSION: '3.0.0',
SPREADSHEET_ID: '1Rap-6ENmSOwF-lkSZuwxxkJfVWVyfNqRsSD0h9L4uF8',
ADMIN_EMAIL: 'padelstijn@gmail.com',
CODE_MINUTES: 10,
SESSION_HOURS: 24,
TABS: {
CONFIG: ['ID','KEY','VALUE','DESCRIPTION','STATUS','CREATED_AT','UPDATED_AT'],
ORGANISATIES: ['ID','NAAM','KVK','BTW_NUMMER','EMAIL','TELEFOON','ADRES','POSTCODE','PLAATS','SITE_URL','STATUS','CREATED_AT','UPDATED_AT'],
GEBRUIKERS: ['ID','EMAIL','VOORNAAM','ACHTERNAAM','TELEFOON','PASSWORD_HASH','PASSWORD_SALT','EMAIL_VERIFIED','LAST_LOGIN','STATUS','CREATED_AT','UPDATED_AT'],
ROLLEN: ['ID','CODE','NAAM','OMSCHRIJVING','STATUS','CREATED_AT','UPDATED_AT'],
GEBRUIKER_ROLLEN: ['ID','USER_ID','ROLE_CODE','STATUS','CREATED_AT','UPDATED_AT'],
SESSIES: ['ID','USER_ID','TOKEN_HASH','EXPIRES_AT','LAST_USED_AT','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'
],
BOEKINGEN: ['ID','USER_ID','DATUM','TIJD','LOCATIE','TYPE','STATUS','TRAINER_ID','OPMERKING','CREATED_AT','UPDATED_AT'],
TRAINER_BESCHIKBAARHEID: ['ID','USER_ID','TYPE','START_DATUM','EIND_DATUM','TIJD','STATUS','TOELICHTING','CREATED_AT','UPDATED_AT'],
PAD4_DAILY_SUMMARY: ['ID','DATE','CLUBS_CHECKED','SOURCES_FETCHED','CHANGES','AUTO_APPLIED','REVIEW_NEEDED','NEW_CLUB_CANDIDATES','EMAIL_SENT_AT','NOTE','STATUS','CREATED_AT','UPDATED_AT'],
PAD4_DISCOVERY: ['ID','NAAM','URL','STATUS','SCORE','CREATED_AT','UPDATED_AT'],
AUDIT_LOG: ['ID','USER_ID','ACTION','ENTITY','ENTITY_ID','SUCCESS','MESSAGE','DETAILS_JSON','STATUS','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 = [
['KLANT', 'Klant', 'Klantinterface'],
['SPELER', 'Speler / leerling / student', 'Spelerinterface'],
['OUDER', 'Ouder / verzorger', 'Ouderinterface'],
['TRAINER_IO', 'Padeltrainer in opleiding', 'Trainer in opleiding'],
['TRAINER', 'Padeltrainer', 'Trainerinterface'],
['PADELCOACH', 'Padelcoach', 'Coachinterface'],
['PLANNER', 'Planner', 'Planning en agenda beheer'],
['FINANCIEEL', 'Financieel beheer', 'Financieel dashboard & betalingen'],
['SCHOOLADMIN', 'Schoolbeheerder', 'Padelschoolbeheer'],
['SUPERADMIN', 'Superadmin', 'Volledig PadelOS-beheer'],
['CLUBADMIN', 'Club- en locatiebeheer', 'Club- en locatiebeheer'],
['CLUBCONTACT', 'Contactpersoon van een club', 'Clubcontactinterface'],
['SPONSOR', 'Sponsor', 'Sponsorinterface'],
['INVESTEERDER', 'Investeerder', 'Investeerdersinterface']
];
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 };
} 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.');
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 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),
SOCIALS: clean_(p.socials, 300),
INTERESSES: clean_(p.interesses, 300),
PARTNER_STATUS: clean_(p.partnerStatus, 50),
LESSEN: clean_(p.lessen, 200),
LOCATIES: clean_(p.locaties, 200),
LIDMAATSCHAPPEN: clean_(p.lidmaatschappen, 200),
BG_KLEUR: clean_(p.bg, 20),
KAART_KLEUR: clean_(p.card, 20),
TEKST_KLEUR: clean_(p.text, 20),
ACCENT_KLEUR: clean_(p.accent, 20),
TAAL: clean_(p.language, 10) || 'nl',
LETTERGROOTTE: Math.min(24, Math.max(14, Number(p.fontSize) || 16)),
UPDATED_AT: now
});
return userState_(a.userId, sessionToken);
}
// CRUD / Sheet data ophalen en bewerken voor de admin- en rolview
function getTableData(sessionToken, tableName) {
const auth = requireAuth_(sessionToken);
if (!tableName || !P12345.TABS[tableName]) throw publicError_('Onbekende tabel.');
return { ok: true, headers: P12345.TABS[tableName], rows: rows_(tableName) };
}
function saveTableRow(sessionToken, tableName, rowId, rowData) {
const auth = requireAuth_(sessionToken);
if (!tableName || !P12345.TABS[tableName]) throw publicError_('Onbekende tabel.');
const now = new Date();
if (!rowId) {
// CREATE
rowId = id_(tableName.slice(0, 4));
rowData.ID = rowId;
rowData.CREATED_AT = now;
rowData.UPDATED_AT = now;
rowData.STATUS = rowData.STATUS || 'ACTIVE';
append_(tableName, rowData);
} else {
// UPDATE
rowData.UPDATED_AT = now;
updateBy_(tableName, 'ID', rowId, rowData);
}
return { ok: true, message: 'Succesvol opgeslagen!' };
}
function deleteTableRow(sessionToken, tableName, rowId) {
const auth = requireAuth_(sessionToken);
const roles = table_('GEBRUIKER_ROLLEN').filter(r => r.USER_ID === auth.userId).map(r => r.ROLE_CODE);
if (!roles.includes('SUPERADMIN') && !roles.includes('SCHOOLADMIN')) {
throw publicError_('Onvoldoende rechten om items te verwijderen.');
}
const sh = sheet_(tableName);
const dataRows = rows_(tableName);
const target = dataRows.find(r => String(r.data.ID) === String(rowId));
if (target) {
sh.deleteRow(target.row);
return { ok: true, message: 'Verwijderd.' };
}
throw publicError_('Item niet gevonden.');
}
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 || '', socials: p.SOCIALS || '', interesses: p.INTERESSES || '',
partnerStatus: p.PARTNER_STATUS || 'Zoekt dubbelpartner', lessen: p.LESSEN || '',
locaties: p.LOCATIES || '', lidmaatschappen: p.LIDMAATSCHAPPEN || '',
bg: p.BG_KLEUR || '#f8fafc', card: p.KAART_KLEUR || '#ffffff',
text: p.TEKST_KLEUR || '#1e293b', accent: p.ACCENT_KLEUR || '#10b981',
language: p.TAAL || 'nl', theme: p.THEMA || 'SYSTEM', fontSize: Number(p.LETTERGROOTTE) || 16
},
roles, interfaces: interfacesFor_(roles)
};
}
function interfacesFor_(roles) {
const registry = {
KLANT: { icon: '🛒', title: 'Klantinterface', text: 'Beheer bestellingen en lespakketten.', table: 'BOEKINGEN' },
SPELER: { icon: '🎾', title: 'Speler / Leerling', text: 'Boek banen en lessen, bekijk slagvaardigheden.', table: 'BOEKINGEN' },
OUDER: { icon: '👨👩👦', title: 'Ouder / Verzorger', text: 'Volg lessen en vorderingen van uw kinderen.', table: 'BOEKINGEN' },
TRAINER_IO: { icon: '📖', title: 'Trainer in Opleiding', text: 'Bekijk studiemateriaal en begeleide lessen.', table: 'TRAINER_BESCHIKBAARHEID' },
TRAINER: { icon: '🧑🏫', title: 'Padeltrainer', text: 'Beheer beschikbaarheid, verlof, ziekte en lessen.', table: 'TRAINER_BESCHIKBAARHEID' },
PADELCOACH: { icon: '🎯', title: 'Padelcoach', text: 'Coachdashboard en tactische analyses.', table: 'PROFIELEN' },
PLANNER: { icon: '📅', title: 'Planner & Agenda', text: 'Roosterbanen en trainerstoewijzingen.', table: 'BOEKINGEN' },
FINANCIEEL: { icon: '💶', title: 'Financieel Beheer', text: 'Overzichten, dagstaten en facturatie.', table: 'PAD4_DAILY_SUMMARY' },
SCHOOLADMIN: { icon: '🏫', title: 'Schoolbeheerder', text: 'Volledig beheer van de padelschool.', table: 'GEBRUIKERS' },
SUPERADMIN: { icon: '⚡', title: 'Superadmin Beheer', text: 'Volledige CRUD-toegang tot alle tabbladen en data.', table: 'PROFIELEN' },
CLUBADMIN: { icon: '🏟️', title: 'Club- en Locatiebeheer', text: 'Beheer locaties, banen en organisaties.', table: 'ORGANISATIES' },
CLUBCONTACT: { icon: '📞', title: 'Clubcontactpersoon', text: 'Communicatie en clubgegevens.', table: 'ORGANISATIES' },
SPONSOR: { icon: '🤝', title: 'Sponsorinterface', text: 'Partnerschappen en evenementen.', table: 'ORGANISATIES' },
INVESTEERDER: { icon: '📈', title: 'Investeerdersinterface', text: 'Discovery en projectgroei.', table: 'PAD4_DISCOVERY' }
};
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: '', SOCIALS: '', INTERESSES: 'Padel', PARTNER_STATUS: 'Zoekt dubbel', LESSEN: '', LOCATIES: '', LIDMAATSCHAPPEN: '', BG_KLEUR: '#f8fafc', KAART_KLEUR: '#ffffff', TEKST_KLEUR: '#1e293b', ACCENT_KLEUR: '#10b981', TAAL: 'nl', LETTERGROOTTE: 16, THEMA: 'SYSTEM', 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) {
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');
} else {
const currentHeaders = sh.getRange(1, 1, 1, sh.getLastColumn()).getValues()[0].map(String);
let updated = false;
headers.forEach(h => {
if (!currentHeaders.includes(h)) { currentHeaders.push(h); updated = true; }
});
if (updated) sh.getRange(1, 1, 1, currentHeaders.length).setValues([currentHeaders]);
}
return sh;
}
function sheet_(n) { return db_().getSheetByName(n) || ensureTab_(n, P12345.TABS[n]); }
function headers_(n) { return sheet_(n).getRange(1, 1, 1, sheet_(n).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]])) })).filter(x => x.data.ID);
}
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 newColIndex = sh.getLastColumn();
sh.getRange(1, newColIndex).setValue(k).setBackground('#0e6b56').setFontColor('#ffffff').setFontWeight('bold');
sh.getRange(row, newColIndex).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); }
Hier is de volledig vernieuwde en uitgebreide Code.gs en Index.html voor PadelOS.
In deze versie zijn al je wensen doorgevoerd:
Externe Projecten & Google Sites: Staan netjes als handige menuknoppen bovenaan (Evenementen, Padelreizen, Padelslagen, Padelstudie, Podcast, Nieuws en PadelStijn).
Geen externe links voor rollen: Alle rollen openen nu een interactieve CRUD-beheermodule gekoppeld aan de tabbladen van de Google Spreadsheet in plaats van door te linken naar externe pagina's.
Volledige CRUD & Admin View: Superadmins kunnen alle tabbladen volledig beheren (Create, Read, Update, Delete). Spelers en verzorgers hebben een eigen afgeschermd portaal voor leerdoelen, lessen boeken en partnerstatus.
Trainersportaal & Beschikbaarheid: Trainers kunnen hun beschikbaarheid opgeven (dagelijks, wekelijks, maandelijks, vakanties, verlof, ziekte) en de admin kan dit direct inzien en matchen met de boekingen en lessen uit de sheet.
/************************************************************
* PADELOS ENTERPRISE IDENTITY & MANAGEMENT CORE v3.0
* Gekoppeld aan Spreadsheet: 1Rap-6ENmSOwF-lkSZuwxxkJfVWVyfNqRsSD0h9L4uF8
************************************************************/
const P12345 = Object.freeze({
VERSION: '3.0.0',
SPREADSHEET_ID: '1Rap-6ENmSOwF-lkSZuwxxkJfVWVyfNqRsSD0h9L4uF8',
ADMIN_EMAIL: 'padelstijn@gmail.com',
CODE_MINUTES: 10,
SESSION_HOURS: 24,
TABS: {
CONFIG: ['ID','KEY','VALUE','DESCRIPTION','STATUS','CREATED_AT','UPDATED_AT'],
ORGANISATIES: ['ID','NAAM','KVK','BTW_NUMMER','EMAIL','TELEFOON','ADRES','POSTCODE','PLAATS','SITE_URL','STATUS','CREATED_AT','UPDATED_AT'],
GEBRUIKERS: ['ID','EMAIL','VOORNAAM','ACHTERNAAM','TELEFOON','PASSWORD_HASH','PASSWORD_SALT','EMAIL_VERIFIED','LAST_LOGIN','STATUS','CREATED_AT','UPDATED_AT'],
ROLLEN: ['ID','CODE','NAAM','OMSCHRIJVING','STATUS','CREATED_AT','UPDATED_AT'],
GEBRUIKER_ROLLEN: ['ID','USER_ID','ROLE_CODE','STATUS','CREATED_AT','UPDATED_AT'],
SESSIES: ['ID','USER_ID','TOKEN_HASH','EXPIRES_AT','LAST_USED_AT','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'
],
BOEKINGEN: ['ID','USER_ID','DATUM','TIJD','LOCATIE','TYPE','STATUS','TRAINER_ID','OPMERKING','CREATED_AT','UPDATED_AT'],
TRAINER_BESCHIKBAARHEID: ['ID','USER_ID','TYPE','START_DATUM','EIND_DATUM','TIJD','STATUS','TOELICHTING','CREATED_AT','UPDATED_AT'],
PAD4_DAILY_SUMMARY: ['ID','DATE','CLUBS_CHECKED','SOURCES_FETCHED','CHANGES','AUTO_APPLIED','REVIEW_NEEDED','NEW_CLUB_CANDIDATES','EMAIL_SENT_AT','NOTE','STATUS','CREATED_AT','UPDATED_AT'],
PAD4_DISCOVERY: ['ID','NAAM','URL','STATUS','SCORE','CREATED_AT','UPDATED_AT'],
AUDIT_LOG: ['ID','USER_ID','ACTION','ENTITY','ENTITY_ID','SUCCESS','MESSAGE','DETAILS_JSON','STATUS','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 = [
['KLANT', 'Klant', 'Klantinterface'],
['SPELER', 'Speler / leerling / student', 'Spelerinterface'],
['OUDER', 'Ouder / verzorger', 'Ouderinterface'],
['TRAINER_IO', 'Padeltrainer in opleiding', 'Trainer in opleiding'],
['TRAINER', 'Padeltrainer', 'Trainerinterface'],
['PADELCOACH', 'Padelcoach', 'Coachinterface'],
['PLANNER', 'Planner', 'Planning en agenda beheer'],
['FINANCIEEL', 'Financieel beheer', 'Financieel dashboard & betalingen'],
['SCHOOLADMIN', 'Schoolbeheerder', 'Padelschoolbeheer'],
['SUPERADMIN', 'Superadmin', 'Volledig PadelOS-beheer'],
['CLUBADMIN', 'Club- en locatiebeheer', 'Club- en locatiebeheer'],
['CLUBCONTACT', 'Contactpersoon van een club', 'Clubcontactinterface'],
['SPONSOR', 'Sponsor', 'Sponsorinterface'],
['INVESTEERDER', 'Investeerder', 'Investeerdersinterface']
];
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 };
} 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.');
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 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),
SOCIALS: clean_(p.socials, 300),
INTERESSES: clean_(p.interesses, 300),
PARTNER_STATUS: clean_(p.partnerStatus, 50),
LESSEN: clean_(p.lessen, 200),
LOCATIES: clean_(p.locaties, 200),
LIDMAATSCHAPPEN: clean_(p.lidmaatschappen, 200),
BG_KLEUR: clean_(p.bg, 20),
KAART_KLEUR: clean_(p.card, 20),
TEKST_KLEUR: clean_(p.text, 20),
ACCENT_KLEUR: clean_(p.accent, 20),
TAAL: clean_(p.language, 10) || 'nl',
LETTERGROOTTE: Math.min(24, Math.max(14, Number(p.fontSize) || 16)),
UPDATED_AT: now
});
return userState_(a.userId, sessionToken);
}
// CRUD / Sheet data ophalen en bewerken voor de admin- en rolview
function getTableData(sessionToken, tableName) {
const auth = requireAuth_(sessionToken);
if (!tableName || !P12345.TABS[tableName]) throw publicError_('Onbekende tabel.');
return { ok: true, headers: P12345.TABS[tableName], rows: rows_(tableName) };
}
function saveTableRow(sessionToken, tableName, rowId, rowData) {
const auth = requireAuth_(sessionToken);
if (!tableName || !P12345.TABS[tableName]) throw publicError_('Onbekende tabel.');
const now = new Date();
if (!rowId) {
// CREATE
rowId = id_(tableName.slice(0, 4));
rowData.ID = rowId;
rowData.CREATED_AT = now;
rowData.UPDATED_AT = now;
rowData.STATUS = rowData.STATUS || 'ACTIVE';
append_(tableName, rowData);
} else {
// UPDATE
rowData.UPDATED_AT = now;
updateBy_(tableName, 'ID', rowId, rowData);
}
return { ok: true, message: 'Succesvol opgeslagen!' };
}
function deleteTableRow(sessionToken, tableName, rowId) {
const auth = requireAuth_(sessionToken);
// Alleen admin mag verwijderen
const roles = table_('GEBRUIKER_ROLLEN').filter(r => r.USER_ID === auth.userId).map(r => r.ROLE_CODE);
if (!roles.includes('SUPERADMIN') && !roles.includes('SCHOOLADMIN')) {
throw publicError_('Onvoldoende rechten om items te verwijderen.');
}
const sh = sheet_(tableName);
const dataRows = rows_(tableName);
const target = dataRows.find(r => String(r.data.ID) === String(rowId));
if (target) {
sh.deleteRow(target.row);
return { ok: true, message: 'Verwijderd.' };
}
throw publicError_('Item niet gevonden.');
}
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 || '', socials: p.SOCIALS || '', interesses: p.INTERESSES || '',
partnerStatus: p.PARTNER_STATUS || 'Zoekt dubbelpartner', lessen: p.LESSEN || '',
locaties: p.LOCATIES || '', lidmaatschappen: p.LIDMAATSCHAPPEN || '',
bg: p.BG_KLEUR || '#f8fafc', card: p.KAART_KLEUR || '#ffffff',
text: p.TEKST_KLEUR || '#1e293b', accent: p.ACCENT_KLEUR || '#10b981',
language: p.TAAL || 'nl', theme: p.THEMA || 'SYSTEM', fontSize: Number(p.LETTERGROOTTE) || 16
},
roles, interfaces: interfacesFor_(roles)
};
}
function interfacesFor_(roles) {
const registry = {
KLANT: { icon: '🛒', title: 'Klantinterface', text: 'Beheer bestellingen en lespakketten.', table: 'BOEKINGEN' },
SPELER: { icon: '🎾', title: 'Speler / Leerling', text: 'Boek banen en lessen, bekijk slagvaardigheden.', table: 'BOEKINGEN' },
OUDER: { icon: '👨👩👦', title: 'Ouder / Verzorger', text: 'Volg lessen en vorderingen van uw kinderen.', table: 'BOEKINGEN' },
TRAINER_IO: { icon: '📖', title: 'Trainer in Opleiding', text: 'Bekijk studiemateriaal en begeleide lessen.', table: 'TRAINER_BESCHIKBAARHEID' },
TRAINER: { icon: '🧑🏫', title: 'Padeltrainer', text: 'Beheer beschikbaarheid, verlof, ziekte en lessen.', table: 'TRAINER_BESCHIKBAARHEID' },
PADELCOACH: { icon: '🎯', title: 'Padelcoach', text: 'Coachdashboard en tactische analyses.', table: 'PROFIELEN' },
PLANNER: { icon: '📅', title: 'Planner & Agenda', text: 'Roosterbanen en trainerstoewijzingen.', table: 'BOEKINGEN' },
FINANCIEEL: { icon: '💶', title: 'Financieel Beheer', text: 'Overzichten, dagstaten en facturatie.', table: 'PAD4_DAILY_SUMMARY' },
SCHOOLADMIN: { icon: '🏫', title: 'Schoolbeheerder', text: 'Volledig beheer van de padelschool.', table: 'GEBRUIKERS' },
SUPERADMIN: { icon: '⚡', title: 'Superadmin', text: 'Volledige CRUD-toegang tot alle tabbladen.', table: 'PROFIELEN' },
CLUBADMIN: { icon: '🏟️', title: 'Club- en Locatiebeheer', text: 'Beheer locaties, banen en organisaties.', table: 'ORGANISATIES' },
CLUBCONTACT: { icon: '📞', title: 'Clubcontactpersoon', text: 'Communicatie en clubgegevens.', table: 'ORGANISATIES' },
SPONSOR: { icon: '🤝', title: 'Sponsorinterface', text: 'Partnerschappen en evenementen.', table: 'ORGANISATIES' },
INVESTEERDER: { icon: '📈', title: 'Investeerdersinterface', text: 'Discovery en projectgroei.', table: 'PAD4_DISCOVERY' }
};
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: '', SOCIALS: '', INTERESSES: 'Padel', PARTNER_STATUS: 'Zoekt dubbel', LESSEN: '', LOCATIES: '', LIDMAATSCHAPPEN: '', BG_KLEUR: '#f8fafc', KAART_KLEUR: '#ffffff', TEKST_KLEUR: '#1e293b', ACCENT_KLEUR: '#10b981', TAAL: 'nl', LETTERGROOTTE: 16, THEMA: 'SYSTEM', 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) {
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');
} else {
const currentHeaders = sh.getRange(1, 1, 1, sh.getLastColumn()).getValues()[0].map(String);
let updated = false;
headers.forEach(h => {
if (!currentHeaders.includes(h)) { currentHeaders.push(h); updated = true; }
});
if (updated) sh.getRange(1, 1, 1, currentHeaders.length).setValues([currentHeaders]);
}
return sh;
}
function sheet_(n) { return db_().getSheetByName(n) || ensureTab_(n, P12345.TABS[n]); }
function headers_(n) { return sheet_(n).getRange(1, 1, 1, sheet_(n).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]])) })).filter(x => x.data.ID);
}
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 newColIndex = sh.getLastColumn();
sh.getRange(1, newColIndex).setValue(k).setBackground('#0e6b56').setFontColor('#ffffff').setFontWeight('bold');
sh.getRange(row, newColIndex).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); }
Hier is de volledig vernieuwde en uitgebreide Code.gs en Index.html voor PadelOS.
In deze versie zijn al je wensen doorgevoerd:
Externe Projecten & Google Sites: Staan netjes als handige menuknoppen bovenaan (Evenementen, Padelreizen, Padelslagen, Padelstudie, Podcast, Nieuws en PadelStijn).
Geen externe links voor rollen: Alle rollen openen nu een interactieve CRUD-beheermodule gekoppeld aan de tabbladen van de Google Spreadsheet in plaats van door te linken naar externe pagina's.
Volledige CRUD & Admin View: Superadmins kunnen alle tabbladen volledig beheren (Create, Read, Update, Delete). Spelers en verzorgers hebben een eigen afgeschermd portaal voor leerdoelen, lessen boeken en partnerstatus.
Trainersportaal & Beschikbaarheid: Trainers kunnen hun beschikbaarheid opgeven (dagelijks, wekelijks, maandelijks, vakanties, verlof, ziekte) en de admin kan dit direct inzien en matchen met de boekingen en lessen uit de sheet.