feat: 태그 관리 페이지 구현 (#25)

- TagCard 컴포넌트 구현 (태그 정보 표시, 수정/삭제 기능)
- TagAddModal 컴포넌트 구현 (태그 등록 폼)
- TagDeleteModal 컴포넌트 구현 (삭제 확인 다이얼로그)
- TagManagePage 구현 (서비스별 탭 필터링 + 9건 단위 페이지네이션)
- 서비스 탭 오버플로우 시 가로 스크롤 처리 (scrollbar-hide 유틸 추가)
- 목 데이터 10건 구성

Closes #25
This commit is contained in:
SEAN 2026-02-28 15:15:49 +09:00
parent bd71547cc2
commit 8b199803d6
6 changed files with 857 additions and 3 deletions

View File

@ -0,0 +1,290 @@
import { useState, useRef, useEffect } from "react";
import useShake from "@/hooks/useShake";
import { SERVICE_OPTIONS } from "../types";
interface TagAddModalProps {
open: boolean;
onClose: () => void;
onSave: (data: { name: string; service: string; description: string }) => void;
}
export default function TagAddModal({ open, onClose, onSave }: TagAddModalProps) {
const { triggerShake, cls } = useShake();
const [name, setName] = useState("");
const [service, setService] = useState("");
const [description, setDescription] = useState("");
const [serviceOpen, setServiceOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
// 에러 상태
const [nameError, setNameError] = useState(false);
const [serviceError, setServiceError] = useState(false);
const serviceRef = useRef<HTMLDivElement>(null);
// 외부 클릭 시 서비스 드롭다운 닫기
useEffect(() => {
function handleClick(e: MouseEvent) {
if (serviceRef.current && !serviceRef.current.contains(e.target as Node)) {
setServiceOpen(false);
}
}
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, []);
const resetForm = () => {
setName("");
setService("");
setDescription("");
setNameError(false);
setServiceError(false);
setServiceOpen(false);
setConfirmOpen(false);
};
const handleClose = () => {
resetForm();
onClose();
};
const handleSaveClick = () => {
const errors: string[] = [];
if (!name.trim()) {
setNameError(true);
errors.push("name");
}
if (!service) {
setServiceError(true);
errors.push("service");
}
if (errors.length > 0) {
triggerShake(errors);
return;
}
// 확인 모달 열기
setConfirmOpen(true);
};
const handleConfirmSave = () => {
onSave({ name: name.trim(), service, description: description.trim() });
resetForm();
onClose();
};
if (!open) return null;
return (
<>
{/* 태그 추가 모달 */}
<div
className="fixed inset-0 z-[60] flex items-center justify-center"
onClick={(e) => {
if (e.target === e.currentTarget) handleClose();
}}
>
<div className="absolute inset-0 bg-black/40" />
<div className="relative bg-white rounded-xl shadow-2xl w-full max-w-md mx-4 overflow-hidden">
{/* 헤더 */}
<div className="flex items-center gap-3 px-6 py-4 border-b border-gray-100">
<div className="size-9 rounded-lg bg-blue-100 flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-primary text-lg">
sell
</span>
</div>
<h3 className="text-base font-bold text-[#0f172a]"> </h3>
</div>
{/* 바디 */}
<div className="px-6 py-5 space-y-4">
{/* 태그명 */}
<div>
<label className="block text-xs font-medium text-gray-600 mb-1.5">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
if (e.target.value.trim()) setNameError(false);
}}
placeholder="태그 이름을 입력하세요"
className={`w-full h-[38px] px-3 border rounded-lg text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all ${
nameError
? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]"
: "border-gray-300"
} ${cls("name")}`}
/>
{nameError && (
<div className="flex items-center gap-1.5 text-red-600 text-xs mt-1.5">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
error
</span>
<span> .</span>
</div>
)}
</div>
{/* 서비스 선택 */}
<div>
<label className="block text-xs font-medium text-gray-600 mb-1.5">
<span className="text-red-500">*</span>
</label>
<div className="relative" ref={serviceRef}>
<button
type="button"
onClick={() => setServiceOpen((v) => !v)}
className={`w-full h-[38px] border rounded-lg px-3 text-sm text-left flex items-center justify-between bg-white hover:border-gray-400 transition-colors ${
serviceError
? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]"
: "border-gray-300"
} ${cls("service")}`}
>
<span className={service ? "text-[#0f172a]" : "text-gray-400"}>
{service || "서비스를 선택하세요"}
</span>
<span className="material-symbols-outlined text-gray-400 text-[18px]">
expand_more
</span>
</button>
{serviceOpen && (
<ul className="absolute left-0 top-full mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg z-50 py-1 overflow-hidden">
{SERVICE_OPTIONS.map((opt) => (
<li
key={opt}
onClick={() => {
setService(opt);
setServiceError(false);
setServiceOpen(false);
}}
className={`px-3 py-2 text-sm hover:bg-gray-50 cursor-pointer transition-colors ${
opt === service ? "text-primary font-medium" : ""
}`}
>
{opt}
</li>
))}
</ul>
)}
</div>
{serviceError && (
<div className="flex items-center gap-1.5 text-red-600 text-xs mt-1.5">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
error
</span>
<span> .</span>
</div>
)}
</div>
{/* 설명 */}
<div>
<label className="block text-xs font-medium text-gray-600 mb-1.5">
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="태그 설명을 입력하세요"
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-none focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all"
/>
</div>
{/* 안내 박스 */}
<div className="flex items-center gap-2 p-3 bg-blue-50 rounded-lg">
<span
className="material-symbols-outlined text-primary flex-shrink-0"
style={{ fontSize: "16px" }}
>
info
</span>
<p className="text-xs text-blue-700 leading-relaxed">
ID는 .
</p>
</div>
</div>
{/* 푸터 */}
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-gray-100">
<button
onClick={handleClose}
className="h-[38px] px-4 border border-gray-300 rounded-lg text-sm font-medium text-gray-600 hover:bg-gray-50 transition-colors"
>
</button>
<button
onClick={handleSaveClick}
className="h-[38px] px-5 bg-primary hover:bg-[#1d4ed8] text-white rounded-lg text-sm font-medium transition-colors"
>
</button>
</div>
</div>
</div>
{/* 저장 확인 모달 */}
{confirmOpen && (
<div
className="fixed inset-0 z-[70] flex items-center justify-center"
onClick={(e) => {
if (e.target === e.currentTarget) setConfirmOpen(false);
}}
>
<div className="absolute inset-0 bg-black/40" />
<div className="relative bg-white rounded-xl shadow-2xl max-w-md w-full p-6 border border-gray-200 mx-4">
<div className="flex items-center gap-3 mb-4">
<div className="size-10 rounded-full bg-amber-100 flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-amber-600 text-xl">
warning
</span>
</div>
<h3 className="text-lg font-bold text-[#0f172a]">
?
</h3>
</div>
<p className="text-sm text-[#0f172a] mb-2">
.
</p>
<div className="bg-amber-50 border border-amber-200 rounded-lg px-4 py-3 mb-5">
<div className="flex items-center gap-1.5 text-amber-700 text-xs font-medium">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
info
</span>
<span> .</span>
</div>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => setConfirmOpen(false)}
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition"
>
</button>
<button
onClick={handleConfirmSave}
className="bg-primary hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm shadow-primary/30 flex items-center gap-2"
>
<span className="material-symbols-outlined text-base">
check
</span>
<span></span>
</button>
</div>
</div>
</div>
)}
</>
);
}

View File

@ -0,0 +1,191 @@
import { useState, useRef, useEffect } from "react";
import { formatNumber } from "@/utils/format";
import type { Tag } from "../types";
interface TagCardProps {
tag: Tag;
onEdit: (id: number, description: string) => void;
onDelete: (tag: Tag) => void;
}
export default function TagCard({ tag, onEdit, onDelete }: TagCardProps) {
const [editing, setEditing] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [editDesc, setEditDesc] = useState(tag.description);
const menuRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// 외부 클릭 시 메뉴 닫기
useEffect(() => {
function handleClick(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false);
}
}
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, []);
// 수정 모드 진입 시 textarea 포커스
useEffect(() => {
if (editing && textareaRef.current) {
const el = textareaRef.current;
el.focus();
el.setSelectionRange(el.value.length, el.value.length);
}
}, [editing]);
const handleEditClick = () => {
setMenuOpen(false);
setEditDesc(tag.description);
setEditing(true);
};
const handleDeleteClick = () => {
setMenuOpen(false);
onDelete(tag);
};
const handleSave = () => {
onEdit(tag.id, editDesc);
setEditing(false);
};
const handleCancel = () => {
setEditDesc(tag.description);
setEditing(false);
};
return (
<div
className={`bg-white border rounded-xl shadow-sm flex flex-col h-full overflow-hidden transition-all ${
editing
? "border-primary shadow-[0_0_0_2px_rgba(37,99,235,0.15)]"
: "border-gray-200 hover:shadow-md"
}`}
>
{/* 상단 컬러 바 */}
<div className={`h-1 ${editing ? "bg-primary" : "bg-primary"}`} />
<div className="p-6 flex flex-col flex-1">
{/* 서비스 뱃지 + 케밥 메뉴 */}
<div className="flex items-start justify-between mb-3 gap-2">
<span className="text-xs font-medium text-gray-600 bg-gray-100 px-2.5 py-1 rounded-lg leading-snug">
{tag.service}
</span>
{!editing && (
<div className="relative" ref={menuRef}>
<button
onClick={(e) => {
e.stopPropagation();
setMenuOpen((v) => !v);
}}
className="size-7 flex items-center justify-center rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
>
<span className="material-symbols-outlined text-lg">
more_vert
</span>
</button>
{menuOpen && (
<div className="absolute right-0 top-full mt-1 w-32 bg-white border border-gray-200 rounded-lg shadow-lg z-10 py-1 overflow-hidden">
<button
onClick={handleEditClick}
className="w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 text-left flex items-center gap-2 transition-colors"
>
<span className="material-symbols-outlined text-base">
edit
</span>
</button>
<button
onClick={handleDeleteClick}
className="w-full px-3 py-2 text-sm text-red-600 hover:bg-red-50 text-left flex items-center gap-2 transition-colors"
>
<span className="material-symbols-outlined text-base">
delete
</span>
</button>
</div>
)}
</div>
)}
</div>
{/* 태그명 + ID */}
{editing ? (
<div className="flex items-center gap-2 mb-1">
<span
className="material-symbols-outlined text-gray-400"
style={{ fontSize: "14px" }}
>
lock
</span>
<span className="text-base font-bold text-gray-400">
{tag.name}
</span>
<span className="text-[10px] text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded">
</span>
</div>
) : (
<div className="flex items-center gap-2 mb-1">
<h3 className="text-base font-bold text-[#0f172a]">{tag.name}</h3>
<span className="text-xs font-medium text-gray-400">
#{tag.tagNumber}
</span>
</div>
)}
{/* 기기 수 */}
<div className="flex items-center gap-1 mb-3">
<span
className="material-symbols-outlined text-gray-400"
style={{ fontSize: "14px" }}
>
devices
</span>
<span className="text-xs text-[#64748b]">
{formatNumber(tag.deviceCount)}
</span>
</div>
{/* 설명 또는 수정 폼 */}
{editing ? (
<>
<div className="flex-grow flex flex-col">
<label className="block text-xs font-medium text-gray-600 mb-1.5">
</label>
<textarea
ref={textareaRef}
value={editDesc}
onChange={(e) => setEditDesc(e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-none focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all flex-grow"
/>
</div>
<div className="pt-4 mt-4 border-t border-gray-100 flex gap-2">
<button
onClick={handleCancel}
className="flex-1 h-[34px] text-sm font-medium text-gray-600 hover:bg-gray-100 rounded-lg transition-colors border border-gray-200"
>
</button>
<button
onClick={handleSave}
className="flex-1 h-[34px] text-sm font-medium text-white bg-primary hover:bg-[#1d4ed8] rounded-lg transition-colors"
>
</button>
</div>
</>
) : (
<p className="text-sm text-[#64748b] flex-grow leading-relaxed">
{tag.description}
</p>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,77 @@
interface TagDeleteModalProps {
open: boolean;
tagName: string;
onClose: () => void;
onConfirm: () => void;
}
export default function TagDeleteModal({
open,
tagName,
onClose,
onConfirm,
}: TagDeleteModalProps) {
if (!open) return null;
return (
<div
className="fixed inset-0 z-[70] flex items-center justify-center"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="absolute inset-0 bg-black/40" />
<div className="relative bg-white rounded-xl shadow-2xl max-w-md w-full p-6 border border-gray-200 mx-4">
<div className="flex items-center gap-3 mb-4">
<div className="size-10 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-red-600 text-xl">
delete_forever
</span>
</div>
<h3 className="text-lg font-bold text-[#0f172a]">
?
</h3>
</div>
<p className="text-sm text-[#0f172a] mb-2">
<strong className="text-red-600">{tagName}</strong>()
.
</p>
<div className="bg-red-50 border border-red-200 rounded-lg px-4 py-3 mb-5 space-y-2">
<div className="flex items-center gap-1.5 text-red-700 text-xs font-medium">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
error
</span>
<span> .</span>
</div>
<div className="flex items-center gap-1.5 text-red-700 text-xs font-medium">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
error
</span>
<span> .</span>
</div>
</div>
<div className="flex justify-end gap-3">
<button
onClick={onClose}
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition"
>
</button>
<button
onClick={onConfirm}
className="bg-red-600 hover:bg-red-700 text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm shadow-red-600/30 flex items-center gap-2"
>
<span className="material-symbols-outlined text-base">delete</span>
<span></span>
</button>
</div>
</div>
</div>
);
}

View File

@ -1,7 +1,181 @@
import { useState, useMemo } from "react";
import { toast } from "sonner";
import PageHeader from "@/components/common/PageHeader";
import EmptyState from "@/components/common/EmptyState";
import Pagination from "@/components/common/Pagination";
import TagCard from "../components/TagCard";
import TagAddModal from "../components/TagAddModal";
import TagDeleteModal from "../components/TagDeleteModal";
import { MOCK_TAGS } from "../types";
import type { Tag } from "../types";
export default function TagManagePage() {
// 태그 데이터 (로컬 state)
const [tags, setTags] = useState<Tag[]>(MOCK_TAGS);
const [nextId, setNextId] = useState(MOCK_TAGS.length + 1);
// 탭 · 페이지 상태
const [activeTab, setActiveTab] = useState("전체");
const [currentPage, setCurrentPage] = useState(1);
const PAGE_SIZE = 9;
// 모달 상태
const [addModalOpen, setAddModalOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Tag | null>(null);
// 서비스 목록 추출
const services = useMemo(() => {
const set = new Set<string>();
for (const tag of tags) set.add(tag.service);
return [...set];
}, [tags]);
// 활성 탭에 따른 필터링
const filtered = useMemo(() => {
if (activeTab === "전체") return tags;
return tags.filter((tag) => tag.service === activeTab);
}, [tags, activeTab]);
// 페이지네이션
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
const paged = useMemo(() => {
const start = (currentPage - 1) * PAGE_SIZE;
return filtered.slice(start, start + PAGE_SIZE);
}, [filtered, currentPage]);
// 태그 추가
const handleAdd = (data: {
name: string;
service: string;
description: string;
}) => {
// 해당 서비스의 기존 최대 순번 + 1
const maxNum = tags
.filter((t) => t.service === data.service)
.reduce((max, t) => Math.max(max, t.tagNumber), 0);
const newTag: Tag = {
id: nextId,
name: data.name,
service: data.service,
tagNumber: maxNum + 1,
deviceCount: 0,
description: data.description,
};
setTags((prev) => [...prev, newTag]);
setNextId((prev) => prev + 1);
toast.success("태그가 등록되었습니다.");
};
// 태그 수정 (설명만)
const handleEdit = (id: number, description: string) => {
setTags((prev) =>
prev.map((tag) => (tag.id === id ? { ...tag, description } : tag))
);
toast.success("태그가 수정되었습니다.");
};
// 태그 삭제
const handleDeleteConfirm = () => {
if (!deleteTarget) return;
setTags((prev) => prev.filter((tag) => tag.id !== deleteTarget.id));
setDeleteTarget(null);
toast.success("태그가 삭제되었습니다.");
};
return (
<div className="p-6">
<h1 className="text-2xl font-bold"> </h1>
<div>
<PageHeader
title="태그 관리"
description="태그를 생성하고 기기 그룹을 관리할 수 있습니다."
action={
<button
onClick={() => setAddModalOpen(true)}
className="flex items-center justify-center gap-2 min-w-[154px] px-5 py-2.5 bg-primary hover:bg-[#1d4ed8] text-white rounded-lg text-sm font-medium transition-colors shadow-sm"
>
<span className="material-symbols-outlined text-lg">add</span>
</button>
}
/>
{/* 서비스 탭 */}
{tags.length > 0 ? (
<>
{/* 서비스 탭 */}
<div className="flex gap-1 border-b border-gray-200 overflow-x-auto scrollbar-hide">
{["전체", ...services].map((tab) => {
const count =
tab === "전체"
? tags.length
: tags.filter((t) => t.service === tab).length;
return (
<button
key={tab}
onClick={() => { setActiveTab(tab); setCurrentPage(1); }}
className={`px-4 py-2.5 text-sm font-medium transition-colors relative whitespace-nowrap flex-shrink-0 ${
activeTab === tab
? "text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
{tab}
<span className="ml-1.5 text-xs">({count})</span>
{activeTab === tab && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-full" />
)}
</button>
);
})}
</div>
{/* 페이지네이션 */}
{totalPages > 1 && (
<div className="flex justify-end mt-4 mb-4">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={filtered.length}
pageSize={PAGE_SIZE}
onPageChange={setCurrentPage}
/>
</div>
)}
{totalPages <= 1 && <div className="mb-6" />}
{/* 카드 그리드 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{paged.map((tag) => (
<TagCard
key={tag.id}
tag={tag}
onEdit={handleEdit}
onDelete={(t) => setDeleteTarget(t)}
/>
))}
</div>
</>
) : (
<EmptyState
icon="label_off"
message="등록된 태그가 없습니다"
description="태그 추가 버튼을 눌러 새로운 태그를 등록하세요."
/>
)}
{/* 태그 추가 모달 */}
<TagAddModal
open={addModalOpen}
onClose={() => setAddModalOpen(false)}
onSave={handleAdd}
/>
{/* 태그 삭제 모달 */}
<TagDeleteModal
open={deleteTarget !== null}
tagName={deleteTarget?.name ?? ""}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDeleteConfirm}
/>
</div>
);
}

View File

@ -1 +1,114 @@
// Tag feature 타입 정의
// Tag 인터페이스
export interface Tag {
id: number;
name: string;
service: string;
tagNumber: number; // 서비스별 순번
deviceCount: number;
description: string;
}
// 서비스 필터 옵션
export const SERVICE_FILTER_OPTIONS = [
"전체 서비스",
"서비스 A",
"서비스 B",
"서비스 C",
];
// 모달에서 사용할 서비스 선택 옵션 (전체 제외)
export const SERVICE_OPTIONS = ["서비스 A", "서비스 B", "서비스 C"];
// 목 데이터 (10건)
export const MOCK_TAGS: Tag[] = [
{
id: 1,
name: "VIP",
service: "서비스 A",
tagNumber: 1,
deviceCount: 1234,
description:
"우수 고객 전용 태그. VIP 혜택 및 프리미엄 알림 발송 시 사용됩니다.",
},
{
id: 2,
name: "이벤트 구독",
service: "서비스 A",
tagNumber: 2,
deviceCount: 8502,
description:
"마케팅 이벤트 및 프로모션 정보 수신에 동의한 기기 그룹입니다.",
},
{
id: 3,
name: "야간 알림",
service: "서비스 B",
tagNumber: 1,
deviceCount: 342,
description:
"야간 시간대(21:00 ~ 08:00)에도 알림 수신을 허용한 사용자 그룹입니다.",
},
{
id: 4,
name: "마케팅 동의",
service: "서비스 A",
tagNumber: 3,
deviceCount: 12100,
description:
"개인정보 수집 및 마케팅 활용에 동의한 전체 사용자 태그입니다.",
},
{
id: 5,
name: "테스트 그룹",
service: "서비스 C",
tagNumber: 1,
deviceCount: 15,
description:
"내부 개발 및 기능 테스트를 위한 사내 테스트용 기기 목록입니다.",
},
{
id: 6,
name: "공지사항 필수",
service: "서비스 B",
tagNumber: 2,
deviceCount: 45200,
description:
"서비스 점검 등 필수 공지사항을 반드시 수신해야 하는 대상입니다.",
},
{
id: 7,
name: "신규 가입",
service: "서비스 A",
tagNumber: 4,
deviceCount: 3750,
description:
"최근 30일 이내 신규 가입한 사용자 기기 그룹입니다.",
},
{
id: 8,
name: "결제 알림",
service: "서비스 B",
tagNumber: 3,
deviceCount: 9820,
description:
"결제 완료, 환불 등 거래 관련 알림을 수신하는 대상입니다.",
},
{
id: 9,
name: "베타 테스터",
service: "서비스 C",
tagNumber: 2,
deviceCount: 87,
description:
"신규 기능 베타 테스트에 참여 중인 외부 사용자 그룹입니다.",
},
{
id: 10,
name: "휴면 계정",
service: "서비스 B",
tagNumber: 4,
deviceCount: 2140,
description:
"90일 이상 미접속한 휴면 상태 사용자 기기 그룹입니다.",
},
];

View File

@ -206,3 +206,12 @@
.chart-dot.show {
transform: translate(-50%, -50%) scale(1);
}
/* 스크롤바 숨김 유틸 */
@utility scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}