hskwon 588bf725ae fix: PDO 연결 설정 개선 (MySQL 8.0 호환)
- charset utf8 → utf8mb4 변경
- PDO 옵션을 생성자에서 직접 설정
- SET NAMES utf8mb4 초기화 명령 추가
2025-12-10 21:51:05 +09:00
2025-12-10 20:14:31 +09:00
2025-12-10 20:14:31 +09:00
2025-12-10 20:14:31 +09:00

동적행 생성 시스템 (Dynamic Row Generation System)

개요

이 문서는 웹 애플리케이션에서 동적으로 테이블 행을 생성, 삭제, 복사할 수 있는 시스템의 구현 방법을 설명합니다.

주요 기능

  • 행 추가: 기존 행 아래에 새로운 행 삽입
  • 행 삭제: 선택된 행 제거
  • 행 복사: 기존 행의 내용을 복사하여 새로운 행 생성
  • 동적 ID 관리: 자동으로 증가하는 행 인덱스 관리

HTML 구조

기본 테이블 구조

<table class="table table-bordered align-middle text-center table-sm" id="taskTable">
    <thead class="table-light">
        <tr>
            <th scope="col" style="width: 10%;">관리</th>
            <th scope="col" style="width: 60%;">할일</th>
            <th scope="col" style="width: 8%;">완료</th>
            <th scope="col" style="width: 8%;">완료일</th>
            <th scope="col" style="width: 8%;">경과</th>
        </tr>
    </thead>
    <tbody id="taskTableBody">
        <!-- 동적으로 생성되는 행들이 여기에 들어갑니다 -->
    </tbody>
</table>

버튼 그룹 구조

<div class="btn-group btn-group-sm" role="group" style="gap: 1px;">
    <button type="button" class="btn btn-outline-primary btn-sm p-0" 
            style="width: 20px; height: 20px; font-size: 12px;" 
            onclick="addRowAfter(<?= $i ?>)" title="아래에 행 추가">
        <i class="bi bi-plus"></i>
    </button>
    <button type="button" class="btn btn-outline-success btn-sm p-0" 
            style="width: 20px; height: 20px; font-size: 12px;" 
            onclick="copyRow(<?= $i ?>)" title="행 복사">
        <i class="bi bi-files"></i>
    </button>
    <button type="button" class="btn btn-outline-danger btn-sm p-0" 
            style="width: 20px; height: 20px; font-size: 12px;" 
            onclick="deleteRow(<?= $i ?>)" title="행 삭제">
        <i class="bi bi-dash"></i>
    </button>
</div>

개별 행 구조

<tr class="task-row align-middle" data-row="<?= $i ?>" data-unique-id="<?= $unique_id ?>">
    <td class="text-center align-middle">
        <!-- 버튼 그룹이 들어가는 셀 -->
    </td>
    <td class="text-center align-middle">
        <div class="d-flex justify-content-start align-items-center w-100">
            <input type="text" name="tasks[<?= $i ?>][task_content]" 
                   class="form-control form-control-sm task-content-input flex-grow-1" 
                   placeholder="할일을 입력하세요" 
                   value="<?= htmlspecialchars($task['task_content'] ?? '') ?>">
            <input type="hidden" name="tasks[<?= $i ?>][unique_id]" value="<?= $unique_id ?>">
            <input type="hidden" name="tasks[<?= $i ?>][is_pending]" value="<?= $is_pending ? '1' : '0' ?>">
        </div>
    </td>
    <td class="text-center align-middle">
        <div class="form-check d-flex justify-content-center align-middle align-items-center">
            <input class="form-check-input task-checkbox" type="checkbox" 
                   name="tasks[<?= $i ?>][is_completed]" value="1" 
                   <?= ($task['is_completed'] ?? false) ? 'checked' : '' ?> 
                   onchange="updateCompletionDate(this)">
        </div>
    </td>
    <td class="text-center align-middle">
        <input type="date" name="tasks[<?= $i ?>][completion_date]" 
               class="form-control form-control-sm completion-date-input" 
               value="<?= $task['completion_date'] ?? '' ?>">
    </td>
    <td class="text-center align-middle">
        <!-- 경과일 표시 영역 -->
    </td>
</tr>

JavaScript 함수들

1. 행 추가 함수 (addRowAfter)

function addRowAfter(rowIndex) {
    const tbody = document.getElementById('taskTableBody');
    const rows = tbody.querySelectorAll('.task-row');
    const newRowIndex = getNextRowIndex();
    
    // 새로운 행 HTML 생성
    const newRow = createTaskRow(newRowIndex);
    
    // 지정된 행 다음에 삽입
    if (rowIndex < rows.length - 1) {
        rows[rowIndex].insertAdjacentHTML('afterend', newRow);
    } else {
        tbody.insertAdjacentHTML('beforeend', newRow);
    }
    
    // 행 인덱스 재정렬
    reorderRowIndexes();
    
    // 통계 업데이트
    updateTaskStatistics();
}

function createTaskRow(rowIndex) {
    return `
        <tr class="task-row align-middle" data-row="${rowIndex}" data-unique-id="">
            <td class="text-center align-middle">
                <div class="d-flex align-items-center justify-content-center">
                    <div class="btn-group btn-group-sm" role="group" style="gap: 1px;">
                        <button type="button" class="btn btn-outline-primary btn-sm p-0" 
                                style="width: 20px; height: 20px; font-size: 12px;" 
                                onclick="addRowAfter(${rowIndex})" title="아래에 행 추가">
                            <i class="bi bi-plus"></i>
                        </button>
                        <button type="button" class="btn btn-outline-success btn-sm p-0" 
                                style="width: 20px; height: 20px; font-size: 12px;" 
                                onclick="copyRow(${rowIndex})" title="행 복사">
                            <i class="bi bi-files"></i>
                        </button>
                        <button type="button" class="btn btn-outline-danger btn-sm p-0" 
                                style="width: 20px; height: 20px; font-size: 12px;" 
                                onclick="deleteRow(${rowIndex})" title="행 삭제">
                            <i class="bi bi-dash"></i>
                        </button>
                    </div>
                </div>
            </td>
            <td class="text-center align-middle">
                <div class="d-flex justify-content-start align-items-center w-100">
                    <input type="text" name="tasks[${rowIndex}][task_content]" 
                           class="form-control form-control-sm task-content-input flex-grow-1" 
                           placeholder="할일을 입력하세요" value="">
                    <input type="hidden" name="tasks[${rowIndex}][unique_id]" value="">
                    <input type="hidden" name="tasks[${rowIndex}][is_pending]" value="0">
                </div>
            </td>
            <td class="text-center align-middle">
                <div class="form-check d-flex justify-content-center align-middle align-items-center">
                    <input class="form-check-input task-checkbox" type="checkbox" 
                           name="tasks[${rowIndex}][is_completed]" value="1" 
                           onchange="updateCompletionDate(this)">
                </div>
            </td>
            <td class="text-center align-middle">
                <input type="date" name="tasks[${rowIndex}][completion_date]" 
                       class="form-control form-control-sm completion-date-input" 
                       value="" readonly>
            </td>
            <td class="text-center align-middle">
                <span class="elapsed-days-display" data-original-date="">-</span>
            </td>
        </tr>
    `;
}

2. 행 삭제 함수 (deleteRow)

function deleteRow(rowIndex) {
    const tbody = document.getElementById('taskTableBody');
    const rows = tbody.querySelectorAll('.task-row');
    
    if (rows.length <= 1) {
        alert('최소 하나의 행은 유지해야 합니다.');
        return;
    }
    
    // 행 삭제 확인
    if (!confirm('이 행을 삭제하시겠습니까?')) {
        return;
    }
    
    // 해당 행 삭제
    const targetRow = tbody.querySelector(`tr[data-row="${rowIndex}"]`);
    if (targetRow) {
        targetRow.remove();
    }
    
    // 행 인덱스 재정렬
    reorderRowIndexes();
    
    // 통계 업데이트
    updateTaskStatistics();
}

3. 행 복사 함수 (copyRow)

function copyRow(rowIndex) {
    const tbody = document.getElementById('taskTableBody');
    const sourceRow = tbody.querySelector(`tr[data-row="${rowIndex}"]`);
    const newRowIndex = getNextRowIndex();
    
    if (!sourceRow) return;
    
    // 소스 행의 데이터 수집
    const taskContent = sourceRow.querySelector('input[name*="[task_content]"]').value;
    const isCompleted = sourceRow.querySelector('input[name*="[is_completed]"]').checked;
    const completionDate = sourceRow.querySelector('input[name*="[completion_date]"]').value;
    
    // 새로운 행 생성 (복사된 데이터 포함)
    const newRow = createTaskRowWithData(newRowIndex, taskContent, isCompleted, completionDate);
    
    // 소스 행 다음에 삽입
    sourceRow.insertAdjacentHTML('afterend', newRow);
    
    // 행 인덱스 재정렬
    reorderRowIndexes();
    
    // 통계 업데이트
    updateTaskStatistics();
}

function createTaskRowWithData(rowIndex, taskContent, isCompleted, completionDate) {
    const checkedAttr = isCompleted ? 'checked' : '';
    const readonlyAttr = isCompleted ? '' : 'readonly';
    
    return `
        <tr class="task-row align-middle" data-row="${rowIndex}" data-unique-id="">
            <td class="text-center align-middle">
                <div class="d-flex align-items-center justify-content-center">
                    <div class="btn-group btn-group-sm" role="group" style="gap: 1px;">
                        <button type="button" class="btn btn-outline-primary btn-sm p-0" 
                                style="width: 20px; height: 20px; font-size: 12px;" 
                                onclick="addRowAfter(${rowIndex})" title="아래에 행 추가">
                            <i class="bi bi-plus"></i>
                        </button>
                        <button type="button" class="btn btn-outline-success btn-sm p-0" 
                                style="width: 20px; height: 20px; font-size: 12px;" 
                                onclick="copyRow(${rowIndex})" title="행 복사">
                            <i class="bi bi-files"></i>
                        </button>
                        <button type="button" class="btn btn-outline-danger btn-sm p-0" 
                                style="width: 20px; height: 20px; font-size: 12px;" 
                                onclick="deleteRow(${rowIndex})" title="행 삭제">
                            <i class="bi bi-dash"></i>
                        </button>
                    </div>
                </div>
            </td>
            <td class="text-center align-middle">
                <div class="d-flex justify-content-start align-items-center w-100">
                    <input type="text" name="tasks[${rowIndex}][task_content]" 
                           class="form-control form-control-sm task-content-input flex-grow-1" 
                           placeholder="할일을 입력하세요" value="${taskContent}">
                    <input type="hidden" name="tasks[${rowIndex}][unique_id]" value="">
                    <input type="hidden" name="tasks[${rowIndex}][is_pending]" value="0">
                </div>
            </td>
            <td class="text-center align-middle">
                <div class="form-check d-flex justify-content-center align-middle align-items-center">
                    <input class="form-check-input task-checkbox" type="checkbox" 
                           name="tasks[${rowIndex}][is_completed]" value="1" 
                           ${checkedAttr} onchange="updateCompletionDate(this)">
                </div>
            </td>
            <td class="text-center align-middle">
                <input type="date" name="tasks[${rowIndex}][completion_date]" 
                       class="form-control form-control-sm completion-date-input" 
                       value="${completionDate}" ${readonlyAttr}>
            </td>
            <td class="text-center align-middle">
                <span class="elapsed-days-display" data-original-date="">-</span>
            </td>
        </tr>
    `;
}

4. 유틸리티 함수들

// 다음 행 인덱스 가져오기
function getNextRowIndex() {
    const tbody = document.getElementById('taskTableBody');
    const rows = tbody.querySelectorAll('.task-row');
    return rows.length;
}

// 행 인덱스 재정렬
function reorderRowIndexes() {
    const tbody = document.getElementById('taskTableBody');
    const rows = tbody.querySelectorAll('.task-row');
    
    rows.forEach((row, index) => {
        // data-row 속성 업데이트
        row.setAttribute('data-row', index);
        
        // 모든 input의 name 속성 업데이트
        const inputs = row.querySelectorAll('input');
        inputs.forEach(input => {
            const name = input.getAttribute('name');
            if (name && name.includes('[')) {
                const newName = name.replace(/\[\d+\]/, `[${index}]`);
                input.setAttribute('name', newName);
            }
        });
        
        // 버튼의 onclick 속성 업데이트
        const buttons = row.querySelectorAll('button');
        buttons.forEach(button => {
            const onclick = button.getAttribute('onclick');
            if (onclick) {
                const newOnclick = onclick.replace(/\(\d+\)/g, `(${index})`);
                button.setAttribute('onclick', newOnclick);
            }
        });
    });
}

// 완료일 업데이트 함수
function updateCompletionDate(checkbox) {
    const row = checkbox.closest('tr');
    const completionDateInput = row.querySelector('input[name*="[completion_date]"]');
    
    if (checkbox.checked) {
        completionDateInput.value = new Date().toISOString().split('T')[0];
        completionDateInput.removeAttribute('readonly');
    } else {
        completionDateInput.value = '';
        completionDateInput.setAttribute('readonly', 'readonly');
    }
    
    // 통계 업데이트
    updateTaskStatistics();
}

// 통계 업데이트 함수
function updateTaskStatistics() {
    const tbody = document.getElementById('taskTableBody');
    const rows = tbody.querySelectorAll('.task-row');
    
    let totalTasks = 0;
    let completedTasks = 0;
    
    rows.forEach(row => {
        const taskContent = row.querySelector('input[name*="[task_content]"]').value.trim();
        const isCompleted = row.querySelector('input[name*="[is_completed]"]').checked;
        
        if (taskContent !== '') {
            totalTasks++;
            if (isCompleted) {
                completedTasks++;
            }
        }
    });
    
    const pendingTasks = totalTasks - completedTasks;
    const completionRate = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
    
    // 통계 표시 업데이트
    document.getElementById('totalTasks').textContent = totalTasks;
    document.getElementById('completedTasks').textContent = completedTasks;
    document.getElementById('pendingTasks').textContent = pendingTasks;
    document.getElementById('completionRate').textContent = completionRate + '%';
}

CSS 스타일

버튼 그룹 스타일

.btn-group-sm .btn {
    border-radius: 0.2rem;
    font-size: 0.875rem;
    line-height: 1.5;
    padding: 0.25rem 0.5rem;
}

.btn-group .btn:not(:last-child):not(.dropdown-toggle) {
    border-top-right-radius: 0;
    border-bottom-right-radius: 0;
}

.btn-group .btn:not(:first-child) {
    border-top-left-radius: 0;
    border-bottom-left-radius: 0;
}

/* 작은 버튼 스타일 */
.btn-sm {
    padding: 0.25rem 0.5rem;
    font-size: 0.875rem;
    border-radius: 0.2rem;
}

/* 아이콘 버튼 스타일 */
.btn i {
    font-size: 12px;
}

테이블 스타일

.table-sm td, .table-sm th {
    padding: 0.3rem;
}

.task-row {
    transition: background-color 0.2s ease;
}

.task-row:hover {
    background-color: rgba(0, 123, 255, 0.05);
}

.form-control-sm {
    height: calc(1.5em + 0.5rem + 2px);
    padding: 0.25rem 0.5rem;
    font-size: 0.875rem;
    line-height: 1.5;
    border-radius: 0.2rem;
}

사용 방법

1. HTML에 테이블 구조 추가

<!-- 위의 HTML 구조를 복사하여 사용 -->

2. JavaScript 함수들 추가

<script>
// 위의 모든 JavaScript 함수들을 복사하여 사용
</script>

3. CSS 스타일 추가

<style>
/* 위의 CSS 스타일들을 복사하여 사용 */
</style>

4. Bootstrap 및 아이콘 라이브러리 포함

<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">

<!-- Bootstrap Icons -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet">

<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>

주의사항

  1. 최소 행 유지: 최소 하나의 행은 항상 유지되어야 합니다.
  2. 인덱스 관리: 행이 삭제되거나 추가될 때 인덱스가 자동으로 재정렬됩니다.
  3. 데이터 무결성: 폼 제출 시 모든 행의 데이터가 올바르게 전송되도록 name 속성이 관리됩니다.
  4. 브라우저 호환성: ES6 문법을 사용하므로 최신 브라우저에서 동작합니다.

확장 가능한 기능

  1. 드래그 앤 드롭: 행 순서 변경
  2. 일괄 작업: 여러 행 선택 및 일괄 처리
  3. 검색 및 필터링: 특정 조건에 맞는 행만 표시
  4. 데이터 유효성 검사: 입력 데이터 검증
  5. 자동 저장: 변경사항 자동 저장

이 시스템을 사용하면 동적으로 테이블 행을 관리할 수 있는 완전한 기능을 갖춘 인터페이스를 구축할 수 있습니다.

Description
KD Project
Readme 110 MiB
Languages
PHP 81.3%
JavaScript 8.6%
HTML 5.6%
Twig 2.4%
CSS 1.1%
Other 0.9%