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