- analysis.py: deterministic claim scorer + /analyze /approve /letter /advance-tier endpoints (auto-runs on intake) - packet.py + packet_fields.json: welcome-packet templating engine (6 onboarding docs, field catalog) - letterstream.py + letters.py: certified-mail send pipeline + letter lifecycle (webhook verified) - docuseal.py: DocuSeal signing integration - staff.py/models.py/schema.sql/auth.py: approval actor from staff key, tier gate (APPROVED+ACTIVE+onboarding docs), onboarding_docs table - frontend/: dependency-free static portal (intake, magic-link login/verify, dashboard) - landing-mockups/: 4 design-stance mockups + favicons - legal/: aup/privacy/sms-terms/terms HTML - docs/: letter-queue scope, letterstream API contract, 6 welcome-packet templates - review-dre-landing-2026-08-21.md: 3-variant landing feedback sprint - compliance/DRE_Compliance_Manual.md: updated Source synced from deployed /opt/dre-portal/app/ (was 4 days ahead of git).
541 lines
20 KiB
HTML
541 lines
20 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Dashboard - Debt Recovery Experts Client Portal</title>
|
|
<link rel="stylesheet" href="css/dre.css">
|
|
</head>
|
|
<body>
|
|
|
|
<header class="dre-header">
|
|
<div class="dre-header-inner">
|
|
<a class="dre-brand" href="dashboard.html">
|
|
<span class="dre-mark">DRE</span>
|
|
<span>
|
|
Debt Recovery Experts
|
|
<span class="dre-brand-sub">Client Portal</span>
|
|
</span>
|
|
</a>
|
|
<div class="dre-header-actions">
|
|
<span class="dre-user" id="header-user"></span>
|
|
<a href="#" id="logout-link">Log Out</a>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main class="dre-main">
|
|
|
|
<div id="dash-alert" class="dre-alert dre-alert-error" role="alert"></div>
|
|
|
|
<!-- ============================== LOADING STATE ============================== -->
|
|
<div id="dash-loading" class="dre-state">
|
|
<div class="dre-spinner dre-spinner-dark" style="width:32px;height:32px;border-width:3px;margin:0 auto 1rem;"></div>
|
|
<p>Loading your account...</p>
|
|
</div>
|
|
|
|
<!-- ============================== LIST VIEW ============================== -->
|
|
<div id="view-list" class="dre-hidden">
|
|
<div class="dre-flex-between" style="margin-bottom:1.25rem;">
|
|
<h1 style="margin:0;">Your Claims</h1>
|
|
</div>
|
|
|
|
<div class="dre-stats-row">
|
|
<div class="dre-stat">
|
|
<div class="dre-stat-label">Total Claims</div>
|
|
<div class="dre-stat-value" id="stat-total">0</div>
|
|
</div>
|
|
<div class="dre-stat">
|
|
<div class="dre-stat-label">Open</div>
|
|
<div class="dre-stat-value" id="stat-open">0</div>
|
|
</div>
|
|
<div class="dre-stat">
|
|
<div class="dre-stat-label">Recovered / Resolved</div>
|
|
<div class="dre-stat-value" id="stat-resolved">0</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="claim-list" class="dre-claim-list"></div>
|
|
|
|
<div id="claim-empty" class="dre-card dre-empty dre-hidden">
|
|
<h3>No Claims Yet</h3>
|
|
<p>You have not submitted any claims. Once you submit a claim, it will appear here with
|
|
live status updates.</p>
|
|
<a href="https://my.debtrecoveryexperts.com/start" class="dre-btn dre-btn-primary">Submit a Claim</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ============================== DETAIL VIEW ============================== -->
|
|
<div id="view-detail" class="dre-hidden">
|
|
<a href="#" id="back-to-list" class="dre-back-link">← Back to all claims</a>
|
|
|
|
<div class="dre-card">
|
|
<div class="dre-flex-between">
|
|
<div>
|
|
<h2 id="detail-claim-number" style="margin-bottom:0.2rem;">--</h2>
|
|
<span id="detail-badge" class="dre-badge">--</span>
|
|
</div>
|
|
<div style="text-align:right;">
|
|
<div class="dre-stat-label">Amount</div>
|
|
<div class="dre-stat-value" id="detail-amount">--</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="dre-progress" id="detail-progress"></div>
|
|
<div class="dre-progress-labels">
|
|
<span>Soft Touch</span>
|
|
<span>Formal Demand</span>
|
|
<span>Escalation</span>
|
|
<span>Legal Action</span>
|
|
</div>
|
|
|
|
<dl class="dre-detail-grid">
|
|
<div>
|
|
<dt>Debtor</dt>
|
|
<dd id="detail-debtor-name">--</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Debtor Type</dt>
|
|
<dd id="detail-debtor-type">--</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Description</dt>
|
|
<dd id="detail-description">--</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Your Reference</dt>
|
|
<dd id="detail-reference">--</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Invoice Date</dt>
|
|
<dd id="detail-invoice-date">--</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Date Assigned</dt>
|
|
<dd id="detail-created">--</dd>
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
|
|
<div class="dre-card">
|
|
<h3>Documents</h3>
|
|
<ul id="doc-list" class="dre-doc-list"></ul>
|
|
<p id="doc-empty" class="dre-small dre-hidden">No documents uploaded yet.</p>
|
|
|
|
<div id="upload-alert" class="dre-alert dre-alert-error" role="alert"></div>
|
|
<label for="doc-file-input" class="dre-dropzone" id="dropzone">
|
|
<strong>Click to choose a file</strong> or drag one here<br>
|
|
<span class="dre-small">PDF, JPG, PNG, DOC, DOCX up to 20 MB</span>
|
|
<input type="file" id="doc-file-input" accept=".pdf,.jpg,.jpeg,.png,.doc,.docx">
|
|
</label>
|
|
<div id="upload-progress" class="dre-small dre-hidden dre-mt">Uploading...</div>
|
|
</div>
|
|
|
|
<div class="dre-card">
|
|
<h3>Case Notes</h3>
|
|
<ul id="note-list" class="dre-note-list"></ul>
|
|
<p id="note-empty" class="dre-small dre-hidden">No notes yet.</p>
|
|
</div>
|
|
|
|
<div class="dre-card">
|
|
<h3>Message the Team</h3>
|
|
<div id="message-alert" class="dre-alert dre-alert-error" role="alert"></div>
|
|
<div id="message-sent" class="dre-alert dre-alert-success" role="status"></div>
|
|
<form id="message-form">
|
|
<div class="dre-field">
|
|
<label for="message-subject">Subject <span class="dre-required">*</span></label>
|
|
<select id="message-subject" required>
|
|
<option value="Question about my claim">Question about my claim</option>
|
|
<option value="New information about the debtor">New information about the debtor</option>
|
|
<option value="Payment received / want to stop recovery">Payment received / want to stop recovery</option>
|
|
<option value="Update my contact info">Update my contact info</option>
|
|
<option value="Complaint or concern">Complaint or concern</option>
|
|
<option value="Other">Other</option>
|
|
</select>
|
|
</div>
|
|
<div class="dre-field">
|
|
<label for="message-content">Message <span class="dre-required">*</span></label>
|
|
<textarea id="message-content" maxlength="10000" required placeholder="Type your message to the recovery team..."></textarea>
|
|
</div>
|
|
<button type="submit" id="message-submit" class="dre-btn dre-btn-primary">Send Message</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
</main>
|
|
|
|
<footer class="dre-footer">
|
|
Debt Recovery Experts Client Portal. All account activity is logged for compliance purposes.
|
|
</footer>
|
|
|
|
<script src="js/dre-api.js"></script>
|
|
<script>
|
|
(function () {
|
|
"use strict";
|
|
|
|
var token = DRE.requireSessionOrRedirect("login.html");
|
|
if (!token) return; // redirect already triggered
|
|
|
|
var loadingEl = document.getElementById("dash-loading");
|
|
var listView = document.getElementById("view-list");
|
|
var detailView = document.getElementById("view-detail");
|
|
var dashAlert = document.getElementById("dash-alert");
|
|
|
|
var claimsCache = [];
|
|
|
|
function handleAuthFailure(result) {
|
|
if (result.status === 401) {
|
|
DRE.clearSessionToken();
|
|
window.location.href = "login.html";
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function logout() {
|
|
DRE.apiFetch("/api/auth/logout", { method: "POST", auth: true }).then(function () {
|
|
DRE.clearSessionToken();
|
|
window.location.href = "login.html";
|
|
});
|
|
}
|
|
document.getElementById("logout-link").addEventListener("click", function (e) {
|
|
e.preventDefault();
|
|
logout();
|
|
});
|
|
|
|
// ---------------------------------------------------------- init: /me
|
|
DRE.apiFetch("/api/auth/me", { auth: true }).then(function (result) {
|
|
if (!result.ok) {
|
|
if (handleAuthFailure(result)) return;
|
|
loadingEl.classList.add("dre-hidden");
|
|
DRE.showAlert(dashAlert, DRE.getErrorMessage(result, "Could not load your account."));
|
|
return;
|
|
}
|
|
var client = result.data.client || {};
|
|
document.getElementById("header-user").textContent =
|
|
(client.contact_name || "") + (client.company_name ? " - " + client.company_name : "");
|
|
loadClaims();
|
|
});
|
|
|
|
// ---------------------------------------------------------- claim list
|
|
function loadClaims() {
|
|
DRE.apiFetch("/api/claims", { auth: true }).then(function (result) {
|
|
loadingEl.classList.add("dre-hidden");
|
|
if (!result.ok) {
|
|
if (handleAuthFailure(result)) return;
|
|
DRE.showAlert(dashAlert, DRE.getErrorMessage(result, "Could not load your claims."));
|
|
listView.classList.remove("dre-hidden");
|
|
return;
|
|
}
|
|
claimsCache = result.data.claims || [];
|
|
renderClaimList(claimsCache);
|
|
listView.classList.remove("dre-hidden");
|
|
routeFromHash();
|
|
});
|
|
}
|
|
|
|
function renderClaimList(claims) {
|
|
var listEl = document.getElementById("claim-list");
|
|
var emptyEl = document.getElementById("claim-empty");
|
|
listEl.innerHTML = "";
|
|
|
|
var openCount = 0, resolvedCount = 0;
|
|
var openStatuses = { NEW: 1, UNDER_REVIEW: 1, ACTIVE: 1, NEGOTIATION: 1, LEGAL: 1 };
|
|
var resolvedStatuses = { SETTLED: 1, CLOSED: 1, WRITE_OFF: 1 };
|
|
|
|
claims.forEach(function (c) {
|
|
if (openStatuses[c.status]) openCount++;
|
|
if (resolvedStatuses[c.status]) resolvedCount++;
|
|
|
|
var row = document.createElement("div");
|
|
row.className = "dre-claim-row";
|
|
row.setAttribute("role", "button");
|
|
row.setAttribute("tabindex", "0");
|
|
|
|
var main = document.createElement("div");
|
|
main.className = "dre-claim-main";
|
|
|
|
var num = document.createElement("span");
|
|
num.className = "dre-claim-number";
|
|
num.textContent = c.claim_number;
|
|
|
|
var sub = document.createElement("span");
|
|
sub.className = "dre-claim-sub";
|
|
sub.textContent = "Debtor: " + (c.debtor_name || "--");
|
|
|
|
var badge = document.createElement("span");
|
|
badge.className = DRE.badgeClass(c.status);
|
|
badge.textContent = c.status_label || c.status;
|
|
badge.style.marginTop = "0.3rem";
|
|
badge.style.width = "fit-content";
|
|
|
|
main.appendChild(num);
|
|
main.appendChild(sub);
|
|
main.appendChild(badge);
|
|
|
|
var right = document.createElement("div");
|
|
var amt = document.createElement("div");
|
|
amt.className = "dre-claim-amount";
|
|
amt.textContent = c.amount_display || DRE.formatCentsUSD(c.amount_cents);
|
|
var meta = document.createElement("div");
|
|
meta.className = "dre-claim-meta";
|
|
meta.textContent = "Submitted " + DRE.formatDate(c.created_at);
|
|
right.appendChild(amt);
|
|
right.appendChild(meta);
|
|
|
|
row.appendChild(main);
|
|
row.appendChild(right);
|
|
|
|
row.addEventListener("click", function () {
|
|
window.location.hash = "claim/" + encodeURIComponent(c.claim_number);
|
|
});
|
|
row.addEventListener("keypress", function (e) {
|
|
if (e.key === "Enter") window.location.hash = "claim/" + encodeURIComponent(c.claim_number);
|
|
});
|
|
|
|
listEl.appendChild(row);
|
|
});
|
|
|
|
document.getElementById("stat-total").textContent = claims.length;
|
|
document.getElementById("stat-open").textContent = openCount;
|
|
document.getElementById("stat-resolved").textContent = resolvedCount;
|
|
|
|
emptyEl.classList.toggle("dre-hidden", claims.length !== 0);
|
|
listEl.classList.toggle("dre-hidden", claims.length === 0);
|
|
}
|
|
|
|
// ---------------------------------------------------------- claim detail
|
|
var currentClaimNumber = null;
|
|
|
|
function showListView() {
|
|
detailView.classList.add("dre-hidden");
|
|
listView.classList.remove("dre-hidden");
|
|
}
|
|
|
|
function showDetailView(claimNumber) {
|
|
currentClaimNumber = claimNumber;
|
|
listView.classList.add("dre-hidden");
|
|
detailView.classList.remove("dre-hidden");
|
|
loadClaimDetail(claimNumber);
|
|
}
|
|
|
|
document.getElementById("back-to-list").addEventListener("click", function (e) {
|
|
e.preventDefault();
|
|
window.location.hash = "";
|
|
});
|
|
|
|
var TIER_LABELS = {
|
|
TIER_1: "Soft Touch", TIER_2: "Formal Demand", TIER_2_5: "Lien Threat",
|
|
TIER_3: "Escalation", TIER_4: "Legal Action",
|
|
};
|
|
|
|
function loadClaimDetail(claimNumber) {
|
|
DRE.hideAlert(dashAlert);
|
|
DRE.apiFetch("/api/claims/" + encodeURIComponent(claimNumber), { auth: true }).then(function (result) {
|
|
if (!result.ok) {
|
|
if (handleAuthFailure(result)) return;
|
|
if (result.status === 404) {
|
|
DRE.showAlert(dashAlert, "That claim was not found on your account.");
|
|
window.location.hash = "";
|
|
return;
|
|
}
|
|
DRE.showAlert(dashAlert, DRE.getErrorMessage(result, "Could not load claim detail."));
|
|
return;
|
|
}
|
|
renderClaimDetail(result.data);
|
|
});
|
|
}
|
|
|
|
function renderClaimDetail(c) {
|
|
document.getElementById("detail-claim-number").textContent = c.claim_number;
|
|
var badge = document.getElementById("detail-badge");
|
|
badge.className = DRE.badgeClass(c.status);
|
|
badge.textContent = c.status_label || c.status;
|
|
document.getElementById("detail-amount").textContent = c.amount_display || DRE.formatCentsUSD(c.amount_cents);
|
|
document.getElementById("detail-debtor-name").textContent = (c.debtor && c.debtor.name) || "--";
|
|
document.getElementById("detail-debtor-type").textContent = (c.debtor && c.debtor.business_type) || "--";
|
|
document.getElementById("detail-description").textContent = c.description || "Not provided";
|
|
document.getElementById("detail-reference").textContent = c.client_reference || "Not provided";
|
|
document.getElementById("detail-invoice-date").textContent = c.invoice_date ? DRE.formatDate(c.invoice_date) : "Not provided";
|
|
document.getElementById("detail-created").textContent = c.date_assigned ? DRE.formatDate(c.date_assigned) : "Not yet assigned";
|
|
|
|
// progress bar
|
|
var progressEl = document.getElementById("detail-progress");
|
|
progressEl.innerHTML = "";
|
|
var step = c.tier_step || 1;
|
|
for (var i = 1; i <= 4; i++) {
|
|
var seg = document.createElement("div");
|
|
seg.className = "dre-progress-step" + (i <= step ? " is-done" : "");
|
|
progressEl.appendChild(seg);
|
|
}
|
|
|
|
// documents
|
|
var docList = document.getElementById("doc-list");
|
|
var docEmpty = document.getElementById("doc-empty");
|
|
docList.innerHTML = "";
|
|
var docs = c.documents || [];
|
|
docs.forEach(function (d) {
|
|
var li = document.createElement("li");
|
|
li.className = "dre-doc-item";
|
|
var left = document.createElement("div");
|
|
var name = document.createElement("div");
|
|
name.className = "dre-doc-name";
|
|
name.textContent = d.original_name;
|
|
var meta = document.createElement("div");
|
|
meta.className = "dre-doc-meta";
|
|
meta.textContent = DRE.formatBytes(d.size_bytes) + " - Uploaded " + DRE.formatDateTime(d.created_at);
|
|
left.appendChild(name);
|
|
left.appendChild(meta);
|
|
li.appendChild(left);
|
|
docList.appendChild(li);
|
|
});
|
|
docEmpty.classList.toggle("dre-hidden", docs.length !== 0);
|
|
|
|
// notes
|
|
var noteList = document.getElementById("note-list");
|
|
var noteEmpty = document.getElementById("note-empty");
|
|
noteList.innerHTML = "";
|
|
var notes = c.notes || [];
|
|
notes.forEach(function (n) {
|
|
var li = document.createElement("li");
|
|
li.className = "dre-note-item";
|
|
var head = document.createElement("div");
|
|
head.className = "dre-note-head";
|
|
var author = document.createElement("span");
|
|
author.className = "dre-note-author";
|
|
author.textContent = n.author_name + (n.subject ? " - " + n.subject : "");
|
|
var when = document.createElement("span");
|
|
when.textContent = DRE.formatDateTime(n.created_at);
|
|
head.appendChild(author);
|
|
head.appendChild(when);
|
|
var body = document.createElement("div");
|
|
body.className = "dre-note-body";
|
|
// Backend already HTML-escapes content; we still use textContent so
|
|
// it renders literally either way (defense in depth, no innerHTML).
|
|
body.textContent = n.content;
|
|
li.appendChild(head);
|
|
li.appendChild(body);
|
|
noteList.appendChild(li);
|
|
});
|
|
noteEmpty.classList.toggle("dre-hidden", notes.length !== 0);
|
|
}
|
|
|
|
// ---------------------------------------------------------- document upload
|
|
var fileInput = document.getElementById("doc-file-input");
|
|
var dropzone = document.getElementById("dropzone");
|
|
var uploadAlert = document.getElementById("upload-alert");
|
|
var uploadProgress = document.getElementById("upload-progress");
|
|
|
|
fileInput.addEventListener("change", function () {
|
|
if (fileInput.files && fileInput.files[0]) {
|
|
uploadFile(fileInput.files[0]);
|
|
}
|
|
});
|
|
|
|
["dragover", "dragenter"].forEach(function (evt) {
|
|
dropzone.addEventListener(evt, function (e) {
|
|
e.preventDefault();
|
|
dropzone.classList.add("is-dragover");
|
|
});
|
|
});
|
|
["dragleave", "drop"].forEach(function (evt) {
|
|
dropzone.addEventListener(evt, function (e) {
|
|
e.preventDefault();
|
|
dropzone.classList.remove("is-dragover");
|
|
});
|
|
});
|
|
dropzone.addEventListener("drop", function (e) {
|
|
e.preventDefault();
|
|
var files = e.dataTransfer && e.dataTransfer.files;
|
|
if (files && files[0]) uploadFile(files[0]);
|
|
});
|
|
|
|
function uploadFile(file) {
|
|
DRE.hideAlert(uploadAlert);
|
|
if (file.size > 20 * 1024 * 1024) {
|
|
DRE.showAlert(uploadAlert, "File exceeds the 20 MB limit.");
|
|
fileInput.value = "";
|
|
return;
|
|
}
|
|
var allowedExt = [".pdf", ".jpg", ".jpeg", ".png", ".doc", ".docx"];
|
|
var lower = file.name.toLowerCase();
|
|
var ok = allowedExt.some(function (ext) { return lower.endsWith(ext); });
|
|
if (!ok) {
|
|
DRE.showAlert(uploadAlert, "File type not allowed. Use PDF, JPG, PNG, DOC, or DOCX.");
|
|
fileInput.value = "";
|
|
return;
|
|
}
|
|
|
|
var formData = new FormData();
|
|
formData.append("file", file);
|
|
uploadProgress.classList.remove("dre-hidden");
|
|
|
|
DRE.apiFetch("/api/claims/" + encodeURIComponent(currentClaimNumber) + "/documents", {
|
|
method: "POST",
|
|
body: formData,
|
|
auth: true,
|
|
}).then(function (result) {
|
|
uploadProgress.classList.add("dre-hidden");
|
|
fileInput.value = "";
|
|
if (!result.ok) {
|
|
if (handleAuthFailure(result)) return;
|
|
DRE.showAlert(uploadAlert, DRE.getErrorMessage(result, "Upload failed. Please try again."));
|
|
return;
|
|
}
|
|
loadClaimDetail(currentClaimNumber);
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------- message the team
|
|
var messageForm = document.getElementById("message-form");
|
|
var messageAlert = document.getElementById("message-alert");
|
|
var messageSent = document.getElementById("message-sent");
|
|
var messageSubmit = document.getElementById("message-submit");
|
|
|
|
messageForm.addEventListener("submit", function (e) {
|
|
e.preventDefault();
|
|
DRE.hideAlert(messageAlert);
|
|
DRE.hideAlert(messageSent);
|
|
|
|
var subject = document.getElementById("message-subject").value;
|
|
var content = document.getElementById("message-content").value.trim();
|
|
if (!content) {
|
|
DRE.showAlert(messageAlert, "Please enter a message before sending.");
|
|
return;
|
|
}
|
|
|
|
DRE.setLoading(messageSubmit, true, "Sending...");
|
|
DRE.apiFetch("/api/claims/" + encodeURIComponent(currentClaimNumber) + "/messages", {
|
|
method: "POST",
|
|
body: { subject: subject, content: content },
|
|
auth: true,
|
|
}).then(function (result) {
|
|
DRE.setLoading(messageSubmit, false, null, "Send Message");
|
|
if (!result.ok) {
|
|
if (handleAuthFailure(result)) return;
|
|
DRE.showAlert(messageAlert, DRE.getErrorMessage(result, "Could not send your message. Please try again."));
|
|
return;
|
|
}
|
|
document.getElementById("message-content").value = "";
|
|
DRE.showAlert(messageSent, "Message sent. Our team will follow up if needed.");
|
|
loadClaimDetail(currentClaimNumber);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------- hash routing
|
|
function routeFromHash() {
|
|
var hash = window.location.hash.replace(/^#/, "");
|
|
if (hash.indexOf("claim/") === 0) {
|
|
var claimNumber = decodeURIComponent(hash.slice("claim/".length));
|
|
showDetailView(claimNumber);
|
|
} else {
|
|
showListView();
|
|
}
|
|
}
|
|
window.addEventListener("hashchange", routeFromHash);
|
|
})();
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|