201 lines
6.1 KiB
TypeScript
201 lines
6.1 KiB
TypeScript
/**
|
|
* 검증게시판 — 우측 이슈 상세 (인라인 편집 + 저장)
|
|
*/
|
|
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';
|
|
import { formatIsoDateTime } from '../../utils/timezone';
|
|
|
|
interface Props {
|
|
issue: VerificationIssueDto | null;
|
|
attachmentsRefreshKey?: number;
|
|
saving?: boolean;
|
|
onSave?: (
|
|
dto: VerificationIssueDto,
|
|
pendingImages: File[],
|
|
) => void | Promise<void>;
|
|
onAttachmentsChanged?: () => void;
|
|
}
|
|
|
|
const VerificationIssueDetailPanel: React.FC<Props> = ({
|
|
issue,
|
|
attachmentsRefreshKey = 0,
|
|
saving = false,
|
|
onSave,
|
|
onAttachmentsChanged,
|
|
}) => {
|
|
const [title, setTitle] = useState('');
|
|
const [content, setContent] = useState('');
|
|
const [reproductionPath, setReproductionPath] = useState('');
|
|
const [stage, setStage] = useState<VerificationIssueStage>('REGISTERED');
|
|
const [pendingImages, setPendingImages] = useState<File[]>([]);
|
|
|
|
const resetFromIssue = useCallback((src: VerificationIssueDto) => {
|
|
setTitle(src.title ?? '');
|
|
setContent(src.content ?? '');
|
|
setReproductionPath(src.reproductionPath ?? '');
|
|
setStage(src.stage ?? 'REGISTERED');
|
|
setPendingImages([]);
|
|
}, []);
|
|
|
|
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
|
|
);
|
|
}, [issue, title, content, reproductionPath, stage, pendingImages]);
|
|
|
|
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,
|
|
);
|
|
};
|
|
|
|
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>등록 {formatIsoDateTime(issue.createdAt)}</span>
|
|
<span>수정 {formatIsoDateTime(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}
|
|
disabled={saving}
|
|
onAttachmentsChanged={onAttachmentsChanged}
|
|
/>
|
|
)}
|
|
|
|
{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;
|