feat: 태그 관리 페이지 구현 (#25) #26
290
react/src/features/tag/components/TagAddModal.tsx
Normal file
290
react/src/features/tag/components/TagAddModal.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
191
react/src/features/tag/components/TagCard.tsx
Normal file
191
react/src/features/tag/components/TagCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
react/src/features/tag/components/TagDeleteModal.tsx
Normal file
77
react/src/features/tag/components/TagDeleteModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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일 이상 미접속한 휴면 상태 사용자 기기 그룹입니다.",
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user