Skip to content

Instantly share code, notes, and snippets.

@tsantos8080
Last active September 28, 2026 16:39
Show Gist options
  • Select an option

  • Save tsantos8080/17ae52ff2790c0a92178e0e2f62af4ff to your computer and use it in GitHub Desktop.

Select an option

Save tsantos8080/17ae52ff2790c0a92178e0e2f62af4ff to your computer and use it in GitHub Desktop.
ADP userscript
// ==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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
/* ---------------- 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