ITN Dev 07-24
feat: 업무단계 스트립에 단계별 도달 날짜 표시
@4a80cc7ccbc174f39f1079b7ee59b9d162e562fb
frontend/src/api/client.ts
--- frontend/src/api/client.ts
+++ frontend/src/api/client.ts
@@ -195,6 +195,16 @@
   return request<ProvisionResult>(`/api/orgs/${id}/channels`, { method: 'POST' })
 }
 
+export interface StageHistoryEntry {
+  stage: number
+  changedAt: number
+}
+
+/** 업무단계 스트립의 단계별 도달 날짜 표시용. 최신순으로 온다. */
+export function getStageHistory(orgId: number): Promise<StageHistoryEntry[]> {
+  return request<StageHistoryEntry[]>(`/api/orgs/${orgId}/stage-history`)
+}
+
 export function updateStage(orgId: number, stage: number): Promise<Org> {
   return request<Org>(`/api/orgs/${orgId}/stage`, {
     method: 'PUT',
frontend/src/components/OrgOverview.test.tsx
--- frontend/src/components/OrgOverview.test.tsx
+++ frontend/src/components/OrgOverview.test.tsx
@@ -11,6 +11,7 @@
   updateAssignments: vi.fn(),
   getTimeline: vi.fn(),
   updateStage: vi.fn(),
+  getStageHistory: vi.fn(),
 }))
 
 vi.mock('../api/client', async (importOriginal) => ({
@@ -22,6 +23,7 @@
   updateAssignments: mocks.updateAssignments,
   getTimeline: mocks.getTimeline,
   updateStage: mocks.updateStage,
+  getStageHistory: mocks.getStageHistory,
 }))
 
 function contact(overrides: Partial<Contact> = {}): Contact {
@@ -71,6 +73,7 @@
   mocks.getMemos.mockReset().mockResolvedValue([])
   mocks.getContacts.mockReset().mockResolvedValue([])
   mocks.updateAssignments.mockReset().mockResolvedValue(undefined)
+  mocks.getStageHistory.mockReset().mockResolvedValue([])
   mocks.getTimeline.mockReset().mockResolvedValue([])
   mocks.updateStage.mockReset().mockResolvedValue(undefined)
 })
@@ -226,6 +229,22 @@
     await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
   })
 
+  it('완료된 단계와 진행중 단계 아래에 도달 날짜가 찍힌다', async () => {
+    mocks.getStageHistory.mockResolvedValue([
+      { stage: 3, changedAt: new Date('2026-07-24T10:00:00').getTime() },
+      { stage: 2, changedAt: new Date('2026-07-20T09:00:00').getTime() },
+      { stage: 1, changedAt: new Date('2026-07-15T09:00:00').getTime() },
+    ])
+
+    render(<OrgOverview org={org({ stage: 3 })} />)
+
+    await waitFor(() => {
+      expect(screen.getByText('2026-07-24')).toBeTruthy()
+      expect(screen.getByText('2026-07-20')).toBeTruthy()
+      expect(screen.getByText('2026-07-15')).toBeTruthy()
+    })
+  })
+
   it('업무단계 12개가 순서대로 보인다', async () => {
     render(<OrgOverview org={org()} />)
 
frontend/src/components/OrgOverview.tsx
--- frontend/src/components/OrgOverview.tsx
+++ frontend/src/components/OrgOverview.tsx
@@ -1,5 +1,6 @@
-import { useState } from 'react'
+import { useEffect, useState } from 'react'
 import {
+  getStageHistory,
   updateAssignments,
   updateStage,
   type Contact,
@@ -43,6 +44,17 @@
 function mattermostChannelUrl(org: Org): string {
   const slug = org.orgNo.trim().toLowerCase().replace(/_/g, '-').replace(/ /g, '-')
   return MATTERMOST_BASE + `org-${slug}-mj`
+}
+
+const stageDateFormatter = new Intl.DateTimeFormat('ko-KR', {
+  year: 'numeric',
+  month: '2-digit',
+  day: '2-digit',
+})
+
+/** 2026. 07. 24. → 2026-07-24 */
+function formatStageDate(ms: number): string {
+  return stageDateFormatter.format(new Date(ms)).replace(/\. ?/g, '-').replace(/-$/, '')
 }
 
 function Field({ label, value }: { label: string; value: string | null }) {
@@ -107,6 +119,33 @@
   const [stageBusy, setStageBusy] = useState(false)
   const [stageError, setStageError] = useState<string | null>(null)
   const [stagePickerOpen, setStagePickerOpen] = useState(false)
+  // 단계별로 '그 단계에 들어간 날짜'(가장 최근 이력)를 찍기 위한 조회 결과
+  const [stageDates, setStageDates] = useState<Record<number, number>>({})
+
+  useEffect(() => {
+    let cancelled = false
+    getStageHistory(org.id)
+      .then((rows) => {
+        if (cancelled) {
+          return
+        }
+        // 최신순으로 오므로, 단계별 첫 등장( = 가장 최근 도달 시각)만 채택한다
+        const dates: Record<number, number> = {}
+        for (const row of rows) {
+          if (!(row.stage in dates)) {
+            dates[row.stage] = row.changedAt
+          }
+        }
+        setStageDates(dates)
+      })
+      .catch(() => {
+        /* 날짜는 장식이다 - 조회 실패로 화면을 깨지 않는다 */
+      })
+    return () => {
+      cancelled = true
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [org.id, org.stage])
 
   async function assign(role: 'applicant' | 'mj' | 'itn' | 'lawyer', contact: Contact) {
     setPickerRole(null)
@@ -337,7 +376,16 @@
                       {step}
                     </span>
                     {isCurrent ? (
-                      <span className="text-[11px] font-semibold text-blue-600">진행중</span>
+                      <>
+                        <span className="text-[11px] font-semibold text-blue-600">진행중</span>
+                        <span className="text-[10px] text-blue-500">
+                          {stageDates[n] ? formatStageDate(stageDates[n]) : ''}
+                        </span>
+                      </>
+                    ) : isDone && stageDates[n] ? (
+                      <span className="text-[10px] text-emerald-600">
+                        {formatStageDate(stageDates[n])}
+                      </span>
                     ) : (
                       <span className="text-[11px] text-gray-300">-</span>
                     )}
src/main/java/kr/itn/itnhub/stage/StageController.java
--- src/main/java/kr/itn/itnhub/stage/StageController.java
+++ src/main/java/kr/itn/itnhub/stage/StageController.java
@@ -1,10 +1,13 @@
 package kr.itn.itnhub.stage;
 
 import kr.itn.itnhub.org.OrgResponse;
+import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PathVariable;
 import org.springframework.web.bind.annotation.PutMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
 
 /** 기관관리 상세의 [진행단계 변경] 및 업무단계 현황 스트립이 부르는 엔드포인트. */
 @RestController
@@ -20,4 +23,10 @@
     public OrgResponse updateStage(@PathVariable Long id, @RequestBody StageRequest request) {
         return stageService.updateStage(id, request);
     }
+
+    /** 업무단계 스트립이 완료/진행중 단계 아래에 날짜를 찍을 때 쓴다. 최신순. */
+    @GetMapping("/api/orgs/{id}/stage-history")
+    public List<StageHistoryRow> history(@PathVariable Long id) {
+        return stageService.history(id);
+    }
 }
src/main/java/kr/itn/itnhub/stage/StageService.java
--- src/main/java/kr/itn/itnhub/stage/StageService.java
+++ src/main/java/kr/itn/itnhub/stage/StageService.java
@@ -40,4 +40,11 @@
 
         return OrgResponse.of(orgMapper.findById(orgId));
     }
+
+    public java.util.List<StageHistoryRow> history(Long orgId) {
+        if (orgMapper.findById(orgId) == null) {
+            throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
+        }
+        return historyMapper.findByOrg(orgId);
+    }
 }
Add a comment
List