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:
parent
2bc3fe87c8
commit
32b48af34c
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 ?? "";
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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) */
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user