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

Merged
seonkyu.kim merged 1 commits from fix/SPMS-47-dashboard-device-bug-fix into develop 2026-03-18 01:49:18 +00:00
8 changed files with 81 additions and 20 deletions
Showing only changes of commit 32b48af34c - Show all commits

View File

@ -16,7 +16,7 @@ export default function PlatformBadge({ platform }: PlatformBadgeProps) {
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="material-symbols-outlined text-base">android</span>
<span className="material-symbols-outlined" style={{ fontSize: "16px", lineHeight: "1" }}>android</span>
</span>
);
}

View File

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

View File

@ -46,13 +46,38 @@ function mapCards(data: DashboardData) {
];
}
/** daily → WeeklyChart props 변환 */
/** daily → WeeklyChart props 변환 (최근 7일 기준, 없는 날짜는 0으로 채움) */
function mapChart(data: DashboardData) {
const trends = data.daily ?? [];
if (trends.length === 0) return [];
const allTrends = data.daily ?? [];
const maxVal = Math.max(...trends.map((t) => Math.max(t.send_count, t.success_count)), 1);
const todayStr = new Date().toISOString().slice(0, 10);
// 최근 7일 날짜 목록 생성 (오늘 포함)
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) => {
const dateStr = t.stat_date ?? "";

View File

@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import CopyButton from "@/components/common/CopyButton";
import { deleteDevice } from "@/api/device.api";
import { formatDate } from "@/utils/format";
import type { DeviceListItem } from "../types";
interface DeviceSlidePanelProps {
@ -68,7 +69,7 @@ export default function DeviceSlidePanel({
// 플랫폼 아이콘 렌더링
const renderPlatformIcon = () => {
if (!device) return null;
if (device.platform === "iOS") {
if (device.platform?.toLowerCase() === "ios") {
return (
<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">
@ -261,7 +262,7 @@ export default function DeviceSlidePanel({
<div>
<p className="text-[11px] text-gray-400 mb-0.5"></p>
<p className="text-sm font-medium text-[#0f172a]">
{device.created_at}
{formatDate(device.created_at ?? "")}
</p>
</div>
<div>
@ -269,7 +270,7 @@ export default function DeviceSlidePanel({
</p>
<p className="text-sm font-medium text-[#0f172a]">
{device.last_active_at}
{formatDate(device.last_active_at ?? "")}
</p>
</div>
<div>
@ -281,7 +282,7 @@ export default function DeviceSlidePanel({
<div>
<p className="text-[11px] text-gray-400 mb-0.5"></p>
<p className="text-sm font-medium text-[#0f172a]">
{device.platform}
{device.platform?.toLowerCase() === "ios" ? "iOS" : "Android"}
</p>
</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";
interface SecretToggleCellProps {
@ -18,7 +18,10 @@ export default function SecretToggleCell({
onToggle,
}: SecretToggleCellProps) {
const containerRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const isOpen = openKey === dropdownKey;
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
// 외부 클릭 시 닫힘
useEffect(() => {
@ -26,7 +29,9 @@ export default function SecretToggleCell({
const handleClick = (e: MouseEvent) => {
if (
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);
}
@ -35,6 +40,26 @@ export default function SecretToggleCell({
return () => document.removeEventListener("mousedown", handleClick);
}, [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 (
<div
ref={containerRef}
@ -42,8 +67,9 @@ export default function SecretToggleCell({
onClick={(e) => e.stopPropagation()}
>
<button
ref={buttonRef}
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}
<span
@ -54,8 +80,13 @@ export default function SecretToggleCell({
</span>
</button>
{isOpen && (
<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">
{/* fixed로 렌더링해서 테이블 overflow 밖으로 벗어남 */}
{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">
<code className="text-[11px] text-gray-600 font-mono break-all leading-relaxed flex-1">
{value}

View File

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

View File

@ -1,9 +1,12 @@
import { format, formatDistanceToNow } from "date-fns";
import { ko } from "date-fns/locale";
/** 날짜 포맷 (YYYY-MM-DD) */
/** 날짜 포맷 (YYYY-MM-DD). 서버 기본값(0001-01-01)이면 "-" 반환 */
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) */