// ==========================================
// [모듈] 회원 및 전적 관리 (members)
// ==========================================
function MemberDetailModal({ member, games, onClose }) {
const memberGames = games.filter(g => g.participants.some(p => p.memberId === member.id));
const wins = memberGames.filter(g => g.participants.find(p => p.memberId === member.id)?.isWinner).length;
const winRate = memberGames.length > 0 ? Math.round((wins / memberGames.length) * 100) : 0;
// 🚀 최근 6개월간 월별 승률 통계 계산 (예전 그래프 기능 복원!)
const monthlyStats = {};
memberGames.forEach(g => {
const month = new Date(g.date).toISOString().slice(0, 7); // "YYYY-MM"
if(!monthlyStats[month]) monthlyStats[month] = { total: 0, wins: 0 };
monthlyStats[month].total++;
if(g.participants.find(p => p.memberId === member.id)?.isWinner) monthlyStats[month].wins++;
});
// 최근 6개월 데이터만 추출 정렬
const sortedMonths = Object.keys(monthlyStats).sort().slice(-6);
return (
{member.nickname} 님의 상세 전적
{/* 왼쪽: 통계 요약 및 그래프 */}
총 게임 수
{memberGames.length}전
{/* 월별 승률 바 그래프 */}
{sortedMonths.length > 0 && (
최근 6개월 월별 승률
{sortedMonths.map(m => {
const stat = monthlyStats[m];
const rate = Math.round((stat.wins / stat.total) * 100);
return (
{rate}% ({stat.wins}승)
{m.split('-')[1]}월
)
})}
)}
{/* 오른쪽: 상세 경기 기록 (일별) */}
상세 경기 기록 (일별)
{memberGames.length === 0 ? (
참여한 경기 기록이 없습니다.
) : (
[...memberGames].reverse().map(g => {
const myRecord = g.participants.find(p => p.memberId === member.id);
return (
{myRecord.isWinner ? '승리 👑' : '패배'}
{new Date(g.date).toLocaleDateString()} {new Date(g.date).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
{g.gameType} ({g.matchType}) - 득점: {myRecord.finalScore} / {myRecord.target}점
)
})
)}
);
}
function MemberInfoScreen({ members, games, showAlert, showConfirm, updateMemberInServer, currentClubName, safeNavigate }) {
const [activeTab, setActiveTab] = React.useState('members');
const [showForm, setShowForm] = React.useState(false);
const [editData, setEditData] = React.useState({ id: '', nickname: '', phone: '', target4Gu: 150, target3Gu: 15, memo: '', pin: '' });
const [showKb, setShowKb] = React.useState(false);
const [showNumKb, setShowNumKb] = React.useState(false); // 🚀 숫자 키패드 팝업
const [numTarget, setNumTarget] = React.useState(''); // 어떤 항목에 숫자 입력할지 타겟
const [selectedMember, setSelectedMember] = React.useState(null);
const handleSave = () => {
if (!editData.nickname) return showAlert('이름(닉네임)을 입력해주세요.');
const isNew = !editData.id;
const payload = { ...editData, id: isNew ? 'm_' + Date.now() : editData.id };
updateMemberInServer(payload, isNew ? 'add' : 'update');
setShowForm(false); setShowKb(false); setShowNumKb(false);
showAlert(isNew ? '신규 회원이 등록되었습니다.' : '회원 정보가 수정되었습니다.');
};
const handleDelete = (id) => {
showConfirm('정말로 이 회원을 삭제하시겠습니까?\n(기존 게임 전적은 유지됩니다)', () => { updateMemberInServer({ id }, 'delete'); });
};
const openEditForm = (member = null) => {
if (member) setEditData(member);
else setEditData({ id: '', nickname: '', phone: '', target4Gu: 150, target3Gu: 15, memo: '', pin: '' });
setShowForm(true);
};
const openNumKb = (target) => { setNumTarget(target); setShowNumKb(true); };
return (
{selectedMember &&
setSelectedMember(null)} />}
{showKb && setEditData({...editData, nickname: v})} onClose={() => setShowKb(false)} />}
{/* 🚀 범용 숫자 키패드 연동 */}
{showNumKb && setEditData({...editData, [numTarget]: numTarget.includes('target') ? Number(v) : v})}
onClose={() => setShowNumKb(false)}
isPassword={numTarget === 'pin'}
/>}
회원 및 전적 관리
{activeTab === 'members' && (
등록된 회원 ({members.length}명)
{showForm && (
{editData.id ? '회원 정보 수정' : '신규 회원 등록'}
{/* 🚀 모든 항목에 가상 키보드/키패드 버튼 부착 */}
setEditData({...editData, nickname: v})} ph="터치하여 직접 입력" onKbClick={() => setShowKb(true)} />
setEditData({...editData, phone: v})} ph="예: 01012345678" onNumClick={() => openNumKb('phone')} />
setEditData({...editData, target4Gu: v})} onNumClick={() => openNumKb('target4Gu')} />
setEditData({...editData, target3Gu: v})} onNumClick={() => openNumKb('target3Gu')} />
setEditData({...editData, pin: v})} ph="4자리 숫자 입력 (본인 확인용)" onNumClick={() => openNumKb('pin')} />
setEditData({...editData, memo: v})} ph="동호회 소속 등" onKbClick={() => setShowKb(true)} cs="sm:col-span-1" />
)}
{members.length === 0 && !showForm &&
등록된 회원이 없습니다. 상단의 버튼을 눌러 추가해주세요.
}
{[...members].reverse().map(m => (
setSelectedMember(m)} className="bg-white border-2 border-slate-200 rounded-2xl p-5 hover:border-blue-400 cursor-pointer transition-colors relative group shadow-sm">
{m.nickname}
4구: {m.target4Gu}점
3구: {m.target3Gu}점
{m.phone &&
📞 {m.phone}
}
))}
)}
{activeTab === 'games' && (
모든 경기 통합 전적 ({games.length}건)
| 일시 |
게임/방식 |
참가자 및 점수 (왕관=우승) |
총 이닝 |
{games.length === 0 ? (
| 아직 기록된 게임 전적이 없습니다. |
) : (
games.map(g => (
| {new Date(g.date).toLocaleString('ko-KR', { month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'})} |
{g.gameType}
{g.matchType}
|
{g.participants.map((p, i) => (
{p.isWinner && '👑 '} {p.name} ({p.finalScore}/{p.target}) AVG {p.average}
))}
|
{g.totalInnings} 이닝 |
))
)}
)}
);
} // ==========================================
// [모듈] 선수 배정 및 회원 관련 모달 (가상 키보드 탑재)
// ==========================================
// 🚀 [안전장치] 아이콘 누락으로 인한 에러를 원천 차단하는 자체 내장 아이콘!
const SafeIconUserPlus = ({ className }) => ;
const SafeIconUserCircle = ({ className }) => ;
// 🚀 1. 자체 내장 가상 키보드 엔진 (숫자/영문 완벽 지원)
function VirtualKeyboard({ type, value, onChange, onClose }) {
const [isShift, setIsShift] = React.useState(false);
const layoutNum = [ ['1','2','3'], ['4','5','6'], ['7','8','9'], ['Clear','0','Back'] ];
const layoutEn = [
['1','2','3','4','5','6','7','8','9','0'],
['q','w','e','r','t','y','u','i','o','p'],
['a','s','d','f','g','h','j','k','l'],
['Shift','z','x','c','v','b','n','m','Back'],
['@','.','_','-','Space','Clear']
];
const handleKey = (key) => {
if(key === 'Back') onChange(value.slice(0, -1));
else if(key === 'Clear') onChange('');
else if(key === 'Space') onChange(value + ' ');
else if(key === 'Shift') setIsShift(!isShift);
else onChange(value + (isShift ? key.toUpperCase() : key));
};
const layout = (type === 'number' || type === 'tel') ? layoutNum : layoutEn;
return (
터치 키보드 작동 중... (한국어 이름은 태블릿 기본 키보드 사용 권장)
{layout.map((row, rIdx) => (
{row.map(k => (
))}
))}
);
}
// 🚀 2. 가상 키보드가 연동되는 만능 입력창 (비밀번호 * 처리 완벽 적용)
function VirtualInput({ label, type = "text", value, set, ph, readOnly = false }) {
const [showKb, setShowKb] = React.useState(false);
return (
!readOnly && setShowKb(true)}
onChange={e => set(e.target.value)}
className={`w-full px-4 py-3 sm:py-4 rounded-xl border-2 border-slate-200 text-slate-900 focus:outline-none focus:border-emerald-500 transition-all font-black text-lg tracking-wide ${readOnly ? 'bg-slate-100 text-slate-500' : 'bg-white shadow-inner'}`}
/>
{showKb && setShowKb(false)} />}
);
}
// 🚀 3. 회원 정보 수정 전용 모달
function MemberProfileModal({ onClose, loggedInMember, setLoggedInMember, showAlert, allStores }) {
const [nickname, setNickname] = React.useState(loggedInMember.nickname || '');
const [password, setPassword] = React.useState(loggedInMember.password || '');
const [phone, setPhone] = React.useState(loggedInMember.phone || '');
const [affiliation, setAffiliation] = React.useState(loggedInMember.affiliation || '소속 없음');
const [target3Gu, setTarget3Gu] = React.useState(loggedInMember.target3Gu || 15);
const [target4Gu, setTarget4Gu] = React.useState(loggedInMember.target4Gu || 150);
const dbStores = (allStores || []).filter(s => s.status === 'approved');
const handleSave = async () => {
if(!password || !nickname) return showAlert('필수 정보를 입력해주세요.');
try {
const res = await fetch('api/member_actions.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'update', id: loggedInMember.id, nickname, password, phone, affiliation, target3Gu, target4Gu }) });
const result = await res.json();
if(result.success) { setLoggedInMember(result.user); showAlert(result.message); onClose(); }
else { showAlert(result.message); }
} catch (e) { showAlert('서버 통신 오류'); }
};
return (
내 정보 수정
);
}
// 🚀 4. 점수판 선수 배정 전용 로그인/가입 모달
function GamePlayerLoginModal({ onClose, onSelectPlayer, partnerInfo, allStores, currentPlayers }) {
const [tab, setTab] = React.useState('local');
const [members, setMembers] = React.useState([]);
const [searchName, setSearchName] = React.useState('');
const [loginTarget, setLoginTarget] = React.useState(null);
const [inputPw, setInputPw] = React.useState('');
const [email, setEmail] = React.useState('');
const [password, setPassword] = React.useState('');
const [nickname, setNickname] = React.useState('');
const [phone, setPhone] = React.useState('');
const [target3Gu, setTarget3Gu] = React.useState(15);
const [target4Gu, setTarget4Gu] = React.useState(150);
const fetchMembers = async () => {
try {
const res = await fetch('api/member_actions.php?action=get_all');
const result = await res.json();
if (result.success) setMembers(result.members);
} catch(e) {}
};
React.useEffect(() => { fetchMembers(); }, []);
const localMembers = members.filter(m => m.affiliation === partnerInfo?.storeName);
const filteredOtherMembers = members.filter(m => m.nickname.toLowerCase().includes(searchName.toLowerCase()));
const isPlayerAlreadySelected = (memberId) => {
return currentPlayers && Object.values(currentPlayers).some(p => p && p.id === memberId);
};
const handleTargetClick = (m) => {
if(isPlayerAlreadySelected(m.id)) return alert('🚨 이미 이 게임에 배정된 선수입니다! 다른 선수를 선택하세요.');
setLoginTarget(m);
};
const handleLoginSubmit = async () => {
if (!inputPw) return alert("비밀번호를 입력해주세요.");
try {
const res = await fetch('api/member_actions.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'login', email: loginTarget.email, password: inputPw }) });
const result = await res.json();
if (result.success) { onSelectPlayer(result.user); onClose(); }
else { alert("비밀번호가 일치하지 않습니다."); }
} catch (e) {}
};
const handleRegister = async () => {
if(!email || !password || !nickname) return alert('필수 정보를 입력해주세요.');
try {
const payload = { action: 'register', email, password, nickname, phone, affiliation: partnerInfo?.storeName || '소속 없음', target3Gu, target4Gu };
const res = await fetch('api/member_actions.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
const result = await res.json();
if(result.success) {
alert('가입 완료! 목록에서 이름을 선택해 로그인하세요.');
fetchMembers(); setTab('local');
} else { alert(result.message); }
} catch (e) {}
};
return (
{loginTarget && (
{loginTarget.nickname} 선수
보안을 위해 비밀번호를 입력해 주세요.
)}
{tab === 'local' && (
{localMembers.length === 0 ?
등록된 회원이 없습니다.
: (
{localMembers.map(m => {
const isSelected = isPlayerAlreadySelected(m.id);
return (
handleTargetClick(m)} className={`p-5 rounded-2xl border shadow-sm transition-all cursor-pointer flex flex-col items-center justify-center text-center ${isSelected ? 'bg-slate-200 border-slate-300 opacity-50 cursor-not-allowed' : 'bg-white border-slate-200 hover:border-blue-500 hover:shadow-md active:scale-95'}`}>
{m.nickname}
대대 {m.target3Gu}점 / 중대 {m.target4Gu}점
{isSelected &&
배정완료}
)
})}
)}
)}
{tab === 'other' && (
{filteredOtherMembers.map(m => (
handleTargetClick(m)} className="bg-white p-5 rounded-2xl border border-slate-200 shadow-sm hover:border-blue-500 cursor-pointer flex flex-col items-center text-center">
{m.nickname}
{m.affiliation}
대대 {m.target3Gu} / 중대 {m.target4Gu}
))}
)}
{tab === 'register' && (
)}
);
} // ==========================================
// [모듈] 최고 관리자 (Super Admin) 시스템
// ==========================================
function SuperAdminScreen({ safeNavigate, showAlert }) {
const [stores, setStores] = React.useState([]);
const [pendingPartners, setPendingPartners] = React.useState([]);
const [members, setMembers] = React.useState([]);
const [notices, setNotices] = React.useState(() => safeJSONParse('billiard_global_notices', []));
const [activeTab, setActiveTab] = React.useState('dashboard');
const [selectedPending, setSelectedPending] = React.useState(null);
const [editingStore, setEditingStore] = React.useState(null);
const [editingMember, setEditingMember] = React.useState(null);
// 본사 관리자용 기기 연동 현황
const [viewConfigStore, setViewConfigStore] = React.useState(null);
const [tableConfig, setTableConfig] = React.useState([]);
const [tablePings, setTablePings] = React.useState({});
const [editingDevice, setEditingDevice] = React.useState(null); // 🚀 개별 기기 수정 상태
const [superAdminData, setSuperAdminData] = React.useState({ id: 'admin', pw: '****' });
const [saEditPw, setSaEditPw] = React.useState('');
const [showSaPw, setShowSaPw] = React.useState(false);
const [searchStore, setSearchStore] = React.useState('');
const [searchMember, setSearchMember] = React.useState('');
const fetchData = async () => {
try {
const resStores = await fetch('api/super_admin_actions.php?action=get_stores');
const resultStores = await resStores.json();
if (resultStores.success) {
const all = resultStores.stores || [];
setStores(all.filter(s => s.status === 'approved'));
setPendingPartners(all.filter(s => s.status === 'pending'));
}
const resMembers = await fetch('api/member_actions.php?action=get_all');
const resultMembers = await resMembers.json();
if (resultMembers.success) setMembers(resultMembers.members || []);
} catch(e) {}
};
React.useEffect(() => { fetchData(); }, []);
const safeStores = stores || [];
const safeMembers = members || [];
const filteredStores = safeStores.filter(s => (s.storeName || '').toLowerCase().includes(searchStore.toLowerCase()) || (s.repName || '').toLowerCase().includes(searchStore.toLowerCase()));
const filteredMembers = safeMembers.filter(m => (m.nickname || '').toLowerCase().includes(searchMember.toLowerCase()) || (m.affiliation || '').toLowerCase().includes(searchMember.toLowerCase()));
const handleApprove = async (partner) => {
if(!window.confirm(`[${partner.companyName}] 매장을 승인하시겠습니까?`)) return;
const deviceId = 'VIP_' + Math.random().toString(36).substr(2, 9).toUpperCase();
try {
const res = await fetch('api/super_admin_actions.php', { method: 'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ action: 'approve_store', id: partner.id, deviceId }) });
if((await res.json()).success) { showAlert(`승인 완료!`); setSelectedPending(null); fetchData(); }
} catch(e) {}
};
const handleReject = async (id) => {
if(!window.confirm("신청을 삭제하시겠습니까?")) return;
try {
const res = await fetch('api/super_admin_actions.php', { method: 'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ action: 'reject_store', id }) });
if((await res.json()).success) { showAlert("삭제되었습니다."); setSelectedPending(null); fetchData(); }
} catch(e) {}
};
const handleDeleteStore = async (id) => {
if(!window.confirm("정말 이 업체를 삭제하시겠습니까? 모든 정보가 날아갑니다.")) return;
try {
const res = await fetch('api/super_admin_actions.php', { method: 'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ action: 'delete_store', id }) });
if((await res.json()).success) { showAlert("가맹점이 삭제되었습니다."); fetchData(); }
} catch(e) {}
};
const handleUpdateStore = async () => {
try {
const res = await fetch('api/super_admin_actions.php', { method: 'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ action: 'update_store', ...editingStore }) });
if((await res.json()).success) { showAlert('수정 완료.'); setEditingStore(null); fetchData(); }
} catch(e) {}
};
const handleUpdateSuperAdmin = async () => {
if(!saEditPw) return showAlert('새 비밀번호를 입력해주세요.');
try {
const res = await fetch('api/super_admin_actions.php', { method: 'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ action: 'update_admin_pw', password: saEditPw }) });
if((await res.json()).success) { setSaEditPw(''); showAlert('비밀번호 변경됨!'); setShowSaPw(false); }
} catch(e) {}
};
const handleSaveMember = async () => {
if(!editingMember.email || !editingMember.nickname) return showAlert('필수 정보를 입력하세요.');
try {
const res = await fetch('api/member_actions.php', { method: 'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ ...editingMember, action: 'admin_update' }) });
if((await res.json()).success) { showAlert('회원 수정 완료'); setEditingMember(null); fetchData(); }
} catch(e) {}
};
const handleDeleteMember = async (id) => {
if(!window.confirm("회원을 삭제하시겠습니까?")) return;
try {
const res = await fetch('api/member_actions.php', { method: 'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ action: 'delete', id }) });
if((await res.json()).success) { showAlert('회원 삭제됨'); fetchData(); }
} catch(e) {}
};
const handleAddNotice = () => {
const text = prompt('송출할 공지사항을 입력하세요:');
if(!text) return;
const updated = [{ id: Date.now(), text, date: new Date().toISOString() }, ...notices];
setNotices(updated); window.localStorage.setItem('billiard_global_notices', JSON.stringify(updated));
};
const handleDeleteNotice = (id) => {
const updated = notices.filter(n => n.id !== id);
setNotices(updated); window.localStorage.setItem('billiard_global_notices', JSON.stringify(updated));
};
const openTableConfigView = async (store) => {
setViewConfigStore(store);
try {
const res = await fetch(`api/table_manager.php?action=get_state&store_id=${store.id}`);
const result = await res.json();
if(result.success) { setTableConfig(result.config || []); setTablePings(result.ping || {}); }
} catch(e) {}
};
// 🚀 본사 관리자의 현장 기기 강제 수정/삭제 로직
const handleRemoveDevice = async (deviceId) => {
if(!window.confirm("해당 기기 셋팅을 시스템에서 삭제하시겠습니까?\n(태블릿을 재설정해야 합니다)")) return;
const newConfig = tableConfig.filter(c => c.id !== deviceId);
try {
await fetch('api/table_manager.php', { method: 'POST', body: JSON.stringify({action:'save_entire_config', store_id: viewConfigStore.id, config: newConfig}) });
setTableConfig(newConfig);
showAlert('기기가 삭제되었습니다.');
} catch(e){}
};
const handleUpdateDevice = async () => {
if(!editingDevice.name) return showAlert("이름을 입력하세요.");
const newConfig = tableConfig.map(c => c.id === editingDevice.id ? editingDevice : c);
try {
await fetch('api/table_manager.php', { method: 'POST', body: JSON.stringify({action:'save_entire_config', store_id: viewConfigStore.id, config: newConfig}) });
setTableConfig(newConfig);
setEditingDevice(null);
showAlert('기기 정보가 수정되었습니다.');
} catch(e){}
};
return (
{viewConfigStore && (
기기 연동 현황 관리
[{viewConfigStore.storeName}] 매장에 설치된 기기를 제어합니다.
{tableConfig.length === 0 ? (
현장에 설치된 기기가 없습니다.
) : (
tableConfig.map((t, i) => {
const isConnected = tablePings[t.id] && (Math.floor(Date.now() / 1000) - tablePings[t.id] < 20);
if(editingDevice && editingDevice.id === t.id) {
return (
)
}
return (
{t.name} {t.type}
{isConnected ? '🟢 온라인 (연결됨)' : '🔴 오프라인 (미연결)'}
IP: {t.camIp || '미설정'} | ID: {t.id}
);
})
)}
)}
{/* 입점 대기 모달 */}
{selectedPending && (
입점 신청 상세 검토
상호{selectedPending.companyName}
대표자명{selectedPending.repName}
연락처{selectedPending.phone}
이메일{selectedPending.email}
)}
{/* 강제 수정 모달 */}
{editingStore && (
가맹점 강제 수정
)}
{/* 일반회원 강제 수정 모달 */}
{editingMember && (
회원 정보 강제 수정
)}
최고 관리자 통합 센터
{activeTab === 'dashboard' && (
setActiveTab('stores')}>
도입 완료 가맹점
{safeStores.length}곳
setActiveTab('members')}>
DB 연동 가입 회원
{safeMembers.length}명
setActiveTab('pending')}>
새 신청 ({pendingPartners.length}건)
)}
{activeTab === 'stores' && (
승인된 가맹점 리스트
setSearchStore(e.target.value)} placeholder="검색..." className="px-4 py-2 rounded-lg bg-slate-900 border border-slate-600 outline-none focus:border-blue-500 text-sm font-bold"/>
{filteredStores.map(s => (
{s.storeName}
대표: {s.repName} | 연락처: {s.phone}
))}
)}
{/* 다른 탭들 생략 없이 모두 유지 (코드 길이 방지를 위해 members 등 위와 동일하게 적용) */}
{activeTab === 'members' && (
)}
{activeTab === 'pending' && (
가맹 승인 대기열
{pendingPartners.map(p => (
{p.storeName}
))}
)}
{activeTab === 'notices' && (
본사(글로벌) 공지사항
{notices.map(n => (
{n.text}
))}
)}
{activeTab === 'info_change' && (
)}
);
} // ==========================================
// [모듈] 홈 화면 (대기 화면, 랜딩 화면, 새 게임 설정) (home.php)
// ==========================================
// 🚀 누락되었던 로그인/가입 입력창 UI 컴포넌트 유지
function AuthFormInput({ label, type = "text", value, set, ph, cs = "" }) {
return (
set(e.target.value)}
placeholder={ph}
className="w-full px-4 py-2.5 rounded-lg border border-slate-300 bg-white font-bold outline-none focus:border-blue-500"
/>
);
}
function PartnerAuthModal({ onClose, setPartnerInfo, safeNavigate, showAlert }) {
const [tab, setTab] = React.useState('login');
const [loginEmail, setLoginEmail] = React.useState('');
const [loginPw, setLoginPw] = React.useState('');
const [regData, setRegData] = React.useState({ companyName: '', repName: '', bizAddress: '', phone: '', email: '', password: '', bizNumber: '', bizCategory: '', bizType: '', storeName: '', storePhone: '', address: '', tableMedium: '', tableLarge: '', intro: '' });
const [photos, setPhotos] = React.useState([]);
const copyToStore = (field) => {
if (field === 'name') setRegData(p => ({...p, storeName: p.companyName}));
if (field === 'phone') setRegData(p => ({...p, storePhone: p.phone}));
if (field === 'address') setRegData(p => ({...p, address: p.bizAddress}));
};
const handlePhotoUpload = (e) => {
const files = Array.from(e.target.files);
if (photos.length + files.length > 5) return showAlert('사진은 최대 5장까지만 업로드 가능합니다.');
files.forEach(file => {
const reader = new FileReader();
reader.onloadend = () => setPhotos(prev => [...prev, reader.result]);
reader.readAsDataURL(file);
});
};
const removePhoto = (index) => setPhotos(photos.filter((_, i) => i !== index));
const handleRegister = async () => {
const { companyName, repName, bizAddress, phone, email, password, bizNumber, bizCategory, bizType, storeName, address, tableMedium, tableLarge } = regData;
if (!companyName || !repName || !bizAddress || !phone || !email || !password || !bizNumber || !bizCategory || !bizType || !storeName || !address || !tableMedium || !tableLarge) {
return showAlert('* 표시된 필수 입력 사항을 모두 채워주세요.');
}
try {
const res = await fetch('api/partner_register.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...regData, photos }) });
const result = await res.json();
if (result.success) { showAlert(result.message); onClose(); } else { showAlert(result.message); }
} catch (e) { showAlert('서버 통신 오류가 발생했습니다.'); }
};
const handleLogin = async () => {
if (!loginEmail || !loginPw) return showAlert('이메일과 비밀번호를 입력해주세요.');
try {
const res = await fetch('api/partner_login.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: loginEmail, password: loginPw }) });
const result = await res.json();
if (result.success) {
setPartnerInfo(result.store);
safeNavigate('store_dashboard');
} else { showAlert(result.message); }
} catch (e) { showAlert('서버 통신 오류가 발생했습니다.'); }
};
return (
{tab === 'login' && (
매장(점주) 로그인
승인된 계정으로 로그인하여 시스템을 시작하세요.
)}
{tab === 'register' && (
당구장 가맹 입점 신청서
최고관리자의 승인 후 정식 시스템 이용이 가능합니다. (* 표시는 필수)
1. 사업자 및 계정 정보
setRegData({...regData, companyName:v})} />
setRegData({...regData, repName:v})} />
setRegData({...regData, bizNumber:v})} />
setRegData({...regData, phone:v})} />
setRegData({...regData, bizAddress:v})} cs="sm:col-span-2" />
setRegData({...regData, email:v})} />
setRegData({...regData, password:v})} />
setRegData({...regData, bizCategory:v})} />
setRegData({...regData, bizType:v})} />
2. 매장 상세 정보
setRegData({...regData, tableMedium:v})} cs="flex-1" />
setRegData({...regData, tableLarge:v})} cs="flex-1" />
3. 매장 사진 등록 (최대 5장)
{photos.length > 0 && (
{photos.map((p, idx) => (
))}
)}
)}
);
}
function LandingScreen({ setPartnerInfo, setLoggedInMember, loggedInMember, safeNavigate, allStores }) {
const [showPartnerModal, setShowPartnerModal] = React.useState(false);
const [showMemberModal, setShowMemberModal] = React.useState(false);
const [showMemberProfile, setShowMemberProfile] = React.useState(false);
const [showSuperAdminModal, setShowSuperAdminModal] = React.useState(false);
const [superAdminPw, setSuperAdminPw] = React.useState('');
const handleSuperAdminSubmit = async () => {
if (!superAdminPw) return alert("비밀번호를 입력하세요.");
try {
const res = await fetch('api/super_login.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: superAdminPw }) });
const result = await res.json();
if (result.success) {
setShowSuperAdminModal(false);
setSuperAdminPw('');
safeNavigate('super_admin');
} else { alert(result.message || "비밀번호가 틀렸습니다."); }
} catch (e) { alert('서버 연결에 실패했습니다.'); }
};
const placeholderAlert = (menuName) => alert(`${menuName} 게시판 페이지로 이동합니다. (현재 준비 중입니다)`);
return (
{showPartnerModal &&
setShowPartnerModal(false)} setPartnerInfo={setPartnerInfo} safeNavigate={safeNavigate} showAlert={alert} />}
{showMemberModal && typeof MemberAuthModal !== 'undefined' && setShowMemberModal(false)} setLoggedInMember={setLoggedInMember} safeNavigate={safeNavigate} showAlert={alert} allStores={allStores} />}
{showMemberProfile && loggedInMember && typeof MemberProfileModal !== 'undefined' && setShowMemberProfile(false)} loggedInMember={loggedInMember} setLoggedInMember={setLoggedInMember} showAlert={alert} allStores={allStores} />}
{showSuperAdminModal && (
최고관리자 접속
)}
VIP BILLIARD
{loggedInMember ? (
{loggedInMember.nickname}님
) : (
)}
PREMIUM PORTAL
프리미엄 디지털 당구장
VIP 통합 커뮤니티 포털
VAR 판독, AI 실시간 중계가 탑재된 스마트 당구장 시스템 도입 안내 및
전국 당구인들의 자유로운 소통 공간입니다.
회원 커뮤니티
해당 메뉴를 클릭하시면 상세 게시판으로 이동합니다.
placeholderAlert('공지사항')}>
공지사항
- • VIP 당구클럽 V2.0 업데이트
- • 악성 유저 제재 안내
- • 서버 점검 사전 공지
placeholderAlert('자유게시판')}>
자유게시판
- • 어제 대대 결승 영상 보신분?
- • 스트록 연습 방법 질문합니다
- • 강남역 근처 같이 치실 분 구함
placeholderAlert('당구장 소개')}>
당구장 소개
- • [서울] 역삼 프로 당구클럽 오픈!
- • [경기] 일산 최대 규모 대대 구장
- • [부산] 24시간 영업하는 곳
placeholderAlert('중고장터')}>
중고장터
- • A급 한밭 큐대 팝니다 (직거래)
- • 상태 좋은 당구화 나눔합니다
- • 초크 대량 구매 원합니다
placeholderAlert('제휴 업체 광고 상세')}>
제휴 업체 상품 홍보 배너 영역
당구 큐대, 장갑, 초크 등 당구용품 관련 업체의 광고를 노출합니다.
광고 및 입점 문의
setShowSuperAdminModal(true)} className="fixed bottom-4 right-6 text-slate-400 hover:text-purple-600 font-bold text-xs cursor-pointer transition-colors z-50" title="최고 관리자 접속">
SUPER ADMIN
);
}
function ScreensaverScreen({ partnerInfo, safeNavigate, homeBgConfig, homeBgMedia }) {
const [time, setTime] = React.useState(new Date());
React.useEffect(() => { const t = setInterval(() => setTime(new Date()), 1000); return () => clearInterval(t); }, []);
const globalNotices = window.safeJSONParse('billiard_global_notices', []);
const latestNotice = globalNotices.length > 0 ? globalNotices[0].text : '';
let bgStyle = {};
const safeBg = homeBgConfig || { type: 'camera' };
if (safeBg.type === 'color' && safeBg.colorValue) bgStyle = { backgroundColor: safeBg.colorValue };
else if (safeBg.type === 'image' && homeBgMedia) bgStyle = { backgroundImage: `url(${homeBgMedia})`, backgroundSize: 'cover', backgroundPosition: 'center' };
return (
safeNavigate('setup')} style={bgStyle}>
{(safeBg.type === 'image' || safeBg.type === 'camera') &&
}
{partnerInfo?.storeName || 'VIP 당구클럽'}
{time.toLocaleTimeString('ko-KR', { hour12: false, hour: '2-digit', minute: '2-digit' })}
화면을 터치하여 게임 시작
{latestNotice && (
)}
);
}
// 🚀 선수 배정 슬롯이 제거된 오리지널 심플 셋팅 화면
function SetupScreen(props) {
const {
gameType, setGameType, matchType, setMatchType,
playerCount, setPlayerCount, prepareGame
} = props;
const deviceSetup = React.useMemo(() => {
try { return JSON.parse(window.localStorage.getItem('billiard_device_setup')); }
catch(e) { return null; }
}, []);
React.useEffect(() => {
if(deviceSetup) {
if (deviceSetup.type === '중대') setGameType('4구');
if (deviceSetup.type === '대대') setGameType('3구');
}
}, [deviceSetup, setGameType]);
const handleStart = () => {
window.localStorage.setItem('billiard_current_players', JSON.stringify({}));
prepareGame();
};
return (
당구 게임 설정
게임 방식을 확인하시고 점수판을 열어주세요.
(선수 배정은 점수판 화면에서 진행합니다)
게임 종류
{['4구', '3구'].map(type => (
))}
매치 방식
{['개인전', '팀전'].map(type => (
))}
참여 인원
{(matchType === '팀전' ? [4] : [2, 3, 4]).map(num => (
))}
);
}
function App() {
const [screen, setScreen] = useState(() => safeJSONParse('billiard_screen', 'landing'));
const [screenHistory, setScreenHistory] = useState(() => safeJSONParse('billiard_screenHistory', ['landing']));
const [partnerInfo, setPartnerInfo] = useState(() => safeJSONParse('test_partners', [])[0] || null);
const [loggedInMember, setLoggedInMember] = useState(() => safeJSONParse('billiard_logged_member', null));
const [allStores, setAllStores] = useState(() => safeJSONParse('billiard_all_stores', [])); // 🚀 소속 당구장 리스트용
const [gameType, setGameType] = useState('4구');
const [matchType, setMatchType] = useState('개인전');
const [playerCount, setPlayerCount] = useState(2);
const [gameState, setGameState] = useState('ready');
const [participants, setParticipants] = useState([]);
const [activePlayerIndex, setActivePlayerIndex] = useState(-1);
const [currentTurnScore, setCurrentTurnScore] = useState(0);
const [history, setHistory] = useState([]);
const [gameStartTime, setGameStartTime] = useState(null);
const [now, setNow] = useState(new Date());
const loadedConfig = safeJSONParse('billiard_settings_config', {});
const [deviceId, setDeviceId] = useState(loadedConfig.deviceId || 'DEVICE_01');
const [adminPassword, setAdminPassword] = useState(loadedConfig.adminPassword || '');
const [editPartnerInfo, setEditPartnerInfo] = useState(partnerInfo || {});
// 🚀 요금 테이블 이원화 (4구, 3구)
const [fee4Gu, setFee4Gu] = useState(loadedConfig.fee4Gu || { baseTime: 30, baseFee: 5000, feePer10Min: 1500 });
const [fee3Gu, setFee3Gu] = useState(loadedConfig.fee3Gu || { baseTime: 30, baseFee: 6000, feePer10Min: 2000 });
const [aiEnabled, setAiEnabled] = useState(loadedConfig.aiEnabled ?? true);
const [ttsMode, setTtsMode] = useState(loadedConfig.ttsMode || 'female');
const [aiIntervalSec, setAiIntervalSec] = useState(loadedConfig.aiIntervalSec || 60);
const [globalTtsVolume, setGlobalTtsVolume] = useState(loadedConfig.globalTtsVolume || 1.0);
const [sfxEnabled, setSfxEnabled] = useState(loadedConfig.sfxEnabled ?? true);
// 텍스트 브랜드 속성 제거, 로고 이미지만 유지
const [localLogo, setLocalLogo] = useState(window.localStorage.getItem('billiard_brand_logo') || '');
const [videoMaxStorageMB, setVideoMaxStorageMB] = useState(loadedConfig.videoMaxStorageMB || 500);
const [globalCameraDelay, setGlobalCameraDelay] = useState(loadedConfig.globalCameraDelay || 15);
const [cameraSettings, setCameraSettings] = useState(loadedConfig.cameraSettings || []);
const [hasDirHandle, setHasDirHandle] = useState(false);
const [autoDownloadEnabled, setAutoDownloadEnabled] = useState(false);
// 대기화면 초기 설정에서 카메라 제거 ('color' 로 기본값 변경)
const [homeBgConfig, setHomeBgConfig] = useState(loadedConfig.homeBgConfig || { type: 'color', colorValue: '#0f172a' });
const [homeBgMedia, setHomeBgMedia] = useState(() => window.localStorage.getItem('billiard_home_bg_media') || '');
const [members, setMembers] = useState([]);
const [games, setGames] = useState([]);
const [aiCommentary, setAiCommentary] = useState('');
useEffect(() => { const err = document.getElementById('error-log'); if (err) err.style.display = 'none'; }, []);
useEffect(() => { const t = setInterval(() => setNow(new Date()), 1000); return () => clearInterval(t); }, []);
useEffect(() => { window.localStorage.setItem('billiard_screen', screen); }, [screen]);
useEffect(() => { window.localStorage.setItem('billiard_logged_member', JSON.stringify(loggedInMember)); }, [loggedInMember]);
useEffect(() => { setEditPartnerInfo(partnerInfo || {}); }, [partnerInfo]);
// 스토어 업데이트 감지
useEffect(() => {
const interval = setInterval(() => {
const currentStores = safeJSONParse('billiard_all_stores', []);
setAllStores(currentStores);
}, 2000);
return () => clearInterval(interval);
}, []);
const apiRequest = async (endpoint, method = 'GET', data = null) => {
try {
const options = { method, headers: { 'Content-Type': 'application/json' } };
if (data) options.body = JSON.stringify(data);
const res = await fetch(`${API_BASE}/${endpoint}`, options);
if (!res.ok) return null;
return await res.json();
} catch(e) { return null; }
};
useEffect(() => {
const syncData = async () => {
const mRes = await apiRequest('get_members.php'); if (mRes) setMembers(mRes);
const gRes = await apiRequest('get_games.php'); if (gRes) setGames(gRes);
};
syncData();
const interval = setInterval(syncData, 5000);
return () => clearInterval(interval);
}, []);
const showAlert = (msg) => alert(msg);
const showConfirm = (msg, cb) => { if(window.confirm(msg)) cb(); };
const updatePartnerInServer = (data, action) => {
const allPartner = safeJSONParse('test_partners', []);
if (action === 'add') allPartner.push(data);
else if (action === 'update') {
const idx = allPartner.findIndex(p => p.loginId === data.loginId);
if(idx > -1) allPartner[idx] = data;
}
window.localStorage.setItem('test_partners', JSON.stringify(allPartner));
setPartnerInfo(data);
};
const updateMemberInServer = async (data, action) => {
const success = await apiRequest(action === 'delete' ? 'delete_member.php' : 'save_member.php', 'POST', data);
if (success) {
let current = [...members];
if(action === 'add') current.push(data);
else if (action === 'update') current = current.map(m => m.id === data.id ? {...m, ...data} : m);
else if (action === 'delete') current = current.filter(m => m.id !== data.id);
setMembers(current);
} else { alert("서버 연결 실패."); }
};
const safeNavigate = (targetScreen) => {
setScreenHistory(prev => targetScreen === 'landing' || targetScreen === 'screensaver' || targetScreen === 'store_dashboard' ? [targetScreen] : [...prev, targetScreen]);
setScreen(targetScreen);
};
const goBack = () => {
if (screenHistory.length > 1) {
const newHist = [...screenHistory]; newHist.pop(); setScreenHistory(newHist); setScreen(newHist[newHist.length - 1]);
} else { safeNavigate(partnerInfo ? 'store_dashboard' : 'landing'); }
};
const prepareGame = () => {
const initialParticipants = Array.from({ length: playerCount }).map((_, i) => ({
id: `p${i}`, name: matchType === '개인전' ? `선수 ${i + 1}` : `팀 ${String.fromCharCode(65 + i)}`,
target: '', score: 0, hasEnteredScore: false,
hasThreeCushionRule: gameType === '4구',
hasFoulRule: gameType === '4구',
hasTwoPointRule: false,
isThreeCushionMode: false, isWinner: false, hasMinusInCurrentInning: false, inningLog: []
}));
setParticipants(initialParticipants);
setGameState('ready'); setActivePlayerIndex(-1); setCurrentTurnScore(0); setHistory([]);
safeNavigate('game');
};
const handleStartGamePlay = () => {
if (!(participants || []).every(p => p.hasEnteredScore)) return alert("모든 선수의 목표 점수를 먼저 설정해주세요.");
setGameState('playing'); setActivePlayerIndex(0); setHistory([]);
setGameStartTime(new Date());
};
const handleEndGame = async (skipConfirm = false) => {
if (skipConfirm === true || window.confirm("게임을 종료하고 대기 화면으로 가시겠습니까? (전적이 자동 저장됩니다)")) {
if (matchType === '개인전' && participants.some(p => p.isWinner)) {
const globalInning = Math.max(1, ...(participants || []).map(p => p.inningLog.length));
const gameData = {
date: new Date().toISOString(), gameType, matchType, totalInnings: globalInning,
participants: participants.map(p => ({
name: p.name, isMember: p.isMember, memberId: p.memberId || p.id,
target: p.target, finalScore: p.score, isWinner: p.isWinner,
average: p.inningLog.length > 0 ? (p.score / Math.max(1, p.inningLog.length)).toFixed(3) : 0,
inningLog: p.inningLog
}))
};
await apiRequest('save_game.php', 'POST', gameData);
const gRes = await apiRequest('get_games.php');
if (gRes) setGames(gRes);
}
setGameState('ready'); safeNavigate('screensaver');
}
};
const handleScoreChange = (index, sign) => {
if (gameState !== 'playing') return; let p = participants[index];
if ((sign < 0 && p.hasMinusInCurrentInning) || (sign > 0 && p.hasMinusInCurrentInning) || (sign < 0 && p.isThreeCushionMode)) return;
if(sfxEnabled && typeof playClickSound === 'function' && sign > 0) playClickSound();
if(sfxEnabled && typeof playMinusSound === 'function' && sign < 0) playMinusSound();
setHistory(prev => [...prev, { participants: participants.map(pt => ({...pt, inningLog: [...(pt.inningLog || [])]})), activePlayerIndex, currentTurnScore }].slice(-50));
setParticipants(prev => {
const next = prev.map(pt => ({...pt, inningLog: [...(pt.inningLog || [])]})); let targetP = next[index];
if (gameType === '4구') {
if (targetP.isWinner) { if (sign < 0) { targetP.isWinner = false; targetP.hasMinusInCurrentInning = true; } }
else if (targetP.isThreeCushionMode) { if (sign > 0) { targetP.isWinner = true; } else { targetP.isThreeCushionMode = false; targetP.score = Math.max(0, targetP.target - 10); targetP.hasMinusInCurrentInning = true; } }
else { let newScore = targetP.score + (sign * 10); if (sign < 0) targetP.hasMinusInCurrentInning = true; if (newScore >= targetP.target) { if (targetP.hasThreeCushionRule !== false) { targetP.score = targetP.target; targetP.isThreeCushionMode = true; } else { targetP.score = newScore; targetP.isWinner = true; } } else targetP.score = newScore; }
} else {
let newScore = targetP.score + sign;
if (!targetP.hasFoulRule && newScore < 0) newScore = 0;
if (sign < 0) targetP.hasMinusInCurrentInning = true;
targetP.score = newScore;
if (newScore >= targetP.target) targetP.isWinner = true; else targetP.isWinner = false;
}
return next;
});
if (index === activePlayerIndex) {
setCurrentTurnScore(prev => prev + (gameType === '4구' ? sign * 10 : sign));
}
};
const handleBoardClick = (index) => {
if (gameState !== 'playing') return;
if (activePlayerIndex === index) { if (!participants[index].isWinner && !participants[index].hasMinusInCurrentInning) handleScoreChange(index, 1); }
else {
if(sfxEnabled && typeof playClickSound === 'function') playClickSound();
setHistory(prev => [...prev, { participants: participants.map(pt => ({...pt, inningLog: [...(pt.inningLog || [])]})), activePlayerIndex, currentTurnScore }].slice(-50));
setParticipants(prev => { const next = prev.map(pt => ({...pt, inningLog: [...(pt.inningLog || [])], hasMinusInCurrentInning: false })); if (!next[activePlayerIndex].inningLog) next[activePlayerIndex].inningLog = []; next[activePlayerIndex].inningLog.push(currentTurnScore); return next; });
setCurrentTurnScore(0); setActivePlayerIndex((activePlayerIndex + 1) % participants.length);
}
};
const handleUndo = () => {
if (history.length === 0) return; const prevState = history[history.length - 1];
setParticipants(prevState.participants.map(p => ({...p, inningLog: [...p.inningLog]}))); setActivePlayerIndex(prevState.activePlayerIndex); setCurrentTurnScore(prevState.currentTurnScore); setHistory(prev => prev.slice(0, -1));
};
const handleRestartGame = (skipConfirm = false) => {
const doRestart = () => { setParticipants(prev => (prev || []).map(p => ({ ...p, score: 0, isThreeCushionMode: false, isWinner: false, inningLog: [], hasMinusInCurrentInning: false }))); setGameState('playing'); setActivePlayerIndex(0); setCurrentTurnScore(0); setHistory([]); setGameStartTime(new Date()); };
if (skipConfirm === true) doRestart(); else if(window.confirm("게임을 초기화하고 다시 진행하시겠습니까?")) doRestart();
};
const formatElapsed = () => {
if (!gameStartTime || gameState !== 'playing') return '00:00';
const diffSecs = Math.floor((now - gameStartTime) / 1000);
const h = Math.floor(diffSecs / 3600), m = Math.floor((diffSecs % 3600) / 60), s = diffSecs % 60;
const pad = (num) => String(num).padStart(2, '0');
return h > 0 ? `${pad(h)}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
};
const adminProps = {
deviceId, setDeviceId, adminPassword, setAdminPassword, editPartnerInfo, setEditPartnerInfo, partnerInfo, setPartnerInfo, updatePartnerInServer,
aiEnabled, setAiEnabled, ttsMode, setTtsMode, aiIntervalSec, setAiIntervalSec, globalTtsVolume, setGlobalTtsVolume, sfxEnabled, setSfxEnabled,
localLogo, setLocalLogo, videoMaxStorageMB, setVideoMaxStorageMB, globalCameraDelay, setGlobalCameraDelay, cameraSettings, setCameraSettings,
hasDirHandle, setHasDirHandle, setAutoDownloadEnabled, homeBgConfig, setHomeBgConfig, homeBgMedia, setHomeBgMedia,
fee4Gu, setFee4Gu, fee3Gu, setFee3Gu, safeNavigate, showAlert
};
return (
{screen !== 'landing' && screen !== 'super_admin' && screen !== 'store_dashboard' && }
{screen === 'landing' && }
{screen === 'super_admin' && }
{screen === 'store_dashboard' && }
{screen === 'screensaver' && }
{screen === 'setup' && }
{screen === 'notice' && }
{screen === 'bracket' && }
{screen === 'member_info' && }
{screen === 'game' && (
)}
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render();