- Add main application files (main.py, models.py, schemas.py, etc.) - Add routers for all features (waiting, attendance, members, etc.) - Add HTML templates for admin and user interfaces - Add migration scripts and utility files - Add Docker configuration - Add documentation files - Add .gitignore to exclude database and cache files 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1533 lines
75 KiB
HTML
1533 lines
75 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ko">
|
|
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>매장 설정</title>
|
|
<link rel="stylesheet" href="/static/css/common.css">
|
|
<style>
|
|
.settings-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.tabs-container {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-bottom: 30px;
|
|
border-bottom: 2px solid #ecf0f1;
|
|
}
|
|
|
|
.settings-tab {
|
|
padding: 15px 30px;
|
|
background: transparent;
|
|
border: none;
|
|
border-bottom: 3px solid transparent;
|
|
cursor: pointer;
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
color: #7f8c8d;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.settings-tab.active {
|
|
color: #3498db;
|
|
border-bottom-color: #3498db;
|
|
}
|
|
|
|
.tab-content {
|
|
display: none;
|
|
}
|
|
|
|
.tab-content.active {
|
|
display: block;
|
|
animation: fadeIn 0.3s;
|
|
}
|
|
|
|
.class-type-content {
|
|
display: none;
|
|
}
|
|
|
|
.class-type-content.active {
|
|
display: block;
|
|
animation: fadeIn 0.3s;
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(10px);
|
|
}
|
|
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
|
|
.class-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 20px;
|
|
background: #f8f9fa;
|
|
padding: 20px;
|
|
border-radius: 12px;
|
|
max-height: calc(100vh - 350px);
|
|
overflow-y: auto;
|
|
}
|
|
|
|
/* Custom Scrollbar for Class List */
|
|
.class-list::-webkit-scrollbar {
|
|
width: 8px;
|
|
}
|
|
|
|
.class-list::-webkit-scrollbar-track {
|
|
background: #f1f1f1;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.class-list::-webkit-scrollbar-thumb {
|
|
background: #bdc3c7;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.class-list::-webkit-scrollbar-thumb:hover {
|
|
background: #95a5a6;
|
|
}
|
|
|
|
.class-item {
|
|
background: #fff;
|
|
padding: 25px 30px;
|
|
border-radius: 12px;
|
|
display: grid;
|
|
grid-template-columns: 60px 1fr auto;
|
|
gap: 25px;
|
|
align-items: center;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
border-left: 5px solid #3498db;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.class-item:hover {
|
|
transform: translateX(5px);
|
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15);
|
|
border-left-color: #2980b9;
|
|
}
|
|
|
|
.class-number-badge {
|
|
width: 60px;
|
|
height: 60px;
|
|
background: linear-gradient(135deg, #3498db, #2980b9);
|
|
color: #fff;
|
|
border-radius: 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 24px;
|
|
font-weight: 700;
|
|
box-shadow: 0 2px 8px rgba(52, 152, 219, 0.3);
|
|
}
|
|
|
|
.class-item-info {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
.class-item-info .title {
|
|
font-size: 26px;
|
|
font-weight: 700;
|
|
color: #2c3e50;
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.class-item-info .details {
|
|
display: flex;
|
|
gap: 30px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.detail-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 5px;
|
|
}
|
|
|
|
.detail-label {
|
|
font-size: 13px;
|
|
color: #95a5a6;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.detail-value {
|
|
font-size: 22px;
|
|
font-weight: 700;
|
|
color: #2c3e50;
|
|
}
|
|
|
|
.detail-value.time {
|
|
color: #3498db;
|
|
}
|
|
|
|
.detail-value.capacity {
|
|
color: #27ae60;
|
|
}
|
|
|
|
.detail-value.waiting {
|
|
color: #e74c3c;
|
|
}
|
|
|
|
.class-item-actions {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
min-width: 120px;
|
|
}
|
|
|
|
.class-item-actions .btn {
|
|
width: 100%;
|
|
}
|
|
|
|
.form-row {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 15px;
|
|
}
|
|
|
|
.inactive-badge {
|
|
display: inline-block;
|
|
padding: 4px 12px;
|
|
background: #e74c3c;
|
|
color: #fff;
|
|
border-radius: 12px;
|
|
font-size: 12px;
|
|
margin-left: 10px;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.settings-header {
|
|
flex-direction: column;
|
|
gap: 15px;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.tabs-container {
|
|
overflow-x: auto;
|
|
flex-wrap: nowrap;
|
|
}
|
|
|
|
.class-item {
|
|
grid-template-columns: 1fr;
|
|
gap: 20px;
|
|
padding: 20px;
|
|
}
|
|
|
|
.class-number-badge {
|
|
width: 50px;
|
|
height: 50px;
|
|
font-size: 20px;
|
|
}
|
|
|
|
.class-item-info .title {
|
|
font-size: 22px;
|
|
}
|
|
|
|
.detail-value {
|
|
font-size: 18px;
|
|
}
|
|
|
|
.class-item-actions {
|
|
width: 100%;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<div class="container">
|
|
<div class="settings-header">
|
|
<div>
|
|
<h1>매장 설정</h1>
|
|
<p class="subtitle">매장 정보 및 클래스 관리</p>
|
|
</div>
|
|
<a href="/" class="btn btn-secondary">← 메인으로</a>
|
|
</div>
|
|
|
|
<div class="tabs-container">
|
|
<button class="settings-tab active" onclick="switchTab('store')">매장 정보</button>
|
|
<button class="settings-tab" onclick="switchTab('class')">클래스 관리</button>
|
|
</div>
|
|
|
|
<!-- 매장 정보 탭 -->
|
|
<div id="storeTab" class="tab-content active">
|
|
<!-- 설정 복제 섹션 -->
|
|
<div class="card"
|
|
style="margin-bottom: 20px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;">
|
|
<h3 style="margin-bottom: 15px; color: white;">⚡ 빠른 설정</h3>
|
|
<p style="margin-bottom: 20px; opacity: 0.9;">다른 매장의 설정을 복제하여 간편하게 설정할 수 있습니다.</p>
|
|
<div style="display: flex; gap: 10px; align-items: center;">
|
|
<select id="cloneSourceStore" class="form-control" style="flex: 1; max-width: 400px;">
|
|
<option value="">복제할 매장 선택</option>
|
|
</select>
|
|
<button class="btn" style="background: white; color: #667eea; font-weight: 600;"
|
|
onclick="cloneSettings()">
|
|
🔄 설정 복제하기
|
|
</button>
|
|
</div>
|
|
<small style="display: block; margin-top: 10px; opacity: 0.85;">
|
|
💡 복제 시 매장명을 제외한 모든 설정값이 복사됩니다
|
|
</small>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2 style="margin-bottom:20px;">매장 기본 정보</h2>
|
|
<form id="storeForm" onsubmit="saveStoreSettings(event)">
|
|
<div class="form-group">
|
|
<label>매장명</label>
|
|
<input type="text" id="storeName" class="form-control" required>
|
|
</div>
|
|
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>대기현황판 표시 클래스 수</label>
|
|
<input type="number" id="displayClassesCount" class="form-control" min="1" max="10"
|
|
required>
|
|
<small style="color:#7f8c8d;">대기현황판에 한 번에 표시할 클래스 개수</small>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>클래스당 줄 수</label>
|
|
<input type="number" id="rowsPerClass" class="form-control" min="1" max="5" required>
|
|
<small style="color:#7f8c8d;">각 클래스별 대기자 목록을 몇 줄로 표시할지</small>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>영업 시작 기준 시간 (새벽)</label>
|
|
<select id="businessDayStart" class="form-control">
|
|
<option value="0">00:00 (자정)</option>
|
|
<option value="1">01:00</option>
|
|
<option value="2">02:00</option>
|
|
<option value="3">03:00</option>
|
|
<option value="4">04:00</option>
|
|
<option value="5" selected>05:00 (기본값)</option>
|
|
<option value="6">06:00</option>
|
|
<option value="7">07:00</option>
|
|
<option value="8">08:00</option>
|
|
</select>
|
|
<small style="color:#7f8c8d;">
|
|
하루 영업 시작으로 간주할 시간을 선택합니다.<br>
|
|
예: 05:00 선택 시, 새벽 4시에 오신 손님은 '전날' 영업일로 기록됩니다.
|
|
</small>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="d-block font-weight-bold mb-2">개점 설정 (Opening Rules)</label>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="radio" name="dailyOpeningRule" id="openingRuleStrict"
|
|
value="strict">
|
|
<label class="form-check-label" for="openingRuleStrict">
|
|
당일 개점은 1번만 가능 (1일 1회 제한)
|
|
</label>
|
|
<small class="form-text text-muted">
|
|
이미 개점/마감한 날짜에는 다시 개점할 수 없습니다. "내일 개점해주세요" 알림이 표시됩니다.
|
|
</small>
|
|
</div>
|
|
<div class="form-check mt-2">
|
|
<input class="form-check-input" type="radio" name="dailyOpeningRule"
|
|
id="openingRuleFlexible" value="flexible">
|
|
<label class="form-check-label" for="openingRuleFlexible">
|
|
2회 이상 개점 가능 (자동 날짜 변경)
|
|
</label>
|
|
<small class="form-text text-muted">
|
|
이미 마감한 날짜에 다시 개점하면, 자동으로 <strong>다음 날짜</strong>로 개점됩니다.
|
|
</small>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-check-label" for="autoClosing">
|
|
<input type="checkbox" id="autoClosing" style="width: 20px; height: 20px; cursor: pointer;">
|
|
자동 마감 및 리셋 사용
|
|
</label>
|
|
<small class="form-text text-muted d-block mt-1">
|
|
활성화 시(기본): 기준 시간이 되면 자동으로 마감 처리되고 대기자가 리셋됩니다.<br>
|
|
비활성화 시: 기준 시간이 지나도 마감되지 않으며, 미처리된 대기자는 다음 영업일로 자동 이월됩니다.
|
|
</small>
|
|
|
|
<!-- 마감 처리 방식 설 (자동 마감 활성화 시 표시) -->
|
|
<div id="closingActionContainer" class="mt-3 ml-4" style="display: none;">
|
|
<label class="d-block font-weight-bold mb-2">미처리 대기자 처리 방식</label>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="radio" name="closingAction" id="actionReset"
|
|
value="reset" checked>
|
|
<label class="form-check-label" for="actionReset">
|
|
초기화 (취소 처리)
|
|
</label>
|
|
<small class="form-text text-muted">
|
|
미처리 대기자를 '취소' 상태로 변경하고 목록에서 제거합니다.
|
|
</small>
|
|
</div>
|
|
<div class="form-check mt-2">
|
|
<input class="form-check-input" type="radio" name="closingAction" id="actionAttended"
|
|
value="attended">
|
|
<label class="form-check-label" for="actionAttended">
|
|
일괄 출석 처리
|
|
</label>
|
|
<small class="form-text text-muted">
|
|
미처리 대기자를 모두 '출석' 상태로 변경합니다. (데이터 누락 방지)
|
|
</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>대기자 리스트 방향</label>
|
|
<select id="listDirection" class="form-control">
|
|
<option value="vertical">세로 방향</option>
|
|
<option value="horizontal">가로 방향</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer;">
|
|
<input type="checkbox" id="useMaxWaitingLimit"
|
|
style="width: 20px; height: 20px; cursor: pointer;">
|
|
<span>최대 대기 인원 제한 사용</span>
|
|
</label>
|
|
<small style="color:#7f8c8d; margin-left: 30px;">
|
|
비활성화 시, 최대 대기 인원 제한이 적용되지 않습니다.
|
|
</small>
|
|
</div>
|
|
|
|
<div class="form-group" id="maxWaitingLimitGroup">
|
|
<label>최대 대기 인원</label>
|
|
<input type="number" id="maxWaitingLimit" class="form-control" min="0" required>
|
|
<small style="color:#7f8c8d;">최대 대기 등록 가능 인원 (0 = 무제한)</small>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer;">
|
|
<input type="checkbox" id="blockLastClassRegistration"
|
|
style="width: 20px; height: 20px; cursor: pointer;">
|
|
<span>마지막 교시 정원 초과 시 대기접수 차단</span>
|
|
</label>
|
|
<small style="color:#7f8c8d; margin-left: 30px;">
|
|
활성화 시, 마지막 교시의 대기 인원이 정원을 초과하면 더 이상 대기접수를 받지 않습니다.
|
|
</small>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer;">
|
|
<input type="checkbox" id="autoRegisterMember"
|
|
style="width: 20px; height: 20px; cursor: pointer;">
|
|
<span>대기 등록 시 자동 회원가입</span>
|
|
</label>
|
|
<small style="color:#7f8c8d; margin-left: 30px;">
|
|
활성화 시, 비회원이 대기 등록을 하면 자동으로 회원으로 등록됩니다.
|
|
</small>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="d-block font-weight-bold mb-2">출석 횟수 표시 기준</label>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="radio" name="attendanceCountType" id="countTypeDays"
|
|
value="days" onchange="toggleAttendanceSettings()">
|
|
<label class="form-check-label" for="countTypeDays">최근 N일 기준</label>
|
|
</div>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="radio" name="attendanceCountType"
|
|
id="countTypeMonthly" value="monthly" onchange="toggleAttendanceSettings()">
|
|
<label class="form-check-label" for="countTypeMonthly">이번 달 (월간) 기준</label>
|
|
</div>
|
|
|
|
<div id="attendanceLookbackDaysContainer"
|
|
style="margin-top: 10px; margin-left: 20px; display: none;">
|
|
<label>조회 기간 (일)</label>
|
|
<input type="number" id="attendanceLookbackDays" class="form-control"
|
|
style="width: 100px; display: inline-block; margin-left: 10px;" min="1" value="30">
|
|
<small class="form-text text-muted d-block mt-1">오늘을 포함한 최근 며칠간의 출석을 집계할지 설정합니다.</small>
|
|
</div>
|
|
</div>
|
|
|
|
<hr style="margin: 30px 0; border: none; border-top: 2px solid #ecf0f1;">
|
|
|
|
<h3 style="margin-bottom: 20px; color: #2c3e50;">대기현황판 표시 설정</h3>
|
|
|
|
<div class="form-group">
|
|
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer;">
|
|
<input type="checkbox" id="showWaitingNumber"
|
|
style="width: 20px; height: 20px; cursor: pointer;">
|
|
<span>대기번호 표시</span>
|
|
</label>
|
|
<small style="color:#7f8c8d; margin-left: 30px;">
|
|
대기번호(예: 대기 5번)를 현황판에 표시합니다.
|
|
</small>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer;">
|
|
<input type="checkbox" id="maskCustomerName"
|
|
style="width: 20px; height: 20px; cursor: pointer;">
|
|
<span>이름 마스킹 (홍O동)</span>
|
|
</label>
|
|
<small style="color:#7f8c8d; margin-left: 30px;">
|
|
이름을 마스킹하여 표시합니다 (예: 홍길동 → 홍O동).
|
|
</small>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>이름 표시 자릿수</label>
|
|
<input type="number" id="nameDisplayLength" class="form-control" min="0" max="10" value="0">
|
|
<small style="color:#7f8c8d;">
|
|
이름을 몇 글자까지 표시할지 설정합니다. (0 = 전체 표시, 1 = 첫 글자만, 2 = 두 글자만 등)
|
|
</small>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer;">
|
|
<input type="checkbox" id="showOrderNumber"
|
|
style="width: 20px; height: 20px; cursor: pointer;">
|
|
<span>순번 표시 (1번째, 2번째)</span>
|
|
</label>
|
|
<small style="color:#7f8c8d; margin-left: 30px;">
|
|
교시 내 순번(예: 1번째, 2번째)을 표시합니다.
|
|
</small>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>표시 순서</label>
|
|
<select id="boardDisplayOrder" class="form-control">
|
|
<option value="number,name,order">대기번호 → 이름 → 순번</option>
|
|
<option value="number,order,name">대기번호 → 순번 → 이름</option>
|
|
<option value="name,number,order">이름 → 대기번호 → 순번</option>
|
|
<option value="name,order,number">이름 → 순번 → 대기번호</option>
|
|
<option value="order,number,name">순번 → 대기번호 → 이름</option>
|
|
<option value="order,name,number">순번 → 이름 → 대기번호</option>
|
|
</select>
|
|
<small style="color:#7f8c8d;">현황판에서 정보를 표시할 순서를 선택합니다.</small>
|
|
</div>
|
|
|
|
<hr style="margin: 30px 0; border: none; border-top: 2px solid #ecf0f1;">
|
|
|
|
<h3 style="margin-bottom: 20px; color: #2c3e50;">폰트 및 스타일 설정</h3>
|
|
|
|
<div class="form-group">
|
|
<label class="d-block font-weight-bold mb-2">대기관리자 화면 (Manager)</label>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>글꼴 (Font)</label>
|
|
<select id="managerFontFamily" class="form-control">
|
|
<option value="Nanum Gothic">나눔고딕 (Nanum Gothic)</option>
|
|
<option value="Gowun Dodum">고운돋움 (Gowun Dodum)</option>
|
|
<option value="Noto Sans KR">노토 산스 (Noto Sans KR)</option>
|
|
<option value="Spoqa Han Sans Neo">스포카 한 산스 네오 (Spoqa Han Sans Neo)</option>
|
|
<option value="Malgun Gothic">맑은 고딕 (Microsoft)</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>글자 크기 (Size)</label>
|
|
<select id="managerFontSize" class="form-control">
|
|
<option value="14px">14px (작음)</option>
|
|
<option value="15px">15px (보통)</option>
|
|
<option value="16px">16px (조금 큼)</option>
|
|
<option value="18px">18px (큼)</option>
|
|
<option value="20px">20px (아주 큼)</option>
|
|
<option value="24px">24px (더 큼)</option>
|
|
<option value="28px">28px (매우 큼)</option>
|
|
<option value="30px">30px (최대)</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="form-row" style="margin-top: 15px;">
|
|
<div class="form-group">
|
|
<label>레이아웃 최대 너비 (px)</label>
|
|
<input type="number" id="managerMaxWidth" class="form-control" placeholder="기본값 (95%)"
|
|
min="800" step="10">
|
|
<small style="color:#7f8c8d;">
|
|
화면의 최대 너비를 픽셀(px) 단위로 설정합니다. 비워두면 화면 전체 너비(95%)를 사용합니다.
|
|
</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="d-block font-weight-bold mb-2">대기현황판 화면 (Board)</label>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>글꼴 (Font)</label>
|
|
<select id="boardFontFamily" class="form-control">
|
|
<option value="Nanum Gothic">나눔고딕 (Nanum Gothic)</option>
|
|
<option value="Gowun Dodum">고운돋움 (Gowun Dodum)</option>
|
|
<option value="Noto Sans KR">노토 산스 (Noto Sans KR)</option>
|
|
<option value="Spoqa Han Sans Neo">스포카 한 산스 네오 (Spoqa Han Sans Neo)</option>
|
|
<option value="Malgun Gothic">맑은 고딕 (Microsoft)</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>글자 크기 (Size)</label>
|
|
<select id="boardFontSize" class="form-control">
|
|
<option value="20px">20px (작음)</option>
|
|
<option value="24px">24px (보통)</option>
|
|
<option value="28px">28px (조금 큼)</option>
|
|
<option value="32px">32px (큼)</option>
|
|
<option value="36px">36px (아주 큼)</option>
|
|
<option value="40px">40px (초대형)</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="d-block font-weight-bold mb-2">대기접수 데스크 키패드</label>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>키패드 스타일</label>
|
|
<select id="keypadStyle" class="form-control">
|
|
<option value="modern">모던 (Modern) - 부드러운 그라데이션</option>
|
|
<option value="bold">진한 경계선 (Bold) - 명확한 구분</option>
|
|
<option value="dark">다크 모드 (Dark) - 검정 배경</option>
|
|
<option value="colorful">컬러풀 (Colorful) - 화려한 그라데이션</option>
|
|
</select>
|
|
<small style="color:#7f8c8d; margin-top: 5px; display: block;">
|
|
어르신들은 '진한 경계선' 스타일을 추천합니다.
|
|
</small>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>숫자 크기</label>
|
|
<select id="keypadFontSize" class="form-control">
|
|
<option value="small">작음 (32px)</option>
|
|
<option value="medium">보통 (38px)</option>
|
|
<option value="large">크게 (44px)</option>
|
|
<option value="xlarge">매우 크게 (52px)</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="d-block font-weight-bold mb-2">대기접수 완료 화면 설정</label>
|
|
<div class="form-group">
|
|
<label>모달 자동 닫힘 시간 (초)</label>
|
|
<input type="number" id="waitingModalTimeout" class="form-control" min="1" max="60"
|
|
value="5">
|
|
<small style="color:#7f8c8d;">대기접수 완료 모달이 자동으로 닫힐 때까지의 시간</small>
|
|
</div>
|
|
<div class="form-group">
|
|
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer;">
|
|
<input type="checkbox" id="showMemberNameInWaitingModal"
|
|
style="width: 20px; height: 20px;">
|
|
<span>회원 이름 표시 (예: 홍길동님)</span>
|
|
</label>
|
|
</div>
|
|
<div class="form-group">
|
|
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer;">
|
|
<input type="checkbox" id="showNewMemberTextInWaitingModal"
|
|
style="width: 20px; height: 20px;">
|
|
<span>신규회원 문구 표시 (예: 신규회원님)</span>
|
|
</label>
|
|
</div>
|
|
<div class="form-group">
|
|
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer;">
|
|
<input type="checkbox" id="enableWaitingVoiceAlert" style="width: 20px; height: 20px;">
|
|
<span>대기접수 완료 음성 안내 사용 (예: "1교시 대기 접수 되었습니다")</span>
|
|
</label>
|
|
<div style="margin-left: 30px; margin-top: 10px;">
|
|
<input type="text" id="waitingVoiceMessage" class="form-control"
|
|
placeholder="음성 안내 문구를 입력하세요 (비워두면 기본 문구 사용)" style="width: 100%;">
|
|
<small style="color: #666; display: block; margin-top: 5px;">
|
|
* 문구 미입력 시 "{클래스명} 대기 접수 되었습니다" 로 안내됩니다.
|
|
</small>
|
|
</div>
|
|
<div style="margin-left: 30px; margin-top: 10px;">
|
|
<label style="display: block; margin-bottom: 5px;">음성 목소리 선택</label>
|
|
<div style="display: flex; gap: 10px;">
|
|
<select id="waitingVoiceSelect" class="form-control" style="flex: 1;">
|
|
<option value="">기본 목소리</option>
|
|
</select>
|
|
<button type="button" id="testVoiceBtn" class="btn btn-secondary"
|
|
style="width: auto;">미리듣기</button>
|
|
</div>
|
|
</div>
|
|
<div style="margin-left: 30px; margin-top: 10px;">
|
|
<label style="display: block; margin-bottom: 5px;">음성 스타일</label>
|
|
<select id="waitingVoiceStyle" class="form-control" style="width: 100%;">
|
|
<option value="standard">기본 (보통 속도/톤)</option>
|
|
<option value="senior">어르신 추천 (느리고 또렷하게)</option>
|
|
<option value="soft">부드러움 (조금 느리고 부드럽게)</option>
|
|
<option value="calm">차분함 (조금 느리고 낮게)</option>
|
|
<option value="bright">활기참 (보통 속도, 조금 높게)</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<hr style="margin: 30px 0; border: none; border-top: 2px solid #ecf0f1;">
|
|
|
|
<div class="form-group">
|
|
<label>관리자 비밀번호</label>
|
|
<input type="password" id="adminPassword" class="form-control" required>
|
|
<small style="color:#7f8c8d;">매장 설정 변경 시 사용됩니다</small>
|
|
</div>
|
|
|
|
<button type="submit" class="btn btn-primary btn-lg">설정 저장</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 클래스 관리 탭 -->
|
|
<div id="classTab" class="tab-content">
|
|
<!-- 평일/주말 서브 탭 -->
|
|
<div class="tabs-container" style="margin-bottom: 20px;">
|
|
<button class="settings-tab active" onclick="switchClassTypeTab('weekday')">평일 클래스</button>
|
|
<button class="settings-tab" onclick="switchClassTypeTab('weekend')">주말 클래스</button>
|
|
</div>
|
|
|
|
<!-- 평일 클래스 -->
|
|
<div id="weekdayClassTab" class="class-type-content active">
|
|
<div class="card" style="margin-bottom:20px;">
|
|
<button class="btn btn-success" onclick="openAddClassModal('weekday')">+ 평일 클래스 추가</button>
|
|
</div>
|
|
|
|
<div class="class-list" id="weekdayClassList">
|
|
<div class="loading">
|
|
<div class="spinner"></div>
|
|
<p>로딩 중...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 주말 클래스 -->
|
|
<div id="weekendClassTab" class="class-type-content">
|
|
<div class="card" style="margin-bottom:20px;">
|
|
<button class="btn btn-success" onclick="openAddClassModal('weekend')">+ 주말 클래스 추가</button>
|
|
</div>
|
|
|
|
<div class="class-list" id="weekendClassList">
|
|
<div class="loading">
|
|
<div class="spinner"></div>
|
|
<p>로딩 중...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 클래스 추가/수정 모달 -->
|
|
<div id="classModal" class="modal">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h2 id="classModalTitle">클래스 추가</h2>
|
|
</div>
|
|
<form id="classForm" onsubmit="saveClass(event)">
|
|
<div class="form-group">
|
|
<label>클래스 타입</label>
|
|
<select id="classType" class="form-control" required>
|
|
<option value="weekday">평일 클래스</option>
|
|
<option value="weekend">주말 클래스</option>
|
|
<option value="all">전체 요일 클래스</option>
|
|
</select>
|
|
<small style="color:#7f8c8d; margin-top: 5px; display: block;">
|
|
평일: 월-금 운영 / 주말: 토-일 운영 / 전체: 모든 요일 운영
|
|
</small>
|
|
</div>
|
|
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>교시 번호</label>
|
|
<input type="number" id="classNumber" class="form-control" min="1" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>교시명</label>
|
|
<input type="text" id="className" class="form-control" placeholder="예: 1교시" required>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>시작 시간</label>
|
|
<input type="time" id="startTime" class="form-control" required step="60">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>종료 시간</label>
|
|
<input type="time" id="endTime" class="form-control" required step="60">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>최대 수용 인원</label>
|
|
<input type="number" id="maxCapacity" class="form-control" min="1" value="10" required>
|
|
</div>
|
|
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" onclick="closeModal('classModal')">취소</button>
|
|
<button type="submit" class="btn btn-primary">저장</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 알림 모달 -->
|
|
<div id="notificationModal" class="modal">
|
|
<div class="modal-content" style="max-width: 500px;">
|
|
<div class="modal-header" style="border-bottom: none; padding-bottom: 0;">
|
|
<h2 id="notificationTitle" style="text-align: center; width: 100%; margin: 0;">알림</h2>
|
|
</div>
|
|
<div class="modal-body" style="padding: 30px; text-align: center;">
|
|
<div style="font-size: 64px; margin-bottom: 20px;" id="notificationIcon">✅</div>
|
|
<p id="notificationMessage"
|
|
style="font-size: 24px; font-weight: 600; color: #2c3e50; line-height: 1.5; margin: 0;"></p>
|
|
</div>
|
|
<div class="modal-footer" style="border-top: none; padding-top: 0;">
|
|
<button type="button" class="btn btn-primary" onclick="closeNotificationModal()"
|
|
style="width: 100%; font-size: 20px; padding: 15px;">확인</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let currentClassId = null;
|
|
let classes = [];
|
|
let availableStores = [];
|
|
|
|
// Helper function to get headers with store ID
|
|
function getHeaders(additionalHeaders = {}) {
|
|
const headers = { ...additionalHeaders };
|
|
const storeId = localStorage.getItem('selected_store_id');
|
|
if (storeId) {
|
|
headers['X-Store-Id'] = storeId;
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
// 알림 모달 표시 함수
|
|
function showNotification(message, icon = '✅', title = '알림') {
|
|
document.getElementById('notificationTitle').textContent = title;
|
|
document.getElementById('notificationMessage').textContent = message;
|
|
document.getElementById('notificationIcon').textContent = icon;
|
|
document.getElementById('notificationModal').classList.add('active');
|
|
}
|
|
|
|
// 알림 모달 닫기
|
|
function closeNotificationModal() {
|
|
document.getElementById('notificationModal').classList.remove('active');
|
|
}
|
|
|
|
// ESC 키로 모달 닫기
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') {
|
|
const notificationModal = document.getElementById('notificationModal');
|
|
if (notificationModal.classList.contains('active')) {
|
|
closeNotificationModal();
|
|
}
|
|
}
|
|
});
|
|
|
|
function switchTab(tab) {
|
|
document.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active'));
|
|
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
|
|
|
|
if (tab === 'store') {
|
|
document.querySelectorAll('.settings-tab')[0].classList.add('active');
|
|
document.getElementById('storeTab').classList.add('active');
|
|
loadStoreSettings();
|
|
loadAvailableStores();
|
|
} else {
|
|
document.querySelectorAll('.settings-tab')[1].classList.add('active');
|
|
document.getElementById('classTab').classList.add('active');
|
|
loadClasses();
|
|
}
|
|
}
|
|
|
|
function switchClassTypeTab(classType) {
|
|
// 서브 탭 버튼 활성화 상태 변경
|
|
const classTab = document.getElementById('classTab');
|
|
const tabButtons = classTab.querySelectorAll('.tabs-container .settings-tab');
|
|
tabButtons.forEach(btn => btn.classList.remove('active'));
|
|
|
|
if (classType === 'weekday') {
|
|
tabButtons[0].classList.add('active');
|
|
document.getElementById('weekdayClassTab').classList.add('active');
|
|
document.getElementById('weekendClassTab').classList.remove('active');
|
|
} else {
|
|
tabButtons[1].classList.add('active');
|
|
document.getElementById('weekendClassTab').classList.add('active');
|
|
document.getElementById('weekdayClassTab').classList.remove('active');
|
|
}
|
|
}
|
|
|
|
async function loadAvailableStores() {
|
|
try {
|
|
const token = localStorage.getItem('access_token');
|
|
const response = await fetch('/api/stores/', {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
availableStores = await response.json();
|
|
const currentStoreId = localStorage.getItem('selected_store_id');
|
|
|
|
const select = document.getElementById('cloneSourceStore');
|
|
select.innerHTML = '<option value="">복제할 매장 선택</option>';
|
|
|
|
// 현재 매장을 제외한 다른 매장들만 표시
|
|
availableStores
|
|
.filter(store => store.id !== parseInt(currentStoreId) && store.is_active)
|
|
.forEach(store => {
|
|
const option = document.createElement('option');
|
|
option.value = store.id;
|
|
option.textContent = `${store.name} (${store.code})`;
|
|
select.appendChild(option);
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('매장 목록 조회 실패:', error);
|
|
}
|
|
}
|
|
|
|
async function cloneSettings() {
|
|
const sourceStoreId = document.getElementById('cloneSourceStore').value;
|
|
|
|
if (!sourceStoreId) {
|
|
showNotification('복제할 매장을 선택해주세요.', '⚠️');
|
|
return;
|
|
}
|
|
|
|
const sourceStore = availableStores.find(s => s.id === parseInt(sourceStoreId));
|
|
const confirmMsg = `${sourceStore.name}의 설정을 복제하시겠습니까?\n\n현재 매장의 모든 설정값(매장명 제외)이 덮어씌워집니다.`;
|
|
|
|
if (!confirm(confirmMsg)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/store/clone/${sourceStoreId}`, {
|
|
method: 'POST',
|
|
headers: getHeaders({ 'Content-Type': 'application/json' })
|
|
});
|
|
|
|
if (response.ok) {
|
|
showNotification('설정이 성공적으로 복제되었습니다!', '✅');
|
|
loadStoreSettings(); // 복제된 설정 다시 로드
|
|
} else {
|
|
const error = await response.json();
|
|
showNotification('복제 실패: ' + (error.detail || '알 수 없는 오류'), '❌', '오류');
|
|
}
|
|
} catch (error) {
|
|
console.error('설정 복제 실패:', error);
|
|
showNotification('복제 중 오류가 발생했습니다.', '❌', '오류');
|
|
}
|
|
}
|
|
|
|
function toggleClosingAction(isChecked) {
|
|
const container = document.getElementById('closingActionContainer');
|
|
if (isChecked) {
|
|
container.style.display = 'block';
|
|
} else {
|
|
container.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
async function loadStoreSettings() {
|
|
try {
|
|
const response = await fetch('/api/store/', {
|
|
headers: getHeaders()
|
|
});
|
|
const settings = await response.json();
|
|
|
|
document.getElementById('storeName').value = settings.store_name;
|
|
document.getElementById('displayClassesCount').value = settings.display_classes_count;
|
|
document.getElementById('rowsPerClass').value = settings.rows_per_class;
|
|
document.getElementById('listDirection').value = settings.list_direction;
|
|
document.getElementById('businessDayStart').value = settings.business_day_start !== undefined ? settings.business_day_start : 5;
|
|
|
|
// 개점 설정
|
|
const openingRule = settings.daily_opening_rule || 'strict';
|
|
if (openingRule === 'flexible') {
|
|
document.getElementById('openingRuleFlexible').checked = true;
|
|
} else {
|
|
document.getElementById('openingRuleStrict').checked = true;
|
|
}
|
|
|
|
// 자동 마감 설정
|
|
const autoClosingCheckbox = document.getElementById('autoClosing');
|
|
autoClosingCheckbox.checked = settings.auto_closing !== false; // Default true
|
|
|
|
// 마감 처리 방식 설정
|
|
const closingAction = settings.closing_action || 'reset';
|
|
if (closingAction === 'attended') {
|
|
document.getElementById('actionAttended').checked = true;
|
|
} else {
|
|
document.getElementById('actionReset').checked = true;
|
|
}
|
|
|
|
// UI 초기 상태 설정
|
|
toggleClosingAction(autoClosingCheckbox.checked);
|
|
|
|
// 이벤트 리스너 추가
|
|
autoClosingCheckbox.addEventListener('change', function () {
|
|
toggleClosingAction(this.checked);
|
|
});
|
|
|
|
// 현황판 설정
|
|
document.getElementById('useMaxWaitingLimit').checked = settings.use_max_waiting_limit !== undefined ? settings.use_max_waiting_limit : true;
|
|
document.getElementById('maxWaitingLimit').value = settings.max_waiting_limit || 50;
|
|
document.getElementById('blockLastClassRegistration').checked = settings.block_last_class_registration || false;
|
|
document.getElementById('autoRegisterMember').checked = settings.auto_register_member || false;
|
|
document.getElementById('adminPassword').value = settings.admin_password;
|
|
|
|
// 대기현황판 표시 설정
|
|
document.getElementById('showWaitingNumber').checked = settings.show_waiting_number !== undefined ? settings.show_waiting_number : true;
|
|
document.getElementById('maskCustomerName').checked = settings.mask_customer_name || false;
|
|
document.getElementById('nameDisplayLength').value = settings.name_display_length || 0;
|
|
document.getElementById('showOrderNumber').checked = settings.show_order_number !== undefined ? settings.show_order_number : true;
|
|
document.getElementById('boardDisplayOrder').value = settings.board_display_order || 'number,name,order';
|
|
|
|
// 출석 횟수 설정
|
|
const countType = settings.attendance_count_type || 'days';
|
|
if (countType === 'monthly') {
|
|
document.getElementById('countTypeMonthly').checked = true;
|
|
} else {
|
|
document.getElementById('countTypeDays').checked = true;
|
|
}
|
|
document.getElementById('attendanceLookbackDays').value = settings.attendance_lookback_days || 30;
|
|
toggleAttendanceSettings();
|
|
|
|
// 폰트 설정
|
|
document.getElementById('managerFontFamily').value = settings.manager_font_family || 'Nanum Gothic';
|
|
document.getElementById('managerFontSize').value = settings.manager_font_size || '15px';
|
|
document.getElementById('managerMaxWidth').value = settings.waiting_manager_max_width || '';
|
|
document.getElementById('boardFontFamily').value = settings.board_font_family || 'Nanum Gothic';
|
|
document.getElementById('boardFontSize').value = settings.board_font_size || '24px';
|
|
|
|
// 키패드 설정
|
|
document.getElementById('keypadStyle').value = settings.keypad_style || 'modern';
|
|
if (settings.keypad_font_size) {
|
|
document.getElementById('keypadFontSize').value = settings.keypad_font_size;
|
|
}
|
|
|
|
// 대기접수 완료 모달 설정
|
|
if (settings.waiting_modal_timeout) {
|
|
document.getElementById('waitingModalTimeout').value = settings.waiting_modal_timeout;
|
|
}
|
|
|
|
// 불리언 값 처리 (undefined인 경우 true로 기본값 설정되는 항목 주의)
|
|
document.getElementById('showMemberNameInWaitingModal').checked =
|
|
(settings.show_member_name_in_waiting_modal !== undefined && settings.show_member_name_in_waiting_modal !== null) ? settings.show_member_name_in_waiting_modal : true;
|
|
|
|
document.getElementById('showNewMemberTextInWaitingModal').checked =
|
|
(settings.show_new_member_text_in_waiting_modal !== undefined && settings.show_new_member_text_in_waiting_modal !== null) ? settings.show_new_member_text_in_waiting_modal : true;
|
|
|
|
document.getElementById('enableWaitingVoiceAlert').checked =
|
|
(settings.enable_waiting_voice_alert !== undefined && settings.enable_waiting_voice_alert !== null) ? settings.enable_waiting_voice_alert : false;
|
|
|
|
document.getElementById('waitingVoiceMessage').value = settings.waiting_voice_message || '';
|
|
|
|
// 음성 목록 로드 및 선택
|
|
if (window.speechSynthesis) {
|
|
const voiceSelect = document.getElementById('waitingVoiceSelect');
|
|
let voices = [];
|
|
|
|
function populateVoices() {
|
|
voices = window.speechSynthesis.getVoices().filter(voice => voice.lang.startsWith('ko'));
|
|
|
|
// 기존 옵션 유지 (기본 목소리)
|
|
voiceSelect.innerHTML = '<option value="">기본 목소리</option>';
|
|
|
|
voices.forEach(voice => {
|
|
const option = document.createElement('option');
|
|
option.value = voice.name;
|
|
option.textContent = `${voice.name} (${voice.lang})`;
|
|
voiceSelect.appendChild(option);
|
|
});
|
|
|
|
// 저장된 목소리 선택
|
|
if (settings.waiting_voice_name) {
|
|
voiceSelect.value = settings.waiting_voice_name;
|
|
}
|
|
}
|
|
|
|
populateVoices();
|
|
if (window.speechSynthesis.onvoiceschanged !== undefined) {
|
|
window.speechSynthesis.onvoiceschanged = populateVoices;
|
|
}
|
|
|
|
// 스타일 프리셋 로직
|
|
const styleSelect = document.getElementById('waitingVoiceStyle');
|
|
let currentRate = settings.waiting_voice_rate || 1.0;
|
|
let currentPitch = settings.waiting_voice_pitch || 1.0;
|
|
|
|
// 저장된 rate/pitch로 스타일 추정하여 선택
|
|
// Floating point comparison needs to be careful, but checking simple equality for presets is fine
|
|
if (currentRate === 0.8 && currentPitch === 1.1) styleSelect.value = 'senior';
|
|
else if (currentRate === 0.9 && currentPitch === 0.9) styleSelect.value = 'calm';
|
|
else if (currentRate === 0.9 && currentPitch === 0.95) styleSelect.value = 'soft';
|
|
else if (currentRate === 1.1 && currentPitch === 1.2) styleSelect.value = 'bright';
|
|
else styleSelect.value = 'standard';
|
|
|
|
styleSelect.onchange = function () {
|
|
const style = this.value;
|
|
if (style === 'senior') { currentRate = 0.8; currentPitch = 1.1; } // Senior: Slower, slightly higher pitch for clarity
|
|
else if (style === 'calm') { currentRate = 0.9; currentPitch = 0.9; }
|
|
else if (style === 'soft') { currentRate = 0.9; currentPitch = 0.95; } // Soft: Slower, slightly lower pitch
|
|
else if (style === 'bright') { currentRate = 1.1; currentPitch = 1.2; }
|
|
else { currentRate = 1.0; currentPitch = 1.0; } // standard
|
|
};
|
|
|
|
// 미리듣기 버튼 이벤트
|
|
document.getElementById('testVoiceBtn').onclick = function () {
|
|
const message = document.getElementById('waitingVoiceMessage').value || "1교시 대기 접수 되었습니다";
|
|
const selectedVoiceName = voiceSelect.value;
|
|
|
|
window.speechSynthesis.cancel();
|
|
const utterance = new SpeechSynthesisUtterance(message);
|
|
utterance.lang = 'ko-KR';
|
|
utterance.rate = currentRate;
|
|
utterance.pitch = currentPitch;
|
|
|
|
if (selectedVoiceName) {
|
|
const selectedVoice = voices.find(voice => voice.name === selectedVoiceName);
|
|
if (selectedVoice) {
|
|
utterance.voice = selectedVoice;
|
|
}
|
|
}
|
|
|
|
window.speechSynthesis.speak(utterance);
|
|
};
|
|
}
|
|
|
|
// 최대 대기 인원 입력 필드 활성화/비활성화
|
|
toggleMaxWaitingLimitInput();
|
|
|
|
} catch (error) {
|
|
console.error('설정 조회 실패:', error);
|
|
}
|
|
}
|
|
|
|
function toggleAttendanceSettings() {
|
|
const isMonthly = document.getElementById('countTypeMonthly').checked;
|
|
const container = document.getElementById('attendanceLookbackDaysContainer');
|
|
if (isMonthly) {
|
|
container.style.display = 'none';
|
|
} else {
|
|
container.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
async function saveStoreSettings(event) {
|
|
event.preventDefault();
|
|
|
|
const storeName = document.getElementById('storeName').value.trim();
|
|
const displayCount = document.getElementById('displayClassesCount').value;
|
|
const rowsPerClass = document.getElementById('rowsPerClass').value;
|
|
const listDirection = document.getElementById('listDirection').value;
|
|
const businessDayStart = document.getElementById('businessDayStart').value;
|
|
const autoClosing = document.getElementById('autoClosing').checked;
|
|
const closingAction = document.querySelector('input[name="closingAction"]:checked').value;
|
|
const useMaxWaitingLimit = document.getElementById('useMaxWaitingLimit').checked;
|
|
const maxWaitingLimit = document.getElementById('maxWaitingLimit').value;
|
|
const blockLastClassRegistration = document.getElementById('blockLastClassRegistration').checked;
|
|
const autoRegisterMember = document.getElementById('autoRegisterMember').checked;
|
|
const adminPassword = document.getElementById('adminPassword').value;
|
|
const showWaitingNumber = document.getElementById('showWaitingNumber').checked;
|
|
const maskCustomerName = document.getElementById('maskCustomerName').checked;
|
|
const nameDisplayLength = document.getElementById('nameDisplayLength').value;
|
|
const showOrderNumber = document.getElementById('showOrderNumber').checked;
|
|
const boardDisplayOrder = document.getElementById('boardDisplayOrder').value;
|
|
|
|
// 출석 횟수 설정
|
|
const attendanceCountType = document.querySelector('input[name="attendanceCountType"]:checked').value;
|
|
const attendanceLookbackDays = document.getElementById('attendanceLookbackDays').value;
|
|
|
|
// 폰트 설정
|
|
const managerFontFamily = document.getElementById('managerFontFamily').value;
|
|
const managerFontSize = document.getElementById('managerFontSize').value;
|
|
const boardFontFamily = document.getElementById('boardFontFamily').value;
|
|
const boardFontSize = document.getElementById('boardFontSize').value;
|
|
|
|
// 키패드 설정
|
|
const keypadStyle = document.getElementById('keypadStyle').value;
|
|
const keypadFontSize = document.getElementById('keypadFontSize').value;
|
|
|
|
const settings = {
|
|
store_name: storeName,
|
|
display_classes_count: parseInt(displayCount),
|
|
rows_per_class: parseInt(rowsPerClass),
|
|
list_direction: listDirection,
|
|
business_day_start: parseInt(businessDayStart),
|
|
auto_closing: autoClosing,
|
|
closing_action: closingAction,
|
|
use_max_waiting_limit: useMaxWaitingLimit,
|
|
max_waiting_limit: parseInt(maxWaitingLimit),
|
|
block_last_class_registration: blockLastClassRegistration,
|
|
auto_register_member: autoRegisterMember,
|
|
admin_password: adminPassword,
|
|
show_waiting_number: showWaitingNumber,
|
|
mask_customer_name: maskCustomerName,
|
|
name_display_length: parseInt(nameDisplayLength),
|
|
show_order_number: showOrderNumber,
|
|
board_display_order: boardDisplayOrder,
|
|
attendance_count_type: attendanceCountType,
|
|
attendance_lookback_days: parseInt(attendanceLookbackDays),
|
|
// 폰트 설정 추가
|
|
manager_font_family: managerFontFamily,
|
|
manager_font_size: managerFontSize,
|
|
waiting_manager_max_width: document.getElementById('managerMaxWidth').value ? parseInt(document.getElementById('managerMaxWidth').value) : null,
|
|
board_font_family: boardFontFamily,
|
|
board_font_size: boardFontSize,
|
|
// 키패드 설정 추가
|
|
keypad_style: keypadStyle,
|
|
keypad_font_size: keypadFontSize,
|
|
|
|
// 대기접수 완료 모달 설정
|
|
waiting_modal_timeout: parseInt(document.getElementById('waitingModalTimeout').value),
|
|
show_member_name_in_waiting_modal: document.getElementById('showMemberNameInWaitingModal').checked,
|
|
show_new_member_text_in_waiting_modal: document.getElementById('showNewMemberTextInWaitingModal').checked,
|
|
enable_waiting_voice_alert: document.getElementById('enableWaitingVoiceAlert').checked,
|
|
waiting_voice_message: document.getElementById('waitingVoiceMessage').value,
|
|
waiting_voice_name: document.getElementById('waitingVoiceSelect').value,
|
|
// 스타일 선택값에서 rate/pitch 도출
|
|
waiting_voice_rate: (function () {
|
|
const style = document.getElementById('waitingVoiceStyle').value;
|
|
if (style === 'senior') return 0.8;
|
|
if (style === 'calm') return 0.9;
|
|
if (style === 'soft') return 0.9;
|
|
if (style === 'bright') return 1.1;
|
|
return 1.0;
|
|
})(),
|
|
waiting_voice_pitch: (function () {
|
|
const style = document.getElementById('waitingVoiceStyle').value;
|
|
if (style === 'senior') return 1.1;
|
|
if (style === 'calm') return 0.9;
|
|
if (style === 'soft') return 0.95;
|
|
if (style === 'bright') return 1.2;
|
|
return 1.0;
|
|
})(),
|
|
|
|
// 개점 설정
|
|
daily_opening_rule: document.querySelector('input[name="dailyOpeningRule"]:checked').value
|
|
};
|
|
|
|
try {
|
|
const response = await fetch('/api/store/', {
|
|
method: 'PUT',
|
|
headers: getHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify(settings)
|
|
});
|
|
|
|
if (response.ok) {
|
|
showNotification('설정이 저장되었습니다.', '✅');
|
|
} else {
|
|
const error = await response.json();
|
|
showNotification(error.detail || '저장에 실패했습니다.', '❌', '오류');
|
|
}
|
|
} catch (error) {
|
|
console.error('저장 실패:', error);
|
|
showNotification('저장 중 오류가 발생했습니다.', '❌', '오류');
|
|
}
|
|
}
|
|
|
|
async function loadClasses() {
|
|
const weekdayList = document.getElementById('weekdayClassList');
|
|
const weekendList = document.getElementById('weekendClassList');
|
|
weekdayList.innerHTML = '<div class="loading"><div class="spinner"></div><p>로딩 중...</p></div>';
|
|
weekendList.innerHTML = '<div class="loading"><div class="spinner"></div><p>로딩 중...</p></div>';
|
|
|
|
try {
|
|
const response = await fetch('/api/classes/?include_inactive=true');
|
|
classes = await response.json();
|
|
|
|
renderClasses();
|
|
} catch (error) {
|
|
console.error('클래스 조회 실패:', error);
|
|
weekdayList.innerHTML = '<div class="empty-state"><p>데이터 로딩 실패</p></div>';
|
|
weekendList.innerHTML = '<div class="empty-state"><p>데이터 로딩 실패</p></div>';
|
|
}
|
|
}
|
|
|
|
function renderClasses() {
|
|
const weekdayList = document.getElementById('weekdayClassList');
|
|
const weekendList = document.getElementById('weekendClassList');
|
|
|
|
// 평일 및 주말 클래스 분리 (각 타입별로 명확히 구분)
|
|
const weekdayClasses = classes.filter(cls => cls.class_type === 'weekday');
|
|
const weekendClasses = classes.filter(cls => cls.class_type === 'weekend');
|
|
const allClasses = classes.filter(cls => cls.class_type === 'all');
|
|
|
|
// 평일 클래스 렌더링
|
|
weekdayList.innerHTML = '';
|
|
if (weekdayClasses.length === 0 && allClasses.length === 0) {
|
|
weekdayList.innerHTML = '<div class="empty-state"><div class="icon">📚</div><p>등록된 평일 클래스가 없습니다</p></div>';
|
|
} else {
|
|
weekdayClasses.forEach(cls => {
|
|
weekdayList.appendChild(createClassItem(cls));
|
|
});
|
|
// all 타입 클래스는 회색으로 표시
|
|
allClasses.forEach(cls => {
|
|
weekdayList.appendChild(createClassItem(cls, true));
|
|
});
|
|
}
|
|
|
|
// 주말 클래스 렌더링
|
|
weekendList.innerHTML = '';
|
|
if (weekendClasses.length === 0 && allClasses.length === 0) {
|
|
weekendList.innerHTML = '<div class="empty-state"><div class="icon">📚</div><p>등록된 주말 클래스가 없습니다</p></div>';
|
|
} else {
|
|
weekendClasses.forEach(cls => {
|
|
weekendList.appendChild(createClassItem(cls));
|
|
});
|
|
// all 타입 클래스는 회색으로 표시
|
|
allClasses.forEach(cls => {
|
|
weekendList.appendChild(createClassItem(cls, true));
|
|
});
|
|
}
|
|
}
|
|
|
|
function createClassItem(cls, isAllType = false) {
|
|
const item = document.createElement('div');
|
|
item.className = 'class-item';
|
|
|
|
const inactiveBadge = !cls.is_active ? '<span class="inactive-badge">비활성</span>' : '';
|
|
const classTypeLabel = cls.class_type === 'weekday' ? '평일 전용' : cls.class_type === 'weekend' ? '주말 전용' : '전체 요일';
|
|
|
|
// all 타입 클래스는 회색 배경으로 표시
|
|
const allTypeStyle = isAllType ? 'opacity: 0.6; background: #f0f0f0;' : '';
|
|
const allTypeBadge = isAllType ? '<span style="background: #95a5a6; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-left: 8px;">전체 요일</span>' : '';
|
|
|
|
item.innerHTML = `
|
|
<div class="class-number-badge" style="${isAllType ? 'background: linear-gradient(135deg, #95a5a6, #7f8c8d);' : ''}">
|
|
${cls.class_number}
|
|
</div>
|
|
<div class="class-item-info">
|
|
<div class="title">
|
|
${cls.class_name}${inactiveBadge}${allTypeBadge}
|
|
</div>
|
|
<div class="details">
|
|
<div class="detail-item">
|
|
<div class="detail-label">수업 시간</div>
|
|
<div class="detail-value time">${cls.start_time.substring(0, 5)} - ${cls.end_time.substring(0, 5)}</div>
|
|
</div>
|
|
<div class="detail-item">
|
|
<div class="detail-label">클래스 타입</div>
|
|
<div class="detail-value" style="color: ${isAllType ? '#95a5a6' : '#9b59b6'}; font-size: 18px;">${classTypeLabel}</div>
|
|
</div>
|
|
<div class="detail-item">
|
|
<div class="detail-label">최대 인원</div>
|
|
<div class="detail-value capacity">${cls.max_capacity}명</div>
|
|
</div>
|
|
<div class="detail-item">
|
|
<div class="detail-label">현재 대기</div>
|
|
<div class="detail-value waiting">${cls.current_count || 0}명</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="class-item-actions">
|
|
<button class="btn btn-sm btn-primary" onclick="openEditClassModal(${cls.id})">수정</button>
|
|
${cls.is_active ?
|
|
`<button class="btn btn-sm btn-warning" onclick="toggleClassStatus(${cls.id}, false)">비활성화</button>` :
|
|
`<button class="btn btn-sm btn-success" onclick="toggleClassStatus(${cls.id}, true)">활성화</button>`
|
|
}
|
|
</div>
|
|
`;
|
|
return item;
|
|
}
|
|
|
|
function openAddClassModal(classType) {
|
|
currentClassId = null;
|
|
|
|
// class_type 설정
|
|
document.getElementById('classType').value = classType;
|
|
|
|
// 같은 타입 또는 all 타입의 클래스들을 필터링하여 다음 번호 계산
|
|
const relevantClasses = classes.filter(cls =>
|
|
cls.class_type === classType || cls.class_type === 'all'
|
|
);
|
|
const nextNumber = relevantClasses.length > 0
|
|
? Math.max(...relevantClasses.map(cls => cls.class_number)) + 1
|
|
: 1;
|
|
|
|
const classTypeLabel = classType === 'weekday' ? '평일' : '주말';
|
|
document.getElementById('classModalTitle').textContent = `${classTypeLabel} 클래스 추가`;
|
|
document.getElementById('classNumber').value = nextNumber;
|
|
document.getElementById('className').value = `${nextNumber}교시`;
|
|
document.getElementById('startTime').value = '';
|
|
document.getElementById('endTime').value = '';
|
|
document.getElementById('maxCapacity').value = 10;
|
|
|
|
document.getElementById('classModal').classList.add('active');
|
|
}
|
|
|
|
function openEditClassModal(classId) {
|
|
const cls = classes.find(c => c.id === classId);
|
|
if (!cls) return;
|
|
|
|
currentClassId = classId;
|
|
|
|
// class_type 설정
|
|
const classType = cls.class_type || 'weekday';
|
|
document.getElementById('classType').value = classType;
|
|
|
|
const classTypeLabel = classType === 'weekday' ? '평일' : classType === 'weekend' ? '주말' : '전체';
|
|
document.getElementById('classModalTitle').textContent = `${classTypeLabel} 클래스 수정`;
|
|
document.getElementById('classNumber').value = cls.class_number;
|
|
document.getElementById('className').value = cls.class_name;
|
|
document.getElementById('startTime').value = cls.start_time.substring(0, 5);
|
|
document.getElementById('endTime').value = cls.end_time.substring(0, 5);
|
|
document.getElementById('maxCapacity').value = cls.max_capacity;
|
|
|
|
document.getElementById('classModal').classList.add('active');
|
|
}
|
|
|
|
async function saveClass(event) {
|
|
event.preventDefault();
|
|
|
|
const classType = document.getElementById('classType').value;
|
|
|
|
// weekday_schedule 기본값 설정 (class_type에 따라)
|
|
let weekday_schedule;
|
|
if (classType === 'weekday') {
|
|
weekday_schedule = { mon: true, tue: true, wed: true, thu: true, fri: true, sat: false, sun: false };
|
|
} else if (classType === 'weekend') {
|
|
weekday_schedule = { mon: false, tue: false, wed: false, thu: false, fri: false, sat: true, sun: true };
|
|
} else {
|
|
// all 타입
|
|
weekday_schedule = { mon: true, tue: true, wed: true, thu: true, fri: true, sat: true, sun: true };
|
|
}
|
|
|
|
const classData = {
|
|
class_number: parseInt(document.getElementById('classNumber').value),
|
|
class_name: document.getElementById('className').value.trim(),
|
|
start_time: document.getElementById('startTime').value + ':00',
|
|
end_time: document.getElementById('endTime').value + ':00',
|
|
max_capacity: parseInt(document.getElementById('maxCapacity').value),
|
|
is_active: true,
|
|
class_type: classType,
|
|
weekday_schedule: weekday_schedule
|
|
};
|
|
|
|
try {
|
|
let response;
|
|
if (currentClassId) {
|
|
response = await fetch(`/api/classes/${currentClassId}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(classData)
|
|
});
|
|
} else {
|
|
response = await fetch('/api/classes/', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(classData)
|
|
});
|
|
}
|
|
|
|
if (response.ok) {
|
|
showNotification('저장되었습니다.', '✅');
|
|
closeModal('classModal');
|
|
loadClasses();
|
|
} else {
|
|
const error = await response.json();
|
|
showNotification(error.detail || '저장에 실패했습니다.', '❌', '오류');
|
|
}
|
|
} catch (error) {
|
|
console.error('저장 실패:', error);
|
|
showNotification('저장 중 오류가 발생했습니다.', '❌', '오류');
|
|
}
|
|
}
|
|
|
|
async function toggleClassStatus(classId, activate) {
|
|
const cls = classes.find(c => c.id === classId);
|
|
const action = activate ? '활성화' : '비활성화';
|
|
|
|
if (!confirm(`${cls.class_name}을(를) ${action}하시겠습니까?`)) return;
|
|
|
|
try {
|
|
const endpoint = activate ? `/api/classes/${classId}/activate` : `/api/classes/${classId}`;
|
|
const method = activate ? 'POST' : 'DELETE';
|
|
|
|
const response = await fetch(endpoint, { method });
|
|
|
|
if (response.ok) {
|
|
showNotification(`${action}되었습니다.`, '✅');
|
|
loadClasses();
|
|
} else {
|
|
const error = await response.json();
|
|
showNotification(error.detail || `${action}에 실패했습니다.`, '❌', '오류');
|
|
}
|
|
} catch (error) {
|
|
console.error('상태 변경 실패:', error);
|
|
showNotification('처리 중 오류가 발생했습니다.', '❌', '오류');
|
|
}
|
|
}
|
|
|
|
function closeModal(modalId) {
|
|
document.getElementById(modalId).classList.remove('active');
|
|
}
|
|
|
|
// URL 파라미터에서 매장 정보 가져오기
|
|
async function checkUrlStoreParam() {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const storeParam = urlParams.get('store');
|
|
|
|
if (storeParam) {
|
|
try {
|
|
const response = await fetch(`/api/stores/code/${storeParam}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
|
}
|
|
});
|
|
if (response.ok) {
|
|
const store = await response.json();
|
|
localStorage.setItem('selected_store_id', store.id);
|
|
localStorage.setItem('selected_store_name', store.name);
|
|
console.log(`URL 매장 파라미터 적용: ${store.name} (코드: ${storeParam})`);
|
|
} else {
|
|
console.error('매장 코드를 찾을 수 없습니다:', storeParam);
|
|
}
|
|
} catch (e) {
|
|
console.error('매장 정보 조회 실패:', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 최대 대기 인원 입력 필드 활성화/비활성화
|
|
function toggleMaxWaitingLimitInput() {
|
|
const useLimit = document.getElementById('useMaxWaitingLimit').checked;
|
|
const limitGroup = document.getElementById('maxWaitingLimitGroup');
|
|
const limitInput = document.getElementById('maxWaitingLimit');
|
|
|
|
if (useLimit) {
|
|
limitGroup.style.opacity = '1';
|
|
limitInput.disabled = false;
|
|
} else {
|
|
limitGroup.style.opacity = '0.5';
|
|
limitInput.disabled = true;
|
|
}
|
|
}
|
|
|
|
// 체크박스 변경 시 입력 필드 활성화/비활성화
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const checkbox = document.getElementById('useMaxWaitingLimit');
|
|
if (checkbox) {
|
|
checkbox.addEventListener('change', toggleMaxWaitingLimitInput);
|
|
}
|
|
});
|
|
|
|
// 초기 로드
|
|
async function init() {
|
|
await checkUrlStoreParam();
|
|
loadStoreSettings();
|
|
loadAvailableStores();
|
|
}
|
|
|
|
init();
|
|
</script>
|
|
</body>
|
|
|
|
</html> |