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',
|
||||
});
|
||||
}
|
||||
@@ -30,12 +30,19 @@ import {
|
||||
message,
|
||||
} from 'antd';
|
||||
import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAppConfig, type AppConfig } from '../../app/main/appConfig';
|
||||
import {
|
||||
buildAutomationTypeOptions,
|
||||
resolveAutomationTypeLabel,
|
||||
useAutomationTypeRegistry,
|
||||
} from '../../app/main/automationTypeAccess';
|
||||
import {
|
||||
buildAutomationContextOptions,
|
||||
resolveDefaultAutomationContextIds,
|
||||
useAutomationContextRegistry,
|
||||
} from '../../app/main/automationContextAccess';
|
||||
import { buildPlansPath } from '../../app/main/routes';
|
||||
import { uploadChatComposerFile } from '../../app/main/mainChatPanel';
|
||||
import { normalizeChatResourceUrl } from '../../app/main/mainChatPanel/chatResourceUrl';
|
||||
import type { ChatComposerAttachment } from '../../app/main/mainChatPanel/types';
|
||||
@@ -607,9 +614,11 @@ function createEmptyDraft(appConfig: AppConfig): PlanDraft {
|
||||
workId: '',
|
||||
note: '',
|
||||
automationType: 'none',
|
||||
automationContextIds: [],
|
||||
status: '등록',
|
||||
jangsingProcessingRequired: appConfig.planDefaults.jangsingProcessingRequired,
|
||||
autoDeployToMain: appConfig.planDefaults.autoDeployToMain,
|
||||
suppressWebPush: false,
|
||||
repeatRequestEnabled: false,
|
||||
repeatIntervalMinutes: 60,
|
||||
};
|
||||
@@ -630,8 +639,10 @@ export function PlanBoardPage({
|
||||
initialSelectedPlanId = null,
|
||||
initialSelectedWorkId = null,
|
||||
}: PlanBoardPageProps) {
|
||||
const navigate = useNavigate();
|
||||
const { hasAccess } = useTokenAccess();
|
||||
const { automationTypes } = useAutomationTypeRegistry();
|
||||
const { automationContexts } = useAutomationContextRegistry();
|
||||
const appConfig = useAppConfig();
|
||||
const autoRefreshIntervalMs = appConfig.automation.autoRefreshIntervalSeconds * 1000;
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
@@ -1186,7 +1197,10 @@ export function PlanBoardPage({
|
||||
}
|
||||
|
||||
noteAttachmentSessionIdRef.current = createPlanNoteAttachmentSessionId();
|
||||
setDraft(createEmptyDraft(appConfig));
|
||||
setDraft({
|
||||
...createEmptyDraft(appConfig),
|
||||
automationContextIds: resolveDefaultAutomationContextIds(automationContexts),
|
||||
});
|
||||
setResolveLatestIssue(false);
|
||||
setRetryLatestIssue(true);
|
||||
setEditorOpen(true);
|
||||
@@ -1598,6 +1612,10 @@ export function PlanBoardPage({
|
||||
() => buildAutomationTypeOptions(automationTypes, draft.automationType),
|
||||
[automationTypes, draft.automationType],
|
||||
);
|
||||
const automationContextOptions = useMemo(
|
||||
() => buildAutomationContextOptions(automationContexts, draft.automationContextIds),
|
||||
[automationContexts, draft.automationContextIds],
|
||||
);
|
||||
const automationTypeLabel = useMemo(
|
||||
() => resolveAutomationTypeLabel(automationTypes, draft.automationType),
|
||||
[automationTypes, draft.automationType],
|
||||
@@ -2102,6 +2120,42 @@ export function PlanBoardPage({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Flex justify="space-between" align="center" gap={8} wrap>
|
||||
<Text strong>Context</Text>
|
||||
<Space size={8} wrap>
|
||||
<Text type="secondary">복수 선택 가능, 모두 해제 가능</Text>
|
||||
<Button size="small" onClick={() => navigate(buildPlansPath('automation-context'))}>
|
||||
Context 관리
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
{requestReceived ? (
|
||||
<div className="plan-board-page__readonly-field" aria-readonly="true">
|
||||
<Text>{draft.automationContextIds.length ? `${draft.automationContextIds.length}개 선택` : '선택 안함'}</Text>
|
||||
<Tag color="processing">접수 후 읽기전용</Tag>
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
className="plan-board-page__select"
|
||||
value={draft.automationContextIds}
|
||||
options={automationContextOptions}
|
||||
popupClassName="plan-board-page__select-popup"
|
||||
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
|
||||
disabled={!hasAccess}
|
||||
placeholder="선택된 Context 없음"
|
||||
onChange={(automationContextIds) => {
|
||||
updateDraft((previous) => ({
|
||||
...previous,
|
||||
automationContextIds,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Flex justify="space-between" align="center" gap={8} wrap>
|
||||
<Text strong>메모</Text>
|
||||
@@ -2201,6 +2255,25 @@ export function PlanBoardPage({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Checkbox
|
||||
checked={draft.suppressWebPush ?? false}
|
||||
disabled={isRequestLocked}
|
||||
onChange={(event) => {
|
||||
const suppressWebPush = event.target.checked;
|
||||
updateDraft((previous) => ({
|
||||
...previous,
|
||||
suppressWebPush,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
자동화 웹푸쉬 보내지 않기
|
||||
</Checkbox>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Text type="secondary">자동화가 직접 요청한 알림은 이 설정과 관계없이 보냅니다.</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong>접수 / 처리 시각</Text>
|
||||
<Space direction="vertical" size={4} style={{ display: 'flex' }}>
|
||||
@@ -3248,9 +3321,11 @@ function toDraft(item: PlanItem): PlanDraft {
|
||||
workId: item.workId,
|
||||
note: item.note,
|
||||
automationType: item.automationType,
|
||||
automationContextIds: item.automationContextIds ?? [],
|
||||
status: item.status,
|
||||
jangsingProcessingRequired: item.jangsingProcessingRequired,
|
||||
autoDeployToMain: item.autoDeployToMain,
|
||||
suppressWebPush: item.suppressWebPush,
|
||||
repeatRequestEnabled: item.repeatRequestEnabled,
|
||||
repeatIntervalMinutes: item.repeatIntervalMinutes,
|
||||
};
|
||||
@@ -3949,6 +4024,9 @@ function summarizeAutomationUsageSnapshotTokens(snapshot: PlanAutomationUsageSna
|
||||
const validEntries = entries.filter(([value]) => Number.isFinite(value) && value > 0);
|
||||
|
||||
if (validEntries.length === 0) {
|
||||
if (Number(snapshot.sourceWorkCount ?? 0) > 0) {
|
||||
return '총 0';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,10 +18,17 @@ import {
|
||||
message,
|
||||
} from 'antd';
|
||||
import { memo, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
buildAutomationTypeOptions,
|
||||
useAutomationTypeRegistry,
|
||||
} from '../../app/main/automationTypeAccess';
|
||||
import {
|
||||
buildAutomationContextOptions,
|
||||
resolveDefaultAutomationContextIds,
|
||||
useAutomationContextRegistry,
|
||||
} from '../../app/main/automationContextAccess';
|
||||
import { buildPlansPath } from '../../app/main/routes';
|
||||
import { useTokenAccess } from '../../app/main/tokenAccess';
|
||||
import './planBoard.css';
|
||||
import './planSchedule.css';
|
||||
@@ -32,21 +39,28 @@ import {
|
||||
deletePlanScheduledTask,
|
||||
fetchPlanScheduledTasks,
|
||||
setupPlanBoard,
|
||||
type PlanScheduleExecutionMode,
|
||||
updatePlanScheduledTask,
|
||||
type PlanScheduleMode,
|
||||
type PlanScheduleRepeatUnit,
|
||||
type PlanScheduledTask,
|
||||
type PlanScheduledTaskDraft,
|
||||
type PlanScheduledTaskSaveResult,
|
||||
} from './api';
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
const { TextArea } = Input;
|
||||
const FUNCTION_CHECK_OPTIONS = ['완료', '오동작'];
|
||||
const EXECUTION_MODE_OPTIONS: { label: string; value: PlanScheduleExecutionMode }[] = [
|
||||
{ label: 'Codex 직접 처리', value: 'codex' },
|
||||
{ label: '별도 서비스 관리', value: 'managed-service' },
|
||||
];
|
||||
const SCHEDULE_MODE_TAB_ITEMS: { key: PlanScheduleMode; label: string }[] = [
|
||||
{ key: 'interval', label: '반복 주기' },
|
||||
{ key: 'daily', label: '매일 시간' },
|
||||
];
|
||||
const REPEAT_UNIT_OPTIONS: { label: string; value: PlanScheduleRepeatUnit }[] = [
|
||||
{ label: '초', value: 'second' },
|
||||
{ label: '분', value: 'minute' },
|
||||
{ label: '시간', value: 'hour' },
|
||||
{ label: '일', value: 'day' },
|
||||
@@ -54,6 +68,7 @@ const REPEAT_UNIT_OPTIONS: { label: string; value: PlanScheduleRepeatUnit }[] =
|
||||
{ label: '월', value: 'month' },
|
||||
];
|
||||
const REPEAT_UNIT_LABELS: Record<PlanScheduleRepeatUnit, string> = {
|
||||
second: '초',
|
||||
minute: '분',
|
||||
hour: '시간',
|
||||
day: '일',
|
||||
@@ -61,7 +76,9 @@ const REPEAT_UNIT_LABELS: Record<PlanScheduleRepeatUnit, string> = {
|
||||
month: '개월',
|
||||
};
|
||||
const REPEAT_PRESET_OPTIONS: { label: string; value: number; unit: PlanScheduleRepeatUnit }[] = [
|
||||
{ label: '10분', value: 10, unit: 'minute' },
|
||||
{ label: '10초', value: 10, unit: 'second' },
|
||||
{ label: '30초', value: 30, unit: 'second' },
|
||||
{ label: '1분', value: 1, unit: 'minute' },
|
||||
{ label: '30분', value: 30, unit: 'minute' },
|
||||
{ label: '1시간', value: 1, unit: 'hour' },
|
||||
{ label: '6시간', value: 6, unit: 'hour' },
|
||||
@@ -80,29 +97,42 @@ const MINUTE_OPTIONS = Array.from({ length: 60 }, (_, minute) => ({
|
||||
const DEFAULT_DAILY_RUN_TIME = '09:00';
|
||||
const KST_TIME_ZONE = 'Asia/Seoul';
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
function getRepeatIntervalMinutes(value: number, unit: PlanScheduleRepeatUnit) {
|
||||
const TIME_OF_DAY_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
function getRepeatIntervalSeconds(value: number, unit: PlanScheduleRepeatUnit) {
|
||||
const normalizedValue = Math.max(1, Math.round(Number(value) || 1));
|
||||
|
||||
if (unit === 'second') {
|
||||
return normalizedValue;
|
||||
}
|
||||
|
||||
if (unit === 'day') {
|
||||
return normalizedValue * 24 * 60;
|
||||
return normalizedValue * 24 * 60 * 60;
|
||||
}
|
||||
|
||||
if (unit === 'week') {
|
||||
return normalizedValue * 7 * 24 * 60;
|
||||
return normalizedValue * 7 * 24 * 60 * 60;
|
||||
}
|
||||
|
||||
if (unit === 'month') {
|
||||
return normalizedValue * 30 * 24 * 60;
|
||||
return normalizedValue * 30 * 24 * 60 * 60;
|
||||
}
|
||||
|
||||
if (unit === 'hour') {
|
||||
return normalizedValue * 60;
|
||||
return normalizedValue * 60 * 60;
|
||||
}
|
||||
|
||||
return normalizedValue;
|
||||
return normalizedValue * 60;
|
||||
}
|
||||
|
||||
function getRepeatIntervalMinutes(value: number, unit: PlanScheduleRepeatUnit) {
|
||||
return Math.max(1, Math.ceil(getRepeatIntervalSeconds(value, unit) / 60));
|
||||
}
|
||||
|
||||
function getRepeatIntervalValueMax(unit: PlanScheduleRepeatUnit) {
|
||||
if (unit === 'second') {
|
||||
return 86400;
|
||||
}
|
||||
|
||||
if (unit === 'month') {
|
||||
return 12;
|
||||
}
|
||||
@@ -122,14 +152,27 @@ function getRepeatIntervalValueMax(unit: PlanScheduleRepeatUnit) {
|
||||
return 525600;
|
||||
}
|
||||
|
||||
function buildScheduleSaveMessage(
|
||||
isUpdate: boolean,
|
||||
saveResult: PlanScheduledTaskSaveResult,
|
||||
) {
|
||||
const baseMessage = isUpdate ? '스케줄을 수정했습니다.' : '스케줄을 등록했습니다.';
|
||||
|
||||
if (!saveResult.registeredPlan) {
|
||||
return baseMessage;
|
||||
}
|
||||
|
||||
return `${baseMessage} 자동화도 바로 접수되어 Plan #${saveResult.registeredPlan.id}가 생성됐습니다.`;
|
||||
}
|
||||
|
||||
function normalizeRepeatIntervalValue(value: number, unit: PlanScheduleRepeatUnit) {
|
||||
const roundedValue = Math.max(1, Math.round(Number(value) || 1));
|
||||
return Math.min(getRepeatIntervalValueMax(unit), roundedValue);
|
||||
}
|
||||
|
||||
function formatRepeatInterval(value: number, unit: PlanScheduleRepeatUnit, fallbackMinutes: number) {
|
||||
function formatRepeatInterval(value: number, unit: PlanScheduleRepeatUnit, fallbackSeconds: number) {
|
||||
if (!value || !unit) {
|
||||
return `${fallbackMinutes}분마다`;
|
||||
return `${fallbackSeconds}초마다`;
|
||||
}
|
||||
|
||||
return `${value}${REPEAT_UNIT_LABELS[unit]}마다`;
|
||||
@@ -140,7 +183,7 @@ function normalizeScheduleMode(value: PlanScheduleMode | null | undefined): Plan
|
||||
}
|
||||
|
||||
function normalizeDailyRunTime(value: string | null | undefined) {
|
||||
return typeof value === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(value) ? value : DEFAULT_DAILY_RUN_TIME;
|
||||
return typeof value === 'string' && TIME_OF_DAY_PATTERN.test(value) ? value : DEFAULT_DAILY_RUN_TIME;
|
||||
}
|
||||
|
||||
function updateDailyRunTime(value: string, part: 'hour' | 'minute', nextPartValue: string) {
|
||||
@@ -148,6 +191,42 @@ function updateDailyRunTime(value: string, part: 'hour' | 'minute', nextPartValu
|
||||
return part === 'hour' ? `${nextPartValue}:${minute}` : `${hour}:${nextPartValue}`;
|
||||
}
|
||||
|
||||
function normalizeOptionalTimeOfDay(value: string | null | undefined) {
|
||||
return typeof value === 'string' && TIME_OF_DAY_PATTERN.test(value) ? value : null;
|
||||
}
|
||||
|
||||
function updateOptionalTimeOfDay(
|
||||
value: string | null | undefined,
|
||||
part: 'hour' | 'minute',
|
||||
nextPartValue: string | undefined,
|
||||
) {
|
||||
if (nextPartValue === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [hour, minute] = (normalizeOptionalTimeOfDay(value) ?? '00:00').split(':');
|
||||
return part === 'hour' ? `${nextPartValue}:${minute}` : `${hour}:${nextPartValue}`;
|
||||
}
|
||||
|
||||
function formatRepeatWindowLabel(startTime: string | null | undefined, endTime: string | null | undefined) {
|
||||
const normalizedStartTime = normalizeOptionalTimeOfDay(startTime);
|
||||
const normalizedEndTime = normalizeOptionalTimeOfDay(endTime);
|
||||
|
||||
if (!normalizedStartTime && !normalizedEndTime) {
|
||||
return '시간 제한 없음';
|
||||
}
|
||||
|
||||
if (normalizedStartTime && normalizedEndTime) {
|
||||
return `${normalizedStartTime}~${normalizedEndTime}`;
|
||||
}
|
||||
|
||||
if (normalizedStartTime) {
|
||||
return `${normalizedStartTime} 이후`;
|
||||
}
|
||||
|
||||
return `${normalizedEndTime} 이전`;
|
||||
}
|
||||
|
||||
function formatScheduleCycle(item: PlanScheduledTask) {
|
||||
const scheduleMode = normalizeScheduleMode(item.scheduleMode);
|
||||
|
||||
@@ -155,7 +234,7 @@ function formatScheduleCycle(item: PlanScheduledTask) {
|
||||
return `매일 ${normalizeDailyRunTime(item.dailyRunTime)} 실행`;
|
||||
}
|
||||
|
||||
return formatRepeatInterval(item.repeatIntervalValue, item.repeatIntervalUnit, item.repeatIntervalMinutes);
|
||||
return `${formatRepeatInterval(item.repeatIntervalValue, item.repeatIntervalUnit, item.repeatIntervalSeconds)} · ${formatRepeatWindowLabel(item.repeatWindowStartTime, item.repeatWindowEndTime)}`;
|
||||
}
|
||||
|
||||
function getValidDate(value: string | null | undefined) {
|
||||
@@ -227,7 +306,7 @@ function resolveNextPlanScheduleRunAt(item: PlanScheduledTask, now = new Date())
|
||||
}
|
||||
|
||||
const baseAt = lastRegisteredAt ?? getValidDate(item.createdAt) ?? now;
|
||||
const nextRunAt = new Date(baseAt.getTime() + item.repeatIntervalMinutes * 60 * 1000);
|
||||
const nextRunAt = new Date(baseAt.getTime() + item.repeatIntervalSeconds * 1000);
|
||||
|
||||
return nextRunAt.getTime() <= now.getTime() ? now : nextRunAt;
|
||||
}
|
||||
@@ -238,16 +317,24 @@ function createEmptyScheduleDraft(defaultReleaseTarget = 'release'): PlanSchedul
|
||||
workId: '',
|
||||
note: '',
|
||||
automationType: 'none',
|
||||
automationContextIds: [],
|
||||
releaseTarget: defaultReleaseTarget,
|
||||
jangsingProcessingRequired: true,
|
||||
autoDeployToMain: true,
|
||||
suppressWebPush: false,
|
||||
enabled: true,
|
||||
immediateRunEnabled: true,
|
||||
refreshContextSnapshotOnNextRun: false,
|
||||
executionMode: 'codex',
|
||||
recreateManagedServiceOnNextSave: false,
|
||||
scheduleMode: 'interval',
|
||||
repeatIntervalValue: 60,
|
||||
repeatIntervalUnit: 'minute',
|
||||
repeatIntervalSeconds: getRepeatIntervalSeconds(60, 'minute'),
|
||||
repeatIntervalMinutes: 60,
|
||||
dailyRunTime: DEFAULT_DAILY_RUN_TIME,
|
||||
repeatWindowStartTime: null,
|
||||
repeatWindowEndTime: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -263,16 +350,24 @@ function toDraft(item: PlanScheduledTask): PlanScheduledTaskDraft {
|
||||
workId: item.workId,
|
||||
note: item.note,
|
||||
automationType: item.automationType,
|
||||
automationContextIds: item.automationContextIds ?? [],
|
||||
releaseTarget: item.releaseTarget,
|
||||
jangsingProcessingRequired: item.jangsingProcessingRequired,
|
||||
autoDeployToMain: item.autoDeployToMain,
|
||||
suppressWebPush: item.suppressWebPush,
|
||||
enabled: item.enabled,
|
||||
immediateRunEnabled: item.immediateRunEnabled,
|
||||
refreshContextSnapshotOnNextRun: item.refreshContextSnapshotOnNextRun,
|
||||
executionMode: item.executionMode ?? 'codex',
|
||||
recreateManagedServiceOnNextSave: item.recreateManagedServiceOnNextSave ?? false,
|
||||
scheduleMode: normalizeScheduleMode(item.scheduleMode),
|
||||
repeatIntervalValue,
|
||||
repeatIntervalUnit,
|
||||
repeatIntervalSeconds: item.repeatIntervalSeconds ?? getRepeatIntervalSeconds(repeatIntervalValue, repeatIntervalUnit),
|
||||
repeatIntervalMinutes: item.repeatIntervalMinutes ?? getRepeatIntervalMinutes(repeatIntervalValue, repeatIntervalUnit),
|
||||
dailyRunTime: normalizeDailyRunTime(item.dailyRunTime),
|
||||
repeatWindowStartTime: normalizeOptionalTimeOfDay(item.repeatWindowStartTime),
|
||||
repeatWindowEndTime: normalizeOptionalTimeOfDay(item.repeatWindowEndTime),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -309,10 +404,6 @@ function validateScheduleDraft(draft: PlanScheduledTaskDraft, items: PlanSchedul
|
||||
messages.push('반복 등록할 작업 메모를 입력하세요.');
|
||||
}
|
||||
|
||||
if (draft.scheduleMode === 'interval' && getRepeatIntervalMinutes(draft.repeatIntervalValue, draft.repeatIntervalUnit) < 10) {
|
||||
messages.push('반복 주기는 최소 10분 이상으로 설정하세요.');
|
||||
}
|
||||
|
||||
if (!draft.enabled) {
|
||||
messages.push('비활성 스케줄은 자동 등록되지 않습니다.');
|
||||
}
|
||||
@@ -323,6 +414,7 @@ function validateScheduleDraft(draft: PlanScheduledTaskDraft, items: PlanSchedul
|
||||
export function PlanSchedulePage() {
|
||||
const { hasAccess } = useTokenAccess();
|
||||
const { automationTypes } = useAutomationTypeRegistry();
|
||||
const { automationContexts } = useAutomationContextRegistry();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [items, setItems] = useState<PlanScheduledTask[]>([]);
|
||||
const [draft, setDraft] = useState(() => createEmptyScheduleDraft());
|
||||
@@ -335,15 +427,22 @@ export function PlanSchedulePage() {
|
||||
[draft.id, items],
|
||||
);
|
||||
const validationMessages = useMemo(() => validateScheduleDraft(draft, items), [draft, items]);
|
||||
const automationContextOptions = useMemo(
|
||||
() => buildAutomationContextOptions(automationContexts, draft.automationContextIds),
|
||||
[automationContexts, draft.automationContextIds],
|
||||
);
|
||||
|
||||
async function loadItems() {
|
||||
setLoading(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
setItems(await fetchPlanScheduledTasks());
|
||||
const nextItems = await fetchPlanScheduledTasks();
|
||||
setItems(nextItems);
|
||||
return nextItems;
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '스케줄 목록을 불러오지 못했습니다.');
|
||||
return [];
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -388,12 +487,20 @@ export function PlanSchedulePage() {
|
||||
const draftToSave = {
|
||||
...draft,
|
||||
repeatIntervalValue,
|
||||
repeatIntervalSeconds: getRepeatIntervalSeconds(repeatIntervalValue, draft.repeatIntervalUnit),
|
||||
repeatIntervalMinutes: getRepeatIntervalMinutes(repeatIntervalValue, draft.repeatIntervalUnit),
|
||||
repeatWindowStartTime:
|
||||
draft.scheduleMode === 'interval' ? normalizeOptionalTimeOfDay(draft.repeatWindowStartTime) : null,
|
||||
repeatWindowEndTime:
|
||||
draft.scheduleMode === 'interval' ? normalizeOptionalTimeOfDay(draft.repeatWindowEndTime) : null,
|
||||
};
|
||||
const savedItem = draft.id ? await updatePlanScheduledTask(draftToSave) : await createPlanScheduledTask(draftToSave);
|
||||
messageApi.success(draft.id ? '스케줄을 수정했습니다.' : '스케줄을 등록했습니다.');
|
||||
setDraft(toDraft(savedItem));
|
||||
await loadItems();
|
||||
const saveResult = draft.id
|
||||
? await updatePlanScheduledTask(draftToSave)
|
||||
: await createPlanScheduledTask(draftToSave);
|
||||
const nextItems = await loadItems();
|
||||
const latestSavedItem = nextItems.find((item) => item.id === saveResult.item.id) ?? saveResult.item;
|
||||
messageApi.success(buildScheduleSaveMessage(Boolean(draft.id), saveResult));
|
||||
setDraft(toDraft(latestSavedItem));
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : '스케줄 저장에 실패했습니다.');
|
||||
} finally {
|
||||
@@ -420,7 +527,10 @@ export function PlanSchedulePage() {
|
||||
try {
|
||||
await deletePlanScheduledTask(draft.id);
|
||||
messageApi.success('스케줄을 삭제했습니다.');
|
||||
setDraft(createEmptyScheduleDraft(draft.releaseTarget));
|
||||
setDraft({
|
||||
...createEmptyScheduleDraft(draft.releaseTarget),
|
||||
automationContextIds: resolveDefaultAutomationContextIds(automationContexts),
|
||||
});
|
||||
setEditorOpen(false);
|
||||
await loadItems();
|
||||
} catch (error) {
|
||||
@@ -440,7 +550,10 @@ export function PlanSchedulePage() {
|
||||
}
|
||||
|
||||
function handleCreateNew() {
|
||||
setDraft(createEmptyScheduleDraft(draft.releaseTarget));
|
||||
setDraft({
|
||||
...createEmptyScheduleDraft(draft.releaseTarget),
|
||||
automationContextIds: resolveDefaultAutomationContextIds(automationContexts),
|
||||
});
|
||||
setEditorOpen(true);
|
||||
}
|
||||
|
||||
@@ -533,6 +646,7 @@ export function PlanSchedulePage() {
|
||||
detailContent={
|
||||
<PlanScheduleDetail
|
||||
automationTypeOptions={buildAutomationTypeOptions(automationTypes, draft.automationType)}
|
||||
automationContextOptions={automationContextOptions}
|
||||
draft={draft}
|
||||
hasAccess={hasAccess}
|
||||
selectedItem={selectedItem}
|
||||
@@ -584,10 +698,18 @@ const PlanScheduleList = memo(function PlanScheduleList({
|
||||
{item.note ? (hasAccess ? item.note : maskNotePreviewByWord(item.note)) : '작업 내용이 없습니다.'}
|
||||
</Paragraph>
|
||||
<Space wrap size={8}>
|
||||
<Tag color={item.executionMode === 'managed-service' ? 'geekblue' : 'default'}>
|
||||
{item.executionMode === 'managed-service' ? '외부 서비스 관리형' : 'Codex 직접'}
|
||||
</Tag>
|
||||
<Tag>{formatScheduleCycle(item)}</Tag>
|
||||
<Tag color="blue">다음 실행 {formatNextPlanScheduleRunAt(item)}</Tag>
|
||||
<Tag>{item.immediateRunEnabled ? '즉시실행' : '주기 후 실행'}</Tag>
|
||||
{item.refreshContextSnapshotOnNextRun ? <Tag color="purple">다음 실행 시 문서 재정리</Tag> : null}
|
||||
{item.executionMode === 'managed-service' && item.managedServiceKey ? (
|
||||
<Tag color="cyan">{item.managedServiceKey}</Tag>
|
||||
) : null}
|
||||
<Tag>{item.autoDeployToMain ? 'main 자동등록' : 'release만'}</Tag>
|
||||
{item.suppressWebPush ? <Tag color="gold">웹푸쉬 끔</Tag> : null}
|
||||
<Tag>기능동작확인 {item.jangsingProcessingRequired ? '완료' : '오동작'}</Tag>
|
||||
</Space>
|
||||
<Flex justify="space-between" align="center" gap={8} wrap style={{ marginTop: 10 }}>
|
||||
@@ -603,6 +725,7 @@ const PlanScheduleList = memo(function PlanScheduleList({
|
||||
|
||||
function PlanScheduleDetail({
|
||||
automationTypeOptions,
|
||||
automationContextOptions,
|
||||
draft,
|
||||
hasAccess,
|
||||
selectedItem,
|
||||
@@ -611,6 +734,7 @@ function PlanScheduleDetail({
|
||||
onCopyText,
|
||||
}: {
|
||||
automationTypeOptions: Array<{ label: string; value: string }>;
|
||||
automationContextOptions: Array<{ label: string; value: string }>;
|
||||
draft: PlanScheduledTaskDraft;
|
||||
hasAccess: boolean;
|
||||
selectedItem: PlanScheduledTask | null;
|
||||
@@ -618,6 +742,8 @@ function PlanScheduleDetail({
|
||||
onChangeDraft: Dispatch<SetStateAction<PlanScheduledTaskDraft>>;
|
||||
onCopyText: (text: string) => Promise<void>;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="plan-schedule-page__detail">
|
||||
{selectedItem ? (
|
||||
@@ -629,7 +755,15 @@ function PlanScheduleDetail({
|
||||
description={
|
||||
<Space direction="vertical" size={4}>
|
||||
<Text>다음 실행: {formatNextPlanScheduleRunAt(selectedItem)}</Text>
|
||||
<Text>문서 재정리 예약: {selectedItem.refreshContextSnapshotOnNextRun ? '다음 실행 1회' : '없음'}</Text>
|
||||
<Text>실행 방식: {selectedItem.executionMode === 'managed-service' ? '별도 서비스 관리형' : 'Codex 직접 처리'}</Text>
|
||||
{selectedItem.executionMode === 'managed-service' ? (
|
||||
<Text>서비스 키: {selectedItem.managedServiceKey ?? `schedule-${selectedItem.id}`}</Text>
|
||||
) : null}
|
||||
<Text>최근 작업 등록: {formatPlanScheduleDateTime(selectedItem.lastRegisteredAt)}</Text>
|
||||
{selectedItem.executionMode === 'managed-service' ? (
|
||||
<Text>서비스 재생성: {selectedItem.managedServiceGeneratedAt ? formatPlanScheduleDateTime(selectedItem.managedServiceGeneratedAt) : '미생성'}</Text>
|
||||
) : null}
|
||||
<Text>생성: {formatPlanScheduleDateTime(selectedItem.createdAt)}</Text>
|
||||
<Text>수정: {formatPlanScheduleDateTime(selectedItem.updatedAt)}</Text>
|
||||
</Space>
|
||||
@@ -704,7 +838,58 @@ function PlanScheduleDetail({
|
||||
popupClassName="plan-schedule-page__select-popup"
|
||||
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
|
||||
disabled={!hasAccess}
|
||||
onChange={(automationType) => onChangeDraft((previous) => ({ ...previous, automationType }))}
|
||||
onChange={(automationType) =>
|
||||
onChangeDraft((previous) => ({
|
||||
...previous,
|
||||
automationType,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong>실행 방식</Text>
|
||||
<Segmented
|
||||
options={EXECUTION_MODE_OPTIONS}
|
||||
value={draft.executionMode}
|
||||
disabled={!hasAccess}
|
||||
onChange={(value) =>
|
||||
onChangeDraft((previous) => ({
|
||||
...previous,
|
||||
executionMode: value as PlanScheduleExecutionMode,
|
||||
recreateManagedServiceOnNextSave:
|
||||
value === 'managed-service' ? previous.recreateManagedServiceOnNextSave : false,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Text type="secondary">
|
||||
{draft.executionMode === 'managed-service'
|
||||
? `스케줄 PK를 포함한 고정 패키지 경로로 별도 서비스 번들을 관리합니다.`
|
||||
: '현재처럼 Codex 기반 자동화 메모 등록 흐름으로 처리합니다.'}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Flex justify="space-between" align="center" gap={8} wrap>
|
||||
<Text strong>Context</Text>
|
||||
<Space size={8} wrap>
|
||||
<Text type="secondary">복수 선택 가능, 모두 해제 가능</Text>
|
||||
<Button size="small" onClick={() => navigate(buildPlansPath('automation-context'))}>
|
||||
Context 관리
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
className="plan-schedule-page__select"
|
||||
value={draft.automationContextIds}
|
||||
options={automationContextOptions}
|
||||
popupClassName="plan-schedule-page__select-popup"
|
||||
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
|
||||
disabled={!hasAccess}
|
||||
placeholder="선택된 Context 없음"
|
||||
onChange={(automationContextIds) => onChangeDraft((previous) => ({ ...previous, automationContextIds }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -743,6 +928,7 @@ function PlanScheduleDetail({
|
||||
dailyRunTime: updateDailyRunTime(previous.dailyRunTime, 'hour', value),
|
||||
repeatIntervalValue: 1,
|
||||
repeatIntervalUnit: 'day',
|
||||
repeatIntervalSeconds: getRepeatIntervalSeconds(1, 'day'),
|
||||
repeatIntervalMinutes: getRepeatIntervalMinutes(1, 'day'),
|
||||
}))
|
||||
}
|
||||
@@ -760,6 +946,7 @@ function PlanScheduleDetail({
|
||||
dailyRunTime: updateDailyRunTime(previous.dailyRunTime, 'minute', value),
|
||||
repeatIntervalValue: 1,
|
||||
repeatIntervalUnit: 'day',
|
||||
repeatIntervalSeconds: getRepeatIntervalSeconds(1, 'day'),
|
||||
repeatIntervalMinutes: getRepeatIntervalMinutes(1, 'day'),
|
||||
}))
|
||||
}
|
||||
@@ -782,6 +969,7 @@ function PlanScheduleDetail({
|
||||
onChangeDraft((previous) => ({
|
||||
...previous,
|
||||
repeatIntervalValue,
|
||||
repeatIntervalSeconds: getRepeatIntervalSeconds(repeatIntervalValue, previous.repeatIntervalUnit),
|
||||
repeatIntervalMinutes: getRepeatIntervalMinutes(repeatIntervalValue, previous.repeatIntervalUnit),
|
||||
}));
|
||||
}}
|
||||
@@ -798,6 +986,10 @@ function PlanScheduleDetail({
|
||||
...previous,
|
||||
repeatIntervalValue: normalizeRepeatIntervalValue(previous.repeatIntervalValue, value),
|
||||
repeatIntervalUnit: value,
|
||||
repeatIntervalSeconds: getRepeatIntervalSeconds(
|
||||
normalizeRepeatIntervalValue(previous.repeatIntervalValue, value),
|
||||
value,
|
||||
),
|
||||
repeatIntervalMinutes: getRepeatIntervalMinutes(
|
||||
normalizeRepeatIntervalValue(previous.repeatIntervalValue, value),
|
||||
value,
|
||||
@@ -819,6 +1011,7 @@ function PlanScheduleDetail({
|
||||
scheduleMode: option.unit === 'day' && option.value === 1 ? 'daily' : 'interval',
|
||||
repeatIntervalValue: option.value,
|
||||
repeatIntervalUnit: option.unit,
|
||||
repeatIntervalSeconds: getRepeatIntervalSeconds(option.value, option.unit),
|
||||
repeatIntervalMinutes: getRepeatIntervalMinutes(option.value, option.unit),
|
||||
}))
|
||||
}
|
||||
@@ -827,6 +1020,77 @@ function PlanScheduleDetail({
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
<Space align="center" wrap style={{ marginTop: 12 }}>
|
||||
<Text type="secondary">적용 시작 시간</Text>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="제한없음"
|
||||
style={{ width: 96 }}
|
||||
options={HOUR_OPTIONS}
|
||||
value={normalizeOptionalTimeOfDay(draft.repeatWindowStartTime)?.split(':')[0]}
|
||||
popupClassName="plan-schedule-page__select-popup"
|
||||
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
|
||||
disabled={!hasAccess}
|
||||
onChange={(value) =>
|
||||
onChangeDraft((previous) => ({
|
||||
...previous,
|
||||
repeatWindowStartTime: updateOptionalTimeOfDay(previous.repeatWindowStartTime, 'hour', value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="제한없음"
|
||||
style={{ width: 96 }}
|
||||
options={MINUTE_OPTIONS}
|
||||
value={normalizeOptionalTimeOfDay(draft.repeatWindowStartTime)?.split(':')[1]}
|
||||
popupClassName="plan-schedule-page__select-popup"
|
||||
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
|
||||
disabled={!hasAccess}
|
||||
onChange={(value) =>
|
||||
onChangeDraft((previous) => ({
|
||||
...previous,
|
||||
repeatWindowStartTime: updateOptionalTimeOfDay(previous.repeatWindowStartTime, 'minute', value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Text type="secondary">적용 종료 시간</Text>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="제한없음"
|
||||
style={{ width: 96 }}
|
||||
options={HOUR_OPTIONS}
|
||||
value={normalizeOptionalTimeOfDay(draft.repeatWindowEndTime)?.split(':')[0]}
|
||||
popupClassName="plan-schedule-page__select-popup"
|
||||
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
|
||||
disabled={!hasAccess}
|
||||
onChange={(value) =>
|
||||
onChangeDraft((previous) => ({
|
||||
...previous,
|
||||
repeatWindowEndTime: updateOptionalTimeOfDay(previous.repeatWindowEndTime, 'hour', value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="제한없음"
|
||||
style={{ width: 96 }}
|
||||
options={MINUTE_OPTIONS}
|
||||
value={normalizeOptionalTimeOfDay(draft.repeatWindowEndTime)?.split(':')[1]}
|
||||
popupClassName="plan-schedule-page__select-popup"
|
||||
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
|
||||
disabled={!hasAccess}
|
||||
onChange={(value) =>
|
||||
onChangeDraft((previous) => ({
|
||||
...previous,
|
||||
repeatWindowEndTime: updateOptionalTimeOfDay(previous.repeatWindowEndTime, 'minute', value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Space>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Text type="secondary">빈값이면 제한없음으로 처리합니다.</Text>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -841,6 +1105,53 @@ function PlanScheduleDetail({
|
||||
즉시실행 여부
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div>
|
||||
<Checkbox
|
||||
checked={draft.refreshContextSnapshotOnNextRun}
|
||||
disabled={!hasAccess}
|
||||
onChange={(event) =>
|
||||
onChangeDraft((previous) => ({
|
||||
...previous,
|
||||
refreshContextSnapshotOnNextRun: event.target.checked,
|
||||
}))
|
||||
}
|
||||
>
|
||||
다음 실행 시 프로젝트/소스 다시 읽고 문서 재정리
|
||||
</Checkbox>
|
||||
<div>
|
||||
<Text type="secondary">켜두면 다음 자동 실행 1회에 한해 `.auto_codex/schedule/...` 문서를 다시 생성한 뒤 자동으로 꺼집니다.</Text>
|
||||
</div>
|
||||
</div>
|
||||
{draft.executionMode === 'managed-service' ? (
|
||||
<div>
|
||||
<Checkbox
|
||||
checked={draft.recreateManagedServiceOnNextSave}
|
||||
disabled={!hasAccess}
|
||||
onChange={(event) =>
|
||||
onChangeDraft((previous) => ({
|
||||
...previous,
|
||||
recreateManagedServiceOnNextSave: event.target.checked,
|
||||
}))
|
||||
}
|
||||
>
|
||||
저장 시 관리 서비스 패키지 생성 Plan 자동 접수
|
||||
</Checkbox>
|
||||
<div>
|
||||
<Text type="secondary">
|
||||
체크하면 저장 직후 서비스 패키지 생성 Plan을 자동 접수하고, 생성된
|
||||
{' '}
|
||||
<Text code>.auto_codex/schedule/<id></Text>
|
||||
{' '}
|
||||
서비스 결과물을 다음 실행부터 사용합니다.
|
||||
</Text>
|
||||
</div>
|
||||
{selectedItem?.managedServiceDirectory ? (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Text code>{selectedItem.managedServiceDirectory}</Text>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<Checkbox
|
||||
checked={draft.autoDeployToMain}
|
||||
@@ -850,6 +1161,18 @@ function PlanScheduleDetail({
|
||||
메인까지 자동등록
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div>
|
||||
<Checkbox
|
||||
checked={draft.suppressWebPush}
|
||||
disabled={!hasAccess}
|
||||
onChange={(event) => onChangeDraft((previous) => ({ ...previous, suppressWebPush: event.target.checked }))}
|
||||
>
|
||||
자동화 웹푸쉬 보내지 않기
|
||||
</Checkbox>
|
||||
<div>
|
||||
<Text type="secondary">자동화가 직접 요청한 알림은 이 설정과 관계없이 보냅니다.</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong>기능동작확인</Text>
|
||||
<Segmented
|
||||
|
||||
@@ -270,8 +270,10 @@ export async function createPlanItem(draft: PlanDraft) {
|
||||
workId: draft.workId,
|
||||
note: draft.note,
|
||||
automationType: draft.automationType,
|
||||
automationContextIds: draft.automationContextIds,
|
||||
jangsingProcessingRequired: draft.jangsingProcessingRequired,
|
||||
autoDeployToMain: draft.autoDeployToMain,
|
||||
suppressWebPush: draft.suppressWebPush,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -289,8 +291,10 @@ export async function updatePlanItem(draft: PlanDraft) {
|
||||
workId: draft.workId,
|
||||
note: draft.note,
|
||||
automationType: draft.automationType,
|
||||
automationContextIds: draft.automationContextIds,
|
||||
jangsingProcessingRequired: draft.jangsingProcessingRequired,
|
||||
autoDeployToMain: draft.autoDeployToMain,
|
||||
suppressWebPush: draft.suppressWebPush,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -382,6 +386,9 @@ function normalizePlanItem(item: PlanItem): PlanItem {
|
||||
...item,
|
||||
automationType: normalizePlanAutomationType(item.automationType),
|
||||
automationBehaviorType: normalizeAutomationTypeId(item.automationBehaviorType),
|
||||
automationContextIds: Array.isArray(item.automationContextIds)
|
||||
? item.automationContextIds.map((value) => String(value).trim()).filter(Boolean)
|
||||
: [],
|
||||
releaseReviewNote: typeof item.releaseReviewNote === 'string' ? item.releaseReviewNote : '',
|
||||
usageSnapshot: normalizePlanAutomationUsageSnapshot(item.usageSnapshot),
|
||||
};
|
||||
@@ -451,39 +458,67 @@ export type PlanScheduledTask = {
|
||||
workId: string;
|
||||
note: string;
|
||||
automationType: PlanAutomationType;
|
||||
automationContextIds: string[];
|
||||
releaseTarget: string;
|
||||
jangsingProcessingRequired: boolean;
|
||||
autoDeployToMain: boolean;
|
||||
suppressWebPush: boolean;
|
||||
enabled: boolean;
|
||||
immediateRunEnabled: boolean;
|
||||
refreshContextSnapshotOnNextRun: boolean;
|
||||
executionMode: PlanScheduleExecutionMode;
|
||||
managedServiceKey: string | null;
|
||||
managedServicePackageName: string | null;
|
||||
managedServiceDirectory: string | null;
|
||||
managedServiceManifestPath: string | null;
|
||||
managedServiceGeneratedAt: string | null;
|
||||
recreateManagedServiceOnNextSave: boolean;
|
||||
scheduleMode: PlanScheduleMode;
|
||||
repeatIntervalValue: number;
|
||||
repeatIntervalUnit: PlanScheduleRepeatUnit;
|
||||
repeatIntervalSeconds: number;
|
||||
repeatIntervalMinutes: number;
|
||||
dailyRunTime: string;
|
||||
repeatWindowStartTime: string | null;
|
||||
repeatWindowEndTime: string | null;
|
||||
lastRegisteredAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PlanScheduleExecutionMode = 'codex' | 'managed-service';
|
||||
export type PlanScheduleMode = 'interval' | 'daily';
|
||||
export type PlanScheduleRepeatUnit = 'minute' | 'hour' | 'day' | 'week' | 'month';
|
||||
export type PlanScheduleRepeatUnit = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month';
|
||||
|
||||
export type PlanScheduledTaskDraft = {
|
||||
id: number | null;
|
||||
workId: string;
|
||||
note: string;
|
||||
automationType: PlanAutomationType;
|
||||
automationContextIds: string[];
|
||||
releaseTarget: string;
|
||||
jangsingProcessingRequired: boolean;
|
||||
autoDeployToMain: boolean;
|
||||
suppressWebPush: boolean;
|
||||
enabled: boolean;
|
||||
immediateRunEnabled: boolean;
|
||||
refreshContextSnapshotOnNextRun: boolean;
|
||||
executionMode: PlanScheduleExecutionMode;
|
||||
recreateManagedServiceOnNextSave: boolean;
|
||||
scheduleMode: PlanScheduleMode;
|
||||
repeatIntervalValue: number;
|
||||
repeatIntervalUnit: PlanScheduleRepeatUnit;
|
||||
repeatIntervalSeconds: number;
|
||||
repeatIntervalMinutes: number;
|
||||
dailyRunTime: string;
|
||||
repeatWindowStartTime: string | null;
|
||||
repeatWindowEndTime: string | null;
|
||||
};
|
||||
|
||||
export type PlanScheduledTaskSaveResult = {
|
||||
item: PlanScheduledTask;
|
||||
registeredPlan: PlanItem | null;
|
||||
registeredBoardPosts: Array<{ id: number } & Record<string, unknown>>;
|
||||
};
|
||||
|
||||
async function requestPlanScheduleTask<T>(pathSuffix = '', init?: RequestInit) {
|
||||
@@ -521,33 +556,60 @@ export async function fetchPlanScheduledTasks() {
|
||||
return response.items.map((item) => ({
|
||||
...item,
|
||||
automationType: normalizePlanAutomationType(item.automationType),
|
||||
automationContextIds: Array.isArray(item.automationContextIds)
|
||||
? item.automationContextIds.map((value) => String(value).trim()).filter(Boolean)
|
||||
: [],
|
||||
refreshContextSnapshotOnNextRun: Boolean(item.refreshContextSnapshotOnNextRun),
|
||||
recreateManagedServiceOnNextSave: Boolean(item.recreateManagedServiceOnNextSave),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createPlanScheduledTask(draft: PlanScheduledTaskDraft) {
|
||||
const response = await requestPlanScheduleTask<{ ok: boolean; item: PlanScheduledTask }>('', {
|
||||
const response = await requestPlanScheduleTask<{
|
||||
ok: boolean;
|
||||
item: PlanScheduledTask;
|
||||
registeredPlan: PlanItem | null;
|
||||
registeredBoardPosts: Array<{ id: number } & Record<string, unknown>>;
|
||||
}>('', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
workId: draft.workId,
|
||||
note: draft.note,
|
||||
automationType: draft.automationType,
|
||||
automationContextIds: draft.automationContextIds,
|
||||
releaseTarget: draft.releaseTarget,
|
||||
jangsingProcessingRequired: draft.jangsingProcessingRequired,
|
||||
autoDeployToMain: draft.autoDeployToMain,
|
||||
suppressWebPush: draft.suppressWebPush,
|
||||
enabled: draft.enabled,
|
||||
immediateRunEnabled: draft.immediateRunEnabled,
|
||||
refreshContextSnapshotOnNextRun: draft.refreshContextSnapshotOnNextRun,
|
||||
executionMode: draft.executionMode,
|
||||
recreateManagedServiceOnNextSave: draft.recreateManagedServiceOnNextSave,
|
||||
scheduleMode: draft.scheduleMode,
|
||||
repeatIntervalValue: draft.repeatIntervalValue,
|
||||
repeatIntervalUnit: draft.repeatIntervalUnit,
|
||||
repeatIntervalSeconds: draft.repeatIntervalSeconds,
|
||||
repeatIntervalMinutes: draft.repeatIntervalMinutes,
|
||||
dailyRunTime: draft.dailyRunTime,
|
||||
repeatWindowStartTime: draft.repeatWindowStartTime,
|
||||
repeatWindowEndTime: draft.repeatWindowEndTime,
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
...response.item,
|
||||
automationType: normalizePlanAutomationType(response.item.automationType),
|
||||
};
|
||||
item: {
|
||||
...response.item,
|
||||
automationType: normalizePlanAutomationType(response.item.automationType),
|
||||
automationContextIds: Array.isArray(response.item.automationContextIds)
|
||||
? response.item.automationContextIds.map((value) => String(value).trim()).filter(Boolean)
|
||||
: [],
|
||||
refreshContextSnapshotOnNextRun: Boolean(response.item.refreshContextSnapshotOnNextRun),
|
||||
recreateManagedServiceOnNextSave: Boolean(response.item.recreateManagedServiceOnNextSave),
|
||||
},
|
||||
registeredPlan: response.registeredPlan,
|
||||
registeredBoardPosts: Array.isArray(response.registeredBoardPosts) ? response.registeredBoardPosts : [],
|
||||
} satisfies PlanScheduledTaskSaveResult;
|
||||
}
|
||||
|
||||
export async function updatePlanScheduledTask(draft: PlanScheduledTaskDraft) {
|
||||
@@ -555,29 +617,51 @@ export async function updatePlanScheduledTask(draft: PlanScheduledTaskDraft) {
|
||||
throw new Error('수정할 스케줄 ID가 없습니다.');
|
||||
}
|
||||
|
||||
const response = await requestPlanScheduleTask<{ ok: boolean; item: PlanScheduledTask }>(`/${draft.id}`, {
|
||||
const response = await requestPlanScheduleTask<{
|
||||
ok: boolean;
|
||||
item: PlanScheduledTask;
|
||||
registeredPlan: PlanItem | null;
|
||||
registeredBoardPosts: Array<{ id: number } & Record<string, unknown>>;
|
||||
}>(`/${draft.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
workId: draft.workId,
|
||||
note: draft.note,
|
||||
automationType: draft.automationType,
|
||||
automationContextIds: draft.automationContextIds,
|
||||
releaseTarget: draft.releaseTarget,
|
||||
jangsingProcessingRequired: draft.jangsingProcessingRequired,
|
||||
autoDeployToMain: draft.autoDeployToMain,
|
||||
suppressWebPush: draft.suppressWebPush,
|
||||
enabled: draft.enabled,
|
||||
immediateRunEnabled: draft.immediateRunEnabled,
|
||||
refreshContextSnapshotOnNextRun: draft.refreshContextSnapshotOnNextRun,
|
||||
executionMode: draft.executionMode,
|
||||
recreateManagedServiceOnNextSave: draft.recreateManagedServiceOnNextSave,
|
||||
scheduleMode: draft.scheduleMode,
|
||||
repeatIntervalValue: draft.repeatIntervalValue,
|
||||
repeatIntervalUnit: draft.repeatIntervalUnit,
|
||||
repeatIntervalSeconds: draft.repeatIntervalSeconds,
|
||||
repeatIntervalMinutes: draft.repeatIntervalMinutes,
|
||||
dailyRunTime: draft.dailyRunTime,
|
||||
repeatWindowStartTime: draft.repeatWindowStartTime,
|
||||
repeatWindowEndTime: draft.repeatWindowEndTime,
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
...response.item,
|
||||
automationType: normalizePlanAutomationType(response.item.automationType),
|
||||
};
|
||||
item: {
|
||||
...response.item,
|
||||
automationType: normalizePlanAutomationType(response.item.automationType),
|
||||
automationContextIds: Array.isArray(response.item.automationContextIds)
|
||||
? response.item.automationContextIds.map((value) => String(value).trim()).filter(Boolean)
|
||||
: [],
|
||||
refreshContextSnapshotOnNextRun: Boolean(response.item.refreshContextSnapshotOnNextRun),
|
||||
recreateManagedServiceOnNextSave: Boolean(response.item.recreateManagedServiceOnNextSave),
|
||||
},
|
||||
registeredPlan: response.registeredPlan,
|
||||
registeredBoardPosts: Array.isArray(response.registeredBoardPosts) ? response.registeredBoardPosts : [],
|
||||
} satisfies PlanScheduledTaskSaveResult;
|
||||
}
|
||||
|
||||
export async function deletePlanScheduledTask(id: number) {
|
||||
|
||||
@@ -111,11 +111,13 @@ export type PlanItem = {
|
||||
note: string;
|
||||
automationType: PlanAutomationType;
|
||||
automationBehaviorType?: string;
|
||||
automationContextIds: string[];
|
||||
releaseReviewNote: string;
|
||||
noteMasked?: boolean;
|
||||
status: PlanStatus;
|
||||
jangsingProcessingRequired: boolean;
|
||||
autoDeployToMain: boolean;
|
||||
suppressWebPush: boolean;
|
||||
repeatRequestEnabled: boolean;
|
||||
repeatIntervalMinutes: number;
|
||||
assignedBranch: string | null;
|
||||
@@ -137,9 +139,11 @@ export type PlanDraft = {
|
||||
workId: string;
|
||||
note: string;
|
||||
automationType: PlanAutomationType;
|
||||
automationContextIds: string[];
|
||||
status: PlanStatus;
|
||||
jangsingProcessingRequired: boolean;
|
||||
autoDeployToMain: boolean;
|
||||
suppressWebPush: boolean;
|
||||
repeatRequestEnabled: boolean;
|
||||
repeatIntervalMinutes: number;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user