diff --git a/src/api.js b/src/api.js
index f13eb97..80badc0 100644
--- a/src/api.js
+++ b/src/api.js
@@ -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}`);
+}
diff --git a/src/pages/blog/Blog.css b/src/pages/blog/Blog.css
index 5e64e12..3346f08 100644
--- a/src/pages/blog/Blog.css
+++ b/src/pages/blog/Blog.css
@@ -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;
+ }
+}
diff --git a/src/pages/blog/Blog.jsx b/src/pages/blog/Blog.jsx
index 70b77f4..a23698d 100644
--- a/src/pages/blog/Blog.jsx
+++ b/src/pages/blog/Blog.jsx
@@ -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(/ /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 (
+
{ if (e.target === e.currentTarget) onClose(); }}>
+
+
+
+ {post?.id ? '글 수정' : '새 글 쓰기'}
+
+
+ ✕
+
+
+
+
setTitle(e.target.value)}
+ autoFocus
+ />
+
+
+ {PRESET_TAGS.map((tag) => (
+ toggleTag(tag)}
+ >
+ {tag}
+
+ ))}
+
+
+
+ setShowPreview(false)}
+ >
+ 편집
+
+ setShowPreview(true)}
+ >
+ 미리보기
+
+
+
+ {showPreview ? (
+
+ {body
+ ? renderMarkdown(body)
+ :
본문을 입력하면 여기에 미리보기가 표시됩니다.
+ }
+
+ ) : (
+
+
+ );
+};
+
+// ── 메인 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 (
@@ -257,14 +456,19 @@ const Blog = () => {
Journal
개인 블로그
- 마크다운 파일을 추가하면 자동으로 글이 목록에 추가됩니다.
+ 글을 작성하고 태그를 달아 정리하세요.
-
-
이번 주의 기록
-
- 손에 닿는 생각을 즉시 적어두고, 나중에 다시 꺼내어 다듬습니다.
-
+
+
+
이번 주의 기록
+
+ 손에 닿는 생각을 즉시 적어두고, 나중에 다시 꺼내어 다듬습니다.
+
+
+
+ + 새 글 쓰기
+
@@ -283,32 +487,54 @@ const Blog = () => {
setSelectedCategory(name)}
>
{name}
))}
+
{pagedPosts.map((post) => (
- {
- setActiveSlug(post.slug);
- setShowList(false); // 모바일에서 글 선택 시 리스트 숨김
- }}
+ className={`blog-list__item-wrap${post.slug === activeSlug ? ' is-active' : ''}`}
>
- {post.title}
- {post.excerpt}
- {post.date || '작성일 미정'}
-
+ {
+ setActiveSlug(post.slug);
+ setShowList(false);
+ }}
+ >
+ {post.title}
+ {post.excerpt}
+ {post.date || '작성일 미정'}
+
+ {post.id && (
+
+ openEditEditor(post)}
+ >
+ 편집
+
+ handleDelete(post)}
+ >
+ 삭제
+
+
+ )}
+
))}
+
{
>
이전
-
- {page} / {totalPages}
-
+ {page} / {totalPages}
- setPage((prev) => Math.min(totalPages, prev + 1))
- }
+ onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
disabled={page === totalPages}
>
다음
+
{activePost ? (
<>
{activePost.date || '작성일 미정'}
- {activePost.tags.length > 0 && (
-
- {activePost.tags.map((tag) => (
-
- {tag}
-
- ))}
-
- )}
+
+ {activePost.tags.length > 0 && (
+
+ {activePost.tags.map((tag) => (
+ {tag}
+ ))}
+
+ )}
+ {activePost.id && (
+ openEditEditor(activePost)}
+ >
+ 편집
+
+ )}
+
{renderMarkdown(activePost.body)}
@@ -354,8 +586,9 @@ const Blog = () => {
>
) : (
- 아직 작성된 글이 없습니다. `src/content/blog`에 마크다운 파일을
- 추가해 주세요.
+ {apiError
+ ? '블로그 API에 연결할 수 없습니다. 백엔드 서버를 확인해 주세요.'
+ : '아직 작성된 글이 없습니다. 새 글 쓰기 버튼으로 첫 글을 작성해 보세요.'}
)}
@@ -376,9 +609,7 @@ const Blog = () => {
>
{group.name}
-
- {group.items.length}건
-
+ {group.items.length}건
{group.items.length ? (
@@ -386,9 +617,7 @@ const Blog = () => {
{post.title}
))
) : (
-
- 아직 글이 없습니다.
-
+ 아직 글이 없습니다.
)}
@@ -400,9 +629,7 @@ const Blog = () => {
>
기타
-
- {categorized.misc.length}건
-
+ {categorized.misc.length}건
{categorized.misc.length ? (
@@ -410,14 +637,20 @@ const Blog = () => {
{post.title}
))
) : (
-
- 아직 글이 없습니다.
-
+ 아직 글이 없습니다.
)}
+
+ {isEditorOpen && (
+
+ )}
);
};
diff --git a/src/pages/stock/Stock.css b/src/pages/stock/Stock.css
index 464333c..9bc1bde 100644
--- a/src/pages/stock/Stock.css
+++ b/src/pages/stock/Stock.css
@@ -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;
diff --git a/src/pages/stock/StockTrade.jsx b/src/pages/stock/StockTrade.jsx
index 12bc7d6..03825b5 100644
--- a/src/pages/stock/StockTrade.jsx
+++ b/src/pages/stock/StockTrade.jsx
@@ -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}
)}
)}
+ {/* 자산 추이 차트 */}
+
+
+
총 자산 추이
+
+ {[
+ { label: '7일', value: 7 },
+ { label: '30일', value: 30 },
+ { label: '90일', value: 90 },
+ { label: '전체', value: 0 },
+ ].map(({ label, value }) => (
+ setAssetHistoryDays(value)}
+ >
+ {label}
+
+ ))}
+
+ {snapshotSaving ? '저장 중...' : '📸 스냅샷'}
+
+
+
+
+ {assetHistoryLoading ? (
+
+
+
+ ) : Array.isArray(assetHistory) && assetHistory.length >= 1 ? (
+
+
+
+
+
+
+
+
+ v?.slice(5)}
+ tickLine={false}
+ axisLine={false}
+ interval="preserveStartEnd"
+ />
+
+ [`${new Intl.NumberFormat('ko-KR').format(v)}원`, '총 자산']}
+ />
+
+
+
+ ) : (
+
+ 저장된 자산 추이 데이터가 없습니다. 📸 스냅샷 버튼으로 오늘 자산을 기록하세요.
+
+ )}
+
{/* 예수금 패널 */}
@@ -967,26 +1183,73 @@ ${holdingsText}${marketText}
{cashList.length > 0 && (
- {cashList.map((item) => (
-
- {item.broker}
-
- {formatNumber(item.cash)}원
-
-
- {item.updated_at
- ? new Date(item.updated_at).toLocaleDateString('ko-KR')
- : ''}
-
- handleCashDelete(item.broker)}
- title="삭제"
- >
- 🗑️
-
-
- ))}
+ {cashList.map((item) => {
+ const isEditing = cashEditingBroker === item.broker;
+ return (
+
+ {item.broker}
+ {isEditing ? (
+ setCashEditingValue(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') handleCashInlineSave(item.broker);
+ if (e.key === 'Escape') handleCashInlineCancel();
+ }}
+ autoFocus
+ />
+ ) : (
+
+ {formatNumber(item.cash)}원
+
+ )}
+
+ {item.updated_at
+ ? new Date(item.updated_at).toLocaleDateString('ko-KR')
+ : ''}
+
+ {isEditing ? (
+ <>
+ handleCashInlineSave(item.broker)}
+ disabled={cashEditSaving}
+ >
+ {cashEditSaving ? '저장 중' : '저장'}
+
+
+ 취소
+
+ >
+ ) : (
+ <>
+ handleCashInlineEdit(item)}
+ title="수정"
+ >
+ ✏️
+
+ handleCashDelete(item.broker)}
+ title="삭제"
+ >
+ 🗑️
+
+ >
+ )}
+
+ );
+ })}
)}
{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 (
-
handleEditStart(item)}
- title="수정"
- >
- ✏️
-
- {isDeleting ? (
+ {!isSelling && !isDeleting && (
+
handleEditStart(item)}
+ title="수정"
+ >
+ ✏️
+
+ )}
+ {isSelling ? (
+
+
+ {item.current_price == null && (
+ 현재가 미조회 — 매입가 기준
+ )}
+ {saleAmount != null
+ ? `${formatNumber(saleAmount)}원 매도 후 예수금 반영`
+ : '매도 처리'}
+
+ handleSell(item)}
+ disabled={sellLoading}
+ >
+ {sellLoading ? '처리 중...' : '매도 확인'}
+
+ setSellConfirmId(null)}
+ disabled={sellLoading}
+ >
+ 취소
+
+
+ ) : isDeleting ? (
<>
- setDeleteConfirmId(null)
- }
+ onClick={() => setDeleteConfirmId(null)}
>
취소
>
) : (
-
- setDeleteConfirmId(item.id)
- }
- title="삭제"
- >
- 🗑️
-
+ <>
+ {
+ setSellConfirmId(item.id);
+ setDeleteConfirmId(null);
+ }}
+ title="매도"
+ >
+ 매도
+
+ {
+ setDeleteConfirmId(item.id);
+ setSellConfirmId(null);
+ }}
+ title="삭제"
+ >
+ 🗑️
+
+ >
)}
>