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>
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user