Initial import

This commit is contained in:
how2ice
2026-04-21 03:33:23 +09:00
commit 9e4b70f1f1
495 changed files with 94680 additions and 0 deletions

795
src/features/board/BoardPage.tsx Executable file
View 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
View 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
View File

@@ -0,0 +1,2 @@
export { BoardPage } from './BoardPage';
export type { BoardDraft, BoardPost } from './types';

28
src/features/board/types.ts Executable file
View 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;
};

View 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>
);
}

View 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>
);
}

View 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
View 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
View File

@@ -0,0 +1 @@
export { HistoryPage } from './HistoryPage';

22
src/features/history/types.ts Executable file
View 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
View File

@@ -0,0 +1,14 @@
# Layout Feature
프로젝트 종속적인 레이아웃은 `src/features/layout` 아래에서 관리합니다.
## 포함 항목
- 컴포넌트 샘플 레이아웃
- 위젯 샘플 레이아웃
- Markdown preview 리스트 레이아웃
## 규칙
- 공통 레이아웃 시스템이 아니라 현재 프로젝트 화면에 종속되면 `features/layout`에 배치
- 공통 재사용 가능성이 높으면 `components` 또는 `widgets`로 승격 검토

View 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>
);
}

View File

@@ -0,0 +1,2 @@
export { ComponentSamplesLayout } from './ComponentSamplesLayout';
export type { ComponentSamplesLayoutProps } from './ComponentSamplesLayout';

View File

@@ -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>
);
}

View File

@@ -0,0 +1 @@
export { DashboardFeatureGalleryLayout } from './DashboardFeatureGalleryLayout';

View File

@@ -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>
);
}

View File

@@ -0,0 +1 @@
export { DashboardReportGalleryLayout } from './DashboardReportGalleryLayout';

View 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>
);
}

View File

@@ -0,0 +1 @@
export { DocsMarkdownPreviewLayout } from './DocsMarkdownPreviewLayout';

View File

@@ -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 문서가 없습니다."
/>
);
}

View File

@@ -0,0 +1 @@
export { FeatureMarkdownPreviewListLayout } from './FeatureMarkdownPreviewListLayout';

View 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>
);
}

View File

@@ -0,0 +1 @@
export { WidgetRegistryLayout } from './WidgetRegistryLayout';

View 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>
);
}

View File

@@ -0,0 +1,2 @@
export { SampleWidgetsLayout } from './SampleWidgetsLayout';
export type { SampleWidgetsLayoutProps } from './SampleWidgetsLayout';

View File

@@ -0,0 +1,3 @@
import { MarkdownPreviewCard } from '../../components/markdownPreview';
export const FeatureMarkdownPreviewCard = MarkdownPreviewCard;

View File

@@ -0,0 +1 @@
export { FeatureMarkdownPreviewCard } from './FeatureMarkdownPreviewCard';

9
src/features/overview.md Executable file
View File

@@ -0,0 +1,9 @@
# Features Overview
이 영역은 현재 프로젝트에 종속된 기능과 화면 구성을 관리합니다.
## 목적
- 공통 `components`, `widgets`와 분리된 프로젝트 전용 기능 관리
- 기능별 문서, 화면 조합, 레이아웃을 한 곳에서 정리
- 향후 `dashboard`, `sampleBoard`, `docsViewer` 같은 프로젝트 전용 기능 확장

File diff suppressed because it is too large Load Diff

View 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}
</>
);
}

View 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>
);
}

View 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
View 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
View 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
View 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';

View 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('');
}

View 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;
}
}

View 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;
}
}

View 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
View 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;
};

View 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
View 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);
}

View File

@@ -0,0 +1 @@
export { ServerCommandPage } from './ServerCommandPage';

View 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;
}
}

View 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';
};