251 lines
8.2 KiB
TypeScript
251 lines
8.2 KiB
TypeScript
/**
|
|
* 검증게시판 — 좌 목록 + 우 상세
|
|
*/
|
|
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;
|