goiintra/public_html/lib/customer_access_time_popup.php

483 lines
21 KiB
PHP

<?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
* - Top editor = dual sliders (06:00~18:00 / 10-min step, end optional & off by
* default). Selecting day chip(s) / clicking a time pill binds that window to
* the sliders; moving the sliders edits every selected window live. Each day
* has its own "+ Add another"; days show their windows as compact time pills.
* ===================================================================== */
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: 12px 14px 70px; color: #333; font-size: 14px; background:#fff; }
h2 { font-size: 15px; margin: 0 0 8px; }
/* ---- Top editor: bound to the currently selected window(s) ---- */
.editor { border: 1px solid #cfe0cf; background: #f4f9f1; border-radius: 8px; padding: 11px 12px; margin-bottom: 12px; }
.slrow { display: flex; align-items: center; gap: 10px; margin-bottom: 7px; }
.slkind { width: 66px; flex: none; font-size: 12px; font-weight: 600; color: #4a7a3a; }
.slrow input[type=range] { flex: 1; min-width: 60px; accent-color: #7fb069; height: 24px; margin: 0; cursor: pointer; }
.slrow.end input[type=range] { accent-color: #d9932f; }
.slrow.end.off input[type=range] { accent-color: #ccc; }
.sltime { width: 54px; flex: none; text-align: right; font-size: 15px; font-weight: 700; color: #333; font-variant-numeric: tabular-nums; }
.slrow.end.off .sltime { color: #aaa; font-weight: 500; }
/* big, easy-to-hit End checkbox */
.endlbl { width: 66px; flex: none; display: inline-flex; align-items: center; gap: 7px; font-size: 12px; font-weight: 600; color: #a06a2a; cursor: pointer; user-select: none; }
.slrow.end.off .endlbl { color: #999; }
.endlbl input { width: 18px; height: 18px; flex: none; cursor: pointer; accent-color: #d9932f; }
.ed-line { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 9px; }
.ed-line input.note { padding: 6px 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px; flex: 1; min-width: 90px; }
.chips { display: flex; gap: 5px; flex-wrap: wrap; }
.chip { border: 1px solid #bcd1bc; background: #fff; color: #4a7a3a; border-radius: 14px; padding: 4px 12px; font-size: 13px; cursor: pointer; user-select: none; }
.chip.on { background: #7fb069; border-color: #7fb069; color: #fff; }
.chip.wknd { color: #c0392b; }
.chip.wknd.on { color: #fff; }
.qbtn { border: 1px solid #cfcfcf; background: #fff; border-radius: 4px; font-size: 12px; padding: 5px 11px; cursor: pointer; color: #555; }
.qbtn:hover { background: #eee; }
.hint { font-size: 11px; color: #8aa07d; margin-top: 7px; line-height: 1.5; }
/* ---- Per-day summary: time-only pills + per-day add ---- */
.day-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border: 1px solid #e6e6e6; border-radius: 6px; margin-bottom: 5px; min-height: 38px; }
.dn { width: 38px; flex: none; font-weight: 600; }
.dn.wknd { color: #c0392b; }
.pills { flex: 1; display: flex; flex-wrap: wrap; gap: 5px; align-items: center; }
.pill { display: inline-flex; align-items: center; gap: 2px; background: #eef5e8; border: 1px solid #cfe0cf; color: #39632a; border-radius: 14px; padding: 3px 3px 3px 11px; font-size: 12px; cursor: pointer; }
.pill.noend { background: #fdf3e6; border-color: #f0d9b5; color: #a06a2a; }
.pill.sel { box-shadow: 0 0 0 2px #7fb069; }
.pill.noend.sel { box-shadow: 0 0 0 2px #d9932f; }
.pill .lbl { font-variant-numeric: tabular-nums; }
.pill .x { border: none; background: transparent; color: inherit; cursor: pointer; font-size: 15px; line-height: 1; padding: 0 5px; opacity: .55; }
.pill .x:hover { opacity: 1; }
.pill.hasnote .lbl::after { content: " *"; color: #d9932f; font-weight: 700; }
.closed { color: #bbb; font-size: 12px; }
.dayadd { border: 1px dashed #bcd1bc; background: #fff; color: #4a7a3a; border-radius: 12px; height: 26px; padding: 0 10px; flex: none; cursor: pointer; font-size: 12px; line-height: 1; }
.dayadd:hover { background: #7fb069; color: #fff; border-color: #7fb069; }
.footer { position: fixed; left: 0; right: 0; bottom: 0; background: #fff; border-top: 1px solid #e3e3e3; padding: 9px 14px; 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-clear { margin-right: auto; color: #c0392b; border-color: #e3b7b1; }
.btn-clear:hover { background: #c0392b; border-color: #c0392b; color: #fff; }
.btn:disabled { opacity: .6; cursor: default; }
</style>
</head>
<body>
<h2>Access Time</h2>
<!-- Top editor: bound to the selected window(s); moving sliders edits them live -->
<div class="editor">
<div class="slrow start">
<span class="slkind">Start</span>
<input type="range" id="openR">
<span class="sltime" id="openT">09:00</span>
</div>
<div class="slrow end off" id="endRow">
<label class="endlbl"><span>End</span><input type="checkbox" id="endChk"></label>
<input type="range" id="closeR">
<span class="sltime" id="closeT">None</span>
</div>
<div class="ed-line">
<div class="chips" id="chips"></div>
</div>
<div class="ed-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>
<input type="text" id="noteIn" class="note" maxlength="200" placeholder="Note (optional)">
</div>
<div class="hint">Pick day(s) or click a time to select it &mdash; the sliders jump to it and edit every selected time live.<br>Use each day's <b>+ Add</b> to add another time to that day.</div>
</div>
<div id="days"></div>
<div class="footer">
<button type="button" class="btn btn-clear" id="clearBtn">Clear all</button>
<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 DAY_MIN = 6 * 60; // 06:00 slider floor (minutes)
const DAY_MAX = 18 * 60; // 18:00 slider ceiling
const STEP = 10; // 10-minute step
const DEF_OPEN = 9 * 60; // default editor start 09:00
const DEF_CLOSE = 17 * 60; // default editor end 17:00 (only when End is on)
const existing = <?=$existingJson?> || {};
const C_UID = <?=$c_uid?>;
function pad(n){ return String(n).padStart(2,'0'); }
function minToStr(v){ return pad(Math.floor(v / 60)) + ':' + pad(v % 60); }
function strToMin(s){ return /^\d{2}:\d{2}$/.test(s || '') ? (parseInt(s.slice(0,2),10) * 60 + parseInt(s.slice(3,5),10)) : null; }
function clampRange(v){ return Math.max(DAY_MIN, Math.min(DAY_MAX, v)); }
/* --------------------------------------------------------------------
* Model
* - windows[]: every time window { id, dow, open, close(min|null), note }
* - selectedByDay: dow -> the ONE selected window object for that day
* Editing the sliders/checkbox writes into every selected window (bulk).
* Editing the note writes the note into every selected window.
* ------------------------------------------------------------------ */
let nextId = 1;
const windows = [];
for (let d = 0; d < 7; d++) {
(existing[d] || []).forEach(function (w) {
const o = strToMin(w.open);
if (o !== null) windows.push({ id: nextId++, dow: d, open: o, close: strToMin(w.close), note: w.note || '' });
});
}
const selectedByDay = {}; // dow -> window obj
function latestWindow(d) {
let best = null;
windows.forEach(function (w) { if (w.dow === d && (!best || w.id > best.id)) best = w; });
return best;
}
function editorTemplate() {
return { open: +openR.value, close: endChk.checked ? +closeR.value : null, note: noteIn.value.trim() };
}
function createWindow(d, tmpl) {
const w = { id: nextId++, dow: d, open: tmpl.open, close: tmpl.close, note: tmpl.note };
windows.push(w);
return w;
}
/* ================= Editor controls ================= */
const openR = document.getElementById('openR');
const closeR = document.getElementById('closeR');
const openT = document.getElementById('openT');
const closeT = document.getElementById('closeT');
const endChk = document.getElementById('endChk');
const endRow = document.getElementById('endRow');
const noteIn = document.getElementById('noteIn');
[openR, closeR].forEach(function (r) { r.min = DAY_MIN; r.max = DAY_MAX; r.step = STEP; });
openR.value = DEF_OPEN; closeR.value = DEF_CLOSE; endChk.checked = false;
function paintEditor() {
openT.textContent = minToStr(+openR.value);
if (endChk.checked) {
endRow.classList.remove('off'); closeR.disabled = false;
closeT.textContent = minToStr(+closeR.value);
} else {
endRow.classList.add('off'); closeR.disabled = true;
closeT.textContent = 'None';
}
}
// snap the editor to a window's values WITHOUT applying back (no event fired)
function editorFromWindow(w) {
openR.value = clampRange(w.open);
if (w.close === null) { endChk.checked = false; }
else { endChk.checked = true; closeR.value = clampRange(w.close); }
noteIn.value = w.note || '';
paintEditor();
}
function selectedList() {
return Object.keys(selectedByDay).map(function (d) { return selectedByDay[d]; });
}
function applyTimeToSelected() {
const open = +openR.value;
const close = endChk.checked ? +closeR.value : null;
selectedList().forEach(function (w) { w.open = open; w.close = close; });
render();
}
function applyNoteToSelected() {
const note = noteIn.value.trim();
selectedList().forEach(function (w) { w.note = note; });
render();
}
openR.addEventListener('input', function () {
if (endChk.checked && +closeR.value < +openR.value) closeR.value = openR.value;
paintEditor(); applyTimeToSelected();
});
closeR.addEventListener('input', function () {
if (+closeR.value < +openR.value) openR.value = closeR.value;
paintEditor(); applyTimeToSelected();
});
endChk.addEventListener('change', function () {
if (endChk.checked && +closeR.value < +openR.value) closeR.value = openR.value;
paintEditor(); applyTimeToSelected();
});
noteIn.addEventListener('input', applyNoteToSelected);
paintEditor();
/* ================= Day selection chips ================= */
const chipsEl = document.getElementById('chips');
for (let d = 0; d < 7; d++) {
const chip = document.createElement('span');
chip.className = 'chip' + ((d === 0 || d === 6) ? ' wknd' : '');
chip.dataset.dow = d;
chip.textContent = DOW_NAMES[d];
chip.addEventListener('click', function () { toggleDay(d); });
chipsEl.appendChild(chip);
}
function syncChips() {
chipsEl.querySelectorAll('.chip').forEach(function (c) {
c.classList.toggle('on', !!selectedByDay[parseInt(c.dataset.dow, 10)]);
});
}
// chip: select that day's latest window (create one if the day is empty); toggle off if already selected
function toggleDay(d) {
if (selectedByDay[d]) { delete selectedByDay[d]; syncChips(); render(); return; }
let w = latestWindow(d);
if (!w) w = createWindow(d, editorTemplate());
selectedByDay[d] = w;
editorFromWindow(w);
syncChips(); render();
}
// group buttons: replace selection with the given days (latest window each, create if empty)
function selectDays(list) {
for (const k in selectedByDay) delete selectedByDay[k];
let ref = null;
list.forEach(function (d) {
let w = latestWindow(d);
if (!w) w = createWindow(d, editorTemplate());
selectedByDay[d] = w;
if (ref === null) ref = w;
});
if (ref) editorFromWindow(ref);
syncChips(); render();
}
document.getElementById('qWeekday').addEventListener('click', function () { selectDays([1,2,3,4,5]); });
document.getElementById('qWeekend').addEventListener('click', function () { selectDays([0,6]); });
document.getElementById('qAll').addEventListener('click', function () { selectDays([0,1,2,3,4,5,6]); });
document.getElementById('qNone').addEventListener('click', function () {
for (const k in selectedByDay) delete selectedByDay[k];
syncChips(); render();
});
/* ================= Per-day render ================= */
const daysEl = document.getElementById('days');
const rowsByDow = {};
for (let d = 0; d < 7; d++) {
const row = document.createElement('div');
row.className = 'day-row';
row.innerHTML =
'<span class="dn ' + ((d === 0 || d === 6) ? 'wknd' : '') + '">' + DOW_NAMES[d] + '</span>' +
'<span class="pills"></span>' +
'<button type="button" class="dayadd">+ Add</button>';
// per-day "Add another": new window from the current editor values, selected alone
row.querySelector('.dayadd').addEventListener('click', function () {
const w = createWindow(d, editorTemplate());
for (const k in selectedByDay) delete selectedByDay[k];
selectedByDay[d] = w;
editorFromWindow(w);
syncChips(); render();
});
rowsByDow[d] = row;
daysEl.appendChild(row);
}
function selectPill(d, w) {
if (selectedByDay[d] === w) { delete selectedByDay[d]; syncChips(); render(); return; }
selectedByDay[d] = w;
editorFromWindow(w);
syncChips(); render();
}
function deleteWindow(d, w) {
const i = windows.indexOf(w);
if (i >= 0) windows.splice(i, 1);
if (selectedByDay[d] === w) delete selectedByDay[d];
syncChips(); render();
}
function render() {
for (let d = 0; d < 7; d++) {
const list = windows.filter(function (w) { return w.dow === d; })
.sort(function (a, b) { return a.open - b.open; });
const pills = rowsByDow[d].querySelector('.pills');
pills.innerHTML = '';
if (list.length === 0) {
const c = document.createElement('span');
c.className = 'closed'; c.textContent = 'Closed';
pills.appendChild(c);
continue;
}
list.forEach(function (w) {
const note = (w.note || '').trim();
const sel = selectedByDay[d] === w;
const pill = document.createElement('span');
pill.className = 'pill' + (w.close === null ? ' noend' : '') + (note ? ' hasnote' : '') + (sel ? ' sel' : '');
const lbl = document.createElement('span');
lbl.className = 'lbl';
lbl.textContent = minToStr(w.open) + (w.close === null ? ' ~' : ' ~ ' + minToStr(w.close));
if (note) lbl.title = note;
lbl.addEventListener('click', function () { selectPill(d, w); });
const x = document.createElement('button');
x.type = 'button'; x.className = 'x'; x.innerHTML = '&times;'; x.title = 'Delete';
x.addEventListener('click', function (e) { e.stopPropagation(); deleteWindow(d, w); });
pill.appendChild(lbl); pill.appendChild(x);
pills.appendChild(pill);
});
}
}
render();
/* ================= 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); });
// Clear all: wipe every window from every day (not saved until "Save")
document.getElementById('clearBtn').addEventListener('click', function () {
if (windows.length === 0) return;
if (!confirm('Remove all times for every day?')) return;
windows.length = 0;
for (const k in selectedByDay) delete selectedByDay[k];
syncChips(); render();
});
document.getElementById('saveBtn').addEventListener('click', function () {
const btn = this;
const rows = windows.map(function (w) {
return { dow: w.dow, open: minToStr(w.open), close: (w.close === null) ? '' : minToStr(w.close), note: (w.note || '').trim() };
});
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>