--- frontend/src/api/client.ts
+++ frontend/src/api/client.ts
... | ... | @@ -373,8 +373,18 @@ |
| 373 | 373 |
export interface DashboardData {
|
| 374 | 374 |
summary: DashboardSummary |
| 375 | 375 |
orgs: DashboardOrgRow[] |
| 376 |
+ rights?: {
|
|
| 377 |
+ reviewResults: DistributionItem[] |
|
| 378 |
+ reviewSourceKoglTypes: DistributionItem[] |
|
| 379 |
+ reviewJudgedKoglTypes: DistributionItem[] |
|
| 380 |
+ processTypes: DistributionItem[] |
|
| 381 |
+ processStatuses: DistributionItem[] |
|
| 382 |
+ changedKoglCount: number |
|
| 383 |
+ } |
|
| 376 | 384 |
} |
| 377 | 385 |
|
| 386 |
+export interface DistributionItem { label: string; count: number }
|
|
| 387 |
+ |
|
| 378 | 388 |
export function getDashboard(): Promise<DashboardData> {
|
| 379 | 389 |
return request<DashboardData>('/api/dashboard')
|
| 380 | 390 |
} |
--- frontend/src/components/RightsDashboard.test.tsx
+++ frontend/src/components/RightsDashboard.test.tsx
... | ... | @@ -10,6 +10,7 @@ |
| 10 | 10 |
|
| 11 | 11 |
const data = {
|
| 12 | 12 |
summary: { applied: 2, docSubmitted: 0, reviewTotal: 120, reviewDone: 90, processTotal: 40, processDone: 10, unresolvedRe: 0, reportWriting: 0, completed: 0 },
|
| 13 |
+ 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 },
|
|
| 13 | 14 |
orgs: [ |
| 14 | 15 |
{ 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 },
|
| 15 | 16 |
{ 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 @@ |
| 24 | 25 |
expect(await screen.findByText('90 / 120')).toBeTruthy()
|
| 25 | 26 |
expect(screen.getAllByText('75%')).toHaveLength(3)
|
| 26 | 27 |
expect(screen.getByText('완료기관')).toBeTruthy()
|
| 28 |
+ expect(screen.getByText('권리확인 결과 유형별 현황')).toBeTruthy()
|
|
| 29 |
+ expect(screen.getByText('검토 대상 분류 현황')).toBeTruthy()
|
|
| 30 |
+ expect(screen.getByText('판정 공공누리유형 현황')).toBeTruthy()
|
|
| 27 | 31 |
}) |
| 28 | 32 |
|
| 29 | 33 |
it('기관 검색과 상태 필터가 동작한다', async () => {
|
--- frontend/src/components/RightsDashboard.tsx
+++ frontend/src/components/RightsDashboard.tsx
... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 |
import { useEffect, useMemo, useState } from 'react'
|
| 2 |
-import { getDashboard, type DashboardData, type DashboardOrgRow } from '../api/client'
|
|
| 2 |
+import { getDashboard, type DashboardData, type DashboardOrgRow, type DistributionItem } from '../api/client'
|
|
| 3 | 3 |
|
| 4 | 4 |
type Mode = 'review' | 'process' |
| 5 | 5 |
|
... | ... | @@ -27,6 +27,31 @@ |
| 27 | 27 |
</div> |
| 28 | 28 |
</div> |
| 29 | 29 |
) |
| 30 |
+} |
|
| 31 |
+ |
|
| 32 |
+const CHART_COLORS = ['#2f7ed8', '#19a974', '#efa500', '#0b8f35', '#8b5cf6', '#64748b'] |
|
| 33 |
+ |
|
| 34 |
+function BarDistribution({ title, items }: { title: string; items: DistributionItem[] }) {
|
|
| 35 |
+ const max = Math.max(...items.map((item) => item.count), 1) |
|
| 36 |
+ return <article className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm"> |
|
| 37 |
+ <h2 className="mb-3 text-xs font-semibold text-gray-600">{title}</h2>
|
|
| 38 |
+ <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]">
|
|
| 39 |
+ <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>
|
|
| 40 |
+ </div>) : <p className="py-6 text-center text-xs text-gray-400">집계 데이터가 없습니다.</p>}</div> |
|
| 41 |
+ </article> |
|
| 42 |
+} |
|
| 43 |
+ |
|
| 44 |
+function DonutDistribution({ title, items }: { title: string; items: DistributionItem[] }) {
|
|
| 45 |
+ const total = items.reduce((sum, item) => sum + item.count, 0) |
|
| 46 |
+ let cursor = 0 |
|
| 47 |
+ 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(', ')
|
|
| 48 |
+ return <article className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm"> |
|
| 49 |
+ <h2 className="mb-3 text-xs font-semibold text-gray-600">{title}</h2>
|
|
| 50 |
+ <div className="flex min-h-28 items-center justify-center gap-5"> |
|
| 51 |
+ <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>
|
|
| 52 |
+ <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>
|
|
| 53 |
+ </div> |
|
| 54 |
+ </article> |
|
| 30 | 55 |
} |
| 31 | 56 |
|
| 32 | 57 |
export default function RightsDashboard({ mode, onOpenOrg }: Props) {
|
... | ... | @@ -70,6 +95,7 @@ |
| 70 | 95 |
const rate = percent(done, total) |
| 71 | 96 |
const accent = isReview ? '#ea8a00' : '#078d2a' |
| 72 | 97 |
const orgsWithWork = data.orgs.filter((row) => (isReview ? row.reviewTotal : row.processTotal) > 0).length |
| 98 |
+ const rights = data.rights |
|
| 73 | 99 |
|
| 74 | 100 |
return ( |
| 75 | 101 |
<div className="p-5 lg:p-7"> |
... | ... | @@ -96,6 +122,19 @@ |
| 96 | 122 |
<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>
|
| 97 | 123 |
</section> |
| 98 | 124 |
|
| 125 |
+ {rights && (isReview ? (
|
|
| 126 |
+ <section className="mt-3 grid gap-3 lg:grid-cols-3"> |
|
| 127 |
+ <BarDistribution title="권리확인 결과 유형별 현황" items={rights.reviewResults} />
|
|
| 128 |
+ <DonutDistribution title="검토 대상 분류 현황" items={rights.reviewSourceKoglTypes} />
|
|
| 129 |
+ <DonutDistribution title="판정 공공누리유형 현황" items={rights.reviewJudgedKoglTypes} />
|
|
| 130 |
+ </section> |
|
| 131 |
+ ) : ( |
|
| 132 |
+ <section className="mt-3 grid gap-3 lg:grid-cols-2"> |
|
| 133 |
+ <BarDistribution title="권리처리 유형별 현황" items={rights.processTypes} />
|
|
| 134 |
+ <DonutDistribution title="처리 상태 현황" items={rights.processStatuses} />
|
|
| 135 |
+ </section> |
|
| 136 |
+ ))} |
|
| 137 |
+ |
|
| 99 | 138 |
<section className="mt-3 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm"> |
| 100 | 139 |
<div className="flex flex-col gap-3 border-b border-gray-200 p-4 sm:flex-row sm:items-end sm:justify-between"> |
| 101 | 140 |
<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
... | ... | @@ -15,4 +15,11 @@ |
| 15 | 15 |
* 참조할 방법이 없어 서비스가 읽어 넘긴다. |
| 16 | 16 |
*/ |
| 17 | 17 |
List<DashboardOrgRow> findOrgRows(@Param("doneStatus") String doneStatus);
|
| 18 |
+ |
|
| 19 |
+ List<DistributionItem> reviewResultDistribution(); |
|
| 20 |
+ List<DistributionItem> reviewSourceKoglDistribution(); |
|
| 21 |
+ List<DistributionItem> reviewJudgedKoglDistribution(); |
|
| 22 |
+ List<DistributionItem> processTypeDistribution(); |
|
| 23 |
+ List<DistributionItem> processStatusDistribution(); |
|
| 24 |
+ int countChangedKoglTypes(); |
|
| 18 | 25 |
} |
--- src/main/java/kr/itn/itnhub/dashboard/DashboardResponse.java
+++ src/main/java/kr/itn/itnhub/dashboard/DashboardResponse.java
... | ... | @@ -3,5 +3,6 @@ |
| 3 | 3 |
import java.util.List; |
| 4 | 4 |
|
| 5 | 5 |
/** {@code GET /api/dashboard} 응답. 화면 세 블록(KPI·칸반·최근 진행 기관)이 이 하나로 다 그려진다. */
|
| 6 |
-public record DashboardResponse(DashboardSummary summary, List<DashboardOrgRow> orgs) {
|
|
| 6 |
+public record DashboardResponse(DashboardSummary summary, List<DashboardOrgRow> orgs, |
|
| 7 |
+ RightsDashboardStats rights) {
|
|
| 7 | 8 |
} |
--- src/main/java/kr/itn/itnhub/dashboard/DashboardService.java
+++ src/main/java/kr/itn/itnhub/dashboard/DashboardService.java
... | ... | @@ -43,7 +43,14 @@ |
| 43 | 43 |
|
| 44 | 44 |
public DashboardResponse dashboard() {
|
| 45 | 45 |
List<DashboardOrgRow> rows = mapper.findOrgRows(doneStatus()); |
| 46 |
- return new DashboardResponse(summarize(rows), rows); |
|
| 46 |
+ RightsDashboardStats rights = new RightsDashboardStats( |
|
| 47 |
+ mapper.reviewResultDistribution(), |
|
| 48 |
+ mapper.reviewSourceKoglDistribution(), |
|
| 49 |
+ mapper.reviewJudgedKoglDistribution(), |
|
| 50 |
+ mapper.processTypeDistribution(), |
|
| 51 |
+ mapper.processStatusDistribution(), |
|
| 52 |
+ mapper.countChangedKoglTypes()); |
|
| 53 |
+ return new DashboardResponse(summarize(rows), rows, rights); |
|
| 47 | 54 |
} |
| 48 | 55 |
|
| 49 | 56 |
/** |
+++ src/main/java/kr/itn/itnhub/dashboard/DistributionItem.java
... | ... | @@ -0,0 +1,4 @@ |
| 1 | +package kr.itn.itnhub.dashboard; | |
| 2 | + | |
| 3 | +public record DistributionItem(String label, int count) { | |
| 4 | +} |
+++ src/main/java/kr/itn/itnhub/dashboard/RightsDashboardStats.java
... | ... | @@ -0,0 +1,12 @@ |
| 1 | +package kr.itn.itnhub.dashboard; | |
| 2 | + | |
| 3 | +import java.util.List; | |
| 4 | + | |
| 5 | +public record RightsDashboardStats( | |
| 6 | + List<DistributionItem> reviewResults, | |
| 7 | + List<DistributionItem> reviewSourceKoglTypes, | |
| 8 | + List<DistributionItem> reviewJudgedKoglTypes, | |
| 9 | + List<DistributionItem> processTypes, | |
| 10 | + List<DistributionItem> processStatuses, | |
| 11 | + int changedKoglCount) { | |
| 12 | +} |
--- src/main/resources/mapper/DashboardMapper.xml
+++ src/main/resources/mapper/DashboardMapper.xml
... | ... | @@ -94,4 +94,43 @@ |
| 94 | 94 |
order by o.org_no asc, o.org_name asc |
| 95 | 95 |
</select> |
| 96 | 96 |
|
| 97 |
+ <resultMap id="distributionResultMap" type="kr.itn.itnhub.dashboard.DistributionItem"> |
|
| 98 |
+ <constructor> |
|
| 99 |
+ <arg column="label" javaType="java.lang.String"/> |
|
| 100 |
+ <arg column="count" javaType="_int"/> |
|
| 101 |
+ </constructor> |
|
| 102 |
+ </resultMap> |
|
| 103 |
+ |
|
| 104 |
+ <select id="reviewResultDistribution" resultMap="distributionResultMap"> |
|
| 105 |
+ select coalesce(nullif(trim(review_result), ''), '미확인') as label, count(*)::int as count |
|
| 106 |
+ from review_item group by 1 order by count desc, label |
|
| 107 |
+ </select> |
|
| 108 |
+ |
|
| 109 |
+ <select id="reviewSourceKoglDistribution" resultMap="distributionResultMap"> |
|
| 110 |
+ select coalesce(nullif(trim(kogl_type), ''), '미부착') as label, count(*)::int as count |
|
| 111 |
+ from review_item group by 1 order by count desc, label |
|
| 112 |
+ </select> |
|
| 113 |
+ |
|
| 114 |
+ <select id="reviewJudgedKoglDistribution" resultMap="distributionResultMap"> |
|
| 115 |
+ select coalesce(nullif(trim(judged_kogl_type), ''), '미판정') as label, count(*)::int as count |
|
| 116 |
+ from review_item where review_result is not null and review_result <> '' |
|
| 117 |
+ group by 1 order by count desc, label |
|
| 118 |
+ </select> |
|
| 119 |
+ |
|
| 120 |
+ <select id="processTypeDistribution" resultMap="distributionResultMap"> |
|
| 121 |
+ select coalesce(nullif(trim(judged_kogl_type), ''), '미판정') as label, count(*)::int as count |
|
| 122 |
+ from process_item group by 1 order by count desc, label |
|
| 123 |
+ </select> |
|
| 124 |
+ |
|
| 125 |
+ <select id="processStatusDistribution" resultMap="distributionResultMap"> |
|
| 126 |
+ select coalesce(nullif(trim(process_status), ''), '진행중') as label, count(*)::int as count |
|
| 127 |
+ from process_item group by 1 order by count desc, label |
|
| 128 |
+ </select> |
|
| 129 |
+ |
|
| 130 |
+ <select id="countChangedKoglTypes" resultType="int"> |
|
| 131 |
+ select count(*)::int from process_item |
|
| 132 |
+ where judged_kogl_type is not null and judged_kogl_type <> '' |
|
| 133 |
+ and coalesce(trim(prior_kogl_type), '') <> trim(judged_kogl_type) |
|
| 134 |
+ </select> |
|
| 135 |
+ |
|
| 97 | 136 |
</mapper> |
--- src/test/java/kr/itn/itnhub/dashboard/DashboardControllerTest.java
+++ src/test/java/kr/itn/itnhub/dashboard/DashboardControllerTest.java
... | ... | @@ -126,6 +126,21 @@ |
| 126 | 126 |
} |
| 127 | 127 |
|
| 128 | 128 |
@Test |
| 129 |
+ void 권리관리_그래프는_DB의_분류값을_집계한다() throws Exception {
|
|
| 130 |
+ jdbc.update("update review_item set kogl_type = '1유형', judged_kogl_type = '2유형' where org_id = ? and seq = 1", stage2Id);
|
|
| 131 |
+ jdbc.update("update process_item set judged_kogl_type = '1유형', prior_kogl_type = '0유형' where org_id = ? and seq = 1", stage2Id);
|
|
| 132 |
+ |
|
| 133 |
+ mvc.perform(get("/api/dashboard"))
|
|
| 134 |
+ .andExpect(status().isOk()) |
|
| 135 |
+ .andExpect(jsonPath("$.rights.reviewResults[0].label").value("미확인"))
|
|
| 136 |
+ .andExpect(jsonPath("$.rights.reviewSourceKoglTypes[0].label").value("미부착"))
|
|
| 137 |
+ .andExpect(jsonPath("$.rights.reviewJudgedKoglTypes[0].label").value("2유형"))
|
|
| 138 |
+ .andExpect(jsonPath("$.rights.processTypes.length()").value(2))
|
|
| 139 |
+ .andExpect(jsonPath("$.rights.processStatuses.length()").value(2))
|
|
| 140 |
+ .andExpect(jsonPath("$.rights.changedKoglCount").value(1));
|
|
| 141 |
+ } |
|
| 142 |
+ |
|
| 143 |
+ @Test |
|
| 129 | 144 |
void RE는_데이터_모델이_없어_항상_0이다() throws Exception {
|
| 130 | 145 |
mvc.perform(get("/api/dashboard"))
|
| 131 | 146 |
.andExpect(status().isOk()) |
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?