주식 히스토리 API 및 블로그 작성 API 추가
This commit is contained in:
38
src/api.js
38
src/api.js
@@ -137,6 +137,22 @@ export function deletePortfolio(id) {
|
|||||||
return apiDelete(`/api/portfolio/${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 ───────────────────────────────────────────────────────────────
|
// ── 예수금 API ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function upsertCash(broker, cash) {
|
export function upsertCash(broker, cash) {
|
||||||
@@ -204,3 +220,25 @@ export function deleteTodo(id) {
|
|||||||
export function clearTodos() {
|
export function clearTodos() {
|
||||||
return apiDelete('/api/todos/done');
|
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}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,30 @@
|
|||||||
align-items: center;
|
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 {
|
.blog-kicker {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.3em;
|
letter-spacing: 0.3em;
|
||||||
@@ -56,23 +80,27 @@
|
|||||||
.blog-toggle-list {
|
.blog-toggle-list {
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 20px;
|
/* 사이드바 토글 버튼(top-left) 과 겹치지 않도록 오른쪽 하단 배치 */
|
||||||
left: 20px;
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
top: auto;
|
||||||
|
left: auto;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
width: 40px;
|
width: 44px;
|
||||||
height: 40px;
|
height: 44px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid rgba(192, 132, 252, 0.45);
|
||||||
background: rgba(10, 12, 20, 0.8);
|
background: rgba(10, 12, 20, 0.88);
|
||||||
color: var(--text);
|
color: var(--accent-blog);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
cursor: pointer;
|
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;
|
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-toggle-list:hover {
|
.blog-toggle-list:hover {
|
||||||
transform: scale(1.1);
|
transform: scale(1.08);
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,31 +131,84 @@
|
|||||||
color: var(--accent-blog);
|
color: var(--accent-blog);
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-list__item {
|
.blog-list__item-wrap {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
padding: 16px;
|
|
||||||
border-radius: var(--radius-md);
|
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;
|
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
|
||||||
box-shadow: var(--shadow-inset);
|
box-shadow: var(--shadow-inset);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-list__item:hover {
|
.blog-list__item-wrap:hover {
|
||||||
border-color: var(--line-strong);
|
border-color: var(--line-strong);
|
||||||
background: var(--surface-raised);
|
background: var(--surface-raised);
|
||||||
box-shadow: var(--shadow-sm), var(--shadow-inset);
|
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);
|
border-color: rgba(192, 132, 252, 0.5);
|
||||||
box-shadow: 0 4px 20px rgba(192, 132, 252, 0.12), var(--shadow-inset);
|
box-shadow: 0 4px 20px rgba(192, 132, 252, 0.12), var(--shadow-inset);
|
||||||
background: rgba(192, 132, 252, 0.05);
|
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 {
|
.blog-pagination {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -376,6 +457,12 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.blog-header__actions {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.blog-toggle-list {
|
.blog-toggle-list {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -427,7 +514,7 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-list__item {
|
.blog-list__item-btn {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,3 +563,207 @@
|
|||||||
padding: 16px;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { getBlogPosts } from '../../data/blog';
|
||||||
|
import {
|
||||||
|
getBlogPostsApi,
|
||||||
|
createBlogPost,
|
||||||
|
updateBlogPost,
|
||||||
|
deleteBlogPost,
|
||||||
|
} from '../../api';
|
||||||
import './Blog.css';
|
import './Blog.css';
|
||||||
|
|
||||||
|
// ── 마크다운 렌더러 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const renderInline = (text) => {
|
const renderInline = (text) => {
|
||||||
const normalized = text.replace(/<br\s*\/?>/gi, '\n');
|
const normalized = text.replace(/<br\s*\/?>/gi, '\n');
|
||||||
const pattern =
|
const pattern =
|
||||||
@@ -122,9 +130,7 @@ const renderMarkdown = (body) => {
|
|||||||
|
|
||||||
flushList();
|
flushList();
|
||||||
|
|
||||||
if (!line.trim()) {
|
if (!line.trim()) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (line.startsWith('###### ')) {
|
if (line.startsWith('###### ')) {
|
||||||
blocks.push({ type: 'h6', value: line.replace(/^######\s+/, '') });
|
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="마크다운으로 글을 작성하세요... 예시: # 제목 ## 소제목 **굵게** *기울임* `코드`"
|
||||||
|
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 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 categoryNames = ['일상', '개발', '공부', '아이디어'];
|
||||||
const categorized = useMemo(() => {
|
const categorized = useMemo(() => {
|
||||||
const map = new Map(categoryNames.map((name) => [name, []]));
|
const map = new Map(categoryNames.map((name) => [name, []]));
|
||||||
const misc = [];
|
const misc = [];
|
||||||
|
allPosts.forEach((post) => {
|
||||||
posts.forEach((post) => {
|
|
||||||
const matched = categoryNames.find((name) => post.tags.includes(name));
|
const matched = categoryNames.find((name) => post.tags.includes(name));
|
||||||
if (matched) {
|
if (matched) map.get(matched).push(post);
|
||||||
map.get(matched).push(post);
|
else misc.push(post);
|
||||||
} else {
|
|
||||||
misc.push(post);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
categories: categoryNames.map((name) => ({
|
categories: categoryNames.map((name) => ({ name, items: map.get(name) })),
|
||||||
name,
|
|
||||||
items: map.get(name),
|
|
||||||
})),
|
|
||||||
misc,
|
misc,
|
||||||
};
|
};
|
||||||
}, [posts]);
|
}, [allPosts]);
|
||||||
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState('전체');
|
const [selectedCategory, setSelectedCategory] = useState('전체');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [showList, setShowList] = useState(false);
|
const [showList, setShowList] = useState(false);
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
|
|
||||||
const filteredPosts = useMemo(() => {
|
const filteredPosts = useMemo(() => {
|
||||||
if (selectedCategory === '전체') return posts;
|
if (selectedCategory === '전체') return allPosts;
|
||||||
if (selectedCategory === '기타') return categorized.misc;
|
if (selectedCategory === '기타') return categorized.misc;
|
||||||
return posts.filter((post) => post.tags.includes(selectedCategory));
|
return allPosts.filter((post) => post.tags.includes(selectedCategory));
|
||||||
}, [posts, categorized.misc, selectedCategory]);
|
}, [allPosts, categorized.misc, selectedCategory]);
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(filteredPosts.length / pageSize));
|
const totalPages = Math.max(1, Math.ceil(filteredPosts.length / pageSize));
|
||||||
const pagedPosts = filteredPosts.slice((page - 1) * pageSize, page * pageSize);
|
const pagedPosts = filteredPosts.slice((page - 1) * pageSize, page * pageSize);
|
||||||
|
|
||||||
const [activeSlug, setActiveSlug] = useState(pagedPosts[0]?.slug);
|
const [activeSlug, setActiveSlug] = useState(pagedPosts[0]?.slug);
|
||||||
const activePost =
|
const activePost = pagedPosts.find((p) => p.slug === activeSlug) || pagedPosts[0];
|
||||||
pagedPosts.find((post) => post.slug === activeSlug) || pagedPosts[0];
|
|
||||||
|
|
||||||
|
useEffect(() => { if (page > totalPages) setPage(1); }, [page, totalPages]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (page > totalPages) {
|
if (!pagedPosts.find((p) => p.slug === activeSlug)) {
|
||||||
setPage(1);
|
|
||||||
}
|
|
||||||
}, [page, totalPages]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!pagedPosts.find((post) => post.slug === activeSlug)) {
|
|
||||||
setActiveSlug(pagedPosts[0]?.slug);
|
setActiveSlug(pagedPosts[0]?.slug);
|
||||||
}
|
}
|
||||||
}, [pagedPosts, activeSlug]);
|
}, [pagedPosts, activeSlug]);
|
||||||
|
useEffect(() => { setPage(1); }, [selectedCategory]);
|
||||||
|
|
||||||
useEffect(() => {
|
// 에디터 저장 핸들러
|
||||||
setPage(1);
|
const handleSave = useCallback(async (data) => {
|
||||||
}, [selectedCategory]);
|
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 (
|
return (
|
||||||
<div className="blog">
|
<div className="blog">
|
||||||
@@ -257,14 +456,19 @@ const Blog = () => {
|
|||||||
<p className="blog-kicker">Journal</p>
|
<p className="blog-kicker">Journal</p>
|
||||||
<h1>개인 블로그</h1>
|
<h1>개인 블로그</h1>
|
||||||
<p className="blog-sub">
|
<p className="blog-sub">
|
||||||
마크다운 파일을 추가하면 자동으로 글이 목록에 추가됩니다.
|
글을 작성하고 태그를 달아 정리하세요.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="blog-status">
|
<div className="blog-header__actions">
|
||||||
<p className="blog-status__title">이번 주의 기록</p>
|
<div className="blog-status">
|
||||||
<p className="blog-status__desc">
|
<p className="blog-status__title">이번 주의 기록</p>
|
||||||
손에 닿는 생각을 즉시 적어두고, 나중에 다시 꺼내어 다듬습니다.
|
<p className="blog-status__desc">
|
||||||
</p>
|
손에 닿는 생각을 즉시 적어두고, 나중에 다시 꺼내어 다듬습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="blog-new-btn" onClick={openNewEditor}>
|
||||||
|
+ 새 글 쓰기
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -283,32 +487,54 @@ const Blog = () => {
|
|||||||
<button
|
<button
|
||||||
key={name}
|
key={name}
|
||||||
type="button"
|
type="button"
|
||||||
className={`blog-category-chip${
|
className={`blog-category-chip${selectedCategory === name ? ' is-active' : ''}`}
|
||||||
selectedCategory === name ? ' is-active' : ''
|
|
||||||
}`}
|
|
||||||
onClick={() => setSelectedCategory(name)}
|
onClick={() => setSelectedCategory(name)}
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pagedPosts.map((post) => (
|
{pagedPosts.map((post) => (
|
||||||
<button
|
<div
|
||||||
key={post.slug}
|
key={post.slug}
|
||||||
type="button"
|
className={`blog-list__item-wrap${post.slug === activeSlug ? ' is-active' : ''}`}
|
||||||
className={`blog-list__item${
|
|
||||||
post.slug === activeSlug ? ' is-active' : ''
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
|
||||||
setActiveSlug(post.slug);
|
|
||||||
setShowList(false); // 모바일에서 글 선택 시 리스트 숨김
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<p className="blog-list__title">{post.title}</p>
|
<button
|
||||||
<p className="blog-list__excerpt">{post.excerpt}</p>
|
type="button"
|
||||||
<span className="blog-list__meta">{post.date || '작성일 미정'}</span>
|
className="blog-list__item-btn"
|
||||||
</button>
|
onClick={() => {
|
||||||
|
setActiveSlug(post.slug);
|
||||||
|
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">
|
<div className="blog-pagination">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -318,35 +544,41 @@ const Blog = () => {
|
|||||||
>
|
>
|
||||||
이전
|
이전
|
||||||
</button>
|
</button>
|
||||||
<span className="blog-page-indicator">
|
<span className="blog-page-indicator">{page} / {totalPages}</span>
|
||||||
{page} / {totalPages}
|
|
||||||
</span>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="blog-page-btn"
|
className="blog-page-btn"
|
||||||
onClick={() =>
|
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
|
||||||
setPage((prev) => Math.min(totalPages, prev + 1))
|
|
||||||
}
|
|
||||||
disabled={page === totalPages}
|
disabled={page === totalPages}
|
||||||
>
|
>
|
||||||
다음
|
다음
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<article className="blog-article">
|
<article className="blog-article">
|
||||||
{activePost ? (
|
{activePost ? (
|
||||||
<>
|
<>
|
||||||
<div className="blog-article__meta">
|
<div className="blog-article__meta">
|
||||||
<span>{activePost.date || '작성일 미정'}</span>
|
<span>{activePost.date || '작성일 미정'}</span>
|
||||||
{activePost.tags.length > 0 && (
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
<span className="blog-tags">
|
{activePost.tags.length > 0 && (
|
||||||
{activePost.tags.map((tag) => (
|
<span className="blog-tags">
|
||||||
<span key={tag} className="blog-tag">
|
{activePost.tags.map((tag) => (
|
||||||
{tag}
|
<span key={tag} className="blog-tag">{tag}</span>
|
||||||
</span>
|
))}
|
||||||
))}
|
</span>
|
||||||
</span>
|
)}
|
||||||
)}
|
{activePost.id && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="blog-article__edit-btn"
|
||||||
|
onClick={() => openEditEditor(activePost)}
|
||||||
|
>
|
||||||
|
편집
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="blog-article__body">
|
<div className="blog-article__body">
|
||||||
{renderMarkdown(activePost.body)}
|
{renderMarkdown(activePost.body)}
|
||||||
@@ -354,8 +586,9 @@ const Blog = () => {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="blog-empty">
|
<p className="blog-empty">
|
||||||
아직 작성된 글이 없습니다. `src/content/blog`에 마크다운 파일을
|
{apiError
|
||||||
추가해 주세요.
|
? '블로그 API에 연결할 수 없습니다. 백엔드 서버를 확인해 주세요.'
|
||||||
|
: '아직 작성된 글이 없습니다. 새 글 쓰기 버튼으로 첫 글을 작성해 보세요.'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
@@ -376,9 +609,7 @@ const Blog = () => {
|
|||||||
>
|
>
|
||||||
<div className="blog-category-card__head">
|
<div className="blog-category-card__head">
|
||||||
<span>{group.name}</span>
|
<span>{group.name}</span>
|
||||||
<span className="blog-category-card__count">
|
<span className="blog-category-card__count">{group.items.length}건</span>
|
||||||
{group.items.length}건
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="blog-category-card__list">
|
<div className="blog-category-card__list">
|
||||||
{group.items.length ? (
|
{group.items.length ? (
|
||||||
@@ -386,9 +617,7 @@ const Blog = () => {
|
|||||||
<span key={post.slug}>{post.title}</span>
|
<span key={post.slug}>{post.title}</span>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<span className="blog-category-card__empty">
|
<span className="blog-category-card__empty">아직 글이 없습니다.</span>
|
||||||
아직 글이 없습니다.
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -400,9 +629,7 @@ const Blog = () => {
|
|||||||
>
|
>
|
||||||
<div className="blog-category-card__head">
|
<div className="blog-category-card__head">
|
||||||
<span>기타</span>
|
<span>기타</span>
|
||||||
<span className="blog-category-card__count">
|
<span className="blog-category-card__count">{categorized.misc.length}건</span>
|
||||||
{categorized.misc.length}건
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="blog-category-card__list">
|
<div className="blog-category-card__list">
|
||||||
{categorized.misc.length ? (
|
{categorized.misc.length ? (
|
||||||
@@ -410,14 +637,20 @@ const Blog = () => {
|
|||||||
<span key={post.slug}>{post.title}</span>
|
<span key={post.slug}>{post.title}</span>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<span className="blog-category-card__empty">
|
<span className="blog-category-card__empty">아직 글이 없습니다.</span>
|
||||||
아직 글이 없습니다.
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{isEditorOpen && (
|
||||||
|
<BlogEditor
|
||||||
|
post={editorPost}
|
||||||
|
onSave={handleSave}
|
||||||
|
onClose={closeEditor}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -854,6 +854,31 @@
|
|||||||
border-color: rgba(243, 167, 167, 0.5) !important;
|
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 {
|
.pf-null-price {
|
||||||
color: var(--muted) !important;
|
color: var(--muted) !important;
|
||||||
font-size: 12px !important;
|
font-size: 12px !important;
|
||||||
@@ -964,6 +989,23 @@
|
|||||||
text-align: right;
|
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 {
|
.pf-cash-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr auto;
|
grid-template-columns: 1fr 1fr auto;
|
||||||
@@ -1026,6 +1068,77 @@
|
|||||||
font-size: 17px;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.pf-cash-form {
|
.pf-cash-form {
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
getVix,
|
getVix,
|
||||||
getTreasury10Y,
|
getTreasury10Y,
|
||||||
getWTI,
|
getWTI,
|
||||||
|
getAssetHistory,
|
||||||
|
saveAssetSnapshot,
|
||||||
} from '../../api';
|
} from '../../api';
|
||||||
import Loading from '../../components/Loading';
|
import Loading from '../../components/Loading';
|
||||||
import './Stock.css';
|
import './Stock.css';
|
||||||
@@ -20,6 +22,7 @@ import {
|
|||||||
PieChart, Pie, Cell,
|
PieChart, Pie, Cell,
|
||||||
BarChart, Bar, XAxis, YAxis, CartesianGrid,
|
BarChart, Bar, XAxis, YAxis, CartesianGrid,
|
||||||
Tooltip as ChartTooltip, Legend, ResponsiveContainer,
|
Tooltip as ChartTooltip, Legend, ResponsiveContainer,
|
||||||
|
AreaChart, Area,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
|
|
||||||
/* ── helpers ─────────────────────────────────────────────────────── */
|
/* ── helpers ─────────────────────────────────────────────────────── */
|
||||||
@@ -156,11 +159,28 @@ const StockTrade = () => {
|
|||||||
/* Portfolio delete */
|
/* Portfolio delete */
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
|
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
|
||||||
|
|
||||||
|
/* Portfolio sell */
|
||||||
|
const [sellConfirmId, setSellConfirmId] = useState(null);
|
||||||
|
const [sellLoading, setSellLoading] = useState(false);
|
||||||
|
|
||||||
/* Cash (예수금) form */
|
/* Cash (예수금) form */
|
||||||
const [cashForm, setCashForm] = useState({ broker: '', cash: '' });
|
const [cashForm, setCashForm] = useState({ broker: '', cash: '' });
|
||||||
const [cashSaving, setCashSaving] = useState(false);
|
const [cashSaving, setCashSaving] = useState(false);
|
||||||
const [cashError, setCashError] = useState('');
|
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 */
|
/* 리포트 탭 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만 호출 */
|
/* Lazy load: 탭 전환 시 해당 API만 호출 */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === TAB_PORTFOLIO && !portfolioLoaded) {
|
if (activeTab === TAB_PORTFOLIO && !portfolioLoaded) {
|
||||||
@@ -236,6 +309,13 @@ const StockTrade = () => {
|
|||||||
}
|
}
|
||||||
}, [activeTab, portfolioLoaded, balanceLoaded, loadPortfolio, loadBalance]);
|
}, [activeTab, portfolioLoaded, balanceLoaded, loadPortfolio, loadBalance]);
|
||||||
|
|
||||||
|
/* 자산 추이: 포트폴리오 탭 첫 진입 또는 기간 변경 시 로드 */
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === TAB_PORTFOLIO) {
|
||||||
|
loadAssetHistory(assetHistoryDays);
|
||||||
|
}
|
||||||
|
}, [activeTab, assetHistoryDays, loadAssetHistory]);
|
||||||
|
|
||||||
/* AI Coach: 마운트 시 localStorage에서 API Key + 오늘 캐시 복원 */
|
/* AI Coach: 마운트 시 localStorage에서 API Key + 오늘 캐시 복원 */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedKey = localStorage.getItem('ai_coach_key') ?? '';
|
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 ─────────────────────────────────────────────── */
|
/* ── report sort ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
const handleReportSort = (field) => {
|
const handleReportSort = (field) => {
|
||||||
@@ -951,6 +1080,93 @@ ${holdingsText}${marketText}
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</section>
|
||||||
|
|
||||||
{/* 예수금 패널 */}
|
{/* 예수금 패널 */}
|
||||||
@@ -967,26 +1183,73 @@ ${holdingsText}${marketText}
|
|||||||
|
|
||||||
{cashList.length > 0 && (
|
{cashList.length > 0 && (
|
||||||
<div className="pf-cash-table">
|
<div className="pf-cash-table">
|
||||||
{cashList.map((item) => (
|
{cashList.map((item) => {
|
||||||
<div key={item.id ?? item.broker} className="pf-cash-row">
|
const isEditing = cashEditingBroker === item.broker;
|
||||||
<span className="pf-cash-broker">{item.broker}</span>
|
return (
|
||||||
<strong className="pf-cash-amount">
|
<div key={item.id ?? item.broker} className="pf-cash-row">
|
||||||
{formatNumber(item.cash)}원
|
<span className="pf-cash-broker">{item.broker}</span>
|
||||||
</strong>
|
{isEditing ? (
|
||||||
<span className="pf-cash-date">
|
<input
|
||||||
{item.updated_at
|
className="pf-cash-edit-input"
|
||||||
? new Date(item.updated_at).toLocaleDateString('ko-KR')
|
type="number"
|
||||||
: ''}
|
min={0}
|
||||||
</span>
|
step={1}
|
||||||
<button
|
value={cashEditingValue}
|
||||||
className="button ghost small pf-btn-danger"
|
onChange={(e) => setCashEditingValue(e.target.value)}
|
||||||
onClick={() => handleCashDelete(item.broker)}
|
onKeyDown={(e) => {
|
||||||
title="삭제"
|
if (e.key === 'Enter') handleCashInlineSave(item.broker);
|
||||||
>
|
if (e.key === 'Escape') handleCashInlineCancel();
|
||||||
🗑️
|
}}
|
||||||
</button>
|
autoFocus
|
||||||
</div>
|
/>
|
||||||
))}
|
) : (
|
||||||
|
<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)}
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{cashList.length === 0 && (
|
{cashList.length === 0 && (
|
||||||
@@ -1087,6 +1350,9 @@ ${holdingsText}${marketText}
|
|||||||
const profitRateN = toNumeric(profitRate);
|
const profitRateN = toNumeric(profitRate);
|
||||||
const isEditing = editingId === item.id;
|
const isEditing = editingId === item.id;
|
||||||
const isDeleting = deleteConfirmId === 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -1198,14 +1464,41 @@ ${holdingsText}${marketText}
|
|||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="pf-item-actions">
|
<div className="pf-item-actions">
|
||||||
<button
|
{!isSelling && !isDeleting && (
|
||||||
className="button ghost small"
|
<button
|
||||||
onClick={() => handleEditStart(item)}
|
className="button ghost small"
|
||||||
title="수정"
|
onClick={() => handleEditStart(item)}
|
||||||
>
|
title="수정"
|
||||||
✏️
|
>
|
||||||
</button>
|
✏️
|
||||||
{isDeleting ? (
|
</button>
|
||||||
|
)}
|
||||||
|
{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
|
<button
|
||||||
className="button ghost small pf-btn-danger"
|
className="button ghost small pf-btn-danger"
|
||||||
@@ -1215,23 +1508,34 @@ ${holdingsText}${marketText}
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="button ghost small"
|
className="button ghost small"
|
||||||
onClick={() =>
|
onClick={() => setDeleteConfirmId(null)}
|
||||||
setDeleteConfirmId(null)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<>
|
||||||
className="button ghost small"
|
<button
|
||||||
onClick={() =>
|
className="button ghost small pf-btn-sell"
|
||||||
setDeleteConfirmId(item.id)
|
onClick={() => {
|
||||||
}
|
setSellConfirmId(item.id);
|
||||||
title="삭제"
|
setDeleteConfirmId(null);
|
||||||
>
|
}}
|
||||||
🗑️
|
title="매도"
|
||||||
</button>
|
>
|
||||||
|
매도
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteConfirmId(item.id);
|
||||||
|
setSellConfirmId(null);
|
||||||
|
}}
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user