반응형
SMALL

나라장터 정보통신공사 입찰/낙찰 정보 분석 시스템 

주식회사 진주정보통신을 위한 나라장터 정보통신공사 입찰 및 낙찰 정보를 효율적으로 조회하고 분석하며, 관련 업체에 메시지를 발송할 수 있는 웹 기반 시스템입니다. 제공된 HTML 구조를 기반으로, 실제 서비스 키를 이용한 API 연동(클라이언트 측 HTML 제약으로 인해 데모 데이터로 대체)과 검색 조건별 데이터 필터링, 결과 표/차트 시각화, 상세 정보 보기, 선택 업체 대상 메시지 발송 기능을 구현했습니다.

주요 기능:

  1. API 인증: 나라장터 API 서비스 키를 입력하여 시스템 사용 권한을 인증합니다. (데모 버전에서는 키 입력 시 기능 활성화)
  2. 상세 검색:
    • 입찰 유형 (입찰공고/낙찰공고/전체) 선택
    • 공고일 기준 기간 설정
    • 지역별 검색 (시/도 단위)
    • 금액 범위 설정
    • 키워드 검색 (공고명, 발주처 등에 포함되는 키워드, AND/OR 조건 선택 가능)
  3. 검색 결과 조회:
    • 검색 조건에 맞는 정보통신공사 관련 입찰/낙찰 목록 표시
    • 결과 테이블 보기 (페이징 처리 포함)
    • 적용된 필터 조건 태그 표시 및 제거 기능
  4. 데이터 분석 및 시각화:
    • 검색 결과를 바탕으로 지역별, 월별, 금액대별, 발주처별 공고 현황 차트 제공
    • 시각적인 데이터 분석을 통해 시장 트렌드 파악 용이
  5. 상세 정보:
    • 테이블에서 특정 공고 선택 시 해당 공고의 상세 정보 표시
    • (데모 버전에서는 제한적인 정보 표시)
  6. 메시지 발송:
    • 검색 결과 테이블에서 특정 업체를 선택 (체크박스)
    • 선택된 업체 목록 확인
    • 미리 정의된 메시지 템플릿 선택 또는 사용자 정의 메시지 작성
    • 선택된 업체들에게 메시지 발송 기능 (데모 버전에서는 발송 시뮬레이션)
  7. 데이터 내보내기: 검색된 전체 결과를 Excel 파일로 다운로드 기능

기술 스택:

  • 프론트엔드: HTML5, CSS3, JavaScript (ES6+)
  • 라이브러리:
    • Chart.js: 데이터 시각화 (차트)
    • PapaParse: CSV/텍스트 데이터 파싱 (예정 또는 향후 확장 고려)
    • XLSX: Excel 파일 생성 및 다운로드
    • Lodash: JavaScript 유틸리티 (데이터 처리 편의성 향상)

개선 및 추가 구현 내용:

  • 데모 데이터 로직 강화: 실제 나라장터 API 구조를 모방하여 검색 조건에 따른 데모 데이터를 더 현실적으로 생성하도록 수정했습니다. (지역, 날짜, 키워드, 금액 필터 적용)
  • 테이블 기능:
    • 체크박스를 이용한 다중 선택 및 전체 선택 기능 추가
    • 선택된 항목의 낙찰 업체 정보를 추출하여 메시지 발송 대상 목록에 반영
    • 페이징 기능 구현
    • 행 클릭 시 상세 정보 탭으로 이동 및 내용 표시 기능 추가
  • 차트 기능: 검색 결과 데이터를 분석하여 Chart.js로 다양한 차트를 생성하는 로직 추가했습니다. (지역별, 월별, 금액대별, 발주처별)
  • 필터 태그: 현재 적용된 검색 조건을 시각적으로 보여주고 쉽게 제거할 수 있도록 필터 태그 기능을 추가했습니다.
  • 메시지 발송 대상 관리: 선택된 업체 목록을 표시하고, 개별 제거 기능을 추가했습니다.
  • 메시지 템플릿: 기본적인 메시지 템플릿을 구현하고, 템플릿 내용을 메시지 내용 영역에 적용할 수 있도록 했습니다. 실제 변수 치환({업체명}, {공고명})은 발송 시뮬레이션 시 이루어집니다.
  • 로딩 인디케이터: 데이터 검색 중 로딩 상태를 사용자에게 알리는 기능을 추가했습니다.
  • UI/UX 개선: 전반적인 스타일 및 인터랙션을 개선하여 사용 편의성을 높였습니다.

실행 방법:

  1. 아래 제공된 전체 HTML 코드를 index.html과 같은 이름으로 저장합니다.
  2. 웹 브라우저(Chrome, Edge 등)로 index.html 파일을 엽니다.
  3. 초기 화면에서 나라장터 API 서비스 키를 입력하고 '인증하기' 버튼을 누릅니다. (데모에서는 아무 키나 입력해도 메인 화면으로 전환됩니다.)
  4. 검색 조건을 설정하고 '검색하기' 버튼을 눌러 결과를 확인합니다.
  5. 결과 테이블, 차트, 상세 정보를 확인하고 필요한 경우 Excel로 다운로드합니다.
  6. 메시지를 보낼 업체를 선택한 후 메시지 내용을 작성하여 발송 시뮬레이션을 진행합니다.

참고:

  • 이 코드는 클라이언트 측 HTML, CSS, JavaScript만으로 구성되어 있어 실제 나라장터 API와의 연동이나 메시지 전송 기능은 포함하고 있지 않습니다. 실제 API 연동 및 메시지 전송은 서버 측 개발이 필요합니다.
  • 제공되는 데이터는 데모 목적으로 무작위 생성된 데이터이며, 실제 나라장터 정보와 다릅니다.
  • 나라장터 API 사용 시 서비스 키 관리 및 호출 정책 준수가 필요합니다.

HTML
 
<!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="발송할 메시지 내용을 입력하세요.&#10;'{업체명}', '{공고명}'과 같은 변수는 실제 값으로 자동 치환됩니다."></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>

개선된 사항 설명:

  1. 구조 개선: 로그인 섹션과 메인 콘텐츠 섹션을 명확히 분리하고, JavaScript에서 display: none/block으로 전환하도록 했습니다.
  2. 데모 데이터 현실화: generateDemoData 함수를 수정하여 입찰/낙찰 구분, 금액, 지역, 발주처, 키워드 등을 포함한 좀 더 현실적인 가상 데이터를 생성하도록 했습니다. 낙찰 공고에는 가상 참여 업체 목록과 낙찰률도 추가했습니다.
  3. API 인증 (시뮬레이션): 서비스 키 입력 필드와 인증 버튼을 추가하고 localStorage를 사용하여 인증 상태를 유지하는 것처럼 보이게 했습니다.
  4. 검색 조건 상세화: 최소/최대 금액 필드를 추가하고, 키워드 검색 시 AND/OR 조건을 선택할 수 있도록 체크박스를 추가했습니다.
  5. 검색 로직 강화: searchData 함수에서 데모 데이터를 생성한 후, 사용자가 입력한 모든 검색 조건 (유형, 날짜, 지역, 금액, 키워드)에 따라 데이터를 필터링하도록 구현했습니다.
  6. 필터 태그 기능: 적용된 검색 조건을 보여주는 태그 영역(filterTags)을 추가하고, 태그 옆의 '×' 버튼을 클릭하면 해당 필터를 제거하고 자동으로 재검색하도록 구현했습니다. updateFilterTags 함수로 현재 필터 상태를 시각적으로 보여줍니다.
  7. 결과 테이블 기능:
    • resultsBody를 비우고, state.filteredData를 기준으로 데이터를 채웁니다.
    • 각 행에 data-bid-no 속성을 추가하여 해당 공고의 고유 번호를 저장하도록 했습니다.
    • 금액은 만원 단위로 표시하고 toLocaleString으로 천 단위 쉼표를 추가했습니다.
    • 낙찰업체와 낙찰률 컬럼을 추가했습니다 (낙찰공고인 경우에만 표시).
    • 체크박스를 추가하여 여러 항목을 선택할 수 있게 하고, '전체 선택' 체크박스와 연동됩니다.
    • 행 클릭 시 showDetail 함수가 호출되도록 이벤트 리스너를 추가했습니다.
    • 검색 결과 건수를 표시합니다.
  8. 페이징 기능: displayResults 함수 내에서 현재 페이지와 항목 수에 따라 데이터를 슬라이스하고, 페이지네이션 버튼(pagination)을 동적으로 생성하도록 구현했습니다. 버튼 클릭 시 state.currentPage를 업데이트하고 displayResults를 다시 호출합니다.
  9. 차트 기능:
    • createCharts 함수에서 state.filteredData를 분석하여 지역별, 월별, 금액대별, 발주처별 데이터를 집계합니다.
    • Chart.js를 사용하여 4가지 차트를 생성하고 각 캔버스에 표시합니다.
    • 새로운 검색 시 기존 차트 인스턴스를 파괴하고 다시 그리도록 하여 메모리 누수를 방지했습니다.
  10. 상세 정보 탭:
    • showDetail(bidNo) 함수를 추가하여 클릭된 행의 bidNo를 받아 state.filteredData에서 해당 데이터를 찾아 #bidDetail 영역에 상세 정보를 표시합니다.
    • 상세 정보를 표시할 때 상세 정보 탭이 활성화되도록 했습니다.
    • 상세 정보가 없을 때 표시되는 메시지를 추가했습니다.
    • 가상 나라장터 원문 링크를 추가했습니다.
  11. 메시지 발송 기능:
    • 체크박스 선택 상태를 state.selectedItems Set 객체로 관리합니다.
    • updateSelectedCompanies 함수는 state.selectedItems에 있는 bidNo를 기반으로 선택된 업체 목록을 표시합니다. 제거 버튼 클릭 시 Set에서 해당 bidNo를 제거하고 체크박스 상태를 업데이트합니다.
    • 메시지 유형 선택 시 템플릿 미리보기가 변경되도록 updateMessageTemplate 함수를 구현했습니다.
    • '템플릿 적용' 버튼 클릭 시 선택된 템플릿 내용이 #messageContent textarea로 복사되도록 applyMessageTemplate 함수를 구현했습니다. {업체명}, {공고명}과 같은 변수를 포함합니다.
    • '메시지 발송' 버튼 클릭 시 sendMessages 함수가 호출됩니다. 이 함수는 선택된 업체 목록을 순회하며 메시지 내용을 가져와 {업체명} 및 {공고명} 변수를 실제 데이터로 치환한 후, 콘솔에 발송될 메시지를 출력하는 시뮬레이션을 수행합니다. 실제 발송 로직은 포함되어 있지 않습니다.
  12. Excel 다운로드: exportToExcel 함수를 구현하여 state.filteredData 전체를 XLSX 라이브러리를 사용하여 Excel 파일로 변환하고 다운로드하도록 했습니다. 컬럼 헤더를 한국어로 설정했습니다.
  13. 로딩/알림: 검색 시작 시 로딩 스피너를 표시하고 완료 시 숨기며, 유효성 검사나 작업 완료 시 간단한 alert 메시지를 표시합니다. (showAlert 함수)
  14. 코드 구조 및 주석: 함수별로 코드를 분리하고 주석을 추가하여 가독성을 높였습니다.

이 코드는 주식회사 진주정보통신에서 나라장터 정보통신공사 입찰/낙찰 정보를 내부적으로 분석하고 활용하기 위한 기반 시스템으로 사용될 수 있습니다. 실제 운영을 위해서는 서버 측에서 나라장터 API를 호출하고 데이터를 관리하는 백엔드 시스템 구축이 필요하며, 메시지 발송 또한 실제 통신 채널(SMS, 이메일 등)과 연동하는 개발이 필요합니다.

 
 
 
 
 

미리보기로 보여줘

 
 
 
 
생각하는 과정 표시

웹 브라우저에서 해당 HTML 파일을 열었을 때 보이게 될 화면을 순서대로 설명해 드리겠습니다. 코드를 직접 실행하여 동적인 부분을 보여드릴 수는 없지만, 정적인 레이아웃과 예상되는 인터랙션을 설명해 드립니다.

1. 초기 화면 (로그인/인증 단계)

  • 화면 중앙에 인증 섹션이 표시됩니다.
  • 회색 배경 위에 흰색 박스가 나타나고, 박스 상단에는 "나라장터 API 인증" 이라는 제목이 보입니다.
  • "나라장터 OpenAPI 서비스 키를 입력해주세요." 라는 안내 메시지 (파란색 alert 박스)가 있습니다.
  • "서비스 키" 입력 필드가 있습니다. (입력 내용은 비밀번호처럼 가려집니다.)
  • 하단에 "인증하기" 버튼이 있습니다.
  • 이 외의 검색, 결과, 메시지 섹션은 보이지 않습니다.

2. 인증 성공 후 메인 화면

  • 서비스 키를 입력하고 "인증하기" 버튼을 누르면 (데모에서는 어떤 키든 입력하면) 인증 섹션이 사라지고 메인 콘텐츠가 나타납니다.
  • 상단에 주식회사 진주정보통신이라는 회사명과 "나라장터 정보통신공사 입찰/낙찰 정보 분석 시스템" 이라는 제목이 있는 파란색 헤더가 보입니다.
  • 그 아래로 주요 기능 섹션들이 배치됩니다.

3. 검색 조건 섹션

  • 흰색 박스 안에 "검색 조건" 이라는 제목이 있습니다.
  • 입찰 유형, 시작일, 종료일, 지역, 최소 금액, 최대 금액을 입력하거나 선택할 수 있는 입력 필드들이 여러 열로 정렬되어 있습니다.
  • "검색 키워드" 입력 필드는 다른 필드보다 넓게 표시됩니다.
  • "모든 키워드 포함 (AND 검색)" 체크박스가 키워드 입력란 아래에 있습니다.
  • 하단 중앙에 "검색하기" 버튼과 "초기화" 버튼이 나란히 있습니다.

4. 로딩 인디케이터 (검색 중)

  • "검색하기" 버튼을 누르면 화면 중앙에 회전하는 파란색 원"데이터를 불러오는 중입니다..." 라는 텍스트가 잠깐 나타납니다.
  • 데이터 로딩이 완료되면 사라집니다.

5. 검색 결과 섹션 (처음에는 숨겨져 있다가 검색 후 표시)

  • 흰색 박스 안에 "검색 결과 (X건)" 이라는 제목이 표시됩니다. (X는 검색된 결과 수)
  • 제목 오른쪽에 "Excel 다운로드" 버튼이 있습니다.
  • 적용된 검색 조건을 보여주는 필터 태그 영역이 나타납니다. 각 태그 옆에는 '×' 버튼이 있어 클릭 시 해당 필터가 제거됩니다.
  • 탭 인터페이스: "표 보기", "차트 보기", "상세 정보" 탭이 보입니다. 기본적으로 "표 보기" 탭이 활성화되어 있습니다.
    • 표 보기 탭:
      • 입찰/낙찰 정보가 표시되는 테이블이 있습니다.
      • 테이블 헤더에는 체크박스(전체 선택), 번호, 공고일, 공고명, 발주처, 지역, 금액, 마감일, 낙찰업체, 낙찰률 컬럼이 있습니다.
      • 검색 결과 데이터가 행으로 표시됩니다. 각 행의 체크박스로 항목을 선택할 수 있습니다.
      • 테이블 하단에 페이지 이동을 위한 페이지네이션 버튼들이 표시됩니다.
      • 결과가 없으면 "검색 결과가 없습니다." 메시지가 표시됩니다.
      • 테이블 행을 클릭하면 "상세 정보" 탭으로 자동 전환됩니다.
    • 차트 보기 탭:
      • 탭 클릭 시 이 내용이 나타납니다.
      • 지역별, 월별, 금액대별, 발주처별 공고 건수를 시각적으로 보여주는 4개의 차트 (막대, 꺾은선, 원형 차트 등)가 표시됩니다.
    • 상세 정보 탭:
      • 탭 클릭 시 이 내용이 나타나거나, 테이블에서 행을 클릭하면 자동으로 전환됩니다.
      • 기본적으로는 "상세 정보를 보려면 표에서 항목을 클릭하세요." 라는 안내 메시지 (파란색 alert 박스)가 보입니다.
      • 테이블에서 특정 공고를 클릭하면 해당 공고의 상세 정보 (공고명, 발주처, 금액 등)가 구조화되어 표시됩니다.

6. 메시지 발송 섹션

  • 흰색 박스 안에 "메시지 발송" 이라는 제목이 있습니다.
  • "선택된 업체 (Y개)" 라는 제목과 함께 테이블에서 체크박스로 선택한 업체들의 이름이 태그 형태로 나열되는 영역이 있습니다. (Y는 선택된 업체 수) 각 태그 옆에는 '×' 버튼이 있어 선택을 취소할 수 있습니다. 선택된 업체가 없으면 안내 메시지가 표시됩니다.
  • "메시지 유형" 드롭다운 메뉴 (낙찰 축하, 회사 홍보, 사용자 정의)가 있습니다.
  • 선택된 메시지 유형에 따른 템플릿 미리보기 영역이 있습니다.
  • **"메시지 내용"**을 직접 입력하거나 편집할 수 있는 큰 텍스트 영역이 있습니다.
  • 하단 오른쪽에 "템플릿 적용" 버튼과 "메시지 발송 (시뮬레이션)" 버튼이 있습니다.

이러한 구성 요소들이 위에서 설명된 CSS 스타일과 함께 표시되어, 나라장터 정보통신공사 입찰/낙찰 정보를 검색하고 분석하며 관련 업체에 메시지를 보내는 시스템의 사용자 인터페이스를 형성합니다.

 
 
 
 
 
AI 음성 개요 생성
반응형

+ Recent posts