refactor(dashboard): 상황판 재설계 — 사용자 지시서 기반 구현
대시보드를 통계판에서 상황판으로 전환: - 헤더 + 시스템 상태 인라인 (비클릭) - 핀 메모 최상단 조건부 (컴팩트 띠, 최대 3개) - 카드 4개 (문서함/메모/뉴스/승인대기) 모바일 2×2 - 최근 활동 전체 너비 7건, 2줄 스캔형 + 법령 배지 - 파이프라인 details 접힘 (실패 시 자동 open) - 제거: 도메인 분포, 법령/시스템 별도 카드, 8:4 분할 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -86,11 +86,11 @@ async def get_dashboard(
|
||||
)
|
||||
law_alerts = law_result.scalar() or 0
|
||||
|
||||
# 최근 문서 5건
|
||||
# 최근 문서 7건
|
||||
recent_result = await session.execute(
|
||||
select(Document)
|
||||
.order_by(Document.created_at.desc())
|
||||
.limit(5)
|
||||
.limit(7)
|
||||
)
|
||||
recent_docs = recent_result.scalars().all()
|
||||
|
||||
|
||||
+214
-221
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
// 대시보드 — 원래 8:4 2열 레이아웃 복원 + 개선된 카드/정보 위계.
|
||||
// 대시보드 — 상황판. 사용자 지시서 기반 재설계.
|
||||
// 정보 위계: 헤더 → 핀 메모 → 카드 4개 → 최근 활동 → 파이프라인.
|
||||
// 단일 흐름 레이아웃, 모바일 우선, 행동 유도는 승인 대기에만.
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
dashboardSummary,
|
||||
@@ -11,8 +13,7 @@
|
||||
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 { Inbox, Scale, FileText, Activity, StickyNote, Newspaper, Pin } from 'lucide-svelte';
|
||||
import { Inbox, Scale, FileText, Activity, StickyNote, Newspaper, Pin, ChevronRight } from 'lucide-svelte';
|
||||
|
||||
let summary = $derived<DashboardSummary | null>($dashboardSummary);
|
||||
let loading = $derived(summary === null);
|
||||
@@ -58,28 +59,22 @@
|
||||
|
||||
let pipelineRows = $derived(summary ? buildPipelineRows(summary.pipeline_status) : []);
|
||||
let pipelineMax = $derived(Math.max(1, ...pipelineRows.map((r) => r.total)));
|
||||
let totalFailed = $derived(summary?.failed_count ?? 0);
|
||||
let totalPending = $derived(pipelineRows.reduce((s, r) => s + r.pending, 0));
|
||||
|
||||
// ─── 도메인 분포 ───
|
||||
import type { DomainCount } from '$lib/stores/system';
|
||||
interface DomainRow extends DomainCount { pct: number; bgClass: string; label: string; }
|
||||
function buildDomainRows(items: DomainCount[]) {
|
||||
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 });
|
||||
// 파이프라인 접힘 상태
|
||||
let pipelineManualClosed = $state(false);
|
||||
let pipelineOpen = $derived(pipelineManualClosed ? false : totalFailed > 0);
|
||||
|
||||
// ─── 시스템 상태 ───
|
||||
function pickSystemTone(s: DashboardSummary) {
|
||||
if (s.failed_count > 0) return { label: `${s.failed_count} 실패`, sub: '확인 필요', tone: 'error' as const };
|
||||
if (s.failed_count > 0) return { label: `실패 ${s.failed_count}`, tone: 'error' as const };
|
||||
const backlog = s.pipeline_status.some((p) => p.status === 'pending' && p.count > 10);
|
||||
if (backlog) return { label: '대기열 적체', sub: 'pending > 10', tone: 'warning' as const };
|
||||
return { label: '정상', sub: '실패 없음', tone: 'success' as const };
|
||||
if (backlog) return { label: '대기열 적체', tone: 'warning' as const };
|
||||
return { label: '정상', tone: 'success' as const };
|
||||
}
|
||||
const TONE_TEXT: Record<string, string> = { success: 'text-success', warning: 'text-warning', error: 'text-error' };
|
||||
const TONE_DOT: Record<string, string> = { success: 'bg-success', warning: 'bg-warning', error: 'bg-error' };
|
||||
const TONE_TEXT: Record<string, string> = { success: 'text-success', warning: 'text-warning', error: 'text-error' };
|
||||
let systemView = $derived(summary ? pickSystemTone(summary) : null);
|
||||
|
||||
function formatTime(dateStr: string) {
|
||||
@@ -94,221 +89,219 @@
|
||||
</script>
|
||||
|
||||
<div class="p-4 lg:p-6">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<h2 class="text-xl font-bold mb-6">대시보드</h2>
|
||||
<div class="max-w-4xl mx-auto">
|
||||
|
||||
<!-- ═══ 1. 헤더 + 시스템 상태 ═══ -->
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<h2 class="text-xl font-bold text-text">대시보드</h2>
|
||||
{#if systemView}
|
||||
<span class="text-xs text-dim flex items-center gap-1.5 select-none">
|
||||
{systemView.label}
|
||||
<span class="w-2 h-2 rounded-full {TONE_DOT[systemView.tone]}"></span>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-4">
|
||||
<!-- 스켈레톤 -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-5">
|
||||
{#each Array(4) as _}
|
||||
<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>
|
||||
<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>
|
||||
{/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>
|
||||
<Card class="mb-4"><Skeleton w="w-24" h="h-4" /><Skeleton w="w-full" h="h-40" class="mt-3" /></Card>
|
||||
<Card><Skeleton w="w-24" h="h-4" /><Skeleton w="w-full" h="h-20" class="mt-3" /></Card>
|
||||
|
||||
{:else if summary}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-4">
|
||||
|
||||
<!-- ─── 행 0: 핀 메모 (조건부) ─── -->
|
||||
{#if pinnedMemos.length > 0}
|
||||
<div class="col-span-full">
|
||||
<div class="space-y-1.5 mb-1">
|
||||
{#each pinnedMemos as memo (memo.id)}
|
||||
<a href="/memos" class="flex items-center gap-2 px-3 py-2 bg-surface border border-default/50 rounded-lg hover:bg-surface-hover transition-colors text-sm">
|
||||
<Pin size={12} class="text-accent shrink-0" />
|
||||
<span class="text-text truncate">{memo.content?.split('\n')[0] || '메모'}</span>
|
||||
</a>
|
||||
<!-- ═══ 2. 핀 고정 메모 (조건부) ═══ -->
|
||||
{#if pinnedMemos.length > 0}
|
||||
<div class="mb-5 space-y-1.5">
|
||||
{#each pinnedMemos as memo (memo.id)}
|
||||
<a
|
||||
href="/memos"
|
||||
class="flex items-center gap-2.5 px-3 py-2 bg-surface border border-default/50 rounded-lg
|
||||
hover:bg-surface-hover transition-colors text-sm group"
|
||||
>
|
||||
<Pin size={13} class="text-accent shrink-0" />
|
||||
<span class="text-text truncate group-hover:text-accent transition-colors">
|
||||
{memo.content?.split('\n')[0] || '메모'}
|
||||
</span>
|
||||
</a>
|
||||
{/each}
|
||||
{#if pinnedMemos.length >= 3}
|
||||
<a href="/memos" class="text-[11px] text-accent hover:underline pl-8">더보기 →</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ═══ 3. 핵심 카드 4개 ═══ -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-5">
|
||||
<!-- 문서함 -->
|
||||
<a href="/documents" class="block">
|
||||
<Card interactive class="h-full">
|
||||
<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.documents_count ?? 0).toLocaleString()}</p>
|
||||
<p class="text-xs text-dim mt-1">
|
||||
{#if summary.today_added > 0}
|
||||
<span class="text-accent">+{summary.today_added} 오늘</span>
|
||||
{:else}
|
||||
일반 문서
|
||||
{/if}
|
||||
</p>
|
||||
</Card>
|
||||
</a>
|
||||
|
||||
<!-- 메모 -->
|
||||
<a href="/memos" class="block">
|
||||
<Card interactive class="h-full">
|
||||
<div class="flex items-start justify-between">
|
||||
<p class="text-sm text-dim">메모</p>
|
||||
<StickyNote size={18} class="text-faint" />
|
||||
</div>
|
||||
<p class="text-3xl font-bold mt-2 text-text">{(summary.memos_count ?? 0).toLocaleString()}</p>
|
||||
<p class="text-xs text-dim mt-1">직접 작성</p>
|
||||
</Card>
|
||||
</a>
|
||||
|
||||
<!-- 뉴스 -->
|
||||
<a href="/news" class="block">
|
||||
<Card interactive class="h-full">
|
||||
<div class="flex items-start justify-between">
|
||||
<p class="text-sm text-dim">뉴스</p>
|
||||
<Newspaper size={18} class="text-faint" />
|
||||
</div>
|
||||
<p class="text-3xl font-bold mt-2 text-text">{(summary.news_count ?? 0).toLocaleString()}</p>
|
||||
<p class="text-xs text-dim mt-1">수집 기사</p>
|
||||
</Card>
|
||||
</a>
|
||||
|
||||
<!-- 승인 대기 (액션형) -->
|
||||
<a href="/inbox" class="block">
|
||||
<Card interactive class="h-full">
|
||||
<div class="flex items-start justify-between">
|
||||
<p class="text-sm text-dim">승인 대기</p>
|
||||
<Inbox size={18} class="text-faint" />
|
||||
</div>
|
||||
<p class="text-3xl font-bold mt-2 {summary.inbox_count > 0 ? 'text-warning' : 'text-success'}">
|
||||
{summary.inbox_count}
|
||||
</p>
|
||||
{#if summary.inbox_count > 0}
|
||||
<p class="text-xs text-accent mt-1">검토하기 →</p>
|
||||
{:else}
|
||||
<p class="text-xs text-dim mt-1">미분류 없음</p>
|
||||
{/if}
|
||||
</Card>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 4. 최근 활동 ═══ -->
|
||||
<Card class="mb-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-semibold text-text">최근 활동</h3>
|
||||
<div class="flex items-center gap-3">
|
||||
{#if summary.law_alerts > 0}
|
||||
<a
|
||||
href="/documents?source=law_monitor"
|
||||
class="text-[11px] flex items-center gap-1 px-2.5 py-1 rounded-full
|
||||
bg-warning/10 text-warning border border-warning/20
|
||||
hover:bg-warning/20 transition-colors"
|
||||
>
|
||||
<Scale size={11} /> 법령 {summary.law_alerts}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if summary.recent_documents.length > 0}
|
||||
<div class="divide-y divide-default/50">
|
||||
{#each summary.recent_documents as doc (doc.id)}
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
class="block py-2.5 first:pt-0 last:pb-0 hover:bg-surface-hover -mx-5 px-5 transition-colors"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-sm text-text truncate">{doc.title || '제목 없음'}</span>
|
||||
<span class="text-[11px] text-dim shrink-0">{formatTime(doc.created_at)}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 mt-1">
|
||||
<span class="w-1.5 h-1.5 rounded-full shrink-0 {domainBgClass(doc.ai_domain)}"></span>
|
||||
<span class="text-[11px] text-dim truncate">{domainLabel(doc.ai_domain)}</span>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="아직 문서가 없습니다"
|
||||
description="NAS PKM 폴더에 파일을 추가하면 자동으로 인덱싱됩니다."
|
||||
/>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<!-- ═══ 5. 파이프라인 (접힘) ═══ -->
|
||||
<details
|
||||
open={pipelineOpen}
|
||||
ontoggle={(e) => { if (!e.currentTarget.open) pipelineManualClosed = true; }}
|
||||
>
|
||||
<summary
|
||||
class="flex items-center justify-between px-5 py-3.5 bg-surface border border-default rounded-lg
|
||||
cursor-pointer hover:bg-surface-hover transition-colors select-none list-none"
|
||||
>
|
||||
<span class="text-sm font-semibold text-text flex items-center gap-2">
|
||||
<ChevronRight size={14} class="transition-transform details-chevron" />
|
||||
파이프라인
|
||||
</span>
|
||||
<span class="text-xs text-dim flex items-center gap-2.5">
|
||||
{#if totalFailed > 0}
|
||||
<span class="text-error font-medium">실패 {totalFailed}</span>
|
||||
{/if}
|
||||
{#if totalPending > 0}
|
||||
<span>대기 {totalPending}</span>
|
||||
{/if}
|
||||
{#if totalFailed === 0 && totalPending === 0}
|
||||
<span>처리 완료</span>
|
||||
{/if}
|
||||
</span>
|
||||
</summary>
|
||||
|
||||
<div class="mt-2 px-5 py-4 bg-surface border border-default rounded-lg">
|
||||
<p class="text-xs text-dim mb-3">최근 24시간</p>
|
||||
{#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={row.failed > 0 ? 'text-error font-medium' : ''}>{row.failed}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex h-1.5 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>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ─── 행 1: stat 카드 4개 ─── -->
|
||||
<div class="lg:col-span-3">
|
||||
<a href="/documents" class="block h-full">
|
||||
<Card interactive class="h-full">
|
||||
<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.documents_count ?? 0).toLocaleString()}</p>
|
||||
<p class="text-xs text-dim mt-1">
|
||||
{#if summary.today_added > 0}
|
||||
<span class="text-accent">+{summary.today_added} 오늘</span>
|
||||
{:else}
|
||||
일반 문서
|
||||
{/if}
|
||||
</p>
|
||||
</Card>
|
||||
</a>
|
||||
{:else}
|
||||
<p class="text-xs text-dim text-center py-3">처리 작업 없음</p>
|
||||
{/if}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="lg:col-span-3">
|
||||
<a href="/memos" class="block h-full">
|
||||
<Card interactive class="h-full">
|
||||
<div class="flex items-start justify-between">
|
||||
<p class="text-sm text-dim">메모</p>
|
||||
<StickyNote size={18} class="text-faint" />
|
||||
</div>
|
||||
<p class="text-3xl font-bold mt-2 text-text">{(summary.memos_count ?? 0).toLocaleString()}</p>
|
||||
<p class="text-xs text-dim mt-1">직접 작성</p>
|
||||
</Card>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-3">
|
||||
<a href="/news" class="block h-full">
|
||||
<Card interactive class="h-full">
|
||||
<div class="flex items-start justify-between">
|
||||
<p class="text-sm text-dim">뉴스</p>
|
||||
<Newspaper size={18} class="text-faint" />
|
||||
</div>
|
||||
<p class="text-3xl font-bold mt-2 text-text">{(summary.news_count ?? 0).toLocaleString()}</p>
|
||||
<p class="text-xs text-dim mt-1">수집 기사</p>
|
||||
</Card>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-3">
|
||||
<a href="/inbox" class="block h-full">
|
||||
<Card interactive class="h-full">
|
||||
<div class="flex items-start justify-between">
|
||||
<p class="text-sm text-dim">승인 대기</p>
|
||||
<Inbox size={18} class="text-faint" />
|
||||
</div>
|
||||
<p class="text-3xl font-bold mt-2 {summary.inbox_count > 0 ? 'text-warning' : 'text-success'}">{summary.inbox_count}</p>
|
||||
{#if summary.inbox_count > 0}
|
||||
<p class="text-xs text-accent mt-1">검토하기 →</p>
|
||||
{:else}
|
||||
<p class="text-xs text-dim mt-1">미분류 없음</p>
|
||||
{/if}
|
||||
</Card>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- ─── 행 2: 파이프라인 (8) + 도메인 분포 (4) ─── -->
|
||||
<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>
|
||||
<div class="flex items-center gap-3">
|
||||
{#if systemView}
|
||||
<span class="text-xs flex items-center gap-1.5 {TONE_TEXT[systemView.tone]}">
|
||||
{systemView.label}
|
||||
<span class="w-2 h-2 rounded-full {TONE_DOT[systemView.tone]}"></span>
|
||||
</span>
|
||||
{/if}
|
||||
<span class="text-xs text-dim">최근 24시간</span>
|
||||
</div>
|
||||
</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={row.failed > 0 ? 'text-error' : ''}>{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: 최근 문서 (8) + 법령/요약 (4) ─── -->
|
||||
<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-0.5">
|
||||
{#each summary.recent_documents as doc (doc.id)}
|
||||
<li>
|
||||
<a href="/documents/{doc.id}" class="block px-2 py-2 -mx-2 rounded-md hover:bg-surface-hover transition-colors">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<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)}"></span>
|
||||
<FormatIcon format={doc.file_format} size={16} class="text-faint shrink-0" />
|
||||
<span class="text-sm text-text truncate">{doc.title || '제목 없음'}</span>
|
||||
</span>
|
||||
<span class="text-xs text-dim shrink-0">{formatTime(doc.created_at)}</span>
|
||||
</div>
|
||||
<p class="text-[11px] text-dim ml-[30px] mt-0.5 truncate">{domainLabel(doc.ai_domain)}</p>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<EmptyState icon={FileText} title="아직 문서가 없습니다" description="NAS PKM 폴더에 파일을 추가하면 자동으로 인덱싱됩니다." />
|
||||
{/if}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-4 space-y-4">
|
||||
<!-- 법령 알림 -->
|
||||
<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>
|
||||
{#if summary.law_alerts > 0}
|
||||
<a href="/documents?source=law_monitor" class="text-xs text-accent hover:underline mt-1 inline-block">확인하기 →</a>
|
||||
{:else}
|
||||
<p class="text-xs text-dim mt-1">오늘 변경 없음</p>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<!-- 시스템 요약 -->
|
||||
<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>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
details[open] .details-chevron { transform: rotate(90deg); }
|
||||
details summary::-webkit-details-marker { display: none; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user