feat: 대시보드·사업관리·시스템관리 신설 및 권리확인 정책변경 반영
요청자 피드백 PPT 2건(기능확인 24쪽, 문정원 검토내용 17쪽)에서 확인된 요구를 다음과 같이 반영한다. - 대시보드: KPI 7타일 + "단계 미지정" 포함 13컬럼 칸반(카드 드래그로 단계 이동) + 최근 진행 기관. 집계는 /api/dashboard 한 번으로 끝낸다 - 기관별로 건수를 반복 조회하면 화면 한 번에 150회 조회가 되기 때문이다. - 사업관리: 3단계 대상 기관 현황, 신청 확인 연락 이력, 기관별 최종 보고서. 기관 현황 숫자는 대시보드와 같은 집계를 재사용해 두 화면이 어긋나지 않게 한다. - 시스템관리: 채널 초기화(대상 미리보기 → 확인문구 입력 → 서버 재검사). 삭제가 아니라 보관(아카이브)이며 DB가 아는 채널만 대상으로 한다. - 권리확인: 제3자 권리처리 정책변경 반영. "권리처리 추진 미희망"과 공공누리 "보류"를 신설하고, 처리결과에 따라 공공누리 유형·비고를 규칙대로 자동 채운다. - 권리확인/권리처리: 일괄 등록·선택 삭제와 열별 조건 복합검색(AND). - 업무메모: 작성자를 화면에서 고르지 않고 로그인 사용자를 서버가 기록하며, 수정 기능을 추가한다(작성자·작성시각은 유지). - 기관관리: 채널에 고정된 최초 안내글을 확인하는 [최초 게시글 보기]. - 도움말: 주요 버튼 옆 (?) 모달 15개 항목. 실제 화면 캡처를 함께 담는다. 마이그레이션 두 건: - V11 "채널 있음 <=> 단계 있음" 불변식을 맞추는 1회성 백필. 단계 기능(V8) 이전에 채널을 만든 기관이 칸반에서 사라지는 것을 막는다. - V12 사업관리용 contact_log / org_report. 테스트: 백엔드 221건, 프런트 191건 전부 통과. Co-Authored-By: Claude Opus 5 (1M context)
@22ea83ee01a34d9170b1d7b0116f2199e9300bcb
+++ frontend/public/help/channel-assign.png
| Binary file is not shown |
+++ frontend/public/help/channel-provision.png
| Binary file is not shown |
+++ frontend/public/help/contacts.png
| Binary file is not shown |
+++ frontend/public/help/dashboard-drag-warning.png
| Binary file is not shown |
+++ frontend/public/help/dashboard-kanban.png
| Binary file is not shown |
+++ frontend/public/help/dashboard-kpi.png
| Binary file is not shown |
+++ frontend/public/help/dashboard-recent.png
| Binary file is not shown |
+++ frontend/public/help/org-chat.png
| Binary file is not shown |
+++ frontend/public/help/org-review.png
| Binary file is not shown |
+++ frontend/public/help/org-stage.png
| Binary file is not shown |
+++ frontend/public/help/system-reset.png
| Binary file is not shown |
--- frontend/src/App.tsx
+++ frontend/src/App.tsx
... | ... | @@ -1,22 +1,29 @@ |
| 1 | 1 |
import { useCallback, useEffect, useState } from 'react'
|
| 2 | 2 |
import { ApiError, getOrgs, logout, type Org } from './api/client'
|
| 3 | 3 |
import ContactsPage from './components/ContactsPage' |
| 4 |
+import Dashboard from './components/Dashboard' |
|
| 4 | 5 |
import LoginPage from './components/LoginPage' |
| 5 | 6 |
import OrgDetail from './components/OrgDetail' |
| 6 | 7 |
import OrgList from './components/OrgList' |
| 7 | 8 |
import OrgOverview from './components/OrgOverview' |
| 9 |
+import ProjectsPage from './components/ProjectsPage' |
|
| 8 | 10 |
import SeedUpload from './components/SeedUpload' |
| 9 | 11 |
import Sidebar from './components/Sidebar' |
| 12 |
+import SystemPage from './components/SystemPage' |
|
| 10 | 13 |
import { useCollapsiblePanel } from './hooks/useCollapsiblePanel'
|
| 11 | 14 |
|
| 12 |
-// 화면 3개: 기관관리(시안 레이아웃의 기관 상세), 채널관리(명부 시드 + 담당자 배정 + 채널 생성), |
|
| 15 |
+// 화면 4개: Dashboard(전체 기관 현황 - 좌측 기관 목록 없이 전폭으로 쓴다), |
|
| 16 |
+// 기관관리(시안 레이아웃의 기관 상세), 채널관리(명부 시드 + 담당자 배정 + 채널 생성), |
|
| 13 | 17 |
// 담당자관리(담당자 3종 CRUD - 담당자 정보가 입력되는 유일한 화면). |
| 14 |
-type Page = 'orgs' | 'channels' | 'contacts' |
|
| 18 |
+type Page = 'dashboard' | 'orgs' | 'channels' | 'contacts' | 'projects' | 'system' |
|
| 15 | 19 |
|
| 16 | 20 |
const PAGE_TITLE: Record<Page, string> = {
|
| 21 |
+ dashboard: 'Dashboard', |
|
| 17 | 22 |
orgs: '기관관리', |
| 18 | 23 |
channels: '채널관리', |
| 19 | 24 |
contacts: '담당자관리', |
| 25 |
+ projects: '사업관리', |
|
| 26 |
+ system: '시스템관리', |
|
| 20 | 27 |
} |
| 21 | 28 |
|
| 22 | 29 |
// 'checking'이 필요하다. true로 시작하면 첫 렌더에서 getOrgs()가 아직 안 끝났는데도 |
... | ... | @@ -33,7 +40,7 @@ |
| 33 | 40 |
const [selectedId, setSelectedId] = useState<number | null>(null) |
| 34 | 41 |
const [auth, setAuth] = useState<AuthState>('checking')
|
| 35 | 42 |
const [authErrorMessage, setAuthErrorMessage] = useState<string | null>(null) |
| 36 |
- const [page, setPage] = useState<Page>('orgs')
|
|
| 43 |
+ const [page, setPage] = useState<Page>('dashboard')
|
|
| 37 | 44 |
// 창이 좁아지면 자동으로 접힌다. 사용자의 접기/펼치기 선택은 저장된다. |
| 38 | 45 |
const [menuCollapsed, toggleMenu] = useCollapsiblePanel('itnhub.menu', '(max-width: 1023px)')
|
| 39 | 46 |
const [listCollapsed, toggleList] = useCollapsiblePanel('itnhub.orglist', '(max-width: 1279px)')
|
... | ... | @@ -100,7 +107,14 @@ |
| 100 | 107 |
collapsed={menuCollapsed}
|
| 101 | 108 |
onToggle={toggleMenu}
|
| 102 | 109 |
onSelect={(key) => {
|
| 103 |
- if (key === 'orgs' || key === 'channels' || key === 'contacts') {
|
|
| 110 |
+ if ( |
|
| 111 |
+ key === 'dashboard' || |
|
| 112 |
+ key === 'orgs' || |
|
| 113 |
+ key === 'channels' || |
|
| 114 |
+ key === 'contacts' || |
|
| 115 |
+ key === 'projects' || |
|
| 116 |
+ key === 'system' |
|
| 117 |
+ ) {
|
|
| 104 | 118 |
setPage(key) |
| 105 | 119 |
} |
| 106 | 120 |
}} |
... | ... | @@ -122,7 +136,7 @@ |
| 122 | 136 |
</header> |
| 123 | 137 |
|
| 124 | 138 |
<div className="flex min-h-0 flex-1"> |
| 125 |
- {page !== 'contacts' && (
|
|
| 139 |
+ {page !== 'contacts' && page !== 'dashboard' && page !== 'system' && page !== 'projects' && (
|
|
| 126 | 140 |
<div |
| 127 | 141 |
className={`flex shrink-0 flex-col border-r border-gray-200 transition-all duration-200 ${
|
| 128 | 142 |
listCollapsed ? 'w-14' : 'w-80' |
... | ... | @@ -142,7 +156,25 @@ |
| 142 | 156 |
)} |
| 143 | 157 |
|
| 144 | 158 |
<main className="min-w-0 flex-1 overflow-y-auto bg-gray-50/50"> |
| 145 |
- {page === 'contacts' ? (
|
|
| 159 |
+ {page === 'dashboard' ? (
|
|
| 160 |
+ <Dashboard |
|
| 161 |
+ onOpenOrg={(orgId) => {
|
|
| 162 |
+ setSelectedId(orgId) |
|
| 163 |
+ setPage('orgs')
|
|
| 164 |
+ }} |
|
| 165 |
+ // 요청자 요구: 대시보드 하단에서 전체를 볼 때는 사업관리로 넘어간다 |
|
| 166 |
+ onOpenOrgList={() => setPage('projects')}
|
|
| 167 |
+ /> |
|
| 168 |
+ ) : page === 'projects' ? ( |
|
| 169 |
+ <ProjectsPage |
|
| 170 |
+ onOpenOrg={(orgId) => {
|
|
| 171 |
+ setSelectedId(orgId) |
|
| 172 |
+ setPage('orgs')
|
|
| 173 |
+ }} |
|
| 174 |
+ /> |
|
| 175 |
+ ) : page === 'system' ? ( |
|
| 176 |
+ <SystemPage onChanged={() => void reload()} />
|
|
| 177 |
+ ) : page === 'contacts' ? ( |
|
| 146 | 178 |
<ContactsPage /> |
| 147 | 179 |
) : selected ? ( |
| 148 | 180 |
page === 'channels' ? ( |
--- frontend/src/api/client.ts
+++ frontend/src/api/client.ts
... | ... | @@ -213,6 +213,183 @@ |
| 213 | 213 |
}) |
| 214 | 214 |
} |
| 215 | 215 |
|
| 216 |
+/** 대시보드 KPI 타일 7개. 모두 서버가 같은 원본에서 접어 만든 값이다. */ |
|
| 217 |
+export interface DashboardSummary {
|
|
| 218 |
+ applied: number |
|
| 219 |
+ docSubmitted: number |
|
| 220 |
+ reviewTotal: number |
|
| 221 |
+ reviewDone: number |
|
| 222 |
+ processTotal: number |
|
| 223 |
+ processDone: number |
|
| 224 |
+ /** RE 데이터 모델이 아직 없어 항상 0이다. */ |
|
| 225 |
+ unresolvedRe: number |
|
| 226 |
+ reportWriting: number |
|
| 227 |
+ completed: number |
|
| 228 |
+} |
|
| 229 |
+ |
|
| 230 |
+/** 대시보드 칸반 카드 1장 = "최근 진행 기관" 표 1줄. 둘이 같은 행을 쓴다. */ |
|
| 231 |
+export interface DashboardOrgRow {
|
|
| 232 |
+ id: number |
|
| 233 |
+ orgNo: string |
|
| 234 |
+ orgName: string |
|
| 235 |
+ stage: number | null |
|
| 236 |
+ /** false면 칸반의 "단계 미지정" 컬럼으로 간다. 드래그로 단계를 옮길 수 없다. */ |
|
| 237 |
+ hasChannel: boolean |
|
| 238 |
+ lawyerName: string | null |
|
| 239 |
+ mjName: string | null |
|
| 240 |
+ lawyerAssignedDate: string | null |
|
| 241 |
+ reviewTotal: number |
|
| 242 |
+ reviewDone: number |
|
| 243 |
+ processTotal: number |
|
| 244 |
+ processDone: number |
|
| 245 |
+ /** 현재 단계로 넘어온 시각(epoch ms). 카드의 D+n 계산용. */ |
|
| 246 |
+ stageChangedAt: number | null |
|
| 247 |
+ /** 단계·권리확인/처리·메모·배정 중 가장 최근 변동 시각(epoch ms). */ |
|
| 248 |
+ lastChangedAt: number | null |
|
| 249 |
+ reCount: number |
|
| 250 |
+} |
|
| 251 |
+ |
|
| 252 |
+export interface DashboardData {
|
|
| 253 |
+ summary: DashboardSummary |
|
| 254 |
+ orgs: DashboardOrgRow[] |
|
| 255 |
+} |
|
| 256 |
+ |
|
| 257 |
+export function getDashboard(): Promise<DashboardData> {
|
|
| 258 |
+ return request<DashboardData>('/api/dashboard')
|
|
| 259 |
+} |
|
| 260 |
+ |
|
| 261 |
+/** 사업관리 목록 1행. org는 대시보드와 같은 집계 행을 그대로 쓴다. */ |
|
| 262 |
+export interface ProjectRow {
|
|
| 263 |
+ org: DashboardOrgRow |
|
| 264 |
+ lastContactedOn: string | null |
|
| 265 |
+ contactCount: number |
|
| 266 |
+ reportCount: number |
|
| 267 |
+} |
|
| 268 |
+ |
|
| 269 |
+/** 신청 확인 연락 이력 1건. */ |
|
| 270 |
+export interface ContactLog {
|
|
| 271 |
+ id: number |
|
| 272 |
+ orgId: number |
|
| 273 |
+ contactedOn: string |
|
| 274 |
+ method: string |
|
| 275 |
+ summary: string |
|
| 276 |
+ author: string |
|
| 277 |
+ createdAt: number |
|
| 278 |
+} |
|
| 279 |
+ |
|
| 280 |
+export interface ContactLogInput {
|
|
| 281 |
+ contactedOn: string |
|
| 282 |
+ method: string |
|
| 283 |
+ summary: string |
|
| 284 |
+} |
|
| 285 |
+ |
|
| 286 |
+/** 기관별 최종 보고서. */ |
|
| 287 |
+export interface OrgReport {
|
|
| 288 |
+ id: number |
|
| 289 |
+ orgId: number |
|
| 290 |
+ fileName: string |
|
| 291 |
+ contentType: string | null |
|
| 292 |
+ byteSize: number |
|
| 293 |
+ uploadedBy: string |
|
| 294 |
+ uploadedAt: number |
|
| 295 |
+} |
|
| 296 |
+ |
|
| 297 |
+export function getProjects(): Promise<ProjectRow[]> {
|
|
| 298 |
+ return request<ProjectRow[]>('/api/projects')
|
|
| 299 |
+} |
|
| 300 |
+ |
|
| 301 |
+export function getContactLogs(orgId: number): Promise<ContactLog[]> {
|
|
| 302 |
+ return request<ContactLog[]>(`/api/orgs/${orgId}/contact-logs`)
|
|
| 303 |
+} |
|
| 304 |
+ |
|
| 305 |
+/** 등록하면 갱신된 이력 전체가 돌아온다(작성자는 서버가 기록). */ |
|
| 306 |
+export function addContactLog(orgId: number, body: ContactLogInput): Promise<ContactLog[]> {
|
|
| 307 |
+ return request<ContactLog[]>(`/api/orgs/${orgId}/contact-logs`, {
|
|
| 308 |
+ method: 'POST', |
|
| 309 |
+ headers: { 'Content-Type': 'application/json' },
|
|
| 310 |
+ body: JSON.stringify(body), |
|
| 311 |
+ }) |
|
| 312 |
+} |
|
| 313 |
+ |
|
| 314 |
+export function updateContactLog( |
|
| 315 |
+ orgId: number, |
|
| 316 |
+ logId: number, |
|
| 317 |
+ body: ContactLogInput, |
|
| 318 |
+): Promise<ContactLog[]> {
|
|
| 319 |
+ return request<ContactLog[]>(`/api/orgs/${orgId}/contact-logs/${logId}`, {
|
|
| 320 |
+ method: 'PUT', |
|
| 321 |
+ headers: { 'Content-Type': 'application/json' },
|
|
| 322 |
+ body: JSON.stringify(body), |
|
| 323 |
+ }) |
|
| 324 |
+} |
|
| 325 |
+ |
|
| 326 |
+export async function deleteContactLog(orgId: number, logId: number): Promise<void> {
|
|
| 327 |
+ const response = await fetch(`/api/orgs/${orgId}/contact-logs/${logId}`, {
|
|
| 328 |
+ method: 'DELETE', |
|
| 329 |
+ credentials: 'same-origin', |
|
| 330 |
+ headers: { 'X-XSRF-TOKEN': csrfToken() },
|
|
| 331 |
+ }) |
|
| 332 |
+ if (!response.ok) {
|
|
| 333 |
+ throw new ApiError(response.status, `요청 실패 (${response.status})`)
|
|
| 334 |
+ } |
|
| 335 |
+} |
|
| 336 |
+ |
|
| 337 |
+export function getReports(orgId: number): Promise<OrgReport[]> {
|
|
| 338 |
+ return request<OrgReport[]>(`/api/orgs/${orgId}/reports`)
|
|
| 339 |
+} |
|
| 340 |
+ |
|
| 341 |
+export function uploadReport(orgId: number, file: File): Promise<OrgReport[]> {
|
|
| 342 |
+ const form = new FormData() |
|
| 343 |
+ form.append('file', file)
|
|
| 344 |
+ return request<OrgReport[]>(`/api/orgs/${orgId}/reports`, { method: 'POST', body: form })
|
|
| 345 |
+} |
|
| 346 |
+ |
|
| 347 |
+/** 다운로드는 <a href>가 그대로 열게 둔다. */ |
|
| 348 |
+export function reportDownloadUrl(orgId: number, reportId: number): string {
|
|
| 349 |
+ return `/api/orgs/${orgId}/reports/${reportId}`
|
|
| 350 |
+} |
|
| 351 |
+ |
|
| 352 |
+export async function deleteReport(orgId: number, reportId: number): Promise<void> {
|
|
| 353 |
+ const response = await fetch(`/api/orgs/${orgId}/reports/${reportId}`, {
|
|
| 354 |
+ method: 'DELETE', |
|
| 355 |
+ credentials: 'same-origin', |
|
| 356 |
+ headers: { 'X-XSRF-TOKEN': csrfToken() },
|
|
| 357 |
+ }) |
|
| 358 |
+ if (!response.ok) {
|
|
| 359 |
+ throw new ApiError(response.status, `요청 실패 (${response.status})`)
|
|
| 360 |
+ } |
|
| 361 |
+} |
|
| 362 |
+ |
|
| 363 |
+/** 시스템관리 > 채널 초기화 미리보기 1행. 웹앱 DB가 아는 채널만 담긴다. */ |
|
| 364 |
+export interface ResetTarget {
|
|
| 365 |
+ orgId: number |
|
| 366 |
+ orgNo: string |
|
| 367 |
+ orgName: string |
|
| 368 |
+ stage: number | null |
|
| 369 |
+ channels: { kind: string; channelId: string }[]
|
|
| 370 |
+} |
|
| 371 |
+ |
|
| 372 |
+export interface ResetReport {
|
|
| 373 |
+ archivedChannels: number |
|
| 374 |
+ clearedOrgs: number |
|
| 375 |
+ failures: string[] |
|
| 376 |
+} |
|
| 377 |
+ |
|
| 378 |
+/** 실행 확인 문구. 서버도 같은 값을 검사한다. */ |
|
| 379 |
+export const RESET_CONFIRM_PHRASE = '채널 초기화' |
|
| 380 |
+ |
|
| 381 |
+export function getResetPreview(): Promise<ResetTarget[]> {
|
|
| 382 |
+ return request<ResetTarget[]>('/api/system/reset/preview')
|
|
| 383 |
+} |
|
| 384 |
+ |
|
| 385 |
+export function resetChannels(confirm: string): Promise<ResetReport> {
|
|
| 386 |
+ return request<ResetReport>('/api/system/reset/channels', {
|
|
| 387 |
+ method: 'POST', |
|
| 388 |
+ headers: { 'Content-Type': 'application/json' },
|
|
| 389 |
+ body: JSON.stringify({ confirm }),
|
|
| 390 |
+ }) |
|
| 391 |
+} |
|
| 392 |
+ |
|
| 216 | 393 |
export function getTimeline(orgId: number): Promise<TimelineEvent[]> {
|
| 217 | 394 |
return request<TimelineEvent[]>(`/api/orgs/${orgId}/timeline`)
|
| 218 | 395 |
} |
... | ... | @@ -280,10 +457,8 @@ |
| 280 | 457 |
return request<WorkMemo[]>(`/api/orgs/${orgId}/memos`)
|
| 281 | 458 |
} |
| 282 | 459 |
|
| 283 |
-export function createMemo( |
|
| 284 |
- orgId: number, |
|
| 285 |
- memo: { contactName: string; body: string },
|
|
| 286 |
-): Promise<WorkMemo> {
|
|
| 460 |
+/** 작성자는 보내지 않는다 - 서버가 로그인 사용자로 기록한다. */ |
|
| 461 |
+export function createMemo(orgId: number, memo: { body: string }): Promise<WorkMemo> {
|
|
| 287 | 462 |
return request<WorkMemo>(`/api/orgs/${orgId}/memos`, {
|
| 288 | 463 |
method: 'POST', |
| 289 | 464 |
headers: { 'Content-Type': 'application/json' },
|
... | ... | @@ -292,6 +467,15 @@ |
| 292 | 467 |
} |
| 293 | 468 |
|
| 294 | 469 |
/** 204를 돌려주므로 request()의 json() 경로를 타지 않고 직접 부른다(logout()과 동일한 패턴). */ |
| 470 |
+/** 본문만 고친다. 작성자·작성시각은 서버가 그대로 유지한다. */ |
|
| 471 |
+export function updateMemo(orgId: number, memoId: number, memo: { body: string }): Promise<WorkMemo> {
|
|
| 472 |
+ return request<WorkMemo>(`/api/orgs/${orgId}/memos/${memoId}`, {
|
|
| 473 |
+ method: 'PUT', |
|
| 474 |
+ headers: { 'Content-Type': 'application/json' },
|
|
| 475 |
+ body: JSON.stringify(memo), |
|
| 476 |
+ }) |
|
| 477 |
+} |
|
| 478 |
+ |
|
| 295 | 479 |
export async function deleteMemo(orgId: number, memoId: number): Promise<void> {
|
| 296 | 480 |
const response = await fetch(`/api/orgs/${orgId}/memos/${memoId}`, {
|
| 297 | 481 |
method: 'DELETE', |
... | ... | @@ -375,13 +559,26 @@ |
| 375 | 559 |
return request<ReviewImportReport>(`/api/orgs/${orgId}/review/import`, { method: 'POST', body: form })
|
| 376 | 560 |
} |
| 377 | 561 |
|
| 562 |
+/** 검색 조건. keyword는 통합검색, site/board/title은 열별 조건으로 서로 AND로 겹친다. */ |
|
| 563 |
+export interface ItemSearch {
|
|
| 564 |
+ keyword?: string |
|
| 565 |
+ site?: string |
|
| 566 |
+ board?: string |
|
| 567 |
+ title?: string |
|
| 568 |
+} |
|
| 569 |
+ |
|
| 378 | 570 |
export function getReviewPage( |
| 379 | 571 |
orgId: number, |
| 380 |
- params: { keyword?: string; page?: number; size?: number } = {},
|
|
| 572 |
+ params: ItemSearch & { page?: number; size?: number } = {},
|
|
| 381 | 573 |
): Promise<ReviewPage> {
|
| 382 | 574 |
const query = new URLSearchParams() |
| 383 | 575 |
if (params.keyword) {
|
| 384 | 576 |
query.set('keyword', params.keyword)
|
| 577 |
+ } |
|
| 578 |
+ for (const field of ['site', 'board', 'title'] as const) {
|
|
| 579 |
+ if (params[field]) {
|
|
| 580 |
+ query.set(field, params[field] as string) |
|
| 581 |
+ } |
|
| 385 | 582 |
} |
| 386 | 583 |
if (params.page !== undefined) {
|
| 387 | 584 |
query.set('page', String(params.page))
|
... | ... | @@ -495,14 +692,59 @@ |
| 495 | 692 |
return request<ProcessImportReport>(`/api/orgs/${orgId}/process/import`, { method: 'POST', body: form })
|
| 496 | 693 |
} |
| 497 | 694 |
|
| 695 |
+/** 권리확인 일괄 등록. 비운 항목은 기존 값을 유지한다. */ |
|
| 696 |
+export function bulkUpdateReview( |
|
| 697 |
+ orgId: number, |
|
| 698 |
+ body: { ids: number[]; reviewResult?: string; judgedKoglType?: string; opinion?: string },
|
|
| 699 |
+): Promise<{ updated: number }> {
|
|
| 700 |
+ return request<{ updated: number }>(`/api/orgs/${orgId}/review/bulk`, {
|
|
| 701 |
+ method: 'PUT', |
|
| 702 |
+ headers: { 'Content-Type': 'application/json' },
|
|
| 703 |
+ body: JSON.stringify(body), |
|
| 704 |
+ }) |
|
| 705 |
+} |
|
| 706 |
+ |
|
| 707 |
+export function bulkDeleteReview(orgId: number, ids: number[]): Promise<{ deleted: number }> {
|
|
| 708 |
+ return request<{ deleted: number }>(`/api/orgs/${orgId}/review/bulk-delete`, {
|
|
| 709 |
+ method: 'POST', |
|
| 710 |
+ headers: { 'Content-Type': 'application/json' },
|
|
| 711 |
+ body: JSON.stringify(ids), |
|
| 712 |
+ }) |
|
| 713 |
+} |
|
| 714 |
+ |
|
| 715 |
+/** 권리처리 일괄 등록. 비운 항목은 기존 값을 유지한다. */ |
|
| 716 |
+export function bulkUpdateProcess( |
|
| 717 |
+ orgId: number, |
|
| 718 |
+ body: { ids: number[]; processStatus?: string; judgedKoglType?: string; finalOpinion?: string },
|
|
| 719 |
+): Promise<{ updated: number }> {
|
|
| 720 |
+ return request<{ updated: number }>(`/api/orgs/${orgId}/process/bulk`, {
|
|
| 721 |
+ method: 'PUT', |
|
| 722 |
+ headers: { 'Content-Type': 'application/json' },
|
|
| 723 |
+ body: JSON.stringify(body), |
|
| 724 |
+ }) |
|
| 725 |
+} |
|
| 726 |
+ |
|
| 727 |
+export function bulkDeleteProcess(orgId: number, ids: number[]): Promise<{ deleted: number }> {
|
|
| 728 |
+ return request<{ deleted: number }>(`/api/orgs/${orgId}/process/bulk-delete`, {
|
|
| 729 |
+ method: 'POST', |
|
| 730 |
+ headers: { 'Content-Type': 'application/json' },
|
|
| 731 |
+ body: JSON.stringify(ids), |
|
| 732 |
+ }) |
|
| 733 |
+} |
|
| 734 |
+ |
|
| 498 | 735 |
export function getProcessPage( |
| 499 | 736 |
orgId: number, |
| 500 |
- params: { keyword?: string; status?: ProcessStatusFilter; page?: number; size?: number } = {},
|
|
| 737 |
+ params: ItemSearch & { status?: ProcessStatusFilter; page?: number; size?: number } = {},
|
|
| 501 | 738 |
): Promise<ProcessPage> {
|
| 502 | 739 |
const query = new URLSearchParams() |
| 503 | 740 |
if (params.keyword) {
|
| 504 | 741 |
query.set('keyword', params.keyword)
|
| 505 | 742 |
} |
| 743 |
+ for (const field of ['site', 'board', 'title'] as const) {
|
|
| 744 |
+ if (params[field]) {
|
|
| 745 |
+ query.set(field, params[field] as string) |
|
| 746 |
+ } |
|
| 747 |
+ } |
|
| 506 | 748 |
if (params.status) {
|
| 507 | 749 |
query.set('status', params.status)
|
| 508 | 750 |
} |
--- frontend/src/components/ContactsPage.tsx
+++ frontend/src/components/ContactsPage.tsx
... | ... | @@ -10,6 +10,7 @@ |
| 10 | 10 |
type ContactInput, |
| 11 | 11 |
type MemberImportReport, |
| 12 | 12 |
} from '../api/client' |
| 13 |
+import HelpButton from '../help/HelpButton' |
|
| 13 | 14 |
|
| 14 | 15 |
const CATEGORY_LABELS: Record<ContactCategory, string> = {
|
| 15 | 16 |
APPLICANT: '신청기관', |
... | ... | @@ -350,6 +351,9 @@ |
| 350 | 351 |
> |
| 351 | 352 |
담당자 추가 |
| 352 | 353 |
</button> |
| 354 |
+ <span className="flex items-center"> |
|
| 355 |
+ <HelpButton topic="contacts.manage" /> |
|
| 356 |
+ </span> |
|
| 353 | 357 |
</div> |
| 354 | 358 |
</div> |
| 355 | 359 |
|
+++ frontend/src/components/Dashboard.test.tsx
... | ... | @@ -0,0 +1,226 @@ |
| 1 | +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' | |
| 2 | +import { beforeEach, describe, expect, it, vi } from 'vitest' | |
| 3 | +import Dashboard from './Dashboard' | |
| 4 | +import type { DashboardData, DashboardOrgRow } from '../api/client' | |
| 5 | + | |
| 6 | +const mocks = vi.hoisted(() => ({ | |
| 7 | + getDashboard: vi.fn(), | |
| 8 | + updateStage: vi.fn(), | |
| 9 | +})) | |
| 10 | + | |
| 11 | +vi.mock('../api/client', async (importOriginal) => ({ | |
| 12 | + ...(await importOriginal<typeof import('../api/client')>()), | |
| 13 | + getDashboard: mocks.getDashboard, | |
| 14 | + updateStage: mocks.updateStage, | |
| 15 | +})) | |
| 16 | + | |
| 17 | +const DAY = 24 * 60 * 60 * 1000 | |
| 18 | + | |
| 19 | +function row(overrides: Partial<DashboardOrgRow> = {}): DashboardOrgRow { | |
| 20 | + return { | |
| 21 | + id: 1, | |
| 22 | + orgNo: '001', | |
| 23 | + orgName: '한국출판문화산업진흥원', | |
| 24 | + stage: 5, | |
| 25 | + hasChannel: true, | |
| 26 | + lawyerName: '전세준', | |
| 27 | + mjName: '이연경', | |
| 28 | + lawyerAssignedDate: '2026-06-19', | |
| 29 | + reviewTotal: 392, | |
| 30 | + reviewDone: 78, | |
| 31 | + processTotal: 0, | |
| 32 | + processDone: 0, | |
| 33 | + stageChangedAt: Date.now() - 3 * DAY, | |
| 34 | + lastChangedAt: Date.now() - 3 * DAY, | |
| 35 | + reCount: 0, | |
| 36 | + ...overrides, | |
| 37 | + } | |
| 38 | +} | |
| 39 | + | |
| 40 | +function data(rows: DashboardOrgRow[], summary: Partial<DashboardData['summary']> = {}): DashboardData { | |
| 41 | + return { | |
| 42 | + summary: { | |
| 43 | + applied: rows.length, | |
| 44 | + docSubmitted: 0, | |
| 45 | + reviewTotal: 0, | |
| 46 | + reviewDone: 0, | |
| 47 | + processTotal: 0, | |
| 48 | + processDone: 0, | |
| 49 | + unresolvedRe: 0, | |
| 50 | + reportWriting: 0, | |
| 51 | + completed: 0, | |
| 52 | + ...summary, | |
| 53 | + }, | |
| 54 | + orgs: rows, | |
| 55 | + } | |
| 56 | +} | |
| 57 | + | |
| 58 | +function renderDashboard(onOpenOrg = vi.fn(), onOpenOrgList = vi.fn()) { | |
| 59 | + render(<Dashboard onOpenOrg={onOpenOrg} onOpenOrgList={onOpenOrgList} />) | |
| 60 | + return { onOpenOrg, onOpenOrgList } | |
| 61 | +} | |
| 62 | + | |
| 63 | +/** 기관명은 칸반 카드와 표에 동시에 나오므로, 표를 볼 때는 항상 표 안에서만 찾는다. */ | |
| 64 | +function table() { | |
| 65 | + return within(screen.getByTestId('recent-table')) | |
| 66 | +} | |
| 67 | + | |
| 68 | +describe('Dashboard', () => { | |
| 69 | + beforeEach(() => { | |
| 70 | + vi.clearAllMocks() | |
| 71 | + mocks.getDashboard.mockResolvedValue(data([row()])) | |
| 72 | + mocks.updateStage.mockResolvedValue({}) | |
| 73 | + }) | |
| 74 | + | |
| 75 | + it('KPI 타일에 서버가 준 집계를 그대로 보여준다', async () => { | |
| 76 | + mocks.getDashboard.mockResolvedValue( | |
| 77 | + data([row()], { | |
| 78 | + applied: 50, | |
| 79 | + docSubmitted: 9, | |
| 80 | + reviewTotal: 2012, | |
| 81 | + reviewDone: 503, | |
| 82 | + processTotal: 197, | |
| 83 | + processDone: 0, | |
| 84 | + completed: 1, | |
| 85 | + }), | |
| 86 | + ) | |
| 87 | + renderDashboard() | |
| 88 | + | |
| 89 | + expect(await screen.findByText('503 / 2,012')).toBeTruthy() | |
| 90 | + expect(screen.getByText('0 / 197')).toBeTruthy() | |
| 91 | + // 권리확인 진척률 | |
| 92 | + expect(screen.getByText('25%')).toBeTruthy() | |
| 93 | + }) | |
| 94 | + | |
| 95 | + it('채널이 없는 기관은 단계 미지정 컬럼에 담긴다', async () => { | |
| 96 | + mocks.getDashboard.mockResolvedValue( | |
| 97 | + data([row({ id: 1, orgName: '채널없는기관', hasChannel: false, stage: null })]), | |
| 98 | + ) | |
| 99 | + renderDashboard() | |
| 100 | + | |
| 101 | + const column = await screen.findByTestId('column-unassigned') | |
| 102 | + expect(column.textContent).toContain('채널없는기관') | |
| 103 | + expect(column.textContent).toContain('단계 미지정') | |
| 104 | + }) | |
| 105 | + | |
| 106 | + it('단계 미지정 카드를 끌면 이동하지 않고 채널 생성 경고창이 뜬다', async () => { | |
| 107 | + mocks.getDashboard.mockResolvedValue( | |
| 108 | + data([row({ orgName: '채널없는기관', hasChannel: false, stage: null })]), | |
| 109 | + ) | |
| 110 | + renderDashboard() | |
| 111 | + | |
| 112 | + const column = await screen.findByTestId('column-unassigned') | |
| 113 | + const card = within(column).getByText('채널없는기관') | |
| 114 | + fireEvent.dragStart(card.parentElement!, { | |
| 115 | + dataTransfer: { setData: vi.fn(), getData: vi.fn() }, | |
| 116 | + }) | |
| 117 | + | |
| 118 | + expect(await screen.findByText('채널을 먼저 생성해 주세요')).toBeTruthy() | |
| 119 | + expect(mocks.updateStage).not.toHaveBeenCalled() | |
| 120 | + }) | |
| 121 | + | |
| 122 | + it('채널이 있는 카드를 다른 컬럼에 떨어뜨리면 단계가 바뀐다', async () => { | |
| 123 | + renderDashboard() | |
| 124 | + await screen.findByTestId('recent-table') | |
| 125 | + | |
| 126 | + const target = screen.getByTestId('column-6') | |
| 127 | + fireEvent.drop(target, { | |
| 128 | + dataTransfer: { getData: () => '1', setData: vi.fn() }, | |
| 129 | + }) | |
| 130 | + | |
| 131 | + await waitFor(() => expect(mocks.updateStage).toHaveBeenCalledWith(1, 6)) | |
| 132 | + }) | |
| 133 | + | |
| 134 | + it('같은 컬럼에 떨어뜨리면 아무 것도 하지 않는다', async () => { | |
| 135 | + renderDashboard() | |
| 136 | + await screen.findByTestId('recent-table') | |
| 137 | + | |
| 138 | + fireEvent.drop(screen.getByTestId('column-5'), { | |
| 139 | + dataTransfer: { getData: () => '1', setData: vi.fn() }, | |
| 140 | + }) | |
| 141 | + | |
| 142 | + await waitFor(() => expect(mocks.getDashboard).toHaveBeenCalledTimes(1)) | |
| 143 | + expect(mocks.updateStage).not.toHaveBeenCalled() | |
| 144 | + }) | |
| 145 | + | |
| 146 | + it('단계 미지정 컬럼에는 떨어뜨려도 단계가 바뀌지 않는다', async () => { | |
| 147 | + renderDashboard() | |
| 148 | + await screen.findByTestId('recent-table') | |
| 149 | + | |
| 150 | + fireEvent.drop(screen.getByTestId('column-unassigned'), { | |
| 151 | + dataTransfer: { getData: () => '1', setData: vi.fn() }, | |
| 152 | + }) | |
| 153 | + | |
| 154 | + await waitFor(() => expect(mocks.updateStage).not.toHaveBeenCalled()) | |
| 155 | + }) | |
| 156 | + | |
| 157 | + it('카드에 현재 단계 진입 후 경과일을 D+n으로 보여준다', async () => { | |
| 158 | + mocks.getDashboard.mockResolvedValue( | |
| 159 | + data([row({ stageChangedAt: Date.now() - 30 * DAY })]), | |
| 160 | + ) | |
| 161 | + renderDashboard() | |
| 162 | + | |
| 163 | + expect(await screen.findByText('D+30')).toBeTruthy() | |
| 164 | + }) | |
| 165 | + | |
| 166 | + it('최근 진행 기관 표는 최근 변경순으로 정렬한다', async () => { | |
| 167 | + mocks.getDashboard.mockResolvedValue( | |
| 168 | + data([ | |
| 169 | + row({ id: 1, orgNo: '001', orgName: '오래된기관', lastChangedAt: Date.now() - 10 * DAY }), | |
| 170 | + row({ id: 2, orgNo: '002', orgName: '최근기관', lastChangedAt: Date.now() }), | |
| 171 | + ]), | |
| 172 | + ) | |
| 173 | + renderDashboard() | |
| 174 | + | |
| 175 | + await screen.findByTestId('recent-table') | |
| 176 | + const names = table().getAllByRole('row').slice(1).map((tr) => tr.children[1].textContent) | |
| 177 | + expect(names).toEqual(['최근기관', '오래된기관']) | |
| 178 | + }) | |
| 179 | + | |
| 180 | + it('표는 8개까지만 보여주고 더보기로 늘린다', async () => { | |
| 181 | + const rows = Array.from({ length: 10 }, (_, i) => | |
| 182 | + row({ id: i + 1, orgNo: String(i + 1).padStart(3, '0'), orgName: `기관${i + 1}` }), | |
| 183 | + ) | |
| 184 | + mocks.getDashboard.mockResolvedValue(data(rows)) | |
| 185 | + renderDashboard() | |
| 186 | + | |
| 187 | + await screen.findByTestId('recent-table') | |
| 188 | + expect(table().getAllByRole('row')).toHaveLength(9) // 헤더 1 + 8 | |
| 189 | + | |
| 190 | + fireEvent.click(screen.getByText('더보기 (2개 남음)')) | |
| 191 | + expect(table().getAllByRole('row')).toHaveLength(11) | |
| 192 | + }) | |
| 193 | + | |
| 194 | + it('기관명 검색으로 표를 거른다', async () => { | |
| 195 | + mocks.getDashboard.mockResolvedValue( | |
| 196 | + data([ | |
| 197 | + row({ id: 1, orgNo: '001', orgName: '한국출판문화산업진흥원' }), | |
| 198 | + row({ id: 2, orgNo: '002', orgName: '세종학당재단' }), | |
| 199 | + ]), | |
| 200 | + ) | |
| 201 | + renderDashboard() | |
| 202 | + | |
| 203 | + await screen.findByTestId('recent-table') | |
| 204 | + fireEvent.change(screen.getByLabelText('기관명/기관코드 검색'), { target: { value: '세종' } }) | |
| 205 | + | |
| 206 | + expect(table().queryByText('한국출판문화산업진흥원')).toBeNull() | |
| 207 | + expect(table().getByText('세종학당재단')).toBeTruthy() | |
| 208 | + }) | |
| 209 | + | |
| 210 | + it('표에서 기관을 클릭하면 기관관리로 넘긴다', async () => { | |
| 211 | + const onOpenOrg = vi.fn() | |
| 212 | + renderDashboard(onOpenOrg) | |
| 213 | + | |
| 214 | + await screen.findByTestId('recent-table') | |
| 215 | + fireEvent.click(table().getByText('한국출판문화산업진흥원')) | |
| 216 | + expect(onOpenOrg).toHaveBeenCalledWith(1) | |
| 217 | + }) | |
| 218 | + | |
| 219 | + it('하단 전체 보기는 사업관리로 넘긴다', async () => { | |
| 220 | + const onOpenOrgList = vi.fn() | |
| 221 | + renderDashboard(vi.fn(), onOpenOrgList) | |
| 222 | + | |
| 223 | + fireEvent.click(await screen.findByText('사업관리에서 전체 보기 →')) | |
| 224 | + expect(onOpenOrgList).toHaveBeenCalled() | |
| 225 | + }) | |
| 226 | +}) |
+++ frontend/src/components/Dashboard.tsx
... | ... | @@ -0,0 +1,697 @@ |
| 1 | +import { useCallback, useEffect, useMemo, useState } from 'react' | |
| 2 | +import { | |
| 3 | + getDashboard, | |
| 4 | + updateStage, | |
| 5 | + type DashboardData, | |
| 6 | + type DashboardOrgRow, | |
| 7 | +} from '../api/client' | |
| 8 | +import { STAGE_GROUPS, STAGE_LABELS, stageGroupKey, stageLabel } from '../stages' | |
| 9 | +import HelpButton from '../help/HelpButton' | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * 시안(3단계 대쉬보드 화면 설계안)의 대시보드 화면. 세 블록으로 구성된다. | |
| 13 | + * 1) KPI 타일 7개 | |
| 14 | + * 2) 단계별 현황 칸반 - 카드를 끌어 단계를 옮긴다 | |
| 15 | + * 3) 최근 진행 기관 표 - 최근 변동순 상위 N개 | |
| 16 | + * | |
| 17 | + * 칸반 맨 앞의 "단계 미지정" 컬럼은 <b>채널을 아직 만들지 않은 기관만</b> 담는다(요청자 확정). | |
| 18 | + * 기관관리 상세 화면에는 이 개념이 없다 - 거기서는 채널 생성 전이면 1~12 스트립이 전부 | |
| 19 | + * 회색으로 보일 뿐이다(OrgOverview). 미지정 카드는 끌어도 단계가 바뀌지 않고, 채널을 | |
| 20 | + * 먼저 만들라는 경고창이 뜬다. 단계만 올려 두면 게시글·자료·권리검토가 전부 채널에 붙어 | |
| 21 | + * 있어서 빈 껍데기가 되기 때문이다. | |
| 22 | + */ | |
| 23 | + | |
| 24 | +interface Props { | |
| 25 | + /** 카드/표에서 기관을 클릭했을 때. 기관관리 화면으로 이동해 해당 기관을 연다. */ | |
| 26 | + onOpenOrg: (orgId: number) => void | |
| 27 | + /** [전체 기관 보기]. 기관관리 화면으로만 이동한다. */ | |
| 28 | + onOpenOrgList: () => void | |
| 29 | +} | |
| 30 | + | |
| 31 | +const CIRCLED = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩', '⑪', '⑫'] | |
| 32 | + | |
| 33 | +/** 칸반 컬럼 상단 색 띠. 구간별로 같은 색을 쓴다. */ | |
| 34 | +const GROUP_COLOR: Record<string, string> = { | |
| 35 | + intake: 'bg-slate-400', | |
| 36 | + assign: 'bg-indigo-400', | |
| 37 | + check: 'bg-emerald-400', | |
| 38 | + process: 'bg-amber-400', | |
| 39 | + closing: 'bg-violet-400', | |
| 40 | +} | |
| 41 | + | |
| 42 | +/** 표/칸반이 한 번에 보여주는 기관 수. [더보기]를 누를 때마다 이만큼 늘어난다. */ | |
| 43 | +const PAGE_SIZE = 8 | |
| 44 | +/** 칸반 컬럼 하나가 한 번에 보여주는 카드 수. */ | |
| 45 | +const COLUMN_SIZE = 2 | |
| 46 | + | |
| 47 | +const DAY_MS = 24 * 60 * 60 * 1000 | |
| 48 | + | |
| 49 | +function formatDate(ms: number | null): string { | |
| 50 | + if (!ms) { | |
| 51 | + return '-' | |
| 52 | + } | |
| 53 | + const d = new Date(ms) | |
| 54 | + const pad = (n: number) => String(n).padStart(2, '0') | |
| 55 | + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` | |
| 56 | +} | |
| 57 | + | |
| 58 | +/** 현재 단계로 넘어온 뒤 며칠 지났는지. 이력이 없으면 null이라 배지를 아예 안 그린다. */ | |
| 59 | +function daysSince(ms: number | null): number | null { | |
| 60 | + if (!ms) { | |
| 61 | + return null | |
| 62 | + } | |
| 63 | + return Math.max(0, Math.floor((Date.now() - ms) / DAY_MS)) | |
| 64 | +} | |
| 65 | + | |
| 66 | +function percent(done: number, total: number): number { | |
| 67 | + return total === 0 ? 0 : Math.round((done / total) * 100) | |
| 68 | +} | |
| 69 | + | |
| 70 | +/** | |
| 71 | + * 카드가 들어갈 칸반 컬럼. null이면 "단계 미지정"이다. | |
| 72 | + * | |
| 73 | + * 채널이 있는데 단계가 비어 있는 기관은 V11 백필과 채널 생성 로직 덕에 원칙적으로 없지만, | |
| 74 | + * 혹시 생기더라도 보드에서 사라지지 않도록 1단계로 본다. | |
| 75 | + */ | |
| 76 | +function columnOf(row: DashboardOrgRow): number | null { | |
| 77 | + if (!row.hasChannel) { | |
| 78 | + return null | |
| 79 | + } | |
| 80 | + return row.stage ?? 1 | |
| 81 | +} | |
| 82 | + | |
| 83 | +function KpiTile({ | |
| 84 | + label, | |
| 85 | + value, | |
| 86 | + sub, | |
| 87 | + accent, | |
| 88 | +}: { | |
| 89 | + label: string | |
| 90 | + value: string | |
| 91 | + sub?: string | |
| 92 | + accent?: boolean | |
| 93 | +}) { | |
| 94 | + return ( | |
| 95 | + <div | |
| 96 | + className={`flex min-w-0 flex-1 flex-col items-center gap-1 rounded-lg border bg-white px-3 py-4 ${ | |
| 97 | + accent ? 'border-gray-300 shadow-sm' : 'border-gray-200' | |
| 98 | + }`} | |
| 99 | + > | |
| 100 | + <span className="text-xs text-gray-500">{label}</span> | |
| 101 | + <span className="text-2xl font-semibold tabular-nums">{value}</span> | |
| 102 | + {sub && <span className="text-xs text-gray-400 tabular-nums">{sub}</span>} | |
| 103 | + </div> | |
| 104 | + ) | |
| 105 | +} | |
| 106 | + | |
| 107 | +function ProgressLine({ label, done, total }: { label: string; done: number; total: number }) { | |
| 108 | + const value = percent(done, total) | |
| 109 | + return ( | |
| 110 | + <div className="space-y-1"> | |
| 111 | + <div className="flex items-baseline justify-between text-xs"> | |
| 112 | + <span className="text-gray-500"> | |
| 113 | + {label} <span className="font-medium text-gray-800 tabular-nums">{done.toLocaleString()} / {total.toLocaleString()}</span> | |
| 114 | + </span> | |
| 115 | + <span className="font-medium text-gray-600 tabular-nums">{value}%</span> | |
| 116 | + </div> | |
| 117 | + <div className="h-1 w-full rounded-full bg-gray-100"> | |
| 118 | + <div className="h-1 rounded-full bg-blue-500" style={{ width: `${value}%` }} /> | |
| 119 | + </div> | |
| 120 | + </div> | |
| 121 | + ) | |
| 122 | +} | |
| 123 | + | |
| 124 | +/** 단계 구간에 따라 카드 본문이 달라진다(시안의 컬럼별 카드 모양). */ | |
| 125 | +function CardBody({ row }: { row: DashboardOrgRow }) { | |
| 126 | + const stage = columnOf(row) | |
| 127 | + const group = stageGroupKey(stage) | |
| 128 | + const lawyer = row.lawyerName ?? '변호사 미배정' | |
| 129 | + | |
| 130 | + if (stage === null) { | |
| 131 | + return <p className="text-xs text-gray-500">채널 미생성 · 담당자 배정 후 채널을 만드세요</p> | |
| 132 | + } | |
| 133 | + if (stage === 1) { | |
| 134 | + return <p className="text-xs text-gray-500">신청일 {formatDate(row.stageChangedAt)}</p> | |
| 135 | + } | |
| 136 | + if (stage === 2 || stage === 3) { | |
| 137 | + return ( | |
| 138 | + <div className="space-y-0.5 text-xs text-gray-500"> | |
| 139 | + <p>목록 {row.reviewTotal.toLocaleString()}건</p> | |
| 140 | + <p>접수일 {formatDate(row.stageChangedAt)}</p> | |
| 141 | + </div> | |
| 142 | + ) | |
| 143 | + } | |
| 144 | + if (group === 'assign') { | |
| 145 | + return ( | |
| 146 | + <div className="space-y-0.5 text-xs text-gray-500"> | |
| 147 | + <p>{lawyer}</p> | |
| 148 | + <p>배정일 {row.lawyerAssignedDate ?? formatDate(row.stageChangedAt)}</p> | |
| 149 | + </div> | |
| 150 | + ) | |
| 151 | + } | |
| 152 | + if (group === 'check') { | |
| 153 | + return ( | |
| 154 | + <div className="space-y-1.5"> | |
| 155 | + <p className="text-xs text-gray-500">{lawyer}</p> | |
| 156 | + <ProgressLine label="확인" done={row.reviewDone} total={row.reviewTotal} /> | |
| 157 | + </div> | |
| 158 | + ) | |
| 159 | + } | |
| 160 | + if (group === 'process') { | |
| 161 | + return ( | |
| 162 | + <div className="space-y-1.5"> | |
| 163 | + <p className="text-xs text-gray-500">{lawyer}</p> | |
| 164 | + <ProgressLine label="확인" done={row.reviewDone} total={row.reviewTotal} /> | |
| 165 | + <ProgressLine label="처리" done={row.processDone} total={row.processTotal} /> | |
| 166 | + </div> | |
| 167 | + ) | |
| 168 | + } | |
| 169 | + return ( | |
| 170 | + <div className="space-y-1 text-xs text-gray-500"> | |
| 171 | + <p>{lawyer}</p> | |
| 172 | + <span className="inline-block rounded-full bg-violet-50 px-2 py-0.5 text-[11px] text-violet-700"> | |
| 173 | + {CIRCLED[stage - 1]} {stageLabel(stage)} | |
| 174 | + </span> | |
| 175 | + </div> | |
| 176 | + ) | |
| 177 | +} | |
| 178 | + | |
| 179 | +export default function Dashboard({ onOpenOrg, onOpenOrgList }: Props) { | |
| 180 | + const [data, setData] = useState<DashboardData | null>(null) | |
| 181 | + const [error, setError] = useState<string | null>(null) | |
| 182 | + const [busy, setBusy] = useState(false) | |
| 183 | + const [warning, setWarning] = useState<string | null>(null) | |
| 184 | + | |
| 185 | + // 칸반 필터 | |
| 186 | + const [groupFilter, setGroupFilter] = useState('') | |
| 187 | + const [lawyerFilter, setLawyerFilter] = useState('') | |
| 188 | + const [stageFilter, setStageFilter] = useState('') | |
| 189 | + const [statusFilter, setStatusFilter] = useState('') | |
| 190 | + const [boardKeyword, setBoardKeyword] = useState('') | |
| 191 | + const [expandedColumns, setExpandedColumns] = useState<Record<string, boolean>>({}) | |
| 192 | + | |
| 193 | + // 최근 진행 기관 표 필터 | |
| 194 | + const [tableKeyword, setTableKeyword] = useState('') | |
| 195 | + const [tableStage, setTableStage] = useState('') | |
| 196 | + const [tableMj, setTableMj] = useState('') | |
| 197 | + const [tableLawyer, setTableLawyer] = useState('') | |
| 198 | + const [tableData, setTableData] = useState('') | |
| 199 | + const [visible, setVisible] = useState(PAGE_SIZE) | |
| 200 | + | |
| 201 | + const reload = useCallback(async () => { | |
| 202 | + try { | |
| 203 | + setData(await getDashboard()) | |
| 204 | + setError(null) | |
| 205 | + } catch (e) { | |
| 206 | + setError((e as Error).message) | |
| 207 | + } | |
| 208 | + }, []) | |
| 209 | + | |
| 210 | + useEffect(() => { | |
| 211 | + void reload() | |
| 212 | + }, [reload]) | |
| 213 | + | |
| 214 | + const rows = useMemo(() => data?.orgs ?? [], [data]) | |
| 215 | + | |
| 216 | + const lawyers = useMemo( | |
| 217 | + () => [...new Set(rows.map((r) => r.lawyerName).filter((n): n is string => !!n))].sort(), | |
| 218 | + [rows], | |
| 219 | + ) | |
| 220 | + const mjs = useMemo( | |
| 221 | + () => [...new Set(rows.map((r) => r.mjName).filter((n): n is string => !!n))].sort(), | |
| 222 | + [rows], | |
| 223 | + ) | |
| 224 | + | |
| 225 | + const boardRows = useMemo(() => { | |
| 226 | + const keyword = boardKeyword.trim() | |
| 227 | + return rows.filter((row) => { | |
| 228 | + const stage = columnOf(row) | |
| 229 | + if (groupFilter && stageGroupKey(stage) !== groupFilter) { | |
| 230 | + return false | |
| 231 | + } | |
| 232 | + if (lawyerFilter && row.lawyerName !== lawyerFilter) { | |
| 233 | + return false | |
| 234 | + } | |
| 235 | + if (stageFilter && String(stage ?? '') !== stageFilter) { | |
| 236 | + return false | |
| 237 | + } | |
| 238 | + if (statusFilter === 'unassigned' && row.hasChannel) { | |
| 239 | + return false | |
| 240 | + } | |
| 241 | + if (statusFilter === 'active' && (!row.hasChannel || (stage ?? 0) >= 11)) { | |
| 242 | + return false | |
| 243 | + } | |
| 244 | + if (statusFilter === 'done' && (stage ?? 0) < 11) { | |
| 245 | + return false | |
| 246 | + } | |
| 247 | + if (keyword && !row.orgName.includes(keyword) && !row.orgNo.includes(keyword)) { | |
| 248 | + return false | |
| 249 | + } | |
| 250 | + return true | |
| 251 | + }) | |
| 252 | + }, [rows, groupFilter, lawyerFilter, stageFilter, statusFilter, boardKeyword]) | |
| 253 | + | |
| 254 | + /** 시안의 "정렬 기준 최근 진행순" - 칸반 카드도 최근 변동이 위로 온다. */ | |
| 255 | + const byRecent = useCallback( | |
| 256 | + (a: DashboardOrgRow, b: DashboardOrgRow) => (b.lastChangedAt ?? 0) - (a.lastChangedAt ?? 0), | |
| 257 | + [], | |
| 258 | + ) | |
| 259 | + | |
| 260 | + const tableRows = useMemo(() => { | |
| 261 | + const keyword = tableKeyword.trim() | |
| 262 | + return rows | |
| 263 | + .filter((row) => { | |
| 264 | + if (keyword && !row.orgName.includes(keyword) && !row.orgNo.includes(keyword)) { | |
| 265 | + return false | |
| 266 | + } | |
| 267 | + if (tableStage && String(columnOf(row) ?? '') !== tableStage) { | |
| 268 | + return false | |
| 269 | + } | |
| 270 | + if (tableMj && row.mjName !== tableMj) { | |
| 271 | + return false | |
| 272 | + } | |
| 273 | + if (tableLawyer && row.lawyerName !== tableLawyer) { | |
| 274 | + return false | |
| 275 | + } | |
| 276 | + if (tableData === 'received' && row.reviewTotal === 0) { | |
| 277 | + return false | |
| 278 | + } | |
| 279 | + if (tableData === 'pending' && row.reviewTotal > 0) { | |
| 280 | + return false | |
| 281 | + } | |
| 282 | + return true | |
| 283 | + }) | |
| 284 | + .sort(byRecent) | |
| 285 | + }, [rows, tableKeyword, tableStage, tableMj, tableLawyer, tableData, byRecent]) | |
| 286 | + | |
| 287 | + async function moveStage(row: DashboardOrgRow, stage: number) { | |
| 288 | + if (!row.hasChannel) { | |
| 289 | + setWarning(`${row.orgNo} ${row.orgName}\n\n채널을 먼저 생성해야 단계를 옮길 수 있습니다. 채널관리에서 채널을 생성하면 자동으로 ①신청 단계로 들어갑니다.`) | |
| 290 | + return | |
| 291 | + } | |
| 292 | + if (columnOf(row) === stage) { | |
| 293 | + return | |
| 294 | + } | |
| 295 | + setBusy(true) | |
| 296 | + try { | |
| 297 | + await updateStage(row.id, stage) | |
| 298 | + await reload() | |
| 299 | + } catch (e) { | |
| 300 | + setError((e as Error).message) | |
| 301 | + } finally { | |
| 302 | + setBusy(false) | |
| 303 | + } | |
| 304 | + } | |
| 305 | + | |
| 306 | + function handleDragStart(e: React.DragEvent, row: DashboardOrgRow) { | |
| 307 | + if (!row.hasChannel) { | |
| 308 | + // 드래그 자체를 취소하고 경고창을 띄운다. draggable을 false로 두면 이벤트가 | |
| 309 | + // 아예 안 와서 "왜 안 움직이는지" 알려줄 기회가 없다. | |
| 310 | + e.preventDefault() | |
| 311 | + setWarning(`${row.orgNo} ${row.orgName}\n\n채널이 아직 없어 단계를 옮길 수 없습니다. 채널관리에서 채널을 먼저 생성해 주세요. 채널을 만들면 자동으로 ①신청 단계로 들어갑니다.`) | |
| 312 | + return | |
| 313 | + } | |
| 314 | + e.dataTransfer.setData('text/plain', String(row.id)) | |
| 315 | + e.dataTransfer.effectAllowed = 'move' | |
| 316 | + } | |
| 317 | + | |
| 318 | + function handleDrop(e: React.DragEvent, stage: number) { | |
| 319 | + e.preventDefault() | |
| 320 | + const id = Number(e.dataTransfer.getData('text/plain')) | |
| 321 | + const row = rows.find((r) => r.id === id) | |
| 322 | + if (row) { | |
| 323 | + void moveStage(row, stage) | |
| 324 | + } | |
| 325 | + } | |
| 326 | + | |
| 327 | + if (error && !data) { | |
| 328 | + return ( | |
| 329 | + <div className="flex flex-col items-center gap-3 p-8 text-sm"> | |
| 330 | + <p className="text-red-600">{error}</p> | |
| 331 | + <button | |
| 332 | + type="button" | |
| 333 | + onClick={() => void reload()} | |
| 334 | + className="rounded-md border border-gray-300 px-3 py-1.5" | |
| 335 | + > | |
| 336 | + 다시 시도 | |
| 337 | + </button> | |
| 338 | + </div> | |
| 339 | + ) | |
| 340 | + } | |
| 341 | + | |
| 342 | + if (!data) { | |
| 343 | + return <p className="p-8 text-sm text-gray-500">불러오는 중…</p> | |
| 344 | + } | |
| 345 | + | |
| 346 | + const s = data.summary | |
| 347 | + const columns: (number | null)[] = [null, ...STAGE_LABELS.map((_, i) => i + 1)] | |
| 348 | + | |
| 349 | + return ( | |
| 350 | + <div className="space-y-4 p-6"> | |
| 351 | + {error && <p className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p>} | |
| 352 | + | |
| 353 | + <section className="rounded-lg border border-gray-200 bg-white p-5"> | |
| 354 | + <div className="flex items-start justify-between"> | |
| 355 | + <div> | |
| 356 | + <h1 className="flex items-center gap-1.5 text-lg font-semibold"> | |
| 357 | + 대시보드 | |
| 358 | + <HelpButton topic="dashboard.kpi" /> | |
| 359 | + </h1> | |
| 360 | + <p className="text-xs text-gray-500">전체기관의 진행 상태 및 검토 현황</p> | |
| 361 | + </div> | |
| 362 | + <span className="text-xs text-gray-500">● 시스템 정상</span> | |
| 363 | + </div> | |
| 364 | + | |
| 365 | + <div className="mt-4 flex flex-wrap gap-2"> | |
| 366 | + <KpiTile label="신청" value={String(s.applied)} /> | |
| 367 | + <KpiTile label="서류제출" value={String(s.docSubmitted)} /> | |
| 368 | + <KpiTile | |
| 369 | + label="권리확인" | |
| 370 | + value={`${s.reviewDone.toLocaleString()} / ${s.reviewTotal.toLocaleString()}`} | |
| 371 | + sub={`${percent(s.reviewDone, s.reviewTotal)}%`} | |
| 372 | + accent | |
| 373 | + /> | |
| 374 | + <KpiTile | |
| 375 | + label="권리처리" | |
| 376 | + value={`${s.processDone.toLocaleString()} / ${s.processTotal.toLocaleString()}`} | |
| 377 | + sub={`${percent(s.processDone, s.processTotal)}%`} | |
| 378 | + accent | |
| 379 | + /> | |
| 380 | + <KpiTile label="미해결 RE" value={String(s.unresolvedRe)} sub="데이터 준비 중" /> | |
| 381 | + <KpiTile label="보고서 작성" value={String(s.reportWriting)} /> | |
| 382 | + <KpiTile label="완료" value={String(s.completed)} /> | |
| 383 | + </div> | |
| 384 | + </section> | |
| 385 | + | |
| 386 | + <section className="rounded-lg border border-gray-200 bg-white p-5"> | |
| 387 | + <h2 className="flex items-center gap-1.5 text-sm font-semibold"> | |
| 388 | + 단계별 현황 | |
| 389 | + <HelpButton topic="dashboard.kanban" /> | |
| 390 | + </h2> | |
| 391 | + | |
| 392 | + <div className="mt-3 flex flex-wrap gap-2"> | |
| 393 | + <select | |
| 394 | + aria-label="업무 구간" | |
| 395 | + value={groupFilter} | |
| 396 | + onChange={(e) => setGroupFilter(e.target.value)} | |
| 397 | + className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" | |
| 398 | + > | |
| 399 | + <option value="">업무 구간 전체</option> | |
| 400 | + {STAGE_GROUPS.map((g) => ( | |
| 401 | + <option key={g.key} value={g.key}>{g.label}</option> | |
| 402 | + ))} | |
| 403 | + </select> | |
| 404 | + <select | |
| 405 | + aria-label="담당 변호사" | |
| 406 | + value={lawyerFilter} | |
| 407 | + onChange={(e) => setLawyerFilter(e.target.value)} | |
| 408 | + className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" | |
| 409 | + > | |
| 410 | + <option value="">담당 변호사 전체</option> | |
| 411 | + {lawyers.map((n) => ( | |
| 412 | + <option key={n} value={n}>{n}</option> | |
| 413 | + ))} | |
| 414 | + </select> | |
| 415 | + <select | |
| 416 | + aria-label="현재 단계" | |
| 417 | + value={stageFilter} | |
| 418 | + onChange={(e) => setStageFilter(e.target.value)} | |
| 419 | + className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" | |
| 420 | + > | |
| 421 | + <option value="">현재 단계 전체</option> | |
| 422 | + {STAGE_LABELS.map((label, i) => ( | |
| 423 | + <option key={label} value={String(i + 1)}>{CIRCLED[i]} {label}</option> | |
| 424 | + ))} | |
| 425 | + </select> | |
| 426 | + <select | |
| 427 | + aria-label="진행 상태" | |
| 428 | + value={statusFilter} | |
| 429 | + onChange={(e) => setStatusFilter(e.target.value)} | |
| 430 | + className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" | |
| 431 | + > | |
| 432 | + <option value="">진행 상태 전체</option> | |
| 433 | + <option value="unassigned">단계 미지정</option> | |
| 434 | + <option value="active">진행중</option> | |
| 435 | + <option value="done">완료</option> | |
| 436 | + </select> | |
| 437 | + <input | |
| 438 | + aria-label="기관명 검색" | |
| 439 | + placeholder="기관명 검색" | |
| 440 | + value={boardKeyword} | |
| 441 | + onChange={(e) => setBoardKeyword(e.target.value)} | |
| 442 | + className="min-w-40 flex-1 rounded-md border border-gray-300 px-2 py-1.5 text-sm" | |
| 443 | + /> | |
| 444 | + </div> | |
| 445 | + | |
| 446 | + <div className="mt-3 flex items-center justify-between rounded-md bg-gray-50 px-3 py-2 text-xs text-gray-500"> | |
| 447 | + <span>기관 카드를 드래그하여 단계 이동이 가능합니다. 단계 이동 시 현재 단계가 자동 변경됩니다.</span> | |
| 448 | + <span>정렬 기준 최근 진행순</span> | |
| 449 | + </div> | |
| 450 | + | |
| 451 | + <div className="mt-3 flex gap-3 overflow-x-auto pb-2"> | |
| 452 | + {columns.map((stage) => { | |
| 453 | + const key = stage === null ? 'unassigned' : String(stage) | |
| 454 | + const cards = boardRows.filter((row) => columnOf(row) === stage).sort(byRecent) | |
| 455 | + const expanded = expandedColumns[key] | |
| 456 | + const shown = expanded ? cards : cards.slice(0, COLUMN_SIZE) | |
| 457 | + const color = stage === null ? 'bg-gray-300' : GROUP_COLOR[stageGroupKey(stage) ?? ''] ?? 'bg-gray-300' | |
| 458 | + | |
| 459 | + return ( | |
| 460 | + <div | |
| 461 | + key={key} | |
| 462 | + data-testid={`column-${key}`} | |
| 463 | + onDragOver={(e) => { | |
| 464 | + if (stage !== null) { | |
| 465 | + e.preventDefault() | |
| 466 | + } | |
| 467 | + }} | |
| 468 | + onDrop={(e) => { | |
| 469 | + if (stage !== null) { | |
| 470 | + handleDrop(e, stage) | |
| 471 | + } | |
| 472 | + }} | |
| 473 | + className="flex w-56 shrink-0 flex-col rounded-lg border border-gray-200 bg-gray-50/60" | |
| 474 | + > | |
| 475 | + <div className={`h-1 rounded-t-lg ${color}`} /> | |
| 476 | + <div className="flex items-center justify-between px-3 py-2"> | |
| 477 | + <span className="text-sm font-medium"> | |
| 478 | + {stage === null ? '단계 미지정' : `${CIRCLED[stage - 1]} ${stageLabel(stage)}`} | |
| 479 | + </span> | |
| 480 | + <span className="text-sm text-gray-500 tabular-nums">{cards.length}</span> | |
| 481 | + </div> | |
| 482 | + | |
| 483 | + <div className="space-y-2 px-2 pb-2"> | |
| 484 | + {shown.map((row) => ( | |
| 485 | + <div | |
| 486 | + key={row.id} | |
| 487 | + draggable | |
| 488 | + onDragStart={(e) => handleDragStart(e, row)} | |
| 489 | + onClick={() => onOpenOrg(row.id)} | |
| 490 | + className={`space-y-1.5 rounded-md border border-gray-200 bg-white p-2.5 ${ | |
| 491 | + row.hasChannel ? 'cursor-grab' : 'cursor-pointer border-dashed' | |
| 492 | + } ${busy ? 'opacity-60' : ''}`} | |
| 493 | + > | |
| 494 | + <p className="truncate text-sm font-medium">{row.orgName}</p> | |
| 495 | + <CardBody row={row} /> | |
| 496 | + {(() => { | |
| 497 | + const days = daysSince(row.stageChangedAt) | |
| 498 | + return days === null ? null : ( | |
| 499 | + <span className="inline-block rounded bg-gray-100 px-1.5 py-0.5 text-[11px] text-gray-500"> | |
| 500 | + D+{days} | |
| 501 | + </span> | |
| 502 | + ) | |
| 503 | + })()} | |
| 504 | + </div> | |
| 505 | + ))} | |
| 506 | + | |
| 507 | + {cards.length > shown.length && ( | |
| 508 | + <button | |
| 509 | + type="button" | |
| 510 | + onClick={() => setExpandedColumns((prev) => ({ ...prev, [key]: true }))} | |
| 511 | + className="w-full rounded-md py-1.5 text-xs text-gray-500 hover:bg-gray-100" | |
| 512 | + > | |
| 513 | + + {cards.length - shown.length}건 더보기 | |
| 514 | + </button> | |
| 515 | + )} | |
| 516 | + </div> | |
| 517 | + </div> | |
| 518 | + ) | |
| 519 | + })} | |
| 520 | + </div> | |
| 521 | + </section> | |
| 522 | + | |
| 523 | + <section className="rounded-lg border border-gray-200 bg-white p-5"> | |
| 524 | + <h2 className="flex items-center gap-1.5 text-sm font-semibold"> | |
| 525 | + 최근 진행 기관 | |
| 526 | + <HelpButton topic="dashboard.recent" /> | |
| 527 | + </h2> | |
| 528 | + <p className="text-xs text-gray-500">최근 변경 기준 {Math.min(visible, tableRows.length)}개 기관</p> | |
| 529 | + | |
| 530 | + <div className="mt-3 flex flex-wrap gap-2"> | |
| 531 | + <input | |
| 532 | + aria-label="기관명/기관코드 검색" | |
| 533 | + placeholder="기관명/기관코드 검색" | |
| 534 | + value={tableKeyword} | |
| 535 | + onChange={(e) => setTableKeyword(e.target.value)} | |
| 536 | + className="min-w-48 flex-1 rounded-md border border-gray-300 px-2 py-1.5 text-sm" | |
| 537 | + /> | |
| 538 | + <select | |
| 539 | + aria-label="표 현재 단계" | |
| 540 | + value={tableStage} | |
| 541 | + onChange={(e) => setTableStage(e.target.value)} | |
| 542 | + className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" | |
| 543 | + > | |
| 544 | + <option value="">현재 단계 전체</option> | |
| 545 | + {STAGE_LABELS.map((label, i) => ( | |
| 546 | + <option key={label} value={String(i + 1)}>{CIRCLED[i]} {label}</option> | |
| 547 | + ))} | |
| 548 | + </select> | |
| 549 | + <select | |
| 550 | + aria-label="문정원 담당" | |
| 551 | + value={tableMj} | |
| 552 | + onChange={(e) => setTableMj(e.target.value)} | |
| 553 | + className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" | |
| 554 | + > | |
| 555 | + <option value="">문정원 담당 전체</option> | |
| 556 | + {mjs.map((n) => ( | |
| 557 | + <option key={n} value={n}>{n}</option> | |
| 558 | + ))} | |
| 559 | + </select> | |
| 560 | + <select | |
| 561 | + aria-label="표 담당변호사" | |
| 562 | + value={tableLawyer} | |
| 563 | + onChange={(e) => setTableLawyer(e.target.value)} | |
| 564 | + className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" | |
| 565 | + > | |
| 566 | + <option value="">담당변호사 전체</option> | |
| 567 | + {lawyers.map((n) => ( | |
| 568 | + <option key={n} value={n}>{n}</option> | |
| 569 | + ))} | |
| 570 | + </select> | |
| 571 | + <select | |
| 572 | + aria-label="자료접수 상태" | |
| 573 | + value={tableData} | |
| 574 | + onChange={(e) => setTableData(e.target.value)} | |
| 575 | + className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" | |
| 576 | + > | |
| 577 | + <option value="">자료접수 상태 전체</option> | |
| 578 | + <option value="received">접수</option> | |
| 579 | + <option value="pending">미접수</option> | |
| 580 | + </select> | |
| 581 | + <button | |
| 582 | + type="button" | |
| 583 | + onClick={() => { | |
| 584 | + setTableKeyword('') | |
| 585 | + setTableStage('') | |
| 586 | + setTableMj('') | |
| 587 | + setTableLawyer('') | |
| 588 | + setTableData('') | |
| 589 | + setVisible(PAGE_SIZE) | |
| 590 | + }} | |
| 591 | + className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50" | |
| 592 | + > | |
| 593 | + 초기화 | |
| 594 | + </button> | |
| 595 | + </div> | |
| 596 | + | |
| 597 | + <div className="mt-3 overflow-x-auto"> | |
| 598 | + <table data-testid="recent-table" className="w-full text-sm"> | |
| 599 | + <thead> | |
| 600 | + <tr className="border-b border-gray-200 text-xs text-gray-500"> | |
| 601 | + <th className="px-2 py-2 text-left font-normal">연번</th> | |
| 602 | + <th className="px-2 py-2 text-left font-normal">기관명</th> | |
| 603 | + <th className="px-2 py-2 text-left font-normal">현재 단계</th> | |
| 604 | + <th className="px-2 py-2 text-left font-normal">문정원 담당</th> | |
| 605 | + <th className="px-2 py-2 text-left font-normal">담당 변호사</th> | |
| 606 | + <th className="px-2 py-2 text-right font-normal">권리확인</th> | |
| 607 | + <th className="px-2 py-2 text-right font-normal">권리처리</th> | |
| 608 | + <th className="px-2 py-2 text-right font-normal">RE</th> | |
| 609 | + <th className="px-2 py-2 text-right font-normal">최근 변경</th> | |
| 610 | + </tr> | |
| 611 | + </thead> | |
| 612 | + <tbody> | |
| 613 | + {tableRows.slice(0, visible).map((row) => { | |
| 614 | + const stage = columnOf(row) | |
| 615 | + return ( | |
| 616 | + <tr | |
| 617 | + key={row.id} | |
| 618 | + onClick={() => onOpenOrg(row.id)} | |
| 619 | + className="cursor-pointer border-b border-gray-100 hover:bg-gray-50" | |
| 620 | + > | |
| 621 | + <td className="px-2 py-2 text-gray-500 tabular-nums">{row.orgNo}</td> | |
| 622 | + <td className="px-2 py-2 font-medium">{row.orgName}</td> | |
| 623 | + <td className="px-2 py-2"> | |
| 624 | + <span className="inline-block rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-700"> | |
| 625 | + {stage === null ? '단계 미지정' : `${CIRCLED[stage - 1]} ${stageLabel(stage)}`} | |
| 626 | + </span> | |
| 627 | + </td> | |
| 628 | + <td className="px-2 py-2 text-gray-600">{row.mjName ?? '-'}</td> | |
| 629 | + <td className="px-2 py-2 text-gray-600">{row.lawyerName ?? '-'}</td> | |
| 630 | + <td className="px-2 py-2 text-right tabular-nums"> | |
| 631 | + {row.reviewDone.toLocaleString()} / {row.reviewTotal.toLocaleString()} | |
| 632 | + </td> | |
| 633 | + <td className="px-2 py-2 text-right tabular-nums"> | |
| 634 | + {row.processDone.toLocaleString()} / {row.processTotal.toLocaleString()} | |
| 635 | + </td> | |
| 636 | + <td className="px-2 py-2 text-right tabular-nums">{row.reCount}</td> | |
| 637 | + <td className="px-2 py-2 text-right text-gray-500 tabular-nums"> | |
| 638 | + {formatDate(row.lastChangedAt)} | |
| 639 | + </td> | |
| 640 | + </tr> | |
| 641 | + ) | |
| 642 | + })} | |
| 643 | + {tableRows.length === 0 && ( | |
| 644 | + <tr> | |
| 645 | + <td colSpan={9} className="px-2 py-6 text-center text-sm text-gray-500"> | |
| 646 | + 조건에 맞는 기관이 없습니다. | |
| 647 | + </td> | |
| 648 | + </tr> | |
| 649 | + )} | |
| 650 | + </tbody> | |
| 651 | + </table> | |
| 652 | + </div> | |
| 653 | + | |
| 654 | + <div className="mt-3 flex items-center justify-between"> | |
| 655 | + {tableRows.length > visible ? ( | |
| 656 | + <button | |
| 657 | + type="button" | |
| 658 | + onClick={() => setVisible((v) => v + PAGE_SIZE)} | |
| 659 | + className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50" | |
| 660 | + > | |
| 661 | + 더보기 ({tableRows.length - visible}개 남음) | |
| 662 | + </button> | |
| 663 | + ) : ( | |
| 664 | + <span /> | |
| 665 | + )} | |
| 666 | + <button | |
| 667 | + type="button" | |
| 668 | + onClick={onOpenOrgList} | |
| 669 | + className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50" | |
| 670 | + > | |
| 671 | + 사업관리에서 전체 보기 → | |
| 672 | + </button> | |
| 673 | + </div> | |
| 674 | + </section> | |
| 675 | + | |
| 676 | + {warning && ( | |
| 677 | + <div className="fixed inset-0 flex items-center justify-center bg-black/30" role="alertdialog"> | |
| 678 | + <div className="w-96 rounded-lg bg-white p-5"> | |
| 679 | + <h2 className="text-base font-semibold">채널을 먼저 생성해 주세요</h2> | |
| 680 | + <p className="mt-3 whitespace-pre-line rounded-md bg-gray-50 p-3 text-sm text-gray-700"> | |
| 681 | + {warning} | |
| 682 | + </p> | |
| 683 | + <div className="mt-5 flex justify-end"> | |
| 684 | + <button | |
| 685 | + type="button" | |
| 686 | + onClick={() => setWarning(null)} | |
| 687 | + className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white" | |
| 688 | + > | |
| 689 | + 확인 | |
| 690 | + </button> | |
| 691 | + </div> | |
| 692 | + </div> | |
| 693 | + </div> | |
| 694 | + )} | |
| 695 | + </div> | |
| 696 | + ) | |
| 697 | +} |
--- frontend/src/components/OrgDetail.tsx
+++ frontend/src/components/OrgDetail.tsx
... | ... | @@ -8,6 +8,7 @@ |
| 8 | 8 |
type Org, |
| 9 | 9 |
type ProvisionResult, |
| 10 | 10 |
} from '../api/client' |
| 11 |
+import HelpButton from '../help/HelpButton' |
|
| 11 | 12 |
import ConfirmDialog from './ConfirmDialog' |
| 12 | 13 |
import ContactPickerModal from './ContactPickerModal' |
| 13 | 14 |
import StatusBadge from './StatusBadge' |
... | ... | @@ -214,7 +215,10 @@ |
| 214 | 215 |
</div> |
| 215 | 216 |
) |
| 216 | 217 |
})} |
| 217 |
- <p className="text-xs text-gray-400">신청기관 담당자를 지정해야 채널을 만들 수 있습니다.</p> |
|
| 218 |
+ <p className="flex items-center gap-1.5 text-xs text-gray-400"> |
|
| 219 |
+ 신청기관 담당자를 지정해야 채널을 만들 수 있습니다. |
|
| 220 |
+ <HelpButton topic="channel.assign" /> |
|
| 221 |
+ </p> |
|
| 218 | 222 |
</section> |
| 219 | 223 |
|
| 220 | 224 |
<div className="mt-5 flex gap-2"> |
... | ... | @@ -226,6 +230,9 @@ |
| 226 | 230 |
> |
| 227 | 231 |
채널 생성 |
| 228 | 232 |
</button> |
| 233 |
+ <span className="flex items-center"> |
|
| 234 |
+ <HelpButton topic="channel.provision" /> |
|
| 235 |
+ </span> |
|
| 229 | 236 |
</div> |
| 230 | 237 |
|
| 231 | 238 |
{error && <p className="mt-4 text-sm text-red-600">{error}</p>}
|
--- frontend/src/components/OrgOverview.test.tsx
+++ frontend/src/components/OrgOverview.test.tsx
... | ... | @@ -370,8 +370,9 @@ |
| 370 | 370 |
render(<OrgOverview org={org()} />)
|
| 371 | 371 |
|
| 372 | 372 |
expect(screen.getByRole('button', { name: '자료 업로드' })).toBeDisabled()
|
| 373 |
- expect(screen.getByRole('button', { name: '최초 게시글 보기' })).toBeDisabled()
|
|
| 374 | 373 |
expect(screen.getByRole('button', { name: '보고서 관리' })).toBeDisabled()
|
| 374 |
+ // 최초 게시글 보기는 구현되어 채널이 있으면 눌릴 수 있다 |
|
| 375 |
+ expect(screen.getByRole('button', { name: '최초 게시글 보기' })).toBeEnabled()
|
|
| 375 | 376 |
await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled()) |
| 376 | 377 |
}) |
| 377 | 378 |
|
--- frontend/src/components/OrgOverview.tsx
+++ frontend/src/components/OrgOverview.tsx
... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 |
import { useEffect, useState } from 'react'
|
| 2 | 2 |
import {
|
| 3 |
+ getNotice, |
|
| 3 | 4 |
getProcessPage, |
| 4 | 5 |
getReviewPage, |
| 5 | 6 |
getStageHistory, |
... | ... | @@ -8,8 +9,10 @@ |
| 8 | 9 |
type Contact, |
| 9 | 10 |
type ContactCategory, |
| 10 | 11 |
type Org, |
| 12 |
+ type PostView, |
|
| 11 | 13 |
} from '../api/client' |
| 12 | 14 |
import { STAGE_LABELS, stageLabel } from '../stages'
|
| 15 |
+import HelpButton from '../help/HelpButton' |
|
| 13 | 16 |
import ChannelFiles from './ChannelFiles' |
| 14 | 17 |
import ChannelPosts from './ChannelPosts' |
| 15 | 18 |
import ContactPickerModal from './ContactPickerModal' |
... | ... | @@ -25,9 +28,19 @@ |
| 25 | 28 |
|
| 26 | 29 |
const TABS = ['채팅', '자료', '권리확인', '권리처리', 'RE', '타임라인', '업무메모'] |
| 27 | 30 |
|
| 31 |
+/** 탭마다 그 탭 기능의 설명서를 띄운다. RE는 화면이 아직 없어 도움말도 없다. */ |
|
| 32 |
+const TAB_HELP: Record<string, string> = {
|
|
| 33 |
+ '채팅': 'org.chat', |
|
| 34 |
+ '자료': 'org.files', |
|
| 35 |
+ '권리확인': 'org.review', |
|
| 36 |
+ '권리처리': 'org.process', |
|
| 37 |
+ '타임라인': 'org.timeline', |
|
| 38 |
+ '업무메모': 'org.memo', |
|
| 39 |
+} |
|
| 40 |
+ |
|
| 28 | 41 |
// 담당 변호사 변경은 카드의 [생성/변경] 버튼으로 대체되어 목록에서 뺐다. |
| 29 | 42 |
// 진행단계 변경은 업무단계 현황 스트립 + 이 버튼의 모달로 실제 동작한다. |
| 30 |
-const DISABLED_ACTIONS = ['자료 업로드', '최초 게시글 보기', '보고서 관리'] |
|
| 43 |
+const DISABLED_ACTIONS = ['자료 업로드', '보고서 관리'] |
|
| 31 | 44 |
|
| 32 | 45 |
// 카드에서 담당자를 지정/변경할 때 쓰는 역할 정의 (채널관리의 배정과 같은 API를 쓴다) |
| 33 | 46 |
const CARD_ROLES: Record< |
... | ... | @@ -130,6 +143,10 @@ |
| 130 | 143 |
const [stageBusy, setStageBusy] = useState(false) |
| 131 | 144 |
const [stageError, setStageError] = useState<string | null>(null) |
| 132 | 145 |
const [stagePickerOpen, setStagePickerOpen] = useState(false) |
| 146 |
+ /** [최초 게시글 보기]가 띄우는 핀 게시물. 채널별로 따로 담는다. */ |
|
| 147 |
+ const [firstPosts, setFirstPosts] = useState<{ mj: PostView | null; law: PostView | null } | null>(null)
|
|
| 148 |
+ const [firstPostsBusy, setFirstPostsBusy] = useState(false) |
|
| 149 |
+ const [firstPostsError, setFirstPostsError] = useState<string | null>(null) |
|
| 133 | 150 |
// 단계별로 '그 단계에 들어간 날짜'(가장 최근 이력)를 찍기 위한 조회 결과 |
| 134 | 151 |
const [stageDates, setStageDates] = useState<Record<number, number>>({})
|
| 135 | 152 |
|
... | ... | @@ -233,6 +250,27 @@ |
| 233 | 250 |
} |
| 234 | 251 |
} |
| 235 | 252 |
|
| 253 |
+ /** |
|
| 254 |
+ * 채널에 고정(핀)된 게시물을 두 채널에서 가져온다. 채널 생성 때 올린 최초 안내글이 |
|
| 255 |
+ * 곧 그 채널의 공지라서, 요청자가 말한 "최초 자료 등록 + 핀 고정한 게시물"이 이것이다. |
|
| 256 |
+ */ |
|
| 257 |
+ async function openFirstPosts() {
|
|
| 258 |
+ setFirstPostsBusy(true) |
|
| 259 |
+ setFirstPostsError(null) |
|
| 260 |
+ setFirstPosts({ mj: null, law: null })
|
|
| 261 |
+ try {
|
|
| 262 |
+ const [mj, law] = await Promise.all([ |
|
| 263 |
+ org.channelIdMj ? getNotice(org.id, 'mj') : Promise.resolve(null), |
|
| 264 |
+ org.channelIdLaw ? getNotice(org.id, 'law') : Promise.resolve(null), |
|
| 265 |
+ ]) |
|
| 266 |
+ setFirstPosts({ mj, law })
|
|
| 267 |
+ } catch (e) {
|
|
| 268 |
+ setFirstPostsError(e instanceof Error ? e.message : '게시글을 불러오지 못했습니다.') |
|
| 269 |
+ } finally {
|
|
| 270 |
+ setFirstPostsBusy(false) |
|
| 271 |
+ } |
|
| 272 |
+ } |
|
| 273 |
+ |
|
| 236 | 274 |
/** 업무단계 현황 스트립에서 "현재 단계"를 클릭했을 때만 호출된다 - 다음 단계로 1칸 전진. */ |
| 237 | 275 |
async function advanceStage() {
|
| 238 | 276 |
if (org.stage === null || org.stage >= 12 || stageBusy) {
|
... | ... | @@ -274,6 +312,21 @@ |
| 274 | 312 |
> |
| 275 | 313 |
진행단계 변경 |
| 276 | 314 |
</button> |
| 315 |
+ <span className="flex items-center"> |
|
| 316 |
+ <HelpButton topic="org.stage" /> |
|
| 317 |
+ </span> |
|
| 318 |
+ <button |
|
| 319 |
+ type="button" |
|
| 320 |
+ disabled={!org.channelIdMj && !org.channelIdLaw}
|
|
| 321 |
+ title={!org.channelIdMj && !org.channelIdLaw ? '채널을 먼저 생성하세요' : undefined}
|
|
| 322 |
+ onClick={() => void openFirstPosts()}
|
|
| 323 |
+ className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:border-gray-200 disabled:text-gray-300" |
|
| 324 |
+ > |
|
| 325 |
+ 최초 게시글 보기 |
|
| 326 |
+ </button> |
|
| 327 |
+ <span className="flex items-center"> |
|
| 328 |
+ <HelpButton topic="org.firstPost" /> |
|
| 329 |
+ </span> |
|
| 277 | 330 |
{DISABLED_ACTIONS.map((label) => (
|
| 278 | 331 |
<button |
| 279 | 332 |
key={label}
|
... | ... | @@ -381,7 +434,10 @@ |
| 381 | 434 |
{/* 업무단계 현황: 완료된 단계는 초록 체크, 현재 단계는 파란 강조 + 클릭 시 다음 단계로
|
| 382 | 435 |
전진(확인 후), 그 뒤 단계는 회색 그대로. 채널 생성 전(stage === null)에는 전부 회색이다. */} |
| 383 | 436 |
<section className="mt-5"> |
| 384 |
- <h2 className="text-sm font-semibold">업무단계 현황</h2> |
|
| 437 |
+ <h2 className="flex items-center gap-1.5 text-sm font-semibold"> |
|
| 438 |
+ 업무단계 현황 |
|
| 439 |
+ <HelpButton topic="org.stage" /> |
|
| 440 |
+ </h2> |
|
| 385 | 441 |
<ol className="mt-3 flex gap-2 overflow-x-auto pb-2"> |
| 386 | 442 |
{STAGE_LABELS.map((step, i) => {
|
| 387 | 443 |
const n = i + 1 |
... | ... | @@ -506,6 +562,11 @@ |
| 506 | 562 |
{tab}
|
| 507 | 563 |
</button> |
| 508 | 564 |
))} |
| 565 |
+ {TAB_HELP[activeTab] && (
|
|
| 566 |
+ <span className="flex items-center pl-2"> |
|
| 567 |
+ <HelpButton topic={TAB_HELP[activeTab]} />
|
|
| 568 |
+ </span> |
|
| 569 |
+ )} |
|
| 509 | 570 |
</nav> |
| 510 | 571 |
</div> |
| 511 | 572 |
|
... | ... | @@ -548,6 +609,77 @@ |
| 548 | 609 |
onClose={() => setStagePickerOpen(false)}
|
| 549 | 610 |
/> |
| 550 | 611 |
)} |
| 612 |
+ |
|
| 613 |
+ {firstPosts && (
|
|
| 614 |
+ <div |
|
| 615 |
+ role="dialog" |
|
| 616 |
+ aria-label="최초 게시글" |
|
| 617 |
+ className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 p-6" |
|
| 618 |
+ > |
|
| 619 |
+ <div className="w-full max-w-2xl rounded-lg bg-white shadow-xl"> |
|
| 620 |
+ <div className="flex items-start justify-between gap-3 border-b border-gray-200 px-5 py-4"> |
|
| 621 |
+ <div> |
|
| 622 |
+ <h2 className="text-base font-semibold">최초 게시글</h2> |
|
| 623 |
+ <p className="mt-1 text-xs text-gray-500"> |
|
| 624 |
+ 각 채널에 고정(핀)된 게시물입니다. 채널을 만들 때 올린 최초 안내글이 여기 보입니다. |
|
| 625 |
+ </p> |
|
| 626 |
+ </div> |
|
| 627 |
+ <button |
|
| 628 |
+ type="button" |
|
| 629 |
+ aria-label="닫기" |
|
| 630 |
+ onClick={() => setFirstPosts(null)}
|
|
| 631 |
+ className="shrink-0 rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50" |
|
| 632 |
+ > |
|
| 633 |
+ 닫기 |
|
| 634 |
+ </button> |
|
| 635 |
+ </div> |
|
| 636 |
+ |
|
| 637 |
+ <div className="space-y-4 px-5 py-4"> |
|
| 638 |
+ {firstPostsBusy && <p className="text-sm text-gray-500">불러오는 중…</p>}
|
|
| 639 |
+ {firstPostsError && <p className="text-sm text-red-600">{firstPostsError}</p>}
|
|
| 640 |
+ |
|
| 641 |
+ {!firstPostsBusy &&
|
|
| 642 |
+ !firstPostsError && |
|
| 643 |
+ ([ |
|
| 644 |
+ ['문정원', firstPosts.mj], |
|
| 645 |
+ ['법률검토', firstPosts.law], |
|
| 646 |
+ ] as const).map(([label, post]) => ( |
|
| 647 |
+ <div key={label}>
|
|
| 648 |
+ <h3 className="text-sm font-semibold">{label} 채널</h3>
|
|
| 649 |
+ {post ? (
|
|
| 650 |
+ <div className="mt-1.5 rounded-md border border-gray-200 bg-amber-50/40 p-3"> |
|
| 651 |
+ <p className="text-xs text-gray-500"> |
|
| 652 |
+ {post.user} · {formatStageDate(post.createAt)}
|
|
| 653 |
+ </p> |
|
| 654 |
+ <p className="mt-1.5 whitespace-pre-wrap text-sm text-gray-800">{post.message}</p>
|
|
| 655 |
+ {post.files.length > 0 && (
|
|
| 656 |
+ <p className="mt-2 text-xs text-gray-500"> |
|
| 657 |
+ 첨부 {post.files.length}개: {post.files.map((f) => f.name).join(', ')}
|
|
| 658 |
+ </p> |
|
| 659 |
+ )} |
|
| 660 |
+ </div> |
|
| 661 |
+ ) : ( |
|
| 662 |
+ <p className="mt-1.5 text-sm text-gray-400">고정된 게시물이 없습니다.</p> |
|
| 663 |
+ )} |
|
| 664 |
+ </div> |
|
| 665 |
+ ))} |
|
| 666 |
+ </div> |
|
| 667 |
+ |
|
| 668 |
+ <div className="flex justify-end border-t border-gray-200 px-5 py-3"> |
|
| 669 |
+ <button |
|
| 670 |
+ type="button" |
|
| 671 |
+ onClick={() => {
|
|
| 672 |
+ setFirstPosts(null) |
|
| 673 |
+ setActiveTab('채팅')
|
|
| 674 |
+ }} |
|
| 675 |
+ className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50" |
|
| 676 |
+ > |
|
| 677 |
+ 채팅 탭에서 보기 |
|
| 678 |
+ </button> |
|
| 679 |
+ </div> |
|
| 680 |
+ </div> |
|
| 681 |
+ </div> |
|
| 682 |
+ )} |
|
| 551 | 683 |
</div> |
| 552 | 684 |
) |
| 553 | 685 |
} |
--- frontend/src/components/ProcessBoard.test.tsx
+++ frontend/src/components/ProcessBoard.test.tsx
... | ... | @@ -9,6 +9,8 @@ |
| 9 | 9 |
importProcess: vi.fn(), |
| 10 | 10 |
updateProcessing: vi.fn(), |
| 11 | 11 |
deleteProcessItem: vi.fn(), |
| 12 |
+ bulkUpdateProcess: vi.fn(), |
|
| 13 |
+ bulkDeleteProcess: vi.fn(), |
|
| 12 | 14 |
})) |
| 13 | 15 |
|
| 14 | 16 |
vi.mock('../api/client', async (importOriginal) => ({
|
... | ... | @@ -18,6 +20,8 @@ |
| 18 | 20 |
importProcess: mocks.importProcess, |
| 19 | 21 |
updateProcessing: mocks.updateProcessing, |
| 20 | 22 |
deleteProcessItem: mocks.deleteProcessItem, |
| 23 |
+ bulkUpdateProcess: mocks.bulkUpdateProcess, |
|
| 24 |
+ bulkDeleteProcess: mocks.bulkDeleteProcess, |
|
| 21 | 25 |
})) |
| 22 | 26 |
|
| 23 | 27 |
function org(overrides: Partial<Org> = {}): Org {
|
... | ... | @@ -92,6 +96,8 @@ |
| 92 | 96 |
mocks.importProcess.mockReset() |
| 93 | 97 |
mocks.updateProcessing.mockReset() |
| 94 | 98 |
mocks.deleteProcessItem.mockReset() |
| 99 |
+ mocks.bulkUpdateProcess.mockReset() |
|
| 100 |
+ mocks.bulkDeleteProcess.mockReset() |
|
| 95 | 101 |
}) |
| 96 | 102 |
|
| 97 | 103 |
describe('ProcessBoard 목록', () => {
|
... | ... | @@ -197,34 +203,84 @@ |
| 197 | 203 |
}) |
| 198 | 204 |
}) |
| 199 | 205 |
|
| 200 |
- it('처리완료 건은 수정/삭제 버튼이, 미처리 건은 처리등록 버튼만 보인다', async () => {
|
|
| 206 |
+ it('처리완료 건은 수정 버튼이, 미처리 건은 처리등록 버튼만 보인다', async () => {
|
|
| 201 | 207 |
mocks.getProcessPage.mockResolvedValue(page({ items: [item({ processStatus: '처리완료' })] }))
|
| 202 | 208 |
|
| 203 | 209 |
render(<ProcessBoard org={org()} />)
|
| 204 | 210 |
|
| 205 | 211 |
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
|
| 206 | 212 |
expect(screen.getByRole('button', { name: '수정' })).toBeTruthy()
|
| 207 |
- expect(screen.getByRole('button', { name: '삭제' })).toBeTruthy()
|
|
| 208 | 213 |
expect(screen.queryByRole('button', { name: '처리등록' })).toBeNull()
|
| 214 |
+ // 개별 행 삭제는 없앴다(상단 [선택 삭제] 또는 상세 화면에서 지운다) |
|
| 215 |
+ expect(screen.queryByRole('button', { name: '삭제' })).toBeNull()
|
|
| 209 | 216 |
}) |
| 210 | 217 |
|
| 211 |
- it('삭제를 누르면 확인 후 deleteProcessItem을 호출한다', async () => {
|
|
| 218 |
+ it('행을 체크하고 [선택 삭제]를 누르면 확인 후 일괄 삭제한다', async () => {
|
|
| 212 | 219 |
mocks.getProcessPage.mockResolvedValue(page({ items: [item({ processStatus: '처리완료' })] }))
|
| 213 |
- mocks.deleteProcessItem.mockResolvedValue(undefined) |
|
| 220 |
+ mocks.bulkDeleteProcess.mockResolvedValue({ deleted: 1 })
|
|
| 214 | 221 |
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) |
| 215 | 222 |
|
| 216 | 223 |
render(<ProcessBoard org={org()} />)
|
| 217 | 224 |
|
| 218 | 225 |
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
|
| 219 | 226 |
|
| 220 |
- fireEvent.click(screen.getByRole('button', { name: '삭제' }))
|
|
| 227 |
+ fireEvent.click(screen.getByLabelText('1번 선택'))
|
|
| 228 |
+ fireEvent.click(screen.getByRole('button', { name: '선택 삭제' }))
|
|
| 221 | 229 |
|
| 222 | 230 |
expect(confirmSpy).toHaveBeenCalled() |
| 223 |
- await waitFor(() => expect(mocks.deleteProcessItem).toHaveBeenCalledWith(1, 1)) |
|
| 231 |
+ await waitFor(() => expect(mocks.bulkDeleteProcess).toHaveBeenCalledWith(1, [1])) |
|
| 224 | 232 |
|
| 225 | 233 |
confirmSpy.mockRestore() |
| 226 | 234 |
}) |
| 227 | 235 |
|
| 236 |
+ it('체크한 건의 처리상태와 최종의견을 일괄 반영한다', async () => {
|
|
| 237 |
+ mocks.getProcessPage.mockResolvedValue(page()) |
|
| 238 |
+ mocks.bulkUpdateProcess.mockResolvedValue({ updated: 1 })
|
|
| 239 |
+ |
|
| 240 |
+ render(<ProcessBoard org={org()} />)
|
|
| 241 |
+ await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
|
|
| 242 |
+ |
|
| 243 |
+ fireEvent.click(screen.getByLabelText('1번 선택'))
|
|
| 244 |
+ fireEvent.change(screen.getByLabelText('일괄 처리상태'), { target: { value: '처리완료' } })
|
|
| 245 |
+ fireEvent.change(screen.getByLabelText('일괄 최종의견'), { target: { value: '일괄 처리' } })
|
|
| 246 |
+ fireEvent.click(screen.getByRole('button', { name: '일괄 등록' }))
|
|
| 247 |
+ |
|
| 248 |
+ await waitFor(() => |
|
| 249 |
+ expect(mocks.bulkUpdateProcess).toHaveBeenCalledWith(1, {
|
|
| 250 |
+ ids: [1], |
|
| 251 |
+ processStatus: '처리완료', |
|
| 252 |
+ finalOpinion: '일괄 처리', |
|
| 253 |
+ judgedKoglType: undefined, |
|
| 254 |
+ }), |
|
| 255 |
+ ) |
|
| 256 |
+ expect(await screen.findByText('1건에 반영했습니다.')).toBeTruthy()
|
|
| 257 |
+ }) |
|
| 258 |
+ |
|
| 259 |
+ it('상세검색 3칸은 열별 조건으로 함께 넘어간다', async () => {
|
|
| 260 |
+ mocks.getProcessPage.mockResolvedValue(page()) |
|
| 261 |
+ |
|
| 262 |
+ render(<ProcessBoard org={org()} />)
|
|
| 263 |
+ await waitFor(() => expect(mocks.getProcessPage).toHaveBeenCalled()) |
|
| 264 |
+ |
|
| 265 |
+ fireEvent.click(screen.getByRole('button', { name: '상세검색' }))
|
|
| 266 |
+ fireEvent.change(screen.getByLabelText('사이트명'), { target: { value: '누리집' } })
|
|
| 267 |
+ fireEvent.change(screen.getByLabelText('게시판명'), { target: { value: '공고' } })
|
|
| 268 |
+ fireEvent.change(screen.getByLabelText('게시물 제목'), { target: { value: '사업' } })
|
|
| 269 |
+ fireEvent.click(screen.getByRole('button', { name: '검색' }))
|
|
| 270 |
+ |
|
| 271 |
+ await waitFor(() => |
|
| 272 |
+ expect(mocks.getProcessPage).toHaveBeenLastCalledWith(1, {
|
|
| 273 |
+ keyword: undefined, |
|
| 274 |
+ site: '누리집', |
|
| 275 |
+ board: '공고', |
|
| 276 |
+ title: '사업', |
|
| 277 |
+ status: undefined, |
|
| 278 |
+ page: 0, |
|
| 279 |
+ size: 30, |
|
| 280 |
+ }), |
|
| 281 |
+ ) |
|
| 282 |
+ }) |
|
| 283 |
+ |
|
| 228 | 284 |
it('다음 페이지 버튼을 누르면 page+1로 다시 조회한다', async () => {
|
| 229 | 285 |
mocks.getProcessPage.mockResolvedValue(page({ total: 31 }))
|
| 230 | 286 |
|
--- frontend/src/components/ProcessBoard.tsx
+++ frontend/src/components/ProcessBoard.tsx
... | ... | @@ -1,5 +1,7 @@ |
| 1 | 1 |
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
| 2 | 2 |
import {
|
| 3 |
+ bulkDeleteProcess, |
|
| 4 |
+ bulkUpdateProcess, |
|
| 3 | 5 |
deleteProcessItem, |
| 4 | 6 |
getProcessItem, |
| 5 | 7 |
getProcessPage, |
... | ... | @@ -175,6 +177,19 @@ |
| 175 | 177 |
const [statusFilter, setStatusFilter] = useState<ProcessStatusFilter | undefined>(undefined) |
| 176 | 178 |
const [keywordInput, setKeywordInput] = useState('')
|
| 177 | 179 |
const [appliedKeyword, setAppliedKeyword] = useState('')
|
| 180 |
+ /** 복합검색(열별 조건). 서로 AND로 겹친다. */ |
|
| 181 |
+ const [advancedOpen, setAdvancedOpen] = useState(false) |
|
| 182 |
+ const [siteInput, setSiteInput] = useState('')
|
|
| 183 |
+ const [boardInput, setBoardInput] = useState('')
|
|
| 184 |
+ const [titleInput, setTitleInput] = useState('')
|
|
| 185 |
+ const [applied, setApplied] = useState({ site: '', board: '', title: '' })
|
|
| 186 |
+ /** 일괄 등록/삭제 대상. */ |
|
| 187 |
+ const [selected, setSelected] = useState<number[]>([]) |
|
| 188 |
+ const [bulkStatus, setBulkStatus] = useState('')
|
|
| 189 |
+ const [bulkKoglType, setBulkKoglType] = useState('')
|
|
| 190 |
+ const [bulkOpinion, setBulkOpinion] = useState('')
|
|
| 191 |
+ const [bulkBusy, setBulkBusy] = useState(false) |
|
| 192 |
+ const [bulkMessage, setBulkMessage] = useState<string | null>(null) |
|
| 178 | 193 |
const [loading, setLoading] = useState(false) |
| 179 | 194 |
const [error, setError] = useState<string | null>(null) |
| 180 | 195 |
const [reloadKey, setReloadKey] = useState(0) |
... | ... | @@ -194,6 +209,9 @@ |
| 194 | 209 |
|
| 195 | 210 |
getProcessPage(org.id, {
|
| 196 | 211 |
keyword: appliedKeyword || undefined, |
| 212 |
+ site: applied.site || undefined, |
|
| 213 |
+ board: applied.board || undefined, |
|
| 214 |
+ title: applied.title || undefined, |
|
| 197 | 215 |
status: statusFilter, |
| 198 | 216 |
page, |
| 199 | 217 |
size: PAGE_SIZE, |
... | ... | @@ -221,17 +239,26 @@ |
| 221 | 239 |
cancelled = true |
| 222 | 240 |
} |
| 223 | 241 |
// eslint-disable-next-line react-hooks/exhaustive-deps |
| 224 |
- }, [mode, org.id, appliedKeyword, statusFilter, page, reloadKey]) |
|
| 242 |
+ }, [mode, org.id, appliedKeyword, applied, statusFilter, page, reloadKey]) |
|
| 243 |
+ |
|
| 244 |
+ function handleSearchFields() {
|
|
| 245 |
+ setApplied({ site: siteInput.trim(), board: boardInput.trim(), title: titleInput.trim() })
|
|
| 246 |
+ } |
|
| 225 | 247 |
|
| 226 | 248 |
function handleSearch() {
|
| 249 |
+ handleSearchFields() |
|
| 227 | 250 |
setPage(0) |
| 228 | 251 |
setAppliedKeyword(keywordInput.trim()) |
| 229 | 252 |
} |
| 230 | 253 |
|
| 231 | 254 |
function handleResetSearch() {
|
| 232 | 255 |
setKeywordInput('')
|
| 256 |
+ setSiteInput('')
|
|
| 257 |
+ setBoardInput('')
|
|
| 258 |
+ setTitleInput('')
|
|
| 233 | 259 |
setPage(0) |
| 234 | 260 |
setAppliedKeyword('')
|
| 261 |
+ setApplied({ site: '', board: '', title: '' })
|
|
| 235 | 262 |
} |
| 236 | 263 |
|
| 237 | 264 |
function pickStatusChip(key: ProcessStatusFilter | undefined) {
|
... | ... | @@ -253,15 +280,65 @@ |
| 253 | 280 |
} |
| 254 | 281 |
} |
| 255 | 282 |
|
| 256 |
- async function handleDelete(item: ProcessItem) {
|
|
| 257 |
- if (!window.confirm(`"${item.postTitle ?? item.seq}" 게시물을 삭제할까요?`)) {
|
|
| 283 |
+ const allChecked = items.length > 0 && items.every((item) => selected.includes(item.id)) |
|
| 284 |
+ |
|
| 285 |
+ function toggleAll() {
|
|
| 286 |
+ setSelected(allChecked ? [] : items.map((item) => item.id)) |
|
| 287 |
+ } |
|
| 288 |
+ |
|
| 289 |
+ function toggleOne(id: number) {
|
|
| 290 |
+ setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])) |
|
| 291 |
+ } |
|
| 292 |
+ |
|
| 293 |
+ /** 체크한 건들에 채운 항목만 한 번에 반영한다. */ |
|
| 294 |
+ async function handleBulkApply() {
|
|
| 295 |
+ if (selected.length === 0 || bulkBusy) {
|
|
| 258 | 296 |
return |
| 259 | 297 |
} |
| 298 |
+ if (!bulkStatus && !bulkKoglType && !bulkOpinion.trim()) {
|
|
| 299 |
+ setBulkMessage('반영할 값을 하나 이상 채워주세요.')
|
|
| 300 |
+ return |
|
| 301 |
+ } |
|
| 302 |
+ setBulkBusy(true) |
|
| 303 |
+ setBulkMessage(null) |
|
| 260 | 304 |
try {
|
| 261 |
- await deleteProcessItem(org.id, item.id) |
|
| 305 |
+ const res = await bulkUpdateProcess(org.id, {
|
|
| 306 |
+ ids: selected, |
|
| 307 |
+ processStatus: bulkStatus || undefined, |
|
| 308 |
+ judgedKoglType: bulkKoglType || undefined, |
|
| 309 |
+ finalOpinion: bulkOpinion.trim() || undefined, |
|
| 310 |
+ }) |
|
| 311 |
+ setBulkMessage(`${res.updated}건에 반영했습니다.`)
|
|
| 312 |
+ setSelected([]) |
|
| 313 |
+ setBulkStatus('')
|
|
| 314 |
+ setBulkKoglType('')
|
|
| 315 |
+ setBulkOpinion('')
|
|
| 262 | 316 |
setReloadKey((k) => k + 1) |
| 263 | 317 |
} catch (e) {
|
| 264 |
- setError(e instanceof Error ? e.message : '삭제에 실패했습니다.') |
|
| 318 |
+ setBulkMessage(e instanceof Error ? e.message : '일괄 등록에 실패했습니다.') |
|
| 319 |
+ } finally {
|
|
| 320 |
+ setBulkBusy(false) |
|
| 321 |
+ } |
|
| 322 |
+ } |
|
| 323 |
+ |
|
| 324 |
+ async function handleBulkDelete() {
|
|
| 325 |
+ if (selected.length === 0 || bulkBusy) {
|
|
| 326 |
+ return |
|
| 327 |
+ } |
|
| 328 |
+ if (!window.confirm(`선택한 ${selected.length}건을 삭제할까요?`)) {
|
|
| 329 |
+ return |
|
| 330 |
+ } |
|
| 331 |
+ setBulkBusy(true) |
|
| 332 |
+ setBulkMessage(null) |
|
| 333 |
+ try {
|
|
| 334 |
+ const res = await bulkDeleteProcess(org.id, selected) |
|
| 335 |
+ setBulkMessage(`${res.deleted}건을 삭제했습니다.`)
|
|
| 336 |
+ setSelected([]) |
|
| 337 |
+ setReloadKey((k) => k + 1) |
|
| 338 |
+ } catch (e) {
|
|
| 339 |
+ setBulkMessage(e instanceof Error ? e.message : '삭제에 실패했습니다.') |
|
| 340 |
+ } finally {
|
|
| 341 |
+ setBulkBusy(false) |
|
| 265 | 342 |
} |
| 266 | 343 |
} |
| 267 | 344 |
|
... | ... | @@ -316,6 +393,14 @@ |
| 316 | 393 |
placeholder="사이트명·게시물제목 검색" |
| 317 | 394 |
className="w-64 rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
| 318 | 395 |
/> |
| 396 |
+ <button |
|
| 397 |
+ type="button" |
|
| 398 |
+ aria-expanded={advancedOpen}
|
|
| 399 |
+ onClick={() => setAdvancedOpen((v) => !v)}
|
|
| 400 |
+ className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50" |
|
| 401 |
+ > |
|
| 402 |
+ 상세검색 |
|
| 403 |
+ </button> |
|
| 319 | 404 |
<button |
| 320 | 405 |
type="button" |
| 321 | 406 |
onClick={handleSearch}
|
... | ... | @@ -376,13 +461,101 @@ |
| 376 | 461 |
|
| 377 | 462 |
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
|
| 378 | 463 |
|
| 464 |
+ {advancedOpen && (
|
|
| 465 |
+ <div className="mt-3 flex flex-wrap items-end gap-2 rounded-md border border-gray-200 bg-gray-50 p-3"> |
|
| 466 |
+ {(
|
|
| 467 |
+ [ |
|
| 468 |
+ ['사이트명', siteInput, setSiteInput], |
|
| 469 |
+ ['게시판명', boardInput, setBoardInput], |
|
| 470 |
+ ['게시물 제목', titleInput, setTitleInput], |
|
| 471 |
+ ] as const |
|
| 472 |
+ ).map(([label, value, setter]) => ( |
|
| 473 |
+ <label key={label} className="flex flex-col gap-1 text-xs text-gray-500">
|
|
| 474 |
+ {label}
|
|
| 475 |
+ <input |
|
| 476 |
+ type="text" |
|
| 477 |
+ aria-label={label}
|
|
| 478 |
+ value={value}
|
|
| 479 |
+ onChange={(e) => setter(e.target.value)}
|
|
| 480 |
+ onKeyDown={(e) => {
|
|
| 481 |
+ if (e.key === 'Enter') {
|
|
| 482 |
+ handleSearch() |
|
| 483 |
+ } |
|
| 484 |
+ }} |
|
| 485 |
+ className="w-48 rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
|
| 486 |
+ /> |
|
| 487 |
+ </label> |
|
| 488 |
+ ))} |
|
| 489 |
+ <span className="pb-2 text-xs text-gray-400">세 조건은 모두 만족하는 건만 찾습니다(AND)</span> |
|
| 490 |
+ </div> |
|
| 491 |
+ )} |
|
| 492 |
+ |
|
| 493 |
+ {/* 일괄 등록: 체크한 건들에 같은 값을 한 번에 넣는다. 삭제도 여기서 한다. */}
|
|
| 494 |
+ <div className="mt-3 flex flex-wrap items-center gap-2 rounded-md border border-gray-200 bg-gray-50 p-3"> |
|
| 495 |
+ <span className="text-sm font-medium text-gray-700">선택 {selected.length}건</span>
|
|
| 496 |
+ <select |
|
| 497 |
+ aria-label="일괄 처리상태" |
|
| 498 |
+ value={bulkStatus}
|
|
| 499 |
+ onChange={(e) => setBulkStatus(e.target.value)}
|
|
| 500 |
+ className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
|
| 501 |
+ > |
|
| 502 |
+ <option value="">처리상태 그대로</option> |
|
| 503 |
+ <option value="처리완료">처리완료</option> |
|
| 504 |
+ <option value="미처리">미처리</option> |
|
| 505 |
+ </select> |
|
| 506 |
+ <select |
|
| 507 |
+ aria-label="일괄 공공누리 유형" |
|
| 508 |
+ value={bulkKoglType}
|
|
| 509 |
+ onChange={(e) => setBulkKoglType(e.target.value)}
|
|
| 510 |
+ className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
|
| 511 |
+ > |
|
| 512 |
+ <option value="">공공누리 유형 그대로</option> |
|
| 513 |
+ {JUDGED_KOGL_TYPE_OPTIONS.map((option) => (
|
|
| 514 |
+ <option key={option} value={option}>{option}</option>
|
|
| 515 |
+ ))} |
|
| 516 |
+ </select> |
|
| 517 |
+ <input |
|
| 518 |
+ type="text" |
|
| 519 |
+ aria-label="일괄 최종의견" |
|
| 520 |
+ value={bulkOpinion}
|
|
| 521 |
+ onChange={(e) => setBulkOpinion(e.target.value)}
|
|
| 522 |
+ placeholder="최종의견(비우면 유지)" |
|
| 523 |
+ className="w-56 rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
|
| 524 |
+ /> |
|
| 525 |
+ <button |
|
| 526 |
+ type="button" |
|
| 527 |
+ disabled={selected.length === 0 || bulkBusy}
|
|
| 528 |
+ onClick={() => void handleBulkApply()}
|
|
| 529 |
+ className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white disabled:bg-gray-300" |
|
| 530 |
+ > |
|
| 531 |
+ 일괄 등록 |
|
| 532 |
+ </button> |
|
| 533 |
+ <button |
|
| 534 |
+ type="button" |
|
| 535 |
+ disabled={selected.length === 0 || bulkBusy}
|
|
| 536 |
+ onClick={() => void handleBulkDelete()}
|
|
| 537 |
+ className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 disabled:text-gray-300" |
|
| 538 |
+ > |
|
| 539 |
+ 선택 삭제 |
|
| 540 |
+ </button> |
|
| 541 |
+ {bulkMessage && <span className="text-sm text-gray-600">{bulkMessage}</span>}
|
|
| 542 |
+ </div> |
|
| 543 |
+ |
|
| 379 | 544 |
<div className="mt-3 overflow-x-auto rounded-lg border border-gray-200"> |
| 380 | 545 |
<table className="w-full min-w-[1400px] text-left text-sm"> |
| 381 | 546 |
<thead> |
| 382 | 547 |
<tr className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500"> |
| 548 |
+ <th className="w-8 px-3 py-2 font-medium"> |
|
| 549 |
+ <input |
|
| 550 |
+ type="checkbox" |
|
| 551 |
+ aria-label="전체 선택" |
|
| 552 |
+ checked={allChecked}
|
|
| 553 |
+ onChange={toggleAll}
|
|
| 554 |
+ /> |
|
| 555 |
+ </th> |
|
| 383 | 556 |
<th className="whitespace-nowrap px-3 py-2 font-medium">No</th> |
| 384 | 557 |
<th className="whitespace-nowrap px-3 py-2 font-medium">사이트/게시판</th> |
| 385 |
- <th className="px-3 py-2 font-medium">제목</th> |
|
| 558 |
+ <th className="px-3 py-2 font-medium">게시물 경로/제목</th> |
|
| 386 | 559 |
<th className="whitespace-nowrap px-3 py-2 font-medium">제작/공표일</th> |
| 387 | 560 |
<th className="whitespace-nowrap px-3 py-2 font-medium">기존유형/계약서</th> |
| 388 | 561 |
<th className="whitespace-nowrap px-3 py-2 font-medium">권리확인구분</th> |
... | ... | @@ -398,19 +571,27 @@ |
| 398 | 571 |
<tbody> |
| 399 | 572 |
{loading ? (
|
| 400 | 573 |
<tr> |
| 401 |
- <td colSpan={13} className="py-8 text-center text-sm text-gray-400">
|
|
| 574 |
+ <td colSpan={14} className="py-8 text-center text-sm text-gray-400">
|
|
| 402 | 575 |
불러오는 중… |
| 403 | 576 |
</td> |
| 404 | 577 |
</tr> |
| 405 | 578 |
) : items.length === 0 ? ( |
| 406 | 579 |
<tr> |
| 407 |
- <td colSpan={13} className="py-10 text-center text-sm text-gray-300">
|
|
| 580 |
+ <td colSpan={14} className="py-10 text-center text-sm text-gray-300">
|
|
| 408 | 581 |
업로드된 권리처리 자료가 없습니다. 엑셀 업로드로 시작하세요. |
| 409 | 582 |
</td> |
| 410 | 583 |
</tr> |
| 411 | 584 |
) : ( |
| 412 | 585 |
items.map((item) => ( |
| 413 | 586 |
<tr key={item.id} className="border-b border-gray-100 align-top last:border-b-0">
|
| 587 |
+ <td className="px-3 py-2"> |
|
| 588 |
+ <input |
|
| 589 |
+ type="checkbox" |
|
| 590 |
+ aria-label={`${item.seq}번 선택`}
|
|
| 591 |
+ checked={selected.includes(item.id)}
|
|
| 592 |
+ onChange={() => toggleOne(item.id)}
|
|
| 593 |
+ /> |
|
| 594 |
+ </td> |
|
| 414 | 595 |
<td className="px-3 py-2 text-gray-500">{item.seq}</td>
|
| 415 | 596 |
<td className="px-3 py-2"> |
| 416 | 597 |
<div className="text-gray-900">{item.siteName ?? '-'}</div>
|
... | ... | @@ -475,13 +656,6 @@ |
| 475 | 656 |
className="rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50" |
| 476 | 657 |
> |
| 477 | 658 |
수정 |
| 478 |
- </button> |
|
| 479 |
- <button |
|
| 480 |
- type="button" |
|
| 481 |
- onClick={() => void handleDelete(item)}
|
|
| 482 |
- className="rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50" |
|
| 483 |
- > |
|
| 484 |
- 삭제 |
|
| 485 | 659 |
</button> |
| 486 | 660 |
</> |
| 487 | 661 |
) : ( |
+++ frontend/src/components/ProjectsPage.test.tsx
... | ... | @@ -0,0 +1,222 @@ |
| 1 | +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' | |
| 2 | +import { beforeEach, describe, expect, it, vi } from 'vitest' | |
| 3 | +import ProjectsPage from './ProjectsPage' | |
| 4 | +import type { ContactLog, DashboardOrgRow, OrgReport, ProjectRow } from '../api/client' | |
| 5 | + | |
| 6 | +const mocks = vi.hoisted(() => ({ | |
| 7 | + getProjects: vi.fn(), | |
| 8 | + getContactLogs: vi.fn(), | |
| 9 | + addContactLog: vi.fn(), | |
| 10 | + updateContactLog: vi.fn(), | |
| 11 | + deleteContactLog: vi.fn(), | |
| 12 | + getReports: vi.fn(), | |
| 13 | + uploadReport: vi.fn(), | |
| 14 | + deleteReport: vi.fn(), | |
| 15 | +})) | |
| 16 | + | |
| 17 | +vi.mock('../api/client', async (importOriginal) => ({ | |
| 18 | + ...(await importOriginal<typeof import('../api/client')>()), | |
| 19 | + ...mocks, | |
| 20 | +})) | |
| 21 | + | |
| 22 | +function orgRow(overrides: Partial<DashboardOrgRow> = {}): DashboardOrgRow { | |
| 23 | + return { | |
| 24 | + id: 1, | |
| 25 | + orgNo: '001_00', | |
| 26 | + orgName: '국제방송교류재단', | |
| 27 | + stage: 5, | |
| 28 | + hasChannel: true, | |
| 29 | + lawyerName: '권홍철', | |
| 30 | + mjName: '이연경', | |
| 31 | + lawyerAssignedDate: null, | |
| 32 | + reviewTotal: 100, | |
| 33 | + reviewDone: 40, | |
| 34 | + processTotal: 10, | |
| 35 | + processDone: 2, | |
| 36 | + stageChangedAt: null, | |
| 37 | + lastChangedAt: null, | |
| 38 | + reCount: 0, | |
| 39 | + ...overrides, | |
| 40 | + } | |
| 41 | +} | |
| 42 | + | |
| 43 | +function row(overrides: Partial<ProjectRow> = {}): ProjectRow { | |
| 44 | + return { | |
| 45 | + org: orgRow(), | |
| 46 | + lastContactedOn: null, | |
| 47 | + contactCount: 0, | |
| 48 | + reportCount: 0, | |
| 49 | + ...overrides, | |
| 50 | + } | |
| 51 | +} | |
| 52 | + | |
| 53 | +function log(overrides: Partial<ContactLog> = {}): ContactLog { | |
| 54 | + return { | |
| 55 | + id: 1, | |
| 56 | + orgId: 1, | |
| 57 | + contactedOn: '2026-07-20', | |
| 58 | + method: '전화', | |
| 59 | + summary: '신청 확인 통화', | |
| 60 | + author: 'admin', | |
| 61 | + createdAt: Date.now(), | |
| 62 | + ...overrides, | |
| 63 | + } | |
| 64 | +} | |
| 65 | + | |
| 66 | +function report(overrides: Partial<OrgReport> = {}): OrgReport { | |
| 67 | + return { | |
| 68 | + id: 1, | |
| 69 | + orgId: 1, | |
| 70 | + fileName: '최종보고서.pdf', | |
| 71 | + contentType: 'application/pdf', | |
| 72 | + byteSize: 2048, | |
| 73 | + uploadedBy: 'admin', | |
| 74 | + uploadedAt: Date.now(), | |
| 75 | + ...overrides, | |
| 76 | + } | |
| 77 | +} | |
| 78 | + | |
| 79 | +async function openPanel() { | |
| 80 | + render(<ProjectsPage onOpenOrg={vi.fn()} />) | |
| 81 | + await screen.findByTestId('project-table') | |
| 82 | + fireEvent.click(screen.getByRole('button', { name: '연락·보고서' })) | |
| 83 | + await waitFor(() => expect(mocks.getContactLogs).toHaveBeenCalledWith(1)) | |
| 84 | +} | |
| 85 | + | |
| 86 | +describe('ProjectsPage', () => { | |
| 87 | + beforeEach(() => { | |
| 88 | + vi.clearAllMocks() | |
| 89 | + mocks.getProjects.mockResolvedValue([row()]) | |
| 90 | + mocks.getContactLogs.mockResolvedValue([]) | |
| 91 | + mocks.getReports.mockResolvedValue([]) | |
| 92 | + }) | |
| 93 | + | |
| 94 | + it('기관 현황을 표로 보여준다', async () => { | |
| 95 | + mocks.getProjects.mockResolvedValue([ | |
| 96 | + row({ lastContactedOn: '2026-07-20', contactCount: 3, reportCount: 1 }), | |
| 97 | + ]) | |
| 98 | + render(<ProjectsPage onOpenOrg={vi.fn()} />) | |
| 99 | + | |
| 100 | + const table = within(await screen.findByTestId('project-table')) | |
| 101 | + expect(table.getByText('국제방송교류재단')).toBeTruthy() | |
| 102 | + expect(table.getByText('5. 법률검토(권리확인)')).toBeTruthy() | |
| 103 | + expect(table.getByText('2026-07-20')).toBeTruthy() | |
| 104 | + expect(table.getByText('(3)')).toBeTruthy() | |
| 105 | + }) | |
| 106 | + | |
| 107 | + it('기관명을 클릭하면 기관관리로 넘긴다', async () => { | |
| 108 | + const onOpenOrg = vi.fn() | |
| 109 | + render(<ProjectsPage onOpenOrg={onOpenOrg} />) | |
| 110 | + | |
| 111 | + fireEvent.click(await screen.findByRole('button', { name: '국제방송교류재단' })) | |
| 112 | + expect(onOpenOrg).toHaveBeenCalledWith(1) | |
| 113 | + }) | |
| 114 | + | |
| 115 | + it('검색과 연락 이력 필터로 목록을 거른다', async () => { | |
| 116 | + mocks.getProjects.mockResolvedValue([ | |
| 117 | + row(), | |
| 118 | + row({ org: orgRow({ id: 2, orgNo: '002_00', orgName: '세종학당재단' }), contactCount: 2 }), | |
| 119 | + ]) | |
| 120 | + render(<ProjectsPage onOpenOrg={vi.fn()} />) | |
| 121 | + await screen.findByTestId('project-table') | |
| 122 | + | |
| 123 | + fireEvent.change(screen.getByLabelText('기관명/기관코드 검색'), { target: { value: '세종' } }) | |
| 124 | + expect(within(screen.getByTestId('project-table')).queryByText('국제방송교류재단')).toBeNull() | |
| 125 | + | |
| 126 | + fireEvent.change(screen.getByLabelText('기관명/기관코드 검색'), { target: { value: '' } }) | |
| 127 | + fireEvent.change(screen.getByLabelText('연락 이력'), { target: { value: 'none' } }) | |
| 128 | + expect(within(screen.getByTestId('project-table')).queryByText('세종학당재단')).toBeNull() | |
| 129 | + expect(within(screen.getByTestId('project-table')).getByText('국제방송교류재단')).toBeTruthy() | |
| 130 | + }) | |
| 131 | + | |
| 132 | + it('연락·보고서 창을 열면 이력과 보고서를 함께 읽는다', async () => { | |
| 133 | + mocks.getContactLogs.mockResolvedValue([log()]) | |
| 134 | + mocks.getReports.mockResolvedValue([report()]) | |
| 135 | + | |
| 136 | + await openPanel() | |
| 137 | + | |
| 138 | + expect(screen.getByText('신청 확인 통화')).toBeTruthy() | |
| 139 | + expect(screen.getByText('최종보고서.pdf')).toBeTruthy() | |
| 140 | + expect(screen.getByText(/2026-07-20 · 전화 · admin/)).toBeTruthy() | |
| 141 | + }) | |
| 142 | + | |
| 143 | + it('연락 이력을 등록하면 서버가 준 목록으로 갱신한다', async () => { | |
| 144 | + mocks.addContactLog.mockResolvedValue([log({ summary: '자료 요청' })]) | |
| 145 | + await openPanel() | |
| 146 | + | |
| 147 | + fireEvent.change(screen.getByLabelText('연락일'), { target: { value: '2026-07-25' } }) | |
| 148 | + fireEvent.change(screen.getByLabelText('연락 방법'), { target: { value: '메일' } }) | |
| 149 | + fireEvent.change(screen.getByLabelText('연락 내용'), { target: { value: '자료 요청' } }) | |
| 150 | + fireEvent.click(screen.getByRole('button', { name: '등록' })) | |
| 151 | + | |
| 152 | + await waitFor(() => | |
| 153 | + expect(mocks.addContactLog).toHaveBeenCalledWith(1, { | |
| 154 | + contactedOn: '2026-07-25', | |
| 155 | + method: '메일', | |
| 156 | + summary: '자료 요청', | |
| 157 | + }), | |
| 158 | + ) | |
| 159 | + expect(await screen.findByText('자료 요청')).toBeTruthy() | |
| 160 | + // 목록 숫자(최근 연락)도 다시 읽는다 | |
| 161 | + expect(mocks.getProjects).toHaveBeenCalledTimes(2) | |
| 162 | + }) | |
| 163 | + | |
| 164 | + it('내용이 비면 등록 버튼이 잠긴다', async () => { | |
| 165 | + await openPanel() | |
| 166 | + expect(screen.getByRole('button', { name: '등록' })).toBeDisabled() | |
| 167 | + }) | |
| 168 | + | |
| 169 | + it('수정을 누르면 입력칸에 값이 올라오고 저장하면 updateContactLog를 부른다', async () => { | |
| 170 | + mocks.getContactLogs.mockResolvedValue([log({ id: 9, summary: '원래 내용' })]) | |
| 171 | + mocks.updateContactLog.mockResolvedValue([log({ id: 9, summary: '고친 내용' })]) | |
| 172 | + await openPanel() | |
| 173 | + | |
| 174 | + fireEvent.click(screen.getByRole('button', { name: '수정' })) | |
| 175 | + expect((screen.getByLabelText('연락 내용') as HTMLInputElement).value).toBe('원래 내용') | |
| 176 | + | |
| 177 | + fireEvent.change(screen.getByLabelText('연락 내용'), { target: { value: '고친 내용' } }) | |
| 178 | + fireEvent.click(screen.getByRole('button', { name: '수정 저장' })) | |
| 179 | + | |
| 180 | + await waitFor(() => | |
| 181 | + expect(mocks.updateContactLog).toHaveBeenCalledWith(1, 9, { | |
| 182 | + contactedOn: '2026-07-20', | |
| 183 | + method: '전화', | |
| 184 | + summary: '고친 내용', | |
| 185 | + }), | |
| 186 | + ) | |
| 187 | + }) | |
| 188 | + | |
| 189 | + it('연락 이력 삭제는 확인을 물어본다', async () => { | |
| 190 | + mocks.getContactLogs.mockResolvedValue([log({ id: 9 })]) | |
| 191 | + mocks.deleteContactLog.mockResolvedValue(undefined) | |
| 192 | + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) | |
| 193 | + await openPanel() | |
| 194 | + | |
| 195 | + fireEvent.click(screen.getByRole('button', { name: '삭제' })) | |
| 196 | + | |
| 197 | + expect(confirmSpy).toHaveBeenCalled() | |
| 198 | + await waitFor(() => expect(mocks.deleteContactLog).toHaveBeenCalledWith(1, 9)) | |
| 199 | + confirmSpy.mockRestore() | |
| 200 | + }) | |
| 201 | + | |
| 202 | + it('보고서를 고르면 업로드하고 목록을 갱신한다', async () => { | |
| 203 | + mocks.uploadReport.mockResolvedValue([report({ fileName: '2차보고서.pdf' })]) | |
| 204 | + await openPanel() | |
| 205 | + | |
| 206 | + const file = new File(['내용'], '2차보고서.pdf', { type: 'application/pdf' }) | |
| 207 | + fireEvent.change(screen.getByLabelText('보고서 파일'), { target: { files: [file] } }) | |
| 208 | + | |
| 209 | + await waitFor(() => expect(mocks.uploadReport).toHaveBeenCalledWith(1, file)) | |
| 210 | + expect(await screen.findByText('2차보고서.pdf')).toBeTruthy() | |
| 211 | + }) | |
| 212 | + | |
| 213 | + it('보고서는 다운로드 링크로 걸린다', async () => { | |
| 214 | + mocks.getReports.mockResolvedValue([report({ id: 7 })]) | |
| 215 | + await openPanel() | |
| 216 | + | |
| 217 | + expect(screen.getByRole('link', { name: '최종보고서.pdf' })).toHaveAttribute( | |
| 218 | + 'href', | |
| 219 | + '/api/orgs/1/reports/7', | |
| 220 | + ) | |
| 221 | + }) | |
| 222 | +}) |
+++ frontend/src/components/ProjectsPage.tsx
... | ... | @@ -0,0 +1,525 @@ |
| 1 | +import { useCallback, useEffect, useMemo, useState } from 'react' | |
| 2 | +import { | |
| 3 | + addContactLog, | |
| 4 | + deleteContactLog, | |
| 5 | + deleteReport, | |
| 6 | + getContactLogs, | |
| 7 | + getProjects, | |
| 8 | + getReports, | |
| 9 | + reportDownloadUrl, | |
| 10 | + updateContactLog, | |
| 11 | + uploadReport, | |
| 12 | + type ContactLog, | |
| 13 | + type OrgReport, | |
| 14 | + type ProjectRow, | |
| 15 | +} from '../api/client' | |
| 16 | +import { stageLabel } from '../stages' | |
| 17 | +import HelpButton from '../help/HelpButton' | |
| 18 | + | |
| 19 | +/** | |
| 20 | + * 사업관리 화면. 3단계 법률검토로 진행할 기관 현황을 한 표로 모아 보고, 기관을 고르면 | |
| 21 | + * "신청 확인 연락" 이력과 최종 보고서를 다룬다(요청자 요구: 별도 메뉴 + 연락 이력관리 + | |
| 22 | + * 기관별 최종 보고서 업로드). | |
| 23 | + * | |
| 24 | + * 기관 현황 숫자는 대시보드와 같은 집계를 그대로 받아 쓴다 - 두 화면 숫자가 어긋나지 않게. | |
| 25 | + */ | |
| 26 | + | |
| 27 | +interface Props { | |
| 28 | + /** 기관명을 클릭했을 때 기관관리 화면으로 넘긴다. */ | |
| 29 | + onOpenOrg: (orgId: number) => void | |
| 30 | +} | |
| 31 | + | |
| 32 | +const METHODS = ['전화', '메일', '방문', '기타'] | |
| 33 | + | |
| 34 | +function formatDateTime(ms: number): string { | |
| 35 | + const d = new Date(ms) | |
| 36 | + const pad = (n: number) => String(n).padStart(2, '0') | |
| 37 | + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` | |
| 38 | +} | |
| 39 | + | |
| 40 | +function formatSize(bytes: number): string { | |
| 41 | + if (bytes < 1024) { | |
| 42 | + return `${bytes} B` | |
| 43 | + } | |
| 44 | + if (bytes < 1024 * 1024) { | |
| 45 | + return `${Math.round(bytes / 1024)} KB` | |
| 46 | + } | |
| 47 | + return `${(bytes / 1024 / 1024).toFixed(1)} MB` | |
| 48 | +} | |
| 49 | + | |
| 50 | +/** 오늘 날짜를 yyyy-MM-dd로. 연락 이력 입력의 기본값이다. */ | |
| 51 | +function today(): string { | |
| 52 | + const d = new Date() | |
| 53 | + const pad = (n: number) => String(n).padStart(2, '0') | |
| 54 | + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` | |
| 55 | +} | |
| 56 | + | |
| 57 | +export default function ProjectsPage({ onOpenOrg }: Props) { | |
| 58 | + const [rows, setRows] = useState<ProjectRow[] | null>(null) | |
| 59 | + const [error, setError] = useState<string | null>(null) | |
| 60 | + const [keyword, setKeyword] = useState('') | |
| 61 | + const [stageFilter, setStageFilter] = useState('') | |
| 62 | + const [contactFilter, setContactFilter] = useState('') | |
| 63 | + | |
| 64 | + /** 오른쪽 상세 패널에서 다루는 기관. */ | |
| 65 | + const [selected, setSelected] = useState<ProjectRow | null>(null) | |
| 66 | + const [logs, setLogs] = useState<ContactLog[]>([]) | |
| 67 | + const [reports, setReports] = useState<OrgReport[]>([]) | |
| 68 | + const [panelBusy, setPanelBusy] = useState(false) | |
| 69 | + const [panelError, setPanelError] = useState<string | null>(null) | |
| 70 | + | |
| 71 | + const [logDate, setLogDate] = useState(today()) | |
| 72 | + const [logMethod, setLogMethod] = useState(METHODS[0]) | |
| 73 | + const [logSummary, setLogSummary] = useState('') | |
| 74 | + const [editingLogId, setEditingLogId] = useState<number | null>(null) | |
| 75 | + | |
| 76 | + const reload = useCallback(async () => { | |
| 77 | + try { | |
| 78 | + setRows(await getProjects()) | |
| 79 | + setError(null) | |
| 80 | + } catch (e) { | |
| 81 | + setError((e as Error).message) | |
| 82 | + } | |
| 83 | + }, []) | |
| 84 | + | |
| 85 | + useEffect(() => { | |
| 86 | + void reload() | |
| 87 | + }, [reload]) | |
| 88 | + | |
| 89 | + const visible = useMemo(() => { | |
| 90 | + const kw = keyword.trim() | |
| 91 | + return (rows ?? []).filter((row) => { | |
| 92 | + if (kw && !row.org.orgName.includes(kw) && !row.org.orgNo.includes(kw)) { | |
| 93 | + return false | |
| 94 | + } | |
| 95 | + if (stageFilter && String(row.org.stage ?? '') !== stageFilter) { | |
| 96 | + return false | |
| 97 | + } | |
| 98 | + if (contactFilter === 'none' && row.contactCount > 0) { | |
| 99 | + return false | |
| 100 | + } | |
| 101 | + if (contactFilter === 'some' && row.contactCount === 0) { | |
| 102 | + return false | |
| 103 | + } | |
| 104 | + return true | |
| 105 | + }) | |
| 106 | + }, [rows, keyword, stageFilter, contactFilter]) | |
| 107 | + | |
| 108 | + async function openPanel(row: ProjectRow) { | |
| 109 | + setSelected(row) | |
| 110 | + setPanelError(null) | |
| 111 | + setEditingLogId(null) | |
| 112 | + setLogSummary('') | |
| 113 | + setLogDate(today()) | |
| 114 | + setPanelBusy(true) | |
| 115 | + try { | |
| 116 | + const [logList, reportList] = await Promise.all([ | |
| 117 | + getContactLogs(row.org.id), | |
| 118 | + getReports(row.org.id), | |
| 119 | + ]) | |
| 120 | + setLogs(logList) | |
| 121 | + setReports(reportList) | |
| 122 | + } catch (e) { | |
| 123 | + setPanelError((e as Error).message) | |
| 124 | + } finally { | |
| 125 | + setPanelBusy(false) | |
| 126 | + } | |
| 127 | + } | |
| 128 | + | |
| 129 | + async function submitLog() { | |
| 130 | + if (!selected || logSummary.trim() === '' || panelBusy) { | |
| 131 | + return | |
| 132 | + } | |
| 133 | + setPanelBusy(true) | |
| 134 | + setPanelError(null) | |
| 135 | + try { | |
| 136 | + const body = { contactedOn: logDate, method: logMethod, summary: logSummary.trim() } | |
| 137 | + const next = editingLogId | |
| 138 | + ? await updateContactLog(selected.org.id, editingLogId, body) | |
| 139 | + : await addContactLog(selected.org.id, body) | |
| 140 | + setLogs(next) | |
| 141 | + setLogSummary('') | |
| 142 | + setEditingLogId(null) | |
| 143 | + await reload() | |
| 144 | + } catch (e) { | |
| 145 | + setPanelError((e as Error).message) | |
| 146 | + } finally { | |
| 147 | + setPanelBusy(false) | |
| 148 | + } | |
| 149 | + } | |
| 150 | + | |
| 151 | + async function removeLog(log: ContactLog) { | |
| 152 | + if (!selected || !window.confirm('이 연락 이력을 삭제할까요?')) { | |
| 153 | + return | |
| 154 | + } | |
| 155 | + setPanelBusy(true) | |
| 156 | + try { | |
| 157 | + await deleteContactLog(selected.org.id, log.id) | |
| 158 | + setLogs((prev) => prev.filter((l) => l.id !== log.id)) | |
| 159 | + await reload() | |
| 160 | + } catch (e) { | |
| 161 | + setPanelError((e as Error).message) | |
| 162 | + } finally { | |
| 163 | + setPanelBusy(false) | |
| 164 | + } | |
| 165 | + } | |
| 166 | + | |
| 167 | + async function handleReportUpload(file: File) { | |
| 168 | + if (!selected) { | |
| 169 | + return | |
| 170 | + } | |
| 171 | + setPanelBusy(true) | |
| 172 | + setPanelError(null) | |
| 173 | + try { | |
| 174 | + setReports(await uploadReport(selected.org.id, file)) | |
| 175 | + await reload() | |
| 176 | + } catch (e) { | |
| 177 | + setPanelError((e as Error).message) | |
| 178 | + } finally { | |
| 179 | + setPanelBusy(false) | |
| 180 | + } | |
| 181 | + } | |
| 182 | + | |
| 183 | + async function removeReport(report: OrgReport) { | |
| 184 | + if (!selected || !window.confirm(`"${report.fileName}"을 삭제할까요?`)) { | |
| 185 | + return | |
| 186 | + } | |
| 187 | + setPanelBusy(true) | |
| 188 | + try { | |
| 189 | + await deleteReport(selected.org.id, report.id) | |
| 190 | + setReports((prev) => prev.filter((r) => r.id !== report.id)) | |
| 191 | + await reload() | |
| 192 | + } catch (e) { | |
| 193 | + setPanelError((e as Error).message) | |
| 194 | + } finally { | |
| 195 | + setPanelBusy(false) | |
| 196 | + } | |
| 197 | + } | |
| 198 | + | |
| 199 | + if (error && !rows) { | |
| 200 | + return ( | |
| 201 | + <div className="flex flex-col items-center gap-3 p-8 text-sm"> | |
| 202 | + <p className="text-red-600">{error}</p> | |
| 203 | + <button | |
| 204 | + type="button" | |
| 205 | + onClick={() => void reload()} | |
| 206 | + className="rounded-md border border-gray-300 px-3 py-1.5" | |
| 207 | + > | |
| 208 | + 다시 시도 | |
| 209 | + </button> | |
| 210 | + </div> | |
| 211 | + ) | |
| 212 | + } | |
| 213 | + | |
| 214 | + return ( | |
| 215 | + <div className="space-y-4 p-6"> | |
| 216 | + <section className="rounded-lg border border-gray-200 bg-white p-5"> | |
| 217 | + <h1 className="flex items-center gap-1.5 text-lg font-semibold"> | |
| 218 | + 사업관리 | |
| 219 | + <HelpButton topic="projects.overview" /> | |
| 220 | + </h1> | |
| 221 | + <p className="text-xs text-gray-500"> | |
| 222 | + 3단계 법률검토 대상 기관 현황과 신청 확인 연락 이력, 최종 보고서를 관리합니다. | |
| 223 | + </p> | |
| 224 | + | |
| 225 | + <div className="mt-4 flex flex-wrap gap-2"> | |
| 226 | + <input | |
| 227 | + aria-label="기관명/기관코드 검색" | |
| 228 | + placeholder="기관명/기관코드 검색" | |
| 229 | + value={keyword} | |
| 230 | + onChange={(e) => setKeyword(e.target.value)} | |
| 231 | + className="min-w-48 flex-1 rounded-md border border-gray-300 px-2 py-1.5 text-sm" | |
| 232 | + /> | |
| 233 | + <select | |
| 234 | + aria-label="현재 단계" | |
| 235 | + value={stageFilter} | |
| 236 | + onChange={(e) => setStageFilter(e.target.value)} | |
| 237 | + className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" | |
| 238 | + > | |
| 239 | + <option value="">현재 단계 전체</option> | |
| 240 | + {Array.from({ length: 12 }, (_, i) => i + 1).map((n) => ( | |
| 241 | + <option key={n} value={String(n)}>{`${n}. ${stageLabel(n)}`}</option> | |
| 242 | + ))} | |
| 243 | + </select> | |
| 244 | + <select | |
| 245 | + aria-label="연락 이력" | |
| 246 | + value={contactFilter} | |
| 247 | + onChange={(e) => setContactFilter(e.target.value)} | |
| 248 | + className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" | |
| 249 | + > | |
| 250 | + <option value="">연락 이력 전체</option> | |
| 251 | + <option value="some">이력 있음</option> | |
| 252 | + <option value="none">이력 없음</option> | |
| 253 | + </select> | |
| 254 | + </div> | |
| 255 | + | |
| 256 | + <div className="mt-3 overflow-x-auto"> | |
| 257 | + <table data-testid="project-table" className="w-full min-w-[1000px] text-sm"> | |
| 258 | + <thead> | |
| 259 | + <tr className="border-b border-gray-200 text-xs text-gray-500"> | |
| 260 | + <th className="px-2 py-2 text-left font-normal">연번</th> | |
| 261 | + <th className="px-2 py-2 text-left font-normal">기관명</th> | |
| 262 | + <th className="px-2 py-2 text-left font-normal">현재 단계</th> | |
| 263 | + <th className="px-2 py-2 text-left font-normal">문정원 담당</th> | |
| 264 | + <th className="px-2 py-2 text-left font-normal">담당 변호사</th> | |
| 265 | + <th className="px-2 py-2 text-right font-normal">권리확인</th> | |
| 266 | + <th className="px-2 py-2 text-right font-normal">권리처리</th> | |
| 267 | + <th className="px-2 py-2 text-right font-normal">최근 연락</th> | |
| 268 | + <th className="px-2 py-2 text-right font-normal">보고서</th> | |
| 269 | + <th className="px-2 py-2 text-right font-normal">관리</th> | |
| 270 | + </tr> | |
| 271 | + </thead> | |
| 272 | + <tbody> | |
| 273 | + {rows === null ? ( | |
| 274 | + <tr> | |
| 275 | + <td colSpan={10} className="px-2 py-6 text-center text-sm text-gray-500"> | |
| 276 | + 불러오는 중… | |
| 277 | + </td> | |
| 278 | + </tr> | |
| 279 | + ) : visible.length === 0 ? ( | |
| 280 | + <tr> | |
| 281 | + <td colSpan={10} className="px-2 py-6 text-center text-sm text-gray-500"> | |
| 282 | + 조건에 맞는 기관이 없습니다. | |
| 283 | + </td> | |
| 284 | + </tr> | |
| 285 | + ) : ( | |
| 286 | + visible.map((row) => ( | |
| 287 | + <tr key={row.org.id} className="border-b border-gray-100 hover:bg-gray-50"> | |
| 288 | + <td className="px-2 py-2 text-gray-500 tabular-nums">{row.org.orgNo}</td> | |
| 289 | + <td className="px-2 py-2"> | |
| 290 | + <button | |
| 291 | + type="button" | |
| 292 | + onClick={() => onOpenOrg(row.org.id)} | |
| 293 | + className="font-medium text-blue-700 hover:underline" | |
| 294 | + > | |
| 295 | + {row.org.orgName} | |
| 296 | + </button> | |
| 297 | + </td> | |
| 298 | + <td className="px-2 py-2 text-gray-600"> | |
| 299 | + {row.org.stage === null ? '단계 미지정' : `${row.org.stage}. ${stageLabel(row.org.stage)}`} | |
| 300 | + </td> | |
| 301 | + <td className="px-2 py-2 text-gray-600">{row.org.mjName ?? '-'}</td> | |
| 302 | + <td className="px-2 py-2 text-gray-600">{row.org.lawyerName ?? '-'}</td> | |
| 303 | + <td className="px-2 py-2 text-right tabular-nums"> | |
| 304 | + {row.org.reviewDone.toLocaleString()} / {row.org.reviewTotal.toLocaleString()} | |
| 305 | + </td> | |
| 306 | + <td className="px-2 py-2 text-right tabular-nums"> | |
| 307 | + {row.org.processDone.toLocaleString()} / {row.org.processTotal.toLocaleString()} | |
| 308 | + </td> | |
| 309 | + <td className="px-2 py-2 text-right text-gray-600 tabular-nums"> | |
| 310 | + {row.lastContactedOn ?? '-'} | |
| 311 | + {row.contactCount > 0 && ( | |
| 312 | + <span className="ml-1 text-xs text-gray-400">({row.contactCount})</span> | |
| 313 | + )} | |
| 314 | + </td> | |
| 315 | + <td className="px-2 py-2 text-right tabular-nums">{row.reportCount}</td> | |
| 316 | + <td className="px-2 py-2 text-right"> | |
| 317 | + <button | |
| 318 | + type="button" | |
| 319 | + onClick={() => void openPanel(row)} | |
| 320 | + className="rounded-md border border-gray-300 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50" | |
| 321 | + > | |
| 322 | + 연락·보고서 | |
| 323 | + </button> | |
| 324 | + </td> | |
| 325 | + </tr> | |
| 326 | + )) | |
| 327 | + )} | |
| 328 | + </tbody> | |
| 329 | + </table> | |
| 330 | + </div> | |
| 331 | + </section> | |
| 332 | + | |
| 333 | + {selected && ( | |
| 334 | + <div | |
| 335 | + role="dialog" | |
| 336 | + aria-label={`${selected.org.orgName} 연락·보고서`} | |
| 337 | + className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 p-6" | |
| 338 | + > | |
| 339 | + <div className="w-full max-w-3xl rounded-lg bg-white shadow-xl"> | |
| 340 | + <div className="flex items-start justify-between gap-3 border-b border-gray-200 px-5 py-4"> | |
| 341 | + <div> | |
| 342 | + <h2 className="text-base font-semibold"> | |
| 343 | + {selected.org.orgNo} {selected.org.orgName} | |
| 344 | + </h2> | |
| 345 | + <p className="mt-1 text-xs text-gray-500">신청 확인 연락 이력과 최종 보고서</p> | |
| 346 | + </div> | |
| 347 | + <button | |
| 348 | + type="button" | |
| 349 | + aria-label="닫기" | |
| 350 | + onClick={() => setSelected(null)} | |
| 351 | + className="shrink-0 rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50" | |
| 352 | + > | |
| 353 | + 닫기 | |
| 354 | + </button> | |
| 355 | + </div> | |
| 356 | + | |
| 357 | + <div className="space-y-6 px-5 py-4"> | |
| 358 | + {panelError && ( | |
| 359 | + <p className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{panelError}</p> | |
| 360 | + )} | |
| 361 | + | |
| 362 | + <section> | |
| 363 | + <h3 className="flex items-center gap-1.5 text-sm font-semibold"> | |
| 364 | + 신청 확인 연락 이력 | |
| 365 | + <HelpButton topic="projects.contactLog" /> | |
| 366 | + </h3> | |
| 367 | + | |
| 368 | + <div className="mt-2 flex flex-wrap items-end gap-2"> | |
| 369 | + <label className="flex flex-col gap-1 text-xs text-gray-500"> | |
| 370 | + 연락일 | |
| 371 | + <input | |
| 372 | + type="date" | |
| 373 | + aria-label="연락일" | |
| 374 | + value={logDate} | |
| 375 | + onChange={(e) => setLogDate(e.target.value)} | |
| 376 | + className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" | |
| 377 | + /> | |
| 378 | + </label> | |
| 379 | + <label className="flex flex-col gap-1 text-xs text-gray-500"> | |
| 380 | + 방법 | |
| 381 | + <select | |
| 382 | + aria-label="연락 방법" | |
| 383 | + value={logMethod} | |
| 384 | + onChange={(e) => setLogMethod(e.target.value)} | |
| 385 | + className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" | |
| 386 | + > | |
| 387 | + {METHODS.map((m) => ( | |
| 388 | + <option key={m} value={m}>{m}</option> | |
| 389 | + ))} | |
| 390 | + </select> | |
| 391 | + </label> | |
| 392 | + <label className="flex min-w-64 flex-1 flex-col gap-1 text-xs text-gray-500"> | |
| 393 | + 내용 | |
| 394 | + <input | |
| 395 | + type="text" | |
| 396 | + aria-label="연락 내용" | |
| 397 | + value={logSummary} | |
| 398 | + onChange={(e) => setLogSummary(e.target.value)} | |
| 399 | + className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" | |
| 400 | + /> | |
| 401 | + </label> | |
| 402 | + <button | |
| 403 | + type="button" | |
| 404 | + disabled={panelBusy || logSummary.trim() === ''} | |
| 405 | + onClick={() => void submitLog()} | |
| 406 | + className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white disabled:bg-gray-300" | |
| 407 | + > | |
| 408 | + {editingLogId ? '수정 저장' : '등록'} | |
| 409 | + </button> | |
| 410 | + {editingLogId && ( | |
| 411 | + <button | |
| 412 | + type="button" | |
| 413 | + onClick={() => { | |
| 414 | + setEditingLogId(null) | |
| 415 | + setLogSummary('') | |
| 416 | + }} | |
| 417 | + className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600" | |
| 418 | + > | |
| 419 | + 취소 | |
| 420 | + </button> | |
| 421 | + )} | |
| 422 | + </div> | |
| 423 | + <p className="mt-1 text-xs text-gray-400">작성자는 로그인한 사용자로 자동 기록됩니다.</p> | |
| 424 | + | |
| 425 | + <ul className="mt-3 space-y-2"> | |
| 426 | + {logs.length === 0 ? ( | |
| 427 | + <li className="text-sm text-gray-400">등록된 연락 이력이 없습니다.</li> | |
| 428 | + ) : ( | |
| 429 | + logs.map((log) => ( | |
| 430 | + <li key={log.id} className="rounded-md border border-gray-200 p-2.5"> | |
| 431 | + <div className="flex items-center justify-between gap-2"> | |
| 432 | + <span className="text-xs text-gray-500"> | |
| 433 | + {log.contactedOn} · {log.method} · {log.author} | |
| 434 | + </span> | |
| 435 | + <span className="flex gap-1"> | |
| 436 | + <button | |
| 437 | + type="button" | |
| 438 | + onClick={() => { | |
| 439 | + setEditingLogId(log.id) | |
| 440 | + setLogDate(log.contactedOn) | |
| 441 | + setLogMethod(log.method) | |
| 442 | + setLogSummary(log.summary) | |
| 443 | + }} | |
| 444 | + className="rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-50" | |
| 445 | + > | |
| 446 | + 수정 | |
| 447 | + </button> | |
| 448 | + <button | |
| 449 | + type="button" | |
| 450 | + onClick={() => void removeLog(log)} | |
| 451 | + className="rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-50" | |
| 452 | + > | |
| 453 | + 삭제 | |
| 454 | + </button> | |
| 455 | + </span> | |
| 456 | + </div> | |
| 457 | + <p className="mt-1 whitespace-pre-wrap text-sm text-gray-800">{log.summary}</p> | |
| 458 | + </li> | |
| 459 | + )) | |
| 460 | + )} | |
| 461 | + </ul> | |
| 462 | + </section> | |
| 463 | + | |
| 464 | + <section> | |
| 465 | + <h3 className="flex items-center gap-1.5 text-sm font-semibold"> | |
| 466 | + 최종 보고서 | |
| 467 | + <HelpButton topic="projects.report" /> | |
| 468 | + </h3> | |
| 469 | + | |
| 470 | + <label className="mt-2 inline-flex cursor-pointer items-center gap-2 rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"> | |
| 471 | + 보고서 업로드 | |
| 472 | + <input | |
| 473 | + type="file" | |
| 474 | + aria-label="보고서 파일" | |
| 475 | + className="hidden" | |
| 476 | + onChange={(e) => { | |
| 477 | + const file = e.target.files?.[0] | |
| 478 | + if (file) { | |
| 479 | + void handleReportUpload(file) | |
| 480 | + } | |
| 481 | + e.target.value = '' | |
| 482 | + }} | |
| 483 | + /> | |
| 484 | + </label> | |
| 485 | + | |
| 486 | + <ul className="mt-3 space-y-2"> | |
| 487 | + {reports.length === 0 ? ( | |
| 488 | + <li className="text-sm text-gray-400">업로드된 보고서가 없습니다.</li> | |
| 489 | + ) : ( | |
| 490 | + reports.map((report) => ( | |
| 491 | + <li | |
| 492 | + key={report.id} | |
| 493 | + className="flex items-center justify-between gap-2 rounded-md border border-gray-200 p-2.5" | |
| 494 | + > | |
| 495 | + <div className="min-w-0"> | |
| 496 | + <a | |
| 497 | + href={reportDownloadUrl(report.orgId, report.id)} | |
| 498 | + className="block truncate text-sm text-blue-700 hover:underline" | |
| 499 | + > | |
| 500 | + {report.fileName} | |
| 501 | + </a> | |
| 502 | + <span className="text-xs text-gray-400"> | |
| 503 | + {formatSize(report.byteSize)} · {report.uploadedBy} ·{' '} | |
| 504 | + {formatDateTime(report.uploadedAt)} | |
| 505 | + </span> | |
| 506 | + </div> | |
| 507 | + <button | |
| 508 | + type="button" | |
| 509 | + onClick={() => void removeReport(report)} | |
| 510 | + className="shrink-0 rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-50" | |
| 511 | + > | |
| 512 | + 삭제 | |
| 513 | + </button> | |
| 514 | + </li> | |
| 515 | + )) | |
| 516 | + )} | |
| 517 | + </ul> | |
| 518 | + </section> | |
| 519 | + </div> | |
| 520 | + </div> | |
| 521 | + </div> | |
| 522 | + )} | |
| 523 | + </div> | |
| 524 | + ) | |
| 525 | +} |
--- frontend/src/components/ReviewBoard.test.tsx
+++ frontend/src/components/ReviewBoard.test.tsx
... | ... | @@ -1,4 +1,4 @@ |
| 1 |
-import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
| 1 |
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
| 2 | 2 |
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
| 3 | 3 |
import ReviewBoard from './ReviewBoard' |
| 4 | 4 |
import type { Org, ReviewItem, ReviewPage } from '../api/client'
|
... | ... | @@ -9,6 +9,8 @@ |
| 9 | 9 |
importReview: vi.fn(), |
| 10 | 10 |
updateReviewJudgment: vi.fn(), |
| 11 | 11 |
deleteReviewItem: vi.fn(), |
| 12 |
+ bulkUpdateReview: vi.fn(), |
|
| 13 |
+ bulkDeleteReview: vi.fn(), |
|
| 12 | 14 |
})) |
| 13 | 15 |
|
| 14 | 16 |
vi.mock('../api/client', async (importOriginal) => ({
|
... | ... | @@ -18,6 +20,8 @@ |
| 18 | 20 |
importReview: mocks.importReview, |
| 19 | 21 |
updateReviewJudgment: mocks.updateReviewJudgment, |
| 20 | 22 |
deleteReviewItem: mocks.deleteReviewItem, |
| 23 |
+ bulkUpdateReview: mocks.bulkUpdateReview, |
|
| 24 |
+ bulkDeleteReview: mocks.bulkDeleteReview, |
|
| 21 | 25 |
})) |
| 22 | 26 |
|
| 23 | 27 |
function org(overrides: Partial<Org> = {}): Org {
|
... | ... | @@ -90,6 +94,8 @@ |
| 90 | 94 |
mocks.importReview.mockReset() |
| 91 | 95 |
mocks.updateReviewJudgment.mockReset() |
| 92 | 96 |
mocks.deleteReviewItem.mockReset() |
| 97 |
+ mocks.bulkUpdateReview.mockReset() |
|
| 98 |
+ mocks.bulkDeleteReview.mockReset() |
|
| 93 | 99 |
}) |
| 94 | 100 |
|
| 95 | 101 |
describe('ReviewBoard 목록', () => {
|
... | ... | @@ -100,8 +106,10 @@ |
| 100 | 106 |
|
| 101 | 107 |
await waitFor(() => {
|
| 102 | 108 |
expect(screen.getByText('2026년 사업 공고')).toBeTruthy()
|
| 103 |
- expect(screen.getByText('신유형 개방')).toBeTruthy()
|
|
| 104 | 109 |
}) |
| 110 |
+ // '신유형 개방'은 일괄 등록 셀렉트의 옵션에도 있으므로 표 안에서만 찾는다 |
|
| 111 |
+ const table = within(screen.getByRole('table'))
|
|
| 112 |
+ expect(table.getByText('신유형 개방')).toBeTruthy()
|
|
| 105 | 113 |
expect(screen.getByText('총 1건 · 처리 0건')).toBeTruthy()
|
| 106 | 114 |
}) |
| 107 | 115 |
|
... | ... | @@ -154,21 +162,99 @@ |
| 154 | 162 |
}) |
| 155 | 163 |
}) |
| 156 | 164 |
|
| 157 |
- it('삭제를 누르면 확인 후 deleteReviewItem을 호출한다', async () => {
|
|
| 165 |
+ it('행을 체크하고 [선택 삭제]를 누르면 확인 후 일괄 삭제한다', async () => {
|
|
| 158 | 166 |
mocks.getReviewPage.mockResolvedValue(page()) |
| 159 |
- mocks.deleteReviewItem.mockResolvedValue(undefined) |
|
| 167 |
+ mocks.bulkDeleteReview.mockResolvedValue({ deleted: 1 })
|
|
| 160 | 168 |
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) |
| 161 | 169 |
|
| 162 | 170 |
render(<ReviewBoard org={org()} />)
|
| 163 | 171 |
|
| 164 | 172 |
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
|
| 165 | 173 |
|
| 166 |
- fireEvent.click(screen.getByRole('button', { name: '삭제' }))
|
|
| 174 |
+ // 개별 행에는 삭제 버튼이 없다(상단 일괄 삭제로 옮겼다) |
|
| 175 |
+ expect(screen.getByRole('button', { name: '선택 삭제' })).toBeDisabled()
|
|
| 176 |
+ |
|
| 177 |
+ fireEvent.click(screen.getByLabelText('1번 선택'))
|
|
| 178 |
+ fireEvent.click(screen.getByRole('button', { name: '선택 삭제' }))
|
|
| 167 | 179 |
|
| 168 | 180 |
expect(confirmSpy).toHaveBeenCalled() |
| 169 |
- await waitFor(() => expect(mocks.deleteReviewItem).toHaveBeenCalledWith(1, 1)) |
|
| 181 |
+ await waitFor(() => expect(mocks.bulkDeleteReview).toHaveBeenCalledWith(1, [1])) |
|
| 170 | 182 |
|
| 171 | 183 |
confirmSpy.mockRestore() |
| 184 |
+ }) |
|
| 185 |
+ |
|
| 186 |
+ it('전체 선택 체크박스로 페이지의 모든 행을 고른다', async () => {
|
|
| 187 |
+ mocks.getReviewPage.mockResolvedValue(page()) |
|
| 188 |
+ |
|
| 189 |
+ render(<ReviewBoard org={org()} />)
|
|
| 190 |
+ await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
|
|
| 191 |
+ |
|
| 192 |
+ fireEvent.click(screen.getByLabelText('전체 선택'))
|
|
| 193 |
+ expect(screen.getByText('선택 1건')).toBeTruthy()
|
|
| 194 |
+ expect(screen.getByLabelText('1번 선택')).toBeChecked()
|
|
| 195 |
+ |
|
| 196 |
+ fireEvent.click(screen.getByLabelText('전체 선택'))
|
|
| 197 |
+ expect(screen.getByText('선택 0건')).toBeTruthy()
|
|
| 198 |
+ }) |
|
| 199 |
+ |
|
| 200 |
+ it('체크한 건에 유형과 의견을 일괄 반영한다', async () => {
|
|
| 201 |
+ mocks.getReviewPage.mockResolvedValue(page()) |
|
| 202 |
+ mocks.bulkUpdateReview.mockResolvedValue({ updated: 1 })
|
|
| 203 |
+ |
|
| 204 |
+ render(<ReviewBoard org={org()} />)
|
|
| 205 |
+ await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
|
|
| 206 |
+ |
|
| 207 |
+ fireEvent.click(screen.getByLabelText('1번 선택'))
|
|
| 208 |
+ fireEvent.change(screen.getByLabelText('일괄 공공누리 유형'), { target: { value: '0유형' } })
|
|
| 209 |
+ fireEvent.change(screen.getByLabelText('일괄 의견'), { target: { value: '일괄 검토 완료' } })
|
|
| 210 |
+ fireEvent.click(screen.getByRole('button', { name: '일괄 등록' }))
|
|
| 211 |
+ |
|
| 212 |
+ await waitFor(() => |
|
| 213 |
+ expect(mocks.bulkUpdateReview).toHaveBeenCalledWith(1, {
|
|
| 214 |
+ ids: [1], |
|
| 215 |
+ judgedKoglType: '0유형', |
|
| 216 |
+ opinion: '일괄 검토 완료', |
|
| 217 |
+ reviewResult: undefined, |
|
| 218 |
+ }), |
|
| 219 |
+ ) |
|
| 220 |
+ expect(await screen.findByText('1건에 반영했습니다.')).toBeTruthy()
|
|
| 221 |
+ }) |
|
| 222 |
+ |
|
| 223 |
+ it('아무 값도 채우지 않고 일괄 등록하면 안내만 하고 호출하지 않는다', async () => {
|
|
| 224 |
+ mocks.getReviewPage.mockResolvedValue(page()) |
|
| 225 |
+ |
|
| 226 |
+ render(<ReviewBoard org={org()} />)
|
|
| 227 |
+ await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
|
|
| 228 |
+ |
|
| 229 |
+ fireEvent.click(screen.getByLabelText('1번 선택'))
|
|
| 230 |
+ fireEvent.click(screen.getByRole('button', { name: '일괄 등록' }))
|
|
| 231 |
+ |
|
| 232 |
+ expect(await screen.findByText('반영할 값을 하나 이상 채워주세요.')).toBeTruthy()
|
|
| 233 |
+ expect(mocks.bulkUpdateReview).not.toHaveBeenCalled() |
|
| 234 |
+ }) |
|
| 235 |
+ |
|
| 236 |
+ it('상세검색 3칸은 열별 조건으로 함께 넘어간다', async () => {
|
|
| 237 |
+ mocks.getReviewPage.mockResolvedValue(page()) |
|
| 238 |
+ |
|
| 239 |
+ render(<ReviewBoard org={org()} />)
|
|
| 240 |
+ await waitFor(() => expect(mocks.getReviewPage).toHaveBeenCalled()) |
|
| 241 |
+ |
|
| 242 |
+ fireEvent.click(screen.getByRole('button', { name: '상세검색' }))
|
|
| 243 |
+ fireEvent.change(screen.getByLabelText('사이트명'), { target: { value: '누리집' } })
|
|
| 244 |
+ fireEvent.change(screen.getByLabelText('게시판명'), { target: { value: '공고' } })
|
|
| 245 |
+ fireEvent.change(screen.getByLabelText('게시물 제목'), { target: { value: '사업' } })
|
|
| 246 |
+ fireEvent.click(screen.getByRole('button', { name: '검색' }))
|
|
| 247 |
+ |
|
| 248 |
+ await waitFor(() => |
|
| 249 |
+ expect(mocks.getReviewPage).toHaveBeenLastCalledWith(1, {
|
|
| 250 |
+ keyword: undefined, |
|
| 251 |
+ site: '누리집', |
|
| 252 |
+ board: '공고', |
|
| 253 |
+ title: '사업', |
|
| 254 |
+ page: 0, |
|
| 255 |
+ size: 30, |
|
| 256 |
+ }), |
|
| 257 |
+ ) |
|
| 172 | 258 |
}) |
| 173 | 259 |
|
| 174 | 260 |
it('다음 페이지 버튼을 누르면 page+1로 다시 조회한다', async () => {
|
... | ... | @@ -229,6 +315,86 @@ |
| 229 | 315 |
expect(screen.getByRole('radio', { name: '권리처리 추진' })).toBeChecked()
|
| 230 | 316 |
}) |
| 231 | 317 |
|
| 318 |
+ describe('제3자 권리처리 정책(2026-07-20 변경)', () => {
|
|
| 319 |
+ /** 상세 편집 화면을 열고 제3자 권리 + 지정한 세부항목까지 고른다. */ |
|
| 320 |
+ async function openDetailWithMinor(minor: string, itemOverrides = {}) {
|
|
| 321 |
+ mocks.getReviewPage.mockResolvedValue(page()) |
|
| 322 |
+ mocks.getReviewItem.mockResolvedValue( |
|
| 323 |
+ item({ reviewMajor: null, reviewMinor: null, reviewResult: null, judgedKoglType: null, ...itemOverrides }),
|
|
| 324 |
+ ) |
|
| 325 |
+ render(<ReviewBoard org={org()} />)
|
|
| 326 |
+ await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
|
|
| 327 |
+ fireEvent.click(screen.getByRole('button', { name: '수정' }))
|
|
| 328 |
+ await waitFor(() => expect(mocks.getReviewItem).toHaveBeenCalled()) |
|
| 329 |
+ |
|
| 330 |
+ fireEvent.click(screen.getByRole('radio', { name: '제3자 권리' }))
|
|
| 331 |
+ fireEvent.click(screen.getByRole('radio', { name: minor }))
|
|
| 332 |
+ } |
|
| 333 |
+ |
|
| 334 |
+ it('권리 일부 보유를 고르면 기본값이 권리처리 추진이고 공공누리 유형은 보류가 된다', async () => {
|
|
| 335 |
+ await openDetailWithMinor('3. 권리 일부(공동) 보유')
|
|
| 336 |
+ |
|
| 337 |
+ expect(screen.getByRole('radio', { name: '권리처리 추진' })).toBeChecked()
|
|
| 338 |
+ expect(screen.getByRole('radio', { name: '공공누리 유형 보류' })).toBeChecked()
|
|
| 339 |
+ }) |
|
| 340 |
+ |
|
| 341 |
+ it('미희망을 고르면 기존 공공누리 유형을 그대로 쓰고 비고에 사유를 남긴다', async () => {
|
|
| 342 |
+ await openDetailWithMinor('4. 권리 미보유', { koglType: '2유형' })
|
|
| 343 |
+ |
|
| 344 |
+ fireEvent.click(screen.getByRole('radio', { name: '권리처리 추진 미희망' }))
|
|
| 345 |
+ |
|
| 346 |
+ expect(screen.getByRole('radio', { name: '공공누리 유형 2유형' })).toBeChecked()
|
|
| 347 |
+ expect((screen.getByLabelText('비고') as HTMLTextAreaElement).value).toContain(
|
|
| 348 |
+ '권리처리 추진 미희망에 따른 기존 공공누리 부착', |
|
| 349 |
+ ) |
|
| 350 |
+ }) |
|
| 351 |
+ |
|
| 352 |
+ it('미희망인데 기존 공공누리 유형이 없으면 유형을 비워 둔다', async () => {
|
|
| 353 |
+ await openDetailWithMinor('4. 권리 미보유', { koglType: null })
|
|
| 354 |
+ |
|
| 355 |
+ fireEvent.click(screen.getByRole('radio', { name: '권리처리 추진 미희망' }))
|
|
| 356 |
+ |
|
| 357 |
+ for (const option of ['0유형', '1유형', '2유형', '3유형', '4유형', '보류', '개방불가']) {
|
|
| 358 |
+ expect(screen.getByRole('radio', { name: `공공누리 유형 ${option}` })).not.toBeChecked()
|
|
| 359 |
+ } |
|
| 360 |
+ expect((screen.getByLabelText('비고') as HTMLTextAreaElement).value).toBe('')
|
|
| 361 |
+ }) |
|
| 362 |
+ |
|
| 363 |
+ it('3·4번에서는 신유형 개방·개방불가를 고를 수 없다', async () => {
|
|
| 364 |
+ await openDetailWithMinor('3. 권리 일부(공동) 보유')
|
|
| 365 |
+ |
|
| 366 |
+ expect(screen.getByRole('radio', { name: '신유형 개방' })).toBeDisabled()
|
|
| 367 |
+ expect(screen.getByRole('radio', { name: '계약서 등 재확인' })).toBeDisabled()
|
|
| 368 |
+ expect(screen.getByRole('radio', { name: '개방불가' })).toBeDisabled()
|
|
| 369 |
+ expect(screen.getByRole('radio', { name: '권리처리 추진 미희망' })).toBeEnabled()
|
|
| 370 |
+ // 공공누리 유형의 개방불가는 처리결과와 별개로 남아 있다 |
|
| 371 |
+ expect(screen.getByRole('radio', { name: '공공누리 유형 개방불가' })).toBeEnabled()
|
|
| 372 |
+ }) |
|
| 373 |
+ |
|
| 374 |
+ it('초상권 포함은 개방불가까지 3택이다', async () => {
|
|
| 375 |
+ await openDetailWithMinor('초상권 포함')
|
|
| 376 |
+ |
|
| 377 |
+ expect(screen.getByRole('radio', { name: '개방불가' })).toBeEnabled()
|
|
| 378 |
+ expect(screen.getByRole('radio', { name: '신유형 개방' })).toBeDisabled()
|
|
| 379 |
+ expect(screen.getByText('개방불가도 선택 가능')).toBeTruthy()
|
|
| 380 |
+ |
|
| 381 |
+ fireEvent.click(screen.getByRole('radio', { name: '개방불가' }))
|
|
| 382 |
+ expect(screen.getByRole('radio', { name: '개방불가' })).toBeChecked()
|
|
| 383 |
+ expect(screen.getByRole('radio', { name: '공공누리 유형 개방불가' })).toBeChecked()
|
|
| 384 |
+ }) |
|
| 385 |
+ |
|
| 386 |
+ it('비고 문구는 여러 번 눌러도 한 번만 붙는다', async () => {
|
|
| 387 |
+ await openDetailWithMinor('4. 권리 미보유', { koglType: '1유형' })
|
|
| 388 |
+ |
|
| 389 |
+ fireEvent.click(screen.getByRole('radio', { name: '권리처리 추진 미희망' }))
|
|
| 390 |
+ fireEvent.click(screen.getByRole('radio', { name: '권리처리 추진' }))
|
|
| 391 |
+ fireEvent.click(screen.getByRole('radio', { name: '권리처리 추진 미희망' }))
|
|
| 392 |
+ |
|
| 393 |
+ const note = (screen.getByLabelText('비고') as HTMLTextAreaElement).value
|
|
| 394 |
+ expect(note.match(/권리처리 추진 미희망에 따른/g)).toHaveLength(1) |
|
| 395 |
+ }) |
|
| 396 |
+ }) |
|
| 397 |
+ |
|
| 232 | 398 |
it('수정을 저장하면 폼 값으로 updateReviewJudgment를 호출하고 목록으로 돌아간다', async () => {
|
| 233 | 399 |
mocks.getReviewPage.mockResolvedValue(page()) |
| 234 | 400 |
mocks.getReviewItem.mockResolvedValue(item({ reviewMajor: null, reviewResult: null }))
|
--- frontend/src/components/ReviewBoard.tsx
+++ frontend/src/components/ReviewBoard.tsx
... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 |
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
| 2 | 2 |
import {
|
| 3 |
- deleteReviewItem, |
|
| 3 |
+ bulkDeleteReview, |
|
| 4 |
+ bulkUpdateReview, |
|
| 4 | 5 |
getReviewItem, |
| 5 | 6 |
getReviewPage, |
| 6 | 7 |
importReview, |
... | ... | @@ -24,14 +25,48 @@ |
| 24 | 25 |
'초상권 포함', |
| 25 | 26 |
] |
| 26 | 27 |
|
| 27 |
-const REVIEW_RESULT_OPTIONS = ['신유형 개방', '계약서 등 재확인', '권리처리 추진', '개방불가'] |
|
| 28 |
+/** |
|
| 29 |
+ * 처리결과 선택지. 앞 4개의 순서는 판정 매트릭스의 행 배치와 맞물려 있으므로 바꾸지 말고, |
|
| 30 |
+ * 새 항목은 뒤에 붙인다. '권리처리 추진 미희망'은 제3자 권리 정책변경으로 추가된 항목이다. |
|
| 31 |
+ */ |
|
| 32 |
+const REVIEW_RESULT_OPTIONS = [ |
|
| 33 |
+ '신유형 개방', |
|
| 34 |
+ '계약서 등 재확인', |
|
| 35 |
+ '권리처리 추진', |
|
| 36 |
+ '개방불가', |
|
| 37 |
+ '권리처리 추진 미희망', |
|
| 38 |
+] |
|
| 28 | 39 |
|
| 29 |
-const JUDGED_KOGL_TYPE_OPTIONS = ['0유형', '1유형', '2유형', '3유형', '4유형', '개방불가'] |
|
| 40 |
+const RESULT_PROCESSING = '권리처리 추진' |
|
| 41 |
+const RESULT_NO_PROCESSING = '권리처리 추진 미희망' |
|
| 42 |
+const RESULT_BLOCKED = '개방불가' |
|
| 43 |
+ |
|
| 44 |
+/** 권리처리를 추진하는 동안에는 공공누리 유형을 확정하지 않고 '보류'로 둔다(정책변경으로 신설). */ |
|
| 45 |
+const KOGL_ON_HOLD = '보류' |
|
| 46 |
+ |
|
| 47 |
+/** 권리처리를 추진하지 않아 기존 공공누리 유형을 그대로 쓸 때 비고에 남기는 문구. */ |
|
| 48 |
+const NOTE_NO_PROCESSING = '권리처리 추진 미희망에 따른 기존 공공누리 부착' |
|
| 49 |
+ |
|
| 50 |
+const JUDGED_KOGL_TYPE_OPTIONS = ['0유형', '1유형', '2유형', '3유형', '4유형', '개방불가', KOGL_ON_HOLD] |
|
| 51 |
+ |
|
| 52 |
+/** 공공누리 유형 라디오 첫 줄에 놓는 항목(나머지는 둘째 줄). */ |
|
| 53 |
+const KOGL_FIRST_ROW = ['0유형', RESULT_BLOCKED, KOGL_ON_HOLD] |
|
| 54 |
+ |
|
| 55 |
+/** |
|
| 56 |
+ * 제3자 권리 세부항목별로 고를 수 있는 처리결과. 정책변경(2026-07-20 검토안)에 따라 |
|
| 57 |
+ * 3·4번은 추진/미희망 택일, 초상권은 개방불가까지 3택이다. 여기 없는 세부항목은 제한이 없다. |
|
| 58 |
+ */ |
|
| 59 |
+const ALLOWED_RESULTS_BY_MINOR: Record<string, string[]> = {
|
|
| 60 |
+ '3. 권리 일부(공동) 보유': [RESULT_PROCESSING, RESULT_NO_PROCESSING], |
|
| 61 |
+ '4. 권리 미보유': [RESULT_PROCESSING, RESULT_NO_PROCESSING], |
|
| 62 |
+ '초상권 포함': [RESULT_PROCESSING, RESULT_NO_PROCESSING, RESULT_BLOCKED], |
|
| 63 |
+} |
|
| 30 | 64 |
|
| 31 | 65 |
const REVIEW_RESULT_BADGE_STYLES: Record<string, string> = {
|
| 32 | 66 |
'신유형 개방': 'bg-emerald-50 text-emerald-700', |
| 33 | 67 |
'계약서 등 재확인': 'bg-amber-50 text-amber-700', |
| 34 | 68 |
'권리처리 추진': 'bg-blue-50 text-blue-700', |
| 69 |
+ '권리처리 추진 미희망': 'bg-slate-100 text-slate-700', |
|
| 35 | 70 |
개방불가: 'bg-red-50 text-red-700', |
| 36 | 71 |
} |
| 37 | 72 |
|
... | ... | @@ -50,8 +85,9 @@ |
| 50 | 85 |
if (minor === '2. 후천적 권리 전부 보유(계약서 확인 필요)') {
|
| 51 | 86 |
return '계약서 등 재확인' |
| 52 | 87 |
} |
| 53 |
- if (minor === '3. 권리 일부(공동) 보유' || minor === '4. 권리 미보유' || minor === '초상권 포함') {
|
|
| 54 |
- return '권리처리 추진' |
|
| 88 |
+ if (ALLOWED_RESULTS_BY_MINOR[minor]) {
|
|
| 89 |
+ // 정책변경: 추진/미희망(초상권은 개방불가까지) 중 택일이며 기본값은 '권리처리 추진'이다. |
|
| 90 |
+ return RESULT_PROCESSING |
|
| 55 | 91 |
} |
| 56 | 92 |
} |
| 57 | 93 |
return null |
... | ... | @@ -145,6 +181,7 @@ |
| 145 | 181 |
checked, |
| 146 | 182 |
onChange, |
| 147 | 183 |
bold, |
| 184 |
+ disabled, |
|
| 148 | 185 |
}: {
|
| 149 | 186 |
name: string |
| 150 | 187 |
value: string |
... | ... | @@ -153,11 +190,20 @@ |
| 153 | 190 |
checked: boolean |
| 154 | 191 |
onChange: () => void |
| 155 | 192 |
bold?: boolean |
| 193 |
+ /** 그 세부항목에서 고를 수 없는 처리결과를 잠근다(정책상 택일 범위 밖). */ |
|
| 194 |
+ disabled?: boolean |
|
| 156 | 195 |
}) {
|
| 157 | 196 |
return ( |
| 158 |
- <label className="flex cursor-pointer flex-col gap-0.5"> |
|
| 197 |
+ <label className={`flex flex-col gap-0.5 ${disabled ? 'cursor-not-allowed opacity-40' : 'cursor-pointer'}`}>
|
|
| 159 | 198 |
<span className="flex items-center gap-1.5"> |
| 160 |
- <input type="radio" name={name} aria-label={value} checked={checked} onChange={onChange} />
|
|
| 199 |
+ <input |
|
| 200 |
+ type="radio" |
|
| 201 |
+ name={name}
|
|
| 202 |
+ aria-label={value}
|
|
| 203 |
+ checked={checked}
|
|
| 204 |
+ disabled={disabled}
|
|
| 205 |
+ onChange={onChange}
|
|
| 206 |
+ /> |
|
| 161 | 207 |
<span className={bold ? 'font-semibold text-gray-900' : 'text-gray-800'}>{label ?? value}</span>
|
| 162 | 208 |
</span> |
| 163 | 209 |
{note && <span className="pl-5 text-[11px] text-gray-400">{note}</span>}
|
... | ... | @@ -190,6 +236,23 @@ |
| 190 | 236 |
const [page, setPage] = useState(0) |
| 191 | 237 |
const [keywordInput, setKeywordInput] = useState('')
|
| 192 | 238 |
const [appliedKeyword, setAppliedKeyword] = useState('')
|
| 239 |
+ /** 복합검색(열별 조건). 서로 AND로 겹친다. */ |
|
| 240 |
+ const [advancedOpen, setAdvancedOpen] = useState(false) |
|
| 241 |
+ const [siteInput, setSiteInput] = useState('')
|
|
| 242 |
+ const [boardInput, setBoardInput] = useState('')
|
|
| 243 |
+ const [titleInput, setTitleInput] = useState('')
|
|
| 244 |
+ const [applied, setApplied] = useState<{ site: string; board: string; title: string }>({
|
|
| 245 |
+ site: '', |
|
| 246 |
+ board: '', |
|
| 247 |
+ title: '', |
|
| 248 |
+ }) |
|
| 249 |
+ /** 일괄 등록/삭제 대상. 체크한 행의 id. */ |
|
| 250 |
+ const [selected, setSelected] = useState<number[]>([]) |
|
| 251 |
+ const [bulkResult, setBulkResult] = useState('')
|
|
| 252 |
+ const [bulkKoglType, setBulkKoglType] = useState('')
|
|
| 253 |
+ const [bulkOpinion, setBulkOpinion] = useState('')
|
|
| 254 |
+ const [bulkBusy, setBulkBusy] = useState(false) |
|
| 255 |
+ const [bulkMessage, setBulkMessage] = useState<string | null>(null) |
|
| 193 | 256 |
const [loading, setLoading] = useState(false) |
| 194 | 257 |
const [error, setError] = useState<string | null>(null) |
| 195 | 258 |
const [reloadKey, setReloadKey] = useState(0) |
... | ... | @@ -207,7 +270,14 @@ |
| 207 | 270 |
setLoading(true) |
| 208 | 271 |
setError(null) |
| 209 | 272 |
|
| 210 |
- getReviewPage(org.id, { keyword: appliedKeyword || undefined, page, size: PAGE_SIZE })
|
|
| 273 |
+ getReviewPage(org.id, {
|
|
| 274 |
+ keyword: appliedKeyword || undefined, |
|
| 275 |
+ site: applied.site || undefined, |
|
| 276 |
+ board: applied.board || undefined, |
|
| 277 |
+ title: applied.title || undefined, |
|
| 278 |
+ page, |
|
| 279 |
+ size: PAGE_SIZE, |
|
| 280 |
+ }) |
|
| 211 | 281 |
.then((data) => {
|
| 212 | 282 |
if (cancelled) {
|
| 213 | 283 |
return |
... | ... | @@ -231,17 +301,84 @@ |
| 231 | 301 |
cancelled = true |
| 232 | 302 |
} |
| 233 | 303 |
// eslint-disable-next-line react-hooks/exhaustive-deps |
| 234 |
- }, [mode, org.id, appliedKeyword, page, reloadKey]) |
|
| 304 |
+ }, [mode, org.id, appliedKeyword, applied, page, reloadKey]) |
|
| 235 | 305 |
|
| 236 | 306 |
function handleSearch() {
|
| 237 | 307 |
setPage(0) |
| 238 | 308 |
setAppliedKeyword(keywordInput.trim()) |
| 309 |
+ setApplied({ site: siteInput.trim(), board: boardInput.trim(), title: titleInput.trim() })
|
|
| 239 | 310 |
} |
| 240 | 311 |
|
| 241 | 312 |
function handleResetSearch() {
|
| 242 | 313 |
setKeywordInput('')
|
| 314 |
+ setSiteInput('')
|
|
| 315 |
+ setBoardInput('')
|
|
| 316 |
+ setTitleInput('')
|
|
| 243 | 317 |
setPage(0) |
| 244 | 318 |
setAppliedKeyword('')
|
| 319 |
+ setApplied({ site: '', board: '', title: '' })
|
|
| 320 |
+ } |
|
| 321 |
+ |
|
| 322 |
+ const allChecked = items.length > 0 && items.every((item) => selected.includes(item.id)) |
|
| 323 |
+ |
|
| 324 |
+ function toggleAll() {
|
|
| 325 |
+ setSelected(allChecked ? [] : items.map((item) => item.id)) |
|
| 326 |
+ } |
|
| 327 |
+ |
|
| 328 |
+ function toggleOne(id: number) {
|
|
| 329 |
+ setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])) |
|
| 330 |
+ } |
|
| 331 |
+ |
|
| 332 |
+ /** 체크한 건들에 채운 항목만 한 번에 반영한다. */ |
|
| 333 |
+ async function handleBulkApply() {
|
|
| 334 |
+ if (selected.length === 0 || bulkBusy) {
|
|
| 335 |
+ return |
|
| 336 |
+ } |
|
| 337 |
+ if (!bulkResult && !bulkKoglType && !bulkOpinion.trim()) {
|
|
| 338 |
+ setBulkMessage('반영할 값을 하나 이상 채워주세요.')
|
|
| 339 |
+ return |
|
| 340 |
+ } |
|
| 341 |
+ setBulkBusy(true) |
|
| 342 |
+ setBulkMessage(null) |
|
| 343 |
+ try {
|
|
| 344 |
+ const res = await bulkUpdateReview(org.id, {
|
|
| 345 |
+ ids: selected, |
|
| 346 |
+ reviewResult: bulkResult || undefined, |
|
| 347 |
+ judgedKoglType: bulkKoglType || undefined, |
|
| 348 |
+ opinion: bulkOpinion.trim() || undefined, |
|
| 349 |
+ }) |
|
| 350 |
+ setBulkMessage(`${res.updated}건에 반영했습니다.`)
|
|
| 351 |
+ setSelected([]) |
|
| 352 |
+ setBulkResult('')
|
|
| 353 |
+ setBulkKoglType('')
|
|
| 354 |
+ setBulkOpinion('')
|
|
| 355 |
+ setReloadKey((k) => k + 1) |
|
| 356 |
+ } catch (e) {
|
|
| 357 |
+ setBulkMessage(e instanceof Error ? e.message : '일괄 등록에 실패했습니다.') |
|
| 358 |
+ } finally {
|
|
| 359 |
+ setBulkBusy(false) |
|
| 360 |
+ } |
|
| 361 |
+ } |
|
| 362 |
+ |
|
| 363 |
+ async function handleBulkDelete() {
|
|
| 364 |
+ if (selected.length === 0 || bulkBusy) {
|
|
| 365 |
+ return |
|
| 366 |
+ } |
|
| 367 |
+ if (!window.confirm(`선택한 ${selected.length}건을 삭제할까요?`)) {
|
|
| 368 |
+ return |
|
| 369 |
+ } |
|
| 370 |
+ setBulkBusy(true) |
|
| 371 |
+ setBulkMessage(null) |
|
| 372 |
+ try {
|
|
| 373 |
+ const res = await bulkDeleteReview(org.id, selected) |
|
| 374 |
+ setBulkMessage(`${res.deleted}건을 삭제했습니다.`)
|
|
| 375 |
+ setSelected([]) |
|
| 376 |
+ setReloadKey((k) => k + 1) |
|
| 377 |
+ } catch (e) {
|
|
| 378 |
+ setBulkMessage(e instanceof Error ? e.message : '삭제에 실패했습니다.') |
|
| 379 |
+ } finally {
|
|
| 380 |
+ setBulkBusy(false) |
|
| 381 |
+ } |
|
| 245 | 382 |
} |
| 246 | 383 |
|
| 247 | 384 |
async function handleImportFile(file: File) {
|
... | ... | @@ -255,18 +392,6 @@ |
| 255 | 392 |
setImportError(e instanceof Error ? e.message : '엑셀 업로드에 실패했습니다.') |
| 256 | 393 |
} finally {
|
| 257 | 394 |
setImportBusy(false) |
| 258 |
- } |
|
| 259 |
- } |
|
| 260 |
- |
|
| 261 |
- async function handleDelete(item: ReviewItem) {
|
|
| 262 |
- if (!window.confirm(`"${item.postTitle ?? item.seq}" 게시물을 삭제할까요?`)) {
|
|
| 263 |
- return |
|
| 264 |
- } |
|
| 265 |
- try {
|
|
| 266 |
- await deleteReviewItem(org.id, item.id) |
|
| 267 |
- setReloadKey((k) => k + 1) |
|
| 268 |
- } catch (e) {
|
|
| 269 |
- setError(e instanceof Error ? e.message : '삭제에 실패했습니다.') |
|
| 270 | 395 |
} |
| 271 | 396 |
} |
| 272 | 397 |
|
... | ... | @@ -300,9 +425,17 @@ |
| 300 | 425 |
handleSearch() |
| 301 | 426 |
} |
| 302 | 427 |
}} |
| 303 |
- placeholder="사이트명·게시판명·제목 검색" |
|
| 428 |
+ placeholder="사이트명·게시판명·제목 통합검색" |
|
| 304 | 429 |
className="w-64 rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
| 305 | 430 |
/> |
| 431 |
+ <button |
|
| 432 |
+ type="button" |
|
| 433 |
+ aria-expanded={advancedOpen}
|
|
| 434 |
+ onClick={() => setAdvancedOpen((v) => !v)}
|
|
| 435 |
+ className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50" |
|
| 436 |
+ > |
|
| 437 |
+ 상세검색 |
|
| 438 |
+ </button> |
|
| 306 | 439 |
<button |
| 307 | 440 |
type="button" |
| 308 | 441 |
onClick={handleSearch}
|
... | ... | @@ -357,9 +490,90 @@ |
| 357 | 490 |
)} |
| 358 | 491 |
{importError && <p className="mt-3 text-sm text-red-600">{importError}</p>}
|
| 359 | 492 |
|
| 493 |
+ {advancedOpen && (
|
|
| 494 |
+ <div className="mt-3 flex flex-wrap items-end gap-2 rounded-md border border-gray-200 bg-gray-50 p-3"> |
|
| 495 |
+ {(
|
|
| 496 |
+ [ |
|
| 497 |
+ ['사이트명', siteInput, setSiteInput], |
|
| 498 |
+ ['게시판명', boardInput, setBoardInput], |
|
| 499 |
+ ['게시물 제목', titleInput, setTitleInput], |
|
| 500 |
+ ] as const |
|
| 501 |
+ ).map(([label, value, setter]) => ( |
|
| 502 |
+ <label key={label} className="flex flex-col gap-1 text-xs text-gray-500">
|
|
| 503 |
+ {label}
|
|
| 504 |
+ <input |
|
| 505 |
+ type="text" |
|
| 506 |
+ aria-label={label}
|
|
| 507 |
+ value={value}
|
|
| 508 |
+ onChange={(e) => setter(e.target.value)}
|
|
| 509 |
+ onKeyDown={(e) => {
|
|
| 510 |
+ if (e.key === 'Enter') {
|
|
| 511 |
+ handleSearch() |
|
| 512 |
+ } |
|
| 513 |
+ }} |
|
| 514 |
+ className="w-48 rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
|
| 515 |
+ /> |
|
| 516 |
+ </label> |
|
| 517 |
+ ))} |
|
| 518 |
+ <span className="pb-2 text-xs text-gray-400">세 조건은 모두 만족하는 건만 찾습니다(AND)</span> |
|
| 519 |
+ </div> |
|
| 520 |
+ )} |
|
| 521 |
+ |
|
| 360 | 522 |
<p className="mt-4 text-sm font-medium text-gray-700"> |
| 361 | 523 |
{`총 ${total}건 · 처리 ${done}건`}
|
| 362 | 524 |
</p> |
| 525 |
+ |
|
| 526 |
+ {/* 일괄 등록: 체크한 건들에 같은 값을 한 번에 넣는다. 삭제도 여기서 한다(행마다 두지 않는다). */}
|
|
| 527 |
+ <div className="mt-3 flex flex-wrap items-center gap-2 rounded-md border border-gray-200 bg-gray-50 p-3"> |
|
| 528 |
+ <span className="text-sm font-medium text-gray-700">선택 {selected.length}건</span>
|
|
| 529 |
+ <select |
|
| 530 |
+ aria-label="일괄 처리결과" |
|
| 531 |
+ value={bulkResult}
|
|
| 532 |
+ onChange={(e) => setBulkResult(e.target.value)}
|
|
| 533 |
+ className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
|
| 534 |
+ > |
|
| 535 |
+ <option value="">처리결과 그대로</option> |
|
| 536 |
+ {REVIEW_RESULT_OPTIONS.map((option) => (
|
|
| 537 |
+ <option key={option} value={option}>{option}</option>
|
|
| 538 |
+ ))} |
|
| 539 |
+ </select> |
|
| 540 |
+ <select |
|
| 541 |
+ aria-label="일괄 공공누리 유형" |
|
| 542 |
+ value={bulkKoglType}
|
|
| 543 |
+ onChange={(e) => setBulkKoglType(e.target.value)}
|
|
| 544 |
+ className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
|
| 545 |
+ > |
|
| 546 |
+ <option value="">공공누리 유형 그대로</option> |
|
| 547 |
+ {JUDGED_KOGL_TYPE_OPTIONS.map((option) => (
|
|
| 548 |
+ <option key={option} value={option}>{option}</option>
|
|
| 549 |
+ ))} |
|
| 550 |
+ </select> |
|
| 551 |
+ <input |
|
| 552 |
+ type="text" |
|
| 553 |
+ aria-label="일괄 의견" |
|
| 554 |
+ value={bulkOpinion}
|
|
| 555 |
+ onChange={(e) => setBulkOpinion(e.target.value)}
|
|
| 556 |
+ placeholder="의견(비우면 유지)" |
|
| 557 |
+ className="w-56 rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
|
| 558 |
+ /> |
|
| 559 |
+ <button |
|
| 560 |
+ type="button" |
|
| 561 |
+ disabled={selected.length === 0 || bulkBusy}
|
|
| 562 |
+ onClick={() => void handleBulkApply()}
|
|
| 563 |
+ className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white disabled:bg-gray-300" |
|
| 564 |
+ > |
|
| 565 |
+ 일괄 등록 |
|
| 566 |
+ </button> |
|
| 567 |
+ <button |
|
| 568 |
+ type="button" |
|
| 569 |
+ disabled={selected.length === 0 || bulkBusy}
|
|
| 570 |
+ onClick={() => void handleBulkDelete()}
|
|
| 571 |
+ className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 disabled:text-gray-300" |
|
| 572 |
+ > |
|
| 573 |
+ 선택 삭제 |
|
| 574 |
+ </button> |
|
| 575 |
+ {bulkMessage && <span className="text-sm text-gray-600">{bulkMessage}</span>}
|
|
| 576 |
+ </div> |
|
| 363 | 577 |
|
| 364 | 578 |
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
|
| 365 | 579 |
|
... | ... | @@ -367,9 +581,17 @@ |
| 367 | 581 |
<table className="w-full min-w-[1100px] text-left text-sm"> |
| 368 | 582 |
<thead> |
| 369 | 583 |
<tr className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500"> |
| 584 |
+ <th className="w-8 px-3 py-2 font-medium"> |
|
| 585 |
+ <input |
|
| 586 |
+ type="checkbox" |
|
| 587 |
+ aria-label="전체 선택" |
|
| 588 |
+ checked={allChecked}
|
|
| 589 |
+ onChange={toggleAll}
|
|
| 590 |
+ /> |
|
| 591 |
+ </th> |
|
| 370 | 592 |
<th className="whitespace-nowrap px-3 py-2 font-medium">No</th> |
| 371 | 593 |
<th className="whitespace-nowrap px-3 py-2 font-medium">사이트/게시판</th> |
| 372 |
- <th className="px-3 py-2 font-medium">범주/제목</th> |
|
| 594 |
+ <th className="px-3 py-2 font-medium">게시물 경로/제목</th> |
|
| 373 | 595 |
<th className="whitespace-nowrap px-3 py-2 font-medium">제작/공표일</th> |
| 374 | 596 |
<th className="whitespace-nowrap px-3 py-2 font-medium">첨부파일</th> |
| 375 | 597 |
<th className="whitespace-nowrap px-3 py-2 font-medium">기존 공공누리</th> |
... | ... | @@ -382,19 +604,27 @@ |
| 382 | 604 |
<tbody> |
| 383 | 605 |
{loading ? (
|
| 384 | 606 |
<tr> |
| 385 |
- <td colSpan={10} className="py-8 text-center text-sm text-gray-400">
|
|
| 607 |
+ <td colSpan={11} className="py-8 text-center text-sm text-gray-400">
|
|
| 386 | 608 |
불러오는 중… |
| 387 | 609 |
</td> |
| 388 | 610 |
</tr> |
| 389 | 611 |
) : items.length === 0 ? ( |
| 390 | 612 |
<tr> |
| 391 |
- <td colSpan={10} className="py-10 text-center text-sm text-gray-300">
|
|
| 613 |
+ <td colSpan={11} className="py-10 text-center text-sm text-gray-300">
|
|
| 392 | 614 |
업로드된 권리확인 자료가 없습니다. 엑셀 업로드로 시작하세요. |
| 393 | 615 |
</td> |
| 394 | 616 |
</tr> |
| 395 | 617 |
) : ( |
| 396 | 618 |
items.map((item) => ( |
| 397 | 619 |
<tr key={item.id} className="border-b border-gray-100 align-top last:border-b-0">
|
| 620 |
+ <td className="px-3 py-2"> |
|
| 621 |
+ <input |
|
| 622 |
+ type="checkbox" |
|
| 623 |
+ aria-label={`${item.seq}번 선택`}
|
|
| 624 |
+ checked={selected.includes(item.id)}
|
|
| 625 |
+ onChange={() => toggleOne(item.id)}
|
|
| 626 |
+ /> |
|
| 627 |
+ </td> |
|
| 398 | 628 |
<td className="px-3 py-2 text-gray-500">{item.seq}</td>
|
| 399 | 629 |
<td className="px-3 py-2"> |
| 400 | 630 |
<div className="text-gray-900">{item.siteName ?? '-'}</div>
|
... | ... | @@ -448,13 +678,6 @@ |
| 448 | 678 |
className="rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50" |
| 449 | 679 |
> |
| 450 | 680 |
수정 |
| 451 |
- </button> |
|
| 452 |
- <button |
|
| 453 |
- type="button" |
|
| 454 |
- onClick={() => void handleDelete(item)}
|
|
| 455 |
- className="rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50" |
|
| 456 |
- > |
|
| 457 |
- 삭제 |
|
| 458 | 681 |
</button> |
| 459 | 682 |
</div> |
| 460 | 683 |
</td> |
... | ... | @@ -571,7 +794,49 @@ |
| 571 | 794 |
const suggestion = suggestReviewResult(major, minor) |
| 572 | 795 |
if (suggestion) {
|
| 573 | 796 |
setReviewResult(suggestion) |
| 797 |
+ applyResultDerivations(suggestion) |
|
| 574 | 798 |
} |
| 799 |
+ } |
|
| 800 |
+ |
|
| 801 |
+ /** |
|
| 802 |
+ * 처리결과가 정해지면 공공누리 유형·비고·권리처리필요를 규칙대로 함께 맞춘다 |
|
| 803 |
+ * (2026-07-20 제3자 권리처리 정책변경). |
|
| 804 |
+ * |
|
| 805 |
+ * - 권리처리 추진 → 공공누리 유형 '보류' |
|
| 806 |
+ * - 권리처리 추진 미희망 → 기존 공공누리 유형을 그대로 쓰고 비고에 사유를 남긴다. |
|
| 807 |
+ * 기존 유형이 없으면 미선택 상태로 둔다(별도 제한 없음) |
|
| 808 |
+ * - 개방불가 → 공공누리 유형 '개방불가' |
|
| 809 |
+ * |
|
| 810 |
+ * 사용자가 이 값들을 다시 손으로 바꿀 수 있게, 잠그지는 않고 값만 채워 준다. |
|
| 811 |
+ */ |
|
| 812 |
+ function applyResultDerivations(result: string) {
|
|
| 813 |
+ if (result === RESULT_PROCESSING) {
|
|
| 814 |
+ setJudgedKoglType(KOGL_ON_HOLD) |
|
| 815 |
+ setNeedsProcessing(true) |
|
| 816 |
+ return |
|
| 817 |
+ } |
|
| 818 |
+ if (result === RESULT_NO_PROCESSING) {
|
|
| 819 |
+ setNeedsProcessing(false) |
|
| 820 |
+ setJudgedKoglType(koglType || '') |
|
| 821 |
+ if (koglType) {
|
|
| 822 |
+ setLawyerNote((prev) => |
|
| 823 |
+ prev.includes(NOTE_NO_PROCESSING) |
|
| 824 |
+ ? prev |
|
| 825 |
+ : [prev.trim(), NOTE_NO_PROCESSING].filter(Boolean).join('\n'),
|
|
| 826 |
+ ) |
|
| 827 |
+ } |
|
| 828 |
+ return |
|
| 829 |
+ } |
|
| 830 |
+ if (result === RESULT_BLOCKED) {
|
|
| 831 |
+ setJudgedKoglType(RESULT_BLOCKED) |
|
| 832 |
+ setNeedsProcessing(false) |
|
| 833 |
+ } |
|
| 834 |
+ } |
|
| 835 |
+ |
|
| 836 |
+ /** 지금 고른 세부항목에서 그 처리결과를 고를 수 있는지. 제한이 없는 세부항목은 전부 허용이다. */ |
|
| 837 |
+ function resultAllowed(option: string): boolean {
|
|
| 838 |
+ const allowed = ALLOWED_RESULTS_BY_MINOR[reviewMinor] |
|
| 839 |
+ return !allowed || allowed.includes(option) |
|
| 575 | 840 |
} |
| 576 | 841 |
|
| 577 | 842 |
function pickMajor(major: string) {
|
... | ... | @@ -589,6 +854,7 @@ |
| 589 | 854 |
function pickResult(result: string) {
|
| 590 | 855 |
setReviewResult(result) |
| 591 | 856 |
setResultManuallySet(true) |
| 857 |
+ applyResultDerivations(result) |
|
| 592 | 858 |
} |
| 593 | 859 |
|
| 594 | 860 |
async function handleSave() {
|
... | ... | @@ -762,6 +1028,7 @@ |
| 762 | 1028 |
value={REVIEW_RESULT_OPTIONS[0]}
|
| 763 | 1029 |
checked={reviewResult === REVIEW_RESULT_OPTIONS[0]}
|
| 764 | 1030 |
onChange={() => pickResult(REVIEW_RESULT_OPTIONS[0])}
|
| 1031 |
+ disabled={!resultAllowed(REVIEW_RESULT_OPTIONS[0])}
|
|
| 765 | 1032 |
bold |
| 766 | 1033 |
/> |
| 767 | 1034 |
</td> |
... | ... | @@ -813,6 +1080,7 @@ |
| 813 | 1080 |
value={REVIEW_RESULT_OPTIONS[1]}
|
| 814 | 1081 |
checked={reviewResult === REVIEW_RESULT_OPTIONS[1]}
|
| 815 | 1082 |
onChange={() => pickResult(REVIEW_RESULT_OPTIONS[1])}
|
| 1083 |
+ disabled={!resultAllowed(REVIEW_RESULT_OPTIONS[1])}
|
|
| 816 | 1084 |
bold |
| 817 | 1085 |
/> |
| 818 | 1086 |
</td> |
... | ... | @@ -829,16 +1097,30 @@ |
| 829 | 1097 |
/> |
| 830 | 1098 |
</td> |
| 831 | 1099 |
<td |
| 832 |
- className="border border-gray-300 bg-gray-50 px-3 py-2 text-center align-middle" |
|
| 1100 |
+ className="border border-gray-300 bg-gray-50 px-3 py-2 align-middle" |
|
| 833 | 1101 |
rowSpan={3}
|
| 834 | 1102 |
> |
| 835 |
- <MatrixRadio |
|
| 836 |
- name="review-result" |
|
| 837 |
- value={REVIEW_RESULT_OPTIONS[2]}
|
|
| 838 |
- checked={reviewResult === REVIEW_RESULT_OPTIONS[2]}
|
|
| 839 |
- onChange={() => pickResult(REVIEW_RESULT_OPTIONS[2])}
|
|
| 840 |
- bold |
|
| 841 |
- /> |
|
| 1103 |
+ {/* 정책변경: 3·4·초상권은 추진/미희망 택일이다. 초상권만 개방불가까지 고를 수 있다. */}
|
|
| 1104 |
+ <div className="flex flex-col items-start gap-1.5"> |
|
| 1105 |
+ <MatrixRadio |
|
| 1106 |
+ name="review-result" |
|
| 1107 |
+ value={RESULT_PROCESSING}
|
|
| 1108 |
+ checked={reviewResult === RESULT_PROCESSING}
|
|
| 1109 |
+ onChange={() => pickResult(RESULT_PROCESSING)}
|
|
| 1110 |
+ disabled={!resultAllowed(RESULT_PROCESSING)}
|
|
| 1111 |
+ bold |
|
| 1112 |
+ /> |
|
| 1113 |
+ <MatrixRadio |
|
| 1114 |
+ name="review-result" |
|
| 1115 |
+ value={RESULT_NO_PROCESSING}
|
|
| 1116 |
+ checked={reviewResult === RESULT_NO_PROCESSING}
|
|
| 1117 |
+ onChange={() => pickResult(RESULT_NO_PROCESSING)}
|
|
| 1118 |
+ disabled={!resultAllowed(RESULT_NO_PROCESSING)}
|
|
| 1119 |
+ /> |
|
| 1120 |
+ {reviewMinor === '초상권 포함' && (
|
|
| 1121 |
+ <span className="text-[11px] text-gray-400">개방불가도 선택 가능</span> |
|
| 1122 |
+ )} |
|
| 1123 |
+ </div> |
|
| 842 | 1124 |
</td> |
| 843 | 1125 |
</tr> |
| 844 | 1126 |
<tr> |
... | ... | @@ -884,6 +1166,7 @@ |
| 884 | 1166 |
value={REVIEW_RESULT_OPTIONS[3]}
|
| 885 | 1167 |
checked={reviewResult === REVIEW_RESULT_OPTIONS[3]}
|
| 886 | 1168 |
onChange={() => pickResult(REVIEW_RESULT_OPTIONS[3])}
|
| 1169 |
+ disabled={!resultAllowed(REVIEW_RESULT_OPTIONS[3])}
|
|
| 887 | 1170 |
bold |
| 888 | 1171 |
/> |
| 889 | 1172 |
</td> |
... | ... | @@ -896,11 +1179,13 @@ |
| 896 | 1179 |
<InfoRow label="공공누리 유형"> |
| 897 | 1180 |
<div className="flex flex-col gap-1.5"> |
| 898 | 1181 |
<div className="flex flex-wrap gap-x-4 gap-y-1"> |
| 899 |
- {JUDGED_KOGL_TYPE_OPTIONS.filter((o) => o === '0유형' || o === '개방불가').map((option) => (
|
|
| 1182 |
+ {JUDGED_KOGL_TYPE_OPTIONS.filter((o) => KOGL_FIRST_ROW.includes(o)).map((option) => (
|
|
| 900 | 1183 |
<label key={option} className="flex items-center gap-1.5 text-sm text-gray-800">
|
| 901 | 1184 |
<input |
| 902 | 1185 |
type="radio" |
| 903 | 1186 |
name="judged-kogl-type" |
| 1187 |
+ // 처리결과에도 '개방불가'가 있어 라벨만으로는 구분되지 않는다. |
|
| 1188 |
+ aria-label={`공공누리 유형 ${option}`}
|
|
| 904 | 1189 |
checked={judgedKoglType === option}
|
| 905 | 1190 |
onChange={() => setJudgedKoglType(option)}
|
| 906 | 1191 |
/> |
... | ... | @@ -909,11 +1194,13 @@ |
| 909 | 1194 |
))} |
| 910 | 1195 |
</div> |
| 911 | 1196 |
<div className="flex flex-wrap items-center gap-x-4 gap-y-1"> |
| 912 |
- {JUDGED_KOGL_TYPE_OPTIONS.filter((o) => o !== '0유형' && o !== '개방불가').map((option) => (
|
|
| 1197 |
+ {JUDGED_KOGL_TYPE_OPTIONS.filter((o) => !KOGL_FIRST_ROW.includes(o)).map((option) => (
|
|
| 913 | 1198 |
<label key={option} className="flex items-center gap-1.5 text-sm text-gray-800">
|
| 914 | 1199 |
<input |
| 915 | 1200 |
type="radio" |
| 916 | 1201 |
name="judged-kogl-type" |
| 1202 |
+ // 처리결과에도 '개방불가'가 있어 라벨만으로는 구분되지 않는다. |
|
| 1203 |
+ aria-label={`공공누리 유형 ${option}`}
|
|
| 917 | 1204 |
checked={judgedKoglType === option}
|
| 918 | 1205 |
onChange={() => setJudgedKoglType(option)}
|
| 919 | 1206 |
/> |
--- frontend/src/components/SeedUpload.tsx
+++ frontend/src/components/SeedUpload.tsx
... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 |
import { useRef, useState } from 'react'
|
| 2 | 2 |
import { uploadSeed, type SeedReport } from '../api/client'
|
| 3 |
+import HelpButton from '../help/HelpButton' |
|
| 3 | 4 |
|
| 4 | 5 |
export default function SeedUpload({ onUploaded }: { onUploaded: () => void }) {
|
| 5 | 6 |
const inputRef = useRef<HTMLInputElement>(null) |
... | ... | @@ -41,6 +42,10 @@ |
| 41 | 42 |
> |
| 42 | 43 |
신청목록 시트 올리기 |
| 43 | 44 |
</button> |
| 45 |
+ <span className="mt-2 flex items-center justify-center gap-1 text-xs text-gray-400"> |
|
| 46 |
+ 업로드 방법 |
|
| 47 |
+ <HelpButton topic="channel.seed" /> |
|
| 48 |
+ </span> |
|
| 44 | 49 |
|
| 45 | 50 |
{report && (
|
| 46 | 51 |
<p className="mt-2 text-xs text-gray-500"> |
--- frontend/src/components/Sidebar.test.tsx
+++ frontend/src/components/Sidebar.test.tsx
... | ... | @@ -43,10 +43,19 @@ |
| 43 | 43 |
it('미구현 메뉴는 비활성으로 표시되고 링크가 아니다', () => {
|
| 44 | 44 |
render(<Sidebar />) |
| 45 | 45 |
|
| 46 |
- const dashboard = screen.getByText('Dashboard')
|
|
| 47 |
- expect(dashboard).toHaveAttribute('aria-disabled', 'true')
|
|
| 48 |
- expect(dashboard).toHaveAttribute('title', '준비 중')
|
|
| 49 |
- expect(screen.queryByRole('link', { name: 'Dashboard' })).toBeNull()
|
|
| 46 |
+ const files = screen.getByText('자료실')
|
|
| 47 |
+ expect(files).toHaveAttribute('aria-disabled', 'true')
|
|
| 48 |
+ expect(files).toHaveAttribute('title', '준비 중')
|
|
| 49 |
+ expect(screen.queryByRole('link', { name: '자료실' })).toBeNull()
|
|
| 50 |
+ }) |
|
| 51 |
+ |
|
| 52 |
+ it('Dashboard는 구현되어 링크로 열린다', () => {
|
|
| 53 |
+ const onSelect = vi.fn() |
|
| 54 |
+ render(<Sidebar onSelect={onSelect} />)
|
|
| 55 |
+ |
|
| 56 |
+ fireEvent.click(screen.getByRole('link', { name: 'Dashboard' }))
|
|
| 57 |
+ |
|
| 58 |
+ expect(onSelect).toHaveBeenCalledWith('dashboard')
|
|
| 50 | 59 |
}) |
| 51 | 60 |
|
| 52 | 61 |
it('브랜드 영역을 보여준다', () => {
|
... | ... | @@ -64,7 +73,7 @@ |
| 64 | 73 |
expect(screen.getByText('IH')).toBeTruthy()
|
| 65 | 74 |
|
| 66 | 75 |
expect(screen.getByRole('link', { name: '기관관리' })).toHaveAttribute('title', '기관관리')
|
| 67 |
- expect(screen.getByTitle('Dashboard (준비 중)')).toBeTruthy()
|
|
| 76 |
+ expect(screen.getByTitle('자료실 (준비 중)')).toBeTruthy()
|
|
| 68 | 77 |
}) |
| 69 | 78 |
|
| 70 | 79 |
it('접기 버튼을 누르면 onToggle이 호출된다', () => {
|
--- frontend/src/components/Sidebar.tsx
+++ frontend/src/components/Sidebar.tsx
... | ... | @@ -15,7 +15,7 @@ |
| 15 | 15 |
{
|
| 16 | 16 |
key: 'dashboard', |
| 17 | 17 |
label: 'Dashboard', |
| 18 |
- enabled: false, |
|
| 18 |
+ enabled: true, |
|
| 19 | 19 |
icon: ( |
| 20 | 20 |
<svg className={ICON_CLASS} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 21 | 21 |
<rect x="3" y="3" width="7" height="7" rx="1" /> |
... | ... | @@ -58,7 +58,7 @@ |
| 58 | 58 |
{
|
| 59 | 59 |
key: 'projects', |
| 60 | 60 |
label: '사업관리', |
| 61 |
- enabled: false, |
|
| 61 |
+ enabled: true, |
|
| 62 | 62 |
icon: ( |
| 63 | 63 |
<svg className={ICON_CLASS} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 64 | 64 |
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z" /> |
... | ... | @@ -110,7 +110,7 @@ |
| 110 | 110 |
{
|
| 111 | 111 |
key: 'system', |
| 112 | 112 |
label: '시스템관리', |
| 113 |
- enabled: false, |
|
| 113 |
+ enabled: true, |
|
| 114 | 114 |
icon: ( |
| 115 | 115 |
<svg className={ICON_CLASS} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 116 | 116 |
<circle cx="12" cy="12" r="3" /> |
+++ frontend/src/components/SystemPage.test.tsx
... | ... | @@ -0,0 +1,128 @@ |
| 1 | +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' | |
| 2 | +import { beforeEach, describe, expect, it, vi } from 'vitest' | |
| 3 | +import SystemPage from './SystemPage' | |
| 4 | +import type { ResetTarget } from '../api/client' | |
| 5 | + | |
| 6 | +const mocks = vi.hoisted(() => ({ | |
| 7 | + getResetPreview: vi.fn(), | |
| 8 | + resetChannels: vi.fn(), | |
| 9 | +})) | |
| 10 | + | |
| 11 | +vi.mock('../api/client', async (importOriginal) => ({ | |
| 12 | + ...(await importOriginal<typeof import('../api/client')>()), | |
| 13 | + getResetPreview: mocks.getResetPreview, | |
| 14 | + resetChannels: mocks.resetChannels, | |
| 15 | +})) | |
| 16 | + | |
| 17 | +function target(overrides: Partial<ResetTarget> = {}): ResetTarget { | |
| 18 | + return { | |
| 19 | + orgId: 1, | |
| 20 | + orgNo: '001', | |
| 21 | + orgName: '국제방송교류재단', | |
| 22 | + stage: 1, | |
| 23 | + channels: [ | |
| 24 | + { kind: '문정원', channelId: 'ch-mj' }, | |
| 25 | + { kind: '법률검토', channelId: 'ch-law' }, | |
| 26 | + ], | |
| 27 | + ...overrides, | |
| 28 | + } | |
| 29 | +} | |
| 30 | + | |
| 31 | +async function typeConfirmAndRun(phrase = '채널 초기화') { | |
| 32 | + fireEvent.change(screen.getByLabelText(/실행하려면/), { target: { value: phrase } }) | |
| 33 | + fireEvent.click(screen.getByText('채널 초기화 실행')) | |
| 34 | + fireEvent.click(await screen.findByText('초기화 실행')) | |
| 35 | +} | |
| 36 | + | |
| 37 | +describe('SystemPage', () => { | |
| 38 | + beforeEach(() => { | |
| 39 | + vi.clearAllMocks() | |
| 40 | + mocks.getResetPreview.mockResolvedValue([target()]) | |
| 41 | + mocks.resetChannels.mockResolvedValue({ archivedChannels: 2, clearedOrgs: 1, failures: [] }) | |
| 42 | + }) | |
| 43 | + | |
| 44 | + it('초기화 대상을 기관·채널 수와 함께 미리 보여준다', async () => { | |
| 45 | + mocks.getResetPreview.mockResolvedValue([ | |
| 46 | + target(), | |
| 47 | + target({ orgId: 2, orgNo: '002', orgName: '세종학당재단', channels: [{ kind: '문정원', channelId: 'c2' }] }), | |
| 48 | + ]) | |
| 49 | + render(<SystemPage />) | |
| 50 | + | |
| 51 | + expect(await screen.findByText('대상 2개 기관 · 채널 3개')).toBeTruthy() | |
| 52 | + const rows = within(screen.getByTestId('reset-targets')).getAllByRole('row') | |
| 53 | + expect(rows).toHaveLength(3) // 헤더 + 2 | |
| 54 | + expect(rows[1].textContent).toContain('문정원 · 법률검토') | |
| 55 | + }) | |
| 56 | + | |
| 57 | + it('확인 문구를 입력하기 전에는 실행 버튼이 잠겨 있다', async () => { | |
| 58 | + render(<SystemPage />) | |
| 59 | + await screen.findByTestId('reset-targets') | |
| 60 | + | |
| 61 | + expect(screen.getByText('채널 초기화 실행')).toHaveProperty('disabled', true) | |
| 62 | + | |
| 63 | + fireEvent.change(screen.getByLabelText(/실행하려면/), { target: { value: '초기화' } }) | |
| 64 | + expect(screen.getByText('채널 초기화 실행')).toHaveProperty('disabled', true) | |
| 65 | + | |
| 66 | + fireEvent.change(screen.getByLabelText(/실행하려면/), { target: { value: '채널 초기화' } }) | |
| 67 | + expect(screen.getByText('채널 초기화 실행')).toHaveProperty('disabled', false) | |
| 68 | + }) | |
| 69 | + | |
| 70 | + it('실행 버튼을 눌러도 확인창에서 한 번 더 물어본다', async () => { | |
| 71 | + render(<SystemPage />) | |
| 72 | + await screen.findByTestId('reset-targets') | |
| 73 | + | |
| 74 | + fireEvent.change(screen.getByLabelText(/실행하려면/), { target: { value: '채널 초기화' } }) | |
| 75 | + fireEvent.click(screen.getByText('채널 초기화 실행')) | |
| 76 | + | |
| 77 | + expect(await screen.findByText('정말 초기화할까요?')).toBeTruthy() | |
| 78 | + expect(mocks.resetChannels).not.toHaveBeenCalled() | |
| 79 | + | |
| 80 | + fireEvent.click(screen.getByText('취소')) | |
| 81 | + expect(mocks.resetChannels).not.toHaveBeenCalled() | |
| 82 | + }) | |
| 83 | + | |
| 84 | + it('확인창에서 실행하면 문구와 함께 초기화를 요청하고 결과를 보여준다', async () => { | |
| 85 | + render(<SystemPage />) | |
| 86 | + await screen.findByTestId('reset-targets') | |
| 87 | + | |
| 88 | + await typeConfirmAndRun() | |
| 89 | + | |
| 90 | + await waitFor(() => expect(mocks.resetChannels).toHaveBeenCalledWith('채널 초기화')) | |
| 91 | + expect(await screen.findByText('채널 2개 보관, 기관 1곳 초기화')).toBeTruthy() | |
| 92 | + // 실행 후 목록을 다시 읽는다 | |
| 93 | + expect(mocks.getResetPreview).toHaveBeenCalledTimes(2) | |
| 94 | + }) | |
| 95 | + | |
| 96 | + it('실패가 있으면 사유를 그대로 보여준다', async () => { | |
| 97 | + mocks.resetChannels.mockResolvedValue({ | |
| 98 | + archivedChannels: 1, | |
| 99 | + clearedOrgs: 0, | |
| 100 | + failures: ['001 국제방송교류재단 (법률검토): 권한 없음'], | |
| 101 | + }) | |
| 102 | + render(<SystemPage />) | |
| 103 | + await screen.findByTestId('reset-targets') | |
| 104 | + | |
| 105 | + await typeConfirmAndRun() | |
| 106 | + | |
| 107 | + expect(await screen.findByText(/실패 1건/)).toBeTruthy() | |
| 108 | + expect(screen.getByText(/권한 없음/)).toBeTruthy() | |
| 109 | + }) | |
| 110 | + | |
| 111 | + it('대상이 없으면 실행할 수 없다', async () => { | |
| 112 | + mocks.getResetPreview.mockResolvedValue([]) | |
| 113 | + render(<SystemPage />) | |
| 114 | + | |
| 115 | + expect(await screen.findByText('초기화할 채널이 없습니다.')).toBeTruthy() | |
| 116 | + fireEvent.change(screen.getByLabelText(/실행하려면/), { target: { value: '채널 초기화' } }) | |
| 117 | + expect(screen.getByText('채널 초기화 실행')).toHaveProperty('disabled', true) | |
| 118 | + }) | |
| 119 | + | |
| 120 | + it('무엇이 남고 무엇이 사라지는지 화면에 적어 둔다', async () => { | |
| 121 | + render(<SystemPage />) | |
| 122 | + await screen.findByTestId('reset-targets') | |
| 123 | + | |
| 124 | + expect(screen.getByText(/보관\(아카이브\)/)).toBeTruthy() | |
| 125 | + expect(screen.getByText(/그대로 남는다/)).toBeTruthy() | |
| 126 | + expect(screen.getByText(/사용자 계정도 건드리지 않는다/)).toBeTruthy() | |
| 127 | + }) | |
| 128 | +}) |
+++ frontend/src/components/SystemPage.tsx
... | ... | @@ -0,0 +1,202 @@ |
| 1 | +import { useCallback, useEffect, useState } from 'react' | |
| 2 | +import { | |
| 3 | + getResetPreview, | |
| 4 | + resetChannels, | |
| 5 | + RESET_CONFIRM_PHRASE, | |
| 6 | + type ResetReport, | |
| 7 | + type ResetTarget, | |
| 8 | +} from '../api/client' | |
| 9 | +import { stageLabel } from '../stages' | |
| 10 | +import HelpButton from '../help/HelpButton' | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * 시스템관리 화면. 지금은 [채널 초기화] 하나만 있다. | |
| 14 | + * | |
| 15 | + * 되돌리기 번거로운 작업이라 3중으로 막는다. | |
| 16 | + * 1) 무엇이 사라지는지 목록으로 먼저 보여준다 | |
| 17 | + * 2) 확인 문구를 그대로 입력해야 버튼이 열린다 | |
| 18 | + * 3) 서버도 같은 문구를 다시 검사한다 | |
| 19 | + * | |
| 20 | + * 지우는 범위는 <b>웹앱이 만든(=DB가 채널 ID를 들고 있는) 채널</b>뿐이다. Mattermost 팀에는 | |
| 21 | + * 엑셀 VBA로 만든 예전 채널과 공용 채널(Town Square/FAQ/대쉬보드/양식 모음/자료실)이 함께 | |
| 22 | + * 있어서, 이름으로 훑어 지우면 그것들까지 날아간다. 사용자 계정도 건드리지 않는다. | |
| 23 | + */ | |
| 24 | + | |
| 25 | +interface Props { | |
| 26 | + /** 초기화가 끝나면 상위(App)가 기관 목록을 다시 읽도록. */ | |
| 27 | + onChanged?: () => void | |
| 28 | +} | |
| 29 | + | |
| 30 | +export default function SystemPage({ onChanged }: Props) { | |
| 31 | + const [targets, setTargets] = useState<ResetTarget[] | null>(null) | |
| 32 | + const [error, setError] = useState<string | null>(null) | |
| 33 | + const [confirm, setConfirm] = useState('') | |
| 34 | + const [busy, setBusy] = useState(false) | |
| 35 | + const [report, setReport] = useState<ResetReport | null>(null) | |
| 36 | + const [dialogOpen, setDialogOpen] = useState(false) | |
| 37 | + | |
| 38 | + const reload = useCallback(async () => { | |
| 39 | + try { | |
| 40 | + setTargets(await getResetPreview()) | |
| 41 | + setError(null) | |
| 42 | + } catch (e) { | |
| 43 | + setError((e as Error).message) | |
| 44 | + } | |
| 45 | + }, []) | |
| 46 | + | |
| 47 | + useEffect(() => { | |
| 48 | + void reload() | |
| 49 | + }, [reload]) | |
| 50 | + | |
| 51 | + const channelCount = (targets ?? []).reduce((sum, t) => sum + t.channels.length, 0) | |
| 52 | + const canRun = confirm.trim() === RESET_CONFIRM_PHRASE && channelCount > 0 && !busy | |
| 53 | + | |
| 54 | + async function run() { | |
| 55 | + setBusy(true) | |
| 56 | + setDialogOpen(false) | |
| 57 | + try { | |
| 58 | + const result = await resetChannels(confirm.trim()) | |
| 59 | + setReport(result) | |
| 60 | + setConfirm('') | |
| 61 | + await reload() | |
| 62 | + onChanged?.() | |
| 63 | + setError(null) | |
| 64 | + } catch (e) { | |
| 65 | + setError((e as Error).message) | |
| 66 | + } finally { | |
| 67 | + setBusy(false) | |
| 68 | + } | |
| 69 | + } | |
| 70 | + | |
| 71 | + return ( | |
| 72 | + <div className="space-y-4 p-6"> | |
| 73 | + <section className="rounded-lg border border-gray-200 bg-white p-5"> | |
| 74 | + <h1 className="text-lg font-semibold">시스템관리</h1> | |
| 75 | + <p className="text-xs text-gray-500">운영 중 되돌리기가 필요한 작업을 모아 둔다.</p> | |
| 76 | + </section> | |
| 77 | + | |
| 78 | + <section className="rounded-lg border border-red-200 bg-white p-5"> | |
| 79 | + <h2 className="flex items-center gap-1.5 text-sm font-semibold text-red-700"> | |
| 80 | + 채널 초기화 | |
| 81 | + <HelpButton topic="system.reset" /> | |
| 82 | + </h2> | |
| 83 | + <ul className="mt-3 space-y-1 rounded-md bg-red-50 p-3 text-xs text-red-800"> | |
| 84 | + <li>· 웹앱이 만든 Mattermost 채널을 <b>보관(아카이브)</b> 처리한다. 영구 삭제가 아니라 시스템 콘솔에서 되돌릴 수 있다.</li> | |
| 85 | + <li>· 기관의 채널 연결과 진행단계가 비워져, 채널관리에서 처음부터 다시 만들 수 있게 된다.</li> | |
| 86 | + <li>· 담당자 배정, 권리확인·권리처리 자료, 업무메모는 <b>그대로 남는다</b>.</li> | |
| 87 | + <li>· 공용 채널(Town Square·FAQ·대쉬보드·양식 모음·자료실)과 엑셀로 만든 예전 채널은 대상이 아니다.</li> | |
| 88 | + <li>· 사용자 계정도 건드리지 않는다.</li> | |
| 89 | + </ul> | |
| 90 | + | |
| 91 | + {error && ( | |
| 92 | + <p className="mt-3 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p> | |
| 93 | + )} | |
| 94 | + | |
| 95 | + {report && ( | |
| 96 | + <div className="mt-3 rounded-md bg-gray-50 px-3 py-2 text-sm"> | |
| 97 | + <p> | |
| 98 | + 채널 {report.archivedChannels}개 보관, 기관 {report.clearedOrgs}곳 초기화 | |
| 99 | + {report.failures.length > 0 && `, 실패 ${report.failures.length}건`} | |
| 100 | + </p> | |
| 101 | + {report.failures.length > 0 && ( | |
| 102 | + <ul className="mt-1 space-y-0.5 text-xs text-red-700"> | |
| 103 | + {report.failures.map((f) => ( | |
| 104 | + <li key={f}>· {f}</li> | |
| 105 | + ))} | |
| 106 | + </ul> | |
| 107 | + )} | |
| 108 | + </div> | |
| 109 | + )} | |
| 110 | + | |
| 111 | + <div className="mt-4"> | |
| 112 | + <p className="text-sm font-medium"> | |
| 113 | + 대상 {targets?.length ?? 0}개 기관 · 채널 {channelCount}개 | |
| 114 | + </p> | |
| 115 | + | |
| 116 | + {targets === null ? ( | |
| 117 | + <p className="mt-2 text-sm text-gray-500">불러오는 중…</p> | |
| 118 | + ) : targets.length === 0 ? ( | |
| 119 | + <p className="mt-2 text-sm text-gray-500">초기화할 채널이 없습니다.</p> | |
| 120 | + ) : ( | |
| 121 | + <div className="mt-2 max-h-72 overflow-y-auto rounded-md border border-gray-200"> | |
| 122 | + <table data-testid="reset-targets" className="w-full text-sm"> | |
| 123 | + <thead> | |
| 124 | + <tr className="border-b border-gray-200 text-xs text-gray-500"> | |
| 125 | + <th className="px-3 py-2 text-left font-normal">연번</th> | |
| 126 | + <th className="px-3 py-2 text-left font-normal">기관명</th> | |
| 127 | + <th className="px-3 py-2 text-left font-normal">현재 단계</th> | |
| 128 | + <th className="px-3 py-2 text-left font-normal">보관될 채널</th> | |
| 129 | + </tr> | |
| 130 | + </thead> | |
| 131 | + <tbody> | |
| 132 | + {targets.map((t) => ( | |
| 133 | + <tr key={t.orgId} className="border-b border-gray-100"> | |
| 134 | + <td className="px-3 py-2 text-gray-500 tabular-nums">{t.orgNo}</td> | |
| 135 | + <td className="px-3 py-2 font-medium">{t.orgName}</td> | |
| 136 | + <td className="px-3 py-2 text-gray-600"> | |
| 137 | + {t.stage === null ? '-' : `${t.stage}. ${stageLabel(t.stage)}`} | |
| 138 | + </td> | |
| 139 | + <td className="px-3 py-2 text-gray-600"> | |
| 140 | + {t.channels.map((c) => c.kind).join(' · ')} | |
| 141 | + </td> | |
| 142 | + </tr> | |
| 143 | + ))} | |
| 144 | + </tbody> | |
| 145 | + </table> | |
| 146 | + </div> | |
| 147 | + )} | |
| 148 | + </div> | |
| 149 | + | |
| 150 | + <div className="mt-4 flex flex-wrap items-center gap-2"> | |
| 151 | + <label htmlFor="reset-confirm" className="text-sm text-gray-600"> | |
| 152 | + 실행하려면 <b>{RESET_CONFIRM_PHRASE}</b> 를 입력하세요 | |
| 153 | + </label> | |
| 154 | + <input | |
| 155 | + id="reset-confirm" | |
| 156 | + value={confirm} | |
| 157 | + onChange={(e) => setConfirm(e.target.value)} | |
| 158 | + className="w-40 rounded-md border border-gray-300 px-2 py-1.5 text-sm" | |
| 159 | + /> | |
| 160 | + <button | |
| 161 | + type="button" | |
| 162 | + disabled={!canRun} | |
| 163 | + onClick={() => setDialogOpen(true)} | |
| 164 | + className="rounded-md bg-red-600 px-3 py-1.5 text-sm text-white disabled:bg-gray-300" | |
| 165 | + > | |
| 166 | + 채널 초기화 실행 | |
| 167 | + </button> | |
| 168 | + </div> | |
| 169 | + </section> | |
| 170 | + | |
| 171 | + {dialogOpen && ( | |
| 172 | + <div className="fixed inset-0 flex items-center justify-center bg-black/30" role="alertdialog"> | |
| 173 | + <div className="w-96 rounded-lg bg-white p-5"> | |
| 174 | + <h2 className="text-base font-semibold">정말 초기화할까요?</h2> | |
| 175 | + <p className="mt-3 rounded-md bg-gray-50 p-3 text-sm text-gray-700"> | |
| 176 | + 기관 {targets?.length ?? 0}곳의 채널 {channelCount}개가 Mattermost에서 보관 처리되고, | |
| 177 | + 해당 기관의 채널 연결과 진행단계가 비워집니다. | |
| 178 | + </p> | |
| 179 | + <div className="mt-5 flex justify-end gap-2"> | |
| 180 | + <button | |
| 181 | + type="button" | |
| 182 | + disabled={busy} | |
| 183 | + onClick={() => setDialogOpen(false)} | |
| 184 | + className="rounded-md border border-gray-300 px-3 py-1.5 text-sm disabled:opacity-50" | |
| 185 | + > | |
| 186 | + 취소 | |
| 187 | + </button> | |
| 188 | + <button | |
| 189 | + type="button" | |
| 190 | + disabled={busy} | |
| 191 | + onClick={() => void run()} | |
| 192 | + className="rounded-md bg-red-600 px-3 py-1.5 text-sm text-white disabled:bg-gray-300" | |
| 193 | + > | |
| 194 | + 초기화 실행 | |
| 195 | + </button> | |
| 196 | + </div> | |
| 197 | + </div> | |
| 198 | + </div> | |
| 199 | + )} | |
| 200 | + </div> | |
| 201 | + ) | |
| 202 | +} |
--- frontend/src/components/WorkMemos.test.tsx
+++ frontend/src/components/WorkMemos.test.tsx
... | ... | @@ -6,6 +6,7 @@ |
| 6 | 6 |
const mocks = vi.hoisted(() => ({
|
| 7 | 7 |
getMemos: vi.fn(), |
| 8 | 8 |
createMemo: vi.fn(), |
| 9 |
+ updateMemo: vi.fn(), |
|
| 9 | 10 |
deleteMemo: vi.fn(), |
| 10 | 11 |
})) |
| 11 | 12 |
|
... | ... | @@ -13,6 +14,7 @@ |
| 13 | 14 |
...(await importOriginal<typeof import('../api/client')>()),
|
| 14 | 15 |
getMemos: mocks.getMemos, |
| 15 | 16 |
createMemo: mocks.createMemo, |
| 17 |
+ updateMemo: mocks.updateMemo, |
|
| 16 | 18 |
deleteMemo: mocks.deleteMemo, |
| 17 | 19 |
})) |
| 18 | 20 |
|
... | ... | @@ -71,6 +73,7 @@ |
| 71 | 73 |
beforeEach(() => {
|
| 72 | 74 |
mocks.getMemos.mockReset() |
| 73 | 75 |
mocks.createMemo.mockReset() |
| 76 |
+ mocks.updateMemo.mockReset() |
|
| 74 | 77 |
mocks.deleteMemo.mockReset() |
| 75 | 78 |
}) |
| 76 | 79 |
|
... | ... | @@ -99,42 +102,16 @@ |
| 99 | 102 |
}) |
| 100 | 103 |
}) |
| 101 | 104 |
|
| 102 |
- it('담당자 선택에 기관 담당자와 직접 입력이 나오고, 직접 입력을 고르면 이름 입력칸이 나타난다', async () => {
|
|
| 105 |
+ it('작성자를 고르는 칸은 없고, 자동 기록 안내만 보인다', async () => {
|
|
| 103 | 106 |
mocks.getMemos.mockResolvedValue([]) |
| 104 | 107 |
|
| 105 | 108 |
render(<WorkMemos org={org()} />)
|
| 106 | 109 |
|
| 107 | 110 |
await waitFor(() => expect(mocks.getMemos).toHaveBeenCalled()) |
| 108 | 111 |
|
| 109 |
- expect(screen.getByRole('option', { name: '송민지 과장' })).toBeTruthy()
|
|
| 110 |
- expect(screen.getByRole('option', { name: '직접 입력' })).toBeTruthy()
|
|
| 112 |
+ expect(screen.queryByLabelText('담당자')).toBeNull()
|
|
| 111 | 113 |
expect(screen.queryByLabelText('담당자 이름')).toBeNull()
|
| 112 |
- |
|
| 113 |
- fireEvent.change(screen.getByLabelText('담당자'), { target: { value: '__custom__' } })
|
|
| 114 |
- |
|
| 115 |
- expect(screen.getByLabelText('담당자 이름')).toBeTruthy()
|
|
| 116 |
- }) |
|
| 117 |
- |
|
| 118 |
- it('신청기관 담당자, 문정원 담당자, 아이티앤 담당자, 담당 변호사가 모두 있으면 넷 다 선택 옵션으로 나온다', async () => {
|
|
| 119 |
- mocks.getMemos.mockResolvedValue([]) |
|
| 120 |
- |
|
| 121 |
- render( |
|
| 122 |
- <WorkMemos |
|
| 123 |
- org={org({
|
|
| 124 |
- mj: contact({ id: 2, category: 'MJ', name: '김문정' }),
|
|
| 125 |
- itn: contact({ id: 4, category: 'ITN', name: '박아이티' }),
|
|
| 126 |
- lawyer: contact({ id: 3, category: 'LAWYER', name: '이변호' }),
|
|
| 127 |
- })} |
|
| 128 |
- />, |
|
| 129 |
- ) |
|
| 130 |
- |
|
| 131 |
- await waitFor(() => expect(mocks.getMemos).toHaveBeenCalled()) |
|
| 132 |
- |
|
| 133 |
- expect(screen.getByRole('option', { name: '송민지 과장' })).toBeTruthy()
|
|
| 134 |
- expect(screen.getByRole('option', { name: '김문정' })).toBeTruthy()
|
|
| 135 |
- expect(screen.getByRole('option', { name: '박아이티' })).toBeTruthy()
|
|
| 136 |
- expect(screen.getByRole('option', { name: '이변호' })).toBeTruthy()
|
|
| 137 |
- expect(screen.getByRole('option', { name: '직접 입력' })).toBeTruthy()
|
|
| 114 |
+ expect(screen.getByText('작성자는 로그인한 사용자로 자동 기록됩니다.')).toBeTruthy()
|
|
| 138 | 115 |
}) |
| 139 | 116 |
|
| 140 | 117 |
it('메모 내용이 비어 있으면 등록 버튼이 비활성이고, 입력하면 활성화된다', async () => {
|
... | ... | @@ -152,13 +129,11 @@ |
| 152 | 129 |
expect(submit).not.toBeDisabled() |
| 153 | 130 |
}) |
| 154 | 131 |
|
| 155 |
- it('등록을 누르면 선택된 담당자와 본문으로 createMemo를 호출하고 본문을 비운다', async () => {
|
|
| 132 |
+ it('등록을 누르면 본문만 보내고(작성자는 서버가 기록) 본문을 비운다', async () => {
|
|
| 156 | 133 |
mocks.getMemos.mockResolvedValue([]) |
| 157 |
- mocks.createMemo.mockResolvedValue(memo({ id: 2, body: '통화 내용' }))
|
|
| 134 |
+ mocks.createMemo.mockResolvedValue(memo({ id: 2, body: '통화 내용', contactName: 'admin' }))
|
|
| 158 | 135 |
|
| 159 | 136 |
render(<WorkMemos org={org()} />)
|
| 160 |
- |
|
| 161 |
- fireEvent.change(screen.getByLabelText('담당자'), { target: { value: '송민지 과장' } })
|
|
| 162 | 137 |
|
| 163 | 138 |
await waitFor(() => expect(mocks.getMemos).toHaveBeenCalled()) |
| 164 | 139 |
|
... | ... | @@ -166,15 +141,46 @@ |
| 166 | 141 |
fireEvent.click(screen.getByRole('button', { name: '등록' }))
|
| 167 | 142 |
|
| 168 | 143 |
await waitFor(() => {
|
| 169 |
- expect(mocks.createMemo).toHaveBeenCalledWith(1, {
|
|
| 170 |
- contactName: '송민지 과장', |
|
| 171 |
- body: '통화 내용', |
|
| 172 |
- }) |
|
| 144 |
+ expect(mocks.createMemo).toHaveBeenCalledWith(1, { body: '통화 내용' })
|
|
| 173 | 145 |
}) |
| 174 | 146 |
|
| 175 | 147 |
await waitFor(() => {
|
| 176 | 148 |
expect((screen.getByLabelText('메모 내용') as HTMLTextAreaElement).value).toBe('')
|
| 177 | 149 |
}) |
| 150 |
+ // 서버가 기록한 작성자가 목록에 그대로 보인다 |
|
| 151 |
+ expect(screen.getByText('admin')).toBeTruthy()
|
|
| 152 |
+ }) |
|
| 153 |
+ |
|
| 154 |
+ it('수정을 누르면 본문을 그 자리에서 고치고 저장하면 updateMemo를 부른다', async () => {
|
|
| 155 |
+ mocks.getMemos.mockResolvedValue([memo({ id: 7, body: '원래 내용' })])
|
|
| 156 |
+ mocks.updateMemo.mockResolvedValue(memo({ id: 7, body: '고친 내용' }))
|
|
| 157 |
+ |
|
| 158 |
+ render(<WorkMemos org={org()} />)
|
|
| 159 |
+ |
|
| 160 |
+ await waitFor(() => expect(screen.getByText('원래 내용')).toBeTruthy())
|
|
| 161 |
+ |
|
| 162 |
+ fireEvent.click(screen.getByRole('button', { name: '수정' }))
|
|
| 163 |
+ fireEvent.change(screen.getByLabelText('메모 수정'), { target: { value: '고친 내용' } })
|
|
| 164 |
+ fireEvent.click(screen.getByRole('button', { name: '저장' }))
|
|
| 165 |
+ |
|
| 166 |
+ await waitFor(() => expect(mocks.updateMemo).toHaveBeenCalledWith(1, 7, { body: '고친 내용' }))
|
|
| 167 |
+ await waitFor(() => expect(screen.getByText('고친 내용')).toBeTruthy())
|
|
| 168 |
+ }) |
|
| 169 |
+ |
|
| 170 |
+ it('수정 중 취소를 누르면 원래 내용으로 돌아간다', async () => {
|
|
| 171 |
+ mocks.getMemos.mockResolvedValue([memo({ id: 7, body: '원래 내용' })])
|
|
| 172 |
+ |
|
| 173 |
+ render(<WorkMemos org={org()} />)
|
|
| 174 |
+ |
|
| 175 |
+ await waitFor(() => expect(screen.getByText('원래 내용')).toBeTruthy())
|
|
| 176 |
+ |
|
| 177 |
+ fireEvent.click(screen.getByRole('button', { name: '수정' }))
|
|
| 178 |
+ fireEvent.change(screen.getByLabelText('메모 수정'), { target: { value: '버릴 내용' } })
|
|
| 179 |
+ fireEvent.click(screen.getByRole('button', { name: '취소' }))
|
|
| 180 |
+ |
|
| 181 |
+ expect(screen.queryByLabelText('메모 수정')).toBeNull()
|
|
| 182 |
+ expect(screen.getByText('원래 내용')).toBeTruthy()
|
|
| 183 |
+ expect(mocks.updateMemo).not.toHaveBeenCalled() |
|
| 178 | 184 |
}) |
| 179 | 185 |
|
| 180 | 186 |
it('삭제를 누르면 확인을 물어보고, 확인하면 deleteMemo를 호출한다', async () => {
|
--- frontend/src/components/WorkMemos.tsx
+++ frontend/src/components/WorkMemos.tsx
... | ... | @@ -1,7 +1,5 @@ |
| 1 | 1 |
import { useEffect, useState } from 'react'
|
| 2 |
-import { createMemo, deleteMemo, getMemos, type Org, type WorkMemo } from '../api/client'
|
|
| 3 |
- |
|
| 4 |
-const CUSTOM_CONTACT_VALUE = '__custom__' |
|
| 2 |
+import { createMemo, deleteMemo, getMemos, updateMemo, type Org, type WorkMemo } from '../api/client'
|
|
| 5 | 3 |
|
| 6 | 4 |
const dateFormatter = new Intl.DateTimeFormat('ko-KR', {
|
| 7 | 5 |
year: 'numeric', |
... | ... | @@ -15,46 +13,21 @@ |
| 15 | 13 |
return dateFormatter.format(new Date(createdAt)) |
| 16 | 14 |
} |
| 17 | 15 |
|
| 18 |
-function applicantOptionLabel(org: Org): string | null {
|
|
| 19 |
- if (!org.applicant) {
|
|
| 20 |
- return null |
|
| 21 |
- } |
|
| 22 |
- return org.applicant.title ? `${org.applicant.name} ${org.applicant.title}` : org.applicant.name
|
|
| 23 |
-} |
|
| 24 |
- |
|
| 25 |
-/** 신청기관 담당자, 문정원 담당자, 아이티앤 담당자, 담당 변호사 중 배정되어 있는 사람만 후보로 낸다. |
|
| 26 |
- * 이름이 같은 사람이 중복 등록돼 있어도 셀렉트 옵션은 한 번만 보이도록 중복을 없앤다. */ |
|
| 27 |
-function contactOptions(org: Org): string[] {
|
|
| 28 |
- const candidates = [ |
|
| 29 |
- applicantOptionLabel(org), |
|
| 30 |
- org.mj?.name ?? null, |
|
| 31 |
- org.itn?.name ?? null, |
|
| 32 |
- org.lawyer?.name ?? null, |
|
| 33 |
- ] |
|
| 34 |
- const seen = new Set<string>() |
|
| 35 |
- // 요구사항: 선택박스 최상단은 항상 '시스템'이다 (자동/시스템성 메모 구분용) |
|
| 36 |
- const options: string[] = ['시스템'] |
|
| 37 |
- for (const candidate of candidates) {
|
|
| 38 |
- if (!candidate || candidate.trim() === '' || seen.has(candidate)) {
|
|
| 39 |
- continue |
|
| 40 |
- } |
|
| 41 |
- seen.add(candidate) |
|
| 42 |
- options.push(candidate) |
|
| 43 |
- } |
|
| 44 |
- return options |
|
| 45 |
-} |
|
| 46 |
- |
|
| 47 |
-/** 기관관리 상세의 업무메모 탭. 통화 등 담당자와의 연락 내용을 기록/조회/삭제한다. */ |
|
| 16 |
+/** |
|
| 17 |
+ * 기관관리 상세의 업무메모 탭. 통화 등 담당자와의 연락 내용을 기록/수정/삭제한다. |
|
| 18 |
+ * |
|
| 19 |
+ * 작성자는 화면에서 고르지 않는다 - 요청자 확정대로 로그인한 사용자를 서버가 직접 |
|
| 20 |
+ * 기록하므로, 목록에 보이는 작성자는 서버가 채워 준 값이다. |
|
| 21 |
+ */ |
|
| 48 | 22 |
export default function WorkMemos({ org }: { org: Org }) {
|
| 49 |
- const options = contactOptions(org) |
|
| 50 |
- |
|
| 51 | 23 |
const [memos, setMemos] = useState<WorkMemo[]>([]) |
| 52 | 24 |
const [loading, setLoading] = useState(false) |
| 53 |
- const [contactChoice, setContactChoice] = useState(options[0] ?? CUSTOM_CONTACT_VALUE) |
|
| 54 |
- const [customContactName, setCustomContactName] = useState('')
|
|
| 55 | 25 |
const [body, setBody] = useState('')
|
| 56 | 26 |
const [busy, setBusy] = useState(false) |
| 57 | 27 |
const [error, setError] = useState<string | null>(null) |
| 28 |
+ /** 수정 중인 메모 id. null이면 새 메모 작성 모드다. */ |
|
| 29 |
+ const [editingId, setEditingId] = useState<number | null>(null) |
|
| 30 |
+ const [editingBody, setEditingBody] = useState('')
|
|
| 58 | 31 |
|
| 59 | 32 |
useEffect(() => {
|
| 60 | 33 |
let cancelled = false |
... | ... | @@ -77,9 +50,7 @@ |
| 77 | 50 |
} |
| 78 | 51 |
}, [org.id]) |
| 79 | 52 |
|
| 80 |
- const isCustom = contactChoice === CUSTOM_CONTACT_VALUE |
|
| 81 |
- const effectiveContactName = (isCustom ? customContactName : contactChoice).trim() |
|
| 82 |
- const canSubmit = effectiveContactName !== '' && body.trim() !== '' && !busy |
|
| 53 |
+ const canSubmit = body.trim() !== '' && !busy |
|
| 83 | 54 |
|
| 84 | 55 |
async function handleSubmit() {
|
| 85 | 56 |
if (!canSubmit) {
|
... | ... | @@ -88,11 +59,29 @@ |
| 88 | 59 |
setBusy(true) |
| 89 | 60 |
setError(null) |
| 90 | 61 |
try {
|
| 91 |
- const created = await createMemo(org.id, { contactName: effectiveContactName, body })
|
|
| 62 |
+ const created = await createMemo(org.id, { body })
|
|
| 92 | 63 |
setMemos((prev) => [created, ...prev]) |
| 93 | 64 |
setBody('')
|
| 94 | 65 |
} catch (e) {
|
| 95 | 66 |
setError(e instanceof Error ? e.message : '메모 등록에 실패했습니다.') |
| 67 |
+ } finally {
|
|
| 68 |
+ setBusy(false) |
|
| 69 |
+ } |
|
| 70 |
+ } |
|
| 71 |
+ |
|
| 72 |
+ async function handleUpdate(memo: WorkMemo) {
|
|
| 73 |
+ if (editingBody.trim() === '' || busy) {
|
|
| 74 |
+ return |
|
| 75 |
+ } |
|
| 76 |
+ setBusy(true) |
|
| 77 |
+ setError(null) |
|
| 78 |
+ try {
|
|
| 79 |
+ const saved = await updateMemo(org.id, memo.id, { body: editingBody })
|
|
| 80 |
+ setMemos((prev) => prev.map((m) => (m.id === memo.id ? saved : m))) |
|
| 81 |
+ setEditingId(null) |
|
| 82 |
+ setEditingBody('')
|
|
| 83 |
+ } catch (e) {
|
|
| 84 |
+ setError(e instanceof Error ? e.message : '메모 수정에 실패했습니다.') |
|
| 96 | 85 |
} finally {
|
| 97 | 86 |
setBusy(false) |
| 98 | 87 |
} |
... | ... | @@ -113,41 +102,7 @@ |
| 113 | 102 |
return ( |
| 114 | 103 |
<div className="p-4"> |
| 115 | 104 |
<section className="rounded-lg border border-gray-200 p-4"> |
| 116 |
- <div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> |
|
| 117 |
- <div className="flex flex-col gap-1 text-sm"> |
|
| 118 |
- <label htmlFor="memo-contact-choice" className="text-xs text-gray-500"> |
|
| 119 |
- 담당자 |
|
| 120 |
- </label> |
|
| 121 |
- <select |
|
| 122 |
- id="memo-contact-choice" |
|
| 123 |
- value={contactChoice}
|
|
| 124 |
- onChange={(e) => setContactChoice(e.target.value)}
|
|
| 125 |
- className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
|
| 126 |
- > |
|
| 127 |
- {options.map((option) => (
|
|
| 128 |
- <option key={option} value={option}>
|
|
| 129 |
- {option}
|
|
| 130 |
- </option> |
|
| 131 |
- ))} |
|
| 132 |
- <option value={CUSTOM_CONTACT_VALUE}>직접 입력</option>
|
|
| 133 |
- </select> |
|
| 134 |
- </div> |
|
| 135 |
- |
|
| 136 |
- {isCustom && (
|
|
| 137 |
- <div className="flex flex-col gap-1 text-sm"> |
|
| 138 |
- <label htmlFor="memo-contact-name" className="text-xs text-gray-500"> |
|
| 139 |
- 담당자 이름 |
|
| 140 |
- </label> |
|
| 141 |
- <input |
|
| 142 |
- id="memo-contact-name" |
|
| 143 |
- type="text" |
|
| 144 |
- value={customContactName}
|
|
| 145 |
- onChange={(e) => setCustomContactName(e.target.value)}
|
|
| 146 |
- className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
|
| 147 |
- /> |
|
| 148 |
- </div> |
|
| 149 |
- )} |
|
| 150 |
- </div> |
|
| 105 |
+ <p className="text-xs text-gray-400">작성자는 로그인한 사용자로 자동 기록됩니다.</p> |
|
| 151 | 106 |
|
| 152 | 107 |
<div className="mt-3 flex flex-col gap-1 text-sm"> |
| 153 | 108 |
<label htmlFor="memo-body" className="text-xs text-gray-500"> |
... | ... | @@ -193,15 +148,61 @@ |
| 193 | 148 |
<span className="text-xs text-gray-300">·</span> |
| 194 | 149 |
<span className="text-sm font-medium text-gray-900">{memo.contactName}</span>
|
| 195 | 150 |
</div> |
| 196 |
- <button |
|
| 197 |
- type="button" |
|
| 198 |
- onClick={() => void handleDelete(memo)}
|
|
| 199 |
- className="rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-50" |
|
| 200 |
- > |
|
| 201 |
- 삭제 |
|
| 202 |
- </button> |
|
| 151 |
+ <div className="flex gap-1"> |
|
| 152 |
+ {editingId === memo.id ? (
|
|
| 153 |
+ <> |
|
| 154 |
+ <button |
|
| 155 |
+ type="button" |
|
| 156 |
+ disabled={busy || editingBody.trim() === ''}
|
|
| 157 |
+ onClick={() => void handleUpdate(memo)}
|
|
| 158 |
+ className="rounded-md bg-blue-600 px-2 py-0.5 text-xs text-white disabled:bg-gray-300" |
|
| 159 |
+ > |
|
| 160 |
+ 저장 |
|
| 161 |
+ </button> |
|
| 162 |
+ <button |
|
| 163 |
+ type="button" |
|
| 164 |
+ disabled={busy}
|
|
| 165 |
+ onClick={() => {
|
|
| 166 |
+ setEditingId(null) |
|
| 167 |
+ setEditingBody('')
|
|
| 168 |
+ }} |
|
| 169 |
+ className="rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-50" |
|
| 170 |
+ > |
|
| 171 |
+ 취소 |
|
| 172 |
+ </button> |
|
| 173 |
+ </> |
|
| 174 |
+ ) : ( |
|
| 175 |
+ <button |
|
| 176 |
+ type="button" |
|
| 177 |
+ onClick={() => {
|
|
| 178 |
+ setEditingId(memo.id) |
|
| 179 |
+ setEditingBody(memo.body) |
|
| 180 |
+ }} |
|
| 181 |
+ className="rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-50" |
|
| 182 |
+ > |
|
| 183 |
+ 수정 |
|
| 184 |
+ </button> |
|
| 185 |
+ )} |
|
| 186 |
+ <button |
|
| 187 |
+ type="button" |
|
| 188 |
+ onClick={() => void handleDelete(memo)}
|
|
| 189 |
+ className="rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-50" |
|
| 190 |
+ > |
|
| 191 |
+ 삭제 |
|
| 192 |
+ </button> |
|
| 193 |
+ </div> |
|
| 203 | 194 |
</div> |
| 204 |
- <p className="mt-1.5 whitespace-pre-wrap text-sm text-gray-800">{memo.body}</p>
|
|
| 195 |
+ {editingId === memo.id ? (
|
|
| 196 |
+ <textarea |
|
| 197 |
+ aria-label="메모 수정" |
|
| 198 |
+ rows={3}
|
|
| 199 |
+ value={editingBody}
|
|
| 200 |
+ onChange={(e) => setEditingBody(e.target.value)}
|
|
| 201 |
+ className="mt-1.5 w-full rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
|
| 202 |
+ /> |
|
| 203 |
+ ) : ( |
|
| 204 |
+ <p className="mt-1.5 whitespace-pre-wrap text-sm text-gray-800">{memo.body}</p>
|
|
| 205 |
+ )} |
|
| 205 | 206 |
</li> |
| 206 | 207 |
))} |
| 207 | 208 |
</ul> |
+++ frontend/src/help/HelpButton.test.tsx
... | ... | @@ -0,0 +1,71 @@ |
| 1 | +import { fireEvent, render, screen } from '@testing-library/react' | |
| 2 | +import { describe, expect, it, vi } from 'vitest' | |
| 3 | +import HelpButton from './HelpButton' | |
| 4 | +import { HELP } from './helpContent' | |
| 5 | + | |
| 6 | +describe('HelpButton', () => { | |
| 7 | + it('물음표를 누르면 그 기능의 설명서가 열린다', () => { | |
| 8 | + render(<HelpButton topic="dashboard.kanban" />) | |
| 9 | + | |
| 10 | + expect(screen.queryByRole('dialog')).toBeNull() | |
| 11 | + fireEvent.click(screen.getByRole('button', { name: '단계별 현황 (칸반) 도움말' })) | |
| 12 | + | |
| 13 | + const dialog = screen.getByRole('dialog') | |
| 14 | + expect(dialog.textContent).toContain('기관 카드를 끌어다 놓아') | |
| 15 | + expect(dialog.textContent).toContain('사용 방법') | |
| 16 | + expect(dialog.textContent).toContain('알아둘 점') | |
| 17 | + }) | |
| 18 | + | |
| 19 | + it('캡처 이미지를 캡션과 함께 보여준다', () => { | |
| 20 | + render(<HelpButton topic="dashboard.kpi" />) | |
| 21 | + fireEvent.click(screen.getByRole('button', { name: 'KPI 타일 도움말' })) | |
| 22 | + | |
| 23 | + const image = screen.getByAltText('대시보드 상단 KPI 타일 7개') | |
| 24 | + expect(image).toHaveAttribute('src', '/help/dashboard-kpi.png') | |
| 25 | + expect(screen.getByText(/왼쪽부터 신청/)).toBeTruthy() | |
| 26 | + }) | |
| 27 | + | |
| 28 | + it('닫기·바깥 클릭·ESC로 닫힌다', () => { | |
| 29 | + render(<HelpButton topic="system.reset" />) | |
| 30 | + const open = () => fireEvent.click(screen.getByRole('button', { name: '채널 초기화 도움말' })) | |
| 31 | + | |
| 32 | + open() | |
| 33 | + fireEvent.click(screen.getByRole('button', { name: '닫기' })) | |
| 34 | + expect(screen.queryByRole('dialog')).toBeNull() | |
| 35 | + | |
| 36 | + open() | |
| 37 | + fireEvent.click(screen.getByRole('dialog')) | |
| 38 | + expect(screen.queryByRole('dialog')).toBeNull() | |
| 39 | + | |
| 40 | + open() | |
| 41 | + fireEvent.keyDown(window, { key: 'Escape' }) | |
| 42 | + expect(screen.queryByRole('dialog')).toBeNull() | |
| 43 | + }) | |
| 44 | + | |
| 45 | + it('카드나 표 줄 안에 있어도 상위 클릭으로 번지지 않는다', () => { | |
| 46 | + const onRowClick = vi.fn() | |
| 47 | + render( | |
| 48 | + <div onClick={onRowClick}> | |
| 49 | + <HelpButton topic="dashboard.recent" /> | |
| 50 | + </div>, | |
| 51 | + ) | |
| 52 | + | |
| 53 | + fireEvent.click(screen.getByRole('button', { name: '최근 진행 기관 도움말' })) | |
| 54 | + | |
| 55 | + expect(screen.getByRole('dialog')).toBeTruthy() | |
| 56 | + expect(onRowClick).not.toHaveBeenCalled() | |
| 57 | + }) | |
| 58 | + | |
| 59 | + it('없는 topic이면 아무것도 그리지 않는다', () => { | |
| 60 | + const { container } = render(<HelpButton topic="없는.항목" />) | |
| 61 | + expect(container.innerHTML).toBe('') | |
| 62 | + }) | |
| 63 | + | |
| 64 | + it('모든 도움말 항목은 제목·요약을 갖는다', () => { | |
| 65 | + for (const [key, topic] of Object.entries(HELP)) { | |
| 66 | + expect(topic.title, key).toBeTruthy() | |
| 67 | + expect(topic.summary, key).toBeTruthy() | |
| 68 | + expect(topic.steps?.length ?? topic.cautions?.length ?? 0, key).toBeGreaterThan(0) | |
| 69 | + } | |
| 70 | + }) | |
| 71 | +}) |
+++ frontend/src/help/HelpButton.tsx
... | ... | @@ -0,0 +1,134 @@ |
| 1 | +import { useEffect, useState } from 'react' | |
| 2 | +import { HELP } from './helpContent' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * 버튼·영역 옆에 붙는 물음표. 누르면 그 기능의 설명서 모달이 열린다. | |
| 6 | + * | |
| 7 | + * 설명 문구는 helpContent.ts 한 곳에만 둔다 - 화면 곳곳에 흩어 두면 화면을 고쳤을 때 | |
| 8 | + * 어떤 설명이 낡았는지 찾을 수 없게 된다. | |
| 9 | + * | |
| 10 | + * 캡처 이미지는 public/help 아래 실제 화면이다. 파일이 아직 없거나 깨져도 설명은 | |
| 11 | + * 그대로 읽히도록 이미지 로드 실패는 조용히 감춘다. | |
| 12 | + */ | |
| 13 | + | |
| 14 | +interface Props { | |
| 15 | + /** helpContent.ts의 키. 없는 키면 아무것도 그리지 않는다(잘못된 키로 화면이 깨지지 않게). */ | |
| 16 | + topic: string | |
| 17 | +} | |
| 18 | + | |
| 19 | +export default function HelpButton({ topic }: Props) { | |
| 20 | + const [open, setOpen] = useState(false) | |
| 21 | + const help = HELP[topic] | |
| 22 | + | |
| 23 | + useEffect(() => { | |
| 24 | + if (!open) { | |
| 25 | + return | |
| 26 | + } | |
| 27 | + const onKey = (e: KeyboardEvent) => { | |
| 28 | + if (e.key === 'Escape') { | |
| 29 | + setOpen(false) | |
| 30 | + } | |
| 31 | + } | |
| 32 | + window.addEventListener('keydown', onKey) | |
| 33 | + return () => window.removeEventListener('keydown', onKey) | |
| 34 | + }, [open]) | |
| 35 | + | |
| 36 | + if (!help) { | |
| 37 | + return null | |
| 38 | + } | |
| 39 | + | |
| 40 | + return ( | |
| 41 | + <> | |
| 42 | + <button | |
| 43 | + type="button" | |
| 44 | + aria-label={`${help.title} 도움말`} | |
| 45 | + title={`${help.title} 도움말`} | |
| 46 | + onClick={(e) => { | |
| 47 | + // 카드/표 줄 안에 놓이는 경우가 있어 상위의 클릭(기관 열기 등)까지 번지면 안 된다. | |
| 48 | + e.stopPropagation() | |
| 49 | + setOpen(true) | |
| 50 | + }} | |
| 51 | + className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-gray-300 text-[10px] leading-none text-gray-500 hover:border-blue-400 hover:text-blue-600" | |
| 52 | + > | |
| 53 | + ? | |
| 54 | + </button> | |
| 55 | + | |
| 56 | + {open && ( | |
| 57 | + <div | |
| 58 | + role="dialog" | |
| 59 | + aria-label={`${help.title} 설명서`} | |
| 60 | + onClick={() => setOpen(false)} | |
| 61 | + className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 p-6" | |
| 62 | + > | |
| 63 | + <div | |
| 64 | + onClick={(e) => e.stopPropagation()} | |
| 65 | + className="w-full max-w-4xl rounded-lg bg-white shadow-xl" | |
| 66 | + > | |
| 67 | + <div className="flex items-start justify-between gap-3 border-b border-gray-200 px-5 py-4"> | |
| 68 | + <div> | |
| 69 | + <h2 className="text-base font-semibold">{help.title}</h2> | |
| 70 | + <p className="mt-1 text-sm text-gray-600">{help.summary}</p> | |
| 71 | + </div> | |
| 72 | + <button | |
| 73 | + type="button" | |
| 74 | + aria-label="닫기" | |
| 75 | + onClick={() => setOpen(false)} | |
| 76 | + className="shrink-0 rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50" | |
| 77 | + > | |
| 78 | + 닫기 | |
| 79 | + </button> | |
| 80 | + </div> | |
| 81 | + | |
| 82 | + <div className="space-y-5 px-5 py-4"> | |
| 83 | + {help.images?.map((image) => ( | |
| 84 | + <figure key={image.src} className="space-y-1.5"> | |
| 85 | + <img | |
| 86 | + src={image.src} | |
| 87 | + alt={image.alt} | |
| 88 | + onError={(e) => { | |
| 89 | + ;(e.currentTarget.parentElement as HTMLElement).style.display = 'none' | |
| 90 | + }} | |
| 91 | + // 작은 캡처(경고창 등)가 모달 폭에 맞춰 늘어나면 흐려진다. | |
| 92 | + // 원본보다 크게 키우지 않고 가운데 정렬만 한다. | |
| 93 | + className="mx-auto max-h-[26rem] w-auto max-w-full rounded-md border border-gray-200" | |
| 94 | + /> | |
| 95 | + <figcaption className="text-xs text-gray-500">{image.caption}</figcaption> | |
| 96 | + </figure> | |
| 97 | + ))} | |
| 98 | + | |
| 99 | + {help.steps && ( | |
| 100 | + <div> | |
| 101 | + <h3 className="text-sm font-semibold">사용 방법</h3> | |
| 102 | + <ol className="mt-2 space-y-1.5"> | |
| 103 | + {help.steps.map((step, i) => ( | |
| 104 | + <li key={step} className="flex gap-2 text-sm text-gray-700"> | |
| 105 | + <span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-gray-100 text-xs font-medium text-gray-600"> | |
| 106 | + {i + 1} | |
| 107 | + </span> | |
| 108 | + <span>{step}</span> | |
| 109 | + </li> | |
| 110 | + ))} | |
| 111 | + </ol> | |
| 112 | + </div> | |
| 113 | + )} | |
| 114 | + | |
| 115 | + {help.cautions && ( | |
| 116 | + <div> | |
| 117 | + <h3 className="text-sm font-semibold text-amber-700">알아둘 점</h3> | |
| 118 | + <ul className="mt-2 space-y-1.5 rounded-md bg-amber-50 p-3"> | |
| 119 | + {help.cautions.map((caution) => ( | |
| 120 | + <li key={caution} className="flex gap-2 text-sm text-amber-900"> | |
| 121 | + <span aria-hidden="true">·</span> | |
| 122 | + <span>{caution}</span> | |
| 123 | + </li> | |
| 124 | + ))} | |
| 125 | + </ul> | |
| 126 | + </div> | |
| 127 | + )} | |
| 128 | + </div> | |
| 129 | + </div> | |
| 130 | + </div> | |
| 131 | + )} | |
| 132 | + </> | |
| 133 | + ) | |
| 134 | +} |
+++ frontend/src/help/helpContent.ts
... | ... | @@ -0,0 +1,386 @@ |
| 1 | +/** | |
| 2 | + * 화면별 도움말 본문. 버튼 옆 물음표(HelpButton)가 이 표에서 topic을 찾아 모달에 그린다. | |
| 3 | + * | |
| 4 | + * 문구는 실제 동작을 그대로 적는다 - 시안이나 계획이 아니라 지금 코드가 하는 일이다. | |
| 5 | + * 화면을 고치면 여기도 같이 고쳐야 한다(그래서 설명이 한 곳에만 있다). | |
| 6 | + * | |
| 7 | + * image는 public/help 아래 실제 화면 캡처다. 빌드하면 static/help로 복사돼 /help/*.png로 열린다. | |
| 8 | + */ | |
| 9 | + | |
| 10 | +export interface HelpImage { | |
| 11 | + src: string | |
| 12 | + alt: string | |
| 13 | + /** 캡처 아래에 붙는 한 줄 설명. 어디를 보라는 안내. */ | |
| 14 | + caption: string | |
| 15 | +} | |
| 16 | + | |
| 17 | +export interface HelpTopic { | |
| 18 | + title: string | |
| 19 | + /** 한 문장 요약. 모달 맨 위에 크게 나온다. */ | |
| 20 | + summary: string | |
| 21 | + images?: HelpImage[] | |
| 22 | + /** 순서가 있는 사용 절차. */ | |
| 23 | + steps?: string[] | |
| 24 | + /** 알아둬야 하는 규칙·주의. 경고색으로 나온다. */ | |
| 25 | + cautions?: string[] | |
| 26 | +} | |
| 27 | + | |
| 28 | +export const HELP: Record<string, HelpTopic> = { | |
| 29 | + 'dashboard.kpi': { | |
| 30 | + title: 'KPI 타일', | |
| 31 | + summary: '전체 기관의 진행 상황을 7개 숫자로 요약한다. 실제 데이터를 그대로 센 값이다.', | |
| 32 | + images: [ | |
| 33 | + { | |
| 34 | + src: '/help/dashboard-kpi.png', | |
| 35 | + alt: '대시보드 상단 KPI 타일 7개', | |
| 36 | + caption: '왼쪽부터 신청 · 서류제출 · 권리확인 · 권리처리 · 미해결 RE · 보고서 작성 · 완료', | |
| 37 | + }, | |
| 38 | + ], | |
| 39 | + steps: [ | |
| 40 | + '신청 — 기관 명부에 등록된 전체 기관 수', | |
| 41 | + '서류제출 — ②목록접수 단계 이상에 도달한 기관 수', | |
| 42 | + '권리확인 / 권리처리 — 전 기관 건수 합계(완료 / 전체)와 진척률', | |
| 43 | + '보고서 작성 — ⑫보고서 작성 단계에 도달한 기관 수', | |
| 44 | + '완료 — ⑪법률검토 최종완료 이상에 도달한 기관 수', | |
| 45 | + ], | |
| 46 | + cautions: [ | |
| 47 | + '미해결 RE는 아직 데이터가 없어 항상 0으로 표시된다(자리만 잡아 둔 상태).', | |
| 48 | + '권리확인·권리처리 진척률은 소수점을 버리고 정수로 반올림한다.', | |
| 49 | + ], | |
| 50 | + }, | |
| 51 | + | |
| 52 | + 'dashboard.kanban': { | |
| 53 | + title: '단계별 현황 (칸반)', | |
| 54 | + summary: '기관 카드를 끌어다 놓아 진행단계를 바꾼다. 맨 왼쪽 "단계 미지정"은 채널을 아직 만들지 않은 기관이다.', | |
| 55 | + images: [ | |
| 56 | + { | |
| 57 | + src: '/help/dashboard-kanban.png', | |
| 58 | + alt: '단계 미지정 컬럼과 ①신청~ 컬럼들이 있는 칸반', | |
| 59 | + caption: '컬럼은 단계 미지정 + ①~⑫ 순서다. 오른쪽으로 스크롤하면 나머지 단계가 나온다', | |
| 60 | + }, | |
| 61 | + { | |
| 62 | + src: '/help/dashboard-drag-warning.png', | |
| 63 | + alt: '채널을 먼저 생성해 주세요 경고창', | |
| 64 | + caption: '단계 미지정 카드를 끌면 이동하지 않고 이 경고창이 뜬다', | |
| 65 | + }, | |
| 66 | + ], | |
| 67 | + steps: [ | |
| 68 | + '카드를 옮기려는 단계 컬럼 위로 끌어다 놓는다. 놓는 순간 그 기관의 현재 단계가 바뀐다.', | |
| 69 | + '카드를 클릭하면 기관관리 화면으로 넘어가 그 기관을 연다.', | |
| 70 | + '컬럼 아래 [+N건 더보기]를 누르면 그 단계의 나머지 기관이 모두 펼쳐진다.', | |
| 71 | + '위쪽 필터 5개(업무 구간·담당 변호사·현재 단계·진행 상태·기관명)로 카드를 걸러낼 수 있다.', | |
| 72 | + ], | |
| 73 | + cautions: [ | |
| 74 | + '단계 미지정 카드는 끌어도 옮겨지지 않는다. 채널이 없으면 게시글·자료·권리검토가 전부 동작하지 않아 단계만 올려도 빈 껍데기가 되기 때문이다. 채널관리에서 채널을 만들면 자동으로 ①신청으로 들어간다.', | |
| 75 | + '단계 미지정 컬럼에는 다른 카드를 놓을 수 없다. 단계를 없애는 기능은 없다(초기화는 시스템관리에서 한다).', | |
| 76 | + '카드의 D+숫자는 현재 단계로 넘어온 뒤 지난 날짜다.', | |
| 77 | + ], | |
| 78 | + }, | |
| 79 | + | |
| 80 | + 'dashboard.recent': { | |
| 81 | + title: '최근 진행 기관', | |
| 82 | + summary: '최근에 변동이 있었던 기관을 위에서부터 보여준다. 기본 8개이며 아래에서 더 펼칠 수 있다.', | |
| 83 | + images: [ | |
| 84 | + { | |
| 85 | + src: '/help/dashboard-recent.png', | |
| 86 | + alt: '최근 진행 기관 표와 필터', | |
| 87 | + caption: '필터 5개 + [초기화], 아래에 [더보기]와 [전체 기관 보기]', | |
| 88 | + }, | |
| 89 | + ], | |
| 90 | + steps: [ | |
| 91 | + '표의 줄을 클릭하면 기관관리 화면으로 넘어가 그 기관을 연다.', | |
| 92 | + '[더보기]를 누르면 8개씩 더 보여준다.', | |
| 93 | + '[전체 기관 보기 →]는 기관관리 화면으로 이동한다.', | |
| 94 | + '[초기화]는 검색어와 필터 5개를 모두 지운다.', | |
| 95 | + ], | |
| 96 | + cautions: [ | |
| 97 | + '"최근 변경"은 단계 변경·권리확인/처리 수정·업무메모·담당자 배정 중 가장 최근 시각이다.', | |
| 98 | + '자료접수 상태 필터의 "접수"는 권리확인 자료가 1건 이상 올라온 기관을 뜻한다.', | |
| 99 | + ], | |
| 100 | + }, | |
| 101 | + | |
| 102 | + 'channel.seed': { | |
| 103 | + title: '신청목록 시트 올리기', | |
| 104 | + summary: '엑셀 신청목록 시트를 올려 기관 명부를 만든다. 여러 번 올려도 안전하다.', | |
| 105 | + steps: [ | |
| 106 | + '[신청목록 시트 올리기]를 누르고 xlsx/xlsm 파일을 고른다.', | |
| 107 | + '연번1_연번2(예: 001_00)를 기관 코드로 삼아 기관을 만들거나 이미 있으면 갱신한다.', | |
| 108 | + '결과로 생성 / 갱신 / 전체 건수가 표시된다.', | |
| 109 | + ], | |
| 110 | + cautions: [ | |
| 111 | + '같은 파일을 다시 올려도 기관이 중복 생성되지 않는다(코드 기준으로 덮어쓴다).', | |
| 112 | + '담당자 정보는 이 파일에서 가져오지 않는다. 담당자는 담당자관리에서만 등록한다.', | |
| 113 | + ], | |
| 114 | + }, | |
| 115 | + | |
| 116 | + 'channel.assign': { | |
| 117 | + title: '담당자 배정', | |
| 118 | + summary: '이미 등록된 담당자를 신청기관 / 문정원 / 아이티앤 / 변호사 네 자리에 배정한다.', | |
| 119 | + images: [ | |
| 120 | + { | |
| 121 | + src: '/help/channel-assign.png', | |
| 122 | + alt: '채널관리의 담당자 4카드와 채널 생성 버튼', | |
| 123 | + caption: '카드마다 [선택]으로 담당자를 고르고, [해제]로 비운다', | |
| 124 | + }, | |
| 125 | + ], | |
| 126 | + steps: [ | |
| 127 | + '카드의 [선택]을 누르면 담당자 목록 모달이 열린다. 이름·소속으로 검색해 고른다.', | |
| 128 | + '변호사 카드에서는 배정일도 함께 입력한다.', | |
| 129 | + '[저장]을 누르면 네 자리 배정이 한 번에 저장된다.', | |
| 130 | + ], | |
| 131 | + cautions: [ | |
| 132 | + '여기서는 담당자를 새로 만들 수 없다. 목록에 없으면 담당자관리에서 먼저 등록한다.', | |
| 133 | + '신청기관 담당자가 비어 있으면 채널을 만들 수 없다.', | |
| 134 | + ], | |
| 135 | + }, | |
| 136 | + | |
| 137 | + 'channel.provision': { | |
| 138 | + title: '채널 생성', | |
| 139 | + summary: '기관마다 (문정원) · (법률검토) 두 채널을 만들고, 담당자 계정·최초 안내글까지 한 번에 준비한다.', | |
| 140 | + images: [ | |
| 141 | + { | |
| 142 | + src: '/help/channel-provision.png', | |
| 143 | + alt: '담당자 카드 아래의 채널 생성 버튼', | |
| 144 | + caption: '신청기관 담당자가 비어 있으면 버튼이 잠긴다. 누르면 두 채널의 처리 결과가 표시된다', | |
| 145 | + }, | |
| 146 | + ], | |
| 147 | + steps: [ | |
| 148 | + '담당자 배정을 저장한 뒤 [채널 생성]을 누른다.', | |
| 149 | + '(문정원) 채널과 (법률검토) 채널이 만들어진다.', | |
| 150 | + '담당자의 Mattermost 계정이 없으면 이메일 앞부분을 아이디로 자동 생성해 팀에 넣는다.', | |
| 151 | + '두 채널에 담당자를 넣고, 담당자 정보를 담은 최초 안내글을 올려 고정한다.', | |
| 152 | + '채널이 만들어지면 진행단계가 ①신청으로 자동 시작된다.', | |
| 153 | + ], | |
| 154 | + cautions: [ | |
| 155 | + '여러 번 눌러도 안전하다. 이미 있는 채널은 다시 만들지 않고 없는 것만 만든다.', | |
| 156 | + '한쪽만 실패했으면 성공한 채널은 그대로 남는다. 다시 눌러 없는 쪽만 만들면 된다.', | |
| 157 | + '자동 생성 계정의 비밀번호는 공통값이다. 실제 담당자에게 전달 후 변경하도록 안내해야 한다.', | |
| 158 | + ], | |
| 159 | + }, | |
| 160 | + | |
| 161 | + 'org.stage': { | |
| 162 | + title: '진행단계 변경', | |
| 163 | + summary: '12단계 업무 진행 상태를 사람이 직접 바꾼다. 자동으로 넘어가는 일은 없다.', | |
| 164 | + images: [ | |
| 165 | + { | |
| 166 | + src: '/help/org-stage.png', | |
| 167 | + alt: '업무단계 현황 스트립과 진행단계 변경 버튼', | |
| 168 | + caption: '완료된 단계는 진하게, 현재 단계는 파랗게, 남은 단계는 회색으로 보인다', | |
| 169 | + }, | |
| 170 | + ], | |
| 171 | + steps: [ | |
| 172 | + '현재 단계(파란 원)를 클릭하면 다음 단계로 넘어갈지 물어본다.', | |
| 173 | + '특정 단계로 바로 가려면 오른쪽 위 [진행단계 변경]을 눌러 단계를 고른다.', | |
| 174 | + '단계마다 그 단계에 도달한 날짜가 아래에 표시된다.', | |
| 175 | + ], | |
| 176 | + cautions: [ | |
| 177 | + '자료를 올리거나 게시글을 써도 단계는 자동으로 바뀌지 않는다(요청자 확정 사항).', | |
| 178 | + '채널을 만들기 전에는 모든 단계가 회색이며 변경할 수 없다.', | |
| 179 | + '단계를 되돌릴 수도 있다. 변경 이력은 타임라인 탭에 모두 남는다.', | |
| 180 | + ], | |
| 181 | + }, | |
| 182 | + | |
| 183 | + 'org.firstPost': { | |
| 184 | + title: '최초 게시글 보기', | |
| 185 | + summary: '두 채널에 고정(핀)된 게시물을 한 번에 확인한다. 채널을 만들 때 올린 최초 안내글이 여기 보인다.', | |
| 186 | + steps: [ | |
| 187 | + '[최초 게시글 보기]를 누르면 문정원·법률검토 채널의 고정 게시물을 함께 보여준다.', | |
| 188 | + '[채팅 탭에서 보기]를 누르면 채팅 탭으로 이동한다.', | |
| 189 | + ], | |
| 190 | + cautions: [ | |
| 191 | + '채널을 만들기 전에는 버튼이 잠긴다.', | |
| 192 | + '공지를 다른 글로 바꾸면 여기에도 바뀐 글이 보인다(채널에 고정된 글을 그대로 읽기 때문).', | |
| 193 | + ], | |
| 194 | + }, | |
| 195 | + | |
| 196 | + 'org.chat': { | |
| 197 | + title: '채팅 탭', | |
| 198 | + summary: 'Mattermost 채널에 글과 파일을 올린다. (문정원) · (법률검토) 채널을 탭으로 오간다.', | |
| 199 | + images: [ | |
| 200 | + { | |
| 201 | + src: '/help/org-chat.png', | |
| 202 | + alt: '채팅 탭 아래쪽 입력창 - 파일 첨부, 공지로 등록, 보내기', | |
| 203 | + caption: '패널 아래 입력창. Enter로 전송, Shift+Enter로 줄바꿈이다', | |
| 204 | + }, | |
| 205 | + ], | |
| 206 | + steps: [ | |
| 207 | + '[문정원] / [법률검토] 칩으로 채널을 고른다.', | |
| 208 | + '내용을 쓰고 [보내기]를 누른다. [파일 첨부] 또는 창에 파일을 끌어다 놓아 첨부할 수 있다.', | |
| 209 | + '중요한 글은 [공지로 등록]을 누른다. 기존 공지는 해제되고 새 글이 채널 상단에 고정된다.', | |
| 210 | + '공지를 내리려면 [공지 해제]를 누른다.', | |
| 211 | + ], | |
| 212 | + cautions: [ | |
| 213 | + '공지는 채널에 하나만 유지된다. 새로 등록하면 이전 공지는 자동으로 풀린다.', | |
| 214 | + '화면은 3초마다 새 글을 확인한다. 다른 사람이 쓴 글도 곧 나타난다.', | |
| 215 | + '올린 파일은 자료 탭에도 함께 나타난다.', | |
| 216 | + ], | |
| 217 | + }, | |
| 218 | + | |
| 219 | + 'org.files': { | |
| 220 | + title: '자료 탭', | |
| 221 | + summary: '두 채널에 올라온 첨부파일을 모아 최신순으로 보여준다.', | |
| 222 | + steps: [ | |
| 223 | + '[문정원] / [법률검토] 칩으로 채널을 고른다.', | |
| 224 | + '파일 이름을 클릭하면 내려받는다.', | |
| 225 | + '[새로고침]을 누르면 방금 올린 파일까지 다시 읽는다.', | |
| 226 | + ], | |
| 227 | + cautions: [ | |
| 228 | + '여기서 파일을 올리거나 지울 수는 없다. 올리기는 채팅 탭에서 한다.', | |
| 229 | + '목록은 Mattermost 채널을 그대로 읽어 만든다. 채널에서 지운 파일은 여기서도 사라진다.', | |
| 230 | + ], | |
| 231 | + }, | |
| 232 | + | |
| 233 | + 'org.review': { | |
| 234 | + title: '권리확인 탭', | |
| 235 | + summary: '조사원이 만든 권리확인 파일을 올리고, 건별로 변호사 판단을 입력한다.', | |
| 236 | + images: [ | |
| 237 | + { | |
| 238 | + src: '/help/org-review.png', | |
| 239 | + alt: '권리확인 목록과 엑셀 업로드·다운로드 버튼', | |
| 240 | + caption: '오른쪽 위 [엑셀 업로드]로 자료를 넣고, 목록에서 건을 클릭해 판단을 입력한다', | |
| 241 | + }, | |
| 242 | + ], | |
| 243 | + steps: [ | |
| 244 | + '조사원이 보낸 xlsm 파일을 올린다. 1행이 1건으로 들어온다.', | |
| 245 | + '목록에서 건을 클릭하면 상세가 열린다.', | |
| 246 | + '권리확인 대분류·세부를 고르면 처리결과가 자동으로 제안된다. 필요하면 직접 바꾼다.', | |
| 247 | + '의견·판단근거를 쓰고 [저장]한다.', | |
| 248 | + '[엑셀 다운로드]로 지금 상태를 파일로 내린다.', | |
| 249 | + ], | |
| 250 | + cautions: [ | |
| 251 | + '같은 파일을 다시 올리면 조사원이 채운 영역은 파일 내용으로 덮어쓰지만, 웹에서 수정한 값은 그대로 보존된다.', | |
| 252 | + '병합 기준은 파일의 행 순서다(순번이 중복된 파일이 있어 순번으로 맞추지 않는다).', | |
| 253 | + '조사원 영역 중에서는 첨부 여부와 기존 공공누리 유형만 웹에서 고칠 수 있다.', | |
| 254 | + ], | |
| 255 | + }, | |
| 256 | + | |
| 257 | + 'org.process': { | |
| 258 | + title: '권리처리 탭', | |
| 259 | + summary: '권리처리 파일을 올리고 건별로 처리 결과를 확정한다.', | |
| 260 | + steps: [ | |
| 261 | + '권리처리 xlsm 파일을 올린다.', | |
| 262 | + '건을 클릭해 상세를 열고 최종의견·판단근거를 쓴다.', | |
| 263 | + '[처리등록]을 누르면 처리상태가 처리완료로 바뀌고 시각이 기록된다.', | |
| 264 | + '처리상태·검색으로 목록을 걸러 볼 수 있다.', | |
| 265 | + ], | |
| 266 | + cautions: [ | |
| 267 | + '기관 카드와 대시보드의 권리처리 숫자는 처리완료 건수 기준이다.', | |
| 268 | + '권리확인과 마찬가지로 웹에서 수정한 값은 재업로드해도 보존된다.', | |
| 269 | + ], | |
| 270 | + }, | |
| 271 | + | |
| 272 | + 'org.timeline': { | |
| 273 | + title: '타임라인 탭', | |
| 274 | + summary: '이 기관에서 일어난 일을 한 줄씩 시간순으로 모아 보여준다.', | |
| 275 | + steps: [ | |
| 276 | + '단계 변경, 파일 등록, 업무메모가 한 줄씩 날짜별로 묶여 나온다.', | |
| 277 | + '왼쪽 색 점으로 종류를 구분한다.', | |
| 278 | + ], | |
| 279 | + cautions: [ | |
| 280 | + '읽기 전용이다. 여기서 지우거나 고칠 수는 없다.', | |
| 281 | + 'Mattermost 조회가 실패하면 파일 항목만 빠지고 나머지는 그대로 보인다.', | |
| 282 | + ], | |
| 283 | + }, | |
| 284 | + | |
| 285 | + 'org.memo': { | |
| 286 | + title: '업무메모', | |
| 287 | + summary: '기관별 내부 메모를 남긴다. Mattermost로는 나가지 않는다.', | |
| 288 | + steps: [ | |
| 289 | + '내용을 쓰고 [등록]한다. 작성자는 로그인한 사용자로 자동 기록된다.', | |
| 290 | + '[수정]을 누르면 그 자리에서 본문을 고칠 수 있다. [저장]으로 반영, [취소]로 되돌린다.', | |
| 291 | + '[삭제]로 메모를 지운다.', | |
| 292 | + ], | |
| 293 | + cautions: [ | |
| 294 | + '작성자를 직접 고르는 칸은 없앴다(요청자 확정). 서버가 로그인 사용자를 기록한다.', | |
| 295 | + '수정해도 작성자와 작성시각은 처음 값이 유지된다.', | |
| 296 | + '작성한 메모는 타임라인 탭에도 함께 나타난다.', | |
| 297 | + ], | |
| 298 | + }, | |
| 299 | + | |
| 300 | + 'projects.overview': { | |
| 301 | + title: '사업관리', | |
| 302 | + summary: '3단계 법률검토 대상 기관 현황을 한 표로 보고, 기관마다 연락 이력과 최종 보고서를 관리한다.', | |
| 303 | + steps: [ | |
| 304 | + '검색·현재 단계·연락 이력 유무로 기관을 걸러 본다.', | |
| 305 | + '기관명을 클릭하면 기관관리 화면으로 넘어간다.', | |
| 306 | + '[연락·보고서]를 누르면 그 기관의 연락 이력과 보고서를 다루는 창이 열린다.', | |
| 307 | + ], | |
| 308 | + cautions: [ | |
| 309 | + '권리확인·권리처리 숫자는 대시보드와 같은 집계를 그대로 쓴다(두 화면 숫자가 어긋나지 않는다).', | |
| 310 | + '대시보드 아래 [사업관리에서 전체 보기]를 누르면 이 화면으로 온다.', | |
| 311 | + ], | |
| 312 | + }, | |
| 313 | + | |
| 314 | + 'projects.contactLog': { | |
| 315 | + title: '신청 확인 연락 이력', | |
| 316 | + summary: '기관에 연락한 내용을 날짜별로 쌓아 둔다. 자료가 자주 바뀌는 부분이라 이력으로 남긴다.', | |
| 317 | + steps: [ | |
| 318 | + '연락일·방법(전화/메일/방문/기타)·내용을 넣고 [등록]한다.', | |
| 319 | + '[수정]을 누르면 위 입력칸에 값이 올라온다. 고친 뒤 [수정 저장]을 누른다.', | |
| 320 | + '[삭제]로 잘못 넣은 이력을 지운다.', | |
| 321 | + ], | |
| 322 | + cautions: [ | |
| 323 | + '작성자는 로그인한 사용자로 자동 기록되며, 수정해도 처음 작성자가 유지된다.', | |
| 324 | + '목록의 "최근 연락"은 여기 등록한 가장 늦은 연락일이다.', | |
| 325 | + ], | |
| 326 | + }, | |
| 327 | + | |
| 328 | + 'projects.report': { | |
| 329 | + title: '최종 보고서', | |
| 330 | + summary: '기관별 최종 보고서를 올리고 내려받는다. 같은 기관에 여러 번 올려 개정판을 쌓을 수 있다.', | |
| 331 | + steps: [ | |
| 332 | + '[보고서 업로드]로 파일을 고른다.', | |
| 333 | + '파일 이름을 클릭하면 내려받는다.', | |
| 334 | + '[삭제]로 잘못 올린 파일을 지운다.', | |
| 335 | + ], | |
| 336 | + cautions: [ | |
| 337 | + '보고서는 Mattermost가 아니라 이 시스템 DB에 보관된다 - 채널을 초기화해도 사라지지 않는다.', | |
| 338 | + '한 번에 올릴 수 있는 파일 크기는 20MB까지다.', | |
| 339 | + ], | |
| 340 | + }, | |
| 341 | + | |
| 342 | + 'contacts.manage': { | |
| 343 | + title: '담당자관리', | |
| 344 | + summary: '담당자 정보를 입력하는 유일한 화면이다. 여기서 등록한 사람을 채널관리에서 배정한다.', | |
| 345 | + images: [ | |
| 346 | + { | |
| 347 | + src: '/help/contacts.png', | |
| 348 | + alt: '담당자 목록과 회원명단 업로드·담당자 추가 버튼', | |
| 349 | + caption: '위쪽 칩으로 구분을 걸러 본다. 캡처의 이름·연락처는 예시로 바꿔 둔 값이다', | |
| 350 | + }, | |
| 351 | + ], | |
| 352 | + steps: [ | |
| 353 | + '[담당자 추가]에서 구분·성명·소속·부서·직급·연락처·이메일을 넣고 [등록]한다.', | |
| 354 | + '목록의 [수정] / [삭제]로 고치거나 지운다.', | |
| 355 | + '회원정보 엑셀을 올리면 여러 명을 한 번에 등록한다. 신청기관 담당자는 소속 기관에 자동으로 배정된다.', | |
| 356 | + ], | |
| 357 | + cautions: [ | |
| 358 | + '이메일은 Mattermost 계정을 만드는 기준이다. 정확히 넣어야 채널 생성이 제대로 된다.', | |
| 359 | + '이미 기관에 배정된 담당자를 지우면 그 기관의 배정이 비워진다.', | |
| 360 | + ], | |
| 361 | + }, | |
| 362 | + | |
| 363 | + 'system.reset': { | |
| 364 | + title: '채널 초기화', | |
| 365 | + summary: '웹앱이 만든 Mattermost 채널을 보관 처리하고, 기관을 채널 생성 전 상태로 되돌린다.', | |
| 366 | + images: [ | |
| 367 | + { | |
| 368 | + src: '/help/system-reset.png', | |
| 369 | + alt: '채널 초기화 대상 목록과 확인 문구 입력', | |
| 370 | + caption: '무엇이 보관되는지 목록으로 먼저 확인한 뒤 실행한다', | |
| 371 | + }, | |
| 372 | + ], | |
| 373 | + steps: [ | |
| 374 | + '대상 목록에서 어느 기관의 어느 채널이 보관될지 확인한다.', | |
| 375 | + '입력창에 "채널 초기화" 를 그대로 입력한다.', | |
| 376 | + '[채널 초기화 실행]을 누르면 확인창이 한 번 더 뜬다.', | |
| 377 | + '실행 후 보관된 채널 수·초기화된 기관 수와 실패 내역이 표시된다.', | |
| 378 | + ], | |
| 379 | + cautions: [ | |
| 380 | + '삭제가 아니라 보관(아카이브)이다. 게시글·파일은 남고 Mattermost 시스템 콘솔에서 되돌릴 수 있다.', | |
| 381 | + '담당자 배정, 권리확인·권리처리 자료, 업무메모, 기관 명부는 지워지지 않는다.', | |
| 382 | + '공용 채널(Town Square·FAQ·대쉬보드·양식 모음·자료실)과 엑셀로 만든 예전 채널은 대상이 아니다. 사용자 계정도 건드리지 않는다.', | |
| 383 | + '한 기관의 채널 중 하나만 실패하면 그 기관의 채널 연결은 그대로 남는다. 다시 실행하면 남은 것만 처리한다.', | |
| 384 | + ], | |
| 385 | + }, | |
| 386 | +} |
--- frontend/src/stages.ts
+++ frontend/src/stages.ts
... | ... | @@ -20,3 +20,23 @@ |
| 20 | 20 |
export function stageLabel(stage: number): string {
|
| 21 | 21 |
return STAGE_LABELS[stage - 1] ?? String(stage) |
| 22 | 22 |
} |
| 23 |
+ |
|
| 24 |
+/** |
|
| 25 |
+ * 12단계를 업무 성격으로 묶은 구간. 대시보드의 "업무 구간" 필터와 칸반 카드 모양 |
|
| 26 |
+ * (권리확인 구간은 확인 진척 막대 1줄, 권리처리 구간은 확인+처리 2줄)이 이 묶음을 쓴다. |
|
| 27 |
+ */ |
|
| 28 |
+export const STAGE_GROUPS: { key: string; label: string; stages: number[] }[] = [
|
|
| 29 |
+ { key: 'intake', label: '접수', stages: [1, 2, 3] },
|
|
| 30 |
+ { key: 'assign', label: '배당', stages: [4] },
|
|
| 31 |
+ { key: 'check', label: '권리확인', stages: [5, 6, 7] },
|
|
| 32 |
+ { key: 'process', label: '권리처리', stages: [8, 9, 10] },
|
|
| 33 |
+ { key: 'closing', label: '마감', stages: [11, 12] },
|
|
| 34 |
+] |
|
| 35 |
+ |
|
| 36 |
+/** 단계 번호가 속한 구간 key. 범위 밖이거나 null이면 null. */ |
|
| 37 |
+export function stageGroupKey(stage: number | null): string | null {
|
|
| 38 |
+ if (stage === null) {
|
|
| 39 |
+ return null |
|
| 40 |
+ } |
|
| 41 |
+ return STAGE_GROUPS.find((group) => group.stages.includes(stage))?.key ?? null |
|
| 42 |
+} |
+++ src/main/java/kr/itn/itnhub/dashboard/DashboardController.java
... | ... | @@ -0,0 +1,19 @@ |
| 1 | +package kr.itn.itnhub.dashboard; | |
| 2 | + | |
| 3 | +import org.springframework.web.bind.annotation.GetMapping; | |
| 4 | +import org.springframework.web.bind.annotation.RestController; | |
| 5 | + | |
| 6 | +@RestController | |
| 7 | +public class DashboardController { | |
| 8 | + | |
| 9 | + private final DashboardService dashboardService; | |
| 10 | + | |
| 11 | + public DashboardController(DashboardService dashboardService) { | |
| 12 | + this.dashboardService = dashboardService; | |
| 13 | + } | |
| 14 | + | |
| 15 | + @GetMapping("/api/dashboard") | |
| 16 | + public DashboardResponse dashboard() { | |
| 17 | + return dashboardService.dashboard(); | |
| 18 | + } | |
| 19 | +} |
+++ src/main/java/kr/itn/itnhub/dashboard/DashboardMapper.java
... | ... | @@ -0,0 +1,12 @@ |
| 1 | +package kr.itn.itnhub.dashboard; | |
| 2 | + | |
| 3 | +import org.apache.ibatis.annotations.Mapper; | |
| 4 | + | |
| 5 | +import java.util.List; | |
| 6 | + | |
| 7 | +@Mapper | |
| 8 | +public interface DashboardMapper { | |
| 9 | + | |
| 10 | + /** 기관 전체를 연번순으로, 권리확인/권리처리 집계와 단계·최근변동 시각까지 붙여 한 번에 읽는다. */ | |
| 11 | + List<DashboardOrgRow> findOrgRows(); | |
| 12 | +} |
+++ src/main/java/kr/itn/itnhub/dashboard/DashboardOrgRow.java
... | ... | @@ -0,0 +1,32 @@ |
| 1 | +package kr.itn.itnhub.dashboard; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 대시보드가 기관 1곳을 그리는 데 필요한 값을 한 줄로 모은 것. 칸반 카드와 "최근 진행 기관" | |
| 5 | + * 표가 같은 행을 쓴다 - 두 곳이 같은 숫자를 보여야 하고, 기관 47곳을 그릴 때마다 기관별로 | |
| 6 | + * 집계 쿼리를 다시 날리면 화면 한 번에 100번 넘게 조회하게 되기 때문이다. | |
| 7 | + * | |
| 8 | + * @param hasChannel 문정원/법률검토 채널 중 하나라도 만들어졌는지. 칸반의 "단계 미지정" | |
| 9 | + * 컬럼은 이 값이 false인 기관만 담는다(요청자 확정). | |
| 10 | + * @param stage 1..12, 채널 생성 전이면 null | |
| 11 | + * @param stageChangedAt 현재 단계로 넘어온 시각(epoch ms). 카드의 D+n 계산용. 이력이 없으면 null | |
| 12 | + * @param lastChangedAt 기관 관련 마지막 변동 시각(epoch ms). 단계변경·권리확인/처리 수정· | |
| 13 | + * 업무메모·배정 변경 중 가장 최근 것. "최근 진행 기관" 정렬 기준이다 | |
| 14 | + * @param reCount 미해결 RE 건수. RE 데이터 모델이 아직 없어 항상 0이다 | |
| 15 | + */ | |
| 16 | +public record DashboardOrgRow( | |
| 17 | + Long id, | |
| 18 | + String orgNo, | |
| 19 | + String orgName, | |
| 20 | + Integer stage, | |
| 21 | + boolean hasChannel, | |
| 22 | + String lawyerName, | |
| 23 | + String mjName, | |
| 24 | + String lawyerAssignedDate, | |
| 25 | + int reviewTotal, | |
| 26 | + int reviewDone, | |
| 27 | + int processTotal, | |
| 28 | + int processDone, | |
| 29 | + Long stageChangedAt, | |
| 30 | + Long lastChangedAt, | |
| 31 | + int reCount) { | |
| 32 | +} |
+++ src/main/java/kr/itn/itnhub/dashboard/DashboardResponse.java
... | ... | @@ -0,0 +1,7 @@ |
| 1 | +package kr.itn.itnhub.dashboard; | |
| 2 | + | |
| 3 | +import java.util.List; | |
| 4 | + | |
| 5 | +/** {@code GET /api/dashboard} 응답. 화면 세 블록(KPI·칸반·최근 진행 기관)이 이 하나로 다 그려진다. */ | |
| 6 | +public record DashboardResponse(DashboardSummary summary, List<DashboardOrgRow> orgs) { | |
| 7 | +} |
+++ src/main/java/kr/itn/itnhub/dashboard/DashboardService.java
... | ... | @@ -0,0 +1,70 @@ |
| 1 | +package kr.itn.itnhub.dashboard; | |
| 2 | + | |
| 3 | +import org.springframework.stereotype.Service; | |
| 4 | + | |
| 5 | +import java.util.List; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * 대시보드 화면 하나가 쓰는 서비스. 집계 쿼리는 {@link DashboardMapper#findOrgRows()} 한 번이고, | |
| 9 | + * 상단 KPI는 그 결과를 Java에서 접어 만든다 - 같은 원본에서 계산해야 타일 숫자와 아래 표/칸반 | |
| 10 | + * 숫자가 어긋나지 않는다(쿼리를 따로 두면 두 결과 사이에 시차가 생긴다). | |
| 11 | + */ | |
| 12 | +@Service | |
| 13 | +public class DashboardService { | |
| 14 | + | |
| 15 | + /** ②목록접수. 시안 KPI "서류제출"의 기준으로 쓴다. */ | |
| 16 | + private static final int STAGE_DOC_SUBMITTED = 2; | |
| 17 | + /** ⑪법률검토 최종완료. 시안 KPI "완료"의 기준. */ | |
| 18 | + private static final int STAGE_COMPLETED = 11; | |
| 19 | + /** ⑫보고서 작성. */ | |
| 20 | + private static final int STAGE_REPORT = 12; | |
| 21 | + | |
| 22 | + private final DashboardMapper mapper; | |
| 23 | + | |
| 24 | + public DashboardService(DashboardMapper mapper) { | |
| 25 | + this.mapper = mapper; | |
| 26 | + } | |
| 27 | + | |
| 28 | + public DashboardResponse dashboard() { | |
| 29 | + List<DashboardOrgRow> rows = mapper.findOrgRows(); | |
| 30 | + return new DashboardResponse(summarize(rows), rows); | |
| 31 | + } | |
| 32 | + | |
| 33 | + DashboardSummary summarize(List<DashboardOrgRow> rows) { | |
| 34 | + int docSubmitted = 0; | |
| 35 | + int reviewTotal = 0; | |
| 36 | + int reviewDone = 0; | |
| 37 | + int processTotal = 0; | |
| 38 | + int processDone = 0; | |
| 39 | + int unresolvedRe = 0; | |
| 40 | + int reportWriting = 0; | |
| 41 | + int completed = 0; | |
| 42 | + | |
| 43 | + for (DashboardOrgRow row : rows) { | |
| 44 | + reviewTotal += row.reviewTotal(); | |
| 45 | + reviewDone += row.reviewDone(); | |
| 46 | + processTotal += row.processTotal(); | |
| 47 | + processDone += row.processDone(); | |
| 48 | + unresolvedRe += row.reCount(); | |
| 49 | + | |
| 50 | + Integer stage = row.stage(); | |
| 51 | + if (stage == null) { | |
| 52 | + continue; | |
| 53 | + } | |
| 54 | + if (stage >= STAGE_DOC_SUBMITTED) { | |
| 55 | + docSubmitted++; | |
| 56 | + } | |
| 57 | + if (stage >= STAGE_COMPLETED) { | |
| 58 | + completed++; | |
| 59 | + } | |
| 60 | + if (stage >= STAGE_REPORT) { | |
| 61 | + reportWriting++; | |
| 62 | + } | |
| 63 | + } | |
| 64 | + | |
| 65 | + // 신청 기관 수는 명부에 등록된 기관 전체다 - 명부에 올라온 것 자체가 신청이므로 | |
| 66 | + // 단계값 유무와 무관하다(채널 생성 전 기관도 신청은 한 상태다). | |
| 67 | + return new DashboardSummary(rows.size(), docSubmitted, reviewTotal, reviewDone, | |
| 68 | + processTotal, processDone, unresolvedRe, reportWriting, completed); | |
| 69 | + } | |
| 70 | +} |
+++ src/main/java/kr/itn/itnhub/dashboard/DashboardSummary.java
... | ... | @@ -0,0 +1,25 @@ |
| 1 | +package kr.itn.itnhub.dashboard; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 대시보드 상단 KPI 타일 7개. 모두 "그 단계에 도달한 기관 수" 누적 기준이고, | |
| 5 | + * 권리확인/권리처리만 전 기관 건수 합계다. | |
| 6 | + * | |
| 7 | + * <p>시안에는 이름만 있고 계산식이 없던 항목이 셋 있어 다음과 같이 정했다(변경 시 여기와 | |
| 8 | + * {@link DashboardService#summarize} 한 곳만 고치면 된다):</p> | |
| 9 | + * <ul> | |
| 10 | + * <li>{@code docSubmitted}(서류제출) - 12단계 라벨에 없는 이름이라 ②목록접수 도달(stage >= 2)로 본다</li> | |
| 11 | + * <li>{@code completed}(완료) - ⑪법률검토 최종완료 도달(stage >= 11)</li> | |
| 12 | + * <li>{@code unresolvedRe}(미해결 RE) - RE 테이블이 아직 없어 항상 0. 자리만 잡아 둔다</li> | |
| 13 | + * </ul> | |
| 14 | + */ | |
| 15 | +public record DashboardSummary( | |
| 16 | + int applied, | |
| 17 | + int docSubmitted, | |
| 18 | + int reviewTotal, | |
| 19 | + int reviewDone, | |
| 20 | + int processTotal, | |
| 21 | + int processDone, | |
| 22 | + int unresolvedRe, | |
| 23 | + int reportWriting, | |
| 24 | + int completed) { | |
| 25 | +} |
--- src/main/java/kr/itn/itnhub/mattermost/MattermostClient.java
+++ src/main/java/kr/itn/itnhub/mattermost/MattermostClient.java
... | ... | @@ -51,4 +51,14 @@ |
| 51 | 51 |
|
| 52 | 52 |
/** 사용자를 채널에 추가한다. 이미 멤버여도 안전하다(idempotent). */ |
| 53 | 53 |
void addUserToChannel(String channelId, String userId); |
| 54 |
+ |
|
| 55 |
+ /** |
|
| 56 |
+ * 채널을 보관 처리한다. Mattermost의 {@code DELETE /channels/{id}}는 삭제가 아니라
|
|
| 57 |
+ * 아카이브라서 게시글·파일이 남고 시스템 콘솔에서 되돌릴 수 있다. |
|
| 58 |
+ * |
|
| 59 |
+ * <p>영구 삭제({@code ?permanent=true})는 일부러 쓰지 않는다. 운영 서버의
|
|
| 60 |
+ * {@code ServiceSettings.EnableAPIChannelDeletion}이 꺼져 있어 어차피 거부되고,
|
|
| 61 |
+ * 켜더라도 복구가 불가능하기 때문이다.</p> |
|
| 62 |
+ */ |
|
| 63 |
+ void archiveChannel(String channelId); |
|
| 54 | 64 |
} |
--- src/main/java/kr/itn/itnhub/mattermost/MattermostRestClient.java
+++ src/main/java/kr/itn/itnhub/mattermost/MattermostRestClient.java
... | ... | @@ -390,6 +390,18 @@ |
| 390 | 390 |
} |
| 391 | 391 |
|
| 392 | 392 |
@Override |
| 393 |
+ public void archiveChannel(String channelId) {
|
|
| 394 |
+ try {
|
|
| 395 |
+ rest.delete() |
|
| 396 |
+ .uri("/api/v4/channels/{channelId}", channelId)
|
|
| 397 |
+ .retrieve() |
|
| 398 |
+ .toBodilessEntity(); |
|
| 399 |
+ } catch (RestClientException e) {
|
|
| 400 |
+ throw new MattermostException("채널 보관 실패: " + channelId, e);
|
|
| 401 |
+ } |
|
| 402 |
+ } |
|
| 403 |
+ |
|
| 404 |
+ @Override |
|
| 393 | 405 |
public List<PostView> getPinnedPosts(String channelId) {
|
| 394 | 406 |
try {
|
| 395 | 407 |
JsonNode body = rest.get() |
--- src/main/java/kr/itn/itnhub/memo/MemoRequest.java
+++ src/main/java/kr/itn/itnhub/memo/MemoRequest.java
... | ... | @@ -1,9 +1,10 @@ |
| 1 | 1 |
package kr.itn.itnhub.memo; |
| 2 | 2 |
|
| 3 | 3 |
import jakarta.validation.constraints.NotBlank; |
| 4 |
-import jakarta.validation.constraints.Size; |
|
| 5 | 4 |
|
| 6 |
-public record MemoRequest( |
|
| 7 |
- @NotBlank @Size(max = 100) String contactName, |
|
| 8 |
- @NotBlank String body) {
|
|
| 5 |
+/** |
|
| 6 |
+ * 업무메모 등록/수정 요청. 작성자는 담지 않는다 - 요청자 확정대로 로그인한 사용자를 |
|
| 7 |
+ * 서버가 직접 기록하기 때문이다(클라이언트가 남의 이름으로 남기는 것도 막힌다). |
|
| 8 |
+ */ |
|
| 9 |
+public record MemoRequest(@NotBlank String body) {
|
|
| 9 | 10 |
} |
--- src/main/java/kr/itn/itnhub/memo/WorkMemoController.java
+++ src/main/java/kr/itn/itnhub/memo/WorkMemoController.java
... | ... | @@ -6,10 +6,12 @@ |
| 6 | 6 |
import org.springframework.web.bind.annotation.GetMapping; |
| 7 | 7 |
import org.springframework.web.bind.annotation.PathVariable; |
| 8 | 8 |
import org.springframework.web.bind.annotation.PostMapping; |
| 9 |
+import org.springframework.web.bind.annotation.PutMapping; |
|
| 9 | 10 |
import org.springframework.web.bind.annotation.RequestBody; |
| 10 | 11 |
import org.springframework.web.bind.annotation.ResponseStatus; |
| 11 | 12 |
import org.springframework.web.bind.annotation.RestController; |
| 12 | 13 |
|
| 14 |
+import java.security.Principal; |
|
| 13 | 15 |
import java.util.List; |
| 14 | 16 |
|
| 15 | 17 |
/** 기관관리 상세의 업무메모 탭이 부르는 엔드포인트. */ |
... | ... | @@ -27,9 +29,17 @@ |
| 27 | 29 |
return memoService.list(id); |
| 28 | 30 |
} |
| 29 | 31 |
|
| 32 |
+ /** 작성자는 본문에서 받지 않고 로그인 사용자(principal)를 서버가 직접 기록한다. */ |
|
| 30 | 33 |
@PostMapping("/api/orgs/{id}/memos")
|
| 31 |
- public WorkMemo create(@PathVariable Long id, @Valid @RequestBody MemoRequest request) {
|
|
| 32 |
- return memoService.create(id, request); |
|
| 34 |
+ public WorkMemo create(@PathVariable Long id, @Valid @RequestBody MemoRequest request, |
|
| 35 |
+ Principal principal) {
|
|
| 36 |
+ return memoService.create(id, request, principal.getName()); |
|
| 37 |
+ } |
|
| 38 |
+ |
|
| 39 |
+ @PutMapping("/api/orgs/{id}/memos/{memoId}")
|
|
| 40 |
+ public WorkMemo update(@PathVariable Long id, @PathVariable Long memoId, |
|
| 41 |
+ @Valid @RequestBody MemoRequest request) {
|
|
| 42 |
+ return memoService.update(id, memoId, request); |
|
| 33 | 43 |
} |
| 34 | 44 |
|
| 35 | 45 |
@ResponseStatus(HttpStatus.NO_CONTENT) |
--- src/main/java/kr/itn/itnhub/memo/WorkMemoMapper.java
+++ src/main/java/kr/itn/itnhub/memo/WorkMemoMapper.java
... | ... | @@ -18,4 +18,7 @@ |
| 18 | 18 |
* 삭제되지 않게 한다(다른 기관 URL로 삭제 시도하는 실수를 막는다). |
| 19 | 19 |
*/ |
| 20 | 20 |
int delete(@Param("orgId") Long orgId, @Param("id") Long id);
|
| 21 |
+ |
|
| 22 |
+ /** 본문만 고친다. 작성자와 작성시각은 처음 남긴 값을 유지한다. */ |
|
| 23 |
+ int updateBody(@Param("orgId") Long orgId, @Param("id") Long id, @Param("body") String body);
|
|
| 21 | 24 |
} |
--- src/main/java/kr/itn/itnhub/memo/WorkMemoService.java
+++ src/main/java/kr/itn/itnhub/memo/WorkMemoService.java
... | ... | @@ -27,13 +27,17 @@ |
| 27 | 27 |
return memoMapper.findByOrg(orgId); |
| 28 | 28 |
} |
| 29 | 29 |
|
| 30 |
+ /** |
|
| 31 |
+ * 메모를 남긴다. 작성자는 화면에서 고르지 않고 <b>로그인한 사용자</b>를 그대로 기록한다 |
|
| 32 |
+ * (요청자 확정: "담당자 입력란은 필요없고 로그인한 사람이 자동으로 표출되면 된다"). |
|
| 33 |
+ */ |
|
| 30 | 34 |
@Transactional |
| 31 |
- public WorkMemo create(Long orgId, MemoRequest request) {
|
|
| 35 |
+ public WorkMemo create(Long orgId, MemoRequest request, String author) {
|
|
| 32 | 36 |
requireOrg(orgId); |
| 33 | 37 |
|
| 34 | 38 |
WorkMemo memo = new WorkMemo(); |
| 35 | 39 |
memo.setOrgId(orgId); |
| 36 |
- memo.setContactName(request.contactName()); |
|
| 40 |
+ memo.setContactName(author); |
|
| 37 | 41 |
memo.setBody(request.body()); |
| 38 | 42 |
memoMapper.insert(memo); |
| 39 | 43 |
|
... | ... | @@ -41,6 +45,21 @@ |
| 41 | 45 |
return memoMapper.findByOrg(orgId).get(0); |
| 42 | 46 |
} |
| 43 | 47 |
|
| 48 |
+ /** 본문만 고친다. 작성자·작성시각은 처음 값을 유지한다. */ |
|
| 49 |
+ @Transactional |
|
| 50 |
+ public WorkMemo update(Long orgId, Long memoId, MemoRequest request) {
|
|
| 51 |
+ requireOrg(orgId); |
|
| 52 |
+ |
|
| 53 |
+ int updated = memoMapper.updateBody(orgId, memoId, request.body()); |
|
| 54 |
+ if (updated == 0) {
|
|
| 55 |
+ throw new OrgNotFoundException("메모를 찾을 수 없습니다: " + memoId);
|
|
| 56 |
+ } |
|
| 57 |
+ return memoMapper.findByOrg(orgId).stream() |
|
| 58 |
+ .filter(memo -> memo.getId().equals(memoId)) |
|
| 59 |
+ .findFirst() |
|
| 60 |
+ .orElseThrow(() -> new OrgNotFoundException("메모를 찾을 수 없습니다: " + memoId));
|
|
| 61 |
+ } |
|
| 62 |
+ |
|
| 44 | 63 |
@Transactional |
| 45 | 64 |
public void delete(Long orgId, Long memoId) {
|
| 46 | 65 |
requireOrg(orgId); |
--- src/main/java/kr/itn/itnhub/org/OrganizationMapper.java
+++ src/main/java/kr/itn/itnhub/org/OrganizationMapper.java
... | ... | @@ -36,4 +36,13 @@ |
| 36 | 36 |
|
| 37 | 37 |
int updateStage(@Param("id") Long id,
|
| 38 | 38 |
@Param("stage") Integer stage);
|
| 39 |
+ |
|
| 40 |
+ /** |
|
| 41 |
+ * 채널 초기화. 채널 ID 2개와 진행단계를 한 번에 비운다. |
|
| 42 |
+ * |
|
| 43 |
+ * <p>단계까지 같이 비우는 이유는 "채널 있음 ⇔ 단계 있음" 불변식 때문이다(V11 참고). |
|
| 44 |
+ * 채널만 지우고 단계를 남기면 대시보드 칸반에서 채널 없는 기관이 ①신청 컬럼에 남아 |
|
| 45 |
+ * 드래그도 안 되는 이상한 상태가 된다.</p> |
|
| 46 |
+ */ |
|
| 47 |
+ int clearChannels(@Param("id") Long id);
|
|
| 39 | 48 |
} |
+++ src/main/java/kr/itn/itnhub/process/BulkProcessingRequest.java
... | ... | @@ -0,0 +1,14 @@ |
| 1 | +package kr.itn.itnhub.process; | |
| 2 | + | |
| 3 | +import java.util.List; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * 권리처리 목록의 일괄 등록 요청. 체크한 건들에 같은 값을 한 번에 넣는다. | |
| 7 | + * 비워 보낸 항목(null/빈 문자열)은 기존 값을 유지한다. | |
| 8 | + */ | |
| 9 | +public record BulkProcessingRequest( | |
| 10 | + List<Long> ids, | |
| 11 | + String processStatus, | |
| 12 | + String judgedKoglType, | |
| 13 | + String finalOpinion) { | |
| 14 | +} |
--- src/main/java/kr/itn/itnhub/process/ProcessController.java
+++ src/main/java/kr/itn/itnhub/process/ProcessController.java
... | ... | @@ -55,13 +55,31 @@ |
| 55 | 55 |
} |
| 56 | 56 |
} |
| 57 | 57 |
|
| 58 |
+ /** keyword는 통합검색, site/board/title은 열별 조건(서로 AND). status는 처리상태 필터다. */ |
|
| 58 | 59 |
@GetMapping("/api/orgs/{id}/process")
|
| 59 | 60 |
public ProcessPage list(@PathVariable("id") Long orgId,
|
| 60 | 61 |
@RequestParam(required = false) String keyword, |
| 62 |
+ @RequestParam(required = false) String site, |
|
| 63 |
+ @RequestParam(required = false) String board, |
|
| 64 |
+ @RequestParam(required = false) String title, |
|
| 61 | 65 |
@RequestParam(required = false) String status, |
| 62 | 66 |
@RequestParam(defaultValue = "0") int page, |
| 63 | 67 |
@RequestParam(defaultValue = "30") int size) {
|
| 64 |
- return processService.list(orgId, keyword, status, page, size); |
|
| 68 |
+ return processService.list(orgId, keyword, site, board, title, status, page, size); |
|
| 69 |
+ } |
|
| 70 |
+ |
|
| 71 |
+ /** 목록에서 체크한 건들에 같은 값을 한 번에 반영한다. */ |
|
| 72 |
+ @PutMapping("/api/orgs/{id}/process/bulk")
|
|
| 73 |
+ public java.util.Map<String, Integer> updateBulk(@PathVariable("id") Long orgId,
|
|
| 74 |
+ @RequestBody BulkProcessingRequest request) {
|
|
| 75 |
+ return java.util.Map.of("updated", processService.updateBulk(orgId, request));
|
|
| 76 |
+ } |
|
| 77 |
+ |
|
| 78 |
+ /** 목록에서 체크한 건들을 지운다. */ |
|
| 79 |
+ @PostMapping("/api/orgs/{id}/process/bulk-delete")
|
|
| 80 |
+ public java.util.Map<String, Integer> deleteBulk(@PathVariable("id") Long orgId,
|
|
| 81 |
+ @RequestBody java.util.List<Long> ids) {
|
|
| 82 |
+ return java.util.Map.of("deleted", processService.deleteBulk(orgId, ids));
|
|
| 65 | 83 |
} |
| 66 | 84 |
|
| 67 | 85 |
@GetMapping("/api/orgs/{id}/process/{itemId}")
|
--- src/main/java/kr/itn/itnhub/process/ProcessItemMapper.java
+++ src/main/java/kr/itn/itnhub/process/ProcessItemMapper.java
... | ... | @@ -13,11 +13,24 @@ |
| 13 | 13 |
* "DONE"=처리완료, "PENDING"=그 외(null/빈/미처리)만. |
| 14 | 14 |
*/ |
| 15 | 15 |
List<ProcessItem> findPage(@Param("orgId") Long orgId, @Param("keyword") String keyword,
|
| 16 |
+ @Param("site") String site, @Param("board") String board,
|
|
| 17 |
+ @Param("title") String title,
|
|
| 16 | 18 |
@Param("status") String status, @Param("offset") int offset,
|
| 17 | 19 |
@Param("limit") int limit);
|
| 18 | 20 |
|
| 19 | 21 |
/** keyword/status로 필터링된 건수 - 필터별 페이지 경계 계산용(현재 응답의 total과는 별개). */ |
| 20 |
- int countByOrg(@Param("orgId") Long orgId, @Param("keyword") String keyword, @Param("status") String status);
|
|
| 22 |
+ int countByOrg(@Param("orgId") Long orgId, @Param("keyword") String keyword,
|
|
| 23 |
+ @Param("site") String site, @Param("board") String board,
|
|
| 24 |
+ @Param("title") String title, @Param("status") String status);
|
|
| 25 |
+ |
|
| 26 |
+ /** 여러 건에 같은 값을 한 번에 반영한다(일괄 등록). null 항목은 기존 값을 유지한다. */ |
|
| 27 |
+ int updateProcessingBulk(@Param("orgId") Long orgId, @Param("ids") List<Long> ids,
|
|
| 28 |
+ @Param("processStatus") String processStatus,
|
|
| 29 |
+ @Param("judgedKoglType") String judgedKoglType,
|
|
| 30 |
+ @Param("finalOpinion") String finalOpinion);
|
|
| 31 |
+ |
|
| 32 |
+ /** 일괄 삭제. */ |
|
| 33 |
+ int deleteByIds(@Param("orgId") Long orgId, @Param("ids") List<Long> ids);
|
|
| 21 | 34 |
|
| 22 | 35 |
/** 다운로드/재업로드 판정용으로 페이징 없이 순번순 전체를 읽는다. */ |
| 23 | 36 |
List<ProcessItem> findAllByOrg(@Param("orgId") Long orgId);
|
--- src/main/java/kr/itn/itnhub/process/ProcessService.java
+++ src/main/java/kr/itn/itnhub/process/ProcessService.java
... | ... | @@ -40,20 +40,52 @@ |
| 40 | 40 |
this.orgMapper = orgMapper; |
| 41 | 41 |
} |
| 42 | 42 |
|
| 43 |
- public ProcessPage list(Long orgId, String keyword, String status, int page, int size) {
|
|
| 43 |
+ public ProcessPage list(Long orgId, String keyword, String site, String board, String title, |
|
| 44 |
+ String status, int page, int size) {
|
|
| 44 | 45 |
requireOrg(orgId); |
| 45 | 46 |
String kw = normalizeKeyword(keyword); |
| 47 |
+ String siteFilter = normalizeKeyword(site); |
|
| 48 |
+ String boardFilter = normalizeKeyword(board); |
|
| 49 |
+ String titleFilter = normalizeKeyword(title); |
|
| 46 | 50 |
String st = normalizeStatus(status); |
| 47 | 51 |
int safePage = Math.max(page, 0); |
| 48 | 52 |
int safeSize = size <= 0 ? 30 : size; |
| 49 | 53 |
int offset = safePage * safeSize; |
| 50 | 54 |
|
| 51 |
- List<ProcessItem> items = mapper.findPage(orgId, kw, st, offset, safeSize); |
|
| 55 |
+ List<ProcessItem> items = |
|
| 56 |
+ mapper.findPage(orgId, kw, siteFilter, boardFilter, titleFilter, st, offset, safeSize); |
|
| 52 | 57 |
// total/done은 검색어·상태 필터와 무관한 기관 전체 진행률이다(헤더/통계 카드용 - ProcessPage 참고). |
| 53 | 58 |
ProcessStats stats = mapper.stats(orgId); |
| 54 | 59 |
return new ProcessPage(items, stats.total(), stats.done(), safePage, safeSize); |
| 55 | 60 |
} |
| 56 | 61 |
|
| 62 |
+ /** 목록에서 고른 여러 건에 같은 값을 한 번에 반영한다. 화면에서 비워 둔 항목은 유지된다. */ |
|
| 63 |
+ @Transactional |
|
| 64 |
+ public int updateBulk(Long orgId, BulkProcessingRequest request) {
|
|
| 65 |
+ requireOrg(orgId); |
|
| 66 |
+ if (request.ids() == null || request.ids().isEmpty()) {
|
|
| 67 |
+ return 0; |
|
| 68 |
+ } |
|
| 69 |
+ return mapper.updateProcessingBulk(orgId, request.ids(), |
|
| 70 |
+ blankToNull(request.processStatus()), |
|
| 71 |
+ blankToNull(request.judgedKoglType()), |
|
| 72 |
+ blankToNull(request.finalOpinion())); |
|
| 73 |
+ } |
|
| 74 |
+ |
|
| 75 |
+ /** 목록에서 고른 여러 건을 지운다. */ |
|
| 76 |
+ @Transactional |
|
| 77 |
+ public int deleteBulk(Long orgId, List<Long> ids) {
|
|
| 78 |
+ requireOrg(orgId); |
|
| 79 |
+ if (ids == null || ids.isEmpty()) {
|
|
| 80 |
+ return 0; |
|
| 81 |
+ } |
|
| 82 |
+ return mapper.deleteByIds(orgId, ids); |
|
| 83 |
+ } |
|
| 84 |
+ |
|
| 85 |
+ private String blankToNull(String value) {
|
|
| 86 |
+ return value == null || value.isBlank() ? null : value; |
|
| 87 |
+ } |
|
| 88 |
+ |
|
| 57 | 89 |
public ProcessItem get(Long orgId, Long itemId) {
|
| 58 | 90 |
requireOrg(orgId); |
| 59 | 91 |
return requireItem(orgId, itemId); |
+++ src/main/java/kr/itn/itnhub/project/ContactLog.java
... | ... | @@ -0,0 +1,18 @@ |
| 1 | +package kr.itn.itnhub.project; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 신청 확인 연락 이력 1건. 작성자는 화면에서 고르지 않고 로그인 사용자를 서버가 기록한다 | |
| 5 | + * (업무메모와 같은 규칙). | |
| 6 | + * | |
| 7 | + * @param contactedOn 연락한 날짜(YYYY-MM-DD 문자열로 오간다 - 날짜 계산이 필요 없고 화면 표기가 전부다) | |
| 8 | + * @param createdAt 기록 시각(epoch ms) | |
| 9 | + */ | |
| 10 | +public record ContactLog( | |
| 11 | + Long id, | |
| 12 | + Long orgId, | |
| 13 | + String contactedOn, | |
| 14 | + String method, | |
| 15 | + String summary, | |
| 16 | + String author, | |
| 17 | + Long createdAt) { | |
| 18 | +} |
+++ src/main/java/kr/itn/itnhub/project/ContactLogRequest.java
... | ... | @@ -0,0 +1,11 @@ |
| 1 | +package kr.itn.itnhub.project; | |
| 2 | + | |
| 3 | +import jakarta.validation.constraints.NotBlank; | |
| 4 | +import jakarta.validation.constraints.Size; | |
| 5 | + | |
| 6 | +/** 연락 이력 등록/수정 요청. 작성자는 서버가 채운다. */ | |
| 7 | +public record ContactLogRequest( | |
| 8 | + @NotBlank String contactedOn, | |
| 9 | + @NotBlank @Size(max = 20) String method, | |
| 10 | + @NotBlank @Size(max = 1000) String summary) { | |
| 11 | +} |
+++ src/main/java/kr/itn/itnhub/project/OrgReport.java
... | ... | @@ -0,0 +1,12 @@ |
| 1 | +package kr.itn.itnhub.project; | |
| 2 | + | |
| 3 | +/** 기관별 최종 보고서 목록 항목. 파일 내용(content)은 담지 않는다 - 목록에서는 필요 없다. */ | |
| 4 | +public record OrgReport( | |
| 5 | + Long id, | |
| 6 | + Long orgId, | |
| 7 | + String fileName, | |
| 8 | + String contentType, | |
| 9 | + Long byteSize, | |
| 10 | + String uploadedBy, | |
| 11 | + Long uploadedAt) { | |
| 12 | +} |
+++ src/main/java/kr/itn/itnhub/project/ProjectController.java
... | ... | @@ -0,0 +1,109 @@ |
| 1 | +package kr.itn.itnhub.project; | |
| 2 | + | |
| 3 | +import jakarta.validation.Valid; | |
| 4 | +import org.springframework.http.HttpHeaders; | |
| 5 | +import org.springframework.http.HttpStatus; | |
| 6 | +import org.springframework.http.MediaType; | |
| 7 | +import org.springframework.http.ResponseEntity; | |
| 8 | +import org.springframework.web.bind.annotation.DeleteMapping; | |
| 9 | +import org.springframework.web.bind.annotation.ExceptionHandler; | |
| 10 | +import org.springframework.web.bind.annotation.GetMapping; | |
| 11 | +import org.springframework.web.bind.annotation.PathVariable; | |
| 12 | +import org.springframework.web.bind.annotation.PostMapping; | |
| 13 | +import org.springframework.web.bind.annotation.PutMapping; | |
| 14 | +import org.springframework.web.bind.annotation.RequestBody; | |
| 15 | +import org.springframework.web.bind.annotation.RequestPart; | |
| 16 | +import org.springframework.web.bind.annotation.ResponseStatus; | |
| 17 | +import org.springframework.web.bind.annotation.RestController; | |
| 18 | +import org.springframework.web.multipart.MultipartFile; | |
| 19 | + | |
| 20 | +import java.io.IOException; | |
| 21 | +import java.net.URLEncoder; | |
| 22 | +import java.nio.charset.StandardCharsets; | |
| 23 | +import java.security.Principal; | |
| 24 | +import java.util.List; | |
| 25 | +import java.util.Map; | |
| 26 | + | |
| 27 | +/** 사업관리 화면이 쓰는 엔드포인트. 기관 현황 목록 + 연락 이력 + 최종 보고서. */ | |
| 28 | +@RestController | |
| 29 | +public class ProjectController { | |
| 30 | + | |
| 31 | + private final ProjectService projectService; | |
| 32 | + | |
| 33 | + public ProjectController(ProjectService projectService) { | |
| 34 | + this.projectService = projectService; | |
| 35 | + } | |
| 36 | + | |
| 37 | + @GetMapping("/api/projects") | |
| 38 | + public List<ProjectRow> rows() { | |
| 39 | + return projectService.rows(); | |
| 40 | + } | |
| 41 | + | |
| 42 | + @GetMapping("/api/orgs/{id}/contact-logs") | |
| 43 | + public List<ContactLog> logs(@PathVariable("id") Long orgId) { | |
| 44 | + return projectService.logs(orgId); | |
| 45 | + } | |
| 46 | + | |
| 47 | + /** 작성자는 업무메모와 마찬가지로 로그인 사용자를 서버가 기록한다. */ | |
| 48 | + @PostMapping("/api/orgs/{id}/contact-logs") | |
| 49 | + public List<ContactLog> addLog(@PathVariable("id") Long orgId, | |
| 50 | + @Valid @RequestBody ContactLogRequest request, | |
| 51 | + Principal principal) { | |
| 52 | + return projectService.addLog(orgId, request, principal.getName()); | |
| 53 | + } | |
| 54 | + | |
| 55 | + @PutMapping("/api/orgs/{id}/contact-logs/{logId}") | |
| 56 | + public List<ContactLog> updateLog(@PathVariable("id") Long orgId, @PathVariable Long logId, | |
| 57 | + @Valid @RequestBody ContactLogRequest request) { | |
| 58 | + return projectService.updateLog(orgId, logId, request); | |
| 59 | + } | |
| 60 | + | |
| 61 | + @ResponseStatus(HttpStatus.NO_CONTENT) | |
| 62 | + @DeleteMapping("/api/orgs/{id}/contact-logs/{logId}") | |
| 63 | + public void deleteLog(@PathVariable("id") Long orgId, @PathVariable Long logId) { | |
| 64 | + projectService.deleteLog(orgId, logId); | |
| 65 | + } | |
| 66 | + | |
| 67 | + @GetMapping("/api/orgs/{id}/reports") | |
| 68 | + public List<OrgReport> reports(@PathVariable("id") Long orgId) { | |
| 69 | + return projectService.reports(orgId); | |
| 70 | + } | |
| 71 | + | |
| 72 | + @PostMapping("/api/orgs/{id}/reports") | |
| 73 | + public List<OrgReport> addReport(@PathVariable("id") Long orgId, | |
| 74 | + @RequestPart("file") MultipartFile file, | |
| 75 | + Principal principal) throws IOException { | |
| 76 | + String name = file.getOriginalFilename() == null ? "report" : file.getOriginalFilename(); | |
| 77 | + return projectService.addReport(orgId, name, file.getContentType(), file.getBytes(), | |
| 78 | + principal.getName()); | |
| 79 | + } | |
| 80 | + | |
| 81 | + /** 한글 파일명이 깨지지 않게 ChannelFeedController.download와 같은 RFC 5987 인코딩을 쓴다. */ | |
| 82 | + @GetMapping("/api/orgs/{id}/reports/{reportId}") | |
| 83 | + public ResponseEntity<byte[]> downloadReport(@PathVariable("id") Long orgId, | |
| 84 | + @PathVariable Long reportId) { | |
| 85 | + ProjectMapper.ReportContent report = projectService.reportContent(orgId, reportId); | |
| 86 | + String encodedName = URLEncoder.encode(report.fileName(), StandardCharsets.UTF_8) | |
| 87 | + .replace("+", "%20"); | |
| 88 | + MediaType type = report.contentType() == null || report.contentType().isBlank() | |
| 89 | + ? MediaType.APPLICATION_OCTET_STREAM | |
| 90 | + : MediaType.parseMediaType(report.contentType()); | |
| 91 | + | |
| 92 | + return ResponseEntity.ok() | |
| 93 | + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedName) | |
| 94 | + .contentType(type) | |
| 95 | + .body(report.content()); | |
| 96 | + } | |
| 97 | + | |
| 98 | + @ResponseStatus(HttpStatus.NO_CONTENT) | |
| 99 | + @DeleteMapping("/api/orgs/{id}/reports/{reportId}") | |
| 100 | + public void deleteReport(@PathVariable("id") Long orgId, @PathVariable Long reportId) { | |
| 101 | + projectService.deleteReport(orgId, reportId); | |
| 102 | + } | |
| 103 | + | |
| 104 | + @ResponseStatus(HttpStatus.BAD_REQUEST) | |
| 105 | + @ExceptionHandler(IllegalArgumentException.class) | |
| 106 | + public Map<String, String> handleBadRequest(IllegalArgumentException e) { | |
| 107 | + return Map.of("message", e.getMessage()); | |
| 108 | + } | |
| 109 | +} |
+++ src/main/java/kr/itn/itnhub/project/ProjectMapper.java
... | ... | @@ -0,0 +1,43 @@ |
| 1 | +package kr.itn.itnhub.project; | |
| 2 | + | |
| 3 | +import org.apache.ibatis.annotations.Mapper; | |
| 4 | +import org.apache.ibatis.annotations.Param; | |
| 5 | + | |
| 6 | +import java.util.List; | |
| 7 | + | |
| 8 | +@Mapper | |
| 9 | +public interface ProjectMapper { | |
| 10 | + | |
| 11 | + /** 기관별 연락이력 건수·최근 연락일·보고서 건수. 사업관리 목록에 붙일 값만 모아 한 번에 읽는다. */ | |
| 12 | + List<ProjectSideInfo> findSideInfo(); | |
| 13 | + | |
| 14 | + List<ContactLog> findLogsByOrg(@Param("orgId") Long orgId); | |
| 15 | + | |
| 16 | + int insertLog(@Param("orgId") Long orgId, @Param("contactedOn") String contactedOn, | |
| 17 | + @Param("method") String method, @Param("summary") String summary, | |
| 18 | + @Param("author") String author); | |
| 19 | + | |
| 20 | + int updateLog(@Param("orgId") Long orgId, @Param("id") Long id, | |
| 21 | + @Param("contactedOn") String contactedOn, @Param("method") String method, | |
| 22 | + @Param("summary") String summary); | |
| 23 | + | |
| 24 | + int deleteLog(@Param("orgId") Long orgId, @Param("id") Long id); | |
| 25 | + | |
| 26 | + List<OrgReport> findReportsByOrg(@Param("orgId") Long orgId); | |
| 27 | + | |
| 28 | + int insertReport(@Param("orgId") Long orgId, @Param("fileName") String fileName, | |
| 29 | + @Param("contentType") String contentType, @Param("byteSize") long byteSize, | |
| 30 | + @Param("content") byte[] content, @Param("uploadedBy") String uploadedBy); | |
| 31 | + | |
| 32 | + /** 다운로드용. 파일 내용까지 읽는다. */ | |
| 33 | + ReportContent findReportContent(@Param("orgId") Long orgId, @Param("id") Long id); | |
| 34 | + | |
| 35 | + int deleteReport(@Param("orgId") Long orgId, @Param("id") Long id); | |
| 36 | + | |
| 37 | + /** @param lastContactedOn 연락 이력이 없으면 null */ | |
| 38 | + record ProjectSideInfo(Long orgId, String lastContactedOn, int contactCount, int reportCount) { | |
| 39 | + } | |
| 40 | + | |
| 41 | + record ReportContent(String fileName, String contentType, byte[] content) { | |
| 42 | + } | |
| 43 | +} |
+++ src/main/java/kr/itn/itnhub/project/ProjectRow.java
... | ... | @@ -0,0 +1,16 @@ |
| 1 | +package kr.itn.itnhub.project; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.dashboard.DashboardOrgRow; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * 사업관리 목록 1행. 기관 현황은 대시보드가 쓰는 집계 행을 그대로 재사용하고(같은 숫자를 | |
| 7 | + * 두 화면이 따로 세지 않도록), 사업관리에서만 쓰는 연락이력·보고서 정보만 덧붙인다. | |
| 8 | + * | |
| 9 | + * @param lastContactedOn 가장 최근 연락 날짜. 연락 이력이 없으면 null | |
| 10 | + */ | |
| 11 | +public record ProjectRow( | |
| 12 | + DashboardOrgRow org, | |
| 13 | + String lastContactedOn, | |
| 14 | + int contactCount, | |
| 15 | + int reportCount) { | |
| 16 | +} |
+++ src/main/java/kr/itn/itnhub/project/ProjectService.java
... | ... | @@ -0,0 +1,124 @@ |
| 1 | +package kr.itn.itnhub.project; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.dashboard.DashboardOrgRow; | |
| 4 | +import kr.itn.itnhub.dashboard.DashboardService; | |
| 5 | +import kr.itn.itnhub.org.OrgNotFoundException; | |
| 6 | +import kr.itn.itnhub.org.OrganizationMapper; | |
| 7 | +import org.springframework.stereotype.Service; | |
| 8 | +import org.springframework.transaction.annotation.Transactional; | |
| 9 | + | |
| 10 | +import java.util.ArrayList; | |
| 11 | +import java.util.HashMap; | |
| 12 | +import java.util.List; | |
| 13 | +import java.util.Map; | |
| 14 | + | |
| 15 | +/** | |
| 16 | + * 사업관리 화면. 3단계 법률검토로 진행할 기관 현황을 한 곳에 모아 보여주고, 기관마다 | |
| 17 | + * "신청 확인 연락" 이력과 최종 보고서를 관리한다(요청자 요구: 별도 메뉴로 구축 + 연락 부분 이력관리 | |
| 18 | + * + 기관별 최종 보고서 업로드). | |
| 19 | + * | |
| 20 | + * <p>기관 현황 숫자는 대시보드와 같은 집계를 그대로 쓴다 - 두 화면이 다른 숫자를 보여주면 | |
| 21 | + * 어느 쪽이 맞는지 확인할 방법이 없기 때문이다.</p> | |
| 22 | + */ | |
| 23 | +@Service | |
| 24 | +public class ProjectService { | |
| 25 | + | |
| 26 | + private final DashboardService dashboardService; | |
| 27 | + private final ProjectMapper mapper; | |
| 28 | + private final OrganizationMapper orgMapper; | |
| 29 | + | |
| 30 | + public ProjectService(DashboardService dashboardService, ProjectMapper mapper, | |
| 31 | + OrganizationMapper orgMapper) { | |
| 32 | + this.dashboardService = dashboardService; | |
| 33 | + this.mapper = mapper; | |
| 34 | + this.orgMapper = orgMapper; | |
| 35 | + } | |
| 36 | + | |
| 37 | + public List<ProjectRow> rows() { | |
| 38 | + Map<Long, ProjectMapper.ProjectSideInfo> side = new HashMap<>(); | |
| 39 | + for (ProjectMapper.ProjectSideInfo info : mapper.findSideInfo()) { | |
| 40 | + side.put(info.orgId(), info); | |
| 41 | + } | |
| 42 | + | |
| 43 | + List<DashboardOrgRow> orgs = dashboardService.dashboard().orgs(); | |
| 44 | + List<ProjectRow> rows = new ArrayList<>(orgs.size()); | |
| 45 | + for (DashboardOrgRow org : orgs) { | |
| 46 | + ProjectMapper.ProjectSideInfo info = side.get(org.id()); | |
| 47 | + rows.add(new ProjectRow(org, | |
| 48 | + info == null ? null : info.lastContactedOn(), | |
| 49 | + info == null ? 0 : info.contactCount(), | |
| 50 | + info == null ? 0 : info.reportCount())); | |
| 51 | + } | |
| 52 | + return rows; | |
| 53 | + } | |
| 54 | + | |
| 55 | + public List<ContactLog> logs(Long orgId) { | |
| 56 | + requireOrg(orgId); | |
| 57 | + return mapper.findLogsByOrg(orgId); | |
| 58 | + } | |
| 59 | + | |
| 60 | + @Transactional | |
| 61 | + public List<ContactLog> addLog(Long orgId, ContactLogRequest request, String author) { | |
| 62 | + requireOrg(orgId); | |
| 63 | + mapper.insertLog(orgId, request.contactedOn(), request.method(), request.summary(), author); | |
| 64 | + return mapper.findLogsByOrg(orgId); | |
| 65 | + } | |
| 66 | + | |
| 67 | + @Transactional | |
| 68 | + public List<ContactLog> updateLog(Long orgId, Long logId, ContactLogRequest request) { | |
| 69 | + requireOrg(orgId); | |
| 70 | + if (mapper.updateLog(orgId, logId, request.contactedOn(), request.method(), | |
| 71 | + request.summary()) == 0) { | |
| 72 | + throw new OrgNotFoundException("연락 이력을 찾을 수 없습니다: " + logId); | |
| 73 | + } | |
| 74 | + return mapper.findLogsByOrg(orgId); | |
| 75 | + } | |
| 76 | + | |
| 77 | + @Transactional | |
| 78 | + public void deleteLog(Long orgId, Long logId) { | |
| 79 | + requireOrg(orgId); | |
| 80 | + if (mapper.deleteLog(orgId, logId) == 0) { | |
| 81 | + throw new OrgNotFoundException("연락 이력을 찾을 수 없습니다: " + logId); | |
| 82 | + } | |
| 83 | + } | |
| 84 | + | |
| 85 | + public List<OrgReport> reports(Long orgId) { | |
| 86 | + requireOrg(orgId); | |
| 87 | + return mapper.findReportsByOrg(orgId); | |
| 88 | + } | |
| 89 | + | |
| 90 | + /** 같은 기관에 여러 번 올릴 수 있다(개정판). 최신순으로 목록에 쌓인다. */ | |
| 91 | + @Transactional | |
| 92 | + public List<OrgReport> addReport(Long orgId, String fileName, String contentType, | |
| 93 | + byte[] content, String uploadedBy) { | |
| 94 | + requireOrg(orgId); | |
| 95 | + if (content == null || content.length == 0) { | |
| 96 | + throw new IllegalArgumentException("빈 파일은 올릴 수 없습니다."); | |
| 97 | + } | |
| 98 | + mapper.insertReport(orgId, fileName, contentType, content.length, content, uploadedBy); | |
| 99 | + return mapper.findReportsByOrg(orgId); | |
| 100 | + } | |
| 101 | + | |
| 102 | + public ProjectMapper.ReportContent reportContent(Long orgId, Long reportId) { | |
| 103 | + requireOrg(orgId); | |
| 104 | + ProjectMapper.ReportContent content = mapper.findReportContent(orgId, reportId); | |
| 105 | + if (content == null) { | |
| 106 | + throw new OrgNotFoundException("보고서를 찾을 수 없습니다: " + reportId); | |
| 107 | + } | |
| 108 | + return content; | |
| 109 | + } | |
| 110 | + | |
| 111 | + @Transactional | |
| 112 | + public void deleteReport(Long orgId, Long reportId) { | |
| 113 | + requireOrg(orgId); | |
| 114 | + if (mapper.deleteReport(orgId, reportId) == 0) { | |
| 115 | + throw new OrgNotFoundException("보고서를 찾을 수 없습니다: " + reportId); | |
| 116 | + } | |
| 117 | + } | |
| 118 | + | |
| 119 | + private void requireOrg(Long orgId) { | |
| 120 | + if (orgMapper.findById(orgId) == null) { | |
| 121 | + throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId); | |
| 122 | + } | |
| 123 | + } | |
| 124 | +} |
+++ src/main/java/kr/itn/itnhub/review/BulkJudgmentRequest.java
... | ... | @@ -0,0 +1,16 @@ |
| 1 | +package kr.itn.itnhub.review; | |
| 2 | + | |
| 3 | +import java.util.List; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * 권리확인 목록의 일괄 등록 요청. 체크한 건들에 같은 값을 한 번에 넣는다. | |
| 7 | + * | |
| 8 | + * <p>값 항목은 비워서 보내도 된다(null/빈 문자열). 채운 항목만 반영되고 나머지는 건드리지 않는다 - | |
| 9 | + * "체크한 것은 전부 0유형, 의견란에는 ooo" 처럼 일부 항목만 맞추는 쓰임새가 대부분이기 때문이다.</p> | |
| 10 | + */ | |
| 11 | +public record BulkJudgmentRequest( | |
| 12 | + List<Long> ids, | |
| 13 | + String reviewResult, | |
| 14 | + String judgedKoglType, | |
| 15 | + String opinion) { | |
| 16 | +} |
--- src/main/java/kr/itn/itnhub/review/ReviewController.java
+++ src/main/java/kr/itn/itnhub/review/ReviewController.java
... | ... | @@ -55,12 +55,32 @@ |
| 55 | 55 |
} |
| 56 | 56 |
} |
| 57 | 57 |
|
| 58 |
+ /** |
|
| 59 |
+ * keyword는 사이트명/게시판명/제목 통합검색, site/board/title은 열별 조건(서로 AND)이다. |
|
| 60 |
+ */ |
|
| 58 | 61 |
@GetMapping("/api/orgs/{id}/review")
|
| 59 | 62 |
public ReviewPage list(@PathVariable("id") Long orgId,
|
| 60 | 63 |
@RequestParam(required = false) String keyword, |
| 64 |
+ @RequestParam(required = false) String site, |
|
| 65 |
+ @RequestParam(required = false) String board, |
|
| 66 |
+ @RequestParam(required = false) String title, |
|
| 61 | 67 |
@RequestParam(defaultValue = "0") int page, |
| 62 | 68 |
@RequestParam(defaultValue = "30") int size) {
|
| 63 |
- return reviewService.list(orgId, keyword, page, size); |
|
| 69 |
+ return reviewService.list(orgId, keyword, site, board, title, page, size); |
|
| 70 |
+ } |
|
| 71 |
+ |
|
| 72 |
+ /** 목록에서 체크한 건들에 같은 값을 한 번에 반영한다. 반영된 건수를 돌려준다. */ |
|
| 73 |
+ @PutMapping("/api/orgs/{id}/review/bulk")
|
|
| 74 |
+ public java.util.Map<String, Integer> updateBulk(@PathVariable("id") Long orgId,
|
|
| 75 |
+ @RequestBody BulkJudgmentRequest request) {
|
|
| 76 |
+ return java.util.Map.of("updated", reviewService.updateBulk(orgId, request));
|
|
| 77 |
+ } |
|
| 78 |
+ |
|
| 79 |
+ /** 목록에서 체크한 건들을 지운다. */ |
|
| 80 |
+ @PostMapping("/api/orgs/{id}/review/bulk-delete")
|
|
| 81 |
+ public java.util.Map<String, Integer> deleteBulk(@PathVariable("id") Long orgId,
|
|
| 82 |
+ @RequestBody java.util.List<Long> ids) {
|
|
| 83 |
+ return java.util.Map.of("deleted", reviewService.deleteBulk(orgId, ids));
|
|
| 64 | 84 |
} |
| 65 | 85 |
|
| 66 | 86 |
@GetMapping("/api/orgs/{id}/review/{itemId}")
|
--- src/main/java/kr/itn/itnhub/review/ReviewItemMapper.java
+++ src/main/java/kr/itn/itnhub/review/ReviewItemMapper.java
... | ... | @@ -8,11 +8,31 @@ |
| 8 | 8 |
@Mapper |
| 9 | 9 |
public interface ReviewItemMapper {
|
| 10 | 10 |
|
| 11 |
- /** 순번 오름차순, keyword는 사이트명/게시판명/게시물제목 LIKE. keyword가 null이면 필터 없음. */ |
|
| 11 |
+ /** |
|
| 12 |
+ * 순번 오름차순. keyword는 사이트명/게시판명/게시물제목을 OR로 훑는 통합검색이고, |
|
| 13 |
+ * site/board/title은 각 열을 따로 걸러 서로 AND로 묶이는 복합검색이다(요청자 요구: |
|
| 14 |
+ * "관광공사의 A 게시판 중 B가 포함된 게시물"처럼 조건을 겹쳐 찾는 용도). null이면 무시된다. |
|
| 15 |
+ */ |
|
| 12 | 16 |
List<ReviewItem> findPage(@Param("orgId") Long orgId, @Param("keyword") String keyword,
|
| 17 |
+ @Param("site") String site, @Param("board") String board,
|
|
| 18 |
+ @Param("title") String title,
|
|
| 13 | 19 |
@Param("offset") int offset, @Param("limit") int limit);
|
| 14 | 20 |
|
| 15 |
- int countByOrg(@Param("orgId") Long orgId, @Param("keyword") String keyword);
|
|
| 21 |
+ int countByOrg(@Param("orgId") Long orgId, @Param("keyword") String keyword,
|
|
| 22 |
+ @Param("site") String site, @Param("board") String board,
|
|
| 23 |
+ @Param("title") String title);
|
|
| 24 |
+ |
|
| 25 |
+ /** |
|
| 26 |
+ * 여러 건에 같은 값을 한 번에 반영한다(일괄 등록). null로 준 항목은 건드리지 않는다. |
|
| 27 |
+ * 개별 저장과 마찬가지로 web_edited_at을 채워, 엑셀 재업로드 때 이 값이 덮이지 않게 한다. |
|
| 28 |
+ */ |
|
| 29 |
+ int updateJudgmentBulk(@Param("orgId") Long orgId, @Param("ids") List<Long> ids,
|
|
| 30 |
+ @Param("reviewResult") String reviewResult,
|
|
| 31 |
+ @Param("judgedKoglType") String judgedKoglType,
|
|
| 32 |
+ @Param("opinion") String opinion);
|
|
| 33 |
+ |
|
| 34 |
+ /** 일괄 삭제. 목록에서 여러 건을 골라 지울 때 쓴다. */ |
|
| 35 |
+ int deleteByIds(@Param("orgId") Long orgId, @Param("ids") List<Long> ids);
|
|
| 16 | 36 |
|
| 17 | 37 |
/** 처리결과(review_result)가 채워진 행 수 - 검색어와 무관한 기관 전체 진행률. */ |
| 18 | 38 |
int countDone(@Param("orgId") Long orgId);
|
--- src/main/java/kr/itn/itnhub/review/ReviewService.java
+++ src/main/java/kr/itn/itnhub/review/ReviewService.java
... | ... | @@ -34,19 +34,54 @@ |
| 34 | 34 |
this.orgMapper = orgMapper; |
| 35 | 35 |
} |
| 36 | 36 |
|
| 37 |
- public ReviewPage list(Long orgId, String keyword, int page, int size) {
|
|
| 37 |
+ public ReviewPage list(Long orgId, String keyword, String site, String board, String title, |
|
| 38 |
+ int page, int size) {
|
|
| 38 | 39 |
requireOrg(orgId); |
| 39 | 40 |
String kw = normalizeKeyword(keyword); |
| 41 |
+ String siteFilter = normalizeKeyword(site); |
|
| 42 |
+ String boardFilter = normalizeKeyword(board); |
|
| 43 |
+ String titleFilter = normalizeKeyword(title); |
|
| 40 | 44 |
int safePage = Math.max(page, 0); |
| 41 | 45 |
int safeSize = size <= 0 ? 30 : size; |
| 42 | 46 |
int offset = safePage * safeSize; |
| 43 | 47 |
|
| 44 |
- List<ReviewItem> items = mapper.findPage(orgId, kw, offset, safeSize); |
|
| 45 |
- int total = mapper.countByOrg(orgId, kw); |
|
| 48 |
+ List<ReviewItem> items = |
|
| 49 |
+ mapper.findPage(orgId, kw, siteFilter, boardFilter, titleFilter, offset, safeSize); |
|
| 50 |
+ int total = mapper.countByOrg(orgId, kw, siteFilter, boardFilter, titleFilter); |
|
| 46 | 51 |
int done = mapper.countDone(orgId); |
| 47 | 52 |
return new ReviewPage(items, total, done, safePage, safeSize); |
| 48 | 53 |
} |
| 49 | 54 |
|
| 55 |
+ /** |
|
| 56 |
+ * 목록에서 고른 여러 건에 같은 값을 한 번에 반영한다. 화면에서 비워 둔 항목은 null로 와서 |
|
| 57 |
+ * 기존 값이 유지된다 - "체크한 것 전부 0유형, 의견은 ooo" 식의 일괄 등록 요구를 위한 것이다. |
|
| 58 |
+ */ |
|
| 59 |
+ @Transactional |
|
| 60 |
+ public int updateBulk(Long orgId, BulkJudgmentRequest request) {
|
|
| 61 |
+ requireOrg(orgId); |
|
| 62 |
+ if (request.ids() == null || request.ids().isEmpty()) {
|
|
| 63 |
+ return 0; |
|
| 64 |
+ } |
|
| 65 |
+ return mapper.updateJudgmentBulk(orgId, request.ids(), |
|
| 66 |
+ blankToNull(request.reviewResult()), |
|
| 67 |
+ blankToNull(request.judgedKoglType()), |
|
| 68 |
+ blankToNull(request.opinion())); |
|
| 69 |
+ } |
|
| 70 |
+ |
|
| 71 |
+ /** 목록에서 고른 여러 건을 지운다. */ |
|
| 72 |
+ @Transactional |
|
| 73 |
+ public int deleteBulk(Long orgId, List<Long> ids) {
|
|
| 74 |
+ requireOrg(orgId); |
|
| 75 |
+ if (ids == null || ids.isEmpty()) {
|
|
| 76 |
+ return 0; |
|
| 77 |
+ } |
|
| 78 |
+ return mapper.deleteByIds(orgId, ids); |
|
| 79 |
+ } |
|
| 80 |
+ |
|
| 81 |
+ private String blankToNull(String value) {
|
|
| 82 |
+ return value == null || value.isBlank() ? null : value; |
|
| 83 |
+ } |
|
| 84 |
+ |
|
| 50 | 85 |
public ReviewItem get(Long orgId, Long itemId) {
|
| 51 | 86 |
requireOrg(orgId); |
| 52 | 87 |
return requireItem(orgId, itemId); |
--- src/main/java/kr/itn/itnhub/stage/StageHistoryMapper.java
+++ src/main/java/kr/itn/itnhub/stage/StageHistoryMapper.java
... | ... | @@ -12,4 +12,7 @@ |
| 12 | 12 |
List<StageHistoryRow> findByOrg(@Param("orgId") Long orgId);
|
| 13 | 13 |
|
| 14 | 14 |
int insert(@Param("orgId") Long orgId, @Param("stage") int stage);
|
| 15 |
+ |
|
| 16 |
+ /** 채널 초기화용. 단계가 null로 돌아가므로 이력을 남겨두면 어긋난다. */ |
|
| 17 |
+ int deleteByOrg(@Param("orgId") Long orgId);
|
|
| 15 | 18 |
} |
+++ src/main/java/kr/itn/itnhub/system/ResetReport.java
... | ... | @@ -0,0 +1,13 @@ |
| 1 | +package kr.itn.itnhub.system; | |
| 2 | + | |
| 3 | +import java.util.List; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * 채널 초기화 실행 결과. | |
| 7 | + * | |
| 8 | + * @param archivedChannels 실제로 보관 처리된 채널 수 | |
| 9 | + * @param clearedOrgs DB에서 채널ID·단계를 비운 기관 수 | |
| 10 | + * @param failures 채널별 실패 사유. 하나가 실패해도 나머지는 계속 진행한다 | |
| 11 | + */ | |
| 12 | +public record ResetReport(int archivedChannels, int clearedOrgs, List<String> failures) { | |
| 13 | +} |
+++ src/main/java/kr/itn/itnhub/system/ResetTarget.java
... | ... | @@ -0,0 +1,16 @@ |
| 1 | +package kr.itn.itnhub.system; | |
| 2 | + | |
| 3 | +import java.util.List; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * 채널 초기화 미리보기 1행. 실행 전에 "무엇이 몇 개 보관되는지"를 사람이 눈으로 확인하기 위한 것이다. | |
| 7 | + * | |
| 8 | + * @param channels 이 기관에 대해 웹앱 DB가 알고 있는 채널(문정원/법률검토). 최대 2개 | |
| 9 | + */ | |
| 10 | +public record ResetTarget(Long orgId, String orgNo, String orgName, Integer stage, | |
| 11 | + List<ResetChannel> channels) { | |
| 12 | + | |
| 13 | + /** @param kind "문정원" 또는 "법률검토" */ | |
| 14 | + public record ResetChannel(String kind, String channelId) { | |
| 15 | + } | |
| 16 | +} |
+++ src/main/java/kr/itn/itnhub/system/SystemResetController.java
... | ... | @@ -0,0 +1,58 @@ |
| 1 | +package kr.itn.itnhub.system; | |
| 2 | + | |
| 3 | +import org.springframework.http.HttpStatus; | |
| 4 | +import org.springframework.web.bind.annotation.GetMapping; | |
| 5 | +import org.springframework.web.bind.annotation.PostMapping; | |
| 6 | +import org.springframework.web.bind.annotation.RequestBody; | |
| 7 | +import org.springframework.web.bind.annotation.ResponseStatus; | |
| 8 | +import org.springframework.web.bind.annotation.RestController; | |
| 9 | +import org.springframework.web.bind.annotation.ExceptionHandler; | |
| 10 | + | |
| 11 | +import java.util.List; | |
| 12 | +import java.util.Map; | |
| 13 | + | |
| 14 | +@RestController | |
| 15 | +public class SystemResetController { | |
| 16 | + | |
| 17 | + /** 실행 확인 문구. 사용자가 그대로 입력해야만 초기화가 돈다. */ | |
| 18 | + static final String CONFIRM_PHRASE = "채널 초기화"; | |
| 19 | + | |
| 20 | + private final SystemResetService resetService; | |
| 21 | + | |
| 22 | + public SystemResetController(SystemResetService resetService) { | |
| 23 | + this.resetService = resetService; | |
| 24 | + } | |
| 25 | + | |
| 26 | + @GetMapping("/api/system/reset/preview") | |
| 27 | + public List<ResetTarget> preview() { | |
| 28 | + return resetService.preview(); | |
| 29 | + } | |
| 30 | + | |
| 31 | + /** | |
| 32 | + * 되돌리기 번거로운 작업이라 확인 문구를 본문으로 받는다. 버튼 오클릭만으로는 절대 | |
| 33 | + * 실행되지 않게 하기 위한 것이다(프런트의 확인창과 이중 방어). | |
| 34 | + */ | |
| 35 | + @PostMapping("/api/system/reset/channels") | |
| 36 | + public ResetReport reset(@RequestBody ConfirmRequest request) { | |
| 37 | + if (!CONFIRM_PHRASE.equals(request.confirm())) { | |
| 38 | + throw new InvalidConfirmException("확인 문구가 일치하지 않습니다: \"%s\" 를 입력해야 합니다." | |
| 39 | + .formatted(CONFIRM_PHRASE)); | |
| 40 | + } | |
| 41 | + return resetService.reset(); | |
| 42 | + } | |
| 43 | + | |
| 44 | + public record ConfirmRequest(String confirm) { | |
| 45 | + } | |
| 46 | + | |
| 47 | + static class InvalidConfirmException extends RuntimeException { | |
| 48 | + InvalidConfirmException(String message) { | |
| 49 | + super(message); | |
| 50 | + } | |
| 51 | + } | |
| 52 | + | |
| 53 | + @ResponseStatus(HttpStatus.BAD_REQUEST) | |
| 54 | + @ExceptionHandler(InvalidConfirmException.class) | |
| 55 | + public Map<String, String> handleInvalidConfirm(InvalidConfirmException e) { | |
| 56 | + return Map.of("message", e.getMessage()); | |
| 57 | + } | |
| 58 | +} |
+++ src/main/java/kr/itn/itnhub/system/SystemResetService.java
... | ... | @@ -0,0 +1,115 @@ |
| 1 | +package kr.itn.itnhub.system; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.mattermost.MattermostClient; | |
| 4 | +import kr.itn.itnhub.org.Organization; | |
| 5 | +import kr.itn.itnhub.org.OrganizationMapper; | |
| 6 | +import kr.itn.itnhub.stage.StageHistoryMapper; | |
| 7 | +import org.slf4j.Logger; | |
| 8 | +import org.slf4j.LoggerFactory; | |
| 9 | +import org.springframework.stereotype.Service; | |
| 10 | + | |
| 11 | +import java.util.ArrayList; | |
| 12 | +import java.util.List; | |
| 13 | + | |
| 14 | +/** | |
| 15 | + * 시스템관리 > 채널 초기화. 웹앱이 만든 Mattermost 채널을 보관 처리하고, 기관의 채널 상태를 | |
| 16 | + * 비워 채널관리에서 처음부터 다시 만들 수 있게 되돌린다. | |
| 17 | + * | |
| 18 | + * <p><b>대상은 웹앱 DB가 아는 채널뿐이다.</b> organization.channel_id_mj / channel_id_law에 | |
| 19 | + * 들어 있는 ID만 건드린다. Mattermost 팀에는 엑셀 VBA(M07)로 만든 예전 채널과 공용 채널 | |
| 20 | + * (Town Square, FAQ, 대쉬보드, 양식 모음, 자료실)이 함께 있는데, 이름으로 훑어서 지우면 | |
| 21 | + * 그것들까지 날아간다. 그래서 이름 검색 경로는 의도적으로 만들지 않았다.</p> | |
| 22 | + * | |
| 23 | + * <p><b>사용자 계정은 건드리지 않는다.</b> 웹앱은 만든 계정을 기록하지 않고 이메일로 조회해 | |
| 24 | + * 없을 때만 만드는 구조라, 원래 있던 실제 직원 계정과 구분할 방법이 없다. 계정까지 정리하려면 | |
| 25 | + * 생성 출처를 DB에 남기는 작업이 먼저다.</p> | |
| 26 | + * | |
| 27 | + * <p>보관은 Mattermost의 아카이브다(영구 삭제 아님). 게시글·파일은 남고 시스템 콘솔에서 | |
| 28 | + * 복구할 수 있다. 권리확인/권리처리·업무메모·담당자 배정 등 DB 데이터도 그대로 둔다.</p> | |
| 29 | + */ | |
| 30 | +@Service | |
| 31 | +public class SystemResetService { | |
| 32 | + | |
| 33 | + private static final Logger log = LoggerFactory.getLogger(SystemResetService.class); | |
| 34 | + | |
| 35 | + static final String KIND_MJ = "문정원"; | |
| 36 | + static final String KIND_LAW = "법률검토"; | |
| 37 | + | |
| 38 | + private final OrganizationMapper orgMapper; | |
| 39 | + private final StageHistoryMapper stageHistoryMapper; | |
| 40 | + private final MattermostClient mattermost; | |
| 41 | + | |
| 42 | + public SystemResetService(OrganizationMapper orgMapper, StageHistoryMapper stageHistoryMapper, | |
| 43 | + MattermostClient mattermost) { | |
| 44 | + this.orgMapper = orgMapper; | |
| 45 | + this.stageHistoryMapper = stageHistoryMapper; | |
| 46 | + this.mattermost = mattermost; | |
| 47 | + } | |
| 48 | + | |
| 49 | + /** 초기화하면 무엇이 사라지는지 미리 보여준다. 아무것도 바꾸지 않는다. */ | |
| 50 | + public List<ResetTarget> preview() { | |
| 51 | + List<ResetTarget> targets = new ArrayList<>(); | |
| 52 | + for (Organization org : orgMapper.findAll()) { | |
| 53 | + List<ResetTarget.ResetChannel> channels = channelsOf(org); | |
| 54 | + if (channels.isEmpty()) { | |
| 55 | + continue; | |
| 56 | + } | |
| 57 | + targets.add(new ResetTarget(org.getId(), org.getOrgNo(), org.getOrgName(), | |
| 58 | + org.getStage(), channels)); | |
| 59 | + } | |
| 60 | + return targets; | |
| 61 | + } | |
| 62 | + | |
| 63 | + /** | |
| 64 | + * 실제 초기화. 채널 하나가 실패해도 나머지는 계속한다 - 이미 지워진 채널이나 권한 문제로 | |
| 65 | + * 전체가 멈추면, 절반만 정리된 상태에서 다시 실행할 방법이 없어지기 때문이다. | |
| 66 | + * | |
| 67 | + * <p>DB 정리는 <b>그 기관의 채널을 모두 보관한 뒤에만</b> 한다. 한쪽만 보관된 상태에서 | |
| 68 | + * ID를 지워버리면 남은 채널을 다시 찾을 방법이 사라진다.</p> | |
| 69 | + */ | |
| 70 | + public ResetReport reset() { | |
| 71 | + int archived = 0; | |
| 72 | + int cleared = 0; | |
| 73 | + List<String> failures = new ArrayList<>(); | |
| 74 | + | |
| 75 | + for (ResetTarget target : preview()) { | |
| 76 | + boolean allArchived = true; | |
| 77 | + | |
| 78 | + for (ResetTarget.ResetChannel channel : target.channels()) { | |
| 79 | + try { | |
| 80 | + mattermost.archiveChannel(channel.channelId()); | |
| 81 | + archived++; | |
| 82 | + } catch (RuntimeException e) { | |
| 83 | + allArchived = false; | |
| 84 | + failures.add("%s %s (%s): %s".formatted( | |
| 85 | + target.orgNo(), target.orgName(), channel.kind(), e.getMessage())); | |
| 86 | + log.warn("채널 보관 실패 org={} kind={}", target.orgNo(), channel.kind(), e); | |
| 87 | + } | |
| 88 | + } | |
| 89 | + | |
| 90 | + if (allArchived) { | |
| 91 | + orgMapper.clearChannels(target.orgId()); | |
| 92 | + stageHistoryMapper.deleteByOrg(target.orgId()); | |
| 93 | + cleared++; | |
| 94 | + } | |
| 95 | + } | |
| 96 | + | |
| 97 | + log.info("채널 초기화 완료: 보관 {}개, 기관 {}곳 초기화, 실패 {}건", archived, cleared, failures.size()); | |
| 98 | + return new ResetReport(archived, cleared, failures); | |
| 99 | + } | |
| 100 | + | |
| 101 | + private List<ResetTarget.ResetChannel> channelsOf(Organization org) { | |
| 102 | + List<ResetTarget.ResetChannel> channels = new ArrayList<>(2); | |
| 103 | + if (hasText(org.getChannelIdMj())) { | |
| 104 | + channels.add(new ResetTarget.ResetChannel(KIND_MJ, org.getChannelIdMj())); | |
| 105 | + } | |
| 106 | + if (hasText(org.getChannelIdLaw())) { | |
| 107 | + channels.add(new ResetTarget.ResetChannel(KIND_LAW, org.getChannelIdLaw())); | |
| 108 | + } | |
| 109 | + return channels; | |
| 110 | + } | |
| 111 | + | |
| 112 | + private boolean hasText(String value) { | |
| 113 | + return value != null && !value.isBlank(); | |
| 114 | + } | |
| 115 | +} |
+++ src/main/resources/db/migration/V11__stage_backfill_for_channeled_orgs.sql
... | ... | @@ -0,0 +1,20 @@ |
| 1 | +-- 채널은 이미 만들었는데 진행단계가 비어 있는 기관을 1단계(신청)로 채운다. | |
| 2 | +-- | |
| 3 | +-- 단계 기능(V8) 이전에 채널을 만든 기관들이 이 상태로 남아 있다. 대시보드 칸반은 | |
| 4 | +-- "단계 미지정" 컬럼을 채널 미생성 기관에만 쓰기로 했으므로(요청자 확정), 이 기관들은 | |
| 5 | +-- 미지정 컬럼에도 ①~⑫ 컬럼에도 속하지 못해 보드에서 사라져 버린다. | |
| 6 | +-- | |
| 7 | +-- 이 백필 이후로는 "채널 있음 <=> 단계 있음" 이 항상 성립한다 - 채널 생성 시 1단계를 | |
| 8 | +-- 자동 시작하는 ChannelProvisionService 로직과 같은 상태가 된다. 1회성이며, 조건에 | |
| 9 | +-- 걸리는 기관이 없으면 아무것도 하지 않는다. | |
| 10 | +insert into stage_history (org_id, stage) | |
| 11 | +select id, 1 | |
| 12 | +from organization | |
| 13 | +where stage is null | |
| 14 | + and (channel_id_mj is not null or channel_id_law is not null); | |
| 15 | + | |
| 16 | +update organization | |
| 17 | +set stage = 1, | |
| 18 | + updated_at = now() | |
| 19 | +where stage is null | |
| 20 | + and (channel_id_mj is not null or channel_id_law is not null); |
+++ src/main/resources/db/migration/V12__project_management.sql
... | ... | @@ -0,0 +1,30 @@ |
| 1 | +-- 사업관리 메뉴용 두 테이블. | |
| 2 | +-- | |
| 3 | +-- contact_log: "신청 확인 연락" 이력. 요청자가 이 부분은 자료가 자주 바뀌어 이력관리가 | |
| 4 | +-- 필요하다고 했으므로, 한 기관에 여러 건이 쌓이고 지우지 않는 이상 남는다. | |
| 5 | +-- org_report: 기관별 최종 보고서. 같은 기관에 여러 번 올릴 수 있고(개정판) 최신순으로 본다. | |
| 6 | +-- 파일은 DB에 그대로 담는다 - Mattermost 채널에 올리면 채널을 초기화하거나 아카이브할 때 | |
| 7 | +-- 보고서까지 같이 사라지기 때문이다. | |
| 8 | + | |
| 9 | +create table contact_log ( | |
| 10 | + id bigserial primary key, | |
| 11 | + org_id bigint not null references organization (id) on delete cascade, | |
| 12 | + contacted_on date not null, | |
| 13 | + method varchar(20) not null, -- 전화 / 메일 / 방문 / 기타 | |
| 14 | + summary varchar(1000) not null, | |
| 15 | + author varchar(100) not null, -- 로그인 사용자 | |
| 16 | + created_at timestamptz not null default now() | |
| 17 | +); | |
| 18 | +create index ix_contact_log_org on contact_log (org_id, contacted_on desc, id desc); | |
| 19 | + | |
| 20 | +create table org_report ( | |
| 21 | + id bigserial primary key, | |
| 22 | + org_id bigint not null references organization (id) on delete cascade, | |
| 23 | + file_name varchar(300) not null, | |
| 24 | + content_type varchar(200), | |
| 25 | + byte_size bigint not null, | |
| 26 | + content bytea not null, | |
| 27 | + uploaded_by varchar(100) not null, | |
| 28 | + uploaded_at timestamptz not null default now() | |
| 29 | +); | |
| 30 | +create index ix_org_report_org on org_report (org_id, uploaded_at desc); |
+++ src/main/resources/mapper/DashboardMapper.xml
... | ... | @@ -0,0 +1,94 @@ |
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | |
| 2 | +<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" | |
| 3 | + "https://mybatis.org/dtd/mybatis-3-mapper.dtd"> | |
| 4 | +<mapper namespace="kr.itn.itnhub.dashboard.DashboardMapper"> | |
| 5 | + | |
| 6 | + <!-- | |
| 7 | + record라서 constructor 매핑을 쓴다(ProcessItemMapper의 statsResultMap과 같은 방식). | |
| 8 | + arg 순서는 DashboardOrgRow의 컴포넌트 순서와 반드시 같아야 한다. | |
| 9 | + --> | |
| 10 | + <resultMap id="orgRowResultMap" type="kr.itn.itnhub.dashboard.DashboardOrgRow"> | |
| 11 | + <constructor> | |
| 12 | + <idArg column="id" javaType="java.lang.Long"/> | |
| 13 | + <arg column="org_no" javaType="java.lang.String"/> | |
| 14 | + <arg column="org_name" javaType="java.lang.String"/> | |
| 15 | + <arg column="stage" javaType="java.lang.Integer"/> | |
| 16 | + <arg column="has_channel" javaType="_boolean"/> | |
| 17 | + <arg column="lawyer_name" javaType="java.lang.String"/> | |
| 18 | + <arg column="mj_name" javaType="java.lang.String"/> | |
| 19 | + <arg column="lawyer_assigned_date" javaType="java.lang.String"/> | |
| 20 | + <arg column="review_total" javaType="_int"/> | |
| 21 | + <arg column="review_done" javaType="_int"/> | |
| 22 | + <arg column="process_total" javaType="_int"/> | |
| 23 | + <arg column="process_done" javaType="_int"/> | |
| 24 | + <arg column="stage_changed_at" javaType="java.lang.Long"/> | |
| 25 | + <arg column="last_changed_at" javaType="java.lang.Long"/> | |
| 26 | + <arg column="re_count" javaType="_int"/> | |
| 27 | + </constructor> | |
| 28 | + </resultMap> | |
| 29 | + | |
| 30 | + <!-- | |
| 31 | + 기관 1행당 집계를 서브쿼리로 붙인다. 기관별로 countByOrg/stats를 반복 호출하면 | |
| 32 | + 기관 50곳 × 3종 = 150회 조회가 되므로(대시보드는 화면 진입마다 그린다) 여기서 한 방에 끝낸다. | |
| 33 | + | |
| 34 | + stage_at / any_at 두 값이 서로 다르다: | |
| 35 | + - stage_at = 지금 단계로 넘어온 시각. 카드의 D+n은 이걸 쓴다 | |
| 36 | + - any_at = 단계 이력 전체의 최신값. "최근 변경"의 재료 중 하나다 | |
| 37 | + lateral을 쓰는 이유는 filter 절에서 o.stage를 참조해야 하기 때문이다. | |
| 38 | + | |
| 39 | + last_changed_at은 greatest()로 합친다. Postgres의 greatest는 NULL을 무시하므로 | |
| 40 | + 권리확인/처리/메모가 아직 없는 기관도 organization.updated_at으로 값이 채워진다. | |
| 41 | + organization.updated_at만으로는 부족한 이유: 배정·단계·채널 변경 때만 갱신되고 | |
| 42 | + 권리확인 업로드나 업무메모 작성은 이 컬럼을 건드리지 않는다. | |
| 43 | + | |
| 44 | + re_count는 RE 테이블이 아직 없어서 0으로 고정한다. RE 모델이 들어오면 여기에 | |
| 45 | + 서브쿼리 하나만 붙이면 화면은 그대로 동작한다. | |
| 46 | + --> | |
| 47 | + <select id="findOrgRows" resultMap="orgRowResultMap"> | |
| 48 | + select | |
| 49 | + o.id, | |
| 50 | + o.org_no, | |
| 51 | + o.org_name, | |
| 52 | + o.stage, | |
| 53 | + (o.channel_id_mj is not null or o.channel_id_law is not null) as has_channel, | |
| 54 | + lc.name as lawyer_name, | |
| 55 | + mc.name as mj_name, | |
| 56 | + o.lawyer_assigned_date, | |
| 57 | + coalesce(rv.total, 0) as review_total, | |
| 58 | + coalesce(rv.done, 0) as review_done, | |
| 59 | + coalesce(pr.total, 0) as process_total, | |
| 60 | + coalesce(pr.done, 0) as process_done, | |
| 61 | + (extract(epoch from sh.stage_at) * 1000)::bigint as stage_changed_at, | |
| 62 | + (extract(epoch from greatest(o.updated_at, sh.any_at, rv.last_at, pr.last_at, wm.last_at)) * 1000)::bigint | |
| 63 | + as last_changed_at, | |
| 64 | + 0 as re_count | |
| 65 | + from organization o | |
| 66 | + left join contact lc on lc.id = o.lawyer_contact_id | |
| 67 | + left join contact mc on mc.id = o.mj_contact_id | |
| 68 | + left join ( | |
| 69 | + select org_id, | |
| 70 | + count(*) as total, | |
| 71 | + count(*) filter (where review_result is not null and review_result <> '') as done, | |
| 72 | + max(updated_at) as last_at | |
| 73 | + from review_item group by org_id | |
| 74 | + ) rv on rv.org_id = o.id | |
| 75 | + left join ( | |
| 76 | + select org_id, | |
| 77 | + count(*) as total, | |
| 78 | + count(*) filter (where process_status = '처리완료') as done, | |
| 79 | + max(updated_at) as last_at | |
| 80 | + from process_item group by org_id | |
| 81 | + ) pr on pr.org_id = o.id | |
| 82 | + left join ( | |
| 83 | + select org_id, max(created_at) as last_at from work_memo group by org_id | |
| 84 | + ) wm on wm.org_id = o.id | |
| 85 | + left join lateral ( | |
| 86 | + select max(h.changed_at) filter (where h.stage = o.stage) as stage_at, | |
| 87 | + max(h.changed_at) as any_at | |
| 88 | + from stage_history h | |
| 89 | + where h.org_id = o.id | |
| 90 | + ) sh on true | |
| 91 | + order by o.org_no asc, o.org_name asc | |
| 92 | + </select> | |
| 93 | + | |
| 94 | +</mapper> |
--- src/main/resources/mapper/OrganizationMapper.xml
+++ src/main/resources/mapper/OrganizationMapper.xml
... | ... | @@ -163,4 +163,14 @@ |
| 163 | 163 |
where id = #{id}
|
| 164 | 164 |
</update> |
| 165 | 165 |
|
| 166 |
+ <!-- 시스템관리 > 채널 초기화. 담당자 배정·권리확인/처리 데이터는 건드리지 않는다. --> |
|
| 167 |
+ <update id="clearChannels"> |
|
| 168 |
+ update organization set |
|
| 169 |
+ channel_id_mj = null, |
|
| 170 |
+ channel_id_law = null, |
|
| 171 |
+ stage = null, |
|
| 172 |
+ updated_at = now() |
|
| 173 |
+ where id = #{id}
|
|
| 174 |
+ </update> |
|
| 175 |
+ |
|
| 166 | 176 |
</mapper> |
--- src/main/resources/mapper/ProcessItemMapper.xml
+++ src/main/resources/mapper/ProcessItemMapper.xml
... | ... | @@ -24,6 +24,16 @@ |
| 24 | 24 |
and (site_name ilike concat('%', #{keyword}, '%')
|
| 25 | 25 |
or post_title ilike concat('%', #{keyword}, '%'))
|
| 26 | 26 |
</if> |
| 27 |
+ <!-- 열별 조건. 서로 AND로 겹쳐서 복합검색이 된다(권리확인과 동일 규칙). --> |
|
| 28 |
+ <if test="site != null"> |
|
| 29 |
+ and site_name ilike concat('%', #{site}, '%')
|
|
| 30 |
+ </if> |
|
| 31 |
+ <if test="board != null"> |
|
| 32 |
+ and board_name ilike concat('%', #{board}, '%')
|
|
| 33 |
+ </if> |
|
| 34 |
+ <if test="title != null"> |
|
| 35 |
+ and post_title ilike concat('%', #{title}, '%')
|
|
| 36 |
+ </if> |
|
| 27 | 37 |
</sql> |
| 28 | 38 |
|
| 29 | 39 |
<!-- status: null=전체, "DONE"=처리완료, "PENDING"=그 외(null/빈/미처리 등 처리완료가 아닌 전부). --> |
... | ... | @@ -157,6 +167,27 @@ |
| 157 | 167 |
</constructor> |
| 158 | 168 |
</resultMap> |
| 159 | 169 |
|
| 170 |
+ <!-- 일괄 등록. 채운 항목만 반영되도록 coalesce를 쓰고, 처리완료로 바뀔 때만 처리시각을 찍는다. --> |
|
| 171 |
+ <update id="updateProcessingBulk"> |
|
| 172 |
+ update process_item set |
|
| 173 |
+ process_status = coalesce(#{processStatus}, process_status),
|
|
| 174 |
+ judged_kogl_type = coalesce(#{judgedKoglType}, judged_kogl_type),
|
|
| 175 |
+ final_opinion = coalesce(#{finalOpinion}, final_opinion),
|
|
| 176 |
+ processed_at = case when #{processStatus} = '처리완료' then now() else processed_at end,
|
|
| 177 |
+ web_edited_at = now(), |
|
| 178 |
+ updated_at = now() |
|
| 179 |
+ where org_id = #{orgId}
|
|
| 180 |
+ and id in |
|
| 181 |
+ <foreach item="id" collection="ids" open="(" separator="," close=")">#{id}</foreach>
|
|
| 182 |
+ </update> |
|
| 183 |
+ |
|
| 184 |
+ <delete id="deleteByIds"> |
|
| 185 |
+ delete from process_item |
|
| 186 |
+ where org_id = #{orgId}
|
|
| 187 |
+ and id in |
|
| 188 |
+ <foreach item="id" collection="ids" open="(" separator="," close=")">#{id}</foreach>
|
|
| 189 |
+ </delete> |
|
| 190 |
+ |
|
| 160 | 191 |
<select id="stats" resultMap="statsResultMap"> |
| 161 | 192 |
select |
| 162 | 193 |
count(*) as total, |
+++ src/main/resources/mapper/ProjectMapper.xml
... | ... | @@ -0,0 +1,78 @@ |
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | |
| 2 | +<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" | |
| 3 | + "https://mybatis.org/dtd/mybatis-3-mapper.dtd"> | |
| 4 | +<mapper namespace="kr.itn.itnhub.project.ProjectMapper"> | |
| 5 | + | |
| 6 | + <!-- | |
| 7 | + 사업관리 목록에 붙일 값만 기관별로 한 번에 센다. 기관 현황 본체는 DashboardMapper가 | |
| 8 | + 이미 만들어 주므로 여기서 다시 조회하지 않는다(같은 숫자를 두 곳에서 세지 않기 위함). | |
| 9 | + --> | |
| 10 | + <select id="findSideInfo" resultType="kr.itn.itnhub.project.ProjectMapper$ProjectSideInfo"> | |
| 11 | + select | |
| 12 | + o.id as org_id, | |
| 13 | + to_char(cl.last_on, 'YYYY-MM-DD') as last_contacted_on, | |
| 14 | + coalesce(cl.cnt, 0) as contact_count, | |
| 15 | + coalesce(rp.cnt, 0) as report_count | |
| 16 | + from organization o | |
| 17 | + left join ( | |
| 18 | + select org_id, max(contacted_on) as last_on, count(*) as cnt | |
| 19 | + from contact_log group by org_id | |
| 20 | + ) cl on cl.org_id = o.id | |
| 21 | + left join ( | |
| 22 | + select org_id, count(*) as cnt from org_report group by org_id | |
| 23 | + ) rp on rp.org_id = o.id | |
| 24 | + </select> | |
| 25 | + | |
| 26 | + <!-- 연락 이력: 최근 연락일 순. 같은 날짜면 나중에 넣은 것이 위로 온다. --> | |
| 27 | + <select id="findLogsByOrg" resultType="kr.itn.itnhub.project.ContactLog"> | |
| 28 | + select id, org_id, | |
| 29 | + to_char(contacted_on, 'YYYY-MM-DD') as contacted_on, | |
| 30 | + method, summary, author, | |
| 31 | + (extract(epoch from created_at) * 1000)::bigint as created_at | |
| 32 | + from contact_log | |
| 33 | + where org_id = #{orgId} | |
| 34 | + order by contacted_on desc, id desc | |
| 35 | + </select> | |
| 36 | + | |
| 37 | + <insert id="insertLog"> | |
| 38 | + insert into contact_log (org_id, contacted_on, method, summary, author) | |
| 39 | + values (#{orgId}, cast(#{contactedOn} as date), #{method}, #{summary}, #{author}) | |
| 40 | + </insert> | |
| 41 | + | |
| 42 | + <!-- 작성자는 처음 남긴 사람을 유지한다(업무메모와 같은 규칙). --> | |
| 43 | + <update id="updateLog"> | |
| 44 | + update contact_log set | |
| 45 | + contacted_on = cast(#{contactedOn} as date), | |
| 46 | + method = #{method}, | |
| 47 | + summary = #{summary} | |
| 48 | + where id = #{id} and org_id = #{orgId} | |
| 49 | + </update> | |
| 50 | + | |
| 51 | + <delete id="deleteLog"> | |
| 52 | + delete from contact_log where id = #{id} and org_id = #{orgId} | |
| 53 | + </delete> | |
| 54 | + | |
| 55 | + <select id="findReportsByOrg" resultType="kr.itn.itnhub.project.OrgReport"> | |
| 56 | + select id, org_id, file_name, content_type, byte_size, uploaded_by, | |
| 57 | + (extract(epoch from uploaded_at) * 1000)::bigint as uploaded_at | |
| 58 | + from org_report | |
| 59 | + where org_id = #{orgId} | |
| 60 | + order by uploaded_at desc, id desc | |
| 61 | + </select> | |
| 62 | + | |
| 63 | + <insert id="insertReport"> | |
| 64 | + insert into org_report (org_id, file_name, content_type, byte_size, content, uploaded_by) | |
| 65 | + values (#{orgId}, #{fileName}, #{contentType}, #{byteSize}, #{content}, #{uploadedBy}) | |
| 66 | + </insert> | |
| 67 | + | |
| 68 | + <select id="findReportContent" resultType="kr.itn.itnhub.project.ProjectMapper$ReportContent"> | |
| 69 | + select file_name, content_type, content | |
| 70 | + from org_report | |
| 71 | + where id = #{id} and org_id = #{orgId} | |
| 72 | + </select> | |
| 73 | + | |
| 74 | + <delete id="deleteReport"> | |
| 75 | + delete from org_report where id = #{id} and org_id = #{orgId} | |
| 76 | + </delete> | |
| 77 | + | |
| 78 | +</mapper> |
--- src/main/resources/mapper/ReviewItemMapper.xml
+++ src/main/resources/mapper/ReviewItemMapper.xml
... | ... | @@ -17,11 +17,24 @@ |
| 17 | 17 |
(extract(epoch from updated_at) * 1000)::bigint as updated_at |
| 18 | 18 |
</sql> |
| 19 | 19 |
|
| 20 |
+ <!-- |
|
| 21 |
+ keyword는 세 열을 OR로 훑는 통합검색, site/board/title은 열별 조건이라 서로 AND로 겹친다. |
|
| 22 |
+ 둘을 같이 보내면 (통합검색) AND (열별 조건들)이 된다. |
|
| 23 |
+ --> |
|
| 20 | 24 |
<sql id="keywordFilter"> |
| 21 | 25 |
<if test="keyword != null"> |
| 22 | 26 |
and (site_name ilike concat('%', #{keyword}, '%')
|
| 23 | 27 |
or board_name ilike concat('%', #{keyword}, '%')
|
| 24 | 28 |
or post_title ilike concat('%', #{keyword}, '%'))
|
| 29 |
+ </if> |
|
| 30 |
+ <if test="site != null"> |
|
| 31 |
+ and site_name ilike concat('%', #{site}, '%')
|
|
| 32 |
+ </if> |
|
| 33 |
+ <if test="board != null"> |
|
| 34 |
+ and board_name ilike concat('%', #{board}, '%')
|
|
| 35 |
+ </if> |
|
| 36 |
+ <if test="title != null"> |
|
| 37 |
+ and post_title ilike concat('%', #{title}, '%')
|
|
| 25 | 38 |
</if> |
| 26 | 39 |
</sql> |
| 27 | 40 |
|
... | ... | @@ -131,6 +144,29 @@ |
| 131 | 144 |
where org_id = #{orgId} and id = #{id}
|
| 132 | 145 |
</update> |
| 133 | 146 |
|
| 147 |
+ <!-- |
|
| 148 |
+ 일괄 등록. null로 온 항목은 coalesce로 기존 값을 유지하므로, 화면에서 채운 항목만 반영된다. |
|
| 149 |
+ 개별 저장과 같이 web_edited_at을 찍어야 엑셀 재업로드가 이 값을 덮지 않는다. |
|
| 150 |
+ --> |
|
| 151 |
+ <update id="updateJudgmentBulk"> |
|
| 152 |
+ update review_item set |
|
| 153 |
+ review_result = coalesce(#{reviewResult}, review_result),
|
|
| 154 |
+ judged_kogl_type = coalesce(#{judgedKoglType}, judged_kogl_type),
|
|
| 155 |
+ opinion = coalesce(#{opinion}, opinion),
|
|
| 156 |
+ web_edited_at = now(), |
|
| 157 |
+ updated_at = now() |
|
| 158 |
+ where org_id = #{orgId}
|
|
| 159 |
+ and id in |
|
| 160 |
+ <foreach item="id" collection="ids" open="(" separator="," close=")">#{id}</foreach>
|
|
| 161 |
+ </update> |
|
| 162 |
+ |
|
| 163 |
+ <delete id="deleteByIds"> |
|
| 164 |
+ delete from review_item |
|
| 165 |
+ where org_id = #{orgId}
|
|
| 166 |
+ and id in |
|
| 167 |
+ <foreach item="id" collection="ids" open="(" separator="," close=")">#{id}</foreach>
|
|
| 168 |
+ </delete> |
|
| 169 |
+ |
|
| 134 | 170 |
<delete id="deleteById"> |
| 135 | 171 |
delete from review_item where org_id = #{orgId} and id = #{id}
|
| 136 | 172 |
</delete> |
--- src/main/resources/mapper/StageHistoryMapper.xml
+++ src/main/resources/mapper/StageHistoryMapper.xml
... | ... | @@ -18,4 +18,12 @@ |
| 18 | 18 |
values (#{orgId}, #{stage})
|
| 19 | 19 |
</insert> |
| 20 | 20 |
|
| 21 |
+ <!-- |
|
| 22 |
+ 시스템관리 > 채널 초기화 전용. 해당 기관의 단계가 null로 되돌아가므로 이력만 남겨두면 |
|
| 23 |
+ 타임라인/대시보드가 어긋난다. org_id 한 곳만 지우며, 전체 삭제 경로는 두지 않는다. |
|
| 24 |
+ --> |
|
| 25 |
+ <delete id="deleteByOrg"> |
|
| 26 |
+ delete from stage_history where org_id = #{orgId}
|
|
| 27 |
+ </delete> |
|
| 28 |
+ |
|
| 21 | 29 |
</mapper> |
--- src/main/resources/mapper/WorkMemoMapper.xml
+++ src/main/resources/mapper/WorkMemoMapper.xml
... | ... | @@ -24,6 +24,17 @@ |
| 24 | 24 |
values (#{orgId}, #{contactName}, #{body})
|
| 25 | 25 |
</insert> |
| 26 | 26 |
|
| 27 |
+ <!-- |
|
| 28 |
+ 본문만 고친다. 작성자(contact_name)와 작성시각은 그대로 둔다 - 누가 언제 남긴 |
|
| 29 |
+ 메모인지가 이력의 핵심이라 수정으로 덮이면 안 된다. |
|
| 30 |
+ delete와 같은 이유로 org_id를 함께 조건에 넣는다. |
|
| 31 |
+ --> |
|
| 32 |
+ <update id="updateBody"> |
|
| 33 |
+ update work_memo |
|
| 34 |
+ set body = #{body}
|
|
| 35 |
+ where id = #{id} and org_id = #{orgId}
|
|
| 36 |
+ </update> |
|
| 37 |
+ |
|
| 27 | 38 |
<delete id="delete"> |
| 28 | 39 |
delete from work_memo |
| 29 | 40 |
where id = #{id} and org_id = #{orgId}
|
+++ src/test/java/kr/itn/itnhub/dashboard/DashboardControllerTest.java
... | ... | @@ -0,0 +1,156 @@ |
| 1 | +package kr.itn.itnhub.dashboard; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.AbstractDbTest; | |
| 4 | +import kr.itn.itnhub.mattermost.MattermostClient; | |
| 5 | +import kr.itn.itnhub.org.Organization; | |
| 6 | +import kr.itn.itnhub.org.OrganizationMapper; | |
| 7 | +import org.junit.jupiter.api.BeforeEach; | |
| 8 | +import org.junit.jupiter.api.Test; | |
| 9 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 10 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | |
| 11 | +import org.springframework.boot.test.mock.mockito.MockBean; | |
| 12 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 13 | +import org.springframework.security.test.context.support.WithMockUser; | |
| 14 | +import org.springframework.test.web.servlet.MockMvc; | |
| 15 | + | |
| 16 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | |
| 17 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |
| 18 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |
| 19 | + | |
| 20 | +@AutoConfigureMockMvc | |
| 21 | +@WithMockUser(roles = "ADMIN") | |
| 22 | +class DashboardControllerTest extends AbstractDbTest { | |
| 23 | + | |
| 24 | + @Autowired | |
| 25 | + MockMvc mvc; | |
| 26 | + | |
| 27 | + @Autowired | |
| 28 | + OrganizationMapper orgMapper; | |
| 29 | + | |
| 30 | + @Autowired | |
| 31 | + JdbcTemplate jdbc; | |
| 32 | + | |
| 33 | + @MockBean | |
| 34 | + MattermostClient mattermost; | |
| 35 | + | |
| 36 | + private Long noChannelId; | |
| 37 | + private Long stage2Id; | |
| 38 | + private Long stage11Id; | |
| 39 | + | |
| 40 | + @BeforeEach | |
| 41 | + void setUp() { | |
| 42 | + jdbc.update("delete from organization"); | |
| 43 | + | |
| 44 | + // 001: 채널도 단계도 없는 기관 - 칸반의 "단계 미지정" 컬럼 대상 | |
| 45 | + noChannelId = seed("001", "채널없는기관"); | |
| 46 | + | |
| 47 | + // 002: 채널 + 2단계, 권리확인 3건(완료 1건) / 권리처리 2건(완료 1건) | |
| 48 | + stage2Id = seed("002", "권리확인중기관"); | |
| 49 | + orgMapper.updateChannelIdMj(stage2Id, "ch-mj"); | |
| 50 | + setStage(stage2Id, 2); | |
| 51 | + insertReview(stage2Id, 1, "이용가능"); | |
| 52 | + insertReview(stage2Id, 2, null); | |
| 53 | + insertReview(stage2Id, 3, ""); | |
| 54 | + insertProcess(stage2Id, 1, "처리완료"); | |
| 55 | + insertProcess(stage2Id, 2, "미처리"); | |
| 56 | + | |
| 57 | + // 003: 채널 + 11단계 - 완료 집계 대상 | |
| 58 | + stage11Id = seed("003", "최종완료기관"); | |
| 59 | + orgMapper.updateChannelIdMj(stage11Id, "ch-mj-2"); | |
| 60 | + orgMapper.updateChannelIdLaw(stage11Id, "ch-law-2"); | |
| 61 | + setStage(stage11Id, 11); | |
| 62 | + } | |
| 63 | + | |
| 64 | + private Long seed(String orgNo, String orgName) { | |
| 65 | + Organization org = new Organization(); | |
| 66 | + org.setOrgNo(orgNo); | |
| 67 | + org.setOrgName(orgName); | |
| 68 | + org.setChannelSlug(orgNo); | |
| 69 | + orgMapper.upsertBySeed(org); | |
| 70 | + return orgMapper.findByOrgNoAndOrgName(orgNo, orgName).getId(); | |
| 71 | + } | |
| 72 | + | |
| 73 | + private void setStage(Long orgId, int stage) { | |
| 74 | + orgMapper.updateStage(orgId, stage); | |
| 75 | + jdbc.update("insert into stage_history (org_id, stage) values (?, ?)", orgId, stage); | |
| 76 | + } | |
| 77 | + | |
| 78 | + private void insertReview(Long orgId, int seq, String reviewResult) { | |
| 79 | + jdbc.update("insert into review_item (org_id, seq, review_result) values (?, ?, ?)", | |
| 80 | + orgId, seq, reviewResult); | |
| 81 | + } | |
| 82 | + | |
| 83 | + private void insertProcess(Long orgId, int seq, String status) { | |
| 84 | + jdbc.update("insert into process_item (org_id, seq, process_status) values (?, ?, ?)", | |
| 85 | + orgId, seq, status); | |
| 86 | + } | |
| 87 | + | |
| 88 | + @Test | |
| 89 | + void KPI는_전체_기관과_단계별_도달_기관수를_센다() throws Exception { | |
| 90 | + mvc.perform(get("/api/dashboard")) | |
| 91 | + .andExpect(status().isOk()) | |
| 92 | + // 신청 = 명부 전체 기관 수 | |
| 93 | + .andExpect(jsonPath("$.summary.applied").value(3)) | |
| 94 | + // 서류제출 = 2단계 이상 (002, 003) | |
| 95 | + .andExpect(jsonPath("$.summary.docSubmitted").value(2)) | |
| 96 | + // 완료 = 11단계 이상 (003) | |
| 97 | + .andExpect(jsonPath("$.summary.completed").value(1)) | |
| 98 | + // 보고서 작성 = 12단계 이상 (없음) | |
| 99 | + .andExpect(jsonPath("$.summary.reportWriting").value(0)); | |
| 100 | + } | |
| 101 | + | |
| 102 | + @Test | |
| 103 | + void KPI의_권리확인_권리처리는_전_기관_건수_합계다() throws Exception { | |
| 104 | + mvc.perform(get("/api/dashboard")) | |
| 105 | + .andExpect(status().isOk()) | |
| 106 | + .andExpect(jsonPath("$.summary.reviewTotal").value(3)) | |
| 107 | + // 빈 문자열은 미완료로 센다 | |
| 108 | + .andExpect(jsonPath("$.summary.reviewDone").value(1)) | |
| 109 | + .andExpect(jsonPath("$.summary.processTotal").value(2)) | |
| 110 | + .andExpect(jsonPath("$.summary.processDone").value(1)); | |
| 111 | + } | |
| 112 | + | |
| 113 | + @Test | |
| 114 | + void RE는_데이터_모델이_없어_항상_0이다() throws Exception { | |
| 115 | + mvc.perform(get("/api/dashboard")) | |
| 116 | + .andExpect(status().isOk()) | |
| 117 | + .andExpect(jsonPath("$.summary.unresolvedRe").value(0)) | |
| 118 | + .andExpect(jsonPath("$.orgs[0].reCount").value(0)); | |
| 119 | + } | |
| 120 | + | |
| 121 | + @Test | |
| 122 | + void 기관행은_연번순이고_채널_보유_여부를_내려준다() throws Exception { | |
| 123 | + mvc.perform(get("/api/dashboard")) | |
| 124 | + .andExpect(status().isOk()) | |
| 125 | + .andExpect(jsonPath("$.orgs.length()").value(3)) | |
| 126 | + .andExpect(jsonPath("$.orgs[0].orgNo").value("001")) | |
| 127 | + .andExpect(jsonPath("$.orgs[0].hasChannel").value(false)) | |
| 128 | + .andExpect(jsonPath("$.orgs[0].stage").doesNotExist()) | |
| 129 | + .andExpect(jsonPath("$.orgs[1].orgNo").value("002")) | |
| 130 | + .andExpect(jsonPath("$.orgs[1].hasChannel").value(true)) | |
| 131 | + .andExpect(jsonPath("$.orgs[1].stage").value(2)); | |
| 132 | + } | |
| 133 | + | |
| 134 | + @Test | |
| 135 | + void 기관행에_권리확인_권리처리_진척이_기관별로_담긴다() throws Exception { | |
| 136 | + mvc.perform(get("/api/dashboard")) | |
| 137 | + .andExpect(status().isOk()) | |
| 138 | + .andExpect(jsonPath("$.orgs[0].reviewTotal").value(0)) | |
| 139 | + .andExpect(jsonPath("$.orgs[1].reviewTotal").value(3)) | |
| 140 | + .andExpect(jsonPath("$.orgs[1].reviewDone").value(1)) | |
| 141 | + .andExpect(jsonPath("$.orgs[1].processTotal").value(2)) | |
| 142 | + .andExpect(jsonPath("$.orgs[1].processDone").value(1)); | |
| 143 | + } | |
| 144 | + | |
| 145 | + @Test | |
| 146 | + void 현재_단계_진입시각과_최근변동시각을_내려준다() throws Exception { | |
| 147 | + mvc.perform(get("/api/dashboard")) | |
| 148 | + .andExpect(status().isOk()) | |
| 149 | + // 단계 이력이 없는 기관은 진입시각이 없다 | |
| 150 | + .andExpect(jsonPath("$.orgs[0].stageChangedAt").doesNotExist()) | |
| 151 | + .andExpect(jsonPath("$.orgs[1].stageChangedAt").isNumber()) | |
| 152 | + // 최근변동은 organization.updated_at이 있어 항상 값이 있다 | |
| 153 | + .andExpect(jsonPath("$.orgs[0].lastChangedAt").isNumber()) | |
| 154 | + .andExpect(jsonPath("$.orgs[1].lastChangedAt").isNumber()); | |
| 155 | + } | |
| 156 | +} |
--- src/test/java/kr/itn/itnhub/memo/WorkMemoControllerTest.java
+++ src/test/java/kr/itn/itnhub/memo/WorkMemoControllerTest.java
... | ... | @@ -18,11 +18,12 @@ |
| 18 | 18 |
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; |
| 19 | 19 |
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
| 20 | 20 |
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; |
| 21 |
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; |
|
| 21 | 22 |
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; |
| 22 | 23 |
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
| 23 | 24 |
|
| 24 | 25 |
@AutoConfigureMockMvc |
| 25 |
-@WithMockUser(roles = "ADMIN") |
|
| 26 |
+@WithMockUser(username = "admin", roles = "ADMIN") |
|
| 26 | 27 |
class WorkMemoControllerTest extends AbstractDbTest {
|
| 27 | 28 |
|
| 28 | 29 |
@Autowired |
... | ... | @@ -60,18 +61,18 @@ |
| 60 | 61 |
.contentType(MediaType.APPLICATION_JSON) |
| 61 | 62 |
.content("""
|
| 62 | 63 |
{
|
| 63 |
- "contactName": "송민지 과장", |
|
| 64 | 64 |
"body": "채널 생성 일정 문의" |
| 65 | 65 |
} |
| 66 | 66 |
""")) |
| 67 | 67 |
.andExpect(status().isOk()) |
| 68 |
- .andExpect(jsonPath("$.contactName").value("송민지 과장"))
|
|
| 68 |
+ // 작성자는 요청 본문이 아니라 로그인 사용자에서 온다 |
|
| 69 |
+ .andExpect(jsonPath("$.contactName").value("admin"))
|
|
| 69 | 70 |
.andExpect(jsonPath("$.body").value("채널 생성 일정 문의"))
|
| 70 | 71 |
.andExpect(jsonPath("$.createdAt").isNumber());
|
| 71 | 72 |
|
| 72 | 73 |
mvc.perform(get("/api/orgs/{id}/memos", orgId))
|
| 73 | 74 |
.andExpect(status().isOk()) |
| 74 |
- .andExpect(jsonPath("$[0].contactName").value("송민지 과장"))
|
|
| 75 |
+ .andExpect(jsonPath("$[0].contactName").value("admin"))
|
|
| 75 | 76 |
.andExpect(jsonPath("$[0].body").value("채널 생성 일정 문의"));
|
| 76 | 77 |
} |
| 77 | 78 |
|
... | ... | @@ -81,7 +82,7 @@ |
| 81 | 82 |
.with(csrf()) |
| 82 | 83 |
.contentType(MediaType.APPLICATION_JSON) |
| 83 | 84 |
.content("""
|
| 84 |
- {"contactName": "송민지", "body": "첫번째 통화"}
|
|
| 85 |
+ {"body": "첫번째 통화"}
|
|
| 85 | 86 |
""")) |
| 86 | 87 |
.andExpect(status().isOk()); |
| 87 | 88 |
|
... | ... | @@ -89,7 +90,7 @@ |
| 89 | 90 |
.with(csrf()) |
| 90 | 91 |
.contentType(MediaType.APPLICATION_JSON) |
| 91 | 92 |
.content("""
|
| 92 |
- {"contactName": "송민지", "body": "두번째 통화"}
|
|
| 93 |
+ {"body": "두번째 통화"}
|
|
| 93 | 94 |
""")) |
| 94 | 95 |
.andExpect(status().isOk()); |
| 95 | 96 |
|
... | ... | @@ -105,7 +106,7 @@ |
| 105 | 106 |
.with(csrf()) |
| 106 | 107 |
.contentType(MediaType.APPLICATION_JSON) |
| 107 | 108 |
.content("""
|
| 108 |
- {"contactName": "송민지", "body": ""}
|
|
| 109 |
+ {"body": ""}
|
|
| 109 | 110 |
""")) |
| 110 | 111 |
.andExpect(status().isBadRequest()) |
| 111 | 112 |
.andExpect(jsonPath("$.message").value(containsString("메모 내용")));
|
... | ... | @@ -126,7 +127,7 @@ |
| 126 | 127 |
.with(csrf()) |
| 127 | 128 |
.contentType(MediaType.APPLICATION_JSON) |
| 128 | 129 |
.content("""
|
| 129 |
- {"contactName": "송민지", "body": "삭제될 메모"}
|
|
| 130 |
+ {"body": "삭제될 메모"}
|
|
| 130 | 131 |
""")) |
| 131 | 132 |
.andExpect(status().isOk()); |
| 132 | 133 |
Long memoId = memoMapper.findByOrg(orgId).get(0).getId(); |
... | ... | @@ -145,7 +146,7 @@ |
| 145 | 146 |
.with(csrf()) |
| 146 | 147 |
.contentType(MediaType.APPLICATION_JSON) |
| 147 | 148 |
.content("""
|
| 148 |
- {"contactName": "송민지", "body": "삭제될 메모"}
|
|
| 149 |
+ {"body": "삭제될 메모"}
|
|
| 149 | 150 |
""")) |
| 150 | 151 |
.andExpect(status().isOk()); |
| 151 | 152 |
Long memoId = memoMapper.findByOrg(orgId).get(0).getId(); |
... | ... | @@ -169,4 +170,51 @@ |
| 169 | 170 |
mvc.perform(get("/api/orgs/{id}/memos", orgId))
|
| 170 | 171 |
.andExpect(status().isUnauthorized()); |
| 171 | 172 |
} |
| 173 |
+ |
|
| 174 |
+ @Test |
|
| 175 |
+ void 메모를_수정하면_본문만_바뀌고_작성자와_작성시각은_그대로다() throws Exception {
|
|
| 176 |
+ String created = mvc.perform(post("/api/orgs/{id}/memos", orgId)
|
|
| 177 |
+ .with(csrf()) |
|
| 178 |
+ .contentType(MediaType.APPLICATION_JSON) |
|
| 179 |
+ .content("""
|
|
| 180 |
+ {"body": "고치기 전"}
|
|
| 181 |
+ """)) |
|
| 182 |
+ .andExpect(status().isOk()) |
|
| 183 |
+ .andReturn().getResponse().getContentAsString(); |
|
| 184 |
+ long memoId = com.jayway.jsonpath.JsonPath.parse(created).read("$.id", Integer.class);
|
|
| 185 |
+ long createdAt = com.jayway.jsonpath.JsonPath.parse(created).read("$.createdAt", Long.class);
|
|
| 186 |
+ |
|
| 187 |
+ mvc.perform(put("/api/orgs/{id}/memos/{memoId}", orgId, memoId)
|
|
| 188 |
+ .with(csrf()) |
|
| 189 |
+ .contentType(MediaType.APPLICATION_JSON) |
|
| 190 |
+ .content("""
|
|
| 191 |
+ {"body": "고친 뒤"}
|
|
| 192 |
+ """)) |
|
| 193 |
+ .andExpect(status().isOk()) |
|
| 194 |
+ .andExpect(jsonPath("$.body").value("고친 뒤"))
|
|
| 195 |
+ .andExpect(jsonPath("$.contactName").value("admin"))
|
|
| 196 |
+ .andExpect(jsonPath("$.createdAt").value(createdAt));
|
|
| 197 |
+ } |
|
| 198 |
+ |
|
| 199 |
+ @Test |
|
| 200 |
+ void 없는_메모를_수정하면_404다() throws Exception {
|
|
| 201 |
+ mvc.perform(put("/api/orgs/{id}/memos/{memoId}", orgId, 999999L)
|
|
| 202 |
+ .with(csrf()) |
|
| 203 |
+ .contentType(MediaType.APPLICATION_JSON) |
|
| 204 |
+ .content("""
|
|
| 205 |
+ {"body": "아무거나"}
|
|
| 206 |
+ """)) |
|
| 207 |
+ .andExpect(status().isNotFound()); |
|
| 208 |
+ } |
|
| 209 |
+ |
|
| 210 |
+ @Test |
|
| 211 |
+ void 본문이_비면_수정도_400이다() throws Exception {
|
|
| 212 |
+ mvc.perform(put("/api/orgs/{id}/memos/{memoId}", orgId, 1L)
|
|
| 213 |
+ .with(csrf()) |
|
| 214 |
+ .contentType(MediaType.APPLICATION_JSON) |
|
| 215 |
+ .content("""
|
|
| 216 |
+ {"body": ""}
|
|
| 217 |
+ """)) |
|
| 218 |
+ .andExpect(status().isBadRequest()); |
|
| 219 |
+ } |
|
| 172 | 220 |
} |
+++ src/test/java/kr/itn/itnhub/project/ProjectControllerTest.java
... | ... | @@ -0,0 +1,211 @@ |
| 1 | +package kr.itn.itnhub.project; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.AbstractDbTest; | |
| 4 | +import kr.itn.itnhub.mattermost.MattermostClient; | |
| 5 | +import kr.itn.itnhub.org.Organization; | |
| 6 | +import kr.itn.itnhub.org.OrganizationMapper; | |
| 7 | +import org.junit.jupiter.api.BeforeEach; | |
| 8 | +import org.junit.jupiter.api.Test; | |
| 9 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 10 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | |
| 11 | +import org.springframework.boot.test.mock.mockito.MockBean; | |
| 12 | +import org.springframework.http.MediaType; | |
| 13 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 14 | +import org.springframework.mock.web.MockMultipartFile; | |
| 15 | +import org.springframework.security.test.context.support.WithMockUser; | |
| 16 | +import org.springframework.test.web.servlet.MockMvc; | |
| 17 | + | |
| 18 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 19 | +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; | |
| 20 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; | |
| 21 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | |
| 22 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; | |
| 23 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | |
| 24 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; | |
| 25 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; | |
| 26 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |
| 27 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |
| 28 | + | |
| 29 | +@AutoConfigureMockMvc | |
| 30 | +@WithMockUser(username = "admin", roles = "ADMIN") | |
| 31 | +class ProjectControllerTest extends AbstractDbTest { | |
| 32 | + | |
| 33 | + @Autowired | |
| 34 | + MockMvc mvc; | |
| 35 | + | |
| 36 | + @Autowired | |
| 37 | + OrganizationMapper orgMapper; | |
| 38 | + | |
| 39 | + @Autowired | |
| 40 | + JdbcTemplate jdbc; | |
| 41 | + | |
| 42 | + @MockBean | |
| 43 | + MattermostClient mattermost; | |
| 44 | + | |
| 45 | + private Long orgId; | |
| 46 | + | |
| 47 | + @BeforeEach | |
| 48 | + void setUp() { | |
| 49 | + jdbc.update("delete from organization"); | |
| 50 | + | |
| 51 | + Organization org = new Organization(); | |
| 52 | + org.setOrgNo("001"); | |
| 53 | + org.setOrgName("국제방송교류재단"); | |
| 54 | + org.setChannelSlug("001"); | |
| 55 | + orgMapper.upsertBySeed(org); | |
| 56 | + orgId = orgMapper.findByOrgNoAndOrgName("001", "국제방송교류재단").getId(); | |
| 57 | + } | |
| 58 | + | |
| 59 | + @Test | |
| 60 | + void 목록은_기관현황에_연락이력_보고서_건수를_붙여_내려준다() throws Exception { | |
| 61 | + mvc.perform(get("/api/projects")) | |
| 62 | + .andExpect(status().isOk()) | |
| 63 | + .andExpect(jsonPath("$.length()").value(1)) | |
| 64 | + .andExpect(jsonPath("$[0].org.orgNo").value("001")) | |
| 65 | + .andExpect(jsonPath("$[0].contactCount").value(0)) | |
| 66 | + .andExpect(jsonPath("$[0].reportCount").value(0)) | |
| 67 | + .andExpect(jsonPath("$[0].lastContactedOn").doesNotExist()); | |
| 68 | + } | |
| 69 | + | |
| 70 | + @Test | |
| 71 | + void 연락이력을_등록하면_최근순으로_돌려주고_작성자는_로그인_사용자다() throws Exception { | |
| 72 | + mvc.perform(post("/api/orgs/{id}/contact-logs", orgId).with(csrf()) | |
| 73 | + .contentType(MediaType.APPLICATION_JSON) | |
| 74 | + .content(""" | |
| 75 | + {"contactedOn": "2026-07-20", "method": "전화", "summary": "신청 확인"} | |
| 76 | + """)) | |
| 77 | + .andExpect(status().isOk()) | |
| 78 | + .andExpect(jsonPath("$.length()").value(1)) | |
| 79 | + .andExpect(jsonPath("$[0].author").value("admin")) | |
| 80 | + .andExpect(jsonPath("$[0].contactedOn").value("2026-07-20")); | |
| 81 | + | |
| 82 | + mvc.perform(post("/api/orgs/{id}/contact-logs", orgId).with(csrf()) | |
| 83 | + .contentType(MediaType.APPLICATION_JSON) | |
| 84 | + .content(""" | |
| 85 | + {"contactedOn": "2026-07-25", "method": "메일", "summary": "자료 요청"} | |
| 86 | + """)) | |
| 87 | + .andExpect(status().isOk()) | |
| 88 | + // 최근 연락일이 위로 온다 | |
| 89 | + .andExpect(jsonPath("$[0].contactedOn").value("2026-07-25")) | |
| 90 | + .andExpect(jsonPath("$[1].contactedOn").value("2026-07-20")); | |
| 91 | + | |
| 92 | + mvc.perform(get("/api/projects")) | |
| 93 | + .andExpect(jsonPath("$[0].contactCount").value(2)) | |
| 94 | + .andExpect(jsonPath("$[0].lastContactedOn").value("2026-07-25")); | |
| 95 | + } | |
| 96 | + | |
| 97 | + @Test | |
| 98 | + void 연락이력_수정은_내용만_바꾸고_작성자는_유지한다() throws Exception { | |
| 99 | + String created = mvc.perform(post("/api/orgs/{id}/contact-logs", orgId).with(csrf()) | |
| 100 | + .contentType(MediaType.APPLICATION_JSON) | |
| 101 | + .content(""" | |
| 102 | + {"contactedOn": "2026-07-20", "method": "전화", "summary": "처음"} | |
| 103 | + """)) | |
| 104 | + .andReturn().getResponse().getContentAsString(); | |
| 105 | + int logId = com.jayway.jsonpath.JsonPath.parse(created).read("$[0].id", Integer.class); | |
| 106 | + | |
| 107 | + mvc.perform(put("/api/orgs/{id}/contact-logs/{logId}", orgId, logId).with(csrf()) | |
| 108 | + .contentType(MediaType.APPLICATION_JSON) | |
| 109 | + .content(""" | |
| 110 | + {"contactedOn": "2026-07-21", "method": "방문", "summary": "고침"} | |
| 111 | + """)) | |
| 112 | + .andExpect(status().isOk()) | |
| 113 | + .andExpect(jsonPath("$[0].summary").value("고침")) | |
| 114 | + .andExpect(jsonPath("$[0].method").value("방문")) | |
| 115 | + .andExpect(jsonPath("$[0].author").value("admin")); | |
| 116 | + } | |
| 117 | + | |
| 118 | + @Test | |
| 119 | + void 연락이력을_삭제하면_목록에서_빠진다() throws Exception { | |
| 120 | + String created = mvc.perform(post("/api/orgs/{id}/contact-logs", orgId).with(csrf()) | |
| 121 | + .contentType(MediaType.APPLICATION_JSON) | |
| 122 | + .content(""" | |
| 123 | + {"contactedOn": "2026-07-20", "method": "전화", "summary": "지울 것"} | |
| 124 | + """)) | |
| 125 | + .andReturn().getResponse().getContentAsString(); | |
| 126 | + int logId = com.jayway.jsonpath.JsonPath.parse(created).read("$[0].id", Integer.class); | |
| 127 | + | |
| 128 | + mvc.perform(delete("/api/orgs/{id}/contact-logs/{logId}", orgId, logId).with(csrf())) | |
| 129 | + .andExpect(status().isNoContent()); | |
| 130 | + | |
| 131 | + mvc.perform(get("/api/orgs/{id}/contact-logs", orgId)) | |
| 132 | + .andExpect(jsonPath("$.length()").value(0)); | |
| 133 | + } | |
| 134 | + | |
| 135 | + @Test | |
| 136 | + void 내용이_비면_400이다() throws Exception { | |
| 137 | + mvc.perform(post("/api/orgs/{id}/contact-logs", orgId).with(csrf()) | |
| 138 | + .contentType(MediaType.APPLICATION_JSON) | |
| 139 | + .content(""" | |
| 140 | + {"contactedOn": "2026-07-20", "method": "전화", "summary": ""} | |
| 141 | + """)) | |
| 142 | + .andExpect(status().isBadRequest()); | |
| 143 | + } | |
| 144 | + | |
| 145 | + @Test | |
| 146 | + void 보고서를_올리면_목록에_쌓이고_다운로드된다() throws Exception { | |
| 147 | + MockMultipartFile file = new MockMultipartFile("file", "최종보고서.pdf", | |
| 148 | + "application/pdf", "보고서 내용".getBytes(java.nio.charset.StandardCharsets.UTF_8)); | |
| 149 | + | |
| 150 | + String uploaded = mvc.perform(multipart("/api/orgs/{id}/reports", orgId).file(file).with(csrf())) | |
| 151 | + .andExpect(status().isOk()) | |
| 152 | + .andExpect(jsonPath("$.length()").value(1)) | |
| 153 | + .andExpect(jsonPath("$[0].fileName").value("최종보고서.pdf")) | |
| 154 | + .andExpect(jsonPath("$[0].uploadedBy").value("admin")) | |
| 155 | + .andReturn().getResponse().getContentAsString(); | |
| 156 | + int reportId = com.jayway.jsonpath.JsonPath.parse(uploaded).read("$[0].id", Integer.class); | |
| 157 | + | |
| 158 | + // 한글 파일명은 RFC 5987로 인코딩돼 내려간다 | |
| 159 | + mvc.perform(get("/api/orgs/{id}/reports/{reportId}", orgId, reportId)) | |
| 160 | + .andExpect(status().isOk()) | |
| 161 | + .andExpect(header().string("Content-Disposition", | |
| 162 | + org.hamcrest.Matchers.containsString("filename*=UTF-8''"))); | |
| 163 | + | |
| 164 | + mvc.perform(get("/api/projects")) | |
| 165 | + .andExpect(jsonPath("$[0].reportCount").value(1)); | |
| 166 | + } | |
| 167 | + | |
| 168 | + @Test | |
| 169 | + void 같은_기관에_여러_번_올리면_최신순으로_쌓인다() throws Exception { | |
| 170 | + mvc.perform(multipart("/api/orgs/{id}/reports", orgId) | |
| 171 | + .file(new MockMultipartFile("file", "1차.pdf", "application/pdf", "a".getBytes())) | |
| 172 | + .with(csrf())) | |
| 173 | + .andExpect(status().isOk()); | |
| 174 | + mvc.perform(multipart("/api/orgs/{id}/reports", orgId) | |
| 175 | + .file(new MockMultipartFile("file", "2차.pdf", "application/pdf", "b".getBytes())) | |
| 176 | + .with(csrf())) | |
| 177 | + .andExpect(status().isOk()) | |
| 178 | + .andExpect(jsonPath("$.length()").value(2)) | |
| 179 | + .andExpect(jsonPath("$[0].fileName").value("2차.pdf")); | |
| 180 | + } | |
| 181 | + | |
| 182 | + @Test | |
| 183 | + void 빈_파일은_400이다() throws Exception { | |
| 184 | + mvc.perform(multipart("/api/orgs/{id}/reports", orgId) | |
| 185 | + .file(new MockMultipartFile("file", "empty.pdf", "application/pdf", new byte[0])) | |
| 186 | + .with(csrf())) | |
| 187 | + .andExpect(status().isBadRequest()); | |
| 188 | + } | |
| 189 | + | |
| 190 | + @Test | |
| 191 | + void 보고서를_삭제하면_목록에서_빠진다() throws Exception { | |
| 192 | + String uploaded = mvc.perform(multipart("/api/orgs/{id}/reports", orgId) | |
| 193 | + .file(new MockMultipartFile("file", "지울보고서.pdf", "application/pdf", "x".getBytes())) | |
| 194 | + .with(csrf())) | |
| 195 | + .andReturn().getResponse().getContentAsString(); | |
| 196 | + int reportId = com.jayway.jsonpath.JsonPath.parse(uploaded).read("$[0].id", Integer.class); | |
| 197 | + | |
| 198 | + mvc.perform(delete("/api/orgs/{id}/reports/{reportId}", orgId, reportId).with(csrf())) | |
| 199 | + .andExpect(status().isNoContent()); | |
| 200 | + | |
| 201 | + mvc.perform(get("/api/orgs/{id}/reports", orgId)) | |
| 202 | + .andExpect(jsonPath("$.length()").value(0)); | |
| 203 | + assertThat(jdbc.queryForObject("select count(*) from org_report", Integer.class)).isZero(); | |
| 204 | + } | |
| 205 | + | |
| 206 | + @Test | |
| 207 | + void 없는_기관이면_404다() throws Exception { | |
| 208 | + mvc.perform(get("/api/orgs/{id}/contact-logs", orgId + 99999L)) | |
| 209 | + .andExpect(status().isNotFound()); | |
| 210 | + } | |
| 211 | +} |
--- src/test/java/kr/itn/itnhub/review/ReviewControllerTest.java
+++ src/test/java/kr/itn/itnhub/review/ReviewControllerTest.java
... | ... | @@ -28,6 +28,7 @@ |
| 28 | 28 |
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; |
| 29 | 29 |
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
| 30 | 30 |
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; |
| 31 |
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; |
|
| 31 | 32 |
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; |
| 32 | 33 |
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; |
| 33 | 34 |
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
... | ... | @@ -322,4 +323,97 @@ |
| 322 | 323 |
.andExpect(status().isOk()) |
| 323 | 324 |
.andExpect(result -> assertThat(result.getResponse().getContentAsByteArray()).isNotEmpty()); |
| 324 | 325 |
} |
| 326 |
+ |
|
| 327 |
+ @Test |
|
| 328 |
+ void 복합검색은_열별_조건을_AND로_겹쳐서_찾는다() throws Exception {
|
|
| 329 |
+ jdbc.update("insert into review_item (org_id, seq, site_name, board_name, post_title) "
|
|
| 330 |
+ + "values (?, 101, '누리집', '공고게시판', '2026년 사업 공고')", orgId); |
|
| 331 |
+ jdbc.update("insert into review_item (org_id, seq, site_name, board_name, post_title) "
|
|
| 332 |
+ + "values (?, 102, '누리집', '자료실', '2026년 사업 결과')", orgId); |
|
| 333 |
+ |
|
| 334 |
+ // 게시판명이 다르면 걸러진다 |
|
| 335 |
+ mvc.perform(get("/api/orgs/{id}/review", orgId)
|
|
| 336 |
+ .param("site", "누리집").param("board", "공고").param("title", "사업"))
|
|
| 337 |
+ .andExpect(status().isOk()) |
|
| 338 |
+ .andExpect(jsonPath("$.total").value(1))
|
|
| 339 |
+ .andExpect(jsonPath("$.items[0].seq").value(101));
|
|
| 340 |
+ |
|
| 341 |
+ // 사이트명만 주면 둘 다 나온다 |
|
| 342 |
+ mvc.perform(get("/api/orgs/{id}/review", orgId).param("site", "누리집"))
|
|
| 343 |
+ .andExpect(status().isOk()) |
|
| 344 |
+ .andExpect(jsonPath("$.total").value(2));
|
|
| 345 |
+ } |
|
| 346 |
+ |
|
| 347 |
+ @Test |
|
| 348 |
+ void 일괄등록은_채운_항목만_반영하고_나머지는_유지한다() throws Exception {
|
|
| 349 |
+ jdbc.update("insert into review_item (org_id, seq, review_result, opinion) "
|
|
| 350 |
+ + "values (?, 201, '신유형 개방', '원래 의견')", orgId); |
|
| 351 |
+ jdbc.update("insert into review_item (org_id, seq, review_result, opinion) "
|
|
| 352 |
+ + "values (?, 202, null, null)", orgId); |
|
| 353 |
+ Long first = jdbc.queryForObject( |
|
| 354 |
+ "select id from review_item where org_id = ? and seq = 201", Long.class, orgId); |
|
| 355 |
+ Long second = jdbc.queryForObject( |
|
| 356 |
+ "select id from review_item where org_id = ? and seq = 202", Long.class, orgId); |
|
| 357 |
+ |
|
| 358 |
+ mvc.perform(put("/api/orgs/{id}/review/bulk", orgId).with(csrf())
|
|
| 359 |
+ .contentType(MediaType.APPLICATION_JSON) |
|
| 360 |
+ .content("""
|
|
| 361 |
+ {"ids": [%d, %d], "judgedKoglType": "0유형"}
|
|
| 362 |
+ """.formatted(first, second))) |
|
| 363 |
+ .andExpect(status().isOk()) |
|
| 364 |
+ .andExpect(jsonPath("$.updated").value(2));
|
|
| 365 |
+ |
|
| 366 |
+ // 유형은 둘 다 채워지고, 보내지 않은 처리결과/의견은 그대로다 |
|
| 367 |
+ assertThat(jdbc.queryForObject( |
|
| 368 |
+ "select judged_kogl_type from review_item where id = ?", String.class, first)) |
|
| 369 |
+ .isEqualTo("0유형");
|
|
| 370 |
+ assertThat(jdbc.queryForObject( |
|
| 371 |
+ "select review_result from review_item where id = ?", String.class, first)) |
|
| 372 |
+ .isEqualTo("신유형 개방");
|
|
| 373 |
+ assertThat(jdbc.queryForObject( |
|
| 374 |
+ "select opinion from review_item where id = ?", String.class, first)) |
|
| 375 |
+ .isEqualTo("원래 의견");
|
|
| 376 |
+ assertThat(jdbc.queryForObject( |
|
| 377 |
+ "select judged_kogl_type from review_item where id = ?", String.class, second)) |
|
| 378 |
+ .isEqualTo("0유형");
|
|
| 379 |
+ } |
|
| 380 |
+ |
|
| 381 |
+ @Test |
|
| 382 |
+ void 일괄등록은_웹수정표시를_남겨_재업로드에_덮이지_않게_한다() throws Exception {
|
|
| 383 |
+ jdbc.update("insert into review_item (org_id, seq) values (?, 301)", orgId);
|
|
| 384 |
+ Long id = jdbc.queryForObject( |
|
| 385 |
+ "select id from review_item where org_id = ? and seq = 301", Long.class, orgId); |
|
| 386 |
+ |
|
| 387 |
+ mvc.perform(put("/api/orgs/{id}/review/bulk", orgId).with(csrf())
|
|
| 388 |
+ .contentType(MediaType.APPLICATION_JSON) |
|
| 389 |
+ .content("""
|
|
| 390 |
+ {"ids": [%d], "reviewResult": "권리처리 추진"}
|
|
| 391 |
+ """.formatted(id))) |
|
| 392 |
+ .andExpect(status().isOk()); |
|
| 393 |
+ |
|
| 394 |
+ assertThat(jdbc.queryForObject( |
|
| 395 |
+ "select web_edited_at is not null from review_item where id = ?", Boolean.class, id)) |
|
| 396 |
+ .isTrue(); |
|
| 397 |
+ } |
|
| 398 |
+ |
|
| 399 |
+ @Test |
|
| 400 |
+ void 일괄삭제는_고른_건만_지운다() throws Exception {
|
|
| 401 |
+ jdbc.update("insert into review_item (org_id, seq) values (?, 401)", orgId);
|
|
| 402 |
+ jdbc.update("insert into review_item (org_id, seq) values (?, 402)", orgId);
|
|
| 403 |
+ Long keep = jdbc.queryForObject( |
|
| 404 |
+ "select id from review_item where org_id = ? and seq = 402", Long.class, orgId); |
|
| 405 |
+ Long drop = jdbc.queryForObject( |
|
| 406 |
+ "select id from review_item where org_id = ? and seq = 401", Long.class, orgId); |
|
| 407 |
+ |
|
| 408 |
+ mvc.perform(post("/api/orgs/{id}/review/bulk-delete", orgId).with(csrf())
|
|
| 409 |
+ .contentType(MediaType.APPLICATION_JSON) |
|
| 410 |
+ .content("[%d]".formatted(drop)))
|
|
| 411 |
+ .andExpect(status().isOk()) |
|
| 412 |
+ .andExpect(jsonPath("$.deleted").value(1));
|
|
| 413 |
+ |
|
| 414 |
+ assertThat(jdbc.queryForObject( |
|
| 415 |
+ "select count(*) from review_item where id = ?", Integer.class, drop)).isZero(); |
|
| 416 |
+ assertThat(jdbc.queryForObject( |
|
| 417 |
+ "select count(*) from review_item where id = ?", Integer.class, keep)).isEqualTo(1); |
|
| 418 |
+ } |
|
| 325 | 419 |
} |
+++ src/test/java/kr/itn/itnhub/system/SystemResetControllerTest.java
... | ... | @@ -0,0 +1,158 @@ |
| 1 | +package kr.itn.itnhub.system; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.AbstractDbTest; | |
| 4 | +import kr.itn.itnhub.mattermost.MattermostClient; | |
| 5 | +import kr.itn.itnhub.mattermost.MattermostException; | |
| 6 | +import kr.itn.itnhub.org.Organization; | |
| 7 | +import kr.itn.itnhub.org.OrganizationMapper; | |
| 8 | +import org.junit.jupiter.api.BeforeEach; | |
| 9 | +import org.junit.jupiter.api.Test; | |
| 10 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 11 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | |
| 12 | +import org.springframework.boot.test.mock.mockito.MockBean; | |
| 13 | +import org.springframework.http.MediaType; | |
| 14 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 15 | +import org.springframework.security.test.context.support.WithMockUser; | |
| 16 | +import org.springframework.test.web.servlet.MockMvc; | |
| 17 | + | |
| 18 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 19 | +import static org.mockito.ArgumentMatchers.anyString; | |
| 20 | +import static org.mockito.Mockito.doThrow; | |
| 21 | +import static org.mockito.Mockito.never; | |
| 22 | +import static org.mockito.Mockito.verify; | |
| 23 | +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; | |
| 24 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | |
| 25 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | |
| 26 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |
| 27 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |
| 28 | + | |
| 29 | +@AutoConfigureMockMvc | |
| 30 | +@WithMockUser(roles = "ADMIN") | |
| 31 | +class SystemResetControllerTest extends AbstractDbTest { | |
| 32 | + | |
| 33 | + @Autowired | |
| 34 | + MockMvc mvc; | |
| 35 | + | |
| 36 | + @Autowired | |
| 37 | + OrganizationMapper orgMapper; | |
| 38 | + | |
| 39 | + @Autowired | |
| 40 | + JdbcTemplate jdbc; | |
| 41 | + | |
| 42 | + @MockBean | |
| 43 | + MattermostClient mattermost; | |
| 44 | + | |
| 45 | + private Long bothChannelsId; | |
| 46 | + private Long noChannelId; | |
| 47 | + | |
| 48 | + @BeforeEach | |
| 49 | + void setUp() { | |
| 50 | + jdbc.update("delete from organization"); | |
| 51 | + | |
| 52 | + bothChannelsId = seed("001", "채널있는기관"); | |
| 53 | + orgMapper.updateChannelIdMj(bothChannelsId, "ch-mj"); | |
| 54 | + orgMapper.updateChannelIdLaw(bothChannelsId, "ch-law"); | |
| 55 | + orgMapper.updateStage(bothChannelsId, 3); | |
| 56 | + jdbc.update("insert into stage_history (org_id, stage) values (?, 3)", bothChannelsId); | |
| 57 | + | |
| 58 | + noChannelId = seed("002", "채널없는기관"); | |
| 59 | + } | |
| 60 | + | |
| 61 | + private Long seed(String orgNo, String orgName) { | |
| 62 | + Organization org = new Organization(); | |
| 63 | + org.setOrgNo(orgNo); | |
| 64 | + org.setOrgName(orgName); | |
| 65 | + org.setChannelSlug(orgNo); | |
| 66 | + orgMapper.upsertBySeed(org); | |
| 67 | + return orgMapper.findByOrgNoAndOrgName(orgNo, orgName).getId(); | |
| 68 | + } | |
| 69 | + | |
| 70 | + @Test | |
| 71 | + void 미리보기는_DB가_아는_채널만_보여준다() throws Exception { | |
| 72 | + mvc.perform(get("/api/system/reset/preview")) | |
| 73 | + .andExpect(status().isOk()) | |
| 74 | + // 채널 없는 기관은 아예 목록에 없다 | |
| 75 | + .andExpect(jsonPath("$.length()").value(1)) | |
| 76 | + .andExpect(jsonPath("$[0].orgNo").value("001")) | |
| 77 | + .andExpect(jsonPath("$[0].stage").value(3)) | |
| 78 | + .andExpect(jsonPath("$[0].channels.length()").value(2)) | |
| 79 | + .andExpect(jsonPath("$[0].channels[0].kind").value("문정원")) | |
| 80 | + .andExpect(jsonPath("$[0].channels[1].kind").value("법률검토")); | |
| 81 | + } | |
| 82 | + | |
| 83 | + @Test | |
| 84 | + void 미리보기는_아무것도_바꾸지_않는다() throws Exception { | |
| 85 | + mvc.perform(get("/api/system/reset/preview")).andExpect(status().isOk()); | |
| 86 | + | |
| 87 | + assertThat(orgMapper.findById(bothChannelsId).getChannelIdMj()).isEqualTo("ch-mj"); | |
| 88 | + verify(mattermost, never()).archiveChannel(anyString()); | |
| 89 | + } | |
| 90 | + | |
| 91 | + @Test | |
| 92 | + void 확인문구가_틀리면_400이고_아무것도_지우지_않는다() throws Exception { | |
| 93 | + mvc.perform(post("/api/system/reset/channels").with(csrf()) | |
| 94 | + .contentType(MediaType.APPLICATION_JSON) | |
| 95 | + .content("{\"confirm\": \"초기화\"}")) | |
| 96 | + .andExpect(status().isBadRequest()); | |
| 97 | + | |
| 98 | + verify(mattermost, never()).archiveChannel(anyString()); | |
| 99 | + assertThat(orgMapper.findById(bothChannelsId).getChannelIdMj()).isEqualTo("ch-mj"); | |
| 100 | + } | |
| 101 | + | |
| 102 | + @Test | |
| 103 | + void 초기화하면_채널을_보관하고_DB의_채널과_단계를_비운다() throws Exception { | |
| 104 | + mvc.perform(post("/api/system/reset/channels").with(csrf()) | |
| 105 | + .contentType(MediaType.APPLICATION_JSON) | |
| 106 | + .content("{\"confirm\": \"채널 초기화\"}")) | |
| 107 | + .andExpect(status().isOk()) | |
| 108 | + .andExpect(jsonPath("$.archivedChannels").value(2)) | |
| 109 | + .andExpect(jsonPath("$.clearedOrgs").value(1)) | |
| 110 | + .andExpect(jsonPath("$.failures.length()").value(0)); | |
| 111 | + | |
| 112 | + verify(mattermost).archiveChannel("ch-mj"); | |
| 113 | + verify(mattermost).archiveChannel("ch-law"); | |
| 114 | + | |
| 115 | + Organization after = orgMapper.findById(bothChannelsId); | |
| 116 | + assertThat(after.getChannelIdMj()).isNull(); | |
| 117 | + assertThat(after.getChannelIdLaw()).isNull(); | |
| 118 | + assertThat(after.getStage()).isNull(); | |
| 119 | + assertThat(jdbc.queryForObject("select count(*) from stage_history where org_id = ?", | |
| 120 | + Integer.class, bothChannelsId)).isZero(); | |
| 121 | + } | |
| 122 | + | |
| 123 | + @Test | |
| 124 | + void 담당자_배정과_권리확인_데이터는_건드리지_않는다() throws Exception { | |
| 125 | + jdbc.update("insert into review_item (org_id, seq, review_result) values (?, 1, '이용가능')", | |
| 126 | + bothChannelsId); | |
| 127 | + jdbc.update("insert into work_memo (org_id, contact_name, body) values (?, '시스템', '메모')", | |
| 128 | + bothChannelsId); | |
| 129 | + | |
| 130 | + mvc.perform(post("/api/system/reset/channels").with(csrf()) | |
| 131 | + .contentType(MediaType.APPLICATION_JSON) | |
| 132 | + .content("{\"confirm\": \"채널 초기화\"}")) | |
| 133 | + .andExpect(status().isOk()); | |
| 134 | + | |
| 135 | + assertThat(jdbc.queryForObject("select count(*) from review_item where org_id = ?", | |
| 136 | + Integer.class, bothChannelsId)).isEqualTo(1); | |
| 137 | + assertThat(jdbc.queryForObject("select count(*) from work_memo where org_id = ?", | |
| 138 | + Integer.class, bothChannelsId)).isEqualTo(1); | |
| 139 | + // 기관 자체도 남는다 | |
| 140 | + assertThat(orgMapper.findById(bothChannelsId)).isNotNull(); | |
| 141 | + } | |
| 142 | + | |
| 143 | + @Test | |
| 144 | + void 한쪽_채널이_실패하면_그_기관의_DB는_그대로_두고_실패를_보고한다() throws Exception { | |
| 145 | + doThrow(new MattermostException("권한 없음")).when(mattermost).archiveChannel("ch-law"); | |
| 146 | + | |
| 147 | + mvc.perform(post("/api/system/reset/channels").with(csrf()) | |
| 148 | + .contentType(MediaType.APPLICATION_JSON) | |
| 149 | + .content("{\"confirm\": \"채널 초기화\"}")) | |
| 150 | + .andExpect(status().isOk()) | |
| 151 | + .andExpect(jsonPath("$.archivedChannels").value(1)) | |
| 152 | + .andExpect(jsonPath("$.clearedOrgs").value(0)) | |
| 153 | + .andExpect(jsonPath("$.failures.length()").value(1)); | |
| 154 | + | |
| 155 | + // 남은 채널을 다시 찾을 수 있어야 하므로 ID를 지우지 않는다 | |
| 156 | + assertThat(orgMapper.findById(bothChannelsId).getChannelIdLaw()).isEqualTo("ch-law"); | |
| 157 | + } | |
| 158 | +} |
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?