deploy ProspectOS workspace final

This commit is contained in:
Marco0300
2026-09-06 17:16:09 +02:00
parent eeffaf4b6c
commit 7250f6d521
10 changed files with 1151 additions and 81 deletions
+15
View File
@@ -27,6 +27,21 @@ BROWSER_USE_LLM_TIMEOUT_SECONDS=90
ANONYMIZED_TELEMETRY=false ANONYMIZED_TELEMETRY=false
LOG_RETENTION=20000 LOG_RETENTION=20000
# Authentication and workspace ownership. Leave OWNER_* blank to use the
# first-run setup screen; setting them seeds the owner on an unattended start.
AUTH_REQUIRED=true
AUTH_SESSION_TTL_SECONDS=604800
AUTH_COOKIE_SECURE=false
WORKSPACE_NAME=ProspectOS workspace
OWNER_EMAIL=
OWNER_PASSWORD=
OWNER_NAME=Workspace owner
# Verified online backups. The API retains the newest N files.
BACKUP_DIR=/data/backups
BACKUP_RETENTION=14
BACKUP_INTERVAL_HOURS=24
# AI provider for analysis and tailored drafts: nous or custom. # AI provider for analysis and tailored drafts: nous or custom.
AI_PROVIDER=custom AI_PROVIDER=custom
# For Nous, use https://portal.nousresearch.com to create an API key; # For Nous, use https://portal.nousresearch.com to create an API key;
+55
View File
@@ -225,3 +225,58 @@ This project is packaged for personal research. Public availability does not
override a website's terms, robots controls, or applicable privacy law; keep override a website's terms, robots controls, or applicable privacy law; keep
searches small, use the worker only on data you are permitted to collect, and searches small, use the worker only on data you are permitted to collect, and
do not turn the review screen into unattended outreach. do not turn the review screen into unattended outreach.
## Workspace accounts and operational controls
Authentication is enabled by default. On a fresh database, open the app and
complete the first-run owner setup. For unattended deployments, set
`OWNER_EMAIL`, `OWNER_PASSWORD` (8+ characters), `OWNER_NAME`, and
`WORKSPACE_NAME` in `.env`; the owner is created automatically only when no
user exists. Sessions are server-side, cookies are HTTP-only, and mutating
browser requests require a per-session CSRF token. Failed sign-ins are
temporarily rate-limited after five consecutive failures.
Roles are deliberately small:
| Role | Capabilities |
|---|---|
| Viewer | Read catalogue and runs |
| Reviewer | Viewer + review status, notes, and follow-up tasks |
| Researcher | Reviewer + discovery runs, retries, cancellation, and assignments |
| Admin | Researcher + users, workspace name, backups, and audit trail |
| Owner | Admin, and cannot be removed as the last active owner |
The **Users** page manages workspace members. A prospect drawer contains its
assignee, notes, tasks, review status, and score/AI assessment history. Every
workspace-scoped API query checks the signed-in organisation before returning
data.
## Backups and recovery
The app keeps the existing SQLite catalogue and performs additive schema
migrations; it does not delete or rewrite existing prospects. Use **Users →
Verified backups → Create backup** or run this inside the app container:
```bash
python scripts/backup_sqlite.py
```
Backups use SQLite's online backup API, run `PRAGMA integrity_check`, and keep
the newest `BACKUP_RETENTION` files in `BACKUP_DIR` (default `/data/backups`).
The background scheduler creates one automatically every `BACKUP_INTERVAL_HOURS`
(default 24; set it to `0` to disable automatic creation).
Include the `prospect_data` volume in server backups. A scheduled or interrupted
run is marked `recovery_required` after 30 minutes without a heartbeat. The
Runs page can show its persisted event timeline and retry a failed/recoverable
run as a new attempt, preserving the original run. Active jobs can be cancelled
and the worker records the cancellation in the event stream.
The audit trail records sign-ins, user and workspace changes, assignments,
review decisions, notes, tasks, AI assessments, message generation, run
retries/cancellations, settings, suppressions, and backup actions. Operational
logs remain available under **Logs**; credentials and authorization values are
redacted and retention is bounded by `LOG_RETENTION`.
This release intentionally keeps SQLite for safety. PostgreSQL migration can
be added later as a separate, tested data-migration project once the current
workspace and backup workflow is stable.
+1
View File
@@ -5,6 +5,7 @@ WORKDIR /app
COPY api/requirements.txt . COPY api/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY api/server.py api/intelligence.py api/browser_investigator.py ./ COPY api/server.py api/intelligence.py api/browser_investigator.py ./
COPY scripts /app/scripts
COPY web /app/web COPY web /app/web
RUN useradd --system --create-home app && mkdir -p /data && chown -R app:app /app /data RUN useradd --system --create-home app && mkdir -p /data && chown -R app:app /app /data
USER app USER app
+989 -73
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -31,6 +31,16 @@ services:
BROWSER_USE_LLM_TIMEOUT_SECONDS: ${BROWSER_USE_LLM_TIMEOUT_SECONDS:-90} BROWSER_USE_LLM_TIMEOUT_SECONDS: ${BROWSER_USE_LLM_TIMEOUT_SECONDS:-90}
ANONYMIZED_TELEMETRY: ${ANONYMIZED_TELEMETRY:-false} ANONYMIZED_TELEMETRY: ${ANONYMIZED_TELEMETRY:-false}
LOG_RETENTION: ${LOG_RETENTION:-20000} LOG_RETENTION: ${LOG_RETENTION:-20000}
AUTH_REQUIRED: ${AUTH_REQUIRED:-true}
AUTH_SESSION_TTL_SECONDS: ${AUTH_SESSION_TTL_SECONDS:-604800}
AUTH_COOKIE_SECURE: ${AUTH_COOKIE_SECURE:-false}
WORKSPACE_NAME: ${WORKSPACE_NAME:-ProspectOS workspace}
OWNER_EMAIL: ${OWNER_EMAIL:-}
OWNER_PASSWORD: ${OWNER_PASSWORD:-}
OWNER_NAME: ${OWNER_NAME:-Workspace owner}
BACKUP_DIR: ${BACKUP_DIR:-/data/backups}
BACKUP_RETENTION: ${BACKUP_RETENTION:-14}
BACKUP_INTERVAL_HOURS: ${BACKUP_INTERVAL_HOURS:-24}
AI_PROVIDER: ${AI_PROVIDER:-custom} AI_PROVIDER: ${AI_PROVIDER:-custom}
AI_BASE_URL: ${AI_BASE_URL:-} AI_BASE_URL: ${AI_BASE_URL:-}
AI_API_KEY: ${AI_API_KEY:-} AI_API_KEY: ${AI_API_KEY:-}
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""Create and verify a consistent SQLite backup for ProspectOS.
Usage inside the app container:
python scripts/backup_sqlite.py
"""
from __future__ import annotations
import json
import os
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
db_path = os.environ.get("PROSPECT_DB", "/data/prospects.db")
backup_dir = Path(os.environ.get("BACKUP_DIR", "") or (Path(db_path).parent / "backups")).resolve()
retention = max(1, min(100, int(os.environ.get("BACKUP_RETENTION", "14"))))
backup_dir.mkdir(parents=True, exist_ok=True)
name = "prospects-" + datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + ".db"
target = backup_dir / name
source = sqlite3.connect(db_path, timeout=30)
destination = sqlite3.connect(str(target), timeout=30)
try:
source.backup(destination)
check = destination.execute("PRAGMA integrity_check").fetchone()[0]
if str(check).lower() != "ok":
raise SystemExit(f"integrity_check_failed:{check}")
destination.commit()
finally:
destination.close()
source.close()
items = sorted(backup_dir.glob("prospects-*.db"), key=lambda path: path.stat().st_mtime, reverse=True)
for old in items[retention:]:
old.unlink(missing_ok=True)
print(json.dumps({"name": name, "path": str(target), "size_bytes": target.stat().st_size, "integrity": "ok"}))
+16
View File
@@ -17,6 +17,8 @@ class BrowserOnlyAppTests(unittest.TestCase):
self.tmp = tempfile.NamedTemporaryFile(delete=False) self.tmp = tempfile.NamedTemporaryFile(delete=False)
self.tmp.close() self.tmp.close()
server.DB_PATH = self.tmp.name server.DB_PATH = self.tmp.name
self.auth_required = server.AUTH_REQUIRED
server.AUTH_REQUIRED = False
self.scan_patcher = patch("api.server.scan_website", return_value={"website_status": "healthy", "website_http_status": 200, "website_final_url": "", "website_latency_ms": 50, "website_https": 1, "website_title": "", "website_technology": "", "website_has_viewport": 1, "website_tls_expires_at": "", "website_checked_at": server.now(), "website_error": "", "website_emails": [], "email_evidence": []}) self.scan_patcher = patch("api.server.scan_website", return_value={"website_status": "healthy", "website_http_status": 200, "website_final_url": "", "website_latency_ms": 50, "website_https": 1, "website_title": "", "website_technology": "", "website_has_viewport": 1, "website_tls_expires_at": "", "website_checked_at": server.now(), "website_error": "", "website_emails": [], "email_evidence": []})
self.scan_patcher.start() self.scan_patcher.start()
self.http = server.ThreadingHTTPServer(("127.0.0.1", 0), server.Handler) self.http = server.ThreadingHTTPServer(("127.0.0.1", 0), server.Handler)
@@ -25,6 +27,7 @@ class BrowserOnlyAppTests(unittest.TestCase):
self.conn = HTTPConnection("127.0.0.1", self.http.server_port) self.conn = HTTPConnection("127.0.0.1", self.http.server_port)
def tearDown(self): def tearDown(self):
server.AUTH_REQUIRED = self.auth_required
self.scan_patcher.stop() self.scan_patcher.stop()
self.http.shutdown(); self.http.server_close(); self.thread.join(1) self.http.shutdown(); self.http.server_close(); self.thread.join(1)
try: os.unlink(self.tmp.name) try: os.unlink(self.tmp.name)
@@ -294,6 +297,19 @@ class BrowserOnlyAppTests(unittest.TestCase):
saved = server.db().execute("SELECT ai_promotion_id FROM prospects WHERE id=1").fetchone() saved = server.db().execute("SELECT ai_promotion_id FROM prospects WHERE id=1").fetchone()
self.assertEqual(saved[0], promotion["id"]) self.assertEqual(saved[0], promotion["id"])
@patch("api.server.ai_assess_prospect", return_value={"ai_probability": 88, "ai_recommendation": "contact", "ai_reasoning": "Active business with a broken site."})
def test_workspace_workflow_and_assessment_history(self, assess):
with patch("api.server.google_search", return_value=[{"name": "Acme", "website": "https://acme.co.za", "domain": "acme.co.za"}]):
_, run = self.request("POST", "/api/discover", {"category": "plumbers", "keywords": "plumbing", "location": "Cape Town"})
self.wait_for_run(run["id"])
conn = server.db(); conn.execute("INSERT INTO users(organization_id,email,name,password_hash,role,active,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)", (1, "reviewer@example.com", "Reviewer", "", "reviewer", 1, server.now(), server.now())); conn.commit(); user_id = conn.execute("SELECT id FROM users WHERE email='reviewer@example.com'").fetchone()[0]; conn.close()
status, note = self.request("POST", "/api/prospects/1/notes", {"body": "Call after reviewing the website."}); self.assertEqual(status, 201)
status, task = self.request("POST", "/api/prospects/1/tasks", {"title": "Send tailored draft", "due_at": "2030-01-01"}); self.assertEqual(status, 201)
status, assigned = self.request("POST", "/api/prospects/1/assignment", {"assigned_user_id": user_id}); self.assertEqual(status, 200); self.assertEqual(assigned["assigned_user_id"], user_id)
status, body = self.request("POST", "/api/prospects/1/assess", {}); self.assertEqual(status, 200); self.assertEqual(body["ai_probability"], 88)
status, history = self.request("GET", "/api/prospects/1/history"); self.assertEqual(status, 200); self.assertTrue(history["score_history"]); self.assertTrue(history["ai_history"])
status, tasks = self.request("GET", "/api/prospects/1/tasks"); self.assertEqual(status, 200); self.assertEqual(tasks["items"][0]["title"], "Send tailored draft")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+25 -7
View File
@@ -1,14 +1,14 @@
const $=id=>document.getElementById(id); const esc=v=>String(v??"").replace(/[&<>\"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',"'":'&#39;'}[c])); const $=id=>document.getElementById(id); const esc=v=>String(v??"").replace(/[&<>\"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',"'":'&#39;'}[c]));
let prospects=[],runs=[],settings={},logs=[],selected=null,pollTimer=null; let prospects=[],runs=[],settings={},logs=[],selected=null,pollTimer=null,currentUser=null,csrfToken='';
let activeLogTab='all'; let activeLogTab='all';
const LOG_TABS=[['all','All'],['action','Actions'],['api','API calls'],['ai','AI calls'],['search','Searches'],['browser','Browser/tool steps'],['errors','Errors']]; const LOG_TABS=[['all','All'],['action','Actions'],['api','API calls'],['ai','AI calls'],['search','Searches'],['browser','Browser/tool steps'],['errors','Errors']];
function logAction(action,details={},level='info'){try{fetch('/api/logs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action,details,level,path:location.hash.slice(1)||'dashboard'})}).catch(()=>{})}catch{}} function logAction(action,details={},level='info'){try{fetch('/api/logs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action,details,level,path:location.hash.slice(1)||'dashboard'})}).catch(()=>{})}catch{}}
function installExtraNav(){const nav=$('nav');if(!nav)return;[['teach-ai','Teach AI','✦'],['promotions','Promotions','%'],['logs','Logs','≋']].forEach(([route,label,icon])=>{if(nav.querySelector(`[data-route="${route}"]`))return;const link=document.createElement('a');link.href='#'+route;link.dataset.route=route;link.innerHTML=`<span>${icon}</span>${label}`;nav.append(link)})} function installExtraNav(){const nav=$('nav');if(!nav)return;[['teach-ai','Teach AI','✦'],['promotions','Promotions','%'],['logs','Logs','≋'],['users','Users','♙']].forEach(([route,label,icon])=>{if(nav.querySelector(`[data-route="${route}"]`))return;const link=document.createElement('a');link.href='#'+route;link.dataset.route=route;link.innerHTML=`<span>${icon}</span>${label}`;nav.append(link)})}
installExtraNav(); installExtraNav();
let activeProspectCategory='all'; let activeProspectCategory='all';
let prospectScoreBand='all',prospectWebsiteFilter='all',prospectEmailFilter='all',prospectReviewFilter='all'; let prospectScoreBand='all',prospectWebsiteFilter='all',prospectEmailFilter='all',prospectReviewFilter='all';
const HIGH_POTENTIAL_SCORE=70; const HIGH_POTENTIAL_SCORE=70;
async function api(path,opt={}){const r=await fetch(path,{...opt,headers:{'Content-Type':'application/json',...(opt.headers||{})}});const b=await r.json().catch(()=>({}));if(!r.ok)throw Error(b.error||`Request failed (${r.status})`);return b} async function api(path,opt={}){const headers={'Content-Type':'application/json',...(opt.headers||{})};if(csrfToken&&!headers['X-CSRF-Token'])headers['X-CSRF-Token']=csrfToken;const r=await fetch(path,{...opt,credentials:'same-origin',headers});const b=await r.json().catch(()=>({}));if(r.status===401&&!path.startsWith('/api/auth/')){currentUser=null;csrfToken='';renderAuth({setup_required:false});}if(!r.ok)throw Error(b.error||`Request failed (${r.status})`);return b}
let loadWarnings=[]; let loadWarnings=[];
async function refreshWorkerStatus(){const dot=$('sourceDot'),label=$('sourceLabel'),sub=$('sourceSub');if(!dot||!label||!sub)return;try{const health=await api('/api/health');const ready=Boolean(health.configured);dot.classList.toggle('offline',!ready);label.textContent=health.source==='browser'?'Browser discovery':'Google Maps worker';sub.textContent=ready?'Ready':'Not configured';}catch(error){dot.classList.add('offline');label.textContent='Discovery status';sub.textContent='Unavailable';}} async function refreshWorkerStatus(){const dot=$('sourceDot'),label=$('sourceLabel'),sub=$('sourceSub');if(!dot||!label||!sub)return;try{const health=await api('/api/health');const ready=Boolean(health.configured);dot.classList.toggle('offline',!ready);label.textContent=health.source==='browser'?'Browser discovery':'Google Maps worker';sub.textContent=ready?'Ready':'Not configured';}catch(error){dot.classList.add('offline');label.textContent='Discovery status';sub.textContent='Unavailable';}}
function date(v){if(!v)return'—';const d=new Date(v);return isNaN(d)?String(v):d.toLocaleString(undefined,{day:'numeric',month:'short',hour:'2-digit',minute:'2-digit'})} function date(v){if(!v)return'—';const d=new Date(v);return isNaN(d)?String(v):d.toLocaleString(undefined,{day:'numeric',month:'short',hour:'2-digit',minute:'2-digit'})}
@@ -24,7 +24,7 @@ function prospectTable(){const rows=prospectRows();return `<div class="table-wra
function renderDashboard(){const total=prospects.length,pending=prospects.filter(p=>p.review_status==='pending').length,hot=prospects.filter(p=>effectiveScore(p)>=75).length,withEmail=prospects.filter(p=>p.emails).length;const strongest=[...prospects].sort((a,b)=>effectiveScore(b)-effectiveScore(a)).slice(0,5);$('view').innerHTML=layout('Dashboard','A clear view of what was found, what needs review, and where the best opportunities are.',`<div class="metric-grid"><div class="metric"><small>CATALOGUE</small><b>${total}</b><span>saved prospects</span></div><div class="metric"><small>NEEDS REVIEW</small><b>${pending}</b><span>awaiting your decision</span></div><div class="metric"><small>HIGH OPPORTUNITY</small><b>${hot}</b><span>score 75 or higher</span></div><div class="metric"><small>PUBLIC EMAILS</small><b>${withEmail}</b><span>evidence-backed contacts</span></div></div><div class="dashboard-grid"><article class="panel"><div class="panel-title"><div><span class="eyebrow">NEXT ACTION</span><h2>Review your strongest leads</h2><p>Inspect the evidence before deciding whether to contact them.</p></div><a class="primary" href="#prospects">Open explorer →</a></div><div class="spotlight">${strongest.map(p=>{const score=effectiveScore(p);return `<button class="spot" data-open="${p.id}"><span class="score ${scoreClass(score)}">${score}</span><span><b>${esc(p.name)}</b><small>${esc(p.website_status?.replaceAll('_',' ')||'unknown')} · ${esc(p.emails||'no public email')}${p.ai_checked_at?' · AI assessed':''}</small></span><span>›</span></button>`}).join('')||empty('Run a discovery to build your catalogue')}</div></article><article class="panel"><div class="panel-title"><div><span class="eyebrow">RECENT ACTIVITY</span><h2>Runs</h2></div><a href="#runs">View all</a></div><div class="activity">${runs.slice(0,6).map(r=>`<div><span class="run-dot ${r.status}"></span><span><b>#${r.id} · ${esc(r.query)}</b><small>${esc(r.status)} · ${r.added||0} added</small></span><time>${date(r.created_at)}</time></div>`).join('')||empty('No runs yet')}</div></article></div>`)} function renderDashboard(){const total=prospects.length,pending=prospects.filter(p=>p.review_status==='pending').length,hot=prospects.filter(p=>effectiveScore(p)>=75).length,withEmail=prospects.filter(p=>p.emails).length;const strongest=[...prospects].sort((a,b)=>effectiveScore(b)-effectiveScore(a)).slice(0,5);$('view').innerHTML=layout('Dashboard','A clear view of what was found, what needs review, and where the best opportunities are.',`<div class="metric-grid"><div class="metric"><small>CATALOGUE</small><b>${total}</b><span>saved prospects</span></div><div class="metric"><small>NEEDS REVIEW</small><b>${pending}</b><span>awaiting your decision</span></div><div class="metric"><small>HIGH OPPORTUNITY</small><b>${hot}</b><span>score 75 or higher</span></div><div class="metric"><small>PUBLIC EMAILS</small><b>${withEmail}</b><span>evidence-backed contacts</span></div></div><div class="dashboard-grid"><article class="panel"><div class="panel-title"><div><span class="eyebrow">NEXT ACTION</span><h2>Review your strongest leads</h2><p>Inspect the evidence before deciding whether to contact them.</p></div><a class="primary" href="#prospects">Open explorer →</a></div><div class="spotlight">${strongest.map(p=>{const score=effectiveScore(p);return `<button class="spot" data-open="${p.id}"><span class="score ${scoreClass(score)}">${score}</span><span><b>${esc(p.name)}</b><small>${esc(p.website_status?.replaceAll('_',' ')||'unknown')} · ${esc(p.emails||'no public email')}${p.ai_checked_at?' · AI assessed':''}</small></span><span>›</span></button>`}).join('')||empty('Run a discovery to build your catalogue')}</div></article><article class="panel"><div class="panel-title"><div><span class="eyebrow">RECENT ACTIVITY</span><h2>Runs</h2></div><a href="#runs">View all</a></div><div class="activity">${runs.slice(0,6).map(r=>`<div><span class="run-dot ${r.status}"></span><span><b>#${r.id} · ${esc(r.query)}</b><small>${esc(r.status)} · ${r.added||0} added</small></span><time>${date(r.created_at)}</time></div>`).join('')||empty('No runs yet')}</div></article></div>`)}
function renderDiscover(){ $('view').innerHTML=layout('Discover','Set the criteria for this run. Source configuration and provider settings live in Settings.',`<div class="two-col"><article class="panel form-panel"><div class="panel-title"><div><span class="eyebrow">NEW RUN</span><h2>Find potential customers</h2><p>Search Google Maps, deduplicate the catalogue, then enrich each result.</p></div><span class="pill">Email collection always on</span></div><form id="discoverForm"><label>Keywords<input id="keywords" required placeholder="plumbing services"></label><label>Category<input id="category" required placeholder="plumbers"></label><label>Location<input id="location" value="South Africa" required></label><label>Maximum results<select id="max-results"><option>10</option><option selected>25</option><option>50</option></select></label><label class="checkline"><input type="checkbox" id="extra-reviews"> Collect extra reviews <small>May increase run time</small></label><div class="form-row"><button class="primary" type="submit" id="discoverBtn">Run discovery →</button><span id="discoverMessage" class="notice"></span></div></form></article><article class="panel"><div class="panel-title"><div><span class="eyebrow">HOW IT WORKS</span><h2>Evidence-first pipeline</h2></div></div><ol class="steps"><li><b>Search</b><span>Maps worker finds public business listings.</span></li><li><b>Enrich</b><span>Website, HTTPS, DNS hints and public email checks run safely.</span></li><li><b>Review</b><span>You decide what is relevant before any contact.</span></li></ol></article></div>`)} function renderDiscover(){ $('view').innerHTML=layout('Discover','Set the criteria for this run. Source configuration and provider settings live in Settings.',`<div class="two-col"><article class="panel form-panel"><div class="panel-title"><div><span class="eyebrow">NEW RUN</span><h2>Find potential customers</h2><p>Search Google Maps, deduplicate the catalogue, then enrich each result.</p></div><span class="pill">Email collection always on</span></div><form id="discoverForm"><label>Keywords<input id="keywords" required placeholder="plumbing services"></label><label>Category<input id="category" required placeholder="plumbers"></label><label>Location<input id="location" value="South Africa" required></label><label>Maximum results<select id="max-results"><option>10</option><option selected>25</option><option>50</option></select></label><label class="checkline"><input type="checkbox" id="extra-reviews"> Collect extra reviews <small>May increase run time</small></label><div class="form-row"><button class="primary" type="submit" id="discoverBtn">Run discovery →</button><span id="discoverMessage" class="notice"></span></div></form></article><article class="panel"><div class="panel-title"><div><span class="eyebrow">HOW IT WORKS</span><h2>Evidence-first pipeline</h2></div></div><ol class="steps"><li><b>Search</b><span>Maps worker finds public business listings.</span></li><li><b>Enrich</b><span>Website, HTTPS, DNS hints and public email checks run safely.</span></li><li><b>Review</b><span>You decide what is relevant before any contact.</span></li></ol></article></div>`)}
function renderProspects(){ $('view').innerHTML=layout('Prospects','Browse the strongest opportunities first, then narrow the catalogue with filters and sort by the latest score.',`<article class="panel"><div class="toolbar prospect-filter-row"><div class="search"><span>⌕</span><input id="prospectSearch" placeholder="Search name, address, domain or email"></div><select id="prospectScoreBand" aria-label="Filter by potential"><option value="all">All potential</option><option value="hot">Hot (85+)</option><option value="high">High (70–84)</option><option value="medium">Medium (45–69)</option><option value="low">Low (0–44)</option></select><select id="prospectWebsiteFilter" aria-label="Filter by website"><option value="all">Any website</option><option value="missing">No website</option><option value="healthy">Healthy website</option><option value="broken">Broken / unreachable</option></select><select id="prospectEmailFilter" aria-label="Filter by email"><option value="all">Any email</option><option value="has_email">Has public email</option><option value="no_email">No public email</option></select><select id="prospectReviewFilter" aria-label="Filter by review status"><option value="all">Any review status</option><option value="pending">In review</option><option value="approved">Accepted</option><option value="rejected">Rejected</option><option value="contacted">Contacted</option></select><button class="secondary" id="exportCsv">Export CSV</button><button class="secondary" id="refreshProspects">Refresh</button></div><div id="prospectTabs" class="prospect-tabs">${prospectTabs()}</div><p class="list-hint">Click any prospect to review evidence, change its status, assess it with AI, or draft a tailored message. Review status is changed inside the prospect drawer.</p><div id="prospectTable">${prospectTable()}</div></article><aside id="detail" class="detail-panel" hidden></aside>`);if($('prospectScoreBand'))$('prospectScoreBand').value=prospectScoreBand;if($('prospectWebsiteFilter'))$('prospectWebsiteFilter').value=prospectWebsiteFilter;if($('prospectEmailFilter'))$('prospectEmailFilter').value=prospectEmailFilter;if($('prospectReviewFilter'))$('prospectReviewFilter').value=prospectReviewFilter} function renderProspects(){ $('view').innerHTML=layout('Prospects','Browse the strongest opportunities first, then narrow the catalogue with filters and sort by the latest score.',`<article class="panel"><div class="toolbar prospect-filter-row"><div class="search"><span>⌕</span><input id="prospectSearch" placeholder="Search name, address, domain or email"></div><select id="prospectScoreBand" aria-label="Filter by potential"><option value="all">All potential</option><option value="hot">Hot (85+)</option><option value="high">High (70–84)</option><option value="medium">Medium (45–69)</option><option value="low">Low (0–44)</option></select><select id="prospectWebsiteFilter" aria-label="Filter by website"><option value="all">Any website</option><option value="missing">No website</option><option value="healthy">Healthy website</option><option value="broken">Broken / unreachable</option></select><select id="prospectEmailFilter" aria-label="Filter by email"><option value="all">Any email</option><option value="has_email">Has public email</option><option value="no_email">No public email</option></select><select id="prospectReviewFilter" aria-label="Filter by review status"><option value="all">Any review status</option><option value="pending">In review</option><option value="approved">Accepted</option><option value="rejected">Rejected</option><option value="contacted">Contacted</option></select><button class="secondary" id="exportCsv">Export CSV</button><button class="secondary" id="refreshProspects">Refresh</button></div><div id="prospectTabs" class="prospect-tabs">${prospectTabs()}</div><p class="list-hint">Click any prospect to review evidence, change its status, assess it with AI, or draft a tailored message. Review status is changed inside the prospect drawer.</p><div id="prospectTable">${prospectTable()}</div></article><aside id="detail" class="detail-panel" hidden></aside>`);if($('prospectScoreBand'))$('prospectScoreBand').value=prospectScoreBand;if($('prospectWebsiteFilter'))$('prospectWebsiteFilter').value=prospectWebsiteFilter;if($('prospectEmailFilter'))$('prospectEmailFilter').value=prospectEmailFilter;if($('prospectReviewFilter'))$('prospectReviewFilter').value=prospectReviewFilter}
function renderRuns(){ $('view').innerHTML=layout('Runs','Monitor discovery jobs and inspect errors without leaving the run history.',`<article class="panel"><div class="table-wrap"><table><thead><tr><th>Run</th><th>Status</th><th>Results</th><th>Started</th><th>Source</th></tr></thead><tbody>${runs.map(r=>`<tr><td><b>#${r.id}</b><small>${esc(r.query)}</small></td><td><span class="status ${r.status}">${esc(r.status)}</span><small>${esc(r.stage||'')}</small></td><td>${r.added||0} added / ${r.found||0} found</td><td>${date(r.created_at)}</td><td>${esc(r.source||'google_maps')}</td></tr>`).join('')}</tbody></table></div>${runs.length?'':empty('No discovery runs yet')}</article>`)} function renderRuns(){ $('view').innerHTML=layout('Runs','Monitor discovery jobs, recover interrupted work, and inspect every state transition.',`<article class="panel"><div class="table-wrap"><table><thead><tr><th>Run</th><th>Status</th><th>Results</th><th>Started</th><th>Source</th><th>Actions</th></tr></thead><tbody>${runs.map(r=>{const active=['queued','running'].includes(r.status);const retry=['failed','cancelled'].includes(r.status)||r.recovery_state==='retryable';return `<tr><td><b>#${r.id}</b><small>${esc(r.query)}</small></td><td><span class="status ${r.status}">${esc(r.status)}</span><small>${esc(r.stage||'')}${r.recovery_state?` · ${esc(r.recovery_state)}`:''}</small></td><td>${r.added||0} added / ${r.found||0} found</td><td>${date(r.created_at)}</td><td>${esc(r.source||'google_maps')}</td><td class="run-actions">${active?`<button class="secondary" data-cancel-run="${r.id}">Cancel</button>`:''}${retry?`<button class="secondary" data-retry-run="${r.id}">Retry</button>`:''}<button class="secondary" data-run-events="${r.id}">Events</button></td></tr>`}).join('')}</tbody></table></div>${runs.length?'':empty('No discovery runs yet')}</article><div id="runEventsPanel"></div>`)}
function logTabCount(tab,counts){return tab==='errors'?Number(counts?.errors||0):Number(counts?.[tab]||0)} function logTabCount(tab,counts){return tab==='errors'?Number(counts?.errors||0):Number(counts?.[tab]||0)}
function logTabs(counts={}){return `<div class="logs-tab-list">${LOG_TABS.map(([key,label])=>`<button class="logs-tab ${activeLogTab===key?'active':''}" data-log-tab="${key}">${label}<span>${logTabCount(key,counts)}</span></button>`).join('')}</div>`} function logTabs(counts={}){return `<div class="logs-tab-list">${LOG_TABS.map(([key,label])=>`<button class="logs-tab ${activeLogTab===key?'active':''}" data-log-tab="${key}">${label}<span>${logTabCount(key,counts)}</span></button>`).join('')}</div>`}
function renderLogTable(){const rows=Array.isArray(logs)?logs:[];return rows.length?`<div class="table-wrap log-table-wrap"><table><thead><tr><th>Time</th><th>Type</th><th>Event</th><th>Status</th><th>Duration</th><th>Details</th></tr></thead><tbody>${rows.map(row=>{const details=typeof row.details==='string'?row.details:JSON.stringify(row.details||{},null,2);return `<tr><td>${esc(date(row.created_at))}</td><td><span class="log-kind ${esc(row.level||'info')}">${esc(row.kind||'system')}</span></td><td><b>${esc(row.event||'')}</b>${row.path?`<small>${esc(row.method||'')} ${esc(row.path)}</small>`:''}${row.error?`<small class="log-error">${esc(row.error)}</small>`:''}</td><td>${row.status?esc(row.status):'—'}</td><td>${row.duration_ms!=null?esc(row.duration_ms)+' ms':'—'}</td><td><details><summary>View</summary><pre>${esc(details)}</pre></details></td></tr>`}).join('')}</tbody></table></div>`:empty('No log entries for this tab')} function renderLogTable(){const rows=Array.isArray(logs)?logs:[];return rows.length?`<div class="table-wrap log-table-wrap"><table><thead><tr><th>Time</th><th>Type</th><th>Event</th><th>Status</th><th>Duration</th><th>Details</th></tr></thead><tbody>${rows.map(row=>{const details=typeof row.details==='string'?row.details:JSON.stringify(row.details||{},null,2);return `<tr><td>${esc(date(row.created_at))}</td><td><span class="log-kind ${esc(row.level||'info')}">${esc(row.kind||'system')}</span></td><td><b>${esc(row.event||'')}</b>${row.path?`<small>${esc(row.method||'')} ${esc(row.path)}</small>`:''}${row.error?`<small class="log-error">${esc(row.error)}</small>`:''}</td><td>${row.status?esc(row.status):'—'}</td><td>${row.duration_ms!=null?esc(row.duration_ms)+' ms':'—'}</td><td><details><summary>View</summary><pre>${esc(details)}</pre></details></td></tr>`}).join('')}</tbody></table></div>`:empty('No log entries for this tab')}
@@ -70,7 +70,7 @@ async function load(){
return loadWarnings; return loadWarnings;
} }
function renderLoadWarnings(){if(!loadWarnings.length)return;const view=$('view');if(!view)return;const notice=document.createElement('div');notice.className='notice error load-warning';notice.setAttribute('role','status');notice.textContent=`Dashboard loaded with limited data. ${loadWarnings.join(' · ')}`;view.prepend(notice)} function renderLoadWarnings(){if(!loadWarnings.length)return;const view=$('view');if(!view)return;const notice=document.createElement('div');notice.className='notice error load-warning';notice.setAttribute('role','status');notice.textContent=`Dashboard loaded with limited data. ${loadWarnings.join(' · ')}`;view.prepend(notice)}
async function route(){const r=location.hash.slice(1)||'dashboard';installExtraNav();document.querySelectorAll('[data-route]').forEach(a=>a.classList.toggle('active',a.dataset.route===r));try{await load();({dashboard:renderDashboard,discover:renderDiscover,prospects:renderProspects,runs:renderRuns,logs:renderLogs,settings:renderSettings,'teach-ai':renderTeachAI,promotions:renderPromotions}[r]||renderDashboard)();renderLoadWarnings();$('updated').textContent='Updated '+new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});refreshWorkerStatus();}catch(e){$('view').innerHTML=empty(e.message);refreshWorkerStatus();}} async function route(){const r=location.hash.slice(1)||'dashboard';installExtraNav();document.querySelectorAll('[data-route]').forEach(a=>a.classList.toggle('active',a.dataset.route===r));try{await load();({dashboard:renderDashboard,discover:renderDiscover,prospects:renderProspects,runs:renderRuns,logs:renderLogs,settings:renderSettings,'teach-ai':renderTeachAI,promotions:renderPromotions,users:renderUsers}[r]||renderDashboard)();renderLoadWarnings();$('updated').textContent='Updated '+new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});refreshWorkerStatus();}catch(e){$('view').innerHTML=empty(e.message);refreshWorkerStatus();}}
function suggestedOffer(p){if(p.ai_offer)return p.ai_offer;if(['broken','unreachable','error','slow'].includes(String(p.website_status||'')))return'Move their hosting to our Gold Plus package at R119/month and rebuild the website at no extra design charge.';if(!p.website)return'Offer a professional .co.za domain, reliable hosting from R119/month, professional email, and a free one-page website.';return'Offer a friendly website and hosting review; only suggest a change that is supported by the evidence.'} function suggestedOffer(p){if(p.ai_offer)return p.ai_offer;if(['broken','unreachable','error','slow'].includes(String(p.website_status||'')))return'Move their hosting to our Gold Plus package at R119/month and rebuild the website at no extra design charge.';if(!p.website)return'Offer a professional .co.za domain, reliable hosting from R119/month, professional email, and a free one-page website.';return'Offer a friendly website and hosting review; only suggest a change that is supported by the evidence.'}
function jsonArray(value){try{const parsed=JSON.parse(value||'[]');return Array.isArray(parsed)?parsed:[]}catch{return[]}} function jsonArray(value){try{const parsed=JSON.parse(value||'[]');return Array.isArray(parsed)?parsed:[]}catch{return[]}}
function showDraftNotes(d){let notes=$('draftNotes');if(!notes){notes=document.createElement('div');notes.id='draftNotes';notes.className='draft-notes';const status=$('messageStatus');if(status)status.parentNode.insertBefore(notes,status)}if(!notes)return;const unknowns=Array.isArray(d?.unknowns)?d.unknowns:[];const used=Array.isArray(d?.evidence_used)?d.evidence_used:[];notes.innerHTML=`<b>AI review notes</b><p>${esc(d?.rationale||'The draft uses the prospect evidence and your instructions.')}</p>${unknowns.length?`<p><b>Confirm before sending:</b> ${esc(unknowns.join(' · '))}</p>`:''}${used.length?`<p class="muted">Evidence used: ${esc(used.join(', '))}</p>`:''}${d?.follow_up?`<p class="muted">${esc(d.follow_up)}</p>`:''}`} function showDraftNotes(d){let notes=$('draftNotes');if(!notes){notes=document.createElement('div');notes.id='draftNotes';notes.className='draft-notes';const status=$('messageStatus');if(status)status.parentNode.insertBefore(notes,status)}if(!notes)return;const unknowns=Array.isArray(d?.unknowns)?d.unknowns:[];const used=Array.isArray(d?.evidence_used)?d.evidence_used:[];notes.innerHTML=`<b>AI review notes</b><p>${esc(d?.rationale||'The draft uses the prospect evidence and your instructions.')}</p>${unknowns.length?`<p><b>Confirm before sending:</b> ${esc(unknowns.join(' · '))}</p>`:''}${used.length?`<p class="muted">Evidence used: ${esc(used.join(', '))}</p>`:''}${d?.follow_up?`<p class="muted">${esc(d.follow_up)}</p>`:''}`}
@@ -112,12 +112,12 @@ function addAssessmentPanel(id){const p=prospects.find(x=>String(x.id)===String(
function candidateEvidence(candidate){try{const value=JSON.parse(candidate.evidence||'[]');return Array.isArray(value)?value.filter(Boolean).map(String):[]}catch{return[]}} function candidateEvidence(candidate){try{const value=JSON.parse(candidate.evidence||'[]');return Array.isArray(value)?value.filter(Boolean).map(String):[]}catch{return[]}}
function renderWebsiteCandidates(items,id){const section=$('websiteCandidatesPanel');if(!section)return;const rows=Array.isArray(items)?items:[];section.innerHTML=`<h3>Possible official websites</h3><p class="muted">Review the evidence before accepting a domain. Accepting runs the normal website and public-email scan.</p><button class="secondary" data-find-websites="${id}">Search again</button>${rows.length?`<div class="candidate-list">${rows.map(c=>{const evidence=candidateEvidence(c);const status=c.status||'candidate';return `<div class="candidate-card"><div><a href="${esc(c.url)}" target="_blank" rel="noopener">${esc(c.domain)} ↗</a><span class="badge ${status==='accepted'?'good':status==='rejected'?'warn':'info'}">${esc(status)}</span></div><p>Confidence <b>${Number(c.confidence||0)}%</b></p>${evidence.length?`<ul>${evidence.map(item=>`<li>${esc(item)}</li>`).join('')}</ul>`:'<p class="muted">No evidence returned.</p>'}${status==='candidate'?`<div class="candidate-actions"><button class="primary" data-accept-website="${c.id}" data-prospect-id="${id}">Accept domain</button><button class="secondary" data-reject-website="${c.id}" data-prospect-id="${id}">Reject</button></div>`:''}</div>`}).join('')}</div>`:'<p class="muted">No plausible official website candidates found yet.</p>'}`} function renderWebsiteCandidates(items,id){const section=$('websiteCandidatesPanel');if(!section)return;const rows=Array.isArray(items)?items:[];section.innerHTML=`<h3>Possible official websites</h3><p class="muted">Review the evidence before accepting a domain. Accepting runs the normal website and public-email scan.</p><button class="secondary" data-find-websites="${id}">Search again</button>${rows.length?`<div class="candidate-list">${rows.map(c=>{const evidence=candidateEvidence(c);const status=c.status||'candidate';return `<div class="candidate-card"><div><a href="${esc(c.url)}" target="_blank" rel="noopener">${esc(c.domain)} ↗</a><span class="badge ${status==='accepted'?'good':status==='rejected'?'warn':'info'}">${esc(status)}</span></div><p>Confidence <b>${Number(c.confidence||0)}%</b></p>${evidence.length?`<ul>${evidence.map(item=>`<li>${esc(item)}</li>`).join('')}</ul>`:'<p class="muted">No evidence returned.</p>'}${status==='candidate'?`<div class="candidate-actions"><button class="primary" data-accept-website="${c.id}" data-prospect-id="${id}">Accept domain</button><button class="secondary" data-reject-website="${c.id}" data-prospect-id="${id}">Reject</button></div>`:''}</div>`}).join('')}</div>`:'<p class="muted">No plausible official website candidates found yet.</p>'}`}
function addWebsitePanel(id){const body=$('drawerBody');const p=prospects.find(x=>String(x.id)===String(id));if(!p||!body)return;const section=document.createElement('section');section.className='drawer-section';section.id='websiteCandidatesPanel';section.innerHTML=`<h3>Find a possible website</h3><p>Searches a few permitted public pages for a likely official domain when Maps did not list one. It never logs in or bypasses access challenges.</p><button class="secondary" data-find-websites="${p.id}">Find possible websites</button><p class="muted">Requires a tested AI provider in Settings.</p>`;const actions=body.querySelector('.drawer-actions');if(actions)body.insertBefore(section,actions);else body.append(section);api(`/api/prospects/${p.id}/website-candidates`).then(d=>{if((d.items||[]).length)renderWebsiteCandidates(d.items,p.id)}).catch(()=>{})} function addWebsitePanel(id){const body=$('drawerBody');const p=prospects.find(x=>String(x.id)===String(id));if(!p||!body)return;const section=document.createElement('section');section.className='drawer-section';section.id='websiteCandidatesPanel';section.innerHTML=`<h3>Find a possible website</h3><p>Searches a few permitted public pages for a likely official domain when Maps did not list one. It never logs in or bypasses access challenges.</p><button class="secondary" data-find-websites="${p.id}">Find possible websites</button><p class="muted">Requires a tested AI provider in Settings.</p>`;const actions=body.querySelector('.drawer-actions');if(actions)body.insertBefore(section,actions);else body.append(section);api(`/api/prospects/${p.id}/website-candidates`).then(d=>{if((d.items||[]).length)renderWebsiteCandidates(d.items,p.id)}).catch(()=>{})}
const _openDrawerWithAssessment=openDrawer;openDrawer=function(id){_openDrawerWithAssessment(id);addAssessmentPanel(id);addWebsitePanel(id)} const _openDrawerWithAssessment=openDrawer;openDrawer=function(id){_openDrawerWithAssessment(id);addAssessmentPanel(id);addWebsitePanel(id);addWorkflowPanel(id);addHistoryPanel(id)}
async function assessProspect(id,button){if(button){button.disabled=true;button.textContent='Assessing…'}try{const d=await api(`/api/prospects/${id}/assess`,{method:'POST',body:'{}'});const index=prospects.findIndex(p=>String(p.id)===String(id));if(index>=0)prospects[index]={...prospects[index],...d};selected=prospects[index]||selected;openDrawer(id);if($('prospectTabs'))$('prospectTabs').innerHTML=prospectTabs();if($('prospectTable'))$('prospectTable').innerHTML=prospectTable()}catch(error){const panel=$('aiAssessmentPanel');if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;panel.append(notice)}if(button){button.disabled=false;button.textContent='Assess with AI'}}} async function assessProspect(id,button){if(button){button.disabled=true;button.textContent='Assessing…'}try{const d=await api(`/api/prospects/${id}/assess`,{method:'POST',body:'{}'});const index=prospects.findIndex(p=>String(p.id)===String(id));if(index>=0)prospects[index]={...prospects[index],...d};selected=prospects[index]||selected;openDrawer(id);if($('prospectTabs'))$('prospectTabs').innerHTML=prospectTabs();if($('prospectTable'))$('prospectTable').innerHTML=prospectTable()}catch(error){const panel=$('aiAssessmentPanel');if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;panel.append(notice)}if(button){button.disabled=false;button.textContent='Assess with AI'}}}
document.addEventListener('click',async e=>{const button=e.target.closest('[data-assess]');if(button){e.preventDefault();e.stopPropagation();await assessProspect(button.dataset.assess,button)}}); document.addEventListener('click',async e=>{const button=e.target.closest('[data-assess]');if(button){e.preventDefault();e.stopPropagation();await assessProspect(button.dataset.assess,button)}});
async function findWebsiteCandidates(id,button){if(button){button.disabled=true;button.textContent='Searching…'}const section=$('websiteCandidatesPanel');try{const d=await api(`/api/prospects/${id}/website-candidates`,{method:'POST',body:'{}'});renderWebsiteCandidates(d.items||[],id)}catch(error){if(section){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;section.append(notice)}}finally{if(button){button.disabled=false;button.textContent='Search again'}}} async function findWebsiteCandidates(id,button){if(button){button.disabled=true;button.textContent='Searching…'}const section=$('websiteCandidatesPanel');try{const d=await api(`/api/prospects/${id}/website-candidates`,{method:'POST',body:'{}'});renderWebsiteCandidates(d.items||[],id)}catch(error){if(section){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;section.append(notice)}}finally{if(button){button.disabled=false;button.textContent='Search again'}}}
document.addEventListener('click',async e=>{const find=e.target.closest('[data-find-websites]');if(find){e.preventDefault();e.stopPropagation();await findWebsiteCandidates(find.dataset.findWebsites,find);return}const accept=e.target.closest('[data-accept-website]');if(accept){e.preventDefault();e.stopPropagation();accept.disabled=true;accept.textContent='Checking…';try{const d=await api(`/api/prospects/${accept.dataset.prospectId}/website-candidates/${accept.dataset.acceptWebsite}`,{method:'PATCH',body:JSON.stringify({status:'accepted'})});if(d.prospect){const i=prospects.findIndex(p=>String(p.id)===String(d.prospect.id));if(i>=0)prospects[i]={...prospects[i],...d.prospect};selected=d.prospect}openDrawer(accept.dataset.prospectId);if($('prospectTable'))$('prospectTable').innerHTML=prospectTable()}catch(error){accept.disabled=false;accept.textContent='Accept domain';const panel=$('websiteCandidatesPanel');if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;panel.append(notice)}}return}const reject=e.target.closest('[data-reject-website]');if(reject){e.preventDefault();e.stopPropagation();reject.disabled=true;try{const d=await api(`/api/prospects/${reject.dataset.prospectId}/website-candidates/${reject.dataset.rejectWebsite}`,{method:'PATCH',body:JSON.stringify({status:'rejected'})});const current=await api(`/api/prospects/${reject.dataset.prospectId}/website-candidates`);renderWebsiteCandidates(current.items||[],reject.dataset.prospectId)}catch(error){reject.disabled=false;const panel=$('websiteCandidatesPanel');if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;panel.append(notice)}}}}); document.addEventListener('click',async e=>{const find=e.target.closest('[data-find-websites]');if(find){e.preventDefault();e.stopPropagation();await findWebsiteCandidates(find.dataset.findWebsites,find);return}const accept=e.target.closest('[data-accept-website]');if(accept){e.preventDefault();e.stopPropagation();accept.disabled=true;accept.textContent='Checking…';try{const d=await api(`/api/prospects/${accept.dataset.prospectId}/website-candidates/${accept.dataset.acceptWebsite}`,{method:'PATCH',body:JSON.stringify({status:'accepted'})});if(d.prospect){const i=prospects.findIndex(p=>String(p.id)===String(d.prospect.id));if(i>=0)prospects[i]={...prospects[i],...d.prospect};selected=d.prospect}openDrawer(accept.dataset.prospectId);if($('prospectTable'))$('prospectTable').innerHTML=prospectTable()}catch(error){accept.disabled=false;accept.textContent='Accept domain';const panel=$('websiteCandidatesPanel');if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;panel.append(notice)}}return}const reject=e.target.closest('[data-reject-website]');if(reject){e.preventDefault();e.stopPropagation();reject.disabled=true;try{const d=await api(`/api/prospects/${reject.dataset.prospectId}/website-candidates/${reject.dataset.rejectWebsite}`,{method:'PATCH',body:JSON.stringify({status:'rejected'})});const current=await api(`/api/prospects/${reject.dataset.prospectId}/website-candidates`);renderWebsiteCandidates(current.items||[],reject.dataset.prospectId)}catch(error){reject.disabled=false;const panel=$('websiteCandidatesPanel');if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;panel.append(notice)}}}});
window.addEventListener('hashchange',route);$('refreshAll').addEventListener('click',route);ensureMessageDialog();refreshWorkerStatus();setInterval(refreshWorkerStatus,30000);route(); window.addEventListener('hashchange',route);$('refreshAll').addEventListener('click',route);ensureMessageDialog();setInterval(refreshWorkerStatus,30000);bootstrapAuth();
document.addEventListener('click',e=>{const tab=e.target.closest('[data-category-tab]');if(!tab)return;e.preventDefault();e.stopPropagation();activeProspectCategory=tab.dataset.categoryTab||'all';if($('prospectTabs'))$('prospectTabs').innerHTML=prospectTabs();if($('prospectTable'))$('prospectTable').innerHTML=prospectTable()}); document.addEventListener('click',e=>{const tab=e.target.closest('[data-category-tab]');if(!tab)return;e.preventDefault();e.stopPropagation();activeProspectCategory=tab.dataset.categoryTab||'all';if($('prospectTabs'))$('prospectTabs').innerHTML=prospectTabs();if($('prospectTable'))$('prospectTable').innerHTML=prospectTable()});
document.addEventListener('click',e=>{const tab=e.target.closest('[data-log-tab]');if(tab){e.preventDefault();activeLogTab=tab.dataset.logTab||'all';refreshLogs();return}if(e.target.id==='refreshLogs')refreshLogs()}); document.addEventListener('click',e=>{const tab=e.target.closest('[data-log-tab]');if(tab){e.preventDefault();activeLogTab=tab.dataset.logTab||'all';refreshLogs();return}if(e.target.id==='refreshLogs')refreshLogs()});
document.addEventListener('click',e=>{const target=e.target.closest('button,a,select,input,textarea,tr,[role="button"]');if(!target)return;const details={tag:target.tagName.toLowerCase(),id:target.id||'',label:target.getAttribute('aria-label')||target.getAttribute('title')||'',route:location.hash.slice(1)||'dashboard'};for(const name of ['open','message','assess','findWebsites','acceptWebsite','rejectWebsite','categoryTab','logTab','review'])if(target.dataset[name])details[name]=target.dataset[name];logAction('ui_click',details)},true); document.addEventListener('click',e=>{const target=e.target.closest('button,a,select,input,textarea,tr,[role="button"]');if(!target)return;const details={tag:target.tagName.toLowerCase(),id:target.id||'',label:target.getAttribute('aria-label')||target.getAttribute('title')||'',route:location.hash.slice(1)||'dashboard'};for(const name of ['open','message','assess','findWebsites','acceptWebsite','rejectWebsite','categoryTab','logTab','review'])if(target.dataset[name])details[name]=target.dataset[name];logAction('ui_click',details)},true);
@@ -125,3 +125,21 @@ document.addEventListener('change',e=>{const target=e.target;if(!target||!target
document.addEventListener('submit',e=>{if(e.target?.id)logAction('ui_submit',{form:e.target.id,route:location.hash.slice(1)||'dashboard'})},true); document.addEventListener('submit',e=>{if(e.target?.id)logAction('ui_submit',{form:e.target.id,route:location.hash.slice(1)||'dashboard'})},true);
window.addEventListener('error',e=>logAction('client_error',{message:String(e.message||'Unknown browser error').slice(0,500),source:String(e.filename||'').slice(0,500),line:e.lineno||0},'error')); window.addEventListener('error',e=>logAction('client_error',{message:String(e.message||'Unknown browser error').slice(0,500),source:String(e.filename||'').slice(0,500),line:e.lineno||0},'error'));
window.addEventListener('unhandledrejection',e=>logAction('client_unhandled_rejection',{message:String(e.reason?.message||e.reason||'Unhandled promise rejection').slice(0,500)},'error')); window.addEventListener('unhandledrejection',e=>logAction('client_unhandled_rejection',{message:String(e.reason?.message||e.reason||'Unhandled promise rejection').slice(0,500)},'error'));
/* Workspace security and workflow UI */
function authShell(title,setup){const root=$('authRoot')||document.body.appendChild(Object.assign(document.createElement('div'),{id:'authRoot'}));root.innerHTML=`<div class="auth-card"><div class="auth-brand"><span class="brand-mark">P</span><div><b>ProspectOS</b><small>Private growth workspace</small></div></div><span class="eyebrow">${setup?'FIRST-TIME SETUP':'SECURE SIGN IN'}</span><h1>${title}</h1><p>${setup?'Create the workspace owner account. Existing catalogue data will be kept in this workspace.':'Sign in to continue to your prospect workspace.'}</p><form id="${setup?'authSetupForm':'authLoginForm'}"><label>Email<input id="authEmail" type="email" autocomplete="email" required placeholder="you@example.com"></label>${setup?`<label>Your name<input id="authName" required placeholder="Workspace owner"></label><label>Workspace name<input id="authWorkspace" required value="ProspectOS workspace"></label>`:''}<label>Password<input id="authPassword" type="password" autocomplete="${setup?'new-password':'current-password'}" required minlength="8" placeholder="At least 8 characters"></label><button class="primary" type="submit">${setup?'Create workspace':'Sign in'} <span>→</span></button><div id="authMessage" class="notice"></div></form></div>`;root.hidden=false;document.querySelector('.app-shell')?.setAttribute('aria-hidden','true');document.querySelector('.app-shell')?.classList.add('auth-hidden');$('detailDrawer')?.classList.remove('open')}
function renderAuth(status={}){authShell(status.setup_required?'Create your workspace':'Welcome back',Boolean(status.setup_required));const nav=$('nav');if(nav)nav.setAttribute('aria-hidden','true')}
function setAppAuthenticated(value){const root=$('authRoot');if(root)root.hidden=value;document.querySelector('.app-shell')?.classList.toggle('auth-hidden',!value);document.querySelector('.app-shell')?.removeAttribute('aria-hidden');const nav=$('nav');if(nav)nav.setAttribute('aria-hidden',value?'false':'true');const actions=document.querySelector('.top-actions');if(actions&&value&&!$('logoutButton')){const button=document.createElement('button');button.id='logoutButton';button.className='secondary';button.textContent='Sign out';actions.prepend(button)}if(actions&&!value)$('logoutButton')?.remove();const who=$('updated');if(who&&currentUser)who.textContent=`${currentUser.name||currentUser.email} · ${currentUser.role}`}
async function bootstrapAuth(){try{const status=await fetch('/api/auth/status',{credentials:'same-origin'}).then(r=>r.json());if(status.authenticated){const me=await fetch('/api/auth/me',{credentials:'same-origin'}).then(r=>r.json());currentUser=me.user||status.user;csrfToken=me.csrf_token||'';setAppAuthenticated(true);route();return}setAppAuthenticated(false);renderAuth(status)}catch(error){setAppAuthenticated(false);renderAuth({setup_required:false})}}
async function submitAuth(form){const setup=form.id==='authSetupForm';const message=$('authMessage');const button=form.querySelector('button');button.disabled=true;message.textContent='Working…';try{const payload={email:$('authEmail').value,password:$('authPassword').value};if(setup){payload.name=$('authName').value;payload.workspace_name=$('authWorkspace').value}const response=await fetch(setup?'/api/auth/setup':'/api/auth/login',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});const body=await response.json().catch(()=>({}));if(!response.ok)throw Error(body.error||`Request failed (${response.status})`);currentUser=body.user;csrfToken=body.csrf_token||'';setAppAuthenticated(true);if(!location.hash)location.hash='#dashboard';route()}catch(error){message.textContent=error.message;message.className='notice error';button.disabled=false}}
async function loadUsers(){const data=await api('/api/users');return data.items||[]}
function renderUsers(){const canManage=ROLE_ORDER_UI(currentUser?.role)>='admin';$('view').innerHTML=layout('Users & workspace','Control who can access this workspace, what they can do, and who owns follow-up work.',`<div class="settings-grid user-grid"><article class="panel"><div class="panel-title"><div><span class="eyebrow">WORKSPACE</span><h2>Ownership</h2><p>All prospects, runs, notes, and audit records are isolated to this workspace.</p></div></div><div id="workspaceInfo" class="workspace-info">Loading workspace…</div>${canManage?`<form id="workspaceForm" class="inline-form"><input id="workspaceName" placeholder="Workspace name"><button class="secondary">Save name</button></form>`:''}</article><article class="panel"><div class="panel-title"><div><span class="eyebrow">ACCESS</span><h2>Invite a teammate</h2><p>Roles limit destructive and operational actions.</p></div></div>${canManage?`<form id="userForm" class="user-form"><label>Name<input id="newUserName" required placeholder="Team member"></label><label>Email<input id="newUserEmail" type="email" required placeholder="name@example.com"></label><label>Role<select id="newUserRole"><option value="reviewer">Reviewer · review and notes</option><option value="researcher">Researcher · runs and assignments</option><option value="admin">Admin · workspace controls</option></select></label><label>Temporary password<input id="newUserPassword" type="password" required minlength="8" placeholder="At least 8 characters"></label><button class="primary">Create user</button><span id="userMessage" class="notice"></span></form>`:'<p class="muted">Only admins and owners can manage users.</p>'}</article></div><article class="panel"><div class="panel-title"><div><span class="eyebrow">MEMBERS</span><h2>Workspace users</h2></div><button class="secondary" id="refreshUsers">Refresh</button></div><div id="usersList" class="user-list">Loading users…</div></article>${canManage?`<article class="panel"><div class="panel-title"><div><span class="eyebrow">BACKUPS</span><h2>Verified database backups</h2><p>Backups are created online, integrity-checked, and retained according to the server setting.</p></div><div><button class="secondary" id="createBackup">Create backup</button></div></div><div id="backupsList" class="backup-list">Loading backups…</div></article><article class="panel"><div class="panel-title"><div><span class="eyebrow">AUDIT</span><h2>Recent control events</h2><p>Sign-ins, permission changes, assignments, reviews, retries, and backup actions.</p></div><a href="#logs">Open logs →</a></div><div id="auditList" class="audit-list">Loading audit…</div></article>`:''}`);refreshUsersPage()}
function ROLE_ORDER_UI(role){return {viewer:10,reviewer:20,researcher:30,admin:40,owner:50}[role]||0}
async function refreshUsersPage(){try{const [workspace,users]=await Promise.all([api('/api/workspace'),loadUsers()]);const info=$('workspaceInfo');if(info)info.innerHTML=`<div class="workspace-stat"><b>${esc(workspace.workspace?.name||'Workspace')}</b><span>${workspace.counts?.prospects||0} prospects · ${workspace.counts?.users||0} users</span></div>`;if($('workspaceName'))$('workspaceName').value=workspace.workspace?.name||'';const list=$('usersList');if(list)list.innerHTML=users.map(u=>`<div class="user-row"><div><b>${esc(u.name||u.email)}</b><small>${esc(u.email)} · joined ${date(u.created_at)}${u.last_login_at?` · last sign-in ${date(u.last_login_at)}`:''}</small></div><div class="user-controls"><span class="pill">${esc(u.role)}</span>${ROLE_ORDER_UI(currentUser?.role)>=40&&u.role!=='owner'?`<select data-user-role="${u.id}"><option value="reviewer" ${u.role==='reviewer'?'selected':''}>Reviewer</option><option value="researcher" ${u.role==='researcher'?'selected':''}>Researcher</option><option value="admin" ${u.role==='admin'?'selected':''}>Admin</option></select><label class="switch"><input type="checkbox" data-user-active="${u.id}" ${u.active?'checked':''}> active</label>`:''}</div></div>`).join('')||empty('No users yet');if($('backupsList')){const backups=await api('/api/backups');$('backupsList').innerHTML=backups.items?.map(b=>`<div class="backup-row"><b>${esc(b.name)}</b><span>${Math.round((b.size_bytes||0)/1024)} KB · ${date(b.created_at)}</span></div>`).join('')||'<p class="muted">No backups created yet.</p>'}if($('auditList')){const audit=await api('/api/audit?limit=30');$('auditList').innerHTML=audit.items?.map(a=>`<div class="audit-row"><b>${esc(a.action)}</b><span>${esc(a.user_name||a.user_email||'system')} · ${esc(a.entity_type||'')} ${a.entity_id||''} · ${date(a.created_at)}</span></div>`).join('')||'<p class="muted">No audit events yet.</p>'}}catch(error){const list=$('usersList');if(list)list.innerHTML=`<p class="notice error">${esc(error.message)}</p>`}}
function addWorkflowPanel(id){const body=$('drawerBody');const p=prospects.find(x=>String(x.id)===String(id));if(!p||!body)return;const section=document.createElement('section');section.className='drawer-section workflow-panel';section.id='workflowPanel';section.innerHTML=`<div class="section-heading"><h3>Ownership & follow-up</h3><span class="muted">Notes and tasks stay with the prospect</span></div><label class="drawer-field">Assigned to<select id="assignmentSelect"><option value="">Unassigned</option></select></label><div id="notesList" class="workflow-list">Loading notes…</div><form id="noteForm" class="drawer-inline-form"><textarea id="noteBody" rows="2" placeholder="Add a review note…" required></textarea><button class="secondary">Add note</button></form><div id="tasksList" class="workflow-list">Loading tasks…</div><form id="taskForm" class="drawer-inline-form"><input id="taskTitle" placeholder="Add a follow-up task…" required><input id="taskDue" type="date"><button class="secondary">Add task</button></form>`;const actions=body.querySelector('.drawer-actions');if(actions)body.insertBefore(section,actions);else body.append(section);Promise.all([loadUsers(),api(`/api/prospects/${p.id}/notes`),api(`/api/prospects/${p.id}/tasks`)]).then(([users,notes,tasks])=>{const select=$('assignmentSelect');if(select){select.innerHTML='<option value="">Unassigned</option>'+users.filter(u=>u.active).map(u=>`<option value="${u.id}" ${String(u.id)===String(p.assigned_user_id)?'selected':''}>${esc(u.name||u.email)}</option>`).join('')}$('notesList').innerHTML=(notes.items||[]).map(n=>`<div class="workflow-item"><b>${esc(n.user_name||n.user_email||'Note')}</b><span>${esc(n.body)}</span><small>${date(n.created_at)}</small></div>`).join('')||'<p class="muted">No notes yet.</p>';$('tasksList').innerHTML=(tasks.items||[]).map(t=>`<div class="workflow-item task-item"><label><input type="checkbox" data-task-status="${t.id}" data-prospect-id="${p.id}" ${t.status==='done'?'checked':''}> <b>${esc(t.title)}</b></label><small>${t.due_at?`Due ${esc(t.due_at)} · `:''}${esc(t.status)}</small></div>`).join('')||'<p class="muted">No follow-up tasks yet.</p>'}).catch(error=>{section.insertAdjacentHTML('beforeend',`<p class="notice error">${esc(error.message)}</p>`)})}
function addHistoryPanel(id){const body=$('drawerBody');if(!body)return;const section=document.createElement('section');section.className='drawer-section';section.id='historyPanel';section.innerHTML='<h3>Score & AI history</h3><p class="muted">Loading assessment history…</p>';const actions=body.querySelector('.drawer-actions');if(actions)body.insertBefore(section,actions);else body.append(section);api(`/api/prospects/${id}/history`).then(data=>{const scores=data.score_history||[],ais=data.ai_history||[];section.innerHTML=`<h3>Score & AI history</h3>${scores.length?`<div class="history-list">${scores.slice(0,12).map(s=>`<div class="history-item"><b>${esc(s.score_type)} · ${Number(s.score)}%</b><span>${date(s.created_at)}${s.model?` · ${esc(s.model)}`:''}</span></div>`).join('')}</div>`:'<p class="muted">No score history yet.</p>'}${ais.length?`<details class="history-details"><summary>${ais.length} AI assessment${ais.length===1?'':'s'}</summary>${ais.slice(0,8).map(a=>`<div class="history-assessment"><b>${Number(a.probability)}% · ${esc(aiRecommendationLabel(a.recommendation))}</b><small>${date(a.created_at)}${a.model?` · ${esc(a.model)}`:''}</small><p>${esc(a.reasoning||'')}</p></div>`).join('')}</details>`:''}`}).catch(error=>{section.innerHTML=`<h3>Score & AI history</h3><p class="notice error">${esc(error.message)}</p>`})}
/* Corrected compact user page (keeps admin controls available to owners/admins). */
function renderUsers(){const canManage=['owner','admin'].includes(currentUser?.role);$('view').innerHTML=layout('Users & workspace','Manage access, ownership, assignments, backups, and audit visibility.',`<div class="settings-grid user-grid"><article class="panel"><div class="panel-title"><div><span class="eyebrow">WORKSPACE</span><h2>Workspace ownership</h2><p>All catalogue records and operational history are isolated here.</p></div></div><div id="workspaceInfo">Loading…</div>${canManage?`<form id="workspaceForm" class="inline-form"><input id="workspaceName" placeholder="Workspace name"><button class="secondary">Save name</button></form>`:''}</article><article class="panel"><div class="panel-title"><div><span class="eyebrow">TEAM ACCESS</span><h2>Add a user</h2><p>Use reviewer for day-to-day review, researcher for runs, and admin for controls.</p></div></div>${canManage?`<form id="userForm" class="user-form"><label>Name<input id="newUserName" required></label><label>Email<input id="newUserEmail" type="email" required></label><label>Role<select id="newUserRole"><option value="reviewer">Reviewer</option><option value="researcher">Researcher</option><option value="admin">Admin</option></select></label><label>Temporary password<input id="newUserPassword" type="password" minlength="8" required></label><button class="primary">Create user</button><span id="userMessage" class="notice"></span></form>`:'<p class="muted">Only owners and admins can manage users.</p>'}</article></div><article class="panel"><div class="panel-title"><div><span class="eyebrow">MEMBERS</span><h2>Workspace users</h2></div><button class="secondary" id="refreshUsers">Refresh</button></div><div id="usersList" class="user-list">Loading…</div></article>${canManage?`<article class="panel"><div class="panel-title"><div><span class="eyebrow">BACKUPS</span><h2>Verified backups</h2><p>Online SQLite backups are integrity-checked before being listed.</p></div><button class="secondary" id="createBackup">Create backup</button></div><div id="backupsList">Loading…</div></article><article class="panel"><div class="panel-title"><div><span class="eyebrow">AUDIT TRAIL</span><h2>Recent control events</h2></div><a href="#logs">Open logs →</a></div><div id="auditList">Loading…</div></article>`:''}`);refreshUsersPage()}
document.addEventListener('submit',async e=>{if(e.target.id==='authLoginForm'||e.target.id==='authSetupForm'){e.preventDefault();await submitAuth(e.target);return}if(e.target.id==='userForm'){e.preventDefault();const m=$('userMessage');try{await api('/api/users',{method:'POST',body:JSON.stringify({name:$('newUserName').value,email:$('newUserEmail').value,password:$('newUserPassword').value,role:$('newUserRole').value})});m.textContent='User created.';m.className='notice ok';e.target.reset();refreshUsersPage()}catch(error){m.textContent=error.message;m.className='notice error'}}if(e.target.id==='workspaceForm'){e.preventDefault();try{await api('/api/workspace',{method:'POST',body:JSON.stringify({name:$('workspaceName').value})});refreshUsersPage()}catch(error){alert(error.message)}}if(e.target.id==='noteForm'){e.preventDefault();try{await api(`/api/prospects/${selected.id}/notes`,{method:'POST',body:JSON.stringify({body:$('noteBody').value})});openDrawer(selected.id)}catch(error){alert(error.message)}}if(e.target.id==='taskForm'){e.preventDefault();try{await api(`/api/prospects/${selected.id}/tasks`,{method:'POST',body:JSON.stringify({title:$('taskTitle').value,due_at:$('taskDue').value})});openDrawer(selected.id)}catch(error){alert(error.message)}}});
document.addEventListener('change',async e=>{if(e.target.id==='assignmentSelect'&&selected){try{const updated=await api(`/api/prospects/${selected.id}/assignment`,{method:'POST',body:JSON.stringify({assigned_user_id:e.target.value||null})});selected=updated;const i=prospects.findIndex(p=>String(p.id)===String(updated.id));if(i>=0)prospects[i]=updated}catch(error){alert(error.message)}}if(e.target.dataset.userRole){try{await api(`/api/users/${e.target.dataset.userRole}`,{method:'PATCH',body:JSON.stringify({role:e.target.value})});refreshUsersPage()}catch(error){alert(error.message)}}if(e.target.dataset.userActive){try{await api(`/api/users/${e.target.dataset.userActive}`,{method:'PATCH',body:JSON.stringify({active:e.target.checked})});refreshUsersPage()}catch(error){e.target.checked=!e.target.checked;alert(error.message)}}if(e.target.dataset.taskStatus&&selected){try{await api(`/api/prospects/${selected.id}/tasks/${e.target.dataset.taskStatus}`,{method:'PATCH',body:JSON.stringify({status:e.target.checked?'done':'open'})});openDrawer(selected.id)}catch(error){alert(error.message)}}});
document.addEventListener('click',async e=>{if(e.target.id==='logoutButton'){try{await fetch('/api/auth/logout',{method:'POST',credentials:'same-origin',headers:{'X-CSRF-Token':csrfToken}})}finally{currentUser=null;csrfToken='';bootstrapAuth()}}if(e.target.id==='refreshUsers')refreshUsersPage();if(e.target.id==='createBackup'){e.target.disabled=true;try{await api('/api/backups',{method:'POST',body:'{}'});refreshUsersPage()}catch(error){alert(error.message)}finally{e.target.disabled=false}}const retry=e.target.closest('[data-retry-run]');if(retry){retry.disabled=true;try{await api(`/api/runs/${retry.dataset.retryRun}/retry`,{method:'POST',body:'{}'});route()}catch(error){alert(error.message)}finally{retry.disabled=false}}const cancel=e.target.closest('[data-cancel-run]');if(cancel){cancel.disabled=true;try{await api(`/api/runs/${cancel.dataset.cancelRun}/cancel`,{method:'POST',body:'{}'});route()}catch(error){alert(error.message)}finally{cancel.disabled=false}}const events=e.target.closest('[data-run-events]');if(events){try{const data=await api(`/api/runs/${events.dataset.runEvents}/events`);const panel=$('runEventsPanel');if(panel)panel.innerHTML=`<article class="panel"><h2>Run #${events.dataset.runEvents} events</h2>${(data.items||[]).map(item=>`<div class="audit-row"><b>${esc(item.stage)} · ${esc(item.status)}</b><span>${esc(item.message||'')} · ${date(item.created_at)}</span></div>`).join('')||'<p class="muted">No events.</p>'}</article>`}catch(error){alert(error.message)}}});
+1 -1
View File
@@ -1,3 +1,3 @@
<!doctype html> <!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#0d1726"><title>ProspectOS</title><link rel="stylesheet" href="/style.css?v=dashboard-fix-v1"></head> <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#0d1726"><title>ProspectOS</title><link rel="stylesheet" href="/style.css?v=workspace-v1"></head>
<body><div class="app-shell"><aside class="sidebar"><div class="brand"><span class="brand-mark">P</span><div><b>ProspectOS</b><small>Growth workspace</small></div></div><nav id="nav"><a href="#dashboard" data-route="dashboard"><span>⌂</span>Dashboard</a><a href="#discover" data-route="discover"><span>⌁</span>Discover</a><a href="#prospects" data-route="prospects"><span>▦</span>Prospects</a><a href="#runs" data-route="runs"><span>◷</span>Runs</a><a href="#settings" data-route="settings"><span>⚙</span>Settings</a></nav><div class="sidebar-foot"><span class="status-dot" id="sourceDot"></span><div><b id="sourceLabel">Checking worker</b><small id="sourceSub">Connecting…</small></div></div></aside><main class="main"><header class="topbar"><div><span class="crumb">Workspace</span><span class="slash">/</span><b id="pageName">Dashboard</b></div><div class="top-actions"><span id="updated">—</span><button class="icon-btn" id="refreshAll" title="Refresh">↻</button></div></header><div id="view"></div><footer>ProspectOS · public data only · manual review before contact</footer></main></div><aside id="detailDrawer" class="detail-drawer" aria-hidden="true"><div class="drawer-head"><div><span class="eyebrow">PROSPECT DETAIL</span><h2 id="drawerName">Prospect</h2></div><button class="icon-btn" id="closeDrawer">×</button></div><div id="drawerBody"></div></aside><dialog id="messageDialog"><form method="dialog" id="messageForm"><div class="dialog-head"><div><label class="eyebrow">AI ASSISTED DRAFT</label><h2 id="messageTitle">Tailor a message</h2><p>Use the evidence and your offer to create an editable draft.</p></div><button class="icon-btn" value="cancel">×</button></div><label for="offerInput">Your offer or instruction</label><textarea id="offerInput" rows="4" placeholder="Move hosting to Gold Plus for R119/month and rebuild the website free."></textarea><div id="messageStatus" class="notice"></div><button class="primary" id="generateMessage" type="button">Generate subject and email</button><label for="subjectOutput">Subject</label><input id="subjectOutput"><label for="bodyOutput">Email draft</label><textarea id="bodyOutput" rows="12"></textarea><div class="dialog-actions"><button class="secondary" id="copyMessage" type="button">Copy draft</button><button class="secondary" value="cancel">Close</button></div></form></dialog><script src="/app.js?v=dashboard-fix-v1"></script></body></html> <body><div class="app-shell"><aside class="sidebar"><div class="brand"><span class="brand-mark">P</span><div><b>ProspectOS</b><small>Growth workspace</small></div></div><nav id="nav"><a href="#dashboard" data-route="dashboard"><span>⌂</span>Dashboard</a><a href="#discover" data-route="discover"><span>⌁</span>Discover</a><a href="#prospects" data-route="prospects"><span>▦</span>Prospects</a><a href="#runs" data-route="runs"><span>◷</span>Runs</a><a href="#settings" data-route="settings"><span>⚙</span>Settings</a></nav><div class="sidebar-foot"><span class="status-dot" id="sourceDot"></span><div><b id="sourceLabel">Checking worker</b><small id="sourceSub">Connecting…</small></div></div></aside><main class="main"><header class="topbar"><div><span class="crumb">Workspace</span><span class="slash">/</span><b id="pageName">Dashboard</b></div><div class="top-actions"><span id="updated">—</span><button class="icon-btn" id="refreshAll" title="Refresh">↻</button></div></header><div id="view"></div><footer>ProspectOS · public data only · manual review before contact</footer></main></div><aside id="detailDrawer" class="detail-drawer" aria-hidden="true"><div class="drawer-head"><div><span class="eyebrow">PROSPECT DETAIL</span><h2 id="drawerName">Prospect</h2></div><button class="icon-btn" id="closeDrawer">×</button></div><div id="drawerBody"></div></aside><dialog id="messageDialog"><form method="dialog" id="messageForm"><div class="dialog-head"><div><label class="eyebrow">AI ASSISTED DRAFT</label><h2 id="messageTitle">Tailor a message</h2><p>Use the evidence and your offer to create an editable draft.</p></div><button class="icon-btn" value="cancel">×</button></div><label for="offerInput">Your offer or instruction</label><textarea id="offerInput" rows="4" placeholder="Move hosting to Gold Plus for R119/month and rebuild the website free."></textarea><div id="messageStatus" class="notice"></div><button class="primary" id="generateMessage" type="button">Generate subject and email</button><label for="subjectOutput">Subject</label><input id="subjectOutput"><label for="bodyOutput">Email draft</label><textarea id="bodyOutput" rows="12"></textarea><div class="dialog-actions"><button class="secondary" id="copyMessage" type="button">Copy draft</button><button class="secondary" value="cancel">Close</button></div></form></dialog><script src="/app.js?v=dashboard-fix-v1"></script></body></html>
+4
View File
@@ -142,3 +142,7 @@ input::placeholder { color: #aeb6c5; } input:focus, select:focus { border-color:
.prospect-filter-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px}.prospect-filter-row .search{flex:1 1 260px;min-width:220px}.prospect-filter-row select{width:auto;min-width:140px;flex:0 1 170px;margin:0}.prospect-filter-row button{flex:0 0 auto}.knowledge-grid,.promotions-grid{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(280px,.75fr);gap:17px;align-items:start}.knowledge-form,.promotion-form{display:grid;gap:13px}.knowledge-form textarea,.promotion-form textarea{resize:vertical;line-height:1.45}.knowledge-preview{padding:22px}.knowledge-status{display:grid;gap:7px;padding:15px;border:1px solid #e5e9f2;border-radius:10px;background:#fafbff;color:#526078;font-size:12px}.knowledge-status span{color:#8b96a8;font-size:11px}.knowledge-preview details{margin-top:15px}.knowledge-preview summary{color:#5b50d3;cursor:pointer;font-size:11px;font-weight:800}.knowledge-preview details p{max-height:400px;overflow:auto;margin:9px 0 0;padding:11px;border-radius:8px;background:#f6f7fb;color:#627087;font-size:11px;line-height:1.55;white-space:pre-wrap}.promotion-list{display:grid;gap:10px}.promotion-card{display:flex;justify-content:space-between;gap:14px;padding:14px;border:1px solid #e5e9f2;border-radius:10px;background:#fff}.promotion-card.inactive{background:#fafbfc;opacity:.78}.promotion-card p{margin:7px 0;color:#627087;font-size:12px;line-height:1.45}.promotion-card small{display:block;margin-top:5px;color:#919bad;font-size:10px}.promotion-card-head{display:flex;align-items:center;gap:12px}.promotion-card-head b{color:#29364d;font-size:13px}.switch{display:inline-flex;align-items:center;gap:6px;color:#7d899c;font-size:10px;font-weight:800;cursor:pointer}.switch input{width:15px;height:15px;margin:0}.promotion-card>.secondary{align-self:flex-start;white-space:nowrap}.message-controls{grid-template-columns:repeat(3,1fr)} .prospect-filter-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px}.prospect-filter-row .search{flex:1 1 260px;min-width:220px}.prospect-filter-row select{width:auto;min-width:140px;flex:0 1 170px;margin:0}.prospect-filter-row button{flex:0 0 auto}.knowledge-grid,.promotions-grid{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(280px,.75fr);gap:17px;align-items:start}.knowledge-form,.promotion-form{display:grid;gap:13px}.knowledge-form textarea,.promotion-form textarea{resize:vertical;line-height:1.45}.knowledge-preview{padding:22px}.knowledge-status{display:grid;gap:7px;padding:15px;border:1px solid #e5e9f2;border-radius:10px;background:#fafbff;color:#526078;font-size:12px}.knowledge-status span{color:#8b96a8;font-size:11px}.knowledge-preview details{margin-top:15px}.knowledge-preview summary{color:#5b50d3;cursor:pointer;font-size:11px;font-weight:800}.knowledge-preview details p{max-height:400px;overflow:auto;margin:9px 0 0;padding:11px;border-radius:8px;background:#f6f7fb;color:#627087;font-size:11px;line-height:1.55;white-space:pre-wrap}.promotion-list{display:grid;gap:10px}.promotion-card{display:flex;justify-content:space-between;gap:14px;padding:14px;border:1px solid #e5e9f2;border-radius:10px;background:#fff}.promotion-card.inactive{background:#fafbfc;opacity:.78}.promotion-card p{margin:7px 0;color:#627087;font-size:12px;line-height:1.45}.promotion-card small{display:block;margin-top:5px;color:#919bad;font-size:10px}.promotion-card-head{display:flex;align-items:center;gap:12px}.promotion-card-head b{color:#29364d;font-size:13px}.switch{display:inline-flex;align-items:center;gap:6px;color:#7d899c;font-size:10px;font-weight:800;cursor:pointer}.switch input{width:15px;height:15px;margin:0}.promotion-card>.secondary{align-self:flex-start;white-space:nowrap}.message-controls{grid-template-columns:repeat(3,1fr)}
@media(max-width:900px){.knowledge-grid,.promotions-grid{grid-template-columns:1fr}.message-controls{grid-template-columns:1fr 1fr}} @media(max-width:900px){.knowledge-grid,.promotions-grid{grid-template-columns:1fr}.message-controls{grid-template-columns:1fr 1fr}}
@media(max-width:680px){.prospect-filter-row .search{min-width:100%;flex-basis:100%}.prospect-filter-row select{flex:1 1 45%;min-width:0}.prospect-filter-row button{flex:1 1 45%}.promotion-card{display:block}.promotion-card>.secondary{margin-top:10px}.message-controls{grid-template-columns:1fr}} @media(max-width:680px){.prospect-filter-row .search{min-width:100%;flex-basis:100%}.prospect-filter-row select{flex:1 1 45%;min-width:0}.prospect-filter-row button{flex:1 1 45%}.promotion-card{display:block}.promotion-card>.secondary{margin-top:10px}.message-controls{grid-template-columns:1fr}}
/* workspace access, ownership, history, and recovery */
.auth-hidden{visibility:hidden}.auth-hidden *{pointer-events:none}.auth-card{position:fixed;inset:0;z-index:100;display:grid;align-content:center;justify-items:start;gap:11px;padding:42px;box-sizing:border-box;background:radial-gradient(circle at 75% 10%,#eceaff 0,#f7f8fc 42%,#f7f8fc 100%)}.auth-card>*,.auth-card form{width:min(430px,100%)}.auth-brand{display:flex;align-items:center;gap:11px;margin-bottom:20px}.auth-brand b,.auth-brand small{display:block}.auth-brand b{font-size:16px}.auth-brand small{color:#8995a8;font-size:11px}.auth-card h1{margin:4px 0;font-size:32px;letter-spacing:-.8px}.auth-card>p{margin:0 0 14px;color:#7c899c;font-size:13px}.auth-card form{display:grid;gap:13px;padding:24px;border:1px solid #e0e5ef;border-radius:16px;background:#fff;box-shadow:0 15px 45px #25355714}.auth-card label{display:grid;gap:6px;color:#4d5c73;font-size:12px;font-weight:800}.auth-card input{margin:0}.auth-card button{height:44px}.user-grid{align-items:start}.workspace-info{padding:15px 0}.workspace-stat{display:grid;gap:4px}.workspace-stat b{font-size:18px}.workspace-stat span{color:#8a96a8;font-size:12px}.user-form{display:grid;gap:12px;margin-top:16px}.user-form label{display:grid;gap:6px;color:#4d5c73;font-size:12px;font-weight:800}.user-form input,.user-form select{margin:0}.user-list,.backup-list,.audit-list{display:grid;gap:0;margin-top:10px}.user-row,.backup-row,.audit-row{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:13px 0;border-top:1px solid #edf0f5}.user-row b,.backup-row b,.audit-row b{display:block;font-size:12px}.user-row small,.backup-row span,.audit-row span{display:block;margin-top:4px;color:#8a96a8;font-size:11px}.user-controls{display:flex;align-items:center;gap:8px}.user-controls select{width:auto;margin:0;height:32px;padding:0 7px;font-size:11px}.user-controls .switch{white-space:nowrap}.run-actions{white-space:nowrap}.run-actions button{margin-right:5px;padding:7px 9px;font-size:10px}.drawer-field{display:grid;gap:6px;color:#526078;font-size:11px;font-weight:800}.drawer-field select{margin:0}.drawer-inline-form{display:grid;grid-template-columns:1fr auto;gap:7px;margin-top:11px}.drawer-inline-form textarea,.drawer-inline-form input{width:100%;margin:0;border:1px solid #dbe2ed;border-radius:8px;padding:9px;font:inherit;font-size:12px;box-sizing:border-box}.drawer-inline-form textarea{grid-column:1/-1;resize:vertical}.drawer-inline-form button{font-size:10px;padding:8px 10px}.workflow-list{display:grid;gap:7px;margin-top:13px}.workflow-item{display:grid;gap:3px;padding:9px;border-radius:8px;background:#fafbff;border:1px solid #edf0f5}.workflow-item b{font-size:11px;color:#3d4b63}.workflow-item span{font-size:12px;color:#59677e;white-space:pre-wrap}.workflow-item small{color:#929cad;font-size:10px}.task-item label{display:flex;align-items:center;gap:6px}.task-item input{width:auto;height:auto;margin:0}.history-list{display:grid;gap:6px}.history-item{display:flex;justify-content:space-between;gap:8px;padding:8px 0;border-bottom:1px solid #f0f1f5}.history-item b{font-size:11px}.history-item span{color:#929cad;font-size:10px}.history-details{margin-top:12px}.history-details summary{color:#5b50d3;cursor:pointer;font-size:11px;font-weight:800}.history-assessment{padding:9px 0;border-bottom:1px solid #f0f1f5}.history-assessment b,.history-assessment small{display:block}.history-assessment b{font-size:11px}.history-assessment small{margin-top:3px;color:#929cad;font-size:10px}.history-assessment p{margin:5px 0 0;color:#59677e;font-size:11px}.run-events-panel{margin-top:16px}
@media(max-width:680px){.auth-card{padding:24px}.user-row,.backup-row,.audit-row{display:block}.user-controls{margin-top:9px;justify-content:flex-start}.drawer-inline-form{grid-template-columns:1fr}.drawer-inline-form button{justify-self:start}}