feat: dashboard awam-first — hero ringkasan, kartu verdict, tooltip istilah
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import { For, Show } from "solid-js";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import { ISTILAH, type Verdict } from "../lib/awam";
|
||||
|
||||
// Kata teknis bergaris-titik + tooltip penjelasan santai.
|
||||
export function Term(props: { kata: string }) {
|
||||
const key = props.kata.toLowerCase();
|
||||
const arti = ISTILAH[key];
|
||||
if (!arti) return <span>{props.kata}</span>;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger as="span" class="cursor-help underline decoration-dotted underline-offset-2">{props.kata}</TooltipTrigger>
|
||||
<TooltipContent class="max-w-xs">{arti}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function VerdictBadge(props: { verdict: Verdict }) {
|
||||
const warna: Record<Verdict, "default" | "error" | "secondary"> = {
|
||||
"Dilirik": "default",
|
||||
"Dilepas": "error",
|
||||
"Netral": "secondary",
|
||||
};
|
||||
return <Badge variant={warna[props.verdict]}>{props.verdict}</Badge>;
|
||||
}
|
||||
|
||||
// Bar alasan: seberapa kuat sinyal asing vs broker (0-100).
|
||||
export function AlasanBar(props: { asing: number; broker: number }) {
|
||||
const total = Math.abs(props.asing) + Math.abs(props.broker) || 1;
|
||||
const pAsing = Math.round((Math.abs(props.asing) / total) * 100);
|
||||
return (
|
||||
<div>
|
||||
<div class="flex h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div class="bg-blue-500" style={{ width: `${pAsing}%` }} />
|
||||
<div class="bg-emerald-500" style={{ width: `${100 - pAsing}%` }} />
|
||||
</div>
|
||||
<div class="mt-1 flex justify-between text-xs text-muted-foreground">
|
||||
<span><span class="text-blue-500">■</span> Asing {props.asing >= 0 ? "masuk" : "keluar"}</span>
|
||||
<span><span class="text-emerald-500">■</span> Broker {props.broker >= 0 ? "borong" : "jualan"}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function IstilahStrip() {
|
||||
const kunci = ["akumulasi", "foreign flow", "broker", "net", "briefing", "alert"];
|
||||
return (
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Istilah asing? Arahkan kursor:{" "}
|
||||
<For each={kunci}>{(k, i) => <><Show when={i() > 0}>, </Show><Term kata={k} /></>}</For>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { ScreenRow } from "./api";
|
||||
|
||||
// Kamus istilah: kata teknis -> penjelasan santai 1 kalimat.
|
||||
export const ISTILAH: Record<string, string> = {
|
||||
"akumulasi": "Broker-broker besar lagi borong saham ini diam-diam. Biasanya tanda mereka tahu sesuatu yang bagus.",
|
||||
"distribusi": "Broker-broker besar lagi jualan saham ini pelan-pelan. Hati-hati, bisa turun.",
|
||||
"foreign flow": "Uang investor luar negeri yang masuk (beli) atau keluar (jual) dari saham ini.",
|
||||
"inflow": "Uang asing lagi masuk — mereka beli. Tanda kepercayaan.",
|
||||
"outflow": "Uang asing lagi keluar — mereka jual. Waspada.",
|
||||
"broker": "Perantara jual-beli saham (sekuritas). Gerakan mereka dibaca buat nebak arah pasar.",
|
||||
"net": "Selisih total beli dikurangi total jual. Positif = lebih banyak yang beli.",
|
||||
"composite": "Nilai gabungan dari semua sinyal (asing + broker + volume). Makin tinggi makin menarik.",
|
||||
"screener": "Alat penyaring: cari saham yang memenuhi kriteria tertentu.",
|
||||
"briefing": "Ringkasan pagi otomatis: kabar pasar hari ini dalam beberapa kalimat.",
|
||||
"alert": "Notifikasi otomatis kalau saham incaranmu bergerak aneh.",
|
||||
"routine": "Jadwal otomatis, misalnya terima ringkasan pasar tiap pagi jam 7.",
|
||||
"portofolio": "Kumpulan semua saham yang kamu pegang.",
|
||||
"diversifikasi": "Jangan taruh semua uang di satu saham — sebar biar aman.",
|
||||
"rekomendasi": "Saran beli/tahan/jual dari sistem. Bukan nasihat keuangan resmi — tetap riset sendiri.",
|
||||
"hold": "Tahan: jangan jual dulu, tapi belum saatnya tambah beli.",
|
||||
"buy": "Beli: sinyalnya bagus, layak dipertimbangkan.",
|
||||
"sell": "Jual: sinyalnya jelek, pertimbangkan keluar.",
|
||||
"citations": "Sumber data: dari API mana angka ini diambil, biar bisa diverifikasi.",
|
||||
"live": "Data real-time dari scheduler, bukan contoh.",
|
||||
"stale": "Data sudah lama, belum diperbarui. Jangan terlalu dipercaya.",
|
||||
"mover": "Saham yang harganya bergerak drastis hari ini.",
|
||||
"agenda": "Jadwal penting emiten: bagi dividen, rapat pemegang saham, laporan keuangan.",
|
||||
"ex-div": "Tanggal batas: kalau beli setelah tanggal ini, tidak dapat dividen periode ini.",
|
||||
};
|
||||
|
||||
export type Verdict = "Dilirik" | "Dilepas" | "Netral";
|
||||
|
||||
export function verdictFor(row: ScreenRow): { verdict: Verdict; alasan: string } {
|
||||
const b = (row.breakdown || {}) as Record<string, unknown>;
|
||||
const comp = Number(row.composite ?? 0);
|
||||
const trend = String(b.foreign_trend ?? "");
|
||||
const broker = Number(b.broker_score ?? b.broker ?? 0);
|
||||
const insider = Boolean(b.insider_buying ?? b.insider);
|
||||
if (comp > 5 || (trend === "inflow" && broker > 0)) {
|
||||
const bits: string[] = [];
|
||||
if (trend === "inflow") bits.push("uang asing masuk");
|
||||
if (broker > 0) bits.push("broker besar borong");
|
||||
if (insider) bits.push("orang dalam ikut beli");
|
||||
return { verdict: "Dilirik", alasan: bits.length ? bits.join(", ") : "sinyal gabungan positif" };
|
||||
}
|
||||
if (comp < -5 || (trend === "outflow" && broker < 0)) {
|
||||
const bits: string[] = [];
|
||||
if (trend === "outflow") bits.push("uang asing keluar");
|
||||
if (broker < 0) bits.push("broker besar jualan");
|
||||
return { verdict: "Dilepas", alasan: bits.length ? bits.join(", ") : "sinyal gabungan negatif" };
|
||||
}
|
||||
return { verdict: "Netral", alasan: "belum ada gerakan mencolok — pantau saja" };
|
||||
}
|
||||
|
||||
// Ringkas briefing mentah jadi 3 kalimat santai.
|
||||
export function heroSummary(briefing: string): string[] {
|
||||
const lines = briefing.split("\n").filter((l) => l.trim());
|
||||
const top = lines.filter((l) => /^\d+\./.test(l.trim())).slice(0, 3);
|
||||
const foreign = lines.filter((l) => /^-/.test(l.trim()) && /Rp/i.test(l)).slice(0, 2);
|
||||
const mover = lines.find((l) => /mover/i.test(l));
|
||||
const out: string[] = [];
|
||||
if (top.length) {
|
||||
const names = top.map((t) => t.replace(/^\d+\.\s*/, "").split(" net")[0]).join(", ");
|
||||
out.push(`Pagi ini yang paling diborong: ${names}. Artinya pemain besar lagi kumpul di sana.`);
|
||||
}
|
||||
if (foreign.length) out.push(`Uang asing: ${foreign.map((f) => f.replace(/^-\s*/, "")).join("; ")}.`);
|
||||
if (mover) out.push(`Perhatian khusus: ${mover.replace(/^.*?:\s*/, "")}.`);
|
||||
if (!out.length && briefing.trim()) out.push(briefing.slice(0, 220));
|
||||
return out.slice(0, 3);
|
||||
}
|
||||
+100
-53
@@ -1,80 +1,127 @@
|
||||
import { createResource, createSignal, onCleanup, For, Show } from "solid-js";
|
||||
import { api, subscribeSSE, fmtIDR } from "../lib/api";
|
||||
import { AccumulationTable, ActivityFeed, RotationMap } from "../components/Cards";
|
||||
import { Citations } from "../components/Citations";
|
||||
import { createResource, createSignal, For, Show } from "solid-js";
|
||||
import { A, useNavigate } from "@solidjs/router";
|
||||
import { api, fmtIDR } from "../lib/api";
|
||||
import { verdictFor, heroSummary } from "../lib/awam";
|
||||
import { Term, VerdictBadge, AlasanBar, IstilahStrip } from "../components/Awam";
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Skeleton } from "../components/ui/skeleton";
|
||||
import { Line } from "solid-chartjs";
|
||||
import { Chart, registerables } from "chart.js";
|
||||
Chart.register(...registerables);
|
||||
|
||||
export default function Dashboard() {
|
||||
const [health] = createResource(() => api.health());
|
||||
const [flow, { refetch }] = createResource(() => api.flowSummary());
|
||||
const [events, { refetch: refetchEv }] = createResource(() => api.alertEvents("2000-01-01").then((r) => r.events.slice(0, 10)));
|
||||
const [foreign] = createResource(() => api.flowForeign("BBCA").catch(() => null));
|
||||
const navigate = useNavigate();
|
||||
const [flow] = createResource(() => api.flowSummary());
|
||||
const [screen] = createResource(() => api.screen({ limit: 5 }).catch(() => null));
|
||||
const [briefing] = createResource(() => api.briefing().catch(() => null));
|
||||
const [live, setLive] = createSignal<string[]>([]);
|
||||
const stop = subscribeSSE((ch, data) => {
|
||||
setLive((p) => [`${ch}: ${data.slice(0, 120)}`, ...p].slice(0, 5));
|
||||
if (ch === "alerts" || ch === "activity") { refetch(); refetchEv(); }
|
||||
});
|
||||
onCleanup(stop);
|
||||
const flowChart = () => ({
|
||||
const [events] = createResource(() => api.alertEvents("2000-01-01").then((r) => r.events.slice(0, 5)).catch(() => []));
|
||||
const [chartTiker, setChartTiker] = createSignal("BBCA");
|
||||
const [foreign] = createResource(chartTiker, (t) => api.flowForeign(t).catch(() => null));
|
||||
|
||||
const ringkasan = () => {
|
||||
const p = (briefing() as { payload: string } | null)?.payload;
|
||||
return p ? heroSummary(p) : [];
|
||||
};
|
||||
const sorotan = () => (screen()?.rows || []).map((r) => ({ row: r, ...verdictFor(r) }));
|
||||
const chartData = () => ({
|
||||
labels: (foreign()?.dates || []) as string[],
|
||||
datasets: [{ label: "BBCA foreign net", data: (foreign()?.nets || []) as number[] }],
|
||||
datasets: [{ label: `${chartTiker()} — uang asing harian`, data: (foreign()?.nets || []) as number[] }],
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight">Smart Money Dashboard</h1>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Credits {health()?.credits_today ?? "…"} · Scheduler {health()?.scheduler_ok ? "ok" : "down"} · Last cycle {health()?.last_cycle_at || "never"}
|
||||
</p>
|
||||
</div>
|
||||
<Show when={health()?.stale_flags?.length}>
|
||||
<p class="text-sm text-muted-foreground">Stale: <For each={health()!.stale_flags}>{(f) => <Badge variant="outline">{f}</Badge>}</For></p>
|
||||
{/* HERO: hari ini ada apa */}
|
||||
<Card class="border-primary/30 bg-gradient-to-br from-primary/10 to-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-2xl">Hari ini ada apa? 📈</CardTitle>
|
||||
<CardDescription>Ringkasan pasar pagi ini — tanpa perlu paham istilah ekonomi.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Show when={!briefing.loading} fallback={<div class="space-y-2"><Skeleton class="h-4 w-full" /><Skeleton class="h-4 w-3/4" /></div>}>
|
||||
<Show when={ringkasan().length} fallback={<p class="text-sm text-muted-foreground">Belum ada ringkasan hari ini — coba lagi setelah scheduler jalan.</p>}>
|
||||
<ul class="space-y-2 text-base leading-relaxed">
|
||||
<For each={ringkasan()}>{(k) => <li>• {k}</li>}</For>
|
||||
</ul>
|
||||
</Show>
|
||||
<Show when={!flow.loading} fallback={<div class="space-y-2"><Skeleton class="h-24 w-full" /><Skeleton class="h-24 w-full" /><Skeleton class="h-24 w-full" /></div>}>
|
||||
</Show>
|
||||
<div class="mt-3"><IstilahStrip /></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* SOROTAN SAHAM */}
|
||||
<div>
|
||||
<h2 class="mb-3 text-lg font-semibold">Saham sorotan hari ini</h2>
|
||||
<Show when={!screen.loading} fallback={<div class="grid gap-4 md:grid-cols-2"><Skeleton class="h-40" /><Skeleton class="h-40" /></div>}>
|
||||
<Show when={sorotan().length} fallback={<p class="text-sm text-muted-foreground">Belum ada data sorotan.</p>}>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<For each={sorotan()}>{(s) => {
|
||||
const b = (s.row.breakdown || {}) as Record<string, unknown>;
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-xl font-bold">{s.row.symbol}</CardTitle>
|
||||
<VerdictBadge verdict={s.verdict} />
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3">
|
||||
<p class="text-sm">Karena: <strong>{s.alasan}</strong>.</p>
|
||||
<AlasanBar asing={Number(b.foreign ?? 0)} broker={Number(b.broker_score ?? b.broker ?? 0)} />
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" onClick={() => navigate(`/report?t=${s.row.symbol}`)}>Kenapa? Jelaskan</Button>
|
||||
<Button size="sm" variant="outline"><A href={`/screener`}>Bandingkan</A></Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}}</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* UANG ASING PER SAHAM */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Uang asing lagi ke mana?</CardTitle>
|
||||
<CardDescription><Term kata="foreign flow" /> harian — naik = asing beli, turun = asing jual. Pilih saham:</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="mb-3 flex flex-wrap gap-2">
|
||||
<For each={(flow()?.top_accumulation || []).map((t) => t.ticker)}>{(t) =>
|
||||
<Button size="sm" variant={chartTiker() === t ? "default" : "outline"} onClick={() => setChartTiker(t)}>{t}</Button>
|
||||
}</For>
|
||||
</div>
|
||||
<Show when={(foreign()?.dates || []).length} fallback={<p class="text-sm text-muted-foreground">Belum ada data grafik.</p>}>
|
||||
<Line data={chartData()} options={{ responsive: true }} />
|
||||
<Show when={foreign()?.reversal}><p class="mt-2 text-sm font-medium">⚠️ Arahnya baru berbalik — perhatikan beberapa hari ke depan.</p></Show>
|
||||
</Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* AKTIVITAS + ANGKA MENTAH LIPAT */}
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Foreign net total</CardTitle><CardDescription>Aggregate across snapshots.</CardDescription></CardHeader>
|
||||
<CardHeader><CardTitle>Kabar terbaru</CardTitle><CardDescription><Term kata="alert" /> dan aktivitas sistem.</CardDescription></CardHeader>
|
||||
<CardContent>
|
||||
<p class="font-mono text-2xl font-bold">{flow() ? fmtIDR(flow()!.foreign_net_total) : "…"}</p>
|
||||
<Citations items={flow()?.citations} />
|
||||
<Show when={(events() || []).length} fallback={<p class="text-sm text-muted-foreground">Belum ada kabar — pasang alert biar dapat notifikasi.</p>}>
|
||||
<ul class="space-y-2 text-sm">
|
||||
<For each={events() || []}>{(e) => <li>• <strong>{String(e.ticker || "")}</strong> — {String(e.message || "")} <span class="text-muted-foreground">({String(e.date || "")})</span></li>}</For>
|
||||
</ul>
|
||||
</Show>
|
||||
<div class="mt-3"><Button size="sm" variant="outline"><A href="/alerts">Atur notifikasi saya</A></Button></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AccumulationTable rows={flow()?.top_accumulation || []} />
|
||||
<RotationMap signal={undefined} citations={flow()?.citations} />
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Foreign flow (BBCA)</CardTitle></CardHeader>
|
||||
<CardHeader><CardTitle>Yang paling diborong</CardTitle><CardDescription>Total <Term kata="net" /> beli broker hari ini: <strong>{flow() ? fmtIDR(flow()!.foreign_net_total) : "…"}</strong></CardDescription></CardHeader>
|
||||
<CardContent>
|
||||
<Show when={(foreign()?.dates || []).length} fallback={<p class="text-sm text-muted-foreground">No foreign-flow series yet.</p>}>
|
||||
<Line data={flowChart()} options={{ responsive: true }} />
|
||||
<Show when={foreign()?.reversal}><p class="text-sm">Reversal flag set.</p></Show>
|
||||
</Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ActivityFeed events={(events() || []).map((e) => ({ ticker: String(e.ticker || ""), message: String(e.message || ""), date: String(e.date || "") }))} />
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Live agent panel</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<Show when={live().length} fallback={<p class="text-sm text-muted-foreground">Waiting for SSE events…</p>}>
|
||||
<ul class="space-y-1 text-sm text-muted-foreground"><For each={live()}>{(l) => <li>{l}</li>}</For></ul>
|
||||
</Show>
|
||||
<ul class="space-y-1 text-sm">
|
||||
<For each={flow()?.top_accumulation || []}>{(t) => <li class="flex justify-between"><A class="font-medium underline-offset-2 hover:underline" href={`/report?t=${t.ticker}`}>{t.ticker}</A><span class="font-mono">{fmtIDR(t.net_sum)} · {t.brokers} broker</span></li>}</For>
|
||||
</ul>
|
||||
<div class="mt-2"><Badge variant="outline">Angka detail untuk yang penasaran — keputusan ada di kartu sorotan ☝️</Badge></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Show>
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Morning briefing</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<Show when={briefing()} fallback={<p class="text-sm text-muted-foreground">No briefing yet — subscribe to the morning-briefing routine.</p>}>
|
||||
<pre class="whitespace-pre-wrap text-sm text-muted-foreground">{(briefing() as { payload: string })?.payload?.slice(0, 800)}</pre>
|
||||
</Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user