From 6ba5667895e42b09c86781b69f02b1f2440a8d07 Mon Sep 17 00:00:00 2001 From: CIOI Date: Mon, 18 May 2026 21:24:50 +0900 Subject: [PATCH] feat(admin): improve group member management UI Co-authored-by: Cursor --- docs/agent/web-routes-and-features.md | 2 +- .../group-members/__tests__/page.test.tsx | 69 +- .../app/admin/entities/group-members/page.tsx | 672 ++++++++++++++++-- .../api/admin/entities/group-members/route.ts | 350 ++++++++- packages/web/lib/api/admin/entities.ts | 125 +++- 5 files changed, 1137 insertions(+), 81 deletions(-) diff --git a/docs/agent/web-routes-and-features.md b/docs/agent/web-routes-and-features.md index 7d091a03..f7a59539 100644 --- a/docs/agent/web-routes-and-features.md +++ b/docs/agent/web-routes-and-features.md @@ -40,7 +40,7 @@ App Router 기준 (`packages/web/app/`). 작업 시 이 표와 실제 `app/` 트 | `/admin/seed/post-spots` | 시드 포스트 스팟 | | `/admin/entities/artists` | 아티스트 관리 (CRUD, paginated, searchable) | | `/admin/entities/brands` | 브랜드 관리 (CRUD) | -| `/admin/entities/group-members` | 그룹 멤버 관리 | +| `/admin/entities/group-members` | 그룹 멤버 관리 — group별 artist membership 조회·추가·수정·삭제 | | `/admin/raw-post-sources` | 수집 소스 등록/관리 (Pinterest 등 — #327) | | `/admin/raw-posts` | **검증 큐** (#333) — assets 의 raw_posts 를 status 탭(COMPLETED/IN_PROGRESS/ERROR/VERIFIED) 으로 필터링, "검증" 버튼으로 prod posts 반영 | | `/admin/data-pipeline/instagram-accounts` | Instagram tagged account enrichment queue, Gemini grounding quota, scheduler controls, manual entity review. See [`docs/database/entity-enrichment-pipeline.md`](../database/entity-enrichment-pipeline.md). | diff --git a/packages/web/app/admin/entities/group-members/__tests__/page.test.tsx b/packages/web/app/admin/entities/group-members/__tests__/page.test.tsx index 3e9a9e67..1205e540 100644 --- a/packages/web/app/admin/entities/group-members/__tests__/page.test.tsx +++ b/packages/web/app/admin/entities/group-members/__tests__/page.test.tsx @@ -1,10 +1,7 @@ /** * @vitest-environment jsdom * - * GroupMembersPage empty / loading / error state tests. - * - * Group members has no search/status filter, so isEmpty is simply: - * not loading AND not error AND no rows. + * GroupMembersPage group list / member table state tests. */ import React from "react"; import { describe, test, expect, vi, beforeEach } from "vitest"; @@ -19,44 +16,87 @@ vi.mock("next/navigation", () => ({ // --- Mock the data hooks --- const useGroupMemberListMock = vi.fn(); +const mutateMock = vi.fn(); vi.mock("@/lib/api/admin/entities", () => ({ useGroupMemberList: (...args: unknown[]) => useGroupMemberListMock(...args), + useGroupMemberArtistSearch: () => ({ data: { data: [] }, isLoading: false }), + useCreateGroupMember: () => ({ mutate: mutateMock, isPending: false }), + useUpdateGroupMember: () => ({ mutate: mutateMock, isPending: false }), + useDeleteGroupMember: () => ({ mutate: mutateMock, isPending: false }), })); import GroupMembersPage from "../page"; beforeEach(() => { useGroupMemberListMock.mockReset(); + mutateMock.mockReset(); }); describe("GroupMembersPage — empty state", () => { test("renders AdminEmptyState when no data", () => { useGroupMemberListMock.mockReturnValue({ - data: { data: [], pagination: undefined }, + data: { + data: [], + groups: [], + selected_group: null, + pagination: undefined, + }, isLoading: false, isError: false, }); render(); - expect(screen.getByText("No group members")).toBeInTheDocument(); + expect(screen.getByText("No groups")).toBeInTheDocument(); expect( screen.getByText( - "Artist–group relationships will appear here once they are seeded." + "Groups created by entity enrichment or manual admin actions will appear here." ) ).toBeInTheDocument(); }); - test("renders table when data has rows", () => { + test("renders selected group and member rows", () => { useGroupMemberListMock.mockReturnValue({ data: { + groups: [ + { + id: "group-uuid-5678", + name_en: "BLACKPINK", + name_ko: "블랙핑크", + profile_image_url: null, + primary_instagram_account_id: null, + primary_instagram_account: null, + member_count: 1, + total_member_count: 1, + metadata: null, + }, + ], + selected_group: { + id: "group-uuid-5678", + name_en: "BLACKPINK", + name_ko: "블랙핑크", + profile_image_url: null, + primary_instagram_account_id: null, + primary_instagram_account: null, + member_count: 1, + total_member_count: 1, + metadata: null, + }, data: [ { artist_id: "artist-uuid-1234", group_id: "group-uuid-5678", is_active: true, metadata: null, + artist: { + id: "artist-uuid-1234", + name_en: "Jennie", + name_ko: "제니", + profile_image_url: null, + primary_instagram_account_id: null, + primary_instagram_account: { username: "jennierubyjane" }, + }, }, ], pagination: { @@ -72,9 +112,10 @@ describe("GroupMembersPage — empty state", () => { render(); - expect(screen.queryByText("No group members")).not.toBeInTheDocument(); - // row data is rendered (truncated artist_id prefix) - expect(screen.getByText("artist-u…")).toBeInTheDocument(); + expect(screen.queryByText("No groups")).not.toBeInTheDocument(); + expect(screen.getAllByText("BLACKPINK").length).toBeGreaterThan(0); + expect(screen.getByText("Jennie")).toBeInTheDocument(); + expect(screen.getByText("@jennierubyjane")).toBeInTheDocument(); }); test("renders error state when fetch fails", () => { @@ -87,10 +128,8 @@ describe("GroupMembersPage — empty state", () => { render(); expect( - screen.getByText( - "Failed to load group members. Please try refreshing the page." - ) + screen.getByText("Failed to load groups. Please try refreshing the page.") ).toBeInTheDocument(); - expect(screen.queryByText("No group members")).not.toBeInTheDocument(); + expect(screen.queryByText("No groups")).not.toBeInTheDocument(); }); }); diff --git a/packages/web/app/admin/entities/group-members/page.tsx b/packages/web/app/admin/entities/group-members/page.tsx index 41e4a67b..493dbe26 100644 --- a/packages/web/app/admin/entities/group-members/page.tsx +++ b/packages/web/app/admin/entities/group-members/page.tsx @@ -1,16 +1,351 @@ "use client"; -import { useCallback, Suspense } from "react"; +import { useCallback, useEffect, useRef, useState, Suspense } from "react"; import { useSearchParams, useRouter } from "next/navigation"; -import { Users } from "lucide-react"; +import { + Check, + Pencil, + Plus, + Search, + Trash2, + UserPlus, + Users, + X, +} from "lucide-react"; import { AdminDataTable, type Column, + AdminImagePreview, AdminStatusBadge, AdminPagination, AdminEmptyState, } from "@/lib/components/admin/common"; -import { useGroupMemberList, type GroupMember } from "@/lib/api/admin/entities"; +import { + useCreateGroupMember, + useDeleteGroupMember, + useGroupMemberArtistSearch, + useGroupMemberList, + useUpdateGroupMember, + type GroupMember, + type GroupMemberArtist, + type GroupSummary, +} from "@/lib/api/admin/entities"; + +function displayName(entity: { + name_en: string | null; + name_ko: string | null; +}) { + return entity.name_en || entity.name_ko || "Untitled"; +} + +function secondaryName(entity: { + name_en: string | null; + name_ko: string | null; +}) { + return entity.name_en && entity.name_ko ? entity.name_ko : null; +} + +function metadataToText(metadata: Record | null | undefined) { + if (!metadata || Object.keys(metadata).length === 0) return ""; + return JSON.stringify(metadata, null, 2); +} + +function parseMetadata(text: string) { + const trimmed = text.trim(); + if (!trimmed) return null; + const parsed = JSON.parse(trimmed); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Metadata must be a JSON object"); + } + return parsed as Record; +} + +interface AddMemberPanelProps { + group: GroupSummary | null; + onCancel: () => void; +} + +function AddMemberPanel({ group, onCancel }: AddMemberPanelProps) { + const [artistSearch, setArtistSearch] = useState(""); + const [selectedArtist, setSelectedArtist] = + useState(null); + const [isActive, setIsActive] = useState(true); + const [metadataText, setMetadataText] = useState(""); + const [error, setError] = useState(null); + const createMember = useCreateGroupMember(); + const artistOptions = useGroupMemberArtistSearch( + artistSearch, + group?.id ?? "" + ); + + const handleSubmit = () => { + if (!group || !selectedArtist) return; + try { + createMember.mutate( + { + group_id: group.id, + artist_id: selectedArtist.id, + is_active: isActive, + metadata: parseMetadata(metadataText), + }, + { + onSuccess: () => { + setArtistSearch(""); + setSelectedArtist(null); + setMetadataText(""); + setIsActive(true); + setError(null); + onCancel(); + }, + onError: (err) => + setError( + err instanceof Error ? err.message : "Failed to add member" + ), + } + ); + } catch (err) { + setError(err instanceof Error ? err.message : "Invalid metadata JSON"); + } + }; + + return ( +
+
+
+

+ Add artist to {group ? displayName(group) : "group"} +

+

+ Search by artist name or Instagram username. +

+
+ +
+ +
+ +
+ + { + setArtistSearch(event.target.value); + setSelectedArtist(null); + }} + className="w-full rounded-lg bg-gray-700 border border-border pl-9 pr-3 py-2 text-sm text-gray-100 focus:outline-none focus:border-primary" + placeholder="Search artists or @username" + /> +
+
+ + {artistSearch && !selectedArtist && ( +
+ {artistOptions.isLoading ? ( +

+ Searching… +

+ ) : artistOptions.data?.data.length ? ( + artistOptions.data.data.map((artist) => ( + + )) + ) : ( +

+ No matching artists found. +

+ )} +
+ )} + + {selectedArtist && ( +
+

+ {displayName(selectedArtist)} +

+

+ {selectedArtist.primary_instagram_account?.username + ? `@${selectedArtist.primary_instagram_account.username}` + : selectedArtist.id} +

+
+ )} + + + +
+ +