242 lines
12 KiB
TypeScript
242 lines
12 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 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>
|
|
<button onClick={addEntry} className="mt-4 rounded-lg bg-primary px-4 py-2.5 text-sm font-bold text-primary-foreground hover:bg-primary/90">
|
|
Add Score
|
|
</button>
|
|
{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>
|
|
);
|
|
}
|