// ── FormApp.jsx ──────────────────────────────────────────────── // Main form orchestration: steps, save/load, submission const STEPS = [ { id: 1, title: '신청자 정보', icon: '👤' }, { id: 2, title: '항해 일정', icon: '🗓️' }, { id: 3, title: '선박 정보', icon: '⛵' }, { id: 4, title: '선주/선장', icon: '🧭' }, { id: 5, title: '승선자', icon: '👥' }, { id: 6, title: '첨부파일', icon: '📎' }, { id: 7, title: '요약·제출', icon: '✅' }, ]; function nowIso() { return new Date().toISOString(); } function normalizeDraftRecord(id, raw = {}) { const data = raw.data || {}; 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 || nowIso(), updatedAt: raw.updatedAt || raw.createdAt || nowIso(), }; } function readDraftRecord(id) { const raw = localStorage.getItem(`ciq_draft_${id}`); if (!raw) return null; return normalizeDraftRecord(id, JSON.parse(raw)); } function saveDraftRecord(record) { localStorage.setItem(`ciq_draft_${record.id}`, JSON.stringify(record)); } function getDraftRecords() { 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(normalizeDraftRecord(id, JSON.parse(localStorage.getItem(key)))); } catch {} } return records.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)); } function findDraftRecord(query) { const trimmed = query.trim(); if (!trimmed) return null; const idQuery = trimmed.toUpperCase(); const nameQuery = trimmed.toLocaleLowerCase('ko-KR'); return getDraftRecords().find(record => record.id.toUpperCase() === idQuery || (record.docName || '').toLocaleLowerCase('ko-KR') === nameQuery ); } function makeDraftRevision(record) { const archivedAt = nowIso(); return { id: record.id, docName: record.docName || '', data: record.data || {}, files: record.files || {}, updatedAt: record.updatedAt || record.createdAt || archivedAt, archivedAt, }; } function defaultCrewMember() { return { legs: { koreaToJapan: true, japanToKorea: true } }; } function normalizeCrewLegs(legs = {}) { return { koreaToJapan: typeof legs.koreaToJapan === 'boolean' ? legs.koreaToJapan : true, japanToKorea: typeof legs.japanToKorea === 'boolean' ? legs.japanToKorea : true, }; } function normalizeFormData(data = {}) { const next = { applicant: {}, voyage: {}, vessel: {}, captain: { crew: [] }, crew: { crew: [defaultCrewMember()] }, consent: {}, ...data, }; const crewList = next.crew?.crew && next.crew.crew.length ? next.crew.crew : [defaultCrewMember()]; next.crew = { ...(next.crew || {}), crew: crewList.map(member => ({ ...member, legs: normalizeCrewLegs(member.legs), })), }; return next; } function countCrewByLeg(crewData, legKey) { return (crewData?.crew || []) .filter(member => normalizeCrewLegs(member.legs)[legKey]) .length; } function fileMeta(fileRecord) { if (!fileRecord) return null; return { name: fileRecord.name || '', size: fileRecord.size || 0, type: fileRecord.type || '', }; } function sanitizeFiles(files = {}) { return Object.fromEntries( Object.entries(files) .filter(([, item]) => item) .map(([key, item]) => [key, fileMeta(item)]) ); } function trimValue(value) { return String(value || '').trim(); } function requiredError(errors, value, label) { if (!trimValue(value)) errors.push(`${label}을 입력해 주세요.`); } function validateBeforeSubmit(data, files) { const errors = []; requiredError(errors, data.applicant?.name, '신청자 성명'); requiredError(errors, data.applicant?.phone, '신청자 연락처'); requiredError(errors, data.applicant?.email, '신청자 이메일'); requiredError(errors, data.voyage?.departPort, '한국 출항 항구'); requiredError(errors, data.voyage?.departDate, '한국 출항일'); requiredError(errors, data.voyage?.departTime, '한국 출항 시각'); requiredError(errors, data.voyage?.departCiqPlace, '한국 출항 CIQ 장소'); requiredError(errors, data.voyage?.departBerth, '한국 출항 선석/계선장소'); requiredError(errors, data.voyage?.arrivePort, '일본 입항 항구'); requiredError(errors, data.voyage?.arriveDate, '일본 입항일'); requiredError(errors, data.voyage?.arriveTime, '일본 입항 시각'); requiredError(errors, data.voyage?.returnDepartPort, '일본 출항 항구'); requiredError(errors, data.voyage?.returnDepartDate, '일본 출항일'); requiredError(errors, data.voyage?.returnDepartTime, '일본 출항 시각'); requiredError(errors, data.voyage?.returnArrivePort, '한국 입항 항구'); requiredError(errors, data.voyage?.returnArriveDate, '한국 입항일'); requiredError(errors, data.voyage?.returnArriveTime, '한국 입항 시각'); requiredError(errors, data.voyage?.returnCiqPlace, '한국 입항 CIQ 장소'); requiredError(errors, data.voyage?.returnBerth, '한국 입항 선석/계선장소'); requiredError(errors, data.vessel?.vesselNameEn, '선박 영문명'); requiredError(errors, data.vessel?.regNum, '선박 등록번호'); requiredError(errors, data.captain?.captainNameKo, '선장 한글명'); requiredError(errors, data.captain?.captainNameEn, '선장 영문명'); requiredError(errors, data.captain?.captainDob, '선장 생년월일'); requiredError(errors, data.captain?.captainPassport, '선장 여권번호'); requiredError(errors, data.captain?.captainSex, '선장 성별'); const crew = data.crew?.crew || []; if (!crew.length) { errors.push('승선자 정보를 1명 이상 입력해 주세요.'); } let koreaToJapan = 0; let japanToKorea = 0; crew.forEach((member, index) => { const label = `${index + 1}번 승선자`; requiredError(errors, member.nameKo, `${label} 한글명`); requiredError(errors, member.nameEn, `${label} 영문명`); requiredError(errors, member.dob, `${label} 생년월일`); requiredError(errors, member.passport, `${label} 여권번호`); requiredError(errors, member.sex, `${label} 성별`); const legs = normalizeCrewLegs(member.legs); if (legs.koreaToJapan) koreaToJapan += 1; if (legs.japanToKorea) japanToKorea += 1; }); if (koreaToJapan === 0) errors.push('한국에서 일본으로 탑승하는 승선자가 1명 이상 필요합니다.'); if (japanToKorea === 0) errors.push('일본에서 한국으로 탑승하는 승선자가 1명 이상 필요합니다.'); if (!data.consent?.privacy || !data.consent?.sensitive || !data.consent?.ciq) { errors.push('필수 동의 항목을 모두 체크해 주세요.'); } ['passport_all', 'captain_passport', 'vessel_reg', 'safety_cert'].forEach(key => { const label = { passport_all: '전 승선자 여권 사본', captain_passport: '선장 여권 사본', vessel_reg: '선박 등록증 사본', safety_cert: '안전검사증 사본', }[key]; if (!(files[key]?.file instanceof File)) errors.push(`${label}을 새로 첨부해 주세요.`); }); return [...new Set(errors)]; } // ── Save/Load Modal ──────────────────────────────────────────── function SaveModal({ onClose, onSave, onLoad, savedId, savedPin, savedDocName, initialMode = 'save' }) { const [mode, setMode] = React.useState(initialMode); // 'save' | 'load' const [saveError, setSaveError] = React.useState(''); const [loadQuery, setLoadQuery] = React.useState(''); const [loadPin, setLoadPin] = React.useState(''); const [loadError, setLoadError] = React.useState(''); React.useEffect(() => { setMode(initialMode); }, [initialMode]); function handleSaveClick() { const error = onSave(); if (error) { setSaveError(error); return; } setSaveError(''); onClose(); } function handleLoad() { const record = findDraftRecord(loadQuery); if (!record || record.pin !== loadPin.trim()) { setLoadError('저장 ID/서류 이름 또는 PIN을 확인해 주세요.'); return; } try { onLoad(record.data, record.files, record.id, record.pin, record.docName); onClose(); } catch { setLoadError('데이터를 불러오는 데 실패했습니다.'); } } return (
e.stopPropagation()}>

작성 중 저장 / 불러오기

{['save','load'].map(m => ( ))}
{mode === 'save' ? (
{savedId ? ( <>

현재 열린 서류에 덮어쓰기 저장합니다. 이전 저장본은 수정 이력으로 남습니다.

저장 ID: {savedId}
서류 이름: {savedDocName || '서류 이름 미설정'}
{saveError &&

{saveError}

} ) : ( <>

먼저 저장 ID 또는 서류 이름과 PIN으로 서류를 불러온 뒤 임시저장할 수 있습니다.

{saveError &&

{saveError}

} )}
) : (
setLoadQuery(e.target.value)} placeholder="저장 ID 또는 서류 이름" className="form-control" />
setLoadPin(e.target.value)} placeholder="비밀번호 또는 PIN" className="form-control" />
{loadError &&

{loadError}

}
)}
); } // ── Success Screen ───────────────────────────────────────────── function SuccessScreen({ receiptId, onReset }) { return (
🎉

접수가 완료되었습니다

서류 준비가 완료되면 입력하신 이메일로 안내드립니다.
통상 1~2 영업일 내 확인 후 연락드립니다.

접수 번호
{receiptId}
안내사항
• 접수 번호를 문의 시 알려주시면 빠른 확인이 가능합니다.
• 제출 서류 중 미비한 항목은 별도 연락드립니다.
); } // ── Main FormApp ─────────────────────────────────────────────── function FormApp() { const [step, setStep] = React.useState(1); const [formData, setFormData] = React.useState(normalizeFormData()); const [files, setFiles] = React.useState({}); const [showSave, setShowSave] = React.useState(false); const [saveMode, setSaveMode] = React.useState('save'); const [savedId, setSavedId] = React.useState(''); const [savedPin, setSavedPin] = React.useState(''); const [savedDocName, setSavedDocName] = React.useState(''); const [saveNotice, setSaveNotice] = React.useState(''); const [submitError, setSubmitError] = React.useState(''); const [submitting, setSubmitting] = React.useState(false); const [submitted, setSubmitted] = React.useState(false); const [receiptId, setReceiptId] = React.useState(''); const stepTabsRef = React.useRef(null); const setStepData = (key) => (val) => setFormData(p => ({ ...p, [key]: val })); React.useEffect(() => { const wrap = stepTabsRef.current; const active = wrap?.querySelector(`[data-step-id="${step}"]`); if (!wrap || !active) return; const wrapRect = wrap.getBoundingClientRect(); const activeRect = active.getBoundingClientRect(); const centeredLeft = wrap.scrollLeft + activeRect.left - wrapRect.left - ((wrap.clientWidth - activeRect.width) / 2); wrap.scrollTo({ left: Math.max(0, centeredLeft), behavior: 'smooth' }); }, [step]); function handleSave() { if (!savedId) return '먼저 불러오기로 서류를 연 뒤 임시저장해 주세요.'; const existing = readDraftRecord(savedId); if (!existing) return '저장본을 찾을 수 없습니다. 다시 불러온 뒤 저장해 주세요.'; const revisions = [...(existing.revisions || []), makeDraftRevision(existing)]; const record = { ...existing, id: savedId, data: formData, files: sanitizeFiles(files), createdAt: existing?.createdAt || nowIso(), updatedAt: nowIso(), revisions, }; saveDraftRecord(record); setSavedPin(record.pin || ''); setSavedDocName(record.docName || ''); return ''; } function handleLoad(data, filesMeta, id, pin, docName) { setFormData(normalizeFormData(data || {})); setFiles(filesMeta || {}); setSavedId(id); setSavedPin(pin); setSavedDocName(docName || ''); setSaveNotice(`${docName || id} 서류를 불러왔습니다.`); setStep(1); } function handleQuickSave() { const error = handleSave(); if (error) { setSaveNotice(error); setSaveMode('load'); setShowSave(true); return; } setSaveNotice('임시저장했습니다. 이전 저장본은 수정 이력으로 남겼습니다.'); } async function handleSubmit() { const errors = validateBeforeSubmit(formData, files); if (errors.length) { setSubmitError(errors.slice(0, 8).join('\n') + (errors.length > 8 ? `\n외 ${errors.length - 8}건` : '')); return; } const body = new FormData(); body.append('payload', JSON.stringify({ formData, files: sanitizeFiles(files) })); Object.entries(files).forEach(([key, item]) => { if (item?.file instanceof File) { body.append(`files[${key}]`, item.file, item.file.name); } }); setSubmitting(true); setSubmitError(''); try { const response = await fetch('submit.php', { method: 'POST', body }); const result = await response.json(); if (!response.ok || !result.ok) { const messages = result.errors || [result.error || '접수 저장에 실패했습니다.']; setSubmitError(messages.join('\n')); return; } setReceiptId(result.receiptId || result.requestId); setSubmitted(true); if (savedId) localStorage.removeItem(`ciq_draft_${savedId}`); } catch (error) { setSubmitError('서버에 접수 데이터를 저장할 수 없습니다. PHP 서버에서 열었는지 확인해 주세요.'); } finally { setSubmitting(false); } } const summary = [ { label: '신청자', value: formData.applicant?.name }, { label: '연락처', value: formData.applicant?.phone }, { label: '출항지', value: formData.voyage?.departPort }, { label: '입항지(일본)', value: formData.voyage?.arrivePort }, { label: '예정 출항일', value: formData.voyage?.departDate }, { label: '선박명', value: [formData.vessel?.vesselNameKo, formData.vessel?.vesselNameEn].filter(Boolean).join(' / ') }, { label: '선장', value: formData.captain?.captainNameKo }, { label: '승선 인원', value: formData.crew?.crew?.length ? `${formData.crew.crew.length}명` : '' }, { label: '한국→일본 승선', value: `${countCrewByLeg(formData.crew, 'koreaToJapan')}명` }, { label: '일본→한국 승선', value: `${countCrewByLeg(formData.crew, 'japanToKorea')}명` }, { label: '첨부파일', value: `${Object.keys(files).length}건 업로드` }, ]; if (submitted) return { setSubmitted(false); setFormData(normalizeFormData()); setFiles({}); setStep(1); setSavedId(''); setSavedPin(''); setSavedDocName(''); }} />; return (
{/* Progress */}
{STEPS.map(s => ( ))}
{/* Progress bar */}
{/* Step title */}
STEP {step} / {STEPS.length}

{STEPS[step-1].icon} {STEPS[step-1].title}

{/* Step content */}
{step === 1 && } {step === 2 && } {step === 3 && } {step === 4 && } {step === 5 && } {step === 6 && } {step === 7 && }
{/* Nav buttons */}
{step > 1 && ( )}
{step < STEPS.length ? ( ) : ( )}
{submitError && (
{submitError}
)} {saveNotice && (

{saveNotice}

)} {showSave && ( setShowSave(false)} onSave={handleSave} onLoad={handleLoad} savedId={savedId} savedPin={savedPin} savedDocName={savedDocName} initialMode={saveMode} /> )}
); } Object.assign(window, { FormApp });