데이터 사용량 기준점 입력 모달 추가

This commit is contained in:
seo
2026-07-02 13:02:08 +09:00
parent da4f48630a
commit de419b483a
2 changed files with 665 additions and 21 deletions
+662 -20
View File
@@ -62,6 +62,21 @@ function db_ensure_data_usage_schema(PDO $pdo): void
$pdo->exec("ALTER TABLE car_data_usage ADD COLUMN carrier_estimated_bytes INT UNSIGNED NOT NULL DEFAULT 0 AFTER total_bytes");
}
$pdo->exec("CREATE TABLE IF NOT EXISTS car_usage_calibrations (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
month CHAR(7) NOT NULL,
anchor_ts DATETIME NOT NULL,
total_bytes BIGINT UNSIGNED NOT NULL,
meter_adjusted_bytes BIGINT UNSIGNED NOT NULL,
included_bytes BIGINT UNSIGNED NOT NULL DEFAULT 104857600,
source VARCHAR(32) NOT NULL DEFAULT 'manual',
note VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uniq_month_anchor (month, anchor_ts),
KEY idx_month_anchor (month, anchor_ts)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
$done = true;
}
@@ -245,8 +260,8 @@ function monthly_data_usage(PDO $pdo): array
$projectionStart = $monthStart->getTimestamp();
}
$measuredSeconds = max(1, $now->getTimestamp() - $projectionStart);
$currentBilling = current_billing_usage($monthStart->format('Y-m'));
$billingScaleInfo = billing_meter_scale_info($monthStart->format('Y-m'), $currentBilling);
$currentBilling = current_billing_usage($monthStart->format('Y-m'), $pdo);
$billingScaleInfo = billing_meter_scale_info($monthStart->format('Y-m'), $currentBilling, $pdo);
$billingScale = $billingScaleInfo['scale'];
$billingCurrentBytes = (int)round($carrierAdjustedTotalBytes * $billingScale);
$projectedBytes = (int)round($carrierAdjustedTotalBytes / $measuredSeconds * $monthSeconds);
@@ -523,22 +538,61 @@ function calibrated_usage_range(PDO $pdo, DateTimeInterface $start, DateTimeInte
];
}
function current_billing_usage(string $month): ?array
function billing_usage_row(array $row): array
{
$totalBytes = (int)($row['total_bytes'] ?? 0);
$includedBytes = (int)($row['included_bytes'] ?? DATA_MONTHLY_INCLUDED_BYTES);
return [
'month' => (string)($row['month'] ?? ''),
'anchor_ts' => $row['anchor_ts'] ?? null,
'total_bytes' => $totalBytes,
'total_mb' => round(bytes_to_mb($totalBytes), 6),
'meter_adjusted_bytes' => (int)($row['meter_adjusted_bytes'] ?? 0),
'included_bytes' => $includedBytes,
'included_mb' => round(bytes_to_mb($includedBytes), 6),
'coupon_registered_at' => $row['coupon_registered_at'] ?? null,
'source' => (string)($row['source'] ?? 'manual'),
'note' => $row['note'] ?? null,
'created_at' => $row['created_at'] ?? null,
];
}
function current_billing_usage(string $month, ?PDO $pdo = null): ?array
{
if ($pdo instanceof PDO) {
try {
$stmt = $pdo->prepare("SELECT month, anchor_ts, total_bytes, meter_adjusted_bytes, included_bytes, source, note, created_at
FROM car_usage_calibrations
WHERE month = ?
ORDER BY anchor_ts DESC, id DESC
LIMIT 1");
$stmt->execute([$month]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (is_array($row)) {
return billing_usage_row($row);
}
} catch (Throwable $e) {
// 테이블 생성 전이거나 DB 권한 문제가 있으면 아래 상수 기준점을 사용한다.
}
}
if (!isset(DATA_CURRENT_BILLING_USAGE_BYTES[$month])) {
return null;
}
$row = DATA_CURRENT_BILLING_USAGE_BYTES[$month];
return [
return billing_usage_row([
'month' => $month,
'anchor_ts' => $row['anchor_ts'] ?? null,
'total_bytes' => (int)($row['total_bytes'] ?? 0),
'total_mb' => round(bytes_to_mb((int)($row['total_bytes'] ?? 0)), 6),
'meter_adjusted_bytes' => (int)($row['meter_adjusted_bytes'] ?? 0),
'included_bytes' => (int)($row['included_bytes'] ?? DATA_MONTHLY_INCLUDED_BYTES),
'included_mb' => round(bytes_to_mb((int)($row['included_bytes'] ?? DATA_MONTHLY_INCLUDED_BYTES)), 6),
'coupon_registered_at' => $row['coupon_registered_at'] ?? null,
];
'source' => 'constant',
'note' => '코드 기준점',
'created_at' => null,
]);
}
function billing_meter_scale(?array $currentBilling): float
@@ -555,7 +609,45 @@ function billing_meter_scale(?array $currentBilling): float
return (int)($currentBilling['total_bytes'] ?? 0) / $meterAdjustedBytes;
}
function billing_meter_scale_info(string $month, ?array $currentBilling): array
function previous_billing_usage(string $month, ?PDO $pdo = null): ?array
{
$dbRow = null;
if ($pdo instanceof PDO) {
try {
$stmt = $pdo->prepare("SELECT month, anchor_ts, total_bytes, meter_adjusted_bytes, included_bytes, source, note, created_at
FROM car_usage_calibrations
WHERE month < ?
ORDER BY month DESC, anchor_ts DESC, id DESC
LIMIT 1");
$stmt->execute([$month]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (is_array($row)) {
$dbRow = billing_usage_row($row);
}
} catch (Throwable $e) {
$dbRow = null;
}
}
$constantMonth = null;
foreach (DATA_CURRENT_BILLING_USAGE_BYTES as $billingMonth => $row) {
if ($billingMonth >= $month || ($row['meter_adjusted_bytes'] ?? 0) <= 0) {
continue;
}
if ($constantMonth === null || $billingMonth > $constantMonth) {
$constantMonth = $billingMonth;
}
}
$constantRow = $constantMonth ? current_billing_usage($constantMonth, null) : null;
if ($dbRow && (!$constantRow || strcmp((string)$dbRow['month'], (string)$constantRow['month']) >= 0)) {
return $dbRow;
}
return $constantRow;
}
function billing_meter_scale_info(string $month, ?array $currentBilling, ?PDO $pdo = null): array
{
$currentScale = billing_meter_scale($currentBilling);
if ($currentBilling && $currentScale > 0) {
@@ -567,24 +659,14 @@ function billing_meter_scale_info(string $month, ?array $currentBilling): array
];
}
$fallbackMonth = null;
$fallbackBilling = null;
foreach (DATA_CURRENT_BILLING_USAGE_BYTES as $billingMonth => $row) {
if ($billingMonth >= $month || ($row['meter_adjusted_bytes'] ?? 0) <= 0) {
continue;
}
if ($fallbackMonth === null || $billingMonth > $fallbackMonth) {
$fallbackMonth = $billingMonth;
$fallbackBilling = current_billing_usage($billingMonth);
}
}
$fallbackBilling = previous_billing_usage($month, $pdo);
$fallbackScale = billing_meter_scale($fallbackBilling);
if ($fallbackBilling && $fallbackScale > 0) {
return [
'scale' => $fallbackScale,
'source' => 'previous_month_anchor',
'source_month' => $fallbackMonth,
'source_month' => $fallbackBilling['month'] ?? null,
'source_billing' => $fallbackBilling,
];
}
@@ -625,6 +707,149 @@ function billing_calibration(?array $currentBilling, array $billingScaleInfo): a
];
}
function car_monitor_is_admin(): bool
{
$session = car_monitor_launcher_session();
return !empty($session['admin']);
}
function car_monitor_require_admin(): void
{
if (car_monitor_is_admin()) {
return;
}
json_response([
'status' => 'forbidden',
'message' => '관리자 권한이 필요합니다.',
], 403);
}
function request_payload(): array
{
$raw = file_get_contents('php://input');
if (is_string($raw) && trim($raw) !== '') {
$json = json_decode($raw, true);
if (is_array($json)) {
return $json;
}
}
return $_POST;
}
function parse_usage_bytes(mixed $value, string $unit): int
{
$normalized = trim(str_replace(',', '', (string)$value));
if ($normalized === '' || !is_numeric($normalized)) {
throw new InvalidArgumentException('T world 사용량을 숫자로 입력해야 합니다.');
}
$amount = (float)$normalized;
if ($amount < 0) {
throw new InvalidArgumentException('T world 사용량은 0 이상이어야 합니다.');
}
return match (strtolower(trim($unit))) {
'b', 'byte', 'bytes' => (int)round($amount),
'kb', 'kib' => (int)round($amount * 1024),
'mb', 'mib' => (int)round($amount * 1024 * 1024),
default => throw new InvalidArgumentException('사용량 단위는 B, KB, MB 중 하나여야 합니다.'),
};
}
function parse_anchor_datetime(?string $value): DateTimeImmutable
{
$timezone = new DateTimeZone(date_default_timezone_get());
$raw = trim((string)$value);
if ($raw === '') {
return new DateTimeImmutable('now', $timezone);
}
$normalized = str_replace('T', ' ', $raw);
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $normalized)) {
$normalized .= ':00';
}
$dt = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $normalized, $timezone);
if (!$dt) {
throw new InvalidArgumentException('기준 시각 형식이 올바르지 않습니다.');
}
return $dt;
}
function save_usage_calibration(PDO $pdo, array $payload): array
{
$anchor = parse_anchor_datetime($payload['anchor_ts'] ?? null);
$monthStart = $anchor->modify('first day of this month')->setTime(0, 0, 0);
$nextMonthStart = $monthStart->modify('first day of next month');
if ($anchor < $monthStart || $anchor >= $nextMonthStart) {
throw new InvalidArgumentException('기준 시각이 현재 월 범위를 벗어났습니다.');
}
$carrierBytes = parse_usage_bytes($payload['total_usage'] ?? '', (string)($payload['unit'] ?? 'KB'));
$rangeUsage = calibrated_usage_range($pdo, $monthStart, $anchor);
$meterBytes = (int)$rangeUsage['carrier_adjusted_total_bytes'];
if ($meterBytes <= 0) {
throw new RuntimeException('기준 시각까지의 로컬 통신사형 누적값이 없습니다.');
}
$month = $monthStart->format('Y-m');
$anchorText = $anchor->format('Y-m-d H:i:s');
$note = trim((string)($payload['note'] ?? ''));
if (function_exists('mb_strlen') && mb_strlen($note) > 255) {
$note = mb_substr($note, 0, 255);
} elseif (strlen($note) > 255) {
$note = substr($note, 0, 255);
}
$stmt = $pdo->prepare("INSERT INTO car_usage_calibrations
(month, anchor_ts, total_bytes, meter_adjusted_bytes, included_bytes, source, note)
VALUES (?, ?, ?, ?, ?, 'manual', ?)
ON DUPLICATE KEY UPDATE
total_bytes = VALUES(total_bytes),
meter_adjusted_bytes = VALUES(meter_adjusted_bytes),
included_bytes = VALUES(included_bytes),
source = VALUES(source),
note = VALUES(note),
created_at = CURRENT_TIMESTAMP");
$stmt->execute([
$month,
$anchorText,
$carrierBytes,
$meterBytes,
DATA_MONTHLY_INCLUDED_BYTES,
$note === '' ? null : $note,
]);
return [
'month' => $month,
'anchor_ts' => $anchorText,
'total_bytes' => $carrierBytes,
'meter_adjusted_bytes' => $meterBytes,
'scale' => round($carrierBytes / $meterBytes, 6),
'note' => $note === '' ? null : $note,
];
}
function usage_calibration_rows(PDO $pdo): array
{
$stmt = $pdo->query("SELECT month, anchor_ts, total_bytes, meter_adjusted_bytes, included_bytes, source, note, created_at
FROM car_usage_calibrations
ORDER BY month DESC, anchor_ts DESC, id DESC
LIMIT 12");
$rows = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$billing = billing_usage_row($row);
$meter = max(1, (int)$billing['meter_adjusted_bytes']);
$billing['scale'] = round((int)$billing['total_bytes'] / $meter, 6);
$rows[] = $billing;
}
return $rows;
}
function json_response(array $payload, int $status = 200): void
{
http_response_code($status);
@@ -853,7 +1078,38 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
}
}
if (isset($_GET['mode']) && $_GET['mode'] === 'usage-calibration') {
car_monitor_require_access(true);
car_monitor_require_admin();
try {
$pdo = car_db();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$saved = save_usage_calibration($pdo, request_payload());
json_response([
'status' => 'success',
'saved' => $saved,
'usage' => monthly_data_usage($pdo),
'calibrations' => usage_calibration_rows($pdo),
]);
}
json_response([
'status' => 'success',
'usage' => monthly_data_usage($pdo),
'calibrations' => usage_calibration_rows($pdo),
'server_time' => time(),
]);
} catch (Throwable $e) {
json_response([
'status' => 'error',
'message' => $e->getMessage(),
], 400);
}
}
car_monitor_require_access(false);
$carMonitorSession = car_monitor_launcher_session();
?>
<!DOCTYPE html>
<html lang="ko" data-bs-theme="light">
@@ -1191,6 +1447,132 @@ car_monitor_require_access(false);
border-color: rgba(34, 197, 94, 0.45);
color: var(--accent-green);
}
.admin-settings-btn.d-none {
display: none !important;
}
.custom-modal-overlay {
position: fixed;
inset: 0;
z-index: 1080;
display: flex;
align-items: center;
justify-content: center;
padding: 18px;
background: rgba(15, 23, 42, 0.36);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.custom-modal-overlay[hidden] {
display: none !important;
}
.custom-modal-panel {
width: min(560px, 100%);
max-height: min(760px, calc(100vh - 36px));
overflow: auto;
border-radius: 20px;
background: var(--card-bg);
border: 1px solid var(--card-border);
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.28);
color: var(--text-primary);
}
.custom-modal-header,
.custom-modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 18px 20px;
}
.custom-modal-header {
border-bottom: 1px solid var(--card-border);
}
.custom-modal-footer {
border-top: 1px solid var(--card-border);
justify-content: flex-end;
}
.custom-modal-body {
padding: 18px 20px 20px;
}
.calibration-grid {
display: grid;
grid-template-columns: 1fr 120px;
gap: 12px;
}
.calibration-field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 14px;
}
.calibration-field label {
font-size: 0.78rem;
font-weight: 700;
color: var(--text-secondary);
}
.calibration-field input,
.calibration-field select {
width: 100%;
border: 1px solid var(--card-border);
border-radius: 12px;
background: rgba(255, 255, 255, 0.78);
color: #0f172a;
padding: 10px 12px;
font: inherit;
outline: none;
}
[data-bs-theme="dark"] .calibration-field input,
[data-bs-theme="dark"] .calibration-field select {
background: rgba(15, 23, 42, 0.86);
color: var(--text-primary);
}
.calibration-summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin: 14px 0;
}
.calibration-summary-item {
border-radius: 12px;
background: rgba(148, 163, 184, 0.12);
padding: 10px 12px;
}
.calibration-history {
margin-top: 14px;
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--card-border);
}
.calibration-history-row {
display: grid;
grid-template-columns: 92px 1fr 78px;
gap: 8px;
padding: 9px 10px;
font-size: 0.78rem;
border-bottom: 1px solid var(--card-border);
}
.calibration-history-row:last-child {
border-bottom: 0;
}
.calibration-message {
min-height: 22px;
font-size: 0.82rem;
}
@media (max-width: 575px) {
.custom-modal-overlay {
align-items: stretch;
padding: 10px;
}
.custom-modal-panel {
max-height: calc(100vh - 20px);
}
.calibration-grid,
.calibration-summary {
grid-template-columns: 1fr;
}
.calibration-history-row {
grid-template-columns: 1fr;
}
}
@keyframes pulse-ring {
0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); }
@@ -1449,6 +1831,9 @@ car_monitor_require_access(false);
<button class="btn-icon-toggle shadow-sm" id="theme-btn" onclick="toggleTheme()">
<i class="fas fa-moon"></i>
</button>
<button class="btn-icon-toggle shadow-sm admin-settings-btn d-none" id="settings-btn" type="button" title="설정">
<i class="fas fa-gear"></i>
</button>
</div>
</div>
@@ -1859,6 +2244,7 @@ car_monitor_require_access(false);
let currentLang = 'ko';
const DEFAULT_USAGE_REFRESH_INTERVAL_SEC = 10;
const CAR_MONITOR_SESSION = <?php echo json_encode($carMonitorSession, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?>;
const CMD_MAP = {
se: { label: 'cmdStatus', icon: 'fa-search', bg: 'var(--box-bg-blue)', color: 'var(--accent-blue)' },
@@ -1880,6 +2266,9 @@ car_monitor_require_access(false);
let wakeLockWanted = false;
let latestDashboardData = null;
let latestDashboardMeta = null;
let latestUsageData = null;
let calibrationModalOpen = false;
let calibrationSaveInFlight = false;
const FALLBACK_BACK_TARGET = 'https://chaegeon.com/';
const BACK_LINK_PATH_TITLE = {
'/': '채건닷컴',
@@ -1892,6 +2281,7 @@ car_monitor_require_access(false);
initLanguage();
initTheme();
initWakeLock();
initCalibrationSettings();
initChart();
fetchData();
refreshTimer = setInterval(fetchData, 1000);
@@ -2299,6 +2689,7 @@ car_monitor_require_access(false);
function updateUsage(usage) {
if (!usage) return;
latestUsageData = usage;
const total = Number(usage.total_bytes || 0);
const billingTotal = Number(usage.billing_estimated_current_bytes ?? total);
@@ -2328,6 +2719,191 @@ car_monitor_require_access(false);
fill.style.width = percent.toFixed(2) + '%';
fill.classList.toggle('warning', overBytes > 0 || projectedOverBytes > 0 || percent >= 85);
if (calibrationModalOpen) {
renderCalibrationSummary(usage);
}
}
function initCalibrationSettings() {
const btn = document.getElementById('settings-btn');
if (!btn || !CAR_MONITOR_SESSION?.admin) return;
btn.classList.remove('d-none');
btn.addEventListener('click', openCalibrationModal);
document.getElementById('calibration-close-btn')?.addEventListener('click', closeCalibrationModal);
document.getElementById('calibration-cancel-btn')?.addEventListener('click', closeCalibrationModal);
document.getElementById('calibration-save-btn')?.addEventListener('click', saveCalibration);
['calibration-total-usage', 'calibration-unit'].forEach(id => {
document.getElementById(id)?.addEventListener('input', updateCalibrationPreview);
document.getElementById(id)?.addEventListener('change', updateCalibrationPreview);
});
}
function openCalibrationModal() {
const overlay = document.getElementById('calibration-overlay');
if (!overlay) return;
calibrationModalOpen = true;
overlay.hidden = false;
document.body.style.overflow = 'hidden';
const anchorInput = document.getElementById('calibration-anchor-ts');
if (anchorInput && !anchorInput.value) {
anchorInput.value = formatDateTimeLocal(new Date());
}
setCalibrationMessage('T world 사용량을 입력하면 현재 월 기준점으로 저장합니다.', 'muted');
renderCalibrationSummary(latestUsageData);
fetchCalibrationState();
setTimeout(() => document.getElementById('calibration-total-usage')?.focus(), 80);
}
function closeCalibrationModal() {
const overlay = document.getElementById('calibration-overlay');
if (!overlay) return;
calibrationModalOpen = false;
overlay.hidden = true;
document.body.style.overflow = '';
}
function fetchCalibrationState() {
fetch('?mode=usage-calibration', { cache: 'no-store' })
.then(res => res.json())
.then(payload => {
if (payload.status !== 'success') {
setCalibrationMessage(payload.message || '기준점 조회 실패', 'danger');
return;
}
if (payload.usage) {
latestUsageData = payload.usage;
updateUsage(payload.usage);
}
renderCalibrationHistory(payload.calibrations || []);
})
.catch(() => setCalibrationMessage('기준점 조회 실패', 'danger'));
}
function saveCalibration() {
if (calibrationSaveInFlight) return;
const totalInput = document.getElementById('calibration-total-usage');
const unitInput = document.getElementById('calibration-unit');
const anchorInput = document.getElementById('calibration-anchor-ts');
const noteInput = document.getElementById('calibration-note');
const totalUsage = totalInput?.value?.trim() || '';
if (!totalUsage) {
setCalibrationMessage('T world 사용량을 입력하세요.', 'danger');
totalInput?.focus();
return;
}
calibrationSaveInFlight = true;
const saveBtn = document.getElementById('calibration-save-btn');
if (saveBtn) saveBtn.disabled = true;
setCalibrationMessage('저장 중입니다.', 'muted');
fetch('?mode=usage-calibration', {
method: 'POST',
cache: 'no-store',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
total_usage: totalUsage,
unit: unitInput?.value || 'KB',
anchor_ts: anchorInput?.value || '',
note: noteInput?.value || '',
}),
})
.then(res => res.json())
.then(payload => {
if (payload.status !== 'success') {
setCalibrationMessage(payload.message || '저장 실패', 'danger');
return;
}
if (payload.usage) {
latestUsageData = payload.usage;
updateUsage(payload.usage);
}
renderCalibrationHistory(payload.calibrations || []);
const saved = payload.saved || {};
setCalibrationMessage(`저장 완료: ${saved.month || '-'} 배율 ${Number(saved.scale || 0).toFixed(6)}`, 'success');
fetchUsage();
})
.catch(() => setCalibrationMessage('저장 실패', 'danger'))
.finally(() => {
calibrationSaveInFlight = false;
if (saveBtn) saveBtn.disabled = false;
});
}
function renderCalibrationSummary(usage) {
const setText = (id, value) => {
const el = document.getElementById(id);
if (el) el.textContent = value;
};
if (!usage) {
setText('calibration-month', '-');
setText('calibration-meter', '-');
setText('calibration-current-scale', '-');
setText('calibration-source', '-');
updateCalibrationPreview();
return;
}
setText('calibration-month', usage.month || '-');
setText('calibration-meter', formatPreciseMb(Number(usage.carrier_adjusted_total_bytes || 0)));
setText('calibration-current-scale', Number(usage.billing_meter_scale || 1).toFixed(6));
const sourceMonth = usage.billing_meter_scale_source_month || '-';
const source = usage.billing_meter_scale_source === 'current_month_anchor'
? '현재월'
: (usage.billing_meter_scale_source === 'previous_month_anchor' ? '직전월' : '로컬');
setText('calibration-source', `${source} ${sourceMonth}`);
updateCalibrationPreview();
}
function renderCalibrationHistory(rows) {
const box = document.getElementById('calibration-history');
if (!box) return;
if (!rows.length) {
box.innerHTML = '<div class="calibration-history-row text-muted"><span>저장된 기준점 없음</span></div>';
return;
}
box.innerHTML = rows.map(row => {
const scale = Number(row.scale || 0);
return `
<div class="calibration-history-row">
<span class="font-mono">${escapeHtml(row.month || '-')}</span>
<span>${escapeHtml(String(row.anchor_ts || '-').replace(' ', ' '))}<br><span class="text-muted">${formatPreciseMb(Number(row.total_bytes || 0))} / ${formatPreciseMb(Number(row.meter_adjusted_bytes || 0))}</span></span>
<span class="font-mono text-end">${scale ? scale.toFixed(6) : '-'}</span>
</div>`;
}).join('');
}
function updateCalibrationPreview() {
const amount = parseFloat(String(document.getElementById('calibration-total-usage')?.value || '').replace(/,/g, ''));
const unit = document.getElementById('calibration-unit')?.value || 'KB';
const meterBytes = Number(latestUsageData?.carrier_adjusted_total_bytes || 0);
let bytes = 0;
if (Number.isFinite(amount) && amount >= 0) {
if (unit === 'MB') bytes = amount * 1024 * 1024;
else if (unit === 'KB') bytes = amount * 1024;
else bytes = amount;
}
const preview = bytes > 0 && meterBytes > 0 ? bytes / meterBytes : 0;
const el = document.getElementById('calibration-preview-scale');
if (el) el.textContent = preview ? preview.toFixed(6) : '-';
}
function setCalibrationMessage(message, type = 'muted') {
const el = document.getElementById('calibration-message');
if (!el) return;
el.textContent = message;
el.className = `calibration-message text-${type}`;
}
function formatDateTimeLocal(date) {
const pad = value => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
function updateRemote(data) {
@@ -2582,6 +3158,72 @@ car_monitor_require_access(false);
});
</script>
<div id="calibration-overlay" class="custom-modal-overlay" hidden>
<div class="custom-modal-panel" role="dialog" aria-modal="true" aria-labelledby="calibration-title">
<div class="custom-modal-header">
<div>
<div id="calibration-title" class="fw-bold">데이터 사용량 기준점</div>
<div class="small text-muted">T world 사용량 숫자로 월별 보정 배율을 저장합니다.</div>
</div>
<button type="button" class="btn-icon-toggle" id="calibration-close-btn" title="닫기">
<i class="fas fa-xmark"></i>
</button>
</div>
<div class="custom-modal-body">
<div class="calibration-grid">
<div class="calibration-field">
<label for="calibration-total-usage">T world 사용량</label>
<input id="calibration-total-usage" name="calibration_total_usage" type="number" inputmode="decimal" min="0" step="0.001" placeholder="예: 6396 또는 6.25" autocomplete="off">
</div>
<div class="calibration-field">
<label for="calibration-unit">단위</label>
<select id="calibration-unit" name="calibration_unit">
<option value="KB" selected>KB</option>
<option value="MB">MB</option>
<option value="B">B</option>
</select>
</div>
</div>
<div class="calibration-field">
<label for="calibration-anchor-ts">기준 시각</label>
<input id="calibration-anchor-ts" name="calibration_anchor_ts" type="datetime-local" step="1" autocomplete="off">
</div>
<div class="calibration-field">
<label for="calibration-note">메모</label>
<input id="calibration-note" name="calibration_note" type="text" maxlength="80" placeholder="예: T world 사용량 조회" autocomplete="off">
</div>
<div class="calibration-summary">
<div class="calibration-summary-item">
<div class="label-text mb-1">현재 월</div>
<div id="calibration-month" class="font-mono fw-bold">-</div>
</div>
<div class="calibration-summary-item">
<div class="label-text mb-1">로컬 통신사형 누적</div>
<div id="calibration-meter" class="font-mono fw-bold">-</div>
</div>
<div class="calibration-summary-item">
<div class="label-text mb-1">현재 배율</div>
<div id="calibration-current-scale" class="font-mono fw-bold">-</div>
</div>
<div class="calibration-summary-item">
<div class="label-text mb-1">입력 예상 배율</div>
<div id="calibration-preview-scale" class="font-mono fw-bold">-</div>
</div>
<div class="calibration-summary-item">
<div class="label-text mb-1">기준 출처</div>
<div id="calibration-source" class="font-mono fw-bold">-</div>
</div>
</div>
<div id="calibration-message" class="calibration-message text-muted"></div>
<div id="calibration-history" class="calibration-history"></div>
</div>
<div class="custom-modal-footer">
<button type="button" class="btn btn-light rounded-pill px-4" id="calibration-cancel-btn">닫기</button>
<button type="button" class="btn btn-primary rounded-pill px-4" id="calibration-save-btn">저장</button>
</div>
</div>
</div>
<div class="modal fade" id="rawModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content glass-card">