feat: fase 3 awam — Screener preset, Alerts template, Routines manfaat, Portfolio saran
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s

This commit is contained in:
asepharyana
2026-09-15 20:23:40 +07:00
parent 9fa47240dc
commit a35182ead0
4 changed files with 162 additions and 256 deletions
+42 -66
View File
@@ -1,25 +1,25 @@
import { createResource, createSignal, For, Show } from "solid-js";
import { api } from "../lib/api";
import { Term, IstilahStrip } from "../components/Awam";
import { EmptyState, PageHead } from "../components/ui";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { TextField, TextFieldInput } from "../components/ui/text-field";
const FIELDS = ["score", "broker_score", "foreign_net", "volume_mult", "composite"];
const OPS = [">=", "<=", ">", "<", "=="];
type Cond = { field: string; op: string; value: number };
// Template siap pakai -> rule JSON backend.
const TEMPLATES: { label: string; desc: string; rule: Record<string, unknown> }[] = [
{ label: "🐋 Bandar mulai borong", desc: "Beri tahu kalau broker besar net-beli di saham watchlist", rule: { all: [{ field: "broker_score", op: ">=", value: 30 }] } },
{ label: "🌍 Asing masuk deras", desc: "Beri tahu kalau uang asing inflow besar", rule: { all: [{ field: "foreign_net", op: ">=", value: 50000000000 }] } },
{ label: "🚨 Bandar mulai jualan", desc: "Beri tahu kalau broker besar net-jual", rule: { all: [{ field: "broker_score", op: "<=", value: -30 }] } },
{ label: "📢 Semua gerakan mencolok", desc: "Skor gabungan di atas ambang", rule: { all: [{ field: "score", op: ">=", value: 60 }] } },
];
export default function Alerts() {
const [alerts, { refetch }] = createResource(() => api.alerts());
const [events, { refetch: refetchEv }] = createResource(() => api.alertEvents("2000-01-01").then((r) => r.events.slice(0, 30)));
const [dests, { refetch: refetchDests }] = createResource(() => api.destinations());
const [name, setName] = createSignal("accumulation watcher");
const [field, setField] = createSignal("score");
const [op, setOp] = createSignal(">=");
const [value, setValue] = createSignal(60);
const [conds, setConds] = createSignal<Cond[]>([]);
const [tpl, setTpl] = createSignal(TEMPLATES[0]);
const [channels, setChannels] = createSignal("");
const [busy, setBusy] = createSignal(false);
const [dkind, setDkind] = createSignal("telegram");
@@ -28,19 +28,10 @@ export default function Alerts() {
const [dchat, setDchat] = createSignal("");
const [dhook, setDhook] = createSignal("");
const [derr, setDerr] = createSignal("");
const rulePreview = () => JSON.stringify(conds().length ? { all: conds() } : { all: true });
function addCond() {
setConds((p) => [...p, { field: field(), op: op(), value: value() }]);
}
async function create() {
setBusy(true);
try {
await api.createAlert({
name: name(),
rule: JSON.parse(rulePreview()),
channels: channels().split(",").map((c) => c.trim()).filter(Boolean),
});
setConds([]);
await api.createAlert({ name: tpl().label, rule: tpl().rule, channels: channels().split(",").map((c) => c.trim()).filter(Boolean) });
refetch();
} finally { setBusy(false); }
}
@@ -56,35 +47,43 @@ export default function Alerts() {
refetchDests();
} catch (e) { setDerr(String(e)); }
}
async function toggleDest(id: number, enabled: boolean) {
await api.updateDestination(id, { enabled: !enabled });
refetchDests();
}
async function toggleDest(id: number, enabled: boolean) { await api.updateDestination(id, { enabled: !enabled }); refetchDests(); }
async function delDest(id: number) { await api.deleteDestination(id); refetchDests(); }
return (
<div class="space-y-4">
<PageHead title="Alerts" sub="Rules evaluated over snapshots each cycle." />
<PageHead title="Notifikasi otomatis 🔔" sub="Pilih kejadian yang mau kamu dikabari — tanpa perlu paham angka." />
<Card>
<CardHeader>
<CardTitle>Push destinations (per login)</CardTitle>
<CardDescription>Secrets stay server-side — the list never shows tokens.</CardDescription>
</CardHeader>
<CardHeader><CardTitle>1. Mau dikabari soal apa?</CardTitle><CardDescription>Pilih satu template <Term kata="alert" />.</CardDescription></CardHeader>
<CardContent class="space-y-3">
<div class="grid gap-2 md:grid-cols-2">
<For each={TEMPLATES}>{(t) => (
<Button variant={tpl().label === t.label ? "default" : "outline"} class="h-auto flex-col items-start p-3" onClick={() => setTpl(t)}>
<span class="font-semibold">{t.label}</span><span class="text-xs font-normal opacity-80">{t.desc}</span>
</Button>
)}</For>
</div>
<div class="flex gap-2">
<TextField class="flex-1"><TextFieldInput placeholder="Kirim ke mana? (kosongkan = catat saja)" value={channels()} onInput={(e) => setChannels(e.currentTarget.value)} /></TextField>
<Button onClick={create} disabled={busy()}>Pasang notifikasi</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>2. Dikirim ke mana?</CardTitle><CardDescription>Telegram / Discord milikmu. Token tersimpan aman di server.</CardDescription></CardHeader>
<CardContent class="space-y-3">
<div class="flex flex-wrap gap-2">
<div class="flex gap-1 rounded-md bg-muted p-1">
<Button size="sm" variant={dkind() === "telegram" ? "default" : "ghost"} onClick={() => setDkind("telegram")}>Telegram</Button>
<Button size="sm" variant={dkind() === "discord" ? "default" : "ghost"} onClick={() => setDkind("discord")}>Discord</Button>
</div>
<TextField class="w-40"><TextFieldInput placeholder="label (e.g. my phone)" value={dlabel()} onInput={(e) => setDlabel(e.currentTarget.value)} /></TextField>
<TextField class="w-40"><TextFieldInput placeholder="nama (mis. HP saya)" value={dlabel()} onInput={(e) => setDlabel(e.currentTarget.value)} /></TextField>
{dkind() === "telegram" ? (
<>
<TextField class="w-52"><TextFieldInput placeholder="bot token" value={dbot()} onInput={(e) => setDbot(e.currentTarget.value)} /></TextField>
<TextField class="w-32"><TextFieldInput placeholder="chat id" value={dchat()} onInput={(e) => setDchat(e.currentTarget.value)} /></TextField>
</>
<><TextField class="w-52"><TextFieldInput placeholder="token bot" value={dbot()} onInput={(e) => setDbot(e.currentTarget.value)} /></TextField>
<TextField class="w-32"><TextFieldInput placeholder="chat id" value={dchat()} onInput={(e) => setDchat(e.currentTarget.value)} /></TextField></>
) : (
<TextField class="min-w-64 flex-1"><TextFieldInput placeholder="https://discord webhook url" value={dhook()} onInput={(e) => setDhook(e.currentTarget.value)} /></TextField>
)}
<Button onClick={addDest}>Add destination</Button>
<Button onClick={addDest}>Tambah tujuan</Button>
</div>
<Show when={derr()}><p class="text-sm text-destructive">{derr()}</p></Show>
<ul class="space-y-1.5 text-sm">
@@ -93,61 +92,38 @@ export default function Alerts() {
<li class="flex flex-wrap items-center gap-2">
<Badge variant={d.enabled ? "success" : "secondary"}>{d.kind}</Badge>
<span>{d.label || `#${d.id}`}</span>
<span class="text-muted-foreground">{d.enabled ? "on" : "off"} · {d.configured ? "configured" : "missing secret"}</span>
<span class="text-muted-foreground">{d.enabled ? "aktif" : "mati"} · {d.configured ? "siap" : "belum ada token"}</span>
<span class="flex-1" />
<Button variant="outline" size="sm" onClick={() => toggleDest(d.id, d.enabled)}>{d.enabled ? "Disable" : "Enable"}</Button>
<Button variant="destructive" size="sm" onClick={() => delDest(d.id)}>Delete</Button>
<Button variant="outline" size="sm" onClick={() => toggleDest(d.id, d.enabled)}>{d.enabled ? "Matikan" : "Nyalakan"}</Button>
<Button variant="destructive" size="sm" onClick={() => delDest(d.id)}>Hapus</Button>
</li>
)}
</For>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>New rule</CardTitle></CardHeader>
<CardContent class="space-y-3">
<TextField><TextFieldInput placeholder="rule name" value={name()} onInput={(e) => setName(e.currentTarget.value)} /></TextField>
<div class="flex flex-wrap gap-2">
<select class="h-10 rounded-md border border-input bg-background px-3 text-sm" value={field()} onChange={(e) => setField(e.currentTarget.value)}>
<For each={FIELDS}>{(f) => <option value={f}>{f}</option>}</For>
</select>
<select class="h-10 rounded-md border border-input bg-background px-3 text-sm" value={op()} onChange={(e) => setOp(e.currentTarget.value)}>
<For each={OPS}>{(o) => <option value={o}>{o}</option>}</For>
</select>
<TextField class="w-24"><TextFieldInput type="number" value={value()} onInput={(e) => setValue(Number(e.currentTarget.value))} /></TextField>
<Button variant="outline" onClick={addCond}>+ condition</Button>
</div>
<Show when={conds().length}>
<ul class="space-y-1 text-sm text-muted-foreground"><For each={conds()}>{(c, i) => <li>{c.field} {c.op} {c.value} <Button variant="ghost" size="sm" onClick={() => setConds((p) => p.filter((_, j) => j !== i()))}>×</Button></li>}</For></ul>
</Show>
<p class="text-sm text-muted-foreground">Rule JSON: <code class="font-mono">{rulePreview()}</code></p>
<div class="flex gap-2">
<TextField class="flex-1"><TextFieldInput placeholder="channels, comma-separated (blank = record only)" value={channels()} onInput={(e) => setChannels(e.currentTarget.value)} /></TextField>
<Button onClick={create} disabled={busy()}>Create rule</Button>
</div>
</CardContent>
</Card>
<div class="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader><CardTitle>Rules</CardTitle></CardHeader>
<CardHeader><CardTitle>Notifikasi terpasang</CardTitle></CardHeader>
<CardContent>
<Show when={(alerts()?.alerts || []).length} fallback={<EmptyState icon="🔔" title="No rules" hint="Create one above." />}>
<ul class="space-y-1.5 text-sm"><For each={alerts()?.alerts || []}>{(a) => <li class="flex items-center gap-2">{a.name} <span class="text-muted-foreground">last: {a.last_fired || "never"}</span><span class="flex-1" /><Button variant="destructive" size="sm" onClick={() => del(a.id)}>Delete</Button></li>}</For></ul>
<Show when={(alerts()?.alerts || []).length} fallback={<EmptyState icon="🔔" title="Belum ada" hint="Pilih template di atas, sekali klik." />}>
<ul class="space-y-1.5 text-sm"><For each={alerts()?.alerts || []}>{(a) => <li class="flex items-center gap-2">{a.name} <span class="text-muted-foreground">terakhir: {a.last_fired || "belum pernah"}</span><span class="flex-1" /><Button variant="destructive" size="sm" onClick={() => del(a.id)}>Hapus</Button></li>}</For></ul>
</Show>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle>Event history</CardTitle>
<CardTitle>Kejadian terakhir</CardTitle>
<Button variant="ghost" size="sm" onClick={() => refetchEv()}>Refresh</Button>
</CardHeader>
<CardContent>
<Show when={(events() || []).length} fallback={<p class="text-sm text-muted-foreground">No events yet.</p>}>
<Show when={(events() || []).length} fallback={<p class="text-sm text-muted-foreground">Belum ada kejadian.</p>}>
<ul class="space-y-1.5 text-sm"><For each={events() || []}>{(e) => <li><Badge variant="secondary">{String(e.ticker)}</Badge> {String(e.message)}</li>}</For></ul>
</Show>
</CardContent>
</Card>
</div>
<div><IstilahStrip /></div>
</div>
);
}
+35 -57
View File
@@ -1,87 +1,65 @@
import { createResource, For, Show } from "solid-js";
import { api } from "../lib/api";
import { Term, IstilahStrip } from "../components/Awam";
import { Bar } from "solid-chartjs";
import { Chart, registerables } from "chart.js";
import { PageHead } from "../components/ui";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { Badge } from "../components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { Skeleton } from "../components/ui/skeleton";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
Chart.register(...registerables);
export default function Portfolio() {
const [risk] = createResource(() => api.risk());
const [acc] = createResource(() => api.accuracy());
const conc = () => risk()?.concentration || [];
const betaN = () => Number(risk()?.beta ?? 1);
const betaArti = () => {
const b = betaN();
if (b > 1.2) return "bergerak lebih liar dari pasar — naik lebih kencang, turun lebih dalam.";
if (b < 0.8) return "lebih kalem dari pasar — cocok buat yang tidak suka deg-degan.";
return "geraknya seirama pasar — tidak terlalu liar, tidak terlalu kalem.";
};
const saran = () => {
const c = conc();
if (!c.length) return "";
const top = c[0];
if (top.weight > 0.4) return `${top.ticker} porsinya ${(top.weight * 100).toFixed(0)}% — kebanyakan telur di satu keranjang. Pertimbangkan tambah saham beda sektor.`;
const sek = [...new Set(c.map((x) => x.sector))];
if (sek.length <= 2) return `Cuma ${sek.length} sektor (${sek.join(", ")}) — kalau sektor itu jatuh, semua ikut. Coba lirik sektor lain.`;
return "Sebarannya sudah lumayan — tidak numpuk di satu tempat. Pertahankan. 👍";
};
const chartData = () => ({
labels: (risk()?.concentration || []).map((c) => c.ticker),
datasets: [{ label: "Weight %", data: (risk()?.concentration || []).map((c) => +(c.weight * 100).toFixed(1)) }],
labels: conc().map((c) => c.ticker),
datasets: [{ label: "Porsi %", data: conc().map((c) => +(c.weight * 100).toFixed(1)) }],
});
return (
<div class="space-y-4">
<PageHead title="Portfolio Risk + Accuracy Ledger" sub="Computed from your watchlist." />
<Show when={!risk.loading && !acc.loading} fallback={<div class="space-y-2"><Skeleton class="h-24 w-full" /><Skeleton class="h-24 w-full" /></div>}>
<PageHead title="Risiko portofolio kamu 💼" sub="Dihitung dari watchlist-mu. Bahasa manusia, bukan laporan bank." />
<Show when={!risk.loading} fallback={<div class="space-y-2"><Skeleton class="h-24 w-full" /><Skeleton class="h-24 w-full" /></div>}>
<Card class="border-primary/30 bg-gradient-to-br from-primary/10 to-transparent">
<CardHeader><CardTitle class="text-xl">Saran 1 kalimat 💡</CardTitle></CardHeader>
<CardContent><p class="text-base leading-relaxed">{saran() || "Tambah saham ke watchlist dulu biar bisa dinilai."}</p></CardContent>
</Card>
<div class="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader><CardTitle>Concentration</CardTitle></CardHeader>
<CardHeader><CardTitle>Porsi tiap saham</CardTitle><CardDescription>Siapa yang paling dominan di <Term kata="portofolio" />-mu.</CardDescription></CardHeader>
<CardContent>
<Show when={(risk()?.concentration || []).length} fallback={<p class="text-sm text-muted-foreground">Add tickers to your watchlist to see concentration.</p>}>
<Show when={conc().length} fallback={<p class="text-sm text-muted-foreground">Tambah saham ke watchlist dulu.</p>}>
<Bar data={chartData()} options={{ responsive: true }} />
</Show>
<For each={risk()?.warnings || []}>{(w) => <p class="text-sm">⚠️ {w}</p>}</For>
<For each={risk()?.warnings || []}>{(w) => <p class="mt-1 text-sm">⚠️ {w}</p>}</For>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Beta vs index</CardTitle></CardHeader>
<CardHeader><CardTitle>Kelakuan vs pasar</CardTitle><CardDescription>Skor beta = {betaN().toFixed(2)}</CardDescription></CardHeader>
<CardContent class="space-y-3">
<p class="font-mono text-2xl font-bold">{risk() ? String(risk()!.beta) : "…"}</p>
<h3 class="text-sm font-semibold">Correlation heatmap</h3>
<CorrHeatmap matrix={risk()?.correlation || {}} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Agent accuracy</CardTitle></CardHeader>
<CardContent>
<Table>
<TableHeader><TableRow><TableHead>Agent</TableHead><TableHead class="text-right">Calls</TableHead><TableHead class="text-right">Resolved</TableHead><TableHead class="text-right">Hit %</TableHead></TableRow></TableHeader>
<TableBody>
<For each={acc()?.agents || []}>
{(a) => <TableRow><TableCell>{a.agent}</TableCell><TableCell class="text-right">{a.calls}</TableCell><TableCell class="text-right">{a.resolved}</TableCell><TableCell class="text-right">{(a.hit_rate * 100).toFixed(0)}%</TableCell></TableRow>}
</For>
</TableBody>
</Table>
<p class="text-base">Portofolio kamu <strong>{betaArti()}</strong></p>
<div><Badge variant="outline"><Term kata="diversifikasi" />: {[...new Set(conc().map((x) => x.sector))].length} sektor</Badge></div>
</CardContent>
</Card>
</div>
</Show>
<div><IstilahStrip /></div>
</div>
);
}
function CorrHeatmap(props: { matrix: Record<string, Record<string, number>> }) {
const keys = () => Object.keys(props.matrix);
const cell = (v: number) => {
const a = Math.min(1, Math.abs(v));
return v >= 0 ? `rgba(34,197,94,${(0.12 + 0.6 * a).toFixed(2)})` : `rgba(239,68,68,${(0.12 + 0.6 * a).toFixed(2)})`;
};
return (
<Show when={keys().length} fallback={<p class="text-sm text-muted-foreground">No correlation data yet.</p>}>
<div class="overflow-x-auto"><Table>
<TableHeader><TableRow><TableHead></TableHead><For each={keys()}>{(k) => <TableHead>{k}</TableHead>}</For></TableRow></TableHeader>
<TableBody>
<For each={keys()}>
{(r) => (
<TableRow>
<TableCell class="font-bold">{r}</TableCell>
<For each={keys()}>
{(c) => {
const v = props.matrix[r]?.[c] ?? 0;
return <TableCell style={{ background: cell(v) }} title={`${r}/${c} = ${v.toFixed(2)}`}>{v.toFixed(2)}</TableCell>;
}}
</For>
</TableRow>
)}
</For>
</TableBody>
</Table></div>
</Show>
);
}
+39 -40
View File
@@ -1,73 +1,70 @@
import { createResource, createSignal, For, Show } from "solid-js";
import { api } from "../lib/api";
import { Citations } from "../components/Citations";
import { Term, IstilahStrip } from "../components/Awam";
import { EmptyState, PageHead } from "../components/ui";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { TextField, TextFieldInput } from "../components/ui/text-field";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
const TYPES = ["morning-briefing", "accumulation-radar", "foreign-reversal", "insider-tape", "earnings-countdown", "dividend-calendar", "weekend-review"];
// Tipe -> penjelasan manfaat bahasa awam.
const MANFAAT: Record<string, { judul: string; desc: string }> = {
"morning-briefing": { judul: "☀️ Ringkasan pagi", desc: "Dapat kabar pasar tiap pagi jam 07:30 — siapa diborong, ke mana uang asing." },
"accumulation-radar": { judul: "🐋 Radar borongan bandar", desc: "Dikabari kalau broker besar mulai borong saham." },
"foreign-reversal": { judul: "🌍 Pantau balik arah asing", desc: "Dikabari kalau uang asing berbalik arah (masuk ↔ keluar)." },
"insider-tape": { judul: "🕵️ Gerak orang dalam", desc: "Dikabari kalau direksi/pemilik saham ikut beli." },
"earnings-countdown": { judul: "📊 Pengingat laporan keuangan", desc: "Dikabari sebelum emiten rilis laporan keuangan." },
"dividend-calendar": { judul: "💰 Pengingat dividen", desc: "Dikabari sebelum tanggal bagi dividen (biar tidak kelewat)." },
"weekend-review": { judul: "📝 Review mingguan", desc: "Ringkasan seminggu: apa yang terjadi dan pelajaran." },
};
export default function Routines() {
const [data, { refetch }] = createResource(() => api.routines());
const [runs, { refetch: refetchRuns }] = createResource(() => api.runs().then((r) => r.runs.slice(0, 20)));
const [type_, setType] = createSignal("morning-briefing");
const [schedule, setSchedule] = createSignal("");
const [channels, setChannels] = createSignal("");
const [busy, setBusy] = createSignal(false);
const [briefing] = createResource(() => api.briefing().catch(() => null));
async function subscribe() {
setBusy(true);
try {
await api.createRoutine({
type: type_(),
schedule_cron: schedule().trim() || undefined,
channels: channels().split(",").map((c) => c.trim()).filter(Boolean),
});
refetch();
} finally { setBusy(false); }
}
async function toggle(id: number, enabled: boolean) {
await api.updateRoutine(id, { enabled: !enabled });
refetch();
try { await api.createRoutine({ type: type_() }); refetch(); }
finally { setBusy(false); }
}
async function toggle(id: number, enabled: boolean) { await api.updateRoutine(id, { enabled: !enabled }); refetch(); }
async function del(id: number) {
await fetch(`/api/routines/${id}`, { method: "DELETE", headers: { "X-User-Key": localStorage.getItem("fs-key") || "demo" } });
refetch();
}
return (
<div class="space-y-4">
<PageHead title="Routine Manager" sub="Scheduled deliveries with citations." />
<PageHead title="Jadwal otomatis 🗓️" sub="Pilih sekali — sistem yang kerja tiap hari. Ini namanya routine." />
<Card>
<CardHeader><CardTitle>Subscribe</CardTitle></CardHeader>
<CardContent class="flex flex-wrap gap-2">
<TextField class="w-52">
<select class="h-10 w-full rounded-md border border-input bg-background px-3 text-sm" value={type_()} onChange={(e) => setType(e.currentTarget.value)}>
<For each={TYPES}>{(t) => <option value={t}>{t}</option>}</For>
</select>
</TextField>
<TextField class="w-60"><TextFieldInput placeholder="schedule cron (blank = default)" value={schedule()} onInput={(e) => setSchedule(e.currentTarget.value)} /></TextField>
<TextField class="w-52"><TextFieldInput placeholder="channels, comma-separated" value={channels()} onInput={(e) => setChannels(e.currentTarget.value)} /></TextField>
<Button onClick={subscribe} disabled={busy()}>Subscribe</Button>
<CardHeader><CardTitle>Mau dilayani apa?</CardTitle><CardDescription>Jadwal standar sudah diatur (mis. ringkasan jam 07:30) — tidak perlu isi cron.</CardDescription></CardHeader>
<CardContent class="space-y-3">
<div class="grid gap-2 md:grid-cols-2">
<For each={Object.entries(MANFAAT)}>{([tipe, m]) => (
<Button variant={type_() === tipe ? "default" : "outline"} class="h-auto flex-col items-start p-3" onClick={() => setType(tipe)}>
<span class="font-semibold">{m.judul}</span><span class="text-xs font-normal opacity-80">{m.desc}</span>
</Button>
)}</For>
</div>
<Button onClick={subscribe} disabled={busy()}>Aktifkan ✓</Button>
</CardContent>
</Card>
<Show when={(data()?.routines || []).length} fallback={
<EmptyState icon="🗓️" title="No routines yet" hint="Subscribe above — the morning briefing runs 07:30 WIB daily." />
<EmptyState icon="🗓️" title="Belum ada jadwal" hint="Pilih layanan di atas — sekali klik langsung jalan." />
}>
<div class="grid gap-4 md:grid-cols-2">
<For each={data()?.routines || []}>
{(r) => (
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-base">{r.type}</CardTitle>
<Badge variant={r.enabled ? "success" : "secondary"}>{r.enabled ? "on" : "off"}</Badge>
<CardTitle class="text-base">{MANFAAT[r.type]?.judul || r.type}</CardTitle>
<Badge variant={r.enabled ? "success" : "secondary"}>{r.enabled ? "jalan" : "mati"}</Badge>
</CardHeader>
<CardContent class="space-y-2">
<p class="font-mono text-xs text-muted-foreground">{r.schedule_cron}</p>
<p class="text-sm text-muted-foreground">{MANFAAT[r.type]?.desc || ""}</p>
<div class="flex gap-2">
<Button variant="outline" size="sm" onClick={() => toggle(r.id, r.enabled)}>{r.enabled ? "Disable" : "Enable"}</Button>
<Button variant="destructive" size="sm" onClick={() => del(r.id)}>Delete</Button>
<Button variant="outline" size="sm" onClick={() => toggle(r.id, r.enabled)}>{r.enabled ? "Matikan" : "Nyalakan"}</Button>
<Button variant="destructive" size="sm" onClick={() => del(r.id)}>Hapus</Button>
</div>
</CardContent>
</Card>
@@ -77,17 +74,18 @@ export default function Routines() {
</Show>
<div class="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader><CardTitle>Latest briefing</CardTitle></CardHeader>
<CardHeader><CardTitle>Ringkasan terakhir</CardTitle><CardDescription><Term kata="briefing" /> pagi yang sudah terbit.</CardDescription></CardHeader>
<CardContent>
<Show when={briefing()} fallback={<p class="text-sm text-muted-foreground">No briefing yet — it generates after the first morning-briefing run.</p>}>
<pre class="whitespace-pre-wrap text-sm text-muted-foreground">{(briefing() as { payload: string })?.payload?.slice(0, 800)}</pre>
<div class="mt-2"><Citations items={(briefing() as { citations: string })?.citations} /></div>
<Show when={briefing()} fallback={<p class="text-sm text-muted-foreground">Belum terbit — aktifkan "Ringkasan pagi" di atas.</p>}>
<Show when={(briefing() as { narasi?: string })?.narasi} fallback={<pre class="whitespace-pre-wrap text-sm text-muted-foreground">{(briefing() as { payload: string })?.payload?.slice(0, 600)}</pre>}>
<p class="whitespace-pre-wrap text-sm leading-relaxed">{(briefing() as { narasi?: string })?.narasi}</p>
</Show>
</Show>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle>Run history</CardTitle>
<CardTitle>Riwayat jalan</CardTitle>
<Button variant="ghost" size="sm" onClick={() => refetchRuns()}>Refresh</Button>
</CardHeader>
<CardContent>
@@ -95,6 +93,7 @@ export default function Routines() {
</CardContent>
</Card>
</div>
<div><IstilahStrip /></div>
</div>
);
}
+46 -93
View File
@@ -1,126 +1,79 @@
import { createSignal, For, Show } from "solid-js";
import { api, type ScreenRow } from "../lib/api";
import { Citations } from "../components/Citations";
import { BreakdownBars, EmptyState, PageHead, RecBadge } from "../components/ui";
import { verdictFor } from "../lib/awam";
import { Term, VerdictBadge, AlasanBar, IstilahStrip } from "../components/Awam";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { Skeleton } from "../components/ui/skeleton";
import { TextField, TextFieldInput } from "../components/ui/text-field";
import { Switch, SwitchControl, SwitchLabel, SwitchThumb } from "../components/ui/switch";
import { useNavigate } from "@solidjs/router";
const SAVED_KEY = "fs-saved-screeners";
function scoreOf(r: ScreenRow): number {
const b = r.breakdown as Record<string, unknown>;
for (const k of ["composite", "score", "broker_score", "broker"]) {
if (typeof b[k] === "number") return b[k] as number;
}
return r.composite || 0;
}
// Preset awam -> body /api/screen.
const PRESETS: { label: string; desc: string; body: Record<string, unknown> }[] = [
{ label: "🔥 Yang lagi diborong bandar", desc: "Broker besar net-beli besar", body: { institutional: { broker_score_min: 5 }, limit: 20 } },
{ label: "🌍 Yang asing lagi beli", desc: "Uang luar negeri masuk", body: { institutional: { foreign_inflow: true }, limit: 20 } },
{ label: "🕵️ Yang orang dalamnya ikut beli", desc: "Insider buying terdeteksi", body: { institutional: { insider_buying: true }, limit: 20 } },
{ label: "📦 Semua — urut paling menarik", desc: "Tanpa filter, ranking gabungan", body: { limit: 20 } },
];
export default function Screener() {
const [mode, setMode] = createSignal<"where" | "q">("q");
const [q, setQ] = createSignal("");
const [brokerMin, setBrokerMin] = createSignal(0);
const [foreignOnly, setForeignOnly] = createSignal(false);
const [insiderOnly, setInsiderOnly] = createSignal(false);
const navigate = useNavigate();
const [rows, setRows] = createSignal<ScreenRow[]>([]);
const [ran, setRan] = createSignal(false);
const [busy, setBusy] = createSignal(false);
const [err, setErr] = createSignal("");
const loadSaved = (): Record<string, string> => {
try { return JSON.parse(localStorage.getItem(SAVED_KEY) || "{}"); } catch { return {}; }
};
const [saved, setSaved] = createSignal<Record<string, string>>(loadSaved());
const [sname, setSname] = createSignal("");
async function run() {
setErr(""); setBusy(true);
const [aktif, setAktif] = createSignal("");
async function runPreset(p: (typeof PRESETS)[number]) {
setErr(""); setBusy(true); setAktif(p.label);
try {
const institutional: Record<string, unknown> = { broker_score_min: brokerMin() };
if (foreignOnly()) institutional.foreign_inflow = true;
if (insiderOnly()) institutional.insider_buying = true;
const body = mode() === "q"
? { q: q() || undefined, institutional, limit: 20 }
: { where: q() || undefined, institutional, limit: 20 };
const r = await api.screen(body);
const r = await api.screen(p.body);
setRows(r.rows); setRan(true);
} catch (e) { setErr(String(e)); }
finally { setBusy(false); }
}
function save() {
const all = { ...saved(), [sname().trim() || q().slice(0, 24) || "untitled"]: q() };
localStorage.setItem(SAVED_KEY, JSON.stringify(all));
setSaved(all);
}
const hasil = () => rows().map((r) => ({ row: r, ...verdictFor(r) }));
return (
<div class="space-y-4">
<PageHead title="Institutional Screener" sub="Rank tickers by smart-money signals." />
<Card>
<CardContent class="space-y-3 pt-6">
<div class="flex flex-wrap items-center gap-2">
<div class="flex gap-1 rounded-md bg-muted p-1">
<Button size="sm" variant={mode() === "q" ? "default" : "ghost"} onClick={() => setMode("q")}>Natural language</Button>
<Button size="sm" variant={mode() === "where" ? "default" : "ghost"} onClick={() => setMode("where")}>SQL-like</Button>
</div>
<TextField class="min-w-52 flex-1">
<TextFieldInput placeholder={mode() === "q" ? "e.g. large banks with foreign inflow" : "e.g. market_cap > 10T"} value={q()} onInput={(e) => setQ(e.currentTarget.value)} />
</TextField>
<Button onClick={run} disabled={busy()}>{busy() ? "Screening…" : "Screen"}</Button>
</div>
<div class="flex flex-wrap items-center gap-4 text-sm">
<label class="flex items-center gap-2 text-muted-foreground">broker ≥
<TextField class="w-20"><TextFieldInput type="number" value={brokerMin()} onInput={(e) => setBrokerMin(Number(e.currentTarget.value))} /></TextField>
</label>
<Switch checked={foreignOnly()} onChange={setForeignOnly}>
<SwitchControl><SwitchThumb /></SwitchControl>
<SwitchLabel>foreign inflow</SwitchLabel>
</Switch>
<Switch checked={insiderOnly()} onChange={setInsiderOnly}>
<SwitchControl><SwitchThumb /></SwitchControl>
<SwitchLabel>insider buying</SwitchLabel>
</Switch>
</div>
<Show when={err()}><p class="text-sm text-destructive">{err()}</p></Show>
</CardContent>
</Card>
<div>
<h1 class="text-2xl font-bold tracking-tight">Cari saham menarik 🔍</h1>
<p class="text-sm text-muted-foreground">Pilih satu kategori — sistem yang menyaring <Term kata="screener" /> buat kamu.</p>
</div>
<div class="grid gap-3 md:grid-cols-2">
<For each={PRESETS}>{(p) => (
<Card class={aktif() === p.label ? "border-primary" : ""}>
<CardHeader class="pb-2"><CardTitle class="text-base">{p.label}</CardTitle><CardDescription>{p.desc}</CardDescription></CardHeader>
<CardContent><Button size="sm" onClick={() => runPreset(p)} disabled={busy()}>{busy() && aktif() === p.label ? "Menyaring…" : "Tampilkan"}</Button></CardContent>
</Card>
)}</For>
</div>
<Show when={err()}><p class="text-sm text-destructive">{err()}</p></Show>
<Show when={busy()}><div class="space-y-2"><Skeleton class="h-24 w-full" /><Skeleton class="h-24 w-full" /></div></Show>
<Show when={ran() && !busy()}>
<Show when={rows().length} fallback={<EmptyState icon="🔍" title="No matches" hint="Loosen the filters and try again." />}>
<Show when={hasil().length} fallback={<p class="text-sm text-muted-foreground">Tidak ada yang cocok — coba kategori lain.</p>}>
<h2 class="text-lg font-semibold">Hasil ({hasil().length})</h2>
<div class="grid gap-4 md:grid-cols-2">
<For each={rows()}>
{(r) => (
<For each={hasil()}>{(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">
<a class="font-bold text-primary hover:underline" href={`/report/${r.symbol}`}>{r.symbol}</a>
<Badge variant="secondary" class="font-mono">{r.composite.toFixed(1)}</Badge>
<CardTitle class="text-xl font-bold">{s.row.symbol}</CardTitle>
<VerdictBadge verdict={s.verdict} />
</CardHeader>
<CardContent class="space-y-2">
<BreakdownBars breakdown={r.breakdown as Record<string, unknown>} />
<Citations items={r.citations} />
<Show when={scoreOf(r) !== 0}><RecBadge rec={scoreOf(r) >= 40 ? "BUY" : scoreOf(r) <= -40 ? "AVOID" : "HOLD"} /></Show>
<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/${s.row.symbol}`)}>Kenapa? Jelaskan</Button>
</div>
</CardContent>
</Card>
)}
</For>
);
}}</For>
</div>
</Show>
</Show>
<Show when={!ran() && !busy()}>
<EmptyState icon="🔍" title="Run a screen" hint="Ranked rows with per-signal bars appear here." />
</Show>
<Card>
<CardHeader><CardTitle>Saved screeners</CardTitle></CardHeader>
<CardContent class="space-y-3">
<div class="flex gap-2">
<TextField class="w-36"><TextFieldInput placeholder="name" value={sname()} onInput={(e) => setSname(e.currentTarget.value)} /></TextField>
<Button variant="outline" onClick={save}>Save current</Button>
</div>
<ul class="space-y-1 text-sm">
<For each={Object.entries(saved())}>{([n, query]) => <li><Button variant="ghost" size="sm" onClick={() => { setQ(query); run(); }}>{n}</Button> <span class="text-muted-foreground">{query.slice(0, 60)}</span></li>}</For>
</ul>
</CardContent>
</Card>
<div><IstilahStrip /></div>
</div>
);
}