/** * @license * SPDX-License-Identifier: Apache-2.0 */ import React, { useState, useMemo, useEffect } from 'react'; import { Handshake, Bell, UserCircle, Calendar as CalendarIcon, ChevronLeft, ChevronRight, Info, MapPin, Users, User as UserIcon, PlusCircle, Check, Verified, ShieldCheck, Headset, Instagram, Share2, Lock, LayoutDashboard, LogOut, Clock, ExternalLink, Search, X, Trash2, Mail, MailOpen, Banknote, MessageCircle, AlertCircle, Video, FileText, BarChart3, CheckCircle2 } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; import { db, auth } from './firebase'; import { collection, addDoc, onSnapshot, query, orderBy, where, Timestamp, DocumentData, doc, updateDoc, deleteDoc, arrayUnion, getDocs } from 'firebase/firestore'; import { onAuthStateChanged, signOut, User, signInWithPopup, GoogleAuthProvider } from 'firebase/auth'; import { WEDDING_HALLS, WeddingHall } from './data/weddingHalls'; import { uploadWeddingHallsFromCSV, clearWeddingHalls } from './utils/csvUploader'; enum OperationType { CREATE = 'create', UPDATE = 'update', DELETE = 'delete', LIST = 'list', GET = 'get', WRITE = 'write', } interface FirestoreErrorInfo { error: string; operationType: OperationType; path: string | null; authInfo: { userId: string | undefined; email: string | null | undefined; emailVerified: boolean | undefined; isAnonymous: boolean | undefined; tenantId: string | null | undefined; providerInfo: { providerId: string; displayName: string | null; email: string | null; photoUrl: string | null; }[]; } } function handleFirestoreError(error: any, operationType: OperationType, path: string | null) { const errInfo: FirestoreErrorInfo = { error: error instanceof Error ? error.message : String(error), authInfo: { userId: auth.currentUser?.uid, email: auth.currentUser?.email, emailVerified: auth.currentUser?.emailVerified, isAnonymous: auth.currentUser?.isAnonymous, tenantId: auth.currentUser?.tenantId, providerInfo: auth.currentUser?.providerData.map(provider => ({ providerId: provider.providerId, displayName: provider.displayName, email: provider.email, photoUrl: provider.photoURL })) || [] }, operationType, path } console.error('Firestore Error: ', JSON.stringify(errInfo)); throw new Error(JSON.stringify(errInfo)); } // Types type TeamOption = { id: string; name: string; description: string; price: number; icon: React.ReactNode; }; type AddOnOption = { id: string; name: string; description: string; price: number; }; type SettlementType = 'A' | 'B' | 'C'; type ReservationHistory = { timestamp: string; status: string; note?: string; }; type Reservation = { id?: string; date: string; time: string; guestCount: number; weddingHall: string; location: string; teamId: string; teamName: string; side?: 'groom' | 'bride'; settlementType: SettlementType; customerName: string; customerPhone: string; addOns: string[]; totalPrice: number; travelFee: number; isWaitlist: boolean; createdAt: string; status: 'pending' | 'confirmed' | 'cancelled' | 'rejected' | 'cancel_requested'; staffAssignments?: { staffId: string; staffName: string }[]; history?: ReservationHistory[]; uid?: string | null; }; type Staff = { id: string; name: string; role: string; phone: string; createdAt: string; }; const LOCATIONS = [ { name: '수원', fee: 0 }, { name: '군포', fee: 0 }, { name: '의왕', fee: 0 }, { name: '오산', fee: 0 }, { name: '성남', fee: 30000 }, { name: '용인', fee: 30000 }, { name: '과천', fee: 30000 }, { name: '안양', fee: 30000 }, { name: '안산', fee: 30000 }, { name: '시흥', fee: 30000 }, { name: '화성', fee: 30000 }, { name: '서울', fee: 50000 }, { name: '인천', fee: 50000 }, { name: '부천', fee: 50000 }, { name: '평택', fee: 50000 }, { name: '광명', fee: 50000 }, { name: '안성', fee: 50000 }, { name: '이천', fee: 50000 }, { name: '여주', fee: 50000 }, { name: '양평', fee: 50000 }, { name: '광주', fee: 50000 }, { name: '하남', fee: 50000 }, { name: '구리', fee: 50000 }, ]; const TEAM_OPTIONS: TeamOption[] = [ { id: 'team-2', name: '2인 전문 팀 (단독 구성)', description: '신랑 또는 신부측 한 쪽만 집중 케어가 필요한 경우 추천드립니다.\n(기준 인원 200명)', price: 400000, icon: (
) }, { id: 'team-4', name: '4인 전문 팀 (양가 구성)', description: '양가 부모님 및 하객분들을 모두 전문적으로 안내해 드리는 베스트 상품입니다.\n(기준 인원 300명)', price: 750000, icon: (
) } ]; const ADD_ON_OPTIONS: AddOnOption[] = [ { id: 'video', name: '영상 메시지 서비스', description: '하객들의 축하 인사를 생생한 영상으로 담아 전달합니다.', price: 100000 }, { id: 'staff', name: '현장 스탭 1인 추가', description: '대규모 하객(400인 이상) 예상 시 원활한 안내를 위해 권장합니다.', price: 150000 }, { id: 'settlement', name: '식권/답례품 정산 대행', description: '예식 종료 후 복잡한 정산 업무를 전문적으로 투명하게 대행합니다.', price: 50000 } ]; const SETTLEMENT_TYPES = [ { id: 'A' as SettlementType, name: 'TYPE A: 금액 미확인 즉시 밀봉', description: '가장 안전한 방식. 금액 노출 없이 봉투 수령 즉시 밀봉 보관 (초과 인원에 대한 추가 비용 없음)', price: 0, icon: , label: 'Sealed' }, { id: 'B' as SettlementType, name: 'TYPE B: 금액 확인 후 밀봉 보관', description: '봉투별 금액 확인 후 장부 기재 및 밀봉. 카메라 앞에서 진행', price: 0, icon: , label: 'Check' }, { id: 'C' as SettlementType, name: 'TYPE C: 개봉 후 권종별 현금정리', description: '전용 계수기 + 수기 더블 체크, 100장 단위 묶음(현금 띠지 사용)', price: 50000, icon: , label: 'Count' }, ]; // Custom 3D-style Logo Component const Logo = ({ className = "w-10 h-10" }: { className?: string }) => ( {/* Mango Shape */} {/* Banana Shape */} {/* Cute Heart with Glow */} {/* Blush dots for cuteness */} ); export default function App() { // View State const [view, setView] = useState<'user' | 'admin' | 'intro'>('intro'); const [adminViewMode, setAdminViewMode] = useState<'list' | 'calendar' | 'data' | 'staff'>('list'); const [isAdminAuthenticated, setIsAdminAuthenticated] = useState(false); const [adminPassword, setAdminPassword] = useState(''); const [reservations, setReservations] = useState([]); const [csvInput, setCsvInput] = useState(''); const [isUploading, setIsUploading] = useState(false); const [isClearing, setIsClearing] = useState(false); const [uploadStatus, setUploadStatus] = useState<{message: string, type: 'success' | 'error' | 'info'} | null>(null); const [adminHallSearch, setAdminHallSearch] = useState(''); const [selectedStatsMonth, setSelectedStatsMonth] = useState(new Date().toISOString().substring(0, 7)); const [adminStatusFilter, setAdminStatusFilter] = useState<'all' | 'pending' | 'confirmed' | 'cancelled' | 'rejected' | 'cancel_requested'>('all'); const [adminSortOrder, setAdminSortOrder] = useState<'createdAt' | 'reservation'>('createdAt'); const [selectedReservationForDetail, setSelectedReservationForDetail] = useState(null); const [deletingHallId, setDeletingHallId] = useState(null); const [staff, setStaff] = useState([]); const [newStaffName, setNewStaffName] = useState(''); const [newStaffRole, setNewStaffRole] = useState(''); const [newStaffPhone, setNewStaffPhone] = useState(''); const [isAddingStaff, setIsAddingStaff] = useState(false); // User Form State const [selectedDate, setSelectedDate] = useState(new Date(2026, 2, 21)); // Mar 21, 2026 (Sat) const [selectedHour, setSelectedHour] = useState('10'); const [selectedMinute, setSelectedMinute] = useState('00'); const [selectedTeamId, setSelectedTeamId] = useState('team-4'); const [selectedAddOns, setSelectedAddOns] = useState([]); const [selectedSide, setSelectedSide] = useState<'groom' | 'bride' | null>(null); const [selectedSettlementType, setSelectedSettlementType] = useState('A'); const [weddingHallSearch, setWeddingHallSearch] = useState(''); const [selectedWeddingHall, setSelectedWeddingHall] = useState(null); const [customerName, setCustomerName] = useState(''); const [customerPhone, setCustomerPhone] = useState(''); const [tripodCheck, setTripodCheck] = useState<'possible' | 'not_checked' | 'impossible' | null>(null); const [isSubmitting, setIsSubmitting] = useState(false); const [submitSuccess, setSubmitSuccess] = useState(false); const [showPrecautions, setShowPrecautions] = useState(false); const [showRefundModal, setShowRefundModal] = useState(false); const [showTermsModal, setShowTermsModal] = useState(false); const [showLoginModal, setShowLoginModal] = useState(false); const [user, setUser] = useState(null); const [allWeddingHalls, setAllWeddingHalls] = useState([]); const [pendingConfirmReservationId, setPendingConfirmReservationId] = useState(null); const [showMyReservations, setShowMyReservations] = useState(false); const [searchPhone, setSearchPhone] = useState(''); const [userReservations, setUserReservations] = useState([]); const [isSearching, setIsSearching] = useState(false); const [isLastSubmissionWaitlist, setIsLastSubmissionWaitlist] = useState(false); const formatPhoneNumber = (value: string) => { const cleaned = value.replace(/\D/g, '').slice(0, 11); if (cleaned.length <= 3) return cleaned; if (cleaned.length <= 7) return `${cleaned.slice(0, 3)}-${cleaned.slice(3)}`; return `${cleaned.slice(0, 3)}-${cleaned.slice(3, 7)}-${cleaned.slice(7)}`; }; // Calendar State const [currentCalendarDate, setCurrentCalendarDate] = useState(new Date(2026, 2, 1)); // March 2026 // Real-time Sync for all views useEffect(() => { const unsubscribeAuth = onAuthStateChanged(auth, (currentUser) => { setUser(currentUser); if (currentUser) { setCustomerName(currentUser.displayName || ''); if (currentUser.email === 'nealjin29@gmail.com' && currentUser.emailVerified) { setIsAdminAuthenticated(true); } } }); return () => unsubscribeAuth(); }, []); useEffect(() => { const q = query(collection(db, 'weddingHalls'), orderBy('name')); const unsubscribe = onSnapshot(q, (snapshot) => { const halls = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })) as any[]; setAllWeddingHalls(halls); }, (error) => { handleFirestoreError(error, OperationType.LIST, 'weddingHalls'); }); return () => unsubscribe(); }, []); useEffect(() => { if (!isAdminAuthenticated) return; const q = query(collection(db, 'staff'), orderBy('name')); const unsubscribe = onSnapshot(q, (snapshot) => { const staffList = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })) as Staff[]; setStaff(staffList); }, (error) => { handleFirestoreError(error, OperationType.LIST, 'staff'); }); return () => unsubscribe(); }, [isAdminAuthenticated]); useEffect(() => { // Only subscribe to reservations if the user is the admin if (!user || user.email !== 'nealjin29@gmail.com') { setReservations([]); return; } const q = query(collection(db, 'reservations'), orderBy('createdAt', 'desc')); const unsubscribeFirestore = onSnapshot(q, (snapshot) => { console.log("Reservations sync update:", snapshot.size, "docs"); const data = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })) as Reservation[]; setReservations(data); }, (error) => { handleFirestoreError(error, OperationType.LIST, 'reservations'); // If permission denied, clear reservations if (error.code === 'permission-denied') { setReservations([]); } }); return () => unsubscribeFirestore(); }, [user]); const handleGoogleLogin = async () => { try { const provider = new GoogleAuthProvider(); await signInWithPopup(auth, provider); setShowLoginModal(false); } catch (error) { console.error("Google Login Error:", error); alert('구글 로그인 중 오류가 발생했습니다.'); } }; const handleKakaoLogin = () => { // In a real app, you would redirect to Kakao OAuth or use Kakao SDK // For this environment, we'll explain the setup and provide a placeholder // that simulates a successful login for demo purposes if the user wants. // However, per guidelines, we should aim for real integration. // Since Kakao requires a registered domain and redirect URI, // we'll provide the instructions. const KAKAO_CLIENT_ID = (import.meta as any).env.VITE_KAKAO_CLIENT_ID; if (!KAKAO_CLIENT_ID) { alert("카카오 클라이언트 ID가 설정되지 않았습니다. .env 파일에 VITE_KAKAO_CLIENT_ID를 설정해 주세요."); return; } const redirectUri = `${window.location.origin}/auth/kakao/callback`; const kakaoAuthUrl = `https://kauth.kakao.com/oauth/authorize?client_id=${KAKAO_CLIENT_ID}&redirect_uri=${redirectUri}&response_type=code`; window.location.href = kakaoAuthUrl; }; const handleLogout = async () => { try { await signOut(auth); setUser(null); setIsAdminAuthenticated(false); } catch (error) { console.error("Logout Error:", error); } }; // Derived State const selectedTeam = TEAM_OPTIONS.find(t => t.id === selectedTeamId); const settlementSurcharge = selectedSettlementType === 'C' ? 50000 : 0; const totalPrice = (selectedTeam?.price || 0) + settlementSurcharge; const [showDisabledTooltip, setShowDisabledTooltip] = useState(false); // Admin Stats const availableMonths = useMemo(() => { const months = new Set(); reservations.forEach(res => months.add(res.date.substring(0, 7))); // Add current month if not present months.add(new Date().toISOString().substring(0, 7)); return Array.from(months).sort((a, b) => b.localeCompare(a)); }, [reservations]); const monthlyStats = useMemo(() => { const stats: Record = {}; reservations.forEach(res => { const month = res.date.substring(0, 7); // YYYY-MM if (!stats[month]) { stats[month] = { count: 0, total: 0, confirmedCount: 0, confirmedTotal: 0, pendingCount: 0, pendingTotal: 0 }; } stats[month].count += 1; stats[month].total += res.totalPrice; if (res.status === 'confirmed') { stats[month].confirmedCount += 1; stats[month].confirmedTotal += res.totalPrice; } else if (res.status === 'pending') { stats[month].pendingCount += 1; stats[month].pendingTotal += res.totalPrice; } }); return stats; }, [reservations]); const staffStats = useMemo(() => { const stats: Record; count: number }> = {}; reservations.forEach(res => { // Only count confirmed reservations for performance/stats if (res.status === 'confirmed' && res.staffAssignments) { res.staffAssignments.forEach(assignment => { if (!stats[assignment.staffId]) { stats[assignment.staffId] = { days: new Set(), count: 0 }; } stats[assignment.staffId].days.add(res.date); stats[assignment.staffId].count += 1; }); } }); return stats; }, [reservations]); const filteredReservations = useMemo(() => { return reservations.filter(res => { const matchesMonth = res.date.startsWith(selectedStatsMonth); const matchesStatus = adminStatusFilter === 'all' || res.status === adminStatusFilter; return matchesMonth && matchesStatus; }).sort((a, b) => { if (adminSortOrder === 'createdAt') { return b.createdAt.localeCompare(a.createdAt); } else { return b.date.localeCompare(a.date) || b.time.localeCompare(a.time); } }); }, [reservations, selectedStatsMonth, adminStatusFilter, adminSortOrder]); const sortedReservations = useMemo(() => { return [...reservations].sort((a, b) => a.createdAt.localeCompare(b.createdAt)); }, [reservations]); const handleCsvUpload = async () => { console.log('handleCsvUpload called'); if (!csvInput.trim()) { setUploadStatus({ message: '입력창이 비어 있습니다. 데이터를 붙여넣어 주세요.', type: 'error' }); return; } setUploadStatus({ message: '데이터 파싱 중...', type: 'info' }); setIsUploading(true); try { console.log('Input length:', csvInput.length); const result = await uploadWeddingHallsFromCSV(csvInput); console.log('Upload result:', result); setUploadStatus({ message: `업로드 성공! 총 ${result.successCount}건의 예식장이 등록되었습니다.`, type: 'success' }); setCsvInput(''); } catch (error: any) { console.error('Upload error details:', error); setUploadStatus({ message: `오류 발생: ${error.message || '알 수 없는 오류'}`, type: 'error' }); } finally { setIsUploading(false); } }; const handleClearHalls = async () => { console.log('handleClearHalls called'); if (!window.confirm('정말로 모든 예식장 데이터를 삭제하시겠습니까?')) return; setIsClearing(true); setUploadStatus({ message: '데이터 전체 삭제 중...', type: 'info' }); try { console.log('Starting clear process...'); const count = await clearWeddingHalls(); console.log('Clear successful, deleted:', count); setUploadStatus({ message: `삭제 완료! 총 ${count}건이 삭제되었습니다.`, type: 'success' }); } catch (error: any) { console.error('Clear error details:', error); setUploadStatus({ message: `삭제 중 오류 발생: ${error.message || '알 수 없는 오류'}`, type: 'error' }); } finally { setIsClearing(false); } }; const handleAddStaff = async () => { if (!newStaffName.trim()) return; setIsAddingStaff(true); try { await addDoc(collection(db, 'staff'), { name: newStaffName, role: newStaffRole, phone: newStaffPhone, createdAt: new Date().toISOString() }); setNewStaffName(''); setNewStaffRole(''); setNewStaffPhone(''); setUploadStatus({ message: '근무자 등록 완료', type: 'success' }); } catch (error) { console.error('Add staff error:', error); setUploadStatus({ message: '근무자 등록 실패', type: 'error' }); } finally { setIsAddingStaff(false); } }; const handleDeleteStaff = async (id: string, name: string) => { if (!window.confirm(`'${name}' 근무자를 삭제하시겠습니까?`)) return; try { await deleteDoc(doc(db, 'staff', id)); setUploadStatus({ message: '근무자 삭제 완료', type: 'success' }); } catch (error) { console.error('Delete staff error:', error); setUploadStatus({ message: '근무자 삭제 실패', type: 'error' }); } }; const handleAssignStaffAtIndex = async (reservationId: string, index: number, staffId: string, staffName: string) => { try { const reservation = reservations.find(r => r.id === reservationId); if (!reservation) return; const currentAssignments = [...(reservation.staffAssignments || [])]; // Ensure the array is long enough based on teamId const requiredCount = reservation.teamId === 'team-4' ? 4 : 2; while (currentAssignments.length < requiredCount) { currentAssignments.push({ staffId: '', staffName: '' }); } currentAssignments[index] = { staffId, staffName }; await updateDoc(doc(db, 'reservations', reservationId), { staffAssignments: currentAssignments }); // Update local state for the detail modal if (selectedReservationForDetail && selectedReservationForDetail.id === reservationId) { setSelectedReservationForDetail(prev => prev ? { ...prev, staffAssignments: currentAssignments } : null); } setUploadStatus({ message: '전담 인원 배정 완료', type: 'success' }); } catch (error) { console.error('Assign staff error:', error); setUploadStatus({ message: '배정 중 오류 발생', type: 'error' }); } }; const handleDeleteHall = async (id: string, name: string) => { console.log('handleDeleteHall called for:', name, id); // If not already in deleting state, set it if (deletingHallId !== id) { setDeletingHallId(id); // Auto-reset after 3 seconds setTimeout(() => setDeletingHallId(null), 3000); return; } try { console.log('Attempting to delete document:', id); setUploadStatus({ message: `'${name}' 삭제 중...`, type: 'info' }); await deleteDoc(doc(db, 'weddingHalls', id)); console.log('Delete successful'); setUploadStatus({ message: `'${name}' 삭제 완료`, type: 'success' }); setDeletingHallId(null); } catch (error: any) { console.error('Delete error details:', error); setUploadStatus({ message: `삭제 실패: ${error.message || '알 수 없는 오류'}`, type: 'error' }); setDeletingHallId(null); } }; const filteredAdminHalls = useMemo(() => { if (!adminHallSearch.trim()) return allWeddingHalls; const search = adminHallSearch.toLowerCase(); return allWeddingHalls.filter(hall => hall.name.toLowerCase().includes(search) || hall.province.toLowerCase().includes(search) || hall.city.toLowerCase().includes(search) || hall.address?.toLowerCase().includes(search) ); }, [allWeddingHalls, adminHallSearch]); const handleSampleUpload = async () => { const sampleData = `글래드 호텔 여의도 서울 영등포구 서울특별시 영등포구 의사당대로 16 신라호텔 다이너스티홀 서울 중구 서울특별시 중구 동호로 249 그랜드 하얏트 서울 서울 용산구 서울특별시 용산구 소월로 322`; setCsvInput(sampleData); alert('샘플 데이터가 입력창에 복사되었습니다. [데이터 업로드 시작] 버튼을 눌러주세요.'); }; const getReservationNumber = (id: string) => { const index = sortedReservations.findIndex(r => r.id === id); return index !== -1 ? index + 1 : 0; }; const formatSubmissionTime = (isoString: string) => { try { const date = new Date(isoString); return `${date.getMonth() + 1}/${date.getDate()} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; } catch (e) { return '-'; } }; const filteredWeddingHalls = useMemo(() => { if (!weddingHallSearch.trim()) return []; return allWeddingHalls.filter(hall => hall.name.toLowerCase().includes(weddingHallSearch.toLowerCase()) || (hall.city && hall.city.toLowerCase().includes(weddingHallSearch.toLowerCase())) || (hall.province && hall.province.toLowerCase().includes(weddingHallSearch.toLowerCase())) ).slice(0, 5); }, [weddingHallSearch, allWeddingHalls]); // Calendar Logic const currentYear = currentCalendarDate.getFullYear(); const currentMonth = currentCalendarDate.getMonth(); const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate(); const firstDayOfMonth = new Date(currentYear, currentMonth, 1).getDay(); const calendarDays = Array.from({ length: 42 }, (_, i) => { const day = i - firstDayOfMonth + 1; return day > 0 && day <= daysInMonth ? day : null; }); const getReservationStatus = (date: Date) => { const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; const dayReservations = reservations.filter(r => r.date === dateStr && r.status !== 'cancelled' && r.status !== 'rejected'); const hasMain = dayReservations.some(r => !r.isWaitlist); const waitlistCount = dayReservations.filter(r => r.isWaitlist).length; if (!hasMain) return 'available'; if (waitlistCount < 2) return 'waitlist'; return 'full'; }; const isDateBooked = (date: Date) => { return getReservationStatus(date) !== 'available'; }; const isWeekend = (day: number) => { const date = new Date(currentYear, currentMonth, day); const dayOfWeek = date.getDay(); return dayOfWeek === 0 || dayOfWeek === 6; }; const isPastDate = (day: number) => { const date = new Date(currentYear, currentMonth, day); const today = new Date(); today.setHours(0, 0, 0, 0); return date < today; }; const handlePrevMonth = () => { setCurrentCalendarDate(new Date(currentYear, currentMonth - 1, 1)); }; const handleNextMonth = () => { setCurrentCalendarDate(new Date(currentYear, currentMonth + 1, 1)); }; const isSideSelectionRequired = selectedTeamId === 'team-2' && !selectedSide; const isWeddingHallRequired = false; const isFormIncomplete = isSideSelectionRequired || isWeddingHallRequired; const isReservationDisabled = isSubmitting || isFormIncomplete || getReservationStatus(selectedDate) === 'full'; const handleReservationSubmit = async () => { if (!customerName || !customerPhone) { alert("성함과 연락처를 모두 입력해주세요."); return; } if (!tripodCheck) { alert("웨딩홀 삼각대 설치 가능 유무를 선택해주세요."); return; } if (tripodCheck === 'impossible') { alert("삼각대 설치가 불가능한 경우 예약 신청이 제한됩니다."); return; } if (isSideSelectionRequired) { alert("진행 측(신랑/신부)을 선택해주세요."); return; } if (isSubmitting) return; setIsSubmitting(true); try { const dateStr = `${selectedDate.getFullYear()}-${String(selectedDate.getMonth() + 1).padStart(2, '0')}-${String(selectedDate.getDate()).padStart(2, '0')}`; const resStatus = getReservationStatus(selectedDate); if (resStatus === 'full') { alert("해당 일자는 예약 및 줄서기가 모두 마감되었습니다."); setIsSubmitting(false); return; } const reservationData: any = { date: dateStr, time: `${selectedHour}:${selectedMinute}`, guestCount: 0, weddingHall: selectedWeddingHall?.name || '미지정', location: selectedWeddingHall?.province || '수원', teamId: selectedTeamId, teamName: selectedTeam?.name || '', settlementType: selectedSettlementType, customerName, customerPhone, tripodCheck, addOns: selectedAddOns, totalPrice, travelFee: 0, isWaitlist: resStatus === 'waitlist', createdAt: new Date().toISOString(), status: 'pending', uid: auth.currentUser?.uid || null, history: [{ timestamp: new Date().toISOString(), status: 'pending', note: '예약 신청 접수' }] }; if (selectedTeamId === 'team-2' && selectedSide) { reservationData.side = selectedSide; } console.log('Submitting reservation data:', reservationData); setIsLastSubmissionWaitlist(reservationData.isWaitlist); await addDoc(collection(db, 'reservations'), reservationData); console.log('Reservation submitted successfully'); setSubmitSuccess(true); setShowPrecautions(false); setCustomerName(''); setCustomerPhone(''); setTripodCheck(null); } catch (error) { console.error("Error submitting reservation:", error); alert("예약 중 오류가 발생했습니다. 다시 시도해 주세요. (오류: " + (error instanceof Error ? error.message : "알 수 없는 오류") + ")"); } finally { setIsSubmitting(false); } }; const updateReservationStatus = async (id: string, status: 'confirmed' | 'cancelled' | 'pending' | 'rejected' | 'cancel_requested', note?: string) => { try { const historyEntry: ReservationHistory = { timestamp: new Date().toISOString(), status, note }; await updateDoc(doc(db, 'reservations', id), { status, history: arrayUnion(historyEntry) }); if (selectedReservationForDetail && selectedReservationForDetail.id === id) { setSelectedReservationForDetail(prev => prev ? { ...prev, status, history: [...(prev.history || []), historyEntry] } : null); } } catch (error) { console.error("Error updating status:", error); alert("상태 변경 중 오류가 발생했습니다."); } }; const deleteReservation = async (id: string) => { if (!window.confirm('이 예약 기록을 영구적으로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.')) return; try { await deleteDoc(doc(db, 'reservations', id)); setSelectedReservationForDetail(null); setUploadStatus({ message: '예약 기록이 삭제되었습니다.', type: 'success' }); } catch (error) { console.error("Error deleting reservation:", error); alert("삭제 중 오류가 발생했습니다."); } }; const handleAdminLogin = (e: React.FormEvent) => { e.preventDefault(); if (adminPassword === '1234567') { setIsAdminAuthenticated(true); } else { alert('비밀번호가 틀렸습니다.'); } }; const searchMyReservations = async () => { if (!searchPhone.trim()) return; setIsSearching(true); try { const q = query(collection(db, 'reservations'), where('customerPhone', '==', searchPhone)); const snapshot = await getDocs(q); const data = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })) as Reservation[]; setUserReservations(data); if (data.length === 0) { alert('해당 연락처로 등록된 예약이 없습니다.'); } } catch (error) { console.error("Error searching reservations:", error); alert("조회 중 오류가 발생했습니다."); } finally { setIsSearching(false); } }; const requestCancellation = async (id: string) => { if (!window.confirm('예약 취소를 요청하시겠습니까? 관리자의 승인 후 최종 취소 처리됩니다.')) return; try { await updateReservationStatus(id, 'cancel_requested', '고객 취소 요청'); // Update local state if needed setUserReservations(prev => prev.map(r => r.id === id ? { ...r, status: 'cancel_requested' } : r)); alert('취소 요청이 접수되었습니다. 관리자가 확인 후 처리해 드릴 예정입니다.'); } catch (error) { console.error("Error requesting cancellation:", error); alert("취소 요청 중 오류가 발생했습니다."); } }; const handleGoogleAdminLogin = async () => { try { const provider = new GoogleAuthProvider(); const result = await signInWithPopup(auth, provider); if (result.user.email === 'nealjin29@gmail.com') { setIsAdminAuthenticated(true); } else { alert('관리자 권한이 없는 계정입니다.'); await signOut(auth); } } catch (error) { console.error("Admin Login Error:", error); alert('로그인 중 오류가 발생했습니다.'); } }; if (view === 'admin') { if (!isAdminAuthenticated) { return (

관리자 로그인

축의대 대행 서비스 관리 시스템

또는 비밀번호로 접속
setAdminPassword(e.target.value)} className="w-full bg-slate-50 border-none rounded-xl py-4 px-6 focus:ring-2 focus:ring-[#0a44b8] transition-all" placeholder="•••••••" />
); } return (
{/* Admin Header */}

관리자 대시보드

Gift Table Agency Service

{!user || user.email !== 'nealjin29@gmail.com' ? (

관리자 권한이 확인되지 않았습니다.

데이터를 보려면 nealjin29@gmail.com 계정으로 로그인해 주세요. 현재 비밀번호로만 접속된 상태입니다.

) : null} {/* Monthly Stats Dashboard */}

월간 실적 요약

{/* Total Card */}

전체 접수 현황

{monthlyStats[selectedStatsMonth]?.count || 0}건

{(monthlyStats[selectedStatsMonth]?.total || 0).toLocaleString()}원

{/* Confirmed Card */}

확정 완료 (매출)

{monthlyStats[selectedStatsMonth]?.confirmedCount || 0}건

{(monthlyStats[selectedStatsMonth]?.confirmedTotal || 0).toLocaleString()}원

{/* Pending Card */}

대기 중 (예상)

{monthlyStats[selectedStatsMonth]?.pendingCount || 0}건

{(monthlyStats[selectedStatsMonth]?.pendingTotal || 0).toLocaleString()}원

{adminViewMode === 'list' && ( <>
)}
{adminViewMode === 'list' ? (
{/* Section 1: Pending/Other Reservations */}

{selectedStatsMonth.replace('-', '년 ')}월 {adminStatusFilter === 'all' ? '전체' : adminStatusFilter === 'pending' ? '대기' : adminStatusFilter === 'confirmed' ? '확정' : adminStatusFilter === 'cancel_requested' ? '취소 요청' : '취소/거절'} 예약 현황

{filteredReservations.length}건 검색됨
{filteredReservations.map((res) => ( setSelectedReservationForDetail(res)} > ))}
No. 접수 시각 예약자 정보 상품 구분 예식 일시 예식장 결제 금액 상태
#{getReservationNumber(res.id!)} {formatSubmissionTime(res.createdAt)}

{res.customerName}

{res.customerPhone}

{res.isWaitlist && ( 줄서기 )}
{res.teamId === 'team-4' ? '4인' : '2인'}

{res.date}

{res.time}

{res.weddingHall || '미입력'} {res.totalPrice.toLocaleString()}원 e.stopPropagation()}>
{res.status === 'cancel_requested' && ( 요청됨 )}
) : adminViewMode === 'calendar' ? (

{currentYear}년 {currentMonth + 1}월

{['일', '월', '화', '수', '목', '금', '토'].map(day => (
{day}
))} {Array.from({ length: 42 }).map((_, i) => { const day = i - firstDayOfMonth + 1; const isCurrentMonth = day > 0 && day <= daysInMonth; const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const dayReservations = reservations.filter(r => r.date === dateStr); return (
{isCurrentMonth && ( <> {day}
{dayReservations.map(res => (
{res.time} {res.customerName}
))}
)}
); })}
) : adminViewMode === 'staff' ? (

근무자 신규 등록

새로운 전담 인원을 시스템에 등록합니다.

setNewStaffName(e.target.value)} placeholder="홍길동" className="w-full px-4 py-3 rounded-xl border border-slate-200 bg-slate-50 focus:outline-none focus:border-[#0a44b8] focus:bg-white transition-all" />
setNewStaffRole(e.target.value)} placeholder="팀장, 메인 스탭 등" className="w-full px-4 py-3 rounded-xl border border-slate-200 bg-slate-50 focus:outline-none focus:border-[#0a44b8] focus:bg-white transition-all" />
setNewStaffPhone(e.target.value)} placeholder="010-0000-0000" className="w-full px-4 py-3 rounded-xl border border-slate-200 bg-slate-50 focus:outline-none focus:border-[#0a44b8] focus:bg-white transition-all" />
{uploadStatus && adminViewMode === 'staff' && (
{uploadStatus.message}
)}

등록된 근무자 목록 ({staff.length}명)

{staff.length === 0 ? ( ) : ( staff.map((s) => ( )) )}
이름 직책 연락처 참여 일수 참여 건수 등록일 관리
등록된 근무자가 없습니다.
{s.name} {s.role || '-'} {s.phone || '-'} {staffStats[s.id]?.days.size || 0}일 {staffStats[s.id]?.count || 0}건 {new Date(s.createdAt).toLocaleDateString()}
) : (

예식장 데이터 업로드

엑셀에서 데이터를 복사해서 그대로 붙여넣으세요. (탭 구분 또는 콤마 구분 모두 지원합니다.)
형식: 예식장명 [탭] 도 [탭] 시/군/구 [탭] 주소