feat: 태그 관리 페이지 API 연동 (#41)
Some checks failed
SPMS_BO/pipeline/head There was a failure building this commit
Some checks failed
SPMS_BO/pipeline/head There was a failure building this commit
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_WEB/pulls/42
This commit is contained in:
commit
0b2aa91a43
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 {
|
||||
serviceId: number;
|
||||
serviceCode: string;
|
||||
serviceName: string;
|
||||
serviceIcon?: string;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
{/* 안내 박스 */}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
||||
{/* 태그 삭제 모달 */}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user