[FE-46] 59점 상한 해제 컨트롤

작업 내용 (설계 의도)

근거: 지원 관리 확장 FE 웹 설계 S-24 지원 추천도 · 상태 표기 규칙(59점 상한 적용·해제됨) · 컴포넌트 트리 · Query 규약(낙관적 업데이트 2건 · 무효화 매핑) · API 연동 > 3단계 · Single Writer per File 검증 > 단계 3 wave 2, 지원 관리 확장 TDD 3단계 — 가중치 수정·상한 해제 (FR-97)POST·DELETE /api/job-postings/{jobPostingId}/recommendation/cap-release.

변경 사항

  • 필수 기술 미충족으로 적용된 59점 상한을 사용자가 해제·재적용할 수 있는 컨트롤(CapReleaseControl)을 만듭니다. 상한은 시스템 판단이지만 사용자가 “그래도 지원하겠다”고 결정할 수 있어야 하므로 토글 형태의 명시적 액션으로 둡니다.
  • 신규 파일만 추가합니다 — components/recommendation/에 컨트롤 1개, api/recommendation/capRelease.ts, hooks/recommendation/useRecommendationCap.ts. FE-36이 만든 기존 4개 컴포넌트(RecommendationScoreCard·RecommendationAxisList·RecommendationCapNotice·RecommendationGradeChip)는 수정하지 않습니다 — 같은 wave의 파일 충돌을 피하기 위한 제약입니다.
  • 공고 상세 배선은 이 티켓이 하지 않습니다. RecommendationSection에 컨트롤을 꽂는 작업은 wave 3의 FE-48이 수행합니다. 이 티켓은 props만 받는 독립 컴포넌트와 mutation 훅까지를 완결합니다.
  • 낙관적 업데이트를 적용합니다(설계가 허용한 2건 중 하나). 단일 토글이라 결과가 명확하고 서버 파생 필드가 화면에서 즉시 필요한 값이 아니므로, onMutate에서 추천도 쿼리 캐시를 스냅샷 → 낙관적 반영 → onError에서 복원 + “상한 해제를 저장하지 못했어요” 토스트로 롤백합니다.
  • capReleased === true여도 capReason을 계속 노출합니다(FR-97 명시 요구). 표시는 중립 톤(“상한 해제됨 · 원래 사유: {capReason}“)입니다. 사유를 지우면 왜 상한이 걸렸는지 기록이 사라져 다음 판단이 불가능해집니다.
  • 상태 표기는 기존 토큰 매핑만 씁니다 — 상한 적용 중(capApplied && !capReleased)은 filled warning, 해제됨은 outlined neutral + 원래 사유 병기. 신규 색 토큰 0건입니다.
  • 해제·재적용 성공 시 ['job-postings', id, 'recommendation']['job-postings','cross-company']를 무효화해 보관함 카드의 등급·점수까지 일치시킵니다. 상세만 갱신하면 목록의 점수가 옛 값으로 남습니다.
  • capApplied === false(상한이 적용되지 않은 공고)에서는 컨트롤을 렌더하지 않습니다. 할 일이 없는 액션을 노출하면 화면 위계가 흐려집니다.
  • 롤백: 해제는 DELETE로 즉시 재적용되므로 파괴적이지 않습니다. 요청 실패 시 낙관적 캐시가 원래 값으로 복원돼 화면과 서버가 어긋나지 않습니다.

의존

  • FE-40 — 상한 해제 API의 응답(RecommendationResponse) 소비와 상한 해제·재적용 MSW 목을 사용합니다.
  • FE-36 (단계 2) — 추천도 표시 컴포넌트와 같은 디렉토리를 쓰지만 기존 파일을 수정하지 않고 신규 파일만 추가합니다.
  • BE-72 — POST·DELETE .../recommendation/cap-release 구현.
  • 라우트 신설이 없어 FE-41에 의존하지 않습니다. 화면 배선은 FE-48이 이어받습니다.

다이어그램

처리 흐름

sequenceDiagram
    participant User as 사용자
    participant Control as CapReleaseControl
    participant Hook as useRecommendationCap
    participant Cache as Query 캐시
    participant API as cap-release API
    User->>Control: 상한 해제
    Control->>Hook: 해제 요청
    Hook->>Cache: 스냅샷 후 낙관적 반영
    Hook->>API: POST cap-release
    API-->>Hook: 200 재계산 결과 또는 실패
    Hook->>Cache: 실패 시 스냅샷 복원
    Hook-->>User: 실패 토스트 또는 갱신된 점수

컴포넌트 의존

flowchart LR
    Section[RecommendationSection · FE-48 배선] --> Control[CapReleaseControl]
    Control --> Hook[useRecommendationCap]
    Hook --> Api[releaseCap · restoreCap]
    Hook --> Keys[queryKeys recommendation]
    Control --> Notice[RecommendationCapNotice · 미수정]
    Control --> Util[utils/recommendation]

테스트 케이스

  • capApplied && !capReleased이면 상한 경고와 [상한 해제] 버튼이 보인다.
  • [상한 해제]를 누르면 화면이 즉시 해제 상태로 바뀌고 해제 요청이 전송된다.
  • 해제 요청이 실패하면 화면이 상한 적용 상태로 롤백되고 “상한 해제를 저장하지 못했어요” 토스트가 보인다.
  • capReleased === true이면 “상한 해제됨”과 원래 capReason이 함께 중립 톤으로 보인다.
  • 해제된 상태에서 재적용을 누르면 상한 적용 상태로 돌아가고 재적용 요청이 전송된다.
  • 재적용 요청이 실패하면 해제 상태로 롤백되고 실패 토스트가 보인다.
  • capApplied === false이면 컨트롤이 렌더되지 않는다.
  • 해제 성공 후 추천도 쿼리와 교차 회사 목록 쿼리가 무효화된다.
  • 요청 진행 중에는 토글이 비활성이라 중복 요청이 발생하지 않는다.
  • 컨트롤을 다크 모드로 렌더하면 시맨틱 토큰 class만 사용하고 하드코딩 색이 0건이다.