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() {
|
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 (
|
return (
|
||||||
<div className="p-6">
|
<div>
|
||||||
<h1 className="text-2xl font-bold">태그 관리</h1>
|
<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>
|
</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 {
|
.chart-dot.show {
|
||||||
transform: translate(-50%, -50%) scale(1);
|
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