- URL 하드코딩 → .env APP_URL 기반 동적 URL로 변경 - DB 연결 하드코딩 → .env 기반으로 변경 - MySQL strict mode DATE 오류 수정
1089 lines
62 KiB
PHP
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>
|