/** * Chatwoot Work Schedule V14 * Dashboard Script + n8n Data Tables * * Regras principais: * - Account config: account_id * - Profile: account_id + profile_id * - User: account_id + user_id * - O id interno da Data Table do n8n nunca e usado pelo navegador. * * Endpoints n8n esperados: * GET /work-schedule/accounts/list * GET /work-schedule/accounts/status?account_id=... * POST /work-schedule/accounts/toggle * GET /work-schedule/profiles/list?account_id=... * POST /work-schedule/profiles/save * POST /work-schedule/profiles/delete * GET /work-schedule/check?account_id=...&user_id=... */ (function () { 'use strict'; const VERSION = '14.1.0'; const INSTANCE_KEY = '__CW_WORK_SCHEDULE_V14__'; if (window[INSTANCE_KEY]?.destroy) { try { window[INSTANCE_KEY].destroy(); } catch (_) {} } const CONFIG = { debug: true, n8nBaseURL: 'https://webhookn8n.agencia4am-automacoes.com.br/webhook', masterAccountIds: ['54', '57', '1'], checkIntervalMs: 60_000, contextPollMs: 1_500, defaultTimezone: 'America/Sao_Paulo', defaultWarningMinutes: 15, moduleLabel: 'Horários de trabalho', }; const DAYS = [ { key: 'seg', short: 'Seg', label: 'Segunda-feira' }, { key: 'ter', short: 'Ter', label: 'Terça-feira' }, { key: 'qua', short: 'Qua', label: 'Quarta-feira' }, { key: 'qui', short: 'Qui', label: 'Quinta-feira' }, { key: 'sex', short: 'Sex', label: 'Sexta-feira' }, { key: 'sab', short: 'Sáb', label: 'Sábado' }, { key: 'dom', short: 'Dom', label: 'Domingo' }, ]; const STATE = { destroyed: false, currentUser: null, accountId: null, accountRole: 'agent', isAdminOfAccount: false, isMaster: false, feature: { loaded: false, enabled: false, timezone: CONFIG.defaultTimezone, warningMinutes: CONFIG.defaultWarningMinutes, }, pageOpen: false, pageMode: null, pageRoot: null, menuItem: null, menuObserver: null, lockObserver: null, checkTimer: null, contextTimer: null, currentAccess: null, profiles: [], agents: [], companies: [], editingProfileId: null, pendingNewProfile: null, lastAccountSeen: null, listeners: [], }; function log(...args) { if (!CONFIG.debug) return; console.log(`[WorkSchedule V${VERSION}]`, ...args); } function logError(...args) { console.error(`[WorkSchedule V${VERSION}]`, ...args); } function addListener(target, event, handler, options) { target.addEventListener(event, handler, options); STATE.listeners.push(() => target.removeEventListener(event, handler, options) ); } function escapeHtml(value) { return String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function escapeAttr(value) { return escapeHtml(value).replace(/`/g, '`'); } function safeJsonParse(value, fallback) { if ( value === null || value === undefined || value === '' ) { return fallback; } if (typeof value === 'object') { return value; } try { return JSON.parse(value); } catch (_) { return fallback; } } function uniqueStrings(values) { return [ ...new Set( (Array.isArray(values) ? values : []) .map(v => String(v)) .filter(Boolean) ), ]; } function makeProfileId() { if (window.crypto?.randomUUID) { return `prof_${window.crypto.randomUUID()}`; } return ( `prof_${Date.now()}_` + Math.random() .toString(36) .slice(2, 10) ); } function getAccountIdFromLocation() { const match = window.location.pathname.match( /\/accounts\/(\d+)/ ); if (match) { return String(match[1]); } const switcher = document.getElementById( 'sidebar-account-switcher' ); if ( switcher?.dataset?.accountId ) { return String( switcher.dataset.accountId ); } if ( window.chatwootConfig ?.currentAccountId ) { return String( window.chatwootConfig .currentAccountId ); } return null; } function getAuthHeaders() { const headers = {}; try { const common = window.axios?.defaults ?.headers?.common || {}; [ 'access-token', 'token-type', 'client', 'expiry', 'uid', ].forEach(key => { if ( common[key] !== undefined && common[key] !== null && common[key] !== '' ) { headers[key] = common[key]; } }); } catch (_) {} return headers; } async function chatwootRequest( path, options = {} ) { const headers = { Accept: 'application/json', ...getAuthHeaders(), ...(options.headers || {}), }; if ( options.body && !headers['Content-Type'] ) { headers['Content-Type'] = 'application/json'; } const response = await fetch(path, { credentials: 'same-origin', ...options, headers, }); let data = null; const text = await response.text(); if (text) { try { data = JSON.parse(text); } catch (_) { data = text; } } if (!response.ok) { const err = new Error( `Chatwoot HTTP ${response.status}` ); err.status = response.status; err.data = data; throw err; } return data; } async function n8nRequest( path, options = {} ) { const url = `${CONFIG.n8nBaseURL}${path}`; const headers = { Accept: 'application/json', ...(options.headers || {}), }; if ( options.body && !headers['Content-Type'] ) { headers['Content-Type'] = 'application/json'; } const response = await fetch(url, { cache: 'no-store', ...options, headers, }); const text = await response.text(); let data = null; if (text) { try { data = JSON.parse(text); } catch (_) { data = { raw: text, }; } } if (!response.ok) { const err = new Error( `N8N HTTP ${response.status}` ); err.status = response.status; err.data = data; throw err; } return data || {}; } function normalizeRole( profileData, accountId ) { const account = Array.isArray( profileData?.accounts ) ? profileData.accounts.find( a => String(a.id) === String(accountId) ) : null; const role = account?.role || account?.user_role || profileData?.role || window.chatwootConfig ?.currentUserRole || 'agent'; return String( role || 'agent' ).toLowerCase(); } async function loadContext( force = false ) { const accountId = getAccountIdFromLocation(); if (!accountId) { return false; } const accountChanged = String(accountId) !== String( STATE.accountId || '' ); if ( !force && STATE.currentUser && !accountChanged ) { return true; } let profileData = null; try { profileData = await chatwootRequest( '/api/v1/profile' ); } catch (error) { logError( 'Falha ao carregar /api/v1/profile', error ); } if (profileData?.id) { STATE.currentUser = profileData; } else if ( window.chatwootConfig ?.currentUserId ) { STATE.currentUser = { id: window.chatwootConfig .currentUserId, name: window.chatwootConfig .currentUserName || 'Usuário', role: window.chatwootConfig .currentUserRole || 'agent', accounts: [], }; } if (!STATE.currentUser?.id) { return false; } STATE.accountId = String(accountId); STATE.accountRole = normalizeRole( profileData || STATE.currentUser, STATE.accountId ); STATE.isAdminOfAccount = STATE.accountRole === 'administrator' || STATE.accountRole === 'admin'; STATE.isMaster = CONFIG.masterAccountIds.includes( STATE.accountId ) && STATE.isAdminOfAccount; STATE.lastAccountSeen = STATE.accountId; await refreshFeatureStatus(); return true; } async function refreshFeatureStatus() { if (!STATE.accountId) { return false; } if (STATE.isMaster) { STATE.feature = { loaded: true, enabled: true, timezone: CONFIG.defaultTimezone, warningMinutes: CONFIG.defaultWarningMinutes, }; refreshMenuVisibility(); return true; } try { const data = await n8nRequest( `/work-schedule/accounts/status?account_id=${encodeURIComponent( STATE.accountId )}&_t=${Date.now()}` ); STATE.feature = { loaded: true, enabled: data.enabled === true && data.feature_disabled !== true, timezone: data.timezone || CONFIG.defaultTimezone, warningMinutes: Number.isFinite( Number( data.warning_minutes ) ) ? Number( data.warning_minutes ) : CONFIG.defaultWarningMinutes, }; } catch (error) { logError( 'Falha ao consultar status do módulo. Fail-open.', error ); STATE.feature = { ...STATE.feature, loaded: true, enabled: false, }; } refreshMenuVisibility(); return STATE.feature.enabled; } function canSeeModuleMenu() { if (STATE.isMaster) { return true; } return ( STATE.isAdminOfAccount && STATE.feature.loaded && STATE.feature.enabled ); } function injectStyles() { if ( document.getElementById( 'cw-wt-v14-styles' ) ) { return; } const style = document.createElement( 'style' ); style.id = 'cw-wt-v14-styles'; style.textContent = ` #cw-wt-page, #cw-wt-lock, #cw-wt-warning, #cw-wt-toast-root { --wt-bg: var( --color-n-background, var( --color-n-surface-1, #ffffff ) ); --wt-surface: var( --color-n-solid-2, #ffffff ); --wt-surface-soft: var( --color-n-solid-1, #f8fafc ); --wt-surface-hover: var( --color-n-alpha-2, rgba( 15, 23, 42, .05 ) ); --wt-border: var( --color-n-weak, #e2e8f0 ); --wt-border-strong: var( --color-n-slate-5, #cbd5e1 ); --wt-text: var( --color-n-slate-12, #0f172a ); --wt-text-muted: var( --color-n-slate-11, #64748b ); --wt-text-subtle: var( --color-n-slate-10, #94a3b8 ); --wt-brand: var( --color-n-brand, #1f93ff ); --wt-brand-soft: var( --color-n-brand-2, rgba( 31, 147, 255, .10 ) ); --wt-danger: var( --color-n-ruby-11, #dc2626 ); --wt-danger-soft: var( --color-n-ruby-2, rgba( 220, 38, 38, .08 ) ); --wt-success: var( --color-n-teal-11, #0f766e ); --wt-success-soft: var( --color-n-teal-3, rgba( 13, 148, 136, .10 ) ); --wt-warning: var( --color-n-amber-11, #b45309 ); --wt-warning-soft: var( --color-n-amber-3, rgba( 245, 158, 11, .12 ) ); color: var(--wt-text); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } .dark #cw-wt-page, .dark #cw-wt-lock, .dark #cw-wt-warning, .dark #cw-wt-toast-root { --wt-bg: var( --color-n-background, #0b0f19 ); --wt-surface: var( --color-n-solid-2, #151b27 ); --wt-surface-soft: var( --color-n-solid-1, #101621 ); --wt-surface-hover: var( --color-n-alpha-2, rgba( 255, 255, 255, .05 ) ); --wt-border: var( --color-n-weak, #273142 ); --wt-border-strong: var( --color-n-slate-5, #39475d ); --wt-text: var( --color-n-slate-12, #f8fafc ); --wt-text-muted: var( --color-n-slate-11, #cbd5e1 ); --wt-text-subtle: var( --color-n-slate-10, #94a3b8 ); } #cw-wt-page { position: fixed; z-index: 1200; background: var(--wt-bg); overflow: auto; box-sizing: border-box; border-left: 1px solid var(--wt-border); } #cw-wt-page * { box-sizing: border-box; } #cw-wt-page button, #cw-wt-page input, #cw-wt-page select { font: inherit; } .cw-wt-shell { width: min( 1180px, calc( 100% - 48px ) ); margin: 0 auto; padding: 28px 0 64px; } .cw-wt-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding-bottom: 20px; border-bottom: 1px solid var(--wt-border); } .cw-wt-title-wrap { min-width: 0; } .cw-wt-eyebrow { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; color: var( --wt-text-muted ); font-size: 12px; font-weight: 600; } .cw-wt-title { margin: 0; color: var(--wt-text); font-size: 24px; line-height: 1.25; font-weight: 700; letter-spacing: -.02em; } .cw-wt-subtitle { margin: 6px 0 0; max-width: 760px; color: var( --wt-text-muted ); font-size: 14px; line-height: 1.55; } .cw-wt-header-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; } .cw-wt-btn { height: 38px; border-radius: 8px; border: 1px solid var(--wt-border); background: var(--wt-surface); color: var(--wt-text); padding: 0 14px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; cursor: pointer; font-size: 13px; font-weight: 600; transition: background .15s, border-color .15s, opacity .15s; } .cw-wt-btn:hover:not(:disabled) { background: var( --wt-surface-hover ); border-color: var( --wt-border-strong ); } .cw-wt-btn:disabled { opacity: .55; cursor: not-allowed; } .cw-wt-btn-primary { background: var(--wt-brand); border-color: var(--wt-brand); color: #fff; } .cw-wt-btn-primary:hover:not(:disabled) { filter: brightness(1.04); background: var(--wt-brand); border-color: var(--wt-brand); } .cw-wt-btn-danger { color: var(--wt-danger); background: transparent; border-color: transparent; } .cw-wt-btn-danger:hover:not(:disabled) { background: var( --wt-danger-soft ); border-color: transparent; } .cw-wt-btn-link { height: auto; padding: 3px 2px; border: 0; background: transparent; color: var(--wt-brand); } .cw-wt-btn-link:hover:not(:disabled) { background: transparent; text-decoration: underline; border: 0; } .cw-wt-icon-btn { width: 34px; height: 34px; padding: 0; border-radius: 8px; } .cw-wt-content { padding-top: 22px; } .cw-wt-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; } .cw-wt-search { position: relative; width: min( 360px, 100% ); } .cw-wt-input, .cw-wt-select { width: 100%; height: 38px; border: 1px solid var(--wt-border); border-radius: 8px; background: var(--wt-surface); color: var(--wt-text); padding: 0 11px; outline: none; font-size: 13px; } .cw-wt-input:focus, .cw-wt-select:focus { border-color: var(--wt-brand); box-shadow: 0 0 0 3px var(--wt-brand-soft); } .cw-wt-input::placeholder { color: var( --wt-text-subtle ); } .cw-wt-field { display: flex; flex-direction: column; gap: 6px; } .cw-wt-label { color: var( --wt-text-muted ); font-size: 12px; font-weight: 600; } .cw-wt-help { color: var( --wt-text-subtle ); font-size: 12px; line-height: 1.45; } .cw-wt-card { border: 1px solid var(--wt-border); border-radius: 10px; background: var(--wt-surface); } .cw-wt-card + .cw-wt-card { margin-top: 12px; } .cw-wt-profile-card { padding: 16px 18px; display: flex; align-items: center; gap: 16px; justify-content: space-between; } .cw-wt-profile-main { min-width: 0; flex: 1; } .cw-wt-profile-name-row { display: flex; align-items: center; gap: 8px; min-width: 0; } .cw-wt-profile-name { color: var(--wt-text); font-size: 14px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .cw-wt-profile-meta { margin-top: 5px; color: var( --wt-text-muted ); font-size: 12px; line-height: 1.5; } .cw-wt-profile-actions { flex-shrink: 0; display: flex; align-items: center; gap: 8px; } .cw-wt-badge { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 999px; font-size: 11px; font-weight: 650; white-space: nowrap; } .cw-wt-badge-success { color: var(--wt-success); background: var( --wt-success-soft ); } .cw-wt-badge-muted { color: var( --wt-text-muted ); background: var( --wt-surface-soft ); } .cw-wt-badge-danger { color: var(--wt-danger); background: var( --wt-danger-soft ); } .cw-wt-empty { border: 1px dashed var( --wt-border-strong ); border-radius: 10px; padding: 50px 22px; text-align: center; background: var( --wt-surface-soft ); } .cw-wt-empty-title { margin: 0 0 7px; color: var(--wt-text); font-size: 15px; font-weight: 650; } .cw-wt-empty-text { margin: 0 auto 16px; max-width: 520px; color: var( --wt-text-muted ); font-size: 13px; line-height: 1.55; } .cw-wt-loading { padding: 48px 16px; text-align: center; color: var( --wt-text-muted ); font-size: 13px; } .cw-wt-error { padding: 14px 16px; border: 1px solid color-mix( in srgb, var(--wt-danger) 28%, var(--wt-border) ); background: var( --wt-danger-soft ); color: var(--wt-danger); border-radius: 9px; font-size: 13px; line-height: 1.5; } .cw-wt-editor { display: grid; grid-template-columns: minmax(0,1fr); gap: 18px; } .cw-wt-section { border: 1px solid var(--wt-border); border-radius: 10px; background: var(--wt-surface); overflow: hidden; } .cw-wt-section-head { padding: 15px 17px; border-bottom: 1px solid var(--wt-border); display: flex; align-items: center; justify-content: space-between; gap: 12px; } .cw-wt-section-title { color: var(--wt-text); font-size: 13px; font-weight: 650; } .cw-wt-section-desc { margin-top: 3px; color: var( --wt-text-muted ); font-size: 12px; line-height: 1.45; } .cw-wt-section-body { padding: 17px; } .cw-wt-days { display: flex; flex-direction: column; } .cw-wt-day { padding: 14px 0; border-bottom: 1px solid var(--wt-border); display: grid; grid-template-columns: 180px minmax(0,1fr); gap: 18px; align-items: flex-start; } .cw-wt-day:last-child { border-bottom: 0; } .cw-wt-day-label-wrap { display: flex; align-items: center; gap: 10px; min-height: 34px; } .cw-wt-day-label { color: var(--wt-text); font-size: 13px; font-weight: 600; } .cw-wt-day.closed .cw-wt-periods { opacity: .55; pointer-events: none; } .cw-wt-day.closed .cw-wt-add-period { display: none; } .cw-wt-periods { display: flex; flex-direction: column; gap: 8px; } .cw-wt-period-row { display: flex; align-items: center; gap: 8px; } .cw-wt-time { width: 112px; } .cw-wt-period-sep { color: var( --wt-text-subtle ); font-size: 12px; } .cw-wt-no-periods { color: var( --wt-text-subtle ); font-size: 12px; line-height: 34px; } .cw-wt-switch { position: relative; width: 34px; height: 20px; display: inline-flex; flex-shrink: 0; } .cw-wt-switch input { opacity: 0; width: 0; height: 0; position: absolute; } .cw-wt-switch-track { position: absolute; inset: 0; border-radius: 999px; background: var( --wt-border-strong ); transition: .15s; cursor: pointer; } .cw-wt-switch-track:before { content: ""; position: absolute; width: 16px; height: 16px; left: 2px; top: 2px; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba( 0, 0, 0, .18 ); transition: .15s; } .cw-wt-switch input:checked + .cw-wt-switch-track { background: var(--wt-brand); } .cw-wt-switch input:checked + .cw-wt-switch-track:before { transform: translateX(14px); } .cw-wt-agent-toolbar { display: flex; align-items: center; gap: 12px; justify-content: space-between; margin-bottom: 12px; flex-wrap: wrap; } .cw-wt-agent-actions { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; } .cw-wt-agent-list { border: 1px solid var(--wt-border); border-radius: 9px; overflow: auto; max-height: 320px; } .cw-wt-agent-row { min-height: 48px; padding: 9px 12px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--wt-border); } .cw-wt-agent-row:last-child { border-bottom: 0; } .cw-wt-agent-row.is-disabled { opacity: .55; background: var( --wt-surface-soft ); } .cw-wt-agent-left { display: flex; align-items: center; gap: 10px; min-width: 0; } .cw-wt-agent-check { width: 16px; height: 16px; accent-color: var(--wt-brand); flex-shrink: 0; } .cw-wt-agent-info { min-width: 0; } .cw-wt-agent-name { color: var(--wt-text); font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .cw-wt-agent-sub { margin-top: 2px; color: var( --wt-text-muted ); font-size: 11px; } .cw-wt-agent-conflict { color: var(--wt-danger); font-size: 11px; font-weight: 600; text-align: right; } .cw-wt-editor-footer { position: sticky; bottom: 0; margin-top: 4px; padding: 12px 0 0; display: flex; justify-content: flex-end; gap: 8px; background: linear-gradient( to bottom, transparent, var(--wt-bg) 28% ); } .cw-wt-table-wrap { border: 1px solid var(--wt-border); border-radius: 10px; overflow: auto; background: var(--wt-surface); } .cw-wt-table { width: 100%; border-collapse: collapse; min-width: 720px; } .cw-wt-table th { text-align: left; padding: 11px 14px; background: var( --wt-surface-soft ); color: var( --wt-text-muted ); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; font-weight: 650; border-bottom: 1px solid var(--wt-border); } .cw-wt-table td { padding: 13px 14px; color: var(--wt-text); font-size: 13px; border-bottom: 1px solid var(--wt-border); vertical-align: middle; } .cw-wt-table tr:last-child td { border-bottom: 0; } .cw-wt-table tbody tr:hover { background: var( --wt-surface-hover ); } .cw-wt-table .right { text-align: right; } .cw-wt-company-sub { margin-top: 2px; color: var( --wt-text-muted ); font-size: 11px; } #cw-wt-warning { position: fixed; top: 14px; left: 50%; transform: translateX(-50%); z-index: 2147483000; width: min( 680px, calc( 100vw - 28px ) ); pointer-events: none; } .cw-wt-warning-card { pointer-events: auto; border: 1px solid color-mix( in srgb, var(--wt-warning) 28%, var(--wt-border) ); background: var(--wt-surface); border-radius: 10px; padding: 11px 13px; display: flex; align-items: flex-start; gap: 10px; box-shadow: 0 12px 30px rgba( 0, 0, 0, .14 ); } .cw-wt-warning-icon { width: 28px; height: 28px; border-radius: 8px; display: grid; place-items: center; background: var( --wt-warning-soft ); color: var(--wt-warning); flex-shrink: 0; } .cw-wt-warning-title { color: var(--wt-text); font-size: 13px; font-weight: 650; } .cw-wt-warning-text { margin-top: 2px; color: var( --wt-text-muted ); font-size: 12px; line-height: 1.45; } #cw-wt-lock { position: fixed !important; inset: 0 !important; z-index: 2147483647 !important; background: var(--wt-bg) !important; display: flex !important; align-items: center !important; justify-content: center !important; padding: 24px !important; overflow: auto !important; } .cw-wt-lock-card { width: min( 440px, 100% ); text-align: center; } .cw-wt-lock-icon { width: 48px; height: 48px; margin: 0 auto 16px; display: grid; place-items: center; border-radius: 12px; background: var( --wt-surface-soft ); color: var( --wt-text-muted ); border: 1px solid var(--wt-border); } .cw-wt-lock-title { margin: 0; color: var(--wt-text); font-size: 21px; font-weight: 700; letter-spacing: -.02em; } .cw-wt-lock-text { margin: 8px auto 0; color: var( --wt-text-muted ); font-size: 13px; line-height: 1.55; max-width: 390px; } .cw-wt-lock-next { margin: 20px 0; border: 1px solid var(--wt-border); background: var(--wt-surface); border-radius: 10px; padding: 13px 15px; text-align: left; } .cw-wt-lock-next-label { color: var( --wt-text-subtle ); font-size: 11px; font-weight: 650; text-transform: uppercase; letter-spacing: .04em; } .cw-wt-lock-next-value { margin-top: 5px; color: var(--wt-text); font-size: 13px; font-weight: 600; } .cw-wt-lock-profile { margin-top: 4px; color: var( --wt-text-muted ); font-size: 12px; } .cw-wt-lock-actions { margin-top: 18px; } .cw-wt-lock-actions .cw-wt-btn { width: 100%; height: 40px; } #cw-wt-toast-root { position: fixed; right: 18px; bottom: 18px; z-index: 2147483500; width: min( 380px, calc( 100vw - 36px ) ); display: flex; flex-direction: column; gap: 8px; pointer-events: none; } .cw-wt-toast { pointer-events: auto; border: 1px solid var(--wt-border); background: var(--wt-surface); color: var(--wt-text); border-radius: 9px; padding: 11px 13px; box-shadow: 0 12px 28px rgba( 0, 0, 0, .14 ); font-size: 12px; line-height: 1.45; } .cw-wt-toast.error { border-color: color-mix( in srgb, var(--wt-danger) 35%, var(--wt-border) ); } .cw-wt-toast.success { border-color: color-mix( in srgb, var(--wt-success) 35%, var(--wt-border) ); } /* ========================================== * V14.1 - DESIGN CHATWOOT 4.16.2 * ========================================== */ #cw-wt-page .cw-wt-shell { width: min( 1380px, calc( 100% - 32px ) ); padding: 20px 0 48px; } #cw-wt-page .cw-wt-content { padding-top: 16px; } #cw-wt-page .cw-wt-header { gap: 16px; padding-bottom: 16px; } #cw-wt-page .cw-wt-eyebrow { margin-bottom: 4px; font-size: 11px; line-height: 16px; font-weight: 500; } #cw-wt-page .cw-wt-title { font-size: 20px; line-height: 28px; font-weight: 600; letter-spacing: -.01em; } #cw-wt-page .cw-wt-subtitle { margin-top: 4px; font-size: 13px; line-height: 19px; max-width: 800px; } #cw-wt-page .cw-wt-btn, #cw-wt-lock .cw-wt-btn { min-height: 32px; height: 32px; padding: 0 12px; gap: 7px; border-radius: 8px; font-size: 13px; line-height: 18px; font-weight: 500; box-shadow: none; border-width: 1px; transition: background-color .1s ease, border-color .1s ease, transform .1s ease; } #cw-wt-page .cw-wt-btn:active:not(:disabled), #cw-wt-lock .cw-wt-btn:active:not(:disabled) { transform: scale(.98); } #cw-wt-page .cw-wt-btn-primary { border-color: transparent; } #cw-wt-page .cw-wt-btn-link { min-height: 28px; height: 28px; padding: 0 9px; border: 0; border-radius: 7px; text-decoration: none; color: var( --wt-text-muted ); background: transparent; font-size: 12px; font-weight: 500; } #cw-wt-page .cw-wt-btn-link:hover:not(:disabled) { text-decoration: none; background: var( --wt-surface-hover ); color: var(--wt-text); border: 0; } #cw-wt-page .cw-wt-card, #cw-wt-page .cw-wt-section, #cw-wt-page .cw-wt-table-wrap { border-radius: 10px; border: 1px solid var(--wt-border); box-shadow: 0 1px 2px rgba( 0, 0, 0, .035 ); } #cw-wt-page .cw-wt-section-head { min-height: 56px; padding: 11px 14px; } #cw-wt-page .cw-wt-section-body { padding: 14px; } #cw-wt-page .cw-wt-section-title { font-size: 13px; line-height: 18px; font-weight: 600; } #cw-wt-page .cw-wt-section-desc { margin-top: 2px; font-size: 11.5px; line-height: 17px; } /* * Desktop: * * [ Perfil ] * [ Horário ][ Usuários ] * [ footer ] */ #cw-wt-page .cw-wt-editor { display: grid; grid-template-columns: minmax( 520px, 1.55fr ) minmax( 340px, .85fr ); gap: 14px; align-items: start; } #cw-wt-page .cw-wt-editor > .cw-wt-section:nth-of-type(1) { grid-column: 1 / -1; } #cw-wt-page .cw-wt-editor > .cw-wt-section:nth-of-type(2) { grid-column: 1; } #cw-wt-page .cw-wt-editor > .cw-wt-section:nth-of-type(3) { grid-column: 2; position: sticky; top: 12px; } #cw-wt-page .cw-wt-editor-footer { grid-column: 1 / -1; } #cw-wt-page .cw-wt-input, #cw-wt-page .cw-wt-select { height: 34px; min-height: 34px; padding: 0 10px; border-radius: 8px; font-size: 13px; font-weight: 400; } #cw-wt-page .cw-wt-label { font-size: 11.5px; line-height: 16px; font-weight: 500; } #cw-wt-page .cw-wt-day { padding: 10px 0; grid-template-columns: 126px minmax( 0, 1fr ); gap: 10px; } #cw-wt-page .cw-wt-day-label-wrap { gap: 8px; min-height: 32px; } #cw-wt-page .cw-wt-day-label { font-size: 12.5px; line-height: 18px; font-weight: 500; } #cw-wt-page .cw-wt-periods { gap: 5px; } #cw-wt-page .cw-wt-period-list { display: flex; flex-direction: column; gap: 5px; } #cw-wt-page .cw-wt-period-row { display: grid; grid-template-columns: 96px 18px 96px 32px; align-items: center; justify-content: start; gap: 5px; width: max-content; max-width: 100%; } #cw-wt-page .cw-wt-time { width: 96px; min-width: 0; height: 32px; min-height: 32px; padding: 0 6px; font-size: 12px; text-align: center; } #cw-wt-page .cw-wt-period-sep { width: 18px; text-align: center; font-size: 11px; } #cw-wt-page .cw-wt-icon-btn { width: 32px; min-width: 32px; height: 32px; min-height: 32px; padding: 0; } #cw-wt-page .cw-wt-remove-period { color: var( --wt-text-subtle ); border-color: transparent; background: transparent; } #cw-wt-page .cw-wt-remove-period:hover { color: var(--wt-danger); background: var( --wt-danger-soft ); } #cw-wt-page .cw-wt-add-period { margin-top: 1px; } #cw-wt-page .cw-wt-switch { width: 32px; height: 18px; } #cw-wt-page .cw-wt-switch-track:before { width: 14px; height: 14px; top: 2px; left: 2px; } #cw-wt-page .cw-wt-switch input:checked + .cw-wt-switch-track:before { transform: translateX(14px); } #cw-wt-page .cw-wt-agent-toolbar { margin-bottom: 9px; gap: 8px; } #cw-wt-page .cw-wt-agent-toolbar .cw-wt-search { width: 100%; } #cw-wt-page .cw-wt-agent-actions { width: 100%; display: flex; align-items: center; justify-content: flex-start; gap: 3px; flex-wrap: wrap; } #cw-wt-page .cw-wt-agent-list { max-height: calc( 100vh - 370px ); min-height: 260px; border-radius: 8px; } #cw-wt-page .cw-wt-agent-row { min-height: 44px; padding: 7px 9px; gap: 8px; } #cw-wt-page .cw-wt-agent-left { gap: 8px; } #cw-wt-page .cw-wt-agent-check { width: 15px; height: 15px; } #cw-wt-page .cw-wt-agent-name { font-size: 12.5px; line-height: 17px; font-weight: 550; } #cw-wt-page .cw-wt-agent-sub { margin-top: 1px; font-size: 10.5px; line-height: 15px; } #cw-wt-page .cw-wt-agent-conflict { max-width: 120px; font-size: 10px; line-height: 14px; } #cw-wt-page .cw-wt-profile-card { min-height: 58px; padding: 11px 13px; gap: 10px; } #cw-wt-page .cw-wt-profile-name { font-size: 13px; line-height: 18px; font-weight: 600; } #cw-wt-page .cw-wt-profile-meta { margin-top: 3px; font-size: 11.5px; line-height: 17px; } #cw-wt-page .cw-wt-badge { height: 20px; padding: 0 7px; font-size: 10px; line-height: 14px; font-weight: 600; } #cw-wt-page .cw-wt-editor-footer { padding: 10px 0 2px; gap: 6px; } #cw-wt-page .cw-wt-table th { padding: 9px 12px; font-size: 10.5px; font-weight: 600; } #cw-wt-page .cw-wt-table td { padding: 10px 12px; font-size: 12.5px; } #cw-wt-page .cw-wt-company-sub { font-size: 10.5px; } @media ( max-width: 1100px ) { #cw-wt-page .cw-wt-editor { grid-template-columns: 1fr; } #cw-wt-page .cw-wt-editor > .cw-wt-section:nth-of-type(1), #cw-wt-page .cw-wt-editor > .cw-wt-section:nth-of-type(2), #cw-wt-page .cw-wt-editor > .cw-wt-section:nth-of-type(3), #cw-wt-page .cw-wt-editor-footer { grid-column: 1; } #cw-wt-page .cw-wt-editor > .cw-wt-section:nth-of-type(3) { position: static; } #cw-wt-page .cw-wt-agent-list { max-height: 340px; } } @media ( max-width: 700px ) { #cw-wt-page .cw-wt-shell { width: calc( 100% - 20px ); } #cw-wt-page .cw-wt-day { grid-template-columns: 1fr; } #cw-wt-page .cw-wt-period-row { grid-template-columns: minmax( 80px, 1fr ) 16px minmax( 80px, 1fr ) 32px; width: 100%; } #cw-wt-page .cw-wt-time { width: 100%; } } `; document.head .appendChild(style); } function svgClock(size = 16) { return ` `; } function svgPlus(size = 15) { return ` `; } function svgTrash(size = 15) { return ` `; } function svgX(size = 15) { return ` `; } function svgChevronLeft( size = 15 ) { return ` `; } function getMainArea() { const candidates = [ document.querySelector( '#app main' ), document.querySelector( '[role="main"]' ), document.querySelector( '.flex-1.h-full.overflow-auto' ), document.querySelector( '.w-full.overflow-auto' ), ].filter(Boolean); if (candidates.length) { return candidates .sort((a, b) => { const ar = a.getBoundingClientRect(); const br = b.getBoundingClientRect(); return ( br.width * br.height - ar.width * ar.height ); })[0]; } return ( document.querySelector( '#app' ) || document.body ); } function positionPageRoot() { if ( !STATE.pageRoot || !document.body.contains( STATE.pageRoot ) ) { return; } const main = getMainArea(); if (!main) { return; } const rect = main.getBoundingClientRect(); STATE.pageRoot.style.left = `${Math.max( 0, rect.left )}px`; STATE.pageRoot.style.top = `${Math.max( 0, rect.top )}px`; STATE.pageRoot.style.width = `${Math.max( 320, rect.width )}px`; STATE.pageRoot.style.height = `${Math.max( 320, rect.height )}px`; } function ensureToastRoot() { let root = document.getElementById( 'cw-wt-toast-root' ); if (!root) { root = document.createElement( 'div' ); root.id = 'cw-wt-toast-root'; document.body .appendChild(root); } return root; } function notify( message, type = 'info', duration = 3200 ) { const root = ensureToastRoot(); const toast = document.createElement( 'div' ); toast.className = `cw-wt-toast ${type}`; toast.textContent = String(message); root.appendChild(toast); setTimeout( () => toast.remove(), duration ); } function refreshMenuVisibility() { const item = document.getElementById( 'menu-item-work-schedule-v14' ); if (!item) { return; } item.style.display = canSeeModuleMenu() ? '' : 'none'; } /* * Aqui usamos a estrutura real * que você capturou no Chatwoot 4.16.2. * * Deixa Horários logo após Conta. */ function findSettingsReferenceItem() { const accountSettings = document.querySelector( 'li[name="Settings Account Settings"]' ); if (accountSettings) { return accountSettings; } const automation = document.querySelector( 'li[name="Settings Automation"]' ); if (automation) { return automation; } const allLis = [ ...document.querySelectorAll( 'li' ), ]; return ( allLis.find(li => { const text = ( li.textContent || '' ) .trim() .toLowerCase(); return ( text === 'conta' || text === 'account' ); }) || null ); } function injectSidebarMenuItem() { if (STATE.destroyed) { return; } let existing = document.getElementById( 'menu-item-work-schedule-v14' ); if (existing) { STATE.menuItem = existing; refreshMenuVisibility(); return; } const reference = findSettingsReferenceItem(); if ( !reference ?.parentElement ) { return; } const li = document.createElement( 'li' ); li.id = 'menu-item-work-schedule-v14'; li.setAttribute( 'name', 'Settings Work Schedules V14' ); /* * Mesma classe do LI * Conta / Agentes / Times. */ li.className = reference.className || ( 'py-0.5 ps-2 ms-3 ' + 'relative text-n-slate-11 ' + 'min-w-0 child-item' ); const anchor = document.createElement( 'a' ); anchor.href = '#'; anchor.title = STATE.isMaster ? 'Master de horários' : CONFIG.moduleLabel; /* * Mesmas classes do A nativo. */ anchor.className = 'flex h-8 items-center gap-2 ' + 'px-2 py-1 rounded-lg ' + 'ltr:hover:bg-gradient-to-r ' + 'rtl:hover:bg-gradient-to-l ' + 'from-transparent ' + 'via-n-slate-3/70 ' + 'to-n-slate-3/70 ' + 'group min-w-0'; /* * Mesma estrutura interna: * * size-4 * lucide * text-sm * hidden span final */ anchor.innerHTML = `
${escapeHtml( STATE.isMaster ? 'Master de horários' : CONFIG.moduleLabel )}
`; anchor.addEventListener( 'click', async event => { event.preventDefault(); event.stopPropagation(); await openModulePage(); } ); li.appendChild(anchor); reference .insertAdjacentElement( 'afterend', li ); STATE.menuItem = li; refreshMenuVisibility(); } function setMenuActive(active) { const li = document.getElementById( 'menu-item-work-schedule-v14' ); const anchor = li?.querySelector('a'); if (!anchor) { return; } anchor.classList.toggle( 'text-n-slate-12', active ); anchor.classList.toggle( 'bg-n-alpha-2', active ); anchor.classList.toggle( 'active', active ); } function closeModulePage() { STATE.pageOpen = false; STATE.pageMode = null; STATE.editingProfileId = null; STATE.pendingNewProfile = null; if (STATE.pageRoot) { STATE.pageRoot.remove(); STATE.pageRoot = null; } setMenuActive(false); } async function openModulePage() { const ok = await loadContext(true); if (!ok) { notify( 'Não foi possível identificar o usuário atual do Chatwoot.', 'error' ); return; } if (!canSeeModuleMenu()) { notify( 'O módulo de horários não está habilitado para esta conta.', 'error' ); return; } injectStyles(); closeModulePage(); const root = document.createElement( 'section' ); root.id = 'cw-wt-page'; root.setAttribute( 'aria-label', CONFIG.moduleLabel ); document.body .appendChild(root); STATE.pageRoot = root; STATE.pageOpen = true; STATE.pageMode = STATE.isMaster ? 'master' : 'profiles'; setMenuActive(true); positionPageRoot(); if (STATE.isMaster) { await renderMasterPage(); } else { await renderProfilesPage(); } } function renderPageHeader({ eyebrow, title, subtitle, actions = '', }) { return `
${svgClock(14)} ${escapeHtml( eyebrow || CONFIG.moduleLabel )}

${escapeHtml(title)}

${escapeHtml( subtitle || '' )}

${actions}
`; } function normalizeProfilesResponse( data ) { const list = Array.isArray(data) ? data : ( Array.isArray( data?.profiles ) ? data.profiles : [] ); return list .map(raw => ({ account_id: String( raw.account_id || STATE.accountId || '' ), profile_id: String( raw.profile_id || '' ), name: raw.name || 'Perfil de horário', enabled: raw.enabled !== false, schedule: safeJsonParse( raw.schedule ?? raw.schedule_json, {} ), assigned_users: uniqueStrings( safeJsonParse( raw.assigned_users ?? raw.assigned_users_json, [] ) ), created_at: raw.created_at || null, updated_at: raw.updated_at || null, })) .filter( p => p.profile_id ); } async function loadProfiles() { const data = await n8nRequest( `/work-schedule/profiles/list?account_id=${encodeURIComponent( STATE.accountId )}&_t=${Date.now()}` ); STATE.profiles = normalizeProfilesResponse( data ); return STATE.profiles; } async function loadAgents() { const data = await chatwootRequest( `/api/v1/accounts/${encodeURIComponent( STATE.accountId )}/agents` ); const list = Array.isArray(data) ? data : ( Array.isArray( data?.payload ) ? data.payload : [] ); STATE.agents = list.map(agent => ({ id: String(agent.id), name: agent.name || agent.available_name || agent.email || `Usuário #${agent.id}`, email: agent.email || '', role: String( agent.role || 'agent' ).toLowerCase(), availability: agent.availability_status || agent.availability || '', })); return STATE.agents; } function summarizeSchedule( schedule ) { const normalized = schedule || {}; const parts = []; for (const day of DAYS) { const cfg = normalized[ day.key ]; if ( !cfg?.enabled || !Array.isArray( cfg.periods ) || cfg.periods.length === 0 ) { continue; } const periods = cfg.periods .map( p => `${p.start}–${p.end}` ) .join(', '); parts.push( `${day.short} ${periods}` ); } return parts.length ? parts.join(' · ') : 'Nenhum horário ativo'; } async function renderProfilesPage() { if (!STATE.pageRoot) { return; } STATE.pageRoot.innerHTML = `
${renderPageHeader({ eyebrow: 'Configurações', title: 'Horários de trabalho', subtitle: 'Crie perfis de jornada por dia da semana e defina quais usuários podem operar o Chatwoot em cada período.', actions: ` `, })}
Carregando perfis e usuários...
`; STATE.pageRoot .querySelector( '#cw-wt-close-page' ) ?.addEventListener( 'click', closeModulePage ); STATE.pageRoot .querySelector( '#cw-wt-new-profile' ) ?.addEventListener( 'click', () => openProfileEditor( createDefaultProfile(), true ) ); try { await Promise.all([ loadProfiles(), loadAgents(), ]); renderProfilesList(); } catch (error) { logError( 'Erro ao carregar perfis/agentes', error ); const content = STATE.pageRoot ?.querySelector( '#cw-wt-profiles-content' ); if (content) { content.innerHTML = `
Não foi possível carregar os dados desta conta. Verifique os endpoints do n8n e a API do Chatwoot.
`; } } } function renderProfilesList( filter = '' ) { const content = STATE.pageRoot ?.querySelector( '#cw-wt-profiles-content' ); if (!content) { return; } const q = String(filter || '') .trim() .toLowerCase(); const filtered = STATE.profiles.filter( p => !q || p.name .toLowerCase() .includes(q) ); const toolbar = `
${STATE.profiles.length} ${ STATE.profiles.length === 1 ? 'perfil' : 'perfis' } configurado(s)
`; if ( STATE.profiles.length === 0 ) { content.innerHTML = toolbar + `

Nenhum perfil de horário criado

Crie um perfil para definir a jornada semanal e depois associe agentes ou administradores da conta.

`; content .querySelector( '#cw-wt-first-profile' ) ?.addEventListener( 'click', () => openProfileEditor( createDefaultProfile(), true ) ); } else if ( filtered.length === 0 ) { content.innerHTML = toolbar + `

Nenhum perfil encontrado

Tente outro termo de busca.

`; } else { content.innerHTML = toolbar + filtered .map( profile => `
${escapeHtml( profile.name )}
${ profile.enabled ? 'Ativo' : 'Desativado' } ${profile.assigned_users.length} ${ profile.assigned_users.length === 1 ? 'usuário' : 'usuários' }
${escapeHtml( summarizeSchedule( profile.schedule ) )}
` ) .join(''); content .querySelectorAll( '.cw-wt-profile-card' ) .forEach(card => { card .querySelector( '.cw-wt-edit-profile' ) ?.addEventListener( 'click', () => { const id = card.dataset .profileId; const profile = STATE.profiles.find( p => p.profile_id === id ); if (profile) { openProfileEditor( structuredCloneSafe( profile ), false ); } } ); }); } const search = content.querySelector( '#cw-wt-profile-search' ); if (search) { search.addEventListener( 'input', () => renderProfilesList( search.value ) ); requestAnimationFrame( () => { const next = STATE.pageRoot ?.querySelector( '#cw-wt-profile-search' ); if ( next && filter ) { next.focus(); next.setSelectionRange( next.value.length, next.value.length ); } } ); } } function structuredCloneSafe( value ) { if (window.structuredClone) { return window.structuredClone( value ); } return JSON.parse( JSON.stringify(value) ); } function createDefaultSchedule() { const schedule = {}; for (const day of DAYS) { const weekday = [ 'seg', 'ter', 'qua', 'qui', 'sex', ].includes( day.key ); schedule[ day.key ] = { enabled: weekday, periods: weekday ? [ { start: '09:00', end: '18:00', }, ] : [], }; } return schedule; } function createDefaultProfile() { return { account_id: STATE.accountId, profile_id: makeProfileId(), name: 'Novo perfil', enabled: true, schedule: createDefaultSchedule(), assigned_users: [], __new: true, }; } function findAgentConflict( userId, currentProfileId ) { return ( STATE.profiles.find( p => p.profile_id !== currentProfileId && p.assigned_users .map(String) .includes( String(userId) ) ) || null ); } function renderDayEditor( day, config ) { const enabled = !!config?.enabled; const periods = Array.isArray( config?.periods ) ? config.periods : []; const periodsHtml = periods.length ? periods .map( ( p, idx ) => renderPeriodRow( day.key, p, idx ) ) .join('') : `
Nenhum período configurado.
`; return `
${escapeHtml( day.label )}
${periodsHtml}
`; } function renderPeriodRow( dayKey, period, index ) { return `
até
`; } function renderAgents(profile) { if (!STATE.agents.length) { return `

Nenhum agente foi retornado pela API desta conta.

`; } return `
${STATE.agents .map(agent => { const conflict = findAgentConflict( agent.id, profile.profile_id ); const selected = profile.assigned_users .map(String) .includes( String(agent.id) ); const isAdmin = agent.role === 'administrator' || agent.role === 'admin'; return ` `; }) .join('')}
`; } function openProfileEditor( profile, isNew ) { if (!STATE.pageRoot) { return; } STATE.editingProfileId = profile.profile_id; STATE.pendingNewProfile = isNew ? profile : null; const shell = STATE.pageRoot .querySelector( '.cw-wt-shell' ); if (!shell) { return; } shell.innerHTML = ` ${renderPageHeader({ eyebrow: 'Horários de trabalho', title: isNew ? 'Novo perfil' : 'Editar perfil', subtitle: 'Defina horários diferentes por dia e associe apenas os usuários que pertencem a esta jornada.', actions: ` `, })}
Perfil
Nome e estado desta jornada.
Horário semanal
Cada dia pode ter um ou vários períodos. O espaço entre os períodos fica bloqueado.
${DAYS .map( day => renderDayEditor( day, profile.schedule ?.[day.key] || { enabled: false, periods: [], } ) ) .join('')}
Usuários vinculados
Um usuário só pode pertencer a um perfil por conta.
${renderAgents( profile )}
`; const back = () => renderProfilesListAfterEditor(); shell .querySelector( '#cw-wt-editor-back' ) ?.addEventListener( 'click', back ); shell .querySelector( '#cw-wt-cancel-profile' ) ?.addEventListener( 'click', back ); shell .querySelector( '#cw-wt-save-profile' ) ?.addEventListener( 'click', () => saveProfileFromEditor( profile ) ); shell .querySelector( '#cw-wt-delete-profile' ) ?.addEventListener( 'click', () => deleteProfile( profile ) ); shell .querySelector( '#cw-wt-copy-monday' ) ?.addEventListener( 'click', copyMondayToWeekdays ); bindScheduleEditorEvents(); bindAgentEditorEvents(); } function renderProfilesListAfterEditor() { STATE.editingProfileId = null; STATE.pendingNewProfile = null; if (!STATE.pageRoot) { return; } renderProfilesPage(); } function bindScheduleEditorEvents() { const daysRoot = STATE.pageRoot ?.querySelector( '#cw-wt-days' ); if (!daysRoot) { return; } daysRoot .querySelectorAll( '.cw-wt-day' ) .forEach(dayEl => { const toggle = dayEl.querySelector( '.cw-wt-day-enabled' ); toggle ?.addEventListener( 'change', () => { dayEl.classList.toggle( 'closed', !toggle.checked ); if ( toggle.checked && dayEl .querySelectorAll( '.cw-wt-period-row' ) .length === 0 ) { appendPeriodRow( dayEl, { start: '09:00', end: '18:00', } ); } } ); dayEl .querySelector( '.cw-wt-add-period' ) ?.addEventListener( 'click', () => appendPeriodRow( dayEl, { start: '09:00', end: '18:00', } ) ); dayEl .querySelectorAll( '.cw-wt-remove-period' ) .forEach( btn => btn.addEventListener( 'click', () => removePeriodRow( btn ) ) ); }); } function appendPeriodRow( dayEl, period ) { const list = dayEl.querySelector( '.cw-wt-period-list' ); if (!list) { return; } const empty = list.querySelector( '.cw-wt-no-periods' ); if (empty) { empty.remove(); } const wrapper = document.createElement( 'div' ); wrapper.innerHTML = renderPeriodRow( dayEl.dataset.day, period, list.querySelectorAll( '.cw-wt-period-row' ).length ).trim(); const row = wrapper.firstElementChild; row .querySelector( '.cw-wt-remove-period' ) ?.addEventListener( 'click', () => removePeriodRow( row.querySelector( '.cw-wt-remove-period' ) ) ); list.appendChild(row); } function removePeriodRow( button ) { const row = button.closest( '.cw-wt-period-row' ); const list = row?.parentElement; if ( !row || !list ) { return; } row.remove(); if ( list.querySelectorAll( '.cw-wt-period-row' ).length === 0 ) { list.innerHTML = `
Nenhum período configurado.
`; } } function copyMondayToWeekdays() { const root = STATE.pageRoot ?.querySelector( '#cw-wt-days' ); const monday = root?.querySelector( '[data-day="seg"]' ); if ( !root || !monday ) { return; } const enabled = !!monday .querySelector( '.cw-wt-day-enabled' ) ?.checked; const periods = [ ...monday.querySelectorAll( '.cw-wt-period-row' ), ].map(row => ({ start: row.querySelector( '.cw-wt-period-start' )?.value || '09:00', end: row.querySelector( '.cw-wt-period-end' )?.value || '18:00', })); [ 'ter', 'qua', 'qui', 'sex', ].forEach(key => { const day = root.querySelector( `[data-day="${key}"]` ); if (!day) { return; } const toggle = day.querySelector( '.cw-wt-day-enabled' ); if (toggle) { toggle.checked = enabled; } day.classList.toggle( 'closed', !enabled ); const list = day.querySelector( '.cw-wt-period-list' ); if (!list) { return; } list.innerHTML = periods.length ? periods .map( ( p, idx ) => renderPeriodRow( key, p, idx ) ) .join('') : `
Nenhum período configurado.
`; list .querySelectorAll( '.cw-wt-remove-period' ) .forEach( btn => btn.addEventListener( 'click', () => removePeriodRow( btn ) ) ); }); notify( 'Horário de segunda-feira copiado para terça a sexta.', 'success' ); } /* * V14.1: * * Delegation faz os atalhos continuarem * funcionando mesmo após re-render do editor. */ function bindAgentEditorEvents() { const root = STATE.pageRoot; if (!root) { return; } const search = root.querySelector( '#cw-wt-agent-search' ); search ?.addEventListener( 'input', () => { const q = search.value .trim() .toLowerCase(); root .querySelectorAll( '.cw-wt-agent-row' ) .forEach(row => { row.style.display = !q || ( row.dataset .agentName || '' ).includes(q) ? '' : 'none'; }); } ); if ( root.dataset .cwWtQuickSelectBound === '1' ) { return; } root.dataset .cwWtQuickSelectBound = '1'; root.addEventListener( 'click', event => { const button = event.target .closest?.( '[data-quick-select]' ); if ( !button || !root.contains(button) ) { return; } event.preventDefault(); event.stopPropagation(); const mode = button.dataset .quickSelect; root .querySelectorAll( '.cw-wt-agent-row' ) .forEach(row => { const cb = row.querySelector( '.cw-wt-agent-check' ); if ( !cb || cb.disabled ) { return; } const role = row.dataset .agentRole; if ( mode === 'all' ) { cb.checked = true; } else if ( mode === 'agents' ) { cb.checked = role === 'agent'; } else if ( mode === 'admins' ) { cb.checked = role === 'admin'; } else if ( mode === 'none' ) { cb.checked = false; } cb.dispatchEvent( new Event( 'change', { bubbles: true, } ) ); }); } ); } function collectScheduleFromEditor() { const root = STATE.pageRoot ?.querySelector( '#cw-wt-days' ); const schedule = {}; const errors = []; if (!root) { return { schedule, errors: [ 'Editor de horários não encontrado.', ], }; } const toMin = time => { const [ h, m, ] = String(time) .split(':') .map(Number); return ( h * 60 + m ); }; DAYS.forEach(day => { const dayEl = root.querySelector( `[data-day="${day.key}"]` ); const enabled = !!dayEl ?.querySelector( '.cw-wt-day-enabled' ) ?.checked; const periods = [ ...( dayEl ?.querySelectorAll( '.cw-wt-period-row' ) || [] ), ].map(row => ({ start: row.querySelector( '.cw-wt-period-start' )?.value || '', end: row.querySelector( '.cw-wt-period-end' )?.value || '', })); if ( enabled && periods.length === 0 ) { errors.push( `${day.label}: adicione ao menos um período.` ); } const validPeriods = []; for ( const p of periods ) { if ( !p.start || !p.end ) { errors.push( `${day.label}: preencha início e fim.` ); continue; } if ( p.start === p.end ) { errors.push( `${day.label}: início e fim não podem ser iguais.` ); continue; } validPeriods.push(p); } const ranges = validPeriods .map(p => { const s = toMin( p.start ); const e0 = toMin( p.end ); return { s, e: e0 > s ? e0 : e0 + 1440, p, }; }) .sort( ( a, b ) => a.s - b.s ); for ( let i = 1; i < ranges.length; i++ ) { if ( ranges[i].s < ranges[ i - 1 ].e ) { errors.push( `${day.label}: existem períodos sobrepostos.` ); } } schedule[ day.key ] = { enabled, periods: validPeriods, }; }); return { schedule, errors, }; } function collectAssignedUsers() { return [ ...( STATE.pageRoot ?.querySelectorAll( '.cw-wt-agent-check:checked' ) || [] ), ].map( cb => String(cb.value) ); } async function saveProfileFromEditor( originalProfile ) { const name = STATE.pageRoot ?.querySelector( '#cw-wt-profile-name' ) ?.value .trim() || ''; const enabled = STATE.pageRoot ?.querySelector( '#cw-wt-profile-enabled' ) ?.value === 'true'; const { schedule, errors, } = collectScheduleFromEditor(); const assignedUsers = collectAssignedUsers(); if (!name) { errors.unshift( 'Informe o nome do perfil.' ); } if ( name.length > 120 ) { errors.unshift( 'O nome do perfil deve ter no máximo 120 caracteres.' ); } const activeDays = DAYS.filter( day => schedule[ day.key ]?.enabled ).length; if ( enabled && activeDays === 0 ) { errors.push( 'Um perfil ativo precisa ter pelo menos um dia de trabalho habilitado.' ); } if (errors.length) { notify( errors[0], 'error', 4800 ); log( 'Validação do perfil:', errors ); return; } const button = STATE.pageRoot ?.querySelector( '#cw-wt-save-profile' ); if (button) { button.disabled = true; button.textContent = 'Salvando...'; } const payload = { account_id: STATE.accountId, profile_id: originalProfile .profile_id, name, enabled, schedule, assigned_users: assignedUsers, requester_user_id: String( STATE.currentUser.id ), }; try { const data = await n8nRequest( '/work-schedule/profiles/save', { method: 'POST', body: JSON.stringify( payload ), } ); if ( data.success !== true ) { throw Object.assign( new Error( data.message || 'Falha ao salvar perfil.' ), { data, } ); } notify( 'Perfil salvo com sucesso.', 'success' ); await loadProfiles(); renderProfilesListAfterEditor(); } catch (error) { const conflicts = error.data?.conflicts || error.data?.data ?.conflicts || []; if ( Array.isArray( conflicts ) && conflicts.length ) { const first = conflicts[0]; notify( `Usuário ${first.user_id} já pertence ao perfil ${ first.profile_name || first.profile_id }.`, 'error', 6000 ); } else { notify( error.data?.message || error.message || 'Erro ao salvar perfil.', 'error', 5000 ); } logError( 'Erro ao salvar perfil', error ); } finally { if ( button && document.body.contains( button ) ) { button.disabled = false; button.textContent = 'Salvar alterações'; } } } async function deleteProfile( profile ) { const assignedCount = profile.assigned_users ?.length || 0; const suffix = assignedCount ? ( `\n\n${assignedCount} usuário(s) ` + 'ficarão sem perfil e, portanto, ' + 'sem restrição de horário.' ) : ''; if ( !window.confirm( `Excluir o perfil “${profile.name}”?${suffix}` ) ) { return; } const button = STATE.pageRoot ?.querySelector( '#cw-wt-delete-profile' ); if (button) { button.disabled = true; } try { const data = await n8nRequest( '/work-schedule/profiles/delete', { method: 'POST', body: JSON.stringify({ account_id: STATE.accountId, profile_id: profile.profile_id, requester_user_id: String( STATE.currentUser.id ), }), } ); if ( data.success !== true ) { throw new Error( data.message || 'Não foi possível excluir o perfil.' ); } notify( 'Perfil excluído.', 'success' ); await loadProfiles(); renderProfilesListAfterEditor(); checkSchedule(); } catch (error) { notify( error.message || 'Erro ao excluir perfil.', 'error', 5000 ); logError( 'Erro ao excluir perfil', error ); if (button) { button.disabled = false; } } } async function discoverChatwootAccounts() { const map = new Map(); const profile = STATE.currentUser; if ( Array.isArray( profile?.accounts ) ) { profile.accounts .forEach(account => { map.set( String(account.id), { account_id: String( account.id ), company_name: account.name || `Conta #${account.id}`, } ); }); } document .querySelectorAll( '[id^="account-"]' ) .forEach(el => { const match = el.id.match( /^account-(\d+)/ ); if (!match) { return; } const accountId = String(match[1]); if ( !map.has( accountId ) ) { map.set( accountId, { account_id: accountId, company_name: ( el.textContent || '' ).trim() || `Conta #${accountId}`, } ); } }); if ( STATE.accountId && !map.has( STATE.accountId ) ) { map.set( STATE.accountId, { account_id: STATE.accountId, company_name: `Conta #${STATE.accountId}`, } ); } return [ ...map.values(), ].sort( ( a, b ) => Number( a.account_id ) - Number( b.account_id ) ); } async function loadCompaniesForMaster() { const [ local, stored, ] = await Promise.all([ discoverChatwootAccounts(), n8nRequest( `/work-schedule/accounts/list?_t=${Date.now()}` ).catch( () => ({ accounts: [], }) ), ]); const storedMap = new Map( ( stored.accounts || [] ).map( item => [ String( item.account_id ), item, ] ) ); const merged = new Map(); local.forEach(item => { const saved = storedMap.get( String( item.account_id ) ); merged.set( String( item.account_id ), { account_id: String( item.account_id ), company_name: saved?.company_name || item.company_name, enabled: saved?.enabled === true, timezone: saved?.timezone || CONFIG.defaultTimezone, warning_minutes: Number.isFinite( Number( saved?.warning_minutes ) ) ? Number( saved.warning_minutes ) : CONFIG.defaultWarningMinutes, } ); }); ( stored.accounts || [] ).forEach(item => { const id = String( item.account_id ); if (!merged.has(id)) { merged.set( id, { account_id: id, company_name: item.company_name || `Conta #${id}`, enabled: item.enabled === true, timezone: item.timezone || CONFIG.defaultTimezone, warning_minutes: Number.isFinite( Number( item.warning_minutes ) ) ? Number( item.warning_minutes ) : CONFIG.defaultWarningMinutes, } ); } }); STATE.companies = [ ...merged.values(), ].sort( ( a, b ) => Number( a.account_id ) - Number( b.account_id ) ); return STATE.companies; } async function renderMasterPage() { if (!STATE.pageRoot) { return; } STATE.pageRoot.innerHTML = `
${renderPageHeader({ eyebrow: 'Configurações · Master', title: 'Acesso ao módulo de horários', subtitle: 'Habilite ou desabilite o controle de jornada por conta. Desabilitar o módulo não apaga os perfis existentes.', actions: ` `, })}
Carregando contas...
`; STATE.pageRoot .querySelector( '#cw-wt-close-page' ) ?.addEventListener( 'click', closeModulePage ); try { await loadCompaniesForMaster(); renderMasterTable(); } catch (error) { logError( 'Erro ao carregar Master', error ); const content = STATE.pageRoot ?.querySelector( '#cw-wt-master-content' ); if (content) { content.innerHTML = `
Não foi possível carregar as contas disponíveis.
`; } } } function renderMasterTable( filter = '' ) { const content = STATE.pageRoot ?.querySelector( '#cw-wt-master-content' ); if (!content) { return; } const q = String(filter || '') .trim() .toLowerCase(); const list = STATE.companies.filter( c => !q || c.company_name .toLowerCase() .includes(q) || c.account_id .includes(q) ); content.innerHTML = `
${STATE.companies.length} conta(s)
${ list .map( company => ` ` ) .join('') || ` ` }
Empresa Account ID Status Acesso
${escapeHtml( company.company_name )}
${escapeHtml( company.timezone )} · aviso ${company.warning_minutes} min
#${escapeHtml( company.account_id )} ${ company.enabled ? 'Habilitado' : 'Desabilitado' }
Nenhuma conta encontrada.
`; content .querySelector( '#cw-wt-company-search' ) ?.addEventListener( 'input', event => renderMasterTable( event.target.value ) ); content .querySelectorAll( '.cw-wt-company-toggle' ) .forEach(toggle => { toggle.addEventListener( 'change', () => toggleCompany( toggle ) ); }); } async function toggleCompany( toggle ) { const row = toggle.closest( 'tr[data-account-id]' ); const accountId = row?.dataset .accountId; const company = STATE.companies.find( c => c.account_id === accountId ); if ( !company || !row ) { return; } const previous = company.enabled; const next = toggle.checked; toggle.disabled = true; try { const data = await n8nRequest( '/work-schedule/accounts/toggle', { method: 'POST', body: JSON.stringify({ account_id: company.account_id, company_name: company.company_name, enabled: next, timezone: company.timezone || CONFIG.defaultTimezone, warning_minutes: company.warning_minutes ?? CONFIG.defaultWarningMinutes, requester_user_id: String( STATE.currentUser.id ), }), } ); if ( data.success !== true ) { throw new Error( data.message || 'Falha ao atualizar a conta.' ); } company.enabled = next; const cell = row.querySelector( '.cw-wt-company-status' ); if (cell) { cell.innerHTML = ` ${ next ? 'Habilitado' : 'Desabilitado' } `; } notify( next ? 'Módulo habilitado para a conta.' : 'Módulo desabilitado para a conta.', 'success' ); } catch (error) { company.enabled = previous; toggle.checked = previous; notify( error.message || 'Erro ao atualizar a conta.', 'error' ); logError( 'Erro no toggle Master', error ); } finally { toggle.disabled = false; } } function removeWarningBanner() { document .getElementById( 'cw-wt-warning' ) ?.remove(); } function renderWarningBanner( access ) { if ( !access || access.state !== 'WARNING' ) { removeWarningBanner(); return; } let root = document.getElementById( 'cw-wt-warning' ); if (!root) { root = document.createElement( 'div' ); root.id = 'cw-wt-warning'; document.body .appendChild(root); } const end = access.current_period ?.end || ''; const remaining = Number( access.minutes_remaining || 0 ); root.innerHTML = `
${svgClock(16)}
Seu expediente termina ${ end ? `às ${escapeHtml(end)}` : 'em breve' }
${ remaining > 0 ? `Faltam ${remaining} minuto(s). ` : '' } Finalize os atendimentos em andamento antes do encerramento da jornada.
`; } function nextPeriodLabel( access ) { const next = access?.next_period; if (!next) { return ( 'Nenhum próximo período configurado.' ); } return ( `${ next.day_label || next.date || 'Próximo período' }, ` + `${next.start} – ${next.end}` ); } function applyLockScreen( access ) { removeWarningBanner(); let root = document.getElementById( 'cw-wt-lock' ); if (!root) { root = document.createElement( 'div' ); root.id = 'cw-wt-lock'; document.body .appendChild(root); } root.innerHTML = `
${svgClock(23)}

Expediente encerrado

Você está fora do período permitido para este perfil de trabalho.

Próximo período
${escapeHtml( nextPeriodLabel( access ) )}
Perfil: ${escapeHtml( access?.profile ?.name || 'Horário de trabalho' )}
`; root .querySelector( '#cw-wt-logout' ) ?.addEventListener( 'click', performLogout ); installLockObserver( access ); } function installLockObserver( access ) { if (STATE.lockObserver) { return; } STATE.lockObserver = new MutationObserver( () => { if ( STATE.destroyed || STATE.currentAccess ?.state !== 'BLOCKED' ) { return; } const lock = document.getElementById( 'cw-wt-lock' ); if (!lock) { STATE.lockObserver .disconnect(); STATE.lockObserver = null; applyLockScreen( access ); return; } const cs = getComputedStyle( lock ); const bad = cs.display === 'none' || cs.visibility === 'hidden' || Number( cs.opacity ) === 0 || cs.pointerEvents === 'none' || Number( cs.zIndex || 0 ) < 2147483000; if (bad) { lock.setAttribute( 'style', ( 'position:fixed!important;' + 'inset:0!important;' + 'z-index:2147483647!important;' + 'display:flex!important;' + 'visibility:visible!important;' + 'opacity:1!important;' + 'pointer-events:auto!important;' ) ); } } ); STATE.lockObserver.observe( document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: [ 'style', 'class', ], } ); } function removeLockScreen() { document .getElementById( 'cw-wt-lock' ) ?.remove(); if (STATE.lockObserver) { STATE.lockObserver .disconnect(); STATE.lockObserver = null; } } async function performLogout() { const button = document.getElementById( 'cw-wt-logout' ); if (button) { button.disabled = true; button.textContent = 'Saindo...'; } let success = false; try { if ( window.axios ?.delete ) { const response = await window.axios.delete( '/auth/sign_out' ); success = response?.status >= 200 && response?.status < 300; } } catch (error) { log( 'Logout via axios falhou, tentando fetch.', error?.message || error ); } if (!success) { try { const response = await fetch( '/auth/sign_out', { method: 'DELETE', credentials: 'same-origin', headers: { Accept: 'application/json', ...getAuthHeaders(), }, } ); success = response.ok; } catch (error) { logError( 'Logout via fetch falhou', error ); } } if (success) { window.location.assign( '/app/login' ); return; } if (button) { button.disabled = false; button.textContent = 'Tentar sair novamente'; } notify( 'Não foi possível encerrar a sessão. Tente novamente.', 'error', 5000 ); } async function checkSchedule() { if (STATE.destroyed) { return; } const ok = await loadContext( false ); if ( !ok || !STATE.accountId || !STATE.currentUser?.id ) { return; } /* * Conta Master fica isenta * para evitar auto-lockout. */ if (STATE.isMaster) { STATE.currentAccess = { state: 'MASTER_EXEMPT', allowed: true, }; removeLockScreen(); removeWarningBanner(); return; } try { const access = await n8nRequest( `/work-schedule/check?account_id=${encodeURIComponent( STATE.accountId )}&user_id=${encodeURIComponent( STATE.currentUser.id )}&_t=${Date.now()}` ); STATE.currentAccess = access; if ( access.feature_disabled === true || access.state === 'FEATURE_DISABLED' || access.state === 'UNASSIGNED' || access.state === 'CONFIG_ERROR' || access.state === 'ALLOWED' ) { removeLockScreen(); removeWarningBanner(); return; } if ( access.state === 'WARNING' ) { removeLockScreen(); renderWarningBanner( access ); return; } if ( access.allowed === false || access.state === 'BLOCKED' ) { applyLockScreen( access ); return; } removeLockScreen(); removeWarningBanner(); } catch (error) { /* * Fail-open: * se N8N cair, * Chatwoot continua operacional. */ logError( 'Falha no check de jornada; aplicando fail-open.', error ); STATE.currentAccess = { state: 'BACKEND_UNAVAILABLE', allowed: true, }; removeLockScreen(); removeWarningBanner(); } } async function handlePossibleAccountChange() { const now = getAccountIdFromLocation(); if ( !now || String(now) === String( STATE.accountId || '' ) ) { return; } log( 'Troca de account detectada:', STATE.accountId, '->', now ); closeModulePage(); removeLockScreen(); removeWarningBanner(); STATE.currentAccess = null; STATE.profiles = []; STATE.agents = []; STATE.feature.loaded = false; STATE.accountId = String(now); await loadContext(true); injectSidebarMenuItem(); refreshMenuVisibility(); checkSchedule(); } function installNavigationBehavior() { addListener( document, 'click', event => { if (!STATE.pageOpen) { return; } const anchor = event.target .closest?.('a'); if (!anchor) { return; } if ( anchor.closest?.( '#menu-item-work-schedule-v14' ) ) { return; } if ( anchor.closest?.( '#cw-wt-page' ) ) { return; } /* * Não bloqueia a navegação nativa. * Só desmonta a camada do módulo. */ closeModulePage(); }, true ); addListener( window, 'resize', positionPageRoot ); addListener( window, 'focus', () => checkSchedule() ); addListener( document, 'visibilitychange', () => { if (!document.hidden) { checkSchedule(); } } ); addListener( window, 'popstate', () => { handlePossibleAccountChange(); if (STATE.pageOpen) { positionPageRoot(); } } ); } function installMenuObserver() { if (STATE.menuObserver) { return; } let scheduled = false; STATE.menuObserver = new MutationObserver( () => { if ( scheduled || STATE.destroyed ) { return; } scheduled = true; requestAnimationFrame( () => { scheduled = false; injectSidebarMenuItem(); if (STATE.pageOpen) { positionPageRoot(); } } ); } ); STATE.menuObserver.observe( document.body, { childList: true, subtree: true, } ); } function startTimers() { if (!STATE.checkTimer) { STATE.checkTimer = setInterval( checkSchedule, CONFIG.checkIntervalMs ); } if (!STATE.contextTimer) { STATE.contextTimer = setInterval( handlePossibleAccountChange, CONFIG.contextPollMs ); } } function destroy() { STATE.destroyed = true; closeModulePage(); removeLockScreen(); removeWarningBanner(); document .getElementById( 'menu-item-work-schedule-v14' ) ?.remove(); document .getElementById( 'cw-wt-toast-root' ) ?.remove(); if (STATE.menuObserver) { STATE.menuObserver .disconnect(); } if (STATE.lockObserver) { STATE.lockObserver .disconnect(); } if (STATE.checkTimer) { clearInterval( STATE.checkTimer ); } if (STATE.contextTimer) { clearInterval( STATE.contextTimer ); } STATE.listeners .splice(0) .forEach(off => { try { off(); } catch (_) {} }); } async function init() { injectStyles(); installNavigationBehavior(); installMenuObserver(); startTimers(); const ok = await loadContext(true); if (ok) { injectSidebarMenuItem(); await checkSchedule(); log( 'Inicializado', { accountId: STATE.accountId, userId: STATE.currentUser?.id, role: STATE.accountRole, master: STATE.isMaster, featureEnabled: STATE.feature.enabled, } ); } else { logError( 'Não foi possível inicializar o contexto do Chatwoot.' ); } } window[INSTANCE_KEY] = { version: VERSION, state: STATE, destroy, checkSchedule, openModulePage, }; if ( document.readyState === 'loading' ) { document.addEventListener( 'DOMContentLoaded', init, { once: true, } ); } else { init(); } })();