generated from pricelees/issue-pr-template
<!-- 제목 양식 --> <!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) --> ## 📝 관련 이슈 및 PR **PR과 관련된 이슈 번호** - #34 ## ✨ 작업 내용 <!-- 어떤 작업을 했는지 알려주세요! --> - 회원 테이블과 관리자 테이블 분리 및 관리자 계정의 예약 기능 제거 - API 인증을 모두(Public) / 회원 전용(UserOnly) / 관리자 전용(AdminOnly) / 회원 + 관리자(Authenticated) 로 세분화해서 구분 - 관리자의 경우 API 접근 권한 세분화 등 인증 로직 개선 - 전체 리팩터링이 완료되어 레거시 코드 제거 ## 🧪 테스트 <!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! --> <img width="750" alt="스크린샷 2025-09-13 19.11.44.png" src="attachments/11e1a79c-9723-4843-839d-be6158d94130"> - 추가 & 변경된 모든 API에 대한 통합 테스트 진행 ## 📚 참고 자료 및 기타 <!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! --> Reviewed-on: #43 Co-authored-by: pricelees <priceelees@gmail.com> Co-committed-by: pricelees <priceelees@gmail.com>
267 lines
13 KiB
TypeScript
267 lines
13 KiB
TypeScript
import {isLoginRequiredError} from '@_api/apiClient';
|
|
import {createTheme, deleteTheme, fetchAdminThemeDetail, updateTheme} from '@_api/theme/themeAPI';
|
|
import {
|
|
type AdminThemeDetailResponse,
|
|
Difficulty,
|
|
type ThemeCreateRequest,
|
|
type ThemeUpdateRequest
|
|
} from '@_api/theme/themeTypes';
|
|
import React, {useEffect, useState} from 'react';
|
|
import {useLocation, useNavigate, useParams} from 'react-router-dom';
|
|
import '@_css/admin-theme-edit-page.css';
|
|
|
|
const AdminThemeEditPage: React.FC = () => {
|
|
const { themeId } = useParams<{ themeId: string }>();
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
|
|
const isNew = themeId === 'new';
|
|
|
|
const [theme, setTheme] = useState<AdminThemeDetailResponse | ThemeCreateRequest | null>(null);
|
|
const [originalTheme, setOriginalTheme] = useState<AdminThemeDetailResponse | ThemeCreateRequest | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isEditing, setIsEditing] = useState(isNew);
|
|
|
|
const handleError = (err: any) => {
|
|
if (isLoginRequiredError(err)) {
|
|
alert('로그인이 필요해요.');
|
|
navigate('/login', { state: { from: location } });
|
|
} else {
|
|
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
|
|
alert(message);
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (isNew) {
|
|
const newTheme: ThemeCreateRequest = {
|
|
name: '',
|
|
description: '',
|
|
thumbnailUrl: '',
|
|
difficulty: Difficulty.NORMAL,
|
|
price: 0,
|
|
minParticipants: 2,
|
|
maxParticipants: 4,
|
|
availableMinutes: 60,
|
|
expectedMinutesFrom: 50,
|
|
expectedMinutesTo: 70,
|
|
isOpen: true,
|
|
};
|
|
setTheme(newTheme);
|
|
setOriginalTheme(newTheme);
|
|
setIsLoading(false);
|
|
} else if (themeId) {
|
|
fetchAdminThemeDetail(themeId)
|
|
.then(data => {
|
|
// Map AdminThemeDetailRetrieveResponse to ThemeV2
|
|
const fetchedTheme: AdminThemeDetailResponse = {
|
|
id: data.id,
|
|
name: data.name,
|
|
description: data.description,
|
|
thumbnailUrl: data.thumbnailUrl,
|
|
difficulty: data.difficulty,
|
|
price: data.price,
|
|
minParticipants: data.minParticipants,
|
|
maxParticipants: data.maxParticipants,
|
|
availableMinutes: data.availableMinutes,
|
|
expectedMinutesFrom: data.expectedMinutesFrom,
|
|
expectedMinutesTo: data.expectedMinutesTo,
|
|
isOpen: data.isOpen,
|
|
createDate: data.createdAt, // Map createdAt to createDate
|
|
updatedDate: data.updatedAt, // Map updatedAt to updatedDate
|
|
createdBy: data.createdBy,
|
|
updatedBy: data.updatedBy,
|
|
};
|
|
setTheme(fetchedTheme);
|
|
setOriginalTheme(fetchedTheme);
|
|
})
|
|
.catch(handleError)
|
|
.finally(() => setIsLoading(false));
|
|
}
|
|
}, [themeId, isNew, navigate]);
|
|
|
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
|
const { name, value, type } = e.target;
|
|
let processedValue: string | number | boolean = value;
|
|
|
|
if (name === 'isOpen') {
|
|
processedValue = value === 'true';
|
|
} else if (type === 'checkbox') {
|
|
processedValue = (e.target as HTMLInputElement).checked;
|
|
} else if (type === 'number') {
|
|
processedValue = value === '' ? '' : Number(value);
|
|
}
|
|
|
|
setTheme(prev => prev ? { ...prev, [name]: processedValue } : null);
|
|
};
|
|
|
|
const handleCancelEdit = () => {
|
|
if (!isNew) {
|
|
setTheme(originalTheme);
|
|
setIsEditing(false);
|
|
} else {
|
|
navigate('/admin/theme');
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
console.log('handleSubmit called');
|
|
e.preventDefault();
|
|
if (!theme) return;
|
|
|
|
try {
|
|
if (isNew) {
|
|
await createTheme(theme as ThemeCreateRequest);
|
|
alert('테마가 성공적으로 생성되었습니다.');
|
|
navigate(`/admin/theme`);
|
|
} else {
|
|
if (!themeId) {
|
|
throw new Error('themeId is undefined');
|
|
}
|
|
await updateTheme(themeId, theme as ThemeUpdateRequest);
|
|
alert('테마가 성공적으로 업데이트되었습니다.');
|
|
setOriginalTheme(theme);
|
|
setIsEditing(false);
|
|
navigate(`/admin/theme`);
|
|
}
|
|
} catch (error) {
|
|
handleError(error);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (isNew || !themeId) return;
|
|
if (window.confirm('정말로 이 테마를 삭제하시겠습니까?')) {
|
|
try {
|
|
await deleteTheme(themeId);
|
|
alert('테마가 삭제되었습니다.');
|
|
navigate('/admin/theme');
|
|
} catch (error) {
|
|
handleError(error);
|
|
}
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return <div className="admin-theme-edit-container"><p>로딩 중...</p></div>;
|
|
}
|
|
|
|
if (!theme) {
|
|
return <div className="admin-theme-edit-container"><p>테마 정보를 찾을 수 없습니다.</p></div>;
|
|
}
|
|
|
|
return (
|
|
<div className="admin-theme-edit-container">
|
|
<div className="centered-layout">
|
|
<header className="page-header">
|
|
<h2 className="page-title">{isNew ? '새 테마 추가' : '테마 정보 수정'}</h2>
|
|
</header>
|
|
<form onSubmit={handleSubmit} className="form-card">
|
|
<div className="form-section">
|
|
<div className="form-group full-width">
|
|
<label className="form-label" htmlFor="name">테마 이름</label>
|
|
<input id="name" name="name" type="text" className="form-input" value={theme.name} onChange={handleChange} required disabled={!isEditing} />
|
|
</div>
|
|
<div className="form-group full-width">
|
|
<label className="form-label" htmlFor="description">설명</label>
|
|
<textarea id="description" name="description" className="form-textarea" value={theme.description} onChange={handleChange} required disabled={!isEditing} />
|
|
</div>
|
|
<div className="form-group full-width">
|
|
<label className="form-label" htmlFor="thumbnailUrl">썸네일 URL</label>
|
|
<input id="thumbnailUrl" name="thumbnailUrl" type="text" className="form-input" value={theme.thumbnailUrl} onChange={handleChange} required disabled={!isEditing} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="form-row">
|
|
<div className="form-group">
|
|
<label className="form-label" htmlFor="difficulty">난이도</label>
|
|
<select id="difficulty" name="difficulty" className="form-select" value={theme.difficulty} onChange={handleChange} disabled={!isEditing}>
|
|
{Object.values(Difficulty).map(d => <option key={d} value={d}>{d}</option>)}
|
|
</select>
|
|
</div>
|
|
<div className="form-group">
|
|
<label className="form-label" htmlFor="isOpen">공개 여부</label>
|
|
<select id="isOpen" name="isOpen" className="form-select" value={String(theme.isOpen)} onChange={handleChange} disabled={!isEditing}>
|
|
<option value="true">공개</option>
|
|
<option value="false">비공개</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<div className="form-row">
|
|
<div className="form-group">
|
|
<label className="form-label" htmlFor="price">1인당 요금 (원)</label>
|
|
<input id="price" name="price" type="number" className="form-input" value={theme.price} onChange={handleChange} required disabled={!isEditing} />
|
|
</div>
|
|
<div className="form-group">
|
|
<label className="form-label" htmlFor="availableMinutes">총 이용시간 (분)</label>
|
|
<input id="availableMinutes" name="availableMinutes" type="number" className="form-input" value={theme.availableMinutes} onChange={handleChange} required disabled={!isEditing} />
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<div className="form-row">
|
|
<div className="form-group">
|
|
<label className="form-label" htmlFor="expectedMinutesFrom">최소 예상 시간 (분)</label>
|
|
<input id="expectedMinutesFrom" name="expectedMinutesFrom" type="number" className="form-input" value={theme.expectedMinutesFrom} onChange={handleChange} required disabled={!isEditing} />
|
|
</div>
|
|
<div className="form-group">
|
|
<label className="form-label" htmlFor="expectedMinutesTo">최대 예상 시간 (분)</label>
|
|
<input id="expectedMinutesTo" name="expectedMinutesTo" type="number" className="form-input" value={theme.expectedMinutesTo} onChange={handleChange} required disabled={!isEditing} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<div className="form-group">
|
|
<label className="form-label" htmlFor="minParticipants">최소 인원 (명)</label>
|
|
<input id="minParticipants" name="minParticipants" type="number" className="form-input" value={theme.minParticipants} onChange={handleChange} required disabled={!isEditing} />
|
|
</div>
|
|
<div className="form-group">
|
|
<label className="form-label" htmlFor="maxParticipants">최대 인원 (명)</label>
|
|
<input id="maxParticipants" name="maxParticipants" type="number" className="form-input" value={theme.maxParticipants} onChange={handleChange} required disabled={!isEditing} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="button-group">
|
|
{isEditing ? (
|
|
<div className="main-actions">
|
|
<button type="button" className="btn btn-secondary" onClick={handleCancelEdit}>취소</button>
|
|
<button type="submit" className="btn btn-primary">저장</button>
|
|
</div>
|
|
) : (
|
|
<div className="main-actions">
|
|
<button type="button" className="btn btn-secondary" onClick={() => navigate('/admin/theme')}>목록</button>
|
|
<button type="button" className="btn btn-primary" onClick={(e) => { e.preventDefault(); console.log('setIsEditing(true) called'); setIsEditing(true); }}>수정</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</form>
|
|
|
|
{!isNew && 'id' in theme && (
|
|
<div className="audit-info">
|
|
<h4 className="audit-title">감사 정보</h4>
|
|
<div className="audit-body">
|
|
<p><strong>생성일:</strong> {new Date(theme.createDate).toLocaleString()}</p>
|
|
<p><strong>수정일:</strong> {new Date(theme.updatedDate).toLocaleString()}</p>
|
|
<p><strong>생성자:</strong> {theme.createdBy}</p>
|
|
<p><strong>수정자:</strong> {theme.updatedBy}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!isNew && !isEditing && (
|
|
<div className="delete-section">
|
|
<button className="btn-delete-text" onClick={handleDelete}>이 테마를 삭제합니다.</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AdminThemeEditPage;
|