// ── FormSteps.jsx ──────────────────────────────────────────────
// All 7 step components for the CIQ form with premium aesthetic
// ── Shared field components ─────────────────────────────────────
function Field({ label, required, hint, children }) {
return (
{label}{required && * }
{hint &&
{hint}
}
{children}
);
}
function Input({ value, onChange, placeholder, type = 'text', disabled }) {
return (
onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
className="form-control"
/>
);
}
function Select({ value, onChange, options, placeholder }) {
return (
onChange(e.target.value)}
className="form-control"
style={{ color: value ? 'inherit' : '#94a3b8' }}
>
{placeholder && {placeholder} }
{options.map(o => {o.label || o} )}
);
}
function PortInput({ value, onChange, options, placeholder, listId }) {
return (
<>
onChange(e.target.value)}
placeholder={placeholder}
className="form-control"
/>
{options.map(o => )}
>
);
}
function Row({ children }) {
return (
{children}
);
}
function SectionTitle({ children }) {
return (
{children}
);
}
function defaultCrewLegs(legs = {}) {
const hasKoreaToJapan = typeof legs.koreaToJapan === 'boolean';
const hasJapanToKorea = typeof legs.japanToKorea === 'boolean';
return {
koreaToJapan: hasKoreaToJapan ? legs.koreaToJapan : true,
japanToKorea: hasJapanToKorea ? legs.japanToKorea : true,
};
}
function createCrewMember() {
return { legs: defaultCrewLegs() };
}
// ── Step 1: 신청자 정보 ─────────────────────────────────────────
function Step1({ data, set }) {
const f = (k) => (v) => set({ ...data, [k]: v });
return (
);
}
// ── Step 2: 항해 일정 ──────────────────────────────────────────
function Step2({ data, set }) {
const f = (k) => (v) => set({ ...data, [k]: v });
return (
);
}
// ── Step 3: 선박 정보 ──────────────────────────────────────────
function Step3({ data, set }) {
const f = (k) => (v) => set({ ...data, [k]: v });
return (
연료·검역 참고 정보
);
}
// ── Step 4: 선주/선장 정보 ─────────────────────────────────────
function Step4({ data, set }) {
const f = (k) => (v) => set({ ...data, [k]: v });
const fo = (k) => (v) => set({ ...data, owner: { ...data.owner, [k]: v } });
return (
);
}
// ── Step 5: 승선자 정보 ─────────────────────────────────────────
function Step5({ data, set }) {
const crew = (data.crew && data.crew.length ? data.crew : [createCrewMember()])
.map(c => ({ ...c, legs: defaultCrewLegs(c.legs) }));
function updateCrew(idx, key, val) {
const next = crew.map((c, i) => i === idx ? { ...c, [key]: val } : c);
set({ ...data, crew: next });
}
function updateCrewLeg(idx, key, checked) {
const next = crew.map((c, i) => {
if (i !== idx) return c;
const currentLegs = defaultCrewLegs(c.legs);
const nextLegs = { ...currentLegs, [key]: checked };
if (!nextLegs.koreaToJapan && !nextLegs.japanToKorea) return c;
return { ...c, legs: nextLegs };
});
set({ ...data, crew: next });
}
function addCrew() {
set({ ...data, crew: [...crew, createCrewMember()] });
}
function removeCrew(idx) {
set({ ...data, crew: crew.filter((_, i) => i !== idx) });
}
return (
선장을 포함한 전 승선자, 즉 선원(승객)을 입력해 주세요. 각 인원이 참가하는 항해 구간을 선택할 수 있습니다.
{crew.map((c, idx) => {
const legs = defaultCrewLegs(c.legs);
const legLabel = legs.koreaToJapan && legs.japanToKorea
? '한국 ↔ 일본 전체 일정 참가'
: legs.koreaToJapan
? '한국 → 일본만 참가'
: '일본 → 한국만 참가';
return (
승선자 {idx + 1} (선원(승객))
{crew.length > 1 && (
removeCrew(idx)} style={{
background: 'none', border: 'none', color: '#ef4444', fontSize: '0.875rem', fontWeight: '600', cursor: 'pointer', padding: '0.25rem 0.5rem'
}}>삭제
)}
updateCrew(idx, 'nameKo', v)} placeholder="홍길동" />
updateCrew(idx, 'nameEn', v)} placeholder="HONG GILDONG" />
updateCrew(idx, 'passport', v)} placeholder="M12345678" />
updateCrew(idx, 'passportExpiry', v)} type="date" />
updateCrew(idx, 'dob', v)} type="date" />
updateCrew(idx, 'sex', v)} placeholder="선택"
options={[
{ value: 'M', label: '남성' },
{ value: 'F', label: '여성' },
]} />
updateCrew(idx, 'nationality', v)} placeholder="선택"
options={['대한민국','일본','미국','기타']} />
updateCrew(idx, 'phone', v)} placeholder="010-0000-0000" />
updateCrew(idx, 'address', v)} placeholder="국문 주소" />
{[
{ key: 'koreaToJapan', label: '한국 → 일본' },
{ key: 'japanToKorea', label: '일본 → 한국' },
].map(option => (
updateCrewLeg(idx, option.key, e.target.checked)}
style={{ width: '16px', height: '16px', accentColor: 'var(--primary)' }}
/>
{option.label}
))}
);
})}
+ 승선자 추가
);
}
// ── Step 6: 첨부파일 ───────────────────────────────────────────
function FileUploadItem({ label, required, hint, fileKey, files, setFiles }) {
const file = files[fileKey];
const inputRef = React.useRef();
function handleChange(e) {
const f = e.target.files[0];
if (!f) return;
const allowedTypes = ['image/jpeg', 'image/png', 'image/jpg', 'image/heic', 'image/heif', 'application/pdf'];
const allowedExtensions = ['jpg', 'jpeg', 'png', 'pdf', 'heic'];
const extension = f.name.split('.').pop().toLowerCase();
if (!allowedTypes.includes(f.type) && !allowedExtensions.includes(extension)) {
alert('JPG, PNG, HEIC, PDF 파일만 업로드 가능합니다.');
return;
}
if (f.size > 10 * 1024 * 1024) {
alert('파일 크기는 10MB 이하여야 합니다.');
return;
}
const nextTotal = Object.entries(files).reduce((sum, [key, item]) => {
if (key === fileKey) return sum;
return sum + (item?.size || 0);
}, 0) + f.size;
if (nextTotal > 50 * 1024 * 1024) {
alert('첨부파일 총 용량은 50MB 이하여야 합니다.');
return;
}
setFiles(prev => ({ ...prev, [fileKey]: { name: f.name, size: f.size, type: f.type, file: f } }));
}
function remove() {
setFiles(prev => { const n = { ...prev }; delete n[fileKey]; return n; });
if (inputRef.current) inputRef.current.value = '';
}
return (
{label}{required && * }
{hint &&
{hint}
}
{file ? (
{file.type === 'application/pdf' ? '📄' : '🖼️'}
{file.name}
{(file.size / 1024).toFixed(0)} KB
✕
) : (
{ e.currentTarget.style.borderColor = 'var(--primary)'; e.currentTarget.style.color = 'var(--primary)'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = '#cbd5e1'; e.currentTarget.style.color = 'var(--text-secondary)'; }}>
⊕ 파일 선택 (JPG·PNG·HEIC·PDF, 최대 10MB)
)}
);
}
function Step6({ files, setFiles }) {
const items = [
{ key: 'passport_all', label: '전 승선자 여권 사본', required: true, hint: '자동 패키징에는 PDF 묶음이 가장 안정적입니다' },
{ key: 'captain_passport', label: '선장 여권 사본', required: true, hint: '일본 운수국·입국관리국 제출 패키지에 사용됩니다' },
{ key: 'captain_license', label: '선장 자격증 사본', required: false, hint: '요트 조종면허 또는 동력수상레저기구 조종면허' },
{ key: 'vessel_reg', label: '선박 등록증 사본', required: true, hint: '선박 원부 또는 등록증명서' },
{ key: 'safety_cert', label: '안전검사증 사본', required: true, hint: '유효기간 확인 필수' },
{ key: 'sscec', label: 'SSCEC (소형선박 안전관리증)', required: false, hint: '해당 선박에 발급된 경우 제출' },
{ key: 'transport_permit', label: '운수국 허가서', required: false, hint: '기존 불개항장 기항 특허 통지서가 있는 경우' },
{ key: 'mooring_map', label: '계류장소 도면', required: false, hint: '일본 해상보안청·항만 제출용 참고자료' },
{ key: 'pow', label: '위임장', required: false, hint: '선주 ≠ 신청자인 경우 필요. 양식 다운로드 후 작성 가능' },
{ key: 'insurance', label: '선박 보험증권', required: false, hint: '일본 입항 일부 항구에서 요구' },
{ key: 'other', label: '기타 서류', required: false, hint: '추가로 제출할 서류가 있으면 업로드' },
];
return (
⚠️
업로드된 파일은 접수 처리용 서버 저장소에 보관되며, 서류 준비 목적 외 사용되지 않습니다.
{items.map(item => (
))}
💡
위임장 양식이 필요하신 경우
여기서 다운로드 하여 작성 후 업로드 해주세요.
);
}
// ── Step 7: 요약 및 제출 ───────────────────────────────────────
function Step7({ summary, consent, setConsent }) {
const toggle = (key) => (e) => setConsent({ ...consent, [key]: e.target.checked });
return (
제출 내용 요약
{summary.map((row, i) => (
{row.label}
{row.value || '—'}
))}
{[
['privacy', '개인정보 수집·이용에 동의합니다.'],
['sensitive', '여권번호, 생년월일 등 CIQ 서류 작성에 필요한 민감정보 처리에 동의합니다.'],
['ciq', '입력 자료를 CIQ 서류 준비와 기관별 제출자료 정리에 사용하는 데 동의합니다.'],
].map(([key, label]) => (
{label}
))}
);
}
// Export all to window
Object.assign(window, { Step1, Step2, Step3, Step4, Step5, Step6, Step7, Field, Input, Select, PortInput, Row, SectionTitle });