From 793e0724ed51d1fc82329d06993956dba5acf14c Mon Sep 17 00:00:00 2001
From: asepharyana
Date: Tue, 15 Sep 2026 20:55:38 +0700
Subject: [PATCH] =?UTF-8?q?feat:=20dashboard=20substansial=20=E2=80=94=20h?=
=?UTF-8?q?arga=20close=20per=20kartu,=20konteks=20pasar,=20grafik=20berfo?=
=?UTF-8?q?rmat=20Rp=20+=20periode?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/internal/api/flow.go | 15 +++++++++-
web/src/lib/api.ts | 5 ++--
web/src/lib/awam.ts | 49 +++++++++++++++++++++++++++++++-
web/src/pages/Dashboard.tsx | 55 ++++++++++++++++++++++++++++--------
4 files changed, 109 insertions(+), 15 deletions(-)
diff --git a/backend/internal/api/flow.go b/backend/internal/api/flow.go
index 5bfcba5..f5bb02c 100644
--- a/backend/internal/api/flow.go
+++ b/backend/internal/api/flow.go
@@ -28,6 +28,7 @@ func (s *Server) FlowSummary(w http.ResponseWriter, r *http.Request) {
}
var accs []accRow
foreignTotal := 0.0
+ var closes []map[string]any
var cites []model.Citation
for _, tk := range wl {
if nets, err := s.DB.NetBuySum5d(tk); err == nil && len(nets) > 0 {
@@ -45,6 +46,9 @@ func (s *Server) FlowSummary(w http.ResponseWriter, r *http.Request) {
foreignTotal += nets[len(nets)-1]
cites = append(cites, model.Cite("v2/foreign-flow/"+tk+"/", tk, date))
}
+ if px, dx, err := s.DB.LatestClose(tk); err == nil {
+ closes = append(closes, map[string]any{"ticker": tk, "close": px, "date": dx})
+ }
}
sort.Slice(accs, func(i, j int) bool { return accs[i].NetSum > accs[j].NetSum })
if len(accs) > 5 {
@@ -52,7 +56,7 @@ func (s *Server) FlowSummary(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]any{
"date": date, "foreign_net_total": foreignTotal,
- "top_accumulation": accs, "citations": cites,
+ "top_accumulation": accs, "closes": closes, "citations": cites,
})
}
@@ -132,5 +136,14 @@ func (s *Server) FlowForeign(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"ticker": q.Ticker, "dates": dates, "nets": nets, "reversal": reversal,
"citations": []model.Citation{model.Cite("v2/foreign-flow/"+q.Ticker+"/", q.Ticker, dates[len(dates)-1])},
+ "start": startOf(dates), "end": dates[len(dates)-1],
})
}
+
+// startOf returns the first series date ("" when empty).
+func startOf(dates []string) string {
+ if len(dates) == 0 {
+ return ""
+ }
+ return dates[0]
+}
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index 18f5769..d4ea0f3 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -17,17 +17,18 @@ async function req(path: string, init?: RequestInit): Promise {
}
export interface Citation { endpoint: string; snapshot_at: string; ticker?: string; stale?: boolean }
export interface Health { last_cycle_at: string; credits_today: number; scheduler_ok: boolean; stale_flags: string[] }
-export interface FlowSummary { date: string; foreign_net_total: number; top_accumulation: { ticker: string; net_sum: number; brokers: number }[]; citations: Citation[] }
+export interface FlowSummary { date: string; foreign_net_total: number; top_accumulation: { ticker: string; net_sum: number; brokers: number }[]; closes: { ticker: string; close: number; date: string }[]; citations: Citation[] }
export interface ScreenRow { symbol: string; name: string; composite: number; breakdown: Record; citations: Citation[] }
export interface Routine { id: number; user_key: string; type: string; schedule_cron: string; channels_json?: string; channels?: string[]; enabled: boolean; last_run?: unknown }
export interface AlertItem { id: number; user_key: string; name: string; rule_json: string; channels_json: string; last_fired: string }
export interface ReportPayload { ticker: string; generated_at: string; sections: { name: string; body: string; citations: Citation[] }[]; synthesis: { recommendation: string; conviction: number; thesis: string; position_pct: number; conflict: boolean; conflict_note?: string }; agent_scores: unknown[]; citations: Citation[]; narasi_awam?: string }
export interface AuthUser { id: number; email: string; name: string; avatar_url: string; user_key: string; google_configured: boolean }
+export interface ForeignSeries { dates: string[]; nets: number[]; reversal: boolean; start?: string; end?: string; citations: Citation[] }
export const api = {
health: (force = false) => req(`/api/health${force ? "?force=1" : ""}`),
flowSummary: () => req("/api/flow/summary"),
flowBroker: (ticker: string) => req<{ buyers: unknown[]; sellers: unknown[]; citations: Citation[] }>(`/api/flow/broker?ticker=${ticker}`),
- flowForeign: (ticker: string) => req<{ dates: string[]; nets: number[]; reversal: boolean; citations: Citation[] }>(`/api/flow/foreign?ticker=${ticker}`),
+ flowForeign: (ticker: string) => req(`/api/flow/foreign?ticker=${ticker}`),
screen: (body: Record) => req<{ rows: ScreenRow[]; count: number }>("/api/screen", { method: "POST", body: JSON.stringify(body) }),
routines: () => req<{ routines: Routine[] }>("/api/routines"),
createRoutine: (body: Record) => req<{ id: number }>("/api/routines", { method: "POST", body: JSON.stringify(body) }),
diff --git a/web/src/lib/awam.ts b/web/src/lib/awam.ts
index c32fcef..51ac0c1 100644
--- a/web/src/lib/awam.ts
+++ b/web/src/lib/awam.ts
@@ -1,4 +1,4 @@
-import type { ScreenRow } from "./api";
+import type { ScreenRow, FlowSummary, ForeignSeries } from "./api";
// Kamus istilah: kata teknis -> penjelasan santai 1 kalimat.
export const ISTILAH: Record = {
@@ -68,3 +68,50 @@ export function heroSummary(briefing: string): string[] {
if (!out.length && briefing.trim()) out.push(briefing.slice(0, 220));
return out.slice(0, 3);
}
+
+// fmtRp: 1.78T / 305B / 25M — untuk harga pakai fmtHarga.
+export function fmtRp(v: number): string {
+ const neg = v < 0; const a = Math.abs(v);
+ let s: string;
+ if (a >= 1e12) s = `Rp${(a / 1e12).toFixed(2)}T`;
+ else if (a >= 1e9) s = `Rp${Math.round(a / 1e9)}B`;
+ else if (a >= 1e6) s = `Rp${Math.round(a / 1e6)}M`;
+ else s = `Rp${Math.round(a)}`;
+ return neg ? "-" + s : s;
+}
+
+export function fmtHarga(v: number): string {
+ return `Rp${Math.round(v).toLocaleString("id-ID")}`;
+}
+
+// Cerita 1 kalimat per kartu sorotan: harga + arah asing + broker.
+export function ceritaKartu(args: {
+ symbol: string; verdict: Verdict; alasan: string;
+ close?: number; closeDate?: string; foreignNet?: number;
+}): string {
+ const parts: string[] = [];
+ if (args.close) parts.push(`terakhir ${fmtHarga(args.close)}`);
+ if (args.foreignNet !== undefined) {
+ parts.push(args.foreignNet >= 0
+ ? `asing beli bersih ${fmtRp(args.foreignNet)}`
+ : `asing jual bersih ${fmtRp(Math.abs(args.foreignNet))}`);
+ }
+ parts.push(args.alasan);
+ return parts.join(" · ") + ".";
+}
+
+// Konteks pasar: berapa Dilirik vs Dilepas + total asing hari ini.
+export function konteksPasar(sorotan: { verdict: Verdict }[], foreignTotal?: number): string {
+ const up = sorotan.filter((s) => s.verdict === "Dilirik").length;
+ const down = sorotan.filter((s) => s.verdict === "Dilepas").length;
+ const mood = up > down ? "mayoritas dilirik — pasar lagi berani."
+ : down > up ? "mayoritas dilepas — pasar lagi hati-hati."
+ : "pasar campur aduk — belum ada arah jelas.";
+ let s = `Dari ${sorotan.length} saham pantauan: ${up} dilirik, ${down} dilepas — ${mood}`;
+ if (foreignTotal !== undefined) {
+ s += foreignTotal >= 0
+ ? ` Total asing hari ini beli bersih ${fmtRp(foreignTotal)}.`
+ : ` Total asing hari ini jual bersih ${fmtRp(Math.abs(foreignTotal))}.`;
+ }
+ return s;
+}
diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx
index 8271d46..f61c5b5 100644
--- a/web/src/pages/Dashboard.tsx
+++ b/web/src/pages/Dashboard.tsx
@@ -1,7 +1,7 @@
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 { api } from "../lib/api";
+import { verdictFor, heroSummary, konteksPasar, fmtRp, fmtHarga } 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";
@@ -27,10 +27,32 @@ export default function Dashboard() {
return p ? heroSummary(p) : [];
};
const sorotan = () => (screen()?.rows || []).map((r) => ({ row: r, ...verdictFor(r) }));
+ const closeMap = () => Object.fromEntries((flow()?.closes || []).map((c) => [c.ticker, c]));
const chartData = () => ({
labels: (foreign()?.dates || []) as string[],
- datasets: [{ label: `${chartTiker()} — uang asing harian`, data: (foreign()?.nets || []) as number[] }],
+ datasets: [{
+ label: `${chartTiker()} — uang asing harian (${fmtRp(0).slice(0, 2)}7700`,
+ data: (foreign()?.nets || []) as number[],
+ borderColor: "#3b82f6",
+ backgroundColor: "rgba(59,130,246,.15)",
+ fill: true,
+ tension: 0.25,
+ }],
});
+ const chartOpts = () => ({
+ responsive: true,
+ plugins: {
+ tooltip: { callbacks: { label: (c: { parsed: { y: number } }) => ` ${fmtRp(c.parsed.y)}` } },
+ legend: { display: false },
+ },
+ scales: {
+ y: { ticks: { callback: (v: number | string) => fmtRp(Number(v)), maxTicksLimit: 5 } },
+ },
+ });
+ const periode = () => {
+ const f = foreign();
+ return f?.start && f?.end ? `${f.start} → ${f.end}` : "";
+ };
return (
@@ -48,6 +70,9 @@ export default function Dashboard() {
+
+ 🧭 {konteksPasar(sorotan(), flow()?.foreign_net_total)}
+
@@ -55,22 +80,29 @@ export default function Dashboard() {
{/* SOROTAN SAHAM */}
Saham sorotan hari ini
- }>
+
}>
Belum ada data sorotan.
}>
{(s) => {
const b = (s.row.breakdown || {}) as Record;
+ const cx = closeMap()[s.row.symbol];
return (
-
+
- {s.row.symbol}
+
+ {s.row.symbol}
+ {fmtHarga(cx.close)}
+
- Karena: {s.alasan}.
+
+ Harga terakhir {cx?.close ? fmtHarga(cx.close) : "—"}
+ {" "}— {s.alasan}.
+
-
+
@@ -95,7 +127,8 @@ export default function Dashboard() {
}
Belum ada data grafik.}>
-
+
+ Periode {periode()} · sumber: Sectors API (lihat sitasi report).
⚠️ Arahnya baru berbalik — perhatikan beberapa hari ke depan.
@@ -115,10 +148,10 @@ export default function Dashboard() {
- Yang paling diborongTotal beli broker hari ini: {flow() ? fmtIDR(flow()!.foreign_net_total) : "…"}
+ Yang paling diborongTotal beli broker 5 hari: {flow() ? fmtRp(flow()!.foreign_net_total) : "…"}
- {(t) => - {t.ticker}{fmtIDR(t.net_sum)} · {t.brokers} broker
}
+ {(t) => - {t.ticker}{fmtRp(t.net_sum)} · {t.brokers} broker
}
Angka detail untuk yang penasaran — keputusan ada di kartu sorotan ☝️