feat(dashboard): 카운트 분리 — 문서함/메모/뉴스/승인대기

전체 문서 1개 카드를 6개로 분리: 문서함, 메모, 뉴스, 승인대기,
법령알림, 시스템. 단일 FILTER 쿼리로 효율적 카운트.
각 카드 클릭 시 해당 페이지로 이동.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-14 09:19:00 +09:00
parent 087cbdc900
commit fa0175058a
3 changed files with 75 additions and 43 deletions
+24 -3
View File
@@ -44,6 +44,10 @@ class DashboardResponse(BaseModel):
pipeline_status: list[PipelineStatus]
failed_count: int
total_documents: int
# 카운트 분리: 문서함(비-note/비-news) / 메모(memo+note) / 뉴스(news)
documents_count: int = 0
memos_count: int = 0
news_count: int = 0
@router.get("/", response_model=DashboardResponse)
@@ -108,9 +112,23 @@ async def get_dashboard(
)
failed_count = failed_result.scalar() or 0
# 전체 문서 수
total_result = await session.execute(select(func.count(Document.id)))
total_documents = total_result.scalar() or 0
# 전체 문서 수 + 카테고리별 분리 (단일 쿼리)
# 문서함: 비-note, 비-news / 메모: memo+note / 뉴스: news 유입 경로 기준
count_result = await session.execute(
text("""
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE source_channel != 'news' AND file_type != 'note') AS documents,
COUNT(*) FILTER (WHERE source_channel = 'memo' AND file_type = 'note') AS memos,
COUNT(*) FILTER (WHERE source_channel = 'news') AS news
FROM documents WHERE deleted_at IS NULL
""")
)
counts = count_result.one()
total_documents = counts[0]
documents_count = counts[1]
memos_count = counts[2]
news_count = counts[3]
return DashboardResponse(
today_added=today_added,
@@ -135,4 +153,7 @@ async def get_dashboard(
],
failed_count=failed_count,
total_documents=total_documents,
documents_count=documents_count,
memos_count=memos_count,
news_count=news_count,
)
+3
View File
@@ -35,6 +35,9 @@ export interface DashboardSummary {
pipeline_status: PipelineStatus[];
failed_count: number;
total_documents: number;
documents_count: number;
memos_count: number;
news_count: number;
}
const POLL_INTERVAL_MS = 60_000;
+48 -40
View File
@@ -15,7 +15,7 @@
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
import FormatIcon from '$lib/components/FormatIcon.svelte';
import { Calendar, Inbox, Scale, FileText, Activity } from 'lucide-svelte';
import { Calendar, Inbox, Scale, FileText, Activity, StickyNote, Newspaper } from 'lucide-svelte';
// ─── 파생 값들 ───────────────────────────────────────────────
// legacy store + runes 혼합: 템플릿에서 $dashboardSummary로 자동 구독.
@@ -175,70 +175,78 @@
</div>
{:else if summary}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-4">
<!-- ─── 행 1: stat 4 ─── -->
<div class="lg:col-span-3">
<Card>
<!-- ─── 행 1: 카테고리별 카운트 (6칸) ─── -->
<a href="/documents" class="lg:col-span-2 block">
<Card interactive>
<div class="flex items-start justify-between">
<p class="text-sm text-dim">전체 문서</p>
<FileText size={18} class="text-faint" />
<p class="text-sm text-dim">문서</p>
<FileText size={16} class="text-faint" />
</div>
<p class="text-3xl font-bold mt-2 text-text">
{summary.total_documents.toLocaleString()}
<p class="text-2xl font-bold mt-1 text-text">
{(summary.documents_count ?? 0).toLocaleString()}
</p>
<p class="text-xs text-dim mt-1">
오늘 +{summary.today_added}
<p class="text-xs text-dim mt-0.5">오늘 +{summary.today_added}</p>
</Card>
</a>
<a href="/memos" class="lg:col-span-2 block">
<Card interactive>
<div class="flex items-start justify-between">
<p class="text-sm text-dim">메모</p>
<StickyNote size={16} class="text-faint" />
</div>
<p class="text-2xl font-bold mt-1 text-text">
{(summary.memos_count ?? 0).toLocaleString()}
</p>
</Card>
</div>
</a>
<div class="lg:col-span-3">
<Card>
<a href="/news" class="lg:col-span-2 block">
<Card interactive>
<div class="flex items-start justify-between">
<p class="text-sm text-dim">Inbox 미분류</p>
<Inbox size={18} class="text-faint" />
<p class="text-sm text-dim">뉴스</p>
<Newspaper size={16} class="text-faint" />
</div>
<p
class="text-3xl font-bold mt-2 {summary.inbox_count > 0
? 'text-warning'
: 'text-text'}"
>
<p class="text-2xl font-bold mt-1 text-text">
{(summary.news_count ?? 0).toLocaleString()}
</p>
</Card>
</a>
<a href="/inbox" class="lg:col-span-2 block">
<Card interactive>
<div class="flex items-start justify-between">
<p class="text-sm text-dim">승인 대기</p>
<Inbox size={16} class="text-faint" />
</div>
<p class="text-2xl font-bold mt-1 {summary.inbox_count > 0 ? 'text-warning' : 'text-text'}">
{summary.inbox_count}
</p>
{#if summary.inbox_count > 0}
<a
href="/inbox"
class="text-xs text-accent hover:underline mt-1 inline-block"
>
분류하기 →
</a>
{:else}
<p class="text-xs text-dim mt-1">대기 없음</p>
{/if}
</Card>
</div>
</a>
<div class="lg:col-span-3">
<div class="lg:col-span-2">
<Card>
<div class="flex items-start justify-between">
<p class="text-sm text-dim">법령 알림</p>
<Scale size={18} class="text-faint" />
<Scale size={16} class="text-faint" />
</div>
<p class="text-3xl font-bold mt-2 text-text">{summary.law_alerts}</p>
<p class="text-xs text-dim mt-1">오늘 변경</p>
<p class="text-2xl font-bold mt-1 text-text">{summary.law_alerts}</p>
<p class="text-xs text-dim mt-0.5">오늘 변경</p>
</Card>
</div>
<div class="lg:col-span-3">
<div class="lg:col-span-2">
<Card>
<div class="flex items-start justify-between">
<p class="text-sm text-dim">시스템 상태</p>
<Activity size={18} class="text-faint" />
<p class="text-sm text-dim">시스템</p>
<Activity size={16} class="text-faint" />
</div>
{#if systemView}
<p class="text-3xl font-bold mt-2 {TONE_TEXT[systemView.tone]}">
<p class="text-2xl font-bold mt-1 {TONE_TEXT[systemView.tone]}">
{systemView.label}
</p>
<p class="text-xs text-dim mt-1">{systemView.sub}</p>
<p class="text-xs text-dim mt-0.5">{systemView.sub}</p>
{/if}
</Card>
</div>