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:
66
frontend/src/lib/utils/domainSlug.ts
Normal file
66
frontend/src/lib/utils/domainSlug.ts
Normal 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];
|
||||||
|
}
|
||||||
@@ -1,88 +1,396 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
// 대시보드 — Phase C 재작성.
|
||||||
import { goto } from '$app/navigation';
|
// - dashboardSummary store(system.ts)를 구독해 SystemStatusDot과 fetch 1회 공유.
|
||||||
import { api } from '$lib/api';
|
// - 5대 위젯: stat 카드 4 / 파이프라인 차트 / 도메인 분포 / 최근 문서 / CalDAV stub.
|
||||||
import { addToast } from '$lib/stores/toast';
|
// - grid-cols-12 (lg+), md 2열, sm 1열.
|
||||||
|
|
||||||
let dashboard = null;
|
import {
|
||||||
let loading = true;
|
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 {
|
// legacy store + runes 혼합: 템플릿에서 $dashboardSummary로 자동 구독.
|
||||||
dashboard = await api('/dashboard/');
|
let summary = $derived<DashboardSummary | null>($dashboardSummary);
|
||||||
} catch (err) {
|
let loading = $derived(summary === null);
|
||||||
addToast('error', '대시보드 로딩 실패');
|
|
||||||
} finally {
|
// ── 파이프라인 차트 row 빌드 ──
|
||||||
loading = false;
|
// 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>
|
</script>
|
||||||
|
|
||||||
<div class="p-4 lg:p-6">
|
<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>
|
<h2 class="text-xl font-bold mb-6">대시보드</h2>
|
||||||
|
|
||||||
{#if loading}
|
{#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 _}
|
{#each Array(4) as _}
|
||||||
<div class="bg-[var(--surface)] rounded-xl p-5 border border-[var(--border)] animate-pulse h-28"></div>
|
<div class="lg:col-span-3">
|
||||||
{/each}
|
<Card>
|
||||||
</div>
|
<Skeleton w="w-20" h="h-3" />
|
||||||
{:else if dashboard}
|
<Skeleton w="w-16" h="h-8" class="mt-3" />
|
||||||
<!-- 위젯 그리드 -->
|
<Skeleton w="w-24" h="h-3" class="mt-2" />
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
</Card>
|
||||||
<!-- 전체 문서 -->
|
|
||||||
<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>
|
</div>
|
||||||
{:else}
|
{/each}
|
||||||
<p class="text-sm text-[var(--text-dim)]">문서가 없습니다</p>
|
<div class="lg:col-span-8">
|
||||||
{/if}
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user