const PID_WIDGET_TYPES = { "calendario-individual": { label: "Calendário Individual", description: "Próximos compromissos da sua agenda pessoal.", href: "calendario-individual.html", linkLabel: "Ver calendário completo", // Mesmo gate usado pra esconder o item "Calendário Individual" da sidebar // (data-section="calendario-individual", pidApplyAccessVisibility em // access.js) — sem isso, um perfil sem esse módulo habilitado ainda // conseguia adicionar o widget (e, por ele, chegar ao calendário // completo) mesmo com o link do menu corretamente escondido. visibleIf: (me) => !!(me.permissoes_efetivas?.["calendario-individual"]?.enabled), }, "links-favoritos": { label: "Links Favoritos", description: "Seus atalhos favoritos de Links & Ferramentas.", href: "links-ferramentas.html", linkLabel: "Ver todos os links", visibleIf: (me) => !!(me.permissoes_efetivas?.["links-ferramentas"]?.apps?.["links-ferramentas-visualizar"]), }, }; const PID_WIDGET_LINK_DEFAULT_ICON = ''; const PID_WIDGET_DATA_FORMATO = new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "2-digit" }); const PID_WIDGET_DATA_HORA_FORMATO = new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", }); const PID_WIDGET_DEFAULT_WIDTH = 320; const PID_WIDGET_DEFAULT_HEIGHT = 240; async function pidFetchWidgets() { return (await pidApiRequest("/widgets/")) || []; } async function pidAddWidget(tipo) { return pidApiRequest("/widgets/", { method: "POST", body: { tipo } }); } async function pidRemoveWidget(tipo) { await pidApiRequest(`/widgets/${encodeURIComponent(tipo)}/`, { method: "DELETE" }); } async function pidUpdateWidget(tipo, payload) { return pidApiRequest(`/widgets/${encodeURIComponent(tipo)}/`, { method: "PATCH", body: payload }); } document.addEventListener("DOMContentLoaded", async () => { const widgetsGrid = document.getElementById("widgets-grid"); if (!widgetsGrid) return; const me = await pidGetMe(); if (!me) return; const widgetsEmpty = document.getElementById("widgets-empty"); const addBtn = document.getElementById("widget-add-btn"); const pickerModal = document.getElementById("widget-picker-modal"); const pickerList = document.getElementById("widget-picker-list"); const textoWidgetsVazio = widgetsEmpty ? widgetsEmpty.textContent : ""; const PID_WIDGET_ERRO_CARGA = "Não foi possível carregar. Recarregue a página; se persistir, contate a Inovação."; function todayIso() { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } function renderCalendarWidgetBody(events) { const today = todayIso(); const upcoming = events .filter((ev) => ev.data >= today) .sort((a, b) => { const dateCompare = a.data.localeCompare(b.data); return dateCompare !== 0 ? dateCompare : (a.horario || "").localeCompare(b.horario || ""); }) .slice(0, 5); if (!upcoming.length) { return `

Nenhum compromisso agendado.

`; } return upcoming .map((ev) => { // ev.data é data pura ("AAAA-MM-DD"): monta pelas partes no fuso local // (new Date("AAAA-MM-DD") seria UTC e cairia no dia anterior). const [ano, mes, dia] = ev.data.split("-").map(Number); const horario = ev.horario ? ev.horario.slice(0, 5) : ""; let dateLabel; if (horario) { const [hora, minuto] = horario.split(":").map(Number); // pt-BR devolve "05/10, 09:03": tira a vírgula pra manter o "05/10 09:03" de antes. dateLabel = PID_WIDGET_DATA_HORA_FORMATO.format(new Date(ano, mes - 1, dia, hora, minuto)).replace(",", ""); } else { dateLabel = PID_WIDGET_DATA_FORMATO.format(new Date(ano, mes - 1, dia)); } return `
${pidEscapeHtml(dateLabel)} ${pidEscapeHtml(ev.titulo)} ${!ev.sou_dono ? `— ${pidEscapeHtml(ev.dono_nome)}` : ""}
`; }) .join(""); } function renderLinksFavoritosWidgetBody(links, favoriteIds) { const favoriteSet = new Set(favoriteIds); const favoritos = links.filter((link) => favoriteSet.has(link.id)); if (!favoritos.length) { return `

Nenhum link favoritado ainda.

`; } return ` `; } async function widgetBodyFor(typeKey) { if (typeKey === "calendario-individual") return renderCalendarWidgetBody(await pidFetchEvents()); if (typeKey === "links-favoritos") { const [links, favoriteIds] = await Promise.all([pidFetchLinks(), pidFetchLinkFavoritos()]); return renderLinksFavoritosWidgetBody(links, favoriteIds); } return ""; } let widgets = []; let widgetBodyCache = {}; let resizeObserver = null; const resizeSaveTimers = new Map(); function widgetCardHtml(widget) { const type = PID_WIDGET_TYPES[widget.tipo]; const width = widget.largura || PID_WIDGET_DEFAULT_WIDTH; const height = widget.altura || PID_WIDGET_DEFAULT_HEIGHT; const body = widgetBodyCache[widget.tipo] || ""; return `

${type.label}

${body}
${type.linkLabel} →
`; } function observeWidgetSizes() { if (resizeObserver) resizeObserver.disconnect(); resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { const card = entry.target; const tipo = card.getAttribute("data-widget-id"); const width = Math.round(card.offsetWidth); const height = Math.round(card.offsetHeight); const widget = widgets.find((w) => w.tipo === tipo); if (!widget || (widget.largura === width && widget.altura === height)) continue; widget.largura = width; widget.altura = height; clearTimeout(resizeSaveTimers.get(tipo)); resizeSaveTimers.set( tipo, setTimeout(() => { pidUpdateWidget(tipo, { largura: width, altura: height }).catch((e) => { pidAlert(`O novo tamanho do widget não foi salvo e voltará ao anterior ao recarregar a página. ${e.message}`, { titulo: "Não foi possível salvar o tamanho", }); }); }, 500) ); } }); widgetsGrid.querySelectorAll(".widget-card").forEach((card) => resizeObserver.observe(card)); } function renderWidgetsDom() { widgetsGrid.querySelectorAll(".widget-card").forEach((el) => el.remove()); const cardsHtml = widgets.map((widget) => widgetCardHtml(widget)).join(""); if (widgetsEmpty) { widgetsEmpty.insertAdjacentHTML("beforebegin", cardsHtml); widgetsEmpty.hidden = widgets.length !== 0; } else { widgetsGrid.insertAdjacentHTML("beforeend", cardsHtml); } observeWidgetSizes(); } async function renderWidgets() { widgets = (await pidFetchWidgets()).filter((w) => { const type = PID_WIDGET_TYPES[w.tipo]; return type && (!type.visibleIf || type.visibleIf(me)); }); // Falha no conteúdo de um widget fica só nele (e não vira "nenhum compromisso"). const bodies = await Promise.all( widgets.map((w) => widgetBodyFor(w.tipo).catch(() => `

${PID_WIDGET_ERRO_CARGA}

`) ) ); widgetBodyCache = {}; widgets.forEach((w, idx) => { widgetBodyCache[w.tipo] = bodies[idx]; }); if (widgetsEmpty) widgetsEmpty.textContent = textoWidgetsVazio; renderWidgetsDom(); } /** renderWidgets com tratamento de erro: falha ao buscar a lista de widgets * mostra o aviso no lugar do "Nenhum widget adicionado". */ async function carregarWidgets() { try { await renderWidgets(); } catch (e) { widgetsGrid.querySelectorAll(".widget-card").forEach((el) => el.remove()); if (widgetsEmpty) { widgetsEmpty.textContent = PID_WIDGET_ERRO_CARGA; widgetsEmpty.hidden = false; } } } async function renderPicker() { const added = widgets.map((w) => w.tipo); const available = Object.keys(PID_WIDGET_TYPES).filter((key) => { if (added.includes(key)) return false; const type = PID_WIDGET_TYPES[key]; return !type.visibleIf || type.visibleIf(me); }); pickerList.innerHTML = available.length ? available .map( (key) => ` ` ) .join("") : `

Você já adicionou todos os widgets disponíveis.

`; } addBtn.addEventListener("click", async () => { await renderPicker(); pickerModal.hidden = false; }); const pickerCloseBtn = document.getElementById("widget-picker-close-btn"); if (pickerCloseBtn) { pickerCloseBtn.addEventListener("click", () => { pickerModal.hidden = true; }); } // Enquanto um widget está sendo adicionado, todas as opções ficam travadas: // clique duplo mandaria dois POST. let adicionandoWidget = false; pickerModal.addEventListener("click", async (event) => { if (event.target === pickerModal) { pickerModal.hidden = true; return; } const choice = event.target.closest("[data-widget-choice]"); if (!choice || adicionandoWidget) return; const key = choice.getAttribute("data-widget-choice"); const opcoes = pickerList.querySelectorAll("[data-widget-choice]"); adicionandoWidget = true; opcoes.forEach((btn) => { btn.disabled = true; }); try { await pidAddWidget(key); pickerModal.hidden = true; } catch (e) { await pidAlert(e.message, { titulo: "Não foi possível adicionar o widget" }); return; } finally { adicionandoWidget = false; opcoes.forEach((btn) => { btn.disabled = false; }); } await carregarWidgets(); }); widgetsGrid.addEventListener("click", async (event) => { const removeBtn = event.target.closest("[data-widget-remove]"); if (!removeBtn || removeBtn.disabled) return; const tipo = removeBtn.getAttribute("data-widget-remove"); const type = PID_WIDGET_TYPES[tipo]; const confirmado = await pidConfirm(`Remover o widget "${type ? type.label : tipo}" da tela inicial?`, { titulo: "Remover widget", textoConfirmar: "Remover", }); if (!confirmado) return; removeBtn.disabled = true; try { await pidRemoveWidget(tipo); } catch (e) { removeBtn.disabled = false; await pidAlert(e.message, { titulo: "Não foi possível remover o widget" }); return; } await carregarWidgets(); }); let draggedTipo = null; let dropTargetTipo = null; let dropBefore = true; function clearDropHighlight() { widgetsGrid.querySelectorAll(".widget-card--drop-before, .widget-card--drop-after").forEach((el) => { el.classList.remove("widget-card--drop-before", "widget-card--drop-after"); }); } widgetsGrid.addEventListener("dragstart", (event) => { const header = event.target.closest(".widget-card__header"); const card = header && header.closest(".widget-card"); if (!card) return; draggedTipo = card.getAttribute("data-widget-id"); event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", draggedTipo); requestAnimationFrame(() => card.classList.add("widget-card--dragging")); }); widgetsGrid.addEventListener("dragover", (event) => { if (draggedTipo === null) return; const card = event.target.closest(".widget-card"); if (!card || card.getAttribute("data-widget-id") === draggedTipo) return; event.preventDefault(); const rect = card.getBoundingClientRect(); dropBefore = event.clientX < rect.left + rect.width / 2; dropTargetTipo = card.getAttribute("data-widget-id"); clearDropHighlight(); card.classList.add(dropBefore ? "widget-card--drop-before" : "widget-card--drop-after"); }); widgetsGrid.addEventListener("drop", async (event) => { if (draggedTipo === null) return; event.preventDefault(); clearDropHighlight(); const fromTipo = draggedTipo; const toTipo = dropTargetTipo; draggedTipo = null; dropTargetTipo = null; if (toTipo === null || fromTipo === toTipo) return; const fromIndex = widgets.findIndex((w) => w.tipo === fromTipo); if (fromIndex === -1) return; const reordered = widgets.slice(); const [moved] = reordered.splice(fromIndex, 1); let insertIndex = reordered.findIndex((w) => w.tipo === toTipo); if (insertIndex === -1) return; if (!dropBefore) insertIndex += 1; reordered.splice(insertIndex, 0, moved); const updates = reordered .map((widget, index) => ({ widget, index })) .filter(({ widget, index }) => widget.ordem !== index); if (!updates.length) return; widgets = reordered; renderWidgetsDom(); try { await Promise.all(updates.map(({ widget, index }) => pidUpdateWidget(widget.tipo, { ordem: index }))); } catch (e) { await pidAlert(e.message, { titulo: "Não foi possível salvar a nova ordem" }); } // Recarrega sempre do servidor: se algum PATCH falhou, volta à ordem gravada. await carregarWidgets(); }); widgetsGrid.addEventListener("dragend", () => { draggedTipo = null; dropTargetTipo = null; clearDropHighlight(); widgetsGrid .querySelectorAll(".widget-card--dragging") .forEach((el) => el.classList.remove("widget-card--dragging")); }); await carregarWidgets(); });