# Frontend Original Work Management Add-on Prompt 이 문서는 이미 구현 또는 구현 중인 AI 캐릭터 관리자 Frontend에 원작 관리 기능만 추가하기 위한 delta 프롬프트다. 기존 PRD 27.8의 stack, 인증·세션, 환경별 API Base URL, Jenkins, noindex, UTC/KST, feedback와 공통 API 규칙은 그대로 유지한다. ## Copy-Paste Prompt ```text 당신은 기존 AI 캐릭터 관리자 Frontend에 원작 관리 기능을 추가한다. [작업 원칙] - 기존 기술 stack, package version, lockfile, VITE_API_BASE_URL mode 설정과 Jenkins 명령을 변경하지 않는다. - .env.development와 .env.production은 같은 VITE_API_BASE_URL key에 서로 다른 실제 dev·production URL을 유지하고 source code에 URL을 하드코딩하지 않는다. - Jenkins의 install·검증 명령은 기존 pnpm install --frozen-lockfile, pnpm run ci:prod를 그대로 사용한다. - 기존 로그인, sessionStorage, fetch wrapper, ApiResponse envelope, error handling, noindex, UTC/KST와 Sonner 규칙을 재사용한다. - 아래 실제 Request/Response JSON과 명시된 nullable JSON 값으로 TypeScript type, API 함수와 mock을 만든다. Backend DTO/data class의 존재나 이름을 전제하지 않는다. - legacy /admin/chat/original/**와 /api/chat/original/**를 호출하지 않는다. - 메뉴는 Backend에서 조회하지 않고 기존 typed static menu configuration에 추가한다. - 새 원작 route에도 기존 접근 제어와 noindex 정책을 동일하게 적용하고 검색 노출 가능한 public route나 metadata를 만들지 않는다. - 도메인별 화면을 범용 CRUD 설정 하나로 합치지 않는다. 두 화면 이상에서 실제 반복되는 UI와 동작만 component로 추출한다. [완료 목표] - global 원작 목록·검색·상세·등록·수정·삭제 화면을 제공한다. - 원작 상세에서 연결된 활성·비활성 캐릭터를 조회하고, 활성 AI 캐릭터를 배정하며 기존 연결을 해제할 수 있게 한다. - 기존 캐릭터 등록·수정 form에서 원작을 검색·선택하거나 연결을 해제할 수 있게 한다. - 아래 ORIGINAL-WORK-01~08을 기존 API client, query key, 화면과 test에 연결한다. - 기존 58개 신규 관리자 Operation에 8개를 더한 66개 신규 관리자 Operation을 브라우저에서 사용할 수 있게 한다. [메뉴와 route] - 기존 typed static menu에 다음 global item을 추가한다. { key: "original-works", label: "원작 관리", to: "/ai-characters/original-works", scope: "GLOBAL" } - 원작 관리는 character-scoped 메뉴가 아니며 진입 전에 캐릭터를 선택하지 않는다. - route는 다음을 사용한다. /ai-characters/original-works /ai-characters/original-works/new /ai-characters/original-works/:originalWorkId /ai-characters/original-works/:originalWorkId/edit - /ai-characters/original-works의 static segment가 /ai-characters/:characterId보다 우선 매칭되는지 route test로 고정한다. [Page와 Dialog 결정] - 원작 목록, 상세, 등록, 수정은 Page로 만든다. 서버 페이징, URL 복원, 이미지와 다중 field form이 있으므로 Dialog로 만들지 않는다. - 원작 상세의 캐릭터 배정은 검색·다중 선택 Dialog로 만든다. - 연결 캐릭터 해제는 table row action 또는 table bulk action으로 제공하고 실행 전에 AlertDialog로 확인한다. - 원작 삭제는 AlertDialog로 확인한다. characterCount가 0보다 크면 UI에서 이유를 표시하고 비활성화하되, race condition에 대한 Backend 409도 처리한다. - 캐릭터 등록·수정 form의 원작 선택은 form 안의 searchable Combobox 또는 선택 Dialog로 구현한다. 별도 사전 선택 Page를 추가하지 않는다. [캐릭터 등록·수정의 원작 선택] - ORIGINAL-WORK-01의 page, size, search를 사용해 isDeleted=false 원작을 검색한다. - 캐릭터 생성 form의 선택값이 없으면 CHAR-03 request.originalWorkId에 null을 보낸다. - 캐릭터 수정 form은 CHAR-02 response.originalWork의 id, title, imageUrl로 현재 선택을 복원한다. - CHAR-02 response.originalWork 자체는 null일 수 있다. null이면 현재 원작 미선택 상태로 복원하고 ORIGINAL-WORK-02를 호출하지 않는다. - 수정 form에서 선택을 지우면 CHAR-04 request.originalWorkId에 명시적 null을 보낸다. - originalWorkId=0을 보내지 않는다. - CHAR-04는 기존 계약대로 originalWorkId key 자체를 반드시 포함한다. - 수정 진입 시 현재 선택된 원작이 첫 검색 page에 없어도 CHAR-02의 brief를 선택값으로 유지하고, 사용자가 검색 결과를 선택할 때만 교체한다. - 현재 brief의 ORIGINAL-WORK-02가 404이면 legacy 데이터의 삭제·누락 원작 연결로 표시하고, 새 원작 선택 또는 명시적 해제 전에는 수정을 제출하지 않는다. [원작 form] - 생성 기본값은 isAdult=false, description="", nullable string=null, originalLinks=[], tags=[]다. - 수정 요청은 아래 11개 JSON key를 항상 모두 보낸다. title, contentType, category, isAdult, description, originalWork, originalLink, writer, studio, originalLinks, tags - nullable string을 지울 때 null, 링크와 태그를 모두 지울 때 []를 보낸다. update key를 생략하지 않는다. - title, contentType, category는 trim 후 필수다. - originalLink와 originalLinks는 http/https 절대 URL만 허용한다. - originalLinks와 tags는 trim하고 빈 값을 제거하며 첫 등장 순서를 유지해 중복을 제거한다. - create의 image는 필수, update의 image는 선택이다. update에서 새 image를 선택하지 않으면 image part를 보내지 않는다. - multipart request part에는 아래 Request JSON을 JSON.stringify한 문자열을 넣고 multipart 전체 Content-Type은 직접 지정하지 않는다. [재사용 component] - OriginalWorkForm: create와 edit Page가 공유하되 create/update API 호출은 각 Page에 둔다. - OriginalWorkSelector: 캐릭터 create/edit form이 공유한다. - OriginalWorkSummaryCell: 원작 목록과 캐릭터 form의 선택 결과가 공유한다. - AssignedCharacterTable: 원작 상세의 연결 캐릭터 목록과 bulk selection을 담당한다. - CharacterAssignmentDialog: CHAR-01 검색과 ORIGINAL-WORK-07 호출을 담당한다. - 기존 Pagination, DataTable, PageHeader, LoadingState, EmptyState, ErrorState, FormErrorSummary, Confirm AlertDialog를 재사용한다. - 원작 전용 validation과 캐릭터 배정 규칙을 generic CRUD schema로 추상화하지 않는다. [Query key와 invalidation] - 목록: ["original-works", { page, size, search }] - 상세: ["original-work", originalWorkId] - 연결 캐릭터: ["original-work", originalWorkId, "characters", { page, size, search, isActive }] - 캐릭터 배정 후보는 기존 CHAR-01 global query를 isActive=true로 조회한다. - ORIGINAL-WORK-03 성공 후 원작 목록을 invalidate한다. - ORIGINAL-WORK-04 성공 후 해당 상세와 원작 목록을 invalidate하고, title/image brief가 바뀔 수 있으므로 ["ai-character"] prefix의 CHAR-02 상세 query도 invalidate한다. - ORIGINAL-WORK-05 성공 후 해당 상세를 제거하고 목록으로 이동한 뒤 원작 목록을 invalidate한다. - ORIGINAL-WORK-07/08 성공 후 원작 목록 전체와 ["original-work"] prefix의 상세·연결 캐릭터 query를 invalidate한다. 배정은 이전 원작에서 이동할 수 있으므로 새 원작 cache만 갱신하지 않는다. 응답 characterIds 각각의 CHAR-02 query도 invalidate한다. - CHAR-03/04 성공 후 새 원작과 이전 원작이 있으면 해당 원작 상세·연결 캐릭터 목록·목록의 characterCount를 invalidate한다. - CHAR-05 성공 후 삭제 캐릭터가 연결된 원작의 연결 캐릭터 목록과 characterCount를 invalidate한다. [배정·해제 UX] - 배정 후보는 CHAR-01의 활성 AI 캐릭터만 표시한다. - 배정 확인문에 “다른 원작에 연결된 캐릭터는 이 원작으로 이동합니다”를 명시한다. - ORIGINAL-WORK-07은 선택한 characterIds 전체를 한 요청으로 전송한다. - ORIGINAL-WORK-08은 DELETE method와 JSON body를 함께 사용하고 Content-Type: application/json을 명시한다. - 배정·해제에서 일부 성공을 가정하지 않는다. 성공 Response 뒤에만 선택을 비우고 toast를 표시한다. - 배정·해제 성공 Response의 characterIds는 실제 변경 여부와 관계없이 검증된 요청 ID 전체가 요청 순서대로 반환된다. characterCount는 처리 건수가 아니라 작업 후 해당 원작에 연결된 전체 캐릭터 수다. - 연결 목록의 creatorId는 legacy 불일치 정리를 위해 null일 수 있다. null이면 캐릭터 row는 계속 표시하고 “연결 크리에이터 없음” 상태를 표시하며 해제 action을 숨기지 않는다. - 400 errorProperty=characterIds는 Dialog의 selection error로 표시한다. - 409 errorProperty=title은 trim·대소문자 무시 중복 제목 field error로 표시한다. - 409 errorProperty=characterIds는 비활성 상태 변경 가능성을 설명하고 후보 목록을 refetch한다. - 409 errorProperty=originalWorkId는 삭제된 원작 또는 연결 캐릭터가 남은 삭제 요청으로 처리하고 상세·연결 목록을 refetch한다. - originalWorkId와 원작 route param은 양수만 전송한다. 0 이하는 client validation으로 차단하고, 404 originalWorkId는 누락·삭제된 조회 대상으로, mutation의 409 originalWorkId는 삭제 상태 또는 연결 존재 충돌로 구분해 처리한다. - 이미 삭제가 성공한 원작의 DELETE 재시도는 같은 성공 Response가 올 수 있으므로 오류로 간주하지 않는다. [날짜와 feedback] - createdAtUtc와 updatedAtUtc는 UTC Z 원문으로 cache·비교하고 기존 formatUtcInKst utility로 KST 표시한다. - mutation 성공은 기존 Sonner success toast, field 오류는 inline, 최초 조회 실패는 ErrorState, 삭제·해제 사전 확인은 AlertDialog 규칙을 유지한다. - characterCount에는 활성·비활성 및 연결 creator 상태와 관계없이 모든 연결 캐릭터가 포함되므로 UI에서 임의로 다시 계산하지 않는다. [응답 nullable JSON 값] - 아래 값은 기존 Operation Response JSON의 해당 위치에 나타날 수 있는 유효 JSON이다. 예시의 non-null 값만 보고 required string/number로 좁히지 않는다. - ORIGINAL-WORK-01 data.items[0]과 ORIGINAL-WORK-02 data의 imageUrl, createdAtUtc, updatedAtUtc는 null일 수 있다. - ORIGINAL-WORK-02 data의 originalWork, originalLink, writer, studio는 null일 수 있다. - ORIGINAL-WORK-06 data.items[0]의 creatorId, imageUrl, createdAtUtc는 null일 수 있다. - CHAR-02 data.originalWork는 아래 null 또는 brief 객체다. brief의 imageUrl도 null일 수 있다. ORIGINAL-WORK-01 data.items[0] nullable JSON: { "originalWorkId": 71, "title": "달빛 도서관", "contentType": "WEB_NOVEL", "category": "FANTASY", "isAdult": false, "imageUrl": null, "characterCount": 2, "createdAtUtc": null, "updatedAtUtc": null } ORIGINAL-WORK-02 data nullable JSON: { "originalWorkId": 71, "title": "달빛 도서관", "contentType": "WEB_NOVEL", "category": "FANTASY", "isAdult": false, "description": "", "originalWork": null, "originalLink": null, "writer": null, "studio": null, "originalLinks": [], "tags": [], "imageUrl": null, "characterCount": 0, "createdAtUtc": null, "updatedAtUtc": null } ORIGINAL-WORK-06 data.items[0] nullable JSON: { "characterId": 101, "creatorId": null, "name": "루나", "imageUrl": null, "isActive": false, "createdAtUtc": null } CHAR-02 data.originalWork nullable JSON: null CHAR-02 data.originalWork brief nullable JSON: { "id": 71, "title": "달빛 도서관", "imageUrl": null } [Operation Catalog] 공통 성공 envelope key는 success, message, data, errorProperty다. 공통 page data key는 items, page, size, totalCount, hasNext다. 공통 오류 Response JSON: {"success":false,"message":"요청을 처리할 수 없습니다.","data":null,"errorProperty":"originalWorkId"} 배정·해제 field 오류 Response JSON: {"success":false,"message":"캐릭터 선택을 확인해 주세요.","data":null,"errorProperty":"characterIds"} 중복 제목 오류 Response JSON: {"success":false,"message":"이미 사용 중인 원작 제목입니다.","data":null,"errorProperty":"title"} ORIGINAL-WORK-01 GET /admin/ai-characters/original-works Path: 없음 Query: page=0&size=20&search=달빛 Request JSON: 없음 Response JSON: { "success": true, "message": null, "data": { "items": [{ "originalWorkId": 71, "title": "달빛 도서관", "contentType": "WEB_NOVEL", "category": "FANTASY", "isAdult": false, "imageUrl": "https://cdn.example.com/originals/71/original.webp", "characterCount": 2, "createdAtUtc": "2026-07-20T01:00:00Z", "updatedAtUtc": "2026-07-20T02:00:00Z" }], "page": 0, "size": 20, "totalCount": 1, "hasNext": false }, "errorProperty": null } search는 title, contentType, category의 대소문자 무시 부분 검색이다. ORIGINAL-WORK-02 GET /admin/ai-characters/original-works/{originalWorkId} Request JSON: 없음 Response JSON: { "success": true, "message": null, "data": { "originalWorkId": 71, "title": "달빛 도서관", "contentType": "WEB_NOVEL", "category": "FANTASY", "isAdult": false, "description": "밤에만 문을 여는 도서관의 이야기", "originalWork": "Moonlight Library", "originalLink": "https://example.com/works/71", "writer": "김작가", "studio": "소다 스튜디오", "originalLinks": [ "https://example.com/works/71", "https://example.com/works/71/official" ], "tags": ["힐링", "판타지"], "imageUrl": "https://cdn.example.com/originals/71/original.webp", "characterCount": 2, "createdAtUtc": "2026-07-20T01:00:00Z", "updatedAtUtc": "2026-07-20T02:00:00Z" }, "errorProperty": null } ORIGINAL-WORK-03 POST /admin/ai-characters/original-works Content-Type: multipart/form-data Parts: image 필수, request 필수 JSON string Request JSON: { "title": "달빛 도서관", "contentType": "WEB_NOVEL", "category": "FANTASY", "isAdult": false, "description": "밤에만 문을 여는 도서관의 이야기", "originalWork": "Moonlight Library", "originalLink": "https://example.com/works/71", "writer": "김작가", "studio": "소다 스튜디오", "originalLinks": [ "https://example.com/works/71", "https://example.com/works/71/official" ], "tags": ["힐링", "판타지"] } Response JSON: { "success": true, "message": null, "data": { "originalWorkId": 71, "isDeleted": false }, "errorProperty": null } ORIGINAL-WORK-04 PUT /admin/ai-characters/original-works/{originalWorkId} Content-Type: multipart/form-data Parts: image 선택, request 필수 JSON string Request JSON: { "title": "달빛 도서관 개정판", "contentType": "WEB_NOVEL", "category": "FANTASY", "isAdult": false, "description": "개정된 작품 소개", "originalWork": null, "originalLink": null, "writer": "김작가", "studio": "소다 스튜디오", "originalLinks": [], "tags": ["판타지"] } Response JSON: { "success": true, "message": null, "data": { "originalWorkId": 71, "isDeleted": false }, "errorProperty": null } ORIGINAL-WORK-05 DELETE /admin/ai-characters/original-works/{originalWorkId} Request JSON: 없음 Response JSON: { "success": true, "message": null, "data": { "originalWorkId": 71, "isDeleted": true }, "errorProperty": null } 삭제되지 않은 원작에 연결된 캐릭터가 하나라도 있으면 409다. 이미 삭제된 원작의 재시도는 같은 성공 Response다. ORIGINAL-WORK-06 GET /admin/ai-characters/original-works/{originalWorkId}/characters Query: page=0&size=20&search=루나&isActive=true Request JSON: 없음 Response JSON: { "success": true, "message": null, "data": { "items": [{ "characterId": 101, "creatorId": 10001, "name": "루나", "imageUrl": "https://cdn.example.com/characters/101.webp", "isActive": true, "createdAtUtc": "2026-07-20T01:30:00Z" }], "page": 0, "size": 20, "totalCount": 1, "hasNext": false }, "errorProperty": null } isActive를 생략하면 활성·비활성 연결 캐릭터를 모두 반환한다. creatorId는 null일 수 있다. ORIGINAL-WORK-07 POST /admin/ai-characters/original-works/{originalWorkId}/characters Content-Type: application/json Request JSON: { "characterIds": [101, 102] } Response JSON: { "success": true, "message": null, "data": { "originalWorkId": 71, "characterIds": [101, 102], "characterCount": 2 }, "errorProperty": null } ORIGINAL-WORK-08 DELETE /admin/ai-characters/original-works/{originalWorkId}/characters Content-Type: application/json Request JSON: { "characterIds": [101, 102] } Response JSON: { "success": true, "message": null, "data": { "originalWorkId": 71, "characterIds": [101, 102], "characterCount": 0 }, "errorProperty": null } [필수 test] - static 원작 route가 dynamic :characterId route보다 우선한다. - 원작 목록의 page, search, empty, loading, error와 KST 날짜 표시가 동작한다. - create는 image를 필수로 보내고 update는 선택 image를 생략할 수 있다. - update가 nullable key를 누락하지 않고 null과 []를 정확히 직렬화한다. - 원작 삭제가 characterCount>0에서 UI상 차단되고 Backend 409 race도 표시한다. - 배정이 다른 원작 연결 이동 경고를 표시하고 성공 뒤 관련 query를 invalidate한다. - 같은 원작 반복 배정에서도 Response characterIds가 요청 순서를 유지하고 characterCount를 처리 건수로 오인하지 않는다. - creatorId=null인 연결 캐릭터를 fallback 상태로 표시하고 해제할 수 있다. - DELETE body 해제가 정확한 method, header와 JSON body를 전송한다. - 캐릭터 생성에서 미선택 null, 수정에서 현재 원작 복원, 명시적 해제 null을 전송하고 0을 보내지 않는다. - CHAR-02 originalWork=null을 미선택 상태로 복원하고, 원작·연결 캐릭터 Response의 모든 nullable field가 null이어도 화면과 typecheck가 정상 동작한다. - 401, 403, 400 originalWorkId/characterIds, 404 originalWorkId, 409 title/originalWorkId/characterIds와 최초 조회 실패가 기존 feedback 규칙을 따른다. - API가 반환한 UTC Z를 유지하고 화면에서만 Asia/Seoul로 표시한다. [검증과 결과물] - 기존 package scripts와 Jenkins의 pnpm install --frozen-lockfile, pnpm run ci:prod 경로가 그대로 통과해야 한다. - typecheck, ESLint, unit/UI test와 production build를 모두 실행한다. - 핵심 E2E에 원작 CRUD, 캐릭터 배정·해제, 캐릭터 form의 원작 선택·해제를 추가한다. - README 또는 기존 API 문서에 원작 route, 8개 Operation, DELETE JSON body와 캐시 무효화 규칙을 추가한다. - 구현하지 못한 항목은 숨기지 말고 Operation ID와 이유를 명시한다. ```