b1128bedb5d649084fddcfa80cafbe670d9a8c1a
- docs/README.md: 프로젝트 개요, 기술 스택, 구조, 핵심 모듈 - docs/MODULES.md: 모듈별 상세 (견적, 출고, 수입검사, 작업, 전자결재) - docs/DATABASE.md: DB 스키마, 테이블 구조, 연결 설정 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
동적행 생성 시스템 (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>
주의사항
- 최소 행 유지: 최소 하나의 행은 항상 유지되어야 합니다.
- 인덱스 관리: 행이 삭제되거나 추가될 때 인덱스가 자동으로 재정렬됩니다.
- 데이터 무결성: 폼 제출 시 모든 행의 데이터가 올바르게 전송되도록 name 속성이 관리됩니다.
- 브라우저 호환성: ES6 문법을 사용하므로 최신 브라우저에서 동작합니다.
확장 가능한 기능
- 드래그 앤 드롭: 행 순서 변경
- 일괄 작업: 여러 행 선택 및 일괄 처리
- 검색 및 필터링: 특정 조건에 맞는 행만 표시
- 데이터 유효성 검사: 입력 데이터 검증
- 자동 저장: 변경사항 자동 저장
이 시스템을 사용하면 동적으로 테이블 행을 관리할 수 있는 완전한 기능을 갖춘 인터페이스를 구축할 수 있습니다.
Description
Languages
PHP
81.3%
JavaScript
8.6%
HTML
5.6%
Twig
2.4%
CSS
1.1%
Other
0.9%