Fix chat type persistence and board flow
This commit is contained in:
@@ -1,18 +1,26 @@
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
DownOutlined,
|
||||
CheckSquareOutlined,
|
||||
CompressOutlined,
|
||||
CloseOutlined,
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
ArrowsAltOutlined,
|
||||
EyeOutlined,
|
||||
ExpandOutlined,
|
||||
FileTextOutlined,
|
||||
LinkOutlined,
|
||||
PaperClipOutlined,
|
||||
PlayCircleOutlined,
|
||||
PlusOutlined,
|
||||
SaveOutlined,
|
||||
ShrinkOutlined,
|
||||
UpOutlined,
|
||||
UploadOutlined,
|
||||
} 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 type { ChangeEvent, RefObject } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { uploadChatComposerFile } from '../../app/main/mainChatPanel';
|
||||
import {
|
||||
buildAutomationTypeOptions,
|
||||
resolveAutomationTypeLabel,
|
||||
@@ -27,7 +35,7 @@ import {
|
||||
setupBoard,
|
||||
updateBoardPost,
|
||||
} from './api';
|
||||
import type { BoardDraft, BoardPost } from './types';
|
||||
import type { BoardAttachment, BoardDraft, BoardPost } from './types';
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
const { TextArea } = Input;
|
||||
@@ -36,15 +44,68 @@ const EMPTY_DRAFT: BoardDraft = {
|
||||
id: null,
|
||||
title: '',
|
||||
content: '',
|
||||
attachments: [],
|
||||
automationType: 'none',
|
||||
};
|
||||
|
||||
function createBoardAttachmentSessionId() {
|
||||
const randomValue =
|
||||
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
return `board-draft-${randomValue}`;
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
return new Date(value).toLocaleString('ko-KR', {
|
||||
timeZone: 'Asia/Seoul',
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return '0 B';
|
||||
}
|
||||
|
||||
if (value >= 1024 * 1024) {
|
||||
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
if (value >= 1024) {
|
||||
return `${(value / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
return `${Math.round(value)} B`;
|
||||
}
|
||||
|
||||
function mergeBoardAttachments(current: BoardAttachment[], next: BoardAttachment[]) {
|
||||
const merged = [...current];
|
||||
const existingPaths = new Set(current.map((item) => item.path));
|
||||
|
||||
next.forEach((item) => {
|
||||
if (existingPaths.has(item.path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
existingPaths.add(item.path);
|
||||
merged.push(item);
|
||||
});
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function resolveBoardAttachmentSessionId(
|
||||
draftId: number | null,
|
||||
draftAttachmentSessionIdRef: RefObject<string>,
|
||||
) {
|
||||
if (draftId) {
|
||||
return `board-post-${draftId}`;
|
||||
}
|
||||
|
||||
return draftAttachmentSessionIdRef.current;
|
||||
}
|
||||
|
||||
async function copyText(value: string) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
@@ -122,6 +183,8 @@ function getBoardPostAutomationReceiveError(item: BoardPost, dirtyDraftId: numbe
|
||||
export function BoardPage() {
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const { automationTypes } = useAutomationTypeRegistry();
|
||||
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const draftAttachmentSessionIdRef = useRef<string>(createBoardAttachmentSessionId());
|
||||
const [items, setItems] = useState<BoardPost[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [checkedIds, setCheckedIds] = useState<number[]>([]);
|
||||
@@ -129,34 +192,15 @@ export function BoardPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [attachmentUploading, setAttachmentUploading] = useState(false);
|
||||
const [automationReceiving, setAutomationReceiving] = useState(false);
|
||||
const [automationReceiveError, setAutomationReceiveError] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [detailMode, setDetailMode] = useState<'list' | 'detail'>('list');
|
||||
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);
|
||||
};
|
||||
}, []);
|
||||
const [maximizedPane, setMaximizedPane] = useState<'none' | 'edit' | 'preview'>('none');
|
||||
const [attachmentsExpanded, setAttachmentsExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -193,11 +237,32 @@ export function BoardPage() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia('(max-width: 960px)');
|
||||
const update = () => {
|
||||
setIsMobileViewport(mediaQuery.matches);
|
||||
if (!mediaQuery.matches) {
|
||||
setMobileView('edit');
|
||||
}
|
||||
setAttachmentsExpanded(!mediaQuery.matches);
|
||||
};
|
||||
|
||||
update();
|
||||
mediaQuery.addEventListener('change', update);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
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(
|
||||
@@ -218,6 +283,7 @@ export function BoardPage() {
|
||||
() => resolveAutomationTypeLabel(automationTypes, draft.automationType),
|
||||
[automationTypes, draft.automationType],
|
||||
);
|
||||
const isPaneMaximized = maximizedPane !== 'none';
|
||||
const receivableIds = useMemo(
|
||||
() =>
|
||||
items
|
||||
@@ -236,6 +302,7 @@ export function BoardPage() {
|
||||
id: selectedItem.id,
|
||||
title: selectedItem.title,
|
||||
content: selectedItem.content,
|
||||
attachments: selectedItem.attachments,
|
||||
automationType: selectedItem.automationType,
|
||||
});
|
||||
setAutomationReceiveError(null);
|
||||
@@ -252,10 +319,82 @@ export function BoardPage() {
|
||||
}, [items]);
|
||||
|
||||
const handleCreateDraft = () => {
|
||||
draftAttachmentSessionIdRef.current = createBoardAttachmentSessionId();
|
||||
setSelectedId(null);
|
||||
setDraft(EMPTY_DRAFT);
|
||||
setAutomationReceiveError(null);
|
||||
setMobileDetailOpen(isMobileViewport);
|
||||
setMaximizedPane('none');
|
||||
setMobileView('edit');
|
||||
setDetailMode('detail');
|
||||
};
|
||||
|
||||
const handleOpenDetail = (itemId: number) => {
|
||||
setSelectedId(itemId);
|
||||
setAutomationReceiveError(null);
|
||||
setMaximizedPane('none');
|
||||
setMobileView('edit');
|
||||
setDetailMode('detail');
|
||||
};
|
||||
|
||||
const handleCloseDetail = () => {
|
||||
setAutomationReceiveError(null);
|
||||
setMaximizedPane('none');
|
||||
setDetailMode('list');
|
||||
};
|
||||
|
||||
const handleAttachmentFilesPicked = async (files: File[]) => {
|
||||
if (files.length === 0 || attachmentUploading || isDraftLocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAttachmentUploading(true);
|
||||
|
||||
try {
|
||||
const sessionId = resolveBoardAttachmentSessionId(draft.id, draftAttachmentSessionIdRef);
|
||||
const uploadResults = await Promise.allSettled(files.map((file) => uploadChatComposerFile(sessionId, file)));
|
||||
const uploadedItems: BoardAttachment[] = [];
|
||||
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) {
|
||||
setDraft((previous) => ({
|
||||
...previous,
|
||||
attachments: mergeBoardAttachments(previous.attachments, uploadedItems),
|
||||
}));
|
||||
messageApi.success(`첨부 파일 ${uploadedItems.length}건을 추가했습니다.`);
|
||||
}
|
||||
|
||||
if (failedFileNames.length > 0) {
|
||||
messageApi.error(`업로드 실패: ${failedFileNames.join(', ')}`);
|
||||
}
|
||||
} finally {
|
||||
setAttachmentUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAttachmentInputChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
event.target.value = '';
|
||||
void handleAttachmentFilesPicked(files);
|
||||
};
|
||||
|
||||
const handleRemoveAttachment = (attachmentId: string) => {
|
||||
if (isDraftLocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDraft((previous) => ({
|
||||
...previous,
|
||||
attachments: previous.attachments.filter((attachment) => attachment.id !== attachmentId),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -293,7 +432,7 @@ export function BoardPage() {
|
||||
return [savedItem, ...filtered];
|
||||
});
|
||||
setSelectedId(savedItem.id);
|
||||
setMobileDetailOpen(isMobileViewport);
|
||||
setDetailMode('detail');
|
||||
messageApi.success(draft.id ? '게시글을 수정했습니다.' : '게시글을 등록했습니다.');
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : '게시글 저장에 실패했습니다.');
|
||||
@@ -315,7 +454,7 @@ export function BoardPage() {
|
||||
setItems((previous) => previous.filter((item) => item.id !== draft.id));
|
||||
setSelectedId((previous) => (previous === draft.id ? null : previous));
|
||||
setDraft(EMPTY_DRAFT);
|
||||
setMobileDetailOpen(false);
|
||||
setDetailMode('list');
|
||||
messageApi.success('게시글을 삭제했습니다.');
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : '게시글 삭제에 실패했습니다.');
|
||||
@@ -441,52 +580,56 @@ export function BoardPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} className="board-page">
|
||||
<div
|
||||
className={`board-page${detailMode === 'detail' ? ' board-page--detail' : ''}${
|
||||
isPaneMaximized ? ' board-page--pane-maximized' : ''
|
||||
}`}
|
||||
>
|
||||
{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>
|
||||
<input
|
||||
ref={attachmentInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="board-page__hidden-file-input"
|
||||
onChange={handleAttachmentInputChange}
|
||||
/>
|
||||
<Space direction="vertical" size={16} className="board-page__stack">
|
||||
{detailMode === 'list' ? (
|
||||
<Card className="board-page__card board-page__overview-card" bordered={false}>
|
||||
<Flex justify="space-between" align="center" gap={16} wrap>
|
||||
<div>
|
||||
<Title level={4} className="board-page__title">
|
||||
작업 요청
|
||||
</Title>
|
||||
<Paragraph className="board-page__copy">
|
||||
제목, 자동화 유형, 첨부 파일을 한 번에 정리하고 저장 후 바로 자동화 접수합니다.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Button icon={<PlusOutlined />} onClick={handleCreateDraft}>
|
||||
새 글
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{errorMessage ? (
|
||||
{errorMessage && detailMode === 'list' ? (
|
||||
<Card className="board-page__card" bordered={false}>
|
||||
<Text type="danger">{errorMessage}</Text>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="board-page__grid">
|
||||
{detailMode === 'list' ? (
|
||||
<Card
|
||||
title={`게시글 목록 (${items.length})`}
|
||||
className={`board-page__card board-page__list-card${
|
||||
showMobileDetailOnly ? ' board-page__list-card--mobile-hidden' : ''
|
||||
}`}
|
||||
className="board-page__card board-page__list-card"
|
||||
bordered={false}
|
||||
extra={
|
||||
<Space size={8} wrap>
|
||||
<Button icon={<PlusOutlined />} onClick={handleCreateDraft}>
|
||||
새 글
|
||||
</Button>
|
||||
{loading ? <Spin size="small" /> : null}
|
||||
<Text type="secondary" className="board-page__bulk-count">
|
||||
선택 {checkedReceivableCount}건
|
||||
@@ -528,15 +671,12 @@ export function BoardPage() {
|
||||
<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
|
||||
className={item.id === selectedId ? 'board-page__list-item is-active' : 'board-page__list-item'}
|
||||
onClick={() => {
|
||||
handleOpenDetail(item.id);
|
||||
}}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<Checkbox
|
||||
@@ -562,6 +702,7 @@ export function BoardPage() {
|
||||
</Flex>
|
||||
<Space size={6} wrap>
|
||||
{item.id === dirtyDraftId ? <Tag color="warning">저장 필요</Tag> : null}
|
||||
{item.attachments.length ? <Tag color="blue">첨부 {item.attachments.length}</Tag> : null}
|
||||
<Tag color={item.automationReceivedAt || item.automationPlanItemId ? 'processing' : 'default'}>
|
||||
{item.automationReceivedAt || item.automationPlanItemId ? '접수완료' : '대기'}
|
||||
</Tag>
|
||||
@@ -584,151 +725,335 @@ export function BoardPage() {
|
||||
<Empty description="등록된 게시글이 없습니다." />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div
|
||||
className={`board-page__editor-column${
|
||||
isMobileViewport && !mobileDetailOpen ? ' board-page__editor-column--mobile-hidden' : ''
|
||||
}`}
|
||||
>
|
||||
) : (
|
||||
<div className="board-page__editor-column">
|
||||
<Card
|
||||
title={draft.id ? `게시글 #${draft.id}` : '새 게시글'}
|
||||
className="board-page__card board-page__editor-card"
|
||||
className={`board-page__card board-page__editor-card${isPaneMaximized ? ' board-page__editor-card--pane-maximized' : ''}`}
|
||||
bordered={false}
|
||||
extra={
|
||||
<Space wrap>
|
||||
{isMobileViewport && mobileDetailOpen ? (
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => {
|
||||
setMobileDetailOpen(false);
|
||||
}}
|
||||
>
|
||||
목록
|
||||
</Button>
|
||||
) : null}
|
||||
<Space wrap className="board-page__header-actions">
|
||||
<Button icon={<ArrowLeftOutlined />} aria-label="목록으로" title="목록으로" onClick={handleCloseDetail} />
|
||||
<Button icon={<PlusOutlined />} onClick={handleCreateDraft} aria-label="새 글" title="새 글" />
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
aria-label="저장"
|
||||
title="저장"
|
||||
loading={saving}
|
||||
disabled={isDraftLocked}
|
||||
onClick={() => {
|
||||
void handleSave();
|
||||
}}
|
||||
/>
|
||||
{draft.id ? <Tag color={automationStatus.color}>{automationStatus.label}</Tag> : null}
|
||||
{draft.id && selectedItem?.automationPlanItemId ? (
|
||||
<Button
|
||||
icon={<LinkOutlined />}
|
||||
aria-label="연결 자동화 열기"
|
||||
title="연결 자동화 열기"
|
||||
href={`/plans/all?planId=${selectedItem.automationPlanItemId}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
연결자동화
|
||||
</Button>
|
||||
/>
|
||||
) : null}
|
||||
{draft.id ? (
|
||||
<Button
|
||||
icon={<PlayCircleOutlined />}
|
||||
aria-label={automationReceived ? '자동화 접수됨' : automationReceiveError ? '접수 재시도' : '자동화 접수'}
|
||||
title={automationReceived ? '자동화 접수됨' : automationReceiveError ? '접수 재시도' : '자동화 접수'}
|
||||
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}
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
aria-label="삭제"
|
||||
title="삭제"
|
||||
loading={deleting}
|
||||
disabled={!draft.id || isDraftLocked}
|
||||
onClick={() => {
|
||||
void handleDelete();
|
||||
}}
|
||||
/>
|
||||
</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 className="board-page__editor">
|
||||
{errorMessage ? <Text type="danger">{errorMessage}</Text> : null}
|
||||
<div className="board-page__editor-scroll">
|
||||
<div className={`board-page__meta-stack${isPaneMaximized ? ' board-page__meta-stack--hidden' : ''}`}>
|
||||
<div className="board-page__hero">
|
||||
<div className="board-page__hero-main">
|
||||
<div className="board-page__field-label-row">
|
||||
<Text strong>요청 제목</Text>
|
||||
<Flex gap={8} wrap>
|
||||
<Tag color={automationStatus.color}>{automationStatus.label}</Tag>
|
||||
{automationStatus.description ? <Text type="secondary">{automationStatus.description}</Text> : null}
|
||||
</Flex>
|
||||
</div>
|
||||
<Input
|
||||
size="large"
|
||||
placeholder="예: 작업요청 입력 폼을 전면 개편하고 첨부 자동 전달 연결"
|
||||
value={draft.title}
|
||||
readOnly={isDraftLocked}
|
||||
onChange={(event) => {
|
||||
setDraft((previous) => ({
|
||||
...previous,
|
||||
title: event.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="board-page__hero-side">
|
||||
<div className="board-page__automation-field">
|
||||
<div className="board-page__field-label-row">
|
||||
<Text strong>자동화 처리</Text>
|
||||
{automationReceived ? <Tag color="processing">접수 후 읽기전용</Tag> : null}
|
||||
</div>
|
||||
{automationReceived ? (
|
||||
<div className="board-page__automation-readonly" aria-readonly="true">
|
||||
<Text>{automationTypeLabel}</Text>
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
className="board-page__automation-select"
|
||||
value={draft.automationType}
|
||||
options={automationTypeOptions}
|
||||
popupClassName="board-page__automation-select-popup"
|
||||
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
|
||||
disabled={isDraftLocked}
|
||||
onChange={(automationType) => {
|
||||
setDraft((previous) => ({
|
||||
...previous,
|
||||
automationType,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
className="board-page__automation-select"
|
||||
value={draft.automationType}
|
||||
options={automationTypeOptions}
|
||||
popupClassName="board-page__automation-select-popup"
|
||||
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
|
||||
disabled={isDraftLocked}
|
||||
onChange={(automationType) => {
|
||||
setDraft((previous) => ({
|
||||
...previous,
|
||||
automationType,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="board-page__attachment-panel">
|
||||
<Flex justify="space-between" align="center" gap={12} wrap>
|
||||
<div>
|
||||
<div className="board-page__field-label-row">
|
||||
<Text strong>첨부 파일</Text>
|
||||
<Tag color={draft.attachments.length ? 'blue' : 'default'}>
|
||||
{draft.attachments.length}건
|
||||
</Tag>
|
||||
</div>
|
||||
<Text type="secondary">자동화 접수 시 아래 첨부 파일 경로가 작업 메모에 자동으로 포함됩니다.</Text>
|
||||
</div>
|
||||
<Space size={8}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={attachmentsExpanded ? <UpOutlined /> : <DownOutlined />}
|
||||
aria-label={attachmentsExpanded ? '첨부 파일 접기' : '첨부 파일 펼치기'}
|
||||
title={attachmentsExpanded ? '첨부 파일 접기' : '첨부 파일 펼치기'}
|
||||
onClick={() => {
|
||||
setAttachmentsExpanded((current) => !current);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
icon={<UploadOutlined />}
|
||||
aria-label="파일 추가"
|
||||
title="파일 추가"
|
||||
loading={attachmentUploading}
|
||||
disabled={isDraftLocked}
|
||||
onClick={() => {
|
||||
attachmentInputRef.current?.click();
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</Flex>
|
||||
{attachmentsExpanded ? draft.attachments.length ? (
|
||||
<div className="board-page__attachment-grid">
|
||||
{draft.attachments.map((attachment) => (
|
||||
<div key={attachment.id} className="board-page__attachment-card">
|
||||
<Flex justify="space-between" align="start" gap={12}>
|
||||
<Flex vertical gap={6} className="board-page__attachment-copy">
|
||||
<Space size={8} wrap>
|
||||
<PaperClipOutlined className="board-page__attachment-icon" />
|
||||
<Text strong ellipsis={{ tooltip: attachment.name }}>
|
||||
{attachment.name}
|
||||
</Text>
|
||||
</Space>
|
||||
<Text type="secondary">{formatBytes(attachment.size)}</Text>
|
||||
<Text type="secondary" className="board-page__attachment-path">
|
||||
{attachment.path}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Space size={6}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<LinkOutlined />}
|
||||
href={attachment.publicUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CloseOutlined />}
|
||||
disabled={isDraftLocked}
|
||||
onClick={() => {
|
||||
handleRemoveAttachment(attachment.id);
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</Flex>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="첨부 파일이 없습니다." />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="board-page__markdown-field">
|
||||
<Text strong className={`board-page__field-label${isPaneMaximized ? ' board-page__field-label--hidden' : ''}`}>
|
||||
본문
|
||||
</Text>
|
||||
<div className="board-page__markdown-editor">
|
||||
<div className="board-page__editor-toolbar">
|
||||
{isMobileViewport ? (
|
||||
<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');
|
||||
setMaximizedPane('none');
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Space size={8} wrap className="board-page__desktop-toolbar">
|
||||
<Button
|
||||
type={maximizedPane === 'edit' ? 'primary' : 'default'}
|
||||
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||
aria-label={maximizedPane === 'edit' ? '편집 축소' : '편집 최대화'}
|
||||
title={maximizedPane === 'edit' ? '편집 축소' : '편집 최대화'}
|
||||
onClick={() => {
|
||||
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type={maximizedPane === 'preview' ? 'primary' : 'default'}
|
||||
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||
aria-label={maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
|
||||
title={maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
|
||||
onClick={() => {
|
||||
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`board-page__preview-grid${
|
||||
isPaneMaximized ? ' board-page__preview-grid--maximized' : ''
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`board-page__pane${
|
||||
mobileView === 'preview' ? ' board-page__pane--mobile-hidden' : ''
|
||||
}${maximizedPane === 'preview' ? ' board-page__pane--desktop-hidden' : ''}`}
|
||||
>
|
||||
<div className="board-page__pane-header">
|
||||
<Text type="secondary">편집</Text>
|
||||
<Space size={8}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
aria-label="본문 복사"
|
||||
title="본문 복사"
|
||||
onClick={() => {
|
||||
void handleCopyContent();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
type={maximizedPane === 'edit' ? 'primary' : 'default'}
|
||||
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||
aria-label={maximizedPane === 'edit' ? '편집 축소' : '편집 최대화'}
|
||||
title={maximizedPane === 'edit' ? '편집 축소' : '편집 최대화'}
|
||||
onClick={() => {
|
||||
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`board-page__pane${
|
||||
mobileView === 'edit' ? ' board-page__pane--mobile-hidden' : ''
|
||||
}${maximizedPane === 'edit' ? ' board-page__pane--desktop-hidden' : ''}`}
|
||||
>
|
||||
<div className="board-page__preview">
|
||||
<div className="board-page__pane-header">
|
||||
<Text type="secondary">미리보기</Text>
|
||||
<Space size={8}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
aria-label="본문 복사"
|
||||
title="본문 복사"
|
||||
onClick={() => {
|
||||
void handleCopyContent();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
type={maximizedPane === 'preview' ? 'primary' : 'default'}
|
||||
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||
aria-label={maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
|
||||
title={maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
|
||||
onClick={() => {
|
||||
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</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>
|
||||
{isPaneMaximized ? (
|
||||
<div className="board-page__floating-toolbar">
|
||||
<Space size={8}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
aria-label="본문 복사"
|
||||
title="본문 복사"
|
||||
@@ -737,60 +1062,22 @@ export function BoardPage() {
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
icon={contentExpanded ? <CompressOutlined /> : <ExpandOutlined />}
|
||||
aria-label={contentExpanded ? '본문 최대화 해제' : '본문 최대화'}
|
||||
title={contentExpanded ? '본문 최대화 해제' : '본문 최대화'}
|
||||
size="small"
|
||||
icon={<ShrinkOutlined />}
|
||||
aria-label="편집 보기로 복귀"
|
||||
title="편집 보기로 복귀"
|
||||
onClick={() => {
|
||||
setContentExpanded((previous) => !previous);
|
||||
setMaximizedPane('none');
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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';
|
||||
import type { BoardAttachment, BoardAutomationType, BoardDraft, BoardPost } from './types';
|
||||
|
||||
class BoardApiError extends Error {
|
||||
status: number;
|
||||
@@ -17,6 +17,37 @@ function normalizeBoardAutomationType(value: unknown): BoardAutomationType {
|
||||
return normalizeAutomationTypeId(value);
|
||||
}
|
||||
|
||||
function normalizeBoardAttachment(item: unknown): BoardAttachment | null {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = item as Partial<BoardAttachment>;
|
||||
const id = String(candidate.id ?? '').trim();
|
||||
const path = String(candidate.path ?? '').trim();
|
||||
|
||||
if (!id || !path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name: String(candidate.name ?? '').trim() || path.split('/').pop() || '첨부 파일',
|
||||
path,
|
||||
publicUrl: String(candidate.publicUrl ?? '').trim() || path,
|
||||
size: Math.max(0, Number(candidate.size ?? 0) || 0),
|
||||
mimeType: String(candidate.mimeType ?? '').trim() || 'application/octet-stream',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBoardPost(item: BoardPost): BoardPost {
|
||||
return {
|
||||
...item,
|
||||
automationType: normalizeBoardAutomationType(item.automationType),
|
||||
attachments: Array.isArray(item.attachments) ? item.attachments.map(normalizeBoardAttachment).filter(Boolean) as BoardAttachment[] : [],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBoardApiBaseUrl() {
|
||||
if (import.meta.env.VITE_WORK_SERVER_URL) {
|
||||
return import.meta.env.VITE_WORK_SERVER_URL;
|
||||
@@ -134,10 +165,7 @@ export async function setupBoard() {
|
||||
|
||||
export async function fetchBoardPosts() {
|
||||
const response = await request<{ ok: boolean; items: BoardPost[] }>('/board/posts');
|
||||
return response.items.map((item) => ({
|
||||
...item,
|
||||
automationType: normalizeBoardAutomationType(item.automationType),
|
||||
}));
|
||||
return response.items.map((item) => normalizeBoardPost(item));
|
||||
}
|
||||
|
||||
export async function createBoardPost(draft: BoardDraft) {
|
||||
@@ -146,14 +174,12 @@ export async function createBoardPost(draft: BoardDraft) {
|
||||
body: JSON.stringify({
|
||||
title: draft.title,
|
||||
content: draft.content,
|
||||
attachments: draft.attachments,
|
||||
automationType: draft.automationType,
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
...response.item,
|
||||
automationType: normalizeBoardAutomationType(response.item.automationType),
|
||||
};
|
||||
return normalizeBoardPost(response.item);
|
||||
}
|
||||
|
||||
export async function updateBoardPost(draft: BoardDraft) {
|
||||
@@ -166,14 +192,12 @@ export async function updateBoardPost(draft: BoardDraft) {
|
||||
body: JSON.stringify({
|
||||
title: draft.title,
|
||||
content: draft.content,
|
||||
attachments: draft.attachments,
|
||||
automationType: draft.automationType,
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
...response.item,
|
||||
automationType: normalizeBoardAutomationType(response.item.automationType),
|
||||
};
|
||||
return normalizeBoardPost(response.item);
|
||||
}
|
||||
|
||||
export async function receiveBoardPostAutomation(id: number) {
|
||||
@@ -188,10 +212,7 @@ export async function receiveBoardPostAutomation(id: number) {
|
||||
});
|
||||
|
||||
return {
|
||||
item: {
|
||||
...response.item,
|
||||
automationType: normalizeBoardAutomationType(response.item.automationType),
|
||||
},
|
||||
item: normalizeBoardPost(response.item),
|
||||
planItemId: response.planItemId,
|
||||
alreadyReceived: response.alreadyReceived,
|
||||
};
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
export type BoardAutomationType = string;
|
||||
|
||||
export type BoardAttachment = {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
publicUrl: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
export type BoardPost = {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
preview: string;
|
||||
attachments: BoardAttachment[];
|
||||
automationType: BoardAutomationType;
|
||||
automationPlanItemId: number | null;
|
||||
automationReceivedAt: string | null;
|
||||
@@ -16,5 +26,6 @@ export type BoardDraft = {
|
||||
id: number | null;
|
||||
title: string;
|
||||
content: string;
|
||||
attachments: BoardAttachment[];
|
||||
automationType: BoardAutomationType;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user