검증게시판 기능 추가
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user