1import { initializeApp } from "https://www.gstatic.com/firebasejs/11.10.0/firebase-app.js"; 2import { 3 getAuth, GoogleAuthProvider, signInWithPopup, signOut, onAuthStateChanged, 4 setPersistence, browserLocalPersistence 5} from "https://www.gstatic.com/firebasejs/11.10.0/firebase-auth.js"; 6import { 7 getFirestore, collection, doc, setDoc, addDoc, updateDoc, deleteDoc, getDoc, 8 onSnapshot, query, where, serverTimestamp, writeBatch 9} from "https://www.gstatic.com/firebasejs/11.10.0/firebase-firestore.js"; 10 11const firebaseConfig = { 12 apiKey: "AIzaSyA5vOjz33CezRcsl7spRSJfEZfdaOC6-h0", 13 authDomain: "ariel-estudio-operativo.firebaseapp.com", 14 projectId: "ariel-estudio-operativo", 15 storageBucket: "ariel-estudio-operativo.firebasestorage.app", 16 messagingSenderId: "724194824103", 17 appId: "1:724194824103:web:52e16ad84b256450fef082" 18}; 19 20const OWNER_EMAIL = "[email protected]"; 21const app = initializeApp(firebaseConfig); 22const auth = getAuth(app); 23const db = getFirestore(app); 24const provider = new GoogleAuthProvider(); 25provider.setCustomParameters({ prompt: "select_account" }); 26setPersistence(auth, browserLocalPersistence); 27 28const root = document.querySelector("#app"); 29const toastEl = document.querySelector("#toast"); 30let currentUser = null; 31let role = null; 32let clientId = null; 33let activeSection = "dashboard"; 34let unsubscribers = []; 35let clockInterval = null; 36let deferredInstall = null; 37let preparedEmailText = ""; 38const EXCHANGE_RATE_CACHE_KEY = "ariel-usd-crc-rate"; 39let exchangeRate = loadExchangeRate(); 40let exchangeRateRequest = null; 41 42const state = { 43 clients: [], projects: [], timeEntries: [], incomes: [], expenses: [], invoices: [], payments: [], client: null 44}; 45 46const icons = { 47 dashboard: "â«", timer: "â", clients: "â", projects: "â", invoices: "â£", finances: "â¡", reports: "â", portal: "â", hours: "â·" 48}; 49 50const navOwner = [ 51 ["dashboard", "Resumen"], ["timer", "Punch in / out"], ["clients", "Clientes"], 52 ["projects", "Proyectos"], ["invoices", "Facturas"], ["finances", "Ingresos y gastos"], ["reports", "Informes"] 53]; 54const navClient = [["portal", "Mi proyecto"], ["hours", "Horas trabajadas"]]; 55 56function esc(value = "") { 57 return String(value).replace(/[&<>'"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ }[char])); 58} 59 60function toast(message, error = false) { 61 toastEl.textContent = message; 62 toastEl.className = `toast show${error ? " error" : ""}`; 63 clearTimeout(toastEl.timer); 64 toastEl.timer = setTimeout(() => toastEl.className = "toast", 3300); 65} 66 67function formatMoney(amount = 0, currency = "CRC") { 68 const locale = currency === "USD" ? "en-US" : "es-CR"; 69 return new Intl.NumberFormat(locale, { style: "currency", currency, maximumFractionDigits: currency === "CRC" ? 0 : 2 }).format(Number(amount) || 0); 70} 71 72function loadExchangeRate() { 73 try { 74 const saved = JSON.parse(localStorage.getItem(EXCHANGE_RATE_CACHE_KEY) || "null"); 75 return Number(saved?.rate) > 0 ? saved : null; 76 } catch { 77 return null; 78 } 79} 80 81function exchangeRateDate(value) { 82 if (!value) return ""; 83 const date = new Date(value); 84 return Number.isNaN(date.getTime()) ? "" : date.toLocaleString("es-CR", { dateStyle: "medium", timeStyle: "short" }); 85} 86 87async function refreshExchangeRate(force = false) { 88 const age = exchangeRate?.fetchedAt ? Date.now() - new Date(exchangeRate.fetchedAt).getTime() : Infinity; 89 if (!force && exchangeRate && age < 6 * 60 * 60 * 1000) return; 90 if (exchangeRateRequest) return exchangeRateRequest; 91 exchangeRateRequest = fetch("https://open.er-api.com/v6/latest/USD", { cache: "no-store" }) 92 .then((response) => { 93 if (!response.ok) throw new Error("No se pudo consultar el tipo de cambio"); 94 return response.json(); 95 }) 96 .then((data) => { 97 const rate = Number(data?.rates?.CRC); 98 if (data?.result !== "success" || !(rate > 0)) throw new Error("La fuente no devolvió una tasa válida"); 99 exchangeRate = { 100 rate, 101 fetchedAt: new Date().toISOString(), 102 sourceUpdatedAt: data.time_last_update_utc || "", 103 nextUpdateAt: data.time_next_update_utc || "" 104 }; 105 localStorage.setItem(EXCHANGE_RATE_CACHE_KEY, JSON.stringify(exchangeRate)); 106 if (activeSection === "reports") render(); 107 if (force) toast("Tipo de cambio actualizado."); 108 }) 109 .catch(() => { 110 if (force) toast(exchangeRate ? "No fue posible actualizar. Se conserva la última tasa válida." : "No fue posible obtener el tipo de cambio.", true); 111 if (activeSection === "reports") render(); 112 }) 113 .finally(() => { exchangeRateRequest = null; }); 114 return exchangeRateRequest; 115} 116 117// Las fuentes internas de jsPDF no incluyen el simbolo de colones y lo muestran como "!". 118function formatPdfMoney(amount = 0, currency = "CRC") { 119 const value = Number(amount) || 0; 120 if (currency === "USD") return `$${new Intl.NumberFormat("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value)}`; 121 return `CRC ${new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(value)}`; 122} 123 124function moneySummary(items, amountField = "amount") { 125 const crc = items.filter((x) => x.currency !== "USD").reduce((sum, x) => sum + Number(x[amountField] || 0), 0); 126 const usd = items.filter((x) => x.currency === "USD").reduce((sum, x) => sum + Number(x[amountField] || 0), 0); 127 return `${formatMoney(crc, "CRC")} · ${formatMoney(usd, "USD")}`; 128} 129 130function balanceSummary(incomes, expenses) { 131 const balance = (currency) => incomes.filter((x) => (x.currency || "CRC") === currency).reduce((sum, x) => sum + Number(x.amount || 0), 0) - expenses.filter((x) => (x.currency || "CRC") === currency).reduce((sum, x) => sum + Number(x.amount || 0), 0); 132 return `${formatMoney(balance("CRC"), "CRC")} · ${formatMoney(balance("USD"), "USD")}`; 133} 134 135function isCancelledStatus(status = "") { 136 return ["cancelled", "anulada", "anulado"].includes(String(status).toLowerCase()); 137} 138 139function invoicePaidAmount(invoice) { 140 const recorded = invoice?.paidAmount == null && invoice?.status === "paid" ? Number(invoice?.total || 0) : Number(invoice?.paidAmount || 0); 141 return Math.min(Number(invoice?.total || 0), Math.max(0, recorded)); 142} 143 144function invoicePaymentDate(invoice) { 145 const dates = state.payments.filter((payment) => payment.invoiceId === invoice?.id && payment.date).map((payment) => payment.date).sort(); 146 if (dates.length) return dates[dates.length - 1]; 147 if (invoice?.paidAt?.toDate) return localDate(invoice.paidAt.toDate()); 148 return invoice?.issueDate || localDate(); 149} 150 151function incomePaidAmount(income) { 152 if (income?.linkedInvoiceId) { 153 const recorded = income.paidAmount == null && isPaidStatus(income.status) ? Number(income.amount || 0) : Number(income.paidAmount || 0); 154 return Math.min(Number(income.amount || 0), Math.max(0, recorded)); 155 } 156 return isPaidStatus(income?.status) ? Number(income?.amount || 0) : 0; 157} 158 159function incomeBalance(income) { 160 return Math.max(0, Number(income?.amount || 0) - incomePaidAmount(income)); 161} 162 163function paymentTermsLabel(terms = "full", depositPercent = 50) { 164 if (terms === "split") return `${Number(depositPercent || 50)}% adelantado y ${100 - Number(depositPercent || 50)}% contra entrega`; 165 if (terms === "custom") return "Pagos parciales según acuerdo"; 166 return "Pago completo"; 167} 168 169function formatDuration(seconds = 0) { 170 const total = Math.max(0, Math.floor(Number(seconds) || 0)); 171 const h = Math.floor(total / 3600); 172 const m = Math.floor((total % 3600) / 60); 173 const s = total % 60; 174 return [h, m, s].map((n) => String(n).padStart(2, "0")).join(":"); 175} 176 177function dateOf(item, field = "date") { 178 const value = item[field]; 179 if (value?.toDate) return value.toDate(); 180 if (value) return new Date(value.length === 10 ? `${value}T12:00:00` : value); 181 return new Date(0); 182} 183 184function localDate(date = new Date()) { 185 const offset = date.getTimezoneOffset(); 186 return new Date(date.getTime() - offset * 60000).toISOString().slice(0, 10); 187} 188 189function monthKey(date) { 190 return `${date.getFullYear()}
190-${String(date.getMonth() + 1).padStart(2, "0")}`; 191} 192 193function itemName(items, id, fallback = "â") { 194 return items.find((item) => item.id === id)?.name || fallback; 195} 196 197function totalSeconds(entries) { 198 return entries.reduce((sum, entry) => sum + entrySeconds(entry), 0); 199} 200 201function entrySeconds(entry) { 202 if (entry.status !== "active") return Number(entry.durationSeconds) || 0; 203 const started = dateOf(entry, entry.startedAt ? "startedAt" : "startedAtClient"); 204 return Math.max(0, Math.floor((Date.now() - started.getTime()) / 1000)); 205} 206 207function cleanupListeners() { 208 unsubscribers.forEach((fn) => fn()); 209 unsubscribers = []; 210 clearInterval(clockInterval); 211} 212 213function loading() { 214 root.innerHTML = '<main class="loading-screen"><div class="spinner" aria-label="Cargando"></div></main>'; 215} 216 217function loginScreen() { 218 root.innerHTML = ` 219 <main class="login-screen"> 220 <section class="login-card"> 221 <img src="/logo.png" alt="Ariel Estudio Inmersivo" /> 222 <h1>Estudio Operativo</h1> 223 <p>Control de proyectos, finanzas y horas de trabajo en un solo lugar.</p> 224 <button class="google-button" id="google-login">Continuar con Google</button> 225 <p class="login-note">El acceso está reservado para el estudio y sus clientes autorizados.</p> 226 </section> 227 </main>`; 228 document.querySelector("#google-login").addEventListener("click", async () => { 229 try { await signInWithPopup(auth, provider); } catch (error) { toast(readableError(error), true); } 230 }); 231} 232 233function deniedScreen() { 234 root.innerHTML = ` 235 <main class="denied-screen"><section class="denied-card"> 236 <h1>Acceso pendiente</h1> 237 <p>La cuenta <strong>${esc(currentUser.email)}</strong> todavÃa no ha sido invitada por Ariel Estudio Inmersivo.</p> 238 <button class="button secondary" id="denied-signout">Usar otra cuenta</button> 239 </section></main>`; 240 document.querySelector("#denied-signout").addEventListener("click", () => signOut(auth)); 241} 242 243function readableError(error) { 244 const code = error?.code || ""; 245 if (code.includes("popup-closed")) return "Se cerró la ventana de acceso antes de completar el proceso."; 246 if (code.includes("permission-denied")) return "No tienes permiso para realizar esta acción."; 247 return "No fue posible completar la acción. Intenta de nuevo."; 248} 249 250async function resolveRole(user) { 251 const email = user.email.toLowerCase(); 252 if (email === OWNER_EMAIL) { 253 role = "owner"; 254 clientId = null; 255 await setDoc(doc(db, "users", user.uid), { 256 email, displayName: user.displayName || "Ariel Quesada", role: "owner", updatedAt: serverTimestamp() 257 }, { merge: true }); 258 return true; 259 } 260 const access = await getDoc(doc(db, "clientAccess", email)); 261 if (!access.exists()) return false; 262 role = "client"; 263 clientId = access.data().clientId; 264 await setDoc(doc(db, "users", user.uid), { 265 email, displayName: user.displayName || email, role: "client", clientId, updatedAt: serverTimestamp() 266 }, { merge: true }); 267 return true; 268} 269 270function navMarkup(items) { 271 return items.map(([id, label]) => `<button class="nav-button ${activeSection === id ? "active" : ""}" data-nav="${id}"><span class="nav-icon">${icons[id]}</span><span>${label}</span></button>`).join(""); 272} 273 274function shell() { 275 const items = role === "owner" ? navOwner : navClient; 276 const title = role === "owner" ? "Control operativo" : "Portal del cliente"; 277 root.innerHTML = ` 278 <div class="shell"> 279 <aside class="sidebar"> 280 <div class="brand"><img src="/logo.png" alt=""><div><strong>ARIEL</strong><small>ESTUDIO INMERSIVO</small></div></div> 281 <nav class="nav">${navMarkup(items)}</nav> 282 <div class="sidebar-bottom"> 283 <div class="account"><strong>${esc(currentUser.displayName || "Usuario")}</strong><small>${esc(currentUser.email)}</small></div> 284 <button class="text-button" data-action="signout">Cerrar sesión</button> 285 </div> 286 </aside> 287 <main class="main"> 288 <header class="topbar"><h1 id="page-title">${title}</h1><div class="top-actions"> 289 <button class="button secondary hidden" id="install-app" data-action="install"><span class="install-label">Instalar aplicación</span> â</button> 290 </div></header> 291 <div class="content" id="content"></div> 292 </main> 293 <nav class="mobile-nav">${navMarkup(items)}</nav> 294 </div> 295 <div id="modal-root"></div>`; 296 root.addEventListener("click", handleAction); 297 if (deferredInstall) document.querySelector("#install-app")?.classList.remove("hidden"); 298 render(); 299} 300 301function subscribe() { 302 cleanupListeners(); 303 if (role === "owner") { 304 [["clients", "clients"], ["projects", "projects"], ["timeEntries", "timeEntries"], ["incomes", "incomes"], ["expenses", "expenses"], ["invoices", "invoices"], ["payments", "payments"]].forEac
304h(([collectionName, key]) => { 305 unsubscribers.push(onSnapshot(collection(db, collectionName), (snapshot) => { 306 state[key] = snapshot.docs.map((d) => ({ id: d.id, ...d.data() })); 307 sortState(key); 308 render(); 309 }, (error) => toast(readableError(error), true))); 310 }); 311 } else { 312 unsubscribers.push(onSnapshot(doc(db, "clients", clientId), (snapshot) => { 313 state.client = snapshot.exists() ? { id: snapshot.id, ...snapshot.data() } : null; render(); 314 })); 315 unsubscribers.push(onSnapshot(query(collection(db, "projects"), where("clientId", "==", clientId)), (snapshot) => { 316 state.projects = snapshot.docs.map((d) => ({ id: d.id, ...d.data() })); sortState("projects"); render(); 317 })); 318 unsubscribers.push(onSnapshot(query(collection(db, "timeEntries"), where("clientId", "==", clientId)), (snapshot) => { 319 state.timeEntries = snapshot.docs.map((d) => ({ id: d.id, ...d.data() })); sortState("timeEntries"); render(); 320 })); 321 } 322 clockInterval = setInterval(updateLiveClocks, 1000); 323} 324 325function sortState(key) { 326 if (["timeEntries", "incomes", "expenses", "invoices", "payments"].includes(key)) state[key].sort((a, b) => dateOf(b, key === "timeEntries" ? (b.startedAt ? "startedAt" : "startedAtClient") : key === "invoices" ? "issueDate" : "date") - dateOf(a, key === "timeEntries" ? (a.startedAt ? "startedAt" : "startedAtClient") : key === "invoices" ? "issueDate" : "date")); 327 else state[key].sort((a, b) => String(a.name || "").localeCompare(String(b.name || ""), "es")); 328} 329 330function handleAction(event) { 331 const nav = event.target.closest("[data-nav]"); 332 if (nav) { 333 activeSection = nav.dataset.nav; 334 document.querySelectorAll("[data-nav]").forEach((el) => el.classList.toggle("active", el.dataset.nav === activeSection)); 335 render(); return; 336 } 337 const action = event.target.closest("[data-action]")?.dataset.action; 338 if (!action) return; 339 const id = event.target.closest("[data-id]")?.dataset.id; 340 if (action === "signout") signOut(auth); 341 if (action === "install") installApp(); 342 if (action === "new-client") clientModal(); 343 if (action === "edit-client") clientModal(state.clients.find((x) => x.id === id)); 344 if (action === "new-project") projectModal(); 345 if (action === "new-quote") projectModal(null, "quote"); 346 if (action === "edit-project") projectModal(state.projects.find((x) => x.id === id)); 347 if (action === "approve-quote") approveQuote(state.projects.find((x) => x.id === id)); 348 if (action === "download-quote") downloadQuotePdf(state.projects.find((x) => x.id === id)); 349 if (action === "new-invoice") invoiceModal(); 350 if (action === "edit-invoice") invoiceModal(state.invoices.find((x) => x.id === id)); 351 if (action === "download-invoice") downloadInvoicePdf(state.invoices.find((x) => x.id === id)); 352 if (action === "email-invoice") emailInvoice(state.invoices.find((x) => x.id === id)); 353 if (action === "copy-email-message") copyPreparedEmail(); 354 if (action === "mark-sent") markInvoiceSent(state.invoices.find((x) => x.id === id)); 355 if (action === "mark-paid") markInvoicePaid(state.invoices.find((x) => x.id === id)); 356 if (action === "register-payment") paymentModal(state.invoices.find((x) => x.id === id)); 357 if (action === "cancel-invoice") cancelInvoice(state.invoices.find((x) => x.id === id)); 358 if (action === "add-line") document.querySelector("#invoice-lines")?.insertAdjacentHTML("beforeend", invoiceLineMarkup()); 359 if (action === "remove-line") { 360 const lines = document.querySelectorAll("[data-invoice-line]"); 361 if (lines.length > 1) event.target.closest("[data-invoice-line]")?.remove(); 362 else toast("La factura necesita al menos un servicio.", true); 363 } 364 if (action === "new-finance") financeModal(event.target.closest("[data-type]")?.dataset.type || "income"); 365 if (action === "mark-finance-paid") markFinancePaid(event.target.closest("[data-type]").dataset.type, id); 366 if (action === "delete-finance") deleteFinance(event.target.closest("[data-type]").dataset.type, id); 367 if (action === "punch-in") punchIn(); 368 if (action === "punch-out") punchOut(id); 369 if (action === "export") exportCsv(); 370 if (action === "refresh-exchange-rate") refreshExchangeRate(true); 371 if (action === "close-modal") closeModal(); 372} 373 374function render() { 375 const content = document.querySelector("#content"); 376 if (!content) return; 377 const allowed = role === "owner" ? navOwner.map((x) =>
377 x[0]) : navClient.map((x) => x[0]); 378 if (!allowed.includes(activeSection)) activeSection = allowed[0]; 379 const titles = { dashboard: "Resumen", timer: "Control de tiempo", clients: "Clientes", projects: "Proyectos", invoices: "Facturas", finances: "Ingresos y gastos", reports: "Informes", portal: "Mi proyecto", hours: "Horas trabajadas" }; 380 document.querySelector("#page-title").textContent = titles[activeSection]; 381 const renderers = { dashboard: renderDashboard, timer: renderTimer, clients: renderClients, projects: renderProjects, invoices: renderInvoices, finances: renderFinances, reports: renderReports, portal: renderPortal, hours: renderClientHours }; 382 content.innerHTML = `<section class="section active">${renderers[activeSection]()}</section>`; 383 updateLiveClocks(); 384} 385 386function currentMonthData() { 387 const key = monthKey(new Date()); 388 return { 389 incomes: state.incomes.filter((item) => item.date?.slice(0, 7) === key && !isCancelledStatus(item.status)), 390 expenses: state.expenses.filter((item) => item.date?.slice(0, 7) === key && !isCancelledStatus(item.status)), 391 payments: state.payments.filter((item) => item.date?.slice(0, 7) === key), 392 time: state.timeEntries.filter((item) => monthKey(dateOf(item, item.startedAt ? "startedAt" : "startedAtClient")) === key) 393 }; 394} 395 396function renderDashboard() {
397 const month = currentMonthData(); 398 const manualPaid = month.incomes.filter((x) => !x.linkedInvoiceId && isPaidStatus(x.status)); 399 const legacyPaid = month.incomes.filter((x) => x.linkedInvoiceId && isPaidStatus(x.status) && !state.payments.some((payment) => payment.invoiceId === x.linkedInvoiceId)).map((x) => ({ ...x, amount: incomePaidAmount(x) })); 400 const collected = [...month.payments, ...manualPaid, ...legacyPaid]; 401 const pending = month.incomes.filter((x) => incomeBalance(x) > 0).map((x) => ({ ...x, amount: incomeBalance(x) })); 402 const paidCRC = collected.filter((x) => x.currency !== "USD").reduce((s, x) => s + Number(x.amount || 0), 0); 403 const expenseCRC = month.expenses.filter((x) => x.currency !== "USD").reduce((s, x) => s + Number(x.amount || 0), 0); 404 const balance = paidCRC - expenseCRC; 405 const active = state.timeEntries.find((x) => x.status === "active"); 406 const recent = [...state.incomes.map((x) => ({ ...x, kind: "Ingreso" })), ...state.expenses.map((x) => ({ ...x, kind: "Gasto" }))].filter((x) => !isCancelledStatus(x.status)).sort((a, b) => dateOf(b) - dateOf(a)).slice(0, 6); 407 return ` 408 <div class="section-head"><div><h2>Hola, Ariel</h2><p>Asà marcha el estudio durante este mes.</p></div><button class="button" data-action="new-finance" data-type="income">+ Registrar ingreso</button></div> 409 <div class="grid stats"> 410 ${stat("Ingresos cobrados", moneySummary(collected), `${collected.length} pagos recibidos`, "positive")} 411 ${stat("Por cobrar", moneySummary(pending), `${pending.length} facturas o ingresos pendientes`, "warning")} 412 ${stat("Gastos del mes", moneySummary(month.expenses), `${month.expenses.length} movimientos`, "negative")} 413 ${stat("Balance en colones", formatMoney(balance), "Cobrado menos gastos en CRC", balance >= 0 ? "positive" : "negative")} 414 ${stat("Horas trabajadas", (totalSeconds(month.time) / 3600).toFixed(1) + " h", `${state.projects.filter((x) => x.status === "active").length} proyectos activos`, "")} 415 </div> 416 <div class="grid two-columns"> 417 <div class="card timer-card ${active ? "timer-running" : ""}"> 418 <div class="timer-state"><span class="live-dot"></span>${active ? `Trabajando en ${esc(itemName(state.projects, active.projectId, "proyecto"))}` : "Temporizador detenido"}</div> 419 <div class="timer-display" data-live-entry="${active?.id || ""}">${active ? formatDuration(entrySeconds(active)) : "00:00:00"}</div> 420 ${active ? `<button class="button danger" data-action="punch-out" data-id="${active.id}">Punch out · Finalizar</button>` : `<button class="button success" data-nav="timer">Ir a Punch in</button>`} 421 </div> 422 <div class="card"><h3>Actividad financiera reciente</h3>${recent.length ? `<div class="list">${recent.map((x) => `<div class="list-item"><div><strong>${esc(x.description || x.category || x.kind)}</strong><small>${x.kind} · ${esc(x.date || "")}</small></div><span class="amount ${x.kind === "Ingreso" ? "positive" : "negative"}">${x.kind === "Ingreso" ? "+" : "â"}${formatMoney(x.amount, x.currency)}</span></div>`).join("")}</div>` : empty("TodavÃa no hay movimientos.")}</div> 423 </div>`; 424} 425 426function stat(label, value, meta, tone) { 427 return `<div class="card stat-card"><span class="stat-label">${label}</span><strong class="stat-value ${tone}">${value}</strong><span class="stat-meta">${meta}</span></div>`; 428} 429 430function renderTimer() { 431 const active = state.timeEntries.find((x) => x.status === "active"); 432 const today = localDate(); 433 const todayEntries = state.timeEntries.filter((x) => localDate(dateOf(x, x.startedAt ? "startedAt" : "startedAtClient")) === today); 434 return ` 435 <div class="section-head"><div><h2>Punch in / Punch out</h2><p>Registro exacto de cuándo empieza y termina cada sesión.</p></div></div> 436 <div class="grid two-columns"> 437 <div class="card timer-card ${active ? "timer-running" : ""}"> 438 <div class="timer-state"><span class="live-dot"></span>${active ? "Sesión en curso" : "Listo para trabajar"}</div> 439 <div class="timer-display" data-live-entry="${active?.id || ""}">${active ? formatDuration(entrySeconds(active)) : "00:00:00"}</div> 440 ${active ? `<div class="list-item"><div><strong>${esc(itemName(state.projects, active.projectId))}</strong><small>${esc(active.note || "Sin nota")}</small></div></div><div class="timer-actions"><button class="button danger" data-action="punch-out" data-id="${active.id}">â Punch out</button></div>` : ` 441 <form id="timer-form" class="form-grid"> 442 <div class="field"><label>Cliente</label><select id="timer-client" required><option value="">Seleccionar</option>${state.clients.filter((x) => x.status !== "archived").map((x) => `<option value="${x.id}">${esc(x.name)}</option>`).join("")}</select></div> 443 <div class="field"><label>Proyecto</label><select id="timer-project" required><option value="">Selecciona primero un cliente</option></select></div> 444 <div class="field full"><label>¿En qué vas a trabajar?</label><textarea id="timer-note" placeholder="Ej. Mezcla final del tema principal"></textarea></div> 445 <div class="field"><label>Tipo de tiempo</label><select id="timer-billable"><option value="true">Facturable</option><option value="false">No facturable</option></select></div> 446 <div class="form-actions"><button type="button" class="button success" data-action="punch-in" ${!state.projects.some((x) => x.status === "active") ? "disabled" : ""}>â¶ Punch in</button></div> 447 </form>`} 448 </div> 449 <div class="card"><h3>Hoy</h3><strong class="stat-value">${(totalSeconds(todayEntries) / 3600).toFixed(2)} h</strong><span class="stat-meta">${todayEntries.length} sesiones registradas</span><div class="list" style="margin-top:18px">${todayEntries.slice(0, 5).map(timeListItem).join("") || empty("Aún no hay sesiones hoy.")}</div></div> 450 </div>`; 451} 452 453function timeListItem(entry) { 454 const start = dateOf(entry, entry.startedAt ? "startedAt" : "startedAtClient"); 455 return `<div class="list-item"><div><strong>${esc(itemName(state.projects, entry.projectId))}</strong><small>
455${start.toLocaleString("es-CR", { dateStyle: "medium", timeStyle: "short" })}${entry.note ? ` · ${esc(entry.note)}` : ""}</small></div><span class="amount ${entry.status === "active" ? "positive" : ""}" ${entry.status === "active" ? `data-live-entry="${entry.id}"` : ""}>${formatDuration(entrySeconds(entry))}</span></div>`; 456} 457 458function renderClients() { 459 return ` 460 <div class="section-head"><div><h2>Clientes</h2><p>Invita clientes con su correo de Google y controla su acceso.</p></div><button class="button" data-action="new-client">+ Nuevo cliente</button></div> 461 <div class="card"><div class="table-wrap">${state.clients.length ? `<table><thead><tr><th>Cliente</th><th>Correo de acceso</th><th>Teléfono</th><th>Proyectos</th><th>Estado</th><th></th></tr></thead><tbody>${state.clients.map((client) => `<tr><td><strong>${esc(client.name)}</strong><br><small>${esc(client.company || "")}</small></td><td>${esc(client.email || "â")}</td><td>${esc(client.phone || "â")}</td><td>${state.projects.filter((p) => p.clientId === client.id).length}</td><td><span class="badge ${client.status === "active" ? "green" : "amber"}">${client.status === "active" ? "Activo" : "Archivado"}</span></td><td><button class="icon-button" data-action="edit-client" data-id="${client.id}">Editar</button></td></tr>`).join("")}</tbody></table>` : empty("Crea el primer cliente para comenzar.")}</div></div>`; 462} 463 464function projectStatusLabel(status = "active") { 465 return ({ quote: "Cotización", active: "Activo", paused: "Pausado", completed: "Completado", rejected: "No aprobado" })[status] || status; 466} 467 468function projectCard(project) { 469 const hours = totalSeconds(state.timeEntries.filter((x) => x.projectId === project.id)) / 3600; 470 const budget = Number(project.budgetHours) || 0; 471 const pct = budget ? Math.min(100, (hours / budget) * 100) : 0; 472 const type = project.billingType || "hourly"; 473 const total = projectEstimatedTotal(project); 474 const detail = type === "unit" ? `${Number(project.quantity || 0)} ${esc(project.unitName || "unidades")} à ${formatMoney(project.unitRate, project.currency)}` : type === "fixed" ? "Precio fijo del proyecto" : `${budget || "â"} horas à ${formatMoney(project.hourlyRate, project.currency)}`; 475 return `<article class="card project-card"><span class="badge ${project.status === "active" ? "green" : project.status === "quote" ? "" : "amber"}">${projectStatusLabel(project.status)}</span><span class="badge" style="margin-left:6px">${billingTypeLabel(type)}</span><h3 style="margin-top:16px">${esc(project.name)}</h3>${project.quoteNumber ? `<p class="stat-meta">${esc(project.quoteNumber)}</p>` : ""}<strong class="stat-value">${formatMoney(total, project.currency)}</strong><span class="stat-meta">${detail}</span>${project.status === "quote" ? `<p class="stat-meta" style="margin-top:12px">Válida hasta: ${esc(project.quoteValidUntil || "Sin fecha lÃmite")}</p>` : type === "hourly" ? `<p class="stat-meta" style="margin-top:12px">${hours.toFixed(1)} de ${budget || "â"} horas trabajadas</p><div class="progress"><span style="width:${pct}%"></span></div>` : `<p class="stat-meta" style="margin-top:12px">Tiempo registrado: ${hours.toFixed(1)} h</p>`}<div class="timer-actions">${project.status === "quote" ? `<button class="button secondary" data-action="download-quote" data-id="${project.id}">PDF</button><button class="button success" data-action="approve-quote" data-id="${project.id}">Aprobar</button>` : ""}<button class="button secondary" data-action="edit-project" data-id="${project.id}">Editar</button></div></article>`; 476} 477 478function renderProjects() { 479 const folders = state.clients.map((client) => ({ client, projects: state.projects.filter((project) => project.clientId === client.id) })).filter((folder) => folder.projects.length); 480 return ` 481 <div class="section-head"><div><h2>Proyectos por cliente</h2><p>Cada cliente tiene su propia carpeta de proyectos y cotizaciones.</p></div><div><button class="button secondary" data-action="new-quote" ${!state.clients.length ? "disabled" : ""}>+ Nueva cotización</button> <button class="button" data-action="new-project" ${!state.clients.length ? "disabled" : ""}>+ Nuevo proyecto</button></div></div> 482 <div class="client-folders">${folders.map(({ client, projects }) => `<details class="client-folder"><summary><span class="folder-icon">
482â¸</span><strong>${esc(client.name)}</strong><span>${projects.length} ${projects.length === 1 ? "proyecto" : "proyectos"} · ${projects.filter((x) => x.status === "quote").length} en cotización</span></summary><div class="folder-content grid three-columns">${projects.map(projectCard).join("")}</div></details>`).join("") || `<div class="card">${empty("No hay proyectos todavÃa.")}</div>`}</div>`; 483} 484 485function billingTypeLabel(type) { 486 return ({ hourly: "Por horas", fixed: "Paquete / fijo", unit: "Por unidad" })[type] || "Por horas"; 487} 488 489function projectEstimatedTotal(project) { 490 const type = project.billingType || "hourly"; 491 if (type === "fixed") return Number(project.fixedPrice || 0); 492 if (type === "unit") return Number(project.quantity || 0) * Number(project.unitRate || 0); 493 return Number(project.budgetHours || 0) * Number(project.hourlyRate || 0); 494} 495 496function invoiceStatus(status = "draft") { 497 const labels = { draft: "Borrador", sent: "Enviada", partial: "Pago parcial", paid: "Pagada", overdue: "Vencida", cancelled: "Anulada" }; 498 const tones = { draft: "amber", sent: "", partial: "amber", paid: "green", overdue: "red", cancelled: "red" }; 499 return `<span class="badge ${tones[status] || ""}">${labels[status] || status}</span>`; 500} 501 502function effectiveInvoiceStatus(invoice) { 503 if (["paid", "cancelled"].includes(invoice.status)) return invoice.status; 504 return invoice.dueDate && invoice.dueDate < localDate() ? "overdue" : (invoice.status || "draft"); 505} 506 507function renderInvoices() { 508 return ` 509 <div class="section-head"><div><h2>Facturas</h2><p>Crea una factura visual, descárgala en PDF y prepara el correo del cliente.</p></div><button class="button" data-action="new-invoice" ${!state.clients.length ? "disabled" : ""}>+ Nueva factura</button></div> 510 <div class="card"><div class="table-wrap">${state.invoices.length ? `<table><thead><tr><th>Factura</th><th>Cliente</th><th>Emisión</th><th>Vencimiento / pago</th><th>Estado</th><th>Total</th><th></th></tr></thead><tbody>${state.invoices.map((invoice) => `<tr> 511 <td><strong>${esc(invoice.number)}</strong><br><small>${esc(invoice.projectName || itemName(state.projects, invoice.projectId, ""))}</small></td> 512 <td>${esc(invoice.clientName || itemName(state.clients, invoice.clientId))}</td><td>${esc(invoice.issueDate)}</td><td>${effectiveInvoiceStatus(invoice) === "paid" ? `${esc(invoicePaymentDate(invoice))}<br><small>Fecha de pago</small>` : `${esc(invoice.dueDate || "â")}<br><small>Vencimiento</small>`}</td><td>${invoiceStatus(effectiveInvoiceStatus(invoice))}</td> 513 <td class="amount">${formatMoney(invoice.total, invoice.currency)}<br><small>Cobrado: ${formatMoney(invoicePaidAmount(invoice), invoice.currency)} · Saldo: ${formatMoney(Math.max(0, Number(invoice.total || 0) - invoicePaidAmount(invoice)), invoice.currency)}</small></td><td><div class="row-actions"><button class="icon-button" data-action="download-invoice" data-id="${invoice.id}">Descargar PDF</button>${invoice.status !== "cancelled" ? `<button class="icon-button" data-action="email-invoice" data-id="${invoice.id}">Preparar correo</button><button class="icon-button" data-action="edit-invoice" data-id="${invoice.id}">Editar</button>${invoice.status === "draft" ? `<button class="icon-button" data-action="mark-sent" data-id="${invoice.id}">Enviada</button>` : ""}${invoice.status !== "paid" ? `<button class="icon-button" data-action="register-payment" data-id="${invoice.id}">Abono</button><button class="icon-button" data-action="mark-paid" data-id="${invoice.id}">Pagada</button>` : ""}<button class="icon-button" data-action="cancel-invoice" data-id="${invoice.id}">Anular</button>` : ""}</div></td> 514 </tr>`).join("")}</tbody></table>` : empty("Crea tu primera factura visual desde aquÃ.")}</div></div> 515 <p class="stat-meta" style="margin:13px 2px 0"><strong>Preparar correo</strong> descarga el PDF y muestra el destinatario, asunto y mensaje para copiar. La app no abrirá Mail automáticamente.</p>`; 516} 517 518function renderFinances() { 519 const rows = [...state.incomes.map((x) => ({ ...x, kind: "income" })), ...state.expenses.map((x) => ({ ...x, kind: "expense" }))].sort((a, b) => dateOf(b) - dateOf(a)); 520 const movementAssignment = (x) => { 521 if (x.kind === "expense" && (x.scope === "studio" || (!x.scope && !x.clientId && !x.projectId))) return `<strong>Operación del estudio</strong><br><small>${esc(x.category || "Gasto operativo")}</small>`; 522 const client = itemName(state.clients, x.clientId, ""); const project = itemName(state.projects, x.projectId, ""); 523 return client || project ? `${esc(client || "â")}<br><small>${esc(project)}</small>` : "â"; 524 }; 525 return ` 526 <div class="section-head"><div><h2>Ingresos y gastos</h2><p>Registra tanto costos generales del estudio como gastos vinculados a proyectos.</p></div><div><button class="button secondary" data-action="new-finance" data-type="expense">+ Gasto</button> <button class="button" data-action="new-finance" data-type="income">+ Ingreso</button></div></div> 527 <div class="card"><div class="table-wrap">${rows.length ? `<table><thead><tr><th>
527Fecha</th><th>Tipo</th><th>Descripción</th><th>Ãrea / proyecto</th><th>Estado</th><th>Monto</th><th></th></tr></thead><tbody>${rows.map((x) => `<tr><td>${esc(x.date)}</td><td><span class="badge ${x.kind === "income" ? "green" : "red"}">${x.kind === "income" ? "Ingreso" : "Gasto"}</span></td><td>${esc(x.description || x.category || "â")}${x.linkedInvoiceId ? "<br><small>Creado desde Facturas</small>" : ""}</td><td>${movementAssignment(x)}</td><td>${esc(x.status || "registrado")}</td><td class="amount ${x.kind === "income" ? "positive" : "negative"}">${x.kind === "income" ? "+" : "â"}${formatMoney(x.amount, x.currency)}${x.linkedInvoiceId ? `<br><small>Cobrado ${formatMoney(incomePaidAmount(x), x.currency)} · Saldo ${formatMoney(incomeBalance(x), x.currency)}</small>` : ""}</td><td>${x.kind === "income" && !isPaidStatus(x.status) && !isCancelledStatus(x.status) ? `<button class="icon-button paid-button" data-action="mark-finance-paid" data-type="income" data-id="${x.id}" aria-label="Marcar como pagado" title="Marcar como pagado">â</button>` : ""}<button class="icon-button" data-action="delete-finance" data-type="${x.kind}" data-id="${x.id}" aria-label="Eliminar">Ã</button></td></tr>`).join("")}</tbody></table>` : empty("Registra el primer ingreso o gasto.")}</div></div>`; 528} 529 530function reportChart(points, currency) { 531 const max = Math.max(1, ...points.flatMap((x) => [x.income, x.expense])); 532 return `<div class="chart">${points.map((p) => `<div class="bar-group"><div class="bar income" title="${formatMoney(p.income, currency)}" style="height:${(p.income / max) * 100}%"></div><div class="bar expense" title="${formatMoney(p.expense, currency)}" style="height:${(p.expense / max) * 100}%"></div><span class="bar-label">${p.label}</span></div>`).join("")}</div><div class="legend"><span>Ingresos facturados</span><span>Gastos</span></div>`; 533} 534 535function profitabilityMeter(collected, expenses, currentMonth, monthLabel) { 536 const monthlyIncome = collected.filter((x) => x.date?.slice(0, 7) === currentMonth); 537 const monthlyExpenses = expenses.filter((x) => x.date?.slice(0, 7) === currentMonth); 538 const incomeCRC = monthlyIncome.filter((x) => (x.currency || "CRC") !== "USD").reduce((sum, x) => sum + Number(x.amount || 0), 0); 539 const incomeUSD = monthlyIncome.filter((x) => x.currency === "USD").reduce((sum, x) => sum + Number(x.amount || 0), 0); 540 const costCRC = monthlyExpenses.filter((x) => (x.currency || "CRC") !== "USD").reduce((sum, x) => sum + Number(x.amount || 0), 0); 541 const costUSD = monthlyExpenses.filter((x) => x.currency === "USD").reduce((sum, x) => sum + Number(x.amount || 0), 0); 542 if (!exchangeRate?.rate) return `<article class="profitability-card unified neutral"><div class="profitability-copy"><span class="profitability-kicker">Balance de ${monthLabel} · CRC</span><strong class="profitability-total">Calculandoâ¦</strong><span class="profitability-message">Consultando el tipo de cambio USD â CRC</span><p class="exchange-note">Si la fuente no está disponible, se utilizará automáticamente la última tasa válida guardada.</p></div></article>`; 543 const income = incomeCRC + incomeUSD * exchangeRate.rate; 544 const cost = costCRC + costUSD * exchangeRate.rate; 545 const profit = income - cost; 546 const margin = income > 0 ? (profit / income) * 100 : cost > 0 ? -100 : 0; 547 const meter = Math.min(100, Math.abs(margin)); 548 const tone = profit > 0 ? "profit" : profit < 0 ? "loss" : "neutral"; 549 const message = income === 0 && cost === 0 ? "Sin movimientos este mes" : profit > 0 ? "Vas con ganancia" : profit < 0 ? "Los gastos superan los ingresos" : "Estás en punto de equilibrio"; 550 return `<article class="profitability-card unified ${tone}"> 551 <div class="profitability-copy"><span class="profitability-kicker">Balance de ${monthLabel} · todo convertido a CRC</span><strong class="profitability-total">${formatMoney(profit, "CRC")}</strong><span class="profitability-message">${message}</span><div class="profitability-breakdown"><span><small>Cobrado total</small><strong>${formatMoney(income, "CRC")}</strong></span><span><small>Gastos totales</small><strong>${formatMoney(cost, "CRC")}</strong></span><span><small>Movimientos en USD</small><strong>${formatMoney(incomeUSD, "USD")} / ${formatMoney(costUSD, "USD")}</strong></span></div></div> 552 <div class="profit-ring" style="--meter:${meter * 3.6}deg"><div><strong>${Math.round(margin)}%</strong><small>margen</small></div></div> 553 </article>`; 554} 555 556function renderReports() { 557 refreshExchangeRate(); 558 const months = Array.from({ length: 6 }, (_, index) => { const d = new Date(); d.setDate(1); d.setMonth(d.getMonth() - (5 - index)); return d; }); 559 const validIncomes = state.incomes.filter((x) => !isCancelledStatus(x.status)); 560 const validExpenses = state.expenses.filter((x) => !isCancelledStatus(x.status)); 561 const studioExpenses = validExpenses.filter((x) => x.scope === "studio" || (!x.scope && !x.clientId && !x.projectId)); 562 const projectExpenses = validExpenses.filter((x) => x.scope === "project" || (!x.scope && (x.clientId || x.projectId))); 563 const manualPaid = validIncomes.filter((x) => !x.linkedInvoiceId && isPaidStatus(x.status)); 564 const legacyPaid = validIncomes.filter((x) => x.linkedInvoiceId && isPaidStatus(x.status) && !state.payments.some((payment) => payment.invoiceId === x.linkedInvoiceId)).map((x) => ({ ...x, amount: incomePaidAmount(x) })); 565 const collected = [...state.payments, ...manualPaid, ...legacyPaid]; 566 const pending = validIncomes.filter((x) => incomeBalance(x) > 0).map((x) => ({ ...x, amount: incomeBalance(x) })); 567 const currentMonth = monthKey(new Date()); 568 const monthLabel = new Date().toLocaleDateString("es-CR", { month: "long" }); 569 const makePoints = (currency) => months.map((date) => {
570 const key = monthKey(date); 571 return { 572 label: date.toLocaleDateString("es-CR", { month: "short" }).replace(".", ""), 573 income: validIncomes.filter((x) => (x.currency || "CRC") === currency && x.date?.slice(0, 7) === key).reduce((s, x) => s + Number(x.amount || 0), 0), 574 expense: validExpenses.filter((x) => (x.currency || "CRC") === currency && x.date?.slice(0, 7) === key).reduce((s, x) => s + Number(x.amount || 0), 0) 575 }; 576 }); 577 const pointsCRC = makePoints("CRC"); const pointsUSD = makePoints("USD"); 578 return ` 579 <div class="section-head"><div><h2>Informes</h2><p>Facturación, cobros, gastos y tiempo actualizados en tiempo real.</p></div><button class="button secondary" data-action="export">Descargar CSV</button></div> 580 <section class="profitability-section"><div class="profitability-heading"><div><span class="badge green">Mes actual</span><h3>Rentabilidad general del estudio</h3><p>Dinero efectivamente cobrado menos todos los gastos registrados durante ${monthLabel}, incluyendo colones y dólares.</p></div><div class="exchange-rate-status"><span>1 USD = <strong>${exchangeRate?.rate ? formatMoney(exchangeRate.rate, "CRC") : "Consultandoâ¦"}</strong></span><small>${exchangeRate?.sourceUpdatedAt ? `Tasa publicada: ${exchangeRateDate(exchangeRate.sourceUpdatedAt)}` : exchangeRate?.fetchedAt ? `Consultada: ${exchangeRateDate(exchangeRate.fetchedAt)}` : "Fuente diaria de referencia"}</small><div><a href="https://www.exchangerate-api.com" target="_blank" rel="noopener">ExchangeRate-API</a><button class="text-button" data-action="refresh-exchange-rate">Actualizar</button></div></div></div><div class="profitability-grid">${profitabilityMeter(collected, validExpenses, currentMonth, monthLabel)}</div><p class="exchange-note">La conversión usa una tasa de referencia media diaria. La última tasa válida queda guardada en este dispositivo para mantener disponible el informe si la fuente falla temporalmente.</p></section> 581 <div class="grid stats"> 582 ${stat("Total facturado", moneySummary(validIncomes), `${validIncomes.length} registros`, "positive")} 583 ${stat("Total cobrado", moneySummary(collected), `${collected.length} pagos recibidos`, "positive")} 584 ${stat("Pendiente por cobrar", moneySummary(pending), `${pending.length} cuentas pendientes`, "warning")} 585 ${stat("Gastos registrados", moneySummary(validExpenses), `${validExpenses.length} registros`, "negative")} 586 ${stat("Gastos operativos", moneySummary(studioExpenses), `${studioExpenses.length} del estudio`, "negative")} 587 ${stat("Gastos de proyectos", moneySummary(projectExpenses), `${projectExpenses.length} asociados`, "warning")} 588 ${stat("Resultado por moneda", balanceSummary(collected, validExpenses), "Cobrado menos gastos", "")} 589 ${stat("Horas totales", (totalSeconds(state.timeEntries) / 3600).toFixed(1) + " h", `${state.timeEntries.length} sesiones`, "")} 590 </div> 591 <div class="grid two-columns"><div class="card"><h3>Colones · últimos 6 meses</h3>${reportChart(pointsCRC, "CRC")}</div><div class="card"><h3>Dólares · últimos 6 meses</h3>${reportChart(pointsUSD, "USD")}</div></div> 592 <div class="card" style="margin-top:18px"><h3>Horas por proyecto</h3><div class="list">${state.projects.map((project) => { const hours = totalSeconds(state.timeEntries.filter((x) => x.projectId === project.id)) / 3600; return `<div class="list-item"><div><strong>${esc(project.name)}</strong><small>${esc(itemName(state.clients, project.clientId))}</small></div><span class="amount">${hours.toFixed(1)} h</span></div>`; }).join("") || empty("Sin datos de tiempo.")}</div></div>`; 593} 594 595function renderPortal() { 596 const active = state.timeEntries.find((x) => x.status === "active"); 597 const total = totalSeconds(state.timeEntries) / 3600; 598 return ` 599 <div class="card client-hero"><span class="badge green">Portal privado</span><h2>${esc(state.client?.name || currentUser.displayName || "Cliente")}</h2><p>Aquà puedes monitorear el trabajo reportado por Ariel Estudio Inmersivo.</p></div> 600 <div class="grid stats" style="margin-top:18px"> 601 ${stat("Horas registradas", total.toFixed(2) + " h", `${state.timeEntries.length} sesiones`, "")} 602 ${stat("Proyectos activos", state.projects.filter((x) => x.status === "active").length, `${state.projects.length} en total`, "")} 603 ${stat("Estado actual", active ? "Trabajando" : "Fuera de sesión", active ? itemName(state.projects, active.projectId) : "Sin temporizador activo", active ? "positive" : "")} 604 ${stat("Ãltima actualización", new Date().toLocaleTimeString("es-CR", { hour: "2-digit", minute: "2-digit" }), "Datos en tiempo real", "")} 605 </div> 606 <div class="grid two-columns"> 607 <div class="card timer-card ${active ? "timer-running" : "timer-idle"}">${active ? `<div class="timer-state"><span class="live-dot"></span>Ariel está trabajando ahora</div><div class="timer-display" data-live-entry="${active.id}">${formatDuration(entrySeconds(active))}</div><strong>${esc(itemName(state.projects, active.projectId))}</strong><p class="stat-meta">${esc(active.note || "Sesión de trabajo")}</p>` : `<div class="timer-state"><span class="live-dot"></span>Estado del trabajo</div><div class="timer-idle-title">No hay una sesión activa</div><p class="timer-idle-copy">
607El cronómetro está detenido. Las horas ya registradas permanecen guardadas abajo.</p><div class="timer-idle-total">${total.toFixed(2)} h <small>registradas en total</small></div>`}</div> 608 <div class="card client-projects"><h3>Proyectos y horas por proyecto</h3><p class="stat-meta">Aquà puedes ver exactamente cuánto tiempo se ha dedicado a cada trabajo.</p><div class="list">${state.projects.map((project) => { const h = totalSeconds(state.timeEntries.filter((x) => x.projectId === project.id)) / 3600; const type = project.billingType || "hourly"; const pct = project.budgetHours ? Math.min(100, (h / project.budgetHours) * 100) : 0; const billing = type === "unit" ? `${project.quantity || 0} ${esc(project.unitName || "unidades")} · ${formatMoney(project.unitRate, project.currency)} c/u` : type === "fixed" ? `Paquete · ${formatMoney(project.fixedPrice, project.currency)}` : `${h.toFixed(2)} de ${project.budgetHours || "â"} horas`; return `<div class="client-project-row"><div><strong>${esc(project.name)}</strong><small>${billing}</small></div><b>${h.toFixed(2)} h</b>${type === "hourly" ? `<div class="progress"><span style="width:${pct}%"></span></div>` : ""}</div>`; }).join("") || empty("No hay proyectos visibles.")}</div></div> 609 </div>`; 610} 611 612function renderClientHours() { 613 return `<div class="section-head"><div><h2>Horas trabajadas</h2><p>Detalle transparente de inicios, cierres y duración.</p></div></div><div class="card"><div class="table-wrap">${state.timeEntries.length ? `<table><thead><tr><th>Inicio</th><th>Proyecto</th><th>Trabajo reportado</th><th>Tipo</th><th>Duración</th></tr></thead><tbody>${state.timeEntries.map((entry) => `<tr><td>${dateOf(entry, entry.startedAt ? "startedAt" : "startedAtClient").toLocaleString("es-CR", { dateStyle: "medium", timeStyle: "short" })}</td><td>${esc(itemName(state.projects, entry.projectId))}</td><td>${esc(entry.note || "â")}</td><td><span class="badge">${entry.billable === false ? "No facturable" : "Facturable"}</span></td><td class="amount" ${entry.status === "active" ? `data-live-entry="${entry.id}"` : ""}>${formatDuration(entrySeconds(entry))}</td></tr>`).join("")}</tbody></table>` : empty("TodavÃa no se han registrado horas.")}</div></div>`; 614} 615 616function empty(message) { return `<div class="table-empty">${message}</div>`; } 617 618function openModal(title, body) { 619 document.querySelector("#modal-root").innerHTML = `<div class="modal-backdrop"><section class="modal" role="dialog" aria-modal="true"><div class="modal-head"><h2>${title}</h2><button class="close-button" data-action="close-modal">Ã</button></div>${body}</section></div>`; 620} 621function closeModal() { document.querySelector("#modal-root").innerHTML = ""; } 622 623function clientModal(client = null) { 624 openModal(client ? "Editar cliente" : "Nuevo cliente", `<form id="client-form" class="form-grid"> 625 <div class="field full"><label>Nombre del cliente *</label><input name="name" required value="${esc(client?.name || "")}"></div> 626 <div class="field"><label>Empresa</label><input name="company" value="${esc(client?.company || "")}"></div> 627 <div class="field"><label>Teléfono</label><input name="phone" value="${esc(client?.phone || "")}"></div> 628 <div class="field full"><label>Correo de Google para darle acceso *</label><input type="email" name="email" required value="${esc(client?.email || "")}" placeholder="[email protected]"></div> 629 <div class="field"><label>Estado</label><select name="status"><option value="active" ${client?.status !== "archived" ? "selected" : ""}>Activo</option><option value="archived" ${client?.status === "archived" ? "selected" : ""}>Archivado</option></select></div> 630 <div class="form-actions"><button type="button" class="button secondary" data-action="close-modal">Cancelar</button><button class="button">Guardar cliente</button></div> 631 </form>`); 632 document.querySelector("#client-form").addEventListener("submit", async (event) => { 633 event.preventDefault(); const data = Object.fromEntries(new FormData(event.target)); data.email = data.email.trim().toLowerCase(); 634 try { 635 const ref = client ? doc(db, "clients", client.id) : doc(collection(db, "clients")); 636 await setDoc(ref, { ...data, updatedAt: serverTimestamp(), ...(client ? {} : { createdAt: serverTimestamp() }) }, { merge: true }); 637 if (client?.email && client.email !== data.email) await deleteDoc(doc(db, "clientAccess", client.email.toLowerCase())); 638 await setDoc(doc(db, "clientAccess", data.email), { clientId: ref.id, clientName: data.name, email: data.email, updatedAt: serverTimestamp() }); 639 closeModal(); toast("Cliente guardado e invitación habilitada."); 640 } catch (error) { toast(readableError(error), true); } 641 }); 642} 643 644function nextQuoteNumber() { 645 const year = new Date().getFullYear(); const prefix = `COT-${year}-`; 646 const sequence = state.projects.filter((project) => String(project.quoteNumber || "").startsWith(prefix)).length + 1; 647 return `${prefix}${String(sequence).padStart(3, "0")}`; 648} 649
650function projectModal(project = null, initialStatus = "active") { 651 const billingType = project?.billingType || "hourly"; 652 const projectStatus = project?.status || initialStatus; 653 const quoteLimit = new Date(); quoteLimit.setDate(quoteLimit.getDate() + 15); 654 openModal(project ? "Editar proyecto" : projectStatus === "quote" ? "Nueva cotización" : "Nuevo proyecto", `<form id="project-form" class="form-grid"> 655 <div class="field full"><label>Nombre del proyecto *</label><input name="name" required value="${esc(project?.name || "")}"></div> 656 <div class="field"><label>Cliente *</label><select name="clientId" required><option value="">Seleccionar</option>${state.clients.map((x) => `<option value="${x.id}" ${project?.clientId === x.id ? "selected" : ""}>${esc(x.name)}</option>`).join("")}</select></div> 657 <div class="field"><label>Estado</label><select name="status"><option value="quote" ${projectStatus === "quote" ? "selected" : ""}>Cotización</option><option value="active" ${projectStatus === "active" ? "selected" : ""}>Activo</option><option value="paused" ${projectStatus === "paused" ? "selected" : ""}>Pausado</option><option value="completed" ${projectStatus === "completed" ? "selected" : ""}>Completado</option><option value="rejected" ${projectStatus === "rejected" ? "selected" : ""}>No aprobado</option></select></div> 658 <div class="field" data-project-status="quote"><label>Número de cotización</label><input name="quoteNumber" value="${esc(project?.quoteNumber || nextQuoteNumber())}"></div> 659 <div class="field" data-project-status="quote"><label>Válida hasta</label><input type="date" name="quoteValidUntil" value="${esc(project?.quoteValidUntil || localDate(quoteLimit))}"></div> 660 <div class="field full"><label>Forma de cobro *</label><select name="billingType" id="project-billing-type"><option value="hourly" ${billingType === "hourly" ? "selected" : ""}>Por horas</option><option value="fixed" ${billingType === "fixed" ? "selected" : ""}>Por proyecto o paquete</option><option value="unit" ${billingType === "unit" ? "selected" : ""}>Por unidad (canción, episodio, piezaâ¦)</option></select></div> 661 <div class="field" data-billing="hourly"><label>Horas presupuestadas</label><input type="number" min="0" step="0.25" name="budgetHours" value="${esc(project?.budgetHours || "")}"></div> 662 <div class="field" data-billing="hourly"><label>Tarifa por hora</label><input type="number" min="0" step="0.01" name="hourlyRate" value="${esc(project?.hourlyRate || "")}"></div> 663 <div class="field full" data-billing="fixed"><label>Precio total del proyecto o paquete</label><input type="number" min="0" step="0.01" name="fixedPrice" value="${esc(project?.fixedPrice || "")}" placeholder="Ej. 500"></div> 664 <div class="field" data-billing="unit"><label>Nombre de la unidad</label><input name="unitName" value="${esc(project?.unitName || "canción")}" placeholder="Canción, episodio, videoâ¦"></div> 665 <div class="field" data-billing="unit"><label>Cantidad de unidades</label><input type="number" min="0" step="1" name="quantity" value="${esc(project?.quantity || "")}" placeholder="Ej. 10"></div> 666 <div class="field" data-billing="unit"><label>Precio por unidad</label><input type="number" min="0" step="0.01" name="unitRate" value="${esc(project?.unitRate || "")}" placeholder="Ej. 50"></div> 667 <div class="field"><label>Moneda</label><select name="currency"><option value="CRC" ${project?.currency !== "USD" ? "selected" : ""}>Colones (CRC)</option><option value="USD" ${project?.currency === "USD" ? "selected" : ""}>Dólares (USD)</option></select></div> 668 <div class="field full"><label>Descripción</label><textarea name="description">${esc(project?.description || "")}</textarea></div> 669 <div class="form-actions"><button type="button" class="button secondary" data-action="close-modal">Cancelar</button><button class="button">Guardar proyecto</button></div> 670 </form>`); 671 const form = document.querySelector("#project-form"); 672 const updateStatusFields = () => form.querySelectorAll("[data-project-status='quote']").forEach((field) => field.classList.toggle("hidden", form.elements.status.value !== "quote")); 673 const updateBillingFields = () => { 674 const selected = form.elements.billingType.value; 675 form.querySelectorAll("[data-billing]").forEach((field) => { field.classList.toggle("hidden", field.dataset.billing !== selected); }); 676 }; 677 form.elements.billingType.addEventListener("change", updateBillingFields); 678 form.elements.status.addEventListener("change", updateStatusFields); 679 updateBillingFields(); 680 updateStatusFields(); 681 form.addEventListener("submit", async (event) => { 682 event.preventDefault(); const data = Object.fromEntries(new FormData(event.target)); 683 data.budgetHours = Number(data.budgetHours || 0);
683 data.hourlyRate = Number(data.hourlyRate || 0); data.fixedPrice = Number(data.fixedPrice || 0); data.quantity = Number(data.quantity || 0); data.unitRate = Number(data.unitRate || 0); 684 try { const ref = project ? doc(db, "projects", project.id) : doc(collection(db, "projects")); await setDoc(ref, { ...data, updatedAt: serverTimestamp(), ...(project ? {} : { createdAt: serverTimestamp() }) }, { merge: true }); closeModal(); toast(data.status === "quote" ? "Cotización guardada." : "Proyecto guardado."); } catch (error) { toast(readableError(error), true); } 685 }); 686} 687 688async function approveQuote(project) { 689 if (!project) return; 690 try { await updateDoc(doc(db, "projects", project.id), { status: "active", approvedAt: serverTimestamp(), updatedAt: serverTimestamp() }); toast("Cotización aprobada y convertida en proyecto activo."); } catch (error) { toast(readableError(error), true); } 691} 692 693function nextInvoiceNumber() { 694 const year = new Date().getFullYear(); 695 const prefix = `AEI-${year}-`; 696 const sequence = state.invoices.filter((invoice) => String(invoice.number || "").startsWith(prefix)).length + 1; 697 return `${prefix}${String(sequence).padStart(3, "0")}`; 698} 699 700function invoiceLineMarkup(line = {}) { 701 return `<div class="invoice-line" data-invoice-line><input class="invoice-description" required placeholder="Descripción del servicio" value="${esc(line.description || "")}"><input class="invoice-quantity" type="number" min="0.01" step="0.01" required value="${esc(line.quantity || 1)}" aria-label="Cantidad"><input class="invoice-price" type="number" min="0" step="0.01" required value="${esc(line.unitPrice || "")}" aria-label="Precio unitario"><button type="button" class="icon-button" data-action="remove-line" aria-label="Quitar lÃnea">Ã</button></div>`; 702} 703 704function projectInvoiceLine(project) { 705 const type = project?.billingType || "hourly"; 706 if (type === "unit") return { description: `${project.name} â ${project.unitName || "unidad"}`, quantity: Number(project.quantity || 1), unitPrice: Number(project.unitRate || 0) }; 707 if (type === "fixed") return { description: project.name, quantity: 1, unitPrice: Number(project.fixedPrice || 0) }; 708 return { description: project?.name || "Servicio por horas", quantity: Number(project?.budgetHours || 1), unitPrice: Number(project?.hourlyRate || 0) }; 709} 710 711function invoiceModal(invoice = null) { 712 const due = new Date(); due.setDate(due.getDate() + 15); 713 const selectedClientId = invoice?.clientId || ""; 714 const availableProjects = selectedClientId ? state.projects.filter((project) => project.clientId === selectedClientId && !["quote", "rejected"].includes(project.status)) : []; 715 openModal(invoice ? "Editar factura" : "Nueva factura", `<form id="invoice-form" class="form-grid"> 716 <div class="field"><label>Número de factura *</label><input name="number" required value="${esc(invoice?.number || nextInvoiceNumber())}"></div> 717 <div class="field"><label>Moneda</label><select name="currency"><option value="CRC" ${invoice?.currency !== "USD" ? "selected" : ""}>Colones (CRC)</option><option value="USD" ${invoice?.currency === "USD" ? "selected" : ""}>Dólares (USD)</option></select></div> 718 <div class="field"><label>Cliente *</label><select name="clientId" required><option value="">Seleccionar</option>${state.clients.filter((x) => x.status !== "archived" || x.id === selectedClientId).map((x) => `<option value="${x.id}" ${x.id === selectedClientId ? "selected" : ""}>${esc(x.name)}</option>`).join("")}</select></div> 719 <div class="field"><label>Proyecto</label><select name="projectId"><option value="">Sin asociar</option>${availableProjects.map((x) => `<option value="${x.id}" ${x.id === invoice?.projectId ? "selected" : ""}>${esc(x.name)}</option>`).join("")}</select></div> 720 <div class="field"><label>Fecha de emisión *</label><input type="date" name="issueDate" required value="${esc(invoice?.issueDate || localDate())}"></div> 721 <div class="field"><label>Fecha de vencimiento</label><input type="date" name="dueDate" value="${esc(invoice?.dueDate || localDate(due))}"></div> 722 <div class="field"><label>Forma de pago</label><select name="paymentTerms"><option value="full" ${!invoice?.paymentTerms || invoice.paymentTerms === "full" ? "selected" : ""}>Pago completo</option><option value="split" ${invoice?.paymentTerms === "split" ? "selected" : ""}>
722Adelanto y saldo contra entrega</option><option value="custom" ${invoice?.paymentTerms === "custom" ? "selected" : ""}>Pagos parciales personalizados</option></select></div> 723 <div class="field" data-payment-terms="split"><label>Porcentaje de adelanto</label><input type="number" min="1" max="99" step="1" name="depositPercent" value="${esc(invoice?.depositPercent || 50)}"></div> 724 <div class="field full"><label>Servicios facturados *</label><div class="invoice-lines-head"><span>Descripción</span><span>Cant.</span><span>Precio unit.</span><span></span></div><div id="invoice-lines">${(invoice?.lines?.length ? invoice.lines : [{}]).map(invoiceLineMarkup).join("")}</div><button type="button" class="button secondary add-line" data-action="add-line">+ Agregar servicio</button></div> 725 <div class="field"><label>Impuesto (%)</label><input type="number" min="0" step="0.01" name="taxRate" value="${esc(invoice?.taxRate || 0)}"></div> 726 <div class="field"><label>Descuento</label><input type="number" min="0" step="0.01" name="discount" value="${esc(invoice?.discount || 0)}"></div> 727 <div class="field full"><label>Nota para el cliente</label><textarea name="note" placeholder="Gracias por confiar en Ariel Estudio Inmersivo.">${esc(invoice?.note || "")}</textarea></div> 728 <div class="form-actions"><button type="button" class="button secondary" data-action="close-modal">Cancelar</button><button class="button">Guardar factura</button></div> 729 </form>`); 730 const form = document.querySelector("#invoice-form"); 731 const refreshPaymentTerms = () => form.querySelector("[data-payment-terms='split']")?.classList.toggle("hidden", form.elements.paymentTerms.value !== "split"); 732 form.elements.paymentTerms.addEventListener("change", refreshPaymentTerms); 733 refreshPaymentTerms(); 734 const refreshProjects = () => { 735 const chosen = form.elements.projectId.value; 736 const projects = state.projects.filter((project) => project.clientId === form.elements.clientId.value && !["quote", "rejected"].includes(project.status)); 737 form.elements.projectId.innerHTML = `<option value="">Sin asociar</option>${projects.map((x) => `<option value="${x.id}" ${x.id === chosen ? "selected" : ""}>${esc(x.name)}</option>`).join("")}`; 738 }; 739 form.elements.clientId.addEventListener("change", refreshProjects); 740 form.elements.projectId.addEventListener("change", () => { 741 const project = state.projects.find((item) => item.id === form.elements.projectId.value); 742 if (!project) return; 743 form.elements.currency.value = project.currency || "CRC"; 744 document.querySelector("#invoice-lines").innerHTML = invoiceLineMarkup(projectInvoiceLine(project)); 745 }); 746 form.addEventListener("submit", async (event) => { 747 event.preventDefault(); 748 const data = Object.fromEntries(new FormData(event.target)); 749 data.depositPercent = Number(data.depositPercent || 50); 750 if (state.invoices.some((item) => item.id !== invoice?.id && String(item.number).toLowerCase() === String(data.number).trim().toLowerCase())) return toast("Ya existe una factura con ese número.", true); 751 const lines = [...document.querySelectorAll("[data-invoice-line]")].map((line) => ({ 752 description: line.querySelector(".invoice-description").value.trim(), quantity: Number(line.querySelector(".invoice-quantity").value), unitPrice: Number(line.querySelector(".invoice-price").value) 753 })).filter((line) => line.description && line.quantity > 0); 754 if (!lines.length) return toast("Agrega al menos un servicio a la factura.", true); 755 const client = state.clients.find((item) => item.id === data.clientId); 756 const project = state.projects.find((item) => item.id === data.projectId); 757 const subtotal = lines.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0); 758 const taxRate = Number(data.taxRate || 0); const discount = Number(data.discount || 0); 759 const tax = Math.max(0, subtotal - discount) * taxRate / 100; 760 const total = Math.max(0, subtotal - discount + tax); 761 try { 762 const invoiceRef = invoice ? doc(db, "invoices", invoice.id) : doc(collection(db, "invoices")); 763 const linkedIncome = state.incomes.find((income) => income.linkedInvoiceId === invoiceRef.id); 764 const incomeRef = linkedIncome ? doc(db, "incomes", linkedIncome.id) : doc(collection(db, "incomes")); 765 const batch = writeBatch(db); 766 const paidAmount = Math.min(total, invoicePaidAmount(invoice)); 767 const nextStatus = paidAmount >= total && total > 0 ? "paid" : paidAmount > 0 ? "partial" : (invoice?.status || "draft"); 768 batch.set(invoiceRef, { 769 ...data, lines, taxRate, discount, subtotal, tax, total, paidAmount, balanceDue: Math.max(0, total - paidAmount), status: nextStatus, 770 clientName: client?.name || "", clientEmail: client?.email || "", projectName: project?.name || "", updatedAt: serverTimestamp(), ...(invoice ? {} : { createdAt: serverTimestamp() }) 771 }, { merge: true }); 772 batch.set(incomeRef, { 773 date: data.issueDate, amount: total, currency: data.currency || "CRC", category: "Factura", description: `Factura ${data.number}`, 774 clientId: data.clientId, projectId: data.projectId || "", status: nextStatus === "paid" ? "pagado" : nextStatus === "partial" ? "parcial" : "pendiente", 775 paidAmount, balanceDue: Math.max(0, total - paidAmount), linkedInvoiceId: invoiceRef.id, updatedAt: serverTimestamp(), ...(linkedIncome ? {} : { createdAt: serverTimestamp() }) 776 }, { merge: true }); 777 await batch.commit(); 778 closeModal(); toast(invoice ? "Factura e ingreso actualizados." : "Factura creada e ingreso pendiente registrado automáticamente."); 779 } catch (error) { toast(readableError(error), true); } 780 }); 781} 782 783async function loadPdfLibrary() { 784 if (window.jspdf?.jsPDF) return window.jspdf; 785 throw new Error("El generador PDF no está disponible."); 786} 787 788function loadPdfImage(src) { 789 return new Promise((resolve, reject) => { 790 const image = new Image(); 791 image.onload = () => resolve(image); 792 image.onerror = reject; 793 image.src = src; 794 }); 795} 796 797async function downloadQuotePdf(project) { 798 if (!project) return; 799 try { 800 const { jsPDF } = await loadPdfLibrary(); 801 const pdf = new jsPDF({ unit: "mm", format: "a4" }); 802 const logo = await loadPdfImage("/logo.png"); 803 const client = state.clients.find((item) => item.id === project.clientId); 804 const currency = project.currency || "CRC"; const total = projectEstimatedTotal(project); 805 const type = project.billingType || "hourly"; 806 const quantity = type === "unit" ? Number(project.quantity || 0) : type === "hourly" ? Number(project.budgetHours || 0) : 1;
807 const rate = type === "unit" ? Number(project.unitRate || 0) : type === "hourly" ? Number(project.hourlyRate || 0) : Number(project.fixedPrice || 0); 808 const unit = type === "unit" ? project.unitName || "unidad" : type === "hourly" ? "hora" : "proyecto"; 809 const quoteNumber = project.quoteNumber || "COTIZACION"; 810 811 // Diseño limpio basado en el documento de referencia del estudio. 812 pdf.setFillColor(255, 255, 255); pdf.rect(0, 0, 210, 297, "F"); 813 pdf.addImage(logo, "PNG", 16, 18, 78, 30.5); 814 pdf.setTextColor(0, 0, 0); pdf.setFont("helvetica", "bold"); pdf.setFontSize(31); pdf.text("Cotizacion", 108, 31); 815 pdf.setFont("helvetica", "normal"); pdf.setFontSize(8.5); pdf.text("Ave 6 de las provincias, calle 15 Sibaja, casa 7.", 108, 39); 816 pdf.text("Alajuela, Costa Rica. 20101", 108, 44); pdf.text("Telefono: 8508-0110", 108, 49); 817 818 pdf.setDrawColor(0, 0, 0); pdf.setLineWidth(0.65); pdf.line(16, 64, 194, 64); 819 pdf.setFont("helvetica", "bold"); pdf.setFontSize(10); pdf.text("PREPARADA PARA:", 18, 74); 820 const detailX = 109; let detailY = 74; 821 const detailRow = (label, value) => { pdf.setFont("helvetica", "bold"); pdf.text(label, detailX, detailY); pdf.setFont("helvetica", "normal"); pdf.text(pdf.splitTextToSize(String(value || "-"), 66), detailX + 29, detailY); detailY += 10; }; 822 detailRow("Cliente:", client?.company || client?.name || "Cliente"); 823 if (client?.company && client?.name) detailRow("Contacto:", client.name); 824 if (client?.email) detailRow("Correo:", client.email); 825 if (client?.phone) detailRow("Telefono:", client.phone); 826 pdf.setLineWidth(0.35); pdf.line(detailX, detailY - 3, 194, detailY - 3); detailY += 4; 827 detailRow("Cotizacion #:", quoteNumber); 828 detailRow("Proyecto:", project.name || "Proyecto"); 829 detailRow("Valida hasta:", project.quoteValidUntil || "Sin fecha limite"); 830 831 let y = Math.max(143, detailY + 5); 832 pdf.setFillColor(0, 0, 0); pdf.rect(16, y, 178, 12, "F"); 833 pdf.setTextColor(255, 255, 255); pdf.setFont("helvetica", "bold"); pdf.setFontSize(8); 834 pdf.text("DESCRIPCION", 19, y + 7.5); pdf.text("TARIFA", 121, y + 7.5, { align: "right" }); pdf.text("CANTIDAD", 153, y + 7.5, { align: "right" }); pdf.text("MONTO", 190, y + 7.5, { align: "right" }); 835 y += 20; pdf.setTextColor(0, 0, 0); pdf.setFont("helvetica", "normal"); pdf.setFontSize(9); 836 const description = project.description || project.name || "Servicios profesionales"; 837 const descriptionLines = pdf.splitTextToSize(String(description), 88); const rowHeight = Math.max(12, descriptionLines.length * 5); 838 pdf.text(descriptionLines, 19, y); pdf.text(formatPdfMoney(rate, currency), 121, y, { align: "right" }); pdf.text(`${quantity} ${unit}`, 153, y, { align: "right" }); pdf.text(formatPdfMoney(total, currency), 190, y, { align: "right" }); 839 y += rowHeight; pdf.setLineWidth(0.3); pdf.line(16, y, 194, y); 840 841 y += 10; const labelX = 150; const amountX = 190; 842 pdf.setFont("helvetica", "bold"); pdf.text("Subtotal", labelX, y, { align: "right" }); pdf.setFont("helvetica", "normal"); pdf.text(formatPdfMoney(total, currency), amountX, y, { align: "right" }); 843 pdf.line(158, y + 5, 194, y + 5); y += 13; 844 pdf.setFont("helvetica", "bold"); pdf.setFontSize(11); pdf.text("TOTAL", labelX, y, { align: "right" }); pdf.setFont("helvetica", "normal"); pdf.text(formatPdfMoney(total, currency), amountX, y, { align: "right" }); pdf.line(158, y + 5, 194, y + 5); 845 846 const noteY = Math.max(y + 24, 235); pdf.setFontSize(8); pdf.setTextColor(45, 45, 45); pdf.setFont("helvetica", "normal"); 847 pdf.text("Esta cotizacion no constituye una factura electronica. Los alcances pueden ajustarse antes de la aprobacion.", 16, noteY); 848 pdf.setDrawColor(100, 100, 100); pdf.line(16, 268, 76, 268); pdf.line(100, 268, 150, 268); pdf.setFont("helvetica", "bold"); pdf.setFontSize(7); pdf.text("APROBADO POR", 16, 274); pdf.text("FECHA", 100, 274); 849 pdf.setLineWidth(0.65); pdf.setDrawColor(0, 0, 0); pdf.line(16, 283, 194, 283); pdf.setFont("helvetica", "normal"); pdf.setFontSize(7); pdf.text("ARIEL ESTUDIO INMERSIVO", 16, 290); pdf.text(String(quoteNumber), 194, 290, { align: "right" }); 850 const filename = `${String(project.quoteNumber || project.name || "cotizacion").replace(/[^a-z0-9-_]/gi, "_")}.pdf`; const blobUrl = URL.createObjectURL(pdf.output("blob")); 851 openModal("Cotización en PDF", `<div class="pdf-preview"><iframe src="${blobUrl}" title="Vista previa de cotización"></iframe></div><div class="form-actions"><button type="button" class="button secondary" data-action="close-modal">Cerrar</button><a class="button secondary" href="${blobUrl}" target="_blank" rel="noopener">Abrir PDF</a><a class="button" href="${blobUrl}" download="${esc(filename)}">
851Descargar archivo</a></div>`); 852 } catch (error) { console.error(error); toast("No fue posible generar la cotización en PDF.", true); } 853} 854 855async function buildInvoicePdf(invoice) { 856 if (!invoice) return; 857 try { 858 const { jsPDF } = await loadPdfLibrary(); 859 const pdf = new jsPDF({ unit: "mm", format: "a4" }); 860 const logo = await loadPdfImage("/logo.png"); 861 const client = state.clients.find((item) => item.id === invoice.clientId); 862 const margin = 16; let y = 20; 863 const currency = invoice.currency || "CRC"; 864 const paidDocument = effectiveInvoiceStatus(invoice) === "paid"; 865 const drawTableHeader = (top) => { 866 pdf.setFillColor(0, 0, 0); pdf.rect(margin, top, 178, 12, "F"); 867 pdf.setTextColor(255, 255, 255); pdf.setFont("helvetica", "bold"); pdf.setFontSize(8); 868 pdf.text("DESCRIPCION", 19, top + 7.5); pdf.text("TARIFA", 121, top + 7.5, { align: "right" }); pdf.text("CANT.", 153, top + 7.5, { align: "right" }); pdf.text("MONTO", 190, top + 7.5, { align: "right" }); 869 }; 870 871 pdf.setFillColor(255, 255, 255); pdf.rect(0, 0, 210, 297, "F"); 872 pdf.addImage(logo, "PNG", 16, 18, 78, 30.5); 873 pdf.setTextColor(0, 0, 0); pdf.setFont("helvetica", "bold"); 874 if (paidDocument) { pdf.setFontSize(24); pdf.text("Comprobante", 108, 26); pdf.setFontSize(18); pdf.text("de pago", 108, 35); } 875 else { pdf.setFontSize(33); pdf.text("Factura", 108, 31); } 876 const studioInfoY = paidDocument ? 43 : 39; 877 pdf.setFont("helvetica", "normal"); pdf.setFontSize(8.5); pdf.text("Ave 6 de las provincias, calle 15 Sibaja, casa 7.", 108, studioInfoY); 878 pdf.text("Alajuela, Costa Rica. 20101", 108, studioInfoY + 5); pdf.text("Telefono: 8508-0110", 108, studioInfoY + 10); 879 pdf.setDrawColor(0, 0, 0); pdf.setLineWidth(0.65); pdf.line(16, 64, 194, 64); 880 881 pdf.setFont("helvetica", "bold"); pdf.setFontSize(10); pdf.text("FACTURAR A:", 18, 74); 882 pdf.setFontSize(9); pdf.text(String(client?.company || invoice.clientName || "Cliente"), 18, 84); 883 pdf.setFont("helvetica", "normal"); pdf.setFontSize(8.5); let clientY = 91; 884 if (client?.company && client?.name) { pdf.text(String(client.name), 18, clientY); clientY += 7; } 885 if (invoice.clientEmail || client?.email) { pdf.text(String(invoice.clientEmail || client.email), 18, clientY); clientY += 7; } 886 if (client?.phone) pdf.text(String(client.phone), 18, clientY); 887 888 const detailX = 109; let detailY = 74; 889 const invoiceDetail = (label, value) => { pdf.setFont("helvetica", "bold"); pdf.text(label, detailX, detailY); pdf.setFont("helvetica", "normal"); pdf.text(pdf.splitTextToSize(String(value || "-"), 56), detailX + 30, detailY); detailY += 10; }; 890 invoiceDetail(paidDocument ? "Comprobante #:" : "Factura #:", invoice.number || "-"); 891 invoiceDetail("Proyecto:", invoice.projectName || itemName(state.projects, invoice.projectId, "Sin asociar")); 892 invoiceDetail("Emision:", invoice.issueDate || "-"); 893 invoiceDetail(paidDocument ? "Fecha de pago:" : "Vencimiento:", paidDocument ? invoicePaymentDate(invoice) : (invoice.dueDate || "-")); 894 invoiceDetail("Condicion:", paymentTermsLabel(invoice.paymentTerms, invoice.depositPercent)); 895 896 y = Math.max(137, detailY + 5); drawTableHeader(y); y += 20; 897 pdf.setTextColor(0, 0, 0); pdf.setFont("helvetica", "normal"); pdf.setFontSize(9); 898 for (const line of invoice.lines || []) { 899 const description = pdf.splitTextToSize(String(line.description), 88); const height = Math.max(12, description.length * 5 + 3); 900 if (y + height > 242) { pdf.addPage(); pdf.setFillColor(255, 255, 255); pdf.rect(0, 0, 210, 297, "F"); pdf.setTextColor(0, 0, 0); pdf.setFont("helvetica", "bold"); pdf.setFontSize(13); pdf.text(`Factura ${invoice.number} - continuacion`, margin, 18); drawTableHeader(27); y = 47; pdf.setTextColor(0, 0, 0); pdf.setFont("helvetica", "normal"); pdf.setFontSize(9); } 901 pdf.text(description, 19, y); pdf.text(formatPdfMoney(line.unitPrice, currency), 121, y, { align: "right" }); pdf.text(String(line.quantity), 153, y, { align: "right" }); pdf.text(formatPdfMoney(line.quantity * line.unitPrice, currency), 190, y, { align: "right" }); 902 y += height; pdf.setDrawColor(80, 80, 80); pdf.setLineWidth(0.25); pdf.line(margin, y, 194, y); y += 8; 903 } 904 const totalRows = 2 + (Number(invoice.discount) ? 1 : 0) + (Number(invoice.tax) ? 1 : 0) + (invoicePaidAmount(invoice) > 0 ? 2 : 0); 905 if (y + totalRows * 11 > 258) { pdf.addPage(); pdf.setFillColor(255, 255, 255); pdf.rect(0, 0, 210, 297, "F"); y = 30; } 906 const labelX = 153; const amountX = 190; const totalRow = (label, value, bold = false) => { pdf.setTextColor(0, 0, 0); pdf.setFont("helvetica", bold ? "bold" : "normal"); pdf.setFontSize(bold ? 11 : 9); pdf.text(label, labelX, y, { align: "right" }); pdf.text(value, amountX, y, { align: "right" }); pdf.setDrawColor(80, 80, 80); pdf.setLineWidth(0.25); pdf.line(159, y + 5, 194, y + 5); y += 13; }; 907 totalRow("Subtotal", formatPdfMoney(invoice.subtotal, currency)); 908 if (Number(invoice.discount)) totalRow("Descuento", `-${formatPdfMoney(invoice.discount, currency)}`); 909 if (Number(invoice.tax)) totalRow(`Impuesto (${invoice.taxRate || 0}%)`, formatPdfMoney(invoice.tax, currency)); 910 totalRow("TOTAL", formatPdfMoney(invoice.total, currency), true); 911 if (invoicePaidAmount(invoice) > 0) { totalRow("Pagado", formatPdfMoney(invoicePaidAmount(invoice), currency)); totalRow("Saldo", formatPdfMoney(Math.max(0, Number(invoice.total || 0) - invoicePaidAmount(invoice)), currency), true); } 912 913 const noteY = Math.max(y + 7, 248); pdf.setTextColor(45, 45, 45); pdf.setFont("helvetica", "normal"); pdf.setFontSize(8); 914 pdf.text(pdf.splitTextToSize(invoice.note || "Gracias por confiar en Ariel Estudio Inmersivo.", 178), margin, noteY); 915 pdf.setFontSize(7); pdf.text(paidDocument ? "Comprobante visual de pago. La factura electronica oficial se emite por el sistema tributario correspondiente." : "Documento visual de cobro. La factura electronica oficial se emite por el sistema tributario correspondiente.", margin, 278); 916 pdf.setLineWidth(0.65); pdf.setDrawColor(0, 0, 0); pdf.line(16, 283, 194, 283); pdf.text("ARIEL ESTUDIO INMERSIVO", 16, 290); pdf.text(String(invoice.number || "FACTURA"), 194, 290, { align: "right" }); 917 const filename = `${String(invoice.number || "factura").replace(/[^a-z0-9-_]/gi, "_")}.pdf`; 918 return { filename, blobUrl: URL.createObjectURL(pdf.output("blob")) }; 919 } catch (error) { console.error(error); toast("No fue posible generar el PDF. Revisa tu conexión e intenta de nuevo.", true); } 920} 921 922async function downloadInvoicePdf(invoice) { 923 const result = await buildInvoicePdf(invoice); if (!result) return; 924 openModal("Vista previa del PDF", `<div class="pdf-preview"><iframe src="${result.blobUrl}" title="Vista previa de ${esc(invoice.number)}"></iframe></div><div class="form-actions"><button type="button" class="button secondary" data-action="close-modal">Cerrar</button><a class="button secondary" href="${result.blobUrl}" target="_blank" rel="noopener">Abrir PDF</a><a class="button" href="${result.blobUrl}" download="${esc(result.filename)}">
924Descargar archivo</a></div>`); 925 return result; 926} 927 928async function emailInvoice(invoice) { 929 if (!invoice?.clientEmail) return toast("Esta factura no tiene un correo de cliente asociado.", true); 930 const result = await buildInvoicePdf(invoice); if (!result) return; 931 const paidDocument = effectiveInvoiceStatus(invoice) === "paid"; 932 const subject = `${paidDocument ? "Comprobante de pago" : "Factura"} ${invoice.number} - Ariel Estudio Inmersivo`; 933 const body = paidDocument ? `Hola ${invoice.clientName || ""},\n\nAdjunto encontrarás el comprobante del pago recibido por ${formatMoney(invoice.total, invoice.currency)}.\n\nFecha de pago: ${invoicePaymentDate(invoice)}.\n\nMuchas gracias,\nAriel Estudio Inmersivo` : `Hola ${invoice.clientName || ""},\n\nAdjunto encontrarás la factura ${invoice.number} por un total de ${formatMoney(invoice.total, invoice.currency)}.\n\nFecha de vencimiento: ${invoice.dueDate || "No indicada"}.\n\nMuchas gracias,\nAriel Estudio Inmersivo`; 934 preparedEmailText = `Para: ${invoice.clientEmail}\nAsunto: ${subject}\n\n${body}`; 935 openModal("Correo preparado", `<div class="form-grid"> 936 <div class="field full"><label>Destinatario</label><input readonly value="${esc(invoice.clientEmail)}"></div> 937 <div class="field full"><label>Asunto</label><input readonly value="${esc(subject)}"></div> 938 <div class="field full"><label>Mensaje</label><textarea id="prepared-email-body" readonly>${esc(body)}</textarea></div> 939 <div class="field full"><p class="stat-meta">Descarga <strong>${esc(result.filename)}</strong>, abre el correo que prefieras, pega el mensaje y adjunta el archivo. No se abrirá Mail automáticamente.</p></div> 940 <div class="form-actions"><button type="button" class="button secondary" data-action="close-modal">Cerrar</button><a class="button secondary" href="${result.blobUrl}" target="_blank" rel="noopener">Abrir PDF</a><a class="button secondary" href="${result.blobUrl}" download="${esc(result.filename)}">Descargar PDF</a><button type="button" class="button" data-action="copy-email-message">Copiar datos del correo</button></div> 941 </div>`); 942} 943 944async function copyPreparedEmail() { 945 try { 946 await navigator.clipboard.writeText(preparedEmailText); toast("Destinatario, asunto y mensaje copiados."); 947 } catch { 948 const text = document.querySelector("#prepared-email-body"); text?.select(); document.execCommand("copy"); toast("Mensaje copiado."); 949 } 950} 951 952async function markInvoiceSent(invoice) { 953 if (!invoice) return; 954 try { await updateDoc(doc(db, "invoices", invoice.id), { status: "sent", sentAt: serverTimestamp(), updatedAt: serverTimestamp() }); toast("Factura marcada como enviada."); } catch (error) { toast(readableError(error), true); } 955} 956 957function paymentModal(invoice) { 958 if (!invoice) return; 959 const balance = Math.max(0, Number(invoice.total || 0) - invoicePaidAmount(invoice)); 960 const suggested = invoice.paymentTerms === "split" && invoicePaidAmount(invoice) === 0 ? balance * Number(invoice.depositPercent || 50) / 100 : balance; 961 openModal("Registrar abono", `<form id="payment-form" class="form-grid"> 962 <div class="field full"><label>Factura</label><input value="${esc(invoice.number)} · Saldo ${formatMoney(balance, invoice.currency)}" disabled></div> 963 <div class="field"><label>Fecha del pago *</label><input type="date" name="date" required value="${localDate()}"></div> 964 <div class="field"><label>Monto recibido *</label><input type="number" name="amount" min="0.01" max="${balance}" step="0.01" required value="${suggested.toFixed(2)}"></div> 965 <div class="field full"><label>Nota</label><input name="note" placeholder="Ej. Adelanto del 50%"></div> 966 <div class="form-actions"><button type="button" class="button secondary" data-action="close-modal">Cancelar</button><button class="button">Registrar pago</button></div> 967 </form>`); 968 document.querySelector("#payment-form").addEventListener("submit", async (event) => { 969 event.preventDefault(); const data = Object.fromEntries(new FormData(event.target)); const amount = Number(data.amount || 0); 970 if (amount <= 0 || amount > balance + 0.001) return toast("El abono debe ser mayor que cero y no superar el saldo pendiente.", true); 971 if (await registerInvoicePayment(invoice, amount, data.date, data.note)) closeModal(); 972 }); 973} 974 975async function registerInvoicePayment(invoice, amount, date = localDate(), note = "") { 976 try { 977 const currentPaid = invoicePaidAmount(invoice); const total = Number(invoice.total || 0); const paidAmount = Math.min(total, currentPaid + Number(amount || 0)); 978 const balanceDue = Math.max(0, total - paidAmount); const status = balanceDue <= 0.001 ? "paid" : "partial"; 979 const linkedIncome = state.incomes.find((income) => income.linkedInvoiceId === invoice.id); 980 const incomeRef = linkedIncome ? doc(db, "incomes", linkedIncome.id) : doc(collection(db, "incomes")); 981 const paymentRef = doc(collection(db, "payments")); const batch = writeBatch(db); 982 batch.set(paymentRef, { invoiceId: invoice.id, invoiceNumber: invoice.number,
982clientId: invoice.clientId || "", projectId: invoice.projectId || "", date, amount: Number(amount), currency: invoice.currency || "CRC", note: note || "Pago de factura", createdAt: serverTimestamp() }); 983 batch.update(doc(db, "invoices", invoice.id), { status, paidAmount, balanceDue, lastPaymentAt: serverTimestamp(), ...(status === "paid" ? { paidAt: serverTimestamp() } : {}), updatedAt: serverTimestamp() }); 984 batch.set(incomeRef, { date: invoice.issueDate || localDate(), amount: total, currency: invoice.currency || "CRC", category: "Factura", description: `Factura ${invoice.number}`, clientId: invoice.clientId || "", projectId: invoice.projectId || "", status: status === "paid" ? "pagado" : "parcial", paidAmount, balanceDue, linkedInvoiceId: invoice.id, updatedAt: serverTimestamp(), ...(linkedIncome ? {} : { createdAt: serverTimestamp() }) }, { merge: true }); 985 await batch.commit(); toast(status === "paid" ? "Pago registrado. La factura quedó pagada." : `Abono registrado. Saldo pendiente: ${formatMoney(balanceDue, invoice.currency)}`); return true; 986 } catch (error) { toast(readableError(error), true); return false; } 987} 988 989async function markInvoicePaid(invoice) { 990 if (!invoice) return; 991 const remaining = Math.max(0, Number(invoice.total || 0) - invoicePaidAmount(invoice)); 992 if (remaining <= 0) return toast("Esta factura ya está pagada."); 993 await registerInvoicePayment(invoice, remaining, localDate(), "Pago final"); 994} 995 996async function cancelInvoice(invoice) { 997 if (!invoice || !confirm(`¿Anular la factura ${invoice.number}? El ingreso asociado dejará de contarse en los informes.`)) return; 998 try { 999 const linkedIncome = state.incomes.find((income) => income.linkedInvoiceId === invoice.id); 1000 const batch = writeBatch(db); 1001 batch.update(doc(db, "invoices", invoice.id), { status: "cancelled", cancelledAt: serverTimestamp(), updatedAt: serverTimestamp() }); 1002 if (linkedIncome) batch.update(doc(db, "incomes", linkedIncome.id), { status: "cancelled", updatedAt: serverTimestamp() }); 1003 await batch.commit(); 1004 toast("Factura anulada. Ya no se contabiliza como ingreso."); 1005 } catch (error) { toast(readableError(error), true); } 1006} 1007 1008function financeModal(type) { 1009 const income = type === "income"; 1010 const expenseCategories = ["Alquiler", "Electricidad", "Agua", "Internet y telefonÃa", "Software y suscripciones", "Equipo y mantenimiento", "Insumos", "Transporte", "Contabilidad y legal", "Marketing", "Limpieza", "Seguros", "Comisiones bancarias", "Impuestos", "Nómina y subcontratación", "Otro"]; 1011 openModal(income ? "Registrar ingreso" : "Registrar gasto", `<form id="finance-form" class="form-grid"> 1012 <div class="field"><label>Fecha *</label><input type="date" name="date" required value="${localDate()}"></div> 1013 <div class="field"><label>Monto *</label><input type="number" min="0" step="0.01" name="amount" required></div> 1014 <div class="field"><label>Moneda</label><select name="currency"><option value="CRC">Colones (CRC)</option><option value="USD">Dólares (USD)</option></select></div> 1015 <div class="field"><label>CategorÃa</label><select name="category">${(income ? ["Servicios", "Anticipo", "Producción", "Mezcla", "Mastering", "Otro"] : expenseCategories).map((x) => `<option>${x}</option>`).join("")}</select></div> 1016 <div class="field full"><label>Descripción *</label><input name="description" required></div> 1017 ${income ? "" : `<div class="field full"><label>¿A qué corresponde el gasto?</label><select name="scope"><option value="studio">Operación general del estudio</option><option value="project">Cliente o proyecto especÃfico</option></select><small>Los gastos operativos no requieren seleccionar un cliente.</small></div>`} 1018 <div class="field ${income ? "" : "hidden"}" data-finance-assignment><label>Cliente</label><select name="clientId"><option value="">Sin asociar</option>${state.clients.map((x) => `<option value="${x.id}">${esc(x.name)}</option>`).join("")}</select></div> 1019 <div class="field ${income ? "" : "hidden"}" data-finance-assignment><label>Proyecto</label><select name="projectId"><option value="">Sin asociar</option></select></div> 1020 <div class="field"><label>Estado</label><select name="status">${(income ? ["Pagado", "Pendiente"] : ["Pagado", "Pendiente"]).map((x) => `<option value="${x.toLowerCase()}">${x}</option>`).join("")}</select></div> 1021 <div class="form-actions"><button type="button" class="button secondary" data-action="close-modal">Cancelar</button><button class="button">Guardar ${income ? "ingreso" : "gasto"}</button></div> 1022 </form>`); 1023 const form = document.querySelector("#finance-form"); 1024 const updateAssignment = () => { 1025 if (income) return; 1026 const projectExpense = form.elements.scope.value === "project"; 1027 form.querySelectorAll("[data-finance-assignment]").forEach((field) => field.classList.toggle("hidden", !projectExpense)); 1028 if (!projectExpense) { form.elements.clientId.value = ""; form.elements.projectId.innerHTML = '<option value="">Sin asociar</option>'; } 1029 }; 1030 if (!income) { form.elements.scope.addEventListener("change", updateAssignment); updateAssignment(); } 1031 form.elements.clientId.addEventListener("change", () => { 1032 const projects = state.projects.filter((project) => project.clientId === form.elements.clientId.value); 1033 form.elements.projectId.innerHTML = `<option value="">Sin asociar</option>${projects.map((x) => `<option value="${x.id}">${esc(x.name)}</option>`).join("")}`;
1034 }); 1035 form.addEventListener("submit", async (event) => { 1036 event.preventDefault(); const data = Object.fromEntries(new FormData(event.target)); data.amount = Number(data.amount); 1037 if (!income && data.scope === "studio") { data.clientId = ""; data.projectId = ""; } 1038 if (!income && !data.scope) data.scope = data.clientId || data.projectId ? "project" : "studio"; 1039 try { await addDoc(collection(db, income ? "incomes" : "expenses"), { ...data, createdAt: serverTimestamp() }); closeModal(); toast(`${income ? "Ingreso" : "Gasto"} registrado.`); } catch (error) { toast(readableError(error), true); } 1040 }); 1041} 1042 1043async function deleteFinance(type, id) { 1044 const movement = (type === "income" ? state.incomes : state.expenses).find((item) => item.id === id); 1045 if (movement?.linkedInvoiceId) return toast("Este ingreso pertenece a una factura. Anula la factura desde Facturas para mantener los datos conectados.", true); 1046 if (!confirm("¿Eliminar este movimiento? Esta acción no se puede deshacer.")) return; 1047 try { await deleteDoc(doc(db, type === "income" ? "incomes" : "expenses", id)); toast("Movimiento eliminado."); } catch (error) { toast(readableError(error), true); } 1048} 1049 1050function isPaidStatus(status = "") { 1051 return ["pagado", "paid"].includes(String(status).toLowerCase()); 1052} 1053 1054async function markFinancePaid(type, id) { 1055 if (type !== "income") return; 1056 try { 1057 const income = state.incomes.find((item) => item.id === id); 1058 if (income?.linkedInvoiceId) { 1059 const invoice = state.invoices.find((item) => item.id === income.linkedInvoiceId); 1060 if (!invoice) return toast("No fue posible encontrar la factura asociada.", true); 1061 const remaining = Math.max(0, Number(invoice.total || 0) - invoicePaidAmount(invoice)); 1062 if (remaining <= 0) return toast("Esta factura ya está pagada."); 1063 await registerInvoicePayment(invoice, remaining, localDate(), "Pago final"); return; 1064 } 1065 const batch = writeBatch(db); 1066 batch.update(doc(db, "incomes", id), { status: "pagado", paidAt: serverTimestamp(), updatedAt: serverTimestamp() }); 1067 await batch.commit(); 1068 toast("Ingreso marcado como pagado."); 1069 } catch (error) { toast(readableError(error), true); } 1070} 1071 1072async function punchIn() { 1073 const client = document.querySelector("#timer-client")?.value; 1074 const project = document.querySelector("#timer-project")?.value; 1075 if (!client || !project) return toast("Selecciona un cliente y un proyecto.", true); 1076 if (state.timeEntries.some((x) => x.status === "active")) return toast("Ya existe una sesión activa.", true); 1077 try { 1078 await addDoc(collection(db, "timeEntries"), { 1079 clientId: client, projectId: project, note: document.querySelector("#timer-note").value.trim(), 1080 billable: document.querySelector("#timer-billable").value === "true", status: "active", 1081 startedAt: serverTimestamp(), startedAtClient: new Date().toISOString(), ownerUid: currentUser.uid 1082 }); 1083 toast("Punch in registrado. El tiempo ya está corriendo."); 1084 } catch (error) { toast(readableError(error), true); } 1085} 1086 1087async function punchOut(id) { 1088 const entry = state.timeEntries.find((x) => x.id === id); 1089 if (!entry) return; 1090 try { 1091 await updateDoc(doc(db, "timeEntries", id), { status: "completed", endedAt: serverTimestamp(), endedAtClient: new Date().toISOString(), durationSeconds: entrySeconds(entry) }); 1092 toast("Punch out registrado. Sesión finalizada."); 1093 } catch (error) { toast(readableError(error), true); } 1094} 1095 1096function updateLiveClocks() { 1097 document.querySelectorAll("[data-live-entry]").forEach((el) => { 1098 const entry = state.timeEntries.find((x) => x.id === el.dataset.liveEntry); 1099 if (entry) el.textContent = formatDuration(entrySeconds(entry)); 1100 }); 1101 const clientSelect = document.querySelector("#timer-client"); 1102 const projectSelect = document.querySelector("#timer-project"); 1103 if (clientSelect && projectSelect && !clientSelect.bound) { 1104 clientSelect.bound = true; 1105 clientSelect.addEventListener("change", () => { 1106 const projects = state.projects.filter((x) => x.clientId === clientSelect.value && x.status === "active"); 1107 projectSelect.innerHTML = `<option value="">Seleccionar</option>${projects.map((x) => `<option value="${x.id}">${esc(x.name)}</option>`).join("")}`;
1108 }); 1109 } 1110} 1111 1112function exportCsv() { 1113 const rows = [["Tipo", "Fecha", "Descripción", "CategorÃa", "Ãrea", "Cliente", "Proyecto", "Monto", "Cobrado", "Saldo", "Moneda", "Estado"]]; 1114 state.incomes.forEach((x) => rows.push(["Ingreso", x.date, x.description, x.category, "", itemName(state.clients, x.clientId, ""), itemName(state.projects, x.projectId, ""), x.amount, incomePaidAmount(x), incomeBalance(x), x.currency, x.status])); 1115 state.expenses.forEach((x) => rows.push(["Gasto", x.date, x.description, x.category, x.scope === "studio" || (!x.scope && !x.clientId && !x.projectId) ? "Operación del estudio" : "Proyecto", itemName(state.clients, x.clientId, ""), itemName(state.projects, x.projectId, ""), x.amount, "", "", x.currency, x.status])); 1116 const csv = rows.map((row) => row.map((value) => `"${String(value ?? "").replaceAll('"', '""')}"`).join(",")).join("\n"); 1117 const url = URL.createObjectURL(new Blob(["\ufeff" + csv], { type: "text/csv;charset=utf-8" })); 1118 const link = document.createElement("a"); link.href = url; link.download = `ariel-operativo-${localDate()}.csv`; link.click(); URL.revokeObjectURL(url); 1119} 1120 1121async function installApp() { 1122 if (!deferredInstall) return toast("En iPhone usa Compartir â Agregar a pantalla de inicio."); 1123 deferredInstall.prompt(); await deferredInstall.userChoice; deferredInstall = null; document.querySelector("#install-app")?.classList.add("hidden"); 1124} 1125 1126window.addEventListener("beforeinstallprompt", (event) => { 1127 event.preventDefault(); deferredInstall = event; document.querySelector("#install-app")?.classList.remove("hidden"); 1128}); 1129 1130onAuthStateChanged(auth, async (user) => { 1131 cleanupListeners(); currentUser = user; 1132 if (!user) { role = null; loginScreen(); return; } 1133 loading(); 1134 try { 1135 if (!(await resolveRole(user))) { deniedScreen(); return; } 1136 activeSection = role === "owner" ? "dashboard" : "portal"; 1137 shell(); subscribe(); 1138 } catch (error) { console.error(error); deniedScreen(); toast(readableError(error), true); } 1139}); 1140 1141if ("serviceWorker" in navigator) window.addEventListener("load", () => navigator.serviceWorker.register("/sw.js").catch(() => {})); 1142loading();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.