ITN Dev 07-24
feat: 업무단계 현황 스트립을 실제 진행단계와 연결하고 타임라인 탭을 구현
- STEPS 배열을 src/stages.ts(STAGE_LABELS)로 분리해 OrgOverview와 Timeline이 공유
- 업무단계 스트립: 완료(체크)/진행중/미래 상태 표시, 현재 단계 클릭 시 확인 후 다음 단계로 진행
- [진행단계 변경] 버튼을 활성화해 12단계 중 하나를 바로 골라 변경하는 모달 추가
- Timeline 컴포넌트: 단계변경/업무메모/자료등록 이벤트를 날짜별로 묶어 시간순으로 표시
- 관련 컴포넌트 테스트 fixture에 stage 필드 추가
@982acbec912ae3c09f00551068abcae2db1f43a0
frontend/src/api/client.ts
--- frontend/src/api/client.ts
+++ frontend/src/api/client.ts
@@ -37,6 +37,8 @@
   lawyerAssignedDate: string | null
   channelIdMj: string | null
   channelIdLaw: string | null
+  /** null이면 아직 어떤 진행단계도 시작되지 않은 것이다(채널 생성 전). 1..12. */
+  stage: number | null
 }
 
 /** 채널관리 화면이 신청기관/문정원/아이티앤/변호사 배정을 한 번에 바꿀 때 보내는 요청. null은 해제다. */
@@ -94,6 +96,14 @@
   contactName: string
   body: string
   createdAt: number
+}
+
+/** 타임라인 탭 한 줄. detail은 STAGE 이벤트에서는 항상 null이다. */
+export interface TimelineEvent {
+  type: 'STAGE' | 'MEMO' | 'FILE'
+  at: number
+  title: string
+  detail: string | null
 }
 
 /**
@@ -185,6 +195,18 @@
   return request<ProvisionResult>(`/api/orgs/${id}/channels`, { method: 'POST' })
 }
 
+export function updateStage(orgId: number, stage: number): Promise<Org> {
+  return request<Org>(`/api/orgs/${orgId}/stage`, {
+    method: 'PUT',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ stage }),
+  })
+}
+
+export function getTimeline(orgId: number): Promise<TimelineEvent[]> {
+  return request<TimelineEvent[]>(`/api/orgs/${orgId}/timeline`)
+}
+
 export function getPosts(orgId: number, channel: ChannelKindKey): Promise<PostView[]> {
   return request<PostView[]>(`/api/orgs/${orgId}/posts?channel=${channel}`)
 }
frontend/src/components/ChannelFiles.test.tsx
--- frontend/src/components/ChannelFiles.test.tsx
+++ frontend/src/components/ChannelFiles.test.tsx
@@ -25,6 +25,7 @@
     lawyerAssignedDate: null,
     channelIdMj: 'chan-mj',
     channelIdLaw: 'chan-law',
+    stage: null,
     ...overrides,
   }
 }
frontend/src/components/ChannelPosts.test.tsx
--- frontend/src/components/ChannelPosts.test.tsx
+++ frontend/src/components/ChannelPosts.test.tsx
@@ -31,6 +31,7 @@
     lawyerAssignedDate: null,
     channelIdMj: 'chan-mj',
     channelIdLaw: 'chan-law',
+    stage: null,
     ...overrides,
   }
 }
frontend/src/components/OrgDetail.test.tsx
--- frontend/src/components/OrgDetail.test.tsx
+++ frontend/src/components/OrgDetail.test.tsx
@@ -43,6 +43,7 @@
     lawyerAssignedDate: null,
     channelIdMj: null,
     channelIdLaw: null,
+    stage: null,
     ...overrides,
   }
 }
frontend/src/components/OrgList.test.tsx
--- frontend/src/components/OrgList.test.tsx
+++ frontend/src/components/OrgList.test.tsx
@@ -7,12 +7,12 @@
   {
     id: 1, orgNo: '001_00', orgName: '국제방송교류재단', status: 'ACTIVE',
     applicant: null, mj: null, itn: null, lawyer: null, lawyerAssignedDate: null,
-    channelIdMj: 'a', channelIdLaw: 'b',
+    channelIdMj: 'a', channelIdLaw: 'b', stage: 1,
   },
   {
     id: 2, orgNo: '008_01', orgName: '경찰청_치안정책연구소', status: 'INFO_PENDING',
     applicant: null, mj: null, itn: null, lawyer: null, lawyerAssignedDate: null,
-    channelIdMj: null, channelIdLaw: null,
+    channelIdMj: null, channelIdLaw: null, stage: null,
   },
 ]
 
frontend/src/components/OrgOverview.test.tsx
--- frontend/src/components/OrgOverview.test.tsx
+++ frontend/src/components/OrgOverview.test.tsx
@@ -9,6 +9,8 @@
   getMemos: vi.fn(),
   getContacts: vi.fn(),
   updateAssignments: vi.fn(),
+  getTimeline: vi.fn(),
+  updateStage: vi.fn(),
 }))
 
 vi.mock('../api/client', async (importOriginal) => ({
@@ -18,6 +20,8 @@
   getMemos: mocks.getMemos,
   getContacts: mocks.getContacts,
   updateAssignments: mocks.updateAssignments,
+  getTimeline: mocks.getTimeline,
+  updateStage: mocks.updateStage,
 }))
 
 function contact(overrides: Partial<Contact> = {}): Contact {
@@ -56,6 +60,7 @@
     lawyerAssignedDate: null,
     channelIdMj: 'chan-mj',
     channelIdLaw: 'chan-law',
+    stage: null,
     ...overrides,
   }
 }
@@ -66,6 +71,8 @@
   mocks.getMemos.mockReset().mockResolvedValue([])
   mocks.getContacts.mockReset().mockResolvedValue([])
   mocks.updateAssignments.mockReset().mockResolvedValue(undefined)
+  mocks.getTimeline.mockReset().mockResolvedValue([])
+  mocks.updateStage.mockReset().mockResolvedValue(undefined)
 })
 
 describe('OrgOverview', () => {
@@ -215,7 +222,7 @@
     render(<OrgOverview org={org()} />)
 
     expect(screen.queryByRole('button', { name: '담당 변호사 변경' })).toBeNull()
-    expect(screen.getByRole('button', { name: '진행단계 변경' })).toBeDisabled()
+    expect(screen.getByRole('button', { name: '진행단계 변경' })).not.toBeDisabled()
     await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
   })
 
@@ -240,10 +247,21 @@
 
     await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
 
+    fireEvent.click(screen.getByRole('button', { name: '권리확인' }))
+
+    expect(screen.getByText('권리확인 상세 화면은 추후 확정')).toBeTruthy()
+    expect(screen.queryByRole('button', { name: '문정원' })).toBeNull()
+  })
+
+  it('타임라인 탭을 누르면 Timeline이 마운트되어 조회를 부른다', async () => {
+    render(<OrgOverview org={org()} />)
+
+    await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
+
     fireEvent.click(screen.getByRole('button', { name: '타임라인' }))
 
-    expect(screen.getByText('타임라인 상세 화면은 추후 확정')).toBeTruthy()
-    expect(screen.queryByRole('button', { name: '문정원' })).toBeNull()
+    await waitFor(() => expect(mocks.getTimeline).toHaveBeenCalledWith(1))
+    expect(screen.queryByText('타임라인 상세 화면은 추후 확정')).toBeNull()
   })
 
   it('자료 탭을 누르면 자료 목록 화면으로 바뀐다', async () => {
@@ -290,8 +308,131 @@
   it('미구현 동작 버튼들은 비활성이다', async () => {
     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: '보고서 관리' })).toBeDisabled()
     await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
   })
+
+  describe('업무단계 현황 스트립', () => {
+    it('채널이 없어 단계가 시작되지 않았으면 전부 회색이고 안내 문구가 보인다', async () => {
+      render(<OrgOverview org={org({ stage: null, channelIdMj: null, channelIdLaw: null, status: 'READY' })} />)
+
+      expect(screen.getByText('채널 생성 시 신청 단계가 시작됩니다')).toBeTruthy()
+      expect(screen.queryByText('진행중')).toBeNull()
+    })
+
+    it('현재 단계 이전은 완료(체크) 표시, 현재 단계는 진행중, 이후는 미래 상태로 보인다', async () => {
+      render(<OrgOverview org={org({ stage: 3 })} />)
+
+      const doneButton = screen.getByText('목록접수').closest('button') as HTMLElement
+      const currentButton = screen.getByText('예비검토').closest('button') as HTMLElement
+      const futureButton = screen.getByText('변호사 배당').closest('button') as HTMLElement
+
+      expect(within(doneButton).getByText('✓')).toBeTruthy()
+      expect(within(currentButton).getByText('진행중')).toBeTruthy()
+      expect(within(futureButton).getByText('-')).toBeTruthy()
+      expect(futureButton).toBeDisabled()
+      expect(doneButton).toBeDisabled()
+      expect(currentButton).not.toBeDisabled()
+
+      await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
+    })
+
+    it('현재 단계를 클릭하면 확인 후 다음 단계로 진행단계 변경 API를 부른다', async () => {
+      const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
+      const onChanged = vi.fn()
+
+      render(<OrgOverview org={org({ stage: 4 })} onChanged={onChanged} />)
+
+      const currentButton = screen.getByText('변호사 배당').closest('button') as HTMLElement
+      fireEvent.click(currentButton)
+
+      expect(confirmSpy).toHaveBeenCalledWith(
+        '다음 단계 "5. 법률검토(권리확인)"(으)로 진행할까요?',
+      )
+      await waitFor(() => expect(mocks.updateStage).toHaveBeenCalledWith(1, 5))
+      expect(onChanged).toHaveBeenCalled()
+
+      confirmSpy.mockRestore()
+    })
+
+    it('확인을 취소하면 진행단계 변경 API를 부르지 않는다', async () => {
+      const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
+
+      render(<OrgOverview org={org({ stage: 4 })} />)
+
+      fireEvent.click(screen.getByText('변호사 배당').closest('button') as HTMLElement)
+
+      expect(confirmSpy).toHaveBeenCalled()
+      expect(mocks.updateStage).not.toHaveBeenCalled()
+
+      confirmSpy.mockRestore()
+    })
+
+    it('현재 단계가 아닌 다른 단계를 클릭해도 아무 일도 일어나지 않는다', async () => {
+      const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
+
+      render(<OrgOverview org={org({ stage: 4 })} />)
+
+      fireEvent.click(screen.getByText('예비검토').closest('button') as HTMLElement)
+      fireEvent.click(screen.getByText('RE:확인').closest('button') as HTMLElement)
+
+      expect(confirmSpy).not.toHaveBeenCalled()
+      expect(mocks.updateStage).not.toHaveBeenCalled()
+
+      confirmSpy.mockRestore()
+    })
+
+    it('마지막 단계(12)가 현재 단계이면 클릭해도 아무 일도 일어나지 않는다', async () => {
+      const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
+
+      render(<OrgOverview org={org({ stage: 12 })} />)
+
+      const lastButton = screen.getByText('보고서 작성').closest('button') as HTMLElement
+      expect(lastButton).toBeDisabled()
+      fireEvent.click(lastButton)
+
+      expect(confirmSpy).not.toHaveBeenCalled()
+      expect(mocks.updateStage).not.toHaveBeenCalled()
+
+      confirmSpy.mockRestore()
+    })
+  })
+
+  describe('진행단계 변경 모달', () => {
+    it('버튼을 누르면 12개 단계 목록이 열리고 하나를 고르면 확인 후 해당 단계로 바꾼다', async () => {
+      const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
+      const onChanged = vi.fn()
+
+      render(<OrgOverview org={org({ stage: 2 })} onChanged={onChanged} />)
+
+      fireEvent.click(screen.getByRole('button', { name: '진행단계 변경' }))
+
+      const modalHeading = screen.getByRole('heading', { name: '진행단계 변경' })
+      const modal = modalHeading.closest('div')!.parentElement as HTMLElement
+      expect(within(modal).getByText('보고서 작성')).toBeTruthy()
+
+      fireEvent.click(within(modal).getByText('법률검토(권리확인)'))
+
+      expect(confirmSpy).toHaveBeenCalledWith(
+        '"5. 법률검토(권리확인)"(으)로 진행단계를 변경할까요?',
+      )
+      await waitFor(() => expect(mocks.updateStage).toHaveBeenCalledWith(1, 5))
+      expect(onChanged).toHaveBeenCalled()
+
+      confirmSpy.mockRestore()
+    })
+
+    it('닫기를 누르면 모달이 사라진다', async () => {
+      render(<OrgOverview org={org({ stage: 2 })} />)
+
+      fireEvent.click(screen.getByRole('button', { name: '진행단계 변경' }))
+      expect(screen.getByRole('heading', { name: '진행단계 변경' })).toBeTruthy()
+
+      fireEvent.click(screen.getByRole('button', { name: '닫기' }))
+
+      expect(screen.queryByRole('heading', { name: '진행단계 변경' })).toBeNull()
+    })
+  })
 })
frontend/src/components/OrgOverview.tsx
--- frontend/src/components/OrgOverview.tsx
+++ frontend/src/components/OrgOverview.tsx
@@ -1,40 +1,28 @@
 import { useState } from 'react'
 import {
   updateAssignments,
+  updateStage,
   type Contact,
   type ContactCategory,
   type Org,
 } from '../api/client'
+import { STAGE_LABELS, stageLabel } from '../stages'
 import ChannelFiles from './ChannelFiles'
 import ChannelPosts from './ChannelPosts'
 import ContactPickerModal from './ContactPickerModal'
 import StatusBadge from './StatusBadge'
+import Timeline from './Timeline'
 import WorkMemos from './WorkMemos'
 
 // 기관관리 상세 화면. 3단계 대시보드 시안의 "기관관리" 레이아웃을 따른다.
-// 웹 DB에 있는 것(연번·기관명·담당자 3종·채널)만 실데이터고,
-// 업무단계/통계/일부 탭 내용은 아직 백엔드가 없어 시안과 같은 골격에
-// "추후 연동" 자리만 잡아 둔다.
-
-const STEPS = [
-  '신청',
-  '목록접수',
-  '예비검토',
-  '변호사 배당',
-  '법률검토(권리확인)',
-  'RE:확인',
-  '법률검토(권리확인) 완료',
-  '법률검토(권리처리)',
-  'RE:처리',
-  '법률검토(권리처리) 완료',
-  '법률검토 최종완료',
-  '보고서 작성',
-]
+// 웹 DB에 있는 것(연번·기관명·담당자 3종·채널·진행단계)만 실데이터고,
+// 통계/일부 탭 내용은 아직 백엔드가 없어 시안과 같은 골격에 "추후 연동" 자리만 잡아 둔다.
 
 const TABS = ['채팅', '자료', '권리확인', '권리처리', 'RE', '타임라인', '업무메모']
 
-// 담당 변호사 변경은 카드의 [생성/변경] 버튼으로 대체되어 목록에서 뺐다
-const DISABLED_ACTIONS = ['진행단계 변경', '자료 업로드', '최초 게시글 보기', '보고서 관리']
+// 담당 변호사 변경은 카드의 [생성/변경] 버튼으로 대체되어 목록에서 뺐다.
+// 진행단계 변경은 업무단계 현황 스트립 + 이 버튼의 모달로 실제 동작한다.
+const DISABLED_ACTIONS = ['자료 업로드', '최초 게시글 보기', '보고서 관리']
 
 // 카드에서 담당자를 지정/변경할 때 쓰는 역할 정의 (채널관리의 배정과 같은 API를 쓴다)
 const CARD_ROLES: Record<
@@ -116,6 +104,10 @@
   const [assignBusy, setAssignBusy] = useState(false)
   const [assignError, setAssignError] = useState<string | null>(null)
 
+  const [stageBusy, setStageBusy] = useState(false)
+  const [stageError, setStageError] = useState<string | null>(null)
+  const [stagePickerOpen, setStagePickerOpen] = useState(false)
+
   async function assign(role: 'applicant' | 'mj' | 'itn' | 'lawyer', contact: Contact) {
     setPickerRole(null)
     setAssignError(null)
@@ -136,6 +128,41 @@
     }
   }
 
+  /** 실제로 진행단계를 바꾸는 공통 경로. 스트립의 "다음 단계로" 클릭과 [진행단계 변경]
+   * 모달의 단계 선택이 모두 여기를 거친다. */
+  async function changeStage(next: number) {
+    setStageError(null)
+    setStageBusy(true)
+    try {
+      await updateStage(org.id, next)
+      onChanged?.()
+    } catch (e) {
+      setStageError(e instanceof Error ? e.message : '진행단계 변경에 실패했습니다.')
+    } finally {
+      setStageBusy(false)
+    }
+  }
+
+  /** 업무단계 현황 스트립에서 "현재 단계"를 클릭했을 때만 호출된다 - 다음 단계로 1칸 전진. */
+  async function advanceStage() {
+    if (org.stage === null || org.stage >= 12 || stageBusy) {
+      return
+    }
+    const next = org.stage + 1
+    if (!window.confirm(`다음 단계 "${next}. ${stageLabel(next)}"(으)로 진행할까요?`)) {
+      return
+    }
+    await changeStage(next)
+  }
+
+  async function pickStage(next: number) {
+    if (!window.confirm(`"${next}. ${stageLabel(next)}"(으)로 진행단계를 변경할까요?`)) {
+      return
+    }
+    setStagePickerOpen(false)
+    await changeStage(next)
+  }
+
   return (
     <div className="p-6">
       <div className="rounded-xl border border-gray-200 bg-white p-6">
@@ -149,6 +176,14 @@
           </div>
 
           <div className="flex flex-wrap gap-2">
+            <button
+              type="button"
+              disabled={stageBusy}
+              onClick={() => setStagePickerOpen(true)}
+              className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
+            >
+              진행단계 변경
+            </button>
             {DISABLED_ACTIONS.map((label) => (
               <button
                 key={label}
@@ -253,23 +288,68 @@
           />
         )}
 
-        {/* 업무단계 현황 */}
+        {/* 업무단계 현황: 완료된 단계는 초록 체크, 현재 단계는 파란 강조 + 클릭 시 다음 단계로
+            전진(확인 후), 그 뒤 단계는 회색 그대로. 채널 생성 전(stage === null)에는 전부 회색이다. */}
         <section className="mt-5">
           <h2 className="text-sm font-semibold">업무단계 현황</h2>
           <ol className="mt-3 flex gap-2 overflow-x-auto pb-2">
-            {STEPS.map((step, i) => (
-              <li
-                key={step}
-                className="flex w-24 shrink-0 flex-col items-center gap-1 rounded-lg border border-gray-200 px-2 py-3 text-center"
-              >
-                <span className="flex h-6 w-6 items-center justify-center rounded-full border border-gray-300 text-xs text-gray-500">
-                  {i + 1}
-                </span>
-                <span className="text-[11px] leading-tight text-gray-600">{step}</span>
-                <span className="text-[11px] text-gray-300">-</span>
-              </li>
-            ))}
+            {STAGE_LABELS.map((step, i) => {
+              const n = i + 1
+              const isDone = org.stage !== null && n < org.stage
+              const isCurrent = org.stage !== null && n === org.stage
+              const clickable = isCurrent && n < 12
+
+              return (
+                <li key={step} className="shrink-0">
+                  <button
+                    type="button"
+                    disabled={!clickable || stageBusy}
+                    title={isCurrent && n >= 12 ? '마지막 단계입니다' : undefined}
+                    onClick={() => {
+                      if (isCurrent) {
+                        void advanceStage()
+                      }
+                    }}
+                    className={`flex w-24 flex-col items-center gap-1 rounded-lg border px-2 py-3 text-center ${
+                      isDone
+                        ? 'border-emerald-200 bg-emerald-50'
+                        : isCurrent
+                          ? 'border-blue-400 bg-blue-50'
+                          : 'border-gray-200'
+                    } ${clickable ? 'cursor-pointer hover:bg-blue-100' : 'cursor-default disabled:opacity-100'}`}
+                  >
+                    <span
+                      className={`flex h-6 w-6 items-center justify-center rounded-full border text-xs ${
+                        isDone
+                          ? 'border-emerald-500 bg-emerald-500 text-white'
+                          : isCurrent
+                            ? 'border-blue-600 bg-blue-600 text-white'
+                            : 'border-gray-300 text-gray-500'
+                      }`}
+                    >
+                      {isDone ? '✓' : n}
+                    </span>
+                    <span
+                      className={`text-[11px] leading-tight ${
+                        isDone ? 'text-emerald-700' : isCurrent ? 'font-semibold text-blue-700' : 'text-gray-600'
+                      }`}
+                    >
+                      {step}
+                    </span>
+                    {isCurrent ? (
+                      <span className="text-[11px] font-semibold text-blue-600">진행중</span>
+                    ) : (
+                      <span className="text-[11px] text-gray-300">-</span>
+                    )}
+                  </button>
+                </li>
+              )
+            })}
           </ol>
+          {org.stage === null && (
+            <p className="mt-1 text-xs text-gray-400">채널 생성 시 신청 단계가 시작됩니다</p>
+          )}
+          {stageError && <p className="mt-1 text-xs text-red-600">{stageError}</p>}
         </section>
 
         {/* 통계 4칸 */}
@@ -322,12 +402,80 @@
           <div className="mt-4 rounded-lg border border-gray-200">
             <WorkMemos key={org.id} org={org} />
           </div>
+        ) : activeTab === '타임라인' ? (
+          <div className="mt-4 rounded-lg border border-gray-200">
+            <Timeline 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>
+
+      {stagePickerOpen && (
+        <StagePickerModal
+          currentStage={org.stage}
+          busy={stageBusy}
+          onPick={(n) => void pickStage(n)}
+          onClose={() => setStagePickerOpen(false)}
+        />
+      )}
+    </div>
+  )
+}
+
+/** [진행단계 변경] 버튼이 여는 모달. 12개 단계 중 하나를 골라 바로 그 단계로 변경한다
+ * (현재 단계는 굵게 표시). ContactPickerModal과 같은 오버레이/닫기 규칙을 따른다. */
+function StagePickerModal({
+  currentStage,
+  busy,
+  onPick,
+  onClose,
+}: {
+  currentStage: number | null
+  busy: boolean
+  onPick: (stage: number) => void
+  onClose: () => void
+}) {
+  return (
+    <div className="fixed inset-0 flex items-center justify-center bg-black/30">
+      <div className="w-96 rounded-lg bg-white p-5">
+        <div className="flex items-center justify-between">
+          <h2 className="text-base font-semibold">진행단계 변경</h2>
+          <button
+            type="button"
+            onClick={onClose}
+            aria-label="닫기"
+            className="rounded-md p-1 text-gray-400 hover:bg-gray-100"
+          >
+            ✕
+          </button>
+        </div>
+
+        <ul className="mt-3 max-h-80 space-y-1 overflow-y-auto">
+          {STAGE_LABELS.map((label, i) => {
+            const n = i + 1
+            const isCurrent = currentStage === n
+            return (
+              <li key={label}>
+                <button
+                  type="button"
+                  disabled={busy}
+                  onClick={() => onPick(n)}
+                  className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-gray-50 disabled:opacity-50 ${
+                    isCurrent ? 'font-semibold text-blue-700' : 'text-gray-700'
+                  }`}
+                >
+                  <span className="w-5 shrink-0 text-xs text-gray-400">{n}</span>
+                  <span>{label}</span>
+                  {isCurrent && <span className="ml-auto text-xs text-blue-500">현재</span>}
+                </button>
+              </li>
+            )
+          })}
+        </ul>
+      </div>
     </div>
   )
 }
 
frontend/src/components/Timeline.test.tsx (added)
+++ frontend/src/components/Timeline.test.tsx
@@ -0,0 +1,118 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import Timeline from './Timeline'
+import type { Org, TimelineEvent } from '../api/client'
+
+const mocks = vi.hoisted(() => ({
+  getTimeline: vi.fn(),
+}))
+
+vi.mock('../api/client', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('../api/client')>()),
+  getTimeline: mocks.getTimeline,
+}))
+
+function org(overrides: Partial<Org> = {}): Org {
+  return {
+    id: 1,
+    orgNo: '001',
+    orgName: '국제방송교류재단',
+    status: 'ACTIVE',
+    applicant: null,
+    mj: null,
+    itn: null,
+    lawyer: null,
+    lawyerAssignedDate: null,
+    channelIdMj: 'chan-mj',
+    channelIdLaw: 'chan-law',
+    stage: 5,
+    ...overrides,
+  }
+}
+
+function event(overrides: Partial<TimelineEvent> = {}): TimelineEvent {
+  return {
+    type: 'STAGE',
+    at: Date.now(),
+    title: '1',
+    detail: null,
+    ...overrides,
+  }
+}
+
+beforeEach(() => {
+  mocks.getTimeline.mockReset()
+})
+
+describe('Timeline', () => {
+  it('불러오는 동안 로딩 문구를 보여준다', () => {
+    mocks.getTimeline.mockReturnValue(new Promise(() => {}))
+
+    render(<Timeline org={org()} />)
+
+    expect(screen.getByText('불러오는 중…')).toBeTruthy()
+  })
+
+  it('기록이 없으면 빈 상태 문구를 보여준다', async () => {
+    mocks.getTimeline.mockResolvedValue([])
+
+    render(<Timeline org={org()} />)
+
+    await waitFor(() =>
+      expect(
+        screen.getByText('아직 기록이 없습니다. 채널을 만들고 업무를 진행하면 여기에 쌓입니다.'),
+      ).toBeTruthy(),
+    )
+  })
+
+  it('세 종류 이벤트를 종류별 라벨과 함께 날짜로 묶어서 보여준다', async () => {
+    const day1 = new Date('2026-07-20T09:00:00').getTime()
+    const day2Morning = new Date('2026-07-21T08:30:00').getTime()
+    const day2Afternoon = new Date('2026-07-21T15:00:00').getTime()
+
+    mocks.getTimeline.mockResolvedValue([
+      event({ type: 'FILE', at: day2Afternoon, title: '보고서.pdf', detail: '문정원 · 박아이티' }),
+      event({ type: 'MEMO', at: day2Morning, title: '송민지', detail: '전화 통화 내용' }),
+      event({ type: 'STAGE', at: day1, title: '4', detail: null }),
+    ])
+
+    render(<Timeline org={org()} />)
+
+    await waitFor(() => expect(screen.getByText('보고서.pdf')).toBeTruthy())
+
+    expect(screen.getAllByText('자료 등록')).toHaveLength(1)
+    expect(screen.getByText('문정원 · 박아이티')).toBeTruthy()
+
+    expect(screen.getByText('업무메모')).toBeTruthy()
+    expect(screen.getByText('송민지')).toBeTruthy()
+    expect(screen.getByText('전화 통화 내용')).toBeTruthy()
+
+    expect(screen.getByText('단계 변경')).toBeTruthy()
+    expect(screen.getByText('4. 변호사 배당 단계로 진행')).toBeTruthy()
+
+    // 날짜 그룹 헤더 2개(2026-07-21, 2026-07-20)가 최신순으로 보여야 한다.
+    const headers = screen.getAllByText(/2026-07-2[01]/)
+    expect(headers[0].textContent).toContain('2026-07-21')
+    expect(headers[1].textContent).toContain('2026-07-20')
+  })
+
+  it('새로고침 버튼을 누르면 다시 조회한다', async () => {
+    mocks.getTimeline.mockResolvedValue([])
+
+    render(<Timeline org={org()} />)
+
+    await waitFor(() => expect(mocks.getTimeline).toHaveBeenCalledTimes(1))
+
+    fireEvent.click(screen.getByRole('button', { name: '새로고침' }))
+
+    await waitFor(() => expect(mocks.getTimeline).toHaveBeenCalledTimes(2))
+  })
+
+  it('조회가 실패하면 오류 메시지를 보여준다', async () => {
+    mocks.getTimeline.mockRejectedValue(new Error('타임라인을 불러오지 못했습니다.'))
+
+    render(<Timeline org={org()} />)
+
+    await waitFor(() => expect(screen.getByText('타임라인을 불러오지 못했습니다.')).toBeTruthy())
+  })
+})
 
frontend/src/components/Timeline.tsx (added)
+++ frontend/src/components/Timeline.tsx
@@ -0,0 +1,185 @@
+import { useEffect, useState } from 'react'
+import { getTimeline, type Org, type TimelineEvent } from '../api/client'
+import { stageLabel } from '../stages'
+
+const dateFormatter = new Intl.DateTimeFormat('ko-KR', {
+  year: 'numeric',
+  month: '2-digit',
+  day: '2-digit',
+  weekday: 'short',
+})
+
+const timeFormatter = new Intl.DateTimeFormat('ko-KR', {
+  hour: '2-digit',
+  minute: '2-digit',
+  hour12: false,
+})
+
+function dateParts(at: number): { year: string; month: string; day: string; weekday: string } {
+  const parts = dateFormatter.formatToParts(new Date(at))
+  const get = (type: string) => parts.find((p) => p.type === type)?.value ?? ''
+  return { year: get('year'), month: get('month'), day: get('day'), weekday: get('weekday') }
+}
+
+function dateKey(at: number): string {
+  const { year, month, day } = dateParts(at)
+  return `${year}-${month}-${day}`
+}
+
+function dateHeader(at: number): string {
+  const { year, month, day, weekday } = dateParts(at)
+  return `${year}-${month}-${day} (${weekday})`
+}
+
+function formatTime(at: number): string {
+  return timeFormatter.format(new Date(at))
+}
+
+interface EventGroup {
+  key: string
+  header: string
+  items: TimelineEvent[]
+}
+
+function groupByDate(events: TimelineEvent[]): EventGroup[] {
+  const groups: EventGroup[] = []
+  for (const event of events) {
+    const key = dateKey(event.at)
+    const current = groups[groups.length - 1]
+    if (current && current.key === key) {
+      current.items.push(event)
+    } else {
+      groups.push({ key, header: dateHeader(event.at), items: [event] })
+    }
+  }
+  return groups
+}
+
+const EVENT_META: Record<TimelineEvent['type'], { dot: string; icon: string; badge: string; badgeClass: string }> = {
+  STAGE: { dot: 'bg-blue-600', icon: '��', badge: '단계 변경', badgeClass: 'bg-blue-50 text-blue-700' },
+  FILE: { dot: 'bg-amber-500', icon: '��', badge: '자료 등록', badgeClass: 'bg-amber-50 text-amber-700' },
+  MEMO: { dot: 'bg-emerald-600', icon: '✏️', badge: '업무메모', badgeClass: 'bg-emerald-50 text-emerald-700' },
+}
+
+/** STAGE 이벤트의 title(단계 번호 문자열)을 "5. 법률검토(권리확인) 진행" 형태로 바꾼다. */
+function stageEventText(title: string): string {
+  const n = Number(title)
+  if (!Number.isInteger(n)) {
+    return `${title} 단계로 진행`
+  }
+  return `${n}. ${stageLabel(n)} 단계로 진행`
+}
+
+function EventBody({ event }: { event: TimelineEvent }) {
+  if (event.type === 'STAGE') {
+    return <p className="text-sm font-medium text-gray-900">{stageEventText(event.title)}</p>
+  }
+  if (event.type === 'FILE') {
+    return (
+      <p className="text-sm text-gray-900">
+        <span className="font-semibold">{event.title}</span>
+        {event.detail && <span className="ml-2 text-xs text-gray-400">{event.detail}</span>}
+      </p>
+    )
+  }
+  return (
+    <p className="text-sm text-gray-900">
+      <span className="font-semibold">{event.title}</span>
+      {event.detail && <span className="ml-2 text-xs text-gray-500">{event.detail}</span>}
+    </p>
+  )
+}
+
+/** 기관관리 상세의 타임라인 탭. 단계 변경 이력·업무메모·채널 첨부파일 등록을 하나의 시간순
+ * 목록으로 합쳐 보여준다(조회는 GET /api/orgs/{id}/timeline 한 번뿐이고 폴링은 없다). */
+export default function Timeline({ org }: { org: Org }) {
+  const [events, setEvents] = useState<TimelineEvent[]>([])
+  const [loading, setLoading] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+  const [reloadKey, setReloadKey] = useState(0)
+
+  useEffect(() => {
+    let cancelled = false
+    setLoading(true)
+    setError(null)
+
+    getTimeline(org.id)
+      .then((data) => {
+        if (!cancelled) {
+          setEvents(data)
+        }
+      })
+      .catch((e: Error) => {
+        if (!cancelled) {
+          setError(e.message)
+        }
+      })
+      .finally(() => {
+        if (!cancelled) {
+          setLoading(false)
+        }
+      })
+
+    return () => {
+      cancelled = true
+    }
+  }, [org.id, reloadKey])
+
+  const groups = groupByDate(events)
+
+  return (
+    <div className="p-4">
+      <div className="flex justify-end">
+        <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>
+
+      {loading && <p className="py-6 text-center text-sm text-gray-400">불러오는 중…</p>}
+      {!loading && error && <p className="py-4 text-center text-sm text-red-600">{error}</p>}
+      {!loading && !error && events.length === 0 && (
+        <p className="py-10 text-center text-sm text-gray-300">
+          아직 기록이 없습니다. 채널을 만들고 업무를 진행하면 여기에 쌓입니다.
+        </p>
+      )}
+
+      {!loading && !error && events.length > 0 && (
+        <div className="mt-3 space-y-6">
+          {groups.map((group) => (
+            <section key={group.key}>
+              <h3 className="text-xs font-semibold text-gray-500">{group.header}</h3>
+              <ol className="mt-2 space-y-4 border-l-2 border-gray-200 pl-5">
+                {group.items.map((event, i) => {
+                  const meta = EVENT_META[event.type]
+                  return (
+                    <li key={`${group.key}-${i}`} className="relative">
+                      <span
+                        aria-hidden="true"
+                        className={`absolute -left-[1.65rem] flex h-5 w-5 items-center justify-center rounded-full text-[10px] leading-none text-white ${meta.dot}`}
+                      >
+                        {meta.icon}
+                      </span>
+                      <div className="flex items-baseline gap-2">
+                        <span className="text-xs tabular-nums text-gray-400">{formatTime(event.at)}</span>
+                        <span className={`rounded px-1.5 py-0.5 text-[11px] font-medium ${meta.badgeClass}`}>
+                          {meta.badge}
+                        </span>
+                      </div>
+                      <div className="mt-1">
+                        <EventBody event={event} />
+                      </div>
+                    </li>
+                  )
+                })}
+              </ol>
+            </section>
+          ))}
+        </div>
+      )}
+    </div>
+  )
+}
frontend/src/components/WorkMemos.test.tsx
--- frontend/src/components/WorkMemos.test.tsx
+++ frontend/src/components/WorkMemos.test.tsx
@@ -52,6 +52,7 @@
     lawyerAssignedDate: null,
     channelIdMj: 'chan-mj',
     channelIdLaw: 'chan-law',
+    stage: null,
     ...overrides,
   }
 }
 
frontend/src/stages.ts (added)
+++ frontend/src/stages.ts
@@ -0,0 +1,22 @@
+// 진행단계 12개 라벨. 백엔드는 숫자(1..12)만 저장하고 라벨은 여기서만 관리한다 -
+// OrgOverview(업무단계 현황 스트립·진행단계 변경 모달)와 Timeline(단계 변경 이벤트)이
+// 같은 배열을 가져다 쓴다. 인덱스 0이 1단계다.
+export const STAGE_LABELS: string[] = [
+  '신청',
+  '목록접수',
+  '예비검토',
+  '변호사 배당',
+  '법률검토(권리확인)',
+  'RE:확인',
+  '법률검토(권리확인) 완료',
+  '법률검토(권리처리)',
+  'RE:처리',
+  '법률검토(권리처리) 완료',
+  '법률검토 최종완료',
+  '보고서 작성',
+]
+
+/** 1..12 단계 번호를 라벨로 바꾼다. 범위 밖이면 번호를 그대로 문자열로 돌려준다. */
+export function stageLabel(stage: number): string {
+  return STAGE_LABELS[stage - 1] ?? String(stage)
+}
Add a comment
List