first commit 2

This commit is contained in:
hmw1001
2026-06-11 18:47:38 +09:00
parent c768729ce6
commit 6f534e33a6
11095 changed files with 1595758 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
<?php
/**
* 상담 예약 관리 시스템 공통 파일 (관리자용)
*/
define('G5_IS_ADMIN', true);
include_once('../../common.php');
include_once(G5_ADMIN_PATH . '/admin.lib.php');
?>
+6
View File
@@ -0,0 +1,6 @@
<?php
if (!defined('_GNUBOARD_')) {
// AJAX 요청 등으로 직접 접근했을 때, 그누보드 환경을 로드합니다.
include_once('../../../common.php');
}
?>
@@ -0,0 +1,19 @@
<?php
if (!defined('_GNUBOARD_'))
exit;
// 800번대 최상위 메뉴 '견적 관리'를 정의합니다.
$menu['menu800'][] = array('800000', '견적 관리', G5_ADMIN_URL . '/order_manage/statistics.php', 'order_manage', 'fa-file-text-o');
// '견적 관리'의 하위 메뉴들을 정의합니다.
$menu['menu800'][] = array('800100', '견적 목록', G5_BBS_URL . '/board.php?bo_table=order', 'order_list');
$menu['menu800'][] = array('800200', '견적 통계', G5_ADMIN_URL . '/order_manage/statistics.php', 'order_statistics');
$menu['menu800'][] = array('800300', '시스템 설정', G5_ADMIN_URL . '/order_manage/config_manager.php', 'order_config');
$menu['menu800'][] = array('800400', '전문가 방문', G5_ADMIN_URL . '/order_manage/expert_visits.php', 'expert_visits');
$menu['menu800'][] = array('800500', '메일 템플릿', G5_ADMIN_URL . '/order_manage/mail_templates.php', 'mail_templates');
$menu['menu800'][] = array('800600', 'SMS 템플릿', G5_ADMIN_URL . '/order_manage/sms_templates.php', 'sms_templates');
$menu['menu800'][] = array('800700', '알림 로그 관리', G5_ADMIN_URL.'/order_manage/notification_log.php', 'notification_log');
$menu['menu800'][] = array('800800', '전문가 방문 예약', G5_ADMIN_URL.'/order_manage/expert_visit_reservations.php', 'expert_visit_reservations');
$menu['menu800'][] = array('800850', '전문가 방문 스케줄', G5_ADMIN_URL.'/order_manage/expert_visit_schedule.php', 'expert_visit_schedule');
$menu['menu800'][] = array('800860', '윈도우 창호 관리', G5_ADMIN_URL.'/order_manage/brand_manager.php', 'brand_manager');
$menu['menu800'][] = array('800900', '솔루션 설치', G5_ADMIN_URL . '/order_manage/install.php', 'order_solution_install');
+167
View File
@@ -0,0 +1,167 @@
<?php
$sub_menu = "800860";
include_once('./_common.php');
auth_check($auth[$sub_menu], 'r');
$g5['title'] = '창호 브랜드 관리';
include_once(G5_ADMIN_PATH.'/admin.head.php');
$sql_common = " FROM `order_window_brands` ";
$sql_search = " WHERE is_deleted = 0 ";
// 검색어 처리
$stx = isset($_GET['stx']) ? clean_xss_tags(trim($_GET['stx'])) : '';
if ($stx) {
$sql_search .= " AND ( brand_name LIKE '%{$stx}%' OR brand_code LIKE '%{$stx}%' OR manufacturer LIKE '%{$stx}%' ) ";
}
$sql_order = " ORDER BY sort_order ASC, brand_name ASC ";
$sql = " SELECT COUNT(*) AS cnt {$sql_common} {$sql_search} ";
$row = sql_fetch($sql);
$total_count = $row['cnt'];
$rows = $config['cf_page_rows'];
$total_page = ceil($total_count / $rows);
if ($page < 1) $page = 1;
$from_record = ($page - 1) * $rows;
$sql = " SELECT * {$sql_common} {$sql_search} {$sql_order} LIMIT {$from_record}, {$rows} ";
$result = sql_query($sql);
$list_num = $total_count - ($page - 1) * $rows;
?>
<div class="local_desc01 local_desc">
<p>
대리점이 견적서 작성 시 선택할 수 있는 창호 브랜드를 관리합니다.<br>
정렬 숫자가 낮을수록 목록 상단에 표시됩니다.
</p>
</div>
<form name="fsearch" id="fsearch" class="local_sch01 local_sch" method="get">
<label for="stx" class="sound_only">검색어</label>
<input type="text" name="stx" value="<?php echo $stx ?>" id="stx" class="frm_input" placeholder="브랜드명, 코드, 제조사">
<input type="submit" value="검색" class="btn_submit">
</form>
<form name="fbrandlist" id="fbrandlist" method="post" action="./brand_manager_update.php" autocomplete="off">
<input type="hidden" name="page" value="<?php echo $page; ?>">
<input type="hidden" name="stx" value="<?php echo $stx; ?>">
<div class="tbl_head01 tbl_wrap">
<table>
<caption><?php echo $g5['title']; ?> 목록</caption>
<thead>
<tr>
<th scope="col">
<label for="chkall" class="sound_only">브랜드 전체</label>
<input type="checkbox" name="chkall" value="1" id="chkall" onclick="check_all(this.form)">
</th>
<th scope="col">브랜드명</th>
<th scope="col">브랜드 코드</th>
<th scope="col">제조사</th>
<th scope="col">정렬</th>
<th scope="col">사용</th>
<th scope="col">관리</th>
</tr>
</thead>
<tbody>
<?php
for ($i=0; $row=sql_fetch_array($result); $i++) {
$bg = 'bg'.($i%2);
?>
<tr class="<?php echo $bg; ?>">
<td class="td_chk">
<input type="hidden" name="id[<?php echo $i ?>]" value="<?php echo $row['id'] ?>">
<label for="chk_<?php echo $i; ?>" class="sound_only"><?php echo get_text($row['brand_name']); ?> 브랜드</label>
<input type="checkbox" name="chk[]" value="<?php echo $i ?>" id="chk_<?php echo $i ?>">
</td>
<td>
<input type="text" name="brand_name[<?php echo $i ?>]" value="<?php echo get_text($row['brand_name']) ?>" class="frm_input" size="15" required>
</td>
<td>
<input type="text" name="brand_code[<?php echo $i ?>]" value="<?php echo get_text($row['brand_code']) ?>" class="frm_input" size="10">
</td>
<td>
<input type="text" name="manufacturer[<?php echo $i ?>]" value="<?php echo get_text($row['manufacturer']) ?>" class="frm_input" size="15">
</td>
<td class="td_num">
<input type="number" name="sort_order[<?php echo $i ?>]" value="<?php echo get_text($row['sort_order']) ?>" class="frm_input" size="3">
</td>
<td class="td_chk">
<input type="checkbox" name="is_used[<?php echo $i ?>]" value="1" id="is_used_<?php echo $i ?>" <?php echo $row['is_used']?'checked':''; ?>>
<label for="is_used_<?php echo $i; ?>" class="sound_only">사용</label>
</td>
<td class="td_mng td_mng_s">
<a href="./brand_manager_update.php?w=d&amp;id=<?php echo $row['id']; ?>&amp;page=<?php echo $page; ?>&amp;stx=<?php echo $stx; ?>&amp;token=<?php echo $token; ?>" onclick="return delete_confirm(this);" class="btn btn_02">삭제</a>
</td>
</tr>
<?php
}
if ($i == 0)
echo '<tr><td colspan="7" class="empty_table">자료가 없습니다.</td></tr>';
?>
</tbody>
</table>
</div>
<div class="btn_fixed_top">
<input type="submit" name="act_button" value="선택수정" onclick="document.pressed=this.value" class="btn btn_02">
<input type="submit" name="act_button" value="선택삭제" onclick="document.pressed=this.value" class="btn btn_02">
</div>
</form>
<form name="fbrandadd" id="fbrandadd" method="post" action="./brand_manager_update.php" autocomplete="off" class="form_01">
<input type="hidden" name="page" value="<?php echo $page; ?>">
<input type="hidden" name="stx" value="<?php echo $stx; ?>">
<h2>브랜드 추가</h2>
<div class="tbl_frm01 tbl_wrap">
<table>
<caption>브랜드 추가</caption>
<colgroup>
<col class="grid_4">
<col>
</colgroup>
<tbody>
<tr>
<th scope="row"><label for="brand_name">브랜드명<strong class="sound_only">필수</strong></label></th>
<td><input type="text" name="brand_name" id="brand_name" required class="required frm_input" size="30"></td>
</tr>
<tr>
<th scope="row"><label for="brand_code">브랜드 코드</label></th>
<td><input type="text" name="brand_code" id="brand_code" class="frm_input" size="15"></td>
</tr>
<tr>
<th scope="row"><label for="manufacturer">제조사</label></th>
<td><input type="text" name="manufacturer" id="manufacturer" class="frm_input" size="30"></td>
</tr>
<tr>
<th scope="row"><label for="sort_order">정렬</label></th>
<td><input type="number" name="sort_order" value="0" id="sort_order" class="frm_input" size="5"></td>
</tr>
<tr>
<th scope="row"><label for="is_used">사용여부</label></th>
<td>
<input type="checkbox" name="is_used" value="1" id="is_used" checked>
<label for="is_used">사용</label>
</td>
</tr>
</tbody>
</table>
</div>
<div class="btn_confirm01 btn_confirm">
<input type="submit" name="act_button" value="추가" class="btn_submit">
</div>
</form>
<?php
echo get_paging(G5_IS_MOBILE ? $config['cf_mobile_pages'] : $config['cf_write_pages'], $page, $total_page, "{$_SERVER['SCRIPT_NAME']}?$qstr&amp;page=");
include_once(G5_ADMIN_PATH.'/admin.tail.php');
?>
+83
View File
@@ -0,0 +1,83 @@
<?php
$sub_menu = "800700";
include_once('./_common.php');
if ($w == 'd')
auth_check($auth[$sub_menu], 'd');
else
auth_check($auth[$sub_menu], 'w');
check_admin_token();
$act_button = isset($_POST['act_button']) ? $_POST['act_button'] : '';
if ($act_button == "선택수정") {
for ($i=0; $i<count($_POST['chk']); $i++) {
$k = isset($_POST['chk'][$i]) ? (int) $_POST['chk'][$i] : 0;
$id = isset($_POST['id'][$k]) ? (int) $_POST['id'][$k] : 0;
$sql = "UPDATE `order_window_brands`
SET brand_name = '".sql_real_escape_string($_POST['brand_name'][$k])."',
brand_code = '".sql_real_escape_string($_POST['brand_code'][$k])."',
manufacturer = '".sql_real_escape_string($_POST['manufacturer'][$k])."',
sort_order = '".(int)($_POST['sort_order'][$k])."',
is_used = '".(isset($_POST['is_used'][$k]) ? 1 : 0)."'
WHERE id = '{$id}'";
sql_query($sql);
}
} else if ($act_button == "선택삭제") {
for ($i=0; $i<count($_POST['chk']); $i++) {
$k = isset($_POST['chk'][$i]) ? (int) $_POST['chk'][$i] : 0;
$id = isset($_POST['id'][$k]) ? (int) $_POST['id'][$k] : 0;
// 소프트 삭제 (is_deleted 플래그 사용)
$sql = "UPDATE `order_window_brands` SET is_deleted = 1 WHERE id = '{$id}'";
sql_query($sql);
}
} else if ($act_button == "추가") {
$brand_name = isset($_POST['brand_name']) ? trim(strip_tags(clean_xss_attributes($_POST['brand_name']))) : '';
if (!$brand_name) {
alert('브랜드명을 입력해주세요.');
}
// 중복 체크
$sql = "SELECT COUNT(*) as cnt FROM `order_window_brands` WHERE brand_name = '".sql_real_escape_string($brand_name)."' AND is_deleted = 0";
$row = sql_fetch($sql);
if ($row['cnt']) {
alert('이미 등록된 브랜드명입니다.');
}
$sql = "INSERT INTO `order_window_brands`
SET brand_name = '".sql_real_escape_string($brand_name)."',
brand_code = '".sql_real_escape_string($_POST['brand_code'])."',
manufacturer = '".sql_real_escape_string($_POST['manufacturer'])."',
sort_order = '".(int)($_POST['sort_order'])."',
is_used = '".(isset($_POST['is_used']) ? 1 : 0)."',
is_deleted = 0,
created_at = NOW(),
updated_at = NOW()";
sql_query($sql);
} else if ($w == 'd') {
$id = isset($_GET['id']) ? (int) $_GET['id'] : 0;
if (!$id) {
alert('잘못된 접근입니다.');
}
// 소프트 삭제 (is_deleted 플래그 사용)
$sql = "UPDATE `order_window_brands` SET is_deleted = 1 WHERE id = '{$id}'";
sql_query($sql);
} else {
alert('잘못된 접근입니다.');
}
$qstr = "page={$page}&amp;stx={$stx}";
goto_url("./brand_manager.php?{$qstr}");
?>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,73 @@
<?php
if (!defined('_GNUBOARD_')) exit;
class FormManager
{
private $ui_manager_table;
private $form_category_table;
private $common_lang_table;
public function __construct()
{
global $g5;
$this->ui_manager_table = $g5['ui_manager_table'];
$this->form_category_table = $g5['form_category_table'];
$this->common_lang_table = $g5['common_lang_table'];
}
/**
* 특정 화면의 모든 UI 리소스를 가져와 구조화된 배열로 반환합니다.
* @param string $screen_code 가져올 화면 코드 (e.g., 'order_form')
* @param string $lang_code 언어 코드 (기본값 'ko')
* @return array 구조화된 리소스 배열
*/
public function getResources($screen_code, $lang_code = 'ko')
{
$sql = "SELECT
A.group_code,
A.resource_code,
A.resource_type,
A.resource_desc,
B.cl_name AS label,
B.cl_description AS tooltip,
C.fc_key,
C.fc_order,
D.cl_name AS option_name
FROM {$this->ui_manager_table} AS A
LEFT JOIN {$this->common_lang_table} AS B
ON (A.um_id = B.target_id AND B.target_table = '{$this->ui_manager_table}' AND B.lang_code = '{$lang_code}')
LEFT JOIN {$this->form_category_table} AS C
ON (A.um_id = C.um_id AND C.is_deleted = 0 AND C.is_used = 1)
LEFT JOIN {$this->common_lang_table} AS D
ON (C.fc_id = D.target_id AND D.target_table = '{$this->form_category_table}' AND D.lang_code = '{$lang_code}')
WHERE A.screen_code = '" . sql_real_escape_string($screen_code) . "' AND A.is_used = 1
ORDER BY A.group_code, A.resource_code, C.fc_order, C.fc_id";
$result = sql_query($sql);
$resources = [];
while ($row = sql_fetch_array($result)) {
$group = $row['group_code'];
$code = $row['resource_code'];
if (!isset($resources[$group][$code])) {
$resources[$group][$code] = [
'type' => $row['resource_type'],
'desc' => $row['resource_desc'],
'label' => $row['label'],
'tooltip' => $row['tooltip'],
'options' => [],
];
}
if ($row['resource_type'] === 'DATA' && $row['fc_key']) {
$resources[$group][$code]['options'][] = [
'key' => $row['fc_key'],
'name' => $row['option_name'],
];
}
}
return $resources;
}
}
@@ -0,0 +1,351 @@
<?php
if (!defined('_GNUBOARD_'))
exit;
/**
* 견적 상태 관리 클래스
*
* 견적 상태 변경, 권한 검증, 이력 관리를 담당합니다.
*/
class StatusManager
{
private $db;
public function __construct()
{
global $g5;
$this->db = $g5;
}
/**
* 상태 코드를 화면 표시용 이름으로 변환합니다.
* @param string $status_code 실제 상태 코드
* @return string 화면 표시용 이름
*/
public static function get_status_display_name($status_code)
{
$config_key = $status_code;
$result = sql_fetch("SELECT config_value FROM order_config WHERE config_key = '" . sql_real_escape_string($config_key) . "'");
return $result ? $result['config_value'] : $status_code;
}
/**
* 사용자 역할별 상태 전환 규칙
*/
private function getStatusTransitions($user_role)
{
$customer_flow = [
'견적신청중' => ['작성완료'],
'작성완료' => [], // 고객은 직접 변경 불가. '견적채택' 시 자동으로 '계약금입금예정'으로 변경됨.
'견적채택' => [],
'계약금입금예정' => [],
'계약금입금완료' => [],
'중도금입금예정' => [],
'중도금입금완료' => [],
'잔금입금예정' => [],
'잔금처리완료' => [],
'시공완료' => []
];
$agent_flow = [
'견적제안' => [], // 대리점은 제안만 가능. 채택/취소는 고객이 함.
'견적채택' => ['시공완료'], // 시공 완료 후 상태 변경
'견적취소' => [],
'시공완료' => []
];
// 관리자는 모든 상태로 변경 가능하도록 모든 상태를 나열합니다.
$all_statuses = array_keys(get_all_status_names());
$admin_flow = [];
foreach ($all_statuses as $status) {
$admin_flow[$status] = $all_statuses;
}
$transitions = [
'customer' => $customer_flow,
'agent' => $agent_flow,
'admin' => $admin_flow
];
// [예외 규칙] 고객은 대리점의 '견적제안'을 '견적채택' 할 수 있습니다.
if ($user_role === 'customer') {
$transitions['customer']['견적제안'] = ['견적채택'];
}
return $transitions;
}
/**
* 상태 전환 유효성 검증
*/
public function validateStatusTransition($current_status, $new_status, $user_role, $wr_id = null)
{
if ($user_role === 'admin') {
return ['valid' => true, 'message' => '관리자 권한으로 상태를 변경합니다.'];
}
$transitions = $this->getStatusTransitions($user_role);
if (!isset($transitions[$current_status])) {
return ['valid' => false, 'message' => "현재 상태 '{$current_status}'에서는 상태 변경이 불가능합니다."];
}
if (!in_array($new_status, $transitions[$current_status])) {
return ['valid' => false, 'message' => "'{$current_status}'에서 '{$new_status}'로 변경할 수 없습니다."];
}
return ['valid' => true, 'message' => '상태 변경이 가능합니다.'];
}
/**
* 상태 변경 실행
*/
public function changeStatus($wr_id, $new_status, $user_id, $user_role, $memo = '')
{
try {
$current_data = $this->getCurrentStatus($wr_id);
if (!$current_data['success']) {
return $current_data;
}
$current_status = $current_data['data']['status'];
$validation = $this->validateStatusTransition($current_status, $new_status, $user_role, $wr_id);
if (!$validation['valid']) {
return ['success' => false, 'message' => $validation['message']];
}
sql_query("BEGIN");
$update_result = $this->updateStatus($wr_id, $new_status, $user_id);
if (!$update_result['success']) {
sql_query("ROLLBACK");
return $update_result;
}
$history_result = $this->logStatusChange($wr_id, $current_status, $new_status, $user_id, $memo);
if (!$history_result['success']) {
sql_query("ROLLBACK");
return $history_result;
}
// ❗ [핵심 수정] 후속 처리 로직 호출 복원
$post_process_result = $this->postProcessStatusChange($wr_id, $current_status, $new_status, $user_id);
if (!$post_process_result['success']) {
// 후속 처리 실패는 경고만 하고 롤백하지 않음
error_log("Status change post-process warning: " . $post_process_result['message']);
}
sql_query("COMMIT");
return [
'success' => true,
'message' => "상태가 '" . self::get_status_display_name($current_status) . "'에서 '" . self::get_status_display_name($new_status) . "'로 변경되었습니다."
];
} catch (Exception $e) {
sql_query("ROLLBACK");
error_log("StatusManager::changeStatus Error: " . $e->getMessage());
return ['success' => false, 'message' => '상태 변경 중 오류가 발생했습니다: ' . $e->getMessage()];
}
}
/**
* 현재 상태 조회
*/
public function getCurrentStatus($wr_id)
{
$sql = "SELECT wr_reply FROM {$this->db['write_prefix']}order WHERE wr_id = '{$wr_id}'";
$write_data = sql_fetch("SELECT wr_reply FROM {$this->db['write_prefix']}order WHERE wr_id = '{$wr_id}'");
if (!$write_data) {
return ['success' => false, 'message' => '게시물을 찾을 수 없습니다.'.'$wr_id => '.$wr_id.'$sql -> '.$sql];
}
if (empty($write_data['wr_reply'])) { // 원본 글 (고객)
$estimate = sql_fetch("SELECT status FROM estimate WHERE wr_id = '{$wr_id}' AND is_deleted = 0");
$status = $estimate ? $estimate['status'] : '견적신청중';
} else { // 답변 글 (대리점)
$reply_data = sql_fetch("SELECT wr_1 FROM {$this->db['write_prefix']}order WHERE wr_id = '{$wr_id}'");
$status = $reply_data['wr_1'] ?: '견적제안';
}
return ['success' => true, 'data' => ['status' => $status]];
}
/**
* 상태 업데이트 실행
*/
private function updateStatus($wr_id, $new_status, $user_id)
{
// 🔥 [핵심 수정] 원본글/답변글 구분 없이 estimate 테이블의 status를 업데이트합니다.
// estimate 테이블에 해당 wr_id의 레코드가 있는지 먼저 확인합니다.
$estimate_exists = sql_fetch("SELECT id FROM estimate WHERE wr_id = '{$wr_id}'");
if ($estimate_exists) {
$sql = "UPDATE estimate SET status = '" . sql_real_escape_string($new_status) . "', updated_at = NOW(), updated_by = '" . sql_real_escape_string($user_id) . "' WHERE wr_id = '{$wr_id}'";
$result = sql_query($sql);
if (!$result) {
return ['success' => false, 'message' => '견적서 상태 업데이트 실패: ' . sql_error()];
}
}
// 🔥 [핵심 수정] 게시판의 wr_1 필드도 동기화합니다.
$sql_board = "UPDATE {$this->db['write_prefix']}order SET wr_1 = '" . sql_real_escape_string($new_status) . "' WHERE wr_id = '{$wr_id}'";
$result_board = sql_query($sql_board);
if (!$result_board) {
// This is not a critical failure, so maybe just log it.
error_log("게시판(wr_1) 상태 동기화 실패: wr_id={$wr_id}, new_status={$new_status}");
}
return ['success' => true, 'message' => '상태 업데이트 성공'];
}
/**
* 상태 변경 이력 기록
*/
public function logStatusChange($wr_id, $old_status, $new_status, $user_id, $memo = '')
{
$write = sql_fetch("SELECT wr_num, wr_reply FROM {$this->db['write_prefix']}order WHERE wr_id = '{$wr_id}'");
if (!$write) {
return ['success' => false, 'message' => '이력 기록을 위한 게시물을 찾을 수 없습니다.'];
}
$origin_wr_id = $wr_id;
if (!empty($write['wr_reply'])) {
$origin_post = sql_fetch("SELECT wr_id FROM {$this->db['write_prefix']}order WHERE wr_num = '{$write['wr_num']}' AND wr_reply = ''");
if ($origin_post) {
$origin_wr_id = $origin_post['wr_id'];
} else {
return ['success' => false, 'message' => '이력 기록을 위한 원본 견적을 찾을 수 없습니다.'];
}
}
$estimate = sql_fetch("SELECT id FROM estimate WHERE wr_id = '{$origin_wr_id}'");
if (!$estimate) {
return ['success' => false, 'message' => '견적 정보를 찾을 수 없습니다.'];
}
$change_details = json_encode(['wr_id' => $wr_id, 'old_status' => $old_status, 'new_status' => $new_status, 'memo' => $memo, 'timestamp' => date('Y-m-d H:i:s')], JSON_UNESCAPED_UNICODE);
$sql = "INSERT INTO estimate_history (estimate_id, action, change_details, changed_by, changed_at, created_at, created_by) VALUES ('{$estimate['id']}', 'status_change', '" . sql_real_escape_string($change_details) . "', '" . sql_real_escape_string($user_id) . "', NOW(), NOW(), '" . sql_real_escape_string($user_id) . "')";
$result = sql_query($sql);
if (!$result) {
return ['success' => false, 'message' => '이력 기록 실패: ' . sql_error()];
}
return ['success' => true, 'message' => '이력 기록 성공'];
}
/**
* 상태 변경 이력 조회
*/
public function getStatusHistory($wr_id, $limit = 10)
{
$write = sql_fetch("SELECT wr_num, wr_reply FROM {$this->db['write_prefix']}order WHERE wr_id = '{$wr_id}'");
if (!$write) return [];
$origin_wr_id = $wr_id;
if (!empty($write['wr_reply'])) {
$origin_post = sql_fetch("SELECT wr_id FROM {$this->db['write_prefix']}order WHERE wr_num = '{$write['wr_num']}' AND wr_reply = ''");
$origin_wr_id = $origin_post ? $origin_post['wr_id'] : 0;
}
if (!$origin_wr_id) return [];
$estimate = sql_fetch("SELECT id FROM estimate WHERE wr_id = '{$origin_wr_id}'");
if (!$estimate) return [];
$sql = "SELECT * FROM estimate_history WHERE estimate_id = '{$estimate['id']}' AND action = 'status_change' ORDER BY changed_at DESC LIMIT {$limit}";
$result = sql_query($sql);
$history = [];
while ($row = sql_fetch_array($result)) {
$details = json_decode($row['change_details'], true);
$history[] = [
'id' => $row['id'],
'changed_at' => $row['changed_at'],
'changed_by' => $row['changed_by'],
'old_status' => self::get_status_display_name($details['old_status'] ?? ''),
'new_status' => self::get_status_display_name($details['new_status'] ?? ''),
'memo' => $details['memo'] ?? ''
];
}
return $history;
}
private function write_debug_log_ff($message)
{
$log_dir = G5_PATH . '/log';
// 1. 디렉토리 존재 여부 확인 및 생성
if (!is_dir($log_dir)) {
if (!@mkdir($log_dir, 0755, true) && !is_dir($log_dir)) {
error_log("--- NotificationSender ERROR: 디버그 로그 디렉토리 생성 실패. '{$log_dir}' 경로를 확인하거나 수동으로 생성 후 웹서버 쓰기 권한을 부여해주세요.");
return;
}
}
// 2. 디렉토리 쓰기 권한 확인
if (!is_writable($log_dir)) {
error_log("--- NotificationSender ERROR: 디버그 로그 쓰기 오류. '{$log_dir}' 디렉토리에 쓰기 권한이 없습니다. 웹서버의 폴더 권한을 확인해주세요.");
return;
}
// 3. 로그 파일에 내용 기록
$log_file = $log_dir . '/notification_debug.log';
$log_message = date("[Y-m-d H:i:s]") . " " . $message . "\n";
if (file_put_contents($log_file, $log_message, FILE_APPEND | LOCK_EX) === false) {
error_log("--- NotificationSender ERROR: 디버그 로그 파일 쓰기 실패. '{$log_file}' 파일에 내용을 쓸 수 없습니다.");
}
}
/**
* ❗ [핵심 수정] 상태 변경 후 후속 처리
*/
private function postProcessStatusChange($wr_id, $old_status, $new_status, $user_id)
{
$this->write_debug_log_ff("[postProcessStatusChange] 상태 변경 후 후속 처리 시작 : {$wr_id} old_status = {$old_status} new_status = {$new_status} user_id = {$user_id}");
try {
// 1. 상태 변경에 따른 알림 발송
if (function_exists('notify_by_status')) {
$this->write_debug_log_ff('function_exists start');
notify_by_status($wr_id, $new_status);
}
// 2. [핵심] '견적채택' 시 후속 처리
if ($new_status === '견적채택') {
$write = sql_fetch("SELECT wr_num, mb_id FROM {$this->db['write_prefix']}order WHERE wr_id = '{$wr_id}'");
if ($write) {
$origin_post = sql_fetch("SELECT wr_id FROM {$this->db['write_prefix']}order WHERE wr_num = '{$write['wr_num']}' AND wr_reply = ''");
if ($origin_post) {
$origin_wr_id = $origin_post['wr_id'];
$selected_dealer_id = $write['mb_id'];
// 2-1. 원본글 상태를 '견적채택'으로 변경
$this->updateStatus($origin_wr_id, '계약금입금예정', $user_id);
$this->logStatusChange($origin_wr_id, '작성완료', '계약금입금예정', $user_id, "대리점({$selected_dealer_id}) 견적 채택됨");
// 2-2. 다른 대리점들의 제안을 '견적취소'로 변경
$other_replies_sql = "SELECT wr_id FROM {$this->db['write_prefix']}order WHERE wr_num = '{$write['wr_num']}' AND wr_reply != '' AND wr_id != '{$wr_id}'";
$other_replies_result = sql_query($other_replies_sql);
while ($other_reply = sql_fetch_array($other_replies_result)) {
$this->updateStatus($other_reply['wr_id'], '견적취소', 'system');
$this->logStatusChange($other_reply['wr_id'], '견적제안', '견적취소', 'system', "다른 견적 채택으로 자동 취소됨");
}
// 2-3. estimate_bidding 테이블의 상태도 업데이트 (선택/거절)
$origin_estimate = sql_fetch("SELECT id FROM estimate WHERE wr_id = '{$origin_wr_id}'");
if ($origin_estimate) {
$estimate_id = $origin_estimate['id'];
sql_query("UPDATE estimate_bidding SET status = 'rejected' WHERE estimate_id = '{$estimate_id}'");
sql_query("UPDATE estimate_bidding SET status = 'selected' WHERE estimate_id = '{$estimate_id}' AND dealer_id = '{$selected_dealer_id}'");
}
}
}
}
} catch (Exception $e) {
error_log("StatusManager::postProcessStatusChange Error: " . $e->getMessage());
return ['success' => false, 'message' => '후속 처리 중 오류: ' . $e->getMessage()];
}
return ['success' => true, 'message' => '후속 처리 완료'];
}
}
?>
@@ -0,0 +1,25 @@
<?php
if (!defined('_GNUBOARD_')) exit;
// 이 파일은 write_update.php 파일의 가장 마지막, 페이지 이동 직전에 include 됩니다.
if ($w == '' || $w == 'r') { // 새 글 또는 답변 글일 때만 실행
if (isset($wr_id) && $wr_id) {
// EstimateManager 클래스 파일 포함
include_once(G5_ADMIN_PATH . '/code_manager/classes/EstimateManager.class.php');
try {
$estimateManager = new EstimateManager();
// $_POST 데이터를 사용하여 견적서 생성
$estimate_id = $estimateManager->createCustomerRequest($wr_id, $_POST);
if (!$estimate_id) {
// 견적서 저장 실패 시 로그를 남기거나 관리자에게 알림을 보낼 수 있습니다.
// 예: error_log("견적서 저장 실패: wr_id={$wr_id}");
}
} catch (Exception $e) {
// 예외 발생 시 처리
// 예: error_log("견적서 저장 중 예외 발생: " . $e->getMessage());
}
}
}
@@ -0,0 +1,14 @@
<?php
if (!defined('_GNUBOARD_')) exit;
// 이 파일은 상담 예약 관련 팝업들을 한번에 쉽게 포함하기 위해 사용됩니다.
// 현재 파일의 경로를 기준으로 팝업 파일들의 경로를 정의합니다.
$expert_visit_components_path = dirname(__FILE__);
// 상담 예약 신청 팝업 포함
include_once($expert_visit_components_path . '/expert_visit_popup.php');
// 예약 확인/취소 팝업 포함
//include_once($expert_visit_components_path . '/my_reservations_popup.php');
?>
@@ -0,0 +1,207 @@
/* 💡 [추가] 로딩 오버레이 스타일 */
.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); } }
/* 팝업 오버레이 */
.expert-visit-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;
}
.expert-visit-modal-overlay.active {
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.expert-visit-modal-content {
background: #fff;
border-radius: 10px;
width: 100%;
max-width: 550px; /* 너비 약간 축소 */
max-height: 85vh; /* 높이 제한 축소 */
overflow-y: auto;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
position: relative;
}
.expert-visit-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px; /* 패딩 축소 */
border-bottom: 1px solid #eee;
background: #f8f9fa;
border-radius: 10px 10px 0 0;
}
.expert-visit-modal-header h2 { margin: 0; font-size: 18px; font-weight: 600; color: #333; }
.expert-visit-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;
}
.expert-visit-modal-close:hover { background: #e9ecef; color: #333; }
.expert-visit-modal-body { padding: 20px; } /* 패딩 축소 */
/* 단계 표시 */
.expert-visit-steps { display: flex; justify-content: center; margin-bottom: 20px; position: relative; } /* 마진 축소 */
.expert-visit-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: 28px; height: 28px; border-radius: 50%; background: #e9ecef; color: #6c757d; /* 크기 축소 */
display: flex; align-items: center; justify-content: center; font-weight: 600; margin-bottom: 6px; transition: all 0.3s ease; font-size: 13px;
}
.step.active .step-number { background: #007bff; color: #fff; }
.step.completed .step-number { background: #28a745; color: #fff; }
.step-text { font-size: 11px; 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: 15px; } /* 마진 축소 */
.calendar-nav {
background: none; border: 1px solid #ddd; width: 30px; height: 30px; 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: 16px; 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: 8px 4px; text-align: center; font-weight: 600; color: #495057; font-size: 13px; 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: 13px; position: relative; padding: 2px; /* 폰트 및 패딩 축소 */
}
.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: 30px 20px; color: #666; font-style: italic; }
.error { color: #dc3545; }
/* 범례 */
.calendar-legend, .time-legend { display: flex; justify-content: center; gap: 15px; margin-top: 10px; flex-wrap: wrap; }
.legend-item { display: flex; align-items: center; gap: 4px; font-size: 11px; color: #666; }
.legend-color { width: 10px; height: 10px; 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: 15px; }
.step-description h4 { margin: 0 0 8px 0; font-size: 16px; }
.step-description p { margin: 0; font-size: 13px; color: #666; }
.selected-date-info h4 { margin: 0; color: #333; font-size: 15px; }
.time-slots-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 8px; margin-bottom: 15px; }
.time-slot {
padding: 10px 6px; border: 2px solid #e9ecef; border-radius: 6px; text-align: center; cursor: pointer;
transition: all 0.2s ease; font-size: 13px; font-weight: 500; display: flex; flex-direction: column; gap: 3px;
}
.time-text { font-weight: 600; }
.slot-info { font-size: 10px; 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; }
/* 고객 정보 폼 */
.expert-visit-summary { background: #f8f9fa; padding: 12px; border-radius: 8px; margin-bottom: 15px; }
.expert-visit-summary h5 { margin: 0 0 8px 0; font-size: 15px; }
.summary-grid { display: grid; grid-template-columns: 1fr; gap: 6px; }
.summary-item { display: flex; justify-content: space-between; font-size: 13px; }
.summary-item .label { font-weight: 500; color: #495057; }
.summary-item span { color: #333; }
.customer-info-form h4 { margin: 0 0 12px 0; color: #333; font-size: 15px; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.form-group { margin-bottom: 12px; }
.form-group label { display: block; margin-bottom: 6px; font-weight: 500; color: #495057; font-size: 13px; }
.required { color: #dc3545; font-weight: bold; }
.form-group input, .form-group textarea, .form-group select {
width: 100%; padding: 10px; border: 1px solid #ced4da; border-radius: 6px;
font-size: 13px; transition: border-color 0.2s ease; box-sizing: border-box;
}
.form-group textarea { resize: vertical; min-height: 60px; }
.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);
}
/* 네비게이션 버튼 */
.expert-visit-nav-buttons {
display: flex; justify-content: space-between; gap: 10px; margin-top: 15px;
padding-top: 15px; border-top: 1px solid #e9ecef;
}
.expert-visit-nav-buttons button {
padding: 10px 20px; border: none; border-radius: 6px; font-size: 13px;
font-weight: 600; cursor: pointer; transition: all 0.2s ease; min-width: 90px;
}
.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) {
.expert-visit-modal-content { margin: 10px; max-height: 95vh; border-radius: 8px; }
.expert-visit-modal-header, .expert-visit-modal-body { padding: 15px; }
.expert-visit-modal-header h2 { font-size: 16px; }
.expert-visit-steps { margin-bottom: 15px; }
.step { padding: 0 8px; }
.step-number { width: 24px; height: 24px; font-size: 12px; }
.step-text { font-size: 10px; }
.calendar-day { font-size: 12px; }
.time-slots-grid { grid-template-columns: repeat(auto-fit, minmax(80px, 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) {
.expert-visit-modal-overlay { padding: 0; align-items: flex-end; }
.expert-visit-modal-content { margin: 0; width: 100%; max-height: 90vh; border-radius: 10px 10px 0 0; }
.expert-visit-nav-buttons { flex-direction: column-reverse; gap: 10px; }
.expert-visit-nav-buttons button { width: 100%; }
}
@@ -0,0 +1,726 @@
<?php
/**
* 전문가 방문 예약 팝업 UI 및 데이터 처리
*/
// AJAX 요청 처리
if (isset($_POST['action'])) {
include_once('../_common_con.php');
include_once('../lib/notification_helper.php');
header('Content-Type: application/json');
$action = $_POST['action'] ?? '';
$response = ['success' => false, 'message' => '알 수 없는 요청입니다.'];
// 월별 예약 가능일 조회
if ($action === 'get_expert_visit_month_availability') {
$year = (int) ($_POST['year'] ?? 0);
$month = (int) ($_POST['month'] ?? 0);
$expert_id = $_POST['expert_id'] ?? ''; // 전문가 ID 추가
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 = get_order_config('expert_visit_max_advance_days', 30);
$max_date = date('Y-m-d', strtotime("+" . $max_advance_days . " days"));
// 날짜별로 루프를 돌며 가용성을 체크합니다.
// 쿼리 하나로 처리하기 복잡하므로, 기간 내의 모든 스케줄을 가져와서 PHP에서 병합합니다.
// 1. 기간 내의 모든 '지정일' 스케줄 가져오기
$specific_schedules = [];
$sql_specific = "SELECT * FROM expert_visit_schedules
WHERE specific_date BETWEEN '{$start_date}' AND '{$end_date}'
AND (expert_id = '{$expert_id}' OR expert_id IS NULL)
ORDER BY expert_id DESC"; // 전문가 설정 우선
$res_specific = sql_query($sql_specific);
while ($row = sql_fetch_array($res_specific)) {
$date = $row['specific_date'];
if (!isset($specific_schedules[$date])) { // 이미 전문가 설정이 있으면 공통 설정은 무시
$specific_schedules[$date] = $row;
}
}
// 2. '요일별' 스케줄 가져오기
$weekly_schedules = [];
$sql_weekly = "SELECT * FROM expert_visit_schedules
WHERE day_of_week IS NOT NULL
AND (expert_id = '{$expert_id}' OR expert_id IS NULL)
ORDER BY expert_id DESC"; // 전문가 설정 우선
$res_weekly = sql_query($sql_weekly);
while ($row = sql_fetch_array($res_weekly)) {
$dow = $row['day_of_week'];
if (!isset($weekly_schedules[$dow])) {
$weekly_schedules[$dow] = $row;
}
}
$availability = [];
$current = strtotime($start_date);
$end = strtotime($end_date);
while ($current <= $end) {
$date_str = date('Y-m-d', $current);
$day_num = date('j', $current);
$dow = date('N', $current); // 1(월) ~ 7(일)
// 우선순위: 지정일 > 요일별
$schedule = $specific_schedules[$date_str] ?? ($weekly_schedules[$dow] ?? null);
$is_bookable = false;
$reason = '';
if (!$schedule) {
$reason = 'no_schedule'; // 스케줄 없음
} elseif ($schedule['is_available'] == 0) {
$reason = 'holiday'; // 휴무
} elseif ($date_str < date('Y-m-d')) {
$reason = 'past_date'; // 지난 날짜
} elseif ($date_str > $max_date) {
$reason = 'too_far'; // 예약 가능 기간 초과
} else {
// 예약 꽉 찼는지 확인
// 해당 날짜, 해당 전문가(또는 전체)의 예약 건수 확인
// 시간대별로 체크해야 정확하지만, 여기서는 '하루 전체 마감' 여부를 대략적으로 판단하거나
// 일단 '가능'으로 표시하고 시간 선택에서 막을 수 있습니다.
// 정확도를 위해 해당 날짜의 총 슬롯 수와 예약 수를 비교합니다.
$start_time = strtotime($date_str . ' ' . $schedule['start_time']);
$end_time = strtotime($date_str . ' ' . $schedule['end_time']);
$slot_duration = $schedule['time_slot'] * 60;
$total_slots = 0;
for ($t = $start_time; $t < $end_time; $t += $slot_duration) {
$total_slots++;
}
$max_capacity = $total_slots * $schedule['max_persons'];
// 예약된 건수 조회
$sql_reserved = "SELECT COUNT(*) as cnt FROM expert_visit_reservations
WHERE visit_date = '{$date_str}' AND status != 'cancelled'";
if ($expert_id) {
$sql_reserved .= " AND expert_id = '{$expert_id}'";
}
$row_reserved = sql_fetch($sql_reserved);
if ($row_reserved['cnt'] >= $max_capacity) {
$reason = 'full';
} else {
$is_bookable = true;
}
}
$availability[$day_num] = [
'available' => $is_bookable,
'reason' => $reason
];
$current = strtotime('+1 day', $current);
}
$response = ['success' => true, 'data' => $availability];
}
}
// 특정일의 예약 가능 시간 조회
if ($action === 'get_expert_visit_time_slots') {
$date = preg_replace('/[^0-9\-]/', '', $_POST['date'] ?? '');
$expert_id = $_POST['expert_id'] ?? '';
if ($date) {
$min_advance_hours = get_order_config('expert_visit_min_advance_hours', 24);
$min_datetime = date('Y-m-d H:i:s', strtotime("+" . $min_advance_hours . " hours"));
$dow = date('N', strtotime($date));
// 1. 해당 날짜의 스케줄 조회 (우선순위 적용)
$schedule = sql_fetch("SELECT * FROM expert_visit_schedules
WHERE (specific_date = '{$date}' OR (specific_date IS NULL AND day_of_week = '{$dow}'))
AND (expert_id = '{$expert_id}' OR expert_id IS NULL)
ORDER BY specific_date DESC, expert_id DESC LIMIT 1");
$slots = [];
if ($schedule && $schedule['is_available']) {
$start_time = strtotime($date . ' ' . $schedule['start_time']);
$end_time = strtotime($date . ' ' . $schedule['end_time']);
$slot_duration = $schedule['time_slot'] * 60;
$max_persons = $schedule['max_persons'];
for ($t = $start_time; $t < $end_time; $t += $slot_duration) {
$current_slot_time = date('H:i', $t);
$slot_datetime = $date . ' ' . $current_slot_time;
// 예약 건수 조회
$sql_reserved = "SELECT COUNT(*) as cnt FROM expert_visit_reservations
WHERE visit_date = '{$date}'
AND visit_time = '{$current_slot_time}:00'
AND status != 'cancelled'";
if ($expert_id) {
$sql_reserved .= " AND expert_id = '{$expert_id}'";
}
$reserved_count = (int)sql_fetch($sql_reserved)['cnt'];
$is_too_soon = ($slot_datetime < $min_datetime);
$is_full = ($reserved_count >= $max_persons);
$slots[] = [
'time' => $current_slot_time,
'available' => !$is_too_soon && !$is_full,
'reason' => $is_too_soon ? 'too_soon' : ($is_full ? 'full' : ''),
'reserved_count' => $reserved_count,
'max_persons' => $max_persons
];
}
}
$response = ['success' => true, 'data' => $slots];
}
}
echo json_encode($response);
exit;
}
if (!defined('_GNUBOARD_')) exit;
include_once(G5_ADMIN_PATH . '/order_manage/_common_con.php');
include_once(G5_ADMIN_PATH . '/order_manage/lib/notification_helper.php');
$current_year = date('Y');
$current_month_num = date('n');
$visit_fee = get_order_config('expert_visit_fee', 50000);
$account_info = get_order_config('expert_visit_account_info', '');
$max_advance_days = get_order_config('expert_visit_max_advance_days', 30);
$ajax_url = G5_ADMIN_URL . '/order_manage/components/expert_visit_popup.php';
$form_action_url = G5_ADMIN_URL . '/order_manage/components/expert_visit_submit.php';
$css_url = G5_ADMIN_URL . '/order_manage/components/expert_visit_popup.css';
$wr_id = isset($_GET['wr_id']) ? (int)$_GET['wr_id'] : 0;
// 💡 [추가] 로그인 회원 정보 가져오기
$customer_name = '';
$customer_phone = '';
$customer_email = '';
if (isset($member) && $member['mb_id']) {
$customer_name = $member['mb_name'];
$customer_phone = $member['mb_hp'];
$customer_email = $member['mb_email'];
}
?>
<link rel="stylesheet" href="<?php echo $css_url; ?>">
<div id="expert-visit-popup-overlay" class="expert-visit-modal-overlay">
<div id="expert-visit-popup" class="expert-visit-modal-content">
<div class="expert-visit-modal-header">
<h2>전문가 방문 예약</h2>
<button type="button" class="expert-visit-modal-close" aria-label="팝업 닫기">
<span>&times;</span>
</button>
</div>
<div class="expert-visit-modal-body">
<div class="loading-overlay" style="display: none;">
<div class="loading-spinner"></div>
</div>
<div class="expert-visit-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="expert-visit-form" method="post" action="<?php echo $form_action_url; ?>">
<div class="expert-visit-step-content" data-step="1">
<div class="step-description">
<h4>📅 방문 날짜를 선택해주세요</h4>
<p>최대 <?php echo $max_advance_days; ?>일 후까지 예약 가능합니다.</p>
<!-- 전문가 선택을 1단계로 이동 -->
<div class="form-group" style="margin-top: 15px;">
<label for="expert-select-step1">👨‍⚕️ 전문가 선택</label>
<select id="expert-select-step1" name="expert_id_display" class="form-control">
<option value="">선택 안 함 (전체 일정 보기)</option>
<?php
/* 전문가 목록 조회 (실제 DB 연동 필요)
$experts = get_experts();
foreach ($experts as $expert):
?>
<option value="<?php echo $expert['id']; ?>"><?php echo htmlspecialchars($expert['name']); ?></option>
<?php endforeach;
*/
?>
</select>
</div>
</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"></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>
<div class="expert-visit-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"></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>
<div class="expert-visit-step-content" data-step="3" style="display: none;">
<div class="step-description">
<h4>📝 고객 정보를 입력해주세요</h4>
</div>
<div class="expert-visit-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($visit_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="홍길동" value="<?php echo htmlspecialchars($customer_name); ?>">
</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" value="<?php echo htmlspecialchars($customer_phone); ?>">
</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" value="<?php echo htmlspecialchars($customer_email); ?>">
</div>
<div class="form-group">
<label for="expert-visit-type">🏠 방문 유형</label>
<select id="expert-visit-type" name="visit_type">
<option value="onsite">현장 방문</option>
<option value="online">온라인 상담</option>
<option value="phone">전화 상담</option>
</select>
</div>
<!-- 3단계 전문가 선택은 hidden으로 처리하고 1단계 값과 동기화 -->
<input type="hidden" id="expert-visit-resource" name="expert_id">
<div class="form-group">
<label for="customer-request">📝 요청사항</label>
<textarea id="customer-request" name="request_memo" rows="4" placeholder="방문 관련 요청사항이나 문의사항을 입력해주세요."></textarea>
</div>
</div>
<input type="hidden" id="selected-date" name="visit_date">
<input type="hidden" id="selected-time" name="visit_time">
<input type="hidden" name="payment_amount" value="<?php echo $visit_fee; ?>">
<input type="hidden" name="status" value="payment_pending">
<input type="hidden" name="wr_id" value="<?php echo $wr_id; ?>">
</div>
<div class="expert-visit-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>
<script>
const ExpertVisitPopup = {
elements: {},
state: {
currentStep: 1,
selectedDate: null,
selectedTime: null,
currentYear: new Date().getFullYear(),
currentMonth: new Date().getMonth() + 1,
expertId: '', // 전문가 ID 상태 추가
},
config: {
ajaxUrl: '<?php echo $ajax_url; ?>',
maxAdvanceDays: <?php echo (int)$max_advance_days; ?>,
},
init() {
this.elements.overlay = document.getElementById('expert-visit-popup-overlay');
if (!this.elements.overlay) return;
this.elements.popup = this.elements.overlay.querySelector('.expert-visit-modal-content');
this.elements.loading = this.elements.popup.querySelector('.loading-overlay');
this.elements.form = this.elements.popup.querySelector('#expert-visit-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.expertSelect = this.elements.popup.querySelector('#expert-select-step1');
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() {
const closeBtn = this.elements.popup.querySelector('.expert-visit-modal-close');
if (closeBtn) closeBtn.addEventListener('click', () => this.close());
this.elements.overlay.addEventListener('click', e => {
if (e.target === this.elements.overlay) this.close();
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && this.elements.overlay.classList.contains('active')) this.close();
});
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();
});
}
// 전문가 선택 변경 시 달력 갱신
if (this.elements.expertSelect) {
this.elements.expertSelect.addEventListener('change', (e) => {
this.state.expertId = e.target.value;
this.state.selectedDate = null; // 날짜 선택 초기화
this.renderCalendar();
});
}
},
open() {
this.state.currentYear = new Date().getFullYear();
this.state.currentMonth = new Date().getMonth() + 1;
this.goToStep(1);
this.renderCalendar();
this.elements.overlay.classList.add('active');
document.body.style.overflow = 'hidden';
},
close() {
this.elements.overlay.classList.remove('active');
document.body.style.overflow = '';
this.elements.form.reset();
this.state.selectedDate = null;
this.state.selectedTime = null;
this.state.expertId = '';
},
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('unavailable');
} else if (availability && availability.available) {
dayElement.classList.add('available');
// 이미 선택된 날짜라면 selected 클래스 추가
if (this.state.selectedDate === dateStr) {
dayElement.classList.add('selected');
}
dayElement.addEventListener('click', () => this.selectDate(dateStr, dayElement));
} else {
const reason = availability ? availability.reason : 'unavailable';
dayElement.classList.add(reason);
}
if (currentDate.getTime() === today.getTime()) {
dayElement.classList.add('today');
}
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_expert_visit_month_availability');
formData.append('year', this.state.currentYear);
formData.append('month', this.state.currentMonth);
formData.append('expert_id', this.state.expertId); // 전문가 ID 전송
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 = '<div class="loading">시간 정보를 불러오는 중...</div>';
try {
const formData = new FormData();
formData.append('action', 'get_expert_visit_time_slots');
formData.append('date', this.state.selectedDate);
formData.append('expert_id', this.state.expertId); // 전문가 ID 전송
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) {
this.elements.timeSlotsGrid.innerHTML = '';
if (slots.length === 0) {
this.elements.timeSlotsGrid.innerHTML = '<div class="no-slots">해당 날짜에 운영되는 시간이 없습니다.</div>';
return;
}
let hasAvailableSlots = false;
slots.forEach(slot => {
const slotElement = document.createElement('div');
slotElement.className = 'time-slot';
let slotInfoText = '';
if (slot.available) {
hasAvailableSlots = true;
slotElement.classList.add('available');
slotElement.addEventListener('click', () => this.selectTime(slot.time, slotElement));
slotInfoText = `예약 ${slot.reserved_count} / ${slot.max_persons}`;
} else {
slotElement.classList.add(slot.reason || 'full');
slotInfoText = slot.reason === 'too_soon' ? '예약 임박' : '마감';
}
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('.expert-visit-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;
// 전문가 ID 동기화
this.elements.form.querySelector('#expert-visit-resource').value = this.state.expertId;
},
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', () => ExpertVisitPopup.init());
function openExpertVisitPopup(wr_id = 0) {
if (wr_id) {
const form = ExpertVisitPopup.elements.form;
if(form) {
let wrIdField = form.querySelector('input[name="wr_id"]');
if (!wrIdField) {
wrIdField = document.createElement('input');
wrIdField.type = 'hidden';
wrIdField.name = 'wr_id';
form.appendChild(wrIdField);
}
wrIdField.value = wr_id;
}
}
ExpertVisitPopup.open();
}
</script>
@@ -0,0 +1,95 @@
<?php
// 경로를 order_manage에 맞게 수정
include_once('../_common_con.php');
// 💡 [추가] 알림 헬퍼 포함
include_once('../lib/notification_helper.php');
header('Content-Type: application/json');
try {
// 입력 데이터 정리 및 컬럼명 변경
$reservation_data = [
'wr_id' => (int)($_POST['wr_id'] ?? 0),
'customer_name' => trim($_POST['customer_name'] ?? ''),
'customer_phone' => trim($_POST['customer_phone'] ?? ''),
'customer_email' => trim($_POST['customer_email'] ?? ''),
'visit_date' => trim($_POST['visit_date'] ?? ''), // 폼의 name="visit_date"와 일치
'visit_time' => trim($_POST['visit_time'] ?? ''), // 폼의 name="visit_time"와 일치
'expert_id' => trim($_POST['expert_id'] ?? ''), // [추가] 전문가 ID
'temp_2' => trim($_POST['visit_type'] ?? ''), // [추가] 방문 유형 (temp_2에 저장)
'request_memo' => trim($_POST['request_memo'] ?? ''), // 폼의 name="request_memo"와 일치
'payment_amount' => (int)($_POST['payment_amount'] ?? 0),
'status' => 'payment_pending',
'created_by' => $member['mb_id'] ?? 'guest'
];
// 필수 항목 유효성 검사
if (empty($reservation_data['customer_name']) || empty($reservation_data['customer_phone']) || empty($reservation_data['visit_date']) || empty($reservation_data['visit_time'])) {
throw new Exception('필수 예약 정보(이름, 연락처, 날짜, 시간)가 누락되었습니다.');
}
// 예약 가능 여부 재확인 (서버 측 검증)
// 1. 해당 날짜/요일에 운영하는 스케줄이 있는지 확인
$dow = date('N', strtotime($reservation_data['visit_date']));
$expert_condition = $reservation_data['expert_id'] ? "(expert_id = '{$reservation_data['expert_id']}' OR expert_id IS NULL)" : "expert_id IS NULL";
$sql_schedule = "SELECT * FROM expert_visit_schedules
WHERE (specific_date = '{$reservation_data['visit_date']}' OR (specific_date IS NULL AND day_of_week = '{$dow}'))
AND {$expert_condition}
AND is_available = 1
ORDER BY specific_date DESC, expert_id DESC LIMIT 1";
$schedule = sql_fetch($sql_schedule);
if (!$schedule) {
throw new Exception('선택하신 날짜는 예약이 불가능합니다.');
}
// 2. 해당 시간대가 운영 시간 내인지 확인
$visit_time_ts = strtotime($reservation_data['visit_date'] . ' ' . $reservation_data['visit_time']);
$start_time_ts = strtotime($reservation_data['visit_date'] . ' ' . $schedule['start_time']);
$end_time_ts = strtotime($reservation_data['visit_date'] . ' ' . $schedule['end_time']);
if ($visit_time_ts < $start_time_ts || $visit_time_ts >= $end_time_ts) {
throw new Exception('선택하신 시간은 운영 시간이 아닙니다.');
}
// 3. 해당 시간대에 예약 정원이 찼는지 확인
$sql_reserved = "SELECT COUNT(*) as cnt FROM expert_visit_reservations
WHERE visit_date = '{$reservation_data['visit_date']}'
AND visit_time = '{$reservation_data['visit_time']}'
AND status != 'cancelled'";
if ($reservation_data['expert_id']) {
$sql_reserved .= " AND expert_id = '{$reservation_data['expert_id']}'";
}
$reserved = sql_fetch($sql_reserved);
if ($reserved['cnt'] >= $schedule['max_persons']) {
throw new Exception('선택하신 시간은 이미 예약이 마감되었습니다.');
}
// 예약 생성
$columns = implode(", ", array_keys($reservation_data));
$values = "'" . implode("', '", array_map('sql_real_escape_string', $reservation_data)) . "'";
$sql = "INSERT INTO expert_visit_reservations ($columns, created_at, updated_at)
VALUES ($values, NOW(), NOW())";
if (sql_query($sql)) {
$reservation_id = sql_insert_id();
// 신규 예약 알림 발송
if (function_exists('notify_for_expert_visit')) {
notify_for_expert_visit($reservation_id, '신규예약');
}
echo json_encode(['success' => true, 'message' => '전문가 방문 예약 신청이 완료되었습니다.']);
} else {
throw new Exception('데이터베이스 저장 중 오류가 발생했습니다.');
}
} catch (Exception $e) {
// 500 오류 방지를 위해 예외 발생 시에도 JSON 응답 반환
http_response_code(200);
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
?>
@@ -0,0 +1,59 @@
<?php
include_once('../../_common.php');
// 💡 [추가] 알림 헬퍼 포함
include_once('../lib/notification_helper.php');
header('Content-Type: application/json');
if (!$is_member) {
die(json_encode(['success' => false, 'message' => '로그인이 필요합니다.']));
}
$action = $_POST['action'] ?? '';
$reservation_id = (int)($_POST['id'] ?? 0);
try {
if ($action === 'cancel') {
if (!$reservation_id) {
throw new Exception('예약 ID가 없습니다.');
}
// 본인의 예약인지 확인
$sql = "SELECT * FROM expert_visit_reservations WHERE id = '{$reservation_id}' AND (customer_id = '{$member['mb_id']}' OR customer_phone = '{$member['mb_hp']}')";
$reservation = sql_fetch($sql);
if (!$reservation) {
throw new Exception('취소할 수 있는 예약 정보가 없거나 권한이 없습니다.');
}
// 이미 취소되었거나 완료된 예약은 변경 불가
if ($reservation['status'] === 'cancelled' || $reservation['status'] === 'completed') {
throw new Exception('이미 취소되었거나 완료된 예약입니다.');
}
// 예약 취소 처리
$sql_update = "UPDATE expert_visit_reservations
SET status = 'cancelled',
updated_at = NOW(),
updated_by = '{$member['mb_id']}'
WHERE id = '{$reservation_id}'";
if (sql_query($sql_update)) {
// 💡 [수정] 예약 취소 알림 발송
if (function_exists('notify_for_expert_visit')) {
notify_for_expert_visit($reservation_id, '예약취소');
}
echo json_encode(['success' => true, 'message' => '예약이 정상적으로 취소되었습니다.']);
} else {
throw new Exception('예약 취소 중 오류가 발생했습니다.');
}
} else {
throw new Exception('잘못된 요청입니다.');
}
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
?>
@@ -0,0 +1,279 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
// AJAX 요청 처리
if (isset($_POST['action'])) {
header('Content-Type: application/json');
include_once('../_common_con.php'); // 💡 [수정] 컴포넌트용 공통 파일 포함
include_once('../lib/notification_helper.php');
if (!$is_member) {
echo json_encode(['success' => false, 'message' => '로그인이 필요합니다.']);
exit;
}
$action = $_POST['action'] ?? '';
$reservation_id = (int)($_POST['id'] ?? 0);
try {
if ($action === 'cancel') {
if (!$reservation_id) {
throw new Exception('예약 ID가 없습니다.');
}
// 본인의 예약인지 확인
$sql = "SELECT * FROM expert_visit_reservations WHERE id = '{$reservation_id}' AND (customer_id = '{$member['mb_id']}' OR customer_phone = '{$member['mb_hp']}')";
$reservation = sql_fetch($sql);
if (!$reservation) {
throw new Exception('취소할 수 있는 예약 정보가 없거나 권한이 없습니다.');
}
// 이미 취소되었거나 완료된 예약은 변경 불가
if ($reservation['status'] === 'cancelled' || $reservation['status'] === 'completed') {
throw new Exception('이미 취소되었거나 완료된 예약입니다.');
}
// 예약 취소 처리
$sql_update = "UPDATE expert_visit_reservations
SET status = 'cancelled',
updated_at = NOW(),
updated_by = '{$member['mb_id']}'
WHERE id = '{$reservation_id}'";
if (sql_query($sql_update)) {
// 예약 취소 알림 발송
if (function_exists('notify_for_expert_visit')) {
notify_for_expert_visit($reservation_id, '예약취소');
}
echo json_encode(['success' => true, 'message' => '예약이 정상적으로 취소되었습니다.']);
} else {
throw new Exception('예약 취소 중 오류가 발생했습니다.');
}
} elseif ($action === 'get_list') {
// 예약 목록 조회
$sql = "SELECT *
FROM expert_visit_reservations
WHERE (customer_id = '{$member['mb_id']}' OR customer_phone = '{$member['mb_hp']}')
AND is_deleted = 0
ORDER BY visit_date DESC, visit_time DESC";
$result = sql_query($sql);
$reservations = [];
while($row = sql_fetch_array($result)) {
$row['status_text'] = get_order_config('reservation_status_' . $row['status'], $row['status']);
$row['is_cancellable'] = !in_array($row['status'], ['completed', 'cancelled']);
$reservations[] = $row;
}
echo json_encode(['success' => true, 'data' => $reservations]);
} else {
throw new Exception('잘못된 요청입니다.');
}
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
exit;
}
if (!defined('_GNUBOARD_')) exit;
$ajax_url = G5_ADMIN_URL . '/order_manage/components/my_reservations_popup.php';
include_once(G5_ADMIN_URL . '/order_manage/_common_con.php');
include_once(G5_ADMIN_URL . '/order_manage/lib/notification_helper.php');
?>
<!-- 나의 예약 현황 팝업 -->
<div id="my-reservations-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="팝업 닫기">&times;</button>
</div>
<div class="reservation-modal-body">
<div class="loading-overlay" style="display: none;">
<div class="loading-spinner"></div>
</div>
<div id="reservation-results">
<!-- 예약 목록이 여기에 표시됩니다. -->
</div>
</div>
</div>
</div>
<style>
/* 팝업 공통 스타일 (expert_visit_popup.php와 통일) */
.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; }
/* 로딩 스피너 */
.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-results { margin-top: 0; }
.reservation-item { border: 1px solid #ddd; border-radius: 8px; padding: 20px; margin-bottom: 15px; background: #fff; }
.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-payment_pending { background: #fff3cd; color: #856404; }
.status-reserved { background: #d4edda; color: #155724; }
.status-completed { background: #cce5ff; color: #004085; }
.status-cancelled { background: #f8d7da; color: #721c24; }
.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; }
.btn-danger { background: #dc3545; color: white; border: none; padding: 8px 15px; border-radius: 4px; cursor: pointer; font-size: 14px; font-weight: 600; }
.btn-danger:hover { background: #c82333; }
.cancel-notice { font-size: 13px; color: #666; margin-top: 15px; text-align: right; padding: 10px; background-color: #f8f9fa; border-radius: 4px; }
.no-results { text-align: center; color: #666; padding: 40px 0; }
</style>
<script>
const MyReservationsPopup = {
elements: {},
config: {
ajaxUrl: '<?php echo $ajax_url; ?>'
},
init() {
this.elements.popup = document.getElementById('my-reservations-popup');
if (!this.elements.popup) return;
this.elements.loading = this.elements.popup.querySelector('.loading-overlay');
this.elements.resultsContainer = this.elements.popup.querySelector('#reservation-results');
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.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';
this.getReservations(); // 팝업 열릴 때 목록 조회
},
close() {
if (!this.elements.popup) return;
this.elements.popup.classList.remove('active');
document.body.style.overflow = '';
},
async getReservations() {
this.showLoading();
const formData = new FormData();
formData.append('action', 'get_list');
try {
const response = await fetch(this.config.ajaxUrl, { method: 'POST', body: formData });
const result = await response.json();
if (result.success) {
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;
}
let html = '';
reservations.forEach(res => {
const date = new Date(res.visit_date + ' ' + res.visit_time);
const options = { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long', hour: '2-digit', minute: '2-digit' };
const formattedDate = date.toLocaleDateString('ko-KR', options);
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}">${res.status_text}</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">${res.temp_3 || '-'}</span></div>
<div class="detail-item"><span class="label">방문 비용:</span><span class="value">${Number(res.payment_amount).toLocaleString()}원</span></div>
</div>
<div class="reservation-actions">`;
if (res.is_cancellable) {
html += `<button type="button" class="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');
formData.append('id', reservationId);
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.getReservations(); // 목록 새로고침
}
} catch (error) {
console.error("취소 오류:", error);
alert('예약 취소 처리 중 오류가 발생했습니다.');
} finally {
this.hideLoading();
}
},
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', () => MyReservationsPopup.init());
function openMyExpertReservationsPopup() {
MyReservationsPopup.open();
}
</script>
+100
View File
@@ -0,0 +1,100 @@
<?php
$sub_menu = "800300";
include_once('./_common.php');
auth_check($auth[$sub_menu], 'r');
$g5['title'] = '시스템 설정';
// 설정값 로드
$configs = [];
$result = sql_query("SELECT * FROM order_config ORDER BY id ASC");
while ($row = sql_fetch_array($result)) {
$configs[$row['config_key']] = $row;
}
// POST 요청 처리
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
auth_check($auth[$sub_menu], 'w');
try {
foreach ($_POST['config'] as $key => $value) {
$key = clean_xss_tags($key);
$value = clean_xss_tags($value);
if (isset($configs[$key])) {
// 기존 설정 업데이트
$sql = "UPDATE order_config SET config_value = '" . sql_real_escape_string($value) . "' WHERE config_key = '" . sql_real_escape_string($key) . "'";
sql_query($sql);
} else {
// 새 설정 추가 (보안상 기본적으로는 비활성화, 필요 시 주석 해제)
// $sql = "INSERT INTO order_config (config_key, config_value) VALUES ('" . sql_real_escape_string($key) . "', '" . sql_real_escape_string($value) . "')";
// sql_query($sql);
}
}
alert('설정이 저장되었습니다.');
} catch (Exception $e) {
alert('오류: ' . $e->getMessage());
}
}
include_once(G5_ADMIN_PATH . '/admin.head.php');
?>
<div class="local_desc01 local_desc">
<p>
견적 관리 솔루션의 주요 설정을 관리합니다.<br>
타이머, 수수료율, 알림 모드 등을 설정할 수 있습니다.
</p>
</div>
<form name="fconfig" method="post">
<div class="tbl_frm01 tbl_wrap">
<table>
<caption><?php echo $g5['title']; ?></caption>
<colgroup>
<col class="grid_4">
<col>
</colgroup>
<tbody>
<?php foreach ($configs as $key => $config): ?>
<tr>
<th scope="row">
<label for="<?php echo $key; ?>"><?php echo $config['config_desc']; ?></label>
</th>
<td>
<?php if ($config['config_type'] === 'boolean'): ?>
<select name="config[<?php echo $key; ?>]" id="<?php echo $key; ?>">
<option value="1" <?php echo $config['config_value'] == '1' ? 'selected' : ''; ?>>사용</option>
<option value="0" <?php echo $config['config_value'] == '0' ? 'selected' : ''; ?>>미사용</option>
</select>
<?php elseif ($config['config_type'] === 'select' && $key === 'notification_mode'): ?>
<select name="config[<?php echo $key; ?>]" id="<?php echo $key; ?>">
<option value="log" <?php echo $config['config_value'] == 'log' ? 'selected' : ''; ?>>개발 모드 (로그만 기록)</option>
<option value="send" <?php echo $config['config_value'] == 'send' ? 'selected' : ''; ?>>실제 발송 모드</option>
</select>
<?php elseif ($config['config_type'] === 'number'): ?>
<input type="number" name="config[<?php echo $key; ?>]" id="<?php echo $key; ?>"
value="<?php echo htmlspecialchars($config['config_value']); ?>" class="frm_input"
style="width: 100px;">
<?php if (strpos($config['config_desc'], '%') !== false) echo ' %'; ?>
<?php else: ?>
<input type="text" name="config[<?php echo $key; ?>]" id="<?php echo $key; ?>"
value="<?php echo htmlspecialchars($config['config_value']); ?>" class="frm_input"
style="width: 90%;">
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="btn_confirm01 btn_confirm">
<input type="submit" value="설정 저장" class="btn_submit">
</div>
</form>
<?php
include_once(G5_ADMIN_PATH . '/admin.tail.php');
?>
+30
View File
@@ -0,0 +1,30 @@
<?php
$sub_menu = "800300";
include_once("./_common.php");
auth_check_menu($auth, $sub_menu, "w");
if ($_POST) {
$configs = [
'timer_enabled',
'timer_message_active',
'timer_message_inactive',
'contract_deposit_rate',
'middle_payment_rate',
'expert_visit_fee',
'expert_account_info'
];
foreach ($configs as $key) {
if (isset($_POST[$key])) {
$value = sql_real_escape_string($_POST[$key]);
$sql = "UPDATE order_config SET config_value = '{$value}', updated_at = NOW() WHERE config_key = '{$key}'";
sql_query($sql);
}
}
alert('설정이 저장되었습니다.', './config_manager.php');
} else {
alert('잘못된 접근입니다.', './config_manager.php');
}
?>
+289
View File
@@ -0,0 +1,289 @@
<?php
$sub_menu = "800800";
include_once('./_common.php');
auth_check($auth[$sub_menu], 'r');
$g5['title'] = '상담 예약 관리';
// lib/notification_helper.php 포함 (설정값 가져오기 위해)
if (file_exists(G5_LIB_PATH . '/notification_helper.php')) {
include_once(G5_LIB_PATH . '/notification_helper.php');
}
function get_reservation_status_name($status_code) {
return get_order_config('reservation_status_' . $status_code, $status_code);
}
// 검색 조건
$sql_search = "";
$sfl = isset($_GET['sfl']) ? clean_xss_tags($_GET['sfl']) : '';
$stx = isset($_GET['stx']) ? clean_xss_tags($_GET['stx']) : '';
$sst = isset($_GET['sst']) ? clean_xss_tags($_GET['sst']) : 'id';
$sod = isset($_GET['sod']) ? clean_xss_tags($_GET['sod']) : 'desc';
$status_filter = isset($_GET['status_filter']) ? clean_xss_tags($_GET['status_filter']) : '';
if ($stx) {
$stx_escaped = sql_real_escape_string($stx);
if ($sfl === 'customer_name') {
$sql_search .= " AND customer_name LIKE '%{$stx_escaped}%' ";
} elseif ($sfl === 'customer_phone') {
$sql_search .= " AND customer_phone LIKE '%{$stx_escaped}%' ";
}
}
if ($status_filter) {
$sql_search .= " AND status = '" . sql_real_escape_string($status_filter) . "' ";
}
// 정렬
$sql_order = " ORDER BY {$sst} {$sod} ";
// 페이징
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
$page_rows = 15;
$from_record = ($page - 1) * $page_rows;
// 전체 개수
$sql_count = "SELECT COUNT(*) as cnt FROM consultant_reservations WHERE is_deleted = 0 {$sql_search}";
$count_row = sql_fetch($sql_count);
$total_count = $count_row['cnt'];
$total_page = ceil($total_count / $page_rows);
// 데이터 조회
$sql = "SELECT * FROM consultant_reservations WHERE is_deleted = 0 {$sql_search} {$sql_order} LIMIT {$from_record}, {$page_rows}";
$result = sql_query($sql);
$reservations = [];
while($row = sql_fetch_array($result)) {
$reservations[] = $row;
}
// 상담가 목록 조회 (예: 레벨 8)
$consultants_result = sql_query("SELECT mb_id, mb_name FROM {$g5['member_table']} WHERE mb_level = 8 AND mb_leave_date = '' ORDER BY mb_name ASC");
$consultants = [];
while($row = sql_fetch_array($consultants_result)) {
$consultants[] = $row;
}
include_once(G5_ADMIN_PATH . '/admin.head.php');
$qstr = http_build_query([
'sfl' => $sfl,
'stx' => $stx,
'sst' => $sst,
'sod' => $sod,
'status_filter' => $status_filter
]);
?>
<div class="local_ov01 local_ov">
<span class="btn_ov01">
<span class="ov_txt">전체 예약 </span>
<span class="ov_num"><?php echo number_format($total_count); ?>건</span>
<?php echo "DDDDD => ".$sql_count ?>
<?php echo "DDDDD=> ".$sql ?>
</span>
</div>
<form name="fsearch" id="fsearch" class="local_sch01 local_sch" method="get">
<label for="status_filter" class="sound_only">상태 필터</label>
<select name="status_filter" id="status_filter">
<option value="">상태 전체</option>
<option value="payment_pending" <?php echo get_selected($status_filter, 'payment_pending'); ?>><?php echo get_reservation_status_name('payment_pending'); ?></option>
<option value="reserved" <?php echo get_selected($status_filter, 'reserved'); ?>><?php echo get_reservation_status_name('reserved'); ?></option>
<option value="completed" <?php echo get_selected($status_filter, 'completed'); ?>><?php echo get_reservation_status_name('completed'); ?></option>
<option value="cancelled" <?php echo get_selected($status_filter, 'cancelled'); ?>><?php echo get_reservation_status_name('cancelled'); ?></option>
</select>
<label for="sfl" class="sound_only">검색대상</label>
<select name="sfl" id="sfl">
<option value="customer_name" <?php echo get_selected($sfl, 'customer_name'); ?>>고객명</option>
<option value="customer_phone" <?php echo get_selected($sfl, 'customer_phone'); ?>>연락처</option>
</select>
<label for="stx" class="sound_only">검색어<strong class="sound_only"> 필수</strong></label>
<input type="text" name="stx" value="<?php echo $stx ?>" id="stx" class="frm_input">
<input type="submit" class="btn_submit" value="검색">
</form>
<div class="tbl_head01 tbl_wrap">
<table>
<caption><?php echo $g5['title']; ?> 목록</caption>
<thead>
<tr>
<th scope="col">예약번호</th>
<th scope="col">고객명</th>
<th scope="col">연락처</th>
<th scope="col">예약일시</th>
<th scope="col">상담종류</th>
<th scope="col">상태</th>
<th scope="col">담당상담가</th>
<th scope="col">신청일</th>
<th scope="col">관리</th>
</tr>
</thead>
<tbody>
<?php if (empty($reservations)): ?>
<tr><td colspan="9" class="empty_table">자료가 없습니다.</td></tr>
<?php else: ?>
<?php foreach ($reservations as $res): ?>
<tr>
<td><?php echo $res['id']; ?></td>
<td><?php echo htmlspecialchars($res['customer_name']); ?></td>
<td><?php echo htmlspecialchars($res['customer_phone']); ?></td>
<td><?php echo $res['reservation_date'] . ' ' . substr($res['reservation_time'], 0, 5); ?></td>
<td><?php echo htmlspecialchars($res['temp_2']); ?></td>
<td><?php echo get_reservation_status_name($res['status']); ?></td>
<td><?php echo $res['consultant_id'] ? (get_member($res['consultant_id'])['mb_name'] ?? $res['consultant_id']) : '미배정'; ?></td>
<td><?php echo substr($res['created_at'], 0, 10); ?></td>
<td class="td_mng td_mng_s">
<button type="button" class="btn btn_03 manage-btn" data-id="<?php echo $res['id']; ?>">상세/관리</button>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<?php echo get_paging(G5_IS_MOBILE ? $config['cf_mobile_pages'] : $config['cf_write_pages'], $page, $total_page, $_SERVER['SCRIPT_NAME'].'?'.$qstr.'&amp;page='); ?>
<!-- 상세/관리 모달 -->
<div id="reservationModal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.5); z-index:1000;">
<div style="position:absolute; top:50%; left:50%; transform:translate(-50%, -50%); background:white; padding:20px; border-radius:5px; width:600px;">
<h3 id="modalTitle">상담 예약 상세/관리</h3>
<form name="fmodal" id="fmodal">
<input type="hidden" name="id" id="modal_id">
<div class="tbl_frm01 tbl_wrap">
<table>
<colgroup>
<col class="grid_4">
<col>
</colgroup>
<tbody>
<tr>
<th scope="row">고객 정보</th>
<td id="modal_customer_info"></td>
</tr>
<tr>
<th scope="row">예약 정보</th>
<td id="modal_reservation_info"></td>
</tr>
<tr>
<th scope="row">상담 정보</th>
<td id="modal_consult_info"></td>
</tr>
<tr>
<th scope="row">고객 요청사항</th>
<td><textarea id="modal_request_memo" readonly style="width:100%;height:60px;"></textarea></td>
</tr>
<tr>
<th scope="row"><label for="modal_status">상태 변경</label></th>
<td>
<select name="status" id="modal_status">
<option value="payment_pending"><?php echo get_reservation_status_name('payment_pending'); ?></option>
<option value="reserved"><?php echo get_reservation_status_name('reserved'); ?></option>
<option value="completed"><?php echo get_reservation_status_name('completed'); ?></option>
<option value="cancelled"><?php echo get_reservation_status_name('cancelled'); ?></option>
</select>
</td>
</tr>
<tr>
<th scope="row"><label for="modal_consultant_id">상담가 배정</label></th>
<td>
<select name="consultant_id" id="modal_consultant_id">
<option value="">상담가 선택</option>
<?php foreach ($consultants as $c): ?>
<option value="<?php echo $c['mb_id']; ?>"><?php echo htmlspecialchars($c['mb_name']); ?></option>
<?php endforeach; ?>
</select>
</td>
</tr>
<tr>
<th scope="row"><label for="modal_admin_memo">관리자 메모</label></th>
<td><textarea name="admin_memo" id="modal_admin_memo" style="width:100%;height:80px;"></textarea></td>
</tr>
</tbody>
</table>
</div>
<div class="btn_confirm01 btn_confirm" style="margin-top:20px;">
<button type="button" id="modalSaveBtn" class="btn_submit">저장</button>
<button type="button" id="modalCloseBtn" class="btn_cancel">닫기</button>
</div>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const modal = document.getElementById('reservationModal');
const manageButtons = document.querySelectorAll('.manage-btn');
const closeBtn = document.getElementById('modalCloseBtn');
const saveBtn = document.getElementById('modalSaveBtn');
function openModal(id) {
fetch('./consultant_reservations_ajax.php?action=get_details&id=' + id)
.then(response => response.json())
.then(data => {
if (data.success) {
const res = data.data;
document.getElementById('modal_id').value = res.id;
document.getElementById('modalTitle').innerText = `상담 예약 상세/관리 (예약번호: ${res.id})`;
document.getElementById('modal_customer_info').innerHTML = `${res.customer_name} (${res.customer_phone} / ${res.customer_email})`;
document.getElementById('modal_reservation_info').innerHTML = `${res.reservation_date} ${res.reservation_time.substring(0,5)} / <strong>상담비: ${Number(res.payment_amount).toLocaleString()}원</strong>`;
document.getElementById('modal_consult_info').innerHTML = `종류: ${res.temp_2} / 지역: ${res.temp_3} / 예상시간: ${res.temp_4}분`;
document.getElementById('modal_request_memo').value = res.request_memo;
document.getElementById('modal_status').value = res.status;
document.getElementById('modal_consultant_id').value = res.consultant_id || '';
document.getElementById('modal_admin_memo').value = res.admin_memo;
modal.style.display = 'block';
} else {
alert(data.message);
}
});
}
function closeModal() {
modal.style.display = 'none';
}
manageButtons.forEach(btn => {
btn.addEventListener('click', function() {
openModal(this.dataset.id);
});
});
closeBtn.addEventListener('click', closeModal);
modal.addEventListener('click', function(e) {
if (e.target === modal) {
closeModal();
}
});
saveBtn.addEventListener('click', function() {
const form = document.getElementById('fmodal');
const formData = new FormData(form);
formData.append('action', 'update_reservation');
if (!confirm('변경 내용을 저장하시겠습니까?')) return;
fetch('./consultant_reservations_ajax.php', {
method: 'POST',
body: new URLSearchParams(formData)
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(data.message);
location.reload();
} else {
alert('오류: ' + data.message);
}
});
});
});
</script>
<?php
include_once(G5_ADMIN_PATH . '/admin.tail.php');
?>
@@ -0,0 +1,70 @@
<?php
include_once('./_common.php');
// 관리자 권한 확인
if (!$is_admin) {
die(json_encode(['success' => false, 'message' => '관리자 권한이 필요합니다.']));
}
// lib/notification_helper.php 포함
if (file_exists(G5_LIB_PATH . '/notification_helper.php')) {
include_once(G5_LIB_PATH . '/notification_helper.php');
}
$action = isset($_REQUEST['action']) ? clean_xss_tags($_REQUEST['action']) : '';
$id = isset($_REQUEST['id']) ? (int)$_REQUEST['id'] : 0;
header('Content-Type: application/json; charset=utf-8');
try {
switch ($action) {
case 'get_details':
if (!$id) throw new Exception('예약 ID가 없습니다.');
$reservation = sql_fetch("SELECT * FROM consultant_reservations WHERE id = '{$id}'");
if (!$reservation) throw new Exception('예약 정보를 찾을 수 없습니다.');
echo json_encode(['success' => true, 'data' => $reservation]);
break;
case 'update_reservation':
if (!$id) throw new Exception('예약 ID가 없습니다.');
$status = isset($_POST['status']) ? clean_xss_tags($_POST['status']) : '';
$consultant_id = isset($_POST['consultant_id']) ? clean_xss_tags($_POST['consultant_id']) : '';
$admin_memo = isset($_POST['admin_memo']) ? clean_xss_tags($_POST['admin_memo']) : '';
// 기존 정보 조회 (상태 변경 시 알림을 위함)
$old_reservation = sql_fetch("SELECT status FROM consultant_reservations WHERE id = '{$id}'");
$sql = "UPDATE consultant_reservations SET " .
" status = '" . sql_real_escape_string($status) . "', " .
" consultant_id = '" . sql_real_escape_string($consultant_id) . "', " .
" admin_memo = '" . sql_real_escape_string($admin_memo) . "', " .
" updated_at = NOW(), " .
" updated_by = '{$member['mb_id']}' " .
" WHERE id = '{$id}' ";
sql_query($sql);
// 상태 변경 시 알림 발송
if (function_exists('notify_for_consulting')) {
// 입금 확인 -> 예약 확정
if ($old_reservation['status'] !== 'reserved' && $status === 'reserved') {
notify_for_consulting($id, '예약확정');
}
}
echo json_encode(['success' => true, 'message' => '예약 정보가 성공적으로 업데이트되었습니다.']);
break;
default:
throw new Exception('유효하지 않은 요청입니다.');
}
} catch (Exception $e) {
echo json_encode([
'success' => false,
'message' => $e->getMessage()
]);
}
exit;
+80
View File
@@ -0,0 +1,80 @@
<?php
// 중도금 알림 크론 작업 스크립트
// 매일 실행하여 시공 2일 전 고객들에게 중도금 알림 발송
include_once('../../common.php');
include_once(G5_LIB_PATH . '/notification_helper.php');
// CLI에서만 실행 가능하도록 제한
if (php_sapi_name() !== 'cli' && !$is_admin) {
die('Access denied');
}
$today = date('Y-m-d');
// 오늘이 중도금 알림일인 견적들 조회
$sql = "SELECT e.*, w.wr_name, w.wr_subject, w.mb_hp,
(SELECT mb_name FROM g5_member WHERE mb_id = e.extra_4) as dealer_name
FROM estimate e
JOIN g5_write_order w ON e.wr_id = w.wr_id
WHERE e.extra_5 = '{$today}'
AND e.status = '입금확인'
AND e.is_deleted = 0";
$result = sql_query($sql);
$processed_count = 0;
$middle_payment_rate = (int) get_order_config('middle_payment_rate', '40');
while ($row = sql_fetch_array($result)) {
// 견적 총액 조회
$total_amount_result = sql_fetch("SELECT SUM(ei.amount) as total_amount
FROM estimate_item ei
WHERE ei.estimate_id = '{$row['id']}'
AND ei.dealer_id = '{$row['extra_4']}'");
$total_amount = $total_amount_result['total_amount'] ?? 0;
$interim_amount = ($total_amount * $middle_payment_rate) / 100;
// 중도금 알림 발송
$notification_result = notify_customer('customer_interim_payment', [
'customer_name' => $row['wr_name'],
'interim_amount' => number_format($interim_amount),
'construction_date' => $row['temp_4'],
'dealer_name' => $row['dealer_name']
]);
if ($notification_result) {
// 상태를 중도금입금예정으로 변경
sql_query("UPDATE estimate SET
status = '중도금입금예정',
updated_at = NOW()
WHERE id = '{$row['id']}'");
// 게시판 상태도 업데이트
sql_query("UPDATE g5_write_order SET wr_1 = '중도금입금예정' WHERE wr_id = '{$row['wr_id']}'");
$processed_count++;
// 로그 기록
error_log("[CRON] 중도금 알림 발송 완료 - wr_id: {$row['wr_id']}, 고객: {$row['wr_name']}, 금액: " . number_format($interim_amount));
}
}
echo "중도금 알림 처리 완료: {$processed_count}\n";
// 템플릿이 없는 경우를 위한 기본 템플릿 추가
$template_check = sql_fetch("SELECT COUNT(*) as cnt FROM order_mail_templates WHERE template_key = 'customer_interim_payment'");
if ($template_check['cnt'] == 0) {
// 중도금 알림 템플릿 추가
sql_query("INSERT INTO order_mail_templates (template_key, template_name, subject, content, variables) VALUES
('customer_interim_payment', '고객 - 중도금 입금 안내', '시공 준비를 위한 중도금 입금 안내',
'안녕하세요 {customer_name}님,<br><br>{construction_date} 시공 예정으로 중도금 입금을 안내드립니다.<br><br>중도금: {interim_amount}원<br>시공업체: {dealer_name}<br><br>입금 확인 후 시공이 진행됩니다.',
'[\"customer_name\", \"interim_amount\", \"construction_date\", \"dealer_name\"]')");
sql_query("INSERT INTO order_sms_templates (template_key, template_name, content, variables) VALUES
('customer_interim_payment', '고객 - 중도금 입금 안내',
'{customer_name}님, {construction_date} 시공 예정으로 중도금 {interim_amount}원 입금 안내드립니다.',
'[\"customer_name\", \"interim_amount\", \"construction_date\"]')");
}
?>
+113
View File
@@ -0,0 +1,113 @@
<?php
/**
* 중도금 입금 알림 자동 발송 크론 작업
*
* 매일 실행하여 중도금 입금 기한이 도래한 견적에 대해 대리점에게 알림을 발송합니다.
*
* 사용법:
* 1. 웹 브라우저: http://yourdomain.com/adm/order_manage/cron_interim_reminders.php?key=your_secret_key
* 2. 커맨드라인: php /path/to/adm/order_manage/cron_interim_reminders.php
* 3. 크론탭: 0 9 * * * php /path/to/adm/order_manage/cron_interim_reminders.php
*/
// 보안 키 확인 (웹 접근 시)
$security_key = 'order_cron_2024';
if (isset($_GET['key']) && $_GET['key'] !== $security_key) {
die('Invalid security key');
}
// 그누보드 환경 로드
if (!defined('_GNUBOARD_')) {
$g5_path = dirname(dirname(dirname(__FILE__)));
include_once($g5_path . '/common.php');
}
// 로그 함수
function log_message($message)
{
$log_file = G5_DATA_PATH . '/log/interim_reminders.log';
$log_dir = dirname($log_file);
if (!is_dir($log_dir)) {
@mkdir($log_dir, 0755, true);
}
$timestamp = date('Y-m-d H:i:s');
$log_entry = "[{$timestamp}] {$message}" . PHP_EOL;
file_put_contents($log_file, $log_entry, FILE_APPEND | LOCK_EX);
// 콘솔 출력 (CLI 모드)
if (php_sapi_name() === 'cli') {
echo $log_entry;
}
}
try {
log_message("중도금 알림 크론 작업 시작");
// DealerNotification 클래스 로드
require_once G5_PATH . '/adm/order_manage/dealer_notification.php';
$dealer_notification = new DealerNotification();
// 중도금 입금 알림 일괄 발송
$result = $dealer_notification->sendInterimPaymentReminders();
if ($result['success']) {
log_message("알림 발송 완료: " . $result['message']);
log_message("발송 건수: " . $result['sent_count']);
// 상세 결과 로그
foreach ($result['results'] as $item) {
$status = $item['result']['success'] ? 'SUCCESS' : 'FAILED';
$message = $item['result']['message'];
log_message("견적 {$item['wr_id']} (대리점: {$item['dealer_id']}) - {$status}: {$message}");
}
} else {
log_message("알림 발송 실패: " . $result['message']);
}
// 추가 작업: 오래된 로그 파일 정리 (30일 이상)
$log_files = glob(G5_DATA_PATH . '/log/interim_reminders_*.log');
$cutoff_date = strtotime('-30 days');
foreach ($log_files as $log_file) {
if (filemtime($log_file) < $cutoff_date) {
@unlink($log_file);
log_message("오래된 로그 파일 삭제: " . basename($log_file));
}
}
log_message("중도금 알림 크론 작업 완료");
// 웹 접근 시 JSON 응답
if (isset($_GET['key'])) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'success' => true,
'message' => '중도금 알림 발송이 완료되었습니다.',
'sent_count' => $result['sent_count'] ?? 0,
'timestamp' => date('Y-m-d H:i:s')
], JSON_UNESCAPED_UNICODE);
}
} catch (Exception $e) {
$error_message = "크론 작업 오류: " . $e->getMessage();
log_message($error_message);
// 웹 접근 시 에러 응답
if (isset($_GET['key'])) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'success' => false,
'message' => $error_message,
'timestamp' => date('Y-m-d H:i:s')
], JSON_UNESCAPED_UNICODE);
}
// CLI 모드에서는 exit code 1로 종료
if (php_sapi_name() === 'cli') {
exit(1);
}
}
?>
+369
View File
@@ -0,0 +1,369 @@
<?php
$sub_menu = "800250";
include_once('./_common.php');
auth_check($auth[$sub_menu], 'r');
$g5['title'] = '대리점 설정 관리';
// 액션 처리
$action = isset($_REQUEST['action']) ? clean_xss_tags($_REQUEST['action']) : '';
if ($action && $_SERVER['REQUEST_METHOD'] === 'POST') {
auth_check($auth[$sub_menu], 'w');
try {
if ($action === 'update_commission') {
$level_5_rate = (float) ($_POST['dealer_commission_level_5'] ?? 5);
$level_6_rate = (float) ($_POST['dealer_commission_level_6'] ?? 11);
$level_7_rate = (float) ($_POST['dealer_commission_level_7'] ?? 19);
// 수수료율 업데이트
$updates = [
'dealer_commission_level_5' => $level_5_rate,
'dealer_commission_level_6' => $level_6_rate,
'dealer_commission_level_7' => $level_7_rate
];
foreach ($updates as $key => $value) {
$existing = sql_fetch("SELECT config_key FROM order_config WHERE config_key = '{$key}'");
if ($existing) {
sql_query("UPDATE order_config SET config_value = '{$value}' WHERE config_key = '{$key}'");
} else {
sql_query("INSERT INTO order_config (config_key, config_value, description, type) VALUES ('{$key}', '{$value}', '대리점 수수료율', 'number')");
}
}
alert('대리점 수수료율이 업데이트되었습니다.', './dealer_config.php');
}
if ($action === 'update_dealer_level') {
$mb_id = clean_xss_tags($_POST['mb_id'] ?? '');
$new_level = (int) ($_POST['new_level'] ?? 0);
if ($mb_id && in_array($new_level, [5, 6, 7, 8])) {
sql_query("UPDATE g5_member SET mb_level = '{$new_level}' WHERE mb_id = '{$mb_id}'");
alert('대리점 레벨이 변경되었습니다.', './dealer_config.php');
} else {
throw new Exception('유효하지 않은 레벨입니다.');
}
}
} catch (Exception $e) {
alert('오류: ' . $e->getMessage());
}
goto_url('./dealer_config.php');
exit;
}
// 현재 수수료율 조회
$commission_rates = [];
$rates_sql = "SELECT config_key, config_value FROM order_config WHERE config_key LIKE 'dealer_commission_level_%'";
$rates_result = sql_query($rates_sql);
while ($row = sql_fetch_array($rates_result)) {
$commission_rates[$row['config_key']] = $row['config_value'];
}
// 대리점 목록 조회 (레벨 5-7)
$dealers = [];
$dealers_sql = "SELECT mb_id, mb_name, mb_level, mb_hp, mb_email, mb_datetime FROM g5_member WHERE mb_level IN (5,6,7) ORDER BY mb_level DESC, mb_name ASC";
$dealers_result = sql_query($dealers_sql);
while ($dealer = sql_fetch_array($dealers_result)) {
$dealers[] = $dealer;
}
// 대리점 성과 조회
$performance = [];
$performance_sql = "
SELECT
b.dealer_id,
COUNT(*) as total_bids,
COUNT(CASE WHEN b.status = 'selected' THEN 1 END) as selected_bids,
AVG(b.total_amount) as avg_amount,
SUM(CASE WHEN b.status = 'selected' THEN b.total_amount ELSE 0 END) as total_revenue
FROM estimate_bidding b
WHERE b.created_at >= DATE_SUB(NOW(), INTERVAL 3 MONTH)
GROUP BY b.dealer_id
";
$performance_result = sql_query($performance_sql);
while ($perf = sql_fetch_array($performance_result)) {
$performance[$perf['dealer_id']] = $perf;
}
include_once(G5_ADMIN_PATH . '/admin.head.php');
?>
<div class="local_desc01 local_desc">
<p>
대리점 수수료율 설정 및 대리점 레벨 관리를 할 수 있습니다.<br>
레벨별 수수료율은 견적 제안 시 자동으로 적용됩니다.
</p>
</div>
<!-- 수수료율 설정 -->
<div class="tbl_frm01 tbl_wrap">
<form name="fcommission" method="post">
<input type="hidden" name="action" value="update_commission">
<table>
<caption>대리점 수수료율 설정</caption>
<colgroup>
<col class="grid_4">
<col>
</colgroup>
<tbody>
<tr>
<th scope="row"><label for="dealer_commission_level_5">레벨 5 대리점 수수료율</label></th>
<td>
<input type="number" name="dealer_commission_level_5" id="dealer_commission_level_5"
value="<?php echo $commission_rates['dealer_commission_level_5'] ?? 5; ?>" class="frm_input"
min="0" max="50" step="0.1"> %
<span class="frm_info">기본 대리점 수수료율</span>
</td>
</tr>
<tr>
<th scope="row"><label for="dealer_commission_level_6">레벨 6 대리점 수수료율</label></th>
<td>
<input type="number" name="dealer_commission_level_6" id="dealer_commission_level_6"
value="<?php echo $commission_rates['dealer_commission_level_6'] ?? 11; ?>"
class="frm_input" min="0" max="50" step="0.1"> %
<span class="frm_info">중급 대리점 수수료율</span>
</td>
</tr>
<tr>
<th scope="row"><label for="dealer_commission_level_7">레벨 7 대리점 수수료율</label></th>
<td>
<input type="number" name="dealer_commission_level_7" id="dealer_commission_level_7"
value="<?php echo $commission_rates['dealer_commission_level_7'] ?? 19; ?>"
class="frm_input" min="0" max="50" step="0.1"> %
<span class="frm_info">고급 대리점 수수료율</span>
</td>
</tr>
</tbody>
</table>
<div class="btn_confirm01 btn_confirm">
<input type="submit" value="수수료율 저장" class="btn_submit">
</div>
</form>
</div>
<!-- 대리점 목록 및 관리 -->
<div class="local_ov01 local_ov">
<span class="btn_ov01">
<span class="ov_txt">등록된 대리점 </span>
<span class="ov_num"><?php echo count($dealers); ?>개</span>
</span>
</div>
<div class="tbl_head01 tbl_wrap">
<table>
<caption>대리점 목록 및 성과</caption>
<thead>
<tr>
<th scope="col">대리점 정보</th>
<th scope="col">레벨</th>
<th scope="col">수수료율</th>
<th scope="col">최근 3개월 성과</th>
<th scope="col">가입일</th>
<th scope="col">관리</th>
</tr>
</thead>
<tbody>
<?php if (empty($dealers)): ?>
<tr>
<td colspan="6" class="empty_table">등록된 대리점이 없습니다.</td>
</tr>
<?php else: ?>
<?php foreach ($dealers as $dealer): ?>
<?php
$perf = $performance[$dealer['mb_id']] ?? null;
$success_rate = $perf && $perf['total_bids'] > 0 ? round(($perf['selected_bids'] / $perf['total_bids']) * 100, 1) : 0;
$commission_key = 'dealer_commission_level_' . $dealer['mb_level'];
$commission_rate = $commission_rates[$commission_key] ?? 0;
?>
<tr>
<td class="td_left">
<strong><?php echo get_text($dealer['mb_name']); ?></strong><br>
<small>ID: <?php echo $dealer['mb_id']; ?></small><br>
<small>연락처: <?php echo get_text($dealer['mb_hp']); ?></small><br>
<small>이메일: <?php echo get_text($dealer['mb_email']); ?></small>
</td>
<td class="td_center">
<span class="level-badge level-<?php echo $dealer['mb_level']; ?>">
레벨 <?php echo $dealer['mb_level']; ?>
</span>
</td>
<td class="td_center">
<strong><?php echo $commission_rate; ?>%</strong>
</td>
<td class="td_left">
<?php if ($perf): ?>
<strong>입찰: <?php echo number_format($perf['total_bids']); ?>건</strong><br>
<small>선택: <?php echo number_format($perf['selected_bids']); ?>건
(<?php echo $success_rate; ?>%)</small><br>
<small>매출: <?php echo number_format($perf['total_revenue']); ?>원</small><br>
<small>평균: <?php echo number_format($perf['avg_amount']); ?>원</small>
<?php else: ?>
<span class="text-muted">성과 데이터 없음</span>
<?php endif; ?>
</td>
<td class="td_datetime">
<?php echo date('Y-m-d', strtotime($dealer['mb_datetime'])); ?>
</td>
<td class="td_mng td_mng_s">
<button type="button" class="btn btn_03"
onclick="changeDealerLevel('<?php echo $dealer['mb_id']; ?>', <?php echo $dealer['mb_level']; ?>)">
레벨 변경
</button>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<!-- 레벨 변경 모달 -->
<div id="levelModal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3>대리점 레벨 변경</h3>
<span class="close" onclick="closeModal()">&times;</span>
</div>
<div class="modal-body">
<form id="levelForm" method="post">
<input type="hidden" name="action" value="update_dealer_level">
<input type="hidden" id="modal_mb_id" name="mb_id">
<div class="form-group">
<label for="new_level">새 레벨:</label>
<select id="new_level" name="new_level" class="frm_input" required>
<option value="5">레벨 5 (기본 대리점 -
<?php echo $commission_rates['dealer_commission_level_5'] ?? 5; ?>%)</option>
<option value="6">레벨 6 (중급 대리점 -
<?php echo $commission_rates['dealer_commission_level_6'] ?? 11; ?>%)</option>
<option value="7">레벨 7 (고급 대리점 -
<?php echo $commission_rates['dealer_commission_level_7'] ?? 19; ?>%)</option>
<option value="8">레벨 8 (관리자)</option>
</select>
</div>
<div class="btn_confirm01 btn_confirm">
<input type="submit" value="레벨 변경" class="btn_submit">
<button type="button" onclick="closeModal()" class="btn_cancel">취소</button>
</div>
</form>
</div>
</div>
</div>
<style>
.level-badge {
padding: 3px 8px;
border-radius: 3px;
color: white;
font-size: 11px;
font-weight: bold;
}
.level-5 {
background-color: #28a745;
}
.level-6 {
background-color: #007bff;
}
.level-7 {
background-color: #6f42c1;
}
.modal {
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
}
.modal-content {
background-color: #fefefe;
margin: 15% auto;
padding: 0;
border: 1px solid #888;
width: 400px;
border-radius: 5px;
}
.modal-header {
padding: 15px 20px;
background-color: #f8f9fa;
border-bottom: 1px solid #dee2e6;
border-radius: 5px 5px 0 0;
}
.modal-header h3 {
margin: 0;
display: inline-block;
}
.close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
.close:hover {
color: black;
}
.modal-body {
padding: 20px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.text-muted {
color: #6c757d;
}
</style>
<script>
// 대리점 레벨 변경
function changeDealerLevel(mb_id, current_level) {
document.getElementById('modal_mb_id').value = mb_id;
document.getElementById('new_level').value = current_level;
document.getElementById('levelModal').style.display = 'block';
}
// 모달 닫기
function closeModal() {
document.getElementById('levelModal').style.display = 'none';
}
// 모달 외부 클릭 시 닫기
window.onclick = function (event) {
const modal = document.getElementById('levelModal');
if (event.target === modal) {
closeModal();
}
}
</script>
<?php
include_once(G5_ADMIN_PATH . '/admin.tail.php');
?>
+161
View File
@@ -0,0 +1,161 @@
<?php
if (!defined('_GNUBOARD_'))
exit;
// 필요한 클래스 및 라이브러리 포함
require_once G5_PATH . '/adm/order_manage/classes/EstimateManager.class.php';
require_once G5_PATH . '/lib/notification_helper.php';
/**
* Class DealerNotification
* 대리점 관련 알림을 처리하는 클래스
*/
class DealerNotification
{
private $estimateManager;
public function __construct()
{
$this->estimateManager = new EstimateManager();
}
/**
* 대리점의 견적이 선택되었을 때 알림을 발송합니다.
*
* @param int $wr_id 게시물 ID
* @param string $dealer_id 대리점 ID
* @param array $bid_info 입찰 정보
* @return array ['success' => bool, 'message' => string]
*/
public function sendBidSelectedNotification($wr_id, $dealer_id, $bid_info)
{
try {
// 1. 고객 정보 조회
$write = sql_fetch("SELECT * FROM g5_write_order WHERE wr_id = '{$wr_id}'");
if (!$write) {
throw new Exception('원본 게시물을 찾을 수 없습니다.');
}
$customer = get_member($write['mb_id']);
// 2. 대리점 정보 조회
$dealer = get_member($dealer_id);
if (!$dealer) {
throw new Exception('대리점 정보를 찾을 수 없습니다.');
}
// 3. 알림에 필요한 변수 설정
$vars = [
'dealer_name' => $dealer['mb_name'],
'customer_name' => $customer['mb_name'] ?? '고객',
'estimate_id' => $bid_info['estimate_id'],
'bid_amount' => number_format($bid_info['total_amount']),
'selected_date' => date('Y-m-d H:i'),
'estimate_url' => get_pretty_url('order', $wr_id)
];
// 4. 알림 발송
if (function_exists('notify_agent')) {
notify_agent('bid_selected_email', $vars);
}
return ['success' => true, 'message' => '대리점에게 견적 선택 알림을 발송했습니다.'];
} catch (Exception $e) {
error_log("DealerNotification::sendBidSelectedNotification Error: " . $e->getMessage());
return ['success' => false, 'message' => $e->getMessage()];
}
}
/**
* 시공 일정 알림 발송
*
* @param int $wr_id 게시물 ID
* @return array ['success' => bool, 'message' => string]
*/
public function sendScheduleReminder($wr_id)
{
try {
// 1. 시공 정보 조회
$schedule_info = $this->estimateManager->getConstructionSchedule($wr_id);
if (!$schedule_info || !$schedule_info['has_schedule']) {
throw new Exception('시공 일정이 없습니다.');
}
// 2. 대리점 정보 조회
$dealer = get_member($schedule_info['dealer_id']);
if (!$dealer) {
throw new Exception('대리점 정보를 찾을 수 없습니다.');
}
// 3. 알림 변수 설정
$vars = [
'dealer_name' => $dealer['mb_name'],
'customer_name' => $schedule_info['customer_name'],
'estimate_id' => $schedule_info['estimate_id'],
'construction_date' => $schedule_info['construction_date'],
'interim_payment_date' => $schedule_info['interim_payment_date']
];
// 4. 알림 발송
if (function_exists('notify_agent')) {
notify_agent('schedule_reminder_email', $vars);
}
return ['success' => true, 'message' => '대리점에게 시공 일정 알림을 발송했습니다.'];
} catch (Exception $e) {
error_log("DealerNotification::sendScheduleReminder Error: " . $e->getMessage());
return ['success' => false, 'message' => $e->getMessage()];
}
}
/**
* 중도금 입금 기한 알림 발송
*
* @param int $wr_id 게시물 ID
* @return array ['success' => bool, 'message' => string]
*/
public function sendInterimPaymentDueNotification($wr_id)
{
try {
// 1. 중도금 입금 타이밍 확인
$timing_info = $this->estimateManager->checkInterimPaymentTiming($wr_id);
if (!$timing_info['is_due']) {
return ['success' => true, 'message' => '중도금 입금 기한이 아직 도래하지 않았습니다.'];
}
// 2. 시공 정보 조회
$schedule_info = $this->estimateManager->getConstructionSchedule($wr_id);
if (!$schedule_info) {
throw new Exception('시공 정보를 찾을 수 없습니다.');
}
// 3. 대리점 정보 조회
$dealer = get_member($schedule_info['dealer_id']);
if (!$dealer) {
throw new Exception('대리점 정보를 찾을 수 없습니다.');
}
// 4. 알림 변수 설정
$vars = [
'dealer_name' => $dealer['mb_name'],
'customer_name' => $schedule_info['customer_name'],
'estimate_id' => $schedule_info['estimate_id'],
'construction_date' => $schedule_info['construction_date'],
'interim_payment_date' => $schedule_info['interim_payment_date'],
'days_remaining' => $timing_info['days_remaining']
];
// 5. 알림 발송
if (function_exists('notify_agent')) {
notify_agent('interim_due_email', $vars);
}
return ['success' => true, 'message' => '대리점에게 중도금 입금 기한 알림을 발송했습니다.'];
} catch (Exception $e) {
error_log("DealerNotification::sendInterimPaymentDueNotification Error: " . $e->getMessage());
return ['success' => false, 'message' => $e->getMessage()];
}
}
}
@@ -0,0 +1,338 @@
<?php
$sub_menu = "800200";
include_once('./_common.php');
auth_check_menu($auth, $sub_menu, 'r');
$g5['title'] = '견적 관리';
include_once('./admin.head.php');
// 검색 조건
$where = " WHERE 1=1 ";
$search_params = '';
if (isset($_GET['status']) && $_GET['status']) {
$status = sql_real_escape_string($_GET['status']);
$where .= " AND (e.status = '{$status}' OR w.wr_1 = '{$status}')";
$search_params .= "&status={$_GET['status']}";
}
if (isset($_GET['stx']) && $_GET['stx']) {
$stx = sql_real_escape_string($_GET['stx']);
$where .= " AND (w.wr_subject LIKE '%{$stx}%' OR w.wr_name LIKE '%{$stx}%')";
$search_params .= "&stx={$_GET['stx']}";
}
$write_table = $g5['write_prefix'] . 'order';
?>
<style>
.status-popup-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.5); z-index: 9999; display: none;
}
.status-popup {
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
background: #fff; border: 1px solid #ddd; border-radius: 4px;
min-width: 400px; z-index: 10000; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.status-popup-header {
padding: 15px 20px; border-bottom: 1px solid #eee; background: #f8f9fa;
border-radius: 4px 4px 0 0;
}
.status-popup-header h3 { margin: 0; font-size: 16px; color: #333; font-weight: 600; }
.status-popup-body { padding: 20px; }
.form-group { margin-bottom: 15px; }
.form-group label { display: block; margin-bottom: 5px; font-weight: 500; color: #333; }
.form-control {
width: 100%; padding: 8px 12px; border: 1px solid #ddd; border-radius: 3px;
font-size: 14px; box-sizing: border-box;
}
.status-popup-footer {
padding: 15px 20px; border-top: 1px solid #eee; text-align: right;
background: #f8f9fa; border-radius: 0 0 4px 4px;
}
.btn {
padding: 8px 16px; border: none; border-radius: 3px; cursor: pointer;
font-size: 14px; margin-left: 5px; transition: all 0.2s;
}
.btn-primary { background: #007bff; color: white; }
.btn-secondary { background: #6c757d; color: white; }
.close-btn {
float: right; background: none; border: none; font-size: 18px;
cursor: pointer; color: #999; padding: 0; width: 20px; height: 20px;
}
.status-견적신청중 { color: #6c757d; }
.status-작성완료 { color: #28a745; }
.status-견적제안 { color: #17a2b8; }
.status-견적채택 { color: #ffc107; }
.status-입금예정 { color: #fd7e14; }
.status-입금확인 { color: #20c997; }
.status-다운로드 { color: #6610f2; }
.status-견적취소 { color: #dc3545; }
.status-btn {
cursor: pointer; padding: 4px 8px; border-radius: 3px;
background: #f8f9fa; border: 1px solid #dee2e6; font-size: 12px;
}
.status-btn:hover { background: #e9ecef; }
.brand-info {
display: inline-block; background: #e9ecef; padding: 2px 6px;
border-radius: 3px; font-size: 11px; color: #495057; margin-right: 3px;
}
</style>
<div class="local_ov01 local_ov">
<span class="btn_ov01"><span class="ov_txt">전체 </span><span class="ov_num"> <?php echo number_format($total_count ?? 0) ?>건 </span></span>
</div>
<form name="fsearch" method="get">
<input type="hidden" name="sca" value="<?php echo $sca ?>">
<div class="local_sch01 local_sch">
<label for="stx" class="sound_only">검색어<strong class="sound_only"> 필수</strong></label>
<select name="status">
<option value="">전체상태</option>
<option value="견적신청중" <?php echo ($_GET['status'] ?? '') === '견적신청중' ? 'selected' : ''; ?>>견적신청중</option>
<option value="작성완료" <?php echo ($_GET['status'] ?? '') === '작성완료' ? 'selected' : ''; ?>>작성완료</option>
<option value="견적제안" <?php echo ($_GET['status'] ?? '') === '견적제안' ? 'selected' : ''; ?>>견적제안</option>
<option value="견적채택" <?php echo ($_GET['status'] ?? '') === '견적채택' ? 'selected' : ''; ?>>견적채택</option>
<option value="입금예정" <?php echo ($_GET['status'] ?? '') === '입금예정' ? 'selected' : ''; ?>>입금예정</option>
<option value="입금확인" <?php echo ($_GET['status'] ?? '') === '입금확인' ? 'selected' : ''; ?>>입금확인</option>
</select>
<input type="text" name="stx" value="<?php echo $_GET['stx'] ?? '' ?>" placeholder="제목, 작성자 검색">
<input type="submit" class="btn_submit" value="검색">
</div>
</form>
<form name="flist" id="flist" method="post" onsubmit="return flist_submit(this);">
<div class="tbl_head01 tbl_wrap">
<table>
<thead>
<tr>
<th scope="col">
<label for="chkall" class="sound_only">전체</label>
<input type="checkbox" name="chkall" value="1" id="chkall" onclick="check_all(this.form)">
</th>
<th scope="col">번호</th>
<th scope="col">제목</th>
<th scope="col">작성자</th>
<th scope="col">상태</th>
<th scope="col">브랜드</th>
<th scope="col">등록일</th>
<th scope="col">관리</th>
</tr>
</thead>
<tbody>
<?php
// 목록 조회
$sql = "SELECT w.*, e.status as estimate_status,
(SELECT GROUP_CONCAT(DISTINCT brand)
FROM estimate_item ei
JOIN estimate est ON ei.estimate_id = est.id
WHERE est.wr_id = w.wr_id AND brand IS NOT NULL AND brand != '') as brands
FROM {$write_table} w
LEFT JOIN estimate e ON w.wr_id = e.wr_id
{$where}
ORDER BY w.wr_num, w.wr_reply
LIMIT 0, 30";
$result = sql_query($sql);
$listall = '<a href="'.$_SERVER['SCRIPT_NAME'].'" class="ov_listall">전체목록</a>';
for ($i=0; $row=sql_fetch_array($result); $i++) {
$status = $row['wr_1'] ?: $row['estimate_status'] ?: '견적신청중';
$brands = $row['brands'] ? explode(',', $row['brands']) : [];
// 사용자별 표시명
$display_status = $status;
if ($member['mb_level'] >= 8) {
// 관리자용 표시명
$display_status = $status;
}
?>
<tr class="<?php echo $row['wr_parent'] ? 'reply' : 'original' ?>">
<td>
<input type="checkbox" name="chk_wr_id[]" value="<?php echo $row['wr_id'] ?>" id="chk_wr_id_<?php echo $i ?>">
</td>
<td><?php echo $row['wr_id'] ?></td>
<td class="td_subject">
<?php echo $row['wr_parent'] ? '└ ' : '' ?>
<a href="<?php echo G5_BBS_URL ?>/board.php?bo_table=order&amp;wr_id=<?php echo $row['wr_id'] ?>">
<?php echo get_text($row['wr_subject']) ?>
</a>
</td>
<td><?php echo $row['wr_name'] ?></td>
<td class="text-center">
<span class="status-btn status-<?php echo $status ?>"
onclick="openStatusPopup(<?php echo $row['wr_id'] ?>, '<?php echo $status ?>', <?php echo $member['mb_level'] ?>, <?php echo !empty($row['wr_parent']) ? 'true' : 'false' ?>)">
<?php echo $display_status ?>
</span>
</td>
<td class="text-center">
<?php
if ($brands) {
foreach (array_slice($brands, 0, 2) as $brand) {
echo '<span class="brand-info">' . htmlspecialchars(trim($brand)) . '</span>';
}
if (count($brands) > 2) {
echo '<span class="brand-info">+' . (count($brands) - 2) . '</span>';
}
} else {
echo '<span style="color:#999;">-</span>';
}
?>
</td>
<td><?php echo substr($row['wr_datetime'], 0, 10) ?></td>
<td>
<a href="<?php echo G5_BBS_URL ?>/board.php?bo_table=order&amp;wr_id=<?php echo $row['wr_id'] ?>" class="btn btn_03">보기</a>
</td>
</tr>
<?php
}
if ($i == 0) {
echo '<tr><td colspan="8" class="empty_table">자료가 없습니다.</td></tr>';
}
?>
</tbody>
</table>
</div>
</form>
<!-- 상태 변경 팝업 -->
<div id="statusPopupOverlay" class="status-popup-overlay">
<div class="status-popup">
<div class="status-popup-header">
<h3>상태 변경</h3>
<button type="button" class="close-btn" onclick="closeStatusPopup()">&times;</button>
</div>
<div class="status-popup-body">
<form id="statusChangeForm">
<input type="hidden" id="popup_wr_id" name="wr_id">
<input type="hidden" name="action" value="change_status">
<div class="form-group">
<label>현재 상태:</label>
<div id="current_status_display" style="color:#666; font-weight:500;"></div>
</div>
<div class="form-group">
<label for="new_status">변경할 상태:</label>
<select id="new_status" name="new_status" class="form-control" required>
<!-- JavaScript로 동적 생성 -->
</select>
</div>
<div class="form-group">
<label for="memo">변경 사유 (선택):</label>
<textarea id="memo" name="memo" class="form-control" placeholder="상태 변경 사유를 입력하세요"></textarea>
</div>
</form>
</div>
<div class="status-popup-footer">
<button type="button" class="btn btn-secondary" onclick="closeStatusPopup()">취소</button>
<button type="button" class="btn btn-primary" onclick="submitStatusChange()">변경</button>
</div>
</div>
</div>
<script>
// 상태 전환 규칙
const statusTransitions = {
admin: {
original: {
'견적신청중': ['작성완료', '견적취소'],
'작성완료': ['견적신청중', '입금예정'],
'견적채택': ['입금예정'],
'입금예정': ['입금확인'],
'입금확인': ['다운로드'],
'다운로드': ['견적신청중']
},
reply: {
'견적제안': ['견적채택', '견적취소'],
'견적채택': ['견적제안'],
'견적취소': ['견적제안']
}
}
};
let currentWrId, currentStatus, currentUserLevel, isReply;
function openStatusPopup(wrId, status, userLevel, isReplyPost) {
currentWrId = wrId;
currentStatus = status;
currentUserLevel = userLevel;
isReply = isReplyPost;
document.getElementById('popup_wr_id').value = wrId;
document.getElementById('current_status_display').textContent = status;
const newStatusSelect = document.getElementById('new_status');
newStatusSelect.innerHTML = '';
const postType = isReplyPost ? 'reply' : 'original';
const availableStatuses = statusTransitions.admin[postType][status] || [];
if (availableStatuses.length === 0) {
newStatusSelect.innerHTML = '<option value="">변경 가능한 상태가 없습니다</option>';
} else {
availableStatuses.forEach(statusValue => {
const option = document.createElement('option');
option.value = statusValue;
option.textContent = statusValue;
newStatusSelect.appendChild(option);
});
}
document.getElementById('statusPopupOverlay').style.display = 'block';
}
function closeStatusPopup() {
document.getElementById('statusPopupOverlay').style.display = 'none';
}
function submitStatusChange() {
const form = document.getElementById('statusChangeForm');
const formData = new FormData(form);
const newStatus = document.getElementById('new_status').value;
if (!newStatus) {
alert('변경할 상태를 선택해주세요.');
return;
}
if (!confirm('상태를 "' + newStatus + '"로 변경하시겠습니까?')) {
return;
}
fetch('./list.php', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(data.message);
closeStatusPopup();
location.reload();
} else {
alert('오류: ' + data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('서버 오류가 발생했습니다.');
});
}
document.getElementById('statusPopupOverlay').addEventListener('click', function(e) {
if (e.target === this) {
closeStatusPopup();
}
});
</script>
<?php
include_once('./admin.tail.php');
?>
@@ -0,0 +1,213 @@
<?php
include_once('../../../_common.php');
// 알림 시스템 로드
if (file_exists(G5_LIB_PATH . '/notification_helper.php')) {
include_once(G5_LIB_PATH . '/notification_helper.php');
}
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['success' => false, 'message' => 'POST 요청만 허용됩니다.']);
exit;
}
$wr_id = (int) ($_POST['wr_id'] ?? 0);
$new_status = trim($_POST['new_status'] ?? '');
$memo = trim($_POST['memo'] ?? '');
if (!$wr_id || !$new_status) {
echo json_encode(['success' => false, 'message' => '필수 파라미터가 누락되었습니다.']);
exit;
}
if (!$is_member) {
echo json_encode(['success' => false, 'message' => '로그인이 필요합니다.']);
exit;
}
$is_admin = ($member['mb_level'] ?? 0) >= 8;
$is_agent = in_array(($member['mb_level'] ?? 0), [5, 6, 7]);
try {
sql_query("START TRANSACTION");
$write_table = $g5['write_prefix'] . 'order';
$write = sql_fetch("SELECT * FROM {$write_table} WHERE wr_id = '{$wr_id}'");
if (!$write) {
throw new Exception('게시물을 찾을 수 없습니다.');
}
$is_owner = ($member['mb_id'] === $write['mb_id']);
if (!$is_admin && !$is_owner && !$is_agent) {
throw new Exception('권한이 없습니다.');
}
$current_estimate = sql_fetch("SELECT * FROM estimate WHERE wr_id = '{$wr_id}'");
$old_status = $current_estimate ? $current_estimate['status'] : '견적신청중';
// 권한별 상태 변경 검증
$allowed = false;
if ($is_admin) {
$allowed = true; // 관리자는 모든 상태 변경 가능
} elseif ($is_owner && empty($write['wr_parent'])) {
// 고객 (원본글 작성자)
if ($old_status === '견적신청중' && $new_status === '작성완료') {
$allowed = true;
} elseif ($old_status === '작성완료' && $new_status === '견적채택') {
$allowed = true;
}
} elseif ($is_agent && !empty($write['wr_parent']) && $write['mb_id'] === $member['mb_id']) {
// 대리점 (답글 작성자)
if ($old_status === '견적제안' && in_array($new_status, ['견적채택', '견적취소'])) {
$allowed = true;
}
}
if (!$allowed) {
throw new Exception('해당 상태로 변경할 권한이 없습니다.');
}
// 상태 업데이트
if ($current_estimate) {
sql_query("UPDATE estimate SET
status = '{$new_status}',
updated_at = NOW(),
updated_by = '{$member['mb_id']}'
WHERE wr_id = '{$wr_id}'");
} else {
sql_query("INSERT INTO estimate (wr_id, status, created_at, created_by, updated_at, updated_by)
VALUES ('{$wr_id}', '{$new_status}', NOW(), '{$member['mb_id']}', NOW(), '{$member['mb_id']}')");
}
// 게시판 wr_1 필드도 업데이트
sql_query("UPDATE {$write_table} SET wr_1 = '{$new_status}' WHERE wr_id = '{$wr_id}'");
// 이력 기록
$history_data = json_encode([
'old_status' => $old_status,
'new_status' => $new_status,
'changed_by' => $member['mb_id'],
'changed_at' => date('Y-m-d H:i:s'),
'memo' => $memo,
'ip' => $_SERVER['REMOTE_ADDR']
], JSON_UNESCAPED_UNICODE);
$estimate = sql_fetch("SELECT id FROM estimate WHERE wr_id = '{$wr_id}'");
$estimate_id = $estimate ? $estimate['id'] : 0;
sql_query("INSERT INTO estimate_history (
estimate_id, action, change_details, changed_by, changed_at
) VALUES (
'{$estimate_id}', 'status_change', '{$history_data}', '{$member['mb_id']}', NOW()
)");
// 알림 발송
processStatusChangeNotification($write, $old_status, $new_status, $member);
// 견적채택 특별 처리
if ($new_status === '견적채택' && !empty($write['wr_parent'])) {
handleQuoteSelection($write, $member);
}
sql_query("COMMIT");
echo json_encode([
'success' => true,
'message' => '상태가 성공적으로 변경되었습니다.',
'data' => [
'old_status' => $old_status,
'new_status' => $new_status
]
]);
} catch (Exception $e) {
sql_query("ROLLBACK");
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
function processStatusChangeNotification($write, $old_status, $new_status, $member) {
switch ($new_status) {
case '작성완료':
notifyAgentsNewRequest($write);
break;
case '견적제안':
notifyCustomerQuoteReceived($write);
break;
case '입금확인':
notifyPaymentConfirmed($write, $member);
break;
}
}
function notifyAgentsNewRequest($write) {
$agents_sql = "SELECT mb_id, mb_name, mb_email, mb_hp FROM {$GLOBALS['g5']['member_table']}
WHERE mb_level IN (5,6,7) AND mb_leave_date = '' AND mb_intercept_date = ''";
$agents = sql_query($agents_sql);
while ($agent = sql_fetch_array($agents)) {
$subject = "[견적요청] 새로운 견적 요청이 등록되었습니다";
$content = "안녕하세요 {$agent['mb_name']}님,\n\n새로운 견적 요청이 등록되었습니다.\n\n고객명: {$write['wr_name']}\n요청제목: {$write['wr_subject']}\n등록시간: " . date('Y-m-d H:i') . "\n\n확인 URL: " . G5_HTTP_BBS_URL . "/board.php?bo_table=order&wr_id={$write['wr_id']}\n\n감사합니다.";
@mailer($agent['mb_name'], $agent['mb_email'], $subject, $content, 1);
if ($agent['mb_hp']) {
@send_sms($agent['mb_hp'], "[견적요청] {$write['wr_name']}님의 새 견적요청이 등록되었습니다.");
}
}
}
function notifyCustomerQuoteReceived($write) {
$parent_write = sql_fetch("SELECT * FROM {$GLOBALS['g5']['write_prefix']}order WHERE wr_id = '{$write['wr_parent']}'");
if ($parent_write) {
$customer = get_member($parent_write['mb_id']);
$agent_name = get_member_name($write['mb_id']);
@mailer($parent_write['wr_name'], $customer['mb_email'], "[견적도착] {$agent_name}님이 견적을 제안했습니다",
"안녕하세요 {$parent_write['wr_name']}님,\n\n{$agent_name}님이 견적을 제안했습니다.\n\n확인해주세요.", 1);
if ($customer['mb_hp']) {
@send_sms($customer['mb_hp'], "[견적도착] {$agent_name}님이 견적을 제안했습니다.");
}
}
}
function notifyPaymentConfirmed($write, $admin_member) {
$customer = get_member($write['mb_id']);
@mailer($write['wr_name'], $customer['mb_email'], "[입금확인] 입금이 확인되었습니다",
"안녕하세요 {$write['wr_name']}님,\n\n입금이 확인되었습니다.\n\n감사합니다.", 1);
if ($customer['mb_hp']) {
@send_sms($customer['mb_hp'], "[입금확인] 입금이 확인되었습니다.");
}
}
function handleQuoteSelection($write, $member) {
$origin_wr_id = $write['wr_parent'];
$write_table = $GLOBALS['g5']['write_prefix'] . 'order';
sql_query("UPDATE estimate SET status = '입금예정' WHERE wr_id = '{$origin_wr_id}'");
sql_query("UPDATE {$write_table} SET wr_1 = '견적취소' WHERE wr_parent = '{$origin_wr_id}' AND wr_id != '{$write['wr_id']}'");
$agent = get_member($write['mb_id']);
@mailer($agent['mb_name'], $agent['mb_email'], "[견적채택] 축하합니다!", "견적이 채택되었습니다.", 1);
if ($agent['mb_hp']) {
@send_sms($agent['mb_hp'], "[견적채택] 축하합니다! 견적이 채택되었습니다.");
}
}
function get_member_name($mb_id) {
if (!$mb_id) return '';
$member = sql_fetch("SELECT mb_name FROM {$GLOBALS['g5']['member_table']} WHERE mb_id = '{$mb_id}'");
return $member ? $member['mb_name'] : $mb_id;
}
function send_sms($phone, $message) {
return true;
}
?>
+227
View File
@@ -0,0 +1,227 @@
<?php
$sub_menu = "800760";
include_once('./_common.php');
auth_check_menu($auth, $sub_menu, 'r');
$g5['title'] = 'EstimateManager 테스트';
// EstimateManager 클래스 로드
require_once G5_PATH . '/adm/order_manage/classes/EstimateManager.class.php';
$estimate_manager = new EstimateManager();
$test_results = [];
// 테스트 실행
if ($_POST['action'] === 'test') {
$test_type = trim($_POST['test_type']);
$wr_id = (int) $_POST['wr_id'];
switch ($test_type) {
case 'complete_estimate':
$result = $estimate_manager->completeEstimate($wr_id, 'customer');
$test_results[] = $result;
break;
case 'calculate_payment':
$estimate_id = (int) $_POST['estimate_id'];
$payment_type = trim($_POST['payment_type']);
$result = $estimate_manager->calculatePaymentAmount($estimate_id, $payment_type);
$test_results[] = [
'success' => $result !== false,
'data' => $result,
'message' => $result !== false ? '결제 금액 계산 성공' : '결제 금액 계산 실패'
];
break;
case 'get_details':
$result = $estimate_manager->getEstimateDetails($wr_id, 'admin', $member['mb_id']);
$test_results[] = [
'success' => $result !== false,
'data' => $result,
'message' => $result !== false ? '견적 정보 조회 성공' : '견적 정보 조회 실패'
];
break;
}
}
// 최근 견적 목록 조회 (테스트용)
$recent_estimates = [];
$sql = "
SELECT w.wr_id, w.wr_subject, w.mb_id, e.id as estimate_id, e.status, e.updated_at, e.selected_bid_id
FROM {$g5['write_prefix']}order w
LEFT JOIN estimate e ON w.wr_id = e.wr_id
WHERE w.wr_parent = 0
ORDER BY w.wr_datetime DESC
LIMIT 10
";
$result = sql_query($sql);
while ($row = sql_fetch_array($result)) {
$recent_estimates[] = $row;
}
include_once(G5_ADMIN_PATH . '/admin.head.php');
?>
<div class="local_desc01 local_desc">
<p>
EstimateManager 클래스의 견적 관리 기능을 테스트합니다.<br>
StatusManager와 연동된 기능들을 확인할 수 있습니다.
</p>
</div>
<?php if (!empty($test_results)): ?>
<div class="local_desc02 local_desc">
<h3>테스트 결과</h3>
<?php foreach ($test_results as $result): ?>
<div
style="padding: 15px; margin: 10px 0; border: 1px solid <?php echo $result['success'] ? '#28a745' : '#dc3545'; ?>; background: <?php echo $result['success'] ? '#d4edda' : '#f8d7da'; ?>;">
<strong><?php echo $result['success'] ? '성공' : '실패'; ?>:</strong>
<?php echo htmlspecialchars($result['message']); ?>
<?php if (isset($result['data']) && $result['data']): ?>
<br><br><strong>데이터:</strong>
<pre
style="background: #f8f9fa; padding: 10px; border-radius: 4px; font-size: 12px; max-height: 300px; overflow-y: auto;"><?php echo htmlspecialchars(json_encode($result['data'], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); ?></pre>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<form name="ftest" method="post" action="./estimate_test.php">
<input type="hidden" name="action" value="test">
<div class="tbl_frm01 tbl_wrap">
<table>
<caption>EstimateManager 테스트</caption>
<colgroup>
<col class="grid_4">
<col>
</colgroup>
<tbody>
<tr>
<th scope="row"><label for="test_type">테스트 유형</label></th>
<td>
<select name="test_type" id="test_type" class="frm_input" onchange="toggleTestFields()">
<option value="">선택하세요</option>
<option value="complete_estimate">견적 작성 완료</option>
<option value="calculate_payment">결제 금액 계산</option>
<option value="get_details">견적 상세 정보 조회</option>
</select>
</td>
</tr>
<tr>
<th scope="row"><label for="wr_id">게시물 ID</label></th>
<td>
<select name="wr_id" id="wr_id" class="frm_input">
<option value="">선택하세요</option>
<?php foreach ($recent_estimates as $estimate): ?>
<option value="<?php echo $estimate['wr_id']; ?>"
data-estimate-id="<?php echo $estimate['estimate_id']; ?>">
<?php echo $estimate['wr_id']; ?> -
<?php echo htmlspecialchars($estimate['wr_subject']); ?>
(상태: <?php echo $estimate['status'] ?: '견적신청중'; ?>)
</option>
<?php endforeach; ?>
</select>
</td>
</tr>
<tr id="estimate_id_row" style="display: none;">
<th scope="row"><label for="estimate_id">견적서 ID</label></th>
<td>
<input type="number" name="estimate_id" id="estimate_id" class="frm_input">
<div class="frm_info">결제 금액 계산 시 필요</div>
</td>
</tr>
<tr id="payment_type_row" style="display: none;">
<th scope="row"><label for="payment_type">결제 타입</label></th>
<td>
<select name="payment_type" id="payment_type" class="frm_input">
<option value="deposit">계약금</option>
<option value="interim">중도금</option>
<option value="final">잔금</option>
</select>
</td>
</tr>
</tbody>
</table>
</div>
<div class="btn_confirm01 btn_confirm">
<input type="submit" value="테스트 실행" class="btn_submit">
</div>
</form>
<div class="local_desc02 local_desc">
<h3>최근 견적 목록</h3>
<table class="tbl_head01 tbl_wrap">
<thead>
<tr>
<th>게시물 ID</th>
<th>견적서 ID</th>
<th>제목</th>
<th>작성자</th>
<th>현재 상태</th>
<th>선택된 입찰</th>
<th>수정일</th>
</tr>
</thead>
<tbody>
<?php foreach ($recent_estimates as $estimate): ?>
<tr>
<td><?php echo $estimate['wr_id']; ?></td>
<td><?php echo $estimate['estimate_id'] ?: '-'; ?></td>
<td><?php echo htmlspecialchars($estimate['wr_subject']); ?></td>
<td><?php echo htmlspecialchars($estimate['mb_id']); ?></td>
<td><?php echo $estimate['status'] ?: '견적신청중'; ?></td>
<td><?php echo $estimate['selected_bid_id'] ?: '-'; ?></td>
<td><?php echo $estimate['updated_at'] ?: '-'; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="local_desc02 local_desc">
<h3>EstimateManager 주요 기능</h3>
<ul>
<li><strong>StatusManager 연동:</strong> 체계적인 상태 관리와 연동</li>
<li><strong>견적 생명주기 관리:</strong> 생성 → 완료 → 선택 → 결제 전체 프로세스</li>
<li><strong>권한별 데이터 접근:</strong> 사용자 역할에 따른 정보 필터링</li>
<li><strong>대리점 입찰 시스템:</strong> 시스템 비용 자동 계산 포함</li>
<li><strong>결제 관리:</strong> 단계별 결제 금액 계산 및 상태 관리</li>
<li><strong>트랜잭션 처리:</strong> 데이터 무결성 보장</li>
</ul>
</div>
<script>
function toggleTestFields() {
const testType = document.getElementById('test_type').value;
const estimateIdRow = document.getElementById('estimate_id_row');
const paymentTypeRow = document.getElementById('payment_type_row');
// 모든 필드 숨기기
estimateIdRow.style.display = 'none';
paymentTypeRow.style.display = 'none';
// 테스트 유형에 따라 필요한 필드 표시
if (testType === 'calculate_payment') {
estimateIdRow.style.display = '';
paymentTypeRow.style.display = '';
}
}
// 게시물 선택 시 견적서 ID 자동 설정
document.getElementById('wr_id').addEventListener('change', function () {
const selectedOption = this.options[this.selectedIndex];
const estimateId = selectedOption.getAttribute('data-estimate-id');
if (estimateId) {
document.getElementById('estimate_id').value = estimateId;
}
});
</script>
<?php
include_once(G5_ADMIN_PATH . '/admin.tail.php');
?>
+95
View File
@@ -0,0 +1,95 @@
<?php
require_once './_common.php';
// 관리자 권한 체크
if (!$is_admin) {
die('관리자만 접근 가능합니다.');
}
echo "<h2>견적서 시스템 업데이트 실행</h2>";
// SQL 파일 읽기
$sql_file = './update_for_new_features.sql';
if (!file_exists($sql_file)) {
die('SQL 파일을 찾을 수 없습니다.');
}
$sql_content = file_get_contents($sql_file);
// SQL 문을 세미콜론으로 분리
$sql_statements = explode(';', $sql_content);
$success_count = 0;
$error_count = 0;
$errors = array();
echo "<div style='background: #f5f5f5; padding: 10px; margin: 10px 0;'>";
echo "<h3>업데이트 진행 상황:</h3>";
foreach ($sql_statements as $sql) {
$sql = trim($sql);
// 빈 문장이나 주석 건너뛰기
if (empty($sql) || strpos($sql, '--') === 0 || strpos($sql, '/*') === 0) {
continue;
}
try {
$result = sql_query($sql, false);
if ($result) {
$success_count++;
echo "<p style='color: green;'>✓ SQL 실행 성공: " . substr($sql, 0, 50) . "...</p>";
} else {
$error_count++;
$error_msg = sql_error();
$errors[] = "SQL: " . substr($sql, 0, 100) . "... | 오류: " . $error_msg;
echo "<p style='color: orange;'>⚠ SQL 실행 건너뜀 (이미 존재하거나 무시): " . substr($sql, 0, 50) . "...</p>";
}
} catch (Exception $e) {
$error_count++;
$errors[] = "SQL: " . substr($sql, 0, 100) . "... | 오류: " . $e->getMessage();
echo "<p style='color: red;'>✗ SQL 실행 실패: " . substr($sql, 0, 50) . "... | 오류: " . $e->getMessage() . "</p>";
}
}
echo "</div>";
echo "<h3>업데이트 결과:</h3>";
echo "<p>성공: {$success_count}개</p>";
echo "<p>오류/건너뜀: {$error_count}개</p>";
if (!empty($errors)) {
echo "<h4>오류 상세:</h4>";
echo "<ul>";
foreach ($errors as $error) {
echo "<li>" . htmlspecialchars($error) . "</li>";
}
echo "</ul>";
}
// 테이블 생성 확인
echo "<h3>테이블 생성 확인:</h3>";
$tables_to_check = array('order_config', 'order_mail_templates', 'order_sms_templates');
foreach ($tables_to_check as $table) {
$full_table_name = G5_TABLE_PREFIX . $table;
$result = sql_query("SHOW TABLES LIKE '{$full_table_name}'", false);
if ($result && sql_num_rows($result) > 0) {
echo "<p style='color: green;'>✓ {$table} 테이블 존재</p>";
// 데이터 개수 확인
$count_result = sql_query("SELECT COUNT(*) as cnt FROM {$full_table_name}", false);
if ($count_result) {
$count_row = sql_fetch_array($count_result);
echo "<p style='margin-left: 20px;'>- 데이터 개수: {$count_row['cnt']}개</p>";
}
} else {
echo "<p style='color: red;'>✗ {$table} 테이블 없음</p>";
}
}
echo "<hr>";
echo "<p><a href='./config_index.php'>설정 관리로 이동</a></p>";
echo "<p><a href='./expert_visits.php'>전문가 방문 관리로 이동</a></p>";
echo "<p><a href='./mail_templates.php'>메일 템플릿 관리로 이동</a></p>";
?>
@@ -0,0 +1,297 @@
<?php
$sub_menu = "800800";
include_once('./_common.php');
auth_check($auth[$sub_menu], 'r');
$g5['title'] = '전문가 방문 예약 관리';
// lib/notification_helper.php 포함 (설정값 가져오기 위해)
// 경로 수정: G5_LIB_PATH 대신 현재 디렉토리 기준 lib 폴더 참조
if (file_exists(__DIR__ . '/lib/notification_helper.php')) {
include_once(__DIR__ . '/lib/notification_helper.php');
}
function get_reservation_status_name($status_code) {
// get_order_config 함수가 정의되어 있는지 확인 후 호출
if (function_exists('get_order_config')) {
return get_order_config('reservation_status_' . $status_code, $status_code);
}
return $status_code;
}
// 검색 조건
$sql_search = "";
$sfl = isset($_GET['sfl']) ? clean_xss_tags($_GET['sfl']) : '';
$stx = isset($_GET['stx']) ? clean_xss_tags($_GET['stx']) : '';
$sst = isset($_GET['sst']) ? clean_xss_tags($_GET['sst']) : 'id';
$sod = isset($_GET['sod']) ? clean_xss_tags($_GET['sod']) : 'desc';
$status_filter = isset($_GET['status_filter']) ? clean_xss_tags($_GET['status_filter']) : '';
if ($stx) {
$stx_escaped = sql_real_escape_string($stx);
if ($sfl === 'customer_name') {
$sql_search .= " AND customer_name LIKE '%{$stx_escaped}%' ";
} elseif ($sfl === 'customer_phone') {
$sql_search .= " AND customer_phone LIKE '%{$stx_escaped}%' ";
}
}
if ($status_filter) {
$sql_search .= " AND status = '" . sql_real_escape_string($status_filter) . "' ";
}
// 정렬
$sql_order = " ORDER BY {$sst} {$sod} ";
// 페이징
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
$page_rows = 15;
$from_record = ($page - 1) * $page_rows;
// 전체 개수
$sql_count = "SELECT COUNT(*) as cnt FROM expert_visit_reservations WHERE is_deleted = 0 {$sql_search}";
$count_row = sql_fetch($sql_count);
$total_count = $count_row['cnt'];
$total_page = ceil($total_count / $page_rows);
// 데이터 조회
$sql = "SELECT * FROM expert_visit_reservations WHERE is_deleted = 0 {$sql_search} {$sql_order} LIMIT {$from_record}, {$page_rows}";
$result = sql_query($sql);
$reservations = [];
while($row = sql_fetch_array($result)) {
$reservations[] = $row;
}
// 전문가 목록 조회 (예: 레벨 8)
$experts_result = sql_query("SELECT mb_id, mb_name FROM {$g5['member_table']} WHERE mb_level = 8 AND mb_leave_date = '' ORDER BY mb_name ASC");
$experts = [];
while($row = sql_fetch_array($experts_result)) {
$experts[] = $row;
}
include_once(G5_ADMIN_PATH . '/admin.head.php');
// jQuery 로드 확인 및 추가 (오류 방지)
?>
<?php
$qstr = http_build_query([
'sfl' => $sfl,
'stx' => $stx,
'sst' => $sst,
'sod' => $sod,
'status_filter' => $status_filter
]);
?>
<div class="local_ov01 local_ov">
<span class="btn_ov01">
<span class="ov_txt">전체 예약 </span>
<span class="ov_num"><?php echo number_format($total_count); ?>건</span>
</span>
</div>
<form name="fsearch" id="fsearch" class="local_sch01 local_sch" method="get">
<label for="status_filter" class="sound_only">상태 필터</label>
<select name="status_filter" id="status_filter">
<option value="">상태 전체</option>
<option value="payment_pending" <?php echo get_selected($status_filter, 'payment_pending'); ?>><?php echo get_reservation_status_name('payment_pending'); ?></option>
<option value="reserved" <?php echo get_selected($status_filter, 'reserved'); ?>><?php echo get_reservation_status_name('reserved'); ?></option>
<option value="completed" <?php echo get_selected($status_filter, 'completed'); ?>><?php echo get_reservation_status_name('completed'); ?></option>
<option value="cancelled" <?php echo get_selected($status_filter, 'cancelled'); ?>><?php echo get_reservation_status_name('cancelled'); ?></option>
</select>
<label for="sfl" class="sound_only">검색대상</label>
<select name="sfl" id="sfl">
<option value="customer_name" <?php echo get_selected($sfl, 'customer_name'); ?>>고객명</option>
<option value="customer_phone" <?php echo get_selected($sfl, 'customer_phone'); ?>>연락처</option>
</select>
<label for="stx" class="sound_only">검색어<strong class="sound_only"> 필수</strong></label>
<input type="text" name="stx" value="<?php echo $stx ?>" id="stx" class="frm_input">
<input type="submit" class="btn_submit" value="검색">
</form>
<div class="tbl_head01 tbl_wrap">
<table>
<caption><?php echo $g5['title']; ?> 목록</caption>
<thead>
<tr>
<th scope="col">예약번호</th>
<th scope="col">고객명</th>
<th scope="col">연락처</th>
<th scope="col">방문일시</th>
<th scope="col">방문종류</th>
<th scope="col">상태</th>
<th scope="col">담당전문가</th>
<th scope="col">신청일</th>
<th scope="col" style="width: 120px;">관리</th>
</tr>
</thead>
<tbody>
<?php if (empty($reservations)): ?>
<tr><td colspan="9" class="empty_table">자료가 없습니다.</td></tr>
<?php else: ?>
<?php foreach ($reservations as $res): ?>
<tr>
<td><?php echo $res['id']; ?></td>
<td><?php echo htmlspecialchars($res['customer_name']); ?></td>
<td><?php echo htmlspecialchars($res['customer_phone']); ?></td>
<td><?php echo $res['visit_date'] . ' ' . substr($res['visit_time'], 0, 5); ?></td>
<td><?php echo htmlspecialchars($res['temp_2']); ?></td>
<td><?php echo get_reservation_status_name($res['status']); ?></td>
<td><?php echo $res['expert_id'] ? (get_member($res['expert_id'])['mb_name'] ?? $res['expert_id']) : '미배정'; ?></td>
<td><?php echo substr($res['created_at'], 0, 10); ?></td>
<td class="td_mng td_mng_s">
<button type="button" class="btn btn_03 manage-btn" data-id="<?php echo $res['id']; ?>">상세/관리</button>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<?php echo get_paging(G5_IS_MOBILE ? $config['cf_mobile_pages'] : $config['cf_write_pages'], $page, $total_page, $_SERVER['SCRIPT_NAME'].'?'.$qstr.'&amp;page='); ?>
<!-- 상세/관리 모달 -->
<div id="reservationModal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.5); z-index:1000;">
<div style="position:absolute; top:50%; left:50%; transform:translate(-50%, -50%); background:white; padding:20px; border-radius:5px; width:600px;">
<h3 id="modalTitle">전문가 방문 예약 상세/관리</h3>
<form name="fmodal" id="fmodal">
<input type="hidden" name="id" id="modal_id">
<div class="tbl_frm01 tbl_wrap">
<table>
<colgroup>
<col class="grid_4">
<col>
</colgroup>
<tbody>
<tr>
<th scope="row">고객 정보</th>
<td id="modal_customer_info"></td>
</tr>
<tr>
<th scope="row">예약 정보</th>
<td id="modal_reservation_info"></td>
</tr>
<tr>
<th scope="row">방문 정보</th>
<td id="modal_visit_info"></td>
</tr>
<tr>
<th scope="row">고객 요청사항</th>
<td><textarea id="modal_request_memo" readonly style="width:100%;height:60px;"></textarea></td>
</tr>
<tr>
<th scope="row"><label for="modal_status">상태 변경</label></th>
<td>
<select name="status" id="modal_status">
<option value="payment_pending"><?php echo get_reservation_status_name('payment_pending'); ?></option>
<option value="reserved"><?php echo get_reservation_status_name('reserved'); ?></option>
<option value="completed"><?php echo get_reservation_status_name('completed'); ?></option>
<option value="cancelled"><?php echo get_reservation_status_name('cancelled'); ?></option>
</select>
</td>
</tr>
<tr>
<th scope="row"><label for="modal_expert_id">전문가 배정</label></th>
<td>
<select name="expert_id" id="modal_expert_id">
<option value="">전문가 선택</option>
<?php foreach ($experts as $c): ?>
<option value="<?php echo $c['mb_id']; ?>"><?php echo htmlspecialchars($c['mb_name']); ?></option>
<?php endforeach; ?>
</select>
</td>
</tr>
<tr>
<th scope="row"><label for="modal_admin_memo">관리자 메모</label></th>
<td><textarea name="admin_memo" id="modal_admin_memo" style="width:100%;height:80px;"></textarea></td>
</tr>
</tbody>
</table>
</div>
<div class="btn_confirm01 btn_confirm" style="margin-top:20px;">
<button type="button" id="modalSaveBtn" class="btn_submit">저장</button>
<button type="button" id="modalCloseBtn" class="btn_cancel">닫기</button>
</div>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const modal = document.getElementById('reservationModal');
const manageButtons = document.querySelectorAll('.manage-btn');
const closeBtn = document.getElementById('modalCloseBtn');
const saveBtn = document.getElementById('modalSaveBtn');
function openModal(id) {
fetch('./expert_visit_reservations_ajax.php?action=get_details&id=' + id)
.then(response => response.json())
.then(data => {
if (data.success) {
const res = data.data;
document.getElementById('modal_id').value = res.id;
document.getElementById('modalTitle').innerText = `전문가 방문 예약 상세/관리 (예약번호: ${res.id})`;
document.getElementById('modal_customer_info').innerHTML = `${res.customer_name} (${res.customer_phone} / ${res.customer_email})`;
document.getElementById('modal_reservation_info').innerHTML = `${res.visit_date} ${res.visit_time.substring(0,5)} / <strong>방문비: ${Number(res.payment_amount).toLocaleString()}원</strong>`;
document.getElementById('modal_visit_info').innerHTML = `종류: ${res.temp_2} / 지역: ${res.temp_3} / 예상시간: ${res.temp_4}분`;
document.getElementById('modal_request_memo').value = res.request_memo;
document.getElementById('modal_status').value = res.status;
document.getElementById('modal_expert_id').value = res.expert_id || '';
document.getElementById('modal_admin_memo').value = res.admin_memo;
modal.style.display = 'block';
} else {
alert(data.message);
}
});
}
function closeModal() {
modal.style.display = 'none';
}
manageButtons.forEach(btn => {
btn.addEventListener('click', function() {
openModal(this.dataset.id);
});
});
closeBtn.addEventListener('click', closeModal);
modal.addEventListener('click', function(e) {
if (e.target === modal) {
closeModal();
}
});
saveBtn.addEventListener('click', function() {
const form = document.getElementById('fmodal');
const formData = new FormData(form);
formData.append('action', 'update_reservation');
if (!confirm('변경 내용을 저장하시겠습니까?')) return;
fetch('./expert_visit_reservations_ajax.php', {
method: 'POST',
body: new URLSearchParams(formData)
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(data.message);
location.reload();
} else {
alert('오류: ' + data.message);
}
});
});
});
</script>
<?php
include_once(G5_ADMIN_PATH . '/admin.tail.php');
?>
@@ -0,0 +1,70 @@
<?php
include_once('./_common.php');
// 관리자 권한 확인
if (!$is_admin) {
die(json_encode(['success' => false, 'message' => '관리자 권한이 필요합니다.']));
}
// lib/notification_helper.php 포함
if (file_exists(G5_LIB_PATH . '/notification_helper.php')) {
include_once(G5_LIB_PATH . '/notification_helper.php');
}
$action = isset($_REQUEST['action']) ? clean_xss_tags($_REQUEST['action']) : '';
$id = isset($_REQUEST['id']) ? (int)$_REQUEST['id'] : 0;
header('Content-Type: application/json; charset=utf-8');
try {
switch ($action) {
case 'get_details':
if (!$id) throw new Exception('예약 ID가 없습니다.');
$reservation = sql_fetch("SELECT * FROM expert_visit_reservations WHERE id = '{$id}'");
if (!$reservation) throw new Exception('예약 정보를 찾을 수 없습니다.');
echo json_encode(['success' => true, 'data' => $reservation]);
break;
case 'update_reservation':
if (!$id) throw new Exception('예약 ID가 없습니다.');
$status = isset($_POST['status']) ? clean_xss_tags($_POST['status']) : '';
$expert_id = isset($_POST['expert_id']) ? clean_xss_tags($_POST['expert_id']) : '';
$admin_memo = isset($_POST['admin_memo']) ? clean_xss_tags($_POST['admin_memo']) : '';
// 기존 정보 조회 (상태 변경 시 알림을 위함)
$old_reservation = sql_fetch("SELECT status FROM expert_visit_reservations WHERE id = '{$id}'");
$sql = "UPDATE expert_visit_reservations SET " .
" status = '" . sql_real_escape_string($status) . "', " .
" expert_id = '" . sql_real_escape_string($expert_id) . "', " .
" admin_memo = '" . sql_real_escape_string($admin_memo) . "', " .
" updated_at = NOW(), " .
" updated_by = '{$member['mb_id']}' " .
" WHERE id = '{$id}' ";
sql_query($sql);
// 상태 변경 시 알림 발송
if (function_exists('notify_for_expert_visit')) {
// 입금 확인 -> 예약 확정
if ($old_reservation['status'] !== 'reserved' && $status === 'reserved') {
notify_for_expert_visit($id, '예약확정');
}
}
echo json_encode(['success' => true, 'message' => '방문 예약 정보가 성공적으로 업데이트되었습니다.']);
break;
default:
throw new Exception('유효하지 않은 요청입니다.');
}
} catch (Exception $e) {
echo json_encode([
'success' => false,
'message' => $e->getMessage()
]);
}
exit;
+285
View File
@@ -0,0 +1,285 @@
<?php
$sub_menu = "800850"; // 메뉴 코드 (admin.menu800.order_manage.php와 일치)
include_once('./_common.php');
auth_check($auth[$sub_menu], 'r');
$g5['title'] = '전문가 방문 스케줄 관리';
// --- 액션 처리 (저장, 삭제) ---
$action = isset($_REQUEST['action']) ? clean_xss_tags($_REQUEST['action']) : '';
$id = isset($_REQUEST['id']) ? (int)$_REQUEST['id'] : 0;
if ($action && $_SERVER['REQUEST_METHOD'] === 'POST') {
auth_check($auth[$sub_menu], 'w');
if ($action === 'save') {
$expert_id = $_POST['expert_id'] ?? '';
$rule_type = $_POST['rule_type'] ?? 'weekly';
$day_of_week = ($rule_type === 'weekly') ? (int)($_POST['day_of_week'] ?? 0) : 'NULL';
$specific_date = ($rule_type === 'specific') ? clean_xss_tags($_POST['specific_date']) : '';
$start_time = clean_xss_tags($_POST['start_time']);
$end_time = clean_xss_tags($_POST['end_time']);
$time_slot = (int)$_POST['time_slot'];
$max_persons = (int)$_POST['max_persons'];
$is_available = (int)$_POST['is_available'];
$temp_2 = clean_xss_tags($_POST['temp_2']); // 휴무사유
$temp_3 = clean_xss_tags($_POST['temp_3']); // 특별일정명
$sql_expert_id = $expert_id ? "'" . sql_real_escape_string($expert_id) . "'" : "NULL";
$sql_specific_date = $specific_date ? "'" . sql_real_escape_string($specific_date) . "'" : "NULL";
$sql_common = "
expert_id = {$sql_expert_id},
day_of_week = {$day_of_week},
specific_date = {$sql_specific_date},
start_time = '" . sql_real_escape_string($start_time) . "',
end_time = '" . sql_real_escape_string($end_time) . "',
time_slot = '{$time_slot}',
max_persons = '{$max_persons}',
is_available = '{$is_available}',
temp_2 = '" . sql_real_escape_string($temp_2) . "',
temp_3 = '" . sql_real_escape_string($temp_3) . "',
updated_at = NOW(),
updated_by = '{$member['mb_id']}'
";
if ($id > 0) { // 수정
$sql = "UPDATE expert_visit_schedules SET {$sql_common} WHERE id = '{$id}'";
} else { // 생성
$sql = "INSERT INTO expert_visit_schedules SET {$sql_common}, created_at = NOW(), created_by = '{$member['mb_id']}'";
}
sql_query($sql);
goto_url('./expert_visit_schedule.php');
} elseif ($action === 'delete') {
if ($id > 0) {
sql_query("UPDATE expert_visit_schedules SET is_deleted = 1, updated_at = NOW(), updated_by = '{$member['mb_id']}' WHERE id = '{$id}'");
}
goto_url('./expert_visit_schedule.php');
}
}
$schedule_to_edit = null;
if ($id > 0) {
$schedule_to_edit = sql_fetch("SELECT * FROM expert_visit_schedules WHERE id = '{$id}'");
}
// --- 데이터 조회 ---
$experts_result = sql_query("SELECT mb_id, mb_name FROM {$g5['member_table']} WHERE mb_level = 8 AND mb_leave_date = '' ORDER BY mb_name ASC");
$experts = [];
while($row = sql_fetch_array($experts_result)) {
$experts[] = $row;
}
$schedules_result = sql_query("SELECT * FROM expert_visit_schedules WHERE is_deleted = 0 ORDER BY specific_date DESC, day_of_week ASC, start_time ASC");
$schedules = [];
while($row = sql_fetch_array($schedules_result)) {
$schedules[] = $row;
}
$week_days = [1 => '월요일', 2 => '화요일', 3 => '수요일', 4 => '목요일', 5 => '금요일', 6 => '토요일', 7 => '일요일'];
include_once(G5_ADMIN_PATH . '/admin.head.php');
?>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<div class="local_desc01 local_desc">
<p>
전문가가 방문 가능한 시간을 설정합니다. '요일별' 규칙은 주간 반복 스케줄이며, '특정일' 규칙은 해당 날짜에만 적용되는 우선순위가 높은 스케줄입니다.<br>
'예약 가능'을 '아니오'로 설정하면 해당 시간을 휴무로 처리할 수 있습니다.
</p>
</div>
<!-- 스케줄 등록/수정 폼 -->
<form name="fschedule" method="post">
<input type="hidden" name="action" value="save">
<input type="hidden" name="id" value="<?php echo $schedule_to_edit['id'] ?? 0; ?>">
<div class="tbl_frm01 tbl_wrap">
<table>
<caption><?php echo $id ? '스케줄 수정' : '새 스케줄 등록'; ?></caption>
<colgroup>
<col class="grid_4">
<col>
</colgroup>
<tbody>
<tr>
<th scope="row"><label for="expert_id">전문가</label></th>
<td>
<select name="expert_id" id="expert_id">
<option value="">전체 전문가 공통</option>
<?php foreach ($experts as $c): ?>
<option value="<?php echo $c['mb_id']; ?>" <?php echo get_selected($schedule_to_edit['expert_id'] ?? '', $c['mb_id']); ?>><?php echo htmlspecialchars($c['mb_name']); ?></option>
<?php endforeach; ?>
</select>
</td>
</tr>
<tr>
<th scope="row">규칙 종류</th>
<td>
<label><input type="radio" name="rule_type" value="weekly" <?php echo (($schedule_to_edit['specific_date'] ?? '') ? '' : 'checked'); ?>> 요일별</label>
<label><input type="radio" name="rule_type" value="specific" <?php echo (($schedule_to_edit['specific_date'] ?? '') ? 'checked' : ''); ?>> 특정일</label>
</td>
</tr>
<tr id="row_day_of_week">
<th scope="row"><label for="day_of_week">요일</label></th>
<td>
<select name="day_of_week" id="day_of_week">
<?php foreach ($week_days as $num => $day): ?>
<option value="<?php echo $num; ?>" <?php echo get_selected($schedule_to_edit['day_of_week'] ?? '', $num); ?>><?php echo $day; ?></option>
<?php endforeach; ?>
</select>
</td>
</tr>
<tr id="row_specific_date" style="display:none;">
<th scope="row"><label for="specific_date">특정 날짜</label></th>
<td>
<input type="text" name="specific_date" id="specific_date" value="<?php echo $schedule_to_edit['specific_date'] ?? ''; ?>" class="frm_input" style="width:120px;">
</td>
</tr>
<tr>
<th scope="row"><label for="start_time">방문 시간</label></th>
<td>
<input type="time" name="start_time" id="start_time" value="<?php echo $schedule_to_edit['start_time'] ?? '09:00'; ?>" required> ~
<input type="time" name="end_time" id="end_time" value="<?php echo $schedule_to_edit['end_time'] ?? '18:00'; ?>" required>
</td>
</tr>
<tr>
<th scope="row"><label for="time_slot">예약 단위</label></th>
<td>
<input type="number" name="time_slot" id="time_slot" value="<?php echo $schedule_to_edit['time_slot'] ?? '60'; ?>" class="frm_input" style="width:80px;" required> 분
</td>
</tr>
<tr>
<th scope="row"><label for="max_persons">최대 인원</label></th>
<td>
<input type="number" name="max_persons" id="max_persons" value="<?php echo $schedule_to_edit['max_persons'] ?? '1'; ?>" class="frm_input" style="width:80px;" required> 명
</td>
</tr>
<tr>
<th scope="row"><label for="is_available">예약 가능</label></th>
<td>
<select name="is_available" id="is_available">
<option value="1" <?php echo get_selected($schedule_to_edit['is_available'] ?? '1', '1'); ?>>예</option>
<option value="0" <?php echo get_selected($schedule_to_edit['is_available'] ?? '1', '0'); ?>>아니오 (휴무)</option>
</select>
</td>
</tr>
<tr id="row_off_reason" style="display:none;">
<th scope="row"><label for="temp_2">휴무 사유</label></th>
<td>
<input type="text" name="temp_2" id="temp_2" value="<?php echo htmlspecialchars($schedule_to_edit['temp_2'] ?? ''); ?>" class="frm_input" style="width:300px;" placeholder="예: 정기 휴무">
</td>
</tr>
<tr>
<th scope="row"><label for="temp_3">일정명</label></th>
<td>
<input type="text" name="temp_3" id="temp_3" value="<?php echo htmlspecialchars($schedule_to_edit['temp_3'] ?? ''); ?>" class="frm_input" style="width:300px;" placeholder="예: 오전 근무, 단축 운영">
</td>
</tr>
</tbody>
</table>
</div>
<div class="btn_confirm01 btn_confirm">
<input type="submit" value="<?php echo $id ? '수정' : '등록'; ?>" class="btn_submit">
<?php if ($id): ?>
<a href="./expert_visit_schedule.php" class="btn_cancel">새로 등록</a>
<?php endif; ?>
</div>
</form>
<!-- 스케줄 목록 -->
<div class="tbl_head01 tbl_wrap" style="margin-top: 30px;">
<table>
<caption>스케줄 목록</caption>
<thead>
<tr>
<th scope="col">ID</th>
<th scope="col">전문가</th>
<th scope="col">규칙</th>
<th scope="col">시간</th>
<th scope="col">예약단위</th>
<th scope="col">최대인원</th>
<th scope="col">상태</th>
<th scope="col">관리</th>
</tr>
</thead>
<tbody>
<?php if (empty($schedules)): ?>
<tr><td colspan="8" class="empty_table">등록된 스케줄이 없습니다.</td></tr>
<?php else: ?>
<?php foreach ($schedules as $sch): ?>
<tr>
<td><?php echo $sch['id']; ?></td>
<td><?php echo $sch['expert_id'] ? (get_member($sch['expert_id'])['mb_name'] ?? $sch['expert_id']) : '전체 공통'; ?></td>
<td>
<?php
if ($sch['specific_date']) {
echo '<strong>[특정일]</strong> ' . $sch['specific_date'] . ($sch['temp_3'] ? ' ('.$sch['temp_3'].')' : '');
} else {
echo '<strong>[요일별]</strong> ' . ($week_days[$sch['day_of_week']] ?? '알수없음');
}
?>
</td>
<td><?php echo substr($sch['start_time'], 0, 5) . ' ~ ' . substr($sch['end_time'], 0, 5); ?></td>
<td><?php echo $sch['time_slot']; ?>분</td>
<td><?php echo $sch['max_persons']; ?>명</td>
<td>
<?php if ($sch['is_available']): ?>
<span style="color:blue;">예약가능</span>
<?php else: ?>
<span style="color:red;">휴무</span>
<?php if($sch['temp_2']) echo ' (' . htmlspecialchars($sch['temp_2']) . ')'; ?>
<?php endif; ?>
</td>
<td class="td_mng td_mng_s">
<a href="?id=<?php echo $sch['id']; ?>" class="btn btn_03">수정</a>
<form name="fdelete_<?php echo $sch['id']; ?>" method="post" style="display:inline;">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="<?php echo $sch['id']; ?>">
<button type="submit" class="btn btn_02" onclick="return confirm('정말로 이 스케줄을 삭제하시겠습니까?');">삭제</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// 날짜 선택기 초기화
flatpickr("#specific_date", { dateFormat: "Y-m-d" });
const ruleTypeRadios = document.querySelectorAll('input[name="rule_type"]');
const dayOfWeekRow = document.getElementById('row_day_of_week');
const specificDateRow = document.getElementById('row_specific_date');
const isAvailableSelect = document.getElementById('is_available');
const offReasonRow = document.getElementById('row_off_reason');
function toggleRuleTypeFields() {
const selectedType = document.querySelector('input[name="rule_type"]:checked').value;
dayOfWeekRow.style.display = (selectedType === 'weekly') ? '' : 'none';
specificDateRow.style.display = (selectedType === 'specific') ? '' : 'none';
}
function toggleOffReasonField() {
offReasonRow.style.display = (isAvailableSelect.value === '0') ? '' : 'none';
}
ruleTypeRadios.forEach(radio => radio.addEventListener('change', toggleRuleTypeFields));
isAvailableSelect.addEventListener('change', toggleOffReasonField);
// 페이지 로드 시 초기 상태 설정
toggleRuleTypeFields();
toggleOffReasonField();
});
</script>
<?php
include_once(G5_ADMIN_PATH . '/admin.tail.php');
?>
+337
View File
@@ -0,0 +1,337 @@
<?php
$sub_menu = "800400";
include_once('./_common.php');
auth_check($auth[$sub_menu], 'r');
$g5['title'] = '전문가 방문 관리';
// EstimateManager 클래스 로드
require_once G5_PATH . '/adm/order_manage/classes/EstimateManager.class.php';
$estimateManager = new EstimateManager();
// 액션 처리
$action = isset($_REQUEST['action']) ? clean_xss_tags($_REQUEST['action']) : '';
$wr_id = (int) ($_REQUEST['wr_id'] ?? 0);
// POST 요청 처리
if ($action && $_SERVER['REQUEST_METHOD'] === 'POST') {
auth_check($auth[$sub_menu], 'w');
try {
if ($action === 'confirm_payment') {
$result = $estimateManager->confirmExpertVisitPayment($wr_id);
} elseif ($action === 'schedule_visit') {
$visit_datetime = clean_xss_tags($_POST['visit_datetime'] ?? '');
$expert_id = clean_xss_tags($_POST['expert_id'] ?? '');
$result = $estimateManager->scheduleExpertVisit($wr_id, $visit_datetime, $expert_id);
} elseif ($action === 'complete_visit') {
$visit_notes = clean_xss_tags($_POST['visit_notes'] ?? '');
$result = $estimateManager->completeExpertVisit($wr_id, $visit_notes);
} else {
throw new Exception('알 수 없는 요청입니다.');
}
if ($result['success']) {
alert($result['message'], './expert_visits.php');
} else {
throw new Exception($result['message']);
}
} catch (Exception $e) {
alert('오류: ' . $e->getMessage());
}
goto_url('./expert_visits.php');
exit;
}
// 검색 조건
$sfl = isset($_GET['sfl']) ? clean_xss_tags($_GET['sfl']) : '';
$stx = isset($_GET['stx']) ? clean_xss_tags($_GET['stx']) : '';
$status_filter = isset($_GET['status_filter']) ? clean_xss_tags($_GET['status_filter']) : '';
$where = [];
if ($stx) {
if ($sfl === 'customer_name') {
$where[] = "cm.mb_name LIKE '%{$stx}%'";
} elseif ($sfl === 'expert_name') {
$where[] = "em.mb_name LIKE '%{$stx}%'";
}
}
if ($status_filter) {
$where[] = "e.temp_2 = '{$status_filter}'";
}
$where_clause = empty($where) ? "e.temp_1 = 'Y'" : "e.temp_1 = 'Y' AND " . implode(' AND ', $where);
// 페이징
$page = (int) ($_GET['page'] ?? 1);
$page_rows = 10;
$total_count_sql = "
SELECT COUNT(*) as cnt
FROM estimate e
LEFT JOIN g5_write_order w ON e.wr_id = w.wr_id
LEFT JOIN g5_member cm ON w.mb_id = cm.mb_id
LEFT JOIN g5_member em ON e.extra_4 = em.mb_id
WHERE {$where_clause}
";
$total_count_res = sql_fetch($total_count_sql);
$total_count = $total_count_res['cnt'];
$total_page = ceil($total_count / $page_rows);
$from_record = ($page - 1) * $page_rows;
// 목록 조회
$sql = "
SELECT
e.id as estimate_id, e.wr_id, e.temp_1, e.temp_2 as status, e.temp_3 as visit_fee,
e.temp_4 as visit_datetime, e.temp_5 as visit_notes, e.extra_4 as expert_id,
w.wr_subject, w.mb_id as customer_id,
cm.mb_name as customer_name, cm.mb_hp as customer_phone,
em.mb_name as expert_name
FROM estimate e
LEFT JOIN g5_write_order w ON e.wr_id = w.wr_id
LEFT JOIN g5_member cm ON w.mb_id = cm.mb_id
LEFT JOIN g5_member em ON e.extra_4 = em.mb_id
WHERE {$where_clause}
ORDER BY e.id DESC
LIMIT {$from_record}, {$page_rows}
";
$result = sql_query($sql);
$visits = [];
while ($row = sql_fetch_array($result)) {
$visits[] = $row;
}
// 전문가 목록 (레벨 8 이상)
$experts = [];
$expert_sql = "SELECT mb_id, mb_name FROM g5_member WHERE mb_level >= 9 ORDER BY mb_name";
$expert_result = sql_query($expert_sql);
while ($row = sql_fetch_array($expert_result)) {
$experts[] = $row;
}
include_once(G5_ADMIN_PATH . '/admin.head.php');
// var_dump($expert_sql);
// var_dump(sql_fetch_array($expert_result));
?>
<div class="local_desc01 local_desc">
<p>
고객이 요청한 전문가 방문을 관리합니다. <br> 결제 확인, 일정 조율, 방문 완료 처리를 할 수 있습니다.
</p>
</div>
<!-- 검색 폼 -->
<form name="fsearch" id="fsearch" class="local_sch01 local_sch" method="get">
<label for="sfl" class="sound_only">검색대상</label>
<select name="sfl" id="sfl">
<option value="customer_name" <?php echo get_selected($sfl, 'customer_name'); ?>>고객명</option>
<option value="expert_name" <?php echo get_selected($sfl, 'expert_name'); ?>>전문가명</option>
</select>
<label for="stx" class="sound_only">검색어<strong class="sound_only"> 필수</strong></label>
<input type="text" name="stx" value="<?php echo $stx ?>" id="stx" class="frm_input">
<label for="status_filter" class="sound_only">상태</label>
<select name="status_filter" id="status_filter">
<option value="">전체 상태</option>
<option value="requested" <?php echo get_selected($status_filter, 'requested'); ?>>결제 대기</option>
<option value="payment_confirmed" <?php echo get_selected($status_filter, 'payment_confirmed'); ?>>일정 조율</option>
<option value="scheduled" <?php echo get_selected($status_filter, 'scheduled'); ?>>방문 예정</option>
<option value="completed" <?php echo get_selected($status_filter, 'completed'); ?>>방문 완료</option>
<option value="cancelled" <?php echo get_selected($status_filter, 'cancelled'); ?>>취소</option>
</select>
<input type="submit" value="검색" class="btn_submit">
</form>
<div class="tbl_head01 tbl_wrap">
<table>
<caption><?php echo $g5['title']; ?> 목록</caption>
<thead>
<tr>
<th scope="col">견적 ID</th>
<th scope="col">고객명</th>
<th scope="col">연락처</th>
<th scope="col">방문 상태</th>
<th scope="col">방문 비용</th>
<th scope="col">방문 일정</th>
<th scope="col">담당 전문가</th>
<th scope="col">관리</th>
</tr>
</thead>
<tbody>
<?php if (empty($visits)): ?>
<tr>
<td colspan="8" class="empty_table">데이터가 없습니다.</td>
</tr>
<?php else: ?>
<?php foreach ($visits as $visit): ?>
<tr>
<td>
<a href="<?php echo G5_BBS_URL; ?>/board.php?bo_table=order&wr_id=<?php echo $visit['wr_id']; ?>"
target="_blank"><?php echo $visit['wr_id']; ?></a>
</td>
<td><?php echo $visit['customer_name']; ?></td>
<td><?php echo $visit['customer_phone']; ?></td>
<td>
<?php
switch ($visit['status']) {
case 'requested': echo '결제 대기'; break;
case 'payment_confirmed': echo '일정 조율'; break;
case 'scheduled': echo '방문 예정'; break;
case 'completed': echo '방문 완료'; break;
case 'cancelled': echo '취소'; break;
default: echo '알 수 없음';
}
?>
</td>
<td><?php echo number_format($visit['visit_fee']); ?>원</td>
<td><?php echo $visit['visit_datetime'] ? date('Y-m-d H:i', strtotime($visit['visit_datetime'])) : '-'; ?></td>
<td><?php echo $visit['expert_name'] ?: '미배정'; ?></td>
<td class="td_mng td_mng_l">
<?php if ($visit['status'] === 'requested'): ?>
<form name="frm_confirm_<?php echo $visit['wr_id']; ?>" method="post" style="display:inline;">
<input type="hidden" name="action" value="confirm_payment">
<input type="hidden" name="wr_id" value="<?php echo $visit['wr_id']; ?>">
<button type="submit" class="btn btn_03"
onclick="return confirm('결제를 확인 처리하시겠습니까?');">결제확인</button>
</form>
<?php elseif ($visit['status'] === 'payment_confirmed'): ?>
<button type="button" class="btn btn_02"
onclick="openScheduleModal(<?php echo $visit['wr_id']; ?>)">일정조율</button>
<?php elseif ($visit['status'] === 'scheduled'): ?>
<form name="frm_complete_<?php echo $visit['wr_id']; ?>" method="post" style="display:inline;">
<input type="hidden" name="action" value="complete_visit">
<input type="hidden" name="wr_id" value="<?php echo $visit['wr_id']; ?>">
<input type="hidden" name="visit_notes" value="방문 완료됨">
<button type="submit" class="btn btn_01"
onclick="return confirm('방문을 완료 처리하시겠습니까?');">방문완료</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<?php echo get_paging(G5_IS_MOBILE ? $config['cf_mobile_pages'] : $config['cf_write_pages'], $page, $total_page, "{$_SERVER['SCRIPT_NAME']}?$qstr&amp;page="); ?>
<!-- 일정 조율 모달 -->
<div id="scheduleModal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3>방문 일정 조율</h3>
<span class="close" onclick="closeScheduleModal()">&times;</span>
</div>
<div class="modal-body">
<form name="frm_schedule" id="frm_schedule" method="post">
<input type="hidden" name="action" value="schedule_visit">
<input type="hidden" name="wr_id" id="modal_wr_id">
<div class="tbl_frm01 tbl_wrap">
<table>
<tr>
<th scope="row"><label for="modal_visit_datetime">방문 일시</label></th>
<td>
<input type="datetime-local" name="visit_datetime" id="modal_visit_datetime"
class="frm_input" required>
</td>
</tr>
<tr>
<th scope="row"><label for="modal_expert_id">담당 전문가</label></th>
<td>
<select name="expert_id" id="modal_expert_id" class="frm_input">
<option value="">선택하세요</option>
<?php foreach ($experts as $expert): ?>
<option value="<?php echo $expert['mb_id']; ?>">
<?php echo $expert['mb_name']; ?> (<?php echo $expert['mb_id']; ?>)
</option>
<?php endforeach; ?>
</select>
</td>
</tr>
</table>
</div>
<div class="btn_confirm01 btn_confirm">
<button type="submit" class="btn_submit">일정 저장</button>
</div>
</form>
</div>
</div>
</div>
<style>
.modal {
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
}
.modal-content {
background-color: #fefefe;
margin: 10% auto;
padding: 0;
border: 1px solid #888;
width: 80%;
max-width: 600px;
border-radius: 5px;
}
.modal-header {
padding: 15px 20px;
background-color: #f8f9fa;
border-bottom: 1px solid #dee2e6;
border-radius: 5px 5px 0 0;
}
.modal-header h3 {
margin: 0;
display: inline-block;
}
.close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
.close:hover {
color: black;
}
.modal-body {
padding: 20px;
}
</style>
<script>
function openScheduleModal(wr_id) {
document.getElementById('modal_wr_id').value = wr_id;
document.getElementById('scheduleModal').style.display = 'block';
}
function closeScheduleModal() {
document.getElementById('scheduleModal').style.display = 'none';
}
window.onclick = function (event) {
const modal = document.getElementById('scheduleModal');
if (event.target === modal) {
closeScheduleModal();
}
}
</script>
<?php
include_once(G5_ADMIN_PATH . '/admin.tail.php');
?>
+114
View File
@@ -0,0 +1,114 @@
<?php
include_once('./_common.php');
// 관리자 권한 확인
if (!$is_admin) {
die(json_encode(['success' => false, 'message' => '관리자 권한이 필요합니다.']));
}
// EstimateManager 로드
require_once G5_PATH . '/adm/order_manage/classes/EstimateManager.class.php';
$estimate_manager = new EstimateManager();
$action = isset($_REQUEST['action']) ? clean_xss_tags($_REQUEST['action']) : '';
header('Content-Type: application/json; charset=utf-8');
try {
switch ($action) {
case 'confirm_payment':
$wr_id = (int) ($_POST['wr_id'] ?? 0);
if (!$wr_id) {
throw new Exception('견적 ID가 필요합니다.');
}
$result = $estimate_manager->confirmExpertVisitPayment($wr_id, 'admin');
if (!$result['success']) {
throw new Exception($result['message']);
}
echo json_encode([
'success' => true,
'message' => '전문가 방문 비용 결제가 확인되었습니다.',
'data' => $result['data']
]);
break;
case 'schedule_visit':
$wr_id = (int) ($_POST['wr_id'] ?? 0);
$visit_datetime = clean_xss_tags($_POST['visit_datetime'] ?? '');
$expert_id = clean_xss_tags($_POST['expert_id'] ?? '');
if (!$wr_id || !$visit_datetime) {
throw new Exception('필수 정보가 누락되었습니다.');
}
// 날짜 형식 변환 (datetime-local -> Y-m-d H:i)
$visit_datetime = date('Y-m-d H:i', strtotime($visit_datetime));
$result = $estimate_manager->scheduleExpertVisit($wr_id, $visit_datetime, $expert_id, 'admin');
if (!$result['success']) {
throw new Exception($result['message']);
}
echo json_encode([
'success' => true,
'message' => '전문가 방문 일정이 설정되었습니다.',
'data' => $result['data']
]);
break;
case 'complete_visit':
$wr_id = (int) ($_POST['wr_id'] ?? 0);
$visit_notes = clean_xss_tags($_POST['visit_notes'] ?? '');
if (!$wr_id) {
throw new Exception('견적 ID가 필요합니다.');
}
$result = $estimate_manager->completeExpertVisit($wr_id, $visit_notes, 'admin');
if (!$result['success']) {
throw new Exception($result['message']);
}
echo json_encode([
'success' => true,
'message' => '전문가 방문이 완료 처리되었습니다.',
'data' => $result['data']
]);
break;
case 'get_visit_info':
$wr_id = (int) ($_GET['wr_id'] ?? 0);
if (!$wr_id) {
throw new Exception('견적 ID가 필요합니다.');
}
$visit_info = $estimate_manager->getExpertVisitInfo($wr_id);
if (!$visit_info) {
throw new Exception('전문가 방문 정보를 찾을 수 없습니다.');
}
echo json_encode([
'success' => true,
'data' => $visit_info
]);
break;
default:
throw new Exception('유효하지 않은 액션입니다.');
}
} catch (Exception $e) {
error_log("expert_visits_ajax.php Error: " . $e->getMessage());
echo json_encode([
'success' => false,
'message' => $e->getMessage()
]);
}
?>
+167
View File
@@ -0,0 +1,167 @@
<?php
$sub_menu = '800900';
include_once('./_common.php');
include_once(__DIR__ . '/lib/SchemaManager.class.php');
if (!$is_admin) {
alert('관리자만 접근할 수 있습니다.');
}
/**
* SQL 파일에서 테이블 이름을 추출하는 함수
*/
function get_tables_from_sql_file($filepath) {
$tables = [];
if (!file_exists($filepath)) return $tables;
$lines = file($filepath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (preg_match('/CREATE TABLE(?: IF NOT EXISTS)? `([^`]+)`/i', $line, $matches)) {
$tables[] = $matches[1];
}
}
return $tables;
}
$g5['title'] = '견적 관리 시스템 설치';
include_once(G5_ADMIN_PATH . '/admin.head.php');
$install_result = null;
$delete_result = null;
$action = $_POST['action'] ?? '';
$sql_file_path = __DIR__ . '/install.sql';
$tables_to_check = get_tables_from_sql_file($sql_file_path);
if ($action === 'install') {
check_admin_token();
try {
$schemaManager = new SchemaManager($sql_file_path);
$schemaManager->execute();
$db_results = $schemaManager->get_results();
$menu_msg = create_admin_menu_file();
$install_result = ['db' => $db_results, 'menu' => $menu_msg];
} catch (Exception $e) {
$install_result['errors'][] = '설치 중 심각한 오류 발생: ' . $e->getMessage();
}
} else if ($action === 'delete') {
check_admin_token();
$delete_result = ['tables' => [], 'menu' => ''];
$tables_to_delete = $tables_to_check;
foreach ($tables_to_delete as $table) {
sql_query("DROP TABLE IF EXISTS `{$table}`", false);
$delete_result['tables'][] = $table;
}
$menu_file = G5_ADMIN_PATH . '/admin.menu800.order_manage.php';
if (file_exists($menu_file)) {
if (@unlink($menu_file)) {
$delete_result['menu'] = '메뉴 파일 삭제 성공';
} else {
$delete_result['menu'] = '메뉴 파일 삭제 실패 (권한 확인 필요)';
}
}
}
function create_admin_menu_file() {
$source_file = __DIR__ . '/admin.menu800.order_manage.php';
$target_file = G5_ADMIN_PATH . '/admin.menu800.order_manage.php';
if (!file_exists($source_file)) return "실패 (메뉴 원본 파일 없음)";
if (file_exists($target_file)) return "성공 (이미 존재함)";
if (@copy($source_file, $target_file)) return "성공";
return "실패 (파일 복사 오류)";
}
$existing_tables = [];
foreach ($tables_to_check as $table) {
if (sql_query("SHOW TABLES LIKE '$table'", false) && sql_num_rows(sql_query("SHOW TABLES LIKE '$table'", false)) > 0) {
$existing_tables[] = $table;
}
}
$is_installed = !empty($existing_tables); // 하나라도 테이블이 있으면 설치된 것으로 간주
?>
<style>
.install-container { max-width: 800px; margin: 20px auto; padding: 20px; background: #fff; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
.install-header { text-align: center; margin-bottom: 30px; padding-bottom: 20px; border-bottom: 2px solid #4A90E2; }
.install-header h1 { color: #4A90E2; margin-bottom: 10px; }
.status-table { width: 100%; border-collapse: collapse; margin: 20px 0; }
.status-table th, .status-table td { padding: 12px; text-align: left; border-bottom: 1px solid #ddd; }
.status-table th { background-color: #f8f9fa; font-weight: bold; }
.status-ok { color: #28a745; font-weight: bold; }
.status-missing { color: #dc3545; font-weight: bold; }
.install-btn { display: block; width: 200px; margin: 30px auto; padding: 15px 30px; background: #4A90E2; color: white; text-align: center; text-decoration: none; border-radius: 5px; font-size: 16px; font-weight: bold; border: none; cursor: pointer; transition: background-color 0.3s; }
.install-btn:hover { background: #357ABD; color: white; }
.alert { padding: 15px; margin: 20px 0; border-radius: 5px; }
.alert-success { background-color: #d4edda; border: 1px solid #c3e6cb; color: #155724; }
.alert-info { background-color: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460; }
.alert-danger { background-color: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; }
.btn-secondary { background: #6c757d; color: white; border-color: #6c757d; padding: 5px 10px; border-radius: 4px; text-decoration: none; }
.btn-secondary:hover { background: #5a6268; }
.btn-danger { background: #dc3545; color: white; border: none; padding: 10px 15px; border-radius: 4px; cursor: pointer; }
.btn-danger:hover { background: #c82333; }
.button-group { display: flex; justify-content: center; align-items: center; gap: 10px; }
</style>
<div class="install-container">
<div class="install-header">
<h1><i class="fa fa-file-text-o"></i> 견적 관리 시스템</h1>
<p>견적, 주문, 전문가 방문, 시공까지 한번에 관리하는 통합 솔루션</p>
</div>
<?php if ($install_result): ?>
<div class="alert alert-success"><h4><i class="fa fa-check-circle"></i> 설치 작업 완료</h4>
<p>데이터베이스 및 기본 설정 설치가 완료되었습니다.</p>
<p><?php echo '메뉴 파일 : '.$install_result['menu']; ?></p>
<p><a href="./config_manager.php" class="btn btn-primary">견적 관리 설정으로 이동</a></p></div>
<?php elseif ($delete_result): ?>
<div class="alert alert-danger"><h4><i class="fa fa-trash"></i> 삭제 작업 완료</h4><p>솔루션 관련 데이터와 파일이 삭제되었습니다.</p><ul><?php foreach($delete_result['tables'] as $tbl) echo "<li>{$tbl} 테이블 삭제됨</li>"; ?><li><?php echo $delete_result['menu']; ?></li></ul></div>
<?php elseif ($is_installed): ?>
<div class="alert alert-success"><h4><i class="fa fa-check-circle"></i> 설치 완료</h4><p>견적 관리 시스템이 이미 설치되어 있습니다.</p><p><a href="./config_manager.php" class="btn btn-primary">견적 관리 설정으로 이동</a></p></div>
<?php else: ?>
<div class="alert alert-info"><h4><i class="fa fa-info-circle"></i> 설치 필요</h4><p>견적 관리 시스템을 사용하기 위해 설치가 필요합니다.</p></div>
<?php endif; ?>
<h3><i class="fa fa-database"></i> 설치 상태</h3>
<table class="status-table">
<thead><tr><th>테이블명</th><th>상태</th></tr></thead>
<tbody>
<?php foreach ($tables_to_check as $table): ?>
<tr>
<td><code><?php echo $table; ?></code></td>
<td>
<?php if (in_array($table, $existing_tables)): ?>
<span class="status-ok"><i class="fa fa-check"></i> 설치됨</span>
<?php else: ?>
<span class="status-missing"><i class="fa fa-times"></i> 미설치</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php if (!$is_installed): ?>
<form method="post" onsubmit="return confirm('솔루션을 설치하시겠습니까?');">
<input type="hidden" name="action" value="install">
<input type="hidden" name="token" value="<?php echo get_token(); ?>">
<button type="submit" class="install-btn"><i class="fa fa-download"></i> 솔루션 설치하기</button>
</form>
<?php endif; ?>
<?php if ($is_installed && !$install_result && !$delete_result): ?>
<div class="button-group" style="text-align: center; margin-top: 20px;">
<form method="post" onsubmit="return confirm('기존 데이터는 유지되며, 변경된 DB 구조만 업데이트 됩니다. 진행하시겠습니까?');">
<input type="hidden" name="action" value="install">
<input type="hidden" name="token" value="<?php echo get_token(); ?>">
<button type="submit" class="btn btn-secondary"><i class="fa fa-sync"></i> 재설치 (업데이트)</button>
</form>
<form method="post" onsubmit="return confirm('정말로 솔루션을 삭제하시겠습니까? 모든 관련 데이터와 파일이 영구적으로 삭제됩니다.');">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="token" value="<?php echo get_token(); ?>">
<button type="submit" class="btn-danger"><i class="fa fa-trash"></i> 솔루션 삭제하기</button>
</form>
</div>
<?php endif; ?>
</div>
<?php
include_once(G5_ADMIN_PATH . '/admin.tail.php');
?>
+547
View File
@@ -0,0 +1,547 @@
-- 1. 견적서 마스터 테이블 (기존 유지)
-- [최종안] 상담가, 수수료, 공통 관리 필드, 여분 필드 모두 포함
CREATE TABLE IF NOT EXISTS `estimate`
(
`id` int NOT NULL AUTO_INCREMENT,
`wr_id` int DEFAULT NULL COMMENT '원본 게시물 ID',
`status` VARCHAR(20) NOT NULL DEFAULT 'requesting' COMMENT '견적 상태',
`bidding_deadline` DATETIME DEFAULT NULL COMMENT '입찰 마감일시',
`expert_id` VARCHAR(20) DEFAULT NULL COMMENT '배정된 전문가 ID',
`expert_visit_status` VARCHAR(20) DEFAULT NULL COMMENT '전문가 방문 상태',
`commission_fee` decimal(10, 2) DEFAULT '0.00' COMMENT '시스템 이용료(수수료)',
`company_name` varchar(100) DEFAULT NULL COMMENT '업체명',
`site_name` varchar(100) DEFAULT NULL COMMENT '사이트명',
`estimate_date` varchar(30) DEFAULT NULL COMMENT '견적일',
`house_type` varchar(50) DEFAULT NULL COMMENT '집의 유형',
`house_size` varchar(50) DEFAULT NULL COMMENT '평형',
`material` varchar(50) DEFAULT NULL COMMENT '창 재질',
`color` varchar(20) DEFAULT NULL COMMENT '창호 색상',
`glass_thickness` varchar(20) DEFAULT NULL COMMENT '유리두께',
`install` varchar(5) DEFAULT NULL COMMENT '시공여부',
`zip_code` varchar(10) DEFAULT NULL COMMENT '우편번호',
`address1` varchar(255) DEFAULT NULL COMMENT '기본주소',
`address2` varchar(255) DEFAULT NULL COMMENT '상세주소',
`address3` varchar(255) DEFAULT NULL COMMENT '참고항목',
`temp_1` varchar(255) DEFAULT NULL COMMENT '전문가방문요청여부(Y/N)',
`temp_2` varchar(255) DEFAULT NULL COMMENT '전문가방문상태(requested/scheduled/completed/cancelled)',
`temp_3` varchar(255) DEFAULT NULL COMMENT '전문가방문비용',
`temp_4` varchar(255) DEFAULT NULL COMMENT '전문가방문일정(YYYY-MM-DD HH:MM)',
`temp_5` varchar(255) DEFAULT NULL COMMENT '전문가방문메모',
`extra_1` varchar(255) DEFAULT NULL COMMENT '고객연락처(전문가방문용)',
`extra_2` varchar(255) DEFAULT NULL COMMENT '결제상태(pending/paid/cancelled)',
`extra_3` varchar(255) DEFAULT NULL COMMENT '추가요청사항',
`extra_4` varchar(255) DEFAULT NULL COMMENT '관리자메모',
`extra_5` varchar(255) DEFAULT NULL COMMENT '예비필드',
`is_used` tinyint(1) NOT NULL DEFAULT '1' COMMENT '사용 여부',
`is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '삭제 여부',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시',
`created_by` varchar(20) DEFAULT NULL COMMENT '생성자',
`updated_at` datetime DEFAULT NULL COMMENT '수정일시',
`updated_by` varchar(20) DEFAULT NULL COMMENT '수정자',
PRIMARY KEY (`id`),
KEY `wr_id` (`wr_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4 COMMENT ='견적서 마스터 정보';
-- 2. 견적서 상세 항목 테이블 (브랜드 필드 강화)
-- [최종안] order.php의 템플릿 필드와 일치하도록 컬럼 및 코멘트 수정
CREATE TABLE IF NOT EXISTS `estimate_item`
(
`id` int NOT NULL AUTO_INCREMENT,
`estimate_id` int DEFAULT NULL COMMENT '견적서 ID (estimate.id)',
`dealer_id` VARCHAR(255) NULL DEFAULT NULL COMMENT '[대리점] 제안 회원 ID',
`no` int DEFAULT NULL COMMENT '항목 번호',
`location` varchar(50) DEFAULT NULL COMMENT '창 종류 (거실, 안방 등)',
`spec_width` varchar(20) DEFAULT NULL COMMENT '기존창 규격 (가로)',
`spec_height` varchar(20) DEFAULT NULL COMMENT '기존창 규격 (세로)',
`window_main_type` VARCHAR(50) DEFAULT NULL COMMENT '창호 형태 (1차: 일반창/프로젝트창)',
`windowType` varchar(20) DEFAULT NULL COMMENT '창호 형태 (2차: 단창/이중창)',
`windowRatio` varchar(20) DEFAULT NULL COMMENT '창 비율 (2W, 4W 등)',
`glass_color` varchar(50) DEFAULT NULL COMMENT '유리사양 (색상)',
`handle` varchar(20) DEFAULT NULL COMMENT '시정장치 (핸들)',
`replacePart` varchar(20) DEFAULT NULL COMMENT '교체 위치 (외부/내부)',
`extra_1` varchar(255) DEFAULT NULL COMMENT '비고 (기타 특이사항)',
-- ----------------------------------------------------
-- 아래는 대리점이 입력하는 필드입니다.
-- ----------------------------------------------------
`product` varchar(50) DEFAULT NULL COMMENT '[대리점] 품명',
`qty` int DEFAULT NULL COMMENT '[대리점] 수량',
`price` int DEFAULT NULL COMMENT '[대리점] 단가',
`amount` int DEFAULT NULL COMMENT '[대리점] 금액',
`brand` varchar(30) DEFAULT NULL COMMENT '[대리점] 창호브랜드명',
-- ----------------------------------------------------
-- 시스템 관리용 필드
-- ----------------------------------------------------
`is_used` tinyint(1) NOT NULL DEFAULT '1' COMMENT '사용 여부 (1:사용, 0:미사용)',
`is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '삭제 여부 (1:삭제, 0:정상)',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시',
`created_by` varchar(20) DEFAULT NULL COMMENT '생성자',
`updated_at` datetime DEFAULT NULL COMMENT '수정일시',
`updated_by` varchar(20) DEFAULT NULL COMMENT '수정자',
-- ----------------------------------------------------
-- 사용하지 않지만 데이터 보존을 위해 남겨둔 필드
-- ----------------------------------------------------
`color` varchar(20) DEFAULT NULL COMMENT '[구] 색상',
`glass_thickness` varchar(20) DEFAULT NULL COMMENT '[구] 유리두께',
`screen` varchar(10) DEFAULT NULL COMMENT '[구] 방충망',
`door_dir` varchar(20) DEFAULT NULL COMMENT '[구] 문방향',
`install` varchar(5) DEFAULT NULL COMMENT '[구] 시공여부',
`temp_1` varchar(255) DEFAULT NULL COMMENT '임시필드1',
`temp_2` varchar(255) DEFAULT NULL COMMENT '임시필드2',
`temp_3` varchar(255) DEFAULT NULL COMMENT '임시필드3',
`temp_4` varchar(255) DEFAULT NULL COMMENT '임시필드4',
`temp_5` varchar(255) DEFAULT NULL COMMENT '임시필드5',
`extra_2` varchar(255) DEFAULT NULL COMMENT '추가필드2',
`extra_3` varchar(255) DEFAULT NULL COMMENT '추가필드3',
`extra_4` varchar(255) DEFAULT NULL COMMENT '추가필드4',
`extra_5` varchar(255) DEFAULT NULL COMMENT '추가필드5',
PRIMARY KEY (`id`),
KEY `estimate_id` (`estimate_id`),
CONSTRAINT `fk_item_to_estimate` FOREIGN KEY (`estimate_id`) REFERENCES `estimate` (`id`) ON DELETE CASCADE
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4 COMMENT ='견적서 상세 항목';
-- 3. 견적 및 항목 변경 이력 테이블 (기존 유지)
-- [install_backup.sql 내용 + 공통 관리 필드, 여분 필드 추가]
CREATE TABLE IF NOT EXISTS `estimate_history`
(
`id` int NOT NULL AUTO_INCREMENT COMMENT '이력 고유 ID',
`estimate_id` int NOT NULL COMMENT '견적서 ID',
`item_id` int DEFAULT NULL COMMENT '견적 항목 ID (항목 변경 시)',
`action` varchar(20) NOT NULL COMMENT '작업 종류 (create, update, delete, status_change)',
`change_details` longtext COMMENT '변경된 데이터 (JSON 형식)',
`changed_by` varchar(20) NOT NULL COMMENT '변경자 ID',
`changed_at` datetime NOT NULL COMMENT '변경일시',
`temp_1` varchar(255) DEFAULT NULL COMMENT '임시필드1',
`temp_2` varchar(255) DEFAULT NULL COMMENT '임시필드2',
`temp_3` varchar(255) DEFAULT NULL COMMENT '임시필드3',
`temp_4` varchar(255) DEFAULT NULL COMMENT '임시필드4',
`temp_5` varchar(255) DEFAULT NULL COMMENT '임시필드5',
`extra_1` varchar(255) DEFAULT NULL COMMENT '추가필드1',
`extra_2` varchar(255) DEFAULT NULL COMMENT '추가필드2',
`extra_3` varchar(255) DEFAULT NULL COMMENT '추가필드3',
`extra_4` varchar(255) DEFAULT NULL COMMENT '추가필드4',
`extra_5` varchar(255) DEFAULT NULL COMMENT '추가필드5',
`is_used` tinyint(1) NOT NULL DEFAULT '1' COMMENT '사용 여부',
`is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '삭제 여부',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시',
`created_by` varchar(20) DEFAULT NULL COMMENT '생성자',
`updated_at` datetime DEFAULT NULL COMMENT '수정일시',
`updated_by` varchar(20) DEFAULT NULL COMMENT '수정자',
PRIMARY KEY (`id`),
KEY `estimate_id` (`estimate_id`),
KEY `item_id` (`item_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4 COMMENT ='견적 및 항목 변경 이력';
-- 4. 대리점 입찰 정보 테이블 (기존 유지)
-- [wr_id 컬럼 추가 + 공통 관리 필드, 여분 필드 추가]
CREATE TABLE IF NOT EXISTS `estimate_bidding`
(
`id` int NOT NULL AUTO_INCREMENT,
`estimate_id` int NOT NULL COMMENT '견적서 ID',
`wr_id` int DEFAULT NULL COMMENT '게시판 답변글 ID',
`dealer_id` varchar(20) NOT NULL COMMENT '입찰한 대리점 회원 ID',
`bid_amount` int NOT NULL COMMENT '대리점 제시 금액',
`bid_message` text COMMENT '대리점 메모',
`status` varchar(20) NOT NULL DEFAULT 'bidding' COMMENT '입찰 상태 (bidding, selected, unselected)',
`temp_1` varchar(255) DEFAULT NULL COMMENT '임시필드1',
`temp_2` varchar(255) DEFAULT NULL COMMENT '임시필드2',
`temp_3` varchar(255) DEFAULT NULL COMMENT '임시필드3',
`temp_4` varchar(255) DEFAULT NULL COMMENT '임시필드4',
`temp_5` varchar(255) DEFAULT NULL COMMENT '임시필드5',
`extra_1` varchar(255) DEFAULT NULL COMMENT '추가필드1',
`extra_2` varchar(255) DEFAULT NULL COMMENT '추가필드2',
`extra_3` varchar(255) DEFAULT NULL COMMENT '추가필드3',
`extra_4` varchar(255) DEFAULT NULL COMMENT '추가필드4',
`extra_5` varchar(255) DEFAULT NULL COMMENT '추가필드5',
`is_used` tinyint(1) NOT NULL DEFAULT '1' COMMENT '사용 여부',
`is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '삭제 여부',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시',
`created_by` varchar(20) DEFAULT NULL COMMENT '생성자',
`updated_at` datetime DEFAULT NULL COMMENT '수정일시',
`updated_by` varchar(20) DEFAULT NULL COMMENT '수정자',
PRIMARY KEY (`id`),
KEY `estimate_id` (`estimate_id`),
KEY `dealer_id` (`dealer_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4 COMMENT ='대리점 입찰 정보';
-- 5. ⭐ '전문가 방문 요청'으로 변경: 전문가 방문 예약 테이블
CREATE TABLE IF NOT EXISTS `expert_visit_reservations`
(
`id` int NOT NULL AUTO_INCREMENT COMMENT '예약 고유번호',
`wr_id` int DEFAULT NULL COMMENT '연관 게시물 ID',
`customer_id` varchar(20) DEFAULT NULL COMMENT '고객 회원 ID',
`customer_name` varchar(50) DEFAULT NULL COMMENT '고객명',
`customer_phone` varchar(20) DEFAULT NULL COMMENT '고객 연락처',
`customer_email` varchar(100) DEFAULT NULL COMMENT '고객 이메일',
`visit_date` DATE DEFAULT NULL COMMENT '방문 날짜',
`visit_time` TIME DEFAULT NULL COMMENT '방문 시간',
`expert_id` varchar(20) DEFAULT NULL COMMENT '배정된 전문가 ID',
`status` varchar(20) NOT NULL DEFAULT 'payment_pending' COMMENT '예약 상태 (payment_pending/reserved/completed/cancelled)',
`payment_amount` int DEFAULT '0' COMMENT '방문 비용',
`payment_status` varchar(20) DEFAULT 'pending' COMMENT '결제 상태 (pending/paid/cancelled)',
`request_memo` text DEFAULT NULL COMMENT '고객 요청사항',
`admin_memo` text DEFAULT NULL COMMENT '관리자 메모',
`temp_1` varchar(255) DEFAULT NULL COMMENT '방문시간구간(예:09:00-11:00)',
`temp_2` varchar(255) DEFAULT NULL COMMENT '방문종류(현장방문/전화상담)',
`temp_3` varchar(255) DEFAULT NULL COMMENT '방문지역',
`temp_4` varchar(255) DEFAULT NULL COMMENT '방문예상시간(분)',
`temp_5` varchar(255) DEFAULT NULL COMMENT '추가정보',
`extra_1` varchar(255) DEFAULT NULL COMMENT '추가필드1',
`extra_2` varchar(255) DEFAULT NULL COMMENT '추가필드2',
`extra_3` varchar(255) DEFAULT NULL COMMENT '추가필드3',
`extra_4` varchar(255) DEFAULT NULL COMMENT '추가필드4',
`extra_5` varchar(255) DEFAULT NULL COMMENT '추가필드5',
`is_used` tinyint(1) NOT NULL DEFAULT '1' COMMENT '사용 여부',
`is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '삭제 여부',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시',
`created_by` varchar(20) DEFAULT NULL COMMENT '생성자',
`updated_at` datetime DEFAULT NULL COMMENT '수정일시',
`updated_by` varchar(20) DEFAULT NULL COMMENT '수정자',
PRIMARY KEY (`id`),
KEY `customer_id` (`customer_id`),
KEY `visit_date` (`visit_date`),
KEY `visit_time` (`visit_time`),
KEY `expert_id` (`expert_id`),
KEY `status` (`status`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4 COMMENT ='전문가 방문 예약 정보';
-- 6. ⭐ '전문가 방문 요청'으로 변경: 전문가 방문 가능 시간 설정 테이블
CREATE TABLE IF NOT EXISTS `expert_visit_schedules`
(
`id` int NOT NULL AUTO_INCREMENT COMMENT '일정 고유번호',
`expert_id` varchar(20) DEFAULT NULL COMMENT '전문가 ID (NULL시 전체 설정)',
`day_of_week` int DEFAULT NULL COMMENT '요일 (1:월요일~7:일요일, NULL시 특정날짜)',
`specific_date` DATE DEFAULT NULL COMMENT '특정 날짜 (요일 설정과 배타적)',
`start_time` TIME NOT NULL COMMENT '방문 시작 시간',
`end_time` TIME NOT NULL COMMENT '방문 종료 시간',
`time_slot` int NOT NULL DEFAULT '60' COMMENT '예약 단위 시간(분)',
`max_persons` int NOT NULL DEFAULT '1' COMMENT '동시간대 최대 예약 가능 인원',
`is_available` tinyint(1) NOT NULL DEFAULT '1' COMMENT '예약 가능 여부',
`temp_1` varchar(255) DEFAULT NULL COMMENT '방문종류제한',
`temp_2` varchar(255) DEFAULT NULL COMMENT '휴무사유',
`temp_3` varchar(255) DEFAULT NULL COMMENT '특별일정명',
`temp_4` varchar(255) DEFAULT NULL COMMENT '임시필드4',
`temp_5` varchar(255) DEFAULT NULL COMMENT '임시필드5',
`extra_1` varchar(255) DEFAULT NULL COMMENT '추가필드1',
`extra_2` varchar(255) DEFAULT NULL COMMENT '추가필드2',
`extra_3` varchar(255) DEFAULT NULL COMMENT '추가필드3',
`extra_4` varchar(255) DEFAULT NULL COMMENT '추가필드4',
`extra_5` varchar(255) DEFAULT NULL COMMENT '추가필드5',
`is_used` tinyint(1) NOT NULL DEFAULT '1' COMMENT '사용 여부',
`is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '삭제 여부',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시',
`created_by` varchar(20) DEFAULT NULL COMMENT '생성자',
`updated_at` datetime DEFAULT NULL COMMENT '수정일시',
`updated_by` varchar(20) DEFAULT NULL COMMENT '수정자',
PRIMARY KEY (`id`),
KEY `expert_id` (`expert_id`),
KEY `day_of_week` (`day_of_week`),
KEY `specific_date` (`specific_date`),
UNIQUE KEY `unique_schedule` (`expert_id`, `day_of_week`, `specific_date`, `start_time`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4 COMMENT ='전문가 방문 가능 시간 설정';
-- 7. ⭐ 새로 추가: 창호 브랜드 마스터 테이블
CREATE TABLE IF NOT EXISTS `order_window_brands`
(
`id` int NOT NULL AUTO_INCREMENT COMMENT '브랜드 고유번호',
`brand_name` varchar(50) NOT NULL COMMENT '브랜드명',
`brand_code` varchar(20) DEFAULT NULL COMMENT '브랜드 코드',
`manufacturer` varchar(100) DEFAULT NULL COMMENT '제조사',
`description` text DEFAULT NULL COMMENT '브랜드 설명',
`logo_url` varchar(255) DEFAULT NULL COMMENT '로고 이미지 URL',
`website_url` varchar(255) DEFAULT NULL COMMENT '홈페이지 URL',
`sort_order` int DEFAULT '0' COMMENT '정렬 순서',
`temp_1` varchar(255) DEFAULT NULL COMMENT '브랜드등급(프리미엄/일반/보급)',
`temp_2` varchar(255) DEFAULT NULL COMMENT '주력제품군',
`temp_3` varchar(255) DEFAULT NULL COMMENT '가격대',
`temp_4` varchar(255) DEFAULT NULL COMMENT 'A/S기간',
`temp_5` varchar(255) DEFAULT NULL COMMENT '보증기간',
`extra_1` varchar(255) DEFAULT NULL COMMENT '추가필드1',
`extra_2` varchar(255) DEFAULT NULL COMMENT '추가필드2',
`extra_3` varchar(255) DEFAULT NULL COMMENT '추가필드3',
`extra_4` varchar(255) DEFAULT NULL COMMENT '추가필드4',
`extra_5` varchar(255) DEFAULT NULL COMMENT '추가필드5',
`is_used` tinyint(1) NOT NULL DEFAULT '1' COMMENT '사용 여부',
`is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '삭제 여부',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시',
`created_by` varchar(20) DEFAULT NULL COMMENT '생성자',
`updated_at` datetime DEFAULT NULL COMMENT '수정일시',
`updated_by` varchar(20) DEFAULT NULL COMMENT '수정자',
PRIMARY KEY (`id`),
UNIQUE KEY `brand_name` (`brand_name`),
KEY `brand_code` (`brand_code`),
KEY `sort_order` (`sort_order`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4 COMMENT ='창호 브랜드 마스터';
-- 기존 테이블들 유지 (order_config, order_mail_templates, order_sms_templates)
-- 기존 테이블 활용 최소 변경 SQL
-- 기존 temp_, extra_ 컬럼을 활용하여 새 기능 구현
-- 1. 시스템 설정을 위한 간단한 테이블 (기존 테이블 없을 경우만 생성)
CREATE TABLE IF NOT EXISTS `order_config` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`config_key` varchar(100) NOT NULL,
`config_value` text NOT NULL,
`config_desc` varchar(255) DEFAULT NULL,
`config_type` varchar(20) DEFAULT 'text',
`temp_1` varchar(255) DEFAULT NULL COMMENT '임시필드1',
`temp_2` varchar(255) DEFAULT NULL COMMENT '임시필드2',
`temp_3` varchar(255) DEFAULT NULL COMMENT '임시필드3',
`temp_4` varchar(255) DEFAULT NULL COMMENT '임시필드4',
`temp_5` varchar(255) DEFAULT NULL COMMENT '임시필드5',
`extra_1` varchar(255) DEFAULT NULL COMMENT '추가필드1',
`extra_2` varchar(255) DEFAULT NULL COMMENT '추가필드2',
`extra_3` varchar(255) DEFAULT NULL COMMENT '추가필드3',
`extra_4` varchar(255) DEFAULT NULL COMMENT '추가필드4',
`extra_5` varchar(255) DEFAULT NULL COMMENT '추가필드5',
`is_used` tinyint(1) NOT NULL DEFAULT '1' COMMENT '사용 여부',
`is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '삭제 여부',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시',
`created_by` varchar(20) DEFAULT NULL COMMENT '생성자',
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일시',
`updated_by` varchar(20) DEFAULT NULL COMMENT '수정자',
PRIMARY KEY (`id`),
UNIQUE KEY `config_key` (`config_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='시스템 설정';
-- 2. 메일 템플릿 테이블 (기존 테이블 없을 경우만 생성)
CREATE TABLE IF NOT EXISTS `order_mail_templates` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`template_key` varchar(100) NOT NULL,
`template_name` varchar(255) NOT NULL,
`subject` varchar(255) NOT NULL,
`content` text NOT NULL,
`variables` text DEFAULT NULL COMMENT '사용 가능한 변수들 (JSON)',
`temp_1` varchar(255) DEFAULT NULL COMMENT '발송대상그룹',
`temp_2` varchar(255) DEFAULT NULL COMMENT '발송조건',
`temp_3` varchar(255) DEFAULT NULL COMMENT '임시필드3',
`temp_4` varchar(255) DEFAULT NULL COMMENT '임시필드4',
`temp_5` varchar(255) DEFAULT NULL COMMENT '임시필드5',
`extra_1` varchar(255) DEFAULT NULL COMMENT '추가필드1',
`extra_2` varchar(255) DEFAULT NULL COMMENT '추가필드2',
`extra_3` varchar(255) DEFAULT NULL COMMENT '추가필드3',
`extra_4` varchar(255) DEFAULT NULL COMMENT '추가필드4',
`extra_5` varchar(255) DEFAULT NULL COMMENT '추가필드5',
`is_used` tinyint(1) NOT NULL DEFAULT '1' COMMENT '사용 여부',
`is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '삭제 여부',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시',
`created_by` varchar(20) DEFAULT NULL COMMENT '생성자',
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일시',
`updated_by` varchar(20) DEFAULT NULL COMMENT '수정자',
PRIMARY KEY (`id`),
UNIQUE KEY `template_key` (`template_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='메일 템플릿';
-- 3. SMS 템플릿 테이블 (기존 테이블 없을 경우만 생성)
CREATE TABLE IF NOT EXISTS `order_sms_templates` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`template_key` varchar(100) NOT NULL,
`template_name` varchar(255) NOT NULL,
`content` text NOT NULL,
`variables` text DEFAULT NULL COMMENT '사용 가능한 변수들 (JSON)',
`temp_1` varchar(255) DEFAULT NULL COMMENT '발송대상그룹',
`temp_2` varchar(255) DEFAULT NULL COMMENT '발송조건',
`temp_3` varchar(255) DEFAULT NULL COMMENT '임시필드3',
`temp_4` varchar(255) DEFAULT NULL COMMENT '임시필드4',
`temp_5` varchar(255) DEFAULT NULL COMMENT '임시필드5',
`extra_1` varchar(255) DEFAULT NULL COMMENT '추가필드1',
`extra_2` varchar(255) DEFAULT NULL COMMENT '추가필드2',
`extra_3` varchar(255) DEFAULT NULL COMMENT '추가필드3',
`extra_4` varchar(255) DEFAULT NULL COMMENT '추가필드4',
`extra_5` varchar(255) DEFAULT NULL COMMENT '추가필드5',
`is_used` tinyint(1) NOT NULL DEFAULT '1' COMMENT '사용 여부',
`is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '삭제 여부',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시',
`created_by` varchar(20) DEFAULT NULL COMMENT '생성자',
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일시',
`updated_by` varchar(20) DEFAULT NULL COMMENT '수정자',
PRIMARY KEY (`id`),
UNIQUE KEY `template_key` (`template_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='SMS 템플릿';
-- ⭐ 기본 설정값 (중복 방지)
INSERT IGNORE INTO `order_config` (`config_key`, `config_value`, `config_desc`, `config_type`) VALUES
('timer_enabled', '1', '24시간 타이머 활성화 여부', 'boolean'),
('timer_message_active', '견적 제안 마감까지 {time} 남았습니다.', '타이머 활성화 시 메시지', 'text'),
('timer_message_inactive', '고객 작성이 완료되었습니다. 24시간 후에 확인 바랍니다.', '타이머 비활성화 시 메시지', 'text'),
('contract_deposit_rate', '10', '계약금 비율 (%)', 'number'),
('middle_payment_rate', '40', '중도금 비율 (%)', 'number'),
('expert_visit_fee', '50000', '전문가 방문 비용', 'number'),
('expert_account_info', '국민은행 123-456-789 (주)창호전문가', '전문가 방문 계좌 정보', 'text'),
('notification_mode', 'log', '알림 발송 모드 (log/send)', 'select'),
('dealer_commission_level_5', '5', '레벨 5 대리점 수수료율 (%)', 'number'),
('dealer_commission_level_6', '11', '레벨 6 대리점 수수료율 (%)', 'number'),
('dealer_commission_level_7', '19', '레벨 7 대리점 수수료율 (%)', 'number'),
('price_increment_step', '1000', '견적서 단가 입력 증감 단위', 'number'),
('payment_account_info', '국민은행 123-456-789012 (주)창호전문', '결제 계좌 정보', 'text'),
('status_견적신청중', '수정중', '상태명: 견적신청중', 'text'),
('status_작성완료', '작성완료', '상태명: 작성완료', 'text'),
('status_계약금입금예정', '계약금입금예정', '상태명: 계약금입금예정', 'text'),
('status_계약금입금완료', '계약금입금완료', '상태명: 계약금입금완료', 'text'),
('status_중도금입금예정', '중도금입금예정', '상태명: 중도금입금예정', 'text'),
('status_중도금입금완료', '중도금입금완료', '상태명: 중도금입금완료', 'text'),
('status_잔금입금예정', '잔금입금예정', '상태명: 잔금입금예정', 'text'),
('status_잔금처리완료', '잔금처리완료', '상태명: 잔금처리완료', 'text'),
('status_시공완료', '시공완료', '상태명: 시공완료', 'text'),
('status_견적제안', '견적제안', '상태명: 견적제안', 'text'),
('status_견적채택', '견적채택', '상태명: 견적채택', 'text'),
('status_견적취소', '견적취소', '상태명: 견적취소', 'text'),
('expert_visit_default_start', '09:00', '기본 방문 시작 시간', 'time'),
('expert_visit_default_end', '18:00', '기본 방문 종료 시간', 'time'),
('expert_visit_default_slot', '60', '기본 예약 단위 시간(분)', 'number'),
('expert_visit_default_max', '2', '기본 동시간대 최대 예약 인원', 'number'),
('expert_visit_account_info', '국민은행 123-456-789 (주)창호방문', '방문비 입금 계좌', 'text'),
('reservation_status_payment_pending', '입금예정', '예약상태: 입금예정', 'text'),
('reservation_status_reserved', '예약완료', '예약상태: 예약완료', 'text'),
('reservation_status_completed', '방문완료', '예약상태: 방문완료', 'text'),
('reservation_status_cancelled', '예약취소', '예약상태: 예약취소', 'text');
-- ⭐ 기본 메일 템플릿 (중복 방지)
INSERT IGNORE INTO `order_mail_templates` (`template_key`, `template_name`, `subject`, `content`, `variables`) VALUES
('customer_request_complete', '고객 - 견적 요청 완료', '견적 요청이 완료되었습니다', '안녕하세요 {customer_name}님,<br><br>견적 요청이 성공적으로 완료되었습니다.<br>24시간 후에 견적서를 확인해주시기 바랍니다.<br><br>감사합니다.', '["customer_name"]'),
('agent_new_request', '대리점 - 새 견적 요청', '새로운 견적 요청이 등록되었습니다', '안녕하세요 {agent_name}님,<br><br>{customer_name}님이 견적을 요청했습니다.<br><br>제목: {request_title}<br>요청일: {request_date}<br><br>견적 작성 URL: {write_url}<br><br>24시간 내에 견적을 제안해주시기 바랍니다.', '["agent_name", "customer_name", "request_title", "request_date", "write_url"]'),
('customer_quote_selected', '고객 - 견적 선택 완료', '견적이 선택되었습니다', '안녕하세요 {customer_name}님,<br><br>{agent_name} 대리점의 견적이 선택되었습니다.<br><br>계약금({deposit_amount}원)을 입금해주시기 바랍니다.<br><br>계좌정보: {account_info}', '["customer_name", "agent_name", "deposit_amount", "account_info"]'),
('agent_quote_selected', '대리점 - 견적 선택됨', '견적이 선택되었습니다', '안녕하세요 {agent_name}님,<br><br>{customer_name}님이 귀하의 견적을 선택하셨습니다.<br><br>시공 일정을 관리자에게 알려주시기 바랍니다.<br><br>감사합니다.', '["agent_name", "customer_name"]'),
('expert_visit_request', '전문가 방문 요청', '전문가 방문이 요청되었습니다', '안녕하세요 {customer_name}님,<br><br>전문가 방문이 요청되었습니다.<br><br>방문 비용: {visit_fee}원<br>계좌정보: {account_info}<br><br>입금 확인 후 방문 일정을 조율하겠습니다.', '["customer_name", "visit_fee", "account_info"]'),
('bid_selected_email', '대리점 - 견적 선택 알림', '축하합니다! 견적이 선택되었습니다', '안녕하세요 {dealer_name}님,<br><br>축하합니다! 고객이 귀하의 견적을 선택하였습니다.<br><br><strong>견적 정보:</strong><br>- 견적번호: {estimate_id}<br>- 고객명: {customer_name}<br>- 견적금액: {bid_amount}원<br>- 선택일시: {selected_date}<br><br>이제 시공 일정을 조율해주시기 바랍니다.<br><br><a href="{estimate_url}">견적 상세보기</a><br><br>감사합니다.', '["dealer_name", "customer_name", "estimate_id", "bid_amount", "selected_date", "estimate_url"]'),
('schedule_reminder_email', '대리점 - 시공 일정 알림', '시공 일정 안내', '안녕하세요 {dealer_name}님,<br><br>시공 일정을 안내드립니다.<br><br><strong>시공 정보:</strong><br>- 견적번호: {estimate_id}<br>- 고객명: {customer_name}<br>- 시공예정일: {construction_date}<br>- 중도금입금예정일: {interim_payment_date}<br><br>시공 준비를 완료해주시기 바랍니다.<br><br>감사합니다.', '["dealer_name", "customer_name", "estimate_id", "construction_date", "interim_payment_date"]'),
('interim_due_email', '대리점 - 중도금 입금 기한 알림', '중도금 입금 기한 안내', '안녕하세요 {dealer_name}님,<br><br>중도금 입금 기한이 도래했습니다.<br><br><strong>시공 정보:</strong><br>- 견적번호: {estimate_id}<br>- 고객명: {customer_name}<br>- 시공예정일: {construction_date}<br>- 중도금입금예정일: {interim_payment_date}<br>- 남은 일수: {days_remaining}일<br><br>고객에게 중도금 입금을 안내해주시기 바랍니다.<br><br>감사합니다.', '["dealer_name", "customer_name", "estimate_id", "construction_date", "interim_payment_date", "days_remaining"]'),
('expert_visit_payment_confirmed', '고객 - 전문가 방문 결제 확인', '전문가 방문 비용 결제가 확인되었습니다', '안녕하세요 {customer_name}님,<br><br>전문가 방문 비용 결제가 확인되었습니다.<br><br>곧 담당자가 연락드려 방문 일정을 조율하겠습니다.<br><br>감사합니다.', '["customer_name"]'),
('expert_visit_scheduled', '고객 - 전문가 방문 일정 안내', '전문가 방문 일정이 확정되었습니다', '안녕하세요 {customer_name}님,<br><br>전문가 방문 일정이 확정되었습니다.<br><br><strong>방문 일정:</strong><br>- 방문 일시: {visit_datetime}<br>- 담당 전문가: {expert_name}<br><br>방문 시간에 맞춰 준비해주시기 바랍니다.<br><br>감사합니다.', '["customer_name", "visit_datetime", "expert_name"]'),
('expert_visit_completed', '고객 - 전문가 방문 완료', '전문가 방문이 완료되었습니다', '안녕하세요 {customer_name}님,<br><br>전문가 방문이 완료되었습니다.<br><br>방문 결과를 바탕으로 정확한 견적을 제공해드리겠습니다.<br><br>감사합니다.', '["customer_name"]'),
('expert_visit_request_new', '고객 - 전문가 방문 신청', '전문가 방문이 신청되었습니다', '안녕하세요 {customer_name}님,<br><br>전문가 방문이 신청되었습니다.<br><br><strong>예약 정보:</strong><br>- 예약번호: {reservation_id}<br>- 방문예정일: {visit_date} {visit_time}<br>- 방문비용: {expert_visit_fee}원<br><br>아래 계좌로 방문비용을 입금해주시면 예약이 확정됩니다.<br><br><strong>계좌 정보:</strong><br>{account_info}<br><br>감사합니다.', '["customer_name", "reservation_id", "visit_date", "visit_time", "expert_visit_fee", "account_info"]'),
('expert_visit_confirmed', '고객 - 전문가 방문 확정', '전문가 방문이 확정되었습니다', '안녕하세요 {customer_name}님,<br><br>전문가 방문이 확정되었습니다.<br><br><strong>예약 정보:</strong><br>- 예약번호: {reservation_id}<br>- 방문일시: {visit_date} {visit_time}<br>- 담당 전문가: {expert_name}<br><br>예약 시간에 맞춰 준비해주시기 바랍니다.<br><br>감사합니다.', '["customer_name", "reservation_id", "visit_date", "visit_time", "expert_name"]'),
('admin_new_visit_request', '관리자 - 새 전문가 방문 요청', '새로운 전문가 방문 요청이 신청되었습니다', '새로운 전문가 방문 요청이 신청되었습니다.<br><br><strong>예약 정보:</strong><br>- 고객명: {customer_name}<br>- 방문예정일: {visit_date} {visit_time}<br>- 연락처: {customer_phone}<br>- 요청사항: {request_memo}<br><br>입금 확인 후 예약을 확정해주세요.', '["customer_name", "visit_date", "visit_time", "customer_phone", "request_memo"]'),
('payment_deposit_complete_customer', '고객 - 계약금 입금 완료', '계약금 입금이 확인되었습니다', '안녕하세요 {customer_name}님,<br><br>요청하신 견적의 계약금({deposit_amount}) 입금이 확인되었습니다.<br>담당 대리점에서 곧 시공 일정을 조율할 예정입니다.<br><br>감사합니다.', '["customer_name", "deposit_amount"]'),
('payment_interim_due_customer', '고객 - 중도금 입금 안내', '중도금 입금 안내', '안녕하세요 {customer_name}님,<br><br>시공일({construction_date})이 확정됨에 따라 중도금 입금을 안내드립니다.<br>시공 2일 전까지 중도금({interim_amount})을 아래 계좌로 입금해주시기 바랍니다.<br><br>계좌정보: {account_info}<br><br>감사합니다.', '["customer_name", "construction_date", "interim_amount", "account_info"]'),
('payment_interim_complete_customer', '고객 - 중도금 입금 완료', '중도금 입금이 확인되었습니다', '안녕하세요 {customer_name}님,<br><br>중도금({interim_amount}) 입금이 확인되었습니다.<br>예정된 날짜에 시공이 원활히 진행되도록 준비하겠습니다.<br><br>감사합니다.', '["customer_name", "interim_amount"]'),
('construction_complete_customer', '고객 - 시공 완료', '시공이 완료되었습니다', '안녕하세요 {customer_name}님,<br><br>요청하신 견적의 시공이 완료되었습니다.<br>이용해주셔서 감사합니다.', '["customer_name"]'),
('construction_complete_agent', '대리점 - 시공 완료', '{customer_name} 고객님의 시공이 완료 처리되었습니다', '안녕하세요 {agent_name}님,<br><br>{customer_name} 고객님의 시공이 완료 처리되었습니다.<br>수고하셨습니다.', '["agent_name", "customer_name"]'),
('expert_new_assignment', '전문가 - 신규 방문 배정', '새로운 방문 예약이 배정되었습니다', '안녕하세요 {expert_name}님,<br><br>새로운 방문 예약이 배정되었습니다.<br><br><strong>예약 정보:</strong><br>- 고객명: {customer_name}<br>- 방문일시: {visit_date} {visit_time}<br><br>관리자 페이지에서 상세 내용을 확인해주세요.', '["expert_name", "customer_name", "visit_date", "visit_time"]');
-- ⭐ 기본 SMS 템플릿 (중복 방지)
INSERT IGNORE INTO `order_sms_templates` (`template_key`, `template_name`, `content`, `variables`) VALUES
('customer_request_complete', '고객 - 견적 요청 완료', '{customer_name}님, 견적 요청이 완료되었습니다. 24시간 후에 확인해주세요.', '["customer_name"]'),
('agent_new_request', '대리점 - 새 견적 요청', '{agent_name}님, {customer_name}님이 견적을 요청했습니다. 24시간 내에 제안해주세요. {write_url}', '["agent_name", "customer_name", "write_url"]'),
('customer_quote_selected', '고객 - 견적 선택 완료', '{customer_name}님, 견적이 선택되었습니다. 계약금 {deposit_amount}원을 입금해주세요.', '["customer_name", "deposit_amount"]'),
('agent_quote_selected', '대리점 - 견적 선택됨', '{agent_name}님, {customer_name}님이 귀하의 견적을 선택하셨습니다. 시공 일정을 알려주세요.', '["agent_name", "customer_name"]'),
('expert_visit_request', '전문가 방문 요청', '{customer_name}님, 전문가 방문이 요청되었습니다. 방문비 {visit_fee}원 입금 후 일정 조율하겠습니다.', '["customer_name", "visit_fee"]'),
('bid_selected_sms', '대리점 - 견적 선택 알림', '{dealer_name}님, 축하합니다! {customer_name}님이 귀하의 견적({bid_amount}원)을 선택하셨습니다. 시공 일정을 조율해주세요.', '["dealer_name", "customer_name", "bid_amount"]'),
('schedule_reminder_sms', '대리점 - 시공 일정 알림', '{dealer_name}님, {customer_name}님 시공예정일({construction_date}) 안내드립니다. 시공 준비를 완료해주세요.', '["dealer_name", "customer_name", "construction_date"]'),
('interim_due_sms', '대리점 - 중도금 입금 기한 알림', '{dealer_name}님, {customer_name}님 중도금 입금기한({interim_payment_date})이 {days_remaining}일 남았습니다. 고객에게 안내해주세요.', '["dealer_name", "customer_name", "interim_payment_date", "days_remaining"]'),
('expert_visit_payment_confirmed', '고객 - 전문가 방문 결제 확인', '{customer_name}님, 전문가 방문 비용 결제가 확인되었습니다. 곧 방문 일정을 안내드리겠습니다.', '["customer_name"]'),
('expert_visit_scheduled', '고객 - 전문가 방문 일정 안내', '{customer_name}님, 전문가 방문 일정이 {visit_datetime}로 확정되었습니다.', '["customer_name", "visit_datetime"]'),
('expert_visit_completed', '고객 - 전문가 방문 완료', '{customer_name}님, 전문가 방문이 완료되었습니다. 감사합니다.', '["customer_name"]'),
('expert_visit_request_new', '고객 - 전문가 방문 신청', '{customer_name}님, 전문가 방문({visit_date} {visit_time})이 신청되었습니다. 방문비 {expert_visit_fee}원을 입금해주세요.', '["customer_name", "visit_date", "visit_time", "expert_visit_fee"]'),
('expert_visit_confirmed', '고객 - 전문가 방문 확정', '{customer_name}님, 전문가 방문({visit_date} {visit_time})이 확정되었습니다. 시간에 맞춰 준비해주세요.', '["customer_name", "visit_date", "visit_time"]'),
('admin_new_visit_request', '관리자 - 새 전문가 방문 요청', '새 전문가 방문 요청: {customer_name}님 {visit_date} {visit_time} (연락처: {customer_phone})', '["customer_name", "visit_date", "visit_time", "customer_phone"]'),
('payment_deposit_complete_customer', '고객 - 계약금 입금 완료', '[{site_name}] {customer_name}님, 계약금 입금이 확인되었습니다. 곧 시공 일정이 안내됩니다.', '["site_name", "customer_name"]'),
('payment_interim_due_customer', '고객 - 중도금 입금 안내', '[{site_name}] {customer_name}님, 중도금({interim_amount}) 입금 예정일({interim_payment_date})입니다. 입금 부탁드립니다.', '["site_name", "customer_name", "interim_amount", "interim_payment_date"]'),
('payment_interim_complete_customer', '고객 - 중도금 입금 완료', '[{site_name}] {customer_name}님, 중도금 입금이 확인되었습니다. 시공을 준비하겠습니다.', '["site_name", "customer_name"]'),
('construction_complete_customer', '고객 - 시공 완료', '[{site_name}] {customer_name}님, 요청하신 시공이 완료되었습니다. 감사합니다.', '["site_name", "customer_name"]'),
('construction_complete_agent', '대리점 - 시공 완료', '[{site_name}] {agent_name}님, {customer_name} 고객님의 시공이 완료 처리되었습니다.', '["site_name", "agent_name", "customer_name"]'),
('expert_new_assignment', '전문가 - 신규 방문 배정', '[{site_name}] {expert_name}님, {customer_name} 고객님의 방문 예약({visit_date} {visit_time})이 배정되었습니다.', '["site_name", "expert_name", "customer_name", "visit_date", "visit_time"]');
-- ⭐ 기본 창호 브랜드 데이터 삽입 (중복 방지)
INSERT IGNORE INTO `order_window_brands` (`brand_name`, `brand_code`, `manufacturer`, `description`, `sort_order`, `temp_1`, `temp_2`) VALUES
('KCC창호', 'KCC', 'KCC', '국내 대표 창호 브랜드', 1, '프리미엄', '고급창호'),
('LG하우시스', 'LG', 'LG하우시스', 'LG 계열 창호 전문 브랜드', 2, '프리미엄', '시스템창호'),
('현대L&C', 'HD', '현대L&C', '현대 계열 리빙 솔루션', 3, '프리미엄', '아파트창호'),
('롯데창호', 'LOTTE', '롯데창호', '롯데 계열 창호 브랜드', 4, '일반', '일반창호'),
('삼성창호', 'SAMSUNG', '삼성창호', '삼성 계열 창호 브랜드', 5, '일반', '시스템창호'),
('대우창호', 'DAEWOO', '대우창호', '대우 계열 창호 브랜드', 6, '일반', '아파트창호'),
('이녹스', 'INOX', '이녹스', '스테인리스 전문 창호', 7, '일반', '스테인리스'),
('센텍창호', 'CENTEC', '센텍', '센텍 창호 시스템', 8, '보급', '일반창호'),
('알루미늄창호', 'ALUMI', '기타', '일반 알루미늄 창호', 9, '보급', '알루미늄'),
('기타', 'ETC', '기타', '기타 브랜드', 10, '보급', '기타');
-- -- ⭐ 기본 전문가 방문 스케줄 설정 (중복 방지)
INSERT IGNORE INTO `expert_visit_schedules` (`day_of_week`, `start_time`, `end_time`, `time_slot`, `max_persons`, `temp_1`) VALUES
(1, '09:00', '18:00', 60, 2, '현장방문'),
(2, '09:00', '18:00', 60, 2, '현장방문'),
(3, '09:00', '18:00', 60, 2, '현장방문'),
(4, '09:00', '18:00', 60, 2, '현장방문'),
(5, '09:00', '18:00', 60, 2, '현장방문');
-- 테이블 컬럼 활용 현황 주석 업데이트
/*
=== 📋 테이블별 temp_/extra_ 컬럼 사용 현황 ===
🔹 estimate 테이블:
- temp_1: 전문가방문요청여부(Y/N) ✅사용중
- temp_2: 전문가방문상태(requested/scheduled/completed/cancelled) ✅사용중
- temp_3: 전문가방문비용 ✅사용중
- temp_4: 전문가방문일정(YYYY-MM-DD HH:MM) ✅사용중
- temp_5: 전문가방문메모 ✅사용중
- extra_1: 고객연락처(전문가방문용) ✅사용중
- extra_2: 결제상태(pending/paid/cancelled) ✅사용중
- extra_3: 추가요청사항 ✅사용중
- extra_4: 관리자메모 ✅사용중
- extra_5: 예비필드 ⭕미사용
🔹 estimate_item 테이블:
- temp_1~5: ⭕전체 미사용 (향후 확장용)
- extra_2~5: ⭕전체 미사용 (향후 확장용)
🔹 estimate_history 테이블:
- temp_1~5: ⭕전체 미사용 (향후 확장용)
- extra_1~5: ⭕전체 미사용 (향후 확장용)
🔹 estimate_bidding 테이블:
- temp_1~5: ⭕전체 미사용 (향후 확장용)
- extra_1~5: ⭕전체 미사용 (향후 확장용)
🔹 expert_visit_reservations 테이블: (신규)
- temp_1: 방문시간구간(예:09:00-11:00) ✅사용중
- temp_2: 방문종류(현장방문/전화상담) ✅사용중
- temp_3: 방문지역 ✅사용중
- temp_4: 방문예상시간(분) ✅사용중
- temp_5: 추가정보 ✅사용중
- extra_1~5: ⭕전체 미사용 (향후 확장용)
🔹 expert_visit_schedules 테이블: (신규)
- temp_1: 방문종류제한 ✅사용중
- temp_2: 휴무사유 ✅사용중
- temp_3: 특별일정명 ✅사용중
- temp_4~5: ⭕미사용 (향후 확장용)
- extra_1~5: ⭕전체 미사용 (향후 확장용)
🔹 order_window_brands 테이블: (신규)
- temp_1: 브랜드등급(프리미엄/일반/보급) ✅사용중
- temp_2: 주력제품군 ✅사용중
- temp_3: 가격대 ⭕미사용
- temp_4: A/S기간 ⭕미사용
- temp_5: 보증기간 ⭕미사용
- extra_1~5: ⭕전체 미사용 (향후 확장용)
🔹 order_config 테이블:
- temp_1~5: ⭕전체 미사용 (향후 확장용)
- extra_1~5: ⭕전체 미사용 (향후 확장용)
🔹 order_mail_templates 테이블:
- temp_1: 발송대상그룹 ✅사용중
- temp_2: 발송조건 ✅사용중
- temp_3~5: ⭕미사용 (향후 확장용)
- extra_1~5: ⭕전체 미사용 (향후 확장용)
🔹 order_sms_templates 테이블:
- temp_1: 발송대상그룹 ✅사용중
- temp_2: 발송조건 ✅사용중
- temp_3~5: ⭕미사용 (향후 확장용)
- extra_1~5: ⭕전체 미사용 (향후 확장용)
=== 📈 전체 사용률 요약 ===
✅ 사용중인 컬럼: 19개
⭕ 미사용 컬럼: 61개
📊 사용률: 23.8%
*/
+114
View File
@@ -0,0 +1,114 @@
-- 1. 견적서 마스터 테이블
-- 고객의 견적 요청 기본 정보와 여분 필드를 저장합니다.
CREATE TABLE IF NOT EXISTS `estimate` (
`id` int NOT NULL AUTO_INCREMENT,
`wr_id` int DEFAULT NULL COMMENT '원본 게시물 ID',
`status` VARCHAR(20) NOT NULL DEFAULT 'requesting' COMMENT '견적 상태 (requesting:신청중, completed:작성완료, bidding:입찰중, selected:채택됨, closed:마감)',
`bidding_deadline` DATETIME DEFAULT NULL COMMENT '입찰 마감일시',
`consultant_id` VARCHAR(20) DEFAULT NULL COMMENT '배정된 상담가 ID',
`consultant_assigned_at` DATETIME DEFAULT NULL COMMENT '상담가 배정일시',
`company_name` varchar(100) DEFAULT NULL COMMENT '회사명',
`site_name` varchar(100) DEFAULT NULL COMMENT '현장명',
`estimate_date` varchar(30) DEFAULT NULL COMMENT '견적일자',
`temp_1` varchar(255) DEFAULT NULL COMMENT '임시 필드 1',
`temp_2` varchar(255) DEFAULT NULL COMMENT '임시 필드 2',
`temp_3` varchar(255) DEFAULT NULL COMMENT '임시 필드 3',
`temp_4` varchar(255) DEFAULT NULL COMMENT '임시 필드 4',
`temp_5` varchar(255) DEFAULT NULL COMMENT '임시 필드 5',
`extra_1` varchar(255) DEFAULT NULL COMMENT '여분 필드 1',
`extra_2` varchar(255) DEFAULT NULL COMMENT '여분 필드 2',
`extra_3` varchar(255) DEFAULT NULL COMMENT '여분 필드 3',
`extra_4` varchar(255) DEFAULT NULL COMMENT '여분 필드 4',
`extra_5` varchar(255) DEFAULT NULL COMMENT '여분 필드 5',
`is_used` tinyint(1) NOT NULL DEFAULT '1' COMMENT '사용 여부 (1:사용, 0:미사용)',
`is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '삭제 여부 (1:삭제, 0:정상)',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시',
`created_by` varchar(20) DEFAULT NULL COMMENT '생성자',
`updated_at` datetime DEFAULT NULL COMMENT '수정일시',
`updated_by` varchar(20) DEFAULT NULL COMMENT '수정자',
PRIMARY KEY (`id`),
KEY `wr_id` (`wr_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='견적서 마스터 정보';
-- 2. 견적서 상세 항목 테이블
-- 각 창의 상세 사양과 여분 필드를 저장합니다.
CREATE TABLE IF NOT EXISTS `estimate_item` (
`id` int NOT NULL AUTO_INCREMENT,
`estimate_id` int DEFAULT NULL COMMENT '견적서 ID (estimate.id)',
`no` int DEFAULT NULL COMMENT '항목 번호',
`location` varchar(50) DEFAULT NULL COMMENT '위치',
`product` varchar(50) DEFAULT NULL COMMENT '품명',
`window_main_type` VARCHAR(255) NULL DEFAULT NULL COMMENT '창 종류',
`windowRatio` varchar(20) DEFAULT NULL COMMENT '창비율',
`windowType` varchar(20) DEFAULT NULL COMMENT '창호형태',
`replacePart` varchar(20) DEFAULT NULL COMMENT '교체위치',
`color` varchar(20) DEFAULT NULL COMMENT '색상',
`spec_width` varchar(20) DEFAULT NULL COMMENT '규격(가로)',
`spec_height` varchar(20) DEFAULT NULL COMMENT '규격(세로)',
`glass_thickness` varchar(20) DEFAULT NULL COMMENT '유리두께',
`glass_color` varchar(20) DEFAULT NULL COMMENT '유리색상',
`screen` varchar(10) DEFAULT NULL COMMENT '방충망',
`handle` varchar(20) DEFAULT NULL COMMENT '손잡이(시정장치)',
`door_dir` varchar(20) DEFAULT NULL COMMENT '문방향',
`qty` int DEFAULT NULL COMMENT '수량',
`price` int DEFAULT NULL COMMENT '단가',
`amount` int DEFAULT NULL COMMENT '금액',
`install` varchar(5) DEFAULT NULL COMMENT '시공여부',
`brand` varchar(30) DEFAULT NULL COMMENT '브랜드',
`temp_1` varchar(255) DEFAULT NULL COMMENT '임시 필드 1',
`temp_2` varchar(255) DEFAULT NULL COMMENT '임시 필드 2',
`temp_3` varchar(255) DEFAULT NULL COMMENT '임시 필드 3',
`temp_4` varchar(255) DEFAULT NULL COMMENT '임시 필드 4',
`temp_5` varchar(255) DEFAULT NULL COMMENT '임시 필드 5',
`extra_1` varchar(255) DEFAULT NULL COMMENT '여분 필드 1',
`extra_2` varchar(255) DEFAULT NULL COMMENT '여분 필드 2',
`extra_3` varchar(255) DEFAULT NULL COMMENT '여분 필드 3',
`extra_4` varchar(255) DEFAULT NULL COMMENT '여분 필드 4',
`extra_5` varchar(255) DEFAULT NULL COMMENT '여분 필드 5',
`is_used` tinyint(1) NOT NULL DEFAULT '1' COMMENT '사용 여부 (1:사용, 0:미사용)',
`is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '삭제 여부 (1:삭제, 0:정상)',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시',
`created_by` varchar(20) DEFAULT NULL COMMENT '생성자',
`updated_at` datetime DEFAULT NULL COMMENT '수정일시',
`updated_by` varchar(20) DEFAULT NULL COMMENT '수정자',
PRIMARY KEY (`id`),
KEY `estimate_id` (`estimate_id`),
CONSTRAINT `fk_item_to_estimate` FOREIGN KEY (`estimate_id`) REFERENCES `estimate` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='견적서 상세 항목';
-- 3. 견적 및 항목 변경 이력 테이블
-- 모든 데이터의 생성, 수정, 삭제 이력을 기록합니다.
CREATE TABLE IF NOT EXISTS `estimate_history` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '이력 고유 ID',
`estimate_id` int NOT NULL COMMENT '견적서 ID',
`item_id` int DEFAULT NULL COMMENT '견적 항목 ID (항목 변경 시)',
`action` varchar(20) NOT NULL COMMENT '작업 종류 (create, update, delete)',
`change_details` longtext COMMENT '변경된 데이터 (JSON 형식)',
`changed_by` varchar(20) NOT NULL COMMENT '변경자 ID',
`changed_at` datetime NOT NULL COMMENT '변경일시',
PRIMARY KEY (`id`),
KEY `estimate_id` (`estimate_id`),
KEY `item_id` (`item_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='견적 및 항목 변경 이력';
-- 4. 대리점 입찰 정보 테이블
-- 대리점이 고객의 견적 요청에 대해 제출한 입찰(견적) 정보를 저장합니다.
CREATE TABLE IF NOT EXISTS `estimate_bidding` (
`id` int NOT NULL AUTO_INCREMENT,
`estimate_id` int NOT NULL COMMENT '견적서 ID (estimate.id)',
`dealer_id` varchar(20) NOT NULL COMMENT '입찰한 대리점 회원 ID',
`bid_amount` int NOT NULL COMMENT '대리점 제시 금액',
`bid_message` text COMMENT '대리점 메모',
`status` varchar(20) NOT NULL DEFAULT 'bidding' COMMENT '입찰 상태 (bidding:입찰중, selected:채택됨, unselected:미채택)',
`is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '삭제 여부 (1:삭제, 0:정상)',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시',
`updated_at` datetime DEFAULT NULL COMMENT '수정일시',
`updated_by` varchar(20) DEFAULT NULL COMMENT '수정자',
PRIMARY KEY (`id`),
KEY `estimate_id` (`estimate_id`),
KEY `dealer_id` (`dealer_id`),
CONSTRAINT `fk_bidding_to_estimate` FOREIGN KEY (`estimate_id`) REFERENCES `estimate` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='대리점 입찰 정보';
@@ -0,0 +1,206 @@
<?php
if (!defined('_GNUBOARD_')) exit;
/**
* SQL 파일을 기반으로 데이터베이스 스키마를 관리(생성/업데이트)하는 범용 클래스
*/
class SchemaManager
{
private $sql_file_path;
private $results;
private $conn; // DB 연결 객체를 저장할 변수
/**
* 생성자
* @param string $sql_file_path install.sql 파일의 절대 경로
*/
public function __construct($sql_file_path)
{
global $g5; // 그누보드 DB 연결 객체에 접근하기 위해 global 선언
$this->conn = $g5['connect_db']; // DB 연결 객체를 저장
if (!file_exists($sql_file_path)) {
throw new Exception($sql_file_path . ' 파일을 찾을 수 없습니다.');
}
$this->sql_file_path = $sql_file_path;
$this->results = [
'created' => [],
'existing' => [],
'updated' => [],
'failed' => [],
'errors' => [],
];
}
/**
* 스키마 설치/업데이트를 실행합니다.
*/
public function execute()
{
$sql_statements = $this->parse_sql_file();
foreach ($sql_statements as $stmt) {
// CREATE TABLE 문인지 확인
if (preg_match('/^CREATE\s+TABLE/i', $stmt)) {
$schema = $this->parse_create_table_sql($stmt);
if ($schema && !empty($schema['name'])) {
$this->process_table_schema($stmt, $schema);
}
} else {
// CREATE TABLE 문이 아닌 다른 SQL 문 (e.g. INSERT, UPDATE)
mysqli_query($this->conn, $stmt);
}
}
}
/**
* 처리 결과를 반환합니다.
* @return array
*/
public function get_results()
{
return $this->results;
}
/**
* 테이블 스키마를 처리합니다. (생성 또는 업데이트)
* @param string $create_sql 전체 CREATE TABLE 구문
* @param array $schema 파싱된 스키마 정보
*/
private function process_table_schema($create_sql, $schema)
{
$table_name = $schema['name'];
if ($this->table_exists($table_name)) {
// 테이블이 존재하면, 컬럼 비교 및 추가/수정
$this->results['existing'][] = $table_name;
$this->update_table_columns($table_name, $schema['columns']);
} else {
// 테이블이 존재하지 않으면, 새로 생성
if (mysqli_query($this->conn, $create_sql)) {
$this->results['created'][] = $table_name;
} else {
$this->results['failed'][] = $table_name;
$this->results['errors'][] = "<strong>{$table_name} 테이블 생성 실패</strong>: " . mysqli_error($this->conn);
}
}
}
/**
* 테이블의 컬럼 구조를 업데이트합니다.
* @param string $table_name
* @param array $target_columns .sql 파일에 정의된 컬럼 목록
*/
private function update_table_columns($table_name, $target_columns)
{
$current_columns = $this->get_current_columns($table_name);
$added_columns_in_table = [];
foreach ($target_columns as $col_name => $col_definition) {
// 현재 테이블에 해당 컬럼이 없으면 추가
if (!isset($current_columns[$col_name])) {
$alter_sql = "ALTER TABLE `{$table_name}` ADD COLUMN `{$col_name}` {$col_definition}";
if (mysqli_query($this->conn, $alter_sql)) {
$added_columns_in_table[] = $col_name;
} else {
$this->results['failed'][] = "{$table_name} (컬럼: {$col_name})";
$this->results['errors'][] = "<strong>{$table_name} 테이블에 '{$col_name}' 컬럼 추가 실패</strong>: " . mysqli_error($this->conn);
}
} else {
// 💡 [핵심 수정] 컬럼이 이미 존재하면, 코멘트 등을 업데이트하기 위해 MODIFY 실행
$alter_sql = "ALTER TABLE `{$table_name}` MODIFY COLUMN `{$col_name}` {$col_definition}";
if (!mysqli_query($this->conn, $alter_sql)) {
// MODIFY 실패 시 에러 기록
$this->results['failed'][] = "{$table_name} (컬럼: {$col_name})";
$this->results['errors'][] = "<strong>{$table_name} 테이블의 '{$col_name}' 컬럼 수정 실패</strong>: " . mysqli_error($this->conn);
}
}
}
if (!empty($added_columns_in_table)) {
$this->results['updated'][$table_name] = $added_columns_in_table;
}
}
/**
* SQL 파일을 읽고 각 구문으로 분리합니다.
* @return array
*/
private function parse_sql_file()
{
$sql = file_get_contents($this->sql_file_path);
// 주석 제거 (SQL 주석 '--' 와 C-style '/* ... */' 주석)
$sql = preg_replace('/--.*/', '', $sql);
$sql = preg_replace('!/\*.*?\*/!s', '', $sql);
$sql = trim($sql);
// 세미콜론(;)을 기준으로 쿼리 분리
return array_filter(array_map('trim', explode(';', $sql)));
}
/**
* 테이블 존재 여부를 확인합니다.
* @param string $table_name
* @return bool
*/
private function table_exists($table_name)
{
$res = mysqli_query($this->conn, "SHOW TABLES LIKE '{$table_name}'");
return mysqli_num_rows($res) > 0;
}
/**
* 현재 DB에 있는 테이블의 컬럼 목록을 가져옵니다.
* @param string $table_name
* @return array
*/
private function get_current_columns($table_name)
{
$res = mysqli_query($this->conn, "SHOW COLUMNS FROM `{$table_name}`");
$columns = [];
while ($row = mysqli_fetch_array($res)) {
$columns[$row['Field']] = true;
}
return $columns;
}
/**
* CREATE TABLE SQL 구문에서 테이블명과 컬럼 정의를 파싱합니다.
* @param string $query CREATE TABLE 구문
* @return array|null
*/
private function parse_create_table_sql($query)
{
$table_name = '';
if (preg_match('/CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+`?(\w+)`?/i', $query, $matches)) {
$table_name = $matches[1];
} else {
return null;
}
// 괄호 안의 내용만 추출
$start = strpos($query, '(');
$end = strrpos($query, ')');
if ($start === false || $end === false) {
return ['name' => $table_name, 'columns' => []];
}
$content = substr($query, $start + 1, $end - $start - 1);
// 줄 단위로 분리
$lines = explode("\n", $content);
$columns = [];
foreach ($lines as $line) {
$line = trim($line, " \t\n\r\0\x0B,"); // 양쪽 공백과 마지막 쉼표 제거
// 컬럼 정의 라인인지 확인 (첫 단어가 `column_name` 형태)
if (preg_match('/^`(\w+)`\s+(.*)/i', $line, $match)) {
$col_name = $match[1];
$col_definition = $match[2];
$columns[$col_name] = $col_definition;
}
}
return ['name' => $table_name, 'columns' => $columns];
}
}
@@ -0,0 +1,441 @@
<?php
if (!defined('_GNUBOARD_'))
exit;
// ❗ [핵심] NotificationSender 클래스 포함
if (file_exists(__DIR__ . '/notification_sender.php')) {
require_once(__DIR__ . '/notification_sender.php');
}
/**
* 주문 설정값을 가져옵니다.
*/
function get_order_config($key, $default = '')
{
$result = sql_fetch("SELECT config_value FROM order_config WHERE config_key = '" . sql_real_escape_string($key) . "'");
return $result ? $result['config_value'] : $default;
}
function write_debug_log($message)
{
$log_dir = G5_PATH . '/log';
// 1. 디렉토리 존재 여부 확인 및 생성
if (!is_dir($log_dir)) {
if (!@mkdir($log_dir, 0755, true) && !is_dir($log_dir)) {
error_log("--- NotificationSender ERROR: 디버그 로그 디렉토리 생성 실패. '{$log_dir}' 경로를 확인하거나 수동으로 생성 후 웹서버 쓰기 권한을 부여해주세요.");
return;
}
}
// 2. 디렉토리 쓰기 권한 확인
if (!is_writable($log_dir)) {
error_log("--- NotificationSender ERROR: 디버그 로그 쓰기 오류. '{$log_dir}' 디렉토리에 쓰기 권한이 없습니다. 웹서버의 폴더 권한을 확인해주세요.");
return;
}
// 3. 로그 파일에 내용 기록
$log_file = $log_dir . '/notification_debug.log';
$log_message = date("[Y-m-d H:i:s]") . " " . $message . "\n";
if (file_put_contents($log_file, $log_message, FILE_APPEND | LOCK_EX) === false) {
error_log("--- NotificationSender ERROR: 디버그 로그 파일 쓰기 실패. '{$log_file}' 파일에 내용을 쓸 수 없습니다.");
}
}
/**
* ❗ [핵심 수정] 1. 견적 상태 변경 시 알림 발송
* @param int $wr_id 상태가 변경된 게시물 ID (원본 또는 답변글)
* @param string $new_status 새로 변경된 상태
*/
function notify_by_status($wr_id, $new_status)
{
global $config;
$post = sql_fetch("SELECT * FROM {$GLOBALS['g5']['write_prefix']}order WHERE wr_id = '{$wr_id}'");
if (!$post) return false;
write_debug_log("[notify_by_status] 원본 견적(estimate) 시작 : {$wr_id}");
// 원본글 정보 조회
$origin_post = sql_fetch("SELECT * FROM {$GLOBALS['g5']['write_prefix']}order WHERE wr_num = '{$post['wr_num']}' AND wr_reply = ''");
if (!$origin_post) return false;
$customer = get_member($origin_post['mb_id']);
if (!$customer) return false;
// 템플릿에 사용할 공통 변수 (영문 키로 표준화)
$common_vars = [
'customer_name' => $customer['mb_name'],
'request_title' => $origin_post['wr_subject'],
'request_date' => date('Y-m-d', strtotime($origin_post['wr_datetime'])),
'write_url' => get_pretty_url('order', $origin_post['wr_id']),
'site_name' => $config['cf_title'] ?? '우리집창호', // 사이트 이름 추가
];
if (!class_exists('NotificationSender')) return false;
$sender = new NotificationSender();
// ❗ [추가] 결제 및 진행상태 관련 알림을 위한 공통 변수 처리
$payment_statuses = ['계약금입금완료', '중도금입금예정', '중도금입금완료', '시공완료'];
$payment_vars = [];
$agent_member = null;
$bid_dealer_id = null;
if (in_array($new_status, $payment_statuses)) {
// ❗ [핵심 수정] estimate_bidding을 사용하지 않고, 채택된 답변글에서 직접 정보를 가져옵니다.
// 1. 원본글의 estimate 정보를 가져옵니다. (시공일, 중도금일 등)
$origin_estimate = sql_fetch("SELECT * FROM estimate WHERE wr_id = '{$origin_post['wr_id']}'");
if (!$origin_estimate) {
write_debug_log("[notify_by_status] 원본 견적(estimate) 정보를 찾을 수 없습니다. wr_id: {$origin_post['wr_id']}");
return false;
}
// 2. 채택된 답변글(대리점 제안) 정보를 찾습니다.
$selected_reply_sql = "
SELECT
w.mb_id as dealer_id,
e.commission_fee as total_amount
FROM
{$GLOBALS['g5']['write_prefix']}order w
JOIN
estimate e ON w.wr_id = e.wr_id
WHERE
w.wr_num = '{$origin_post['wr_num']}'
AND w.wr_reply != ''
AND e.status = '견적채택'
LIMIT 1
";
$selected_bid_info = sql_fetch($selected_reply_sql);
if ($selected_bid_info) {
$bid_dealer_id = $selected_bid_info['dealer_id'];
$agent_member = get_member($bid_dealer_id);
$total_amount = (int) $selected_bid_info['total_amount']; // 답변글의 estimate.commission_fee를 총액으로 사용
$deposit_rate = (float) get_order_config('contract_deposit_rate', 10);
$interim_rate = (float) get_order_config('middle_payment_rate', 40);
$deposit_amount = (int) ($total_amount * $deposit_rate / 100);
$interim_amount = (int) ($total_amount * $interim_rate / 100);
$payment_vars = array_merge($common_vars, [
'deposit_amount' => number_format($deposit_amount) . '원',
'interim_amount' => number_format($interim_amount) . '원',
'agent_name' => $agent_member['mb_name'] ?? '담당 대리점',
'construction_date' => $origin_estimate['temp_6'] ?? '미정',
'interim_payment_date' => $origin_estimate['temp_7'] ?? '미정',
'account_info' => get_order_config('payment_account_info', '계좌 정보 미설정'),
]);
}
if (empty($payment_vars)) { // 필수 정보가 없으면 발송 중단
write_debug_log("[notify_by_status] 결제 정보 부족으로 '{$new_status}' 알림 발송을 건너뜁니다. wr_id: {$wr_id}");
return false;
}
}
switch ($new_status) {
case '작성완료':
// 1. 고객에게 알림
$sender->send([
'target_type' => 'single',
'member_id' => $customer['mb_id'],
'email_template_key' => 'customer_request_complete',
'sms_template_key' => 'customer_request_complete',
'vars' => $common_vars,
]);
// 2. 모든 대리점에게 알림
$sender->send([
'target_type' => 'bulk',
'member_levels' => [9,10],
'email_template_key' => 'agent_new_request',
'sms_template_key' => 'agent_new_request',
'vars' => $common_vars,
]);
break;
case '견적채택':
$agent_member = get_member($post['mb_id']);
write_debug_log("[notify_by_status] agent_member=> ".$agent_member['mb_id'].', name'.$agent_member['mb_name'].', id '.$post['mb_id'].', w_id '.$wr_id);
if (!$agent_member) break;
// 🔥 [핵심 수정] 채택된 답변글(wr_id)의 estimate 레코드에서 최종 금액을 가져옵니다.
$agent_estimate = sql_fetch("SELECT id, commission_fee FROM estimate WHERE wr_id = '{$post['wr_id']}'");
$final_amount = $agent_estimate ? (int)$agent_estimate['commission_fee'] : 0;
// [수정] 입찰 정보를 바탕으로 변수를 설정합니다.
$bid_vars = array_merge($common_vars, [
'agent_name' => $agent_member['mb_name'],
'dealer_name' => $agent_member['mb_name'], // 템플릿 호환용
'deposit_amount' => number_format($final_amount) . '원', // 최종 금액을 입금 안내
'bid_amount' => number_format($final_amount), // 템플릿 호환용
'account_info' => get_order_config('payment_account_info', '계좌 정보 미설정'),
'estimate_id' => $agent_estimate ? $agent_estimate['id'] : 'N/A',
'selected_date' => date('Y-m-d H:i'),
'estimate_url' => get_pretty_url('order', $origin_post['wr_id']),
]);
// 1. 채택된 대리점에게 알림 (축하 메시지)
$sender->send([
'target_type' => 'single',
'member_id' => $agent_member['mb_id'],
'email_template_key' => 'bid_selected_email',
'sms_template_key' => 'bid_selected_sms',
'vars' => $bid_vars,
]);
// 2. 고객에게 알림 (선택 완료 및 입금 안내)
$sender->send([
'target_type' => 'single',
'member_id' => $customer['mb_id'],
'email_template_key' => 'customer_quote_selected',
'sms_template_key' => 'customer_quote_selected',
'vars' => $bid_vars,
]);
break;
case '계약금입금완료':
$sender->send([
'target_type' => 'single',
'member_id' => $customer['mb_id'],
'email_template_key' => 'payment_deposit_complete_customer',
'sms_template_key' => 'payment_deposit_complete_customer',
'vars' => $payment_vars,
]);
break;
case '중도금입금예정':
$sender->send([
'target_type' => 'single',
'member_id' => $customer['mb_id'],
'email_template_key' => 'payment_interim_due_customer',
'sms_template_key' => 'payment_interim_due_customer',
'vars' => $payment_vars,
]);
break;
case '중도금입금완료':
$sender->send([
'target_type' => 'single',
'member_id' => $customer['mb_id'],
'email_template_key' => 'payment_interim_complete_customer',
'sms_template_key' => 'payment_interim_complete_customer',
'vars' => $payment_vars,
]);
break;
case '시공완료':
// 고객에게 알림
$sender->send([
'target_type' => 'single',
'member_id' => $customer['mb_id'],
'email_template_key' => 'construction_complete_customer',
'sms_template_key' => 'construction_complete_customer',
'vars' => $payment_vars,
]);
// 대리점에게 알림
if ($bid_dealer_id) {
$sender->send([
'target_type' => 'single',
'member_id' => $bid_dealer_id,
'email_template_key' => 'construction_complete_agent',
'sms_template_key' => 'construction_complete_agent',
'vars' => $payment_vars,
]);
}
break;
}
return true;
}
/**
* ❗ [신규] 2. 입금 관련 알림 발송 (관리자, 고객)
* @param int $wr_id 원본 게시물 ID
* @param string $payment_status 입금 상태 (예: '계약금입금완료')
* @param array $payment_info 추가 결제 정보 (금액 등)
*/
function notify_for_payment($wr_id, $payment_status, $payment_info = [])
{
$origin_post = sql_fetch("SELECT * FROM {$GLOBALS['g5']['write_prefix']}order WHERE wr_id = '{$wr_id}'");
if (!$origin_post) return false;
$customer = get_member($origin_post['mb_id']);
if (!$customer) return false;
$common_vars = array_merge([
'customer_name' => $customer['mb_name'],
'estimate_subject' => $origin_post['wr_subject'],
], $payment_info);
if (!class_exists('NotificationSender')) return false;
$sender = new NotificationSender();
// 1. 고객에게 알림
$sender->send([
'target_type' => 'single',
'member_id' => $customer['mb_id'],
'email_template_key' => 'payment_status_update_customer', // 예시 템플릿 키
'sms_template_key' => 'payment_status_update_customer',
'vars' => $common_vars,
]);
// 2. 관리자에게 알림
$sender->send([
'target_type' => 'bulk',
'member_levels' => [10], // 관리자 레벨
'email_template_key' => 'payment_status_update_admin', // 예시 템플릿 키
'sms_template_key' => 'payment_status_update_admin',
'vars' => $common_vars,
]);
return true;
}
/**
* ❗ [신규] 3. 전문가 방문 예약 관련 알림 발송 (고객, 전문가, 관리자)
* @param int $reservation_id 예약 ID
* @param string $visit_status 방문 상태 (예: '신규예약', '예약확정', '예약취소')
*/
function notify_for_expert_visit($reservation_id, $visit_status)
{
// 예약 정보 조회
$reservation = sql_fetch("SELECT * FROM expert_visit_reservations WHERE id = '{$reservation_id}'");
if (!$reservation) return false;
// 💡 [수정] 고객 정보 조회 (회원/비회원 구분)
$customer = get_member($reservation['customer_id']);
$is_guest = empty($customer['mb_id']);
// 비회원일 경우 예약 정보에서 직접 가져옴
$customer_name = $is_guest ? $reservation['customer_name'] : $customer['mb_name'];
$customer_hp = $is_guest ? $reservation['customer_phone'] : $customer['mb_hp'];
$customer_email = $is_guest ? $reservation['customer_email'] : $customer['mb_email'];
$expert = get_member($reservation['expert_id']);
$common_vars = [
'customer_name' => $customer_name,
'visit_date' => $reservation['visit_date'],
'visit_time' => $reservation['visit_time'],
'expert_name' => $expert['mb_name'] ?? '미지정',
];
if (!class_exists('NotificationSender')) return false;
$sender = new NotificationSender();
switch ($visit_status) {
case '신규예약':
// 1. 고객에게 신청 완료 알림
$send_params = [
'email_template_key' => 'expert_visit_request_new',
'sms_template_key' => 'expert_visit_request_new',
'vars' => array_merge($common_vars, [
'reservation_id' => $reservation_id,
'expert_visit_fee' => number_format($reservation['payment_amount']) . '원',
'account_info' => get_order_config('expert_visit_account_info', '계좌 정보 미설정'),
]),
];
if ($is_guest) {
// 비회원: 직접 수신자 정보 지정
$send_params['target_type'] = 'direct';
$send_params['receivers'] = [
[
'mb_name' => $customer_name,
'mb_hp' => $customer_hp,
'mb_email' => $customer_email,
'mb_sms' => 1, // SMS 수신 동의 가정
'mb_mailling' => 1 // 메일 수신 동의 가정
]
];
} else {
// 회원: member_id 사용
$send_params['target_type'] = 'single';
$send_params['member_id'] = $customer['mb_id'];
}
$sender->send($send_params);
// 2. 관리자에게 알림
$sender->send([
'target_type' => 'bulk',
'member_levels' => [10],
'email_template_key' => 'admin_new_visit_request',
'sms_template_key' => 'admin_new_visit_request',
'vars' => array_merge($common_vars, [
'customer_phone' => $customer_hp,
'request_memo' => $reservation['request_memo'],
]),
]);
break;
case '예약확정':
// 1. 고객에게 예약 확정 알림
$send_params = [
'email_template_key' => 'expert_visit_confirmed',
'sms_template_key' => 'expert_visit_confirmed',
'vars' => $common_vars,
];
if ($is_guest) {
$send_params['target_type'] = 'direct';
$send_params['receivers'] = [
[
'mb_name' => $customer_name,
'mb_hp' => $customer_hp,
'mb_email' => $customer_email,
'mb_sms' => 1,
'mb_mailling' => 1
]
];
} else {
$send_params['target_type'] = 'single';
$send_params['member_id'] = $customer['mb_id'];
}
$sender->send($send_params);
// 2. 배정된 전문가에게 알림
if ($expert) {
$sender->send([
'target_type' => 'single',
'member_id' => $expert['mb_id'],
'email_template_key' => 'expert_new_assignment',
'sms_template_key' => 'expert_new_assignment',
'vars' => $common_vars,
]);
}
break;
case '예약취소':
// 1. 고객에게 예약 취소 알림
$send_params = [
'email_template_key' => 'expert_visit_cancelled',
'sms_template_key' => 'expert_visit_cancelled',
'vars' => $common_vars,
];
if ($is_guest) {
$send_params['target_type'] = 'direct';
$send_params['receivers'] = [
[
'mb_name' => $customer_name,
'mb_hp' => $customer_hp,
'mb_email' => $customer_email,
'mb_sms' => 1,
'mb_mailling' => 1
]
];
} else {
$send_params['target_type'] = 'single';
$send_params['member_id'] = $customer['mb_id'];
}
$sender->send($send_params);
// 2. 관리자에게 알림
$sender->send([
'target_type' => 'bulk',
'member_levels' => [10],
'email_template_key' => 'admin_visit_cancelled',
'sms_template_key' => 'admin_visit_cancelled',
'vars' => $common_vars,
]);
break;
}
return true;
}
?>
@@ -0,0 +1,380 @@
<?php
if (!defined('_GNUBOARD_'))
exit;
// ❗ [핵심] 필요한 클래스와 라이브러리를 포함합니다.
if (file_exists(G5_ADMIN_PATH . '/mail_manage/classes/MailSender.php')) {
require_once(G5_ADMIN_PATH . '/mail_manage/classes/MailSender.php');
}
if (file_exists(G5_PLUGIN_PATH . '/sms5/sms5.lib.php')) {
// 💡 [수정] 라이브러리 원본 파일(icode.lms.lib.php 등)의 Deprecated 경고를 숨기기 위해 에러 리포팅 임시 조정
// (PHP 8.0+에서 선택적 파라미터 선언 순서 문제로 인한 경고 회피)
$old_reporting_level = error_reporting();
error_reporting($old_reporting_level & ~E_DEPRECATED);
include_once(G5_PLUGIN_PATH . '/sms5/sms5.lib.php');
error_reporting($old_reporting_level);
}
/**
* 통합 메일/SMS 발송 클래스
* 기존 mail_manage, sms_admin 시스템을 활용하여 발송 및 이력 기록
*/
class NotificationSender
{
private $g5;
public function __construct()
{
global $g5;
$this->g5 = $g5;
}
/**
* ❗ [핵심 수정] 템플릿 기반 메인 발송 함수
* @param array $params 발송 파라미터
* @return array 발송 결과
*/
public function send($params)
{
// 파라미터 검증
$validated = $this->validateParams($params);
if (!$validated['success']) {
return $validated;
}
// 대상 회원 조회
$members = $this->getTargetMembers($params);
if (empty($members)) {
return ['success' => false, 'message' => '발송 대상 회원이 없습니다.'];
}
$results = [
'success' => true,
'total_targets' => count($members),
'sms_success' => 0,
'sms_fail' => 0,
'email_success' => 0,
'email_fail' => 0,
'message' => ''
];
// SMS 발송
if (!empty($params['sms_template_key'])) {
$sms_result = $this->sendSMS($members, $params['sms_template_key'], $params['vars'] ?? []);
$results['sms_success'] = $sms_result['success'];
$results['sms_fail'] = $sms_result['fail'];
}
// 이메일 발송
if (!empty($params['email_template_key'])) {
$email_result = $this->sendEmail($members, $params['email_template_key'], $params['vars'] ?? []);
$results['email_success'] = $email_result['success'];
$results['email_fail'] = $email_result['fail'];
}
$results['message'] = $this->generateResultMessage($results);
return $results;
}
/**
* ❗ [핵심 수정] 템플릿 기반 파라미터 검증
*/
private function validateParams($params)
{
if (empty($params['target_type'])) {
return ['success' => false, 'message' => "필수 파라미터 'target_type'이 누락되었습니다."];
}
if ($params['target_type'] === 'single' && empty($params['member_id'])) {
return ['success' => false, 'message' => '단일 발송 시 회원 ID(member_id)가 필요합니다.'];
}
if ($params['target_type'] === 'bulk' && empty($params['member_levels'])) {
return ['success' => false, 'message' => '대량 발송 시 회원 레벨(member_levels)이 필요합니다.'];
}
if ($params['target_type'] === 'direct' && empty($params['receivers'])) {
return ['success' => false, 'message' => '직접 발송 시 수신자 정보(receivers)가 필요합니다.'];
}
if (empty($params['sms_template_key']) && empty($params['email_template_key'])) {
return ['success' => false, 'message' => 'SMS 또는 이메일 템플릿 키 중 하나는 반드시 필요합니다.'];
}
return ['success' => true];
}
/**
* 대상 회원 조회
*/
private function getTargetMembers($params)
{
$members = [];
$member_table = $this->g5['member_table'];
if ($params['target_type'] === 'single') {
$sql = "SELECT mb_id, mb_name, mb_hp, mb_email, mb_sms, mb_mailling FROM `{$member_table}` WHERE mb_id = '" . sql_real_escape_string($params['member_id']) . "' AND mb_leave_date = '' AND mb_intercept_date = ''";
$this->write_debug_log("[SMS 발송 시작] sql '{$sql}'");
$result = sql_query($sql);
while ($row = sql_fetch_array($result)) {
$members[] = $row;
}
} elseif ($params['target_type'] === 'bulk') {
$levels = array_map('intval', $params['member_levels']);
$level_condition = implode(',', $levels);
$sql = "SELECT mb_id, mb_name, mb_hp, mb_email, mb_sms, mb_mailling FROM `{$member_table}` WHERE mb_level IN ({$level_condition}) AND mb_leave_date = '' AND mb_intercept_date = ''";
$this->write_debug_log("[SMS 발송 시작] sql '{$sql}'");
$result = sql_query($sql);
while ($row = sql_fetch_array($result)) {
$members[] = $row;
}
} elseif ($params['target_type'] === 'direct') {
// 직접 입력된 수신자 정보 사용
$members = $params['receivers'];
}
return $members;
}
/**
* ❗ [핵심 수정] SMS 발송 (템플릿 및 변수 처리, 이력 기록 포함)
*/
private function sendSMS($members, $template_key, $common_vars)
{
global $config; // config 전역 변수 사용
$success = 0;
$fail = 0;
$notification_mode = get_order_config('notification_mode', 'log');
$is_test_mode = ($notification_mode !== 'send');
$sizeof_members = count($members);
$template = sql_fetch("SELECT * FROM `order_sms_templates` WHERE template_key = '" . sql_real_escape_string($template_key) . "' ");
if (!$template) {
$this->write_debug_log("[SMS 발송 오류] 템플릿 '{$template_key}'을(를) 찾을 수 없습니다.");
return ['success' => 0, 'fail' => count($members)];
}
$count= count($members);
if ($is_test_mode) {
// --- 개발 모드: 로그 파일에만 기록 ---
foreach ($members as $member) {
if ($member['mb_sms'] && !empty($member['mb_hp'])) {
$personal_vars = array_merge($common_vars, ['이름' => $member['mb_name'], 'agent_name' => $member['mb_name'], 'dealer_name' => $member['mb_name']]);
$personal_message = $template['content'];
foreach ($personal_vars as $key => $value) {
$personal_message = str_replace('{' . $key . '}', $value, $personal_message);
}
$this->write_debug_log("[SMS LOG] To: {$member['mb_hp']}, Content: {$personal_message}");
$success++;
} else {
$empty = !empty($member['mb_hp']);
$this->write_debug_log("[SMS 발송오류] 사용자 '{$member['mb_sms']}' '{$member['mb_hp']}' '$empty'");
$fail++;
}
}
} else {
// --- 실제 발송 모드: DB 기록 및 실제 발송 ---
$sms_config = sql_fetch("SELECT * FROM {$this->g5['sms5_config_table']}");
$send_phone = $sms_config['cf_phone'];
$wr_message = sql_real_escape_string($template['content']);
$wr_reply = sql_real_escape_string($send_phone);
sql_query("INSERT INTO {$this->g5['sms5_write_table']} (wr_message, wr_reply, wr_total, wr_datetime) VALUES ('{$wr_message}', '{$wr_reply}', '" . count($members) . "', '" . G5_TIME_YMDHIS . "')");
$wr_no = sql_insert_id();
$SMS = null;
if (class_exists('SMS5')) {
$SMS = new SMS5;
$SMS->SMS_con($sms_config['cf_sms_ip'], $sms_config['cf_sms_id'], $sms_config['cf_sms_pw'], $sms_config['cf_sms_port']);
} else {
$this->write_debug_log("[SMS 발송 오류] SMS5 클래스를 찾을 수 없습니다. 실제 발송을 건너뜁니다.");
}
foreach ($members as $member) {
if ($member['mb_sms'] && !empty($member['mb_hp'])) {
$personal_vars = array_merge($common_vars, ['이름' => $member['mb_name'], 'agent_name' => $member['mb_name'], 'dealer_name' => $member['mb_name']]);
$personal_message = $template['content'];
foreach ($personal_vars as $key => $value) {
$personal_message = str_replace('{' . $key . '}', $value, $personal_message);
}
$result_code = 'Fail';
$result_msg = 'SMS5 클래스가 없어 발송할 수 없습니다.';
$hs_status = '0';
$hs_code = '';
if ($SMS) {
// ❗ [수정] SMS/LMS 타입에 따라 함수 호출 분기 및 파라미터 순서 명시
if($config['cf_sms_type'] == 'LMS') {
// LMS: Add($strDest, $strCallBack, $strCaller, $strSubject, $strURL, $strData, $strDate, $nCount)
// $strDest는 배열로 전달
$SMS->Add(array($member['mb_hp']), $send_phone, '', '', '', $personal_message, '', 1);
} else {
// SMS: Add2($strDest, $strCallBack, $strCaller, $strURL, $strMessage, $strDate, $nCount)
// $strDest는 배열(구조체)로 전달
$dest = array(array('bk_hp' => $member['mb_hp'], 'bk_name' => $member['mb_name']));
$SMS->Add2($dest, $send_phone, '', '', $personal_message, '', 1);
}
$SMS->Send();
$result_arr = $SMS->Result;
$result_code = 'Fail';
$result_msg = '서버로부터 응답이 없습니다.';
if(!empty($result_arr)){
// 💡 [추가] 상세 에러 코드 처리 (sms_write_send.php 참고)
foreach ($result_arr as $result) {
list($phone, $code) = explode(":", $result);
if (substr($code, 0, 5) == "Error") {
$hs_code = substr($code, 6, 2);
switch ($hs_code) {
case '02': $result_msg = "형식이 잘못되어 전송이 실패하였습니다."; break;
case '23': $result_msg = "데이터를 다시 확인해 주시기바랍니다."; break;
case '97': $result_msg = "잔여코인이 부족합니다."; break;
case '98': $result_msg = "사용기간이 만료되었습니다."; break;
case '99': $result_msg = "인증 받지 못하였습니다. 계정을 다시 확인해 주세요."; break;
default: $result_msg = "알 수 없는 오류로 전송이 실패하였습니다."; break;
}
$result_code = 'Fail';
} else {
$hs_code = $code;
$result_msg = "전송했습니다.";
$result_code = 'Success';
}
}
}
$hs_status = ($result_code == 'Success') ? '1' : '0';
$SMS->Init();
}
$mb_id = isset($member['mb_id']) ? $member['mb_id'] : ''; // 비회원일 경우 mb_id 없음
sql_query("INSERT INTO {$this->g5['sms5_history_table']} (wr_no, mb_id, hs_name, hs_hp, hs_datetime, hs_status, hs_code, hs_message) VALUES ('{$wr_no}', '{$mb_id}', '{$member['mb_name']}', '{$member['mb_hp']}', '" . G5_TIME_YMDHIS . "', '{$hs_status}', '{$hs_code}', '{$result_msg}')");
if ($hs_status == '1') $success++;
else $fail++;
} else {
$fail++;
}
}
// 💡 [추가] 발송 완료 후 마스터 테이블 업데이트
sql_query("UPDATE {$this->g5['sms5_write_table']} SET wr_success = '{$success}', wr_failure = '{$fail}' WHERE wr_no = '{$wr_no}'");
}
return ['success' => $success, 'fail' => $fail];
}
/**
* ❗ [핵심 수정] 이메일 발송 (MailSender 클래스 활용)
*/
private function sendEmail($members, $template_key, $common_vars)
{
$success = 0;
$fail = 0;
$notification_mode = get_order_config('notification_mode', 'log');
$is_test_mode = ($notification_mode !== 'send');
$sizeof_members = count($members);
$template = sql_fetch("SELECT * FROM `order_mail_templates` WHERE template_key = '" . sql_real_escape_string($template_key) . "'");
if (!$template) {
$this->write_debug_log("[EMAIL 발송 오류] 템플릿 '{$template_key}'을(를) 찾을 수 없습니다.");
return ['success' => 0, 'fail' => count($members)];
}
if ($is_test_mode) {
// --- 개발 모드: 로그 파일에만 기록 ---
foreach ($members as $member) {
if ($member['mb_mailling'] && !empty($member['mb_email'])) {
$personal_vars = array_merge($common_vars, ['이름' => $member['mb_name'], 'agent_name' => $member['mb_name'], 'dealer_name' => $member['mb_name']]);
$subject = $template['subject'];
$content = $template['content'];
foreach ($personal_vars as $key => $value) {
$search = '{' . $key . '}';
$subject = str_replace($search, $value, $subject);
$content = str_replace($search, $value, $content);
}
$this->write_debug_log("[EMAIL LOG] To: {$member['mb_email']}, Subject: {$subject}, Content: {$content}");
$success++;
} else {
$fail++;
$empty = !empty($member['mb_email']);
$this->write_debug_log("[EMAIL 발송 오류] 사용자 '{$member['mb_sms']}' '{$member['mb_hp']}' '$empty'");
}
}
} else {
// --- 실제 발송 모드: MailSender 호출 ---
if (!class_exists('MailSender')) {
$this->write_debug_log("[EMAIL 발송 오류] MailSender 클래스를 찾을 수 없습니다.");
return ['success' => 0, 'fail' => count($members)];
}
$mailSender = new MailSender();
foreach ($members as $member) {
if ($member['mb_mailling'] && !empty($member['mb_email'])) {
$personal_vars = array_merge($common_vars, ['이름' => $member['mb_name'], 'agent_name' => $member['mb_name'], 'dealer_name' => $member['mb_name']]);
$subject = $template['subject'];
$content = $template['content'];
foreach ($personal_vars as $key => $value) {
$search = '{' . $key . '}';
$subject = str_replace($search, $value, $subject);
$content = str_replace($search, $value, $content);
}
if($mailSender->sendSimple($member['mb_email'], $subject,$content)){
$success++;
} else {
$fail++;
}
} else {
$fail++;
$empty = !empty($member['mb_email']);
$this->write_debug_log("[EMAIL 발송 오류] 사용자 '{$member['mb_sms']}' '{$member['mb_hp']}' '$empty'");
}
}
}
return ['success' => $success, 'fail' => $fail];
}
/**
* 결과 메시지 생성
*/
private function generateResultMessage($results)
{
$message = "발송이 완료되었습니다.\n\n";
$message .= "전체 대상: " . $results['total_targets'] . "\n\n";
if (isset($results['sms_success'])) {
$message .= "SMS 발송 결과: 성공 " . $results['sms_success'] . "건, 실패 " . $results['sms_fail'] . "\n";
}
if (isset($results['email_success'])) {
$message .= "이메일 발송 결과: 성공 " . $results['email_success'] . "건, 실패 " . $results['email_fail'] . "\n";
}
return $message;
}
/**
* ❗ [핵심 수정] 디버그 로그 기록 함수 (권한 문제 해결)
*/
private function write_debug_log($message)
{
$log_dir = G5_PATH . '/log';
// 1. 디렉토리 존재 여부 확인 및 생성
if (!is_dir($log_dir)) {
if (!@mkdir($log_dir, 0755, true) && !is_dir($log_dir)) {
error_log("--- NotificationSender ERROR: 디버그 로그 디렉토리 생성 실패. '{$log_dir}' 경로를 확인하거나 수동으로 생성 후 웹서버 쓰기 권한을 부여해주세요.");
return;
}
}
// 2. 디렉토리 쓰기 권한 확인
if (!is_writable($log_dir)) {
error_log("--- NotificationSender ERROR: 디버그 로그 쓰기 오류. '{$log_dir}' 디렉토리에 쓰기 권한이 없습니다. 웹서버의 폴더 권한을 확인해주세요.");
return;
}
// 3. 로그 파일에 내용 기록
$log_file = $log_dir . '/notification_debug.log';
$log_message = date("[Y-m-d H:i:s]") . " " . $message . "\n";
if (file_put_contents($log_file, $log_message, FILE_APPEND | LOCK_EX) === false) {
error_log("--- NotificationSender ERROR: 디버그 로그 파일 쓰기 실패. '{$log_file}' 파일에 내용을 쓸 수 없습니다.");
}
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
$sub_menu = "800500";
include_once("./_common.php");
auth_check_menu($auth, $sub_menu, "r");
$id = (int) $_GET['id'];
$template = sql_fetch("SELECT * FROM order_mail_templates WHERE id = {$id}");
if (!$template) {
alert('템플릿을 찾을 수 없습니다.', './mail_templates.php');
}
$g5['title'] = "메일 템플릿 수정";
include_once(G5_ADMIN_PATH . '/admin.head.php');
$variables = json_decode($template['variables'], true);
?>
<div class="local_ov01 local_ov">
메일 템플릿 수정 - <?php echo htmlspecialchars($template['template_name']); ?>
</div>
<form name="templateForm" method="post" action="mail_template_update.php">
<input type="hidden" name="id" value="<?php echo $template['id']; ?>">
<div class="tbl_frm01 tbl_wrap">
<table>
<caption>메일 템플릿 수정</caption>
<colgroup>
<col class="grid_4">
<col>
</colgroup>
<tbody>
<tr>
<th scope="row">템플릿명</th>
<td>
<input type="text" name="template_name"
value="<?php echo htmlspecialchars($template['template_name']); ?>" class="frm_input"
style="width:100%;" required>
</td>
</tr>
<tr>
<th scope="row">메일 제목</th>
<td>
<input type="text" name="subject" value="<?php echo htmlspecialchars($template['subject']); ?>"
class="frm_input" style="width:100%;" required>
</td>
</tr>
<tr>
<th scope="row">메일 내용</th>
<td>
<textarea name="content" class="frm_input" style="width:100%; height:300px;"
required><?php echo htmlspecialchars($template['content']); ?></textarea>
<div class="frm_info">HTML 태그 사용 가능합니다.</div>
</td>
</tr>
<tr>
<th scope="row">사용 가능한 변수</th>
<td>
<?php if (is_array($variables)) { ?>
<?php foreach ($variables as $var) { ?>
<span class="variable-tag">{<?php echo $var; ?>}</span>
<?php } ?>
<?php } ?>
<div class="frm_info">위 변수들을 메일 제목과 내용에 사용할 수 있습니다.</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="btn_confirm01 btn_confirm">
<input type="submit" value="저장" class="btn_submit">
<a href="mail_templates.php" class="btn btn_02">목록</a>
</div>
</form>
<style>
.variable-tag {
display: inline-block;
background: #e3f2fd;
color: #1976d2;
padding: 2px 8px;
margin: 2px;
border-radius: 12px;
font-size: 12px;
font-family: monospace;
}
</style>
<?php
include_once(G5_ADMIN_PATH . '/admin.tail.php');
?>
+26
View File
@@ -0,0 +1,26 @@
<?php
$sub_menu = "800500";
include_once("./_common.php");
auth_check_menu($auth, $sub_menu, "w");
if ($_POST) {
$id = (int) $_POST['id'];
$template_name = sql_real_escape_string($_POST['template_name']);
$subject = sql_real_escape_string($_POST['subject']);
$content = sql_real_escape_string($_POST['content']);
$sql = "UPDATE order_mail_templates SET
template_name = '{$template_name}',
subject = '{$subject}',
content = '{$content}',
updated_at = NOW()
WHERE id = {$id}";
sql_query($sql);
alert('메일 템플릿이 수정되었습니다.', './mail_templates.php');
} else {
alert('잘못된 접근입니다.', './mail_templates.php');
}
?>
+416
View File
@@ -0,0 +1,416 @@
<?php
$sub_menu = "800500";
include_once('./_common.php');
auth_check($auth[$sub_menu], 'r');
$g5['title'] = '이메일 템플릿 관리';
// 액션 처리
$action = isset($_REQUEST['action']) ? clean_xss_tags($_REQUEST['action']) : '';
$id = (int) ($_REQUEST['id'] ?? 0);
// POST 요청 처리 (생성, 수정, 삭제)
if ($action && $_SERVER['REQUEST_METHOD'] === 'POST') {
auth_check($auth[$sub_menu], 'w');
try {
if ($action === 'save') {
$template_key = clean_xss_tags($_POST['template_key'] ?? '');
$template_name = clean_xss_tags($_POST['template_name'] ?? '');
$subject = clean_xss_tags($_POST['subject'] ?? '');
$content = $_POST['content'] ?? ''; // HTML 내용이므로 clean_xss_tags 사용하지 않음
$variables = clean_xss_tags($_POST['variables'] ?? '');
if (!$template_key || !$template_name || !$subject || !$content) {
throw new Exception('필수 항목을 모두 입력해주세요.');
}
// 템플릿 키 중복 확인 (수정 시 제외)
if ($id === 0) {
$existing = sql_fetch("SELECT id FROM order_mail_templates WHERE template_key = '{$template_key}'");
if ($existing) {
throw new Exception('이미 존재하는 템플릿 키입니다.');
}
}
$data = [
'template_key' => $template_key,
'template_name' => $template_name,
'subject' => $subject,
'content' => $content,
'variables' => $variables,
'updated_at' => date('Y-m-d H:i:s')
];
if ($id > 0) {
// 수정
$set_clauses = [];
foreach ($data as $key => $value) {
$set_clauses[] = "`{$key}` = '" . sql_real_escape_string($value) . "'";
}
$sql = "UPDATE order_mail_templates SET " . implode(', ', $set_clauses) . " WHERE id = '{$id}'";
sql_query($sql);
$message = '이메일 템플릿이 수정되었습니다.';
} else {
// 생성
$data['created_at'] = date('Y-m-d H:i:s');
$fields = array_keys($data);
$values = array_values($data);
$fields_str = '`' . implode('`, `', $fields) . '`';
$values_str = "'" . implode("', '", array_map('sql_real_escape_string', $values)) . "'";
$sql = "INSERT INTO order_mail_templates ({$fields_str}) VALUES ({$values_str})";
sql_query($sql);
$message = '이메일 템플릿이 생성되었습니다.';
}
alert($message, './mail_templates.php');
}
if ($action === 'delete') {
if ($id > 0) {
sql_query("DELETE FROM order_mail_templates WHERE id = '{$id}'");
alert('이메일 템플릿이 삭제되었습니다.', './mail_templates.php');
}
}
} catch (Exception $e) {
alert('오류: ' . $e->getMessage());
}
goto_url('./mail_templates.php');
exit;
}
// GET 요청 처리 (목록, 편집 폼)
$mode = isset($_GET['mode']) ? clean_xss_tags($_GET['mode']) : 'list';
$template = null;
if ($mode === 'edit' && $id > 0) {
$template = sql_fetch("SELECT * FROM order_mail_templates WHERE id = '{$id}'");
if (!$template) {
alert('존재하지 않는 템플릿입니다.', './mail_templates.php');
}
}
// 템플릿 목록 조회
$templates = [];
$sql = "SELECT * FROM order_mail_templates ORDER BY template_key ASC";
$result = sql_query($sql);
while ($row = sql_fetch_array($result)) {
$templates[] = $row;
}
include_once(G5_ADMIN_PATH . '/admin.head.php');
?>
<div class="local_desc01 local_desc">
<p>
이메일 알림에 사용되는 템플릿을 관리합니다.<br>
변수를 사용하여 동적인 내용을 삽입할 수 있습니다. 예: {customer_name}, {estimate_id}
</p>
</div>
<?php if ($mode === 'list'): ?>
<!-- 템플릿 목록 -->
<div class="local_ov01 local_ov">
<span class="btn_ov01">
<span class="ov_txt">전체 템플릿 </span>
<span class="ov_num"><?php echo count($templates); ?>개</span>
</span>
<a href="?mode=edit" class="btn btn_02">새 템플릿 추가</a>
</div>
<div class="tbl_head01 tbl_wrap">
<table>
<caption><?php echo $g5['title']; ?> 목록</caption>
<thead>
<tr>
<th scope="col">템플릿 키</th>
<th scope="col">템플릿명</th>
<th scope="col">제목</th>
<th scope="col">변수</th>
<th scope="col">수정일</th>
<th scope="col">관리</th>
</tr>
</thead>
<tbody>
<?php if (empty($templates)): ?>
<tr>
<td colspan="6" class="empty_table">등록된 템플릿이 없습니다.</td>
</tr>
<?php else: ?>
<?php foreach ($templates as $tpl): ?>
<tr>
<td class="td_left">
<code><?php echo get_text($tpl['template_key']); ?></code>
</td>
<td class="td_left">
<strong><?php echo get_text($tpl['template_name']); ?></strong>
</td>
<td class="td_left">
<?php echo get_text($tpl['subject']); ?>
</td>
<td class="td_left">
<?php
$variables = json_decode($tpl['variables'], true);
if (is_array($variables)) {
echo '<small>' . implode(', ', array_map(function ($var) {
return '{' . $var . '}'; }, $variables)) . '</small>';
}
?>
</td>
<td class="td_datetime">
<?php echo $tpl['updated_at'] ? date('Y-m-d H:i', strtotime($tpl['updated_at'])) : '-'; ?>
</td>
<td class="td_mng td_mng_s">
<a href="?mode=edit&id=<?php echo $tpl['id']; ?>" class="btn btn_03">수정</a>
<a href="javascript:void(0);" onclick="previewTemplate(<?php echo $tpl['id']; ?>)"
class="btn btn_04">미리보기</a>
<a href="javascript:void(0);" onclick="deleteTemplate(<?php echo $tpl['id']; ?>)"
class="btn btn_02">삭제</a>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<?php else: ?>
<!-- 템플릿 편집 폼 -->
<div class="local_desc02 local_desc">
<p>
<a href="./mail_templates.php" class="btn btn_01">목록으로 돌아가기</a>
</p>
</div>
<form name="ftemplate" method="post" onsubmit="return validateForm()">
<input type="hidden" name="action" value="save">
<input type="hidden" name="id" value="<?php echo $template['id'] ?? 0; ?>">
<div class="tbl_frm01 tbl_wrap">
<table>
<caption><?php echo $template ? '템플릿 수정' : '새 템플릿 추가'; ?></caption>
<colgroup>
<col class="grid_4">
<col>
</colgroup>
<tbody>
<tr>
<th scope="row"><label for="template_key">템플릿 키<strong class="sound_only">필수</strong></label></th>
<td>
<input type="text" name="template_key" id="template_key"
value="<?php echo get_text($template['template_key'] ?? ''); ?>" class="frm_input"
maxlength="100" required <?php echo $template ? 'readonly' : ''; ?>>
<span class="frm_info">영문, 숫자, 언더스코어만 사용 가능 (예: customer_request_complete)</span>
</td>
</tr>
<tr>
<th scope="row"><label for="template_name">템플릿명<strong class="sound_only">필수</strong></label></th>
<td>
<input type="text" name="template_name" id="template_name"
value="<?php echo get_text($template['template_name'] ?? ''); ?>" class="frm_input"
maxlength="255" required>
</td>
</tr>
<tr>
<th scope="row"><label for="subject">이메일 제목<strong class="sound_only">필수</strong></label></th>
<td>
<input type="text" name="subject" id="subject"
value="<?php echo get_text($template['subject'] ?? ''); ?>" class="frm_input"
maxlength="255" required>
<span class="frm_info">변수 사용 가능: {customer_name}, {estimate_id} 등</span>
</td>
</tr>
<tr>
<th scope="row"><label for="content">이메일 내용<strong class="sound_only">필수</strong></label></th>
<td>
<textarea name="content" id="content" rows="15" class="frm_input"
required><?php echo get_text($template['content'] ?? ''); ?></textarea>
<span class="frm_info">HTML 태그 사용 가능. 변수는 {변수명} 형태로 입력</span>
</td>
</tr>
<tr>
<th scope="row"><label for="variables">사용 가능한 변수</label></th>
<td>
<input type="text" name="variables" id="variables"
value="<?php echo get_text($template['variables'] ?? ''); ?>" class="frm_input"
placeholder='["customer_name", "estimate_id", "total_amount"]'>
<span class="frm_info">JSON 배열 형태로 입력 (예: ["customer_name", "estimate_id"])</span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="btn_confirm01 btn_confirm">
<input type="submit" value="<?php echo $template ? '수정' : '등록'; ?>" class="btn_submit">
<a href="./mail_templates.php" class="btn_cancel">취소</a>
</div>
</form>
<?php endif; ?>
<!-- 미리보기 모달 -->
<div id="previewModal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3>템플릿 미리보기</h3>
<span class="close" onclick="closePreview()">&times;</span>
</div>
<div class="modal-body">
<div id="previewContent"></div>
</div>
</div>
</div>
<style>
.modal {
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
}
.modal-content {
background-color: #fefefe;
margin: 5% auto;
padding: 0;
border: 1px solid #888;
width: 80%;
max-width: 800px;
border-radius: 5px;
}
.modal-header {
padding: 15px 20px;
background-color: #f8f9fa;
border-bottom: 1px solid #dee2e6;
border-radius: 5px 5px 0 0;
}
.modal-header h3 {
margin: 0;
display: inline-block;
}
.close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
.close:hover {
color: black;
}
.modal-body {
padding: 20px;
max-height: 500px;
overflow-y: auto;
}
code {
background-color: #f8f9fa;
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
}
</style>
<script>
// 폼 유효성 검사
function validateForm() {
const templateKey = document.getElementById('template_key').value.trim();
const templateName = document.getElementById('template_name').value.trim();
const subject = document.getElementById('subject').value.trim();
const content = document.getElementById('content').value.trim();
if (!templateKey || !templateName || !subject || !content) {
alert('필수 항목을 모두 입력해주세요.');
return false;
}
// 템플릿 키 형식 검사
if (!/^[a-zA-Z0-9_]+$/.test(templateKey)) {
alert('템플릿 키는 영문, 숫자, 언더스코어만 사용할 수 있습니다.');
return false;
}
// 변수 JSON 형식 검사
const variables = document.getElementById('variables').value.trim();
if (variables) {
try {
JSON.parse(variables);
} catch (e) {
alert('변수는 올바른 JSON 배열 형태로 입력해주세요.');
return false;
}
}
return true;
}
// 템플릿 삭제
function deleteTemplate(id) {
if (!confirm('정말로 이 템플릿을 삭제하시겠습니까?')) {
return;
}
const form = document.createElement('form');
form.method = 'POST';
form.innerHTML = `
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="${id}">
`;
document.body.appendChild(form);
form.submit();
}
// 템플릿 미리보기
function previewTemplate(id) {
fetch('mail_templates_ajax.php?action=preview&id=' + id)
.then(response => response.json())
.then(data => {
if (data.success) {
document.getElementById('previewContent').innerHTML = `
<h4>제목: ${data.data.subject}</h4>
<div style="border: 1px solid #ddd; padding: 15px; background: white;">
${data.data.content}
</div>
<p><small>사용 가능한 변수: ${data.data.variables || '없음'}</small></p>
`;
document.getElementById('previewModal').style.display = 'block';
} else {
alert('미리보기를 불러올 수 없습니다: ' + data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('미리보기 중 오류가 발생했습니다.');
});
}
// 미리보기 닫기
function closePreview() {
document.getElementById('previewModal').style.display = 'none';
}
// 모달 외부 클릭 시 닫기
window.onclick = function (event) {
const modal = document.getElementById('previewModal');
if (event.target === modal) {
closePreview();
}
}
</script>
<?php
include_once(G5_ADMIN_PATH . '/admin.tail.php');
?>
+47
View File
@@ -0,0 +1,47 @@
<?php
include_once('./_common.php');
// AJAX 요청만 처리
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'GET 요청만 허용됩니다.']);
exit;
}
$action = isset($_GET['action']) ? clean_xss_tags($_GET['action']) : '';
$id = (int) ($_GET['id'] ?? 0);
header('Content-Type: application/json; charset=utf-8');
if ($action === 'preview' && $id > 0) {
$template = sql_fetch("SELECT * FROM order_mail_templates WHERE id = '{$id}'");
if ($template) {
// 변수들을 예시 값으로 치환
$variables = json_decode($template['variables'], true);
$content = $template['content'];
$subject = $template['subject'];
if (is_array($variables)) {
foreach ($variables as $var) {
$placeholder = '{' . $var . '}';
$example_value = '[' . $var . ']'; // 예: [customer_name]
$content = str_replace($placeholder, $example_value, $content);
$subject = str_replace($placeholder, $example_value, $subject);
}
}
echo json_encode([
'success' => true,
'data' => [
'subject' => $subject,
'content' => $content,
'variables' => $template['variables']
]
], JSON_UNESCAPED_UNICODE);
} else {
echo json_encode(['success' => false, 'message' => '템플릿을 찾을 수 없습니다.']);
}
} else {
echo json_encode(['success' => false, 'message' => '잘못된 요청입니다.']);
}
+81
View File
@@ -0,0 +1,81 @@
<?php
$sub_menu = "800700"; // 새로운 메뉴 코드
include_once('./_common.php');
auth_check($auth[$sub_menu], 'r');
$g5['title'] = '알림 로그 관리';
$log_file = G5_PATH . '/log/notification_debug.log';
$action = isset($_POST['action']) ? clean_xss_tags($_POST['action']) : '';
// 로그 파일 비우기 처리
if ($action === 'clear_log' && $_SERVER['REQUEST_METHOD'] === 'POST') {
auth_check($auth[$sub_menu], 'w');
if (file_exists($log_file)) {
// 파일을 0바이트로 만듭니다.
file_put_contents($log_file, '');
alert('알림 로그 파일의 내용을 모두 비웠습니다.', './notification_log.php');
} else {
alert('로그 파일이 존재하지 않습니다.', './notification_log.php');
}
exit;
}
// 로그 파일 내용 읽기
$log_content = '';
if (file_exists($log_file) && filesize($log_file) > 0) {
// 파일을 라인별로 배열로 읽어옵니다.
$lines = file($log_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
// 배열을 뒤집어 최신 로그가 위로 오게 합니다.
$reversed_lines = array_reverse($lines);
// 다시 문자열로 합칩니다.
$log_content = implode("\n", $reversed_lines);
}
include_once(G5_ADMIN_PATH . '/admin.head.php');
?>
<div class="local_desc01 local_desc">
<p>
시스템에서 발송된 메일 및 SMS의 발송 기록(로그)을 확인합니다.<br>
로그 파일은 <code>/log/notification_debug.log</code> 에 저장되며, 최신 기록이 가장 위에 표시됩니다.
</p>
</div>
<div class="local_ov01 local_ov">
<form name="fclearlog" method="post" action="./notification_log.php" onsubmit="return confirm('정말로 로그 파일의 모든 내용을 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.');">
<input type="hidden" name="action" value="clear_log">
<input type="submit" value="로그 비우기" class="btn btn_02">
</form>
</div>
<section id="sod_fin">
<h2 class="h2_frm">로그 내용</h2>
<div class="tbl_frm01 tbl_wrap">
<textarea id="log_view" readonly style="width: 100%; height: 600px; padding: 10px; border: 1px solid #ccc; background: #f9f9f9; font-family: monospace; font-size: 12px; line-height: 1.5; resize: vertical;"><?php echo htmlspecialchars($log_content); ?></textarea>
</div>
</section>
<?php if (empty($log_content)): ?>
<div class="local_desc02 local_desc">
<p>
로그 파일이 없거나 내용이 비어있습니다.
p>
</div>
<?php endif; ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
const textarea = document.getElementById('log_view');
if (textarea) {
// 페이지 로드 시 스크롤을 맨 위로 이동
textarea.scrollTop = 0;
}
});
</script>
<?php
include_once(G5_ADMIN_PATH . '/admin.tail.php');
?>
+380
View File
@@ -0,0 +1,380 @@
<?php
$sub_menu = "800750";
include_once('./_common.php');
auth_check($auth[$sub_menu], 'r');
$g5['title'] = '알림 발송 테스트';
// NotificationHelper 로드
require_once G5_PATH . '/adm/order_manage/classes/NotificationHelper.class.php';
// 액션 처리
$action = isset($_REQUEST['action']) ? clean_xss_tags($_REQUEST['action']) : '';
if ($action && $_SERVER['REQUEST_METHOD'] === 'POST') {
auth_check($auth[$sub_menu], 'w');
try {
if ($action === 'test_notification') {
$template_type = clean_xss_tags($_POST['template_type'] ?? '');
$template_key = clean_xss_tags($_POST['template_key'] ?? '');
$notification_type = clean_xss_tags($_POST['notification_type'] ?? '');
$test_recipient = clean_xss_tags($_POST['test_recipient'] ?? '');
if (!$template_key || !$notification_type || !$test_recipient) {
throw new Exception('필수 항목을 모두 입력해주세요.');
}
// 수신자 유효성 검증
if ($notification_type === 'email' && !filter_var($test_recipient, FILTER_VALIDATE_EMAIL)) {
throw new Exception('유효하지 않은 이메일 주소입니다.');
}
if ($notification_type === 'sms') {
$test_recipient = preg_replace('/[^0-9]/', '', $test_recipient);
if (!preg_match('/^01[0-9]{8,9}$/', $test_recipient)) {
throw new Exception('유효하지 않은 전화번호입니다.');
}
}
// 테스트 데이터 준비
$test_data = [
'customer_name' => '테스트 고객',
'customer_email' => $notification_type === 'email' ? $test_recipient : 'test@example.com',
'customer_phone' => $notification_type === 'sms' ? $test_recipient : '01012345678',
'estimate_id' => 'TEST-001',
'estimate_subject' => '테스트 견적 요청',
'total_amount' => '1,500,000',
'visit_fee' => '50,000',
'account_info' => '국민은행 123-456-789012 (주)창호전문',
'dealer_name' => '테스트 대리점',
'construction_date' => '2024-02-15',
'visit_datetime' => '2024-02-10 14:00',
'interim_payment_date' => '2024-02-13',
'days_remaining' => '2',
'bid_amount' => '1,200,000',
'selected_date' => date('Y-m-d H:i')
];
$notification_helper = new NotificationHelper();
// 알림 발송
if ($template_type === 'customer') {
$result = $notification_helper->sendCustomerNotification($template_key, $test_data);
} elseif ($template_type === 'dealer') {
$result = $notification_helper->sendDealerNotification($template_key, $test_data);
} else {
throw new Exception('유효하지 않은 템플릿 타입입니다.');
}
if ($result['success']) {
$message = '테스트 알림이 성공적으로 발송되었습니다.';
if (isset($result['debug_info'])) {
$message .= '\n\n디버그 정보: ' . $result['debug_info'];
}
alert($message);
} else {
throw new Exception('알림 발송 실패: ' . $result['message']);
}
}
} catch (Exception $e) {
alert('오류: ' . $e->getMessage());
}
goto_url('./notification_test.php');
exit;
}
// 템플릿 목록 조회
$email_templates = [];
$sms_templates = [];
$email_sql = "SELECT template_key, template_name FROM order_mail_templates ORDER BY template_key ASC";
$email_result = sql_query($email_sql);
while ($row = sql_fetch_array($email_result)) {
$email_templates[] = $row;
}
$sms_sql = "SELECT template_key, template_name FROM order_sms_templates ORDER BY template_key ASC";
$sms_result = sql_query($sms_sql);
while ($row = sql_fetch_array($sms_result)) {
$sms_templates[] = $row;
}
include_once(G5_ADMIN_PATH . '/admin.head.php');
?>
<div class="local_desc01 local_desc">
<p>
이메일 및 SMS 알림 템플릿의 실제 발송을 테스트할 수 있습니다.<br>
개발 단계에서는 알림창으로 표시되며, 운영 단계에서는 실제 발송됩니다.
</p>
</div>
<!-- 알림 발송 테스트 -->
<div class="tbl_frm01 tbl_wrap">
<form name="ftest" method="post" onsubmit="return validateTestForm()">
<input type="hidden" name="action" value="test_notification">
<table>
<caption>알림 발송 테스트</caption>
<colgroup>
<col class="grid_4">
<col>
</colgroup>
<tbody>
<tr>
<th scope="row"><label for="template_type">템플릿 타입<strong class="sound_only">필수</strong></label></th>
<td>
<select name="template_type" id="template_type" class="frm_input"
onchange="updateTemplateList()" required>
<option value="">선택하세요</option>
<option value="customer">고객 알림</option>
<option value="dealer">대리점 알림</option>
</select>
</td>
</tr>
<tr>
<th scope="row"><label for="template_key">템플릿<strong class="sound_only">필수</strong></label></th>
<td>
<select name="template_key" id="template_key" class="frm_input"
onchange="updateNotificationTypes()" required>
<option value="">먼저 템플릿 타입을 선택하세요</option>
</select>
</td>
</tr>
<tr>
<th scope="row"><label for="notification_type">발송 방식<strong class="sound_only">필수</strong></label>
</th>
<td>
<label><input type="radio" name="notification_type" value="email"
onchange="updateRecipientField()"> 이메일</label>
<label><input type="radio" name="notification_type" value="sms"
onchange="updateRecipientField()"> SMS</label>
<label><input type="radio" name="notification_type" value="both"
onchange="updateRecipientField()"> 둘 다</label>
</td>
</tr>
<tr>
<th scope="row"><label for="test_recipient">테스트 수신자<strong class="sound_only">필수</strong></label>
</th>
<td>
<input type="text" name="test_recipient" id="test_recipient" class="frm_input" required>
<span class="frm_info" id="recipient_info">발송 방식을 먼저 선택하세요</span>
</td>
</tr>
</tbody>
</table>
<div class="btn_confirm01 btn_confirm">
<input type="submit" value="테스트 발송" class="btn_submit">
</div>
</form>
</div>
<!-- 발송 모드 설정 -->
<div class="tbl_frm01 tbl_wrap" style="margin-top: 30px;">
<table>
<caption>발송 모드 설정</caption>
<colgroup>
<col class="grid_4">
<col>
</colgroup>
<tbody>
<tr>
<th scope="row">현재 모드</th>
<td>
<?php
// 개발/운영 모드 확인 (설정에서 가져오거나 기본값 사용)
$notification_mode = 'development'; // 실제로는 설정에서 가져와야 함
?>
<strong><?php echo $notification_mode === 'development' ? '개발 모드 (알림창 표시)' : '운영 모드 (실제 발송)'; ?></strong>
</td>
</tr>
<tr>
<th scope="row">모드 설명</th>
<td>
<ul>
<li><strong>개발 모드:</strong> 실제 발송 대신 알림창으로 내용을 표시합니다.</li>
<li><strong>운영 모드:</strong> 실제 이메일/SMS를 발송합니다.</li>
</ul>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 템플릿 변수 참조 -->
<div class="tbl_head01 tbl_wrap" style="margin-top: 30px;">
<table>
<caption>사용 가능한 템플릿 변수</caption>
<thead>
<tr>
<th scope="col">변수명</th>
<th scope="col">설명</th>
<th scope="col">예시값</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>{customer_name}</code></td>
<td>고객명</td>
<td>홍길동</td>
</tr>
<tr>
<td><code>{estimate_id}</code></td>
<td>견적 번호</td>
<td>12345</td>
</tr>
<tr>
<td><code>{estimate_subject}</code></td>
<td>견적 제목</td>
<td>아파트 창호 교체 견적</td>
</tr>
<tr>
<td><code>{total_amount}</code></td>
<td>총 금액</td>
<td>1,500,000</td>
</tr>
<tr>
<td><code>{visit_fee}</code></td>
<td>방문 비용</td>
<td>50,000</td>
</tr>
<tr>
<td><code>{dealer_name}</code></td>
<td>대리점명</td>
<td>김대리점</td>
</tr>
<tr>
<td><code>{construction_date}</code></td>
<td>시공 예정일</td>
<td>2024-02-15</td>
</tr>
<tr>
<td><code>{account_info}</code></td>
<td>계좌 정보</td>
<td>국민은행 123-456-789012</td>
</tr>
</tbody>
</table>
</div>
<script>
// 템플릿 데이터
const emailTemplates = <?php echo json_encode($email_templates); ?>;
const smsTemplates = <?php echo json_encode($sms_templates); ?>;
// 템플릿 타입 변경 시 템플릿 목록 업데이트
function updateTemplateList() {
const templateType = document.getElementById('template_type').value;
const templateSelect = document.getElementById('template_key');
// 기존 옵션 제거
templateSelect.innerHTML = '<option value="">템플릿을 선택하세요</option>';
if (templateType === 'customer' || templateType === 'dealer') {
// 이메일과 SMS 템플릿 모두 추가
emailTemplates.forEach(template => {
const option = document.createElement('option');
option.value = template.template_key;
option.textContent = `[이메일] ${template.template_name}`;
templateSelect.appendChild(option);
});
smsTemplates.forEach(template => {
const option = document.createElement('option');
option.value = template.template_key;
option.textContent = `[SMS] ${template.template_name}`;
templateSelect.appendChild(option);
});
}
// 발송 방식 초기화
document.querySelectorAll('input[name="notification_type"]').forEach(radio => {
radio.checked = false;
});
updateRecipientField();
}
// 템플릿 선택 시 발송 방식 업데이트
function updateNotificationTypes() {
const templateKey = document.getElementById('template_key').value;
if (templateKey) {
// 선택된 템플릿이 이메일인지 SMS인지 확인
const isEmailTemplate = emailTemplates.some(t => t.template_key === templateKey);
const isSmsTemplate = smsTemplates.some(t => t.template_key === templateKey);
// 해당하는 발송 방식만 활성화
document.querySelector('input[value="email"]').disabled = !isEmailTemplate;
document.querySelector('input[value="sms"]').disabled = !isSmsTemplate;
document.querySelector('input[value="both"]').disabled = !(isEmailTemplate && isSmsTemplate);
}
}
// 발송 방식 변경 시 수신자 필드 업데이트
function updateRecipientField() {
const notificationType = document.querySelector('input[name="notification_type"]:checked');
const recipientField = document.getElementById('test_recipient');
const recipientInfo = document.getElementById('recipient_info');
if (!notificationType) {
recipientField.placeholder = '';
recipientInfo.textContent = '발송 방식을 먼저 선택하세요';
return;
}
switch (notificationType.value) {
case 'email':
recipientField.placeholder = 'test@example.com';
recipientInfo.textContent = '테스트 이메일 주소를 입력하세요';
break;
case 'sms':
recipientField.placeholder = '01012345678';
recipientInfo.textContent = '테스트 전화번호를 입력하세요 (하이픈 없이)';
break;
case 'both':
recipientField.placeholder = 'test@example.com 또는 01012345678';
recipientInfo.textContent = '이메일 주소 또는 전화번호를 입력하세요';
break;
}
}
// 폼 유효성 검사
function validateTestForm() {
const templateType = document.getElementById('template_type').value;
const templateKey = document.getElementById('template_key').value;
const notificationType = document.querySelector('input[name="notification_type"]:checked');
const testRecipient = document.getElementById('test_recipient').value.trim();
if (!templateType || !templateKey || !notificationType || !testRecipient) {
alert('모든 필수 항목을 입력해주세요.');
return false;
}
// 수신자 유효성 검증
if (notificationType.value === 'email' || notificationType.value === 'both') {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(testRecipient)) {
alert('유효한 이메일 주소를 입력해주세요.');
return false;
}
}
if (notificationType.value === 'sms') {
const phoneNumber = testRecipient.replace(/[^0-9]/g, '');
if (!/^01[0-9]{8,9}$/.test(phoneNumber)) {
alert('유효한 전화번호를 입력해주세요.');
return false;
}
}
return confirm('테스트 알림을 발송하시겠습니까?');
}
</script>
<?php
include_once(G5_ADMIN_PATH . '/admin.tail.php');
?>
+247
View File
@@ -0,0 +1,247 @@
<?php
$sub_menu = '800800';
include_once('./_common.php');
if ($is_admin != 'super') {
alert('최고관리자만 접근 가능합니다.');
}
// 결제 상태 업데이트 처리
if ($_POST['mode'] == 'update_payment') {
$wr_id = (int) $_POST['wr_id'];
$payment_status = sql_real_escape_string($_POST['payment_status']);
$payment_memo = sql_real_escape_string($_POST['payment_memo']);
// 결제 상태에 따른 견적 상태 결정
$estimate_status_map = [
'pending' => '견적채택',
'deposit_paid' => '입금확인',
'interim_pending' => '중도금입금예정',
'interim_paid' => '중도금입금완료',
'final_pending' => '잔금입금예정',
'final_paid' => '시공완료'
];
$new_estimate_status = $estimate_status_map[$payment_status] ?? '견적채택';
sql_query("UPDATE estimate SET
status = '{$new_estimate_status}',
extra_2 = '{$payment_status}',
extra_4 = '{$payment_memo}',
updated_at = NOW()
WHERE wr_id = '{$wr_id}'");
// 게시판 상태도 업데이트
sql_query("UPDATE g5_write_order SET wr_1 = '{$new_estimate_status}' WHERE wr_id = '{$wr_id}'");
alert('결제 상태가 업데이트되었습니다.', './payment_manager.php');
}
// 결제 대기 중인 견적 목록 조회
$sql = "SELECT e.*, w.wr_subject, w.wr_name, w.wr_datetime,
(SELECT mb_name FROM g5_member WHERE mb_id = e.extra_4) as selected_dealer_name
FROM estimate e
JOIN g5_write_order w ON e.wr_id = w.wr_id
WHERE e.status IN ('견적채택', '입금확인', '중도금입금예정', '중도금입금완료', '잔금입금예정')
AND e.is_deleted = 0
ORDER BY
CASE e.status
WHEN '견적채택' THEN 1
WHEN '중도금입금예정' THEN 2
WHEN '잔금입금예정' THEN 3
ELSE 4
END,
e.updated_at DESC";
$result = sql_query($sql);
$g5['title'] = '결제 관리';
include_once(G5_ADMIN_PATH . '/admin.head.php');
?>
<div class="local_desc01 local_desc">
<p>견적 선택 후 결제 진행 상황을 관리합니다.</p>
</div>
<section id="anc_payment_manager">
<h2>결제 관리 목록</h2>
<div class="tbl_head01 tbl_wrap">
<table>
<caption>결제 진행 현황</caption>
<colgroup>
<col style="width:80px">
<col>
<col style="width:100px">
<col style="width:100px">
<col style="width:120px">
<col style="width:100px">
<col style="width:100px">
<col style="width:80px">
</colgroup>
<thead>
<tr>
<th scope="col">번호</th>
<th scope="col">제목</th>
<th scope="col">고객명</th>
<th scope="col">선택된 대리점</th>
<th scope="col">견적 상태</th>
<th scope="col">결제 상태</th>
<th scope="col">결제 금액</th>
<th scope="col">관리</th>
</tr>
</thead>
<tbody>
<?php
$num = 1;
while ($row = sql_fetch_array($result)) {
$payment_status = $row['extra_2'] ?: 'pending';
$deposit_amount = $row['extra_3'] ?: 0;
$payment_status_text = [
'pending' => '계약금 대기',
'deposit_paid' => '계약금 완료',
'interim_pending' => '중도금 대기',
'interim_paid' => '중도금 완료',
'final_pending' => '잔금 대기',
'final_paid' => '잔금 완료'
];
$status_class = [
'pending' => 'status-pending',
'deposit_paid' => 'status-paid',
'interim_pending' => 'status-pending',
'interim_paid' => 'status-paid',
'final_pending' => 'status-pending',
'final_paid' => 'status-completed'
];
?>
<tr>
<td><?php echo $num++; ?></td>
<td>
<a href="<?php echo G5_BBS_URL; ?>/board.php?bo_table=order&wr_id=<?php echo $row['wr_id']; ?>">
<?php echo htmlspecialchars($row['wr_subject']); ?>
</a>
</td>
<td><?php echo $row['wr_name']; ?></td>
<td><?php echo $row['selected_dealer_name'] ?: '-'; ?></td>
<td>
<span class="estimate-status">
<?php echo get_status_display_name($row['status']); ?>
</span>
</td>
<td>
<span class="payment-status <?php echo $status_class[$payment_status] ?? ''; ?>">
<?php echo $payment_status_text[$payment_status] ?? $payment_status; ?>
</span>
</td>
<td><?php echo $deposit_amount ? number_format($deposit_amount) . '원' : '-'; ?></td>
<td>
<button type="button" class="btn btn_03"
onclick="editPayment(<?php echo $row['wr_id']; ?>, '<?php echo $payment_status; ?>')">관리</button>
</td>
</tr>
<?php } ?>
<?php if (sql_num_rows($result) == 0) { ?>
<tr>
<td colspan="8" class="empty_table">결제 대기 중인 견적이 없습니다.</td>
</tr>
<?php } ?>
</tbody>
</table>
</div>
</section>
<!-- 결제 관리 모달 -->
<div id="paymentModal"
style="display:none; position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); background:white; border:2px solid #ccc; padding:20px; z-index:1000; width:500px;">
<h3>결제 상태 관리</h3>
<form name="paymentForm" method="post" action="./payment_manager.php">
<input type="hidden" name="mode" value="update_payment">
<input type="hidden" name="wr_id" id="modal_wr_id">
<table class="tbl_frm01">
<tr>
<th>결제 상태</th>
<td>
<select name="payment_status" id="modal_payment_status">
<option value="pending">계약금 대기</option>
<option value="deposit_paid">계약금 완료</option>
<option value="interim_pending">중도금 대기</option>
<option value="interim_paid">중도금 완료</option>
<option value="final_pending">잔금 대기</option>
<option value="final_paid">잔금 완료 (시공완료)</option>
</select>
</td>
</tr>
<tr>
<th>관리자 메모</th>
<td>
<textarea name="payment_memo" id="modal_payment_memo" rows="3" cols="50"
placeholder="입금 확인일, 특이사항 등"></textarea>
</td>
</tr>
</table>
<div style="text-align:center; margin-top:20px;">
<input type="submit" value="저장" class="btn btn_02">
<button type="button" onclick="closePaymentModal()" class="btn btn_03">취소</button>
</div>
</form>
</div>
<div id="paymentModalOverlay"
style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.5); z-index:999;"
onclick="closePaymentModal()"></div>
<style>
.status-pending {
color: #ff6600;
font-weight: bold;
}
.status-paid {
color: #0066cc;
font-weight: bold;
}
.status-completed {
color: #009900;
font-weight: bold;
}
.estimate-status {
background: #f0f0f0;
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
}
.payment-status {
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
}
.empty_table {
text-align: center;
padding: 20px;
color: #999;
}
</style>
<script>
function editPayment(wr_id, current_status) {
document.getElementById('modal_wr_id').value = wr_id;
document.getElementById('modal_payment_status').value = current_status;
document.getElementById('paymentModal').style.display = 'block';
document.getElementById('paymentModalOverlay').style.display = 'block';
}
function closePaymentModal() {
document.getElementById('paymentModal').style.display = 'none';
document.getElementById('paymentModalOverlay').style.display = 'none';
}
</script>
<?php
include_once(G5_ADMIN_PATH . '/admin.tail.php');
?>
+72
View File
@@ -0,0 +1,72 @@
<?php
$sub_menu = '800900'; // 메뉴 코드 (적절히 수정 필요)
include_once('./_common.php');
$g5['title'] = '전문가 방문 예약 기능 사용법';
include_once(G5_ADMIN_PATH.'/admin.head.php');
?>
<div class="local_desc01 local_desc">
<p>
이 페이지는 사이트의 어떤 페이지에서든 <strong>전문가 방문 예약</strong> 및 <strong>나의 예약 확인</strong> 기능을 추가하는 방법을 보여주는 예제입니다.<br>
아래 코드 한 줄만 포함하면, 버튼과 팝업 기능이 모두 활성화됩니다.
</p>
<pre><code>&lt;?php include_once(G5_ADMIN_PATH . '/order_manage/components/_expert_visit_popups.php'); ?&gt;</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="expert-buttons">
<!-- wr_id는 게시판 글 ID입니다. 없으면 0 또는 생략 가능합니다. -->
<button type="button" class="expert-request-btn-main" onclick="openExpertVisitPopup(0)">
<i class="fa fa-calendar-check-o"></i> 전문가 방문 예약하기
</button>
<button type="button" class="my-reservation-btn" onclick="openMyExpertReservationsPopup()">
<i class="fa fa-list"></i> 나의 예약 현황
</button>
</div>
</div>
<?php
// 2. 팝업 파일들 포함 (이 코드 한 줄이면 모든 팝업 기능이 로드됩니다)
include_once(G5_ADMIN_PATH . '/order_manage/components/_expert_visit_popups.php');
?>
<!-- 3. 스타일 (CSS 파일에 추가 권장) -->
<style>
/* 버튼 스타일 */
.expert-buttons { display: flex; justify-content: center; gap: 15px; flex-wrap: wrap; }
.expert-request-btn-main, .my-reservation-btn {
color: white; border: none; padding: 15px 30px; border-radius: 25px;
font-size: 16px; font-weight: bold; cursor: pointer; transition: all 0.3s ease;
}
.expert-request-btn-main {
background: linear-gradient(135deg, #ff6b35 0%, #f98e5a 100%);
box-shadow: 0 4px 15px rgba(255, 107, 53, 0.3);
}
.expert-request-btn-main:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(255, 107, 53, 0.4); }
.my-reservation-btn { background: #6c757d; }
.my-reservation-btn:hover { background: #5a6268; }
.expert-request-btn-main i, .my-reservation-btn i { margin-right: 8px; }
</style>
<!-- ================================================================== -->
<!-- 💡 [끝] 실제 작동 예제 -->
<!-- ================================================================== -->
<div class="local_desc02 local_desc">
<h3>📌 적용 방법</h3>
<p>위의 예제 코드를 복사하여 원하는 페이지(예: <code>tail.php</code>, <code>index.php</code> 등)에 붙여넣으세요.</p>
<p><strong>주의사항:</strong> <code>G5_ADMIN_PATH</code> 상수는 그누보드 설정에 따라 다를 수 있으므로, 실제 경로(예: <code>/adm</code>)로 직접 입력해야 할 수도 있습니다.</p>
</div>
<?php
include_once(G5_ADMIN_PATH.'/admin.tail.php');
?>
+146
View File
@@ -0,0 +1,146 @@
<?php
$sub_menu = "800600";
include_once("./_common.php");
auth_check_menu($auth, $sub_menu, "r");
$id = (int) $_GET['id'];
$template = sql_fetch("SELECT * FROM order_sms_templates WHERE id = {$id}");
if (!$template) {
alert('템플릿을 찾을 수 없습니다.', './sms_templates.php');
}
$g5['title'] = "SMS 템플릿 수정";
include_once(G5_ADMIN_PATH . '/admin.head.php');
$variables = json_decode($template['variables'], true);
?>
<div class="local_ov01 local_ov">
SMS 템플릿 수정 - <?php echo htmlspecialchars($template['template_name']); ?>
</div>
<form name="templateForm" method="post" action="sms_template_update.php">
<input type="hidden" name="id" value="<?php echo $template['id']; ?>">
<div class="tbl_frm01 tbl_wrap">
<table>
<caption>SMS 템플릿 수정</caption>
<colgroup>
<col class="grid_4">
<col>
</colgroup>
<tbody>
<tr>
<th scope="row">템플릿명</th>
<td>
<input type="text" name="template_name"
value="<?php echo htmlspecialchars($template['template_name']); ?>" class="frm_input"
style="width:100%;" required>
</td>
</tr>
<tr>
<th scope="row">SMS 내용</th>
<td>
<textarea name="content" id="sms_content" class="frm_input" style="width:100%; height:150px;"
onkeyup="checkSMSLength();"
required><?php echo htmlspecialchars($template['content']); ?></textarea>
<div id="sms_length_info">
<span id="current_length">0</span> / 80 바이트
(<span id="current_chars">0</span> / 40 글자)
<span id="length_warning" style="color: red; display: none;">SMS 길이 초과!</span>
</div>
<div class="frm_info">한글 1글자 = 2바이트, 영문/숫자 1글자 = 1바이트</div>
</td>
</tr>
<tr>
<th scope="row">사용 가능한 변수</th>
<td>
<?php if (is_array($variables)) { ?>
<?php foreach ($variables as $var) { ?>
<span class="variable-tag"
onclick="insertVariable('{<?php echo $var; ?>}')">{<?php echo $var; ?>}</span>
<?php } ?>
<?php } ?>
<div class="frm_info">변수를 클릭하면 SMS 내용에 삽입됩니다.</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="btn_confirm01 btn_confirm">
<input type="submit" value="저장" class="btn_submit">
<a href="sms_templates.php" class="btn btn_02">목록</a>
</div>
</form>
<style>
.variable-tag {
display: inline-block;
background: #e8f5e8;
color: #2e7d32;
padding: 2px 8px;
margin: 2px;
border-radius: 12px;
font-size: 12px;
font-family: monospace;
cursor: pointer;
}
.variable-tag:hover {
background: #c8e6c9;
}
#sms_length_info {
margin-top: 5px;
font-size: 12px;
color: #666;
}
</style>
<script>
function checkSMSLength() {
const content = document.getElementById('sms_content').value;
let byteLength = 0;
for (let i = 0; i < content.length; i++) {
const char = content.charAt(i);
byteLength += (escape(char).length > 4) ? 2 : 1;
}
document.getElementById('current_length').textContent = byteLength;
document.getElementById('current_chars').textContent = content.length;
const warning = document.getElementById('length_warning');
if (byteLength > 80) {
warning.style.display = 'inline';
} else {
warning.style.display = 'none';
}
}
function insertVariable(variable) {
const textarea = document.getElementById('sms_content');
const cursorPos = textarea.selectionStart;
const textBefore = textarea.value.substring(0, cursorPos);
const textAfter = textarea.value.substring(cursorPos);
textarea.value = textBefore + variable + textAfter;
textarea.focus();
textarea.setSelectionRange(cursorPos + variable.length, cursorPos + variable.length);
checkSMSLength();
}
// 페이지 로드 시 길이 체크
document.addEventListener('DOMContentLoaded', function () {
checkSMSLength();
});
</script>
<?php
include_once(G5_ADMIN_PATH . '/admin.tail.php');
?>
+24
View File
@@ -0,0 +1,24 @@
<?php
$sub_menu = "800600";
include_once("./_common.php");
auth_check_menu($auth, $sub_menu, "w");
if ($_POST) {
$id = (int) $_POST['id'];
$template_name = sql_real_escape_string($_POST['template_name']);
$content = sql_real_escape_string($_POST['content']);
$sql = "UPDATE order_sms_templates SET
template_name = '{$template_name}',
content = '{$content}',
updated_at = NOW()
WHERE id = {$id}";
sql_query($sql);
alert('SMS 템플릿이 수정되었습니다.', './sms_templates.php');
} else {
alert('잘못된 접근입니다.', './sms_templates.php');
}
?>
+475
View File
@@ -0,0 +1,475 @@
<?php
$sub_menu = "800600";
include_once('./_common.php');
auth_check($auth[$sub_menu], 'r');
$g5['title'] = 'SMS 템플릿 관리';
// 액션 처리
$action = isset($_REQUEST['action']) ? clean_xss_tags($_REQUEST['action']) : '';
$id = (int) ($_REQUEST['id'] ?? 0);
// POST 요청 처리 (생성, 수정, 삭제)
if ($action && $_SERVER['REQUEST_METHOD'] === 'POST') {
auth_check($auth[$sub_menu], 'w');
try {
if ($action === 'save') {
$template_key = clean_xss_tags($_POST['template_key'] ?? '');
$template_name = clean_xss_tags($_POST['template_name'] ?? '');
$content = clean_xss_tags($_POST['content'] ?? '');
$variables = clean_xss_tags($_POST['variables'] ?? '');
if (!$template_key || !$template_name || !$content) {
throw new Exception('필수 항목을 모두 입력해주세요.');
}
// SMS 길이 제한 확인 (한글 기준 90자, 영문 기준 160자)
if (mb_strlen($content, 'UTF-8') > 90) {
throw new Exception('SMS 내용은 90자를 초과할 수 없습니다.');
}
// 템플릿 키 중복 확인 (수정 시 제외)
if ($id === 0) {
$existing = sql_fetch("SELECT id FROM order_sms_templates WHERE template_key = '{$template_key}'");
if ($existing) {
throw new Exception('이미 존재하는 템플릿 키입니다.');
}
}
$data = [
'template_key' => $template_key,
'template_name' => $template_name,
'content' => $content,
'variables' => $variables,
'updated_at' => date('Y-m-d H:i:s')
];
if ($id > 0) {
// 수정
$set_clauses = [];
foreach ($data as $key => $value) {
$set_clauses[] = "`{$key}` = '" . sql_real_escape_string($value) . "'";
}
$sql = "UPDATE order_sms_templates SET " . implode(', ', $set_clauses) . " WHERE id = '{$id}'";
sql_query($sql);
$message = 'SMS 템플릿이 수정되었습니다.';
} else {
// 생성
$data['created_at'] = date('Y-m-d H:i:s');
$fields = array_keys($data);
$values = array_values($data);
$fields_str = '`' . implode('`, `', $fields) . '`';
$values_str = "'" . implode("', '", array_map('sql_real_escape_string', $values)) . "'";
$sql = "INSERT INTO order_sms_templates ({$fields_str}) VALUES ({$values_str})";
sql_query($sql);
$message = 'SMS 템플릿이 생성되었습니다.';
}
alert($message, './sms_templates.php');
}
if ($action === 'delete') {
if ($id > 0) {
sql_query("DELETE FROM order_sms_templates WHERE id = '{$id}'");
alert('SMS 템플릿이 삭제되었습니다.', './sms_templates.php');
}
}
} catch (Exception $e) {
alert('오류: ' . $e->getMessage());
}
goto_url('./sms_templates.php');
exit;
}
// GET 요청 처리 (목록, 편집 폼)
$mode = isset($_GET['mode']) ? clean_xss_tags($_GET['mode']) : 'list';
$template = null;
if ($mode === 'edit' && $id > 0) {
$template = sql_fetch("SELECT * FROM order_sms_templates WHERE id = '{$id}'");
if (!$template) {
alert('존재하지 않는 템플릿입니다.', './sms_templates.php');
}
}
// 템플릿 목록 조회
$templates = [];
$sql = "SELECT * FROM order_sms_templates ORDER BY template_key ASC";
$result = sql_query($sql);
while ($row = sql_fetch_array($result)) {
$templates[] = $row;
}
include_once(G5_ADMIN_PATH . '/admin.head.php');
?>
<div class="local_desc01 local_desc">
<p>
SMS 알림에 사용되는 템플릿을 관리합니다.<br>
SMS는 90자 이내로 작성해야 하며, 변수를 사용하여 동적인 내용을 삽입할 수 있습니다.
</p>
</div>
<?php if ($mode === 'list'): ?>
<!-- 템플릿 목록 -->
<div class="local_ov01 local_ov">
<span class="btn_ov01">
<span class="ov_txt">전체 템플릿 </span>
<span class="ov_num"><?php echo count($templates); ?>개</span>
</span>
<a href="?mode=edit" class="btn btn_02">새 템플릿 추가</a>
</div>
<div class="tbl_head01 tbl_wrap">
<table>
<caption><?php echo $g5['title']; ?> 목록</caption>
<thead>
<tr>
<th scope="col">템플릿 키</th>
<th scope="col">템플릿명</th>
<th scope="col">내용</th>
<th scope="col">길이</th>
<th scope="col">변수</th>
<th scope="col">수정일</th>
<th scope="col">관리</th>
</tr>
</thead>
<tbody>
<?php if (empty($templates)): ?>
<tr>
<td colspan="7" class="empty_table">등록된 템플릿이 없습니다.</td>
</tr>
<?php else: ?>
<?php foreach ($templates as $tpl): ?>
<tr>
<td class="td_left">
<code><?php echo get_text($tpl['template_key']); ?></code>
</td>
<td class="td_left">
<strong><?php echo get_text($tpl['template_name']); ?></strong>
</td>
<td class="td_left">
<div style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
<?php echo get_text($tpl['content']); ?>
</div>
</td>
<td class="td_num">
<?php
$length = mb_strlen($tpl['content'], 'UTF-8');
$color = $length > 90 ? 'red' : ($length > 70 ? 'orange' : 'green');
?>
<span style="color: <?php echo $color; ?>"><?php echo $length; ?>/90자</span>
</td>
<td class="td_left">
<?php
$variables = json_decode($tpl['variables'], true);
if (is_array($variables)) {
echo '<small>' . implode(', ', array_map(function ($var) {
return '{' . $var . '}'; }, $variables)) . '</small>';
}
?>
</td>
<td class="td_datetime">
<?php echo $tpl['updated_at'] ? date('Y-m-d H:i', strtotime($tpl['updated_at'])) : '-'; ?>
</td>
<td class="td_mng td_mng_s">
<a href="?mode=edit&id=<?php echo $tpl['id']; ?>" class="btn btn_03">수정</a>
<a href="javascript:void(0);" onclick="previewTemplate(<?php echo $tpl['id']; ?>)"
class="btn btn_04">미리보기</a>
<a href="javascript:void(0);" onclick="deleteTemplate(<?php echo $tpl['id']; ?>)"
class="btn btn_02">삭제</a>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<?php else: ?>
<!-- 템플릿 편집 폼 -->
<div class="local_desc02 local_desc">
<p>
<a href="./sms_templates.php" class="btn btn_01">목록으로 돌아가기</a>
</p>
</div>
<form name="ftemplate" method="post" onsubmit="return validateForm()">
<input type="hidden" name="action" value="save">
<input type="hidden" name="id" value="<?php echo $template['id'] ?? 0; ?>">
<div class="tbl_frm01 tbl_wrap">
<table>
<caption><?php echo $template ? '템플릿 수정' : '새 템플릿 추가'; ?></caption>
<colgroup>
<col class="grid_4">
<col>
</colgroup>
<tbody>
<tr>
<th scope="row"><label for="template_key">템플릿 키<strong class="sound_only">필수</strong></label></th>
<td>
<input type="text" name="template_key" id="template_key"
value="<?php echo get_text($template['template_key'] ?? ''); ?>" class="frm_input"
maxlength="100" required <?php echo $template ? 'readonly' : ''; ?>>
<span class="frm_info">영문, 숫자, 언더스코어만 사용 가능 (예: customer_request_complete)</span>
</td>
</tr>
<tr>
<th scope="row"><label for="template_name">템플릿명<strong class="sound_only">필수</strong></label></th>
<td>
<input type="text" name="template_name" id="template_name"
value="<?php echo get_text($template['template_name'] ?? ''); ?>" class="frm_input"
maxlength="255" required>
</td>
</tr>
<tr>
<th scope="row"><label for="content">SMS 내용<strong class="sound_only">필수</strong></label></th>
<td>
<textarea name="content" id="content" rows="5" class="frm_input" maxlength="90" required
oninput="updateCharCount()"
onkeyup="updateCharCount()"><?php echo get_text($template['content'] ?? ''); ?></textarea>
<div class="char-count">
<span id="charCount">0</span>/90자
<span id="charWarning" style="color: red; display: none;">90자를 초과했습니다!</span>
</div>
<span class="frm_info">변수는 {변수명} 형태로 입력. 예: {customer_name}님, 안녕하세요.</span>
</td>
</tr>
<tr>
<th scope="row"><label for="variables">사용 가능한 변수</label></th>
<td>
<input type="text" name="variables" id="variables"
value="<?php echo get_text($template['variables'] ?? ''); ?>" class="frm_input"
placeholder='["customer_name", "estimate_id"]'>
<span class="frm_info">JSON 배열 형태로 입력 (예: ["customer_name", "estimate_id"])</span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="btn_confirm01 btn_confirm">
<input type="submit" value="<?php echo $template ? '수정' : '등록'; ?>" class="btn_submit">
<a href="./sms_templates.php" class="btn_cancel">취소</a>
</div>
</form>
<?php endif; ?>
<!-- 미리보기 모달 -->
<div id="previewModal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3>SMS 템플릿 미리보기</h3>
<span class="close" onclick="closePreview()">&times;</span>
</div>
<div class="modal-body">
<div id="previewContent"></div>
</div>
</div>
</div>
<style>
.modal {
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
}
.modal-content {
background-color: #fefefe;
margin: 15% auto;
padding: 0;
border: 1px solid #888;
width: 500px;
border-radius: 5px;
}
.modal-header {
padding: 15px 20px;
background-color: #f8f9fa;
border-bottom: 1px solid #dee2e6;
border-radius: 5px 5px 0 0;
}
.modal-header h3 {
margin: 0;
display: inline-block;
}
.close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
.close:hover {
color: black;
}
.modal-body {
padding: 20px;
}
.char-count {
margin-top: 5px;
font-size: 12px;
color: #666;
}
code {
background-color: #f8f9fa;
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
}
.sms-preview {
border: 1px solid #ddd;
padding: 15px;
background: #f8f9fa;
border-radius: 5px;
font-family: monospace;
white-space: pre-wrap;
}
</style>
<script>
// 문자 수 카운트 업데이트
function updateCharCount() {
const content = document.getElementById('content');
const charCount = document.getElementById('charCount');
const charWarning = document.getElementById('charWarning');
if(!content || !charCount || !charWarning){
console.log(document.getElementById('content'))
return;
}
const length = content && content.value ? content.value.length :-1;
if(charCount) {
charCount.textContent = charCount && charCount.textContent ? length : -1;
}
if (length > 90) {
charCount.style.color = 'red';
charWarning.style.display = 'inline';
} else if (length > 70) {
charCount.style.color = 'orange';
charWarning.style.display = 'none';
} else {
charCount.style.color = 'green';
charWarning.style.display = 'none';
}
}
// 페이지 로드 시 문자 수 카운트 초기화
document.addEventListener('DOMContentLoaded', function () {
updateCharCount();
});
// 폼 유효성 검사
function validateForm() {
const templateKey = document.getElementById('template_key').value.trim();
const templateName = document.getElementById('template_name').value.trim();
const content = document.getElementById('content').value.trim();
if (!templateKey || !templateName || !content) {
alert('필수 항목을 모두 입력해주세요.');
return false;
}
// 템플릿 키 형식 검사
if (!/^[a-zA-Z0-9_]+$/.test(templateKey)) {
alert('템플릿 키는 영문, 숫자, 언더스코어만 사용할 수 있습니다.');
return false;
}
// SMS 길이 제한 검사
if (content.length > 90) {
alert('SMS 내용은 90자를 초과할 수 없습니다.');
return false;
}
// 변수 JSON 형식 검사
const variables = document.getElementById('variables').value.trim();
if (variables) {
try {
JSON.parse(variables);
} catch (e) {
alert('변수는 올바른 JSON 배열 형태로 입력해주세요.');
return false;
}
}
return true;
}
// 템플릿 삭제
function deleteTemplate(id) {
if (!confirm('정말로 이 템플릿을 삭제하시겠습니까?')) {
return;
}
const form = document.createElement('form');
form.method = 'POST';
form.innerHTML = `
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="${id}">
`;
document.body.appendChild(form);
form.submit();
}
// 템플릿 미리보기
function previewTemplate(id) {
fetch('sms_templates_ajax.php?action=preview&id=' + id)
.then(response => response.json())
.then(data => {
if (data.success) {
const length = data.data.content.length;
const lengthColor = length > 90 ? 'red' : (length > 70 ? 'orange' : 'green');
document.getElementById('previewContent').innerHTML = `
<div class="sms-preview">${data.data.content}</div>
<p><strong>길이:</strong> <span style="color: ${lengthColor}">${length}/90자</span></p>
<p><strong>사용 가능한 변수:</strong> ${data.data.variables || '없음'}</p>
`;
document.getElementById('previewModal').style.display = 'block';
} else {
alert('미리보기를 불러올 수 없습니다: ' + data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('미리보기 중 오류가 발생했습니다.');
});
}
// 미리보기 닫기
function closePreview() {
document.getElementById('previewModal').style.display = 'none';
}
// 모달 외부 클릭 시 닫기
window.onclick = function (event) {
const modal = document.getElementById('previewModal');
if (event.target === modal) {
closePreview();
}
}
</script>
<?php
include_once(G5_ADMIN_PATH . '/admin.tail.php');
?>
+44
View File
@@ -0,0 +1,44 @@
<?php
include_once('./_common.php');
// AJAX 요청만 처리
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'GET 요청만 허용됩니다.']);
exit;
}
$action = isset($_GET['action']) ? clean_xss_tags($_GET['action']) : '';
$id = (int) ($_GET['id'] ?? 0);
header('Content-Type: application/json; charset=utf-8');
if ($action === 'preview' && $id > 0) {
$template = sql_fetch("SELECT * FROM order_sms_templates WHERE id = '{$id}'");
if ($template) {
// 변수들을 예시 값으로 치환
$variables = json_decode($template['variables'], true);
$content = $template['content'];
if (is_array($variables)) {
foreach ($variables as $var) {
$placeholder = '{' . $var . '}';
$example_value = '[' . $var . ']'; // 예: [customer_name]
$content = str_replace($placeholder, $example_value, $content);
}
}
echo json_encode([
'success' => true,
'data' => [
'content' => $content,
'variables' => $template['variables']
]
], JSON_UNESCAPED_UNICODE);
} else {
echo json_encode(['success' => false, 'message' => '템플릿을 찾을 수 없습니다.']);
}
} else {
echo json_encode(['success' => false, 'message' => '잘못된 요청입니다.']);
}
+587
View File
@@ -0,0 +1,587 @@
<?php
$sub_menu = "800200";
include_once('./_common.php');
auth_check($auth[$sub_menu], 'r');
$g5['title'] = '견적 통계 및 리포트';
// EstimateManager 로드
require_once G5_PATH . '/adm/order_manage/classes/EstimateManager.class.php';
$estimate_manager = new EstimateManager();
// 필터 파라미터
$date_from = isset($_GET['date_from']) ? clean_xss_tags($_GET['date_from']) : date('Y-m-01'); // 이번 달 첫째 날
$date_to = isset($_GET['date_to']) ? clean_xss_tags($_GET['date_to']) : date('Y-m-d'); // 오늘
$report_type = isset($_GET['report_type']) ? clean_xss_tags($_GET['report_type']) : 'overview';
// CSV 내보내기 처리
if (isset($_GET['export']) && $_GET['export'] === 'csv') {
$export_type = clean_xss_tags($_GET['export_type'] ?? 'estimates');
$csv_data = $estimate_manager->exportStatisticsCSV($export_type, $date_from, $date_to);
if ($csv_data) {
$filename = "order_statistics_{$export_type}_" . date('Y-m-d') . ".csv";
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Cache-Control: no-cache, must-revalidate');
// UTF-8 BOM 추가 (엑셀에서 한글 깨짐 방지)
echo "\xEF\xBB\xBF";
echo $csv_data;
exit;
} else {
alert('CSV 내보내기에 실패했습니다.');
}
}
// 통계 데이터 조회
$estimate_stats = $estimate_manager->getEstimateStatistics($date_from, $date_to);
$dealer_performance = $estimate_manager->getDealerPerformance($date_from, $date_to);
$revenue_stats = $estimate_manager->getRevenueStatistics($date_from, $date_to);
$expert_visit_stats = $estimate_manager->getExpertVisitStatistics($date_from, $date_to);
include_once(G5_ADMIN_PATH . '/admin.head.php');
?>
<div class="local_desc01 local_desc">
<p>
견적 요청 현황, 대리점 성과, 매출 분석, 전문가 방문 통계 등 종합적인 비즈니스 인사이트를 제공합니다.<br>
데이터를 CSV 파일로 내보내어 상세 분석이 가능합니다.
</p>
</div>
<!-- 필터 및 내보내기 영역 -->
<div class="local_sch01 local_sch">
<form name="fsearch" method="get">
<fieldset>
<legend>기간 설정</legend>
<label for="date_from" class="sound_only">시작일</label>
<input type="date" name="date_from" id="date_from" value="<?php echo $date_from; ?>" class="frm_input">
~
<label for="date_to" class="sound_only">종료일</label>
<input type="date" name="date_to" id="date_to" value="<?php echo $date_to; ?>" class="frm_input">
<input type="submit" value="조회" class="btn_submit">
<div style="float: right;">
<select id="export_type" class="frm_input">
<option value="estimates">견적 통계</option>
<option value="dealers">대리점 성과</option>
<option value="revenue">매출 현황</option>
<option value="expert_visits">전문가 방문</option>
</select>
<button type="button" onclick="exportCSV()" class="btn btn_02">CSV 내보내기</button>
</div>
</fieldset>
</form>
</div>
<!-- 탭 메뉴 -->
<div class="tab-menu">
<button class="tab-btn active" onclick="showTab('overview')">전체 개요</button>
<button class="tab-btn" onclick="showTab('estimates')">견적 통계</button>
<button class="tab-btn" onclick="showTab('dealers')">대리점 성과</button>
<button class="tab-btn" onclick="showTab('revenue')">매출 분석</button>
<button class="tab-btn" onclick="showTab('expert_visits')">전문가 방문</button>
</div>
<!-- 전체 개요 탭 -->
<div id="overview-tab" class="tab-content active">
<div class="stats-grid">
<!-- 견적 현황 카드 -->
<div class="stats-card">
<h3>견적 현황</h3>
<div class="stats-number">
<?php echo number_format($estimate_stats['total_statistics']['total_estimates']); ?></div>
<div class="stats-label">총 견적 요청</div>
<div class="stats-detail">
완료: <?php echo number_format($estimate_stats['total_statistics']['completed_estimates']); ?>건 |
선택: <?php echo number_format($estimate_stats['total_statistics']['selected_estimates']); ?>건 |
전환율: <?php echo $estimate_stats['total_statistics']['conversion_rate']; ?>%
</div>
</div>
<!-- 매출 현황 카드 -->
<div class="stats-card">
<h3>매출 현황</h3>
<div class="stats-number">
<?php echo number_format($revenue_stats['total_revenue']['total_revenue'] ?? 0); ?>원</div>
<div class="stats-label">총 매출</div>
<div class="stats-detail">
주문: <?php echo number_format($revenue_stats['total_revenue']['total_orders'] ?? 0); ?>건 |
평균: <?php echo number_format($revenue_stats['total_revenue']['avg_order_value'] ?? 0); ?>원
</div>
</div>
<!-- 대리점 현황 카드 -->
<div class="stats-card">
<h3>대리점 현황</h3>
<div class="stats-number"><?php echo count($dealer_performance); ?></div>
<div class="stats-label">활성 대리점</div>
<div class="stats-detail">
<?php
$total_bids = array_sum(array_column($dealer_performance, 'total_bids'));
$selected_bids = array_sum(array_column($dealer_performance, 'selected_bids'));
$avg_success_rate = $total_bids > 0 ? round(($selected_bids / $total_bids) * 100, 2) : 0;
?>
총 입찰: <?php echo number_format($total_bids); ?>건 |
평균 성공률: <?php echo $avg_success_rate; ?>%
</div>
</div>
<!-- 전문가 방문 카드 -->
<div class="stats-card">
<h3>전문가 방문</h3>
<div class="stats-number">
<?php echo number_format($expert_visit_stats['visit_statistics']['total_requests'] ?? 0); ?></div>
<div class="stats-label">방문 요청</div>
<div class="stats-detail">
완료: <?php echo number_format($expert_visit_stats['visit_statistics']['completed_visits'] ?? 0); ?>건 |
매출: <?php echo number_format($expert_visit_stats['visit_statistics']['total_visit_revenue'] ?? 0); ?>원
</div>
</div>
</div>
<!-- 월별 트렌드 차트 -->
<div class="chart-container">
<h3>월별 견적 요청 트렌드</h3>
<canvas id="monthlyTrendChart" width="800" height="400"></canvas>
</div>
</div>
<!-- 견적 통계 탭 -->
<div id="estimates-tab" class="tab-content">
<div class="tbl_head01 tbl_wrap">
<table>
<caption>상태별 견적 분포</caption>
<thead>
<tr>
<th scope="col">상태</th>
<th scope="col">건수</th>
<th scope="col">비율</th>
</tr>
</thead>
<tbody>
<?php
$total_estimates = $estimate_stats['total_statistics']['total_estimates'];
foreach ($estimate_stats['status_distribution'] as $status):
$percentage = $total_estimates > 0 ? round(($status['count'] / $total_estimates) * 100, 2) : 0;
?>
<tr>
<td class="td_left"><?php echo get_text($status['status']); ?></td>
<td class="td_num"><?php echo number_format($status['count']); ?>건</td>
<td class="td_num"><?php echo $percentage; ?>%</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="chart-container">
<h3>월별 견적 전환율</h3>
<canvas id="conversionChart" width="800" height="400"></canvas>
</div>
</div>
<!-- 대리점 성과 탭 -->
<div id="dealers-tab" class="tab-content">
<div class="tbl_head01 tbl_wrap">
<table>
<caption>대리점별 성과 분석</caption>
<thead>
<tr>
<th scope="col">대리점</th>
<th scope="col">레벨</th>
<th scope="col">총 입찰</th>
<th scope="col">선택된 입찰</th>
<th scope="col">성공률</th>
<th scope="col">평균 입찰금액</th>
<th scope="col">총 매출</th>
</tr>
</thead>
<tbody>
<?php if (empty($dealer_performance)): ?>
<tr>
<td colspan="7" class="empty_table">대리점 성과 데이터가 없습니다.</td>
</tr>
<?php else: ?>
<?php foreach ($dealer_performance as $dealer): ?>
<tr>
<td class="td_left">
<strong><?php echo get_text($dealer['dealer_name']); ?></strong><br>
<small><?php echo $dealer['dealer_id']; ?></small>
</td>
<td class="td_num">레벨 <?php echo $dealer['dealer_level']; ?></td>
<td class="td_num"><?php echo number_format($dealer['total_bids']); ?>건</td>
<td class="td_num"><?php echo number_format($dealer['selected_bids']); ?>건</td>
<td class="td_num">
<span
class="<?php echo $dealer['success_rate'] >= 30 ? 'text-success' : ($dealer['success_rate'] >= 15 ? 'text-warning' : 'text-danger'); ?>">
<?php echo $dealer['success_rate']; ?>%
</span>
</td>
<td class="td_num"><?php echo number_format($dealer['avg_bid_amount']); ?>원</td>
<td class="td_num"><?php echo number_format($dealer['total_revenue']); ?>원</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<!-- 매출 분석 탭 -->
<div id="revenue-tab" class="tab-content">
<div class="stats-grid">
<div class="stats-card">
<h4>총 주문</h4>
<div class="stats-number">
<?php echo number_format($revenue_stats['total_revenue']['total_orders'] ?? 0); ?>건</div>
</div>
<div class="stats-card">
<h4>총 매출</h4>
<div class="stats-number">
<?php echo number_format($revenue_stats['total_revenue']['total_revenue'] ?? 0); ?>원</div>
</div>
<div class="stats-card">
<h4>평균 주문금액</h4>
<div class="stats-number">
<?php echo number_format($revenue_stats['total_revenue']['avg_order_value'] ?? 0); ?>원</div>
</div>
<div class="stats-card">
<h4>결제 완료율</h4>
<?php
$payment_rate = ($revenue_stats['total_revenue']['total_orders'] ?? 0) > 0
? round((($revenue_stats['total_revenue']['paid_orders'] ?? 0) / $revenue_stats['total_revenue']['total_orders']) * 100, 2)
: 0;
?>
<div class="stats-number"><?php echo $payment_rate; ?>%</div>
</div>
</div>
<div class="tbl_head01 tbl_wrap">
<table>
<caption>결제 단계별 현황</caption>
<thead>
<tr>
<th scope="col">결제 단계</th>
<th scope="col">건수</th>
<th scope="col">매출</th>
</tr>
</thead>
<tbody>
<?php foreach ($revenue_stats['payment_breakdown'] as $payment): ?>
<tr>
<td class="td_left"><?php echo get_text($payment['status']); ?></td>
<td class="td_num"><?php echo number_format($payment['count']); ?>건</td>
<td class="td_num"><?php echo number_format($payment['revenue']); ?>원</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="chart-container">
<h3>월별 매출 트렌드</h3>
<canvas id="revenueChart" width="800" height="400"></canvas>
</div>
</div>
<!-- 전문가 방문 탭 -->
<div id="expert_visits-tab" class="tab-content">
<div class="stats-grid">
<div class="stats-card">
<h4>총 방문 요청</h4>
<div class="stats-number">
<?php echo number_format($expert_visit_stats['visit_statistics']['total_requests'] ?? 0); ?>건</div>
</div>
<div class="stats-card">
<h4>완료된 방문</h4>
<div class="stats-number">
<?php echo number_format($expert_visit_stats['visit_statistics']['completed_visits'] ?? 0); ?>건</div>
</div>
<div class="stats-card">
<h4>방문 매출</h4>
<div class="stats-number">
<?php echo number_format($expert_visit_stats['visit_statistics']['total_visit_revenue'] ?? 0); ?>원</div>
</div>
<div class="stats-card">
<h4>평균 방문비</h4>
<div class="stats-number">
<?php echo number_format($expert_visit_stats['visit_statistics']['avg_visit_fee'] ?? 0); ?>원</div>
</div>
</div>
<div class="tbl_head01 tbl_wrap">
<table>
<caption>전문가별 방문 성과</caption>
<thead>
<tr>
<th scope="col">전문가</th>
<th scope="col">배정된 방문</th>
<th scope="col">완료된 방문</th>
<th scope="col">완료율</th>
</tr>
</thead>
<tbody>
<?php if (empty($expert_visit_stats['expert_performance'])): ?>
<tr>
<td colspan="4" class="empty_table">전문가 방문 데이터가 없습니다.</td>
</tr>
<?php else: ?>
<?php foreach ($expert_visit_stats['expert_performance'] as $expert): ?>
<tr>
<td class="td_left">
<strong><?php echo get_text($expert['expert_name']); ?></strong><br>
<small><?php echo $expert['expert_id']; ?></small>
</td>
<td class="td_num"><?php echo number_format($expert['assigned_visits']); ?>건</td>
<td class="td_num"><?php echo number_format($expert['completed_visits']); ?>건</td>
<td class="td_num">
<span
class="<?php echo $expert['completion_rate'] >= 80 ? 'text-success' : ($expert['completion_rate'] >= 60 ? 'text-warning' : 'text-danger'); ?>">
<?php echo $expert['completion_rate']; ?>%
</span>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<style>
.tab-menu {
margin: 20px 0;
border-bottom: 1px solid #ddd;
}
.tab-btn {
background: none;
border: none;
padding: 10px 20px;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-right: 10px;
}
.tab-btn.active {
border-bottom-color: #007cba;
color: #007cba;
font-weight: bold;
}
.tab-content {
display: none;
padding: 20px 0;
}
.tab-content.active {
display: block;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stats-card {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
border-left: 4px solid #007cba;
}
.stats-card h3,
.stats-card h4 {
margin: 0 0 10px 0;
color: #333;
font-size: 14px;
}
.stats-number {
font-size: 24px;
font-weight: bold;
color: #007cba;
margin: 10px 0;
}
.stats-label {
color: #666;
font-size: 12px;
margin-bottom: 5px;
}
.stats-detail {
color: #888;
font-size: 11px;
}
.chart-container {
background: white;
padding: 20px;
border-radius: 8px;
border: 1px solid #ddd;
margin: 20px 0;
}
.chart-container h3 {
margin: 0 0 20px 0;
color: #333;
}
.text-success {
color: #28a745;
}
.text-warning {
color: #ffc107;
}
.text-danger {
color: #dc3545;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
// 탭 전환
function showTab(tabName) {
// 모든 탭 비활성화
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
// 선택된 탭 활성화
event.target.classList.add('active');
document.getElementById(tabName + '-tab').classList.add('active');
}
// CSV 내보내기
function exportCSV() {
const exportType = document.getElementById('export_type').value;
const dateFrom = document.getElementById('date_from').value;
const dateTo = document.getElementById('date_to').value;
const url = `?export=csv&export_type=${exportType}&date_from=${dateFrom}&date_to=${dateTo}`;
window.location.href = url;
}
// 차트 데이터 준비
const monthlyTrendData = <?php echo json_encode($estimate_stats['monthly_trend']); ?>;
const monthlyRevenueData = <?php echo json_encode($revenue_stats['monthly_revenue']); ?>;
// 월별 트렌드 차트
if (document.getElementById('monthlyTrendChart')) {
const ctx1 = document.getElementById('monthlyTrendChart').getContext('2d');
new Chart(ctx1, {
type: 'line',
data: {
labels: monthlyTrendData.map(item => item.month),
datasets: [{
label: '총 견적수',
data: monthlyTrendData.map(item => item.total_count),
borderColor: '#007cba',
backgroundColor: 'rgba(0, 124, 186, 0.1)',
tension: 0.4
}, {
label: '선택된 견적수',
data: monthlyTrendData.map(item => item.selected_count),
borderColor: '#28a745',
backgroundColor: 'rgba(40, 167, 69, 0.1)',
tension: 0.4
}]
},
options: {
responsive: true,
scales: {
y: {
beginAtZero: true
}
}
}
});
}
// 전환율 차트
if (document.getElementById('conversionChart')) {
const ctx2 = document.getElementById('conversionChart').getContext('2d');
new Chart(ctx2, {
type: 'bar',
data: {
labels: monthlyTrendData.map(item => item.month),
datasets: [{
label: '전환율 (%)',
data: monthlyTrendData.map(item => item.conversion_rate),
backgroundColor: 'rgba(255, 193, 7, 0.8)',
borderColor: '#ffc107',
borderWidth: 1
}]
},
options: {
responsive: true,
scales: {
y: {
beginAtZero: true,
max: 100
}
}
}
});
}
// 매출 차트
if (document.getElementById('revenueChart')) {
const ctx3 = document.getElementById('revenueChart').getContext('2d');
new Chart(ctx3, {
type: 'line',
data: {
labels: monthlyRevenueData.map(item => item.month),
datasets: [{
label: '월별 매출 (원)',
data: monthlyRevenueData.map(item => item.revenue),
borderColor: '#dc3545',
backgroundColor: 'rgba(220, 53, 69, 0.1)',
tension: 0.4,
yAxisID: 'y'
}, {
label: '주문 건수',
data: monthlyRevenueData.map(item => item.order_count),
borderColor: '#6f42c1',
backgroundColor: 'rgba(111, 66, 193, 0.1)',
tension: 0.4,
yAxisID: 'y1'
}]
},
options: {
responsive: true,
scales: {
y: {
type: 'linear',
display: true,
position: 'left',
beginAtZero: true
},
y1: {
type: 'linear',
display: true,
position: 'right',
beginAtZero: true,
grid: {
drawOnChartArea: false,
},
}
}
}
});
}
</script>
<?php
include_once(G5_ADMIN_PATH . '/admin.tail.php');
?>
+176
View File
@@ -0,0 +1,176 @@
<?php
$sub_menu = '800700';
include_once('./_common.php');
if ($is_admin != 'super') {
alert('최고관리자만 접근 가능합니다.');
}
// 상태명 저장 처리
if ($_POST['mode'] == 'update_status_names') {
foreach ($_POST['status'] as $code => $name) {
$code = sql_real_escape_string($code);
$name = sql_real_escape_string($name);
$config_key = 'status_' . $code;
sql_query("UPDATE order_config SET config_value = '{$name}' WHERE config_key = '{$config_key}'");
}
alert('상태명이 저장되었습니다.', './status_manager.php');
}
// 현재 상태 설정 조회
$status_configs = [];
$result = sql_query("SELECT config_key, config_value, config_desc FROM order_config WHERE config_key LIKE 'status_%' ORDER BY config_key");
while ($row = sql_fetch_array($result)) {
$status_code = str_replace('status_', '', $row['config_key']);
$status_configs[$status_code] = $row;
}
$customer_statuses = ['견적신청중', '작성완료', '계약금입금예정', '계약금입금완료', '중도금입금예정', '중도금입금완료', '잔금입금예정', '잔금처리완료', '시공완료'];
$agent_statuses = ['견적제안', '견적채택', '견적취소', '시공완료'];
$g5['title'] = '상태 관리';
include_once(G5_ADMIN_PATH . '/admin.head.php');
?>
<div class="local_desc01 local_desc">
<p>견적 상태 코드는 변경하지 않고, 화면에 표시되는 이름만 수정할 수 있습니다.</p>
</div>
<form name="fstatusform" method="post" action="./status_manager.php">
<input type="hidden" name="mode" value="update_status_names">
<section id="anc_status_manager">
<h2>고객 상태 관리</h2>
<div class="tbl_frm01 tbl_wrap">
<table>
<caption>고객 견적 상태 표시명 설정</caption>
<colgroup>
<col class="grid_3">
<col class="grid_3">
<col>
<col class="grid_2">
</colgroup>
<thead>
<tr>
<th scope="col">상태 코드</th>
<th scope="col">현재 표시명</th>
<th scope="col">새 표시명</th>
<th scope="col">설명</th>
</tr>
</thead>
<tbody>
<?php
// $customer_statuses = ['견적신청중', '작성완료', '계약금입금예정', '계약금입금완료', '중도금입금예정', '중도금입금완료', '잔금입금예정', '잔금처리완료', '시공완료'];
foreach ($customer_statuses as $code) {
if (isset($status_configs[$code])) {
$config = $status_configs[$code];
?>
<tr>
<td><strong><?php echo $code; ?></strong></td>
<td><?php echo htmlspecialchars($config['config_value']); ?></td>
<td>
<input type="text" name="status[<?php echo $code; ?>]"
value="<?php echo htmlspecialchars($config['config_value']); ?>" class="frm_input"
size="20">
</td>
<td><?php echo $config['config_desc']; ?></td>
</tr>
<?php
}
}
?>
</tbody>
</table>
</div>
<h2 style="margin-top:30px;">대리점 상태 관리</h2>
<div class="tbl_frm01 tbl_wrap">
<table>
<caption>대리점 견적 상태 표시명 설정</caption>
<colgroup>
<col class="grid_3">
<col class="grid_3">
<col>
<col class="grid_2">
</colgroup>
<thead>
<tr>
<th scope="col">상태 코드</th>
<th scope="col">현재 표시명</th>
<th scope="col">새 표시명</th>
<th scope="col">설명</th>
</tr>
</thead>
<tbody>
<?php
// $agent_statuses = ['견적제안', '견적채택', '견적취소', '시공완료'];
foreach ($agent_statuses as $code) {
if (isset($status_configs[$code])) {
$config = $status_configs[$code];
?>
<tr>
<td><strong><?php echo $code; ?></strong></td>
<td><?php echo htmlspecialchars($config['config_value']); ?></td>
<td>
<input type="text" name="status[<?php echo $code; ?>]"
value="<?php echo htmlspecialchars($config['config_value']); ?>" class="frm_input"
size="20">
</td>
<td><?php echo $config['config_desc']; ?></td>
</tr>
<?php
}
}
?>
</tbody>
</table>
</div>
<h2 style="margin-top:30px;">상태 전환 규칙</h2>
<div class="local_desc02">
<h3>고객 상태 전환:</h3>
<p><strong>견적신청중</strong> → 작성완료 → 견적채택 → 입금예정 → 입금확인 → 다운로드</p>
<h3>대리점 상태 전환:</h3>
<p><strong>견적제안</strong> → 견적채택 또는 견적취소</p>
<h3>관리자 권한:</h3>
<p>- 모든 상태 변경 가능<br>
- 작성완료 → 견적신청중 (되돌리기)<br>
- 견적취소 → 견적제안 (되돌리기)<br>
- 다운로드 → 견적신청중 (루프백)</p>
</div>
</section>
<div class="btn_confirm01 btn_confirm">
<input type="submit" value="상태명 저장" class="btn_submit">
</div>
</form>
<style>
.local_desc02 {
background: #f8f9fa;
border: 1px solid #dee2e6;
padding: 20px;
margin-top: 20px;
}
.local_desc02 h3 {
color: #495057;
margin-top: 15px;
margin-bottom: 8px;
}
.local_desc02 p {
margin-bottom: 10px;
line-height: 1.6;
}
</style>
<?php
include_once(G5_ADMIN_PATH . '/admin.tail.php');
?>
+171
View File
@@ -0,0 +1,171 @@
<?php
$sub_menu = "800700";
include_once('./_common.php');
auth_check_menu($auth, $sub_menu, 'r');
$g5['title'] = 'StatusManager 테스트';
// StatusManager 클래스 로드
require_once G5_PATH . '/adm/order_manage/classes/StatusManager.class.php';
$status_manager = new StatusManager();
$test_results = [];
// 테스트 실행
if ($_POST['action'] === 'test') {
$wr_id = (int) $_POST['wr_id'];
$new_status = trim($_POST['new_status']);
$user_role = trim($_POST['user_role']);
$user_id = $member['mb_id'];
if ($wr_id && $new_status && $user_role) {
$result = $status_manager->changeStatus($wr_id, $new_status, $user_id, $user_role, 'StatusManager 테스트');
$test_results[] = $result;
}
}
// 최근 견적 목록 조회 (테스트용)
$recent_estimates = [];
$sql = "
SELECT w.wr_id, w.wr_subject, w.mb_id, e.status, e.updated_at
FROM {$g5['write_prefix']}order w
LEFT JOIN estimate e ON w.wr_id = e.wr_id
WHERE w.wr_parent = 0
ORDER BY w.wr_datetime DESC
LIMIT 10
";
$result = sql_query($sql);
while ($row = sql_fetch_array($result)) {
$recent_estimates[] = $row;
}
include_once(G5_ADMIN_PATH . '/admin.head.php');
?>
<div class="local_desc01 local_desc">
<p>
StatusManager 클래스의 상태 변경 기능을 테스트합니다.<br>
실제 데이터가 변경되므로 주의해서 사용하세요.
</p>
</div>
<?php if (!empty($test_results)): ?>
<div class="local_desc02 local_desc">
<h3>테스트 결과</h3>
<?php foreach ($test_results as $result): ?>
<div
style="padding: 10px; margin: 10px 0; border: 1px solid <?php echo $result['success'] ? '#28a745' : '#dc3545'; ?>; background: <?php echo $result['success'] ? '#d4edda' : '#f8d7da'; ?>;">
<strong><?php echo $result['success'] ? '성공' : '실패'; ?>:</strong>
<?php echo htmlspecialchars($result['message']); ?>
<?php if (isset($result['data'])): ?>
<br><small>변경 내용: <?php echo htmlspecialchars(json_encode($result['data'], JSON_UNESCAPED_UNICODE)); ?></small>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<form name="ftest" method="post" action="./status_test.php">
<input type="hidden" name="action" value="test">
<div class="tbl_frm01 tbl_wrap">
<table>
<caption>StatusManager 테스트</caption>
<colgroup>
<col class="grid_4">
<col>
</colgroup>
<tbody>
<tr>
<th scope="row"><label for="wr_id">게시물 ID</label></th>
<td>
<select name="wr_id" id="wr_id" class="frm_input">
<option value="">선택하세요</option>
<?php foreach ($recent_estimates as $estimate): ?>
<option value="<?php echo $estimate['wr_id']; ?>">
<?php echo $estimate['wr_id']; ?> -
<?php echo htmlspecialchars($estimate['wr_subject']); ?>
(현재: <?php echo $estimate['status'] ?: '견적신청중'; ?>)
</option>
<?php endforeach; ?>
</select>
</td>
</tr>
<tr>
<th scope="row"><label for="new_status">새 상태</label></th>
<td>
<select name="new_status" id="new_status" class="frm_input">
<option value="">선택하세요</option>
<option value="견적신청중">견적신청중</option>
<option value="작성완료">작성완료</option>
<option value="견적채택">견적채택</option>
<option value="입금예정">입금예정</option>
<option value="입금확인">입금확인</option>
<option value="다운로드">다운로드</option>
<option value="견적제안">견적제안</option>
<option value="견적취소">견적취소</option>
</select>
</td>
</tr>
<tr>
<th scope="row"><label for="user_role">사용자 역할</label></th>
<td>
<select name="user_role" id="user_role" class="frm_input">
<option value="">선택하세요</option>
<option value="admin">관리자</option>
<option value="customer">고객</option>
<option value="agent">대리점</option>
</select>
</td>
</tr>
</tbody>
</table>
</div>
<div class="btn_confirm01 btn_confirm">
<input type="submit" value="상태 변경 테스트" class="btn_submit">
</div>
</form>
<div class="local_desc02 local_desc">
<h3>최근 견적 목록</h3>
<table class="tbl_head01 tbl_wrap">
<thead>
<tr>
<th>ID</th>
<th>제목</th>
<th>작성자</th>
<th>현재 상태</th>
<th>수정일</th>
</tr>
</thead>
<tbody>
<?php foreach ($recent_estimates as $estimate): ?>
<tr>
<td><?php echo $estimate['wr_id']; ?></td>
<td><?php echo htmlspecialchars($estimate['wr_subject']); ?></td>
<td><?php echo htmlspecialchars($estimate['mb_id']); ?></td>
<td><?php echo $estimate['status'] ?: '견적신청중'; ?></td>
<td><?php echo $estimate['updated_at'] ?: '-'; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="local_desc02 local_desc">
<h3>StatusManager 주요 기능</h3>
<ul>
<li><strong>상태 전환 검증:</strong> 사용자 권한별 허용된 상태 변경만 가능</li>
<li><strong>비즈니스 규칙 검증:</strong> 24시간 제한, 중복 선택 방지 등</li>
<li><strong>자동 이력 기록:</strong> 모든 상태 변경이 estimate_history에 기록</li>
<li><strong>후속 처리:</strong> 알림 발송, 자동 상태 전환, 다른 견적 취소 등</li>
<li><strong>트랜잭션 처리:</strong> 오류 발생 시 자동 롤백</li>
</ul>
</div>
<?php
include_once(G5_ADMIN_PATH . '/admin.tail.php');
?>
+213
View File
@@ -0,0 +1,213 @@
<?php
include_once('../../../_common.php');
// 알림 시스템 로드
if (file_exists(G5_LIB_PATH . '/notification_helper.php')) {
include_once(G5_LIB_PATH . '/notification_helper.php');
}
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['success' => false, 'message' => 'POST 요청만 허용됩니다.']);
exit;
}
$wr_id = (int) ($_POST['wr_id'] ?? 0);
$new_status = trim($_POST['new_status'] ?? '');
$memo = trim($_POST['memo'] ?? '');
if (!$wr_id || !$new_status) {
echo json_encode(['success' => false, 'message' => '필수 파라미터가 누락되었습니다.']);
exit;
}
if (!$is_member) {
echo json_encode(['success' => false, 'message' => '로그인이 필요합니다.']);
exit;
}
$is_admin = ($member['mb_level'] ?? 0) >= 8;
$is_agent = in_array(($member['mb_level'] ?? 0), [5, 6, 7]);
try {
sql_query("START TRANSACTION");
$write_table = $g5['write_prefix'] . 'order';
$write = sql_fetch("SELECT * FROM {$write_table} WHERE wr_id = '{$wr_id}'");
if (!$write) {
throw new Exception('게시물을 찾을 수 없습니다.');
}
$is_owner = ($member['mb_id'] === $write['mb_id']);
if (!$is_admin && !$is_owner && !$is_agent) {
throw new Exception('권한이 없습니다.');
}
$current_estimate = sql_fetch("SELECT * FROM estimate WHERE wr_id = '{$wr_id}'");
$old_status = $current_estimate ? $current_estimate['status'] : '견적신청중';
// 권한별 상태 변경 검증
$allowed = false;
if ($is_admin) {
$allowed = true; // 관리자는 모든 상태 변경 가능
} elseif ($is_owner && empty($write['wr_parent'])) {
// 고객 (원본글 작성자)
if ($old_status === '견적신청중' && $new_status === '작성완료') {
$allowed = true;
} elseif ($old_status === '작성완료' && $new_status === '견적채택') {
$allowed = true;
}
} elseif ($is_agent && !empty($write['wr_parent']) && $write['mb_id'] === $member['mb_id']) {
// 대리점 (답글 작성자)
if ($old_status === '견적제안' && in_array($new_status, ['견적채택', '견적취소'])) {
$allowed = true;
}
}
if (!$allowed) {
throw new Exception('해당 상태로 변경할 권한이 없습니다.');
}
// 상태 업데이트
if ($current_estimate) {
sql_query("UPDATE estimate SET
status = '{$new_status}',
updated_at = NOW(),
updated_by = '{$member['mb_id']}'
WHERE wr_id = '{$wr_id}'");
} else {
sql_query("INSERT INTO estimate (wr_id, status, created_at, created_by, updated_at, updated_by)
VALUES ('{$wr_id}', '{$new_status}', NOW(), '{$member['mb_id']}', NOW(), '{$member['mb_id']}')");
}
// 게시판 wr_1 필드도 업데이트
sql_query("UPDATE {$write_table} SET wr_1 = '{$new_status}' WHERE wr_id = '{$wr_id}'");
// 이력 기록
$history_data = json_encode([
'old_status' => $old_status,
'new_status' => $new_status,
'changed_by' => $member['mb_id'],
'changed_at' => date('Y-m-d H:i:s'),
'memo' => $memo,
'ip' => $_SERVER['REMOTE_ADDR']
], JSON_UNESCAPED_UNICODE);
$estimate = sql_fetch("SELECT id FROM estimate WHERE wr_id = '{$wr_id}'");
$estimate_id = $estimate ? $estimate['id'] : 0;
sql_query("INSERT INTO estimate_history (
estimate_id, action, change_details, changed_by, changed_at
) VALUES (
'{$estimate_id}', 'status_change', '{$history_data}', '{$member['mb_id']}', NOW()
)");
// 알림 발송
processStatusChangeNotification($write, $old_status, $new_status, $member);
// 견적채택 특별 처리
if ($new_status === '견적채택' && !empty($write['wr_parent'])) {
handleQuoteSelection($write, $member);
}
sql_query("COMMIT");
echo json_encode([
'success' => true,
'message' => '상태가 성공적으로 변경되었습니다.',
'data' => [
'old_status' => $old_status,
'new_status' => $new_status
]
]);
} catch (Exception $e) {
sql_query("ROLLBACK");
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
function processStatusChangeNotification($write, $old_status, $new_status, $member) {
switch ($new_status) {
case '작성완료':
notifyAgentsNewRequest($write);
break;
case '견적제안':
notifyCustomerQuoteReceived($write);
break;
case '입금확인':
notifyPaymentConfirmed($write, $member);
break;
}
}
function notifyAgentsNewRequest($write) {
$agents_sql = "SELECT mb_id, mb_name, mb_email, mb_hp FROM {$GLOBALS['g5']['member_table']}
WHERE mb_level IN (5,6,7) AND mb_leave_date = '' AND mb_intercept_date = ''";
$agents = sql_query($agents_sql);
while ($agent = sql_fetch_array($agents)) {
$subject = "[견적요청] 새로운 견적 요청이 등록되었습니다";
$content = "안녕하세요 {$agent['mb_name']}님,\n\n새로운 견적 요청이 등록되었습니다.\n\n고객명: {$write['wr_name']}\n요청제목: {$write['wr_subject']}\n등록시간: " . date('Y-m-d H:i') . "\n\n확인 URL: " . G5_HTTP_BBS_URL . "/board.php?bo_table=order&wr_id={$write['wr_id']}\n\n감사합니다.";
@mailer($agent['mb_name'], $agent['mb_email'], $subject, $content, 1);
if ($agent['mb_hp']) {
@send_sms($agent['mb_hp'], "[견적요청] {$write['wr_name']}님의 새 견적요청이 등록되었습니다.");
}
}
}
function notifyCustomerQuoteReceived($write) {
$parent_write = sql_fetch("SELECT * FROM {$GLOBALS['g5']['write_prefix']}order WHERE wr_id = '{$write['wr_parent']}'");
if ($parent_write) {
$customer = get_member($parent_write['mb_id']);
$agent_name = get_member_name($write['mb_id']);
@mailer($parent_write['wr_name'], $customer['mb_email'], "[견적도착] {$agent_name}님이 견적을 제안했습니다",
"안녕하세요 {$parent_write['wr_name']}님,\n\n{$agent_name}님이 견적을 제안했습니다.\n\n확인해주세요.", 1);
if ($customer['mb_hp']) {
@send_sms($customer['mb_hp'], "[견적도착] {$agent_name}님이 견적을 제안했습니다.");
}
}
}
function notifyPaymentConfirmed($write, $admin_member) {
$customer = get_member($write['mb_id']);
@mailer($write['wr_name'], $customer['mb_email'], "[입금확인] 입금이 확인되었습니다",
"안녕하세요 {$write['wr_name']}님,\n\n입금이 확인되었습니다.\n\n감사합니다.", 1);
if ($customer['mb_hp']) {
@send_sms($customer['mb_hp'], "[입금확인] 입금이 확인되었습니다.");
}
}
function handleQuoteSelection($write, $member) {
$origin_wr_id = $write['wr_parent'];
$write_table = $GLOBALS['g5']['write_prefix'] . 'order';
sql_query("UPDATE estimate SET status = '입금예정' WHERE wr_id = '{$origin_wr_id}'");
sql_query("UPDATE {$write_table} SET wr_1 = '견적취소' WHERE wr_parent = '{$origin_wr_id}' AND wr_id != '{$write['wr_id']}'");
$agent = get_member($write['mb_id']);
@mailer($agent['mb_name'], $agent['mb_email'], "[견적채택] 축하합니다!", "견적이 채택되었습니다.", 1);
if ($agent['mb_hp']) {
@send_sms($agent['mb_hp'], "[견적채택] 축하합니다! 견적이 채택되었습니다.");
}
}
function get_member_name($mb_id) {
if (!$mb_id) return '';
$member = sql_fetch("SELECT mb_name FROM {$GLOBALS['g5']['member_table']} WHERE mb_id = '{$mb_id}'");
return $member ? $member['mb_name'] : $mb_id;
}
function send_sms($phone, $message) {
return true;
}
?>
+142
View File
@@ -0,0 +1,142 @@
<?php
require_once './_common.php';
// 관리자 권한 체크
if (!$is_admin) {
die('관리자만 접근 가능합니다.');
}
echo "<h2>견적서 시스템 기능 테스트</h2>";
// 1. 데이터베이스 테이블 확인
echo "<h3>1. 데이터베이스 테이블 확인</h3>";
$tables_to_check = ['order_config', 'order_mail_templates', 'order_sms_templates', 'estimate'];
foreach ($tables_to_check as $table) {
$full_table_name = ($table === 'estimate') ? $table : G5_TABLE_PREFIX . $table;
$result = sql_query("SHOW TABLES LIKE '{$full_table_name}'", false);
if ($result && sql_num_rows($result) > 0) {
echo "<p style='color: green;'>✓ {$table} 테이블 존재</p>";
// 데이터 개수 확인
$count_result = sql_query("SELECT COUNT(*) as cnt FROM {$full_table_name}", false);
if ($count_result) {
$count_row = sql_fetch_array($count_result);
echo "<p style='margin-left: 20px;'>- 데이터 개수: {$count_row['cnt']}개</p>";
}
} else {
echo "<p style='color: red;'>✗ {$table} 테이블 없음</p>";
}
}
// 2. notification_helper.php 함수 테스트
echo "<h3>2. 알림 시스템 함수 테스트</h3>";
include_once G5_LIB_PATH . '/notification_helper.php';
// get_order_config 함수 테스트
$timer_enabled = get_order_config('timer_enabled', '1');
$visit_fee = get_order_config('expert_visit_fee', '50000');
echo "<p>타이머 활성화: " . ($timer_enabled ? '예' : '아니오') . "</p>";
echo "<p>전문가 방문 비용: " . number_format($visit_fee) . "원</p>";
// 3. 템플릿 시스템 테스트
echo "<h3>3. 템플릿 시스템 테스트</h3>";
$mail_templates = sql_query("SELECT template_key, template_name FROM order_mail_templates LIMIT 5");
if ($mail_templates && sql_num_rows($mail_templates) > 0) {
echo "<p style='color: green;'>✓ 메일 템플릿 로드 성공</p>";
echo "<ul>";
while ($row = sql_fetch_array($mail_templates)) {
echo "<li>{$row['template_key']}: {$row['template_name']}</li>";
}
echo "</ul>";
} else {
echo "<p style='color: red;'>✗ 메일 템플릿 없음</p>";
}
$sms_templates = sql_query("SELECT template_key, template_name FROM order_sms_templates LIMIT 5");
if ($sms_templates && sql_num_rows($sms_templates) > 0) {
echo "<p style='color: green;'>✓ SMS 템플릿 로드 성공</p>";
echo "<ul>";
while ($row = sql_fetch_array($sms_templates)) {
echo "<li>{$row['template_key']}: {$row['template_name']}</li>";
}
echo "</ul>";
} else {
echo "<p style='color: red;'>✗ SMS 템플릿 없음</p>";
}
// 4. estimate 테이블 구조 확인
echo "<h3>4. estimate 테이블 구조 확인</h3>";
$result = sql_query("DESCRIBE estimate", false);
if ($result) {
echo "<table border='1' style='border-collapse: collapse;'>";
echo "<tr><th>컬럼명</th><th>타입</th><th>설명</th></tr>";
while ($row = sql_fetch_array($result)) {
$description = '';
if (strpos($row['Field'], 'temp_') === 0) {
switch ($row['Field']) {
case 'temp_1':
$description = '전문가 방문 요청 여부 (Y/N)';
break;
case 'temp_2':
$description = '전문가 방문 상태';
break;
case 'temp_3':
$description = '전문가 방문 비용';
break;
case 'temp_4':
$description = '전문가 방문 일정';
break;
case 'temp_5':
$description = '전문가 방문 메모';
break;
}
} elseif (strpos($row['Field'], 'extra_') === 0) {
switch ($row['Field']) {
case 'extra_1':
$description = '고객 연락처';
break;
case 'extra_2':
$description = '결제 상태';
break;
case 'extra_3':
$description = '추가 요청사항';
break;
case 'extra_4':
$description = '관리자 메모';
break;
case 'extra_5':
$description = '예비 필드';
break;
}
}
echo "<tr><td>{$row['Field']}</td><td>{$row['Type']}</td><td>{$description}</td></tr>";
}
echo "</table>";
} else {
echo "<p style='color: red;'>✗ estimate 테이블 구조 확인 실패</p>";
}
// 5. 24시간 타이머 계산 테스트
echo "<h3>5. 24시간 타이머 계산 테스트</h3>";
$now = time();
$test_time = date('Y-m-d H:i:s', $now - 3600); // 1시간 전
$test_timestamp = strtotime($test_time);
$elapsed = $now - $test_timestamp;
$limit_seconds = 24 * 60 * 60; // 24시간
$remain = $limit_seconds - $elapsed;
echo "<p>현재 시간: " . date('Y-m-d H:i:s', $now) . "</p>";
echo "<p>테스트 시작 시간: {$test_time}</p>";
echo "<p>경과 시간: " . gmdate('H:i:s', $elapsed) . "</p>";
echo "<p>남은 시간: " . gmdate('H:i:s', $remain) . "</p>";
echo "<p>타이머 활성화: " . ($remain > 0 ? '예' : '아니오') . "</p>";
echo "<hr>";
echo "<h3>테스트 완료</h3>";
echo "<p><a href='./execute_update.php'>데이터베이스 업데이트 실행</a></p>";
echo "<p><a href='./config_manager.php'>시스템 설정 관리</a></p>";
echo "<p><a href='./expert_visits.php'>전문가 방문 관리</a></p>";
echo "<p><a href='./mail_templates.php'>메일 템플릿 관리</a></p>";
echo "<p><a href='./sms_templates.php'>SMS 템플릿 관리</a></p>";
?>
@@ -0,0 +1,120 @@
-- 기존 테이블 활용 최소 변경 SQL
-- 기존 temp_, extra_ 컬럼을 활용하여 새 기능 구현
-- 1. 시스템 설정을 위한 간단한 테이블 (기존 테이블 없을 경우만 생성)
CREATE TABLE IF NOT EXISTS `order_config` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`config_key` varchar(100) NOT NULL,
`config_value` text NOT NULL,
`config_desc` varchar(255) DEFAULT NULL,
`config_type` varchar(20) DEFAULT 'text',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `config_key` (`config_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- 기본 설정값 삽입 (중복 시 무시)
INSERT IGNORE INTO `order_config` (`config_key`, `config_value`, `config_desc`, `config_type`) VALUES
('timer_enabled', '1', '24시간 타이머 활성화 여부', 'boolean'),
('timer_message_active', '견적 제안 마감까지 {time} 남았습니다.', '타이머 활성화 시 메시지', 'text'),
('timer_message_inactive', '고객 작성이 완료되었습니다. 24시간 후에 확인 바랍니다.', '타이머 비활성화 시 메시지', 'text'),
('contract_deposit_rate', '10', '계약금 비율 (%)', 'number'),
('middle_payment_rate', '40', '중도금 비율 (%)', 'number'),
('expert_visit_fee', '50000', '전문가 방문 비용', 'number'),
('expert_account_info', '국민은행 123-456-789 (주)창호전문가', '전문가 방문 계좌 정보', 'text');
-- 2. 메일 템플릿 테이블 (기존 테이블 없을 경우만 생성)
CREATE TABLE IF NOT EXISTS `order_mail_templates` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`template_key` varchar(100) NOT NULL,
`template_name` varchar(255) NOT NULL,
`subject` varchar(255) NOT NULL,
`content` text NOT NULL,
`variables` text DEFAULT NULL COMMENT '사용 가능한 변수들 (JSON)',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `template_key` (`template_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- 3. SMS 템플릿 테이블 (기존 테이블 없을 경우만 생성)
CREATE TABLE IF NOT EXISTS `order_sms_templates` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`template_key` varchar(100) NOT NULL,
`template_name` varchar(255) NOT NULL,
`content` text NOT NULL,
`variables` text DEFAULT NULL COMMENT '사용 가능한 변수들 (JSON)',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `template_key` (`template_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- 4. 기존 estimate 테이블의 temp_ 컬럼 활용 정의
-- temp_1: 전문가 방문 요청 여부 ('Y'/'N')
-- temp_2: 전문가 방문 상태 ('requested'/'scheduled'/'completed'/'cancelled')
-- temp_3: 전문가 방문 비용
-- temp_4: 전문가 방문 일정 (YYYY-MM-DD HH:MM)
-- temp_5: 전문가 방문 메모
-- 5. 기본 메일 템플릿 삽입 (중복 시 무시)
INSERT IGNORE INTO `order_mail_templates` (`template_key`, `template_name`, `subject`, `content`, `variables`) VALUES
('customer_request_complete', '고객 - 견적 요청 완료', '견적 요청이 완료되었습니다',
'안녕하세요 {customer_name}님,<br><br>견적 요청이 성공적으로 완료되었습니다.<br>24시간 후에 견적서를 확인해주시기 바랍니다.<br><br>감사합니다.',
'["customer_name", "request_title", "request_date"]'),
('agent_new_request', '대리점 - 새 견적 요청', '새로운 견적 요청이 등록되었습니다',
'안녕하세요 {agent_name}님,<br><br>{customer_name}님이 견적을 요청했습니다.<br><br>제목: {request_title}<br>요청일: {request_date}<br><br>견적 작성 URL: {write_url}<br><br>24시간 내에 견적을 제안해주시기 바랍니다.',
'["agent_name", "customer_name", "request_title", "request_date", "write_url"]'),
('customer_quote_selected', '고객 - 견적 선택 완료', '견적이 선택되었습니다',
'안녕하세요 {customer_name}님,<br><br>{agent_name} 대리점의 견적이 선택되었습니다.<br><br>계약금({deposit_amount}원)을 입금해주시기 바랍니다.<br><br>계좌정보: {account_info}',
'["customer_name", "agent_name", "deposit_amount", "account_info"]'),
('agent_quote_selected', '대리점 - 견적 선택됨', '견적이 선택되었습니다',
'안녕하세요 {agent_name}님,<br><br>{customer_name}님이 귀하의 견적을 선택하셨습니다.<br><br>시공 일정을 관리자에게 알려주시기 바랍니다.<br><br>감사합니다.',
'["agent_name", "customer_name", "request_title"]'),
('expert_visit_request', '전문가 방문 요청', '전문가 방문이 요청되었습니다',
'안녕하세요 {customer_name}님,<br><br>전문가 방문이 요청되었습니다.<br><br>방문 비용: {visit_fee}원<br>계좌정보: {account_info}<br><br>입금 확인 후 방문 일정을 조율하겠습니다.',
'["customer_name", "visit_fee", "account_info"]');
-- 6. 기본 SMS 템플릿 삽입 (중복 시 무시)
INSERT IGNORE INTO `order_sms_templates` (`template_key`, `template_name`, `content`, `variables`) VALUES
('customer_request_complete', '고객 - 견적 요청 완료',
'{customer_name}님, 견적 요청이 완료되었습니다. 24시간 후에 확인해주세요.',
'["customer_name"]'),
('agent_new_request', '대리점 - 새 견적 요청',
'{agent_name}님, {customer_name}님이 견적을 요청했습니다. 24시간 내에 제안해주세요. {write_url}',
'["agent_name", "customer_name", "write_url"]'),
('customer_quote_selected', '고객 - 견적 선택 완료',
'{customer_name}님, 견적이 선택되었습니다. 계약금 {deposit_amount}원을 입금해주세요.',
'["customer_name", "deposit_amount"]'),
('agent_quote_selected', '대리점 - 견적 선택됨',
'{agent_name}님, {customer_name}님이 귀하의 견적을 선택하셨습니다. 시공 일정을 알려주세요.',
'["agent_name", "customer_name"]'),
('expert_visit_request', '전문가 방문 요청',
'{customer_name}님, 전문가 방문이 요청되었습니다. 방문비 {visit_fee}원 입금 후 일정 조율하겠습니다.',
'["customer_name", "visit_fee"]');
-- 7. 기존 테이블 컬럼 활용 방법 주석
/*
기존 estimate 테이블의 temp_ 컬럼 활용:
- temp_1: 전문가 방문 요청 여부 ('Y'/'N')
- temp_2: 전문가 방문 상태 ('requested'/'scheduled'/'completed'/'cancelled')
- temp_3: 전문가 방문 비용 (숫자)
- temp_4: 전문가 방문 일정 (YYYY-MM-DD HH:MM 형식)
- temp_5: 전문가 방문 메모/특이사항
기존 estimate 테이블의 extra_ 컬럼 활용:
- extra_1: 고객 연락처 (전문가 방문용)
- extra_2: 결제 상태 ('pending'/'paid'/'cancelled')
- extra_3: 추가 요청사항
- extra_4: 관리자 메모
- extra_5: 예비 필드
*/
+670
View File
@@ -0,0 +1,670 @@
<?php
$sub_menu = "800760";
include_once('./_common.php');
auth_check($auth[$sub_menu], 'r');
$g5['title'] = '워크플로우 통합 테스트';
// 필요한 클래스들 로드
require_once G5_PATH . '/adm/order_manage/classes/EstimateManager.class.php';
require_once G5_PATH . '/adm/order_manage/classes/StatusManager.class.php';
require_once G5_PATH . '/adm/order_manage/classes/NotificationHelper.class.php';
$estimate_manager = new EstimateManager();
$status_manager = new StatusManager();
$notification_helper = new NotificationHelper();
// 테스트 액션 처리
$action = isset($_REQUEST['action']) ? clean_xss_tags($_REQUEST['action']) : '';
$test_results = [];
if ($action && $_SERVER['REQUEST_METHOD'] === 'POST') {
auth_check($auth[$sub_menu], 'w');
try {
switch ($action) {
case 'test_customer_workflow':
$test_results = runCustomerWorkflowTest();
break;
case 'test_dealer_workflow':
$test_results = runDealerWorkflowTest();
break;
case 'test_admin_workflow':
$test_results = runAdminWorkflowTest();
break;
case 'test_expert_visit_workflow':
$test_results = runExpertVisitWorkflowTest();
break;
case 'test_full_integration':
$test_results = runFullIntegrationTest();
break;
case 'cleanup_test_data':
$test_results = cleanupTestData();
break;
}
} catch (Exception $e) {
$test_results = [
'success' => false,
'message' => '테스트 실행 중 오류 발생: ' . $e->getMessage(),
'details' => []
];
}
}
// 테스트 함수들
function runCustomerWorkflowTest()
{
global $estimate_manager, $status_manager;
$results = [
'success' => true,
'message' => '고객 워크플로우 테스트',
'details' => []
];
try {
// 1. 테스트 견적 생성
$test_data = [
'wr_id' => 99999, // 테스트용 ID
'zip_code' => '12345',
'address1' => '서울시 강남구',
'address2' => '테스트동 123-45',
'house_type' => '아파트',
'house_size' => '84㎡',
'material' => '시스템창호',
'color' => '화이트',
'glass_thickness' => '24mm',
'install' => '전체교체',
'items' => [
[
'product' => '거실 창호',
'qty' => 2,
'price' => 500000,
'amount' => 1000000
]
]
];
// 견적 생성 테스트
$estimate_id = $estimate_manager->createEstimate($test_data);
if ($estimate_id) {
$results['details'][] = ['step' => '견적 생성', 'status' => 'PASS', 'message' => "견적 ID: {$estimate_id}"];
} else {
throw new Exception('견적 생성 실패');
}
// 2. 상태 변경 테스트 (견적신청중 → 작성완료)
$status_result = $estimate_manager->completeEstimate(99999, 'customer');
if ($status_result['success']) {
$results['details'][] = ['step' => '견적 완료', 'status' => 'PASS', 'message' => $status_result['message']];
} else {
throw new Exception('견적 완료 실패: ' . $status_result['message']);
}
// 3. 견적 선택 시뮬레이션 (가상의 입찰 생성 후 선택)
$bid_data = [
'total_amount' => 1200000,
'message' => '테스트 입찰입니다.'
];
$bid_result = $estimate_manager->createBiddingWithItems($estimate_id, $bid_data, []);
if ($bid_result['success']) {
$results['details'][] = ['step' => '입찰 생성', 'status' => 'PASS', 'message' => '테스트 입찰 생성됨'];
// 견적 선택
$select_result = $estimate_manager->selectBidWithStatus($estimate_id, $bid_result['data']['bidding_id'], 'customer');
if ($select_result['success']) {
$results['details'][] = ['step' => '견적 선택', 'status' => 'PASS', 'message' => $select_result['message']];
} else {
$results['details'][] = ['step' => '견적 선택', 'status' => 'FAIL', 'message' => $select_result['message']];
}
} else {
$results['details'][] = ['step' => '입찰 생성', 'status' => 'FAIL', 'message' => $bid_result['message']];
}
} catch (Exception $e) {
$results['success'] = false;
$results['details'][] = ['step' => '오류', 'status' => 'FAIL', 'message' => $e->getMessage()];
}
return $results;
}
function runDealerWorkflowTest()
{
global $estimate_manager;
$results = [
'success' => true,
'message' => '대리점 워크플로우 테스트',
'details' => []
];
try {
// 1. 기존 견적에 입찰 제출 테스트
$estimate = sql_fetch("SELECT * FROM estimate WHERE is_deleted = 0 AND status = '작성완료' LIMIT 1");
if (!$estimate) {
// 테스트용 견적이 없으면 생성
$results['details'][] = ['step' => '테스트 견적 확인', 'status' => 'INFO', 'message' => '테스트용 견적이 없어 고객 워크플로우를 먼저 실행하세요.'];
return $results;
}
$bid_data = [
'total_amount' => 1500000,
'message' => '대리점 테스트 입찰입니다.'
];
$items_data = [
[
'product' => '테스트 창호',
'qty' => 1,
'price' => 1500000,
'amount' => 1500000,
'brand' => '테스트브랜드'
]
];
$bid_result = $estimate_manager->createBiddingWithItems($estimate['id'], $bid_data, $items_data);
if ($bid_result['success']) {
$results['details'][] = ['step' => '입찰 제출', 'status' => 'PASS', 'message' => '입찰이 성공적으로 제출됨'];
} else {
throw new Exception('입찰 제출 실패: ' . $bid_result['message']);
}
// 2. 시공 일정 설정 테스트 (선택된 견적이 있는 경우)
if ($estimate['selected_bid_id']) {
$construction_date = date('Y-m-d', strtotime('+7 days'));
$schedule_result = $estimate_manager->setConstructionSchedule($estimate['wr_id'], $construction_date, 'agent');
if ($schedule_result['success']) {
$results['details'][] = ['step' => '시공 일정 설정', 'status' => 'PASS', 'message' => $schedule_result['message']];
} else {
$results['details'][] = ['step' => '시공 일정 설정', 'status' => 'FAIL', 'message' => $schedule_result['message']];
}
}
} catch (Exception $e) {
$results['success'] = false;
$results['details'][] = ['step' => '오류', 'status' => 'FAIL', 'message' => $e->getMessage()];
}
return $results;
}
function runAdminWorkflowTest()
{
global $estimate_manager, $status_manager;
$results = [
'success' => true,
'message' => '관리자 워크플로우 테스트',
'details' => []
];
try {
// 1. 상태 관리 테스트
$estimate = sql_fetch("SELECT * FROM estimate WHERE is_deleted = 0 LIMIT 1");
if ($estimate) {
$current_status = $status_manager->getCurrentStatus($estimate['wr_id']);
if ($current_status) {
$results['details'][] = ['step' => '상태 조회', 'status' => 'PASS', 'message' => "현재 상태: {$current_status['status']}"];
} else {
$results['details'][] = ['step' => '상태 조회', 'status' => 'FAIL', 'message' => '상태 조회 실패'];
}
// 2. 결제 상태 업데이트 테스트
if ($estimate['selected_bid_id']) {
$payment_result = $estimate_manager->updatePaymentStatus($estimate['wr_id'], 'deposit', 'admin');
if ($payment_result['success']) {
$results['details'][] = ['step' => '결제 상태 업데이트', 'status' => 'PASS', 'message' => $payment_result['message']];
} else {
$results['details'][] = ['step' => '결제 상태 업데이트', 'status' => 'FAIL', 'message' => $payment_result['message']];
}
}
}
// 3. 통계 데이터 조회 테스트
$stats = $estimate_manager->getEstimateStatistics();
if (!empty($stats['total_statistics'])) {
$results['details'][] = ['step' => '통계 조회', 'status' => 'PASS', 'message' => "총 견적: {$stats['total_statistics']['total_estimates']}"];
} else {
$results['details'][] = ['step' => '통계 조회', 'status' => 'FAIL', 'message' => '통계 데이터 조회 실패'];
}
} catch (Exception $e) {
$results['success'] = false;
$results['details'][] = ['step' => '오류', 'status' => 'FAIL', 'message' => $e->getMessage()];
}
return $results;
}
function runExpertVisitWorkflowTest()
{
global $estimate_manager;
$results = [
'success' => true,
'message' => '전문가 방문 워크플로우 테스트',
'details' => []
];
try {
// 1. 전문가 방문 요청 테스트
$estimate = sql_fetch("SELECT * FROM estimate WHERE is_deleted = 0 AND temp_1 != 'Y' LIMIT 1");
if ($estimate) {
$visit_result = $estimate_manager->requestExpertVisit($estimate['wr_id'], 'customer');
if ($visit_result['success']) {
$results['details'][] = ['step' => '전문가 방문 요청', 'status' => 'PASS', 'message' => $visit_result['message']];
// 2. 결제 확인 테스트
$payment_result = $estimate_manager->confirmExpertVisitPayment($estimate['wr_id'], 'admin');
if ($payment_result['success']) {
$results['details'][] = ['step' => '방문 비용 결제 확인', 'status' => 'PASS', 'message' => $payment_result['message']];
// 3. 일정 설정 테스트
$visit_datetime = date('Y-m-d H:i', strtotime('+3 days 14:00'));
$schedule_result = $estimate_manager->scheduleExpertVisit($estimate['wr_id'], $visit_datetime, 'admin', 'admin');
if ($schedule_result['success']) {
$results['details'][] = ['step' => '방문 일정 설정', 'status' => 'PASS', 'message' => $schedule_result['message']];
// 4. 방문 완료 테스트
$complete_result = $estimate_manager->completeExpertVisit($estimate['wr_id'], '테스트 방문 완료', 'admin');
if ($complete_result['success']) {
$results['details'][] = ['step' => '방문 완료 처리', 'status' => 'PASS', 'message' => $complete_result['message']];
} else {
$results['details'][] = ['step' => '방문 완료 처리', 'status' => 'FAIL', 'message' => $complete_result['message']];
}
} else {
$results['details'][] = ['step' => '방문 일정 설정', 'status' => 'FAIL', 'message' => $schedule_result['message']];
}
} else {
$results['details'][] = ['step' => '방문 비용 결제 확인', 'status' => 'FAIL', 'message' => $payment_result['message']];
}
} else {
$results['details'][] = ['step' => '전문가 방문 요청', 'status' => 'FAIL', 'message' => $visit_result['message']];
}
} else {
$results['details'][] = ['step' => '테스트 견적 확인', 'status' => 'INFO', 'message' => '전문가 방문 테스트용 견적이 없습니다.'];
}
} catch (Exception $e) {
$results['success'] = false;
$results['details'][] = ['step' => '오류', 'status' => 'FAIL', 'message' => $e->getMessage()];
}
return $results;
}
function runFullIntegrationTest()
{
$results = [
'success' => true,
'message' => '전체 통합 테스트',
'details' => []
];
try {
// 1. 고객 워크플로우 테스트
$customer_test = runCustomerWorkflowTest();
$results['details'][] = ['step' => '고객 워크플로우', 'status' => $customer_test['success'] ? 'PASS' : 'FAIL', 'message' => $customer_test['message']];
// 2. 대리점 워크플로우 테스트
$dealer_test = runDealerWorkflowTest();
$results['details'][] = ['step' => '대리점 워크플로우', 'status' => $dealer_test['success'] ? 'PASS' : 'FAIL', 'message' => $dealer_test['message']];
// 3. 관리자 워크플로우 테스트
$admin_test = runAdminWorkflowTest();
$results['details'][] = ['step' => '관리자 워크플로우', 'status' => $admin_test['success'] ? 'PASS' : 'FAIL', 'message' => $admin_test['message']];
// 4. 전문가 방문 워크플로우 테스트
$expert_test = runExpertVisitWorkflowTest();
$results['details'][] = ['step' => '전문가 방문 워크플로우', 'status' => $expert_test['success'] ? 'PASS' : 'FAIL', 'message' => $expert_test['message']];
// 전체 성공 여부 판단
$all_passed = $customer_test['success'] && $dealer_test['success'] && $admin_test['success'] && $expert_test['success'];
$results['success'] = $all_passed;
if ($all_passed) {
$results['message'] = '모든 워크플로우 테스트가 성공적으로 완료되었습니다.';
} else {
$results['message'] = '일부 워크플로우 테스트에서 오류가 발생했습니다.';
}
} catch (Exception $e) {
$results['success'] = false;
$results['details'][] = ['step' => '통합 테스트 오류', 'status' => 'FAIL', 'message' => $e->getMessage()];
}
return $results;
}
function cleanupTestData()
{
$results = [
'success' => true,
'message' => '테스트 데이터 정리',
'details' => []
];
try {
// 테스트용 데이터 삭제
$cleanup_queries = [
"DELETE FROM estimate WHERE wr_id = 99999",
"DELETE FROM estimate_item WHERE estimate_id IN (SELECT id FROM estimate WHERE wr_id = 99999)",
"DELETE FROM estimate_bidding WHERE estimate_id IN (SELECT id FROM estimate WHERE wr_id = 99999)",
"DELETE FROM estimate_history WHERE estimate_id IN (SELECT id FROM estimate WHERE wr_id = 99999)"
];
foreach ($cleanup_queries as $query) {
sql_query($query);
}
$results['details'][] = ['step' => '테스트 데이터 삭제', 'status' => 'PASS', 'message' => '테스트 데이터가 정리되었습니다.'];
} catch (Exception $e) {
$results['success'] = false;
$results['details'][] = ['step' => '정리 오류', 'status' => 'FAIL', 'message' => $e->getMessage()];
}
return $results;
}
include_once(G5_ADMIN_PATH . '/admin.head.php');
?>
<div class="local_desc01 local_desc">
<p>
전체 주문 관리 시스템의 워크플로우를 통합 테스트합니다.<br>
고객, 대리점, 관리자, 전문가 방문의 각 시나리오를 자동으로 검증합니다.
</p>
</div>
<!-- 테스트 실행 버튼들 -->
<div class="test-controls">
<div class="btn-group">
<form method="post" style="display: inline;">
<input type="hidden" name="action" value="test_customer_workflow">
<button type="submit" class="btn btn-primary">고객 워크플로우 테스트</button>
</form>
<form method="post" style="display: inline;">
<input type="hidden" name="action" value="test_dealer_workflow">
<button type="submit" class="btn btn-info">대리점 워크플로우 테스트</button>
</form>
<form method="post" style="display: inline;">
<input type="hidden" name="action" value="test_admin_workflow">
<button type="submit" class="btn btn-warning">관리자 워크플로우 테스트</button>
</form>
<form method="post" style="display: inline;">
<input type="hidden" name="action" value="test_expert_visit_workflow">
<button type="submit" class="btn btn-success">전문가 방문 워크플로우 테스트</button>
</form>
</div>
<div class="btn-group" style="margin-top: 10px;">
<form method="post" style="display: inline;">
<input type="hidden" name="action" value="test_full_integration">
<button type="submit" class="btn btn-danger">전체 통합 테스트</button>
</form>
<form method="post" style="display: inline;">
<input type="hidden" name="action" value="cleanup_test_data">
<button type="submit" class="btn btn-secondary" onclick="return confirm('테스트 데이터를 정리하시겠습니까?')">테스트 데이터
정리</button>
</form>
</div>
</div>
<!-- 테스트 결과 표시 -->
<?php if (!empty($test_results)): ?>
<div class="test-results">
<h3>테스트 결과: <?php echo get_text($test_results['message']); ?></h3>
<div class="result-summary <?php echo $test_results['success'] ? 'success' : 'failure'; ?>">
<strong><?php echo $test_results['success'] ? '✅ 성공' : '❌ 실패'; ?></strong>
<p><?php echo get_text($test_results['message']); ?></p>
</div>
<?php if (!empty($test_results['details'])): ?>
<div class="tbl_head01 tbl_wrap">
<table>
<caption>상세 테스트 결과</caption>
<thead>
<tr>
<th scope="col">단계</th>
<th scope="col">상태</th>
<th scope="col">메시지</th>
</tr>
</thead>
<tbody>
<?php foreach ($test_results['details'] as $detail): ?>
<tr class="<?php echo strtolower($detail['status']); ?>">
<td class="td_left"><?php echo get_text($detail['step']); ?></td>
<td class="td_center">
<span class="status-badge <?php echo strtolower($detail['status']); ?>">
<?php echo $detail['status']; ?>
</span>
</td>
<td class="td_left"><?php echo get_text($detail['message']); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- 시스템 상태 체크 -->
<div class="system-status">
<h3>시스템 상태 체크</h3>
<div class="tbl_head01 tbl_wrap">
<table>
<caption>시스템 구성 요소 상태</caption>
<thead>
<tr>
<th scope="col">구성 요소</th>
<th scope="col">상태</th>
<th scope="col">설명</th>
</tr>
</thead>
<tbody>
<?php
// 시스템 상태 체크
$system_checks = [
[
'component' => 'EstimateManager',
'status' => class_exists('EstimateManager') ? 'OK' : 'ERROR',
'description' => class_exists('EstimateManager') ? '견적 관리 클래스 로드됨' : '견적 관리 클래스 로드 실패'
],
[
'component' => 'StatusManager',
'status' => class_exists('StatusManager') ? 'OK' : 'ERROR',
'description' => class_exists('StatusManager') ? '상태 관리 클래스 로드됨' : '상태 관리 클래스 로드 실패'
],
[
'component' => 'NotificationHelper',
'status' => class_exists('NotificationHelper') ? 'OK' : 'ERROR',
'description' => class_exists('NotificationHelper') ? '알림 헬퍼 클래스 로드됨' : '알림 헬퍼 클래스 로드 실패'
],
[
'component' => 'Database Tables',
'status' => sql_fetch("SHOW TABLES LIKE 'estimate'") ? 'OK' : 'ERROR',
'description' => sql_fetch("SHOW TABLES LIKE 'estimate'") ? '필수 테이블 존재' : '필수 테이블 누락'
],
[
'component' => 'Mail Templates',
'status' => sql_fetch("SHOW TABLES LIKE 'order_mail_templates'") ? 'OK' : 'ERROR',
'description' => sql_fetch("SHOW TABLES LIKE 'order_mail_templates'") ? '메일 템플릿 테이블 존재' : '메일 템플릿 테이블 누락'
],
[
'component' => 'SMS Templates',
'status' => sql_fetch("SHOW TABLES LIKE 'order_sms_templates'") ? 'OK' : 'ERROR',
'description' => sql_fetch("SHOW TABLES LIKE 'order_sms_templates'") ? 'SMS 템플릿 테이블 존재' : 'SMS 템플릿 테이블 누락'
]
];
foreach ($system_checks as $check):
?>
<tr class="<?php echo strtolower($check['status']); ?>">
<td class="td_left"><?php echo $check['component']; ?></td>
<td class="td_center">
<span class="status-badge <?php echo strtolower($check['status']); ?>">
<?php echo $check['status']; ?>
</span>
</td>
<td class="td_left"><?php echo $check['description']; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<style>
.test-controls {
margin: 20px 0;
padding: 20px;
background: #f8f9fa;
border-radius: 5px;
}
.btn-group {
margin-bottom: 10px;
}
.btn-group form {
margin-right: 10px;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background: #007cba;
color: white;
}
.btn-info {
background: #17a2b8;
color: white;
}
.btn-warning {
background: #ffc107;
color: black;
}
.btn-success {
background: #28a745;
color: white;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.test-results {
margin: 30px 0;
padding: 20px;
border-radius: 5px;
}
.result-summary {
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
}
.result-summary.success {
background: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
}
.result-summary.failure {
background: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
}
.status-badge {
padding: 3px 8px;
border-radius: 3px;
font-size: 11px;
font-weight: bold;
}
.status-badge.pass {
background: #28a745;
color: white;
}
.status-badge.fail {
background: #dc3545;
color: white;
}
.status-badge.info {
background: #17a2b8;
color: white;
}
.status-badge.ok {
background: #28a745;
color: white;
}
.status-badge.error {
background: #dc3545;
color: white;
}
tr.pass {
background-color: #d4edda;
}
tr.fail {
background-color: #f8d7da;
}
tr.info {
background-color: #d1ecf1;
}
tr.ok {
background-color: #d4edda;
}
tr.error {
background-color: #f8d7da;
}
.system-status {
margin-top: 30px;
}
</style>
<?php
include_once(G5_ADMIN_PATH . '/admin.tail.php');
?>