ITN Dev 07-22
feat: 기관관리 상세에 업무메모 탭 구현
placeholder였던 업무메모 탭을 실제 화면으로 교체한다. 담당자를 기관
담당자 중에서 고르거나 직접 입력할 수 있고, 등록한 메모는 최신순으로
보여주며 확인 후 삭제할 수 있다.

- client.ts: WorkMemo 타입 및 getMemos/createMemo/deleteMemo 추가
- WorkMemos.tsx: 담당자 선택(기관 담당자/직접 입력) + 메모 등록/목록/삭제
- OrgOverview.tsx: 업무메모 탭에 WorkMemos 연결
- WorkMemos.test.tsx, OrgOverview.test.tsx: getMemos 목업 포함 테스트 추가

Co-Authored-By: Claude Opus 4.8 (1M context) 
@42102e0a220c614561e3f58a3f4095ac51ae3075
frontend/src/api/client.ts
--- frontend/src/api/client.ts
+++ frontend/src/api/client.ts
@@ -62,6 +62,14 @@
 
 export type ChannelKindKey = 'mj' | 'law'
 
+export interface WorkMemo {
+  id: number
+  orgId: number
+  contactName: string
+  body: string
+  createdAt: number
+}
+
 /**
  * 서버가 4xx/5xx로 응답했을 때 던지는 예외. status를 함께 들고 있어야 호출자가
  * "인증이 끊긴 것(401)"과 "그 밖의 모든 오류"를 구분해서 다르게 처리할 수 있다 -
@@ -130,6 +138,34 @@
   return `/api/files/${fileId}`
 }
 
+export function getMemos(orgId: number): Promise<WorkMemo[]> {
+  return request<WorkMemo[]>(`/api/orgs/${orgId}/memos`)
+}
+
+export function createMemo(
+  orgId: number,
+  memo: { contactName: string; body: string },
+): Promise<WorkMemo> {
+  return request<WorkMemo>(`/api/orgs/${orgId}/memos`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(memo),
+  })
+}
+
+/** 204를 돌려주므로 request()의 json() 경로를 타지 않고 직접 부른다(logout()과 동일한 패턴). */
+export async function deleteMemo(orgId: number, memoId: number): Promise<void> {
+  const response = await fetch(`/api/orgs/${orgId}/memos/${memoId}`, {
+    method: 'DELETE',
+    credentials: 'same-origin',
+    headers: { 'X-XSRF-TOKEN': csrfToken() },
+  })
+  if (!response.ok) {
+    const body = await response.text()
+    throw new ApiError(response.status, `요청 실패 (${response.status}): ${body}`)
+  }
+}
+
 export function uploadSeed(file: File): Promise<SeedReport> {
   const form = new FormData()
   form.append('file', file)
frontend/src/components/OrgOverview.test.tsx
--- frontend/src/components/OrgOverview.test.tsx
+++ frontend/src/components/OrgOverview.test.tsx
@@ -6,12 +6,14 @@
 const mocks = vi.hoisted(() => ({
   getPosts: vi.fn(),
   getFiles: vi.fn(),
+  getMemos: vi.fn(),
 }))
 
 vi.mock('../api/client', async (importOriginal) => ({
   ...(await importOriginal<typeof import('../api/client')>()),
   getPosts: mocks.getPosts,
   getFiles: mocks.getFiles,
+  getMemos: mocks.getMemos,
 }))
 
 function org(overrides: Partial<Org> = {}): Org {
@@ -34,6 +36,7 @@
 beforeEach(() => {
   mocks.getPosts.mockReset().mockResolvedValue([])
   mocks.getFiles.mockReset().mockResolvedValue([])
+  mocks.getMemos.mockReset().mockResolvedValue([])
 })
 
 describe('OrgOverview', () => {
frontend/src/components/OrgOverview.tsx
--- frontend/src/components/OrgOverview.tsx
+++ frontend/src/components/OrgOverview.tsx
@@ -3,6 +3,7 @@
 import ChannelFiles from './ChannelFiles'
 import ChannelPosts from './ChannelPosts'
 import StatusBadge from './StatusBadge'
+import WorkMemos from './WorkMemos'
 
 // 기관관리 상세 화면. 3단계 대시보드 시안의 "기관관리" 레이아웃을 따른다.
 // 웹 DB에 있는 것(연번·기관명·신청기관 담당자·채널)만 실데이터고,
@@ -199,6 +200,10 @@
           <div className="mt-4 rounded-lg border border-gray-200">
             <ChannelFiles key={org.id} org={org} />
           </div>
+        ) : activeTab === '업무메모' ? (
+          <div className="mt-4 rounded-lg border border-gray-200">
+            <WorkMemos 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/WorkMemos.test.tsx (added)
+++ frontend/src/components/WorkMemos.test.tsx
@@ -0,0 +1,164 @@
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import WorkMemos from './WorkMemos'
+import type { Org, WorkMemo } from '../api/client'
+
+const mocks = vi.hoisted(() => ({
+  getMemos: vi.fn(),
+  createMemo: vi.fn(),
+  deleteMemo: vi.fn(),
+}))
+
+vi.mock('../api/client', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('../api/client')>()),
+  getMemos: mocks.getMemos,
+  createMemo: mocks.createMemo,
+  deleteMemo: mocks.deleteMemo,
+}))
+
+function org(overrides: Partial<Org> = {}): Org {
+  return {
+    id: 1,
+    orgNo: '001',
+    orgName: '국제방송교류재단',
+    status: 'ACTIVE',
+    deptName: '데이터정보화팀',
+    managerName: '송민지',
+    managerTitle: '과장',
+    managerPhone: '02-3475-5434',
+    managerEmail: 'ming@arirang.com',
+    channelIdMj: 'chan-mj',
+    channelIdLaw: 'chan-law',
+    ...overrides,
+  }
+}
+
+function memo(overrides: Partial<WorkMemo> = {}): WorkMemo {
+  return {
+    id: 1,
+    orgId: 1,
+    contactName: '송민지 과장',
+    body: '채널 생성 일정 문의',
+    createdAt: new Date('2026-07-01T10:30:00').getTime(),
+    ...overrides,
+  }
+}
+
+beforeEach(() => {
+  mocks.getMemos.mockReset()
+  mocks.createMemo.mockReset()
+  mocks.deleteMemo.mockReset()
+})
+
+describe('WorkMemos', () => {
+  it('등록된 메모를 담당자와 본문과 함께 보여준다', async () => {
+    mocks.getMemos.mockResolvedValue([memo()])
+
+    render(<WorkMemos org={org()} />)
+
+    await waitFor(() => expect(screen.getByRole('listitem')).toBeTruthy())
+
+    const item = within(screen.getByRole('listitem'))
+    expect(item.getByText('송민지 과장')).toBeTruthy()
+    expect(item.getByText('채널 생성 일정 문의')).toBeTruthy()
+  })
+
+  it('메모가 없으면 안내 문구를 보여준다', async () => {
+    mocks.getMemos.mockResolvedValue([])
+
+    render(<WorkMemos org={org()} />)
+
+    await waitFor(() => {
+      expect(
+        screen.getByText('등록된 메모가 없습니다. 통화 후 첫 메모를 남겨보세요.'),
+      ).toBeTruthy()
+    })
+  })
+
+  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()
+
+    fireEvent.change(screen.getByLabelText('담당자'), { target: { value: '__custom__' } })
+
+    expect(screen.getByLabelText('담당자 이름')).toBeTruthy()
+  })
+
+  it('메모 내용이 비어 있으면 등록 버튼이 비활성이고, 입력하면 활성화된다', async () => {
+    mocks.getMemos.mockResolvedValue([])
+
+    render(<WorkMemos org={org()} />)
+
+    await waitFor(() => expect(mocks.getMemos).toHaveBeenCalled())
+
+    const submit = screen.getByRole('button', { name: '등록' })
+    expect(submit).toBeDisabled()
+
+    fireEvent.change(screen.getByLabelText('메모 내용'), { target: { value: '통화 내용' } })
+
+    expect(submit).not.toBeDisabled()
+  })
+
+  it('등록을 누르면 선택된 담당자와 본문으로 createMemo를 호출하고 본문을 비운다', async () => {
+    mocks.getMemos.mockResolvedValue([])
+    mocks.createMemo.mockResolvedValue(memo({ id: 2, body: '통화 내용' }))
+
+    render(<WorkMemos org={org()} />)
+
+    await waitFor(() => expect(mocks.getMemos).toHaveBeenCalled())
+
+    fireEvent.change(screen.getByLabelText('메모 내용'), { target: { value: '통화 내용' } })
+    fireEvent.click(screen.getByRole('button', { name: '등록' }))
+
+    await waitFor(() => {
+      expect(mocks.createMemo).toHaveBeenCalledWith(1, {
+        contactName: '송민지 과장',
+        body: '통화 내용',
+      })
+    })
+
+    await waitFor(() => {
+      expect((screen.getByLabelText('메모 내용') as HTMLTextAreaElement).value).toBe('')
+    })
+  })
+
+  it('삭제를 누르면 확인을 물어보고, 확인하면 deleteMemo를 호출한다', async () => {
+    mocks.getMemos.mockResolvedValue([memo()])
+    mocks.deleteMemo.mockResolvedValue(undefined)
+    const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
+
+    render(<WorkMemos org={org()} />)
+
+    await waitFor(() => expect(screen.getByText('채널 생성 일정 문의')).toBeTruthy())
+
+    fireEvent.click(screen.getByRole('button', { name: '삭제' }))
+
+    expect(confirmSpy).toHaveBeenCalledWith('이 메모를 삭제할까요?')
+    await waitFor(() => expect(mocks.deleteMemo).toHaveBeenCalledWith(1, 1))
+
+    confirmSpy.mockRestore()
+  })
+
+  it('삭제 확인을 취소하면 deleteMemo를 호출하지 않는다', async () => {
+    mocks.getMemos.mockResolvedValue([memo()])
+    const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
+
+    render(<WorkMemos org={org()} />)
+
+    await waitFor(() => expect(screen.getByText('채널 생성 일정 문의')).toBeTruthy())
+
+    fireEvent.click(screen.getByRole('button', { name: '삭제' }))
+
+    expect(confirmSpy).toHaveBeenCalled()
+    expect(mocks.deleteMemo).not.toHaveBeenCalled()
+
+    confirmSpy.mockRestore()
+  })
+})
 
frontend/src/components/WorkMemos.tsx (added)
+++ frontend/src/components/WorkMemos.tsx
@@ -0,0 +1,186 @@
+import { useEffect, useState } from 'react'
+import { createMemo, deleteMemo, getMemos, type Org, type WorkMemo } from '../api/client'
+
+const CUSTOM_CONTACT_VALUE = '__custom__'
+
+const dateFormatter = new Intl.DateTimeFormat('ko-KR', {
+  year: 'numeric',
+  month: '2-digit',
+  day: '2-digit',
+  hour: '2-digit',
+  minute: '2-digit',
+})
+
+function formatDateTime(createdAt: number): string {
+  return dateFormatter.format(new Date(createdAt))
+}
+
+function managerOptionLabel(org: Org): string | null {
+  if (!org.managerName) {
+    return null
+  }
+  return org.managerTitle ? `${org.managerName} ${org.managerTitle}` : org.managerName
+}
+
+/** 기관관리 상세의 업무메모 탭. 통화 등 담당자와의 연락 내용을 기록/조회/삭제한다. */
+export default function WorkMemos({ org }: { org: Org }) {
+  const managerLabel = managerOptionLabel(org)
+
+  const [memos, setMemos] = useState<WorkMemo[]>([])
+  const [loading, setLoading] = useState(false)
+  const [contactChoice, setContactChoice] = useState(managerLabel ?? CUSTOM_CONTACT_VALUE)
+  const [customContactName, setCustomContactName] = useState('')
+  const [body, setBody] = useState('')
+  const [busy, setBusy] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+
+  useEffect(() => {
+    let cancelled = false
+    setLoading(true)
+
+    getMemos(org.id)
+      .then((data) => {
+        if (!cancelled) {
+          setMemos(data)
+        }
+      })
+      .finally(() => {
+        if (!cancelled) {
+          setLoading(false)
+        }
+      })
+
+    return () => {
+      cancelled = true
+    }
+  }, [org.id])
+
+  const isCustom = contactChoice === CUSTOM_CONTACT_VALUE
+  const effectiveContactName = (isCustom ? customContactName : contactChoice).trim()
+  const canSubmit = effectiveContactName !== '' && body.trim() !== '' && !busy
+
+  async function handleSubmit() {
+    if (!canSubmit) {
+      return
+    }
+    setBusy(true)
+    setError(null)
+    try {
+      const created = await createMemo(org.id, { contactName: effectiveContactName, body })
+      setMemos((prev) => [created, ...prev])
+      setBody('')
+    } catch (e) {
+      setError(e instanceof Error ? e.message : '메모 등록에 실패했습니다.')
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  async function handleDelete(memo: WorkMemo) {
+    if (!window.confirm('이 메모를 삭제할까요?')) {
+      return
+    }
+    try {
+      await deleteMemo(org.id, memo.id)
+      setMemos((prev) => prev.filter((m) => m.id !== memo.id))
+    } catch (e) {
+      setError(e instanceof Error ? e.message : '메모 삭제에 실패했습니다.')
+    }
+  }
+
+  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"
+            >
+              {managerLabel && <option value={managerLabel}>{managerLabel}</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>
+
+        <div className="mt-3 flex flex-col gap-1 text-sm">
+          <label htmlFor="memo-body" className="text-xs text-gray-500">
+            메모 내용
+          </label>
+          <textarea
+            id="memo-body"
+            rows={3}
+            value={body}
+            onChange={(e) => setBody(e.target.value)}
+            className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+          />
+        </div>
+
+        {error && <p className="mt-2 text-xs text-red-600">{error}</p>}
+
+        <div className="mt-3 flex justify-end">
+          <button
+            type="button"
+            disabled={!canSubmit}
+            onClick={() => void handleSubmit()}
+            className="rounded-md bg-blue-600 px-4 py-1.5 text-sm font-semibold text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-300"
+          >
+            등록
+          </button>
+        </div>
+      </section>
+
+      <div className="mt-4">
+        {loading ? (
+          <p className="py-6 text-center text-sm text-gray-400">불러오는 중…</p>
+        ) : memos.length === 0 ? (
+          <p className="py-6 text-center text-sm text-gray-300">
+            등록된 메모가 없습니다. 통화 후 첫 메모를 남겨보세요.
+          </p>
+        ) : (
+          <ul className="space-y-3">
+            {memos.map((memo) => (
+              <li key={memo.id} className="rounded-lg border border-gray-200 p-3">
+                <div className="flex items-center justify-between gap-2">
+                  <div className="flex items-baseline gap-2">
+                    <span className="text-xs text-gray-400">{formatDateTime(memo.createdAt)}</span>
+                    <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>
+                <p className="mt-1.5 whitespace-pre-wrap text-sm text-gray-800">{memo.body}</p>
+              </li>
+            ))}
+          </ul>
+        )}
+      </div>
+    </div>
+  )
+}
Add a comment
List