<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>출석부 앱</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
* {
box-sizing: border-box;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
body {
margin: 0;
padding: 16px;
background: #f3f4f6;
display: flex;
justify-content: center;
}
.container {
width: 100%;
max-width: 960px;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 10px 30px rgba(0,0,0,0.08);
padding: 20px 24px 28px;
}
h1 {
margin: 0 0 4px;
font-size: 24px;
font-weight: 700;
}
.subtitle {
color: #6b7280;
font-size: 13px;
margin-bottom: 16px;
}
.row {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 12px;
}
.field {
flex: 1 1 180px;
min-width: 160px;
}
label {
font-size: 12px;
font-weight: 600;
color: #374151;
display: block;
margin-bottom: 4px;
}
input[type="text"],
input[type="date"],
textarea {
width: 100%;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid #d1d5db;
font-size: 13px;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
background: #f9fafb;
}
input[type="text"]:focus,
input[type="date"]:focus,
textarea:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59,130,246,0.15);
background: #ffffff;
}
textarea {
resize: vertical;
min-height: 80px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 14px;
border-radius: 999px;
border: none;
background: #3b82f6;
color: white;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: transform 0.05s ease, box-shadow 0.1s ease, background 0.15s;
white-space: nowrap;
}
.btn:hover {
background: #2563eb;
box-shadow: 0 6px 16px rgba(37,99,235,0.35);
transform: translateY(-1px);
}
.btn:active {
transform: translateY(0);
box-shadow: none;
}
.btn.outline {
background: white;
color: #111827;
border: 1px solid #d1d5db;
box-shadow: none;
}
.btn.outline:hover {
background: #f3f4f6;
}
.btn.warn {
background: #ef4444;
}
.btn.warn:hover {
background: #dc2626;
box-shadow: 0 6px 16px rgba(220,38,38,0.35);
}
.btn-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 8px 0 4px;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 12px;
font-size: 13px;
border-radius: 12px;
overflow: hidden;
}
thead {
background: #f3f4f6;
}
th, td {
padding: 8px 10px;
border-bottom: 1px solid #e5e7eb;
text-align: left;
}
th {
font-weight: 600;
font-size: 12px;
color: #4b5563;
}
tbody tr:nth-child(even) {
background: #fafafa;
}
tbody tr:hover {
background: #eff6ff;
}
.center {
text-align: center;
}
.status-group {
display: flex;
justify-content: center;
gap: 6px;
font-size: 11px;
}
.status-group label {
font-weight: 400;
color: #374151;
}
.status-group input {
margin-right: 2px;
}
.summary {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 8px;
font-size: 12px;
color: #374151;
}
.summary span.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: #f3f4f6;
font-weight: 500;
}
.summary span.badge strong {
font-weight: 700;
}
.footer-note {
margin-top: 10px;
font-size: 11px;
color: #9ca3af;
text-align: right;
}
@media (max-width: 640px) {
.container {
padding: 16px;
}
table {
font-size: 12px;
}
th, td {
padding: 6px;
}
.status-group {
flex-direction: column;
align-items: flex-start;
}
}
</style>
</head>
<body>
<div class="container">
<h1>출석부</h1>
<div class="subtitle">반 이름과 날짜를 설정하고 학생 출석을 기록하세요. (브라우저에 자동 저장)</div>
<!-- 반/날짜 설정 -->
<div class="row">
<div class="field">
<label for="className">반 이름</label>
<input type="text" id="className" placeholder="예) 3학년 2반">
</div>
<div class="field" style="max-width: 200px;">
<label for="date">날짜</label>
<input type="date" id="date">
</div>
<div class="field" style="flex: 0 0 auto; align-self: flex-end;">
<button class="btn outline" id="loadBtn">저장된 출석 불러오기</button>
</div>
</div>
<!-- 학생 목록 입력 -->
<div class="row">
<div class="field" style="flex: 2 1 280px;">
<label for="namesInput">학생 이름 (한 줄에 한 명씩)</label>
<textarea id="namesInput" placeholder="예) 홍길동 이몽룡 성춘향"></textarea>
</div>
<div class="field" style="flex: 1 1 180px;">
<label>학생 목록 적용</label>
<div class="btn-group">
<button class="btn" id="applyNamesBtn">학생목록 적용</button>
<button class="btn outline" id="addOneBtn">한 명 추가</button>
</div>
<input type="text" id="singleNameInput" placeholder="추가할 학생 이름">
</div>
</div>
<!-- 출석 테이블 -->
<table id="attendanceTable">
<thead>
<tr>
<th class="center" style="width: 50px;">번호</th>
<th>이름</th>
<th class="center" style="width: 200px;">출석 상태</th>
<th>비고</th>
</tr>
</thead>
<tbody>
<!-- JS로 생성 -->
</tbody>
</table>
<!-- 요약/버튼 -->
<div class="summary">
<span class="badge">출석 <strong id="presentCount">0</strong>명</span>
<span class="badge">지각 <strong id="lateCount">0</strong>명</span>
<span class="badge">결석 <strong id="absentCount">0</strong>명</span>
<span class="badge">총원 <strong id="totalCount">0</strong>명</span>
</div>
<div class="btn-group" style="justify-content: flex-end; margin-top: 10px;">
<button class="btn" id="saveBtn">오늘 출석 저장</button>
<button class="btn outline" id="exportBtn">CSV로 내보내기</button>
<button class="btn warn outline" id="resetBtn">새로 시작</button>
</div>
<div class="footer-note">
저장된 데이터는 이 브라우저(이 컴퓨터)에만 보관됩니다. (localStorage 사용)
</div>
</div>
<script>
// 엘리먼트 참조
const classNameInput = document.getElementById('className');
const dateInput = document.getElementById('date');
const namesInput = document.getElementById('namesInput');
const singleNameInput = document.getElementById('singleNameInput');
const applyNamesBtn = document.getElementById('applyNamesBtn');
const addOneBtn = document.getElementById('addOneBtn');
const attendanceTableBody = document.querySelector('#attendanceTable tbody');
const presentCountEl = document.getElementById('presentCount');
const lateCountEl = document.getElementById('lateCount');
const absentCountEl = document.getElementById('absentCount');
const totalCountEl = document.getElementById('totalCount');
const saveBtn = document.getElementById('saveBtn');
const loadBtn = document.getElementById('loadBtn');
const exportBtn = document.getElementById('exportBtn');
const resetBtn = document.getElementById('resetBtn');
// 오늘 날짜 기본값 세팅
function setToday() {
const today = new Date();
const y = today.getFullYear();
const m = String(today.getMonth() + 1).padStart(2, '0');
const d = String(today.getDate()).padStart(2, '0');
dateInput.value = `${y}-${m}-${d}`;
}
setToday();
// 학생 목록으로 테이블 생성
function buildTableFromNames(names) {
attendanceTableBody.innerHTML = '';
names.forEach((name, idx) => {
const trimmed = name.trim();
if (!trimmed) return;
const tr = document.createElement('tr');
// 번호
const tdNo = document.createElement('td');
tdNo.className = 'center';
tdNo.textContent = idx + 1;
tr.appendChild(tdNo);
// 이름
const tdName = document.createElement('td');
tdName.textContent = trimmed;
tr.appendChild(tdName);
// 출석 상태
const tdStatus = document.createElement('td');
tdStatus.className = 'center';
const group = document.createElement('div');
group.className = 'status-group';
const statuses = [
{ value: 'present', label: '출석' },
{ value: 'late', label: '지각' },
{ value: 'absent', label: '결석' }
];
const groupName = `status-${idx}`;
statuses.forEach(st => {
const label = document.createElement('label');
const radio = document.createElement('input');
radio.type = 'radio';
radio.name = groupName;
radio.value = st.value;
if (st.value === 'present') radio.checked = true;
radio.addEventListener('change', updateSummary);
label.appendChild(radio);
label.appendChild(document.createTextNode(st.label));
group.appendChild(label);
});
tdStatus.appendChild(group);
tr.appendChild(tdStatus);
// 비고
const tdRemark = document.createElement('td');
const remarkInput = document.createElement('input');
remarkInput.type = 'text';
remarkInput.placeholder = '메모';
remarkInput.style.width = '100%';
remarkInput.style.padding = '4px 6px';
remarkInput.style.borderRadius = '6px';
remarkInput.style.border = '1px solid #e5e7eb';
remarkInput.style.fontSize = '12px';
tdRemark.appendChild(remarkInput);
tr.appendChild(tdRemark);
attendanceTableBody.appendChild(tr);
});
updateSummary();
}
// 요약 카운트 업데이트
function updateSummary() {
const rows = attendanceTableBody.querySelectorAll('tr');
let present = 0, late = 0, absent = 0, total = 0;
rows.forEach(row => {
total++;
const radios = row.querySelectorAll('input[type="radio"]');
radios.forEach(r => {
if (r.checked) {
if (r.value === 'present') present++;
else if (r.value === 'late') late++;
else if (r.value === 'absent') absent++;
}
});
});
presentCountEl.textContent = present;
lateCountEl.textContent = late;
absentCountEl.textContent = absent;
totalCountEl.textContent = total;
}
// localStorage 키 생성
function getStorageKey() {
const cls = classNameInput.value.trim();
const date = dateInput.value;
if (!cls || !date) return null;
return `attendance_${cls}_${date}`;
}
// 저장 데이터 만들기
function collectData() {
const cls = classNameInput.value.trim();
const date = dateInput.value;
if (!cls) {
alert('반 이름을 입력해주세요.');
return null;
}
if (!date) {
alert('날짜를 선택해주세요.');
return null;
}
const rows = attendanceTableBody.querySelectorAll('tr');
if (rows.length === 0) {
alert('학생 목록이 비어 있습니다. 학생을 먼저 추가해주세요.');
return null;
}
const students = [];
rows.forEach(row => {
const name = row.children[1].textContent.trim();
const radios = row.querySelectorAll('input[type="radio"]');
let status = 'present';
radios.forEach(r => {
if (r.checked) status = r.value;
});
const remark = row.children[3].querySelector('input').value.trim();
students.push({ name, status, remark });
});
return {
className: cls,
date,
students
};
}
// 저장
saveBtn.addEventListener('click', () => {
const data = collectData();
if (!data) return;
const key = getStorageKey();
if (!key) return;
localStorage.setItem(key, JSON.stringify(data));
alert('출석 데이터가 저장되었습니다.\n(이 브라우저에서만 보관됩니다)');
});
// 불러오기
loadBtn.addEventListener('click', () => {
const key = getStorageKey();
if (!key) {
alert('반 이름과 날짜를 모두 입력/선택한 후 불러올 수 있습니다.');
return;
}
const raw = localStorage.getItem(key);
if (!raw) {
alert('해당 반/날짜로 저장된 출석 데이터가 없습니다.');
return;
}
try {
const data = JSON.parse(raw);
if (!data.students || !Array.isArray(data.students)) throw new Error();
// 테이블 재구성
attendanceTableBody.innerHTML = '';
data.students.forEach((st, idx) => {
const tr = document.createElement('tr');
const tdNo = document.createElement('td');
tdNo.className = 'center';
tdNo.textContent = idx + 1;
tr.appendChild(tdNo);
const tdName = document.createElement('td');
tdName.textContent = st.name;
tr.appendChild(tdName);
const tdStatus = document.createElement('td');
tdStatus.className = 'center';
const group = document.createElement('div');
group.className = 'status-group';
const statuses = [
{ value: 'present', label: '출석' },
{ value: 'late', label: '지각' },
{ value: 'absent', label: '결석' }
];
const groupName = `status-${idx}`;
statuses.forEach(s => {
const label = document.createElement('label');
const radio = document.createElement('input');
radio.type = 'radio';
radio.name = groupName;
radio.value = s.value;
if (st.status === s.value) radio.checked = true;
radio.addEventListener('change', updateSummary);
label.appendChild(radio);
label.appendChild(document.createTextNode(s.label));
group.appendChild(label);
});
tdStatus.appendChild(group);
tr.appendChild(tdStatus);
const tdRemark = document.createElement('td');
const remarkInput = document.createElement('input');
remarkInput.type = 'text';
remarkInput.value = st.remark || '';
remarkInput.placeholder = '메모';
remarkInput.style.width = '100%';
remarkInput.style.padding = '4px 6px';
remarkInput.style.borderRadius = '6px';
remarkInput.style.border = '1px solid #e5e7eb';
remarkInput.style.fontSize = '12px';
tdRemark.appendChild(remarkInput);
tr.appendChild(tdRemark);
attendanceTableBody.appendChild(tr);
});
updateSummary();
alert('저장된 출석 데이터를 불러왔습니다.');
} catch (e) {
console.error(e);
alert('저장된 데이터 형식에 문제가 있습니다.');
}
});
// CSV로 내보내기
exportBtn.addEventListener('click', () => {
const data = collectData();
if (!data) return;
const header = ['번호', '이름', '상태', '비고'];
const rows = data.students.map((st, idx) => {
let statusText = '';
if (st.status === 'present') statusText = '출석';
else if (st.status === 'late') statusText = '지각';
else if (st.status === 'absent') statusText = '결석';
// CSV 특수문자 처리 (큰따옴표 감싸기)
const escape = (value) => {
if (value == null) return '';
const v = String(value).replace(/"/g, '""');
return `"${v}"`;
};
return [
idx + 1,
escape(st.name),
escape(statusText),
escape(st.remark || '')
].join(',');
});
const csvContent = [
header.join(','),
...rows
].join('\r\n');
const blob = new Blob(
['\ufeff' + csvContent],
{ type: 'text/csv;charset=utf-8;' }
);
const cls = data.className.replace(/\s+/g, '_');
const date = data.date;
const fileName = `출석부_${cls}_${date}.csv`;
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
});
// 새로 시작
resetBtn.addEventListener('click', () => {
if (!confirm('현재 화면의 출석 내용을 모두 지우시겠습니까? (저장된 데이터는 남아 있습니다)')) return;
attendanceTableBody.innerHTML = '';
namesInput.value = '';
singleNameInput.value = '';
updateSummary();
});
// 학생목록 적용 버튼
applyNamesBtn.addEventListener('click', () => {
const text = namesInput.value;
if (!text.trim()) {
alert('학생 이름을 먼저 입력해주세요. (한 줄에 한 명씩)');
return;
}
const names = text.split('\n');
buildTableFromNames(names);
});
// 한 명 추가 버튼
addOneBtn.addEventListener('click', () => {
const name = singleNameInput.value.trim();
if (!name) {
alert('추가할 학생 이름을 입력해주세요.');
return;
}
const currentNames = [];
attendanceTableBody.querySelectorAll('tr').forEach(row => {
currentNames.push(row.children[1].textContent.trim());
});
currentNames.push(name);
buildTableFromNames(currentNames);
singleNameInput.value = '';
namesInput.value = currentNames.join('\n');
});
</script>
</body>
</html>
1. 위 코드를 메모장/VSCode 등에 붙여넣고 attendance.html 로 저장
2. 저장한 파일을 더블클릭해서 브라우저(크롬,엣지 등)로 열기
3. 상단에 반이름 입력, 날짜 선택(기본은 오늘 날짜)
4. 왼쪽 큰 칸에 학생 이름을, 한줄에 한 명씩 입력 - 학생목록 적용 클릭