// ── AdminPage.jsx ────────────────────────────────────────────── // Admin dashboard: login, submission list, settings function getFirebaseAdminAuth() { return window.CIQ_FIREBASE_AUTH || null; } function AdminLogin({ onLogin, initialError }) { const [loading, setLoading] = React.useState(false); const [status, setStatus] = React.useState('Firebase Auth 프로젝트 auth-f7b2b를 사용합니다.'); const [err, setErr] = React.useState(initialError || ''); React.useEffect(() => { setErr(initialError || ''); }, [initialError]); async function handleLogin() { const firebaseAuth = getFirebaseAdminAuth(); if (!firebaseAuth) { setErr('Firebase 로그인 설정을 찾을 수 없습니다.'); return; } setLoading(true); setErr(''); setStatus('Google 로그인 창을 여는 중입니다.'); try { const user = await firebaseAuth.signInWithGoogle(); setStatus('로그인이 완료되었습니다.'); onLogin(user); } catch (error) { setErr(firebaseAuth.loginMessage(error)); setStatus('허용된 Google 계정으로 다시 시도해 주세요.'); } finally { setLoading(false); } } return (

관리자 로그인

Yacht CIQ 서류 지원 시스템

허용된 Google 관리자 계정으로만 접속할 수 있습니다.
{err &&

{err}

}

{status}

); } const STATUS_LABELS = { received: '접수됨', reviewing: '검토 중', missing_info: '보완 요청', validated: '자동화 가능', generated: '서류 생성', packaged: '패키지 완료', submitted: '제출 완료', archived: '보관', }; const STATUS_COLORS = { received: 'badge-yellow', reviewing: 'badge-blue', missing_info: 'badge-red', validated: 'badge-green', generated: 'badge-blue', packaged: 'badge-green', submitted: 'badge-green', archived: 'badge-gray', }; function adminNowIso() { return new Date().toISOString(); } function adminEmptyDraftData() { return { applicant: {}, voyage: {}, vessel: {}, captain: { crew: [] }, crew: { crew: [{}] }, consent: {}, }; } function adminNormalizeDraftRecord(id, raw = {}) { const data = raw.data || adminEmptyDraftData(); return { id, docName: raw.docName || '', pin: String(raw.pin || ''), data, files: raw.files || {}, revisions: Array.isArray(raw.revisions) ? raw.revisions : [], createdAt: raw.createdAt || raw.updatedAt || adminNowIso(), updatedAt: raw.updatedAt || raw.createdAt || adminNowIso(), }; } function adminGetDraftRecords() { const records = []; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (!key || !key.startsWith('ciq_draft_')) continue; try { const id = key.replace('ciq_draft_', ''); records.push(adminNormalizeDraftRecord(id, JSON.parse(localStorage.getItem(key)))); } catch {} } return records.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)); } function adminSaveDraftRecord(record) { localStorage.setItem(`ciq_draft_${record.id}`, JSON.stringify(record)); } function adminDraftKey(id) { return `ciq_draft_${id}`; } function adminFormatDate(value) { if (!value) return '미기록'; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return date.toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' }); } function AdminDashboard({ adminUser, onLogout }) { const [tab, setTab] = React.useState('submissions'); // 'submissions' | 'documents' | 'settings' const [selected, setSelected] = React.useState(null); const [search, setSearch] = React.useState(''); const [gaId, setGaId] = React.useState('G-XXXXXXXXXX'); const [clarityId, setClarityId] = React.useState(''); const [adminNotifyEmail, setAdminNotifyEmail] = React.useState(adminUser.email); const [saved, setSaved] = React.useState(false); const [draftVersion, setDraftVersion] = React.useState(0); const [docForm, setDocForm] = React.useState({ originalId: '', id: '', docName: '', pin: '' }); const [docMessage, setDocMessage] = React.useState(''); const [submissions, setSubmissions] = React.useState([]); const [submissionLoading, setSubmissionLoading] = React.useState(false); const [submissionError, setSubmissionError] = React.useState(''); const [statusDraft, setStatusDraft] = React.useState(''); const localDrafts = React.useMemo(() => adminGetDraftRecords(), [draftVersion]); React.useEffect(() => { loadSubmissions(); }, [adminUser.idToken]); const filtered = submissions.filter(s => !search || String(s.name || '').includes(search) || String(s.vessel || '').includes(search) || String(s.id || '').includes(search.toUpperCase()) ); async function adminFetch(url, options = {}) { const headers = { ...(options.headers || {}), Authorization: `Bearer ${adminUser.idToken}` }; const response = await fetch(url, { ...options, headers }); if (!response.ok) { let message = '관리자 요청에 실패했습니다.'; try { const body = await response.json(); message = body.error || message; } catch {} throw new Error(message); } return response; } async function loadSubmissions() { setSubmissionLoading(true); setSubmissionError(''); try { const response = await adminFetch('submit.php?action=list'); const data = await response.json(); setSubmissions(data.items || []); } catch (error) { setSubmissionError(error.message || '접수 목록을 불러오지 못했습니다.'); } finally { setSubmissionLoading(false); } } async function downloadSubmission(id) { try { const response = await adminFetch(`submit.php?action=download&id=${encodeURIComponent(id)}`); const blob = await response.blob(); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `${id}.zip`; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); } catch (error) { setSubmissionError(error.message || '자동화 ZIP을 내려받지 못했습니다.'); } } async function updateSubmissionStatus(id, status) { try { const response = await adminFetch('submit.php?action=status', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, status }), }); const data = await response.json(); setSubmissions(items => items.map(item => item.id === id ? data.item : item)); setSelected(data.item); setStatusDraft(status); } catch (error) { setSubmissionError(error.message || '상태를 변경하지 못했습니다.'); } } function saveSettings() { setSaved(true); setTimeout(() => setSaved(false), 2000); } function resetDocForm() { setDocForm({ originalId: '', id: '', docName: '', pin: '' }); setDocMessage(''); } function editDocument(record) { setDocForm({ originalId: record.id, id: record.id, docName: record.docName || '', pin: record.pin || '' }); setDocMessage(''); setTab('documents'); } function saveDocument(e) { e.preventDefault(); const id = docForm.id.trim(); const docName = docForm.docName.trim(); const pin = docForm.pin.trim(); const originalId = docForm.originalId; if (!id || !docName || !pin) { setDocMessage('저장 ID, 서류 이름, 비밀번호/PIN을 모두 입력해 주세요.'); return; } if (id.includes('/') || id.includes('\\')) { setDocMessage('저장 ID에는 / 또는 \\ 문자를 사용할 수 없습니다.'); return; } const duplicate = localStorage.getItem(adminDraftKey(id)); if (duplicate && id !== originalId) { setDocMessage('이미 사용 중인 저장 ID입니다. 다른 ID를 입력해 주세요.'); return; } let existing = null; try { const raw = originalId ? localStorage.getItem(adminDraftKey(originalId)) : localStorage.getItem(adminDraftKey(id)); existing = raw ? adminNormalizeDraftRecord(originalId || id, JSON.parse(raw)) : null; } catch {} const record = { id, docName, pin, data: existing?.data || adminEmptyDraftData(), files: existing?.files || {}, revisions: existing?.revisions || [], createdAt: existing?.createdAt || adminNowIso(), updatedAt: adminNowIso(), }; adminSaveDraftRecord(record); if (originalId && originalId !== id) { localStorage.removeItem(adminDraftKey(originalId)); } setDocForm({ originalId: id, id, docName, pin }); setDraftVersion(v => v + 1); setDocMessage(originalId ? '저장 ID, 서류 이름, 비밀번호/PIN을 수정했습니다.' : '새 서류를 생성했습니다.'); } return (
{/* Top bar */}
Yacht CIQ Admin
{adminUser.email}
{/* Tab nav */}
{[ { key: 'submissions', label: '📋 접수 목록' }, { key: 'documents', label: '🗂️ 서류 관리' }, { key: 'settings', label: '⚙️ 설정' }, ].map(t => ( ))}
{tab === 'submissions' && (
{/* Stats */}
{[ { label: '전체 접수', value: submissions.length, color: '#0f172a' }, { label: '검토 대기', value: submissions.filter(s=>s.status==='received').length, color: '#b91c1c' }, { label: '검토 중', value: submissions.filter(s=>s.status==='reviewing').length, color: '#0284c7' }, { label: '자동화 가능', value: submissions.filter(s=>s.status==='validated').length, color: '#15803d' }, ].map(s => (
{s.value}
{s.label}
))}
{/* Search */}
setSearch(e.target.value)} placeholder="이름, 선박명, 접수번호 검색..." className="form-control" style={{ width: '300px' }} />
{submissionError && (
{submissionError}
)} {submissionLoading && (
접수 목록을 불러오는 중입니다.
)} {/* Table */}
{['접수번호','접수일','신청자','선박명','항로','파일','상태',''].map(h => ( ))} {filtered.map((s, i) => ( e.currentTarget.style.background='#f8fafc'} onMouseLeave={e=>e.currentTarget.style.background='transparent'}> ))} {filtered.length === 0 && !submissionLoading && ( )}
{h}
{s.id} {s.date} {s.name} {s.vessel} {s.route} {s.files}건 {STATUS_LABELS[s.status] || s.status}
저장된 접수가 없습니다.
{localDrafts.length > 0 && (

임시저장 중인 신청서 ({localDrafts.length}건)

{localDrafts.map(d => (
{d.id} | {d.docName || '서류 이름 미설정'} | {d.data?.applicant?.name || '이름 미입력'}
))}
)}
)} {tab === 'documents' && (

서류 관리

고객에게 안내할 서류 이름과 불러오기 비밀번호/PIN을 생성하거나 수정합니다.

{docForm.originalId && ( )}
setDocForm(p => ({ ...p, id: e.target.value }))} placeholder="예: TSUSHIMA-2026-01" className="form-control" style={{ fontFamily: 'monospace' }} />
setDocForm(p => ({ ...p, docName: e.target.value }))} placeholder="예: 홍길동 2026 쓰시마 출항" className="form-control" />
setDocForm(p => ({ ...p, pin: e.target.value }))} placeholder="고객에게 안내할 PIN" className="form-control" />
{docMessage && (

{docMessage}

)}

저장된 서류 목록

{localDrafts.length}건
{localDrafts.length === 0 ? (
아직 생성된 서류가 없습니다.
) : (
{['저장 ID','서류 이름','신청자','선박명','수정일','비밀번호',''].map(h => ( ))} {localDrafts.map((record, i) => { const vessel = [record.data?.vessel?.vesselNameKo, record.data?.vessel?.vesselNameEn].filter(Boolean).join(' / '); return ( ); })}
{h}
{record.id} {record.docName || '서류 이름 미설정'} {record.data?.applicant?.name || '미입력'} {vessel || '미입력'} {adminFormatDate(record.updatedAt)} {record.pin ? '설정됨' : '미설정'}
)}
)} {tab === 'settings' && (

📊 분석 도구 설정

setGaId(e.target.value)} placeholder="G-XXXXXXXXXX" className="form-control" style={{ fontFamily: 'monospace' }} />
setClarityId(e.target.value)} placeholder="xxxxxxxxxx" className="form-control" style={{ fontFamily: 'monospace' }} />

📧 이메일 알림 설정

setAdminNotifyEmail(e.target.value)} type="email" className="form-control" />
ℹ️ 고객 확인 메일에는 접수번호와 안내 사항만 포함됩니다.
여권번호 등 민감 정보는 메일에 포함되지 않습니다.

🔒 보안 설정

{[ { label: 'HTTPS 강제 리디렉션', on: true }, { label: '업로드 파일 직접 URL 접근 차단', on: true }, { label: '허용 확장자 제한 (JPG·PNG·PDF)', on: true }, { label: '접수 JSON 외부 노출 차단', on: true }, ].map((item, idx, arr) => (
{item.label} {item.on ? '✓ 적용됨' : '⚠ 미적용'}
))}
)}
{/* Detail modal */} {selected && (
setSelected(null)}>
e.stopPropagation()}>

{selected.name} — {selected.vessel}

{selected.id}
{[ { label: '접수일', value: selected.date }, { label: '항로', value: selected.route }, { label: '첨부파일', value: `${selected.files}건` }, { label: '상태', value: {STATUS_LABELS[selected.status] || selected.status} }, { label: '자동화 폴더', value: selected.project_path || '미기록' }, ].map((row, idx, arr) => (
{row.label} {row.value}
))}
)}
); } function AdminLoading() { return (

관리자 로그인 확인 중

Firebase Auth 세션을 확인하고 있습니다.

); } function AdminPage() { const [checkingAuth, setCheckingAuth] = React.useState(true); const [adminUser, setAdminUser] = React.useState(null); const [authError, setAuthError] = React.useState(''); React.useEffect(() => { const firebaseAuth = getFirebaseAdminAuth(); if (!firebaseAuth) { setAuthError('Firebase 로그인 설정을 찾을 수 없습니다.'); setCheckingAuth(false); return; } let active = true; let unsubscribe = null; firebaseAuth.listen(user => { if (!active) return; setAdminUser(user); setCheckingAuth(false); }).then(stop => { unsubscribe = stop; }).catch(error => { if (!active) return; setAuthError(firebaseAuth.loginMessage(error)); setCheckingAuth(false); }); return () => { active = false; if (unsubscribe) unsubscribe(); }; }, []); async function handleLogout() { const firebaseAuth = getFirebaseAdminAuth(); if (firebaseAuth) await firebaseAuth.signOutAdmin(); setAdminUser(null); } if (checkingAuth) return ; return adminUser ? : ; } Object.assign(window, { AdminPage });