이호영 이호영 07-28
feat: 확정 요구사항 반영 (4·5·6·8·15·25)
  [4][5] RE 메모  기관관리 RE 탭을 메모장처럼 만들었다. 메모 1개가 RE 1개다.
                  내용·작성자·일자에 완료 체크 한 칸을 더했고, 대시보드 '미해결 RE'가
                  이제 실제 숫자를 센다(예전에는 항상 0으로 고정돼 있었다).
                  완료 체크는 회신에 없던 항목이라 확인 요청해 둔 상태다.
  [6]     업무구간 필터를 뺐다. 현재 단계와 겹친다는 지적을 반영했고 순서도 현재 단계를
                  앞으로 옮겼다. 구간 구분 자체는 칸반 컬럼 색에 계속 쓰이므로 남겨 둔다.
  [8]     자료접수를 권리확인·권리처리 통합으로 바꿨다. 어느 쪽이든 한 건이라도 있으면 접수다.
  [15]    일괄 등록에 비고를 더했다. 권리확인·권리처리 양쪽 모두.
  [25]    보고서 교체 기능을 넣었다. 목록에 새 줄이 생기지 않고 그 자리만 바뀐다.
          개정판으로 남기고 싶으면 지금처럼 새로 올리면 된다.

RE 메모의 해결 여부를 뒤집으면 대시보드 숫자가 실제로 따라 움직이는지까지 테스트한다.

백엔드 275건, 프론트 208건 통과.

Co-Authored-By: Claude Opus 5 (1M context) 
@4dee05fbdb7ae41a372a881500fb37f7edc95151
frontend/src/api/client.ts
--- frontend/src/api/client.ts
+++ frontend/src/api/client.ts
@@ -171,6 +171,53 @@
   return request<CodeMap>('/api/meta/codes')
 }
 
+/** RE 메모 한 건. 요구사항 [4]대로 메모 1개가 RE 1개다. */
+export interface ReMemo {
+  id: number
+  orgId: number
+  content: string
+  author: string
+  /** yyyy-MM-dd */
+  memoDate: string
+  resolved: boolean
+  createdAt: number
+}
+
+export interface ReMemoInput {
+  content: string
+  author: string
+  memoDate: string
+  resolved: boolean
+}
+
+export function getReMemos(orgId: number): Promise<ReMemo[]> {
+  return request<ReMemo[]>(`/api/orgs/${orgId}/re-memos`)
+}
+
+export function addReMemo(orgId: number, body: ReMemoInput): Promise<ReMemo[]> {
+  return request<ReMemo[]>(`/api/orgs/${orgId}/re-memos`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(body),
+  })
+}
+
+export function updateReMemo(orgId: number, memoId: number, body: ReMemoInput): Promise<ReMemo[]> {
+  return request<ReMemo[]>(`/api/orgs/${orgId}/re-memos/${memoId}`, {
+    method: 'PUT',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(body),
+  })
+}
+
+export async function deleteReMemo(orgId: number, memoId: number): Promise<void> {
+  await fetch(`/api/orgs/${orgId}/re-memos/${memoId}`, {
+    method: 'DELETE',
+    headers: { 'X-XSRF-TOKEN': csrfToken() },
+    credentials: 'same-origin',
+  })
+}
+
 export function updateAssignments(orgId: number, body: AssignmentInput): Promise<Org> {
   return request<Org>(`/api/orgs/${orgId}/assignments`, {
     method: 'PUT',
@@ -717,7 +764,14 @@
 /** 권리확인 일괄 등록. 비운 항목은 기존 값을 유지한다. */
 export function bulkUpdateReview(
   orgId: number,
-  body: { ids: number[]; reviewResult?: string; judgedKoglType?: string; opinion?: string },
+  body: {
+    ids: number[]
+    reviewResult?: string
+    judgedKoglType?: string
+    opinion?: string
+    /** 요구사항 [15]로 추가된 비고. */
+    lawyerNote?: string
+  },
 ): Promise<{ updated: number }> {
   return request<{ updated: number }>(`/api/orgs/${orgId}/review/bulk`, {
     method: 'PUT',
@@ -737,7 +791,14 @@
 /** 권리처리 일괄 등록. 비운 항목은 기존 값을 유지한다. */
 export function bulkUpdateProcess(
   orgId: number,
-  body: { ids: number[]; processStatus?: string; judgedKoglType?: string; finalOpinion?: string },
+  body: {
+    ids: number[]
+    processStatus?: string
+    judgedKoglType?: string
+    finalOpinion?: string
+    /** 요구사항 [15]로 추가된 비고. */
+    reviewNote?: string
+  },
 ): Promise<{ updated: number }> {
   return request<{ updated: number }>(`/api/orgs/${orgId}/process/bulk`, {
     method: 'PUT',
frontend/src/components/Dashboard.tsx
--- frontend/src/components/Dashboard.tsx
+++ frontend/src/components/Dashboard.tsx
@@ -177,8 +177,7 @@
   const [busy, setBusy] = useState(false)
   const [warning, setWarning] = useState<string | null>(null)
 
-  // 칸반 필터
-  const [groupFilter, setGroupFilter] = useState('')
+  // 칸반 필터 (업무 구간은 요구사항 [6]으로 제거했다)
   const [lawyerFilter, setLawyerFilter] = useState('')
   const [stageFilter, setStageFilter] = useState('')
   const [statusFilter, setStatusFilter] = useState('')
@@ -221,9 +220,6 @@
     const keyword = boardKeyword.trim()
     return rows.filter((row) => {
       const stage = columnOf(row)
-      if (groupFilter && stages.groupKey(stage) !== groupFilter) {
-        return false
-      }
       if (lawyerFilter && row.lawyerName !== lawyerFilter) {
         return false
       }
@@ -247,7 +243,7 @@
       }
       return true
     })
-  }, [rows, groupFilter, lawyerFilter, stageFilter, statusFilter, boardKeyword, stages])
+  }, [rows, lawyerFilter, stageFilter, statusFilter, boardKeyword, stages])
 
   /** 시안의 "정렬 기준 최근 진행순" - 칸반 카드도 최근 변동이 위로 온다. */
   const byRecent = useCallback(
@@ -271,10 +267,13 @@
         if (tableLawyer && row.lawyerName !== tableLawyer) {
           return false
         }
-        if (tableData === 'received' && row.reviewTotal === 0) {
+        // 요구사항 [8]: 권리확인·권리처리를 구분하지 않는다. 어느 쪽이든 한 건이라도
+        // 올라와 있으면 접수로 본다.
+        const received = row.reviewTotal + row.processTotal > 0
+        if (tableData === 'received' && !received) {
           return false
         }
-        if (tableData === 'pending' && row.reviewTotal > 0) {
+        if (tableData === 'pending' && received) {
           return false
         }
         return true
@@ -388,16 +387,21 @@
           <HelpButton topic="dashboard.kanban" />
         </h2>
 
+        {/*
+          요구사항 [6]: 업무 구간 필터는 뺐다. 현재 단계와 기능이 겹친다는 지적이 있었고,
+          순서도 현재 단계를 앞으로 옮겼다. 구간 구분 자체는 칸반 컬럼 색에 계속 쓰이므로
+          코드표에는 그대로 남겨 둔다.
+        */}
         <div className="mt-3 flex flex-wrap gap-2">
           <select
-            aria-label="업무 구간"
-            value={groupFilter}
-            onChange={(e) => setGroupFilter(e.target.value)}
+            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>
-            {stages.groups.map((g) => (
-              <option key={g.code} value={g.code}>{g.label}</option>
+            <option value="">현재 단계 전체</option>
+            {stages.numbers.map((n) => (
+              <option key={n} value={String(n)}>{stages.symbol(n)} {stages.label(n)}</option>
             ))}
           </select>
           <select
@@ -409,17 +413,6 @@
             <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>
-            {stages.numbers.map((n) => (
-              <option key={n} value={String(n)}>{stages.symbol(n)} {stages.label(n)}</option>
             ))}
           </select>
           <select
frontend/src/components/OrgOverview.test.tsx
--- frontend/src/components/OrgOverview.test.tsx
+++ frontend/src/components/OrgOverview.test.tsx
@@ -15,6 +15,7 @@
   getStageHistory: vi.fn(),
   getReviewPage: vi.fn(),
   getProcessPage: vi.fn(),
+  getReMemos: vi.fn(),
 }))
 
 vi.mock('../api/client', async (importOriginal) => ({
@@ -29,6 +30,7 @@
   getStageHistory: mocks.getStageHistory,
   getReviewPage: mocks.getReviewPage,
   getProcessPage: mocks.getProcessPage,
+  getReMemos: mocks.getReMemos,
 }))
 
 function contact(overrides: Partial<Contact> = {}): Contact {
@@ -282,14 +284,18 @@
     expect(screen.queryByRole('button', { name: '문정원' })).toBeNull()
   })
 
-  it('탭을 누르면 해당 탭의 자리표시 내용으로 바뀐다', async () => {
+  // 요구사항 [4][5]로 RE 탭에 실제 화면(메모장)이 생겼다. 자리표시가 아니다.
+  it('RE 탭을 누르면 메모 입력 화면이 나온다', async () => {
+    mocks.getReMemos.mockResolvedValue([])
     render(withCodes(<OrgOverview org={org()} />))
 
     await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
 
     fireEvent.click(screen.getByRole('button', { name: 'RE' }))
 
-    expect(screen.getByText('RE 상세 화면은 추후 확정')).toBeTruthy()
+    await waitFor(() => expect(mocks.getReMemos).toHaveBeenCalledWith(1))
+    expect(screen.getByLabelText('RE 내용')).toBeTruthy()
+    expect(screen.getByLabelText('작성자')).toBeTruthy()
     expect(screen.queryByRole('button', { name: '문정원' })).toBeNull()
   })
 
frontend/src/components/OrgOverview.tsx
--- frontend/src/components/OrgOverview.tsx
+++ frontend/src/components/OrgOverview.tsx
@@ -17,6 +17,7 @@
 import ChannelPosts from './ChannelPosts'
 import ContactPickerModal from './ContactPickerModal'
 import ProcessBoard from './ProcessBoard'
+import ReMemos from './ReMemos'
 import ReviewBoard from './ReviewBoard'
 import StatusBadge from './StatusBadge'
 import Timeline from './Timeline'
@@ -28,7 +29,7 @@
 
 const TABS = ['채팅', '자료', '권리확인', '권리처리', 'RE', '타임라인', '업무메모']
 
-/** 탭마다 그 탭 기능의 설명서를 띄운다. RE는 화면이 아직 없어 도움말도 없다. */
+/** 탭마다 그 탭 기능의 설명서를 띄운다. */
 const TAB_HELP: Record<string, string> = {
   '채팅': 'org.chat',
   '자료': 'org.files',
@@ -595,6 +596,10 @@
           <div className="mt-4 rounded-lg border border-gray-200">
             <Timeline key={org.id} org={org} />
           </div>
+        ) : activeTab === 'RE' ? (
+          <div className="mt-4 rounded-lg border border-gray-200">
+            <ReMemos key={org.id} org={org} />
+          </div>
         ) : (
           <div className="mt-4 rounded-lg border border-dashed border-gray-200 p-10 text-center text-sm text-gray-400">
             {activeTab} 상세 화면은 추후 확정
frontend/src/components/ProcessBoard.tsx
--- frontend/src/components/ProcessBoard.tsx
+++ frontend/src/components/ProcessBoard.tsx
@@ -218,6 +218,8 @@
   const [bulkStatus, setBulkStatus] = useState('')
   const [bulkKoglType, setBulkKoglType] = useState('')
   const [bulkOpinion, setBulkOpinion] = useState('')
+  /** 요구사항 [15]로 추가된 비고 일괄 입력. */
+  const [bulkNote, setBulkNote] = useState('')
   const [bulkBusy, setBulkBusy] = useState(false)
   const [bulkMessage, setBulkMessage] = useState<string | null>(null)
   const [loading, setLoading] = useState(false)
@@ -325,7 +327,7 @@
     if (selected.length === 0 || bulkBusy) {
       return
     }
-    if (!bulkStatus && !bulkKoglType && !bulkOpinion.trim()) {
+    if (!bulkStatus && !bulkKoglType && !bulkOpinion.trim() && !bulkNote.trim()) {
       setBulkMessage('반영할 값을 하나 이상 채워주세요.')
       return
     }
@@ -337,12 +339,14 @@
         processStatus: bulkStatus || undefined,
         judgedKoglType: bulkKoglType || undefined,
         finalOpinion: bulkOpinion.trim() || undefined,
+        reviewNote: bulkNote.trim() || undefined,
       })
       setBulkMessage(`${res.updated}건에 반영했습니다.`)
       setSelected([])
       setBulkStatus('')
       setBulkKoglType('')
       setBulkOpinion('')
+      setBulkNote('')
       setReloadKey((k) => k + 1)
     } catch (e) {
       setBulkMessage(e instanceof Error ? e.message : '일괄 등록에 실패했습니다.')
@@ -552,6 +556,14 @@
           placeholder="최종의견(비우면 유지)"
           className="w-56 rounded-md border border-gray-300 px-2 py-1.5 text-sm"
         />
+        <input
+          type="text"
+          aria-label="일괄 비고"
+          value={bulkNote}
+          onChange={(e) => setBulkNote(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}
 
frontend/src/components/ReMemos.tsx (added)
+++ frontend/src/components/ReMemos.tsx
@@ -0,0 +1,231 @@
+import { useCallback, useEffect, useState } from 'react'
+import {
+  addReMemo,
+  deleteReMemo,
+  getReMemos,
+  updateReMemo,
+  type Org,
+  type ReMemo,
+} from '../api/client'
+
+/**
+ * 기관관리 상세의 RE 탭.
+ *
+ * 요구사항 [4]: 별도 양식 없이 메모장처럼 등록한다. 메모 1개가 RE 1개다.
+ *   예) ooo 계약서 및 제안요청서 송부 요청, 장영익, 2026.07.08
+ *
+ * 완료 체크는 회신에 없던 항목이다. 대시보드 '미해결 RE' 숫자를 세려면 해결된 건과 아닌 건을
+ * 가릴 수단이 필요해 한 칸 두었다.
+ */
+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 ReMemos({ org }: { org: Org }) {
+  const [memos, setMemos] = useState<ReMemo[]>([])
+  const [loading, setLoading] = useState(true)
+  const [error, setError] = useState<string | null>(null)
+  const [busy, setBusy] = useState(false)
+
+  const [content, setContent] = useState('')
+  const [author, setAuthor] = useState('')
+  const [memoDate, setMemoDate] = useState(today())
+  const [editingId, setEditingId] = useState<number | null>(null)
+
+  const load = useCallback(async () => {
+    setLoading(true)
+    try {
+      setMemos(await getReMemos(org.id))
+      setError(null)
+    } catch (e) {
+      setError((e as Error).message)
+    } finally {
+      setLoading(false)
+    }
+  }, [org.id])
+
+  useEffect(() => {
+    void load()
+  }, [load])
+
+  function resetForm() {
+    setContent('')
+    setAuthor('')
+    setMemoDate(today())
+    setEditingId(null)
+  }
+
+  async function handleSave() {
+    if (!content.trim() || !author.trim() || busy) {
+      return
+    }
+    setBusy(true)
+    setError(null)
+    try {
+      const body = { content: content.trim(), author: author.trim(), memoDate, resolved: false }
+      const next = editingId === null
+        ? await addReMemo(org.id, body)
+        : await updateReMemo(org.id, editingId, {
+            ...body,
+            resolved: memos.find((m) => m.id === editingId)?.resolved ?? false,
+          })
+      setMemos(next)
+      resetForm()
+    } catch (e) {
+      setError((e as Error).message)
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  async function toggleResolved(memo: ReMemo) {
+    setBusy(true)
+    try {
+      setMemos(await updateReMemo(org.id, memo.id, {
+        content: memo.content,
+        author: memo.author,
+        memoDate: memo.memoDate,
+        resolved: !memo.resolved,
+      }))
+    } catch (e) {
+      setError((e as Error).message)
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  async function handleDelete(memo: ReMemo) {
+    if (!window.confirm('이 RE를 지울까요?')) {
+      return
+    }
+    setBusy(true)
+    try {
+      await deleteReMemo(org.id, memo.id)
+      await load()
+      if (editingId === memo.id) {
+        resetForm()
+      }
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  function startEdit(memo: ReMemo) {
+    setEditingId(memo.id)
+    setContent(memo.content)
+    setAuthor(memo.author)
+    setMemoDate(memo.memoDate)
+  }
+
+  const unresolved = memos.filter((m) => !m.resolved).length
+
+  return (
+    <div className="p-5">
+      <div className="flex items-center justify-between">
+        <h3 className="text-sm font-semibold">
+          RE <span className="ml-1 text-xs font-normal text-gray-500">미해결 {unresolved}건 / 전체 {memos.length}건</span>
+        </h3>
+      </div>
+
+      <div className="mt-3 rounded-lg border border-gray-200 p-3">
+        <textarea
+          aria-label="RE 내용"
+          value={content}
+          onChange={(e) => setContent(e.target.value)}
+          rows={2}
+          placeholder="예) ooo 계약서 및 제안요청서 송부 요청"
+          className="w-full rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+        />
+        <div className="mt-2 flex flex-wrap items-center gap-2">
+          <input
+            aria-label="작성자"
+            value={author}
+            onChange={(e) => setAuthor(e.target.value)}
+            placeholder="작성자"
+            className="w-32 rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+          />
+          <input
+            type="date"
+            aria-label="일자"
+            value={memoDate}
+            onChange={(e) => setMemoDate(e.target.value)}
+            className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+          />
+          <button
+            type="button"
+            disabled={!content.trim() || !author.trim() || busy}
+            onClick={() => void handleSave()}
+            className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white disabled:bg-gray-300"
+          >
+            {editingId === null ? '등록' : '수정'}
+          </button>
+          {editingId !== null && (
+            <button
+              type="button"
+              onClick={resetForm}
+              className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600"
+            >
+              취소
+            </button>
+          )}
+        </div>
+      </div>
+
+      {error && <p className="mt-2 text-sm text-red-600">{error}</p>}
+
+      {loading ? (
+        <p className="mt-4 text-sm text-gray-500">불러오는 중…</p>
+      ) : memos.length === 0 ? (
+        <p className="mt-4 text-sm text-gray-500">등록된 RE가 없습니다.</p>
+      ) : (
+        <ul className="mt-4 space-y-2">
+          {memos.map((memo) => (
+            <li
+              key={memo.id}
+              className={`rounded-lg border p-3 ${
+                memo.resolved ? 'border-gray-200 bg-gray-50' : 'border-amber-200 bg-amber-50/40'
+              }`}
+            >
+              <div className="flex items-start justify-between gap-3">
+                <p className={`whitespace-pre-wrap text-sm ${memo.resolved ? 'text-gray-400 line-through' : 'text-gray-900'}`}>
+                  {memo.content}
+                </p>
+                <div className="flex shrink-0 gap-2">
+                  <button
+                    type="button"
+                    disabled={busy}
+                    onClick={() => void toggleResolved(memo)}
+                    className="rounded-md border border-gray-300 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50"
+                  >
+                    {memo.resolved ? '미해결로' : '해결'}
+                  </button>
+                  <button
+                    type="button"
+                    disabled={busy}
+                    onClick={() => startEdit(memo)}
+                    className="rounded-md border border-gray-300 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50"
+                  >
+                    수정
+                  </button>
+                  <button
+                    type="button"
+                    disabled={busy}
+                    onClick={() => void handleDelete(memo)}
+                    className="rounded-md border border-gray-300 px-2 py-0.5 text-xs text-red-600 hover:bg-red-50"
+                  >
+                    삭제
+                  </button>
+                </div>
+              </div>
+              <p className="mt-1.5 text-xs text-gray-500">
+                {memo.author} · {memo.memoDate}
+              </p>
+            </li>
+          ))}
+        </ul>
+      )}
+    </div>
+  )
+}
frontend/src/components/ReviewBoard.tsx
--- frontend/src/components/ReviewBoard.tsx
+++ frontend/src/components/ReviewBoard.tsx
@@ -202,6 +202,8 @@
   const [bulkResult, setBulkResult] = useState('')
   const [bulkKoglType, setBulkKoglType] = useState('')
   const [bulkOpinion, setBulkOpinion] = useState('')
+  /** 요구사항 [15]로 추가된 비고 일괄 입력. */
+  const [bulkNote, setBulkNote] = useState('')
   const [bulkBusy, setBulkBusy] = useState(false)
   const [bulkMessage, setBulkMessage] = useState<string | null>(null)
   const [loading, setLoading] = useState(false)
@@ -285,7 +287,7 @@
     if (selected.length === 0 || bulkBusy) {
       return
     }
-    if (!bulkResult && !bulkKoglType && !bulkOpinion.trim()) {
+    if (!bulkResult && !bulkKoglType && !bulkOpinion.trim() && !bulkNote.trim()) {
       setBulkMessage('반영할 값을 하나 이상 채워주세요.')
       return
     }
@@ -297,12 +299,14 @@
         reviewResult: bulkResult || undefined,
         judgedKoglType: bulkKoglType || undefined,
         opinion: bulkOpinion.trim() || undefined,
+        lawyerNote: bulkNote.trim() || undefined,
       })
       setBulkMessage(`${res.updated}건에 반영했습니다.`)
       setSelected([])
       setBulkResult('')
       setBulkKoglType('')
       setBulkOpinion('')
+      setBulkNote('')
       setReloadKey((k) => k + 1)
     } catch (e) {
       setBulkMessage(e instanceof Error ? e.message : '일괄 등록에 실패했습니다.')
@@ -507,6 +511,14 @@
           placeholder="의견(비우면 유지)"
           className="w-56 rounded-md border border-gray-300 px-2 py-1.5 text-sm"
         />
+        <input
+          type="text"
+          aria-label="일괄 비고"
+          value={bulkNote}
+          onChange={(e) => setBulkNote(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}
src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
--- src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
+++ src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
@@ -7,6 +7,7 @@
 import kr.itn.itnhub.org.OrgNotFoundException;
 import kr.itn.itnhub.process.InvalidProcessStatusException;
 import kr.itn.itnhub.process.ProcessNotFoundException;
+import kr.itn.itnhub.re.ReMemoNotFoundException;
 import kr.itn.itnhub.review.ReviewNotFoundException;
 import kr.itn.itnhub.seed.SeedParseException;
 import kr.itn.itnhub.stage.InvalidStageException;
@@ -85,6 +86,11 @@
         return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ApiError(e.getMessage()));
     }
 
+    @ExceptionHandler(ReMemoNotFoundException.class)
+    public ResponseEntity<ApiError> handleReMemoNotFound(ReMemoNotFoundException e) {
+        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ApiError(e.getMessage()));
+    }
+
     @ExceptionHandler(MethodArgumentNotValidException.class)
     public ResponseEntity<ApiError> handleValidation(MethodArgumentNotValidException e) {
         String fields = e.getBindingResult().getFieldErrors().stream()
src/main/java/kr/itn/itnhub/process/BulkProcessingRequest.java
--- src/main/java/kr/itn/itnhub/process/BulkProcessingRequest.java
+++ src/main/java/kr/itn/itnhub/process/BulkProcessingRequest.java
@@ -10,5 +10,7 @@
         List<Long> ids,
         String processStatus,
         String judgedKoglType,
-        String finalOpinion) {
+        String finalOpinion,
+        /** 요구사항 [15]로 추가된 항목. 화면의 '비고'와 같은 칸이다. */
+        String reviewNote) {
 }
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
@@ -38,6 +38,7 @@
                              @Param("processStatus") String processStatus,
                              @Param("judgedKoglType") String judgedKoglType,
                              @Param("finalOpinion") String finalOpinion,
+                             @Param("reviewNote") String reviewNote,
                              @Param("doneStatus") String doneStatus);
 
     /** 일괄 삭제. */
src/main/java/kr/itn/itnhub/process/ProcessService.java (Binary)
--- src/main/java/kr/itn/itnhub/process/ProcessService.java
+++ src/main/java/kr/itn/itnhub/process/ProcessService.java
Binary file is not shown
src/main/java/kr/itn/itnhub/project/ProjectController.java
--- src/main/java/kr/itn/itnhub/project/ProjectController.java
+++ src/main/java/kr/itn/itnhub/project/ProjectController.java
@@ -95,6 +95,17 @@
                 .body(report.content());
     }
 
+    /** 올려둔 보고서를 새 파일로 바꾼다(요구사항 [25]). 목록에 새 줄이 생기지 않는다. */
+    @PutMapping("/api/orgs/{id}/reports/{reportId}")
+    public List<OrgReport> replaceReport(@PathVariable("id") Long orgId,
+                                         @PathVariable Long reportId,
+                                         @RequestPart("file") MultipartFile file,
+                                         Principal principal) throws IOException {
+        String name = file.getOriginalFilename() == null ? "report" : file.getOriginalFilename();
+        return projectService.replaceReport(orgId, reportId, name, file.getContentType(),
+                file.getBytes(), principal.getName());
+    }
+
     @ResponseStatus(HttpStatus.NO_CONTENT)
     @DeleteMapping("/api/orgs/{id}/reports/{reportId}")
     public void deleteReport(@PathVariable("id") Long orgId, @PathVariable Long reportId) {
src/main/java/kr/itn/itnhub/project/ProjectMapper.java
--- src/main/java/kr/itn/itnhub/project/ProjectMapper.java
+++ src/main/java/kr/itn/itnhub/project/ProjectMapper.java
@@ -29,6 +29,12 @@
                      @Param("contentType") String contentType, @Param("byteSize") long byteSize,
                      @Param("content") byte[] content, @Param("uploadedBy") String uploadedBy);
 
+    /** 보고서 교체(요구사항 [25]). 새 행을 만들지 않고 같은 자리를 덮어쓴다. */
+    int updateReport(@Param("orgId") Long orgId, @Param("id") Long id,
+                     @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);
 
src/main/java/kr/itn/itnhub/project/ProjectService.java
--- src/main/java/kr/itn/itnhub/project/ProjectService.java
+++ src/main/java/kr/itn/itnhub/project/ProjectService.java
@@ -99,6 +99,25 @@
         return mapper.findReportsByOrg(orgId);
     }
 
+    /**
+     * 올려둔 보고서를 새 파일로 바꾼다(요구사항 [25]). 목록에 새 줄이 생기지 않고 그 자리만
+     * 바뀐다 - 개정판으로 남기고 싶으면 새로 올리면 된다.
+     */
+    @Transactional
+    public List<OrgReport> replaceReport(Long orgId, Long reportId, String fileName,
+                                         String contentType, byte[] content, String uploadedBy) {
+        requireOrg(orgId);
+        if (content == null || content.length == 0) {
+            throw new IllegalArgumentException("빈 파일은 올릴 수 없습니다.");
+        }
+        int updated = mapper.updateReport(orgId, reportId, fileName, contentType, content.length,
+                content, uploadedBy);
+        if (updated == 0) {
+            throw new OrgNotFoundException("보고서를 찾을 수 없습니다: " + reportId);
+        }
+        return mapper.findReportsByOrg(orgId);
+    }
+
     public ProjectMapper.ReportContent reportContent(Long orgId, Long reportId) {
         requireOrg(orgId);
         ProjectMapper.ReportContent content = mapper.findReportContent(orgId, reportId);
 
src/main/java/kr/itn/itnhub/re/ReMemo.java (added)
+++ src/main/java/kr/itn/itnhub/re/ReMemo.java
@@ -0,0 +1,18 @@
+package kr.itn.itnhub.re;
+
+/**
+ * RE 메모 한 건. 요구사항 [4]대로 "메모 1개 = RE 1개"다.
+ *
+ * <p>예) 내용 "ooo 계약서 및 제안요청서 송부 요청" / 작성자 "장영익" / 일자 2026-07-08</p>
+ *
+ * @param resolved 해결 여부. 대시보드의 '미해결 RE'는 이 값이 false인 건수다.
+ */
+public record ReMemo(
+        Long id,
+        Long orgId,
+        String content,
+        String author,
+        String memoDate,
+        boolean resolved,
+        Long createdAt) {
+}
 
src/main/java/kr/itn/itnhub/re/ReMemoController.java (added)
+++ src/main/java/kr/itn/itnhub/re/ReMemoController.java
@@ -0,0 +1,48 @@
+package kr.itn.itnhub.re;
+
+import jakarta.validation.Valid;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.DeleteMapping;
+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.util.List;
+
+/** 기관관리 상세의 RE 탭. 등록·수정·삭제 후에는 갱신된 목록을 그대로 돌려준다. */
+@RestController
+public class ReMemoController {
+
+    private final ReMemoService service;
+
+    public ReMemoController(ReMemoService service) {
+        this.service = service;
+    }
+
+    @GetMapping("/api/orgs/{id}/re-memos")
+    public List<ReMemo> list(@PathVariable("id") Long orgId) {
+        return service.list(orgId);
+    }
+
+    @PostMapping("/api/orgs/{id}/re-memos")
+    public List<ReMemo> add(@PathVariable("id") Long orgId,
+                            @Valid @RequestBody ReMemoRequest request) {
+        return service.add(orgId, request);
+    }
+
+    @PutMapping("/api/orgs/{id}/re-memos/{memoId}")
+    public List<ReMemo> update(@PathVariable("id") Long orgId, @PathVariable Long memoId,
+                               @Valid @RequestBody ReMemoRequest request) {
+        return service.update(orgId, memoId, request);
+    }
+
+    @ResponseStatus(HttpStatus.NO_CONTENT)
+    @DeleteMapping("/api/orgs/{id}/re-memos/{memoId}")
+    public void delete(@PathVariable("id") Long orgId, @PathVariable Long memoId) {
+        service.delete(orgId, memoId);
+    }
+}
 
src/main/java/kr/itn/itnhub/re/ReMemoMapper.java (added)
+++ src/main/java/kr/itn/itnhub/re/ReMemoMapper.java
@@ -0,0 +1,28 @@
+package kr.itn.itnhub.re;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+@Mapper
+public interface ReMemoMapper {
+
+    /** 기관의 RE 메모를 일자 최신순으로. */
+    List<ReMemo> findByOrg(@Param("orgId") Long orgId);
+
+    ReMemo findById(@Param("orgId") Long orgId, @Param("id") Long id);
+
+    int insert(@Param("orgId") Long orgId, @Param("content") String content,
+               @Param("author") String author, @Param("memoDate") String memoDate,
+               @Param("resolved") boolean resolved);
+
+    int update(@Param("orgId") Long orgId, @Param("id") Long id,
+               @Param("content") String content, @Param("author") String author,
+               @Param("memoDate") String memoDate, @Param("resolved") boolean resolved);
+
+    int delete(@Param("orgId") Long orgId, @Param("id") Long id);
+
+    /** 채널 초기화 시 함께 지운다 - 기관 데이터가 되돌아가는데 메모만 남으면 어긋난다. */
+    int deleteByOrg(@Param("orgId") Long orgId);
+}
 
src/main/java/kr/itn/itnhub/re/ReMemoNotFoundException.java (added)
+++ src/main/java/kr/itn/itnhub/re/ReMemoNotFoundException.java
@@ -0,0 +1,9 @@
+package kr.itn.itnhub.re;
+
+/** 없는 RE 메모를 고치거나 지우려 할 때. GlobalExceptionHandler가 404로 바꾼다. */
+public class ReMemoNotFoundException extends RuntimeException {
+
+    public ReMemoNotFoundException(String message) {
+        super(message);
+    }
+}
 
src/main/java/kr/itn/itnhub/re/ReMemoRequest.java (added)
+++ src/main/java/kr/itn/itnhub/re/ReMemoRequest.java
@@ -0,0 +1,20 @@
+package kr.itn.itnhub.re;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+/** RE 메모 등록/수정 입력. 일자를 비우면 오늘로 넣는다. */
+public record ReMemoRequest(
+        @NotBlank(message = "내용을 입력하세요.")
+        @Size(max = 2000, message = "내용은 2000자까지 넣을 수 있습니다.")
+        String content,
+
+        @NotBlank(message = "작성자를 입력하세요.")
+        @Size(max = 100, message = "작성자는 100자까지 넣을 수 있습니다.")
+        String author,
+
+        /** yyyy-MM-dd. 비우면 오늘. */
+        String memoDate,
+
+        boolean resolved) {
+}
 
src/main/java/kr/itn/itnhub/re/ReMemoService.java (added)
+++ src/main/java/kr/itn/itnhub/re/ReMemoService.java
@@ -0,0 +1,67 @@
+package kr.itn.itnhub.re;
+
+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.List;
+
+/**
+ * 기관관리 상세의 RE 탭이 쓰는 서비스.
+ *
+ * <p>요구사항 [4]: 별도 양식 없이 메모장처럼 등록한다. 메모 1개가 RE 1개다.</p>
+ */
+@Service
+public class ReMemoService {
+
+    private final ReMemoMapper mapper;
+    private final OrganizationMapper orgMapper;
+
+    public ReMemoService(ReMemoMapper mapper, OrganizationMapper orgMapper) {
+        this.mapper = mapper;
+        this.orgMapper = orgMapper;
+    }
+
+    public List<ReMemo> list(Long orgId) {
+        requireOrg(orgId);
+        return mapper.findByOrg(orgId);
+    }
+
+    @Transactional
+    public List<ReMemo> add(Long orgId, ReMemoRequest request) {
+        requireOrg(orgId);
+        mapper.insert(orgId, request.content().trim(), request.author().trim(),
+                blankToNull(request.memoDate()), request.resolved());
+        return mapper.findByOrg(orgId);
+    }
+
+    @Transactional
+    public List<ReMemo> update(Long orgId, Long memoId, ReMemoRequest request) {
+        requireOrg(orgId);
+        int updated = mapper.update(orgId, memoId, request.content().trim(),
+                request.author().trim(), blankToNull(request.memoDate()), request.resolved());
+        if (updated == 0) {
+            throw new ReMemoNotFoundException("RE 메모를 찾을 수 없습니다: " + memoId);
+        }
+        return mapper.findByOrg(orgId);
+    }
+
+    @Transactional
+    public void delete(Long orgId, Long memoId) {
+        requireOrg(orgId);
+        if (mapper.delete(orgId, memoId) == 0) {
+            throw new ReMemoNotFoundException("RE 메모를 찾을 수 없습니다: " + memoId);
+        }
+    }
+
+    private String blankToNull(String value) {
+        return (value == null || value.isBlank()) ? null : value.trim();
+    }
+
+    private void requireOrg(Long orgId) {
+        if (orgMapper.findById(orgId) == null) {
+            throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
+        }
+    }
+}
src/main/java/kr/itn/itnhub/review/BulkJudgmentRequest.java
--- src/main/java/kr/itn/itnhub/review/BulkJudgmentRequest.java
+++ src/main/java/kr/itn/itnhub/review/BulkJudgmentRequest.java
@@ -12,5 +12,7 @@
         List<Long> ids,
         String reviewResult,
         String judgedKoglType,
-        String opinion) {
+        String opinion,
+        /** 요구사항 [15]로 추가된 항목. 화면의 '비고(변호사)'와 같은 칸이다. */
+        String lawyerNote) {
 }
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
@@ -29,7 +29,8 @@
     int updateJudgmentBulk(@Param("orgId") Long orgId, @Param("ids") List<Long> ids,
                            @Param("reviewResult") String reviewResult,
                            @Param("judgedKoglType") String judgedKoglType,
-                           @Param("opinion") String opinion);
+                           @Param("opinion") String opinion,
+                           @Param("lawyerNote") String lawyerNote);
 
     /** 일괄 삭제. 목록에서 여러 건을 골라 지울 때 쓴다. */
     int deleteByIds(@Param("orgId") Long orgId, @Param("ids") List<Long> ids);
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
@@ -65,7 +65,8 @@
         return mapper.updateJudgmentBulk(orgId, request.ids(),
                 blankToNull(request.reviewResult()),
                 blankToNull(request.judgedKoglType()),
-                blankToNull(request.opinion()));
+                blankToNull(request.opinion()),
+                blankToNull(request.lawyerNote()));
     }
 
     /** 목록에서 고른 여러 건을 지운다. */
 
src/main/resources/db/migration/V16__re_memo.sql (added)
+++ src/main/resources/db/migration/V16__re_memo.sql
@@ -0,0 +1,24 @@
+-- RE 메모(요구사항 [4][5]).
+--
+-- 회신 내용: "별도 양식까진 필요없고, 기관관리 메뉴의 RE 메뉴에서 메모장처럼 등록하는
+-- 기능을 만들어 주시면 됩니다. 메모 1개당 RE 1개입니다."
+--   예) ooo 계약서 및 제안요청서 송부 요청, 장영익, 2026.07.08
+--
+-- resolved(해결 여부)는 회신에 없던 항목이다. 대시보드 '미해결 RE' 숫자를 세려면 해결된
+-- 건과 아닌 건을 가릴 수단이 있어야 해서 완료 체크 한 칸으로 두었다. 확인 요청해 둔 상태다.
+
+create table re_memo (
+    id          bigserial primary key,
+    org_id      bigint       not null references organization (id) on delete cascade,
+    content     text         not null,
+    author      varchar(100) not null,
+    memo_date   date         not null,
+    resolved    boolean      not null default false,
+    resolved_at timestamptz,
+    created_at  timestamptz  not null default now(),
+    updated_at  timestamptz  not null default now()
+);
+
+-- 기관별 최신순 조회와 미해결 건수 집계를 함께 받는 인덱스.
+create index ix_re_memo_org on re_memo (org_id, memo_date desc, id desc);
+create index ix_re_memo_unresolved on re_memo (org_id) where not resolved;
src/main/resources/mapper/DashboardMapper.xml
--- src/main/resources/mapper/DashboardMapper.xml
+++ src/main/resources/mapper/DashboardMapper.xml
@@ -41,8 +41,7 @@
     organization.updated_at만으로는 부족한 이유: 배정·단계·채널 변경 때만 갱신되고
     권리확인 업로드나 업무메모 작성은 이 컬럼을 건드리지 않는다.
 
-    re_count는 RE 테이블이 아직 없어서 0으로 고정한다. RE 모델이 들어오면 여기에
-    서브쿼리 하나만 붙이면 화면은 그대로 동작한다.
+    re_count는 아직 해결하지 않은 RE 메모 건수다(요구사항 [4]: 메모 1개 = RE 1개).
   -->
   <select id="findOrgRows" resultMap="orgRowResultMap">
     select
@@ -61,8 +60,12 @@
       (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
+      coalesce(re.unresolved, 0) as re_count
     from organization o
+    left join (
+      select org_id, count(*) as unresolved
+      from re_memo where not resolved group by org_id
+    ) re on re.org_id = o.id
     left join contact lc on lc.id = o.lawyer_contact_id
     left join contact mc on mc.id = o.mj_contact_id
     left join (
src/main/resources/mapper/ProcessItemMapper.xml
--- src/main/resources/mapper/ProcessItemMapper.xml
+++ src/main/resources/mapper/ProcessItemMapper.xml
@@ -173,6 +173,7 @@
       process_status   = coalesce(#{processStatus}, process_status),
       judged_kogl_type = coalesce(#{judgedKoglType}, judged_kogl_type),
       final_opinion    = coalesce(#{finalOpinion}, final_opinion),
+      review_note      = coalesce(#{reviewNote}, review_note),
       processed_at     = case when #{processStatus} = #{doneStatus} then now() else processed_at end,
       web_edited_at    = now(),
       updated_at       = now()
src/main/resources/mapper/ProjectMapper.xml
--- src/main/resources/mapper/ProjectMapper.xml
+++ src/main/resources/mapper/ProjectMapper.xml
@@ -71,6 +71,22 @@
     where id = #{id} and org_id = #{orgId}
   </select>
 
+  <!--
+    보고서 교체(요구사항 [25]). 같은 자리에 새 파일을 덮어쓰고 올린 사람·시각을 갱신한다.
+    새 행을 만들지 않는 이유는 "고쳐 올리기"가 개정판 쌓기와 다른 동작이기 때문이다 -
+    개정판으로 남기고 싶으면 지금처럼 새로 올리면 된다.
+  -->
+  <update id="updateReport">
+    update org_report set
+      file_name    = #{fileName},
+      content_type = #{contentType},
+      byte_size    = #{byteSize},
+      content      = #{content},
+      uploaded_by  = #{uploadedBy},
+      uploaded_at  = now()
+    where id = #{id} and org_id = #{orgId}
+  </update>
+
   <delete id="deleteReport">
     delete from org_report where id = #{id} and org_id = #{orgId}
   </delete>
 
src/main/resources/mapper/ReMemoMapper.xml (added)
+++ src/main/resources/mapper/ReMemoMapper.xml
@@ -0,0 +1,63 @@
+<?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.re.ReMemoMapper">
+
+  <!--
+    memo_date는 화면이 그대로 쓰도록 yyyy-MM-dd 문자열로, created_at은 다른 매퍼와 같은
+    관례로 밀리초 epoch로 내려준다.
+  -->
+  <sql id="columns">
+    id, org_id, content, author,
+    to_char(memo_date, 'YYYY-MM-DD') as memo_date,
+    resolved,
+    (extract(epoch from created_at) * 1000)::bigint as created_at
+  </sql>
+
+  <select id="findByOrg" resultType="kr.itn.itnhub.re.ReMemo">
+    select <include refid="columns"/>
+    from re_memo
+    where org_id = #{orgId}
+    order by memo_date desc, id desc
+  </select>
+
+  <select id="findById" resultType="kr.itn.itnhub.re.ReMemo">
+    select <include refid="columns"/>
+    from re_memo
+    where org_id = #{orgId} and id = #{id}
+  </select>
+
+  <!-- 일자를 비워 보내면 오늘로 넣는다. -->
+  <insert id="insert">
+    insert into re_memo (org_id, content, author, memo_date, resolved, resolved_at)
+    values (#{orgId}, #{content}, #{author},
+            coalesce(cast(#{memoDate} as date), current_date),
+            #{resolved},
+            case when #{resolved} then now() else null end)
+  </insert>
+
+  <!-- 해결로 바뀌는 순간만 resolved_at을 찍고, 되돌리면 지운다. -->
+  <update id="update">
+    update re_memo set
+      content     = #{content},
+      author      = #{author},
+      memo_date   = coalesce(cast(#{memoDate} as date), memo_date),
+      resolved    = #{resolved},
+      resolved_at = case
+                      when #{resolved} and resolved_at is null then now()
+                      when not #{resolved} then null
+                      else resolved_at
+                    end,
+      updated_at  = now()
+    where org_id = #{orgId} and id = #{id}
+  </update>
+
+  <delete id="delete">
+    delete from re_memo where org_id = #{orgId} and id = #{id}
+  </delete>
+
+  <delete id="deleteByOrg">
+    delete from re_memo where org_id = #{orgId}
+  </delete>
+
+</mapper>
src/main/resources/mapper/ReviewItemMapper.xml
--- src/main/resources/mapper/ReviewItemMapper.xml
+++ src/main/resources/mapper/ReviewItemMapper.xml
@@ -153,6 +153,7 @@
       review_result    = coalesce(#{reviewResult}, review_result),
       judged_kogl_type = coalesce(#{judgedKoglType}, judged_kogl_type),
       opinion          = coalesce(#{opinion}, opinion),
+      lawyer_note      = coalesce(#{lawyerNote}, lawyer_note),
       web_edited_at    = now(),
       updated_at       = now()
     where org_id = #{orgId}
 
src/test/java/kr/itn/itnhub/re/ReMemoControllerTest.java (added)
+++ src/test/java/kr/itn/itnhub/re/ReMemoControllerTest.java
@@ -0,0 +1,141 @@
+package kr.itn.itnhub.re;
+
+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.security.test.context.support.WithMockUser;
+import org.springframework.test.web.servlet.MockMvc;
+
+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.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")
+class ReMemoControllerTest extends AbstractDbTest {
+
+    @Autowired
+    MockMvc mvc;
+
+    @Autowired
+    OrganizationMapper orgMapper;
+
+    @Autowired
+    JdbcTemplate jdbc;
+
+    @MockBean
+    MattermostClient mattermost;
+
+    private Long orgId;
+
+    @BeforeEach
+    void setUp() {
+        jdbc.update("delete from re_memo");
+        jdbc.update("delete from organization");
+
+        Organization org = new Organization();
+        org.setOrgNo("001");
+        org.setOrgName("RE테스트기관");
+        org.setChannelSlug("001");
+        orgMapper.upsertBySeed(org);
+        orgId = orgMapper.findByOrgNoAndOrgName("001", "RE테스트기관").getId();
+    }
+
+    private String body(String content, String author, String date) {
+        return "{\"content\":\"" + content + "\",\"author\":\"" + author
+                + "\",\"memoDate\":\"" + date + "\",\"resolved\":false}";
+    }
+
+    @Test
+    void 메모를_등록하면_목록에_최신순으로_쌓인다() throws Exception {
+        mvc.perform(post("/api/orgs/{id}/re-memos", orgId).with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(body("ooo 계약서 및 제안요청서 송부 요청", "장영익", "2026-07-08")))
+                .andExpect(status().isOk());
+
+        mvc.perform(post("/api/orgs/{id}/re-memos", orgId).with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(body("추가 자료 요청", "장영익", "2026-07-10")))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.length()").value(2))
+                // 일자 최신순
+                .andExpect(jsonPath("$[0].content").value("추가 자료 요청"))
+                .andExpect(jsonPath("$[0].memoDate").value("2026-07-10"))
+                .andExpect(jsonPath("$[0].author").value("장영익"))
+                .andExpect(jsonPath("$[0].resolved").value(false));
+    }
+
+    @Test
+    void 내용과_작성자는_비울_수_없다() throws Exception {
+        mvc.perform(post("/api/orgs/{id}/re-memos", orgId).with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(body("", "장영익", "2026-07-08")))
+                .andExpect(status().isBadRequest());
+
+        mvc.perform(post("/api/orgs/{id}/re-memos", orgId).with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(body("내용", "", "2026-07-08")))
+                .andExpect(status().isBadRequest());
+    }
+
+    @Test
+    void 해결로_바꾸면_대시보드_미해결_수에서_빠진다() throws Exception {
+        mvc.perform(post("/api/orgs/{id}/re-memos", orgId).with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(body("미해결 건", "장영익", "2026-07-08")))
+                .andExpect(status().isOk());
+
+        mvc.perform(get("/api/dashboard"))
+                .andExpect(jsonPath("$.summary.unresolvedRe").value(1));
+
+        Long memoId = jdbc.queryForObject(
+                "select id from re_memo where org_id = ?", Long.class, orgId);
+
+        mvc.perform(put("/api/orgs/{id}/re-memos/{memoId}", orgId, memoId).with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"content\":\"미해결 건\",\"author\":\"장영익\","
+                                + "\"memoDate\":\"2026-07-08\",\"resolved\":true}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$[0].resolved").value(true));
+
+        mvc.perform(get("/api/dashboard"))
+                .andExpect(jsonPath("$.summary.unresolvedRe").value(0));
+    }
+
+    @Test
+    void 메모를_지울_수_있다() throws Exception {
+        mvc.perform(post("/api/orgs/{id}/re-memos", orgId).with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(body("지울 건", "장영익", "2026-07-08")))
+                .andExpect(status().isOk());
+
+        Long memoId = jdbc.queryForObject(
+                "select id from re_memo where org_id = ?", Long.class, orgId);
+
+        mvc.perform(delete("/api/orgs/{id}/re-memos/{memoId}", orgId, memoId).with(csrf()))
+                .andExpect(status().isNoContent());
+
+        mvc.perform(get("/api/orgs/{id}/re-memos", orgId))
+                .andExpect(jsonPath("$.length()").value(0));
+    }
+
+    @Test
+    void 없는_메모를_고치면_404다() throws Exception {
+        mvc.perform(put("/api/orgs/{id}/re-memos/{memoId}", orgId, 999999L).with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(body("없는 건", "장영익", "2026-07-08")))
+                .andExpect(status().isNotFound());
+    }
+}
Add a comment
List