검증게시판 기능 추가

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,115 @@
/**
* 재현경로 입력 — 직접 입력 + 관련 메뉴 경로 자동완성
*/
import React, { useEffect, useId, useRef, useState } from 'react';
import { searchAppNavigationPaths } from '../../utils/appNavigationPaths';
interface Props {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
placeholder?: string;
}
const ReproductionPathInput: React.FC<Props> = ({
value,
onChange,
disabled = false,
placeholder = '예: 메뉴-실시간 차트-지표 추가 버튼',
}) => {
const listId = useId();
const wrapRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [activeIdx, setActiveIdx] = useState(-1);
const suggestions = searchAppNavigationPaths(value);
useEffect(() => {
setActiveIdx(-1);
}, [value, suggestions.length]);
useEffect(() => {
const onDoc = (e: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
}, []);
const pick = (path: string) => {
onChange(path);
setOpen(false);
setActiveIdx(-1);
};
const showList = open && !disabled && suggestions.length > 0;
return (
<div className="vbd-path-wrap" ref={wrapRef}>
<input
type="text"
className="vbd-path-input"
value={value}
disabled={disabled}
placeholder={placeholder}
autoComplete="off"
aria-autocomplete="list"
aria-controls={showList ? listId : undefined}
aria-expanded={showList}
onChange={e => {
onChange(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={e => {
if (!showList) {
if (e.key === 'ArrowDown' && suggestions.length > 0) {
setOpen(true);
setActiveIdx(0);
e.preventDefault();
}
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIdx(i => Math.min(i + 1, suggestions.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIdx(i => Math.max(i - 1, 0));
} else if (e.key === 'Enter' && activeIdx >= 0) {
e.preventDefault();
pick(suggestions[activeIdx]!.path);
} else if (e.key === 'Escape') {
setOpen(false);
setActiveIdx(-1);
}
}}
/>
{showList && (
<ul id={listId} className="vbd-path-suggest" role="listbox">
{suggestions.map((s, i) => (
<li key={s.path} role="presentation">
<button
type="button"
role="option"
aria-selected={i === activeIdx}
className={`vbd-path-suggest-item${i === activeIdx ? ' vbd-path-suggest-item--active' : ''}`}
onMouseDown={e => e.preventDefault()}
onClick={() => pick(s.path)}
>
{s.path}
</button>
</li>
))}
</ul>
)}
{open && value.trim() && suggestions.length === 0 && (
<p className="vbd-path-hint"> . .</p>
)}
</div>
);
};
export default ReproductionPathInput;
@@ -0,0 +1,394 @@
/**
* 검증 이슈 첨부 이미지 — 파일 선택·드래그·클립보드 붙여넣기
* issueId 있음: 서버 즉시 업로드 / 없음: draft 로컬 보관(등록 시 저장 후 업로드)
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
deleteVerificationIssueImage,
loadVerificationIssueImages,
uploadVerificationIssueImages,
verificationIssueImageUrl,
type VerificationIssueImageDto,
} from '../../utils/verificationBoardApi';
import {
filesFromClipboardRead,
filesFromDataTransfer,
pickImageFiles,
prepareImagesForUpload,
} from '../../utils/clipboardImageFiles';
function uploadErrorMessage(e: unknown): string {
if (e instanceof Error) {
if (e.message === 'IMAGE_TOO_LARGE') {
return '이미지가 너무 큽니다. 5MB 이하로 줄인 뒤 다시 시도하세요.';
}
if (e.message === 'IMAGE_READ_FAILED') {
return '이미지를 읽을 수 없습니다.';
}
if (e.message) return e.message;
}
return '이미지 업로드에 실패했습니다.';
}
interface Props {
/** 저장된 이슈 ID — 수정·상세 화면 */
issueId?: number | null;
/** 등록 draft — 저장 전 로컬 파일 */
draftFiles?: File[];
onDraftFilesChange?: (files: File[]) => void;
/** 상세 화면 — 저장 시 삭제할 서버 이미지 ID */
pendingDeleteIds?: number[];
onPendingDeleteIdsChange?: (ids: number[]) => void;
/** true: 추가·삭제를 저장 버튼까지 지연 (상세 편집) */
deferUpload?: boolean;
disabled?: boolean;
/** 모달 등 좁은 영역 */
compact?: boolean;
}
interface CtxMenuState {
x: number;
y: number;
}
const VerificationIssueAttachmentsSection: React.FC<Props> = ({
issueId = null,
draftFiles = [],
onDraftFilesChange,
pendingDeleteIds = [],
onPendingDeleteIdsChange,
deferUpload = false,
disabled = false,
compact = false,
}) => {
const isDraft = issueId == null;
const saveOnSubmit = deferUpload && issueId != null;
const [images, setImages] = useState<VerificationIssueImageDto[]>([]);
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [ctxMenu, setCtxMenu] = useState<CtxMenuState | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const zoneRef = useRef<HTMLDivElement>(null);
const reload = useCallback(async () => {
if (issueId == null) return;
setLoading(true);
try {
setImages(await loadVerificationIssueImages(issueId));
} catch (e) {
console.warn('[VerificationBoard] images load', e);
} finally {
setLoading(false);
}
}, [issueId]);
useEffect(() => {
if (issueId != null) void reload();
else setImages([]);
}, [issueId, reload]);
const draftPreviews = useMemo(
() => draftFiles.map((file, index) => ({
key: `${file.name}-${file.size}-${file.lastModified}-${index}`,
index,
fileName: file.name,
src: URL.createObjectURL(file),
})),
[draftFiles],
);
useEffect(() => () => {
draftPreviews.forEach(p => URL.revokeObjectURL(p.src));
}, [draftPreviews]);
useEffect(() => {
const close = () => setCtxMenu(null);
window.addEventListener('click', close);
window.addEventListener('scroll', close, true);
return () => {
window.removeEventListener('click', close);
window.removeEventListener('scroll', close, true);
};
}, []);
const addDraftFiles = useCallback(async (files: File[]) => {
const imgs = pickImageFiles(files);
if (imgs.length === 0) {
window.alert('이미지 파일(jpeg, png, gif, webp, bmp)만 첨부할 수 있습니다.');
return;
}
setUploading(true);
try {
const prepared = await prepareImagesForUpload(imgs);
onDraftFilesChange?.([...draftFiles, ...prepared]);
} catch (e) {
window.alert(uploadErrorMessage(e));
} finally {
setUploading(false);
}
}, [draftFiles, onDraftFilesChange]);
const uploadFiles = useCallback(async (files: File[]) => {
if (issueId == null || saveOnSubmit) {
await addDraftFiles(files);
return;
}
const imgs = pickImageFiles(files);
if (imgs.length === 0) {
window.alert('이미지 파일(jpeg, png, gif, webp, bmp)만 첨부할 수 있습니다.');
return;
}
setUploading(true);
try {
const prepared = await prepareImagesForUpload(imgs);
await uploadVerificationIssueImages(issueId, prepared);
await reload();
} catch (e) {
console.warn('[VerificationBoard] image upload', e);
window.alert(uploadErrorMessage(e));
} finally {
setUploading(false);
}
}, [issueId, saveOnSubmit, addDraftFiles, reload]);
const pasteFromClipboard = useCallback(async () => {
setCtxMenu(null);
try {
const files = await filesFromClipboardRead();
if (files.length === 0) {
window.alert('클립보드에 붙여넣을 수 있는 이미지(png, jpeg 등)가 없습니다.');
return;
}
await uploadFiles(files);
} catch (e) {
if (e instanceof Error && e.message === 'CLIPBOARD_UNSUPPORTED') {
window.alert('이 브라우저에서는 클립보드 이미지 읽기를 지원하지 않습니다.');
return;
}
window.alert('클립보드 접근이 거부되었습니다. 브라우저 권한을 확인하세요.');
}
}, [uploadFiles]);
const handleDeleteServer = async (imageId: number) => {
if (issueId == null) return;
if (!window.confirm('이 이미지를 삭제할까요?')) return;
if (saveOnSubmit) {
onPendingDeleteIdsChange?.(
pendingDeleteIds.includes(imageId)
? pendingDeleteIds
: [...pendingDeleteIds, imageId],
);
return;
}
try {
await deleteVerificationIssueImage(issueId, imageId);
await reload();
} catch (e) {
console.warn('[VerificationBoard] image delete', e);
window.alert('이미지 삭제에 실패했습니다.');
}
};
const handleDeleteDraft = (index: number) => {
onDraftFilesChange?.(draftFiles.filter((_, i) => i !== index));
};
const openImage = (src: string) => {
window.open(src, '_blank', 'noopener,noreferrer');
};
const handleContextMenu = (e: React.MouseEvent) => {
if (disabled) return;
e.preventDefault();
e.stopPropagation();
setCtxMenu({ x: e.clientX, y: e.clientY });
};
const handlePaste = (e: React.ClipboardEvent) => {
if (disabled) return;
const files = filesFromDataTransfer(e.clipboardData);
if (files.length === 0) return;
e.preventDefault();
void uploadFiles(files);
};
const handleDrop = (e: React.DragEvent) => {
if (disabled) return;
e.preventDefault();
setDragOver(false);
const fromTransfer = filesFromDataTransfer(e.dataTransfer);
if (fromTransfer.length > 0) void uploadFiles(fromTransfer);
};
const visibleServerImages = useMemo(
() => images.filter(img => img.id != null && !pendingDeleteIds.includes(img.id)),
[images, pendingDeleteIds],
);
const totalCount = isDraft || saveOnSubmit
? visibleServerImages.length + draftFiles.length
: images.length;
const showEmptyHint = !loading && totalCount === 0;
return (
<div className={`vbd-attachments${compact ? ' vbd-attachments--compact' : ''}`}>
<div className="vbd-attachments-head">
<h3 className="vbd-attachments-title">
{totalCount > 0 && `(${totalCount})`}
</h3>
<button
type="button"
className="vbd-btn vbd-btn--sm"
onClick={() => fileInputRef.current?.click()}
disabled={disabled || uploading}
>
</button>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp,image/bmp"
multiple
hidden
disabled={disabled}
onChange={e => {
const files = e.target.files;
if (files?.length) void uploadFiles(pickImageFiles(files));
e.target.value = '';
}}
/>
</div>
<div
ref={zoneRef}
className={`vbd-attach-zone${dragOver ? ' vbd-attach-zone--over' : ''}${disabled ? ' vbd-attach-zone--disabled' : ''}`}
tabIndex={disabled ? -1 : 0}
role="region"
aria-label="이미지 첨부 영역"
onContextMenu={handleContextMenu}
onPaste={handlePaste}
onDragEnter={e => { if (disabled) return; e.preventDefault(); setDragOver(true); }}
onDragOver={e => { if (disabled) return; e.preventDefault(); setDragOver(true); }}
onDragLeave={e => {
if (zoneRef.current && !zoneRef.current.contains(e.relatedTarget as Node)) {
setDragOver(false);
}
}}
onDrop={handleDrop}
>
{loading && issueId != null && !isDraft && images.length === 0 ? (
<p className="vbd-attach-hint"> </p>
) : showEmptyHint ? (
<p className="vbd-attach-hint">
{isDraft || saveOnSubmit
? '저장 시 함께 반영됩니다. 드래그·우클릭 붙여넣기·파일 선택'
: '이미지를 드래그하거나, 우클릭 → 클립보드 붙여넣기, 또는 「파일 선택」'}
</p>
) : (
<div className="vbd-attach-grid">
{(isDraft || saveOnSubmit) && visibleServerImages.map(img => {
if (img.id == null) return null;
const src = verificationIssueImageUrl(issueId!, img.id);
return (
<figure key={img.id} className="vbd-attach-item">
<button
type="button"
className="vbd-attach-thumb"
title={`${img.fileName ?? '첨부 이미지'} — 더블클릭하여 열기`}
onDoubleClick={() => openImage(src)}
>
<img src={src} alt={img.fileName ?? '첨부 이미지'} loading="lazy" draggable={false} />
</button>
<figcaption className="vbd-attach-caption" title={img.fileName}>
{img.fileName}
</figcaption>
<button
type="button"
className="vbd-attach-remove"
title="삭제"
disabled={disabled}
onClick={() => void handleDeleteServer(img.id!)}
>
</button>
</figure>
);
})}
{(isDraft || saveOnSubmit)
? draftPreviews.map(p => (
<figure key={p.key} className="vbd-attach-item">
<button
type="button"
className="vbd-attach-thumb"
title={`${p.fileName} — 더블클릭하여 열기`}
onDoubleClick={() => openImage(p.src)}
>
<img src={p.src} alt={p.fileName} draggable={false} />
</button>
<figcaption className="vbd-attach-caption" title={p.fileName}>
{p.fileName}
</figcaption>
<button
type="button"
className="vbd-attach-remove"
title="삭제"
disabled={disabled}
onClick={() => handleDeleteDraft(p.index)}
>
</button>
</figure>
))
: !saveOnSubmit && images.map(img => {
if (img.id == null) return null;
const src = verificationIssueImageUrl(issueId!, img.id);
return (
<figure key={img.id} className="vbd-attach-item">
<button
type="button"
className="vbd-attach-thumb"
title={`${img.fileName ?? '첨부 이미지'} — 더블클릭하여 열기`}
onDoubleClick={() => openImage(src)}
>
<img src={src} alt={img.fileName ?? '첨부 이미지'} loading="lazy" draggable={false} />
</button>
<figcaption className="vbd-attach-caption" title={img.fileName}>
{img.fileName}
</figcaption>
<button
type="button"
className="vbd-attach-remove"
title="삭제"
disabled={disabled}
onClick={() => void handleDeleteServer(img.id!)}
>
</button>
</figure>
);
})}
</div>
)}
{uploading && <div className="vbd-attach-uploading"> </div>}
</div>
{ctxMenu && (
<div
className="vbd-attach-ctx"
style={{ left: ctxMenu.x, top: ctxMenu.y }}
onClick={e => e.stopPropagation()}
>
<button type="button" onClick={() => { setCtxMenu(null); fileInputRef.current?.click(); }}>
</button>
<button type="button" onClick={() => void pasteFromClipboard()}>
</button>
</div>
)}
</div>
);
};
export default VerificationIssueAttachmentsSection;
@@ -0,0 +1,231 @@
/**
* 검증 이슈 댓글 — 목록·추가·수정·삭제
*/
import React, { useCallback, useEffect, useState } from 'react';
import { getAuthSession } from '../../utils/auth';
import {
createVerificationIssueComment,
deleteVerificationIssueComment,
loadVerificationIssueComments,
updateVerificationIssueComment,
type VerificationIssueCommentDto,
} from '../../utils/verificationBoardApi';
interface Props {
issueId: number;
}
function formatDateTime(iso?: string): string {
if (!iso) return '';
try {
return new Date(iso).toLocaleString('ko-KR', {
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
});
} catch { return ''; }
}
function defaultAuthorName(): string {
const session = getAuthSession();
if (session?.displayName) return session.displayName;
return '게스트';
}
const VerificationIssueCommentsSection: React.FC<Props> = ({ issueId }) => {
const [comments, setComments] = useState<VerificationIssueCommentDto[]>([]);
const [loading, setLoading] = useState(false);
const [newContent, setNewContent] = useState('');
const [submitting, setSubmitting] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [editContent, setEditContent] = useState('');
const reload = useCallback(async () => {
setLoading(true);
try {
const list = await loadVerificationIssueComments(issueId);
setComments(list);
} catch (e) {
console.warn('[VerificationBoard] comments load', e);
} finally {
setLoading(false);
}
}, [issueId]);
useEffect(() => {
setEditingId(null);
setEditContent('');
setNewContent('');
void reload();
}, [issueId, reload]);
const handleAdd = async () => {
const content = newContent.trim();
if (!content) {
window.alert('댓글 내용을 입력하세요.');
return;
}
setSubmitting(true);
try {
await createVerificationIssueComment(issueId, {
content,
authorName: defaultAuthorName(),
});
setNewContent('');
await reload();
} catch (e) {
console.warn('[VerificationBoard] comment add', e);
window.alert('댓글 등록에 실패했습니다.');
} finally {
setSubmitting(false);
}
};
const startEdit = (c: VerificationIssueCommentDto) => {
if (c.id == null) return;
setEditingId(c.id);
setEditContent(c.content);
};
const cancelEdit = () => {
setEditingId(null);
setEditContent('');
};
const handleSaveEdit = async (commentId: number) => {
const content = editContent.trim();
if (!content) {
window.alert('댓글 내용을 입력하세요.');
return;
}
setSubmitting(true);
try {
await updateVerificationIssueComment(issueId, commentId, { content });
cancelEdit();
await reload();
} catch (e) {
console.warn('[VerificationBoard] comment edit', e);
window.alert('댓글 수정에 실패했습니다.');
} finally {
setSubmitting(false);
}
};
const handleDelete = async (commentId: number) => {
if (!window.confirm('이 댓글을 삭제할까요?')) return;
setSubmitting(true);
try {
await deleteVerificationIssueComment(issueId, commentId);
if (editingId === commentId) cancelEdit();
await reload();
} catch (e) {
console.warn('[VerificationBoard] comment delete', e);
window.alert('댓글 삭제에 실패했습니다.');
} finally {
setSubmitting(false);
}
};
return (
<div className="vbd-comments">
<div className="vbd-comments-header">
<h3 className="vbd-comments-title"> {comments.length > 0 && `(${comments.length})`}</h3>
</div>
<div className="vbd-comments-list">
{loading && comments.length === 0 ? (
<p className="vbd-comments-empty"> </p>
) : comments.length === 0 ? (
<p className="vbd-comments-empty"> .</p>
) : comments.map(c => {
const isEditing = c.id === editingId;
return (
<article key={c.id} className="vbd-comment">
<div className="vbd-comment-head">
<span className="vbd-comment-author">{c.authorName ?? '익명'}</span>
<span className="vbd-comment-date">
{formatDateTime(c.updatedAt ?? c.createdAt)}
{c.updatedAt && c.createdAt && c.updatedAt !== c.createdAt && ' (수정됨)'}
</span>
<div className="vbd-comment-actions">
{!isEditing && (
<>
<button
type="button"
className="vbd-comment-action"
onClick={() => startEdit(c)}
disabled={submitting}
>
</button>
<button
type="button"
className="vbd-comment-action vbd-comment-action--danger"
onClick={() => c.id != null && void handleDelete(c.id)}
disabled={submitting}
>
</button>
</>
)}
</div>
</div>
{isEditing ? (
<div className="vbd-comment-edit">
<textarea
className="vbd-comment-textarea"
value={editContent}
onChange={e => setEditContent(e.target.value)}
rows={3}
disabled={submitting}
/>
<div className="vbd-comment-edit-actions">
<button
type="button"
className="vbd-btn vbd-btn--sm"
onClick={cancelEdit}
disabled={submitting}
>
</button>
<button
type="button"
className="vbd-btn vbd-btn--sm vbd-btn--primary"
onClick={() => c.id != null && void handleSaveEdit(c.id)}
disabled={submitting}
>
</button>
</div>
</div>
) : (
<p className="vbd-comment-body">{c.content}</p>
)}
</article>
);
})}
</div>
<div className="vbd-comment-compose">
<textarea
className="vbd-comment-textarea"
placeholder="댓글을 입력하세요…"
value={newContent}
onChange={e => setNewContent(e.target.value)}
rows={3}
disabled={submitting}
/>
<div className="vbd-comment-compose-actions">
<button
type="button"
className="vbd-btn vbd-btn--primary"
onClick={() => void handleAdd()}
disabled={submitting || !newContent.trim()}
>
{submitting ? '등록 중…' : '댓글 등록'}
</button>
</div>
</div>
</div>
);
};
export default VerificationIssueCommentsSection;
@@ -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;
@@ -0,0 +1,134 @@
/**
* 검증 이슈 등록·수정 모달
*/
import React, { useEffect, useState } from 'react';
import type { VerificationIssueDto } from '../../utils/verificationBoardApi';
import {
VERIFICATION_ISSUE_STAGES,
type VerificationIssueStage,
} from '../../utils/verificationBoardStages';
import ReproductionPathInput from './ReproductionPathInput';
import VerificationIssueAttachmentsSection from './VerificationIssueAttachmentsSection';
interface Props {
open: boolean;
initial: VerificationIssueDto | null;
saving: boolean;
onSave: (dto: VerificationIssueDto, pendingImages: File[]) => void;
onCancel: () => void;
}
const EMPTY: VerificationIssueDto = {
title: '',
content: '',
reproductionPath: '',
stage: 'REGISTERED',
};
const VerificationIssueFormModal: React.FC<Props> = ({
open, initial, saving, onSave, onCancel,
}) => {
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [reproductionPath, setReproductionPath] = useState('');
const [stage, setStage] = useState<VerificationIssueStage>('REGISTERED');
const [draftImages, setDraftImages] = useState<File[]>([]);
useEffect(() => {
if (!open) return;
setTitle(initial?.title ?? '');
setContent(initial?.content ?? '');
setReproductionPath(initial?.reproductionPath ?? '');
setStage(initial?.stage ?? 'REGISTERED');
setDraftImages([]);
}, [open, initial]);
if (!open) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = title.trim();
if (!trimmed) {
window.alert('제목을 입력하세요.');
return;
}
const pathTrimmed = reproductionPath.trim();
onSave({
...(initial?.id != null ? { id: initial.id } : {}),
title: trimmed,
content: content.trim(),
reproductionPath: pathTrimmed || undefined,
stage,
}, initial?.id == null ? draftImages : []);
};
return (
<div className="vbd-modal-overlay" onMouseDown={e => { if (e.target === e.currentTarget) onCancel(); }}>
<div className="vbd-modal vbd-modal--wide" role="dialog" aria-modal="true" aria-labelledby="vbd-modal-title">
<div className="vbd-modal-header">
<h3 id="vbd-modal-title">{initial?.id != null ? '검증 이슈 수정' : '검증 이슈 등록'}</h3>
<button type="button" className="vbd-modal-close" onClick={onCancel} aria-label="닫기"></button>
</div>
<form className="vbd-modal-form" onSubmit={handleSubmit}>
<label className="vbd-field">
<span className="vbd-field-label"></span>
<select
value={stage}
onChange={e => setStage(e.target.value as VerificationIssueStage)}
>
{VERIFICATION_ISSUE_STAGES.map(s => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</label>
<label className="vbd-field">
<span className="vbd-field-label"></span>
<input
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
maxLength={300}
autoFocus
placeholder="검증 이슈 제목"
/>
</label>
<label className="vbd-field">
<span className="vbd-field-label"></span>
<ReproductionPathInput
value={reproductionPath}
onChange={setReproductionPath}
disabled={saving}
/>
<span className="vbd-field-hint">
, .
</span>
</label>
<label className="vbd-field vbd-field--grow">
<span className="vbd-field-label"></span>
<textarea
value={content}
onChange={e => setContent(e.target.value)}
rows={10}
placeholder="문제점·재현 방법·기대 결과 등"
/>
</label>
<VerificationIssueAttachmentsSection
issueId={initial?.id ?? null}
draftFiles={draftImages}
onDraftFilesChange={setDraftImages}
disabled={saving}
compact
/>
<div className="vbd-modal-actions">
<button type="button" className="vbd-btn" onClick={onCancel} disabled={saving}></button>
<button type="submit" className="vbd-btn vbd-btn--primary" disabled={saving}>
{saving ? '저장 중…' : '저장'}
</button>
</div>
</form>
</div>
</div>
);
};
export default VerificationIssueFormModal;
@@ -0,0 +1,118 @@
/**
* 검증게시판 — 좌측 이슈 목록 (단계 필터 + 검색)
*/
import React, { useMemo } from 'react';
import type { VerificationIssueDto } from '../../utils/verificationBoardApi';
import {
VERIFICATION_ISSUE_STAGES,
stageColor,
stageLabel,
type VerificationIssueStage,
} from '../../utils/verificationBoardStages';
import VerificationStageIcon from './VerificationStageIcon';
interface Props {
issues: VerificationIssueDto[];
selectedId: number | null;
stageFilter: VerificationIssueStage | '';
searchQuery: string;
onStageFilterChange: (stage: VerificationIssueStage | '') => void;
onSearchChange: (q: string) => void;
onSelect: (issue: VerificationIssueDto) => void;
}
function formatDate(iso?: string): string {
if (!iso) return '';
try {
const d = new Date(iso);
return d.toLocaleString('ko-KR', {
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
});
} catch { return ''; }
}
const VerificationIssueListPanel: React.FC<Props> = ({
issues,
selectedId,
stageFilter,
searchQuery,
onStageFilterChange,
onSearchChange,
onSelect,
}) => {
const filtered = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) return issues;
return issues.filter(i =>
i.title.toLowerCase().includes(q)
|| i.content.toLowerCase().includes(q)
|| (i.reproductionPath?.toLowerCase().includes(q) ?? false),
);
}, [issues, searchQuery]);
return (
<div className="vbd-list-panel">
<div className="vbd-list-filters">
<select
className="vbd-stage-select"
value={stageFilter}
onChange={e => onStageFilterChange(e.target.value as VerificationIssueStage | '')}
aria-label="이슈 단계 필터"
>
<option value=""> </option>
{VERIFICATION_ISSUE_STAGES.map(s => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
<input
type="search"
className="vbd-search-input"
placeholder="제목·내용·재현경로 검색…"
value={searchQuery}
onChange={e => onSearchChange(e.target.value)}
aria-label="검색"
/>
</div>
<div className="vbd-list-count">
{filtered.length}
{searchQuery.trim() && issues.length !== filtered.length && (
<span className="vbd-list-count-sub"> / {issues.length} </span>
)}
</div>
<ul className="vbd-list">
{filtered.length === 0 ? (
<li className="vbd-list-empty"> .</li>
) : filtered.map(issue => {
const active = issue.id === selectedId;
return (
<li key={issue.id}>
<button
type="button"
className={`vbd-list-item${active ? ' vbd-list-item--active' : ''}`}
onClick={() => onSelect(issue)}
>
<VerificationStageIcon stage={issue.stage} size={34} />
<span className="vbd-list-item-body">
<span className="vbd-list-item-top">
<span
className="vbd-stage-badge"
style={{ backgroundColor: `${stageColor(issue.stage)}22`, color: stageColor(issue.stage) }}
>
{stageLabel(issue.stage)}
</span>
<span className="vbd-list-meta">{formatDate(issue.updatedAt ?? issue.createdAt)}</span>
</span>
<span className="vbd-list-title">{issue.title}</span>
</span>
</button>
</li>
);
})}
</ul>
</div>
);
};
export default VerificationIssueListPanel;
@@ -0,0 +1,86 @@
/**
* 검증 이슈 등록·단계 변경 팝업 알림
*/
import React from 'react';
import { useVerificationIssueNotification } from '../../contexts/VerificationIssueNotificationContext';
import VerificationStageIcon from './VerificationStageIcon';
import { stageColor, stageLabel, type VerificationIssueStage } from '../../utils/verificationBoardStages';
import type { VerificationIssueToastItem } from '../../utils/verificationIssueEvents';
interface Props {
onGoToIssue: (issueId: number) => void;
}
const IcListGo = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="2" width="5" height="12" rx="1" />
<path d="M9 5h5M9 8h5M9 11h3" />
</svg>
);
function headline(eventType: string, stage: VerificationIssueStage, previousStage?: VerificationIssueStage | null): string {
if (eventType === 'CREATED') return '새 검증 이슈가 등록되었습니다';
const prev = previousStage ? stageLabel(previousStage) : '—';
return `단계 변경: ${prev}${stageLabel(stage)}`;
}
const VerificationIssueToastStack: React.FC<Props> = ({ onGoToIssue }) => {
const { toasts, dismissToast, dismissAll } = useVerificationIssueNotification();
if (toasts.length === 0) return null;
return (
<div className="vbd-toast-stack" aria-live="polite" aria-label="검증 이슈 알림">
<div className="vbd-toast-toolbar">
<span className="vbd-toast-toolbar-label"> </span>
<button type="button" className="vbd-toast-dismiss-all" onClick={dismissAll}>
({toasts.length})
</button>
</div>
{toasts.map((item: VerificationIssueToastItem) => (
<article key={item.id} className="vbd-toast-card">
<div className="vbd-toast-head">
<VerificationStageIcon stage={item.stage} size={18} />
<div className="vbd-toast-head-text">
<p className="vbd-toast-kind">{headline(item.eventType, item.stage, item.previousStage)}</p>
<h4 className="vbd-toast-title">{item.title}</h4>
</div>
<button
type="button"
className="vbd-toast-close"
onClick={() => dismissToast(item.id)}
aria-label="알림 닫기"
>
</button>
</div>
<div className="vbd-toast-meta">
<span
className="vbd-stage-badge"
style={{ backgroundColor: `${stageColor(item.stage)}22`, color: stageColor(item.stage) }}
>
{stageLabel(item.stage)}
</span>
</div>
<div className="vbd-toast-actions">
<button
type="button"
className="vbd-toast-go"
title="검증게시판에서 이슈 열기"
aria-label="목록으로 이동하여 이슈 상세 보기"
onClick={() => {
dismissToast(item.id);
onGoToIssue(item.issueId);
}}
>
<IcListGo />
<span> </span>
</button>
</div>
</article>
))}
</div>
);
};
export default VerificationIssueToastStack;
@@ -0,0 +1,87 @@
import React from 'react';
import { stageColor, stageLabel, type VerificationIssueStage } from '../../utils/verificationBoardStages';
interface Props {
stage: VerificationIssueStage;
size?: number;
className?: string;
title?: string;
}
function StageGlyph({ stage }: { stage: VerificationIssueStage }) {
switch (stage) {
case 'REGISTERED':
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<rect x="3" y="2" width="10" height="12" rx="1.5" />
<path d="M8 5v4M6 7h4" />
</svg>
);
case 'IN_FIX':
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M11.5 2.5l2 2-6.5 6.5H5v-2L11.5 2.5z" />
<path d="M9.5 4.5l2 2" />
</svg>
);
case 'FIX_COMPLETE':
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="8" cy="8" r="5.5" />
<path d="M5.5 8l1.8 1.8L10.8 6.2" />
</svg>
);
case 'RE_REGISTERED':
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M11 2.5h2.5V5" />
<path d="M4.5 13.5H2V11" />
<path d="M12.8 5.2A4.5 4.5 0 1 0 6.5 11.5" />
</svg>
);
case 'COMPLETE':
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M3 4.5h10v8H3z" />
<path d="M6 4.5V3.5a2 2 0 0 1 2-2h0a2 2 0 0 1 2 2v1" />
<path d="M6 8.5l1.5 1.5L10 7" />
</svg>
);
default:
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden>
<circle cx="8" cy="8" r="4" />
</svg>
);
}
}
const VerificationStageIcon: React.FC<Props> = ({
stage,
size = 32,
className = '',
title,
}) => {
const color = stageColor(stage);
const label = title ?? stageLabel(stage);
return (
<span
className={`vbd-stage-icon ${className}`.trim()}
style={{
width: size,
height: size,
color,
backgroundColor: `${color}18`,
borderColor: `${color}44`,
}}
title={label}
aria-label={label}
role="img"
>
<StageGlyph stage={stage} />
</span>
);
};
export default VerificationStageIcon;