516 lines
19 KiB
TypeScript
516 lines
19 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { api } from '@common/services/api';
|
|
import { useMapStore } from '@common/store/mapStore';
|
|
|
|
// ─── 타입 ─────────────────────────────────────────────────
|
|
interface MapBaseItem {
|
|
mapSn: number;
|
|
mapKey: string;
|
|
mapNm: string;
|
|
mapLevelCd: string | null;
|
|
mapSrc: string | null;
|
|
mapDc: string | null;
|
|
useYn: string;
|
|
regId: string | null;
|
|
regNm: string | null;
|
|
regDtm: string | null;
|
|
}
|
|
|
|
interface MapBaseForm {
|
|
mapKey: string;
|
|
mapNm: string;
|
|
mapLevelCd: string;
|
|
mapSrc: string;
|
|
mapDc: string;
|
|
useYn: string;
|
|
}
|
|
|
|
interface Message {
|
|
type: 'success' | 'error';
|
|
text: string;
|
|
}
|
|
|
|
// ─── 상수 ─────────────────────────────────────────────────
|
|
const EMPTY_FORM: MapBaseForm = {
|
|
mapKey: '',
|
|
mapNm: '',
|
|
mapLevelCd: '',
|
|
mapSrc: '',
|
|
mapDc: '',
|
|
useYn: 'Y',
|
|
};
|
|
|
|
const MAP_LEVEL_OPTIONS = ['S-52', 'S-57', 'S-101', '3D', 'SAT', '기타'] as const;
|
|
|
|
// ─── 모달 ─────────────────────────────────────────────────
|
|
interface MapBaseModalProps {
|
|
editItem: MapBaseItem | null;
|
|
form: MapBaseForm;
|
|
onFormChange: (form: MapBaseForm) => void;
|
|
onClose: () => void;
|
|
onSave: () => Promise<void>;
|
|
saving: boolean;
|
|
modalError: string | null;
|
|
}
|
|
|
|
function MapBaseModal({
|
|
editItem,
|
|
form,
|
|
onFormChange,
|
|
onClose,
|
|
onSave,
|
|
saving,
|
|
modalError,
|
|
}: MapBaseModalProps) {
|
|
const isEdit = editItem !== null;
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
await onSave();
|
|
};
|
|
|
|
const setField = <K extends keyof MapBaseForm>(key: K, value: MapBaseForm[K]) => {
|
|
onFormChange({ ...form, [key]: value });
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
|
<div className="bg-bg-surface border border-stroke rounded-lg shadow-lg w-[520px] max-h-[90vh] flex flex-col">
|
|
{/* 모달 헤더 */}
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
|
<h2 className="text-body-2 font-bold text-fg font-korean">
|
|
{isEdit ? '지도 수정' : '지도 등록'}
|
|
</h2>
|
|
<button onClick={onClose} className="text-fg-disabled hover:text-fg transition-colors">
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2.5"
|
|
>
|
|
<path d="M18 6L6 18M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* 폼 */}
|
|
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto">
|
|
<div className="px-6 py-4 space-y-4">
|
|
{/* 지도 이름 */}
|
|
<div>
|
|
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
|
|
지도 이름 <span className="text-red-400">*</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={form.mapNm}
|
|
onChange={(e) => setField('mapNm', e.target.value)}
|
|
placeholder="지도 이름을 입력하세요"
|
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
|
/>
|
|
</div>
|
|
|
|
{/* 지도 키 */}
|
|
<div>
|
|
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
|
|
지도 키 <span className="text-red-400">*</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={form.mapKey}
|
|
onChange={(e) => setField('mapKey', e.target.value)}
|
|
placeholder="고유 식별 키 (영문/숫자)"
|
|
disabled={isEdit}
|
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono disabled:opacity-50 disabled:cursor-not-allowed"
|
|
/>
|
|
</div>
|
|
|
|
{/* 지도 레벨 */}
|
|
<div>
|
|
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
|
|
지도 레벨
|
|
</label>
|
|
<select
|
|
value={form.mapLevelCd}
|
|
onChange={(e) => setField('mapLevelCd', e.target.value)}
|
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
|
>
|
|
<option value="">선택</option>
|
|
{MAP_LEVEL_OPTIONS.map((opt) => (
|
|
<option key={opt} value={opt}>
|
|
{opt}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* 파일 소스 */}
|
|
<div>
|
|
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
|
|
파일 소스
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={form.mapSrc}
|
|
onChange={(e) => setField('mapSrc', e.target.value)}
|
|
placeholder="타일 URL 또는 파일 경로"
|
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
|
|
/>
|
|
</div>
|
|
|
|
{/* 상세 설명 */}
|
|
<div>
|
|
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
|
|
상세 설명
|
|
</label>
|
|
<textarea
|
|
rows={3}
|
|
value={form.mapDc}
|
|
onChange={(e) => setField('mapDc', e.target.value)}
|
|
placeholder="지도에 대한 설명을 입력하세요"
|
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean resize-none"
|
|
/>
|
|
</div>
|
|
|
|
{/* 사용여부 */}
|
|
<div>
|
|
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
|
|
사용여부
|
|
</label>
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setField('useYn', form.useYn === 'Y' ? 'N' : 'Y')}
|
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
form.useYn === 'Y' ? 'bg-color-accent' : 'bg-border'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
|
form.useYn === 'Y' ? 'translate-x-6' : 'translate-x-1'
|
|
}`}
|
|
/>
|
|
</button>
|
|
<span className="text-caption text-fg-sub font-korean">
|
|
{form.useYn === 'Y' ? '사용' : '미사용'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 에러 */}
|
|
{modalError && <p className="text-label-2 text-red-400 font-korean">{modalError}</p>}
|
|
</div>
|
|
|
|
{/* 모달 푸터 */}
|
|
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-stroke">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="px-4 py-2 text-caption border border-stroke text-fg-sub rounded-md hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
|
>
|
|
취소
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={saving}
|
|
className="px-4 py-2 text-caption font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all disabled:opacity-50 font-korean"
|
|
>
|
|
{saving ? '저장 중...' : isEdit ? '수정' : '등록'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 메인 패널 ─────────────────────────────────────────────
|
|
function MapBasePanel() {
|
|
const loadMapTypes = useMapStore((s) => s.loadMapTypes);
|
|
const [items, setItems] = useState<MapBaseItem[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [page, setPage] = useState(1);
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
const [loading, setLoading] = useState(false);
|
|
const [message, setMessage] = useState<Message | null>(null);
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [editItem, setEditItem] = useState<MapBaseItem | null>(null);
|
|
const [form, setForm] = useState<MapBaseForm>(EMPTY_FORM);
|
|
const [saving, setSaving] = useState(false);
|
|
const [modalError, setModalError] = useState<string | null>(null);
|
|
|
|
const loadData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await api.get<{ rows: MapBaseItem[]; total: number }>(
|
|
`/map-base?page=${page}&limit=10`,
|
|
);
|
|
setItems(res.data.rows);
|
|
setTotal(res.data.total);
|
|
setTotalPages(Math.max(1, Math.ceil(res.data.total / 10)));
|
|
} catch {
|
|
setMessage({ type: 'error', text: '지도 목록을 불러오는 데 실패했습니다.' });
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// page 변경 시 목록 재조회 (loadData는 page를 클로저로 참조)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
useEffect(() => {
|
|
loadData();
|
|
}, [page]);
|
|
|
|
const openModal = (item: MapBaseItem | null) => {
|
|
setEditItem(item);
|
|
setModalError(null);
|
|
if (item) {
|
|
setForm({
|
|
mapKey: item.mapKey,
|
|
mapNm: item.mapNm,
|
|
mapLevelCd: item.mapLevelCd ?? '',
|
|
mapSrc: item.mapSrc ?? '',
|
|
mapDc: item.mapDc ?? '',
|
|
useYn: item.useYn,
|
|
});
|
|
} else {
|
|
setForm(EMPTY_FORM);
|
|
}
|
|
setShowModal(true);
|
|
};
|
|
|
|
const closeModal = () => {
|
|
setShowModal(false);
|
|
setEditItem(null);
|
|
setForm(EMPTY_FORM);
|
|
setModalError(null);
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!form.mapNm.trim()) {
|
|
setModalError('지도 이름은 필수 항목입니다.');
|
|
return;
|
|
}
|
|
if (!form.mapKey.trim()) {
|
|
setModalError('지도 키는 필수 항목입니다.');
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
setModalError(null);
|
|
try {
|
|
if (editItem) {
|
|
await api.post('/map-base/update', { mapSn: editItem.mapSn, ...form });
|
|
} else {
|
|
await api.post('/map-base', form);
|
|
}
|
|
closeModal();
|
|
await loadData();
|
|
setMessage({
|
|
type: 'success',
|
|
text: editItem ? '지도 정보가 수정되었습니다.' : '지도가 등록되었습니다.',
|
|
});
|
|
setTimeout(() => setMessage(null), 3000);
|
|
} catch {
|
|
setModalError(editItem ? '지도 수정에 실패했습니다.' : '지도 등록에 실패했습니다.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (item: MapBaseItem) => {
|
|
if (!window.confirm('이 지도를 삭제하시겠습니까?')) return;
|
|
try {
|
|
await api.post('/map-base/delete', { mapSn: item.mapSn });
|
|
await loadData();
|
|
setMessage({ type: 'success', text: '지도가 삭제되었습니다.' });
|
|
setTimeout(() => setMessage(null), 3000);
|
|
} catch {
|
|
setMessage({ type: 'error', text: '지도 삭제에 실패했습니다.' });
|
|
}
|
|
};
|
|
|
|
const handleToggleUse = async (item: MapBaseItem) => {
|
|
try {
|
|
await api.post('/map-base/update', {
|
|
mapSn: item.mapSn,
|
|
useYn: item.useYn === 'Y' ? 'N' : 'Y',
|
|
});
|
|
await loadData();
|
|
await loadMapTypes();
|
|
} catch {
|
|
setMessage({ type: 'error', text: '사용여부 변경에 실패했습니다.' });
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col h-full overflow-hidden bg-bg-base">
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
|
<div>
|
|
<h1 className="text-lg font-bold text-fg font-korean">지도 관리</h1>
|
|
<p className="text-caption text-fg-disabled mt-1 font-korean">총 {total}건</p>
|
|
</div>
|
|
<button
|
|
onClick={() => openModal(null)}
|
|
className="px-4 py-2 text-caption font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
|
|
>
|
|
+ 등록
|
|
</button>
|
|
</div>
|
|
|
|
{/* 메시지 */}
|
|
{message && (
|
|
<div
|
|
className={`mx-6 mt-2 px-3 py-2 text-label-2 rounded-md font-korean ${
|
|
message.type === 'success'
|
|
? 'text-green-400 bg-[rgba(74,222,128,0.08)] border border-[rgba(74,222,128,0.2)]'
|
|
: 'text-red-400 bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.2)]'
|
|
}`}
|
|
>
|
|
{message.text}
|
|
</div>
|
|
)}
|
|
|
|
{/* 테이블 영역 */}
|
|
<div className="flex-1 overflow-auto">
|
|
<table className="w-full text-caption">
|
|
<thead className="sticky top-0 bg-bg-surface z-10">
|
|
<tr className="border-b border-stroke text-fg-disabled">
|
|
<th className="w-12 py-3 text-center">번호</th>
|
|
<th className="py-3 text-left pl-4">지도 리스트</th>
|
|
<th className="w-20 py-3 text-center">지도 레벨</th>
|
|
<th className="w-24 py-3 text-center">등록자</th>
|
|
<th className="w-28 py-3 text-center">등록일</th>
|
|
<th className="w-16 py-3 text-center">사용여부</th>
|
|
<th className="w-12 py-3 text-center">수정</th>
|
|
<th className="w-12 py-3 text-center">삭제</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading ? (
|
|
<tr>
|
|
<td colSpan={8} className="py-8 text-center text-fg-disabled font-korean">
|
|
불러오는 중...
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
items.map((item, idx) => (
|
|
<tr
|
|
key={item.mapSn}
|
|
className="border-b border-stroke hover:bg-bg-surface/50 transition-colors"
|
|
>
|
|
<td className="py-3 text-center text-fg-disabled">{(page - 1) * 10 + idx + 1}</td>
|
|
<td className="py-3 pl-4">
|
|
<span className="text-fg font-korean">{item.mapNm}</span>
|
|
<span className="ml-2 text-caption text-fg-disabled font-mono">
|
|
{item.mapKey}
|
|
</span>
|
|
</td>
|
|
<td className="py-3 text-center text-fg-sub">{item.mapLevelCd ?? '-'}</td>
|
|
<td className="py-3 text-center text-fg-sub font-korean">{item.regNm ?? '-'}</td>
|
|
<td className="py-3 text-center text-fg-disabled">{item.regDtm ?? '-'}</td>
|
|
<td
|
|
className="py-3 text-center cursor-pointer"
|
|
onClick={() => handleToggleUse(item)}
|
|
>
|
|
<div className="flex justify-center">
|
|
<button
|
|
type="button"
|
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
|
item.useYn === 'Y' ? 'bg-color-accent' : 'bg-border'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
|
|
item.useYn === 'Y' ? 'translate-x-5' : 'translate-x-1'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
<td className="py-3 text-center">
|
|
<button
|
|
onClick={() => openModal(item)}
|
|
className="px-3 py-1 text-caption rounded bg-[rgba(6,182,212,0.15)] text-color-accent hover:bg-[rgba(6,182,212,0.25)]"
|
|
>
|
|
수정
|
|
</button>
|
|
</td>
|
|
<td className="py-3 text-center">
|
|
<button
|
|
onClick={() => handleDelete(item)}
|
|
className="px-3 py-1 text-caption rounded bg-red-500/20 text-red-400 hover:bg-red-500/30"
|
|
>
|
|
삭제
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
{!loading && items.length === 0 && (
|
|
<div className="flex items-center justify-center h-32 text-caption text-fg-disabled font-korean">
|
|
등록된 지도가 없습니다.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 페이지네이션 */}
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-center gap-1 py-2 border-t border-stroke">
|
|
<button
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
disabled={page <= 1}
|
|
className="px-2 py-1 text-caption rounded text-fg-disabled hover:bg-bg-elevated disabled:opacity-30"
|
|
>
|
|
<
|
|
</button>
|
|
{Array.from({ length: Math.min(totalPages, 10) }, (_, i) => {
|
|
const startPage = Math.max(1, Math.min(page - 4, totalPages - 9));
|
|
const p = startPage + i;
|
|
if (p > totalPages) return null;
|
|
return (
|
|
<button
|
|
key={p}
|
|
onClick={() => setPage(p)}
|
|
className={`w-7 h-7 text-caption rounded ${
|
|
p === page
|
|
? 'bg-blue-500/20 text-blue-400 font-medium'
|
|
: 'text-fg-disabled hover:bg-bg-elevated'
|
|
}`}
|
|
>
|
|
{p}
|
|
</button>
|
|
);
|
|
})}
|
|
<button
|
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
disabled={page >= totalPages}
|
|
className="px-2 py-1 text-caption rounded text-fg-disabled hover:bg-bg-elevated disabled:opacity-30"
|
|
>
|
|
>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* 모달 */}
|
|
{showModal && (
|
|
<MapBaseModal
|
|
editItem={editItem}
|
|
form={form}
|
|
onFormChange={setForm}
|
|
onClose={closeModal}
|
|
onSave={handleSave}
|
|
saving={saving}
|
|
modalError={modalError}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default MapBasePanel;
|