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

- tag.api.ts 신규 생성 (list/create/update/delete POST 엔드포인트)
- TagListItem, TagResponse, TagPagination 등 API 타입 정의
- Mock 데이터(MOCK_TAGS, SERVICE_OPTIONS 등) 제거
- 서비스 목록 동적 로드 (fetchServices) + 탭 필터링
- X-Service-Code 헤더 처리 (전체 API)
- tagCode 우측 배치 + 클릭 시 클립보드 복사
- swagger 제약조건 반영 (태그명 50자, 설명 200자)
- ServiceSummary에 serviceId 필드 추가

Closes #41
This commit is contained in:
SEAN 2026-03-02 18:08:48 +09:00
parent 683d0e6f30
commit b4b6426ded
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 {
serviceId: number;
serviceCode: string;
serviceName: string;
serviceIcon?: string;

View File

@ -1,14 +1,14 @@
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;
serviceOptions: string[];
}
export default function TagAddModal({ open, onClose, onSave }: TagAddModalProps) {
export default function TagAddModal({ open, onClose, onSave, serviceOptions }: TagAddModalProps) {
const { triggerShake, cls } = useShake();
const [name, setName] = useState("");
@ -107,9 +107,10 @@ export default function TagAddModal({ open, onClose, onSave }: TagAddModalProps)
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
if (e.target.value.length <= 50) setName(e.target.value);
if (e.target.value.trim()) setNameError(false);
}}
maxLength={50}
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
@ -154,7 +155,7 @@ export default function TagAddModal({ open, onClose, onSave }: TagAddModalProps)
</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) => (
{serviceOptions.map((opt) => (
<li
key={opt}
onClick={() => {
@ -192,11 +193,17 @@ export default function TagAddModal({ open, onClose, onSave }: TagAddModalProps)
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
onChange={(e) => {
if (e.target.value.length <= 200) setDescription(e.target.value);
}}
maxLength={200}
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 className="text-right text-xs text-gray-400 mt-1">
{description.length}/200
</div>
</div>
{/* 안내 박스 */}

View File

@ -1,4 +1,5 @@
import { useState, useRef, useEffect } from "react";
import { toast } from "sonner";
import { formatNumber } from "@/utils/format";
import type { Tag } from "../types";
@ -129,11 +130,24 @@ export default function TagCard({ tag, onEdit, onDelete }: TagCardProps) {
</span>
</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>
<span className="text-xs font-medium text-gray-400">
#{tag.tagNumber}
<button
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>
{tag.tagCode}
</button>
</div>
)}
@ -160,10 +174,16 @@ export default function TagCard({ tag, onEdit, onDelete }: TagCardProps) {
<textarea
ref={textareaRef}
value={editDesc}
onChange={(e) => setEditDesc(e.target.value)}
onChange={(e) => {
if (e.target.value.length <= 200) setEditDesc(e.target.value);
}}
maxLength={200}
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 className="text-right text-xs text-gray-400 mt-1">
{editDesc.length}/200
</div>
</div>
<div className="pt-4 mt-4 border-t border-gray-100 flex gap-2">
<button

View File

@ -1,4 +1,4 @@
import { useState, useMemo } from "react";
import { useState, useCallback, useEffect } from "react";
import { toast } from "sonner";
import PageHeader from "@/components/common/PageHeader";
import EmptyState from "@/components/common/EmptyState";
@ -6,82 +6,211 @@ 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";
import { fetchTagList, createTag, updateTag, deleteTag } from "@/api/tag.api";
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() {
// 태그 데이터 (로컬 state)
const [tags, setTags] = useState<Tag[]>(MOCK_TAGS);
const [nextId, setNextId] = useState(MOCK_TAGS.length + 1);
// 태그 데이터
const [tags, setTags] = useState<Tag[]>([]);
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 [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]);
/** 서비스 목록 로드 (초기 1회) */
const loadServices = useCallback(async () => {
try {
const res = await fetchServices({ page: 1, pageSize: 100 });
const items = res.data.data.items ?? [];
setServiceList(
items.map((svc) => ({
serviceId: svc.serviceId,
serviceName: svc.serviceName,
serviceCode: svc.serviceCode,
})),
);
} catch {
setServiceList([]);
}
}, []);
// 활성 탭에 따른 필터링
const filtered = useMemo(() => {
if (activeTab === "전체") return tags;
return tags.filter((tag) => tag.service === activeTab);
}, [tags, activeTab]);
/** 탭별 건수 계산 (전체 조회 1회) */
const loadTabCounts = useCallback(async () => {
try {
const res = await fetchTagList({ page: 1, size: 100 });
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 paged = useMemo(() => {
const start = (currentPage - 1) * PAGE_SIZE;
return filtered.slice(start, start + PAGE_SIZE);
}, [filtered, currentPage]);
/** 태그 목록 로드 */
const loadData = useCallback(
async (page: number, tab: string) => {
setLoading(true);
try {
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;
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);
const svc = serviceList.find((s) => s.serviceName === data.service);
if (!svc) {
toast.error("서비스를 찾을 수 없습니다.");
return;
}
try {
await createTag({
service_id: svc.serviceId,
tag_name: data.name,
description: data.description || undefined,
}, svc.serviceCode);
toast.success("태그가 등록되었습니다.");
refresh();
} catch {
toast.error("태그 등록에 실패했습니다.");
}
};
// 태그 수정 (설명만)
const handleEdit = (id: number, description: string) => {
setTags((prev) =>
prev.map((tag) => (tag.id === id ? { ...tag, description } : tag))
);
const handleEdit = async (id: number, description: string) => {
// 해당 태그의 서비스코드 찾기
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("태그가 수정되었습니다.");
refresh();
} catch {
toast.error("태그 수정에 실패했습니다.");
}
};
// 태그 삭제
const handleDeleteConfirm = () => {
const handleDeleteConfirm = async () => {
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);
toast.success("태그가 삭제되었습니다.");
refresh();
} catch {
toast.error("태그 삭제에 실패했습니다.");
}
};
const totalCount = tabCounts["전체"] ?? 0;
const serviceNames = serviceList.map((s) => s.serviceName);
return (
<div>
<PageHeader
@ -98,20 +227,22 @@ export default function TagManagePage() {
}
/>
{/* 서비스 탭 */}
{tags.length > 0 ? (
{!loading && totalCount === 0 && serviceList.length > 0 ? (
<EmptyState
icon="label_off"
message="등록된 태그가 없습니다"
description="태그 추가 버튼을 눌러 새로운 태그를 등록하세요."
/>
) : (
<>
{/* 서비스 탭 */}
<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;
{["전체", ...serviceNames].map((tab) => {
const count = tabCounts[tab] ?? 0;
return (
<button
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 ${
activeTab === tab
? "text-primary"
@ -134,9 +265,9 @@ export default function TagManagePage() {
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={filtered.length}
totalItems={totalItems}
pageSize={PAGE_SIZE}
onPageChange={setCurrentPage}
onPageChange={handlePageChange}
/>
</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">
{paged.map((tag) => (
{tags.map((tag) => (
<TagCard
key={tag.id}
tag={tag}
@ -154,12 +285,6 @@ export default function TagManagePage() {
))}
</div>
</>
) : (
<EmptyState
icon="label_off"
message="등록된 태그가 없습니다"
description="태그 추가 버튼을 눌러 새로운 태그를 등록하세요."
/>
)}
{/* 태그 추가 모달 */}
@ -167,6 +292,7 @@ export default function TagManagePage() {
open={addModalOpen}
onClose={() => setAddModalOpen(false)}
onSave={handleAdd}
serviceOptions={serviceNames}
/>
{/* 태그 삭제 모달 */}

View File

@ -1,114 +1,81 @@
// Tag 인터페이스
// Tag 뷰모델 (TagCard/TagDeleteModal이 사용)
export interface Tag {
id: number;
name: string;
service: string;
tagCode: string; // 태그 코드 (예: "TAG-001")
tagNumber: number; // 서비스별 순번
deviceCount: number;
description: string;
}
// 서비스 필터 옵션
export const SERVICE_FILTER_OPTIONS = [
"전체 서비스",
"서비스 A",
"서비스 B",
"서비스 C",
];
// --- API 요청 타입 (swagger snake_case) ---
// 모달에서 사용할 서비스 선택 옵션 (전체 제외)
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[] = [
{
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일 이상 미접속한 휴면 상태 사용자 기기 그룹입니다.",
},
];
/** 태그 생성 요청 */
export interface CreateTagRequest {
service_id: number;
tag_name: string;
description?: string;
}
/** 태그 수정 요청 */
export interface UpdateTagRequest {
tag_id: number;
tag_code: string;
description?: string;
}
/** 태그 삭제 요청 */
export interface DeleteTagRequest {
tag_id: number;
tag_code: string;
}
// --- API 응답 타입 (실제 서버 응답 기준) ---
/** 태그 목록 항목 (TagSummaryDto) */
export interface TagListItem {
tag_index: number;
tag_id: number;
tag_code: string | null;
tag_name: string | null;
service_id: number;
service_name: string | null;
device_count: number;
description: string | null;
created_at: string | null;
updated_at: string | null;
}
/** 태그 단건 응답 (TagResponseDto) — create/update 응답 */
export interface TagResponse {
tag_id: number;
tag_name: string | null;
description: string | null;
service_id: number;
service_name: string | null;
created_at: string;
updated_at: string | null;
}
/** 태그 목록 페이지네이션 */
export interface TagPagination {
page: number;
size: number;
total_count: number;
total_pages: number;
}
/** 태그 목록 응답 */
export interface TagListResponse {
items: TagListItem[];
pagination: TagPagination;
}