이호영 이호영 07-27
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) (added)
+++ frontend/public/help/channel-assign.png
Binary file is not shown
 
frontend/public/help/channel-provision.png (Binary) (added)
+++ frontend/public/help/channel-provision.png
Binary file is not shown
 
frontend/public/help/contacts.png (Binary) (added)
+++ frontend/public/help/contacts.png
Binary file is not shown
 
frontend/public/help/dashboard-drag-warning.png (Binary) (added)
+++ frontend/public/help/dashboard-drag-warning.png
Binary file is not shown
 
frontend/public/help/dashboard-kanban.png (Binary) (added)
+++ frontend/public/help/dashboard-kanban.png
Binary file is not shown
 
frontend/public/help/dashboard-kpi.png (Binary) (added)
+++ frontend/public/help/dashboard-kpi.png
Binary file is not shown
 
frontend/public/help/dashboard-recent.png (Binary) (added)
+++ frontend/public/help/dashboard-recent.png
Binary file is not shown
 
frontend/public/help/org-chat.png (Binary) (added)
+++ frontend/public/help/org-chat.png
Binary file is not shown
 
frontend/public/help/org-review.png (Binary) (added)
+++ frontend/public/help/org-review.png
Binary file is not shown
 
frontend/public/help/org-stage.png (Binary) (added)
+++ frontend/public/help/org-stage.png
Binary file is not shown
 
frontend/public/help/system-reset.png (Binary) (added)
+++ frontend/public/help/system-reset.png
Binary file is not shown
frontend/src/App.tsx
--- frontend/src/App.tsx
+++ frontend/src/App.tsx
@@ -1,22 +1,29 @@
 import { useCallback, useEffect, useState } from 'react'
 import { ApiError, getOrgs, logout, type Org } from './api/client'
 import ContactsPage from './components/ContactsPage'
+import Dashboard from './components/Dashboard'
 import LoginPage from './components/LoginPage'
 import OrgDetail from './components/OrgDetail'
 import OrgList from './components/OrgList'
 import OrgOverview from './components/OrgOverview'
+import ProjectsPage from './components/ProjectsPage'
 import SeedUpload from './components/SeedUpload'
 import Sidebar from './components/Sidebar'
+import SystemPage from './components/SystemPage'
 import { useCollapsiblePanel } from './hooks/useCollapsiblePanel'
 
-// 화면 3개: 기관관리(시안 레이아웃의 기관 상세), 채널관리(명부 시드 + 담당자 배정 + 채널 생성),
+// 화면 4개: Dashboard(전체 기관 현황 - 좌측 기관 목록 없이 전폭으로 쓴다),
+// 기관관리(시안 레이아웃의 기관 상세), 채널관리(명부 시드 + 담당자 배정 + 채널 생성),
 // 담당자관리(담당자 3종 CRUD - 담당자 정보가 입력되는 유일한 화면).
-type Page = 'orgs' | 'channels' | 'contacts'
+type Page = 'dashboard' | 'orgs' | 'channels' | 'contacts' | 'projects' | 'system'
 
 const PAGE_TITLE: Record<Page, string> = {
+  dashboard: 'Dashboard',
   orgs: '기관관리',
   channels: '채널관리',
   contacts: '담당자관리',
+  projects: '사업관리',
+  system: '시스템관리',
 }
 
 // 'checking'이 필요하다. true로 시작하면 첫 렌더에서 getOrgs()가 아직 안 끝났는데도
@@ -33,7 +40,7 @@
   const [selectedId, setSelectedId] = useState<number | null>(null)
   const [auth, setAuth] = useState<AuthState>('checking')
   const [authErrorMessage, setAuthErrorMessage] = useState<string | null>(null)
-  const [page, setPage] = useState<Page>('orgs')
+  const [page, setPage] = useState<Page>('dashboard')
   // 창이 좁아지면 자동으로 접힌다. 사용자의 접기/펼치기 선택은 저장된다.
   const [menuCollapsed, toggleMenu] = useCollapsiblePanel('itnhub.menu', '(max-width: 1023px)')
   const [listCollapsed, toggleList] = useCollapsiblePanel('itnhub.orglist', '(max-width: 1279px)')
@@ -100,7 +107,14 @@
         collapsed={menuCollapsed}
         onToggle={toggleMenu}
         onSelect={(key) => {
-          if (key === 'orgs' || key === 'channels' || key === 'contacts') {
+          if (
+            key === 'dashboard' ||
+            key === 'orgs' ||
+            key === 'channels' ||
+            key === 'contacts' ||
+            key === 'projects' ||
+            key === 'system'
+          ) {
             setPage(key)
           }
         }}
@@ -122,7 +136,7 @@
         </header>
 
         <div className="flex min-h-0 flex-1">
-          {page !== 'contacts' && (
+          {page !== 'contacts' && page !== 'dashboard' && page !== 'system' && page !== 'projects' && (
             <div
               className={`flex shrink-0 flex-col border-r border-gray-200 transition-all duration-200 ${
                 listCollapsed ? 'w-14' : 'w-80'
@@ -142,7 +156,25 @@
           )}
 
           <main className="min-w-0 flex-1 overflow-y-auto bg-gray-50/50">
-            {page === 'contacts' ? (
+            {page === 'dashboard' ? (
+              <Dashboard
+                onOpenOrg={(orgId) => {
+                  setSelectedId(orgId)
+                  setPage('orgs')
+                }}
+                // 요청자 요구: 대시보드 하단에서 전체를 볼 때는 사업관리로 넘어간다
+                onOpenOrgList={() => setPage('projects')}
+              />
+            ) : page === 'projects' ? (
+              <ProjectsPage
+                onOpenOrg={(orgId) => {
+                  setSelectedId(orgId)
+                  setPage('orgs')
+                }}
+              />
+            ) : page === 'system' ? (
+              <SystemPage onChanged={() => void reload()} />
+            ) : page === 'contacts' ? (
               <ContactsPage />
             ) : selected ? (
               page === 'channels' ? (
frontend/src/api/client.ts
--- frontend/src/api/client.ts
+++ frontend/src/api/client.ts
@@ -213,6 +213,183 @@
   })
 }
 
+/** 대시보드 KPI 타일 7개. 모두 서버가 같은 원본에서 접어 만든 값이다. */
+export interface DashboardSummary {
+  applied: number
+  docSubmitted: number
+  reviewTotal: number
+  reviewDone: number
+  processTotal: number
+  processDone: number
+  /** RE 데이터 모델이 아직 없어 항상 0이다. */
+  unresolvedRe: number
+  reportWriting: number
+  completed: number
+}
+
+/** 대시보드 칸반 카드 1장 = "최근 진행 기관" 표 1줄. 둘이 같은 행을 쓴다. */
+export interface DashboardOrgRow {
+  id: number
+  orgNo: string
+  orgName: string
+  stage: number | null
+  /** false면 칸반의 "단계 미지정" 컬럼으로 간다. 드래그로 단계를 옮길 수 없다. */
+  hasChannel: boolean
+  lawyerName: string | null
+  mjName: string | null
+  lawyerAssignedDate: string | null
+  reviewTotal: number
+  reviewDone: number
+  processTotal: number
+  processDone: number
+  /** 현재 단계로 넘어온 시각(epoch ms). 카드의 D+n 계산용. */
+  stageChangedAt: number | null
+  /** 단계·권리확인/처리·메모·배정 중 가장 최근 변동 시각(epoch ms). */
+  lastChangedAt: number | null
+  reCount: number
+}
+
+export interface DashboardData {
+  summary: DashboardSummary
+  orgs: DashboardOrgRow[]
+}
+
+export function getDashboard(): Promise<DashboardData> {
+  return request<DashboardData>('/api/dashboard')
+}
+
+/** 사업관리 목록 1행. org는 대시보드와 같은 집계 행을 그대로 쓴다. */
+export interface ProjectRow {
+  org: DashboardOrgRow
+  lastContactedOn: string | null
+  contactCount: number
+  reportCount: number
+}
+
+/** 신청 확인 연락 이력 1건. */
+export interface ContactLog {
+  id: number
+  orgId: number
+  contactedOn: string
+  method: string
+  summary: string
+  author: string
+  createdAt: number
+}
+
+export interface ContactLogInput {
+  contactedOn: string
+  method: string
+  summary: string
+}
+
+/** 기관별 최종 보고서. */
+export interface OrgReport {
+  id: number
+  orgId: number
+  fileName: string
+  contentType: string | null
+  byteSize: number
+  uploadedBy: string
+  uploadedAt: number
+}
+
+export function getProjects(): Promise<ProjectRow[]> {
+  return request<ProjectRow[]>('/api/projects')
+}
+
+export function getContactLogs(orgId: number): Promise<ContactLog[]> {
+  return request<ContactLog[]>(`/api/orgs/${orgId}/contact-logs`)
+}
+
+/** 등록하면 갱신된 이력 전체가 돌아온다(작성자는 서버가 기록). */
+export function addContactLog(orgId: number, body: ContactLogInput): Promise<ContactLog[]> {
+  return request<ContactLog[]>(`/api/orgs/${orgId}/contact-logs`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(body),
+  })
+}
+
+export function updateContactLog(
+  orgId: number,
+  logId: number,
+  body: ContactLogInput,
+): Promise<ContactLog[]> {
+  return request<ContactLog[]>(`/api/orgs/${orgId}/contact-logs/${logId}`, {
+    method: 'PUT',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(body),
+  })
+}
+
+export async function deleteContactLog(orgId: number, logId: number): Promise<void> {
+  const response = await fetch(`/api/orgs/${orgId}/contact-logs/${logId}`, {
+    method: 'DELETE',
+    credentials: 'same-origin',
+    headers: { 'X-XSRF-TOKEN': csrfToken() },
+  })
+  if (!response.ok) {
+    throw new ApiError(response.status, `요청 실패 (${response.status})`)
+  }
+}
+
+export function getReports(orgId: number): Promise<OrgReport[]> {
+  return request<OrgReport[]>(`/api/orgs/${orgId}/reports`)
+}
+
+export function uploadReport(orgId: number, file: File): Promise<OrgReport[]> {
+  const form = new FormData()
+  form.append('file', file)
+  return request<OrgReport[]>(`/api/orgs/${orgId}/reports`, { method: 'POST', body: form })
+}
+
+/** 다운로드는 <a href>가 그대로 열게 둔다. */
+export function reportDownloadUrl(orgId: number, reportId: number): string {
+  return `/api/orgs/${orgId}/reports/${reportId}`
+}
+
+export async function deleteReport(orgId: number, reportId: number): Promise<void> {
+  const response = await fetch(`/api/orgs/${orgId}/reports/${reportId}`, {
+    method: 'DELETE',
+    credentials: 'same-origin',
+    headers: { 'X-XSRF-TOKEN': csrfToken() },
+  })
+  if (!response.ok) {
+    throw new ApiError(response.status, `요청 실패 (${response.status})`)
+  }
+}
+
+/** 시스템관리 > 채널 초기화 미리보기 1행. 웹앱 DB가 아는 채널만 담긴다. */
+export interface ResetTarget {
+  orgId: number
+  orgNo: string
+  orgName: string
+  stage: number | null
+  channels: { kind: string; channelId: string }[]
+}
+
+export interface ResetReport {
+  archivedChannels: number
+  clearedOrgs: number
+  failures: string[]
+}
+
+/** 실행 확인 문구. 서버도 같은 값을 검사한다. */
+export const RESET_CONFIRM_PHRASE = '채널 초기화'
+
+export function getResetPreview(): Promise<ResetTarget[]> {
+  return request<ResetTarget[]>('/api/system/reset/preview')
+}
+
+export function resetChannels(confirm: string): Promise<ResetReport> {
+  return request<ResetReport>('/api/system/reset/channels', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ confirm }),
+  })
+}
+
 export function getTimeline(orgId: number): Promise<TimelineEvent[]> {
   return request<TimelineEvent[]>(`/api/orgs/${orgId}/timeline`)
 }
@@ -280,10 +457,8 @@
   return request<WorkMemo[]>(`/api/orgs/${orgId}/memos`)
 }
 
-export function createMemo(
-  orgId: number,
-  memo: { contactName: string; body: string },
-): Promise<WorkMemo> {
+/** 작성자는 보내지 않는다 - 서버가 로그인 사용자로 기록한다. */
+export function createMemo(orgId: number, memo: { body: string }): Promise<WorkMemo> {
   return request<WorkMemo>(`/api/orgs/${orgId}/memos`, {
     method: 'POST',
     headers: { 'Content-Type': 'application/json' },
@@ -292,6 +467,15 @@
 }
 
 /** 204를 돌려주므로 request()의 json() 경로를 타지 않고 직접 부른다(logout()과 동일한 패턴). */
+/** 본문만 고친다. 작성자·작성시각은 서버가 그대로 유지한다. */
+export function updateMemo(orgId: number, memoId: number, memo: { body: string }): Promise<WorkMemo> {
+  return request<WorkMemo>(`/api/orgs/${orgId}/memos/${memoId}`, {
+    method: 'PUT',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(memo),
+  })
+}
+
 export async function deleteMemo(orgId: number, memoId: number): Promise<void> {
   const response = await fetch(`/api/orgs/${orgId}/memos/${memoId}`, {
     method: 'DELETE',
@@ -375,13 +559,26 @@
   return request<ReviewImportReport>(`/api/orgs/${orgId}/review/import`, { method: 'POST', body: form })
 }
 
+/** 검색 조건. keyword는 통합검색, site/board/title은 열별 조건으로 서로 AND로 겹친다. */
+export interface ItemSearch {
+  keyword?: string
+  site?: string
+  board?: string
+  title?: string
+}
+
 export function getReviewPage(
   orgId: number,
-  params: { keyword?: string; page?: number; size?: number } = {},
+  params: ItemSearch & { page?: number; size?: number } = {},
 ): Promise<ReviewPage> {
   const query = new URLSearchParams()
   if (params.keyword) {
     query.set('keyword', params.keyword)
+  }
+  for (const field of ['site', 'board', 'title'] as const) {
+    if (params[field]) {
+      query.set(field, params[field] as string)
+    }
   }
   if (params.page !== undefined) {
     query.set('page', String(params.page))
@@ -495,14 +692,59 @@
   return request<ProcessImportReport>(`/api/orgs/${orgId}/process/import`, { method: 'POST', body: form })
 }
 
+/** 권리확인 일괄 등록. 비운 항목은 기존 값을 유지한다. */
+export function bulkUpdateReview(
+  orgId: number,
+  body: { ids: number[]; reviewResult?: string; judgedKoglType?: string; opinion?: string },
+): Promise<{ updated: number }> {
+  return request<{ updated: number }>(`/api/orgs/${orgId}/review/bulk`, {
+    method: 'PUT',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(body),
+  })
+}
+
+export function bulkDeleteReview(orgId: number, ids: number[]): Promise<{ deleted: number }> {
+  return request<{ deleted: number }>(`/api/orgs/${orgId}/review/bulk-delete`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(ids),
+  })
+}
+
+/** 권리처리 일괄 등록. 비운 항목은 기존 값을 유지한다. */
+export function bulkUpdateProcess(
+  orgId: number,
+  body: { ids: number[]; processStatus?: string; judgedKoglType?: string; finalOpinion?: string },
+): Promise<{ updated: number }> {
+  return request<{ updated: number }>(`/api/orgs/${orgId}/process/bulk`, {
+    method: 'PUT',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(body),
+  })
+}
+
+export function bulkDeleteProcess(orgId: number, ids: number[]): Promise<{ deleted: number }> {
+  return request<{ deleted: number }>(`/api/orgs/${orgId}/process/bulk-delete`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(ids),
+  })
+}
+
 export function getProcessPage(
   orgId: number,
-  params: { keyword?: string; status?: ProcessStatusFilter; page?: number; size?: number } = {},
+  params: ItemSearch & { status?: ProcessStatusFilter; page?: number; size?: number } = {},
 ): Promise<ProcessPage> {
   const query = new URLSearchParams()
   if (params.keyword) {
     query.set('keyword', params.keyword)
   }
+  for (const field of ['site', 'board', 'title'] as const) {
+    if (params[field]) {
+      query.set(field, params[field] as string)
+    }
+  }
   if (params.status) {
     query.set('status', params.status)
   }
frontend/src/components/ContactsPage.tsx
--- frontend/src/components/ContactsPage.tsx
+++ frontend/src/components/ContactsPage.tsx
@@ -10,6 +10,7 @@
   type ContactInput,
   type MemberImportReport,
 } from '../api/client'
+import HelpButton from '../help/HelpButton'
 
 const CATEGORY_LABELS: Record<ContactCategory, string> = {
   APPLICANT: '신청기관',
@@ -350,6 +351,9 @@
           >
             담당자 추가
           </button>
+          <span className="flex items-center">
+            <HelpButton topic="contacts.manage" />
+          </span>
         </div>
       </div>
 
 
frontend/src/components/Dashboard.test.tsx (added)
+++ frontend/src/components/Dashboard.test.tsx
@@ -0,0 +1,226 @@
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import Dashboard from './Dashboard'
+import type { DashboardData, DashboardOrgRow } from '../api/client'
+
+const mocks = vi.hoisted(() => ({
+  getDashboard: vi.fn(),
+  updateStage: vi.fn(),
+}))
+
+vi.mock('../api/client', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('../api/client')>()),
+  getDashboard: mocks.getDashboard,
+  updateStage: mocks.updateStage,
+}))
+
+const DAY = 24 * 60 * 60 * 1000
+
+function row(overrides: Partial<DashboardOrgRow> = {}): DashboardOrgRow {
+  return {
+    id: 1,
+    orgNo: '001',
+    orgName: '한국출판문화산업진흥원',
+    stage: 5,
+    hasChannel: true,
+    lawyerName: '전세준',
+    mjName: '이연경',
+    lawyerAssignedDate: '2026-06-19',
+    reviewTotal: 392,
+    reviewDone: 78,
+    processTotal: 0,
+    processDone: 0,
+    stageChangedAt: Date.now() - 3 * DAY,
+    lastChangedAt: Date.now() - 3 * DAY,
+    reCount: 0,
+    ...overrides,
+  }
+}
+
+function data(rows: DashboardOrgRow[], summary: Partial<DashboardData['summary']> = {}): DashboardData {
+  return {
+    summary: {
+      applied: rows.length,
+      docSubmitted: 0,
+      reviewTotal: 0,
+      reviewDone: 0,
+      processTotal: 0,
+      processDone: 0,
+      unresolvedRe: 0,
+      reportWriting: 0,
+      completed: 0,
+      ...summary,
+    },
+    orgs: rows,
+  }
+}
+
+function renderDashboard(onOpenOrg = vi.fn(), onOpenOrgList = vi.fn()) {
+  render(<Dashboard onOpenOrg={onOpenOrg} onOpenOrgList={onOpenOrgList} />)
+  return { onOpenOrg, onOpenOrgList }
+}
+
+/** 기관명은 칸반 카드와 표에 동시에 나오므로, 표를 볼 때는 항상 표 안에서만 찾는다. */
+function table() {
+  return within(screen.getByTestId('recent-table'))
+}
+
+describe('Dashboard', () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+    mocks.getDashboard.mockResolvedValue(data([row()]))
+    mocks.updateStage.mockResolvedValue({})
+  })
+
+  it('KPI 타일에 서버가 준 집계를 그대로 보여준다', async () => {
+    mocks.getDashboard.mockResolvedValue(
+      data([row()], {
+        applied: 50,
+        docSubmitted: 9,
+        reviewTotal: 2012,
+        reviewDone: 503,
+        processTotal: 197,
+        processDone: 0,
+        completed: 1,
+      }),
+    )
+    renderDashboard()
+
+    expect(await screen.findByText('503 / 2,012')).toBeTruthy()
+    expect(screen.getByText('0 / 197')).toBeTruthy()
+    // 권리확인 진척률
+    expect(screen.getByText('25%')).toBeTruthy()
+  })
+
+  it('채널이 없는 기관은 단계 미지정 컬럼에 담긴다', async () => {
+    mocks.getDashboard.mockResolvedValue(
+      data([row({ id: 1, orgName: '채널없는기관', hasChannel: false, stage: null })]),
+    )
+    renderDashboard()
+
+    const column = await screen.findByTestId('column-unassigned')
+    expect(column.textContent).toContain('채널없는기관')
+    expect(column.textContent).toContain('단계 미지정')
+  })
+
+  it('단계 미지정 카드를 끌면 이동하지 않고 채널 생성 경고창이 뜬다', async () => {
+    mocks.getDashboard.mockResolvedValue(
+      data([row({ orgName: '채널없는기관', hasChannel: false, stage: null })]),
+    )
+    renderDashboard()
+
+    const column = await screen.findByTestId('column-unassigned')
+    const card = within(column).getByText('채널없는기관')
+    fireEvent.dragStart(card.parentElement!, {
+      dataTransfer: { setData: vi.fn(), getData: vi.fn() },
+    })
+
+    expect(await screen.findByText('채널을 먼저 생성해 주세요')).toBeTruthy()
+    expect(mocks.updateStage).not.toHaveBeenCalled()
+  })
+
+  it('채널이 있는 카드를 다른 컬럼에 떨어뜨리면 단계가 바뀐다', async () => {
+    renderDashboard()
+    await screen.findByTestId('recent-table')
+
+    const target = screen.getByTestId('column-6')
+    fireEvent.drop(target, {
+      dataTransfer: { getData: () => '1', setData: vi.fn() },
+    })
+
+    await waitFor(() => expect(mocks.updateStage).toHaveBeenCalledWith(1, 6))
+  })
+
+  it('같은 컬럼에 떨어뜨리면 아무 것도 하지 않는다', async () => {
+    renderDashboard()
+    await screen.findByTestId('recent-table')
+
+    fireEvent.drop(screen.getByTestId('column-5'), {
+      dataTransfer: { getData: () => '1', setData: vi.fn() },
+    })
+
+    await waitFor(() => expect(mocks.getDashboard).toHaveBeenCalledTimes(1))
+    expect(mocks.updateStage).not.toHaveBeenCalled()
+  })
+
+  it('단계 미지정 컬럼에는 떨어뜨려도 단계가 바뀌지 않는다', async () => {
+    renderDashboard()
+    await screen.findByTestId('recent-table')
+
+    fireEvent.drop(screen.getByTestId('column-unassigned'), {
+      dataTransfer: { getData: () => '1', setData: vi.fn() },
+    })
+
+    await waitFor(() => expect(mocks.updateStage).not.toHaveBeenCalled())
+  })
+
+  it('카드에 현재 단계 진입 후 경과일을 D+n으로 보여준다', async () => {
+    mocks.getDashboard.mockResolvedValue(
+      data([row({ stageChangedAt: Date.now() - 30 * DAY })]),
+    )
+    renderDashboard()
+
+    expect(await screen.findByText('D+30')).toBeTruthy()
+  })
+
+  it('최근 진행 기관 표는 최근 변경순으로 정렬한다', async () => {
+    mocks.getDashboard.mockResolvedValue(
+      data([
+        row({ id: 1, orgNo: '001', orgName: '오래된기관', lastChangedAt: Date.now() - 10 * DAY }),
+        row({ id: 2, orgNo: '002', orgName: '최근기관', lastChangedAt: Date.now() }),
+      ]),
+    )
+    renderDashboard()
+
+    await screen.findByTestId('recent-table')
+    const names = table().getAllByRole('row').slice(1).map((tr) => tr.children[1].textContent)
+    expect(names).toEqual(['최근기관', '오래된기관'])
+  })
+
+  it('표는 8개까지만 보여주고 더보기로 늘린다', async () => {
+    const rows = Array.from({ length: 10 }, (_, i) =>
+      row({ id: i + 1, orgNo: String(i + 1).padStart(3, '0'), orgName: `기관${i + 1}` }),
+    )
+    mocks.getDashboard.mockResolvedValue(data(rows))
+    renderDashboard()
+
+    await screen.findByTestId('recent-table')
+    expect(table().getAllByRole('row')).toHaveLength(9) // 헤더 1 + 8
+
+    fireEvent.click(screen.getByText('더보기 (2개 남음)'))
+    expect(table().getAllByRole('row')).toHaveLength(11)
+  })
+
+  it('기관명 검색으로 표를 거른다', async () => {
+    mocks.getDashboard.mockResolvedValue(
+      data([
+        row({ id: 1, orgNo: '001', orgName: '한국출판문화산업진흥원' }),
+        row({ id: 2, orgNo: '002', orgName: '세종학당재단' }),
+      ]),
+    )
+    renderDashboard()
+
+    await screen.findByTestId('recent-table')
+    fireEvent.change(screen.getByLabelText('기관명/기관코드 검색'), { target: { value: '세종' } })
+
+    expect(table().queryByText('한국출판문화산업진흥원')).toBeNull()
+    expect(table().getByText('세종학당재단')).toBeTruthy()
+  })
+
+  it('표에서 기관을 클릭하면 기관관리로 넘긴다', async () => {
+    const onOpenOrg = vi.fn()
+    renderDashboard(onOpenOrg)
+
+    await screen.findByTestId('recent-table')
+    fireEvent.click(table().getByText('한국출판문화산업진흥원'))
+    expect(onOpenOrg).toHaveBeenCalledWith(1)
+  })
+
+  it('하단 전체 보기는 사업관리로 넘긴다', async () => {
+    const onOpenOrgList = vi.fn()
+    renderDashboard(vi.fn(), onOpenOrgList)
+
+    fireEvent.click(await screen.findByText('사업관리에서 전체 보기 →'))
+    expect(onOpenOrgList).toHaveBeenCalled()
+  })
+})
 
frontend/src/components/Dashboard.tsx (added)
+++ frontend/src/components/Dashboard.tsx
@@ -0,0 +1,697 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import {
+  getDashboard,
+  updateStage,
+  type DashboardData,
+  type DashboardOrgRow,
+} from '../api/client'
+import { STAGE_GROUPS, STAGE_LABELS, stageGroupKey, stageLabel } from '../stages'
+import HelpButton from '../help/HelpButton'
+
+/**
+ * 시안(3단계 대쉬보드 화면 설계안)의 대시보드 화면. 세 블록으로 구성된다.
+ *   1) KPI 타일 7개
+ *   2) 단계별 현황 칸반 - 카드를 끌어 단계를 옮긴다
+ *   3) 최근 진행 기관 표 - 최근 변동순 상위 N개
+ *
+ * 칸반 맨 앞의 "단계 미지정" 컬럼은 <b>채널을 아직 만들지 않은 기관만</b> 담는다(요청자 확정).
+ * 기관관리 상세 화면에는 이 개념이 없다 - 거기서는 채널 생성 전이면 1~12 스트립이 전부
+ * 회색으로 보일 뿐이다(OrgOverview). 미지정 카드는 끌어도 단계가 바뀌지 않고, 채널을
+ * 먼저 만들라는 경고창이 뜬다. 단계만 올려 두면 게시글·자료·권리검토가 전부 채널에 붙어
+ * 있어서 빈 껍데기가 되기 때문이다.
+ */
+
+interface Props {
+  /** 카드/표에서 기관을 클릭했을 때. 기관관리 화면으로 이동해 해당 기관을 연다. */
+  onOpenOrg: (orgId: number) => void
+  /** [전체 기관 보기]. 기관관리 화면으로만 이동한다. */
+  onOpenOrgList: () => void
+}
+
+const CIRCLED = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩', '⑪', '⑫']
+
+/** 칸반 컬럼 상단 색 띠. 구간별로 같은 색을 쓴다. */
+const GROUP_COLOR: Record<string, string> = {
+  intake: 'bg-slate-400',
+  assign: 'bg-indigo-400',
+  check: 'bg-emerald-400',
+  process: 'bg-amber-400',
+  closing: 'bg-violet-400',
+}
+
+/** 표/칸반이 한 번에 보여주는 기관 수. [더보기]를 누를 때마다 이만큼 늘어난다. */
+const PAGE_SIZE = 8
+/** 칸반 컬럼 하나가 한 번에 보여주는 카드 수. */
+const COLUMN_SIZE = 2
+
+const DAY_MS = 24 * 60 * 60 * 1000
+
+function formatDate(ms: number | null): string {
+  if (!ms) {
+    return '-'
+  }
+  const d = new Date(ms)
+  const pad = (n: number) => String(n).padStart(2, '0')
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
+}
+
+/** 현재 단계로 넘어온 뒤 며칠 지났는지. 이력이 없으면 null이라 배지를 아예 안 그린다. */
+function daysSince(ms: number | null): number | null {
+  if (!ms) {
+    return null
+  }
+  return Math.max(0, Math.floor((Date.now() - ms) / DAY_MS))
+}
+
+function percent(done: number, total: number): number {
+  return total === 0 ? 0 : Math.round((done / total) * 100)
+}
+
+/**
+ * 카드가 들어갈 칸반 컬럼. null이면 "단계 미지정"이다.
+ *
+ * 채널이 있는데 단계가 비어 있는 기관은 V11 백필과 채널 생성 로직 덕에 원칙적으로 없지만,
+ * 혹시 생기더라도 보드에서 사라지지 않도록 1단계로 본다.
+ */
+function columnOf(row: DashboardOrgRow): number | null {
+  if (!row.hasChannel) {
+    return null
+  }
+  return row.stage ?? 1
+}
+
+function KpiTile({
+  label,
+  value,
+  sub,
+  accent,
+}: {
+  label: string
+  value: string
+  sub?: string
+  accent?: boolean
+}) {
+  return (
+    <div
+      className={`flex min-w-0 flex-1 flex-col items-center gap-1 rounded-lg border bg-white px-3 py-4 ${
+        accent ? 'border-gray-300 shadow-sm' : 'border-gray-200'
+      }`}
+    >
+      <span className="text-xs text-gray-500">{label}</span>
+      <span className="text-2xl font-semibold tabular-nums">{value}</span>
+      {sub && <span className="text-xs text-gray-400 tabular-nums">{sub}</span>}
+    </div>
+  )
+}
+
+function ProgressLine({ label, done, total }: { label: string; done: number; total: number }) {
+  const value = percent(done, total)
+  return (
+    <div className="space-y-1">
+      <div className="flex items-baseline justify-between text-xs">
+        <span className="text-gray-500">
+          {label} <span className="font-medium text-gray-800 tabular-nums">{done.toLocaleString()} / {total.toLocaleString()}</span>
+        </span>
+        <span className="font-medium text-gray-600 tabular-nums">{value}%</span>
+      </div>
+      <div className="h-1 w-full rounded-full bg-gray-100">
+        <div className="h-1 rounded-full bg-blue-500" style={{ width: `${value}%` }} />
+      </div>
+    </div>
+  )
+}
+
+/** 단계 구간에 따라 카드 본문이 달라진다(시안의 컬럼별 카드 모양). */
+function CardBody({ row }: { row: DashboardOrgRow }) {
+  const stage = columnOf(row)
+  const group = stageGroupKey(stage)
+  const lawyer = row.lawyerName ?? '변호사 미배정'
+
+  if (stage === null) {
+    return <p className="text-xs text-gray-500">채널 미생성 · 담당자 배정 후 채널을 만드세요</p>
+  }
+  if (stage === 1) {
+    return <p className="text-xs text-gray-500">신청일 {formatDate(row.stageChangedAt)}</p>
+  }
+  if (stage === 2 || stage === 3) {
+    return (
+      <div className="space-y-0.5 text-xs text-gray-500">
+        <p>목록 {row.reviewTotal.toLocaleString()}건</p>
+        <p>접수일 {formatDate(row.stageChangedAt)}</p>
+      </div>
+    )
+  }
+  if (group === 'assign') {
+    return (
+      <div className="space-y-0.5 text-xs text-gray-500">
+        <p>{lawyer}</p>
+        <p>배정일 {row.lawyerAssignedDate ?? formatDate(row.stageChangedAt)}</p>
+      </div>
+    )
+  }
+  if (group === 'check') {
+    return (
+      <div className="space-y-1.5">
+        <p className="text-xs text-gray-500">{lawyer}</p>
+        <ProgressLine label="확인" done={row.reviewDone} total={row.reviewTotal} />
+      </div>
+    )
+  }
+  if (group === 'process') {
+    return (
+      <div className="space-y-1.5">
+        <p className="text-xs text-gray-500">{lawyer}</p>
+        <ProgressLine label="확인" done={row.reviewDone} total={row.reviewTotal} />
+        <ProgressLine label="처리" done={row.processDone} total={row.processTotal} />
+      </div>
+    )
+  }
+  return (
+    <div className="space-y-1 text-xs text-gray-500">
+      <p>{lawyer}</p>
+      <span className="inline-block rounded-full bg-violet-50 px-2 py-0.5 text-[11px] text-violet-700">
+        {CIRCLED[stage - 1]} {stageLabel(stage)}
+      </span>
+    </div>
+  )
+}
+
+export default function Dashboard({ onOpenOrg, onOpenOrgList }: Props) {
+  const [data, setData] = useState<DashboardData | null>(null)
+  const [error, setError] = useState<string | null>(null)
+  const [busy, setBusy] = useState(false)
+  const [warning, setWarning] = useState<string | null>(null)
+
+  // 칸반 필터
+  const [groupFilter, setGroupFilter] = useState('')
+  const [lawyerFilter, setLawyerFilter] = useState('')
+  const [stageFilter, setStageFilter] = useState('')
+  const [statusFilter, setStatusFilter] = useState('')
+  const [boardKeyword, setBoardKeyword] = useState('')
+  const [expandedColumns, setExpandedColumns] = useState<Record<string, boolean>>({})
+
+  // 최근 진행 기관 표 필터
+  const [tableKeyword, setTableKeyword] = useState('')
+  const [tableStage, setTableStage] = useState('')
+  const [tableMj, setTableMj] = useState('')
+  const [tableLawyer, setTableLawyer] = useState('')
+  const [tableData, setTableData] = useState('')
+  const [visible, setVisible] = useState(PAGE_SIZE)
+
+  const reload = useCallback(async () => {
+    try {
+      setData(await getDashboard())
+      setError(null)
+    } catch (e) {
+      setError((e as Error).message)
+    }
+  }, [])
+
+  useEffect(() => {
+    void reload()
+  }, [reload])
+
+  const rows = useMemo(() => data?.orgs ?? [], [data])
+
+  const lawyers = useMemo(
+    () => [...new Set(rows.map((r) => r.lawyerName).filter((n): n is string => !!n))].sort(),
+    [rows],
+  )
+  const mjs = useMemo(
+    () => [...new Set(rows.map((r) => r.mjName).filter((n): n is string => !!n))].sort(),
+    [rows],
+  )
+
+  const boardRows = useMemo(() => {
+    const keyword = boardKeyword.trim()
+    return rows.filter((row) => {
+      const stage = columnOf(row)
+      if (groupFilter && stageGroupKey(stage) !== groupFilter) {
+        return false
+      }
+      if (lawyerFilter && row.lawyerName !== lawyerFilter) {
+        return false
+      }
+      if (stageFilter && String(stage ?? '') !== stageFilter) {
+        return false
+      }
+      if (statusFilter === 'unassigned' && row.hasChannel) {
+        return false
+      }
+      if (statusFilter === 'active' && (!row.hasChannel || (stage ?? 0) >= 11)) {
+        return false
+      }
+      if (statusFilter === 'done' && (stage ?? 0) < 11) {
+        return false
+      }
+      if (keyword && !row.orgName.includes(keyword) && !row.orgNo.includes(keyword)) {
+        return false
+      }
+      return true
+    })
+  }, [rows, groupFilter, lawyerFilter, stageFilter, statusFilter, boardKeyword])
+
+  /** 시안의 "정렬 기준 최근 진행순" - 칸반 카드도 최근 변동이 위로 온다. */
+  const byRecent = useCallback(
+    (a: DashboardOrgRow, b: DashboardOrgRow) => (b.lastChangedAt ?? 0) - (a.lastChangedAt ?? 0),
+    [],
+  )
+
+  const tableRows = useMemo(() => {
+    const keyword = tableKeyword.trim()
+    return rows
+      .filter((row) => {
+        if (keyword && !row.orgName.includes(keyword) && !row.orgNo.includes(keyword)) {
+          return false
+        }
+        if (tableStage && String(columnOf(row) ?? '') !== tableStage) {
+          return false
+        }
+        if (tableMj && row.mjName !== tableMj) {
+          return false
+        }
+        if (tableLawyer && row.lawyerName !== tableLawyer) {
+          return false
+        }
+        if (tableData === 'received' && row.reviewTotal === 0) {
+          return false
+        }
+        if (tableData === 'pending' && row.reviewTotal > 0) {
+          return false
+        }
+        return true
+      })
+      .sort(byRecent)
+  }, [rows, tableKeyword, tableStage, tableMj, tableLawyer, tableData, byRecent])
+
+  async function moveStage(row: DashboardOrgRow, stage: number) {
+    if (!row.hasChannel) {
+      setWarning(`${row.orgNo} ${row.orgName}\n\n채널을 먼저 생성해야 단계를 옮길 수 있습니다. 채널관리에서 채널을 생성하면 자동으로 ①신청 단계로 들어갑니다.`)
+      return
+    }
+    if (columnOf(row) === stage) {
+      return
+    }
+    setBusy(true)
+    try {
+      await updateStage(row.id, stage)
+      await reload()
+    } catch (e) {
+      setError((e as Error).message)
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  function handleDragStart(e: React.DragEvent, row: DashboardOrgRow) {
+    if (!row.hasChannel) {
+      // 드래그 자체를 취소하고 경고창을 띄운다. draggable을 false로 두면 이벤트가
+      // 아예 안 와서 "왜 안 움직이는지" 알려줄 기회가 없다.
+      e.preventDefault()
+      setWarning(`${row.orgNo} ${row.orgName}\n\n채널이 아직 없어 단계를 옮길 수 없습니다. 채널관리에서 채널을 먼저 생성해 주세요. 채널을 만들면 자동으로 ①신청 단계로 들어갑니다.`)
+      return
+    }
+    e.dataTransfer.setData('text/plain', String(row.id))
+    e.dataTransfer.effectAllowed = 'move'
+  }
+
+  function handleDrop(e: React.DragEvent, stage: number) {
+    e.preventDefault()
+    const id = Number(e.dataTransfer.getData('text/plain'))
+    const row = rows.find((r) => r.id === id)
+    if (row) {
+      void moveStage(row, stage)
+    }
+  }
+
+  if (error && !data) {
+    return (
+      <div className="flex flex-col items-center gap-3 p-8 text-sm">
+        <p className="text-red-600">{error}</p>
+        <button
+          type="button"
+          onClick={() => void reload()}
+          className="rounded-md border border-gray-300 px-3 py-1.5"
+        >
+          다시 시도
+        </button>
+      </div>
+    )
+  }
+
+  if (!data) {
+    return <p className="p-8 text-sm text-gray-500">불러오는 중…</p>
+  }
+
+  const s = data.summary
+  const columns: (number | null)[] = [null, ...STAGE_LABELS.map((_, i) => i + 1)]
+
+  return (
+    <div className="space-y-4 p-6">
+      {error && <p className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p>}
+
+      <section className="rounded-lg border border-gray-200 bg-white p-5">
+        <div className="flex items-start justify-between">
+          <div>
+            <h1 className="flex items-center gap-1.5 text-lg font-semibold">
+              대시보드
+              <HelpButton topic="dashboard.kpi" />
+            </h1>
+            <p className="text-xs text-gray-500">전체기관의 진행 상태 및 검토 현황</p>
+          </div>
+          <span className="text-xs text-gray-500">● 시스템 정상</span>
+        </div>
+
+        <div className="mt-4 flex flex-wrap gap-2">
+          <KpiTile label="신청" value={String(s.applied)} />
+          <KpiTile label="서류제출" value={String(s.docSubmitted)} />
+          <KpiTile
+            label="권리확인"
+            value={`${s.reviewDone.toLocaleString()} / ${s.reviewTotal.toLocaleString()}`}
+            sub={`${percent(s.reviewDone, s.reviewTotal)}%`}
+            accent
+          />
+          <KpiTile
+            label="권리처리"
+            value={`${s.processDone.toLocaleString()} / ${s.processTotal.toLocaleString()}`}
+            sub={`${percent(s.processDone, s.processTotal)}%`}
+            accent
+          />
+          <KpiTile label="미해결 RE" value={String(s.unresolvedRe)} sub="데이터 준비 중" />
+          <KpiTile label="보고서 작성" value={String(s.reportWriting)} />
+          <KpiTile label="완료" value={String(s.completed)} />
+        </div>
+      </section>
+
+      <section className="rounded-lg border border-gray-200 bg-white p-5">
+        <h2 className="flex items-center gap-1.5 text-sm font-semibold">
+          단계별 현황
+          <HelpButton topic="dashboard.kanban" />
+        </h2>
+
+        <div className="mt-3 flex flex-wrap gap-2">
+          <select
+            aria-label="업무 구간"
+            value={groupFilter}
+            onChange={(e) => setGroupFilter(e.target.value)}
+            className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+          >
+            <option value="">업무 구간 전체</option>
+            {STAGE_GROUPS.map((g) => (
+              <option key={g.key} value={g.key}>{g.label}</option>
+            ))}
+          </select>
+          <select
+            aria-label="담당 변호사"
+            value={lawyerFilter}
+            onChange={(e) => setLawyerFilter(e.target.value)}
+            className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+          >
+            <option value="">담당 변호사 전체</option>
+            {lawyers.map((n) => (
+              <option key={n} value={n}>{n}</option>
+            ))}
+          </select>
+          <select
+            aria-label="현재 단계"
+            value={stageFilter}
+            onChange={(e) => setStageFilter(e.target.value)}
+            className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+          >
+            <option value="">현재 단계 전체</option>
+            {STAGE_LABELS.map((label, i) => (
+              <option key={label} value={String(i + 1)}>{CIRCLED[i]} {label}</option>
+            ))}
+          </select>
+          <select
+            aria-label="진행 상태"
+            value={statusFilter}
+            onChange={(e) => setStatusFilter(e.target.value)}
+            className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+          >
+            <option value="">진행 상태 전체</option>
+            <option value="unassigned">단계 미지정</option>
+            <option value="active">진행중</option>
+            <option value="done">완료</option>
+          </select>
+          <input
+            aria-label="기관명 검색"
+            placeholder="기관명 검색"
+            value={boardKeyword}
+            onChange={(e) => setBoardKeyword(e.target.value)}
+            className="min-w-40 flex-1 rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+          />
+        </div>
+
+        <div className="mt-3 flex items-center justify-between rounded-md bg-gray-50 px-3 py-2 text-xs text-gray-500">
+          <span>기관 카드를 드래그하여 단계 이동이 가능합니다. 단계 이동 시 현재 단계가 자동 변경됩니다.</span>
+          <span>정렬 기준 최근 진행순</span>
+        </div>
+
+        <div className="mt-3 flex gap-3 overflow-x-auto pb-2">
+          {columns.map((stage) => {
+            const key = stage === null ? 'unassigned' : String(stage)
+            const cards = boardRows.filter((row) => columnOf(row) === stage).sort(byRecent)
+            const expanded = expandedColumns[key]
+            const shown = expanded ? cards : cards.slice(0, COLUMN_SIZE)
+            const color = stage === null ? 'bg-gray-300' : GROUP_COLOR[stageGroupKey(stage) ?? ''] ?? 'bg-gray-300'
+
+            return (
+              <div
+                key={key}
+                data-testid={`column-${key}`}
+                onDragOver={(e) => {
+                  if (stage !== null) {
+                    e.preventDefault()
+                  }
+                }}
+                onDrop={(e) => {
+                  if (stage !== null) {
+                    handleDrop(e, stage)
+                  }
+                }}
+                className="flex w-56 shrink-0 flex-col rounded-lg border border-gray-200 bg-gray-50/60"
+              >
+                <div className={`h-1 rounded-t-lg ${color}`} />
+                <div className="flex items-center justify-between px-3 py-2">
+                  <span className="text-sm font-medium">
+                    {stage === null ? '단계 미지정' : `${CIRCLED[stage - 1]} ${stageLabel(stage)}`}
+                  </span>
+                  <span className="text-sm text-gray-500 tabular-nums">{cards.length}</span>
+                </div>
+
+                <div className="space-y-2 px-2 pb-2">
+                  {shown.map((row) => (
+                    <div
+                      key={row.id}
+                      draggable
+                      onDragStart={(e) => handleDragStart(e, row)}
+                      onClick={() => onOpenOrg(row.id)}
+                      className={`space-y-1.5 rounded-md border border-gray-200 bg-white p-2.5 ${
+                        row.hasChannel ? 'cursor-grab' : 'cursor-pointer border-dashed'
+                      } ${busy ? 'opacity-60' : ''}`}
+                    >
+                      <p className="truncate text-sm font-medium">{row.orgName}</p>
+                      <CardBody row={row} />
+                      {(() => {
+                        const days = daysSince(row.stageChangedAt)
+                        return days === null ? null : (
+                          <span className="inline-block rounded bg-gray-100 px-1.5 py-0.5 text-[11px] text-gray-500">
+                            D+{days}
+                          </span>
+                        )
+                      })()}
+                    </div>
+                  ))}
+
+                  {cards.length > shown.length && (
+                    <button
+                      type="button"
+                      onClick={() => setExpandedColumns((prev) => ({ ...prev, [key]: true }))}
+                      className="w-full rounded-md py-1.5 text-xs text-gray-500 hover:bg-gray-100"
+                    >
+                      + {cards.length - shown.length}건 더보기
+                    </button>
+                  )}
+                </div>
+              </div>
+            )
+          })}
+        </div>
+      </section>
+
+      <section className="rounded-lg border border-gray-200 bg-white p-5">
+        <h2 className="flex items-center gap-1.5 text-sm font-semibold">
+          최근 진행 기관
+          <HelpButton topic="dashboard.recent" />
+        </h2>
+        <p className="text-xs text-gray-500">최근 변경 기준 {Math.min(visible, tableRows.length)}개 기관</p>
+
+        <div className="mt-3 flex flex-wrap gap-2">
+          <input
+            aria-label="기관명/기관코드 검색"
+            placeholder="기관명/기관코드 검색"
+            value={tableKeyword}
+            onChange={(e) => setTableKeyword(e.target.value)}
+            className="min-w-48 flex-1 rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+          />
+          <select
+            aria-label="표 현재 단계"
+            value={tableStage}
+            onChange={(e) => setTableStage(e.target.value)}
+            className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+          >
+            <option value="">현재 단계 전체</option>
+            {STAGE_LABELS.map((label, i) => (
+              <option key={label} value={String(i + 1)}>{CIRCLED[i]} {label}</option>
+            ))}
+          </select>
+          <select
+            aria-label="문정원 담당"
+            value={tableMj}
+            onChange={(e) => setTableMj(e.target.value)}
+            className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+          >
+            <option value="">문정원 담당 전체</option>
+            {mjs.map((n) => (
+              <option key={n} value={n}>{n}</option>
+            ))}
+          </select>
+          <select
+            aria-label="표 담당변호사"
+            value={tableLawyer}
+            onChange={(e) => setTableLawyer(e.target.value)}
+            className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+          >
+            <option value="">담당변호사 전체</option>
+            {lawyers.map((n) => (
+              <option key={n} value={n}>{n}</option>
+            ))}
+          </select>
+          <select
+            aria-label="자료접수 상태"
+            value={tableData}
+            onChange={(e) => setTableData(e.target.value)}
+            className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+          >
+            <option value="">자료접수 상태 전체</option>
+            <option value="received">접수</option>
+            <option value="pending">미접수</option>
+          </select>
+          <button
+            type="button"
+            onClick={() => {
+              setTableKeyword('')
+              setTableStage('')
+              setTableMj('')
+              setTableLawyer('')
+              setTableData('')
+              setVisible(PAGE_SIZE)
+            }}
+            className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50"
+          >
+            초기화
+          </button>
+        </div>
+
+        <div className="mt-3 overflow-x-auto">
+          <table data-testid="recent-table" className="w-full text-sm">
+            <thead>
+              <tr className="border-b border-gray-200 text-xs text-gray-500">
+                <th className="px-2 py-2 text-left font-normal">연번</th>
+                <th className="px-2 py-2 text-left font-normal">기관명</th>
+                <th className="px-2 py-2 text-left font-normal">현재 단계</th>
+                <th className="px-2 py-2 text-left font-normal">문정원 담당</th>
+                <th className="px-2 py-2 text-left font-normal">담당 변호사</th>
+                <th className="px-2 py-2 text-right font-normal">권리확인</th>
+                <th className="px-2 py-2 text-right font-normal">권리처리</th>
+                <th className="px-2 py-2 text-right font-normal">RE</th>
+                <th className="px-2 py-2 text-right font-normal">최근 변경</th>
+              </tr>
+            </thead>
+            <tbody>
+              {tableRows.slice(0, visible).map((row) => {
+                const stage = columnOf(row)
+                return (
+                  <tr
+                    key={row.id}
+                    onClick={() => onOpenOrg(row.id)}
+                    className="cursor-pointer border-b border-gray-100 hover:bg-gray-50"
+                  >
+                    <td className="px-2 py-2 text-gray-500 tabular-nums">{row.orgNo}</td>
+                    <td className="px-2 py-2 font-medium">{row.orgName}</td>
+                    <td className="px-2 py-2">
+                      <span className="inline-block rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-700">
+                        {stage === null ? '단계 미지정' : `${CIRCLED[stage - 1]} ${stageLabel(stage)}`}
+                      </span>
+                    </td>
+                    <td className="px-2 py-2 text-gray-600">{row.mjName ?? '-'}</td>
+                    <td className="px-2 py-2 text-gray-600">{row.lawyerName ?? '-'}</td>
+                    <td className="px-2 py-2 text-right tabular-nums">
+                      {row.reviewDone.toLocaleString()} / {row.reviewTotal.toLocaleString()}
+                    </td>
+                    <td className="px-2 py-2 text-right tabular-nums">
+                      {row.processDone.toLocaleString()} / {row.processTotal.toLocaleString()}
+                    </td>
+                    <td className="px-2 py-2 text-right tabular-nums">{row.reCount}</td>
+                    <td className="px-2 py-2 text-right text-gray-500 tabular-nums">
+                      {formatDate(row.lastChangedAt)}
+                    </td>
+                  </tr>
+                )
+              })}
+              {tableRows.length === 0 && (
+                <tr>
+                  <td colSpan={9} className="px-2 py-6 text-center text-sm text-gray-500">
+                    조건에 맞는 기관이 없습니다.
+                  </td>
+                </tr>
+              )}
+            </tbody>
+          </table>
+        </div>
+
+        <div className="mt-3 flex items-center justify-between">
+          {tableRows.length > visible ? (
+            <button
+              type="button"
+              onClick={() => setVisible((v) => v + PAGE_SIZE)}
+              className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50"
+            >
+              더보기 ({tableRows.length - visible}개 남음)
+            </button>
+          ) : (
+            <span />
+          )}
+          <button
+            type="button"
+            onClick={onOpenOrgList}
+            className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50"
+          >
+            사업관리에서 전체 보기 →
+          </button>
+        </div>
+      </section>
+
+      {warning && (
+        <div className="fixed inset-0 flex items-center justify-center bg-black/30" role="alertdialog">
+          <div className="w-96 rounded-lg bg-white p-5">
+            <h2 className="text-base font-semibold">채널을 먼저 생성해 주세요</h2>
+            <p className="mt-3 whitespace-pre-line rounded-md bg-gray-50 p-3 text-sm text-gray-700">
+              {warning}
+            </p>
+            <div className="mt-5 flex justify-end">
+              <button
+                type="button"
+                onClick={() => setWarning(null)}
+                className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white"
+              >
+                확인
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}
frontend/src/components/OrgDetail.tsx
--- frontend/src/components/OrgDetail.tsx
+++ frontend/src/components/OrgDetail.tsx
@@ -8,6 +8,7 @@
   type Org,
   type ProvisionResult,
 } from '../api/client'
+import HelpButton from '../help/HelpButton'
 import ConfirmDialog from './ConfirmDialog'
 import ContactPickerModal from './ContactPickerModal'
 import StatusBadge from './StatusBadge'
@@ -214,7 +215,10 @@
             </div>
           )
         })}
-        <p className="text-xs text-gray-400">신청기관 담당자를 지정해야 채널을 만들 수 있습니다.</p>
+        <p className="flex items-center gap-1.5 text-xs text-gray-400">
+          신청기관 담당자를 지정해야 채널을 만들 수 있습니다.
+          <HelpButton topic="channel.assign" />
+        </p>
       </section>
 
       <div className="mt-5 flex gap-2">
@@ -226,6 +230,9 @@
         >
           채널 생성
         </button>
+        <span className="flex items-center">
+          <HelpButton topic="channel.provision" />
+        </span>
       </div>
 
       {error && <p className="mt-4 text-sm text-red-600">{error}</p>}
frontend/src/components/OrgOverview.test.tsx
--- frontend/src/components/OrgOverview.test.tsx
+++ frontend/src/components/OrgOverview.test.tsx
@@ -370,8 +370,9 @@
     render(<OrgOverview org={org()} />)
 
     expect(screen.getByRole('button', { name: '자료 업로드' })).toBeDisabled()
-    expect(screen.getByRole('button', { name: '최초 게시글 보기' })).toBeDisabled()
     expect(screen.getByRole('button', { name: '보고서 관리' })).toBeDisabled()
+    // 최초 게시글 보기는 구현되어 채널이 있으면 눌릴 수 있다
+    expect(screen.getByRole('button', { name: '최초 게시글 보기' })).toBeEnabled()
     await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
   })
 
frontend/src/components/OrgOverview.tsx
--- frontend/src/components/OrgOverview.tsx
+++ frontend/src/components/OrgOverview.tsx
@@ -1,5 +1,6 @@
 import { useEffect, useState } from 'react'
 import {
+  getNotice,
   getProcessPage,
   getReviewPage,
   getStageHistory,
@@ -8,8 +9,10 @@
   type Contact,
   type ContactCategory,
   type Org,
+  type PostView,
 } from '../api/client'
 import { STAGE_LABELS, stageLabel } from '../stages'
+import HelpButton from '../help/HelpButton'
 import ChannelFiles from './ChannelFiles'
 import ChannelPosts from './ChannelPosts'
 import ContactPickerModal from './ContactPickerModal'
@@ -25,9 +28,19 @@
 
 const TABS = ['채팅', '자료', '권리확인', '권리처리', 'RE', '타임라인', '업무메모']
 
+/** 탭마다 그 탭 기능의 설명서를 띄운다. RE는 화면이 아직 없어 도움말도 없다. */
+const TAB_HELP: Record<string, string> = {
+  '채팅': 'org.chat',
+  '자료': 'org.files',
+  '권리확인': 'org.review',
+  '권리처리': 'org.process',
+  '타임라인': 'org.timeline',
+  '업무메모': 'org.memo',
+}
+
 // 담당 변호사 변경은 카드의 [생성/변경] 버튼으로 대체되어 목록에서 뺐다.
 // 진행단계 변경은 업무단계 현황 스트립 + 이 버튼의 모달로 실제 동작한다.
-const DISABLED_ACTIONS = ['자료 업로드', '최초 게시글 보기', '보고서 관리']
+const DISABLED_ACTIONS = ['자료 업로드', '보고서 관리']
 
 // 카드에서 담당자를 지정/변경할 때 쓰는 역할 정의 (채널관리의 배정과 같은 API를 쓴다)
 const CARD_ROLES: Record<
@@ -130,6 +143,10 @@
   const [stageBusy, setStageBusy] = useState(false)
   const [stageError, setStageError] = useState<string | null>(null)
   const [stagePickerOpen, setStagePickerOpen] = useState(false)
+  /** [최초 게시글 보기]가 띄우는 핀 게시물. 채널별로 따로 담는다. */
+  const [firstPosts, setFirstPosts] = useState<{ mj: PostView | null; law: PostView | null } | null>(null)
+  const [firstPostsBusy, setFirstPostsBusy] = useState(false)
+  const [firstPostsError, setFirstPostsError] = useState<string | null>(null)
   // 단계별로 '그 단계에 들어간 날짜'(가장 최근 이력)를 찍기 위한 조회 결과
   const [stageDates, setStageDates] = useState<Record<number, number>>({})
 
@@ -233,6 +250,27 @@
     }
   }
 
+  /**
+   * 채널에 고정(핀)된 게시물을 두 채널에서 가져온다. 채널 생성 때 올린 최초 안내글이
+   * 곧 그 채널의 공지라서, 요청자가 말한 "최초 자료 등록 + 핀 고정한 게시물"이 이것이다.
+   */
+  async function openFirstPosts() {
+    setFirstPostsBusy(true)
+    setFirstPostsError(null)
+    setFirstPosts({ mj: null, law: null })
+    try {
+      const [mj, law] = await Promise.all([
+        org.channelIdMj ? getNotice(org.id, 'mj') : Promise.resolve(null),
+        org.channelIdLaw ? getNotice(org.id, 'law') : Promise.resolve(null),
+      ])
+      setFirstPosts({ mj, law })
+    } catch (e) {
+      setFirstPostsError(e instanceof Error ? e.message : '게시글을 불러오지 못했습니다.')
+    } finally {
+      setFirstPostsBusy(false)
+    }
+  }
+
   /** 업무단계 현황 스트립에서 "현재 단계"를 클릭했을 때만 호출된다 - 다음 단계로 1칸 전진. */
   async function advanceStage() {
     if (org.stage === null || org.stage >= 12 || stageBusy) {
@@ -274,6 +312,21 @@
             >
               진행단계 변경
             </button>
+            <span className="flex items-center">
+              <HelpButton topic="org.stage" />
+            </span>
+            <button
+              type="button"
+              disabled={!org.channelIdMj && !org.channelIdLaw}
+              title={!org.channelIdMj && !org.channelIdLaw ? '채널을 먼저 생성하세요' : undefined}
+              onClick={() => void openFirstPosts()}
+              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"
+            >
+              최초 게시글 보기
+            </button>
+            <span className="flex items-center">
+              <HelpButton topic="org.firstPost" />
+            </span>
             {DISABLED_ACTIONS.map((label) => (
               <button
                 key={label}
@@ -381,7 +434,10 @@
         {/* 업무단계 현황: 완료된 단계는 초록 체크, 현재 단계는 파란 강조 + 클릭 시 다음 단계로
             전진(확인 후), 그 뒤 단계는 회색 그대로. 채널 생성 전(stage === null)에는 전부 회색이다. */}
         <section className="mt-5">
-          <h2 className="text-sm font-semibold">업무단계 현황</h2>
+          <h2 className="flex items-center gap-1.5 text-sm font-semibold">
+            업무단계 현황
+            <HelpButton topic="org.stage" />
+          </h2>
           <ol className="mt-3 flex gap-2 overflow-x-auto pb-2">
             {STAGE_LABELS.map((step, i) => {
               const n = i + 1
@@ -506,6 +562,11 @@
                 {tab}
               </button>
             ))}
+            {TAB_HELP[activeTab] && (
+              <span className="flex items-center pl-2">
+                <HelpButton topic={TAB_HELP[activeTab]} />
+              </span>
+            )}
           </nav>
         </div>
 
@@ -548,6 +609,77 @@
           onClose={() => setStagePickerOpen(false)}
         />
       )}
+
+      {firstPosts && (
+        <div
+          role="dialog"
+          aria-label="최초 게시글"
+          className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 p-6"
+        >
+          <div className="w-full max-w-2xl rounded-lg bg-white shadow-xl">
+            <div className="flex items-start justify-between gap-3 border-b border-gray-200 px-5 py-4">
+              <div>
+                <h2 className="text-base font-semibold">최초 게시글</h2>
+                <p className="mt-1 text-xs text-gray-500">
+                  각 채널에 고정(핀)된 게시물입니다. 채널을 만들 때 올린 최초 안내글이 여기 보입니다.
+                </p>
+              </div>
+              <button
+                type="button"
+                aria-label="닫기"
+                onClick={() => setFirstPosts(null)}
+                className="shrink-0 rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50"
+              >
+                닫기
+              </button>
+            </div>
+
+            <div className="space-y-4 px-5 py-4">
+              {firstPostsBusy && <p className="text-sm text-gray-500">불러오는 중…</p>}
+              {firstPostsError && <p className="text-sm text-red-600">{firstPostsError}</p>}
+
+              {!firstPostsBusy &&
+                !firstPostsError &&
+                ([
+                  ['문정원', firstPosts.mj],
+                  ['법률검토', firstPosts.law],
+                ] as const).map(([label, post]) => (
+                  <div key={label}>
+                    <h3 className="text-sm font-semibold">{label} 채널</h3>
+                    {post ? (
+                      <div className="mt-1.5 rounded-md border border-gray-200 bg-amber-50/40 p-3">
+                        <p className="text-xs text-gray-500">
+                          {post.user} · {formatStageDate(post.createAt)}
+                        </p>
+                        <p className="mt-1.5 whitespace-pre-wrap text-sm text-gray-800">{post.message}</p>
+                        {post.files.length > 0 && (
+                          <p className="mt-2 text-xs text-gray-500">
+                            첨부 {post.files.length}개: {post.files.map((f) => f.name).join(', ')}
+                          </p>
+                        )}
+                      </div>
+                    ) : (
+                      <p className="mt-1.5 text-sm text-gray-400">고정된 게시물이 없습니다.</p>
+                    )}
+                  </div>
+                ))}
+            </div>
+
+            <div className="flex justify-end border-t border-gray-200 px-5 py-3">
+              <button
+                type="button"
+                onClick={() => {
+                  setFirstPosts(null)
+                  setActiveTab('채팅')
+                }}
+                className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
+              >
+                채팅 탭에서 보기
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
     </div>
   )
 }
frontend/src/components/ProcessBoard.test.tsx
--- frontend/src/components/ProcessBoard.test.tsx
+++ frontend/src/components/ProcessBoard.test.tsx
@@ -9,6 +9,8 @@
   importProcess: vi.fn(),
   updateProcessing: vi.fn(),
   deleteProcessItem: vi.fn(),
+  bulkUpdateProcess: vi.fn(),
+  bulkDeleteProcess: vi.fn(),
 }))
 
 vi.mock('../api/client', async (importOriginal) => ({
@@ -18,6 +20,8 @@
   importProcess: mocks.importProcess,
   updateProcessing: mocks.updateProcessing,
   deleteProcessItem: mocks.deleteProcessItem,
+  bulkUpdateProcess: mocks.bulkUpdateProcess,
+  bulkDeleteProcess: mocks.bulkDeleteProcess,
 }))
 
 function org(overrides: Partial<Org> = {}): Org {
@@ -92,6 +96,8 @@
   mocks.importProcess.mockReset()
   mocks.updateProcessing.mockReset()
   mocks.deleteProcessItem.mockReset()
+  mocks.bulkUpdateProcess.mockReset()
+  mocks.bulkDeleteProcess.mockReset()
 })
 
 describe('ProcessBoard 목록', () => {
@@ -197,34 +203,84 @@
     })
   })
 
-  it('처리완료 건은 수정/삭제 버튼이, 미처리 건은 처리등록 버튼만 보인다', async () => {
+  it('처리완료 건은 수정 버튼이, 미처리 건은 처리등록 버튼만 보인다', async () => {
     mocks.getProcessPage.mockResolvedValue(page({ items: [item({ processStatus: '처리완료' })] }))
 
     render(<ProcessBoard org={org()} />)
 
     await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
     expect(screen.getByRole('button', { name: '수정' })).toBeTruthy()
-    expect(screen.getByRole('button', { name: '삭제' })).toBeTruthy()
     expect(screen.queryByRole('button', { name: '처리등록' })).toBeNull()
+    // 개별 행 삭제는 없앴다(상단 [선택 삭제] 또는 상세 화면에서 지운다)
+    expect(screen.queryByRole('button', { name: '삭제' })).toBeNull()
   })
 
-  it('삭제를 누르면 확인 후 deleteProcessItem을 호출한다', async () => {
+  it('행을 체크하고 [선택 삭제]를 누르면 확인 후 일괄 삭제한다', async () => {
     mocks.getProcessPage.mockResolvedValue(page({ items: [item({ processStatus: '처리완료' })] }))
-    mocks.deleteProcessItem.mockResolvedValue(undefined)
+    mocks.bulkDeleteProcess.mockResolvedValue({ deleted: 1 })
     const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
 
     render(<ProcessBoard org={org()} />)
 
     await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
 
-    fireEvent.click(screen.getByRole('button', { name: '삭제' }))
+    fireEvent.click(screen.getByLabelText('1번 선택'))
+    fireEvent.click(screen.getByRole('button', { name: '선택 삭제' }))
 
     expect(confirmSpy).toHaveBeenCalled()
-    await waitFor(() => expect(mocks.deleteProcessItem).toHaveBeenCalledWith(1, 1))
+    await waitFor(() => expect(mocks.bulkDeleteProcess).toHaveBeenCalledWith(1, [1]))
 
     confirmSpy.mockRestore()
   })
 
+  it('체크한 건의 처리상태와 최종의견을 일괄 반영한다', async () => {
+    mocks.getProcessPage.mockResolvedValue(page())
+    mocks.bulkUpdateProcess.mockResolvedValue({ updated: 1 })
+
+    render(<ProcessBoard org={org()} />)
+    await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
+
+    fireEvent.click(screen.getByLabelText('1번 선택'))
+    fireEvent.change(screen.getByLabelText('일괄 처리상태'), { target: { value: '처리완료' } })
+    fireEvent.change(screen.getByLabelText('일괄 최종의견'), { target: { value: '일괄 처리' } })
+    fireEvent.click(screen.getByRole('button', { name: '일괄 등록' }))
+
+    await waitFor(() =>
+      expect(mocks.bulkUpdateProcess).toHaveBeenCalledWith(1, {
+        ids: [1],
+        processStatus: '처리완료',
+        finalOpinion: '일괄 처리',
+        judgedKoglType: undefined,
+      }),
+    )
+    expect(await screen.findByText('1건에 반영했습니다.')).toBeTruthy()
+  })
+
+  it('상세검색 3칸은 열별 조건으로 함께 넘어간다', async () => {
+    mocks.getProcessPage.mockResolvedValue(page())
+
+    render(<ProcessBoard org={org()} />)
+    await waitFor(() => expect(mocks.getProcessPage).toHaveBeenCalled())
+
+    fireEvent.click(screen.getByRole('button', { name: '상세검색' }))
+    fireEvent.change(screen.getByLabelText('사이트명'), { target: { value: '누리집' } })
+    fireEvent.change(screen.getByLabelText('게시판명'), { target: { value: '공고' } })
+    fireEvent.change(screen.getByLabelText('게시물 제목'), { target: { value: '사업' } })
+    fireEvent.click(screen.getByRole('button', { name: '검색' }))
+
+    await waitFor(() =>
+      expect(mocks.getProcessPage).toHaveBeenLastCalledWith(1, {
+        keyword: undefined,
+        site: '누리집',
+        board: '공고',
+        title: '사업',
+        status: undefined,
+        page: 0,
+        size: 30,
+      }),
+    )
+  })
+
   it('다음 페이지 버튼을 누르면 page+1로 다시 조회한다', async () => {
     mocks.getProcessPage.mockResolvedValue(page({ total: 31 }))
 
frontend/src/components/ProcessBoard.tsx
--- frontend/src/components/ProcessBoard.tsx
+++ frontend/src/components/ProcessBoard.tsx
@@ -1,5 +1,7 @@
 import { useEffect, useRef, useState, type ReactNode } from 'react'
 import {
+  bulkDeleteProcess,
+  bulkUpdateProcess,
   deleteProcessItem,
   getProcessItem,
   getProcessPage,
@@ -175,6 +177,19 @@
   const [statusFilter, setStatusFilter] = useState<ProcessStatusFilter | undefined>(undefined)
   const [keywordInput, setKeywordInput] = useState('')
   const [appliedKeyword, setAppliedKeyword] = useState('')
+  /** 복합검색(열별 조건). 서로 AND로 겹친다. */
+  const [advancedOpen, setAdvancedOpen] = useState(false)
+  const [siteInput, setSiteInput] = useState('')
+  const [boardInput, setBoardInput] = useState('')
+  const [titleInput, setTitleInput] = useState('')
+  const [applied, setApplied] = useState({ site: '', board: '', title: '' })
+  /** 일괄 등록/삭제 대상. */
+  const [selected, setSelected] = useState<number[]>([])
+  const [bulkStatus, setBulkStatus] = useState('')
+  const [bulkKoglType, setBulkKoglType] = useState('')
+  const [bulkOpinion, setBulkOpinion] = useState('')
+  const [bulkBusy, setBulkBusy] = useState(false)
+  const [bulkMessage, setBulkMessage] = useState<string | null>(null)
   const [loading, setLoading] = useState(false)
   const [error, setError] = useState<string | null>(null)
   const [reloadKey, setReloadKey] = useState(0)
@@ -194,6 +209,9 @@
 
     getProcessPage(org.id, {
       keyword: appliedKeyword || undefined,
+      site: applied.site || undefined,
+      board: applied.board || undefined,
+      title: applied.title || undefined,
       status: statusFilter,
       page,
       size: PAGE_SIZE,
@@ -221,17 +239,26 @@
       cancelled = true
     }
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [mode, org.id, appliedKeyword, statusFilter, page, reloadKey])
+  }, [mode, org.id, appliedKeyword, applied, statusFilter, page, reloadKey])
+
+  function handleSearchFields() {
+    setApplied({ site: siteInput.trim(), board: boardInput.trim(), title: titleInput.trim() })
+  }
 
   function handleSearch() {
+    handleSearchFields()
     setPage(0)
     setAppliedKeyword(keywordInput.trim())
   }
 
   function handleResetSearch() {
     setKeywordInput('')
+    setSiteInput('')
+    setBoardInput('')
+    setTitleInput('')
     setPage(0)
     setAppliedKeyword('')
+    setApplied({ site: '', board: '', title: '' })
   }
 
   function pickStatusChip(key: ProcessStatusFilter | undefined) {
@@ -253,15 +280,65 @@
     }
   }
 
-  async function handleDelete(item: ProcessItem) {
-    if (!window.confirm(`"${item.postTitle ?? item.seq}" 게시물을 삭제할까요?`)) {
+  const allChecked = items.length > 0 && items.every((item) => selected.includes(item.id))
+
+  function toggleAll() {
+    setSelected(allChecked ? [] : items.map((item) => item.id))
+  }
+
+  function toggleOne(id: number) {
+    setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
+  }
+
+  /** 체크한 건들에 채운 항목만 한 번에 반영한다. */
+  async function handleBulkApply() {
+    if (selected.length === 0 || bulkBusy) {
       return
     }
+    if (!bulkStatus && !bulkKoglType && !bulkOpinion.trim()) {
+      setBulkMessage('반영할 값을 하나 이상 채워주세요.')
+      return
+    }
+    setBulkBusy(true)
+    setBulkMessage(null)
     try {
-      await deleteProcessItem(org.id, item.id)
+      const res = await bulkUpdateProcess(org.id, {
+        ids: selected,
+        processStatus: bulkStatus || undefined,
+        judgedKoglType: bulkKoglType || undefined,
+        finalOpinion: bulkOpinion.trim() || undefined,
+      })
+      setBulkMessage(`${res.updated}건에 반영했습니다.`)
+      setSelected([])
+      setBulkStatus('')
+      setBulkKoglType('')
+      setBulkOpinion('')
       setReloadKey((k) => k + 1)
     } catch (e) {
-      setError(e instanceof Error ? e.message : '삭제에 실패했습니다.')
+      setBulkMessage(e instanceof Error ? e.message : '일괄 등록에 실패했습니다.')
+    } finally {
+      setBulkBusy(false)
+    }
+  }
+
+  async function handleBulkDelete() {
+    if (selected.length === 0 || bulkBusy) {
+      return
+    }
+    if (!window.confirm(`선택한 ${selected.length}건을 삭제할까요?`)) {
+      return
+    }
+    setBulkBusy(true)
+    setBulkMessage(null)
+    try {
+      const res = await bulkDeleteProcess(org.id, selected)
+      setBulkMessage(`${res.deleted}건을 삭제했습니다.`)
+      setSelected([])
+      setReloadKey((k) => k + 1)
+    } catch (e) {
+      setBulkMessage(e instanceof Error ? e.message : '삭제에 실패했습니다.')
+    } finally {
+      setBulkBusy(false)
     }
   }
 
@@ -316,6 +393,14 @@
             placeholder="사이트명·게시물제목 검색"
             className="w-64 rounded-md border border-gray-300 px-2 py-1.5 text-sm"
           />
+          <button
+            type="button"
+            aria-expanded={advancedOpen}
+            onClick={() => setAdvancedOpen((v) => !v)}
+            className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
+          >
+            상세검색
+          </button>
           <button
             type="button"
             onClick={handleSearch}
@@ -376,13 +461,101 @@
 
       {error && <p className="mt-2 text-sm text-red-600">{error}</p>}
 
+      {advancedOpen && (
+        <div className="mt-3 flex flex-wrap items-end gap-2 rounded-md border border-gray-200 bg-gray-50 p-3">
+          {(
+            [
+              ['사이트명', siteInput, setSiteInput],
+              ['게시판명', boardInput, setBoardInput],
+              ['게시물 제목', titleInput, setTitleInput],
+            ] as const
+          ).map(([label, value, setter]) => (
+            <label key={label} className="flex flex-col gap-1 text-xs text-gray-500">
+              {label}
+              <input
+                type="text"
+                aria-label={label}
+                value={value}
+                onChange={(e) => setter(e.target.value)}
+                onKeyDown={(e) => {
+                  if (e.key === 'Enter') {
+                    handleSearch()
+                  }
+                }}
+                className="w-48 rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+              />
+            </label>
+          ))}
+          <span className="pb-2 text-xs text-gray-400">세 조건은 모두 만족하는 건만 찾습니다(AND)</span>
+        </div>
+      )}
+
+      {/* 일괄 등록: 체크한 건들에 같은 값을 한 번에 넣는다. 삭제도 여기서 한다. */}
+      <div className="mt-3 flex flex-wrap items-center gap-2 rounded-md border border-gray-200 bg-gray-50 p-3">
+        <span className="text-sm font-medium text-gray-700">선택 {selected.length}건</span>
+        <select
+          aria-label="일괄 처리상태"
+          value={bulkStatus}
+          onChange={(e) => setBulkStatus(e.target.value)}
+          className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+        >
+          <option value="">처리상태 그대로</option>
+          <option value="처리완료">처리완료</option>
+          <option value="미처리">미처리</option>
+        </select>
+        <select
+          aria-label="일괄 공공누리 유형"
+          value={bulkKoglType}
+          onChange={(e) => setBulkKoglType(e.target.value)}
+          className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+        >
+          <option value="">공공누리 유형 그대로</option>
+          {JUDGED_KOGL_TYPE_OPTIONS.map((option) => (
+            <option key={option} value={option}>{option}</option>
+          ))}
+        </select>
+        <input
+          type="text"
+          aria-label="일괄 최종의견"
+          value={bulkOpinion}
+          onChange={(e) => setBulkOpinion(e.target.value)}
+          placeholder="최종의견(비우면 유지)"
+          className="w-56 rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+        />
+        <button
+          type="button"
+          disabled={selected.length === 0 || bulkBusy}
+          onClick={() => void handleBulkApply()}
+          className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white disabled:bg-gray-300"
+        >
+          일괄 등록
+        </button>
+        <button
+          type="button"
+          disabled={selected.length === 0 || bulkBusy}
+          onClick={() => void handleBulkDelete()}
+          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"
+        >
+          선택 삭제
+        </button>
+        {bulkMessage && <span className="text-sm text-gray-600">{bulkMessage}</span>}
+      </div>
+
       <div className="mt-3 overflow-x-auto rounded-lg border border-gray-200">
         <table className="w-full min-w-[1400px] text-left text-sm">
           <thead>
             <tr className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
+              <th className="w-8 px-3 py-2 font-medium">
+                <input
+                  type="checkbox"
+                  aria-label="전체 선택"
+                  checked={allChecked}
+                  onChange={toggleAll}
+                />
+              </th>
               <th className="whitespace-nowrap px-3 py-2 font-medium">No</th>
               <th className="whitespace-nowrap px-3 py-2 font-medium">사이트/게시판</th>
-              <th className="px-3 py-2 font-medium">제목</th>
+              <th className="px-3 py-2 font-medium">게시물 경로/제목</th>
               <th className="whitespace-nowrap px-3 py-2 font-medium">제작/공표일</th>
               <th className="whitespace-nowrap px-3 py-2 font-medium">기존유형/계약서</th>
               <th className="whitespace-nowrap px-3 py-2 font-medium">권리확인구분</th>
@@ -398,19 +571,27 @@
           <tbody>
             {loading ? (
               <tr>
-                <td colSpan={13} className="py-8 text-center text-sm text-gray-400">
+                <td colSpan={14} className="py-8 text-center text-sm text-gray-400">
                   불러오는 중…
                 </td>
               </tr>
             ) : items.length === 0 ? (
               <tr>
-                <td colSpan={13} className="py-10 text-center text-sm text-gray-300">
+                <td colSpan={14} className="py-10 text-center text-sm text-gray-300">
                   업로드된 권리처리 자료가 없습니다. 엑셀 업로드로 시작하세요.
                 </td>
               </tr>
             ) : (
               items.map((item) => (
                 <tr key={item.id} className="border-b border-gray-100 align-top last:border-b-0">
+                  <td className="px-3 py-2">
+                    <input
+                      type="checkbox"
+                      aria-label={`${item.seq}번 선택`}
+                      checked={selected.includes(item.id)}
+                      onChange={() => toggleOne(item.id)}
+                    />
+                  </td>
                   <td className="px-3 py-2 text-gray-500">{item.seq}</td>
                   <td className="px-3 py-2">
                     <div className="text-gray-900">{item.siteName ?? '-'}</div>
@@ -475,13 +656,6 @@
                             className="rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50"
                           >
                             수정
-                          </button>
-                          <button
-                            type="button"
-                            onClick={() => void handleDelete(item)}
-                            className="rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50"
-                          >
-                            삭제
                           </button>
                         </>
                       ) : (
 
frontend/src/components/ProjectsPage.test.tsx (added)
+++ frontend/src/components/ProjectsPage.test.tsx
@@ -0,0 +1,222 @@
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import ProjectsPage from './ProjectsPage'
+import type { ContactLog, DashboardOrgRow, OrgReport, ProjectRow } from '../api/client'
+
+const mocks = vi.hoisted(() => ({
+  getProjects: vi.fn(),
+  getContactLogs: vi.fn(),
+  addContactLog: vi.fn(),
+  updateContactLog: vi.fn(),
+  deleteContactLog: vi.fn(),
+  getReports: vi.fn(),
+  uploadReport: vi.fn(),
+  deleteReport: vi.fn(),
+}))
+
+vi.mock('../api/client', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('../api/client')>()),
+  ...mocks,
+}))
+
+function orgRow(overrides: Partial<DashboardOrgRow> = {}): DashboardOrgRow {
+  return {
+    id: 1,
+    orgNo: '001_00',
+    orgName: '국제방송교류재단',
+    stage: 5,
+    hasChannel: true,
+    lawyerName: '권홍철',
+    mjName: '이연경',
+    lawyerAssignedDate: null,
+    reviewTotal: 100,
+    reviewDone: 40,
+    processTotal: 10,
+    processDone: 2,
+    stageChangedAt: null,
+    lastChangedAt: null,
+    reCount: 0,
+    ...overrides,
+  }
+}
+
+function row(overrides: Partial<ProjectRow> = {}): ProjectRow {
+  return {
+    org: orgRow(),
+    lastContactedOn: null,
+    contactCount: 0,
+    reportCount: 0,
+    ...overrides,
+  }
+}
+
+function log(overrides: Partial<ContactLog> = {}): ContactLog {
+  return {
+    id: 1,
+    orgId: 1,
+    contactedOn: '2026-07-20',
+    method: '전화',
+    summary: '신청 확인 통화',
+    author: 'admin',
+    createdAt: Date.now(),
+    ...overrides,
+  }
+}
+
+function report(overrides: Partial<OrgReport> = {}): OrgReport {
+  return {
+    id: 1,
+    orgId: 1,
+    fileName: '최종보고서.pdf',
+    contentType: 'application/pdf',
+    byteSize: 2048,
+    uploadedBy: 'admin',
+    uploadedAt: Date.now(),
+    ...overrides,
+  }
+}
+
+async function openPanel() {
+  render(<ProjectsPage onOpenOrg={vi.fn()} />)
+  await screen.findByTestId('project-table')
+  fireEvent.click(screen.getByRole('button', { name: '연락·보고서' }))
+  await waitFor(() => expect(mocks.getContactLogs).toHaveBeenCalledWith(1))
+}
+
+describe('ProjectsPage', () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+    mocks.getProjects.mockResolvedValue([row()])
+    mocks.getContactLogs.mockResolvedValue([])
+    mocks.getReports.mockResolvedValue([])
+  })
+
+  it('기관 현황을 표로 보여준다', async () => {
+    mocks.getProjects.mockResolvedValue([
+      row({ lastContactedOn: '2026-07-20', contactCount: 3, reportCount: 1 }),
+    ])
+    render(<ProjectsPage onOpenOrg={vi.fn()} />)
+
+    const table = within(await screen.findByTestId('project-table'))
+    expect(table.getByText('국제방송교류재단')).toBeTruthy()
+    expect(table.getByText('5. 법률검토(권리확인)')).toBeTruthy()
+    expect(table.getByText('2026-07-20')).toBeTruthy()
+    expect(table.getByText('(3)')).toBeTruthy()
+  })
+
+  it('기관명을 클릭하면 기관관리로 넘긴다', async () => {
+    const onOpenOrg = vi.fn()
+    render(<ProjectsPage onOpenOrg={onOpenOrg} />)
+
+    fireEvent.click(await screen.findByRole('button', { name: '국제방송교류재단' }))
+    expect(onOpenOrg).toHaveBeenCalledWith(1)
+  })
+
+  it('검색과 연락 이력 필터로 목록을 거른다', async () => {
+    mocks.getProjects.mockResolvedValue([
+      row(),
+      row({ org: orgRow({ id: 2, orgNo: '002_00', orgName: '세종학당재단' }), contactCount: 2 }),
+    ])
+    render(<ProjectsPage onOpenOrg={vi.fn()} />)
+    await screen.findByTestId('project-table')
+
+    fireEvent.change(screen.getByLabelText('기관명/기관코드 검색'), { target: { value: '세종' } })
+    expect(within(screen.getByTestId('project-table')).queryByText('국제방송교류재단')).toBeNull()
+
+    fireEvent.change(screen.getByLabelText('기관명/기관코드 검색'), { target: { value: '' } })
+    fireEvent.change(screen.getByLabelText('연락 이력'), { target: { value: 'none' } })
+    expect(within(screen.getByTestId('project-table')).queryByText('세종학당재단')).toBeNull()
+    expect(within(screen.getByTestId('project-table')).getByText('국제방송교류재단')).toBeTruthy()
+  })
+
+  it('연락·보고서 창을 열면 이력과 보고서를 함께 읽는다', async () => {
+    mocks.getContactLogs.mockResolvedValue([log()])
+    mocks.getReports.mockResolvedValue([report()])
+
+    await openPanel()
+
+    expect(screen.getByText('신청 확인 통화')).toBeTruthy()
+    expect(screen.getByText('최종보고서.pdf')).toBeTruthy()
+    expect(screen.getByText(/2026-07-20 · 전화 · admin/)).toBeTruthy()
+  })
+
+  it('연락 이력을 등록하면 서버가 준 목록으로 갱신한다', async () => {
+    mocks.addContactLog.mockResolvedValue([log({ summary: '자료 요청' })])
+    await openPanel()
+
+    fireEvent.change(screen.getByLabelText('연락일'), { target: { value: '2026-07-25' } })
+    fireEvent.change(screen.getByLabelText('연락 방법'), { target: { value: '메일' } })
+    fireEvent.change(screen.getByLabelText('연락 내용'), { target: { value: '자료 요청' } })
+    fireEvent.click(screen.getByRole('button', { name: '등록' }))
+
+    await waitFor(() =>
+      expect(mocks.addContactLog).toHaveBeenCalledWith(1, {
+        contactedOn: '2026-07-25',
+        method: '메일',
+        summary: '자료 요청',
+      }),
+    )
+    expect(await screen.findByText('자료 요청')).toBeTruthy()
+    // 목록 숫자(최근 연락)도 다시 읽는다
+    expect(mocks.getProjects).toHaveBeenCalledTimes(2)
+  })
+
+  it('내용이 비면 등록 버튼이 잠긴다', async () => {
+    await openPanel()
+    expect(screen.getByRole('button', { name: '등록' })).toBeDisabled()
+  })
+
+  it('수정을 누르면 입력칸에 값이 올라오고 저장하면 updateContactLog를 부른다', async () => {
+    mocks.getContactLogs.mockResolvedValue([log({ id: 9, summary: '원래 내용' })])
+    mocks.updateContactLog.mockResolvedValue([log({ id: 9, summary: '고친 내용' })])
+    await openPanel()
+
+    fireEvent.click(screen.getByRole('button', { name: '수정' }))
+    expect((screen.getByLabelText('연락 내용') as HTMLInputElement).value).toBe('원래 내용')
+
+    fireEvent.change(screen.getByLabelText('연락 내용'), { target: { value: '고친 내용' } })
+    fireEvent.click(screen.getByRole('button', { name: '수정 저장' }))
+
+    await waitFor(() =>
+      expect(mocks.updateContactLog).toHaveBeenCalledWith(1, 9, {
+        contactedOn: '2026-07-20',
+        method: '전화',
+        summary: '고친 내용',
+      }),
+    )
+  })
+
+  it('연락 이력 삭제는 확인을 물어본다', async () => {
+    mocks.getContactLogs.mockResolvedValue([log({ id: 9 })])
+    mocks.deleteContactLog.mockResolvedValue(undefined)
+    const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
+    await openPanel()
+
+    fireEvent.click(screen.getByRole('button', { name: '삭제' }))
+
+    expect(confirmSpy).toHaveBeenCalled()
+    await waitFor(() => expect(mocks.deleteContactLog).toHaveBeenCalledWith(1, 9))
+    confirmSpy.mockRestore()
+  })
+
+  it('보고서를 고르면 업로드하고 목록을 갱신한다', async () => {
+    mocks.uploadReport.mockResolvedValue([report({ fileName: '2차보고서.pdf' })])
+    await openPanel()
+
+    const file = new File(['내용'], '2차보고서.pdf', { type: 'application/pdf' })
+    fireEvent.change(screen.getByLabelText('보고서 파일'), { target: { files: [file] } })
+
+    await waitFor(() => expect(mocks.uploadReport).toHaveBeenCalledWith(1, file))
+    expect(await screen.findByText('2차보고서.pdf')).toBeTruthy()
+  })
+
+  it('보고서는 다운로드 링크로 걸린다', async () => {
+    mocks.getReports.mockResolvedValue([report({ id: 7 })])
+    await openPanel()
+
+    expect(screen.getByRole('link', { name: '최종보고서.pdf' })).toHaveAttribute(
+      'href',
+      '/api/orgs/1/reports/7',
+    )
+  })
+})
 
frontend/src/components/ProjectsPage.tsx (added)
+++ frontend/src/components/ProjectsPage.tsx
@@ -0,0 +1,525 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import {
+  addContactLog,
+  deleteContactLog,
+  deleteReport,
+  getContactLogs,
+  getProjects,
+  getReports,
+  reportDownloadUrl,
+  updateContactLog,
+  uploadReport,
+  type ContactLog,
+  type OrgReport,
+  type ProjectRow,
+} from '../api/client'
+import { stageLabel } from '../stages'
+import HelpButton from '../help/HelpButton'
+
+/**
+ * 사업관리 화면. 3단계 법률검토로 진행할 기관 현황을 한 표로 모아 보고, 기관을 고르면
+ * "신청 확인 연락" 이력과 최종 보고서를 다룬다(요청자 요구: 별도 메뉴 + 연락 이력관리 +
+ * 기관별 최종 보고서 업로드).
+ *
+ * 기관 현황 숫자는 대시보드와 같은 집계를 그대로 받아 쓴다 - 두 화면 숫자가 어긋나지 않게.
+ */
+
+interface Props {
+  /** 기관명을 클릭했을 때 기관관리 화면으로 넘긴다. */
+  onOpenOrg: (orgId: number) => void
+}
+
+const METHODS = ['전화', '메일', '방문', '기타']
+
+function formatDateTime(ms: number): string {
+  const d = new Date(ms)
+  const pad = (n: number) => String(n).padStart(2, '0')
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
+}
+
+function formatSize(bytes: number): string {
+  if (bytes < 1024) {
+    return `${bytes} B`
+  }
+  if (bytes < 1024 * 1024) {
+    return `${Math.round(bytes / 1024)} KB`
+  }
+  return `${(bytes / 1024 / 1024).toFixed(1)} MB`
+}
+
+/** 오늘 날짜를 yyyy-MM-dd로. 연락 이력 입력의 기본값이다. */
+function today(): string {
+  const d = new Date()
+  const pad = (n: number) => String(n).padStart(2, '0')
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
+}
+
+export default function ProjectsPage({ onOpenOrg }: Props) {
+  const [rows, setRows] = useState<ProjectRow[] | null>(null)
+  const [error, setError] = useState<string | null>(null)
+  const [keyword, setKeyword] = useState('')
+  const [stageFilter, setStageFilter] = useState('')
+  const [contactFilter, setContactFilter] = useState('')
+
+  /** 오른쪽 상세 패널에서 다루는 기관. */
+  const [selected, setSelected] = useState<ProjectRow | null>(null)
+  const [logs, setLogs] = useState<ContactLog[]>([])
+  const [reports, setReports] = useState<OrgReport[]>([])
+  const [panelBusy, setPanelBusy] = useState(false)
+  const [panelError, setPanelError] = useState<string | null>(null)
+
+  const [logDate, setLogDate] = useState(today())
+  const [logMethod, setLogMethod] = useState(METHODS[0])
+  const [logSummary, setLogSummary] = useState('')
+  const [editingLogId, setEditingLogId] = useState<number | null>(null)
+
+  const reload = useCallback(async () => {
+    try {
+      setRows(await getProjects())
+      setError(null)
+    } catch (e) {
+      setError((e as Error).message)
+    }
+  }, [])
+
+  useEffect(() => {
+    void reload()
+  }, [reload])
+
+  const visible = useMemo(() => {
+    const kw = keyword.trim()
+    return (rows ?? []).filter((row) => {
+      if (kw && !row.org.orgName.includes(kw) && !row.org.orgNo.includes(kw)) {
+        return false
+      }
+      if (stageFilter && String(row.org.stage ?? '') !== stageFilter) {
+        return false
+      }
+      if (contactFilter === 'none' && row.contactCount > 0) {
+        return false
+      }
+      if (contactFilter === 'some' && row.contactCount === 0) {
+        return false
+      }
+      return true
+    })
+  }, [rows, keyword, stageFilter, contactFilter])
+
+  async function openPanel(row: ProjectRow) {
+    setSelected(row)
+    setPanelError(null)
+    setEditingLogId(null)
+    setLogSummary('')
+    setLogDate(today())
+    setPanelBusy(true)
+    try {
+      const [logList, reportList] = await Promise.all([
+        getContactLogs(row.org.id),
+        getReports(row.org.id),
+      ])
+      setLogs(logList)
+      setReports(reportList)
+    } catch (e) {
+      setPanelError((e as Error).message)
+    } finally {
+      setPanelBusy(false)
+    }
+  }
+
+  async function submitLog() {
+    if (!selected || logSummary.trim() === '' || panelBusy) {
+      return
+    }
+    setPanelBusy(true)
+    setPanelError(null)
+    try {
+      const body = { contactedOn: logDate, method: logMethod, summary: logSummary.trim() }
+      const next = editingLogId
+        ? await updateContactLog(selected.org.id, editingLogId, body)
+        : await addContactLog(selected.org.id, body)
+      setLogs(next)
+      setLogSummary('')
+      setEditingLogId(null)
+      await reload()
+    } catch (e) {
+      setPanelError((e as Error).message)
+    } finally {
+      setPanelBusy(false)
+    }
+  }
+
+  async function removeLog(log: ContactLog) {
+    if (!selected || !window.confirm('이 연락 이력을 삭제할까요?')) {
+      return
+    }
+    setPanelBusy(true)
+    try {
+      await deleteContactLog(selected.org.id, log.id)
+      setLogs((prev) => prev.filter((l) => l.id !== log.id))
+      await reload()
+    } catch (e) {
+      setPanelError((e as Error).message)
+    } finally {
+      setPanelBusy(false)
+    }
+  }
+
+  async function handleReportUpload(file: File) {
+    if (!selected) {
+      return
+    }
+    setPanelBusy(true)
+    setPanelError(null)
+    try {
+      setReports(await uploadReport(selected.org.id, file))
+      await reload()
+    } catch (e) {
+      setPanelError((e as Error).message)
+    } finally {
+      setPanelBusy(false)
+    }
+  }
+
+  async function removeReport(report: OrgReport) {
+    if (!selected || !window.confirm(`"${report.fileName}"을 삭제할까요?`)) {
+      return
+    }
+    setPanelBusy(true)
+    try {
+      await deleteReport(selected.org.id, report.id)
+      setReports((prev) => prev.filter((r) => r.id !== report.id))
+      await reload()
+    } catch (e) {
+      setPanelError((e as Error).message)
+    } finally {
+      setPanelBusy(false)
+    }
+  }
+
+  if (error && !rows) {
+    return (
+      <div className="flex flex-col items-center gap-3 p-8 text-sm">
+        <p className="text-red-600">{error}</p>
+        <button
+          type="button"
+          onClick={() => void reload()}
+          className="rounded-md border border-gray-300 px-3 py-1.5"
+        >
+          다시 시도
+        </button>
+      </div>
+    )
+  }
+
+  return (
+    <div className="space-y-4 p-6">
+      <section className="rounded-lg border border-gray-200 bg-white p-5">
+        <h1 className="flex items-center gap-1.5 text-lg font-semibold">
+          사업관리
+          <HelpButton topic="projects.overview" />
+        </h1>
+        <p className="text-xs text-gray-500">
+          3단계 법률검토 대상 기관 현황과 신청 확인 연락 이력, 최종 보고서를 관리합니다.
+        </p>
+
+        <div className="mt-4 flex flex-wrap gap-2">
+          <input
+            aria-label="기관명/기관코드 검색"
+            placeholder="기관명/기관코드 검색"
+            value={keyword}
+            onChange={(e) => setKeyword(e.target.value)}
+            className="min-w-48 flex-1 rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+          />
+          <select
+            aria-label="현재 단계"
+            value={stageFilter}
+            onChange={(e) => setStageFilter(e.target.value)}
+            className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+          >
+            <option value="">현재 단계 전체</option>
+            {Array.from({ length: 12 }, (_, i) => i + 1).map((n) => (
+              <option key={n} value={String(n)}>{`${n}. ${stageLabel(n)}`}</option>
+            ))}
+          </select>
+          <select
+            aria-label="연락 이력"
+            value={contactFilter}
+            onChange={(e) => setContactFilter(e.target.value)}
+            className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+          >
+            <option value="">연락 이력 전체</option>
+            <option value="some">이력 있음</option>
+            <option value="none">이력 없음</option>
+          </select>
+        </div>
+
+        <div className="mt-3 overflow-x-auto">
+          <table data-testid="project-table" className="w-full min-w-[1000px] text-sm">
+            <thead>
+              <tr className="border-b border-gray-200 text-xs text-gray-500">
+                <th className="px-2 py-2 text-left font-normal">연번</th>
+                <th className="px-2 py-2 text-left font-normal">기관명</th>
+                <th className="px-2 py-2 text-left font-normal">현재 단계</th>
+                <th className="px-2 py-2 text-left font-normal">문정원 담당</th>
+                <th className="px-2 py-2 text-left font-normal">담당 변호사</th>
+                <th className="px-2 py-2 text-right font-normal">권리확인</th>
+                <th className="px-2 py-2 text-right font-normal">권리처리</th>
+                <th className="px-2 py-2 text-right font-normal">최근 연락</th>
+                <th className="px-2 py-2 text-right font-normal">보고서</th>
+                <th className="px-2 py-2 text-right font-normal">관리</th>
+              </tr>
+            </thead>
+            <tbody>
+              {rows === null ? (
+                <tr>
+                  <td colSpan={10} className="px-2 py-6 text-center text-sm text-gray-500">
+                    불러오는 중…
+                  </td>
+                </tr>
+              ) : visible.length === 0 ? (
+                <tr>
+                  <td colSpan={10} className="px-2 py-6 text-center text-sm text-gray-500">
+                    조건에 맞는 기관이 없습니다.
+                  </td>
+                </tr>
+              ) : (
+                visible.map((row) => (
+                  <tr key={row.org.id} className="border-b border-gray-100 hover:bg-gray-50">
+                    <td className="px-2 py-2 text-gray-500 tabular-nums">{row.org.orgNo}</td>
+                    <td className="px-2 py-2">
+                      <button
+                        type="button"
+                        onClick={() => onOpenOrg(row.org.id)}
+                        className="font-medium text-blue-700 hover:underline"
+                      >
+                        {row.org.orgName}
+                      </button>
+                    </td>
+                    <td className="px-2 py-2 text-gray-600">
+                      {row.org.stage === null ? '단계 미지정' : `${row.org.stage}. ${stageLabel(row.org.stage)}`}
+                    </td>
+                    <td className="px-2 py-2 text-gray-600">{row.org.mjName ?? '-'}</td>
+                    <td className="px-2 py-2 text-gray-600">{row.org.lawyerName ?? '-'}</td>
+                    <td className="px-2 py-2 text-right tabular-nums">
+                      {row.org.reviewDone.toLocaleString()} / {row.org.reviewTotal.toLocaleString()}
+                    </td>
+                    <td className="px-2 py-2 text-right tabular-nums">
+                      {row.org.processDone.toLocaleString()} / {row.org.processTotal.toLocaleString()}
+                    </td>
+                    <td className="px-2 py-2 text-right text-gray-600 tabular-nums">
+                      {row.lastContactedOn ?? '-'}
+                      {row.contactCount > 0 && (
+                        <span className="ml-1 text-xs text-gray-400">({row.contactCount})</span>
+                      )}
+                    </td>
+                    <td className="px-2 py-2 text-right tabular-nums">{row.reportCount}</td>
+                    <td className="px-2 py-2 text-right">
+                      <button
+                        type="button"
+                        onClick={() => void openPanel(row)}
+                        className="rounded-md border border-gray-300 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50"
+                      >
+                        연락·보고서
+                      </button>
+                    </td>
+                  </tr>
+                ))
+              )}
+            </tbody>
+          </table>
+        </div>
+      </section>
+
+      {selected && (
+        <div
+          role="dialog"
+          aria-label={`${selected.org.orgName} 연락·보고서`}
+          className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 p-6"
+        >
+          <div className="w-full max-w-3xl rounded-lg bg-white shadow-xl">
+            <div className="flex items-start justify-between gap-3 border-b border-gray-200 px-5 py-4">
+              <div>
+                <h2 className="text-base font-semibold">
+                  {selected.org.orgNo} {selected.org.orgName}
+                </h2>
+                <p className="mt-1 text-xs text-gray-500">신청 확인 연락 이력과 최종 보고서</p>
+              </div>
+              <button
+                type="button"
+                aria-label="닫기"
+                onClick={() => setSelected(null)}
+                className="shrink-0 rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50"
+              >
+                닫기
+              </button>
+            </div>
+
+            <div className="space-y-6 px-5 py-4">
+              {panelError && (
+                <p className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{panelError}</p>
+              )}
+
+              <section>
+                <h3 className="flex items-center gap-1.5 text-sm font-semibold">
+                  신청 확인 연락 이력
+                  <HelpButton topic="projects.contactLog" />
+                </h3>
+
+                <div className="mt-2 flex flex-wrap items-end gap-2">
+                  <label className="flex flex-col gap-1 text-xs text-gray-500">
+                    연락일
+                    <input
+                      type="date"
+                      aria-label="연락일"
+                      value={logDate}
+                      onChange={(e) => setLogDate(e.target.value)}
+                      className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+                    />
+                  </label>
+                  <label className="flex flex-col gap-1 text-xs text-gray-500">
+                    방법
+                    <select
+                      aria-label="연락 방법"
+                      value={logMethod}
+                      onChange={(e) => setLogMethod(e.target.value)}
+                      className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+                    >
+                      {METHODS.map((m) => (
+                        <option key={m} value={m}>{m}</option>
+                      ))}
+                    </select>
+                  </label>
+                  <label className="flex min-w-64 flex-1 flex-col gap-1 text-xs text-gray-500">
+                    내용
+                    <input
+                      type="text"
+                      aria-label="연락 내용"
+                      value={logSummary}
+                      onChange={(e) => setLogSummary(e.target.value)}
+                      className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+                    />
+                  </label>
+                  <button
+                    type="button"
+                    disabled={panelBusy || logSummary.trim() === ''}
+                    onClick={() => void submitLog()}
+                    className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white disabled:bg-gray-300"
+                  >
+                    {editingLogId ? '수정 저장' : '등록'}
+                  </button>
+                  {editingLogId && (
+                    <button
+                      type="button"
+                      onClick={() => {
+                        setEditingLogId(null)
+                        setLogSummary('')
+                      }}
+                      className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600"
+                    >
+                      취소
+                    </button>
+                  )}
+                </div>
+                <p className="mt-1 text-xs text-gray-400">작성자는 로그인한 사용자로 자동 기록됩니다.</p>
+
+                <ul className="mt-3 space-y-2">
+                  {logs.length === 0 ? (
+                    <li className="text-sm text-gray-400">등록된 연락 이력이 없습니다.</li>
+                  ) : (
+                    logs.map((log) => (
+                      <li key={log.id} className="rounded-md border border-gray-200 p-2.5">
+                        <div className="flex items-center justify-between gap-2">
+                          <span className="text-xs text-gray-500">
+                            {log.contactedOn} · {log.method} · {log.author}
+                          </span>
+                          <span className="flex gap-1">
+                            <button
+                              type="button"
+                              onClick={() => {
+                                setEditingLogId(log.id)
+                                setLogDate(log.contactedOn)
+                                setLogMethod(log.method)
+                                setLogSummary(log.summary)
+                              }}
+                              className="rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-50"
+                            >
+                              수정
+                            </button>
+                            <button
+                              type="button"
+                              onClick={() => void removeLog(log)}
+                              className="rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-50"
+                            >
+                              삭제
+                            </button>
+                          </span>
+                        </div>
+                        <p className="mt-1 whitespace-pre-wrap text-sm text-gray-800">{log.summary}</p>
+                      </li>
+                    ))
+                  )}
+                </ul>
+              </section>
+
+              <section>
+                <h3 className="flex items-center gap-1.5 text-sm font-semibold">
+                  최종 보고서
+                  <HelpButton topic="projects.report" />
+                </h3>
+
+                <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">
+                  보고서 업로드
+                  <input
+                    type="file"
+                    aria-label="보고서 파일"
+                    className="hidden"
+                    onChange={(e) => {
+                      const file = e.target.files?.[0]
+                      if (file) {
+                        void handleReportUpload(file)
+                      }
+                      e.target.value = ''
+                    }}
+                  />
+                </label>
+
+                <ul className="mt-3 space-y-2">
+                  {reports.length === 0 ? (
+                    <li className="text-sm text-gray-400">업로드된 보고서가 없습니다.</li>
+                  ) : (
+                    reports.map((report) => (
+                      <li
+                        key={report.id}
+                        className="flex items-center justify-between gap-2 rounded-md border border-gray-200 p-2.5"
+                      >
+                        <div className="min-w-0">
+                          <a
+                            href={reportDownloadUrl(report.orgId, report.id)}
+                            className="block truncate text-sm text-blue-700 hover:underline"
+                          >
+                            {report.fileName}
+                          </a>
+                          <span className="text-xs text-gray-400">
+                            {formatSize(report.byteSize)} · {report.uploadedBy} ·{' '}
+                            {formatDateTime(report.uploadedAt)}
+                          </span>
+                        </div>
+                        <button
+                          type="button"
+                          onClick={() => void removeReport(report)}
+                          className="shrink-0 rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-50"
+                        >
+                          삭제
+                        </button>
+                      </li>
+                    ))
+                  )}
+                </ul>
+              </section>
+            </div>
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}
frontend/src/components/ReviewBoard.test.tsx
--- frontend/src/components/ReviewBoard.test.tsx
+++ frontend/src/components/ReviewBoard.test.tsx
@@ -1,4 +1,4 @@
-import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
 import { beforeEach, describe, expect, it, vi } from 'vitest'
 import ReviewBoard from './ReviewBoard'
 import type { Org, ReviewItem, ReviewPage } from '../api/client'
@@ -9,6 +9,8 @@
   importReview: vi.fn(),
   updateReviewJudgment: vi.fn(),
   deleteReviewItem: vi.fn(),
+  bulkUpdateReview: vi.fn(),
+  bulkDeleteReview: vi.fn(),
 }))
 
 vi.mock('../api/client', async (importOriginal) => ({
@@ -18,6 +20,8 @@
   importReview: mocks.importReview,
   updateReviewJudgment: mocks.updateReviewJudgment,
   deleteReviewItem: mocks.deleteReviewItem,
+  bulkUpdateReview: mocks.bulkUpdateReview,
+  bulkDeleteReview: mocks.bulkDeleteReview,
 }))
 
 function org(overrides: Partial<Org> = {}): Org {
@@ -90,6 +94,8 @@
   mocks.importReview.mockReset()
   mocks.updateReviewJudgment.mockReset()
   mocks.deleteReviewItem.mockReset()
+  mocks.bulkUpdateReview.mockReset()
+  mocks.bulkDeleteReview.mockReset()
 })
 
 describe('ReviewBoard 목록', () => {
@@ -100,8 +106,10 @@
 
     await waitFor(() => {
       expect(screen.getByText('2026년 사업 공고')).toBeTruthy()
-      expect(screen.getByText('신유형 개방')).toBeTruthy()
     })
+    // '신유형 개방'은 일괄 등록 셀렉트의 옵션에도 있으므로 표 안에서만 찾는다
+    const table = within(screen.getByRole('table'))
+    expect(table.getByText('신유형 개방')).toBeTruthy()
     expect(screen.getByText('총 1건 · 처리 0건')).toBeTruthy()
   })
 
@@ -154,21 +162,99 @@
     })
   })
 
-  it('삭제를 누르면 확인 후 deleteReviewItem을 호출한다', async () => {
+  it('행을 체크하고 [선택 삭제]를 누르면 확인 후 일괄 삭제한다', async () => {
     mocks.getReviewPage.mockResolvedValue(page())
-    mocks.deleteReviewItem.mockResolvedValue(undefined)
+    mocks.bulkDeleteReview.mockResolvedValue({ deleted: 1 })
     const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
 
     render(<ReviewBoard org={org()} />)
 
     await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
 
-    fireEvent.click(screen.getByRole('button', { name: '삭제' }))
+    // 개별 행에는 삭제 버튼이 없다(상단 일괄 삭제로 옮겼다)
+    expect(screen.getByRole('button', { name: '선택 삭제' })).toBeDisabled()
+
+    fireEvent.click(screen.getByLabelText('1번 선택'))
+    fireEvent.click(screen.getByRole('button', { name: '선택 삭제' }))
 
     expect(confirmSpy).toHaveBeenCalled()
-    await waitFor(() => expect(mocks.deleteReviewItem).toHaveBeenCalledWith(1, 1))
+    await waitFor(() => expect(mocks.bulkDeleteReview).toHaveBeenCalledWith(1, [1]))
 
     confirmSpy.mockRestore()
+  })
+
+  it('전체 선택 체크박스로 페이지의 모든 행을 고른다', async () => {
+    mocks.getReviewPage.mockResolvedValue(page())
+
+    render(<ReviewBoard org={org()} />)
+    await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
+
+    fireEvent.click(screen.getByLabelText('전체 선택'))
+    expect(screen.getByText('선택 1건')).toBeTruthy()
+    expect(screen.getByLabelText('1번 선택')).toBeChecked()
+
+    fireEvent.click(screen.getByLabelText('전체 선택'))
+    expect(screen.getByText('선택 0건')).toBeTruthy()
+  })
+
+  it('체크한 건에 유형과 의견을 일괄 반영한다', async () => {
+    mocks.getReviewPage.mockResolvedValue(page())
+    mocks.bulkUpdateReview.mockResolvedValue({ updated: 1 })
+
+    render(<ReviewBoard org={org()} />)
+    await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
+
+    fireEvent.click(screen.getByLabelText('1번 선택'))
+    fireEvent.change(screen.getByLabelText('일괄 공공누리 유형'), { target: { value: '0유형' } })
+    fireEvent.change(screen.getByLabelText('일괄 의견'), { target: { value: '일괄 검토 완료' } })
+    fireEvent.click(screen.getByRole('button', { name: '일괄 등록' }))
+
+    await waitFor(() =>
+      expect(mocks.bulkUpdateReview).toHaveBeenCalledWith(1, {
+        ids: [1],
+        judgedKoglType: '0유형',
+        opinion: '일괄 검토 완료',
+        reviewResult: undefined,
+      }),
+    )
+    expect(await screen.findByText('1건에 반영했습니다.')).toBeTruthy()
+  })
+
+  it('아무 값도 채우지 않고 일괄 등록하면 안내만 하고 호출하지 않는다', async () => {
+    mocks.getReviewPage.mockResolvedValue(page())
+
+    render(<ReviewBoard org={org()} />)
+    await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
+
+    fireEvent.click(screen.getByLabelText('1번 선택'))
+    fireEvent.click(screen.getByRole('button', { name: '일괄 등록' }))
+
+    expect(await screen.findByText('반영할 값을 하나 이상 채워주세요.')).toBeTruthy()
+    expect(mocks.bulkUpdateReview).not.toHaveBeenCalled()
+  })
+
+  it('상세검색 3칸은 열별 조건으로 함께 넘어간다', async () => {
+    mocks.getReviewPage.mockResolvedValue(page())
+
+    render(<ReviewBoard org={org()} />)
+    await waitFor(() => expect(mocks.getReviewPage).toHaveBeenCalled())
+
+    fireEvent.click(screen.getByRole('button', { name: '상세검색' }))
+    fireEvent.change(screen.getByLabelText('사이트명'), { target: { value: '누리집' } })
+    fireEvent.change(screen.getByLabelText('게시판명'), { target: { value: '공고' } })
+    fireEvent.change(screen.getByLabelText('게시물 제목'), { target: { value: '사업' } })
+    fireEvent.click(screen.getByRole('button', { name: '검색' }))
+
+    await waitFor(() =>
+      expect(mocks.getReviewPage).toHaveBeenLastCalledWith(1, {
+        keyword: undefined,
+        site: '누리집',
+        board: '공고',
+        title: '사업',
+        page: 0,
+        size: 30,
+      }),
+    )
   })
 
   it('다음 페이지 버튼을 누르면 page+1로 다시 조회한다', async () => {
@@ -229,6 +315,86 @@
     expect(screen.getByRole('radio', { name: '권리처리 추진' })).toBeChecked()
   })
 
+  describe('제3자 권리처리 정책(2026-07-20 변경)', () => {
+    /** 상세 편집 화면을 열고 제3자 권리 + 지정한 세부항목까지 고른다. */
+    async function openDetailWithMinor(minor: string, itemOverrides = {}) {
+      mocks.getReviewPage.mockResolvedValue(page())
+      mocks.getReviewItem.mockResolvedValue(
+        item({ reviewMajor: null, reviewMinor: null, reviewResult: null, judgedKoglType: null, ...itemOverrides }),
+      )
+      render(<ReviewBoard org={org()} />)
+      await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
+      fireEvent.click(screen.getByRole('button', { name: '수정' }))
+      await waitFor(() => expect(mocks.getReviewItem).toHaveBeenCalled())
+
+      fireEvent.click(screen.getByRole('radio', { name: '제3자 권리' }))
+      fireEvent.click(screen.getByRole('radio', { name: minor }))
+    }
+
+    it('권리 일부 보유를 고르면 기본값이 권리처리 추진이고 공공누리 유형은 보류가 된다', async () => {
+      await openDetailWithMinor('3. 권리 일부(공동) 보유')
+
+      expect(screen.getByRole('radio', { name: '권리처리 추진' })).toBeChecked()
+      expect(screen.getByRole('radio', { name: '공공누리 유형 보류' })).toBeChecked()
+    })
+
+    it('미희망을 고르면 기존 공공누리 유형을 그대로 쓰고 비고에 사유를 남긴다', async () => {
+      await openDetailWithMinor('4. 권리 미보유', { koglType: '2유형' })
+
+      fireEvent.click(screen.getByRole('radio', { name: '권리처리 추진 미희망' }))
+
+      expect(screen.getByRole('radio', { name: '공공누리 유형 2유형' })).toBeChecked()
+      expect((screen.getByLabelText('비고') as HTMLTextAreaElement).value).toContain(
+        '권리처리 추진 미희망에 따른 기존 공공누리 부착',
+      )
+    })
+
+    it('미희망인데 기존 공공누리 유형이 없으면 유형을 비워 둔다', async () => {
+      await openDetailWithMinor('4. 권리 미보유', { koglType: null })
+
+      fireEvent.click(screen.getByRole('radio', { name: '권리처리 추진 미희망' }))
+
+      for (const option of ['0유형', '1유형', '2유형', '3유형', '4유형', '보류', '개방불가']) {
+        expect(screen.getByRole('radio', { name: `공공누리 유형 ${option}` })).not.toBeChecked()
+      }
+      expect((screen.getByLabelText('비고') as HTMLTextAreaElement).value).toBe('')
+    })
+
+    it('3·4번에서는 신유형 개방·개방불가를 고를 수 없다', async () => {
+      await openDetailWithMinor('3. 권리 일부(공동) 보유')
+
+      expect(screen.getByRole('radio', { name: '신유형 개방' })).toBeDisabled()
+      expect(screen.getByRole('radio', { name: '계약서 등 재확인' })).toBeDisabled()
+      expect(screen.getByRole('radio', { name: '개방불가' })).toBeDisabled()
+      expect(screen.getByRole('radio', { name: '권리처리 추진 미희망' })).toBeEnabled()
+      // 공공누리 유형의 개방불가는 처리결과와 별개로 남아 있다
+      expect(screen.getByRole('radio', { name: '공공누리 유형 개방불가' })).toBeEnabled()
+    })
+
+    it('초상권 포함은 개방불가까지 3택이다', async () => {
+      await openDetailWithMinor('초상권 포함')
+
+      expect(screen.getByRole('radio', { name: '개방불가' })).toBeEnabled()
+      expect(screen.getByRole('radio', { name: '신유형 개방' })).toBeDisabled()
+      expect(screen.getByText('개방불가도 선택 가능')).toBeTruthy()
+
+      fireEvent.click(screen.getByRole('radio', { name: '개방불가' }))
+      expect(screen.getByRole('radio', { name: '개방불가' })).toBeChecked()
+      expect(screen.getByRole('radio', { name: '공공누리 유형 개방불가' })).toBeChecked()
+    })
+
+    it('비고 문구는 여러 번 눌러도 한 번만 붙는다', async () => {
+      await openDetailWithMinor('4. 권리 미보유', { koglType: '1유형' })
+
+      fireEvent.click(screen.getByRole('radio', { name: '권리처리 추진 미희망' }))
+      fireEvent.click(screen.getByRole('radio', { name: '권리처리 추진' }))
+      fireEvent.click(screen.getByRole('radio', { name: '권리처리 추진 미희망' }))
+
+      const note = (screen.getByLabelText('비고') as HTMLTextAreaElement).value
+      expect(note.match(/권리처리 추진 미희망에 따른/g)).toHaveLength(1)
+    })
+  })
+
   it('수정을 저장하면 폼 값으로 updateReviewJudgment를 호출하고 목록으로 돌아간다', async () => {
     mocks.getReviewPage.mockResolvedValue(page())
     mocks.getReviewItem.mockResolvedValue(item({ reviewMajor: null, reviewResult: null }))
frontend/src/components/ReviewBoard.tsx
--- frontend/src/components/ReviewBoard.tsx
+++ frontend/src/components/ReviewBoard.tsx
@@ -1,6 +1,7 @@
 import { useEffect, useRef, useState, type ReactNode } from 'react'
 import {
-  deleteReviewItem,
+  bulkDeleteReview,
+  bulkUpdateReview,
   getReviewItem,
   getReviewPage,
   importReview,
@@ -24,14 +25,48 @@
   '초상권 포함',
 ]
 
-const REVIEW_RESULT_OPTIONS = ['신유형 개방', '계약서 등 재확인', '권리처리 추진', '개방불가']
+/**
+ * 처리결과 선택지. 앞 4개의 순서는 판정 매트릭스의 행 배치와 맞물려 있으므로 바꾸지 말고,
+ * 새 항목은 뒤에 붙인다. '권리처리 추진 미희망'은 제3자 권리 정책변경으로 추가된 항목이다.
+ */
+const REVIEW_RESULT_OPTIONS = [
+  '신유형 개방',
+  '계약서 등 재확인',
+  '권리처리 추진',
+  '개방불가',
+  '권리처리 추진 미희망',
+]
 
-const JUDGED_KOGL_TYPE_OPTIONS = ['0유형', '1유형', '2유형', '3유형', '4유형', '개방불가']
+const RESULT_PROCESSING = '권리처리 추진'
+const RESULT_NO_PROCESSING = '권리처리 추진 미희망'
+const RESULT_BLOCKED = '개방불가'
+
+/** 권리처리를 추진하는 동안에는 공공누리 유형을 확정하지 않고 '보류'로 둔다(정책변경으로 신설). */
+const KOGL_ON_HOLD = '보류'
+
+/** 권리처리를 추진하지 않아 기존 공공누리 유형을 그대로 쓸 때 비고에 남기는 문구. */
+const NOTE_NO_PROCESSING = '권리처리 추진 미희망에 따른 기존 공공누리 부착'
+
+const JUDGED_KOGL_TYPE_OPTIONS = ['0유형', '1유형', '2유형', '3유형', '4유형', '개방불가', KOGL_ON_HOLD]
+
+/** 공공누리 유형 라디오 첫 줄에 놓는 항목(나머지는 둘째 줄). */
+const KOGL_FIRST_ROW = ['0유형', RESULT_BLOCKED, KOGL_ON_HOLD]
+
+/**
+ * 제3자 권리 세부항목별로 고를 수 있는 처리결과. 정책변경(2026-07-20 검토안)에 따라
+ * 3·4번은 추진/미희망 택일, 초상권은 개방불가까지 3택이다. 여기 없는 세부항목은 제한이 없다.
+ */
+const ALLOWED_RESULTS_BY_MINOR: Record<string, string[]> = {
+  '3. 권리 일부(공동) 보유': [RESULT_PROCESSING, RESULT_NO_PROCESSING],
+  '4. 권리 미보유': [RESULT_PROCESSING, RESULT_NO_PROCESSING],
+  '초상권 포함': [RESULT_PROCESSING, RESULT_NO_PROCESSING, RESULT_BLOCKED],
+}
 
 const REVIEW_RESULT_BADGE_STYLES: Record<string, string> = {
   '신유형 개방': 'bg-emerald-50 text-emerald-700',
   '계약서 등 재확인': 'bg-amber-50 text-amber-700',
   '권리처리 추진': 'bg-blue-50 text-blue-700',
+  '권리처리 추진 미희망': 'bg-slate-100 text-slate-700',
   개방불가: 'bg-red-50 text-red-700',
 }
 
@@ -50,8 +85,9 @@
     if (minor === '2. 후천적 권리 전부 보유(계약서 확인 필요)') {
       return '계약서 등 재확인'
     }
-    if (minor === '3. 권리 일부(공동) 보유' || minor === '4. 권리 미보유' || minor === '초상권 포함') {
-      return '권리처리 추진'
+    if (ALLOWED_RESULTS_BY_MINOR[minor]) {
+      // 정책변경: 추진/미희망(초상권은 개방불가까지) 중 택일이며 기본값은 '권리처리 추진'이다.
+      return RESULT_PROCESSING
     }
   }
   return null
@@ -145,6 +181,7 @@
   checked,
   onChange,
   bold,
+  disabled,
 }: {
   name: string
   value: string
@@ -153,11 +190,20 @@
   checked: boolean
   onChange: () => void
   bold?: boolean
+  /** 그 세부항목에서 고를 수 없는 처리결과를 잠근다(정책상 택일 범위 밖). */
+  disabled?: boolean
 }) {
   return (
-    <label className="flex cursor-pointer flex-col gap-0.5">
+    <label className={`flex flex-col gap-0.5 ${disabled ? 'cursor-not-allowed opacity-40' : 'cursor-pointer'}`}>
       <span className="flex items-center gap-1.5">
-        <input type="radio" name={name} aria-label={value} checked={checked} onChange={onChange} />
+        <input
+          type="radio"
+          name={name}
+          aria-label={value}
+          checked={checked}
+          disabled={disabled}
+          onChange={onChange}
+        />
         <span className={bold ? 'font-semibold text-gray-900' : 'text-gray-800'}>{label ?? value}</span>
       </span>
       {note && <span className="pl-5 text-[11px] text-gray-400">{note}</span>}
@@ -190,6 +236,23 @@
   const [page, setPage] = useState(0)
   const [keywordInput, setKeywordInput] = useState('')
   const [appliedKeyword, setAppliedKeyword] = useState('')
+  /** 복합검색(열별 조건). 서로 AND로 겹친다. */
+  const [advancedOpen, setAdvancedOpen] = useState(false)
+  const [siteInput, setSiteInput] = useState('')
+  const [boardInput, setBoardInput] = useState('')
+  const [titleInput, setTitleInput] = useState('')
+  const [applied, setApplied] = useState<{ site: string; board: string; title: string }>({
+    site: '',
+    board: '',
+    title: '',
+  })
+  /** 일괄 등록/삭제 대상. 체크한 행의 id. */
+  const [selected, setSelected] = useState<number[]>([])
+  const [bulkResult, setBulkResult] = useState('')
+  const [bulkKoglType, setBulkKoglType] = useState('')
+  const [bulkOpinion, setBulkOpinion] = useState('')
+  const [bulkBusy, setBulkBusy] = useState(false)
+  const [bulkMessage, setBulkMessage] = useState<string | null>(null)
   const [loading, setLoading] = useState(false)
   const [error, setError] = useState<string | null>(null)
   const [reloadKey, setReloadKey] = useState(0)
@@ -207,7 +270,14 @@
     setLoading(true)
     setError(null)
 
-    getReviewPage(org.id, { keyword: appliedKeyword || undefined, page, size: PAGE_SIZE })
+    getReviewPage(org.id, {
+      keyword: appliedKeyword || undefined,
+      site: applied.site || undefined,
+      board: applied.board || undefined,
+      title: applied.title || undefined,
+      page,
+      size: PAGE_SIZE,
+    })
       .then((data) => {
         if (cancelled) {
           return
@@ -231,17 +301,84 @@
       cancelled = true
     }
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [mode, org.id, appliedKeyword, page, reloadKey])
+  }, [mode, org.id, appliedKeyword, applied, page, reloadKey])
 
   function handleSearch() {
     setPage(0)
     setAppliedKeyword(keywordInput.trim())
+    setApplied({ site: siteInput.trim(), board: boardInput.trim(), title: titleInput.trim() })
   }
 
   function handleResetSearch() {
     setKeywordInput('')
+    setSiteInput('')
+    setBoardInput('')
+    setTitleInput('')
     setPage(0)
     setAppliedKeyword('')
+    setApplied({ site: '', board: '', title: '' })
+  }
+
+  const allChecked = items.length > 0 && items.every((item) => selected.includes(item.id))
+
+  function toggleAll() {
+    setSelected(allChecked ? [] : items.map((item) => item.id))
+  }
+
+  function toggleOne(id: number) {
+    setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
+  }
+
+  /** 체크한 건들에 채운 항목만 한 번에 반영한다. */
+  async function handleBulkApply() {
+    if (selected.length === 0 || bulkBusy) {
+      return
+    }
+    if (!bulkResult && !bulkKoglType && !bulkOpinion.trim()) {
+      setBulkMessage('반영할 값을 하나 이상 채워주세요.')
+      return
+    }
+    setBulkBusy(true)
+    setBulkMessage(null)
+    try {
+      const res = await bulkUpdateReview(org.id, {
+        ids: selected,
+        reviewResult: bulkResult || undefined,
+        judgedKoglType: bulkKoglType || undefined,
+        opinion: bulkOpinion.trim() || undefined,
+      })
+      setBulkMessage(`${res.updated}건에 반영했습니다.`)
+      setSelected([])
+      setBulkResult('')
+      setBulkKoglType('')
+      setBulkOpinion('')
+      setReloadKey((k) => k + 1)
+    } catch (e) {
+      setBulkMessage(e instanceof Error ? e.message : '일괄 등록에 실패했습니다.')
+    } finally {
+      setBulkBusy(false)
+    }
+  }
+
+  async function handleBulkDelete() {
+    if (selected.length === 0 || bulkBusy) {
+      return
+    }
+    if (!window.confirm(`선택한 ${selected.length}건을 삭제할까요?`)) {
+      return
+    }
+    setBulkBusy(true)
+    setBulkMessage(null)
+    try {
+      const res = await bulkDeleteReview(org.id, selected)
+      setBulkMessage(`${res.deleted}건을 삭제했습니다.`)
+      setSelected([])
+      setReloadKey((k) => k + 1)
+    } catch (e) {
+      setBulkMessage(e instanceof Error ? e.message : '삭제에 실패했습니다.')
+    } finally {
+      setBulkBusy(false)
+    }
   }
 
   async function handleImportFile(file: File) {
@@ -255,18 +392,6 @@
       setImportError(e instanceof Error ? e.message : '엑셀 업로드에 실패했습니다.')
     } finally {
       setImportBusy(false)
-    }
-  }
-
-  async function handleDelete(item: ReviewItem) {
-    if (!window.confirm(`"${item.postTitle ?? item.seq}" 게시물을 삭제할까요?`)) {
-      return
-    }
-    try {
-      await deleteReviewItem(org.id, item.id)
-      setReloadKey((k) => k + 1)
-    } catch (e) {
-      setError(e instanceof Error ? e.message : '삭제에 실패했습니다.')
     }
   }
 
@@ -300,9 +425,17 @@
                 handleSearch()
               }
             }}
-            placeholder="사이트명·게시판명·제목 검색"
+            placeholder="사이트명·게시판명·제목 통합검색"
             className="w-64 rounded-md border border-gray-300 px-2 py-1.5 text-sm"
           />
+          <button
+            type="button"
+            aria-expanded={advancedOpen}
+            onClick={() => setAdvancedOpen((v) => !v)}
+            className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
+          >
+            상세검색
+          </button>
           <button
             type="button"
             onClick={handleSearch}
@@ -357,9 +490,90 @@
       )}
       {importError && <p className="mt-3 text-sm text-red-600">{importError}</p>}
 
+      {advancedOpen && (
+        <div className="mt-3 flex flex-wrap items-end gap-2 rounded-md border border-gray-200 bg-gray-50 p-3">
+          {(
+            [
+              ['사이트명', siteInput, setSiteInput],
+              ['게시판명', boardInput, setBoardInput],
+              ['게시물 제목', titleInput, setTitleInput],
+            ] as const
+          ).map(([label, value, setter]) => (
+            <label key={label} className="flex flex-col gap-1 text-xs text-gray-500">
+              {label}
+              <input
+                type="text"
+                aria-label={label}
+                value={value}
+                onChange={(e) => setter(e.target.value)}
+                onKeyDown={(e) => {
+                  if (e.key === 'Enter') {
+                    handleSearch()
+                  }
+                }}
+                className="w-48 rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+              />
+            </label>
+          ))}
+          <span className="pb-2 text-xs text-gray-400">세 조건은 모두 만족하는 건만 찾습니다(AND)</span>
+        </div>
+      )}
+
       <p className="mt-4 text-sm font-medium text-gray-700">
         {`총 ${total}건 · 처리 ${done}건`}
       </p>
+
+      {/* 일괄 등록: 체크한 건들에 같은 값을 한 번에 넣는다. 삭제도 여기서 한다(행마다 두지 않는다). */}
+      <div className="mt-3 flex flex-wrap items-center gap-2 rounded-md border border-gray-200 bg-gray-50 p-3">
+        <span className="text-sm font-medium text-gray-700">선택 {selected.length}건</span>
+        <select
+          aria-label="일괄 처리결과"
+          value={bulkResult}
+          onChange={(e) => setBulkResult(e.target.value)}
+          className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+        >
+          <option value="">처리결과 그대로</option>
+          {REVIEW_RESULT_OPTIONS.map((option) => (
+            <option key={option} value={option}>{option}</option>
+          ))}
+        </select>
+        <select
+          aria-label="일괄 공공누리 유형"
+          value={bulkKoglType}
+          onChange={(e) => setBulkKoglType(e.target.value)}
+          className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+        >
+          <option value="">공공누리 유형 그대로</option>
+          {JUDGED_KOGL_TYPE_OPTIONS.map((option) => (
+            <option key={option} value={option}>{option}</option>
+          ))}
+        </select>
+        <input
+          type="text"
+          aria-label="일괄 의견"
+          value={bulkOpinion}
+          onChange={(e) => setBulkOpinion(e.target.value)}
+          placeholder="의견(비우면 유지)"
+          className="w-56 rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+        />
+        <button
+          type="button"
+          disabled={selected.length === 0 || bulkBusy}
+          onClick={() => void handleBulkApply()}
+          className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white disabled:bg-gray-300"
+        >
+          일괄 등록
+        </button>
+        <button
+          type="button"
+          disabled={selected.length === 0 || bulkBusy}
+          onClick={() => void handleBulkDelete()}
+          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"
+        >
+          선택 삭제
+        </button>
+        {bulkMessage && <span className="text-sm text-gray-600">{bulkMessage}</span>}
+      </div>
 
       {error && <p className="mt-2 text-sm text-red-600">{error}</p>}
 
@@ -367,9 +581,17 @@
         <table className="w-full min-w-[1100px] text-left text-sm">
           <thead>
             <tr className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
+              <th className="w-8 px-3 py-2 font-medium">
+                <input
+                  type="checkbox"
+                  aria-label="전체 선택"
+                  checked={allChecked}
+                  onChange={toggleAll}
+                />
+              </th>
               <th className="whitespace-nowrap px-3 py-2 font-medium">No</th>
               <th className="whitespace-nowrap px-3 py-2 font-medium">사이트/게시판</th>
-              <th className="px-3 py-2 font-medium">범주/제목</th>
+              <th className="px-3 py-2 font-medium">게시물 경로/제목</th>
               <th className="whitespace-nowrap px-3 py-2 font-medium">제작/공표일</th>
               <th className="whitespace-nowrap px-3 py-2 font-medium">첨부파일</th>
               <th className="whitespace-nowrap px-3 py-2 font-medium">기존 공공누리</th>
@@ -382,19 +604,27 @@
           <tbody>
             {loading ? (
               <tr>
-                <td colSpan={10} className="py-8 text-center text-sm text-gray-400">
+                <td colSpan={11} className="py-8 text-center text-sm text-gray-400">
                   불러오는 중…
                 </td>
               </tr>
             ) : items.length === 0 ? (
               <tr>
-                <td colSpan={10} className="py-10 text-center text-sm text-gray-300">
+                <td colSpan={11} className="py-10 text-center text-sm text-gray-300">
                   업로드된 권리확인 자료가 없습니다. 엑셀 업로드로 시작하세요.
                 </td>
               </tr>
             ) : (
               items.map((item) => (
                 <tr key={item.id} className="border-b border-gray-100 align-top last:border-b-0">
+                  <td className="px-3 py-2">
+                    <input
+                      type="checkbox"
+                      aria-label={`${item.seq}번 선택`}
+                      checked={selected.includes(item.id)}
+                      onChange={() => toggleOne(item.id)}
+                    />
+                  </td>
                   <td className="px-3 py-2 text-gray-500">{item.seq}</td>
                   <td className="px-3 py-2">
                     <div className="text-gray-900">{item.siteName ?? '-'}</div>
@@ -448,13 +678,6 @@
                         className="rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50"
                       >
                         수정
-                      </button>
-                      <button
-                        type="button"
-                        onClick={() => void handleDelete(item)}
-                        className="rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50"
-                      >
-                        삭제
                       </button>
                     </div>
                   </td>
@@ -571,7 +794,49 @@
     const suggestion = suggestReviewResult(major, minor)
     if (suggestion) {
       setReviewResult(suggestion)
+      applyResultDerivations(suggestion)
     }
+  }
+
+  /**
+   * 처리결과가 정해지면 공공누리 유형·비고·권리처리필요를 규칙대로 함께 맞춘다
+   * (2026-07-20 제3자 권리처리 정책변경).
+   *
+   *  - 권리처리 추진      → 공공누리 유형 '보류'
+   *  - 권리처리 추진 미희망 → 기존 공공누리 유형을 그대로 쓰고 비고에 사유를 남긴다.
+   *                        기존 유형이 없으면 미선택 상태로 둔다(별도 제한 없음)
+   *  - 개방불가          → 공공누리 유형 '개방불가'
+   *
+   * 사용자가 이 값들을 다시 손으로 바꿀 수 있게, 잠그지는 않고 값만 채워 준다.
+   */
+  function applyResultDerivations(result: string) {
+    if (result === RESULT_PROCESSING) {
+      setJudgedKoglType(KOGL_ON_HOLD)
+      setNeedsProcessing(true)
+      return
+    }
+    if (result === RESULT_NO_PROCESSING) {
+      setNeedsProcessing(false)
+      setJudgedKoglType(koglType || '')
+      if (koglType) {
+        setLawyerNote((prev) =>
+          prev.includes(NOTE_NO_PROCESSING)
+            ? prev
+            : [prev.trim(), NOTE_NO_PROCESSING].filter(Boolean).join('\n'),
+        )
+      }
+      return
+    }
+    if (result === RESULT_BLOCKED) {
+      setJudgedKoglType(RESULT_BLOCKED)
+      setNeedsProcessing(false)
+    }
+  }
+
+  /** 지금 고른 세부항목에서 그 처리결과를 고를 수 있는지. 제한이 없는 세부항목은 전부 허용이다. */
+  function resultAllowed(option: string): boolean {
+    const allowed = ALLOWED_RESULTS_BY_MINOR[reviewMinor]
+    return !allowed || allowed.includes(option)
   }
 
   function pickMajor(major: string) {
@@ -589,6 +854,7 @@
   function pickResult(result: string) {
     setReviewResult(result)
     setResultManuallySet(true)
+    applyResultDerivations(result)
   }
 
   async function handleSave() {
@@ -762,6 +1028,7 @@
                     value={REVIEW_RESULT_OPTIONS[0]}
                     checked={reviewResult === REVIEW_RESULT_OPTIONS[0]}
                     onChange={() => pickResult(REVIEW_RESULT_OPTIONS[0])}
+                    disabled={!resultAllowed(REVIEW_RESULT_OPTIONS[0])}
                     bold
                   />
                 </td>
@@ -813,6 +1080,7 @@
                     value={REVIEW_RESULT_OPTIONS[1]}
                     checked={reviewResult === REVIEW_RESULT_OPTIONS[1]}
                     onChange={() => pickResult(REVIEW_RESULT_OPTIONS[1])}
+                    disabled={!resultAllowed(REVIEW_RESULT_OPTIONS[1])}
                     bold
                   />
                 </td>
@@ -829,16 +1097,30 @@
                   />
                 </td>
                 <td
-                  className="border border-gray-300 bg-gray-50 px-3 py-2 text-center align-middle"
+                  className="border border-gray-300 bg-gray-50 px-3 py-2 align-middle"
                   rowSpan={3}
                 >
-                  <MatrixRadio
-                    name="review-result"
-                    value={REVIEW_RESULT_OPTIONS[2]}
-                    checked={reviewResult === REVIEW_RESULT_OPTIONS[2]}
-                    onChange={() => pickResult(REVIEW_RESULT_OPTIONS[2])}
-                    bold
-                  />
+                  {/* 정책변경: 3·4·초상권은 추진/미희망 택일이다. 초상권만 개방불가까지 고를 수 있다. */}
+                  <div className="flex flex-col items-start gap-1.5">
+                    <MatrixRadio
+                      name="review-result"
+                      value={RESULT_PROCESSING}
+                      checked={reviewResult === RESULT_PROCESSING}
+                      onChange={() => pickResult(RESULT_PROCESSING)}
+                      disabled={!resultAllowed(RESULT_PROCESSING)}
+                      bold
+                    />
+                    <MatrixRadio
+                      name="review-result"
+                      value={RESULT_NO_PROCESSING}
+                      checked={reviewResult === RESULT_NO_PROCESSING}
+                      onChange={() => pickResult(RESULT_NO_PROCESSING)}
+                      disabled={!resultAllowed(RESULT_NO_PROCESSING)}
+                    />
+                    {reviewMinor === '초상권 포함' && (
+                      <span className="text-[11px] text-gray-400">개방불가도 선택 가능</span>
+                    )}
+                  </div>
                 </td>
               </tr>
               <tr>
@@ -884,6 +1166,7 @@
                     value={REVIEW_RESULT_OPTIONS[3]}
                     checked={reviewResult === REVIEW_RESULT_OPTIONS[3]}
                     onChange={() => pickResult(REVIEW_RESULT_OPTIONS[3])}
+                    disabled={!resultAllowed(REVIEW_RESULT_OPTIONS[3])}
                     bold
                   />
                 </td>
@@ -896,11 +1179,13 @@
           <InfoRow label="공공누리 유형">
             <div className="flex flex-col gap-1.5">
               <div className="flex flex-wrap gap-x-4 gap-y-1">
-                {JUDGED_KOGL_TYPE_OPTIONS.filter((o) => o === '0유형' || o === '개방불가').map((option) => (
+                {JUDGED_KOGL_TYPE_OPTIONS.filter((o) => KOGL_FIRST_ROW.includes(o)).map((option) => (
                   <label key={option} className="flex items-center gap-1.5 text-sm text-gray-800">
                     <input
                       type="radio"
                       name="judged-kogl-type"
+                      // 처리결과에도 '개방불가'가 있어 라벨만으로는 구분되지 않는다.
+                      aria-label={`공공누리 유형 ${option}`}
                       checked={judgedKoglType === option}
                       onChange={() => setJudgedKoglType(option)}
                     />
@@ -909,11 +1194,13 @@
                 ))}
               </div>
               <div className="flex flex-wrap items-center gap-x-4 gap-y-1">
-                {JUDGED_KOGL_TYPE_OPTIONS.filter((o) => o !== '0유형' && o !== '개방불가').map((option) => (
+                {JUDGED_KOGL_TYPE_OPTIONS.filter((o) => !KOGL_FIRST_ROW.includes(o)).map((option) => (
                   <label key={option} className="flex items-center gap-1.5 text-sm text-gray-800">
                     <input
                       type="radio"
                       name="judged-kogl-type"
+                      // 처리결과에도 '개방불가'가 있어 라벨만으로는 구분되지 않는다.
+                      aria-label={`공공누리 유형 ${option}`}
                       checked={judgedKoglType === option}
                       onChange={() => setJudgedKoglType(option)}
                     />
frontend/src/components/SeedUpload.tsx
--- frontend/src/components/SeedUpload.tsx
+++ frontend/src/components/SeedUpload.tsx
@@ -1,5 +1,6 @@
 import { useRef, useState } from 'react'
 import { uploadSeed, type SeedReport } from '../api/client'
+import HelpButton from '../help/HelpButton'
 
 export default function SeedUpload({ onUploaded }: { onUploaded: () => void }) {
   const inputRef = useRef<HTMLInputElement>(null)
@@ -41,6 +42,10 @@
       >
         신청목록 시트 올리기
       </button>
+      <span className="mt-2 flex items-center justify-center gap-1 text-xs text-gray-400">
+        업로드 방법
+        <HelpButton topic="channel.seed" />
+      </span>
 
       {report && (
         <p className="mt-2 text-xs text-gray-500">
frontend/src/components/Sidebar.test.tsx
--- frontend/src/components/Sidebar.test.tsx
+++ frontend/src/components/Sidebar.test.tsx
@@ -43,10 +43,19 @@
   it('미구현 메뉴는 비활성으로 표시되고 링크가 아니다', () => {
     render(<Sidebar />)
 
-    const dashboard = screen.getByText('Dashboard')
-    expect(dashboard).toHaveAttribute('aria-disabled', 'true')
-    expect(dashboard).toHaveAttribute('title', '준비 중')
-    expect(screen.queryByRole('link', { name: 'Dashboard' })).toBeNull()
+    const files = screen.getByText('자료실')
+    expect(files).toHaveAttribute('aria-disabled', 'true')
+    expect(files).toHaveAttribute('title', '준비 중')
+    expect(screen.queryByRole('link', { name: '자료실' })).toBeNull()
+  })
+
+  it('Dashboard는 구현되어 링크로 열린다', () => {
+    const onSelect = vi.fn()
+    render(<Sidebar onSelect={onSelect} />)
+
+    fireEvent.click(screen.getByRole('link', { name: 'Dashboard' }))
+
+    expect(onSelect).toHaveBeenCalledWith('dashboard')
   })
 
   it('브랜드 영역을 보여준다', () => {
@@ -64,7 +73,7 @@
     expect(screen.getByText('IH')).toBeTruthy()
 
     expect(screen.getByRole('link', { name: '기관관리' })).toHaveAttribute('title', '기관관리')
-    expect(screen.getByTitle('Dashboard (준비 중)')).toBeTruthy()
+    expect(screen.getByTitle('자료실 (준비 중)')).toBeTruthy()
   })
 
   it('접기 버튼을 누르면 onToggle이 호출된다', () => {
frontend/src/components/Sidebar.tsx
--- frontend/src/components/Sidebar.tsx
+++ frontend/src/components/Sidebar.tsx
@@ -15,7 +15,7 @@
   {
     key: 'dashboard',
     label: 'Dashboard',
-    enabled: false,
+    enabled: true,
     icon: (
       <svg className={ICON_CLASS} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
         <rect x="3" y="3" width="7" height="7" rx="1" />
@@ -58,7 +58,7 @@
   {
     key: 'projects',
     label: '사업관리',
-    enabled: false,
+    enabled: true,
     icon: (
       <svg className={ICON_CLASS} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
         <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 @@
   {
     key: 'system',
     label: '시스템관리',
-    enabled: false,
+    enabled: true,
     icon: (
       <svg className={ICON_CLASS} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
         <circle cx="12" cy="12" r="3" />
 
frontend/src/components/SystemPage.test.tsx (added)
+++ frontend/src/components/SystemPage.test.tsx
@@ -0,0 +1,128 @@
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import SystemPage from './SystemPage'
+import type { ResetTarget } from '../api/client'
+
+const mocks = vi.hoisted(() => ({
+  getResetPreview: vi.fn(),
+  resetChannels: vi.fn(),
+}))
+
+vi.mock('../api/client', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('../api/client')>()),
+  getResetPreview: mocks.getResetPreview,
+  resetChannels: mocks.resetChannels,
+}))
+
+function target(overrides: Partial<ResetTarget> = {}): ResetTarget {
+  return {
+    orgId: 1,
+    orgNo: '001',
+    orgName: '국제방송교류재단',
+    stage: 1,
+    channels: [
+      { kind: '문정원', channelId: 'ch-mj' },
+      { kind: '법률검토', channelId: 'ch-law' },
+    ],
+    ...overrides,
+  }
+}
+
+async function typeConfirmAndRun(phrase = '채널 초기화') {
+  fireEvent.change(screen.getByLabelText(/실행하려면/), { target: { value: phrase } })
+  fireEvent.click(screen.getByText('채널 초기화 실행'))
+  fireEvent.click(await screen.findByText('초기화 실행'))
+}
+
+describe('SystemPage', () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+    mocks.getResetPreview.mockResolvedValue([target()])
+    mocks.resetChannels.mockResolvedValue({ archivedChannels: 2, clearedOrgs: 1, failures: [] })
+  })
+
+  it('초기화 대상을 기관·채널 수와 함께 미리 보여준다', async () => {
+    mocks.getResetPreview.mockResolvedValue([
+      target(),
+      target({ orgId: 2, orgNo: '002', orgName: '세종학당재단', channels: [{ kind: '문정원', channelId: 'c2' }] }),
+    ])
+    render(<SystemPage />)
+
+    expect(await screen.findByText('대상 2개 기관 · 채널 3개')).toBeTruthy()
+    const rows = within(screen.getByTestId('reset-targets')).getAllByRole('row')
+    expect(rows).toHaveLength(3) // 헤더 + 2
+    expect(rows[1].textContent).toContain('문정원 · 법률검토')
+  })
+
+  it('확인 문구를 입력하기 전에는 실행 버튼이 잠겨 있다', async () => {
+    render(<SystemPage />)
+    await screen.findByTestId('reset-targets')
+
+    expect(screen.getByText('채널 초기화 실행')).toHaveProperty('disabled', true)
+
+    fireEvent.change(screen.getByLabelText(/실행하려면/), { target: { value: '초기화' } })
+    expect(screen.getByText('채널 초기화 실행')).toHaveProperty('disabled', true)
+
+    fireEvent.change(screen.getByLabelText(/실행하려면/), { target: { value: '채널 초기화' } })
+    expect(screen.getByText('채널 초기화 실행')).toHaveProperty('disabled', false)
+  })
+
+  it('실행 버튼을 눌러도 확인창에서 한 번 더 물어본다', async () => {
+    render(<SystemPage />)
+    await screen.findByTestId('reset-targets')
+
+    fireEvent.change(screen.getByLabelText(/실행하려면/), { target: { value: '채널 초기화' } })
+    fireEvent.click(screen.getByText('채널 초기화 실행'))
+
+    expect(await screen.findByText('정말 초기화할까요?')).toBeTruthy()
+    expect(mocks.resetChannels).not.toHaveBeenCalled()
+
+    fireEvent.click(screen.getByText('취소'))
+    expect(mocks.resetChannels).not.toHaveBeenCalled()
+  })
+
+  it('확인창에서 실행하면 문구와 함께 초기화를 요청하고 결과를 보여준다', async () => {
+    render(<SystemPage />)
+    await screen.findByTestId('reset-targets')
+
+    await typeConfirmAndRun()
+
+    await waitFor(() => expect(mocks.resetChannels).toHaveBeenCalledWith('채널 초기화'))
+    expect(await screen.findByText('채널 2개 보관, 기관 1곳 초기화')).toBeTruthy()
+    // 실행 후 목록을 다시 읽는다
+    expect(mocks.getResetPreview).toHaveBeenCalledTimes(2)
+  })
+
+  it('실패가 있으면 사유를 그대로 보여준다', async () => {
+    mocks.resetChannels.mockResolvedValue({
+      archivedChannels: 1,
+      clearedOrgs: 0,
+      failures: ['001 국제방송교류재단 (법률검토): 권한 없음'],
+    })
+    render(<SystemPage />)
+    await screen.findByTestId('reset-targets')
+
+    await typeConfirmAndRun()
+
+    expect(await screen.findByText(/실패 1건/)).toBeTruthy()
+    expect(screen.getByText(/권한 없음/)).toBeTruthy()
+  })
+
+  it('대상이 없으면 실행할 수 없다', async () => {
+    mocks.getResetPreview.mockResolvedValue([])
+    render(<SystemPage />)
+
+    expect(await screen.findByText('초기화할 채널이 없습니다.')).toBeTruthy()
+    fireEvent.change(screen.getByLabelText(/실행하려면/), { target: { value: '채널 초기화' } })
+    expect(screen.getByText('채널 초기화 실행')).toHaveProperty('disabled', true)
+  })
+
+  it('무엇이 남고 무엇이 사라지는지 화면에 적어 둔다', async () => {
+    render(<SystemPage />)
+    await screen.findByTestId('reset-targets')
+
+    expect(screen.getByText(/보관\(아카이브\)/)).toBeTruthy()
+    expect(screen.getByText(/그대로 남는다/)).toBeTruthy()
+    expect(screen.getByText(/사용자 계정도 건드리지 않는다/)).toBeTruthy()
+  })
+})
 
frontend/src/components/SystemPage.tsx (added)
+++ frontend/src/components/SystemPage.tsx
@@ -0,0 +1,202 @@
+import { useCallback, useEffect, useState } from 'react'
+import {
+  getResetPreview,
+  resetChannels,
+  RESET_CONFIRM_PHRASE,
+  type ResetReport,
+  type ResetTarget,
+} from '../api/client'
+import { stageLabel } from '../stages'
+import HelpButton from '../help/HelpButton'
+
+/**
+ * 시스템관리 화면. 지금은 [채널 초기화] 하나만 있다.
+ *
+ * 되돌리기 번거로운 작업이라 3중으로 막는다.
+ *   1) 무엇이 사라지는지 목록으로 먼저 보여준다
+ *   2) 확인 문구를 그대로 입력해야 버튼이 열린다
+ *   3) 서버도 같은 문구를 다시 검사한다
+ *
+ * 지우는 범위는 <b>웹앱이 만든(=DB가 채널 ID를 들고 있는) 채널</b>뿐이다. Mattermost 팀에는
+ * 엑셀 VBA로 만든 예전 채널과 공용 채널(Town Square/FAQ/대쉬보드/양식 모음/자료실)이 함께
+ * 있어서, 이름으로 훑어 지우면 그것들까지 날아간다. 사용자 계정도 건드리지 않는다.
+ */
+
+interface Props {
+  /** 초기화가 끝나면 상위(App)가 기관 목록을 다시 읽도록. */
+  onChanged?: () => void
+}
+
+export default function SystemPage({ onChanged }: Props) {
+  const [targets, setTargets] = useState<ResetTarget[] | null>(null)
+  const [error, setError] = useState<string | null>(null)
+  const [confirm, setConfirm] = useState('')
+  const [busy, setBusy] = useState(false)
+  const [report, setReport] = useState<ResetReport | null>(null)
+  const [dialogOpen, setDialogOpen] = useState(false)
+
+  const reload = useCallback(async () => {
+    try {
+      setTargets(await getResetPreview())
+      setError(null)
+    } catch (e) {
+      setError((e as Error).message)
+    }
+  }, [])
+
+  useEffect(() => {
+    void reload()
+  }, [reload])
+
+  const channelCount = (targets ?? []).reduce((sum, t) => sum + t.channels.length, 0)
+  const canRun = confirm.trim() === RESET_CONFIRM_PHRASE && channelCount > 0 && !busy
+
+  async function run() {
+    setBusy(true)
+    setDialogOpen(false)
+    try {
+      const result = await resetChannels(confirm.trim())
+      setReport(result)
+      setConfirm('')
+      await reload()
+      onChanged?.()
+      setError(null)
+    } catch (e) {
+      setError((e as Error).message)
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  return (
+    <div className="space-y-4 p-6">
+      <section className="rounded-lg border border-gray-200 bg-white p-5">
+        <h1 className="text-lg font-semibold">시스템관리</h1>
+        <p className="text-xs text-gray-500">운영 중 되돌리기가 필요한 작업을 모아 둔다.</p>
+      </section>
+
+      <section className="rounded-lg border border-red-200 bg-white p-5">
+        <h2 className="flex items-center gap-1.5 text-sm font-semibold text-red-700">
+          채널 초기화
+          <HelpButton topic="system.reset" />
+        </h2>
+        <ul className="mt-3 space-y-1 rounded-md bg-red-50 p-3 text-xs text-red-800">
+          <li>· 웹앱이 만든 Mattermost 채널을 <b>보관(아카이브)</b> 처리한다. 영구 삭제가 아니라 시스템 콘솔에서 되돌릴 수 있다.</li>
+          <li>· 기관의 채널 연결과 진행단계가 비워져, 채널관리에서 처음부터 다시 만들 수 있게 된다.</li>
+          <li>· 담당자 배정, 권리확인·권리처리 자료, 업무메모는 <b>그대로 남는다</b>.</li>
+          <li>· 공용 채널(Town Square·FAQ·대쉬보드·양식 모음·자료실)과 엑셀로 만든 예전 채널은 대상이 아니다.</li>
+          <li>· 사용자 계정도 건드리지 않는다.</li>
+        </ul>
+
+        {error && (
+          <p className="mt-3 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p>
+        )}
+
+        {report && (
+          <div className="mt-3 rounded-md bg-gray-50 px-3 py-2 text-sm">
+            <p>
+              채널 {report.archivedChannels}개 보관, 기관 {report.clearedOrgs}곳 초기화
+              {report.failures.length > 0 && `, 실패 ${report.failures.length}건`}
+            </p>
+            {report.failures.length > 0 && (
+              <ul className="mt-1 space-y-0.5 text-xs text-red-700">
+                {report.failures.map((f) => (
+                  <li key={f}>· {f}</li>
+                ))}
+              </ul>
+            )}
+          </div>
+        )}
+
+        <div className="mt-4">
+          <p className="text-sm font-medium">
+            대상 {targets?.length ?? 0}개 기관 · 채널 {channelCount}개
+          </p>
+
+          {targets === null ? (
+            <p className="mt-2 text-sm text-gray-500">불러오는 중…</p>
+          ) : targets.length === 0 ? (
+            <p className="mt-2 text-sm text-gray-500">초기화할 채널이 없습니다.</p>
+          ) : (
+            <div className="mt-2 max-h-72 overflow-y-auto rounded-md border border-gray-200">
+              <table data-testid="reset-targets" className="w-full text-sm">
+                <thead>
+                  <tr className="border-b border-gray-200 text-xs text-gray-500">
+                    <th className="px-3 py-2 text-left font-normal">연번</th>
+                    <th className="px-3 py-2 text-left font-normal">기관명</th>
+                    <th className="px-3 py-2 text-left font-normal">현재 단계</th>
+                    <th className="px-3 py-2 text-left font-normal">보관될 채널</th>
+                  </tr>
+                </thead>
+                <tbody>
+                  {targets.map((t) => (
+                    <tr key={t.orgId} className="border-b border-gray-100">
+                      <td className="px-3 py-2 text-gray-500 tabular-nums">{t.orgNo}</td>
+                      <td className="px-3 py-2 font-medium">{t.orgName}</td>
+                      <td className="px-3 py-2 text-gray-600">
+                        {t.stage === null ? '-' : `${t.stage}. ${stageLabel(t.stage)}`}
+                      </td>
+                      <td className="px-3 py-2 text-gray-600">
+                        {t.channels.map((c) => c.kind).join(' · ')}
+                      </td>
+                    </tr>
+                  ))}
+                </tbody>
+              </table>
+            </div>
+          )}
+        </div>
+
+        <div className="mt-4 flex flex-wrap items-center gap-2">
+          <label htmlFor="reset-confirm" className="text-sm text-gray-600">
+            실행하려면 <b>{RESET_CONFIRM_PHRASE}</b> 를 입력하세요
+          </label>
+          <input
+            id="reset-confirm"
+            value={confirm}
+            onChange={(e) => setConfirm(e.target.value)}
+            className="w-40 rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+          />
+          <button
+            type="button"
+            disabled={!canRun}
+            onClick={() => setDialogOpen(true)}
+            className="rounded-md bg-red-600 px-3 py-1.5 text-sm text-white disabled:bg-gray-300"
+          >
+            채널 초기화 실행
+          </button>
+        </div>
+      </section>
+
+      {dialogOpen && (
+        <div className="fixed inset-0 flex items-center justify-center bg-black/30" role="alertdialog">
+          <div className="w-96 rounded-lg bg-white p-5">
+            <h2 className="text-base font-semibold">정말 초기화할까요?</h2>
+            <p className="mt-3 rounded-md bg-gray-50 p-3 text-sm text-gray-700">
+              기관 {targets?.length ?? 0}곳의 채널 {channelCount}개가 Mattermost에서 보관 처리되고,
+              해당 기관의 채널 연결과 진행단계가 비워집니다.
+            </p>
+            <div className="mt-5 flex justify-end gap-2">
+              <button
+                type="button"
+                disabled={busy}
+                onClick={() => setDialogOpen(false)}
+                className="rounded-md border border-gray-300 px-3 py-1.5 text-sm disabled:opacity-50"
+              >
+                취소
+              </button>
+              <button
+                type="button"
+                disabled={busy}
+                onClick={() => void run()}
+                className="rounded-md bg-red-600 px-3 py-1.5 text-sm text-white disabled:bg-gray-300"
+              >
+                초기화 실행
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}
frontend/src/components/WorkMemos.test.tsx
--- frontend/src/components/WorkMemos.test.tsx
+++ frontend/src/components/WorkMemos.test.tsx
@@ -6,6 +6,7 @@
 const mocks = vi.hoisted(() => ({
   getMemos: vi.fn(),
   createMemo: vi.fn(),
+  updateMemo: vi.fn(),
   deleteMemo: vi.fn(),
 }))
 
@@ -13,6 +14,7 @@
   ...(await importOriginal<typeof import('../api/client')>()),
   getMemos: mocks.getMemos,
   createMemo: mocks.createMemo,
+  updateMemo: mocks.updateMemo,
   deleteMemo: mocks.deleteMemo,
 }))
 
@@ -71,6 +73,7 @@
 beforeEach(() => {
   mocks.getMemos.mockReset()
   mocks.createMemo.mockReset()
+  mocks.updateMemo.mockReset()
   mocks.deleteMemo.mockReset()
 })
 
@@ -99,42 +102,16 @@
     })
   })
 
-  it('담당자 선택에 기관 담당자와 직접 입력이 나오고, 직접 입력을 고르면 이름 입력칸이 나타난다', async () => {
+  it('작성자를 고르는 칸은 없고, 자동 기록 안내만 보인다', async () => {
     mocks.getMemos.mockResolvedValue([])
 
     render(<WorkMemos org={org()} />)
 
     await waitFor(() => expect(mocks.getMemos).toHaveBeenCalled())
 
-    expect(screen.getByRole('option', { name: '송민지 과장' })).toBeTruthy()
-    expect(screen.getByRole('option', { name: '직접 입력' })).toBeTruthy()
+    expect(screen.queryByLabelText('담당자')).toBeNull()
     expect(screen.queryByLabelText('담당자 이름')).toBeNull()
-
-    fireEvent.change(screen.getByLabelText('담당자'), { target: { value: '__custom__' } })
-
-    expect(screen.getByLabelText('담당자 이름')).toBeTruthy()
-  })
-
-  it('신청기관 담당자, 문정원 담당자, 아이티앤 담당자, 담당 변호사가 모두 있으면 넷 다 선택 옵션으로 나온다', async () => {
-    mocks.getMemos.mockResolvedValue([])
-
-    render(
-      <WorkMemos
-        org={org({
-          mj: contact({ id: 2, category: 'MJ', name: '김문정' }),
-          itn: contact({ id: 4, category: 'ITN', name: '박아이티' }),
-          lawyer: contact({ id: 3, category: 'LAWYER', name: '이변호' }),
-        })}
-      />,
-    )
-
-    await waitFor(() => expect(mocks.getMemos).toHaveBeenCalled())
-
-    expect(screen.getByRole('option', { name: '송민지 과장' })).toBeTruthy()
-    expect(screen.getByRole('option', { name: '김문정' })).toBeTruthy()
-    expect(screen.getByRole('option', { name: '박아이티' })).toBeTruthy()
-    expect(screen.getByRole('option', { name: '이변호' })).toBeTruthy()
-    expect(screen.getByRole('option', { name: '직접 입력' })).toBeTruthy()
+    expect(screen.getByText('작성자는 로그인한 사용자로 자동 기록됩니다.')).toBeTruthy()
   })
 
   it('메모 내용이 비어 있으면 등록 버튼이 비활성이고, 입력하면 활성화된다', async () => {
@@ -152,13 +129,11 @@
     expect(submit).not.toBeDisabled()
   })
 
-  it('등록을 누르면 선택된 담당자와 본문으로 createMemo를 호출하고 본문을 비운다', async () => {
+  it('등록을 누르면 본문만 보내고(작성자는 서버가 기록) 본문을 비운다', async () => {
     mocks.getMemos.mockResolvedValue([])
-    mocks.createMemo.mockResolvedValue(memo({ id: 2, body: '통화 내용' }))
+    mocks.createMemo.mockResolvedValue(memo({ id: 2, body: '통화 내용', contactName: 'admin' }))
 
     render(<WorkMemos org={org()} />)
-
-    fireEvent.change(screen.getByLabelText('담당자'), { target: { value: '송민지 과장' } })
 
     await waitFor(() => expect(mocks.getMemos).toHaveBeenCalled())
 
@@ -166,15 +141,46 @@
     fireEvent.click(screen.getByRole('button', { name: '등록' }))
 
     await waitFor(() => {
-      expect(mocks.createMemo).toHaveBeenCalledWith(1, {
-        contactName: '송민지 과장',
-        body: '통화 내용',
-      })
+      expect(mocks.createMemo).toHaveBeenCalledWith(1, { body: '통화 내용' })
     })
 
     await waitFor(() => {
       expect((screen.getByLabelText('메모 내용') as HTMLTextAreaElement).value).toBe('')
     })
+    // 서버가 기록한 작성자가 목록에 그대로 보인다
+    expect(screen.getByText('admin')).toBeTruthy()
+  })
+
+  it('수정을 누르면 본문을 그 자리에서 고치고 저장하면 updateMemo를 부른다', async () => {
+    mocks.getMemos.mockResolvedValue([memo({ id: 7, body: '원래 내용' })])
+    mocks.updateMemo.mockResolvedValue(memo({ id: 7, body: '고친 내용' }))
+
+    render(<WorkMemos org={org()} />)
+
+    await waitFor(() => expect(screen.getByText('원래 내용')).toBeTruthy())
+
+    fireEvent.click(screen.getByRole('button', { name: '수정' }))
+    fireEvent.change(screen.getByLabelText('메모 수정'), { target: { value: '고친 내용' } })
+    fireEvent.click(screen.getByRole('button', { name: '저장' }))
+
+    await waitFor(() => expect(mocks.updateMemo).toHaveBeenCalledWith(1, 7, { body: '고친 내용' }))
+    await waitFor(() => expect(screen.getByText('고친 내용')).toBeTruthy())
+  })
+
+  it('수정 중 취소를 누르면 원래 내용으로 돌아간다', async () => {
+    mocks.getMemos.mockResolvedValue([memo({ id: 7, body: '원래 내용' })])
+
+    render(<WorkMemos org={org()} />)
+
+    await waitFor(() => expect(screen.getByText('원래 내용')).toBeTruthy())
+
+    fireEvent.click(screen.getByRole('button', { name: '수정' }))
+    fireEvent.change(screen.getByLabelText('메모 수정'), { target: { value: '버릴 내용' } })
+    fireEvent.click(screen.getByRole('button', { name: '취소' }))
+
+    expect(screen.queryByLabelText('메모 수정')).toBeNull()
+    expect(screen.getByText('원래 내용')).toBeTruthy()
+    expect(mocks.updateMemo).not.toHaveBeenCalled()
   })
 
   it('삭제를 누르면 확인을 물어보고, 확인하면 deleteMemo를 호출한다', async () => {
frontend/src/components/WorkMemos.tsx
--- frontend/src/components/WorkMemos.tsx
+++ frontend/src/components/WorkMemos.tsx
@@ -1,7 +1,5 @@
 import { useEffect, useState } from 'react'
-import { createMemo, deleteMemo, getMemos, type Org, type WorkMemo } from '../api/client'
-
-const CUSTOM_CONTACT_VALUE = '__custom__'
+import { createMemo, deleteMemo, getMemos, updateMemo, type Org, type WorkMemo } from '../api/client'
 
 const dateFormatter = new Intl.DateTimeFormat('ko-KR', {
   year: 'numeric',
@@ -15,46 +13,21 @@
   return dateFormatter.format(new Date(createdAt))
 }
 
-function applicantOptionLabel(org: Org): string | null {
-  if (!org.applicant) {
-    return null
-  }
-  return org.applicant.title ? `${org.applicant.name} ${org.applicant.title}` : org.applicant.name
-}
-
-/** 신청기관 담당자, 문정원 담당자, 아이티앤 담당자, 담당 변호사 중 배정되어 있는 사람만 후보로 낸다.
- * 이름이 같은 사람이 중복 등록돼 있어도 셀렉트 옵션은 한 번만 보이도록 중복을 없앤다. */
-function contactOptions(org: Org): string[] {
-  const candidates = [
-    applicantOptionLabel(org),
-    org.mj?.name ?? null,
-    org.itn?.name ?? null,
-    org.lawyer?.name ?? null,
-  ]
-  const seen = new Set<string>()
-  // 요구사항: 선택박스 최상단은 항상 '시스템'이다 (자동/시스템성 메모 구분용)
-  const options: string[] = ['시스템']
-  for (const candidate of candidates) {
-    if (!candidate || candidate.trim() === '' || seen.has(candidate)) {
-      continue
-    }
-    seen.add(candidate)
-    options.push(candidate)
-  }
-  return options
-}
-
-/** 기관관리 상세의 업무메모 탭. 통화 등 담당자와의 연락 내용을 기록/조회/삭제한다. */
+/**
+ * 기관관리 상세의 업무메모 탭. 통화 등 담당자와의 연락 내용을 기록/수정/삭제한다.
+ *
+ * 작성자는 화면에서 고르지 않는다 - 요청자 확정대로 로그인한 사용자를 서버가 직접
+ * 기록하므로, 목록에 보이는 작성자는 서버가 채워 준 값이다.
+ */
 export default function WorkMemos({ org }: { org: Org }) {
-  const options = contactOptions(org)
-
   const [memos, setMemos] = useState<WorkMemo[]>([])
   const [loading, setLoading] = useState(false)
-  const [contactChoice, setContactChoice] = useState(options[0] ?? CUSTOM_CONTACT_VALUE)
-  const [customContactName, setCustomContactName] = useState('')
   const [body, setBody] = useState('')
   const [busy, setBusy] = useState(false)
   const [error, setError] = useState<string | null>(null)
+  /** 수정 중인 메모 id. null이면 새 메모 작성 모드다. */
+  const [editingId, setEditingId] = useState<number | null>(null)
+  const [editingBody, setEditingBody] = useState('')
 
   useEffect(() => {
     let cancelled = false
@@ -77,9 +50,7 @@
     }
   }, [org.id])
 
-  const isCustom = contactChoice === CUSTOM_CONTACT_VALUE
-  const effectiveContactName = (isCustom ? customContactName : contactChoice).trim()
-  const canSubmit = effectiveContactName !== '' && body.trim() !== '' && !busy
+  const canSubmit = body.trim() !== '' && !busy
 
   async function handleSubmit() {
     if (!canSubmit) {
@@ -88,11 +59,29 @@
     setBusy(true)
     setError(null)
     try {
-      const created = await createMemo(org.id, { contactName: effectiveContactName, body })
+      const created = await createMemo(org.id, { body })
       setMemos((prev) => [created, ...prev])
       setBody('')
     } catch (e) {
       setError(e instanceof Error ? e.message : '메모 등록에 실패했습니다.')
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  async function handleUpdate(memo: WorkMemo) {
+    if (editingBody.trim() === '' || busy) {
+      return
+    }
+    setBusy(true)
+    setError(null)
+    try {
+      const saved = await updateMemo(org.id, memo.id, { body: editingBody })
+      setMemos((prev) => prev.map((m) => (m.id === memo.id ? saved : m)))
+      setEditingId(null)
+      setEditingBody('')
+    } catch (e) {
+      setError(e instanceof Error ? e.message : '메모 수정에 실패했습니다.')
     } finally {
       setBusy(false)
     }
@@ -113,41 +102,7 @@
   return (
     <div className="p-4">
       <section className="rounded-lg border border-gray-200 p-4">
-        <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
-          <div className="flex flex-col gap-1 text-sm">
-            <label htmlFor="memo-contact-choice" className="text-xs text-gray-500">
-              담당자
-            </label>
-            <select
-              id="memo-contact-choice"
-              value={contactChoice}
-              onChange={(e) => setContactChoice(e.target.value)}
-              className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
-            >
-              {options.map((option) => (
-                <option key={option} value={option}>
-                  {option}
-                </option>
-              ))}
-              <option value={CUSTOM_CONTACT_VALUE}>직접 입력</option>
-            </select>
-          </div>
-
-          {isCustom && (
-            <div className="flex flex-col gap-1 text-sm">
-              <label htmlFor="memo-contact-name" className="text-xs text-gray-500">
-                담당자 이름
-              </label>
-              <input
-                id="memo-contact-name"
-                type="text"
-                value={customContactName}
-                onChange={(e) => setCustomContactName(e.target.value)}
-                className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
-              />
-            </div>
-          )}
-        </div>
+        <p className="text-xs text-gray-400">작성자는 로그인한 사용자로 자동 기록됩니다.</p>
 
         <div className="mt-3 flex flex-col gap-1 text-sm">
           <label htmlFor="memo-body" className="text-xs text-gray-500">
@@ -193,15 +148,61 @@
                     <span className="text-xs text-gray-300">·</span>
                     <span className="text-sm font-medium text-gray-900">{memo.contactName}</span>
                   </div>
-                  <button
-                    type="button"
-                    onClick={() => void handleDelete(memo)}
-                    className="rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-50"
-                  >
-                    삭제
-                  </button>
+                  <div className="flex gap-1">
+                    {editingId === memo.id ? (
+                      <>
+                        <button
+                          type="button"
+                          disabled={busy || editingBody.trim() === ''}
+                          onClick={() => void handleUpdate(memo)}
+                          className="rounded-md bg-blue-600 px-2 py-0.5 text-xs text-white disabled:bg-gray-300"
+                        >
+                          저장
+                        </button>
+                        <button
+                          type="button"
+                          disabled={busy}
+                          onClick={() => {
+                            setEditingId(null)
+                            setEditingBody('')
+                          }}
+                          className="rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-50"
+                        >
+                          취소
+                        </button>
+                      </>
+                    ) : (
+                      <button
+                        type="button"
+                        onClick={() => {
+                          setEditingId(memo.id)
+                          setEditingBody(memo.body)
+                        }}
+                        className="rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-50"
+                      >
+                        수정
+                      </button>
+                    )}
+                    <button
+                      type="button"
+                      onClick={() => void handleDelete(memo)}
+                      className="rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-50"
+                    >
+                      삭제
+                    </button>
+                  </div>
                 </div>
-                <p className="mt-1.5 whitespace-pre-wrap text-sm text-gray-800">{memo.body}</p>
+                {editingId === memo.id ? (
+                  <textarea
+                    aria-label="메모 수정"
+                    rows={3}
+                    value={editingBody}
+                    onChange={(e) => setEditingBody(e.target.value)}
+                    className="mt-1.5 w-full rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+                  />
+                ) : (
+                  <p className="mt-1.5 whitespace-pre-wrap text-sm text-gray-800">{memo.body}</p>
+                )}
               </li>
             ))}
           </ul>
 
frontend/src/help/HelpButton.test.tsx (added)
+++ frontend/src/help/HelpButton.test.tsx
@@ -0,0 +1,71 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import HelpButton from './HelpButton'
+import { HELP } from './helpContent'
+
+describe('HelpButton', () => {
+  it('물음표를 누르면 그 기능의 설명서가 열린다', () => {
+    render(<HelpButton topic="dashboard.kanban" />)
+
+    expect(screen.queryByRole('dialog')).toBeNull()
+    fireEvent.click(screen.getByRole('button', { name: '단계별 현황 (칸반) 도움말' }))
+
+    const dialog = screen.getByRole('dialog')
+    expect(dialog.textContent).toContain('기관 카드를 끌어다 놓아')
+    expect(dialog.textContent).toContain('사용 방법')
+    expect(dialog.textContent).toContain('알아둘 점')
+  })
+
+  it('캡처 이미지를 캡션과 함께 보여준다', () => {
+    render(<HelpButton topic="dashboard.kpi" />)
+    fireEvent.click(screen.getByRole('button', { name: 'KPI 타일 도움말' }))
+
+    const image = screen.getByAltText('대시보드 상단 KPI 타일 7개')
+    expect(image).toHaveAttribute('src', '/help/dashboard-kpi.png')
+    expect(screen.getByText(/왼쪽부터 신청/)).toBeTruthy()
+  })
+
+  it('닫기·바깥 클릭·ESC로 닫힌다', () => {
+    render(<HelpButton topic="system.reset" />)
+    const open = () => fireEvent.click(screen.getByRole('button', { name: '채널 초기화 도움말' }))
+
+    open()
+    fireEvent.click(screen.getByRole('button', { name: '닫기' }))
+    expect(screen.queryByRole('dialog')).toBeNull()
+
+    open()
+    fireEvent.click(screen.getByRole('dialog'))
+    expect(screen.queryByRole('dialog')).toBeNull()
+
+    open()
+    fireEvent.keyDown(window, { key: 'Escape' })
+    expect(screen.queryByRole('dialog')).toBeNull()
+  })
+
+  it('카드나 표 줄 안에 있어도 상위 클릭으로 번지지 않는다', () => {
+    const onRowClick = vi.fn()
+    render(
+      <div onClick={onRowClick}>
+        <HelpButton topic="dashboard.recent" />
+      </div>,
+    )
+
+    fireEvent.click(screen.getByRole('button', { name: '최근 진행 기관 도움말' }))
+
+    expect(screen.getByRole('dialog')).toBeTruthy()
+    expect(onRowClick).not.toHaveBeenCalled()
+  })
+
+  it('없는 topic이면 아무것도 그리지 않는다', () => {
+    const { container } = render(<HelpButton topic="없는.항목" />)
+    expect(container.innerHTML).toBe('')
+  })
+
+  it('모든 도움말 항목은 제목·요약을 갖는다', () => {
+    for (const [key, topic] of Object.entries(HELP)) {
+      expect(topic.title, key).toBeTruthy()
+      expect(topic.summary, key).toBeTruthy()
+      expect(topic.steps?.length ?? topic.cautions?.length ?? 0, key).toBeGreaterThan(0)
+    }
+  })
+})
 
frontend/src/help/HelpButton.tsx (added)
+++ frontend/src/help/HelpButton.tsx
@@ -0,0 +1,134 @@
+import { useEffect, useState } from 'react'
+import { HELP } from './helpContent'
+
+/**
+ * 버튼·영역 옆에 붙는 물음표. 누르면 그 기능의 설명서 모달이 열린다.
+ *
+ * 설명 문구는 helpContent.ts 한 곳에만 둔다 - 화면 곳곳에 흩어 두면 화면을 고쳤을 때
+ * 어떤 설명이 낡았는지 찾을 수 없게 된다.
+ *
+ * 캡처 이미지는 public/help 아래 실제 화면이다. 파일이 아직 없거나 깨져도 설명은
+ * 그대로 읽히도록 이미지 로드 실패는 조용히 감춘다.
+ */
+
+interface Props {
+  /** helpContent.ts의 키. 없는 키면 아무것도 그리지 않는다(잘못된 키로 화면이 깨지지 않게). */
+  topic: string
+}
+
+export default function HelpButton({ topic }: Props) {
+  const [open, setOpen] = useState(false)
+  const help = HELP[topic]
+
+  useEffect(() => {
+    if (!open) {
+      return
+    }
+    const onKey = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') {
+        setOpen(false)
+      }
+    }
+    window.addEventListener('keydown', onKey)
+    return () => window.removeEventListener('keydown', onKey)
+  }, [open])
+
+  if (!help) {
+    return null
+  }
+
+  return (
+    <>
+      <button
+        type="button"
+        aria-label={`${help.title} 도움말`}
+        title={`${help.title} 도움말`}
+        onClick={(e) => {
+          // 카드/표 줄 안에 놓이는 경우가 있어 상위의 클릭(기관 열기 등)까지 번지면 안 된다.
+          e.stopPropagation()
+          setOpen(true)
+        }}
+        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"
+      >
+        ?
+      </button>
+
+      {open && (
+        <div
+          role="dialog"
+          aria-label={`${help.title} 설명서`}
+          onClick={() => setOpen(false)}
+          className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 p-6"
+        >
+          <div
+            onClick={(e) => e.stopPropagation()}
+            className="w-full max-w-4xl rounded-lg bg-white shadow-xl"
+          >
+            <div className="flex items-start justify-between gap-3 border-b border-gray-200 px-5 py-4">
+              <div>
+                <h2 className="text-base font-semibold">{help.title}</h2>
+                <p className="mt-1 text-sm text-gray-600">{help.summary}</p>
+              </div>
+              <button
+                type="button"
+                aria-label="닫기"
+                onClick={() => setOpen(false)}
+                className="shrink-0 rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50"
+              >
+                닫기
+              </button>
+            </div>
+
+            <div className="space-y-5 px-5 py-4">
+              {help.images?.map((image) => (
+                <figure key={image.src} className="space-y-1.5">
+                  <img
+                    src={image.src}
+                    alt={image.alt}
+                    onError={(e) => {
+                      ;(e.currentTarget.parentElement as HTMLElement).style.display = 'none'
+                    }}
+                    // 작은 캡처(경고창 등)가 모달 폭에 맞춰 늘어나면 흐려진다.
+                    // 원본보다 크게 키우지 않고 가운데 정렬만 한다.
+                    className="mx-auto max-h-[26rem] w-auto max-w-full rounded-md border border-gray-200"
+                  />
+                  <figcaption className="text-xs text-gray-500">{image.caption}</figcaption>
+                </figure>
+              ))}
+
+              {help.steps && (
+                <div>
+                  <h3 className="text-sm font-semibold">사용 방법</h3>
+                  <ol className="mt-2 space-y-1.5">
+                    {help.steps.map((step, i) => (
+                      <li key={step} className="flex gap-2 text-sm text-gray-700">
+                        <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">
+                          {i + 1}
+                        </span>
+                        <span>{step}</span>
+                      </li>
+                    ))}
+                  </ol>
+                </div>
+              )}
+
+              {help.cautions && (
+                <div>
+                  <h3 className="text-sm font-semibold text-amber-700">알아둘 점</h3>
+                  <ul className="mt-2 space-y-1.5 rounded-md bg-amber-50 p-3">
+                    {help.cautions.map((caution) => (
+                      <li key={caution} className="flex gap-2 text-sm text-amber-900">
+                        <span aria-hidden="true">·</span>
+                        <span>{caution}</span>
+                      </li>
+                    ))}
+                  </ul>
+                </div>
+              )}
+            </div>
+          </div>
+        </div>
+      )}
+    </>
+  )
+}
 
frontend/src/help/helpContent.ts (added)
+++ frontend/src/help/helpContent.ts
@@ -0,0 +1,386 @@
+/**
+ * 화면별 도움말 본문. 버튼 옆 물음표(HelpButton)가 이 표에서 topic을 찾아 모달에 그린다.
+ *
+ * 문구는 실제 동작을 그대로 적는다 - 시안이나 계획이 아니라 지금 코드가 하는 일이다.
+ * 화면을 고치면 여기도 같이 고쳐야 한다(그래서 설명이 한 곳에만 있다).
+ *
+ * image는 public/help 아래 실제 화면 캡처다. 빌드하면 static/help로 복사돼 /help/*.png로 열린다.
+ */
+
+export interface HelpImage {
+  src: string
+  alt: string
+  /** 캡처 아래에 붙는 한 줄 설명. 어디를 보라는 안내. */
+  caption: string
+}
+
+export interface HelpTopic {
+  title: string
+  /** 한 문장 요약. 모달 맨 위에 크게 나온다. */
+  summary: string
+  images?: HelpImage[]
+  /** 순서가 있는 사용 절차. */
+  steps?: string[]
+  /** 알아둬야 하는 규칙·주의. 경고색으로 나온다. */
+  cautions?: string[]
+}
+
+export const HELP: Record<string, HelpTopic> = {
+  'dashboard.kpi': {
+    title: 'KPI 타일',
+    summary: '전체 기관의 진행 상황을 7개 숫자로 요약한다. 실제 데이터를 그대로 센 값이다.',
+    images: [
+      {
+        src: '/help/dashboard-kpi.png',
+        alt: '대시보드 상단 KPI 타일 7개',
+        caption: '왼쪽부터 신청 · 서류제출 · 권리확인 · 권리처리 · 미해결 RE · 보고서 작성 · 완료',
+      },
+    ],
+    steps: [
+      '신청 — 기관 명부에 등록된 전체 기관 수',
+      '서류제출 — ②목록접수 단계 이상에 도달한 기관 수',
+      '권리확인 / 권리처리 — 전 기관 건수 합계(완료 / 전체)와 진척률',
+      '보고서 작성 — ⑫보고서 작성 단계에 도달한 기관 수',
+      '완료 — ⑪법률검토 최종완료 이상에 도달한 기관 수',
+    ],
+    cautions: [
+      '미해결 RE는 아직 데이터가 없어 항상 0으로 표시된다(자리만 잡아 둔 상태).',
+      '권리확인·권리처리 진척률은 소수점을 버리고 정수로 반올림한다.',
+    ],
+  },
+
+  'dashboard.kanban': {
+    title: '단계별 현황 (칸반)',
+    summary: '기관 카드를 끌어다 놓아 진행단계를 바꾼다. 맨 왼쪽 "단계 미지정"은 채널을 아직 만들지 않은 기관이다.',
+    images: [
+      {
+        src: '/help/dashboard-kanban.png',
+        alt: '단계 미지정 컬럼과 ①신청~ 컬럼들이 있는 칸반',
+        caption: '컬럼은 단계 미지정 + ①~⑫ 순서다. 오른쪽으로 스크롤하면 나머지 단계가 나온다',
+      },
+      {
+        src: '/help/dashboard-drag-warning.png',
+        alt: '채널을 먼저 생성해 주세요 경고창',
+        caption: '단계 미지정 카드를 끌면 이동하지 않고 이 경고창이 뜬다',
+      },
+    ],
+    steps: [
+      '카드를 옮기려는 단계 컬럼 위로 끌어다 놓는다. 놓는 순간 그 기관의 현재 단계가 바뀐다.',
+      '카드를 클릭하면 기관관리 화면으로 넘어가 그 기관을 연다.',
+      '컬럼 아래 [+N건 더보기]를 누르면 그 단계의 나머지 기관이 모두 펼쳐진다.',
+      '위쪽 필터 5개(업무 구간·담당 변호사·현재 단계·진행 상태·기관명)로 카드를 걸러낼 수 있다.',
+    ],
+    cautions: [
+      '단계 미지정 카드는 끌어도 옮겨지지 않는다. 채널이 없으면 게시글·자료·권리검토가 전부 동작하지 않아 단계만 올려도 빈 껍데기가 되기 때문이다. 채널관리에서 채널을 만들면 자동으로 ①신청으로 들어간다.',
+      '단계 미지정 컬럼에는 다른 카드를 놓을 수 없다. 단계를 없애는 기능은 없다(초기화는 시스템관리에서 한다).',
+      '카드의 D+숫자는 현재 단계로 넘어온 뒤 지난 날짜다.',
+    ],
+  },
+
+  'dashboard.recent': {
+    title: '최근 진행 기관',
+    summary: '최근에 변동이 있었던 기관을 위에서부터 보여준다. 기본 8개이며 아래에서 더 펼칠 수 있다.',
+    images: [
+      {
+        src: '/help/dashboard-recent.png',
+        alt: '최근 진행 기관 표와 필터',
+        caption: '필터 5개 + [초기화], 아래에 [더보기]와 [전체 기관 보기]',
+      },
+    ],
+    steps: [
+      '표의 줄을 클릭하면 기관관리 화면으로 넘어가 그 기관을 연다.',
+      '[더보기]를 누르면 8개씩 더 보여준다.',
+      '[전체 기관 보기 →]는 기관관리 화면으로 이동한다.',
+      '[초기화]는 검색어와 필터 5개를 모두 지운다.',
+    ],
+    cautions: [
+      '"최근 변경"은 단계 변경·권리확인/처리 수정·업무메모·담당자 배정 중 가장 최근 시각이다.',
+      '자료접수 상태 필터의 "접수"는 권리확인 자료가 1건 이상 올라온 기관을 뜻한다.',
+    ],
+  },
+
+  'channel.seed': {
+    title: '신청목록 시트 올리기',
+    summary: '엑셀 신청목록 시트를 올려 기관 명부를 만든다. 여러 번 올려도 안전하다.',
+    steps: [
+      '[신청목록 시트 올리기]를 누르고 xlsx/xlsm 파일을 고른다.',
+      '연번1_연번2(예: 001_00)를 기관 코드로 삼아 기관을 만들거나 이미 있으면 갱신한다.',
+      '결과로 생성 / 갱신 / 전체 건수가 표시된다.',
+    ],
+    cautions: [
+      '같은 파일을 다시 올려도 기관이 중복 생성되지 않는다(코드 기준으로 덮어쓴다).',
+      '담당자 정보는 이 파일에서 가져오지 않는다. 담당자는 담당자관리에서만 등록한다.',
+    ],
+  },
+
+  'channel.assign': {
+    title: '담당자 배정',
+    summary: '이미 등록된 담당자를 신청기관 / 문정원 / 아이티앤 / 변호사 네 자리에 배정한다.',
+    images: [
+      {
+        src: '/help/channel-assign.png',
+        alt: '채널관리의 담당자 4카드와 채널 생성 버튼',
+        caption: '카드마다 [선택]으로 담당자를 고르고, [해제]로 비운다',
+      },
+    ],
+    steps: [
+      '카드의 [선택]을 누르면 담당자 목록 모달이 열린다. 이름·소속으로 검색해 고른다.',
+      '변호사 카드에서는 배정일도 함께 입력한다.',
+      '[저장]을 누르면 네 자리 배정이 한 번에 저장된다.',
+    ],
+    cautions: [
+      '여기서는 담당자를 새로 만들 수 없다. 목록에 없으면 담당자관리에서 먼저 등록한다.',
+      '신청기관 담당자가 비어 있으면 채널을 만들 수 없다.',
+    ],
+  },
+
+  'channel.provision': {
+    title: '채널 생성',
+    summary: '기관마다 (문정원) · (법률검토) 두 채널을 만들고, 담당자 계정·최초 안내글까지 한 번에 준비한다.',
+    images: [
+      {
+        src: '/help/channel-provision.png',
+        alt: '담당자 카드 아래의 채널 생성 버튼',
+        caption: '신청기관 담당자가 비어 있으면 버튼이 잠긴다. 누르면 두 채널의 처리 결과가 표시된다',
+      },
+    ],
+    steps: [
+      '담당자 배정을 저장한 뒤 [채널 생성]을 누른다.',
+      '(문정원) 채널과 (법률검토) 채널이 만들어진다.',
+      '담당자의 Mattermost 계정이 없으면 이메일 앞부분을 아이디로 자동 생성해 팀에 넣는다.',
+      '두 채널에 담당자를 넣고, 담당자 정보를 담은 최초 안내글을 올려 고정한다.',
+      '채널이 만들어지면 진행단계가 ①신청으로 자동 시작된다.',
+    ],
+    cautions: [
+      '여러 번 눌러도 안전하다. 이미 있는 채널은 다시 만들지 않고 없는 것만 만든다.',
+      '한쪽만 실패했으면 성공한 채널은 그대로 남는다. 다시 눌러 없는 쪽만 만들면 된다.',
+      '자동 생성 계정의 비밀번호는 공통값이다. 실제 담당자에게 전달 후 변경하도록 안내해야 한다.',
+    ],
+  },
+
+  'org.stage': {
+    title: '진행단계 변경',
+    summary: '12단계 업무 진행 상태를 사람이 직접 바꾼다. 자동으로 넘어가는 일은 없다.',
+    images: [
+      {
+        src: '/help/org-stage.png',
+        alt: '업무단계 현황 스트립과 진행단계 변경 버튼',
+        caption: '완료된 단계는 진하게, 현재 단계는 파랗게, 남은 단계는 회색으로 보인다',
+      },
+    ],
+    steps: [
+      '현재 단계(파란 원)를 클릭하면 다음 단계로 넘어갈지 물어본다.',
+      '특정 단계로 바로 가려면 오른쪽 위 [진행단계 변경]을 눌러 단계를 고른다.',
+      '단계마다 그 단계에 도달한 날짜가 아래에 표시된다.',
+    ],
+    cautions: [
+      '자료를 올리거나 게시글을 써도 단계는 자동으로 바뀌지 않는다(요청자 확정 사항).',
+      '채널을 만들기 전에는 모든 단계가 회색이며 변경할 수 없다.',
+      '단계를 되돌릴 수도 있다. 변경 이력은 타임라인 탭에 모두 남는다.',
+    ],
+  },
+
+  'org.firstPost': {
+    title: '최초 게시글 보기',
+    summary: '두 채널에 고정(핀)된 게시물을 한 번에 확인한다. 채널을 만들 때 올린 최초 안내글이 여기 보인다.',
+    steps: [
+      '[최초 게시글 보기]를 누르면 문정원·법률검토 채널의 고정 게시물을 함께 보여준다.',
+      '[채팅 탭에서 보기]를 누르면 채팅 탭으로 이동한다.',
+    ],
+    cautions: [
+      '채널을 만들기 전에는 버튼이 잠긴다.',
+      '공지를 다른 글로 바꾸면 여기에도 바뀐 글이 보인다(채널에 고정된 글을 그대로 읽기 때문).',
+    ],
+  },
+
+  'org.chat': {
+    title: '채팅 탭',
+    summary: 'Mattermost 채널에 글과 파일을 올린다. (문정원) · (법률검토) 채널을 탭으로 오간다.',
+    images: [
+      {
+        src: '/help/org-chat.png',
+        alt: '채팅 탭 아래쪽 입력창 - 파일 첨부, 공지로 등록, 보내기',
+        caption: '패널 아래 입력창. Enter로 전송, Shift+Enter로 줄바꿈이다',
+      },
+    ],
+    steps: [
+      '[문정원] / [법률검토] 칩으로 채널을 고른다.',
+      '내용을 쓰고 [보내기]를 누른다. [파일 첨부] 또는 창에 파일을 끌어다 놓아 첨부할 수 있다.',
+      '중요한 글은 [공지로 등록]을 누른다. 기존 공지는 해제되고 새 글이 채널 상단에 고정된다.',
+      '공지를 내리려면 [공지 해제]를 누른다.',
+    ],
+    cautions: [
+      '공지는 채널에 하나만 유지된다. 새로 등록하면 이전 공지는 자동으로 풀린다.',
+      '화면은 3초마다 새 글을 확인한다. 다른 사람이 쓴 글도 곧 나타난다.',
+      '올린 파일은 자료 탭에도 함께 나타난다.',
+    ],
+  },
+
+  'org.files': {
+    title: '자료 탭',
+    summary: '두 채널에 올라온 첨부파일을 모아 최신순으로 보여준다.',
+    steps: [
+      '[문정원] / [법률검토] 칩으로 채널을 고른다.',
+      '파일 이름을 클릭하면 내려받는다.',
+      '[새로고침]을 누르면 방금 올린 파일까지 다시 읽는다.',
+    ],
+    cautions: [
+      '여기서 파일을 올리거나 지울 수는 없다. 올리기는 채팅 탭에서 한다.',
+      '목록은 Mattermost 채널을 그대로 읽어 만든다. 채널에서 지운 파일은 여기서도 사라진다.',
+    ],
+  },
+
+  'org.review': {
+    title: '권리확인 탭',
+    summary: '조사원이 만든 권리확인 파일을 올리고, 건별로 변호사 판단을 입력한다.',
+    images: [
+      {
+        src: '/help/org-review.png',
+        alt: '권리확인 목록과 엑셀 업로드·다운로드 버튼',
+        caption: '오른쪽 위 [엑셀 업로드]로 자료를 넣고, 목록에서 건을 클릭해 판단을 입력한다',
+      },
+    ],
+    steps: [
+      '조사원이 보낸 xlsm 파일을 올린다. 1행이 1건으로 들어온다.',
+      '목록에서 건을 클릭하면 상세가 열린다.',
+      '권리확인 대분류·세부를 고르면 처리결과가 자동으로 제안된다. 필요하면 직접 바꾼다.',
+      '의견·판단근거를 쓰고 [저장]한다.',
+      '[엑셀 다운로드]로 지금 상태를 파일로 내린다.',
+    ],
+    cautions: [
+      '같은 파일을 다시 올리면 조사원이 채운 영역은 파일 내용으로 덮어쓰지만, 웹에서 수정한 값은 그대로 보존된다.',
+      '병합 기준은 파일의 행 순서다(순번이 중복된 파일이 있어 순번으로 맞추지 않는다).',
+      '조사원 영역 중에서는 첨부 여부와 기존 공공누리 유형만 웹에서 고칠 수 있다.',
+    ],
+  },
+
+  'org.process': {
+    title: '권리처리 탭',
+    summary: '권리처리 파일을 올리고 건별로 처리 결과를 확정한다.',
+    steps: [
+      '권리처리 xlsm 파일을 올린다.',
+      '건을 클릭해 상세를 열고 최종의견·판단근거를 쓴다.',
+      '[처리등록]을 누르면 처리상태가 처리완료로 바뀌고 시각이 기록된다.',
+      '처리상태·검색으로 목록을 걸러 볼 수 있다.',
+    ],
+    cautions: [
+      '기관 카드와 대시보드의 권리처리 숫자는 처리완료 건수 기준이다.',
+      '권리확인과 마찬가지로 웹에서 수정한 값은 재업로드해도 보존된다.',
+    ],
+  },
+
+  'org.timeline': {
+    title: '타임라인 탭',
+    summary: '이 기관에서 일어난 일을 한 줄씩 시간순으로 모아 보여준다.',
+    steps: [
+      '단계 변경, 파일 등록, 업무메모가 한 줄씩 날짜별로 묶여 나온다.',
+      '왼쪽 색 점으로 종류를 구분한다.',
+    ],
+    cautions: [
+      '읽기 전용이다. 여기서 지우거나 고칠 수는 없다.',
+      'Mattermost 조회가 실패하면 파일 항목만 빠지고 나머지는 그대로 보인다.',
+    ],
+  },
+
+  'org.memo': {
+    title: '업무메모',
+    summary: '기관별 내부 메모를 남긴다. Mattermost로는 나가지 않는다.',
+    steps: [
+      '내용을 쓰고 [등록]한다. 작성자는 로그인한 사용자로 자동 기록된다.',
+      '[수정]을 누르면 그 자리에서 본문을 고칠 수 있다. [저장]으로 반영, [취소]로 되돌린다.',
+      '[삭제]로 메모를 지운다.',
+    ],
+    cautions: [
+      '작성자를 직접 고르는 칸은 없앴다(요청자 확정). 서버가 로그인 사용자를 기록한다.',
+      '수정해도 작성자와 작성시각은 처음 값이 유지된다.',
+      '작성한 메모는 타임라인 탭에도 함께 나타난다.',
+    ],
+  },
+
+  'projects.overview': {
+    title: '사업관리',
+    summary: '3단계 법률검토 대상 기관 현황을 한 표로 보고, 기관마다 연락 이력과 최종 보고서를 관리한다.',
+    steps: [
+      '검색·현재 단계·연락 이력 유무로 기관을 걸러 본다.',
+      '기관명을 클릭하면 기관관리 화면으로 넘어간다.',
+      '[연락·보고서]를 누르면 그 기관의 연락 이력과 보고서를 다루는 창이 열린다.',
+    ],
+    cautions: [
+      '권리확인·권리처리 숫자는 대시보드와 같은 집계를 그대로 쓴다(두 화면 숫자가 어긋나지 않는다).',
+      '대시보드 아래 [사업관리에서 전체 보기]를 누르면 이 화면으로 온다.',
+    ],
+  },
+
+  'projects.contactLog': {
+    title: '신청 확인 연락 이력',
+    summary: '기관에 연락한 내용을 날짜별로 쌓아 둔다. 자료가 자주 바뀌는 부분이라 이력으로 남긴다.',
+    steps: [
+      '연락일·방법(전화/메일/방문/기타)·내용을 넣고 [등록]한다.',
+      '[수정]을 누르면 위 입력칸에 값이 올라온다. 고친 뒤 [수정 저장]을 누른다.',
+      '[삭제]로 잘못 넣은 이력을 지운다.',
+    ],
+    cautions: [
+      '작성자는 로그인한 사용자로 자동 기록되며, 수정해도 처음 작성자가 유지된다.',
+      '목록의 "최근 연락"은 여기 등록한 가장 늦은 연락일이다.',
+    ],
+  },
+
+  'projects.report': {
+    title: '최종 보고서',
+    summary: '기관별 최종 보고서를 올리고 내려받는다. 같은 기관에 여러 번 올려 개정판을 쌓을 수 있다.',
+    steps: [
+      '[보고서 업로드]로 파일을 고른다.',
+      '파일 이름을 클릭하면 내려받는다.',
+      '[삭제]로 잘못 올린 파일을 지운다.',
+    ],
+    cautions: [
+      '보고서는 Mattermost가 아니라 이 시스템 DB에 보관된다 - 채널을 초기화해도 사라지지 않는다.',
+      '한 번에 올릴 수 있는 파일 크기는 20MB까지다.',
+    ],
+  },
+
+  'contacts.manage': {
+    title: '담당자관리',
+    summary: '담당자 정보를 입력하는 유일한 화면이다. 여기서 등록한 사람을 채널관리에서 배정한다.',
+    images: [
+      {
+        src: '/help/contacts.png',
+        alt: '담당자 목록과 회원명단 업로드·담당자 추가 버튼',
+        caption: '위쪽 칩으로 구분을 걸러 본다. 캡처의 이름·연락처는 예시로 바꿔 둔 값이다',
+      },
+    ],
+    steps: [
+      '[담당자 추가]에서 구분·성명·소속·부서·직급·연락처·이메일을 넣고 [등록]한다.',
+      '목록의 [수정] / [삭제]로 고치거나 지운다.',
+      '회원정보 엑셀을 올리면 여러 명을 한 번에 등록한다. 신청기관 담당자는 소속 기관에 자동으로 배정된다.',
+    ],
+    cautions: [
+      '이메일은 Mattermost 계정을 만드는 기준이다. 정확히 넣어야 채널 생성이 제대로 된다.',
+      '이미 기관에 배정된 담당자를 지우면 그 기관의 배정이 비워진다.',
+    ],
+  },
+
+  'system.reset': {
+    title: '채널 초기화',
+    summary: '웹앱이 만든 Mattermost 채널을 보관 처리하고, 기관을 채널 생성 전 상태로 되돌린다.',
+    images: [
+      {
+        src: '/help/system-reset.png',
+        alt: '채널 초기화 대상 목록과 확인 문구 입력',
+        caption: '무엇이 보관되는지 목록으로 먼저 확인한 뒤 실행한다',
+      },
+    ],
+    steps: [
+      '대상 목록에서 어느 기관의 어느 채널이 보관될지 확인한다.',
+      '입력창에 "채널 초기화" 를 그대로 입력한다.',
+      '[채널 초기화 실행]을 누르면 확인창이 한 번 더 뜬다.',
+      '실행 후 보관된 채널 수·초기화된 기관 수와 실패 내역이 표시된다.',
+    ],
+    cautions: [
+      '삭제가 아니라 보관(아카이브)이다. 게시글·파일은 남고 Mattermost 시스템 콘솔에서 되돌릴 수 있다.',
+      '담당자 배정, 권리확인·권리처리 자료, 업무메모, 기관 명부는 지워지지 않는다.',
+      '공용 채널(Town Square·FAQ·대쉬보드·양식 모음·자료실)과 엑셀로 만든 예전 채널은 대상이 아니다. 사용자 계정도 건드리지 않는다.',
+      '한 기관의 채널 중 하나만 실패하면 그 기관의 채널 연결은 그대로 남는다. 다시 실행하면 남은 것만 처리한다.',
+    ],
+  },
+}
frontend/src/stages.ts
--- frontend/src/stages.ts
+++ frontend/src/stages.ts
@@ -20,3 +20,23 @@
 export function stageLabel(stage: number): string {
   return STAGE_LABELS[stage - 1] ?? String(stage)
 }
+
+/**
+ * 12단계를 업무 성격으로 묶은 구간. 대시보드의 "업무 구간" 필터와 칸반 카드 모양
+ * (권리확인 구간은 확인 진척 막대 1줄, 권리처리 구간은 확인+처리 2줄)이 이 묶음을 쓴다.
+ */
+export const STAGE_GROUPS: { key: string; label: string; stages: number[] }[] = [
+  { key: 'intake', label: '접수', stages: [1, 2, 3] },
+  { key: 'assign', label: '배당', stages: [4] },
+  { key: 'check', label: '권리확인', stages: [5, 6, 7] },
+  { key: 'process', label: '권리처리', stages: [8, 9, 10] },
+  { key: 'closing', label: '마감', stages: [11, 12] },
+]
+
+/** 단계 번호가 속한 구간 key. 범위 밖이거나 null이면 null. */
+export function stageGroupKey(stage: number | null): string | null {
+  if (stage === null) {
+    return null
+  }
+  return STAGE_GROUPS.find((group) => group.stages.includes(stage))?.key ?? null
+}
 
src/main/java/kr/itn/itnhub/dashboard/DashboardController.java (added)
+++ src/main/java/kr/itn/itnhub/dashboard/DashboardController.java
@@ -0,0 +1,19 @@
+package kr.itn.itnhub.dashboard;
+
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+public class DashboardController {
+
+    private final DashboardService dashboardService;
+
+    public DashboardController(DashboardService dashboardService) {
+        this.dashboardService = dashboardService;
+    }
+
+    @GetMapping("/api/dashboard")
+    public DashboardResponse dashboard() {
+        return dashboardService.dashboard();
+    }
+}
 
src/main/java/kr/itn/itnhub/dashboard/DashboardMapper.java (added)
+++ src/main/java/kr/itn/itnhub/dashboard/DashboardMapper.java
@@ -0,0 +1,12 @@
+package kr.itn.itnhub.dashboard;
+
+import org.apache.ibatis.annotations.Mapper;
+
+import java.util.List;
+
+@Mapper
+public interface DashboardMapper {
+
+    /** 기관 전체를 연번순으로, 권리확인/권리처리 집계와 단계·최근변동 시각까지 붙여 한 번에 읽는다. */
+    List<DashboardOrgRow> findOrgRows();
+}
 
src/main/java/kr/itn/itnhub/dashboard/DashboardOrgRow.java (added)
+++ src/main/java/kr/itn/itnhub/dashboard/DashboardOrgRow.java
@@ -0,0 +1,32 @@
+package kr.itn.itnhub.dashboard;
+
+/**
+ * 대시보드가 기관 1곳을 그리는 데 필요한 값을 한 줄로 모은 것. 칸반 카드와 "최근 진행 기관"
+ * 표가 같은 행을 쓴다 - 두 곳이 같은 숫자를 보여야 하고, 기관 47곳을 그릴 때마다 기관별로
+ * 집계 쿼리를 다시 날리면 화면 한 번에 100번 넘게 조회하게 되기 때문이다.
+ *
+ * @param hasChannel      문정원/법률검토 채널 중 하나라도 만들어졌는지. 칸반의 "단계 미지정"
+ *                        컬럼은 이 값이 false인 기관만 담는다(요청자 확정).
+ * @param stage           1..12, 채널 생성 전이면 null
+ * @param stageChangedAt  현재 단계로 넘어온 시각(epoch ms). 카드의 D+n 계산용. 이력이 없으면 null
+ * @param lastChangedAt   기관 관련 마지막 변동 시각(epoch ms). 단계변경·권리확인/처리 수정·
+ *                        업무메모·배정 변경 중 가장 최근 것. "최근 진행 기관" 정렬 기준이다
+ * @param reCount         미해결 RE 건수. RE 데이터 모델이 아직 없어 항상 0이다
+ */
+public record DashboardOrgRow(
+        Long id,
+        String orgNo,
+        String orgName,
+        Integer stage,
+        boolean hasChannel,
+        String lawyerName,
+        String mjName,
+        String lawyerAssignedDate,
+        int reviewTotal,
+        int reviewDone,
+        int processTotal,
+        int processDone,
+        Long stageChangedAt,
+        Long lastChangedAt,
+        int reCount) {
+}
 
src/main/java/kr/itn/itnhub/dashboard/DashboardResponse.java (added)
+++ src/main/java/kr/itn/itnhub/dashboard/DashboardResponse.java
@@ -0,0 +1,7 @@
+package kr.itn.itnhub.dashboard;
+
+import java.util.List;
+
+/** {@code GET /api/dashboard} 응답. 화면 세 블록(KPI·칸반·최근 진행 기관)이 이 하나로 다 그려진다. */
+public record DashboardResponse(DashboardSummary summary, List<DashboardOrgRow> orgs) {
+}
 
src/main/java/kr/itn/itnhub/dashboard/DashboardService.java (added)
+++ src/main/java/kr/itn/itnhub/dashboard/DashboardService.java
@@ -0,0 +1,70 @@
+package kr.itn.itnhub.dashboard;
+
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/**
+ * 대시보드 화면 하나가 쓰는 서비스. 집계 쿼리는 {@link DashboardMapper#findOrgRows()} 한 번이고,
+ * 상단 KPI는 그 결과를 Java에서 접어 만든다 - 같은 원본에서 계산해야 타일 숫자와 아래 표/칸반
+ * 숫자가 어긋나지 않는다(쿼리를 따로 두면 두 결과 사이에 시차가 생긴다).
+ */
+@Service
+public class DashboardService {
+
+    /** ②목록접수. 시안 KPI "서류제출"의 기준으로 쓴다. */
+    private static final int STAGE_DOC_SUBMITTED = 2;
+    /** ⑪법률검토 최종완료. 시안 KPI "완료"의 기준. */
+    private static final int STAGE_COMPLETED = 11;
+    /** ⑫보고서 작성. */
+    private static final int STAGE_REPORT = 12;
+
+    private final DashboardMapper mapper;
+
+    public DashboardService(DashboardMapper mapper) {
+        this.mapper = mapper;
+    }
+
+    public DashboardResponse dashboard() {
+        List<DashboardOrgRow> rows = mapper.findOrgRows();
+        return new DashboardResponse(summarize(rows), rows);
+    }
+
+    DashboardSummary summarize(List<DashboardOrgRow> rows) {
+        int docSubmitted = 0;
+        int reviewTotal = 0;
+        int reviewDone = 0;
+        int processTotal = 0;
+        int processDone = 0;
+        int unresolvedRe = 0;
+        int reportWriting = 0;
+        int completed = 0;
+
+        for (DashboardOrgRow row : rows) {
+            reviewTotal += row.reviewTotal();
+            reviewDone += row.reviewDone();
+            processTotal += row.processTotal();
+            processDone += row.processDone();
+            unresolvedRe += row.reCount();
+
+            Integer stage = row.stage();
+            if (stage == null) {
+                continue;
+            }
+            if (stage >= STAGE_DOC_SUBMITTED) {
+                docSubmitted++;
+            }
+            if (stage >= STAGE_COMPLETED) {
+                completed++;
+            }
+            if (stage >= STAGE_REPORT) {
+                reportWriting++;
+            }
+        }
+
+        // 신청 기관 수는 명부에 등록된 기관 전체다 - 명부에 올라온 것 자체가 신청이므로
+        // 단계값 유무와 무관하다(채널 생성 전 기관도 신청은 한 상태다).
+        return new DashboardSummary(rows.size(), docSubmitted, reviewTotal, reviewDone,
+                processTotal, processDone, unresolvedRe, reportWriting, completed);
+    }
+}
 
src/main/java/kr/itn/itnhub/dashboard/DashboardSummary.java (added)
+++ src/main/java/kr/itn/itnhub/dashboard/DashboardSummary.java
@@ -0,0 +1,25 @@
+package kr.itn.itnhub.dashboard;
+
+/**
+ * 대시보드 상단 KPI 타일 7개. 모두 "그 단계에 도달한 기관 수" 누적 기준이고,
+ * 권리확인/권리처리만 전 기관 건수 합계다.
+ *
+ * <p>시안에는 이름만 있고 계산식이 없던 항목이 셋 있어 다음과 같이 정했다(변경 시 여기와
+ * {@link DashboardService#summarize} 한 곳만 고치면 된다):</p>
+ * <ul>
+ *   <li>{@code docSubmitted}(서류제출) - 12단계 라벨에 없는 이름이라 ②목록접수 도달(stage &gt;= 2)로 본다</li>
+ *   <li>{@code completed}(완료) - ⑪법률검토 최종완료 도달(stage &gt;= 11)</li>
+ *   <li>{@code unresolvedRe}(미해결 RE) - RE 테이블이 아직 없어 항상 0. 자리만 잡아 둔다</li>
+ * </ul>
+ */
+public record DashboardSummary(
+        int applied,
+        int docSubmitted,
+        int reviewTotal,
+        int reviewDone,
+        int processTotal,
+        int processDone,
+        int unresolvedRe,
+        int reportWriting,
+        int completed) {
+}
src/main/java/kr/itn/itnhub/mattermost/MattermostClient.java
--- src/main/java/kr/itn/itnhub/mattermost/MattermostClient.java
+++ src/main/java/kr/itn/itnhub/mattermost/MattermostClient.java
@@ -51,4 +51,14 @@
 
     /** 사용자를 채널에 추가한다. 이미 멤버여도 안전하다(idempotent). */
     void addUserToChannel(String channelId, String userId);
+
+    /**
+     * 채널을 보관 처리한다. Mattermost의 {@code DELETE /channels/{id}}는 삭제가 아니라
+     * 아카이브라서 게시글·파일이 남고 시스템 콘솔에서 되돌릴 수 있다.
+     *
+     * <p>영구 삭제({@code ?permanent=true})는 일부러 쓰지 않는다. 운영 서버의
+     * {@code ServiceSettings.EnableAPIChannelDeletion}이 꺼져 있어 어차피 거부되고,
+     * 켜더라도 복구가 불가능하기 때문이다.</p>
+     */
+    void archiveChannel(String channelId);
 }
src/main/java/kr/itn/itnhub/mattermost/MattermostRestClient.java
--- src/main/java/kr/itn/itnhub/mattermost/MattermostRestClient.java
+++ src/main/java/kr/itn/itnhub/mattermost/MattermostRestClient.java
@@ -390,6 +390,18 @@
     }
 
     @Override
+    public void archiveChannel(String channelId) {
+        try {
+            rest.delete()
+                    .uri("/api/v4/channels/{channelId}", channelId)
+                    .retrieve()
+                    .toBodilessEntity();
+        } catch (RestClientException e) {
+            throw new MattermostException("채널 보관 실패: " + channelId, e);
+        }
+    }
+
+    @Override
     public List<PostView> getPinnedPosts(String channelId) {
         try {
             JsonNode body = rest.get()
src/main/java/kr/itn/itnhub/memo/MemoRequest.java
--- src/main/java/kr/itn/itnhub/memo/MemoRequest.java
+++ src/main/java/kr/itn/itnhub/memo/MemoRequest.java
@@ -1,9 +1,10 @@
 package kr.itn.itnhub.memo;
 
 import jakarta.validation.constraints.NotBlank;
-import jakarta.validation.constraints.Size;
 
-public record MemoRequest(
-        @NotBlank @Size(max = 100) String contactName,
-        @NotBlank String body) {
+/**
+ * 업무메모 등록/수정 요청. 작성자는 담지 않는다 - 요청자 확정대로 로그인한 사용자를
+ * 서버가 직접 기록하기 때문이다(클라이언트가 남의 이름으로 남기는 것도 막힌다).
+ */
+public record MemoRequest(@NotBlank String body) {
 }
src/main/java/kr/itn/itnhub/memo/WorkMemoController.java
--- src/main/java/kr/itn/itnhub/memo/WorkMemoController.java
+++ src/main/java/kr/itn/itnhub/memo/WorkMemoController.java
@@ -6,10 +6,12 @@
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PathVariable;
 import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.ResponseStatus;
 import org.springframework.web.bind.annotation.RestController;
 
+import java.security.Principal;
 import java.util.List;
 
 /** 기관관리 상세의 업무메모 탭이 부르는 엔드포인트. */
@@ -27,9 +29,17 @@
         return memoService.list(id);
     }
 
+    /** 작성자는 본문에서 받지 않고 로그인 사용자(principal)를 서버가 직접 기록한다. */
     @PostMapping("/api/orgs/{id}/memos")
-    public WorkMemo create(@PathVariable Long id, @Valid @RequestBody MemoRequest request) {
-        return memoService.create(id, request);
+    public WorkMemo create(@PathVariable Long id, @Valid @RequestBody MemoRequest request,
+                           Principal principal) {
+        return memoService.create(id, request, principal.getName());
+    }
+
+    @PutMapping("/api/orgs/{id}/memos/{memoId}")
+    public WorkMemo update(@PathVariable Long id, @PathVariable Long memoId,
+                           @Valid @RequestBody MemoRequest request) {
+        return memoService.update(id, memoId, request);
     }
 
     @ResponseStatus(HttpStatus.NO_CONTENT)
src/main/java/kr/itn/itnhub/memo/WorkMemoMapper.java
--- src/main/java/kr/itn/itnhub/memo/WorkMemoMapper.java
+++ src/main/java/kr/itn/itnhub/memo/WorkMemoMapper.java
@@ -18,4 +18,7 @@
      * 삭제되지 않게 한다(다른 기관 URL로 삭제 시도하는 실수를 막는다).
      */
     int delete(@Param("orgId") Long orgId, @Param("id") Long id);
+
+    /** 본문만 고친다. 작성자와 작성시각은 처음 남긴 값을 유지한다. */
+    int updateBody(@Param("orgId") Long orgId, @Param("id") Long id, @Param("body") String body);
 }
src/main/java/kr/itn/itnhub/memo/WorkMemoService.java
--- src/main/java/kr/itn/itnhub/memo/WorkMemoService.java
+++ src/main/java/kr/itn/itnhub/memo/WorkMemoService.java
@@ -27,13 +27,17 @@
         return memoMapper.findByOrg(orgId);
     }
 
+    /**
+     * 메모를 남긴다. 작성자는 화면에서 고르지 않고 <b>로그인한 사용자</b>를 그대로 기록한다
+     * (요청자 확정: "담당자 입력란은 필요없고 로그인한 사람이 자동으로 표출되면 된다").
+     */
     @Transactional
-    public WorkMemo create(Long orgId, MemoRequest request) {
+    public WorkMemo create(Long orgId, MemoRequest request, String author) {
         requireOrg(orgId);
 
         WorkMemo memo = new WorkMemo();
         memo.setOrgId(orgId);
-        memo.setContactName(request.contactName());
+        memo.setContactName(author);
         memo.setBody(request.body());
         memoMapper.insert(memo);
 
@@ -41,6 +45,21 @@
         return memoMapper.findByOrg(orgId).get(0);
     }
 
+    /** 본문만 고친다. 작성자·작성시각은 처음 값을 유지한다. */
+    @Transactional
+    public WorkMemo update(Long orgId, Long memoId, MemoRequest request) {
+        requireOrg(orgId);
+
+        int updated = memoMapper.updateBody(orgId, memoId, request.body());
+        if (updated == 0) {
+            throw new OrgNotFoundException("메모를 찾을 수 없습니다: " + memoId);
+        }
+        return memoMapper.findByOrg(orgId).stream()
+                .filter(memo -> memo.getId().equals(memoId))
+                .findFirst()
+                .orElseThrow(() -> new OrgNotFoundException("메모를 찾을 수 없습니다: " + memoId));
+    }
+
     @Transactional
     public void delete(Long orgId, Long memoId) {
         requireOrg(orgId);
src/main/java/kr/itn/itnhub/org/OrganizationMapper.java
--- src/main/java/kr/itn/itnhub/org/OrganizationMapper.java
+++ src/main/java/kr/itn/itnhub/org/OrganizationMapper.java
@@ -36,4 +36,13 @@
 
     int updateStage(@Param("id") Long id,
                     @Param("stage") Integer stage);
+
+    /**
+     * 채널 초기화. 채널 ID 2개와 진행단계를 한 번에 비운다.
+     *
+     * <p>단계까지 같이 비우는 이유는 "채널 있음 ⇔ 단계 있음" 불변식 때문이다(V11 참고).
+     * 채널만 지우고 단계를 남기면 대시보드 칸반에서 채널 없는 기관이 ①신청 컬럼에 남아
+     * 드래그도 안 되는 이상한 상태가 된다.</p>
+     */
+    int clearChannels(@Param("id") Long id);
 }
 
src/main/java/kr/itn/itnhub/process/BulkProcessingRequest.java (added)
+++ src/main/java/kr/itn/itnhub/process/BulkProcessingRequest.java
@@ -0,0 +1,14 @@
+package kr.itn.itnhub.process;
+
+import java.util.List;
+
+/**
+ * 권리처리 목록의 일괄 등록 요청. 체크한 건들에 같은 값을 한 번에 넣는다.
+ * 비워 보낸 항목(null/빈 문자열)은 기존 값을 유지한다.
+ */
+public record BulkProcessingRequest(
+        List<Long> ids,
+        String processStatus,
+        String judgedKoglType,
+        String finalOpinion) {
+}
src/main/java/kr/itn/itnhub/process/ProcessController.java
--- src/main/java/kr/itn/itnhub/process/ProcessController.java
+++ src/main/java/kr/itn/itnhub/process/ProcessController.java
@@ -55,13 +55,31 @@
         }
     }
 
+    /** keyword는 통합검색, site/board/title은 열별 조건(서로 AND). status는 처리상태 필터다. */
     @GetMapping("/api/orgs/{id}/process")
     public ProcessPage list(@PathVariable("id") Long orgId,
                              @RequestParam(required = false) String keyword,
+                             @RequestParam(required = false) String site,
+                             @RequestParam(required = false) String board,
+                             @RequestParam(required = false) String title,
                              @RequestParam(required = false) String status,
                              @RequestParam(defaultValue = "0") int page,
                              @RequestParam(defaultValue = "30") int size) {
-        return processService.list(orgId, keyword, status, page, size);
+        return processService.list(orgId, keyword, site, board, title, status, page, size);
+    }
+
+    /** 목록에서 체크한 건들에 같은 값을 한 번에 반영한다. */
+    @PutMapping("/api/orgs/{id}/process/bulk")
+    public java.util.Map<String, Integer> updateBulk(@PathVariable("id") Long orgId,
+                                                     @RequestBody BulkProcessingRequest request) {
+        return java.util.Map.of("updated", processService.updateBulk(orgId, request));
+    }
+
+    /** 목록에서 체크한 건들을 지운다. */
+    @PostMapping("/api/orgs/{id}/process/bulk-delete")
+    public java.util.Map<String, Integer> deleteBulk(@PathVariable("id") Long orgId,
+                                                     @RequestBody java.util.List<Long> ids) {
+        return java.util.Map.of("deleted", processService.deleteBulk(orgId, ids));
     }
 
     @GetMapping("/api/orgs/{id}/process/{itemId}")
src/main/java/kr/itn/itnhub/process/ProcessItemMapper.java
--- src/main/java/kr/itn/itnhub/process/ProcessItemMapper.java
+++ src/main/java/kr/itn/itnhub/process/ProcessItemMapper.java
@@ -13,11 +13,24 @@
      * "DONE"=처리완료, "PENDING"=그 외(null/빈/미처리)만.
      */
     List<ProcessItem> findPage(@Param("orgId") Long orgId, @Param("keyword") String keyword,
+                                @Param("site") String site, @Param("board") String board,
+                                @Param("title") String title,
                                 @Param("status") String status, @Param("offset") int offset,
                                 @Param("limit") int limit);
 
     /** keyword/status로 필터링된 건수 - 필터별 페이지 경계 계산용(현재 응답의 total과는 별개). */
-    int countByOrg(@Param("orgId") Long orgId, @Param("keyword") String keyword, @Param("status") String status);
+    int countByOrg(@Param("orgId") Long orgId, @Param("keyword") String keyword,
+                   @Param("site") String site, @Param("board") String board,
+                   @Param("title") String title, @Param("status") String status);
+
+    /** 여러 건에 같은 값을 한 번에 반영한다(일괄 등록). null 항목은 기존 값을 유지한다. */
+    int updateProcessingBulk(@Param("orgId") Long orgId, @Param("ids") List<Long> ids,
+                             @Param("processStatus") String processStatus,
+                             @Param("judgedKoglType") String judgedKoglType,
+                             @Param("finalOpinion") String finalOpinion);
+
+    /** 일괄 삭제. */
+    int deleteByIds(@Param("orgId") Long orgId, @Param("ids") List<Long> ids);
 
     /** 다운로드/재업로드 판정용으로 페이징 없이 순번순 전체를 읽는다. */
     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
+++ src/main/java/kr/itn/itnhub/process/ProcessService.java
@@ -40,20 +40,52 @@
         this.orgMapper = orgMapper;
     }
 
-    public ProcessPage list(Long orgId, String keyword, String status, int page, int size) {
+    public ProcessPage list(Long orgId, String keyword, String site, String board, String title,
+                            String status, int page, int size) {
         requireOrg(orgId);
         String kw = normalizeKeyword(keyword);
+        String siteFilter = normalizeKeyword(site);
+        String boardFilter = normalizeKeyword(board);
+        String titleFilter = normalizeKeyword(title);
         String st = normalizeStatus(status);
         int safePage = Math.max(page, 0);
         int safeSize = size <= 0 ? 30 : size;
         int offset = safePage * safeSize;
 
-        List<ProcessItem> items = mapper.findPage(orgId, kw, st, offset, safeSize);
+        List<ProcessItem> items =
+                mapper.findPage(orgId, kw, siteFilter, boardFilter, titleFilter, st, offset, safeSize);
         // total/done은 검색어·상태 필터와 무관한 기관 전체 진행률이다(헤더/통계 카드용 - ProcessPage 참고).
         ProcessStats stats = mapper.stats(orgId);
         return new ProcessPage(items, stats.total(), stats.done(), safePage, safeSize);
     }
 
+    /** 목록에서 고른 여러 건에 같은 값을 한 번에 반영한다. 화면에서 비워 둔 항목은 유지된다. */
+    @Transactional
+    public int updateBulk(Long orgId, BulkProcessingRequest request) {
+        requireOrg(orgId);
+        if (request.ids() == null || request.ids().isEmpty()) {
+            return 0;
+        }
+        return mapper.updateProcessingBulk(orgId, request.ids(),
+                blankToNull(request.processStatus()),
+                blankToNull(request.judgedKoglType()),
+                blankToNull(request.finalOpinion()));
+    }
+
+    /** 목록에서 고른 여러 건을 지운다. */
+    @Transactional
+    public int deleteBulk(Long orgId, List<Long> ids) {
+        requireOrg(orgId);
+        if (ids == null || ids.isEmpty()) {
+            return 0;
+        }
+        return mapper.deleteByIds(orgId, ids);
+    }
+
+    private String blankToNull(String value) {
+        return value == null || value.isBlank() ? null : value;
+    }
+
     public ProcessItem get(Long orgId, Long itemId) {
         requireOrg(orgId);
         return requireItem(orgId, itemId);
 
src/main/java/kr/itn/itnhub/project/ContactLog.java (added)
+++ src/main/java/kr/itn/itnhub/project/ContactLog.java
@@ -0,0 +1,18 @@
+package kr.itn.itnhub.project;
+
+/**
+ * 신청 확인 연락 이력 1건. 작성자는 화면에서 고르지 않고 로그인 사용자를 서버가 기록한다
+ * (업무메모와 같은 규칙).
+ *
+ * @param contactedOn 연락한 날짜(YYYY-MM-DD 문자열로 오간다 - 날짜 계산이 필요 없고 화면 표기가 전부다)
+ * @param createdAt   기록 시각(epoch ms)
+ */
+public record ContactLog(
+        Long id,
+        Long orgId,
+        String contactedOn,
+        String method,
+        String summary,
+        String author,
+        Long createdAt) {
+}
 
src/main/java/kr/itn/itnhub/project/ContactLogRequest.java (added)
+++ src/main/java/kr/itn/itnhub/project/ContactLogRequest.java
@@ -0,0 +1,11 @@
+package kr.itn.itnhub.project;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+/** 연락 이력 등록/수정 요청. 작성자는 서버가 채운다. */
+public record ContactLogRequest(
+        @NotBlank String contactedOn,
+        @NotBlank @Size(max = 20) String method,
+        @NotBlank @Size(max = 1000) String summary) {
+}
 
src/main/java/kr/itn/itnhub/project/OrgReport.java (added)
+++ src/main/java/kr/itn/itnhub/project/OrgReport.java
@@ -0,0 +1,12 @@
+package kr.itn.itnhub.project;
+
+/** 기관별 최종 보고서 목록 항목. 파일 내용(content)은 담지 않는다 - 목록에서는 필요 없다. */
+public record OrgReport(
+        Long id,
+        Long orgId,
+        String fileName,
+        String contentType,
+        Long byteSize,
+        String uploadedBy,
+        Long uploadedAt) {
+}
 
src/main/java/kr/itn/itnhub/project/ProjectController.java (added)
+++ src/main/java/kr/itn/itnhub/project/ProjectController.java
@@ -0,0 +1,109 @@
+package kr.itn.itnhub.project;
+
+import jakarta.validation.Valid;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestPart;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.Principal;
+import java.util.List;
+import java.util.Map;
+
+/** 사업관리 화면이 쓰는 엔드포인트. 기관 현황 목록 + 연락 이력 + 최종 보고서. */
+@RestController
+public class ProjectController {
+
+    private final ProjectService projectService;
+
+    public ProjectController(ProjectService projectService) {
+        this.projectService = projectService;
+    }
+
+    @GetMapping("/api/projects")
+    public List<ProjectRow> rows() {
+        return projectService.rows();
+    }
+
+    @GetMapping("/api/orgs/{id}/contact-logs")
+    public List<ContactLog> logs(@PathVariable("id") Long orgId) {
+        return projectService.logs(orgId);
+    }
+
+    /** 작성자는 업무메모와 마찬가지로 로그인 사용자를 서버가 기록한다. */
+    @PostMapping("/api/orgs/{id}/contact-logs")
+    public List<ContactLog> addLog(@PathVariable("id") Long orgId,
+                                   @Valid @RequestBody ContactLogRequest request,
+                                   Principal principal) {
+        return projectService.addLog(orgId, request, principal.getName());
+    }
+
+    @PutMapping("/api/orgs/{id}/contact-logs/{logId}")
+    public List<ContactLog> updateLog(@PathVariable("id") Long orgId, @PathVariable Long logId,
+                                      @Valid @RequestBody ContactLogRequest request) {
+        return projectService.updateLog(orgId, logId, request);
+    }
+
+    @ResponseStatus(HttpStatus.NO_CONTENT)
+    @DeleteMapping("/api/orgs/{id}/contact-logs/{logId}")
+    public void deleteLog(@PathVariable("id") Long orgId, @PathVariable Long logId) {
+        projectService.deleteLog(orgId, logId);
+    }
+
+    @GetMapping("/api/orgs/{id}/reports")
+    public List<OrgReport> reports(@PathVariable("id") Long orgId) {
+        return projectService.reports(orgId);
+    }
+
+    @PostMapping("/api/orgs/{id}/reports")
+    public List<OrgReport> addReport(@PathVariable("id") Long orgId,
+                                     @RequestPart("file") MultipartFile file,
+                                     Principal principal) throws IOException {
+        String name = file.getOriginalFilename() == null ? "report" : file.getOriginalFilename();
+        return projectService.addReport(orgId, name, file.getContentType(), file.getBytes(),
+                principal.getName());
+    }
+
+    /** 한글 파일명이 깨지지 않게 ChannelFeedController.download와 같은 RFC 5987 인코딩을 쓴다. */
+    @GetMapping("/api/orgs/{id}/reports/{reportId}")
+    public ResponseEntity<byte[]> downloadReport(@PathVariable("id") Long orgId,
+                                                 @PathVariable Long reportId) {
+        ProjectMapper.ReportContent report = projectService.reportContent(orgId, reportId);
+        String encodedName = URLEncoder.encode(report.fileName(), StandardCharsets.UTF_8)
+                .replace("+", "%20");
+        MediaType type = report.contentType() == null || report.contentType().isBlank()
+                ? MediaType.APPLICATION_OCTET_STREAM
+                : MediaType.parseMediaType(report.contentType());
+
+        return ResponseEntity.ok()
+                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedName)
+                .contentType(type)
+                .body(report.content());
+    }
+
+    @ResponseStatus(HttpStatus.NO_CONTENT)
+    @DeleteMapping("/api/orgs/{id}/reports/{reportId}")
+    public void deleteReport(@PathVariable("id") Long orgId, @PathVariable Long reportId) {
+        projectService.deleteReport(orgId, reportId);
+    }
+
+    @ResponseStatus(HttpStatus.BAD_REQUEST)
+    @ExceptionHandler(IllegalArgumentException.class)
+    public Map<String, String> handleBadRequest(IllegalArgumentException e) {
+        return Map.of("message", e.getMessage());
+    }
+}
 
src/main/java/kr/itn/itnhub/project/ProjectMapper.java (added)
+++ src/main/java/kr/itn/itnhub/project/ProjectMapper.java
@@ -0,0 +1,43 @@
+package kr.itn.itnhub.project;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+@Mapper
+public interface ProjectMapper {
+
+    /** 기관별 연락이력 건수·최근 연락일·보고서 건수. 사업관리 목록에 붙일 값만 모아 한 번에 읽는다. */
+    List<ProjectSideInfo> findSideInfo();
+
+    List<ContactLog> findLogsByOrg(@Param("orgId") Long orgId);
+
+    int insertLog(@Param("orgId") Long orgId, @Param("contactedOn") String contactedOn,
+                  @Param("method") String method, @Param("summary") String summary,
+                  @Param("author") String author);
+
+    int updateLog(@Param("orgId") Long orgId, @Param("id") Long id,
+                  @Param("contactedOn") String contactedOn, @Param("method") String method,
+                  @Param("summary") String summary);
+
+    int deleteLog(@Param("orgId") Long orgId, @Param("id") Long id);
+
+    List<OrgReport> findReportsByOrg(@Param("orgId") Long orgId);
+
+    int insertReport(@Param("orgId") Long orgId, @Param("fileName") String fileName,
+                     @Param("contentType") String contentType, @Param("byteSize") long byteSize,
+                     @Param("content") byte[] content, @Param("uploadedBy") String uploadedBy);
+
+    /** 다운로드용. 파일 내용까지 읽는다. */
+    ReportContent findReportContent(@Param("orgId") Long orgId, @Param("id") Long id);
+
+    int deleteReport(@Param("orgId") Long orgId, @Param("id") Long id);
+
+    /** @param lastContactedOn 연락 이력이 없으면 null */
+    record ProjectSideInfo(Long orgId, String lastContactedOn, int contactCount, int reportCount) {
+    }
+
+    record ReportContent(String fileName, String contentType, byte[] content) {
+    }
+}
 
src/main/java/kr/itn/itnhub/project/ProjectRow.java (added)
+++ src/main/java/kr/itn/itnhub/project/ProjectRow.java
@@ -0,0 +1,16 @@
+package kr.itn.itnhub.project;
+
+import kr.itn.itnhub.dashboard.DashboardOrgRow;
+
+/**
+ * 사업관리 목록 1행. 기관 현황은 대시보드가 쓰는 집계 행을 그대로 재사용하고(같은 숫자를
+ * 두 화면이 따로 세지 않도록), 사업관리에서만 쓰는 연락이력·보고서 정보만 덧붙인다.
+ *
+ * @param lastContactedOn 가장 최근 연락 날짜. 연락 이력이 없으면 null
+ */
+public record ProjectRow(
+        DashboardOrgRow org,
+        String lastContactedOn,
+        int contactCount,
+        int reportCount) {
+}
 
src/main/java/kr/itn/itnhub/project/ProjectService.java (added)
+++ src/main/java/kr/itn/itnhub/project/ProjectService.java
@@ -0,0 +1,124 @@
+package kr.itn.itnhub.project;
+
+import kr.itn.itnhub.dashboard.DashboardOrgRow;
+import kr.itn.itnhub.dashboard.DashboardService;
+import kr.itn.itnhub.org.OrgNotFoundException;
+import kr.itn.itnhub.org.OrganizationMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 사업관리 화면. 3단계 법률검토로 진행할 기관 현황을 한 곳에 모아 보여주고, 기관마다
+ * "신청 확인 연락" 이력과 최종 보고서를 관리한다(요청자 요구: 별도 메뉴로 구축 + 연락 부분 이력관리
+ * + 기관별 최종 보고서 업로드).
+ *
+ * <p>기관 현황 숫자는 대시보드와 같은 집계를 그대로 쓴다 - 두 화면이 다른 숫자를 보여주면
+ * 어느 쪽이 맞는지 확인할 방법이 없기 때문이다.</p>
+ */
+@Service
+public class ProjectService {
+
+    private final DashboardService dashboardService;
+    private final ProjectMapper mapper;
+    private final OrganizationMapper orgMapper;
+
+    public ProjectService(DashboardService dashboardService, ProjectMapper mapper,
+                          OrganizationMapper orgMapper) {
+        this.dashboardService = dashboardService;
+        this.mapper = mapper;
+        this.orgMapper = orgMapper;
+    }
+
+    public List<ProjectRow> rows() {
+        Map<Long, ProjectMapper.ProjectSideInfo> side = new HashMap<>();
+        for (ProjectMapper.ProjectSideInfo info : mapper.findSideInfo()) {
+            side.put(info.orgId(), info);
+        }
+
+        List<DashboardOrgRow> orgs = dashboardService.dashboard().orgs();
+        List<ProjectRow> rows = new ArrayList<>(orgs.size());
+        for (DashboardOrgRow org : orgs) {
+            ProjectMapper.ProjectSideInfo info = side.get(org.id());
+            rows.add(new ProjectRow(org,
+                    info == null ? null : info.lastContactedOn(),
+                    info == null ? 0 : info.contactCount(),
+                    info == null ? 0 : info.reportCount()));
+        }
+        return rows;
+    }
+
+    public List<ContactLog> logs(Long orgId) {
+        requireOrg(orgId);
+        return mapper.findLogsByOrg(orgId);
+    }
+
+    @Transactional
+    public List<ContactLog> addLog(Long orgId, ContactLogRequest request, String author) {
+        requireOrg(orgId);
+        mapper.insertLog(orgId, request.contactedOn(), request.method(), request.summary(), author);
+        return mapper.findLogsByOrg(orgId);
+    }
+
+    @Transactional
+    public List<ContactLog> updateLog(Long orgId, Long logId, ContactLogRequest request) {
+        requireOrg(orgId);
+        if (mapper.updateLog(orgId, logId, request.contactedOn(), request.method(),
+                request.summary()) == 0) {
+            throw new OrgNotFoundException("연락 이력을 찾을 수 없습니다: " + logId);
+        }
+        return mapper.findLogsByOrg(orgId);
+    }
+
+    @Transactional
+    public void deleteLog(Long orgId, Long logId) {
+        requireOrg(orgId);
+        if (mapper.deleteLog(orgId, logId) == 0) {
+            throw new OrgNotFoundException("연락 이력을 찾을 수 없습니다: " + logId);
+        }
+    }
+
+    public List<OrgReport> reports(Long orgId) {
+        requireOrg(orgId);
+        return mapper.findReportsByOrg(orgId);
+    }
+
+    /** 같은 기관에 여러 번 올릴 수 있다(개정판). 최신순으로 목록에 쌓인다. */
+    @Transactional
+    public List<OrgReport> addReport(Long orgId, String fileName, String contentType,
+                                     byte[] content, String uploadedBy) {
+        requireOrg(orgId);
+        if (content == null || content.length == 0) {
+            throw new IllegalArgumentException("빈 파일은 올릴 수 없습니다.");
+        }
+        mapper.insertReport(orgId, fileName, contentType, content.length, content, uploadedBy);
+        return mapper.findReportsByOrg(orgId);
+    }
+
+    public ProjectMapper.ReportContent reportContent(Long orgId, Long reportId) {
+        requireOrg(orgId);
+        ProjectMapper.ReportContent content = mapper.findReportContent(orgId, reportId);
+        if (content == null) {
+            throw new OrgNotFoundException("보고서를 찾을 수 없습니다: " + reportId);
+        }
+        return content;
+    }
+
+    @Transactional
+    public void deleteReport(Long orgId, Long reportId) {
+        requireOrg(orgId);
+        if (mapper.deleteReport(orgId, reportId) == 0) {
+            throw new OrgNotFoundException("보고서를 찾을 수 없습니다: " + reportId);
+        }
+    }
+
+    private void requireOrg(Long orgId) {
+        if (orgMapper.findById(orgId) == null) {
+            throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
+        }
+    }
+}
 
src/main/java/kr/itn/itnhub/review/BulkJudgmentRequest.java (added)
+++ src/main/java/kr/itn/itnhub/review/BulkJudgmentRequest.java
@@ -0,0 +1,16 @@
+package kr.itn.itnhub.review;
+
+import java.util.List;
+
+/**
+ * 권리확인 목록의 일괄 등록 요청. 체크한 건들에 같은 값을 한 번에 넣는다.
+ *
+ * <p>값 항목은 비워서 보내도 된다(null/빈 문자열). 채운 항목만 반영되고 나머지는 건드리지 않는다 -
+ * "체크한 것은 전부 0유형, 의견란에는 ooo" 처럼 일부 항목만 맞추는 쓰임새가 대부분이기 때문이다.</p>
+ */
+public record BulkJudgmentRequest(
+        List<Long> ids,
+        String reviewResult,
+        String judgedKoglType,
+        String opinion) {
+}
src/main/java/kr/itn/itnhub/review/ReviewController.java
--- src/main/java/kr/itn/itnhub/review/ReviewController.java
+++ src/main/java/kr/itn/itnhub/review/ReviewController.java
@@ -55,12 +55,32 @@
         }
     }
 
+    /**
+     * keyword는 사이트명/게시판명/제목 통합검색, site/board/title은 열별 조건(서로 AND)이다.
+     */
     @GetMapping("/api/orgs/{id}/review")
     public ReviewPage list(@PathVariable("id") Long orgId,
                             @RequestParam(required = false) String keyword,
+                            @RequestParam(required = false) String site,
+                            @RequestParam(required = false) String board,
+                            @RequestParam(required = false) String title,
                             @RequestParam(defaultValue = "0") int page,
                             @RequestParam(defaultValue = "30") int size) {
-        return reviewService.list(orgId, keyword, page, size);
+        return reviewService.list(orgId, keyword, site, board, title, page, size);
+    }
+
+    /** 목록에서 체크한 건들에 같은 값을 한 번에 반영한다. 반영된 건수를 돌려준다. */
+    @PutMapping("/api/orgs/{id}/review/bulk")
+    public java.util.Map<String, Integer> updateBulk(@PathVariable("id") Long orgId,
+                                                     @RequestBody BulkJudgmentRequest request) {
+        return java.util.Map.of("updated", reviewService.updateBulk(orgId, request));
+    }
+
+    /** 목록에서 체크한 건들을 지운다. */
+    @PostMapping("/api/orgs/{id}/review/bulk-delete")
+    public java.util.Map<String, Integer> deleteBulk(@PathVariable("id") Long orgId,
+                                                     @RequestBody java.util.List<Long> ids) {
+        return java.util.Map.of("deleted", reviewService.deleteBulk(orgId, ids));
     }
 
     @GetMapping("/api/orgs/{id}/review/{itemId}")
src/main/java/kr/itn/itnhub/review/ReviewItemMapper.java
--- src/main/java/kr/itn/itnhub/review/ReviewItemMapper.java
+++ src/main/java/kr/itn/itnhub/review/ReviewItemMapper.java
@@ -8,11 +8,31 @@
 @Mapper
 public interface ReviewItemMapper {
 
-    /** 순번 오름차순, keyword는 사이트명/게시판명/게시물제목 LIKE. keyword가 null이면 필터 없음. */
+    /**
+     * 순번 오름차순. keyword는 사이트명/게시판명/게시물제목을 OR로 훑는 통합검색이고,
+     * site/board/title은 각 열을 따로 걸러 서로 AND로 묶이는 복합검색이다(요청자 요구:
+     * "관광공사의 A 게시판 중 B가 포함된 게시물"처럼 조건을 겹쳐 찾는 용도). null이면 무시된다.
+     */
     List<ReviewItem> findPage(@Param("orgId") Long orgId, @Param("keyword") String keyword,
+                               @Param("site") String site, @Param("board") String board,
+                               @Param("title") String title,
                                @Param("offset") int offset, @Param("limit") int limit);
 
-    int countByOrg(@Param("orgId") Long orgId, @Param("keyword") String keyword);
+    int countByOrg(@Param("orgId") Long orgId, @Param("keyword") String keyword,
+                   @Param("site") String site, @Param("board") String board,
+                   @Param("title") String title);
+
+    /**
+     * 여러 건에 같은 값을 한 번에 반영한다(일괄 등록). null로 준 항목은 건드리지 않는다.
+     * 개별 저장과 마찬가지로 web_edited_at을 채워, 엑셀 재업로드 때 이 값이 덮이지 않게 한다.
+     */
+    int updateJudgmentBulk(@Param("orgId") Long orgId, @Param("ids") List<Long> ids,
+                           @Param("reviewResult") String reviewResult,
+                           @Param("judgedKoglType") String judgedKoglType,
+                           @Param("opinion") String opinion);
+
+    /** 일괄 삭제. 목록에서 여러 건을 골라 지울 때 쓴다. */
+    int deleteByIds(@Param("orgId") Long orgId, @Param("ids") List<Long> ids);
 
     /** 처리결과(review_result)가 채워진 행 수 - 검색어와 무관한 기관 전체 진행률. */
     int countDone(@Param("orgId") Long orgId);
src/main/java/kr/itn/itnhub/review/ReviewService.java
--- src/main/java/kr/itn/itnhub/review/ReviewService.java
+++ src/main/java/kr/itn/itnhub/review/ReviewService.java
@@ -34,19 +34,54 @@
         this.orgMapper = orgMapper;
     }
 
-    public ReviewPage list(Long orgId, String keyword, int page, int size) {
+    public ReviewPage list(Long orgId, String keyword, String site, String board, String title,
+                           int page, int size) {
         requireOrg(orgId);
         String kw = normalizeKeyword(keyword);
+        String siteFilter = normalizeKeyword(site);
+        String boardFilter = normalizeKeyword(board);
+        String titleFilter = normalizeKeyword(title);
         int safePage = Math.max(page, 0);
         int safeSize = size <= 0 ? 30 : size;
         int offset = safePage * safeSize;
 
-        List<ReviewItem> items = mapper.findPage(orgId, kw, offset, safeSize);
-        int total = mapper.countByOrg(orgId, kw);
+        List<ReviewItem> items =
+                mapper.findPage(orgId, kw, siteFilter, boardFilter, titleFilter, offset, safeSize);
+        int total = mapper.countByOrg(orgId, kw, siteFilter, boardFilter, titleFilter);
         int done = mapper.countDone(orgId);
         return new ReviewPage(items, total, done, safePage, safeSize);
     }
 
+    /**
+     * 목록에서 고른 여러 건에 같은 값을 한 번에 반영한다. 화면에서 비워 둔 항목은 null로 와서
+     * 기존 값이 유지된다 - "체크한 것 전부 0유형, 의견은 ooo" 식의 일괄 등록 요구를 위한 것이다.
+     */
+    @Transactional
+    public int updateBulk(Long orgId, BulkJudgmentRequest request) {
+        requireOrg(orgId);
+        if (request.ids() == null || request.ids().isEmpty()) {
+            return 0;
+        }
+        return mapper.updateJudgmentBulk(orgId, request.ids(),
+                blankToNull(request.reviewResult()),
+                blankToNull(request.judgedKoglType()),
+                blankToNull(request.opinion()));
+    }
+
+    /** 목록에서 고른 여러 건을 지운다. */
+    @Transactional
+    public int deleteBulk(Long orgId, List<Long> ids) {
+        requireOrg(orgId);
+        if (ids == null || ids.isEmpty()) {
+            return 0;
+        }
+        return mapper.deleteByIds(orgId, ids);
+    }
+
+    private String blankToNull(String value) {
+        return value == null || value.isBlank() ? null : value;
+    }
+
     public ReviewItem get(Long orgId, Long itemId) {
         requireOrg(orgId);
         return requireItem(orgId, itemId);
src/main/java/kr/itn/itnhub/stage/StageHistoryMapper.java
--- src/main/java/kr/itn/itnhub/stage/StageHistoryMapper.java
+++ src/main/java/kr/itn/itnhub/stage/StageHistoryMapper.java
@@ -12,4 +12,7 @@
     List<StageHistoryRow> findByOrg(@Param("orgId") Long orgId);
 
     int insert(@Param("orgId") Long orgId, @Param("stage") int stage);
+
+    /** 채널 초기화용. 단계가 null로 돌아가므로 이력을 남겨두면 어긋난다. */
+    int deleteByOrg(@Param("orgId") Long orgId);
 }
 
src/main/java/kr/itn/itnhub/system/ResetReport.java (added)
+++ src/main/java/kr/itn/itnhub/system/ResetReport.java
@@ -0,0 +1,13 @@
+package kr.itn.itnhub.system;
+
+import java.util.List;
+
+/**
+ * 채널 초기화 실행 결과.
+ *
+ * @param archivedChannels 실제로 보관 처리된 채널 수
+ * @param clearedOrgs      DB에서 채널ID·단계를 비운 기관 수
+ * @param failures         채널별 실패 사유. 하나가 실패해도 나머지는 계속 진행한다
+ */
+public record ResetReport(int archivedChannels, int clearedOrgs, List<String> failures) {
+}
 
src/main/java/kr/itn/itnhub/system/ResetTarget.java (added)
+++ src/main/java/kr/itn/itnhub/system/ResetTarget.java
@@ -0,0 +1,16 @@
+package kr.itn.itnhub.system;
+
+import java.util.List;
+
+/**
+ * 채널 초기화 미리보기 1행. 실행 전에 "무엇이 몇 개 보관되는지"를 사람이 눈으로 확인하기 위한 것이다.
+ *
+ * @param channels 이 기관에 대해 웹앱 DB가 알고 있는 채널(문정원/법률검토). 최대 2개
+ */
+public record ResetTarget(Long orgId, String orgNo, String orgName, Integer stage,
+                          List<ResetChannel> channels) {
+
+    /** @param kind "문정원" 또는 "법률검토" */
+    public record ResetChannel(String kind, String channelId) {
+    }
+}
 
src/main/java/kr/itn/itnhub/system/SystemResetController.java (added)
+++ src/main/java/kr/itn/itnhub/system/SystemResetController.java
@@ -0,0 +1,58 @@
+package kr.itn.itnhub.system;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+
+import java.util.List;
+import java.util.Map;
+
+@RestController
+public class SystemResetController {
+
+    /** 실행 확인 문구. 사용자가 그대로 입력해야만 초기화가 돈다. */
+    static final String CONFIRM_PHRASE = "채널 초기화";
+
+    private final SystemResetService resetService;
+
+    public SystemResetController(SystemResetService resetService) {
+        this.resetService = resetService;
+    }
+
+    @GetMapping("/api/system/reset/preview")
+    public List<ResetTarget> preview() {
+        return resetService.preview();
+    }
+
+    /**
+     * 되돌리기 번거로운 작업이라 확인 문구를 본문으로 받는다. 버튼 오클릭만으로는 절대
+     * 실행되지 않게 하기 위한 것이다(프런트의 확인창과 이중 방어).
+     */
+    @PostMapping("/api/system/reset/channels")
+    public ResetReport reset(@RequestBody ConfirmRequest request) {
+        if (!CONFIRM_PHRASE.equals(request.confirm())) {
+            throw new InvalidConfirmException("확인 문구가 일치하지 않습니다: \"%s\" 를 입력해야 합니다."
+                    .formatted(CONFIRM_PHRASE));
+        }
+        return resetService.reset();
+    }
+
+    public record ConfirmRequest(String confirm) {
+    }
+
+    static class InvalidConfirmException extends RuntimeException {
+        InvalidConfirmException(String message) {
+            super(message);
+        }
+    }
+
+    @ResponseStatus(HttpStatus.BAD_REQUEST)
+    @ExceptionHandler(InvalidConfirmException.class)
+    public Map<String, String> handleInvalidConfirm(InvalidConfirmException e) {
+        return Map.of("message", e.getMessage());
+    }
+}
 
src/main/java/kr/itn/itnhub/system/SystemResetService.java (added)
+++ src/main/java/kr/itn/itnhub/system/SystemResetService.java
@@ -0,0 +1,115 @@
+package kr.itn.itnhub.system;
+
+import kr.itn.itnhub.mattermost.MattermostClient;
+import kr.itn.itnhub.org.Organization;
+import kr.itn.itnhub.org.OrganizationMapper;
+import kr.itn.itnhub.stage.StageHistoryMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 시스템관리 &gt; 채널 초기화. 웹앱이 만든 Mattermost 채널을 보관 처리하고, 기관의 채널 상태를
+ * 비워 채널관리에서 처음부터 다시 만들 수 있게 되돌린다.
+ *
+ * <p><b>대상은 웹앱 DB가 아는 채널뿐이다.</b> organization.channel_id_mj / channel_id_law에
+ * 들어 있는 ID만 건드린다. Mattermost 팀에는 엑셀 VBA(M07)로 만든 예전 채널과 공용 채널
+ * (Town Square, FAQ, 대쉬보드, 양식 모음, 자료실)이 함께 있는데, 이름으로 훑어서 지우면
+ * 그것들까지 날아간다. 그래서 이름 검색 경로는 의도적으로 만들지 않았다.</p>
+ *
+ * <p><b>사용자 계정은 건드리지 않는다.</b> 웹앱은 만든 계정을 기록하지 않고 이메일로 조회해
+ * 없을 때만 만드는 구조라, 원래 있던 실제 직원 계정과 구분할 방법이 없다. 계정까지 정리하려면
+ * 생성 출처를 DB에 남기는 작업이 먼저다.</p>
+ *
+ * <p>보관은 Mattermost의 아카이브다(영구 삭제 아님). 게시글·파일은 남고 시스템 콘솔에서
+ * 복구할 수 있다. 권리확인/권리처리·업무메모·담당자 배정 등 DB 데이터도 그대로 둔다.</p>
+ */
+@Service
+public class SystemResetService {
+
+    private static final Logger log = LoggerFactory.getLogger(SystemResetService.class);
+
+    static final String KIND_MJ = "문정원";
+    static final String KIND_LAW = "법률검토";
+
+    private final OrganizationMapper orgMapper;
+    private final StageHistoryMapper stageHistoryMapper;
+    private final MattermostClient mattermost;
+
+    public SystemResetService(OrganizationMapper orgMapper, StageHistoryMapper stageHistoryMapper,
+                              MattermostClient mattermost) {
+        this.orgMapper = orgMapper;
+        this.stageHistoryMapper = stageHistoryMapper;
+        this.mattermost = mattermost;
+    }
+
+    /** 초기화하면 무엇이 사라지는지 미리 보여준다. 아무것도 바꾸지 않는다. */
+    public List<ResetTarget> preview() {
+        List<ResetTarget> targets = new ArrayList<>();
+        for (Organization org : orgMapper.findAll()) {
+            List<ResetTarget.ResetChannel> channels = channelsOf(org);
+            if (channels.isEmpty()) {
+                continue;
+            }
+            targets.add(new ResetTarget(org.getId(), org.getOrgNo(), org.getOrgName(),
+                    org.getStage(), channels));
+        }
+        return targets;
+    }
+
+    /**
+     * 실제 초기화. 채널 하나가 실패해도 나머지는 계속한다 - 이미 지워진 채널이나 권한 문제로
+     * 전체가 멈추면, 절반만 정리된 상태에서 다시 실행할 방법이 없어지기 때문이다.
+     *
+     * <p>DB 정리는 <b>그 기관의 채널을 모두 보관한 뒤에만</b> 한다. 한쪽만 보관된 상태에서
+     * ID를 지워버리면 남은 채널을 다시 찾을 방법이 사라진다.</p>
+     */
+    public ResetReport reset() {
+        int archived = 0;
+        int cleared = 0;
+        List<String> failures = new ArrayList<>();
+
+        for (ResetTarget target : preview()) {
+            boolean allArchived = true;
+
+            for (ResetTarget.ResetChannel channel : target.channels()) {
+                try {
+                    mattermost.archiveChannel(channel.channelId());
+                    archived++;
+                } catch (RuntimeException e) {
+                    allArchived = false;
+                    failures.add("%s %s (%s): %s".formatted(
+                            target.orgNo(), target.orgName(), channel.kind(), e.getMessage()));
+                    log.warn("채널 보관 실패 org={} kind={}", target.orgNo(), channel.kind(), e);
+                }
+            }
+
+            if (allArchived) {
+                orgMapper.clearChannels(target.orgId());
+                stageHistoryMapper.deleteByOrg(target.orgId());
+                cleared++;
+            }
+        }
+
+        log.info("채널 초기화 완료: 보관 {}개, 기관 {}곳 초기화, 실패 {}건", archived, cleared, failures.size());
+        return new ResetReport(archived, cleared, failures);
+    }
+
+    private List<ResetTarget.ResetChannel> channelsOf(Organization org) {
+        List<ResetTarget.ResetChannel> channels = new ArrayList<>(2);
+        if (hasText(org.getChannelIdMj())) {
+            channels.add(new ResetTarget.ResetChannel(KIND_MJ, org.getChannelIdMj()));
+        }
+        if (hasText(org.getChannelIdLaw())) {
+            channels.add(new ResetTarget.ResetChannel(KIND_LAW, org.getChannelIdLaw()));
+        }
+        return channels;
+    }
+
+    private boolean hasText(String value) {
+        return value != null && !value.isBlank();
+    }
+}
 
src/main/resources/db/migration/V11__stage_backfill_for_channeled_orgs.sql (added)
+++ src/main/resources/db/migration/V11__stage_backfill_for_channeled_orgs.sql
@@ -0,0 +1,20 @@
+-- 채널은 이미 만들었는데 진행단계가 비어 있는 기관을 1단계(신청)로 채운다.
+--
+-- 단계 기능(V8) 이전에 채널을 만든 기관들이 이 상태로 남아 있다. 대시보드 칸반은
+-- "단계 미지정" 컬럼을 채널 미생성 기관에만 쓰기로 했으므로(요청자 확정), 이 기관들은
+-- 미지정 컬럼에도 ①~⑫ 컬럼에도 속하지 못해 보드에서 사라져 버린다.
+--
+-- 이 백필 이후로는 "채널 있음 <=> 단계 있음" 이 항상 성립한다 - 채널 생성 시 1단계를
+-- 자동 시작하는 ChannelProvisionService 로직과 같은 상태가 된다. 1회성이며, 조건에
+-- 걸리는 기관이 없으면 아무것도 하지 않는다.
+insert into stage_history (org_id, stage)
+select id, 1
+from organization
+where stage is null
+  and (channel_id_mj is not null or channel_id_law is not null);
+
+update organization
+set stage      = 1,
+    updated_at = now()
+where stage is null
+  and (channel_id_mj is not null or channel_id_law is not null);
 
src/main/resources/db/migration/V12__project_management.sql (added)
+++ src/main/resources/db/migration/V12__project_management.sql
@@ -0,0 +1,30 @@
+-- 사업관리 메뉴용 두 테이블.
+--
+-- contact_log: "신청 확인 연락" 이력. 요청자가 이 부분은 자료가 자주 바뀌어 이력관리가
+--   필요하다고 했으므로, 한 기관에 여러 건이 쌓이고 지우지 않는 이상 남는다.
+-- org_report: 기관별 최종 보고서. 같은 기관에 여러 번 올릴 수 있고(개정판) 최신순으로 본다.
+--   파일은 DB에 그대로 담는다 - Mattermost 채널에 올리면 채널을 초기화하거나 아카이브할 때
+--   보고서까지 같이 사라지기 때문이다.
+
+create table contact_log (
+    id           bigserial primary key,
+    org_id       bigint       not null references organization (id) on delete cascade,
+    contacted_on date         not null,
+    method       varchar(20)  not null,   -- 전화 / 메일 / 방문 / 기타
+    summary      varchar(1000) not null,
+    author       varchar(100) not null,   -- 로그인 사용자
+    created_at   timestamptz  not null default now()
+);
+create index ix_contact_log_org on contact_log (org_id, contacted_on desc, id desc);
+
+create table org_report (
+    id           bigserial primary key,
+    org_id       bigint       not null references organization (id) on delete cascade,
+    file_name    varchar(300) not null,
+    content_type varchar(200),
+    byte_size    bigint       not null,
+    content      bytea        not null,
+    uploaded_by  varchar(100) not null,
+    uploaded_at  timestamptz  not null default now()
+);
+create index ix_org_report_org on org_report (org_id, uploaded_at desc);
 
src/main/resources/mapper/DashboardMapper.xml (added)
+++ src/main/resources/mapper/DashboardMapper.xml
@@ -0,0 +1,94 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="kr.itn.itnhub.dashboard.DashboardMapper">
+
+  <!--
+    record라서 constructor 매핑을 쓴다(ProcessItemMapper의 statsResultMap과 같은 방식).
+    arg 순서는 DashboardOrgRow의 컴포넌트 순서와 반드시 같아야 한다.
+  -->
+  <resultMap id="orgRowResultMap" type="kr.itn.itnhub.dashboard.DashboardOrgRow">
+    <constructor>
+      <idArg column="id" javaType="java.lang.Long"/>
+      <arg column="org_no" javaType="java.lang.String"/>
+      <arg column="org_name" javaType="java.lang.String"/>
+      <arg column="stage" javaType="java.lang.Integer"/>
+      <arg column="has_channel" javaType="_boolean"/>
+      <arg column="lawyer_name" javaType="java.lang.String"/>
+      <arg column="mj_name" javaType="java.lang.String"/>
+      <arg column="lawyer_assigned_date" javaType="java.lang.String"/>
+      <arg column="review_total" javaType="_int"/>
+      <arg column="review_done" javaType="_int"/>
+      <arg column="process_total" javaType="_int"/>
+      <arg column="process_done" javaType="_int"/>
+      <arg column="stage_changed_at" javaType="java.lang.Long"/>
+      <arg column="last_changed_at" javaType="java.lang.Long"/>
+      <arg column="re_count" javaType="_int"/>
+    </constructor>
+  </resultMap>
+
+  <!--
+    기관 1행당 집계를 서브쿼리로 붙인다. 기관별로 countByOrg/stats를 반복 호출하면
+    기관 50곳 × 3종 = 150회 조회가 되므로(대시보드는 화면 진입마다 그린다) 여기서 한 방에 끝낸다.
+
+    stage_at / any_at 두 값이 서로 다르다:
+      - stage_at = 지금 단계로 넘어온 시각. 카드의 D+n은 이걸 쓴다
+      - any_at   = 단계 이력 전체의 최신값. "최근 변경"의 재료 중 하나다
+    lateral을 쓰는 이유는 filter 절에서 o.stage를 참조해야 하기 때문이다.
+
+    last_changed_at은 greatest()로 합친다. Postgres의 greatest는 NULL을 무시하므로
+    권리확인/처리/메모가 아직 없는 기관도 organization.updated_at으로 값이 채워진다.
+    organization.updated_at만으로는 부족한 이유: 배정·단계·채널 변경 때만 갱신되고
+    권리확인 업로드나 업무메모 작성은 이 컬럼을 건드리지 않는다.
+
+    re_count는 RE 테이블이 아직 없어서 0으로 고정한다. RE 모델이 들어오면 여기에
+    서브쿼리 하나만 붙이면 화면은 그대로 동작한다.
+  -->
+  <select id="findOrgRows" resultMap="orgRowResultMap">
+    select
+      o.id,
+      o.org_no,
+      o.org_name,
+      o.stage,
+      (o.channel_id_mj is not null or o.channel_id_law is not null) as has_channel,
+      lc.name as lawyer_name,
+      mc.name as mj_name,
+      o.lawyer_assigned_date,
+      coalesce(rv.total, 0) as review_total,
+      coalesce(rv.done, 0)  as review_done,
+      coalesce(pr.total, 0) as process_total,
+      coalesce(pr.done, 0)  as process_done,
+      (extract(epoch from sh.stage_at) * 1000)::bigint as stage_changed_at,
+      (extract(epoch from greatest(o.updated_at, sh.any_at, rv.last_at, pr.last_at, wm.last_at)) * 1000)::bigint
+        as last_changed_at,
+      0 as re_count
+    from organization o
+    left join contact lc on lc.id = o.lawyer_contact_id
+    left join contact mc on mc.id = o.mj_contact_id
+    left join (
+      select org_id,
+             count(*) as total,
+             count(*) filter (where review_result is not null and review_result &lt;&gt; '') as done,
+             max(updated_at) as last_at
+      from review_item group by org_id
+    ) rv on rv.org_id = o.id
+    left join (
+      select org_id,
+             count(*) as total,
+             count(*) filter (where process_status = '처리완료') as done,
+             max(updated_at) as last_at
+      from process_item group by org_id
+    ) pr on pr.org_id = o.id
+    left join (
+      select org_id, max(created_at) as last_at from work_memo group by org_id
+    ) wm on wm.org_id = o.id
+    left join lateral (
+      select max(h.changed_at) filter (where h.stage = o.stage) as stage_at,
+             max(h.changed_at) as any_at
+      from stage_history h
+      where h.org_id = o.id
+    ) sh on true
+    order by o.org_no asc, o.org_name asc
+  </select>
+
+</mapper>
src/main/resources/mapper/OrganizationMapper.xml
--- src/main/resources/mapper/OrganizationMapper.xml
+++ src/main/resources/mapper/OrganizationMapper.xml
@@ -163,4 +163,14 @@
     where id = #{id}
   </update>
 
+  <!-- 시스템관리 > 채널 초기화. 담당자 배정·권리확인/처리 데이터는 건드리지 않는다. -->
+  <update id="clearChannels">
+    update organization set
+      channel_id_mj  = null,
+      channel_id_law = null,
+      stage          = null,
+      updated_at     = now()
+    where id = #{id}
+  </update>
+
 </mapper>
src/main/resources/mapper/ProcessItemMapper.xml
--- src/main/resources/mapper/ProcessItemMapper.xml
+++ src/main/resources/mapper/ProcessItemMapper.xml
@@ -24,6 +24,16 @@
       and (site_name ilike concat('%', #{keyword}, '%')
         or post_title ilike concat('%', #{keyword}, '%'))
     </if>
+    <!-- 열별 조건. 서로 AND로 겹쳐서 복합검색이 된다(권리확인과 동일 규칙). -->
+    <if test="site != null">
+      and site_name ilike concat('%', #{site}, '%')
+    </if>
+    <if test="board != null">
+      and board_name ilike concat('%', #{board}, '%')
+    </if>
+    <if test="title != null">
+      and post_title ilike concat('%', #{title}, '%')
+    </if>
   </sql>
 
   <!-- status: null=전체, "DONE"=처리완료, "PENDING"=그 외(null/빈/미처리 등 처리완료가 아닌 전부). -->
@@ -157,6 +167,27 @@
     </constructor>
   </resultMap>
 
+  <!-- 일괄 등록. 채운 항목만 반영되도록 coalesce를 쓰고, 처리완료로 바뀔 때만 처리시각을 찍는다. -->
+  <update id="updateProcessingBulk">
+    update process_item set
+      process_status   = coalesce(#{processStatus}, process_status),
+      judged_kogl_type = coalesce(#{judgedKoglType}, judged_kogl_type),
+      final_opinion    = coalesce(#{finalOpinion}, final_opinion),
+      processed_at     = case when #{processStatus} = '처리완료' then now() else processed_at end,
+      web_edited_at    = now(),
+      updated_at       = now()
+    where org_id = #{orgId}
+      and id in
+      <foreach item="id" collection="ids" open="(" separator="," close=")">#{id}</foreach>
+  </update>
+
+  <delete id="deleteByIds">
+    delete from process_item
+    where org_id = #{orgId}
+      and id in
+      <foreach item="id" collection="ids" open="(" separator="," close=")">#{id}</foreach>
+  </delete>
+
   <select id="stats" resultMap="statsResultMap">
     select
       count(*) as total,
 
src/main/resources/mapper/ProjectMapper.xml (added)
+++ src/main/resources/mapper/ProjectMapper.xml
@@ -0,0 +1,78 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="kr.itn.itnhub.project.ProjectMapper">
+
+  <!--
+    사업관리 목록에 붙일 값만 기관별로 한 번에 센다. 기관 현황 본체는 DashboardMapper가
+    이미 만들어 주므로 여기서 다시 조회하지 않는다(같은 숫자를 두 곳에서 세지 않기 위함).
+  -->
+  <select id="findSideInfo" resultType="kr.itn.itnhub.project.ProjectMapper$ProjectSideInfo">
+    select
+      o.id as org_id,
+      to_char(cl.last_on, 'YYYY-MM-DD') as last_contacted_on,
+      coalesce(cl.cnt, 0) as contact_count,
+      coalesce(rp.cnt, 0) as report_count
+    from organization o
+    left join (
+      select org_id, max(contacted_on) as last_on, count(*) as cnt
+      from contact_log group by org_id
+    ) cl on cl.org_id = o.id
+    left join (
+      select org_id, count(*) as cnt from org_report group by org_id
+    ) rp on rp.org_id = o.id
+  </select>
+
+  <!-- 연락 이력: 최근 연락일 순. 같은 날짜면 나중에 넣은 것이 위로 온다. -->
+  <select id="findLogsByOrg" resultType="kr.itn.itnhub.project.ContactLog">
+    select id, org_id,
+           to_char(contacted_on, 'YYYY-MM-DD') as contacted_on,
+           method, summary, author,
+           (extract(epoch from created_at) * 1000)::bigint as created_at
+    from contact_log
+    where org_id = #{orgId}
+    order by contacted_on desc, id desc
+  </select>
+
+  <insert id="insertLog">
+    insert into contact_log (org_id, contacted_on, method, summary, author)
+    values (#{orgId}, cast(#{contactedOn} as date), #{method}, #{summary}, #{author})
+  </insert>
+
+  <!-- 작성자는 처음 남긴 사람을 유지한다(업무메모와 같은 규칙). -->
+  <update id="updateLog">
+    update contact_log set
+      contacted_on = cast(#{contactedOn} as date),
+      method       = #{method},
+      summary      = #{summary}
+    where id = #{id} and org_id = #{orgId}
+  </update>
+
+  <delete id="deleteLog">
+    delete from contact_log where id = #{id} and org_id = #{orgId}
+  </delete>
+
+  <select id="findReportsByOrg" resultType="kr.itn.itnhub.project.OrgReport">
+    select id, org_id, file_name, content_type, byte_size, uploaded_by,
+           (extract(epoch from uploaded_at) * 1000)::bigint as uploaded_at
+    from org_report
+    where org_id = #{orgId}
+    order by uploaded_at desc, id desc
+  </select>
+
+  <insert id="insertReport">
+    insert into org_report (org_id, file_name, content_type, byte_size, content, uploaded_by)
+    values (#{orgId}, #{fileName}, #{contentType}, #{byteSize}, #{content}, #{uploadedBy})
+  </insert>
+
+  <select id="findReportContent" resultType="kr.itn.itnhub.project.ProjectMapper$ReportContent">
+    select file_name, content_type, content
+    from org_report
+    where id = #{id} and org_id = #{orgId}
+  </select>
+
+  <delete id="deleteReport">
+    delete from org_report where id = #{id} and org_id = #{orgId}
+  </delete>
+
+</mapper>
src/main/resources/mapper/ReviewItemMapper.xml
--- src/main/resources/mapper/ReviewItemMapper.xml
+++ src/main/resources/mapper/ReviewItemMapper.xml
@@ -17,11 +17,24 @@
     (extract(epoch from updated_at) * 1000)::bigint as updated_at
   </sql>
 
+  <!--
+    keyword는 세 열을 OR로 훑는 통합검색, site/board/title은 열별 조건이라 서로 AND로 겹친다.
+    둘을 같이 보내면 (통합검색) AND (열별 조건들)이 된다.
+  -->
   <sql id="keywordFilter">
     <if test="keyword != null">
       and (site_name ilike concat('%', #{keyword}, '%')
         or board_name ilike concat('%', #{keyword}, '%')
         or post_title ilike concat('%', #{keyword}, '%'))
+    </if>
+    <if test="site != null">
+      and site_name ilike concat('%', #{site}, '%')
+    </if>
+    <if test="board != null">
+      and board_name ilike concat('%', #{board}, '%')
+    </if>
+    <if test="title != null">
+      and post_title ilike concat('%', #{title}, '%')
     </if>
   </sql>
 
@@ -131,6 +144,29 @@
     where org_id = #{orgId} and id = #{id}
   </update>
 
+  <!--
+    일괄 등록. null로 온 항목은 coalesce로 기존 값을 유지하므로, 화면에서 채운 항목만 반영된다.
+    개별 저장과 같이 web_edited_at을 찍어야 엑셀 재업로드가 이 값을 덮지 않는다.
+  -->
+  <update id="updateJudgmentBulk">
+    update review_item set
+      review_result    = coalesce(#{reviewResult}, review_result),
+      judged_kogl_type = coalesce(#{judgedKoglType}, judged_kogl_type),
+      opinion          = coalesce(#{opinion}, opinion),
+      web_edited_at    = now(),
+      updated_at       = now()
+    where org_id = #{orgId}
+      and id in
+      <foreach item="id" collection="ids" open="(" separator="," close=")">#{id}</foreach>
+  </update>
+
+  <delete id="deleteByIds">
+    delete from review_item
+    where org_id = #{orgId}
+      and id in
+      <foreach item="id" collection="ids" open="(" separator="," close=")">#{id}</foreach>
+  </delete>
+
   <delete id="deleteById">
     delete from review_item where org_id = #{orgId} and id = #{id}
   </delete>
src/main/resources/mapper/StageHistoryMapper.xml
--- src/main/resources/mapper/StageHistoryMapper.xml
+++ src/main/resources/mapper/StageHistoryMapper.xml
@@ -18,4 +18,12 @@
     values (#{orgId}, #{stage})
   </insert>
 
+  <!--
+    시스템관리 > 채널 초기화 전용. 해당 기관의 단계가 null로 되돌아가므로 이력만 남겨두면
+    타임라인/대시보드가 어긋난다. org_id 한 곳만 지우며, 전체 삭제 경로는 두지 않는다.
+  -->
+  <delete id="deleteByOrg">
+    delete from stage_history where org_id = #{orgId}
+  </delete>
+
 </mapper>
src/main/resources/mapper/WorkMemoMapper.xml
--- src/main/resources/mapper/WorkMemoMapper.xml
+++ src/main/resources/mapper/WorkMemoMapper.xml
@@ -24,6 +24,17 @@
     values (#{orgId}, #{contactName}, #{body})
   </insert>
 
+  <!--
+    본문만 고친다. 작성자(contact_name)와 작성시각은 그대로 둔다 - 누가 언제 남긴
+    메모인지가 이력의 핵심이라 수정으로 덮이면 안 된다.
+    delete와 같은 이유로 org_id를 함께 조건에 넣는다.
+  -->
+  <update id="updateBody">
+    update work_memo
+    set body = #{body}
+    where id = #{id} and org_id = #{orgId}
+  </update>
+
   <delete id="delete">
     delete from work_memo
     where id = #{id} and org_id = #{orgId}
 
src/test/java/kr/itn/itnhub/dashboard/DashboardControllerTest.java (added)
+++ src/test/java/kr/itn/itnhub/dashboard/DashboardControllerTest.java
@@ -0,0 +1,156 @@
+package kr.itn.itnhub.dashboard;
+
+import kr.itn.itnhub.AbstractDbTest;
+import kr.itn.itnhub.mattermost.MattermostClient;
+import kr.itn.itnhub.org.Organization;
+import kr.itn.itnhub.org.OrganizationMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@AutoConfigureMockMvc
+@WithMockUser(roles = "ADMIN")
+class DashboardControllerTest extends AbstractDbTest {
+
+    @Autowired
+    MockMvc mvc;
+
+    @Autowired
+    OrganizationMapper orgMapper;
+
+    @Autowired
+    JdbcTemplate jdbc;
+
+    @MockBean
+    MattermostClient mattermost;
+
+    private Long noChannelId;
+    private Long stage2Id;
+    private Long stage11Id;
+
+    @BeforeEach
+    void setUp() {
+        jdbc.update("delete from organization");
+
+        // 001: 채널도 단계도 없는 기관 - 칸반의 "단계 미지정" 컬럼 대상
+        noChannelId = seed("001", "채널없는기관");
+
+        // 002: 채널 + 2단계, 권리확인 3건(완료 1건) / 권리처리 2건(완료 1건)
+        stage2Id = seed("002", "권리확인중기관");
+        orgMapper.updateChannelIdMj(stage2Id, "ch-mj");
+        setStage(stage2Id, 2);
+        insertReview(stage2Id, 1, "이용가능");
+        insertReview(stage2Id, 2, null);
+        insertReview(stage2Id, 3, "");
+        insertProcess(stage2Id, 1, "처리완료");
+        insertProcess(stage2Id, 2, "미처리");
+
+        // 003: 채널 + 11단계 - 완료 집계 대상
+        stage11Id = seed("003", "최종완료기관");
+        orgMapper.updateChannelIdMj(stage11Id, "ch-mj-2");
+        orgMapper.updateChannelIdLaw(stage11Id, "ch-law-2");
+        setStage(stage11Id, 11);
+    }
+
+    private Long seed(String orgNo, String orgName) {
+        Organization org = new Organization();
+        org.setOrgNo(orgNo);
+        org.setOrgName(orgName);
+        org.setChannelSlug(orgNo);
+        orgMapper.upsertBySeed(org);
+        return orgMapper.findByOrgNoAndOrgName(orgNo, orgName).getId();
+    }
+
+    private void setStage(Long orgId, int stage) {
+        orgMapper.updateStage(orgId, stage);
+        jdbc.update("insert into stage_history (org_id, stage) values (?, ?)", orgId, stage);
+    }
+
+    private void insertReview(Long orgId, int seq, String reviewResult) {
+        jdbc.update("insert into review_item (org_id, seq, review_result) values (?, ?, ?)",
+                orgId, seq, reviewResult);
+    }
+
+    private void insertProcess(Long orgId, int seq, String status) {
+        jdbc.update("insert into process_item (org_id, seq, process_status) values (?, ?, ?)",
+                orgId, seq, status);
+    }
+
+    @Test
+    void KPI는_전체_기관과_단계별_도달_기관수를_센다() throws Exception {
+        mvc.perform(get("/api/dashboard"))
+                .andExpect(status().isOk())
+                // 신청 = 명부 전체 기관 수
+                .andExpect(jsonPath("$.summary.applied").value(3))
+                // 서류제출 = 2단계 이상 (002, 003)
+                .andExpect(jsonPath("$.summary.docSubmitted").value(2))
+                // 완료 = 11단계 이상 (003)
+                .andExpect(jsonPath("$.summary.completed").value(1))
+                // 보고서 작성 = 12단계 이상 (없음)
+                .andExpect(jsonPath("$.summary.reportWriting").value(0));
+    }
+
+    @Test
+    void KPI의_권리확인_권리처리는_전_기관_건수_합계다() throws Exception {
+        mvc.perform(get("/api/dashboard"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.summary.reviewTotal").value(3))
+                // 빈 문자열은 미완료로 센다
+                .andExpect(jsonPath("$.summary.reviewDone").value(1))
+                .andExpect(jsonPath("$.summary.processTotal").value(2))
+                .andExpect(jsonPath("$.summary.processDone").value(1));
+    }
+
+    @Test
+    void RE는_데이터_모델이_없어_항상_0이다() throws Exception {
+        mvc.perform(get("/api/dashboard"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.summary.unresolvedRe").value(0))
+                .andExpect(jsonPath("$.orgs[0].reCount").value(0));
+    }
+
+    @Test
+    void 기관행은_연번순이고_채널_보유_여부를_내려준다() throws Exception {
+        mvc.perform(get("/api/dashboard"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.orgs.length()").value(3))
+                .andExpect(jsonPath("$.orgs[0].orgNo").value("001"))
+                .andExpect(jsonPath("$.orgs[0].hasChannel").value(false))
+                .andExpect(jsonPath("$.orgs[0].stage").doesNotExist())
+                .andExpect(jsonPath("$.orgs[1].orgNo").value("002"))
+                .andExpect(jsonPath("$.orgs[1].hasChannel").value(true))
+                .andExpect(jsonPath("$.orgs[1].stage").value(2));
+    }
+
+    @Test
+    void 기관행에_권리확인_권리처리_진척이_기관별로_담긴다() throws Exception {
+        mvc.perform(get("/api/dashboard"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.orgs[0].reviewTotal").value(0))
+                .andExpect(jsonPath("$.orgs[1].reviewTotal").value(3))
+                .andExpect(jsonPath("$.orgs[1].reviewDone").value(1))
+                .andExpect(jsonPath("$.orgs[1].processTotal").value(2))
+                .andExpect(jsonPath("$.orgs[1].processDone").value(1));
+    }
+
+    @Test
+    void 현재_단계_진입시각과_최근변동시각을_내려준다() throws Exception {
+        mvc.perform(get("/api/dashboard"))
+                .andExpect(status().isOk())
+                // 단계 이력이 없는 기관은 진입시각이 없다
+                .andExpect(jsonPath("$.orgs[0].stageChangedAt").doesNotExist())
+                .andExpect(jsonPath("$.orgs[1].stageChangedAt").isNumber())
+                // 최근변동은 organization.updated_at이 있어 항상 값이 있다
+                .andExpect(jsonPath("$.orgs[0].lastChangedAt").isNumber())
+                .andExpect(jsonPath("$.orgs[1].lastChangedAt").isNumber());
+    }
+}
src/test/java/kr/itn/itnhub/memo/WorkMemoControllerTest.java
--- src/test/java/kr/itn/itnhub/memo/WorkMemoControllerTest.java
+++ src/test/java/kr/itn/itnhub/memo/WorkMemoControllerTest.java
@@ -18,11 +18,12 @@
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
 
 @AutoConfigureMockMvc
-@WithMockUser(roles = "ADMIN")
+@WithMockUser(username = "admin", roles = "ADMIN")
 class WorkMemoControllerTest extends AbstractDbTest {
 
     @Autowired
@@ -60,18 +61,18 @@
                         .contentType(MediaType.APPLICATION_JSON)
                         .content("""
                                 {
-                                  "contactName": "송민지 과장",
                                   "body": "채널 생성 일정 문의"
                                 }
                                 """))
                 .andExpect(status().isOk())
-                .andExpect(jsonPath("$.contactName").value("송민지 과장"))
+                // 작성자는 요청 본문이 아니라 로그인 사용자에서 온다
+                .andExpect(jsonPath("$.contactName").value("admin"))
                 .andExpect(jsonPath("$.body").value("채널 생성 일정 문의"))
                 .andExpect(jsonPath("$.createdAt").isNumber());
 
         mvc.perform(get("/api/orgs/{id}/memos", orgId))
                 .andExpect(status().isOk())
-                .andExpect(jsonPath("$[0].contactName").value("송민지 과장"))
+                .andExpect(jsonPath("$[0].contactName").value("admin"))
                 .andExpect(jsonPath("$[0].body").value("채널 생성 일정 문의"));
     }
 
@@ -81,7 +82,7 @@
                         .with(csrf())
                         .contentType(MediaType.APPLICATION_JSON)
                         .content("""
-                                {"contactName": "송민지", "body": "첫번째 통화"}
+                                {"body": "첫번째 통화"}
                                 """))
                 .andExpect(status().isOk());
 
@@ -89,7 +90,7 @@
                         .with(csrf())
                         .contentType(MediaType.APPLICATION_JSON)
                         .content("""
-                                {"contactName": "송민지", "body": "두번째 통화"}
+                                {"body": "두번째 통화"}
                                 """))
                 .andExpect(status().isOk());
 
@@ -105,7 +106,7 @@
                         .with(csrf())
                         .contentType(MediaType.APPLICATION_JSON)
                         .content("""
-                                {"contactName": "송민지", "body": ""}
+                                {"body": ""}
                                 """))
                 .andExpect(status().isBadRequest())
                 .andExpect(jsonPath("$.message").value(containsString("메모 내용")));
@@ -126,7 +127,7 @@
                         .with(csrf())
                         .contentType(MediaType.APPLICATION_JSON)
                         .content("""
-                                {"contactName": "송민지", "body": "삭제될 메모"}
+                                {"body": "삭제될 메모"}
                                 """))
                 .andExpect(status().isOk());
         Long memoId = memoMapper.findByOrg(orgId).get(0).getId();
@@ -145,7 +146,7 @@
                         .with(csrf())
                         .contentType(MediaType.APPLICATION_JSON)
                         .content("""
-                                {"contactName": "송민지", "body": "삭제될 메모"}
+                                {"body": "삭제될 메모"}
                                 """))
                 .andExpect(status().isOk());
         Long memoId = memoMapper.findByOrg(orgId).get(0).getId();
@@ -169,4 +170,51 @@
         mvc.perform(get("/api/orgs/{id}/memos", orgId))
                 .andExpect(status().isUnauthorized());
     }
+
+    @Test
+    void 메모를_수정하면_본문만_바뀌고_작성자와_작성시각은_그대로다() throws Exception {
+        String created = mvc.perform(post("/api/orgs/{id}/memos", orgId)
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {"body": "고치기 전"}
+                                """))
+                .andExpect(status().isOk())
+                .andReturn().getResponse().getContentAsString();
+        long memoId = com.jayway.jsonpath.JsonPath.parse(created).read("$.id", Integer.class);
+        long createdAt = com.jayway.jsonpath.JsonPath.parse(created).read("$.createdAt", Long.class);
+
+        mvc.perform(put("/api/orgs/{id}/memos/{memoId}", orgId, memoId)
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {"body": "고친 뒤"}
+                                """))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.body").value("고친 뒤"))
+                .andExpect(jsonPath("$.contactName").value("admin"))
+                .andExpect(jsonPath("$.createdAt").value(createdAt));
+    }
+
+    @Test
+    void 없는_메모를_수정하면_404다() throws Exception {
+        mvc.perform(put("/api/orgs/{id}/memos/{memoId}", orgId, 999999L)
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {"body": "아무거나"}
+                                """))
+                .andExpect(status().isNotFound());
+    }
+
+    @Test
+    void 본문이_비면_수정도_400이다() throws Exception {
+        mvc.perform(put("/api/orgs/{id}/memos/{memoId}", orgId, 1L)
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {"body": ""}
+                                """))
+                .andExpect(status().isBadRequest());
+    }
 }
 
src/test/java/kr/itn/itnhub/project/ProjectControllerTest.java (added)
+++ src/test/java/kr/itn/itnhub/project/ProjectControllerTest.java
@@ -0,0 +1,211 @@
+package kr.itn.itnhub.project;
+
+import kr.itn.itnhub.AbstractDbTest;
+import kr.itn.itnhub.mattermost.MattermostClient;
+import kr.itn.itnhub.org.Organization;
+import kr.itn.itnhub.org.OrganizationMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@AutoConfigureMockMvc
+@WithMockUser(username = "admin", roles = "ADMIN")
+class ProjectControllerTest extends AbstractDbTest {
+
+    @Autowired
+    MockMvc mvc;
+
+    @Autowired
+    OrganizationMapper orgMapper;
+
+    @Autowired
+    JdbcTemplate jdbc;
+
+    @MockBean
+    MattermostClient mattermost;
+
+    private Long orgId;
+
+    @BeforeEach
+    void setUp() {
+        jdbc.update("delete from organization");
+
+        Organization org = new Organization();
+        org.setOrgNo("001");
+        org.setOrgName("국제방송교류재단");
+        org.setChannelSlug("001");
+        orgMapper.upsertBySeed(org);
+        orgId = orgMapper.findByOrgNoAndOrgName("001", "국제방송교류재단").getId();
+    }
+
+    @Test
+    void 목록은_기관현황에_연락이력_보고서_건수를_붙여_내려준다() throws Exception {
+        mvc.perform(get("/api/projects"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.length()").value(1))
+                .andExpect(jsonPath("$[0].org.orgNo").value("001"))
+                .andExpect(jsonPath("$[0].contactCount").value(0))
+                .andExpect(jsonPath("$[0].reportCount").value(0))
+                .andExpect(jsonPath("$[0].lastContactedOn").doesNotExist());
+    }
+
+    @Test
+    void 연락이력을_등록하면_최근순으로_돌려주고_작성자는_로그인_사용자다() throws Exception {
+        mvc.perform(post("/api/orgs/{id}/contact-logs", orgId).with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {"contactedOn": "2026-07-20", "method": "전화", "summary": "신청 확인"}
+                                """))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.length()").value(1))
+                .andExpect(jsonPath("$[0].author").value("admin"))
+                .andExpect(jsonPath("$[0].contactedOn").value("2026-07-20"));
+
+        mvc.perform(post("/api/orgs/{id}/contact-logs", orgId).with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {"contactedOn": "2026-07-25", "method": "메일", "summary": "자료 요청"}
+                                """))
+                .andExpect(status().isOk())
+                // 최근 연락일이 위로 온다
+                .andExpect(jsonPath("$[0].contactedOn").value("2026-07-25"))
+                .andExpect(jsonPath("$[1].contactedOn").value("2026-07-20"));
+
+        mvc.perform(get("/api/projects"))
+                .andExpect(jsonPath("$[0].contactCount").value(2))
+                .andExpect(jsonPath("$[0].lastContactedOn").value("2026-07-25"));
+    }
+
+    @Test
+    void 연락이력_수정은_내용만_바꾸고_작성자는_유지한다() throws Exception {
+        String created = mvc.perform(post("/api/orgs/{id}/contact-logs", orgId).with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {"contactedOn": "2026-07-20", "method": "전화", "summary": "처음"}
+                                """))
+                .andReturn().getResponse().getContentAsString();
+        int logId = com.jayway.jsonpath.JsonPath.parse(created).read("$[0].id", Integer.class);
+
+        mvc.perform(put("/api/orgs/{id}/contact-logs/{logId}", orgId, logId).with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {"contactedOn": "2026-07-21", "method": "방문", "summary": "고침"}
+                                """))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$[0].summary").value("고침"))
+                .andExpect(jsonPath("$[0].method").value("방문"))
+                .andExpect(jsonPath("$[0].author").value("admin"));
+    }
+
+    @Test
+    void 연락이력을_삭제하면_목록에서_빠진다() throws Exception {
+        String created = mvc.perform(post("/api/orgs/{id}/contact-logs", orgId).with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {"contactedOn": "2026-07-20", "method": "전화", "summary": "지울 것"}
+                                """))
+                .andReturn().getResponse().getContentAsString();
+        int logId = com.jayway.jsonpath.JsonPath.parse(created).read("$[0].id", Integer.class);
+
+        mvc.perform(delete("/api/orgs/{id}/contact-logs/{logId}", orgId, logId).with(csrf()))
+                .andExpect(status().isNoContent());
+
+        mvc.perform(get("/api/orgs/{id}/contact-logs", orgId))
+                .andExpect(jsonPath("$.length()").value(0));
+    }
+
+    @Test
+    void 내용이_비면_400이다() throws Exception {
+        mvc.perform(post("/api/orgs/{id}/contact-logs", orgId).with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {"contactedOn": "2026-07-20", "method": "전화", "summary": ""}
+                                """))
+                .andExpect(status().isBadRequest());
+    }
+
+    @Test
+    void 보고서를_올리면_목록에_쌓이고_다운로드된다() throws Exception {
+        MockMultipartFile file = new MockMultipartFile("file", "최종보고서.pdf",
+                "application/pdf", "보고서 내용".getBytes(java.nio.charset.StandardCharsets.UTF_8));
+
+        String uploaded = mvc.perform(multipart("/api/orgs/{id}/reports", orgId).file(file).with(csrf()))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.length()").value(1))
+                .andExpect(jsonPath("$[0].fileName").value("최종보고서.pdf"))
+                .andExpect(jsonPath("$[0].uploadedBy").value("admin"))
+                .andReturn().getResponse().getContentAsString();
+        int reportId = com.jayway.jsonpath.JsonPath.parse(uploaded).read("$[0].id", Integer.class);
+
+        // 한글 파일명은 RFC 5987로 인코딩돼 내려간다
+        mvc.perform(get("/api/orgs/{id}/reports/{reportId}", orgId, reportId))
+                .andExpect(status().isOk())
+                .andExpect(header().string("Content-Disposition",
+                        org.hamcrest.Matchers.containsString("filename*=UTF-8''")));
+
+        mvc.perform(get("/api/projects"))
+                .andExpect(jsonPath("$[0].reportCount").value(1));
+    }
+
+    @Test
+    void 같은_기관에_여러_번_올리면_최신순으로_쌓인다() throws Exception {
+        mvc.perform(multipart("/api/orgs/{id}/reports", orgId)
+                        .file(new MockMultipartFile("file", "1차.pdf", "application/pdf", "a".getBytes()))
+                        .with(csrf()))
+                .andExpect(status().isOk());
+        mvc.perform(multipart("/api/orgs/{id}/reports", orgId)
+                        .file(new MockMultipartFile("file", "2차.pdf", "application/pdf", "b".getBytes()))
+                        .with(csrf()))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.length()").value(2))
+                .andExpect(jsonPath("$[0].fileName").value("2차.pdf"));
+    }
+
+    @Test
+    void 빈_파일은_400이다() throws Exception {
+        mvc.perform(multipart("/api/orgs/{id}/reports", orgId)
+                        .file(new MockMultipartFile("file", "empty.pdf", "application/pdf", new byte[0]))
+                        .with(csrf()))
+                .andExpect(status().isBadRequest());
+    }
+
+    @Test
+    void 보고서를_삭제하면_목록에서_빠진다() throws Exception {
+        String uploaded = mvc.perform(multipart("/api/orgs/{id}/reports", orgId)
+                        .file(new MockMultipartFile("file", "지울보고서.pdf", "application/pdf", "x".getBytes()))
+                        .with(csrf()))
+                .andReturn().getResponse().getContentAsString();
+        int reportId = com.jayway.jsonpath.JsonPath.parse(uploaded).read("$[0].id", Integer.class);
+
+        mvc.perform(delete("/api/orgs/{id}/reports/{reportId}", orgId, reportId).with(csrf()))
+                .andExpect(status().isNoContent());
+
+        mvc.perform(get("/api/orgs/{id}/reports", orgId))
+                .andExpect(jsonPath("$.length()").value(0));
+        assertThat(jdbc.queryForObject("select count(*) from org_report", Integer.class)).isZero();
+    }
+
+    @Test
+    void 없는_기관이면_404다() throws Exception {
+        mvc.perform(get("/api/orgs/{id}/contact-logs", orgId + 99999L))
+                .andExpect(status().isNotFound());
+    }
+}
src/test/java/kr/itn/itnhub/review/ReviewControllerTest.java
--- src/test/java/kr/itn/itnhub/review/ReviewControllerTest.java
+++ src/test/java/kr/itn/itnhub/review/ReviewControllerTest.java
@@ -28,6 +28,7 @@
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -322,4 +323,97 @@
                 .andExpect(status().isOk())
                 .andExpect(result -> assertThat(result.getResponse().getContentAsByteArray()).isNotEmpty());
     }
+
+    @Test
+    void 복합검색은_열별_조건을_AND로_겹쳐서_찾는다() throws Exception {
+        jdbc.update("insert into review_item (org_id, seq, site_name, board_name, post_title) "
+                + "values (?, 101, '누리집', '공고게시판', '2026년 사업 공고')", orgId);
+        jdbc.update("insert into review_item (org_id, seq, site_name, board_name, post_title) "
+                + "values (?, 102, '누리집', '자료실', '2026년 사업 결과')", orgId);
+
+        // 게시판명이 다르면 걸러진다
+        mvc.perform(get("/api/orgs/{id}/review", orgId)
+                        .param("site", "누리집").param("board", "공고").param("title", "사업"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.total").value(1))
+                .andExpect(jsonPath("$.items[0].seq").value(101));
+
+        // 사이트명만 주면 둘 다 나온다
+        mvc.perform(get("/api/orgs/{id}/review", orgId).param("site", "누리집"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.total").value(2));
+    }
+
+    @Test
+    void 일괄등록은_채운_항목만_반영하고_나머지는_유지한다() throws Exception {
+        jdbc.update("insert into review_item (org_id, seq, review_result, opinion) "
+                + "values (?, 201, '신유형 개방', '원래 의견')", orgId);
+        jdbc.update("insert into review_item (org_id, seq, review_result, opinion) "
+                + "values (?, 202, null, null)", orgId);
+        Long first = jdbc.queryForObject(
+                "select id from review_item where org_id = ? and seq = 201", Long.class, orgId);
+        Long second = jdbc.queryForObject(
+                "select id from review_item where org_id = ? and seq = 202", Long.class, orgId);
+
+        mvc.perform(put("/api/orgs/{id}/review/bulk", orgId).with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {"ids": [%d, %d], "judgedKoglType": "0유형"}
+                                """.formatted(first, second)))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.updated").value(2));
+
+        // 유형은 둘 다 채워지고, 보내지 않은 처리결과/의견은 그대로다
+        assertThat(jdbc.queryForObject(
+                "select judged_kogl_type from review_item where id = ?", String.class, first))
+                .isEqualTo("0유형");
+        assertThat(jdbc.queryForObject(
+                "select review_result from review_item where id = ?", String.class, first))
+                .isEqualTo("신유형 개방");
+        assertThat(jdbc.queryForObject(
+                "select opinion from review_item where id = ?", String.class, first))
+                .isEqualTo("원래 의견");
+        assertThat(jdbc.queryForObject(
+                "select judged_kogl_type from review_item where id = ?", String.class, second))
+                .isEqualTo("0유형");
+    }
+
+    @Test
+    void 일괄등록은_웹수정표시를_남겨_재업로드에_덮이지_않게_한다() throws Exception {
+        jdbc.update("insert into review_item (org_id, seq) values (?, 301)", orgId);
+        Long id = jdbc.queryForObject(
+                "select id from review_item where org_id = ? and seq = 301", Long.class, orgId);
+
+        mvc.perform(put("/api/orgs/{id}/review/bulk", orgId).with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {"ids": [%d], "reviewResult": "권리처리 추진"}
+                                """.formatted(id)))
+                .andExpect(status().isOk());
+
+        assertThat(jdbc.queryForObject(
+                "select web_edited_at is not null from review_item where id = ?", Boolean.class, id))
+                .isTrue();
+    }
+
+    @Test
+    void 일괄삭제는_고른_건만_지운다() throws Exception {
+        jdbc.update("insert into review_item (org_id, seq) values (?, 401)", orgId);
+        jdbc.update("insert into review_item (org_id, seq) values (?, 402)", orgId);
+        Long keep = jdbc.queryForObject(
+                "select id from review_item where org_id = ? and seq = 402", Long.class, orgId);
+        Long drop = jdbc.queryForObject(
+                "select id from review_item where org_id = ? and seq = 401", Long.class, orgId);
+
+        mvc.perform(post("/api/orgs/{id}/review/bulk-delete", orgId).with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("[%d]".formatted(drop)))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.deleted").value(1));
+
+        assertThat(jdbc.queryForObject(
+                "select count(*) from review_item where id = ?", Integer.class, drop)).isZero();
+        assertThat(jdbc.queryForObject(
+                "select count(*) from review_item where id = ?", Integer.class, keep)).isEqualTo(1);
+    }
 }
 
src/test/java/kr/itn/itnhub/system/SystemResetControllerTest.java (added)
+++ src/test/java/kr/itn/itnhub/system/SystemResetControllerTest.java
@@ -0,0 +1,158 @@
+package kr.itn.itnhub.system;
+
+import kr.itn.itnhub.AbstractDbTest;
+import kr.itn.itnhub.mattermost.MattermostClient;
+import kr.itn.itnhub.mattermost.MattermostException;
+import kr.itn.itnhub.org.Organization;
+import kr.itn.itnhub.org.OrganizationMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@AutoConfigureMockMvc
+@WithMockUser(roles = "ADMIN")
+class SystemResetControllerTest extends AbstractDbTest {
+
+    @Autowired
+    MockMvc mvc;
+
+    @Autowired
+    OrganizationMapper orgMapper;
+
+    @Autowired
+    JdbcTemplate jdbc;
+
+    @MockBean
+    MattermostClient mattermost;
+
+    private Long bothChannelsId;
+    private Long noChannelId;
+
+    @BeforeEach
+    void setUp() {
+        jdbc.update("delete from organization");
+
+        bothChannelsId = seed("001", "채널있는기관");
+        orgMapper.updateChannelIdMj(bothChannelsId, "ch-mj");
+        orgMapper.updateChannelIdLaw(bothChannelsId, "ch-law");
+        orgMapper.updateStage(bothChannelsId, 3);
+        jdbc.update("insert into stage_history (org_id, stage) values (?, 3)", bothChannelsId);
+
+        noChannelId = seed("002", "채널없는기관");
+    }
+
+    private Long seed(String orgNo, String orgName) {
+        Organization org = new Organization();
+        org.setOrgNo(orgNo);
+        org.setOrgName(orgName);
+        org.setChannelSlug(orgNo);
+        orgMapper.upsertBySeed(org);
+        return orgMapper.findByOrgNoAndOrgName(orgNo, orgName).getId();
+    }
+
+    @Test
+    void 미리보기는_DB가_아는_채널만_보여준다() throws Exception {
+        mvc.perform(get("/api/system/reset/preview"))
+                .andExpect(status().isOk())
+                // 채널 없는 기관은 아예 목록에 없다
+                .andExpect(jsonPath("$.length()").value(1))
+                .andExpect(jsonPath("$[0].orgNo").value("001"))
+                .andExpect(jsonPath("$[0].stage").value(3))
+                .andExpect(jsonPath("$[0].channels.length()").value(2))
+                .andExpect(jsonPath("$[0].channels[0].kind").value("문정원"))
+                .andExpect(jsonPath("$[0].channels[1].kind").value("법률검토"));
+    }
+
+    @Test
+    void 미리보기는_아무것도_바꾸지_않는다() throws Exception {
+        mvc.perform(get("/api/system/reset/preview")).andExpect(status().isOk());
+
+        assertThat(orgMapper.findById(bothChannelsId).getChannelIdMj()).isEqualTo("ch-mj");
+        verify(mattermost, never()).archiveChannel(anyString());
+    }
+
+    @Test
+    void 확인문구가_틀리면_400이고_아무것도_지우지_않는다() throws Exception {
+        mvc.perform(post("/api/system/reset/channels").with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"confirm\": \"초기화\"}"))
+                .andExpect(status().isBadRequest());
+
+        verify(mattermost, never()).archiveChannel(anyString());
+        assertThat(orgMapper.findById(bothChannelsId).getChannelIdMj()).isEqualTo("ch-mj");
+    }
+
+    @Test
+    void 초기화하면_채널을_보관하고_DB의_채널과_단계를_비운다() throws Exception {
+        mvc.perform(post("/api/system/reset/channels").with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"confirm\": \"채널 초기화\"}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.archivedChannels").value(2))
+                .andExpect(jsonPath("$.clearedOrgs").value(1))
+                .andExpect(jsonPath("$.failures.length()").value(0));
+
+        verify(mattermost).archiveChannel("ch-mj");
+        verify(mattermost).archiveChannel("ch-law");
+
+        Organization after = orgMapper.findById(bothChannelsId);
+        assertThat(after.getChannelIdMj()).isNull();
+        assertThat(after.getChannelIdLaw()).isNull();
+        assertThat(after.getStage()).isNull();
+        assertThat(jdbc.queryForObject("select count(*) from stage_history where org_id = ?",
+                Integer.class, bothChannelsId)).isZero();
+    }
+
+    @Test
+    void 담당자_배정과_권리확인_데이터는_건드리지_않는다() throws Exception {
+        jdbc.update("insert into review_item (org_id, seq, review_result) values (?, 1, '이용가능')",
+                bothChannelsId);
+        jdbc.update("insert into work_memo (org_id, contact_name, body) values (?, '시스템', '메모')",
+                bothChannelsId);
+
+        mvc.perform(post("/api/system/reset/channels").with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"confirm\": \"채널 초기화\"}"))
+                .andExpect(status().isOk());
+
+        assertThat(jdbc.queryForObject("select count(*) from review_item where org_id = ?",
+                Integer.class, bothChannelsId)).isEqualTo(1);
+        assertThat(jdbc.queryForObject("select count(*) from work_memo where org_id = ?",
+                Integer.class, bothChannelsId)).isEqualTo(1);
+        // 기관 자체도 남는다
+        assertThat(orgMapper.findById(bothChannelsId)).isNotNull();
+    }
+
+    @Test
+    void 한쪽_채널이_실패하면_그_기관의_DB는_그대로_두고_실패를_보고한다() throws Exception {
+        doThrow(new MattermostException("권한 없음")).when(mattermost).archiveChannel("ch-law");
+
+        mvc.perform(post("/api/system/reset/channels").with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"confirm\": \"채널 초기화\"}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.archivedChannels").value(1))
+                .andExpect(jsonPath("$.clearedOrgs").value(0))
+                .andExpect(jsonPath("$.failures.length()").value(1));
+
+        // 남은 채널을 다시 찾을 수 있어야 하므로 ID를 지우지 않는다
+        assertThat(orgMapper.findById(bothChannelsId).getChannelIdLaw()).isEqualTo("ch-law");
+    }
+}
Add a comment
List