first commit 2
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
if (!defined('_GNUBOARD_')) exit;
|
||||
|
||||
// 이 파일은 상담 예약 관련 팝업들을 한번에 쉽게 포함하기 위해 사용됩니다.
|
||||
|
||||
// 현재 파일의 경로를 기준으로 팝업 파일들의 경로를 정의합니다.
|
||||
$consultant_components_path = dirname(__FILE__);
|
||||
|
||||
// 상담 예약 신청 팝업 포함
|
||||
include_once($consultant_components_path . '/reservation_popup.php');
|
||||
|
||||
// 예약 확인/취소 팝업 포함
|
||||
include_once($consultant_components_path . '/reservation_check.php');
|
||||
?>
|
||||
@@ -0,0 +1,401 @@
|
||||
<?php
|
||||
if (isset($_POST['action'])) {
|
||||
include_once('../_common_con.php'); // 💡 [수정] 컴포넌트용 공통 파일 포함
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
// 예약 조회
|
||||
if ($action === 'find_reservations') {
|
||||
$customer_name = trim($_POST['customer_name'] ?? '');
|
||||
$customer_phone = trim($_POST['customer_phone'] ?? '');
|
||||
|
||||
if (!$customer_name || !$customer_phone) {
|
||||
echo json_encode(['success' => false, 'message' => '이름과 연락처를 모두 입력해주세요.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 💡 [수정] 리소스 정보를 함께 조회하기 위해 LEFT JOIN 추가
|
||||
$sql = " SELECT r.*, res.name as resource_name
|
||||
FROM consultant_reservations r
|
||||
LEFT JOIN consultant_resources res ON r.resource_id = res.id
|
||||
WHERE r.customer_name = '" . sql_real_escape_string($customer_name) . "'
|
||||
AND r.customer_phone = '" . sql_real_escape_string($customer_phone) . "'
|
||||
AND r.is_deleted = 0
|
||||
ORDER BY r.reservation_date DESC, r.reservation_time DESC ";
|
||||
|
||||
$result = sql_query($sql);
|
||||
$reservations = [];
|
||||
$cancel_deadline_hours = (int)consultant_get_config('cancel_deadline_hours', 24);
|
||||
|
||||
while ($row = sql_fetch_array($result)) {
|
||||
$reservation_timestamp = strtotime($row['reservation_date'] . ' ' . $row['reservation_time']);
|
||||
$cancellable_until = $reservation_timestamp - ($cancel_deadline_hours * 3600);
|
||||
|
||||
$row['is_cancellable'] = (time() < $cancellable_until && in_array($row['status'], ['payment_pending', 'reserved']));
|
||||
$reservations[] = $row;
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'data' => $reservations]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 예약 취소
|
||||
if ($action === 'cancel_reservation') {
|
||||
$reservation_id = (int)($_POST['reservation_id'] ?? 0);
|
||||
$customer_name = trim($_POST['customer_name'] ?? '');
|
||||
$customer_phone = trim($_POST['customer_phone'] ?? '');
|
||||
|
||||
if (!$reservation_id || !$customer_name || !$customer_phone) {
|
||||
echo json_encode(['success' => false, 'message' => '필수 정보가 누락되었습니다.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$sql = " SELECT * FROM consultant_reservations
|
||||
WHERE id = '{$reservation_id}'
|
||||
AND customer_name = '" . sql_real_escape_string($customer_name) . "'
|
||||
AND customer_phone = '" . sql_real_escape_string($customer_phone) . "'
|
||||
AND is_deleted = 0 ";
|
||||
$reservation = sql_fetch($sql);
|
||||
|
||||
if (!$reservation) {
|
||||
echo json_encode(['success' => false, 'message' => '예약 정보를 찾을 수 없거나, 입력하신 정보와 일치하지 않습니다.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$cancel_deadline_hours = (int)consultant_get_config('cancel_deadline_hours', 24);
|
||||
$reservation_timestamp = strtotime($reservation['reservation_date'] . ' ' . $reservation['reservation_time']);
|
||||
$cancellable_until = $reservation_timestamp - ($cancel_deadline_hours * 3600);
|
||||
|
||||
if (time() >= $cancellable_until) {
|
||||
echo json_encode(['success' => false, 'message' => '예약 취소 가능 시간이 지났습니다. 관리자에게 문의해주세요.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!in_array($reservation['status'], ['payment_pending', 'reserved'])) {
|
||||
echo json_encode(['success' => false, 'message' => '이미 처리되었거나 취소된 예약입니다.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$sql_update = " UPDATE consultant_reservations
|
||||
SET status = 'cancelled', updated_at = NOW()
|
||||
WHERE id = '{$reservation_id}' ";
|
||||
|
||||
if (sql_query($sql_update)) {
|
||||
consultant_log("고객 예약 취소: ID {$reservation_id} (고객: {$customer_name})");
|
||||
echo json_encode(['success' => true, 'message' => '예약이 성공적으로 취소되었습니다.']);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => '예약 취소 중 오류가 발생했습니다. 다시 시도해주세요.']);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
if (!defined('_GNUBOARD_')) exit;
|
||||
|
||||
include_once(G5_ADMIN_PATH . '/consultant_manage/_common_con.php'); // 💡 [수정] 컴포넌트용 공통 파일 포함
|
||||
|
||||
// 💡 [추가] 상담 유형 한글 이름을 JavaScript에서 사용하기 위해 배열을 정의합니다.
|
||||
$consultant_types_map = json_decode(consultant_get_config('consultation_types', '{"onsite":"현장 상담"}'), true);
|
||||
if (!is_array($consultant_types_map)) {
|
||||
$consultant_types_map = ['onsite' => '현장 상담']; // 파싱 실패 시 기본값
|
||||
}
|
||||
|
||||
$ajax_url = G5_ADMIN_URL . '/consultant_manage/components/reservation_check.php';
|
||||
?>
|
||||
|
||||
<!-- 예약 확인/취소 팝업 -->
|
||||
<div id="reservation-check-popup" class="reservation-modal-overlay">
|
||||
<div class="reservation-modal-content">
|
||||
<div class="reservation-modal-header">
|
||||
<h2>예약 확인 및 취소</h2>
|
||||
<button type="button" class="reservation-modal-close" aria-label="팝업 닫기">×</button>
|
||||
</div>
|
||||
<div class="reservation-modal-body">
|
||||
<div class="loading-overlay" style="display: none;">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
|
||||
<!-- 💡 [개선] 조회 폼과 결과 화면을 분리합니다. -->
|
||||
<div id="reservation-check-view-form">
|
||||
<div class="check-form-wrap">
|
||||
<p>예약 시 입력하신 이름과 연락처로 예약 내역을 조회할 수 있습니다.</p>
|
||||
<form id="reservation-check-form">
|
||||
<div class="form-group">
|
||||
<label for="check_customer_name">예약자명</label>
|
||||
<input type="text" id="check_customer_name" name="customer_name" class="form-control" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="check_customer_phone">연락처</label>
|
||||
<input type="tel" id="check_customer_phone" name="customer_phone" class="form-control" placeholder="010-1234-5678" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">예약 조회</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="reservation-check-view-results" style="display: none;">
|
||||
<div class="results-header">
|
||||
<h3>조회된 예약 내역</h3>
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="back-to-search-btn">새로 조회</button>
|
||||
</div>
|
||||
<div id="reservation-results">
|
||||
<!-- 검색 결과가 여기에 표시됩니다. -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* 💡 [개선] 예약 신청 팝업과 스타일을 통일합니다. */
|
||||
.check-form-wrap { background: #fff; padding: 20px; border-radius: 8px; margin-bottom: 20px; }
|
||||
.form-group { margin-bottom: 15px; }
|
||||
.form-group label { display: block; font-weight: 500; color: #495057; margin-bottom: 8px; }
|
||||
.form-control { width: 100%; padding: 12px; border: 1px solid #ced4da; border-radius: 6px; font-size: 16px; box-sizing: border-box; } /* iOS 줌 방지 */
|
||||
.form-control:focus { outline: none; border-color: #007bff; box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); }
|
||||
.btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 12px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
transition: background-color 0.2s ease;
|
||||
/* 💡 [수정] 버튼 내 텍스트가 잘려보이는 현상을 해결합니다. */
|
||||
height: auto;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.btn-primary { background: #007bff; color: white; }
|
||||
.btn-primary:hover { background: #0056b3; }
|
||||
#reservation-results { margin-top: 20px; }
|
||||
.reservation-item { border: 1px solid #ddd; border-radius: 8px; padding: 20px; margin-bottom: 15px; }
|
||||
.reservation-item-header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #eee; padding-bottom: 10px; margin-bottom: 10px; }
|
||||
.reservation-status { padding: 4px 8px; border-radius: 12px; font-size: 12px; font-weight: bold; }
|
||||
.status-reserved { background: #d4edda; color: #155724; }
|
||||
/* 💡 [개선] 예약 상세 정보 스타일 */
|
||||
.reservation-details-grid {
|
||||
margin: 15px 0;
|
||||
font-size: 15px; /* 글씨 크기 키움 */
|
||||
line-height: 1.6; /* 줄 간격 확보 */
|
||||
}
|
||||
.detail-item { display: flex; margin-bottom: 10px; } /* 간격 조정 */
|
||||
.detail-item .label { font-weight: 600; color: #555; width: 90px; flex-shrink: 0; } /* 너비 조정 */
|
||||
.detail-item .value { color: #333; }
|
||||
/* 💡 [추가] 예약 취소 버튼/안내문 영역 스타일 */
|
||||
.reservation-actions {
|
||||
margin-top: 20px;
|
||||
text-align: right;
|
||||
}
|
||||
.reservation-actions .btn-danger {
|
||||
width: auto; /* 버튼이 전체 너비를 차지하지 않도록 */
|
||||
display: inline-block;
|
||||
}
|
||||
.cancel-notice { font-size: 13px !important; color: #666 !important; margin-top: 15px; text-align: right; padding: 10px; background-color: #fff; border-radius: 4px;}
|
||||
/* 💡 [개선] 예약 조회 결과 헤더 스타일 */
|
||||
.results-header { display: flex; align-items: center; gap: 1rem; margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid #eee; }
|
||||
.results-header h3 { margin: 0; font-size: 18px; flex-grow: 1; } /* 제목이 남는 공간을 모두 차지하도록 설정 */
|
||||
.results-header .btn { width: auto; flex-shrink: 0; } /* 헤더 안의 버튼은 내용만큼만 너비를 갖도록 설정 */
|
||||
.btn-sm { padding: 5px 10px; font-size: 12px; }
|
||||
.status-payment_pending { background: #fff3cd; color: #856404; }
|
||||
.status-cancelled { background: #f8d7da; color: #721c24; }
|
||||
.status-completed { background: #cce5ff; color: #004085; }
|
||||
.btn-danger { background: #dc3545; color: white; }
|
||||
.no-results { text-align: center; color: #666; padding: 40px 0; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const ReservationCheckPopup = {
|
||||
elements: {},
|
||||
// 💡 [추가] PHP에서 전달받은 상담 유형 맵
|
||||
consultantTypes: <?php echo json_encode($consultant_types_map, JSON_UNESCAPED_UNICODE); ?>,
|
||||
state: {
|
||||
name: '',
|
||||
phone: ''
|
||||
},
|
||||
config: {
|
||||
ajaxUrl: '<?php echo $ajax_url; ?>'
|
||||
},
|
||||
|
||||
init() {
|
||||
this.elements.popup = document.getElementById('reservation-check-popup');
|
||||
if (!this.elements.popup) return;
|
||||
|
||||
this.elements.loading = this.elements.popup.querySelector('.loading-overlay');
|
||||
this.elements.form = this.elements.popup.querySelector('#reservation-check-form');
|
||||
this.elements.resultsContainer = this.elements.popup.querySelector('#reservation-results');
|
||||
|
||||
// 💡 [추가] 화면 전환용 요소
|
||||
this.elements.formView = this.elements.popup.querySelector('#reservation-check-view-form');
|
||||
this.elements.resultsView = this.elements.popup.querySelector('#reservation-check-view-results');
|
||||
this.elements.backBtn = this.elements.popup.querySelector('#back-to-search-btn');
|
||||
|
||||
this.addEventListeners();
|
||||
},
|
||||
|
||||
addEventListeners() {
|
||||
const closeBtn = this.elements.popup.querySelector('.reservation-modal-close');
|
||||
if (closeBtn) closeBtn.addEventListener('click', () => this.close());
|
||||
|
||||
this.elements.popup.addEventListener('click', e => {
|
||||
if (e.target === this.elements.popup) this.close();
|
||||
});
|
||||
|
||||
if (this.elements.form) {
|
||||
this.elements.form.addEventListener('submit', e => {
|
||||
e.preventDefault();
|
||||
// 💡 [개선] 조회 버튼을 누를 때만 state를 업데이트하고, 그 state를 기반으로 조회합니다.
|
||||
this.state.name = this.elements.form.querySelector('#check_customer_name').value;
|
||||
this.state.phone = this.elements.form.querySelector('#check_customer_phone').value;
|
||||
this.findReservations(); // state에 저장된 정보로 조회
|
||||
});
|
||||
}
|
||||
|
||||
if (this.elements.backBtn) {
|
||||
this.elements.backBtn.addEventListener('click', () => {
|
||||
this.elements.resultsView.style.display = 'none';
|
||||
this.elements.formView.style.display = 'block';
|
||||
this.elements.resultsContainer.innerHTML = '';
|
||||
});
|
||||
}
|
||||
|
||||
if (this.elements.resultsContainer) {
|
||||
this.elements.resultsContainer.addEventListener('click', e => {
|
||||
if (e.target.classList.contains('btn-cancel-reservation')) {
|
||||
const reservationId = e.target.dataset.id;
|
||||
if (confirm('정말 이 예약을 취소하시겠습니까?')) {
|
||||
this.cancelReservation(reservationId);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
open() {
|
||||
if (!this.elements.popup) return;
|
||||
this.elements.popup.classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
},
|
||||
|
||||
close() {
|
||||
if (!this.elements.popup) return;
|
||||
this.elements.popup.classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
if (this.elements.form) this.elements.form.reset();
|
||||
|
||||
// 💡 [추가] 팝업을 닫을 때 화면 상태를 초기화합니다.
|
||||
if (this.elements.resultsContainer) this.elements.resultsContainer.innerHTML = '';
|
||||
if (this.elements.resultsView) this.elements.resultsView.style.display = 'none';
|
||||
if (this.elements.formView) this.elements.formView.style.display = 'block';
|
||||
},
|
||||
|
||||
async findReservations() {
|
||||
this.showLoading();
|
||||
|
||||
// 💡 [개선] state에 저장된 정보로 FormData를 생성하여 일관성을 유지합니다.
|
||||
const formData = new FormData();
|
||||
formData.append('action', 'find_reservations');
|
||||
formData.append('customer_name', this.state.name);
|
||||
formData.append('customer_phone', this.state.phone);
|
||||
|
||||
try {
|
||||
const response = await fetch(this.config.ajaxUrl, { method: 'POST', body: formData });
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
// 💡 [추가] 조회 성공 시 화면을 전환합니다.
|
||||
this.elements.formView.style.display = 'none';
|
||||
this.elements.resultsView.style.display = 'block';
|
||||
this.renderResults(result.data);
|
||||
} else {
|
||||
this.elements.resultsContainer.innerHTML = `<p class="no-results">${result.message}</p>`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("조회 오류:", error);
|
||||
this.elements.resultsContainer.innerHTML = '<p class="no-results">조회 중 오류가 발생했습니다.</p>';
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
}
|
||||
},
|
||||
|
||||
renderResults(reservations) {
|
||||
if (reservations.length === 0) {
|
||||
this.elements.resultsContainer.innerHTML = '<p class="no-results">조회된 예약 내역이 없습니다.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// 💡 [수정] h3 태그는 정적 HTML로 이동했으므로 제거합니다.
|
||||
let html = '';
|
||||
const statusLabels = {
|
||||
'payment_pending': '입금대기', 'reserved': '예약확정',
|
||||
'completed': '상담완료', 'cancelled': '예약취소'
|
||||
};
|
||||
|
||||
reservations.forEach(res => {
|
||||
const date = new Date(res.reservation_date + ' ' + res.reservation_time);
|
||||
const options = { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long', hour: '2-digit', minute: '2-digit' };
|
||||
const formattedDate = date.toLocaleDateString('ko-KR', options);
|
||||
|
||||
// 💡 [개선] 상담 유형의 한글 이름을 맵에서 찾아옵니다.
|
||||
const consultationTypeName = this.consultantTypes[res.consultation_type] || res.consultation_type;
|
||||
|
||||
// 💡 [추가] 리소스 이름 표시
|
||||
const resourceName = res.resource_name ? res.resource_name : '미지정 (빠른 배정)';
|
||||
|
||||
html += `
|
||||
<div class="reservation-item" id="reservation-${res.id}">
|
||||
<div class="reservation-item-header">
|
||||
<strong>예약 번호: #${res.id}</strong>
|
||||
<span class="reservation-status status-${res.status}">${statusLabels[res.status] || res.status}</span>
|
||||
</div>
|
||||
<div class="reservation-details-grid">
|
||||
<div class="detail-item"><span class="label">상담 일시:</span><span class="value">${formattedDate}</span></div>
|
||||
<div class="detail-item"><span class="label">상담 유형:</span><span class="value">${consultationTypeName}</span></div>
|
||||
<div class="detail-item"><span class="label">담당/공간:</span><span class="value">${resourceName}</span></div>
|
||||
</div>
|
||||
<div class="reservation-actions">`;
|
||||
|
||||
if (res.is_cancellable) {
|
||||
html += `<button type="button" class="btn btn-danger btn-cancel-reservation" data-id="${res.id}">예약 취소</button>`;
|
||||
} else if (res.status !== 'cancelled' && res.status !== 'completed') {
|
||||
html += `<p class="cancel-notice">예약 취소 가능 시간이 지났습니다. 변경/취소는 관리자에게 문의해주세요.</p>`;
|
||||
}
|
||||
html += `</div></div>`;
|
||||
});
|
||||
this.elements.resultsContainer.innerHTML = html;
|
||||
},
|
||||
|
||||
async cancelReservation(reservationId) {
|
||||
this.showLoading();
|
||||
const formData = new FormData();
|
||||
formData.append('action', 'cancel_reservation');
|
||||
formData.append('reservation_id', reservationId);
|
||||
formData.append('customer_name', this.state.name);
|
||||
formData.append('customer_phone', this.state.phone);
|
||||
|
||||
try {
|
||||
const response = await fetch(this.config.ajaxUrl, { method: 'POST', body: formData });
|
||||
const result = await response.json();
|
||||
alert(result.message);
|
||||
if (result.success) {
|
||||
await this.findReservations(); // 💡 [수정] await를 추가하여 목록 새로고침이 완료될 때까지 기다립니다.
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("취소 오류:", error);
|
||||
alert('예약 취소 처리 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
// 💡 [제거] findReservations()가 자체적으로 로딩을 숨기므로 중복 호출을 제거합니다.
|
||||
}
|
||||
},
|
||||
|
||||
showLoading() { if (this.elements.loading) this.elements.loading.style.display = 'flex'; },
|
||||
hideLoading() { if (this.elements.loading) this.elements.loading.style.display = 'none'; },
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => ReservationCheckPopup.init());
|
||||
|
||||
function openReservationCheckPopup() {
|
||||
ReservationCheckPopup.open();
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,868 @@
|
||||
<?php
|
||||
/**
|
||||
* 상담 예약 팝업 UI 및 데이터 처리
|
||||
*/
|
||||
|
||||
// AJAX 요청 처리
|
||||
if (isset($_POST['action'])) {
|
||||
include_once('../_common_con.php'); // 💡 [수정] 컴포넌트용 공통 파일 포함
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$action = $_POST['action'] ?? '';
|
||||
$response = ['success' => false, 'message' => '알 수 없는 요청입니다.'];
|
||||
|
||||
// 월별 예약 가능일 조회
|
||||
if ($action === 'get_month_availability') {
|
||||
$year = (int) ($_POST['year'] ?? 0);
|
||||
$month = (int) ($_POST['month'] ?? 0);
|
||||
|
||||
if ($year && $month) {
|
||||
$start_date = date('Y-m-d', mktime(0, 0, 0, $month, 1, $year));
|
||||
$end_date = date('Y-m-t', strtotime($start_date));
|
||||
|
||||
$max_advance_days = consultant_get_config('max_advance_days', 30);
|
||||
$max_date = date('Y-m-d', strtotime("+" . $max_advance_days . " days"));
|
||||
|
||||
$sql = "SELECT
|
||||
specific_date,
|
||||
SUM(CASE WHEN is_available = 1 AND max_persons > (SELECT COUNT(*) FROM consultant_reservations r WHERE r.reservation_date = s.specific_date AND r.reservation_time = s.start_time AND r.status != 'cancelled') THEN 1 ELSE 0 END) as available_slots,
|
||||
MAX(CASE WHEN temp_1 = 'holiday' THEN 1 ELSE 0 END) as is_holiday
|
||||
FROM consultant_schedule s
|
||||
WHERE specific_date BETWEEN '{$start_date}' AND '{$end_date}'
|
||||
GROUP BY specific_date";
|
||||
|
||||
$result = sql_query($sql);
|
||||
$availability = [];
|
||||
while ($row = sql_fetch_array($result)) {
|
||||
$is_bookable = true;
|
||||
$reason = '';
|
||||
|
||||
if ($row['is_holiday']) {
|
||||
$is_bookable = false;
|
||||
$reason = 'holiday';
|
||||
} elseif ($row['specific_date'] < date('Y-m-d')) {
|
||||
$is_bookable = false;
|
||||
$reason = 'past_date';
|
||||
} elseif ($row['specific_date'] > $max_date) {
|
||||
$is_bookable = false;
|
||||
$reason = 'too_far';
|
||||
} elseif ($row['available_slots'] == 0) {
|
||||
$is_bookable = false;
|
||||
$reason = 'full';
|
||||
}
|
||||
|
||||
$availability[date('j', strtotime($row['specific_date']))] = [
|
||||
'available' => $is_bookable,
|
||||
'reason' => $reason
|
||||
];
|
||||
}
|
||||
$response = ['success' => true, 'data' => $availability];
|
||||
}
|
||||
}
|
||||
|
||||
// 특정일의 예약 가능 시간 조회
|
||||
if ($action === 'get_time_slots') {
|
||||
$date = preg_replace('/[^0-9\-]/', '', $_POST['date'] ?? '');
|
||||
|
||||
if ($date) {
|
||||
$min_advance_hours = consultant_get_config('min_advance_hours', 24);
|
||||
$min_datetime = date('Y-m-d H:i:s', strtotime("+" . $min_advance_hours . " hours"));
|
||||
|
||||
$sql = "SELECT
|
||||
s.start_time,
|
||||
s.max_persons,
|
||||
(SELECT COUNT(*) FROM consultant_reservations r WHERE r.reservation_date = s.specific_date AND r.reservation_time = s.start_time AND r.status != 'cancelled') as reserved_count
|
||||
FROM consultant_schedule s
|
||||
WHERE s.specific_date = '{$date}' AND s.is_available = 1
|
||||
ORDER BY s.start_time";
|
||||
|
||||
$result = sql_query($sql);
|
||||
$slots = [];
|
||||
while ($row = sql_fetch_array($result)) {
|
||||
$slot_datetime = $date . ' ' . $row['start_time'];
|
||||
$is_too_soon = ($slot_datetime < $min_datetime);
|
||||
|
||||
$available_count = $row['max_persons'] - $row['reserved_count'];
|
||||
$is_full = ($available_count <= 0);
|
||||
|
||||
$slots[] = [
|
||||
'time' => substr($row['start_time'], 0, 5),
|
||||
'available' => !$is_too_soon && !$is_full,
|
||||
'reason' => $is_too_soon ? 'too_soon' : ($is_full ? 'full' : ''),
|
||||
'reserved_count' => (int)$row['reserved_count'],
|
||||
'max_persons' => (int)$row['max_persons']
|
||||
];
|
||||
}
|
||||
$response = ['success' => true, 'data' => $slots];
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode($response);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 💡 [수정] 아래는 팝업의 HTML/CSS/JS 부분입니다.
|
||||
if (!defined('_GNUBOARD_')) exit;
|
||||
|
||||
include_once(G5_ADMIN_PATH . '/consultant_manage/_common_con.php'); // 💡 [수정] 컴포넌트용 공통 파일 포함
|
||||
$consultant_installed = is_consultant_installed();
|
||||
|
||||
if($consultant_installed) { // 💡 [추가] 시스템이 설치된 경우에만 팝업을 렌더링합니다.
|
||||
// 현재 날짜 정보
|
||||
$current_year = date('Y');
|
||||
$current_month_num = date('n');
|
||||
|
||||
// 💡 [개선] 설정 값을 DB에서 직접 가져와 일관성을 유지합니다.
|
||||
$consultation_fee = consultant_get_config('consultation_fee', 50000);
|
||||
$account_info = consultant_get_config('account_info', '');
|
||||
$max_advance_days = consultant_get_config('max_advance_days', 30);
|
||||
|
||||
// 💡 [수정] 스킨 URL 및 AJAX 엔드포인트 설정
|
||||
$ajax_url = G5_ADMIN_URL . '/consultant_manage/components/reservation_popup.php';
|
||||
$form_action_url = G5_ADMIN_URL . '/consultant_manage/components/reservation_submit.php';
|
||||
|
||||
// 💡 [추가] 리소스 목록 조회
|
||||
$resources = [];
|
||||
$res_result = sql_query("SELECT id, name FROM consultant_resources WHERE is_active = 1 ORDER BY group_id, name");
|
||||
while ($row = sql_fetch_array($res_result)) {
|
||||
$resources[] = $row;
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- 예약 팝업 오버레이 -->
|
||||
<div id="reservation-popup" class="reservation-modal-overlay">
|
||||
<div class="reservation-modal-content">
|
||||
<!-- 팝업 헤더 -->
|
||||
<div class="reservation-modal-header">
|
||||
<h2>상담 예약 신청</h2>
|
||||
<button type="button" class="reservation-modal-close" aria-label="팝업 닫기">
|
||||
<span>×</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 팝업 본문 -->
|
||||
<div class="reservation-modal-body">
|
||||
<!-- 💡 [개선] 로딩 스피너 추가 -->
|
||||
<div class="loading-overlay" style="display: none;">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
|
||||
<!-- 단계 표시 -->
|
||||
<div class="reservation-steps">
|
||||
<div class="step active" data-step="1">
|
||||
<span class="step-number">1</span>
|
||||
<span class="step-text">날짜 선택</span>
|
||||
</div>
|
||||
<div class="step" data-step="2">
|
||||
<span class="step-number">2</span>
|
||||
<span class="step-text">시간 선택</span>
|
||||
</div>
|
||||
<div class="step" data-step="3">
|
||||
<span class="step-number">3</span>
|
||||
<span class="step-text">정보 입력</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 예약 폼 -->
|
||||
<form id="reservation-form" method="post"
|
||||
action="<?php echo $form_action_url; ?>">
|
||||
<!-- 1단계: 달력 -->
|
||||
<div class="reservation-step-content" data-step="1">
|
||||
<div class="step-description">
|
||||
<h4>📅 상담 날짜를 선택해주세요</h4>
|
||||
<p>최대 <?php echo $max_advance_days; ?>일 후까지 예약 가능합니다.</p>
|
||||
</div>
|
||||
<div class="calendar-container">
|
||||
<div class="calendar-header">
|
||||
<button type="button" class="calendar-nav prev-month" aria-label="이전 달">
|
||||
<span>‹</span>
|
||||
</button>
|
||||
<h3 class="calendar-title">
|
||||
<span id="calendar-year"><?php echo $current_year; ?></span>년
|
||||
<span id="calendar-month"><?php echo $current_month_num; ?></span>월
|
||||
</h3>
|
||||
<button type="button" class="calendar-nav next-month" aria-label="다음 달">
|
||||
<span>›</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="calendar-grid">
|
||||
<div class="calendar-weekdays">
|
||||
<div class="weekday">일</div>
|
||||
<div class="weekday">월</div>
|
||||
<div class="weekday">화</div>
|
||||
<div class="weekday">수</div>
|
||||
<div class="weekday">목</div>
|
||||
<div class="weekday">금</div>
|
||||
<div class="weekday">토</div>
|
||||
</div>
|
||||
<div class="calendar-days" id="calendar-days">
|
||||
<!-- 달력 날짜들이 JavaScript로 동적 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="calendar-legend">
|
||||
<div class="legend-item"><span class="legend-color available"></span><span>예약 가능</span></div>
|
||||
<!-- 💡 [추가] 휴일/마감 상태를 구분하는 범례 추가 -->
|
||||
<div class="legend-item"><span class="legend-color holiday"></span><span>휴일</span></div>
|
||||
<div class="legend-item"><span class="legend-color full"></span><span>예약 마감</span></div>
|
||||
<div class="legend-item"><span class="legend-color unavailable"></span><span>지난날짜</span></div>
|
||||
<div class="legend-item"><span class="legend-color selected"></span><span>선택</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2단계: 시간 선택 -->
|
||||
<div class="reservation-step-content" data-step="2" style="display: none;">
|
||||
<div class="step-description">
|
||||
<h4>🕐 상담 시간을 선택해주세요</h4>
|
||||
<div class="selected-date-info"><strong>선택된 날짜: <span id="selected-date-display"></span></strong></div>
|
||||
</div>
|
||||
<div class="time-slots-container">
|
||||
<div class="time-slots-grid" id="time-slots-grid">
|
||||
<!-- 시간대들이 JavaScript로 동적 생성됩니다 -->
|
||||
</div>
|
||||
<div class="time-legend">
|
||||
<div class="legend-item"><span class="legend-color time-available"></span><span>예약 가능</span></div>
|
||||
<div class="legend-item"><span class="legend-color time-full"></span><span>예약 마감</span></div>
|
||||
<div class="legend-item"><span class="legend-color time-too-soon"></span><span>예약 임박</span></div>
|
||||
<div class="legend-item"><span class="legend-color time-selected"></span><span>선택</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3단계: 고객 정보 입력 -->
|
||||
<div class="reservation-step-content" data-step="3" style="display: none;">
|
||||
<div class="step-description">
|
||||
<h4>📝 고객 정보를 입력해주세요</h4>
|
||||
</div>
|
||||
|
||||
<div class="reservation-summary">
|
||||
<h5>📋 예약 정보 확인</h5>
|
||||
<div class="summary-grid">
|
||||
<div class="summary-item">
|
||||
<span class="label">📅 예약 날짜:</span>
|
||||
<span id="summary-date">-</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="label">🕐 예약 시간:</span>
|
||||
<span id="summary-time">-</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="label">💰 상담 비용:</span>
|
||||
<span><?php echo number_format($consultation_fee); ?>원</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="customer-info-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="customer-name">👤 이름 <span class="required">*</span></label>
|
||||
<input type="text" id="customer-name" name="customer_name" required placeholder="홍길동">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="customer-phone">📱 연락처 <span class="required">*</span></label>
|
||||
<input type="tel" id="customer-phone" name="customer_phone" required placeholder="010-1234-5678">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="customer-email">📧 이메일 <span class="required">*</span></label>
|
||||
<input type="email" id="customer-email" name="customer_email" required placeholder="example@email.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="consultation-type">🏠 상담 유형</label>
|
||||
<select id="consultation-type" name="consultation_type">
|
||||
<option value="onsite">현장 상담</option>
|
||||
<option value="online">온라인 상담</option>
|
||||
<option value="phone">전화 상담</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- 💡 [추가] 담당자/공간 선택 드롭다운 -->
|
||||
<div class="form-group">
|
||||
<label for="consultation-resource">👨⚕️ 담당자/공간 선택</label>
|
||||
<select id="consultation-resource" name="resource_id">
|
||||
<option value="">선택 안 함 (빠른 배정)</option>
|
||||
<?php foreach ($resources as $res): ?>
|
||||
<option value="<?php echo $res['id']; ?>"><?php echo htmlspecialchars($res['name']); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="customer-request">📝 요청사항</label>
|
||||
<textarea id="customer-request" name="customer_request" rows="4" placeholder="상담 관련 요청사항이나 문의사항을 입력해주세요."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 숨겨진 필드들 -->
|
||||
<input type="hidden" id="selected-date" name="reservation_date">
|
||||
<input type="hidden" id="selected-time" name="reservation_time">
|
||||
<input type="hidden" name="payment_amount" value="<?php echo $consultation_fee; ?>">
|
||||
<input type="hidden" name="status" value="payment_pending">
|
||||
</div>
|
||||
|
||||
<!-- 네비게이션 버튼 -->
|
||||
<div class="reservation-nav-buttons">
|
||||
<button type="button" class="btn-prev" style="display: none;">← 이전</button>
|
||||
<button type="button" class="btn-next">다음 →</button>
|
||||
<button type="submit" class="btn-submit" style="display: none;">예약 신청</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 예약 팝업 스타일 -->
|
||||
<style>
|
||||
/* 💡 [추가] 로딩 오버레이 스타일 */
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
.loading-spinner {
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #007bff;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
||||
|
||||
/* 팝업 오버레이 */
|
||||
.reservation-modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
z-index: 9999;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.reservation-modal-overlay.active {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.reservation-modal-content {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
.reservation-modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 25px;
|
||||
border-bottom: 1px solid #eee;
|
||||
background: #f8f9fa;
|
||||
border-radius: 10px 10px 0 0;
|
||||
}
|
||||
.reservation-modal-header h2 { margin: 0; font-size: 20px; font-weight: 600; color: #333; }
|
||||
.reservation-modal-close {
|
||||
background: none; border: none; font-size: 24px; cursor: pointer; color: #666;
|
||||
padding: 0; width: 30px; height: 30px; display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 50%; transition: all 0.2s ease;
|
||||
}
|
||||
.reservation-modal-close:hover { background: #e9ecef; color: #333; }
|
||||
.reservation-modal-body { padding: 25px; }
|
||||
|
||||
/* 단계 표시 */
|
||||
.reservation-steps { display: flex; justify-content: center; margin-bottom: 30px; position: relative; }
|
||||
.reservation-steps::before {
|
||||
content: ''; position: absolute; top: 15px; left: 25%; right: 25%;
|
||||
height: 2px; background: #e9ecef; z-index: 1;
|
||||
}
|
||||
.step { display: flex; flex-direction: column; align-items: center; position: relative; z-index: 2; background: #fff; padding: 0 15px; }
|
||||
.step-number {
|
||||
width: 30px; height: 30px; border-radius: 50%; background: #e9ecef; color: #6c757d;
|
||||
display: flex; align-items: center; justify-content: center; font-weight: 600; margin-bottom: 8px; transition: all 0.3s ease;
|
||||
}
|
||||
.step.active .step-number { background: #007bff; color: #fff; }
|
||||
.step.completed .step-number { background: #28a745; color: #fff; }
|
||||
.step-text { font-size: 12px; color: #6c757d; font-weight: 500; }
|
||||
.step.active .step-text { color: #007bff; font-weight: 600; }
|
||||
|
||||
/* 달력 */
|
||||
.calendar-container { max-width: 100%; }
|
||||
.calendar-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.calendar-nav {
|
||||
background: none; border: 1px solid #ddd; width: 35px; height: 35px; border-radius: 50%;
|
||||
cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.2s ease;
|
||||
}
|
||||
.calendar-nav:hover { background: #f8f9fa; border-color: #007bff; }
|
||||
.calendar-title { margin: 0; font-size: 18px; font-weight: 600; color: #333; }
|
||||
.calendar-grid { border: 1px solid #e9ecef; border-radius: 8px; overflow: hidden; }
|
||||
.calendar-weekdays { display: grid; grid-template-columns: repeat(7, 1fr); background: #f8f9fa; }
|
||||
.weekday { padding: 12px 8px; text-align: center; font-weight: 600; color: #495057; font-size: 14px; border-right: 1px solid #e9ecef; }
|
||||
.weekday:last-child { border-right: none; }
|
||||
.calendar-days { display: grid; grid-template-columns: repeat(7, 1fr); }
|
||||
.calendar-day {
|
||||
aspect-ratio: 1; display: flex; align-items: center; justify-content: center;
|
||||
border-right: 1px solid #e9ecef; border-bottom: 1px solid #e9ecef; cursor: pointer;
|
||||
transition: all 0.2s ease; font-size: 14px; position: relative;
|
||||
}
|
||||
.calendar-day:nth-child(7n) { border-right: none; }
|
||||
.calendar-day.other-month { color: #ccc; background: #f8f9fa; cursor: default; }
|
||||
.calendar-day.available { background: #fff; color: #333; }
|
||||
.calendar-day.available:hover { background: #e3f2fd; color: #1976d2; }
|
||||
.calendar-day.unavailable { background: #f5f5f5; color: #999; cursor: not-allowed; }
|
||||
.calendar-day.full { background: #fbe9e7; color: #c62828; cursor: not-allowed; } /* 예약 마감 */
|
||||
.calendar-day.holiday { background: #e8eaf6; color: #3f51b5; cursor: not-allowed; } /* 휴일 */
|
||||
.calendar-day.selected { background: #007bff; color: #fff; font-weight: 600; }
|
||||
.calendar-day.today { font-weight: 600; color: #dc3545; }
|
||||
.calendar-loading, .loading, .error, .no-slots { grid-column: 1 / -1; text-align: center; padding: 40px 20px; color: #666; font-style: italic; }
|
||||
.error { color: #dc3545; }
|
||||
|
||||
/* 범례 */
|
||||
.calendar-legend, .time-legend { display: flex; justify-content: center; gap: 20px; margin-top: 15px; flex-wrap: wrap; }
|
||||
.legend-item { display: flex; align-items: center; gap: 5px; font-size: 12px; color: #666; }
|
||||
.legend-color { width: 12px; height: 12px; border-radius: 2px; border: 1px solid #ddd; }
|
||||
.legend-color.available { background: #fff; }
|
||||
.legend-color.unavailable { background: #f5f5f5; }
|
||||
.legend-color.full { background: #fbe9e7; }
|
||||
.legend-color.holiday { background: #e8eaf6; }
|
||||
.legend-color.selected { background: #007bff; }
|
||||
.legend-color.time-available { background: #e8f5e8; border-color: #28a745; }
|
||||
.legend-color.time-full { background: #f8d7da; border-color: #dc3545; }
|
||||
.legend-color.time-too-soon { background: #f1f3f5; border-color: #dee2e6; }
|
||||
.legend-color.time-selected { background: #007bff; border-color: #007bff; }
|
||||
|
||||
/* 시간 선택 */
|
||||
.selected-date-info { margin-bottom: 10px; }
|
||||
.step-description { margin-bottom: 20px; }
|
||||
.step-description h4 { margin: 0 0 10px 0; font-size: 18px; }
|
||||
.step-description p { margin: 0; font-size: 14px; color: #666; }
|
||||
.selected-date-info h4 { margin: 0; color: #333; font-size: 16px; }
|
||||
.time-slots-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; margin-bottom: 20px; }
|
||||
.time-slot {
|
||||
padding: 12px 8px; border: 2px solid #e9ecef; border-radius: 6px; text-align: center; cursor: pointer;
|
||||
transition: all 0.2s ease; font-size: 14px; font-weight: 500; display: flex; flex-direction: column; gap: 4px;
|
||||
}
|
||||
.time-text { font-weight: 600; }
|
||||
.slot-info { font-size: 11px; opacity: 0.8; }
|
||||
/* 💡 [수정] 예약 가능 슬롯 스타일 변경 */
|
||||
.time-slot.available { background: #e7f3ff; border-color: #007bff; color: #004085; }
|
||||
.time-slot.available:hover { background: #d4edda; transform: translateY(-1px); }
|
||||
.time-slot.full { background: #f8d7da; border-color: #dc3545; color: #721c24; cursor: not-allowed; }
|
||||
.time-slot.too-soon { background: #f1f3f5; color: #868e96; cursor: not-allowed; border-color: #dee2e6; }
|
||||
.time-slot.selected { background: #007bff; border-color: #007bff; color: #fff; }
|
||||
|
||||
/* 고객 정보 폼 */
|
||||
.reservation-summary { background: #f8f9fa; padding: 15px; border-radius: 8px; margin-bottom: 20px; }
|
||||
.reservation-summary h5 { margin: 0 0 10px 0; font-size: 16px; }
|
||||
.summary-grid { display: grid; grid-template-columns: 1fr; gap: 8px; }
|
||||
.summary-item { display: flex; justify-content: space-between; font-size: 14px; }
|
||||
.summary-item .label { font-weight: 500; color: #495057; }
|
||||
.summary-item span { color: #333; }
|
||||
.customer-info-form h4 { margin: 0 0 15px 0; color: #333; font-size: 16px; }
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
|
||||
.form-group { margin-bottom: 15px; }
|
||||
.form-group label { display: block; margin-bottom: 8px; font-weight: 500; color: #495057; font-size: 14px; }
|
||||
.required { color: #dc3545; font-weight: bold; }
|
||||
.form-group input, .form-group textarea, .form-group select {
|
||||
width: 100%; padding: 12px; border: 1px solid #ced4da; border-radius: 6px;
|
||||
font-size: 14px; transition: border-color 0.2s ease; box-sizing: border-box;
|
||||
}
|
||||
.form-group textarea { resize: vertical; min-height: 80px; }
|
||||
.form-group input:focus, .form-group textarea:focus, .form-group select:focus {
|
||||
outline: none; border-color: #007bff; box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
|
||||
}
|
||||
|
||||
/* 네비게이션 버튼 */
|
||||
.reservation-nav-buttons {
|
||||
display: flex; justify-content: space-between; gap: 10px; margin-top: 25px;
|
||||
padding-top: 20px; border-top: 1px solid #e9ecef;
|
||||
}
|
||||
.reservation-nav-buttons button {
|
||||
padding: 12px 24px; border: none; border-radius: 6px; font-size: 14px;
|
||||
font-weight: 600; cursor: pointer; transition: all 0.2s ease; min-width: 100px;
|
||||
}
|
||||
.btn-prev { background: #6c757d; color: #fff; }
|
||||
.btn-prev:hover { background: #5a6268; }
|
||||
.btn-next, .btn-submit { background: #007bff; color: #fff; }
|
||||
.btn-next:hover, .btn-submit:hover { background: #0056b3; }
|
||||
|
||||
/* 반응형 디자인 - 모바일 최적화 */
|
||||
@media (max-width: 768px) {
|
||||
.reservation-modal-content { margin: 10px; max-height: 95vh; border-radius: 8px; }
|
||||
.reservation-modal-header, .reservation-modal-body { padding: 15px; }
|
||||
.reservation-modal-header h2 { font-size: 18px; }
|
||||
.reservation-steps { margin-bottom: 20px; }
|
||||
.step { padding: 0 10px; }
|
||||
.step-number { width: 28px; height: 28px; font-size: 13px; }
|
||||
.step-text { font-size: 11px; }
|
||||
.calendar-day { font-size: 13px; }
|
||||
.time-slots-grid { grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); }
|
||||
.form-row { grid-template-columns: 1fr; gap: 0; }
|
||||
.form-group input, .form-group textarea, .form-group select { font-size: 16px; /* iOS 줌 방지 */ }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.reservation-modal-overlay { padding: 0; align-items: flex-end; }
|
||||
.reservation-modal-content { margin: 0; width: 100%; max-height: 90vh; border-radius: 10px 10px 0 0; }
|
||||
.reservation-nav-buttons { flex-direction: column-reverse; gap: 10px; }
|
||||
.reservation-nav-buttons button { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 💡 [개선] JavaScript 로직 전면 수정 -->
|
||||
<script>
|
||||
const ReservationPopup = {
|
||||
elements: {},
|
||||
state: {
|
||||
currentStep: 1,
|
||||
selectedDate: null,
|
||||
selectedTime: null,
|
||||
currentYear: new Date().getFullYear(),
|
||||
currentMonth: new Date().getMonth() + 1,
|
||||
},
|
||||
config: {
|
||||
ajaxUrl: '<?php echo $ajax_url; ?>',
|
||||
maxAdvanceDays: <?php echo (int)$max_advance_days; ?>,
|
||||
},
|
||||
|
||||
init() {
|
||||
this.elements.popup = document.getElementById('reservation-popup');
|
||||
if (!this.elements.popup) return;
|
||||
|
||||
this.elements.loading = this.elements.popup.querySelector('.loading-overlay');
|
||||
this.elements.form = this.elements.popup.querySelector('#reservation-form');
|
||||
this.elements.calendar = {
|
||||
year: this.elements.popup.querySelector('#calendar-year'),
|
||||
month: this.elements.popup.querySelector('#calendar-month'),
|
||||
days: this.elements.popup.querySelector('#calendar-days'),
|
||||
prevBtn: this.elements.popup.querySelector('.prev-month'),
|
||||
nextBtn: this.elements.popup.querySelector('.next-month'),
|
||||
};
|
||||
this.elements.timeSlotsGrid = this.elements.popup.querySelector('#time-slots-grid');
|
||||
this.elements.nav = {
|
||||
prevBtn: this.elements.popup.querySelector('.btn-prev'),
|
||||
nextBtn: this.elements.popup.querySelector('.btn-next'),
|
||||
submitBtn: this.elements.popup.querySelector('.btn-submit'),
|
||||
};
|
||||
|
||||
this.addEventListeners();
|
||||
},
|
||||
|
||||
addEventListeners() {
|
||||
// 💡 [수정] 닫기 버튼을 더 안정적으로 찾아 이벤트를 추가하고, null 체크를 추가합니다.
|
||||
const closeBtn = this.elements.popup.querySelector('.reservation-modal-close');
|
||||
if (closeBtn) closeBtn.addEventListener('click', () => this.close());
|
||||
|
||||
this.elements.popup.addEventListener('click', e => {
|
||||
if (e.target === this.elements.popup) this.close();
|
||||
});
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape' && this.elements.popup.classList.contains('active')) this.close();
|
||||
});
|
||||
|
||||
// 💡 [수정] 모든 이벤트 리스너에 null 체크를 추가하여 스크립트 오류를 방지합니다.
|
||||
if (this.elements.calendar.prevBtn) this.elements.calendar.prevBtn.addEventListener('click', () => this.changeMonth(-1));
|
||||
if (this.elements.calendar.nextBtn) this.elements.calendar.nextBtn.addEventListener('click', () => this.changeMonth(1));
|
||||
|
||||
if (this.elements.nav.prevBtn) this.elements.nav.prevBtn.addEventListener('click', () => this.goToStep(this.state.currentStep - 1));
|
||||
if (this.elements.nav.nextBtn) this.elements.nav.nextBtn.addEventListener('click', () => this.goToNextStep());
|
||||
if (this.elements.form) {
|
||||
this.elements.form.addEventListener('submit', e => {
|
||||
e.preventDefault();
|
||||
this.submitForm();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
open() {
|
||||
this.state.currentYear = new Date().getFullYear();
|
||||
this.state.currentMonth = new Date().getMonth() + 1;
|
||||
this.goToStep(1);
|
||||
this.renderCalendar();
|
||||
this.elements.popup.classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
},
|
||||
|
||||
close() {
|
||||
this.elements.popup.classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
this.elements.form.reset();
|
||||
},
|
||||
|
||||
changeMonth(delta) {
|
||||
this.state.currentMonth += delta;
|
||||
if (this.state.currentMonth < 1) {
|
||||
this.state.currentMonth = 12;
|
||||
this.state.currentYear--;
|
||||
} else if (this.state.currentMonth > 12) {
|
||||
this.state.currentMonth = 1;
|
||||
this.state.currentYear++;
|
||||
}
|
||||
this.renderCalendar();
|
||||
},
|
||||
|
||||
async renderCalendar() {
|
||||
this.elements.calendar.year.textContent = this.state.currentYear;
|
||||
this.elements.calendar.month.textContent = this.state.currentMonth;
|
||||
this.elements.calendar.days.innerHTML = '<div class="loading">달력 정보를 불러오는 중...</div>';
|
||||
|
||||
const availability = await this.fetchMonthAvailability();
|
||||
if (!availability) {
|
||||
this.elements.calendar.days.innerHTML = '<div class="error">달력 정보를 불러올 수 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
this.elements.calendar.days.innerHTML = '';
|
||||
const firstDay = new Date(this.state.currentYear, this.state.currentMonth - 1, 1);
|
||||
const daysInMonth = new Date(this.state.currentYear, this.state.currentMonth, 0).getDate();
|
||||
const startDayOfWeek = firstDay.getDay();
|
||||
|
||||
for (let i = 0; i < startDayOfWeek; i++) {
|
||||
this.elements.calendar.days.appendChild(this.createDayElement(0, true));
|
||||
}
|
||||
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
this.elements.calendar.days.appendChild(this.createDayElement(day, false, availability[day]));
|
||||
}
|
||||
},
|
||||
|
||||
createDayElement(day, isOtherMonth, availability = null) {
|
||||
const dayElement = document.createElement('div');
|
||||
dayElement.className = 'calendar-day';
|
||||
if (isOtherMonth) {
|
||||
dayElement.classList.add('other-month');
|
||||
return dayElement;
|
||||
}
|
||||
|
||||
|
||||
dayElement.textContent = day;
|
||||
|
||||
const dateStr = `${this.state.currentYear}-${String(this.state.currentMonth).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const currentDate = new Date(dateStr);
|
||||
currentDate.setHours(0, 0, 0, 0);
|
||||
if (currentDate.getTime() === today.getTime()) {
|
||||
dayElement.classList.add('today');
|
||||
} else if (currentDate.getTime() < today.getTime()){
|
||||
dayElement.classList.add('unavailable');
|
||||
} else if (availability && availability.available) {
|
||||
dayElement.classList.add('available');
|
||||
dayElement.addEventListener('click', () => this.selectDate(dateStr, dayElement));
|
||||
} else {
|
||||
// 💡 [개선] 예약 불가 사유에 따라 다른 스타일을 적용합니다.
|
||||
if (availability && availability.reason === 'holiday') {
|
||||
dayElement.classList.add('holiday');
|
||||
} else if (availability && availability.reason === 'full') {
|
||||
dayElement.classList.add('full');
|
||||
} else {
|
||||
dayElement.classList.add('unavailable');
|
||||
}
|
||||
}
|
||||
return dayElement;
|
||||
},
|
||||
|
||||
selectDate(dateStr, element) {
|
||||
const prevSelected = this.elements.calendar.days.querySelector('.selected');
|
||||
if (prevSelected) prevSelected.classList.remove('selected');
|
||||
element.classList.add('selected');
|
||||
this.state.selectedDate = dateStr;
|
||||
},
|
||||
|
||||
async fetchMonthAvailability() {
|
||||
this.showLoading();
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('action', 'get_month_availability');
|
||||
formData.append('year', this.state.currentYear);
|
||||
formData.append('month', this.state.currentMonth);
|
||||
|
||||
const response = await fetch(this.config.ajaxUrl, { method: 'POST', body: formData });
|
||||
const result = await response.json();
|
||||
return result.success ? result.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching month availability:', error);
|
||||
return null;
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
}
|
||||
},
|
||||
|
||||
async fetchTimeSlots() {
|
||||
this.showLoading();
|
||||
this.elements.timeSlotsGrid.innerHTML = '';
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('action', 'get_time_slots');
|
||||
formData.append('date', this.state.selectedDate);
|
||||
|
||||
const response = await fetch(this.config.ajaxUrl, { method: 'POST', body: formData });
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
this.renderTimeSlots(result.data);
|
||||
} else {
|
||||
this.elements.timeSlotsGrid.innerHTML = `<div class="error">${result.message || '시간 정보를 불러올 수 없습니다.'}</div>`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching time slots:', error);
|
||||
this.elements.timeSlotsGrid.innerHTML = '<div class="error">오류가 발생했습니다. 다시 시도해주세요.</div>';
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
}
|
||||
},
|
||||
|
||||
renderTimeSlots(slots) {
|
||||
// 💡 [수정] 슬롯이 하나도 없는 경우와, 예약 가능한 슬롯이 없는 경우를 구분하여 메시지를 표시합니다.
|
||||
if (slots.length === 0) {
|
||||
this.elements.timeSlotsGrid.innerHTML = '<div class="no-slots">해당 날짜에 운영되는 상담 시간이 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let hasAvailableSlots = false;
|
||||
this.elements.timeSlotsGrid.innerHTML = ''; // 그리드 초기화
|
||||
|
||||
slots.forEach(slot => {
|
||||
const slotElement = document.createElement('div');
|
||||
slotElement.className = 'time-slot';
|
||||
|
||||
let slotInfoText = '';
|
||||
const reservedCount = slot.reserved_count;
|
||||
|
||||
if (slot.available) {
|
||||
hasAvailableSlots = true;
|
||||
slotElement.classList.add('available');
|
||||
slotElement.addEventListener('click', () => this.selectTime(slot.time, slotElement));
|
||||
slotInfoText = `예약 ${reservedCount} / ${slot.max_persons}`;
|
||||
} else {
|
||||
if (slot.reason === 'full') {
|
||||
slotElement.classList.add('full');
|
||||
slotInfoText = '마감';
|
||||
} else if (slot.reason === 'too_soon') {
|
||||
slotElement.classList.add('too-soon');
|
||||
slotInfoText = '예약 임박';
|
||||
} else {
|
||||
slotElement.classList.add('full'); // Fallback
|
||||
slotInfoText = '마감';
|
||||
}
|
||||
}
|
||||
slotElement.innerHTML = `<span class="time-text">${slot.time}</span> <span class="slot-info">${slotInfoText}</span>`;
|
||||
this.elements.timeSlotsGrid.appendChild(slotElement);
|
||||
});
|
||||
|
||||
// 예약 가능한 슬롯이 하나도 없는 경우 안내 메시지 추가
|
||||
if (!hasAvailableSlots) {
|
||||
const noSlotsMessage = document.createElement('div');
|
||||
noSlotsMessage.className = 'no-slots';
|
||||
noSlotsMessage.textContent = '현재 예약 가능한 시간이 없습니다. 다른 날짜를 선택해주세요.';
|
||||
this.elements.timeSlotsGrid.prepend(noSlotsMessage);
|
||||
}
|
||||
},
|
||||
|
||||
selectTime(time, element) {
|
||||
const prevSelected = this.elements.timeSlotsGrid.querySelector('.selected');
|
||||
if (prevSelected) prevSelected.classList.remove('selected');
|
||||
element.classList.add('selected');
|
||||
this.state.selectedTime = time;
|
||||
},
|
||||
|
||||
goToStep(step) {
|
||||
this.state.currentStep = step;
|
||||
this.elements.popup.querySelectorAll('.step').forEach((el, i) => {
|
||||
el.classList.toggle('active', i + 1 === step);
|
||||
el.classList.toggle('completed', i + 1 < step);
|
||||
});
|
||||
this.elements.popup.querySelectorAll('.reservation-step-content').forEach(el => {
|
||||
el.style.display = parseInt(el.dataset.step) === step ? 'block' : 'none';
|
||||
});
|
||||
|
||||
this.elements.nav.prevBtn.style.display = step > 1 ? 'inline-block' : 'none';
|
||||
this.elements.nav.nextBtn.style.display = step < 3 ? 'inline-block' : 'none';
|
||||
this.elements.nav.submitBtn.style.display = step === 3 ? 'inline-block' : 'none';
|
||||
},
|
||||
|
||||
goToNextStep() {
|
||||
if (this.state.currentStep === 1 && !this.state.selectedDate) {
|
||||
alert('날짜를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
if (this.state.currentStep === 2 && !this.state.selectedTime) {
|
||||
alert('시간을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.currentStep < 3) {
|
||||
this.goToStep(this.state.currentStep + 1);
|
||||
if (this.state.currentStep === 2) this.fetchTimeSlots();
|
||||
if (this.state.currentStep === 3) this.updateSummary();
|
||||
}
|
||||
},
|
||||
|
||||
updateSummary() {
|
||||
const date = new Date(this.state.selectedDate);
|
||||
const options = { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' };
|
||||
this.elements.popup.querySelector('#summary-date').textContent = date.toLocaleDateString('ko-KR', options);
|
||||
this.elements.popup.querySelector('#summary-time').textContent = this.state.selectedTime;
|
||||
this.elements.popup.querySelector('#selected-date-display').textContent = date.toLocaleDateString('ko-KR', options);
|
||||
this.elements.form.querySelector('#selected-date').value = this.state.selectedDate;
|
||||
this.elements.form.querySelector('#selected-time').value = this.state.selectedTime;
|
||||
},
|
||||
|
||||
async submitForm() {
|
||||
if (!this.elements.form.checkValidity()) {
|
||||
alert('필수 입력 항목을 모두 채워주세요.');
|
||||
this.elements.form.reportValidity();
|
||||
return;
|
||||
}
|
||||
this.showLoading();
|
||||
this.elements.nav.submitBtn.disabled = true;
|
||||
this.elements.nav.submitBtn.textContent = '처리 중...';
|
||||
|
||||
try {
|
||||
const formData = new FormData(this.elements.form);
|
||||
const response = await fetch('<?php echo $form_action_url; ?>', { method: 'POST', body: formData });
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alert('예약 신청이 완료되었습니다. 입금 확인 후 예약이 확정됩니다.');
|
||||
this.close();
|
||||
// 필요시 페이지 새로고침 또는 다른 동작 수행
|
||||
// location.reload();
|
||||
} else {
|
||||
alert(result.message || '예약 처리 중 오류가 발생했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Form submission error:', error);
|
||||
alert('네트워크 오류가 발생했습니다. 다시 시도해주세요.');
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
this.elements.nav.submitBtn.disabled = false;
|
||||
this.elements.nav.submitBtn.textContent = '예약 신청';
|
||||
}
|
||||
},
|
||||
|
||||
showLoading() { if (this.elements.loading) this.elements.loading.style.display = 'flex'; },
|
||||
hideLoading() { if (this.elements.loading) this.elements.loading.style.display = 'none'; },
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => ReservationPopup.init());
|
||||
|
||||
// 외부에서 팝업을 열기 위한 전역 함수
|
||||
function openReservationPopup() {
|
||||
ReservationPopup.open();
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php } // end if($consultant_installed) ?>
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
include_once('../_common_con.php'); // 💡 [수정] 컴포넌트용 공통 파일 포함
|
||||
header('Content-Type: application/json');
|
||||
|
||||
try {
|
||||
// 입력 데이터 정리
|
||||
$reservation_data = [
|
||||
'customer_name' => trim($_POST['customer_name'] ?? ''),
|
||||
'customer_phone' => trim($_POST['customer_phone'] ?? ''),
|
||||
'customer_email' => trim($_POST['customer_email'] ?? ''),
|
||||
'reservation_date' => trim($_POST['reservation_date'] ?? ''),
|
||||
'reservation_time' => trim($_POST['reservation_time'] ?? ''),
|
||||
'consultation_type' => trim($_POST['consultation_type'] ?? 'onsite'),
|
||||
'request_memo' => trim($_POST['customer_request'] ?? ''),
|
||||
'payment_amount' => (int)($_POST['payment_amount'] ?? 0),
|
||||
'status' => 'payment_pending'
|
||||
];
|
||||
|
||||
// 필수 항목 유효성 검사
|
||||
if (empty($reservation_data['customer_name']) || empty($reservation_data['customer_phone']) || empty($reservation_data['reservation_date']) || empty($reservation_data['reservation_time'])) {
|
||||
throw new Exception('필수 예약 정보가 누락되었습니다.');
|
||||
}
|
||||
|
||||
// 예약 가능 여부 재확인 (서버 측 검증)
|
||||
$sql_check = "SELECT COUNT(*) as cnt FROM consultant_schedule
|
||||
WHERE specific_date = '{$reservation_data['reservation_date']}'
|
||||
AND start_time = '{$reservation_data['reservation_time']}'
|
||||
AND is_available = 1";
|
||||
$schedule = sql_fetch($sql_check);
|
||||
|
||||
if (!$schedule || $schedule['cnt'] == 0) {
|
||||
throw new Exception('선택하신 시간은 예약이 불가능합니다. 다른 시간을 선택해주세요.');
|
||||
}
|
||||
|
||||
// 예약 생성
|
||||
$sql = "INSERT INTO consultant_reservations
|
||||
(customer_name, customer_phone, customer_email, reservation_date, reservation_time, consultation_type, request_memo, payment_amount, status, created_at, updated_at)
|
||||
VALUES
|
||||
(
|
||||
'" . sql_real_escape_string($reservation_data['customer_name']) . "',
|
||||
'" . sql_real_escape_string($reservation_data['customer_phone']) . "',
|
||||
'" . sql_real_escape_string($reservation_data['customer_email']) . "',
|
||||
'" . sql_real_escape_string($reservation_data['reservation_date']) . "',
|
||||
'" . sql_real_escape_string($reservation_data['reservation_time']) . "',
|
||||
'" . sql_real_escape_string($reservation_data['consultation_type']) . "',
|
||||
'" . sql_real_escape_string($reservation_data['request_memo']) . "',
|
||||
'{$reservation_data['payment_amount']}',
|
||||
'{$reservation_data['status']}',
|
||||
NOW(),
|
||||
NOW()
|
||||
)";
|
||||
|
||||
if (sql_query($sql)) {
|
||||
$reservation_id = sql_insert_id();
|
||||
consultant_log("새 예약 신청: ID {$reservation_id} (고객: {$reservation_data['customer_name']})");
|
||||
|
||||
// TODO: 고객 및 관리자에게 알림 발송 로직 추가
|
||||
|
||||
echo json_encode(['success' => true, 'message' => '예약 신청이 완료되었습니다.']);
|
||||
} else {
|
||||
throw new Exception('데이터베이스 저장 중 오류가 발생했습니다.');
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
consultant_log("예약 신청 오류: " . $e->getMessage(), 'error');
|
||||
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
$sub_menu = '850100'; // 대시보드 메뉴와 동일하게 설정
|
||||
include_once('./_common.php');
|
||||
|
||||
$g5['title'] = '상담 예약 팝업 샘플';
|
||||
include_once(G5_ADMIN_PATH.'/admin.head.php');
|
||||
?>
|
||||
|
||||
<div class="local_desc01 local_desc">
|
||||
<p>
|
||||
이 페이지는 사이트의 어떤 페이지에서든 상담 예약 기능을 쉽게 추가하는 방법을 보여주는 예제입니다.<br>
|
||||
아래 코드 한 줄만 포함하면, 버튼과 팝업 기능이 모두 활성화됩니다.
|
||||
</p>
|
||||
<pre><code><?php include_once(G5_ADMIN_PATH . '/consultant_manage/components/_consultant_popups.php'); ?></code></pre>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- 💡 [시작] 상담 예약 기능 추가 예제 -->
|
||||
<!-- ================================================================== -->
|
||||
|
||||
<div style="text-align:center; padding: 50px 20px; background-color:#f5f5f5; border-radius:10px; margin: 20px 0;">
|
||||
<h3 style="margin-bottom:15px;">상담이 필요하신가요?</h3>
|
||||
<p style="margin-bottom:25px; color:#666;">버튼을 눌러 간편하게 상담을 신청하거나, 기존 예약을 확인/취소할 수 있습니다.</p>
|
||||
|
||||
<!-- 1. 팝업을 여는 버튼들 -->
|
||||
<div class="consultant-buttons">
|
||||
<button type="button" class="reservation-btn-main" onclick="openReservationPopup()">
|
||||
<i class="fa fa-calendar"></i> 상담 예약 신청
|
||||
</button>
|
||||
<button type="button" class="reservation-check-btn" onclick="openReservationCheckPopup()">
|
||||
예약 확인/취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
// 2. 팝업 파일들 포함 (이 코드 한 줄이면 모든 팝업 기능이 로드됩니다)
|
||||
include_once(G5_ADMIN_PATH . '/consultant_manage/components/_consultant_popups.php');
|
||||
?>
|
||||
|
||||
<!-- 버튼 디자인을 위한 CSS (사이트의 공통 CSS 파일에 추가하는 것을 권장합니다) -->
|
||||
<style>
|
||||
.consultant-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.reservation-btn-main, .reservation-check-btn {
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 15px 30px;
|
||||
border-radius: 25px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.reservation-btn-main {
|
||||
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||
box-shadow: 0 4px 15px rgba(40, 167, 69, 0.3);
|
||||
}
|
||||
.reservation-btn-main:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(40, 167, 69, 0.4);
|
||||
}
|
||||
.reservation-btn-main i {
|
||||
margin-right: 8px;
|
||||
}
|
||||
.reservation-check-btn {
|
||||
background: #6c757d;
|
||||
}
|
||||
.reservation-check-btn:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- 💡 [끝] 상담 예약 기능 추가 예제 -->
|
||||
<!-- ================================================================== -->
|
||||
|
||||
|
||||
<?php
|
||||
include_once(G5_ADMIN_PATH.'/admin.tail.php');
|
||||
?>
|
||||
Reference in New Issue
Block a user