Initial import
This commit is contained in:
795
src/features/board/BoardPage.tsx
Executable file
795
src/features/board/BoardPage.tsx
Executable file
@@ -0,0 +1,795 @@
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
CheckSquareOutlined,
|
||||
CompressOutlined,
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
EyeOutlined,
|
||||
ExpandOutlined,
|
||||
FileTextOutlined,
|
||||
PlayCircleOutlined,
|
||||
PlusOutlined,
|
||||
SaveOutlined,
|
||||
} 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 { MarkdownPreviewContent } from '../../components/markdownPreview';
|
||||
import {
|
||||
createBoardPost,
|
||||
deleteBoardPost,
|
||||
fetchBoardPosts,
|
||||
receiveBoardPostAutomation,
|
||||
setupBoard,
|
||||
updateBoardPost,
|
||||
} from './api';
|
||||
import type { BoardAutomationType, BoardDraft, BoardPost } from './types';
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const EMPTY_DRAFT: BoardDraft = {
|
||||
id: null,
|
||||
title: '',
|
||||
content: '',
|
||||
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',
|
||||
});
|
||||
}
|
||||
|
||||
async function copyText(value: string) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = value;
|
||||
textArea.setAttribute('readonly', '');
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
|
||||
function resolveBoardAutomationStatus(
|
||||
draftId: number | null,
|
||||
automationReceived: boolean,
|
||||
draftDirty: boolean,
|
||||
errorMessage: string | null,
|
||||
) {
|
||||
if (errorMessage) {
|
||||
return {
|
||||
color: 'error',
|
||||
label: '접수 실패',
|
||||
description: errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
if (!draftId) {
|
||||
return {
|
||||
color: 'default',
|
||||
label: '저장 필요',
|
||||
description: '저장 후 자동화 접수 가능',
|
||||
};
|
||||
}
|
||||
|
||||
if (draftDirty) {
|
||||
return {
|
||||
color: 'warning',
|
||||
label: '저장 필요',
|
||||
description: '변경 내용을 저장한 뒤 접수',
|
||||
};
|
||||
}
|
||||
|
||||
if (automationReceived) {
|
||||
return {
|
||||
color: 'processing',
|
||||
label: '접수완료',
|
||||
description: '연결된 Plan에서 작업 상태 확인',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
color: 'default',
|
||||
label: '대기',
|
||||
description: '자동화 접수 전',
|
||||
};
|
||||
}
|
||||
|
||||
function getBoardPostAutomationReceiveError(item: BoardPost, dirtyDraftId: number | null) {
|
||||
if (item.id === dirtyDraftId) {
|
||||
return '변경 내용을 저장한 뒤 자동화 접수하세요.';
|
||||
}
|
||||
|
||||
if (item.automationReceivedAt || item.automationPlanItemId) {
|
||||
return '이미 자동화 접수된 게시글입니다.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function BoardPage() {
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [items, setItems] = useState<BoardPost[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [checkedIds, setCheckedIds] = useState<number[]>([]);
|
||||
const [draft, setDraft] = useState<BoardDraft>(EMPTY_DRAFT);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [automationReceiving, setAutomationReceiving] = useState(false);
|
||||
const [automationReceiveError, setAutomationReceiveError] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isMobileViewport, setIsMobileViewport] = useState(false);
|
||||
const [mobileDetailOpen, setMobileDetailOpen] = useState(false);
|
||||
const [mobileView, setMobileView] = useState<'edit' | 'preview'>('edit');
|
||||
const [contentExpanded, setContentExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia('(max-width: 960px)');
|
||||
const update = () => {
|
||||
setIsMobileViewport(mediaQuery.matches);
|
||||
if (!mediaQuery.matches) {
|
||||
setMobileDetailOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
update();
|
||||
mediaQuery.addEventListener('change', update);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
await setupBoard();
|
||||
const nextItems = await fetchBoardPosts();
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setItems(nextItems);
|
||||
setSelectedId((previous) => previous ?? nextItems[0]?.id ?? null);
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '게시판을 불러오지 못했습니다.');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const selectedItem = useMemo(
|
||||
() => items.find((item) => item.id === selectedId) ?? null,
|
||||
[items, selectedId],
|
||||
);
|
||||
const showMobileDetailOnly = isMobileViewport && mobileDetailOpen;
|
||||
const automationReceived = Boolean(selectedItem?.automationReceivedAt || selectedItem?.automationPlanItemId);
|
||||
const isDraftLocked = automationReceived;
|
||||
const draftDirty = Boolean(
|
||||
selectedItem &&
|
||||
(
|
||||
draft.title !== selectedItem.title ||
|
||||
draft.content !== selectedItem.content ||
|
||||
draft.automationType !== selectedItem.automationType
|
||||
),
|
||||
);
|
||||
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 receivableIds = useMemo(
|
||||
() =>
|
||||
items
|
||||
.filter((item) => !getBoardPostAutomationReceiveError(item, dirtyDraftId))
|
||||
.map((item) => item.id),
|
||||
[dirtyDraftId, items],
|
||||
);
|
||||
const receivableIdSet = useMemo(() => new Set(receivableIds), [receivableIds]);
|
||||
const checkedReceivableCount = checkedIds.filter((id) => receivableIdSet.has(id)).length;
|
||||
const allReceivableChecked = receivableIds.length > 0 && checkedReceivableCount === receivableIds.length;
|
||||
const partiallyChecked = checkedReceivableCount > 0 && checkedReceivableCount < receivableIds.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedItem) {
|
||||
setDraft({
|
||||
id: selectedItem.id,
|
||||
title: selectedItem.title,
|
||||
content: selectedItem.content,
|
||||
automationType: selectedItem.automationType,
|
||||
});
|
||||
setAutomationReceiveError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setDraft(EMPTY_DRAFT);
|
||||
setAutomationReceiveError(null);
|
||||
}, [selectedItem]);
|
||||
|
||||
useEffect(() => {
|
||||
const itemIdSet = new Set(items.map((item) => item.id));
|
||||
setCheckedIds((previous) => previous.filter((id) => itemIdSet.has(id)));
|
||||
}, [items]);
|
||||
|
||||
const handleCreateDraft = () => {
|
||||
setSelectedId(null);
|
||||
setDraft(EMPTY_DRAFT);
|
||||
setAutomationReceiveError(null);
|
||||
setMobileDetailOpen(isMobileViewport);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const normalizedTitle = draft.title.trim();
|
||||
const normalizedContent = draft.content.trim();
|
||||
|
||||
if (!normalizedTitle) {
|
||||
messageApi.warning('제목을 입력하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!normalizedContent) {
|
||||
messageApi.warning('본문을 입력하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setAutomationReceiveError(null);
|
||||
|
||||
try {
|
||||
const savedItem = draft.id
|
||||
? await updateBoardPost({
|
||||
...draft,
|
||||
title: normalizedTitle,
|
||||
content: normalizedContent,
|
||||
})
|
||||
: await createBoardPost({
|
||||
...draft,
|
||||
title: normalizedTitle,
|
||||
content: normalizedContent,
|
||||
});
|
||||
|
||||
setItems((previous) => {
|
||||
const filtered = previous.filter((item) => item.id !== savedItem.id);
|
||||
return [savedItem, ...filtered];
|
||||
});
|
||||
setSelectedId(savedItem.id);
|
||||
setMobileDetailOpen(isMobileViewport);
|
||||
messageApi.success(draft.id ? '게시글을 수정했습니다.' : '게시글을 등록했습니다.');
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : '게시글 저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!draft.id) {
|
||||
handleCreateDraft();
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleting(true);
|
||||
|
||||
try {
|
||||
await deleteBoardPost(draft.id);
|
||||
setItems((previous) => previous.filter((item) => item.id !== draft.id));
|
||||
setSelectedId((previous) => (previous === draft.id ? null : previous));
|
||||
setDraft(EMPTY_DRAFT);
|
||||
setMobileDetailOpen(false);
|
||||
messageApi.success('게시글을 삭제했습니다.');
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : '게시글 삭제에 실패했습니다.');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyContent = async () => {
|
||||
if (!draft.content.trim()) {
|
||||
messageApi.warning('복사할 본문이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await copyText(draft.content);
|
||||
messageApi.success('본문을 복사했습니다.');
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : '본문 복사에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutomationReceive = async () => {
|
||||
if (!draft.id) {
|
||||
messageApi.warning('저장된 게시글만 자동화 접수할 수 있습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (draftDirty) {
|
||||
messageApi.warning('변경 내용을 저장한 뒤 자동화 접수하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (automationReceived) {
|
||||
messageApi.info('이미 자동화 접수된 게시글입니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
setAutomationReceiving(true);
|
||||
setAutomationReceiveError(null);
|
||||
|
||||
try {
|
||||
const result = await receiveBoardPostAutomation(draft.id);
|
||||
setItems((previous) => previous.map((item) => (item.id === result.item.id ? result.item : item)));
|
||||
messageApi.success(
|
||||
result.alreadyReceived
|
||||
? '이미 자동화 접수된 게시글입니다.'
|
||||
: `자동화 접수했습니다. Plan #${result.planItemId}`,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '자동화 접수에 실패했습니다.';
|
||||
setAutomationReceiveError(message);
|
||||
messageApi.error(message);
|
||||
} finally {
|
||||
setAutomationReceiving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkAutomationReceive = async () => {
|
||||
if (!checkedIds.length) {
|
||||
messageApi.warning('접수할 게시글을 선택하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const targetItems = items.filter((item) => checkedIds.includes(item.id));
|
||||
const receivableItems = targetItems.filter((item) => !getBoardPostAutomationReceiveError(item, dirtyDraftId));
|
||||
|
||||
if (!receivableItems.length) {
|
||||
messageApi.warning('선택한 게시글 중 자동화 접수할 수 있는 항목이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
setAutomationReceiving(true);
|
||||
setAutomationReceiveError(null);
|
||||
|
||||
const updatedItems = new Map<number, BoardPost>();
|
||||
const alreadyReceivedIds: number[] = [];
|
||||
const failedItems: Array<{ id: number; message: string }> = [];
|
||||
|
||||
try {
|
||||
for (const item of receivableItems) {
|
||||
try {
|
||||
const result = await receiveBoardPostAutomation(item.id);
|
||||
updatedItems.set(result.item.id, result.item);
|
||||
|
||||
if (result.alreadyReceived) {
|
||||
alreadyReceivedIds.push(item.id);
|
||||
}
|
||||
} catch (error) {
|
||||
failedItems.push({
|
||||
id: item.id,
|
||||
message: error instanceof Error ? error.message : '자동화 접수에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedItems.size > 0) {
|
||||
setItems((previous) => previous.map((item) => updatedItems.get(item.id) ?? item));
|
||||
}
|
||||
|
||||
setCheckedIds((previous) => previous.filter((id) => !updatedItems.has(id)));
|
||||
|
||||
const receivedCount = updatedItems.size - alreadyReceivedIds.length;
|
||||
const summaryParts = [
|
||||
receivedCount > 0 ? `${receivedCount}건 접수` : null,
|
||||
alreadyReceivedIds.length > 0 ? `${alreadyReceivedIds.length}건 기존 접수` : null,
|
||||
failedItems.length > 0 ? `${failedItems.length}건 실패` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
if (failedItems.length > 0) {
|
||||
messageApi.warning(`선택 접수 결과: ${summaryParts.join(', ')}.`);
|
||||
} else {
|
||||
messageApi.success(`선택한 게시글을 처리했습니다. ${summaryParts.join(', ')}.`);
|
||||
}
|
||||
|
||||
if (selectedItem) {
|
||||
const selectedFailure = failedItems.find((entry) => entry.id === selectedItem.id);
|
||||
setAutomationReceiveError(selectedFailure?.message ?? null);
|
||||
}
|
||||
} finally {
|
||||
setAutomationReceiving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} className="board-page">
|
||||
{contextHolder}
|
||||
<Card className="board-page__card" bordered={false}>
|
||||
<Flex justify="space-between" align="center" gap={16} wrap>
|
||||
<div>
|
||||
<Title level={4} className="board-page__title">
|
||||
Plan
|
||||
</Title>
|
||||
<Paragraph className="board-page__copy">
|
||||
마크다운 본문을 입력하고 즉시 프리뷰를 확인한 뒤 DB에 저장합니다.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Button icon={<PlusOutlined />} onClick={handleCreateDraft}>
|
||||
새 글
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
loading={saving}
|
||||
disabled={isDraftLocked}
|
||||
onClick={() => {
|
||||
void handleSave();
|
||||
}}
|
||||
>
|
||||
저장
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
</Card>
|
||||
|
||||
{errorMessage ? (
|
||||
<Card className="board-page__card" bordered={false}>
|
||||
<Text type="danger">{errorMessage}</Text>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="board-page__grid">
|
||||
<Card
|
||||
title={`게시글 목록 (${items.length})`}
|
||||
className={`board-page__card board-page__list-card${
|
||||
showMobileDetailOnly ? ' board-page__list-card--mobile-hidden' : ''
|
||||
}`}
|
||||
bordered={false}
|
||||
extra={
|
||||
<Space size={8} wrap>
|
||||
{loading ? <Spin size="small" /> : null}
|
||||
<Text type="secondary" className="board-page__bulk-count">
|
||||
선택 {checkedReceivableCount}건
|
||||
</Text>
|
||||
<Checkbox
|
||||
indeterminate={partiallyChecked}
|
||||
checked={allReceivableChecked}
|
||||
disabled={!receivableIds.length}
|
||||
onChange={(event) => {
|
||||
setCheckedIds((previous) => {
|
||||
if (event.target.checked) {
|
||||
return Array.from(new Set([...previous, ...receivableIds]));
|
||||
}
|
||||
|
||||
return previous.filter((id) => !receivableIdSet.has(id));
|
||||
});
|
||||
}}
|
||||
>
|
||||
대기 전체
|
||||
</Checkbox>
|
||||
<Button
|
||||
icon={<CheckSquareOutlined />}
|
||||
loading={automationReceiving}
|
||||
disabled={!checkedReceivableCount}
|
||||
onClick={() => {
|
||||
void handleBulkAutomationReceive();
|
||||
}}
|
||||
>
|
||||
선택 접수
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="board-page__loading">
|
||||
<Spin />
|
||||
</div>
|
||||
) : items.length ? (
|
||||
<List
|
||||
dataSource={items}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
className={item.id === selectedId ? 'board-page__list-item is-active' : 'board-page__list-item'}
|
||||
onClick={() => {
|
||||
setSelectedId(item.id);
|
||||
if (isMobileViewport) {
|
||||
setMobileDetailOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<Checkbox
|
||||
checked={checkedIds.includes(item.id)}
|
||||
disabled={Boolean(getBoardPostAutomationReceiveError(item, dirtyDraftId))}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onChange={(event) => {
|
||||
setCheckedIds((previous) =>
|
||||
event.target.checked
|
||||
? Array.from(new Set([...previous, item.id]))
|
||||
: previous.filter((checkedId) => checkedId !== item.id),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
title={
|
||||
<Flex justify="space-between" align="center" gap={8}>
|
||||
<Flex align="center" gap={8} className="board-page__list-title">
|
||||
<FileTextOutlined className="board-page__list-icon" />
|
||||
<Text strong>{item.title}</Text>
|
||||
</Flex>
|
||||
<Space size={6} wrap>
|
||||
{item.id === dirtyDraftId ? <Tag color="warning">저장 필요</Tag> : null}
|
||||
<Tag color={item.automationReceivedAt || item.automationPlanItemId ? 'processing' : 'default'}>
|
||||
{item.automationReceivedAt || item.automationPlanItemId ? '접수완료' : '대기'}
|
||||
</Tag>
|
||||
</Space>
|
||||
</Flex>
|
||||
}
|
||||
description={
|
||||
<Space direction="vertical" size={4}>
|
||||
<Text type="secondary" className="board-page__list-preview">
|
||||
{item.preview || '본문 미리보기가 없습니다.'}
|
||||
</Text>
|
||||
<Text type="secondary">수정 {formatDateTime(item.updatedAt)}</Text>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="등록된 게시글이 없습니다." />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div
|
||||
className={`board-page__editor-column${
|
||||
isMobileViewport && !mobileDetailOpen ? ' board-page__editor-column--mobile-hidden' : ''
|
||||
}`}
|
||||
>
|
||||
<Card
|
||||
title={draft.id ? `게시글 #${draft.id}` : '새 게시글'}
|
||||
className="board-page__card board-page__editor-card"
|
||||
bordered={false}
|
||||
extra={
|
||||
<Space wrap>
|
||||
{isMobileViewport && mobileDetailOpen ? (
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => {
|
||||
setMobileDetailOpen(false);
|
||||
}}
|
||||
>
|
||||
목록
|
||||
</Button>
|
||||
) : null}
|
||||
{draft.id ? <Tag color={automationStatus.color}>{automationStatus.label}</Tag> : null}
|
||||
{draft.id && selectedItem?.automationPlanItemId ? (
|
||||
<Button
|
||||
href={`/plans/all?planId=${selectedItem.automationPlanItemId}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
연결자동화
|
||||
</Button>
|
||||
) : null}
|
||||
{draft.id ? (
|
||||
<Button
|
||||
icon={<PlayCircleOutlined />}
|
||||
loading={automationReceiving}
|
||||
disabled={automationReceived && !automationReceiveError}
|
||||
onClick={() => {
|
||||
void handleAutomationReceive();
|
||||
}}
|
||||
>
|
||||
{automationReceived ? '자동화 접수됨' : automationReceiveError ? '접수 재시도' : '자동화 접수'}
|
||||
</Button>
|
||||
) : null}
|
||||
{draft.id ? (
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={deleting}
|
||||
disabled={isDraftLocked}
|
||||
onClick={() => {
|
||||
void handleDelete();
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" size={16} className="board-page__editor">
|
||||
<Input
|
||||
size="large"
|
||||
placeholder="제목을 입력하세요"
|
||||
value={draft.title}
|
||||
readOnly={isDraftLocked}
|
||||
onChange={(event) => {
|
||||
setDraft((previous) => ({
|
||||
...previous,
|
||||
title: event.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Flex gap={8} wrap>
|
||||
<Tag color={automationStatus.color}>{automationStatus.label}</Tag>
|
||||
{automationStatus.description ? <Text type="secondary">{automationStatus.description}</Text> : null}
|
||||
</Flex>
|
||||
<div className="board-page__automation-field">
|
||||
<Text strong>자동화 처리</Text>
|
||||
{automationReceived ? (
|
||||
<div className="board-page__automation-readonly" aria-readonly="true">
|
||||
<Text>{automationTypeLabel}</Text>
|
||||
<Tag color="processing">접수 후 읽기전용</Tag>
|
||||
</div>
|
||||
) : (
|
||||
<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}
|
||||
disabled={isDraftLocked}
|
||||
onChange={(automationType) => {
|
||||
setDraft((previous) => ({
|
||||
...previous,
|
||||
automationType,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={`board-page__editor-frame${contentExpanded ? ' board-page__editor-frame--expanded' : ''}`}>
|
||||
{contentExpanded ? (
|
||||
<Flex justify="space-between" align="center" gap={12} className="board-page__editor-toolbar">
|
||||
<Text strong>본문 전체화면</Text>
|
||||
<Space size={8}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
aria-label="본문 복사"
|
||||
title="본문 복사"
|
||||
onClick={() => {
|
||||
void handleCopyContent();
|
||||
}}
|
||||
>
|
||||
복사
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CompressOutlined />}
|
||||
aria-label="본문 최대화 해제"
|
||||
onClick={() => {
|
||||
setContentExpanded(false);
|
||||
}}
|
||||
>
|
||||
닫기
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
) : null}
|
||||
<Segmented
|
||||
className="board-page__mobile-toggle"
|
||||
options={[
|
||||
{ label: '편집', value: 'edit', icon: <FileTextOutlined /> },
|
||||
{ label: '미리보기', value: 'preview', icon: <EyeOutlined /> },
|
||||
]}
|
||||
value={mobileView}
|
||||
onChange={(value) => {
|
||||
setMobileView(value as 'edit' | 'preview');
|
||||
}}
|
||||
/>
|
||||
<Flex justify="space-between" align="center" gap={8}>
|
||||
<Text type="secondary">본문</Text>
|
||||
<Space size={8}>
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
aria-label="본문 복사"
|
||||
title="본문 복사"
|
||||
onClick={() => {
|
||||
void handleCopyContent();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
icon={contentExpanded ? <CompressOutlined /> : <ExpandOutlined />}
|
||||
aria-label={contentExpanded ? '본문 최대화 해제' : '본문 최대화'}
|
||||
title={contentExpanded ? '본문 최대화 해제' : '본문 최대화'}
|
||||
onClick={() => {
|
||||
setContentExpanded((previous) => !previous);
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</Flex>
|
||||
<div
|
||||
className={`board-page__preview-grid${contentExpanded ? ' board-page__preview-grid--expanded' : ''}`}
|
||||
>
|
||||
<div
|
||||
className={`board-page__pane${mobileView === 'preview' ? ' board-page__pane--mobile-hidden' : ''}${
|
||||
contentExpanded ? ' board-page__pane--expanded' : ''
|
||||
}`}
|
||||
>
|
||||
<TextArea
|
||||
value={draft.content}
|
||||
placeholder={'# 제목\n\n마크다운 본문을 입력하세요.\n\n- 목록\n- 코드블록\n- 링크'}
|
||||
readOnly={isDraftLocked}
|
||||
onChange={(event) => {
|
||||
setDraft((previous) => ({
|
||||
...previous,
|
||||
content: event.target.value,
|
||||
}));
|
||||
}}
|
||||
className={`board-page__textarea${contentExpanded ? ' board-page__textarea--expanded' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`board-page__pane${mobileView === 'edit' ? ' board-page__pane--mobile-hidden' : ''}${
|
||||
contentExpanded ? ' board-page__pane--expanded' : ''
|
||||
}`}
|
||||
>
|
||||
<div className={`board-page__preview${contentExpanded ? ' board-page__preview--expanded' : ''}`}>
|
||||
<div className="board-page__preview-content">
|
||||
{draft.content.trim() ? (
|
||||
<MarkdownPreviewContent content={draft.content} />
|
||||
) : (
|
||||
<Empty description="미리보기할 본문이 없습니다." image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isDraftLocked ? (
|
||||
<Text type="secondary">자동화 접수된 작업메모는 게시판에서 수정하거나 삭제할 수 없습니다.</Text>
|
||||
) : null}
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user