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>
|
||||
);
|
||||
}
|
||||
214
src/features/board/api.ts
Executable file
214
src/features/board/api.ts
Executable file
@@ -0,0 +1,214 @@
|
||||
import { appendClientIdHeader } from '../../app/main/clientIdentity';
|
||||
import { getRegisteredAccessToken } from '../../app/main/tokenAccess';
|
||||
import type { BoardAutomationType, BoardDraft, BoardPost } from './types';
|
||||
|
||||
class BoardApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = 'BoardApiError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
function resolveBoardApiBaseUrl() {
|
||||
if (import.meta.env.VITE_WORK_SERVER_URL) {
|
||||
return import.meta.env.VITE_WORK_SERVER_URL;
|
||||
}
|
||||
|
||||
return '/api';
|
||||
}
|
||||
|
||||
function resolveBoardFallbackBaseUrl() {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostname = window.location.hostname;
|
||||
const isLocalWorkServerHost =
|
||||
hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0';
|
||||
|
||||
if (!isLocalWorkServerHost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallbackUrl = new URL(window.location.origin);
|
||||
fallbackUrl.port = '3100';
|
||||
fallbackUrl.pathname = '/api';
|
||||
fallbackUrl.search = '';
|
||||
fallbackUrl.hash = '';
|
||||
return fallbackUrl.toString().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
const BOARD_API_BASE_URL = resolveBoardApiBaseUrl();
|
||||
const BOARD_API_FALLBACK_BASE_URL =
|
||||
!import.meta.env.VITE_WORK_SERVER_URL && BOARD_API_BASE_URL === '/api'
|
||||
? resolveBoardFallbackBaseUrl()
|
||||
: null;
|
||||
|
||||
async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit) {
|
||||
const headers = appendClientIdHeader(init?.headers);
|
||||
const hasBody = init?.body !== undefined && init.body !== null;
|
||||
const method = init?.method?.toUpperCase() ?? 'GET';
|
||||
const controller = new AbortController();
|
||||
const timeoutId = globalThis.setTimeout(() => controller.abort(), 8000);
|
||||
|
||||
if (hasBody && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
const token = getRegisteredAccessToken();
|
||||
if (token && !headers.has('X-Access-Token')) {
|
||||
headers.set('X-Access-Token', token);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
cache: init?.cache ?? (method === 'GET' ? 'no-store' : undefined),
|
||||
});
|
||||
} catch (error) {
|
||||
globalThis.clearTimeout(timeoutId);
|
||||
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new BoardApiError('게시판 서버 응답이 지연됩니다.', 408);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
globalThis.clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
let message = text || '게시판 요청에 실패했습니다.';
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(text) as { message?: string };
|
||||
message = payload.message || message;
|
||||
} catch {
|
||||
// Keep the plain text fallback when the response body is not JSON.
|
||||
}
|
||||
|
||||
throw new BoardApiError(message, response.status);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
try {
|
||||
return await requestOnce<T>(BOARD_API_BASE_URL, path, init);
|
||||
} catch (error) {
|
||||
const shouldRetryWithFallback =
|
||||
BOARD_API_FALLBACK_BASE_URL &&
|
||||
BOARD_API_FALLBACK_BASE_URL !== BOARD_API_BASE_URL &&
|
||||
(error instanceof BoardApiError
|
||||
? error.status === 404 || error.status === 408 || error.status === 502
|
||||
: error instanceof Error && /404|Failed to fetch|NetworkError/i.test(error.message));
|
||||
|
||||
if (!shouldRetryWithFallback) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return requestOnce<T>(BOARD_API_FALLBACK_BASE_URL, path, init);
|
||||
}
|
||||
}
|
||||
|
||||
export async function setupBoard() {
|
||||
return request<{ ok: boolean; table: string }>('/board/setup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchBoardPosts() {
|
||||
const response = await request<{ ok: boolean; items: BoardPost[] }>('/board/posts');
|
||||
return response.items.map((item) => ({
|
||||
...item,
|
||||
automationType: normalizeBoardAutomationType(item.automationType),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createBoardPost(draft: BoardDraft) {
|
||||
const response = await request<{ ok: boolean; item: BoardPost }>('/board/posts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: draft.title,
|
||||
content: draft.content,
|
||||
automationType: draft.automationType,
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
...response.item,
|
||||
automationType: normalizeBoardAutomationType(response.item.automationType),
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateBoardPost(draft: BoardDraft) {
|
||||
if (!draft.id) {
|
||||
throw new Error('수정할 게시글 ID가 없습니다.');
|
||||
}
|
||||
|
||||
const response = await request<{ ok: boolean; item: BoardPost }>(`/board/posts/${draft.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
title: draft.title,
|
||||
content: draft.content,
|
||||
automationType: draft.automationType,
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
...response.item,
|
||||
automationType: normalizeBoardAutomationType(response.item.automationType),
|
||||
};
|
||||
}
|
||||
|
||||
export async function receiveBoardPostAutomation(id: number) {
|
||||
const response = await request<{
|
||||
ok: boolean;
|
||||
item: BoardPost;
|
||||
planItemId: number | null;
|
||||
alreadyReceived: boolean;
|
||||
}>(`/board/posts/${id}/actions/automation-receive`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
return {
|
||||
item: {
|
||||
...response.item,
|
||||
automationType: normalizeBoardAutomationType(response.item.automationType),
|
||||
},
|
||||
planItemId: response.planItemId,
|
||||
alreadyReceived: response.alreadyReceived,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteBoardPost(id: number) {
|
||||
const response = await request<{ ok: boolean; id: number }>(`/board/posts/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
return response.id;
|
||||
}
|
||||
2
src/features/board/index.ts
Executable file
2
src/features/board/index.ts
Executable file
@@ -0,0 +1,2 @@
|
||||
export { BoardPage } from './BoardPage';
|
||||
export type { BoardDraft, BoardPost } from './types';
|
||||
28
src/features/board/types.ts
Executable file
28
src/features/board/types.ts
Executable file
@@ -0,0 +1,28 @@
|
||||
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 BoardPost = {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
preview: string;
|
||||
automationType: BoardAutomationType;
|
||||
automationPlanItemId: number | null;
|
||||
automationReceivedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type BoardDraft = {
|
||||
id: number | null;
|
||||
title: string;
|
||||
content: string;
|
||||
automationType: BoardAutomationType;
|
||||
};
|
||||
29
src/features/dashboard/TmsDashboardFeatureSamples.tsx
Executable file
29
src/features/dashboard/TmsDashboardFeatureSamples.tsx
Executable file
@@ -0,0 +1,29 @@
|
||||
import { Card, Flex, Typography } from 'antd';
|
||||
import { DashboardReportCardWidget } from '../../widgets/dashboard-report-card/DashboardReportCardWidget';
|
||||
import {
|
||||
tmsDeliveryFlowCardPreset,
|
||||
tmsDeliveryMetricsCardPreset,
|
||||
} from '../../data/dashboard-report-presets';
|
||||
|
||||
const { Paragraph, Title } = Typography;
|
||||
|
||||
export function TmsDashboardFeatureSamples() {
|
||||
return (
|
||||
<Card className="feature-dashboard-card" bordered={false}>
|
||||
<Title level={4}>TMS Dashboard Feature</Title>
|
||||
<Paragraph type="secondary">
|
||||
프로젝트에서 사용하는 TMS 전용 샘플입니다. 배송 현황과 배송 실적 지표를 함께
|
||||
보여줍니다.
|
||||
</Paragraph>
|
||||
|
||||
<Flex gap={16} wrap className="feature-dashboard-card__grid">
|
||||
<div className="feature-dashboard-card__item">
|
||||
<DashboardReportCardWidget {...tmsDeliveryFlowCardPreset} />
|
||||
</div>
|
||||
<div className="feature-dashboard-card__item">
|
||||
<DashboardReportCardWidget {...tmsDeliveryMetricsCardPreset} />
|
||||
</div>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
29
src/features/dashboard/WmsDashboardFeatureSamples.tsx
Executable file
29
src/features/dashboard/WmsDashboardFeatureSamples.tsx
Executable file
@@ -0,0 +1,29 @@
|
||||
import { Card, Flex, Typography } from 'antd';
|
||||
import { DashboardReportCardWidget } from '../../widgets/dashboard-report-card/DashboardReportCardWidget';
|
||||
import {
|
||||
wmsInboundOutboundCardPreset,
|
||||
wmsInventoryTrendCardPreset,
|
||||
} from '../../data/dashboard-report-presets';
|
||||
|
||||
const { Paragraph, Title } = Typography;
|
||||
|
||||
export function WmsDashboardFeatureSamples() {
|
||||
return (
|
||||
<Card className="feature-dashboard-card" bordered={false}>
|
||||
<Title level={4}>WMS Dashboard Feature</Title>
|
||||
<Paragraph type="secondary">
|
||||
프로젝트에서 사용하는 WMS 전용 샘플입니다. 입출고 진행 현황과 재고 추이를 함께
|
||||
확인할 수 있습니다.
|
||||
</Paragraph>
|
||||
|
||||
<Flex gap={16} wrap className="feature-dashboard-card__grid">
|
||||
<div className="feature-dashboard-card__item">
|
||||
<DashboardReportCardWidget {...wmsInboundOutboundCardPreset} />
|
||||
</div>
|
||||
<div className="feature-dashboard-card__item">
|
||||
<DashboardReportCardWidget {...wmsInventoryTrendCardPreset} />
|
||||
</div>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
398
src/features/history/HistoryPage.tsx
Executable file
398
src/features/history/HistoryPage.tsx
Executable file
@@ -0,0 +1,398 @@
|
||||
import { Button, Card, Descriptions, Empty, Flex, Input, List, Space, Spin, Table, Tag, Typography, message } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTokenAccess } from '../../app/main/tokenAccess';
|
||||
import { fetchVisitorClients, fetchVisitorDetail, updateVisitorNickname, type VisitorClientSearchFilters } from './api';
|
||||
import type { VisitHistory, VisitorClient } from './types';
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
|
||||
function formatDateTime(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '미기록';
|
||||
}
|
||||
|
||||
return new Date(value).toLocaleString('ko-KR', {
|
||||
timeZone: 'Asia/Seoul',
|
||||
});
|
||||
}
|
||||
|
||||
function formatCompactText(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return value.length > 88 ? `${value.slice(0, 85)}...` : value;
|
||||
}
|
||||
|
||||
function normalizeVisitorFilters(filters: VisitorClientSearchFilters) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(filters)
|
||||
.map(([key, value]) => [key, value?.trim() ?? ''])
|
||||
.filter(([, value]) => Boolean(value)),
|
||||
) as VisitorClientSearchFilters;
|
||||
}
|
||||
|
||||
export function HistoryPage() {
|
||||
const { hasAccess } = useTokenAccess();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [searchFilters, setSearchFilters] = useState<VisitorClientSearchFilters>({});
|
||||
const [filterInputs, setFilterInputs] = useState<VisitorClientSearchFilters>({});
|
||||
const [visitors, setVisitors] = useState<VisitorClient[]>([]);
|
||||
const [selectedClientId, setSelectedClientId] = useState<string | null>(null);
|
||||
const [selectedClient, setSelectedClient] = useState<VisitorClient | null>(null);
|
||||
const [visits, setVisits] = useState<VisitHistory[]>([]);
|
||||
const [nicknameInput, setNicknameInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [savingNickname, setSavingNickname] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAccess) {
|
||||
setVisitors([]);
|
||||
setSelectedClientId(null);
|
||||
setSelectedClient(null);
|
||||
setVisits([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const loadVisitors = async () => {
|
||||
setLoading(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const nextVisitors = await fetchVisitorClients(searchFilters);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setVisitors(nextVisitors);
|
||||
setSelectedClientId((previous) => {
|
||||
if (previous && nextVisitors.some((item) => item.clientId === previous)) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
return nextVisitors[0]?.clientId ?? null;
|
||||
});
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '방문자 목록을 불러오지 못했습니다.');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadVisitors();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [hasAccess, searchFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAccess || !selectedClientId) {
|
||||
setSelectedClient(null);
|
||||
setVisits([]);
|
||||
setNicknameInput('');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const loadDetail = async () => {
|
||||
setDetailLoading(true);
|
||||
|
||||
try {
|
||||
const detail = await fetchVisitorDetail(selectedClientId);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedClient(detail.client);
|
||||
setVisits(detail.visits);
|
||||
setNicknameInput(detail.client.nickname);
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
messageApi.error(error instanceof Error ? error.message : '방문 상세를 불러오지 못했습니다.');
|
||||
setSelectedClient(null);
|
||||
setVisits([]);
|
||||
setNicknameInput('');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadDetail();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [hasAccess, messageApi, selectedClientId]);
|
||||
|
||||
const visitColumns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '방문시각',
|
||||
dataIndex: 'visitedAt',
|
||||
key: 'visitedAt',
|
||||
render: (value: string) => formatDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '이벤트',
|
||||
dataIndex: 'eventType',
|
||||
key: 'eventType',
|
||||
render: (value: string) => <Tag color="blue">{value}</Tag>,
|
||||
},
|
||||
{
|
||||
title: 'URL',
|
||||
dataIndex: 'url',
|
||||
key: 'url',
|
||||
render: (value: string) => (
|
||||
<Typography.Link href={value} target="_blank" rel="noreferrer">
|
||||
{formatCompactText(value)}
|
||||
</Typography.Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'IP',
|
||||
dataIndex: 'ip',
|
||||
key: 'ip',
|
||||
render: (value: string | null) => value ?? '-',
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSaveNickname = async () => {
|
||||
if (!selectedClientId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedNickname = nicknameInput.trim();
|
||||
|
||||
if (!normalizedNickname) {
|
||||
messageApi.warning('닉네임을 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingNickname(true);
|
||||
|
||||
try {
|
||||
const updatedClient = await updateVisitorNickname(selectedClientId, normalizedNickname);
|
||||
setSelectedClient(updatedClient);
|
||||
setNicknameInput(updatedClient.nickname);
|
||||
setVisitors((previous) =>
|
||||
previous.map((item) => (item.clientId === updatedClient.clientId ? updatedClient : item)),
|
||||
);
|
||||
messageApi.success('닉네임을 수정했습니다.');
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : '닉네임 수정에 실패했습니다.');
|
||||
} finally {
|
||||
setSavingNickname(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!hasAccess) {
|
||||
return (
|
||||
<Card className="history-page__card" bordered={false}>
|
||||
<Paragraph className="app-main-copy">
|
||||
토큰 등록 사용자만 방문 이력 메뉴를 사용할 수 있습니다.
|
||||
</Paragraph>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} className="history-page">
|
||||
{contextHolder}
|
||||
<Card className="history-page__card" bordered={false}>
|
||||
<Flex justify="space-between" align="center" gap={16} wrap>
|
||||
<div>
|
||||
<Title level={4} className="history-page__title">
|
||||
방문자 이력
|
||||
</Title>
|
||||
<Paragraph className="history-page__copy">
|
||||
`client master / visit history` 구조로 비로그인 방문자를 식별하고 상세 이력을 조회합니다.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</Flex>
|
||||
<div className="history-page__filter-grid">
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="clientId"
|
||||
value={filterInputs.clientId}
|
||||
onChange={(event) => {
|
||||
setFilterInputs((previous) => ({ ...previous, clientId: event.target.value }));
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="닉네임"
|
||||
value={filterInputs.nickname}
|
||||
onChange={(event) => {
|
||||
setFilterInputs((previous) => ({ ...previous, nickname: event.target.value }));
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="방문 경로"
|
||||
value={filterInputs.path}
|
||||
onChange={(event) => {
|
||||
setFilterInputs((previous) => ({ ...previous, path: event.target.value }));
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="시작일 YYYY-MM-DD"
|
||||
value={filterInputs.visitedFrom}
|
||||
onChange={(event) => {
|
||||
setFilterInputs((previous) => ({ ...previous, visitedFrom: event.target.value }));
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="종료일 YYYY-MM-DD"
|
||||
value={filterInputs.visitedTo}
|
||||
onChange={(event) => {
|
||||
setFilterInputs((previous) => ({ ...previous, visitedTo: event.target.value }));
|
||||
}}
|
||||
/>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setSearchFilters(normalizeVisitorFilters(filterInputs));
|
||||
}}
|
||||
>
|
||||
검색
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setFilterInputs({});
|
||||
setSearchFilters({});
|
||||
}}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{errorMessage ? (
|
||||
<Card className="history-page__card" bordered={false}>
|
||||
<Text type="danger">{errorMessage}</Text>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="history-page__grid">
|
||||
<Card
|
||||
title={`전체 방문자 목록 (${visitors.length})`}
|
||||
className="history-page__card history-page__list-card"
|
||||
extra={loading ? <Spin size="small" /> : null}
|
||||
bordered={false}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="history-page__loading">
|
||||
<Spin />
|
||||
</div>
|
||||
) : visitors.length ? (
|
||||
<List
|
||||
dataSource={visitors}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
className={item.clientId === selectedClientId ? 'history-page__list-item is-active' : 'history-page__list-item'}
|
||||
onClick={() => {
|
||||
setSelectedClientId(item.clientId);
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size={4}>
|
||||
<Text strong>{item.nickname}</Text>
|
||||
<Text type="secondary">{item.clientId}</Text>
|
||||
<Text type="secondary">누적 방문 {item.visitCount}회</Text>
|
||||
<Text type="secondary">최근 방문 {formatDateTime(item.lastVisitedAt)}</Text>
|
||||
</Space>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="조회된 방문자가 없습니다." />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="특정 clientId 상세 조회"
|
||||
className="history-page__card history-page__detail-card"
|
||||
extra={detailLoading ? <Spin size="small" /> : null}
|
||||
bordered={false}
|
||||
>
|
||||
{!selectedClientId ? (
|
||||
<Empty description="방문자를 선택해 주세요." />
|
||||
) : detailLoading && !selectedClient ? (
|
||||
<div className="history-page__loading">
|
||||
<Spin />
|
||||
</div>
|
||||
) : selectedClient ? (
|
||||
<Space direction="vertical" size={16} className="history-page__detail">
|
||||
<Descriptions
|
||||
column={1}
|
||||
size="small"
|
||||
items={[
|
||||
{ key: 'clientId', label: 'clientId', children: selectedClient.clientId },
|
||||
{ key: 'firstVisitedAt', label: '최초 방문', children: formatDateTime(selectedClient.firstVisitedAt) },
|
||||
{ key: 'lastVisitedAt', label: '최근 방문', children: formatDateTime(selectedClient.lastVisitedAt) },
|
||||
{ key: 'visitCount', label: '방문 수', children: `${selectedClient.visitCount}회` },
|
||||
{
|
||||
key: 'lastVisitedUrl',
|
||||
label: '최근 URL',
|
||||
children: selectedClient.lastVisitedUrl ? (
|
||||
<Typography.Link href={selectedClient.lastVisitedUrl} target="_blank" rel="noreferrer">
|
||||
{formatCompactText(selectedClient.lastVisitedUrl)}
|
||||
</Typography.Link>
|
||||
) : (
|
||||
'-'
|
||||
),
|
||||
},
|
||||
{ key: 'lastUserAgent', label: 'User-Agent', children: formatCompactText(selectedClient.lastUserAgent) },
|
||||
{ key: 'lastIp', label: 'IP', children: selectedClient.lastIp ?? '-' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Flex gap={8} wrap>
|
||||
<Input
|
||||
value={nicknameInput}
|
||||
onChange={(event) => {
|
||||
setNicknameInput(event.target.value);
|
||||
}}
|
||||
placeholder="닉네임 수정"
|
||||
/>
|
||||
<Button type="primary" onClick={handleSaveNickname} loading={savingNickname}>
|
||||
닉네임 수정
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<Table<VisitHistory>
|
||||
rowKey="id"
|
||||
columns={visitColumns}
|
||||
dataSource={visits}
|
||||
pagination={{ pageSize: 8, showSizeChanger: false }}
|
||||
scroll={{ x: 720 }}
|
||||
/>
|
||||
</Space>
|
||||
) : (
|
||||
<Empty description="방문 상세를 불러오지 못했습니다." />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
202
src/features/history/api.ts
Executable file
202
src/features/history/api.ts
Executable file
@@ -0,0 +1,202 @@
|
||||
import { appendClientIdHeader, buildTrackedPageUrl, getOrCreateClientId } from '../../app/main/clientIdentity';
|
||||
import { getRegisteredAccessToken } from '../../app/main/tokenAccess';
|
||||
import type { AppPageDescriptor } from '../../store/appStore/types';
|
||||
import type { VisitHistory, VisitorClient } from './types';
|
||||
|
||||
export type VisitorClientSearchFilters = {
|
||||
search?: string;
|
||||
clientId?: string;
|
||||
nickname?: string;
|
||||
path?: string;
|
||||
visitedFrom?: string;
|
||||
visitedTo?: string;
|
||||
};
|
||||
|
||||
class HistoryApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = 'HistoryApiError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveHistoryApiBaseUrl() {
|
||||
if (import.meta.env.VITE_WORK_SERVER_URL) {
|
||||
return import.meta.env.VITE_WORK_SERVER_URL;
|
||||
}
|
||||
|
||||
return '/api';
|
||||
}
|
||||
|
||||
function resolveHistoryFallbackBaseUrl() {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostname = window.location.hostname;
|
||||
const isLocalWorkServerHost =
|
||||
hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0';
|
||||
|
||||
if (!isLocalWorkServerHost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallbackUrl = new URL(window.location.origin);
|
||||
fallbackUrl.port = '3100';
|
||||
fallbackUrl.pathname = '/api';
|
||||
fallbackUrl.search = '';
|
||||
fallbackUrl.hash = '';
|
||||
return fallbackUrl.toString().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
const HISTORY_API_BASE_URL = resolveHistoryApiBaseUrl();
|
||||
const HISTORY_API_FALLBACK_BASE_URL =
|
||||
!import.meta.env.VITE_WORK_SERVER_URL && HISTORY_API_BASE_URL === '/api'
|
||||
? resolveHistoryFallbackBaseUrl()
|
||||
: null;
|
||||
|
||||
async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit) {
|
||||
const headers = appendClientIdHeader(init?.headers);
|
||||
const hasBody = init?.body !== undefined && init.body !== null;
|
||||
const method = init?.method?.toUpperCase() ?? 'GET';
|
||||
const controller = new AbortController();
|
||||
const timeoutId = globalThis.setTimeout(() => controller.abort(), 8000);
|
||||
|
||||
if (hasBody && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
cache: init?.cache ?? (method === 'GET' ? 'no-store' : undefined),
|
||||
});
|
||||
} catch (error) {
|
||||
globalThis.clearTimeout(timeoutId);
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new HistoryApiError('서버 응답이 지연됩니다.', 408);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
globalThis.clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(text) as { message?: string };
|
||||
throw new HistoryApiError(payload.message || '방문 이력 요청에 실패했습니다.', response.status);
|
||||
} catch {
|
||||
throw new HistoryApiError(text || '방문 이력 요청에 실패했습니다.', response.status);
|
||||
}
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
try {
|
||||
return await requestOnce<T>(HISTORY_API_BASE_URL, path, init);
|
||||
} catch (error) {
|
||||
const shouldRetryWithFallback =
|
||||
HISTORY_API_FALLBACK_BASE_URL &&
|
||||
HISTORY_API_FALLBACK_BASE_URL !== HISTORY_API_BASE_URL &&
|
||||
(error instanceof HistoryApiError
|
||||
? error.status === 404 || error.status === 408 || error.status === 502
|
||||
: error instanceof Error && /404|Failed to fetch|NetworkError/i.test(error.message));
|
||||
|
||||
if (!shouldRetryWithFallback) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return requestOnce<T>(HISTORY_API_FALLBACK_BASE_URL, path, init);
|
||||
}
|
||||
}
|
||||
|
||||
function buildAdminHeaders() {
|
||||
const headers = appendClientIdHeader();
|
||||
const token = getRegisteredAccessToken();
|
||||
|
||||
if (token && !headers.has('X-Access-Token')) {
|
||||
headers.set('X-Access-Token', token);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function reportVisitorPageView(page: AppPageDescriptor) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const clientId = getOrCreateClientId();
|
||||
|
||||
if (!clientId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await request('/history/track', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
clientId,
|
||||
url: buildTrackedPageUrl(page),
|
||||
eventType: 'page_view',
|
||||
userAgent: window.navigator.userAgent,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// 방문 이력 적재 실패는 사용자 흐름을 막지 않습니다.
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchVisitorClients(filters: VisitorClientSearchFilters = {}) {
|
||||
const query = new URLSearchParams();
|
||||
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
const normalizedValue = value?.trim();
|
||||
|
||||
if (normalizedValue) {
|
||||
query.set(key, normalizedValue);
|
||||
}
|
||||
});
|
||||
|
||||
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||
const response = await request<{ ok: boolean; items: VisitorClient[] }>(`/history/visitors${suffix}`, {
|
||||
headers: buildAdminHeaders(),
|
||||
});
|
||||
return response.items;
|
||||
}
|
||||
|
||||
export async function fetchVisitorDetail(clientId: string) {
|
||||
const response = await request<{ ok: boolean; client: VisitorClient; visits: VisitHistory[] }>(
|
||||
`/history/visitors/${encodeURIComponent(clientId)}`,
|
||||
{
|
||||
headers: buildAdminHeaders(),
|
||||
},
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function updateVisitorNickname(clientId: string, nickname: string) {
|
||||
const response = await request<{ ok: boolean; client: VisitorClient }>(
|
||||
`/history/visitors/${encodeURIComponent(clientId)}/nickname`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: buildAdminHeaders(),
|
||||
body: JSON.stringify({
|
||||
nickname,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
return response.client;
|
||||
}
|
||||
1
src/features/history/index.ts
Executable file
1
src/features/history/index.ts
Executable file
@@ -0,0 +1 @@
|
||||
export { HistoryPage } from './HistoryPage';
|
||||
22
src/features/history/types.ts
Executable file
22
src/features/history/types.ts
Executable file
@@ -0,0 +1,22 @@
|
||||
export type VisitorClient = {
|
||||
clientId: string;
|
||||
nickname: string;
|
||||
firstVisitedAt: string;
|
||||
lastVisitedAt: string;
|
||||
visitCount: number;
|
||||
lastVisitedUrl: string | null;
|
||||
lastUserAgent: string | null;
|
||||
lastIp: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type VisitHistory = {
|
||||
id: number;
|
||||
clientId: string;
|
||||
visitedAt: string;
|
||||
url: string;
|
||||
eventType: string;
|
||||
userAgent: string | null;
|
||||
ip: string | null;
|
||||
};
|
||||
14
src/features/layout/README.md
Executable file
14
src/features/layout/README.md
Executable file
@@ -0,0 +1,14 @@
|
||||
# Layout Feature
|
||||
|
||||
프로젝트 종속적인 레이아웃은 `src/features/layout` 아래에서 관리합니다.
|
||||
|
||||
## 포함 항목
|
||||
|
||||
- 컴포넌트 샘플 레이아웃
|
||||
- 위젯 샘플 레이아웃
|
||||
- Markdown preview 리스트 레이아웃
|
||||
|
||||
## 규칙
|
||||
|
||||
- 공통 레이아웃 시스템이 아니라 현재 프로젝트 화면에 종속되면 `features/layout`에 배치
|
||||
- 공통 재사용 가능성이 높으면 `components` 또는 `widgets`로 승격 검토
|
||||
178
src/features/layout/component-sample-gallery/ComponentSamplesLayout.tsx
Executable file
178
src/features/layout/component-sample-gallery/ComponentSamplesLayout.tsx
Executable file
@@ -0,0 +1,178 @@
|
||||
import { Anchor, Card, Empty, Flex, Space, Tag, Typography } from 'antd';
|
||||
import { createElement, useEffect, useMemo, useState } from 'react';
|
||||
import type { LoadedSampleEntry, SampleEntry } from '../../../samples/registry';
|
||||
import { resolveSampleEntries } from '../../../samples/registry';
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
|
||||
export type ComponentSamplesLayoutProps = {
|
||||
entries: SampleEntry[];
|
||||
pathFilter?: string;
|
||||
excludeComponentIds?: string[];
|
||||
includeComponentIds?: string[];
|
||||
};
|
||||
|
||||
export function ComponentSamplesLayout({
|
||||
entries,
|
||||
pathFilter = '/components/',
|
||||
excludeComponentIds = [],
|
||||
includeComponentIds = [],
|
||||
}: ComponentSamplesLayoutProps) {
|
||||
const [sampleEntries, setSampleEntries] = useState<LoadedSampleEntry[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
void resolveSampleEntries(entries, pathFilter).then((loadedEntries) => {
|
||||
if (mounted) {
|
||||
setSampleEntries(loadedEntries);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [entries, pathFilter]);
|
||||
const groupedComponents = useMemo(() => {
|
||||
const excludedComponentIdSet = new Set(excludeComponentIds);
|
||||
const includedComponentIdSet = includeComponentIds.length ? new Set(includeComponentIds) : null;
|
||||
const componentMap = new Map<
|
||||
string,
|
||||
{
|
||||
componentId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
baseSample?: (typeof sampleEntries)[number];
|
||||
pluginSamples: (typeof sampleEntries)[number][];
|
||||
featureSamples: (typeof sampleEntries)[number][];
|
||||
}
|
||||
>();
|
||||
|
||||
sampleEntries.forEach((entry) => {
|
||||
if (excludedComponentIdSet.has(entry.sampleMeta.componentId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (includedComponentIdSet && !includedComponentIdSet.has(entry.sampleMeta.componentId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingGroup = componentMap.get(entry.sampleMeta.componentId) ?? {
|
||||
componentId: entry.sampleMeta.componentId,
|
||||
title: entry.sampleMeta.title,
|
||||
description: entry.sampleMeta.description,
|
||||
category: entry.sampleMeta.category,
|
||||
pluginSamples: [],
|
||||
featureSamples: [],
|
||||
};
|
||||
|
||||
if (entry.sampleMeta.kind === 'base') {
|
||||
existingGroup.baseSample = entry;
|
||||
existingGroup.title = entry.sampleMeta.title;
|
||||
existingGroup.description = entry.sampleMeta.description;
|
||||
existingGroup.category = entry.sampleMeta.category;
|
||||
} else if (entry.sampleMeta.kind === 'feature') {
|
||||
existingGroup.featureSamples.push(entry);
|
||||
} else {
|
||||
existingGroup.pluginSamples.push(entry);
|
||||
}
|
||||
|
||||
componentMap.set(entry.sampleMeta.componentId, existingGroup);
|
||||
});
|
||||
|
||||
return Array.from(componentMap.values());
|
||||
}, [excludeComponentIds, includeComponentIds, sampleEntries]);
|
||||
|
||||
if (groupedComponents.length === 0) {
|
||||
return <Empty description="표시할 컴포넌트 샘플이 없습니다." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="component-samples-layout">
|
||||
<aside className="component-samples-layout__sidebar">
|
||||
<Card title="Components" className="component-samples-layout__nav-card">
|
||||
<Anchor
|
||||
affix={false}
|
||||
items={groupedComponents.map((componentGroup) => ({
|
||||
key: componentGroup.componentId,
|
||||
href: `#component-sample-${componentGroup.componentId}`,
|
||||
title: componentGroup.title,
|
||||
}))}
|
||||
/>
|
||||
</Card>
|
||||
</aside>
|
||||
|
||||
<div className="component-samples-layout__content">
|
||||
<Flex vertical gap={20} className="component-samples-layout__stack">
|
||||
{groupedComponents.map((componentGroup) => (
|
||||
<Card
|
||||
key={componentGroup.componentId}
|
||||
id={`component-sample-${componentGroup.componentId}`}
|
||||
data-focus-id={`component:${componentGroup.componentId}`}
|
||||
title={componentGroup.title}
|
||||
extra={<Tag color="blue">{componentGroup.category}</Tag>}
|
||||
className="component-samples-layout__card"
|
||||
>
|
||||
<Space direction="vertical" size={16} className="component-samples-layout__section">
|
||||
<div>
|
||||
<Paragraph>{componentGroup.description}</Paragraph>
|
||||
<Text type="secondary" code>
|
||||
{componentGroup.componentId}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{componentGroup.baseSample ? (
|
||||
<div className="component-samples-layout__sample-block">
|
||||
<Title level={5}>Base Sample</Title>
|
||||
<div>{createElement(componentGroup.baseSample.Sample)}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{componentGroup.pluginSamples.length > 0 ? (
|
||||
<div className="component-samples-layout__sample-block">
|
||||
<Title level={5}>Plugin Samples</Title>
|
||||
<Flex vertical gap={16}>
|
||||
{componentGroup.pluginSamples.map(({ modulePath, Sample, sampleMeta }) => (
|
||||
<Card
|
||||
key={modulePath}
|
||||
size="small"
|
||||
data-focus-id={`component:${componentGroup.componentId}:${sampleMeta.id}`}
|
||||
title={sampleMeta.title}
|
||||
extra={<Text code>{sampleMeta.variantLabel ?? sampleMeta.id}</Text>}
|
||||
>
|
||||
<Paragraph>{sampleMeta.description}</Paragraph>
|
||||
{createElement(Sample)}
|
||||
</Card>
|
||||
))}
|
||||
</Flex>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{componentGroup.featureSamples.length > 0 ? (
|
||||
<div className="component-samples-layout__sample-block">
|
||||
<Title level={5}>Feature Samples</Title>
|
||||
<Flex vertical gap={16}>
|
||||
{componentGroup.featureSamples.map(({ modulePath, Sample, sampleMeta }) => (
|
||||
<Card
|
||||
key={modulePath}
|
||||
size="small"
|
||||
data-focus-id={`component:${componentGroup.componentId}:${sampleMeta.id}`}
|
||||
title={sampleMeta.title}
|
||||
extra={<Text code>{sampleMeta.variantLabel ?? sampleMeta.id}</Text>}
|
||||
>
|
||||
<Paragraph>{sampleMeta.description}</Paragraph>
|
||||
{createElement(Sample)}
|
||||
</Card>
|
||||
))}
|
||||
</Flex>
|
||||
</div>
|
||||
) : null}
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</Flex>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
src/features/layout/component-sample-gallery/index.ts
Executable file
2
src/features/layout/component-sample-gallery/index.ts
Executable file
@@ -0,0 +1,2 @@
|
||||
export { ComponentSamplesLayout } from './ComponentSamplesLayout';
|
||||
export type { ComponentSamplesLayoutProps } from './ComponentSamplesLayout';
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Flex } from 'antd';
|
||||
import { TmsDashboardFeatureSamples } from '../../dashboard/TmsDashboardFeatureSamples';
|
||||
import { WmsDashboardFeatureSamples } from '../../dashboard/WmsDashboardFeatureSamples';
|
||||
|
||||
export function DashboardFeatureGalleryLayout() {
|
||||
return (
|
||||
<Flex vertical gap={20} className="feature-dashboard-gallery">
|
||||
<WmsDashboardFeatureSamples />
|
||||
<TmsDashboardFeatureSamples />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
1
src/features/layout/dashboard-feature-gallery/index.ts
Executable file
1
src/features/layout/dashboard-feature-gallery/index.ts
Executable file
@@ -0,0 +1 @@
|
||||
export { DashboardFeatureGalleryLayout } from './DashboardFeatureGalleryLayout';
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Empty, Flex } from 'antd';
|
||||
import { createElement, useEffect, useState } from 'react';
|
||||
import { widgetSampleEntries } from '../../../app/manifests/samples.manifest';
|
||||
import type { LoadedSampleEntry } from '../../../samples/registry';
|
||||
import { resolveSampleEntries } from '../../../samples/registry';
|
||||
|
||||
export function DashboardReportGalleryLayout() {
|
||||
const [dashboardSamples, setDashboardSamples] = useState<LoadedSampleEntry[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
void resolveSampleEntries(widgetSampleEntries, '/widgets/').then((loadedEntries) => {
|
||||
if (mounted) {
|
||||
setDashboardSamples(
|
||||
loadedEntries.filter((entry) => entry.sampleMeta.componentId === 'dashboard-report-card'),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (dashboardSamples.length === 0) {
|
||||
return <Empty description="표시할 대시보드 카드 샘플이 없습니다." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex gap={20} wrap className="dashboard-widget-grid">
|
||||
{dashboardSamples.map(({ modulePath, Sample }) => (
|
||||
<div key={modulePath} className="dashboard-widget-grid__item">
|
||||
{createElement(Sample)}
|
||||
</div>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
1
src/features/layout/dashboard-report-gallery/index.ts
Executable file
1
src/features/layout/dashboard-report-gallery/index.ts
Executable file
@@ -0,0 +1 @@
|
||||
export { DashboardReportGalleryLayout } from './DashboardReportGalleryLayout';
|
||||
87
src/features/layout/docs-markdown-preview/DocsMarkdownPreviewLayout.tsx
Executable file
87
src/features/layout/docs-markdown-preview/DocsMarkdownPreviewLayout.tsx
Executable file
@@ -0,0 +1,87 @@
|
||||
import { Card, Empty, Flex, Typography } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { docsMarkdownEntries } from '../../../app/manifests/docs.manifest';
|
||||
import { FolderTreeNav } from '../../../components/navigation';
|
||||
import {
|
||||
MarkdownPreviewCard,
|
||||
resolveMarkdownDocuments,
|
||||
type MarkdownDocument,
|
||||
} from '../../../components/markdownPreview';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export function DocsMarkdownPreviewLayout() {
|
||||
const [documents, setDocuments] = useState<MarkdownDocument[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
void resolveMarkdownDocuments(docsMarkdownEntries, '/docs/').then((loadedDocuments) => {
|
||||
if (mounted) {
|
||||
setDocuments(loadedDocuments);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
const grouped = useMemo(() => {
|
||||
const folderMap = new Map<string, typeof documents>();
|
||||
|
||||
documents.forEach((document) => {
|
||||
const bucket = folderMap.get(document.folder) ?? [];
|
||||
bucket.push(document);
|
||||
folderMap.set(document.folder, bucket);
|
||||
});
|
||||
|
||||
return Array.from(folderMap.entries()).sort((left, right) => left[0].localeCompare(right[0]));
|
||||
}, [documents]);
|
||||
|
||||
if (documents.length === 0) {
|
||||
return <Empty description="표시할 docs markdown 문서가 없습니다." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="docs-markdown-layout">
|
||||
<aside className="docs-markdown-layout__sidebar">
|
||||
<Card title="Docs Folders" className="component-samples-layout__nav-card">
|
||||
<FolderTreeNav
|
||||
title="Docs Tree"
|
||||
groups={grouped.map(([folder, folderDocuments]) => ({
|
||||
id: `docs-folder-${folder}`,
|
||||
label: folder,
|
||||
items: folderDocuments.map((document) => ({
|
||||
id: document.id,
|
||||
label: document.title,
|
||||
href: `#doc-item-${document.id}`,
|
||||
})),
|
||||
}))}
|
||||
/>
|
||||
</Card>
|
||||
</aside>
|
||||
|
||||
<div className="docs-markdown-layout__content">
|
||||
<Flex vertical gap={20}>
|
||||
{grouped.map(([folder, folderDocuments]) => (
|
||||
<section key={folder} id={`docs-folder-${folder}`}>
|
||||
<Card title={folder} extra={<Text code>{folderDocuments.length} docs</Text>}>
|
||||
<Flex vertical gap={16}>
|
||||
{folderDocuments.map((document) => (
|
||||
<div
|
||||
key={document.id}
|
||||
id={`doc-item-${document.id}`}
|
||||
className="feature-markdown-list__item"
|
||||
>
|
||||
<MarkdownPreviewCard document={document} />
|
||||
</div>
|
||||
))}
|
||||
</Flex>
|
||||
</Card>
|
||||
</section>
|
||||
))}
|
||||
</Flex>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
src/features/layout/docs-markdown-preview/index.ts
Executable file
1
src/features/layout/docs-markdown-preview/index.ts
Executable file
@@ -0,0 +1 @@
|
||||
export { DocsMarkdownPreviewLayout } from './DocsMarkdownPreviewLayout';
|
||||
@@ -0,0 +1,12 @@
|
||||
import { featureMarkdownEntries } from '../../../app/manifests/docs.manifest';
|
||||
import { MarkdownPreviewList } from '../../../components/markdownPreview';
|
||||
|
||||
export function FeatureMarkdownPreviewListLayout() {
|
||||
return (
|
||||
<MarkdownPreviewList
|
||||
entries={featureMarkdownEntries}
|
||||
basePath="/features/"
|
||||
emptyDescription="표시할 feature markdown 문서가 없습니다."
|
||||
/>
|
||||
);
|
||||
}
|
||||
1
src/features/layout/feature-markdown-preview/index.ts
Executable file
1
src/features/layout/feature-markdown-preview/index.ts
Executable file
@@ -0,0 +1 @@
|
||||
export { FeatureMarkdownPreviewListLayout } from './FeatureMarkdownPreviewListLayout';
|
||||
33
src/features/layout/widget-registry-gallery/WidgetRegistryLayout.tsx
Executable file
33
src/features/layout/widget-registry-gallery/WidgetRegistryLayout.tsx
Executable file
@@ -0,0 +1,33 @@
|
||||
import { Card, Empty, Flex, List, Tag, Typography } from 'antd';
|
||||
import { registeredWidgets } from '../../../widgets/registry';
|
||||
import { resolveWidgetFeatures } from '../../../widgets/core';
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
|
||||
export function WidgetRegistryLayout() {
|
||||
if (registeredWidgets.length === 0) {
|
||||
return <Empty description="등록된 위젯이 없습니다." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex gap={20} wrap className="sample-widgets-layout">
|
||||
{registeredWidgets.map((widget) => (
|
||||
<div key={widget.id} className="sample-widgets-layout__item">
|
||||
<Card title={widget.title} extra={<Text code>{widget.id}</Text>}>
|
||||
<Paragraph>{widget.description}</Paragraph>
|
||||
<List
|
||||
size="small"
|
||||
dataSource={resolveWidgetFeatures(widget.features)}
|
||||
renderItem={(feature) => (
|
||||
<List.Item>
|
||||
<Tag color="blue">{feature.label}</Tag>
|
||||
<span>{feature.description}</span>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
1
src/features/layout/widget-registry-gallery/index.ts
Executable file
1
src/features/layout/widget-registry-gallery/index.ts
Executable file
@@ -0,0 +1 @@
|
||||
export { WidgetRegistryLayout } from './WidgetRegistryLayout';
|
||||
56
src/features/layout/widget-sample-gallery/SampleWidgetsLayout.tsx
Executable file
56
src/features/layout/widget-sample-gallery/SampleWidgetsLayout.tsx
Executable file
@@ -0,0 +1,56 @@
|
||||
import { Empty, Flex } from 'antd';
|
||||
import { createElement, useEffect, useState } from 'react';
|
||||
import type { LoadedSampleEntry, SampleEntry } from '../../../samples/registry';
|
||||
import { resolveSampleEntries } from '../../../samples/registry';
|
||||
|
||||
export type SampleWidgetsLayoutProps = {
|
||||
entries: SampleEntry[];
|
||||
pathFilter?: string;
|
||||
includeComponentIds?: string[];
|
||||
};
|
||||
|
||||
export function SampleWidgetsLayout({
|
||||
entries,
|
||||
pathFilter = '/widgets/',
|
||||
includeComponentIds = [],
|
||||
}: SampleWidgetsLayoutProps) {
|
||||
const [sampleEntries, setSampleEntries] = useState<LoadedSampleEntry[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
void resolveSampleEntries(entries, pathFilter).then((loadedEntries) => {
|
||||
if (mounted) {
|
||||
setSampleEntries(loadedEntries);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [entries, pathFilter]);
|
||||
|
||||
const visibleEntries =
|
||||
includeComponentIds.length > 0
|
||||
? sampleEntries.filter((entry) => includeComponentIds.includes(entry.sampleMeta.componentId))
|
||||
: sampleEntries;
|
||||
|
||||
if (visibleEntries.length === 0) {
|
||||
return <Empty description="표시할 위젯 샘플이 없습니다." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex gap={20} wrap className="sample-widgets-layout">
|
||||
{visibleEntries.map(({ modulePath, Sample, sampleMeta }) => (
|
||||
<div
|
||||
key={modulePath}
|
||||
id={`widget-sample-${sampleMeta.componentId}`}
|
||||
className="sample-widgets-layout__item"
|
||||
data-focus-id={`widget:${sampleMeta.componentId}`}
|
||||
>
|
||||
<div className="sample-widgets-layout__item">{createElement(Sample)}</div>
|
||||
</div>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
2
src/features/layout/widget-sample-gallery/index.ts
Executable file
2
src/features/layout/widget-sample-gallery/index.ts
Executable file
@@ -0,0 +1,2 @@
|
||||
export { SampleWidgetsLayout } from './SampleWidgetsLayout';
|
||||
export type { SampleWidgetsLayoutProps } from './SampleWidgetsLayout';
|
||||
3
src/features/markdownPreview/FeatureMarkdownPreviewCard.tsx
Executable file
3
src/features/markdownPreview/FeatureMarkdownPreviewCard.tsx
Executable file
@@ -0,0 +1,3 @@
|
||||
import { MarkdownPreviewCard } from '../../components/markdownPreview';
|
||||
|
||||
export const FeatureMarkdownPreviewCard = MarkdownPreviewCard;
|
||||
1
src/features/markdownPreview/index.ts
Executable file
1
src/features/markdownPreview/index.ts
Executable file
@@ -0,0 +1 @@
|
||||
export { FeatureMarkdownPreviewCard } from './FeatureMarkdownPreviewCard';
|
||||
9
src/features/overview.md
Executable file
9
src/features/overview.md
Executable file
@@ -0,0 +1,9 @@
|
||||
# Features Overview
|
||||
|
||||
이 영역은 현재 프로젝트에 종속된 기능과 화면 구성을 관리합니다.
|
||||
|
||||
## 목적
|
||||
|
||||
- 공통 `components`, `widgets`와 분리된 프로젝트 전용 기능 관리
|
||||
- 기능별 문서, 화면 조합, 레이아웃을 한 곳에서 정리
|
||||
- 향후 `dashboard`, `sampleBoard`, `docsViewer` 같은 프로젝트 전용 기능 확장
|
||||
3834
src/features/planBoard/PlanBoardPage.tsx
Executable file
3834
src/features/planBoard/PlanBoardPage.tsx
Executable file
File diff suppressed because it is too large
Load Diff
191
src/features/planBoard/PlanListDetailLayout.tsx
Executable file
191
src/features/planBoard/PlanListDetailLayout.tsx
Executable file
@@ -0,0 +1,191 @@
|
||||
import { ArrowLeftOutlined, CloseOutlined } from '@ant-design/icons';
|
||||
import { Button, Card, Empty, Flex, Space, Typography } from 'antd';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
type PlanListDetailLayoutProps = {
|
||||
listTitle: ReactNode;
|
||||
listExtra?: ReactNode;
|
||||
listContent: ReactNode;
|
||||
classNamePrefix?: string;
|
||||
desktopLayout?: 'split' | 'stacked';
|
||||
mobileLayoutMode?: 'overlay' | 'detail-only';
|
||||
desktopDetailOpen: boolean;
|
||||
mobileDetailOpen: boolean;
|
||||
detailTitle: ReactNode;
|
||||
detailActions?: ReactNode;
|
||||
detailContent: ReactNode;
|
||||
onCloseDetail: () => void;
|
||||
closeLabel?: string;
|
||||
showDesktopClose?: boolean;
|
||||
emptyDetailTitle?: ReactNode;
|
||||
emptyDetailContent?: ReactNode;
|
||||
};
|
||||
|
||||
function useBodyScrollLock(locked: boolean) {
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined' || !locked) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollY = window.scrollY;
|
||||
const previousBodyOverflow = document.body.style.overflow;
|
||||
const previousBodyTouchAction = document.body.style.touchAction;
|
||||
const previousBodyPosition = document.body.style.position;
|
||||
const previousBodyTop = document.body.style.top;
|
||||
const previousBodyWidth = document.body.style.width;
|
||||
const previousHtmlOverflow = document.documentElement.style.overflow;
|
||||
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.touchAction = 'manipulation';
|
||||
document.body.style.position = 'fixed';
|
||||
document.body.style.top = `-${scrollY}px`;
|
||||
document.body.style.width = '100%';
|
||||
document.documentElement.style.overflow = 'hidden';
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousBodyOverflow;
|
||||
document.body.style.touchAction = previousBodyTouchAction;
|
||||
document.body.style.position = previousBodyPosition;
|
||||
document.body.style.top = previousBodyTop;
|
||||
document.body.style.width = previousBodyWidth;
|
||||
document.documentElement.style.overflow = previousHtmlOverflow;
|
||||
window.scrollTo(0, scrollY);
|
||||
};
|
||||
}, [locked]);
|
||||
}
|
||||
|
||||
function useMobileOverlayEnabled() {
|
||||
const [mobileOverlayEnabled, setMobileOverlayEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia('(max-width: 960px)');
|
||||
const update = () => {
|
||||
setMobileOverlayEnabled(mediaQuery.matches);
|
||||
};
|
||||
|
||||
update();
|
||||
mediaQuery.addEventListener('change', update);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return mobileOverlayEnabled;
|
||||
}
|
||||
|
||||
export function PlanListDetailLayout({
|
||||
listTitle,
|
||||
listExtra,
|
||||
listContent,
|
||||
classNamePrefix = 'plan-board-page',
|
||||
desktopLayout = 'split',
|
||||
mobileLayoutMode = 'overlay',
|
||||
desktopDetailOpen,
|
||||
mobileDetailOpen,
|
||||
detailTitle,
|
||||
detailActions,
|
||||
detailContent,
|
||||
onCloseDetail,
|
||||
closeLabel = '뒤로가기',
|
||||
showDesktopClose = false,
|
||||
emptyDetailTitle = '상세 보기',
|
||||
emptyDetailContent,
|
||||
}: PlanListDetailLayoutProps) {
|
||||
const mobileOverlayEnabled = useMobileOverlayEnabled();
|
||||
const showMobileDetail = mobileOverlayEnabled && mobileDetailOpen;
|
||||
const showMobileOverlay = showMobileDetail && mobileLayoutMode === 'overlay';
|
||||
const showMobileDetailOnly = showMobileDetail && mobileLayoutMode === 'detail-only';
|
||||
|
||||
useBodyScrollLock(showMobileOverlay || showMobileDetailOnly);
|
||||
|
||||
const resolvedEmptyDetailContent = emptyDetailContent ?? <Empty description="목록에서 항목을 선택하세요." />;
|
||||
const detailCardOpen = desktopDetailOpen;
|
||||
const detailCardTitle = detailCardOpen ? detailTitle : emptyDetailTitle;
|
||||
const detailCardContent = detailCardOpen ? detailContent : resolvedEmptyDetailContent;
|
||||
const detailCardActions = detailCardOpen ? detailActions : null;
|
||||
const splitClassName = `${classNamePrefix}__split${
|
||||
desktopLayout === 'stacked' ? ` ${classNamePrefix}__split--stacked` : ''
|
||||
}${showMobileDetailOnly ? ` ${classNamePrefix}__split--mobile-detail-only` : ''}`;
|
||||
const listCardClassName = `${classNamePrefix}__list-card${
|
||||
showMobileDetailOnly ? ` ${classNamePrefix}__list-card--mobile-hidden` : ''
|
||||
}`;
|
||||
const detailCardClassName = `${classNamePrefix}__editor-card ${classNamePrefix}__detail-card${
|
||||
showMobileOverlay ? ` ${classNamePrefix}__detail-card--mobile-hidden` : ''
|
||||
}${showMobileDetailOnly ? ` ${classNamePrefix}__detail-card--mobile-only` : ''}`;
|
||||
const detailActionsClassName = `${classNamePrefix}__detail-actions`;
|
||||
const detailEmptyClassName = `${classNamePrefix}__detail-empty`;
|
||||
const overlayClassName = `${classNamePrefix}__overlay`;
|
||||
const overlayCardClassName = `${classNamePrefix}__overlay-card`;
|
||||
const overlayHeaderClassName = `${classNamePrefix}__overlay-header`;
|
||||
const overlayTitleClassName = `${classNamePrefix}__overlay-title`;
|
||||
const overlayBodyClassName = `${classNamePrefix}__overlay-body`;
|
||||
const mobileOverlayNode =
|
||||
showMobileOverlay && typeof document !== 'undefined'
|
||||
? createPortal(
|
||||
<div className={overlayClassName}>
|
||||
<Card className={overlayCardClassName} bordered={false}>
|
||||
<Flex justify="space-between" align="center" gap={12} className={overlayHeaderClassName}>
|
||||
<Space>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={onCloseDetail}>
|
||||
{closeLabel}
|
||||
</Button>
|
||||
<Title level={5} className={overlayTitleClassName}>
|
||||
{detailTitle}
|
||||
</Title>
|
||||
</Space>
|
||||
|
||||
{detailActions ? <Space wrap>{detailActions}</Space> : null}
|
||||
</Flex>
|
||||
|
||||
<div className={overlayBodyClassName}>{detailContent}</div>
|
||||
</Card>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={splitClassName}>
|
||||
<Card title={listTitle} className={listCardClassName} extra={listExtra} bordered={false}>
|
||||
{listContent}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={detailCardTitle}
|
||||
className={detailCardClassName}
|
||||
extra={
|
||||
detailCardOpen && (detailCardActions || showDesktopClose || showMobileDetailOnly) ? (
|
||||
<Space wrap className={detailActionsClassName}>
|
||||
{detailCardActions}
|
||||
{showMobileDetailOnly ? (
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={onCloseDetail}>
|
||||
{closeLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
{showDesktopClose && !showMobileDetailOnly ? (
|
||||
<Button icon={<CloseOutlined />} onClick={onCloseDetail}>
|
||||
닫기
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
bordered={false}
|
||||
>
|
||||
<div className={!detailCardOpen ? detailEmptyClassName : undefined}>{detailCardContent}</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{mobileOverlayNode}
|
||||
</>
|
||||
);
|
||||
}
|
||||
871
src/features/planBoard/PlanSchedulePage.tsx
Executable file
871
src/features/planBoard/PlanSchedulePage.tsx
Executable file
@@ -0,0 +1,871 @@
|
||||
import { CopyOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Empty,
|
||||
Flex,
|
||||
Input,
|
||||
InputNumber,
|
||||
List,
|
||||
Segmented,
|
||||
Select,
|
||||
Space,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { memo, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
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,
|
||||
fetchPlanScheduledTasks,
|
||||
setupPlanBoard,
|
||||
updatePlanScheduledTask,
|
||||
type PlanScheduleMode,
|
||||
type PlanScheduleRepeatUnit,
|
||||
type PlanScheduledTask,
|
||||
type PlanScheduledTaskDraft,
|
||||
} from './api';
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
const { TextArea } = Input;
|
||||
const FUNCTION_CHECK_OPTIONS = ['완료', '오동작'];
|
||||
const SCHEDULE_MODE_TAB_ITEMS: { key: PlanScheduleMode; label: string }[] = [
|
||||
{ key: 'interval', label: '반복 주기' },
|
||||
{ key: 'daily', label: '매일 시간' },
|
||||
];
|
||||
const REPEAT_UNIT_OPTIONS: { label: string; value: PlanScheduleRepeatUnit }[] = [
|
||||
{ label: '분', value: 'minute' },
|
||||
{ label: '시간', value: 'hour' },
|
||||
{ label: '일', value: 'day' },
|
||||
{ label: '주', value: 'week' },
|
||||
{ label: '월', value: 'month' },
|
||||
];
|
||||
const REPEAT_UNIT_LABELS: Record<PlanScheduleRepeatUnit, string> = {
|
||||
minute: '분',
|
||||
hour: '시간',
|
||||
day: '일',
|
||||
week: '주',
|
||||
month: '개월',
|
||||
};
|
||||
const REPEAT_PRESET_OPTIONS: { label: string; value: number; unit: PlanScheduleRepeatUnit }[] = [
|
||||
{ label: '10분', value: 10, unit: 'minute' },
|
||||
{ label: '30분', value: 30, unit: 'minute' },
|
||||
{ label: '1시간', value: 1, unit: 'hour' },
|
||||
{ label: '6시간', value: 6, unit: 'hour' },
|
||||
{ label: '매일 1회', value: 1, unit: 'day' },
|
||||
{ label: '매주 1회', value: 1, unit: 'week' },
|
||||
{ label: '매월 1회', value: 1, unit: 'month' },
|
||||
];
|
||||
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, hour) => ({
|
||||
label: `${String(hour).padStart(2, '0')}시`,
|
||||
value: String(hour).padStart(2, '0'),
|
||||
}));
|
||||
const MINUTE_OPTIONS = Array.from({ length: 60 }, (_, minute) => ({
|
||||
label: `${String(minute).padStart(2, '0')}분`,
|
||||
value: String(minute).padStart(2, '0'),
|
||||
}));
|
||||
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));
|
||||
|
||||
if (unit === 'day') {
|
||||
return normalizedValue * 24 * 60;
|
||||
}
|
||||
|
||||
if (unit === 'week') {
|
||||
return normalizedValue * 7 * 24 * 60;
|
||||
}
|
||||
|
||||
if (unit === 'month') {
|
||||
return normalizedValue * 30 * 24 * 60;
|
||||
}
|
||||
|
||||
if (unit === 'hour') {
|
||||
return normalizedValue * 60;
|
||||
}
|
||||
|
||||
return normalizedValue;
|
||||
}
|
||||
|
||||
function getRepeatIntervalValueMax(unit: PlanScheduleRepeatUnit) {
|
||||
if (unit === 'month') {
|
||||
return 12;
|
||||
}
|
||||
|
||||
if (unit === 'week') {
|
||||
return 52;
|
||||
}
|
||||
|
||||
if (unit === 'day') {
|
||||
return 365;
|
||||
}
|
||||
|
||||
if (unit === 'hour') {
|
||||
return 8760;
|
||||
}
|
||||
|
||||
return 525600;
|
||||
}
|
||||
|
||||
function normalizeRepeatIntervalValue(value: number, unit: PlanScheduleRepeatUnit) {
|
||||
const roundedValue = Math.max(1, Math.round(Number(value) || 1));
|
||||
return Math.min(getRepeatIntervalValueMax(unit), roundedValue);
|
||||
}
|
||||
|
||||
function formatRepeatInterval(value: number, unit: PlanScheduleRepeatUnit, fallbackMinutes: number) {
|
||||
if (!value || !unit) {
|
||||
return `${fallbackMinutes}분마다`;
|
||||
}
|
||||
|
||||
return `${value}${REPEAT_UNIT_LABELS[unit]}마다`;
|
||||
}
|
||||
|
||||
function normalizeScheduleMode(value: PlanScheduleMode | null | undefined): PlanScheduleMode {
|
||||
return value === 'daily' ? 'daily' : 'interval';
|
||||
}
|
||||
|
||||
function normalizeDailyRunTime(value: string | null | undefined) {
|
||||
return typeof value === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(value) ? value : DEFAULT_DAILY_RUN_TIME;
|
||||
}
|
||||
|
||||
function updateDailyRunTime(value: string, part: 'hour' | 'minute', nextPartValue: string) {
|
||||
const [hour, minute] = normalizeDailyRunTime(value).split(':');
|
||||
return part === 'hour' ? `${nextPartValue}:${minute}` : `${hour}:${nextPartValue}`;
|
||||
}
|
||||
|
||||
function formatScheduleCycle(item: PlanScheduledTask) {
|
||||
const scheduleMode = normalizeScheduleMode(item.scheduleMode);
|
||||
|
||||
if (scheduleMode === 'daily') {
|
||||
return `매일 ${normalizeDailyRunTime(item.dailyRunTime)} 실행`;
|
||||
}
|
||||
|
||||
return formatRepeatInterval(item.repeatIntervalValue, item.repeatIntervalUnit, item.repeatIntervalMinutes);
|
||||
}
|
||||
|
||||
function getValidDate(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function getKstDateTimeParts(value: Date) {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: KST_TIME_ZONE,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(value);
|
||||
const partMap = Object.fromEntries(parts.map((part) => [part.type, part.value]));
|
||||
|
||||
return {
|
||||
year: Number(partMap.year),
|
||||
month: Number(partMap.month),
|
||||
day: Number(partMap.day),
|
||||
hour: Number(partMap.hour),
|
||||
minute: Number(partMap.minute),
|
||||
};
|
||||
}
|
||||
|
||||
function getKstDateKey(value: Date) {
|
||||
const parts = getKstDateTimeParts(value);
|
||||
return `${parts.year}-${String(parts.month).padStart(2, '0')}-${String(parts.day).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function createDateFromKstParts(year: number, month: number, day: number, hour: number, minute: number) {
|
||||
return new Date(Date.UTC(year, month - 1, day, hour - 9, minute, 0, 0));
|
||||
}
|
||||
|
||||
function resolveNextPlanScheduleRunAt(item: PlanScheduledTask, now = new Date()) {
|
||||
if (!item.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastRegisteredAt = getValidDate(item.lastRegisteredAt);
|
||||
|
||||
if (!lastRegisteredAt && item.immediateRunEnabled) {
|
||||
return now;
|
||||
}
|
||||
|
||||
if (normalizeScheduleMode(item.scheduleMode) === 'daily') {
|
||||
const [hour, minute] = normalizeDailyRunTime(item.dailyRunTime).split(':').map((value) => Number(value));
|
||||
const nowParts = getKstDateTimeParts(now);
|
||||
const scheduledToday = createDateFromKstParts(nowParts.year, nowParts.month, nowParts.day, hour, minute);
|
||||
const lastRegisteredToday = lastRegisteredAt ? getKstDateKey(lastRegisteredAt) === getKstDateKey(now) : false;
|
||||
|
||||
if (!lastRegisteredToday && scheduledToday.getTime() <= now.getTime()) {
|
||||
return now;
|
||||
}
|
||||
|
||||
if (!lastRegisteredToday) {
|
||||
return scheduledToday;
|
||||
}
|
||||
|
||||
return new Date(scheduledToday.getTime() + DAY_MS);
|
||||
}
|
||||
|
||||
const baseAt = lastRegisteredAt ?? getValidDate(item.createdAt) ?? now;
|
||||
const nextRunAt = new Date(baseAt.getTime() + item.repeatIntervalMinutes * 60 * 1000);
|
||||
|
||||
return nextRunAt.getTime() <= now.getTime() ? now : nextRunAt;
|
||||
}
|
||||
|
||||
function createEmptyScheduleDraft(defaultReleaseTarget = 'release'): PlanScheduledTaskDraft {
|
||||
return {
|
||||
id: null,
|
||||
workId: '',
|
||||
note: '',
|
||||
automationType: 'none',
|
||||
releaseTarget: defaultReleaseTarget,
|
||||
jangsingProcessingRequired: true,
|
||||
autoDeployToMain: true,
|
||||
enabled: true,
|
||||
immediateRunEnabled: true,
|
||||
scheduleMode: 'interval',
|
||||
repeatIntervalValue: 60,
|
||||
repeatIntervalUnit: 'minute',
|
||||
repeatIntervalMinutes: 60,
|
||||
dailyRunTime: DEFAULT_DAILY_RUN_TIME,
|
||||
};
|
||||
}
|
||||
|
||||
function toDraft(item: PlanScheduledTask): PlanScheduledTaskDraft {
|
||||
const repeatIntervalUnit = item.repeatIntervalUnit ?? 'minute';
|
||||
const repeatIntervalValue = normalizeRepeatIntervalValue(
|
||||
item.repeatIntervalValue ?? item.repeatIntervalMinutes,
|
||||
repeatIntervalUnit,
|
||||
);
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
workId: item.workId,
|
||||
note: item.note,
|
||||
automationType: item.automationType,
|
||||
releaseTarget: item.releaseTarget,
|
||||
jangsingProcessingRequired: item.jangsingProcessingRequired,
|
||||
autoDeployToMain: item.autoDeployToMain,
|
||||
enabled: item.enabled,
|
||||
immediateRunEnabled: item.immediateRunEnabled,
|
||||
scheduleMode: normalizeScheduleMode(item.scheduleMode),
|
||||
repeatIntervalValue,
|
||||
repeatIntervalUnit,
|
||||
repeatIntervalMinutes: item.repeatIntervalMinutes ?? getRepeatIntervalMinutes(repeatIntervalValue, repeatIntervalUnit),
|
||||
dailyRunTime: normalizeDailyRunTime(item.dailyRunTime),
|
||||
};
|
||||
}
|
||||
|
||||
function formatPlanScheduleDateTime(value: string | Date | null | undefined) {
|
||||
if (!value) {
|
||||
return '미등록';
|
||||
}
|
||||
|
||||
return new Date(value).toLocaleString('ko-KR', {
|
||||
timeZone: KST_TIME_ZONE,
|
||||
});
|
||||
}
|
||||
|
||||
function formatNextPlanScheduleRunAt(item: PlanScheduledTask) {
|
||||
if (!item.enabled) {
|
||||
return '중지';
|
||||
}
|
||||
|
||||
return formatPlanScheduleDateTime(resolveNextPlanScheduleRunAt(item));
|
||||
}
|
||||
|
||||
function validateScheduleDraft(draft: PlanScheduledTaskDraft, items: PlanScheduledTask[]) {
|
||||
const messages: string[] = [];
|
||||
const workId = draft.workId.trim();
|
||||
const note = draft.note.trim();
|
||||
|
||||
if (!workId) {
|
||||
messages.push('작업 ID를 입력하세요.');
|
||||
} else if (items.some((item) => item.id !== draft.id && item.workId.trim() === workId)) {
|
||||
messages.push('같은 작업 ID의 스케줄이 이미 있습니다.');
|
||||
}
|
||||
|
||||
if (!note) {
|
||||
messages.push('반복 등록할 작업 메모를 입력하세요.');
|
||||
}
|
||||
|
||||
if (draft.scheduleMode === 'interval' && getRepeatIntervalMinutes(draft.repeatIntervalValue, draft.repeatIntervalUnit) < 10) {
|
||||
messages.push('반복 주기는 최소 10분 이상으로 설정하세요.');
|
||||
}
|
||||
|
||||
if (!draft.enabled) {
|
||||
messages.push('비활성 스케줄은 자동 등록되지 않습니다.');
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
export function PlanSchedulePage() {
|
||||
const { hasAccess } = useTokenAccess();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [items, setItems] = useState<PlanScheduledTask[]>([]);
|
||||
const [draft, setDraft] = useState(() => createEmptyScheduleDraft());
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const selectedItem = useMemo(
|
||||
() => items.find((item) => item.id === draft.id) ?? null,
|
||||
[draft.id, items],
|
||||
);
|
||||
const validationMessages = useMemo(() => validateScheduleDraft(draft, items), [draft, items]);
|
||||
|
||||
async function loadItems() {
|
||||
setLoading(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
setItems(await fetchPlanScheduledTasks());
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '스케줄 목록을 불러오지 못했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadItems();
|
||||
}, [hasAccess]);
|
||||
|
||||
async function handleSetup() {
|
||||
if (!hasAccess) {
|
||||
messageApi.error('설정 > 토큰 관리에서 권한 토큰을 등록한 사용자만 사용할 수 있습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await setupPlanBoard();
|
||||
messageApi.success('작업 스케줄 테이블을 준비했습니다.');
|
||||
await loadItems();
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : '테이블 생성에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!hasAccess) {
|
||||
messageApi.error('설정 > 토큰 관리에서 권한 토큰을 등록한 사용자만 사용할 수 있습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const blockingMessages = validationMessages.filter((message) => message !== '비활성 스케줄은 자동 등록되지 않습니다.');
|
||||
|
||||
if (blockingMessages.length) {
|
||||
messageApi.warning(blockingMessages[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const repeatIntervalValue = normalizeRepeatIntervalValue(draft.repeatIntervalValue, draft.repeatIntervalUnit);
|
||||
const draftToSave = {
|
||||
...draft,
|
||||
repeatIntervalValue,
|
||||
repeatIntervalMinutes: getRepeatIntervalMinutes(repeatIntervalValue, draft.repeatIntervalUnit),
|
||||
};
|
||||
const savedItem = draft.id ? await updatePlanScheduledTask(draftToSave) : await createPlanScheduledTask(draftToSave);
|
||||
messageApi.success(draft.id ? '스케줄을 수정했습니다.' : '스케줄을 등록했습니다.');
|
||||
setDraft(toDraft(savedItem));
|
||||
await loadItems();
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : '스케줄 저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!draft.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasAccess) {
|
||||
messageApi.error('설정 > 토큰 관리에서 권한 토큰을 등록한 사용자만 사용할 수 있습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm('선택한 스케줄을 삭제할까요?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
await deletePlanScheduledTask(draft.id);
|
||||
messageApi.success('스케줄을 삭제했습니다.');
|
||||
setDraft(createEmptyScheduleDraft(draft.releaseTarget));
|
||||
setEditorOpen(false);
|
||||
await loadItems();
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : '스케줄 삭제에 실패했습니다.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopyText(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
messageApi.success('복사했습니다.');
|
||||
} catch {
|
||||
messageApi.error('복사에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreateNew() {
|
||||
setDraft(createEmptyScheduleDraft(draft.releaseTarget));
|
||||
setEditorOpen(true);
|
||||
}
|
||||
|
||||
function handleSelectItem(item: PlanScheduledTask) {
|
||||
setDraft(toDraft(item));
|
||||
setEditorOpen(true);
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
setEditorOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="plan-schedule-page">
|
||||
{contextHolder}
|
||||
<Card className="plan-schedule-page__overview" bordered={false}>
|
||||
<Flex justify="space-between" align="center" gap={12} wrap>
|
||||
<div>
|
||||
<Title level={4}>자동화 Schedule</Title>
|
||||
<Paragraph className="plan-schedule-page__intro">
|
||||
반복 작업 내용을 스케줄로 등록하고 활성 상태와 반복 주기를 관리합니다.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Button onClick={() => void loadItems()} loading={loading}>
|
||||
조회
|
||||
</Button>
|
||||
<Button onClick={handleCreateNew} disabled={!hasAccess}>
|
||||
새 스케줄
|
||||
</Button>
|
||||
<Button onClick={() => void handleSetup()} disabled={!hasAccess}>
|
||||
테이블 생성
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
</Card>
|
||||
|
||||
{!hasAccess ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
className="plan-schedule-page__alert"
|
||||
message="권한 토큰이 없어 작업 스케줄은 조회만 사용할 수 있습니다."
|
||||
description="설정 > 토큰 관리에서 권한 토큰을 등록하기 전에는 조회 외 버튼과 입력을 사용할 수 없고, 작업 메모는 단어별로 마스킹됩니다."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{errorMessage ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type="error"
|
||||
className="plan-schedule-page__alert"
|
||||
message="스케줄을 사용할 수 없습니다."
|
||||
description={errorMessage}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<PlanListDetailLayout
|
||||
classNamePrefix="plan-schedule-page"
|
||||
listTitle="스케줄 목록"
|
||||
listExtra={<Text code>{items.length} items</Text>}
|
||||
listContent={
|
||||
<PlanScheduleList
|
||||
activeDraftId={draft.id}
|
||||
editorOpen={editorOpen}
|
||||
hasAccess={hasAccess}
|
||||
items={items}
|
||||
loading={loading}
|
||||
onSelectItem={handleSelectItem}
|
||||
/>
|
||||
}
|
||||
desktopDetailOpen={editorOpen}
|
||||
mobileDetailOpen={editorOpen}
|
||||
detailTitle={draft.id ? `스케줄 #${draft.id}` : '새 스케줄'}
|
||||
detailActions={
|
||||
<>
|
||||
{draft.id ? (
|
||||
<Button danger onClick={() => void handleDelete()} loading={saving} disabled={!hasAccess}>
|
||||
삭제
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="primary" onClick={() => void handleSave()} loading={saving} disabled={!hasAccess}>
|
||||
저장
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onCloseDetail={closeEditor}
|
||||
showDesktopClose
|
||||
emptyDetailTitle="스케줄 상세"
|
||||
detailContent={
|
||||
<PlanScheduleDetail
|
||||
draft={draft}
|
||||
hasAccess={hasAccess}
|
||||
selectedItem={selectedItem}
|
||||
validationMessages={validationMessages}
|
||||
onChangeDraft={setDraft}
|
||||
onCopyText={handleCopyText}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PlanScheduleList = memo(function PlanScheduleList({
|
||||
activeDraftId,
|
||||
editorOpen,
|
||||
hasAccess,
|
||||
items,
|
||||
loading,
|
||||
onSelectItem,
|
||||
}: {
|
||||
activeDraftId: number | null;
|
||||
editorOpen: boolean;
|
||||
hasAccess: boolean;
|
||||
items: PlanScheduledTask[];
|
||||
loading: boolean;
|
||||
onSelectItem: (item: PlanScheduledTask) => void;
|
||||
}) {
|
||||
return (
|
||||
<List
|
||||
className="plan-schedule-page__list"
|
||||
loading={loading}
|
||||
dataSource={items}
|
||||
locale={{ emptyText: <Empty description="등록된 스케줄이 없습니다." /> }}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
key={item.id}
|
||||
className={`plan-schedule-page__list-item${
|
||||
editorOpen && activeDraftId === item.id ? ' plan-schedule-page__list-item--active' : ''
|
||||
}`}
|
||||
onClick={() => onSelectItem(item)}
|
||||
>
|
||||
<div className="plan-schedule-page__list-body">
|
||||
<Flex justify="space-between" align="start" gap={8}>
|
||||
<Text strong>{item.workId || `스케줄 #${item.id}`}</Text>
|
||||
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '적용' : '중지'}</Tag>
|
||||
</Flex>
|
||||
<Paragraph ellipsis={{ rows: 2 }} className="plan-schedule-page__list-note">
|
||||
{item.note ? (hasAccess ? item.note : maskNotePreviewByWord(item.note)) : '작업 내용이 없습니다.'}
|
||||
</Paragraph>
|
||||
<Space wrap size={8}>
|
||||
<Tag>{formatScheduleCycle(item)}</Tag>
|
||||
<Tag color="blue">다음 실행 {formatNextPlanScheduleRunAt(item)}</Tag>
|
||||
<Tag>{item.immediateRunEnabled ? '즉시실행' : '주기 후 실행'}</Tag>
|
||||
<Tag>{item.autoDeployToMain ? 'main 자동등록' : 'release만'}</Tag>
|
||||
<Tag>기능동작확인 {item.jangsingProcessingRequired ? '완료' : '오동작'}</Tag>
|
||||
</Space>
|
||||
<Flex justify="space-between" align="center" gap={8} wrap style={{ marginTop: 10 }}>
|
||||
<Text type="secondary">최근 등록 {formatPlanScheduleDateTime(item.lastRegisteredAt)}</Text>
|
||||
<Text type="secondary">수정 {formatPlanScheduleDateTime(item.updatedAt)}</Text>
|
||||
</Flex>
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
function PlanScheduleDetail({
|
||||
draft,
|
||||
hasAccess,
|
||||
selectedItem,
|
||||
validationMessages,
|
||||
onChangeDraft,
|
||||
onCopyText,
|
||||
}: {
|
||||
draft: PlanScheduledTaskDraft;
|
||||
hasAccess: boolean;
|
||||
selectedItem: PlanScheduledTask | null;
|
||||
validationMessages: string[];
|
||||
onChangeDraft: Dispatch<SetStateAction<PlanScheduledTaskDraft>>;
|
||||
onCopyText: (text: string) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<Space direction="vertical" size={14} style={{ width: '100%' }}>
|
||||
{selectedItem ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type="info"
|
||||
className="plan-schedule-page__alert"
|
||||
message="등록 정보"
|
||||
description={
|
||||
<Space direction="vertical" size={4}>
|
||||
<Text>다음 실행: {formatNextPlanScheduleRunAt(selectedItem)}</Text>
|
||||
<Text>최근 작업 등록: {formatPlanScheduleDateTime(selectedItem.lastRegisteredAt)}</Text>
|
||||
<Text>생성: {formatPlanScheduleDateTime(selectedItem.createdAt)}</Text>
|
||||
<Text>수정: {formatPlanScheduleDateTime(selectedItem.updatedAt)}</Text>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{validationMessages.length ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type={validationMessages.some((message) => message !== '비활성 스케줄은 자동 등록되지 않습니다.') ? 'warning' : 'info'}
|
||||
className="plan-schedule-page__alert"
|
||||
message="스케줄 등록 검증"
|
||||
description={
|
||||
<Space direction="vertical" size={2}>
|
||||
{validationMessages.map((message) => (
|
||||
<Text key={message}>{message}</Text>
|
||||
))}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="plan-schedule-page__form">
|
||||
<div>
|
||||
<Text strong>작업 ID</Text>
|
||||
<Input
|
||||
value={draft.workId}
|
||||
placeholder="예: 반복-정리"
|
||||
disabled={!hasAccess}
|
||||
onChange={(event) => onChangeDraft((previous) => ({ ...previous, workId: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Flex justify="space-between" align="center" gap={8} wrap>
|
||||
<Text strong>작업 메모</Text>
|
||||
<Space size={8}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => void onCopyText(draft.note)}>
|
||||
복사
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
<div className="plan-schedule-page__notepad-frame">
|
||||
<TextArea
|
||||
value={hasAccess ? draft.note : maskNotePreviewByWord(draft.note)}
|
||||
rows={10}
|
||||
placeholder={hasAccess ? '반복 등록할 작업 내용을 입력하세요.' : '권한 토큰 등록 후 편집할 수 있습니다.'}
|
||||
className="plan-schedule-page__notepad"
|
||||
disabled={!hasAccess}
|
||||
onChange={(event) => onChangeDraft((previous) => ({ ...previous, note: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
{!hasAccess ? <Text type="secondary">조회 화면에서는 작업 메모를 단어별 마스킹으로 표시합니다.</Text> : null}
|
||||
</div>
|
||||
<div>
|
||||
<Text strong>release 브랜치</Text>
|
||||
<Input
|
||||
value={draft.releaseTarget}
|
||||
placeholder="release"
|
||||
disabled={!hasAccess}
|
||||
onChange={(event) =>
|
||||
onChangeDraft((previous) => ({ ...previous, releaseTarget: event.target.value || 'release' }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong>자동화 처리</Text>
|
||||
<Select
|
||||
className="plan-schedule-page__select plan-schedule-page__select--automation"
|
||||
value={draft.automationType}
|
||||
options={PLAN_AUTOMATION_TYPE_OPTIONS}
|
||||
popupClassName="plan-schedule-page__select-popup"
|
||||
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
|
||||
disabled={!hasAccess}
|
||||
onChange={(automationType) => onChangeDraft((previous) => ({ ...previous, automationType }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Checkbox
|
||||
checked={draft.enabled}
|
||||
disabled={!hasAccess}
|
||||
onChange={(event) => onChangeDraft((previous) => ({ ...previous, enabled: event.target.checked }))}
|
||||
>
|
||||
적용
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong>반복 주기</Text>
|
||||
<Tabs
|
||||
size="small"
|
||||
items={SCHEDULE_MODE_TAB_ITEMS.map((item) => ({
|
||||
...item,
|
||||
disabled: !hasAccess,
|
||||
}))}
|
||||
activeKey={draft.scheduleMode}
|
||||
onChange={(key) => onChangeDraft((previous) => ({ ...previous, scheduleMode: key as PlanScheduleMode }))}
|
||||
/>
|
||||
{draft.scheduleMode === 'daily' ? (
|
||||
<Space align="center" wrap>
|
||||
<Text>매일</Text>
|
||||
<Select
|
||||
style={{ width: 96 }}
|
||||
options={HOUR_OPTIONS}
|
||||
value={normalizeDailyRunTime(draft.dailyRunTime).split(':')[0]}
|
||||
popupClassName="plan-schedule-page__select-popup"
|
||||
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
|
||||
disabled={!hasAccess}
|
||||
onChange={(value) =>
|
||||
onChangeDraft((previous) => ({
|
||||
...previous,
|
||||
dailyRunTime: updateDailyRunTime(previous.dailyRunTime, 'hour', value),
|
||||
repeatIntervalValue: 1,
|
||||
repeatIntervalUnit: 'day',
|
||||
repeatIntervalMinutes: getRepeatIntervalMinutes(1, 'day'),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 96 }}
|
||||
options={MINUTE_OPTIONS}
|
||||
value={normalizeDailyRunTime(draft.dailyRunTime).split(':')[1]}
|
||||
popupClassName="plan-schedule-page__select-popup"
|
||||
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
|
||||
disabled={!hasAccess}
|
||||
onChange={(value) =>
|
||||
onChangeDraft((previous) => ({
|
||||
...previous,
|
||||
dailyRunTime: updateDailyRunTime(previous.dailyRunTime, 'minute', value),
|
||||
repeatIntervalValue: 1,
|
||||
repeatIntervalUnit: 'day',
|
||||
repeatIntervalMinutes: getRepeatIntervalMinutes(1, 'day'),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Text type="secondary">에 작업 메모로 등록</Text>
|
||||
</Space>
|
||||
) : (
|
||||
<>
|
||||
<Space align="center" wrap>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={getRepeatIntervalValueMax(draft.repeatIntervalUnit)}
|
||||
value={draft.repeatIntervalValue}
|
||||
disabled={!hasAccess}
|
||||
onChange={(value) => {
|
||||
const repeatIntervalValue = normalizeRepeatIntervalValue(
|
||||
Number(value) || 1,
|
||||
draft.repeatIntervalUnit,
|
||||
);
|
||||
onChangeDraft((previous) => ({
|
||||
...previous,
|
||||
repeatIntervalValue,
|
||||
repeatIntervalMinutes: getRepeatIntervalMinutes(repeatIntervalValue, previous.repeatIntervalUnit),
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 96 }}
|
||||
options={REPEAT_UNIT_OPTIONS}
|
||||
value={draft.repeatIntervalUnit}
|
||||
popupClassName="plan-schedule-page__select-popup"
|
||||
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
|
||||
disabled={!hasAccess}
|
||||
onChange={(value) =>
|
||||
onChangeDraft((previous) => ({
|
||||
...previous,
|
||||
repeatIntervalValue: normalizeRepeatIntervalValue(previous.repeatIntervalValue, value),
|
||||
repeatIntervalUnit: value,
|
||||
repeatIntervalMinutes: getRepeatIntervalMinutes(
|
||||
normalizeRepeatIntervalValue(previous.repeatIntervalValue, value),
|
||||
value,
|
||||
),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Text type="secondary">마다 작업 메모로 등록</Text>
|
||||
</Space>
|
||||
<Space size={6} wrap style={{ marginTop: 8 }}>
|
||||
{REPEAT_PRESET_OPTIONS.map((option) => (
|
||||
<Button
|
||||
key={`${option.value}-${option.unit}`}
|
||||
size="small"
|
||||
disabled={!hasAccess}
|
||||
onClick={() =>
|
||||
onChangeDraft((previous) => ({
|
||||
...previous,
|
||||
scheduleMode: option.unit === 'day' && option.value === 1 ? 'daily' : 'interval',
|
||||
repeatIntervalValue: option.value,
|
||||
repeatIntervalUnit: option.unit,
|
||||
repeatIntervalMinutes: getRepeatIntervalMinutes(option.value, option.unit),
|
||||
}))
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Checkbox
|
||||
checked={draft.immediateRunEnabled}
|
||||
disabled={!hasAccess}
|
||||
onChange={(event) =>
|
||||
onChangeDraft((previous) => ({ ...previous, immediateRunEnabled: event.target.checked }))
|
||||
}
|
||||
>
|
||||
즉시실행 여부
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div>
|
||||
<Checkbox
|
||||
checked={draft.autoDeployToMain}
|
||||
disabled={!hasAccess}
|
||||
onChange={(event) => onChangeDraft((previous) => ({ ...previous, autoDeployToMain: event.target.checked }))}
|
||||
>
|
||||
메인까지 자동등록
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong>기능동작확인</Text>
|
||||
<Segmented
|
||||
options={FUNCTION_CHECK_OPTIONS}
|
||||
value={draft.jangsingProcessingRequired ? '완료' : '오동작'}
|
||||
disabled={!hasAccess}
|
||||
onChange={(value) =>
|
||||
onChangeDraft((previous) => ({
|
||||
...previous,
|
||||
jangsingProcessingRequired: value === '완료',
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
773
src/features/planBoard/ReleaseReviewPage.tsx
Executable file
773
src/features/planBoard/ReleaseReviewPage.tsx
Executable file
@@ -0,0 +1,773 @@
|
||||
import {
|
||||
CloseOutlined,
|
||||
ExpandOutlined,
|
||||
EyeOutlined,
|
||||
LinkOutlined,
|
||||
ReloadOutlined,
|
||||
SaveOutlined,
|
||||
ShrinkOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Alert, Button, Card, Checkbox, Empty, Flex, Input, Space, Spin, Tag, Typography, message } from 'antd';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { componentSampleEntries, widgetSampleEntries } from '../../app/manifests/samples.manifest';
|
||||
import { useTokenAccess } from '../../app/main/tokenAccess';
|
||||
import { buildPlansPath } from '../../app/main/routes';
|
||||
import { ComponentSamplesLayout } from '../layout/component-sample-gallery';
|
||||
import { SampleWidgetsLayout } from '../layout/widget-sample-gallery';
|
||||
import { fetchReleaseReviewBoardItems, updatePlanReleaseReview } from './api';
|
||||
import { maskNotePreviewByWord } from './noteMasking';
|
||||
import { isReleasePendingMainItem, normalizeWorkerStatus } from './quickFilters';
|
||||
import type { PlanItem, PlanReleaseReviewBoardItem, PlanReleaseReviewStatus } from './types';
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
type ReviewMenuTarget = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
targetType: 'component' | 'widget';
|
||||
targetId: string;
|
||||
};
|
||||
|
||||
type ReviewOverlayTarget = ReviewMenuTarget & {
|
||||
planItemId: number;
|
||||
planLabel: string;
|
||||
};
|
||||
|
||||
type ReleaseReviewFilter = 'all' | 'pending-main' | 'approved' | 'changes-requested';
|
||||
|
||||
const RELEASE_REVIEW_FILTER_OPTIONS: Array<{ label: string; value: ReleaseReviewFilter }> = [
|
||||
{ label: '전체', value: 'all' },
|
||||
{ label: 'main 대기', value: 'pending-main' },
|
||||
{ label: '검수완료', value: 'approved' },
|
||||
{ label: '수정필요', value: 'changes-requested' },
|
||||
];
|
||||
const HIDDEN_COMPONENT_IDS = ['search-command-modal', 'window-ui'];
|
||||
|
||||
function formatPlanLabel(item: Pick<PlanItem, 'id' | 'workId'>) {
|
||||
const normalizedWorkId = item.workId.trim();
|
||||
return normalizedWorkId ? `${normalizedWorkId} · #${item.id}` : `#${item.id}`;
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '미기록';
|
||||
}
|
||||
|
||||
return new Date(value).toLocaleString('ko-KR', {
|
||||
timeZone: 'Asia/Seoul',
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeNote(note: string | null | undefined) {
|
||||
if (!note) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return note.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function resolveProtectedText(text: string | null | undefined, hasAccess: boolean) {
|
||||
const normalizedText = summarizeNote(text);
|
||||
return hasAccess ? normalizedText : maskNotePreviewByWord(normalizedText);
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown) {
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
|
||||
}
|
||||
|
||||
function getReviewStatusTagColor(status: PlanReleaseReviewStatus) {
|
||||
if (status === 'approved') {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
if (status === 'changes-requested') {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
if (status === 'reviewing') {
|
||||
return 'processing';
|
||||
}
|
||||
|
||||
return 'default';
|
||||
}
|
||||
|
||||
function getReviewStatusLabel(status: PlanReleaseReviewStatus) {
|
||||
if (status === 'approved') {
|
||||
return '검수완료';
|
||||
}
|
||||
|
||||
if (status === 'changes-requested') {
|
||||
return '수정필요';
|
||||
}
|
||||
|
||||
if (status === 'reviewing') {
|
||||
return '검수중';
|
||||
}
|
||||
|
||||
return '미검수';
|
||||
}
|
||||
|
||||
function matchesReleaseReviewFilter(item: PlanReleaseReviewBoardItem, filter: ReleaseReviewFilter) {
|
||||
if (filter === 'pending-main') {
|
||||
return isReleasePendingMainItem(item.planItem);
|
||||
}
|
||||
|
||||
if (filter === 'approved') {
|
||||
return item.review.status === 'approved';
|
||||
}
|
||||
|
||||
if (filter === 'changes-requested') {
|
||||
return item.review.status === 'changes-requested';
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildPlanPath(section: 'release-review' | 'release', item: Pick<PlanItem, 'id' | 'workId'>) {
|
||||
const params = new URLSearchParams();
|
||||
params.set('planId', String(item.id));
|
||||
|
||||
if (item.workId.trim()) {
|
||||
params.set('workId', item.workId.trim());
|
||||
}
|
||||
|
||||
return `${buildPlansPath(section)}?${params.toString()}`;
|
||||
}
|
||||
|
||||
function buildRelatedMenuTargets(item: PlanReleaseReviewBoardItem) {
|
||||
const componentTargets = normalizeStringArray(item.review.metadata.componentIds).map(
|
||||
(componentId) =>
|
||||
({
|
||||
id: `component:${componentId}`,
|
||||
label: `컴포넌트 · ${componentId}`,
|
||||
description: '이번 자동화에서 개발된 컴포넌트 샘플만 바로 확인합니다.',
|
||||
targetType: 'component',
|
||||
targetId: componentId,
|
||||
}) satisfies ReviewMenuTarget,
|
||||
);
|
||||
const widgetTargets = normalizeStringArray(item.review.metadata.widgetIds).map(
|
||||
(widgetId) =>
|
||||
({
|
||||
id: `widget:${widgetId}`,
|
||||
label: `위젯 · ${widgetId}`,
|
||||
description: '이번 자동화에서 개발된 위젯 샘플만 바로 확인합니다.',
|
||||
targetType: 'widget',
|
||||
targetId: widgetId,
|
||||
}) satisfies ReviewMenuTarget,
|
||||
);
|
||||
|
||||
return [...componentTargets, ...widgetTargets];
|
||||
}
|
||||
|
||||
function buildCheckedMenuIds(item: PlanReleaseReviewBoardItem) {
|
||||
return new Set(normalizeStringArray(item.review.metadata.checkedPageSelectionIds));
|
||||
}
|
||||
|
||||
function deriveReviewStatus(args: {
|
||||
checkedIds: string[];
|
||||
targetIds: string[];
|
||||
reviewNote: string;
|
||||
currentStatus: PlanReleaseReviewStatus;
|
||||
}) {
|
||||
if (args.currentStatus === 'changes-requested' && args.checkedIds.length < args.targetIds.length) {
|
||||
return 'changes-requested';
|
||||
}
|
||||
|
||||
if (args.targetIds.length > 0 && args.checkedIds.length === args.targetIds.length) {
|
||||
return 'approved';
|
||||
}
|
||||
|
||||
if (args.checkedIds.length > 0 || args.reviewNote.trim()) {
|
||||
return 'reviewing';
|
||||
}
|
||||
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
export function ReleaseReviewPage() {
|
||||
const { hasAccess } = useTokenAccess();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [items, setItems] = useState<PlanReleaseReviewBoardItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [filter, setFilter] = useState<ReleaseReviewFilter>('pending-main');
|
||||
const [reviewNoteDrafts, setReviewNoteDrafts] = useState<Record<number, string>>({});
|
||||
const [savingByPlanId, setSavingByPlanId] = useState<Record<number, boolean>>({});
|
||||
const [overlayTarget, setOverlayTarget] = useState<ReviewOverlayTarget | null>(null);
|
||||
const [overlayMinimized, setOverlayMinimized] = useState(false);
|
||||
const cardRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
||||
|
||||
const selectedPlanId = useMemo(() => {
|
||||
const value = Number(searchParams.get('planId'));
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}, [searchParams]);
|
||||
|
||||
const selectedWorkId = useMemo(() => {
|
||||
const value = searchParams.get('workId')?.trim();
|
||||
return value ? value : null;
|
||||
}, [searchParams]);
|
||||
|
||||
async function loadReleaseReviews(cancelledRef?: { current: boolean }) {
|
||||
setLoading(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const nextItems = await fetchReleaseReviewBoardItems();
|
||||
|
||||
if (cancelledRef?.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setItems(nextItems);
|
||||
setReviewNoteDrafts(
|
||||
Object.fromEntries(nextItems.map((item) => [item.planItem.id, item.review.reviewNote ?? ''])),
|
||||
);
|
||||
} catch (error) {
|
||||
if (cancelledRef?.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(error instanceof Error ? error.message : 'release 검수 목록을 불러오지 못했습니다.');
|
||||
} finally {
|
||||
if (!cancelledRef?.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const cancelledRef = { current: false };
|
||||
void loadReleaseReviews(cancelledRef);
|
||||
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const keyword = searchKeyword.trim().toLocaleLowerCase('ko-KR');
|
||||
|
||||
return items
|
||||
.filter((item) => matchesReleaseReviewFilter(item, filter))
|
||||
.filter((item) => {
|
||||
if (!keyword) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const relatedTargets = buildRelatedMenuTargets(item);
|
||||
const searchable = [
|
||||
item.planItem.workId,
|
||||
item.planItem.note,
|
||||
item.planItem.status,
|
||||
item.planItem.workerStatus,
|
||||
item.planItem.assignedBranch,
|
||||
item.review.reviewNote,
|
||||
item.review.metadata.summary,
|
||||
item.latestSourceWork?.summary,
|
||||
...relatedTargets.flatMap((target) => [target.label, target.description]),
|
||||
...normalizeStringArray(item.latestSourceWork?.changedFiles),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLocaleLowerCase('ko-KR');
|
||||
|
||||
return searchable.includes(keyword);
|
||||
} catch (error) {
|
||||
console.error('release review search filter failed', item.planItem.id, error);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}, [filter, items, searchKeyword]);
|
||||
|
||||
const highlightedPlanId = useMemo(() => {
|
||||
if (selectedPlanId) {
|
||||
return selectedPlanId;
|
||||
}
|
||||
|
||||
if (!selectedWorkId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return items.find((item) => item.planItem.workId.trim() === selectedWorkId)?.planItem.id ?? null;
|
||||
}, [items, selectedPlanId, selectedWorkId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlightedPlanId) {
|
||||
return;
|
||||
}
|
||||
|
||||
cardRefs.current[highlightedPlanId]?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
}, [filteredItems, highlightedPlanId]);
|
||||
|
||||
function patchReview(
|
||||
planItemId: number,
|
||||
updater: (current: PlanReleaseReviewBoardItem) => PlanReleaseReviewBoardItem,
|
||||
) {
|
||||
setItems((previous) => previous.map((item) => (item.planItem.id === planItemId ? updater(item) : item)));
|
||||
}
|
||||
|
||||
async function handleSaveReview(
|
||||
planItemId: number,
|
||||
payload: {
|
||||
status?: PlanReleaseReviewStatus;
|
||||
reviewNote?: string;
|
||||
metadata?: PlanReleaseReviewBoardItem['review']['metadata'];
|
||||
},
|
||||
successMessage: string,
|
||||
) {
|
||||
setSavingByPlanId((previous) => ({
|
||||
...previous,
|
||||
[planItemId]: true,
|
||||
}));
|
||||
|
||||
try {
|
||||
const review = await updatePlanReleaseReview(planItemId, payload);
|
||||
patchReview(planItemId, (current) => ({
|
||||
...current,
|
||||
review: {
|
||||
...review,
|
||||
metadata: {
|
||||
...current.review.metadata,
|
||||
...review.metadata,
|
||||
},
|
||||
},
|
||||
}));
|
||||
setReviewNoteDrafts((previous) => ({
|
||||
...previous,
|
||||
[planItemId]: review.reviewNote ?? '',
|
||||
}));
|
||||
messageApi.success(successMessage);
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : '검수 정보 저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSavingByPlanId((previous) => ({
|
||||
...previous,
|
||||
[planItemId]: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopyLink(path: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(`${window.location.origin}${path}`);
|
||||
messageApi.success('링크를 복사했습니다.');
|
||||
} catch {
|
||||
messageApi.error('링크 복사에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenOverlay(target: ReviewMenuTarget, item: PlanReleaseReviewBoardItem) {
|
||||
setOverlayTarget({
|
||||
...target,
|
||||
planItemId: item.planItem.id,
|
||||
planLabel: formatPlanLabel(item.planItem),
|
||||
});
|
||||
setOverlayMinimized(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="release-review-page">
|
||||
{contextHolder}
|
||||
<Space direction="vertical" size={16} className="release-review-page__stack">
|
||||
<Flex align="center" justify="space-between" gap={12} wrap>
|
||||
<div>
|
||||
<Title level={4} className="release-review-page__title">
|
||||
release 검수 보드
|
||||
</Title>
|
||||
<Paragraph type="secondary" className="release-review-page__copy">
|
||||
관련 메뉴를 한 줄씩 확인하고, 필요한 화면만 내부 전체화면으로 열어 최소화/최대화하며 검수할 수 있습니다.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Button disabled={!hasAccess} onClick={() => navigate(buildPlansPath('release'))}>
|
||||
main 대기 목록
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
void loadReleaseReviews();
|
||||
}}
|
||||
icon={<ReloadOutlined />}
|
||||
>
|
||||
새로고침
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
|
||||
<Alert
|
||||
type={hasAccess ? 'info' : 'warning'}
|
||||
showIcon
|
||||
message={
|
||||
hasAccess
|
||||
? '관련 메뉴는 내부 전체화면으로 열리며, 체크박스는 서버에 저장됩니다.'
|
||||
: '권한 토큰이 없어 release 검수 보드는 조회만 사용할 수 있습니다.'
|
||||
}
|
||||
description={
|
||||
hasAccess
|
||||
? undefined
|
||||
: '새로고침 외 버튼과 입력은 비활성화되며, 요청/메모 내용은 40% 마스킹으로 표시됩니다.'
|
||||
}
|
||||
/>
|
||||
|
||||
<Flex gap={12} wrap className="release-review-page__toolbar">
|
||||
<Input.Search
|
||||
allowClear
|
||||
placeholder="작업 ID, 검수 메모, 관련 메뉴 설명, 변경 파일로 검색"
|
||||
value={searchKeyword}
|
||||
disabled={!hasAccess}
|
||||
onChange={(event) => {
|
||||
setSearchKeyword(event.target.value);
|
||||
}}
|
||||
className="release-review-page__search"
|
||||
/>
|
||||
<Space.Compact>
|
||||
{RELEASE_REVIEW_FILTER_OPTIONS.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
type={filter === option.value ? 'primary' : 'default'}
|
||||
disabled={!hasAccess}
|
||||
onClick={() => {
|
||||
setFilter(option.value);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</Space.Compact>
|
||||
</Flex>
|
||||
|
||||
{errorMessage ? <Alert type="error" showIcon message={errorMessage} /> : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="release-review-page__loading">
|
||||
<Spin />
|
||||
</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<Empty description="release 검수 대상이 없습니다." />
|
||||
) : (
|
||||
<div className="release-review-page__grid">
|
||||
{filteredItems.map((item) => {
|
||||
const planItem = item.planItem;
|
||||
const detailPath = buildPlanPath('release', planItem);
|
||||
const reviewPath = buildPlanPath('release-review', planItem);
|
||||
const previewUrl = item.latestSourceWork?.previewUrl ?? null;
|
||||
const changedFiles = item.latestSourceWork?.changedFiles?.slice(0, 8) ?? [];
|
||||
const isSaving = Boolean(savingByPlanId[planItem.id]);
|
||||
const reviewNoteDraft = reviewNoteDrafts[planItem.id] ?? '';
|
||||
const relatedMenuTargets = buildRelatedMenuTargets(item);
|
||||
const checkedMenuIds = buildCheckedMenuIds(item);
|
||||
const checkedCount = relatedMenuTargets.filter((target) => checkedMenuIds.has(target.id)).length;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={planItem.id}
|
||||
ref={(node) => {
|
||||
cardRefs.current[planItem.id] = node;
|
||||
}}
|
||||
className={
|
||||
highlightedPlanId === planItem.id
|
||||
? 'release-review-page__card-shell release-review-page__card-shell--selected'
|
||||
: 'release-review-page__card-shell'
|
||||
}
|
||||
>
|
||||
<Card
|
||||
size="small"
|
||||
className="release-review-page__card"
|
||||
title={
|
||||
<Space size={[8, 8]} wrap>
|
||||
<Text strong>{formatPlanLabel(planItem)}</Text>
|
||||
<Tag color={planItem.status === '완료' ? 'success' : 'geekblue'}>{planItem.status}</Tag>
|
||||
{planItem.workerStatus ? (
|
||||
<Tag color={normalizeWorkerStatus(planItem.workerStatus).includes('실패') ? 'error' : 'gold'}>
|
||||
{planItem.workerStatus}
|
||||
</Tag>
|
||||
) : null}
|
||||
<Tag color={getReviewStatusTagColor(item.review.status)}>{getReviewStatusLabel(item.review.status)}</Tag>
|
||||
<Tag>{`메뉴 ${checkedCount}/${relatedMenuTargets.length}`}</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={<Text type="secondary">{formatDateTime(planItem.updatedAt)}</Text>}
|
||||
>
|
||||
<Space direction="vertical" size={12} className="release-review-page__card-body">
|
||||
<div>
|
||||
<Text strong>검수 요약</Text>
|
||||
<Paragraph className="release-review-page__summary" ellipsis={{ rows: 3, expandable: true, symbol: '더보기' }}>
|
||||
{resolveProtectedText(item.review.metadata.summary || planItem.note, hasAccess)}
|
||||
</Paragraph>
|
||||
<Paragraph className="release-review-page__history-summary" ellipsis={{ rows: 2, expandable: true, symbol: '더보기' }}>
|
||||
원본 요청: {resolveProtectedText(planItem.note, hasAccess)}
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<Space size={[8, 8]} wrap>
|
||||
{planItem.assignedBranch ? <Tag>{planItem.assignedBranch}</Tag> : null}
|
||||
{planItem.releaseTarget ? <Tag color="blue">{planItem.releaseTarget}</Tag> : null}
|
||||
{planItem.autoDeployToMain ? <Tag color="green">main 자동반영</Tag> : <Tag>main 수동반영</Tag>}
|
||||
{previewUrl ? <Tag color="purple">Preview 연결됨</Tag> : <Tag>Preview 없음</Tag>}
|
||||
</Space>
|
||||
|
||||
<Flex gap={8} wrap>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<EyeOutlined />}
|
||||
disabled={!hasAccess}
|
||||
onClick={() => {
|
||||
navigate(detailPath);
|
||||
}}
|
||||
>
|
||||
자동화 상세
|
||||
</Button>
|
||||
<Button
|
||||
icon={<LinkOutlined />}
|
||||
disabled={!hasAccess}
|
||||
onClick={() => {
|
||||
void handleCopyLink(reviewPath);
|
||||
}}
|
||||
>
|
||||
검수 링크
|
||||
</Button>
|
||||
<Button
|
||||
href={previewUrl ?? undefined}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
disabled={!hasAccess || !previewUrl}
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
disabled={!hasAccess || isSaving}
|
||||
onClick={() => {
|
||||
void handleSaveReview(
|
||||
planItem.id,
|
||||
{
|
||||
status: 'changes-requested',
|
||||
reviewNote: reviewNoteDraft,
|
||||
metadata: item.review.metadata,
|
||||
},
|
||||
'검수 상태를 수정필요로 저장했습니다.',
|
||||
);
|
||||
}}
|
||||
>
|
||||
수정 필요
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<div className="release-review-page__menu-section">
|
||||
<Text strong>관련 메뉴 체크</Text>
|
||||
{relatedMenuTargets.length ? (
|
||||
<div className="release-review-page__menu-list">
|
||||
{relatedMenuTargets.map((target) => {
|
||||
const checked = checkedMenuIds.has(target.id);
|
||||
|
||||
return (
|
||||
<div key={`${planItem.id}:${target.id}`} className="release-review-page__menu-item">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
disabled={!hasAccess || isSaving}
|
||||
onChange={(event) => {
|
||||
const nextCheckedIds = relatedMenuTargets
|
||||
.map((menuTarget) => menuTarget.id)
|
||||
.filter((menuTargetId) =>
|
||||
menuTargetId === target.id ? event.target.checked : checkedMenuIds.has(menuTargetId),
|
||||
);
|
||||
|
||||
void handleSaveReview(
|
||||
planItem.id,
|
||||
{
|
||||
reviewNote: reviewNoteDraft,
|
||||
status: deriveReviewStatus({
|
||||
checkedIds: nextCheckedIds,
|
||||
targetIds: relatedMenuTargets.map((menuTarget) => menuTarget.id),
|
||||
reviewNote: reviewNoteDraft,
|
||||
currentStatus: item.review.status,
|
||||
}),
|
||||
metadata: {
|
||||
...item.review.metadata,
|
||||
checkedPageSelectionIds: nextCheckedIds,
|
||||
},
|
||||
},
|
||||
event.target.checked
|
||||
? `${target.label} 검수를 체크했습니다.`
|
||||
: `${target.label} 검수를 해제했습니다.`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<span className="release-review-page__menu-label">{target.label}</span>
|
||||
</Checkbox>
|
||||
<Text type="secondary" className="release-review-page__menu-description">
|
||||
{target.description}
|
||||
</Text>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!hasAccess}
|
||||
onClick={() => {
|
||||
handleOpenOverlay(target, item);
|
||||
}}
|
||||
>
|
||||
전체화면 열기
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Paragraph className="release-review-page__empty-copy" type="secondary">
|
||||
연결된 관련 메뉴가 아직 없습니다.
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="release-review-page__memo">
|
||||
<Text strong>검수 메모</Text>
|
||||
<TextArea
|
||||
rows={3}
|
||||
value={hasAccess ? reviewNoteDraft : maskNotePreviewByWord(reviewNoteDraft)}
|
||||
placeholder="확인 결과나 수정 필요 사항을 남기세요."
|
||||
disabled={!hasAccess}
|
||||
onChange={(event) => {
|
||||
setReviewNoteDrafts((previous) => ({
|
||||
...previous,
|
||||
[planItem.id]: event.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Flex justify="space-between" align="center" gap={8} wrap>
|
||||
<Text type="secondary">
|
||||
확인자 {item.review.checkedByNickname ?? item.review.checkedByClientId ?? '미기록'} · {formatDateTime(item.review.checkedAt)}
|
||||
</Text>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
loading={isSaving}
|
||||
disabled={!hasAccess}
|
||||
onClick={() => {
|
||||
void handleSaveReview(
|
||||
planItem.id,
|
||||
{
|
||||
reviewNote: reviewNoteDraft,
|
||||
status: deriveReviewStatus({
|
||||
checkedIds: Array.from(checkedMenuIds),
|
||||
targetIds: relatedMenuTargets.map((target) => target.id),
|
||||
reviewNote: reviewNoteDraft,
|
||||
currentStatus: item.review.status,
|
||||
}),
|
||||
metadata: item.review.metadata,
|
||||
},
|
||||
'검수 메모를 저장했습니다.',
|
||||
);
|
||||
}}
|
||||
>
|
||||
메모 저장
|
||||
</Button>
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
{item.latestSourceWork ? (
|
||||
<Space direction="vertical" size={8} className="release-review-page__history">
|
||||
<Flex gap={8} wrap>
|
||||
<Text type="secondary">최근 작업란: {formatDateTime(item.latestSourceWork.createdAt)}</Text>
|
||||
<Text type="secondary">브랜치 {item.latestSourceWork.branchName}</Text>
|
||||
</Flex>
|
||||
<Paragraph className="release-review-page__history-summary" ellipsis={{ rows: 2, expandable: true, symbol: '더보기' }}>
|
||||
{resolveProtectedText(item.latestSourceWork.summary, hasAccess)}
|
||||
</Paragraph>
|
||||
</Space>
|
||||
) : (
|
||||
<Text type="secondary">연결된 source work 기록이 없습니다.</Text>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Text strong>검수 대상 파일</Text>
|
||||
{changedFiles.length ? (
|
||||
<Space size={[6, 6]} wrap className="release-review-page__target-tags">
|
||||
{changedFiles.map((file) => (
|
||||
<Tag key={`${planItem.id}:${file}`}>{file}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
<Paragraph className="release-review-page__empty-copy" type="secondary">
|
||||
최근 기록에 남은 변경 파일이 없습니다.
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
{overlayTarget ? (
|
||||
<div
|
||||
className={`release-review-page__overlay${
|
||||
overlayMinimized ? ' release-review-page__overlay--minimized' : ''
|
||||
}`}
|
||||
>
|
||||
<Card
|
||||
bordered={false}
|
||||
className={`release-review-page__overlay-card${
|
||||
overlayMinimized ? ' release-review-page__overlay-card--minimized' : ''
|
||||
}`}
|
||||
title={
|
||||
<div className="release-review-page__overlay-title-row">
|
||||
<div className="release-review-page__overlay-title-copy">
|
||||
<Text strong>{overlayTarget.label}</Text>
|
||||
<Text type="secondary">
|
||||
{overlayTarget.planLabel} · {overlayTarget.description}
|
||||
</Text>
|
||||
</div>
|
||||
<Space size={8}>
|
||||
<Button
|
||||
type="text"
|
||||
aria-label={overlayMinimized ? '전체화면으로 확장' : '최소화'}
|
||||
icon={overlayMinimized ? <ExpandOutlined /> : <ShrinkOutlined />}
|
||||
onClick={() => {
|
||||
setOverlayMinimized((previous) => !previous);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
aria-label="닫기"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={() => {
|
||||
setOverlayTarget(null);
|
||||
setOverlayMinimized(false);
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{overlayMinimized ? (
|
||||
<Text type="secondary">헤더 아이콘으로 다시 최대화할 수 있습니다.</Text>
|
||||
) : overlayTarget.targetType === 'component' ? (
|
||||
<div className="release-review-page__overlay-content">
|
||||
<ComponentSamplesLayout
|
||||
entries={componentSampleEntries}
|
||||
excludeComponentIds={HIDDEN_COMPONENT_IDS}
|
||||
includeComponentIds={[overlayTarget.targetId]}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="release-review-page__overlay-content">
|
||||
<SampleWidgetsLayout entries={widgetSampleEntries} includeComponentIds={[overlayTarget.targetId]} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
596
src/features/planBoard/api.ts
Executable file
596
src/features/planBoard/api.ts
Executable file
@@ -0,0 +1,596 @@
|
||||
import { appendClientIdHeader } from '../../app/main/clientIdentity';
|
||||
import { getRegisteredAccessToken } from '../../app/main/tokenAccess';
|
||||
import type {
|
||||
PlanActionType,
|
||||
PlanAutomationType,
|
||||
PlanActionHistory,
|
||||
PlanAutomationUsageSnapshot,
|
||||
PlanDraft,
|
||||
PlanFilterStatus,
|
||||
PlanIssueHistory,
|
||||
PlanItem,
|
||||
PlanReleaseReview,
|
||||
PlanReleaseReviewBoardItem,
|
||||
PlanReleaseReviewMetadata,
|
||||
PlanReleaseReviewStatus,
|
||||
PlanSourceWorkHistory,
|
||||
} from './types';
|
||||
|
||||
function resolvePlanApiBaseUrl() {
|
||||
if (import.meta.env.VITE_WORK_SERVER_URL) {
|
||||
return import.meta.env.VITE_WORK_SERVER_URL;
|
||||
}
|
||||
|
||||
return '/api';
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
function resolveWorkServerFallbackBaseUrl() {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostname = window.location.hostname;
|
||||
const isLocalWorkServerHost =
|
||||
hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0';
|
||||
|
||||
if (!isLocalWorkServerHost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallbackUrl = new URL(window.location.origin);
|
||||
fallbackUrl.port = '3100';
|
||||
fallbackUrl.pathname = '/api';
|
||||
fallbackUrl.search = '';
|
||||
fallbackUrl.hash = '';
|
||||
|
||||
return fallbackUrl.toString().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
const PLAN_API_BASE_URL = resolvePlanApiBaseUrl();
|
||||
const PLAN_API_FALLBACK_BASE_URL =
|
||||
!import.meta.env.VITE_WORK_SERVER_URL && PLAN_API_BASE_URL === '/api'
|
||||
? resolveWorkServerFallbackBaseUrl()
|
||||
: null;
|
||||
|
||||
class PlanApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = 'PlanApiError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export type PlanApiRequestMeta = {
|
||||
durationMs: number;
|
||||
responseBytes: number;
|
||||
};
|
||||
|
||||
async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit): Promise<T> {
|
||||
const headers = appendClientIdHeader(init?.headers);
|
||||
const hasBody = init?.body !== undefined && init.body !== null;
|
||||
const method = init?.method?.toUpperCase() ?? 'GET';
|
||||
const timeoutMs = 8000;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
|
||||
if (hasBody && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
const token = getRegisteredAccessToken();
|
||||
if (token && !headers.has('X-Access-Token')) {
|
||||
headers.set('X-Access-Token', token);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
cache: init?.cache ?? (method === 'GET' ? 'no-store' : undefined),
|
||||
});
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new PlanApiError('서버 응답이 지연됩니다. 잠시 후 다시 시도해 주세요.', 408);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(text) as { message?: string };
|
||||
throw new PlanApiError(payload.message || "요청 처리에 실패했습니다.", response.status);
|
||||
} catch {
|
||||
throw new PlanApiError(text || "요청 처리에 실패했습니다.", response.status);
|
||||
}
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
|
||||
if (!contentType.toLowerCase().includes("application/json")) {
|
||||
const text = await response.text();
|
||||
throw new PlanApiError(text ? "서버 응답이 JSON이 아닙니다." : "서버 응답을 확인할 수 없습니다.", 502);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
try {
|
||||
return await requestOnce<T>(PLAN_API_BASE_URL, path, init);
|
||||
} catch (error) {
|
||||
const shouldRetryWithFallback =
|
||||
PLAN_API_FALLBACK_BASE_URL &&
|
||||
PLAN_API_FALLBACK_BASE_URL !== PLAN_API_BASE_URL &&
|
||||
(error instanceof PlanApiError
|
||||
? error.status === 404 || error.status === 408 || error.status === 502
|
||||
: error instanceof Error && (/not found/i.test(error.message) || /404/.test(error.message)));
|
||||
|
||||
if (!shouldRetryWithFallback) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return requestOnce<T>(PLAN_API_FALLBACK_BASE_URL, path, init);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestWithMetaOnce<T>(baseUrl: string, path: string, init?: RequestInit) {
|
||||
const headers = appendClientIdHeader(init?.headers);
|
||||
const hasBody = init?.body !== undefined && init.body !== null;
|
||||
const method = init?.method?.toUpperCase() ?? 'GET';
|
||||
const timeoutMs = 8000;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
|
||||
if (hasBody && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
const token = getRegisteredAccessToken();
|
||||
if (token && !headers.has('X-Access-Token')) {
|
||||
headers.set('X-Access-Token', token);
|
||||
}
|
||||
|
||||
const startedAt = performance.now();
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
cache: init?.cache ?? (method === 'GET' ? 'no-store' : undefined),
|
||||
});
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new PlanApiError('서버 응답이 지연됩니다. 잠시 후 다시 시도해 주세요.', 408);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const text = await response.text();
|
||||
const meta: PlanApiRequestMeta = {
|
||||
durationMs: Math.max(0, Math.round(performance.now() - startedAt)),
|
||||
responseBytes: new TextEncoder().encode(text).length,
|
||||
};
|
||||
|
||||
if (!response.ok) {
|
||||
try {
|
||||
const payload = JSON.parse(text) as { message?: string };
|
||||
throw new PlanApiError(payload.message || '요청 처리에 실패했습니다.', response.status);
|
||||
} catch {
|
||||
throw new PlanApiError(text || '요청 처리에 실패했습니다.', response.status);
|
||||
}
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
|
||||
if (!contentType.toLowerCase().includes('application/json')) {
|
||||
throw new PlanApiError(text ? '서버 응답이 JSON이 아닙니다.' : '서버 응답을 확인할 수 없습니다.', 502);
|
||||
}
|
||||
|
||||
return {
|
||||
data: JSON.parse(text) as T,
|
||||
meta,
|
||||
};
|
||||
}
|
||||
|
||||
async function requestWithMeta<T>(path: string, init?: RequestInit) {
|
||||
try {
|
||||
return await requestWithMetaOnce<T>(PLAN_API_BASE_URL, path, init);
|
||||
} catch (error) {
|
||||
const shouldRetryWithFallback =
|
||||
PLAN_API_FALLBACK_BASE_URL &&
|
||||
PLAN_API_FALLBACK_BASE_URL !== PLAN_API_BASE_URL &&
|
||||
(error instanceof PlanApiError
|
||||
? error.status === 404 || error.status === 408 || error.status === 502
|
||||
: error instanceof Error && (/not found/i.test(error.message) || /404/.test(error.message)));
|
||||
|
||||
if (!shouldRetryWithFallback) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return requestWithMetaOnce<T>(PLAN_API_FALLBACK_BASE_URL, path, init);
|
||||
}
|
||||
}
|
||||
|
||||
export async function setupPlanBoard() {
|
||||
return request<{ ok: boolean; table: string }>('/plan/setup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchPlanItems(status: PlanFilterStatus) {
|
||||
void status;
|
||||
const query = '';
|
||||
const response = await request<{ items: PlanItem[] }>(`/plan/items${query}`);
|
||||
|
||||
return response.items.map(normalizePlanItem);
|
||||
}
|
||||
|
||||
export async function fetchPlanItemsWithLatestSourceWorks(status: PlanFilterStatus) {
|
||||
void status;
|
||||
const query = '';
|
||||
const response = await requestWithMeta<{
|
||||
items: PlanItem[];
|
||||
}>(`/plan/items${query}`);
|
||||
|
||||
return {
|
||||
items: response.data.items.map(normalizePlanItem),
|
||||
meta: response.meta,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createPlanItem(draft: PlanDraft) {
|
||||
const response = await request<{ ok: boolean; item: PlanItem }>('/plan/items', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
workId: draft.workId,
|
||||
note: draft.note,
|
||||
automationType: draft.automationType,
|
||||
jangsingProcessingRequired: draft.jangsingProcessingRequired,
|
||||
autoDeployToMain: draft.autoDeployToMain,
|
||||
}),
|
||||
});
|
||||
|
||||
return normalizePlanItem(response.item);
|
||||
}
|
||||
|
||||
export async function updatePlanItem(draft: PlanDraft) {
|
||||
if (!draft.id) {
|
||||
throw new Error('수정할 작업 항목 ID가 없습니다.');
|
||||
}
|
||||
|
||||
const response = await request<{ ok: boolean; item: PlanItem }>(`/plan/items/${draft.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
workId: draft.workId,
|
||||
note: draft.note,
|
||||
automationType: draft.automationType,
|
||||
jangsingProcessingRequired: draft.jangsingProcessingRequired,
|
||||
autoDeployToMain: draft.autoDeployToMain,
|
||||
}),
|
||||
});
|
||||
|
||||
return normalizePlanItem(response.item);
|
||||
}
|
||||
|
||||
export async function updatePlanItemJangsingProcessingRequired(id: number, jangsingProcessingRequired: boolean) {
|
||||
const response = await request<{ ok: boolean; item: PlanItem }>(`/plan/items/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
jangsingProcessingRequired,
|
||||
}),
|
||||
});
|
||||
|
||||
return normalizePlanItem(response.item);
|
||||
}
|
||||
|
||||
export async function deletePlanItem(id: number) {
|
||||
const response = await request<{ ok: boolean; id: number }>(`/plan/items/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
return response.id;
|
||||
}
|
||||
|
||||
export async function runPlanAction(id: number, action: PlanActionType) {
|
||||
const response = await request<{ ok: boolean; item: PlanItem; message?: string }>(`/plan/items/${id}/actions/${action}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
return {
|
||||
item: normalizePlanItem(response.item),
|
||||
message: response.message,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchPlanIssueHistories(id: number) {
|
||||
const response = await request<{ items: PlanIssueHistory[] }>(`/plan/items/${id}/issues`);
|
||||
return response.items;
|
||||
}
|
||||
|
||||
export async function appendPlanIssueAction(id: number, actionNote: string, resolve = false, retry = false) {
|
||||
const response = await request<{ ok: boolean; item: PlanIssueHistory; planItem?: PlanItem; message?: string }>(
|
||||
`/plan/items/${id}/issues/action`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
actionNote,
|
||||
resolve,
|
||||
retry,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
item: response.item,
|
||||
planItem: response.planItem ? normalizePlanItem(response.planItem) : undefined,
|
||||
message: response.message,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchPlanActionHistories(id: number) {
|
||||
const response = await request<{ items: PlanActionHistory[] }>(`/plan/items/${id}/actions`);
|
||||
return response.items;
|
||||
}
|
||||
|
||||
export async function appendPlanActionHistory(id: number, actionNote: string) {
|
||||
const response = await request<{ ok: boolean; item: PlanActionHistory; planItem?: PlanItem; message?: string }>(
|
||||
`/plan/items/${id}/actions/note`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
actionNote,
|
||||
actionType: '추가조치',
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
item: response.item,
|
||||
planItem: response.planItem ? normalizePlanItem(response.planItem) : undefined,
|
||||
message: response.message,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePlanItem(item: PlanItem): PlanItem {
|
||||
return {
|
||||
...item,
|
||||
automationType: normalizePlanAutomationType(item.automationType),
|
||||
releaseReviewNote: typeof item.releaseReviewNote === 'string' ? item.releaseReviewNote : '',
|
||||
usageSnapshot: normalizePlanAutomationUsageSnapshot(item.usageSnapshot),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePlanAutomationUsageSnapshot(value: PlanAutomationUsageSnapshot | null | undefined) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
tokenTotals: {
|
||||
total: Number(value.tokenTotals?.total ?? 0),
|
||||
input: Number(value.tokenTotals?.input ?? 0),
|
||||
output: Number(value.tokenTotals?.output ?? 0),
|
||||
cached: Number(value.tokenTotals?.cached ?? 0),
|
||||
reasoning: Number(value.tokenTotals?.reasoning ?? 0),
|
||||
},
|
||||
totalTokens: Number(value.totalTokens ?? 0),
|
||||
retryCount: Number(value.retryCount ?? 0),
|
||||
sourceWorkCount: Number(value.sourceWorkCount ?? 0),
|
||||
processingStartedAt: value.processingStartedAt ?? null,
|
||||
processingEndedAt: value.processingEndedAt ?? null,
|
||||
processingEndedAtSource: value.processingEndedAtSource ?? null,
|
||||
processingDurationSeconds:
|
||||
value.processingDurationSeconds === null || value.processingDurationSeconds === undefined
|
||||
? null
|
||||
: Number(value.processingDurationSeconds),
|
||||
} satisfies PlanAutomationUsageSnapshot;
|
||||
}
|
||||
|
||||
export async function fetchPlanSourceWorkHistories(id: number) {
|
||||
const response = await request<{ items: PlanSourceWorkHistory[] }>(`/plan/items/${id}/source-works`);
|
||||
return response.items;
|
||||
}
|
||||
|
||||
export async function fetchReleaseReviewBoardItems() {
|
||||
const response = await request<{ items: PlanReleaseReviewBoardItem[] }>('/plan/release-reviews');
|
||||
return response.items;
|
||||
}
|
||||
|
||||
export async function updatePlanReleaseReview(
|
||||
planItemId: number,
|
||||
payload: {
|
||||
status?: PlanReleaseReviewStatus;
|
||||
reviewNote?: string;
|
||||
metadata?: PlanReleaseReviewMetadata;
|
||||
},
|
||||
) {
|
||||
const response = await request<{ ok: boolean; item: PlanReleaseReview }>(`/plan/release-reviews/${planItemId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
return response.item;
|
||||
}
|
||||
|
||||
export async function fetchPlanSourceWorkHistory(id: number, sourceWorkId: number) {
|
||||
const response = await request<{ item: PlanSourceWorkHistory }>(
|
||||
`/plan/items/${id}/source-works/${sourceWorkId}`,
|
||||
);
|
||||
return response.item;
|
||||
}
|
||||
|
||||
export type PlanScheduledTask = {
|
||||
id: number;
|
||||
workId: string;
|
||||
note: string;
|
||||
automationType: PlanAutomationType;
|
||||
releaseTarget: string;
|
||||
jangsingProcessingRequired: boolean;
|
||||
autoDeployToMain: boolean;
|
||||
enabled: boolean;
|
||||
immediateRunEnabled: boolean;
|
||||
scheduleMode: PlanScheduleMode;
|
||||
repeatIntervalValue: number;
|
||||
repeatIntervalUnit: PlanScheduleRepeatUnit;
|
||||
repeatIntervalMinutes: number;
|
||||
dailyRunTime: string;
|
||||
lastRegisteredAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PlanScheduleMode = 'interval' | 'daily';
|
||||
export type PlanScheduleRepeatUnit = 'minute' | 'hour' | 'day' | 'week' | 'month';
|
||||
|
||||
export type PlanScheduledTaskDraft = {
|
||||
id: number | null;
|
||||
workId: string;
|
||||
note: string;
|
||||
automationType: PlanAutomationType;
|
||||
releaseTarget: string;
|
||||
jangsingProcessingRequired: boolean;
|
||||
autoDeployToMain: boolean;
|
||||
enabled: boolean;
|
||||
immediateRunEnabled: boolean;
|
||||
scheduleMode: PlanScheduleMode;
|
||||
repeatIntervalValue: number;
|
||||
repeatIntervalUnit: PlanScheduleRepeatUnit;
|
||||
repeatIntervalMinutes: number;
|
||||
dailyRunTime: string;
|
||||
};
|
||||
|
||||
async function requestPlanScheduleTask<T>(pathSuffix = '', init?: RequestInit) {
|
||||
const paths = [
|
||||
`/plan/scheduled-tasks${pathSuffix}`,
|
||||
`/plan/schedule/tasks${pathSuffix}`,
|
||||
`/plan/schedule${pathSuffix}`,
|
||||
`/plan/schedules${pathSuffix}`,
|
||||
`/plans/scheduled-tasks${pathSuffix}`,
|
||||
`/plans/schedule/tasks${pathSuffix}`,
|
||||
`/plans/schedule${pathSuffix}`,
|
||||
`/plans/schedules${pathSuffix}`,
|
||||
];
|
||||
|
||||
let lastNotFoundError: PlanApiError | null = null;
|
||||
|
||||
for (const path of paths) {
|
||||
try {
|
||||
return await request<T>(path, init);
|
||||
} catch (error) {
|
||||
if (error instanceof PlanApiError && error.status === 404) {
|
||||
lastNotFoundError = error;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastNotFoundError ?? new PlanApiError('스케줄 API 경로를 찾을 수 없습니다.', 404);
|
||||
}
|
||||
|
||||
export async function fetchPlanScheduledTasks() {
|
||||
const response = await requestPlanScheduleTask<{ items: PlanScheduledTask[] }>();
|
||||
return response.items.map((item) => ({
|
||||
...item,
|
||||
automationType: normalizePlanAutomationType(item.automationType),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createPlanScheduledTask(draft: PlanScheduledTaskDraft) {
|
||||
const response = await requestPlanScheduleTask<{ ok: boolean; item: PlanScheduledTask }>('', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
workId: draft.workId,
|
||||
note: draft.note,
|
||||
automationType: draft.automationType,
|
||||
releaseTarget: draft.releaseTarget,
|
||||
jangsingProcessingRequired: draft.jangsingProcessingRequired,
|
||||
autoDeployToMain: draft.autoDeployToMain,
|
||||
enabled: draft.enabled,
|
||||
immediateRunEnabled: draft.immediateRunEnabled,
|
||||
scheduleMode: draft.scheduleMode,
|
||||
repeatIntervalValue: draft.repeatIntervalValue,
|
||||
repeatIntervalUnit: draft.repeatIntervalUnit,
|
||||
repeatIntervalMinutes: draft.repeatIntervalMinutes,
|
||||
dailyRunTime: draft.dailyRunTime,
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
...response.item,
|
||||
automationType: normalizePlanAutomationType(response.item.automationType),
|
||||
};
|
||||
}
|
||||
|
||||
export async function updatePlanScheduledTask(draft: PlanScheduledTaskDraft) {
|
||||
if (!draft.id) {
|
||||
throw new Error('수정할 스케줄 ID가 없습니다.');
|
||||
}
|
||||
|
||||
const response = await requestPlanScheduleTask<{ ok: boolean; item: PlanScheduledTask }>(`/${draft.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
workId: draft.workId,
|
||||
note: draft.note,
|
||||
automationType: draft.automationType,
|
||||
releaseTarget: draft.releaseTarget,
|
||||
jangsingProcessingRequired: draft.jangsingProcessingRequired,
|
||||
autoDeployToMain: draft.autoDeployToMain,
|
||||
enabled: draft.enabled,
|
||||
immediateRunEnabled: draft.immediateRunEnabled,
|
||||
scheduleMode: draft.scheduleMode,
|
||||
repeatIntervalValue: draft.repeatIntervalValue,
|
||||
repeatIntervalUnit: draft.repeatIntervalUnit,
|
||||
repeatIntervalMinutes: draft.repeatIntervalMinutes,
|
||||
dailyRunTime: draft.dailyRunTime,
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
...response.item,
|
||||
automationType: normalizePlanAutomationType(response.item.automationType),
|
||||
};
|
||||
}
|
||||
|
||||
export async function deletePlanScheduledTask(id: number) {
|
||||
const response = await requestPlanScheduleTask<{ ok: boolean; id: number }>(`/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
return response.id;
|
||||
}
|
||||
474
src/features/planBoard/charts.tsx
Executable file
474
src/features/planBoard/charts.tsx
Executable file
@@ -0,0 +1,474 @@
|
||||
import { Button, Card, Empty, Flex, Space, Tag, Typography } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import {
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import './planBoard.css';
|
||||
import { fetchPlanItems } from './api';
|
||||
import type { PlanItem } from './types';
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
|
||||
export const DAILY_CHART_DAYS = 7;
|
||||
export const WEEKLY_CHART_WEEKS = 8;
|
||||
const CHART_REFRESH_INTERVAL_MS = 5000;
|
||||
|
||||
type PerformanceChartDatum = {
|
||||
label: string;
|
||||
registered: number;
|
||||
jangsing: number;
|
||||
completed: number;
|
||||
merged: number;
|
||||
};
|
||||
|
||||
type CurrentPlanSnapshot = {
|
||||
total: number;
|
||||
registered: number;
|
||||
inProgress: number;
|
||||
functionChecked: number;
|
||||
completed: number;
|
||||
merged: number;
|
||||
};
|
||||
|
||||
export function PlanBoardChartsPage() {
|
||||
const [items, setItems] = useState<PlanItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const load = async (showLoading = true) => {
|
||||
if (showLoading) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const nextItems = await fetchPlanItems('all');
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setItems(nextItems);
|
||||
setErrorMessage(null);
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(error instanceof Error ? error.message : '차트 데이터를 불러오지 못했습니다.');
|
||||
} finally {
|
||||
if (!cancelled && showLoading) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
if (document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
|
||||
void load(false);
|
||||
}, CHART_REFRESH_INTERVAL_MS);
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void load(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('focus', handleVisibilityChange);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(intervalId);
|
||||
window.removeEventListener('focus', handleVisibilityChange);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const dailyPerformanceData = useMemo(() => buildDailyPerformanceData(items, DAILY_CHART_DAYS), [items]);
|
||||
const weeklyPerformanceData = useMemo(() => buildWeeklyPerformanceData(items, WEEKLY_CHART_WEEKS), [items]);
|
||||
const currentSnapshot = useMemo(() => buildCurrentPlanSnapshot(items), [items]);
|
||||
|
||||
return (
|
||||
<div className="plan-board-page">
|
||||
<Card className="plan-board-page__overview" bordered={false}>
|
||||
<Title level={4}>Plan Charts</Title>
|
||||
<Paragraph className="plan-board-page__intro">
|
||||
현재 상태 집계와 최근 작업 등록, 기능동작 확인완료, 자동 작업 완료, main 반영 추이를 함께 확인합니다.
|
||||
</Paragraph>
|
||||
</Card>
|
||||
|
||||
{errorMessage ? (
|
||||
<Card className="plan-board-page__chart-card" bordered={false}>
|
||||
<Empty description={errorMessage} />
|
||||
</Card>
|
||||
) : (
|
||||
<Card
|
||||
title="작업성과 차트"
|
||||
className="plan-board-page__chart-card"
|
||||
loading={loading}
|
||||
bordered={false}
|
||||
extra={(
|
||||
<Space size={8}>
|
||||
<Text code>5초 자동갱신</Text>
|
||||
<Button size="small" onClick={() => void fetchLatestChartItems(setItems, setLoading, setErrorMessage)}>
|
||||
조회
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
<Flex vertical gap={16}>
|
||||
<CurrentSnapshotSummary snapshot={currentSnapshot} />
|
||||
|
||||
<div className="plan-board-page__chart-grid">
|
||||
<PerformanceChart
|
||||
title="일별 작업성과"
|
||||
description={`최근 ${DAILY_CHART_DAYS}일 등록·기능확인완료·작업완료·main반영 건수`}
|
||||
data={dailyPerformanceData}
|
||||
/>
|
||||
<PerformanceChart
|
||||
title="주별 작업성과"
|
||||
description={`최근 ${WEEKLY_CHART_WEEKS}주 등록·기능확인완료·작업완료·main반영 건수`}
|
||||
data={weeklyPerformanceData}
|
||||
/>
|
||||
</div>
|
||||
</Flex>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchLatestChartItems(
|
||||
setItems: Dispatch<SetStateAction<PlanItem[]>>,
|
||||
setLoading: Dispatch<SetStateAction<boolean>>,
|
||||
setErrorMessage: Dispatch<SetStateAction<string | null>>,
|
||||
) {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const nextItems = await fetchPlanItems('all');
|
||||
setItems(nextItems);
|
||||
setErrorMessage(null);
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '차트 데이터를 불러오지 못했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function CurrentSnapshotSummary({ snapshot }: { snapshot: CurrentPlanSnapshot }) {
|
||||
return (
|
||||
<Flex justify="space-between" align="start" gap={12} wrap>
|
||||
<div>
|
||||
<Title level={5} className="plan-board-page__chart-title">
|
||||
현재데이터
|
||||
</Title>
|
||||
<Paragraph className="plan-board-page__chart-description">
|
||||
지금 저장된 작업 상태 기준 집계입니다. 완료 처리된 예전 작업도 상태가 바뀌면 바로 반영됩니다.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Space size={[8, 8]} wrap>
|
||||
<Tag>전체 {snapshot.total}</Tag>
|
||||
<Tag color="blue">등록 {snapshot.registered}</Tag>
|
||||
<Tag color="gold">작업중 {snapshot.inProgress}</Tag>
|
||||
<Tag color="purple">기능확인완료 {snapshot.functionChecked}</Tag>
|
||||
<Tag color="cyan">완료 {snapshot.completed}</Tag>
|
||||
<Tag color="green">main반영 {snapshot.merged}</Tag>
|
||||
</Space>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function PerformanceChart({
|
||||
title,
|
||||
description,
|
||||
data,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
data: PerformanceChartDatum[];
|
||||
}) {
|
||||
const totalRegistered = data.reduce((sum, item) => sum + item.registered, 0);
|
||||
const totalJangsing = data.reduce((sum, item) => sum + item.jangsing, 0);
|
||||
const totalCompleted = data.reduce((sum, item) => sum + item.completed, 0);
|
||||
const totalMerged = data.reduce((sum, item) => sum + item.merged, 0);
|
||||
|
||||
return (
|
||||
<div className="plan-board-page__chart-panel">
|
||||
<Flex justify="space-between" align="start" gap={12} wrap>
|
||||
<div>
|
||||
<Title level={5} className="plan-board-page__chart-title">
|
||||
{title}
|
||||
</Title>
|
||||
<Paragraph className="plan-board-page__chart-description">{description}</Paragraph>
|
||||
</div>
|
||||
<Space size={[8, 8]} wrap>
|
||||
<Tag color="blue">등록 {totalRegistered}</Tag>
|
||||
<Tag color="purple">기능확인완료 {totalJangsing}</Tag>
|
||||
<Tag color="cyan">완료 {totalCompleted}</Tag>
|
||||
<Tag color="green">main반영 {totalMerged}</Tag>
|
||||
</Space>
|
||||
</Flex>
|
||||
|
||||
<div className="plan-board-page__chart-shell">
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<LineChart data={data} margin={{ top: 12, right: 12, left: -18, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(24, 34, 48, 0.08)" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{ fill: 'rgba(24, 34, 48, 0.62)', fontSize: 12 }}
|
||||
/>
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={36}
|
||||
tick={{ fill: 'rgba(24, 34, 48, 0.62)', fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
borderRadius: 12,
|
||||
border: '1px solid rgba(22, 93, 255, 0.12)',
|
||||
boxShadow: '0 16px 30px rgba(23, 61, 130, 0.08)',
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="registered"
|
||||
name="등록"
|
||||
stroke="#165dff"
|
||||
strokeWidth={3}
|
||||
dot={{ r: 3, fill: '#165dff', stroke: '#ffffff', strokeWidth: 2 }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="jangsing"
|
||||
name="기능확인완료"
|
||||
stroke="#722ed1"
|
||||
strokeWidth={3}
|
||||
dot={{ r: 3, fill: '#722ed1', stroke: '#ffffff', strokeWidth: 2 }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="completed"
|
||||
name="작업완료"
|
||||
stroke="#13c2c2"
|
||||
strokeWidth={3}
|
||||
dot={{ r: 3, fill: '#13c2c2', stroke: '#ffffff', strokeWidth: 2 }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="merged"
|
||||
name="main반영"
|
||||
stroke="#52c41a"
|
||||
strokeWidth={3}
|
||||
dot={{ r: 3, fill: '#52c41a', stroke: '#ffffff', strokeWidth: 2 }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildDailyPerformanceData(items: PlanItem[], days: number): PerformanceChartDatum[] {
|
||||
const today = new Date();
|
||||
const start = startOfLocalDay(addDays(today, -(days - 1)));
|
||||
const keys = Array.from({ length: days }, (_, index) => formatLocalDateKey(addDays(start, index)));
|
||||
const buckets = createPerformanceBuckets(keys, (date) =>
|
||||
`${date.getMonth() + 1}.${String(date.getDate()).padStart(2, '0')}`,
|
||||
);
|
||||
|
||||
items.forEach((item) => {
|
||||
accumulatePerformanceCount(buckets, item.createdAt, 'registered');
|
||||
if (item.jangsingProcessingRequired && item.completedAt) {
|
||||
accumulatePerformanceCount(buckets, item.completedAt, 'jangsing');
|
||||
}
|
||||
accumulatePerformanceCount(buckets, item.completedAt, 'completed');
|
||||
accumulatePerformanceCount(buckets, item.mergedAt, 'merged');
|
||||
});
|
||||
|
||||
return keys.map((key) => buckets.get(key) ?? createEmptyPerformanceBucket(key, key));
|
||||
}
|
||||
|
||||
function buildCurrentPlanSnapshot(items: PlanItem[]): CurrentPlanSnapshot {
|
||||
return items.reduce<CurrentPlanSnapshot>(
|
||||
(summary, item) => {
|
||||
summary.total += 1;
|
||||
|
||||
if (item.status === '등록') {
|
||||
summary.registered += 1;
|
||||
}
|
||||
|
||||
if (item.status === '작업중') {
|
||||
summary.inProgress += 1;
|
||||
}
|
||||
|
||||
if (isCompletedPlanStatus(item.status)) {
|
||||
summary.completed += 1;
|
||||
|
||||
if (item.jangsingProcessingRequired) {
|
||||
summary.functionChecked += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (item.status === '완료' || item.mergedAt) {
|
||||
summary.merged += 1;
|
||||
}
|
||||
|
||||
return summary;
|
||||
},
|
||||
{
|
||||
total: 0,
|
||||
registered: 0,
|
||||
inProgress: 0,
|
||||
functionChecked: 0,
|
||||
completed: 0,
|
||||
merged: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function buildWeeklyPerformanceData(items: PlanItem[], weeks: number): PerformanceChartDatum[] {
|
||||
const today = new Date();
|
||||
const start = startOfLocalDay(addDays(today, -((weeks - 1) * 7)));
|
||||
const keys = Array.from({ length: weeks }, (_, index) => formatLocalDateKey(addDays(start, index * 7)));
|
||||
const buckets = createPerformanceBuckets(keys, (date) => {
|
||||
const weekEnd = addDays(date, 6);
|
||||
return `${date.getMonth() + 1}.${String(date.getDate()).padStart(2, '0')}-${weekEnd.getMonth() + 1}.${String(
|
||||
weekEnd.getDate(),
|
||||
).padStart(2, '0')}`;
|
||||
});
|
||||
|
||||
items.forEach((item) => {
|
||||
accumulateWeeklyPerformanceCount(buckets, item.createdAt, start, weeks, 'registered');
|
||||
if (item.jangsingProcessingRequired && item.completedAt) {
|
||||
accumulateWeeklyPerformanceCount(buckets, item.completedAt, start, weeks, 'jangsing');
|
||||
}
|
||||
accumulateWeeklyPerformanceCount(buckets, item.completedAt, start, weeks, 'completed');
|
||||
accumulateWeeklyPerformanceCount(buckets, item.mergedAt, start, weeks, 'merged');
|
||||
});
|
||||
|
||||
return keys.map((key) => buckets.get(key) ?? createEmptyPerformanceBucket(key, key));
|
||||
}
|
||||
|
||||
function createPerformanceBuckets(keys: string[], labelBuilder: (date: Date) => string) {
|
||||
const buckets = new Map<string, PerformanceChartDatum>();
|
||||
|
||||
keys.forEach((key) => {
|
||||
const date = parseDateKey(key);
|
||||
buckets.set(key, createEmptyPerformanceBucket(key, labelBuilder(date)));
|
||||
});
|
||||
|
||||
return buckets;
|
||||
}
|
||||
|
||||
function createEmptyPerformanceBucket(key: string, label: string): PerformanceChartDatum {
|
||||
void key;
|
||||
|
||||
return {
|
||||
label,
|
||||
registered: 0,
|
||||
jangsing: 0,
|
||||
completed: 0,
|
||||
merged: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function accumulatePerformanceCount(
|
||||
buckets: Map<string, PerformanceChartDatum>,
|
||||
value: string | null,
|
||||
field: keyof Omit<PerformanceChartDatum, 'label'>,
|
||||
) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = formatLocalDateKey(new Date(value));
|
||||
const bucket = buckets.get(key);
|
||||
|
||||
if (bucket) {
|
||||
bucket[field] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function accumulateWeeklyPerformanceCount(
|
||||
buckets: Map<string, PerformanceChartDatum>,
|
||||
value: string | null,
|
||||
start: Date,
|
||||
weeks: number,
|
||||
field: keyof Omit<PerformanceChartDatum, 'label'>,
|
||||
) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const date = startOfLocalDay(new Date(value));
|
||||
const diffDays = Math.floor((date.getTime() - start.getTime()) / (24 * 60 * 60 * 1000));
|
||||
|
||||
if (diffDays < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const weekIndex = Math.floor(diffDays / 7);
|
||||
|
||||
if (weekIndex < 0 || weekIndex >= weeks) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bucketKey = formatLocalDateKey(addDays(start, weekIndex * 7));
|
||||
const bucket = buckets.get(bucketKey);
|
||||
|
||||
if (bucket) {
|
||||
bucket[field] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function startOfLocalDay(date: Date) {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number) {
|
||||
const next = new Date(date);
|
||||
next.setDate(next.getDate() + days);
|
||||
return next;
|
||||
}
|
||||
|
||||
function formatLocalDateKey(date: Date) {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(
|
||||
2,
|
||||
'0',
|
||||
)}`;
|
||||
}
|
||||
|
||||
function parseDateKey(key: string) {
|
||||
const [year, month, day] = key.split('-').map(Number);
|
||||
return new Date(year, (month ?? 1) - 1, day ?? 1);
|
||||
}
|
||||
|
||||
function isCompletedPlanStatus(status: PlanItem['status']) {
|
||||
return status === '작업완료' || status === '릴리즈완료' || status === '완료';
|
||||
}
|
||||
14
src/features/planBoard/index.ts
Executable file
14
src/features/planBoard/index.ts
Executable file
@@ -0,0 +1,14 @@
|
||||
export { PlanBoardPage } from './PlanBoardPage';
|
||||
export { PlanBoardChartsPage } from './charts';
|
||||
export { PlanSchedulePage } from './PlanSchedulePage';
|
||||
export { ReleaseReviewPage } from './ReleaseReviewPage';
|
||||
export { PLAN_FILTER_STATUSES, PLAN_STATUSES } from './types';
|
||||
export type { PlanDraft, PlanFilterStatus, PlanItem, PlanStatus } from './types';
|
||||
export {
|
||||
getPlanQuickFilterLabel,
|
||||
isAutomationFailedItem,
|
||||
isReleasePendingMainItem,
|
||||
isWorkingPlanItem,
|
||||
normalizeWorkerStatus,
|
||||
type PlanQuickFilter,
|
||||
} from './quickFilters';
|
||||
31
src/features/planBoard/noteMasking.ts
Executable file
31
src/features/planBoard/noteMasking.ts
Executable file
@@ -0,0 +1,31 @@
|
||||
export function maskNotePreviewByWord(note: string) {
|
||||
const trimmed = note.trim();
|
||||
if (!trimmed) {
|
||||
return '요청 내용이 마스킹되었습니다.';
|
||||
}
|
||||
|
||||
return note
|
||||
.split(/(\s+)/)
|
||||
.map((segment) => {
|
||||
if (!segment || /\s+/.test(segment)) {
|
||||
return segment;
|
||||
}
|
||||
|
||||
if (segment.length === 1) {
|
||||
return '*';
|
||||
}
|
||||
|
||||
const visiblePrefixLength = segment.length >= 4 ? 1 : 0;
|
||||
const visibleSuffixLength = segment.length >= 6 ? 1 : 0;
|
||||
const maxMaskLength = Math.max(1, segment.length - visiblePrefixLength - visibleSuffixLength);
|
||||
const maskLength = Math.min(maxMaskLength, Math.max(1, Math.ceil(segment.length * 0.4)));
|
||||
const maskStart = Math.max(
|
||||
visiblePrefixLength,
|
||||
Math.floor((segment.length - maskLength) / 2),
|
||||
);
|
||||
const maskEnd = Math.min(segment.length - visibleSuffixLength, maskStart + maskLength);
|
||||
|
||||
return `${segment.slice(0, maskStart)}${'*'.repeat(maskEnd - maskStart)}${segment.slice(maskEnd)}`;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
893
src/features/planBoard/planBoard.css
Executable file
893
src/features/planBoard/planBoard.css
Executable file
@@ -0,0 +1,893 @@
|
||||
.plan-board-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plan-board-page__overview,
|
||||
.plan-board-page__list-card,
|
||||
.plan-board-page__chart-card,
|
||||
.plan-board-page__editor-card {
|
||||
border: 0;
|
||||
border-radius: 20px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.plan-board-page__split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plan-board-page__split--stacked {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.plan-board-page__list-card .ant-card-body,
|
||||
.plan-board-page__editor-card .ant-card-body,
|
||||
.plan-board-page__detail-card .ant-card-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plan-board-page__detail-actions.ant-space {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.plan-board-page__detail-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 360px;
|
||||
padding: 24px;
|
||||
border-radius: 18px;
|
||||
border: 1px dashed rgba(22, 93, 255, 0.18);
|
||||
background: linear-gradient(180deg, rgba(248, 251, 255, 0.96) 0%, rgba(238, 244, 255, 0.96) 100%);
|
||||
}
|
||||
|
||||
.plan-board-page__chart-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plan-board-page__chart-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
border-radius: 20px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(22, 93, 255, 0.05) 0%, rgba(22, 93, 255, 0.015) 100%),
|
||||
#ffffff;
|
||||
border: 1px solid rgba(22, 93, 255, 0.08);
|
||||
}
|
||||
|
||||
.plan-board-page__chart-title.ant-typography,
|
||||
.plan-board-page__chart-description.ant-typography {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.plan-board-page__chart-description.ant-typography {
|
||||
margin-top: 4px;
|
||||
color: rgba(24, 34, 48, 0.62);
|
||||
}
|
||||
|
||||
.plan-board-page__chart-shell {
|
||||
width: 100%;
|
||||
padding: 12px 12px 6px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
}
|
||||
|
||||
.plan-board-page__intro.ant-typography {
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
.plan-board-page__auto-refresh-control.ant-space {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.plan-board-page__auto-refresh-button.ant-btn {
|
||||
border-radius: 999px;
|
||||
border-color: rgba(22, 93, 255, 0.18);
|
||||
}
|
||||
|
||||
.plan-board-page__auto-refresh-button--active.ant-btn {
|
||||
border-color: rgba(34, 197, 94, 0.28);
|
||||
background: linear-gradient(135deg, rgba(240, 253, 244, 0.96), rgba(239, 246, 255, 0.96));
|
||||
}
|
||||
|
||||
.plan-board-page__auto-refresh-countdown.ant-typography {
|
||||
margin: 0;
|
||||
min-width: 2.75rem;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: #166534;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.plan-board-page__alert {
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.plan-board-page__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.plan-board-page__list-filter-bar {
|
||||
margin: 12px 0 16px;
|
||||
}
|
||||
|
||||
.plan-board-page__list-filter-bar .ant-select {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.plan-board-page__list-item {
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 16px;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.plan-board-page__list-item:hover {
|
||||
background: #f8fbff;
|
||||
border-color: rgba(22, 93, 255, 0.12);
|
||||
}
|
||||
|
||||
.plan-board-page__list-item--active {
|
||||
background: #f5f9ff;
|
||||
border-color: rgba(22, 93, 255, 0.22);
|
||||
}
|
||||
|
||||
.plan-board-page__list-body {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.plan-board-page__list-note.ant-typography {
|
||||
margin: 8px 0 10px;
|
||||
}
|
||||
|
||||
.plan-board-page__list-tags.ant-space {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.plan-board-page__priority-button.ant-btn {
|
||||
min-width: 40px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.plan-board-page__priority-button--active.ant-btn {
|
||||
box-shadow: 0 10px 20px rgba(22, 93, 255, 0.18);
|
||||
}
|
||||
|
||||
.plan-board-page__automation-status {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(126, 141, 163, 0.28);
|
||||
background: linear-gradient(135deg, rgba(246, 248, 252, 0.96), rgba(238, 242, 247, 0.88));
|
||||
min-height: 34px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.plan-board-page__automation-status--compact {
|
||||
min-height: 30px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.plan-board-page__automation-status-fill {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
opacity: 0.96;
|
||||
}
|
||||
|
||||
.plan-board-page__automation-status--idle .plan-board-page__automation-status-fill {
|
||||
background: linear-gradient(90deg, rgba(148, 163, 184, 0.18), rgba(148, 163, 184, 0.3));
|
||||
}
|
||||
|
||||
.plan-board-page__automation-status--active .plan-board-page__automation-status-fill {
|
||||
background: linear-gradient(90deg, rgba(34, 197, 94, 0.2), rgba(59, 130, 246, 0.42), rgba(34, 197, 94, 0.2));
|
||||
background-size: 200% 100%;
|
||||
animation: plan-board-status-flow 2.4s linear infinite;
|
||||
}
|
||||
|
||||
.plan-board-page__automation-status--success .plan-board-page__automation-status-fill {
|
||||
background: linear-gradient(90deg, rgba(34, 197, 94, 0.2), rgba(22, 163, 74, 0.34));
|
||||
}
|
||||
|
||||
.plan-board-page__automation-status--error .plan-board-page__automation-status-fill {
|
||||
background: linear-gradient(90deg, rgba(248, 113, 113, 0.2), rgba(239, 68, 68, 0.36));
|
||||
}
|
||||
|
||||
.plan-board-page__automation-status-text.ant-typography {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
color: #10233f;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.plan-board-page__automation-status-copy {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
min-height: inherit;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
|
||||
.plan-board-page__automation-status-description.ant-typography {
|
||||
margin: 0;
|
||||
color: #10233f;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
@keyframes plan-board-status-flow {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.plan-board-page__form {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.plan-board-page__form > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid rgba(22, 93, 255, 0.1);
|
||||
border-radius: 18px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(244, 248, 255, 0.94) 100%);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.92),
|
||||
0 10px 30px rgba(23, 61, 130, 0.04);
|
||||
}
|
||||
|
||||
.plan-board-page__action-bar {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.plan-board-page__select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.plan-board-page__select.ant-select .ant-select-selector {
|
||||
min-height: 42px;
|
||||
border-radius: 12px;
|
||||
border-color: rgba(22, 93, 255, 0.14);
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.plan-board-page__select.ant-select .ant-select-selection-wrap {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.plan-board-page__select.ant-select.ant-select-focused .ant-select-selector,
|
||||
.plan-board-page__select.ant-select.ant-select-open .ant-select-selector {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.14);
|
||||
}
|
||||
|
||||
.plan-board-page__select-popup.ant-select-dropdown {
|
||||
z-index: 1450;
|
||||
padding: 6px;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.plan-board-page__select-popup.ant-select-dropdown .ant-select-item {
|
||||
min-height: 38px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.plan-board-page__notepad.ant-input {
|
||||
padding: 20px 18px;
|
||||
line-height: 1.85;
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(22, 93, 255, 0.1);
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(255, 255, 255, 0.96) 0,
|
||||
rgba(255, 255, 255, 0.96) 32px,
|
||||
rgba(75, 130, 255, 0.08) 32px,
|
||||
rgba(75, 130, 255, 0.08) 33px
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.82),
|
||||
0 18px 40px rgba(23, 61, 130, 0.06);
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.plan-board-page__notepad.ant-input:focus,
|
||||
.plan-board-page__notepad.ant-input-focused {
|
||||
border-color: rgba(22, 93, 255, 0.24);
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(22, 93, 255, 0.08),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.plan-board-page__notepad-frame {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.plan-board-page__notepad-expand-button.ant-btn {
|
||||
color: rgba(71, 98, 130, 0.92);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.plan-board-page__notepad-expand-button.ant-btn:hover {
|
||||
color: #1d4ed8;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.plan-board-page__notepad-frame .ant-input-textarea,
|
||||
.plan-board-page__notepad-frame .plan-board-page__notepad.ant-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.plan-board-page__readonly-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 40px;
|
||||
margin-top: 8px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid rgba(22, 93, 255, 0.14);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
||||
}
|
||||
|
||||
.plan-board-page__notepad--readonly.ant-input {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.plan-board-page__notepad-toolbar {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.plan-board-page__note-modal .ant-modal {
|
||||
max-width: 100vw;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.plan-board-page__note-modal .ant-modal-content {
|
||||
min-height: 100dvh;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plan-board-page__note-modal .ant-modal-header {
|
||||
padding: 20px 24px 12px;
|
||||
}
|
||||
|
||||
.plan-board-page__note-modal .ant-modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding-inline: 24px;
|
||||
}
|
||||
|
||||
.plan-board-page__notepad-modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
flex: 1 1 auto;
|
||||
height: calc(100dvh - 88px);
|
||||
min-height: 0;
|
||||
padding-bottom: max(24px, env(safe-area-inset-bottom, 0px));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plan-board-page__notepad-modal-body .ant-input-textarea {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.plan-board-page__notepad-modal-body .ant-input-textarea textarea {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.plan-board-page__notepad--expanded.ant-input {
|
||||
flex: 1 1 auto;
|
||||
min-height: calc(100dvh - 180px);
|
||||
height: calc(100dvh - 180px);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.plan-board-page__notepad-modal-body .plan-board-page__notepad--expanded.ant-input {
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.plan-board-page__overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1700;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
background: rgba(11, 23, 57, 0.24);
|
||||
backdrop-filter: blur(10px);
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.plan-board-page__overlay--detail-only {
|
||||
background: #f5f7fb;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.plan-board-page__overlay-card.ant-card {
|
||||
width: 100vw;
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plan-board-page__overlay--detail-only .plan-board-page__overlay-card.ant-card {
|
||||
background: #f5f7fb;
|
||||
}
|
||||
|
||||
.plan-board-page__overlay-card .ant-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 14px 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plan-board-page__overlay-header {
|
||||
flex: 0 0 auto;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding-top: max(14px, env(safe-area-inset-top, 0px));
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(22, 93, 255, 0.08);
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
.plan-board-page__overlay-title.ant-typography,
|
||||
.plan-board-page__viewer-heading.ant-typography {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.plan-board-page__overlay-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 0 18px calc(env(safe-area-inset-bottom, 0px) + 24px);
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.plan-board-page__split--mobile-detail-only {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.plan-board-page__split {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.plan-board-page__list-card--mobile-hidden,
|
||||
.plan-board-page__detail-card--mobile-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.plan-board-page__detail-card--mobile-only.ant-card {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100vw;
|
||||
min-height: 100dvh;
|
||||
border-radius: 0;
|
||||
background: #f5f7fb;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plan-board-page__detail-card--mobile-only.ant-card .ant-card-head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
padding-top: max(0px, env(safe-area-inset-top, 0px));
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
.plan-board-page__detail-card--mobile-only.ant-card .ant-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-bottom: calc(24px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.plan-board-page__overlay-card.ant-card {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.plan-board-page__overlay-card .ant-card-body {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.plan-board-page__overlay-header {
|
||||
padding-inline: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.plan-board-page__viewer-pre {
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.plan-board-page__detail-text {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plan-board-page__detail-text-body.ant-typography {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.plan-board-page__detail-text-body--collapsed.ant-typography {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
}
|
||||
|
||||
.plan-board-page__detail-text-toggle.ant-btn {
|
||||
flex: none;
|
||||
margin-top: -2px;
|
||||
color: #476282;
|
||||
}
|
||||
|
||||
.plan-board-page__detail-section {
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.plan-board-page__detail-section-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.plan-board-page__detail-section-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.plan-board-page__detail-section-body {
|
||||
margin-top: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plan-board-page__checklist.ant-space,
|
||||
.plan-board-page__release-summary.ant-space {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.plan-board-page__memo-pre {
|
||||
max-height: min(60vh, 520px);
|
||||
}
|
||||
|
||||
.plan-board-page__file-tags {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.plan-board-page__viewer-tabs .ant-tabs-content-holder {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.plan-board-page__viewer-tabs,
|
||||
.plan-board-page__viewer-tabs .ant-tabs-content,
|
||||
.plan-board-page__viewer-tabs .ant-tabs-tabpane {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.plan-board-page__viewer-tabs .ant-tabs-tabpane {
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.plan-board-page__viewer-tabs .previewer-ui {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.plan-board-page__viewer-tabs .previewer-ui__body.previewer-ui__scroll {
|
||||
height: auto !important;
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.plan-board-page__viewer-tabs .previewer-ui__editor-body {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.plan-board-page__viewer-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.plan-board-page__summary-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.plan-board-page__evidence-modal .ant-modal {
|
||||
top: 40px;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.plan-board-page__evidence-modal .ant-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - 80px);
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.plan-board-page__evidence-modal .ant-modal-body {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: calc(100vh - 220px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plan-board-page__evidence-modal-body {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plan-board-page__evidence-modal-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.plan-board-page__evidence-modal-toolbar {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.plan-board-page__evidence-modal-body--expanded {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.plan-board-page__evidence-modal-body .previewer-ui,
|
||||
.plan-board-page__evidence-modal-body .codex-diff-previewer,
|
||||
.plan-board-page__evidence-modal-body .evidence-attachment-preview-body__previewer,
|
||||
.plan-board-page__evidence-modal-body .evidence-attachment-preview-body__frame-wrap,
|
||||
.plan-board-page__evidence-modal-body .evidence-attachment-preview-body__media-wrap,
|
||||
.plan-board-page__evidence-modal-body .evidence-attachment-preview-body__audio-wrap {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.plan-board-page__evidence-modal-body .previewer-ui__body.previewer-ui__scroll,
|
||||
.plan-board-page__evidence-modal-body .previewer-ui__pre,
|
||||
.plan-board-page__evidence-modal-body .previewer-ui__markdown pre,
|
||||
.plan-board-page__evidence-modal-body .codex-diff-previewer__diff-body,
|
||||
.plan-board-page__evidence-modal-body .codex-diff-previewer__diff-section--expanded .codex-diff-previewer__diff-body {
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.plan-board-page__evidence-modal--expanded .ant-modal {
|
||||
max-width: 100vw;
|
||||
top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.plan-board-page__evidence-modal--expanded .ant-modal-content {
|
||||
min-height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.plan-board-page__evidence-modal--expanded .ant-modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-inline: 0;
|
||||
padding-bottom: 0;
|
||||
min-height: calc(100vh - 120px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plan-board-page__evidence-modal--expanded .plan-board-page__evidence-modal-shell {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.plan-board-page__evidence-modal--expanded .plan-board-page__evidence-modal-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.plan-board-page__evidence-modal--expanded .evidence-attachment-preview-body__frame-wrap {
|
||||
height: calc(100vh - 140px);
|
||||
min-height: calc(100vh - 140px);
|
||||
border-radius: 0;
|
||||
border-inline: 0;
|
||||
}
|
||||
|
||||
.plan-board-page__evidence-modal--expanded .evidence-attachment-preview-body__image {
|
||||
max-height: calc(100vh - 140px);
|
||||
border-radius: 0;
|
||||
border-inline: 0;
|
||||
}
|
||||
|
||||
.plan-board-page__overlay .ant-input,
|
||||
.plan-board-page__overlay .ant-input-affix-wrapper,
|
||||
.plan-board-page__overlay .ant-input-textarea textarea {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.plan-board-page__chart-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.plan-board-page__chart-panel {
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.plan-board-page__overlay-card.ant-card {
|
||||
width: 100vw;
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
}
|
||||
|
||||
.plan-board-page__detail-card--mobile-only.ant-card {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.plan-board-page__form {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plan-board-page__form > div {
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.plan-board-page__note-modal .ant-modal-header {
|
||||
padding: 16px 18px 10px;
|
||||
}
|
||||
|
||||
.plan-board-page__note-modal .ant-modal-body {
|
||||
padding-inline: 18px;
|
||||
}
|
||||
|
||||
.plan-board-page__notepad-modal-body {
|
||||
min-height: calc(100dvh - 76px);
|
||||
padding-bottom: max(18px, env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.plan-board-page__overlay-card .ant-card-body {
|
||||
padding: 14px 14px 18px;
|
||||
}
|
||||
|
||||
.plan-board-page__detail-card--mobile-only.ant-card .ant-card-head {
|
||||
padding-inline: 14px;
|
||||
}
|
||||
|
||||
.plan-board-page__detail-card--mobile-only.ant-card .ant-card-body {
|
||||
padding: 14px 14px max(18px, env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.plan-board-page__readonly-field {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.plan-board-page__split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.plan-board-page__list-card--mobile-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.plan-board-page__detail-card.plan-board-page__detail-card--mobile-hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
268
src/features/planBoard/planSchedule.css
Executable file
268
src/features/planBoard/planSchedule.css
Executable file
@@ -0,0 +1,268 @@
|
||||
.plan-schedule-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plan-schedule-page__overview,
|
||||
.plan-schedule-page__list-card,
|
||||
.plan-schedule-page__editor-card {
|
||||
border: 0;
|
||||
border-radius: 20px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.plan-schedule-page__split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plan-schedule-page__split--stacked {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.plan-schedule-page__list-card .ant-card-body,
|
||||
.plan-schedule-page__editor-card .ant-card-body,
|
||||
.plan-schedule-page__detail-card .ant-card-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plan-schedule-page__detail-actions.ant-space {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.plan-schedule-page__detail-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 360px;
|
||||
padding: 24px;
|
||||
border-radius: 18px;
|
||||
border: 1px dashed rgba(22, 93, 255, 0.18);
|
||||
background: linear-gradient(180deg, rgba(248, 251, 255, 0.96) 0%, rgba(238, 244, 255, 0.96) 100%);
|
||||
}
|
||||
|
||||
.plan-schedule-page__intro.ant-typography {
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
.plan-schedule-page__alert {
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.plan-schedule-page__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.plan-schedule-page__list-item {
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 16px;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.plan-schedule-page__list-item:hover {
|
||||
background: #f8fbff;
|
||||
border-color: rgba(22, 93, 255, 0.12);
|
||||
}
|
||||
|
||||
.plan-schedule-page__list-item--active {
|
||||
background: #f5f9ff;
|
||||
border-color: rgba(22, 93, 255, 0.22);
|
||||
}
|
||||
|
||||
.plan-schedule-page__list-body {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.plan-schedule-page__list-note.ant-typography {
|
||||
margin: 8px 0 10px;
|
||||
}
|
||||
|
||||
.plan-schedule-page__form {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.plan-schedule-page__form > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid rgba(22, 93, 255, 0.1);
|
||||
border-radius: 18px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(244, 248, 255, 0.94) 100%);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.92),
|
||||
0 10px 30px rgba(23, 61, 130, 0.04);
|
||||
}
|
||||
|
||||
.plan-schedule-page__select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.plan-schedule-page__select.ant-select .ant-select-selector {
|
||||
min-height: 42px;
|
||||
border-radius: 12px;
|
||||
border-color: rgba(22, 93, 255, 0.14);
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.plan-schedule-page__select.ant-select .ant-select-selection-wrap {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.plan-schedule-page__select.ant-select.ant-select-focused .ant-select-selector,
|
||||
.plan-schedule-page__select.ant-select.ant-select-open .ant-select-selector {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.14);
|
||||
}
|
||||
|
||||
.plan-schedule-page__select-popup.ant-select-dropdown {
|
||||
z-index: 1450;
|
||||
padding: 6px;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.plan-schedule-page__select-popup.ant-select-dropdown .ant-select-item {
|
||||
min-height: 38px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.plan-schedule-page__notepad.ant-input {
|
||||
padding: 20px 18px;
|
||||
line-height: 1.85;
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(22, 93, 255, 0.1);
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(255, 255, 255, 0.96) 0,
|
||||
rgba(255, 255, 255, 0.96) 32px,
|
||||
rgba(75, 130, 255, 0.08) 32px,
|
||||
rgba(75, 130, 255, 0.08) 33px
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.82),
|
||||
0 18px 40px rgba(23, 61, 130, 0.06);
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.plan-schedule-page__notepad.ant-input:focus,
|
||||
.plan-schedule-page__notepad.ant-input-focused {
|
||||
border-color: rgba(22, 93, 255, 0.24);
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(22, 93, 255, 0.08),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.plan-schedule-page__notepad-frame {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.plan-schedule-page__notepad-frame .ant-input-textarea,
|
||||
.plan-schedule-page__notepad-frame .plan-schedule-page__notepad.ant-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.plan-schedule-page__overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
background: rgba(11, 23, 57, 0.24);
|
||||
backdrop-filter: blur(10px);
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.plan-schedule-page__overlay-card.ant-card {
|
||||
width: 100vw;
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plan-schedule-page__overlay-card .ant-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 14px 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plan-schedule-page__overlay-header {
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(22, 93, 255, 0.08);
|
||||
}
|
||||
|
||||
.plan-schedule-page__overlay-title.ant-typography {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.plan-schedule-page__overlay-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 0 18px calc(env(safe-area-inset-bottom, 0px) + 24px);
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.plan-schedule-page__split {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.plan-schedule-page__detail-card {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.plan-schedule-page__overlay-card.ant-card {
|
||||
width: 100vw;
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
}
|
||||
|
||||
.plan-schedule-page__form {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plan-schedule-page__form > div {
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.plan-schedule-page__overlay-card .ant-card-body {
|
||||
padding: 14px 14px 18px;
|
||||
}
|
||||
}
|
||||
49
src/features/planBoard/quickFilters.ts
Executable file
49
src/features/planBoard/quickFilters.ts
Executable file
@@ -0,0 +1,49 @@
|
||||
import type { PlanItem } from './types';
|
||||
|
||||
const MAIN_PENDING_WORKER_STATUSES = new Set(['main반영대기', 'main반영중', 'main반영실패']);
|
||||
const AUTOMATION_FAILURE_WORKER_STATUSES = new Set([
|
||||
'브랜치실패',
|
||||
'자동작업실패',
|
||||
'release반영실패',
|
||||
'main반영실패',
|
||||
]);
|
||||
|
||||
export type PlanQuickFilter = 'working' | 'release-pending-main' | 'automation-failed';
|
||||
|
||||
export function isWorkingPlanItem(item: Pick<PlanItem, 'status'>) {
|
||||
return item.status === '작업중';
|
||||
}
|
||||
|
||||
export function normalizeWorkerStatus(workerStatus: string | null) {
|
||||
return workerStatus?.replace(/\s+/g, '') ?? '';
|
||||
}
|
||||
|
||||
export function isReleasePendingMainItem(item: Pick<PlanItem, 'status' | 'workerStatus'>) {
|
||||
const normalizedWorkerStatus = normalizeWorkerStatus(item.workerStatus);
|
||||
|
||||
if (MAIN_PENDING_WORKER_STATUSES.has(normalizedWorkerStatus)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return item.status === '릴리즈완료';
|
||||
}
|
||||
|
||||
export function isAutomationFailedItem(item: Pick<PlanItem, 'workerStatus'>) {
|
||||
return AUTOMATION_FAILURE_WORKER_STATUSES.has(normalizeWorkerStatus(item.workerStatus));
|
||||
}
|
||||
|
||||
export function getPlanQuickFilterLabel(filter: PlanQuickFilter | null) {
|
||||
if (filter === 'working') {
|
||||
return '현재 작업중';
|
||||
}
|
||||
|
||||
if (filter === 'release-pending-main') {
|
||||
return '현재 release 상태';
|
||||
}
|
||||
|
||||
if (filter === 'automation-failed') {
|
||||
return '현재 자동화 실패';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
145
src/features/planBoard/types.ts
Executable file
145
src/features/planBoard/types.ts
Executable file
@@ -0,0 +1,145 @@
|
||||
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 PlanActionType =
|
||||
| 'start-work'
|
||||
| 'complete-development'
|
||||
| 'retry-branch'
|
||||
| 'retry-work'
|
||||
| 'retry-merge'
|
||||
| 'cancel-release'
|
||||
| 'request-main-merge';
|
||||
|
||||
export type PlanIssueHistory = {
|
||||
id: number;
|
||||
planItemId: number;
|
||||
issueTag: string;
|
||||
message: string;
|
||||
actionNote: string | null;
|
||||
resolved: boolean;
|
||||
resolvedAt: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type PlanActionHistory = {
|
||||
id: number;
|
||||
planItemId: number;
|
||||
actionType: string;
|
||||
note: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type PlanSourceWorkHistory = {
|
||||
id: number;
|
||||
planItemId: number;
|
||||
summary: string;
|
||||
branchName: string;
|
||||
commitHash: string | null;
|
||||
previewUrl: string | null;
|
||||
changedFiles: string[];
|
||||
commandLog: string | null;
|
||||
diffText: string | null;
|
||||
sourceFiles: PlanSourceFileSnapshot[];
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type PlanSourceFileSnapshotStatus = 'added' | 'modified' | 'deleted' | 'renamed' | 'binary' | 'unknown';
|
||||
|
||||
export type PlanSourceFileSnapshot = {
|
||||
path: string;
|
||||
previousPath: string | null;
|
||||
status: PlanSourceFileSnapshotStatus;
|
||||
language: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export const PLAN_RELEASE_REVIEW_STATUSES = ['pending', 'reviewing', 'approved', 'changes-requested'] as const;
|
||||
|
||||
export type PlanReleaseReviewStatus = (typeof PLAN_RELEASE_REVIEW_STATUSES)[number];
|
||||
|
||||
export type PlanReleaseReviewMetadata = {
|
||||
summary?: string;
|
||||
pageSelectionIds?: string[];
|
||||
checkedPageSelectionIds?: string[];
|
||||
docIds?: string[];
|
||||
componentIds?: string[];
|
||||
widgetIds?: string[];
|
||||
};
|
||||
|
||||
export type PlanReleaseReview = {
|
||||
id: number | null;
|
||||
planItemId: number;
|
||||
status: PlanReleaseReviewStatus;
|
||||
reviewNote: string;
|
||||
checkedByClientId: string | null;
|
||||
checkedByNickname: string | null;
|
||||
checkedAt: string | null;
|
||||
metadata: PlanReleaseReviewMetadata;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type PlanReleaseReviewBoardItem = {
|
||||
planItem: PlanItem;
|
||||
review: PlanReleaseReview;
|
||||
latestSourceWork: PlanSourceWorkHistory | null;
|
||||
};
|
||||
|
||||
export type PlanAutomationUsageSnapshot = {
|
||||
tokenTotals: {
|
||||
total: number;
|
||||
input: number;
|
||||
output: number;
|
||||
cached: number;
|
||||
reasoning: number;
|
||||
};
|
||||
totalTokens: number;
|
||||
retryCount: number;
|
||||
sourceWorkCount: number;
|
||||
processingStartedAt: string | null;
|
||||
processingEndedAt: string | null;
|
||||
processingEndedAtSource: string | null;
|
||||
processingDurationSeconds: number | null;
|
||||
};
|
||||
|
||||
export type PlanItem = {
|
||||
id: number;
|
||||
workId: string;
|
||||
note: string;
|
||||
automationType: PlanAutomationType;
|
||||
releaseReviewNote: string;
|
||||
noteMasked?: boolean;
|
||||
status: PlanStatus;
|
||||
jangsingProcessingRequired: boolean;
|
||||
autoDeployToMain: boolean;
|
||||
repeatRequestEnabled: boolean;
|
||||
repeatIntervalMinutes: number;
|
||||
assignedBranch: string | null;
|
||||
releaseTarget: string | null;
|
||||
workerStatus: string | null;
|
||||
lastError: string | null;
|
||||
issueTags: string[];
|
||||
hasOpenIssues: boolean;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
mergedAt: string | null;
|
||||
usageSnapshot: PlanAutomationUsageSnapshot | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PlanDraft = {
|
||||
id: number | null;
|
||||
workId: string;
|
||||
note: string;
|
||||
automationType: PlanAutomationType;
|
||||
status: PlanStatus;
|
||||
jangsingProcessingRequired: boolean;
|
||||
autoDeployToMain: boolean;
|
||||
repeatRequestEnabled: boolean;
|
||||
repeatIntervalMinutes: number;
|
||||
};
|
||||
438
src/features/serverCommand/ServerCommandPage.tsx
Executable file
438
src/features/serverCommand/ServerCommandPage.tsx
Executable file
@@ -0,0 +1,438 @@
|
||||
import { CopyOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { Alert, Button, Card, Col, Descriptions, Empty, Row, Space, Statistic, Tag, Typography, message } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTokenAccess } from '../../app/main/tokenAccess';
|
||||
import { DataStatePanel } from '../../components/dataStatePanel';
|
||||
import { copyText } from '../../app/main/mainChatPanel';
|
||||
import { fetchServerCommands, restartServerCommand } from './api';
|
||||
import type { ServerCommandItem, ServerCommandKey } from './types';
|
||||
import './serverCommand.css';
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
|
||||
type RestartErrorInfo = {
|
||||
title: string;
|
||||
detail: string;
|
||||
missingScriptPath: string | null;
|
||||
};
|
||||
|
||||
type LastActionInfo = {
|
||||
output: string | null;
|
||||
executedAt: string;
|
||||
restartState: 'completed' | 'accepted';
|
||||
};
|
||||
|
||||
function formatDateTime(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return new Date(value).toLocaleString('ko-KR', {
|
||||
timeZone: 'Asia/Seoul',
|
||||
});
|
||||
}
|
||||
|
||||
function formatResponseTime(value: number | null | undefined) {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return `${Math.max(0, Math.round(value))}ms`;
|
||||
}
|
||||
|
||||
function formatStatusCode(value: number | null | undefined) {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return `${value}`;
|
||||
}
|
||||
|
||||
function formatContentType(value: string | null | undefined) {
|
||||
return value?.trim() || '-';
|
||||
}
|
||||
|
||||
function resolveHostLabel(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '내부 전용';
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(value).host;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAvailabilityTag(item: ServerCommandItem) {
|
||||
if (item.availability === 'online') {
|
||||
return <Tag color="success">ONLINE</Tag>;
|
||||
}
|
||||
|
||||
if (item.availability === 'degraded') {
|
||||
return <Tag color="warning">DEGRADED</Tag>;
|
||||
}
|
||||
|
||||
return <Tag color="error">OFFLINE</Tag>;
|
||||
}
|
||||
|
||||
function buildRestartErrorInfo(targetLabel: string, detail: string): RestartErrorInfo {
|
||||
const missingScriptMatch = detail.match(/cannot open\s+([^\n:]+\.sh)\s*:\s*No such file/i);
|
||||
|
||||
if (missingScriptMatch?.[1]) {
|
||||
const missingScriptPath = missingScriptMatch[1].trim();
|
||||
|
||||
return {
|
||||
title: `${targetLabel} 재기동 실패`,
|
||||
detail: `재기동 스크립트 파일이 서버에 없습니다.\n배치 경로: ${missingScriptPath}\n\n원본 오류:\n${detail}`,
|
||||
missingScriptPath,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${targetLabel} 재기동 실패`,
|
||||
detail,
|
||||
missingScriptPath: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function ServerCommandPage() {
|
||||
const { hasAccess } = useTokenAccess();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [items, setItems] = useState<ServerCommandItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [restartingKey, setRestartingKey] = useState<ServerCommandKey | null>(null);
|
||||
const [restartErrorInfo, setRestartErrorInfo] = useState<RestartErrorInfo | null>(null);
|
||||
const [copyingRestartError, setCopyingRestartError] = useState(false);
|
||||
const [lastActionByKey, setLastActionByKey] = useState<Record<ServerCommandKey, LastActionInfo>>({
|
||||
test: { output: null, executedAt: '', restartState: 'completed' },
|
||||
rel: { output: null, executedAt: '', restartState: 'completed' },
|
||||
'work-server': { output: null, executedAt: '', restartState: 'completed' },
|
||||
'command-runner': { output: null, executedAt: '', restartState: 'completed' },
|
||||
});
|
||||
|
||||
const loadItems = async () => {
|
||||
setLoading(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const nextItems = await fetchServerCommands();
|
||||
setItems(nextItems);
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '서버 정보를 불러오지 못했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAccess) {
|
||||
setItems([]);
|
||||
setLoading(false);
|
||||
setErrorMessage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
void loadItems();
|
||||
}, [hasAccess]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
return items.reduce(
|
||||
(result, item) => {
|
||||
result.total += 1;
|
||||
result[item.availability] += 1;
|
||||
return result;
|
||||
},
|
||||
{ total: 0, online: 0, degraded: 0, offline: 0 },
|
||||
);
|
||||
}, [items]);
|
||||
|
||||
const handleRestart = async (key: ServerCommandKey) => {
|
||||
setRestartingKey(key);
|
||||
setRestartErrorInfo(null);
|
||||
|
||||
try {
|
||||
const result = await restartServerCommand(key);
|
||||
setItems((previous) => previous.map((item) => (item.key === result.item.key ? result.item : item)));
|
||||
setLastActionByKey((previous) => ({
|
||||
...previous,
|
||||
[result.item.key]: {
|
||||
output: result.commandOutput,
|
||||
executedAt: new Date().toISOString(),
|
||||
restartState: result.restartState,
|
||||
},
|
||||
}));
|
||||
messageApi.success(
|
||||
result.restartState === 'accepted' ? `${result.item.label} 재기동 요청 완료` : `${result.item.label} 재기동 완료`,
|
||||
);
|
||||
} catch (error) {
|
||||
const targetLabel = items.find((item) => item.key === key)?.label ?? key.toUpperCase();
|
||||
const detail = error instanceof Error ? error.message : '서버 재기동에 실패했습니다.';
|
||||
setRestartErrorInfo(buildRestartErrorInfo(targetLabel, detail));
|
||||
} finally {
|
||||
setRestartingKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyRestartError = async () => {
|
||||
if (!restartErrorInfo || copyingRestartError) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCopyingRestartError(true);
|
||||
|
||||
try {
|
||||
await copyText(`${restartErrorInfo.title}\n${restartErrorInfo.detail}`);
|
||||
messageApi.success('에러 메시지를 복사했습니다.');
|
||||
} catch {
|
||||
messageApi.error('에러 메시지 복사에 실패했습니다.');
|
||||
} finally {
|
||||
setCopyingRestartError(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!hasAccess) {
|
||||
return (
|
||||
<Card className="server-command-page__card" bordered={false}>
|
||||
<Paragraph className="app-main-copy">
|
||||
토큰 등록 사용자만 Server Command 메뉴를 사용할 수 있습니다.
|
||||
</Paragraph>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} className="server-command-page">
|
||||
{contextHolder}
|
||||
<Card className="server-command-page__card" bordered={false}>
|
||||
<Space direction="vertical" size={8}>
|
||||
<Title level={4} className="server-command-page__title">
|
||||
Server Command
|
||||
</Title>
|
||||
<Paragraph className="server-command-page__copy">
|
||||
TEST, REL, WORK-SERVER, COMMAND-RUNNER 상태를 확인하고 허용된 재기동 명령만 실행합니다.
|
||||
</Paragraph>
|
||||
<Row gutter={[12, 12]} className="server-command-page__summary-grid">
|
||||
<Col xs={12} md={6}>
|
||||
<Statistic title="전체" value={summary.total} />
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Statistic title="ONLINE" value={summary.online} valueStyle={{ color: '#389e0d' }} />
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Statistic title="DEGRADED" value={summary.degraded} valueStyle={{ color: '#d48806' }} />
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Statistic title="OFFLINE" value={summary.offline} valueStyle={{ color: '#cf1322' }} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Space wrap>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadItems()} loading={loading}>
|
||||
새로고침
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{restartErrorInfo ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type="error"
|
||||
message="재기동 에러"
|
||||
description={
|
||||
<Space direction="vertical" size={8} className="server-command-page__alert-body">
|
||||
<Text strong>{restartErrorInfo.title}</Text>
|
||||
{restartErrorInfo.missingScriptPath ? (
|
||||
<Text code className="server-command-page__alert-code">
|
||||
{restartErrorInfo.missingScriptPath}
|
||||
</Text>
|
||||
) : null}
|
||||
<span className="server-command-page__alert-text">{restartErrorInfo.detail}</span>
|
||||
</Space>
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
loading={copyingRestartError}
|
||||
aria-label="에러 메시지 복사"
|
||||
onClick={() => {
|
||||
void handleCopyRestartError();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<DataStatePanel state="loading" title="서버 상태를 확인하는 중입니다." />
|
||||
) : errorMessage ? (
|
||||
<DataStatePanel
|
||||
state="error"
|
||||
title="서버 명령 메뉴를 불러오지 못했습니다."
|
||||
description={errorMessage}
|
||||
actions={
|
||||
<Button type="primary" onClick={() => void loadItems()}>
|
||||
다시 시도
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : items.length === 0 ? (
|
||||
<Card className="server-command-page__card" bordered={false}>
|
||||
<Empty description="표시할 서버가 없습니다." />
|
||||
</Card>
|
||||
) : (
|
||||
<div className="server-command-page__grid">
|
||||
{items.map((item) => (
|
||||
<Card
|
||||
key={item.key}
|
||||
className="server-command-page__server-card"
|
||||
bordered={false}
|
||||
title={
|
||||
<Space size={8} wrap className="server-command-page__title-row">
|
||||
<Title level={5} className="server-command-page__server-title">
|
||||
{item.label}
|
||||
</Title>
|
||||
{resolveAvailabilityTag(item)}
|
||||
{item.composeStatus ? <Tag color="blue">{item.composeStatus}</Tag> : null}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
className="server-command-page__restart-button"
|
||||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={restartingKey === item.key}
|
||||
onClick={() => {
|
||||
void handleRestart(item.key);
|
||||
}}
|
||||
>
|
||||
{item.label} 재기동
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" size={14} style={{ width: '100%' }}>
|
||||
<Paragraph className="server-command-page__summary">{item.summary}</Paragraph>
|
||||
<Descriptions
|
||||
size="small"
|
||||
column={1}
|
||||
className="server-command-page__meta"
|
||||
items={[
|
||||
{
|
||||
key: 'environment',
|
||||
label: '환경',
|
||||
children: item.environment,
|
||||
},
|
||||
{
|
||||
key: 'started-at',
|
||||
label: '시작일시',
|
||||
children: formatDateTime(item.startedAt),
|
||||
},
|
||||
{
|
||||
key: 'response-time',
|
||||
label: '응답시간',
|
||||
children: formatResponseTime(item.responseTimeMs),
|
||||
},
|
||||
{
|
||||
key: 'http-status',
|
||||
label: 'HTTP',
|
||||
children: formatStatusCode(item.httpStatus),
|
||||
},
|
||||
{
|
||||
key: 'checked-at',
|
||||
label: '확인시각',
|
||||
children: formatDateTime(item.checkedAt),
|
||||
},
|
||||
{
|
||||
key: 'content-type',
|
||||
label: 'Content-Type',
|
||||
children: formatContentType(item.contentType),
|
||||
},
|
||||
{
|
||||
key: 'service',
|
||||
label: '서비스',
|
||||
children: item.serviceName,
|
||||
},
|
||||
{
|
||||
key: 'compose-file',
|
||||
label: 'Compose',
|
||||
children: item.composeFile,
|
||||
},
|
||||
{
|
||||
key: 'command-script',
|
||||
label: 'Script',
|
||||
children: item.commandScript,
|
||||
},
|
||||
{
|
||||
key: 'working-directory',
|
||||
label: '작업경로',
|
||||
children: item.commandWorkingDirectory,
|
||||
},
|
||||
{
|
||||
key: 'host',
|
||||
label: '호스트',
|
||||
children: resolveHostLabel(item.publicUrl ?? item.checkUrl),
|
||||
},
|
||||
{
|
||||
key: 'public-url',
|
||||
label: 'URL',
|
||||
children: item.publicUrl ? (
|
||||
<Typography.Link href={item.publicUrl} target="_blank" rel="noreferrer">
|
||||
{item.publicUrl}
|
||||
</Typography.Link>
|
||||
) : (
|
||||
<Text type="secondary">내부 전용</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'check-url',
|
||||
label: '체크',
|
||||
children: item.checkUrl,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{item.composeDetails ? (
|
||||
<Text type="secondary" className="server-command-page__preview">
|
||||
{item.composeDetails}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{item.responsePreview ? (
|
||||
<Text type="secondary" className="server-command-page__preview">
|
||||
{item.responsePreview}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{item.errorMessage ? (
|
||||
<Text type="danger" className="server-command-page__preview">
|
||||
{item.errorMessage}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Text code className="server-command-page__command">
|
||||
{item.lastCommand}
|
||||
</Text>
|
||||
|
||||
{lastActionByKey[item.key]?.executedAt ? (
|
||||
<Text type="secondary" className="server-command-page__preview">
|
||||
{lastActionByKey[item.key].restartState === 'accepted' ? '최근 재기동 요청' : '최근 재기동 완료'}:{' '}
|
||||
{formatDateTime(lastActionByKey[item.key].executedAt)}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{lastActionByKey[item.key]?.output ? (
|
||||
<Text className="server-command-page__command">
|
||||
{lastActionByKey[item.key].output}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
251
src/features/serverCommand/api.ts
Executable file
251
src/features/serverCommand/api.ts
Executable file
@@ -0,0 +1,251 @@
|
||||
import { appendClientIdHeader } from '../../app/main/clientIdentity';
|
||||
import { getRegisteredAccessToken, isAllowedRegistrationToken } from '../../app/main/tokenAccess';
|
||||
import type { ServerCommandActionResult, ServerCommandItem, ServerCommandKey } from './types';
|
||||
|
||||
class ServerCommandApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = 'ServerCommandApiError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveServerCommandApiBaseUrl() {
|
||||
if (import.meta.env.VITE_WORK_SERVER_URL) {
|
||||
return import.meta.env.VITE_WORK_SERVER_URL;
|
||||
}
|
||||
|
||||
return '/api';
|
||||
}
|
||||
|
||||
function resolveServerCommandFallbackBaseUrl() {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostname = window.location.hostname;
|
||||
const isLocalWorkServerHost =
|
||||
hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0';
|
||||
|
||||
if (!isLocalWorkServerHost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallbackUrl = new URL(window.location.origin);
|
||||
fallbackUrl.port = '3100';
|
||||
fallbackUrl.pathname = '/api';
|
||||
fallbackUrl.search = '';
|
||||
fallbackUrl.hash = '';
|
||||
return fallbackUrl.toString().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
const SERVER_COMMAND_API_BASE_URL = resolveServerCommandApiBaseUrl();
|
||||
const SERVER_COMMAND_API_FALLBACK_BASE_URL =
|
||||
!import.meta.env.VITE_WORK_SERVER_URL && SERVER_COMMAND_API_BASE_URL === '/api'
|
||||
? resolveServerCommandFallbackBaseUrl()
|
||||
: null;
|
||||
|
||||
async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit) {
|
||||
const headers = appendClientIdHeader(init?.headers);
|
||||
const hasBody = init?.body !== undefined && init?.body !== null;
|
||||
const method = init?.method?.toUpperCase() ?? 'GET';
|
||||
const controller = new AbortController();
|
||||
const timeoutId = globalThis.setTimeout(() => controller.abort(), 8000);
|
||||
|
||||
if (hasBody && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
const token = getRegisteredAccessToken();
|
||||
if (!isAllowedRegistrationToken(token)) {
|
||||
throw new ServerCommandApiError('권한 토큰 등록 후에만 Work Server API를 호출할 수 있습니다.', 403);
|
||||
}
|
||||
|
||||
if (token && !headers.has('X-Access-Token')) {
|
||||
headers.set('X-Access-Token', token);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
cache: init?.cache ?? (method === 'GET' ? 'no-store' : undefined),
|
||||
});
|
||||
} catch (error) {
|
||||
globalThis.clearTimeout(timeoutId);
|
||||
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new ServerCommandApiError('서버 명령 응답이 지연됩니다.', 408);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
globalThis.clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(text) as { message?: string };
|
||||
throw new ServerCommandApiError(payload.message || '서버 명령 요청에 실패했습니다.', response.status);
|
||||
} catch {
|
||||
throw new ServerCommandApiError(text || '서버 명령 요청에 실패했습니다.', response.status);
|
||||
}
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
try {
|
||||
return await requestOnce<T>(SERVER_COMMAND_API_BASE_URL, path, init);
|
||||
} catch (error) {
|
||||
const shouldRetryWithFallback =
|
||||
SERVER_COMMAND_API_FALLBACK_BASE_URL &&
|
||||
SERVER_COMMAND_API_FALLBACK_BASE_URL !== SERVER_COMMAND_API_BASE_URL &&
|
||||
(error instanceof ServerCommandApiError
|
||||
? error.status === 404 || error.status === 408 || error.status === 502
|
||||
: error instanceof Error && /404|Failed to fetch|NetworkError/i.test(error.message));
|
||||
|
||||
if (!shouldRetryWithFallback) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return requestOnce<T>(SERVER_COMMAND_API_FALLBACK_BASE_URL, path, init);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeServerCommandItem(value: unknown): ServerCommandItem {
|
||||
if (!value || typeof value !== 'object') {
|
||||
throw new Error('서버 명령 항목 형식이 올바르지 않습니다.');
|
||||
}
|
||||
|
||||
const item = value as Partial<Record<keyof ServerCommandItem, unknown>>;
|
||||
const key = typeof item.key === 'string' ? item.key : '';
|
||||
|
||||
if (key !== 'test' && key !== 'rel' && key !== 'work-server' && key !== 'command-runner') {
|
||||
throw new Error('지원하지 않는 서버 키입니다.');
|
||||
}
|
||||
|
||||
const availability =
|
||||
item.availability === 'online' || item.availability === 'degraded' || item.availability === 'offline'
|
||||
? item.availability
|
||||
: 'offline';
|
||||
|
||||
return {
|
||||
key,
|
||||
label: typeof item.label === 'string' ? item.label : key.toUpperCase(),
|
||||
summary: typeof item.summary === 'string' ? item.summary : '',
|
||||
environment: typeof item.environment === 'string' ? item.environment : '-',
|
||||
publicUrl: typeof item.publicUrl === 'string' ? item.publicUrl : null,
|
||||
checkUrl: typeof item.checkUrl === 'string' ? item.checkUrl : '-',
|
||||
composeFile: typeof item.composeFile === 'string' ? item.composeFile : '-',
|
||||
serviceName: typeof item.serviceName === 'string' ? item.serviceName : '-',
|
||||
availability,
|
||||
httpStatus: typeof item.httpStatus === 'number' ? item.httpStatus : null,
|
||||
contentType: typeof item.contentType === 'string' ? item.contentType : null,
|
||||
responsePreview: typeof item.responsePreview === 'string' ? item.responsePreview : null,
|
||||
checkedAt: typeof item.checkedAt === 'string' ? item.checkedAt : new Date(0).toISOString(),
|
||||
startedAt: typeof item.startedAt === 'string' ? item.startedAt : null,
|
||||
runningVersion: typeof item.runningVersion === 'string' ? item.runningVersion : null,
|
||||
runningBuiltAt: typeof item.runningBuiltAt === 'string' ? item.runningBuiltAt : null,
|
||||
latestVersion: typeof item.latestVersion === 'string' ? item.latestVersion : null,
|
||||
latestBuiltAt: typeof item.latestBuiltAt === 'string' ? item.latestBuiltAt : null,
|
||||
latestSourceChangeAt: typeof item.latestSourceChangeAt === 'string' ? item.latestSourceChangeAt : null,
|
||||
latestSourceChangePath: typeof item.latestSourceChangePath === 'string' ? item.latestSourceChangePath : null,
|
||||
buildRequired: typeof item.buildRequired === 'boolean' ? item.buildRequired : false,
|
||||
updateAvailable: typeof item.updateAvailable === 'boolean' ? item.updateAvailable : false,
|
||||
updateSummary: typeof item.updateSummary === 'string' ? item.updateSummary : null,
|
||||
responseTimeMs: typeof item.responseTimeMs === 'number' ? item.responseTimeMs : null,
|
||||
composeStatus: typeof item.composeStatus === 'string' ? item.composeStatus : null,
|
||||
composeDetails: typeof item.composeDetails === 'string' ? item.composeDetails : null,
|
||||
lastCommand: typeof item.lastCommand === 'string' ? item.lastCommand : '-',
|
||||
commandScript: typeof item.commandScript === 'string' ? item.commandScript : '-',
|
||||
commandWorkingDirectory: typeof item.commandWorkingDirectory === 'string' ? item.commandWorkingDirectory : '-',
|
||||
errorMessage: typeof item.errorMessage === 'string' ? item.errorMessage : null,
|
||||
};
|
||||
}
|
||||
|
||||
function extractServerCommandItems(response: unknown) {
|
||||
if (Array.isArray(response)) {
|
||||
return response.map((item) => normalizeServerCommandItem(item));
|
||||
}
|
||||
|
||||
if (!response || typeof response !== 'object') {
|
||||
throw new Error('서버 명령 응답 형식이 올바르지 않습니다.');
|
||||
}
|
||||
|
||||
const payload = response as {
|
||||
items?: unknown;
|
||||
data?: { items?: unknown } | unknown[];
|
||||
};
|
||||
const items = Array.isArray(payload.items)
|
||||
? payload.items
|
||||
: payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data) && Array.isArray(payload.data.items)
|
||||
? payload.data.items
|
||||
: Array.isArray(payload.data)
|
||||
? payload.data
|
||||
: null;
|
||||
|
||||
if (!items) {
|
||||
throw new Error('서버 명령 목록을 읽지 못했습니다.');
|
||||
}
|
||||
|
||||
return items.map((item) => normalizeServerCommandItem(item));
|
||||
}
|
||||
|
||||
function extractServerCommandActionResult(response: unknown): ServerCommandActionResult {
|
||||
if (!response || typeof response !== 'object') {
|
||||
throw new Error('서버 재기동 응답 형식이 올바르지 않습니다.');
|
||||
}
|
||||
|
||||
const payload = response as {
|
||||
item?: unknown;
|
||||
server?: unknown;
|
||||
commandOutput?: unknown;
|
||||
output?: unknown;
|
||||
restartState?: unknown;
|
||||
data?: {
|
||||
item?: unknown;
|
||||
server?: unknown;
|
||||
commandOutput?: unknown;
|
||||
output?: unknown;
|
||||
restartState?: unknown;
|
||||
};
|
||||
};
|
||||
const nestedData = payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data) ? payload.data : null;
|
||||
const item = payload.item ?? payload.server ?? nestedData?.item ?? nestedData?.server;
|
||||
|
||||
if (!item) {
|
||||
throw new Error('재기동 결과를 읽지 못했습니다.');
|
||||
}
|
||||
|
||||
const commandOutput = payload.commandOutput ?? payload.output ?? nestedData?.commandOutput ?? nestedData?.output;
|
||||
const restartState = payload.restartState ?? nestedData?.restartState;
|
||||
|
||||
return {
|
||||
item: normalizeServerCommandItem(item),
|
||||
commandOutput: typeof commandOutput === 'string' ? commandOutput : null,
|
||||
restartState: restartState === 'accepted' ? 'accepted' : 'completed',
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchServerCommands() {
|
||||
const response = await request<unknown>('/server-commands');
|
||||
return extractServerCommandItems(response);
|
||||
}
|
||||
|
||||
export async function restartServerCommand(key: ServerCommandKey) {
|
||||
const response = await request<unknown>(`/server-commands/${key}/actions/restart`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
return extractServerCommandActionResult(response);
|
||||
}
|
||||
1
src/features/serverCommand/index.ts
Executable file
1
src/features/serverCommand/index.ts
Executable file
@@ -0,0 +1 @@
|
||||
export { ServerCommandPage } from './ServerCommandPage';
|
||||
124
src/features/serverCommand/serverCommand.css
Executable file
124
src/features/serverCommand/serverCommand.css
Executable file
@@ -0,0 +1,124 @@
|
||||
.server-command-page {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.server-command-page .ant-alert-description {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.server-command-page__alert-body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.server-command-page__alert-code {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: #fff2f0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
.server-command-page__card,
|
||||
.server-command-page__server-card {
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.server-command-page__server-card {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.server-command-page__title.ant-typography,
|
||||
.server-command-page__server-title.ant-typography {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.server-command-page__title-row {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.server-command-page__title-row .ant-space-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.server-command-page__copy.ant-typography {
|
||||
max-width: 760px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.server-command-page__summary-grid {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.server-command-page__summary-grid .ant-statistic {
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
background: #f7faff;
|
||||
}
|
||||
|
||||
.server-command-page__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.server-command-page__summary.ant-typography,
|
||||
.server-command-page__preview.ant-typography,
|
||||
.server-command-page__command.ant-typography {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.server-command-page__preview,
|
||||
.server-command-page__command {
|
||||
display: block;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
background: #f7faff;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.server-command-page__alert-text {
|
||||
display: block;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
-webkit-touch-callout: default;
|
||||
}
|
||||
|
||||
.server-command-page__meta .ant-descriptions-item-label {
|
||||
width: 104px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.server-command-page__server-card .ant-card-head {
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.server-command-page__server-card .ant-card-head-wrapper {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.server-command-page__server-card .ant-card-extra {
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
|
||||
.server-command-page__restart-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.server-command-page__server-card .ant-card-body {
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.server-command-page__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
40
src/features/serverCommand/types.ts
Executable file
40
src/features/serverCommand/types.ts
Executable file
@@ -0,0 +1,40 @@
|
||||
export type ServerCommandKey = 'test' | 'rel' | 'work-server' | 'command-runner';
|
||||
|
||||
export type ServerCommandItem = {
|
||||
key: ServerCommandKey;
|
||||
label: string;
|
||||
summary: string;
|
||||
environment: string;
|
||||
publicUrl: string | null;
|
||||
checkUrl: string;
|
||||
composeFile: string;
|
||||
serviceName: string;
|
||||
availability: 'online' | 'degraded' | 'offline';
|
||||
httpStatus: number | null;
|
||||
contentType: string | null;
|
||||
responsePreview: string | null;
|
||||
checkedAt: string;
|
||||
startedAt: string | null;
|
||||
runningVersion: string | null;
|
||||
runningBuiltAt: string | null;
|
||||
latestVersion: string | null;
|
||||
latestBuiltAt: string | null;
|
||||
latestSourceChangeAt: string | null;
|
||||
latestSourceChangePath: string | null;
|
||||
buildRequired: boolean;
|
||||
updateAvailable: boolean;
|
||||
updateSummary: string | null;
|
||||
responseTimeMs: number | null;
|
||||
composeStatus: string | null;
|
||||
composeDetails: string | null;
|
||||
lastCommand: string;
|
||||
commandScript: string;
|
||||
commandWorkingDirectory: string;
|
||||
errorMessage: string | null;
|
||||
};
|
||||
|
||||
export type ServerCommandActionResult = {
|
||||
item: ServerCommandItem;
|
||||
commandOutput: string | null;
|
||||
restartState: 'completed' | 'accepted';
|
||||
};
|
||||
Reference in New Issue
Block a user