// ========================================== // [모듈] 회원 및 전적 관리 (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}전
승리
{wins}승
승률
{winRate}%
{/* 월별 승률 바 그래프 */} {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}점
에버리지
{myRecord.average}
) }) )}
); } 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 (
setEditingDevice({...editingDevice, name: e.target.value})} className="px-3 py-2 border rounded-lg outline-none font-bold" />
setEditingDevice({...editingDevice, camIp: e.target.value})} className="px-3 py-2 border rounded-lg outline-none font-bold" />
) } return (
{t.name} {t.type}
{isConnected ? '🟢 온라인 (연결됨)' : '🔴 오프라인 (미연결)'}
IP: {t.camIp || '미설정'} | ID: {t.id}
); }) )}
)} {/* 입점 대기 모달 */} {selectedPending && (

입점 신청 상세 검토

상호{selectedPending.companyName}
대표자명{selectedPending.repName}
연락처{selectedPending.phone}
이메일{selectedPending.email}
)} {/* 강제 수정 모달 */} {editingStore && (

가맹점 강제 수정

setEditingStore({...editingStore, storeName: e.target.value})} className="px-4 py-2.5 rounded-lg border bg-white font-bold outline-none focus:border-blue-500"/>
setEditingStore({...editingStore, phone: e.target.value})} className="px-4 py-2.5 rounded-lg border bg-white font-bold outline-none focus:border-blue-500"/>
setEditingStore({...editingStore, email: e.target.value})} className="px-4 py-2.5 rounded-lg border bg-white font-bold outline-none focus:border-blue-500"/>
setEditingStore({...editingStore, password: e.target.value})} className="w-full px-4 py-2.5 rounded-lg border bg-white font-bold outline-none focus:border-blue-500"/>
)} {/* 일반회원 강제 수정 모달 */} {editingMember && (

회원 정보 강제 수정

setEditingMember({...editingMember, email: e.target.value})} className="px-4 py-3 rounded-lg border bg-white font-bold outline-none focus:border-emerald-500"/>
setEditingMember({...editingMember, password: e.target.value})} className="px-4 py-3 rounded-lg border bg-white font-bold outline-none focus:border-emerald-500"/>
setEditingMember({...editingMember, nickname: e.target.value})} className="px-4 py-3 rounded-lg border bg-white font-bold outline-none focus:border-emerald-500"/>
)}

최고 관리자 통합 센터

{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' && (

통합 회원 리스트

setSearchMember(e.target.value)} placeholder="회원 검색..." className="px-4 py-2 rounded-lg bg-slate-900 border border-slate-600 outline-none focus:border-emerald-500 text-sm font-bold"/>
{filteredMembers.map(m => ( ))}
이메일닉네임소속관리
{m.email}{m.nickname}{m.affiliation}
)} {activeTab === 'pending' && (

가맹 승인 대기열

{pendingPartners.map(p => (

{p.storeName}

))}
)} {activeTab === 'notices' && (

본사(글로벌) 공지사항

{notices.map(n => (
{n.text}
))}
)} {activeTab === 'info_change' && (

최고 관리자 계정 정보변경

setSaEditPw(e.target.value)} className="w-full px-4 py-3 bg-slate-900 rounded-xl text-white font-bold border border-slate-700 outline-none"/>
)}
); } // ========================================== // [모듈] 홈 화면 (대기 화면, 랜딩 화면, 새 게임 설정) (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, storeName: e.target.value})} className="w-full px-4 py-2.5 rounded-lg border border-slate-300 bg-white font-bold outline-none focus:border-blue-500" />
setRegData({...regData, storePhone: e.target.value})} className="w-full px-4 py-2.5 rounded-lg border border-slate-300 bg-white font-bold outline-none focus:border-blue-500" />
setRegData({...regData, address: e.target.value})} className="w-full px-4 py-2.5 rounded-lg border border-slate-300 bg-white font-bold outline-none focus:border-blue-500" />
setRegData({...regData, tableMedium:v})} cs="flex-1" /> setRegData({...regData, tableLarge:v})} cs="flex-1" />
3. 매장 사진 등록 (최대 5장)
{photos.length > 0 && (
{photos.map((p, idx) => (
preview
))}
)}
)}
); } 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 && (
📢 [공지사항] {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();