- URL 하드코딩 → .env APP_URL 기반 동적 URL로 변경 - DB 연결 하드코딩 → .env 기반으로 변경 - MySQL strict mode DATE 오류 수정
474 lines
18 KiB
Markdown
474 lines
18 KiB
Markdown
# 동적행 생성 시스템 (Dynamic Row Generation System)
|
|
|
|
## 개요
|
|
이 문서는 웹 애플리케이션에서 동적으로 테이블 행을 생성, 삭제, 복사할 수 있는 시스템의 구현 방법을 설명합니다.
|
|
|
|
## 주요 기능
|
|
- **행 추가**: 기존 행 아래에 새로운 행 삽입
|
|
- **행 삭제**: 선택된 행 제거
|
|
- **행 복사**: 기존 행의 내용을 복사하여 새로운 행 생성
|
|
- **동적 ID 관리**: 자동으로 증가하는 행 인덱스 관리
|
|
|
|
## HTML 구조
|
|
|
|
### 기본 테이블 구조
|
|
```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>
|
|
```
|
|
|
|
### 버튼 그룹 구조
|
|
```html
|
|
<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>
|
|
```
|
|
|
|
### 개별 행 구조
|
|
```html
|
|
<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)
|
|
```javascript
|
|
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)
|
|
```javascript
|
|
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)
|
|
```javascript
|
|
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. 유틸리티 함수들
|
|
```javascript
|
|
// 다음 행 인덱스 가져오기
|
|
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 스타일
|
|
|
|
### 버튼 그룹 스타일
|
|
```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;
|
|
}
|
|
```
|
|
|
|
### 테이블 스타일
|
|
```css
|
|
.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
|
|
<!-- 위의 HTML 구조를 복사하여 사용 -->
|
|
```
|
|
|
|
### 2. JavaScript 함수들 추가
|
|
```html
|
|
<script>
|
|
// 위의 모든 JavaScript 함수들을 복사하여 사용
|
|
</script>
|
|
```
|
|
|
|
### 3. CSS 스타일 추가
|
|
```html
|
|
<style>
|
|
/* 위의 CSS 스타일들을 복사하여 사용 */
|
|
</style>
|
|
```
|
|
|
|
### 4. Bootstrap 및 아이콘 라이브러리 포함
|
|
```html
|
|
<!-- 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. **자동 저장**: 변경사항 자동 저장
|
|
|
|
이 시스템을 사용하면 동적으로 테이블 행을 관리할 수 있는 완전한 기능을 갖춘 인터페이스를 구축할 수 있습니다.
|