merge: sync origin main
This commit is contained in:
465
src/app/main/AutomationTypeManagementPage.tsx
Normal file
465
src/app/main/AutomationTypeManagementPage.tsx
Normal file
@@ -0,0 +1,465 @@
|
||||
import {
|
||||
ArrowsAltOutlined,
|
||||
ArrowLeftOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
PlusOutlined,
|
||||
ShrinkOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Alert, Button, Card, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Typography } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { MarkdownPreviewContent } from '../../components/markdownPreview';
|
||||
import {
|
||||
deleteAutomationType,
|
||||
upsertAutomationType,
|
||||
useAutomationTypeRegistry,
|
||||
type AutomationBehaviorType,
|
||||
type AutomationTypeRecord,
|
||||
} from './automationTypeAccess';
|
||||
import { useTokenAccess } from './tokenAccess';
|
||||
import './ChatTypeManagementPage.css';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
type AutomationTypeFormValue = {
|
||||
id?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
behaviorType: AutomationBehaviorType;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_FORM_VALUE: AutomationTypeFormValue = {
|
||||
name: '',
|
||||
description: '',
|
||||
behaviorType: 'none',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
function toFormValue(automationType: AutomationTypeRecord | null): AutomationTypeFormValue {
|
||||
if (!automationType) {
|
||||
return EMPTY_FORM_VALUE;
|
||||
}
|
||||
|
||||
return {
|
||||
id: automationType.id,
|
||||
name: automationType.name,
|
||||
description: automationType.description,
|
||||
behaviorType: automationType.behaviorType,
|
||||
enabled: automationType.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function AutomationTypeManagementPage() {
|
||||
const { hasAccess } = useTokenAccess();
|
||||
const { automationTypes, setAutomationTypes, isLoading, errorMessage } = useAutomationTypeRegistry();
|
||||
const [selectedAutomationTypeId, setSelectedAutomationTypeId] = useState<string | null>(automationTypes[0]?.id ?? null);
|
||||
const [isMobileViewport, setIsMobileViewport] = useState(false);
|
||||
const [mobileView, setMobileView] = useState<'edit' | 'preview'>('edit');
|
||||
const [detailMode, setDetailMode] = useState<'list' | 'detail'>('list');
|
||||
const [maximizedPane, setMaximizedPane] = useState<'none' | 'edit' | 'preview'>('none');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveErrorMessage, setSaveErrorMessage] = useState('');
|
||||
const [form] = Form.useForm<AutomationTypeFormValue>();
|
||||
const isPaneMaximized = maximizedPane !== 'none';
|
||||
|
||||
const selectedAutomationType = useMemo(
|
||||
() => automationTypes.find((item) => item.id === selectedAutomationTypeId) ?? null,
|
||||
[automationTypes, selectedAutomationTypeId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedAutomationTypeId && automationTypes.some((item) => item.id === selectedAutomationTypeId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedAutomationTypeId(automationTypes[0]?.id ?? null);
|
||||
}, [automationTypes, selectedAutomationTypeId]);
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue(toFormValue(isCreating ? null : selectedAutomationType));
|
||||
}, [form, isCreating, selectedAutomationType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (detailMode !== 'detail') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCreating || selectedAutomationType) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDetailMode('list');
|
||||
}, [detailMode, isCreating, selectedAutomationType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia('(max-width: 960px)');
|
||||
const update = () => {
|
||||
setIsMobileViewport(mediaQuery.matches);
|
||||
if (!mediaQuery.matches) {
|
||||
setMobileView('edit');
|
||||
}
|
||||
};
|
||||
|
||||
update();
|
||||
mediaQuery.addEventListener('change', update);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openCreateForm = () => {
|
||||
setIsCreating(true);
|
||||
setSelectedAutomationTypeId(null);
|
||||
setDetailMode('detail');
|
||||
setMaximizedPane('none');
|
||||
form.resetFields();
|
||||
form.setFieldsValue(EMPTY_FORM_VALUE);
|
||||
};
|
||||
|
||||
const openDetail = (automationTypeId: string) => {
|
||||
setIsCreating(false);
|
||||
setSelectedAutomationTypeId(automationTypeId);
|
||||
setDetailMode('detail');
|
||||
setMaximizedPane('none');
|
||||
};
|
||||
|
||||
const closeDetail = () => {
|
||||
setIsCreating(false);
|
||||
setDetailMode('list');
|
||||
setMaximizedPane('none');
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedAutomationType) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm(`"${selectedAutomationType.name}" 자동화 유형을 삭제할까요?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextAutomationTypes = deleteAutomationType(automationTypes, selectedAutomationType.id);
|
||||
setIsSaving(true);
|
||||
setSaveErrorMessage('');
|
||||
|
||||
try {
|
||||
const savedAutomationTypes = await setAutomationTypes(nextAutomationTypes);
|
||||
setSelectedAutomationTypeId(savedAutomationTypes[0]?.id ?? null);
|
||||
setIsCreating(false);
|
||||
setDetailMode('list');
|
||||
form.resetFields();
|
||||
form.setFieldsValue(EMPTY_FORM_VALUE);
|
||||
} catch (error) {
|
||||
setSaveErrorMessage(error instanceof Error ? error.message : '자동화 처리 유형 삭제에 실패했습니다.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!hasAccess) {
|
||||
return (
|
||||
<Card title="자동화 유형 관리" className="chat-type-management-page">
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
|
||||
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 자동화 처리 유형을 관리하세요."
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`chat-type-management-page${detailMode === 'detail' ? ' chat-type-management-page--detail' : ''}${
|
||||
isPaneMaximized ? ' chat-type-management-page--pane-maximized' : ''
|
||||
}`}
|
||||
>
|
||||
{detailMode === 'list' ? (
|
||||
<Card
|
||||
title="자동화 유형 관리"
|
||||
className="chat-type-management-page__card"
|
||||
extra={
|
||||
<Button icon={<PlusOutlined />} onClick={openCreateForm}>
|
||||
신규 유형
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="chat-type-management-page__list">
|
||||
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
|
||||
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
||||
<div className="chat-type-management-page__list-header">
|
||||
<Title level={5}>등록 자동화 유형</Title>
|
||||
<Text type="secondary">{isLoading ? '불러오는 중' : `${automationTypes.length}건`}</Text>
|
||||
</div>
|
||||
|
||||
{automationTypes.length > 0 ? (
|
||||
<List
|
||||
dataSource={automationTypes}
|
||||
renderItem={(item) => {
|
||||
const itemClassName =
|
||||
item.id === selectedAutomationTypeId
|
||||
? 'chat-type-management-page__item chat-type-management-page__item--active'
|
||||
: 'chat-type-management-page__item';
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
className={itemClassName}
|
||||
onClick={() => {
|
||||
openDetail(item.id);
|
||||
}}
|
||||
actions={[
|
||||
<Button
|
||||
key="edit"
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
disabled={isSaving}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openDetail(item.id);
|
||||
}}
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
<div className="chat-type-management-page__item-main">
|
||||
<Space size={[8, 8]} wrap>
|
||||
<Text strong>{item.name}</Text>
|
||||
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag>
|
||||
</Space>
|
||||
<div className="chat-type-management-page__item-description">
|
||||
{item.description ? <MarkdownPreviewContent content={item.description} maxBlocks={3} /> : '설명 없음'}
|
||||
</div>
|
||||
</div>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="등록된 자동화 유형이 없습니다." />
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card
|
||||
title={isCreating ? '자동화 유형 등록' : '자동화 유형 상세'}
|
||||
className={`chat-type-management-page__card${isPaneMaximized ? ' chat-type-management-page__card--pane-maximized' : ''}`}
|
||||
>
|
||||
<div className="chat-type-management-page__editor">
|
||||
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
|
||||
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
||||
<div className="chat-type-management-page__list-header">
|
||||
<Title level={5}>{isCreating ? '신규 자동화 유형 등록' : selectedAutomationType?.name ?? '자동화 유형 수정'}</Title>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
className="chat-type-management-page__editor-form"
|
||||
layout="vertical"
|
||||
form={form}
|
||||
initialValues={EMPTY_FORM_VALUE}
|
||||
onFinish={async (values) => {
|
||||
const nextAutomationTypes = upsertAutomationType(automationTypes, {
|
||||
...values,
|
||||
behaviorType:
|
||||
selectedAutomationType?.behaviorType ?? values.behaviorType ?? EMPTY_FORM_VALUE.behaviorType,
|
||||
});
|
||||
setIsSaving(true);
|
||||
setSaveErrorMessage('');
|
||||
|
||||
try {
|
||||
const savedAutomationTypes = await setAutomationTypes(nextAutomationTypes);
|
||||
const savedAutomationType = savedAutomationTypes.find(
|
||||
(item) => item.id === values.id || item.name === values.name,
|
||||
);
|
||||
setIsCreating(false);
|
||||
setSelectedAutomationTypeId(savedAutomationType?.id ?? null);
|
||||
setDetailMode('detail');
|
||||
} catch (error) {
|
||||
setSaveErrorMessage(error instanceof Error ? error.message : '자동화 처리 유형 저장에 실패했습니다.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form.Item name="id" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<div className={`chat-type-management-page__meta-grid${isPaneMaximized ? ' chat-type-management-page__meta-grid--hidden' : ''}`}>
|
||||
<Form.Item
|
||||
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
|
||||
label="유형명"
|
||||
name="name"
|
||||
rules={[{ required: true, message: '유형명을 입력하세요.' }]}
|
||||
>
|
||||
<Input placeholder="예: 자동화 메모" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--enabled"
|
||||
label="사용 여부"
|
||||
name="enabled"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch checkedChildren="사용" unCheckedChildren="중지" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="chat-type-management-page__markdown-field">
|
||||
<Text strong className="chat-type-management-page__field-label">
|
||||
설명
|
||||
</Text>
|
||||
<div className="chat-type-management-page__markdown-editor">
|
||||
<div className="chat-type-management-page__editor-toolbar">
|
||||
{isMobileViewport ? (
|
||||
<Segmented
|
||||
className="chat-type-management-page__mobile-toggle"
|
||||
options={[
|
||||
{ label: '입력', value: 'edit' },
|
||||
{ label: '미리보기', value: 'preview' },
|
||||
]}
|
||||
value={mobileView}
|
||||
onChange={(value) => {
|
||||
setMobileView(value as 'edit' | 'preview');
|
||||
setMaximizedPane('none');
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Space size={8} wrap>
|
||||
<Button
|
||||
type={maximizedPane === 'edit' ? 'primary' : 'default'}
|
||||
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||
onClick={() => {
|
||||
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
|
||||
}}
|
||||
>
|
||||
{maximizedPane === 'edit' ? '입력 축소' : '입력 최대화'}
|
||||
</Button>
|
||||
<Button
|
||||
type={maximizedPane === 'preview' ? 'primary' : 'default'}
|
||||
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||
onClick={() => {
|
||||
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
|
||||
}}
|
||||
>
|
||||
{maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`chat-type-management-page__markdown-grid${
|
||||
maximizedPane !== 'none' ? ' chat-type-management-page__markdown-grid--maximized' : ''
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`chat-type-management-page__markdown-pane${
|
||||
isMobileViewport && mobileView === 'preview'
|
||||
? ' chat-type-management-page__markdown-pane--mobile-hidden'
|
||||
: ''
|
||||
}${
|
||||
maximizedPane === 'preview' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="chat-type-management-page__markdown-pane-header">
|
||||
<Text type="secondary">입력</Text>
|
||||
{isMobileViewport ? (
|
||||
<Button
|
||||
size="small"
|
||||
type={maximizedPane === 'edit' ? 'primary' : 'default'}
|
||||
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||
onClick={() => {
|
||||
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
|
||||
}}
|
||||
>
|
||||
{maximizedPane === 'edit' ? '축소' : '최대화'}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<Form.Item name="description" noStyle>
|
||||
<Input.TextArea
|
||||
autoSize={isMobileViewport ? false : { minRows: 10, maxRows: 18 }}
|
||||
className="chat-type-management-page__markdown-textarea"
|
||||
placeholder={
|
||||
'## 처리 기준\n- 이 자동화 유형의 작업 규칙을 Markdown으로 정리하세요.\n\n## 실패 처리\n- 에러/롤백 기준'
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div
|
||||
className={`chat-type-management-page__markdown-pane${
|
||||
isMobileViewport && mobileView === 'edit'
|
||||
? ' chat-type-management-page__markdown-pane--mobile-hidden'
|
||||
: ''
|
||||
}${
|
||||
maximizedPane === 'edit' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="chat-type-management-page__markdown-preview">
|
||||
<div className="chat-type-management-page__markdown-pane-header">
|
||||
<Text type="secondary">미리보기</Text>
|
||||
{isMobileViewport ? (
|
||||
<Button
|
||||
size="small"
|
||||
type={maximizedPane === 'preview' ? 'primary' : 'default'}
|
||||
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||
onClick={() => {
|
||||
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
|
||||
}}
|
||||
>
|
||||
{maximizedPane === 'preview' ? '축소' : '최대화'}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="chat-type-management-page__markdown-preview-body">
|
||||
<Form.Item noStyle shouldUpdate={(prev, next) => prev.description !== next.description}>
|
||||
{({ getFieldValue }) => {
|
||||
const description = String(getFieldValue('description') ?? '').trim();
|
||||
|
||||
return description ? (
|
||||
<MarkdownPreviewContent content={description} />
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="미리보기할 설명이 없습니다." />
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`chat-type-management-page__form-actions${
|
||||
isPaneMaximized ? ' chat-type-management-page__form-actions--compact' : ''
|
||||
}`}
|
||||
>
|
||||
<Space wrap>
|
||||
<Button type="primary" htmlType="submit" loading={isSaving}>
|
||||
{isCreating ? '등록' : '수정 저장'}
|
||||
</Button>
|
||||
<Button onClick={openCreateForm} disabled={isSaving}>
|
||||
새 입력
|
||||
</Button>
|
||||
</Space>
|
||||
<Space wrap>
|
||||
{!isCreating && selectedAutomationType ? (
|
||||
<Button danger icon={<DeleteOutlined />} loading={isSaving} onClick={() => void handleDelete()}>
|
||||
삭제
|
||||
</Button>
|
||||
) : null}
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={closeDetail}>
|
||||
목록
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,7 +33,6 @@ export function ChatRuntimeBridgeV2() {
|
||||
chatTypeId: null,
|
||||
chatTypeLabel: '',
|
||||
chatTypeDescription: '',
|
||||
chatTypeIsTemplate: false,
|
||||
}),
|
||||
[currentPage, focusedComponentId],
|
||||
);
|
||||
|
||||
@@ -1,27 +1,79 @@
|
||||
.chat-type-management-page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page--detail {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card,
|
||||
.chat-type-management-page .ant-card-body,
|
||||
.chat-type-management-page__card {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card-head {
|
||||
min-height: 52px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card-head-title,
|
||||
.chat-type-management-page .ant-card-extra {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__list,
|
||||
.chat-type-management-page__editor {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__list .ant-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.chat-type-management-page__editor-form {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__editor-form .ant-form-item {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__list-header .ant-typography {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__item {
|
||||
@@ -44,3 +96,246 @@
|
||||
.chat-type-management-page__item-description.ant-typography {
|
||||
margin: 8px 0 10px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__item-description {
|
||||
margin: 8px 0 10px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__item-description .markdown-preview > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-field {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__field-label {
|
||||
flex: 0 0 auto;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-editor {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__mobile-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-grid {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 6px;
|
||||
align-items: stretch;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-grid--maximized {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane .ant-form-item {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane--desktop-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane .ant-input-textarea,
|
||||
.chat-type-management-page__markdown-pane .ant-input {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-textarea {
|
||||
height: 100% !important;
|
||||
min-height: 180px;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-textarea textarea {
|
||||
height: 100% !important;
|
||||
min-height: 180px;
|
||||
overflow: auto !important;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 12px;
|
||||
background: #fafafa;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-preview-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-preview-body .markdown-preview > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__form-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__form-actions--compact {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) auto;
|
||||
gap: 8px 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-grid--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item .ant-form-item-label {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item--enabled .ant-form-item-control-input {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__editor,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-form,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-field,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-editor,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-grid {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__field-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__card--pane-maximized .ant-card-body {
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.chat-type-management-page,
|
||||
.chat-type-management-page .ant-card,
|
||||
.chat-type-management-page .ant-card-body,
|
||||
.chat-type-management-page__card,
|
||||
.chat-type-management-page__list,
|
||||
.chat-type-management-page__editor,
|
||||
.chat-type-management-page__editor-form,
|
||||
.chat-type-management-page__markdown-field,
|
||||
.chat-type-management-page__markdown-editor,
|
||||
.chat-type-management-page__markdown-grid,
|
||||
.chat-type-management-page__markdown-pane,
|
||||
.chat-type-management-page__markdown-preview {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__list-header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card-head {
|
||||
min-height: 48px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card-head-title,
|
||||
.chat-type-management-page .ant-card-extra,
|
||||
.chat-type-management-page .ant-card-body {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__mobile-toggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.chat-type-management-page__editor-toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane--mobile-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-textarea,
|
||||
.chat-type-management-page__markdown-textarea textarea,
|
||||
.chat-type-management-page__markdown-preview-body {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__form-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { ArrowLeftOutlined, DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { Alert, Button, Card, Checkbox, Empty, Form, Input, List, Space, Switch, Tag, Typography } from 'antd';
|
||||
import {
|
||||
ArrowsAltOutlined,
|
||||
ArrowLeftOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
PlusOutlined,
|
||||
ShrinkOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Alert, Button, Card, Checkbox, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Typography } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { MarkdownPreviewContent } from '../../components/markdownPreview';
|
||||
import {
|
||||
canUseChatType,
|
||||
CHAT_PERMISSION_ROLE_LABELS,
|
||||
@@ -14,13 +22,12 @@ import {
|
||||
import { useTokenAccess } from './tokenAccess';
|
||||
import './ChatTypeManagementPage.css';
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
type ChatTypeFormValue = {
|
||||
id?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
isTemplate: boolean;
|
||||
permissions: ChatPermissionRole[];
|
||||
enabled: boolean;
|
||||
};
|
||||
@@ -28,7 +35,6 @@ type ChatTypeFormValue = {
|
||||
const EMPTY_FORM_VALUE: ChatTypeFormValue = {
|
||||
name: '',
|
||||
description: '',
|
||||
isTemplate: false,
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
};
|
||||
@@ -42,7 +48,6 @@ function toFormValue(chatType: ChatTypeRecord | null): ChatTypeFormValue {
|
||||
id: chatType.id,
|
||||
name: chatType.name,
|
||||
description: chatType.description,
|
||||
isTemplate: chatType.isTemplate,
|
||||
permissions: chatType.permissions,
|
||||
enabled: chatType.enabled,
|
||||
};
|
||||
@@ -52,12 +57,16 @@ export function ChatTypeManagementPage() {
|
||||
const { hasAccess } = useTokenAccess();
|
||||
const { chatTypes, setChatTypes, isLoading, errorMessage } = useChatTypeRegistry();
|
||||
const [selectedChatTypeId, setSelectedChatTypeId] = useState<string | null>(chatTypes[0]?.id ?? null);
|
||||
const [isMobileViewport, setIsMobileViewport] = useState(false);
|
||||
const [mobileView, setMobileView] = useState<'edit' | 'preview'>('edit');
|
||||
const [detailMode, setDetailMode] = useState<'list' | 'detail'>('list');
|
||||
const [maximizedPane, setMaximizedPane] = useState<'none' | 'edit' | 'preview'>('none');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveErrorMessage, setSaveErrorMessage] = useState('');
|
||||
const [form] = Form.useForm<ChatTypeFormValue>();
|
||||
const userRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]);
|
||||
const isPaneMaximized = maximizedPane !== 'none';
|
||||
|
||||
const selectedChatType = useMemo(
|
||||
() => chatTypes.find((item) => item.id === selectedChatTypeId) ?? null,
|
||||
@@ -88,10 +97,32 @@ export function ChatTypeManagementPage() {
|
||||
setDetailMode('list');
|
||||
}, [detailMode, isCreating, selectedChatType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia('(max-width: 960px)');
|
||||
const update = () => {
|
||||
setIsMobileViewport(mediaQuery.matches);
|
||||
if (!mediaQuery.matches) {
|
||||
setMobileView('edit');
|
||||
}
|
||||
};
|
||||
|
||||
update();
|
||||
mediaQuery.addEventListener('change', update);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openCreateForm = () => {
|
||||
setIsCreating(true);
|
||||
setSelectedChatTypeId(null);
|
||||
setDetailMode('detail');
|
||||
setMaximizedPane('none');
|
||||
form.resetFields();
|
||||
form.setFieldsValue(EMPTY_FORM_VALUE);
|
||||
};
|
||||
@@ -100,11 +131,13 @@ export function ChatTypeManagementPage() {
|
||||
setIsCreating(false);
|
||||
setSelectedChatTypeId(chatTypeId);
|
||||
setDetailMode('detail');
|
||||
setMaximizedPane('none');
|
||||
};
|
||||
|
||||
const closeDetail = () => {
|
||||
setIsCreating(false);
|
||||
setDetailMode('list');
|
||||
setMaximizedPane('none');
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
@@ -148,7 +181,11 @@ export function ChatTypeManagementPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-type-management-page">
|
||||
<div
|
||||
className={`chat-type-management-page${detailMode === 'detail' ? ' chat-type-management-page--detail' : ''}${
|
||||
isPaneMaximized ? ' chat-type-management-page--pane-maximized' : ''
|
||||
}`}
|
||||
>
|
||||
{detailMode === 'list' ? (
|
||||
<Card
|
||||
title="컨텍스트 권한 관리"
|
||||
@@ -200,14 +237,17 @@ export function ChatTypeManagementPage() {
|
||||
<Space size={[8, 8]} wrap>
|
||||
<Text strong>{item.name}</Text>
|
||||
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag>
|
||||
{item.isTemplate ? <Tag color="gold">템플릿</Tag> : null}
|
||||
<Tag color={isCurrentUserAllowed ? 'blue' : 'default'}>
|
||||
{isCurrentUserAllowed ? '현재 사용자 사용 가능' : '현재 사용자 사용 불가'}
|
||||
</Tag>
|
||||
</Space>
|
||||
<Paragraph className="chat-type-management-page__item-description">
|
||||
{item.description || '기본 문맥 설명 없음'}
|
||||
</Paragraph>
|
||||
<div className="chat-type-management-page__item-description">
|
||||
{item.description ? (
|
||||
<MarkdownPreviewContent content={item.description} maxBlocks={3} />
|
||||
) : (
|
||||
'기본 문맥 설명 없음'
|
||||
)}
|
||||
</div>
|
||||
<Space size={[6, 6]} wrap>
|
||||
{item.permissions.map((permission) => (
|
||||
<Tag key={`${item.id}-${permission}`}>{CHAT_PERMISSION_ROLE_LABELS[permission]}</Tag>
|
||||
@@ -226,29 +266,17 @@ export function ChatTypeManagementPage() {
|
||||
) : (
|
||||
<Card
|
||||
title={isCreating ? '컨텍스트 등록' : '컨텍스트 상세'}
|
||||
className="chat-type-management-page__card"
|
||||
extra={
|
||||
<Space wrap>
|
||||
{!isCreating && selectedChatType ? (
|
||||
<Button danger icon={<DeleteOutlined />} loading={isSaving} onClick={() => void handleDelete()}>
|
||||
삭제
|
||||
</Button>
|
||||
) : null}
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={closeDetail}>
|
||||
목록
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
className={`chat-type-management-page__card${isPaneMaximized ? ' chat-type-management-page__card--pane-maximized' : ''}`}
|
||||
>
|
||||
<div className="chat-type-management-page__editor">
|
||||
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
|
||||
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
||||
<div className="chat-type-management-page__list-header">
|
||||
<Title level={5}>{isCreating ? '신규 컨텍스트 등록' : selectedChatType?.name ?? '컨텍스트 수정'}</Title>
|
||||
<Text type="secondary">이름과 기본 문맥 설명, 권한 대상만 관리하면 채팅에 그대로 반영됩니다.</Text>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
className="chat-type-management-page__editor-form"
|
||||
layout="vertical"
|
||||
form={form}
|
||||
initialValues={EMPTY_FORM_VALUE}
|
||||
@@ -273,41 +301,187 @@ export function ChatTypeManagementPage() {
|
||||
<Form.Item name="id" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="컨텍스트명"
|
||||
name="name"
|
||||
rules={[{ required: true, message: '컨텍스트명을 입력하세요.' }]}
|
||||
<div className={`chat-type-management-page__meta-grid${isPaneMaximized ? ' chat-type-management-page__meta-grid--hidden' : ''}`}>
|
||||
<Form.Item
|
||||
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
|
||||
label="컨텍스트명"
|
||||
name="name"
|
||||
rules={[{ required: true, message: '컨텍스트명을 입력하세요.' }]}
|
||||
>
|
||||
<Input placeholder="예: 운영 문의" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--permissions"
|
||||
label="권한 대상"
|
||||
name="permissions"
|
||||
>
|
||||
<Checkbox.Group
|
||||
options={[
|
||||
{ label: CHAT_PERMISSION_ROLE_LABELS['token-user'], value: 'token-user' },
|
||||
{ label: CHAT_PERMISSION_ROLE_LABELS.guest, value: 'guest' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--enabled"
|
||||
label="사용 여부"
|
||||
name="enabled"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch checkedChildren="사용" unCheckedChildren="중지" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="chat-type-management-page__markdown-field">
|
||||
<Text strong className="chat-type-management-page__field-label">
|
||||
기본 문맥 설명
|
||||
</Text>
|
||||
<div className="chat-type-management-page__markdown-editor">
|
||||
<div className="chat-type-management-page__editor-toolbar">
|
||||
{isMobileViewport ? (
|
||||
<Segmented
|
||||
className="chat-type-management-page__mobile-toggle"
|
||||
options={[
|
||||
{ label: '입력', value: 'edit' },
|
||||
{ label: '미리보기', value: 'preview' },
|
||||
]}
|
||||
value={mobileView}
|
||||
onChange={(value) => {
|
||||
setMobileView(value as 'edit' | 'preview');
|
||||
setMaximizedPane('none');
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Space size={8} wrap>
|
||||
<Button
|
||||
type={maximizedPane === 'edit' ? 'primary' : 'default'}
|
||||
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||
onClick={() => {
|
||||
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
|
||||
}}
|
||||
>
|
||||
{maximizedPane === 'edit' ? '입력 축소' : '입력 최대화'}
|
||||
</Button>
|
||||
<Button
|
||||
type={maximizedPane === 'preview' ? 'primary' : 'default'}
|
||||
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||
onClick={() => {
|
||||
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
|
||||
}}
|
||||
>
|
||||
{maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`chat-type-management-page__markdown-grid${
|
||||
maximizedPane !== 'none' ? ' chat-type-management-page__markdown-grid--maximized' : ''
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`chat-type-management-page__markdown-pane${
|
||||
isMobileViewport && mobileView === 'preview'
|
||||
? ' chat-type-management-page__markdown-pane--mobile-hidden'
|
||||
: ''
|
||||
}${
|
||||
maximizedPane === 'preview' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="chat-type-management-page__markdown-pane-header">
|
||||
<Text type="secondary">입력</Text>
|
||||
{isMobileViewport ? (
|
||||
<Button
|
||||
size="small"
|
||||
type={maximizedPane === 'edit' ? 'primary' : 'default'}
|
||||
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||
onClick={() => {
|
||||
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
|
||||
}}
|
||||
>
|
||||
{maximizedPane === 'edit' ? '축소' : '최대화'}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<Form.Item name="description" noStyle>
|
||||
<Input.TextArea
|
||||
autoSize={isMobileViewport ? false : { minRows: 10, maxRows: 18 }}
|
||||
className="chat-type-management-page__markdown-textarea"
|
||||
placeholder={
|
||||
'## 기본 처리\n- 이 채팅 유형의 실행 기준을 Markdown으로 정리하세요.\n\n## 검증\n- 브라우저/API 확인 기준'
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div
|
||||
className={`chat-type-management-page__markdown-pane${
|
||||
isMobileViewport && mobileView === 'edit'
|
||||
? ' chat-type-management-page__markdown-pane--mobile-hidden'
|
||||
: ''
|
||||
}${
|
||||
maximizedPane === 'edit' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="chat-type-management-page__markdown-preview">
|
||||
<div className="chat-type-management-page__markdown-pane-header">
|
||||
<Text type="secondary">미리보기</Text>
|
||||
{isMobileViewport ? (
|
||||
<Button
|
||||
size="small"
|
||||
type={maximizedPane === 'preview' ? 'primary' : 'default'}
|
||||
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||
onClick={() => {
|
||||
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
|
||||
}}
|
||||
>
|
||||
{maximizedPane === 'preview' ? '축소' : '최대화'}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="chat-type-management-page__markdown-preview-body">
|
||||
<Form.Item noStyle shouldUpdate={(prev, next) => prev.description !== next.description}>
|
||||
{({ getFieldValue }) => {
|
||||
const description = String(getFieldValue('description') ?? '').trim();
|
||||
|
||||
return description ? (
|
||||
<MarkdownPreviewContent content={description} />
|
||||
) : (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="미리보기할 문맥 설명이 없습니다."
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`chat-type-management-page__form-actions${
|
||||
isPaneMaximized ? ' chat-type-management-page__form-actions--compact' : ''
|
||||
}`}
|
||||
>
|
||||
<Input placeholder="예: 운영 문의" />
|
||||
</Form.Item>
|
||||
<Form.Item label="기본 문맥 설명" name="description">
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 4, maxRows: 8 }}
|
||||
placeholder="이 컨텍스트에서 기본으로 참고해야 할 문맥을 입력하세요."
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="템플릿 요청 여부" name="isTemplate" valuePropName="checked">
|
||||
<Switch checkedChildren="템플릿" unCheckedChildren="일반" />
|
||||
</Form.Item>
|
||||
<Form.Item label="권한 대상" name="permissions">
|
||||
<Checkbox.Group
|
||||
options={[
|
||||
{ label: CHAT_PERMISSION_ROLE_LABELS['token-user'], value: 'token-user' },
|
||||
{ label: CHAT_PERMISSION_ROLE_LABELS.guest, value: 'guest' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="사용 여부" name="enabled" valuePropName="checked">
|
||||
<Switch checkedChildren="사용" unCheckedChildren="중지" />
|
||||
</Form.Item>
|
||||
<Space wrap>
|
||||
<Button type="primary" htmlType="submit" loading={isSaving}>
|
||||
{isCreating ? '등록' : '수정 저장'}
|
||||
</Button>
|
||||
<Button onClick={openCreateForm} disabled={isSaving}>
|
||||
새 입력
|
||||
</Button>
|
||||
</Space>
|
||||
<Space wrap>
|
||||
<Button type="primary" htmlType="submit" loading={isSaving}>
|
||||
{isCreating ? '등록' : '수정 저장'}
|
||||
</Button>
|
||||
<Button onClick={openCreateForm} disabled={isSaving}>
|
||||
새 입력
|
||||
</Button>
|
||||
</Space>
|
||||
<Space wrap>
|
||||
{!isCreating && selectedChatType ? (
|
||||
<Button danger icon={<DeleteOutlined />} loading={isSaving} onClick={() => void handleDelete()}>
|
||||
삭제
|
||||
</Button>
|
||||
) : null}
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={closeDetail}>
|
||||
목록
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -76,11 +76,10 @@ type ChatTypeOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
description: string;
|
||||
isTemplate: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type PreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'document' | 'pdf' | 'file';
|
||||
type PreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'diff' | 'document' | 'pdf' | 'file';
|
||||
|
||||
type PreviewItem = {
|
||||
id: string;
|
||||
@@ -98,7 +97,6 @@ type PendingChatRequest = {
|
||||
chatTypeId: string;
|
||||
chatTypeLabel: string;
|
||||
chatTypeDescription: string;
|
||||
chatTypeIsTemplate: boolean;
|
||||
retryCount: number;
|
||||
failed: boolean;
|
||||
};
|
||||
@@ -109,7 +107,6 @@ type PendingContextConfirm = {
|
||||
chatTypeId: string;
|
||||
chatTypeLabel: string;
|
||||
chatTypeDescription: string;
|
||||
chatTypeIsTemplate: boolean;
|
||||
includedContextCount: number;
|
||||
omittedContextCount: number;
|
||||
};
|
||||
@@ -660,6 +657,21 @@ function normalizePreviewUrl(value: string) {
|
||||
return normalizeChatResourceUrl(value);
|
||||
}
|
||||
|
||||
function isPreviewRouteUrl(url: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin);
|
||||
const pathname = parsed.pathname.toLowerCase();
|
||||
const hasKnownFileExtension = /\.[a-z0-9]+$/i.test(pathname);
|
||||
return !hasKnownFileExtension && /^https?:$/.test(parsed.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function classifyPreviewKind(url: string): PreviewKind {
|
||||
const pathname = url.toLowerCase().split('?')[0] ?? '';
|
||||
|
||||
@@ -675,6 +687,10 @@ function classifyPreviewKind(url: string): PreviewKind {
|
||||
return 'markdown';
|
||||
}
|
||||
|
||||
if (/\.(diff|patch)$/i.test(pathname)) {
|
||||
return 'diff';
|
||||
}
|
||||
|
||||
if (/\.(ts|tsx|js|jsx|json|css|scss|html|xml|java|kt|sql|sh|py|go|rs|c|cpp|h|hpp|yml|yaml)$/i.test(pathname)) {
|
||||
return 'code';
|
||||
}
|
||||
@@ -687,6 +703,10 @@ function classifyPreviewKind(url: string): PreviewKind {
|
||||
return 'pdf';
|
||||
}
|
||||
|
||||
if (isPreviewRouteUrl(url)) {
|
||||
return 'document';
|
||||
}
|
||||
|
||||
return 'file';
|
||||
}
|
||||
|
||||
@@ -869,7 +889,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
value: item.id,
|
||||
label: item.name,
|
||||
description: item.description,
|
||||
isTemplate: item.isTemplate,
|
||||
disabled: !isAllowed,
|
||||
};
|
||||
}),
|
||||
@@ -959,7 +978,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
chatTypeId: selectedChatType?.id ?? null,
|
||||
chatTypeLabel: selectedChatType?.name ?? '',
|
||||
chatTypeDescription: selectedChatType?.description ?? '',
|
||||
chatTypeIsTemplate: selectedChatType?.isTemplate ?? false,
|
||||
};
|
||||
const {
|
||||
conversationItems,
|
||||
@@ -2124,7 +2142,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
chatTypeId: request.chatTypeId,
|
||||
chatTypeLabel: request.chatTypeLabel,
|
||||
chatTypeDescription: request.chatTypeDescription,
|
||||
chatTypeIsTemplate: request.chatTypeIsTemplate,
|
||||
requestId: request.requestId,
|
||||
mode: request.mode,
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ import { PlanBoardChartsPage, PlanBoardPage, PlanSchedulePage, ReleaseReviewPage
|
||||
import { useSearchLayer } from '../../layer';
|
||||
import { useAppStore } from '../../store';
|
||||
import { LayoutPlaygroundView } from '../../views/play/LayoutPlaygroundView';
|
||||
import { AutomationTypeManagementPage } from './AutomationTypeManagementPage';
|
||||
import { ChatTypeManagementPage } from './ChatTypeManagementPage';
|
||||
import { ChatSourceChangesPage } from './ChatSourceChangesPage';
|
||||
import { MainChatPanel } from './MainChatPanel';
|
||||
@@ -169,6 +170,10 @@ export function MainContent({
|
||||
return <HistoryPage />;
|
||||
}
|
||||
|
||||
if (selectionId === 'page:plans:automation-type') {
|
||||
return <AutomationTypeManagementPage />;
|
||||
}
|
||||
|
||||
const planStatus = getPlanStatusFromWindowSelection(selectionId);
|
||||
|
||||
if (planStatus) {
|
||||
|
||||
@@ -3,19 +3,20 @@ import {
|
||||
BellOutlined,
|
||||
ClockCircleOutlined,
|
||||
CopyOutlined,
|
||||
DownOutlined,
|
||||
FileMarkdownOutlined,
|
||||
LoadingOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
ProfileOutlined,
|
||||
ReloadOutlined,
|
||||
RightOutlined,
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Drawer,
|
||||
Dropdown,
|
||||
Grid,
|
||||
@@ -884,6 +885,7 @@ export function MainHeader({
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [automationGroupExpanded, setAutomationGroupExpanded] = useState(false);
|
||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||
const [activeSettingsModal, setActiveSettingsModal] = useState<SettingsModalKey>('appSettings');
|
||||
const [activeAppSettingsCategory, setActiveAppSettingsCategory] = useState<AppSettingsCategoryKey>('automation');
|
||||
@@ -926,9 +928,7 @@ export function MainHeader({
|
||||
const [prodServerStatus, setProdServerStatus] = useState<ServerCommandItem | null>(null);
|
||||
const [workServerStatus, setWorkServerStatus] = useState<ServerCommandItem | null>(null);
|
||||
const [workServerStatusLoading, setWorkServerStatusLoading] = useState(false);
|
||||
const [serverRestartingKey, setServerRestartingKey] = useState<
|
||||
'test' | 'prod' | 'work-server' | 'command-runner' | 'all' | null
|
||||
>(null);
|
||||
const [serverRestartingKey, setServerRestartingKey] = useState<'test' | 'prod' | 'work-server' | 'all' | null>(null);
|
||||
const [serverRestartFeedback, setServerRestartFeedback] = useState<InlineFeedback | null>(null);
|
||||
const [serverRestartCopyFeedback, setServerRestartCopyFeedback] = useState<InlineFeedback | null>(null);
|
||||
const { registeredToken, hasAccess } = useTokenAccess();
|
||||
@@ -961,6 +961,8 @@ export function MainHeader({
|
||||
const workServerPendingUpdateCount =
|
||||
workServerStatus && (workServerStatus.updateAvailable || workServerStatus.buildRequired) ? 1 : 0;
|
||||
const totalPendingUpdateCount = testServerPendingUpdateCount + prodServerPendingUpdateCount + workServerPendingUpdateCount;
|
||||
const totalAutomationShortcutCount =
|
||||
planShortcutCounts.working + planShortcutCounts.releasePendingMain + planShortcutCounts.automationFailed;
|
||||
const settingsStatusClassName =
|
||||
totalPendingUpdateCount >= 2
|
||||
? 'app-header__status-dot--inactive'
|
||||
@@ -1698,50 +1700,6 @@ export function MainHeader({
|
||||
});
|
||||
};
|
||||
|
||||
const handleRestartCommandRunner = async () => {
|
||||
if (!hasAccess || serverRestartingKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
setServerRestartCopyFeedback(null);
|
||||
setServerRestartFeedback(null);
|
||||
setServerRestartingKey('command-runner');
|
||||
|
||||
try {
|
||||
const result = await restartServerCommand('command-runner');
|
||||
setServerRestartFeedback({
|
||||
tone: 'success',
|
||||
message:
|
||||
result.restartState === 'accepted'
|
||||
? 'Command runner 배포 및 재기동 요청을 접수했습니다.'
|
||||
: 'Command runner 배포 및 재기동을 완료했습니다.',
|
||||
});
|
||||
} catch (error) {
|
||||
setServerRestartFeedback({
|
||||
tone: 'error',
|
||||
message: error instanceof Error ? error.message : 'Command runner 배포 및 재기동에 실패했습니다.',
|
||||
});
|
||||
} finally {
|
||||
setServerRestartingKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmRestartCommandRunner = () => {
|
||||
if (!hasAccess || serverRestartingKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
modalApi.confirm({
|
||||
title: 'Command runner 배포 및 재기동',
|
||||
content: '현재 command runner를 다시 배포하고 재기동합니다. 진행할까요?',
|
||||
okText: '배포 및 재기동',
|
||||
cancelText: '취소',
|
||||
onOk: async () => {
|
||||
await handleRestartCommandRunner();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRestartBothServers = async () => {
|
||||
if (!hasAccess || serverRestartingKey) {
|
||||
return;
|
||||
@@ -2668,68 +2626,20 @@ export function MainHeader({
|
||||
const settingsMenu = (
|
||||
<div className="app-header__settings-menu">
|
||||
{hasAccess ? (
|
||||
<>
|
||||
<div className="app-header__settings-group">
|
||||
<button
|
||||
type="button"
|
||||
className="app-header__settings-item"
|
||||
aria-expanded={automationGroupExpanded}
|
||||
onClick={() => {
|
||||
setSettingsOpen(false);
|
||||
onOpenPlanQuickFilter('working');
|
||||
}}
|
||||
>
|
||||
<span className="app-header__settings-icon">
|
||||
<ProfileOutlined />
|
||||
<span
|
||||
className={`app-header__status-dot ${
|
||||
planShortcutCounts.working > 0
|
||||
? 'app-header__status-dot--active'
|
||||
: 'app-header__status-dot--inactive'
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<span className="app-header__settings-label">
|
||||
작업중 항목
|
||||
<Text type="secondary"> {planShortcutCounts.working}건</Text>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="app-header__settings-item"
|
||||
onClick={() => {
|
||||
setSettingsOpen(false);
|
||||
onOpenPlanQuickFilter('release-pending-main');
|
||||
}}
|
||||
>
|
||||
<span className="app-header__settings-icon">
|
||||
<ProfileOutlined />
|
||||
<span
|
||||
className={`app-header__status-dot ${
|
||||
planShortcutCounts.releasePendingMain > 0
|
||||
? 'app-header__status-dot--warning'
|
||||
: 'app-header__status-dot--inactive'
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<span className="app-header__settings-label">
|
||||
release 상태 작업
|
||||
<Text type="secondary"> {planShortcutCounts.releasePendingMain}건</Text>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="app-header__settings-item"
|
||||
onClick={() => {
|
||||
setSettingsOpen(false);
|
||||
onOpenPlanQuickFilter('automation-failed');
|
||||
setAutomationGroupExpanded((current) => !current);
|
||||
}}
|
||||
>
|
||||
<span className="app-header__settings-icon">
|
||||
<ReloadOutlined />
|
||||
<span
|
||||
className={`app-header__status-dot ${
|
||||
planShortcutCounts.automationFailed > 0
|
||||
totalAutomationShortcutCount > 0
|
||||
? 'app-header__status-dot--warning'
|
||||
: 'app-header__status-dot--inactive'
|
||||
}`}
|
||||
@@ -2737,11 +2647,93 @@ export function MainHeader({
|
||||
/>
|
||||
</span>
|
||||
<span className="app-header__settings-label">
|
||||
자동화 실패
|
||||
<Text type="secondary"> {planShortcutCounts.automationFailed}건</Text>
|
||||
자동화 건수
|
||||
<Text type="secondary"> {totalAutomationShortcutCount}건</Text>
|
||||
</span>
|
||||
<span className="app-header__settings-group-arrow" aria-hidden="true">
|
||||
{automationGroupExpanded ? <DownOutlined /> : <RightOutlined />}
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
{automationGroupExpanded ? (
|
||||
<div className="app-header__settings-group-children">
|
||||
<button
|
||||
type="button"
|
||||
className="app-header__settings-item app-header__settings-item--nested"
|
||||
onClick={() => {
|
||||
setSettingsOpen(false);
|
||||
setAutomationGroupExpanded(false);
|
||||
onOpenPlanQuickFilter('working');
|
||||
}}
|
||||
>
|
||||
<span className="app-header__settings-icon">
|
||||
<ProfileOutlined />
|
||||
<span
|
||||
className={`app-header__status-dot ${
|
||||
planShortcutCounts.working > 0
|
||||
? 'app-header__status-dot--active'
|
||||
: 'app-header__status-dot--inactive'
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<span className="app-header__settings-label">
|
||||
작업중 항목
|
||||
<Text type="secondary"> {planShortcutCounts.working}건</Text>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="app-header__settings-item app-header__settings-item--nested"
|
||||
onClick={() => {
|
||||
setSettingsOpen(false);
|
||||
setAutomationGroupExpanded(false);
|
||||
onOpenPlanQuickFilter('release-pending-main');
|
||||
}}
|
||||
>
|
||||
<span className="app-header__settings-icon">
|
||||
<ProfileOutlined />
|
||||
<span
|
||||
className={`app-header__status-dot ${
|
||||
planShortcutCounts.releasePendingMain > 0
|
||||
? 'app-header__status-dot--warning'
|
||||
: 'app-header__status-dot--inactive'
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<span className="app-header__settings-label">
|
||||
release 상태 작업
|
||||
<Text type="secondary"> {planShortcutCounts.releasePendingMain}건</Text>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="app-header__settings-item app-header__settings-item--nested"
|
||||
onClick={() => {
|
||||
setSettingsOpen(false);
|
||||
setAutomationGroupExpanded(false);
|
||||
onOpenPlanQuickFilter('automation-failed');
|
||||
}}
|
||||
>
|
||||
<span className="app-header__settings-icon">
|
||||
<ReloadOutlined />
|
||||
<span
|
||||
className={`app-header__status-dot ${
|
||||
planShortcutCounts.automationFailed > 0
|
||||
? 'app-header__status-dot--warning'
|
||||
: 'app-header__status-dot--inactive'
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<span className="app-header__settings-label">
|
||||
자동화 실패
|
||||
<Text type="secondary"> {planShortcutCounts.automationFailed}건</Text>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
@@ -3120,24 +3112,6 @@ export function MainHeader({
|
||||
{activeAppSettingsSection === 'worklogAutomation' ? worklogAutomationPanel : null}
|
||||
{activeAppSettingsSection === 'automationNotifications' ? automationNotificationPanel : null}
|
||||
{activeAppSettingsSection === 'gestureShortcuts' ? gestureShortcutsPanel : null}
|
||||
<Divider style={{ marginBlock: 4 }} />
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Text strong>Command runner</Text>
|
||||
<Text type="secondary">
|
||||
별도 명시적 요청이 있을 때만 command runner 배포 및 재기동을 실행합니다.
|
||||
</Text>
|
||||
{renderFeedback(serverRestartFeedback, serverRestartCopyFeedback, setServerRestartCopyFeedback)}
|
||||
<Button
|
||||
block
|
||||
type="primary"
|
||||
icon={serverRestartingKey === 'command-runner' ? <ReloadOutlined spin /> : <ReloadOutlined />}
|
||||
loading={serverRestartingKey === 'command-runner'}
|
||||
disabled={!canRestartServers}
|
||||
onClick={handleConfirmRestartCommandRunner}
|
||||
>
|
||||
command runner 배포 및 재기동
|
||||
</Button>
|
||||
</Space>
|
||||
</>
|
||||
) : null}
|
||||
{activeSettingsModal === 'notification' ? (
|
||||
|
||||
@@ -174,10 +174,23 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-header__settings-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.app-header__settings-item:hover {
|
||||
background: #f3f7ff;
|
||||
}
|
||||
|
||||
.app-header__settings-item--nested {
|
||||
margin-left: 12px;
|
||||
min-width: 0;
|
||||
padding-left: 14px;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.app-header__settings-icon {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
@@ -241,10 +254,23 @@
|
||||
}
|
||||
|
||||
.app-header__settings-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-header__settings-group-arrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.app-header__update-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -363,11 +389,15 @@
|
||||
position: fixed;
|
||||
inset: 72px 0 0;
|
||||
z-index: 40;
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
width: 100vw !important;
|
||||
min-width: 100vw !important;
|
||||
max-width: 100vw;
|
||||
flex: 0 0 100vw !important;
|
||||
height: calc(100vh - 72px);
|
||||
border-right: 0;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
transition: none !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-sider--mobile-inline.ant-layout-sider {
|
||||
@@ -385,6 +415,9 @@
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
padding: 12px 10px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.app-sider__intro {
|
||||
|
||||
401
src/app/main/automationTypeAccess.ts
Normal file
401
src/app/main/automationTypeAccess.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { BoardAutomationType } from '../../features/board/types';
|
||||
import type { PlanAutomationType } from '../../features/planBoard/types';
|
||||
import { appendClientIdHeader } from './clientIdentity';
|
||||
|
||||
export const AUTOMATION_BEHAVIOR_TYPES = [
|
||||
'none',
|
||||
'plan',
|
||||
'command_execution',
|
||||
'non_source_work',
|
||||
'auto_worker',
|
||||
] as const;
|
||||
|
||||
export type AutomationBehaviorType = (typeof AUTOMATION_BEHAVIOR_TYPES)[number];
|
||||
|
||||
export type AutomationTypeRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
behaviorType: AutomationBehaviorType;
|
||||
enabled: boolean;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type AutomationTypeInput = {
|
||||
id?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
behaviorType?: AutomationBehaviorType;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
const AUTOMATION_TYPES_API_PATH = '/automation-types';
|
||||
const AUTOMATION_TYPE_SYNC_EVENT = 'work-app:automation-types-changed';
|
||||
const AUTOMATION_TYPE_REQUEST_TIMEOUT_MS = 8000;
|
||||
|
||||
export const AUTOMATION_BEHAVIOR_LABELS: Record<AutomationBehaviorType, string> = {
|
||||
none: '기본유형',
|
||||
plan: '작업 요청 등록',
|
||||
command_execution: 'Command 실행',
|
||||
non_source_work: '비 소스작업',
|
||||
auto_worker: 'autoWorker',
|
||||
};
|
||||
|
||||
const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
|
||||
{
|
||||
id: 'none',
|
||||
name: '기본유형',
|
||||
description:
|
||||
'## 기본 처리\n- 실제 작업 범위와 절차는 현재 요청 문맥과 운영 설정을 기준으로 판단합니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 기록합니다.\n\n## 기록\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.\n- 세부 Git 흐름은 여기서 하드코딩하지 않습니다.',
|
||||
behaviorType: 'none',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'plan',
|
||||
name: '작업 요청 등록',
|
||||
description: 'Plan 등록/문서형 자동화 요청으로 처리합니다.',
|
||||
behaviorType: 'plan',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'command_execution',
|
||||
name: 'Command 실행',
|
||||
description: '저장소 수정 없이 명령 실행/조회 중심으로 처리합니다.',
|
||||
behaviorType: 'command_execution',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'non_source_work',
|
||||
name: '비 소스작업',
|
||||
description: '문서/운영/확인 등 비소스 작업으로 처리합니다.',
|
||||
behaviorType: 'non_source_work',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'auto_worker',
|
||||
name: 'autoWorker',
|
||||
description: '자동화 작업메모로 처리하며, 세부 절차는 현재 운영 설정을 따릅니다.',
|
||||
behaviorType: 'auto_worker',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
function normalizeText(value: string | null | undefined) {
|
||||
return value?.trim() ?? '';
|
||||
}
|
||||
|
||||
export function normalizeAutomationTypeId(
|
||||
value: unknown,
|
||||
): PlanAutomationType | BoardAutomationType {
|
||||
const normalized = normalizeText(typeof value === 'string' ? value : '');
|
||||
|
||||
if (normalized === 'plan_registration') {
|
||||
return 'plan';
|
||||
}
|
||||
|
||||
if (normalized === 'general_development') {
|
||||
return 'auto_worker';
|
||||
}
|
||||
|
||||
return normalized || 'none';
|
||||
}
|
||||
|
||||
function normalizeBehaviorType(value: unknown): AutomationBehaviorType {
|
||||
const normalized = normalizeAutomationTypeId(value);
|
||||
return AUTOMATION_BEHAVIOR_TYPES.includes(normalized as AutomationBehaviorType)
|
||||
? (normalized as AutomationBehaviorType)
|
||||
: 'none';
|
||||
}
|
||||
|
||||
function getSemanticKey(record: Pick<AutomationTypeRecord, 'name' | 'behaviorType'>) {
|
||||
return `${record.behaviorType}:${normalizeText(record.name).replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR')}`;
|
||||
}
|
||||
|
||||
function compareUpdatedAt(left: AutomationTypeRecord, right: AutomationTypeRecord) {
|
||||
const leftTime = Date.parse(left.updatedAt);
|
||||
const rightTime = Date.parse(right.updatedAt);
|
||||
|
||||
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
|
||||
return leftTime - rightTime;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function normalizeAutomationType(record: Partial<AutomationTypeRecord>): AutomationTypeRecord | null {
|
||||
const name = normalizeText(record.name);
|
||||
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id =
|
||||
normalizeText(record.id) ||
|
||||
`automation-type-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description: normalizeText(record.description),
|
||||
behaviorType: normalizeBehaviorType(record.behaviorType),
|
||||
enabled: record.enabled !== false,
|
||||
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeAutomationTypes(items: Partial<AutomationTypeRecord>[]) {
|
||||
const byId = new Map<string, AutomationTypeRecord>();
|
||||
const bySemanticKey = new Map<string, AutomationTypeRecord>();
|
||||
|
||||
items
|
||||
.map((item) => normalizeAutomationType(item))
|
||||
.filter((item): item is AutomationTypeRecord => Boolean(item))
|
||||
.forEach((item) => {
|
||||
const currentById = byId.get(item.id);
|
||||
if (!currentById || compareUpdatedAt(currentById, item) <= 0) {
|
||||
byId.set(item.id, item);
|
||||
}
|
||||
});
|
||||
|
||||
for (const item of byId.values()) {
|
||||
const semanticKey = getSemanticKey(item);
|
||||
const current = bySemanticKey.get(semanticKey);
|
||||
|
||||
if (!current || compareUpdatedAt(current, item) <= 0) {
|
||||
bySemanticKey.set(semanticKey, item);
|
||||
}
|
||||
}
|
||||
|
||||
const values = Array.from(bySemanticKey.values()).sort((left, right) => left.name.localeCompare(right.name, 'ko-KR'));
|
||||
return values.length > 0 ? values : DEFAULT_AUTOMATION_TYPES;
|
||||
}
|
||||
|
||||
function emitAutomationTypesChange() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(new Event(AUTOMATION_TYPE_SYNC_EVENT));
|
||||
}
|
||||
|
||||
function resolveApiBaseUrl() {
|
||||
if (import.meta.env.VITE_WORK_SERVER_URL) {
|
||||
return import.meta.env.VITE_WORK_SERVER_URL;
|
||||
}
|
||||
|
||||
return '/api';
|
||||
}
|
||||
|
||||
function resolveFallbackBaseUrl() {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostname = window.location.hostname;
|
||||
|
||||
if (!['localhost', '127.0.0.1', '0.0.0.0'].includes(hostname)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallbackUrl = new URL(window.location.origin);
|
||||
fallbackUrl.port = '3100';
|
||||
fallbackUrl.pathname = '/api';
|
||||
fallbackUrl.search = '';
|
||||
fallbackUrl.hash = '';
|
||||
|
||||
return fallbackUrl.toString().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
const API_BASE_URL = resolveApiBaseUrl();
|
||||
const FALLBACK_BASE_URL = resolveFallbackBaseUrl();
|
||||
|
||||
async function requestOnce<T>(baseUrl: string, init?: RequestInit) {
|
||||
const headers = appendClientIdHeader(init?.headers);
|
||||
const hasBody = init?.body !== undefined && init.body !== null;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), AUTOMATION_TYPE_REQUEST_TIMEOUT_MS);
|
||||
|
||||
if (hasBody && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${AUTOMATION_TYPES_API_PATH}`, {
|
||||
...init,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
cache: init?.cache ?? (init?.method?.toUpperCase() === 'GET' ? 'no-store' : undefined),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text() || '자동화 처리 유형 요청에 실패했습니다.');
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestAutomationTypes<T>(init?: RequestInit) {
|
||||
try {
|
||||
return await requestOnce<T>(API_BASE_URL, init);
|
||||
} catch (error) {
|
||||
const shouldRetryWithFallback =
|
||||
FALLBACK_BASE_URL &&
|
||||
FALLBACK_BASE_URL !== API_BASE_URL &&
|
||||
error instanceof Error &&
|
||||
/404|408|502|Failed to fetch|Load failed|NetworkError|응답이 지연/i.test(error.message);
|
||||
|
||||
if (!shouldRetryWithFallback) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return requestOnce<T>(FALLBACK_BASE_URL, init);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAutomationTypesFromServer() {
|
||||
const response = await requestAutomationTypes<{ ok: boolean; automationTypes: Partial<AutomationTypeRecord>[] | null }>({
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (response.automationTypes == null) {
|
||||
return DEFAULT_AUTOMATION_TYPES;
|
||||
}
|
||||
|
||||
return sanitizeAutomationTypes(response.automationTypes);
|
||||
}
|
||||
|
||||
async function saveAutomationTypesToServer(items: AutomationTypeRecord[]) {
|
||||
const resolved = sanitizeAutomationTypes(items);
|
||||
const response = await requestAutomationTypes<{ ok: boolean; automationTypes: Partial<AutomationTypeRecord>[] }>({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ automationTypes: resolved }),
|
||||
});
|
||||
|
||||
return sanitizeAutomationTypes(response.automationTypes);
|
||||
}
|
||||
|
||||
export function upsertAutomationType(items: AutomationTypeRecord[], input: AutomationTypeInput) {
|
||||
const nextItem = normalizeAutomationType(input);
|
||||
|
||||
if (!nextItem) {
|
||||
return sanitizeAutomationTypes(items);
|
||||
}
|
||||
|
||||
const nextItems = items.filter((item) => item.id !== nextItem.id);
|
||||
nextItems.push(nextItem);
|
||||
return sanitizeAutomationTypes(nextItems);
|
||||
}
|
||||
|
||||
export function deleteAutomationType(items: AutomationTypeRecord[], automationTypeId: string) {
|
||||
const normalizedId = normalizeText(automationTypeId);
|
||||
|
||||
if (!normalizedId) {
|
||||
return sanitizeAutomationTypes(items);
|
||||
}
|
||||
|
||||
return sanitizeAutomationTypes(items.filter((item) => item.id !== normalizedId));
|
||||
}
|
||||
|
||||
export function resolveAutomationTypeLabel(items: AutomationTypeRecord[], automationTypeId: string | null | undefined) {
|
||||
const normalizedId = normalizeAutomationTypeId(automationTypeId);
|
||||
return items.find((item) => item.id === normalizedId)?.name ?? normalizedId;
|
||||
}
|
||||
|
||||
export function buildAutomationTypeOptions(
|
||||
items: AutomationTypeRecord[],
|
||||
value?: string | null,
|
||||
) {
|
||||
const normalizedValue = normalizeAutomationTypeId(value);
|
||||
const enabledItems = items.filter((item) => item.enabled);
|
||||
const currentItem = items.find((item) => item.id === normalizedValue);
|
||||
const source = currentItem && !enabledItems.some((item) => item.id === currentItem.id) ? [...enabledItems, currentItem] : enabledItems;
|
||||
|
||||
if (source.length === 0) {
|
||||
return [{ label: '선택 안함', value: 'none' }];
|
||||
}
|
||||
|
||||
return source.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
}));
|
||||
}
|
||||
|
||||
export function useAutomationTypeRegistry() {
|
||||
const [automationTypes, setAutomationTypesState] = useState<AutomationTypeRecord[]>(DEFAULT_AUTOMATION_TYPES);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
setIsLoading(true);
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
const nextAutomationTypes = await loadAutomationTypesFromServer();
|
||||
|
||||
if (!cancelled && mountedRef.current) {
|
||||
setAutomationTypesState(nextAutomationTypes);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled && mountedRef.current) {
|
||||
setAutomationTypesState(DEFAULT_AUTOMATION_TYPES);
|
||||
setErrorMessage(error instanceof Error ? error.message : '자동화 처리 유형을 불러오지 못했습니다.');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled && mountedRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
|
||||
const handleSync = () => {
|
||||
void load();
|
||||
};
|
||||
|
||||
window.addEventListener(AUTOMATION_TYPE_SYNC_EVENT, handleSync);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener(AUTOMATION_TYPE_SYNC_EVENT, handleSync);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setAutomationTypes = async (nextItems: AutomationTypeRecord[]) => {
|
||||
const saved = await saveAutomationTypesToServer(nextItems);
|
||||
|
||||
if (mountedRef.current) {
|
||||
setAutomationTypesState(saved);
|
||||
setErrorMessage('');
|
||||
}
|
||||
|
||||
emitAutomationTypesChange();
|
||||
return saved;
|
||||
};
|
||||
|
||||
return {
|
||||
automationTypes,
|
||||
setAutomationTypes,
|
||||
isLoading,
|
||||
errorMessage,
|
||||
};
|
||||
}
|
||||
@@ -7,7 +7,6 @@ export type ChatTypeRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
isTemplate: boolean;
|
||||
permissions: ChatPermissionRole[];
|
||||
enabled: boolean;
|
||||
updatedAt: string;
|
||||
@@ -17,7 +16,6 @@ export type ChatTypeInput = {
|
||||
id?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
isTemplate?: boolean;
|
||||
permissions: ChatPermissionRole[];
|
||||
enabled?: boolean;
|
||||
};
|
||||
@@ -39,8 +37,7 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
|
||||
id: 'general-request',
|
||||
name: '일반 요청',
|
||||
description:
|
||||
'현재는 프로젝트 루트 main브랜치에서 직접 수정하세요. 작업 이후 실패된 경우 현재 세션에서 수정된 소스에 라인만 롤백하세요. 리소스 제공은 public/.codex_chat/채팅방ID 아래 해주세요. 대화방 내 내용은 context로 참조해주세요. 브라우저 테스트가 필요시 진행해주세요. 브라우저 캡쳐도 리소스 preview 제공해주세요.',
|
||||
isTemplate: false,
|
||||
'## 기본 처리\n- 실제 수정 범위와 방식은 현재 요청 문맥과 AGENTS.md 규칙을 우선 따릅니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 대화방 내용은 `context`로 우선 참조합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n\n## 검증\n- 필요하면 브라우저와 API를 직접 테스트합니다.\n- UI 변경이 있으면 모바일 화면 캡처와 preview 리소스를 함께 남깁니다.',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-21T00:00:00.000Z',
|
||||
@@ -48,8 +45,7 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
|
||||
{
|
||||
id: 'api-request-template',
|
||||
name: 'API요청',
|
||||
description: 'API요청만 진행 (자동화, 작업요청, 스케줄 등 호출 가능한 API)',
|
||||
isTemplate: true,
|
||||
description: '## 처리 범위\n- 호출 가능한 API 요청만 진행합니다.\n- 자동화, 작업요청, 스케줄 등 API 중심 요청에 사용합니다.',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-16T00:00:00.000Z',
|
||||
@@ -90,15 +86,14 @@ function normalizeChatType(record: Partial<ChatTypeRecord>): ChatTypeRecord | nu
|
||||
id,
|
||||
name,
|
||||
description: normalizeText(record.description),
|
||||
isTemplate: record.isTemplate === true,
|
||||
permissions: normalizePermissions(record.permissions),
|
||||
enabled: record.enabled !== false,
|
||||
updatedAt: typeof record.updatedAt === 'string' && record.updatedAt ? record.updatedAt : new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function getChatTypeSemanticKey(record: Pick<ChatTypeRecord, 'name' | 'isTemplate'>) {
|
||||
return `${record.isTemplate ? 'template' : 'live'}:${buildChatTypeNameKey(record.name)}`;
|
||||
function getChatTypeSemanticKey(record: Pick<ChatTypeRecord, 'name'>) {
|
||||
return buildChatTypeNameKey(record.name);
|
||||
}
|
||||
|
||||
function compareUpdatedAt(left: ChatTypeRecord, right: ChatTypeRecord) {
|
||||
@@ -314,7 +309,6 @@ export function upsertChatType(chatTypes: ChatTypeRecord[], input: ChatTypeInput
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
isTemplate: input.isTemplate,
|
||||
permissions: input.permissions,
|
||||
enabled: input.enabled,
|
||||
updatedAt: new Date().toISOString(),
|
||||
|
||||
@@ -10,7 +10,6 @@ type PendingChatRequest = {
|
||||
chatTypeId: string;
|
||||
chatTypeLabel: string;
|
||||
chatTypeDescription: string;
|
||||
chatTypeIsTemplate: boolean;
|
||||
retryCount: number;
|
||||
failed: boolean;
|
||||
};
|
||||
@@ -21,7 +20,6 @@ type PendingContextConfirm = {
|
||||
chatTypeId: string;
|
||||
chatTypeLabel: string;
|
||||
chatTypeDescription: string;
|
||||
chatTypeIsTemplate: boolean;
|
||||
includedContextCount: number;
|
||||
omittedContextCount: number;
|
||||
};
|
||||
@@ -30,7 +28,6 @@ type SelectedChatType = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
isTemplate: boolean;
|
||||
} | null;
|
||||
|
||||
type RecentContextSummary = {
|
||||
@@ -170,7 +167,7 @@ export function useConversationComposerController({
|
||||
|
||||
const executeSendMessage = useCallback(
|
||||
(request: PendingContextConfirm) => {
|
||||
const { mode, text, chatTypeId, chatTypeLabel, chatTypeDescription, chatTypeIsTemplate } = request;
|
||||
const { mode, text, chatTypeId, chatTypeLabel, chatTypeDescription } = request;
|
||||
const requestId = `client-${Date.now().toString(36)}`;
|
||||
const outgoingRequest: PendingChatRequest = {
|
||||
sessionId: activeSessionId,
|
||||
@@ -180,7 +177,6 @@ export function useConversationComposerController({
|
||||
chatTypeId,
|
||||
chatTypeLabel,
|
||||
chatTypeDescription,
|
||||
chatTypeIsTemplate,
|
||||
retryCount: 0,
|
||||
failed: false,
|
||||
};
|
||||
@@ -335,26 +331,23 @@ export function useConversationComposerController({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedChatType.isTemplate) {
|
||||
const recentContext = summarizeRecentContext(
|
||||
messagesRef.current,
|
||||
appConfigChat.maxContextMessages,
|
||||
appConfigChat.maxContextChars,
|
||||
);
|
||||
const recentContext = summarizeRecentContext(
|
||||
messagesRef.current,
|
||||
appConfigChat.maxContextMessages,
|
||||
appConfigChat.maxContextChars,
|
||||
);
|
||||
|
||||
if (recentContext.omittedCount > 0) {
|
||||
setPendingContextConfirm({
|
||||
mode,
|
||||
text: trimmed,
|
||||
chatTypeId: selectedChatType.id,
|
||||
chatTypeLabel: selectedChatType.name,
|
||||
chatTypeDescription: selectedChatType.description,
|
||||
chatTypeIsTemplate: false,
|
||||
includedContextCount: recentContext.includedCount,
|
||||
omittedContextCount: recentContext.omittedCount,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (recentContext.omittedCount > 0) {
|
||||
setPendingContextConfirm({
|
||||
mode,
|
||||
text: trimmed,
|
||||
chatTypeId: selectedChatType.id,
|
||||
chatTypeLabel: selectedChatType.name,
|
||||
chatTypeDescription: selectedChatType.description,
|
||||
includedContextCount: recentContext.includedCount,
|
||||
omittedContextCount: recentContext.omittedCount,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
executeSendMessage({
|
||||
@@ -363,7 +356,6 @@ export function useConversationComposerController({
|
||||
chatTypeId: selectedChatType.id,
|
||||
chatTypeLabel: selectedChatType.name,
|
||||
chatTypeDescription: selectedChatType.description,
|
||||
chatTypeIsTemplate: selectedChatType.isTemplate,
|
||||
includedContextCount: 0,
|
||||
omittedContextCount: 0,
|
||||
});
|
||||
|
||||
@@ -17,7 +17,6 @@ type PendingChatRequest = {
|
||||
chatTypeId: string;
|
||||
chatTypeLabel: string;
|
||||
chatTypeDescription: string;
|
||||
chatTypeIsTemplate: boolean;
|
||||
retryCount: number;
|
||||
failed: boolean;
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ type PreviewItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
kind: 'image' | 'video' | 'markdown' | 'code' | 'document' | 'pdf' | 'file';
|
||||
kind: 'image' | 'video' | 'markdown' | 'code' | 'diff' | 'document' | 'pdf' | 'file';
|
||||
source: 'message' | 'context';
|
||||
};
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ function parseRoute(pathname: string): {
|
||||
first === 'charts' ||
|
||||
first === 'schedule' ||
|
||||
first === 'history' ||
|
||||
first === 'automation-type' ||
|
||||
first === 'server-command')
|
||||
) {
|
||||
return {
|
||||
@@ -146,6 +147,22 @@ function isRestrictedTopMenu(topMenu: TopMenuKey, hasAccess: boolean) {
|
||||
return !hasAccess && topMenu !== 'docs';
|
||||
}
|
||||
|
||||
function getIsMobileViewport() {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.matchMedia('(max-width: 768px)').matches;
|
||||
}
|
||||
|
||||
function resolveSidebarCollapsedForViewport(isMobileViewport: boolean, topMenu: TopMenuKey) {
|
||||
if (!isMobileViewport) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return topMenu !== 'docs';
|
||||
}
|
||||
|
||||
function resolveSidebarOpenKeys(
|
||||
topMenu: TopMenuKey,
|
||||
hasAccess: boolean,
|
||||
@@ -189,9 +206,11 @@ export function MainLayout() {
|
||||
const { openSearch, setOptions: setSearchOptions } = useSearchLayer();
|
||||
const layoutData = useMainLayoutData();
|
||||
const routeState = useMemo(() => parseRoute(location.pathname), [location.pathname]);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [isMobileViewport, setIsMobileViewport] = useState(() => getIsMobileViewport());
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() =>
|
||||
resolveSidebarCollapsedForViewport(getIsMobileViewport(), routeState.topMenu),
|
||||
);
|
||||
const [contentExpanded, setContentExpanded] = useState(false);
|
||||
const [isMobileViewport, setIsMobileViewport] = useState(false);
|
||||
const [sidebarOpenKeys, setSidebarOpenKeys] = useState<string[]>(
|
||||
resolveSidebarOpenKeys(routeState.topMenu, hasAccess, routeState.planMenu, routeState.chatMenu),
|
||||
);
|
||||
@@ -221,12 +240,7 @@ export function MainLayout() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobileViewport) {
|
||||
setSidebarCollapsed(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSidebarCollapsed(routeState.topMenu !== 'docs');
|
||||
setSidebarCollapsed(resolveSidebarCollapsedForViewport(isMobileViewport, routeState.topMenu));
|
||||
}, [isMobileViewport, routeState.topMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -370,7 +384,6 @@ export function MainLayout() {
|
||||
const planMenuItems = useMemo(() => buildPlanMenuItems(hasAccess), [hasAccess]);
|
||||
const chatMenuItems = useMemo(() => buildChatMenuItems(hasAccess, chatUnreadCount), [chatUnreadCount, hasAccess]);
|
||||
const playMenuItems = useMemo(() => buildPlayMenuItems(savedLayouts), [savedLayouts]);
|
||||
const showInlineMobileDocsSidebar = isMobileViewport && routeState.topMenu === 'docs';
|
||||
const initialSelectedPlanId = Number(searchParams.get('planId'));
|
||||
const initialSelectedWorkId = searchParams.get('workId');
|
||||
|
||||
@@ -414,7 +427,7 @@ export function MainLayout() {
|
||||
}}
|
||||
onChangeTopMenu={(menu) => {
|
||||
navigate(resolveTopMenuPath(menu, currentDocsFolder));
|
||||
setSidebarCollapsed(false);
|
||||
setSidebarCollapsed(resolveSidebarCollapsedForViewport(isMobileViewport, menu));
|
||||
}}
|
||||
onOpenPlanQuickFilter={(filter) => {
|
||||
const targetPlanMenu = resolvePlanQuickFilterMenu(filter);
|
||||
@@ -428,13 +441,12 @@ export function MainLayout() {
|
||||
)}
|
||||
|
||||
<Layout>
|
||||
{contentExpanded || (isMobileViewport && sidebarCollapsed && !showInlineMobileDocsSidebar) ? null : (
|
||||
{contentExpanded || (isMobileViewport && sidebarCollapsed) ? null : (
|
||||
<MainSidebar
|
||||
activeTopMenu={routeState.topMenu}
|
||||
hasAccess={hasAccess}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
isMobileViewport={isMobileViewport}
|
||||
mobileInline={showInlineMobileDocsSidebar}
|
||||
openKeys={sidebarOpenKeys}
|
||||
apiMenuItems={apiMenuItems}
|
||||
docsMenuItems={docsMenuItems}
|
||||
@@ -455,7 +467,7 @@ export function MainLayout() {
|
||||
}}
|
||||
onSelectDocsMenu={(key) => {
|
||||
navigate(buildDocsPath(key));
|
||||
if (isMobileViewport && !showInlineMobileDocsSidebar) {
|
||||
if (isMobileViewport) {
|
||||
setSidebarCollapsed(true);
|
||||
}
|
||||
}}
|
||||
@@ -486,7 +498,7 @@ export function MainLayout() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{isMobileViewport && !sidebarCollapsed && !showInlineMobileDocsSidebar ? null : (
|
||||
{isMobileViewport && !sidebarCollapsed ? null : (
|
||||
<MainContent contentExpanded={contentExpanded} onToggleContentExpanded={() => setContentExpanded((previous) => !previous)}>
|
||||
<Outlet />
|
||||
</MainContent>
|
||||
|
||||
@@ -145,6 +145,18 @@ export function buildSearchOptions({
|
||||
},
|
||||
...(hasAccess
|
||||
? [
|
||||
{
|
||||
id: 'page:plans:automation-type',
|
||||
label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS['automation-type']}`,
|
||||
group: 'Page',
|
||||
keywords: ['plans', 'plan', 'automation type', '자동화 유형', '자동화 처리', '유형 관리'],
|
||||
onSelect: () => {
|
||||
requestPlanQuickFilter(null);
|
||||
navigateTo(buildPlansPath('automation-type'));
|
||||
setFocusedComponentId(null);
|
||||
},
|
||||
onSelectWindow,
|
||||
} satisfies SearchKeywordOption,
|
||||
{
|
||||
id: 'page:plans:history',
|
||||
label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS.history}`,
|
||||
|
||||
@@ -30,7 +30,6 @@ export type ChatViewContext = {
|
||||
chatTypeId: string | null;
|
||||
chatTypeLabel: string;
|
||||
chatTypeDescription: string;
|
||||
chatTypeIsTemplate: boolean;
|
||||
};
|
||||
|
||||
export type ChatConversationSummary = {
|
||||
|
||||
@@ -202,7 +202,6 @@ function sendContextUpdate(context: ChatViewContext | null = sharedChatConnectio
|
||||
chatTypeId: context.chatTypeId,
|
||||
chatTypeLabel: context.chatTypeLabel,
|
||||
chatTypeDescription: context.chatTypeDescription,
|
||||
chatTypeIsTemplate: context.chatTypeIsTemplate,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -27,6 +27,7 @@ export const PLAN_SIDEBAR_LABELS: Record<PlanSidebarKey, string> = {
|
||||
charts: '차트',
|
||||
schedule: '스케줄',
|
||||
history: '이력',
|
||||
'automation-type': '자동화 유형',
|
||||
'server-command': 'Command',
|
||||
};
|
||||
|
||||
@@ -50,6 +51,7 @@ export const PLAN_MENU_ANCHOR_IDS: Partial<Record<PlanSidebarKey, string>> = {
|
||||
charts: 'plan-menu-charts',
|
||||
schedule: 'plan-menu-schedule',
|
||||
history: 'plan-menu-history',
|
||||
'automation-type': 'plan-menu-automation-type',
|
||||
'server-command': 'plan-menu-server-command',
|
||||
};
|
||||
|
||||
|
||||
@@ -101,6 +101,18 @@ export function buildMainViewSearchOptions({
|
||||
},
|
||||
onSelectWindow,
|
||||
},
|
||||
{
|
||||
id: 'page:plans:automation-type',
|
||||
label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS['automation-type']}`,
|
||||
group: 'Page',
|
||||
keywords: ['plans', 'plan', 'automation type', '자동화 유형', '자동화 처리', '유형 관리'],
|
||||
onSelect: () => {
|
||||
setActiveTopMenu('plans');
|
||||
setSelectedPlanMenu('automation-type');
|
||||
setFocusedComponentId(null);
|
||||
},
|
||||
onSelectWindow,
|
||||
},
|
||||
...(hasAccess
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AutomationTypeManagementPage } from '../AutomationTypeManagementPage';
|
||||
import { BoardPage } from '../../../features/board';
|
||||
import { HistoryPage } from '../../../features/history';
|
||||
import { PlanBoardChartsPage, PlanBoardPage, PlanSchedulePage, ReleaseReviewPage } from '../../../features/planBoard';
|
||||
@@ -53,6 +54,14 @@ export function PlansPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedPlanMenu === 'automation-type') {
|
||||
return (
|
||||
<div className="app-main-panel">
|
||||
<AutomationTypeManagementPage />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedPlanMenu === 'server-command') {
|
||||
return (
|
||||
<div className="app-main-panel">
|
||||
|
||||
@@ -7,7 +7,16 @@ import type { PlanFilterStatus } from '../../features/planBoard';
|
||||
export type TopMenuKey = 'docs' | 'apis' | 'plans' | 'chat' | 'play';
|
||||
export type HeaderTopMenuKey = 'docs' | 'plans';
|
||||
export type ApiSectionKey = 'components' | 'widgets';
|
||||
export type PlanSectionKey = PlanFilterStatus | 'release' | 'release-review' | 'board' | 'charts' | 'schedule' | 'history' | 'server-command';
|
||||
export type PlanSectionKey =
|
||||
| PlanFilterStatus
|
||||
| 'release'
|
||||
| 'release-review'
|
||||
| 'board'
|
||||
| 'charts'
|
||||
| 'schedule'
|
||||
| 'history'
|
||||
| 'automation-type'
|
||||
| 'server-command';
|
||||
export type ChatSectionKey = 'live' | 'changes' | 'errors' | 'manage';
|
||||
export type PlaySectionKey = 'layout';
|
||||
export type PlaySidebarKey = PlaySectionKey | `layout-record:${string}`;
|
||||
@@ -39,6 +48,7 @@ export const PLAN_SIDEBAR_LABELS: Record<PlanSectionKey, string> = {
|
||||
charts: '차트',
|
||||
schedule: '스케줄',
|
||||
history: '이력',
|
||||
'automation-type': '자동화 유형',
|
||||
'server-command': 'Command',
|
||||
};
|
||||
|
||||
@@ -57,6 +67,7 @@ export const PLAN_MENU_ANCHOR_IDS: Partial<Record<PlanSectionKey, string>> = {
|
||||
charts: 'plan-menu-charts',
|
||||
schedule: 'plan-menu-schedule',
|
||||
history: 'plan-menu-history',
|
||||
'automation-type': 'plan-menu-automation-type',
|
||||
'server-command': 'plan-menu-server-command',
|
||||
};
|
||||
|
||||
@@ -188,6 +199,10 @@ export function buildPlanMenuItems(hasAccess = true): MenuProps['items'] {
|
||||
key: 'history',
|
||||
label: renderPlanMenuLabel('history', PLAN_SIDEBAR_LABELS.history),
|
||||
},
|
||||
{
|
||||
key: 'automation-type',
|
||||
label: renderPlanMenuLabel('automation-type', PLAN_SIDEBAR_LABELS['automation-type']),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import { Button, Card, Checkbox, Empty, Flex, Input, List, Segmented, Select, Space, Spin, Tag, Typography, message } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
buildAutomationTypeOptions,
|
||||
resolveAutomationTypeLabel,
|
||||
useAutomationTypeRegistry,
|
||||
} from '../../app/main/automationTypeAccess';
|
||||
import { MarkdownPreviewContent } from '../../components/markdownPreview';
|
||||
import {
|
||||
createBoardPost,
|
||||
@@ -22,7 +27,7 @@ import {
|
||||
setupBoard,
|
||||
updateBoardPost,
|
||||
} from './api';
|
||||
import type { BoardAutomationType, BoardDraft, BoardPost } from './types';
|
||||
import type { BoardDraft, BoardPost } from './types';
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
const { TextArea } = Input;
|
||||
@@ -34,18 +39,6 @@ const EMPTY_DRAFT: BoardDraft = {
|
||||
automationType: 'none',
|
||||
};
|
||||
|
||||
const BOARD_AUTOMATION_TYPE_OPTIONS: Array<{ label: string; value: BoardAutomationType }> = [
|
||||
{ label: '선택 안함', value: 'none' },
|
||||
{ label: 'Plan', value: 'plan' },
|
||||
{ label: 'Command 실행', value: 'command_execution' },
|
||||
{ label: '비 소스작업', value: 'non_source_work' },
|
||||
{ label: 'autoWorker', value: 'auto_worker' },
|
||||
];
|
||||
|
||||
const BOARD_AUTOMATION_TYPE_LABELS = new Map(
|
||||
BOARD_AUTOMATION_TYPE_OPTIONS.map((option) => [option.value, option.label] as const),
|
||||
);
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
return new Date(value).toLocaleString('ko-KR', {
|
||||
timeZone: 'Asia/Seoul',
|
||||
@@ -128,6 +121,7 @@ function getBoardPostAutomationReceiveError(item: BoardPost, dirtyDraftId: numbe
|
||||
|
||||
export function BoardPage() {
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const { automationTypes } = useAutomationTypeRegistry();
|
||||
const [items, setItems] = useState<BoardPost[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [checkedIds, setCheckedIds] = useState<number[]>([]);
|
||||
@@ -216,7 +210,14 @@ export function BoardPage() {
|
||||
);
|
||||
const dirtyDraftId = draftDirty && draft.id ? draft.id : null;
|
||||
const automationStatus = resolveBoardAutomationStatus(draft.id, automationReceived, draftDirty, automationReceiveError);
|
||||
const automationTypeLabel = BOARD_AUTOMATION_TYPE_LABELS.get(draft.automationType) ?? draft.automationType;
|
||||
const automationTypeOptions = useMemo(
|
||||
() => buildAutomationTypeOptions(automationTypes, draft.automationType),
|
||||
[automationTypes, draft.automationType],
|
||||
);
|
||||
const automationTypeLabel = useMemo(
|
||||
() => resolveAutomationTypeLabel(automationTypes, draft.automationType),
|
||||
[automationTypes, draft.automationType],
|
||||
);
|
||||
const receivableIds = useMemo(
|
||||
() =>
|
||||
items
|
||||
@@ -671,17 +672,17 @@ export function BoardPage() {
|
||||
<Select
|
||||
className="board-page__automation-select"
|
||||
value={draft.automationType}
|
||||
options={BOARD_AUTOMATION_TYPE_OPTIONS}
|
||||
popupClassName="board-page__automation-select-popup"
|
||||
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
|
||||
options={automationTypeOptions}
|
||||
popupClassName="board-page__automation-select-popup"
|
||||
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
|
||||
disabled={isDraftLocked}
|
||||
onChange={(automationType) => {
|
||||
setDraft((previous) => ({
|
||||
...previous,
|
||||
automationType,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
automationType,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={`board-page__editor-frame${contentExpanded ? ' board-page__editor-frame--expanded' : ''}`}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { appendClientIdHeader } from '../../app/main/clientIdentity';
|
||||
import { normalizeAutomationTypeId } from '../../app/main/automationTypeAccess';
|
||||
import { getRegisteredAccessToken } from '../../app/main/tokenAccess';
|
||||
import type { BoardAutomationType, BoardDraft, BoardPost } from './types';
|
||||
|
||||
@@ -13,16 +14,7 @@ class BoardApiError extends Error {
|
||||
}
|
||||
|
||||
function normalizeBoardAutomationType(value: unknown): BoardAutomationType {
|
||||
return value === 'plan' ||
|
||||
value === 'command_execution' ||
|
||||
value === 'non_source_work' ||
|
||||
value === 'auto_worker'
|
||||
? value
|
||||
: value === 'plan_registration'
|
||||
? 'plan'
|
||||
: value === 'general_development'
|
||||
? 'auto_worker'
|
||||
: 'none';
|
||||
return normalizeAutomationTypeId(value);
|
||||
}
|
||||
|
||||
function resolveBoardApiBaseUrl() {
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
export const BOARD_AUTOMATION_TYPES = [
|
||||
'none',
|
||||
'plan',
|
||||
'command_execution',
|
||||
'non_source_work',
|
||||
'auto_worker',
|
||||
] as const;
|
||||
|
||||
export type BoardAutomationType = (typeof BOARD_AUTOMATION_TYPES)[number];
|
||||
export type BoardAutomationType = string;
|
||||
|
||||
export type BoardPost = {
|
||||
id: number;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
DownOutlined,
|
||||
FullscreenExitOutlined,
|
||||
FullscreenOutlined,
|
||||
PaperClipOutlined,
|
||||
UpOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
@@ -28,8 +29,16 @@ import {
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { memo, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode } from 'react';
|
||||
import { useAppConfig, type AppConfig } from '../../app/main/appConfig';
|
||||
import {
|
||||
buildAutomationTypeOptions,
|
||||
resolveAutomationTypeLabel,
|
||||
useAutomationTypeRegistry,
|
||||
} from '../../app/main/automationTypeAccess';
|
||||
import { uploadChatComposerFile } from '../../app/main/mainChatPanel';
|
||||
import { normalizeChatResourceUrl } from '../../app/main/mainChatPanel/chatResourceUrl';
|
||||
import type { ChatComposerAttachment } from '../../app/main/mainChatPanel/types';
|
||||
import { useTokenAccess } from '../../app/main/tokenAccess';
|
||||
import './planBoard.css';
|
||||
import {
|
||||
@@ -67,7 +76,6 @@ import { maskNotePreviewByWord } from './noteMasking';
|
||||
import type {
|
||||
PlanActionHistory,
|
||||
PlanActionType,
|
||||
PlanAutomationType,
|
||||
PlanAutomationUsageSnapshot,
|
||||
PlanDraft,
|
||||
PlanFilterStatus,
|
||||
@@ -130,16 +138,6 @@ const MAIN_STATE_FILTER_OPTIONS: Array<{ label: string; value: MainStateFilter }
|
||||
{ label: 'main 실패', value: 'failed' },
|
||||
{ label: 'main 미대상', value: 'not-targeted' },
|
||||
];
|
||||
const PLAN_AUTOMATION_TYPE_OPTIONS: Array<{ label: string; value: PlanAutomationType }> = [
|
||||
{ label: '선택 안함', value: 'none' },
|
||||
{ label: '작업 요청 등록', value: 'plan' },
|
||||
{ label: 'Command 실행', value: 'command_execution' },
|
||||
{ label: '비 소스작업', value: 'non_source_work' },
|
||||
{ label: 'autoWorker', value: 'auto_worker' },
|
||||
];
|
||||
const PLAN_AUTOMATION_TYPE_LABELS = new Map(
|
||||
PLAN_AUTOMATION_TYPE_OPTIONS.map((option) => [option.value, option.label] as const),
|
||||
);
|
||||
const ISSUE_STATE_FILTER_OPTIONS: Array<{ label: string; value: IssueStateFilter }> = [
|
||||
{ label: '이슈 전체', value: 'all' },
|
||||
{ label: '열린 이슈', value: 'open' },
|
||||
@@ -160,6 +158,191 @@ type ReviewListIndicator = {
|
||||
totalCount: number;
|
||||
};
|
||||
|
||||
type PlanNoteResource = {
|
||||
id: string;
|
||||
label: string;
|
||||
sourcePath: string;
|
||||
publicUrl: string;
|
||||
previewType: 'image' | 'document' | 'link';
|
||||
};
|
||||
|
||||
const PLAN_NOTE_RESOURCE_LINE_PATTERN =
|
||||
/^\s*-\s+(.+?):\s+((?:\/api\/chat\/resources\/[^\s)`]+)|(?:\/?(?:public\/)?\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+)|(?:public\/\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+))\s*$/;
|
||||
const PLAN_NOTE_RESOURCE_GLOBAL_PATTERN =
|
||||
/(?:\/api\/chat\/resources\/[^\s)`]+)|(?:\/?(?:public\/)?\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+)|(?:public\/\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+)/g;
|
||||
const PLAN_NOTE_IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp']);
|
||||
const PLAN_NOTE_DOCUMENT_EXTENSIONS = new Set([
|
||||
'pdf',
|
||||
'txt',
|
||||
'md',
|
||||
'json',
|
||||
'ts',
|
||||
'tsx',
|
||||
'js',
|
||||
'jsx',
|
||||
'mjs',
|
||||
'cjs',
|
||||
'css',
|
||||
'html',
|
||||
'diff',
|
||||
'log',
|
||||
]);
|
||||
|
||||
function createPlanNoteAttachmentSessionId() {
|
||||
return `plan-note-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function normalizePlanNoteResourceSourcePath(value: string) {
|
||||
return String(value ?? '')
|
||||
.trim()
|
||||
.replace(/[)>.,]+$/, '')
|
||||
.replace(/^\/+/, '/')
|
||||
.replace(/^public\/(?=\.codex_chat\/)/, '');
|
||||
}
|
||||
|
||||
function normalizePlanNoteResourceUrl(value: string) {
|
||||
const normalizedSourcePath = normalizePlanNoteResourceSourcePath(value);
|
||||
if (!normalizedSourcePath) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (normalizedSourcePath.startsWith('/api/chat/resources/')) {
|
||||
return normalizeChatResourceUrl(normalizedSourcePath);
|
||||
}
|
||||
|
||||
if (normalizedSourcePath.startsWith('/.codex_chat/')) {
|
||||
return normalizeChatResourceUrl(normalizedSourcePath);
|
||||
}
|
||||
|
||||
if (normalizedSourcePath.startsWith('.codex_chat/')) {
|
||||
return normalizeChatResourceUrl(`/${normalizedSourcePath}`);
|
||||
}
|
||||
|
||||
return normalizeChatResourceUrl(normalizedSourcePath);
|
||||
}
|
||||
|
||||
function getPlanNoteResourceBaseName(sourcePath: string) {
|
||||
const normalized = normalizePlanNoteResourceSourcePath(sourcePath).replace(/^\/+/, '');
|
||||
const segments = normalized.split('/').filter(Boolean);
|
||||
return segments.at(-1) ?? normalized;
|
||||
}
|
||||
|
||||
function resolvePlanNoteResourcePreviewType(sourcePath: string): PlanNoteResource['previewType'] {
|
||||
const baseName = getPlanNoteResourceBaseName(sourcePath);
|
||||
const extension = baseName.includes('.') ? baseName.split('.').at(-1)?.toLowerCase() ?? '' : '';
|
||||
|
||||
if (PLAN_NOTE_IMAGE_EXTENSIONS.has(extension)) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
if (PLAN_NOTE_DOCUMENT_EXTENSIONS.has(extension)) {
|
||||
return 'document';
|
||||
}
|
||||
|
||||
return 'link';
|
||||
}
|
||||
|
||||
function extractPlanNoteResources(note: string) {
|
||||
const normalizedNote = String(note ?? '');
|
||||
const lineEntries = normalizedNote
|
||||
.split(/\r?\n/)
|
||||
.map((line) => {
|
||||
const matched = line.match(PLAN_NOTE_RESOURCE_LINE_PATTERN);
|
||||
|
||||
if (!matched) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
label: matched[1]?.trim() || getPlanNoteResourceBaseName(matched[2] ?? ''),
|
||||
sourcePath: normalizePlanNoteResourceSourcePath(matched[2] ?? ''),
|
||||
};
|
||||
})
|
||||
.filter((item): item is { label: string; sourcePath: string } => Boolean(item?.sourcePath));
|
||||
|
||||
const seen = new Set(lineEntries.map((item) => item.sourcePath));
|
||||
const genericEntries = Array.from(normalizedNote.matchAll(PLAN_NOTE_RESOURCE_GLOBAL_PATTERN))
|
||||
.map((matched) => normalizePlanNoteResourceSourcePath(matched[0] ?? ''))
|
||||
.filter(Boolean)
|
||||
.filter((sourcePath) => {
|
||||
if (seen.has(sourcePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seen.add(sourcePath);
|
||||
return true;
|
||||
})
|
||||
.map((sourcePath) => ({
|
||||
label: getPlanNoteResourceBaseName(sourcePath),
|
||||
sourcePath,
|
||||
}));
|
||||
|
||||
return [...lineEntries, ...genericEntries].map((item, index) => ({
|
||||
id: `${index}-${item.sourcePath}`,
|
||||
label: item.label,
|
||||
sourcePath: item.sourcePath,
|
||||
publicUrl: normalizePlanNoteResourceUrl(item.sourcePath),
|
||||
previewType: resolvePlanNoteResourcePreviewType(item.sourcePath),
|
||||
}));
|
||||
}
|
||||
|
||||
function appendPlanNoteAttachments(note: string, attachments: ChatComposerAttachment[]) {
|
||||
if (attachments.length === 0) {
|
||||
return note;
|
||||
}
|
||||
|
||||
const existingSourcePaths = new Set(extractPlanNoteResources(note).map((item) => item.sourcePath));
|
||||
const nextLines = attachments
|
||||
.map((attachment) => {
|
||||
const sourcePath = normalizePlanNoteResourceSourcePath(attachment.path);
|
||||
return {
|
||||
label: attachment.name.trim() || getPlanNoteResourceBaseName(sourcePath),
|
||||
sourcePath,
|
||||
};
|
||||
})
|
||||
.filter((item) => item.sourcePath)
|
||||
.filter((item) => {
|
||||
if (existingSourcePaths.has(item.sourcePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
existingSourcePaths.add(item.sourcePath);
|
||||
return true;
|
||||
})
|
||||
.map((item) => `- ${item.label}: ${item.sourcePath}`);
|
||||
|
||||
if (nextLines.length === 0) {
|
||||
return note;
|
||||
}
|
||||
|
||||
const currentNote = note.trimEnd();
|
||||
const attachmentSectionPattern = /(^|\n)첨부 파일:\n(?:- .+\n?)*/;
|
||||
const matchedSection = currentNote.match(attachmentSectionPattern);
|
||||
|
||||
if (!matchedSection || matchedSection.index === undefined) {
|
||||
return `${currentNote}${currentNote ? '\n\n' : ''}첨부 파일:\n${nextLines.join('\n')}`;
|
||||
}
|
||||
|
||||
const startIndex = matchedSection.index;
|
||||
const matchedText = matchedSection[0];
|
||||
const insertIndex = startIndex + matchedText.length;
|
||||
const prefix = currentNote.slice(0, insertIndex).replace(/\n*$/, '\n');
|
||||
const suffix = currentNote.slice(insertIndex).replace(/^\n+/, '\n');
|
||||
|
||||
return `${prefix}${nextLines.join('\n')}${suffix}`;
|
||||
}
|
||||
|
||||
function resolvePlanNoteAttachmentSessionId(
|
||||
draftId: number | null,
|
||||
fallbackSessionId: string,
|
||||
) {
|
||||
if (draftId) {
|
||||
return `plan-note-${draftId}`;
|
||||
}
|
||||
|
||||
return fallbackSessionId;
|
||||
}
|
||||
|
||||
function isPlanItemRequestLocked(item: Pick<PlanItem, 'startedAt'> | null | undefined) {
|
||||
return Boolean(item?.startedAt);
|
||||
}
|
||||
@@ -317,6 +500,59 @@ function ExpandableDetailText({
|
||||
);
|
||||
}
|
||||
|
||||
function PlanNoteResourcePanel({ resources }: { resources: PlanNoteResource[] }) {
|
||||
return (
|
||||
<div className="plan-board-page__note-resources">
|
||||
<Flex justify="space-between" align="center" gap={8} wrap>
|
||||
<Text strong>첨부 리소스</Text>
|
||||
<Text type="secondary">{resources.length}건</Text>
|
||||
</Flex>
|
||||
<div className="plan-board-page__note-resource-list">
|
||||
{resources.map((resource) => (
|
||||
<div key={resource.id} className="plan-board-page__note-resource-card">
|
||||
<Flex justify="space-between" align="start" gap={12} wrap>
|
||||
<Space direction="vertical" size={2} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text strong ellipsis={{ tooltip: resource.label }}>
|
||||
{resource.label}
|
||||
</Text>
|
||||
<Text type="secondary" className="plan-board-page__note-resource-path">
|
||||
{resource.sourcePath}
|
||||
</Text>
|
||||
</Space>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
href={resource.publicUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
열기
|
||||
</Button>
|
||||
</Flex>
|
||||
{resource.previewType === 'image' ? (
|
||||
<img
|
||||
className="plan-board-page__note-resource-image"
|
||||
src={resource.publicUrl}
|
||||
alt={resource.label}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : null}
|
||||
{resource.previewType === 'document' ? (
|
||||
<iframe
|
||||
className="plan-board-page__note-resource-frame"
|
||||
src={resource.publicUrl}
|
||||
title={resource.label}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ActionButton = {
|
||||
key: PlanActionType;
|
||||
label: string;
|
||||
@@ -352,6 +588,7 @@ export function PlanBoardPage({
|
||||
initialSelectedWorkId = null,
|
||||
}: PlanBoardPageProps) {
|
||||
const { hasAccess } = useTokenAccess();
|
||||
const { automationTypes } = useAutomationTypeRegistry();
|
||||
const appConfig = useAppConfig();
|
||||
const autoRefreshIntervalMs = appConfig.automation.autoRefreshIntervalSeconds * 1000;
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
@@ -365,6 +602,7 @@ export function PlanBoardPage({
|
||||
const [selectedSourceWork, setSelectedSourceWork] = useState<PlanSourceWorkHistory | null>(null);
|
||||
const [draft, setDraft] = useState<PlanDraft>(() => createEmptyDraft(appConfig));
|
||||
const [noteInputValue, setNoteInputValue] = useState('');
|
||||
const [noteAttachmentUploading, setNoteAttachmentUploading] = useState(false);
|
||||
const [actionNote, setActionNote] = useState('');
|
||||
const [issueActionNote, setIssueActionNote] = useState('');
|
||||
const [resolveLatestIssue, setResolveLatestIssue] = useState(false);
|
||||
@@ -393,6 +631,8 @@ export function PlanBoardPage({
|
||||
workId: initialSelectedWorkId,
|
||||
});
|
||||
const draftRef = useRef(draft);
|
||||
const noteAttachmentInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const noteAttachmentSessionIdRef = useRef(createPlanNoteAttachmentSessionId());
|
||||
const savingRef = useRef(saving);
|
||||
const previousWorkerStatusMapRef = useRef<Map<number, string | null>>(new Map());
|
||||
const notifiedAutomationStartKeysRef = useRef<Set<string>>(new Set());
|
||||
@@ -404,6 +644,10 @@ export function PlanBoardPage({
|
||||
);
|
||||
const isAutoRefreshRunning = hasPendingAutomation && autoRefreshEnabled;
|
||||
const autoRefreshCountdownSeconds = Math.max(1, Math.ceil(autoRefreshRemainingMs / 1000));
|
||||
const noteResources = useMemo(
|
||||
() => (hasAccess ? extractPlanNoteResources(noteInputValue) : []),
|
||||
[hasAccess, noteInputValue],
|
||||
);
|
||||
|
||||
draftRef.current = draft;
|
||||
savingRef.current = saving;
|
||||
@@ -898,6 +1142,7 @@ export function PlanBoardPage({
|
||||
return;
|
||||
}
|
||||
|
||||
noteAttachmentSessionIdRef.current = createPlanNoteAttachmentSessionId();
|
||||
setDraft(createEmptyDraft(appConfig));
|
||||
setResolveLatestIssue(false);
|
||||
setRetryLatestIssue(true);
|
||||
@@ -907,6 +1152,7 @@ export function PlanBoardPage({
|
||||
}
|
||||
|
||||
function handleSelectItem(item: PlanItem) {
|
||||
noteAttachmentSessionIdRef.current = resolvePlanNoteAttachmentSessionId(item.id, noteAttachmentSessionIdRef.current);
|
||||
setDraft(toDraft(item));
|
||||
setResolveLatestIssue(false);
|
||||
setRetryLatestIssue(true);
|
||||
@@ -1042,6 +1288,58 @@ export function PlanBoardPage({
|
||||
};
|
||||
}
|
||||
|
||||
async function handleNoteAttachmentFilesPicked(files: File[]) {
|
||||
if (files.length === 0 || noteAttachmentUploading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasAccess) {
|
||||
messageApi.error(TOKEN_ACCESS_REQUIRED_MESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlanItemRequestLocked(selectedItem)) {
|
||||
messageApi.warning('자동화 접수된 항목은 첨부 파일을 추가할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
setNoteAttachmentUploading(true);
|
||||
|
||||
try {
|
||||
const sessionId = resolvePlanNoteAttachmentSessionId(draftRef.current.id, noteAttachmentSessionIdRef.current);
|
||||
const uploadResults = await Promise.allSettled(files.map((file) => uploadChatComposerFile(sessionId, file)));
|
||||
const uploadedItems: ChatComposerAttachment[] = [];
|
||||
const failedFileNames: string[] = [];
|
||||
|
||||
uploadResults.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
uploadedItems.push(result.value);
|
||||
return;
|
||||
}
|
||||
|
||||
failedFileNames.push(files[index]?.name || `파일 ${index + 1}`);
|
||||
});
|
||||
|
||||
if (uploadedItems.length > 0) {
|
||||
const nextNote = appendPlanNoteAttachments(noteInputValue, uploadedItems);
|
||||
handleNoteChange(nextNote);
|
||||
messageApi.success(`첨부 파일 ${uploadedItems.length}건을 메모에 추가했습니다.`);
|
||||
}
|
||||
|
||||
if (failedFileNames.length > 0) {
|
||||
messageApi.error(`업로드 실패: ${failedFileNames.join(', ')}`);
|
||||
}
|
||||
} finally {
|
||||
setNoteAttachmentUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleNoteAttachmentInputChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
event.target.value = '';
|
||||
void handleNoteAttachmentFilesPicked(files);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (savingRef.current || !draftRef.current.id) {
|
||||
return;
|
||||
@@ -1253,7 +1551,14 @@ export function PlanBoardPage({
|
||||
hasAccess && selectedItem && !isRequestLocked && isFunctionCheckEditableStatus(selectedItem.status),
|
||||
);
|
||||
const canSave = hasAccess && !isRequestLocked;
|
||||
const automationTypeLabel = PLAN_AUTOMATION_TYPE_LABELS.get(draft.automationType) ?? draft.automationType;
|
||||
const automationTypeOptions = useMemo(
|
||||
() => buildAutomationTypeOptions(automationTypes, draft.automationType),
|
||||
[automationTypes, draft.automationType],
|
||||
);
|
||||
const automationTypeLabel = useMemo(
|
||||
() => resolveAutomationTypeLabel(automationTypes, draft.automationType),
|
||||
[automationTypes, draft.automationType],
|
||||
);
|
||||
const latestActionHistory = actionHistories[0] ?? null;
|
||||
const latestIssueHistory = issueHistories[0] ?? null;
|
||||
const releaseCompletedTimestamps = useMemo(
|
||||
@@ -1740,7 +2045,7 @@ export function PlanBoardPage({
|
||||
<Select
|
||||
className="plan-board-page__select plan-board-page__select--automation"
|
||||
value={draft.automationType}
|
||||
options={PLAN_AUTOMATION_TYPE_OPTIONS}
|
||||
options={automationTypeOptions}
|
||||
popupClassName="plan-board-page__select-popup"
|
||||
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
|
||||
disabled={!hasAccess}
|
||||
@@ -1757,14 +2062,27 @@ export function PlanBoardPage({
|
||||
<div>
|
||||
<Flex justify="space-between" align="center" gap={8} wrap>
|
||||
<Text strong>메모</Text>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
disabled={!hasAccess}
|
||||
onClick={() => void handleCopyText(noteInputValue)}
|
||||
>
|
||||
복사
|
||||
</Button>
|
||||
<Space size={8} wrap>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<PaperClipOutlined />}
|
||||
disabled={!hasAccess || isRequestLocked}
|
||||
loading={noteAttachmentUploading}
|
||||
onClick={() => {
|
||||
noteAttachmentInputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
첨부
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
disabled={!hasAccess}
|
||||
onClick={() => void handleCopyText(noteInputValue)}
|
||||
>
|
||||
복사
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
<div className="plan-board-page__notepad-frame">
|
||||
<TextArea
|
||||
@@ -1778,11 +2096,19 @@ export function PlanBoardPage({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
ref={noteAttachmentInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="plan-board-page__hidden-file-input"
|
||||
onChange={handleNoteAttachmentInputChange}
|
||||
/>
|
||||
{isRequestLocked ? (
|
||||
<Text type="secondary">
|
||||
{hasAccess ? '자동화 접수된 항목은 본문을 수정할 수 없습니다.' : '조회 화면에서는 작업 메모를 40% 마스킹해 표시합니다.'}
|
||||
</Text>
|
||||
) : null}
|
||||
{noteResources.length ? <PlanNoteResourcePanel resources={noteResources} /> : null}
|
||||
</div>
|
||||
|
||||
{selectedReleaseReviewNote.trim() ? (
|
||||
|
||||
@@ -18,12 +18,15 @@ import {
|
||||
message,
|
||||
} from 'antd';
|
||||
import { memo, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
import {
|
||||
buildAutomationTypeOptions,
|
||||
useAutomationTypeRegistry,
|
||||
} from '../../app/main/automationTypeAccess';
|
||||
import { useTokenAccess } from '../../app/main/tokenAccess';
|
||||
import './planBoard.css';
|
||||
import './planSchedule.css';
|
||||
import { maskNotePreviewByWord } from './noteMasking';
|
||||
import { PlanListDetailLayout } from './PlanListDetailLayout';
|
||||
import type { PlanAutomationType } from './types';
|
||||
import {
|
||||
createPlanScheduledTask,
|
||||
deletePlanScheduledTask,
|
||||
@@ -77,14 +80,6 @@ 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;
|
||||
const PLAN_AUTOMATION_TYPE_OPTIONS: Array<{ label: string; value: PlanAutomationType }> = [
|
||||
{ label: '선택 안함', value: 'none' },
|
||||
{ label: '작업 요청 등록', value: 'plan' },
|
||||
{ label: 'Command 실행', value: 'command_execution' },
|
||||
{ label: '비 소스작업', value: 'non_source_work' },
|
||||
{ label: 'autoWorker', value: 'auto_worker' },
|
||||
];
|
||||
|
||||
function getRepeatIntervalMinutes(value: number, unit: PlanScheduleRepeatUnit) {
|
||||
const normalizedValue = Math.max(1, Math.round(Number(value) || 1));
|
||||
|
||||
@@ -327,6 +322,7 @@ function validateScheduleDraft(draft: PlanScheduledTaskDraft, items: PlanSchedul
|
||||
|
||||
export function PlanSchedulePage() {
|
||||
const { hasAccess } = useTokenAccess();
|
||||
const { automationTypes } = useAutomationTypeRegistry();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [items, setItems] = useState<PlanScheduledTask[]>([]);
|
||||
const [draft, setDraft] = useState(() => createEmptyScheduleDraft());
|
||||
@@ -536,6 +532,7 @@ export function PlanSchedulePage() {
|
||||
emptyDetailTitle="스케줄 상세"
|
||||
detailContent={
|
||||
<PlanScheduleDetail
|
||||
automationTypeOptions={buildAutomationTypeOptions(automationTypes, draft.automationType)}
|
||||
draft={draft}
|
||||
hasAccess={hasAccess}
|
||||
selectedItem={selectedItem}
|
||||
@@ -605,6 +602,7 @@ const PlanScheduleList = memo(function PlanScheduleList({
|
||||
});
|
||||
|
||||
function PlanScheduleDetail({
|
||||
automationTypeOptions,
|
||||
draft,
|
||||
hasAccess,
|
||||
selectedItem,
|
||||
@@ -612,6 +610,7 @@ function PlanScheduleDetail({
|
||||
onChangeDraft,
|
||||
onCopyText,
|
||||
}: {
|
||||
automationTypeOptions: Array<{ label: string; value: string }>;
|
||||
draft: PlanScheduledTaskDraft;
|
||||
hasAccess: boolean;
|
||||
selectedItem: PlanScheduledTask | null;
|
||||
@@ -701,7 +700,7 @@ function PlanScheduleDetail({
|
||||
<Select
|
||||
className="plan-schedule-page__select plan-schedule-page__select--automation"
|
||||
value={draft.automationType}
|
||||
options={PLAN_AUTOMATION_TYPE_OPTIONS}
|
||||
options={automationTypeOptions}
|
||||
popupClassName="plan-schedule-page__select-popup"
|
||||
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
|
||||
disabled={!hasAccess}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { appendClientIdHeader } from '../../app/main/clientIdentity';
|
||||
import { normalizeAutomationTypeId } from '../../app/main/automationTypeAccess';
|
||||
import { getRegisteredAccessToken } from '../../app/main/tokenAccess';
|
||||
import type {
|
||||
PlanActionType,
|
||||
@@ -25,16 +26,7 @@ function resolvePlanApiBaseUrl() {
|
||||
}
|
||||
|
||||
function normalizePlanAutomationType(value: unknown): PlanAutomationType {
|
||||
return value === 'plan' ||
|
||||
value === 'command_execution' ||
|
||||
value === 'non_source_work' ||
|
||||
value === 'auto_worker'
|
||||
? value
|
||||
: value === 'plan_registration'
|
||||
? 'plan'
|
||||
: value === 'general_development'
|
||||
? 'auto_worker'
|
||||
: 'none';
|
||||
return normalizeAutomationTypeId(value);
|
||||
}
|
||||
|
||||
function resolveWorkServerFallbackBaseUrl() {
|
||||
@@ -389,6 +381,7 @@ function normalizePlanItem(item: PlanItem): PlanItem {
|
||||
return {
|
||||
...item,
|
||||
automationType: normalizePlanAutomationType(item.automationType),
|
||||
automationBehaviorType: normalizeAutomationTypeId(item.automationBehaviorType),
|
||||
releaseReviewNote: typeof item.releaseReviewNote === 'string' ? item.releaseReviewNote : '',
|
||||
usageSnapshot: normalizePlanAutomationUsageSnapshot(item.usageSnapshot),
|
||||
};
|
||||
|
||||
@@ -351,6 +351,53 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.plan-board-page__hidden-file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.plan-board-page__note-resources {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.plan-board-page__note-resource-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plan-board-page__note-resource-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(22, 93, 255, 0.12);
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(245, 249, 255, 0.96));
|
||||
}
|
||||
|
||||
.plan-board-page__note-resource-path {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.plan-board-page__note-resource-image,
|
||||
.plan-board-page__note-resource-frame {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.plan-board-page__note-resource-image {
|
||||
max-height: 320px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.plan-board-page__note-resource-frame {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.plan-board-page__notepad-expand-button.ant-btn {
|
||||
color: rgba(71, 98, 130, 0.92);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
export const PLAN_STATUSES = ['등록', '작업중', '작업완료', '릴리즈완료', '완료'] as const;
|
||||
export const PLAN_FILTER_STATUSES = ['all', 'in-progress', 'done', 'error'] as const;
|
||||
export const PLAN_AUTOMATION_TYPES = ['none', 'plan', 'command_execution', 'non_source_work', 'auto_worker'] as const;
|
||||
|
||||
export type PlanStatus = (typeof PLAN_STATUSES)[number];
|
||||
export type PlanFilterStatus = (typeof PLAN_FILTER_STATUSES)[number];
|
||||
export type PlanAutomationType = (typeof PLAN_AUTOMATION_TYPES)[number];
|
||||
export type PlanAutomationType = string;
|
||||
export type PlanActionType =
|
||||
| 'start-work'
|
||||
| 'complete-development'
|
||||
@@ -111,6 +110,7 @@ export type PlanItem = {
|
||||
workId: string;
|
||||
note: string;
|
||||
automationType: PlanAutomationType;
|
||||
automationBehaviorType?: string;
|
||||
releaseReviewNote: string;
|
||||
noteMasked?: boolean;
|
||||
status: PlanStatus;
|
||||
|
||||
Reference in New Issue
Block a user