feat: 권리처리 탭 화면(목록/필터/업로드/처리등록 상세) 추가
권리확인 탭과 같은 목록↔상세 구조로 권리처리 보드를 만든다. 상태 칩(전체/미처리/ 처리완료)과 검색어로 목록을 필터링하고, 상세에서 계약서 유무를 체크박스 조합(+기타 직접입력)으로 관리하며 권리처리상태를 고르기 전에는 저장을 막는다. 기관 상세 헤더의 권리확인/권리처리 통계 카드도 각 탭의 완료·전체 건수를 실데이터로 보여주도록 연결한다.
@ee9edce761d6f91bc2f1c09f735f18e24f119454
--- frontend/src/api/client.ts
+++ frontend/src/api/client.ts
... | ... | @@ -423,6 +423,129 @@ |
| 423 | 423 |
return `/api/orgs/${orgId}/review/download`
|
| 424 | 424 |
} |
| 425 | 425 |
|
| 426 |
+/** 권리처리 탭 게시물 한 건. U(수정 링크)/Y(빈 열)열은 원본 시트의 건너뛰는 열이라 대응 필드가 없다. */ |
|
| 427 |
+export interface ProcessItem {
|
|
| 428 |
+ id: number |
|
| 429 |
+ orgId: number |
|
| 430 |
+ seq: number |
|
| 431 |
+ siteName: string | null |
|
| 432 |
+ category: string | null |
|
| 433 |
+ boardName: string | null |
|
| 434 |
+ postTitle: string | null |
|
| 435 |
+ url: string | null |
|
| 436 |
+ description: string | null |
|
| 437 |
+ hasAttachment: string | null |
|
| 438 |
+ priorKoglType: string | null |
|
| 439 |
+ contractDocs: string | null |
|
| 440 |
+ producedDate: string | null |
|
| 441 |
+ publishedDate: string | null |
|
| 442 |
+ reviewMajor: string | null |
|
| 443 |
+ reviewMinor: string | null |
|
| 444 |
+ reviewResult: string | null |
|
| 445 |
+ reviewKoglType: string | null |
|
| 446 |
+ reviewAiType: string | null |
|
| 447 |
+ reviewOpinion: string | null |
|
| 448 |
+ reviewNote: string | null |
|
| 449 |
+ priorEvidence: string | null |
|
| 450 |
+ judgedKoglType: string | null |
|
| 451 |
+ judgedAiType: string | null |
|
| 452 |
+ finalOpinion: string | null |
|
| 453 |
+ judgmentBasis: string | null |
|
| 454 |
+ processStatus: string | null |
|
| 455 |
+ webEditedAt: number | null |
|
| 456 |
+ processedAt: number | null |
|
| 457 |
+ createdAt: number |
|
| 458 |
+ updatedAt: number |
|
| 459 |
+} |
|
| 460 |
+ |
|
| 461 |
+export type ProcessStatusFilter = 'DONE' | 'PENDING' |
|
| 462 |
+ |
|
| 463 |
+/** total/done은 검색어·상태 필터와 무관한 기관 전체 진행률이다(헤더 "총 n건 · 처리완료 m건"용). */ |
|
| 464 |
+export interface ProcessPage {
|
|
| 465 |
+ items: ProcessItem[] |
|
| 466 |
+ total: number |
|
| 467 |
+ done: number |
|
| 468 |
+ page: number |
|
| 469 |
+ size: number |
|
| 470 |
+} |
|
| 471 |
+ |
|
| 472 |
+export interface ProcessImportReport {
|
|
| 473 |
+ created: number |
|
| 474 |
+ updated: number |
|
| 475 |
+ total: number |
|
| 476 |
+} |
|
| 477 |
+ |
|
| 478 |
+/** 권리처리 상세의 [처리등록]/[수정] 저장 요청. 전부 선택값이다. */ |
|
| 479 |
+export interface ProcessingRequest {
|
|
| 480 |
+ contractDocs: string | null |
|
| 481 |
+ judgedKoglType: string | null |
|
| 482 |
+ judgedAiType: string | null |
|
| 483 |
+ finalOpinion: string | null |
|
| 484 |
+ judgmentBasis: string | null |
|
| 485 |
+ processStatus: string | null |
|
| 486 |
+} |
|
| 487 |
+ |
|
| 488 |
+export function importProcess(orgId: number, file: File): Promise<ProcessImportReport> {
|
|
| 489 |
+ const form = new FormData() |
|
| 490 |
+ form.append('file', file)
|
|
| 491 |
+ return request<ProcessImportReport>(`/api/orgs/${orgId}/process/import`, { method: 'POST', body: form })
|
|
| 492 |
+} |
|
| 493 |
+ |
|
| 494 |
+export function getProcessPage( |
|
| 495 |
+ orgId: number, |
|
| 496 |
+ params: { keyword?: string; status?: ProcessStatusFilter; page?: number; size?: number } = {},
|
|
| 497 |
+): Promise<ProcessPage> {
|
|
| 498 |
+ const query = new URLSearchParams() |
|
| 499 |
+ if (params.keyword) {
|
|
| 500 |
+ query.set('keyword', params.keyword)
|
|
| 501 |
+ } |
|
| 502 |
+ if (params.status) {
|
|
| 503 |
+ query.set('status', params.status)
|
|
| 504 |
+ } |
|
| 505 |
+ if (params.page !== undefined) {
|
|
| 506 |
+ query.set('page', String(params.page))
|
|
| 507 |
+ } |
|
| 508 |
+ if (params.size !== undefined) {
|
|
| 509 |
+ query.set('size', String(params.size))
|
|
| 510 |
+ } |
|
| 511 |
+ const qs = query.toString() |
|
| 512 |
+ return request<ProcessPage>(`/api/orgs/${orgId}/process${qs ? `?${qs}` : ''}`)
|
|
| 513 |
+} |
|
| 514 |
+ |
|
| 515 |
+export function getProcessItem(orgId: number, itemId: number): Promise<ProcessItem> {
|
|
| 516 |
+ return request<ProcessItem>(`/api/orgs/${orgId}/process/${itemId}`)
|
|
| 517 |
+} |
|
| 518 |
+ |
|
| 519 |
+export function updateProcessing( |
|
| 520 |
+ orgId: number, |
|
| 521 |
+ itemId: number, |
|
| 522 |
+ body: ProcessingRequest, |
|
| 523 |
+): Promise<ProcessItem> {
|
|
| 524 |
+ return request<ProcessItem>(`/api/orgs/${orgId}/process/${itemId}`, {
|
|
| 525 |
+ method: 'PUT', |
|
| 526 |
+ headers: { 'Content-Type': 'application/json' },
|
|
| 527 |
+ body: JSON.stringify(body), |
|
| 528 |
+ }) |
|
| 529 |
+} |
|
| 530 |
+ |
|
| 531 |
+/** 204를 돌려주므로 request()의 json() 경로를 타지 않고 직접 부른다. */ |
|
| 532 |
+export async function deleteProcessItem(orgId: number, itemId: number): Promise<void> {
|
|
| 533 |
+ const response = await fetch(`/api/orgs/${orgId}/process/${itemId}`, {
|
|
| 534 |
+ method: 'DELETE', |
|
| 535 |
+ credentials: 'same-origin', |
|
| 536 |
+ headers: { 'X-XSRF-TOKEN': csrfToken() },
|
|
| 537 |
+ }) |
|
| 538 |
+ if (!response.ok) {
|
|
| 539 |
+ const body = await response.text() |
|
| 540 |
+ throw new ApiError(response.status, `요청 실패 (${response.status}): ${body}`)
|
|
| 541 |
+ } |
|
| 542 |
+} |
|
| 543 |
+ |
|
| 544 |
+/** 다운로드는 <a href>가 그대로 열게 두므로 fetch()를 거치지 않는다. */ |
|
| 545 |
+export function processDownloadUrl(orgId: number): string {
|
|
| 546 |
+ return `/api/orgs/${orgId}/process/download`
|
|
| 547 |
+} |
|
| 548 |
+ |
|
| 426 | 549 |
export function uploadSeed(file: File): Promise<SeedReport> {
|
| 427 | 550 |
const form = new FormData() |
| 428 | 551 |
form.append('file', file)
|
--- frontend/src/components/OrgOverview.test.tsx
+++ frontend/src/components/OrgOverview.test.tsx
... | ... | @@ -13,6 +13,7 @@ |
| 13 | 13 |
updateStage: vi.fn(), |
| 14 | 14 |
getStageHistory: vi.fn(), |
| 15 | 15 |
getReviewPage: vi.fn(), |
| 16 |
+ getProcessPage: vi.fn(), |
|
| 16 | 17 |
})) |
| 17 | 18 |
|
| 18 | 19 |
vi.mock('../api/client', async (importOriginal) => ({
|
... | ... | @@ -26,6 +27,7 @@ |
| 26 | 27 |
updateStage: mocks.updateStage, |
| 27 | 28 |
getStageHistory: mocks.getStageHistory, |
| 28 | 29 |
getReviewPage: mocks.getReviewPage, |
| 30 |
+ getProcessPage: mocks.getProcessPage, |
|
| 29 | 31 |
})) |
| 30 | 32 |
|
| 31 | 33 |
function contact(overrides: Partial<Contact> = {}): Contact {
|
... | ... | @@ -79,6 +81,7 @@ |
| 79 | 81 |
mocks.getTimeline.mockReset().mockResolvedValue([]) |
| 80 | 82 |
mocks.updateStage.mockReset().mockResolvedValue(undefined) |
| 81 | 83 |
mocks.getReviewPage.mockReset().mockResolvedValue({ items: [], total: 0, done: 0, page: 0, size: 30 })
|
| 84 |
+ mocks.getProcessPage.mockReset().mockResolvedValue({ items: [], total: 0, done: 0, page: 0, size: 30 })
|
|
| 82 | 85 |
}) |
| 83 | 86 |
|
| 84 | 87 |
describe('OrgOverview', () => {
|
... | ... | @@ -281,12 +284,36 @@ |
| 281 | 284 |
|
| 282 | 285 |
await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled()) |
| 283 | 286 |
|
| 287 |
+ fireEvent.click(screen.getByRole('button', { name: 'RE' }))
|
|
| 288 |
+ |
|
| 289 |
+ expect(screen.getByText('RE 상세 화면은 추후 확정')).toBeTruthy()
|
|
| 290 |
+ expect(screen.queryByRole('button', { name: '문정원' })).toBeNull()
|
|
| 291 |
+ }) |
|
| 292 |
+ |
|
| 293 |
+ it('권리처리 탭을 누르면 권리처리 보드가 마운트되어 조회를 부른다', async () => {
|
|
| 294 |
+ render(<OrgOverview org={org()} />)
|
|
| 295 |
+ |
|
| 296 |
+ await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled()) |
|
| 297 |
+ |
|
| 284 | 298 |
fireEvent.click(screen.getByRole('button', { name: '권리처리' }))
|
| 285 | 299 |
|
| 286 |
- expect(screen.getByText('권리처리 상세 화면은 추후 확정')).toBeTruthy()
|
|
| 300 |
+ await waitFor(() => expect(mocks.getProcessPage).toHaveBeenCalledWith(1, expect.anything())) |
|
| 301 |
+ expect(screen.queryByText('권리처리 상세 화면은 추후 확정')).toBeNull()
|
|
| 287 | 302 |
expect(screen.queryByRole('button', { name: '문정원' })).toBeNull()
|
| 288 | 303 |
}) |
| 289 | 304 |
|
| 305 |
+ it('권리확인/권리처리 통계 카드가 각 조회 결과의 완료·전체 건수를 보여준다', async () => {
|
|
| 306 |
+ mocks.getReviewPage.mockResolvedValue({ items: [], total: 12, done: 5, page: 0, size: 1 })
|
|
| 307 |
+ mocks.getProcessPage.mockResolvedValue({ items: [], total: 8, done: 3, page: 0, size: 1 })
|
|
| 308 |
+ |
|
| 309 |
+ render(<OrgOverview org={org()} />)
|
|
| 310 |
+ |
|
| 311 |
+ await waitFor(() => {
|
|
| 312 |
+ expect(screen.getByText('완료 5 / 전체 12')).toBeTruthy()
|
|
| 313 |
+ expect(screen.getByText('완료 3 / 전체 8')).toBeTruthy()
|
|
| 314 |
+ }) |
|
| 315 |
+ }) |
|
| 316 |
+ |
|
| 290 | 317 |
it('타임라인 탭을 누르면 Timeline이 마운트되어 조회를 부른다', async () => {
|
| 291 | 318 |
render(<OrgOverview org={org()} />)
|
| 292 | 319 |
|
--- frontend/src/components/OrgOverview.tsx
+++ frontend/src/components/OrgOverview.tsx
... | ... | @@ -1,5 +1,7 @@ |
| 1 | 1 |
import { useEffect, useState } from 'react'
|
| 2 | 2 |
import {
|
| 3 |
+ getProcessPage, |
|
| 4 |
+ getReviewPage, |
|
| 3 | 5 |
getStageHistory, |
| 4 | 6 |
updateAssignments, |
| 5 | 7 |
updateStage, |
... | ... | @@ -11,6 +13,7 @@ |
| 11 | 13 |
import ChannelFiles from './ChannelFiles' |
| 12 | 14 |
import ChannelPosts from './ChannelPosts' |
| 13 | 15 |
import ContactPickerModal from './ContactPickerModal' |
| 16 |
+import ProcessBoard from './ProcessBoard' |
|
| 14 | 17 |
import ReviewBoard from './ReviewBoard' |
| 15 | 18 |
import StatusBadge from './StatusBadge' |
| 16 | 19 |
import Timeline from './Timeline' |
... | ... | @@ -129,6 +132,46 @@ |
| 129 | 132 |
const [stagePickerOpen, setStagePickerOpen] = useState(false) |
| 130 | 133 |
// 단계별로 '그 단계에 들어간 날짜'(가장 최근 이력)를 찍기 위한 조회 결과 |
| 131 | 134 |
const [stageDates, setStageDates] = useState<Record<number, number>>({})
|
| 135 |
+ |
|
| 136 |
+ // 통계 카드용 권리확인/권리처리 완료·전체 건수. 탭을 열지 않아도 헤더에 바로 보여야 하므로 |
|
| 137 |
+ // 각 탭이 마운트되기 전에 가볍게(size:1) 별도로 조회한다 - 실패해도 카드는 '-'로 남을 뿐 |
|
| 138 |
+ // 화면 전체를 깨뜨리지 않는다. |
|
| 139 |
+ const [reviewStats, setReviewStats] = useState<{ done: number; total: number } | null>(null)
|
|
| 140 |
+ const [processStats, setProcessStats] = useState<{ done: number; total: number } | null>(null)
|
|
| 141 |
+ |
|
| 142 |
+ useEffect(() => {
|
|
| 143 |
+ let cancelled = false |
|
| 144 |
+ setReviewStats(null) |
|
| 145 |
+ getReviewPage(org.id, { page: 0, size: 1 })
|
|
| 146 |
+ .then((data) => {
|
|
| 147 |
+ if (!cancelled) {
|
|
| 148 |
+ setReviewStats({ done: data.done, total: data.total })
|
|
| 149 |
+ } |
|
| 150 |
+ }) |
|
| 151 |
+ .catch(() => {
|
|
| 152 |
+ /* 통계는 장식이다 - 조회 실패로 화면을 깨지 않는다 */ |
|
| 153 |
+ }) |
|
| 154 |
+ return () => {
|
|
| 155 |
+ cancelled = true |
|
| 156 |
+ } |
|
| 157 |
+ }, [org.id]) |
|
| 158 |
+ |
|
| 159 |
+ useEffect(() => {
|
|
| 160 |
+ let cancelled = false |
|
| 161 |
+ setProcessStats(null) |
|
| 162 |
+ getProcessPage(org.id, { page: 0, size: 1 })
|
|
| 163 |
+ .then((data) => {
|
|
| 164 |
+ if (!cancelled) {
|
|
| 165 |
+ setProcessStats({ done: data.done, total: data.total })
|
|
| 166 |
+ } |
|
| 167 |
+ }) |
|
| 168 |
+ .catch(() => {
|
|
| 169 |
+ /* 통계는 장식이다 - 조회 실패로 화면을 깨지 않는다 */ |
|
| 170 |
+ }) |
|
| 171 |
+ return () => {
|
|
| 172 |
+ cancelled = true |
|
| 173 |
+ } |
|
| 174 |
+ }, [org.id]) |
|
| 132 | 175 |
|
| 133 | 176 |
useEffect(() => {
|
| 134 | 177 |
let cancelled = false |
... | ... | @@ -408,21 +451,41 @@ |
| 408 | 451 |
{stageError && <p className="mt-1 text-xs text-red-600">{stageError}</p>}
|
| 409 | 452 |
</section> |
| 410 | 453 |
|
| 411 |
- {/* 통계 4칸 */}
|
|
| 454 |
+ {/* 통계 4칸. 권리확인/권리처리는 실제 완료·전체 건수를 보여주고, 나머지는 아직 백엔드가
|
|
| 455 |
+ 없어 자리만 잡아 둔다. */} |
|
| 412 | 456 |
<div className="mt-4 grid grid-cols-2 gap-3 lg:grid-cols-4"> |
| 413 |
- {[
|
|
| 414 |
- ['사전검토', '검토 대상'], |
|
| 415 |
- ['권리확인', '완료 / 전체'], |
|
| 416 |
- ['권리처리', '완료 / 전체'], |
|
| 417 |
- ['RE', '확인·처리 합계'], |
|
| 418 |
- ].map(([title, caption]) => ( |
|
| 419 |
- <div key={title} className="rounded-lg border border-gray-200 p-4">
|
|
| 420 |
- <p className="text-xs text-gray-400">{title}</p>
|
|
| 421 |
- <p className="mt-1 text-sm font-semibold"> |
|
| 422 |
- {caption} <span className="ml-1 text-lg text-gray-300">-</span>
|
|
| 423 |
- </p> |
|
| 424 |
- </div> |
|
| 425 |
- ))} |
|
| 457 |
+ <div className="rounded-lg border border-gray-200 p-4"> |
|
| 458 |
+ <p className="text-xs text-gray-400">사전검토</p> |
|
| 459 |
+ <p className="mt-1 text-sm font-semibold"> |
|
| 460 |
+ 검토 대상 <span className="ml-1 text-lg text-gray-300">-</span> |
|
| 461 |
+ </p> |
|
| 462 |
+ </div> |
|
| 463 |
+ <div className="rounded-lg border border-gray-200 p-4"> |
|
| 464 |
+ <p className="text-xs text-gray-400">권리확인</p> |
|
| 465 |
+ <p className="mt-1 text-sm font-semibold"> |
|
| 466 |
+ {reviewStats ? `완료 ${reviewStats.done} / 전체 ${reviewStats.total}` : (
|
|
| 467 |
+ <> |
|
| 468 |
+ 완료 / 전체 <span className="ml-1 text-lg text-gray-300">-</span> |
|
| 469 |
+ </> |
|
| 470 |
+ )} |
|
| 471 |
+ </p> |
|
| 472 |
+ </div> |
|
| 473 |
+ <div className="rounded-lg border border-gray-200 p-4"> |
|
| 474 |
+ <p className="text-xs text-gray-400">권리처리</p> |
|
| 475 |
+ <p className="mt-1 text-sm font-semibold"> |
|
| 476 |
+ {processStats ? `완료 ${processStats.done} / 전체 ${processStats.total}` : (
|
|
| 477 |
+ <> |
|
| 478 |
+ 완료 / 전체 <span className="ml-1 text-lg text-gray-300">-</span> |
|
| 479 |
+ </> |
|
| 480 |
+ )} |
|
| 481 |
+ </p> |
|
| 482 |
+ </div> |
|
| 483 |
+ <div className="rounded-lg border border-gray-200 p-4"> |
|
| 484 |
+ <p className="text-xs text-gray-400">RE</p> |
|
| 485 |
+ <p className="mt-1 text-sm font-semibold"> |
|
| 486 |
+ 확인·처리 합계 <span className="ml-1 text-lg text-gray-300">-</span> |
|
| 487 |
+ </p> |
|
| 488 |
+ </div> |
|
| 426 | 489 |
</div> |
| 427 | 490 |
|
| 428 | 491 |
{/* 탭 */}
|
... | ... | @@ -462,6 +525,10 @@ |
| 462 | 525 |
<div className="mt-4 rounded-lg border border-gray-200"> |
| 463 | 526 |
<ReviewBoard key={org.id} org={org} />
|
| 464 | 527 |
</div> |
| 528 |
+ ) : activeTab === '권리처리' ? ( |
|
| 529 |
+ <div className="mt-4 rounded-lg border border-gray-200"> |
|
| 530 |
+ <ProcessBoard key={org.id} org={org} />
|
|
| 531 |
+ </div> |
|
| 465 | 532 |
) : activeTab === '타임라인' ? ( |
| 466 | 533 |
<div className="mt-4 rounded-lg border border-gray-200"> |
| 467 | 534 |
<Timeline key={org.id} org={org} />
|
+++ frontend/src/components/ProcessBoard.test.tsx
... | ... | @@ -0,0 +1,353 @@ |
| 1 | +import { fireEvent, render, screen, waitFor } from '@testing-library/react' | |
| 2 | +import { beforeEach, describe, expect, it, vi } from 'vitest' | |
| 3 | +import ProcessBoard from './ProcessBoard' | |
| 4 | +import type { Org, ProcessItem, ProcessPage } from '../api/client' | |
| 5 | + | |
| 6 | +const mocks = vi.hoisted(() => ({ | |
| 7 | + getProcessPage: vi.fn(), | |
| 8 | + getProcessItem: vi.fn(), | |
| 9 | + importProcess: vi.fn(), | |
| 10 | + updateProcessing: vi.fn(), | |
| 11 | + deleteProcessItem: vi.fn(), | |
| 12 | +})) | |
| 13 | + | |
| 14 | +vi.mock('../api/client', async (importOriginal) => ({ | |
| 15 | + ...(await importOriginal<typeof import('../api/client')>()), | |
| 16 | + getProcessPage: mocks.getProcessPage, | |
| 17 | + getProcessItem: mocks.getProcessItem, | |
| 18 | + importProcess: mocks.importProcess, | |
| 19 | + updateProcessing: mocks.updateProcessing, | |
| 20 | + deleteProcessItem: mocks.deleteProcessItem, | |
| 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<ProcessItem> = {}): ProcessItem { | |
| 42 | + return { | |
| 43 | + id: 1, | |
| 44 | + orgId: 1, | |
| 45 | + seq: 1, | |
| 46 | + siteName: '한국출판문화산업진흥원', | |
| 47 | + category: '공지', | |
| 48 | + boardName: '공지사항', | |
| 49 | + postTitle: '2026년 사업 공고', | |
| 50 | + url: 'https://example.com/1', | |
| 51 | + description: null, | |
| 52 | + hasAttachment: 'N', | |
| 53 | + priorKoglType: '1유형', | |
| 54 | + contractDocs: null, | |
| 55 | + producedDate: '2026-01-01', | |
| 56 | + publishedDate: '2026-01-02', | |
| 57 | + reviewMajor: '만료 저작물', | |
| 58 | + reviewMinor: null, | |
| 59 | + reviewResult: '신유형 개방', | |
| 60 | + reviewKoglType: '1유형', | |
| 61 | + reviewAiType: null, | |
| 62 | + reviewOpinion: null, | |
| 63 | + reviewNote: null, | |
| 64 | + priorEvidence: null, | |
| 65 | + judgedKoglType: null, | |
| 66 | + judgedAiType: null, | |
| 67 | + finalOpinion: null, | |
| 68 | + judgmentBasis: null, | |
| 69 | + processStatus: null, | |
| 70 | + webEditedAt: null, | |
| 71 | + processedAt: null, | |
| 72 | + createdAt: 0, | |
| 73 | + updatedAt: 0, | |
| 74 | + ...overrides, | |
| 75 | + } | |
| 76 | +} | |
| 77 | + | |
| 78 | +function page(overrides: Partial<ProcessPage> = {}): ProcessPage { | |
| 79 | + return { | |
| 80 | + items: [item()], | |
| 81 | + total: 1, | |
| 82 | + done: 0, | |
| 83 | + page: 0, | |
| 84 | + size: 30, | |
| 85 | + ...overrides, | |
| 86 | + } | |
| 87 | +} | |
| 88 | + | |
| 89 | +beforeEach(() => { | |
| 90 | + mocks.getProcessPage.mockReset() | |
| 91 | + mocks.getProcessItem.mockReset() | |
| 92 | + mocks.importProcess.mockReset() | |
| 93 | + mocks.updateProcessing.mockReset() | |
| 94 | + mocks.deleteProcessItem.mockReset() | |
| 95 | +}) | |
| 96 | + | |
| 97 | +describe('ProcessBoard 목록', () => { | |
| 98 | + it('목록을 표로 보여주고 처리상태 배지가 나온다', async () => { | |
| 99 | + mocks.getProcessPage.mockResolvedValue(page()) | |
| 100 | + | |
| 101 | + render(<ProcessBoard org={org()} />) | |
| 102 | + | |
| 103 | + await waitFor(() => { | |
| 104 | + expect(screen.getByText('2026년 사업 공고')).toBeTruthy() | |
| 105 | + // "미처리"는 상태 필터 칩과 배지 둘 다에 나오므로 배지를 role로 구분해 확인한다. | |
| 106 | + expect(screen.getAllByText('미처리').length).toBeGreaterThanOrEqual(2) | |
| 107 | + }) | |
| 108 | + expect(screen.getByText('총 1건 · 처리완료 0건')).toBeTruthy() | |
| 109 | + }) | |
| 110 | + | |
| 111 | + it('자료가 없으면 안내 문구를 보여준다', async () => { | |
| 112 | + mocks.getProcessPage.mockResolvedValue(page({ items: [], total: 0 })) | |
| 113 | + | |
| 114 | + render(<ProcessBoard org={org()} />) | |
| 115 | + | |
| 116 | + await waitFor(() => { | |
| 117 | + expect( | |
| 118 | + screen.getByText('업로드된 권리처리 자료가 없습니다. 엑셀 업로드로 시작하세요.'), | |
| 119 | + ).toBeTruthy() | |
| 120 | + }) | |
| 121 | + }) | |
| 122 | + | |
| 123 | + it('상태 칩을 누르면 status 파라미터로 다시 조회한다', async () => { | |
| 124 | + mocks.getProcessPage.mockResolvedValue(page()) | |
| 125 | + | |
| 126 | + render(<ProcessBoard org={org()} />) | |
| 127 | + | |
| 128 | + await waitFor(() => | |
| 129 | + expect(mocks.getProcessPage).toHaveBeenCalledWith(1, { | |
| 130 | + keyword: undefined, | |
| 131 | + status: undefined, | |
| 132 | + page: 0, | |
| 133 | + size: 30, | |
| 134 | + }), | |
| 135 | + ) | |
| 136 | + | |
| 137 | + fireEvent.click(screen.getByRole('button', { name: '처리완료' })) | |
| 138 | + | |
| 139 | + await waitFor(() => | |
| 140 | + expect(mocks.getProcessPage).toHaveBeenCalledWith(1, { | |
| 141 | + keyword: undefined, | |
| 142 | + status: 'DONE', | |
| 143 | + page: 0, | |
| 144 | + size: 30, | |
| 145 | + }), | |
| 146 | + ) | |
| 147 | + | |
| 148 | + fireEvent.click(screen.getByRole('button', { name: '미처리' })) | |
| 149 | + | |
| 150 | + await waitFor(() => | |
| 151 | + expect(mocks.getProcessPage).toHaveBeenCalledWith(1, { | |
| 152 | + keyword: undefined, | |
| 153 | + status: 'PENDING', | |
| 154 | + page: 0, | |
| 155 | + size: 30, | |
| 156 | + }), | |
| 157 | + ) | |
| 158 | + }) | |
| 159 | + | |
| 160 | + it('검색어를 입력하고 검색을 누르면 keyword로 다시 조회한다', async () => { | |
| 161 | + mocks.getProcessPage.mockResolvedValue(page()) | |
| 162 | + | |
| 163 | + render(<ProcessBoard org={org()} />) | |
| 164 | + | |
| 165 | + await waitFor(() => expect(mocks.getProcessPage).toHaveBeenCalledTimes(1)) | |
| 166 | + | |
| 167 | + fireEvent.change(screen.getByLabelText('검색어'), { target: { value: '출판' } }) | |
| 168 | + fireEvent.click(screen.getByRole('button', { name: '검색' })) | |
| 169 | + | |
| 170 | + await waitFor(() => | |
| 171 | + expect(mocks.getProcessPage).toHaveBeenCalledWith(1, { | |
| 172 | + keyword: '출판', | |
| 173 | + status: undefined, | |
| 174 | + page: 0, | |
| 175 | + size: 30, | |
| 176 | + }), | |
| 177 | + ) | |
| 178 | + }) | |
| 179 | + | |
| 180 | + it('엑셀 업로드를 하면 importProcess를 호출하고 결과를 보여준다', async () => { | |
| 181 | + mocks.getProcessPage.mockResolvedValue(page()) | |
| 182 | + mocks.importProcess.mockResolvedValue({ created: 3, updated: 2, total: 5 }) | |
| 183 | + | |
| 184 | + const { container } = render(<ProcessBoard org={org()} />) | |
| 185 | + | |
| 186 | + await waitFor(() => expect(mocks.getProcessPage).toHaveBeenCalledTimes(1)) | |
| 187 | + | |
| 188 | + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement | |
| 189 | + const file = new File(['dummy'], 'process.xlsx', { | |
| 190 | + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', | |
| 191 | + }) | |
| 192 | + fireEvent.change(fileInput, { target: { files: [file] } }) | |
| 193 | + | |
| 194 | + await waitFor(() => expect(mocks.importProcess).toHaveBeenCalledWith(1, file)) | |
| 195 | + await waitFor(() => { | |
| 196 | + expect(screen.getByText('신규 3 · 갱신 2 · 전체 5')).toBeTruthy() | |
| 197 | + }) | |
| 198 | + }) | |
| 199 | + | |
| 200 | + it('처리완료 건은 수정/삭제 버튼이, 미처리 건은 처리등록 버튼만 보인다', async () => { | |
| 201 | + mocks.getProcessPage.mockResolvedValue(page({ items: [item({ processStatus: '처리완료' })] })) | |
| 202 | + | |
| 203 | + render(<ProcessBoard org={org()} />) | |
| 204 | + | |
| 205 | + await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy()) | |
| 206 | + expect(screen.getByRole('button', { name: '수정' })).toBeTruthy() | |
| 207 | + expect(screen.getByRole('button', { name: '삭제' })).toBeTruthy() | |
| 208 | + expect(screen.queryByRole('button', { name: '처리등록' })).toBeNull() | |
| 209 | + }) | |
| 210 | + | |
| 211 | + it('삭제를 누르면 확인 후 deleteProcessItem을 호출한다', async () => { | |
| 212 | + mocks.getProcessPage.mockResolvedValue(page({ items: [item({ processStatus: '처리완료' })] })) | |
| 213 | + mocks.deleteProcessItem.mockResolvedValue(undefined) | |
| 214 | + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) | |
| 215 | + | |
| 216 | + render(<ProcessBoard org={org()} />) | |
| 217 | + | |
| 218 | + await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy()) | |
| 219 | + | |
| 220 | + fireEvent.click(screen.getByRole('button', { name: '삭제' })) | |
| 221 | + | |
| 222 | + expect(confirmSpy).toHaveBeenCalled() | |
| 223 | + await waitFor(() => expect(mocks.deleteProcessItem).toHaveBeenCalledWith(1, 1)) | |
| 224 | + | |
| 225 | + confirmSpy.mockRestore() | |
| 226 | + }) | |
| 227 | + | |
| 228 | + it('다음 페이지 버튼을 누르면 page+1로 다시 조회한다', async () => { | |
| 229 | + mocks.getProcessPage.mockResolvedValue(page({ total: 31 })) | |
| 230 | + | |
| 231 | + render(<ProcessBoard org={org()} />) | |
| 232 | + | |
| 233 | + await waitFor(() => | |
| 234 | + expect(mocks.getProcessPage).toHaveBeenCalledWith(1, { | |
| 235 | + keyword: undefined, | |
| 236 | + status: undefined, | |
| 237 | + page: 0, | |
| 238 | + size: 30, | |
| 239 | + }), | |
| 240 | + ) | |
| 241 | + | |
| 242 | + fireEvent.click(screen.getByRole('button', { name: '›' })) | |
| 243 | + | |
| 244 | + await waitFor(() => | |
| 245 | + expect(mocks.getProcessPage).toHaveBeenCalledWith(1, { | |
| 246 | + keyword: undefined, | |
| 247 | + status: undefined, | |
| 248 | + page: 1, | |
| 249 | + size: 30, | |
| 250 | + }), | |
| 251 | + ) | |
| 252 | + }) | |
| 253 | +}) | |
| 254 | + | |
| 255 | +describe('ProcessBoard 상세', () => { | |
| 256 | + it('처리등록을 누르면 상세 화면이 열리고 조회한 값이 채워진다', async () => { | |
| 257 | + mocks.getProcessPage.mockResolvedValue(page()) | |
| 258 | + mocks.getProcessItem.mockResolvedValue(item({ finalOpinion: '검토 완료' })) | |
| 259 | + | |
| 260 | + render(<ProcessBoard org={org()} />) | |
| 261 | + | |
| 262 | + await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy()) | |
| 263 | + fireEvent.click(screen.getByRole('button', { name: '처리등록' })) | |
| 264 | + | |
| 265 | + await waitFor(() => expect(mocks.getProcessItem).toHaveBeenCalledWith(1, 1)) | |
| 266 | + await waitFor(() => { | |
| 267 | + expect((screen.getByLabelText('최종의견') as HTMLTextAreaElement).value).toBe('검토 완료') | |
| 268 | + }) | |
| 269 | + }) | |
| 270 | + | |
| 271 | + it('권리처리상태를 선택하지 않으면 저장 버튼이 비활성이고, 고르면 활성화된다', async () => { | |
| 272 | + mocks.getProcessPage.mockResolvedValue(page()) | |
| 273 | + mocks.getProcessItem.mockResolvedValue(item()) | |
| 274 | + | |
| 275 | + render(<ProcessBoard org={org()} />) | |
| 276 | + | |
| 277 | + await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy()) | |
| 278 | + fireEvent.click(screen.getByRole('button', { name: '처리등록' })) | |
| 279 | + await waitFor(() => expect(mocks.getProcessItem).toHaveBeenCalled()) | |
| 280 | + | |
| 281 | + expect(screen.getByRole('button', { name: '수정' })).toBeDisabled() | |
| 282 | + | |
| 283 | + fireEvent.change(screen.getByLabelText('*권리처리상태'), { target: { value: '처리완료' } }) | |
| 284 | + | |
| 285 | + expect(screen.getByRole('button', { name: '수정' })).not.toBeDisabled() | |
| 286 | + }) | |
| 287 | + | |
| 288 | + it('계약서 유무 체크박스 조합과 기타 입력이 쉼표로 연결되어 저장 페이로드에 실린다', async () => { | |
| 289 | + mocks.getProcessPage.mockResolvedValue(page()) | |
| 290 | + mocks.getProcessItem.mockResolvedValue(item()) | |
| 291 | + mocks.updateProcessing.mockResolvedValue(item({ processStatus: '처리완료' })) | |
| 292 | + | |
| 293 | + render(<ProcessBoard org={org()} />) | |
| 294 | + | |
| 295 | + await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy()) | |
| 296 | + fireEvent.click(screen.getByRole('button', { name: '처리등록' })) | |
| 297 | + await waitFor(() => expect(mocks.getProcessItem).toHaveBeenCalled()) | |
| 298 | + | |
| 299 | + fireEvent.click(screen.getByRole('checkbox', { name: '양도계약서' })) | |
| 300 | + fireEvent.click(screen.getByRole('checkbox', { name: '공문' })) | |
| 301 | + fireEvent.click(screen.getByRole('checkbox', { name: '기타' })) | |
| 302 | + fireEvent.change(screen.getByLabelText('기타 계약서 내용'), { target: { value: '직접 촬영본 사용동의' } }) | |
| 303 | + fireEvent.change(screen.getByLabelText('*권리처리상태'), { target: { value: '처리완료' } }) | |
| 304 | + fireEvent.click(screen.getByRole('button', { name: '수정' })) | |
| 305 | + | |
| 306 | + await waitFor(() => | |
| 307 | + expect(mocks.updateProcessing).toHaveBeenCalledWith(1, 1, { | |
| 308 | + contractDocs: '양도계약서, 공문, 기타:직접 촬영본 사용동의', | |
| 309 | + judgedKoglType: null, | |
| 310 | + judgedAiType: null, | |
| 311 | + finalOpinion: null, | |
| 312 | + judgmentBasis: null, | |
| 313 | + processStatus: '처리완료', | |
| 314 | + }), | |
| 315 | + ) | |
| 316 | + | |
| 317 | + // 저장 후 목록으로 복귀 | |
| 318 | + await waitFor(() => expect(screen.getByRole('button', { name: '엑셀 업로드' })).toBeTruthy()) | |
| 319 | + }) | |
| 320 | + | |
| 321 | + it('기존에 저장된 계약서 유무 값을 체크박스 상태로 역파싱해 보여준다', async () => { | |
| 322 | + mocks.getProcessPage.mockResolvedValue(page()) | |
| 323 | + mocks.getProcessItem.mockResolvedValue( | |
| 324 | + item({ contractDocs: '양도계약서, 기타:구두 동의' }), | |
| 325 | + ) | |
| 326 | + | |
| 327 | + render(<ProcessBoard org={org()} />) | |
| 328 | + | |
| 329 | + await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy()) | |
| 330 | + fireEvent.click(screen.getByRole('button', { name: '처리등록' })) | |
| 331 | + await waitFor(() => expect(mocks.getProcessItem).toHaveBeenCalled()) | |
| 332 | + | |
| 333 | + expect(screen.getByRole('checkbox', { name: '양도계약서' })).toBeChecked() | |
| 334 | + expect(screen.getByRole('checkbox', { name: '기타' })).toBeChecked() | |
| 335 | + expect((screen.getByLabelText('기타 계약서 내용') as HTMLInputElement).value).toBe('구두 동의') | |
| 336 | + }) | |
| 337 | + | |
| 338 | + it('목록 버튼을 누르면 저장 없이 목록으로 돌아간다', async () => { | |
| 339 | + mocks.getProcessPage.mockResolvedValue(page()) | |
| 340 | + mocks.getProcessItem.mockResolvedValue(item()) | |
| 341 | + | |
| 342 | + render(<ProcessBoard org={org()} />) | |
| 343 | + | |
| 344 | + await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy()) | |
| 345 | + fireEvent.click(screen.getByRole('button', { name: '처리등록' })) | |
| 346 | + await waitFor(() => expect(mocks.getProcessItem).toHaveBeenCalled()) | |
| 347 | + | |
| 348 | + fireEvent.click(screen.getByRole('button', { name: '목록' })) | |
| 349 | + | |
| 350 | + expect(screen.getByRole('button', { name: '엑셀 업로드' })).toBeTruthy() | |
| 351 | + expect(mocks.updateProcessing).not.toHaveBeenCalled() | |
| 352 | + }) | |
| 353 | +}) |
+++ frontend/src/components/ProcessBoard.tsx
... | ... | @@ -0,0 +1,742 @@ |
| 1 | +import { useEffect, useRef, useState } from 'react' | |
| 2 | +import { | |
| 3 | + deleteProcessItem, | |
| 4 | + getProcessItem, | |
| 5 | + getProcessPage, | |
| 6 | + importProcess, | |
| 7 | + processDownloadUrl, | |
| 8 | + updateProcessing, | |
| 9 | + type Org, | |
| 10 | + type ProcessImportReport, | |
| 11 | + type ProcessItem, | |
| 12 | + type ProcessStatusFilter, | |
| 13 | + type ProcessingRequest, | |
| 14 | +} from '../api/client' | |
| 15 | + | |
| 16 | +const PAGE_SIZE = 30 | |
| 17 | + | |
| 18 | +const STATUS_CHIPS: { key: ProcessStatusFilter | undefined; label: string }[] = [ | |
| 19 | + { key: undefined, label: '전체' }, | |
| 20 | + { key: 'PENDING', label: '미처리' }, | |
| 21 | + { key: 'DONE', label: '처리완료' }, | |
| 22 | +] | |
| 23 | + | |
| 24 | +const CONTRACT_DOC_OPTIONS = ['양도계약서', '제안요청서', '초상이용동의서', '공공누리동의서', '공문'] | |
| 25 | +const CONTRACT_ETC = '기타' | |
| 26 | + | |
| 27 | +const JUDGED_KOGL_TYPE_OPTIONS = ['0유형', '개방불가', '1유형', '2유형', '3유형', '4유형'] | |
| 28 | + | |
| 29 | +const PROCESS_STATUS_OPTIONS = ['미처리', '처리완료'] | |
| 30 | + | |
| 31 | +/** "양도계약서, 공문, 기타:직접 촬영본 사용동의" 형태의 저장값 → 체크박스 상태로 역파싱한다. */ | |
| 32 | +function parseContractDocs(value: string | null): { selected: Set<string>; etcText: string } { | |
| 33 | + const selected = new Set<string>() | |
| 34 | + let etcText = '' | |
| 35 | + if (!value) { | |
| 36 | + return { selected, etcText } | |
| 37 | + } | |
| 38 | + for (const raw of value.split(',')) { | |
| 39 | + const part = raw.trim() | |
| 40 | + if (!part) { | |
| 41 | + continue | |
| 42 | + } | |
| 43 | + if (part.startsWith(`${CONTRACT_ETC}:`)) { | |
| 44 | + selected.add(CONTRACT_ETC) | |
| 45 | + etcText = part.slice(CONTRACT_ETC.length + 1) | |
| 46 | + } else if (CONTRACT_DOC_OPTIONS.includes(part)) { | |
| 47 | + selected.add(part) | |
| 48 | + } | |
| 49 | + } | |
| 50 | + return { selected, etcText } | |
| 51 | +} | |
| 52 | + | |
| 53 | +/** 체크박스 상태 → 저장값. 아무것도 선택하지 않았으면 null. */ | |
| 54 | +function serializeContractDocs(selected: Set<string>, etcText: string): string | null { | |
| 55 | + const parts: string[] = CONTRACT_DOC_OPTIONS.filter((opt) => selected.has(opt)) | |
| 56 | + if (selected.has(CONTRACT_ETC)) { | |
| 57 | + parts.push(`${CONTRACT_ETC}:${etcText}`) | |
| 58 | + } | |
| 59 | + return parts.length > 0 ? parts.join(', ') : null | |
| 60 | +} | |
| 61 | + | |
| 62 | +function formatDate(ms: number | null): string { | |
| 63 | + if (!ms) { | |
| 64 | + return '-' | |
| 65 | + } | |
| 66 | + const d = new Date(ms) | |
| 67 | + const y = d.getFullYear() | |
| 68 | + const m = String(d.getMonth() + 1).padStart(2, '0') | |
| 69 | + const day = String(d.getDate()).padStart(2, '0') | |
| 70 | + return `${y}-${m}-${day}` | |
| 71 | +} | |
| 72 | + | |
| 73 | +function truncate(text: string | null, max: number): string { | |
| 74 | + if (!text) { | |
| 75 | + return '-' | |
| 76 | + } | |
| 77 | + return text.length > max ? `${text.slice(0, max)}…` : text | |
| 78 | +} | |
| 79 | + | |
| 80 | +function Field({ label, value }: { label: string; value: string | null }) { | |
| 81 | + return ( | |
| 82 | + <div className="flex gap-3 text-sm"> | |
| 83 | + <span className="w-24 shrink-0 text-gray-400">{label}</span> | |
| 84 | + <span className={value ? 'text-gray-900' : 'text-gray-300'}>{value ?? '-'}</span> | |
| 85 | + </div> | |
| 86 | + ) | |
| 87 | +} | |
| 88 | + | |
| 89 | +function ProcessStatusBadge({ value }: { value: string | null }) { | |
| 90 | + const done = value === '처리완료' | |
| 91 | + return ( | |
| 92 | + <span | |
| 93 | + className={`rounded-full px-2 py-0.5 text-xs font-medium ${ | |
| 94 | + done ? 'bg-emerald-50 text-emerald-700' : 'bg-gray-100 text-gray-500' | |
| 95 | + }`} | |
| 96 | + > | |
| 97 | + {done ? '처리완료' : '미처리'} | |
| 98 | + </span> | |
| 99 | + ) | |
| 100 | +} | |
| 101 | + | |
| 102 | +/** 기관관리 상세의 권리처리 탭. 목록↔상세 두 화면을 이 컴포넌트 하나에서 전환한다(ReviewBoard와 동일 구조). */ | |
| 103 | +export default function ProcessBoard({ org }: { org: Org }) { | |
| 104 | + const [mode, setMode] = useState<'list' | number>('list') | |
| 105 | + | |
| 106 | + const [items, setItems] = useState<ProcessItem[]>([]) | |
| 107 | + const [total, setTotal] = useState(0) | |
| 108 | + const [done, setDone] = useState(0) | |
| 109 | + const [page, setPage] = useState(0) | |
| 110 | + const [statusFilter, setStatusFilter] = useState<ProcessStatusFilter | undefined>(undefined) | |
| 111 | + const [keywordInput, setKeywordInput] = useState('') | |
| 112 | + const [appliedKeyword, setAppliedKeyword] = useState('') | |
| 113 | + const [loading, setLoading] = useState(false) | |
| 114 | + const [error, setError] = useState<string | null>(null) | |
| 115 | + const [reloadKey, setReloadKey] = useState(0) | |
| 116 | + | |
| 117 | + const importInputRef = useRef<HTMLInputElement>(null) | |
| 118 | + const [importBusy, setImportBusy] = useState(false) | |
| 119 | + const [importReport, setImportReport] = useState<ProcessImportReport | null>(null) | |
| 120 | + const [importError, setImportError] = useState<string | null>(null) | |
| 121 | + | |
| 122 | + useEffect(() => { | |
| 123 | + if (mode !== 'list') { | |
| 124 | + return | |
| 125 | + } | |
| 126 | + let cancelled = false | |
| 127 | + setLoading(true) | |
| 128 | + setError(null) | |
| 129 | + | |
| 130 | + getProcessPage(org.id, { | |
| 131 | + keyword: appliedKeyword || undefined, | |
| 132 | + status: statusFilter, | |
| 133 | + page, | |
| 134 | + size: PAGE_SIZE, | |
| 135 | + }) | |
| 136 | + .then((data) => { | |
| 137 | + if (cancelled) { | |
| 138 | + return | |
| 139 | + } | |
| 140 | + setItems(data.items) | |
| 141 | + setTotal(data.total) | |
| 142 | + setDone(data.done) | |
| 143 | + }) | |
| 144 | + .catch((e: Error) => { | |
| 145 | + if (!cancelled) { | |
| 146 | + setError(e.message) | |
| 147 | + } | |
| 148 | + }) | |
| 149 | + .finally(() => { | |
| 150 | + if (!cancelled) { | |
| 151 | + setLoading(false) | |
| 152 | + } | |
| 153 | + }) | |
| 154 | + | |
| 155 | + return () => { | |
| 156 | + cancelled = true | |
| 157 | + } | |
| 158 | + // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 159 | + }, [mode, org.id, appliedKeyword, statusFilter, page, reloadKey]) | |
| 160 | + | |
| 161 | + function handleSearch() { | |
| 162 | + setPage(0) | |
| 163 | + setAppliedKeyword(keywordInput.trim()) | |
| 164 | + } | |
| 165 | + | |
| 166 | + function handleResetSearch() { | |
| 167 | + setKeywordInput('') | |
| 168 | + setPage(0) | |
| 169 | + setAppliedKeyword('') | |
| 170 | + } | |
| 171 | + | |
| 172 | + function pickStatusChip(key: ProcessStatusFilter | undefined) { | |
| 173 | + setPage(0) | |
| 174 | + setStatusFilter(key) | |
| 175 | + } | |
| 176 | + | |
| 177 | + async function handleImportFile(file: File) { | |
| 178 | + setImportBusy(true) | |
| 179 | + setImportError(null) | |
| 180 | + try { | |
| 181 | + const report = await importProcess(org.id, file) | |
| 182 | + setImportReport(report) | |
| 183 | + setReloadKey((k) => k + 1) | |
| 184 | + } catch (e) { | |
| 185 | + setImportError(e instanceof Error ? e.message : '엑셀 업로드에 실패했습니다.') | |
| 186 | + } finally { | |
| 187 | + setImportBusy(false) | |
| 188 | + } | |
| 189 | + } | |
| 190 | + | |
| 191 | + async function handleDelete(item: ProcessItem) { | |
| 192 | + if (!window.confirm(`"${item.postTitle ?? item.seq}" 게시물을 삭제할까요?`)) { | |
| 193 | + return | |
| 194 | + } | |
| 195 | + try { | |
| 196 | + await deleteProcessItem(org.id, item.id) | |
| 197 | + setReloadKey((k) => k + 1) | |
| 198 | + } catch (e) { | |
| 199 | + setError(e instanceof Error ? e.message : '삭제에 실패했습니다.') | |
| 200 | + } | |
| 201 | + } | |
| 202 | + | |
| 203 | + if (mode !== 'list') { | |
| 204 | + return ( | |
| 205 | + <ProcessDetail | |
| 206 | + org={org} | |
| 207 | + itemId={mode} | |
| 208 | + onBack={() => setMode('list')} | |
| 209 | + onSaved={() => { | |
| 210 | + setMode('list') | |
| 211 | + setReloadKey((k) => k + 1) | |
| 212 | + }} | |
| 213 | + /> | |
| 214 | + ) | |
| 215 | + } | |
| 216 | + | |
| 217 | + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) | |
| 218 | + | |
| 219 | + return ( | |
| 220 | + <div className="p-4"> | |
| 221 | + <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 222 | + <div className="flex flex-wrap items-center gap-2"> | |
| 223 | + <div className="flex items-center gap-1" role="group" aria-label="처리상태 필터"> | |
| 224 | + {STATUS_CHIPS.map((chip) => ( | |
| 225 | + <button | |
| 226 | + key={chip.label} | |
| 227 | + type="button" | |
| 228 | + onClick={() => pickStatusChip(chip.key)} | |
| 229 | + aria-pressed={statusFilter === chip.key} | |
| 230 | + className={`rounded-full border px-3 py-1 text-xs font-medium ${ | |
| 231 | + statusFilter === chip.key | |
| 232 | + ? 'border-blue-400 bg-blue-50 text-blue-700' | |
| 233 | + : 'border-gray-200 text-gray-500 hover:bg-gray-50' | |
| 234 | + }`} | |
| 235 | + > | |
| 236 | + {chip.label} | |
| 237 | + </button> | |
| 238 | + ))} | |
| 239 | + </div> | |
| 240 | + | |
| 241 | + <input | |
| 242 | + type="text" | |
| 243 | + aria-label="검색어" | |
| 244 | + value={keywordInput} | |
| 245 | + onChange={(e) => setKeywordInput(e.target.value)} | |
| 246 | + onKeyDown={(e) => { | |
| 247 | + if (e.key === 'Enter') { | |
| 248 | + handleSearch() | |
| 249 | + } | |
| 250 | + }} | |
| 251 | + placeholder="사이트명·게시물제목 검색" | |
| 252 | + className="w-64 rounded-md border border-gray-300 px-2 py-1.5 text-sm" | |
| 253 | + /> | |
| 254 | + <button | |
| 255 | + type="button" | |
| 256 | + onClick={handleSearch} | |
| 257 | + className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50" | |
| 258 | + > | |
| 259 | + 검색 | |
| 260 | + </button> | |
| 261 | + <button | |
| 262 | + type="button" | |
| 263 | + onClick={handleResetSearch} | |
| 264 | + className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50" | |
| 265 | + > | |
| 266 | + 초기화 | |
| 267 | + </button> | |
| 268 | + </div> | |
| 269 | + | |
| 270 | + <div className="flex items-center gap-2"> | |
| 271 | + <input | |
| 272 | + ref={importInputRef} | |
| 273 | + type="file" | |
| 274 | + accept=".xlsx,.xlsm" | |
| 275 | + className="hidden" | |
| 276 | + onChange={(e) => { | |
| 277 | + const file = e.target.files?.[0] | |
| 278 | + if (file) { | |
| 279 | + void handleImportFile(file) | |
| 280 | + } | |
| 281 | + e.target.value = '' | |
| 282 | + }} | |
| 283 | + /> | |
| 284 | + <button | |
| 285 | + type="button" | |
| 286 | + disabled={importBusy} | |
| 287 | + onClick={() => importInputRef.current?.click()} | |
| 288 | + className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50" | |
| 289 | + > | |
| 290 | + {importBusy ? '업로드 중…' : '엑셀 업로드'} | |
| 291 | + </button> | |
| 292 | + <a | |
| 293 | + href={processDownloadUrl(org.id)} | |
| 294 | + className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50" | |
| 295 | + > | |
| 296 | + 엑셀 다운로드 | |
| 297 | + </a> | |
| 298 | + </div> | |
| 299 | + </div> | |
| 300 | + | |
| 301 | + {importReport && ( | |
| 302 | + <p className="mt-3 text-sm text-gray-600"> | |
| 303 | + {`신규 ${importReport.created} · 갱신 ${importReport.updated} · 전체 ${importReport.total}`} | |
| 304 | + </p> | |
| 305 | + )} | |
| 306 | + {importError && <p className="mt-3 text-sm text-red-600">{importError}</p>} | |
| 307 | + | |
| 308 | + <p className="mt-4 text-sm font-medium text-gray-700"> | |
| 309 | + {`총 ${total}건 · 처리완료 ${done}건`} | |
| 310 | + </p> | |
| 311 | + | |
| 312 | + {error && <p className="mt-2 text-sm text-red-600">{error}</p>} | |
| 313 | + | |
| 314 | + <div className="mt-3 overflow-x-auto rounded-lg border border-gray-200"> | |
| 315 | + <table className="w-full min-w-[1400px] text-left text-sm"> | |
| 316 | + <thead> | |
| 317 | + <tr className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500"> | |
| 318 | + <th className="whitespace-nowrap px-3 py-2 font-medium">No</th> | |
| 319 | + <th className="whitespace-nowrap px-3 py-2 font-medium">사이트/게시판</th> | |
| 320 | + <th className="px-3 py-2 font-medium">제목</th> | |
| 321 | + <th className="whitespace-nowrap px-3 py-2 font-medium">제작/공표일</th> | |
| 322 | + <th className="whitespace-nowrap px-3 py-2 font-medium">기존유형/계약서</th> | |
| 323 | + <th className="whitespace-nowrap px-3 py-2 font-medium">권리확인구분</th> | |
| 324 | + <th className="whitespace-nowrap px-3 py-2 font-medium">권리확인 공공누리유형</th> | |
| 325 | + <th className="whitespace-nowrap px-3 py-2 font-medium">계약여부</th> | |
| 326 | + <th className="whitespace-nowrap px-3 py-2 font-medium">처리 공공누리유형</th> | |
| 327 | + <th className="px-3 py-2 font-medium">최종의견</th> | |
| 328 | + <th className="whitespace-nowrap px-3 py-2 font-medium">처리상태</th> | |
| 329 | + <th className="whitespace-nowrap px-3 py-2 font-medium">처리일시</th> | |
| 330 | + <th className="whitespace-nowrap px-3 py-2 font-medium">관리</th> | |
| 331 | + </tr> | |
| 332 | + </thead> | |
| 333 | + <tbody> | |
| 334 | + {loading ? ( | |
| 335 | + <tr> | |
| 336 | + <td colSpan={13} className="py-8 text-center text-sm text-gray-400"> | |
| 337 | + 불러오는 중… | |
| 338 | + </td> | |
| 339 | + </tr> | |
| 340 | + ) : items.length === 0 ? ( | |
| 341 | + <tr> | |
| 342 | + <td colSpan={13} className="py-10 text-center text-sm text-gray-300"> | |
| 343 | + 업로드된 권리처리 자료가 없습니다. 엑셀 업로드로 시작하세요. | |
| 344 | + </td> | |
| 345 | + </tr> | |
| 346 | + ) : ( | |
| 347 | + items.map((item) => ( | |
| 348 | + <tr key={item.id} className="border-b border-gray-100 align-top last:border-b-0"> | |
| 349 | + <td className="px-3 py-2 text-gray-500">{item.seq}</td> | |
| 350 | + <td className="px-3 py-2"> | |
| 351 | + <div className="text-gray-900">{item.siteName ?? '-'}</div> | |
| 352 | + <div className="text-xs text-gray-400">{item.boardName ?? '-'}</div> | |
| 353 | + </td> | |
| 354 | + <td className="px-3 py-2"> | |
| 355 | + <div className="flex items-center gap-1 text-gray-900"> | |
| 356 | + <span>{item.postTitle ?? '-'}</span> | |
| 357 | + {item.url && ( | |
| 358 | + <a | |
| 359 | + href={item.url} | |
| 360 | + target="_blank" | |
| 361 | + rel="noreferrer" | |
| 362 | + title="원문 보기" | |
| 363 | + aria-label="원문 보기" | |
| 364 | + className="text-blue-500 hover:underline" | |
| 365 | + > | |
| 366 | + | |
| 367 | + </a> | |
| 368 | + )} | |
| 369 | + </div> | |
| 370 | + <div className="text-xs text-gray-400">{item.hasAttachment ?? '-'}</div> | |
| 371 | + </td> | |
| 372 | + <td className="px-3 py-2 text-gray-600"> | |
| 373 | + <div>{item.producedDate ?? '-'}</div> | |
| 374 | + <div className="text-xs text-gray-400">{item.publishedDate ?? '-'}</div> | |
| 375 | + </td> | |
| 376 | + <td className="px-3 py-2 text-gray-600"> | |
| 377 | + <div>{item.priorKoglType ?? '-'}</div> | |
| 378 | + <div className="text-xs text-gray-400">{item.contractDocs ?? '-'}</div> | |
| 379 | + </td> | |
| 380 | + <td className="px-3 py-2 text-gray-600"> | |
| 381 | + <div> | |
| 382 | + {item.reviewMajor ?? '-'} | |
| 383 | + {item.reviewMinor ? ` > ${item.reviewMinor}` : ''} | |
| 384 | + </div> | |
| 385 | + <div className="text-xs text-gray-400">{item.reviewResult ?? ''}</div> | |
| 386 | + </td> | |
| 387 | + <td className="px-3 py-2 text-gray-600"> | |
| 388 | + <div>{item.reviewKoglType ?? '-'}</div> | |
| 389 | + <div className="text-xs text-gray-400">{item.reviewAiType === 'Y' ? 'AI' : ''}</div> | |
| 390 | + </td> | |
| 391 | + <td className="px-3 py-2 text-gray-600">{item.contractDocs ?? '-'}</td> | |
| 392 | + <td className="px-3 py-2 text-gray-600"> | |
| 393 | + <div>{item.judgedKoglType ?? '-'}</div> | |
| 394 | + <div className="text-xs text-gray-400">{item.judgedAiType === 'Y' ? 'AI' : ''}</div> | |
| 395 | + </td> | |
| 396 | + <td className="px-3 py-2 text-gray-600">{truncate(item.finalOpinion, 60)}</td> | |
| 397 | + <td className="px-3 py-2"> | |
| 398 | + <ProcessStatusBadge value={item.processStatus} /> | |
| 399 | + </td> | |
| 400 | + <td className="px-3 py-2 text-gray-600"> | |
| 401 | + {item.processStatus === '처리완료' ? formatDate(item.processedAt) : '-'} | |
| 402 | + </td> | |
| 403 | + <td className="px-3 py-2"> | |
| 404 | + <div className="flex gap-2"> | |
| 405 | + {item.processStatus === '처리완료' ? ( | |
| 406 | + <> | |
| 407 | + <button | |
| 408 | + type="button" | |
| 409 | + onClick={() => setMode(item.id)} | |
| 410 | + className="rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50" | |
| 411 | + > | |
| 412 | + 수정 | |
| 413 | + </button> | |
| 414 | + <button | |
| 415 | + type="button" | |
| 416 | + onClick={() => void handleDelete(item)} | |
| 417 | + className="rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50" | |
| 418 | + > | |
| 419 | + 삭제 | |
| 420 | + </button> | |
| 421 | + </> | |
| 422 | + ) : ( | |
| 423 | + <button | |
| 424 | + type="button" | |
| 425 | + onClick={() => setMode(item.id)} | |
| 426 | + className="rounded-md border border-emerald-300 bg-emerald-50 px-2 py-0.5 text-xs text-emerald-700 hover:bg-emerald-100" | |
| 427 | + > | |
| 428 | + 처리등록 | |
| 429 | + </button> | |
| 430 | + )} | |
| 431 | + </div> | |
| 432 | + </td> | |
| 433 | + </tr> | |
| 434 | + )) | |
| 435 | + )} | |
| 436 | + </tbody> | |
| 437 | + </table> | |
| 438 | + </div> | |
| 439 | + | |
| 440 | + {items.length > 0 && ( | |
| 441 | + <div className="mt-3 flex items-center justify-center gap-3 text-sm text-gray-600"> | |
| 442 | + <button | |
| 443 | + type="button" | |
| 444 | + disabled={page <= 0} | |
| 445 | + onClick={() => setPage((p) => Math.max(0, p - 1))} | |
| 446 | + className="rounded-md border border-gray-300 px-2 py-1 disabled:opacity-40" | |
| 447 | + > | |
| 448 | + ‹ | |
| 449 | + </button> | |
| 450 | + <span>{`${page + 1} / ${totalPages} Page`}</span> | |
| 451 | + <button | |
| 452 | + type="button" | |
| 453 | + disabled={page + 1 >= totalPages} | |
| 454 | + onClick={() => setPage((p) => p + 1)} | |
| 455 | + className="rounded-md border border-gray-300 px-2 py-1 disabled:opacity-40" | |
| 456 | + > | |
| 457 | + › | |
| 458 | + </button> | |
| 459 | + </div> | |
| 460 | + )} | |
| 461 | + </div> | |
| 462 | + ) | |
| 463 | +} | |
| 464 | + | |
| 465 | +/** 권리처리 상세([처리등록]/[수정]) 화면. */ | |
| 466 | +function ProcessDetail({ | |
| 467 | + org, | |
| 468 | + itemId, | |
| 469 | + onBack, | |
| 470 | + onSaved, | |
| 471 | +}: { | |
| 472 | + org: Org | |
| 473 | + itemId: number | |
| 474 | + onBack: () => void | |
| 475 | + onSaved: () => void | |
| 476 | +}) { | |
| 477 | + const [item, setItem] = useState<ProcessItem | null>(null) | |
| 478 | + const [loading, setLoading] = useState(true) | |
| 479 | + const [loadError, setLoadError] = useState<string | null>(null) | |
| 480 | + | |
| 481 | + const [contractSelected, setContractSelected] = useState<Set<string>>(new Set()) | |
| 482 | + const [contractEtc, setContractEtc] = useState('') | |
| 483 | + const [judgedKoglType, setJudgedKoglType] = useState('') | |
| 484 | + const [judgedAiType, setJudgedAiType] = useState(false) | |
| 485 | + const [finalOpinion, setFinalOpinion] = useState('') | |
| 486 | + const [judgmentBasis, setJudgmentBasis] = useState('') | |
| 487 | + const [processStatus, setProcessStatus] = useState('') | |
| 488 | + | |
| 489 | + const [busy, setBusy] = useState(false) | |
| 490 | + const [saveError, setSaveError] = useState<string | null>(null) | |
| 491 | + | |
| 492 | + useEffect(() => { | |
| 493 | + let cancelled = false | |
| 494 | + setLoading(true) | |
| 495 | + setLoadError(null) | |
| 496 | + | |
| 497 | + getProcessItem(org.id, itemId) | |
| 498 | + .then((data) => { | |
| 499 | + if (cancelled) { | |
| 500 | + return | |
| 501 | + } | |
| 502 | + setItem(data) | |
| 503 | + const { selected, etcText } = parseContractDocs(data.contractDocs) | |
| 504 | + setContractSelected(selected) | |
| 505 | + setContractEtc(etcText) | |
| 506 | + setJudgedKoglType(data.judgedKoglType ?? '') | |
| 507 | + setJudgedAiType(data.judgedAiType === 'Y') | |
| 508 | + setFinalOpinion(data.finalOpinion ?? '') | |
| 509 | + setJudgmentBasis(data.judgmentBasis ?? '') | |
| 510 | + setProcessStatus(data.processStatus ?? '') | |
| 511 | + }) | |
| 512 | + .catch((e: Error) => { | |
| 513 | + if (!cancelled) { | |
| 514 | + setLoadError(e.message) | |
| 515 | + } | |
| 516 | + }) | |
| 517 | + .finally(() => { | |
| 518 | + if (!cancelled) { | |
| 519 | + setLoading(false) | |
| 520 | + } | |
| 521 | + }) | |
| 522 | + | |
| 523 | + return () => { | |
| 524 | + cancelled = true | |
| 525 | + } | |
| 526 | + }, [org.id, itemId]) | |
| 527 | + | |
| 528 | + function toggleContractOption(option: string) { | |
| 529 | + setContractSelected((prev) => { | |
| 530 | + const next = new Set(prev) | |
| 531 | + if (next.has(option)) { | |
| 532 | + next.delete(option) | |
| 533 | + } else { | |
| 534 | + next.add(option) | |
| 535 | + } | |
| 536 | + return next | |
| 537 | + }) | |
| 538 | + } | |
| 539 | + | |
| 540 | + async function handleSave() { | |
| 541 | + if (!processStatus) { | |
| 542 | + return | |
| 543 | + } | |
| 544 | + setBusy(true) | |
| 545 | + setSaveError(null) | |
| 546 | + try { | |
| 547 | + const body: ProcessingRequest = { | |
| 548 | + contractDocs: serializeContractDocs(contractSelected, contractEtc), | |
| 549 | + judgedKoglType: judgedKoglType || null, | |
| 550 | + judgedAiType: judgedAiType ? 'Y' : null, | |
| 551 | + finalOpinion: finalOpinion || null, | |
| 552 | + judgmentBasis: judgmentBasis || null, | |
| 553 | + processStatus, | |
| 554 | + } | |
| 555 | + await updateProcessing(org.id, itemId, body) | |
| 556 | + onSaved() | |
| 557 | + } catch (e) { | |
| 558 | + setSaveError(e instanceof Error ? e.message : '저장에 실패했습니다.') | |
| 559 | + } finally { | |
| 560 | + setBusy(false) | |
| 561 | + } | |
| 562 | + } | |
| 563 | + | |
| 564 | + if (loading) { | |
| 565 | + return <p className="p-4 py-10 text-center text-sm text-gray-400">불러오는 중…</p> | |
| 566 | + } | |
| 567 | + if (loadError || !item) { | |
| 568 | + return <p className="p-4 py-10 text-center text-sm text-red-600">{loadError ?? '게시물을 찾을 수 없습니다.'}</p> | |
| 569 | + } | |
| 570 | + | |
| 571 | + return ( | |
| 572 | + <div className="p-4"> | |
| 573 | + <section className="rounded-lg border border-gray-200 p-4"> | |
| 574 | + <h2 className="text-sm font-semibold">기본정보</h2> | |
| 575 | + <div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2"> | |
| 576 | + <Field label="기관명" value={org.orgName} /> | |
| 577 | + <Field label="사이트명" value={item.siteName} /> | |
| 578 | + <Field label="게시판명" value={item.boardName} /> | |
| 579 | + <Field label="게시물제목" value={item.postTitle} /> | |
| 580 | + <div className="flex gap-3 text-sm"> | |
| 581 | + <span className="w-24 shrink-0 text-gray-400">URL</span> | |
| 582 | + {item.url ? ( | |
| 583 | + <a href={item.url} target="_blank" rel="noreferrer" className="break-all text-blue-600 hover:underline"> | |
| 584 | + {item.url} | |
| 585 | + </a> | |
| 586 | + ) : ( | |
| 587 | + <span className="text-gray-300">-</span> | |
| 588 | + )} | |
| 589 | + </div> | |
| 590 | + <Field label="기존 공공누리" value={item.priorKoglType} /> | |
| 591 | + </div> | |
| 592 | + </section> | |
| 593 | + | |
| 594 | + <section className="mt-4 rounded-lg border border-gray-200 p-4"> | |
| 595 | + <h2 className="text-sm font-semibold">권리확인</h2> | |
| 596 | + <div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2"> | |
| 597 | + <Field | |
| 598 | + label="권리확인" | |
| 599 | + value={item.reviewMajor ? `${item.reviewMajor}${item.reviewMinor ? ` > ${item.reviewMinor}` : ''}` : null} | |
| 600 | + /> | |
| 601 | + <Field | |
| 602 | + label="공공누리유형" | |
| 603 | + value={item.reviewKoglType ? `${item.reviewKoglType}${item.reviewAiType === 'Y' ? ' + AI' : ''}` : null} | |
| 604 | + /> | |
| 605 | + <Field label="처리 구분" value={item.reviewResult} /> | |
| 606 | + <Field label="의견" value={item.reviewOpinion} /> | |
| 607 | + <Field label="비고" value={item.reviewNote} /> | |
| 608 | + </div> | |
| 609 | + </section> | |
| 610 | + | |
| 611 | + <section className="mt-4 rounded-lg border border-gray-200 p-4"> | |
| 612 | + <h2 className="text-sm font-semibold">권리처리</h2> | |
| 613 | + | |
| 614 | + <div className="mt-3 flex flex-col gap-4"> | |
| 615 | + <fieldset className="flex flex-col gap-1.5"> | |
| 616 | + <legend className="text-xs text-gray-500">계약서 유무</legend> | |
| 617 | + <div className="flex flex-wrap gap-x-4 gap-y-1"> | |
| 618 | + {CONTRACT_DOC_OPTIONS.map((option) => ( | |
| 619 | + <label key={option} className="flex items-center gap-1.5 text-sm text-gray-800"> | |
| 620 | + <input | |
| 621 | + type="checkbox" | |
| 622 | + checked={contractSelected.has(option)} | |
| 623 | + onChange={() => toggleContractOption(option)} | |
| 624 | + /> | |
| 625 | + {option} | |
| 626 | + </label> | |
| 627 | + ))} | |
| 628 | + <label className="flex items-center gap-1.5 text-sm text-gray-800"> | |
| 629 | + <input | |
| 630 | + type="checkbox" | |
| 631 | + checked={contractSelected.has(CONTRACT_ETC)} | |
| 632 | + onChange={() => toggleContractOption(CONTRACT_ETC)} | |
| 633 | + /> | |
| 634 | + {CONTRACT_ETC} | |
| 635 | + </label> | |
| 636 | + </div> | |
| 637 | + {contractSelected.has(CONTRACT_ETC) && ( | |
| 638 | + <input | |
| 639 | + type="text" | |
| 640 | + aria-label="기타 계약서 내용" | |
| 641 | + value={contractEtc} | |
| 642 | + onChange={(e) => setContractEtc(e.target.value)} | |
| 643 | + placeholder="기타 내용을 입력하세요" | |
| 644 | + className="mt-1 w-72 rounded-md border border-gray-300 px-2 py-1.5 text-sm" | |
| 645 | + /> | |
| 646 | + )} | |
| 647 | + </fieldset> | |
| 648 | + | |
| 649 | + <fieldset className="flex flex-col gap-1.5"> | |
| 650 | + <legend className="text-xs text-gray-500">공공누리 유형</legend> | |
| 651 | + <div className="flex flex-wrap gap-x-4 gap-y-1"> | |
| 652 | + {JUDGED_KOGL_TYPE_OPTIONS.map((option) => ( | |
| 653 | + <label key={option} className="flex items-center gap-1.5 text-sm text-gray-800"> | |
| 654 | + <input | |
| 655 | + type="radio" | |
| 656 | + name="judged-kogl-type" | |
| 657 | + checked={judgedKoglType === option} | |
| 658 | + onChange={() => setJudgedKoglType(option)} | |
| 659 | + /> | |
| 660 | + {option} | |
| 661 | + </label> | |
| 662 | + ))} | |
| 663 | + </div> | |
| 664 | + </fieldset> | |
| 665 | + | |
| 666 | + <label className="flex items-center gap-2 text-sm text-gray-800"> | |
| 667 | + <input type="checkbox" checked={judgedAiType} onChange={(e) => setJudgedAiType(e.target.checked)} /> | |
| 668 | + AI유형 | |
| 669 | + </label> | |
| 670 | + | |
| 671 | + <div className="flex flex-col gap-1 text-sm"> | |
| 672 | + <label htmlFor="process-final-opinion" className="text-xs text-gray-500"> | |
| 673 | + 최종의견 | |
| 674 | + </label> | |
| 675 | + <textarea | |
| 676 | + id="process-final-opinion" | |
| 677 | + rows={3} | |
| 678 | + value={finalOpinion} | |
| 679 | + onChange={(e) => setFinalOpinion(e.target.value)} | |
| 680 | + placeholder="전부 양도 체결, 일부 양도 체결, 초상 이용 동의, 공공누리 동의 등 처리한 사유 기입" | |
| 681 | + className="rounded-md border border-gray-300 px-2 py-1.5" | |
| 682 | + /> | |
| 683 | + </div> | |
| 684 | + | |
| 685 | + <div className="flex flex-col gap-1 text-sm"> | |
| 686 | + <label htmlFor="process-judgment-basis" className="text-xs text-gray-500"> | |
| 687 | + 판단근거 | |
| 688 | + </label> | |
| 689 | + <textarea | |
| 690 | + id="process-judgment-basis" | |
| 691 | + rows={3} | |
| 692 | + value={judgmentBasis} | |
| 693 | + onChange={(e) => setJudgmentBasis(e.target.value)} | |
| 694 | + placeholder="계약서 명칭, 해당 문구 기입" | |
| 695 | + className="rounded-md border border-gray-300 px-2 py-1.5" | |
| 696 | + /> | |
| 697 | + </div> | |
| 698 | + | |
| 699 | + <div className="flex flex-col gap-1 text-sm"> | |
| 700 | + <label htmlFor="process-status" className="text-xs text-gray-500"> | |
| 701 | + *권리처리상태 | |
| 702 | + </label> | |
| 703 | + <select | |
| 704 | + id="process-status" | |
| 705 | + value={processStatus} | |
| 706 | + onChange={(e) => setProcessStatus(e.target.value)} | |
| 707 | + className="w-40 rounded-md border border-gray-300 px-2 py-1.5" | |
| 708 | + > | |
| 709 | + <option value="">선택</option> | |
| 710 | + {PROCESS_STATUS_OPTIONS.map((option) => ( | |
| 711 | + <option key={option} value={option}> | |
| 712 | + {option} | |
| 713 | + </option> | |
| 714 | + ))} | |
| 715 | + </select> | |
| 716 | + </div> | |
| 717 | + </div> | |
| 718 | + | |
| 719 | + {saveError && <p className="mt-3 text-sm text-red-600">{saveError}</p>} | |
| 720 | + | |
| 721 | + <div className="mt-4 flex justify-end gap-2"> | |
| 722 | + <button | |
| 723 | + type="button" | |
| 724 | + disabled={busy} | |
| 725 | + onClick={onBack} | |
| 726 | + className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50" | |
| 727 | + > | |
| 728 | + 목록 | |
| 729 | + </button> | |
| 730 | + <button | |
| 731 | + type="button" | |
| 732 | + disabled={busy || !processStatus} | |
| 733 | + onClick={() => void handleSave()} | |
| 734 | + 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" | |
| 735 | + > | |
| 736 | + 수정 | |
| 737 | + </button> | |
| 738 | + </div> | |
| 739 | + </section> | |
| 740 | + </div> | |
| 741 | + ) | |
| 742 | +} |
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?