이호영 이호영 7 days ago
feat: 권리관리 기관 현황에 운영 상태 표시
@42b275443fda1cd98aa8725604fbafa3022ef4ce
frontend/src/api/client.ts
--- frontend/src/api/client.ts
+++ frontend/src/api/client.ts
@@ -353,6 +353,7 @@
   id: number
   orgNo: string
   orgName: string
+  status: OrgStatus
   stage: number | null
   /** false면 칸반의 "단계 미지정" 컬럼으로 간다. 드래그로 단계를 옮길 수 없다. */
   hasChannel: boolean
frontend/src/components/Dashboard.test.tsx
--- frontend/src/components/Dashboard.test.tsx
+++ frontend/src/components/Dashboard.test.tsx
@@ -22,6 +22,7 @@
     id: 1,
     orgNo: '001',
     orgName: '한국출판문화산업진흥원',
+    status: 'ACTIVE',
     stage: 5,
     hasChannel: true,
     lawyerName: '전세준',
frontend/src/components/ProjectsPage.test.tsx
--- frontend/src/components/ProjectsPage.test.tsx
+++ frontend/src/components/ProjectsPage.test.tsx
@@ -25,6 +25,7 @@
     id: 1,
     orgNo: '001_00',
     orgName: '국제방송교류재단',
+    status: 'ACTIVE',
     stage: 5,
     hasChannel: true,
     lawyerName: '권홍철',
frontend/src/components/RightsDashboard.test.tsx
--- frontend/src/components/RightsDashboard.test.tsx
+++ frontend/src/components/RightsDashboard.test.tsx
@@ -12,8 +12,8 @@
   summary: { applied: 2, docSubmitted: 0, reviewTotal: 120, reviewDone: 90, processTotal: 40, processDone: 10, unresolvedRe: 0, reportWriting: 0, completed: 0 },
   rights: { reviewResults: [{ label: '신유형개방', count: 90 }], reviewSourceKoglTypes: [{ label: '1유형', count: 120 }], reviewJudgedKoglTypes: [{ label: '1유형', count: 90 }], processTypes: [{ label: '0유형', count: 30 }, { label: '1유형', count: 10 }], processStatuses: [{ label: '진행중', count: 30 }, { label: '처리완료', count: 10 }], changedKoglCount: 6, reviewFreeUseCount: 80, reviewNeedsProcessingCount: 7, reviewNoProcessingCount: 3 },
   orgs: [
-    { id: 1, orgNo: '001', orgName: '완료기관', stage: 7, hasChannel: true, lawyerName: null, mjName: null, lawyerAssignedDate: null, reviewTotal: 90, reviewDone: 90, processTotal: 10, processDone: 10, stageChangedAt: null, lastChangedAt: null, reCount: 0 },
-    { id: 2, orgNo: '002', orgName: '대기기관', stage: 5, hasChannel: true, lawyerName: null, mjName: null, lawyerAssignedDate: null, reviewTotal: 30, reviewDone: 0, processTotal: 30, processDone: 0, stageChangedAt: null, lastChangedAt: null, reCount: 0 },
+    { id: 1, orgNo: '001', orgName: '완료기관', status: 'ACTIVE' as const, stage: 7, hasChannel: true, lawyerName: null, mjName: null, lawyerAssignedDate: null, reviewTotal: 90, reviewDone: 90, processTotal: 10, processDone: 10, stageChangedAt: null, lastChangedAt: null, reCount: 0 },
+    { id: 2, orgNo: '002', orgName: '대기기관', status: 'READY' as const, stage: 5, hasChannel: true, lawyerName: null, mjName: null, lawyerAssignedDate: null, reviewTotal: 30, reviewDone: 0, processTotal: 30, processDone: 0, stageChangedAt: null, lastChangedAt: null, reCount: 0 },
   ],
 }
 
@@ -31,6 +31,8 @@
     expect(screen.getByText('자유이용 확정')).toBeTruthy()
     expect(screen.getByText('처리필요 건수')).toBeTruthy()
     expect(screen.getByText('권리처리 추진 미희망')).toBeTruthy()
+    expect(screen.getByText('운영 중')).toBeTruthy()
+    expect(screen.getByText('생성 가능')).toBeTruthy()
   })
 
   it('기관 검색과 상태 필터가 동작한다', async () => {
frontend/src/components/RightsDashboard.tsx
--- frontend/src/components/RightsDashboard.tsx
+++ frontend/src/components/RightsDashboard.tsx
@@ -1,5 +1,6 @@
 import { useEffect, useMemo, useState } from 'react'
 import { getDashboard, type DashboardData, type DashboardOrgRow, type DistributionItem } from '../api/client'
+import StatusBadge from './StatusBadge'
 
 type Mode = 'review' | 'process'
 
@@ -150,13 +151,13 @@
         </div>
         <div className="overflow-x-auto">
           <table className="w-full min-w-[680px] text-xs">
-            <thead className="bg-gray-50 text-gray-500"><tr><th className="px-4 py-2.5 text-left font-medium">기관명</th><th className="px-4 py-2.5 text-right font-medium">대상</th><th className="px-4 py-2.5 text-right font-medium">완료</th><th className="px-4 py-2.5 text-right font-medium">잔여</th><th className="w-64 px-4 py-2.5 text-left font-medium">진척률</th></tr></thead>
+            <thead className="bg-gray-50 text-gray-500"><tr><th className="px-4 py-2.5 text-left font-medium">기관명</th><th className="px-4 py-2.5 text-left font-medium">기관 상태</th><th className="px-4 py-2.5 text-right font-medium">대상</th><th className="px-4 py-2.5 text-right font-medium">완료</th><th className="px-4 py-2.5 text-right font-medium">잔여</th><th className="w-64 px-4 py-2.5 text-left font-medium">진척률</th></tr></thead>
             <tbody className="divide-y divide-gray-100">
               {rows.map((row: DashboardOrgRow) => { const rowTotal=isReview?row.reviewTotal:row.processTotal; const rowDone=isReview?row.reviewDone:row.processDone; const rowRate=percent(rowDone,rowTotal); return (
                 <tr key={row.id} className="cursor-pointer hover:bg-gray-50" tabIndex={0} onClick={() => onOpenOrg(row.id)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpenOrg(row.id) } }}>
-                  <td className="px-4 py-3 font-medium text-gray-900"><span className="mr-2 text-gray-400">{row.orgNo}</span>{row.orgName}</td><td className="px-4 py-3 text-right tabular-nums">{format(rowTotal)}</td><td className="px-4 py-3 text-right font-medium tabular-nums" style={{ color: accent }}>{format(rowDone)}</td><td className="px-4 py-3 text-right tabular-nums">{format(Math.max(rowTotal-rowDone,0))}</td><td className="px-4 py-3"><div className="flex items-center gap-2"><div className="h-1.5 flex-1 rounded-full bg-gray-200"><div className="h-full rounded-full" style={{ width: `${rowRate}%`, backgroundColor: accent }} /></div><b className="w-10 text-right tabular-nums">{rowRate}%</b></div></td>
+                  <td className="px-4 py-3 font-medium text-gray-900"><span className="mr-2 text-gray-400">{row.orgNo}</span>{row.orgName}</td><td className="px-4 py-3"><StatusBadge status={row.status} /></td><td className="px-4 py-3 text-right tabular-nums">{format(rowTotal)}</td><td className="px-4 py-3 text-right font-medium tabular-nums" style={{ color: accent }}>{format(rowDone)}</td><td className="px-4 py-3 text-right tabular-nums">{format(Math.max(rowTotal-rowDone,0))}</td><td className="px-4 py-3"><div className="flex items-center gap-2"><div className="h-1.5 flex-1 rounded-full bg-gray-200"><div className="h-full rounded-full" style={{ width: `${rowRate}%`, backgroundColor: accent }} /></div><b className="w-10 text-right tabular-nums">{rowRate}%</b></div></td>
                 </tr>) })}
-              {!rows.length && <tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500">조건에 맞는 기관이 없습니다.</td></tr>}
+              {!rows.length && <tr><td colSpan={6} className="px-4 py-12 text-center text-gray-500">조건에 맞는 기관이 없습니다.</td></tr>}
             </tbody>
           </table>
         </div>
src/main/java/kr/itn/itnhub/dashboard/DashboardOrgRow.java
--- src/main/java/kr/itn/itnhub/dashboard/DashboardOrgRow.java
+++ src/main/java/kr/itn/itnhub/dashboard/DashboardOrgRow.java
@@ -1,5 +1,7 @@
 package kr.itn.itnhub.dashboard;
 
+import kr.itn.itnhub.org.OrgStatus;
+
 /**
  * 대시보드가 기관 1곳을 그리는 데 필요한 값을 한 줄로 모은 것. 칸반 카드와 "최근 진행 기관"
  * 표가 같은 행을 쓴다 - 두 곳이 같은 숫자를 보여야 하고, 기관 47곳을 그릴 때마다 기관별로
@@ -17,6 +19,7 @@
         Long id,
         String orgNo,
         String orgName,
+        OrgStatus status,
         Integer stage,
         boolean hasChannel,
         String lawyerName,
src/main/resources/mapper/DashboardMapper.xml
--- src/main/resources/mapper/DashboardMapper.xml
+++ src/main/resources/mapper/DashboardMapper.xml
@@ -12,6 +12,7 @@
       <idArg column="id" javaType="java.lang.Long"/>
       <arg column="org_no" javaType="java.lang.String"/>
       <arg column="org_name" javaType="java.lang.String"/>
+      <arg column="org_status" javaType="kr.itn.itnhub.org.OrgStatus"/>
       <arg column="stage" javaType="java.lang.Integer"/>
       <arg column="has_channel" javaType="_boolean"/>
       <arg column="lawyer_name" javaType="java.lang.String"/>
@@ -48,6 +49,12 @@
       o.id,
       o.org_no,
       o.org_name,
+      case
+        when nullif(trim(o.channel_id_mj), '') is not null and nullif(trim(o.channel_id_law), '') is not null then 'ACTIVE'
+        when nullif(trim(o.channel_id_mj), '') is not null or nullif(trim(o.channel_id_law), '') is not null then 'PARTIAL'
+        when o.applicant_contact_id is not null then 'READY'
+        else 'INFO_PENDING'
+      end as org_status,
       o.stage,
       (o.channel_id_mj is not null or o.channel_id_law is not null) as has_channel,
       lc.name as lawyer_name,
src/test/java/kr/itn/itnhub/dashboard/DashboardControllerTest.java
--- src/test/java/kr/itn/itnhub/dashboard/DashboardControllerTest.java
+++ src/test/java/kr/itn/itnhub/dashboard/DashboardControllerTest.java
@@ -157,10 +157,12 @@
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.orgs.length()").value(3))
                 .andExpect(jsonPath("$.orgs[0].orgNo").value("001"))
+                .andExpect(jsonPath("$.orgs[0].status").value("INFO_PENDING"))
                 .andExpect(jsonPath("$.orgs[0].hasChannel").value(false))
                 .andExpect(jsonPath("$.orgs[0].stage").doesNotExist())
                 .andExpect(jsonPath("$.orgs[1].orgNo").value("002"))
                 .andExpect(jsonPath("$.orgs[1].hasChannel").value(true))
+                .andExpect(jsonPath("$.orgs[1].status").value("PARTIAL"))
                 .andExpect(jsonPath("$.orgs[1].stage").value(2));
     }
 
Add a comment
List