Skip to content
Merged
4 changes: 2 additions & 2 deletions frontend/src/features/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@ features/{X} → pages/*, app/* ✗
|---------|------|---------|
| `auth` | GitHub OAuth, 토큰 관리, 로그아웃, 동의 | US-01, US-02, US-03, US-04 |
| `resume` | 이력서 업로드, 목록, 삭제 | US-05, US-06 |
| `repo` (계획) | GitHub 레포 가져오기/등록/삭제 | US-07, US-08 |
| `analysis` (계획) | 분석 상태 표시, 재분석 | US-11, US-12 |
| `repo` | GitHub 후보 조회/등록/목록/삭제 | US-07, US-08 |
| `analysis` | 분석 문서 목록·요약·기술스택·원문(presigned) | US-11, US-12 |
| `interview` | 세션 생성·진행·종료, 메시지, 음성 | US-13~22 |
| `feedback` | 피드백 리포트, 점수, 키워드 | US-24, US-25 |
| `history` (계획) | 세션 히스토리 목록·상세, 통계 | US-15, US-16, US-26, US-27 |
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/features/analysis/api/analysis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { apiClient } from '@/shared/api'
import type { AnalyzedDocument } from '../model/types'

export type DocumentFilter = {
resumeId?: number
repositoryId?: number
}

export async function fetchDocuments(
filter: DocumentFilter = {},
): Promise<AnalyzedDocument[]> {
const response = await apiClient.get<AnalyzedDocument[]>('/api/documents', {
params: filter,
})
return response.data
}

// 상세 조회만 documentDownloadUrl(presigned) 을 포함한다.
export async function fetchDocument(id: number): Promise<AnalyzedDocument> {
const response = await apiClient.get<AnalyzedDocument>(`/api/documents/${id}`)
return response.data
}
11 changes: 11 additions & 0 deletions frontend/src/features/analysis/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export { DocumentList } from './ui/DocumentList'
export { useDocuments, documentKeys } from './model/useDocuments'
export { fetchDocuments, fetchDocument } from './api/analysis'
export type { DocumentFilter } from './api/analysis'
export type {
AnalyzedDocument,
AnalysisStatus,
AnalysisSourceType,
AnalysisEventPayload,
SseEnvelope,
} from './model/types'
43 changes: 43 additions & 0 deletions frontend/src/features/analysis/model/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
export type AnalysisStatus = 'PROCESSING' | 'ANALYZED' | 'FAILED'

export type AnalysisSourceType = 'RESUME' | 'REPOSITORY' | 'WEB'

export type AnalyzedDocument = {
id: number
sourceType: AnalysisSourceType
sourceId: number
documentPath: string | null
// 목록 응답에서는 비어 있고, 상세 조회 시에만 10분 TTL presigned URL 이 채워진다.
documentDownloadUrl: string | null
summary: string | null
techStack: string[]
embeddingChunkCount: number
analysisStatus: AnalysisStatus
errorCode: string | null
errorMessage: string | null
status: string
createdAt: string
updatedAt: string
}

// /api/stream/me 의 DOC_STATE / REPO_STATE 이벤트 data 의 payload 필드.
export type AnalysisEventPayload = {
targetType: AnalysisSourceType
targetId: number
status: string
summary?: string | null
techStack?: string[]
documentPath?: string | null
embeddingChunkCount?: number | null
errorCode?: string | null
errorMessage?: string | null
}

// 백엔드 SseEvent 봉투. event data 전체 형태.
export type SseEnvelope<T> = {
id: string | null
type: string
payload: T
timestamp: string
traceId: string | null
}
15 changes: 15 additions & 0 deletions frontend/src/features/analysis/model/useDocuments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/react-query'
import { fetchDocuments, type DocumentFilter } from '../api/analysis'
import type { AnalyzedDocument } from './types'

export const documentKeys = {
all: ['documents'] as const,
list: (filter: DocumentFilter) => ['documents', filter] as const,
}

export function useDocuments(filter: DocumentFilter = {}) {
return useQuery<AnalyzedDocument[]>({
queryKey: documentKeys.list(filter),
queryFn: () => fetchDocuments(filter),
})
}
131 changes: 131 additions & 0 deletions frontend/src/features/analysis/ui/DocumentList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { useState } from 'react'
import { isApiError } from '@/shared/api'
import { StatusBadge, type StatusTone } from '@/shared/ui'
import { fetchDocument, type DocumentFilter } from '../api/analysis'
import { useDocuments } from '../model/useDocuments'
import type {
AnalysisSourceType,
AnalysisStatus,
AnalyzedDocument,
} from '../model/types'

const STATUS_META: Record<AnalysisStatus, { tone: StatusTone; label: string }> =
{
PROCESSING: { tone: 'warning', label: '분석 중' },
ANALYZED: { tone: 'success', label: '분석 완료' },
FAILED: { tone: 'danger', label: '분석 실패' },
}

const SOURCE_LABEL: Record<AnalysisSourceType, string> = {
RESUME: '이력서',
REPOSITORY: '레포지토리',
WEB: '웹',
}

type Props = {
filter?: DocumentFilter
}

export function DocumentList({ filter = {} }: Props) {
const { data = [], isPending, isError, error } = useDocuments(filter)

if (isPending) {
return <p className="text-body text-fg-muted">분석 결과를 불러오는 중…</p>
}
if (isError) {
return (
<p className="text-body text-danger-700">
{isApiError(error) ? error.message : '분석 결과를 불러오지 못했습니다.'}
</p>
)
}
if (data.length === 0) {
return (
<div className="rounded-xl border border-dashed border-border-strong bg-surface p-10 text-center">
<p className="text-body text-fg-muted">아직 분석된 문서가 없습니다.</p>
<p className="text-caption text-fg-subtle mt-2">
이력서·레포 분석이 완료되면 요약과 기술 스택이 여기에 표시됩니다.
</p>
</div>
)
}

return (
<ul className="flex flex-col gap-3">
{data.map((doc) => (
<DocumentRow key={doc.id} doc={doc} />
))}
</ul>
)
}

function DocumentRow({ doc }: { doc: AnalyzedDocument }) {
const meta = STATUS_META[doc.analysisStatus]
const [opening, setOpening] = useState(false)
const [openError, setOpenError] = useState<string | null>(null)

const openSource = async () => {
setOpenError(null)
setOpening(true)
try {
const detail = await fetchDocument(doc.id)
if (detail.documentDownloadUrl) {
window.open(detail.documentDownloadUrl, '_blank', 'noreferrer')
} else {
setOpenError('다운로드 URL을 가져오지 못했습니다.')
}
} catch (e) {
setOpenError(isApiError(e) ? e.message : '원문을 열지 못했습니다.')
} finally {
setOpening(false)
}
}

return (
<li className="rounded-xl border border-border bg-surface-raised px-4 py-3">
<div className="flex items-center justify-between gap-4">
<span className="text-caption text-fg-subtle">
{SOURCE_LABEL[doc.sourceType]}
</span>
<StatusBadge tone={meta.tone}>{meta.label}</StatusBadge>
</div>

{doc.analysisStatus === 'ANALYZED' ? (
<>
{doc.summary ? (
<p className="text-body text-fg-strong mt-2">{doc.summary}</p>
) : null}
{doc.techStack.length > 0 ? (
<ul className="flex flex-wrap gap-1.5 mt-2">
{doc.techStack.map((tech) => (
<li
key={tech}
className="rounded-pill bg-surface px-2 py-0.5 text-caption text-fg-muted"
>
{tech}
</li>
))}
</ul>
) : null}
<button
type="button"
disabled={opening}
onClick={() => void openSource()}
className="text-caption text-primary hover:underline disabled:opacity-60 mt-2"
>
{opening ? '여는 중…' : '분석 원문 보기'}
</button>
{openError ? (
<p className="text-caption text-danger-700 mt-1">{openError}</p>
) : null}
</>
) : null}

{doc.analysisStatus === 'FAILED' ? (
<p className="text-caption text-danger-700 mt-2">
{doc.errorMessage ?? '분석에 실패했습니다.'}
</p>
) : null}
</li>
)
}
8 changes: 8 additions & 0 deletions frontend/src/features/auth/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,11 @@ export async function fetchCurrentUser(): Promise<AuthUser> {
export async function logout(): Promise<void> {
await apiClient.delete('/api/auth/logout')
}

export async function createStreamToken(): Promise<string> {
const response = await apiClient.post<{ streamToken: string }>(
'/api/auth/stream-token',
{},
)
return response.data.streamToken
}
1 change: 1 addition & 0 deletions frontend/src/features/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export {
completeGithubLogin,
fetchCurrentUser,
logout,
createStreamToken,
} from './api/auth'
export type {
AuthUser,
Expand Down
40 changes: 40 additions & 0 deletions frontend/src/features/repo/api/repo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { apiClient } from '@/shared/api'
import type {
CandidateRepository,
RegisteredRepository,
RegisterRepositoryInput,
} from '../model/types'

export async function fetchRegisteredRepositories(): Promise<
RegisteredRepository[]
> {
const response = await apiClient.get<RegisteredRepository[]>(
'/api/repositories',
)
return response.data
}

export async function fetchCandidateRepositories(
page: number,
perPage: number,
): Promise<CandidateRepository[]> {
const response = await apiClient.get<CandidateRepository[]>(
'/api/repositories/github',
{ params: { page, perPage } },
)
return response.data
}

export async function registerRepository(
input: RegisterRepositoryInput,
): Promise<RegisteredRepository> {
const response = await apiClient.post<RegisteredRepository>(
'/api/repositories',
input,
)
return response.data
}

export async function deleteRepository(id: number): Promise<void> {
await apiClient.delete(`/api/repositories/${id}`)
}
15 changes: 15 additions & 0 deletions frontend/src/features/repo/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export { RepoList } from './ui/RepoList'
export { RepoPicker } from './ui/RepoPicker'
export {
useRegisteredRepositories,
useCandidateRepositories,
useRegisterRepository,
useDeleteRepository,
repoKeys,
} from './model/useRepositories'
export type {
RegisteredRepository,
CandidateRepository,
RepositoryStatus,
RegisterRepositoryInput,
} from './model/types'
29 changes: 29 additions & 0 deletions frontend/src/features/repo/model/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
export type RepositoryStatus = 'PENDING' | 'ANALYZING' | 'ANALYZED' | 'FAILED'

export type RegisteredRepository = {
id: number
githubRepoId: number
repoName: string
repoFullName: string
repoUrl: string
defaultBranch: string | null
status: RepositoryStatus
lastSyncedAt: string | null
createdAt: string
updatedAt: string
}

export type CandidateRepository = {
githubRepoId: number
name: string
fullName: string
htmlUrl: string
defaultBranch: string | null
isPrivate: boolean
alreadyRegistered: boolean
}

export type RegisterRepositoryInput = {
githubRepoId: number
fullName: string
}
54 changes: 54 additions & 0 deletions frontend/src/features/repo/model/useRepositories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
deleteRepository,
fetchCandidateRepositories,
fetchRegisteredRepositories,
registerRepository,
} from '../api/repo'
import type { CandidateRepository, RegisteredRepository } from './types'

export const repoKeys = {
// registered 와 candidates 모두 ['repositories'] prefix → 한 번에 invalidate 가능
registered: ['repositories'] as const,
candidates: (page: number, perPage: number) =>
['repositories', 'github', page, perPage] as const,
}

export function useRegisteredRepositories() {
return useQuery<RegisteredRepository[]>({
queryKey: repoKeys.registered,
queryFn: fetchRegisteredRepositories,
})
}

export function useCandidateRepositories(
page: number,
perPage: number,
enabled: boolean,
) {
return useQuery<CandidateRepository[]>({
queryKey: repoKeys.candidates(page, perPage),
queryFn: () => fetchCandidateRepositories(page, perPage),
enabled,
})
}

export function useRegisterRepository() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: registerRepository,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: repoKeys.registered })
},
})
}

export function useDeleteRepository() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: deleteRepository,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: repoKeys.registered })
},
})
}
Loading