2026-07-17 23:36:31 +05:30

267 lines
13 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import { AppShell } from "@/components/app-shell";
import { apiFetch } from "@/lib/api";
type CreditScoreEntry = {
id: string;
score: number;
bureau: string;
source: string;
model: string;
scoreDate: string;
factors?: Record<string, unknown>;
change?: number | null;
};
type CreditScoreSummary = {
latest: CreditScoreEntry | null;
previous: CreditScoreEntry | null;
change: number | null;
averageScore: number | null;
entryCount: number;
latestByBureau: CreditScoreEntry[];
trend: CreditScoreEntry[];
};
const bureaus = [
{ value: "experian", label: "Experian" },
{ value: "equifax", label: "Equifax" },
{ value: "transunion", label: "TransUnion" },
{ value: "unknown", label: "Unknown" },
];
const today = new Date().toISOString().slice(0, 10);
export default function CreditScorePage() {
const [summary, setSummary] = useState<CreditScoreSummary | null>(null);
const [entries, setEntries] = useState<CreditScoreEntry[]>([]);
const [filter, setFilter] = useState("all");
const [status, setStatus] = useState("");
const [form, setForm] = useState({
score: "",
bureau: "experian",
source: "manual",
model: "fico_8",
scoreDate: today,
positiveFactors: "",
negativeFactors: "",
});
const filteredEntries = useMemo(() => {
if (filter === "all") return entries;
return entries.filter((entry) => entry.bureau === filter);
}, [entries, filter]);
const scoreBand = (score?: number | null) => {
if (!score) return "No score";
if (score >= 800) return "Exceptional";
if (score >= 740) return "Very good";
if (score >= 670) return "Good";
if (score >= 580) return "Fair";
return "Needs work";
};
const load = async () => {
const query = filter === "all" ? "" : `?bureau=${filter}`;
const [summaryRes, entriesRes] = await Promise.all([
apiFetch<CreditScoreSummary>("/api/credit-score/summary"),
apiFetch<CreditScoreEntry[]>(`/api/credit-score/entries${query}`),
]);
if (!summaryRes.error) setSummary(summaryRes.data ?? null);
if (!entriesRes.error) setEntries(entriesRes.data ?? []);
};
useEffect(() => {
load().catch(() => setStatus("Unable to load credit score history."));
}, [filter]);
const addEntry = async () => {
const score = Number(form.score);
if (!score || score < 300 || score > 850) {
setStatus("Score must be between 300 and 850.");
return;
}
const factors = {
positive: form.positiveFactors.split(",").map((item) => item.trim()).filter(Boolean),
negative: form.negativeFactors.split(",").map((item) => item.trim()).filter(Boolean),
};
const res = await apiFetch<CreditScoreEntry>("/api/credit-score/entries", {
method: "POST",
body: JSON.stringify({
score,
bureau: form.bureau,
source: form.source,
model: form.model,
scoreDate: form.scoreDate,
factors,
}),
});
if (res.error) {
setStatus(res.error.message ?? "Unable to add score entry.");
return;
}
const change = res.data?.change;
setStatus(change === null || change === undefined ? "Credit score entry added." : `Credit score entry added. Change: ${change > 0 ? "+" : ""}${change}.`);
setForm((prev) => ({ ...prev, score: "", positiveFactors: "", negativeFactors: "" }));
await load();
};
const pullScore = async () => {
const bureau = ["experian", "equifax", "transunion"].includes(form.bureau) ? form.bureau : "experian";
const res = await apiFetch<CreditScoreEntry>("/api/credit-score/pull", {
method: "POST",
body: JSON.stringify({
bureau,
consent: {
acceptedAt: new Date().toISOString(),
purpose: "credit_score_monitoring",
},
}),
});
if (res.error) {
setStatus(res.error.message ?? "Unable to pull credit score.");
return;
}
setStatus(`${bureau} score pulled: ${res.data.score}.`);
await load();
};
const inputCls = "mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary focus:outline-none";
const labelCls = "text-xs text-muted-foreground font-semibold uppercase tracking-wider";
return (
<AppShell title="Credit Score" subtitle="Track bureau scores, history, factors, and score movement alerts.">
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-4">
<div className="rounded-xl border border-border bg-secondary/10 p-5">
<p className="text-xs text-muted-foreground">Latest score</p>
<p className="mt-2 text-3xl font-bold text-foreground">{summary?.latest?.score ?? "--"}</p>
<p className="mt-1 text-xs text-muted-foreground">{scoreBand(summary?.latest?.score)}</p>
</div>
<div className="rounded-xl border border-border bg-secondary/10 p-5">
<p className="text-xs text-muted-foreground">Last change</p>
<p className="mt-2 text-3xl font-bold text-foreground">
{summary?.change === null || summary?.change === undefined ? "--" : `${summary.change > 0 ? "+" : ""}${summary.change}`}
</p>
<p className="mt-1 text-xs text-muted-foreground">Compared with previous same-bureau entry</p>
</div>
<div className="rounded-xl border border-border bg-secondary/10 p-5">
<p className="text-xs text-muted-foreground">Average score</p>
<p className="mt-2 text-3xl font-bold text-foreground">{summary?.averageScore ?? "--"}</p>
<p className="mt-1 text-xs text-muted-foreground">Across saved entries</p>
</div>
<div className="rounded-xl border border-border bg-secondary/10 p-5">
<p className="text-xs text-muted-foreground">Entries</p>
<p className="mt-2 text-3xl font-bold text-foreground">{summary?.entryCount ?? 0}</p>
<p className="mt-1 text-xs text-muted-foreground">Manual or imported history</p>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]">
<section className="rounded-xl border border-border bg-secondary/10 p-6">
<p className="text-sm font-bold text-foreground">Add Score Entry</p>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<div>
<label className={labelCls}>Score</label>
<input type="number" min={300} max={850} value={form.score} onChange={(event) => setForm((prev) => ({ ...prev, score: event.target.value }))} className={inputCls} />
</div>
<div>
<label className={labelCls}>Bureau</label>
<select value={form.bureau} onChange={(event) => setForm((prev) => ({ ...prev, bureau: event.target.value }))} className={inputCls}>
{bureaus.map((bureau) => <option key={bureau.value} value={bureau.value}>{bureau.label}</option>)}
</select>
</div>
<div>
<label className={labelCls}>Model</label>
<select value={form.model} onChange={(event) => setForm((prev) => ({ ...prev, model: event.target.value }))} className={inputCls}>
<option value="fico_8">FICO 8</option>
<option value="fico_9">FICO 9</option>
<option value="vantage_score_3">VantageScore 3</option>
<option value="vantage_score_4">VantageScore 4</option>
</select>
</div>
<div>
<label className={labelCls}>Score date</label>
<input type="date" value={form.scoreDate} onChange={(event) => setForm((prev) => ({ ...prev, scoreDate: event.target.value }))} className={inputCls} />
</div>
<div>
<label className={labelCls}>Positive factors</label>
<input value={form.positiveFactors} onChange={(event) => setForm((prev) => ({ ...prev, positiveFactors: event.target.value }))} className={inputCls} placeholder="low utilization, on-time payments" />
</div>
<div>
<label className={labelCls}>Negative factors</label>
<input value={form.negativeFactors} onChange={(event) => setForm((prev) => ({ ...prev, negativeFactors: event.target.value }))} className={inputCls} placeholder="hard inquiry, high balance" />
</div>
</div>
<div className="mt-4 flex flex-wrap gap-3">
<button onClick={addEntry} className="rounded-lg bg-primary px-4 py-2.5 text-sm font-bold text-primary-foreground hover:bg-primary/90">
Add Score
</button>
<button onClick={pullScore} className="rounded-lg border border-primary/30 px-4 py-2.5 text-sm font-bold text-primary hover:bg-primary/10">
Pull From Provider
</button>
</div>
{status && <p className="mt-4 rounded-lg border border-border bg-background/60 px-4 py-3 text-sm text-muted-foreground">{status}</p>}
</section>
<section className="rounded-xl border border-border bg-secondary/10 p-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<p className="text-sm font-bold text-foreground">Bureau Snapshot</p>
<select value={filter} onChange={(event) => setFilter(event.target.value)} className="rounded-lg border border-border bg-background/50 px-3 py-2 text-sm text-foreground">
<option value="all">All bureaus</option>
{bureaus.map((bureau) => <option key={bureau.value} value={bureau.value}>{bureau.label}</option>)}
</select>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-3">
{(summary?.latestByBureau ?? []).map((entry) => (
<div key={entry.bureau} className="rounded-lg border border-border bg-background/50 p-4">
<p className="text-xs uppercase text-muted-foreground">{entry.bureau}</p>
<p className="mt-2 text-2xl font-bold text-foreground">{entry.score}</p>
<p className="text-xs text-muted-foreground">{new Date(entry.scoreDate).toLocaleDateString()}</p>
</div>
))}
{(summary?.latestByBureau?.length ?? 0) === 0 && <p className="text-sm text-muted-foreground">No bureau scores yet.</p>}
</div>
</section>
</div>
<section className="rounded-xl border border-border bg-secondary/10 p-6">
<p className="text-sm font-bold text-foreground">Score History</p>
<div className="mt-4 divide-y divide-border">
{filteredEntries.length === 0 && <p className="py-8 text-sm text-muted-foreground">No score entries for this view.</p>}
{filteredEntries.map((entry) => {
const factors = entry.factors ?? {};
const positive = Array.isArray(factors.positive) ? factors.positive : [];
const negative = Array.isArray(factors.negative) ? factors.negative : [];
return (
<article key={entry.id} className="py-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">
<p className="text-lg font-bold text-foreground">{entry.score}</p>
<span className="rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground">{entry.bureau}</span>
<span className="rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground">{entry.model}</span>
</div>
<p className="mt-1 text-sm text-muted-foreground">{new Date(entry.scoreDate).toLocaleDateString()} · {entry.source}</p>
{(positive.length > 0 || negative.length > 0) && (
<p className="mt-2 text-xs text-muted-foreground">
{positive.length > 0 ? `Positive: ${positive.join(", ")}` : ""}
{positive.length > 0 && negative.length > 0 ? " · " : ""}
{negative.length > 0 ? `Negative: ${negative.join(", ")}` : ""}
</p>
)}
</div>
<p className="text-sm font-semibold text-foreground">{scoreBand(entry.score)}</p>
</div>
</article>
);
})}
</div>
</section>
</div>
</AppShell>
);
}