PageSourceSearch

https://ariel-estudio-operativo.web.app/app.js?v=16

js ariel-estudio-operativo.web.app collected 2026-10-03 12:16:57 UTC 95,132 bytes, 1,142 lines download raw bytes

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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;" }[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.