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); }
.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); }
.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 { padding: var(--sp-5); }
@ -569,6 +569,17 @@ tbody tr:hover td { background: var(--surface-2); }
/* ── 예보 칩: 여유(safe) 상시 표시용 중립 스타일 ────────────────────────────── */
.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) ─────── */
.donut-ring circle.seg.cat-hot { stroke: var(--cat-hot); }
.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); }
.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; }
@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-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: var(--sp-4); align-items: start; }

View file

@ -3,79 +3,100 @@
// 사용 변수: load_global_data() 결과(extract) — $metric_statuses, $leak_statuses,
// $unrecovered_incidents, $forecasts, $sms_* , $today_* 등.
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-label"><b>실시간 상태</b> <span class="en">Live</span></div>
<!-- 온습도 센서 카드 -->
<h2 id="sensors-section">온습도 센서</h2>
<div class="sensor-grid">
<?php foreach ($metric_statuses as $metric_sid => $s):
// 미수신 placeholder: 기대 온습도 센서(METRIC_SENSOR_IDS)가 아직 보고 전이면
// '데이터 없음' 카드로 항상 노출한다(기대 센서가 화면에서 조용히 빠지지 않게).
<h2 id="sensors-section">센서 현황 <span class="h2-sub">· <?= count($sensor_cards) ?>개소</span></h2>
<div class="sensor-grid" id="leak-section">
<?php foreach ($sensor_cards as $sid => $card):
$type = $card['type'];
$s = $card['s'];
$type_label = $type === 'metric' ? '온습도' : '누수';
// 미수신 placeholder
if ($s === null): ?>
<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="metric-time">아직 수신된 온습도 데이터가 없습니다.</div>
<div class="metric-time">아직 수신된 <?= $type_label ?> 데이터가 없습니다.</div>
</div>
<?php continue; endif;
// 상태 우선순위: 오프라인 > 임계 경보 > 정상
// 상태 우선순위: 오프라인 > (온습도 경보 | 누수) > 정상
if ($type === 'metric') {
$status_code = $s['metric_status'] ?? 'normal';
if ($s['is_offline']) {
$card_class = 'offline';
$badge_class = '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';
$card_class = 'alert'; $badge_class = 'alert';
$badge_text = metric_status_label($status_code) . ' 경보!';
} else {
$card_class = '';
$badge_class = 'ok';
$badge_text = '정상';
$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 <?= $type === 'leak' ? 'leak-status-card ' : '' ?><?= $card_class ?>">
<div class="sc-head">
<strong><?= htmlspecialchars($s['sensor_name']) ?></strong>
<span class="sc-type <?= $type ?>"><?= $type_label ?></span>
</div>
<small><?= htmlspecialchars($s['device_location']) ?> / <?= htmlspecialchars($s['device_id']) ?></small>
<div class="badge-row">
<span class="badge <?= $badge_class ?>"><?= htmlspecialchars($badge_text) ?></span>
</div>
$temp_chip_class = '';
$hum_chip_class = '';
<?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'; }
?>
<div class="sensor-card <?= $card_class ?>">
<strong><?= htmlspecialchars($s['sensor_name']) ?></strong><br>
<small><?= htmlspecialchars($s['device_location']) ?> / <?= htmlspecialchars($s['device_id']) ?></small><br>
<div class="badge-row">
<span class="badge <?= $badge_class ?>">
<?= htmlspecialchars($badge_text) ?>
</span>
</div>
<?php if (!empty($s['latest_metric'])):
$metric = $s['latest_metric'];
$metric_temp = metric_display($metric['temperature_c'], '°C');
$metric_humidity = metric_display($metric['humidity_percent'], '%');
?>
if (!empty($s['latest_metric'])):
$metric = $s['latest_metric']; ?>
<div class="metric-line">
<span class="metric-chip <?= $temp_chip_class ?>">온도 <?= htmlspecialchars($metric_temp) ?></span>
<span class="metric-chip <?= $hum_chip_class ?>">습도 <?= htmlspecialchars($metric_humidity) ?></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_display($metric['humidity_percent'], '%')) ?></span>
</div>
<div class="metric-time">측정: <?= htmlspecialchars($metric['measured_at']) ?></div>
<?php else: ?>
<div class="metric-time">측정값 없음</div>
<?php endif; ?>
<div class="metric-time<?= $s['is_offline'] ? ' stale' : '' ?>">
최종확인: <?= htmlspecialchars($s['last_seen']) ?>
<?php if ($s['is_offline']): ?>
(<?= $s['offline_elapsed_min'] ?>분 전)
<?php endif; ?>
<div class="metric-time<?= $s['is_offline'] ? ' stale' : '' ?>">
최종확인: <?= htmlspecialchars($s['last_seen']) ?><?php if ($s['is_offline']): ?> (<?= $s['offline_elapsed_min'] ?>분 전)<?php endif; ?>
</div>
<?php
// 카드 예보줄(상시): 온라인 + 예보 있으면 임계 여유를 항상 표시한다.
// 초과(over)/근접(near)은 경고색, 여유(safe)도 숫자를 회색 칩으로 상시 노출
// ("정상"이 임계까지 얼마나 남았는지 즉시 파악 — 리뷰 반영).
$sid_fc = (int)$s['sensor_id'];
$fc = (!$s['is_offline'] && isset($forecasts[$sid_fc])) ? $forecasts[$sid_fc] : null;
// 온습도 카드 예보줄: 온라인 + 예보 있으면 임계 여유 상시 표시(over>near>safe)
if ($type === 'metric'):
$fc = (!$s['is_offline'] && isset($forecasts[$sid])) ? $forecasts[$sid] : null;
if ($fc):
$chips = [];
foreach ($fc['headroom'] as $h) {
@ -91,7 +112,6 @@ if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
if (!empty($fc['dew']['risk'])) {
$chips[] = ['state' => 'near', 'text' => '결로주의 (마진 ' . $fc['dew']['margin'] . '℃)'];
}
// 위험한 것 먼저: 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));
?>
@ -102,61 +122,14 @@ if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
<span class="forecast-note">예보: 임계 여유 충분</span>
<?php endif; ?>
</div>
<?php endif; ?>
<?php endif; endif; ?>
</div>
<?php endforeach; ?>
<?php if (empty($metric_statuses)): ?>
<?php if (empty($sensor_cards)): ?>
<p class="empty-note">아직 수신된 센서 데이터가 없습니다.</p>
<?php endif; ?>
</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)): ?>
<!-- 미복구 누수 사고 (전체 이력·사진은 '이력 조회' 탭) -->
<h2>미복구 누수 사고 <span class="h2-sub">· <?= count($unrecovered_incidents) ?>건</span></h2>