generated from pricelees/issue-pr-template
feat: 매장이 반영된 프론트엔드 관리자 페이지 초안
This commit is contained in:
parent
8cd1084bd8
commit
2481e026eb
@ -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>
|
||||
|
||||
@ -2,3 +2,10 @@ export interface OperatorInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AuditInfo {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdBy: OperatorInfo;
|
||||
updatedBy: OperatorInfo;
|
||||
}
|
||||
@ -19,3 +19,9 @@ export interface SigunguListResponse {
|
||||
export interface RegionCodeResponse {
|
||||
code: string
|
||||
}
|
||||
|
||||
export interface RegionInfoResponse {
|
||||
code: string,
|
||||
sidoName: string,
|
||||
sigunguName: string,
|
||||
}
|
||||
@ -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> => {
|
||||
|
||||
22
frontend/src/api/store/storeAPI.ts
Normal file
22
frontend/src/api/store/storeAPI.ts
Normal 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}`);
|
||||
};
|
||||
32
frontend/src/api/store/storeTypes.ts
Normal file
32
frontend/src/api/store/storeTypes.ts
Normal 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;
|
||||
}
|
||||
194
frontend/src/css/admin-store-page.css
Normal file
194
frontend/src/css/admin-store-page.css
Normal 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;
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
<div className="table-header">
|
||||
<button className="btn btn-primary" onClick={() => setIsAdding(true)}>일정 추가</button>
|
||||
</div>
|
||||
{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 ---
|
||||
<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>
|
||||
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
|
||||
|
||||
240
frontend/src/pages/admin/AdminStorePage.tsx
Normal file
240
frontend/src/pages/admin/AdminStorePage.tsx
Normal 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;
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
52
src/main/kotlin/roomescape/store/web/StoreDTO.kt
Normal file
52
src/main/kotlin/roomescape/store/web/StoreDTO.kt
Normal 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
|
||||
)
|
||||
Loading…
x
Reference in New Issue
Block a user