import React, { useState, useEffect, useMemo } from 'react';
import { initializeApp, getApps } from 'firebase/app';
import {
getAuth,
signInWithCustomToken,
signInAnonymously,
onAuthStateChanged
} from 'firebase/auth';
import {
getFirestore,
collection,
doc,
setDoc,
addDoc,
deleteDoc,
onSnapshot,
Timestamp
} from 'firebase/firestore';
import {
Calendar as CalendarIcon,
Users,
MapPin,
FileText,
Plus,
X,
ChevronLeft,
ChevronRight,
Check,
Home as HomeIcon,
Save,
FileSpreadsheet,
CalendarDays,
Landmark,
Coins,
Cloud,
CircleX,
ArrowDownCircle,
AlertCircle,
Info,
Sparkles,
Brain,
TrendingUp,
MessageSquare,
Search,
LayoutDashboard,
ClipboardList,
Calculator,
Percent,
Wallet,
Truck,
UserPlus,
Phone,
Trash2,
AlertTriangle
} from 'lucide-react';
// --- Firebase 초기화 ---
const firebaseConfig = JSON.parse(__firebase_config);
const app = !getApps().length ? initializeApp(firebaseConfig) : getApps()[0];
const auth = getAuth(app);
const db = getFirestore(app);
const appId = typeof __app_id !== 'undefined' ? __app_id : 'field-management-pro-v1';
// --- Gemini API 설정 ---
const apiKey = "";
const App = () => {
// --- 유틸리티 함수 ---
const getLocalDateString = (date) => {
if (!date) return "";
const offset = date.getTimezoneOffset() * 60000;
return (new Date(date.getTime() - offset)).toISOString().slice(0, 10);
};
// --- 상태 관리 ---
const [user, setUser] = useState(null);
const [activeTab, setActiveTab] = useState('home');
const [viewDate, setViewDate] = useState(new Date());
const [reports, setReports] = useState([]);
const [isInputModalOpen, setIsInputModalOpen] = useState(false);
const [editingId, setEditingId] = useState(null);
const [toast, setToast] = useState({ open: false, message: '', type: 'info' });
const [showExpDetail, setShowExpDetail] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
// 삭제 확인용 상태
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
const [aiResult, setAiResult] = useState(null);
const [isAIAnalyzing, setIsAIAnalyzing] = useState(false);
const [isAIModalOpen, setIsAIModalOpen] = useState(false);
// Firestore 데이터 상태
const [userMeta, setUserMeta] = useState({});
const [receivedAmounts, setReceivedAmounts] = useState({});
const [vatOverrides, setVatOverrides] = useState({});
const now = new Date();
const [startDate, setStartDate] = useState(new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString().split('T')[0]);
const [endDate, setEndDate] = useState(new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().split('T')[0]);
// 상세 지출 항목 12개 슬롯 구성
const initialForm = {
date: getLocalDateString(new Date()),
company: '', site: '', names: '',
totalAmount: '0', vat: '0', totalSum: '0',
materialCost: '0', laborCost: '0', expense: '0', profit: '0',
...Object.fromEntries(Array.from({ length: 12 }, (_, i) => [`expName${i+1}`, ''])),
...Object.fromEntries(Array.from({ length: 12 }, (_, i) => [`expAmt${i+1}`, '0']))
};
const [formData, setFormData] = useState(initialForm);
// --- 헬퍼 함수 ---
const showToastMessage = (msg, type = 'info') => {
setToast({ open: true, message: msg, type });
setTimeout(() => setToast(prev => ({ ...prev, open: false })), 3000);
};
const formatNumber = (num) => {
if (!num && num !== 0) return '0';
const n = typeof num === 'string' ? parseInt(num.replace(/,/g, '')) || 0 : num;
return Math.floor(n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
};
const parseNumber = (str) => {
if (!str) return '0';
const val = str.toString().replace(/,/g, '').replace(/[^0-9-]/g, '');
return val === '' ? '0' : val;
};
const calculateFinancials = (data) => {
const amt = parseInt(parseNumber(data.totalAmount)) || 0;
const vat = parseInt(parseNumber(data.vat)) || 0;
const mat = parseInt(parseNumber(data.materialCost)) || 0;
const lab = parseInt(parseNumber(data.laborCost)) || 0;
const expTotal = Array.from({ length: 12 }, (_, i) => i + 1).reduce((sum, num) => {
return sum + (parseInt(parseNumber(data[`expAmt${num}`])) || 0);
}, 0);
return {
totalSum: (amt + vat).toString(),
expense: expTotal.toString(),
profit: (amt - mat - lab - expTotal).toString()
};
};
// --- 엑셀(CSV) 전환 ---
const handleExportExcel = () => {
if (reports.length === 0) {
showToastMessage("전환할 데이터가 없습니다.", "error");
return;
}
const headers = ["날짜", "거래처", "현장", "공급가액", "부가세", "합계", "인건비", "자재비", "경비", "이익"];
const rows = reports.map(r => [
r.date, r.company, r.site, r.totalAmount, r.vat, r.totalSum, r.laborCost, r.materialCost, r.expense, r.profit
]);
const csvContent = "\uFEFF" + headers.join(",") + "\n" + rows.map(e => e.join(",")).join("\n");
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `현장관리리포트_${getLocalDateString(new Date())}.csv`;
link.click();
showToastMessage("엑셀 파일이 다운로드되었습니다.", "success");
};
// --- Firebase 인증 및 데이터 동기화 ---
useEffect(() => {
const initAuth = async () => {
if (typeof __initial_auth_token !== 'undefined' && __initial_auth_token) {
await signInWithCustomToken(auth, __initial_auth_token);
} else {
await signInAnonymously(auth);
}
};
initAuth();
const unsubscribe = onAuthStateChanged(auth, setUser);
return () => unsubscribe();
}, []);
useEffect(() => {
if (!user) return;
const unsubReports = onSnapshot(collection(db, 'artifacts', appId, 'public', 'data', 'reports'), (snap) => {
setReports(snap.docs.map(d => ({ id: d.id, ...d.data() })).sort((a, b) => b.date.localeCompare(a.date)));
});
const unsubMeta = onSnapshot(collection(db, 'artifacts', appId, 'public', 'data', 'personnelMeta'), (snap) => {
const meta = {}; snap.docs.forEach(d => { meta[d.id] = d.data(); }); setUserMeta(meta);
});
const unsubFinance = onSnapshot(collection(db, 'artifacts', appId, 'public', 'data', 'receivedAmounts'), (snap) => {
const fin = {}; snap.docs.forEach(d => { fin[d.id] = d.data().history; }); setReceivedAmounts(fin);
});
const unsubVat = onSnapshot(collection(db, 'artifacts', appId, 'public', 'data', 'vatOverrides'), (snap) => {
const vats = {}; snap.docs.forEach(d => { vats[d.id] = d.data().amount; }); setVatOverrides(vats);
});
return () => { unsubReports(); unsubMeta(); unsubFinance(); unsubVat(); };
}, [user]);
// --- 핸들러 ---
const handleSaveReport = async (e) => {
e.preventDefault();
if (!user) return;
setIsSyncing(true);
try {
if (editingId) {
await setDoc(doc(db, 'artifacts', appId, 'public', 'data', 'reports', editingId), formData);
} else {
await addDoc(collection(db, 'artifacts', appId, 'public', 'data', 'reports'), formData);
}
setIsInputModalOpen(false);
setFormData(initialForm);
setEditingId(null);
showToastMessage("데이터 저장 완료", "success");
} catch (err) { showToastMessage("저장 실패", "info"); }
finally { setIsSyncing(false); }
};
// [수정] 윈도우 confirm 대신 커스텀 모달을 트리거하는 방식으로 변경
const triggerDelete = (id, e) => {
if (e) e.stopPropagation();
setDeleteConfirmId(id);
};
const confirmDeleteReport = async () => {
if (!user || !deleteConfirmId) return;
try {
await deleteDoc(doc(db, 'artifacts', appId, 'public', 'data', 'reports', deleteConfirmId));
showToastMessage("데이터 삭제 완료", "success");
} catch (err) {
showToastMessage("삭제 중 오류 발생", "info");
} finally {
setDeleteConfirmId(null);
}
};
const handleUpdateMeta = async (name, field, val) => {
if (!user) return;
const current = userMeta[name] || {};
const cleanVal = (field === 'unitCost' || field === 'deduction') ? parseNumber(val) : val;
await setDoc(doc(db, 'artifacts', appId, 'public', 'data', 'personnelMeta', name), { ...current, [field]: cleanVal });
};
const handleUpdateFinance = async (key, idx, field, val) => {
if (!user) return;
const history = receivedAmounts[key] ? [...receivedAmounts[key]] : Array.from({ length: 5 }, () => ({ amt: 0, date: '' }));
history[idx] = { ...history[idx], [field]: field === 'amt' ? parseNumber(val) : val };
await setDoc(doc(db, 'artifacts', appId, 'public', 'data', 'receivedAmounts', key), { history });
};
const handleUpdateVatOverride = async (key, val) => {
if (!user) return;
const amount = parseNumber(val);
await setDoc(doc(db, 'artifacts', appId, 'public', 'data', 'vatOverrides', key), { amount });
};
const callGeminiAI = async (promptText) => {
setIsAIAnalyzing(true);
setIsAIModalOpen(true);
setAiResult("데이터 분석 중...");
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key=${apiKey}`;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contents: [{ parts: [{ text: promptText }] }] })
});
const result = await response.json();
setAiResult(result.candidates?.[0]?.content?.parts?.[0]?.text || "분석 실패");
} catch (err) { setAiResult("AI 연결 오류"); }
finally { setIsAIAnalyzing(false); }
};
// --- 데이터 집계 ---
const totals = useMemo(() => {
return reports.reduce((acc, r) => {
const key = `${r.company.trim()}|${r.site.trim()}`;
const sumReceived = (receivedAmounts[key] || []).reduce((s, v) => s + (parseInt(v.amt) || 0), 0);
acc.pure += parseInt(r.totalAmount || 0);
acc.vat += parseInt(r.vat || 0);
acc.total += parseInt(r.totalSum || 0);
acc.received += sumReceived;
acc.balance += (parseInt(r.totalSum || 0) - sumReceived);
acc.mat += parseInt(r.materialCost || 0);
acc.lab += parseInt(r.laborCost || 0);
acc.exp += parseInt(r.expense || 0);
acc.profit += parseInt(r.profit || 0);
return acc;
}, { pure: 0, vat: 0, total: 0, received: 0, balance: 0, mat: 0, lab: 0, exp: 0, profit: 0 });
}, [reports, receivedAmounts]);
const groupedProjectData = useMemo(() => {
const groups = {};
reports.forEach(r => {
const key = `${r.company.trim()}|${r.site.trim()}`;
if (!groups[key]) {
groups[key] = {
id: key, company: r.company, site: r.site, date: r.date,
totalAmount: 0, vat: 0, totalSum: 0, materialCost: 0, laborCost: 0, expense: 0, profit: 0, count: 0
};
}
groups[key].totalAmount += parseInt(r.totalAmount || 0);
groups[key].vat += parseInt(r.vat || 0);
groups[key].totalSum += parseInt(r.totalSum || 0);
groups[key].materialCost += parseInt(r.materialCost || 0);
groups[key].laborCost += parseInt(r.laborCost || 0);
groups[key].expense += parseInt(r.expense || 0);
groups[key].profit += parseInt(r.profit || 0);
groups[key].count += 1;
if (r.date > groups[key].date) groups[key].date = r.date;
});
return Object.values(groups).sort((a, b) => b.date.localeCompare(a.date));
}, [reports]);
const personStats = useMemo(() => {
const stats = {};
const filtered = reports.filter(r => r.date >= startDate && r.date <= endDate);
filtered.forEach(r => {
const names = (r.names || '').split(':').map(n => n.trim()).filter(n => n !== "");
names.forEach(name => {
if (!stats[name]) stats[name] = { name, dates: [], sites: [], days: 0 };
if (!stats[name].dates.includes(r.date)) {
stats[name].dates.push(r.date);
stats[name].days += 1;
}
if (!stats[name].sites.includes(r.site)) stats[name].sites.push(r.site);
});
});
return Object.values(stats).sort((a, b) => b.days - a.days);
}, [reports, startDate, endDate]);
const calendarDays = useMemo(() => {
const y = viewDate.getFullYear();
const m = viewDate.getMonth();
const start = new Date(y, m, 1).getDay();
const end = new Date(y, m + 1, 0).getDate();
const days = [];
for (let i = 0; i < start; i++) days.push(null);
for (let i = 1; i <= end; i++) days.push(new Date(y, m, i));
return days;
}, [viewDate]);
const renderExpDetailRow = (num) => (
);
return (
{/* --- 헤더 --- */}
Office Hub Pro
{viewDate.getFullYear()}년 {viewDate.getMonth() + 1}월
{/* --- 메인 컨텐츠 --- */}
{activeTab === 'home' && (
{['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'].map((d, i) => (
{d}
))}
{calendarDays.map((date, idx) => {
const ds = date ? getLocalDateString(date) : null;
const daily = reports.filter(r => r.date === ds);
return (
date && (setFormData({ ...initialForm, date: ds }), setEditingId(null), setIsInputModalOpen(true))}>
{date && (
<>
{date.getDate()}
{daily.map(r => (
{ e.stopPropagation(); setFormData(r); setEditingId(r.id); setIsInputModalOpen(true); }}>
{r.company}
{r.site}
{/* [개선] 즉시 삭제 버튼 로직 강화 */}
))}
>
)}
);
})}
)}
{activeTab === 'statement' && (
통합 정산 내역 (현장별 합계)
현장별 자동 합산 데이터입니다.
{[
{ label: '총 공사액', val: totals.pure, color: 'text-indigo-600', bg: 'bg-indigo-50/50' },
{ label: '자재비', val: totals.mat, color: 'text-orange-600', bg: 'bg-orange-50/50' },
{ label: '총 인건비', val: totals.lab, color: 'text-violet-600', bg: 'bg-violet-50/50' },
{ label: '총 경비', val: totals.exp, color: 'text-amber-600', bg: 'bg-amber-50/50' },
{ label: '최종 이익', val: totals.profit, color: 'text-emerald-600', bg: 'bg-emerald-50/50' },
{ label: '수익률', val: totals.pure > 0 ? ((totals.profit / totals.pure) * 100).toFixed(1) + '%' : '0%', color: 'text-slate-800', bg: 'bg-slate-50' }
].map(s => (
{s.label}
{s.label === '수익률' ? s.val : formatNumber(s.val)}
))}
| No. | 최근작업일 | 거래처 / 현장 | 총 공사금액 | 자재비 | 인건비 | 경비 | 누적 이익금 | 마진율 |
{groupedProjectData.map((r, i) => {
const margin = r.totalAmount > 0 ? ((r.profit / r.totalAmount) * 100).toFixed(0) : 0;
return (
| {i + 1} |
{r.date.slice(5)} |
{r.company} x{r.count} {r.site} |
{formatNumber(r.totalAmount)} |
{formatNumber(r.materialCost)} |
{formatNumber(r.laborCost)} |
{formatNumber(r.expense)} |
= 0 ? 'text-emerald-700' : 'text-rose-600'}`}>{formatNumber(r.profit)} |
= 0 ? 'bg-emerald-50 text-emerald-700 border-emerald-200' : 'bg-rose-50 text-rose-700 border border-rose-200'} border-2`}>{margin}% |
);
})}
)}
{activeTab === 'attendance' && (
)}
{activeTab === 'finance' && (
{[
{ label: '총공사금액', val: totals.total, color: 'text-indigo-700', border: 'border-indigo-200' },
{ label: '총입금액', val: totals.received, color: 'text-emerald-700', border: 'border-emerald-200' },
{ label: '총미수금', val: totals.balance, color: 'text-rose-700', border: 'border-rose-200' }
].map(item => (
{item.label}
{formatNumber(item.val)}
))}
| 날짜 |
거래처 / 현장 |
합산 총금액 |
합산 부가세 |
금액 |
{[1,2,3,4,5].map(n => 누적 입금 {n} | )}
{groupedProjectData.map((r, i) => {
const key = r.id;
const defaultVat = Math.floor(r.totalAmount * 0.1);
const activeVat = vatOverrides[key] !== undefined ? parseInt(vatOverrides[key]) : defaultVat;
const totalSumWithActiveVat = r.totalAmount + activeVat;
const hist = receivedAmounts[key] || Array.from({ length: 5 }, () => ({ amt: 0, date: '' }));
const sumRec = hist.reduce((s, v) => s + (parseInt(v.amt) || 0), 0);
const bal = totalSumWithActiveVat - sumRec;
const balanceClass = bal > 0 ? "animate-urgent text-rose-700 font-black" : "text-emerald-700 font-black bg-emerald-50/40";
return (
| {r.date.slice(5)} |
{r.company} x{r.count}
{r.site}
|
{formatNumber(r.totalAmount)} |
handleUpdateVatOverride(key, e.target.value)}
className="w-full h-10 text-center bg-violet-50/30 border border-violet-100 rounded-lg font-bold text-violet-700 outline-none focus:ring-2 focus:ring-violet-200"
/>
|
{formatNumber(bal)} |
{hist.map((entry, idx) => (
handleUpdateFinance(key, idx, 'amt', e.target.value)} className="w-full text-right bg-white border border-slate-200 rounded-lg px-2 py-1 font-black text-emerald-700 outline-none focus:ring-2 focus:ring-emerald-100 shadow-sm" />
handleUpdateFinance(key, idx, 'date', e.target.value)} className="w-full text-center bg-slate-100 text-[10px] font-black text-slate-500 rounded-md py-0.5 shadow-inner" placeholder="MM.DD" />
|
))}
);
})}
)}
{/* --- [규격 최적화] 현장 데이터 관리 모달 --- */}
{isInputModalOpen && (
setIsInputModalOpen(false)}>
e.stopPropagation()}>
현장 데이터 관리
Cloud Sync Enabled
)}
{/* [개선] 삭제 확인 커스텀 모달 (window.confirm 대체) */}
{deleteConfirmId && (
데이터를 삭제할까요?
한 번 삭제된 현장 리포트는
다시 복구할 수 없습니다.
)}
{/* AI 분석 결과 모달 */}
{isAIModalOpen && (
Gemini AI 경영 분석
Deep Analysis Report
{isAIAnalyzing ?
: aiResult}
)}
{/* 토스트 */}
{toast.open && (
{toast.type === 'success' ?
: } {toast.message}
)}
);
};
export default App;