/* ============================================================ MESTAFF – SHIFT PLANNING SERVICE Plánované směny jsou oddělené od skutečné docházky. Výchozí pravidla: NE–ČT 18:00–23:00, kapacita 1 PÁ–SO 18:00–02:00, kapacita 1 20:00–00:00, kapacita 1 Automatické vytvoření dalšího měsíce: každý 10. den předchozího měsíce kolem 17:00 ============================================================ */ const SHIFT_PLAN_SHEETS = { SLOTS: 'SHIFT_SLOTS', SIGNUPS: 'SHIFT_SIGNUPS' }; /* ============================================================ JEDNORÁZOVÝ SETUP ============================================================ */ function setupShiftPlanning() { ensureShiftPlanningSheet_( SHIFT_PLAN_SHEETS.SLOTS, [ 'slot_id', 'date', 'start_time', 'end_time', 'capacity', 'location_id', 'status', 'note', 'generated', 'created_at', 'updated_at' ] ); ensureShiftPlanningSheet_( SHIFT_PLAN_SHEETS.SIGNUPS, [ 'signup_id', 'slot_id', 'employee_id', 'status', 'created_at', 'cancelled_at', 'cancelled_by' ] ); createShiftPlanningTrigger_(); Logger.log('Shift planning setup OK'); } /* ============================================================ AUTOMATICKÝ TRIGGER Apps Script měsíční trigger neumí garantovat přesně 17:00:00. atHour(17) znamená spuštění v hodinovém okně 17:00–18:00. ============================================================ */ function createShiftPlanningTrigger_() { const handler = 'generateNextMonthShiftSlots'; ScriptApp .getProjectTriggers() .filter( function(trigger) { return ( trigger.getHandlerFunction() === handler ); } ) .forEach( function(trigger) { ScriptApp.deleteTrigger( trigger ); } ); ScriptApp .newTrigger( handler ) .timeBased() .onMonthDay( 10 ) .atHour( 17 ) .create(); } /* ============================================================ GENERÁTOR DALŠÍHO MĚSÍCE ============================================================ */ function generateNextMonthShiftSlots() { const now = new Date(); let year = now.getFullYear(); let month = now.getMonth() + 1; if ( month > 11 ) { month = 0; year++; } return generateShiftSlotsForMonth_( year, month ); } /* ============================================================ RUČNÝ GENERÁTOR PRO ADMINA monthNumber = 1–12 ============================================================ */ function adminGenerateShiftMonth( token, year, monthNumber ) { requireUser_( token, [ 'ADMIN', 'MANAGER' ] ); year = Number( year ); monthNumber = Number( monthNumber ); if ( !year || monthNumber < 1 || monthNumber > 12 ) { throw new Error( 'Neplatný měsíc.' ); } return generateShiftSlotsForMonth_( year, monthNumber - 1 ); } /* ============================================================ VLASTNÍ GENERÁTOR monthIndex = JS měsíc 0–11 ============================================================ */ function generateShiftSlotsForMonth_( year, monthIndex ) { const slots = displayRows_( SHIFT_PLAN_SHEETS.SLOTS ); const existingKeys = {}; slots.forEach( function(slot) { existingKeys[ shiftSlotKey_( slot.date, slot.start_time, slot.end_time, slot.location_id ) ] = true; } ); const defaultLocation = getDefaultPlanningLocation_(); const days = new Date( year, monthIndex + 1, 0 ).getDate(); let created = 0; for ( let day = 1; day <= days; day++ ) { const date = new Date( year, monthIndex, day ); const dow = date.getDay(); const dateText = Utilities.formatDate( date, CFG.TZ, 'yyyy-MM-dd' ); let templates = []; /* * neděle = 0 * pondělí = 1 * ... * čtvrtek = 4 */ if ( dow === 0 || ( dow >= 1 && dow <= 4 ) ) { templates = [ { start:'18:00', end:'23:00', capacity:1 } ]; } /* * pátek + sobota */ if ( dow === 5 || dow === 6 ) { templates = [ { start:'18:00', end:'02:00', capacity:1 }, { start:'20:00', end:'00:00', capacity:1 } ]; } templates.forEach( function(template) { const key = shiftSlotKey_( dateText, template.start, template.end, defaultLocation ); /* * idempotence: * při opakovaném spuštění nevznikne duplicita */ if ( existingKeys[ key ] ) { return; } append_( SHIFT_PLAN_SHEETS.SLOTS, { slot_id: uuid_( 'SLOT' ), date: dateText, start_time: template.start, end_time: template.end, capacity: template.capacity, location_id: defaultLocation, status: 'OPEN_FOR_SIGNUP', note: '', generated: true, created_at: now_(), updated_at: now_() } ); existingKeys[ key ] = true; created++; } ); } return { ok:true, year: year, month: monthIndex + 1, created: created }; } /* ============================================================ ADMIN – MĚSÍČNÍ PLÁN ============================================================ */ function getAdminShiftPlan( token, period ) { requireUser_( token, [ 'ADMIN', 'MANAGER' ] ); period = normalizePlanningPeriod_( period ); const slots = displayRows_( SHIFT_PLAN_SHEETS.SLOTS ); const signups = displayRows_( SHIFT_PLAN_SHEETS.SIGNUPS ); const employees = displayRows_( CFG.SHEETS.EMPLOYEES ); const employeeMap = {}; employees.forEach( function(employee) { employeeMap[ String( employee.employee_id ) ] = employee; } ); const signupsBySlot = buildApprovedSignupsBySlot_( signups ); return slots .filter( function(slot) { return ( String( slot.date || '' ).substring( 0, 7 ) === period ); } ) .map( function(slot) { const slotSignups = signupsBySlot[ String( slot.slot_id ) ] || []; return { slot_id: String( slot.slot_id || '' ), date: String( slot.date || '' ), start_time: String( slot.start_time || '' ), end_time: String( slot.end_time || '' ), capacity: planningNumber_( slot.capacity ), location_id: String( slot.location_id || '' ), status: String( slot.status || '' ), note: String( slot.note || '' ), occupied: slotSignups.length, employees: slotSignups.map( function(signup) { const employee = employeeMap[ String( signup.employee_id ) ]; return { employee_id: String( signup.employee_id || '' ), name: employee ? ( String( employee.first_name || '' ) + ' ' + String( employee.last_name || '' ) ).trim() : String( signup.employee_id || '' ) }; } ) }; } ) .sort( shiftSlotSort_ ); } /* ============================================================ ADMIN – ÚPRAVA KONKRÉTNÍ SMĚNY ============================================================ */ function updateShiftSlot( token, slotId, data ) { const user = requireUser_( token, [ 'ADMIN', 'MANAGER' ] ); const slots = displayRows_( SHIFT_PLAN_SHEETS.SLOTS ); const slot = slots.find( function(row) { return ( String( row.slot_id ) === String( slotId ) ); } ); if ( !slot ) { throw new Error( 'Směna nebyla nalezena.' ); } const capacity = Number( data.capacity ); if ( !Number.isInteger( capacity ) || capacity < 0 ) { throw new Error( 'Kapacita musí být celé číslo 0 nebo vyšší.' ); } const signups = getApprovedSlotSignups_( slotId ); if ( capacity < signups.length ) { throw new Error( 'Kapacitu nelze snížit pod počet již přihlášených zaměstnanců.' ); } const status = capacity === 0 ? 'CLOSED' : ( signups.length >= capacity ? 'FULL' : 'OPEN_FOR_SIGNUP' ); updateBy_( SHIFT_PLAN_SHEETS.SLOTS, 'slot_id', slotId, { date: String( data.date || slot.date ), start_time: normalizePlanningTime_( data.start_time ), end_time: normalizePlanningTime_( data.end_time ), capacity: capacity, status: status, note: String( data.note || '' ), updated_at: now_() } ); audit_( user.user_id, 'SHIFT_SLOT_UPDATED', 'SHIFT_SLOT', slotId, JSON.stringify( slot ), JSON.stringify( data ) ); return { ok:true }; } /* ============================================================ ADMIN – PŘIDÁNÍ MIMOŘÁDNÉ SMĚNY ============================================================ */ function createShiftSlot( token, data ) { const user = requireUser_( token, [ 'ADMIN', 'MANAGER' ] ); const date = String( data.date || '' ); if ( !/^\d{4}-\d{2}-\d{2}$/.test( date ) ) { throw new Error( 'Neplatné datum.' ); } const startTime = normalizePlanningTime_( data.start_time ); const endTime = normalizePlanningTime_( data.end_time ); const capacity = Number( data.capacity ); if ( !Number.isInteger( capacity ) || capacity < 1 ) { throw new Error( 'Kapacita musí být alespoň 1.' ); } const locationId = String( data.location_id || getDefaultPlanningLocation_() ); const existing = displayRows_( SHIFT_PLAN_SHEETS.SLOTS ) .find( function(slot) { return ( shiftSlotKey_( slot.date, slot.start_time, slot.end_time, slot.location_id ) === shiftSlotKey_( date, startTime, endTime, locationId ) ); } ); if ( existing ) { throw new Error( 'Stejná směna už existuje.' ); } const slotId = uuid_( 'SLOT' ); append_( SHIFT_PLAN_SHEETS.SLOTS, { slot_id: slotId, date: date, start_time: startTime, end_time: endTime, capacity: capacity, location_id: locationId, status: 'OPEN_FOR_SIGNUP', note: String( data.note || '' ), generated: false, created_at: now_(), updated_at: now_() } ); audit_( user.user_id, 'SHIFT_SLOT_CREATED', 'SHIFT_SLOT', slotId, '', JSON.stringify( data ) ); return { ok:true, slot_id: slotId }; } /* ============================================================ ADMIN – RUČNÍ PŘIDÁNÍ ČLOVĚKA NA SMĚNU ============================================================ */ function adminAssignEmployeeToSlot( token, slotId, employeeId ) { const user = requireUser_( token, [ 'ADMIN', 'MANAGER' ] ); return signupEmployeeToSlot_( slotId, employeeId, user.user_id ); } /* ============================================================ ADMIN – ODEBRÁNÍ ČLOVĚKA ZE SMĚNY ============================================================ */ function adminRemoveEmployeeFromSlot( token, slotId, employeeId ) { const user = requireUser_( token, [ 'ADMIN', 'MANAGER' ] ); const signups = displayRows_( SHIFT_PLAN_SHEETS.SIGNUPS ); const signup = signups.find( function(row) { return ( String( row.slot_id ) === String( slotId ) && String( row.employee_id ) === String( employeeId ) && String( row.status ) === 'APPROVED' ); } ); if ( !signup ) { throw new Error( 'Přihlášení na směnu nebylo nalezeno.' ); } updateBy_( SHIFT_PLAN_SHEETS.SIGNUPS, 'signup_id', signup.signup_id, { status: 'CANCELLED', cancelled_at: now_(), cancelled_by: user.user_id } ); refreshSlotStatus_( slotId ); audit_( user.user_id, 'SHIFT_SIGNUP_REMOVED', 'SHIFT_SLOT', slotId, employeeId, '' ); return { ok:true }; } /* ============================================================ RYCHLÝ BUNDLE PRO ZAMĚSTNANECKÉ SMĚNY Jeden request + jedno čtení SHIFT_SLOTS + jedno čtení SHIFT_SIGNUPS. ============================================================ */ function getEmployeeShiftPlanningBundle( token, period ) { const user = requireUser_( token, [ 'EMPLOYEE', 'MANAGER', 'ADMIN' ] ); if ( !user.employee_id ) { throw new Error( 'Účet není propojen se zaměstnancem.' ); } period = normalizePlanningPeriod_( period ); const slots = displayRows_( SHIFT_PLAN_SHEETS.SLOTS ); const signups = displayRows_( SHIFT_PLAN_SHEETS.SIGNUPS ); return { available: buildAvailableShiftSlotsFromRows_( user.employee_id, period, slots, signups ), mine: buildMyPlannedShiftsFromRows_( user.employee_id, period, slots, signups ) }; } /* ============================================================ INDEX PŘIHLÁŠEK PODLE SLOTU ============================================================ */ function buildApprovedSignupsBySlot_( signups ) { const result = {}; signups.forEach( function(signup) { if ( String( signup.status ) !== 'APPROVED' ) { return; } const slotId = String( signup.slot_id || '' ); if ( !slotId ) { return; } if ( !result[ slotId ] ) { result[ slotId ] = []; } result[ slotId ].push( signup ); } ); return result; } /* ============================================================ VOLNÉ SMĚNY Z JIŽ NAČTENÝCH ŘÁDKŮ ============================================================ */ function buildAvailableShiftSlotsFromRows_( employeeId, period, slots, signups ) { const employeeKey = String( employeeId ); const signupsBySlot = buildApprovedSignupsBySlot_( signups ); const mySlotMap = {}; signups.forEach( function(signup) { if ( String( signup.employee_id ) === employeeKey && String( signup.status ) === 'APPROVED' ) { mySlotMap[ String( signup.slot_id ) ] = true; } } ); const result = []; slots.forEach( function(slot) { if ( String( slot.date || '' ).substring( 0, 7 ) !== period ) { return; } if ( String( slot.status ) !== 'OPEN_FOR_SIGNUP' ) { return; } const slotId = String( slot.slot_id || '' ); if ( mySlotMap[ slotId ] ) { return; } const occupied = ( signupsBySlot[ slotId ] || [] ).length; const capacity = planningNumber_( slot.capacity ); const freePlaces = Math.max( 0, capacity - occupied ); if ( freePlaces <= 0 ) { return; } result.push( { slot_id: slotId, date: String( slot.date || '' ), start_time: String( slot.start_time || '' ), end_time: String( slot.end_time || '' ), capacity: capacity, occupied: occupied, free_places: freePlaces, already_signed: false, note: String( slot.note || '' ) } ); } ); return result.sort( shiftSlotSort_ ); } /* ============================================================ MOJE SMĚNY Z JIŽ NAČTENÝCH ŘÁDKŮ ============================================================ */ function buildMyPlannedShiftsFromRows_( employeeId, period, slots, signups ) { const slotMap = {}; slots.forEach( function(slot) { slotMap[ String( slot.slot_id ) ] = slot; } ); const result = []; signups.forEach( function(signup) { if ( String( signup.employee_id ) !== String( employeeId ) || String( signup.status ) !== 'APPROVED' ) { return; } const slot = slotMap[ String( signup.slot_id ) ]; if ( !slot ) { return; } if ( String( slot.date || '' ).substring( 0, 7 ) !== period ) { return; } result.push( { signup_id: String( signup.signup_id || '' ), slot_id: String( slot.slot_id || '' ), date: String( slot.date || '' ), start_time: String( slot.start_time || '' ), end_time: String( slot.end_time || '' ), note: String( slot.note || '' ) } ); } ); return result.sort( shiftSlotSort_ ); } /* ============================================================ ZAMĚSTNANEC – VOLNÉ SMĚNY ============================================================ */ function getAvailableShiftSlots( token, period ) { const user = requireUser_( token, [ 'EMPLOYEE', 'MANAGER', 'ADMIN' ] ); if ( !user.employee_id ) { throw new Error( 'Účet není propojen se zaměstnancem.' ); } period = normalizePlanningPeriod_( period ); const slots = displayRows_( SHIFT_PLAN_SHEETS.SLOTS ); const signups = displayRows_( SHIFT_PLAN_SHEETS.SIGNUPS ); return buildAvailableShiftSlotsFromRows_( user.employee_id, period, slots, signups ); } /* ============================================================ ZAMĚSTNANEC – PŘIHLÁŠENÍ NA SMĚNU ============================================================ */ function signupForShiftSlot( token, slotId ) { const user = requireUser_( token, [ 'EMPLOYEE', 'MANAGER', 'ADMIN' ] ); if ( !user.employee_id ) { throw new Error( 'Účet není propojen se zaměstnancem.' ); } return signupEmployeeToSlot_( slotId, user.employee_id, user.user_id ); } /* ============================================================ ZAMĚSTNANEC – MOJE SMĚNY ============================================================ */ function getMyPlannedShifts( token, period ) { const user = requireUser_( token, [ 'EMPLOYEE', 'MANAGER', 'ADMIN' ] ); if ( !user.employee_id ) { throw new Error( 'Účet není propojen se zaměstnancem.' ); } period = normalizePlanningPeriod_( period ); const slots = displayRows_( SHIFT_PLAN_SHEETS.SLOTS ); const signups = displayRows_( SHIFT_PLAN_SHEETS.SIGNUPS ); return buildMyPlannedShiftsFromRows_( user.employee_id, period, slots, signups ); } /* ============================================================ ZAMĚSTNANEC – ZRUŠENÍ PŘIHLÁŠKY ============================================================ */ function cancelMyShiftSignup( token, slotId ) { const user = requireUser_( token, [ 'EMPLOYEE', 'MANAGER', 'ADMIN' ] ); if ( !user.employee_id ) { throw new Error( 'Účet není propojen se zaměstnancem.' ); } const signup = displayRows_( SHIFT_PLAN_SHEETS.SIGNUPS ) .find( function(row) { return ( String( row.slot_id ) === String( slotId ) && String( row.employee_id ) === String( user.employee_id ) && String( row.status ) === 'APPROVED' ); } ); if ( !signup ) { throw new Error( 'Na této směně nejsi přihlášen.' ); } updateBy_( SHIFT_PLAN_SHEETS.SIGNUPS, 'signup_id', signup.signup_id, { status: 'CANCELLED', cancelled_at: now_(), cancelled_by: user.user_id } ); refreshSlotStatus_( slotId ); return { ok:true }; } /* ============================================================ INTERNÍ PŘIHLÁŠENÍ ============================================================ */ function signupEmployeeToSlot_( slotId, employeeId, actorUserId ) { const lock = LockService .getScriptLock(); lock.waitLock( 10000 ); try { const slots = displayRows_( SHIFT_PLAN_SHEETS.SLOTS ); const slot = slots.find( function(row) { return ( String( row.slot_id ) === String( slotId ) ); } ); if ( !slot ) { throw new Error( 'Směna nebyla nalezena.' ); } if ( String( slot.status ) === 'CLOSED' ) { throw new Error( 'Směna je uzavřená.' ); } const existing = displayRows_( SHIFT_PLAN_SHEETS.SIGNUPS ) .find( function(row) { return ( String( row.slot_id ) === String( slotId ) && String( row.employee_id ) === String( employeeId ) && String( row.status ) === 'APPROVED' ); } ); if ( existing ) { throw new Error( 'Na této směně už jsi přihlášen.' ); } assertNoPlanningCollision_( employeeId, slot ); const approved = getApprovedSlotSignups_( slotId ); const capacity = planningNumber_( slot.capacity ); if ( approved.length >= capacity ) { throw new Error( 'Směna je už obsazená.' ); } append_( SHIFT_PLAN_SHEETS.SIGNUPS, { signup_id: uuid_( 'SIGN' ), slot_id: slotId, employee_id: employeeId, status: 'APPROVED', created_at: now_(), cancelled_at: '', cancelled_by: '' } ); refreshSlotStatus_( slotId ); audit_( actorUserId, 'SHIFT_SIGNUP_CREATED', 'SHIFT_SLOT', slotId, '', employeeId ); return { ok:true }; } finally { lock.releaseLock(); } } /* ============================================================ KOLIZE SMĚN ============================================================ */ function assertNoPlanningCollision_( employeeId, targetSlot ) { const myShifts = getApprovedPlannedSlotsForEmployee_( employeeId ); const targetStart = planningSlotStart_( targetSlot ); const targetEnd = planningSlotEnd_( targetSlot ); myShifts.forEach( function(slot) { const start = planningSlotStart_( slot ); const end = planningSlotEnd_( slot ); if ( targetStart < end && targetEnd > start ) { throw new Error( 'Tato směna se překrývá s jinou směnou, na kterou už jsi přihlášen.' ); } } ); } /* ============================================================ POMOCNÉ FUNKCE ============================================================ */ function getApprovedSlotSignups_( slotId ) { return displayRows_( SHIFT_PLAN_SHEETS.SIGNUPS ) .filter( function(signup) { return ( String( signup.slot_id ) === String( slotId ) && String( signup.status ) === 'APPROVED' ); } ); } function getApprovedPlannedSlotsForEmployee_( employeeId ) { const slots = displayRows_( SHIFT_PLAN_SHEETS.SLOTS ); const slotMap = {}; slots.forEach( function(slot) { slotMap[ String( slot.slot_id ) ] = slot; } ); return displayRows_( SHIFT_PLAN_SHEETS.SIGNUPS ) .filter( function(signup) { return ( String( signup.employee_id ) === String( employeeId ) && String( signup.status ) === 'APPROVED' ); } ) .map( function(signup) { return slotMap[ String( signup.slot_id ) ]; } ) .filter( Boolean ); } function refreshSlotStatus_( slotId ) { const slots = displayRows_( SHIFT_PLAN_SHEETS.SLOTS ); const slot = slots.find( function(row) { return ( String( row.slot_id ) === String( slotId ) ); } ); if ( !slot ) { return; } const capacity = planningNumber_( slot.capacity ); const occupied = getApprovedSlotSignups_( slotId ).length; let status = 'OPEN_FOR_SIGNUP'; if ( capacity <= 0 ) { status = 'CLOSED'; } else if ( occupied >= capacity ) { status = 'FULL'; } updateBy_( SHIFT_PLAN_SHEETS.SLOTS, 'slot_id', slotId, { status: status, updated_at: now_() } ); } function planningSlotStart_( slot ) { const parts = String( slot.date ).split( '-' ); const time = String( slot.start_time ).split( ':' ); return new Date( Number( parts[0] ), Number( parts[1] ) - 1, Number( parts[2] ), Number( time[0] ), Number( time[1] || 0 ) ); } function planningSlotEnd_( slot ) { const start = planningSlotStart_( slot ); const time = String( slot.end_time ).split( ':' ); const end = new Date( start.getFullYear(), start.getMonth(), start.getDate(), Number( time[0] ), Number( time[1] || 0 ) ); if ( end <= start ) { end.setDate( end.getDate() + 1 ); } return end; } function shiftSlotKey_( date, start, end, locationId ) { return [ String( date || '' ).substring( 0, 10 ), String( start || '' ).substring( 0, 5 ), String( end || '' ).substring( 0, 5 ), String( locationId || '' ) ].join( '|' ); } function shiftSlotSort_( a, b ) { const aKey = String( a.date || '' ) + ' ' + String( a.start_time || '' ); const bKey = String( b.date || '' ) + ' ' + String( b.start_time || '' ); return aKey.localeCompare( bKey ); } function normalizePlanningPeriod_( period ) { period = String( period || Utilities.formatDate( new Date(), CFG.TZ, 'yyyy-MM' ) ); if ( !/^\d{4}-\d{2}$/.test( period ) ) { throw new Error( 'Neplatné období.' ); } return period; } function normalizePlanningTime_( value ) { value = String( value || '' ).trim(); if ( !/^\d{1,2}:\d{2}$/.test( value ) ) { throw new Error( 'Neplatný čas.' ); } const parts = value.split( ':' ); const hour = Number( parts[0] ); const minute = Number( parts[1] ); if ( hour < 0 || hour > 23 || minute < 0 || minute > 59 ) { throw new Error( 'Neplatný čas.' ); } return ( String( hour ).padStart( 2, '0' ) + ':' + String( minute ).padStart( 2, '0' ) ); } function planningNumber_( value ) { const number = Number( String( value == null ? 0 : value ) .replace( ',', '.' ) ); return isFinite( number ) ? number : 0; } /* ============================================================ VÝCHOZÍ PROVOZOVNA ============================================================ */ function getDefaultPlanningLocation_() { const locations = displayRows_( CFG.SHEETS.LOCATIONS ); if ( locations.length ) { return String( locations[0] .location_id || '' ); } return 'LOCATION_MAIN'; } /* ============================================================ VYTVOŘENÍ LISTŮ Použije existující databázový spreadsheet. ============================================================ */ function ensureShiftPlanningSheet_( sheetName, headers ) { let sheet; try { sheet = sh_( sheetName ); if ( sheet ) { return sheet; } } catch(error) { /* * list zatím neexistuje */ } const spreadsheet = getPlanningSpreadsheet_(); sheet = spreadsheet .getSheetByName( sheetName ); if ( !sheet ) { sheet = spreadsheet .insertSheet( sheetName ); } sheet .getRange( 1, 1, 1, headers.length ) .setValues( [ headers ] ); sheet .setFrozenRows( 1 ); return sheet; } function getPlanningSpreadsheet_() { /* * Nejprve zkusíme aktivní spreadsheet. */ const active = SpreadsheetApp .getActiveSpreadsheet(); if ( active ) { return active; } /* * Pokud je projekt standalone, * hledáme ID ve Script Properties. */ const id = PropertiesService .getScriptProperties() .getProperty( 'SPREADSHEET_ID' ); if ( id ) { return SpreadsheetApp .openById( id ); } /* * Kompatibilita s případným CFG.SPREADSHEET_ID */ if ( typeof CFG !== 'undefined' && CFG.SPREADSHEET_ID ) { return SpreadsheetApp .openById( CFG.SPREADSHEET_ID ); } throw new Error( 'Nepodařilo se zjistit databázový spreadsheet.' ); } /* ============================================================ DIAGNOSTIKA ============================================================ */ function testShiftPlanning() { Logger.log( 'SHIFT_SLOTS: ' + JSON.stringify( displayRows_( SHIFT_PLAN_SHEETS.SLOTS ) ) ); Logger.log( 'SHIFT_SIGNUPS: ' + JSON.stringify( displayRows_( SHIFT_PLAN_SHEETS.SIGNUPS ) ) ); Logger.log( 'ShiftPlanningService OK' ); }