feat: expand live chat and work server tools
This commit is contained in:
@@ -7,8 +7,33 @@
|
||||
- 컴포넌트 샘플 레이아웃
|
||||
- 위젯 샘플 레이아웃
|
||||
- Markdown preview 리스트 레이아웃
|
||||
- `Layout Editor`와 저장 레이아웃 흐름
|
||||
|
||||
## 규칙
|
||||
|
||||
- 공통 레이아웃 시스템이 아니라 현재 프로젝트 화면에 종속되면 `features/layout`에 배치
|
||||
- 공통 재사용 가능성이 높으면 `components` 또는 `widgets`로 승격 검토
|
||||
|
||||
## Layout Editor 기준
|
||||
|
||||
`Layout Editor`는 위젯 자체의 기본 기능을 정의하는 화면이 아닙니다. 이 화면은 레이아웃 안에 배치된 컴포넌트가 현재 화면에서 어떤 역할을 해야 하는지 기술하는 편집기입니다.
|
||||
|
||||
용어 기준:
|
||||
|
||||
- `컴포넌트 기능 명세`: 현재 레이아웃에 배치된 컴포넌트의 화면 동작, 상호작용, 구현 포인트를 적는 항목
|
||||
- `저장 레이아웃`: 구조, 배치, 기능 명세를 함께 저장한 편집 결과
|
||||
- `Codex 요청`: 저장된 레이아웃과 그 안의 기능 명세를 기준으로 구현 요청을 만드는 액션
|
||||
|
||||
허용 범위:
|
||||
|
||||
- 컴포넌트 간 이벤트 전달, 상태 동기화, focus/selection 이동, 표시/숨김 제어를 기능 명세로 기술할 수 있다
|
||||
- 한 컴포넌트의 입력/액션이 다른 컴포넌트 목록, 상세, 요약, CTA 상태를 바꾸는 흐름을 기술할 수 있다
|
||||
- 서버 저장, 조회, 갱신, 삭제 같은 API 연결과 그 응답이 다른 컴포넌트에 반영되는 흐름도 기능 명세 범위에 포함한다
|
||||
|
||||
금지 해석:
|
||||
|
||||
- 위젯 또는 컴포넌트 샘플 메타에서 "기본 기능"을 자동으로 끌어와 `Layout Editor` 기능 명세처럼 취급하지 않는다
|
||||
- 사용자가 직접 추가하지 않은 항목을 기능 명세 목록에 자동 주입하지 않는다
|
||||
- 레이아웃 기능 명세와 위젯 고유 스펙 문서를 같은 개념으로 섞지 않는다
|
||||
|
||||
구현/문서 작성 시에는 항상 "이 기능은 위젯의 본래 능력 설명인가, 아니면 현재 레이아웃에서 그 컴포넌트가 수행할 역할 설명인가"를 먼저 구분합니다. `Layout Editor` 문맥에서는 후자만 다룹니다.
|
||||
|
||||
240
src/features/layout/memo/MemoLayoutPage.css
Normal file
240
src/features/layout/memo/MemoLayoutPage.css
Normal file
@@ -0,0 +1,240 @@
|
||||
.memo-layout-page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(250, 204, 21, 0.18), transparent 30%),
|
||||
linear-gradient(180deg, #fbfdff 0%, #f4f7fb 100%);
|
||||
}
|
||||
|
||||
.memo-layout-page__splitter,
|
||||
.memo-layout-page__splitter .ant-splitter-panel {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.memo-layout-page__pane {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 18px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.memo-layout-page__pane--title {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.memo-layout-page__title-input.ant-input {
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 24px 26px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
border-radius: 28px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
box-shadow:
|
||||
0 22px 48px rgba(15, 23, 42, 0.08),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.95);
|
||||
color: #0f172a;
|
||||
font-size: clamp(26px, 4vw, 40px);
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.memo-layout-page__title-input.ant-input::placeholder {
|
||||
color: rgba(100, 116, 139, 0.7);
|
||||
}
|
||||
|
||||
.memo-layout-page__pane--memo {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.memo-layout-page__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.memo-layout-page__toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.memo-layout-page__toolbar .ant-btn {
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 12px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.memo-layout-page__toolbar .ant-btn:not(:disabled):hover {
|
||||
color: #0f172a;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.memo-layout-page__body {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.memo-layout-page__body--list-open .memo-layout-page__editor {
|
||||
border-top-left-radius: 22px;
|
||||
border-bottom-left-radius: 22px;
|
||||
}
|
||||
|
||||
.memo-layout-page__list-shell {
|
||||
flex: 0 0 260px;
|
||||
min-width: 220px;
|
||||
min-height: 0;
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.memo-layout-page__empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.memo-layout-page__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.memo-layout-page__list-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 0;
|
||||
border-radius: 18px;
|
||||
background: rgba(248, 250, 252, 0.96);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.memo-layout-page__list-item:hover {
|
||||
background: rgba(241, 245, 249, 1);
|
||||
}
|
||||
|
||||
.memo-layout-page__list-item--active {
|
||||
background: rgba(254, 240, 138, 0.42);
|
||||
}
|
||||
|
||||
.memo-layout-page__list-time {
|
||||
color: rgba(100, 116, 139, 0.94);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.memo-layout-page__list-preview {
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.memo-layout-page__editor {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 1px solid rgba(245, 158, 11, 0.18);
|
||||
border-radius: 28px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.74), rgba(255, 255, 255, 0.42)),
|
||||
repeating-linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 248, 216, 0.98) 0,
|
||||
rgba(255, 248, 216, 0.98) 37px,
|
||||
rgba(236, 221, 177, 0.78) 37px,
|
||||
rgba(236, 221, 177, 0.78) 38px
|
||||
);
|
||||
box-shadow:
|
||||
0 18px 44px rgba(15, 23, 42, 0.08),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.memo-layout-page__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 28px;
|
||||
padding: 14px 18px 0;
|
||||
color: rgba(100, 116, 139, 0.92);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.memo-layout-page__meta > :first-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.memo-layout-page__textarea.ant-input {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 10px 18px 32px;
|
||||
color: #3f3a2f;
|
||||
font-size: 16px;
|
||||
line-height: 38px;
|
||||
background: transparent;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.memo-layout-page__textarea.ant-input::placeholder {
|
||||
color: rgba(120, 113, 91, 0.72);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.memo-layout-page__pane {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.memo-layout-page__body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.memo-layout-page__list-shell {
|
||||
flex: 0 0 180px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.memo-layout-page__title-input.ant-input {
|
||||
padding: 18px 20px;
|
||||
border-radius: 22px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.memo-layout-page__editor {
|
||||
border-radius: 22px;
|
||||
}
|
||||
}
|
||||
345
src/features/layout/memo/MemoLayoutPage.tsx
Normal file
345
src/features/layout/memo/MemoLayoutPage.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
import {
|
||||
CheckOutlined,
|
||||
DeleteOutlined,
|
||||
LeftOutlined,
|
||||
PlusOutlined,
|
||||
RightOutlined,
|
||||
SaveOutlined,
|
||||
UnorderedListOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Button, Empty, Input, Modal, Splitter, message } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { renderModalWithEnterConfirm } from '../../../app/main/modalKeyboard';
|
||||
import { InputUI } from '../../../components/inputs/primitives/input';
|
||||
import {
|
||||
createTextMemoNote,
|
||||
deleteTextMemoNote,
|
||||
fetchTextMemoNotes,
|
||||
updateTextMemoNote,
|
||||
type TextMemoNoteRecord,
|
||||
} from '../../../widgets/text-memo-widget/textMemoApi';
|
||||
import './MemoLayoutPage.css';
|
||||
|
||||
type MemoLayoutPageProps = {
|
||||
layoutId: string;
|
||||
};
|
||||
|
||||
type MemoNote = TextMemoNoteRecord;
|
||||
|
||||
const PRIMARY_SIZE = '42%';
|
||||
const PRIMARY_MIN = '24%';
|
||||
const SECONDARY_MIN = '20%';
|
||||
const MAX_NOTE_COUNT = 12;
|
||||
const MAX_BODY_LENGTH = 1200;
|
||||
|
||||
function getFirstLine(value: string) {
|
||||
const [firstLine = ''] = value.split(/\r?\n/u);
|
||||
return firstLine;
|
||||
}
|
||||
|
||||
function replaceFirstLine(body: string, nextTitle: string) {
|
||||
const normalizedTitle = nextTitle.trim();
|
||||
const lineBreakIndex = body.search(/\r?\n/u);
|
||||
|
||||
if (lineBreakIndex < 0) {
|
||||
return normalizedTitle;
|
||||
}
|
||||
|
||||
const nextTail = body.slice(lineBreakIndex);
|
||||
return `${normalizedTitle}${nextTail}`;
|
||||
}
|
||||
|
||||
function getPreviewText(body: string) {
|
||||
const preview = body.replace(/\s+/gu, ' ').trim();
|
||||
return preview || '새 메모';
|
||||
}
|
||||
|
||||
function formatMemoTimestamp(value: string) {
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export function MemoLayoutPage({ layoutId }: MemoLayoutPageProps) {
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [modalApi, modalContextHolder] = Modal.useModal();
|
||||
const [notes, setNotes] = useState<MemoNote[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [body, setBody] = useState('');
|
||||
const [isListOpen, setIsListOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const items = await fetchTextMemoNotes();
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setNotes(items);
|
||||
|
||||
if (items[0]) {
|
||||
setSelectedId(items[0].id);
|
||||
setBody(items[0].body);
|
||||
} else {
|
||||
setSelectedId(null);
|
||||
setBody('');
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
void messageApi.error(error instanceof Error ? error.message : '메모를 불러오지 못했습니다.');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [messageApi]);
|
||||
|
||||
const selectedIndex = useMemo(
|
||||
() => (selectedId ? notes.findIndex((note) => note.id === selectedId) : -1),
|
||||
[notes, selectedId],
|
||||
);
|
||||
const selectedNote = selectedIndex >= 0 ? notes[selectedIndex] : null;
|
||||
const inputValue = getFirstLine(body);
|
||||
const hasDraft = body.trim().length > 0;
|
||||
const isDirty = selectedNote ? selectedNote.body !== body : hasDraft;
|
||||
|
||||
const selectNote = (noteId: string) => {
|
||||
const nextNote = notes.find((item) => item.id === noteId);
|
||||
|
||||
if (!nextNote) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedId(nextNote.id);
|
||||
setBody(nextNote.body);
|
||||
};
|
||||
|
||||
const moveSelection = (direction: -1 | 1) => {
|
||||
if (notes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackIndex = direction > 0 ? 0 : notes.length - 1;
|
||||
const nextIndex = selectedIndex < 0 ? fallbackIndex : (selectedIndex + direction + notes.length) % notes.length;
|
||||
const nextNote = notes[nextIndex];
|
||||
|
||||
if (!nextNote) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedId(nextNote.id);
|
||||
setBody(nextNote.body);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setSelectedId(null);
|
||||
setBody('');
|
||||
setIsListOpen(false);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmedBody = body.trim();
|
||||
|
||||
if (!trimmedBody || isSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
if (selectedNote) {
|
||||
const updated = await updateTextMemoNote(selectedNote.id, { body: trimmedBody });
|
||||
const nextNotes = [updated, ...notes.filter((note) => note.id !== updated.id)].slice(0, MAX_NOTE_COUNT);
|
||||
|
||||
setNotes(nextNotes);
|
||||
setSelectedId(updated.id);
|
||||
setBody(updated.body);
|
||||
} else {
|
||||
const created = await createTextMemoNote({ body: trimmedBody });
|
||||
const nextNotes = [created, ...notes].slice(0, MAX_NOTE_COUNT);
|
||||
|
||||
setNotes(nextNotes);
|
||||
setSelectedId(created.id);
|
||||
setBody(created.body);
|
||||
}
|
||||
|
||||
void messageApi.success('저장됨');
|
||||
} catch (error) {
|
||||
void messageApi.error(error instanceof Error ? error.message : '메모 저장에 실패했습니다.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!selectedNote && !hasDraft) {
|
||||
return;
|
||||
}
|
||||
|
||||
void modalApi.confirm({
|
||||
title: selectedNote ? '선택한 메모를 삭제할까요?' : '작성 중인 메모를 삭제할까요?',
|
||||
content: selectedNote ? '삭제 후 되돌릴 수 없습니다.' : '작성 중인 내용이 사라집니다.',
|
||||
okText: '삭제',
|
||||
cancelText: '취소',
|
||||
autoFocusButton: 'ok',
|
||||
modalRender: renderModalWithEnterConfirm,
|
||||
okButtonProps: { danger: true },
|
||||
async onOk() {
|
||||
if (!selectedNote) {
|
||||
setBody('');
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteTextMemoNote(selectedNote.id);
|
||||
const nextNotes = notes.filter((note) => note.id !== selectedNote.id);
|
||||
const fallbackNote = nextNotes[0] ?? null;
|
||||
|
||||
setNotes(nextNotes);
|
||||
setSelectedId(fallbackNote?.id ?? null);
|
||||
setBody(fallbackNote?.body ?? '');
|
||||
void messageApi.success('삭제됨');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="memo-layout-page" data-layout-id={layoutId}>
|
||||
{contextHolder}
|
||||
{modalContextHolder}
|
||||
<Splitter layout="vertical" className="memo-layout-page__splitter">
|
||||
<Splitter.Panel size={PRIMARY_SIZE} min={PRIMARY_MIN} resizable>
|
||||
<section className="memo-layout-page__pane memo-layout-page__pane--title">
|
||||
<InputUI
|
||||
value={inputValue}
|
||||
placeholder="제목"
|
||||
className="memo-layout-page__title-input"
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value.slice(0, MAX_BODY_LENGTH);
|
||||
setBody((previousBody) => replaceFirstLine(previousBody, nextValue));
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</Splitter.Panel>
|
||||
<Splitter.Panel min={SECONDARY_MIN} resizable>
|
||||
<section className="memo-layout-page__pane memo-layout-page__pane--memo">
|
||||
<div className="memo-layout-page__toolbar" role="toolbar" aria-label="메모 도구">
|
||||
<div className="memo-layout-page__toolbar-group">
|
||||
<Button type="text" aria-label="새 메모" icon={<PlusOutlined />} onClick={handleCreate} />
|
||||
<Button
|
||||
type={isListOpen ? 'default' : 'text'}
|
||||
aria-label="메모 목록"
|
||||
icon={<UnorderedListOutlined />}
|
||||
onClick={() => {
|
||||
setIsListOpen((previous) => !previous);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
aria-label="이전 메모"
|
||||
icon={<LeftOutlined />}
|
||||
disabled={notes.length === 0}
|
||||
onClick={() => {
|
||||
moveSelection(-1);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
aria-label="다음 메모"
|
||||
icon={<RightOutlined />}
|
||||
disabled={notes.length === 0}
|
||||
onClick={() => {
|
||||
moveSelection(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="memo-layout-page__toolbar-group">
|
||||
<Button
|
||||
type="text"
|
||||
aria-label="삭제"
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={!selectedNote && !hasDraft}
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
aria-label="저장"
|
||||
icon={isDirty ? <SaveOutlined /> : <CheckOutlined />}
|
||||
disabled={!hasDraft || isSaving || !isDirty}
|
||||
onClick={() => {
|
||||
void handleSave();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`memo-layout-page__body${isListOpen ? ' memo-layout-page__body--list-open' : ''}`}>
|
||||
{isListOpen ? (
|
||||
<div className="memo-layout-page__list-shell">
|
||||
{notes.length === 0 ? (
|
||||
<div className="memo-layout-page__empty">
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={false} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="memo-layout-page__list">
|
||||
{notes.map((note) => (
|
||||
<button
|
||||
key={note.id}
|
||||
type="button"
|
||||
className={`memo-layout-page__list-item${
|
||||
note.id === selectedId ? ' memo-layout-page__list-item--active' : ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
selectNote(note.id);
|
||||
setIsListOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="memo-layout-page__list-time">{formatMemoTimestamp(note.updatedAt)}</span>
|
||||
<span className="memo-layout-page__list-preview">{getPreviewText(note.body)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="memo-layout-page__editor">
|
||||
<div className="memo-layout-page__meta">
|
||||
<span>{selectedNote ? formatMemoTimestamp(selectedNote.updatedAt) : ''}</span>
|
||||
<span>{body.length}/{MAX_BODY_LENGTH}</span>
|
||||
</div>
|
||||
<Input.TextArea
|
||||
value={body}
|
||||
placeholder="메모 입력"
|
||||
className="memo-layout-page__textarea"
|
||||
autoSize={false}
|
||||
disabled={isLoading}
|
||||
maxLength={MAX_BODY_LENGTH}
|
||||
onChange={(event) => {
|
||||
setBody(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Splitter.Panel>
|
||||
</Splitter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
src/features/layout/memo/index.ts
Normal file
1
src/features/layout/memo/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { MemoLayoutPage } from './MemoLayoutPage';
|
||||
97
src/features/layout/stock-alert/StockAlertLayout.css
Normal file
97
src/features/layout/stock-alert/StockAlertLayout.css
Normal file
@@ -0,0 +1,97 @@
|
||||
.stock-alert-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.stock-alert-layout__filter {
|
||||
flex: 0 0 auto;
|
||||
height: auto;
|
||||
min-height: auto;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.stock-alert-layout__filter .ant-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stock-alert-layout__grid {
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.stock-alert-layout__toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stock-alert-layout__toolbar .ant-btn {
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.stock-alert-layout__surface {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.stock-alert-layout__surface.ag-theme-quartz {
|
||||
--ag-font-size: 13px;
|
||||
--ag-border-color: #d9d9d9;
|
||||
--ag-header-background-color: #fafafa;
|
||||
--ag-row-border-color: #f0f0f0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stock-alert-layout__search-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stock-alert-layout__search-modal .ant-table-wrapper {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.stock-alert-layout__change-rate--up {
|
||||
color: #cf1322;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stock-alert-layout__change-rate--down {
|
||||
color: #0958d9;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stock-alert-layout__change-rate--flat {
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
.stock-alert-layout__alert-type-editor {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stock-alert-layout__alert-type-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stock-alert-layout__alert-type-select .ant-select-selector {
|
||||
min-height: 32px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stock-alert-layout__alert-type-editor.is-open .stock-alert-layout__alert-type-select .ant-select-selector {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgb(5 145 255 / 0.12);
|
||||
}
|
||||
702
src/features/layout/stock-alert/StockAlertLayout.tsx
Normal file
702
src/features/layout/stock-alert/StockAlertLayout.tsx
Normal file
@@ -0,0 +1,702 @@
|
||||
import { DeleteOutlined, PlusOutlined, ReloadOutlined, SaveOutlined } from '@ant-design/icons';
|
||||
import { Button, Flex, Input, Modal, Select, Table, message } from 'antd';
|
||||
import type {
|
||||
CellValueChangedEvent,
|
||||
ColDef,
|
||||
GridApi,
|
||||
ICellRendererParams,
|
||||
RowSelectionOptions,
|
||||
ValueFormatterParams,
|
||||
} from 'ag-grid-community';
|
||||
import { AllCommunityModule, ModuleRegistry } from 'ag-grid-community';
|
||||
import { AgGridReact } from 'ag-grid-react';
|
||||
import {
|
||||
createContext,
|
||||
useDeferredValue,
|
||||
use,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
} from 'react';
|
||||
import { renderModalWithEnterConfirm } from '../../../app/main/modalKeyboard';
|
||||
import { SelectUI, type SelectOptionItem } from '../../../components/inputs/select';
|
||||
import {
|
||||
deleteStockAlertRow,
|
||||
fetchStockAlerts,
|
||||
searchStockAlertCandidates,
|
||||
saveStockAlertRows,
|
||||
type StockAlertDraftRow,
|
||||
type StockAlertFilterValue,
|
||||
type StockAlertSearchItem,
|
||||
type StockAlertType,
|
||||
} from './stockAlertApi';
|
||||
import 'ag-grid-community/styles/ag-grid.css';
|
||||
import 'ag-grid-community/styles/ag-theme-quartz.css';
|
||||
import './StockAlertLayout.css';
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
const FILTER_OPTIONS: SelectOptionItem[] = [
|
||||
{ code: 'all', value: '전체' },
|
||||
{ code: 'price', value: '현재가' },
|
||||
{ code: 'top3', value: '등락폭이 큰 상위3종목' },
|
||||
];
|
||||
|
||||
const ALERT_TYPE_LABEL_MAP = new Map<StockAlertType, string>([
|
||||
['price', '현재가'],
|
||||
['top3', '등락폭이 큰 상위3종목'],
|
||||
]);
|
||||
|
||||
const ALERT_TYPE_VALUES = Array.from(ALERT_TYPE_LABEL_MAP.keys());
|
||||
|
||||
type StockAlertLayoutContextValue = {
|
||||
filterValue: StockAlertFilterValue;
|
||||
rows: StockAlertDraftRow[];
|
||||
isLoading: boolean;
|
||||
pendingFocusRowId: string | null;
|
||||
setFilterValue: (value: StockAlertFilterValue) => void;
|
||||
updateRow: (rowId: string, patch: Partial<StockAlertDraftRow>) => void;
|
||||
addRow: (item: StockAlertSearchItem) => boolean;
|
||||
clearPendingFocusRowId: () => void;
|
||||
refreshRows: () => Promise<void>;
|
||||
saveRows: () => Promise<void>;
|
||||
deleteRows: (rowIds: string[]) => Promise<void>;
|
||||
};
|
||||
|
||||
const StockAlertLayoutContext = createContext<StockAlertLayoutContextValue | null>(null);
|
||||
|
||||
function useStockAlertLayoutContext() {
|
||||
const context = use(StockAlertLayoutContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('StockAlertLayoutProvider가 필요합니다.');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function getAlertTypeLabel(value: StockAlertType) {
|
||||
return ALERT_TYPE_LABEL_MAP.get(value) ?? value;
|
||||
}
|
||||
|
||||
function toAlertTypeLabels(values: StockAlertType[]) {
|
||||
return values.map((value) => getAlertTypeLabel(value));
|
||||
}
|
||||
|
||||
function mergeDraftRows(previousRows: StockAlertDraftRow[], nextRows: StockAlertDraftRow[]) {
|
||||
const dirtyRowMap = new Map(
|
||||
previousRows
|
||||
.filter((row) => row.isDirty)
|
||||
.map((row) => [row.persistedId ?? row.id, row]),
|
||||
);
|
||||
|
||||
return nextRows.map((row) => {
|
||||
const dirtyRow = dirtyRowMap.get(row.persistedId ?? row.id);
|
||||
|
||||
if (!dirtyRow) {
|
||||
return row;
|
||||
}
|
||||
|
||||
return {
|
||||
...row,
|
||||
stockCode: dirtyRow.stockCode,
|
||||
stockName: dirtyRow.stockName,
|
||||
alertTypes: dirtyRow.alertTypes,
|
||||
alertTypeLabels: toAlertTypeLabels(dirtyRow.alertTypes),
|
||||
isDirty: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function StockAlertLayoutProvider({ children }: PropsWithChildren) {
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [filterValue, setFilterValue] = useState<StockAlertFilterValue>('all');
|
||||
const [rows, setRows] = useState<StockAlertDraftRow[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [pendingFocusRowId, setPendingFocusRowId] = useState<string | null>(null);
|
||||
|
||||
const refreshRows = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const nextRows = await fetchStockAlerts(filterValue);
|
||||
setRows((previousRows) => mergeDraftRows(previousRows, nextRows));
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : '종목 알림 목록을 불러오지 못했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void refreshRows();
|
||||
}, [filterValue]);
|
||||
|
||||
const updateRow = (rowId: string, patch: Partial<StockAlertDraftRow>) => {
|
||||
setRows((previousRows) =>
|
||||
previousRows.map((row) =>
|
||||
row.id === rowId
|
||||
? {
|
||||
...row,
|
||||
...patch,
|
||||
alertTypes: (patch.alertTypes ?? row.alertTypes) as StockAlertType[],
|
||||
alertTypeLabels: toAlertTypeLabels((patch.alertTypes ?? row.alertTypes) as StockAlertType[]),
|
||||
isDirty: true,
|
||||
}
|
||||
: row,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const addRow = (item: StockAlertSearchItem) => {
|
||||
const nextAlertTypes: StockAlertType[] = [filterValue === 'all' ? 'price' : filterValue];
|
||||
|
||||
if (rows.some((row) => row.stockCode === item.stockCode)) {
|
||||
messageApi.warning('이미 추가된 종목입니다.');
|
||||
return false;
|
||||
}
|
||||
|
||||
const nextRowId = `stock-alert-draft-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
setRows((previousRows) => [
|
||||
{
|
||||
id: nextRowId,
|
||||
persistedId: null,
|
||||
stockCode: item.stockCode,
|
||||
stockName: item.stockName,
|
||||
alertTypes: nextAlertTypes,
|
||||
alertTypeLabels: toAlertTypeLabels(nextAlertTypes),
|
||||
currentPrice: null,
|
||||
changeRate: null,
|
||||
quotedAt: null,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
isDirty: true,
|
||||
isNew: true,
|
||||
},
|
||||
...previousRows,
|
||||
]);
|
||||
setPendingFocusRowId(nextRowId);
|
||||
return true;
|
||||
};
|
||||
|
||||
const saveRows = async () => {
|
||||
const dirtyRows = rows.filter((row) => row.isDirty);
|
||||
|
||||
if (!dirtyRows.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (dirtyRows.some((row) => !row.stockName.trim())) {
|
||||
messageApi.error('종목명을 입력한 뒤 저장해 주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (dirtyRows.some((row) => !row.stockCode.trim())) {
|
||||
messageApi.error('종목 검색으로 추가한 뒤 저장해 주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (dirtyRows.some((row) => !row.alertTypes.length)) {
|
||||
messageApi.error('알림유형을 하나 이상 선택해 주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await saveStockAlertRows(dirtyRows);
|
||||
await refreshRows();
|
||||
messageApi.success('종목 알림을 저장했습니다.');
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : '종목 알림 저장에 실패했습니다.');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRows = async (rowIds: string[]) => {
|
||||
if (!rowIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const persistedIds = rowIds
|
||||
.map((rowId) => rows.find((row) => row.id === rowId)?.persistedId ?? null)
|
||||
.filter((value): value is string => Boolean(value));
|
||||
|
||||
await Promise.all(persistedIds.map((id) => deleteStockAlertRow(id)));
|
||||
setRows((previousRows) => previousRows.filter((row) => !rowIds.includes(row.id)));
|
||||
await refreshRows();
|
||||
messageApi.success('선택한 종목 알림을 삭제했습니다.');
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : '종목 알림 삭제에 실패했습니다.');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const contextValue = useMemo<StockAlertLayoutContextValue>(
|
||||
() => ({
|
||||
filterValue,
|
||||
rows,
|
||||
isLoading,
|
||||
pendingFocusRowId,
|
||||
setFilterValue,
|
||||
updateRow,
|
||||
addRow,
|
||||
clearPendingFocusRowId: () => {
|
||||
setPendingFocusRowId(null);
|
||||
},
|
||||
refreshRows,
|
||||
saveRows,
|
||||
deleteRows,
|
||||
}),
|
||||
[filterValue, isLoading, pendingFocusRowId, rows],
|
||||
);
|
||||
|
||||
return (
|
||||
<StockAlertLayoutContext.Provider value={contextValue}>
|
||||
{contextHolder}
|
||||
{children}
|
||||
</StockAlertLayoutContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function StockAlertFilterPane() {
|
||||
const { filterValue, setFilterValue } = useStockAlertLayoutContext();
|
||||
|
||||
return (
|
||||
<Flex className="stock-alert-layout stock-alert-layout__filter">
|
||||
<SelectUI
|
||||
data={FILTER_OPTIONS}
|
||||
value={filterValue}
|
||||
allowClear={false}
|
||||
placeholder="알림유형"
|
||||
onChange={(nextCode) => {
|
||||
setFilterValue((nextCode as StockAlertFilterValue | undefined) ?? 'all');
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function formatPrice(value: number | null) {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat('ko-KR').format(value);
|
||||
}
|
||||
|
||||
function formatChangeRate(value: number | null) {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${value > 0 ? '+' : ''}${value.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
function formatQuotedAt(value: string | null) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function ChangeRateCellRenderer({ value }: ICellRendererParams<StockAlertDraftRow, number | null>) {
|
||||
const numericValue = typeof value === 'number' ? value : null;
|
||||
const className =
|
||||
numericValue === null
|
||||
? 'stock-alert-layout__change-rate--flat'
|
||||
: numericValue > 0
|
||||
? 'stock-alert-layout__change-rate--up'
|
||||
: numericValue < 0
|
||||
? 'stock-alert-layout__change-rate--down'
|
||||
: 'stock-alert-layout__change-rate--flat';
|
||||
|
||||
return <span className={className}>{formatChangeRate(numericValue)}</span>;
|
||||
}
|
||||
|
||||
type AlertTypeCellRendererProps = ICellRendererParams<StockAlertDraftRow> & {
|
||||
isOpen?: boolean;
|
||||
onOpen?: (rowId: string) => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
function AlertTypeCellEditorRenderer({ data, isOpen = false, onOpen, onClose }: AlertTypeCellRendererProps) {
|
||||
const { updateRow } = useStockAlertLayoutContext();
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`stock-alert-layout__alert-type-editor${isOpen ? ' is-open' : ''}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpen?.(data.id);
|
||||
}}
|
||||
>
|
||||
<Select<StockAlertType[]>
|
||||
mode="multiple"
|
||||
size="small"
|
||||
open={isOpen}
|
||||
autoFocus={isOpen}
|
||||
value={data.alertTypes}
|
||||
options={ALERT_TYPE_VALUES.map((value) => ({
|
||||
value,
|
||||
label: getAlertTypeLabel(value),
|
||||
}))}
|
||||
placeholder="알림유형 선택"
|
||||
maxTagCount="responsive"
|
||||
allowClear={false}
|
||||
popupMatchSelectWidth={false}
|
||||
className="stock-alert-layout__alert-type-select"
|
||||
onChange={(nextValues) => {
|
||||
updateRow(data.id, {
|
||||
alertTypes: nextValues as StockAlertType[],
|
||||
});
|
||||
}}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (nextOpen) {
|
||||
onOpen?.(data.id);
|
||||
return;
|
||||
}
|
||||
|
||||
onClose?.();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StockSearchModal({
|
||||
open,
|
||||
onCancel,
|
||||
onSelect,
|
||||
}: {
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
onSelect: (item: StockAlertSearchItem) => void;
|
||||
}) {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const deferredKeyword = useDeferredValue(keyword);
|
||||
const [items, setItems] = useState<StockAlertSearchItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
|
||||
const search = async (rawValue: string) => {
|
||||
const trimmedValue = rawValue.trim();
|
||||
|
||||
if (!trimmedValue) {
|
||||
setItems([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const nextItems = await searchStockAlertCandidates(trimmedValue, 20);
|
||||
setItems(nextItems);
|
||||
|
||||
if (!nextItems.length) {
|
||||
messageApi.info('조회 결과가 없습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : '종목 검색에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setKeyword('');
|
||||
setItems([]);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<Modal
|
||||
open={open}
|
||||
title="종목 검색"
|
||||
onCancel={onCancel}
|
||||
footer={null}
|
||||
width={720}
|
||||
destroyOnHidden
|
||||
modalRender={renderModalWithEnterConfirm}
|
||||
>
|
||||
<div className="stock-alert-layout__search-modal">
|
||||
<Input.Search
|
||||
value={keyword}
|
||||
placeholder="종목명 또는 종목코드"
|
||||
enterButton="조회"
|
||||
allowClear
|
||||
onChange={(event) => {
|
||||
setKeyword(event.target.value);
|
||||
}}
|
||||
onSearch={(value) => {
|
||||
void search(value);
|
||||
}}
|
||||
/>
|
||||
<Table<StockAlertSearchItem>
|
||||
size="small"
|
||||
rowKey={(record) => record.stockCode}
|
||||
loading={isLoading}
|
||||
pagination={false}
|
||||
dataSource={items}
|
||||
scroll={{ y: 360 }}
|
||||
locale={{
|
||||
emptyText: deferredKeyword.trim() ? '조회 결과가 없습니다.' : '종목명 또는 종목코드를 입력하세요.',
|
||||
}}
|
||||
onRow={(record) => ({
|
||||
onDoubleClick: () => {
|
||||
onSelect(record);
|
||||
},
|
||||
})}
|
||||
columns={[
|
||||
{
|
||||
title: '종목코드',
|
||||
dataIndex: 'stockCode',
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: '종목명',
|
||||
dataIndex: 'stockName',
|
||||
},
|
||||
{
|
||||
title: '시장구분',
|
||||
dataIndex: 'market',
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
width: 88,
|
||||
render: (_, record) => (
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => {
|
||||
onSelect(record);
|
||||
}}
|
||||
>
|
||||
선택
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function StockAlertGridPane() {
|
||||
const {
|
||||
rows,
|
||||
isLoading,
|
||||
pendingFocusRowId,
|
||||
updateRow,
|
||||
addRow,
|
||||
clearPendingFocusRowId,
|
||||
refreshRows,
|
||||
saveRows,
|
||||
deleteRows,
|
||||
} = useStockAlertLayoutContext();
|
||||
const [selectedRowIds, setSelectedRowIds] = useState<string[]>([]);
|
||||
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
|
||||
const [activeAlertTypeEditorRowId, setActiveAlertTypeEditorRowId] = useState<string | null>(null);
|
||||
const gridApiRef = useRef<GridApi<StockAlertDraftRow> | null>(null);
|
||||
|
||||
const rowSelection = useMemo<RowSelectionOptions>(
|
||||
() => ({
|
||||
mode: 'multiRow',
|
||||
enableClickSelection: true,
|
||||
checkboxes: true,
|
||||
headerCheckbox: true,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const columnDefs = useMemo<ColDef<StockAlertDraftRow>[]>(
|
||||
() => [
|
||||
{
|
||||
field: 'stockName',
|
||||
headerName: '종목명',
|
||||
editable: false,
|
||||
minWidth: 170,
|
||||
flex: 1.3,
|
||||
},
|
||||
{
|
||||
field: 'changeRate',
|
||||
headerName: '등락률',
|
||||
editable: false,
|
||||
minWidth: 130,
|
||||
flex: 0.9,
|
||||
cellRenderer: ChangeRateCellRenderer,
|
||||
},
|
||||
{
|
||||
field: 'currentPrice',
|
||||
headerName: '현재가',
|
||||
editable: false,
|
||||
minWidth: 120,
|
||||
flex: 0.9,
|
||||
valueFormatter: (params: ValueFormatterParams<StockAlertDraftRow, number | null>) => formatPrice(params.value ?? null),
|
||||
},
|
||||
{
|
||||
field: 'quotedAt',
|
||||
headerName: '기준일시',
|
||||
editable: false,
|
||||
minWidth: 190,
|
||||
flex: 1.2,
|
||||
valueFormatter: (params: ValueFormatterParams<StockAlertDraftRow, string | null>) => formatQuotedAt(params.value ?? null),
|
||||
},
|
||||
{
|
||||
field: 'alertTypes',
|
||||
headerName: '알림유형',
|
||||
editable: false,
|
||||
minWidth: 220,
|
||||
flex: 1.1,
|
||||
cellRenderer: AlertTypeCellEditorRenderer,
|
||||
cellRendererParams: (params: ICellRendererParams<StockAlertDraftRow>) => ({
|
||||
isOpen: params.data?.id === activeAlertTypeEditorRowId,
|
||||
onOpen: (rowId: string) => {
|
||||
setActiveAlertTypeEditorRowId(rowId);
|
||||
},
|
||||
onClose: () => {
|
||||
setActiveAlertTypeEditorRowId((currentValue) => (currentValue === params.data?.id ? null : currentValue));
|
||||
},
|
||||
}),
|
||||
sortable: false,
|
||||
filter: false,
|
||||
valueFormatter: (params: ValueFormatterParams<StockAlertDraftRow, StockAlertType[] | null>) =>
|
||||
Array.isArray(params.value) ? toAlertTypeLabels(params.value).join(', ') : '',
|
||||
},
|
||||
{
|
||||
field: 'stockCode',
|
||||
headerName: '종목코드',
|
||||
hide: true,
|
||||
},
|
||||
],
|
||||
[activeAlertTypeEditorRowId],
|
||||
);
|
||||
|
||||
const defaultColDef = useMemo<ColDef<StockAlertDraftRow>>(
|
||||
() => ({
|
||||
sortable: true,
|
||||
filter: true,
|
||||
resizable: true,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCellValueChanged = (event: CellValueChangedEvent<StockAlertDraftRow>) => {
|
||||
if (!event.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.colDef.field === 'stockName') {
|
||||
updateRow(event.data.id, {
|
||||
stockName: String(event.newValue ?? ''),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingFocusRowId || !gridApiRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rowIndex = rows.findIndex((row) => row.id === pendingFocusRowId);
|
||||
|
||||
if (rowIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
gridApiRef.current.ensureIndexVisible(rowIndex, 'top');
|
||||
gridApiRef.current.setFocusedCell(rowIndex, 'alertTypes');
|
||||
setActiveAlertTypeEditorRowId(pendingFocusRowId);
|
||||
clearPendingFocusRowId();
|
||||
}, [clearPendingFocusRowId, pendingFocusRowId, rows]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StockSearchModal
|
||||
open={isSearchModalOpen}
|
||||
onCancel={() => {
|
||||
setIsSearchModalOpen(false);
|
||||
}}
|
||||
onSelect={(item) => {
|
||||
const added = addRow(item);
|
||||
|
||||
if (added) {
|
||||
setIsSearchModalOpen(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="stock-alert-layout stock-alert-layout__grid">
|
||||
<div className="stock-alert-layout__toolbar">
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setIsSearchModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
<Button icon={<DeleteOutlined />} danger disabled={!selectedRowIds.length} onClick={() => void deleteRows(selectedRowIds)} />
|
||||
<Button icon={<SaveOutlined />} type="primary" onClick={() => void saveRows()} />
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void refreshRows()} />
|
||||
</div>
|
||||
<div className="stock-alert-layout__surface ag-theme-quartz">
|
||||
<AgGridReact<StockAlertDraftRow>
|
||||
loading={isLoading}
|
||||
rowData={rows}
|
||||
columnDefs={columnDefs}
|
||||
defaultColDef={defaultColDef}
|
||||
rowSelection={rowSelection}
|
||||
suppressRowClickSelection={false}
|
||||
getRowId={(params) => params.data.id}
|
||||
onGridReady={(event) => {
|
||||
gridApiRef.current = event.api;
|
||||
}}
|
||||
onCellValueChanged={handleCellValueChanged}
|
||||
onCellClicked={(event) => {
|
||||
const rowId = event.data?.id ?? null;
|
||||
|
||||
if (event.colDef.field === 'alertTypes' && rowId) {
|
||||
setActiveAlertTypeEditorRowId(rowId);
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveAlertTypeEditorRowId(null);
|
||||
}}
|
||||
onSelectionChanged={(event) => {
|
||||
setSelectedRowIds(event.api.getSelectedRows().map((row) => row.id));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
src/features/layout/stock-alert/index.ts
Normal file
1
src/features/layout/stock-alert/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { StockAlertFilterPane, StockAlertGridPane, StockAlertLayoutProvider } from './StockAlertLayout';
|
||||
171
src/features/layout/stock-alert/stockAlertApi.ts
Normal file
171
src/features/layout/stock-alert/stockAlertApi.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { appendClientIdHeader } from '../../../app/main/clientIdentity';
|
||||
import { getRegisteredAccessToken } from '../../../app/main/tokenAccess';
|
||||
|
||||
export type StockAlertFilterValue = 'all' | 'price' | 'top3';
|
||||
export type StockAlertType = Exclude<StockAlertFilterValue, 'all'>;
|
||||
|
||||
export type StockAlertItem = {
|
||||
id: string;
|
||||
stockCode: string;
|
||||
stockName: string;
|
||||
alertTypes: StockAlertType[];
|
||||
alertTypeLabels: string[];
|
||||
currentPrice: number | null;
|
||||
changeRate: number | null;
|
||||
quotedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type StockAlertDraftRow = {
|
||||
id: string;
|
||||
persistedId: string | null;
|
||||
stockCode: string;
|
||||
stockName: string;
|
||||
alertTypes: StockAlertType[];
|
||||
alertTypeLabels: string[];
|
||||
currentPrice: number | null;
|
||||
changeRate: number | null;
|
||||
quotedAt: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
isDirty: boolean;
|
||||
isNew: boolean;
|
||||
};
|
||||
|
||||
export type StockAlertSearchItem = {
|
||||
stockCode: string;
|
||||
stockName: string;
|
||||
market: string;
|
||||
};
|
||||
|
||||
const WORK_SERVER_TIMEOUT_MS = 10000;
|
||||
|
||||
function resolveWorkServerBaseUrl() {
|
||||
if (import.meta.env.VITE_WORK_SERVER_URL) {
|
||||
return import.meta.env.VITE_WORK_SERVER_URL;
|
||||
}
|
||||
|
||||
return '/api';
|
||||
}
|
||||
|
||||
const WORK_SERVER_BASE_URL = resolveWorkServerBaseUrl();
|
||||
|
||||
function toDraftRow(item: StockAlertItem): StockAlertDraftRow {
|
||||
return {
|
||||
id: item.id,
|
||||
persistedId: item.id,
|
||||
stockCode: item.stockCode,
|
||||
stockName: item.stockName,
|
||||
alertTypes: item.alertTypes,
|
||||
alertTypeLabels: item.alertTypeLabels,
|
||||
currentPrice: item.currentPrice,
|
||||
changeRate: item.changeRate,
|
||||
quotedAt: item.quotedAt,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt,
|
||||
isDirty: false,
|
||||
isNew: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyStockAlertRow(): StockAlertDraftRow {
|
||||
const localId = `stock-alert-draft-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
return {
|
||||
id: localId,
|
||||
persistedId: null,
|
||||
stockCode: '',
|
||||
stockName: '',
|
||||
alertTypes: ['price'],
|
||||
alertTypeLabels: ['현재가'],
|
||||
currentPrice: null,
|
||||
changeRate: null,
|
||||
quotedAt: null,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
isDirty: true,
|
||||
isNew: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit) {
|
||||
const headers = appendClientIdHeader(init?.headers);
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), WORK_SERVER_TIMEOUT_MS);
|
||||
|
||||
if (init?.body && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
const token = getRegisteredAccessToken();
|
||||
if (token && !headers.has('X-Access-Token')) {
|
||||
headers.set('X-Access-Token', token);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${WORK_SERVER_BASE_URL}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
cache: init?.cache ?? 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(text) as { message?: string };
|
||||
throw new Error(payload.message || '종목 알림 요청에 실패했습니다.');
|
||||
} catch {
|
||||
throw new Error(text || '종목 알림 요청에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchStockAlerts(filterValue: StockAlertFilterValue) {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (filterValue !== 'all') {
|
||||
searchParams.set('alertType', filterValue);
|
||||
}
|
||||
|
||||
const path = `/stock-alerts${searchParams.size ? `?${searchParams.toString()}` : ''}`;
|
||||
const response = await request<{ items: StockAlertItem[] }>(path);
|
||||
return response.items.map(toDraftRow);
|
||||
}
|
||||
|
||||
export async function searchStockAlertCandidates(query: string, limit = 20) {
|
||||
const searchParams = new URLSearchParams({
|
||||
query: query.trim(),
|
||||
limit: String(limit),
|
||||
});
|
||||
const response = await request<{ items: StockAlertSearchItem[] }>(`/stock-alerts/search?${searchParams.toString()}`);
|
||||
return response.items;
|
||||
}
|
||||
|
||||
export async function saveStockAlertRows(rows: StockAlertDraftRow[]) {
|
||||
const payload = rows.map((row) => ({
|
||||
id: row.persistedId ?? undefined,
|
||||
stockCode: row.stockCode,
|
||||
stockName: row.stockName,
|
||||
alertTypes: row.alertTypes,
|
||||
}));
|
||||
const response = await request<{ items: StockAlertItem[] }>('/stock-alerts/batch', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ items: payload }),
|
||||
});
|
||||
|
||||
return response.items.map(toDraftRow);
|
||||
}
|
||||
|
||||
export async function deleteStockAlertRow(id: string) {
|
||||
await request<{ ok: boolean; id: string }>(`/stock-alerts/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user