검증게시판 기능 추가

This commit is contained in:
Macbook
2026-05-27 15:41:19 +09:00
parent 7a0af36b9b
commit 63693d47c0
57 changed files with 4480 additions and 14 deletions
@@ -0,0 +1,250 @@
/**
* 검증게시판 — 좌 목록 + 우 상세
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import BuilderPageShell from './layout/BuilderPageShell';
import VerificationIssueListPanel from './verificationBoard/VerificationIssueListPanel';
import VerificationIssueDetailPanel from './verificationBoard/VerificationIssueDetailPanel';
import VerificationIssueFormModal from './verificationBoard/VerificationIssueFormModal';
import type { Theme } from '../types';
import type { VerificationIssueStage } from '../utils/verificationBoardStages';
import {
deleteVerificationIssue,
deleteVerificationIssueImage,
loadVerificationIssues,
saveVerificationIssue,
uploadVerificationIssueImages,
type VerificationIssueDto,
} from '../utils/verificationBoardApi';
import { prepareImagesForUpload } from '../utils/clipboardImageFiles';
import '../styles/verificationBoard.css';
interface Props {
theme?: Theme;
focusIssueId?: number | null;
onFocusIssueConsumed?: () => void;
}
const IconAdd = () => (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
<path d="M9 3v12M3 9h12" />
</svg>
);
const IconEdit = () => (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.5 2.5l3 3L6 15H3v-3L12.5 2.5z" />
</svg>
);
const IconDelete = () => (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 5h12M7 5V3h4v2M6 5l.5 10h5L12 5" />
</svg>
);
const VerificationBoardPage: React.FC<Props> = ({
theme = 'dark',
focusIssueId = null,
onFocusIssueConsumed,
}) => {
const [issues, setIssues] = useState<VerificationIssueDto[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [stageFilter, setStageFilter] = useState<VerificationIssueStage | ''>('');
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editTarget, setEditTarget] = useState<VerificationIssueDto | null>(null);
const [saving, setSaving] = useState(false);
const [detailSaving, setDetailSaving] = useState(false);
const [attachmentsRefreshKey, setAttachmentsRefreshKey] = useState(0);
const reload = useCallback(async (stage?: VerificationIssueStage | '') => {
setLoading(true);
try {
const list = await loadVerificationIssues(stage ?? stageFilter);
setIssues(list);
setSelectedId(prev => {
if (prev != null && list.some(i => i.id === prev)) return prev;
return list[0]?.id ?? null;
});
} catch (e) {
console.warn('[VerificationBoard] load', e);
window.alert('검증 이슈 목록을 불러오지 못했습니다.');
} finally {
setLoading(false);
}
}, [stageFilter]);
useEffect(() => {
void reload(stageFilter);
}, [stageFilter]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (focusIssueId == null) return;
setSelectedId(focusIssueId);
setStageFilter('');
onFocusIssueConsumed?.();
}, [focusIssueId, onFocusIssueConsumed]);
const selected = useMemo(
() => issues.find(i => i.id === selectedId) ?? null,
[issues, selectedId],
);
const handleSelect = useCallback((issue: VerificationIssueDto) => {
if (issue.id != null) setSelectedId(issue.id);
}, []);
const handleAdd = useCallback(() => {
setEditTarget(null);
setModalOpen(true);
}, []);
const handleEdit = useCallback(() => {
if (!selected) {
window.alert('수정할 이슈를 선택하세요.');
return;
}
setEditTarget(selected);
setModalOpen(true);
}, [selected]);
const handleDelete = useCallback(async () => {
if (!selected?.id) {
window.alert('삭제할 이슈를 선택하세요.');
return;
}
if (!window.confirm(`${selected.title}」 이슈를 삭제할까요?`)) return;
try {
await deleteVerificationIssue(selected.id);
await reload(stageFilter);
} catch (e) {
console.warn('[VerificationBoard] delete', e);
window.alert('삭제에 실패했습니다.');
}
}, [selected, reload, stageFilter]);
const handleSave = useCallback(async (dto: VerificationIssueDto, pendingImages: File[]) => {
setSaving(true);
try {
const saved = await saveVerificationIssue(dto);
if (pendingImages.length > 0 && saved.id != null) {
await uploadVerificationIssueImages(saved.id, pendingImages);
}
setModalOpen(false);
setEditTarget(null);
setAttachmentsRefreshKey(k => k + 1);
await reload(stageFilter);
if (saved.id != null) setSelectedId(saved.id);
} catch (e) {
console.warn('[VerificationBoard] save', e);
window.alert('저장에 실패했습니다.');
} finally {
setSaving(false);
}
}, [reload, stageFilter]);
const handleDetailSave = useCallback(async (
dto: VerificationIssueDto,
pendingImages: File[],
pendingDeleteIds: number[],
) => {
if (dto.id == null) return;
setDetailSaving(true);
try {
const saved = await saveVerificationIssue(dto);
const issueId = saved.id ?? dto.id;
for (const imageId of pendingDeleteIds) {
await deleteVerificationIssueImage(issueId, imageId);
}
if (pendingImages.length > 0) {
const prepared = await prepareImagesForUpload(pendingImages);
await uploadVerificationIssueImages(issueId, prepared);
}
setAttachmentsRefreshKey(k => k + 1);
await reload(stageFilter);
if (issueId != null) setSelectedId(issueId);
} catch (e) {
console.warn('[VerificationBoard] detail save', e);
window.alert('저장에 실패했습니다.');
} finally {
setDetailSaving(false);
}
}, [reload, stageFilter]);
return (
<>
<BuilderPageShell
theme={theme}
title="검증게시판"
subtitle="Golden QA · Verification Board"
pageClassName="bps-page--vbd"
loading={loading}
loadingText="검증 이슈 로딩 중…"
headerActions={(
<div className="vbd-header-actions">
<button type="button" className="vbd-icon-btn" onClick={handleAdd} title="이슈 추가">
<IconAdd />
<span className="vbd-icon-btn-label"></span>
</button>
<button
type="button"
className="vbd-icon-btn"
onClick={handleEdit}
disabled={!selected}
title="이슈 수정"
>
<IconEdit />
<span className="vbd-icon-btn-label"></span>
</button>
<button
type="button"
className="vbd-icon-btn vbd-icon-btn--danger"
onClick={() => void handleDelete()}
disabled={!selected}
title="이슈 삭제"
>
<IconDelete />
<span className="vbd-icon-btn-label"></span>
</button>
</div>
)}
leftStorageKey="vbd-left-width"
leftDefaultWidth={320}
leftCollapsedStorageKey="vbd-left-open"
collapsiblePanels
leftTitle="검증 이슈"
left={(
<VerificationIssueListPanel
issues={issues}
selectedId={selectedId}
stageFilter={stageFilter}
searchQuery={searchQuery}
onStageFilterChange={setStageFilter}
onSearchChange={setSearchQuery}
onSelect={handleSelect}
/>
)}
center={(
<VerificationIssueDetailPanel
issue={selected}
attachmentsRefreshKey={attachmentsRefreshKey}
saving={detailSaving}
onSave={handleDetailSave}
/>
)}
/>
<VerificationIssueFormModal
open={modalOpen}
initial={editTarget}
saving={saving}
onSave={(dto, pendingImages) => void handleSave(dto, pendingImages)}
onCancel={() => { setModalOpen(false); setEditTarget(null); }}
/>
</>
);
};
export default VerificationBoardPage;