// ================= Real data layer for Fabcom Media Ops Portal ================= // Fetches live data from backend and populates window.BRANDS, window.NON_MEDIA, etc. // App.jsx waits for window.__fabcomDataReady() before rendering dashboard screens. (function () { const PLATFORMS = [ { id: "meta", name: "Meta", short: "M", color: "#1877f2" }, { id: "google", name: "Google Ads", short: "G", color: "#ea4335" }, { id: "dv360", name: "DV360", short: "D", color: "#34a853" }, { id: "tiktok", name: "TikTok", short: "T", color: "#111111" }, { id: "ttd", name: "TTD", short: "TT", color: "#ff6e00" }, { id: "youtube", name: "YouTube", short: "Y", color: "#ff0000" }, ]; // Normalize arbitrary platform names from BQ (e.g. "facebook", "google_ads") to UI ids. function normalizePlatform(p) { if (!p) return "meta"; const x = String(p).toLowerCase(); if (x.includes("facebook") || x.includes("meta")) return "meta"; if (x.includes("google") && !x.includes("dv")) return "google"; if (x.includes("dv360") || x.includes("display")) return "dv360"; if (x.includes("tiktok")) return "tiktok"; if (x.includes("ttd") || x.includes("trade desk") || x.includes("tradedesk")) return "ttd"; if (x.includes("youtube") || x === "yt") return "youtube"; return x; } function brandInitials(name, code) { if (code && code.length <= 4) return code; if (!name) return (code || "??").slice(0, 3).toUpperCase(); return name.split(/\s+/).slice(0, 2).map(s => s[0]).join("").toUpperCase(); } // ---------- Format helpers ---------- const fmt = { hkd: (n) => n == null ? "—" : `HK$${Math.round(n).toLocaleString()}`, hkdK: (n) => { if (n == null) return "—"; const a = Math.abs(n); if (a >= 1e6) return `HK$${(n / 1e6).toFixed(2)}M`; if (a >= 1e3) return `HK$${(n / 1e3).toFixed(1)}k`; return `HK$${Math.round(n)}`; }, int: (n) => n == null ? "—" : Math.round(n).toLocaleString(), pct: (n, digits = 1) => n == null ? "—" : `${(n * 100).toFixed(digits)}%`, pctDelta: (n, digits = 1) => n == null ? "—" : `${n >= 0 ? "+" : ""}${(n * 100).toFixed(digits)}%`, money2: (n) => n == null ? "—" : `HK$${Number(n).toFixed(2)}`, date: (s) => s, timeAgo: (hoursOrDate) => { if (hoursOrDate == null) return "—"; let hours = hoursOrDate; if (typeof hoursOrDate === "string") { hours = (Date.now() - new Date(hoursOrDate).getTime()) / 3.6e6; } if (hours < 1) return `${Math.max(1, Math.round(hours * 60))}m ago`; if (hours < 24) return `${Math.round(hours)}h ago`; return `${Math.round(hours / 24)}d ago`; }, mins: (m) => { if (m == null) return "—"; if (m < 60) return `${m}m ago`; if (m < 24 * 60) return `${Math.round(m / 60)}h ago`; return `${Math.round(m / 1440)}d ago`; }, }; // ---------- Fetch + transform ---------- async function api(path) { const res = await fetch(path, { credentials: "include", headers: { "Accept": "application/json" } }); if (res.status === 401) throw Object.assign(new Error("unauthorized"), { code: 401 }); if (!res.ok) throw new Error(`${path}: ${res.status}`); return res.json(); } function buildDailySeries(series30d) { // Brand daily series: array of {date, cost, platforms} per day aggregated. // Stitch code expects brand.daily_series as number[] length 30 for sparklines. if (!series30d || !series30d.length) return Array(30).fill(0); const byDate = {}; for (const row of series30d) byDate[row.date] = (byDate[row.date] || 0) + (row.cost || 0); // Last 30 days ascending const out = []; const today = new Date(); for (let i = 29; i >= 0; i--) { const d = new Date(today); d.setDate(d.getDate() - i); const key = d.toISOString().slice(0, 10); out.push(Math.round(byDate[key] || 0)); } return out; } function transformBrands(apiBrands) { return apiBrands.map(b => { const paceTone = (pace) => pace > 1.2 ? "danger" : pace < 0.7 ? "warn" : "success"; const plat = (b.platforms || []).map(normalizePlatform); const mtd = b.spend_mtd_hkd || 0; const plan = b.plan_active_total_hkd || 0; const pace = plan > 0 ? mtd / plan : 0; const series = buildDailySeries(b.daily_series); const dailyBudget = series.slice(-7).length ? Math.round(series.slice(-7).reduce((s, x) => s + x, 0) / series.slice(-7).length) : 0; return { code: b.brand_code, name: b.brand_name || b.brand_code, industry: null, am: b.am_team_lead || null, tl: b.am_team_lead || null, accounts: [], // lazily filled on drill platforms: [...new Set(plat)], daily_budget_hkd: dailyBudget, mtd_spend: mtd, plan_mtd: plan, pace, pace_status: paceTone(pace), spend_7d: b.spend_7d_hkd || 0, spend_30d: b.spend_30d_hkd || 0, gp_mtd: b.gp_mtd_hkd || 0, non_media_mtd: b.non_media_mtd_hkd || 0, daily_series: series, alerts: 0, last_change: null, _api: b, }; }); } function transformNonMedia(rows) { return rows.map(r => ({ id: r.id, brand_code: r.brand_code, brand_name: r.brand_name || r.brand_code, campaign_code: r.campaign_code, campaign_name: r.campaign_name, vendor_name: r.vendor_name, service_type: r.service_type, start_date: r.start_date, end_date: r.end_date, net_vendor_cost: r.net_vendor_cost, vendor_cost_with_markup: r.vendor_cost_with_markup, total_vendor_cost: r.total_vendor_cost, net_client_cost: r.net_client_cost, client_cost_with_ac: r.client_cost_with_ac, fabcom_mark_up: r.fabcom_mark_up, gross_profit: r.gross_profit, invoice_number: null, invoice_status: r.invoice_payment_received ? "Paid" : (r.vendor_invoice_status || "Not issued"), vendor_po_status: r.vendor_po_status || "Not created", vendor_invoice_status: r.vendor_invoice_status || "Not issued", workflow_state: r.workflow_state || "Draft", main_am_contact: r.main_am_contact, team_lead: r.team_lead, })); } function transformPacing(rows) { return rows.map(r => ({ id: r.id, brand_code: r.brand_code, brand_name: r.brand_code, platform: normalizePlatform(r.platform), account: "", campaign: r.campaign_name, campaign_id: r.id, name: r.campaign_name, plan_budget: r.plan_hkd, spend: r.spent_hkd, flight_days: r.flight_days, days_elapsed: r.days_elapsed, days_remaining: r.days_remaining, start_date: r.start_date, end_date: r.end_date, pace: r.pace, forecast: r.forecast_hkd, variance: r.variance, sev: r.severity, tl: r.team_lead, am: r.main_am_contact, account_id: null, status: { id: r.severity === "danger" ? "issues" : "active", label: r.severity === "danger" ? "Over" : "Active", tone: r.severity === "danger" ? "danger" : "success" }, })); } function transformChanges(rows) { return rows.map(r => ({ id: `${r.platform}_${r.entity_id}_${r.ts}`, brand_code: null, brand_name: null, platform: normalizePlatform(r.platform), account: r.account_name || r.account_id, campaign: r.entity_name || r.entity_id, campaign_id: r.entity_id, actor: r.actor || "system", action: r.action || "update", hours_ago: r.ts ? (Date.now() - new Date(r.ts).getTime()) / 3.6e6 : null, prev: r.prev, next: r.next, })); } async function loadData() { const [me, brands, nonmedia, pacing, changes] = await Promise.all([ api("/api/me"), api("/api/brands"), api("/api/nonmedia"), api("/api/pacing"), api("/api/changes").catch(() => ({ rows: [] })), ]); const transformedBrands = transformBrands(brands.brands || []); window.BRANDS = transformedBrands; window.NON_MEDIA = transformNonMedia(nonmedia.rows || []); window.PACING_ROWS = transformPacing(pacing.rows || []); window.CHANGES = transformChanges(changes.rows || []); window.ALL_ACCOUNTS = transformedBrands.flatMap(b => b.accounts.map(a => ({ ...a, brand_name: b.name }))); window.ALL_CAMPAIGNS = transformedBrands.flatMap(b => b.accounts.flatMap(a => (a.campaigns || []).map(c => ({ ...c, brand_name: b.name, account_name: a.name })))); // Alerts: derive from pacing severity + overdue non-media const alerts = []; for (const p of window.PACING_ROWS) { if (p.sev === "danger") { alerts.push({ id: `pacing_${p.id}`, type: "pacing", severity: "danger", title: p.pace > 1.3 ? "Overpacing >130% plan" : "Severe pacing deviation", kind: "Budget pacing", brand_code: p.brand_code, brand_name: p.brand_code, account_name: p.account || "", platform: p.platform, campaign_id: p.id, campaign_name: p.campaign || "", detected_at: 30, detail: `${p.campaign || p.id}: pace ${(p.pace * 100).toFixed(0)}%`, }); } } for (const nm of window.NON_MEDIA) { if (nm.invoice_status === "Overdue") { alerts.push({ id: `invoice_${nm.id}`, type: "invoice", severity: "danger", title: "Vendor invoice overdue", kind: "Non-media", brand_code: nm.brand_code, brand_name: nm.brand_name, account_name: nm.vendor_name, platform: "meta", campaign_id: nm.id, campaign_name: nm.campaign_name || "", detected_at: 60, detail: `${nm.vendor_name}: ${nm.campaign_name}`, }); } } window.ALERTS = alerts; window.PLATFORMS_LIST = PLATFORMS; window.PEOPLE = { ME: me.user, AMS: [], TLS: [...new Set(window.PACING_ROWS.map(p => p.tl).filter(Boolean))], }; window.ME = me.user; window.ME_ADMIN = me.is_admin; window.ME_BRANDS = me.brand_codes; return me; } // Lazily fetch brand detail (accounts + campaigns) when user drills in. async function loadBrandDetail(brandCode) { const payload = await api(`/api/brand/${encodeURIComponent(brandCode)}`); const brand = window.BRANDS.find(b => b.code === brandCode); if (!brand) return null; brand.accounts = (payload.accounts || []).map(a => { const platform = normalizePlatform(a.platform); const campaigns = (payload.campaigns || []) .filter(c => c.account_id === a.account_id && normalizePlatform(c.platform) === platform) .map(c => ({ id: c.campaign_id, name: c.campaign_name, brand_code: brand.code, account_id: a.account_id, platform, objective: "—", status: { id: "active", label: "Active", tone: "success" }, daily_budget: Math.round((a.spend_7d || 0) / 7) || 0, plan_budget: c.mtd_spend || 0, spend: c.mtd_spend || 0, mtd_spend: c.mtd_spend || 0, impressions: c.impressions || 0, clicks: c.clicks || 0, conv: c.leads || 0, ctr: c.impressions ? c.clicks / c.impressions : 0, cpc: c.clicks ? (c.mtd_spend || 0) / c.clicks : 0, cpm: c.impressions ? ((c.mtd_spend || 0) / c.impressions) * 1000 : 0, cpa: c.leads ? (c.mtd_spend || 0) / c.leads : 0, roas: 0, start_date: null, end_date: null, days_elapsed: 0, days_remaining: 0, flight_days: 30, daily_series: brand.daily_series, bid_strategy: "—", budget_type: "—", main_am: brand.am, team_lead: brand.tl, })); return { id: a.account_id, name: a.account_name, platform, brand_code: brand.code, balance_hkd: 0, spend_cap_hkd: 0, daily_budget_hkd: Math.round((a.spend_7d || 0) / 7), today_spend: a.today_spend || 0, yesterday_spend: a.yesterday_spend || 0, spend_7d: a.spend_7d || 0, spend_30d: a.spend_30d || 0, ctr: a.ctr_30d || 0, cpc: a.clicks_30d ? (a.spend_30d || 0) / a.clicks_30d : 0, cpm: a.impressions_30d ? ((a.spend_30d || 0) / a.impressions_30d) * 1000 : 0, conv: 0, cpa: 0, roas: 0, campaigns, }; }); window.ALL_ACCOUNTS = window.BRANDS.flatMap(b => b.accounts.map(x => ({ ...x, brand_name: b.name }))); window.ALL_CAMPAIGNS = window.BRANDS.flatMap(b => b.accounts.flatMap(a => (a.campaigns || []).map(c => ({ ...c, brand_name: b.name, account_name: a.name })))); return brand; } // Expose globals Object.assign(window, { fmt, __fabcomApi: api, __fabcomLoadData: loadData, __fabcomLoadBrandDetail: loadBrandDetail, }); })();