검증게시판 기능 추가
This commit is contained in:
@@ -5,6 +5,7 @@ import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import TradeSignalSnackbar from './TradeSignalSnackbar';
|
||||
import TradeAlertModal from './TradeAlertModal';
|
||||
import VerificationIssueToastStack from './verificationBoard/VerificationIssueToastStack';
|
||||
import { useTradeNotification } from '../contexts/TradeNotificationContext';
|
||||
import {
|
||||
normalizeTradeAlertGridCols,
|
||||
@@ -12,11 +13,13 @@ import {
|
||||
normalizeTradeAlertPopupPosition,
|
||||
} from '../utils/tradeAlertPopupLayout';
|
||||
import type { MenuPage } from './TopMenuBar';
|
||||
import '../styles/verificationBoard.css';
|
||||
|
||||
interface Props {
|
||||
menuPage: MenuPage;
|
||||
onPage: (page: MenuPage) => void;
|
||||
onGoToChart: (market: string) => void;
|
||||
onGoToVerificationIssue?: (issueId: number) => void;
|
||||
tradingMode?: string;
|
||||
hasUpbitKeys?: boolean;
|
||||
paperTradingEnabled?: boolean;
|
||||
@@ -32,6 +35,7 @@ export const AppNotificationLayer: React.FC<Props> = ({
|
||||
menuPage,
|
||||
onPage,
|
||||
onGoToChart,
|
||||
onGoToVerificationIssue,
|
||||
tradingMode,
|
||||
hasUpbitKeys,
|
||||
paperTradingEnabled,
|
||||
@@ -73,6 +77,9 @@ export const AppNotificationLayer: React.FC<Props> = ({
|
||||
onOrderDone={onOrderDone}
|
||||
/>
|
||||
)}
|
||||
{onGoToVerificationIssue && (
|
||||
<VerificationIssueToastStack onGoToIssue={onGoToVerificationIssue} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -142,6 +142,8 @@ interface SettingsPageProps {
|
||||
displayTimezone?: string;
|
||||
onDisplayTimezoneChange?: (tz: string) => void;
|
||||
menuPermissions?: Record<string, boolean>;
|
||||
verificationIssueNotify?: boolean;
|
||||
onVerificationIssueNotify?: (v: boolean) => void;
|
||||
trendSearchSettings?: TrendSearchAppSettings;
|
||||
onTrendSearchSettingsChange?: (s: TrendSearchAppSettings) => void;
|
||||
}
|
||||
@@ -534,7 +536,16 @@ const PaperPanel: React.FC<PaperPanelProps> = ({
|
||||
const GeneralPanel: React.FC<{
|
||||
displayTimezone: string;
|
||||
onDisplayTimezoneChange: (tz: string) => void;
|
||||
}> = ({ displayTimezone, onDisplayTimezoneChange }) => {
|
||||
verificationIssueNotify?: boolean;
|
||||
onVerificationIssueNotify?: (v: boolean) => void;
|
||||
showVerificationNotify?: boolean;
|
||||
}> = ({
|
||||
displayTimezone,
|
||||
onDisplayTimezoneChange,
|
||||
verificationIssueNotify = true,
|
||||
onVerificationIssueNotify,
|
||||
showVerificationNotify = true,
|
||||
}) => {
|
||||
const [lang, setLang] = useState('ko');
|
||||
const [dateFormat, setDateFmt] = useState('YYYY-MM-DD');
|
||||
const [startPage, setStart] = useState('chart');
|
||||
@@ -567,6 +578,24 @@ const GeneralPanel: React.FC<{
|
||||
</SettingRow>
|
||||
</SettingSection>
|
||||
|
||||
{showVerificationNotify && (
|
||||
<SettingSection title="검증게시판">
|
||||
<SettingRow
|
||||
label="검증 이슈 등록 알림"
|
||||
desc="검증 이슈가 등록되거나 단계가 변경될 때 팝업 알림을 표시합니다."
|
||||
>
|
||||
<label className="stg-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={verificationIssueNotify}
|
||||
onChange={e => onVerificationIssueNotify?.(e.target.checked)}
|
||||
/>
|
||||
<span className="stg-toggle-slider" />
|
||||
</label>
|
||||
</SettingRow>
|
||||
</SettingSection>
|
||||
)}
|
||||
|
||||
<SettingSection title="시작 화면">
|
||||
<SettingRow label="시작 페이지" desc="앱 시작 시 표시될 기본 화면입니다.">
|
||||
<select className="stg-select" value={startPage} onChange={e => setStart(e.target.value)}>
|
||||
@@ -1458,6 +1487,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
|
||||
displayTimezone = 'Asia/Seoul',
|
||||
onDisplayTimezoneChange,
|
||||
menuPermissions,
|
||||
verificationIssueNotify = true,
|
||||
onVerificationIssueNotify,
|
||||
trendSearchSettings,
|
||||
onTrendSearchSettingsChange,
|
||||
}) => {
|
||||
@@ -1540,6 +1571,9 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
|
||||
<GeneralPanel
|
||||
displayTimezone={displayTimezone}
|
||||
onDisplayTimezoneChange={onDisplayTimezoneChange ?? (() => {})}
|
||||
verificationIssueNotify={verificationIssueNotify}
|
||||
onVerificationIssueNotify={onVerificationIssueNotify}
|
||||
showVerificationNotify={menuPermissions == null || menuPermissions['verification-board'] === true}
|
||||
/>
|
||||
);
|
||||
case 'admin': return (
|
||||
|
||||
@@ -11,7 +11,7 @@ import TradeAlertPopupMenubarSelect from './TradeAlertPopupMenubarSelect';
|
||||
import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout';
|
||||
import { canAccessMenu } from '../utils/permissions';
|
||||
|
||||
export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'trend-search' | 'strategy' | 'strategy-editor' | 'backtest' | 'notifications' | 'settings';
|
||||
export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'trend-search' | 'verification-board' | 'strategy' | 'strategy-editor' | 'backtest' | 'notifications' | 'settings';
|
||||
|
||||
interface TopMenuBarProps {
|
||||
activePage: MenuPage;
|
||||
@@ -137,6 +137,14 @@ const IcTrendSearch = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcVerificationBoard = () => (
|
||||
<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="1.5" width="12" height="13" rx="1.5"/>
|
||||
<path d="M5 5h6M5 8h6M5 11h4"/>
|
||||
<path d="M11.5 10.5l1 1 2-2" strokeWidth="1.3"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [
|
||||
{ page: 'dashboard', label: '대시보드', icon: <IcDashboard /> },
|
||||
{ page: 'chart', label: '실시간차트', icon: <IcChart /> },
|
||||
@@ -145,6 +153,7 @@ const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [
|
||||
{ page: 'strategy-editor', label: '전략편집기', icon: <IcStrategyEditor /> },
|
||||
{ page: 'backtest', label: '백테스팅', icon: <IcBacktest /> },
|
||||
{ page: 'settings', label: '설정', icon: <IcSettings /> },
|
||||
{ page: 'verification-board', label: '검증게시판', icon: <IcVerificationBoard /> },
|
||||
];
|
||||
|
||||
export const TopMenuBar = memo(function TopMenuBar({
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* 검증게시판 — 좌 목록 + 우 상세
|
||||
*/
|
||||
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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user