feat: expand live chat and work server tools

This commit is contained in:
2026-04-30 11:40:02 +09:00
parent 42ae640470
commit 2df0ba30cb
112 changed files with 15241 additions and 996 deletions

View File

@@ -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` 문맥에서는 후자만 다룹니다.

View 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;
}
}

View 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>
);
}

View File

@@ -0,0 +1 @@
export { MemoLayoutPage } from './MemoLayoutPage';

View 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);
}

View 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>
</>
);
}

View File

@@ -0,0 +1 @@
export { StockAlertFilterPane, StockAlertGridPane, StockAlertLayoutProvider } from './StockAlertLayout';

View 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',
});
}