feat(ui): Phase C — 대시보드 위젯 그리드 + dashboardSummary 공유 fetch

- dashboardSummary store 구독으로 SystemStatusDot과 fetch 1회 공유 (60초 폴링)
- Svelte 5 runes + Card/EmptyState/Skeleton/FormatIcon 프리미티브
- 12-col 그리드 (sm 1열 / md 2열 / lg 표 그대로):
  * 행1: stat 4장 (전체/Inbox/법령/시스템 상태)
  * 행2: 파이프라인 가로 막대 차트(8) + 오늘 도메인 누적바(4)
  * 행3: 최근 문서(8) + CalDAV stub(4)
- 신규 util: domainSlug.ts — ai_domain → bg-domain-{slug} + 라벨 매핑
- 새 코드에 bg-[var(--*)] 0건 (lint:tokens 통과)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-08 12:13:36 +09:00
parent 0c63c0b6ab
commit b3124928a6
2 changed files with 448 additions and 74 deletions

View File

@@ -0,0 +1,66 @@
// ai_domain 값을 @theme의 bg-domain-{slug} 토큰 슬러그로 매핑.
// 백엔드는 'Knowledge/Philosophy', 'Knowledge/Industrial_Safety', 'Reference', null
// 형태를 모두 보낼 수 있으므로 leaf만 잘라 lowercase하고 industrial_safety→safety로 재매핑.
//
// Phase C: 대시보드 today_by_domain 누적바, recent_documents accent에서 사용.
// 추후 phase에서도 도메인 색상 칩이 필요해지면 여기로 일원화.
export type DomainSlug =
| 'philosophy'
| 'language'
| 'engineering'
| 'safety'
| 'programming'
| 'general'
| 'reference';
const LEAF_TO_SLUG: Record<string, DomainSlug> = {
philosophy: 'philosophy',
language: 'language',
engineering: 'engineering',
industrial_safety: 'safety',
safety: 'safety',
programming: 'programming',
general: 'general',
reference: 'reference',
};
/** 'Knowledge/Philosophy' → 'philosophy', 'Reference' → 'reference', null → null */
export function domainSlug(domain: string | null | undefined): DomainSlug | null {
if (!domain) return null;
const leaf = domain.includes('/') ? domain.split('/').pop()! : domain;
return LEAF_TO_SLUG[leaf.toLowerCase()] ?? null;
}
const SLUG_BG_CLASS: Record<DomainSlug, string> = {
philosophy: 'bg-domain-philosophy',
language: 'bg-domain-language',
engineering: 'bg-domain-engineering',
safety: 'bg-domain-safety',
programming: 'bg-domain-programming',
general: 'bg-domain-general',
reference: 'bg-domain-reference',
};
/** bg-domain-{slug} 클래스. 미지정/미매핑은 bg-default. */
export function domainBgClass(domain: string | null | undefined): string {
const slug = domainSlug(domain);
return slug ? SLUG_BG_CLASS[slug] : 'bg-default';
}
const SLUG_LABEL: Record<DomainSlug, string> = {
philosophy: 'Philosophy',
language: 'Language',
engineering: 'Engineering',
safety: 'Industrial Safety',
programming: 'Programming',
general: 'General',
reference: 'Reference',
};
/** 표시용 라벨. domain이 null이면 '미분류'. */
export function domainLabel(domain: string | null | undefined): string {
const slug = domainSlug(domain);
if (!slug) return domain ?? '미분류';
return SLUG_LABEL[slug];
}

View File

@@ -1,88 +1,396 @@
<script>
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
<script lang="ts">
// 대시보드 — Phase C 재작성.
// - dashboardSummary store(system.ts)를 구독해 SystemStatusDot과 fetch 1회 공유.
// - 5대 위젯: stat 카드 4 / 파이프라인 차트 / 도메인 분포 / 최근 문서 / CalDAV stub.
// - grid-cols-12 (lg+), md 2열, sm 1열.
let dashboard = null;
let loading = true;
import {
dashboardSummary,
type DashboardSummary,
type DomainCount,
type PipelineStatus,
} from '$lib/stores/system';
import { domainBgClass, domainLabel } from '$lib/utils/domainSlug';
import Card from '$lib/components/ui/Card.svelte';
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';
onMount(async () => {
try {
dashboard = await api('/dashboard/');
} catch (err) {
addToast('error', '대시보드 로딩 실패');
} finally {
loading = false;
// ─── 파생 값들 ───────────────────────────────────────────────
// legacy store + runes 혼합: 템플릿에서 $dashboardSummary로 자동 구독.
let summary = $derived<DashboardSummary | null>($dashboardSummary);
let loading = $derived(summary === null);
// ── 파이프라인 차트 row 빌드 ──
// SystemStatusDot과 동일 규칙으로 stage별 pending/processing/failed 합산.
const STAGE_ORDER = ['extract', 'classify', 'embed', 'preview'] as const;
const STAGE_LABEL: Record<string, string> = {
extract: '추출',
classify: '분류',
embed: '임베딩',
preview: '미리보기',
};
interface PipelineRow {
stage: string;
label: string;
pending: number;
processing: number;
failed: number;
total: number;
}
function buildPipelineRows(items: PipelineStatus[]): PipelineRow[] {
const grouped = new Map<
string,
{ pending: number; processing: number; failed: number }
>();
for (const it of items) {
const cur = grouped.get(it.stage) ?? { pending: 0, processing: 0, failed: 0 };
if (it.status === 'pending') cur.pending += it.count;
else if (it.status === 'processing') cur.processing += it.count;
else if (it.status === 'failed') cur.failed += it.count;
grouped.set(it.stage, cur);
}
});
const orderedStages = [
...STAGE_ORDER.filter((s) => grouped.has(s)),
...[...grouped.keys()].filter((s) => !STAGE_ORDER.includes(s as never)),
];
return orderedStages.map((stage) => {
const r = grouped.get(stage)!;
return {
stage,
label: STAGE_LABEL[stage] ?? stage,
...r,
total: r.pending + r.processing + r.failed,
};
});
}
let pipelineRows = $derived<PipelineRow[]>(
summary ? buildPipelineRows(summary.pipeline_status) : []
);
// 모든 막대가 같은 스케일을 공유하도록 row 최대 total을 분모로.
let pipelineMax = $derived(
Math.max(1, ...pipelineRows.map((r) => r.total))
);
// ── 도메인 분포 row ──
interface DomainRow extends DomainCount {
pct: number;
bgClass: string;
label: string;
}
function buildDomainRows(items: DomainCount[]): {
rows: DomainRow[];
total: number;
} {
const total = items.reduce((s, i) => s + i.count, 0);
const rows = items
.filter((i) => i.count > 0)
.map((i) => ({
...i,
pct: total > 0 ? (i.count / total) * 100 : 0,
bgClass: domainBgClass(i.domain),
label: domainLabel(i.domain),
}))
// 큰 것부터
.sort((a, b) => b.count - a.count);
return { rows, total };
}
let domainData = $derived(
summary
? buildDomainRows(summary.today_by_domain)
: { rows: [], total: 0 }
);
// ── 시스템 상태 카드 표시값 ──
function pickSystemTone(s: DashboardSummary): {
label: string;
sub: string;
tone: 'success' | 'warning' | 'error';
} {
if (s.failed_count > 0) {
return { label: `${s.failed_count} 실패`, sub: '확인 필요', tone: 'error' };
}
const backlog = s.pipeline_status.some(
(p) => p.status === 'pending' && p.count > 10
);
if (backlog) {
return { label: '대기열 적체', sub: 'pending > 10', tone: 'warning' };
}
return { label: '정상', sub: '실패 없음', tone: 'success' };
}
const TONE_TEXT: Record<'success' | 'warning' | 'error', string> = {
success: 'text-success',
warning: 'text-warning',
error: 'text-error',
};
let systemView = $derived(summary ? pickSystemTone(summary) : null);
</script>
<div class="p-4 lg:p-6">
<div class="max-w-6xl mx-auto">
<div class="max-w-7xl mx-auto">
<h2 class="text-xl font-bold mb-6">대시보드</h2>
{#if loading}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- 로딩 — stat 카드 4개 + 큰 위젯 자리 4개 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-4">
{#each Array(4) as _}
<div class="bg-[var(--surface)] rounded-xl p-5 border border-[var(--border)] animate-pulse h-28"></div>
{/each}
</div>
{:else if dashboard}
<!-- 위젯 그리드 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<!-- 전체 문서 -->
<div class="bg-[var(--surface)] rounded-xl p-5 border border-[var(--border)]">
<p class="text-sm text-[var(--text-dim)]">전체 문서</p>
<p class="text-3xl font-bold mt-1">{dashboard.total_documents}</p>
<p class="text-xs text-[var(--text-dim)] mt-1">오늘 +{dashboard.today_added}</p>
</div>
<!-- Inbox -->
<div class="bg-[var(--surface)] rounded-xl p-5 border border-[var(--border)]">
<p class="text-sm text-[var(--text-dim)]">Inbox 미분류</p>
<p class="text-3xl font-bold mt-1" class:text-[var(--warning)]={dashboard.inbox_count > 0}>{dashboard.inbox_count}</p>
{#if dashboard.inbox_count > 0}
<a href="/inbox" class="text-xs text-[var(--accent)] hover:underline mt-1 inline-block">분류하기</a>
{/if}
</div>
<!-- 법령 알림 -->
<div class="bg-[var(--surface)] rounded-xl p-5 border border-[var(--border)]">
<p class="text-sm text-[var(--text-dim)]">법령 알림</p>
<p class="text-3xl font-bold mt-1">{dashboard.law_alerts}</p>
<p class="text-xs text-[var(--text-dim)] mt-1">오늘 변경</p>
</div>
<!-- 파이프라인 -->
<div class="bg-[var(--surface)] rounded-xl p-5 border border-[var(--border)]">
<p class="text-sm text-[var(--text-dim)]">파이프라인</p>
{#if dashboard.failed_count > 0}
<p class="text-3xl font-bold mt-1 text-[var(--error)]">{dashboard.failed_count} 실패</p>
{:else}
<p class="text-3xl font-bold mt-1 text-[var(--success)]">정상</p>
{/if}
</div>
</div>
<!-- 최근 문서 -->
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-5">
<h3 class="text-sm font-semibold text-[var(--text-dim)] mb-3">최근 문서</h3>
{#if dashboard.recent_documents.length > 0}
<div class="space-y-2">
{#each dashboard.recent_documents as doc}
<a href="/documents/{doc.id}" class="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-[var(--bg)] transition-colors">
<div class="flex items-center gap-3">
<span class="text-xs px-2 py-0.5 rounded bg-[var(--border)] text-[var(--text-dim)] uppercase">{doc.file_format}</span>
<span class="text-sm truncate max-w-md">{doc.title || '제목 없음'}</span>
</div>
<span class="text-xs text-[var(--text-dim)]">{doc.ai_domain || ''}</span>
</a>
{/each}
<div class="lg:col-span-3">
<Card>
<Skeleton w="w-20" h="h-3" />
<Skeleton w="w-16" h="h-8" class="mt-3" />
<Skeleton w="w-24" h="h-3" class="mt-2" />
</Card>
</div>
{:else}
<p class="text-sm text-[var(--text-dim)]">문서가 없습니다</p>
{/if}
{/each}
<div class="lg:col-span-8">
<Card>
<Skeleton w="w-32" h="h-4" />
<Skeleton w="w-full" h="h-24" class="mt-4" />
</Card>
</div>
<div class="lg:col-span-4">
<Card>
<Skeleton w="w-32" h="h-4" />
<Skeleton w="w-full" h="h-24" class="mt-4" />
</Card>
</div>
<div class="lg:col-span-8">
<Card>
<Skeleton w="w-32" h="h-4" />
<Skeleton w="w-full" h="h-32" class="mt-4" />
</Card>
</div>
<div class="lg:col-span-4">
<Card>
<Skeleton w="w-32" h="h-4" />
<Skeleton w="w-full" h="h-32" class="mt-4" />
</Card>
</div>
</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>
<div class="flex items-start justify-between">
<p class="text-sm text-dim">전체 문서</p>
<FileText size={18} class="text-faint" />
</div>
<p class="text-3xl font-bold mt-2 text-text">
{summary.total_documents.toLocaleString()}
</p>
<p class="text-xs text-dim mt-1">
오늘 +{summary.today_added}
</p>
</Card>
</div>
<div class="lg:col-span-3">
<Card>
<div class="flex items-start justify-between">
<p class="text-sm text-dim">Inbox 미분류</p>
<Inbox size={18} class="text-faint" />
</div>
<p
class="text-3xl font-bold mt-2 {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>
<div class="lg:col-span-3">
<Card>
<div class="flex items-start justify-between">
<p class="text-sm text-dim">법령 알림</p>
<Scale size={18} 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>
</Card>
</div>
<div class="lg:col-span-3">
<Card>
<div class="flex items-start justify-between">
<p class="text-sm text-dim">시스템 상태</p>
<Activity size={18} class="text-faint" />
</div>
{#if systemView}
<p class="text-3xl font-bold mt-2 {TONE_TEXT[systemView.tone]}">
{systemView.label}
</p>
<p class="text-xs text-dim mt-1">{systemView.sub}</p>
{/if}
</Card>
</div>
<!-- ─── 행 2: 파이프라인 + 도메인 분포 ─── -->
<div class="lg:col-span-8">
<Card>
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-text">파이프라인 상태</h3>
<span class="text-xs text-dim">최근 24시간</span>
</div>
{#if pipelineRows.length > 0}
<div class="space-y-3">
{#each pipelineRows as row (row.stage)}
<div>
<div class="flex items-center justify-between text-xs mb-1.5">
<span class="text-dim">{row.label}</span>
<span class="text-dim tabular-nums">
대기 <span class="text-text">{row.pending}</span> ·
처리 <span class="text-text">{row.processing}</span> ·
실패 <span class:text-error={row.failed > 0}>{row.failed}</span>
</span>
</div>
<div
class="flex h-2 w-full overflow-hidden rounded-sm bg-bg"
>
{#if row.pending > 0}
<div
class="bg-warning h-full"
style="width: {(row.pending / pipelineMax) * 100}%"
></div>
{/if}
{#if row.processing > 0}
<div
class="bg-accent h-full"
style="width: {(row.processing / pipelineMax) * 100}%"
></div>
{/if}
{#if row.failed > 0}
<div
class="bg-error h-full"
style="width: {(row.failed / pipelineMax) * 100}%"
></div>
{/if}
</div>
</div>
{/each}
</div>
{:else}
<EmptyState
icon={Activity}
title="처리 작업 없음"
description="최근 24시간 내 파이프라인 큐가 비어 있습니다."
/>
{/if}
</Card>
</div>
<div class="lg:col-span-4">
<Card>
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-text">오늘 도메인 분포</h3>
<span class="text-xs text-dim tabular-nums">
{domainData.total}
</span>
</div>
{#if domainData.rows.length > 0}
<!-- 누적 가로 바 -->
<div
class="flex h-2 w-full overflow-hidden rounded-sm bg-bg mb-3"
>
{#each domainData.rows as r (r.label)}
<div
class="{r.bgClass} h-full"
style="width: {r.pct}%"
title="{r.label} {r.count}건"
></div>
{/each}
</div>
<ul class="space-y-1.5">
{#each domainData.rows as r (r.label)}
<li class="flex items-center justify-between text-xs">
<span class="flex items-center gap-2">
<span class="h-2 w-2 rounded-full {r.bgClass}"></span>
<span class="text-text">{r.label}</span>
</span>
<span class="text-dim tabular-nums">{r.count}</span>
</li>
{/each}
</ul>
{:else}
<EmptyState
title="오늘 추가된 문서 없음"
description="새로 분류된 문서가 도메인별로 여기에 표시됩니다."
/>
{/if}
</Card>
</div>
<!-- ─── 행 3: 최근 문서 + CalDAV stub ─── -->
<div class="lg:col-span-8">
<Card>
<h3 class="text-sm font-semibold text-text mb-3">최근 문서</h3>
{#if summary.recent_documents.length > 0}
<ul class="space-y-1">
{#each summary.recent_documents as doc (doc.id)}
<li>
<a
href="/documents/{doc.id}"
class="flex items-center justify-between gap-3 py-2 px-2 -mx-2 rounded-md hover:bg-surface-hover transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
>
<span class="flex items-center gap-3 min-w-0">
<span
class="h-1.5 w-1.5 rounded-full shrink-0 {domainBgClass(
doc.ai_domain
)}"
aria-hidden="true"
></span>
<span class="text-faint shrink-0">
<FormatIcon format={doc.file_format} size={16} />
</span>
<span class="text-sm text-text truncate">
{doc.title || '제목 없음'}
</span>
</span>
<span class="text-xs text-dim shrink-0">
{domainLabel(doc.ai_domain)}
</span>
</a>
</li>
{/each}
</ul>
{:else}
<EmptyState
icon={FileText}
title="아직 문서가 없습니다"
description="NAS PKM 폴더에 파일을 추가하면 자동으로 인덱싱됩니다."
/>
{/if}
</Card>
</div>
<div class="lg:col-span-4">
<Card>
<h3 class="text-sm font-semibold text-text mb-3">오늘의 할 일</h3>
<EmptyState
icon={Calendar}
title="Synology Calendar 연동 예정"
description="CalDAV 연동은 추후 추가됩니다."
/>
</Card>
</div>
</div>
{/if}
</div>