검증게시판 기능 추가

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,210 @@
/**
* 검증게시판 — 우측 이슈 상세 (인라인 편집 + 저장)
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { VerificationIssueDto } from '../../utils/verificationBoardApi';
import {
VERIFICATION_ISSUE_STAGES,
stageColor,
type VerificationIssueStage,
} from '../../utils/verificationBoardStages';
import ReproductionPathInput from './ReproductionPathInput';
import VerificationIssueCommentsSection from './VerificationIssueCommentsSection';
import VerificationIssueAttachmentsSection from './VerificationIssueAttachmentsSection';
interface Props {
issue: VerificationIssueDto | null;
attachmentsRefreshKey?: number;
saving?: boolean;
onSave?: (
dto: VerificationIssueDto,
pendingImages: File[],
pendingDeleteIds: number[],
) => void | Promise<void>;
}
function formatDateTime(iso?: string): string {
if (!iso) return '—';
try {
return new Date(iso).toLocaleString('ko-KR');
} catch { return iso; }
}
const VerificationIssueDetailPanel: React.FC<Props> = ({
issue,
attachmentsRefreshKey = 0,
saving = false,
onSave,
}) => {
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [reproductionPath, setReproductionPath] = useState('');
const [stage, setStage] = useState<VerificationIssueStage>('REGISTERED');
const [pendingImages, setPendingImages] = useState<File[]>([]);
const [pendingDeleteIds, setPendingDeleteIds] = useState<number[]>([]);
const resetFromIssue = useCallback((src: VerificationIssueDto) => {
setTitle(src.title ?? '');
setContent(src.content ?? '');
setReproductionPath(src.reproductionPath ?? '');
setStage(src.stage ?? 'REGISTERED');
setPendingImages([]);
setPendingDeleteIds([]);
}, []);
useEffect(() => {
if (issue) resetFromIssue(issue);
}, [issue?.id, attachmentsRefreshKey, resetFromIssue]); // eslint-disable-line react-hooks/exhaustive-deps
const dirty = useMemo(() => {
if (!issue) return false;
const pathTrimmed = reproductionPath.trim();
const issuePath = (issue.reproductionPath ?? '').trim();
return (
title.trim() !== (issue.title ?? '').trim()
|| content.trim() !== (issue.content ?? '').trim()
|| pathTrimmed !== issuePath
|| stage !== issue.stage
|| pendingImages.length > 0
|| pendingDeleteIds.length > 0
);
}, [issue, title, content, reproductionPath, stage, pendingImages, pendingDeleteIds]);
const handleCancel = () => {
if (issue) resetFromIssue(issue);
};
const handleSave = () => {
if (!issue?.id || !onSave) return;
const trimmed = title.trim();
if (!trimmed) {
window.alert('제목을 입력하세요.');
return;
}
const pathTrimmed = reproductionPath.trim();
void onSave(
{
id: issue.id,
title: trimmed,
content: content.trim(),
reproductionPath: pathTrimmed || undefined,
stage,
},
pendingImages,
pendingDeleteIds,
);
};
if (!issue) {
return (
<div className="vbd-detail vbd-detail--empty">
<p> .</p>
</div>
);
}
return (
<div className="vbd-detail">
<div className="vbd-detail-scroll">
<div className="vbd-detail-title-row">
<input
type="text"
className="vbd-detail-title-input"
value={title}
onChange={e => setTitle(e.target.value)}
maxLength={300}
disabled={saving}
placeholder="검증 이슈 제목"
aria-label="제목"
/>
<select
value={stage}
onChange={e => setStage(e.target.value as VerificationIssueStage)}
disabled={saving}
className="vbd-detail-stage-select"
aria-label="단계"
title="처리 단계"
style={{
borderColor: `${stageColor(stage)}55`,
color: stageColor(stage),
backgroundColor: `${stageColor(stage)}14`,
}}
>
{VERIFICATION_ISSUE_STAGES.map(s => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
<div className="vbd-detail-meta">
<span> {formatDateTime(issue.createdAt)}</span>
<span> {formatDateTime(issue.updatedAt)}</span>
</div>
<label className="vbd-field">
<span className="vbd-field-label"></span>
<ReproductionPathInput
value={reproductionPath}
onChange={setReproductionPath}
disabled={saving}
/>
</label>
<label className="vbd-field vbd-field--grow">
<span className="vbd-field-label"></span>
<textarea
className="vbd-detail-content-input"
value={content}
onChange={e => setContent(e.target.value)}
rows={12}
disabled={saving}
placeholder="문제점·재현 방법·기대 결과 등"
/>
</label>
{issue.id != null && (
<VerificationIssueAttachmentsSection
key={`${issue.id}-${attachmentsRefreshKey}`}
issueId={issue.id}
deferUpload
draftFiles={pendingImages}
onDraftFilesChange={setPendingImages}
pendingDeleteIds={pendingDeleteIds}
onPendingDeleteIdsChange={setPendingDeleteIds}
disabled={saving}
/>
)}
{issue.id != null && (
<VerificationIssueCommentsSection issueId={issue.id} />
)}
</div>
<div className="vbd-detail-footer">
<span className="vbd-detail-dirty-hint">
{dirty ? '저장하지 않은 변경 사항이 있습니다.' : ''}
</span>
<div className="vbd-detail-footer-actions">
<button
type="button"
className="vbd-btn"
onClick={handleCancel}
disabled={saving || !dirty}
>
</button>
<button
type="button"
className="vbd-btn vbd-btn--primary"
onClick={handleSave}
disabled={saving || !dirty}
>
{saving ? '저장 중…' : '저장'}
</button>
</div>
</div>
</div>
);
};
export default VerificationIssueDetailPanel;