From 2481e026eb559f4432ef80ac55442eefeb117398 Mon Sep 17 00:00:00 2001 From: pricelees Date: Mon, 15 Sep 2025 15:45:35 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A7=A4=EC=9E=A5=EC=9D=B4=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=EB=90=9C=20=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94?= =?UTF-8?q?=EB=93=9C=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=B4=88=EC=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 2 + frontend/src/api/common/commonTypes.ts | 7 + frontend/src/api/region/regionTypes.ts | 6 + frontend/src/api/schedule/scheduleAPI.ts | 4 +- frontend/src/api/store/storeAPI.ts | 22 ++ frontend/src/api/store/storeTypes.ts | 32 +++ frontend/src/css/admin-store-page.css | 194 ++++++++++++++ frontend/src/pages/admin/AdminNavbar.tsx | 1 + .../src/pages/admin/AdminSchedulePage.tsx | 71 ++++-- frontend/src/pages/admin/AdminStorePage.tsx | 240 ++++++++++++++++++ .../roomescape/admin/business/AdminService.kt | 19 +- .../kotlin/roomescape/store/web/StoreDTO.kt | 52 ++++ 12 files changed, 624 insertions(+), 26 deletions(-) create mode 100644 frontend/src/api/store/storeAPI.ts create mode 100644 frontend/src/api/store/storeTypes.ts create mode 100644 frontend/src/css/admin-store-page.css create mode 100644 frontend/src/pages/admin/AdminStorePage.tsx create mode 100644 src/main/kotlin/roomescape/store/web/StoreDTO.kt diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8a5e2ee4..ed5c0139 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import AdminLayout from './pages/admin/AdminLayout'; import AdminLoginPage from './pages/admin/AdminLoginPage'; import AdminPage from './pages/admin/AdminPage'; import AdminSchedulePage from './pages/admin/AdminSchedulePage'; +import AdminStorePage from './pages/admin/AdminStorePage'; import AdminThemeEditPage from './pages/admin/AdminThemeEditPage'; import AdminThemePage from './pages/admin/AdminThemePage'; import HomePage from '@_pages/HomePage'; @@ -32,6 +33,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/api/common/commonTypes.ts b/frontend/src/api/common/commonTypes.ts index e094754c..babcdab3 100644 --- a/frontend/src/api/common/commonTypes.ts +++ b/frontend/src/api/common/commonTypes.ts @@ -2,3 +2,10 @@ export interface OperatorInfo { id: string; name: string; } + +export interface AuditInfo { + createdAt: string; + updatedAt: string; + createdBy: OperatorInfo; + updatedBy: OperatorInfo; +} \ No newline at end of file diff --git a/frontend/src/api/region/regionTypes.ts b/frontend/src/api/region/regionTypes.ts index df351773..7c8f181d 100644 --- a/frontend/src/api/region/regionTypes.ts +++ b/frontend/src/api/region/regionTypes.ts @@ -19,3 +19,9 @@ export interface SigunguListResponse { export interface RegionCodeResponse { code: string } + +export interface RegionInfoResponse { + code: string, + sidoName: string, + sigunguName: string, +} \ No newline at end of file diff --git a/frontend/src/api/schedule/scheduleAPI.ts b/frontend/src/api/schedule/scheduleAPI.ts index 82b7e383..978e9ddc 100644 --- a/frontend/src/api/schedule/scheduleAPI.ts +++ b/frontend/src/api/schedule/scheduleAPI.ts @@ -12,8 +12,8 @@ export const fetchAvailableThemesByDate = async (date: string): Promise(`/schedules/themes?date=${date}`); }; -export const fetchSchedulesByDateAndTheme = async (date: string, themeId: string): Promise => { - return await apiClient.get(`/schedules?date=${date}&themeId=${themeId}`); +export const fetchSchedulesByDateAndTheme = async (storeId: number, date: string, themeId: string): Promise => { + return await apiClient.get(`/schedules?storeId=${storeId}&date=${date}&themeId=${themeId}`); }; export const fetchScheduleById = async (id: string): Promise => { diff --git a/frontend/src/api/store/storeAPI.ts b/frontend/src/api/store/storeAPI.ts new file mode 100644 index 00000000..f9333bb0 --- /dev/null +++ b/frontend/src/api/store/storeAPI.ts @@ -0,0 +1,22 @@ +import apiClient from '@_api/apiClient'; +import { type SimpleStoreResponse, type StoreDetailResponse, type StoreRegisterRequest, type UpdateStoreRequest } from './storeTypes'; + +export const getStores = async (): Promise => { + return await apiClient.get('/admin/stores'); +}; + +export const getStoreDetail = async (id: number): Promise => { + return await apiClient.get(`/admin/stores/${id}`); +}; + +export const createStore = async (data: StoreRegisterRequest): Promise => { + return await apiClient.post('/admin/stores', data); +}; + +export const updateStore = async (id: number, data: UpdateStoreRequest): Promise => { + return await apiClient.put(`/admin/stores/${id}`, data); +}; + +export const deleteStore = async (id: number): Promise => { + await apiClient.del(`/admin/stores/${id}`); +}; diff --git a/frontend/src/api/store/storeTypes.ts b/frontend/src/api/store/storeTypes.ts new file mode 100644 index 00000000..bf54e0fc --- /dev/null +++ b/frontend/src/api/store/storeTypes.ts @@ -0,0 +1,32 @@ +import { type AuditInfo } from '@_api/common/commonTypes'; +import type { RegionInfoResponse } from '@_api/region/regionTypes'; + +export interface SimpleStoreResponse { + id: number; + name: string; +} + +export interface StoreDetailResponse { + id: number; + name: string; + address: string; + contact: string; + businessRegNum: string; + region: RegionInfoResponse; + audit: AuditInfo; +} + +export interface StoreRegisterRequest { + name: string; + address: string; + contact: string; + businessRegNum: string; + regionCode: string; +} + +export interface UpdateStoreRequest { + name: string; + address: string; + contact: string; + regionCode: string; +} diff --git a/frontend/src/css/admin-store-page.css b/frontend/src/css/admin-store-page.css new file mode 100644 index 00000000..7dc8924a --- /dev/null +++ b/frontend/src/css/admin-store-page.css @@ -0,0 +1,194 @@ +/* /src/css/admin-store-page.css */ +.admin-store-container { + max-width: 1400px; + margin: 40px auto; + padding: 40px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background-color: #f4f6f8; + border-radius: 16px; +} + +.admin-store-container .page-title { + font-size: 32px; + font-weight: 700; + color: #333d4b; + margin-bottom: 30px; + text-align: center; +} + +.section-card { + background-color: #ffffff; + border-radius: 12px; + padding: 24px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); +} + +.table-header { + display: flex; + justify-content: flex-end; + margin-bottom: 20px; +} + +.table-container table { + width: 100%; + border-collapse: collapse; + font-size: 15px; +} + +.table-container th, +.table-container td { + padding: 12px 16px; + text-align: left; + border-bottom: 1px solid #e5e8eb; + vertical-align: middle; +} + +.table-container th { + background-color: #f9fafb; + color: #505a67; + font-weight: 600; +} + +.table-container tr:last-child td { + border-bottom: none; +} + +.table-container tr:hover { + background-color: #f4f6f8; +} + +.form-input, .form-select, .form-textarea { + width: 100%; + padding: 10px 12px; + font-size: 15px; + border: 1px solid #E5E8EB; + border-radius: 8px; + box-sizing: border-box; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.form-input:focus, .form-select:focus, .form-textarea:focus { + outline: none; + border-color: #3182F6; + box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2); +} + +.btn { + padding: 8px 16px; + font-size: 15px; + font-weight: 600; + border-radius: 8px; + border: none; + cursor: pointer; + transition: background-color 0.2s; +} + +.btn-primary { + background-color: #3182F6; + color: #ffffff; +} + +.btn-primary:hover { + background-color: #1B64DA; +} + +.btn-secondary { + background-color: #F2F4F6; + color: #4E5968; +} + +.btn-secondary:hover { + background-color: #E5E8EB; +} + +.btn-danger { + background-color: #e53e3e; + color: white; +} + +.btn-danger:hover { + background-color: #c53030; +} + +.action-buttons { + display: flex; + gap: 0.5rem; +} + +.details-row td { + padding: 0; + background-color: #f8f9fa; +} + +.details-container { + padding: 1.5rem; +} + +.details-form-card { + background-color: #fff; + padding: 1.5rem; + border-radius: 8px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05); + margin-bottom: 1.5rem; +} + +.form-row { + display: flex; + gap: 1.5rem; + margin-bottom: 1rem; +} + +.form-group { + flex: 1; +} + +.form-label { + display: block; + margin-bottom: 8px; + font-size: 14px; + font-weight: 600; + color: #4E5968; +} + +.button-group { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 1.5rem; +} + +.audit-info { + padding: 1.5rem; + border: 1px solid #dee2e6; + border-radius: 8px; + background-color: #fff; + margin-bottom: 1.5rem; +} + +.audit-title { + font-size: 1.1rem; + font-weight: 600; + color: #343a40; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid #dee2e6; +} + +.audit-body p { + margin: 0.5rem 0; + font-size: 0.9rem; + color: #495057; +} + +.audit-body p strong { + color: #212529; + margin-right: 0.5rem; +} + +.add-store-form { + padding: 1.5rem; + background-color: #fdfdff; + border: 1px solid #e5e8eb; + border-radius: 8px; + margin-bottom: 2rem; +} diff --git a/frontend/src/pages/admin/AdminNavbar.tsx b/frontend/src/pages/admin/AdminNavbar.tsx index 363ce561..5cd0993c 100644 --- a/frontend/src/pages/admin/AdminNavbar.tsx +++ b/frontend/src/pages/admin/AdminNavbar.tsx @@ -22,6 +22,7 @@ const AdminNavbar: React.FC = () => {
홈 {type === 'HQ' && 테마} + {type === 'HQ' && 매장} 일정
diff --git a/frontend/src/pages/admin/AdminSchedulePage.tsx b/frontend/src/pages/admin/AdminSchedulePage.tsx index 99f652b2..ae0cec33 100644 --- a/frontend/src/pages/admin/AdminSchedulePage.tsx +++ b/frontend/src/pages/admin/AdminSchedulePage.tsx @@ -11,6 +11,8 @@ import { type ScheduleRetrieveResponse, ScheduleStatus } from '@_api/schedule/scheduleTypes'; +import { getStores } from '@_api/store/storeAPI'; +import { type SimpleStoreResponse } from '@_api/store/storeTypes'; import { fetchActiveThemes, fetchThemeById } from '@_api/theme/themeAPI'; import { DifficultyKoreanMap, type ThemeInfoResponse } from '@_api/theme/themeTypes'; import { useAdminAuth } from '@_context/AdminAuthContext'; @@ -41,6 +43,8 @@ type ThemeForSchedule = { const AdminSchedulePage: React.FC = () => { const [schedules, setSchedules] = useState([]); const [themes, setThemes] = useState([]); + const [stores, setStores] = useState([]); + const [selectedStoreId, setSelectedStoreId] = useState(''); const [selectedThemeId, setSelectedThemeId] = useState(''); const [selectedDate, setSelectedDate] = useState(new Date().toLocaleDateString('en-CA')); @@ -59,7 +63,7 @@ const AdminSchedulePage: React.FC = () => { const navigate = useNavigate(); const location = useLocation(); - const { type: adminType } = useAdminAuth(); + const { type: adminType, storeId: adminStoreId } = useAdminAuth(); const handleError = (err: any) => { if (isLoginRequiredError(err)) { @@ -75,26 +79,36 @@ const AdminSchedulePage: React.FC = () => { useEffect(() => { if (!adminType) return; - const fetchThemesForSchedule = async () => { + const fetchPrerequisites = async () => { try { - let themeData: ThemeForSchedule[]; - const res = await fetchActiveThemes(); - themeData = res.themes.map(t => ({ id: String(t.id), name: t.name })); + // Fetch themes + const themeRes = await fetchActiveThemes(); + const themeData = themeRes.themes.map(t => ({ id: String(t.id), name: t.name })); setThemes(themeData); if (themeData.length > 0) { setSelectedThemeId(themeData[0].id); } + + // Fetch stores for HQ admin + if (adminType === 'HQ') { + const storeRes = await getStores(); + setStores(storeRes); + if (storeRes.length > 0) { + setSelectedStoreId(String(storeRes[0].id)); + } + } } catch (error) { handleError(error); } }; - fetchThemesForSchedule(); + fetchPrerequisites(); }, [adminType]); const fetchSchedules = () => { - if (selectedDate && selectedThemeId) { - fetchSchedulesByDateAndTheme(selectedDate, selectedThemeId) + const storeId = adminType === 'HQ' ? selectedStoreId : adminStoreId; + if (storeId && selectedDate && selectedThemeId) { + fetchSchedulesByDateAndTheme(Number(storeId), selectedDate, selectedThemeId) .then(res => setSchedules(res.schedules)) .catch(err => { setSchedules([]); @@ -107,7 +121,7 @@ const AdminSchedulePage: React.FC = () => { useEffect(() => { fetchSchedules(); - }, [selectedDate, selectedThemeId]); + }, [selectedDate, selectedThemeId, selectedStoreId, adminType, adminStoreId]); const handleShowThemeDetails = async () => { if (!selectedThemeId) return; @@ -219,11 +233,28 @@ const AdminSchedulePage: React.FC = () => { } }; + const canModify = adminType === 'HQ'; + return (

일정 관리

+ {adminType === 'HQ' && ( +
+ + +
+ )}
{
-
- -
+ {canModify && ( +
+ +
+ )}
@@ -327,10 +360,12 @@ const AdminSchedulePage: React.FC = () => { ) : ( // --- VIEW MODE --- -
- - -
+ canModify && ( +
+ + +
+ ) )} ) : ( @@ -341,7 +376,7 @@ const AdminSchedulePage: React.FC = () => { )} ))} - {isAdding && ( + {isAdding && canModify && (
{ ); }; -export default AdminSchedulePage; +export default AdminSchedulePage; \ No newline at end of file diff --git a/frontend/src/pages/admin/AdminStorePage.tsx b/frontend/src/pages/admin/AdminStorePage.tsx new file mode 100644 index 00000000..4652b1ed --- /dev/null +++ b/frontend/src/pages/admin/AdminStorePage.tsx @@ -0,0 +1,240 @@ +import { isLoginRequiredError } from '@_api/apiClient'; +import { createStore, deleteStore, getStoreDetail, getStores, updateStore } from '@_api/store/storeAPI'; +import { type StoreRegisterRequest, type StoreDetailResponse, type SimpleStoreResponse, type UpdateStoreRequest } from '@_api/store/storeTypes'; +import { useAdminAuth } from '@_context/AdminAuthContext'; +import '@_css/admin-store-page.css'; +import React, { Fragment, useEffect, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; + +const AdminStorePage: React.FC = () => { + const [stores, setStores] = useState([]); + const [isAdding, setIsAdding] = useState(false); + const [newStore, setNewStore] = useState({ name: '', address: '', contact: '', businessRegNum: '', regionCode: '' }); + + const [expandedStoreId, setExpandedStoreId] = useState(null); + const [detailedStores, setDetailedStores] = useState<{ [key: number]: StoreDetailResponse }>({}); + const [isLoadingDetails, setIsLoadingDetails] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [editingStore, setEditingStore] = useState(null); + + const navigate = useNavigate(); + const location = useLocation(); + const { type: adminType } = useAdminAuth(); + + const handleError = (err: any) => { + if (isLoginRequiredError(err)) { + alert('로그인이 필요합니다.'); + navigate('/admin/login', { state: { from: location } }); + } else { + const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.'; + alert(message); + console.error(err); + } + }; + + const fetchStores = async () => { + try { + const storesData = await getStores(); + setStores(storesData); + } catch (error) { + handleError(error); + } + }; + + useEffect(() => { + if (adminType !== 'HQ') { + alert('접근 권한이 없습니다.'); + navigate('/admin'); + return; + } + fetchStores(); + }, [adminType, navigate]); + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setNewStore(prev => ({ ...prev, [name]: value })); + }; + + const handleAddStore = async () => { + if (Object.values(newStore).some(val => val === '')) { + alert('모든 필드를 입력해주세요.'); + return; + } + try { + await createStore(newStore); + fetchStores(); + setIsAdding(false); + setNewStore({ name: '', address: '', contact: '', businessRegNum: '', regionCode: '' }); + } catch (error) { + handleError(error); + } + }; + + const handleToggleDetails = async (storeId: number) => { + const isAlreadyExpanded = expandedStoreId === storeId; + setIsEditing(false); + if (isAlreadyExpanded) { + setExpandedStoreId(null); + } else { + setExpandedStoreId(storeId); + if (!detailedStores[storeId]) { + setIsLoadingDetails(true); + try { + const details = await getStoreDetail(storeId); + setDetailedStores(prev => ({ ...prev, [storeId]: details })); + } catch (error) { + handleError(error); + } finally { + setIsLoadingDetails(false); + } + } + } + }; + + const handleDeleteStore = async (storeId: number) => { + if (window.confirm('정말 이 매장을 삭제하시겠습니까? 관련 데이터가 모두 삭제될 수 있습니다.')) { + try { + await deleteStore(storeId); + fetchStores(); + setExpandedStoreId(null); + } catch (error) { + handleError(error); + } + } + }; + + const handleEditClick = (store: StoreDetailResponse) => { + setEditingStore({ name: store.name, address: store.address, contact: store.contact }); + setIsEditing(true); + }; + + const handleCancelEdit = () => { + setIsEditing(false); + setEditingStore(null); + }; + + const handleEditChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + if (editingStore) { + setEditingStore(prev => ({ ...prev!, [name]: value })); + } + }; + + const handleSave = async (storeId: number) => { + if (!editingStore) return; + try { + const updatedStore = await updateStore(storeId, editingStore); + setDetailedStores(prev => ({ ...prev, [storeId]: updatedStore })); + setStores(prev => prev.map(s => s.id === storeId ? { ...s, name: updatedStore.name } : s)); + setIsEditing(false); + setEditingStore(null); + alert('매장 정보가 성공적으로 업데이트되었습니다.'); + } catch (error) { + handleError(error); + } + }; + + return ( +
+

매장 관리

+ +
+
+ +
+ + {isAdding && ( +
+
+
+
+
+
+
+
+
+
+
+ +
+
+ )} + +
+ + + + + + + + + + {stores.map(store => ( + + + + + + + {expandedStoreId === store.id && ( + + + + )} + + ))} + +
ID매장명관리
{store.id}{store.name} + +
+
+ {isLoadingDetails ?

로딩 중...

: detailedStores[store.id] ? ( +
+
+

상세 정보

+
+

주소: {detailedStores[store.id].address}

+

연락처: {detailedStores[store.id].contact}

+

사업자등록번호: {detailedStores[store.id].businessRegNum}

+

지역 코드: {detailedStores[store.id].regionCode}

+

생성일: {new Date(detailedStores[store.id].createdAt).toLocaleString()}

+

수정일: {new Date(detailedStores[store.id].updatedAt).toLocaleString()}

+

생성자: {detailedStores[store.id].createdBy.name}

+

수정자: {detailedStores[store.id].updatedBy.name}

+
+
+ + {isEditing && editingStore ? ( +
+
+
+
+
+
+
+ + +
+
+ ) : ( +
+ + +
+ )} +
+ ) :

상세 정보를 불러올 수 없습니다.

} +
+
+
+
+
+ ); +}; + +export default AdminStorePage; \ No newline at end of file diff --git a/src/main/kotlin/roomescape/admin/business/AdminService.kt b/src/main/kotlin/roomescape/admin/business/AdminService.kt index 30e8b04c..786fdadf 100644 --- a/src/main/kotlin/roomescape/admin/business/AdminService.kt +++ b/src/main/kotlin/roomescape/admin/business/AdminService.kt @@ -9,7 +9,11 @@ import roomescape.admin.exception.AdminErrorCode import roomescape.admin.exception.AdminException import roomescape.admin.infrastructure.persistence.AdminEntity import roomescape.admin.infrastructure.persistence.AdminRepository -import roomescape.common.dto.* +import roomescape.common.dto.AdminLoginCredentials +import roomescape.common.dto.AuditConstant +import roomescape.common.dto.OperatorInfo +import roomescape.common.dto.PrincipalType +import roomescape.common.dto.toCredentials private val log: KLogger = KotlinLogging.logger {} @@ -33,13 +37,16 @@ class AdminService( } @Transactional(readOnly = true) - fun findOperatorById(id: Long): OperatorInfo { + fun findOperatorOrNull(id: Long): OperatorInfo? { log.info { "[AdminService.findOperatorById] 작업자 정보 조회 시작: id=${id}" } - val admin: AdminEntity = findOrThrow(id) - - return OperatorInfo(admin.id, admin.name).also { - log.info { "[AdminService.findOperatorById] 작업자 정보 조회 완료: id=${admin.id}, name=${admin.name}" } + return adminRepository.findByIdOrNull(id)?.let { admin -> + OperatorInfo(admin.id, admin.name, PrincipalType.ADMIN).also { + log.info { "[AdminService.findOperatorById] 작업자 정보 조회 완료: id=${admin.id}, name=${admin.name}" } + } + } ?: run { + log.info { "[AdminService.findOperatorById] 작업자 정보 조회 실패. id=${id}" } + null } } diff --git a/src/main/kotlin/roomescape/store/web/StoreDTO.kt b/src/main/kotlin/roomescape/store/web/StoreDTO.kt new file mode 100644 index 00000000..bf5211ed --- /dev/null +++ b/src/main/kotlin/roomescape/store/web/StoreDTO.kt @@ -0,0 +1,52 @@ +package roomescape.store.web + +import roomescape.common.dto.AuditInfo +import roomescape.region.web.RegionInfoResponse +import roomescape.store.infrastructure.persistence.StoreEntity + +data class SimpleStoreResponse( + val id: Long, + val name: String +) + +data class SimpleStoreListResponse( + val stores: List +) + +fun List.toSimpleListResponse() = SimpleStoreListResponse( + stores = this.map { SimpleStoreResponse(id = it.id, name = it.name) } +) + +data class StoreInfoResponse( + val id: Long, + val name: String, + val address: String, + val contact: String, + val businessRegNum: String +) + +fun StoreEntity.toInfoResponse() = StoreInfoResponse( + id = this.id, + name = this.name, + address = this.address, + contact = this.contact, + businessRegNum = this.businessRegNum +) + +data class StoreInfoListResponse( + val stores: List +) + +fun List.toInfoListResponse() = StoreInfoListResponse( + stores = this.map { it.toInfoResponse() } +) + +data class StoreDetailResponse( + val id: Long, + val name: String, + val address: String, + val contact: String, + val businessRegNum: String, + val region: RegionInfoResponse, + val audit: AuditInfo +)