Files
sam-kd/opendart/index.php
hskwon aca1767eb9 초기 커밋: 5130 레거시 시스템
- URL 하드코딩 → .env APP_URL 기반 동적 URL로 변경
- DB 연결 하드코딩 → .env 기반으로 변경
- MySQL strict mode DATE 오류 수정
2025-12-10 20:14:31 +09:00

1089 lines
62 KiB
PHP

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>기업개황 조회 - Open DART</title>
<!-- Fonts: Pretendard -->
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.8/dist/web/static/pretendard.css" />
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Pretendard', 'sans-serif'],
},
colors: {
background: 'rgb(250, 250, 250)',
primary: {
DEFAULT: '#2563eb',
foreground: '#ffffff',
},
},
borderRadius: {
'card': '12px',
}
}
}
}
</script>
<!-- React & ReactDOM -->
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<!-- Babel for JSX -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<!-- Icons: Lucide -->
<script src="https://unpkg.com/lucide@latest"></script>
<!-- Chart.js for comparison charts -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head>
<body class="bg-background text-slate-800 antialiased">
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useRef } = React;
// --- Components ---
const Header = () => {
return (
<header className="bg-white border-b border-gray-100 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center text-blue-600 font-bold">
🏢
</div>
<h1 className="text-lg font-semibold text-slate-900">기업개황 조회 (Open DART)</h1>
</div>
<div className="flex items-center gap-4">
<a href="https://opendart.fss.or.kr/" target="_blank" className="text-sm text-slate-500 hover:text-slate-900 flex items-center gap-1">
<i data-lucide="external-link" className="w-4 h-4"></i>
Open DART
</a>
<a href="../index.php" className="text-sm text-slate-500 hover:text-slate-900 flex items-center gap-1">
<i data-lucide="home" className="w-4 h-4"></i>
홈으로
</a>
</div>
</div>
</header>
);
};
const SearchBar = ({ onSearch, isSearching }) => {
const [query, setQuery] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
onSearch(query);
};
return (
<div className="bg-white rounded-card p-6 shadow-sm border border-slate-100 mb-6">
<form onSubmit={handleSubmit} className="flex gap-4 items-end">
<div className="flex-1">
<label className="block text-sm font-medium text-slate-700 mb-2">기업명 검색</label>
<input
type="text"
className="w-full rounded-lg border-slate-200 border p-3 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="기업명을 입력하세요 (예: 삼성전자)"
required
/>
</div>
<button
type="submit"
disabled={isSearching}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors disabled:opacity-50 flex items-center gap-2"
>
{isSearching ? '검색 중...' : '검색'}
</button>
</form>
</div>
);
};
const CompanyList = ({ companies, onViewDetail }) => {
useEffect(() => {
lucide.createIcons();
}, [companies]);
return (
<div className="bg-white rounded-card shadow-sm border border-slate-100 overflow-hidden">
<div className="p-6 border-b border-slate-100">
<h2 className="text-lg font-bold text-slate-900">검색 결과</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm text-slate-600">
<thead className="bg-slate-50 text-xs uppercase font-medium text-slate-500">
<tr>
<th className="px-6 py-4">기업코드 (CorpCode)</th>
<th className="px-6 py-4">기업명</th>
<th className="px-6 py-4">종목코드 (StockCode)</th>
<th className="px-6 py-4">수정일</th>
<th className="px-6 py-4 text-right">관리</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{companies.length === 0 ? (
<tr>
<td colSpan="5" className="px-6 py-8 text-center text-slate-400">
검색된 기업이 없습니다.
</td>
</tr>
) : (
companies.map((corp, index) => (
<tr
key={index}
className="hover:bg-slate-50 transition-colors cursor-pointer"
onClick={() => onViewDetail(corp.corp_code)}
>
<td className="px-6 py-4 font-mono">{corp.corp_code}</td>
<td className="px-6 py-4 font-medium text-slate-900">{corp.corp_name}</td>
<td className="px-6 py-4">{corp.stock_code || '-'}</td>
<td className="px-6 py-4">{corp.modify_date}</td>
<td className="px-6 py-4 text-right">
<button
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
>
상세보기
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
};
const CompanyDetailModal = ({ company, onClose }) => {
const [activeTab, setActiveTab] = useState('overview');
const [disclosures, setDisclosures] = useState([]);
const [financialData, setFinancialData] = useState(null);
const [loadingDisclosures, setLoadingDisclosures] = useState(false);
const [loadingFinancial, setLoadingFinancial] = useState(false);
// 아이콘 초기화 (탭 변경 및 데이터 로드 후)
useEffect(() => {
const timer = setTimeout(() => {
if (typeof lucide !== 'undefined' && lucide.createIcons) {
try {
lucide.createIcons();
} catch (error) {
console.warn('Lucide icons initialization error:', error);
}
}
}, 100);
return () => clearTimeout(timer);
}, [activeTab, disclosures, financialData]);
// 공시목록 로드
const loadDisclosures = async () => {
if (disclosures.length > 0) return; // 이미 로드됨
setLoadingDisclosures(true);
try {
const today = new Date();
const oneYearAgo = new Date(today.getFullYear() - 1, today.getMonth(), today.getDate());
const bgn_de = oneYearAgo.toISOString().split('T')[0].replace(/-/g, '');
const end_de = today.toISOString().split('T')[0].replace(/-/g, '');
const response = await fetch(`api/list_disclosures.php?corp_code=${company.corp_code}&bgn_de=${bgn_de}&end_de=${end_de}`);
const data = await response.json();
if (data.status === '000' && data.list) {
setDisclosures(data.list);
} else {
setDisclosures([]);
}
} catch (error) {
console.error('Disclosures load error:', error);
setDisclosures([]);
} finally {
setLoadingDisclosures(false);
}
};
// 아이콘 초기화 (탭 변경 시)
useEffect(() => {
const timer = setTimeout(() => {
if (typeof lucide !== 'undefined' && lucide.createIcons) {
lucide.createIcons();
}
}, 100);
return () => clearTimeout(timer);
}, [activeTab, disclosures, financialData]);
// 재무제표 로드
const loadFinancial = async () => {
if (financialData) return; // 이미 로드됨
setLoadingFinancial(true);
try {
const currentYear = new Date().getFullYear();
// 최근 3년 데이터 시도 (현재 연도에 데이터가 없을 수 있음)
const yearsToTry = [currentYear, currentYear - 1, currentYear - 2];
let loaded = false;
for (const year of yearsToTry) {
try {
const response = await fetch(`api/financial_statement.php?corp_code=${company.corp_code}&bsns_year=${year}&reprt_code=11011&fs_div=CFS`);
const data = await response.json();
console.log(`재무제표 API 응답 (${year}년):`, data);
// Open DART API 응답 형식 확인
if (data.status === '000' && data.list && data.list.length > 0) {
setFinancialData(data.list);
loaded = true;
break;
} else if (data.status && data.status !== '000') {
console.warn(`재무제표 조회 실패 (${year}년):`, data.message || data.status_nm || '알 수 없는 오류');
// 다음 연도 시도
continue;
}
} catch (error) {
console.error(`재무제표 로드 오류 (${year}년):`, error);
continue;
}
}
if (!loaded) {
console.warn('모든 연도에서 재무제표를 찾을 수 없습니다.');
setFinancialData([]);
}
} catch (error) {
console.error('Financial load error:', error);
setFinancialData([]);
} finally {
setLoadingFinancial(false);
}
};
// 탭 변경 시 데이터 로드
useEffect(() => {
if (activeTab === 'disclosures') {
loadDisclosures();
} else if (activeTab === 'financial') {
loadFinancial();
}
}, [activeTab]);
if (!company) return null;
const tabs = [
{ id: 'overview', label: '기업개황', icon: 'building' },
{ id: 'disclosures', label: '공시목록', icon: 'file-text' },
{ id: 'financial', label: '재무제표', icon: 'trending-up' }
];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={onClose}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-4xl overflow-hidden flex flex-col max-h-[90vh]" onClick={e => e.stopPropagation()}>
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50 shrink-0">
<div>
<h3 className="text-xl font-bold text-slate-900">{company.corp_name}</h3>
<p className="text-sm text-slate-500">기업 정보 대시보드</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-slate-200 rounded-full transition-colors">
<i data-lucide="x" className="w-5 h-5 text-slate-500"></i>
</button>
</div>
{/* 탭 메뉴 */}
<div className="border-b border-slate-200 flex gap-1 px-6 bg-white" onClick={e => e.stopPropagation()}>
{tabs.map(tab => (
<button
key={tab.id}
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setActiveTab(tab.id);
}}
className={`px-4 py-3 text-sm font-medium transition-colors border-b-2 ${
activeTab === tab.id
? 'border-blue-600 text-blue-600'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
<i data-lucide={tab.icon} className="w-4 h-4 inline-block mr-2"></i>
{tab.label}
</button>
))}
</div>
<div className="p-6 overflow-y-auto flex-1">
{/* 기업개황 탭 */}
{activeTab === 'overview' && (
<div className="grid grid-cols-2 gap-6" onClick={e => e.stopPropagation()}>
<div>
<label className="text-xs text-slate-500 mb-1 block">대표자명</label>
<div className="text-slate-900 font-medium">{company.ceo_nm}</div>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">법인등록번호</label>
<div className="text-slate-900 font-medium">{company.jurir_no}</div>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">사업자등록번호</label>
<div className="text-slate-900 font-medium text-blue-600">{company.bizr_no}</div>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">설립일</label>
<div className="text-slate-900">{company.est_dt}</div>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">업종코드</label>
<div className="text-slate-900">{company.induty_code}</div>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">상태</label>
<div className="text-slate-900">{company.status}</div>
</div>
<div className="col-span-2">
<label className="text-xs text-slate-500 mb-1 block">주소</label>
<div className="text-slate-900">{company.adres}</div>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">홈페이지</label>
<div className="text-slate-900">
{company.hm_url ? <a href={company.hm_url} target="_blank" className="text-blue-500 hover:underline">{company.hm_url}</a> : '-'}
</div>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">전화번호</label>
<div className="text-slate-900">{company.phn_no}</div>
</div>
</div>
)}
{/* 공시목록 탭 */}
{activeTab === 'disclosures' && (
<div onClick={e => e.stopPropagation()}>
<div className="mb-4 flex items-center justify-between">
<h4 className="text-lg font-semibold text-slate-900">최근 1 공시 내역</h4>
<span className="text-sm text-slate-500">기업코드: {company.corp_code}</span>
</div>
{loadingDisclosures ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="ml-3 text-slate-600">공시 목록을 불러오는 ...</span>
</div>
) : disclosures.length === 0 ? (
<div className="text-center py-12 text-slate-400">
<i data-lucide="file-x" className="w-12 h-12 mx-auto mb-2 opacity-50"></i>
<p>공시 내역이 없습니다.</p>
</div>
) : (
<div className="space-y-2" onClick={e => e.stopPropagation()}>
{disclosures.slice(0, 50).map((item, idx) => (
<div
key={idx}
className="p-4 border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors"
onClick={e => e.stopPropagation()}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs px-2 py-1 bg-blue-100 text-blue-700 rounded">{item.report_nm || '공시'}</span>
<span className="text-xs text-slate-500">{item.rcept_dt}</span>
</div>
<div className="font-medium text-slate-900">{item.corp_name}</div>
{item.flr_nm && (
<div className="text-sm text-slate-600 mt-1">{item.flr_nm}</div>
)}
</div>
{item.rcept_no && (
<a
href={`https://dart.fss.or.kr/dsaf001/main.do?rcpNo=${item.rcept_no}`}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
className="text-blue-600 hover:text-blue-800 text-sm flex items-center gap-1"
>
<i data-lucide="external-link" className="w-4 h-4"></i>
보기
</a>
)}
</div>
</div>
))}
</div>
)}
</div>
)}
{/* 재무제표 탭 */}
{activeTab === 'financial' && (
<div onClick={e => e.stopPropagation()}>
<div className="mb-4 flex items-center justify-between">
<h4 className="text-lg font-semibold text-slate-900">재무제표 정보</h4>
<span className="text-sm text-slate-500">연결재무제표 기준</span>
</div>
{loadingFinancial ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="ml-3 text-slate-600">재무제표를 불러오는 ...</span>
</div>
) : !financialData || financialData.length === 0 ? (
<div className="text-center py-12 text-slate-400">
<i data-lucide="trending-up" className="w-12 h-12 mx-auto mb-2 opacity-50"></i>
<p>재무제표 정보가 없습니다.</p>
<p className="text-xs mt-2 text-slate-400">브라우저 콘솔(F12)에서 상세 오류를 확인하세요.</p>
</div>
) : (
<div className="overflow-x-auto" onClick={e => e.stopPropagation()}>
<table className="w-full text-sm">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-3 text-left font-semibold text-slate-700">항목명</th>
<th className="px-4 py-3 text-right font-semibold text-slate-700">금액</th>
<th className="px-4 py-3 text-left font-semibold text-slate-700">단위</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{financialData.map((item, idx) => (
<tr key={idx} className="hover:bg-slate-50">
<td className="px-4 py-3 text-slate-900">{item.account_nm}</td>
<td className="px-4 py-3 text-right font-medium text-slate-900">
{item.thstrm_amount ? parseInt(item.thstrm_amount).toLocaleString() : '-'}
</td>
<td className="px-4 py-3 text-slate-500">{item.account_id}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
<div className="p-6 border-t border-slate-100 bg-slate-50 flex justify-end shrink-0">
<button onClick={onClose} className="px-4 py-2 bg-white border border-slate-200 rounded-lg text-slate-700 hover:bg-slate-50 font-medium transition-colors">
닫기
</button>
</div>
</div>
</div>
);
};
// 경쟁사 비교 컴포넌트
const CompareView = ({ companies, onBack, onAddCompany, onRemoveCompany, onLoadFinancial }) => {
const [financialData, setFinancialData] = useState({});
const [loading, setLoading] = useState(false);
const chartRefs = {};
useEffect(() => {
lucide.createIcons();
}, [companies]);
const loadAllFinancials = async () => {
if (companies.length === 0) return;
setLoading(true);
const data = {};
const currentYear = new Date().getFullYear();
for (const company of companies) {
try {
const response = await fetch(`api/financial_statement.php?corp_code=${company.corp_code}&bsns_year=${currentYear}&reprt_code=11011&fs_div=CFS`);
const result = await response.json();
if (result.status === '000' && result.list) {
data[company.corp_code] = result.list;
}
} catch (error) {
console.error(`Error loading financial for ${company.corp_name}:`, error);
}
}
setFinancialData(data);
setLoading(false);
// 차트 렌더링
setTimeout(() => renderCharts(data), 100);
};
const renderCharts = (data) => {
// 매출액 비교 차트
const revenueData = companies.map(comp => {
const financials = data[comp.corp_code] || [];
const revenue = financials.find(f => f.account_nm && f.account_nm.includes('매출액'));
return revenue ? parseInt(revenue.thstrm_amount || 0) : 0;
});
if (revenueChartRef.current) {
// 기존 차트 인스턴스 제거
if (chartInstanceRef.current) {
chartInstanceRef.current.destroy();
}
chartInstanceRef.current = new Chart(revenueChartRef.current, {
type: 'bar',
data: {
labels: companies.map(c => c.corp_name),
datasets: [{
label: '매출액 (원)',
data: revenueData,
backgroundColor: 'rgba(37, 99, 235, 0.6)',
borderColor: 'rgba(37, 99, 235, 1)',
borderWidth: 1
}]
},
options: {
responsive: true,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function(context) {
return '매출액: ' + context.parsed.y.toLocaleString() + '원';
}
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: function(value) {
return (value / 1000000000).toFixed(1) + '억';
}
}
}
}
}
});
}
};
useEffect(() => {
if (companies.length > 0) {
loadAllFinancials();
}
}, [companies.length]);
return (
<div className="space-y-6">
<div className="bg-white rounded-card p-6 shadow-sm border border-slate-100">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-slate-900">경쟁사 비교</h3>
<button
onClick={onBack}
className="text-sm text-slate-500 hover:text-slate-700 flex items-center gap-1"
>
<i data-lucide="arrow-left" className="w-4 h-4"></i>
돌아가기
</button>
</div>
<div className="mb-4">
<p className="text-sm text-slate-600 mb-3">비교할 기업을 추가하세요 (최대 5)</p>
<div className="flex flex-wrap gap-2">
{companies.map((comp, idx) => (
<div key={idx} className="flex items-center gap-2 px-3 py-2 bg-blue-50 border border-blue-200 rounded-lg">
<span className="text-sm font-medium text-blue-900">{comp.corp_name}</span>
<button
onClick={() => onRemoveCompany(idx)}
className="text-blue-600 hover:text-blue-800"
>
<i data-lucide="x" className="w-4 h-4"></i>
</button>
</div>
))}
{companies.length < 5 && (
<button
onClick={onAddCompany}
className="px-3 py-2 border-2 border-dashed border-slate-300 rounded-lg text-slate-500 hover:border-blue-400 hover:text-blue-600 text-sm flex items-center gap-1"
>
<i data-lucide="plus" className="w-4 h-4"></i>
기업 추가
</button>
)}
</div>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="ml-3 text-slate-600">재무제표를 불러오는 ...</span>
</div>
) : companies.length > 0 ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 매출액 비교 차트 */}
<div className="bg-white rounded-card p-6 shadow-sm border border-slate-100">
<h4 className="text-md font-semibold text-slate-900 mb-4">매출액 비교</h4>
<canvas ref={revenueChartRef} height="200"></canvas>
</div>
{/* 재무제표 요약 테이블 */}
<div className="bg-white rounded-card p-6 shadow-sm border border-slate-100">
<h4 className="text-md font-semibold text-slate-900 mb-4">주요 재무 지표</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50">
<tr>
<th className="px-3 py-2 text-left text-xs font-semibold text-slate-700">기업명</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-700">매출액</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-700">영업이익</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{companies.map((comp, idx) => {
const financials = financialData[comp.corp_code] || [];
const revenue = financials.find(f => f.account_nm && f.account_nm.includes('매출액'));
const operating = financials.find(f => f.account_nm && f.account_nm.includes('영업이익'));
return (
<tr key={idx}>
<td className="px-3 py-2 font-medium text-slate-900">{comp.corp_name}</td>
<td className="px-3 py-2 text-right text-slate-700">
{revenue ? (parseInt(revenue.thstrm_amount || 0) / 1000000000).toFixed(1) + '억' : '-'}
</td>
<td className="px-3 py-2 text-right text-slate-700">
{operating ? (parseInt(operating.thstrm_amount || 0) / 1000000000).toFixed(1) + '억' : '-'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
) : (
<div className="bg-white rounded-card p-12 text-center">
<i data-lucide="bar-chart-2" className="w-16 h-16 mx-auto mb-4 text-slate-300"></i>
<p className="text-slate-500">비교할 기업을 추가해주세요.</p>
</div>
)}
</div>
);
};
// 공시 모니터링 컴포넌트
const MonitorView = ({ monitoredCompanies, onBack, onAddCompany, onRemoveCompany, notifications }) => {
useEffect(() => {
lucide.createIcons();
}, [monitoredCompanies, notifications]);
return (
<div className="space-y-6">
<div className="bg-white rounded-card p-6 shadow-sm border border-slate-100">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-bold text-slate-900">공시 모니터링</h3>
<p className="text-sm text-slate-500 mt-1">관심 기업의 공시를 실시간으로 모니터링합니다.</p>
</div>
<button
onClick={onBack}
className="text-sm text-slate-500 hover:text-slate-700 flex items-center gap-1"
>
<i data-lucide="arrow-left" className="w-4 h-4"></i>
돌아가기
</button>
</div>
<div className="mb-4">
<p className="text-sm text-slate-600 mb-3">모니터링할 기업 목록</p>
<div className="flex flex-wrap gap-2">
{monitoredCompanies.map((comp, idx) => (
<div key={idx} className="flex items-center gap-2 px-3 py-2 bg-green-50 border border-green-200 rounded-lg">
<i data-lucide="bell" className="w-4 h-4 text-green-600"></i>
<span className="text-sm font-medium text-green-900">{comp.corp_name}</span>
<button
onClick={() => onRemoveCompany(idx)}
className="text-green-600 hover:text-green-800"
>
<i data-lucide="x" className="w-4 h-4"></i>
</button>
</div>
))}
<button
onClick={onAddCompany}
className="px-3 py-2 border-2 border-dashed border-slate-300 rounded-lg text-slate-500 hover:border-green-400 hover:text-green-600 text-sm flex items-center gap-1"
>
<i data-lucide="plus" className="w-4 h-4"></i>
기업 추가
</button>
</div>
</div>
</div>
{/* 공시 알림 목록 */}
<div className="bg-white rounded-card p-6 shadow-sm border border-slate-100">
<h4 className="text-md font-semibold text-slate-900 mb-4 flex items-center gap-2">
<i data-lucide="bell" className="w-5 h-5 text-green-600"></i>
최근 공시 알림
</h4>
{notifications.length === 0 ? (
<div className="text-center py-8 text-slate-400">
<i data-lucide="bell-off" className="w-12 h-12 mx-auto mb-2 opacity-50"></i>
<p>새로운 공시 알림이 없습니다.</p>
</div>
) : (
<div className="space-y-2">
{notifications.map((notif, idx) => (
<div key={idx} className="p-4 border border-green-200 bg-green-50 rounded-lg">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs px-2 py-1 bg-green-600 text-white rounded">{notif.report_nm || '공시'}</span>
<span className="text-xs text-slate-600">{notif.rcept_dt}</span>
<span className="text-xs font-medium text-green-700">{notif.corp_name}</span>
</div>
{notif.flr_nm && (
<div className="text-sm text-slate-700 mt-1">{notif.flr_nm}</div>
)}
</div>
{notif.rcept_no && (
<a
href={`https://dart.fss.or.kr/dsaf001/main.do?rcpNo=${notif.rcept_no}`}
target="_blank"
className="text-green-600 hover:text-green-800 text-sm flex items-center gap-1"
>
<i data-lucide="external-link" className="w-4 h-4"></i>
보기
</a>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
const SyncButton = ({ onSync, isSyncing }) => (
<div className="mb-6 flex justify-end">
<button
onClick={onSync}
disabled={isSyncing}
className="flex items-center gap-2 px-4 py-2 bg-slate-800 text-white rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-50"
>
{isSyncing ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
데이터 동기화 ( 1 소요)...
</>
) : (
<>
<span className="flex items-center justify-center"><i data-lucide="refresh-cw" className="w-4 h-4"></i></span>
기업코드 데이터 동기화
</>
)}
</button>
</div>
);
const App = () => {
const [companies, setCompanies] = useState([]);
const [selectedCompany, setSelectedCompany] = useState(null);
const [isSearching, setIsSearching] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const [loadingDetail, setLoadingDetail] = useState(false);
const [viewMode, setViewMode] = useState('search'); // 'search', 'dashboard', 'compare', 'monitor'
const [dashboardCompany, setDashboardCompany] = useState(null);
const [compareCompanies, setCompareCompanies] = useState([]); // 경쟁사 비교용
const [monitoredCompanies, setMonitoredCompanies] = useState([]); // 모니터링 기업 목록
const [monitorNotifications, setMonitorNotifications] = useState([]); // 공시 알림
useEffect(() => {
lucide.createIcons();
}, []);
// 공시 모니터링 체크 함수
const checkNewDisclosures = async (company) => {
try {
const today = new Date();
const oneWeekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
const bgn_de = oneWeekAgo.toISOString().split('T')[0].replace(/-/g, '');
const end_de = today.toISOString().split('T')[0].replace(/-/g, '');
const response = await fetch(`api/list_disclosures.php?corp_code=${company.corp_code}&bgn_de=${bgn_de}&end_de=${end_de}`);
const data = await response.json();
if (data.status === '000' && data.list) {
const newDisclosures = data.list.slice(0, 10); // 최근 10개
setMonitorNotifications(prev => {
const existing = prev.map(n => n.rcept_no);
const filtered = newDisclosures.filter(d => !existing.includes(d.rcept_no));
return [...filtered, ...prev].slice(0, 50); // 최대 50개 유지
});
}
} catch (error) {
console.error('Disclosure check error:', error);
}
};
// 모니터링된 기업들의 공시 주기적 체크 (5분마다)
useEffect(() => {
if (monitoredCompanies.length === 0) return;
const interval = setInterval(() => {
monitoredCompanies.forEach(company => {
checkNewDisclosures(company);
});
}, 5 * 60 * 1000); // 5분
// 초기 체크
monitoredCompanies.forEach(company => {
checkNewDisclosures(company);
});
return () => clearInterval(interval);
}, [monitoredCompanies.length]);
const handleSearch = async (query) => {
setIsSearching(true);
try {
const response = await fetch(`api/search.php?query=${encodeURIComponent(query)}`);
const data = await response.json();
if (data.status === 'success') {
setCompanies(data.results);
} else {
alert(data.message);
setCompanies([]);
}
} catch (error) {
console.error('Search error:', error);
alert('검색 중 오류가 발생했습니다.');
} finally {
setIsSearching(false);
}
};
const handleSync = async () => {
if (!confirm('기업코드 데이터를 동기화하시겠습니까? (대용량 데이터 처리로 인해 시간이 소요될 수 있습니다)')) return;
setIsSyncing(true);
try {
const response = await fetch('api/sync_corpcode.php');
const data = await response.json();
if (data.status === 'success') {
alert('데이터 동기화가 완료되었습니다. (' + data.count + '건)');
} else {
// IP 접근 오류인 경우 더 자세한 메시지 표시
if (data.error_type === 'ip_access_denied') {
const message = data.message.replace(/\n/g, '\n');
alert('동기화 실패: ' + message);
// IP 정보를 콘솔에도 출력
if (data.actual_ip && data.reported_ip) {
console.log('IP 불일치 감지:');
console.log('- Open DART에 등록된 IP: ' + data.reported_ip);
console.log('- 현재 서버의 공인 IP: ' + data.actual_ip);
}
} else {
alert('동기화 실패: ' + data.message);
}
}
} catch (error) {
console.error('Sync error:', error);
alert('데이터 동기화 중 오류가 발생했습니다.');
} finally {
setIsSyncing(false);
}
};
const handleViewDetail = async (corpCode) => {
setLoadingDetail(true);
try {
const response = await fetch(`api/detail.php?corp_code=${corpCode}`);
const data = await response.json();
if (data.status === '000') {
setSelectedCompany(data);
setDashboardCompany(data);
setViewMode('dashboard');
} else {
alert('상세 정보를 불러오는데 실패했습니다: ' + (data.message || data.status));
}
} catch (error) {
console.error('Detail error:', error);
alert('상세 정보를 불러오는 중 오류가 발생했습니다.');
} finally {
setLoadingDetail(false);
}
};
return (
<div className="min-h-screen bg-slate-50 font-sans">
<Header />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{viewMode === 'search' ? (
<>
<div className="flex justify-between items-center mb-6">
<div>
<h2 className="text-2xl font-bold text-slate-900">기업 정보 검색</h2>
<p className="text-slate-500 mt-1">기업명으로 검색하여 사업자등록번호 상세 정보를 조회할 있습니다.</p>
</div>
<SyncButton onSync={handleSync} isSyncing={isSyncing} />
</div>
{/* 기능 메뉴 카드 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<button
onClick={() => setViewMode('compare')}
className="p-6 bg-white rounded-card shadow-sm border border-slate-100 hover:border-blue-300 hover:shadow-md transition-all text-left group"
>
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center group-hover:bg-blue-200 transition-colors">
<i data-lucide="bar-chart-2" className="w-6 h-6 text-blue-600"></i>
</div>
<h3 className="text-lg font-semibold text-slate-900">경쟁사 비교</h3>
</div>
<p className="text-sm text-slate-600">여러 기업의 재무제표를 비교하고 차트로 시각화합니다.</p>
</button>
<button
onClick={() => setViewMode('monitor')}
className="p-6 bg-white rounded-card shadow-sm border border-slate-100 hover:border-green-300 hover:shadow-md transition-all text-left group"
>
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center group-hover:bg-green-200 transition-colors">
<i data-lucide="bell" className="w-6 h-6 text-green-600"></i>
</div>
<h3 className="text-lg font-semibold text-slate-900">공시 모니터링</h3>
</div>
<p className="text-sm text-slate-600">관심 기업의 공시를 실시간으로 모니터링하고 알림을 받습니다.</p>
</button>
</div>
<SearchBar onSearch={handleSearch} isSearching={isSearching} />
<CompanyList companies={companies} onViewDetail={handleViewDetail} />
</>
) : viewMode === 'compare' ? (
<CompareView
companies={compareCompanies}
onBack={() => setViewMode('search')}
onAddCompany={async () => {
const query = prompt('비교할 기업명을 입력하세요:');
if (!query) return;
try {
const response = await fetch(`api/search.php?query=${encodeURIComponent(query)}`);
const data = await response.json();
if (data.status === 'success' && data.results.length > 0) {
const selected = data.results[0];
if (compareCompanies.length < 5) {
setCompareCompanies([...compareCompanies, selected]);
} else {
alert('최대 5개까지만 비교할 수 있습니다.');
}
} else {
alert('기업을 찾을 수 없습니다.');
}
} catch (error) {
alert('기업 검색 중 오류가 발생했습니다.');
}
}}
onRemoveCompany={(idx) => {
setCompareCompanies(compareCompanies.filter((_, i) => i !== idx));
}}
/>
) : viewMode === 'monitor' ? (
<MonitorView
monitoredCompanies={monitoredCompanies}
onBack={() => setViewMode('search')}
onAddCompany={async () => {
const query = prompt('모니터링할 기업명을 입력하세요:');
if (!query) return;
try {
const response = await fetch(`api/search.php?query=${encodeURIComponent(query)}`);
const data = await response.json();
if (data.status === 'success' && data.results.length > 0) {
const selected = data.results[0];
if (!monitoredCompanies.find(c => c.corp_code === selected.corp_code)) {
setMonitoredCompanies([...monitoredCompanies, selected]);
// 모니터링 시작
checkNewDisclosures(selected);
} else {
alert('이미 모니터링 중인 기업입니다.');
}
} else {
alert('기업을 찾을 수 없습니다.');
}
} catch (error) {
alert('기업 검색 중 오류가 발생했습니다.');
}
}}
onRemoveCompany={(idx) => {
setMonitoredCompanies(monitoredCompanies.filter((_, i) => i !== idx));
}}
notifications={monitorNotifications}
/>
) : (
<div>
<div className="flex justify-between items-center mb-6">
<div>
<button
onClick={() => { setViewMode('search'); setDashboardCompany(null); }}
className="text-blue-600 hover:text-blue-800 flex items-center gap-2 mb-2"
>
<i data-lucide="arrow-left" className="w-4 h-4"></i>
검색으로 돌아가기
</button>
<h2 className="text-2xl font-bold text-slate-900">
{dashboardCompany?.corp_name || '기업 정보 대시보드'}
</h2>
<p className="text-slate-500 mt-1">기업개황, 공시내역, 재무제표를 한눈에 확인하세요.</p>
</div>
</div>
{dashboardCompany && (
<CompanyDetailModal
company={dashboardCompany}
onClose={() => { setViewMode('search'); setDashboardCompany(null); }}
/>
)}
</div>
)}
</main>
{selectedCompany && (
<CompanyDetailModal
company={selectedCompany}
onClose={() => setSelectedCompany(null)}
/>
)}
{loadingDetail && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/20 backdrop-blur-sm">
<div className="bg-white p-4 rounded-xl shadow-lg flex items-center gap-3">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
<span className="font-medium text-slate-700">상세 정보 불러오는 ...</span>
</div>
</div>
)}
</div>
);
};
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
</script>
</body>
</html>