<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>나라장터 정보통신공사 입찰/낙찰 정보 분석 시스템</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.9.1/chart.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/PapaParse/5.3.2/papaparse.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js"></script>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: 'Malgun Gothic', sans-serif;
}
body {
background-color: #f5f5f5;
color: #333;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header {
background-color: #1e3a8a;
color: white;
padding: 20px 0;
margin-bottom: 30px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
header h1 {
text-align: center;
font-size: 28px;
}
.company-info {
text-align: center;
margin-bottom: 10px;
font-size: 18px;
color: #f0f0f0;
}
.search-section, .results-section, .message-section, .login-section {
background-color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 30px;
}
.search-title {
font-size: 20px;
margin-bottom: 15px;
color: #1e3a8a;
border-bottom: 2px solid #1e3a8a;
padding-bottom: 10px;
}
.search-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.form-group input, .form-group select, .form-group textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.btn {
background-color: #1e3a8a;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.3s;
}
.btn:hover {
background-color: #1e40af;
}
.search-buttons {
display: flex;
justify-content: center;
gap: 15px;
margin-top: 20px;
grid-column: 1 / -1; /* Span across all columns in grid */
}
.results-title {
font-size: 20px;
margin-bottom: 15px;
color: #1e3a8a;
border-bottom: 2px solid #1e3a8a;
padding-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.export-btn {
background-color: #10b981;
font-size: 14px;
padding: 6px 12px;
}
.table-container {
overflow-x: auto;
margin-bottom: 20px;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
th, td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid #ddd;
font-size: 14px;
}
th {
background-color: #f2f2f2;
font-weight: bold;
}
tr:hover {
background-color: #f5f5f5;
}
.clickable-row {
cursor: pointer;
}
.chart-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); /* Adjusted minmax for smaller screens */
gap: 20px;
margin-bottom: 30px;
}
.chart-box {
background-color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.chart-title {
font-size: 18px;
margin-bottom: 15px;
color: #1e3a8a;
text-align: center;
}
.message-section h3 {
margin-bottom: 10px;
color: #1e3a8a;
}
.message-form {
display: flex;
flex-direction: column;
gap: 15px;
}
.message-template {
background-color: #f9f9f9;
padding: 15px;
border-radius: 4px;
border-left: 4px solid #1e3a8a;
}
.message-template p {
margin-bottom: 5px;
font-size: 14px;
}
.message-template p:last-child {
margin-bottom: 0;
}
.message-actions {
display: flex;
justify-content: flex-end; /* Align buttons to the right */
gap: 15px;
margin-top: 20px;
}
.message-actions .btn {
padding: 8px 15px;
font-size: 15px;
}
textarea {
width: 100%;
height: 150px;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
resize: vertical;
font-size: 14px;
}
.selected-companies {
margin-top: 20px;
border-top: 1px solid #eee;
padding-top: 15px;
}
.company-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 10px;
min-height: 30px; /* Ensure space even when empty */
}
.company-tag {
background-color: #e0e7ff;
color: #1e3a8a;
padding: 5px 10px;
border-radius: 15px; /* Pill shape */
display: flex;
align-items: center;
gap: 5px;
font-size: 14px;
}
.remove-tag {
cursor: pointer;
color: #4338ca;
font-weight: bold;
font-size: 12px;
margin-left: 5px;
}
.loading {
display: none;
text-align: center;
padding: 20px;
}
.loading-spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #1e3a8a;
border-radius: 50%;
width: 30px;
height: 30px;
animation: spin 1s linear infinite;
margin: 0 auto 10px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.tab-container {
margin-bottom: 20px;
}
.tabs {
display: flex;
list-style: none;
margin-bottom: 0;
border-bottom: 2px solid #1e3a8a;
}
.tab {
padding: 10px 20px;
cursor: pointer;
background-color: #f0f0f0;
border-radius: 5px 5px 0 0;
margin-right: 5px;
transition: background-color 0.3s;
}
.tab:hover {
background-color: #e0e0e0;
}
.tab.active {
background-color: #1e3a8a;
color: white;
}
.tab-content {
display: none;
padding: 20px;
background-color: white;
border-radius: 0 0 5px 5px;
}
.tab-content.active {
display: block;
}
.pagination {
display: flex;
justify-content: center;
margin-top: 20px;
gap: 10px;
}
.pagination button {
padding: 5px 10px;
background-color: #f0f0f0;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
}
.pagination button:hover:not(.active) {
background-color: #e0e0e0;
}
.pagination button.active {
background-color: #1e3a8a;
color: white;
font-weight: bold;
}
.filter-tags {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 15px;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
}
.filter-tag {
background-color: #e0e7ff;
color: #1e3a8a;
padding: 5px 10px;
border-radius: 15px; /* Pill shape */
display: flex;
align-items: center;
gap: 5px;
font-size: 14px;
}
.filter-tag .remove-tag {
color: #4338ca;
}
.alert {
padding: 10px 15px;
border-radius: 4px;
margin-bottom: 15px;
font-size: 14px;
}
.alert-success {
background-color: #d1fae5;
color: #065f46;
border: 1px solid #6ee7b7;
}
.alert-error {
background-color: #fee2e2;
color: #b91c1c;
border: 1px solid #fca5a5;
}
.alert-info {
background-color: #e0f2fe;
color: #0369a1;
border: 1px solid #7dd3fc;
}
.checkbox-group {
display: flex;
align-items: center;
gap: 10px;
margin-top: 5px; /* Less margin than form-group */
font-size: 14px;
}
.checkbox-group input {
width: auto;
margin: 0; /* Reset default input margin */
}
.login-section {
text-align: center;
margin-bottom: 30px;
padding: 40px 20px; /* More padding for login box */
}
.login-form {
max-width: 400px;
margin: 0 auto;
background-color: white;
padding: 30px; /* More padding */
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.login-form .form-group {
margin-bottom: 20px; /* More space in login form */
}
.company-details {
margin-top: 20px;
padding: 20px; /* More padding */
background-color: #f0f7ff;
border-radius: 5px;
border-left: 4px solid #1e3a8a;
font-size: 14px;
}
.company-details h3 {
margin-bottom: 10px;
color: #1e3a8a;
}
.company-details p {
margin-bottom: 10px;
}
.contact-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); /* Adjusted for contact items */
gap: 15px;
margin-top: 15px;
}
.contact-item {
display: flex;
align-items: center;
gap: 10px;
}
.contact-icon {
font-size: 20px;
color: #1e3a8a;
}
#bidDetail {
min-height: 100px; /* Ensure detail area has some height */
}
#bidDetail p {
margin-bottom: 8px;
}
#bidDetail strong {
color: #1e3a8a;
}
</style>
</head>
<body>
<div class="container">
<header>
<div class="company-info">주식회사 진주정보통신</div>
<h1>나라장터 정보통신공사 입찰/낙찰 정보 분석 시스템</h1>
</header>
<div class="login-section" id="loginSection">
<div class="login-form">
<h2 class="search-title">나라장터 API 인증</h2>
<p class="alert alert-info">나라장터 OpenAPI 서비스 키를 입력해주세요. (데모 버전에서는 어떤 키든 입력 시 시스템이 활성화됩니다.)</p>
<div class="form-group">
<label for="serviceKey">서비스 키</label>
<input type="password" id="serviceKey" placeholder="나라장터 API 서비스 키를 입력하세요">
</div>
<button class="btn" id="loginBtn">인증하기</button>
</div>
</div>
<div id="mainContent" style="display: none;">
<div class="search-section">
<h2 class="search-title">검색 조건</h2>
<div class="search-form">
<div class="form-group">
<label for="bidType">입찰 유형</label>
<select id="bidType">
<option value="bid" selected>입찰공고</option>
<option value="award">낙찰공고</option>
<option value="all">전체 (데모에서는 입찰/낙찰 구분 없이 생성)</option>
</select>
</div>
<div class="form-group">
<label for="startDate">시작일</label>
<input type="date" id="startDate">
</div>
<div class="form-group">
<label for="endDate">종료일</label>
<input type="date" id="endDate">
</div>
<div class="form-group">
<label for="region">지역</label>
<select id="region">
<option value="all">전체</option>
<option value="서울">서울</option>
<option value="경기">경기</option>
<option value="인천">인천</option>
<option value="부산">부산</option>
<option value="대구">대구</option>
<option value="광주">광주</option>
<option value="대전">대전</option>
<option value="울산">울산</option>
<option value="세종">세종</option>
<option value="강원">강원</option>
<option value="충북">충북</option>
<option value="충남">충남</option>
<option value="전북">전북</option>
<option value="전남">전남</option>
<option value="경북">경북</option>
<option value="경남">경남</option>
<option value="제주">제주</option>
</select>
</div>
<div class="form-group">
<label for="minAmount">최소 금액 (만원)</label>
<input type="number" id="minAmount" placeholder="최소 금액">
</div>
<div class="form-group">
<label for="maxAmount">최대 금액 (만원)</label>
<input type="number" id="maxAmount" placeholder="최대 금액">
</div>
<div class="form-group" style="grid-column: 1 / -1;">
<label for="keyword">검색 키워드</label>
<input type="text" id="keyword" placeholder="예: 정보통신공사, 통신망 구축 (쉼표로 구분)">
<div class="checkbox-group">
<input type="checkbox" id="keywordAnd" checked>
<label for="keywordAnd">모든 키워드 포함 (AND 검색)</label>
</div>
</div>
</div>
<div class="search-buttons">
<button class="btn" id="searchBtn">검색하기</button>
<button class="btn" id="resetBtn" style="background-color: #6b7280;">초기화</button>
</div>
</div>
<div class="loading" id="loadingIndicator">
<div class="loading-spinner"></div>
<p>데이터를 불러오는 중입니다...</p>
</div>
<div class="results-section" id="resultsSection" style="display: none;">
<div class="results-title">
<h2>검색 결과 (<span id="resultCount">0</span>건)</h2>
<button class="btn export-btn" id="exportExcelBtn">Excel 다운로드</button>
</div>
<div class="filter-tags" id="filterTags">
</div>
<div class="tab-container">
<ul class="tabs">
<li class="tab active" data-tab="tab-table">표 보기</li>
<li class="tab" data-tab="tab-chart">차트 보기</li>
<li class="tab" data-tab="tab-detail">상세 정보</li>
</ul>
<div id="tab-table" class="tab-content active">
<div class="table-container">
<table id="resultsTable">
<thead>
<tr>
<th><input type="checkbox" id="selectAll"></th>
<th>번호</th>
<th>공고일</th>
<th>공고명</th>
<th>발주처</th>
<th>지역</th>
<th>예가/낙찰금액(만원)</th>
<th>마감일</th>
<th>낙찰업체</th>
<th>낙찰률(%)</th>
</tr>
</thead>
<tbody id="resultsBody">
</tbody>
</table>
</div>
<div class="pagination" id="pagination">
</div>
</div>
<div id="tab-chart" class="tab-content">
<div class="chart-container">
<div class="chart-box">
<div class="chart-title">지역별 공고 건수</div>
<canvas id="regionChart"></canvas>
</div>
<div class="chart-box">
<div class="chart-title">월별 공고 건수</div>
<canvas id="monthlyChart"></canvas>
</div>
<div class="chart-box">
<div class="chart-title">금액대별 분포</div>
<canvas id="amountChart"></canvas>
</div>
<div class="chart-box">
<div class="chart-title">발주처 TOP 10</div>
<canvas id="orgChart"></canvas>
</div>
</div>
</div>
<div id="tab-detail" class="tab-content">
<div class="alert alert-info" id="detailPlaceholder">상세 정보를 보려면 표에서 항목을 클릭하세요.</div>
<div id="bidDetail" style="display: none;">
</div>
</div>
</div>
</div>
<div class="message-section">
<h2 class="message-title">메시지 발송</h2>
<div class="selected-companies">
<h3>선택된 업체 (<span id="selectedCount">0</span>개)</h3>
<div class="company-list" id="selectedCompanies">
<p class="alert alert-info">업체를 선택하려면 표에서 항목을 체크하세요.</p>
</div>
</div>
<div class="message-form">
<div class="form-group">
<label for="messageType">메시지 유형</label>
<select id="messageType">
<option value="congrats">낙찰 축하 메시지</option>
<option value="promotion">회사 홍보 메시지</option>
<option value="custom">사용자 정의 메시지</option>
</select>
</div>
<div class="message-template" id="messageTemplate">
</div>
<div class="form-group">
<label for="messageContent">메시지 내용</label>
<textarea id="messageContent" placeholder="발송할 메시지 내용을 입력하세요. '{업체명}', '{공고명}'과 같은 변수는 실제 값으로 자동 치환됩니다."></textarea>
</div>
<div class="message-actions">
<button class="btn" id="applyTemplateBtn">템플릿 적용</button>
<button class="btn" id="sendMessageBtn">메시지 발송 (시뮬레이션)</button>
</div>
</div>
</div>
<div class="company-details">
<h3>주식회사 진주정보통신</h3>
<p>정보통신공사 전문 업체로, 고품질 통신 인프라 구축 서비스를 제공합니다. 다양한 규모의 프로젝트 경험과 최신 기술력을 바탕으로 고객 만족을 최우선으로 합니다.</p>
<div class="contact-info">
<div class="contact-item">
<span class="contact-icon">📞</span>
<span>전화: 055-123-4567</span>
</div>
<div class="contact-item">
<span class="contact-icon">📧</span>
<span>이메일: info@jinjucomm.co.kr</span>
</div>
<div class="contact-item">
<span class="contact-icon">🏢</span>
<span>주소: 경상남도 진주시 통신로 123</span>
</div>
<div class="contact-item">
<span class="contact-icon">🌐</span>
<span>웹사이트: www.jinjucomm.co.kr (가상)</span>
</div>
</div>
</div>
</div>
</div>
<script>
// 현재 날짜 설정 및 페이지 초기화
document.addEventListener('DOMContentLoaded', function() {
const today = new Date();
const oneMonthAgo = new Date();
oneMonthAgo.setMonth(today.getMonth() - 1);
const formatDate = (date) => {
const d = new Date(date);
let month = '' + (d.getMonth() + 1);
let day = '' + d.getDate();
const year = d.getFullYear();
if (month.length < 2) month = '0' + month;
if (day.length < 2) day = '0' + day;
return [year, month, day].join('-');
};
document.getElementById('startDate').value = formatDate(oneMonthAgo);
document.getElementById('endDate').value = formatDate(today);
// 로그인 버튼 이벤트
document.getElementById('loginBtn').addEventListener('click', function() {
const serviceKey = document.getElementById('serviceKey').value.trim();
if (!serviceKey) {
alert('서비스 키를 입력해주세요.');
return;
}
// 실제로는 API 인증을 확인해야 하지만, 데모에서는 키 입력만으로 진행
localStorage.setItem('naraServiceKey', serviceKey);
document.getElementById('loginSection').style.display = 'none';
document.getElementById('mainContent').style.display = 'block';
// 초기 메시지 템플릿 로드
updateMessageTemplate();
});
// 이미 인증 정보가 있는 경우 자동 로그인
const savedKey = localStorage.getItem('naraServiceKey');
if (savedKey) {
document.getElementById('serviceKey').value = savedKey;
document.getElementById('loginSection').style.display = 'none';
document.getElementById('mainContent').style.display = 'block';
// 초기 메시지 템플릿 로드
updateMessageTemplate();
}
// 탭 전환 이벤트
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', function() {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
this.classList.add('active');
document.getElementById(this.dataset.tab).classList.add('active');
});
});
// 검색 버튼 이벤트
document.getElementById('searchBtn').addEventListener('click', searchData);
// 초기화 버튼 이벤트
document.getElementById('resetBtn').addEventListener('click', function() {
document.getElementById('startDate').value = formatDate(oneMonthAgo);
document.getElementById('endDate').value = formatDate(today);
document.getElementById('region').value = 'all';
document.getElementById('bidType').value = 'bid'; // Reset to default bid type
document.getElementById('minAmount').value = '';
document.getElementById('maxAmount').value = '';
document.getElementById('keyword').value = '';
document.getElementById('keywordAnd').checked = true;
// 초기화 시 결과 및 차트, 선택 업체 정보도 리셋
state.data = [];
state.currentPage = 1;
state.selectedItems.clear();
displayResults(); // 빈 테이블 표시
updateFilterTags(); // 필터 태그 초기화
updateSelectedCompanies(); // 선택 업체 초기화
document.getElementById('resultsSection').style.display = 'none'; // 결과 섹션 숨김
document.getElementById('detailPlaceholder').style.display = 'block'; // 상세 정보 초기 메시지
document.getElementById('bidDetail').style.display = 'none';
if (window.regionChart) window.regionChart.destroy();
if (window.monthlyChart) window.monthlyChart.destroy();
if (window.amountChart) window.amountChart.destroy();
if (window.orgChart) window.orgChart.destroy();
});
// 엑셀 다운로드 버튼 이벤트
document.getElementById('exportExcelBtn').addEventListener('click', exportToExcel);
// 전체 선택 체크박스 이벤트 (동적으로 생성될 수 있으므로 이벤트 위임 사용)
document.getElementById('resultsTable').addEventListener('change', function(event) {
if (event.target.id === 'selectAll') {
const isChecked = event.target.checked;
const checkboxes = document.querySelectorAll('#resultsBody input[type="checkbox"]');
checkboxes.forEach(checkbox => {
checkbox.checked = isChecked;
const bidNo = checkbox.closest('tr').dataset.bidNo;
if (isChecked) {
state.selectedItems.add(bidNo);
} else {
state.selectedItems.delete(bidNo);
}
});
updateSelectedCompanies();
} else if (event.target.type === 'checkbox' && event.target.name === 'selectBid') {
const bidNo = event.target.closest('tr').dataset.bidNo;
if (event.target.checked) {
state.selectedItems.add(bidNo);
} else {
state.selectedItems.delete(bidNo);
}
updateSelectedCompanies();
// Check/uncheck selectAll based on individual checkboxes
const allCheckboxes = document.querySelectorAll('#resultsBody input[type="checkbox"][name="selectBid"]');
const allSelected = allCheckboxes.length > 0 && Array.from(allCheckboxes).every(cb => cb.checked);
document.getElementById('selectAll').checked = allSelected;
}
});
// 행 클릭 이벤트 (상세 정보 표시) - 이벤트 위임 사용
document.getElementById('resultsBody').addEventListener('click', function(event) {
const row = event.target.closest('tr');
if (row && !event.target.matches('input[type="checkbox"]')) { // Ignore clicks on checkboxes
const bidNo = row.dataset.bidNo;
showDetail(bidNo);
}
});
// 템플릿 적용 버튼 이벤트
document.getElementById('applyTemplateBtn').addEventListener('click', applyMessageTemplate);
// 메시지 유형 변경 이벤트
document.getElementById('messageType').addEventListener('change', updateMessageTemplate);
// 메시지 발송 버튼 이벤트
document.getElementById('sendMessageBtn').addEventListener('click', sendMessages);
// Filter tag remove event (using event delegation)
document.getElementById('filterTags').addEventListener('click', function(event) {
if (event.target.classList.contains('remove-tag')) {
const filterType = event.target.dataset.filterType;
const filterValue = event.target.dataset.filterValue;
removeFilterTag(filterType, filterValue);
}
});
// Selected company tag remove event (using event delegation)
document.getElementById('selectedCompanies').addEventListener('click', function(event) {
if (event.target.classList.contains('remove-tag')) {
const bidNoToRemove = event.target.dataset.bidNo;
// Uncheck the corresponding checkbox in the table (if visible)
const checkbox = document.querySelector(`#resultsBody input[type="checkbox"][data-bid-no="${bidNoToRemove}"]`);
if (checkbox) {
checkbox.checked = false;
}
// Remove from selectedItems set
state.selectedItems.delete(bidNoToRemove);
// Update the displayed list
updateSelectedCompanies();
// Update the selectAll checkbox state
const allCheckboxes = document.querySelectorAll('#resultsBody input[type="checkbox"][name="selectBid"]');
const allSelected = allCheckboxes.length > 0 && Array.from(allCheckboxes).every(cb => cb.checked);
document.getElementById('selectAll').checked = allSelected;
}
});
});
// 전역 상태 관리 객체
const state = {
originalData: [], // Store unfiltered data if needed later
filteredData: [], // Data after applying search filters
currentPage: 1,
itemsPerPage: 10,
selectedItems: new Set(), // Store bidNo of selected items
charts: {} // Store chart instances
};
// 로딩 인디케이터 표시/숨김
function showLoading(show) {
document.getElementById('loadingIndicator').style.display = show ? 'block' : 'none';
}
// 알림 메시지 표시 (예: 성공/실패)
function showAlert(message, type = 'info') {
// 간단한 alert 대신 UI 내부에 표시하는 기능 구현 가능
alert(message);
}
// 검색 함수
function searchData() {
showLoading(true);
const bidType = document.getElementById('bidType').value;
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
const region = document.getElementById('region').value;
const minAmount = document.getElementById('minAmount').value ? parseInt(document.getElementById('minAmount').value) * 10000 : null; // 만원 단위를 원 단위로 변환
const maxAmount = document.getElementById('maxAmount').value ? parseInt(document.getElementById('maxAmount').value) * 10000 : null; // 만원 단위를 원 단위로 변환
const keyword = document.getElementById('keyword').value.trim();
const keywordAnd = document.getElementById('keywordAnd').checked;
// 날짜 유효성 검사
if (new Date(startDate) > new Date(endDate)) {
showAlert('시작일은 종료일보다 늦을 수 없습니다.', 'error');
showLoading(false);
return;
}
// 실제 API 호출 대신 데모 데이터 생성 및 필터링
// generateDemoData 함수는 모든 가능한 데이터를 생성하고,
// 이 searchData 함수에서 사용자 입력에 따라 필터링합니다.
// 실제 API 사용 시에는 API 호출 파라미터에 검색 조건을 넣어 호출합니다.
const allDemoData = generateDemoData(startDate, endDate); // Generate data for the date range
let filteredData = allDemoData.filter(item => {
// Bid Type Filter
if (bidType !== 'all') {
if (bidType === 'bid' && item.result !== '입찰공고') return false;
if (bidType === 'award' && item.result === '입찰공고') return false; // 낙찰공고만 필터링
}
// Region Filter
if (region !== 'all' && item.areaNm !== region) return false;
// Amount Filter (use prefairPrice for bids, awardPrice for awards)
const amount = item.result === '입찰공고' ? item.prefairPrice : item.awardPrice;
if (minAmount !== null && amount < minAmount) return false;
if (maxAmount !== null && amount > maxAmount) return false;
// Keyword Filter (Apply to bidNm and insttNm)
if (keyword) {
const keywords = keyword.split(',').map(k => k.trim()).filter(k => k !== '');
const textToSearch = `${item.bidNm} ${item.insttNm}`; // Search in bid name and institution name
if (keywordAnd) { // AND 검색
if (!keywords.every(k => textToSearch.includes(k))) return false;
} else { // OR 검색
if (!keywords.some(k => textToSearch.includes(k))) return false;
}
}
return true;
});
// 결과 저장 및 표시
state.originalData = allDemoData; // Keep original if needed
state.filteredData = filteredData; // Filtered data for display and charts
state.currentPage = 1;
state.selectedItems.clear(); // Clear selection on new search
document.getElementById('resultCount').textContent = state.filteredData.length;
document.getElementById('resultsSection').style.display = 'block';
document.getElementById('detailPlaceholder').style.display = 'block';
document.getElementById('bidDetail').style.display = 'none';
updateFilterTags({ bidType, startDate, endDate, region, minAmount, maxAmount, keyword, keywordAnd }); // 필터 태그 업데이트
displayResults(); // 테이블 표시
createCharts(); // 차트 생성
updateSelectedCompanies(); // 선택 업체 초기화
showLoading(false);
}
// 적용된 필터 태그 표시
function updateFilterTags(filters = {}) {
const container = document.getElementById('filterTags');
container.innerHTML = ''; // Clear existing tags
const addTag = (label, value, type, dataValue) => {
if (value && value !== 'all' && value !== null) {
const tag = document.createElement('div');
tag.classList.add('filter-tag');
tag.innerHTML = `
${label}: <strong>${value}</strong>
<span class="remove-tag" data-filter-type="${type}" data-filter-value="${dataValue || value}">×</span>
`;
container.appendChild(tag);
}
};
if (filters.bidType && filters.bidType !== 'all') {
const bidTypeLabel = filters.bidType === 'bid' ? '입찰공고' : '낙찰공고';
addTag('유형', bidTypeLabel, 'bidType', filters.bidType);
}
if (filters.startDate && filters.endDate) {
addTag('기간', `${filters.startDate} ~ ${filters.endDate}`, 'dateRange', `${filters.startDate},${filters.endDate}`);
}
if (filters.region && filters.region !== 'all') {
addTag('지역', filters.region, 'region', filters.region);
}
if (filters.minAmount !== null || filters.maxAmount !== null) {
let amountRange = '';
if (filters.minAmount !== null) amountRange += `${filters.minAmount/10000}만원 이상`;
if (filters.minAmount !== null && filters.maxAmount !== null) amountRange += ' ~ ';
if (filters.maxAmount !== null) amountRange += `${filters.maxAmount/10000}만원 이하`;
addTag('금액', amountRange, 'amountRange', `${filters.minAmount || ''},${filters.maxAmount || ''}`);
}
if (filters.keyword) {
const keywordType = filters.keywordAnd ? 'AND' : 'OR';
addTag('키워드', `${filters.keyword} (${keywordType})`, 'keyword', filters.keyword);
}
if (container.innerHTML === '') {
container.innerHTML = '<p class="alert alert-info">적용된 검색 필터가 없습니다.</p>';
} else {
const infoTag = container.querySelector('.alert-info');
if (infoTag) infoTag.remove(); // Remove info message if tags are added
}
}
// 필터 태그 제거 시 검색 조건 업데이트 및 재검색
function removeFilterTag(type, value) {
// Get current search parameters
const bidType = document.getElementById('bidType');
const startDate = document.getElementById('startDate');
const endDate = document.getElementById('endDate');
const region = document.getElementById('region');
const minAmount = document.getElementById('minAmount');
const maxAmount = document.getElementById('maxAmount');
const keyword = document.getElementById('keyword');
const keywordAnd = document.getElementById('keywordAnd');
// Update the corresponding input field
if (type === 'bidType') bidType.value = 'all';
else if (type === 'dateRange') {
const dates = value.split(',');
// Reset to default last month range if removed
const today = new Date();
const oneMonthAgo = new Date();
oneMonthAgo.setMonth(today.getMonth() - 1);
const formatDate = (date) => {
const d = new Date(date);
let month = '' + (d.getMonth() + 1);
let day = '' + d.getDate();
const year = d.getFullYear();
if (month.length < 2) month = '0' + month;
if (day.length < 2) day = '0' + day;
return [year, month, day].join('-');
};
startDate.value = formatDate(oneMonthAgo);
endDate.value = formatDate(today);
}
else if (type === 'region') region.value = 'all';
else if (type === 'amountRange') {
minAmount.value = '';
maxAmount.value = '';
}
else if (type === 'keyword') {
keyword.value = '';
keywordAnd.checked = true; // Reset keyword search to AND
}
// Perform search again with updated criteria
searchData();
}
// 결과 테이블 표시 (페이징 적용)
function displayResults() {
const tbody = document.getElementById('resultsBody');
tbody.innerHTML = ''; // Clear previous results
const paginationDiv = document.getElementById('pagination');
paginationDiv.innerHTML = ''; // Clear previous pagination
const data = state.filteredData;
const itemsPerPage = state.itemsPerPage;
const currentPage = state.currentPage;
const totalPages = Math.ceil(data.length / itemsPerPage);
if (data.length === 0) {
tbody.innerHTML = '<tr><td colspan="10" style="text-align: center;">검색 결과가 없습니다.</td></tr>';
document.getElementById('resultCount').textContent = 0;
document.getElementById('exportExcelBtn').style.display = 'none'; // Hide export button
return;
}
document.getElementById('exportExcelBtn').style.display = 'inline-block'; // Show export button
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = Math.min(startIndex + itemsPerPage, data.length);
const itemsToShow = data.slice(startIndex, endIndex);
itemsToShow.forEach((item, index) => {
const row = document.createElement('tr');
row.classList.add('clickable-row');
row.dataset.bidNo = item.bidNo; // Store bidNo for detail view
row.innerHTML = `
<td><input type="checkbox" name="selectBid" data-bid-no="${item.bidNo}" ${state.selectedItems.has(item.bidNo) ? 'checked' : ''}></td>
<td>${startIndex + index + 1}</td>
<td>${item.openDt}</td>
<td>${item.bidNm}</td>
<td>${item.insttNm}</td>
<td>${item.areaNm}</td>
<td>${item.result === '입찰공고' ? (item.prefairPrice / 10000).toLocaleString() : (item.awardPrice / 10000).toLocaleString()}</td>
<td>${item.closeDt || '-'}</td>
<td>${item.result === '낙찰' && item.prtcpntList && item.prtcpntList.length > 0 ? item.prtcpntList.find(p => p.result === '낙찰')?.cmpnyNm || '-' : '-'}</td>
<td>${item.result === '낙찰' && item.prtcpntList && item.prtcpntList.length > 0 ? (item.prtcpntList.find(p => p.result === '낙찰')?.bidRate || '-').toFixed(2) : '-'}</td>
`;
tbody.appendChild(row);
});
// Pagination controls
if (totalPages > 1) {
const createButton = (page, text, isActive = false) => {
const button = document.createElement('button');
button.textContent = text;
button.disabled = (page < 1 || page > totalPages || page === currentPage);
if (isActive) button.classList.add('active');
button.addEventListener('click', () => {
state.currentPage = page;
displayResults(); // Re-render table for new page
});
return button;
};
paginationDiv.appendChild(createButton(1, '처음'));
paginationDiv.appendChild(createButton(currentPage - 1, '이전'));
const startPage = Math.max(1, currentPage - 5);
const endPage = Math.min(totalPages, currentPage + 4);
for (let i = startPage; i <= endPage; i++) {
paginationDiv.appendChild(createButton(i, i, i === currentPage));
}
paginationDiv.appendChild(createButton(currentPage + 1, '다음'));
paginationDiv.appendChild(createButton(totalPages, '마지막'));
}
// Update selectAll checkbox state based on current page's selection
const allCheckboxes = document.querySelectorAll('#resultsBody input[type="checkbox"][name="selectBid"]');
const allSelected = allCheckboxes.length > 0 && Array.from(allCheckboxes).every(cb => state.selectedItems.has(cb.dataset.bidNo));
document.getElementById('selectAll').checked = allSelected;
}
// 상세 정보 표시
function showDetail(bidNo) {
const detailDiv = document.getElementById('bidDetail');
const placeholder = document.getElementById('detailPlaceholder');
const item = state.filteredData.find(d => d.bidNo === bidNo);
if (!item) {
detailDiv.style.display = 'none';
placeholder.style.display = 'block';
detailDiv.innerHTML = '';
return;
}
placeholder.style.display = 'none';
detailDiv.style.display = 'block';
let detailContent = `
<h3>공고 상세 정보</h3>
<p><strong>공고 번호:</strong> ${item.bidNo}</p>
<p><strong>공고명:</strong> ${item.bidNm}</p>
<p><strong>발주처:</strong> ${item.insttNm}</p>
<p><strong>지역:</strong> ${item.areaNm}</p>
<p><strong>공고일:</strong> ${item.openDt}</p>
<p><strong>마감일:</strong> ${item.closeDt || '-'}</p>
<p><strong>공고 금액 (예가):</strong> ${item.prefairPrice ? (item.prefairPrice / 10000).toLocaleString() + '만원' : '-'}</p>
`;
if (item.result !== '입찰공고') { // 낙찰 또는 유찰인 경우
detailContent += `<p><strong>진행 상태:</strong> ${item.result}</p>`;
if (item.result === '낙찰') {
detailContent += `
<p><strong>낙찰 금액:</strong> ${item.awardPrice ? (item.awardPrice / 10000).toLocaleString() + '만원' : '-'}</p>
<p><strong>낙찰 업체:</strong> ${item.prtcpntList && item.prtcpntList.length > 0 ? item.prtcpntList.find(p => p.result === '낙찰')?.cmpnyNm || '-' : '-'}</p>
<p><strong>낙찰률:</strong> ${item.prtcpntList && item.prtcpntList.length > 0 ? (item.prtcpntList.find(p => p.result === '낙찰')?.bidRate || '-').toFixed(2) + '%' : '-'}</p>
`;
// 참여 업체 목록 (간단히 표시)
if (item.prtcpntList && item.prtcpntList.length > 0) {
detailContent += `<p><strong>참여 업체:</strong></p><ul>`;
item.prtcpntList.forEach(p => {
detailContent += `<li>${p.cmpnyNm} (${p.result}${p.bidRate ? ', 낙찰률: ' + p.bidRate.toFixed(2) + '%' : ''})</li>`;
});
detailContent += `</ul>`;
}
}
}
detailContent += `<p><a href="${item.linkUrl}" target="_blank">나라장터 원문 링크</a></p>`; // 원문 링크 추가
detailDiv.innerHTML = detailContent;
// Activate the detail tab
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
document.querySelector('.tab[data-tab="tab-detail"]').classList.add('active');
document.getElementById('tab-detail').classList.add('active');
}
// 차트 생성
function createCharts() {
// Destroy previous chart instances to avoid memory leaks
if (state.charts.regionChart) state.charts.regionChart.destroy();
if (state.charts.monthlyChart) state.charts.monthlyChart.destroy();
if (state.charts.amountChart) state.charts.amountChart.destroy();
if (state.charts.orgChart) state.charts.orgChart.destroy();
const data = state.filteredData;
if (data.length === 0) {
// Clear canvas if no data
document.getElementById('regionChart').getContext('2d').clearRect(0, 0, document.getElementById('regionChart').width, document.getElementById('regionChart').height);
document.getElementById('monthlyChart').getContext('2d').clearRect(0, 0, document.getElementById('monthlyChart').width, document.getElementById('monthlyChart').height);
document.getElementById('amountChart').getContext('2d').clearRect(0, 0, document.getElementById('amountChart').width, document.getElementById('amountChart').height);
document.getElementById('orgChart').getContext('2d').clearRect(0, 0, document.getElementById('orgChart').width, document.getElementById('orgChart').height);
return;
}
// Data for charts
const regionData = _.countBy(data, 'areaNm');
const monthlyData = _.countBy(data, item => item.openDt.substring(0, 7)); // YYYY-MM format
const orgData = _.countBy(data, 'insttNm');
// Sort monthly data by date
const sortedMonthlyKeys = _.sortBy(_.keys(monthlyData));
const sortedMonthlyValues = sortedMonthlyKeys.map(key => monthlyData[key]);
// Sort organization data and get top 10
const sortedOrg = _.orderBy(_.toPairs(orgData), [1], ['desc']).slice(0, 10);
const orgLabels = sortedOrg.map(pair => pair[0]);
const orgValues = sortedOrg.map(pair => pair[1]);
// Amount ranges (example ranges, adjust as needed)
const amountRanges = {
'~ 1억 미만': [0, 100000000],
'1억 ~ 5억 미만': [100000000, 500000000],
'5억 ~ 10억 미만': [500000000, 1000000000],
'10억 이상': [1000000000, Infinity]
};
const amountCounts = _.countBy(data, item => {
const amount = item.result === '입찰공고' ? item.prefairPrice : item.awardPrice; // Use relevant amount
for (const range in amountRanges) {
if (amount >= amountRanges[range][0] && amount < amountRanges[range][1]) {
return range;
}
}
return '기타'; // Handle cases outside defined ranges
});
const amountLabels = _.keys(amountCounts);
const amountValues = _.values(amountCounts);
// Chart configurations
const commonOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'top' },
tooltip: { enabled: true }
}
};
// Region Chart (Bar)
const regionCtx = document.getElementById('regionChart').getContext('2d');
state.charts.regionChart = new Chart(regionCtx, {
type: 'bar',
data: {
labels: _.keys(regionData),
datasets: [{
label: '공고 건수',
data: _.values(regionData),
backgroundColor: 'rgba(75, 192, 192, 0.6)',
borderColor: 'rgba(75, 192, 192, 1)',
borderWidth: 1
}]
},
options: {
...commonOptions,
scales: { y: { beginAtZero: true } }
}
});
// Monthly Chart (Line)
const monthlyCtx = document.getElementById('monthlyChart').getContext('2d');
state.charts.monthlyChart = new Chart(monthlyCtx, {
type: 'line',
data: {
labels: sortedMonthlyKeys,
datasets: [{
label: '공고 건수',
data: sortedMonthlyValues,
borderColor: 'rgba(153, 102, 255, 1)',
backgroundColor: 'rgba(153, 102, 255, 0.2)',
fill: true,
tension: 0.1
}]
},
options: {
...commonOptions,
scales: { y: { beginAtZero: true } }
}
});
// Amount Chart (Pie or Doughnut)
const amountCtx = document.getElementById('amountChart').getContext('2d');
state.charts.amountChart = new Chart(amountCtx, {
type: 'pie', // Or 'doughnut'
data: {
labels: amountLabels,
datasets: [{
data: amountValues,
backgroundColor: [
'rgba(255, 99, 132, 0.6)',
'rgba(54, 162, 235, 0.6)',
'rgba(255, 206, 86, 0.6)',
'rgba(75, 192, 192, 0.6)',
'rgba(153, 102, 255, 0.6)',
'rgba(255, 159, 64, 0.6)'
],
borderColor: [
'rgba(255, 99, 132, 1)',
'rgba(54, 162, 235, 1)',
'rgba(255, 206, 86, 1)',
'rgba(75, 192, 192, 1)',
'rgba(153, 102, 255, 1)',
'rgba(255, 159, 64, 1)'
],
borderWidth: 1
}]
},
options: {
...commonOptions,
plugins: {
legend: { position: 'right' }, // Adjust legend position
tooltip: { enabled: true }
}
}
});
// Organization Chart (Horizontal Bar) - Top 10
const orgCtx = document.getElementById('orgChart').getContext('2d');
state.charts.orgChart = new Chart(orgCtx, {
type: 'bar',
data: {
labels: orgLabels,
datasets: [{
label: '공고 건수',
data: orgValues,
backgroundColor: 'rgba(54, 162, 235, 0.6)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 1
}]
},
options: {
...commonOptions,
indexAxis: 'y', // Horizontal bar chart
scales: { x: { beginAtZero: true } }
}
});
}
// 선택된 업체 목록 업데이트 및 표시
function updateSelectedCompanies() {
const selectedCompaniesDiv = document.getElementById('selectedCompanies');
selectedCompaniesDiv.innerHTML = ''; // Clear previous list
const selectedCountSpan = document.getElementById('selectedCount');
if (state.selectedItems.size === 0) {
selectedCompaniesDiv.innerHTML = '<p class="alert alert-info">업체를 선택하려면 표에서 항목을 체크하세요.</p>';
selectedCountSpan.textContent = 0;
return;
}
selectedCountSpan.textContent = state.selectedItems.size;
// Get the selected items' data from the filtered data
const selectedItemsData = state.filteredData.filter(item => state.selectedItems.has(item.bidNo));
// Sort selected items by company name or other criteria if needed
// selectedItemsData.sort((a, b) => a.낙찰업체.localeCompare(b.낙찰업체)); // Example sort
selectedItemsData.forEach(item => {
const companyTag = document.createElement('div');
companyTag.classList.add('company-tag');
const companyName = item.result === '낙찰' && item.prtcpntList && item.prtcpntList.length > 0 ? item.prtcpntList.find(p => p.result === '낙찰')?.cmpnyNm || '선택된 공고의 낙찰업체 정보 없음' : '선택된 공고 (낙찰업체 정보 없음)';
companyTag.innerHTML = `
${companyName}
<span class="remove-tag" data-bid-no="${item.bidNo}">×</span>
`;
selectedCompaniesDiv.appendChild(companyTag);
});
}
// 메시지 템플릿 업데이트
function updateMessageTemplate() {
const messageType = document.getElementById('messageType').value;
const templateDiv = document.getElementById('messageTemplate');
const textarea = document.getElementById('messageContent');
let templateContent = '';
const congratsTemplate = `
<p><strong>[낙찰 축하 메시지]</strong></p>
<p>안녕하세요, {업체명} 담당자님!</p>
<p>정보통신공사 입찰 건 '{공고명}'에 낙찰되신 것을 진심으로 축하드립니다.</p>
<p>앞으로도 귀사의 무궁한 발전을 기원합니다.</p>
<p>감사합니다.</p>
<p>주식회사 진주정보통신 드림</p>
`;
const promotionTemplate = `
<p><strong>[주식회사 진주정보통신 홍보]</strong></p>
<p>안녕하세요, {업체명} 담당자님!</p>
<p>저희 주식회사 진주정보통신은 정보통신공사 분야에서 다년간의 경험과 기술력을 보유하고 있습니다.</p>
<p>귀사와의 상호 협력을 통해 더 큰 시너지를 창출할 수 있기를 기대합니다.</p>
<p>프로젝트 관련 문의사항은 언제든지 연락 부탁드립니다.</p>
<p>감사합니다.</p>
<p>주식회사 진주정보통신 드림</p>
`;
const customTemplate = `
<p><strong>[사용자 정의 메시지]</strong></p>
<p>메시지 내용을 직접 입력하세요. '{업체명}', '{공고명}' 변수를 사용할 수 있습니다.</p>
`;
if (messageType === 'congrats') {
templateContent = congratsTemplate;
textarea.placeholder = "발송할 메시지 내용을 입력하세요.\n'{업체명}', '{공고명}'과 같은 변수는 실제 값으로 자동 치환됩니다.";
} else if (messageType === 'promotion') {
templateContent = promotionTemplate;
textarea.placeholder = "발송할 메시지 내용을 입력하세요.\n'{업체명}', '{공고명}'과 같은 변수는 실제 값으로 자동 치환됩니다.";
} else if (messageType === 'custom') {
templateContent = customTemplate;
textarea.placeholder = "사용자 정의 메시지 내용을 입력하세요. '{업체명}', '{공고명}' 변수를 사용할 수 있습니다.";
}
templateDiv.innerHTML = templateContent;
// Clear textarea or keep content based on preference for custom
if (messageType !== 'custom') {
textarea.value = ''; // Clear textarea when switching to a non-custom template
}
}
// 선택된 템플릿 내용을 메시지 내용 영역에 적용
function applyMessageTemplate() {
const messageType = document.getElementById('messageType').value;
const textarea = document.getElementById('messageContent');
let templateRawContent = '';
const congratsTemplate = `안녕하세요, {업체명} 담당자님!
정보통신공사 입찰 건 '{공고명}'에 낙찰되신 것을 진심으로 축하드립니다.
앞으로도 귀사의 무궁한 발전을 기원합니다.
감사합니다.
주식회사 진주정보통신 드림
`;
const promotionTemplate = `안녕하세요, {업체명} 담당자님!
저희 주식회사 진주정보통신은 정보통신공사 분야에서 다년간의 경험과 기술력을 보유하고 있습니다.
귀사와의 상호 협력을 통해 더 큰 시너지를 창출할 수 있기를 기대합니다.
프로젝트 관련 문의사항은 언제든지 연락 부탁드립니다.
감사합니다.
주식회사 진주정보통신 드림
`;
if (messageType === 'congrats') {
templateRawContent = congratsTemplate;
} else if (messageType === 'promotion') {
templateRawContent = promotionTemplate;
} else if (messageType === 'custom') {
// For custom, applying template just gives the placeholder info
textarea.value = "메시지 내용을 직접 입력하세요. '{업체명}', '{공고명}' 변수를 사용할 수 있습니다.";
return; // Don't overwrite if custom is already being edited
}
textarea.value = templateRawContent.trim();
}
// 메시지 발송 (시뮬레이션)
function sendMessages() {
if (state.selectedItems.size === 0) {
showAlert('메시지를 발송할 업체를 선택해주세요.', 'info');
return;
}
const messageContent = document.getElementById('messageContent').value.trim();
if (!messageContent) {
showAlert('발송할 메시지 내용을 입력해주세요.', 'info');
return;
}
const selectedItemsData = state.filteredData.filter(item => state.selectedItems.has(item.bidNo));
console.log('--- 메시지 발송 시뮬레이션 시작 ---');
selectedItemsData.forEach(item => {
const companyName = item.result === '낙찰' && item.prtcpntList && item.prtcpntList.length > 0 ? item.prtcpntList.find(p => p.result === '낙찰')?.cmpnyNm || '업체명 정보 없음' : '업체명 정보 없음';
const bidName = item.bidNm || '공고명 정보 없음';
let finalMessage = messageContent
.replace(/{업체명}/g, companyName)
.replace(/{공고명}/g, bidName);
console.log(`업체: ${companyName}, 공고: ${bidName}`);
console.log('발송될 메시지:');
console.log(finalMessage);
console.log('---');
});
showAlert(`${state.selectedItems.size}개 업체에게 메시지 발송 시뮬레이션이 완료되었습니다. 상세 내용은 콘솔을 확인하세요.`, 'success');
console.log('--- 메시지 발송 시뮬레이션 종료 ---');
// Optionally clear selection and message after sending
// state.selectedItems.clear();
// updateSelectedCompanies();
// document.getElementById('messageContent').value = '';
}
// Excel 파일 다운로드
function exportToExcel() {
if (state.filteredData.length === 0) {
showAlert('내보낼 데이터가 없습니다.', 'info');
return;
}
// Prepare data for export
const exportData = state.filteredData.map(item => {
const awardedCompany = item.result === '낙찰' && item.prtcpntList && item.prtcpntList.length > 0 ? item.prtcpntList.find(p => p.result === '낙찰')?.cmpnyNm || '-' : '-';
const bidRate = item.result === '낙찰' && item.prtcpntList && item.prtcpntList.length > 0 ? (item.prtcpntList.find(p => p.result === '낙찰')?.bidRate || '-').toFixed(2) : '-';
const amount = item.result === '입찰공고' ? item.prefairPrice : item.awardPrice;
return {
'공고일': item.openDt,
'공고명': item.bidNm,
'발주처': item.insttNm,
'지역': item.areaNm,
'예가/낙찰금액(만원)': amount ? (amount / 10000).toLocaleString() : '-',
'마감일': item.closeDt || '-',
'낙찰업체': awardedCompany,
'낙찰률(%)': bidRate,
'공고번호': item.bidNo, // Include bid number for reference
'진행상태': item.result,
'원문링크': item.linkUrl
// Add other relevant fields as needed
};
});
const worksheet = XLSX.utils.json_to_sheet(exportData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, '나라장터정보통신공사');
// Set column widths (optional)
const colWidths = [
{ wch: 12 }, // 공고일
{ wch: 40 }, // 공고명
{ wch: 25 }, // 발주처
{ wch: 8 }, // 지역
{ wch: 15 }, // 금액
{ wch: 12 }, // 마감일
{ wch: 20 }, // 낙찰업체
{ wch: 10 }, // 낙찰률
{ wch: 15 }, // 공고번호
{ wch: 8 }, // 진행상태
{ wch: 50 } // 원문링크
];
worksheet['!cols'] = colWidths;
// Export the file
XLSX.writeFile(workbook, '나라장터_정보통신공사_입낙찰정보.xlsx');
}
// --- 데모 데이터 생성 함수 (실제 API 호출로 대체 필요) ---
function generateDemoData(startDateStr, endDateStr) {
const data = [];
const start = new Date(startDateStr);
const end = new Date(endDateStr);
const timeDiff = end - start;
const diffDays = Math.ceil(timeDiff / (1000 * 60 * 60 * 24));
const regions = ['서울', '경기', '인천', '부산', '대구', '광주', '대전', '울산', '세종', '강원', '충북', '충남', '전북', '전남', '경북', '경남', '제주'];
const orgs = ['과학기술정보통신부', '한국지능정보사회진흥원', '한국방송통신전파진흥원', '서울특별시청', '경기도청', '부산광역시청', '국방부', '경찰청', '한국도로공사', '한국철도공사', 'OO대학교', 'XX고등학교', '△△경찰서', '□□소방서'];
const keywords = ['정보통신공사', '통신망 구축', '네트워크 공사', 'CCTV 설치', '광케이블', '구내통신', '정보화 사업', '시스템 구축'];
// Helper to get random date within range
const getRandomDate = (start, end) => {
const startDate = new Date(start);
const endDate = new Date(end);
const timeRange = endDate.getTime() - startDate.getTime();
const randomTime = startDate.getTime() + Math.random() * timeRange;
const date = new Date(randomTime);
return date.toISOString().split('T')[0]; // YYYY-MM-DD format
};
// Helper to generate random participants for awards
const generateParticipants = (prefairPrice) => {
const participants = [];
const numParticipants = Math.floor(Math.random() * 10) + 2; // 2 to 11 participants
const awardCompanyIndex = Math.floor(Math.random() * numParticipants); // Randomly select one winner
for (let i = 0; i < numParticipants; i++) {
const cmpnyNm = `가상정보통신_${String(1000 + Math.floor(Math.random() * 9000)).padStart(4, '0')}_${i+1}`;
const bidRate = 85 + Math.random() * 10; // 낙찰률 85% ~ 95% 사이
const awardPrice = prefairPrice * (bidRate / 100);
participants.push({
cmpnyNm: cmpnyNm,
result: i === awardCompanyIndex ? '낙찰' : '유찰',
bidRate: i === awardCompanyIndex ? bidRate : null,
awardPrice: i === awardCompanyIndex ? awardPrice : null
});
}
// Ensure at least one 낙찰 if it's an award notice
if (!participants.some(p => p.result === '낙찰')) {
if (participants.length > 0) {
participants[0].result = '낙찰';
participants[0].bidRate = 85 + Math.random() * 10;
participants[0].awardPrice = prefairPrice * (participants[0].bidRate / 100);
} else { // Should not happen with numParticipants logic, but fallback
participants.push({
cmpnyNm: `가상정보통신_낙찰업체_${String(1000 + Math.floor(Math.random() * 9000)).padStart(4, '0')}`,
result: '낙찰',
bidRate: 85 + Math.random() * 10,
awardPrice: prefairPrice * (85 + Math.random() * 10)/100
});
}
}
return participants;
};
// Generate a fixed number of demo data entries (e.g., 100 to 300 entries)
const numberOfEntries = 100 + Math.floor(Math.random() * 201); // Between 100 and 300
for (let i = 0; i < numberOfEntries; i++) {
const openDt = getRandomDate(start, end);
const isAward = Math.random() < 0.5; // Roughly 50% chance of being an award notice
const result = isAward ? (Math.random() < 0.8 ? '낙찰' : '유찰') : '입찰공고'; // 80% chance of 낙찰 if it's an award notice
const item = {
bidNo: `DEMO-${i + 1}-${Math.random().toString(36).substr(2, 5).toUpperCase()}`, // Unique demo ID
openDt: openDt,
closeDt: result === '입찰공고' ? getRandomDate(new Date(openDt), end) : null, // Close date only for bids
insttNm: _.sample(orgs),
areaNm: _.sample(regions),
prefairPrice: Math.floor(Math.random() * 5000000000) + 10000000, // 1천만원 ~ 50억원 (원 단위)
result: result,
awardPrice: null,
prtcpntList: null, // Participants list only for awards
linkUrl: 'https://www.g2b.go.kr' // Placeholder link
};
// Generate bid name based on keywords and type
const selectedKeywords = _.sampleSize(keywords, Math.floor(Math.random() * 3) + 1); // 1 to 3 keywords
const bidPrefix = result === '입찰공고' ? '나라장터 입찰공고' : (result === '낙찰' ? '나라장터 낙찰결과' : '나라장터 유찰공고');
item.bidNm = `${bidPrefix}: ${item.insttNm} ${item.areaNm} ${selectedKeywords.join(' 및 ')} 공사`;
// If it's an award notice, generate participants and award price
if (result !== '입찰공고') {
item.prtcpntList = generateParticipants(item.prefairPrice);
// Find the winner's award price if 낙찰
if (result === '낙찰') {
const winner = item.prtcpntList.find(p => p.result === '낙찰');
if (winner) {
item.awardPrice = winner.awardPrice;
} else {
// Fallback if generateParticipants somehow failed to make a winner
item.awardPrice = item.prefairPrice * (88 + Math.random()*2)/100; // Simulate a winning price near 88-90%
}
}
}
data.push(item);
}
console.log(`Generated ${data.length} demo entries.`);
return data;
}
// --- End of Demo Data Generation ---
// 선택된 항목의 bidNo를 관리하는 Set을 업데이트
// (displayResults 함수 내에서 체크박스 상태 변경 시 호출됨)
// 이 함수는 이제 직접 호출되지 않고, 이벤트 리스너에서 Set을 관리합니다.
// updateSelectedCompanies 함수가 Set의 상태를 읽어서 화면에 표시합니다.
</script>
</body>
</html>