ITN Dev 07-22
feat: 기관관리 게시물/자료 탭에 Mattermost 채널 실시간 열람 화면 추가
- ChannelPosts: 문정원/법률검토 채널 선택 칩 + 3초 폴링으로 최근 게시글을 보여주고,
  시스템 메시지·첨부파일 다운로드 칩·자동 스크롤을 처리한다
- ChannelFiles: 같은 채널 선택으로 자료실 파일 전체를 표로 보여주고 새로고침을 지원한다
- client.ts에 getPosts/getFiles/fileDownloadUrl 추가, OrgOverview는 게시물/자료 탭에서
  자리표시 대신 위 컴포넌트를 렌더링(org.id로 key를 잡아 기관 전환 시 폴링을 재시작)
@b2761465741de3ad76272479928d9fa52c4ea4f8
frontend/src/api/client.ts
--- frontend/src/api/client.ts
+++ frontend/src/api/client.ts
@@ -36,6 +36,32 @@
   total: number
 }
 
+export interface FileRef {
+  id: string
+  name: string
+  size: number
+}
+
+export interface PostView {
+  id: string
+  user: string
+  message: string
+  createAt: number
+  system: boolean
+  files: FileRef[]
+}
+
+export interface FileView {
+  id: string
+  name: string
+  size: number
+  mimeType: string
+  createAt: number
+  uploader: string
+}
+
+export type ChannelKindKey = 'mj' | 'law'
+
 /**
  * 서버가 4xx/5xx로 응답했을 때 던지는 예외. status를 함께 들고 있어야 호출자가
  * "인증이 끊긴 것(401)"과 "그 밖의 모든 오류"를 구분해서 다르게 처리할 수 있다 -
@@ -91,6 +117,19 @@
   return request<ProvisionResult>(`/api/orgs/${id}/channels`, { method: 'POST' })
 }
 
+export function getPosts(orgId: number, channel: ChannelKindKey): Promise<PostView[]> {
+  return request<PostView[]>(`/api/orgs/${orgId}/posts?channel=${channel}`)
+}
+
+export function getFiles(orgId: number, channel: ChannelKindKey): Promise<FileView[]> {
+  return request<FileView[]>(`/api/orgs/${orgId}/files?channel=${channel}`)
+}
+
+/** 다운로드는 <a href>가 그대로 열게 두므로 fetch()를 거치지 않는다. */
+export function fileDownloadUrl(fileId: string): string {
+  return `/api/files/${fileId}`
+}
+
 export function uploadSeed(file: File): Promise<SeedReport> {
   const form = new FormData()
   form.append('file', file)
 
frontend/src/components/ChannelFiles.test.tsx (added)
+++ frontend/src/components/ChannelFiles.test.tsx
@@ -0,0 +1,97 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import ChannelFiles from './ChannelFiles'
+import type { FileView, Org } from '../api/client'
+
+const mocks = vi.hoisted(() => ({
+  getFiles: vi.fn(),
+}))
+
+vi.mock('../api/client', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('../api/client')>()),
+  getFiles: mocks.getFiles,
+}))
+
+function org(overrides: Partial<Org> = {}): Org {
+  return {
+    id: 1,
+    orgNo: '001',
+    orgName: '국제방송교류재단',
+    status: 'ACTIVE',
+    deptName: null,
+    managerName: null,
+    managerTitle: null,
+    managerPhone: null,
+    managerEmail: null,
+    channelIdMj: 'chan-mj',
+    channelIdLaw: 'chan-law',
+    ...overrides,
+  }
+}
+
+function file(overrides: Partial<FileView> = {}): FileView {
+  return {
+    id: 'f1',
+    name: '보고서.pdf',
+    size: 1234,
+    mimeType: 'application/pdf',
+    createAt: new Date('2026-07-01T10:30:00').getTime(),
+    uploader: '송민지',
+    ...overrides,
+  }
+}
+
+beforeEach(() => {
+  mocks.getFiles.mockReset()
+})
+
+describe('ChannelFiles', () => {
+  it('목록을 표로 보여준다', async () => {
+    mocks.getFiles.mockResolvedValue([file()])
+
+    render(<ChannelFiles org={org()} />)
+
+    await waitFor(() => {
+      expect(screen.getByText('보고서.pdf')).toBeTruthy()
+      expect(screen.getByText('송민지')).toBeTruthy()
+    })
+    expect(screen.getByText('보고서.pdf').closest('a')).toHaveAttribute('href', '/api/files/f1')
+  })
+
+  it('새로고침을 누르면 다시 조회한다', async () => {
+    mocks.getFiles.mockResolvedValue([])
+
+    render(<ChannelFiles org={org()} />)
+
+    await waitFor(() => expect(mocks.getFiles).toHaveBeenCalledTimes(1))
+
+    fireEvent.click(screen.getByRole('button', { name: '새로고침' }))
+
+    await waitFor(() => expect(mocks.getFiles).toHaveBeenCalledTimes(2))
+  })
+
+  it('자료가 없으면 안내 문구를 보여준다', async () => {
+    mocks.getFiles.mockResolvedValue([])
+
+    render(<ChannelFiles org={org()} />)
+
+    await waitFor(() => {
+      expect(screen.getByText('등록된 자료가 없습니다.')).toBeTruthy()
+    })
+  })
+
+  it('조회 중에는 불러오는 중 문구를 보여준다', () => {
+    mocks.getFiles.mockReturnValue(new Promise(() => {}))
+
+    render(<ChannelFiles org={org()} />)
+
+    expect(screen.getByText('불러오는 중…')).toBeTruthy()
+  })
+
+  it('양쪽 채널이 모두 없으면 안내 문구를 보여주고 API를 호출하지 않는다', () => {
+    render(<ChannelFiles org={org({ channelIdMj: null, channelIdLaw: null })} />)
+
+    expect(screen.getByText('채널이 아직 생성되지 않았습니다.')).toBeTruthy()
+    expect(mocks.getFiles).not.toHaveBeenCalled()
+  })
+})
 
frontend/src/components/ChannelFiles.tsx (added)
+++ frontend/src/components/ChannelFiles.tsx
@@ -0,0 +1,153 @@
+import { useEffect, useState } from 'react'
+import { getFiles, fileDownloadUrl, type ChannelKindKey, type FileView, type Org } from '../api/client'
+
+const dateFormatter = new Intl.DateTimeFormat('ko-KR', {
+  year: 'numeric',
+  month: '2-digit',
+  day: '2-digit',
+  hour: '2-digit',
+  minute: '2-digit',
+})
+
+function humanizeSize(bytes: number): string {
+  if (bytes < 1024) {
+    return `${bytes} B`
+  }
+  const kb = bytes / 1024
+  if (kb < 1024) {
+    return `${kb.toFixed(1)} KB`
+  }
+  return `${(kb / 1024).toFixed(1)} MB`
+}
+
+function formatDate(createAt: number): string {
+  // Intl 기본 포맷은 "2026. 07. 22. 14:05" 형태라 화면 요구사항(yyyy-MM-dd HH:mm)에 맞게 다듬는다.
+  const parts = dateFormatter.formatToParts(new Date(createAt))
+  const get = (type: string) => parts.find((p) => p.type === type)?.value ?? ''
+  return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}`
+}
+
+/** 기관관리 상세의 자료 탭. 선택한 채널에 올라온 첨부파일 전체를 목록으로 보여준다(폴링 없음). */
+export default function ChannelFiles({ org }: { org: Org }) {
+  const bothMissing = !org.channelIdMj && !org.channelIdLaw
+  const [channel, setChannel] = useState<ChannelKindKey>(org.channelIdMj ? 'mj' : 'law')
+  const [files, setFiles] = useState<FileView[]>([])
+  const [loading, setLoading] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+  const [reloadKey, setReloadKey] = useState(0)
+
+  useEffect(() => {
+    if (bothMissing) {
+      return
+    }
+
+    let cancelled = false
+    setLoading(true)
+    setError(null)
+
+    getFiles(org.id, channel)
+      .then((data) => {
+        if (!cancelled) {
+          setFiles(data)
+        }
+      })
+      .catch((e: Error) => {
+        if (!cancelled) {
+          setError(e.message)
+        }
+      })
+      .finally(() => {
+        if (!cancelled) {
+          setLoading(false)
+        }
+      })
+
+    return () => {
+      cancelled = true
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [channel, org.id, reloadKey])
+
+  return (
+    <div className="p-4">
+      <div className="flex items-center justify-between gap-2">
+        <div className="flex gap-2">
+          <button
+            type="button"
+            disabled={!org.channelIdMj}
+            title={org.channelIdMj ? undefined : '채널 미생성'}
+            onClick={() => setChannel('mj')}
+            className={`rounded-full border px-3 py-1 text-xs ${
+              channel === 'mj'
+                ? 'border-blue-600 bg-blue-50 font-semibold text-blue-700'
+                : 'border-gray-200 text-gray-500'
+            } disabled:cursor-not-allowed disabled:opacity-40`}
+          >
+            문정원
+          </button>
+          <button
+            type="button"
+            disabled={!org.channelIdLaw}
+            title={org.channelIdLaw ? undefined : '채널 미생성'}
+            onClick={() => setChannel('law')}
+            className={`rounded-full border px-3 py-1 text-xs ${
+              channel === 'law'
+                ? 'border-blue-600 bg-blue-50 font-semibold text-blue-700'
+                : 'border-gray-200 text-gray-500'
+            } disabled:cursor-not-allowed disabled:opacity-40`}
+          >
+            법률검토
+          </button>
+        </div>
+
+        {!bothMissing && (
+          <button
+            type="button"
+            onClick={() => setReloadKey((k) => k + 1)}
+            className="rounded-md border border-gray-300 px-3 py-1 text-xs text-gray-600 hover:bg-gray-50"
+          >
+            새로고침
+          </button>
+        )}
+      </div>
+
+      {bothMissing ? (
+        <p className="mt-6 text-center text-sm text-gray-400">채널이 아직 생성되지 않았습니다.</p>
+      ) : (
+        <div className="mt-3">
+          {loading && <p className="py-6 text-center text-sm text-gray-400">불러오는 중…</p>}
+          {!loading && error && <p className="py-4 text-sm text-red-600">{error}</p>}
+          {!loading && !error && files.length === 0 && (
+            <p className="py-6 text-center text-sm text-gray-300">등록된 자료가 없습니다.</p>
+          )}
+          {!loading && !error && files.length > 0 && (
+            <table className="w-full text-left text-sm">
+              <thead>
+                <tr className="border-b border-gray-200 text-xs text-gray-400">
+                  <th className="py-2 font-medium">파일명</th>
+                  <th className="py-2 font-medium">크기</th>
+                  <th className="py-2 font-medium">올린 사람</th>
+                  <th className="py-2 font-medium">올린 시각</th>
+                </tr>
+              </thead>
+              <tbody>
+                {files.map((file) => (
+                  <tr key={file.id} className="border-b border-gray-100">
+                    <td className="py-2">
+                      <a href={fileDownloadUrl(file.id)} className="text-blue-600 hover:underline">
+                        {file.name}
+                      </a>
+                    </td>
+                    <td className="py-2 text-gray-600">{humanizeSize(file.size)}</td>
+                    <td className="py-2 text-gray-600">{file.uploader}</td>
+                    <td className="py-2 text-gray-600">{formatDate(file.createAt)}</td>
+                  </tr>
+                ))}
+              </tbody>
+            </table>
+          )}
+        </div>
+      )}
+    </div>
+  )
+}
 
frontend/src/components/ChannelPosts.test.tsx (added)
+++ frontend/src/components/ChannelPosts.test.tsx
@@ -0,0 +1,119 @@
+import { act, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import ChannelPosts from './ChannelPosts'
+import type { Org, PostView } from '../api/client'
+
+const mocks = vi.hoisted(() => ({
+  getPosts: vi.fn(),
+}))
+
+vi.mock('../api/client', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('../api/client')>()),
+  getPosts: mocks.getPosts,
+}))
+
+function org(overrides: Partial<Org> = {}): Org {
+  return {
+    id: 1,
+    orgNo: '001',
+    orgName: '국제방송교류재단',
+    status: 'ACTIVE',
+    deptName: null,
+    managerName: null,
+    managerTitle: null,
+    managerPhone: null,
+    managerEmail: null,
+    channelIdMj: 'chan-mj',
+    channelIdLaw: 'chan-law',
+    ...overrides,
+  }
+}
+
+function post(overrides: Partial<PostView> = {}): PostView {
+  return {
+    id: 'p1',
+    user: '송민지',
+    message: '안녕하세요',
+    createAt: Date.now(),
+    system: false,
+    files: [],
+    ...overrides,
+  }
+}
+
+beforeEach(() => {
+  mocks.getPosts.mockReset()
+  vi.useRealTimers()
+})
+
+describe('ChannelPosts', () => {
+  it('메시지를 작성자와 본문과 함께 보여준다', async () => {
+    mocks.getPosts.mockResolvedValue([post()])
+
+    render(<ChannelPosts org={org()} />)
+
+    await waitFor(() => {
+      expect(screen.getByText('송민지')).toBeTruthy()
+      expect(screen.getByText('안녕하세요')).toBeTruthy()
+    })
+  })
+
+  it('시스템 메시지는 회색 중앙 정렬 한 줄로 보여준다', async () => {
+    mocks.getPosts.mockResolvedValue([post({ system: true, message: '님이 입장했습니다.' })])
+
+    render(<ChannelPosts org={org()} />)
+
+    await waitFor(() => {
+      const line = screen.getByText('님이 입장했습니다.')
+      expect(line.className).toContain('text-gray-400')
+      expect(line.className).toContain('text-center')
+    })
+  })
+
+  it('3초마다 폴링해서 게시글을 다시 조회한다', async () => {
+    vi.useFakeTimers()
+    mocks.getPosts.mockResolvedValue([])
+
+    render(<ChannelPosts org={org()} />)
+
+    await act(async () => {
+      await vi.advanceTimersByTimeAsync(0)
+    })
+    expect(mocks.getPosts).toHaveBeenCalledTimes(1)
+
+    await act(async () => {
+      await vi.advanceTimersByTimeAsync(3000)
+    })
+    expect(mocks.getPosts).toHaveBeenCalledTimes(2)
+
+    vi.useRealTimers()
+  })
+
+  it('양쪽 채널이 모두 없으면 안내 문구를 보여주고 API를 호출하지 않는다', () => {
+    render(<ChannelPosts org={org({ channelIdMj: null, channelIdLaw: null })} />)
+
+    expect(screen.getByText('채널이 아직 생성되지 않았습니다.')).toBeTruthy()
+    expect(mocks.getPosts).not.toHaveBeenCalled()
+  })
+
+  it('법률검토 채널이 없으면 법률검토 칩이 비활성화된다', async () => {
+    mocks.getPosts.mockResolvedValue([])
+    render(<ChannelPosts org={org({ channelIdLaw: null })} />)
+
+    expect(screen.getByRole('button', { name: '법률검토' })).toBeDisabled()
+    await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
+  })
+
+  it('첨부파일 칩의 링크는 다운로드 엔드포인트를 가리킨다', async () => {
+    mocks.getPosts.mockResolvedValue([
+      post({ files: [{ id: 'f1', name: '보고서.pdf', size: 1200 }] }),
+    ])
+
+    render(<ChannelPosts org={org()} />)
+
+    await waitFor(() => {
+      const link = screen.getByText('보고서.pdf').closest('a')
+      expect(link).toHaveAttribute('href', '/api/files/f1')
+    })
+  })
+})
 
frontend/src/components/ChannelPosts.tsx (added)
+++ frontend/src/components/ChannelPosts.tsx
@@ -0,0 +1,153 @@
+import { useEffect, useRef, useState } from 'react'
+import { getPosts, fileDownloadUrl, type ChannelKindKey, type FileRef, type Org, type PostView } from '../api/client'
+
+const POLL_INTERVAL_MS = 3000
+
+const timeFormatter = new Intl.DateTimeFormat('ko-KR', { hour: '2-digit', minute: '2-digit' })
+
+function humanizeSize(bytes: number): string {
+  if (bytes < 1024) {
+    return `${bytes} B`
+  }
+  const kb = bytes / 1024
+  if (kb < 1024) {
+    return `${kb.toFixed(1)} KB`
+  }
+  return `${(kb / 1024).toFixed(1)} MB`
+}
+
+function AttachmentChip({ file }: { file: FileRef }) {
+  return (
+    <a
+      href={fileDownloadUrl(file.id)}
+      className="inline-flex items-center gap-1 rounded-md border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-gray-600 hover:bg-gray-100"
+    >
+      <span className="font-medium text-gray-700">{file.name}</span>
+      <span className="text-gray-400">({humanizeSize(file.size)})</span>
+    </a>
+  )
+}
+
+function PostRow({ post }: { post: PostView }) {
+  if (post.system) {
+    return <p className="py-1 text-center text-xs text-gray-400">{post.message}</p>
+  }
+
+  return (
+    <div className="py-2">
+      <div className="flex items-baseline gap-2">
+        <span className="text-sm font-semibold text-gray-800">{post.user}</span>
+        <span className="text-xs text-gray-400">{timeFormatter.format(new Date(post.createAt))}</span>
+      </div>
+      <p className="mt-0.5 whitespace-pre-wrap text-sm text-gray-700">{post.message}</p>
+      {post.files.length > 0 && (
+        <div className="mt-1.5 flex flex-wrap gap-1.5">
+          {post.files.map((f) => (
+            <AttachmentChip key={f.id} file={f} />
+          ))}
+        </div>
+      )}
+    </div>
+  )
+}
+
+/** 기관관리 상세의 게시물 탭. 선택한 채널의 최근 게시글을 3초 간격으로 폴링해서 보여준다. */
+export default function ChannelPosts({ org }: { org: Org }) {
+  const bothMissing = !org.channelIdMj && !org.channelIdLaw
+  const [channel, setChannel] = useState<ChannelKindKey>(org.channelIdMj ? 'mj' : 'law')
+  const [posts, setPosts] = useState<PostView[]>([])
+  const [error, setError] = useState<string | null>(null)
+
+  const containerRef = useRef<HTMLDivElement>(null)
+  const prevCountRef = useRef(0)
+
+  useEffect(() => {
+    if (bothMissing) {
+      return
+    }
+
+    let cancelled = false
+
+    async function load() {
+      try {
+        const data = await getPosts(org.id, channel)
+        if (!cancelled) {
+          setPosts(data)
+          setError(null)
+        }
+      } catch {
+        if (!cancelled) {
+          setError('연결이 원활하지 않습니다. 재시도 중…')
+        }
+      }
+    }
+
+    void load()
+    const timer = setInterval(() => void load(), POLL_INTERVAL_MS)
+    return () => {
+      cancelled = true
+      clearInterval(timer)
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [channel, org.id])
+
+  useEffect(() => {
+    if (posts.length > prevCountRef.current && containerRef.current) {
+      containerRef.current.scrollTop = containerRef.current.scrollHeight
+    }
+    prevCountRef.current = posts.length
+  }, [posts.length])
+
+  return (
+    <div className="p-4">
+      <div className="flex gap-2">
+        <button
+          type="button"
+          disabled={!org.channelIdMj}
+          title={org.channelIdMj ? undefined : '채널 미생성'}
+          onClick={() => setChannel('mj')}
+          className={`rounded-full border px-3 py-1 text-xs ${
+            channel === 'mj'
+              ? 'border-blue-600 bg-blue-50 font-semibold text-blue-700'
+              : 'border-gray-200 text-gray-500'
+          } disabled:cursor-not-allowed disabled:opacity-40`}
+        >
+          문정원
+        </button>
+        <button
+          type="button"
+          disabled={!org.channelIdLaw}
+          title={org.channelIdLaw ? undefined : '채널 미생성'}
+          onClick={() => setChannel('law')}
+          className={`rounded-full border px-3 py-1 text-xs ${
+            channel === 'law'
+              ? 'border-blue-600 bg-blue-50 font-semibold text-blue-700'
+              : 'border-gray-200 text-gray-500'
+          } disabled:cursor-not-allowed disabled:opacity-40`}
+        >
+          법률검토
+        </button>
+      </div>
+
+      {bothMissing ? (
+        <p className="mt-6 text-center text-sm text-gray-400">채널이 아직 생성되지 않았습니다.</p>
+      ) : (
+        <>
+          {error && (
+            <p className="mt-3 rounded-md bg-amber-50 px-3 py-1.5 text-xs text-amber-700">{error}</p>
+          )}
+          <div
+            ref={containerRef}
+            className="mt-3 h-96 overflow-y-auto rounded-lg border border-gray-200 px-3"
+          >
+            {posts.length === 0 ? (
+              <p className="py-6 text-center text-sm text-gray-300">게시글이 없습니다.</p>
+            ) : (
+              posts.map((post) => <PostRow key={post.id} post={post} />)
+            )}
+          </div>
+        </>
+      )}
+    </div>
+  )
+}
frontend/src/components/OrgOverview.test.tsx
--- frontend/src/components/OrgOverview.test.tsx
+++ frontend/src/components/OrgOverview.test.tsx
@@ -1,7 +1,18 @@
-import { fireEvent, render, screen } from '@testing-library/react'
-import { describe, expect, it } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
 import OrgOverview from './OrgOverview'
 import type { Org } from '../api/client'
+
+const mocks = vi.hoisted(() => ({
+  getPosts: vi.fn(),
+  getFiles: vi.fn(),
+}))
+
+vi.mock('../api/client', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('../api/client')>()),
+  getPosts: mocks.getPosts,
+  getFiles: mocks.getFiles,
+}))
 
 function org(overrides: Partial<Org> = {}): Org {
   return {
@@ -20,8 +31,13 @@
   }
 }
 
+beforeEach(() => {
+  mocks.getPosts.mockReset().mockResolvedValue([])
+  mocks.getFiles.mockReset().mockResolvedValue([])
+})
+
 describe('OrgOverview', () => {
-  it('연번_기관명 헤더와 신청기관 담당자 정보를 보여준다', () => {
+  it('연번_기관명 헤더와 신청기관 담당자 정보를 보여준다', async () => {
     render(<OrgOverview org={org()} />)
 
     expect(screen.getByText('001_국제방송교류재단')).toBeTruthy()
@@ -29,36 +45,55 @@
     expect(screen.getByText('송민지 과장')).toBeTruthy()
     expect(screen.getByText('02-3475-5434')).toBeTruthy()
     expect(screen.getByText('ming@arirang.com')).toBeTruthy()
+    await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
   })
 
-  it('아직 연동 전인 문정원 담당자와 담당 변호사 카드는 자리만 보여준다', () => {
+  it('아직 연동 전인 문정원 담당자와 담당 변호사 카드는 자리만 보여준다', async () => {
     render(<OrgOverview org={org()} />)
 
     expect(screen.getByText('문정원 담당자')).toBeTruthy()
     expect(screen.getByText('담당 변호사')).toBeTruthy()
     expect(screen.getAllByText('추후 연동')).toHaveLength(2)
+    await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
   })
 
-  it('업무단계 12개가 순서대로 보인다', () => {
+  it('업무단계 12개가 순서대로 보인다', async () => {
     render(<OrgOverview org={org()} />)
 
     expect(screen.getByText('신청')).toBeTruthy()
     expect(screen.getByText('변호사 배당')).toBeTruthy()
     expect(screen.getByText('보고서 작성')).toBeTruthy()
+    await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
   })
 
-  it('탭을 누르면 해당 탭의 자리표시 내용으로 바뀐다', () => {
+  it('기본 탭인 게시물은 자리표시 대신 채널 선택 화면을 보여준다', async () => {
     render(<OrgOverview org={org()} />)
 
-    expect(screen.getByText('게시물 상세 화면은 추후 확정')).toBeTruthy()
+    expect(screen.getByRole('button', { name: '문정원' })).toBeTruthy()
+    await waitFor(() => expect(mocks.getPosts).toHaveBeenCalledWith(1, 'mj'))
+  })
+
+  it('탭을 누르면 해당 탭의 자리표시 내용으로 바뀐다', async () => {
+    render(<OrgOverview org={org()} />)
+
+    await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
 
     fireEvent.click(screen.getByRole('button', { name: '타임라인' }))
 
     expect(screen.getByText('타임라인 상세 화면은 추후 확정')).toBeTruthy()
-    expect(screen.queryByText('게시물 상세 화면은 추후 확정')).toBeNull()
+    expect(screen.queryByRole('button', { name: '문정원' })).toBeNull()
   })
 
-  it('채널이 있으면 Mattermost 열기가 채널 링크로 활성화된다', () => {
+  it('자료 탭을 누르면 자료 목록 화면으로 바뀐다', async () => {
+    render(<OrgOverview org={org()} />)
+
+    fireEvent.click(screen.getByRole('button', { name: '자료' }))
+
+    await waitFor(() => expect(mocks.getFiles).toHaveBeenCalledWith(1, 'mj'))
+    expect(screen.getByRole('button', { name: '새로고침' })).toBeTruthy()
+  })
+
+  it('채널이 있으면 Mattermost 열기가 채널 링크로 활성화된다', async () => {
     render(<OrgOverview org={org()} />)
 
     const link = screen.getByRole('link', { name: 'Mattermost 열기' })
@@ -66,6 +101,7 @@
       'href',
       'https://hub.iten.co.kr/itn-hub/channels/org-001-mj',
     )
+    await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
   })
 
   it('채널이 없으면 Mattermost 열기가 비활성이다', () => {
@@ -75,7 +111,7 @@
     expect(screen.getByRole('button', { name: 'Mattermost 열기' })).toBeDisabled()
   })
 
-  it('치안정책연구소는 policy 채널 링크를 쓴다', () => {
+  it('치안정책연구소는 policy 채널 링크를 쓴다', async () => {
     render(
       <OrgOverview
         org={org({ orgNo: '008', orgName: '경찰청_치안정책연구소', channelIdMj: 'x' })}
@@ -86,12 +122,14 @@
       'href',
       'https://hub.iten.co.kr/itn-hub/channels/org-008-policy-mj',
     )
+    await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
   })
 
-  it('미구현 동작 버튼들은 비활성이다', () => {
+  it('미구현 동작 버튼들은 비활성이다', async () => {
     render(<OrgOverview org={org()} />)
 
     expect(screen.getByRole('button', { name: '진행단계 변경' })).toBeDisabled()
     expect(screen.getByRole('button', { name: '보고서 관리' })).toBeDisabled()
+    await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
   })
 })
frontend/src/components/OrgOverview.tsx
--- frontend/src/components/OrgOverview.tsx
+++ frontend/src/components/OrgOverview.tsx
@@ -1,5 +1,7 @@
 import { useState } from 'react'
 import type { Org } from '../api/client'
+import ChannelFiles from './ChannelFiles'
+import ChannelPosts from './ChannelPosts'
 import StatusBadge from './StatusBadge'
 
 // 기관관리 상세 화면. 3단계 대시보드 시안의 "기관관리" 레이아웃을 따른다.
@@ -189,9 +191,19 @@
           </nav>
         </div>
 
-        <div className="mt-4 rounded-lg border border-dashed border-gray-200 p-10 text-center text-sm text-gray-400">
-          {activeTab} 상세 화면은 추후 확정
-        </div>
+        {activeTab === '게시물' ? (
+          <div className="mt-4 rounded-lg border border-gray-200">
+            <ChannelPosts key={org.id} org={org} />
+          </div>
+        ) : activeTab === '자료' ? (
+          <div className="mt-4 rounded-lg border border-gray-200">
+            <ChannelFiles 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} 상세 화면은 추후 확정
+          </div>
+        )}
       </div>
     </div>
   )
Add a comment
List