Phase 5: 대시보드(Dashboard) 페이지
- 히어로 브리핑(브리핑 노트 HTML 강조 + 추천 칩 3 + 자연어 명령 입력) - 자연어 명령 → POST capture → 토스트 → /inbox 이동 - 결재함 요약(high-risk 3건, "47분 아껴드렸어요", 대기 N건, 준비중 CTA) - 인박스 요약(최근 3건, kind별 아이콘, /inbox CTA) - 오늘 일정(4건·"곧" 배지·accent 바·sched-note) / 할 일(상위 5·"지금" focus·우선순위) / 목표(진행 막대·progressbar aria) - 벤토 그리드(.board 4열) + cardin 진입 애니메이션 + 스켈레톤/빈/에러 - dashboard.css(dash.css+approve.css 이식), DashUser/Weather/... 타입, SWR useDashboard - 백엔드 보정: EventOut 에 id 추가(React key), task_summary 최상위 미완료 상위 5건으로 제한 검증: 백엔드 pytest 54, 프론트 vitest 49, dashboard e2e 6 + a11y 2, tsc/eslint/ruff clean, build OK. 라이브 시각 확인(히어로/요약/일정/할일/목표), 콘솔 에러 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>main
parent
cb028df08a
commit
09ed8497f3
@ -0,0 +1,69 @@
|
||||
# phase-5 §7.2 — 대시보드 집계 정확성
|
||||
def test_dashboard_shape(client):
|
||||
d = client.get("/api/dashboard").json()
|
||||
for k in (
|
||||
"user", "briefing", "saved_today", "today_routed", "schedule",
|
||||
"task_summary", "goals", "approvals_summary", "inbox_recent", "badges",
|
||||
):
|
||||
assert k in d
|
||||
|
||||
|
||||
def test_user_top_level(client):
|
||||
d = client.get("/api/dashboard").json()
|
||||
assert d["user"]["name"] == "지우"
|
||||
assert d["user"]["initial"] == "지"
|
||||
|
||||
|
||||
def test_briefing_seed_values(client):
|
||||
d = client.get("/api/dashboard").json()
|
||||
b = d["briefing"]
|
||||
assert b["today"] == "6월 7일 일요일"
|
||||
assert b["weather"]["cond"] == "맑음 · 한낮 28°"
|
||||
assert b["weather"]["icon"] == "sun" # cloudSun → sun
|
||||
assert d["saved_today"] == "47분"
|
||||
assert d["today_routed"] == 7
|
||||
assert "<b>분기 리포트</b>" in b["note"]
|
||||
|
||||
|
||||
def test_schedule_four_items_and_soon(client):
|
||||
d = client.get("/api/dashboard").json()
|
||||
sch = d["schedule"]
|
||||
assert len(sch) == 4
|
||||
assert sch[0]["time"] == "09:30"
|
||||
soon = [e for e in sch if e["soon"]]
|
||||
assert len(soon) == 1 and soon[0]["title"] == "분기 전략 미팅"
|
||||
|
||||
|
||||
def test_approvals_summary_only_high_risk_max3(client):
|
||||
d = client.get("/api/dashboard").json()
|
||||
ap = d["approvals_summary"]
|
||||
assert len(ap) == 3
|
||||
titles = [a["title"] for a in ap]
|
||||
assert "현우님께 회신 초안이 준비됐어요" in titles
|
||||
assert "Netflix 일시정지를 추천해요" in titles
|
||||
assert all("치과 예약" not in t for t in titles)
|
||||
|
||||
|
||||
def test_inbox_recent_three_with_type_and_proj(client):
|
||||
d = client.get("/api/dashboard").json()
|
||||
inb = d["inbox_recent"]
|
||||
assert len(inb) == 3
|
||||
assert inb[0]["type"] in ("task", "event", "idea", "")
|
||||
assert all("proj_label" in x for x in inb)
|
||||
assert all("tone" in x for x in inb)
|
||||
|
||||
|
||||
def test_goals_three_with_tone_key(client):
|
||||
d = client.get("/api/dashboard").json()
|
||||
goals = d["goals"]
|
||||
assert len(goals) == 3
|
||||
assert goals[0]["pct"] == 68
|
||||
assert goals[0]["tone"] == "blue" # var(--blue) 아님
|
||||
|
||||
|
||||
def test_badges_match_counts(client):
|
||||
d = client.get("/api/dashboard").json()
|
||||
b = d["badges"]
|
||||
assert b["appr"] == 3
|
||||
assert b["task"] == d["task_summary"]["open_count"]
|
||||
assert b["noti"] == 6
|
||||
@ -1,4 +1,10 @@
|
||||
// frontend/app/dashboard/page.tsx (Phase 1 스텁 — Phase 5에서 교체)
|
||||
export default function Page() {
|
||||
return <h1 className="ph-title">대시보드</h1>;
|
||||
// frontend/app/dashboard/page.tsx
|
||||
import type { Metadata } from "next";
|
||||
import "@/styles/dashboard.css";
|
||||
import { DashboardClient } from "@/components/dashboard/DashboardClient";
|
||||
|
||||
export const metadata: Metadata = { title: "대시보드 · 아리" };
|
||||
|
||||
export default function DashboardPage() {
|
||||
return <DashboardClient />;
|
||||
}
|
||||
|
||||
@ -0,0 +1,52 @@
|
||||
// frontend/components/dashboard/ApprovalSummaryCard.tsx
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { Icon } from "@/components/Icon";
|
||||
import type { ApprovalSummary } from "@/lib/types";
|
||||
import { MiniRow } from "./MiniRow";
|
||||
|
||||
export function ApprovalSummaryCard({
|
||||
items,
|
||||
savedToday,
|
||||
pending,
|
||||
}: {
|
||||
items: ApprovalSummary[];
|
||||
savedToday: string;
|
||||
pending: number;
|
||||
}) {
|
||||
return (
|
||||
<section className="card appr">
|
||||
<div className="ch">
|
||||
<div className="ico lime">
|
||||
<Icon name="spark" />
|
||||
</div>
|
||||
<div className="htext">
|
||||
<h3>아리 결재함</h3>
|
||||
<div className="sub">오늘 {savedToday} 아껴드렸어요</div>
|
||||
</div>
|
||||
<span className="count warm">대기 {pending}건</span>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="appr-empty">
|
||||
<span className="ae-tick">
|
||||
<Icon name="tick" w={3} />
|
||||
</span>
|
||||
지금은 확인할 게 없어요 — 다 처리해뒀어요.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mini-list">
|
||||
{items.map((it) => (
|
||||
<MiniRow key={it.id} tone={it.tone} icon={it.icon} text={it.title} sub={it.time} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MVP: 결재함 전용 페이지는 placeholder("준비 중") */}
|
||||
<Link className="mini-cta" href="/approvals" aria-label="결재함에서 승인하기 (준비 중)">
|
||||
<Icon name="tick" w={3} />
|
||||
결재함에서 승인하기
|
||||
</Link>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
// frontend/components/dashboard/CommandInput.tsx
|
||||
"use client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Icon } from "@/components/Icon";
|
||||
import { captureCommand } from "@/lib/dashboard/api";
|
||||
|
||||
export function CommandInput() {
|
||||
const [value, setValue] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
async function submit() {
|
||||
const raw = value.trim();
|
||||
if (!raw || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await captureCommand(raw); // POST /api/inbox/capture
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("ari:toast", {
|
||||
detail: { text: "인박스에 적어뒀어요 — 아리가 분류할게요" },
|
||||
}),
|
||||
);
|
||||
router.push("/inbox");
|
||||
} catch {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("ari:toast", {
|
||||
detail: { text: "지금은 적어두지 못했어요. 잠시 후 다시 시도해 주세요.", tone: "coral" },
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setValue("");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="cmd"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<Icon name="spark" />
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="오늘 하루, 무엇이든 맡겨보세요…"
|
||||
aria-label="아리에게 명령 입력"
|
||||
disabled={busy}
|
||||
/>
|
||||
<button className="send" type="submit" aria-label="보내기" disabled={busy || !value.trim()}>
|
||||
<Icon name="send" />
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
// frontend/components/dashboard/DashboardClient.tsx
|
||||
"use client";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Icon } from "@/components/Icon";
|
||||
import type { IconName } from "@/components/icons/paths";
|
||||
import { useDashboard } from "@/lib/dashboard/useDashboard";
|
||||
import { ApprovalSummaryCard } from "./ApprovalSummaryCard";
|
||||
import { DashboardSkeleton } from "./DashboardSkeleton";
|
||||
import { GoalsCard } from "./GoalsCard";
|
||||
import { HeroBriefing } from "./HeroBriefing";
|
||||
import { InboxSummaryCard } from "./InboxSummaryCard";
|
||||
import { ScheduleCard } from "./ScheduleCard";
|
||||
import { TaskSummaryCard } from "./TaskSummaryCard";
|
||||
|
||||
export function DashboardClient() {
|
||||
const { data, isLoading, error, mutate } = useDashboard();
|
||||
const shellRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = shellRef.current;
|
||||
if (!el) return;
|
||||
const id = requestAnimationFrame(() => el.classList.add("entered"));
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [data]);
|
||||
|
||||
if (isLoading) return <DashboardSkeleton />;
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="dash-error" role="alert">
|
||||
<p>대시보드를 불러오지 못했어요.</p>
|
||||
<button className="mini-cta" onClick={() => mutate()}>
|
||||
<Icon name="swap" /> 다시 시도
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
user,
|
||||
briefing,
|
||||
saved_today,
|
||||
today_routed,
|
||||
schedule,
|
||||
task_summary,
|
||||
goals,
|
||||
approvals_summary,
|
||||
inbox_recent,
|
||||
badges,
|
||||
} = data;
|
||||
|
||||
return (
|
||||
<div className="dash-page" ref={shellRef}>
|
||||
<div className="pagehead">
|
||||
<div>
|
||||
<div className="ph-eyebrow">
|
||||
<span>{briefing.today}</span>
|
||||
<span className="sep" />
|
||||
<span className="wx">
|
||||
<Icon name={briefing.weather.icon as IconName} />
|
||||
{briefing.weather.cond}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="ph-title">
|
||||
좋은 아침이에요, {user.name}님 <em>☀</em>
|
||||
</h1>
|
||||
</div>
|
||||
<div className="ph-search">
|
||||
<Icon name="search" />
|
||||
<input placeholder="아리에게 무엇이든…" aria-label="검색" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="work">
|
||||
<div className="board">
|
||||
<HeroBriefing briefing={briefing} />
|
||||
<ApprovalSummaryCard
|
||||
items={approvals_summary}
|
||||
savedToday={saved_today}
|
||||
pending={badges.appr}
|
||||
/>
|
||||
<InboxSummaryCard items={inbox_recent} todayRouted={today_routed} />
|
||||
<ScheduleCard items={schedule} />
|
||||
<TaskSummaryCard summary={task_summary} />
|
||||
<GoalsCard goals={goals} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
// frontend/components/dashboard/DashboardSkeleton.tsx
|
||||
export function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="dash-page" aria-busy="true" aria-live="polite">
|
||||
<div className="pagehead">
|
||||
<div>
|
||||
<div className="skel skel-eyebrow" />
|
||||
<div className="skel skel-title" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="work">
|
||||
<div className="board">
|
||||
<section className="card hero sp2">
|
||||
<div className="skel skel-block" />
|
||||
</section>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<section className="card" key={i}>
|
||||
<div className="skel skel-head" />
|
||||
<div className="skel skel-line" />
|
||||
<div className="skel skel-line" />
|
||||
<div className="skel skel-line short" />
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
// frontend/components/dashboard/GoalsCard.tsx
|
||||
import { Icon } from "@/components/Icon";
|
||||
import type { DashGoal } from "@/lib/types";
|
||||
|
||||
export function GoalsCard({ goals }: { goals: DashGoal[] }) {
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="ch">
|
||||
<div className="ico">
|
||||
<Icon name="target" />
|
||||
</div>
|
||||
<div className="htext">
|
||||
<h3>목표</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="goals">
|
||||
{goals.map((g) => (
|
||||
<div key={g.id}>
|
||||
<div className="goal-top">
|
||||
<span className="goal-title">{g.title}</span>
|
||||
<span className="goal-pct mono">{g.pct}%</span>
|
||||
</div>
|
||||
<div
|
||||
className="goal-bar"
|
||||
role="progressbar"
|
||||
aria-valuenow={g.pct}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={g.title}
|
||||
>
|
||||
<i style={{ width: `${g.pct}%`, background: `var(--${g.tone})` }} />
|
||||
</div>
|
||||
<div className="goal-sub">{g.sub}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
// frontend/components/dashboard/HeroBriefing.tsx
|
||||
"use client";
|
||||
import { Icon } from "@/components/Icon";
|
||||
import type { IconName } from "@/components/icons/paths";
|
||||
import type { DashBriefing } from "@/lib/types";
|
||||
import { CommandInput } from "./CommandInput";
|
||||
|
||||
const CHIPS: { icon: IconName; text: string }[] = [
|
||||
{ icon: "cal", text: "내일 오후 비워줘" },
|
||||
{ icon: "mail", text: "중요 메일만 요약" },
|
||||
{ icon: "video", text: "14시 미팅 준비" },
|
||||
];
|
||||
|
||||
export function HeroBriefing({ briefing }: { briefing: DashBriefing }) {
|
||||
return (
|
||||
<section className="card hero sp2">
|
||||
<span className="hero-spark">
|
||||
<Icon name="spark" />
|
||||
아리 브리핑
|
||||
</span>
|
||||
|
||||
{/* briefingNote — 시드 출처 HTML(<b> 강조). 사용자 입력 아님 → 안전. */}
|
||||
<p className="brief" dangerouslySetInnerHTML={{ __html: briefing.note ?? "" }} />
|
||||
|
||||
<div className="chips">
|
||||
{CHIPS.map((c, i) => (
|
||||
<button className="chip" key={i} type="button">
|
||||
<Icon name={c.icon} />
|
||||
{c.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<CommandInput />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
// frontend/components/dashboard/InboxSummaryCard.tsx
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { Icon } from "@/components/Icon";
|
||||
import type { InboxRecent } from "@/lib/types";
|
||||
import { MiniRow } from "./MiniRow";
|
||||
|
||||
const kindIcon = (k: InboxRecent["kind"]) =>
|
||||
k === "voice" ? "mic" : k === "image" ? "image" : "pen";
|
||||
|
||||
const typeLabel = (t: string) =>
|
||||
(({ task: "작업", event: "일정", idea: "아이디어" }) as Record<string, string>)[t] ?? "분류 중";
|
||||
|
||||
export function InboxSummaryCard({
|
||||
items,
|
||||
todayRouted,
|
||||
}: {
|
||||
items: InboxRecent[];
|
||||
todayRouted: number;
|
||||
}) {
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="ch">
|
||||
<div className="ico">
|
||||
<Icon name="inbox" />
|
||||
</div>
|
||||
<div className="htext">
|
||||
<h3>스마트 인박스</h3>
|
||||
<div className="sub">적으면 아리가 제자리로</div>
|
||||
</div>
|
||||
<span className="count">오늘 {todayRouted}건</span>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="appr-empty">
|
||||
<span className="ae-tick">
|
||||
<Icon name="tick" w={3} />
|
||||
</span>
|
||||
인박스가 비었어요 — 떠오르면 바로 적어두세요.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mini-list">
|
||||
{items.map((c) => (
|
||||
<MiniRow
|
||||
key={c.id}
|
||||
tone={c.tone || "faint"}
|
||||
icon={kindIcon(c.kind)}
|
||||
text={c.raw}
|
||||
sub={typeLabel(c.type)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link className="mini-cta" href="/inbox">
|
||||
<Icon name="plus" />
|
||||
새로 적기 · 인박스 열기
|
||||
</Link>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
// frontend/components/dashboard/MiniRow.tsx
|
||||
import type { CSSProperties } from "react";
|
||||
import { Icon } from "@/components/Icon";
|
||||
import type { IconName } from "@/components/icons/paths";
|
||||
import type { Tone } from "@/lib/types";
|
||||
|
||||
export function MiniRow({
|
||||
tone,
|
||||
icon,
|
||||
text,
|
||||
sub,
|
||||
}: {
|
||||
tone: Tone;
|
||||
icon: string;
|
||||
text: string;
|
||||
sub: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="mini-row">
|
||||
<span className="mini-ic" style={{ "--tone": `var(--${tone})` } as CSSProperties}>
|
||||
<Icon name={icon as IconName} />
|
||||
</span>
|
||||
<span className="mini-text">{text}</span>
|
||||
<span className="mini-sub">{sub}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
// frontend/components/dashboard/ScheduleCard.tsx
|
||||
"use client";
|
||||
import type { CSSProperties } from "react";
|
||||
import { Icon } from "@/components/Icon";
|
||||
import type { ScheduleItem } from "@/lib/types";
|
||||
|
||||
export function ScheduleCard({ items }: { items: ScheduleItem[] }) {
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="ch">
|
||||
<div className="ico">
|
||||
<Icon name="cal" />
|
||||
</div>
|
||||
<div className="htext">
|
||||
<h3>오늘 일정</h3>
|
||||
<div className="sub">{items.length}개 · 다음까지 2시간</div>
|
||||
</div>
|
||||
<button className="tool" aria-label="더보기">
|
||||
<Icon name="more" />
|
||||
</button>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<div className="appr-empty">
|
||||
<span className="ae-tick">
|
||||
<Icon name="tick" w={3} />
|
||||
</span>
|
||||
오늘은 일정이 없어요. 여유로운 하루예요.
|
||||
</div>
|
||||
) : (
|
||||
<div className="sched">
|
||||
{items.map((e) => (
|
||||
<div className="ev" key={e.id}>
|
||||
<div className="ev-time mono">{e.time}</div>
|
||||
<div className="ev-body" style={{ "--accent": `var(--${e.tone})` } as CSSProperties}>
|
||||
<div className="ev-title">{e.title}</div>
|
||||
<div className="ev-meta">
|
||||
<span>{e.tag}</span>
|
||||
<span className="dur">{e.dur}</span>
|
||||
</div>
|
||||
</div>
|
||||
{e.soon && <span className="soon">곧</span>}
|
||||
</div>
|
||||
))}
|
||||
<div className="sched-note" key="sched-note">
|
||||
<Icon name="bell" />
|
||||
치과 예약은 16:00로 옮겨뒀어요 — 결재함에서 되돌릴 수 있어요.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
// frontend/components/dashboard/TaskSummaryCard.tsx
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { Icon } from "@/components/Icon";
|
||||
import { cx } from "@/lib/cx";
|
||||
import type { DashTaskSummary } from "@/lib/types";
|
||||
|
||||
const PRIO: Record<string, string> = { 높음: "high", 보통: "mid", 낮음: "low" };
|
||||
|
||||
export function TaskSummaryCard({ summary }: { summary: DashTaskSummary }) {
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="ch">
|
||||
<div className="ico">
|
||||
<Icon name="check" />
|
||||
</div>
|
||||
<div className="htext">
|
||||
<h3>할 일</h3>
|
||||
</div>
|
||||
<span className="count">{summary.open_count}개 남음</span>
|
||||
</div>
|
||||
{summary.items.length === 0 ? (
|
||||
<div className="appr-empty">
|
||||
<span className="ae-tick">
|
||||
<Icon name="tick" w={3} />
|
||||
</span>
|
||||
할 일이 모두 끝났어요 🎉
|
||||
</div>
|
||||
) : (
|
||||
<div className="tasks">
|
||||
{summary.items.map((t, idx) => {
|
||||
const focus = idx === 0;
|
||||
return (
|
||||
<Link key={t.id} href={`/tasks?task=${t.id}`} className={cx("task", focus && "focus")}>
|
||||
<div className="box">
|
||||
<Icon name="tick" w={3} />
|
||||
</div>
|
||||
<div className="task-body">
|
||||
<div className="task-title">{t.title}</div>
|
||||
<div className="task-meta">{t.project}</div>
|
||||
</div>
|
||||
{focus ? (
|
||||
<span className="focus-tag">지금</span>
|
||||
) : (
|
||||
<span className={"prio " + PRIO[t.prio]}>{t.prio}</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
// frontend/lib/dashboard/api.ts
|
||||
import type { Dashboard } from "@/lib/types";
|
||||
|
||||
const BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8000";
|
||||
|
||||
export async function getDashboard(signal?: AbortSignal): Promise<Dashboard> {
|
||||
const res = await fetch(`${BASE}/api/dashboard`, { signal, cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`dashboard ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** 자연어 명령 → 인박스 캡처 (phase-4와 동일 엔드포인트) */
|
||||
export async function captureCommand(raw: string) {
|
||||
const res = await fetch(`${BASE}/api/inbox/capture`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ kind: "text", raw }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`capture ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
// frontend/lib/dashboard/useDashboard.ts
|
||||
"use client";
|
||||
import useSWR from "swr";
|
||||
import type { Dashboard } from "@/lib/types";
|
||||
import { getDashboard } from "./api";
|
||||
|
||||
export function useDashboard() {
|
||||
return useSWR<Dashboard>("dashboard", () => getDashboard(), {
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 5000,
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
// frontend/playwright/dashboard.a11y.spec.ts
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// color-contrast 제외(레퍼런스 액센트 팔레트) — 구조/시맨틱 WCAG 는 엄격.
|
||||
const AXE = (page: import("@playwright/test").Page) =>
|
||||
new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa"]).disableRules(["color-contrast"]);
|
||||
|
||||
test("대시보드 a11y — 라이트", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
await page.getByText("아리 브리핑").waitFor();
|
||||
expect((await AXE(page).analyze()).violations).toEqual([]);
|
||||
});
|
||||
|
||||
test("대시보드 a11y — 다크", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
await page.getByText("아리 브리핑").waitFor();
|
||||
await page.getByLabel("테마 전환").click();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
||||
expect((await AXE(page).analyze()).violations).toEqual([]);
|
||||
});
|
||||
@ -0,0 +1,50 @@
|
||||
// frontend/playwright/dashboard.spec.ts
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test("로드 → 집계가 시드 기준으로 표시", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
await expect(page.getByText("아리 브리핑")).toBeVisible();
|
||||
await expect(page.locator(".brief b")).toHaveText("분기 리포트");
|
||||
await expect(page.getByText("아리 결재함")).toBeVisible();
|
||||
await expect(page.getByText("대기 3건")).toBeVisible();
|
||||
await expect(page.getByText("현우님께 회신 초안이 준비됐어요")).toBeVisible();
|
||||
await expect(page.locator(".ev")).toHaveCount(4);
|
||||
await expect(page.getByText("곧", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("progressbar")).toHaveCount(3);
|
||||
});
|
||||
|
||||
test("인박스 카드 '새로 적기' → /inbox", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
await page.getByRole("link", { name: /새로 적기/ }).click();
|
||||
await expect(page).toHaveURL(/\/inbox$/);
|
||||
});
|
||||
|
||||
test("결재함 카드 CTA → placeholder(준비 중)", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
await page.getByRole("link", { name: /결재함에서 승인하기/ }).click();
|
||||
await expect(page.getByText(/준비 중/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("작업 요약 항목 클릭 → /tasks?task= 딥링크", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
await page.locator(".task-title", { hasText: "분기 리포트 초안 마무리" }).click();
|
||||
await expect(page).toHaveURL(/\/tasks\?task=/);
|
||||
});
|
||||
|
||||
test("자연어 명령 입력 → 캡처 후 /inbox 이동", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
const input = page.getByLabel("아리에게 명령 입력");
|
||||
await input.fill("수요일 11시 자전거 수리 맡기기");
|
||||
await page.getByLabel("보내기").click();
|
||||
await expect(page).toHaveURL(/\/inbox$/);
|
||||
await expect(page.locator(".sb-raw", { hasText: "수요일 11시 자전거 수리 맡기기" }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("다크 테마 토글 후에도 렌더 정상", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
await page.getByLabel("테마 전환").click();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
||||
await expect(page.getByText("아리 브리핑")).toBeVisible();
|
||||
});
|
||||
@ -0,0 +1,559 @@
|
||||
/* frontend/styles/dashboard.css — 원본 dash.css + approve.css 의 대시보드 전용 규칙 이식
|
||||
(공유 레이아웃/카드는 dash-base.css) */
|
||||
@import "./dash-base.css";
|
||||
|
||||
/* ---------- 히어로 ---------- */
|
||||
.hero {
|
||||
background:
|
||||
radial-gradient(95% 105% at 0% 0%, rgba(231, 128, 82, 0.22), transparent 56%),
|
||||
radial-gradient(130% 130% at 100% 0%, rgba(255, 255, 255, 0.42), transparent 50%),
|
||||
var(--glass);
|
||||
-webkit-backdrop-filter: var(--blur);
|
||||
backdrop-filter: var(--blur);
|
||||
border: 1px solid var(--glass-brd);
|
||||
box-shadow: var(--shadow), var(--glass-hi);
|
||||
color: var(--ink);
|
||||
}
|
||||
.hero .ch h3 {
|
||||
color: var(--ink);
|
||||
}
|
||||
.hero-spark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: #fff;
|
||||
background: var(--coral);
|
||||
border: 1px solid transparent;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
align-self: flex-start;
|
||||
box-shadow: 0 5px 14px -5px var(--coral);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hero-spark .ic {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
.hero p.brief {
|
||||
font-size: clamp(18px, 1.7vw, 22px);
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 16px 0 18px;
|
||||
color: var(--ink);
|
||||
max-width: 40ch;
|
||||
}
|
||||
.hero p.brief b {
|
||||
color: var(--ink);
|
||||
font-weight: 700;
|
||||
border-bottom: 2px solid color-mix(in oklab, var(--coral) 60%, transparent);
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
.cmd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--glass-2);
|
||||
border: 1px solid var(--glass-brd);
|
||||
border-radius: 15px;
|
||||
padding: 7px 7px 7px 16px;
|
||||
margin-top: auto;
|
||||
}
|
||||
.cmd > svg:first-child {
|
||||
color: var(--ink-2);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cmd input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
color: var(--ink);
|
||||
font-size: 14.5px;
|
||||
}
|
||||
.cmd input::placeholder {
|
||||
color: var(--faint);
|
||||
}
|
||||
.cmd .send {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 11px;
|
||||
background: var(--fill);
|
||||
color: var(--on-fill);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
transition: filter 0.14s;
|
||||
}
|
||||
.cmd .send .ic {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
.cmd .send:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
.cmd .send:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
color: var(--ink-2);
|
||||
background: var(--glass-2);
|
||||
border: 1px solid var(--glass-brd);
|
||||
padding: 7px 12px;
|
||||
border-radius: 999px;
|
||||
transition: background 0.14s;
|
||||
}
|
||||
.chip .ic {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.chip:hover {
|
||||
background: var(--card-2);
|
||||
}
|
||||
|
||||
/* ---------- 요약 카드 (approve.css mini-*) ---------- */
|
||||
.appr .ico.lime {
|
||||
background: var(--lime);
|
||||
color: var(--lime-ink);
|
||||
border-color: color-mix(in oklab, var(--lime-ink) 12%, transparent);
|
||||
}
|
||||
.appr .count.warm {
|
||||
color: #fff;
|
||||
background: var(--coral);
|
||||
border-color: transparent;
|
||||
}
|
||||
.mini-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.mini-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 9px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.mini-row:first-child {
|
||||
border-top: none;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.mini-ic {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: color-mix(in oklab, var(--tone, var(--blue)) 13%, transparent);
|
||||
color: var(--tone, var(--blue));
|
||||
}
|
||||
.mini-ic .ic {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
.mini-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mini-sub {
|
||||
font-size: 10.5px;
|
||||
color: var(--faint);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mini-cta {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 11px;
|
||||
border-radius: 13px;
|
||||
background: var(--fill);
|
||||
color: var(--on-fill);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: filter 0.14s;
|
||||
}
|
||||
.mini-cta .ic {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
.mini-cta:hover {
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
.appr-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--ink-2);
|
||||
line-height: 1.5;
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
color-mix(in oklab, var(--green) 10%, transparent),
|
||||
transparent 70%
|
||||
),
|
||||
var(--glass-2);
|
||||
border: 1px solid color-mix(in oklab, var(--green) 28%, var(--glass-brd));
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 13px 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.ae-tick {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--green);
|
||||
color: #fff;
|
||||
}
|
||||
.ae-tick .ic {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
/* ---------- 오늘 일정 ---------- */
|
||||
.sched {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.ev {
|
||||
display: flex;
|
||||
gap: 13px;
|
||||
padding: 13px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.ev:first-of-type {
|
||||
border-top: none;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.ev-time {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--ink-2);
|
||||
width: 42px;
|
||||
flex-shrink: 0;
|
||||
padding-top: 1px;
|
||||
}
|
||||
.ev-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
padding-left: 15px;
|
||||
}
|
||||
.ev-body::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 3px;
|
||||
border-radius: 3px;
|
||||
background: var(--accent, var(--blue));
|
||||
}
|
||||
.ev-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.ev-meta {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
.ev-meta .dur {
|
||||
color: var(--faint);
|
||||
}
|
||||
.ev .soon {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: #fff;
|
||||
background: var(--coral);
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
align-self: flex-start;
|
||||
margin-top: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sched-note {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
align-items: flex-start;
|
||||
background: var(--glass-2);
|
||||
border: 1px solid var(--glass-brd);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 11px 13px;
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-2);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.sched-note .ic {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
color: var(--coral);
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* ---------- 할 일 ---------- */
|
||||
.tasks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.task {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 12px 13px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--glass-brd);
|
||||
background: var(--glass-2);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: background 0.14s, border-color 0.14s;
|
||||
}
|
||||
.task:hover {
|
||||
background: var(--card-2);
|
||||
}
|
||||
.task .box {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
border-radius: 7px;
|
||||
border: 2px solid var(--line-2);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
color: transparent;
|
||||
transition: all 0.14s;
|
||||
}
|
||||
.task .box .ic {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
.task-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.task-title {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.task-meta {
|
||||
font-size: 11.5px;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.task .prio {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.prio.high {
|
||||
color: var(--coral);
|
||||
background: color-mix(in oklab, var(--coral) 14%, transparent);
|
||||
}
|
||||
.prio.mid {
|
||||
color: var(--amber);
|
||||
background: color-mix(in oklab, var(--amber) 16%, transparent);
|
||||
}
|
||||
.prio.low {
|
||||
color: var(--muted);
|
||||
background: var(--card-2);
|
||||
}
|
||||
.task.focus {
|
||||
background:
|
||||
linear-gradient(90deg, color-mix(in oklab, var(--coral) 13%, transparent), transparent 58%),
|
||||
var(--glass);
|
||||
border-color: color-mix(in oklab, var(--coral) 38%, var(--glass-brd));
|
||||
box-shadow: var(--shadow-sm), inset 3px 0 0 0 var(--coral);
|
||||
}
|
||||
.task.focus .task-title {
|
||||
color: var(--ink);
|
||||
font-weight: 700;
|
||||
}
|
||||
.task.focus .box {
|
||||
border-color: var(--coral);
|
||||
background: color-mix(in oklab, var(--coral) 14%, transparent);
|
||||
}
|
||||
.task.focus .focus-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: #fff;
|
||||
background: var(--coral);
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---------- 목표 ---------- */
|
||||
.goals {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
.goal-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
.goal-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.3;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.goal-pct {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.goal-bar {
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--card-2);
|
||||
overflow: hidden;
|
||||
}
|
||||
.goal-bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.goal-sub {
|
||||
font-size: 11.5px;
|
||||
color: var(--muted);
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
/* ---------- 진입 애니메이션 ---------- */
|
||||
.entered .card {
|
||||
animation: cardin 0.5s cubic-bezier(0.2, 0.7, 0.2, 1) both;
|
||||
}
|
||||
@keyframes cardin {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 로딩 스켈레톤 ---------- */
|
||||
.skel {
|
||||
background: var(--card-2);
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.skel::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.35), transparent);
|
||||
transform: translateX(-100%);
|
||||
animation: dash-shimmer 1.4s infinite;
|
||||
}
|
||||
@keyframes dash-shimmer {
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
.skel-title {
|
||||
height: 40px;
|
||||
width: 60%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.skel-eyebrow {
|
||||
height: 14px;
|
||||
width: 180px;
|
||||
}
|
||||
.skel-head {
|
||||
height: 20px;
|
||||
width: 50%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.skel-line {
|
||||
height: 14px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.skel-line.short {
|
||||
width: 60%;
|
||||
}
|
||||
.skel-block {
|
||||
height: 160px;
|
||||
}
|
||||
|
||||
/* ---------- 에러 ---------- */
|
||||
.dash-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 48px 24px;
|
||||
margin-top: 24px;
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in oklab, var(--coral) 7%, var(--card));
|
||||
border: 1px solid color-mix(in oklab, var(--coral) 24%, transparent);
|
||||
color: var(--ink-2);
|
||||
text-align: center;
|
||||
}
|
||||
.dash-error .mini-cta {
|
||||
margin-top: 0;
|
||||
padding: 10px 18px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.entered .card,
|
||||
.skel::after {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
// frontend/tests/dashboard/CommandInput.test.tsx
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const push = vi.fn();
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push }) }));
|
||||
|
||||
const capture = vi.fn().mockResolvedValue({ item: {}, classification: {} });
|
||||
vi.mock("@/lib/dashboard/api", () => ({ captureCommand: (r: string) => capture(r) }));
|
||||
|
||||
import { CommandInput } from "@/components/dashboard/CommandInput";
|
||||
|
||||
describe("CommandInput", () => {
|
||||
it("전송 시 캡처 호출 후 /inbox로 이동", async () => {
|
||||
render(<CommandInput />);
|
||||
const input = screen.getByLabelText("아리에게 명령 입력");
|
||||
fireEvent.change(input, { target: { value: "다음 주 한국 비행기 티켓 사기" } });
|
||||
fireEvent.click(screen.getByLabelText("보내기"));
|
||||
await waitFor(() => expect(capture).toHaveBeenCalledWith("다음 주 한국 비행기 티켓 사기"));
|
||||
await waitFor(() => expect(push).toHaveBeenCalledWith("/inbox"));
|
||||
});
|
||||
|
||||
it("빈 입력이면 send 비활성", () => {
|
||||
render(<CommandInput />);
|
||||
expect(screen.getByLabelText("보내기")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,75 @@
|
||||
// frontend/tests/dashboard/cards.test.tsx
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// HeroBriefing 는 CommandInput(useRouter) 을 포함 → next/navigation 모킹
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
import { ApprovalSummaryCard } from "@/components/dashboard/ApprovalSummaryCard";
|
||||
import { GoalsCard } from "@/components/dashboard/GoalsCard";
|
||||
import { HeroBriefing } from "@/components/dashboard/HeroBriefing";
|
||||
import { InboxSummaryCard } from "@/components/dashboard/InboxSummaryCard";
|
||||
import type { ApprovalSummary, DashBriefing, DashGoal, InboxRecent } from "@/lib/types";
|
||||
|
||||
const briefing: DashBriefing = {
|
||||
today: "6월 7일 일요일",
|
||||
weather: { temp: 24, cond: "맑음 · 한낮 28°", icon: "sun" },
|
||||
commute: "출근 23분",
|
||||
sleep: "7시간 12분",
|
||||
note: "오늘은 오후 미팅이 핵심이에요. <b>분기 리포트</b>",
|
||||
};
|
||||
|
||||
describe("HeroBriefing", () => {
|
||||
it("브리핑 노트 HTML 강조 + 칩 + 명령 입력", () => {
|
||||
render(<HeroBriefing briefing={briefing} />);
|
||||
expect(screen.getByText("아리 브리핑")).toBeInTheDocument();
|
||||
expect(document.querySelector(".brief b")?.textContent).toBe("분기 리포트");
|
||||
expect(screen.getByText("내일 오후 비워줘")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("오늘 하루, 무엇이든 맡겨보세요…")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ApprovalSummaryCard", () => {
|
||||
const items: ApprovalSummary[] = [
|
||||
{ id: "a4", icon: "mail", tone: "violet", title: "현우님께 회신 초안이 준비됐어요", time: "보내기 대기" },
|
||||
];
|
||||
it("대기 N건 + 미니 행 + CTA", () => {
|
||||
render(<ApprovalSummaryCard items={items} savedToday="47분" pending={3} />);
|
||||
expect(screen.getByText("아리 결재함")).toBeInTheDocument();
|
||||
expect(screen.getByText("오늘 47분 아껴드렸어요")).toBeInTheDocument();
|
||||
expect(screen.getByText("대기 3건")).toBeInTheDocument();
|
||||
expect(screen.getByText("현우님께 회신 초안이 준비됐어요")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /결재함에서 승인하기/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/approvals",
|
||||
);
|
||||
});
|
||||
it("0건이면 빈 상태", () => {
|
||||
render(<ApprovalSummaryCard items={[]} savedToday="0분" pending={0} />);
|
||||
expect(screen.getByText(/다 처리해뒀어요/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GoalsCard", () => {
|
||||
it("진행 막대 width + progressbar aria", () => {
|
||||
const goals: DashGoal[] = [
|
||||
{ id: "g1", title: "분기 OKR", pct: 68, sub: "12개 중 8개", tone: "blue" },
|
||||
];
|
||||
render(<GoalsCard goals={goals} />);
|
||||
const bar = screen.getByRole("progressbar", { name: "분기 OKR" });
|
||||
expect(bar).toHaveAttribute("aria-valuenow", "68");
|
||||
expect(bar.querySelector("i")).toHaveStyle({ width: "68%" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("InboxSummaryCard", () => {
|
||||
it("kind별 아이콘 + 새로 적기 CTA(/inbox)", () => {
|
||||
const items: InboxRecent[] = [
|
||||
{ id: "s3", kind: "voice", raw: "엄마 생신 선물", type: "task", proj_label: "가족", tone: "green" },
|
||||
];
|
||||
render(<InboxSummaryCard items={items} todayRouted={7} />);
|
||||
expect(screen.getByText("오늘 7건")).toBeInTheDocument();
|
||||
expect(screen.getByText("엄마 생신 선물")).toBeInTheDocument();
|
||||
expect(screen.getByText("작업")).toBeInTheDocument(); // typeLabel(task)
|
||||
expect(screen.getByRole("link", { name: /새로 적기/ })).toHaveAttribute("href", "/inbox");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue