[FE-25] 관리 상태 분류 시트 · 상세 편집 시트
작업 내용 (설계 의도)
근거: 지원 관리 확장 FE 웹 설계 S-16 관리 상태 분류 시트 · S-17 관리 상태 상세 편집 시트 · 상태 표기 규칙(관리 상태·우선순위) · Query 규약 > 낙관적 업데이트 · 신규 공용 도메인 컴포넌트 · API 연동 > 1단계의 S-16·S-17 행, 지원 관리 확장 TDD 1단계 — 관리 상태 (FR-73~77).
변경 사항
- 관리 상태를 붙이고 편집하는 공용 시트 2종과 표시 컴포넌트 2종을 소유합니다. 시트는 보관함(S-15)과 공고 상세(S-04) 양쪽에서 열리므로 페이지가 아니라
components/watchlist/에 두고, 자기 mutation을 스스로 소유합니다(컴포넌트 트리의 시트 예외 규칙). api/watchlist/watchState.ts(그룹 기준 PUT·GET·DELETE·histories + 공고 기준 PUT)와 대응 훅을 신규로 둡니다.- 저장 경로가 2종입니다(C-1 확정).
dedupGroupId가 있으면PUT /api/dedup-groups/{dedupGroupId}/watch-state, 없으면PUT /api/job-postings/{jobPostingId}/watch-state입니다. 후자는 서버가 그 공고의dedup_key로 단독(singleton) 그룹을 만든 뒤 저장하고 응답에dedupGroupId를 실어 줍니다. 이 분기는useSaveWatchState훅 하나가 흡수하고 시트·카드는 어느 경로인지 모릅니다 — 분기가 컴포넌트로 새면 카드·시트·상세 섹션 3곳에 같은 조건문이 복제됩니다. - 공고 기준 저장 응답의
dedupGroupId를 캐시에 반영해 다음 요청부터 그룹 기준 경로로 나가게 합니다. 같은 공고를 연속으로 분류할 때 매번 공고 기준 경로를 타면 서버가 매번 그룹 존재를 재확인합니다. - 이렇게 만든 단독 그룹은 다음 08:30 배치의 그룹 정체성 승계에 그대로 올라타므로 FE가 별도 이관을 하지 않습니다 — 배치가 계산한 클러스터가 이 공고를 포함해 멤버 교집합이 1 이상이 되고 기존 그룹이 재사용되어 관리 상태가 보존됩니다.
- S-16 분류 시트는 저장 버튼이 없습니다. 선택 = 확정입니다. 관심/지원 예정/제외 중 하나를 고르면 즉시 PUT하고 시트를 닫습니다. 3지선다에 저장 버튼을 얹으면 탭 수가 두 배가 됩니다.
- S-16에만 낙관적 업데이트를 적용합니다. 적용 범위는
status1개 필드뿐입니다 —onMutate에서 무한 쿼리 캐시의 해당 아이템watchStatus를 교체하고 스냅샷을 남기고,onError에서 스냅샷 복원 + 토스트(“분류를 저장하지 못했어요”),onSettled에서 무효화합니다. 성공 시 토스트를 띄우지 않습니다 — 칩이 바뀐 것 자체가 피드백입니다. 409 POSTING_DEDUP_KEY_ABSENT— 공고 기준 저장 경로에서만 발생합니다(백필 이전에 만들어져dedup_key가 아예 없는 잔존 공고). 낙관적 업데이트를 롤백하고 토스트 “이 공고는 아직 분류할 수 없어요 · 자정 배치 이후 다시 시도해 주세요” 를 띄웁니다. 재시도 액션은 주지 않습니다 — 사용자가 지금 할 수 있는 일이 없는데 버튼을 주면 실패를 반복시킵니다. 배포 절차 1-4 백필 완료 후에는 발생하지 않습니다.404 JOB_POSTING_NOT_FOUND— 롤백 + 토스트 “공고를 찾을 수 없어요” + 목록 무효화(재조회)합니다.제외를 고르면 시트 안에서 사유 입력 단계가 이어집니다. 계약상exclusionReason은 optional이므로 건너뛰어도 제외가 저장됩니다 — 입력을 강제하면 제외를 안 쓰게 되고, 그러면 보관함이 정리되지 않습니다.EXCLUDED가 아닌 상태로 전환할 때는exclusionReason: null을 함께 보냅니다. 계약상EXCLUDED가 아닌데 사유가 남아 있으면400 WATCH_STATE_INVALID_FIELD입니다.- S-17 상세 편집 시트는 낙관적 업데이트를 적용하지 않습니다. 우선순위·목표일·태그·메모를 한 번에 저장하는 다중 필드라, 부분 실패 시 어느 값이 되돌아갔는지 화면이 설명하지 못합니다. 서버 응답 후 무효화합니다.
- S-17에서 관리 상태(
status)는 바꾸지 않습니다 — S-16의 책임입니다. 다만 계약상status가 필수이므로 현재 값을 그대로 실어 보냅니다. - 태그 입력은 Enter 또는 쉼표로 확정하고, 30자 초과·11번째 태그는 입력 단계에서 차단하고 사유를 인라인으로 보여 줍니다. 서버 400을 기다리면 사용자가 이미 입력을 끝낸 뒤에 거절당합니다.
TagInput은 사용처가 S-17 1곳뿐이므로components/ui로 공용화하지 않고components/watchlist/에 둡니다(“두 번째 사용처가 확정된 것만 공용화” 규칙).- S-17 상세 편집 시트는 관리 상태 본문을 별도 조회하지 않습니다(C-1 — 왕복 절약). 공고 상세 응답의
watchState를 그대로 폼 초기값으로 씁니다. 이력만GET .../histories로 조회하며,dedupGroupId === null이면 이력 조회를 아예 하지 않습니다(그룹이 없으므로 이력도 없습니다). - 그룹 기준 조회를 직접 쓰는 경우 응답은 봉투입니다(B 확정) —
GET /api/dedup-groups/{id}/watch-state는{ dedupGroupId, watchState: WatchStateResponse | null }을 반환하며 단독WatchStateResponse가 아닙니다. 훅은 봉투를 벗겨watchState만 시트에 넘기고,dedupGroupId는 이후 저장 경로 판단에 씁니다. - 조회 결과는 3분기로 처리합니다(C 확정). 세 경우를 뭉뚱그리면 “지금 분류할 수 있는 상태”인지 사용자가 알 수 없습니다.
| 응답 | 의미 | 처리 |
|---|---|---|
200 { dedupGroupId, watchState: null } | 미분류 (정상) | 기본값 빈 폼 + 관리 상태 선택 UI 활성 + “아직 분류하지 않은 공고예요 · 저장하면 관심으로 등록돼요”. 오류·404 처리를 하지 않습니다 |
409 FEATURE_DISABLED | watchlist.management 플래그 OFF | 관리 상태 섹션을 숨기거나 “준비 중” 으로 표시. 롤백 토스트를 띄우지 않습니다 — 사용자 조작 실패가 아닙니다 |
404 DEDUP_GROUP_NOT_FOUND | 그룹이 실제로 없음 | 오류로 처리(토스트 + 목록 무효화). 이것만 404입니다 |
WATCH_STATE_NOT_FOUND는 폐기됐습니다(C 확정). 이 코드를 참조하는 분기를 만들지 않습니다. 미분류는 404가 아니라 200이므로 미분류 404 분기도 만들지 않습니다.- 이력 조회는 없으면 빈 배열이고
DELETE는 관리 상태가 없어도 204(멱등) 입니다(C 확정). 두 경로 모두 404 분기를 만들지 않습니다 — 이력이 0건이면 “변경 이력이 없어요”, 해제는 대상이 없어도 성공으로 처리합니다. watchState === null(관리 상태 미등록)은 에러가 아니라 빈 폼입니다 — 기본값(우선순위 보통)으로 열고 관리 상태 선택 UI를 활성한 채 안내 문구를 보여 줍니다. 이력 조회만 실패하면 이력 영역만 축소 에러로 처리하고 폼은 살립니다.- 변경 이력은 최근 3건만 인라인으로 두고
[전체 보기]는 같은 시트 내 확장입니다. 이력 전용 화면을 새로 만들지 않습니다. WatchStatusChip(관리 상태 3종 + 미분류)과WatchPriorityMark(▲/표시 없음/▼)를 공용 컴포넌트로 소유합니다. 신규 색 토큰 0건 —INTERESTED는--accent-subtle/--accent-on-subtle,PLANNED는 positive,EXCLUDED는 none +--text-tertiary, 미분류는 outlined neutral입니다. 우선순위는 색을 쓰지 않고 기호와 굵기로만 구분합니다.- 저장·해제 성공 시
['job-postings','cross-company']접두사와['dedup-groups', id]접두사를 무효화해 보관함·공고 상세가 함께 갱신되게 합니다. - 접근성: 상태 선택은
role="radiogroup"+aria-checked로 화살표 키 이동을 지원하고, 우선순위 기호에aria-label(“우선순위 높음”), 태그 삭제✕에aria-label="{태그명} 삭제"를 붙입니다.
롤백: 낙관적 업데이트 분기를 제거하면 서버 응답 후 무효화 방식으로 즉시 되돌아갑니다(동작은 느려지되 정합성은 동일).
의존
-
표시 칩 2종(
WatchStatusChip·WatchPriorityMark)은 FE-20이 소유합니다 — 이 티켓은 시트 2종과TagInput, mutation만 만들고 칩은 import만 합니다(같은 wave 의존 회피). -
FE-20 —
WatchStatus·WatchPriority·WatchStateResponse·봉투 응답 타입·WatchStateHistoryResponse·SaveWatchStateRequest타입,['dedup-groups', id, 'watch-state']계열 queryKey, 관리 상태 MSW 목(3분기 전환 fixture · 이력 빈 배열 ·DELETE204),utils/watchStatus,TextArea프리미티브. -
FE-22 — 보관함·공고 상세가 게이트 뒤에 있는 라우트 골격.
-
BE-50 — 관리 상태 API(그룹 기준 4종 + 공고 기준 PUT). 통합 검증은 BE-50 완료 후 수행합니다.
-
BE-52 — 공고 상세 확장 응답(
dedupGroupId·watchState). S-17이 관리 상태 본문을 별도 조회하지 않고 상세 응답에서 받으므로 이 계약이 전제입니다. -
시트를 실제 화면에 연결하는 작업은 FE-29(wave 3)가 합니다 — 이 티켓은 컴포넌트와 mutation만 소유합니다.
다이어그램
처리 흐름
sequenceDiagram participant User as 사용자 participant Sheet as WatchStateSheet participant Mutation as useSaveWatchState participant Cache as 보관함 무한 쿼리 캐시 participant Api as PUT watch-state User->>Sheet: 지원 예정 선택 Sheet->>Mutation: status 저장 요청 Mutation->>Cache: onMutate 스냅샷 + 칩 즉시 교체 Mutation->>Api: exclusionReason null 동봉 Api-->>Mutation: 실패 Mutation->>Cache: onError 스냅샷 복원 + 토스트
컴포넌트 의존
flowchart LR Sheet[WatchStateSheet] --> Save[useSaveWatchState] Detail[WatchStateDetailSheet] --> Save Detail --> Remove[useRemoveWatchState] Detail --> State["공고 상세 응답의 watchState"] Detail --> Env["봉투 dedupGroupId · watchState"] Env --> Three{3분기} Three -->|200 null| Unclassified[미분류 · 선택 UI 활성] Three -->|409| Disabled[FEATURE_DISABLED · 섹션 숨김] Three -->|404| NotFound[DEDUP_GROUP_NOT_FOUND · 오류] Detail --> Hist[useWatchStateHistories] Save --> Branch{dedupGroupId 유무} Branch -->|있음| Group[PUT dedup-groups] Branch -->|없음| Posting[PUT job-postings] Detail --> Tag[TagInput] Detail --> Area[TextArea FE-20] Save --> Api[api/watchlist/watchState] Chip[WatchStatusChip] --> Util[utils/watchStatus] Mark[WatchPriorityMark] --> Util
테스트 케이스
- 분류 시트에서 상태를 선택하면 별도 저장 버튼 없이 PUT이 발생하고 시트가 닫힌다.
- 분류 시트에서 상태를 선택하면 목록 카드의 관리 상태 칩이 응답 전에 즉시 바뀐다.
- 관리 상태 저장이 실패하면 카드 칩이 이전 값으로 롤백되고 토스트가 뜬다.
EXCLUDED가 아닌 상태로 전환하면 요청 본문에exclusionReason: null이 실린다.제외를 고르고 사유를 건너뛰어도 제외가 저장된다.404 DEDUP_GROUP_NOT_FOUND응답 시 토스트가 뜨고 목록 쿼리가 무효화된다.dedupGroupId가 있는 공고를 분류하면 저장 요청이PUT /api/dedup-groups/{id}/watch-state로 나간다.dedupGroupId === null인 공고를 분류하면 저장 요청이PUT /api/job-postings/{id}/watch-state로 나간다.- 공고 기준 저장 응답의
dedupGroupId가 캐시에 반영돼 같은 공고의 다음 저장은 그룹 기준 경로로 나간다. 409 POSTING_DEDUP_KEY_ABSENT응답 시 낙관적 업데이트가 롤백되고 “아직 분류할 수 없어요” 토스트가 뜨며 재시도 액션이 제공되지 않는다.404 JOB_POSTING_NOT_FOUND응답 시 롤백 + 토스트가 뜨고 목록 쿼리가 무효화된다.- 시트·카드 컴포넌트가 저장 경로를 직접 분기하지 않고
useSaveWatchState호출 시그니처가 두 경우에 동일하다. - 태그 11개째 입력은 입력 단계에서 차단되고 사유가 인라인으로 보이며 서버 요청이 발생하지 않는다.
- 31자 태그 입력이 입력 단계에서 차단되고 사유가 인라인으로 보인다.
- 상세 편집 시트 저장은 낙관적 업데이트 없이 응답 후에 목록이 갱신된다.
- 상세 편집 시트가 공고 상세 응답의
watchState === null을 받으면ErrorState대신 기본값 빈 폼과 안내 문구가 보인다. - 상세 편집 시트가 관리 상태 본문을 별도로 조회하지 않고 공고 상세 응답의
watchState를 폼 초기값으로 사용한다. dedupGroupId === null이면 이력 조회 요청이 발생하지 않고 “변경 이력이 없어요”가 보인다.- 관리 상태 그룹 조회 응답이 봉투
{ dedupGroupId, watchState }로 오면 훅이watchState만 벗겨 시트 초기값으로 넘긴다. - 관리 상태 조회가
200 { watchState: null }이면 미분류로 렌더되고 선택 UI가 활성이며 오류·404 처리가 발생하지 않는다. - 관리 상태 조회가
409 FEATURE_DISABLED이면 섹션이 숨겨지거나 “준비 중”으로 보이고 롤백 토스트가 뜨지 않는다. - 관리 상태 조회가
404 DEDUP_GROUP_NOT_FOUND이면 오류로 처리되어 미분류(200)와 구분된다. - 이력이 없는 그룹은 빈 배열을 받아 “변경 이력이 없어요”가 보이고 404 분기를 타지 않는다.
- 관리 상태가 없는 그룹에
DELETE를 보내면 204를 받아 정상 처리된다(멱등, 404 분기 없음). - 코드베이스에
WATCH_STATE_NOT_FOUND를 참조하는 분기가 0건이다. - 상세 편집 시트 저장 시 현재
status값이 요청에 그대로 실려 나간다. - 이력 조회만 실패하면 폼은 정상 렌더되고 이력 영역만 축소 에러로 보인다.
- 이력이 4건 이상이면 최근 3건만 보이고
[전체 보기]로 같은 시트 안에서 펼쳐진다. - 저장 성공 시 보관함과 공고 상세 쿼리 접두사가 함께 무효화된다.
WatchStatusChip이INTERESTED·PLANNED·EXCLUDED·미분류를 각각 다른 형태로 렌더한다.WatchPriorityMark가NORMAL에서는 아무 기호도 렌더하지 않는다.- 상태 선택 영역이
role="radiogroup"과aria-checked를 갖고 화살표 키로 이동한다. - 두 시트와 칩·마크를 다크 모드로 렌더하면 시맨틱 토큰 class만 사용하고 하드코딩 색이 0건이다.