주식 히스토리 API 및 블로그 작성 API 추가

This commit is contained in:
2026-03-11 08:08:39 +09:00
parent bbc9bf36f9
commit c6ac849a25
5 changed files with 1121 additions and 142 deletions

View File

@@ -137,6 +137,22 @@ export function deletePortfolio(id) {
return apiDelete(`/api/portfolio/${id}`);
}
// ── 자산 스냅샷 API ──────────────────────────────────────────────────────────
// 장 마감 시점 총 자산을 기록하고, 기간별 추이를 조회합니다.
// GET /api/portfolio/snapshot/history?days=N
// response: { history: [{ date: "2026-03-07", total_assets: 12345678 }, ...] }
export function getAssetHistory(days = 30) {
const qs = days ? `?days=${days}` : '';
return apiGet(`/api/portfolio/snapshot/history${qs}`);
}
// POST /api/portfolio/snapshot (body 없이 호출 — 서버가 현재 total_assets 계산해서 저장)
// 또는 body: { total_assets: number } 로 직접 지정 가능
export function saveAssetSnapshot(total_assets) {
return apiPost('/api/portfolio/snapshot', total_assets != null ? { total_assets } : undefined);
}
// ── 예수금 API ───────────────────────────────────────────────────────────────
export function upsertCash(broker, cash) {
@@ -204,3 +220,25 @@ export function deleteTodo(id) {
export function clearTodos() {
return apiDelete('/api/todos/done');
}
// ── 블로그 API ────────────────────────────────────────────────────────────────
// GET /api/blog/posts → { posts: [{id, title, tags, body, date, excerpt}] }
// POST /api/blog/posts → 새 글 생성
// PUT /api/blog/posts/:id → 글 수정
// DELETE /api/blog/posts/:id → 글 삭제
export function getBlogPostsApi() {
return apiGet('/api/blog/posts');
}
export function createBlogPost(data) {
return apiPost('/api/blog/posts', data);
}
export function updateBlogPost(id, data) {
return apiPut(`/api/blog/posts/${id}`, data);
}
export function deleteBlogPost(id) {
return apiDelete(`/api/blog/posts/${id}`);
}

View File

@@ -10,6 +10,30 @@
align-items: center;
}
.blog-header__actions {
display: flex;
flex-direction: column;
gap: 12px;
}
.blog-new-btn {
align-self: flex-start;
border: 1px solid rgba(192, 132, 252, 0.45);
background: rgba(192, 132, 252, 0.1);
color: var(--accent-blog);
border-radius: 999px;
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease;
}
.blog-new-btn:hover {
background: rgba(192, 132, 252, 0.2);
border-color: rgba(192, 132, 252, 0.7);
}
.blog-kicker {
text-transform: uppercase;
letter-spacing: 0.3em;
@@ -56,23 +80,27 @@
.blog-toggle-list {
display: none;
position: fixed;
top: 20px;
left: 20px;
/* 사이드바 토글 버튼(top-left) 과 겹치지 않도록 오른쪽 하단 배치 */
bottom: 24px;
right: 24px;
top: auto;
left: auto;
z-index: 1000;
width: 40px;
height: 40px;
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid var(--line);
background: rgba(10, 12, 20, 0.8);
color: var(--text);
border: 1px solid rgba(192, 132, 252, 0.45);
background: rgba(10, 12, 20, 0.88);
color: var(--accent-blog);
font-size: 18px;
cursor: pointer;
backdrop-filter: blur(10px);
backdrop-filter: blur(12px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
transition: transform 0.2s ease, opacity 0.2s ease;
}
.blog-toggle-list:hover {
transform: scale(1.1);
transform: scale(1.08);
opacity: 0.9;
}
@@ -103,31 +131,84 @@
color: var(--accent-blog);
}
.blog-list__item {
.blog-list__item-wrap {
border: 1px solid var(--line);
background: var(--surface);
padding: 16px;
border-radius: var(--radius-md);
text-align: left;
cursor: pointer;
display: grid;
gap: 8px;
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
box-shadow: var(--shadow-inset);
overflow: hidden;
}
.blog-list__item:hover {
.blog-list__item-wrap:hover {
border-color: var(--line-strong);
background: var(--surface-raised);
box-shadow: var(--shadow-sm), var(--shadow-inset);
}
.blog-list__item.is-active {
.blog-list__item-wrap.is-active {
border-color: rgba(192, 132, 252, 0.5);
box-shadow: 0 4px 20px rgba(192, 132, 252, 0.12), var(--shadow-inset);
background: rgba(192, 132, 252, 0.05);
}
.blog-list__item-btn {
width: 100%;
padding: 16px;
text-align: left;
cursor: pointer;
display: grid;
gap: 8px;
background: transparent;
border: none;
color: inherit;
}
.blog-list__actions {
display: flex;
gap: 6px;
padding: 0 12px 10px;
}
.blog-list__action-btn {
font-size: 11px;
padding: 3px 10px;
border-radius: 999px;
border: 1px solid var(--line);
background: transparent;
color: var(--muted);
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.blog-list__action-btn:hover {
border-color: var(--accent-blog);
color: var(--accent-blog);
}
.blog-list__action-btn--del:hover {
border-color: #f04452;
color: #f04452;
}
.blog-article__edit-btn {
font-size: 11px;
padding: 4px 12px;
border-radius: 999px;
border: 1px solid var(--line);
background: transparent;
color: var(--muted);
cursor: pointer;
text-transform: none;
letter-spacing: 0;
transition: border-color 0.15s, color 0.15s;
}
.blog-article__edit-btn:hover {
border-color: var(--accent-blog);
color: var(--accent-blog);
}
.blog-pagination {
display: flex;
align-items: center;
@@ -376,6 +457,12 @@
grid-template-columns: 1fr;
}
.blog-header__actions {
flex-direction: row;
align-items: center;
flex-wrap: wrap;
}
.blog-toggle-list {
display: block;
}
@@ -427,7 +514,7 @@
gap: 10px;
}
.blog-list__item {
.blog-list__item-btn {
padding: 14px;
}
@@ -476,3 +563,207 @@
padding: 16px;
}
}
/* ── 블로그 에디터 모달 ──────────────────────────────────────────────────── */
.blog-editor-overlay {
position: fixed;
inset: 0;
background: rgba(4, 6, 14, 0.75);
backdrop-filter: blur(6px);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.blog-editor {
background: #0c0f1e;
border: 1px solid rgba(192, 132, 252, 0.25);
border-radius: var(--radius-xl, 20px);
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6), 0 0 40px rgba(192, 132, 252, 0.06);
width: 100%;
max-width: 860px;
max-height: 92vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.blog-editor__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 24px 14px;
border-bottom: 1px solid var(--line);
flex-shrink: 0;
}
.blog-editor__heading {
margin: 0;
font-size: 17px;
font-weight: 700;
color: var(--accent-blog);
letter-spacing: 0.04em;
}
.blog-editor__close {
background: transparent;
border: none;
color: var(--muted);
font-size: 18px;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
line-height: 1;
transition: color 0.15s;
}
.blog-editor__close:hover {
color: var(--text-bright, #fff);
}
.blog-editor__title-input {
margin: 14px 24px 0;
padding: 10px 14px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--line);
border-radius: var(--radius-md, 10px);
color: var(--text-bright, #f8f3ee);
font-size: 16px;
font-weight: 600;
outline: none;
transition: border-color 0.15s;
flex-shrink: 0;
}
.blog-editor__title-input:focus {
border-color: rgba(192, 132, 252, 0.5);
}
.blog-editor__title-input::placeholder {
color: var(--muted);
}
.blog-editor__tag-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 24px 0;
flex-shrink: 0;
}
.blog-editor__tab-bar {
display: flex;
gap: 4px;
padding: 12px 24px 0;
flex-shrink: 0;
}
.blog-editor__tab {
padding: 5px 14px;
border-radius: 999px;
border: 1px solid var(--line);
background: transparent;
color: var(--muted);
font-size: 12px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.blog-editor__tab.is-active {
border-color: rgba(192, 132, 252, 0.55);
background: rgba(192, 132, 252, 0.12);
color: var(--accent-blog);
}
.blog-editor__textarea {
flex: 1;
margin: 10px 24px 0;
padding: 14px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid var(--line);
border-radius: var(--radius-md, 10px);
color: var(--text-bright, #f8f3ee);
font-size: 14px;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
line-height: 1.75;
resize: none;
outline: none;
min-height: 320px;
transition: border-color 0.15s;
}
.blog-editor__textarea:focus {
border-color: rgba(192, 132, 252, 0.4);
}
.blog-editor__preview {
flex: 1;
margin: 10px 24px 0;
padding: 14px;
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--line);
border-radius: var(--radius-md, 10px);
overflow-y: auto;
min-height: 320px;
}
.blog-editor__footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 14px 24px 18px;
border-top: 1px solid var(--line);
flex-shrink: 0;
margin-top: 12px;
}
.blog-editor__save-btn {
border-color: rgba(192, 132, 252, 0.55) !important;
background: rgba(192, 132, 252, 0.15) !important;
color: var(--accent-blog) !important;
}
.blog-editor__save-btn:hover:not(:disabled) {
background: rgba(192, 132, 252, 0.25) !important;
}
.blog-editor__save-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
@media (max-width: 768px) {
.blog-editor-overlay {
align-items: flex-end;
padding: 0;
}
.blog-editor {
max-width: 100%;
max-height: 95vh;
border-radius: var(--radius-xl, 20px) var(--radius-xl, 20px) 0 0;
}
.blog-editor__title-input,
.blog-editor__tag-row,
.blog-editor__tab-bar,
.blog-editor__textarea,
.blog-editor__preview {
margin-left: 16px;
margin-right: 16px;
}
.blog-editor__header,
.blog-editor__footer {
padding-left: 16px;
padding-right: 16px;
}
.blog-new-btn {
align-self: stretch;
text-align: center;
}
}

View File

@@ -1,7 +1,15 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getBlogPosts } from '../../data/blog';
import {
getBlogPostsApi,
createBlogPost,
updateBlogPost,
deleteBlogPost,
} from '../../api';
import './Blog.css';
// ── 마크다운 렌더러 ──────────────────────────────────────────────────────────
const renderInline = (text) => {
const normalized = text.replace(/<br\s*\/?>/gi, '\n');
const pattern =
@@ -122,9 +130,7 @@ const renderMarkdown = (body) => {
flushList();
if (!line.trim()) {
return;
}
if (!line.trim()) return;
if (line.startsWith('###### ')) {
blocks.push({ type: 'h6', value: line.replace(/^######\s+/, '') });
@@ -193,62 +199,255 @@ const renderMarkdown = (body) => {
});
};
// ── 블로그 에디터 모달 ────────────────────────────────────────────────────────
const PRESET_TAGS = ['일상', '개발', '공부', '아이디어', '기타'];
const BlogEditor = ({ post, onSave, onClose }) => {
const [title, setTitle] = useState(post?.title || '');
const [tags, setTags] = useState(post?.tags || []);
const [body, setBody] = useState(post?.body || '');
const [showPreview, setShowPreview] = useState(false);
const [saving, setSaving] = useState(false);
const textareaRef = useRef(null);
// Tab 키로 들여쓰기 삽입
const handleKeyDown = (e) => {
if (e.key === 'Tab') {
e.preventDefault();
const el = textareaRef.current;
const start = el.selectionStart;
const end = el.selectionEnd;
const next = body.substring(0, start) + ' ' + body.substring(end);
setBody(next);
requestAnimationFrame(() => {
el.selectionStart = el.selectionEnd = start + 2;
});
}
};
const toggleTag = (tag) => {
setTags((prev) =>
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
);
};
const handleSave = async () => {
if (!title.trim()) return;
setSaving(true);
try {
const today = new Date().toISOString().slice(0, 10);
const excerpt = body
.split(/\r?\n/)
.find((l) => l.trim() && !l.startsWith('#'))
?.trim()
.slice(0, 120) || '';
await onSave({
title: title.trim(),
tags,
body,
excerpt,
date: post?.date || today,
});
onClose();
} finally {
setSaving(false);
}
};
// ESC 키로 닫기
useEffect(() => {
const handler = (e) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [onClose]);
return (
<div className="blog-editor-overlay" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="blog-editor">
<div className="blog-editor__header">
<h2 className="blog-editor__heading">
{post?.id ? '글 수정' : '새 글 쓰기'}
</h2>
<button type="button" className="blog-editor__close" onClick={onClose} aria-label="닫기">
</button>
</div>
<input
className="blog-editor__title-input"
type="text"
placeholder="제목을 입력하세요"
value={title}
onChange={(e) => setTitle(e.target.value)}
autoFocus
/>
<div className="blog-editor__tag-row">
{PRESET_TAGS.map((tag) => (
<button
key={tag}
type="button"
className={`blog-category-chip${tags.includes(tag) ? ' is-active' : ''}`}
onClick={() => toggleTag(tag)}
>
{tag}
</button>
))}
</div>
<div className="blog-editor__tab-bar">
<button
type="button"
className={`blog-editor__tab${!showPreview ? ' is-active' : ''}`}
onClick={() => setShowPreview(false)}
>
편집
</button>
<button
type="button"
className={`blog-editor__tab${showPreview ? ' is-active' : ''}`}
onClick={() => setShowPreview(true)}
>
미리보기
</button>
</div>
{showPreview ? (
<div className="blog-article__body blog-editor__preview">
{body
? renderMarkdown(body)
: <p style={{ color: 'var(--muted)' }}>본문을 입력하면 여기에 미리보기가 표시됩니다.</p>
}
</div>
) : (
<textarea
ref={textareaRef}
className="blog-editor__textarea"
placeholder="마크다운으로 글을 작성하세요...&#10;&#10;예시:&#10;# 제목&#10;## 소제목&#10;**굵게** *기울임* `코드`"
value={body}
onChange={(e) => setBody(e.target.value)}
onKeyDown={handleKeyDown}
spellCheck={false}
/>
)}
<div className="blog-editor__footer">
<button type="button" className="button" onClick={onClose}>
취소
</button>
<button
type="button"
className="button blog-editor__save-btn"
onClick={handleSave}
disabled={saving || !title.trim()}
>
{saving ? '저장 중...' : '저장'}
</button>
</div>
</div>
</div>
);
};
// ── 메인 Blog 컴포넌트 ───────────────────────────────────────────────────────
const Blog = () => {
const posts = useMemo(() => getBlogPosts(), []);
const staticPosts = useMemo(() => getBlogPosts(), []);
const [apiPosts, setApiPosts] = useState([]);
const [apiError, setApiError] = useState(false);
const [editorPost, setEditorPost] = useState(null); // null=닫힘, {}=새글, post=수정
const [isEditorOpen, setIsEditorOpen] = useState(false);
// API 글 불러오기
useEffect(() => {
getBlogPostsApi()
.then((data) => {
const posts = Array.isArray(data) ? data : (data?.posts ?? []);
setApiPosts(posts.map((p) => ({ ...p, slug: `api-${p.id}` })));
})
.catch(() => setApiError(true));
}, []);
// 정적 + API 글 병합 (API 글이 앞에 표시)
const allPosts = useMemo(() => {
const combined = [...apiPosts, ...staticPosts];
return combined.sort((a, b) => {
const aDate = Date.parse(a.date || '') || 0;
const bDate = Date.parse(b.date || '') || 0;
return bDate - aDate;
});
}, [apiPosts, staticPosts]);
const categoryNames = ['일상', '개발', '공부', '아이디어'];
const categorized = useMemo(() => {
const map = new Map(categoryNames.map((name) => [name, []]));
const misc = [];
posts.forEach((post) => {
allPosts.forEach((post) => {
const matched = categoryNames.find((name) => post.tags.includes(name));
if (matched) {
map.get(matched).push(post);
} else {
misc.push(post);
}
if (matched) map.get(matched).push(post);
else misc.push(post);
});
return {
categories: categoryNames.map((name) => ({
name,
items: map.get(name),
})),
categories: categoryNames.map((name) => ({ name, items: map.get(name) })),
misc,
};
}, [posts]);
}, [allPosts]);
const [selectedCategory, setSelectedCategory] = useState('전체');
const [page, setPage] = useState(1);
const [showList, setShowList] = useState(false);
const pageSize = 10;
const filteredPosts = useMemo(() => {
if (selectedCategory === '전체') return posts;
if (selectedCategory === '전체') return allPosts;
if (selectedCategory === '기타') return categorized.misc;
return posts.filter((post) => post.tags.includes(selectedCategory));
}, [posts, categorized.misc, selectedCategory]);
return allPosts.filter((post) => post.tags.includes(selectedCategory));
}, [allPosts, categorized.misc, selectedCategory]);
const totalPages = Math.max(1, Math.ceil(filteredPosts.length / pageSize));
const pagedPosts = filteredPosts.slice((page - 1) * pageSize, page * pageSize);
const [activeSlug, setActiveSlug] = useState(pagedPosts[0]?.slug);
const activePost =
pagedPosts.find((post) => post.slug === activeSlug) || pagedPosts[0];
const activePost = pagedPosts.find((p) => p.slug === activeSlug) || pagedPosts[0];
useEffect(() => { if (page > totalPages) setPage(1); }, [page, totalPages]);
useEffect(() => {
if (page > totalPages) {
setPage(1);
}
}, [page, totalPages]);
useEffect(() => {
if (!pagedPosts.find((post) => post.slug === activeSlug)) {
if (!pagedPosts.find((p) => p.slug === activeSlug)) {
setActiveSlug(pagedPosts[0]?.slug);
}
}, [pagedPosts, activeSlug]);
useEffect(() => { setPage(1); }, [selectedCategory]);
useEffect(() => {
setPage(1);
}, [selectedCategory]);
// 에디터 저장 핸들러
const handleSave = useCallback(async (data) => {
if (editorPost?.id) {
// 수정
const updated = await updateBlogPost(editorPost.id, data);
setApiPosts((prev) =>
prev.map((p) =>
p.id === editorPost.id ? { ...p, ...updated, slug: `api-${updated.id ?? editorPost.id}` } : p
)
);
} else {
// 새 글
const created = await createBlogPost(data);
setApiPosts((prev) => [{ ...created, slug: `api-${created.id}` }, ...prev]);
setActiveSlug(`api-${created.id}`);
}
}, [editorPost]);
// 삭제 핸들러
const handleDelete = useCallback(async (post) => {
if (!window.confirm(`"${post.title}" 글을 삭제하시겠습니까?`)) return;
await deleteBlogPost(post.id);
setApiPosts((prev) => prev.filter((p) => p.id !== post.id));
if (activeSlug === post.slug) setActiveSlug(null);
}, [activeSlug]);
const openNewEditor = () => { setEditorPost({}); setIsEditorOpen(true); };
const openEditEditor = (post) => { setEditorPost(post); setIsEditorOpen(true); };
const closeEditor = useCallback(() => { setIsEditorOpen(false); setEditorPost(null); }, []);
return (
<div className="blog">
@@ -257,15 +456,20 @@ const Blog = () => {
<p className="blog-kicker">Journal</p>
<h1>개인 블로그</h1>
<p className="blog-sub">
마크다운 파일을 추가하면 자동으로 글이 목록에 추가됩니다.
글을 작성하고 태그를 달아 정리하세요.
</p>
</div>
<div className="blog-header__actions">
<div className="blog-status">
<p className="blog-status__title">이번 주의 기록</p>
<p className="blog-status__desc">
손에 닿는 생각을 즉시 적어두고, 나중에 다시 꺼내어 다듬습니다.
</p>
</div>
<button type="button" className="blog-new-btn" onClick={openNewEditor}>
+ 쓰기
</button>
</div>
</header>
<div className="blog-grid">
@@ -283,32 +487,54 @@ const Blog = () => {
<button
key={name}
type="button"
className={`blog-category-chip${
selectedCategory === name ? ' is-active' : ''
}`}
className={`blog-category-chip${selectedCategory === name ? ' is-active' : ''}`}
onClick={() => setSelectedCategory(name)}
>
{name}
</button>
))}
</div>
{pagedPosts.map((post) => (
<button
<div
key={post.slug}
className={`blog-list__item-wrap${post.slug === activeSlug ? ' is-active' : ''}`}
>
<button
type="button"
className={`blog-list__item${
post.slug === activeSlug ? ' is-active' : ''
}`}
className="blog-list__item-btn"
onClick={() => {
setActiveSlug(post.slug);
setShowList(false); // 모바일에서 글 선택 시 리스트 숨김
setShowList(false);
}}
>
<p className="blog-list__title">{post.title}</p>
<p className="blog-list__excerpt">{post.excerpt}</p>
<span className="blog-list__meta">{post.date || '작성일 미정'}</span>
</button>
{post.id && (
<div className="blog-list__actions">
<button
type="button"
className="blog-list__action-btn"
title="수정"
onClick={() => openEditEditor(post)}
>
편집
</button>
<button
type="button"
className="blog-list__action-btn blog-list__action-btn--del"
title="삭제"
onClick={() => handleDelete(post)}
>
삭제
</button>
</div>
)}
</div>
))}
<div className="blog-pagination">
<button
type="button"
@@ -318,35 +544,41 @@ const Blog = () => {
>
이전
</button>
<span className="blog-page-indicator">
{page} / {totalPages}
</span>
<span className="blog-page-indicator">{page} / {totalPages}</span>
<button
type="button"
className="blog-page-btn"
onClick={() =>
setPage((prev) => Math.min(totalPages, prev + 1))
}
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
disabled={page === totalPages}
>
다음
</button>
</div>
</aside>
<article className="blog-article">
{activePost ? (
<>
<div className="blog-article__meta">
<span>{activePost.date || '작성일 미정'}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
{activePost.tags.length > 0 && (
<span className="blog-tags">
{activePost.tags.map((tag) => (
<span key={tag} className="blog-tag">
{tag}
</span>
<span key={tag} className="blog-tag">{tag}</span>
))}
</span>
)}
{activePost.id && (
<button
type="button"
className="blog-article__edit-btn"
onClick={() => openEditEditor(activePost)}
>
편집
</button>
)}
</div>
</div>
<div className="blog-article__body">
{renderMarkdown(activePost.body)}
@@ -354,8 +586,9 @@ const Blog = () => {
</>
) : (
<p className="blog-empty">
아직 작성된 글이 없습니다. `src/content/blog` 마크다운 파일을
추가해 주세요.
{apiError
? '블로그 API에 연결할 수 없습니다. 백엔드 서버를 확인해 주세요.'
: '아직 작성된 글이 없습니다. 새 글 쓰기 버튼으로 첫 글을 작성해 보세요.'}
</p>
)}
</article>
@@ -376,9 +609,7 @@ const Blog = () => {
>
<div className="blog-category-card__head">
<span>{group.name}</span>
<span className="blog-category-card__count">
{group.items.length}
</span>
<span className="blog-category-card__count">{group.items.length}</span>
</div>
<div className="blog-category-card__list">
{group.items.length ? (
@@ -386,9 +617,7 @@ const Blog = () => {
<span key={post.slug}>{post.title}</span>
))
) : (
<span className="blog-category-card__empty">
아직 글이 없습니다.
</span>
<span className="blog-category-card__empty">아직 글이 없습니다.</span>
)}
</div>
</button>
@@ -400,9 +629,7 @@ const Blog = () => {
>
<div className="blog-category-card__head">
<span>기타</span>
<span className="blog-category-card__count">
{categorized.misc.length}
</span>
<span className="blog-category-card__count">{categorized.misc.length}</span>
</div>
<div className="blog-category-card__list">
{categorized.misc.length ? (
@@ -410,14 +637,20 @@ const Blog = () => {
<span key={post.slug}>{post.title}</span>
))
) : (
<span className="blog-category-card__empty">
아직 글이 없습니다.
</span>
<span className="blog-category-card__empty">아직 글이 없습니다.</span>
)}
</div>
</button>
</div>
</section>
{isEditorOpen && (
<BlogEditor
post={editorPost}
onSave={handleSave}
onClose={closeEditor}
/>
)}
</div>
);
};

View File

@@ -854,6 +854,31 @@
border-color: rgba(243, 167, 167, 0.5) !important;
}
.pf-btn-sell {
color: #fbbf24 !important;
border-color: rgba(251, 191, 36, 0.5) !important;
}
.pf-sell-confirm {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.pf-sell-confirm__msg {
font-size: 12px;
color: var(--text-dim);
display: flex;
flex-direction: column;
gap: 2px;
}
.pf-sell-confirm__warn {
color: #fbbf24;
font-size: 11px;
}
.pf-null-price {
color: var(--muted) !important;
font-size: 12px !important;
@@ -964,6 +989,23 @@
text-align: right;
}
.pf-cash-edit-input {
border: 1px solid var(--neon-cyan);
border-radius: 8px;
padding: 6px 10px;
background: rgba(0, 212, 255, 0.05);
color: var(--text-bright);
font-size: 14px;
font-weight: 600;
width: 140px;
outline: none;
}
.pf-cash-edit-input:focus {
border-color: var(--neon-cyan);
box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.15);
}
.pf-cash-form {
display: grid;
grid-template-columns: 1fr 1fr auto;
@@ -1026,6 +1068,77 @@
font-size: 17px;
}
/* ── 자산 추이 차트 ─────────────────────────────────────────────────────── */
.pf-asset-history {
margin-top: 20px;
padding-top: 18px;
border-top: 1px solid var(--line-subtle);
}
.pf-asset-history__head {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.pf-asset-history__title {
font-size: 13px;
font-weight: 600;
color: var(--text-dim);
letter-spacing: 0.03em;
}
.pf-asset-history__controls {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.pf-asset-period-btn {
padding: 4px 10px;
font-size: 11px;
font-weight: 600;
border-radius: 20px;
border: 1px solid var(--line);
background: transparent;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s ease;
}
.pf-asset-period-btn:hover {
border-color: var(--neon-cyan);
color: var(--neon-cyan);
}
.pf-asset-period-btn.is-active {
background: rgba(56, 189, 248, 0.12);
border-color: var(--neon-cyan);
color: var(--neon-cyan);
}
.pf-asset-history__empty {
display: flex;
align-items: center;
justify-content: center;
height: 80px;
font-size: 12px;
color: var(--text-muted);
text-align: center;
}
@media (max-width: 768px) {
.pf-asset-history__head {
flex-direction: column;
align-items: flex-start;
}
}
@media (max-width: 768px) {
.pf-cash-form {
grid-template-columns: 1fr 1fr;

View File

@@ -13,6 +13,8 @@ import {
getVix,
getTreasury10Y,
getWTI,
getAssetHistory,
saveAssetSnapshot,
} from '../../api';
import Loading from '../../components/Loading';
import './Stock.css';
@@ -20,6 +22,7 @@ import {
PieChart, Pie, Cell,
BarChart, Bar, XAxis, YAxis, CartesianGrid,
Tooltip as ChartTooltip, Legend, ResponsiveContainer,
AreaChart, Area,
} from 'recharts';
/* ── helpers ─────────────────────────────────────────────────────── */
@@ -156,11 +159,28 @@ const StockTrade = () => {
/* Portfolio delete */
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
/* Portfolio sell */
const [sellConfirmId, setSellConfirmId] = useState(null);
const [sellLoading, setSellLoading] = useState(false);
/* Cash (예수금) form */
const [cashForm, setCashForm] = useState({ broker: '', cash: '' });
const [cashSaving, setCashSaving] = useState(false);
const [cashError, setCashError] = useState('');
/* Cash inline edit */
const [cashEditingBroker, setCashEditingBroker] = useState(null);
const [cashEditingValue, setCashEditingValue] = useState('');
const [cashEditSaving, setCashEditSaving] = useState(false);
/* ────────────────────────────────────────────────────────────── */
/* 자산 추이 state */
/* ────────────────────────────────────────────────────────────── */
const [assetHistory, setAssetHistory] = useState(null);
const [assetHistoryLoading, setAssetHistoryLoading] = useState(false);
const [assetHistoryDays, setAssetHistoryDays] = useState(30);
const [snapshotSaving, setSnapshotSaving] = useState(false);
/* ────────────────────────────────────────────────────────────── */
/* 리포트 탭 state */
/* ────────────────────────────────────────────────────────────── */
@@ -225,6 +245,59 @@ const StockTrade = () => {
}
}, []);
const loadAssetHistory = useCallback(async (days) => {
setAssetHistoryLoading(true);
try {
const data = await getAssetHistory(days);
// 백엔드 응답 키: snapshots 또는 history 모두 허용
const raw = data?.snapshots ?? data?.history ?? (Array.isArray(data) ? data : []);
// 날짜 → total_assets 맵
const byDate = {};
for (const item of raw) {
byDate[item.date] = item.total_assets ?? 0;
}
// days > 0: 오늘 기준으로 days일치 전체 날짜 생성 후 없는 날은 0 채움
// days = 0(전체): 받은 데이터만 날짜순 정렬
const toLocalDate = (d) => {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
};
let filled;
if (days > 0) {
const today = new Date();
filled = Array.from({ length: days }, (_, i) => {
const d = new Date(today);
d.setDate(today.getDate() - (days - 1 - i));
const dateStr = toLocalDate(d);
return { date: dateStr, total_assets: byDate[dateStr] ?? 0 };
});
} else {
filled = Object.entries(byDate)
.map(([date, total_assets]) => ({ date, total_assets }))
.sort((a, b) => a.date.localeCompare(b.date));
}
setAssetHistory(filled);
} catch {
setAssetHistory([]);
} finally {
setAssetHistoryLoading(false);
}
}, []);
const handleSaveSnapshot = async () => {
setSnapshotSaving(true);
try {
await saveAssetSnapshot(totalAssets != null ? Number(totalAssets) : undefined);
await loadAssetHistory(assetHistoryDays);
} catch (err) {
alert('스냅샷 저장 실패: ' + (err?.message ?? String(err)));
} finally {
setSnapshotSaving(false);
}
};
/* Lazy load: 탭 전환 시 해당 API만 호출 */
useEffect(() => {
if (activeTab === TAB_PORTFOLIO && !portfolioLoaded) {
@@ -236,6 +309,13 @@ const StockTrade = () => {
}
}, [activeTab, portfolioLoaded, balanceLoaded, loadPortfolio, loadBalance]);
/* 자산 추이: 포트폴리오 탭 첫 진입 또는 기간 변경 시 로드 */
useEffect(() => {
if (activeTab === TAB_PORTFOLIO) {
loadAssetHistory(assetHistoryDays);
}
}, [activeTab, assetHistoryDays, loadAssetHistory]);
/* AI Coach: 마운트 시 localStorage에서 API Key + 오늘 캐시 복원 */
useEffect(() => {
const savedKey = localStorage.getItem('ai_coach_key') ?? '';
@@ -386,6 +466,55 @@ const StockTrade = () => {
}
};
const handleCashInlineEdit = (item) => {
setCashEditingBroker(item.broker);
setCashEditingValue(String(item.cash ?? ''));
};
const handleCashInlineSave = async (broker) => {
if (cashEditingValue === '') return;
setCashEditSaving(true);
try {
await upsertCash(broker, Number(cashEditingValue));
setCashEditingBroker(null);
setCashEditingValue('');
await loadPortfolio();
} catch (err) {
alert('예수금 수정 실패: ' + (err?.message ?? String(err)));
} finally {
setCashEditSaving(false);
}
};
const handleCashInlineCancel = () => {
setCashEditingBroker(null);
setCashEditingValue('');
};
/* ── sell (현재가 매도) ───────────────────────────────────────── */
const handleSell = async (item) => {
const sellPrice = item.current_price ?? item.avg_price;
const qty = item.quantity ?? 0;
const saleAmount = sellPrice * qty;
const broker = item.broker ?? '';
setSellLoading(true);
try {
// 기존 예수금에 판매금액 합산
const existing = cashList.find((c) => c.broker === broker);
const newCash = (existing?.cash ?? 0) + saleAmount;
await upsertCash(broker, newCash);
await deletePortfolio(item.id);
setSellConfirmId(null);
await loadPortfolio();
} catch (err) {
alert('매도 처리 실패: ' + (err?.message ?? String(err)));
} finally {
setSellLoading(false);
}
};
/* ── report sort ─────────────────────────────────────────────── */
const handleReportSort = (field) => {
@@ -951,6 +1080,93 @@ ${holdingsText}${marketText}
)}
</div>
)}
{/* 자산 추이 차트 */}
<div className="pf-asset-history">
<div className="pf-asset-history__head">
<p className="pf-asset-history__title"> 자산 추이</p>
<div className="pf-asset-history__controls">
{[
{ label: '7일', value: 7 },
{ label: '30일', value: 30 },
{ label: '90일', value: 90 },
{ label: '전체', value: 0 },
].map(({ label, value }) => (
<button
key={value}
type="button"
className={`pf-asset-period-btn ${assetHistoryDays === value ? 'is-active' : ''}`}
onClick={() => setAssetHistoryDays(value)}
>
{label}
</button>
))}
<button
type="button"
className="button ghost small"
onClick={handleSaveSnapshot}
disabled={snapshotSaving || totalAssets == null}
title="현재 총 자산을 오늘 날짜로 저장"
>
{snapshotSaving ? '저장 중...' : '📸 스냅샷'}
</button>
</div>
</div>
{assetHistoryLoading ? (
<div className="pf-asset-history__empty">
<Loading type="spinner" message="" />
</div>
) : Array.isArray(assetHistory) && assetHistory.length >= 1 ? (
<ResponsiveContainer width="100%" height={180}>
<AreaChart
data={assetHistory}
margin={{ top: 8, right: 12, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="assetGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#38bdf8" stopOpacity={0.25} />
<stop offset="95%" stopColor="#38bdf8" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis
dataKey="date"
tick={{ fill: 'var(--text-muted)', fontSize: 10 }}
tickFormatter={(v) => v?.slice(5)}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
/>
<YAxis
hide
domain={['auto', 'auto']}
/>
<ChartTooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--line)',
borderRadius: 8,
fontSize: 12,
}}
labelStyle={{ color: 'var(--text-dim)', marginBottom: 4 }}
formatter={(v) => [`${new Intl.NumberFormat('ko-KR').format(v)}`, '총 자산']}
/>
<Area
type="monotone"
dataKey="total_assets"
stroke="#38bdf8"
strokeWidth={2}
fill="url(#assetGrad)"
dot={false}
activeDot={{ r: 4, fill: '#38bdf8' }}
/>
</AreaChart>
</ResponsiveContainer>
) : (
<div className="pf-asset-history__empty">
저장된 자산 추이 데이터가 없습니다. 📸 스냅샷 버튼으로 오늘 자산을 기록하세요.
</div>
)}
</div>
</section>
{/* 예수금 패널 */}
@@ -967,17 +1183,61 @@ ${holdingsText}${marketText}
{cashList.length > 0 && (
<div className="pf-cash-table">
{cashList.map((item) => (
{cashList.map((item) => {
const isEditing = cashEditingBroker === item.broker;
return (
<div key={item.id ?? item.broker} className="pf-cash-row">
<span className="pf-cash-broker">{item.broker}</span>
{isEditing ? (
<input
className="pf-cash-edit-input"
type="number"
min={0}
step={1}
value={cashEditingValue}
onChange={(e) => setCashEditingValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCashInlineSave(item.broker);
if (e.key === 'Escape') handleCashInlineCancel();
}}
autoFocus
/>
) : (
<strong className="pf-cash-amount">
{formatNumber(item.cash)}
</strong>
)}
<span className="pf-cash-date">
{item.updated_at
? new Date(item.updated_at).toLocaleDateString('ko-KR')
: ''}
</span>
{isEditing ? (
<>
<button
className="button primary small"
onClick={() => handleCashInlineSave(item.broker)}
disabled={cashEditSaving}
>
{cashEditSaving ? '저장 중' : '저장'}
</button>
<button
className="button ghost small"
onClick={handleCashInlineCancel}
disabled={cashEditSaving}
>
취소
</button>
</>
) : (
<>
<button
className="button ghost small"
onClick={() => handleCashInlineEdit(item)}
title="수정"
>
</button>
<button
className="button ghost small pf-btn-danger"
onClick={() => handleCashDelete(item.broker)}
@@ -985,8 +1245,11 @@ ${holdingsText}${marketText}
>
🗑
</button>
</>
)}
</div>
))}
);
})}
</div>
)}
{cashList.length === 0 && (
@@ -1087,6 +1350,9 @@ ${holdingsText}${marketText}
const profitRateN = toNumeric(profitRate);
const isEditing = editingId === item.id;
const isDeleting = deleteConfirmId === item.id;
const isSelling = sellConfirmId === item.id;
const sellPrice = item.current_price ?? item.avg_price;
const saleAmount = sellPrice != null ? sellPrice * (item.quantity ?? 0) : null;
return (
<div
@@ -1198,6 +1464,7 @@ ${holdingsText}${marketText}
</strong>
</div>
<div className="pf-item-actions">
{!isSelling && !isDeleting && (
<button
className="button ghost small"
onClick={() => handleEditStart(item)}
@@ -1205,7 +1472,33 @@ ${holdingsText}${marketText}
>
</button>
{isDeleting ? (
)}
{isSelling ? (
<div className="pf-sell-confirm">
<span className="pf-sell-confirm__msg">
{item.current_price == null && (
<small className="pf-sell-confirm__warn">현재가 미조회 매입가 기준</small>
)}
{saleAmount != null
? `${formatNumber(saleAmount)}원 매도 후 예수금 반영`
: '매도 처리'}
</span>
<button
className="button small pf-btn-sell"
onClick={() => handleSell(item)}
disabled={sellLoading}
>
{sellLoading ? '처리 중...' : '매도 확인'}
</button>
<button
className="button ghost small"
onClick={() => setSellConfirmId(null)}
disabled={sellLoading}
>
취소
</button>
</div>
) : isDeleting ? (
<>
<button
className="button ghost small pf-btn-danger"
@@ -1215,23 +1508,34 @@ ${holdingsText}${marketText}
</button>
<button
className="button ghost small"
onClick={() =>
setDeleteConfirmId(null)
}
onClick={() => setDeleteConfirmId(null)}
>
취소
</button>
</>
) : (
<>
<button
className="button ghost small pf-btn-sell"
onClick={() => {
setSellConfirmId(item.id);
setDeleteConfirmId(null);
}}
title="매도"
>
매도
</button>
<button
className="button ghost small"
onClick={() =>
setDeleteConfirmId(item.id)
}
onClick={() => {
setDeleteConfirmId(item.id);
setSellConfirmId(null);
}}
title="삭제"
>
🗑
</button>
</>
)}
</div>
</>