이호영 이호영 7 days ago
feat: 권리관리 유형별 그래프 DB 집계 추가
@d1d4ccf195f04830cb55cbdc2fd5d92c7caaa188
frontend/src/api/client.ts
--- frontend/src/api/client.ts
+++ frontend/src/api/client.ts
@@ -373,8 +373,18 @@
 export interface DashboardData {
   summary: DashboardSummary
   orgs: DashboardOrgRow[]
+  rights?: {
+    reviewResults: DistributionItem[]
+    reviewSourceKoglTypes: DistributionItem[]
+    reviewJudgedKoglTypes: DistributionItem[]
+    processTypes: DistributionItem[]
+    processStatuses: DistributionItem[]
+    changedKoglCount: number
+  }
 }
 
+export interface DistributionItem { label: string; count: number }
+
 export function getDashboard(): Promise<DashboardData> {
   return request<DashboardData>('/api/dashboard')
 }
frontend/src/components/RightsDashboard.test.tsx
--- frontend/src/components/RightsDashboard.test.tsx
+++ frontend/src/components/RightsDashboard.test.tsx
@@ -10,6 +10,7 @@
 
 const data = {
   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 },
   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 },
@@ -24,6 +25,9 @@
     expect(await screen.findByText('90 / 120')).toBeTruthy()
     expect(screen.getAllByText('75%')).toHaveLength(3)
     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,5 @@
 import { useEffect, useMemo, useState } from 'react'
-import { getDashboard, type DashboardData, type DashboardOrgRow } from '../api/client'
+import { getDashboard, type DashboardData, type DashboardOrgRow, type DistributionItem } from '../api/client'
 
 type Mode = 'review' | 'process'
 
@@ -27,6 +27,31 @@
       </div>
     </div>
   )
+}
+
+const CHART_COLORS = ['#2f7ed8', '#19a974', '#efa500', '#0b8f35', '#8b5cf6', '#64748b']
+
+function BarDistribution({ title, items }: { title: string; items: DistributionItem[] }) {
+  const max = Math.max(...items.map((item) => item.count), 1)
+  return <article className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
+    <h2 className="mb-3 text-xs font-semibold text-gray-600">{title}</h2>
+    <div className="space-y-2.5">{items.length ? items.map((item) => <div key={item.label} className="grid grid-cols-[minmax(80px,130px)_1fr_auto] items-center gap-2 text-[11px]">
+      <span className="truncate" title={item.label}>{item.label}</span><div className="h-1.5 rounded-full bg-gray-200"><div className="h-full rounded-full bg-blue-600" style={{ width: `${item.count / max * 100}%` }} /></div><b className="tabular-nums">{format(item.count)}</b>
+    </div>) : <p className="py-6 text-center text-xs text-gray-400">집계 데이터가 없습니다.</p>}</div>
+  </article>
+}
+
+function DonutDistribution({ title, items }: { title: string; items: DistributionItem[] }) {
+  const total = items.reduce((sum, item) => sum + item.count, 0)
+  let cursor = 0
+  const stops = items.map((item, index) => { const start = cursor; cursor += total ? item.count / total * 100 : 0; return `${CHART_COLORS[index % CHART_COLORS.length]} ${start}% ${cursor}%` }).join(', ')
+  return <article className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
+    <h2 className="mb-3 text-xs font-semibold text-gray-600">{title}</h2>
+    <div className="flex min-h-28 items-center justify-center gap-5">
+      <div role="img" aria-label={`${title} 전체 ${format(total)}건`} className="relative flex h-20 w-20 shrink-0 items-center justify-center rounded-full" style={{ background: total ? `conic-gradient(${stops})` : '#e5e7eb' }}><div className="absolute inset-2.5 rounded-full bg-white"/><div className="relative text-center"><span className="block text-[9px] text-gray-500">합계</span><b className="text-sm tabular-nums">{format(total)}</b></div></div>
+      <div className="space-y-1.5">{items.map((item,index)=><div key={item.label} className="flex items-center gap-2 text-[10px]"><i className="h-2 w-2 rounded-full" style={{backgroundColor:CHART_COLORS[index%CHART_COLORS.length]}}/><span>{item.label}</span><b className="tabular-nums">{format(item.count)}</b><span className="text-gray-400">{total ? Math.round(item.count/total*100) : 0}%</span></div>)}</div>
+    </div>
+  </article>
 }
 
 export default function RightsDashboard({ mode, onOpenOrg }: Props) {
@@ -70,6 +95,7 @@
   const rate = percent(done, total)
   const accent = isReview ? '#ea8a00' : '#078d2a'
   const orgsWithWork = data.orgs.filter((row) => (isReview ? row.reviewTotal : row.processTotal) > 0).length
+  const rights = data.rights
 
   return (
     <div className="p-5 lg:p-7">
@@ -96,6 +122,19 @@
         <article className="rounded-lg border border-gray-200 bg-white p-5 shadow-sm"><p className="text-xs font-medium text-gray-500">진행 기관</p><p className="mt-2 text-2xl font-bold tabular-nums">{data.orgs.filter((row) => { const t=isReview?row.reviewTotal:row.processTotal; const d=isReview?row.reviewDone:row.processDone; return d>0&&d<t }).length}</p><p className="mt-2 text-xs text-gray-500">일부 처리가 완료된 기관</p></article>
       </section>
 
+      {rights && (isReview ? (
+        <section className="mt-3 grid gap-3 lg:grid-cols-3">
+          <BarDistribution title="권리확인 결과 유형별 현황" items={rights.reviewResults} />
+          <DonutDistribution title="검토 대상 분류 현황" items={rights.reviewSourceKoglTypes} />
+          <DonutDistribution title="판정 공공누리유형 현황" items={rights.reviewJudgedKoglTypes} />
+        </section>
+      ) : (
+        <section className="mt-3 grid gap-3 lg:grid-cols-2">
+          <BarDistribution title="권리처리 유형별 현황" items={rights.processTypes} />
+          <DonutDistribution title="처리 상태 현황" items={rights.processStatuses} />
+        </section>
+      ))}
+
       <section className="mt-3 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm">
         <div className="flex flex-col gap-3 border-b border-gray-200 p-4 sm:flex-row sm:items-end sm:justify-between">
           <div><h2 className="text-sm font-semibold">기관별 {title} 현황</h2><p className="mt-1 text-xs text-gray-500">기관을 선택하면 상세 {title} 화면으로 이동합니다.</p></div>
src/main/java/kr/itn/itnhub/dashboard/DashboardMapper.java
--- src/main/java/kr/itn/itnhub/dashboard/DashboardMapper.java
+++ src/main/java/kr/itn/itnhub/dashboard/DashboardMapper.java
@@ -15,4 +15,11 @@
      *                   참조할 방법이 없어 서비스가 읽어 넘긴다.
      */
     List<DashboardOrgRow> findOrgRows(@Param("doneStatus") String doneStatus);
+
+    List<DistributionItem> reviewResultDistribution();
+    List<DistributionItem> reviewSourceKoglDistribution();
+    List<DistributionItem> reviewJudgedKoglDistribution();
+    List<DistributionItem> processTypeDistribution();
+    List<DistributionItem> processStatusDistribution();
+    int countChangedKoglTypes();
 }
src/main/java/kr/itn/itnhub/dashboard/DashboardResponse.java
--- src/main/java/kr/itn/itnhub/dashboard/DashboardResponse.java
+++ src/main/java/kr/itn/itnhub/dashboard/DashboardResponse.java
@@ -3,5 +3,6 @@
 import java.util.List;
 
 /** {@code GET /api/dashboard} 응답. 화면 세 블록(KPI·칸반·최근 진행 기관)이 이 하나로 다 그려진다. */
-public record DashboardResponse(DashboardSummary summary, List<DashboardOrgRow> orgs) {
+public record DashboardResponse(DashboardSummary summary, List<DashboardOrgRow> orgs,
+                                RightsDashboardStats rights) {
 }
src/main/java/kr/itn/itnhub/dashboard/DashboardService.java
--- src/main/java/kr/itn/itnhub/dashboard/DashboardService.java
+++ src/main/java/kr/itn/itnhub/dashboard/DashboardService.java
@@ -43,7 +43,14 @@
 
     public DashboardResponse dashboard() {
         List<DashboardOrgRow> rows = mapper.findOrgRows(doneStatus());
-        return new DashboardResponse(summarize(rows), rows);
+        RightsDashboardStats rights = new RightsDashboardStats(
+                mapper.reviewResultDistribution(),
+                mapper.reviewSourceKoglDistribution(),
+                mapper.reviewJudgedKoglDistribution(),
+                mapper.processTypeDistribution(),
+                mapper.processStatusDistribution(),
+                mapper.countChangedKoglTypes());
+        return new DashboardResponse(summarize(rows), rows, rights);
     }
 
     /**
 
src/main/java/kr/itn/itnhub/dashboard/DistributionItem.java (added)
+++ src/main/java/kr/itn/itnhub/dashboard/DistributionItem.java
@@ -0,0 +1,4 @@
+package kr.itn.itnhub.dashboard;
+
+public record DistributionItem(String label, int count) {
+}
 
src/main/java/kr/itn/itnhub/dashboard/RightsDashboardStats.java (added)
+++ src/main/java/kr/itn/itnhub/dashboard/RightsDashboardStats.java
@@ -0,0 +1,12 @@
+package kr.itn.itnhub.dashboard;
+
+import java.util.List;
+
+public record RightsDashboardStats(
+        List<DistributionItem> reviewResults,
+        List<DistributionItem> reviewSourceKoglTypes,
+        List<DistributionItem> reviewJudgedKoglTypes,
+        List<DistributionItem> processTypes,
+        List<DistributionItem> processStatuses,
+        int changedKoglCount) {
+}
src/main/resources/mapper/DashboardMapper.xml
--- src/main/resources/mapper/DashboardMapper.xml
+++ src/main/resources/mapper/DashboardMapper.xml
@@ -94,4 +94,43 @@
     order by o.org_no asc, o.org_name asc
   </select>
 
+  <resultMap id="distributionResultMap" type="kr.itn.itnhub.dashboard.DistributionItem">
+    <constructor>
+      <arg column="label" javaType="java.lang.String"/>
+      <arg column="count" javaType="_int"/>
+    </constructor>
+  </resultMap>
+
+  <select id="reviewResultDistribution" resultMap="distributionResultMap">
+    select coalesce(nullif(trim(review_result), ''), '미확인') as label, count(*)::int as count
+    from review_item group by 1 order by count desc, label
+  </select>
+
+  <select id="reviewSourceKoglDistribution" resultMap="distributionResultMap">
+    select coalesce(nullif(trim(kogl_type), ''), '미부착') as label, count(*)::int as count
+    from review_item group by 1 order by count desc, label
+  </select>
+
+  <select id="reviewJudgedKoglDistribution" resultMap="distributionResultMap">
+    select coalesce(nullif(trim(judged_kogl_type), ''), '미판정') as label, count(*)::int as count
+    from review_item where review_result is not null and review_result &lt;&gt; ''
+    group by 1 order by count desc, label
+  </select>
+
+  <select id="processTypeDistribution" resultMap="distributionResultMap">
+    select coalesce(nullif(trim(judged_kogl_type), ''), '미판정') as label, count(*)::int as count
+    from process_item group by 1 order by count desc, label
+  </select>
+
+  <select id="processStatusDistribution" resultMap="distributionResultMap">
+    select coalesce(nullif(trim(process_status), ''), '진행중') as label, count(*)::int as count
+    from process_item group by 1 order by count desc, label
+  </select>
+
+  <select id="countChangedKoglTypes" resultType="int">
+    select count(*)::int from process_item
+    where judged_kogl_type is not null and judged_kogl_type &lt;&gt; ''
+      and coalesce(trim(prior_kogl_type), '') &lt;&gt; trim(judged_kogl_type)
+  </select>
+
 </mapper>
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
@@ -126,6 +126,21 @@
     }
 
     @Test
+    void 권리관리_그래프는_DB의_분류값을_집계한다() throws Exception {
+        jdbc.update("update review_item set kogl_type = '1유형', judged_kogl_type = '2유형' where org_id = ? and seq = 1", stage2Id);
+        jdbc.update("update process_item set judged_kogl_type = '1유형', prior_kogl_type = '0유형' where org_id = ? and seq = 1", stage2Id);
+
+        mvc.perform(get("/api/dashboard"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.rights.reviewResults[0].label").value("미확인"))
+                .andExpect(jsonPath("$.rights.reviewSourceKoglTypes[0].label").value("미부착"))
+                .andExpect(jsonPath("$.rights.reviewJudgedKoglTypes[0].label").value("2유형"))
+                .andExpect(jsonPath("$.rights.processTypes.length()").value(2))
+                .andExpect(jsonPath("$.rights.processStatuses.length()").value(2))
+                .andExpect(jsonPath("$.rights.changedKoglCount").value(1));
+    }
+
+    @Test
     void RE는_데이터_모델이_없어_항상_0이다() throws Exception {
         mvc.perform(get("/api/dashboard"))
                 .andExpect(status().isOk())
Add a comment
List