/* ============================================================ FTrack — bundle gerado (IIFE-wrap por módulo, deps via window). NÃO editar à mão: edite os arquivos-fonte e regenere. ============================================================ */ /* ===== tweaks-panel.jsx ===== */ ;(function(){ // @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design) /* BEGIN USAGE */ // tweaks-panel.jsx // Reusable Tweaks shell + form-control helpers. // Exports (to window): useTweaks, TweaksPanel, TweakSection, TweakRow, TweakSlider, // TweakToggle, TweakRadio, TweakSelect, TweakText, TweakNumber, TweakColor, TweakButton. // // Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode, // posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so // individual prototypes don't re-roll it. Ships a consistent set of controls so you // don't hand-draw , segmented radios, steppers, etc. // // Usage (in an HTML file that loads React + Babel): // // const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ // "primaryColor": "#D97757", // "palette": ["#D97757", "#29261b", "#f6f4ef"], // "fontSize": 16, // "density": "regular", // "dark": false // }/*EDITMODE-END*/; // // function App() { // const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); // return ( //
// Hello // // // setTweak('fontSize', v)} /> // setTweak('density', v)} /> // // setTweak('primaryColor', v)} /> // setTweak('palette', v)} /> // setTweak('dark', v)} /> // //
// ); // } // // TweakRadio is the segmented control for 2–3 short options (auto-falls-back to // TweakSelect past ~16/~10 chars per label); reach for TweakSelect directly when // options are many or long. For color tweaks always curate 3-4 options rather than // a free picker; an option can also be a whole 2–5 color palette (the stored value // is the array). The Tweak* controls are a floor, not a ceiling — build custom // controls inside the panel if a tweak calls for UI they don't cover. /* END USAGE */ // ───────────────────────────────────────────────────────────────────────────── const __TWEAKS_STYLE = ` .twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px; max-height:calc(100vh - 32px);display:flex;flex-direction:column; transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right; background:rgba(250,249,247,.78);color:#29261b; -webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%); border:.5px solid rgba(255,255,255,.6);border-radius:14px; box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18); font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden} .twk-hd{display:flex;align-items:center;justify-content:space-between; padding:10px 8px 10px 14px;cursor:move;user-select:none} .twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em} .twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55); width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1} .twk-x:hover{background:rgba(0,0,0,.06);color:#29261b} .twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px; overflow-y:auto;overflow-x:hidden;min-height:0; scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent} .twk-body::-webkit-scrollbar{width:8px} .twk-body::-webkit-scrollbar-track{background:transparent;margin:2px} .twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px; border:2px solid transparent;background-clip:content-box} .twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25); border:2px solid transparent;background-clip:content-box} .twk-row{display:flex;flex-direction:column;gap:5px} .twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px} .twk-lbl{display:flex;justify-content:space-between;align-items:baseline; color:rgba(41,38,27,.72)} .twk-lbl>span:first-child{font-weight:500} .twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums} .twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase; color:rgba(41,38,27,.45);padding:10px 0 0} .twk-sect:first-child{padding-top:0} .twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px; border:.5px solid rgba(0,0,0,.1);border-radius:7px; background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none} .twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)} select.twk-field{padding-right:22px; background-image:url("data:image/svg+xml;utf8,"); background-repeat:no-repeat;background-position:right 8px center} .twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0; border-radius:999px;background:rgba(0,0,0,.12);outline:none} .twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none; width:14px;height:14px;border-radius:50%;background:#fff; border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} .twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%; background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} .twk-seg{position:relative;display:flex;padding:2px;border-radius:8px; background:rgba(0,0,0,.06);user-select:none} .twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px; background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12); transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s} .twk-seg.dragging .twk-seg-thumb{transition:none} .twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0; background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px; border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2; overflow-wrap:anywhere} .twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px; background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0} .twk-toggle[data-on="1"]{background:#34c759} .twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%; background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s} .twk-toggle[data-on="1"] i{transform:translateX(14px)} .twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px; border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)} .twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize; user-select:none;padding-right:8px} .twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent; font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0; outline:none;color:inherit;-moz-appearance:textfield} .twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{ -webkit-appearance:none;margin:0} .twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)} .twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px; background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default} .twk-btn:hover{background:rgba(0,0,0,.88)} .twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit} .twk-btn.secondary:hover{background:rgba(0,0,0,.1)} .twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px; border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default; background:transparent;flex-shrink:0} .twk-swatch::-webkit-color-swatch-wrapper{padding:0} .twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px} .twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px} .twk-chips{display:flex;gap:6px} .twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px; padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default; box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06); transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s} .twk-chip:hover{transform:translateY(-1px); box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)} .twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85), 0 2px 6px rgba(0,0,0,.15)} .twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%; display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)} .twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)} .twk-chip>span>i:first-child{box-shadow:none} .twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px; filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))} `; // ── useTweaks ─────────────────────────────────────────────────────────────── // Single source of truth for tweak values. setTweak persists via the host // (__edit_mode_set_keys → host rewrites the EDITMODE block on disk). function useTweaks(defaults) { const [values, setValues] = React.useState(defaults); // Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a // useState-style call doesn't write a "[object Object]" key into the persisted // JSON block. const setTweak = React.useCallback((keyOrEdits, val) => { const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null ? keyOrEdits : { [keyOrEdits]: val }; setValues((prev) => ({ ...prev, ...edits })); window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*'); // Same-window signal so in-page listeners (deck-stage rail thumbnails) // can react — the parent message only reaches the host, not peers. window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits })); }, []); return [values, setTweak]; } // ── TweaksPanel ───────────────────────────────────────────────────────────── // Floating shell. Registers the protocol listener BEFORE announcing // availability — if the announce ran first, the host's activate could land // before our handler exists and the toolbar toggle would silently no-op. // The close button posts __edit_mode_dismissed so the host's toolbar toggle // flips off in lockstep; the host echoes __deactivate_edit_mode back which // is what actually hides the panel. function TweaksPanel({ title = 'Tweaks', children }) { const [open, setOpen] = React.useState(false); const dragRef = React.useRef(null); const offsetRef = React.useRef({ x: 16, y: 16 }); const PAD = 16; const clampToViewport = React.useCallback(() => { const panel = dragRef.current; if (!panel) return; const w = panel.offsetWidth, h = panel.offsetHeight; const maxRight = Math.max(PAD, window.innerWidth - w - PAD); const maxBottom = Math.max(PAD, window.innerHeight - h - PAD); offsetRef.current = { x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)), y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)), }; panel.style.right = offsetRef.current.x + 'px'; panel.style.bottom = offsetRef.current.y + 'px'; }, []); React.useEffect(() => { if (!open) return; clampToViewport(); if (typeof ResizeObserver === 'undefined') { window.addEventListener('resize', clampToViewport); return () => window.removeEventListener('resize', clampToViewport); } const ro = new ResizeObserver(clampToViewport); ro.observe(document.documentElement); return () => ro.disconnect(); }, [open, clampToViewport]); React.useEffect(() => { const onMsg = (e) => { const t = e?.data?.type; if (t === '__activate_edit_mode') setOpen(true); else if (t === '__deactivate_edit_mode') setOpen(false); }; window.addEventListener('message', onMsg); window.parent.postMessage({ type: '__edit_mode_available' }, '*'); return () => window.removeEventListener('message', onMsg); }, []); const dismiss = () => { setOpen(false); window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*'); }; const onDragStart = (e) => { const panel = dragRef.current; if (!panel) return; const r = panel.getBoundingClientRect(); const sx = e.clientX, sy = e.clientY; const startRight = window.innerWidth - r.right; const startBottom = window.innerHeight - r.bottom; const move = (ev) => { offsetRef.current = { x: startRight - (ev.clientX - sx), y: startBottom - (ev.clientY - sy), }; clampToViewport(); }; const up = () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); }; window.addEventListener('mousemove', move); window.addEventListener('mouseup', up); }; if (!open) return null; return ( <>
{title}
{children}
); } // ── Layout helpers ────────────────────────────────────────────────────────── function TweakSection({ label, children }) { return ( <>
{label}
{children} ); } function TweakRow({ label, value, children, inline = false }) { return (
{label} {value != null && {value}}
{children}
); } // ── Controls ──────────────────────────────────────────────────────────────── function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) { return ( onChange(Number(e.target.value))} /> ); } function TweakToggle({ label, value, onChange }) { return (
{label}
); } function TweakRadio({ label, value, options, onChange }) { const trackRef = React.useRef(null); const [dragging, setDragging] = React.useState(false); // The active value is read by pointer-move handlers attached for the lifetime // of a drag — ref it so a stale closure doesn't fire onChange for every move. const valueRef = React.useRef(value); valueRef.current = value; // Segments wrap mid-word once per-segment width runs out. The track is // ~248px (280 panel − 28 body pad − 4 seg pad), each button loses 12px // to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2 // options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall // back to a dropdown rather than wrap. const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length; const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0); const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0); if (!fitsAsSegments) { // onChange(e.target.value)}> {options.map((o) => { const v = typeof o === 'object' ? o.value : o; const l = typeof o === 'object' ? o.label : o; return ; })} ); } function TweakText({ label, value, placeholder, onChange }) { return ( onChange(e.target.value)} /> ); } function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) { const clamp = (n) => { if (min != null && n < min) return min; if (max != null && n > max) return max; return n; }; const startRef = React.useRef({ x: 0, val: 0 }); const onScrubStart = (e) => { e.preventDefault(); startRef.current = { x: e.clientX, val: value }; const decimals = (String(step).split('.')[1] || '').length; const move = (ev) => { const dx = ev.clientX - startRef.current.x; const raw = startRef.current.val + dx * step; const snapped = Math.round(raw / step) * step; onChange(clamp(Number(snapped.toFixed(decimals)))); }; const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); }; window.addEventListener('pointermove', move); window.addEventListener('pointerup', up); }; return (
{label} onChange(clamp(Number(e.target.value)))} /> {unit && {unit}}
); } // Relative-luminance contrast pick — checkmarks drawn over a swatch need to // read on both #111 and #fafafa without per-option configuration. Hex input // only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light". function __twkIsLight(hex) { const h = String(hex).replace('#', ''); const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0'); const n = parseInt(x.slice(0, 6), 16); if (Number.isNaN(n)) return true; const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255; return r * 299 + g * 587 + b * 114 > 148000; } const __TwkCheck = ({ light }) => ( ); // TweakColor — curated color/palette picker. Each option is either a single // hex string or an array of 1-5 hex strings; the card adapts — a lone color // renders solid, a palette renders colors[0] as the hero (left ~2/3) with the // rest stacked in a sharp column on the right. onChange emits the // option in the shape it was passed (string stays string, array stays array). // Without options it falls back to the native color input for back-compat. function TweakColor({ label, value, options, onChange }) { if (!options || !options.length) { return (
{label}
onChange(e.target.value)} />
); } // Native emits lowercase hex per the HTML spec, so // compare case-insensitively. String() guards JSON.stringify(undefined), // which returns the primitive undefined (no .toLowerCase). const key = (o) => String(JSON.stringify(o)).toLowerCase(); const cur = key(value); return (
{options.map((o, i) => { const colors = Array.isArray(o) ? o : [o]; const [hero, ...rest] = colors; const sup = rest.slice(0, 4); const on = key(o) === cur; return ( ); })}
); } function TweakButton({ label, onClick, secondary = false }) { return ( ); } Object.assign(window, { useTweaks, TweaksPanel, TweakSection, TweakRow, TweakSlider, TweakToggle, TweakRadio, TweakSelect, TweakText, TweakNumber, TweakColor, TweakButton, }); })(); /* ===== data.js ===== */ ;(function(){ /* ============ FTrack — mock data (pesca esportiva · Amazônia) ============ */ (function () { // seeded RNG (mulberry32) — estável entre reloads function mulberry32(a) { return function () { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } const rng = mulberry32(20260604); const rand = (min, max) => min + rng() * (max - min); const randi = (min, max) => Math.floor(rand(min, max + 1)); const pick = (arr) => arr[Math.floor(rng() * arr.length)]; const wpick = (arr) => { // weighted pick: [item, weight] const tot = arr.reduce((s, x) => s + x[1], 0); let r = rng() * tot; for (const [item, w] of arr) { if ((r -= w) <= 0) return item; } return arr[0][0]; }; // ---- Espécies (alvos da pesca esportiva amazônica) ---- const SPECIES = [ { id: 'tuc-acu', nome: 'Tucunaré-açu', cientifico: 'Cichla temensis', cor: '#0369a1', cat: 'Predador', pesoMin: 1.5, pesoMax: 12.5, compMin: 45, compMax: 98, soltura: 0.93 }, { id: 'tuc-paca', nome: 'Tucunaré-paca', cientifico: 'Cichla pinima', cor: '#0891b2', cat: 'Predador', pesoMin: 0.8, pesoMax: 6.0, compMin: 35, compMax: 72, soltura: 0.94 }, { id: 'tuc-borb', nome: 'Tucunaré-borboleta', cientifico: 'Cichla orinocensis', cor: '#06b6d4', cat: 'Predador', pesoMin: 0.6, pesoMax: 4.2, compMin: 30, compMax: 60, soltura: 0.95 }, { id: 'pirarucu', nome: 'Pirarucu', cientifico: 'Arapaima gigas', cor: '#7c3aed', cat: 'Gigante', pesoMin: 25, pesoMax: 110, compMin: 130, compMax: 245, soltura: 0.99 }, { id: 'aruana', nome: 'Aruanã', cientifico: 'Osteoglossum bicirrhosum', cor: '#0d9488', cat: 'Predador', pesoMin: 1.0, pesoMax: 4.5, compMin: 55, compMax: 90, soltura: 0.9 }, { id: 'traira', nome: 'Traíra', cientifico: 'Hoplias malabaricus', cor: '#65a30d', cat: 'Predador', pesoMin: 0.7, pesoMax: 5.0, compMin: 30, compMax: 65, soltura: 0.7 }, { id: 'bicuda', nome: 'Bicuda', cientifico: 'Boulengerella cuvieri', cor: '#ca8a04', cat: 'Predador', pesoMin: 0.5, pesoMax: 3.2, compMin: 40, compMax: 80, soltura: 0.85 }, { id: 'cachorra', nome: 'Cachorra', cientifico: 'Hydrolycus scomberoides', cor: '#dc2626', cat: 'Predador', pesoMin: 1.0, pesoMax: 8.0, compMin: 45, compMax: 95, soltura: 0.88 }, { id: 'matrinxa', nome: 'Matrinxã', cientifico: 'Brycon amazonicus', cor: '#ea580c', cat: 'Escama', pesoMin: 0.8, pesoMax: 4.0, compMin: 35, compMax: 60, soltura: 0.6 }, { id: 'pirapitinga', nome: 'Pirapitinga', cientifico: 'Piaractus brachypomus', cor: '#d97706', cat: 'Escama', pesoMin: 2.0, pesoMax: 12.0, compMin: 40, compMax: 75, soltura: 0.55 }, { id: 'tambaqui', nome: 'Tambaqui', cientifico: 'Colossoma macropomum', cor: '#b45309', cat: 'Escama', pesoMin: 3.0, pesoMax: 22.0, compMin: 45, compMax: 90, soltura: 0.5 }, { id: 'jau', nome: 'Jaú', cientifico: 'Zungaro zungaro', cor: '#475569', cat: 'Bagre', pesoMin: 8.0, pesoMax: 65.0, compMin: 70, compMax: 150, soltura: 0.92 }, { id: 'pintado', nome: 'Pintado', cientifico: 'Pseudoplatystoma corruscans', cor: '#64748b', cat: 'Bagre', pesoMin: 4.0, pesoMax: 40.0, compMin: 60, compMax: 130, soltura: 0.88 }, { id: 'piraiba', nome: 'Piraíba', cientifico: 'Brachyplatystoma filamentosum', cor: '#334155', cat: 'Gigante', pesoMin: 15, pesoMax: 95.0, compMin: 90, compMax: 200, soltura: 0.97 }, ]; const SPECIES_WEIGHTS = [ ['tuc-acu', 30], ['tuc-paca', 18], ['tuc-borb', 12], ['pirarucu', 3], ['aruana', 6], ['traira', 5], ['bicuda', 5], ['cachorra', 7], ['matrinxa', 4], ['pirapitinga', 3], ['tambaqui', 3], ['jau', 2], ['pintado', 3], ['piraiba', 1.5], ]; // ---- Operações (lodges / barcos) ---- const OPERATIONS = [ { id: 'op-rio-negro', nome: 'Lodge Rio Negro', tipo: 'Lodge', base: 'Barcelos, AM', barcos: 6, ativa: true }, { id: 'op-marie', nome: 'Untamed Marié', tipo: 'Barco-hotel', base: 'Santa Isabel, AM', barcos: 4, ativa: true }, { id: 'op-xeriuini', nome: 'Xeriuíni Expedições', tipo: 'Barco-hotel', base: 'Caracaraí, RR', barcos: 3, ativa: true }, { id: 'op-agua-boa', nome: 'Água Boa Amazon', tipo: 'Lodge', base: 'Novo Airão, AM', barcos: 8, ativa: true }, { id: 'op-thaimacu', nome: 'Pousada Thaimaçu', tipo: 'Pousada', base: 'Jacareacanga, PA', barcos: 5, ativa: true }, { id: 'op-kalua', nome: 'Barco Kaluana', tipo: 'Barco-hotel', base: 'Manaus, AM', barcos: 2, ativa: false }, { id: 'op-otter', nome: 'Otter Expeditions', tipo: 'Barco-hotel', base: 'Barcelos, AM', barcos: 3, ativa: true }, ]; const OP_WEIGHTS = [ ['op-rio-negro', 24], ['op-marie', 14], ['op-xeriuini', 10], ['op-agua-boa', 22], ['op-thaimacu', 13], ['op-kalua', 6], ['op-otter', 11], ]; // ---- Guias ---- const NOMES = ['João', 'Pedro', 'Raimundo', 'Antônio', 'Manoel', 'Francisco', 'Sebastião', 'José', 'Carlos', 'Edmilson', 'Adriano', 'Wesley', 'Cleiton', 'Marcos', 'Rogério', 'Valdir', 'Nélson', 'Aldo', 'Genilson', 'Ronaldo', 'Daniel', 'Fábio', 'Igor', 'Lucas']; const SOBRENOMES = ['da Silva', 'Souza', 'Ferreira', 'Lima', 'Pereira', 'Nogueira', 'Cordeiro', 'da Costa', 'Mendes', 'Tavares', 'Barreto', 'Rodrigues', 'Albuquerque', 'Bentes', 'Vasconcelos', 'Pinto', 'Maciel', 'Brandão']; const GUIDES = []; for (let i = 0; i < 28; i++) { const nome = `${pick(NOMES)} ${pick(SOBRENOMES)}`; GUIDES.push({ id: 'g-' + (i + 1), nome, iniciais: nome.split(' ').map(p => p[0]).filter(c => c === c.toUpperCase()).slice(0, 2).join(''), operacaoId: wpick(OP_WEIGHTS), desde: 2018 + randi(0, 6), ativo: rng() > 0.12, telefone: `(92) 9${randi(8000, 9999)}-${randi(1000, 9999)}`, }); } // ---- Locais (rios / igarapés) com âncora geográfica ---- const LOCAIS = [ { nome: 'Rio Negro', lat: -0.93, lng: -62.92 }, { nome: 'Rio Marié', lat: -0.42, lng: -66.05 }, { nome: 'Rio Xeriuíni', lat: 0.65, lng: -61.55 }, { nome: 'Rio Branco', lat: 1.82, lng: -61.12 }, { nome: 'Rio Aracá', lat: 0.38, lng: -63.20 }, { nome: 'Rio Demini', lat: 0.55, lng: -62.78 }, { nome: 'Igarapé do Daraá', lat: -0.21, lng: -64.83 }, { nome: 'Rio Cururu', lat: -7.55, lng: -57.95 }, { nome: 'Rio São Benedito', lat: -8.40, lng: -57.95 }, { nome: 'Lago do Jaú', lat: -1.92, lng: -61.60 }, { nome: 'Paraná do Cuieiras', lat: -2.65, lng: -60.50 }, { nome: 'Rio Unini', lat: -1.70, lng: -61.45 }, ]; const ISCAS = ['Zara', 'Helicóptero', 'Spinnerbait', 'Jig', 'Plug de superfície', 'Hot dog', 'Colher', 'Isca viva', 'Soft', 'Popper']; // ---- Registros ---- function gauss() { // soft normal in [0,1] return (rng() + rng() + rng()) / 3; } const N = 4180; const RECORDS = []; const START = new Date('2024-01-08').getTime(); const END = new Date('2026-06-02').getTime(); for (let i = 0; i < N; i++) { const spId = wpick(SPECIES_WEIGHTS); const sp = SPECIES.find(s => s.id === spId); const g = gauss(); const peso = +(sp.pesoMin + (sp.pesoMax - sp.pesoMin) * g * g).toFixed(1); const comp = Math.round(sp.compMin + (sp.compMax - sp.compMin) * (0.4 * g + 0.6 * rng())); // sazonalidade: seca amazônica (jul–nov) concentra capturas let ts; do { ts = START + rng() * (END - START); const m = new Date(ts).getMonth(); // 0=jan const seasonal = (m >= 6 && m <= 10) ? 0.85 : 0.32; if (rng() < seasonal) break; } while (true); const d = new Date(ts); d.setHours(randi(5, 17), randi(0, 59), 0, 0); const loc = pick(LOCAIS); const guia = pick(GUIDES); RECORDS.push({ id: i + 1, code: 'FT-' + String(i + 1).padStart(5, '0'), data: d.toISOString(), especieId: sp.id, peso, comprimento: comp, guiaId: guia.id, operacaoId: guia.operacaoId, local: loc.nome, lat: +(loc.lat + rand(-0.18, 0.18)).toFixed(4), lng: +(loc.lng + rand(-0.18, 0.18)).toFixed(4), isca: pick(ISCAS), }); } RECORDS.sort((a, b) => new Date(b.data) - new Date(a.data)); // ---- Lookups + agregados úteis ---- const speciesById = Object.fromEntries(SPECIES.map(s => [s.id, s])); const guidesById = Object.fromEntries(GUIDES.map(g => [g.id, g])); const opsById = Object.fromEntries(OPERATIONS.map(o => [o.id, o])); // contagens por espécie / guia / operação (sobre todos os registros) const countBy = (key) => RECORDS.reduce((m, r) => { m[r[key]] = (m[r[key]] || 0) + 1; return m; }, {}); const bySpecies = countBy('especieId'); const byGuide = countBy('guiaId'); const byOp = countBy('operacaoId'); SPECIES.forEach(s => { s.capturas = bySpecies[s.id] || 0; }); GUIDES.forEach(g => { g.capturas = byGuide[g.id] || 0; }); OPERATIONS.forEach(o => { o.capturas = byOp[o.id] || 0; o.guias = GUIDES.filter(g => g.operacaoId === o.id).length; }); window.FT = { SPECIES, OPERATIONS, GUIDES, RECORDS, LOCAIS, ISCAS, speciesById, guidesById, opsById, }; })(); })(); /* ===== icons.jsx ===== */ ;(function(){ /* ============ FTrack — ícones (line, estilo lucide) ============ */ const ICON_PATHS = { dashboard: 'M3 3h8v8H3zM13 3h8v5h-8zM13 12h8v9h-8zM3 14h8v7H3z', fish: 'M6.5 12c.94-3.46 4.94-6 8.5-6 3.56 0 6.06 2.54 7 6-.94 3.47-3.44 6-7 6s-7.56-2.53-8.5-6zM18 12h.01M2 16s1.5-2 4-2M2 8s1.5 2 4 2', ship: 'M12 10.19V4M5 10.5V4a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v6.5M3 14l1.5 5.5a2 2 0 0 0 2 .5h11a2 2 0 0 0 2-.5L21 14M3 14a3 3 0 0 0 4 0 3 3 0 0 0 4 0 3 3 0 0 0 4 0 3 3 0 0 0 4 0', list: 'M3 5h18M3 12h18M3 19h18', table: 'M3 4h18v16H3zM3 10h18M3 16h18M9 4v16', users: 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75', search: 'M11 11m-8 0a8 8 0 1 0 16 0 8 8 0 1 0-16 0M21 21l-4.3-4.3', bell: 'M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0', settings: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z', chevronDown: 'M6 9l6 6 6-6', chevronRight: 'M9 18l6-6-6-6', chevronLeft: 'M15 18l-6-6 6-6', chevronsLeft: 'M11 17l-5-5 5-5M18 17l-5-5 5-5', chevronsRight: 'M13 17l5-5-5-5M6 17l5-5-5-5', filter: 'M22 3H2l8 9.46V19l4 2v-8.54z', plus: 'M12 5v14M5 12h14', download: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3', more: 'M12 12h.01M19 12h.01M5 12h.01', x: 'M18 6L6 18M6 6l12 12', check: 'M20 6L9 17l-5-5', sort: 'M11 5h10M11 9h7M11 13h4M3 17l3 3 3-3M6 18V4', arrowUp: 'M12 19V5M5 12l7-7 7 7', arrowDown: 'M12 5v14M19 12l-7 7-7-7', calendar: 'M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z', mapPin: 'M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0zM12 10m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0', trendUp: 'M22 7l-8.5 8.5-5-5L2 17M16 7h6v6', trendDown: 'M22 17l-8.5-8.5-5 5L2 7M16 17h6v-6', scale: 'M12 3v18M7 7l-5 9a5 3 0 0 0 10 0zM17 7l5 9a5 3 0 0 1-10 0zM7 7h10M5 21h14', ruler: 'M3 21l18-18M14.5 3.5l6 6M3.5 14.5l6 6M7 11l2 2M11 7l2 2M11 15l2 2M15 11l2 2', logout: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9', eye: 'M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7zM12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0', columns: 'M3 3h18v18H3zM12 3v18', sliders: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6', waves: 'M2 6c.6.5 1.2 1 2.5 1C7 7 7 5 9.5 5c2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1M2 12c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1M2 18c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1', target: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0M12 12m-6 0a6 6 0 1 0 12 0 6 6 0 1 0-12 0M12 12m-2 0a2 2 0 1 0 4 0 2 2 0 1 0-4 0', award: 'M12 15a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM8.21 13.89L7 23l5-3 5 3-1.21-9.12', menu: 'M3 6h18M3 12h18M3 18h18', lock: 'M5 11h14a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2zM7 11V7a5 5 0 0 1 10 0v4', user: 'M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z', mail: 'M22 6l-10 7L2 6M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z', arrowRight: 'M5 12h14M12 5l7 7-7 7', hash: 'M4 9h16M4 15h16M10 3L8 21M16 3l-2 18', dot: 'M12 12m-4 0a4 4 0 1 0 8 0 4 4 0 1 0-8 0', ruler2: 'M3 8h18v8H3zM7 8v4M11 8v4M15 8v4M19 8v4', clock: 'M12 12m-9 0a9 9 0 1 0 18 0 9 9 0 1 0-18 0M12 7v5l3 2', refresh: 'M3 12a9 9 0 0 1 15-6.7L21 8M21 3v5h-5M21 12a9 9 0 0 1-15 6.7L3 16M3 21v-5h5', trophy: 'M6 9H4.5a2.5 2.5 0 0 1 0-5H6M18 9h1.5a2.5 2.5 0 0 0 0-5H18M4 22h16M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22M18 2H6v7a6 6 0 0 0 12 0V2z', upload: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12', pencil: 'M17 3a2.85 2.85 0 0 1 4 4L7.5 20.5 2 22l1.5-5.5z', fishSimple: 'M2 16s9-3 9-9c0 0 3 2 3 6s-3 6-3 6M11 7s2 0 3 1M16 10c2-1 5 0 6 2-1 2-4 3-6 2', fileText: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6M8 13h8M8 17h8M8 9h2', printer: 'M6 9V2h12v7M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2M6 14h12v8H6z', shield: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z', building: 'M3 21h18M5 21V7l8-4v18M19 21V11l-6-4M9 9v.01M9 12v.01M9 15v.01M9 18v.01', fileCheck: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6M9 15l2 2 4-4', send: 'M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z', info: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0M12 16v-4M12 8h.01', chartBar: 'M3 3v18h18M7 16v-5M12 16V8M17 16v-3', }; function Icon({ name, size = 16, className = '', style = {}, strokeWidth = 1.75 }) { const d = ICON_PATHS[name] || ICON_PATHS.dot; return ( ); } window.Icon = Icon; })(); /* ===== ui.jsx ===== */ ;(function(){ /* ============ FTrack — primitivos de UI (shadcn-like) ============ */ const { useState, useRef, useEffect, useMemo, useCallback } = React; // ---------- formatadores PT-BR ---------- const nf0 = new Intl.NumberFormat('pt-BR'); const nf1 = new Intl.NumberFormat('pt-BR', { minimumFractionDigits: 1, maximumFractionDigits: 1 }); const fmtInt = (n) => nf0.format(Math.round(n)); const fmtKg = (n) => nf1.format(n) + ' kg'; const MESES = ['jan', 'fev', 'mar', 'abr', 'mai', 'jun', 'jul', 'ago', 'set', 'out', 'nov', 'dez']; const MESES_FULL = ['Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho', 'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro']; function fmtDate(iso) { const d = new Date(iso); return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`; } function fmtDateTime(iso) { const d = new Date(iso); return `${fmtDate(iso)} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; } function fmtTime(iso) { const d = new Date(iso); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; } // ---------- Button ---------- function Button({ children, variant = 'default', size = 'md', icon, iconRight, className = '', style = {}, ...rest }) { const base = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontWeight: 500, whiteSpace: 'nowrap', borderRadius: 'var(--radius-sm)', border: '1px solid transparent', transition: 'background .12s, border-color .12s, box-shadow .12s, color .12s', cursor: 'pointer', userSelect: 'none', }; const sizes = { sm: { height: 28, padding: '0 9px', fontSize: 12.5 }, md: { height: 34, padding: '0 13px', fontSize: 13.5 }, lg: { height: 40, padding: '0 18px', fontSize: 14 }, icon: { height: 32, width: 32, padding: 0, fontSize: 13.5 }, iconSm: { height: 28, width: 28, padding: 0 }, }; const variants = { default: { background: 'hsl(var(--primary))', color: 'hsl(var(--primary-foreground))', boxShadow: 'var(--shadow-sm)' }, secondary: { background: 'hsl(var(--background))', color: 'hsl(var(--foreground))', borderColor: 'hsl(var(--border))', boxShadow: 'var(--shadow-sm)' }, ghost: { background: 'transparent', color: 'hsl(var(--muted-foreground))' }, ghostDark: { background: 'transparent', color: 'hsl(var(--foreground))' }, outline: { background: 'transparent', color: 'hsl(var(--foreground))', borderColor: 'hsl(var(--border))' }, danger: { background: 'hsl(var(--danger-bg))', color: 'hsl(var(--danger))', borderColor: 'hsl(var(--danger) / .2)' }, }; const [hover, setHover] = useState(false); const hoverStyle = hover ? { default: { background: 'hsl(var(--primary-700))' }, secondary: { background: 'hsl(var(--muted))' }, ghost: { background: 'hsl(var(--muted))', color: 'hsl(var(--foreground))' }, ghostDark: { background: 'hsl(var(--muted))' }, outline: { background: 'hsl(var(--muted))' }, danger: { background: 'hsl(var(--danger) / .12)' }, }[variant] : {}; return ( ); } // ---------- Badge / chip ---------- function Badge({ children, tone = 'neutral', dot, style = {}, className = '' }) { const tones = { neutral: { bg: 'hsl(var(--muted))', fg: 'hsl(var(--muted-foreground))', bd: 'hsl(var(--border))' }, primary: { bg: 'hsl(var(--primary-50))', fg: 'hsl(var(--primary-700))', bd: 'hsl(var(--primary) / .18)' }, success: { bg: 'hsl(var(--success-bg))', fg: 'hsl(var(--success))', bd: 'hsl(var(--success) / .2)' }, warning: { bg: 'hsl(var(--warning-bg))', fg: 'hsl(var(--warning))', bd: 'hsl(var(--warning) / .25)' }, danger: { bg: 'hsl(var(--danger-bg))', fg: 'hsl(var(--danger))', bd: 'hsl(var(--danger) / .2)' }, }; const t = tones[tone] || tones.neutral; return ( {dot && } {children} ); } // ---------- SpeciesDot ---------- function SpeciesDot({ cor, size = 8 }) { return ; } // ---------- Avatar (iniciais) ---------- function Avatar({ name, iniciais, size = 24, cor }) { const hue = useMemo(() => { let h = 0; for (const c of (name || '')) h = (h * 31 + c.charCodeAt(0)) % 360; return h; }, [name]); const bg = cor || `hsl(${hue} 42% 92%)`; const fg = cor ? '#fff' : `hsl(${hue} 45% 32%)`; return ( {iniciais || (name || '?').slice(0, 2)} ); } // ---------- Card ---------- function Card({ children, className = '', style = {}, pad = true, ...rest }) { return (
{children}
); } // ---------- Input ---------- function Input({ icon, className = '', style = {}, wrapStyle = {}, ...rest }) { return (
{icon && }
); } // ---------- Checkbox ---------- function Checkbox({ checked, indeterminate, onChange, size = 15 }) { return ( { e.stopPropagation(); onChange && onChange(!checked); }} className="focusable" tabIndex={0} onKeyDown={(e) => { if (e.key === ' ') { e.preventDefault(); onChange && onChange(!checked); } }} style={{ width: size, height: size, borderRadius: 4, flexShrink: 0, cursor: 'pointer', border: `1.5px solid ${checked || indeterminate ? 'hsl(var(--primary))' : 'hsl(var(--border))'}`, background: checked || indeterminate ? 'hsl(var(--primary))' : 'hsl(var(--background))', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', transition: 'background .1s, border-color .1s', }}> {checked && } {indeterminate && !checked && } ); } // ---------- Select (custom dropdown) ---------- function Select({ value, options, onChange, size = 'md', placeholder = 'Selecionar', className = '', style = {}, align = 'left', minWidth = 0 }) { const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h); }, []); const cur = options.find(o => (o.value ?? o) === value); const label = cur ? (cur.label ?? cur) : placeholder; const h = size === 'sm' ? 28 : 34; return (
{open && (
{options.map((o, i) => { const v = o.value ?? o, l = o.label ?? o; const sel = v === value; return (
{ onChange(v); setOpen(false); }} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', borderRadius: 5, cursor: 'pointer', fontSize: 13, color: 'hsl(var(--foreground))', background: sel ? 'hsl(var(--muted))' : 'transparent', }} onMouseEnter={(e) => e.currentTarget.style.background = 'hsl(var(--muted))'} onMouseLeave={(e) => e.currentTarget.style.background = sel ? 'hsl(var(--muted))' : 'transparent'}> {o.dot && } {l} {sel && }
); })}
)}
); } // ---------- Popover genérico (anchored) ---------- function Popover({ trigger, children, align = 'left', width = 240, panelStyle = {} }) { const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h); }, []); return (
{trigger(open, () => setOpen(o => !o))} {open && (
{typeof children === 'function' ? children(() => setOpen(false)) : children}
)}
); } // ---------- KPI stat ---------- function Stat({ label, value, sub, delta, deltaTone, icon, accent }) { return (
{label} {icon && ( )}
{value} {sub && {sub}}
{delta != null && (
{delta} vs. período anterior
)}
); } // ---------- Section header ---------- function PageHeader({ title, subtitle, actions, children }) { return (

{title}

{subtitle &&

{subtitle}

} {children}
{actions &&
{actions}
}
); } // ---------- DatePicker (presets + calendário, intervalo) ---------- const DP_PRESETS = [ { key: 'all', label: 'Todo período' }, { key: '7d', label: 'Últimos 7 dias' }, { key: '30d', label: 'Últimos 30 dias' }, { key: '90d', label: 'Últimos 90 dias' }, { key: '12m', label: 'Últimos 12 meses' }, { key: 'ytd', label: 'Este ano' }, ]; function dpRange(key, refDate) { const ref = new Date(refDate); const end = ref.getTime(); const days = { '7d': 7, '30d': 30, '90d': 90, '12m': 365 }[key]; if (days) return { from: end - days * 864e5, to: end }; if (key === 'ytd') return { from: new Date(ref.getFullYear(), 0, 1).getTime(), to: end }; return { from: null, to: null }; } function DatePicker({ value, onChange, refDate, align = 'left' }) { // value: { key, from, to } — key='custom' usa from/to const [viewM, setViewM] = useState(() => { const d = new Date(value.to || refDate); return new Date(d.getFullYear(), d.getMonth(), 1); }); const [pendFrom, setPendFrom] = useState(null); const label = useMemo(() => { if (value.key && value.key !== 'custom') return DP_PRESETS.find(p => p.key === value.key)?.label || 'Período'; if (value.from && value.to) return `${fmtDate(new Date(value.from).toISOString())} – ${fmtDate(new Date(value.to).toISOString())}`; return 'Período personalizado'; }, [value]); const isActive = value.key && value.key !== 'all'; const buildDays = (base) => { const y = base.getFullYear(), m = base.getMonth(); const first = new Date(y, m, 1); const startDow = (first.getDay() + 6) % 7; // semana começa seg const daysInM = new Date(y, m + 1, 0).getDate(); const cells = []; for (let i = 0; i < startDow; i++) cells.push(null); for (let d = 1; d <= daysInM; d++) cells.push(new Date(y, m, d)); return cells; }; return ( ( )}> {(close) => (
{/* presets */}
{DP_PRESETS.map(p => { const sel = value.key === p.key; return ( ); })}
{/* calendário */}
{MESES_FULL[viewM.getMonth()]} {viewM.getFullYear()}
{['S', 'T', 'Q', 'Q', 'S', 'S', 'D'].map((d, i) =>
{d}
)}
{buildDays(viewM).map((d, i) => { if (!d) return
; const ts = d.getTime(); const inRange = value.from && value.to && ts >= value.from && ts <= value.to + 864e5 - 1; const isEdge = (value.from && new Date(value.from).toDateString() === d.toDateString()) || (value.to && new Date(value.to).toDateString() === d.toDateString()); const isPend = pendFrom && new Date(pendFrom).toDateString() === d.toDateString(); return ( ); })}
{pendFrom ? 'Selecione a data final' : 'Clique para definir um intervalo'}
)} ); } const dpNav = { width: 26, height: 26, borderRadius: 5, border: 'none', background: 'transparent', color: 'hsl(var(--muted-foreground))', display: 'grid', placeItems: 'center', cursor: 'pointer' }; // ---------- Dialog / Modal ---------- function Dialog({ open, onClose, title, desc, icon, children, footer, width = 480 }) { useEffect(() => { if (!open) return; const h = (e) => { if (e.key === 'Escape') onClose(); }; document.addEventListener('keydown', h); return () => document.removeEventListener('keydown', h); }, [open, onClose]); if (!open) return null; return (
{ if (e.target === e.currentTarget) onClose(); }} style={{ position: 'fixed', inset: 0, zIndex: 300, background: 'hsl(240 10% 8% / .42)', backdropFilter: 'blur(2px)', display: 'grid', placeItems: 'center', padding: 24, animation: 'ft-pop .14s ease both', }}>
{icon &&
}

{title}

{desc &&

{desc}

}
{children}
{footer &&
{footer}
}
); } // ---------- Form field (para modais) ---------- function FormField({ label, hint, required, children, span }) { return (
{children} {hint && {hint}}
); } Object.assign(window, { useState, useRef, useEffect, useMemo, useCallback, nf0, nf1, fmtInt, fmtKg, MESES, MESES_FULL, fmtDate, fmtDateTime, fmtTime, Button, Badge, SpeciesDot, Avatar, Card, Input, Checkbox, Select, Popover, Stat, PageHeader, DatePicker, dpRange, DP_PRESETS, Dialog, FormField, }); })(); /* ===== charts.jsx ===== */ ;(function(){ /* ============ FTrack — gráficos (SVG leve) ============ */ // ---------- Tooltip flutuante ---------- function useChartTip() { const [tip, setTip] = useState(null); // {x,y,content} const node = tip ? (
{tip.content}
) : null; return [node, setTip]; } // ---------- BarChart (vertical, mensal) ---------- function BarChart({ data, height = 220, color = 'hsl(var(--primary))', valueFmt = fmtInt, barWidth = 26 }) { const [tip, setTip] = useChartTip(); const max = Math.max(...data.map(d => d.value), 1); const ticks = 4; return (
{tip}
{/* y axis */}
{Array.from({ length: ticks + 1 }).map((_, i) => ( {fmtInt(max * (ticks - i) / ticks)} ))}
{/* plot */}
{/* gridlines */}
{Array.from({ length: ticks + 1 }).map((_, i) => (
))}
{/* bars */}
{data.map((d, i) => { const hpct = (d.value / max) * 100; return (
setTip({ x: e.clientX, y: e.clientY, content: `${d.label}: ${valueFmt(d.value)}` })} onMouseLeave={() => setTip(null)}>
0 ? 2 : 0, background: d.highlight ? color : 'hsl(var(--primary) / .82)', borderRadius: '4px 4px 0 0', transition: 'background .12s', cursor: 'default', }} />
); })}
{/* x labels */}
{data.map((d, i) => ( {d.short ?? d.label} ))}
); } // ---------- Donut ---------- function Donut({ data, size = 168, thickness = 26, centerLabel, centerSub }) { const [tip, setTip] = useChartTip(); const total = data.reduce((s, d) => s + d.value, 0) || 1; const r = (size - thickness) / 2; const c = 2 * Math.PI * r; let acc = 0; return (
{tip} {data.map((d, i) => { const frac = d.value / total; const dash = frac * c; const off = acc * c; acc += frac; return ( setTip({ x: e.clientX, y: e.clientY, content: `${d.label}: ${fmtInt(d.value)} (${Math.round(frac * 100)}%)` })} onMouseLeave={() => setTip(null)} /> ); })} {centerLabel != null && (
{centerLabel}
{centerSub &&
{centerSub}
}
)}
); } // ---------- Ranking horizontal ---------- function RankBars({ data, valueFmt = fmtInt, max: maxProp }) { const max = maxProp ?? Math.max(...data.map(d => d.value), 1); return (
{data.map((d, i) => (
{d.dot && } {d.avatar} {d.label}
{valueFmt(d.value)}
))}
); } // ---------- Sparkline (área) ---------- function Sparkline({ data, width = 120, height = 34, color = 'hsl(var(--primary))' }) { const max = Math.max(...data, 1), min = Math.min(...data, 0); const rng = max - min || 1; const pts = data.map((v, i) => [i / (data.length - 1) * width, height - ((v - min) / rng) * (height - 4) - 2]); const line = pts.map((p, i) => `${i ? 'L' : 'M'}${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' '); const area = `${line} L${width} ${height} L0 ${height} Z`; const id = useMemo(() => 'sp' + Math.random().toString(36).slice(2, 7), []); return ( ); } // ---------- Mapa de calor geográfico (capturas por coordenada) ---------- function HeatMap({ records, locais, height = 320, onPointHover }) { const [tip, setTip] = useChartTip(); // bounding box a partir dos locais const lats = locais.map(l => l.lat), lngs = locais.map(l => l.lng); const pad = 0.6; const minLat = Math.min(...lats) - pad, maxLat = Math.max(...lats) + pad; const minLng = Math.min(...lngs) - pad, maxLng = Math.max(...lngs) + pad; const W = 1000, H = 620; const proj = (lat, lng) => [ ((lng - minLng) / (maxLng - minLng)) * W, H - ((lat - minLat) / (maxLat - minLat)) * H, ]; // agregação por local const agg = useMemo(() => { const m = {}; for (const r of records) { m[r.local] = (m[r.local] || 0) + 1; } return locais.map(l => ({ ...l, n: m[l.nome] || 0 })).filter(l => l.n > 0); }, [records, locais]); const maxN = Math.max(...agg.map(a => a.n), 1); return (
{tip} {/* graticula */} {/* heat blobs */} {agg.map((a, i) => { const [x, y] = proj(a.lat, a.lng); const rad = 70 + (a.n / maxN) * 140; return ; })} {/* pontos + rótulos */} {agg.map((a, i) => { const [x, y] = proj(a.lat, a.lng); const sz = 4 + (a.n / maxN) * 7; return ( setTip({ x: e.clientX, y: e.clientY, content: `${a.nome} · ${fmtInt(a.n)} capturas` })} onMouseLeave={() => setTip(null)}> ); })} {/* labels HTML overlay para nitidez */}
{agg.map((a, i) => { const [x, y] = proj(a.lat, a.lng); return ( {a.nome} ); })}
{/* legenda */}
Menos
Mais capturas
); } Object.assign(window, { BarChart, Donut, RankBars, Sparkline, HeatMap, useChartTip }); })(); /* ===== shell.jsx ===== */ ;(function(){ /* ============ FTrack — shell: sidebar + topbar ============ */ const NAV = [ { group: null, items: [ { id: 'dashboard', label: 'Dashboard', icon: 'dashboard' }, { id: 'registros', label: 'Registros', icon: 'table', badge: () => fmtInt(window.FT.RECORDS.length) }, { id: 'relatorios', label: 'Relatórios', icon: 'fileText' }, ]}, { group: 'Cadastros', items: [ { id: 'especies', label: 'Espécies', icon: 'fish' }, { id: 'operacoes', label: 'Operações', icon: 'ship' }, { id: 'usuarios', label: 'Usuários', icon: 'users' }, ]}, ]; function Logo({ compact }) { return (
{!compact && (
FTrack
Pesca esportiva
)}
); } function NavItem({ item, active, onClick }) { const [hover, setHover] = useState(false); return ( ); } function Sidebar({ current, onNavigate, user, onLogout }) { return ( ); } const menuItemStyle = { display: 'flex', alignItems: 'center', gap: 9, width: '100%', padding: '7px 8px', borderRadius: 5, border: 'none', background: 'transparent', cursor: 'pointer', fontSize: 13, color: 'hsl(var(--foreground))', textAlign: 'left' }; function Topbar({ title, onSearch }) { return (
FTrack {title}
onSearch && onSearch(e.target.value)} />
); } const iconBtn = { width: 32, height: 32, borderRadius: 'var(--radius-sm)', border: '1px solid transparent', background: 'transparent', color: 'hsl(var(--muted-foreground))', display: 'grid', placeItems: 'center', cursor: 'pointer' }; Object.assign(window, { Sidebar, Topbar, Logo, iconBtn }); })(); /* ===== login.jsx ===== */ ;(function(){ /* ============ FTrack — tela de Login ============ */ function LoginScreen({ onLogin }) { const [email, setEmail] = useState('admin@ftrack.com.br'); const [senha, setSenha] = useState('demo1234'); const [loading, setLoading] = useState(false); const submit = (e) => { e.preventDefault(); setLoading(true); setTimeout(() => { setLoading(false); onLogin(); }, 650); }; return (
{/* esquerda — formulário */}

Entrar

Acesse o painel de gestão de capturas da sua operação.

setEmail(e.target.value)} placeholder="voce@operacao.com" autoComplete="username" /> e.preventDefault()} style={{ fontSize: 12.5, color: 'hsl(var(--primary))', fontWeight: 500, textDecoration: 'none' }}>Esqueci a senha}> setSenha(e.target.value)} placeholder="••••••••" autoComplete="current-password" />
Acesso de demonstração
admin@ftrack.com.br · demo1234

Não tem conta? e.preventDefault()} style={{ color: 'hsl(var(--primary))', fontWeight: 500, textDecoration: 'none' }}>Fale com sua operação

© 2026 FTrack · Gestão de pesca esportiva
{/* direita — painel de marca */}
{/* placeholder de foto (listrado) */}
[ foto: pesca esportiva no Rio Negro ]
{[0, 1, 2].map(i => )}

Cada captura, registrada e mensurável.

Seus guias registram peso, espécie e coordenadas direto do barco. Você acompanha tudo aqui — em tempo real.

{[['4.180', 'capturas em 2025'], ['14', 'espécies-alvo'], ['7', 'operações ativas']].map(([n, l]) => (
{n}
{l}
))}
); } function Field({ label, aside, children }) { return (
{aside}
{children}
); } Object.assign(window, { LoginScreen }); })(); /* ===== dashboard.jsx ===== */ ;(function(){ /* ============ FTrack — Dashboard ============ */ function useDashData() { return useMemo(() => { const R = window.FT.RECORDS, S = window.FT.speciesById, G = window.FT.guidesById; const total = R.length; const pesoMedio = R.reduce((s, r) => s + r.peso, 0) / total; const pesoTotal = R.reduce((s, r) => s + r.peso, 0); const maiorPeixe = R.reduce((a, b) => b.peso > a.peso ? b : a); // por mês (últimos 12) const ref = new Date(R[0].data); // mais recente const months = []; for (let i = 11; i >= 0; i--) { const d = new Date(ref.getFullYear(), ref.getMonth() - i, 1); months.push({ key: `${d.getFullYear()}-${d.getMonth()}`, label: `${MESES[d.getMonth()]}/${String(d.getFullYear()).slice(2)}`, short: MESES[d.getMonth()], value: 0 }); } const mIdx = Object.fromEntries(months.map((m, i) => [m.key, i])); R.forEach(r => { const d = new Date(r.data); const k = `${d.getFullYear()}-${d.getMonth()}`; if (k in mIdx) months[mIdx[k]].value++; }); months[months.length - 1].highlight = true; // espécies const spArr = window.FT.SPECIES.map(s => ({ ...s })).sort((a, b) => b.capturas - a.capturas); const topSp = spArr[0]; // guias top const topGuias = window.FT.GUIDES.slice().sort((a, b) => b.capturas - a.capturas).slice(0, 6); // sparkline últimos 12 const spark = months.map(m => m.value); return { total, pesoMedio, pesoTotal, maiorPeixe, months, spArr, topSp, topGuias, spark, S, G }; }, []); } function Panel({ title, sub, children, action, style = {}, bodyStyle = {} }) { return ( {(title || action) && (

{title}

{sub &&

{sub}

}
{action}
)}
{children}
); } function RecentTable({ data, rows = 8 }) { const R = window.FT.RECORDS.slice(0, rows); return (
{R.map((r, i) => { const sp = data.S[r.especieId], g = data.G[r.guiaId]; return (
{sp.nome}
{r.local} · {g.nome.split(' ')[0]}
{fmtKg(r.peso)}
{fmtTime(r.data)}
); })}
); } function SpeciesLegend({ spArr, total }) { return (
{spArr.slice(0, 6).map(s => (
{s.nome} {Math.round(s.capturas / total * 100)}%
))}
); } function Dashboard({ layout = 'padrao', onNavigate }) { const d = useDashData(); const donutData = d.spArr.slice(0, 7).map(s => ({ label: s.nome, value: s.capturas, cor: s.cor })); const period = ( setQ(e.target.value)} style={{ height: 30, fontSize: 12.5 }} autoFocus />
{filt.map(o => { const on = selected.has(o.value); return (
{ const n = new Set(selected); on ? n.delete(o.value) : n.add(o.value); onChange(n); }} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 7px', borderRadius: 5, cursor: 'pointer' }} onMouseEnter={(e) => e.currentTarget.style.background = 'hsl(var(--muted))'} onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}> {}} size={14} /> {o.dot && } {o.avatar} {o.label} {fmtInt(o.count)}
); })} {filt.length === 0 &&
Nenhum resultado
}
{count > 0 && (
)}
)} ); } const STATUS_REMOVED = true; function Registros({ density = 'ultra', onNew }) { const FT = window.FT; const ALL = FT.RECORDS; const maxTs = new Date(ALL[0].data).getTime(); const [search, setSearch] = useState(''); const [fSpecies, setFSpecies] = useState(new Set()); const [fOp, setFOp] = useState(new Set()); const [fGuia, setFGuia] = useState(new Set()); const [range, setRange] = useState({ key: 'all', from: null, to: null }); const [sort, setSort] = useState({ key: 'data', dir: 'desc' }); const [selected, setSelected] = useState(new Set()); const [scrollTop, setScrollTop] = useState(0); const scrollRef = useRef(null); const rowH = density === 'ultra' ? 30 : 36; // contagens p/ facetas (dataset completo) const speciesOpts = FT.SPECIES.map(s => ({ value: s.id, label: s.nome, dot: s.cor, count: s.capturas })).sort((a, b) => b.count - a.count); const opOpts = FT.OPERATIONS.map(o => ({ value: o.id, label: o.nome, count: o.capturas })).sort((a, b) => b.count - a.count); const guiaOpts = FT.GUIDES.map(g => ({ value: g.id, label: g.nome, avatar: , count: g.capturas })).sort((a, b) => b.count - a.count); // filtragem const filtered = useMemo(() => { let fromTs = null, toTs = null; if (range.key === 'custom') { fromTs = range.from; toTs = range.to + 864e5 - 1; } else if (range.key !== 'all') { const r = dpRange(range.key, maxTs); fromTs = r.from; toTs = r.to; } const q = search.trim().toLowerCase(); let res = ALL.filter(r => { if (fSpecies.size && !fSpecies.has(r.especieId)) return false; if (fOp.size && !fOp.has(r.operacaoId)) return false; if (fGuia.size && !fGuia.has(r.guiaId)) return false; const ts = new Date(r.data).getTime(); if (fromTs && ts < fromTs) return false; if (toTs && ts > toTs) return false; if (q) { const sp = FT.speciesById[r.especieId], g = FT.guidesById[r.guiaId]; const hay = `${r.code} ${sp.nome} ${g.nome} ${r.local} ${r.isca}`.toLowerCase(); if (!hay.includes(q)) return false; } return true; }); const dir = sort.dir === 'asc' ? 1 : -1; const getv = (r) => { switch (sort.key) { case 'data': return new Date(r.data).getTime(); case 'especie': return FT.speciesById[r.especieId].nome; case 'guia': return FT.guidesById[r.guiaId].nome; case 'operacao': return FT.opsById[r.operacaoId].nome; case 'local': return r.local; default: return r[sort.key]; } }; res = res.slice().sort((a, b) => { const va = getv(a), vb = getv(b); return va < vb ? -dir : va > vb ? dir : 0; }); return res; }, [search, fSpecies, fOp, fGuia, range, sort]); // virtualização const VH = 100000; // computed dynamically below by container; approximate buffer const containerH = (scrollRef.current && scrollRef.current.clientHeight) || 560; const visCount = Math.ceil(containerH / rowH) + 8; const start = Math.max(0, Math.floor(scrollTop / rowH) - 4); const slice = filtered.slice(start, start + visCount); const activeFilters = fSpecies.size + fOp.size + fGuia.size + (range.key !== 'all' ? 1 : 0); const clearAll = () => { setFSpecies(new Set()); setFOp(new Set()); setFGuia(new Set()); setRange({ key: 'all', from: null, to: null }); setSearch(''); }; // colunas — largura mínima + peso de expansão (fr) para preencher a linha toda const cols = [ { id: 'check', w: 34, header: 0 && selected.size === filtered.length} indeterminate={selected.size > 0 && selected.size < filtered.length} onChange={(v) => setSelected(v ? new Set(filtered.map(r => r.id)) : new Set())} /> }, { id: 'code', w: 80, label: '#', sortable: true, mono: true }, { id: 'data', min: 120, grow: 1, label: 'Data / hora', sortable: true }, { id: 'especie', min: 150, grow: 1.6, label: 'Espécie', sortable: true }, { id: 'peso', w: 76, label: 'Peso', align: 'right', sortable: true }, { id: 'comprimento', w: 70, label: 'Comp.', align: 'right', sortable: true }, { id: 'guia', min: 140, grow: 1.5, label: 'Guia', sortable: true }, { id: 'operacao', min: 138, grow: 1.4, label: 'Operação', sortable: true }, { id: 'local', min: 120, grow: 1.2, label: 'Local', sortable: true }, { id: 'coord', min: 120, grow: 1.1, label: 'Coordenadas' }, { id: 'isca', min: 92, grow: 1, label: 'Isca' }, { id: 'act', w: 40, label: '' }, ]; const [hiddenCols, setHiddenCols] = useState(new Set()); const visCols = cols.filter(c => !hiddenCols.has(c.id)); const gridTemplate = visCols.map(c => c.w != null ? c.w + 'px' : `minmax(${c.min}px, ${c.grow}fr)`).join(' '); const minW = visCols.reduce((s, c) => s + (c.w != null ? c.w : c.min), 0); const toggleSort = (key) => setSort(s => s.key === key ? { key, dir: s.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: key === 'data' || key === 'peso' || key === 'comprimento' ? 'desc' : 'asc' }); const renderCell = (c, r) => { const sp = FT.speciesById[r.especieId], g = FT.guidesById[r.guiaId], op = FT.opsById[r.operacaoId]; switch (c.id) { case 'check': return { const n = new Set(selected); v ? n.add(r.id) : n.delete(r.id); setSelected(n); }} />; case 'code': return {r.code}; case 'data': return {fmtDate(r.data)} {fmtTime(r.data)}; case 'especie': return {sp.nome}; case 'peso': return {nf1.format(r.peso)}; case 'comprimento': return {r.comprimento}; case 'guia': return {g.nome}; case 'operacao': return {op.nome}; case 'local': return {r.local}; case 'coord': return {r.lat.toFixed(3)}, {r.lng.toFixed(3)}; case 'isca': return {r.isca}; case 'act': return ; default: return null; } }; return (
} /> {/* toolbar de filtros */}
setSearch(e.target.value)} style={{ height: 32 }} />
{activeFilters > 0 && }
c.label)} hidden={hiddenCols} onChange={setHiddenCols} />
{/* tabela */}
setScrollTop(e.target.scrollTop)} style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
{/* header */}
{visCols.map(c => (
c.sortable && toggleSort(c.id)} style={{ display: 'flex', alignItems: 'center', gap: 4, justifyContent: c.align === 'right' ? 'flex-end' : 'flex-start', padding: '0 10px', fontSize: 11.5, fontWeight: 600, color: 'hsl(var(--muted-foreground))', cursor: c.sortable ? 'pointer' : 'default', userSelect: 'none', letterSpacing: '.01em', }}> {c.header || c.label} {c.sortable && sort.key === c.id && }
))}
{/* corpo virtualizado */}
{slice.map((r, i) => { const sel = selected.has(r.id); return (
{ if (!sel) e.currentTarget.style.background = 'hsl(var(--muted))'; }} onMouseLeave={(e) => { if (!sel) e.currentTarget.style.background = (start + i) % 2 ? 'hsl(var(--muted) / .35)' : 'transparent'; }}> {visCols.map(c => (
{renderCell(c, r)}
))}
); })}
{filtered.length === 0 &&

Nenhum registro encontrado com esses filtros.

}
{/* footer */}
{selected.size > 0 ? `${fmtInt(selected.size)} selecionado(s)` : `Exibindo ${fmtInt(filtered.length)} registro(s)`}
{selected.size > 0 && <>} Linha {fmtInt(Math.min(start + 1, filtered.length))}–{fmtInt(Math.min(start + visCount, filtered.length))}
); } function ColumnToggle({ cols, hidden, onChange }) { return ( ( )}>
{cols.map(c => { const vis = !hidden.has(c.id); return (
{ const n = new Set(hidden); vis ? n.add(c.id) : n.delete(c.id); onChange(n); }} style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '6px 8px', borderRadius: 5, cursor: 'pointer', fontSize: 13 }} onMouseEnter={(e) => e.currentTarget.style.background = 'hsl(var(--muted))'} onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}> {}} size={14} />{c.label}
); })}
); } Object.assign(window, { Registros }); })(); /* ===== registries.jsx ===== */ ;(function(){ /* ============ FTrack — Cadastros: Espécies · Operações · Usuários ============ */ // ---------------- ESPÉCIES ---------------- function EspeciesScreen({ onNavigate, onNew }) { const FT = window.FT; const [q, setQ] = useState(''); const [cat, setCat] = useState('all'); const cats = ['all', ...Array.from(new Set(FT.SPECIES.map(s => s.cat)))]; const maxCap = Math.max(...FT.SPECIES.map(s => s.capturas)); const list = FT.SPECIES.filter(s => (cat === 'all' || s.cat === cat) && (s.nome.toLowerCase().includes(q.toLowerCase()) || s.cientifico.toLowerCase().includes(q.toLowerCase()))).sort((a, b) => b.capturas - a.capturas); const catTone = { 'Predador': 'primary', 'Gigante': 'danger', 'Bagre': 'neutral', 'Escama': 'warning' }; return (
onNew && onNew('especie')}>Nova espécie} />
setQ(e.target.value)} style={{ height: 32 }} />
{cats.map(c => ( ))}
{list.map(s => (

{s.nome}

{s.cat}

{s.cientifico}

{[['Capturas', fmtInt(s.capturas)], ['Peso típ.', `${nf1.format(s.pesoMin)}–${nf1.format(s.pesoMax)} kg`], ['Comp. típ.', `${s.compMin}–${s.compMax} cm`]].map(([l, v], i) => (
{l}
{v}
))}
))}
); } // ---------------- OPERAÇÕES ---------------- function OperacoesScreen({ onNew }) { const FT = window.FT; const [q, setQ] = useState(''); const list = FT.OPERATIONS.filter(o => o.nome.toLowerCase().includes(q.toLowerCase())).sort((a, b) => b.capturas - a.capturas); const tipoTone = { 'Lodge': 'primary', 'Barco-hotel': 'neutral', 'Pousada': 'warning' }; return (
o.ativa).length} ativas · ${FT.OPERATIONS.length} no total`} actions={} />
setQ(e.target.value)} style={{ height: 32 }} />
{list.map(o => (

{o.nome}

{o.ativa ? 'Ativa' : 'Inativa'}
{o.base}
{[['Tipo', {o.tipo}], ['Barcos', fmtInt(o.barcos)], ['Guias', fmtInt(o.guias)], ['Capturas', fmtInt(o.capturas)]].map(([l, v], i) => (
{l}
{v}
))}
))}
); } // ---------------- USUÁRIOS ---------------- function UsuariosScreen({ onNew }) { const FT = window.FT; const [q, setQ] = useState(''); const [fOp, setFOp] = useState('all'); const [fStatus, setFStatus] = useState('all'); // monta lista de usuários: admin + guias const users = useMemo(() => { const arr = [{ id: 'u-admin', nome: 'Marina Couto', iniciais: 'MC', papel: 'Administrador', operacaoId: null, desde: 2021, ativo: true, telefone: '(92) 99812-4471', capturas: 0 }]; FT.GUIDES.forEach((g, i) => arr.push({ ...g, papel: i % 9 === 0 ? 'Gestor' : 'Guia' })); return arr; }, []); const list = users.filter(u => (fOp === 'all' || u.operacaoId === fOp) && (fStatus === 'all' || (fStatus === 'ativo' ? u.ativo : !u.ativo)) && u.nome.toLowerCase().includes(q.toLowerCase()) ).sort((a, b) => b.capturas - a.capturas); const papelTone = { 'Administrador': 'danger', 'Gestor': 'primary', 'Guia': 'neutral' }; const cols = [ { id: 'nome', w: '2fr', label: 'Usuário' }, { id: 'papel', w: '1fr', label: 'Papel' }, { id: 'op', w: '1.4fr', label: 'Operação' }, { id: 'desde', w: '0.8fr', label: 'Desde' }, { id: 'tel', w: '1.2fr', label: 'Telefone' }, { id: 'cap', w: '0.9fr', label: 'Capturas', align: 'right' }, { id: 'status', w: '0.9fr', label: 'Status' }, { id: 'act', w: '44px', label: '' }, ]; const gt = cols.map(c => typeof c.w === 'number' ? c.w + 'px' : c.w).join(' '); return (
u.ativo).length} ativos · guias, gestores e administradores`} actions={} />
setQ(e.target.value)} style={{ height: 32 }} />
{cols.map(c =>
{c.label}
)}
{list.map((u, i) => { const op = u.operacaoId ? FT.opsById[u.operacaoId] : null; return (
e.currentTarget.style.background = 'hsl(var(--muted) / .5)'} onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}>
{u.nome}
{u.nome.toLowerCase().replace(/[^a-z ]/g, '').split(' ').slice(0, 2).join('.')}@ftrack
{u.papel}
{op ? op.nome : '—'}
{u.desde}
{u.telefone}
{u.capturas ? fmtInt(u.capturas) : '—'}
{u.ativo ? 'Ativo' : 'Inativo'}
); })}
); } Object.assign(window, { EspeciesScreen, OperacoesScreen, UsuariosScreen }); })(); /* ===== modals.jsx ===== */ ;(function(){ /* ============ FTrack — Modais de cadastro + Toast ============ */ // ---------- Toast (canto inferior direito) ---------- function useToast() { const [toasts, setToasts] = useState([]); const push = useCallback((msg, tone = 'success') => { const id = Math.random().toString(36).slice(2); setToasts(t => [...t, { id, msg, tone }]); setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), 3400); }, []); const node = (
{toasts.map(t => (
{t.msg}
))}
); return [node, push]; } // ---------- Helpers de formulário ---------- const inputCls = { height: 34 }; function fieldGrid(children, cols = 2) { return
{children}
; } // ---------- Form: Espécie ---------- function EspecieForm({ onChange }) { const [f, setF] = useState({ nome: '', cientifico: '', cat: 'Predador', cor: '#0369a1', pesoMin: '', pesoMax: '', compMin: '', compMax: '' }); useEffect(() => onChange(f), [f]); const set = (k) => (e) => setF(s => ({ ...s, [k]: e.target ? e.target.value : e })); const CORES = ['#0369a1', '#0891b2', '#06b6d4', '#7c3aed', '#0d9488', '#65a30d', '#ca8a04', '#dc2626', '#ea580c', '#475569']; return (
{fieldGrid(<> {fieldGrid(<>
)}
{CORES.map(c => (
); } // ---------- Form: Operação ---------- function OperacaoForm({ onChange }) { const [f, setF] = useState({ nome: '', tipo: 'Lodge', cidade: '', uf: 'AM', barcos: '', cnpj: '', ativa: true }); useEffect(() => onChange(f), [f]); const set = (k) => (e) => setF(s => ({ ...s, [k]: e.target ? e.target.value : e })); const UFS = ['AM', 'PA', 'RR', 'RO', 'AC', 'MT', 'TO']; return (
{fieldGrid(<> )} {fieldGrid(<> setF(s => ({ ...s, ativa: !s.ativa }))} label={f.ativa ? 'Operação ativa' : 'Operação inativa'} /> )}
); } // ---------- Form: Usuário ---------- function UsuarioForm({ onChange }) { const FT = window.FT; const [f, setF] = useState({ nome: '', email: '', papel: 'Guia', operacaoId: FT.OPERATIONS[0].id, telefone: '', ativo: true }); useEffect(() => onChange(f), [f]); const set = (k) => (e) => setF(s => ({ ...s, [k]: e.target ? e.target.value : e })); return (
O usuário receberá um convite por e-mail para acessar o app de registro.
{fieldGrid(<> {fieldGrid(<> )}
); } // ---------- Form: Registro de captura ---------- function RegistroForm({ onChange }) { const FT = window.FT; const [f, setF] = useState({ especieId: FT.SPECIES[0].id, peso: '', comprimento: '', guiaId: FT.GUIDES[0].id, local: FT.LOCAIS[0].nome, isca: FT.ISCAS[0], data: new Date().toISOString().slice(0, 10), lat: '', lng: '' }); useEffect(() => onChange(f), [f]); const set = (k) => (e) => setF(s => ({ ...s, [k]: e.target ? e.target.value : e })); return (
)} {fieldGrid(<> )} {fieldGrid(<> setF(s => ({ ...s, isca: v }))} options={FT.ISCAS} /> )} {fieldGrid(<> )}
); } // ---------- Toggle row ---------- function ToggleRow({ on, onToggle, label }) { return ( ); } // ---------- Modal config ---------- const MODALS = { especie: { title: 'Nova espécie', desc: 'Cadastre uma espécie-alvo da operação.', icon: 'fish', width: 540, Form: EspecieForm, cta: 'Cadastrar espécie', toast: 'Espécie cadastrada com sucesso.' }, operacao: { title: 'Nova operação', desc: 'Lodge, barco-hotel ou pousada.', icon: 'ship', width: 520, Form: OperacaoForm, cta: 'Cadastrar operação', toast: 'Operação cadastrada com sucesso.' }, usuario: { title: 'Convidar usuário', desc: 'Guia, gestor ou administrador.', icon: 'users', width: 520, Form: UsuarioForm, cta: 'Enviar convite', toast: 'Convite enviado por e-mail.' }, registro: { title: 'Novo registro de captura', desc: 'Lançamento manual de uma captura.', icon: 'plus', width: 560, Form: RegistroForm, cta: 'Salvar registro', toast: 'Registro de captura salvo.' }, }; // ---------- Modal host (escuta eventos globais) ---------- function ModalHost() { const [active, setActive] = useState(null); const [toastNode, pushToast] = useToast(); const formRef = useRef({}); useEffect(() => { const h = (e) => setActive(e.detail); window.addEventListener('ft:open-modal', h); return () => window.removeEventListener('ft:open-modal', h); }, []); const cfg = active ? MODALS[active] : null; const close = () => setActive(null); const save = () => { close(); if (cfg) pushToast(cfg.toast); }; return ( <> {cfg && ( }> { formRef.current = v; }} /> )} {toastNode} ); } window.openModal = (name) => window.dispatchEvent(new CustomEvent('ft:open-modal', { detail: name })); Object.assign(window, { ModalHost, useToast }); })(); /* ===== reports.jsx ===== */ ;(function(){ /* ============ FTrack — Relatórios (foco IBAMA) ============ */ const REPORT_TYPES = [ { value: 'captura', label: 'Relatório de Atividade de Pesca' }, { value: 'especie', label: 'Quantitativo por espécie' }, { value: 'esforco', label: 'Esforço de pesca por operação' }, ]; function useReportData(operacaoId, range) { const FT = window.FT; return useMemo(() => { const maxTs = new Date(FT.RECORDS[0].data).getTime(); let fromTs = null, toTs = null; if (range.key === 'custom') { fromTs = range.from; toTs = range.to + 864e5 - 1; } else if (range.key !== 'all') { const r = dpRange(range.key, maxTs); fromTs = r.from; toTs = r.to; } const recs = FT.RECORDS.filter(r => { if (operacaoId !== 'all' && r.operacaoId !== operacaoId) return false; const ts = new Date(r.data).getTime(); if (fromTs && ts < fromTs) return false; if (toTs && ts > toTs) return false; return true; }); // por espécie const bySp = {}; recs.forEach(r => { const s = bySp[r.especieId] || (bySp[r.especieId] = { n: 0, peso: 0, comp: 0 }); s.n++; s.peso += r.peso; s.comp += r.comprimento; }); const linhas = Object.entries(bySp).map(([id, v]) => ({ sp: FT.speciesById[id], n: v.n, pesoTotal: v.peso, pesoMedio: v.peso / v.n, compMedio: v.comp / v.n, })).sort((a, b) => b.n - a.n); const totN = recs.length; const totPeso = recs.reduce((s, r) => s + r.peso, 0); const guias = new Set(recs.map(r => r.guiaId)).size; const dias = new Set(recs.map(r => r.data.slice(0, 10))).size; const locais = new Set(recs.map(r => r.local)).size; return { recs, linhas, totN, totPeso, guias, dias, locais, fromTs, toTs }; }, [operacaoId, range]); } function fmtPeriodo(fromTs, toTs) { const f = fromTs ? fmtDate(new Date(fromTs).toISOString()) : '—'; const t = toTs ? fmtDate(new Date(toTs).toISOString()) : fmtDate(new Date().toISOString()); return `${f} a ${t}`; } function ReportDoc({ tipo, operacao, data, range }) { const hoje = fmtDate(new Date().toISOString()); const protocolo = 'IBAMA-PA/' + (operacao === 'all' ? 'CONSOLIDADO' : operacao.toUpperCase().replace('OP-', '')) + '-' + new Date().getFullYear(); const op = operacao === 'all' ? null : window.FT.opsById[operacao]; return (
{/* cabeçalho oficial */}
Ministério do Meio Ambiente e Mudança do Clima
IBAMA — Instituto Brasileiro do Meio Ambiente e dos Recursos Naturais Renováveis
Sistema de Controle de Pesca Amadora e Esportiva

{tipo === 'captura' ? 'RELATÓRIO DE ATIVIDADE DE PESCA AMADORA/ESPORTIVA' : tipo === 'especie' ? 'RELATÓRIO QUANTITATIVO POR ESPÉCIE' : 'RELATÓRIO DE ESFORÇO DE PESCA'}

Protocolo nº {protocolo} · Emitido em {hoje}
{/* dados da operação */} {[ ['Operação / Estabelecimento', op ? op.nome : 'Todas as operações (consolidado)'], ['Tipo', op ? op.tipo : '—'], ['Município-base / UF', op ? op.base : '—'], ['CNPJ', op ? '12.345.678/0001-90' : '—'], ['Período de apuração', fmtPeriodo(data.fromTs, data.toTs)], ['Responsável técnico', 'Marina Couto — Administrador'], ].map(([k, v], i) => ( ))}
{k} {v}
{/* resumo */}
{[['Exemplares', fmtInt(data.totN)], ['Peso total (kg)', nf1.format(data.totPeso)], ['Dias de atividade', fmtInt(data.dias)], ['Guias envolvidos', fmtInt(data.guias)]].map(([l, v], i) => (
{l}
{v}
))}
{/* tabela de espécies */}
Demonstrativo de capturas por espécie
{['Nome popular', 'Nome científico', 'Exemplares', 'Peso total (kg)', 'Peso médio (kg)', 'Comp. médio (cm)'].map((h, i) => ( ))} {data.linhas.map((l, i) => ( ))}
{h}
{l.sp.nome} {l.sp.cientifico} {fmtInt(l.n)} {nf1.format(l.pesoTotal)} {nf1.format(l.pesoMedio)} {fmtInt(l.compMedio)}
TOTAL GERAL {fmtInt(data.totN)} {nf1.format(data.totPeso)}
{/* declaração */}

Declaro, para os devidos fins e sob as penas da lei, que as informações constantes neste relatório são verídicas e refletem fielmente a atividade de pesca amadora/esportiva conduzida no período de apuração indicado, em conformidade com a Instrução Normativa IBAMA aplicável e demais normas de ordenamento pesqueiro vigentes.

{/* assinaturas */}
{['Responsável pela operação', 'Responsável técnico'].map(l => (
{l}
))}
Documento gerado eletronicamente por FTrack · {hoje} · página 1 de 1
); } function RelatoriosScreen() { const FT = window.FT; const [tipo, setTipo] = useState('captura'); const [operacao, setOperacao] = useState('all'); const [range, setRange] = useState({ key: 'ytd', ...dpRange('ytd', new Date(FT.RECORDS[0].data).getTime()) }); const maxTs = new Date(FT.RECORDS[0].data).getTime(); const data = useReportData(operacao, range); const [toastNode, pushToast] = useToast(); return (
} />
{/* configuração */}

Configuração

({ value: o.id, label: o.nome }))]} />
Resumo do período
{[['Exemplares', fmtInt(data.totN)], ['Peso total', fmtKg(data.totPeso)], ['Espécies', fmtInt(data.linhas.length)], ['Pontos de pesca', fmtInt(data.locais)], ['Dias de atividade', fmtInt(data.dias)]].map(([l, v]) => (
{l} {v}
))}
Pré-visualização do documento ao lado. Use “Imprimir / PDF” para gerar o arquivo final assinável.
{/* documento */}
{toastNode}
); } Object.assign(window, { RelatoriosScreen }); })(); /* ===== app.jsx ===== */ ;(function(){ /* ============ FTrack — app raiz ============ */ const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "dashLayout": "padrao", "accent": "oceano", "tableDensity": "ultra" }/*EDITMODE-END*/; const ACCENTS = { oceano: { p: '201 96% 32%', p7: '202 90% 26%', p50: '204 100% 97%', ring: '201 96% 32%', label: 'Azul oceano' }, verde: { p: '162 88% 26%', p7: '163 90% 20%', p50: '152 76% 96%', ring: '162 88% 26%', label: 'Verde água' }, slate: { p: '215 25% 35%', p7: '216 30% 27%', p50: '214 32% 96%', ring: '215 25% 35%', label: 'Slate' }, indigo: { p: '243 60% 50%', p7: '244 55% 42%', p50: '226 100% 97%', ring: '243 60% 50%', label: 'Índigo' }, }; const USER = { nome: 'Marina Couto', iniciais: 'MC', papel: 'Administrador' }; const SCREEN_TITLE = { dashboard: 'Dashboard', registros: 'Registros', relatorios: 'Relatórios', especies: 'Espécies', operacoes: 'Operações', usuarios: 'Usuários' }; function App() { const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); const [authed, setAuthed] = useState(() => sessionStorage.getItem('ft_auth') === '1'); const [screen, setScreen] = useState(() => localStorage.getItem('ft_screen') || 'dashboard'); // aplica accent useEffect(() => { const a = ACCENTS[t.accent] || ACCENTS.oceano; const r = document.documentElement.style; r.setProperty('--primary', a.p); r.setProperty('--primary-700', a.p7); r.setProperty('--primary-50', a.p50); r.setProperty('--ring', a.ring); }, [t.accent]); const navigate = (s) => { setScreen(s); localStorage.setItem('ft_screen', s); }; const login = () => { setAuthed(true); sessionStorage.setItem('ft_auth', '1'); }; const logout = () => { setAuthed(false); sessionStorage.removeItem('ft_auth'); }; const panel = ( setTweak('dashLayout', v)} /> setTweak('tableDensity', v)} /> 'hsl(' + ACCENTS[k].p + ')')} onChange={(v) => { const k = Object.keys(ACCENTS).find(k => 'hsl(' + ACCENTS[k].p + ')' === v); if (k) setTweak('accent', k); }} /> ); if (!authed) { return <>{}{panel}; } let content; if (screen === 'dashboard') content = ; else if (screen === 'registros') content = ; else if (screen === 'relatorios') content = ; else if (screen === 'especies') content = ; else if (screen === 'operacoes') content = ; else if (screen === 'usuarios') content = ; const fullBleed = screen === 'registros'; return (
{content}
{panel}
); } ReactDOM.createRoot(document.getElementById('root')).render(); })();