[#44] 매장 기능 도입 #45

Merged
pricelees merged 116 commits from feat/#44 into main 2025-09-20 03:15:06 +00:00
12 changed files with 624 additions and 26 deletions
Showing only changes of commit 2481e026eb - Show all commits

View File

@ -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() {
<Route path="/" element={<AdminPage />} />
<Route path="/theme" element={<AdminThemePage />} />
<Route path="/theme/edit/:themeId" element={<AdminThemeEditPage />} />
<Route path="/store" element={<AdminStorePage />} />
<Route path="/schedule" element={<AdminSchedulePage />} />
</Routes>
</AdminLayout>

View File

@ -2,3 +2,10 @@ export interface OperatorInfo {
id: string;
name: string;
}
export interface AuditInfo {
createdAt: string;
updatedAt: string;
createdBy: OperatorInfo;
updatedBy: OperatorInfo;
}

View File

@ -19,3 +19,9 @@ export interface SigunguListResponse {
export interface RegionCodeResponse {
code: string
}
export interface RegionInfoResponse {
code: string,
sidoName: string,
sigunguName: string,
}

View File

@ -12,8 +12,8 @@ export const fetchAvailableThemesByDate = async (date: string): Promise<Availabl
return await apiClient.get<AvailableThemeIdListResponse>(`/schedules/themes?date=${date}`);
};
export const fetchSchedulesByDateAndTheme = async (date: string, themeId: string): Promise<ScheduleRetrieveListResponse> => {
return await apiClient.get<ScheduleRetrieveListResponse>(`/schedules?date=${date}&themeId=${themeId}`);
export const fetchSchedulesByDateAndTheme = async (storeId: number, date: string, themeId: string): Promise<ScheduleRetrieveListResponse> => {
return await apiClient.get<ScheduleRetrieveListResponse>(`/schedules?storeId=${storeId}&date=${date}&themeId=${themeId}`);
};
export const fetchScheduleById = async (id: string): Promise<ScheduleDetailRetrieveResponse> => {

View File

@ -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<SimpleStoreResponse[]> => {
return await apiClient.get('/admin/stores');
};
export const getStoreDetail = async (id: number): Promise<StoreDetailResponse> => {
return await apiClient.get(`/admin/stores/${id}`);
};
export const createStore = async (data: StoreRegisterRequest): Promise<StoreDetailResponse> => {
return await apiClient.post('/admin/stores', data);
};
export const updateStore = async (id: number, data: UpdateStoreRequest): Promise<StoreDetailResponse> => {
return await apiClient.put(`/admin/stores/${id}`, data);
};
export const deleteStore = async (id: number): Promise<void> => {
await apiClient.del(`/admin/stores/${id}`);
};

View File

@ -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;
}

View File

@ -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;
}

View File

@ -22,6 +22,7 @@ const AdminNavbar: React.FC = () => {
<div className="nav-links">
<Link className="nav-link" to="/admin"></Link>
{type === 'HQ' && <Link className="nav-link" to="/admin/theme"></Link>}
{type === 'HQ' && <Link className="nav-link" to="/admin/store"></Link>}
<Link className="nav-link" to="/admin/schedule"></Link>
</div>
<div className="nav-actions">

View File

@ -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<ScheduleRetrieveResponse[]>([]);
const [themes, setThemes] = useState<ThemeForSchedule[]>([]);
const [stores, setStores] = useState<SimpleStoreResponse[]>([]);
const [selectedStoreId, setSelectedStoreId] = useState<string>('');
const [selectedThemeId, setSelectedThemeId] = useState<string>('');
const [selectedDate, setSelectedDate] = useState<string>(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 (
<div className="admin-schedule-container">
<h2 className="page-title"> </h2>
<div className="schedule-controls">
{adminType === 'HQ' && (
<div className="form-group">
<label className="form-label" htmlFor="store-filter"></label>
<select
id="store-filter"
className="form-select"
value={selectedStoreId}
onChange={e => setSelectedStoreId(e.target.value)}
>
{stores.map(store => (
<option key={store.id} value={store.id}>{store.name}</option>
))}
</select>
</div>
)}
<div className="form-group">
<label className="form-label" htmlFor="date-filter"></label>
<input
@ -259,9 +290,11 @@ const AdminSchedulePage: React.FC = () => {
</div>
<div className="section-card">
{canModify && (
<div className="table-header">
<button className="btn btn-primary" onClick={() => setIsAdding(true)}> </button>
</div>
)}
<div className="table-container">
<table>
<thead>
@ -327,10 +360,12 @@ const AdminSchedulePage: React.FC = () => {
</div>
) : (
// --- VIEW MODE ---
canModify && (
<div className="button-group view-mode-buttons">
<button type="button" className="btn btn-danger" onClick={() => handleDeleteSchedule(schedule.id)}></button>
<button type="button" className="btn btn-primary" onClick={handleEditClick}></button>
</div>
)
)}
</div>
) : (
@ -341,7 +376,7 @@ const AdminSchedulePage: React.FC = () => {
)}
</Fragment>
))}
{isAdding && (
{isAdding && canModify && (
<tr className="editing-row">
<td>
<input

View File

@ -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<SimpleStoreResponse[]>([]);
const [isAdding, setIsAdding] = useState(false);
const [newStore, setNewStore] = useState<StoreRegisterRequest>({ name: '', address: '', contact: '', businessRegNum: '', regionCode: '' });
const [expandedStoreId, setExpandedStoreId] = useState<number | null>(null);
const [detailedStores, setDetailedStores] = useState<{ [key: number]: StoreDetailResponse }>({});
const [isLoadingDetails, setIsLoadingDetails] = useState<boolean>(false);
const [isEditing, setIsEditing] = useState(false);
const [editingStore, setEditingStore] = useState<UpdateStoreRequest | null>(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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<div className="admin-store-container">
<h2 className="page-title"> </h2>
<div className="section-card">
<div className="table-header">
<button className="btn btn-primary" onClick={() => setIsAdding(!isAdding)}>
{isAdding ? '취소' : '매장 추가'}
</button>
</div>
{isAdding && (
<div className="add-store-form">
<div className="form-row">
<div className="form-group"><label className="form-label"></label><input type="text" name="name" className="form-input" value={newStore.name} onChange={handleInputChange} /></div>
<div className="form-group"><label className="form-label"></label><input type="text" name="address" className="form-input" value={newStore.address} onChange={handleInputChange} /></div>
</div>
<div className="form-row">
<div className="form-group"><label className="form-label"></label><input type="text" name="contact" className="form-input" value={newStore.contact} onChange={handleInputChange} /></div>
<div className="form-group"><label className="form-label"></label><input type="text" name="businessRegNum" className="form-input" value={newStore.businessRegNum} onChange={handleInputChange} /></div>
<div className="form-group"><label className="form-label"> </label><input type="text" name="regionCode" className="form-input" value={newStore.regionCode} onChange={handleInputChange} /></div>
</div>
<div className="button-group">
<button className="btn btn-primary" onClick={handleAddStore}></button>
</div>
</div>
)}
<div className="table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{stores.map(store => (
<Fragment key={store.id}>
<tr>
<td>{store.id}</td>
<td>{store.name}</td>
<td className="action-buttons">
<button className="btn btn-secondary" onClick={() => handleToggleDetails(store.id)}>
{expandedStoreId === store.id ? '닫기' : '상세'}
</button>
</td>
</tr>
{expandedStoreId === store.id && (
<tr className="details-row">
<td colSpan={3}>
<div className="details-container">
{isLoadingDetails ? <p> ...</p> : detailedStores[store.id] ? (
<div>
<div className="audit-info">
<h4 className="audit-title"> </h4>
<div className="audit-body">
<p><strong>:</strong> {detailedStores[store.id].address}</p>
<p><strong>:</strong> {detailedStores[store.id].contact}</p>
<p><strong>:</strong> {detailedStores[store.id].businessRegNum}</p>
<p><strong> :</strong> {detailedStores[store.id].regionCode}</p>
<p><strong>:</strong> {new Date(detailedStores[store.id].createdAt).toLocaleString()}</p>
<p><strong>:</strong> {new Date(detailedStores[store.id].updatedAt).toLocaleString()}</p>
<p><strong>:</strong> {detailedStores[store.id].createdBy.name}</p>
<p><strong>:</strong> {detailedStores[store.id].updatedBy.name}</p>
</div>
</div>
{isEditing && editingStore ? (
<div className="details-form-card">
<div className="form-row">
<div className="form-group"><label className="form-label"></label><input type="text" name="name" className="form-input" value={editingStore.name} onChange={handleEditChange} /></div>
<div className="form-group"><label className="form-label"></label><input type="text" name="address" className="form-input" value={editingStore.address} onChange={handleEditChange} /></div>
<div className="form-group"><label className="form-label"></label><input type="text" name="contact" className="form-input" value={editingStore.contact} onChange={handleEditChange} /></div>
</div>
<div className="button-group">
<button className="btn btn-secondary" onClick={handleCancelEdit}></button>
<button className="btn btn-primary" onClick={() => handleSave(store.id)}></button>
</div>
</div>
) : (
<div className="button-group">
<button className="btn btn-danger" onClick={() => handleDeleteStore(store.id)}></button>
<button className="btn btn-primary" onClick={() => handleEditClick(detailedStores[store.id])}></button>
</div>
)}
</div>
) : <p> .</p>}
</div>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
</div>
</div>
);
};
export default AdminStorePage;

View File

@ -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,14 +37,17 @@ 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 {
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
}
}
private fun findOrThrow(id: Long): AdminEntity {

View File

@ -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<SimpleStoreResponse>
)
fun List<StoreEntity>.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<StoreInfoResponse>
)
fun List<StoreEntity>.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
)