Last active
September 28, 2026 16:39
-
-
Save tsantos8080/17ae52ff2790c0a92178e0e2f62af4ff to your computer and use it in GitHub Desktop.
ADP userscript
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name ADP | |
| // @namespace https://expert.hcm.adp.com/ | |
| // @version 0.7.4 | |
| // @description Previsão de saída (com tolerância de 10 min), saldo do banco de horas, pagamentos a receber e confirmação de batida no ADP eXpert Brasil. | |
| // @author Thyago | |
| // @license MIT | |
| // @icon https://www.adp.com/favicon.ico | |
| // @match https://expert.hcm.adp.com/expert2/v5/* | |
| // @run-at document-idle | |
| // @noframes | |
| // @grant GM_addStyle | |
| // @grant GM_getResourceText | |
| // @require https://gist.github.com/raw/2625891/waitForKeyElements.js | |
| // @require https://code.jquery.com/jquery-3.6.0.min.js | |
| // @require https://cdn.jsdelivr.net/npm/moment@2.30.1/moment.min.js | |
| // @require https://cdn.jsdelivr.net/npm/moment@2.30.1/locale/pt-br.js | |
| // @require https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js | |
| // @require https://cdn.jsdelivr.net/npm/sweetalert2@11.26.25/dist/sweetalert2.all.min.js | |
| // @require https://cdn.jsdelivr.net/npm/mitt@3.0.1/dist/mitt.umd.js | |
| // @require https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.js | |
| // @require https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/l10n/pt.js | |
| // @resource flatpickrCss https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.css | |
| // ==/UserScript== | |
| /* global $, _, moment, mitt, flatpickr, Swal, waitForKeyElements, GM_addStyle, GM_getResourceText */ | |
| (function () { | |
| 'use strict'; | |
| moment.locale('pt-br'); | |
| if (window.flatpickr?.l10ns?.pt) flatpickr.localize(flatpickr.l10ns.pt); | |
| const emitter = mitt(); | |
| // =============================================================== | |
| // LOG | |
| // Padrão: uma linha de resumo por carregamento + erros. | |
| // Detalhe cru: localStorage.setItem('adphDebug', '1') e recarregar. | |
| // =============================================================== | |
| const log = (function () { | |
| const PREFIX = '[ADP]'; | |
| const DEBUG = localStorage.getItem('adphDebug') === '1'; | |
| const alreadyWarned = new Set(); | |
| return { | |
| debugEnabled: DEBUG, | |
| info: (...args) => console.log(PREFIX, ...args), | |
| error: (...args) => console.error(PREFIX, ...args), | |
| /** Avisos de seletor rodam a cada ciclo de 30s — este loga uma vez por sessão. */ | |
| once: (key, ...args) => { | |
| if (alreadyWarned.has(key)) return; | |
| alreadyWarned.add(key); | |
| console.warn(PREFIX, ...args); | |
| }, | |
| debug: (...args) => { if (DEBUG) console.log(PREFIX, ...args); }, | |
| dump: (label, payload) => { | |
| if (!DEBUG) return; | |
| console.groupCollapsed(`${PREFIX} ${label}`); | |
| console.log(payload); | |
| console.groupEnd(); | |
| } | |
| }; | |
| })(); | |
| // =============================================================== | |
| // ESTILOS | |
| // =============================================================== | |
| GM_addStyle(GM_getResourceText('flatpickrCss')); | |
| GM_addStyle(` | |
| /* ---------- Banner dentro do card "Batida" ---------- */ | |
| .adph-banner { | |
| margin: 0 0 12px 14px; | |
| box-sizing: border-box; | |
| text-align: left; | |
| font-size: 13px; | |
| line-height: 1.45; | |
| color: #212529; | |
| width: 100%; | |
| min-width: 240px; | |
| max-width: 340px; | |
| flex: 0 0 auto; | |
| align-self: flex-start; | |
| } | |
| .adph-banner__row { | |
| display: flex; | |
| align-items: baseline; | |
| justify-content: space-between; | |
| gap: 12px; | |
| } | |
| .adph-banner__worked { color: #5a6472; } | |
| .adph-banner__headline { | |
| font-size: 16px; | |
| font-weight: 600; | |
| white-space: nowrap; | |
| } | |
| .adph-bar { | |
| height: 6px; | |
| margin: 7px 0; | |
| border-radius: 999px; | |
| background: #e9ecef; | |
| overflow: hidden; | |
| } | |
| .adph-bar__fill { | |
| height: 100%; | |
| width: 0; | |
| border-radius: 999px; | |
| background: #0f6cbd; | |
| transition: width .4s ease; | |
| } | |
| .adph-bar__fill--ok { background: #1e8e3e; } | |
| .adph-bar__fill--warn { background: #b26a00; } | |
| .adph-bar__fill--idle { background: #adb5bd; } | |
| .adph-banner__row--foot { font-size: 12px; color: #5a6472; } | |
| .adph-banner__actions { display: inline-flex; gap: 12px; flex-shrink: 0; } | |
| .adph-banner__meta { | |
| margin-top: 6px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 2px; | |
| font-size: 12px; | |
| color: #5a6472; | |
| } | |
| .adph-banner__meta:empty { display: none; } | |
| .adph-banner-row > td { padding: 0 8px 8px !important; border: 0 !important; } | |
| /* ---------- Botão secundário (link) ---------- */ | |
| .adph-link-btn { | |
| background: none; | |
| border: 0; | |
| padding: 0; | |
| font: inherit; | |
| font-size: 12px; | |
| color: #0f6cbd; | |
| cursor: pointer; | |
| text-decoration: underline; | |
| text-underline-offset: 2px; | |
| } | |
| .adph-link-btn:hover { color: #0a4c86; } | |
| .adph-link-btn:focus-visible { outline: 2px solid #0f6cbd; outline-offset: 2px; border-radius: 2px; } | |
| /* ---------- Selo ao lado dos títulos dos cards ---------- */ | |
| .adph-badge { | |
| display: inline-flex; | |
| align-items: center; | |
| align-self: center; | |
| gap: 6px; | |
| margin-left: 10px; | |
| padding: 3px 10px; | |
| border-radius: 999px; | |
| background: #f1f3f5; | |
| color: #495057; | |
| font-size: 12px; | |
| font-weight: 600; | |
| font-family: inherit; | |
| letter-spacing: normal; | |
| line-height: 1.4; | |
| white-space: nowrap; | |
| vertical-align: middle; | |
| cursor: default; | |
| } | |
| .adph-badge__when { font-weight: 400; } | |
| .adph-badge__more { | |
| padding: 0 6px; | |
| border-radius: 999px; | |
| background: rgba(0, 0, 0, .10); | |
| font-size: 11px; | |
| } | |
| .adph-badge--ok { background: #e6f4ea; color: #136c3a; } | |
| .adph-badge--debt { background: #fdecea; color: #a52714; } | |
| .adph-badge--warn { background: #fdf3e2; color: #8a5a00; font-weight: 400; } | |
| .adph-badge--quiet { background: #f1f3f5; color: #5a6472; font-weight: 400; } | |
| /* ---------- Cores de status ---------- */ | |
| .adph-in { color: #0f6cbd; } | |
| .adph-out { color: #6c757d; } | |
| .adph-ok { color: #1e8e3e; } | |
| .adph-warn { color: #b26a00; } | |
| .adph-debt { color: #c5221f; } | |
| .adph-extra { color: #0f8b9e; } | |
| /* ---------- Responsivo ---------- */ | |
| @media (max-width: 600px) { | |
| .adph-banner { margin-left: 0; max-width: 100%; } | |
| .adph-badge { margin-left: 0; margin-top: 4px; } | |
| } | |
| /* ---------- Diálogos ---------- */ | |
| .swal2-html-container .adph-dialog-hint { | |
| font-size: 13px; | |
| color: #5a6472; | |
| margin-top: 6px; | |
| } | |
| .swal2-html-container .adph-preview { | |
| margin-top: 12px; | |
| font-size: 15px; | |
| font-weight: 500; | |
| min-height: 1.4em; | |
| } | |
| .swal2-html-container .adph-confirm-time { | |
| font-size: 30px; | |
| font-weight: 600; | |
| margin: 4px 0 10px; | |
| } | |
| .swal2-html-container .adph-confirm-note, | |
| .swal2-html-container .adph-confirm-warn { | |
| font-size: 14px; | |
| line-height: 1.4; | |
| margin-top: 8px; | |
| } | |
| .swal2-html-container .adph-confirm-note { color: #1e8e3e; } | |
| .swal2-html-container .adph-confirm-warn { color: #b26a00; } | |
| /* ---------- Flatpickr dentro do SweetAlert ---------- */ | |
| .swal2-html-container #flatpickr-extra-time { | |
| width: auto !important; | |
| min-width: 90px; | |
| display: inline-block !important; | |
| margin: 10px auto 0; | |
| padding: .375rem .75rem; | |
| text-align: center; | |
| } | |
| .swal2-html-container > div { margin-bottom: 5px; } | |
| `); | |
| // =============================================================== | |
| // API MODULE | |
| // =============================================================== | |
| const ApiModule = (function () { | |
| const BASE_URL = 'https://expert.hcm.adp.com/expert2/api'; | |
| let cachedContextId = null; | |
| let cachedReference = null; | |
| function buildHeaders(contextId = null) { | |
| const sessionId = $('#newexpert_sessionid').val(); | |
| if (!sessionId) { | |
| log.error('sessão não encontrada no DOM — recarregue a página'); | |
| emitter.emit('api-error', 'ID da Sessão não encontrado. Recarregue a página.'); | |
| throw new Error('Session ID not available.'); | |
| } | |
| const headers = { newexpert_sessionid: sessionId }; | |
| if (contextId) headers['ServicePlace-Context-ContextId'] = contextId; | |
| return headers; | |
| } | |
| /** | |
| * Ponto único de saída HTTP. Anexa path e status ao erro para que o log | |
| * de quem chamou diga qual endpoint quebrou e com qual código. | |
| */ | |
| async function request(path, { contextId = null, allow404 = false } = {}) { | |
| const response = await fetch(`${BASE_URL}${path}`, { | |
| method: 'GET', | |
| headers: buildHeaders(contextId) | |
| }); | |
| if (response.status === 404 && allow404) return null; | |
| if (!response.ok) { | |
| const error = new Error(`HTTP ${response.status}`); | |
| error.path = path; | |
| error.status = response.status; | |
| throw error; | |
| } | |
| return response.json(); | |
| } | |
| function describe(error) { | |
| return `${error.path || 'requisição'}${error.status ? ` (${error.status})` : ''}`; | |
| } | |
| async function getContextId() { | |
| if (cachedContextId) return cachedContextId; | |
| const path = '/contextselection/default/usercontext/tree'; | |
| try { | |
| const data = await request(path); | |
| if (!data?.contexts?.[0]?.id) { | |
| const error = new Error('resposta de contexto sem id'); | |
| error.path = path; | |
| throw error; | |
| } | |
| cachedContextId = data.contexts[0].id; | |
| return cachedContextId; | |
| } catch (error) { | |
| cachedContextId = null; | |
| error.path = error.path || path; | |
| log.error(`falha em ${describe(error)} — sem contexto, nada carrega`); | |
| emitter.emit('api-error', 'Falha ao buscar contexto do usuário.'); | |
| throw error; | |
| } | |
| } | |
| /** | |
| * O período de referência não é o mês do calendário: em 23/09/2026 a ADP | |
| * já trabalha com 10/2026. É esse par que vai para comptime e time-card, | |
| * do mesmo jeito que a própria aplicação faz. | |
| * Resposta: {"referenceKey":"BR0000004156:13279","year":2026,"month":10} | |
| */ | |
| async function fetchReferencePeriod() { | |
| if (cachedReference) return cachedReference; | |
| const path = '/timesheet/time-card/whichToShow'; | |
| try { | |
| const data = await request(path, { contextId: await getContextId() }); | |
| if (!data?.year || !data?.month) { | |
| const error = new Error('resposta sem year/month'); | |
| error.path = path; | |
| throw error; | |
| } | |
| cachedReference = { | |
| year: String(data.year), | |
| month: String(data.month).padStart(2, '0'), | |
| referenceKey: data.referenceKey || null | |
| }; | |
| log.debug('período de referência', cachedReference); | |
| return cachedReference; | |
| } catch (error) { | |
| // Sem período, cai no mês do calendário em vez de derrubar a carga inteira. | |
| error.path = error.path || path; | |
| log.error(`falha em ${describe(error)} — usando o mês do calendário como referência`); | |
| return { year: moment().format('YYYY'), month: moment().format('MM'), referenceKey: null }; | |
| } | |
| } | |
| async function fetchLatestPunches() { | |
| const path = '/punch/punchin?lp=true'; | |
| try { | |
| return await request(path); | |
| } catch (error) { | |
| error.path = error.path || path; | |
| log.error(`falha em ${describe(error)} — últimas batidas indisponíveis`); | |
| emitter.emit('api-error', 'Falha ao buscar últimas batidas.'); | |
| throw error; | |
| } | |
| } | |
| async function fetchTimesheetForMonth(year, month) { | |
| const path = `/timesheet/time-card/reference/${year}/${month}`; | |
| try { | |
| return await request(path, { contextId: await getContextId() }); | |
| } catch (error) { | |
| error.path = error.path || path; | |
| log.error(`falha em ${describe(error)} — folha de ponto indisponível`); | |
| emitter.emit('api-error', 'Falha ao buscar folha de ponto.'); | |
| throw error; | |
| } | |
| } | |
| async function fetchCompTime(year, month) { | |
| const path = `/timesheet/comptime/${year}/${month}`; | |
| try { | |
| const data = await request(path, { contextId: await getContextId(), allow404: true }); | |
| if (data === null) { | |
| log.debug(`sem banco de horas em ${month}/${year} — saldo tratado como 0`); | |
| return { accumulated: { total: 0 } }; | |
| } | |
| return data; | |
| } catch (error) { | |
| error.path = error.path || path; | |
| log.error(`falha em ${describe(error)} — saldo do banco assumido como 0`); | |
| emitter.emit('api-error', 'Falha ao buscar saldo do banco de horas.'); | |
| return null; | |
| } | |
| } | |
| async function fetchPayments() { | |
| const path = '/payroll/v1_0/my-org/me/payroll-list'; | |
| try { | |
| return await request(path, { contextId: await getContextId() }); | |
| } catch (error) { | |
| error.path = error.path || path; | |
| log.error(`falha em ${describe(error)} — pagamentos indisponíveis`); | |
| emitter.emit('api-error', 'Falha ao buscar informações de pagamento.'); | |
| throw error; | |
| } | |
| } | |
| return { fetchReferencePeriod, fetchLatestPunches, fetchTimesheetForMonth, fetchCompTime, fetchPayments }; | |
| })(); | |
| // =============================================================== | |
| // LOGIC MODULE | |
| // =============================================================== | |
| const LogicModule = (function (api) { | |
| const REQUIRED_WORK_MINUTES = 60 * 8; | |
| // CLT art. 71: jornada acima de 6h exige intervalo mínimo de 1 hora. | |
| const MIN_LUNCH_MINUTES = 60; | |
| // O ADP credita a jornada padrão para qualquer total entre 7h50 e 8h10, | |
| // então 10 min antes das 8h ainda fecham o dia como 8h. | |
| const EXIT_TOLERANCE_MINUTES = 10; | |
| // Regra da empresa: no máximo 1h59 além das 8h por dia (meta máxima de 9h59). | |
| const MAX_DAILY_EXTRA_MINUTES = 119; | |
| // Regra da empresa: nenhuma batida depois das 20h. 19:59 é o último minuto seguro. | |
| const LEAVE_LIMIT = { hour: 19, minute: 59 }; | |
| // Abaixo desta margem até o limite, o banner passa a avisar. | |
| const LIMIT_MARGIN_WARN_MINUTES = 60; | |
| const EXTRA_STORAGE_KEY = 'adphExtraTime'; | |
| let todayPunches = []; | |
| // Débito do banco: só informativo. Não entra na meta sozinho — para abater, | |
| // o usuário soma pelo "ajustar meta" ou "sair às…", respeitando o teto diário. | |
| let negativeBalanceMinutes = 0; | |
| let compTimeBalanceMinutes = null; // saldo com sinal; null = desconhecido | |
| // Declarado depois das constantes de propósito: loadStoredExtraTime() lê | |
| // EXTRA_STORAGE_KEY e MAX_DAILY_EXTRA_MINUTES, que ainda estariam na zona morta temporal. | |
| let extraTimeRequestMinutes = loadStoredExtraTime(); | |
| let lastCalculationResult = {}; | |
| let pendingPayments = null; // null = carregando | { error, items: [] } | |
| let inconsistentDays = []; | |
| /** | |
| * O clique em "Bater Ponto" recarrega a página, então o extra precisa | |
| * sobreviver ao reload. Guardamos com a data para expirar sozinho. | |
| */ | |
| function loadStoredExtraTime() { | |
| try { | |
| const raw = localStorage.getItem(EXTRA_STORAGE_KEY); | |
| if (!raw) return 0; | |
| const saved = JSON.parse(raw); | |
| if (saved.date !== moment().format('YYYY-MM-DD')) { | |
| localStorage.removeItem(EXTRA_STORAGE_KEY); | |
| return 0; | |
| } | |
| // Um valor salvo por versão antiga não pode furar o teto diário. | |
| return Math.min(Number(saved.minutes) || 0, MAX_DAILY_EXTRA_MINUTES); | |
| } catch (error) { | |
| localStorage.removeItem(EXTRA_STORAGE_KEY); | |
| return 0; | |
| } | |
| } | |
| function storeExtraTime(minutes) { | |
| try { | |
| if (minutes) { | |
| localStorage.setItem(EXTRA_STORAGE_KEY, JSON.stringify({ | |
| date: moment().format('YYYY-MM-DD'), | |
| minutes | |
| })); | |
| } else { | |
| localStorage.removeItem(EXTRA_STORAGE_KEY); | |
| } | |
| } catch (error) { | |
| log.debug('não foi possível salvar o extra no localStorage', error); | |
| } | |
| } | |
| function extractTimesheetPunchesToday(timesheetData) { | |
| const realPunchTypes = [3, 10, 21]; | |
| return _.chain(timesheetData?.timetable) | |
| .find(t => moment(t.date, 'YYYY-MM-DD').isSame(moment(), 'day')) | |
| .get('timeline', []) | |
| .filter(t => realPunchTypes.includes(t.itemType)) | |
| .map('dateTime') | |
| .value(); | |
| } | |
| function extractLatestPunchesToday(latestPunchesData) { | |
| return _.chain(latestPunchesData?.lastPunches) | |
| .map('punchDateTime') | |
| .filter(p => moment(p).isSame(moment(), 'day')) | |
| .value(); | |
| } | |
| function mergeAndSortPunches(...punchLists) { | |
| return _.chain(punchLists) | |
| .flatten() | |
| .filter(Boolean) | |
| .map(d => moment(d).format('YYYY-MM-DD HH:mm')) | |
| .uniq() | |
| .sortBy() | |
| .value(); | |
| } | |
| function calculateWorkedMinutes(punches) { | |
| const intervals = _.chunk(punches, 2); | |
| return _.chain(intervals) | |
| .map(([i, o]) => (o ? moment(o, 'YYYY-MM-DD HH:mm').diff(moment(i, 'YYYY-MM-DD HH:mm'), 'minutes') : 0)) | |
| .sum() | |
| .value(); | |
| } | |
| /** | |
| * Minutos do intervalo ainda aberto: da última batida ímpar até agora. | |
| * Fica separado do calculateWorkedMinutes para não afetar o cálculo da | |
| * previsão de saída, que já parte da última batida. | |
| */ | |
| function calculateOpenMinutes(punches) { | |
| if (!punches.length || punches.length % 2 === 0) return 0; | |
| const lastPunch = moment(_.last(punches), 'YYYY-MM-DD HH:mm'); | |
| return Math.max(0, moment().diff(lastPunch, 'minutes')); | |
| } | |
| /** Devolve o saldo com sinal (minutos) ou null se a API não respondeu. */ | |
| function processCompTimeData(compTimeData) { | |
| const total = compTimeData?.accumulated?.total; | |
| if (typeof total !== 'number') return null; | |
| return Math.round(total); | |
| } | |
| /** | |
| * Intervalo de almoço: janela entre a 2ª e a 3ª batida. Enquanto a 3ª | |
| * não existe, mede até agora para avisar antes de você voltar cedo demais. | |
| */ | |
| function getLunchIntervalInfo(punches) { | |
| if (punches.length < 2) return null; | |
| const start = moment(punches[1], 'YYYY-MM-DD HH:mm'); | |
| const closed = punches.length >= 3; | |
| const end = closed ? moment(punches[2], 'YYYY-MM-DD HH:mm') : moment(); | |
| const minutes = Math.max(0, end.diff(start, 'minutes')); | |
| return { | |
| minutes, | |
| closed, | |
| isShort: minutes < MIN_LUNCH_MINUTES, | |
| earliestReturn: moment(start).add(MIN_LUNCH_MINUTES, 'minutes') | |
| }; | |
| } | |
| function checkInconsistentPunches(timesheetData) { | |
| if (!timesheetData?.timetable) return []; | |
| return timesheetData.timetable | |
| .filter(day => day.inconsistent === true || (day.changed === true && day.variables?.receipt?.add?.length > 0)) | |
| .map(day => { | |
| const isToday = moment(day.date, 'YYYY-MM-DD').isSame(moment(), 'day'); | |
| const hasAdjustment = day.variables?.receipt?.add?.length > 0; | |
| const adjustmentStatus = hasAdjustment ? day.variables.internalStatus : null; | |
| return { | |
| date: moment(day.date, 'YYYY-MM-DD').format('DD/MM/YYYY'), | |
| dayOfWeek: moment(day.date, 'YYYY-MM-DD').format('dddd'), | |
| // O dia corrente sempre parece inconsistente enquanto está em aberto. | |
| isInconsistent: day.inconsistent === true && !isToday, | |
| hasAdjustment, | |
| adjustmentStatus, | |
| adjustments: hasAdjustment | |
| ? day.variables.receipt.add.map(adj => ({ | |
| time: moment(adj.punchInTime).format('HH:mm'), | |
| type: adj.punchType, | |
| justification: adj.justification | |
| })) | |
| : [], | |
| isWaitingApproval: adjustmentStatus === 'WAITING_APPROVAL' | |
| }; | |
| }) | |
| .filter(day => day.isInconsistent || day.hasAdjustment); | |
| } | |
| /** | |
| * A tolerância existe só no referencial das 8h: é o arredondamento do | |
| * ADP, não uma folga aplicável a qualquer alvo. Meta ajustada à mão | |
| * (somar/descontar) é cobrada cheia, sem arredondamento. | |
| */ | |
| function getToleranceMinutes(requiredMinutes) { | |
| return requiredMinutes === REQUIRED_WORK_MINUTES ? EXIT_TOLERANCE_MINUTES : 0; | |
| } | |
| /** Meta do dia: 8h + ajuste manual. O débito do banco não entra aqui. */ | |
| function getRequiredMinutes(adjustMinutes = extraTimeRequestMinutes) { | |
| return REQUIRED_WORK_MINUTES + adjustMinutes; | |
| } | |
| /** Último minuto em que dá para bater a saída hoje (19:59:00). */ | |
| function getLeaveLimit() { | |
| return moment().set({ ...LEAVE_LIMIT, second: 0, millisecond: 0 }); | |
| } | |
| /** | |
| * Previsão enquanto o almoço ainda não fechou, supondo intervalo de | |
| * MIN_LUNCH_MINUTES. Antes do almoço, o total do dia é entrada + meta + | |
| * 1h, independente de quando você sair. Durante o almoço, parte do | |
| * retorno mais cedo permitido (ou de agora, se já passou dele). | |
| */ | |
| function projectLeaveBeforeLunchEnds(punches, requiredMinutes, tolerance) { | |
| const count = punches.length; | |
| if (count !== 1 && count !== 2) return null; | |
| let returnAt; | |
| let fullLeave; | |
| if (count === 1) { | |
| const entry = moment(punches[0], 'YYYY-MM-DD HH:mm'); | |
| fullLeave = moment(entry).add(requiredMinutes + MIN_LUNCH_MINUTES, 'minutes'); | |
| returnAt = null; | |
| } else { | |
| const lunchStart = moment(punches[1], 'YYYY-MM-DD HH:mm'); | |
| const earliestReturn = moment(lunchStart).add(MIN_LUNCH_MINUTES, 'minutes'); | |
| const now = moment().seconds(0).milliseconds(0); | |
| returnAt = moment.max(earliestReturn, now); | |
| fullLeave = moment(returnAt).add(requiredMinutes - calculateWorkedMinutes(punches), 'minutes'); | |
| } | |
| return { | |
| lunchMinutes: MIN_LUNCH_MINUTES, | |
| returnAt, | |
| returnIsNow: count === 2 && returnAt.isAfter(moment(punches[1], 'YYYY-MM-DD HH:mm').add(MIN_LUNCH_MINUTES, 'minutes')), | |
| fullLeave, | |
| earliestLeave: moment(fullLeave).subtract(tolerance, 'minutes') | |
| }; | |
| } | |
| function formatMinutes(totalMinutes) { | |
| if (!totalMinutes || totalMinutes <= 0) return '0:00'; | |
| return moment.utc(totalMinutes * 60 * 1000).format('H:mm'); | |
| } | |
| function processPaymentData(paymentData) { | |
| try { | |
| if (!paymentData) throw new Error('Dados de pagamento não recebidos.'); | |
| const list = _.filter( | |
| paymentData?.employee?.[0]?.payrollList, | |
| p => moment(p.payday).isSameOrAfter(moment().startOf('day')) | |
| ); | |
| const items = _.sortBy(list, p => moment(p.payday).valueOf()).map(p => { | |
| const daysRemaining = moment(p.payday).endOf('day').diff(moment(), 'days'); | |
| let relative = ''; | |
| if (daysRemaining === 0) relative = 'hoje'; | |
| else if (daysRemaining === 1) relative = 'amanhã'; | |
| else if (daysRemaining > 1) relative = `em ${daysRemaining} dias`; | |
| return { | |
| amount: new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(p.totalAmount), | |
| date: moment(p.payday).format('DD/MM/YYYY'), | |
| relative | |
| }; | |
| }); | |
| return { error: null, items }; | |
| } catch (error) { | |
| log.error('não foi possível interpretar a lista de pagamentos', error); | |
| return { error: 'Não foi possível carregar os pagamentos.', items: [] }; | |
| } | |
| } | |
| async function loadData() { | |
| emitter.emit('data-loading'); | |
| try { | |
| // Período de referência da ADP (ex.: 10/2026 em 23/09), não o mês do calendário. | |
| const reference = await api.fetchReferencePeriod(); | |
| const { year, month } = reference; | |
| const [latestPunchesResponse, timesheetResponse, compTimeResponse, paymentResponse] = await Promise.all([ | |
| api.fetchLatestPunches(), | |
| api.fetchTimesheetForMonth(year, month), | |
| api.fetchCompTime(year, month), | |
| api.fetchPayments() | |
| ]); | |
| todayPunches = mergeAndSortPunches( | |
| extractLatestPunchesToday(latestPunchesResponse), | |
| extractTimesheetPunchesToday(timesheetResponse) | |
| ); | |
| compTimeBalanceMinutes = processCompTimeData(compTimeResponse); | |
| negativeBalanceMinutes = compTimeBalanceMinutes < 0 ? Math.abs(compTimeBalanceMinutes) : 0; | |
| inconsistentDays = checkInconsistentPunches(timesheetResponse); | |
| pendingPayments = processPaymentData(paymentResponse); | |
| calculateAndStoreExitTime(); | |
| logDailySummary(reference); | |
| log.dump('respostas cruas da API', { | |
| reference, | |
| latestPunchesResponse, | |
| timesheetResponse, | |
| compTimeResponse, | |
| paymentResponse | |
| }); | |
| emitter.emit('data-updated', { | |
| punches: todayPunches, | |
| balance: negativeBalanceMinutes, | |
| payments: pendingPayments, | |
| inconsistentDays | |
| }); | |
| } catch (error) { | |
| // Falha de rede já foi logada pelo ApiModule com endpoint e status. | |
| if (!error.path) log.error('falha ao montar os dados do dia', error); | |
| if (pendingPayments === null) { | |
| pendingPayments = { error: 'Falha ao carregar dados.', items: [] }; | |
| } | |
| emitter.emit('logic-error', 'Não foi possível carregar todos os dados (batidas/banco/pagamentos).'); | |
| emitter.emit('data-load-failed'); | |
| } | |
| } | |
| function logDailySummary(reference) { | |
| const times = todayPunches.map(p => p.slice(11)); | |
| const open = todayPunches.length % 2 !== 0 ? ' · em aberto' : ''; | |
| const period = reference ? ` · período ${reference.month}/${reference.year}` : ''; | |
| log.info(`batidas: ${times.length ? times.join(' · ') : 'nenhuma hoje'}${open}${period}`); | |
| const calc = lastCalculationResult; | |
| const parts = [`${formatMinutes(calc.liveWorkedMinutes)} de ${formatMinutes(calc.requiredMinutes)}`]; | |
| if (calc.predictionStatus === 'CAN_ESTIMATE') { | |
| parts.push(calc.canLeaveNow | |
| ? `pode sair agora${calc.withinTolerance ? ' (tolerância)' : ''}` | |
| : calc.limitWarning | |
| ? `saída ${calc.earliestLeaveTime.format('HH:mm')} (limite)` | |
| : `saída ${calc.earliestLeaveTime.format('HH:mm')} (meta cheia ${calc.estimatedLeaveTime.format('HH:mm')})`); | |
| } else if (calc.predictionStatus === 'COMPLETE') { | |
| parts.push(calc.withinTolerance ? 'turno completo (tolerância)' : 'turno completo'); | |
| } else if (calc.projection) { | |
| parts.push(`saída ≈ ${calc.projection.earliestLeave.format('HH:mm')} (estimada com ${calc.projection.lunchMinutes} min de almoço)`); | |
| } | |
| if (calc.limitWarning) { | |
| parts.push('passa das 19:59'); | |
| } else if (calc.leaveMarginMinutes !== null && calc.leaveMarginMinutes < LIMIT_MARGIN_WARN_MINUTES) { | |
| parts.push(`margem até 19:59: ${formatMinutes(calc.leaveMarginMinutes)}`); | |
| } | |
| if (compTimeBalanceMinutes) { | |
| const sign = compTimeBalanceMinutes > 0 ? '+' : '-'; | |
| parts.push(`banco ${sign}${formatMinutes(Math.abs(compTimeBalanceMinutes))}`); | |
| } | |
| if (inconsistentDays.length) parts.push(`${inconsistentDays.length} dia(s) a resolver`); | |
| if (pendingPayments?.items?.length) { | |
| const next = pendingPayments.items[0]; | |
| parts.push(`${pendingPayments.items.length} pagamento(s), próximo em ${next.date.slice(0, 5)}`); | |
| } | |
| log.info(parts.join(' · ')); | |
| } | |
| function calculateAndStoreExitTime() { | |
| const punches = todayPunches; | |
| const punchesCount = punches.length; | |
| const isPunchedIn = punchesCount % 2 !== 0; | |
| const currentWorkedMinutes = calculateWorkedMinutes(punches); | |
| const openMinutes = calculateOpenMinutes(punches); | |
| const totalMinutesRequired = getRequiredMinutes(); | |
| const tolerance = getToleranceMinutes(totalMinutesRequired); | |
| const result = { | |
| punchesCount, | |
| isPunchedIn, | |
| requiredMinutes: totalMinutesRequired, | |
| workedMinutes: currentWorkedMinutes, | |
| openMinutes, | |
| liveWorkedMinutes: currentWorkedMinutes + openMinutes, | |
| now: moment(), | |
| negativeBalance: negativeBalanceMinutes, | |
| extraRequested: extraTimeRequestMinutes, | |
| toleranceMinutes: tolerance, | |
| estimatedLeaveTime: null, // meta cheia | |
| earliestLeaveTime: null, // meta cheia menos a tolerância | |
| canLeaveNow: false, | |
| withinTolerance: false, // pode/pôde sair, mas dentro da folga | |
| isComplete: false, | |
| warning: null, | |
| limitWarning: null, // saída prevista passa do limite das 19:59 | |
| leaveMarginMinutes: null, // minutos entre a saída prevista e as 19:59 | |
| error: null, | |
| lastPunchTime: punchesCount > 0 ? moment(_.last(punches), 'YYYY-MM-DD HH:mm') : null, | |
| projection: null, // previsão com almoço presumido (1–2 batidas) | |
| predictionStatus: 'NONE' | |
| }; | |
| if (punchesCount === 0) { | |
| result.error = 'Nenhuma batida encontrada para hoje.'; | |
| } else if (punchesCount === 1 || punchesCount === 2) { | |
| result.isPunchedIn = punchesCount === 1; | |
| result.predictionStatus = punchesCount === 1 ? 'WAITING_LUNCH_START' : 'WAITING_LUNCH_END'; | |
| result.projection = projectLeaveBeforeLunchEnds(punches, totalMinutesRequired, tolerance); | |
| if (result.projection) { | |
| const limit = getLeaveLimit(); | |
| const leave = result.projection.earliestLeave; | |
| if (leave.isAfter(limit)) { | |
| const overflow = leave.diff(limit, 'minutes'); | |
| result.limitWarning = `Com ${result.projection.lunchMinutes} min de almoço, a saída prevista passa das 19:59 — limite da empresa. Saindo às 19:59, ficam faltando ${formatMinutes(overflow)}.`; | |
| } else { | |
| result.leaveMarginMinutes = limit.diff(leave, 'minutes'); | |
| } | |
| } | |
| } else if (punchesCount >= 3 && isPunchedIn) { | |
| result.predictionStatus = 'CAN_ESTIMATE'; | |
| const remaining = totalMinutesRequired - currentWorkedMinutes; | |
| const toleratedRemaining = remaining - tolerance; | |
| if (toleratedRemaining <= 0) { | |
| // Já está dentro da tolerância: pode bater a saída agora, mesmo | |
| // que a meta cheia só feche alguns minutos depois. | |
| result.canLeaveNow = true; | |
| result.withinTolerance = remaining > 0; | |
| result.earliestLeaveTime = moment(); | |
| result.estimatedLeaveTime = remaining > 0 | |
| ? moment(result.lastPunchTime).add(remaining, 'minutes') | |
| : moment(); | |
| } else { | |
| result.earliestLeaveTime = moment(result.lastPunchTime).add(toleratedRemaining, 'minutes'); | |
| result.estimatedLeaveTime = moment(result.lastPunchTime).add(remaining, 'minutes'); | |
| const limit = getLeaveLimit(); | |
| // A régua é a saída com tolerância: se ela cabe até 19:59, o dia | |
| // fecha, mesmo que a meta cheia passe um pouco do limite. | |
| if (result.earliestLeaveTime.isAfter(limit)) { | |
| const overflow = result.earliestLeaveTime.diff(limit, 'minutes'); | |
| result.limitWarning = `A meta não fecha até 19:59 — limite da empresa. Saia às 19:59; ficam faltando ${formatMinutes(overflow)}.`; | |
| result.earliestLeaveTime = moment(limit); | |
| if (result.estimatedLeaveTime.isAfter(limit)) result.estimatedLeaveTime = moment(limit); | |
| } else { | |
| result.leaveMarginMinutes = limit.diff(result.earliestLeaveTime, 'minutes'); | |
| } | |
| } | |
| } else if (punchesCount >= 4 && !isPunchedIn) { | |
| const missing = totalMinutesRequired - currentWorkedMinutes; | |
| result.isComplete = missing <= tolerance; | |
| result.withinTolerance = result.isComplete && missing > 0; | |
| result.estimatedLeaveTime = result.lastPunchTime; | |
| result.earliestLeaveTime = result.lastPunchTime; | |
| if (result.isComplete) { | |
| result.predictionStatus = 'COMPLETE'; | |
| } else { | |
| result.predictionStatus = 'OUT_INCOMPLETE'; | |
| result.warning = tolerance > 0 | |
| ? `Saída registrada, mas faltaram ${formatMinutes(missing - tolerance)} além da tolerância de ${tolerance} min (${formatMinutes(currentWorkedMinutes)} de ${formatMinutes(totalMinutesRequired)}).` | |
| : `Saída registrada, mas horas insuficientes (${formatMinutes(currentWorkedMinutes)} de ${formatMinutes(totalMinutesRequired)}).`; | |
| } | |
| } | |
| lastCalculationResult = result; | |
| } | |
| /** | |
| * Resumo do status do dia, separado em linha principal + linhas de apoio. | |
| * O UiModule só renderiza — nenhuma regra de negócio fica na UI. | |
| */ | |
| function getStatusSummary() { | |
| // Recalcula a cada leitura: o intervalo aberto cresce com o relógio. | |
| calculateAndStoreExitTime(); | |
| const calc = lastCalculationResult; | |
| const summary = { | |
| headline: 'Sem dados', | |
| headlineClass: 'adph-out', | |
| workedText: '', | |
| remainingText: '', | |
| progressPercent: 0, | |
| barClass: '', | |
| showBar: false, | |
| meta: [] | |
| }; | |
| if (!calc || calc.error) { | |
| summary.headline = calc?.error || 'Sem previsão'; | |
| summary.headlineClass = 'adph-warn'; | |
| appendPendingDayWarnings(summary); | |
| return summary; | |
| } | |
| const live = calc.liveWorkedMinutes; | |
| const required = calc.requiredMinutes; | |
| const remaining = required - live; | |
| if (calc.predictionStatus !== 'NONE') { | |
| summary.showBar = true; | |
| summary.progressPercent = required > 0 ? Math.min(100, Math.round((live / required) * 100)) : 0; | |
| summary.workedText = `${formatMinutes(live)} de ${formatMinutes(required)}`; | |
| // "Faltam" é medido até a tolerância: é a partir dali que dá para sair. | |
| const toleratedRemaining = remaining - calc.toleranceMinutes; | |
| if (toleratedRemaining > 0) summary.remainingText = `faltam ${formatMinutes(toleratedRemaining)}`; | |
| else if (remaining > 0) summary.remainingText = `na tolerância (-${formatMinutes(remaining)})`; | |
| else if (remaining === 0) summary.remainingText = 'meta batida'; | |
| else summary.remainingText = `extra de ${formatMinutes(-remaining)}`; | |
| } | |
| switch (calc.predictionStatus) { | |
| case 'NONE': | |
| summary.headline = 'Nenhuma batida hoje'; | |
| break; | |
| case 'WAITING_LUNCH_START': { | |
| const p = calc.projection; | |
| summary.headlineClass = 'adph-in'; | |
| // "≈" deixa claro que é estimativa: o almoço ainda não aconteceu. | |
| summary.headline = p ? `≈ ${p.earliestLeave.format('HH:mm')}` : 'Trabalhando'; | |
| if (p) { | |
| const full = calc.toleranceMinutes > 0 | |
| ? ` Meta cheia às ${p.fullLeave.format('HH:mm')}.` | |
| : ''; | |
| summary.meta.push({ text: `Estimativa com ${p.lunchMinutes} min de almoço.${full}` }); | |
| } | |
| break; | |
| } | |
| case 'WAITING_LUNCH_END': { | |
| const lunch = getLunchIntervalInfo(todayPunches); | |
| const p = calc.projection; | |
| summary.barClass = 'adph-bar__fill--idle'; | |
| summary.headline = lunch?.isShort | |
| ? `Volte às ${lunch.earliestReturn.format('HH:mm')}` | |
| : 'Em intervalo'; | |
| if (p) { | |
| const when = p.returnIsNow ? 'Voltando agora' : `Voltando às ${p.returnAt.format('HH:mm')}`; | |
| const full = calc.toleranceMinutes > 0 | |
| ? ` (meta cheia ${p.fullLeave.format('HH:mm')})` | |
| : ''; | |
| summary.meta.push({ text: `${when}, saída ≈ ${p.earliestLeave.format('HH:mm')}${full}.` }); | |
| } | |
| break; | |
| } | |
| case 'CAN_ESTIMATE': | |
| if (calc.canLeaveNow) { | |
| summary.headline = 'Pode sair agora'; | |
| summary.headlineClass = 'adph-ok'; | |
| summary.barClass = 'adph-bar__fill--ok'; | |
| } else { | |
| summary.headline = (calc.earliestLeaveTime || calc.estimatedLeaveTime).format('HH:mm'); | |
| summary.headlineClass = 'adph-in'; | |
| } | |
| break; | |
| case 'COMPLETE': | |
| summary.headline = 'Turno completo'; | |
| summary.headlineClass = 'adph-ok'; | |
| summary.barClass = 'adph-bar__fill--ok'; | |
| break; | |
| case 'OUT_INCOMPLETE': | |
| summary.headline = 'Fora do expediente'; | |
| summary.headlineClass = 'adph-warn'; | |
| summary.barClass = 'adph-bar__fill--warn'; | |
| break; | |
| default: | |
| summary.headline = 'Status indisponível'; | |
| } | |
| // Com o limite estourado, o aviso vermelho já diz tudo; a "meta cheia" ficaria falsa. | |
| if (calc.predictionStatus === 'CAN_ESTIMATE' && calc.toleranceMinutes > 0 && !calc.limitWarning) { | |
| if (calc.withinTolerance) { | |
| summary.meta.push({ | |
| text: `Dentro da tolerância de ${calc.toleranceMinutes} min — a meta cheia fecha às ${calc.estimatedLeaveTime.format('HH:mm')}.`, | |
| cls: 'adph-warn' | |
| }); | |
| } else if (!calc.canLeaveNow) { | |
| summary.meta.push({ | |
| text: `Horário com tolerância de ${calc.toleranceMinutes} min. Meta cheia às ${calc.estimatedLeaveTime.format('HH:mm')}.` | |
| }); | |
| } | |
| } | |
| if (calc.predictionStatus === 'COMPLETE' && calc.withinTolerance) { | |
| summary.meta.push({ | |
| text: `Fechou ${formatMinutes(required - calc.workedMinutes)} abaixo da meta cheia, dentro da tolerância de ${calc.toleranceMinutes} min.`, | |
| cls: 'adph-warn' | |
| }); | |
| } | |
| const lunch = getLunchIntervalInfo(todayPunches); | |
| if (lunch) { | |
| if (!lunch.closed && lunch.isShort) { | |
| summary.meta.push({ | |
| text: `Intervalo de ${formatMinutes(lunch.minutes)} — o mínimo é 1h.`, | |
| cls: 'adph-warn' | |
| }); | |
| } else if (lunch.closed && lunch.isShort) { | |
| summary.meta.push({ | |
| text: `⚠️ Intervalo de ${formatMinutes(lunch.minutes)}, abaixo do mínimo de 1h — o dia pode ficar inconsistente.`, | |
| cls: 'adph-debt' | |
| }); | |
| } | |
| } | |
| if (calc.negativeBalance > 0 && calc.extraRequested <= 0) { | |
| const canAbate = Math.min(calc.negativeBalance, MAX_DAILY_EXTRA_MINUTES); | |
| summary.meta.push({ | |
| text: `Banco com débito de ${formatMinutes(calc.negativeBalance)} — dá para abater até ${formatMinutes(canAbate)} hoje em "ajustar meta".`, | |
| cls: 'adph-debt' | |
| }); | |
| } | |
| if (calc.extraRequested > 0) { | |
| summary.meta.push({ text: `Inclui extra solicitado de ${formatMinutes(calc.extraRequested)}.`, cls: 'adph-extra' }); | |
| } else if (calc.extraRequested < 0) { | |
| summary.meta.push({ text: `Meta reduzida em ${formatMinutes(-calc.extraRequested)} para sair mais cedo.`, cls: 'adph-warn' }); | |
| } | |
| if (calc.limitWarning) { | |
| summary.meta.push({ text: `⛔ ${calc.limitWarning}`, cls: 'adph-debt' }); | |
| } else if (calc.leaveMarginMinutes !== null && calc.leaveMarginMinutes < LIMIT_MARGIN_WARN_MINUTES) { | |
| summary.meta.push({ text: `Margem até 19:59: ${formatMinutes(calc.leaveMarginMinutes)}`, cls: 'adph-warn' }); | |
| } | |
| if (calc.warning) { | |
| summary.meta.push({ text: `⚠️ ${calc.warning}`, cls: 'adph-warn' }); | |
| } | |
| appendPendingDayWarnings(summary); | |
| return summary; | |
| } | |
| function appendPendingDayWarnings(summary) { | |
| const pending = inconsistentDays.filter(d => d.isWaitingApproval); | |
| const broken = inconsistentDays.filter(d => d.isInconsistent && !d.hasAdjustment); | |
| if (pending.length) { | |
| summary.meta.push({ text: `⏳ ${pending.length} dia(s) aguardando aprovação: ${pending.map(d => d.date).join(', ')}`, cls: 'adph-warn' }); | |
| } | |
| if (broken.length) { | |
| summary.meta.push({ text: `⚠️ ${broken.length} dia(s) com marcação inconsistente: ${broken.map(d => d.date).join(', ')}`, cls: 'adph-debt' }); | |
| } | |
| } | |
| function getPendingPayments() { | |
| return pendingPayments; | |
| } | |
| /** Saldo do banco de horas com sinal, em minutos. null = não carregado. */ | |
| function getCompTimeBalance() { | |
| return compTimeBalanceMinutes; | |
| } | |
| function getWorkedMinutesToday() { | |
| return calculateWorkedMinutes(todayPunches); | |
| } | |
| /** | |
| * Ajuste da meta do dia em minutos. Aceita negativo (sair mais cedo); | |
| * o positivo para no teto diário de extra. | |
| */ | |
| function setExtraTimeMinutes(minutes) { | |
| extraTimeRequestMinutes = Math.min(Math.round(minutes) || 0, MAX_DAILY_EXTRA_MINUTES); | |
| storeExtraTime(extraTimeRequestMinutes); | |
| calculateAndStoreExitTime(); | |
| emitter.emit('punch-out-time-recalculated'); | |
| return lastCalculationResult; | |
| } | |
| /** direction: 1 soma à meta do dia, -1 desconta (gasta saldo do banco). */ | |
| function setExtraTimeRequest(hhmm, direction = 1) { | |
| const [h, m] = String(hhmm).split(':'); | |
| const hours = parseInt(h, 10); | |
| const minutes = parseInt(m, 10); | |
| if (isNaN(hours) || isNaN(minutes)) { | |
| setExtraTimeMinutes(0); | |
| return { ...lastCalculationResult, error: 'Formato inválido para tempo extra.' }; | |
| } | |
| const sign = direction < 0 ? -1 : 1; | |
| return setExtraTimeMinutes(sign * (hours * 60 + minutes)); | |
| } | |
| /** | |
| * Simula um ajuste sem aplicá-lo, para a prévia do diálogo mostrar as | |
| * duas direções lado a lado antes do usuário escolher. | |
| */ | |
| function previewTargetAdjustment(adjustMinutes) { | |
| const adjust = Math.round(adjustMinutes); | |
| const requiredMinutes = getRequiredMinutes(adjust); | |
| const result = { | |
| requiredMinutes, | |
| leaveTime: null, | |
| canLeaveNow: false, | |
| exceedsDailyLimit: adjust > MAX_DAILY_EXTRA_MINUTES | |
| }; | |
| if (result.exceedsDailyLimit) return result; | |
| if (!todayPunches.length || todayPunches.length % 2 === 0) return result; | |
| const lastPunch = moment(_.last(todayPunches), 'YYYY-MM-DD HH:mm'); | |
| const closed = calculateWorkedMinutes(todayPunches); | |
| // Mesma régua do banner: só há tolerância se a meta continuar em 8h. | |
| const toleratedRemaining = requiredMinutes - getToleranceMinutes(requiredMinutes) - closed; | |
| result.leaveTime = moment(lastPunch).add(Math.max(0, toleratedRemaining), 'minutes'); | |
| result.canLeaveNow = result.leaveTime.isSameOrBefore(moment()); | |
| return result; | |
| } | |
| /** | |
| * Caminho inverso do extra: em vez de "quanto a mais eu fico?", responde | |
| * "se eu sair às HH:MM, quanto sobra ou falta?". | |
| */ | |
| function previewLeaveAt(hhmm) { | |
| const [h, m] = String(hhmm).split(':').map(v => parseInt(v, 10)); | |
| if (isNaN(h) || isNaN(m)) return { error: 'Horário inválido.' }; | |
| if (!todayPunches.length || todayPunches.length % 2 === 0) { | |
| return { error: 'Só dá para projetar com o ponto aberto.' }; | |
| } | |
| const lastPunch = moment(_.last(todayPunches), 'YYYY-MM-DD HH:mm'); | |
| const leaveTime = moment(lastPunch).set({ hour: h, minute: m, second: 0 }); | |
| if (!leaveTime.isAfter(lastPunch)) { | |
| return { error: `Escolha um horário depois da última batida (${lastPunch.format('HH:mm')}).` }; | |
| } | |
| const totalMinutes = calculateWorkedMinutes(todayPunches) + leaveTime.diff(lastPunch, 'minutes'); | |
| const baseline = REQUIRED_WORK_MINUTES; | |
| const deltaMinutes = totalMinutes - baseline; | |
| const tolerance = getToleranceMinutes(baseline); | |
| const exceedsDailyLimit = deltaMinutes > MAX_DAILY_EXTRA_MINUTES; | |
| return { | |
| error: null, | |
| leaveTime, | |
| totalMinutes, | |
| deltaMinutes, | |
| toleranceMinutes: tolerance, | |
| withinTolerance: tolerance > 0 && deltaMinutes < 0 && -deltaMinutes <= tolerance, | |
| exceedsDailyLimit, | |
| maxLeaveTime: exceedsDailyLimit | |
| ? moment(leaveTime).subtract(deltaMinutes - MAX_DAILY_EXTRA_MINUTES, 'minutes') | |
| : null, | |
| afterLimit: leaveTime.isAfter(moment(lastPunch).set({ hour: 19, minute: 59, second: 0 })) | |
| }; | |
| } | |
| function applyLeaveTarget(hhmm) { | |
| const preview = previewLeaveAt(hhmm); | |
| if (preview.error || preview.exceedsDailyLimit) return preview; | |
| setExtraTimeMinutes(preview.deltaMinutes); | |
| return preview; | |
| } | |
| function getPunchPreview() { | |
| const count = todayPunches.length; | |
| const labels = ['entrada', 'saída para o almoço', 'retorno do almoço', 'saída do expediente']; | |
| const last = count ? moment(_.last(todayPunches), 'YYYY-MM-DD HH:mm') : null; | |
| const minutesSinceLast = last ? Math.max(0, moment().diff(last, 'minutes')) : null; | |
| const preview = { | |
| label: labels[count] || (count % 2 === 0 ? 'nova entrada' : 'nova saída'), | |
| time: moment().format('HH:mm'), | |
| minutesSinceLast, | |
| isDuplicateRisk: minutesSinceLast !== null && minutesSinceLast < 5, | |
| warnings: [], | |
| notes: [] | |
| }; | |
| if (preview.isDuplicateRisk) { | |
| preview.warnings.push(`A última batida foi há ${minutesSinceLast} min. Batidas muito próximas podem ser descartadas pela regra de tolerância.`); | |
| } | |
| if (count === 1) { | |
| // Batendo a saída para o almoço agora: já diz quando voltar e o que isso dá no fim do dia. | |
| const required = getRequiredMinutes(); | |
| const simulated = [...todayPunches, moment().format('YYYY-MM-DD HH:mm')]; | |
| const p = projectLeaveBeforeLunchEnds(simulated, required, getToleranceMinutes(required)); | |
| if (p) { | |
| preview.notes.push(`Volte às ${p.returnAt.format('HH:mm')} para fechar 1h de intervalo — saída prevista ≈ ${p.earliestLeave.format('HH:mm')}.`); | |
| } | |
| } | |
| if (count === 2) { | |
| const lunch = getLunchIntervalInfo(todayPunches); | |
| if (lunch?.isShort) { | |
| preview.warnings.push(`O intervalo ficaria com ${formatMinutes(lunch.minutes)}, abaixo do mínimo de 1h. Volte às ${lunch.earliestReturn.format('HH:mm')} para evitar inconsistência.`); | |
| } | |
| } | |
| if (count >= 3 && count % 2 !== 0) { | |
| const live = calculateWorkedMinutes(todayPunches) + calculateOpenMinutes(todayPunches); | |
| const required = getRequiredMinutes(); | |
| const tolerance = getToleranceMinutes(required); | |
| const remaining = required - live; | |
| const toleratedRemaining = remaining - tolerance; | |
| if (toleratedRemaining > 0) { | |
| const withTolerance = tolerance > 0 ? `, já contando a tolerância de ${tolerance} min` : ''; | |
| preview.warnings.push(`Faltam ${formatMinutes(toleratedRemaining)} para a meta de hoje${withTolerance} (${formatMinutes(live)} de ${formatMinutes(required)}).`); | |
| } else if (remaining > 0) { | |
| preview.notes.push(`Dentro da tolerância de ${tolerance} min: ${formatMinutes(live)} de ${formatMinutes(required)}, faltam ${formatMinutes(remaining)} para as 8h.`); | |
| } else { | |
| preview.notes.push(`Meta cumprida: ${formatMinutes(live)} de ${formatMinutes(required)}.`); | |
| } | |
| } | |
| return preview; | |
| } | |
| function clearExtraTimeRequest() { | |
| extraTimeRequestMinutes = 0; | |
| storeExtraTime(0); | |
| calculateAndStoreExitTime(); | |
| emitter.emit('punch-out-time-recalculated'); | |
| } | |
| function notifyPunchAttempt() { | |
| emitter.emit('punch-attempted', { | |
| timestamp: new Date().toISOString(), | |
| currentPunches: todayPunches.length, | |
| nextPunchType: todayPunches.length % 2 === 0 ? 'IN' : 'OUT' | |
| }); | |
| } | |
| return { | |
| loadData, | |
| getStatusSummary, | |
| getPendingPayments, | |
| getCompTimeBalance, | |
| getPunchPreview, | |
| previewTargetAdjustment, | |
| previewLeaveAt, | |
| applyLeaveTarget, | |
| getWorkedMinutesToday, | |
| setExtraTimeRequest, | |
| clearExtraTimeRequest, | |
| notifyPunchAttempt, | |
| getMaxDailyExtraMinutes: () => MAX_DAILY_EXTRA_MINUTES | |
| }; | |
| })(ApiModule); | |
| // =============================================================== | |
| // UI MODULE | |
| // =============================================================== | |
| const UiModule = (function (logic) { | |
| let updateIntervalId = null; | |
| const BANNER_ID = 'adph-punch-banner'; | |
| const PAY_BADGE_ID = 'adph-payments-badge'; | |
| const COMP_BADGE_ID = 'adph-comptime-badge'; | |
| const BTN_EXTRA_ID = 'adph-btn-extra'; | |
| const BTN_LEAVE_ID = 'adph-btn-leave'; | |
| // Liberação de uma única batida após a confirmação do usuário. | |
| let allowNextPunch = false; | |
| let allowNextPunchTimer = null; | |
| let confirmInFlight = false; | |
| const PUNCH_TESTID = 'btn_punch_gadget-punch-in'; | |
| function escapeAttr(value) { | |
| return String(value) | |
| .replace(/&/g, '&') | |
| .replace(/"/g, '"') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>'); | |
| } | |
| /* ---------------- Alvos no DOM ---------------- */ | |
| function findPunchButton() { | |
| const byTestId = $(`sdf-button[data-testid="${PUNCH_TESTID}"]`).filter(':visible').first(); | |
| if (byTestId.length) return byTestId; | |
| return $("sdf-button:contains('Bater Ponto')") | |
| .filter((_i, el) => isPunchButton(el)) | |
| .filter(':visible') | |
| .first(); | |
| } | |
| /** | |
| * O botão de confirmar do SweetAlert também é um <button> cujo texto | |
| * fala de bater ponto, então casar só pelo texto fazia o handler | |
| * interceptar o próprio modal. Daí o data-testid e a exigência de | |
| * estar na árvore do app, fora de qualquer diálogo nosso. | |
| */ | |
| function isPunchButton(el) { | |
| if (!el || typeof el.closest !== 'function') return false; | |
| if (el.closest('.swal2-container')) return false; | |
| if (el.getAttribute('data-testid') === PUNCH_TESTID) return true; | |
| if (!el.closest('#js-react-app')) return false; | |
| const label = `${el.getAttribute('aria-label') || ''} ${el.textContent || ''}`; | |
| return /bater\s+ponto/i.test(label); | |
| } | |
| /** | |
| * O banner fica logo abaixo do bloco do relógio (círculo + hora + botão), | |
| * dentro da mesma coluna — não esticado na largura do card. A âncora é o | |
| * ancestral mais próximo do botão que também contém o <time> da última batida. | |
| */ | |
| function findBannerAnchor(btn) { | |
| const clockRow = btn.parents('div').filter(function () { | |
| return $(this).find('time').length > 0; | |
| }).first(); | |
| if (clockRow.length) return clockRow; | |
| const byClass = btn.closest('.display-flex.py3-5'); | |
| if (byClass.length) return byClass; | |
| const wrapper = btn.closest('div'); | |
| return wrapper.parent().length ? wrapper.parent() : wrapper; | |
| } | |
| /** | |
| * Título de card cujo texto casa com o padrão. O selo entra dentro dele, | |
| * então o próprio selo é ignorado na checagem — senão, no segundo ciclo | |
| * o título deixaria de ser "folha" e não seria mais encontrado. | |
| */ | |
| function findCardTitle(pattern, badgeId) { | |
| return $('h1,h2,h3,h4,h5,h6,span,div,p') | |
| .filter(function () { | |
| const el = $(this); | |
| if (el.children().not(`#${badgeId}`).length) return false; | |
| const text = el.clone().find(`#${badgeId}`).remove().end().text().trim(); | |
| return pattern.test(text); | |
| }) | |
| .filter(':visible') | |
| .first(); | |
| } | |
| /** Duplicata proposital: o formatMinutes do LogicModule é privado. */ | |
| function formatMinutesUi(totalMinutes) { | |
| if (!totalMinutes || totalMinutes <= 0) return '0:00'; | |
| return moment.utc(totalMinutes * 60 * 1000).format('H:mm'); | |
| } | |
| /* ---------------- Banner do card Batida ---------------- */ | |
| function bannerTemplate() { | |
| return ` | |
| <div id="${BANNER_ID}" class="adph-banner" role="status" aria-live="polite"> | |
| <div class="adph-banner__row"> | |
| <span class="adph-banner__worked"></span> | |
| <span class="adph-banner__headline">Carregando…</span> | |
| </div> | |
| <div class="adph-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100"> | |
| <div class="adph-bar__fill"></div> | |
| </div> | |
| <div class="adph-banner__row adph-banner__row--foot"> | |
| <span class="adph-banner__remaining"></span> | |
| <span class="adph-banner__actions"> | |
| <button type="button" class="adph-link-btn" id="${BTN_EXTRA_ID}">ajustar meta</button> | |
| <button type="button" class="adph-link-btn" id="${BTN_LEAVE_ID}">sair às…</button> | |
| </span> | |
| </div> | |
| <div class="adph-banner__meta"></div> | |
| </div>`; | |
| } | |
| function injectBanner() { | |
| if ($(`#${BANNER_ID}`).length) return true; | |
| const btn = findPunchButton(); | |
| if (!btn.length) { | |
| log.once('punch-btn', 'botão "Bater Ponto" não encontrado — banner não injetado'); | |
| return false; | |
| } | |
| const row = btn.closest('tr'); | |
| if (row.length) { | |
| const cols = row.children('td, th').length || 1; | |
| row.after(`<tr class="adph-banner-row"><td colspan="${cols}">${bannerTemplate()}</td></tr>`); | |
| } else { | |
| findBannerAnchor(btn).after(bannerTemplate()); | |
| } | |
| $(`#${BTN_EXTRA_ID}`).off('click.adph').on('click.adph', handleClickAdjustTarget); | |
| $(`#${BTN_LEAVE_ID}`).off('click.adph').on('click.adph', handleClickLeaveTarget); | |
| return true; | |
| } | |
| function renderBanner() { | |
| const banner = $(`#${BANNER_ID}`); | |
| if (!banner.length) return; | |
| const summary = logic.getStatusSummary(); | |
| banner.find('.adph-banner__headline') | |
| .attr('class', `adph-banner__headline ${summary.headlineClass || ''}`) | |
| .text(summary.headline); | |
| banner.find('.adph-banner__worked').text(summary.workedText || ''); | |
| banner.find('.adph-banner__remaining').text(summary.remainingText || ''); | |
| banner.find('.adph-bar') | |
| .toggle(!!summary.showBar) | |
| .attr('aria-valuenow', summary.progressPercent); | |
| banner.find('.adph-bar__fill') | |
| .attr('class', `adph-bar__fill ${summary.barClass || ''}`) | |
| .css('width', `${summary.progressPercent}%`); | |
| banner.find('.adph-banner__meta').html( | |
| summary.meta.map(m => `<span class="${m.cls || ''}">${m.text}</span>`).join('') | |
| ); | |
| } | |
| /* ---------------- Selos nos títulos dos cards ---------------- */ | |
| function renderBadge({ badgeId, titlePattern, html, warnKey, warnMessage }) { | |
| const title = findCardTitle(titlePattern, badgeId); | |
| if (!title.length) { | |
| log.once(warnKey, warnMessage); | |
| return false; | |
| } | |
| const existing = $(`#${badgeId}`); | |
| if (existing.length) existing.replaceWith(html); | |
| else title.append(html); | |
| return true; | |
| } | |
| function paymentsBadgeTemplate(payments) { | |
| if (payments === null) { | |
| return `<span id="${PAY_BADGE_ID}" class="adph-badge adph-badge--quiet">Carregando…</span>`; | |
| } | |
| if (payments.error) { | |
| return `<span id="${PAY_BADGE_ID}" class="adph-badge adph-badge--warn">${payments.error}</span>`; | |
| } | |
| if (!payments.items.length) { | |
| return `<span id="${PAY_BADGE_ID}" class="adph-badge adph-badge--quiet">Nada a receber</span>`; | |
| } | |
| const [next, ...rest] = payments.items; | |
| const shortDate = next.date.slice(0, 5); | |
| const more = rest.length ? `<span class="adph-badge__more">+${rest.length}</span>` : ''; | |
| const tooltip = payments.items | |
| .map(p => `${p.amount} em ${p.date}${p.relative ? ` (${p.relative})` : ''}`) | |
| .join('\n'); | |
| return `<span id="${PAY_BADGE_ID}" class="adph-badge adph-badge--ok" title="${escapeAttr(tooltip)}"> | |
| <span class="adph-badge__value">${next.amount}</span> | |
| <span class="adph-badge__when">em ${shortDate}</span> | |
| ${more} | |
| </span>`; | |
| } | |
| function compTimeBadgeTemplate(balance) { | |
| if (balance === null || balance === undefined) { | |
| return `<span id="${COMP_BADGE_ID}" class="adph-badge adph-badge--quiet">Banco indisponível</span>`; | |
| } | |
| if (balance === 0) { | |
| return `<span id="${COMP_BADGE_ID}" class="adph-badge adph-badge--quiet">Banco zerado</span>`; | |
| } | |
| const positive = balance > 0; | |
| const variant = positive ? 'adph-badge--ok' : 'adph-badge--debt'; | |
| const sign = positive ? '+' : '-'; | |
| const tooltip = positive | |
| ? 'Saldo a seu favor no banco de horas.' | |
| : `Horas devidas ao banco. Para abater hoje, use "ajustar meta" → Somar (até +${formatMinutesUi(logic.getMaxDailyExtraMinutes())} por dia).`; | |
| return `<span id="${COMP_BADGE_ID}" class="adph-badge ${variant}" title="${escapeAttr(tooltip)}"> | |
| <span class="adph-badge__when">Banco</span> | |
| <span class="adph-badge__value">${sign}${formatMinutesUi(Math.abs(balance))}</span> | |
| </span>`; | |
| } | |
| function renderPayments() { | |
| return renderBadge({ | |
| badgeId: PAY_BADGE_ID, | |
| titlePattern: /^pagamentos?$/i, | |
| html: paymentsBadgeTemplate(logic.getPendingPayments()), | |
| warnKey: 'pay-title', | |
| warnMessage: 'título "Pagamentos" não encontrado — selo não injetado' | |
| }); | |
| } | |
| function renderCompTime() { | |
| return renderBadge({ | |
| badgeId: COMP_BADGE_ID, | |
| titlePattern: /^ponto$/i, | |
| html: compTimeBadgeTemplate(logic.getCompTimeBalance()), | |
| warnKey: 'comp-title', | |
| warnMessage: 'título "Ponto" não encontrado — selo do banco de horas não injetado' | |
| }); | |
| } | |
| /* ---------------- Ciclo de atualização ---------------- */ | |
| function refresh() { | |
| injectBanner(); | |
| renderBanner(); | |
| renderPayments(); | |
| renderCompTime(); | |
| } | |
| function startClockUpdate() { | |
| if (updateIntervalId) clearInterval(updateIntervalId); | |
| refresh(); | |
| updateIntervalId = setInterval(refresh, 30000); | |
| } | |
| /** | |
| * O clique é interceptado na fase de captura, no document, porque os | |
| * listeners internos do sdf-button rodam antes de qualquer handler que | |
| * a gente registre no próprio elemento. | |
| */ | |
| function punchCaptureHandler(event) { | |
| const target = event.target; | |
| if (!target || typeof target.closest !== 'function') return; | |
| if (target.closest('.swal2-container')) return; | |
| const btn = target.closest('sdf-button, button'); | |
| if (!btn || !isPunchButton(btn)) return; | |
| if (allowNextPunch) { | |
| allowNextPunch = false; | |
| clearTimeout(allowNextPunchTimer); | |
| return; // deixa a batida passar | |
| } | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| if (confirmInFlight) return; | |
| confirmInFlight = true; | |
| confirmPunch().then((confirmed) => { | |
| confirmInFlight = false; | |
| if (!confirmed) return; | |
| logic.notifyPunchAttempt(); | |
| allowNextPunch = true; | |
| // Se o replay não chegar até aqui, a trava se fecha sozinha. | |
| allowNextPunchTimer = setTimeout(() => { allowNextPunch = false; }, 3000); | |
| const inner = btn.shadowRoot && btn.shadowRoot.querySelector('button, [role="button"]'); | |
| (inner || btn).click(); | |
| setTimeout(() => window.location.reload(), 2000); | |
| }); | |
| } | |
| async function confirmPunch() { | |
| const preview = logic.getPunchPreview(); | |
| const notes = preview.notes | |
| .map(n => `<div class="adph-confirm-note">${n}</div>`) | |
| .join(''); | |
| const warnings = preview.warnings | |
| .map(w => `<div class="adph-confirm-warn">${w}</div>`) | |
| .join(''); | |
| const { isConfirmed } = await Swal.fire({ | |
| title: `Registrar ${preview.label}?`, | |
| html: `<div class="adph-confirm-time">${preview.time}</div>${notes}${warnings}`, | |
| icon: preview.warnings.length ? 'warning' : 'question', | |
| showCancelButton: true, | |
| confirmButtonText: 'Confirmar batida', | |
| cancelButtonText: 'Cancelar', | |
| focusCancel: preview.isDuplicateRisk | |
| }); | |
| return isConfirmed; | |
| } | |
| /* ---------------- Handlers ---------------- */ | |
| async function handleClickAdjustTarget() { | |
| let flatpickrInstance = null; | |
| const balance = logic.getCompTimeBalance(); | |
| const maxExtra = logic.getMaxDailyExtraMinutes(); | |
| const balanceLine = typeof balance === 'number' && balance !== 0 | |
| ? `<div class="adph-dialog-hint">Saldo no banco: ${balance > 0 ? '+' : '-'}${formatMinutesUi(Math.abs(balance))}</div>` | |
| : ''; | |
| const readValue = () => { | |
| const input = document.getElementById('flatpickr-extra-time'); | |
| return input && input.value ? input.value : '00:00'; | |
| }; | |
| const result = await Swal.fire({ | |
| title: 'Ajustar a meta de hoje', | |
| html: ` | |
| <div>Quanto tempo somar ao turno, ou descontar gastando saldo do banco.</div> | |
| ${balanceLine} | |
| <div class="adph-dialog-hint">Máximo de +${formatMinutesUi(maxExtra)} de extra por dia.</div> | |
| <input id="flatpickr-extra-time" class="swal2-input" placeholder="00:00"> | |
| <div id="adph-extra-preview" class="adph-preview">Escolha um tempo.</div> | |
| <div class="adph-dialog-hint">Selecione 00:00 para voltar à meta padrão.</div> | |
| `, | |
| showCancelButton: true, | |
| showDenyButton: true, | |
| confirmButtonText: 'Somar', | |
| denyButtonText: 'Descontar', | |
| cancelButtonText: 'Cancelar', | |
| didOpen: () => { | |
| flatpickrInstance = flatpickr('#flatpickr-extra-time', { | |
| enableTime: true, | |
| noCalendar: true, | |
| dateFormat: 'H:i', | |
| time_24hr: true, | |
| minuteIncrement: 5, | |
| defaultHour: 0, | |
| defaultMinute: 0, | |
| onChange: () => updateAdjustPreview(balance) | |
| }); | |
| }, | |
| willClose: () => { | |
| if (flatpickrInstance) { | |
| flatpickrInstance.destroy(); | |
| flatpickrInstance = null; | |
| } | |
| }, | |
| preConfirm: () => { | |
| const value = readValue(); | |
| const [h, m] = value.split(':').map(v => parseInt(v, 10)); | |
| if (h * 60 + m > maxExtra) { | |
| Swal.showValidationMessage(`O máximo de extra por dia é +${formatMinutesUi(maxExtra)}.`); | |
| return false; | |
| } | |
| return value; | |
| }, | |
| preDeny: readValue | |
| }); | |
| if (!result.isConfirmed && !result.isDenied) return; | |
| const hhmm = result.value; | |
| if (!hhmm || hhmm === '00:00') { | |
| logic.clearExtraTimeRequest(); | |
| return; | |
| } | |
| const applied = logic.setExtraTimeRequest(hhmm, result.isDenied ? -1 : 1); | |
| if (applied.error) { | |
| Swal.fire({ icon: 'error', title: 'Não deu certo', text: applied.error }); | |
| } | |
| } | |
| /** Mostra as duas direções ao mesmo tempo, já que a escolha vem no botão. */ | |
| function updateAdjustPreview(balance) { | |
| const input = document.getElementById('flatpickr-extra-time'); | |
| const box = document.getElementById('adph-extra-preview'); | |
| if (!input || !box) return; | |
| const [h, m] = String(input.value).split(':').map(v => parseInt(v, 10)); | |
| const minutes = (isNaN(h) || isNaN(m)) ? 0 : h * 60 + m; | |
| if (!minutes) { | |
| box.className = 'adph-preview'; | |
| box.textContent = 'Escolha um tempo.'; | |
| return; | |
| } | |
| const describe = (preview) => { | |
| if (preview.exceedsDailyLimit) return `acima do limite de +${formatMinutesUi(logic.getMaxDailyExtraMinutes())}/dia`; | |
| if (preview.canLeaveNow) return 'pode sair agora'; | |
| if (preview.leaveTime) return `saída ${preview.leaveTime.format('HH:mm')}`; | |
| return `meta de ${formatMinutesUi(preview.requiredMinutes)}`; | |
| }; | |
| const plus = describe(logic.previewTargetAdjustment(minutes)); | |
| const minus = describe(logic.previewTargetAdjustment(-minutes)); | |
| let html = `<div class="adph-extra">Somando → ${plus}</div>` | |
| + `<div class="adph-warn">Descontando → ${minus}</div>`; | |
| if (typeof balance === 'number' && minutes > Math.max(0, balance)) { | |
| html += `<div class="adph-dialog-hint adph-debt">Descontar ${formatMinutesUi(minutes)} vai além do saldo disponível.</div>`; | |
| } | |
| box.className = 'adph-preview'; | |
| box.innerHTML = html; | |
| } | |
| async function handleClickLeaveTarget() { | |
| let flatpickrInstance = null; | |
| const { isConfirmed, value } = await Swal.fire({ | |
| title: 'Sair a que horas?', | |
| html: ` | |
| <div>Escolha o horário e veja quanto sobra ou falta no fim do dia.</div> | |
| <input id="flatpickr-leave-time" class="swal2-input" placeholder="17:00"> | |
| <div id="adph-leave-preview" class="adph-preview">Escolha um horário.</div> | |
| `, | |
| showCancelButton: true, | |
| cancelButtonText: 'Fechar', | |
| confirmButtonText: 'Usar como meta', | |
| didOpen: () => { | |
| flatpickrInstance = flatpickr('#flatpickr-leave-time', { | |
| enableTime: true, | |
| noCalendar: true, | |
| dateFormat: 'H:i', | |
| time_24hr: true, | |
| minuteIncrement: 5, | |
| onChange: updateLeavePreview | |
| }); | |
| }, | |
| willClose: () => { | |
| if (flatpickrInstance) { | |
| flatpickrInstance.destroy(); | |
| flatpickrInstance = null; | |
| } | |
| }, | |
| preConfirm: () => { | |
| const input = document.getElementById('flatpickr-leave-time'); | |
| const chosen = input ? input.value : ''; | |
| if (!chosen) { | |
| Swal.showValidationMessage('Escolha um horário.'); | |
| return false; | |
| } | |
| const preview = logic.previewLeaveAt(chosen); | |
| if (preview.error) { | |
| Swal.showValidationMessage(preview.error); | |
| return false; | |
| } | |
| if (preview.exceedsDailyLimit) { | |
| Swal.showValidationMessage(`Passa do limite de extra do dia. O máximo é sair às ${preview.maxLeaveTime.format('HH:mm')}.`); | |
| return false; | |
| } | |
| return chosen; | |
| } | |
| }); | |
| if (isConfirmed && value) logic.applyLeaveTarget(value); | |
| } | |
| function updateLeavePreview() { | |
| const input = document.getElementById('flatpickr-leave-time'); | |
| const box = document.getElementById('adph-leave-preview'); | |
| if (!input || !box) return; | |
| const preview = logic.previewLeaveAt(input.value); | |
| if (preview.error) { | |
| box.className = 'adph-preview adph-warn'; | |
| box.textContent = preview.error; | |
| return; | |
| } | |
| const total = formatMinutesUi(preview.totalMinutes); | |
| const delta = preview.deltaMinutes; | |
| if (preview.exceedsDailyLimit) { | |
| box.className = 'adph-preview adph-debt'; | |
| box.textContent = `Total de ${total} no dia — passa do limite de +${formatMinutesUi(logic.getMaxDailyExtraMinutes())} de extra. O máximo é sair às ${preview.maxLeaveTime.format('HH:mm')}.`; | |
| } else if (delta > 0) { | |
| box.className = 'adph-preview adph-extra'; | |
| box.textContent = `Total de ${total} no dia — fecha com +${formatMinutesUi(delta)} de crédito.`; | |
| } else if (delta === 0) { | |
| box.className = 'adph-preview adph-ok'; | |
| box.textContent = `Total de ${total} no dia — fecha exatamente na meta.`; | |
| } else if (preview.withinTolerance) { | |
| box.className = 'adph-preview adph-warn'; | |
| box.textContent = `Total de ${total} no dia — ${formatMinutesUi(-delta)} abaixo da meta, dentro da tolerância de ${preview.toleranceMinutes} min.`; | |
| } else { | |
| box.className = 'adph-preview adph-debt'; | |
| box.textContent = `Total de ${total} no dia — fica devendo ${formatMinutesUi(-delta)}.`; | |
| } | |
| if (preview.afterLimit) { | |
| box.textContent += ' Atenção: passa das 19:59.'; | |
| } | |
| } | |
| function showBannerError(message) { | |
| injectBanner(); | |
| const banner = $(`#${BANNER_ID}`); | |
| banner.find('.adph-banner__headline') | |
| .attr('class', 'adph-banner__headline adph-debt') | |
| .text(message); | |
| banner.find('.adph-banner__worked, .adph-banner__remaining').empty(); | |
| banner.find('.adph-bar').hide(); | |
| banner.find('.adph-banner__meta').empty(); | |
| } | |
| /* ---------------- Inicialização ---------------- */ | |
| function initialize() { | |
| emitter.on('data-loading', () => { | |
| $(`#${BANNER_ID} .adph-banner__headline`).text('Carregando…'); | |
| }); | |
| document.addEventListener('click', punchCaptureHandler, true); | |
| emitter.on('data-updated', () => { | |
| waitForKeyElements("sdf-button:contains('Bater Ponto')", () => { | |
| startClockUpdate(); | |
| }); | |
| }); | |
| emitter.on('data-load-failed', () => { | |
| showBannerError('Não foi possível carregar os dados do ponto.'); | |
| }); | |
| emitter.on('punch-out-time-recalculated', renderBanner); | |
| emitter.on('api-error', (message) => { | |
| Swal.fire({ icon: 'error', title: 'Erro de comunicação', text: message || 'Falha ao falar com a API da ADP.' }); | |
| showBannerError('Erro de comunicação com a ADP.'); | |
| }); | |
| emitter.on('logic-error', (message) => { | |
| showBannerError(message || 'Erro ao processar os dados.'); | |
| }); | |
| } | |
| return { initialize }; | |
| })(LogicModule); | |
| // =============================================================== | |
| // INICIALIZAÇÃO | |
| // =============================================================== | |
| function start() { | |
| log.debug('v0.7.4 iniciado'); | |
| UiModule.initialize(); | |
| LogicModule.loadData(); | |
| } | |
| // document-idle roda no DOMContentLoaded; aqui esperamos o load completo. | |
| // O readyState cobre o caso de o load já ter acontecido antes do script entrar. | |
| if (document.readyState === 'complete') { | |
| start(); | |
| } else { | |
| window.addEventListener('load', start, { once: true }); | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment