feat: 권리확인 탭 화면(목록/검색/업로드/판정 상세) 추가
기관관리 상세의 권리확인 탭을 자리표시에서 실제 화면으로 교체한다. 목록은 검색·페이지네이션·엑셀 업로드/다운로드와 처리결과 배지를 보여주고, 상세는 권리확인 대분류/세부에 따라 처리결과를 추천하되(수동 선택 시 유지) 변호사 판정을 저장할 수 있게 한다.
@12b5a3e2fdf9832b7e7bc4aec1b5fd94c8e09afc
--- frontend/src/api/client.ts
+++ frontend/src/api/client.ts
... | ... | @@ -304,6 +304,125 @@ |
| 304 | 304 |
} |
| 305 | 305 |
} |
| 306 | 306 |
|
| 307 |
+/** 권리확인 탭 게시물 한 건. W열은 원본 시트의 빈 열이라 대응 필드가 없다. */ |
|
| 308 |
+export interface ReviewItem {
|
|
| 309 |
+ id: number |
|
| 310 |
+ orgId: number |
|
| 311 |
+ seq: number |
|
| 312 |
+ siteName: string | null |
|
| 313 |
+ category: string | null |
|
| 314 |
+ boardPath: string | null |
|
| 315 |
+ boardName: string | null |
|
| 316 |
+ postTitle: string | null |
|
| 317 |
+ url: string | null |
|
| 318 |
+ postRegistered: string | null |
|
| 319 |
+ producedDate: string | null |
|
| 320 |
+ publishedDate: string | null |
|
| 321 |
+ hasAttachment: string | null |
|
| 322 |
+ koglAttached: string | null |
|
| 323 |
+ koglType: string | null |
|
| 324 |
+ aiType: string | null |
|
| 325 |
+ surveyorNote: string | null |
|
| 326 |
+ openable: string | null |
|
| 327 |
+ reviewMajor: string | null |
|
| 328 |
+ reviewMinor: string | null |
|
| 329 |
+ reviewResult: string | null |
|
| 330 |
+ judgedKoglType: string | null |
|
| 331 |
+ judgedAiType: string | null |
|
| 332 |
+ opinion: string | null |
|
| 333 |
+ lawyerNote: string | null |
|
| 334 |
+ needsProcessing: string | null |
|
| 335 |
+ webEditedAt: number | null |
|
| 336 |
+ createdAt: number |
|
| 337 |
+ updatedAt: number |
|
| 338 |
+} |
|
| 339 |
+ |
|
| 340 |
+/** total은 현재 keyword 필터 기준 건수(페이지 계산용), done은 검색어와 무관한 기관 전체 처리 건수다. */ |
|
| 341 |
+export interface ReviewPage {
|
|
| 342 |
+ items: ReviewItem[] |
|
| 343 |
+ total: number |
|
| 344 |
+ done: number |
|
| 345 |
+ page: number |
|
| 346 |
+ size: number |
|
| 347 |
+} |
|
| 348 |
+ |
|
| 349 |
+export interface ReviewImportReport {
|
|
| 350 |
+ created: number |
|
| 351 |
+ updated: number |
|
| 352 |
+ total: number |
|
| 353 |
+} |
|
| 354 |
+ |
|
| 355 |
+/** 권리확인 상세의 [수정] 저장 요청. 전부 선택값이다. */ |
|
| 356 |
+export interface JudgmentRequest {
|
|
| 357 |
+ openable: string | null |
|
| 358 |
+ reviewMajor: string | null |
|
| 359 |
+ reviewMinor: string | null |
|
| 360 |
+ reviewResult: string | null |
|
| 361 |
+ judgedKoglType: string | null |
|
| 362 |
+ judgedAiType: string | null |
|
| 363 |
+ opinion: string | null |
|
| 364 |
+ lawyerNote: string | null |
|
| 365 |
+ needsProcessing: string | null |
|
| 366 |
+} |
|
| 367 |
+ |
|
| 368 |
+export function importReview(orgId: number, file: File): Promise<ReviewImportReport> {
|
|
| 369 |
+ const form = new FormData() |
|
| 370 |
+ form.append('file', file)
|
|
| 371 |
+ return request<ReviewImportReport>(`/api/orgs/${orgId}/review/import`, { method: 'POST', body: form })
|
|
| 372 |
+} |
|
| 373 |
+ |
|
| 374 |
+export function getReviewPage( |
|
| 375 |
+ orgId: number, |
|
| 376 |
+ params: { keyword?: string; page?: number; size?: number } = {},
|
|
| 377 |
+): Promise<ReviewPage> {
|
|
| 378 |
+ const query = new URLSearchParams() |
|
| 379 |
+ if (params.keyword) {
|
|
| 380 |
+ query.set('keyword', params.keyword)
|
|
| 381 |
+ } |
|
| 382 |
+ if (params.page !== undefined) {
|
|
| 383 |
+ query.set('page', String(params.page))
|
|
| 384 |
+ } |
|
| 385 |
+ if (params.size !== undefined) {
|
|
| 386 |
+ query.set('size', String(params.size))
|
|
| 387 |
+ } |
|
| 388 |
+ const qs = query.toString() |
|
| 389 |
+ return request<ReviewPage>(`/api/orgs/${orgId}/review${qs ? `?${qs}` : ''}`)
|
|
| 390 |
+} |
|
| 391 |
+ |
|
| 392 |
+export function getReviewItem(orgId: number, itemId: number): Promise<ReviewItem> {
|
|
| 393 |
+ return request<ReviewItem>(`/api/orgs/${orgId}/review/${itemId}`)
|
|
| 394 |
+} |
|
| 395 |
+ |
|
| 396 |
+export function updateReviewJudgment( |
|
| 397 |
+ orgId: number, |
|
| 398 |
+ itemId: number, |
|
| 399 |
+ body: JudgmentRequest, |
|
| 400 |
+): Promise<ReviewItem> {
|
|
| 401 |
+ return request<ReviewItem>(`/api/orgs/${orgId}/review/${itemId}`, {
|
|
| 402 |
+ method: 'PUT', |
|
| 403 |
+ headers: { 'Content-Type': 'application/json' },
|
|
| 404 |
+ body: JSON.stringify(body), |
|
| 405 |
+ }) |
|
| 406 |
+} |
|
| 407 |
+ |
|
| 408 |
+/** 204를 돌려주므로 request()의 json() 경로를 타지 않고 직접 부른다. */ |
|
| 409 |
+export async function deleteReviewItem(orgId: number, itemId: number): Promise<void> {
|
|
| 410 |
+ const response = await fetch(`/api/orgs/${orgId}/review/${itemId}`, {
|
|
| 411 |
+ method: 'DELETE', |
|
| 412 |
+ credentials: 'same-origin', |
|
| 413 |
+ headers: { 'X-XSRF-TOKEN': csrfToken() },
|
|
| 414 |
+ }) |
|
| 415 |
+ if (!response.ok) {
|
|
| 416 |
+ const body = await response.text() |
|
| 417 |
+ throw new ApiError(response.status, `요청 실패 (${response.status}): ${body}`)
|
|
| 418 |
+ } |
|
| 419 |
+} |
|
| 420 |
+ |
|
| 421 |
+/** 다운로드는 <a href>가 그대로 열게 두므로 fetch()를 거치지 않는다. */ |
|
| 422 |
+export function reviewDownloadUrl(orgId: number): string {
|
|
| 423 |
+ return `/api/orgs/${orgId}/review/download`
|
|
| 424 |
+} |
|
| 425 |
+ |
|
| 307 | 426 |
export function uploadSeed(file: File): Promise<SeedReport> {
|
| 308 | 427 |
const form = new FormData() |
| 309 | 428 |
form.append('file', file)
|
--- frontend/src/components/OrgOverview.test.tsx
+++ frontend/src/components/OrgOverview.test.tsx
... | ... | @@ -12,6 +12,7 @@ |
| 12 | 12 |
getTimeline: vi.fn(), |
| 13 | 13 |
updateStage: vi.fn(), |
| 14 | 14 |
getStageHistory: vi.fn(), |
| 15 |
+ getReviewPage: vi.fn(), |
|
| 15 | 16 |
})) |
| 16 | 17 |
|
| 17 | 18 |
vi.mock('../api/client', async (importOriginal) => ({
|
... | ... | @@ -24,6 +25,7 @@ |
| 24 | 25 |
getTimeline: mocks.getTimeline, |
| 25 | 26 |
updateStage: mocks.updateStage, |
| 26 | 27 |
getStageHistory: mocks.getStageHistory, |
| 28 |
+ getReviewPage: mocks.getReviewPage, |
|
| 27 | 29 |
})) |
| 28 | 30 |
|
| 29 | 31 |
function contact(overrides: Partial<Contact> = {}): Contact {
|
... | ... | @@ -76,6 +78,7 @@ |
| 76 | 78 |
mocks.getStageHistory.mockReset().mockResolvedValue([]) |
| 77 | 79 |
mocks.getTimeline.mockReset().mockResolvedValue([]) |
| 78 | 80 |
mocks.updateStage.mockReset().mockResolvedValue(undefined) |
| 81 |
+ mocks.getReviewPage.mockReset().mockResolvedValue({ items: [], total: 0, done: 0, page: 0, size: 30 })
|
|
| 79 | 82 |
}) |
| 80 | 83 |
|
| 81 | 84 |
describe('OrgOverview', () => {
|
... | ... | @@ -261,14 +264,26 @@ |
| 261 | 264 |
await waitFor(() => expect(mocks.getPosts).toHaveBeenCalledWith(1, 'mj')) |
| 262 | 265 |
}) |
| 263 | 266 |
|
| 264 |
- it('탭을 누르면 해당 탭의 자리표시 내용으로 바뀐다', async () => {
|
|
| 267 |
+ it('권리확인 탭을 누르면 권리확인 보드가 마운트되어 조회를 부른다', async () => {
|
|
| 265 | 268 |
render(<OrgOverview org={org()} />)
|
| 266 | 269 |
|
| 267 | 270 |
await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled()) |
| 268 | 271 |
|
| 269 | 272 |
fireEvent.click(screen.getByRole('button', { name: '권리확인' }))
|
| 270 | 273 |
|
| 271 |
- expect(screen.getByText('권리확인 상세 화면은 추후 확정')).toBeTruthy()
|
|
| 274 |
+ await waitFor(() => expect(mocks.getReviewPage).toHaveBeenCalledWith(1, expect.anything())) |
|
| 275 |
+ expect(screen.queryByText('권리확인 상세 화면은 추후 확정')).toBeNull()
|
|
| 276 |
+ expect(screen.queryByRole('button', { name: '문정원' })).toBeNull()
|
|
| 277 |
+ }) |
|
| 278 |
+ |
|
| 279 |
+ it('탭을 누르면 해당 탭의 자리표시 내용으로 바뀐다', async () => {
|
|
| 280 |
+ render(<OrgOverview org={org()} />)
|
|
| 281 |
+ |
|
| 282 |
+ await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled()) |
|
| 283 |
+ |
|
| 284 |
+ fireEvent.click(screen.getByRole('button', { name: '권리처리' }))
|
|
| 285 |
+ |
|
| 286 |
+ expect(screen.getByText('권리처리 상세 화면은 추후 확정')).toBeTruthy()
|
|
| 272 | 287 |
expect(screen.queryByRole('button', { name: '문정원' })).toBeNull()
|
| 273 | 288 |
}) |
| 274 | 289 |
|
--- frontend/src/components/OrgOverview.tsx
+++ frontend/src/components/OrgOverview.tsx
... | ... | @@ -11,6 +11,7 @@ |
| 11 | 11 |
import ChannelFiles from './ChannelFiles' |
| 12 | 12 |
import ChannelPosts from './ChannelPosts' |
| 13 | 13 |
import ContactPickerModal from './ContactPickerModal' |
| 14 |
+import ReviewBoard from './ReviewBoard' |
|
| 14 | 15 |
import StatusBadge from './StatusBadge' |
| 15 | 16 |
import Timeline from './Timeline' |
| 16 | 17 |
import WorkMemos from './WorkMemos' |
... | ... | @@ -457,6 +458,10 @@ |
| 457 | 458 |
<div className="mt-4 rounded-lg border border-gray-200"> |
| 458 | 459 |
<WorkMemos key={org.id} org={org} />
|
| 459 | 460 |
</div> |
| 461 |
+ ) : activeTab === '권리확인' ? ( |
|
| 462 |
+ <div className="mt-4 rounded-lg border border-gray-200"> |
|
| 463 |
+ <ReviewBoard key={org.id} org={org} />
|
|
| 464 |
+ </div> |
|
| 460 | 465 |
) : activeTab === '타임라인' ? ( |
| 461 | 466 |
<div className="mt-4 rounded-lg border border-gray-200"> |
| 462 | 467 |
<Timeline key={org.id} org={org} />
|
+++ frontend/src/components/ReviewBoard.test.tsx
... | ... | @@ -0,0 +1,280 @@ |
| 1 | +import { fireEvent, render, screen, waitFor } from '@testing-library/react' | |
| 2 | +import { beforeEach, describe, expect, it, vi } from 'vitest' | |
| 3 | +import ReviewBoard from './ReviewBoard' | |
| 4 | +import type { Org, ReviewItem, ReviewPage } from '../api/client' | |
| 5 | + | |
| 6 | +const mocks = vi.hoisted(() => ({ | |
| 7 | + getReviewPage: vi.fn(), | |
| 8 | + getReviewItem: vi.fn(), | |
| 9 | + importReview: vi.fn(), | |
| 10 | + updateReviewJudgment: vi.fn(), | |
| 11 | + deleteReviewItem: vi.fn(), | |
| 12 | +})) | |
| 13 | + | |
| 14 | +vi.mock('../api/client', async (importOriginal) => ({ | |
| 15 | + ...(await importOriginal<typeof import('../api/client')>()), | |
| 16 | + getReviewPage: mocks.getReviewPage, | |
| 17 | + getReviewItem: mocks.getReviewItem, | |
| 18 | + importReview: mocks.importReview, | |
| 19 | + updateReviewJudgment: mocks.updateReviewJudgment, | |
| 20 | + deleteReviewItem: mocks.deleteReviewItem, | |
| 21 | +})) | |
| 22 | + | |
| 23 | +function org(overrides: Partial<Org> = {}): Org { | |
| 24 | + return { | |
| 25 | + id: 1, | |
| 26 | + orgNo: '001', | |
| 27 | + orgName: '한국출판문화산업진흥원', | |
| 28 | + status: 'ACTIVE', | |
| 29 | + applicant: null, | |
| 30 | + mj: null, | |
| 31 | + itn: null, | |
| 32 | + lawyer: null, | |
| 33 | + lawyerAssignedDate: null, | |
| 34 | + channelIdMj: 'chan-mj', | |
| 35 | + channelIdLaw: 'chan-law', | |
| 36 | + stage: null, | |
| 37 | + ...overrides, | |
| 38 | + } | |
| 39 | +} | |
| 40 | + | |
| 41 | +function item(overrides: Partial<ReviewItem> = {}): ReviewItem { | |
| 42 | + return { | |
| 43 | + id: 1, | |
| 44 | + orgId: 1, | |
| 45 | + seq: 1, | |
| 46 | + siteName: '한국출판문화산업진흥원', | |
| 47 | + category: '공지', | |
| 48 | + boardPath: '게시판>공지', | |
| 49 | + boardName: '공지사항', | |
| 50 | + postTitle: '2026년 사업 공고', | |
| 51 | + url: 'https://example.com/1', | |
| 52 | + postRegistered: '2026-01-01', | |
| 53 | + producedDate: '20260101', | |
| 54 | + publishedDate: '20260102', | |
| 55 | + hasAttachment: 'N', | |
| 56 | + koglAttached: 'Y', | |
| 57 | + koglType: '1유형', | |
| 58 | + aiType: null, | |
| 59 | + surveyorNote: null, | |
| 60 | + openable: null, | |
| 61 | + reviewMajor: null, | |
| 62 | + reviewMinor: null, | |
| 63 | + reviewResult: '신유형 개방', | |
| 64 | + judgedKoglType: null, | |
| 65 | + judgedAiType: null, | |
| 66 | + opinion: null, | |
| 67 | + lawyerNote: null, | |
| 68 | + needsProcessing: null, | |
| 69 | + webEditedAt: null, | |
| 70 | + createdAt: 0, | |
| 71 | + updatedAt: 0, | |
| 72 | + ...overrides, | |
| 73 | + } | |
| 74 | +} | |
| 75 | + | |
| 76 | +function page(overrides: Partial<ReviewPage> = {}): ReviewPage { | |
| 77 | + return { | |
| 78 | + items: [item()], | |
| 79 | + total: 1, | |
| 80 | + done: 0, | |
| 81 | + page: 0, | |
| 82 | + size: 30, | |
| 83 | + ...overrides, | |
| 84 | + } | |
| 85 | +} | |
| 86 | + | |
| 87 | +beforeEach(() => { | |
| 88 | + mocks.getReviewPage.mockReset() | |
| 89 | + mocks.getReviewItem.mockReset() | |
| 90 | + mocks.importReview.mockReset() | |
| 91 | + mocks.updateReviewJudgment.mockReset() | |
| 92 | + mocks.deleteReviewItem.mockReset() | |
| 93 | +}) | |
| 94 | + | |
| 95 | +describe('ReviewBoard 목록', () => { | |
| 96 | + it('목록을 표로 보여주고 처리결과 배지가 나온다', async () => { | |
| 97 | + mocks.getReviewPage.mockResolvedValue(page()) | |
| 98 | + | |
| 99 | + render(<ReviewBoard org={org()} />) | |
| 100 | + | |
| 101 | + await waitFor(() => { | |
| 102 | + expect(screen.getByText('2026년 사업 공고')).toBeTruthy() | |
| 103 | + expect(screen.getByText('신유형 개방')).toBeTruthy() | |
| 104 | + }) | |
| 105 | + expect(screen.getByText('총 1건 · 처리 0건')).toBeTruthy() | |
| 106 | + }) | |
| 107 | + | |
| 108 | + it('자료가 없으면 안내 문구를 보여준다', async () => { | |
| 109 | + mocks.getReviewPage.mockResolvedValue(page({ items: [], total: 0 })) | |
| 110 | + | |
| 111 | + render(<ReviewBoard org={org()} />) | |
| 112 | + | |
| 113 | + await waitFor(() => { | |
| 114 | + expect( | |
| 115 | + screen.getByText('업로드된 권리확인 자료가 없습니다. 엑셀 업로드로 시작하세요.'), | |
| 116 | + ).toBeTruthy() | |
| 117 | + }) | |
| 118 | + }) | |
| 119 | + | |
| 120 | + it('검색어를 입력하고 검색을 누르면 keyword로 다시 조회한다', async () => { | |
| 121 | + mocks.getReviewPage.mockResolvedValue(page()) | |
| 122 | + | |
| 123 | + render(<ReviewBoard org={org()} />) | |
| 124 | + | |
| 125 | + await waitFor(() => | |
| 126 | + expect(mocks.getReviewPage).toHaveBeenCalledWith(1, { keyword: undefined, page: 0, size: 30 }), | |
| 127 | + ) | |
| 128 | + | |
| 129 | + fireEvent.change(screen.getByLabelText('검색어'), { target: { value: '출판' } }) | |
| 130 | + fireEvent.click(screen.getByRole('button', { name: '검색' })) | |
| 131 | + | |
| 132 | + await waitFor(() => | |
| 133 | + expect(mocks.getReviewPage).toHaveBeenCalledWith(1, { keyword: '출판', page: 0, size: 30 }), | |
| 134 | + ) | |
| 135 | + }) | |
| 136 | + | |
| 137 | + it('엑셀 업로드를 하면 importReview를 호출하고 결과를 보여준다', async () => { | |
| 138 | + mocks.getReviewPage.mockResolvedValue(page()) | |
| 139 | + mocks.importReview.mockResolvedValue({ created: 3, updated: 2, total: 5 }) | |
| 140 | + | |
| 141 | + const { container } = render(<ReviewBoard org={org()} />) | |
| 142 | + | |
| 143 | + await waitFor(() => expect(mocks.getReviewPage).toHaveBeenCalledTimes(1)) | |
| 144 | + | |
| 145 | + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement | |
| 146 | + const file = new File(['dummy'], 'review.xlsx', { | |
| 147 | + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', | |
| 148 | + }) | |
| 149 | + fireEvent.change(fileInput, { target: { files: [file] } }) | |
| 150 | + | |
| 151 | + await waitFor(() => expect(mocks.importReview).toHaveBeenCalledWith(1, file)) | |
| 152 | + await waitFor(() => { | |
| 153 | + expect(screen.getByText('신규 3 · 갱신 2 · 전체 5')).toBeTruthy() | |
| 154 | + }) | |
| 155 | + }) | |
| 156 | + | |
| 157 | + it('삭제를 누르면 확인 후 deleteReviewItem을 호출한다', async () => { | |
| 158 | + mocks.getReviewPage.mockResolvedValue(page()) | |
| 159 | + mocks.deleteReviewItem.mockResolvedValue(undefined) | |
| 160 | + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) | |
| 161 | + | |
| 162 | + render(<ReviewBoard org={org()} />) | |
| 163 | + | |
| 164 | + await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy()) | |
| 165 | + | |
| 166 | + fireEvent.click(screen.getByRole('button', { name: '삭제' })) | |
| 167 | + | |
| 168 | + expect(confirmSpy).toHaveBeenCalled() | |
| 169 | + await waitFor(() => expect(mocks.deleteReviewItem).toHaveBeenCalledWith(1, 1)) | |
| 170 | + | |
| 171 | + confirmSpy.mockRestore() | |
| 172 | + }) | |
| 173 | + | |
| 174 | + it('다음 페이지 버튼을 누르면 page+1로 다시 조회한다', async () => { | |
| 175 | + mocks.getReviewPage.mockResolvedValue(page({ total: 31 })) | |
| 176 | + | |
| 177 | + render(<ReviewBoard org={org()} />) | |
| 178 | + | |
| 179 | + await waitFor(() => | |
| 180 | + expect(mocks.getReviewPage).toHaveBeenCalledWith(1, { keyword: undefined, page: 0, size: 30 }), | |
| 181 | + ) | |
| 182 | + | |
| 183 | + fireEvent.click(screen.getByRole('button', { name: '›' })) | |
| 184 | + | |
| 185 | + await waitFor(() => | |
| 186 | + expect(mocks.getReviewPage).toHaveBeenCalledWith(1, { keyword: undefined, page: 1, size: 30 }), | |
| 187 | + ) | |
| 188 | + }) | |
| 189 | +}) | |
| 190 | + | |
| 191 | +describe('ReviewBoard 상세', () => { | |
| 192 | + it('수정을 누르면 상세 화면이 열리고 조회한 값이 채워진다', async () => { | |
| 193 | + mocks.getReviewPage.mockResolvedValue(page()) | |
| 194 | + mocks.getReviewItem.mockResolvedValue( | |
| 195 | + item({ reviewMajor: '만료 저작물', opinion: '검토 완료' }), | |
| 196 | + ) | |
| 197 | + | |
| 198 | + render(<ReviewBoard org={org()} />) | |
| 199 | + | |
| 200 | + await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy()) | |
| 201 | + fireEvent.click(screen.getByRole('button', { name: '수정' })) | |
| 202 | + | |
| 203 | + await waitFor(() => expect(mocks.getReviewItem).toHaveBeenCalledWith(1, 1)) | |
| 204 | + await waitFor(() => { | |
| 205 | + expect((screen.getByLabelText('의견') as HTMLTextAreaElement).value).toBe('검토 완료') | |
| 206 | + }) | |
| 207 | + expect(screen.getByRole('radio', { name: '만료 저작물' })).toBeChecked() | |
| 208 | + }) | |
| 209 | + | |
| 210 | + it('대분류를 바꾸면 처리결과가 자동으로 채워지지만, 수동으로 고르면 이후 대분류 변경에도 유지된다', async () => { | |
| 211 | + mocks.getReviewPage.mockResolvedValue(page()) | |
| 212 | + mocks.getReviewItem.mockResolvedValue(item({ reviewMajor: null, reviewResult: null })) | |
| 213 | + | |
| 214 | + render(<ReviewBoard org={org()} />) | |
| 215 | + | |
| 216 | + await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy()) | |
| 217 | + fireEvent.click(screen.getByRole('button', { name: '수정' })) | |
| 218 | + await waitFor(() => expect(mocks.getReviewItem).toHaveBeenCalled()) | |
| 219 | + | |
| 220 | + fireEvent.click(screen.getByRole('radio', { name: '만료 저작물' })) | |
| 221 | + expect(screen.getByRole('radio', { name: '신유형 개방' })).toBeChecked() | |
| 222 | + | |
| 223 | + // 수동으로 다른 처리결과를 고른다 | |
| 224 | + fireEvent.click(screen.getByRole('radio', { name: '권리처리 추진' })) | |
| 225 | + expect(screen.getByRole('radio', { name: '권리처리 추진' })).toBeChecked() | |
| 226 | + | |
| 227 | + // 대분류를 다시 바꿔도(원래대로면 "신유형 개방"을 추천) 수동 선택이 유지된다 | |
| 228 | + fireEvent.click(screen.getByRole('radio', { name: '업무상 저작물' })) | |
| 229 | + expect(screen.getByRole('radio', { name: '권리처리 추진' })).toBeChecked() | |
| 230 | + }) | |
| 231 | + | |
| 232 | + it('수정을 저장하면 폼 값으로 updateReviewJudgment를 호출하고 목록으로 돌아간다', async () => { | |
| 233 | + mocks.getReviewPage.mockResolvedValue(page()) | |
| 234 | + mocks.getReviewItem.mockResolvedValue(item({ reviewMajor: null, reviewResult: null })) | |
| 235 | + mocks.updateReviewJudgment.mockResolvedValue(item({ reviewResult: '신유형 개방' })) | |
| 236 | + | |
| 237 | + render(<ReviewBoard org={org()} />) | |
| 238 | + | |
| 239 | + await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy()) | |
| 240 | + fireEvent.click(screen.getByRole('button', { name: '수정' })) | |
| 241 | + await waitFor(() => expect(mocks.getReviewItem).toHaveBeenCalled()) | |
| 242 | + | |
| 243 | + fireEvent.click(screen.getByRole('radio', { name: '만료 저작물' })) | |
| 244 | + fireEvent.change(screen.getByLabelText('의견'), { target: { value: '이상 없음' } }) | |
| 245 | + fireEvent.click(screen.getByRole('button', { name: '수정' })) | |
| 246 | + | |
| 247 | + await waitFor(() => | |
| 248 | + expect(mocks.updateReviewJudgment).toHaveBeenCalledWith(1, 1, { | |
| 249 | + openable: 'Y', | |
| 250 | + reviewMajor: '만료 저작물', | |
| 251 | + reviewMinor: null, | |
| 252 | + reviewResult: '신유형 개방', | |
| 253 | + judgedKoglType: null, | |
| 254 | + judgedAiType: null, | |
| 255 | + opinion: '이상 없음', | |
| 256 | + lawyerNote: null, | |
| 257 | + needsProcessing: null, | |
| 258 | + }), | |
| 259 | + ) | |
| 260 | + | |
| 261 | + // 저장 후 목록으로 복귀 - 업로드 버튼 등 목록 화면 요소가 다시 보인다 | |
| 262 | + await waitFor(() => expect(screen.getByRole('button', { name: '엑셀 업로드' })).toBeTruthy()) | |
| 263 | + }) | |
| 264 | + | |
| 265 | + it('목록 버튼을 누르면 저장 없이 목록으로 돌아간다', async () => { | |
| 266 | + mocks.getReviewPage.mockResolvedValue(page()) | |
| 267 | + mocks.getReviewItem.mockResolvedValue(item()) | |
| 268 | + | |
| 269 | + render(<ReviewBoard org={org()} />) | |
| 270 | + | |
| 271 | + await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy()) | |
| 272 | + fireEvent.click(screen.getByRole('button', { name: '수정' })) | |
| 273 | + await waitFor(() => expect(mocks.getReviewItem).toHaveBeenCalled()) | |
| 274 | + | |
| 275 | + fireEvent.click(screen.getByRole('button', { name: '목록' })) | |
| 276 | + | |
| 277 | + expect(screen.getByRole('button', { name: '엑셀 업로드' })).toBeTruthy() | |
| 278 | + expect(mocks.updateReviewJudgment).not.toHaveBeenCalled() | |
| 279 | + }) | |
| 280 | +}) |
+++ frontend/src/components/ReviewBoard.tsx
... | ... | @@ -0,0 +1,690 @@ |
| 1 | +import { useEffect, useRef, useState } from 'react' | |
| 2 | +import { | |
| 3 | + deleteReviewItem, | |
| 4 | + getReviewItem, | |
| 5 | + getReviewPage, | |
| 6 | + importReview, | |
| 7 | + reviewDownloadUrl, | |
| 8 | + updateReviewJudgment, | |
| 9 | + type JudgmentRequest, | |
| 10 | + type Org, | |
| 11 | + type ReviewImportReport, | |
| 12 | + type ReviewItem, | |
| 13 | +} from '../api/client' | |
| 14 | + | |
| 15 | +const PAGE_SIZE = 30 | |
| 16 | + | |
| 17 | +const REVIEW_MAJOR_OPTIONS = ['만료 저작물', '업무상 저작물', '제3자 권리', '개인정보 포함'] | |
| 18 | + | |
| 19 | +const REVIEW_MINOR_OPTIONS = [ | |
| 20 | + '1. 원시적 권리 전부 보유', | |
| 21 | + '2. 후천적 권리 전부 보유(계약서 확인 필요)', | |
| 22 | + '3. 권리 일부(공동) 보유', | |
| 23 | + '4. 권리 미보유', | |
| 24 | + '초상권 포함', | |
| 25 | +] | |
| 26 | + | |
| 27 | +const REVIEW_RESULT_OPTIONS = ['신유형 개방', '계약서 등 재확인', '권리처리 추진', '개방불가'] | |
| 28 | + | |
| 29 | +const JUDGED_KOGL_TYPE_OPTIONS = ['0유형', '1유형', '2유형', '3유형', '4유형', '개방불가'] | |
| 30 | + | |
| 31 | +const REVIEW_RESULT_BADGE_STYLES: Record<string, string> = { | |
| 32 | + '신유형 개방': 'bg-emerald-50 text-emerald-700', | |
| 33 | + '계약서 등 재확인': 'bg-amber-50 text-amber-700', | |
| 34 | + '권리처리 추진': 'bg-blue-50 text-blue-700', | |
| 35 | + 개방불가: 'bg-red-50 text-red-700', | |
| 36 | +} | |
| 37 | + | |
| 38 | +/** 대분류/세부 선택에 따른 처리결과 추천값. 매칭되는 조합이 없으면 null(현재 값 유지). */ | |
| 39 | +function suggestReviewResult(major: string, minor: string): string | null { | |
| 40 | + if (major === '만료 저작물' || major === '업무상 저작물') { | |
| 41 | + return '신유형 개방' | |
| 42 | + } | |
| 43 | + if (major === '개인정보 포함') { | |
| 44 | + return '개방불가' | |
| 45 | + } | |
| 46 | + if (major === '제3자 권리') { | |
| 47 | + if (minor === '1. 원시적 권리 전부 보유') { | |
| 48 | + return '신유형 개방' | |
| 49 | + } | |
| 50 | + if (minor === '2. 후천적 권리 전부 보유(계약서 확인 필요)') { | |
| 51 | + return '계약서 등 재확인' | |
| 52 | + } | |
| 53 | + if (minor === '3. 권리 일부(공동) 보유' || minor === '4. 권리 미보유' || minor === '초상권 포함') { | |
| 54 | + return '권리처리 추진' | |
| 55 | + } | |
| 56 | + } | |
| 57 | + return null | |
| 58 | +} | |
| 59 | + | |
| 60 | +function Field({ label, value }: { label: string; value: string | null }) { | |
| 61 | + return ( | |
| 62 | + <div className="flex gap-3 text-sm"> | |
| 63 | + <span className="w-24 shrink-0 text-gray-400">{label}</span> | |
| 64 | + <span className={value ? 'text-gray-900' : 'text-gray-300'}>{value ?? '-'}</span> | |
| 65 | + </div> | |
| 66 | + ) | |
| 67 | +} | |
| 68 | + | |
| 69 | +function RadioGroup({ | |
| 70 | + legend, | |
| 71 | + name, | |
| 72 | + options, | |
| 73 | + value, | |
| 74 | + onChange, | |
| 75 | + disabled, | |
| 76 | +}: { | |
| 77 | + legend: string | |
| 78 | + name: string | |
| 79 | + options: string[] | |
| 80 | + value: string | |
| 81 | + onChange: (value: string) => void | |
| 82 | + disabled?: boolean | |
| 83 | +}) { | |
| 84 | + return ( | |
| 85 | + <fieldset className="flex flex-col gap-1.5"> | |
| 86 | + <legend className="text-xs text-gray-500">{legend}</legend> | |
| 87 | + <div className="flex flex-wrap gap-x-4 gap-y-1"> | |
| 88 | + {options.map((option) => ( | |
| 89 | + <label key={option} className="flex items-center gap-1.5 text-sm text-gray-800"> | |
| 90 | + <input | |
| 91 | + type="radio" | |
| 92 | + name={name} | |
| 93 | + disabled={disabled} | |
| 94 | + checked={value === option} | |
| 95 | + onChange={() => onChange(option)} | |
| 96 | + /> | |
| 97 | + {option} | |
| 98 | + </label> | |
| 99 | + ))} | |
| 100 | + </div> | |
| 101 | + </fieldset> | |
| 102 | + ) | |
| 103 | +} | |
| 104 | + | |
| 105 | +function ReviewResultBadge({ value }: { value: string | null }) { | |
| 106 | + if (!value) { | |
| 107 | + return ( | |
| 108 | + <span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-500">미판정</span> | |
| 109 | + ) | |
| 110 | + } | |
| 111 | + return ( | |
| 112 | + <span | |
| 113 | + className={`rounded-full px-2 py-0.5 text-xs font-medium ${REVIEW_RESULT_BADGE_STYLES[value] ?? 'bg-gray-100 text-gray-600'}`} | |
| 114 | + > | |
| 115 | + {value} | |
| 116 | + </span> | |
| 117 | + ) | |
| 118 | +} | |
| 119 | + | |
| 120 | +/** 기관관리 상세의 권리확인 탭. 목록↔상세 두 화면을 이 컴포넌트 하나에서 전환한다. */ | |
| 121 | +export default function ReviewBoard({ org }: { org: Org }) { | |
| 122 | + const [mode, setMode] = useState<'list' | number>('list') | |
| 123 | + | |
| 124 | + const [items, setItems] = useState<ReviewItem[]>([]) | |
| 125 | + const [total, setTotal] = useState(0) | |
| 126 | + const [done, setDone] = useState(0) | |
| 127 | + const [page, setPage] = useState(0) | |
| 128 | + const [keywordInput, setKeywordInput] = useState('') | |
| 129 | + const [appliedKeyword, setAppliedKeyword] = useState('') | |
| 130 | + const [loading, setLoading] = useState(false) | |
| 131 | + const [error, setError] = useState<string | null>(null) | |
| 132 | + const [reloadKey, setReloadKey] = useState(0) | |
| 133 | + | |
| 134 | + const importInputRef = useRef<HTMLInputElement>(null) | |
| 135 | + const [importBusy, setImportBusy] = useState(false) | |
| 136 | + const [importReport, setImportReport] = useState<ReviewImportReport | null>(null) | |
| 137 | + const [importError, setImportError] = useState<string | null>(null) | |
| 138 | + | |
| 139 | + useEffect(() => { | |
| 140 | + if (mode !== 'list') { | |
| 141 | + return | |
| 142 | + } | |
| 143 | + let cancelled = false | |
| 144 | + setLoading(true) | |
| 145 | + setError(null) | |
| 146 | + | |
| 147 | + getReviewPage(org.id, { keyword: appliedKeyword || undefined, page, size: PAGE_SIZE }) | |
| 148 | + .then((data) => { | |
| 149 | + if (cancelled) { | |
| 150 | + return | |
| 151 | + } | |
| 152 | + setItems(data.items) | |
| 153 | + setTotal(data.total) | |
| 154 | + setDone(data.done) | |
| 155 | + }) | |
| 156 | + .catch((e: Error) => { | |
| 157 | + if (!cancelled) { | |
| 158 | + setError(e.message) | |
| 159 | + } | |
| 160 | + }) | |
| 161 | + .finally(() => { | |
| 162 | + if (!cancelled) { | |
| 163 | + setLoading(false) | |
| 164 | + } | |
| 165 | + }) | |
| 166 | + | |
| 167 | + return () => { | |
| 168 | + cancelled = true | |
| 169 | + } | |
| 170 | + // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 171 | + }, [mode, org.id, appliedKeyword, page, reloadKey]) | |
| 172 | + | |
| 173 | + function handleSearch() { | |
| 174 | + setPage(0) | |
| 175 | + setAppliedKeyword(keywordInput.trim()) | |
| 176 | + } | |
| 177 | + | |
| 178 | + function handleResetSearch() { | |
| 179 | + setKeywordInput('') | |
| 180 | + setPage(0) | |
| 181 | + setAppliedKeyword('') | |
| 182 | + } | |
| 183 | + | |
| 184 | + async function handleImportFile(file: File) { | |
| 185 | + setImportBusy(true) | |
| 186 | + setImportError(null) | |
| 187 | + try { | |
| 188 | + const report = await importReview(org.id, file) | |
| 189 | + setImportReport(report) | |
| 190 | + setReloadKey((k) => k + 1) | |
| 191 | + } catch (e) { | |
| 192 | + setImportError(e instanceof Error ? e.message : '엑셀 업로드에 실패했습니다.') | |
| 193 | + } finally { | |
| 194 | + setImportBusy(false) | |
| 195 | + } | |
| 196 | + } | |
| 197 | + | |
| 198 | + async function handleDelete(item: ReviewItem) { | |
| 199 | + if (!window.confirm(`"${item.postTitle ?? item.seq}" 게시물을 삭제할까요?`)) { | |
| 200 | + return | |
| 201 | + } | |
| 202 | + try { | |
| 203 | + await deleteReviewItem(org.id, item.id) | |
| 204 | + setReloadKey((k) => k + 1) | |
| 205 | + } catch (e) { | |
| 206 | + setError(e instanceof Error ? e.message : '삭제에 실패했습니다.') | |
| 207 | + } | |
| 208 | + } | |
| 209 | + | |
| 210 | + if (mode !== 'list') { | |
| 211 | + return ( | |
| 212 | + <ReviewDetail | |
| 213 | + org={org} | |
| 214 | + itemId={mode} | |
| 215 | + onBack={() => setMode('list')} | |
| 216 | + onSaved={() => { | |
| 217 | + setMode('list') | |
| 218 | + setReloadKey((k) => k + 1) | |
| 219 | + }} | |
| 220 | + /> | |
| 221 | + ) | |
| 222 | + } | |
| 223 | + | |
| 224 | + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) | |
| 225 | + | |
| 226 | + return ( | |
| 227 | + <div className="p-4"> | |
| 228 | + <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 229 | + <div className="flex items-center gap-2"> | |
| 230 | + <input | |
| 231 | + type="text" | |
| 232 | + aria-label="검색어" | |
| 233 | + value={keywordInput} | |
| 234 | + onChange={(e) => setKeywordInput(e.target.value)} | |
| 235 | + onKeyDown={(e) => { | |
| 236 | + if (e.key === 'Enter') { | |
| 237 | + handleSearch() | |
| 238 | + } | |
| 239 | + }} | |
| 240 | + placeholder="사이트명·게시판명·제목 검색" | |
| 241 | + className="w-64 rounded-md border border-gray-300 px-2 py-1.5 text-sm" | |
| 242 | + /> | |
| 243 | + <button | |
| 244 | + type="button" | |
| 245 | + onClick={handleSearch} | |
| 246 | + className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50" | |
| 247 | + > | |
| 248 | + 검색 | |
| 249 | + </button> | |
| 250 | + <button | |
| 251 | + type="button" | |
| 252 | + onClick={handleResetSearch} | |
| 253 | + className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50" | |
| 254 | + > | |
| 255 | + 초기화 | |
| 256 | + </button> | |
| 257 | + </div> | |
| 258 | + | |
| 259 | + <div className="flex items-center gap-2"> | |
| 260 | + <input | |
| 261 | + ref={importInputRef} | |
| 262 | + type="file" | |
| 263 | + accept=".xlsx,.xlsm" | |
| 264 | + className="hidden" | |
| 265 | + onChange={(e) => { | |
| 266 | + const file = e.target.files?.[0] | |
| 267 | + if (file) { | |
| 268 | + void handleImportFile(file) | |
| 269 | + } | |
| 270 | + e.target.value = '' | |
| 271 | + }} | |
| 272 | + /> | |
| 273 | + <button | |
| 274 | + type="button" | |
| 275 | + disabled={importBusy} | |
| 276 | + onClick={() => importInputRef.current?.click()} | |
| 277 | + className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50" | |
| 278 | + > | |
| 279 | + {importBusy ? '업로드 중…' : '엑셀 업로드'} | |
| 280 | + </button> | |
| 281 | + <a | |
| 282 | + href={reviewDownloadUrl(org.id)} | |
| 283 | + className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50" | |
| 284 | + > | |
| 285 | + 엑셀 다운로드 | |
| 286 | + </a> | |
| 287 | + </div> | |
| 288 | + </div> | |
| 289 | + | |
| 290 | + {importReport && ( | |
| 291 | + <p className="mt-3 text-sm text-gray-600"> | |
| 292 | + {`신규 ${importReport.created} · 갱신 ${importReport.updated} · 전체 ${importReport.total}`} | |
| 293 | + </p> | |
| 294 | + )} | |
| 295 | + {importError && <p className="mt-3 text-sm text-red-600">{importError}</p>} | |
| 296 | + | |
| 297 | + <p className="mt-4 text-sm font-medium text-gray-700"> | |
| 298 | + {`총 ${total}건 · 처리 ${done}건`} | |
| 299 | + </p> | |
| 300 | + | |
| 301 | + {error && <p className="mt-2 text-sm text-red-600">{error}</p>} | |
| 302 | + | |
| 303 | + <div className="mt-3 overflow-x-auto rounded-lg border border-gray-200"> | |
| 304 | + <table className="w-full min-w-[1100px] text-left text-sm"> | |
| 305 | + <thead> | |
| 306 | + <tr className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500"> | |
| 307 | + <th className="whitespace-nowrap px-3 py-2 font-medium">No</th> | |
| 308 | + <th className="whitespace-nowrap px-3 py-2 font-medium">사이트/게시판</th> | |
| 309 | + <th className="px-3 py-2 font-medium">범주/제목</th> | |
| 310 | + <th className="whitespace-nowrap px-3 py-2 font-medium">제작/공표일</th> | |
| 311 | + <th className="whitespace-nowrap px-3 py-2 font-medium">첨부파일</th> | |
| 312 | + <th className="whitespace-nowrap px-3 py-2 font-medium">기존 공공누리</th> | |
| 313 | + <th className="whitespace-nowrap px-3 py-2 font-medium">권리확인</th> | |
| 314 | + <th className="whitespace-nowrap px-3 py-2 font-medium">처리결과</th> | |
| 315 | + <th className="whitespace-nowrap px-3 py-2 font-medium">공공누리유형(판정)</th> | |
| 316 | + <th className="whitespace-nowrap px-3 py-2 font-medium">관리</th> | |
| 317 | + </tr> | |
| 318 | + </thead> | |
| 319 | + <tbody> | |
| 320 | + {loading ? ( | |
| 321 | + <tr> | |
| 322 | + <td colSpan={10} className="py-8 text-center text-sm text-gray-400"> | |
| 323 | + 불러오는 중… | |
| 324 | + </td> | |
| 325 | + </tr> | |
| 326 | + ) : items.length === 0 ? ( | |
| 327 | + <tr> | |
| 328 | + <td colSpan={10} className="py-10 text-center text-sm text-gray-300"> | |
| 329 | + 업로드된 권리확인 자료가 없습니다. 엑셀 업로드로 시작하세요. | |
| 330 | + </td> | |
| 331 | + </tr> | |
| 332 | + ) : ( | |
| 333 | + items.map((item) => ( | |
| 334 | + <tr key={item.id} className="border-b border-gray-100 align-top last:border-b-0"> | |
| 335 | + <td className="px-3 py-2 text-gray-500">{item.seq}</td> | |
| 336 | + <td className="px-3 py-2"> | |
| 337 | + <div className="text-gray-900">{item.siteName ?? '-'}</div> | |
| 338 | + <div className="text-xs text-gray-400">{item.boardName ?? '-'}</div> | |
| 339 | + </td> | |
| 340 | + <td className="px-3 py-2"> | |
| 341 | + <div className="text-gray-500">{item.category ?? '-'}</div> | |
| 342 | + <div className="flex items-center gap-1 text-gray-900"> | |
| 343 | + <span>{item.postTitle ?? '-'}</span> | |
| 344 | + {item.url && ( | |
| 345 | + <a | |
| 346 | + href={item.url} | |
| 347 | + target="_blank" | |
| 348 | + rel="noreferrer" | |
| 349 | + title="원문 보기" | |
| 350 | + aria-label="원문 보기" | |
| 351 | + className="text-blue-500 hover:underline" | |
| 352 | + > | |
| 353 | + | |
| 354 | + </a> | |
| 355 | + )} | |
| 356 | + </div> | |
| 357 | + </td> | |
| 358 | + <td className="px-3 py-2 text-gray-600"> | |
| 359 | + <div>{item.producedDate ?? '-'}</div> | |
| 360 | + <div className="text-xs text-gray-400">{item.publishedDate ?? '-'}</div> | |
| 361 | + </td> | |
| 362 | + <td className="px-3 py-2 text-gray-600">{item.hasAttachment ?? '-'}</td> | |
| 363 | + <td className="px-3 py-2 text-gray-600"> | |
| 364 | + <div> | |
| 365 | + {item.koglAttached ?? '-'} / {item.koglType ?? '-'} | |
| 366 | + </div> | |
| 367 | + <div className="text-xs text-gray-400">{item.aiType ? 'AI' : ''}</div> | |
| 368 | + </td> | |
| 369 | + <td className="px-3 py-2 text-gray-600"> | |
| 370 | + <div>{item.reviewMajor ?? '-'}</div> | |
| 371 | + <div className="text-xs text-gray-400">{item.reviewMinor ?? ''}</div> | |
| 372 | + </td> | |
| 373 | + <td className="px-3 py-2"> | |
| 374 | + <ReviewResultBadge value={item.reviewResult} /> | |
| 375 | + </td> | |
| 376 | + <td className="px-3 py-2 text-gray-600"> | |
| 377 | + <div>{item.judgedKoglType ?? '-'}</div> | |
| 378 | + <div className="text-xs text-gray-400">{item.judgedAiType === 'Y' ? 'AI' : ''}</div> | |
| 379 | + </td> | |
| 380 | + <td className="px-3 py-2"> | |
| 381 | + <div className="flex gap-2"> | |
| 382 | + <button | |
| 383 | + type="button" | |
| 384 | + onClick={() => setMode(item.id)} | |
| 385 | + className="rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50" | |
| 386 | + > | |
| 387 | + 수정 | |
| 388 | + </button> | |
| 389 | + <button | |
| 390 | + type="button" | |
| 391 | + onClick={() => void handleDelete(item)} | |
| 392 | + className="rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50" | |
| 393 | + > | |
| 394 | + 삭제 | |
| 395 | + </button> | |
| 396 | + </div> | |
| 397 | + </td> | |
| 398 | + </tr> | |
| 399 | + )) | |
| 400 | + )} | |
| 401 | + </tbody> | |
| 402 | + </table> | |
| 403 | + </div> | |
| 404 | + | |
| 405 | + {items.length > 0 && ( | |
| 406 | + <div className="mt-3 flex items-center justify-center gap-3 text-sm text-gray-600"> | |
| 407 | + <button | |
| 408 | + type="button" | |
| 409 | + disabled={page <= 0} | |
| 410 | + onClick={() => setPage((p) => Math.max(0, p - 1))} | |
| 411 | + className="rounded-md border border-gray-300 px-2 py-1 disabled:opacity-40" | |
| 412 | + > | |
| 413 | + ‹ | |
| 414 | + </button> | |
| 415 | + <span>{`${page + 1} / ${totalPages} Page`}</span> | |
| 416 | + <button | |
| 417 | + type="button" | |
| 418 | + disabled={page + 1 >= totalPages} | |
| 419 | + onClick={() => setPage((p) => p + 1)} | |
| 420 | + className="rounded-md border border-gray-300 px-2 py-1 disabled:opacity-40" | |
| 421 | + > | |
| 422 | + › | |
| 423 | + </button> | |
| 424 | + </div> | |
| 425 | + )} | |
| 426 | + </div> | |
| 427 | + ) | |
| 428 | +} | |
| 429 | + | |
| 430 | +/** 권리확인 상세(변호사 판정 입력) 화면. */ | |
| 431 | +function ReviewDetail({ | |
| 432 | + org, | |
| 433 | + itemId, | |
| 434 | + onBack, | |
| 435 | + onSaved, | |
| 436 | +}: { | |
| 437 | + org: Org | |
| 438 | + itemId: number | |
| 439 | + onBack: () => void | |
| 440 | + onSaved: () => void | |
| 441 | +}) { | |
| 442 | + const [item, setItem] = useState<ReviewItem | null>(null) | |
| 443 | + const [loading, setLoading] = useState(true) | |
| 444 | + const [loadError, setLoadError] = useState<string | null>(null) | |
| 445 | + | |
| 446 | + const [reviewMajor, setReviewMajor] = useState('') | |
| 447 | + const [reviewMinor, setReviewMinor] = useState('') | |
| 448 | + const [reviewResult, setReviewResult] = useState('') | |
| 449 | + const [resultManuallySet, setResultManuallySet] = useState(false) | |
| 450 | + const [judgedKoglType, setJudgedKoglType] = useState('') | |
| 451 | + const [judgedAiType, setJudgedAiType] = useState(false) | |
| 452 | + const [needsProcessing, setNeedsProcessing] = useState(false) | |
| 453 | + const [opinion, setOpinion] = useState('') | |
| 454 | + const [lawyerNote, setLawyerNote] = useState('') | |
| 455 | + | |
| 456 | + const [busy, setBusy] = useState(false) | |
| 457 | + const [saveError, setSaveError] = useState<string | null>(null) | |
| 458 | + | |
| 459 | + useEffect(() => { | |
| 460 | + let cancelled = false | |
| 461 | + setLoading(true) | |
| 462 | + setLoadError(null) | |
| 463 | + | |
| 464 | + getReviewItem(org.id, itemId) | |
| 465 | + .then((data) => { | |
| 466 | + if (cancelled) { | |
| 467 | + return | |
| 468 | + } | |
| 469 | + setItem(data) | |
| 470 | + setReviewMajor(data.reviewMajor ?? '') | |
| 471 | + setReviewMinor(data.reviewMinor ?? '') | |
| 472 | + setReviewResult(data.reviewResult ?? '') | |
| 473 | + setResultManuallySet(false) | |
| 474 | + setJudgedKoglType(data.judgedKoglType ?? '') | |
| 475 | + setJudgedAiType(data.judgedAiType === 'Y') | |
| 476 | + setNeedsProcessing(data.needsProcessing === 'Y') | |
| 477 | + setOpinion(data.opinion ?? '') | |
| 478 | + setLawyerNote(data.lawyerNote ?? '') | |
| 479 | + }) | |
| 480 | + .catch((e: Error) => { | |
| 481 | + if (!cancelled) { | |
| 482 | + setLoadError(e.message) | |
| 483 | + } | |
| 484 | + }) | |
| 485 | + .finally(() => { | |
| 486 | + if (!cancelled) { | |
| 487 | + setLoading(false) | |
| 488 | + } | |
| 489 | + }) | |
| 490 | + | |
| 491 | + return () => { | |
| 492 | + cancelled = true | |
| 493 | + } | |
| 494 | + }, [org.id, itemId]) | |
| 495 | + | |
| 496 | + function applySuggestion(major: string, minor: string) { | |
| 497 | + if (resultManuallySet) { | |
| 498 | + return | |
| 499 | + } | |
| 500 | + const suggestion = suggestReviewResult(major, minor) | |
| 501 | + if (suggestion) { | |
| 502 | + setReviewResult(suggestion) | |
| 503 | + } | |
| 504 | + } | |
| 505 | + | |
| 506 | + function pickMajor(major: string) { | |
| 507 | + setReviewMajor(major) | |
| 508 | + const minor = major === '제3자 권리' ? reviewMinor : '' | |
| 509 | + setReviewMinor(minor) | |
| 510 | + applySuggestion(major, minor) | |
| 511 | + } | |
| 512 | + | |
| 513 | + function pickMinor(minor: string) { | |
| 514 | + setReviewMinor(minor) | |
| 515 | + applySuggestion(reviewMajor, minor) | |
| 516 | + } | |
| 517 | + | |
| 518 | + function pickResult(result: string) { | |
| 519 | + setReviewResult(result) | |
| 520 | + setResultManuallySet(true) | |
| 521 | + } | |
| 522 | + | |
| 523 | + async function handleSave() { | |
| 524 | + setBusy(true) | |
| 525 | + setSaveError(null) | |
| 526 | + try { | |
| 527 | + const body: JudgmentRequest = { | |
| 528 | + // 개방가능여부는 별도 입력칸 없이 처리결과에서 파생한다("신유형 개방"일 때만 개방 가능). | |
| 529 | + openable: reviewResult === '신유형 개방' ? 'Y' : reviewResult ? 'N' : null, | |
| 530 | + reviewMajor: reviewMajor || null, | |
| 531 | + reviewMinor: reviewMinor || null, | |
| 532 | + reviewResult: reviewResult || null, | |
| 533 | + judgedKoglType: judgedKoglType || null, | |
| 534 | + judgedAiType: judgedAiType ? 'Y' : null, | |
| 535 | + opinion: opinion || null, | |
| 536 | + lawyerNote: lawyerNote || null, | |
| 537 | + needsProcessing: needsProcessing ? 'Y' : null, | |
| 538 | + } | |
| 539 | + await updateReviewJudgment(org.id, itemId, body) | |
| 540 | + onSaved() | |
| 541 | + } catch (e) { | |
| 542 | + setSaveError(e instanceof Error ? e.message : '저장에 실패했습니다.') | |
| 543 | + } finally { | |
| 544 | + setBusy(false) | |
| 545 | + } | |
| 546 | + } | |
| 547 | + | |
| 548 | + if (loading) { | |
| 549 | + return <p className="p-4 py-10 text-center text-sm text-gray-400">불러오는 중…</p> | |
| 550 | + } | |
| 551 | + if (loadError || !item) { | |
| 552 | + return <p className="p-4 py-10 text-center text-sm text-red-600">{loadError ?? '게시물을 찾을 수 없습니다.'}</p> | |
| 553 | + } | |
| 554 | + | |
| 555 | + return ( | |
| 556 | + <div className="p-4"> | |
| 557 | + <section className="rounded-lg border border-gray-200 p-4"> | |
| 558 | + <h2 className="text-sm font-semibold">게시물 정보 (조사원)</h2> | |
| 559 | + <div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2"> | |
| 560 | + <Field label="기관명" value={org.orgName} /> | |
| 561 | + <Field label="사이트명" value={item.siteName} /> | |
| 562 | + <Field label="범주" value={item.category} /> | |
| 563 | + <Field label="게시판명" value={item.boardName} /> | |
| 564 | + <Field label="게시물제목" value={item.postTitle} /> | |
| 565 | + <div className="flex gap-3 text-sm"> | |
| 566 | + <span className="w-24 shrink-0 text-gray-400">URL</span> | |
| 567 | + {item.url ? ( | |
| 568 | + <a href={item.url} target="_blank" rel="noreferrer" className="break-all text-blue-600 hover:underline"> | |
| 569 | + {item.url} | |
| 570 | + </a> | |
| 571 | + ) : ( | |
| 572 | + <span className="text-gray-300">-</span> | |
| 573 | + )} | |
| 574 | + </div> | |
| 575 | + <Field label="제작일" value={item.producedDate} /> | |
| 576 | + <Field label="공표일" value={item.publishedDate} /> | |
| 577 | + <Field label="첨부파일 여부" value={item.hasAttachment} /> | |
| 578 | + <Field label="기존 공공누리 부착" value={item.koglAttached} /> | |
| 579 | + <Field label="기존 공공누리유형" value={item.koglType} /> | |
| 580 | + <Field label="기존 AI유형" value={item.aiType} /> | |
| 581 | + </div> | |
| 582 | + </section> | |
| 583 | + | |
| 584 | + <section className="mt-4 rounded-lg border border-gray-200 p-4"> | |
| 585 | + <h2 className="text-sm font-semibold">권리확인 (변호사)</h2> | |
| 586 | + | |
| 587 | + <div className="mt-3 flex flex-col gap-4"> | |
| 588 | + <RadioGroup | |
| 589 | + legend="권리확인(대분류)" | |
| 590 | + name="review-major" | |
| 591 | + options={REVIEW_MAJOR_OPTIONS} | |
| 592 | + value={reviewMajor} | |
| 593 | + onChange={pickMajor} | |
| 594 | + /> | |
| 595 | + | |
| 596 | + {reviewMajor === '제3자 권리' && ( | |
| 597 | + <RadioGroup | |
| 598 | + legend="권리확인(세부)" | |
| 599 | + name="review-minor" | |
| 600 | + options={REVIEW_MINOR_OPTIONS} | |
| 601 | + value={reviewMinor} | |
| 602 | + onChange={pickMinor} | |
| 603 | + /> | |
| 604 | + )} | |
| 605 | + | |
| 606 | + <RadioGroup | |
| 607 | + legend="처리결과" | |
| 608 | + name="review-result" | |
| 609 | + options={REVIEW_RESULT_OPTIONS} | |
| 610 | + value={reviewResult} | |
| 611 | + onChange={pickResult} | |
| 612 | + /> | |
| 613 | + | |
| 614 | + <RadioGroup | |
| 615 | + legend="공공누리유형(판정)" | |
| 616 | + name="judged-kogl-type" | |
| 617 | + options={JUDGED_KOGL_TYPE_OPTIONS} | |
| 618 | + value={judgedKoglType} | |
| 619 | + onChange={setJudgedKoglType} | |
| 620 | + /> | |
| 621 | + | |
| 622 | + <label className="flex items-center gap-2 text-sm text-gray-800"> | |
| 623 | + <input | |
| 624 | + type="checkbox" | |
| 625 | + checked={judgedAiType} | |
| 626 | + onChange={(e) => setJudgedAiType(e.target.checked)} | |
| 627 | + /> | |
| 628 | + AI유형 | |
| 629 | + </label> | |
| 630 | + | |
| 631 | + <label className="flex items-center gap-2 text-sm text-gray-800"> | |
| 632 | + <input | |
| 633 | + type="checkbox" | |
| 634 | + checked={needsProcessing} | |
| 635 | + onChange={(e) => setNeedsProcessing(e.target.checked)} | |
| 636 | + /> | |
| 637 | + 권리처리필요 | |
| 638 | + </label> | |
| 639 | + | |
| 640 | + <div className="flex flex-col gap-1 text-sm"> | |
| 641 | + <label htmlFor="review-opinion" className="text-xs text-gray-500"> | |
| 642 | + 의견 | |
| 643 | + </label> | |
| 644 | + <textarea | |
| 645 | + id="review-opinion" | |
| 646 | + rows={3} | |
| 647 | + value={opinion} | |
| 648 | + onChange={(e) => setOpinion(e.target.value)} | |
| 649 | + className="rounded-md border border-gray-300 px-2 py-1.5" | |
| 650 | + /> | |
| 651 | + </div> | |
| 652 | + | |
| 653 | + <div className="flex flex-col gap-1 text-sm"> | |
| 654 | + <label htmlFor="review-lawyer-note" className="text-xs text-gray-500"> | |
| 655 | + 비고 | |
| 656 | + </label> | |
| 657 | + <textarea | |
| 658 | + id="review-lawyer-note" | |
| 659 | + rows={3} | |
| 660 | + value={lawyerNote} | |
| 661 | + onChange={(e) => setLawyerNote(e.target.value)} | |
| 662 | + className="rounded-md border border-gray-300 px-2 py-1.5" | |
| 663 | + /> | |
| 664 | + </div> | |
| 665 | + </div> | |
| 666 | + | |
| 667 | + {saveError && <p className="mt-3 text-sm text-red-600">{saveError}</p>} | |
| 668 | + | |
| 669 | + <div className="mt-4 flex justify-end gap-2"> | |
| 670 | + <button | |
| 671 | + type="button" | |
| 672 | + disabled={busy} | |
| 673 | + onClick={onBack} | |
| 674 | + className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50" | |
| 675 | + > | |
| 676 | + 목록 | |
| 677 | + </button> | |
| 678 | + <button | |
| 679 | + type="button" | |
| 680 | + disabled={busy} | |
| 681 | + onClick={() => void handleSave()} | |
| 682 | + className="rounded-md bg-blue-600 px-4 py-1.5 text-sm font-semibold text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-300" | |
| 683 | + > | |
| 684 | + 수정 | |
| 685 | + </button> | |
| 686 | + </div> | |
| 687 | + </section> | |
| 688 | + </div> | |
| 689 | + ) | |
| 690 | +} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?