사이드바 5번째(이자 마지막 정보성) 패널인 PriceTargets 도 공용 Collapsible 로 통일. SymbolSidebar(상단 종목 카드 — 자체 톤 유지가 맞음) 만 남기고 나머지 사이드 패널 5개(Orderbook / Targets / Alerts / Note / Related)가 모두 같은 토글/영속 패턴. subtitle 설계 — 접힌 상태에서도 저장값 한눈에: - 둘 다 저장됨: "목표 12,500 · 손절 9,800" - 한쪽만: "목표 12,500" / "손절 9,800" - 둘 다 없음: "비공개 · 브라우저 저장" (InvestmentNote 와 동일 톤) - dirty 입력값이 아닌 saved 기준만 표시 — 토글 사이클 사이에 안내가 깜빡이지 않게. storageKey="targets" → panel-open:targets 영속. defaultOpen=true — 입력값은 Collapsible 의 hidden 본문 덕에 토글에도 보존되지만, 처음 보는 사용자에겐 input 이 펴져 있어야 "여기서 적는다" 가 자명. 입력 흐름 우선. 기존 rounded-lg 는 Collapsible 기본 rounded-md 로 흡수 — 다른 사이드 패널과 모서리 톤 통일. 검증: - npx tsc --noEmit clean - npx next lint "✔ No ESLint warnings or errors"
168 lines
5.8 KiB
TypeScript
168 lines
5.8 KiB
TypeScript
"use client";
|
|
|
|
// 목표가 / 손절가 시뮬레이터 (Toss "내 목표가" read-only 클론).
|
|
// - 현재가 기준 변동률 미리 계산해서 표시
|
|
// - 저장 시 localStorage 에만 — 차트 오버레이는 부모 page 가 같은 상태를 읽어 StockChart 에 전달
|
|
// - 알림은 별도 AlertsPanel 담당, 여기는 시각화 + 손익 프리뷰만
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { readTargets, writeTargets, type Targets } from "../lib/targets";
|
|
import { Collapsible } from "./Collapsible";
|
|
|
|
type Props = {
|
|
code: string;
|
|
current: number | null;
|
|
// 저장/해제 시 부모에 변경을 전달 — 차트에 즉시 반영하도록.
|
|
onChange?: (t: Targets) => void;
|
|
};
|
|
|
|
function parseNum(s: string): number | null {
|
|
const cleaned = s.replace(/,/g, "").trim();
|
|
if (!cleaned) return null;
|
|
const n = Number(cleaned);
|
|
return Number.isFinite(n) && n > 0 ? n : null;
|
|
}
|
|
|
|
function pct(price: number | null, current: number | null): number | null {
|
|
if (price == null || current == null || current <= 0) return null;
|
|
return ((price - current) / current) * 100;
|
|
}
|
|
|
|
export function PriceTargets({ code, current, onChange }: Props) {
|
|
const [target, setTarget] = useState("");
|
|
const [stop, setStop] = useState("");
|
|
const [saved, setSaved] = useState<Targets>({});
|
|
|
|
// 종목 바뀌면 새로 로드. 입력창도 저장값으로 초기화.
|
|
useEffect(() => {
|
|
const t = readTargets(code);
|
|
setSaved(t);
|
|
setTarget(t.target != null ? String(t.target) : "");
|
|
setStop(t.stop != null ? String(t.stop) : "");
|
|
}, [code]);
|
|
|
|
const targetN = useMemo(() => parseNum(target), [target]);
|
|
const stopN = useMemo(() => parseNum(stop), [stop]);
|
|
const targetPct = pct(targetN, current);
|
|
const stopPct = pct(stopN, current);
|
|
|
|
const dirty =
|
|
(targetN ?? null) !== (saved.target ?? null) ||
|
|
(stopN ?? null) !== (saved.stop ?? null);
|
|
|
|
const save = () => {
|
|
const next: Targets = { target: targetN, stop: stopN };
|
|
writeTargets(code, next);
|
|
setSaved(next);
|
|
onChange?.(next);
|
|
};
|
|
const clear = () => {
|
|
setTarget("");
|
|
setStop("");
|
|
const next: Targets = {};
|
|
writeTargets(code, next);
|
|
setSaved(next);
|
|
onChange?.(next);
|
|
};
|
|
|
|
const fmtPct = (p: number | null): string =>
|
|
p == null ? "" : `${p >= 0 ? "+" : ""}${p.toFixed(2)}%`;
|
|
const pctCls = (p: number | null): string =>
|
|
p == null ? "text-zinc-500" : p >= 0 ? "text-rose-400" : "text-sky-400";
|
|
|
|
// 접힌 상태에서도 유저가 어떤 값을 저장해뒀는지 한눈에 — 둘 다 없으면 InvestmentNote 와
|
|
// 같은 톤의 "비공개 · 브라우저 저장" 안내. 저장값(savedN)이 아닌 dirty 입력값을 보여주면
|
|
// 토글 사이클 사이에 안내가 깜빡여 혼란. saved 기준만 표시.
|
|
const fmtNum = (n: number): string =>
|
|
n.toLocaleString(undefined, { maximumFractionDigits: 0 });
|
|
const subtitle =
|
|
saved.target != null && saved.stop != null
|
|
? `목표 ${fmtNum(saved.target)} · 손절 ${fmtNum(saved.stop)}`
|
|
: saved.target != null
|
|
? `목표 ${fmtNum(saved.target)}`
|
|
: saved.stop != null
|
|
? `손절 ${fmtNum(saved.stop)}`
|
|
: "비공개 · 브라우저 저장";
|
|
|
|
return (
|
|
// defaultOpen=true — 입력 중인 텍스트는 Collapsible 의 hidden 본문 덕에 토글로 사라지지
|
|
// 않지만, 처음 보는 사용자에겐 input 이 펴져 있어야 "여기서 목표가를 적는다" 가 자명.
|
|
<Collapsible title="내 목표가" subtitle={subtitle} storageKey="targets">
|
|
<div className="space-y-2">
|
|
<Row
|
|
label="목표가"
|
|
color="bg-rose-400/80"
|
|
value={target}
|
|
onChange={setTarget}
|
|
deltaText={fmtPct(targetPct)}
|
|
deltaCls={pctCls(targetPct)}
|
|
/>
|
|
<Row
|
|
label="손절가"
|
|
color="bg-sky-400/80"
|
|
value={stop}
|
|
onChange={setStop}
|
|
deltaText={fmtPct(stopPct)}
|
|
deltaCls={pctCls(stopPct)}
|
|
/>
|
|
</div>
|
|
<div className="mt-2 flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={save}
|
|
disabled={!dirty}
|
|
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800/80 px-2 py-1 text-[11px] text-zinc-100 transition hover:border-zinc-500 disabled:cursor-not-allowed disabled:opacity-40"
|
|
>
|
|
저장
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={clear}
|
|
disabled={saved.target == null && saved.stop == null && !target && !stop}
|
|
className="rounded-md border border-zinc-800 px-2 py-1 text-[11px] text-zinc-400 transition hover:border-zinc-600 hover:text-zinc-200 disabled:cursor-not-allowed disabled:opacity-40"
|
|
>
|
|
해제
|
|
</button>
|
|
</div>
|
|
{current == null && (
|
|
<div className="mt-1 text-[10px] text-zinc-500">현재가 로딩 중 — 변동률은 잠시 후 표시</div>
|
|
)}
|
|
</Collapsible>
|
|
);
|
|
}
|
|
|
|
function Row({
|
|
label,
|
|
color,
|
|
value,
|
|
onChange,
|
|
deltaText,
|
|
deltaCls,
|
|
}: {
|
|
label: string;
|
|
color: string;
|
|
value: string;
|
|
onChange: (s: string) => void;
|
|
deltaText: string;
|
|
deltaCls: string;
|
|
}) {
|
|
return (
|
|
<label className="flex items-center gap-2 text-[11px]">
|
|
<span className={`inline-block h-2 w-2 rounded-sm ${color}`} aria-hidden />
|
|
<span className="w-12 shrink-0 text-zinc-400">{label}</span>
|
|
<input
|
|
type="text"
|
|
inputMode="numeric"
|
|
autoComplete="off"
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder="0"
|
|
className="min-w-0 flex-1 rounded border border-zinc-800 bg-zinc-950/50 px-2 py-1 text-right tabular-nums text-zinc-100 outline-none focus:border-zinc-600"
|
|
/>
|
|
<span className={`w-16 shrink-0 text-right tabular-nums ${deltaCls}`}>
|
|
{deltaText}
|
|
</span>
|
|
</label>
|
|
);
|
|
}
|