이호영 이호영 7 days ago
feat: 권리확인·권리처리 통합 현황 메뉴 구현
@fd373a87e771f875794b28d9c80f8c7f152b5a9e
frontend/src/App.tsx
--- frontend/src/App.tsx
+++ frontend/src/App.tsx
@@ -7,6 +7,7 @@
 import OrgList from './components/OrgList'
 import OrgOverview from './components/OrgOverview'
 import ProjectsPage from './components/ProjectsPage'
+import RightsDashboard from './components/RightsDashboard'
 import SeedUpload from './components/SeedUpload'
 import SharedFiles from './components/SharedFiles'
 import Sidebar from './components/Sidebar'
@@ -17,7 +18,7 @@
 // 화면 4개: Dashboard(전체 기관 현황 - 좌측 기관 목록 없이 전폭으로 쓴다),
 // 기관관리(시안 레이아웃의 기관 상세), 채널관리(명부 시드 + 담당자 배정 + 채널 생성),
 // 담당자관리(담당자 3종 CRUD - 담당자 정보가 입력되는 유일한 화면).
-type Page = 'dashboard' | 'orgs' | 'channels' | 'contacts' | 'projects' | 'files' | 'system'
+type Page = 'dashboard' | 'orgs' | 'channels' | 'contacts' | 'projects' | 'rights-check' | 'rights-process' | 'files' | 'system'
 
 const PAGE_TITLE: Record<Page, string> = {
   dashboard: 'Dashboard',
@@ -25,6 +26,8 @@
   channels: '채널관리',
   contacts: '담당자관리',
   projects: '사업관리',
+  'rights-check': '권리확인',
+  'rights-process': '권리처리',
   files: '자료실',
   system: '시스템관리',
 }
@@ -44,6 +47,7 @@
   const [auth, setAuth] = useState<AuthState>('checking')
   const [authErrorMessage, setAuthErrorMessage] = useState<string | null>(null)
   const [page, setPage] = useState<Page>('dashboard')
+  const [orgInitialTab, setOrgInitialTab] = useState<'권리확인' | '권리처리' | undefined>()
   // 창이 좁아지면 자동으로 접힌다. 사용자의 접기/펼치기 선택은 저장된다.
   const [menuCollapsed, toggleMenu] = useCollapsiblePanel('itnhub.menu', '(max-width: 1023px)')
   const [listCollapsed, toggleList] = useCollapsiblePanel('itnhub.orglist', '(max-width: 1279px)')
@@ -119,9 +123,12 @@
             key === 'channels' ||
             key === 'contacts' ||
             key === 'projects' ||
+            key === 'rights-check' ||
+            key === 'rights-process' ||
             key === 'files' ||
             key === 'system'
           ) {
+            if (key === 'orgs') setOrgInitialTab(undefined)
             setPage(key)
           }
         }}
@@ -144,7 +151,7 @@
 
         <div className="flex min-h-0 flex-1">
           {page !== 'contacts' && page !== 'dashboard' && page !== 'system'
-            && page !== 'projects' && page !== 'files' && (
+            && page !== 'projects' && page !== 'rights-check' && page !== 'rights-process' && page !== 'files' && (
             <div
               className={`flex shrink-0 flex-col border-r border-gray-200 transition-all duration-200 ${
                 listCollapsed ? 'w-14' : 'w-80'
@@ -168,6 +175,7 @@
               <Dashboard
                 onOpenOrg={(orgId) => {
                   setSelectedId(orgId)
+                  setOrgInitialTab(undefined)
                   setPage('orgs')
                 }}
                 // 요청자 요구: 대시보드 하단에서 전체를 볼 때는 사업관리로 넘어간다
@@ -177,6 +185,16 @@
               <ProjectsPage
                 onOpenOrg={(orgId) => {
                   setSelectedId(orgId)
+                  setOrgInitialTab(undefined)
+                  setPage('orgs')
+                }}
+              />
+            ) : page === 'rights-check' || page === 'rights-process' ? (
+              <RightsDashboard
+                mode={page === 'rights-check' ? 'review' : 'process'}
+                onOpenOrg={(orgId) => {
+                  setSelectedId(orgId)
+                  setOrgInitialTab(page === 'rights-check' ? '권리확인' : '권리처리')
                   setPage('orgs')
                 }}
               />
@@ -190,7 +208,7 @@
               page === 'channels' ? (
                 <OrgDetail org={selected} onChanged={() => void reload()} />
               ) : (
-                <OrgOverview org={selected} onChanged={() => void reload()} />
+                <OrgOverview org={selected} onChanged={() => void reload()} initialTab={orgInitialTab} />
               )
             ) : (
               <p className="p-8 text-sm text-gray-500">왼쪽에서 기관을 선택하세요.</p>
frontend/src/components/OrgOverview.tsx
--- frontend/src/components/OrgOverview.tsx
+++ frontend/src/components/OrgOverview.tsx
@@ -133,12 +133,18 @@
 export default function OrgOverview({
   org,
   onChanged,
+  initialTab,
 }: {
   org: Org
   onChanged?: () => void
+  initialTab?: '권리확인' | '권리처리'
 }) {
   const stages = useStages()
-  const [activeTab, setActiveTab] = useState(TABS[0])
+  const [activeTab, setActiveTab] = useState(initialTab ?? TABS[0])
+
+  useEffect(() => {
+    if (initialTab) setActiveTab(initialTab)
+  }, [initialTab, org.id])
   const [pickerRole, setPickerRole] = useState<'applicant' | 'mj' | 'itn' | 'lawyer' | null>(null)
   const [assignBusy, setAssignBusy] = useState(false)
   const [assignError, setAssignError] = useState<string | null>(null)
 
frontend/src/components/RightsDashboard.test.tsx (added)
+++ frontend/src/components/RightsDashboard.test.tsx
@@ -0,0 +1,46 @@
+import { fireEvent, render, screen, within } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import RightsDashboard from './RightsDashboard'
+
+const mocks = vi.hoisted(() => ({ getDashboard: vi.fn() }))
+vi.mock('../api/client', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('../api/client')>()),
+  getDashboard: mocks.getDashboard,
+}))
+
+const data = {
+  summary: { applied: 2, docSubmitted: 0, reviewTotal: 120, reviewDone: 90, processTotal: 40, processDone: 10, unresolvedRe: 0, reportWriting: 0, completed: 0 },
+  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 },
+  ],
+}
+
+describe('RightsDashboard', () => {
+  beforeEach(() => mocks.getDashboard.mockResolvedValue(data))
+
+  it('DB 집계 응답으로 권리확인 현황을 표시한다', async () => {
+    render(<RightsDashboard mode="review" onOpenOrg={() => {}} />)
+    expect(await screen.findByText('90 / 120')).toBeTruthy()
+    expect(screen.getAllByText('75%')).toHaveLength(3)
+    expect(screen.getByText('완료기관')).toBeTruthy()
+  })
+
+  it('기관 검색과 상태 필터가 동작한다', async () => {
+    render(<RightsDashboard mode="process" onOpenOrg={() => {}} />)
+    await screen.findByText('완료기관')
+    fireEvent.change(screen.getByLabelText('기관명 검색'), { target: { value: '대기' } })
+    expect(screen.queryByText('완료기관')).toBeNull()
+    expect(screen.getByText('대기기관')).toBeTruthy()
+    fireEvent.change(screen.getByLabelText('진행 상태'), { target: { value: 'complete' } })
+    expect(screen.getByText('조건에 맞는 기관이 없습니다.')).toBeTruthy()
+  })
+
+  it('기관 행을 선택하면 상세 화면 콜백을 호출한다', async () => {
+    const onOpenOrg = vi.fn()
+    render(<RightsDashboard mode="review" onOpenOrg={onOpenOrg} />)
+    const cell = await screen.findByText('완료기관')
+    fireEvent.click(within(cell.closest('tr')!).getByText('완료기관'))
+    expect(onOpenOrg).toHaveBeenCalledWith(1)
+  })
+})
 
frontend/src/components/RightsDashboard.tsx (added)
+++ frontend/src/components/RightsDashboard.tsx
@@ -0,0 +1,124 @@
+import { useEffect, useMemo, useState } from 'react'
+import { getDashboard, type DashboardData, type DashboardOrgRow } from '../api/client'
+
+type Mode = 'review' | 'process'
+
+interface Props {
+  mode: Mode
+  onOpenOrg: (orgId: number) => void
+}
+
+const format = (value: number) => value.toLocaleString('ko-KR')
+const percent = (done: number, total: number) => total ? Math.round((done / total) * 1000) / 10 : 0
+
+function ProgressRing({ done, total, color }: { done: number; total: number; color: string }) {
+  const rate = percent(done, total)
+  return (
+    <div
+      className="relative flex h-24 w-24 shrink-0 items-center justify-center rounded-full"
+      style={{ background: `conic-gradient(${color} ${rate}%, #e5e7eb 0)` }}
+      role="img"
+      aria-label={`${rate}% 완료`}
+    >
+      <div className="absolute inset-3 rounded-full bg-white" />
+      <div className="relative text-center">
+        <strong className="block text-xl font-bold">{rate}%</strong>
+        <span className="text-[10px] text-gray-500">{format(done)} / {format(total)}</span>
+      </div>
+    </div>
+  )
+}
+
+export default function RightsDashboard({ mode, onOpenOrg }: Props) {
+  const [data, setData] = useState<DashboardData | null>(null)
+  const [error, setError] = useState<string | null>(null)
+  const [keyword, setKeyword] = useState('')
+  const [status, setStatus] = useState('all')
+
+  useEffect(() => {
+    let active = true
+    setData(null)
+    setError(null)
+    void getDashboard()
+      .then((result) => active && setData(result))
+      .catch((cause: Error) => active && setError(cause.message))
+    return () => { active = false }
+  }, [mode])
+
+  const rows = useMemo(() => {
+    if (!data) return []
+    return data.orgs.filter((row) => {
+      const total = mode === 'review' ? row.reviewTotal : row.processTotal
+      const done = mode === 'review' ? row.reviewDone : row.processDone
+      const matchesKeyword = row.orgName.includes(keyword.trim()) || row.orgNo.includes(keyword.trim())
+      const matchesStatus = status === 'all'
+        || (status === 'complete' && total > 0 && done === total)
+        || (status === 'progress' && done > 0 && done < total)
+        || (status === 'waiting' && total > 0 && done === 0)
+      return matchesKeyword && matchesStatus
+    })
+  }, [data, keyword, mode, status])
+
+  if (error) return <div role="alert" className="m-6 rounded-md border border-red-200 bg-red-50 p-4 text-sm text-red-700">{error}</div>
+  if (!data) return <p className="p-8 text-sm text-gray-500">권리관리 현황을 불러오는 중…</p>
+
+  const isReview = mode === 'review'
+  const title = isReview ? '권리확인' : '권리처리'
+  const total = isReview ? data.summary.reviewTotal : data.summary.processTotal
+  const done = isReview ? data.summary.reviewDone : data.summary.processDone
+  const remaining = Math.max(total - done, 0)
+  const rate = percent(done, total)
+  const accent = isReview ? '#ea8a00' : '#078d2a'
+  const orgsWithWork = data.orgs.filter((row) => (isReview ? row.reviewTotal : row.processTotal) > 0).length
+
+  return (
+    <div className="p-5 lg:p-7">
+      <div className="mb-4">
+        <h1 className="text-xl font-bold text-gray-900">{title}</h1>
+        <p className="mt-1 text-xs text-gray-500">{isReview ? '검토 대상 게시물의 권리 확인 및 처리 방향 결정' : '권리확인 완료 건의 처리 진행 상황 관리'}</p>
+      </div>
+
+      <section className="grid overflow-hidden rounded-lg text-white shadow-sm sm:grid-cols-2 lg:grid-cols-4" style={{ backgroundColor: accent }}>
+        {[[`${rate}%`, `${title} 진척률`], [format(done), `${title} 완료`], [format(total), '전체 대상'], [format(remaining), '잔여 건수']].map(([value, label]) => (
+          <div key={label} className="border-b border-white/20 px-5 py-4 sm:border-r lg:border-b-0">
+            <strong className="block text-2xl font-bold tabular-nums">{value}</strong>
+            <span className="text-[11px] font-medium text-white/85">{label}</span>
+          </div>
+        ))}
+      </section>
+
+      <section className="mt-3 grid gap-3 lg:grid-cols-[1.2fr_1fr_1fr]">
+        <article className="flex items-center gap-5 rounded-lg border border-gray-200 bg-white p-5 shadow-sm">
+          <ProgressRing done={done} total={total} color={accent} />
+          <div><p className="text-xs font-medium text-gray-500">전체 진행률</p><p className="mt-1 text-lg font-bold">{rate}%</p><p className="mt-2 text-xs text-gray-500">잔여 {format(remaining)}건</p></div>
+        </article>
+        <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">{orgsWithWork}</p><p className="mt-2 text-xs text-gray-500">{title} 자료가 등록된 기관</p></article>
+        <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>
+
+      <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>
+          <div className="flex gap-2">
+            <label className="sr-only" htmlFor={`${mode}-search`}>기관명 검색</label>
+            <input id={`${mode}-search`} value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="기관명 검색…" className="h-9 w-44 rounded-md border border-gray-300 px-3 text-xs focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100" />
+            <label className="sr-only" htmlFor={`${mode}-status`}>진행 상태</label>
+            <select id={`${mode}-status`} value={status} onChange={(e) => setStatus(e.target.value)} className="h-9 rounded-md border border-gray-300 bg-white px-2 text-xs focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"><option value="all">상태 전체</option><option value="complete">완료</option><option value="progress">진행 중</option><option value="waiting">대기</option></select>
+          </div>
+        </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>
+            <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>
+                </tr>) })}
+              {!rows.length && <tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500">조건에 맞는 기관이 없습니다.</td></tr>}
+            </tbody>
+          </table>
+        </div>
+      </section>
+    </div>
+  )
+}
frontend/src/components/Sidebar.test.tsx
--- frontend/src/components/Sidebar.test.tsx
+++ frontend/src/components/Sidebar.test.tsx
@@ -40,7 +40,7 @@
     expect(onSelect).toHaveBeenCalledWith('channels')
   })
 
-  // 자료실은 요구사항 [26]으로 열렸다. 아직 준비 중인 메뉴는 권리확인·권리처리·Mattermost다.
+  // 권리확인·권리처리는 통합 현황 화면으로 열렸다. 아직 준비 중인 메뉴는 Mattermost다.
   it('미구현 메뉴는 비활성으로 표시되고 링크가 아니다', () => {
     render(<Sidebar />)
 
@@ -50,6 +50,17 @@
     expect(screen.queryByRole('link', { name: 'Mattermost' })).toBeNull()
   })
 
+  it('권리관리 메뉴는 구현되어 링크로 열린다', () => {
+    const onSelect = vi.fn()
+    render(<Sidebar onSelect={onSelect} />)
+
+    fireEvent.click(screen.getByRole('link', { name: '권리확인' }))
+    fireEvent.click(screen.getByRole('link', { name: '권리처리' }))
+
+    expect(onSelect).toHaveBeenNthCalledWith(1, 'rights-check')
+    expect(onSelect).toHaveBeenNthCalledWith(2, 'rights-process')
+  })
+
   it('자료실은 구현되어 링크로 열린다', () => {
     const onSelect = vi.fn()
     render(<Sidebar onSelect={onSelect} />)
frontend/src/components/Sidebar.tsx
--- frontend/src/components/Sidebar.tsx
+++ frontend/src/components/Sidebar.tsx
@@ -68,7 +68,7 @@
   {
     key: 'rights-check',
     label: '권리확인',
-    enabled: false,
+    enabled: true,
     icon: (
       <svg className={ICON_CLASS} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
         <path d="M12 3l8 3v6c0 4.5-3.2 7.7-8 9-4.8-1.3-8-4.5-8-9V6l8-3z" />
@@ -79,7 +79,7 @@
   {
     key: 'rights-process',
     label: '권리처리',
-    enabled: false,
+    enabled: true,
     icon: (
       <svg className={ICON_CLASS} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
         <path d="M7 3h7l5 5v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z" />
Add a comment
List