refactor(dashboard): UI/UX 재설계 — 정보 위계 + 모바일 최적화
대시보드 전면 재작성: - 핀 메모: 최상단 조건부 컴팩트 띠 (pinned=true API 파라미터 추가) - 4개 핵심 카드: 문서함/메모/뉴스/승인대기 (2×2 모바일, 4열 데스크탑) - 승인 대기: 액션형 카드 (warning + 검토하기 CTA) - 최근 활동: 전체 너비, 2줄 스캔형, 법령 알림 뱃지 - 파이프라인: details 기반 접힘 (실패 시 자동 펼침, 수동 접힘 유지) - 시스템 상태: 헤더 인라인 배지 (비클릭) - CalDAV stub/도메인 분포 위젯 제거 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -173,6 +173,7 @@ async def list_memos(
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
tag: str | None = Query(None, description="user_tags 또는 ai_tags 필터"),
|
||||
archived: bool = Query(False, description="true면 아카이브 목록"),
|
||||
pinned: bool | None = Query(None, description="true면 핀 고정된 메모만"),
|
||||
):
|
||||
"""메모 목록 — 활성: 핀 우선 + 최신순 / 아카이브: 최신순 (핀 무시)"""
|
||||
base = select(Document).where(
|
||||
@@ -182,6 +183,9 @@ async def list_memos(
|
||||
Document.archived == archived,
|
||||
)
|
||||
|
||||
if pinned is not None:
|
||||
base = base.where(Document.pinned == pinned)
|
||||
|
||||
if tag:
|
||||
base = base.where(
|
||||
Document.user_tags.op("@>")(f'["{tag}"]')
|
||||
|
||||
+236
-335
@@ -1,51 +1,43 @@
|
||||
<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열.
|
||||
|
||||
// 대시보드 — 재설계.
|
||||
// 정보 위계: 핀 메모 → 카드 4개 → 최근 활동 → 파이프라인.
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
dashboardSummary,
|
||||
type DashboardSummary,
|
||||
type DomainCount,
|
||||
type PipelineStatus,
|
||||
} from '$lib/stores/system';
|
||||
import { domainBgClass, domainLabel } from '$lib/utils/domainSlug';
|
||||
import { api } from '$lib/api';
|
||||
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, StickyNote, Newspaper } from 'lucide-svelte';
|
||||
import { Inbox, Scale, FileText, Activity, StickyNote, Newspaper, Pin, ChevronRight } from 'lucide-svelte';
|
||||
|
||||
// ─── 파생 값들 ───────────────────────────────────────────────
|
||||
// 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;
|
||||
// ─── 핀 고정 메모 ───
|
||||
let pinnedMemos = $state<any[]>([]);
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await api<any>('/memos/?pinned=true&page_size=3&archived=false');
|
||||
pinnedMemos = res.items || [];
|
||||
} catch { /* 실패 시 빈 배열 유지 */ }
|
||||
});
|
||||
|
||||
// ─── 파이프라인 ───
|
||||
const STAGE_LABEL: Record<string, string> = {
|
||||
extract: '추출',
|
||||
classify: '분류',
|
||||
embed: '임베딩',
|
||||
preview: '미리보기',
|
||||
extract: '추출', classify: '분류', embed: '임베딩', preview: '미리보기',
|
||||
};
|
||||
|
||||
interface PipelineRow {
|
||||
stage: string;
|
||||
label: string;
|
||||
pending: number;
|
||||
processing: number;
|
||||
failed: number;
|
||||
total: number;
|
||||
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 }
|
||||
>();
|
||||
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;
|
||||
@@ -53,337 +45,246 @@
|
||||
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,
|
||||
};
|
||||
});
|
||||
return [...grouped.entries()].map(([stage, r]) => ({
|
||||
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))
|
||||
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));
|
||||
|
||||
// 파이프라인 접힘 (실패 있으면 첫 진입 시 자동 펼침, 사용자 접으면 세션 유지)
|
||||
let pipelineManualClosed = $state(false);
|
||||
let pipelineOpen = $derived(
|
||||
pipelineManualClosed ? false : totalFailed > 0
|
||||
);
|
||||
|
||||
// ── 도메인 분포 row ──
|
||||
interface DomainRow extends DomainCount {
|
||||
pct: number;
|
||||
bgClass: string;
|
||||
label: string;
|
||||
// ─── 시스템 상태 ───
|
||||
function pickSystemTone(s: DashboardSummary) {
|
||||
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: '대기열 적체', tone: 'warning' as const };
|
||||
return { label: '정상', tone: 'success' as const };
|
||||
}
|
||||
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',
|
||||
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) {
|
||||
const d = new Date(dateStr);
|
||||
const diff = Date.now() - d.getTime();
|
||||
if (diff < 60000) return '방금';
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}분 전`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}시간 전`;
|
||||
if (diff < 604800000) return `${Math.floor(diff / 86400000)}일 전`;
|
||||
return d.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
</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-5xl mx-auto">
|
||||
|
||||
<!-- ─── 헤더 + 시스템 상태 ─── -->
|
||||
<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">
|
||||
{systemView.label}
|
||||
<span class="w-2 h-2 rounded-full {TONE_DOT[systemView.tone]}"></span>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<!-- 로딩 — stat 카드 4개 + 큰 위젯 자리 4개 -->
|
||||
<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-3 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-16" h="h-3" /><Skeleton w="w-12" h="h-7" 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 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>
|
||||
<Card><Skeleton w="w-24" h="h-4" /><Skeleton w="w-full" h="h-32" class="mt-3" /></Card>
|
||||
|
||||
{:else if summary}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-4">
|
||||
<!-- ─── 행 1: 카테고리별 카운트 ─── -->
|
||||
<!-- 모바일 3열×2행 / lg 6열×1행, 균등 분할 -->
|
||||
<div class="col-span-full grid grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
<a href="/documents" class="block min-w-0">
|
||||
<Card interactive class="h-full text-center overflow-hidden">
|
||||
<p class="text-[11px] text-dim flex items-center justify-center gap-1"><FileText size={11} /> 문서함</p>
|
||||
<p class="text-xl font-bold mt-1 text-text">{(summary.documents_count ?? 0).toLocaleString()}</p>
|
||||
<p class="text-[10px] mt-0.5 {summary.today_added > 0 ? 'text-accent' : 'text-transparent'}">
|
||||
{summary.today_added > 0 ? `+${summary.today_added} 오늘` : '-'}
|
||||
</p>
|
||||
</Card>
|
||||
</a>
|
||||
|
||||
<a href="/memos" class="block min-w-0">
|
||||
<Card interactive class="h-full text-center overflow-hidden">
|
||||
<p class="text-[11px] text-dim flex items-center justify-center gap-1"><StickyNote size={11} /> 메모</p>
|
||||
<p class="text-xl font-bold mt-1 text-text">{(summary.memos_count ?? 0).toLocaleString()}</p>
|
||||
<p class="text-[10px] text-transparent mt-0.5">-</p>
|
||||
</Card>
|
||||
</a>
|
||||
|
||||
<a href="/news" class="block min-w-0">
|
||||
<Card interactive class="h-full text-center overflow-hidden">
|
||||
<p class="text-[11px] text-dim flex items-center justify-center gap-1"><Newspaper size={11} /> 뉴스</p>
|
||||
<p class="text-xl font-bold mt-1 text-text">{(summary.news_count ?? 0).toLocaleString()}</p>
|
||||
<p class="text-[10px] text-transparent mt-0.5">-</p>
|
||||
</Card>
|
||||
</a>
|
||||
|
||||
<a href="/inbox" class="block min-w-0">
|
||||
<Card interactive class="h-full text-center overflow-hidden">
|
||||
<p class="text-[11px] text-dim flex items-center justify-center gap-1"><Inbox size={11} /> 승인 대기</p>
|
||||
<p class="text-xl font-bold mt-1 {summary.inbox_count > 0 ? 'text-warning' : 'text-text'}">{summary.inbox_count}</p>
|
||||
<p class="text-[10px] mt-0.5 {summary.inbox_count > 0 ? 'text-accent' : 'text-transparent'}">
|
||||
{summary.inbox_count > 0 ? '분류하기 →' : '-'}
|
||||
</p>
|
||||
</Card>
|
||||
</a>
|
||||
|
||||
<div class="min-w-0">
|
||||
<Card class="h-full text-center overflow-hidden">
|
||||
<p class="text-[11px] text-dim flex items-center justify-center gap-1"><Scale size={11} /> 법령 알림</p>
|
||||
<p class="text-xl font-bold mt-1 text-text">{summary.law_alerts}</p>
|
||||
<p class="text-[10px] mt-0.5 {summary.law_alerts > 0 ? 'text-dim' : 'text-transparent'}">
|
||||
{summary.law_alerts > 0 ? '오늘 변경' : '-'}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<Card class="h-full text-center overflow-hidden">
|
||||
<p class="text-[11px] text-dim flex items-center justify-center gap-1"><Activity size={11} /> 시스템</p>
|
||||
{#if systemView}
|
||||
<p class="text-xl font-bold mt-1 {TONE_TEXT[systemView.tone]}">{systemView.label}</p>
|
||||
<p class="text-[10px] text-dim mt-0.5">{systemView.sub}</p>
|
||||
{/if}
|
||||
</Card>
|
||||
</div>
|
||||
<!-- ─── 0. 핀 고정 메모 (조건부) ─── -->
|
||||
{#if pinnedMemos.length > 0}
|
||||
<div class="mb-4 space-y-1.5">
|
||||
{#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>
|
||||
{/each}
|
||||
{#if pinnedMemos.length >= 3}
|
||||
<a href="/memos" class="text-[11px] text-accent hover:underline pl-7">더보기 →</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ─── 행 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>
|
||||
<!-- ─── 1. 핵심 카드 4개 ─── -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-5">
|
||||
<!-- 문서함 -->
|
||||
<a href="/documents" class="block min-w-0">
|
||||
<Card interactive class="h-full">
|
||||
<p class="text-[11px] text-dim flex items-center gap-1"><FileText size={12} /> 문서함</p>
|
||||
<p class="text-2xl font-bold mt-1.5 text-text">{(summary.documents_count ?? 0).toLocaleString()}</p>
|
||||
<p class="text-[10px] text-dim mt-1">일반 문서</p>
|
||||
</Card>
|
||||
</a>
|
||||
|
||||
<!-- 메모 -->
|
||||
<a href="/memos" class="block min-w-0">
|
||||
<Card interactive class="h-full">
|
||||
<p class="text-[11px] text-dim flex items-center gap-1"><StickyNote size={12} /> 메모</p>
|
||||
<p class="text-2xl font-bold mt-1.5 text-text">{(summary.memos_count ?? 0).toLocaleString()}</p>
|
||||
<p class="text-[10px] text-dim mt-1">직접 작성</p>
|
||||
</Card>
|
||||
</a>
|
||||
|
||||
<!-- 뉴스 -->
|
||||
<a href="/news" class="block min-w-0">
|
||||
<Card interactive class="h-full">
|
||||
<p class="text-[11px] text-dim flex items-center gap-1"><Newspaper size={12} /> 뉴스</p>
|
||||
<p class="text-2xl font-bold mt-1.5 text-text">{(summary.news_count ?? 0).toLocaleString()}</p>
|
||||
<p class="text-[10px] text-dim mt-1">수집 기사</p>
|
||||
</Card>
|
||||
</a>
|
||||
|
||||
<!-- 승인 대기 -->
|
||||
<a href="/inbox" class="block min-w-0">
|
||||
<Card interactive class="h-full">
|
||||
<p class="text-[11px] text-dim flex items-center gap-1"><Inbox size={12} /> 승인 대기</p>
|
||||
<p class="text-2xl font-bold mt-1.5 {summary.inbox_count > 0 ? 'text-warning' : 'text-success'}">
|
||||
{summary.inbox_count}
|
||||
</p>
|
||||
{#if summary.inbox_count > 0}
|
||||
<p class="text-[10px] text-accent mt-1">검토하기 →</p>
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={Activity}
|
||||
title="처리 작업 없음"
|
||||
description="최근 24시간 내 파이프라인 큐가 비어 있습니다."
|
||||
/>
|
||||
<p class="text-[10px] text-dim mt-1">미분류 문서</p>
|
||||
{/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>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- ─── 2. 최근 활동 ─── -->
|
||||
<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 py-0.5 rounded-full
|
||||
bg-warning/10 text-warning hover:bg-warning/20 transition-colors"
|
||||
>
|
||||
<Scale size={11} /> 법령 {summary.law_alerts}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if summary.recent_documents.length > 0}
|
||||
<div class="space-y-0.5">
|
||||
{#each summary.recent_documents as doc (doc.id)}
|
||||
<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="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-0.5">
|
||||
<span class="w-1.5 h-1.5 rounded-full shrink-0 {domainBgClass(doc.ai_domain)}"></span>
|
||||
<span class="text-[11px] text-dim truncate max-w-[200px]">{domainLabel(doc.ai_domain)}</span>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="아직 문서가 없습니다"
|
||||
description="NAS PKM 폴더에 파일을 추가하면 자동으로 인덱싱됩니다."
|
||||
/>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<!-- ─── 3. 파이프라인 (접힘) ─── -->
|
||||
<details
|
||||
open={pipelineOpen}
|
||||
ontoggle={(e) => { if (!e.currentTarget.open) pipelineManualClosed = true; }}
|
||||
>
|
||||
<summary class="flex items-center justify-between px-4 py-3 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-open-rotate" />
|
||||
파이프라인
|
||||
</span>
|
||||
<span class="text-[11px] text-dim flex items-center gap-2">
|
||||
{#if totalFailed > 0}
|
||||
<span class="text-error">실패 {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-4 py-3 bg-surface border border-default rounded-lg">
|
||||
{#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">
|
||||
<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-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>
|
||||
{:else}
|
||||
<p class="text-xs text-dim text-center py-2">최근 24시간 처리 작업 없음</p>
|
||||
{/if}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
details[open] .details-open-rotate {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
details summary::-webkit-details-marker { display: none; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user