generated from pricelees/issue-pr-template
<!-- 제목 양식 --> <!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) --> ## 📝 관련 이슈 및 PR **PR과 관련된 이슈 번호** - #54 ## ✨ 작업 내용 <!-- 어떤 작업을 했는지 알려주세요! --> - 애플리케이션 배포 - 1차 배포에서 각 Service의 Trace가 구분이 되지 않아 XxxService 클래스에 \@Observation을 적용하는 AOP 추가 - 불필요하게 느껴지는 Prometheus Actuator 요청과 스케쥴링 작업 Tracing 제외 - 애플리케이션이 UTC로 배포됨에 따라 발생하는 문제 해결을 위해 LocalDateTime, OffsetDateTime -> Instant 타입 변경 및 LocalDate, LocalTime은 KST로 비교하도록 수정 - 기존 로그의 가독성이 좋지 않아, 로그 메시지가 가장 먼저 보이도록 형식 수정 ## 🧪 테스트 <!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! --> - 실제 웹에 접속하여 전체적인 기능 점검 - 예약 처리 로직에서 미숙한 부분이 발견되어 다음 작업은 예약 처리 로직 개선 예정 ## 📚 참고 자료 및 기타 <!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! --> Reviewed-on: #55 Co-authored-by: pricelees <priceelees@gmail.com> Co-committed-by: pricelees <priceelees@gmail.com>
371 lines
19 KiB
TypeScript
371 lines
19 KiB
TypeScript
import {isLoginRequiredError} from '@_api/apiClient';
|
|
import {fetchSidoList, fetchSigunguList} from '@_api/region/regionAPI';
|
|
import type {SidoResponse, SigunguResponse} from '@_api/region/regionTypes';
|
|
import {createStore, deleteStore, getStoreDetail, getStores, updateStore} from '@_api/store/storeAPI';
|
|
import {
|
|
type SimpleStoreResponse,
|
|
type StoreDetailResponse,
|
|
type StoreRegisterRequest,
|
|
type UpdateStoreRequest
|
|
} from '@_api/store/storeTypes';
|
|
import {useAdminAuth} from '@_context/AdminAuthContext';
|
|
import '@_css/admin-store-page.css';
|
|
import {formatDisplayDateTime} from '@_util/DateTimeFormatter';
|
|
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<string | null>(null);
|
|
const [detailedStores, setDetailedStores] = useState<{ [key: string]: StoreDetailResponse }>({});
|
|
const [isLoadingDetails, setIsLoadingDetails] = useState<boolean>(false);
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [editingStore, setEditingStore] = useState<UpdateStoreRequest | null>(null);
|
|
|
|
const [sidoList, setSidoList] = useState<SidoResponse[]>([]);
|
|
const [sigunguList, setSigunguList] = useState<SigunguResponse[]>([]);
|
|
const [selectedSido, setSelectedSido] = useState('');
|
|
const [selectedSigungu, setSelectedSigungu] = useState('');
|
|
|
|
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(selectedSido || undefined, selectedSigungu || undefined)).stores;
|
|
setStores(storesData);
|
|
} catch (error) {
|
|
handleError(error);
|
|
};
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (adminType !== 'HQ') {
|
|
alert('접근 권한이 없습니다.');
|
|
navigate('/admin');
|
|
return;
|
|
}
|
|
|
|
const fetchInitialData = async () => {
|
|
try {
|
|
const sidoRes = await fetchSidoList();
|
|
setSidoList(sidoRes.sidoList);
|
|
} catch (error) {
|
|
handleError(error);
|
|
}
|
|
};
|
|
|
|
fetchInitialData();
|
|
}, [adminType, navigate]);
|
|
|
|
useEffect(() => {
|
|
const fetchSigungu = async () => {
|
|
if (selectedSido) {
|
|
try {
|
|
const sigunguRes = await fetchSigunguList(selectedSido);
|
|
setSigunguList(sigunguRes.sigunguList);
|
|
} catch (error) {
|
|
handleError(error);
|
|
}
|
|
} else {
|
|
setSigunguList([]);
|
|
}
|
|
setSelectedSigungu('');
|
|
};
|
|
fetchSigungu();
|
|
}, [selectedSido]);
|
|
|
|
useEffect(() => { fetchStores();}, [selectedSido, selectedSigungu]);
|
|
|
|
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);
|
|
const storesData = (await getStores(selectedSido || undefined, selectedSigungu || undefined)).stores;
|
|
setStores(storesData);
|
|
setIsAdding(false);
|
|
setNewStore({ name: '', address: '', contact: '', businessRegNum: '', regionCode: '' });
|
|
} catch (error) {
|
|
handleError(error);
|
|
}
|
|
};
|
|
|
|
const handleToggleDetails = async (storeId: string) => {
|
|
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: string) => {
|
|
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: string) => {
|
|
if (!editingStore) return;
|
|
try {
|
|
await updateStore(storeId, editingStore);
|
|
const updatedStore = await getStoreDetail(storeId);
|
|
setDetailedStores(prev => ({ ...prev, [storeId]: updatedStore }));
|
|
setStores(prev => prev.map(s => s.id === String(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="filter-controls">
|
|
<div className="form-group">
|
|
<label className="form-label">시/도</label>
|
|
<select className="form-select" value={selectedSido} onChange={e => setSelectedSido(e.target.value)}>
|
|
<option value="">전체</option>
|
|
{sidoList.map(s => <option key={s.code} value={s.code}>{s.name}</option>)}
|
|
</select>
|
|
</div>
|
|
<div className="form-group">
|
|
<label className="form-label">시/군/구</label>
|
|
<select className="form-select" value={selectedSigungu} onChange={e => setSelectedSigungu(e.target.value)} disabled={!selectedSido}>
|
|
<option value="">전체</option>
|
|
{sigunguList.map(s => <option key={s.code} value={s.code}>{s.name}</option>)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<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].region.code}
|
|
</p>
|
|
<p>
|
|
<strong>생성일:</strong> {formatDisplayDateTime(detailedStores[store.id].audit.createdAt)}
|
|
</p>
|
|
<p>
|
|
<strong>수정일:</strong> {formatDisplayDateTime(detailedStores[store.id].audit.updatedAt)}
|
|
</p>
|
|
<p>
|
|
<strong>생성자:</strong> {detailedStores[store.id].audit.createdBy.name}({detailedStores[store.id].audit.createdBy.id})
|
|
</p>
|
|
<p>
|
|
<strong>수정자:</strong> {detailedStores[store.id].audit.updatedBy.name}({detailedStores[store.id].audit.updatedBy.id})
|
|
</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; |