- [CUSTOMER] access time 정보 입력 / 조회 추가. closing days 정보 입력 / 조회 추가.
- [MAP] 아직 access 할 수 없는 곳은 남은 시간 표시. 상태 기준을 색깔에서 d_status 값으로 변경.
- [SHORT INFO] access time, closing days 정보 표시.
- [FORECAST] SHORT INFO 클릭은 right pannel 이벤트에서 제외.
This commit is contained in:
Hyojin Ahn 2026-07-07 14:47:55 -04:00
parent c0cb2f00a8
commit 9925b9eaae
11 changed files with 1553 additions and 19 deletions

View File

@ -113,10 +113,18 @@ class CustomerService extends CONF
// =====================================
else {
// 주소/우편번호가 바뀌면 좌표 초기화
$this->handleGeoReset($beforeData, $data);
$setParts = [];
foreach ($data as $k => $v) {
if ($v === null) {
$setParts[] = "{$k}=NULL";
continue;
}
$vEsc = mysqli_real_escape_string(
$connect,
(string)$v
@ -814,6 +822,14 @@ class CustomerService extends CONF
);
}
// c_time_restricted: Y/N 로 강제 (MIS 전용 필드)
// - hasCustomerChanges() 의 $businessFields 에 없으므로 ERP outbox 로 안 나감
if (isset($data['c_time_restricted'])) {
$v = $data['c_time_restricted'];
$data['c_time_restricted'] =
($v === 'Y' || $v === '1' || $v === 1 || $v === true) ? 'Y' : 'N';
}
return $data;
}
@ -954,3 +970,60 @@ class CustomerService extends CONF
return false;
}
}
/* =====================================================================
* 파일을 "직접 호출"했을 때만 동작하는 경량 디스패처.
* (다른 코드에서 require CustomerService 클래스만 제공)
*
* action = patchTimeRestricted
* - c_time_restricted (Y|N) patchCustomer() 갱신
* - c_time_restricted hasCustomerChanges() $businessFields 없으므로
* ERP outbox 나가지 않음
* - 입력: POST c_uid, value(Y|N) / 출력: { ok, value }
* ===================================================================== */
$__self = basename(__FILE__);
$__script = isset($_SERVER['SCRIPT_FILENAME']) ? basename($_SERVER['SCRIPT_FILENAME']) : '';
if ($__script === $__self) {
if (is_file(getenv("DOCUMENT_ROOT")."/include/session_include.php")) {
include_once getenv("DOCUMENT_ROOT")."/include/session_include.php";
}
header('Content-Type: application/json; charset=utf-8');
$action = $_POST['action'] ?? $_GET['action'] ?? '';
try {
if ($action === 'patchTimeRestricted') {
$c_uid = intval($_POST['c_uid'] ?? $_GET['c_uid'] ?? 0);
$value = ((($_POST['value'] ?? $_GET['value'] ?? '')) === 'Y') ? 'Y' : 'N';
if ($c_uid <= 0) {
echo json_encode(['ok' => false, 'msg' => 'invalid c_uid']);
exit;
}
$user = $_SESSION['ss_INITIAL'] ?? ($_SESSION['ss_UID'] ?? 'system');
$svc = new CustomerService();
$svc->patchCustomer(
$c_uid,
[
'c_time_restricted' => $value,
'c_updatedby' => $user,
],
['skip_after' => true] // 부수효과(runAfterChange) 생략
);
echo json_encode(['ok' => true, 'value' => $value]);
exit;
}
echo json_encode(['ok' => false, 'msg' => 'unknown action']);
} catch (Exception $e) {
echo json_encode(['ok' => false, 'msg' => $e->getMessage()]);
}
}

View File

@ -0,0 +1,64 @@
/* =====================================================================
* customer_access.css
* Access Time / Closing Days 표시() + 클릭 편집 박스 공용 스타일
* - customer_detail.php, map.php, shortInfo_lib.php, customer_access_display.php 공용
* - 클래스 기준(스코프 없음)이라 어느 화면에 넣어도 동일하게 적용됨
* ===================================================================== */
/* 필드 박스 (흰색) */
.at-field, .cd-field {
background:#fff; border:1px solid #ccc; border-radius:4px;
padding:8px 10px; min-height:36px; height:100%; box-sizing:border-box;
}
/* td 안에서 박스가 셀 높이를 채우도록 (td height:100% 는 auto 로 계산되므로 1px 시드) */
.td-text-info:has(> .box-clickable) { height:1px; }
/* Access Time */
.at-wrap { display:flex; flex-direction:column; gap:2px; }
.at-row { display:flex; align-items:center; gap:8px; line-height:1.6; }
.at-row.at-off { opacity:.55; }
.at-dow { display:inline-block; width:36px; font-weight:600; font-size:14px; color:#444; }
.at-dow.at-wknd { color:#c0392b; }
.at-win { display:flex; flex-wrap:wrap; gap:5px; }
.at-chip {
display:inline-block; padding:1px 8px; border-radius:4px;
background:#eef3fb; border:1px solid #cfddf2; color:#23548f;
font-size:14px; white-space:nowrap;
}
.at-dash { color:#bbb; font-size:14px; }
/* Closing Days */
.cd-wrap { display:flex; flex-direction:column; gap:5px; }
.cd-chip {
display:inline-flex; align-items:center; gap:6px;
padding:2px 9px; border-radius:4px; align-self:flex-start;
background:#fbf1f1; border:1px solid #e8c9c9; color:#a23a3a;
font-size:14px; white-space:nowrap;
}
.cd-range { font-weight:600; }
.cd-note { color:#8a6d3b; font-style:italic; font-size:13px; }
.cd-empty { color:#bbb; font-size:14px; }
/* 박스 전체 클릭 → 수정 팝업 */
.box-clickable { cursor:pointer; position:relative; transition:border-color .12s, background-color .12s; }
.box-clickable:hover { border-color:#7fb069; background:#fafdf7; }
.box-clickable::after {
content:"\270E"; position:absolute; top:5px; right:8px;
font-size:13px; color:#bbb; line-height:1;
}
.box-clickable:hover::after { color:#7fb069; }
/* "지금 닫힘" 경고 배지 (마커/Info버튼/모달 Access Time 공용) */
.closed-badge {
display:inline-flex; align-items:center; justify-content:center;
min-width:18px; height:18px; padding:0 5px; margin-left:6px;
background:#e53935; color:#fff; border-radius:9px;
font-size:13px; font-weight:700; line-height:1; vertical-align:middle;
}
/* 오늘 요일 강조 (주황 하이라이트 + 붉은 테두리) */
.at-row.at-today {
background:#fff6da; border:1px solid #e53935; border-radius:4px;
padding:1px 6px; margin:0 -7px;
opacity:1 !important; /* 오늘은 휴무여도 흐리게 하지 않음 */
}

View File

@ -207,6 +207,7 @@ class API extends CONF {
"qty" => $rstOptionPoint['d_quantity'],
"estqty" => $rstOptionPoint['d_estquantity'],
"color" => $color,
"d_status" => $rstOptionPoint['d_status'],
"comment" => $rstOptionPoint['c_comment_ri'],
"container_location" => stripslashes($rstOptionPoint['c_location']),
"last_pickup_date" => $rstOptionPoint['c_lastpickupdate'],
@ -704,6 +705,7 @@ class API extends CONF {
"qty" => $rstOptionPoint['d_quantity'],
"estqty" => $rstOptionPoint['d_estquantity'],
"color" => $color,
"d_status" => $rstOptionPoint['d_status'],
"comment" => $rstOptionPoint['c_comment_ri'],
"container_location" => stripslashes($rstOptionPoint['c_location']),
"last_pickup_date" => $rstOptionPoint['c_lastpickupdate'],

View File

@ -162,6 +162,46 @@ if ($mode == "update") {
$containerList[] = $row;
}
//
// ===============================
// Access Time (요일별 수거 가능 시간창)
// $accessTimeMap[dow] = [ {open, close, note, seq}, ... ]
// ===============================
$accessTimeMap = [];
$qAT = "
SELECT ca_uid, ca_day_of_week, ca_seq, ca_open_time, ca_close_time, ca_note
FROM tbl_customer_accesstime
WHERE ca_customer_uid = '{$c_uid}' AND ca_status = 'A'
ORDER BY ca_day_of_week ASC, ca_seq ASC
";
$rAT = mysqli_query($jdb->DBConn, $qAT);
if ($rAT) {
while ($row = mysqli_fetch_array($rAT, MYSQLI_ASSOC)) {
$accessTimeMap[(int)$row['ca_day_of_week']][] = $row;
}
}
// ===============================
// Closing Days (휴무 날짜 구간)
// 지나간 구간(ccd_end_date < 오늘)은 화면에 표시하지 않음
// ===============================
$closingDays = [];
$qCD = "
SELECT ccd_uid, ccd_start_date, ccd_end_date, ccd_note
FROM tbl_customer_closing_days
WHERE ccd_customer_uid = '{$c_uid}'
AND ccd_end_date >= CURDATE()
ORDER BY ccd_start_date ASC
";
$rCD = mysqli_query($jdb->DBConn, $qCD);
if ($rCD) {
while ($row = mysqli_fetch_array($rCD, MYSQLI_ASSOC)) {
$closingDays[] = $row;
}
}
// Restricted 체크박스 상태 (c_time_restricted = 'Y'/'N')
$isRestrictedChecked = ($c_time_restricted == 'Y') ? 'checked' : '';
}
else {
$c_salescommissiondateSTR = "NOT YET";
@ -1373,6 +1413,9 @@ $(document).ready(function()
<div class="h3-title-section">
<h3><span style="color:var(--color-primary);">Restaurant</span> Information</h3>
</div>
<link rel="stylesheet" href="/assets/css/customer_access.css">
<table class="tb-info-box">
<tr>
<td class="td-title-info">Account No</td>
@ -1554,6 +1597,62 @@ $(document).ready(function()
</td>
</tr>
<tr>
<td class="td-title-info">Access Time<div style="display: inline; float:right; padding-right: 30px;">Restricted <input type="checkbox" id="atna" name="c_time_restricted" value="Y" <?=$isRestrictedChecked?>></div></td>
<td class="td-text-info">
<?php
$dowNames = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
if (!function_exists('fmtAT')) { function fmtAT($t){ return $t ? substr($t,0,5) : ''; } }
?>
<div class="at-field box-clickable" data-popup="accesstime" title="클릭하여 수정">
<div class="at-wrap" id="atWrap">
<?php for($d=0; $d<7; $d++):
$wins = isset($accessTimeMap[$d]) ? $accessTimeMap[$d] : [];
$isWeekend = ($d == 0 || $d == 6);
?>
<div class="at-row<?= empty($wins) ? ' at-off' : '' ?>">
<span class="at-dow<?= $isWeekend ? ' at-wknd' : '' ?>"><?=$dowNames[$d]?></span>
<span class="at-win">
<?php if (empty($wins)): ?>
<span class="at-dash">&mdash;</span>
<?php else: foreach($wins as $w):
$o = fmtAT($w['ca_open_time']);
$c = fmtAT($w['ca_close_time']);
$label = $c ? ($o.'&ndash;'.$c) : ($o.' &rarr;');
?>
<span class="at-chip"<?= !empty($w['ca_note']) ? ' title="'.htmlspecialchars($w['ca_note'], ENT_QUOTES).'"' : '' ?>><?=$label?></span>
<?php endforeach; endif; ?>
</span>
</div>
<?php endfor; ?>
</div>
</div>
</td>
<td class="td-title-info">Closing Days</td>
<td class="td-text-info">
<div class="cd-field box-clickable" data-popup="closingdays" title="클릭하여 수정">
<div class="cd-wrap" id="cdWrap">
<?php if (empty($closingDays)): ?>
<span class="cd-empty">&mdash;</span>
<?php else: foreach($closingDays as $cd):
$s = $cd['ccd_start_date'];
$e = $cd['ccd_end_date'];
$range = ($s === $e) ? $s : ($s.' ~ '.$e);
?>
<span class="cd-chip">
<i class="bi bi-calendar-x"></i>
<span class="cd-range"><?=$range?></span>
<?php if (!empty($cd['ccd_note'])): ?>
<em class="cd-note"><?=htmlspecialchars($cd['ccd_note'])?></em>
<?php endif; ?>
</span>
<?php endforeach; endif; ?>
</div>
</div>
</td>
</tr>
<tr>
<td class="td-title-info">Comment</td>
<td class="td-text-info" colspan="3">
@ -1982,10 +2081,80 @@ $(document).ready(function()
</FORM>
<!-- ==============================================================
Access Time / Closing Days 공용 팝업 모달
- 외부 PHP를 iframe 으로 로드 (다른 화면에서도 동일 파일 재사용)
- /lib/customer_access_time_popup.php
- /lib/customer_closing_days_popup.php
- 저장 완료 자식 iframe 에서 window.parent.closeCustomerPopup(true) 호출
============================================================== -->
<div class="modal fade" id="customerPopupModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title" id="customerPopupTitle">Edit</h4>
<button type="button" class="btn" data-bs-dismiss="modal" aria-label="Close" style="color:#fff;"><i class="bi bi-x"></i></button>
</div>
<div class="modal-body" style="padding:0;">
<iframe id="customerPopupFrame" src="about:blank" title="Edit popup"
style="width:100%; height:70vh; border:0; display:block;"></iframe>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
const C_UID = "<?=$c_uid?>";
const GO_STR = "<?=$goStr?>";
const popupConfig = {
accesstime: { url: '/lib/customer_access_time_popup.php', title: 'Access Time' },
closingdays: { url: '/lib/customer_closing_days_popup.php', title: 'Closing Days' }
};
const modalEl = document.getElementById('customerPopupModal');
const popupModal = new bootstrap.Modal(modalEl);
const frameEl = document.getElementById('customerPopupFrame');
const titleEl = document.getElementById('customerPopupTitle');
// 변경 발생 여부 (자식이 closeCustomerPopup(true) 호출 시 true)
let popupChanged = false;
document.querySelectorAll('.box-clickable[data-popup]').forEach(function (box) {
box.addEventListener('click', function () {
const key = box.getAttribute('data-popup');
const cfg = popupConfig[key];
if (!cfg) return;
popupChanged = false;
titleEl.innerText = cfg.title;
frameEl.src = cfg.url
+ '?c_uid=' + encodeURIComponent(C_UID)
+ '&goStr=' + encodeURIComponent(GO_STR);
popupModal.show();
});
});
// iframe 비우기 (다음 오픈 시 깨끗하게)
modalEl.addEventListener('hidden.bs.modal', function () {
frameEl.src = 'about:blank';
if (popupChanged) location.reload(); // 변경됐으면 부모 새로고침해서 갱신된 칩 반영
});
// 자식 iframe(팝업 PHP)에서 호출하는 전역 핸들러
// 저장 성공 후: window.parent.closeCustomerPopup(true);
// 단순 닫기 : window.parent.closeCustomerPopup(false);
window.closeCustomerPopup = function (changed) {
if (changed) popupChanged = true;
popupModal.hide();
};
});
</script>
<?
if ($_SESSION['ss_LEVEL'] == "1" || $_SESSION['ss_LEVEL'] == "5" || $_SESSION['ss_LEVEL'] == "6" || $_SESSION['ss_LEVEL'] == "7" ) {
$add_query = " ORDER BY n_uid DESC";
}

View File

@ -1429,6 +1429,10 @@ $(document).on("click", "a.restaurant-link", function(e) {
e.stopPropagation();
});
$(document).on("click", "a.customerShortInfo", function(e) {
e.stopPropagation();
});
let openedCid = null;
let panelOpen = false;

View File

@ -606,6 +606,9 @@ function popup(){
</div>
<!-- HISTORY DETAIL MODAL Ends -->
<!-- Access Time / Closing Days 표시 스타일 (customer_detail 동일) -->
<link rel="stylesheet" href="/assets/css/customer_access.css">
<!-- INFO MODAL-->
<div class="modal fade top map in" id="map-modal-info" tabindex="-1" role="dialog">
<div id="modal-info" class="modal-dialog" role="document">
@ -620,33 +623,33 @@ function popup(){
<form name="customerShortInfo" id="customerShortInfo" method="POST" novalidate="novalidate">
<input type="hidden" name="actionStr" value="SHORTINFO">
<input type="hidden" name="actionPage" value="">
<input type="hidden" name="c_uid" class="c_uid" value="3888">
<input type="hidden" name="c_uid" class="c_uid" value="">
<input type="hidden" name="c_accountno" class="c_accountno" value="">
<input type="hidden" name="mode" class="mode" value="update">
<table class="tb-info-box">
<tbody>
<tr>
<td class="td-title-info">Restaurant Name</td>
<td colspan="2" class="td-text-info shortinfo-store-name">Name</td>
<td colspan="2" class="td-text-info shortinfo-store-name"></td>
</tr>
<tr>
<td class="td-title-info">Payment Type</td>
<td colspan="2" class="td-text-info shortinfo-payment-type">Cheque</td>
<td colspan="2" class="td-text-info shortinfo-payment-type"></td>
</tr>
<tr>
<td class="td-title-info">Pay Status</td>
<td colspan="2" class="td-text-info shortinfo-payment-string">Paid</td>
<td colspan="2" class="td-text-info shortinfo-payment-string"></td>
</tr>
<tr>
<td class="td-title-info">Comment</td>
<td colspan="2" class="td-text-info">
<textarea id="c_comment_ri" class="textarea shortinfo-comment" name="c_comment_ri" rows="4" cols="20">매주 수요일 정기 픽업, 11 이후 방문</textarea>
<textarea id="c_comment_ri" class="textarea shortinfo-comment" name="c_comment_ri" rows="4" cols="20"></textarea>
</td>
</tr>
<tr>
<td class="td-title-info">Container Location</td>
<td colspan="2" class="td-text-info">
<textarea id="c_location" class="textarea shortinfo-container-location" name="c_location" rows="2" cols="20">Inside Rest</textarea>
<textarea id="c_location" class="textarea shortinfo-container-location" name="c_location" rows="2" cols="20"></textarea>
</td>
</tr>
<!-- <tr>
@ -661,6 +664,7 @@ function popup(){
</td>
</tr> -->
</tbody>
<tbody id="mapAccessClosing"></tbody>
</table>
<br>
<div class="text-center grid-layout-col-2">
@ -686,6 +690,82 @@ function popup(){
</div>
<!-- INFO DETAIL MODAL Ends -->
<!-- ==============================================================
Access Time / Closing Days 공용 편집 팝업 모달 (Bootstrap 3)
- 외부 PHP iframe 으로 로드 (customer_detail 동일 팝업 재사용)
- 저장 완료 자식이 window.parent.closeCustomerPopup(true) 호출
============================================================== -->
<!-- ==============================================================
Access Time / Closing Days 공용 편집 팝업 (순수 오버레이)
- Bootstrap 모달을 쓰지 않음: BS3/BS5 focus-trap 충돌(무한재귀) 회피
- 외부 PHP iframe 으로 로드 (customer_detail 동일 팝업 재사용)
- 저장 완료 자식이 window.parent.closeCustomerPopup(true) 호출
============================================================== -->
<div id="customerPopupOverlay" style="display:none; position:fixed; top:0; left:0; right:0; bottom:0; z-index:100000; background:rgba(0,0,0,0.5);">
<div id="customerPopupBox" style="position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:92%; max-width:760px; background:#fff; border-radius:6px; overflow:hidden; box-shadow:0 10px 40px rgba(0,0,0,0.35);">
<div style="display:flex; justify-content:space-between; align-items:center; padding:10px 14px; background:#2A9B56; color:#fff;">
<h4 id="customerPopupTitle" style="margin:0; font-size:16px;">Edit</h4>
<button type="button" id="customerPopupClose" style="background:none; border:none; color:#fff; font-size:24px; line-height:1; cursor:pointer;">&times;</button>
</div>
<iframe id="customerPopupFrame" src="about:blank" title="Edit popup" style="width:100%; height:70vh; border:0; display:block;"></iframe>
</div>
</div>
<script>
(function () {
var popupConfig = {
accesstime: { url: '/lib/customer_access_time_popup.php', title: 'Access Time' },
closingdays: { url: '/lib/customer_closing_days_popup.php', title: 'Closing Days' }
};
var popupChanged = false;
// 클리핑/스택 방지: 오버레이를 body 직속으로 이동
jQuery('#customerPopupOverlay').appendTo(document.body);
// 현재 INFO MODAL 의 c_uid
function currentCuid() { return jQuery('#customerShortInfo .c_uid').val() || ''; }
// 표시 조각 로드 (모달 열릴 때 / 저장 후 갱신)
window.loadCustomerAccess = function (cuid) {
cuid = cuid || currentCuid();
if (!cuid) { jQuery('#mapAccessClosing').empty(); return; }
jQuery('#mapAccessClosing').load('/lib/customer_access_display.php?c_uid=' + encodeURIComponent(cuid));
};
function openPopup(key) {
var cfg = popupConfig[key];
if (!cfg) return;
var cuid = currentCuid();
if (!cuid) { alert('No customer selected.'); return; }
popupChanged = false;
jQuery('#customerPopupTitle').text(cfg.title);
jQuery('#customerPopupFrame').attr('src', cfg.url + '?c_uid=' + encodeURIComponent(cuid) + '&goStr=');
jQuery('#customerPopupOverlay').css('display', 'block');
}
function closePopup() {
jQuery('#customerPopupOverlay').css('display', 'none');
jQuery('#customerPopupFrame').attr('src', 'about:blank');
if (popupChanged) { window.loadCustomerAccess(); popupChanged = false; }
}
// 박스 클릭 → 편집 팝업 (동적 주입이라 위임 바인딩)
jQuery(document).on('click', '#mapAccessClosing .box-clickable[data-popup]', function () {
openPopup(jQuery(this).data('popup'));
});
// 닫기: X 버튼 / 배경 클릭(박스 내부 제외)
jQuery('#customerPopupClose').on('click', closePopup);
jQuery('#customerPopupOverlay').on('click', function (e) { if (e.target === this) closePopup(); });
// 자식 iframe(팝업 PHP)에서 호출하는 전역 핸들러
window.closeCustomerPopup = function (changed) {
if (changed) popupChanged = true;
closePopup();
};
})();
</script>
<!-- NOTE MODAL-->
<div class="modal fade top map" id="map-modal-note" tabindex="-1" role="dialog">
<div id="modal-note" class="modal-dialog" role="document">
@ -1798,6 +1878,15 @@ function popup(){
clusterer = null;
}
// 닫힘 배지 마커 정리
if (typeof closedBadgeMarker !== 'undefined' && closedBadgeMarker) {
Object.keys(closedBadgeMarker).forEach(function (idx) {
if (closedBadgeMarker[idx]) closedBadgeMarker[idx].setMap(null);
});
closedBadgeMarker = {};
}
if (window.closedNowSet) window.closedNowSet = new Set();
marker = [];
overlay = [];
let pointGeocoder = new google.maps.Geocoder();
@ -1819,6 +1908,8 @@ function popup(){
ordertype: json.result[i]['ordertype'],
customerid: json.result[i]['id'],
type: json.result[i]['type'],
d_status: json.result[i]['d_status'],
schedule: json.result[i]['schedule'],
colour: "transparency",
color: json.result[i]['color'],
lat: json.result[i]['lat'],
@ -1876,7 +1967,7 @@ function popup(){
position: location,
map: map,
icon: icon,
animation: (property.rnote && /\S/.test(property.rnote) && property.color !== "#7B7A7A") ? google.maps.Animation.BOUNCE : null
animation: (property.rnote && /\S/.test(property.rnote) && property.d_status !== 'F') ? google.maps.Animation.BOUNCE : null
});
marker[i].customType = property.type; //'flag' 또는 'map-marker'
@ -1902,11 +1993,11 @@ function popup(){
markerwindow[i].close();
});
let content = buildContent(property);
marker[i].addListener('click', function() {
if(overlay[i]) {
overlay[i].setMap(null);
}
let content = buildContent(property); // 클릭 시점 생성 → closedNowSet(닫힘 배지) 반영
overlay[i] = new CustomOverlay(map, marker[i].getPosition(), content.innerHTML, i);
});
@ -1947,20 +2038,43 @@ function popup(){
const size = Math.min(50, 30 + Math.log(count) * 10);
const textColor = (String(topColor).toUpperCase() === "#FFFDD5") ? "#000" : "#fff";
// 클러스터 내 닫힘 마커: 남은시간 최대값을 알약으로 표시(느낌표 최소화).
// 시간이 있는 닫힘이 하나도 없고 "오늘 더 안 열림"만 있으면 !
const closedProps = markers.map(m => m.property).filter(p => isClosedNowProp(p));
const closedMins = closedProps
.map(p => closedMinutesFor(p.customerid))
.filter(v => v != null && v > 0);
const clusterMaxLabel = closedMins.length ? remainLabel(Math.max.apply(null, closedMins)) : null;
let W = 34;
let closedBadgeSvg = '';
if (clusterMaxLabel) {
const pw = Math.max(22, 12 + clusterMaxLabel.length * 7);
W = Math.max(34, 21 + pw + 2);
closedBadgeSvg =
`<rect x="21" y="0.5" width="${pw}" height="15" rx="7.5" fill="#f0ad4e" stroke="#ffffff" stroke-width="1"/>
<text x="${21 + pw / 2}" y="12" text-anchor="middle" font-size="11" font-weight="bold" font-family="${BADGE_FONT}" fill="#5a3d00">${clusterMaxLabel}</text>`;
} else if (closedProps.length > 0) {
closedBadgeSvg =
`<circle cx="27" cy="7" r="6.5" fill="#e53935" stroke="#fff" stroke-width="1.5"/>
<text x="27" y="10.5" text-anchor="middle" font-size="9" font-weight="bold" font-family="${BADGE_FONT}" fill="#fff">!</text>`;
}
return new google.maps.Marker({
position,
icon: {
url:
"data:image/svg+xml;charset=UTF-8," +
encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30">
<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="34">
<circle cx="15" cy="15" r="13"
fill="${topColor}" />
<text x="15" y="20" text-anchor="middle"
font-size="12" fill="${textColor}">${count}</text>
font-size="12" font-family="${BADGE_FONT}" fill="${textColor}">${count}</text>
${closedBadgeSvg}
</svg>
`),
scaledSize: new google.maps.Size(30, 30),
scaledSize: new google.maps.Size(W, 34),
anchor: new google.maps.Point(15, 15),
},
zIndex: Number(google.maps.Marker.MAX_ZINDEX) + count,
@ -1970,7 +2084,7 @@ function popup(){
markers.some(m =>
m.property &&
m.property.rnote && /\S/.test(m.property.rnote) &&
m.property.color !== "#7B7A7A"
m.property.d_status !== 'F'
)
) ? google.maps.Animation.BOUNCE : null,
});
@ -2016,14 +2130,24 @@ function popup(){
let html =
"<div style='min-width:220px;max-height:240px;overflow:auto'>";
markersInCluster.forEach((m) => {
const p = m.property || {};
const hasRnote = p.rnote && /\S/.test(p.rnote);
const isClosed = isClosedNowProp(p);
const openInLbl = isClosed ? remainLabel(closedMinutesFor(p.customerid)) : null;
html += `
<div class="cluster-item" data-marker-index="${p.index}" style="display:flex; align-items:center; margin-bottom:4px; cursor:pointer;">
<span style="width:15px; height:15px; margin-right:6px; background-color:${p.color || "#ccc"}; display:inline-block; border:1px solid #999;"></span>
<strong>${p.store ?? ""}</strong>
${
isClosed
? (openInLbl
? `<span title="Opens in ${openInLbl}" style="margin-left:6px; display:inline-flex; align-items:center; padding:0 7px; height:16px; border-radius:8px; background:#f0ad4e; color:#5a3d00; font-size:11px; font-weight:bold;">${openInLbl}</span>`
: `<span title="Closed today" style="margin-left:6px; display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; border-radius:8px; background:#e53935; color:#fff; font-size:11px; font-weight:bold;">!</span>`)
: ""
}
${
hasRnote
? `<span style="margin-left:6px; padding:2px 4px; background:#FF9800; color:white; border-radius:3px; font-size:10px;">
@ -2073,14 +2197,18 @@ function popup(){
clusterer.addListener('clusteringend', () => {
marker.forEach(m => {
if (!m.getMap()) return;
if (m.property?.rnote && /\S/.test(m.property.rnote) && m.property.color !== "#7B7A7A") {
if (m.property?.rnote && /\S/.test(m.property.rnote) && m.property.d_status !== 'F') {
m.setAnimation(google.maps.Animation.BOUNCE);
} else {
m.setAnimation(null);
}
});
// 클러스터로 숨겨진 마커의 닫힘 배지도 같이 숨김/표시
if (typeof syncClosedBadges === 'function') syncClosedBadges();
});
}
applyClosedBadges();
}
function showInfoWindow(position, title, quantity, infoType) {
@ -2245,8 +2373,8 @@ function popup(){
markerwindow[parseInt(json.markerIndex)].close();
});
let content = buildContent(json.property);
marker[parseInt(json.markerIndex)].addListener('click', function() {
let content = buildContent(json.property); // 클릭 시점 생성 → closedNowSet 반영
overlay[parseInt(json.markerIndex)] = new CustomOverlay(map, marker[parseInt(json.markerIndex)].getPosition(), content.innerHTML, parseInt(json.markerIndex));
});
}
@ -2362,6 +2490,10 @@ function popup(){
jQuery("#customerShortInfo #c_comment_ri,#customerShortInfo #c_location").val(loadingTxt).attr("disabled","disabled");
jQuery("#customerShortInfo .btn-sub").attr("disabled","disabled");
// Access Time / Closing Days 표시 조각 로드
jQuery("#customerShortInfo .c_uid").val(id);
if (typeof loadCustomerAccess === 'function') loadCustomerAccess(id);
let data = {
"id" : id
}
@ -2924,9 +3056,140 @@ function popup(){
jQuery("#map-modal-history tbody").html(output);
}
// 지금 닫힘(closed now) 고객 집합 + 상세(다음 오픈까지 남은 분 등)
window.closedNowSet = window.closedNowSet || new Set();
window.closedNowInfo = window.closedNowInfo || {}; // { customerid: {open, next_open, minutes_until_open, ...} }
// 고객의 "다음 오픈까지 남은 분" (없으면 null)
function closedMinutesFor(cid) {
var d = window.closedNowInfo && window.closedNowInfo[String(cid)];
return (d && typeof d.minutes_until_open === 'number') ? d.minutes_until_open : null;
}
// 남은 분 → 짧은 라벨. 오늘 더 안 열면(null/0 이하) null → '!' 로 표시됨
function remainLabel(mins) {
if (mins == null || mins <= 0) return null;
if (mins < 60) return mins + 'm';
var h = Math.floor(mins / 60), m = mins % 60;
return m ? (h + 'h ' + m + 'm') : (h + 'h');
}
// 뱃지 텍스트 폰트 (HTML 배지와 통일). 실제 UI 폰트에 맞게 바꿔도 됨.
var BADGE_FONT = "Arial, Helvetica, sans-serif";
// 남은시간 앰버 알약 배지 SVG
function timeBadgeSvg(label) {
var w = Math.max(22, 12 + label.length * 7);
return 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="18">' +
'<rect x="0.5" y="0.5" width="' + (w - 1) + '" height="17" rx="8.5" fill="#f0ad4e" stroke="#ffffff" stroke-width="1"/>' +
'<text x="' + (w / 2) + '" y="13" text-anchor="middle" font-size="12" font-weight="bold" font-family="' + BADGE_FONT + '" fill="#5a3d00">' + label + '</text>' +
'</svg>');
}
// 닫힘 배지(빨간 원 + 흰 !) 아이콘
var CLOSED_BADGE_SVG = 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20">' +
'<circle cx="10" cy="10" r="8.5" fill="#e53935" stroke="#ffffff" stroke-width="2"/>' +
'<text x="10" y="14.5" text-anchor="middle" font-size="13" font-weight="bold" font-family="' + BADGE_FONT + '" fill="#ffffff">!</text>' +
'</svg>');
// 깃발 대비 배지 위치.
// 깃발 심볼은 anchor (0,0) → latlng 이 깃발 좌상단, 깃발은 우/하로 그려짐(약 17x20px).
// 배지 아이콘 20x20 기준, 배지 중심 화면오프셋 = (10 - x, 10 - y).
// 아래 값 = 배지 중심이 latlng 기준 (17, -1) → 깃발 배너 우상단.
// 깃발과 안 맞으면 x/y 두 숫자만 조정.
var BADGE_ANCHOR = { x: -7, y: 11 };
var closedBadgeMarker = {}; // markerArrayIndex -> 배지 google.maps.Marker
function makeClosedBadge(pos, label) {
var icon;
if (label) {
var w = Math.max(22, 12 + label.length * 7);
icon = {
url: timeBadgeSvg(label),
scaledSize: new google.maps.Size(w, 18),
anchor: new google.maps.Point(-6, 15), // 깃발 우상단, 알약 좌측 기준
};
} else {
icon = {
url: CLOSED_BADGE_SVG,
scaledSize: new google.maps.Size(20, 20),
anchor: new google.maps.Point(BADGE_ANCHOR.x, BADGE_ANCHOR.y),
};
}
return new google.maps.Marker({
position: pos,
map: map,
clickable: false,
zIndex: Number(google.maps.Marker.MAX_ZINDEX) + 1,
icon: icon,
});
}
// 클러스터로 숨겨진(부모 마커가 map 에서 빠진) 배지는 같이 숨김
function syncClosedBadges() {
Object.keys(closedBadgeMarker).forEach(function (idx) {
var b = closedBadgeMarker[idx];
var m = marker[idx];
if (b && m) b.setMap(m.getMap() ? map : null);
});
}
// "지금 닫힘" 표시 대상인지 (Finished = d_status 'F' 는 제외)
function isClosedNowProp(p) {
return !!(p && p.d_status !== 'F' &&
window.closedNowSet && window.closedNowSet.has(String(p.customerid)));
}
// 마커들의 고객 id 로 "지금 닫힘" 조회 → 개별 배지 + 클러스터 재렌더
function applyClosedBadges() {
var ids = [];
marker.forEach(function (m) {
if (m && m.property && m.property.customerid != null) ids.push(String(m.property.customerid));
});
ids = Array.from(new Set(ids));
if (ids.length === 0) return;
jQuery.ajax({
url: '/lib/customer_open_status.php',
method: 'POST',
dataType: 'json',
data: { ids: ids.join(',') },
success: function (res) {
window.closedNowSet = new Set(((res && res.closed) ? res.closed : []).map(String));
window.closedNowInfo = (res && res.detail) ? res.detail : {};
// 기존 배지 제거
Object.keys(closedBadgeMarker).forEach(function (idx) {
if (closedBadgeMarker[idx]) closedBadgeMarker[idx].setMap(null);
});
closedBadgeMarker = {};
// 닫힌 마커마다 배지 생성 (오늘 이따 열면 남은시간, 아니면 !)
marker.forEach(function (m, idx) {
if (!m || !m.property) return;
if (isClosedNowProp(m.property)) {
var lbl = remainLabel(closedMinutesFor(m.property.customerid));
closedBadgeMarker[idx] = makeClosedBadge(m.getPosition(), lbl);
m.setTitle(lbl ? ('Opens in ' + lbl) : 'Closed today');
}
});
syncClosedBadges();
// 클러스터 아이콘도 ! 반영되도록 재렌더
if (typeof clusterer !== 'undefined' && clusterer && typeof clusterer.render === 'function') {
clusterer.render();
}
}
});
}
function buildContent(property) {
let content = document.createElement("div");
const chipsHtml = renderContainerChips(property.containers);
const closedBadge = isClosedNowProp(property)
? '<span class="closed-badge">!</span>' : '';
content.classList.add("property");
let innerHtml = `
@ -3002,7 +3265,7 @@ function popup(){
data-target="#map-modal-info"
onClick="showShortInfo(${property.customerid});">
<i class="fa-solid fa-question mapBtn-ico"></i>
<div class="mapBtn-txt">Info</div>
<div class="mapBtn-txt">Info ${closedBadge}</div>
</button>
<button type="button"

View File

@ -0,0 +1,136 @@
<?php
/* =====================================================================
* /lib/customer_access_display.php
* - Access Time / Closing Days 읽기 전용 표시 조각 ( 개의 <tr> 반환)
* - customer_detail.php 동일한 스타일 (CSS 부모 화면이 보유)
* - AJAX 모달 등에 주입해서 재사용 (map.php )
* - 박스는 .box-clickable[data-popup] 이라 부모의 클릭 핸들러가 편집 팝업을 연다
* ===================================================================== */
include getenv("DOCUMENT_ROOT")."/include/session_include.php";
require_once getenv("DOCUMENT_ROOT")."/assets/dbCon.php";
require_once getenv("DOCUMENT_ROOT")."/lib/customer_open_status.php";
$c_uid = isset($_GET['c_uid']) ? intval($_GET['c_uid']) : 0;
// 지금 닫힘 여부 (Access Time 제목 옆 경고 배지용)
$openStat = ($c_uid > 0) ? customerIsOpenNow($c_uid) : ['restricted'=>false,'open'=>true,'reason'=>''];
$closedNow = (!empty($openStat['restricted']) && empty($openStat['open']));
// Restricted 토글 현재값
$isRestricted = false;
if ($c_uid > 0) {
$rR = qry("SELECT c_time_restricted AS f FROM tbl_customer WHERE c_uid = $c_uid LIMIT 1");
$rowR = fetch_array($rR);
$isRestricted = ($rowR && $rowR['f'] === 'Y');
}
$accessTimeMap = [];
$closingDays = [];
if ($c_uid > 0) {
$res = qry("SELECT ca_day_of_week, ca_seq, ca_open_time, ca_close_time, ca_note
FROM tbl_customer_accesstime
WHERE ca_customer_uid = $c_uid AND ca_status = 'A'
ORDER BY ca_day_of_week, ca_seq");
while ($row = fetch_array($res)) { $accessTimeMap[(int)$row['ca_day_of_week']][] = $row; }
// 지나간 휴무(종료일 < 오늘)는 제외
$res2 = qry("SELECT ccd_start_date, ccd_end_date, ccd_note
FROM tbl_customer_closing_days
WHERE ccd_customer_uid = $c_uid AND ccd_end_date >= CURDATE()
ORDER BY ccd_start_date");
while ($row = fetch_array($res2)) { $closingDays[] = $row; }
}
$dowNames = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
$todayDow = (int)date('w'); // 0=Sun .. 6=Sat (서버 기준, open-status 와 동일)
if (!function_exists('fmtAccT')) { function fmtAccT($t){ return $t ? substr($t, 0, 5) : ''; } }
?>
<tr>
<td class="td-title-info">Access Time<?php if ($closedNow): ?> <span class="closed-badge" title="Closed right now">!</span><?php endif; ?><span class="restricted-toggle" style="float:right; font-weight:normal; font-size:13px;">Restricted <input type="checkbox" class="ca-restricted-chk" data-cuid="<?=$c_uid?>" <?= $isRestricted ? 'checked' : '' ?>></span></td>
<td colspan="2" class="td-text-info">
<div class="at-field box-clickable" data-popup="accesstime" title="Click to edit">
<div class="at-wrap">
<?php for ($d = 0; $d < 7; $d++):
$wins = isset($accessTimeMap[$d]) ? $accessTimeMap[$d] : [];
?>
<div class="at-row<?= empty($wins) ? ' at-off' : '' ?><?= ($d === $todayDow) ? ' at-today' : '' ?>">
<span class="at-dow<?= ($d == 0 || $d == 6) ? ' at-wknd' : '' ?>"><?=$dowNames[$d]?></span>
<span class="at-win">
<?php if (empty($wins)): ?>
<span class="at-dash">&mdash;</span>
<?php else: foreach ($wins as $w):
$o = fmtAccT($w['ca_open_time']);
$c = fmtAccT($w['ca_close_time']);
$label = $c ? ($o.'&ndash;'.$c) : ($o.' &rarr;');
?>
<span class="at-chip"<?= !empty($w['ca_note']) ? ' title="'.htmlspecialchars($w['ca_note'], ENT_QUOTES).'"' : '' ?>><?=$label?></span>
<?php endforeach; endif; ?>
</span>
</div>
<?php endfor; ?>
</div>
</div>
</td>
</tr>
<tr>
<td class="td-title-info">Closing Days</td>
<td colspan="2" class="td-text-info">
<div class="cd-field box-clickable" data-popup="closingdays" title="Click to edit">
<div class="cd-wrap">
<?php if (empty($closingDays)): ?>
<span class="cd-empty">&mdash;</span>
<?php else: foreach ($closingDays as $cd):
$s = $cd['ccd_start_date'];
$e = $cd['ccd_end_date'];
$range = ($s === $e) ? $s : ($s.' ~ '.$e);
?>
<span class="cd-chip">
<span class="cd-range"><?=$range?></span>
<?php if (!empty($cd['ccd_note'])): ?><em class="cd-note"><?=htmlspecialchars($cd['ccd_note'])?></em><?php endif; ?>
</span>
<?php endforeach; endif; ?>
</div>
</div>
</td>
</tr>
<script>
/* Restricted 체크박스 c_time_restricted 즉시 저장 (map / shortInfo 공용)
- 조각이 .load() 주입될 때마다 스크립트가 실행되지만,
window 플래그로 문서 위임 바인딩은 번만 건다. */
if (!window.__caRestrictedBound) {
window.__caRestrictedBound = true;
jQuery(document).on('change', '.ca-restricted-chk', function () {
var $chk = jQuery(this);
var cuid = $chk.data('cuid');
var val = this.checked ? 'Y' : 'N';
if (!cuid) return;
$chk.prop('disabled', true);
jQuery.ajax({
url: '/assets/api/customer.php',
method: 'POST',
dataType: 'json',
data: { action: 'patchTimeRestricted', c_uid: cuid, value: val },
success: function (res) {
if (res && res.ok) {
// 표시 조각 다시 로드 → "!" 배지/상태 갱신
var $tb = $chk.closest('tbody');
if ($tb.attr('id')) {
$tb.load('/lib/customer_access_display.php?c_uid=' + encodeURIComponent(cuid));
} else {
$chk.prop('disabled', false);
}
} else {
$chk.prop('checked', !$chk.prop('checked')).prop('disabled', false);
alert('Failed to update Restricted.');
}
},
error: function () {
$chk.prop('checked', !$chk.prop('checked')).prop('disabled', false);
alert('Failed to update Restricted.');
}
});
});
}
</script>

View File

@ -0,0 +1,387 @@
<?php
/* =====================================================================
* /lib/customer_access_time_popup.php
* - Customer Access Time (weekly pickup-availability windows) editor popup
* - Opened as a modal (iframe) from customer_detail.php; reusable on other screens
* - On successful save, notifies parent via window.parent.closeCustomerPopup(true)
* - Save = "full replace": delete the customer's active rows, re-insert current input
* - Time is chosen via hour/minute selects (minute step = 5) so granularity is
* guaranteed regardless of the browser's native time picker
* ===================================================================== */
include getenv("DOCUMENT_ROOT")."/include/session_include.php";
require_once getenv("DOCUMENT_ROOT")."/assets/dbCon.php";
$c_uid = isset($_REQUEST['c_uid']) ? intval($_REQUEST['c_uid']) : 0;
$goStr = $_GET['goStr'] ?? '';
$user = $_SESSION['ss_INITIAL'] ?? ($_SESSION['ss_UID'] ?? 'system');
// mbstring 미설치 환경 대비: UTF-8 문자 기준 N자로 자르기
if (!function_exists('utf8_cut')) {
function utf8_cut($s, $n) {
if (function_exists('mb_substr')) return mb_substr($s, 0, $n);
preg_match_all('/./us', (string)$s, $mm);
return (count($mm[0]) <= $n) ? $s : implode('', array_slice($mm[0], 0, $n));
}
}
/* =====================================================================
* AJAX save
* ===================================================================== */
if (($_POST['action'] ?? '') === 'save') {
header('Content-Type: application/json; charset=utf-8');
if ($c_uid <= 0) { echo json_encode(['ok' => false, 'msg' => 'invalid c_uid']); exit; }
$rows = json_decode($_POST['data'] ?? '[]', true);
if (!is_array($rows)) $rows = [];
// validate + clean
$clean = [];
foreach ($rows as $r) {
$dow = isset($r['dow']) ? intval($r['dow']) : -1;
if ($dow < 0 || $dow > 6) continue;
$open = trim($r['open'] ?? '');
if (!preg_match('/^\d{2}:\d{2}$/', $open)) continue; // start time required
$close = trim($r['close'] ?? '');
if ($close !== '' && !preg_match('/^\d{2}:\d{2}$/', $close)) continue; // end optional (open-ended)
$note = isset($r['note']) ? trim($r['note']) : '';
$note = utf8_cut($note, 200);
$clean[] = ['dow' => $dow, 'open' => $open, 'close' => $close, 'note' => $note];
}
// sort by dow then start time, assign seq within each dow
usort($clean, function ($a, $b) {
if ($a['dow'] !== $b['dow']) return $a['dow'] - $b['dow'];
return strcmp($a['open'], $b['open']);
});
$uEsc = addslashes($user);
// full replace
qry("DELETE FROM tbl_customer_accesstime WHERE ca_customer_uid = " . $c_uid);
$seqByDow = [];
foreach ($clean as $r) {
$d = $r['dow'];
$seqByDow[$d] = isset($seqByDow[$d]) ? $seqByDow[$d] + 1 : 1;
$seq = $seqByDow[$d];
$openV = "'" . $r['open'] . ":00'";
$closeV = ($r['close'] === '') ? "NULL" : "'" . $r['close'] . ":00'";
$noteV = ($r['note'] === '') ? "NULL" : "'" . addslashes($r['note']) . "'";
qry("INSERT INTO tbl_customer_accesstime
(ca_customer_uid, ca_day_of_week, ca_seq, ca_open_time, ca_close_time, ca_note,
ca_status, ca_createdby, ca_created_at)
VALUES
($c_uid, $d, $seq, $openV, $closeV, $noteV, 'A', '$uEsc', NOW())");
}
echo json_encode(['ok' => true]);
exit;
}
/* =====================================================================
* GET: load existing
* ===================================================================== */
$existing = []; // dow => [ {open, close, note}, ... ]
if ($c_uid > 0) {
$res = qry("SELECT ca_day_of_week, ca_seq, ca_open_time, ca_close_time, ca_note
FROM tbl_customer_accesstime
WHERE ca_customer_uid = $c_uid AND ca_status = 'A'
ORDER BY ca_day_of_week, ca_seq");
while ($row = fetch_array($res)) {
$existing[(int)$row['ca_day_of_week']][] = [
'open' => substr($row['ca_open_time'], 0, 5),
'close' => $row['ca_close_time'] ? substr($row['ca_close_time'], 0, 5) : '',
'note' => $row['ca_note'],
];
}
}
$existingJson = json_encode($existing, JSON_UNESCAPED_UNICODE);
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Access Time</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, "Segoe UI", Arial, sans-serif; margin: 0; padding: 14px 16px 80px; color: #333; font-size: 14px; background:#fff; }
h2 { font-size: 16px; margin: 0 0 10px; }
/* time selects */
.tf { display: inline-flex; align-items: center; gap: 2px; }
.tf select { padding: 5px 4px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px; background: #fff; }
.tf-colon { color: #999; }
/* Quick fill */
.quick { border: 1px solid #cfe0cf; background: #f4f9f1; border-radius: 8px; padding: 10px 12px; margin-bottom: 14px; }
.quick-line { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
.quick-line:last-child { margin-bottom: 0; }
.quick input.note { padding: 5px 7px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px; flex: 1; min-width: 90px; max-width: 200px; }
.qday { border: 1px solid #bcd1bc; background: #fff; color: #4a7a3a; border-radius: 14px; padding: 2px 11px; font-size: 13px; cursor: pointer; user-select: none; }
.qday.on { background: #7fb069; border-color: #7fb069; color: #fff; }
.qday.wknd { color: #c0392b; }
.qday.wknd.on { color: #fff; }
.qbtn { border: 1px solid #cfcfcf; background: #fff; border-radius: 4px; font-size: 12px; padding: 4px 10px; cursor: pointer; color: #555; }
.qbtn:hover { background: #eee; }
.qbtn.primary { background: #7fb069; border-color: #7fb069; color: #fff; }
.qbtn.primary:hover { background: #6da356; }
/* Per-day list */
.day { border: 1px solid #e3e3e3; border-radius: 6px; margin-bottom: 8px; }
.day-head { display: flex; align-items: center; justify-content: space-between; padding: 7px 10px; background: #f6f8f3; border-bottom: 1px solid #eee; }
.day-name { font-weight: 600; }
.day-name.wknd { color: #c0392b; }
.add-win { border: 1px solid #cfcfcf; background: #fff; border-radius: 4px; font-size: 12px; padding: 2px 8px; cursor: pointer; color: #555; }
.add-win:hover { background: #7fb069; color: #fff; border-color: #7fb069; }
.wins { padding: 6px 10px; display: flex; flex-direction: column; gap: 6px; }
.empty { color: #bbb; font-size: 13px; padding: 4px 0; }
.win { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.win input.note { padding: 5px 7px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px; flex: 1; min-width: 90px; }
.tilde { color: #999; }
.rm { border: none; background: #fbecec; color: #c0392b; border-radius: 4px; width: 26px; height: 28px; cursor: pointer; font-size: 15px; line-height: 1; }
.rm:hover { background: #c0392b; color: #fff; }
.footer { position: fixed; left: 0; right: 0; bottom: 0; background: #fff; border-top: 1px solid #e3e3e3; padding: 10px 16px; display: flex; justify-content: flex-end; gap: 8px; }
.btn { padding: 8px 18px; border-radius: 4px; border: 1px solid #ccc; background: #fff; cursor: pointer; font-size: 14px; }
.btn-save { background: #7fb069; border-color: #7fb069; color: #fff; }
.btn-save:hover { background: #6da356; }
.btn:disabled { opacity: .6; cursor: default; }
</style>
</head>
<body>
<h2>Access Time</h2>
<!-- Quick fill: enter a time once and add it to multiple days at once -->
<div class="quick">
<div class="quick-line">
<span id="qOpenField"></span>
<span class="tilde">~</span>
<span id="qCloseField"></span>
<input type="text" id="qNote" class="note" maxlength="200" placeholder="Note (optional)">
</div>
<div class="quick-line" id="qDays"></div>
<div class="quick-line">
<button type="button" class="qbtn" id="qWeekday">Weekdays</button>
<button type="button" class="qbtn" id="qWeekend">Weekend</button>
<button type="button" class="qbtn" id="qAll">Everyday</button>
<button type="button" class="qbtn" id="qNone">Unselect</button>
<button type="button" class="qbtn primary" id="qApply">Apply</button>
</div>
</div>
<div id="days"></div>
<div class="footer">
<button type="button" class="btn" id="closeBtn">Close</button>
<button type="button" class="btn btn-save" id="saveBtn">Save</button>
</div>
<script>
const DOW_NAMES = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
const MIN_STEP = 5; // minute step (change to 15 if desired)
const existing = <?=$existingJson?> || {};
const C_UID = <?=$c_uid?>;
const daysEl = document.getElementById('days');
function pad(n){ return String(n).padStart(2,'0'); }
function esc(s){ return (s||'').replace(/"/g,'&quot;'); }
/* ---- hour/minute select field. Reads value via the hidden input.className ---- */
function timeField(cls, value) {
value = value || '';
const wrap = document.createElement('span');
wrap.className = 'tf';
const selH = document.createElement('select');
const selM = document.createElement('select');
const hid = document.createElement('input');
hid.type = 'hidden';
hid.className = cls;
hid.value = value;
// "--" (empty) option
let o = document.createElement('option'); o.value = ''; o.textContent = '--'; selH.appendChild(o);
o = document.createElement('option'); o.value = ''; o.textContent = '--'; selM.appendChild(o);
for (let h = 0; h < 24; h++) { const op = document.createElement('option'); op.value = pad(h); op.textContent = pad(h); selH.appendChild(op); }
const mins = [];
for (let m = 0; m < 60; m += MIN_STEP) mins.push(pad(m));
mins.forEach(function (m) { const op = document.createElement('option'); op.value = m; op.textContent = m; selM.appendChild(op); });
// 새 필드(빈 값)는 분을 00으로 기본 세팅. 시(hour)를 고르면 HH:00 으로 확정됨
let hh = '', mm = '00';
if (/^\d{2}:\d{2}$/.test(value)) {
hh = value.slice(0, 2); mm = value.slice(3, 5);
if (mins.indexOf(mm) === -1) { const op = document.createElement('option'); op.value = mm; op.textContent = mm; selM.appendChild(op); } // keep legacy minute
}
selH.value = hh; selM.value = mm;
function sync() {
const h = selH.value, m = selM.value;
hid.value = (h !== '' && m !== '') ? (h + ':' + m) : '';
}
selH.addEventListener('change', sync);
selM.addEventListener('change', sync);
const colon = document.createElement('span'); colon.className = 'tf-colon'; colon.textContent = ':';
wrap.appendChild(selH); wrap.appendChild(colon); wrap.appendChild(selM); wrap.appendChild(hid);
return wrap;
}
function winRow(w) {
w = w || { open: '', close: '', note: '' };
const div = document.createElement('div');
div.className = 'win';
div.appendChild(timeField('open', w.open));
const tilde = document.createElement('span'); tilde.className = 'tilde'; tilde.textContent = '~'; div.appendChild(tilde);
div.appendChild(timeField('close', w.close));
const note = document.createElement('input');
note.type = 'text'; note.className = 'note'; note.maxLength = 200;
note.placeholder = 'Note (optional)'; note.value = w.note || '';
div.appendChild(note);
const rm = document.createElement('button');
rm.type = 'button'; rm.className = 'rm'; rm.title = 'Delete'; rm.innerHTML = '&times;';
rm.addEventListener('click', function () { const wins = div.parentElement; div.remove(); refreshEmpty(wins); });
div.appendChild(rm);
return div;
}
function refreshEmpty(winsEl) {
const hasRow = winsEl.querySelector('.win');
let emptyEl = winsEl.querySelector('.empty');
if (!hasRow && !emptyEl) {
emptyEl = document.createElement('div');
emptyEl.className = 'empty';
emptyEl.textContent = 'Closed (no hours)';
winsEl.appendChild(emptyEl);
} else if (hasRow && emptyEl) {
emptyEl.remove();
}
}
const winsByDow = {};
function addWindowToDay(d, w) {
const wins = winsByDow[d];
wins.appendChild(winRow(w));
refreshEmpty(wins);
}
for (let d = 0; d < 7; d++) {
const day = document.createElement('div');
day.className = 'day';
day.dataset.dow = d;
const head = document.createElement('div');
head.className = 'day-head';
head.innerHTML =
'<span class="day-name ' + ((d === 0 || d === 6) ? 'wknd' : '') + '">' + DOW_NAMES[d] + '</span>' +
'<button type="button" class="add-win">+ Add time</button>';
const wins = document.createElement('div');
wins.className = 'wins';
winsByDow[d] = wins;
(existing[d] || []).forEach(function (w) { wins.appendChild(winRow(w)); });
refreshEmpty(wins);
head.querySelector('.add-win').addEventListener('click', function () { addWindowToDay(d); });
day.appendChild(head);
day.appendChild(wins);
daysEl.appendChild(day);
}
/* ----- Quick fill ----- */
document.getElementById('qOpenField').appendChild(timeField('qOpen', ''));
document.getElementById('qCloseField').appendChild(timeField('qClose', ''));
const qDaysEl = document.getElementById('qDays');
for (let d = 0; d < 7; d++) {
const chip = document.createElement('span');
chip.className = 'qday' + ((d === 0 || d === 6) ? ' wknd' : '');
chip.dataset.dow = d;
chip.textContent = DOW_NAMES[d];
chip.addEventListener('click', function () { chip.classList.toggle('on'); });
qDaysEl.appendChild(chip);
}
function setDays(list) {
qDaysEl.querySelectorAll('.qday').forEach(function (c) {
c.classList.toggle('on', list.includes(parseInt(c.dataset.dow, 10)));
});
}
document.getElementById('qWeekday').addEventListener('click', function () { setDays([1,2,3,4,5]); });
document.getElementById('qWeekend').addEventListener('click', function () { setDays([0,6]); });
document.getElementById('qAll').addEventListener('click', function () { setDays([0,1,2,3,4,5,6]); });
document.getElementById('qNone').addEventListener('click', function () { setDays([]); });
document.getElementById('qApply').addEventListener('click', function () {
const open = document.querySelector('.qOpen').value.trim();
const close = document.querySelector('.qClose').value.trim();
const note = document.getElementById('qNote').value.trim();
if (!open) { alert('Enter a start time.'); return; }
if (close && close < open) { alert('End time is earlier than start time.'); return; }
const targets = [];
qDaysEl.querySelectorAll('.qday.on').forEach(function (c) { targets.push(parseInt(c.dataset.dow, 10)); });
if (targets.length === 0) { alert('Select day(s) to apply.'); return; }
targets.forEach(function (d) { addWindowToDay(d, { open: open, close: close, note: note }); });
});
/* ----- Close / Save ----- */
function closeUp(changed) {
if (window.parent && window.parent.closeCustomerPopup) {
window.parent.closeCustomerPopup(!!changed);
} else {
if (changed) location.reload(); else history.back();
}
}
document.getElementById('closeBtn').addEventListener('click', function () { closeUp(false); });
document.getElementById('saveBtn').addEventListener('click', function () {
const btn = this;
const rows = [];
let bad = '';
for (let d = 0; d < 7; d++) {
winsByDow[d].querySelectorAll('.win').forEach(function (win) {
const open = win.querySelector('.open').value.trim();
const close = win.querySelector('.close').value.trim();
const note = win.querySelector('.note').value.trim();
if (!open) return;
if (close && close < open) { if (!bad) bad = DOW_NAMES[d] + ': end time is earlier than start time.'; return; }
rows.push({ dow: d, open: open, close: close, note: note });
});
}
if (bad) { alert(bad); return; }
btn.disabled = true;
const body = new URLSearchParams();
body.set('action', 'save');
body.set('c_uid', C_UID);
body.set('data', JSON.stringify(rows));
fetch(location.href, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() })
.then(function (r) { return r.json(); })
.then(function (res) {
if (res && res.ok) { closeUp(true); }
else { alert('Save failed: ' + (res && res.msg ? res.msg : 'unknown')); btn.disabled = false; }
})
.catch(function (e) { alert('Save error: ' + e); btn.disabled = false; });
});
</script>
</body>
</html>

View File

@ -0,0 +1,217 @@
<?php
/* =====================================================================
* /lib/customer_closing_days_popup.php
* - Customer Closing Days (closure date ranges) editor popup
* - Opened as a modal (iframe) from customer_detail.php; reusable on other screens
* - On successful save, notifies parent via window.parent.closeCustomerPopup(true)
* - Save = "full replace" within current/future only; past ranges are not shown and are left untouched
* ===================================================================== */
include getenv("DOCUMENT_ROOT")."/include/session_include.php";
require_once getenv("DOCUMENT_ROOT")."/assets/dbCon.php";
$c_uid = isset($_REQUEST['c_uid']) ? intval($_REQUEST['c_uid']) : 0;
$goStr = $_GET['goStr'] ?? '';
$user = $_SESSION['ss_INITIAL'] ?? ($_SESSION['ss_UID'] ?? 'system');
// mbstring 미설치 환경 대비: UTF-8 문자 기준 N자로 자르기
if (!function_exists('utf8_cut')) {
function utf8_cut($s, $n) {
if (function_exists('mb_substr')) return mb_substr($s, 0, $n);
preg_match_all('/./us', (string)$s, $mm);
return (count($mm[0]) <= $n) ? $s : implode('', array_slice($mm[0], 0, $n));
}
}
/* =====================================================================
* AJAX save
* ===================================================================== */
if (($_POST['action'] ?? '') === 'save') {
header('Content-Type: application/json; charset=utf-8');
if ($c_uid <= 0) { echo json_encode(['ok' => false, 'msg' => 'invalid c_uid']); exit; }
$rows = json_decode($_POST['data'] ?? '[]', true);
if (!is_array($rows)) $rows = [];
$clean = [];
foreach ($rows as $r) {
$start = trim($r['start'] ?? '');
$end = trim($r['end'] ?? '');
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $start)) continue; // start date required
if ($end === '') $end = $start; // empty end = same day
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $end)) continue;
if ($end < $start) { $tmp = $start; $start = $end; $end = $tmp; } // auto-fix if reversed
$note = isset($r['note']) ? trim($r['note']) : '';
$note = utf8_cut($note, 200);
$clean[] = ['start' => $start, 'end' => $end, 'note' => $note];
}
usort($clean, function ($a, $b) { return strcmp($a['start'], $b['start']); });
$uEsc = addslashes($user);
// full replace
// full replace — 현재/미래만 관리, 과거 행은 건드리지 않음
qry("DELETE FROM tbl_customer_closing_days
WHERE ccd_customer_uid = " . $c_uid . " AND ccd_end_date >= CURDATE()");
foreach ($clean as $r) {
$noteV = ($r['note'] === '') ? "NULL" : "'" . addslashes($r['note']) . "'";
qry("INSERT INTO tbl_customer_closing_days
(ccd_customer_uid, ccd_start_date, ccd_end_date, ccd_note, ccd_createdby, ccd_created_at)
VALUES
($c_uid, '{$r['start']}', '{$r['end']}', $noteV, '$uEsc', NOW())");
}
echo json_encode(['ok' => true]);
exit;
}
/* =====================================================================
* GET: load existing (current/future only past is not shown)
* ===================================================================== */
$existing = [];
if ($c_uid > 0) {
$res = qry("SELECT ccd_start_date, ccd_end_date, ccd_note
FROM tbl_customer_closing_days
WHERE ccd_customer_uid = $c_uid AND ccd_end_date >= CURDATE()
ORDER BY ccd_start_date");
while ($row = fetch_array($res)) {
$existing[] = [
'start' => $row['ccd_start_date'],
'end' => $row['ccd_end_date'],
'note' => $row['ccd_note'],
];
}
}
$existingJson = json_encode($existing, JSON_UNESCAPED_UNICODE);
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Closing Days</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, "Segoe UI", Arial, sans-serif; margin: 0; padding: 14px 16px 80px; color: #333; font-size: 14px; background:#fff; }
h2 { font-size: 16px; margin: 0 0 10px; }
.toolbar { display:flex; gap:8px; flex-wrap:wrap; margin-bottom: 10px; }
.add { border: 1px solid #7fb069; background: #7fb069; color: #fff; border-radius: 4px; font-size: 13px; padding: 6px 12px; cursor: pointer; }
.add:hover { background: #6da356; }
.rows { display: flex; flex-direction: column; gap: 6px; }
.empty { color: #bbb; font-size: 13px; padding: 6px 0; }
.row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; padding: 4px 0; }
.row input { padding: 5px 7px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px; }
.row input[type=date] { width: 150px; }
.row input.note { flex: 1; min-width: 120px; }
.tilde { color: #999; }
.rm { border: none; background: #fbecec; color: #c0392b; border-radius: 4px; width: 26px; height: 28px; cursor: pointer; font-size: 15px; line-height: 1; }
.rm:hover { background: #c0392b; color: #fff; }
.footer { position: fixed; left: 0; right: 0; bottom: 0; background: #fff; border-top: 1px solid #e3e3e3; padding: 10px 16px; display: flex; justify-content: flex-end; gap: 8px; }
.btn { padding: 8px 18px; border-radius: 4px; border: 1px solid #ccc; background: #fff; cursor: pointer; font-size: 14px; }
.btn-save { background: #7fb069; border-color: #7fb069; color: #fff; }
.btn-save:hover { background: #6da356; }
.btn:disabled { opacity: .6; cursor: default; }
</style>
</head>
<body>
<h2>Closing Days</h2>
<div class="toolbar">
<button type="button" class="add" id="addBtn">+ Add closing</button>
</div>
<div class="rows" id="rows"></div>
<div class="footer">
<button type="button" class="btn" id="closeBtn">Close</button>
<button type="button" class="btn btn-save" id="saveBtn">Save</button>
</div>
<script>
const existing = <?=$existingJson?> || [];
const C_UID = <?=$c_uid?>;
const rowsEl = document.getElementById('rows');
function esc(s){ return (s||'').replace(/"/g,'&quot;'); }
function makeRow(d) {
d = d || { start: '', end: '', note: '' };
const row = document.createElement('div');
row.className = 'row';
row.innerHTML =
'<input type="date" class="start" value="' + (d.start || '') + '">' +
'<span class="tilde">~</span>' +
'<input type="date" class="end" value="' + (d.end || '') + '">' +
'<input type="text" class="note" maxlength="200" placeholder="Note (optional)" value="' + esc(d.note) + '">' +
'<button type="button" class="rm" title="Delete">&times;</button>';
row.querySelector('.rm').addEventListener('click', function () { row.remove(); refreshEmpty(); });
return row;
}
function refreshEmpty() {
const has = rowsEl.querySelector('.row');
let emptyEl = rowsEl.querySelector('.empty');
if (!has && !emptyEl) {
emptyEl = document.createElement('div');
emptyEl.className = 'empty';
emptyEl.textContent = 'No closing days.';
rowsEl.appendChild(emptyEl);
} else if (has && emptyEl) {
emptyEl.remove();
}
}
existing.forEach(function (d) { rowsEl.appendChild(makeRow(d)); });
refreshEmpty();
document.getElementById('addBtn').addEventListener('click', function () { rowsEl.appendChild(makeRow()); refreshEmpty(); });
function closeUp(changed) {
if (window.parent && window.parent.closeCustomerPopup) {
window.parent.closeCustomerPopup(!!changed);
} else {
if (changed) location.reload(); else history.back();
}
}
document.getElementById('closeBtn').addEventListener('click', function () { closeUp(false); });
document.getElementById('saveBtn').addEventListener('click', function () {
const btn = this;
const rows = [];
let bad = false;
document.querySelectorAll('.row').forEach(function (row) {
const start = row.querySelector('.start').value.trim();
let end = row.querySelector('.end').value.trim();
const note = row.querySelector('.note').value.trim();
if (!start) return;
if (!end) end = start;
if (end < start) bad = true;
rows.push({ start: start, end: end, note: note });
});
if (bad) { alert('Some rows have an end date earlier than the start date.'); return; }
btn.disabled = true;
const body = new URLSearchParams();
body.set('action', 'save');
body.set('c_uid', C_UID);
body.set('data', JSON.stringify(rows));
fetch(location.href, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() })
.then(function (r) { return r.json(); })
.then(function (res) {
if (res && res.ok) { closeUp(true); }
else { alert('Save failed: ' + (res && res.msg ? res.msg : 'unknown')); btn.disabled = false; }
})
.catch(function (e) { alert('Save error: ' + e); btn.disabled = false; });
});
</script>
</body>
</html>

View File

@ -0,0 +1,153 @@
<?php
/* =====================================================================
* /lib/customer_open_status.php
* "지금 이 고객이 오픈인가?" 판정 라이브러리 + 엔드포인트 겸용
*
* [라이브러리로 ] 다른 파일에서 include/require 하면 함수만 제공됨
* require_once getenv("DOCUMENT_ROOT")."/lib/customer_open_status.php";
* $st = customerIsOpenNow($c_uid); // 단일
* $map = customersOpenStatus([13013,13014]); // 배치
* (호출 전에 dbCon.php(qry/fetch_array) 로드돼 있어야 )
*
* [엔드포인트로 ] 파일을 직접 호출하면 JSON 반환
* POST/GET ids=13013,13014,... { "closed":[...], "detail":{...} }
*
* 판정 규칙:
* · c_time_restricted != 'Y' open=true (경고 없음)
* · tbl_customer_accesstime 데이터 없음 open=true (항상 열림)
* · 오늘이 휴무(closing day) open=false (closing_day)
* · 오늘 요일에 창이 하나도 없음 open=false (day_off)
* · 현재 시각이 어떤 (open~close) 포함 open=true (close=NULL 이면 오픈엔드)
* · open=false (outside_hours)
* ===================================================================== */
if (!function_exists('customersOpenStatus')) {
function customersOpenStatus($ids, $now = null) {
$ids = array_values(array_unique(array_filter(
array_map('intval', (array)$ids),
function ($x) { return $x > 0; })));
$out = [];
if (empty($ids)) return $out;
if ($now === null) $now = time();
$dow = (int)date('w', $now); // 0=Sun .. 6=Sat
$today = date('Y-m-d', $now);
$hm = date('H:i:s', $now);
$inList = implode(',', $ids);
// 기본값: 제한 없음(열림)
foreach ($ids as $id) {
$out[$id] = ['restricted' => false, 'open' => true, 'reason' => 'no_restriction'];
}
// c_time_restricted='Y' 인 고객만 대상 (1차 게이트)
$restrictedFlag = [];
$r = qry("SELECT c_uid AS id FROM tbl_customer
WHERE c_uid IN ($inList) AND c_time_restricted = 'Y'");
while ($row = fetch_array($r)) { $restrictedFlag[(int)$row['id']] = true; }
// 액세스타임 사용 여부
$usesAT = [];
$r = qry("SELECT DISTINCT ca_customer_uid AS id
FROM tbl_customer_accesstime
WHERE ca_customer_uid IN ($inList) AND ca_status = 'A'");
while ($row = fetch_array($r)) { $usesAT[(int)$row['id']] = true; }
// 오늘 휴무인 고객
$closedToday = [];
$r = qry("SELECT DISTINCT ccd_customer_uid AS id
FROM tbl_customer_closing_days
WHERE ccd_customer_uid IN ($inList)
AND '$today' BETWEEN ccd_start_date AND ccd_end_date");
while ($row = fetch_array($r)) { $closedToday[(int)$row['id']] = true; }
// 오늘 요일의 창
$windows = []; // id => [ [open, close], ... ]
$r = qry("SELECT ca_customer_uid AS id, ca_open_time AS o, ca_close_time AS c
FROM tbl_customer_accesstime
WHERE ca_customer_uid IN ($inList) AND ca_status = 'A' AND ca_day_of_week = $dow");
while ($row = fetch_array($r)) { $windows[(int)$row['id']][] = [$row['o'], $row['c']]; }
foreach ($ids as $id) {
// (1) c_time_restricted='Y' 아니면 신경 안 씀 → 항상 열림
if (empty($restrictedFlag[$id])) {
$out[$id] = ['restricted' => false, 'open' => true, 'reason' => 'not_restricted'];
continue;
}
// (2) 액세스타임 데이터가 없으면 항상 열림
if (empty($usesAT[$id])) {
$out[$id] = ['restricted' => false, 'open' => true, 'reason' => 'no_accesstime'];
continue;
}
// 오늘 휴무
if (!empty($closedToday[$id])) {
$out[$id] = ['restricted' => true, 'open' => false, 'reason' => 'closing_day'];
continue;
}
// 오늘 요일 창 없음 → 휴무
$wins = isset($windows[$id]) ? $windows[$id] : [];
if (empty($wins)) {
$out[$id] = ['restricted' => true, 'open' => false, 'reason' => 'day_off'];
continue;
}
$open = false;
$nextOpen = null; // 오늘 아직 안 열린(현재보다 늦은) 창의 오픈시간 중 가장 이른 것
foreach ($wins as $w) {
$o = $w[0]; $c = $w[1];
if ($o === null || $o === '') continue;
if ($c === null || $c === '') { // 오픈엔드
if ($hm >= $o) { $open = true; }
} else {
if ($hm >= $o && $hm <= $c) { $open = true; }
}
if ($o > $hm) { // 오늘 이따 여는 창
if ($nextOpen === null || $o < $nextOpen) $nextOpen = $o;
}
}
$entry = ['restricted' => true, 'open' => $open, 'reason' => ($open ? '' : 'outside_hours')];
if (!$open && $nextOpen !== null) {
$entry['next_open'] = substr($nextOpen, 0, 5); // 'HH:MM'
$secs = strtotime("$today $nextOpen") - $now;
$entry['minutes_until_open'] = max(0, (int)round($secs / 60));
}
$out[$id] = $entry;
}
return $out;
}
// 단일 고객
function customerIsOpenNow($id, $now = null) {
$r = customersOpenStatus([$id], $now);
$id = intval($id);
return isset($r[$id]) ? $r[$id]
: ['restricted' => false, 'open' => true, 'reason' => 'no_restriction'];
}
}
/* ---------------------------------------------------------------------
* 파일을 "직접 호출"했을 때만 엔드포인트로 동작.
* (다른 파일에서 include/require 함수만 제공하고 여기는 건너뜀)
* ------------------------------------------------------------------- */
$__self = basename(__FILE__);
$__script = isset($_SERVER['SCRIPT_FILENAME']) ? basename($_SERVER['SCRIPT_FILENAME']) : '';
if ($__script === $__self) {
include getenv("DOCUMENT_ROOT")."/include/session_include.php";
require_once getenv("DOCUMENT_ROOT")."/assets/dbCon.php";
header('Content-Type: application/json; charset=utf-8');
$idsRaw = isset($_POST['ids']) ? $_POST['ids'] : (isset($_GET['ids']) ? $_GET['ids'] : '');
$ids = is_array($idsRaw) ? $idsRaw : array_filter(explode(',', (string)$idsRaw), 'strlen');
$detail = customersOpenStatus($ids);
$closed = [];
foreach ($detail as $id => $st) {
if (!empty($st['restricted']) && empty($st['open'])) $closed[] = $id;
}
echo json_encode(['closed' => $closed, 'detail' => $detail]);
}

View File

@ -137,6 +137,9 @@ if ($c_paymenttype =="CA") {
</td>
</tr>
<!-- Access Time / Closing Days (표시 조각 AJAX 주입, 클릭 편집 팝업) -->
<tbody id="siAccessClosing"></tbody>
</table>
<br>
<div class="text-center grid-layout-col-2">
@ -160,6 +163,21 @@ if ($c_paymenttype =="CA") {
</div>
<!-- ==============================================================
Access Time / Closing Days 편집 오버레이 (순수 오버레이)
- Bootstrap 모달 미사용: BS3/BS5 focus-trap 충돌 회피
- customer_access_time_popup.php / customer_closing_days_popup.php 재사용
============================================================== -->
<div id="siAccessPopupOverlay" style="display:none; position:fixed; top:0; left:0; right:0; bottom:0; z-index:100000; background:rgba(0,0,0,0.5);">
<div style="position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:92%; max-width:760px; background:#fff; border-radius:6px; overflow:hidden; box-shadow:0 10px 40px rgba(0,0,0,0.35);">
<div style="display:flex; justify-content:space-between; align-items:center; padding:10px 14px; background:#2A9B56; color:#fff;">
<h4 id="siAccessPopupTitle" style="margin:0; font-size:16px;">Edit</h4>
<button type="button" id="siAccessPopupClose" style="background:none; border:none; color:#fff; font-size:24px; line-height:1; cursor:pointer;">&times;</button>
</div>
<iframe id="siAccessPopupFrame" src="about:blank" title="Edit popup" style="width:100%; height:70vh; border:0; display:block;"></iframe>
</div>
</div>
<script>
@ -277,9 +295,57 @@ function popup() {
const url = `/lib/customer_image_upload_popup.php?c_uid=<?=$c_uid?>&goStr=<?=$goStr?>`;
window.open(url, "uploadImage", "width=600,height=750,scrollbars=yes");
}
/* ----- Access Time / Closing Days 표시 + 편집 오버레이 ----- */
(function () {
var C_UID = "<?=$c_uid?>";
var popupConfig = {
accesstime: { url: '/lib/customer_access_time_popup.php', title: 'Access Time' },
closingdays: { url: '/lib/customer_closing_days_popup.php', title: 'Closing Days' }
};
var popupChanged = false;
// 이전 로드분 오버레이 제거 후 body 직속으로 이동 (중복/클리핑 방지)
jQuery('body > #siAccessPopupOverlay').remove();
var $overlay = jQuery('#siAccessPopupOverlay').appendTo(document.body);
function loadDisplay() {
if (!C_UID) { jQuery('#siAccessClosing').empty(); return; }
jQuery('#siAccessClosing').load('/lib/customer_access_display.php?c_uid=' + encodeURIComponent(C_UID));
}
loadDisplay();
function openPopup(key) {
var cfg = popupConfig[key];
if (!cfg || !C_UID) return;
popupChanged = false;
jQuery('#siAccessPopupTitle').text(cfg.title);
jQuery('#siAccessPopupFrame').attr('src', cfg.url + '?c_uid=' + encodeURIComponent(C_UID) + '&goStr=');
$overlay.css('display', 'block');
}
function closePopup() {
$overlay.css('display', 'none');
jQuery('#siAccessPopupFrame').attr('src', 'about:blank');
if (popupChanged) { loadDisplay(); popupChanged = false; }
}
jQuery(document).on('click', '#siAccessClosing .box-clickable[data-popup]', function () {
openPopup(jQuery(this).data('popup'));
});
jQuery('#siAccessPopupClose').on('click', closePopup);
$overlay.on('click', function (e) { if (e.target === this) closePopup(); });
// 자식 iframe(팝업 PHP)에서 호출
window.closeCustomerPopup = function (changed) {
if (changed) popupChanged = true;
closePopup();
};
})();
</script>
<link rel="stylesheet" href="/assets/css/customer_access.css">
<style>
.error{
color: red;