feat: expand live chat and work server tools

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

View File

@@ -7,8 +7,33 @@
- 컴포넌트 샘플 레이아웃
- 위젯 샘플 레이아웃
- Markdown preview 리스트 레이아웃
- `Layout Editor`와 저장 레이아웃 흐름
## 규칙
- 공통 레이아웃 시스템이 아니라 현재 프로젝트 화면에 종속되면 `features/layout`에 배치
- 공통 재사용 가능성이 높으면 `components` 또는 `widgets`로 승격 검토
## Layout Editor 기준
`Layout Editor`는 위젯 자체의 기본 기능을 정의하는 화면이 아닙니다. 이 화면은 레이아웃 안에 배치된 컴포넌트가 현재 화면에서 어떤 역할을 해야 하는지 기술하는 편집기입니다.
용어 기준:
- `컴포넌트 기능 명세`: 현재 레이아웃에 배치된 컴포넌트의 화면 동작, 상호작용, 구현 포인트를 적는 항목
- `저장 레이아웃`: 구조, 배치, 기능 명세를 함께 저장한 편집 결과
- `Codex 요청`: 저장된 레이아웃과 그 안의 기능 명세를 기준으로 구현 요청을 만드는 액션
허용 범위:
- 컴포넌트 간 이벤트 전달, 상태 동기화, focus/selection 이동, 표시/숨김 제어를 기능 명세로 기술할 수 있다
- 한 컴포넌트의 입력/액션이 다른 컴포넌트 목록, 상세, 요약, CTA 상태를 바꾸는 흐름을 기술할 수 있다
- 서버 저장, 조회, 갱신, 삭제 같은 API 연결과 그 응답이 다른 컴포넌트에 반영되는 흐름도 기능 명세 범위에 포함한다
금지 해석:
- 위젯 또는 컴포넌트 샘플 메타에서 "기본 기능"을 자동으로 끌어와 `Layout Editor` 기능 명세처럼 취급하지 않는다
- 사용자가 직접 추가하지 않은 항목을 기능 명세 목록에 자동 주입하지 않는다
- 레이아웃 기능 명세와 위젯 고유 스펙 문서를 같은 개념으로 섞지 않는다
구현/문서 작성 시에는 항상 "이 기능은 위젯의 본래 능력 설명인가, 아니면 현재 레이아웃에서 그 컴포넌트가 수행할 역할 설명인가"를 먼저 구분합니다. `Layout Editor` 문맥에서는 후자만 다룹니다.

View File

@@ -0,0 +1,240 @@
.memo-layout-page {
width: 100%;
height: 100%;
min-height: 0;
background:
radial-gradient(circle at top left, rgba(250, 204, 21, 0.18), transparent 30%),
linear-gradient(180deg, #fbfdff 0%, #f4f7fb 100%);
}
.memo-layout-page__splitter,
.memo-layout-page__splitter .ant-splitter-panel {
width: 100%;
height: 100%;
min-height: 0;
}
.memo-layout-page__pane {
display: flex;
width: 100%;
height: 100%;
min-height: 0;
padding: 18px;
box-sizing: border-box;
}
.memo-layout-page__pane--title {
align-items: stretch;
}
.memo-layout-page__title-input.ant-input {
align-self: stretch;
width: 100%;
height: 100%;
padding: 24px 26px;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 28px;
background: rgba(255, 255, 255, 0.88);
box-shadow:
0 22px 48px rgba(15, 23, 42, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.95);
color: #0f172a;
font-size: clamp(26px, 4vw, 40px);
font-weight: 700;
line-height: 1.15;
}
.memo-layout-page__title-input.ant-input::placeholder {
color: rgba(100, 116, 139, 0.7);
}
.memo-layout-page__pane--memo {
flex-direction: column;
gap: 12px;
}
.memo-layout-page__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.memo-layout-page__toolbar-group {
display: flex;
align-items: center;
gap: 6px;
}
.memo-layout-page__toolbar .ant-btn {
width: 32px;
min-width: 32px;
height: 32px;
border-radius: 12px;
color: #475569;
}
.memo-layout-page__toolbar .ant-btn:not(:disabled):hover {
color: #0f172a;
background: rgba(255, 255, 255, 0.8);
}
.memo-layout-page__body {
display: flex;
flex: 1 1 auto;
gap: 12px;
min-height: 0;
}
.memo-layout-page__body--list-open .memo-layout-page__editor {
border-top-left-radius: 22px;
border-bottom-left-radius: 22px;
}
.memo-layout-page__list-shell {
flex: 0 0 260px;
min-width: 220px;
min-height: 0;
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 24px;
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.06);
overflow: hidden;
}
.memo-layout-page__empty {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-height: 0;
}
.memo-layout-page__list {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
height: 100%;
min-height: 0;
padding: 10px;
overflow: auto;
box-sizing: border-box;
}
.memo-layout-page__list-item {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
padding: 12px 14px;
border: 0;
border-radius: 18px;
background: rgba(248, 250, 252, 0.96);
text-align: left;
cursor: pointer;
}
.memo-layout-page__list-item:hover {
background: rgba(241, 245, 249, 1);
}
.memo-layout-page__list-item--active {
background: rgba(254, 240, 138, 0.42);
}
.memo-layout-page__list-time {
color: rgba(100, 116, 139, 0.94);
font-size: 12px;
}
.memo-layout-page__list-preview {
color: #0f172a;
font-size: 14px;
line-height: 1.45;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.memo-layout-page__editor {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-width: 0;
min-height: 0;
border: 1px solid rgba(245, 158, 11, 0.18);
border-radius: 28px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.74), rgba(255, 255, 255, 0.42)),
repeating-linear-gradient(
180deg,
rgba(255, 248, 216, 0.98) 0,
rgba(255, 248, 216, 0.98) 37px,
rgba(236, 221, 177, 0.78) 37px,
rgba(236, 221, 177, 0.78) 38px
);
box-shadow:
0 18px 44px rgba(15, 23, 42, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.9);
overflow: hidden;
}
.memo-layout-page__meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 28px;
padding: 14px 18px 0;
color: rgba(100, 116, 139, 0.92);
font-size: 12px;
}
.memo-layout-page__meta > :first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.memo-layout-page__textarea.ant-input {
flex: 1 1 auto;
min-height: 0;
padding: 10px 18px 32px;
color: #3f3a2f;
font-size: 16px;
line-height: 38px;
background: transparent;
resize: none;
}
.memo-layout-page__textarea.ant-input::placeholder {
color: rgba(120, 113, 91, 0.72);
}
@media (max-width: 768px) {
.memo-layout-page__pane {
padding: 12px;
}
.memo-layout-page__body {
flex-direction: column;
}
.memo-layout-page__list-shell {
flex: 0 0 180px;
min-width: 0;
}
.memo-layout-page__title-input.ant-input {
padding: 18px 20px;
border-radius: 22px;
font-size: 24px;
}
.memo-layout-page__editor {
border-radius: 22px;
}
}

View File

@@ -0,0 +1,345 @@
import {
CheckOutlined,
DeleteOutlined,
LeftOutlined,
PlusOutlined,
RightOutlined,
SaveOutlined,
UnorderedListOutlined,
} from '@ant-design/icons';
import { Button, Empty, Input, Modal, Splitter, message } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { renderModalWithEnterConfirm } from '../../../app/main/modalKeyboard';
import { InputUI } from '../../../components/inputs/primitives/input';
import {
createTextMemoNote,
deleteTextMemoNote,
fetchTextMemoNotes,
updateTextMemoNote,
type TextMemoNoteRecord,
} from '../../../widgets/text-memo-widget/textMemoApi';
import './MemoLayoutPage.css';
type MemoLayoutPageProps = {
layoutId: string;
};
type MemoNote = TextMemoNoteRecord;
const PRIMARY_SIZE = '42%';
const PRIMARY_MIN = '24%';
const SECONDARY_MIN = '20%';
const MAX_NOTE_COUNT = 12;
const MAX_BODY_LENGTH = 1200;
function getFirstLine(value: string) {
const [firstLine = ''] = value.split(/\r?\n/u);
return firstLine;
}
function replaceFirstLine(body: string, nextTitle: string) {
const normalizedTitle = nextTitle.trim();
const lineBreakIndex = body.search(/\r?\n/u);
if (lineBreakIndex < 0) {
return normalizedTitle;
}
const nextTail = body.slice(lineBreakIndex);
return `${normalizedTitle}${nextTail}`;
}
function getPreviewText(body: string) {
const preview = body.replace(/\s+/gu, ' ').trim();
return preview || '새 메모';
}
function formatMemoTimestamp(value: string) {
return new Intl.DateTimeFormat('ko-KR', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(new Date(value));
}
export function MemoLayoutPage({ layoutId }: MemoLayoutPageProps) {
const [messageApi, contextHolder] = message.useMessage();
const [modalApi, modalContextHolder] = Modal.useModal();
const [notes, setNotes] = useState<MemoNote[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [body, setBody] = useState('');
const [isListOpen, setIsListOpen] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
let cancelled = false;
void (async () => {
setIsLoading(true);
try {
const items = await fetchTextMemoNotes();
if (cancelled) {
return;
}
setNotes(items);
if (items[0]) {
setSelectedId(items[0].id);
setBody(items[0].body);
} else {
setSelectedId(null);
setBody('');
}
} catch (error) {
if (!cancelled) {
void messageApi.error(error instanceof Error ? error.message : '메모를 불러오지 못했습니다.');
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [messageApi]);
const selectedIndex = useMemo(
() => (selectedId ? notes.findIndex((note) => note.id === selectedId) : -1),
[notes, selectedId],
);
const selectedNote = selectedIndex >= 0 ? notes[selectedIndex] : null;
const inputValue = getFirstLine(body);
const hasDraft = body.trim().length > 0;
const isDirty = selectedNote ? selectedNote.body !== body : hasDraft;
const selectNote = (noteId: string) => {
const nextNote = notes.find((item) => item.id === noteId);
if (!nextNote) {
return;
}
setSelectedId(nextNote.id);
setBody(nextNote.body);
};
const moveSelection = (direction: -1 | 1) => {
if (notes.length === 0) {
return;
}
const fallbackIndex = direction > 0 ? 0 : notes.length - 1;
const nextIndex = selectedIndex < 0 ? fallbackIndex : (selectedIndex + direction + notes.length) % notes.length;
const nextNote = notes[nextIndex];
if (!nextNote) {
return;
}
setSelectedId(nextNote.id);
setBody(nextNote.body);
};
const handleCreate = () => {
setSelectedId(null);
setBody('');
setIsListOpen(false);
};
const handleSave = async () => {
const trimmedBody = body.trim();
if (!trimmedBody || isSaving) {
return;
}
setIsSaving(true);
try {
if (selectedNote) {
const updated = await updateTextMemoNote(selectedNote.id, { body: trimmedBody });
const nextNotes = [updated, ...notes.filter((note) => note.id !== updated.id)].slice(0, MAX_NOTE_COUNT);
setNotes(nextNotes);
setSelectedId(updated.id);
setBody(updated.body);
} else {
const created = await createTextMemoNote({ body: trimmedBody });
const nextNotes = [created, ...notes].slice(0, MAX_NOTE_COUNT);
setNotes(nextNotes);
setSelectedId(created.id);
setBody(created.body);
}
void messageApi.success('저장됨');
} catch (error) {
void messageApi.error(error instanceof Error ? error.message : '메모 저장에 실패했습니다.');
} finally {
setIsSaving(false);
}
};
const handleDelete = () => {
if (!selectedNote && !hasDraft) {
return;
}
void modalApi.confirm({
title: selectedNote ? '선택한 메모를 삭제할까요?' : '작성 중인 메모를 삭제할까요?',
content: selectedNote ? '삭제 후 되돌릴 수 없습니다.' : '작성 중인 내용이 사라집니다.',
okText: '삭제',
cancelText: '취소',
autoFocusButton: 'ok',
modalRender: renderModalWithEnterConfirm,
okButtonProps: { danger: true },
async onOk() {
if (!selectedNote) {
setBody('');
return;
}
await deleteTextMemoNote(selectedNote.id);
const nextNotes = notes.filter((note) => note.id !== selectedNote.id);
const fallbackNote = nextNotes[0] ?? null;
setNotes(nextNotes);
setSelectedId(fallbackNote?.id ?? null);
setBody(fallbackNote?.body ?? '');
void messageApi.success('삭제됨');
},
});
};
return (
<div className="memo-layout-page" data-layout-id={layoutId}>
{contextHolder}
{modalContextHolder}
<Splitter layout="vertical" className="memo-layout-page__splitter">
<Splitter.Panel size={PRIMARY_SIZE} min={PRIMARY_MIN} resizable>
<section className="memo-layout-page__pane memo-layout-page__pane--title">
<InputUI
value={inputValue}
placeholder="제목"
className="memo-layout-page__title-input"
onChange={(event) => {
const nextValue = event.target.value.slice(0, MAX_BODY_LENGTH);
setBody((previousBody) => replaceFirstLine(previousBody, nextValue));
}}
/>
</section>
</Splitter.Panel>
<Splitter.Panel min={SECONDARY_MIN} resizable>
<section className="memo-layout-page__pane memo-layout-page__pane--memo">
<div className="memo-layout-page__toolbar" role="toolbar" aria-label="메모 도구">
<div className="memo-layout-page__toolbar-group">
<Button type="text" aria-label="새 메모" icon={<PlusOutlined />} onClick={handleCreate} />
<Button
type={isListOpen ? 'default' : 'text'}
aria-label="메모 목록"
icon={<UnorderedListOutlined />}
onClick={() => {
setIsListOpen((previous) => !previous);
}}
/>
<Button
type="text"
aria-label="이전 메모"
icon={<LeftOutlined />}
disabled={notes.length === 0}
onClick={() => {
moveSelection(-1);
}}
/>
<Button
type="text"
aria-label="다음 메모"
icon={<RightOutlined />}
disabled={notes.length === 0}
onClick={() => {
moveSelection(1);
}}
/>
</div>
<div className="memo-layout-page__toolbar-group">
<Button
type="text"
aria-label="삭제"
icon={<DeleteOutlined />}
disabled={!selectedNote && !hasDraft}
onClick={handleDelete}
/>
<Button
type="text"
aria-label="저장"
icon={isDirty ? <SaveOutlined /> : <CheckOutlined />}
disabled={!hasDraft || isSaving || !isDirty}
onClick={() => {
void handleSave();
}}
/>
</div>
</div>
<div className={`memo-layout-page__body${isListOpen ? ' memo-layout-page__body--list-open' : ''}`}>
{isListOpen ? (
<div className="memo-layout-page__list-shell">
{notes.length === 0 ? (
<div className="memo-layout-page__empty">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={false} />
</div>
) : (
<div className="memo-layout-page__list">
{notes.map((note) => (
<button
key={note.id}
type="button"
className={`memo-layout-page__list-item${
note.id === selectedId ? ' memo-layout-page__list-item--active' : ''
}`}
onClick={() => {
selectNote(note.id);
setIsListOpen(false);
}}
>
<span className="memo-layout-page__list-time">{formatMemoTimestamp(note.updatedAt)}</span>
<span className="memo-layout-page__list-preview">{getPreviewText(note.body)}</span>
</button>
))}
</div>
)}
</div>
) : null}
<div className="memo-layout-page__editor">
<div className="memo-layout-page__meta">
<span>{selectedNote ? formatMemoTimestamp(selectedNote.updatedAt) : ''}</span>
<span>{body.length}/{MAX_BODY_LENGTH}</span>
</div>
<Input.TextArea
value={body}
placeholder="메모 입력"
className="memo-layout-page__textarea"
autoSize={false}
disabled={isLoading}
maxLength={MAX_BODY_LENGTH}
onChange={(event) => {
setBody(event.target.value);
}}
/>
</div>
</div>
</section>
</Splitter.Panel>
</Splitter>
</div>
);
}

View File

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

View File

@@ -0,0 +1,97 @@
.stock-alert-layout {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.stock-alert-layout__filter {
flex: 0 0 auto;
height: auto;
min-height: auto;
justify-content: center;
padding: 16px;
}
.stock-alert-layout__filter .ant-select {
width: 100%;
}
.stock-alert-layout__grid {
gap: 12px;
padding: 12px;
}
.stock-alert-layout__toolbar {
display: flex;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.stock-alert-layout__toolbar .ant-btn {
min-width: 40px;
}
.stock-alert-layout__surface {
flex: 1;
min-height: 0;
}
.stock-alert-layout__surface.ag-theme-quartz {
--ag-font-size: 13px;
--ag-border-color: #d9d9d9;
--ag-header-background-color: #fafafa;
--ag-row-border-color: #f0f0f0;
width: 100%;
height: 100%;
border: 1px solid #f0f0f0;
border-radius: 14px;
overflow: hidden;
}
.stock-alert-layout__search-modal {
display: flex;
flex-direction: column;
gap: 12px;
}
.stock-alert-layout__search-modal .ant-table-wrapper {
min-height: 0;
}
.stock-alert-layout__change-rate--up {
color: #cf1322;
font-weight: 600;
}
.stock-alert-layout__change-rate--down {
color: #0958d9;
font-weight: 600;
}
.stock-alert-layout__change-rate--flat {
color: #595959;
}
.stock-alert-layout__alert-type-editor {
display: flex;
align-items: center;
min-height: 100%;
width: 100%;
cursor: pointer;
}
.stock-alert-layout__alert-type-select {
width: 100%;
}
.stock-alert-layout__alert-type-select .ant-select-selector {
min-height: 32px;
border-radius: 8px;
}
.stock-alert-layout__alert-type-editor.is-open .stock-alert-layout__alert-type-select .ant-select-selector {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgb(5 145 255 / 0.12);
}

View File

@@ -0,0 +1,702 @@
import { DeleteOutlined, PlusOutlined, ReloadOutlined, SaveOutlined } from '@ant-design/icons';
import { Button, Flex, Input, Modal, Select, Table, message } from 'antd';
import type {
CellValueChangedEvent,
ColDef,
GridApi,
ICellRendererParams,
RowSelectionOptions,
ValueFormatterParams,
} from 'ag-grid-community';
import { AllCommunityModule, ModuleRegistry } from 'ag-grid-community';
import { AgGridReact } from 'ag-grid-react';
import {
createContext,
useDeferredValue,
use,
useEffect,
useMemo,
useRef,
useState,
type PropsWithChildren,
} from 'react';
import { renderModalWithEnterConfirm } from '../../../app/main/modalKeyboard';
import { SelectUI, type SelectOptionItem } from '../../../components/inputs/select';
import {
deleteStockAlertRow,
fetchStockAlerts,
searchStockAlertCandidates,
saveStockAlertRows,
type StockAlertDraftRow,
type StockAlertFilterValue,
type StockAlertSearchItem,
type StockAlertType,
} from './stockAlertApi';
import 'ag-grid-community/styles/ag-grid.css';
import 'ag-grid-community/styles/ag-theme-quartz.css';
import './StockAlertLayout.css';
ModuleRegistry.registerModules([AllCommunityModule]);
const FILTER_OPTIONS: SelectOptionItem[] = [
{ code: 'all', value: '전체' },
{ code: 'price', value: '현재가' },
{ code: 'top3', value: '등락폭이 큰 상위3종목' },
];
const ALERT_TYPE_LABEL_MAP = new Map<StockAlertType, string>([
['price', '현재가'],
['top3', '등락폭이 큰 상위3종목'],
]);
const ALERT_TYPE_VALUES = Array.from(ALERT_TYPE_LABEL_MAP.keys());
type StockAlertLayoutContextValue = {
filterValue: StockAlertFilterValue;
rows: StockAlertDraftRow[];
isLoading: boolean;
pendingFocusRowId: string | null;
setFilterValue: (value: StockAlertFilterValue) => void;
updateRow: (rowId: string, patch: Partial<StockAlertDraftRow>) => void;
addRow: (item: StockAlertSearchItem) => boolean;
clearPendingFocusRowId: () => void;
refreshRows: () => Promise<void>;
saveRows: () => Promise<void>;
deleteRows: (rowIds: string[]) => Promise<void>;
};
const StockAlertLayoutContext = createContext<StockAlertLayoutContextValue | null>(null);
function useStockAlertLayoutContext() {
const context = use(StockAlertLayoutContext);
if (!context) {
throw new Error('StockAlertLayoutProvider가 필요합니다.');
}
return context;
}
function getAlertTypeLabel(value: StockAlertType) {
return ALERT_TYPE_LABEL_MAP.get(value) ?? value;
}
function toAlertTypeLabels(values: StockAlertType[]) {
return values.map((value) => getAlertTypeLabel(value));
}
function mergeDraftRows(previousRows: StockAlertDraftRow[], nextRows: StockAlertDraftRow[]) {
const dirtyRowMap = new Map(
previousRows
.filter((row) => row.isDirty)
.map((row) => [row.persistedId ?? row.id, row]),
);
return nextRows.map((row) => {
const dirtyRow = dirtyRowMap.get(row.persistedId ?? row.id);
if (!dirtyRow) {
return row;
}
return {
...row,
stockCode: dirtyRow.stockCode,
stockName: dirtyRow.stockName,
alertTypes: dirtyRow.alertTypes,
alertTypeLabels: toAlertTypeLabels(dirtyRow.alertTypes),
isDirty: true,
};
});
}
export function StockAlertLayoutProvider({ children }: PropsWithChildren) {
const [messageApi, contextHolder] = message.useMessage();
const [filterValue, setFilterValue] = useState<StockAlertFilterValue>('all');
const [rows, setRows] = useState<StockAlertDraftRow[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [pendingFocusRowId, setPendingFocusRowId] = useState<string | null>(null);
const refreshRows = async () => {
setIsLoading(true);
try {
const nextRows = await fetchStockAlerts(filterValue);
setRows((previousRows) => mergeDraftRows(previousRows, nextRows));
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '종목 알림 목록을 불러오지 못했습니다.');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
void refreshRows();
}, [filterValue]);
const updateRow = (rowId: string, patch: Partial<StockAlertDraftRow>) => {
setRows((previousRows) =>
previousRows.map((row) =>
row.id === rowId
? {
...row,
...patch,
alertTypes: (patch.alertTypes ?? row.alertTypes) as StockAlertType[],
alertTypeLabels: toAlertTypeLabels((patch.alertTypes ?? row.alertTypes) as StockAlertType[]),
isDirty: true,
}
: row,
),
);
};
const addRow = (item: StockAlertSearchItem) => {
const nextAlertTypes: StockAlertType[] = [filterValue === 'all' ? 'price' : filterValue];
if (rows.some((row) => row.stockCode === item.stockCode)) {
messageApi.warning('이미 추가된 종목입니다.');
return false;
}
const nextRowId = `stock-alert-draft-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
setRows((previousRows) => [
{
id: nextRowId,
persistedId: null,
stockCode: item.stockCode,
stockName: item.stockName,
alertTypes: nextAlertTypes,
alertTypeLabels: toAlertTypeLabels(nextAlertTypes),
currentPrice: null,
changeRate: null,
quotedAt: null,
createdAt: null,
updatedAt: null,
isDirty: true,
isNew: true,
},
...previousRows,
]);
setPendingFocusRowId(nextRowId);
return true;
};
const saveRows = async () => {
const dirtyRows = rows.filter((row) => row.isDirty);
if (!dirtyRows.length) {
return;
}
if (dirtyRows.some((row) => !row.stockName.trim())) {
messageApi.error('종목명을 입력한 뒤 저장해 주세요.');
return;
}
if (dirtyRows.some((row) => !row.stockCode.trim())) {
messageApi.error('종목 검색으로 추가한 뒤 저장해 주세요.');
return;
}
if (dirtyRows.some((row) => !row.alertTypes.length)) {
messageApi.error('알림유형을 하나 이상 선택해 주세요.');
return;
}
setIsLoading(true);
try {
await saveStockAlertRows(dirtyRows);
await refreshRows();
messageApi.success('종목 알림을 저장했습니다.');
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '종목 알림 저장에 실패했습니다.');
setIsLoading(false);
}
};
const deleteRows = async (rowIds: string[]) => {
if (!rowIds.length) {
return;
}
setIsLoading(true);
try {
const persistedIds = rowIds
.map((rowId) => rows.find((row) => row.id === rowId)?.persistedId ?? null)
.filter((value): value is string => Boolean(value));
await Promise.all(persistedIds.map((id) => deleteStockAlertRow(id)));
setRows((previousRows) => previousRows.filter((row) => !rowIds.includes(row.id)));
await refreshRows();
messageApi.success('선택한 종목 알림을 삭제했습니다.');
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '종목 알림 삭제에 실패했습니다.');
setIsLoading(false);
}
};
const contextValue = useMemo<StockAlertLayoutContextValue>(
() => ({
filterValue,
rows,
isLoading,
pendingFocusRowId,
setFilterValue,
updateRow,
addRow,
clearPendingFocusRowId: () => {
setPendingFocusRowId(null);
},
refreshRows,
saveRows,
deleteRows,
}),
[filterValue, isLoading, pendingFocusRowId, rows],
);
return (
<StockAlertLayoutContext.Provider value={contextValue}>
{contextHolder}
{children}
</StockAlertLayoutContext.Provider>
);
}
export function StockAlertFilterPane() {
const { filterValue, setFilterValue } = useStockAlertLayoutContext();
return (
<Flex className="stock-alert-layout stock-alert-layout__filter">
<SelectUI
data={FILTER_OPTIONS}
value={filterValue}
allowClear={false}
placeholder="알림유형"
onChange={(nextCode) => {
setFilterValue((nextCode as StockAlertFilterValue | undefined) ?? 'all');
}}
/>
</Flex>
);
}
function formatPrice(value: number | null) {
if (typeof value !== 'number' || Number.isNaN(value)) {
return '';
}
return new Intl.NumberFormat('ko-KR').format(value);
}
function formatChangeRate(value: number | null) {
if (typeof value !== 'number' || Number.isNaN(value)) {
return '';
}
return `${value > 0 ? '+' : ''}${value.toFixed(2)}%`;
}
function formatQuotedAt(value: string | null) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return new Intl.DateTimeFormat('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).format(date);
}
function ChangeRateCellRenderer({ value }: ICellRendererParams<StockAlertDraftRow, number | null>) {
const numericValue = typeof value === 'number' ? value : null;
const className =
numericValue === null
? 'stock-alert-layout__change-rate--flat'
: numericValue > 0
? 'stock-alert-layout__change-rate--up'
: numericValue < 0
? 'stock-alert-layout__change-rate--down'
: 'stock-alert-layout__change-rate--flat';
return <span className={className}>{formatChangeRate(numericValue)}</span>;
}
type AlertTypeCellRendererProps = ICellRendererParams<StockAlertDraftRow> & {
isOpen?: boolean;
onOpen?: (rowId: string) => void;
onClose?: () => void;
};
function AlertTypeCellEditorRenderer({ data, isOpen = false, onOpen, onClose }: AlertTypeCellRendererProps) {
const { updateRow } = useStockAlertLayoutContext();
if (!data) {
return null;
}
return (
<div
className={`stock-alert-layout__alert-type-editor${isOpen ? ' is-open' : ''}`}
onClick={(event) => {
event.stopPropagation();
onOpen?.(data.id);
}}
>
<Select<StockAlertType[]>
mode="multiple"
size="small"
open={isOpen}
autoFocus={isOpen}
value={data.alertTypes}
options={ALERT_TYPE_VALUES.map((value) => ({
value,
label: getAlertTypeLabel(value),
}))}
placeholder="알림유형 선택"
maxTagCount="responsive"
allowClear={false}
popupMatchSelectWidth={false}
className="stock-alert-layout__alert-type-select"
onChange={(nextValues) => {
updateRow(data.id, {
alertTypes: nextValues as StockAlertType[],
});
}}
onOpenChange={(nextOpen) => {
if (nextOpen) {
onOpen?.(data.id);
return;
}
onClose?.();
}}
/>
</div>
);
}
function StockSearchModal({
open,
onCancel,
onSelect,
}: {
open: boolean;
onCancel: () => void;
onSelect: (item: StockAlertSearchItem) => void;
}) {
const [keyword, setKeyword] = useState('');
const deferredKeyword = useDeferredValue(keyword);
const [items, setItems] = useState<StockAlertSearchItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [messageApi, contextHolder] = message.useMessage();
const search = async (rawValue: string) => {
const trimmedValue = rawValue.trim();
if (!trimmedValue) {
setItems([]);
return;
}
setIsLoading(true);
try {
const nextItems = await searchStockAlertCandidates(trimmedValue, 20);
setItems(nextItems);
if (!nextItems.length) {
messageApi.info('조회 결과가 없습니다.');
}
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '종목 검색에 실패했습니다.');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (!open) {
setKeyword('');
setItems([]);
setIsLoading(false);
}
}, [open]);
return (
<>
{contextHolder}
<Modal
open={open}
title="종목 검색"
onCancel={onCancel}
footer={null}
width={720}
destroyOnHidden
modalRender={renderModalWithEnterConfirm}
>
<div className="stock-alert-layout__search-modal">
<Input.Search
value={keyword}
placeholder="종목명 또는 종목코드"
enterButton="조회"
allowClear
onChange={(event) => {
setKeyword(event.target.value);
}}
onSearch={(value) => {
void search(value);
}}
/>
<Table<StockAlertSearchItem>
size="small"
rowKey={(record) => record.stockCode}
loading={isLoading}
pagination={false}
dataSource={items}
scroll={{ y: 360 }}
locale={{
emptyText: deferredKeyword.trim() ? '조회 결과가 없습니다.' : '종목명 또는 종목코드를 입력하세요.',
}}
onRow={(record) => ({
onDoubleClick: () => {
onSelect(record);
},
})}
columns={[
{
title: '종목코드',
dataIndex: 'stockCode',
width: 140,
},
{
title: '종목명',
dataIndex: 'stockName',
},
{
title: '시장구분',
dataIndex: 'market',
width: 140,
},
{
key: 'action',
width: 88,
render: (_, record) => (
<Button
type="link"
onClick={() => {
onSelect(record);
}}
>
</Button>
),
},
]}
/>
</div>
</Modal>
</>
);
}
export function StockAlertGridPane() {
const {
rows,
isLoading,
pendingFocusRowId,
updateRow,
addRow,
clearPendingFocusRowId,
refreshRows,
saveRows,
deleteRows,
} = useStockAlertLayoutContext();
const [selectedRowIds, setSelectedRowIds] = useState<string[]>([]);
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
const [activeAlertTypeEditorRowId, setActiveAlertTypeEditorRowId] = useState<string | null>(null);
const gridApiRef = useRef<GridApi<StockAlertDraftRow> | null>(null);
const rowSelection = useMemo<RowSelectionOptions>(
() => ({
mode: 'multiRow',
enableClickSelection: true,
checkboxes: true,
headerCheckbox: true,
}),
[],
);
const columnDefs = useMemo<ColDef<StockAlertDraftRow>[]>(
() => [
{
field: 'stockName',
headerName: '종목명',
editable: false,
minWidth: 170,
flex: 1.3,
},
{
field: 'changeRate',
headerName: '등락률',
editable: false,
minWidth: 130,
flex: 0.9,
cellRenderer: ChangeRateCellRenderer,
},
{
field: 'currentPrice',
headerName: '현재가',
editable: false,
minWidth: 120,
flex: 0.9,
valueFormatter: (params: ValueFormatterParams<StockAlertDraftRow, number | null>) => formatPrice(params.value ?? null),
},
{
field: 'quotedAt',
headerName: '기준일시',
editable: false,
minWidth: 190,
flex: 1.2,
valueFormatter: (params: ValueFormatterParams<StockAlertDraftRow, string | null>) => formatQuotedAt(params.value ?? null),
},
{
field: 'alertTypes',
headerName: '알림유형',
editable: false,
minWidth: 220,
flex: 1.1,
cellRenderer: AlertTypeCellEditorRenderer,
cellRendererParams: (params: ICellRendererParams<StockAlertDraftRow>) => ({
isOpen: params.data?.id === activeAlertTypeEditorRowId,
onOpen: (rowId: string) => {
setActiveAlertTypeEditorRowId(rowId);
},
onClose: () => {
setActiveAlertTypeEditorRowId((currentValue) => (currentValue === params.data?.id ? null : currentValue));
},
}),
sortable: false,
filter: false,
valueFormatter: (params: ValueFormatterParams<StockAlertDraftRow, StockAlertType[] | null>) =>
Array.isArray(params.value) ? toAlertTypeLabels(params.value).join(', ') : '',
},
{
field: 'stockCode',
headerName: '종목코드',
hide: true,
},
],
[activeAlertTypeEditorRowId],
);
const defaultColDef = useMemo<ColDef<StockAlertDraftRow>>(
() => ({
sortable: true,
filter: true,
resizable: true,
}),
[],
);
const handleCellValueChanged = (event: CellValueChangedEvent<StockAlertDraftRow>) => {
if (!event.data) {
return;
}
if (event.colDef.field === 'stockName') {
updateRow(event.data.id, {
stockName: String(event.newValue ?? ''),
});
}
};
useEffect(() => {
if (!pendingFocusRowId || !gridApiRef.current) {
return;
}
const rowIndex = rows.findIndex((row) => row.id === pendingFocusRowId);
if (rowIndex < 0) {
return;
}
gridApiRef.current.ensureIndexVisible(rowIndex, 'top');
gridApiRef.current.setFocusedCell(rowIndex, 'alertTypes');
setActiveAlertTypeEditorRowId(pendingFocusRowId);
clearPendingFocusRowId();
}, [clearPendingFocusRowId, pendingFocusRowId, rows]);
return (
<>
<StockSearchModal
open={isSearchModalOpen}
onCancel={() => {
setIsSearchModalOpen(false);
}}
onSelect={(item) => {
const added = addRow(item);
if (added) {
setIsSearchModalOpen(false);
}
}}
/>
<div className="stock-alert-layout stock-alert-layout__grid">
<div className="stock-alert-layout__toolbar">
<Button
icon={<PlusOutlined />}
onClick={() => {
setIsSearchModalOpen(true);
}}
/>
<Button icon={<DeleteOutlined />} danger disabled={!selectedRowIds.length} onClick={() => void deleteRows(selectedRowIds)} />
<Button icon={<SaveOutlined />} type="primary" onClick={() => void saveRows()} />
<Button icon={<ReloadOutlined />} onClick={() => void refreshRows()} />
</div>
<div className="stock-alert-layout__surface ag-theme-quartz">
<AgGridReact<StockAlertDraftRow>
loading={isLoading}
rowData={rows}
columnDefs={columnDefs}
defaultColDef={defaultColDef}
rowSelection={rowSelection}
suppressRowClickSelection={false}
getRowId={(params) => params.data.id}
onGridReady={(event) => {
gridApiRef.current = event.api;
}}
onCellValueChanged={handleCellValueChanged}
onCellClicked={(event) => {
const rowId = event.data?.id ?? null;
if (event.colDef.field === 'alertTypes' && rowId) {
setActiveAlertTypeEditorRowId(rowId);
return;
}
setActiveAlertTypeEditorRowId(null);
}}
onSelectionChanged={(event) => {
setSelectedRowIds(event.api.getSelectedRows().map((row) => row.id));
}}
/>
</div>
</div>
</>
);
}

View File

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

View File

@@ -0,0 +1,171 @@
import { appendClientIdHeader } from '../../../app/main/clientIdentity';
import { getRegisteredAccessToken } from '../../../app/main/tokenAccess';
export type StockAlertFilterValue = 'all' | 'price' | 'top3';
export type StockAlertType = Exclude<StockAlertFilterValue, 'all'>;
export type StockAlertItem = {
id: string;
stockCode: string;
stockName: string;
alertTypes: StockAlertType[];
alertTypeLabels: string[];
currentPrice: number | null;
changeRate: number | null;
quotedAt: string | null;
createdAt: string;
updatedAt: string;
};
export type StockAlertDraftRow = {
id: string;
persistedId: string | null;
stockCode: string;
stockName: string;
alertTypes: StockAlertType[];
alertTypeLabels: string[];
currentPrice: number | null;
changeRate: number | null;
quotedAt: string | null;
createdAt: string | null;
updatedAt: string | null;
isDirty: boolean;
isNew: boolean;
};
export type StockAlertSearchItem = {
stockCode: string;
stockName: string;
market: string;
};
const WORK_SERVER_TIMEOUT_MS = 10000;
function resolveWorkServerBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) {
return import.meta.env.VITE_WORK_SERVER_URL;
}
return '/api';
}
const WORK_SERVER_BASE_URL = resolveWorkServerBaseUrl();
function toDraftRow(item: StockAlertItem): StockAlertDraftRow {
return {
id: item.id,
persistedId: item.id,
stockCode: item.stockCode,
stockName: item.stockName,
alertTypes: item.alertTypes,
alertTypeLabels: item.alertTypeLabels,
currentPrice: item.currentPrice,
changeRate: item.changeRate,
quotedAt: item.quotedAt,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
isDirty: false,
isNew: false,
};
}
export function createEmptyStockAlertRow(): StockAlertDraftRow {
const localId = `stock-alert-draft-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
return {
id: localId,
persistedId: null,
stockCode: '',
stockName: '',
alertTypes: ['price'],
alertTypeLabels: ['현재가'],
currentPrice: null,
changeRate: null,
quotedAt: null,
createdAt: null,
updatedAt: null,
isDirty: true,
isNew: true,
};
}
async function request<T>(path: string, init?: RequestInit) {
const headers = appendClientIdHeader(init?.headers);
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), WORK_SERVER_TIMEOUT_MS);
if (init?.body && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
const token = getRegisteredAccessToken();
if (token && !headers.has('X-Access-Token')) {
headers.set('X-Access-Token', token);
}
try {
const response = await fetch(`${WORK_SERVER_BASE_URL}${path}`, {
...init,
headers,
signal: controller.signal,
cache: init?.cache ?? 'no-store',
});
if (!response.ok) {
const text = await response.text();
try {
const payload = JSON.parse(text) as { message?: string };
throw new Error(payload.message || '종목 알림 요청에 실패했습니다.');
} catch {
throw new Error(text || '종목 알림 요청에 실패했습니다.');
}
}
return response.json() as Promise<T>;
} finally {
window.clearTimeout(timeoutId);
}
}
export async function fetchStockAlerts(filterValue: StockAlertFilterValue) {
const searchParams = new URLSearchParams();
if (filterValue !== 'all') {
searchParams.set('alertType', filterValue);
}
const path = `/stock-alerts${searchParams.size ? `?${searchParams.toString()}` : ''}`;
const response = await request<{ items: StockAlertItem[] }>(path);
return response.items.map(toDraftRow);
}
export async function searchStockAlertCandidates(query: string, limit = 20) {
const searchParams = new URLSearchParams({
query: query.trim(),
limit: String(limit),
});
const response = await request<{ items: StockAlertSearchItem[] }>(`/stock-alerts/search?${searchParams.toString()}`);
return response.items;
}
export async function saveStockAlertRows(rows: StockAlertDraftRow[]) {
const payload = rows.map((row) => ({
id: row.persistedId ?? undefined,
stockCode: row.stockCode,
stockName: row.stockName,
alertTypes: row.alertTypes,
}));
const response = await request<{ items: StockAlertItem[] }>('/stock-alerts/batch', {
method: 'PUT',
body: JSON.stringify({ items: payload }),
});
return response.items.map(toDraftRow);
}
export async function deleteStockAlertRow(id: string) {
await request<{ ok: boolean; id: string }>(`/stock-alerts/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
}

View File

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

View File

@@ -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/&lt;id&gt;</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

View File

@@ -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) {

View File

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