/* ============================================================ EATME PORTÁL – ATTENDANCE CORRECTION ADMIN SERVICE Admin / manager: - seznam čekajících žádostí o opravu docházky - schválení -> přepis SHIFTS + přepočet minut - zamítnutí -> pouze změna stavu žádosti - uzamčenou směnu nelze měnit ============================================================ */ /* ============================================================ SEZNAM ŽÁDOSTÍ ============================================================ */ function getPendingAttendanceCorrections(token) { requireUser_( token, [ 'ADMIN', 'MANAGER' ] ); const requests = displayRows_( CFG.SHEETS.SHIFT_CHANGE_REQUESTS ); const shifts = displayRows_( CFG.SHEETS.SHIFTS ); const employees = displayRows_( CFG.SHEETS.EMPLOYEES ); const shiftMap = {}; shifts.forEach( function(shift) { shiftMap[ String( shift.shift_id || '' ) ] = shift; } ); const employeeMap = {}; employees.forEach( function(employee) { employeeMap[ String( employee.employee_id || '' ) ] = employee; } ); /* * Jedna oprava může být v SHIFT_CHANGE_REQUESTS uložena * jako více řádků (např. clock_in + clock_out). * Seskládáme je podle request_id. */ const groups = {}; requests.forEach( function(request,index) { if ( String( request.status || '' ).trim() !== 'PENDING' ) { return; } const groupId = String( request.request_id || '' ) || ( 'ROW_' + index ); if ( !groups[ groupId ] ) { groups[ groupId ] = { request_id: groupId, shift_id: String( request.shift_id || '' ), employee_id: String( request.employee_id || '' ), reason: String( request.reason || '' ), created_at: String( request.created_at || '' ), fields: [], rows: [] }; } groups[ groupId ].rows.push( request ); groups[ groupId ].fields.push( { field: String( request.field || '' ), old_value: String( request.old_value || '' ), requested_value: String( request.requested_value || '' ) } ); if ( !groups[ groupId ].reason && request.reason ) { groups[ groupId ].reason = String( request.reason ); } } ); const result = Object.keys( groups ) .map( function(groupId) { const group = groups[ groupId ]; const shift = shiftMap[ String( group.shift_id ) ] || null; const employeeId = group.employee_id || ( shift ? String( shift.employee_id || '' ) : '' ); const employee = employeeMap[ employeeId ] || null; const requested = attendanceCorrectionRequestedValues_( group.fields ); return { request_id: group.request_id, shift_id: group.shift_id, employee_id: employeeId, employee_name: employee ? ( String( employee.first_name || '' ) + ' ' + String( employee.last_name || '' ) ).trim() : employeeId, work_date: shift ? String( shift.work_date || '' ) : '', current_clock_in: shift ? String( shift.clock_in || '' ) : '', current_clock_out: shift ? String( shift.clock_out || '' ) : '', requested_clock_in: requested.clock_in, requested_clock_out: requested.clock_out, reason: group.reason, created_at: group.created_at, shift_status: shift ? String( shift.status || '' ) : '', locked: shift ? String( shift.status || '' ).toUpperCase() === 'LOCKED' : false }; } ) .sort( function(a,b) { return String( b.created_at || '' ).localeCompare( String( a.created_at || '' ) ); } ); return { count: result.length, rows: result }; } /* ============================================================ SCHVÁLENÍ ŽÁDOSTI ============================================================ */ function approveAttendanceCorrection( token, requestId ) { const user = requireUser_( token, [ 'ADMIN', 'MANAGER' ] ); const lock = LockService .getScriptLock(); lock.waitLock( 10000 ); try { const requests = displayRows_( CFG.SHEETS.SHIFT_CHANGE_REQUESTS ); const requestRows = requests.filter( function(request) { return ( String( request.request_id || '' ) === String( requestId ) && String( request.status || '' ).trim() === 'PENDING' ); } ); if ( !requestRows.length ) { throw new Error( 'Žádost nebyla nalezena nebo už byla vyřešena.' ); } const shiftId = String( requestRows[0].shift_id || '' ); const shifts = displayRows_( CFG.SHEETS.SHIFTS ); const shift = shifts.find( function(row) { return ( String( row.shift_id || '' ) === shiftId ); } ); if ( !shift ) { throw new Error( 'Směna uvedená v žádosti nebyla nalezena.' ); } if ( String( shift.status || '' ).toUpperCase() === 'LOCKED' ) { throw new Error( 'Tato směna je už uzamčená a předaná do mzdy. Nejdřív je nutné řešit mzdovou uzávěrku.' ); } const requested = attendanceCorrectionRequestedValues_( requestRows.map( function(request) { return { field: request.field, old_value: request.old_value, requested_value: request.requested_value }; } ) ); let newClockIn = requested.clock_in || String( shift.clock_in || '' ); let newClockOut = requested.clock_out || String( shift.clock_out || '' ); const clockInDate = parseAttendanceCorrectionDate_( newClockIn ); const clockOutDate = parseAttendanceCorrectionDate_( newClockOut ); if ( !clockInDate ) { throw new Error( 'Navrhovaný čas příchodu není platný.' ); } if ( newClockOut && !clockOutDate ) { throw new Error( 'Navrhovaný čas odchodu není platný.' ); } if ( clockOutDate && clockOutDate <= clockInDate ) { throw new Error( 'Odchod musí být později než příchod.' ); } const breakMinutes = correctionNumber_( shift.break_minutes ); let workedMinutes = correctionNumber_( shift.worked_minutes ); let nightMinutes = correctionNumber_( shift.night_minutes ); if ( clockOutDate ) { workedMinutes = Math.max( 0, Math.round( ( clockOutDate.getTime() - clockInDate.getTime() ) / 60000 ) - breakMinutes ); nightMinutes = calculateCorrectionNightMinutes_( clockInDate, clockOutDate ); } const workDate = Utilities.formatDate( clockInDate, CFG.TZ, 'yyyy-MM-dd' ); const update = { clock_in: formatAttendanceCorrectionDate_( clockInDate ), work_date: workDate, worked_minutes: workedMinutes, night_minutes: nightMinutes, updated_at: now_() }; if ( clockOutDate ) { update.clock_out = formatAttendanceCorrectionDate_( clockOutDate ); /* * Pokud měla směna odchod a není uzamčená, * má být po opravě uzavřená. */ if ( String( shift.status || '' ) === 'OPEN' ) { update.status = 'COMPLETED'; } } updateBy_( CFG.SHEETS.SHIFTS, 'shift_id', shiftId, update ); requestRows.forEach( function(request) { updateBy_( CFG.SHEETS.SHIFT_CHANGE_REQUESTS, 'request_id', request.request_id, { status: 'APPROVED', resolved_at: now_(), resolved_by: user.user_id } ); } ); audit_( user.user_id, 'ATTENDANCE_CORRECTION_APPROVED', 'SHIFT', shiftId, { clock_in: shift.clock_in, clock_out: shift.clock_out, worked_minutes: shift.worked_minutes }, { clock_in: update.clock_in, clock_out: update.clock_out || shift.clock_out, worked_minutes: workedMinutes } ); notifyEmployee_( shift.employee_id, 'Oprava docházky schválena', 'Tvoje žádost o opravu docházky ze dne ' + workDate + ' byla schválena.' ); return { ok: true, shift_id: shiftId, worked_minutes: workedMinutes }; } finally { lock.releaseLock(); } } /* ============================================================ ZAMÍTNUTÍ ŽÁDOSTI ============================================================ */ function rejectAttendanceCorrection( token, requestId ) { const user = requireUser_( token, [ 'ADMIN', 'MANAGER' ] ); const requests = displayRows_( CFG.SHEETS.SHIFT_CHANGE_REQUESTS ); const requestRows = requests.filter( function(request) { return ( String( request.request_id || '' ) === String( requestId ) && String( request.status || '' ).trim() === 'PENDING' ); } ); if ( !requestRows.length ) { throw new Error( 'Žádost nebyla nalezena nebo už byla vyřešena.' ); } requestRows.forEach( function(request) { updateBy_( CFG.SHEETS.SHIFT_CHANGE_REQUESTS, 'request_id', request.request_id, { status: 'REJECTED', resolved_at: now_(), resolved_by: user.user_id } ); } ); const shiftId = String( requestRows[0].shift_id || '' ); const employeeId = String( requestRows[0].employee_id || '' ); audit_( user.user_id, 'ATTENDANCE_CORRECTION_REJECTED', 'SHIFT', shiftId, '', '' ); if ( employeeId ) { notifyEmployee_( employeeId, 'Oprava docházky zamítnuta', 'Tvoje žádost o opravu docházky byla zamítnuta.' ); } return { ok:true }; } /* ============================================================ REQUESTED VALUES – ROBUSTNÍ PARSER ============================================================ */ function attendanceCorrectionRequestedValues_( fields ) { const result = { clock_in: '', clock_out: '' }; ( fields || [] ).forEach( function(item) { const field = String( item.field || '' ) .trim() .toLowerCase(); const value = String( item.requested_value || '' ).trim(); if ( field === 'clock_in' ) { result.clock_in = value; return; } if ( field === 'clock_out' ) { result.clock_out = value; return; } /* * Pro jistotu podporujeme i případ, že requested_value * obsahuje JSON s oběma časy. */ if ( value && ( value.charAt( 0 ) === '{' ) ) { try { const parsed = JSON.parse( value ); if ( parsed.clock_in ) { result.clock_in = String( parsed.clock_in ); } if ( parsed.clock_out ) { result.clock_out = String( parsed.clock_out ); } } catch(error) { /* * Není JSON, ignorujeme. */ } } } ); return result; } /* ============================================================ DATUM / ČAS ============================================================ */ function parseAttendanceCorrectionDate_( value ) { if ( !value ) { return null; } if ( value instanceof Date ) { return value; } const text = String( value ).trim(); let match = text.match( /^(\d{4})-(\d{2})-(\d{2})T(\d{1,2}):(\d{2})(?::(\d{2}))?$/ ); if ( match ) { return new Date( Number( match[1] ), Number( match[2] ) - 1, Number( match[3] ), Number( match[4] ), Number( match[5] ), Number( match[6] || 0 ) ); } match = text.match( /^(\d{4})-(\d{2})-(\d{2})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$/ ); if ( match ) { return new Date( Number( match[1] ), Number( match[2] ) - 1, Number( match[3] ), Number( match[4] ), Number( match[5] ), Number( match[6] || 0 ) ); } match = text.match( /^(\d{1,2})\/(\d{1,2})\/(\d{4})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?$/ ); if ( match ) { return new Date( Number( match[3] ), Number( match[1] ) - 1, Number( match[2] ), Number( match[4] || 0 ), Number( match[5] || 0 ), Number( match[6] || 0 ) ); } const date = new Date( text ); return isNaN( date.getTime() ) ? null : date; } function formatAttendanceCorrectionDate_( date ) { return Utilities.formatDate( date, CFG.TZ, 'yyyy-MM-dd HH:mm:ss' ); } /* ============================================================ NOČNÍ MINUTY 22:00–06:00 ============================================================ */ function calculateCorrectionNightMinutes_( start, end ) { if ( !start || !end || end <= start ) { return 0; } let total = 0; let cursor = new Date( start ); cursor.setSeconds( 0, 0 ); while ( cursor < end ) { const hour = cursor.getHours(); if ( hour >= 22 || hour < 6 ) { total++; } cursor = new Date( cursor.getTime() + 60000 ); } return total; } /* ============================================================ ČÍSLO ============================================================ */ function correctionNumber_( value ) { const number = Number( String( value == null ? 0 : value ) .replace( ',', '.' ) .replace( /\s/g, '' ) ); return isFinite( number ) ? number : 0; }