fix(dashboard): 센서 카드 정렬 — 유형 통합 그리드로 sparse/줄 안맞음 해소

실제 렌더(MockPDO 하네스 + 브라우저 스크린샷)로 확인한 레이아웃 결함 수정:

- 온습도/누수 센서를 별도 섹션(각 1~2개 카드)으로 나눠 auto-fill 그리드에
  띄우던 것을 단일 '센서 현황' 그리드로 통합. sensor_id 순, 유형은 카드 우상단
  칩(온습도/누수)으로 구분. auto-fill→auto-fit 로 바꿔 카드가 행을 균등하게
  채우고 높이가 정렬됨(센서 1~2개여도 '덩그러니 빈 행' 사라짐).
- 추이·통계 위젯: '경보 최다 센서' 리더보드를 span 12(가로 리더보드)로 바꿔
  3타일 다음 행에 타일 하나만 남던 문제 해소.
- 센서 카드에 sc-head/sc-type 스타일 추가, 예보줄을 카드 하단 고정(margin-top:auto).

app.css 는 기존 규칙 유지 + 추가/조정만(공용 7페이지 영향 없음: .sensor-grid·
.trend-widgets·.sc-* 는 대시보드 전용 셀렉터).
This commit is contained in:
cw2007 2026-07-11 11:52:31 +09:00
parent f063060e2d
commit 83f7dae6e8
2 changed files with 116 additions and 122 deletions

View file

@ -87,7 +87,7 @@ header h1::before { content: ""; width: 10px; height: 10px; border-radius: 50%;
.ops-grid { display: grid; grid-template-columns: minmax(0,1.5fr) minmax(300px,.9fr); gap: var(--sp-4); align-items: start; margin-bottom: var(--sp-6); } .ops-grid { display: grid; grid-template-columns: minmax(0,1.5fr) minmax(300px,.9fr); gap: var(--sp-4); align-items: start; margin-bottom: var(--sp-6); }
.cards { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: var(--sp-4); margin-bottom: var(--sp-4); } .cards { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: var(--sp-4); margin-bottom: var(--sp-4); }
.cards.flush { margin-bottom: var(--sp-6); } .cards.flush { margin-bottom: var(--sp-6); }
.sensor-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px,1fr)); gap: var(--sp-4); margin-bottom: var(--sp-6); } .sensor-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px,1fr)); gap: var(--sp-4); margin-bottom: var(--sp-6); }
.panel, .card, .chart-panel, .leak-status-card, .empty-note { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg); box-shadow: var(--shadow); } .panel, .card, .chart-panel, .leak-status-card, .empty-note { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg); box-shadow: var(--shadow); }
.panel { padding: var(--sp-5); } .panel { padding: var(--sp-5); }
@ -569,6 +569,17 @@ tbody tr:hover td { background: var(--surface-2); }
/* ── 예보 칩: 여유(safe) 상시 표시용 중립 스타일 ────────────────────────────── */ /* ── 예보 칩: 여유(safe) 상시 표시용 중립 스타일 ────────────────────────────── */
.forecast-chip.safe { background: var(--surface-2); color: var(--ink-2); } .forecast-chip.safe { background: var(--surface-2); color: var(--ink-2); }
/* ── 통합 센서 카드: 헤더(이름+유형칩) / 카드 내부 세로 리듬 정렬 ─────────────── */
.sensor-card { display: flex; flex-direction: column; }
.sc-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); }
.sc-head strong { font-size: 1rem; font-weight: 700; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sc-type { flex: none; font-size: .68rem; font-weight: 800; letter-spacing: .03em; padding: 2px 8px; border-radius: var(--r-pill); background: var(--surface-2); color: var(--ink-2); border: 1px solid var(--line); }
.sc-type.metric { background: rgba(63,199,230,.13); color: var(--cold-ink); }
.sc-type.leak { background: var(--offline-bg); color: var(--offline-ink); }
.sensor-card small { display: block; margin-top: 2px; }
/* 예보줄을 카드 하단에 고정(카드 높이가 그리드 행 기준으로 늘어나도 자연스럽게) */
.sensor-card .forecast { margin-top: auto; }
/* ── 범주형 차트 클래스 (도넛 세그먼트 stroke / 범례 스와치 background) ─────── */ /* ── 범주형 차트 클래스 (도넛 세그먼트 stroke / 범례 스와치 background) ─────── */
.donut-ring circle.seg.cat-hot { stroke: var(--cat-hot); } .donut-ring circle.seg.cat-hot { stroke: var(--cat-hot); }
.donut-ring circle.seg.cat-humid { stroke: var(--cat-humid); } .donut-ring circle.seg.cat-humid { stroke: var(--cat-humid); }
@ -582,9 +593,19 @@ tbody tr:hover td { background: var(--surface-2); }
.dl i.cat-dry { background: var(--cat-dry); } .dl i.cat-dry { background: var(--cat-dry); }
.donut-note { margin-top: var(--sp-2); } .donut-note { margin-top: var(--sp-2); }
/* ── trend 탭 위젯 그리드 (기존 .overview 12열 위에서 3타일/행 균등 배치) ────── */ /* ── trend 탭 위젯 그리드 (기존 .overview 12열 위에서 배치) ──────────────────
1행: 경보 추이·도넛·게이지 각 span 4 (=12). 2행: 리더보드 span 12 (가로 리더보드
— 타일 하나만 다음 행에 덩그러니 남는 것 방지). */
.trend-widgets > .ov-tile { grid-column: span 4; } .trend-widgets > .ov-tile { grid-column: span 4; }
@media (max-width: 1080px) { .trend-widgets > .ov-tile { grid-column: span 6; } } .trend-widgets > .ov-board { grid-column: span 12; }
.trend-widgets > .ov-board .board { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
@media (max-width: 1080px) {
.trend-widgets > .ov-tile { grid-column: span 6; }
.trend-widgets > .ov-board { grid-column: span 12; }
}
@media (max-width: 620px) {
.trend-widgets > .ov-tile, .trend-widgets > .ov-board { grid-column: span 12; }
}
/* ── system 탭 패널 그리드 ──────────────────────────────────────────────────── */ /* ── system 탭 패널 그리드 ──────────────────────────────────────────────────── */
.system-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: var(--sp-4); align-items: start; } .system-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: var(--sp-4); align-items: start; }

View file

@ -3,97 +3,117 @@
// 사용 변수: load_global_data() 결과(extract) — $metric_statuses, $leak_statuses, // 사용 변수: load_global_data() 결과(extract) — $metric_statuses, $leak_statuses,
// $unrecovered_incidents, $forecasts, $sms_* , $today_* 등. // $unrecovered_incidents, $forecasts, $sms_* , $today_* 등.
if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단 if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
// 온습도·누수 센서를 하나의 그리드로 통합한다(센서 수가 적어도 한 행을 균등하게
// 채우도록 — 섹션마다 카드가 1개씩 흩어지는 것 방지). sensor_id 순서, 유형은 칩으로 구분.
$sensor_cards = [];
foreach ($metric_statuses as $sid => $s) { $sensor_cards[(int)$sid] = ['type' => 'metric', 's' => $s]; }
foreach ($leak_statuses as $sid => $s) {
if (!isset($sensor_cards[(int)$sid])) { $sensor_cards[(int)$sid] = ['type' => 'leak', 's' => $s]; }
}
ksort($sensor_cards);
?> ?>
<div class="zone" id="tab-live"> <div class="zone" id="tab-live">
<div class="zone-label"><b>실시간 상태</b> <span class="en">Live</span></div> <div class="zone-label"><b>실시간 상태</b> <span class="en">Live</span></div>
<!-- 온습도 센서 카드 --> <h2 id="sensors-section">센서 현황 <span class="h2-sub">· <?= count($sensor_cards) ?>개소</span></h2>
<h2 id="sensors-section">온습도 센서</h2> <div class="sensor-grid" id="leak-section">
<div class="sensor-grid"> <?php foreach ($sensor_cards as $sid => $card):
<?php foreach ($metric_statuses as $metric_sid => $s): $type = $card['type'];
// 미수신 placeholder: 기대 온습도 센서(METRIC_SENSOR_IDS)가 아직 보고 전이면 $s = $card['s'];
// '데이터 없음' 카드로 항상 노출한다(기대 센서가 화면에서 조용히 빠지지 않게). $type_label = $type === 'metric' ? '온습도' : '누수';
// 미수신 placeholder
if ($s === null): ?> if ($s === null): ?>
<div class="sensor-card"> <div class="sensor-card">
<strong><?= (int)$metric_sid ?>번 센서 (온습도)</strong><br> <div class="sc-head">
<strong><?= (int)$sid ?>번 센서</strong>
<span class="sc-type <?= $type ?>"><?= $type_label ?></span>
</div>
<div class="badge-row"><span class="badge offline">데이터 없음</span></div> <div class="badge-row"><span class="badge offline">데이터 없음</span></div>
<div class="metric-time">아직 수신된 온습도 데이터가 없습니다.</div> <div class="metric-time">아직 수신된 <?= $type_label ?> 데이터가 없습니다.</div>
</div> </div>
<?php continue; endif; <?php continue; endif;
// 상태 우선순위: 오프라인 > 임계 경보 > 정상
$status_code = $s['metric_status'] ?? 'normal';
if ($s['is_offline']) {
$card_class = 'offline';
$badge_class = 'offline';
$badge_text = $s['is_alerting'] ? ('오프라인 (' . metric_status_label($status_code) . ' 중 끊김)') : '오프라인';
} elseif ($s['is_alerting']) {
$card_class = 'alert';
$badge_class = 'alert';
$badge_text = metric_status_label($status_code) . ' 경보!';
} else {
$card_class = '';
$badge_class = 'ok';
$badge_text = '정상';
}
$temp_chip_class = ''; // 상태 우선순위: 오프라인 > (온습도 경보 | 누수) > 정상
$hum_chip_class = ''; if ($type === 'metric') {
if ($status_code === 'high_temp') { $temp_chip_class = 'hot'; } $status_code = $s['metric_status'] ?? 'normal';
elseif ($status_code === 'low_temp') { $temp_chip_class = 'cold'; } if ($s['is_offline']) {
elseif ($status_code === 'high_humidity') { $hum_chip_class = 'humid'; } $card_class = 'offline'; $badge_class = 'offline';
elseif ($status_code === 'low_humidity') { $hum_chip_class = 'dry'; } $badge_text = $s['is_alerting'] ? ('오프라인 (' . metric_status_label($status_code) . ' 중 끊김)') : '오프라인';
} elseif ($s['is_alerting']) {
$card_class = 'alert'; $badge_class = 'alert';
$badge_text = metric_status_label($status_code) . ' 경보!';
} else {
$card_class = ''; $badge_class = 'ok'; $badge_text = '정상';
}
} else {
$is_leaking = (int)($s['is_leak'] ?? 0) === 1;
if ($s['is_offline']) {
$card_class = 'offline'; $badge_class = 'offline';
$badge_text = $is_leaking ? '오프라인 (누수 중 끊김)' : '오프라인';
} elseif ($is_leaking) {
$card_class = 'leak'; $badge_class = 'leak'; $badge_text = '누수';
} else {
$card_class = ''; $badge_class = 'ok'; $badge_text = '정상';
}
}
?> ?>
<div class="sensor-card <?= $card_class ?>"> <div class="sensor-card <?= $type === 'leak' ? 'leak-status-card ' : '' ?><?= $card_class ?>">
<strong><?= htmlspecialchars($s['sensor_name']) ?></strong><br> <div class="sc-head">
<small><?= htmlspecialchars($s['device_location']) ?> / <?= htmlspecialchars($s['device_id']) ?></small><br> <strong><?= htmlspecialchars($s['sensor_name']) ?></strong>
<div class="badge-row"> <span class="sc-type <?= $type ?>"><?= $type_label ?></span>
<span class="badge <?= $badge_class ?>">
<?= htmlspecialchars($badge_text) ?>
</span>
</div> </div>
<?php if (!empty($s['latest_metric'])): <small><?= htmlspecialchars($s['device_location']) ?> / <?= htmlspecialchars($s['device_id']) ?></small>
$metric = $s['latest_metric']; <div class="badge-row">
$metric_temp = metric_display($metric['temperature_c'], '°C'); <span class="badge <?= $badge_class ?>"><?= htmlspecialchars($badge_text) ?></span>
$metric_humidity = metric_display($metric['humidity_percent'], '%'); </div>
?>
<?php if ($type === 'metric'):
$status_code = $s['metric_status'] ?? 'normal';
$temp_chip_class = ''; $hum_chip_class = '';
if ($status_code === 'high_temp') { $temp_chip_class = 'hot'; }
elseif ($status_code === 'low_temp') { $temp_chip_class = 'cold'; }
elseif ($status_code === 'high_humidity') { $hum_chip_class = 'humid'; }
elseif ($status_code === 'low_humidity') { $hum_chip_class = 'dry'; }
if (!empty($s['latest_metric'])):
$metric = $s['latest_metric']; ?>
<div class="metric-line"> <div class="metric-line">
<span class="metric-chip <?= $temp_chip_class ?>">온도 <?= htmlspecialchars($metric_temp) ?></span> <span class="metric-chip <?= $temp_chip_class ?>">온도 <?= htmlspecialchars(metric_display($metric['temperature_c'], '°C')) ?></span>
<span class="metric-chip <?= $hum_chip_class ?>">습도 <?= htmlspecialchars($metric_humidity) ?></span> <span class="metric-chip <?= $hum_chip_class ?>">습도 <?= htmlspecialchars(metric_display($metric['humidity_percent'], '%')) ?></span>
</div> </div>
<div class="metric-time">측정: <?= htmlspecialchars($metric['measured_at']) ?></div> <div class="metric-time">측정: <?= htmlspecialchars($metric['measured_at']) ?></div>
<?php else: ?> <?php else: ?>
<div class="metric-time">측정값 없음</div> <div class="metric-time">측정값 없음</div>
<?php endif; ?> <?php endif; ?>
<?php endif; ?>
<div class="metric-time<?= $s['is_offline'] ? ' stale' : '' ?>"> <div class="metric-time<?= $s['is_offline'] ? ' stale' : '' ?>">
최종확인: <?= htmlspecialchars($s['last_seen']) ?> 최종확인: <?= htmlspecialchars($s['last_seen']) ?><?php if ($s['is_offline']): ?> (<?= $s['offline_elapsed_min'] ?>분 전)<?php endif; ?>
<?php if ($s['is_offline']): ?>
(<?= $s['offline_elapsed_min'] ?>분 전)
<?php endif; ?>
</div> </div>
<?php <?php
// 카드 예보줄(상시): 온라인 + 예보 있으면 임계 여유를 항상 표시한다. // 온습도 카드 예보줄: 온라인 + 예보 있으면 임계 여유 상시 표시(over>near>safe)
// 초과(over)/근접(near)은 경고색, 여유(safe)도 숫자를 회색 칩으로 상시 노출 if ($type === 'metric'):
// ("정상"이 임계까지 얼마나 남았는지 즉시 파악 — 리뷰 반영). $fc = (!$s['is_offline'] && isset($forecasts[$sid])) ? $forecasts[$sid] : null;
$sid_fc = (int)$s['sensor_id']; if ($fc):
$fc = (!$s['is_offline'] && isset($forecasts[$sid_fc])) ? $forecasts[$sid_fc] : null; $chips = [];
if ($fc): foreach ($fc['headroom'] as $h) {
$chips = []; $chips[] = ['state' => $h['state'], 'text' => $h['label'] . ' 여유 ' . $h['margin'] . $h['unit']];
foreach ($fc['headroom'] as $h) { }
$chips[] = ['state' => $h['state'], 'text' => $h['label'] . ' 여유 ' . $h['margin'] . $h['unit']]; foreach (['temp', 'hum'] as $vk) {
} $e = $fc['trend'][$vk]['eta'] ?? null;
foreach (['temp', 'hum'] as $vk) { if ($e) { $chips[] = ['state' => 'near', 'text' => '↗ ~' . (int)$e['min'] . '분 뒤 ' . metric_status_label($e['key'])]; }
$e = $fc['trend'][$vk]['eta'] ?? null; }
if ($e) { $chips[] = ['state' => 'near', 'text' => '↗ ~' . (int)$e['min'] . '분 뒤 ' . metric_status_label($e['key'])]; } foreach ($fc['rate'] as $r) {
} $chips[] = ['state' => 'near', 'text' => ($r['spike'] === 'rise' ? '급상승 ' : '급락 ') . ($r['per10'] > 0 ? '+' : '') . $r['per10'] . '/10분'];
foreach ($fc['rate'] as $r) { }
$chips[] = ['state' => 'near', 'text' => ($r['spike'] === 'rise' ? '급상승 ' : '급락 ') . ($r['per10'] > 0 ? '+' : '') . $r['per10'] . '/10분']; if (!empty($fc['dew']['risk'])) {
} $chips[] = ['state' => 'near', 'text' => '결로주의 (마진 ' . $fc['dew']['margin'] . '℃)'];
if (!empty($fc['dew']['risk'])) { }
$chips[] = ['state' => 'near', 'text' => '결로주의 (마진 ' . $fc['dew']['margin'] . '℃)']; $chip_rank = ['over' => 0, 'near' => 1, 'safe' => 2];
} usort($chips, fn($a, $b) => ($chip_rank[$a['state']] ?? 3) <=> ($chip_rank[$b['state']] ?? 3));
// 위험한 것 먼저: over > near > safe
$chip_rank = ['over' => 0, 'near' => 1, 'safe' => 2];
usort($chips, fn($a, $b) => ($chip_rank[$a['state']] ?? 3) <=> ($chip_rank[$b['state']] ?? 3));
?> ?>
<div class="forecast"> <div class="forecast">
<?php if ($chips): foreach ($chips as $c): ?> <?php if ($chips): foreach ($chips as $c): ?>
@ -102,61 +122,14 @@ if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
<span class="forecast-note">예보: 임계 여유 충분</span> <span class="forecast-note">예보: 임계 여유 충분</span>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php endif; ?> <?php endif; endif; ?>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($metric_statuses)): ?> <?php if (empty($sensor_cards)): ?>
<p class="empty-note">아직 수신된 센서 데이터가 없습니다.</p> <p class="empty-note">아직 수신된 센서 데이터가 없습니다.</p>
<?php endif; ?> <?php endif; ?>
</div> </div>
<!-- 누수 감지 카드 -->
<h2 id="leak-section">누수 감지</h2>
<div class="sensor-grid">
<?php foreach ($leak_statuses as $leak_sid => $leak_status):
// 누수 센서 상태 표시: 오프라인 > 누수 > 정상 우선순위
if ($leak_status === null) {
$leak_card_class = '';
$leak_badge_class = 'offline';
$leak_badge_text = '데이터 없음';
} elseif ($leak_status['is_offline']) {
$leak_card_class = 'offline';
$leak_badge_class = 'offline';
$leak_badge_text = ((int)($leak_status['is_leak'] ?? 0) === 1) ? '오프라인 (누수 중 끊김)' : '오프라인';
} elseif ((int)($leak_status['is_leak'] ?? 0) === 1) {
$leak_card_class = 'leak';
$leak_badge_class = 'leak';
$leak_badge_text = '누수';
} else {
$leak_card_class = '';
$leak_badge_class = 'ok';
$leak_badge_text = '정상';
}
?>
<div class="sensor-card leak-status-card <?= $leak_card_class ?>">
<?php if ($leak_status === null): ?>
<strong><?= (int)$leak_sid ?>번 센서 (누수)</strong><br>
<div class="badge-row">
<span class="badge <?= $leak_badge_class ?>"><?= htmlspecialchars($leak_badge_text) ?></span>
</div>
<div class="metric-time">아직 수신된 누수 센서 데이터가 없습니다.</div>
<?php else: ?>
<strong><?= htmlspecialchars($leak_status['sensor_name']) ?></strong><br>
<small><?= htmlspecialchars($leak_status['device_location']) ?> / <?= htmlspecialchars($leak_status['device_id']) ?></small><br>
<div class="badge-row">
<span class="badge <?= $leak_badge_class ?>"><?= htmlspecialchars($leak_badge_text) ?></span>
</div>
<div class="metric-time<?= $leak_status['is_offline'] ? ' stale' : '' ?>">
최종확인: <?= htmlspecialchars($leak_status['last_seen']) ?>
<?php if ($leak_status['is_offline']): ?>
(<?= $leak_status['offline_elapsed_min'] ?>분 전)
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php if (!empty($unrecovered_incidents)): ?> <?php if (!empty($unrecovered_incidents)): ?>
<!-- 미복구 누수 사고 (전체 이력·사진은 '이력 조회' 탭) --> <!-- 미복구 누수 사고 (전체 이력·사진은 '이력 조회' 탭) -->
<h2>미복구 누수 사고 <span class="h2-sub">· <?= count($unrecovered_incidents) ?>건</span></h2> <h2>미복구 누수 사고 <span class="h2-sub">· <?= count($unrecovered_incidents) ?>건</span></h2>