feat: 태그 관리 페이지 API 연동 (#41) #42
40
react/src/api/tag.api.ts
Normal file
40
react/src/api/tag.api.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
import type { ApiResponse } from "@/types/api";
|
||||||
|
import type {
|
||||||
|
TagListRequest,
|
||||||
|
TagListResponse,
|
||||||
|
TagResponse,
|
||||||
|
CreateTagRequest,
|
||||||
|
UpdateTagRequest,
|
||||||
|
DeleteTagRequest,
|
||||||
|
} from "@/features/tag/types";
|
||||||
|
|
||||||
|
/** 태그 목록 조회 (serviceCode 생략 시 전체 서비스) */
|
||||||
|
export function fetchTagList(data: TagListRequest, serviceCode?: string) {
|
||||||
|
return apiClient.post<ApiResponse<TagListResponse>>(
|
||||||
|
"/v1/in/tag/list",
|
||||||
|
data,
|
||||||
|
serviceCode ? { headers: { "X-Service-Code": serviceCode } } : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 태그 생성 */
|
||||||
|
export function createTag(data: CreateTagRequest, serviceCode: string) {
|
||||||
|
return apiClient.post<ApiResponse<TagResponse>>("/v1/in/tag/create", data, {
|
||||||
|
headers: { "X-Service-Code": serviceCode },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 태그 수정 */
|
||||||
|
export function updateTag(data: UpdateTagRequest, serviceCode: string) {
|
||||||
|
return apiClient.post<ApiResponse<TagResponse>>("/v1/in/tag/update", data, {
|
||||||
|
headers: { "X-Service-Code": serviceCode },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 태그 삭제 */
|
||||||
|
export function deleteTag(data: DeleteTagRequest, serviceCode: string) {
|
||||||
|
return apiClient.post<ApiResponse<null>>("/v1/in/tag/delete", data, {
|
||||||
|
headers: { "X-Service-Code": serviceCode },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,7 @@ export interface PlatformCredentialSummary {
|
||||||
|
|
||||||
// 서비스 목록 항목
|
// 서비스 목록 항목
|
||||||
export interface ServiceSummary {
|
export interface ServiceSummary {
|
||||||
|
serviceId: number;
|
||||||
serviceCode: string;
|
serviceCode: string;
|
||||||
serviceName: string;
|
serviceName: string;
|
||||||
serviceIcon?: string;
|
serviceIcon?: string;
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import useShake from "@/hooks/useShake";
|
import useShake from "@/hooks/useShake";
|
||||||
import { SERVICE_OPTIONS } from "../types";
|
|
||||||
|
|
||||||
interface TagAddModalProps {
|
interface TagAddModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (data: { name: string; service: string; description: string }) => void;
|
onSave: (data: { name: string; service: string; description: string }) => void;
|
||||||
|
serviceOptions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TagAddModal({ open, onClose, onSave }: TagAddModalProps) {
|
export default function TagAddModal({ open, onClose, onSave, serviceOptions }: TagAddModalProps) {
|
||||||
const { triggerShake, cls } = useShake();
|
const { triggerShake, cls } = useShake();
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
|
|
@ -107,9 +107,10 @@ export default function TagAddModal({ open, onClose, onSave }: TagAddModalProps)
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setName(e.target.value);
|
if (e.target.value.length <= 50) setName(e.target.value);
|
||||||
if (e.target.value.trim()) setNameError(false);
|
if (e.target.value.trim()) setNameError(false);
|
||||||
}}
|
}}
|
||||||
|
maxLength={50}
|
||||||
placeholder="태그 이름을 입력하세요"
|
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 ${
|
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
|
nameError
|
||||||
|
|
@ -154,7 +155,7 @@ export default function TagAddModal({ open, onClose, onSave }: TagAddModalProps)
|
||||||
</button>
|
</button>
|
||||||
{serviceOpen && (
|
{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">
|
<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) => (
|
{serviceOptions.map((opt) => (
|
||||||
<li
|
<li
|
||||||
key={opt}
|
key={opt}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
@ -192,11 +193,17 @@ export default function TagAddModal({ open, onClose, onSave }: TagAddModalProps)
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => {
|
||||||
|
if (e.target.value.length <= 200) setDescription(e.target.value);
|
||||||
|
}}
|
||||||
|
maxLength={200}
|
||||||
placeholder="태그 설명을 입력하세요"
|
placeholder="태그 설명을 입력하세요"
|
||||||
rows={3}
|
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"
|
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 className="text-right text-xs text-gray-400 mt-1">
|
||||||
|
{description.length}/200
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 안내 박스 */}
|
{/* 안내 박스 */}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { formatNumber } from "@/utils/format";
|
import { formatNumber } from "@/utils/format";
|
||||||
import type { Tag } from "../types";
|
import type { Tag } from "../types";
|
||||||
|
|
||||||
|
|
@ -129,11 +130,24 @@ export default function TagCard({ tag, onEdit, onDelete }: TagCardProps) {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
<h3 className="text-base font-bold text-[#0f172a]">{tag.name}</h3>
|
<h3 className="text-base font-bold text-[#0f172a]">{tag.name}</h3>
|
||||||
<span className="text-xs font-medium text-gray-400">
|
<button
|
||||||
#{tag.tagNumber}
|
onClick={() => {
|
||||||
</span>
|
navigator.clipboard.writeText(tag.tagCode);
|
||||||
|
toast.success("태그 코드가 복사되었습니다.");
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 text-xs font-medium text-gray-400 hover:text-primary bg-gray-50 hover:bg-blue-50 px-2 py-0.5 rounded transition-colors cursor-pointer flex-shrink-0"
|
||||||
|
title="클릭하여 복사"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "12px" }}
|
||||||
|
>
|
||||||
|
content_copy
|
||||||
|
</span>
|
||||||
|
{tag.tagCode}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -160,10 +174,16 @@ export default function TagCard({ tag, onEdit, onDelete }: TagCardProps) {
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={editDesc}
|
value={editDesc}
|
||||||
onChange={(e) => setEditDesc(e.target.value)}
|
onChange={(e) => {
|
||||||
|
if (e.target.value.length <= 200) setEditDesc(e.target.value);
|
||||||
|
}}
|
||||||
|
maxLength={200}
|
||||||
rows={3}
|
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"
|
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 className="text-right text-xs text-gray-400 mt-1">
|
||||||
|
{editDesc.length}/200
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-4 mt-4 border-t border-gray-100 flex gap-2">
|
<div className="pt-4 mt-4 border-t border-gray-100 flex gap-2">
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import PageHeader from "@/components/common/PageHeader";
|
import PageHeader from "@/components/common/PageHeader";
|
||||||
import EmptyState from "@/components/common/EmptyState";
|
import EmptyState from "@/components/common/EmptyState";
|
||||||
|
|
@ -6,82 +6,211 @@ import Pagination from "@/components/common/Pagination";
|
||||||
import TagCard from "../components/TagCard";
|
import TagCard from "../components/TagCard";
|
||||||
import TagAddModal from "../components/TagAddModal";
|
import TagAddModal from "../components/TagAddModal";
|
||||||
import TagDeleteModal from "../components/TagDeleteModal";
|
import TagDeleteModal from "../components/TagDeleteModal";
|
||||||
import { MOCK_TAGS } from "../types";
|
import { fetchTagList, createTag, updateTag, deleteTag } from "@/api/tag.api";
|
||||||
import type { Tag } from "../types";
|
import { fetchServices } from "@/api/service.api";
|
||||||
|
import type { Tag, TagListItem } from "../types";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 9;
|
||||||
|
|
||||||
|
/** 서비스 정보 */
|
||||||
|
interface ServiceInfo {
|
||||||
|
serviceId: number;
|
||||||
|
serviceName: string;
|
||||||
|
serviceCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** API 응답 → 뷰모델 변환 */
|
||||||
|
function mapTagItem(item: TagListItem): Tag {
|
||||||
|
return {
|
||||||
|
id: item.tag_id,
|
||||||
|
name: item.tag_name ?? "",
|
||||||
|
service: item.service_name ?? "",
|
||||||
|
tagCode: item.tag_code ?? `#${item.tag_index}`,
|
||||||
|
tagNumber: item.tag_index,
|
||||||
|
deviceCount: item.device_count,
|
||||||
|
description: item.description ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function TagManagePage() {
|
export default function TagManagePage() {
|
||||||
// 태그 데이터 (로컬 state)
|
// 태그 데이터
|
||||||
const [tags, setTags] = useState<Tag[]>(MOCK_TAGS);
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
const [nextId, setNextId] = useState(MOCK_TAGS.length + 1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [totalItems, setTotalItems] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// 서비스 목록
|
||||||
|
const [serviceList, setServiceList] = useState<ServiceInfo[]>([]);
|
||||||
|
|
||||||
|
// 탭별 건수
|
||||||
|
const [tabCounts, setTabCounts] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
// 탭 · 페이지 상태
|
// 탭 · 페이지 상태
|
||||||
const [activeTab, setActiveTab] = useState("전체");
|
const [activeTab, setActiveTab] = useState("전체");
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const PAGE_SIZE = 9;
|
|
||||||
|
|
||||||
// 모달 상태
|
// 모달 상태
|
||||||
const [addModalOpen, setAddModalOpen] = useState(false);
|
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Tag | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Tag | null>(null);
|
||||||
|
|
||||||
// 서비스 목록 추출
|
/** 서비스 목록 로드 (초기 1회) */
|
||||||
const services = useMemo(() => {
|
const loadServices = useCallback(async () => {
|
||||||
const set = new Set<string>();
|
try {
|
||||||
for (const tag of tags) set.add(tag.service);
|
const res = await fetchServices({ page: 1, pageSize: 100 });
|
||||||
return [...set];
|
const items = res.data.data.items ?? [];
|
||||||
}, [tags]);
|
setServiceList(
|
||||||
|
items.map((svc) => ({
|
||||||
|
serviceId: svc.serviceId,
|
||||||
|
serviceName: svc.serviceName,
|
||||||
|
serviceCode: svc.serviceCode,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setServiceList([]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 활성 탭에 따른 필터링
|
/** 탭별 건수 계산 (전체 조회 1회) */
|
||||||
const filtered = useMemo(() => {
|
const loadTabCounts = useCallback(async () => {
|
||||||
if (activeTab === "전체") return tags;
|
try {
|
||||||
return tags.filter((tag) => tag.service === activeTab);
|
const res = await fetchTagList({ page: 1, size: 100 });
|
||||||
}, [tags, activeTab]);
|
const items = res.data.data.items ?? [];
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
let total = 0;
|
||||||
|
for (const item of items) {
|
||||||
|
counts[item.service_name] = (counts[item.service_name] ?? 0) + 1;
|
||||||
|
total++;
|
||||||
|
}
|
||||||
|
counts["전체"] = total;
|
||||||
|
setTabCounts(counts);
|
||||||
|
} catch {
|
||||||
|
setTabCounts({});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 페이지네이션
|
/** 태그 목록 로드 */
|
||||||
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
|
const loadData = useCallback(
|
||||||
const paged = useMemo(() => {
|
async (page: number, tab: string) => {
|
||||||
const start = (currentPage - 1) * PAGE_SIZE;
|
setLoading(true);
|
||||||
return filtered.slice(start, start + PAGE_SIZE);
|
try {
|
||||||
}, [filtered, currentPage]);
|
const svc = tab !== "전체"
|
||||||
|
? serviceList.find((s) => s.serviceName === tab)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const res = await fetchTagList(
|
||||||
|
{ page, size: PAGE_SIZE },
|
||||||
|
svc?.serviceCode,
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = res.data.data;
|
||||||
|
setTags((data.items ?? []).map(mapTagItem));
|
||||||
|
setTotalItems(data.pagination?.total_count ?? 0);
|
||||||
|
setTotalPages(data.pagination?.total_pages ?? 1);
|
||||||
|
} catch {
|
||||||
|
setTags([]);
|
||||||
|
setTotalItems(0);
|
||||||
|
setTotalPages(1);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[serviceList],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 초기 로드
|
||||||
|
useEffect(() => {
|
||||||
|
loadServices();
|
||||||
|
}, [loadServices]);
|
||||||
|
|
||||||
|
// 서비스 목록 준비 후 데이터 + 탭건수 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (serviceList.length === 0) return;
|
||||||
|
loadData(1, activeTab);
|
||||||
|
loadTabCounts();
|
||||||
|
}, [serviceList]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// 탭 변경
|
||||||
|
const handleTabChange = (tab: string) => {
|
||||||
|
setActiveTab(tab);
|
||||||
|
setCurrentPage(1);
|
||||||
|
loadData(1, tab);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 페이지 변경
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
loadData(page, activeTab);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 데이터 리프레시 */
|
||||||
|
const refresh = () => {
|
||||||
|
loadData(currentPage, activeTab);
|
||||||
|
loadTabCounts();
|
||||||
|
};
|
||||||
|
|
||||||
// 태그 추가
|
// 태그 추가
|
||||||
const handleAdd = (data: {
|
const handleAdd = async (data: {
|
||||||
name: string;
|
name: string;
|
||||||
service: string;
|
service: string;
|
||||||
description: string;
|
description: string;
|
||||||
}) => {
|
}) => {
|
||||||
// 해당 서비스의 기존 최대 순번 + 1
|
const svc = serviceList.find((s) => s.serviceName === data.service);
|
||||||
const maxNum = tags
|
if (!svc) {
|
||||||
.filter((t) => t.service === data.service)
|
toast.error("서비스를 찾을 수 없습니다.");
|
||||||
.reduce((max, t) => Math.max(max, t.tagNumber), 0);
|
return;
|
||||||
const newTag: Tag = {
|
}
|
||||||
id: nextId,
|
try {
|
||||||
name: data.name,
|
await createTag({
|
||||||
service: data.service,
|
service_id: svc.serviceId,
|
||||||
tagNumber: maxNum + 1,
|
tag_name: data.name,
|
||||||
deviceCount: 0,
|
description: data.description || undefined,
|
||||||
description: data.description,
|
}, svc.serviceCode);
|
||||||
};
|
toast.success("태그가 등록되었습니다.");
|
||||||
setTags((prev) => [...prev, newTag]);
|
refresh();
|
||||||
setNextId((prev) => prev + 1);
|
} catch {
|
||||||
toast.success("태그가 등록되었습니다.");
|
toast.error("태그 등록에 실패했습니다.");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 태그 수정 (설명만)
|
// 태그 수정 (설명만)
|
||||||
const handleEdit = (id: number, description: string) => {
|
const handleEdit = async (id: number, description: string) => {
|
||||||
setTags((prev) =>
|
// 해당 태그의 서비스코드 찾기
|
||||||
prev.map((tag) => (tag.id === id ? { ...tag, description } : tag))
|
const tag = tags.find((t) => t.id === id);
|
||||||
);
|
const svc = serviceList.find((s) => s.serviceName === tag?.service);
|
||||||
toast.success("태그가 수정되었습니다.");
|
if (!svc) {
|
||||||
|
toast.error("서비스를 찾을 수 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await updateTag({ tag_id: id, tag_code: tag!.tagCode, description }, svc.serviceCode);
|
||||||
|
toast.success("태그가 수정되었습니다.");
|
||||||
|
refresh();
|
||||||
|
} catch {
|
||||||
|
toast.error("태그 수정에 실패했습니다.");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 태그 삭제
|
// 태그 삭제
|
||||||
const handleDeleteConfirm = () => {
|
const handleDeleteConfirm = async () => {
|
||||||
if (!deleteTarget) return;
|
if (!deleteTarget) return;
|
||||||
setTags((prev) => prev.filter((tag) => tag.id !== deleteTarget.id));
|
const svc = serviceList.find((s) => s.serviceName === deleteTarget.service);
|
||||||
setDeleteTarget(null);
|
if (!svc) {
|
||||||
toast.success("태그가 삭제되었습니다.");
|
toast.error("서비스를 찾을 수 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await deleteTag({ tag_id: deleteTarget.id, tag_code: deleteTarget.tagCode }, svc.serviceCode);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
toast.success("태그가 삭제되었습니다.");
|
||||||
|
refresh();
|
||||||
|
} catch {
|
||||||
|
toast.error("태그 삭제에 실패했습니다.");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const totalCount = tabCounts["전체"] ?? 0;
|
||||||
|
const serviceNames = serviceList.map((s) => s.serviceName);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
|
|
@ -98,20 +227,22 @@ export default function TagManagePage() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 서비스 탭 */}
|
{!loading && totalCount === 0 && serviceList.length > 0 ? (
|
||||||
{tags.length > 0 ? (
|
<EmptyState
|
||||||
|
icon="label_off"
|
||||||
|
message="등록된 태그가 없습니다"
|
||||||
|
description="태그 추가 버튼을 눌러 새로운 태그를 등록하세요."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* 서비스 탭 */}
|
{/* 서비스 탭 */}
|
||||||
<div className="flex gap-1 border-b border-gray-200 overflow-x-auto scrollbar-hide">
|
<div className="flex gap-1 border-b border-gray-200 overflow-x-auto scrollbar-hide">
|
||||||
{["전체", ...services].map((tab) => {
|
{["전체", ...serviceNames].map((tab) => {
|
||||||
const count =
|
const count = tabCounts[tab] ?? 0;
|
||||||
tab === "전체"
|
|
||||||
? tags.length
|
|
||||||
: tags.filter((t) => t.service === tab).length;
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
onClick={() => { setActiveTab(tab); setCurrentPage(1); }}
|
onClick={() => handleTabChange(tab)}
|
||||||
className={`px-4 py-2.5 text-sm font-medium transition-colors relative whitespace-nowrap flex-shrink-0 ${
|
className={`px-4 py-2.5 text-sm font-medium transition-colors relative whitespace-nowrap flex-shrink-0 ${
|
||||||
activeTab === tab
|
activeTab === tab
|
||||||
? "text-primary"
|
? "text-primary"
|
||||||
|
|
@ -134,9 +265,9 @@ export default function TagManagePage() {
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
totalItems={filtered.length}
|
totalItems={totalItems}
|
||||||
pageSize={PAGE_SIZE}
|
pageSize={PAGE_SIZE}
|
||||||
onPageChange={setCurrentPage}
|
onPageChange={handlePageChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -144,7 +275,7 @@ export default function TagManagePage() {
|
||||||
|
|
||||||
{/* 카드 그리드 */}
|
{/* 카드 그리드 */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{paged.map((tag) => (
|
{tags.map((tag) => (
|
||||||
<TagCard
|
<TagCard
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
tag={tag}
|
tag={tag}
|
||||||
|
|
@ -154,12 +285,6 @@ export default function TagManagePage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
|
||||||
<EmptyState
|
|
||||||
icon="label_off"
|
|
||||||
message="등록된 태그가 없습니다"
|
|
||||||
description="태그 추가 버튼을 눌러 새로운 태그를 등록하세요."
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 태그 추가 모달 */}
|
{/* 태그 추가 모달 */}
|
||||||
|
|
@ -167,6 +292,7 @@ export default function TagManagePage() {
|
||||||
open={addModalOpen}
|
open={addModalOpen}
|
||||||
onClose={() => setAddModalOpen(false)}
|
onClose={() => setAddModalOpen(false)}
|
||||||
onSave={handleAdd}
|
onSave={handleAdd}
|
||||||
|
serviceOptions={serviceNames}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 태그 삭제 모달 */}
|
{/* 태그 삭제 모달 */}
|
||||||
|
|
|
||||||
|
|
@ -1,114 +1,81 @@
|
||||||
// Tag 인터페이스
|
// Tag 뷰모델 (TagCard/TagDeleteModal이 사용)
|
||||||
export interface Tag {
|
export interface Tag {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
service: string;
|
service: string;
|
||||||
|
tagCode: string; // 태그 코드 (예: "TAG-001")
|
||||||
tagNumber: number; // 서비스별 순번
|
tagNumber: number; // 서비스별 순번
|
||||||
deviceCount: number;
|
deviceCount: number;
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 서비스 필터 옵션
|
// --- API 요청 타입 (swagger snake_case) ---
|
||||||
export const SERVICE_FILTER_OPTIONS = [
|
|
||||||
"전체 서비스",
|
|
||||||
"서비스 A",
|
|
||||||
"서비스 B",
|
|
||||||
"서비스 C",
|
|
||||||
];
|
|
||||||
|
|
||||||
// 모달에서 사용할 서비스 선택 옵션 (전체 제외)
|
/** 태그 목록 요청 */
|
||||||
export const SERVICE_OPTIONS = ["서비스 A", "서비스 B", "서비스 C"];
|
export interface TagListRequest {
|
||||||
|
service_id?: number;
|
||||||
|
keyword?: string;
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
// 목 데이터 (10건)
|
/** 태그 생성 요청 */
|
||||||
export const MOCK_TAGS: Tag[] = [
|
export interface CreateTagRequest {
|
||||||
{
|
service_id: number;
|
||||||
id: 1,
|
tag_name: string;
|
||||||
name: "VIP",
|
description?: string;
|
||||||
service: "서비스 A",
|
}
|
||||||
tagNumber: 1,
|
|
||||||
deviceCount: 1234,
|
/** 태그 수정 요청 */
|
||||||
description:
|
export interface UpdateTagRequest {
|
||||||
"우수 고객 전용 태그. VIP 혜택 및 프리미엄 알림 발송 시 사용됩니다.",
|
tag_id: number;
|
||||||
},
|
tag_code: string;
|
||||||
{
|
description?: string;
|
||||||
id: 2,
|
}
|
||||||
name: "이벤트 구독",
|
|
||||||
service: "서비스 A",
|
/** 태그 삭제 요청 */
|
||||||
tagNumber: 2,
|
export interface DeleteTagRequest {
|
||||||
deviceCount: 8502,
|
tag_id: number;
|
||||||
description:
|
tag_code: string;
|
||||||
"마케팅 이벤트 및 프로모션 정보 수신에 동의한 기기 그룹입니다.",
|
}
|
||||||
},
|
|
||||||
{
|
// --- API 응답 타입 (실제 서버 응답 기준) ---
|
||||||
id: 3,
|
|
||||||
name: "야간 알림",
|
/** 태그 목록 항목 (TagSummaryDto) */
|
||||||
service: "서비스 B",
|
export interface TagListItem {
|
||||||
tagNumber: 1,
|
tag_index: number;
|
||||||
deviceCount: 342,
|
tag_id: number;
|
||||||
description:
|
tag_code: string | null;
|
||||||
"야간 시간대(21:00 ~ 08:00)에도 알림 수신을 허용한 사용자 그룹입니다.",
|
tag_name: string | null;
|
||||||
},
|
service_id: number;
|
||||||
{
|
service_name: string | null;
|
||||||
id: 4,
|
device_count: number;
|
||||||
name: "마케팅 동의",
|
description: string | null;
|
||||||
service: "서비스 A",
|
created_at: string | null;
|
||||||
tagNumber: 3,
|
updated_at: string | null;
|
||||||
deviceCount: 12100,
|
}
|
||||||
description:
|
|
||||||
"개인정보 수집 및 마케팅 활용에 동의한 전체 사용자 태그입니다.",
|
/** 태그 단건 응답 (TagResponseDto) — create/update 응답 */
|
||||||
},
|
export interface TagResponse {
|
||||||
{
|
tag_id: number;
|
||||||
id: 5,
|
tag_name: string | null;
|
||||||
name: "테스트 그룹",
|
description: string | null;
|
||||||
service: "서비스 C",
|
service_id: number;
|
||||||
tagNumber: 1,
|
service_name: string | null;
|
||||||
deviceCount: 15,
|
created_at: string;
|
||||||
description:
|
updated_at: string | null;
|
||||||
"내부 개발 및 기능 테스트를 위한 사내 테스트용 기기 목록입니다.",
|
}
|
||||||
},
|
|
||||||
{
|
/** 태그 목록 페이지네이션 */
|
||||||
id: 6,
|
export interface TagPagination {
|
||||||
name: "공지사항 필수",
|
page: number;
|
||||||
service: "서비스 B",
|
size: number;
|
||||||
tagNumber: 2,
|
total_count: number;
|
||||||
deviceCount: 45200,
|
total_pages: number;
|
||||||
description:
|
}
|
||||||
"서비스 점검 등 필수 공지사항을 반드시 수신해야 하는 대상입니다.",
|
|
||||||
},
|
/** 태그 목록 응답 */
|
||||||
{
|
export interface TagListResponse {
|
||||||
id: 7,
|
items: TagListItem[];
|
||||||
name: "신규 가입",
|
pagination: TagPagination;
|
||||||
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일 이상 미접속한 휴면 상태 사용자 기기 그룹입니다.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user