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([]); const [selectedId, setSelectedId] = useState(null); const [checkedIds, setCheckedIds] = useState([]); const [draft, setDraft] = useState(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(null); const [errorMessage, setErrorMessage] = useState(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(); 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 ( {contextHolder}
Plan 마크다운 본문을 입력하고 즉시 프리뷰를 확인한 뒤 DB에 저장합니다.
{errorMessage ? ( {errorMessage} ) : null}
{loading ? : null} 선택 {checkedReceivableCount}건 { setCheckedIds((previous) => { if (event.target.checked) { return Array.from(new Set([...previous, ...receivableIds])); } return previous.filter((id) => !receivableIdSet.has(id)); }); }} > 대기 전체 } > {loading ? (
) : items.length ? ( ( { setSelectedId(item.id); if (isMobileViewport) { setMobileDetailOpen(true); } }} > { event.stopPropagation(); }} onChange={(event) => { setCheckedIds((previous) => event.target.checked ? Array.from(new Set([...previous, item.id])) : previous.filter((checkedId) => checkedId !== item.id), ); }} /> } title={ {item.title} {item.id === dirtyDraftId ? 저장 필요 : null} {item.automationReceivedAt || item.automationPlanItemId ? '접수완료' : '대기'} } description={ {item.preview || '본문 미리보기가 없습니다.'} 수정 {formatDateTime(item.updatedAt)} } /> )} /> ) : ( )}
{isMobileViewport && mobileDetailOpen ? ( ) : null} {draft.id ? {automationStatus.label} : null} {draft.id && selectedItem?.automationPlanItemId ? ( ) : null} {draft.id ? ( ) : null} {draft.id ? ( ) : null} } > { setDraft((previous) => ({ ...previous, title: event.target.value, })); }} /> {automationStatus.label} {automationStatus.description ? {automationStatus.description} : null}
자동화 처리 {automationReceived ? (
{automationTypeLabel} 접수 후 읽기전용
) : (