feat: 태그 관리 페이지 API 연동 (#41) #42

Merged
seonkyu.kim merged 1 commits from feature/SPMS-41-tag-api-integration into develop 2026-03-02 09:10:52 +00:00
6 changed files with 340 additions and 179 deletions

40
react/src/api/tag.api.ts Normal file
View 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 },
});
}

View File

@ -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;

View File

@ -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>
{/* 안내 박스 */} {/* 안내 박스 */}

View File

@ -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={() => {
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> </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

View File

@ -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);
};
setTags((prev) => [...prev, newTag]);
setNextId((prev) => prev + 1);
toast.success("태그가 등록되었습니다."); toast.success("태그가 등록되었습니다.");
refresh();
} catch {
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);
if (!svc) {
toast.error("서비스를 찾을 수 없습니다.");
return;
}
try {
await updateTag({ tag_id: id, tag_code: tag!.tagCode, description }, svc.serviceCode);
toast.success("태그가 수정되었습니다."); 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);
if (!svc) {
toast.error("서비스를 찾을 수 없습니다.");
return;
}
try {
await deleteTag({ tag_id: deleteTarget.id, tag_code: deleteTarget.tagCode }, svc.serviceCode);
setDeleteTarget(null); setDeleteTarget(null);
toast.success("태그가 삭제되었습니다."); 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}
/> />
{/* 태그 삭제 모달 */} {/* 태그 삭제 모달 */}

View File

@ -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일 이상 미접속한 휴면 상태 사용자 기기 그룹입니다.",
},
];