feat: 업무단계 스트립에 단계별 도달 날짜 표시
@4a80cc7ccbc174f39f1079b7ee59b9d162e562fb
--- frontend/src/api/client.ts
+++ frontend/src/api/client.ts
... | ... | @@ -195,6 +195,16 @@ |
| 195 | 195 |
return request<ProvisionResult>(`/api/orgs/${id}/channels`, { method: 'POST' })
|
| 196 | 196 |
} |
| 197 | 197 |
|
| 198 |
+export interface StageHistoryEntry {
|
|
| 199 |
+ stage: number |
|
| 200 |
+ changedAt: number |
|
| 201 |
+} |
|
| 202 |
+ |
|
| 203 |
+/** 업무단계 스트립의 단계별 도달 날짜 표시용. 최신순으로 온다. */ |
|
| 204 |
+export function getStageHistory(orgId: number): Promise<StageHistoryEntry[]> {
|
|
| 205 |
+ return request<StageHistoryEntry[]>(`/api/orgs/${orgId}/stage-history`)
|
|
| 206 |
+} |
|
| 207 |
+ |
|
| 198 | 208 |
export function updateStage(orgId: number, stage: number): Promise<Org> {
|
| 199 | 209 |
return request<Org>(`/api/orgs/${orgId}/stage`, {
|
| 200 | 210 |
method: 'PUT', |
--- frontend/src/components/OrgOverview.test.tsx
+++ frontend/src/components/OrgOverview.test.tsx
... | ... | @@ -11,6 +11,7 @@ |
| 11 | 11 |
updateAssignments: vi.fn(), |
| 12 | 12 |
getTimeline: vi.fn(), |
| 13 | 13 |
updateStage: vi.fn(), |
| 14 |
+ getStageHistory: vi.fn(), |
|
| 14 | 15 |
})) |
| 15 | 16 |
|
| 16 | 17 |
vi.mock('../api/client', async (importOriginal) => ({
|
... | ... | @@ -22,6 +23,7 @@ |
| 22 | 23 |
updateAssignments: mocks.updateAssignments, |
| 23 | 24 |
getTimeline: mocks.getTimeline, |
| 24 | 25 |
updateStage: mocks.updateStage, |
| 26 |
+ getStageHistory: mocks.getStageHistory, |
|
| 25 | 27 |
})) |
| 26 | 28 |
|
| 27 | 29 |
function contact(overrides: Partial<Contact> = {}): Contact {
|
... | ... | @@ -71,6 +73,7 @@ |
| 71 | 73 |
mocks.getMemos.mockReset().mockResolvedValue([]) |
| 72 | 74 |
mocks.getContacts.mockReset().mockResolvedValue([]) |
| 73 | 75 |
mocks.updateAssignments.mockReset().mockResolvedValue(undefined) |
| 76 |
+ mocks.getStageHistory.mockReset().mockResolvedValue([]) |
|
| 74 | 77 |
mocks.getTimeline.mockReset().mockResolvedValue([]) |
| 75 | 78 |
mocks.updateStage.mockReset().mockResolvedValue(undefined) |
| 76 | 79 |
}) |
... | ... | @@ -226,6 +229,22 @@ |
| 226 | 229 |
await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled()) |
| 227 | 230 |
}) |
| 228 | 231 |
|
| 232 |
+ it('완료된 단계와 진행중 단계 아래에 도달 날짜가 찍힌다', async () => {
|
|
| 233 |
+ mocks.getStageHistory.mockResolvedValue([ |
|
| 234 |
+ { stage: 3, changedAt: new Date('2026-07-24T10:00:00').getTime() },
|
|
| 235 |
+ { stage: 2, changedAt: new Date('2026-07-20T09:00:00').getTime() },
|
|
| 236 |
+ { stage: 1, changedAt: new Date('2026-07-15T09:00:00').getTime() },
|
|
| 237 |
+ ]) |
|
| 238 |
+ |
|
| 239 |
+ render(<OrgOverview org={org({ stage: 3 })} />)
|
|
| 240 |
+ |
|
| 241 |
+ await waitFor(() => {
|
|
| 242 |
+ expect(screen.getByText('2026-07-24')).toBeTruthy()
|
|
| 243 |
+ expect(screen.getByText('2026-07-20')).toBeTruthy()
|
|
| 244 |
+ expect(screen.getByText('2026-07-15')).toBeTruthy()
|
|
| 245 |
+ }) |
|
| 246 |
+ }) |
|
| 247 |
+ |
|
| 229 | 248 |
it('업무단계 12개가 순서대로 보인다', async () => {
|
| 230 | 249 |
render(<OrgOverview org={org()} />)
|
| 231 | 250 |
|
--- frontend/src/components/OrgOverview.tsx
+++ frontend/src/components/OrgOverview.tsx
... | ... | @@ -1,5 +1,6 @@ |
| 1 |
-import { useState } from 'react'
|
|
| 1 |
+import { useEffect, useState } from 'react'
|
|
| 2 | 2 |
import {
|
| 3 |
+ getStageHistory, |
|
| 3 | 4 |
updateAssignments, |
| 4 | 5 |
updateStage, |
| 5 | 6 |
type Contact, |
... | ... | @@ -43,6 +44,17 @@ |
| 43 | 44 |
function mattermostChannelUrl(org: Org): string {
|
| 44 | 45 |
const slug = org.orgNo.trim().toLowerCase().replace(/_/g, '-').replace(/ /g, '-') |
| 45 | 46 |
return MATTERMOST_BASE + `org-${slug}-mj`
|
| 47 |
+} |
|
| 48 |
+ |
|
| 49 |
+const stageDateFormatter = new Intl.DateTimeFormat('ko-KR', {
|
|
| 50 |
+ year: 'numeric', |
|
| 51 |
+ month: '2-digit', |
|
| 52 |
+ day: '2-digit', |
|
| 53 |
+}) |
|
| 54 |
+ |
|
| 55 |
+/** 2026. 07. 24. → 2026-07-24 */ |
|
| 56 |
+function formatStageDate(ms: number): string {
|
|
| 57 |
+ return stageDateFormatter.format(new Date(ms)).replace(/\. ?/g, '-').replace(/-$/, '') |
|
| 46 | 58 |
} |
| 47 | 59 |
|
| 48 | 60 |
function Field({ label, value }: { label: string; value: string | null }) {
|
... | ... | @@ -107,6 +119,33 @@ |
| 107 | 119 |
const [stageBusy, setStageBusy] = useState(false) |
| 108 | 120 |
const [stageError, setStageError] = useState<string | null>(null) |
| 109 | 121 |
const [stagePickerOpen, setStagePickerOpen] = useState(false) |
| 122 |
+ // 단계별로 '그 단계에 들어간 날짜'(가장 최근 이력)를 찍기 위한 조회 결과 |
|
| 123 |
+ const [stageDates, setStageDates] = useState<Record<number, number>>({})
|
|
| 124 |
+ |
|
| 125 |
+ useEffect(() => {
|
|
| 126 |
+ let cancelled = false |
|
| 127 |
+ getStageHistory(org.id) |
|
| 128 |
+ .then((rows) => {
|
|
| 129 |
+ if (cancelled) {
|
|
| 130 |
+ return |
|
| 131 |
+ } |
|
| 132 |
+ // 최신순으로 오므로, 단계별 첫 등장( = 가장 최근 도달 시각)만 채택한다 |
|
| 133 |
+ const dates: Record<number, number> = {}
|
|
| 134 |
+ for (const row of rows) {
|
|
| 135 |
+ if (!(row.stage in dates)) {
|
|
| 136 |
+ dates[row.stage] = row.changedAt |
|
| 137 |
+ } |
|
| 138 |
+ } |
|
| 139 |
+ setStageDates(dates) |
|
| 140 |
+ }) |
|
| 141 |
+ .catch(() => {
|
|
| 142 |
+ /* 날짜는 장식이다 - 조회 실패로 화면을 깨지 않는다 */ |
|
| 143 |
+ }) |
|
| 144 |
+ return () => {
|
|
| 145 |
+ cancelled = true |
|
| 146 |
+ } |
|
| 147 |
+ // eslint-disable-next-line react-hooks/exhaustive-deps |
|
| 148 |
+ }, [org.id, org.stage]) |
|
| 110 | 149 |
|
| 111 | 150 |
async function assign(role: 'applicant' | 'mj' | 'itn' | 'lawyer', contact: Contact) {
|
| 112 | 151 |
setPickerRole(null) |
... | ... | @@ -337,7 +376,16 @@ |
| 337 | 376 |
{step}
|
| 338 | 377 |
</span> |
| 339 | 378 |
{isCurrent ? (
|
| 340 |
- <span className="text-[11px] font-semibold text-blue-600">진행중</span> |
|
| 379 |
+ <> |
|
| 380 |
+ <span className="text-[11px] font-semibold text-blue-600">진행중</span> |
|
| 381 |
+ <span className="text-[10px] text-blue-500"> |
|
| 382 |
+ {stageDates[n] ? formatStageDate(stageDates[n]) : ''}
|
|
| 383 |
+ </span> |
|
| 384 |
+ </> |
|
| 385 |
+ ) : isDone && stageDates[n] ? ( |
|
| 386 |
+ <span className="text-[10px] text-emerald-600"> |
|
| 387 |
+ {formatStageDate(stageDates[n])}
|
|
| 388 |
+ </span> |
|
| 341 | 389 |
) : ( |
| 342 | 390 |
<span className="text-[11px] text-gray-300">-</span> |
| 343 | 391 |
)} |
--- src/main/java/kr/itn/itnhub/stage/StageController.java
+++ src/main/java/kr/itn/itnhub/stage/StageController.java
... | ... | @@ -1,10 +1,13 @@ |
| 1 | 1 |
package kr.itn.itnhub.stage; |
| 2 | 2 |
|
| 3 | 3 |
import kr.itn.itnhub.org.OrgResponse; |
| 4 |
+import org.springframework.web.bind.annotation.GetMapping; |
|
| 4 | 5 |
import org.springframework.web.bind.annotation.PathVariable; |
| 5 | 6 |
import org.springframework.web.bind.annotation.PutMapping; |
| 6 | 7 |
import org.springframework.web.bind.annotation.RequestBody; |
| 7 | 8 |
import org.springframework.web.bind.annotation.RestController; |
| 9 |
+ |
|
| 10 |
+import java.util.List; |
|
| 8 | 11 |
|
| 9 | 12 |
/** 기관관리 상세의 [진행단계 변경] 및 업무단계 현황 스트립이 부르는 엔드포인트. */ |
| 10 | 13 |
@RestController |
... | ... | @@ -20,4 +23,10 @@ |
| 20 | 23 |
public OrgResponse updateStage(@PathVariable Long id, @RequestBody StageRequest request) {
|
| 21 | 24 |
return stageService.updateStage(id, request); |
| 22 | 25 |
} |
| 26 |
+ |
|
| 27 |
+ /** 업무단계 스트립이 완료/진행중 단계 아래에 날짜를 찍을 때 쓴다. 최신순. */ |
|
| 28 |
+ @GetMapping("/api/orgs/{id}/stage-history")
|
|
| 29 |
+ public List<StageHistoryRow> history(@PathVariable Long id) {
|
|
| 30 |
+ return stageService.history(id); |
|
| 31 |
+ } |
|
| 23 | 32 |
} |
--- src/main/java/kr/itn/itnhub/stage/StageService.java
+++ src/main/java/kr/itn/itnhub/stage/StageService.java
... | ... | @@ -40,4 +40,11 @@ |
| 40 | 40 |
|
| 41 | 41 |
return OrgResponse.of(orgMapper.findById(orgId)); |
| 42 | 42 |
} |
| 43 |
+ |
|
| 44 |
+ public java.util.List<StageHistoryRow> history(Long orgId) {
|
|
| 45 |
+ if (orgMapper.findById(orgId) == null) {
|
|
| 46 |
+ throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
|
|
| 47 |
+ } |
|
| 48 |
+ return historyMapper.findByOrg(orgId); |
|
| 49 |
+ } |
|
| 43 | 50 |
} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?