fix: 대시보드 및 기기 관리 화면 버그 수정 (#47)

- StatusBadge: 매핑 실패 시 undefined variant → default 폴백 처리
- RecentMessages: STATUS_MAP 미등록 status 값 → default 폴백 처리
- DashboardPage: mapChart에서 최근 7일 날짜 항상 채우기 (빈 날짜 0으로)
- DeviceListPage/DeviceSlidePanel: 플랫폼 비교 toLowerCase() 처리
- DeviceSlidePanel: 플랫폼 텍스트 iOS/Android 정규화, 날짜 formatDate 적용
- PlatformBadge: Android 아이콘 lineHeight: 1 추가로 수직 정렬 수정
- formatDate: 서버 기본값(0001-01-01) → "-" 반환
- SecretToggleCell: position fixed로 테이블 overflow 탈출

Closes #47
This commit is contained in:
SEAN 2026-03-18 10:43:08 +09:00
parent 2bc3fe87c8
commit 32b48af34c
8 changed files with 81 additions and 20 deletions

View File

@ -16,7 +16,7 @@ export default function PlatformBadge({ platform }: PlatformBadgeProps) {
return ( return (
<span className="inline-flex items-center justify-center w-8 h-6 rounded text-xs font-medium bg-green-50 text-green-700 border border-green-200"> <span className="inline-flex items-center justify-center w-8 h-6 rounded text-xs font-medium bg-green-50 text-green-700 border border-green-200">
<span className="material-symbols-outlined text-base">android</span> <span className="material-symbols-outlined" style={{ fontSize: "16px", lineHeight: "1" }}>android</span>
</span> </span>
); );
} }

View File

@ -30,7 +30,8 @@ const VARIANT_STYLES: Record<StatusVariant, { badge: string; dot: string }> = {
/** 상태 dot 뱃지 (완료/실패/진행/예약/활성/비활성) */ /** 상태 dot 뱃지 (완료/실패/진행/예약/활성/비활성) */
export default function StatusBadge({ variant, label }: StatusBadgeProps) { export default function StatusBadge({ variant, label }: StatusBadgeProps) {
const style = VARIANT_STYLES[variant]; // variant가 유효하지 않으면 default로 폴백
const style = VARIANT_STYLES[variant] ?? VARIANT_STYLES.default;
return ( return (
<span <span

View File

@ -79,7 +79,7 @@ export default function RecentMessages({ messages = DEFAULT_MESSAGES, loading }:
<td className="px-6 py-3.5 text-center text-gray-600">{msg.targetCount}</td> <td className="px-6 py-3.5 text-center text-gray-600">{msg.targetCount}</td>
<td className="px-6 py-3.5 text-center"> <td className="px-6 py-3.5 text-center">
<StatusBadge <StatusBadge
variant={STATUS_MAP[msg.status]} variant={STATUS_MAP[msg.status] ?? "default"}
label={msg.status} label={msg.status}
/> />
</td> </td>

View File

@ -46,13 +46,38 @@ function mapCards(data: DashboardData) {
]; ];
} }
/** daily → WeeklyChart props 변환 */ /** daily → WeeklyChart props 변환 (최근 7일 기준, 없는 날짜는 0으로 채움) */
function mapChart(data: DashboardData) { function mapChart(data: DashboardData) {
const trends = data.daily ?? []; const allTrends = data.daily ?? [];
if (trends.length === 0) return [];
const maxVal = Math.max(...trends.map((t) => Math.max(t.send_count, t.success_count)), 1); // 최근 7일 날짜 목록 생성 (오늘 포함)
const todayStr = new Date().toISOString().slice(0, 10); const today = new Date();
const todayStr = today.toISOString().slice(0, 10);
const last7Days = Array.from({ length: 7 }, (_, i) => {
const d = new Date(today);
d.setDate(today.getDate() - 6 + i);
return d.toISOString().slice(0, 10);
});
const dataMap = new Map(allTrends.map((t) => [t.stat_date, t]));
// 없는 날짜는 send_count 0으로 채움
const trends = last7Days.map(
(date) =>
dataMap.get(date) ?? {
stat_date: date,
send_count: 0,
success_count: 0,
fail_count: 0,
open_count: 0,
ctr: 0,
},
);
const maxVal = Math.max(
...trends.map((t) => Math.max(t.send_count, t.success_count)),
1,
);
return trends.map((t) => { return trends.map((t) => {
const dateStr = t.stat_date ?? ""; const dateStr = t.stat_date ?? "";

View File

@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import CopyButton from "@/components/common/CopyButton"; import CopyButton from "@/components/common/CopyButton";
import { deleteDevice } from "@/api/device.api"; import { deleteDevice } from "@/api/device.api";
import { formatDate } from "@/utils/format";
import type { DeviceListItem } from "../types"; import type { DeviceListItem } from "../types";
interface DeviceSlidePanelProps { interface DeviceSlidePanelProps {
@ -68,7 +69,7 @@ export default function DeviceSlidePanel({
// 플랫폼 아이콘 렌더링 // 플랫폼 아이콘 렌더링
const renderPlatformIcon = () => { const renderPlatformIcon = () => {
if (!device) return null; if (!device) return null;
if (device.platform === "iOS") { if (device.platform?.toLowerCase() === "ios") {
return ( return (
<div className="size-10 rounded-lg bg-gray-100 flex items-center justify-center flex-shrink-0"> <div className="size-10 rounded-lg bg-gray-100 flex items-center justify-center flex-shrink-0">
<svg className="size-5 text-gray-700" viewBox="0 0 384 512" fill="currentColor"> <svg className="size-5 text-gray-700" viewBox="0 0 384 512" fill="currentColor">
@ -261,7 +262,7 @@ export default function DeviceSlidePanel({
<div> <div>
<p className="text-[11px] text-gray-400 mb-0.5"></p> <p className="text-[11px] text-gray-400 mb-0.5"></p>
<p className="text-sm font-medium text-[#0f172a]"> <p className="text-sm font-medium text-[#0f172a]">
{device.created_at} {formatDate(device.created_at ?? "")}
</p> </p>
</div> </div>
<div> <div>
@ -269,7 +270,7 @@ export default function DeviceSlidePanel({
</p> </p>
<p className="text-sm font-medium text-[#0f172a]"> <p className="text-sm font-medium text-[#0f172a]">
{device.last_active_at} {formatDate(device.last_active_at ?? "")}
</p> </p>
</div> </div>
<div> <div>
@ -281,7 +282,7 @@ export default function DeviceSlidePanel({
<div> <div>
<p className="text-[11px] text-gray-400 mb-0.5"></p> <p className="text-[11px] text-gray-400 mb-0.5"></p>
<p className="text-sm font-medium text-[#0f172a]"> <p className="text-sm font-medium text-[#0f172a]">
{device.platform} {device.platform?.toLowerCase() === "ios" ? "iOS" : "Android"}
</p> </p>
</div> </div>
</div> </div>

View File

@ -1,4 +1,4 @@
import { useRef, useEffect } from "react"; import { useRef, useEffect, useState } from "react";
import CopyButton from "@/components/common/CopyButton"; import CopyButton from "@/components/common/CopyButton";
interface SecretToggleCellProps { interface SecretToggleCellProps {
@ -18,7 +18,10 @@ export default function SecretToggleCell({
onToggle, onToggle,
}: SecretToggleCellProps) { }: SecretToggleCellProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const isOpen = openKey === dropdownKey; const isOpen = openKey === dropdownKey;
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
// 외부 클릭 시 닫힘 // 외부 클릭 시 닫힘
useEffect(() => { useEffect(() => {
@ -26,7 +29,9 @@ export default function SecretToggleCell({
const handleClick = (e: MouseEvent) => { const handleClick = (e: MouseEvent) => {
if ( if (
containerRef.current && containerRef.current &&
!containerRef.current.contains(e.target as Node) !containerRef.current.contains(e.target as Node) &&
popoverRef.current &&
!popoverRef.current.contains(e.target as Node)
) { ) {
onToggle(null); onToggle(null);
} }
@ -35,6 +40,26 @@ export default function SecretToggleCell({
return () => document.removeEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick);
}, [isOpen, onToggle]); }, [isOpen, onToggle]);
// 스크롤 시 닫기
useEffect(() => {
if (!isOpen) return;
const close = () => onToggle(null);
window.addEventListener("scroll", close, true);
return () => window.removeEventListener("scroll", close, true);
}, [isOpen, onToggle]);
const handleToggle = () => {
if (isOpen) {
onToggle(null);
} else {
const rect = buttonRef.current?.getBoundingClientRect();
if (rect) {
setPos({ top: rect.bottom + 4, left: rect.left + rect.width / 2 });
}
onToggle(dropdownKey);
}
};
return ( return (
<div <div
ref={containerRef} ref={containerRef}
@ -42,8 +67,9 @@ export default function SecretToggleCell({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<button <button
ref={buttonRef}
className="text-xs text-[#2563EB] hover:text-[#1d4ed8] font-medium transition-colors inline-flex items-center gap-1" className="text-xs text-[#2563EB] hover:text-[#1d4ed8] font-medium transition-colors inline-flex items-center gap-1"
onClick={() => onToggle(isOpen ? null : dropdownKey)} onClick={handleToggle}
> >
{label} {label}
<span <span
@ -54,8 +80,13 @@ export default function SecretToggleCell({
</span> </span>
</button> </button>
{isOpen && ( {/* fixed로 렌더링해서 테이블 overflow 밖으로 벗어남 */}
<div className="absolute left-1/2 -translate-x-1/2 top-full mt-1 w-[340px] bg-gray-50 border border-gray-200 rounded-lg p-3 shadow-lg z-10"> {isOpen && pos && (
<div
ref={popoverRef}
className="fixed w-[340px] bg-gray-50 border border-gray-200 rounded-lg p-3 shadow-lg z-[9999]"
style={{ top: pos.top, left: pos.left, transform: "translateX(-50%)" }}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<code className="text-[11px] text-gray-600 font-mono break-all leading-relaxed flex-1"> <code className="text-[11px] text-gray-600 font-mono break-all leading-relaxed flex-1">
{value} {value}

View File

@ -396,7 +396,7 @@ export default function DeviceListPage() {
<td className="px-6 py-4 text-center"> <td className="px-6 py-4 text-center">
<PlatformBadge <PlatformBadge
platform={ platform={
device.platform === "iOS" ? "ios" : "android" device.platform?.toLowerCase() === "ios" ? "ios" : "android"
} }
/> />
</td> </td>

View File

@ -1,9 +1,12 @@
import { format, formatDistanceToNow } from "date-fns"; import { format, formatDistanceToNow } from "date-fns";
import { ko } from "date-fns/locale"; import { ko } from "date-fns/locale";
/** 날짜 포맷 (YYYY-MM-DD) */ /** 날짜 포맷 (YYYY-MM-DD). 서버 기본값(0001-01-01)이면 "-" 반환 */
export function formatDate(date: string | Date): string { export function formatDate(date: string | Date): string {
return format(new Date(date), "yyyy-MM-dd"); if (!date) return "-";
const d = new Date(date);
if (d.getFullYear() <= 1) return "-";
return format(d, "yyyy-MM-dd");
} }
/** 날짜+시간 포맷 (YYYY-MM-DD HH:mm) */ /** 날짜+시간 포맷 (YYYY-MM-DD HH:mm) */