|
|
# Phase 5 — 대시보드(Dashboard) 페이지
|
|
|
|
|
|
> 한 줄 요약: 아침 브리핑 · 결재함/인박스 요약 카드 · 오늘 일정/작업/목표 요약 · 자연어 명령 입력을 한 화면에 모은 "요약/알림 허브"를 `GET /api/dashboard` 집계 한 번으로 그려내는 읽기 중심 페이지를 완성한다.
|
|
|
>
|
|
|
> 이 문서는 `dev/` 문서 세트의 일부입니다 — 먼저 `overview.md`를 읽으세요. 선행 문서: `phase-0-foundation.md`, `phase-1-design-system.md`, `phase-2-backend.md`, `phase-3-tasks.md`, `phase-4-inbox.md`.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 1. 개요 & 목표
|
|
|
|
|
|
### 1.1 이 phase가 끝나면 무엇이 동작하는가
|
|
|
|
|
|
`/dashboard` 라우트를 열면 다음이 한 화면(벤토 그리드)에 시드 데이터 기준으로 표시된다.
|
|
|
|
|
|
1. **히어로 브리핑** — "좋은 아침이에요, 지우님" + 날씨/출근/수면 한 줄 + `briefingNote`(HTML, `<b>` 강조 포함) + 추천 칩 3개 + **자연어 명령 입력창**.
|
|
|
2. **결재함 요약 카드** — `risk === "high"` 대기 건 목록 3개(미니 리스트) + "오늘 47분 아껴드렸어요" 서브 + "결재함에서 승인하기" CTA(MVP에서는 `/결재함` placeholder로 라우팅, "준비 중").
|
|
|
3. **인박스 요약 카드** — 최근 캡처 3건 미니 리스트 + "오늘 7건" 서브 + "새로 적기 · 인박스 열기" CTA(→ `/inbox`, 실제 동작).
|
|
|
4. **오늘 일정 요약** — `schedule` 4건(시간·제목·태그·길이·"곧" 배지) + 일정 노트.
|
|
|
5. **할 일 요약** — `task_summary`(남은 개수 + 상위 작업 목록, 첫 항목 "지금" 포커스).
|
|
|
6. **목표 요약** — `goals` 3건(제목·퍼센트·진행 막대·서브).
|
|
|
7. **자연어 명령 입력** — 히어로 하단 `.cmd` 입력창. 입력 후 전송 시 인박스 캡처(`POST /api/inbox/capture`) 호출 → 토스트로 "인박스에 적어뒀어요" 안내 후 `/inbox`로 이동(MVP 동작, 5.5 정의).
|
|
|
8. **라이트/다크 테마** — Topbar 토글에 따라 모든 카드/히어로 색이 토큰 기반으로 전환.
|
|
|
9. **로딩 스켈레톤 / 빈 상태 / 부분 실패(위젯별)** 처리.
|
|
|
|
|
|
> MVP 범위 한정(CONTRACT 준수): 원본 `dash-v2.jsx`에는 13개 위젯(미팅 준비·위임 현황·이어서 하기·집중도 차트·메일·건강·지출·오늘의 집중 등)이 있으나, **이번 MVP 대시보드는 백엔드 `GET /api/dashboard`가 집계하는 6개 블록(브리핑/결재함/인박스/일정/작업/목표)만 실데이터로 렌더**한다. 나머지 위젯은 본 phase에서 구현하지 않는다(원본의 픽셀 보캐뷸러리만 5.8 디자인 충실도 절에서 참고로 인용). 결재함/일정 전용 페이지는 placeholder이므로 카드 CTA는 "준비 중"으로 라우팅하고 카드 본문은 읽기 전용 시드만 표시한다.
|
|
|
|
|
|
### 1.2 핵심 제품 원칙 반영
|
|
|
|
|
|
- "적을 때는 분류하지 않는다 — 분류·배치·자동화는 아리가." → 대시보드의 자연어 명령 입력은 사용자가 "그냥 적기"만 하면 인박스로 흘려보내 아리가 분류하도록 한다.
|
|
|
- "할까요?"가 아니라 "이미 해뒀어요." → 결재함 요약은 "치과 예약을 16:00로 옮겼어요"처럼 **이미 실행된 결과**를 보여주고 승인/되돌리기만 받는다. 일정 노트 역시 "치과 예약은 16:00로 옮겨뒀어요 — 결재함에서 되돌릴 수 있어요."로 표현한다.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 2. 선행 조건(의존 phase) / 산출물(Deliverables)
|
|
|
|
|
|
### 2.1 선행 조건
|
|
|
|
|
|
| 의존 | 문서 | 이 phase에서 필요한 것 |
|
|
|
|---|---|---|
|
|
|
| 모노레포/개발환경 | `phase-0-foundation.md` | `frontend`(Next.js App Router)·`backend`(FastAPI) 기동, dev 프록시 |
|
|
|
| 디자인 시스템 | `phase-1-design-system.md` | `tokens.css`, `<Icon>`, `<Topbar current="dash">`, `<GlassCard>`, 테마 토글(next-themes), placeholder 라우팅 |
|
|
|
| 백엔드 | `phase-2-backend.md` | `event`/`approval`/`goal`/`briefing` 시드 테이블, `GET /api/dashboard` 라우터, `task_summary` 집계, `POST /api/inbox/capture` |
|
|
|
| 작업 페이지 | `phase-3-tasks.md` | 작업 타입(`Task`, `status` enum), 작업 요약이 참조하는 task 시드 |
|
|
|
| 인박스 페이지 | `phase-4-inbox.md` | `inbox_item`·`inbox_classification` 시드, `POST /api/inbox/capture` 동작(자연어 명령이 호출) |
|
|
|
|
|
|
> 대시보드는 다른 데이터를 **집계**하므로 빌드 순서상 마지막 페이지다(작업 → 인박스 → 대시보드). `phase-6-integration.md`에서 연합 흐름 E2E로 마무리한다.
|
|
|
|
|
|
### 2.2 산출물(Deliverables)
|
|
|
|
|
|
```
|
|
|
backend/app/
|
|
|
└─ (대시보드 백엔드는 phase-2 소유) # schemas.py(DashboardOut 등)·routers/dashboard.py(GET /api/dashboard 인라인 집계)·models.py(event/approval/goal/briefing)·seed.py(_seed_dashboard)는 phase-2-backend.md §3.14가 단일 소유. 본 phase는 그 응답을 소비만 하며 백엔드를 재정의하지 않는다.
|
|
|
|
|
|
frontend/
|
|
|
├─ app/dashboard/page.tsx # 서버 컴포넌트 진입(메타데이터) → <DashboardClient/>
|
|
|
├─ components/dashboard/
|
|
|
│ ├─ DashboardClient.tsx # 페이지 셸 + 데이터 패칭 + 벤토 그리드
|
|
|
│ ├─ HeroBriefing.tsx # 히어로(브리핑 + 칩 + 명령 입력)
|
|
|
│ ├─ CommandInput.tsx # 자연어 명령 입력창(.cmd)
|
|
|
│ ├─ ApprovalSummaryCard.tsx # 결재함 요약(미니 리스트 + CTA)
|
|
|
│ ├─ InboxSummaryCard.tsx # 인박스 요약(미니 리스트 + CTA)
|
|
|
│ ├─ ScheduleCard.tsx # 오늘 일정(.sched)
|
|
|
│ ├─ TaskSummaryCard.tsx # 할 일(.tasks)
|
|
|
│ ├─ GoalsCard.tsx # 목표(.goals)
|
|
|
│ ├─ MiniRow.tsx # 요약 카드 공용 미니 행(approve.css .mini-row)
|
|
|
│ ├─ DashboardSkeleton.tsx # 로딩 스켈레톤
|
|
|
│ └─ WidgetError.tsx # 위젯별 부분 실패 표시
|
|
|
├─ lib/
|
|
|
│ ├─ types.ts # Dashboard* 타입(schemas.py와 1:1)
|
|
|
│ ├─ api.ts # getDashboard(), captureInbox()
|
|
|
│ └─ hooks/useDashboard.ts # SWR/React Query 훅
|
|
|
├─ styles/dashboard.css # dash.css + approve.css의 대시보드 관련 규칙 이식
|
|
|
└─ tests/ # 컴포넌트/E2E/접근성 테스트
|
|
|
├─ HeroBriefing.test.tsx
|
|
|
├─ ApprovalSummaryCard.test.tsx
|
|
|
├─ GoalsCard.test.tsx
|
|
|
└─ ...
|
|
|
frontend/playwright/dashboard.spec.ts # E2E
|
|
|
backend/tests/test_dashboard.py # API 집계 테스트
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## 3. 상세 구현(파일별, 단계별)
|
|
|
|
|
|
### 3.1 백엔드 — 시드 테이블(phase-2 소유, 동일 형태 참조)
|
|
|
|
|
|
대시보드 읽기전용 테이블(`briefing`/`event`/`approval`/`goal`)은 **`phase-2-backend.md`가 유일하게 정의·소유**한다. 이 phase는 모델을 재정의하지 않고 phase-2의 동일 형태를 참조해 집계·소비만 한다(프론트 컴포넌트는 `GET /api/dashboard` 정본 응답을 소비). 아래는 참조 편의를 위한 phase-2 정의 발췌이며, 필드명은 CONTRACT 고정값과 원본 데이터 파일을 그대로 따른다.
|
|
|
|
|
|
```python
|
|
|
# backend/app/models.py (phase-2 소유 — 참조용 발췌, 본 phase에서 재정의하지 않음)
|
|
|
from datetime import datetime
|
|
|
from sqlmodel import SQLModel, Field
|
|
|
|
|
|
class Briefing(SQLModel, table=True):
|
|
|
"""아침 브리핑 — 단일 row(id=1) 또는 settings로 운용."""
|
|
|
id: int = Field(default=1, primary_key=True)
|
|
|
today: str # "6월 7일 일요일"
|
|
|
weather_temp: int # 24
|
|
|
weather_cond: str # "맑음 · 한낮 28°"
|
|
|
weather_icon: str # "cloudSun" 저장 → 응답/표시 시 'sun' 으로 매핑(R11/R12, 디자인 충실도 노트)
|
|
|
commute: str # "출근 23분 · 평소보다 4분 빠름"
|
|
|
sleep: str # "어젯밤 7시간 12분 · 평소만큼 푹 잤어요"
|
|
|
note: str # briefingNote (HTML 허용; <b> 강조)
|
|
|
saved_today: str # "47분" (결재함 카드 서브용)
|
|
|
today_routed: int # 7 (인박스 카드 서브용)
|
|
|
|
|
|
class Event(SQLModel, table=True):
|
|
|
"""오늘 일정(읽기 전용 시드)."""
|
|
|
id: str = Field(primary_key=True) # "e1"
|
|
|
time: str # "09:30"
|
|
|
title: str # "팀 데일리 스탠드업"
|
|
|
tag: str # "프로덕트"
|
|
|
dur: str # "15분"
|
|
|
tone: str # blue|violet|coral|green
|
|
|
soon: bool = False
|
|
|
sort_order: int = 0
|
|
|
|
|
|
class Approval(SQLModel, table=True):
|
|
|
"""결재함(승인 큐) — 대시보드 요약 + (placeholder) 전용 페이지 공용."""
|
|
|
id: str = Field(primary_key=True) # "a1"
|
|
|
icon: str # "cal" | "mail" | "users" | "wallet"
|
|
|
tone: str # coral|blue|violet|green|amber
|
|
|
risk: str # "low" | "high" → high만 요약 카드에 노출
|
|
|
time: str # "07:42" | "보내기 대기" 등
|
|
|
title: str # "치과 예약을 16:00로 옮겼어요"
|
|
|
detail: str
|
|
|
cta: str | None = None # "보내기" (risk=high)
|
|
|
alt: str | None = None # "수정"
|
|
|
undo_label: str | None = None # "원래 시간으로" (risk=low)
|
|
|
sort_order: int = 0
|
|
|
|
|
|
class Goal(SQLModel, table=True):
|
|
|
id: str = Field(primary_key=True) # "g1"
|
|
|
title: str # "분기 OKR — 사용자 리텐션"
|
|
|
pct: int # 68
|
|
|
sub: str # "12개 중 8개 달성"
|
|
|
tone: str # "blue" (tone 키. 프론트에서 var(--blue)로 변환. 'var(--blue)'로 저장하지 않음)
|
|
|
sort_order: int = 0
|
|
|
```
|
|
|
|
|
|
> 참고: 원본 `data.js`는 `goals[].c: "var(--blue)"`처럼 CSS 변수 문자열을 그대로 담지만, 백엔드 스키마는 **tone 키(`blue`)** 만 저장하고 프론트에서 `var(--${tone})`로 변환한다(CONTRACT tone 값 집합 준수: `blue|violet|coral|green|amber|ink|faint`).
|
|
|
|
|
|
### 3.2 백엔드 — 시드 데이터(`seed.py`)
|
|
|
|
|
|
원본 파일의 값을 그대로 옮긴다. `briefingNote`/`note`의 HTML은 보존한다.
|
|
|
|
|
|
> 시드 진입점은 `phase-2`의 `run_seed(session=None, reset=True)` 단일 정본이며, 대시보드 시드는 그 내부에서 호출되는 비공개 헬퍼 `_seed_dashboard(session)`로 둔다(공개 진입점으로 노출하지 않음). `run_seed`가 자체/주입 세션을 만들고 `reset` 후 `_seed_dashboard(session)`를 호출한다.
|
|
|
|
|
|
```python
|
|
|
# backend/app/seed.py (대시보드 시드 발췌 — run_seed 내부에서 호출되는 비공개 헬퍼)
|
|
|
def _seed_dashboard(session):
|
|
|
session.add(Briefing(
|
|
|
id=1,
|
|
|
today="6월 7일 일요일",
|
|
|
weather_temp=24, weather_cond="맑음 · 한낮 28°", weather_icon="cloudSun", # 표시 시 'sun' 매핑
|
|
|
commute="출근 23분 · 평소보다 4분 빠름",
|
|
|
sleep="어젯밤 7시간 12분 · 평소만큼 푹 잤어요",
|
|
|
note=("오늘은 오후 미팅이 핵심이에요. 오전을 비워 <b>분기 리포트</b>에 "
|
|
|
"집중하시면 좋겠어요. 14시 전엔 비가 그칠 예정이라 우산은 안 챙기셔도 돼요."),
|
|
|
saved_today="47분",
|
|
|
today_routed=7,
|
|
|
))
|
|
|
|
|
|
events = [
|
|
|
Event(id="e1", time="09:30", title="팀 데일리 스탠드업", tag="프로덕트", dur="15분", tone="blue", sort_order=0),
|
|
|
Event(id="e2", time="11:00", title="디자인 리뷰 — 온보딩 플로우", tag="디자인", dur="45분", tone="violet", sort_order=1),
|
|
|
Event(id="e3", time="14:00", title="분기 전략 미팅", tag="경영진", dur="60분", tone="coral", soon=True, sort_order=2),
|
|
|
Event(id="e4", time="16:30", title="1:1 — 민서님", tag="팀", dur="30분", tone="green", sort_order=3),
|
|
|
]
|
|
|
session.add_all(events)
|
|
|
|
|
|
# approve-data.js 그대로
|
|
|
approvals = [
|
|
|
Approval(id="a1", icon="cal", tone="coral", risk="low", time="07:42",
|
|
|
title="치과 예약을 16:00로 옮겼어요",
|
|
|
detail="14시 분기 전략 미팅과 겹침 · 병원 예약 시스템에서 빈 슬롯 확인 후 변경",
|
|
|
undo_label="원래 시간으로", sort_order=0),
|
|
|
Approval(id="a2", icon="mail", tone="blue", risk="low", time="06:10",
|
|
|
title="영수증·뉴스레터 7통을 정리했어요",
|
|
|
detail="영수증 3통 → 금융 폴더 · 뉴스레터 4통 → 읽을거리, 받은편지함은 중요한 것만 남김",
|
|
|
undo_label="되돌리기", sort_order=1),
|
|
|
Approval(id="a3", icon="cal", tone="violet", risk="low", time="07:40",
|
|
|
title="내일 오전 딥 워크 2시간을 예약했어요",
|
|
|
detail="분기 리포트 마감(내일 18시) 대비 · 9:00–11:00, 방해 금지로 설정",
|
|
|
undo_label="블록 해제", sort_order=2),
|
|
|
Approval(id="a4", icon="mail", tone="violet", risk="high", time="보내기 대기",
|
|
|
title="현우님께 회신 초안이 준비됐어요",
|
|
|
detail="“잘 받았어요! 금요일 오전까지 화면별 코멘트 정리해서 드릴게요.”",
|
|
|
cta="보내기", alt="수정", sort_order=3),
|
|
|
Approval(id="a5", icon="users", tone="green", risk="high", time="전달 대기",
|
|
|
title="민서님께 ‘데이터 전처리’ 위임 요청",
|
|
|
detail="오늘 일정 과부하 감지 · 맥락 요약과 마감(목)을 담은 요청 메시지 작성 완료",
|
|
|
cta="전달", alt="내가 할게", sort_order=4),
|
|
|
Approval(id="a6", icon="wallet", tone="amber", risk="high", time="확인 필요",
|
|
|
title="Netflix 일시정지를 추천해요",
|
|
|
detail="최근 2개월 시청 기록 없음 · 모레 17,000원 결제 예정 — 정지 절차는 준비해뒀어요",
|
|
|
cta="일시정지", alt="유지", sort_order=5),
|
|
|
]
|
|
|
session.add_all(approvals)
|
|
|
|
|
|
# data.js goals (var(--x) → tone 키)
|
|
|
goals = [
|
|
|
Goal(id="g1", title="분기 OKR — 사용자 리텐션", pct=68, sub="12개 중 8개 달성", tone="blue", sort_order=0),
|
|
|
Goal(id="g2", title="주 4회 운동", pct=75, sub="이번 주 3/4회", tone="coral", sort_order=1),
|
|
|
Goal(id="g3", title="‘딥 워크’ 책 완독", pct=40, sub="320쪽 중 128쪽", tone="violet", sort_order=2),
|
|
|
]
|
|
|
session.add_all(goals)
|
|
|
session.commit()
|
|
|
```
|
|
|
|
|
|
> 인박스 최근 캡처는 별도 시드하지 않는다 — `phase-4-inbox.md`에서 시드한 `inbox_item`(sinbox-data.js의 s1~s4)을 그대로 집계해 최신 3건을 사용한다.
|
|
|
|
|
|
### 3.3 백엔드 — 응답 스키마(`schemas.py`)
|
|
|
|
|
|
`frontend/lib/types.ts`와 **필드명 1:1 대응**. CONTRACT의 `GET /api/dashboard → {user, briefing, saved_today, today_routed, schedule[], task_summary, goals[], approvals_summary[], inbox_recent[], badges:{appr,task,noti}}` 형태를 그대로 따른다. `briefing`은 `{weather, commute, sleep, note}`만 담고, `saved_today`·`today_routed`는 **최상위 필드**다. `goals[]`의 색상 키는 `tone`(예 `blue`), `inbox_recent[]`는 `{id,kind,raw,type,proj_label,tone}`이다.
|
|
|
|
|
|
```python
|
|
|
# backend/app/schemas.py (대시보드 출력)
|
|
|
from pydantic import BaseModel
|
|
|
|
|
|
class UserOut(BaseModel):
|
|
|
name: str # "지우"
|
|
|
initial: str # "지"
|
|
|
|
|
|
class WeatherOut(BaseModel): # phase-2 §3.2 소유 — 동일. { temp:int, cond:str, icon:str }
|
|
|
temp: int # 24
|
|
|
cond: str # "맑음 · 한낮 28°"
|
|
|
icon: str # "sun" (cloudSun→sun 매핑 후)
|
|
|
|
|
|
class BriefingOut(BaseModel):
|
|
|
today: str # "6월 7일 일요일" (phase-2 Briefing 소유 컬럼)
|
|
|
weather: WeatherOut # 중첩 타입(dict 아님): {temp, cond, icon}
|
|
|
commute: str
|
|
|
sleep: str
|
|
|
note: str # HTML 허용
|
|
|
# 주의: saved_today / today_routed 는 응답 최상위 필드(briefing 안이 아님)
|
|
|
|
|
|
class ScheduleItemOut(BaseModel):
|
|
|
id: str
|
|
|
time: str
|
|
|
title: str
|
|
|
tag: str
|
|
|
dur: str
|
|
|
tone: str
|
|
|
soon: bool
|
|
|
|
|
|
class ApprovalSummaryOut(BaseModel):
|
|
|
id: str
|
|
|
icon: str
|
|
|
tone: str
|
|
|
title: str
|
|
|
time: str # "보내기 대기" 등 (risk=high만)
|
|
|
|
|
|
class InboxRecentOut(BaseModel):
|
|
|
id: str
|
|
|
kind: str # text|voice|image
|
|
|
raw: str
|
|
|
type: str # 분류 type: "task"|"event"|"idea" (없으면 "" 또는 미분류)
|
|
|
proj_label: str # 프로젝트 표시 라벨 "개인 › 여행 — 한국" (없으면 "")
|
|
|
tone: str # 분류 tone (없으면 "faint")
|
|
|
|
|
|
class TaskSummaryItem(BaseModel): # phase-2 §3.2 소유 — 동일 이름·필드
|
|
|
id: str
|
|
|
title: str
|
|
|
project: str # 프로젝트 표시명
|
|
|
prio: str # 높음|보통|낮음
|
|
|
|
|
|
class TaskSummaryOut(BaseModel):
|
|
|
open_count: int # 미완료 작업 수
|
|
|
items: list[TaskSummaryItem] # 미완료(open) 작업만, 상위 N건(기본 5)
|
|
|
|
|
|
class GoalOut(BaseModel):
|
|
|
id: str
|
|
|
title: str
|
|
|
pct: int
|
|
|
sub: str
|
|
|
tone: str # tone 키(예 "blue"). 프론트가 var(--tone)로 변환
|
|
|
|
|
|
class BadgesOut(BaseModel):
|
|
|
appr: int # 결재 대기(=high risk 개수) → Topbar 결재함 badge
|
|
|
task: int # 작업 미완료 수 → Topbar 작업 badge
|
|
|
noti: int # 알림 수(시드 상수 6)
|
|
|
|
|
|
class DashboardOut(BaseModel):
|
|
|
user: UserOut
|
|
|
briefing: BriefingOut
|
|
|
saved_today: str # "47분" (결재함 카드 서브용, 최상위)
|
|
|
today_routed: int # 7 (인박스 카드 서브용, 최상위)
|
|
|
schedule: list[ScheduleItemOut]
|
|
|
task_summary: TaskSummaryOut
|
|
|
goals: list[GoalOut]
|
|
|
approvals_summary: list[ApprovalSummaryOut]
|
|
|
inbox_recent: list[InboxRecentOut]
|
|
|
badges: BadgesOut
|
|
|
```
|
|
|
|
|
|
### 3.4 백엔드 — 집계·라우터(phase-2 §3.14 단일 소유)
|
|
|
|
|
|
> 대시보드 백엔드 집계·스키마·라우터는 **`phase-2-backend.md` §3.14가 단일 소유**한다. 별도 `services/dashboard.py`는 만들지 않으며, 집계는 `backend/app/routers/dashboard.py` 안에서 **인라인**으로 수행한다(라우터는 `APIRouter()`로 내부 prefix 없이 정의하고, `main.py`에서 `prefix="/api"`로 등록 — R13). 본 phase는 그 응답을 **소비만** 하며 백엔드 집계·스키마·라우터를 재정의하지 않는다.
|
|
|
>
|
|
|
> 집계 규칙 요약(정본 참조용): 결재함 요약은 `risk=="high"`만 정렬 순서대로 최대 3건, `task_summary.items`는 **미완료(open) 작업만** 상위 N건(done 제외), 인박스 최근 3건 + 최신 분류, `weather_icon`은 `cloudSun→sun` 매핑 후 `briefing.weather`(중첩 타입)로 응답, `user`는 people 시드 `is_me`에서 채움. 구현 코드 정본은 phase-2 §3.14를 참조한다.
|
|
|
|
|
|
#### 예시 응답(`GET /api/dashboard`, 시드 기준 — phase-2 정본과 동일)
|
|
|
|
|
|
```json
|
|
|
{
|
|
|
"user": { "name": "지우", "initial": "지" },
|
|
|
"briefing": {
|
|
|
"today": "6월 7일 일요일",
|
|
|
"weather": { "temp": 24, "cond": "맑음 · 한낮 28°", "icon": "sun" },
|
|
|
"commute": "출근 23분 · 평소보다 4분 빠름",
|
|
|
"sleep": "어젯밤 7시간 12분 · 평소만큼 푹 잤어요",
|
|
|
"note": "오늘은 오후 미팅이 핵심이에요. 오전을 비워 <b>분기 리포트</b>에 집중하시면 좋겠어요. 14시 전엔 비가 그칠 예정이라 우산은 안 챙기셔도 돼요."
|
|
|
},
|
|
|
"saved_today": "47분",
|
|
|
"today_routed": 7,
|
|
|
"schedule": [
|
|
|
{ "id": "e1", "time": "09:30", "title": "팀 데일리 스탠드업", "tag": "프로덕트", "dur": "15분", "tone": "blue", "soon": false },
|
|
|
{ "id": "e2", "time": "11:00", "title": "디자인 리뷰 — 온보딩 플로우", "tag": "디자인", "dur": "45분", "tone": "violet", "soon": false },
|
|
|
{ "id": "e3", "time": "14:00", "title": "분기 전략 미팅", "tag": "경영진", "dur": "60분", "tone": "coral", "soon": true },
|
|
|
{ "id": "e4", "time": "16:30", "title": "1:1 — 민서님", "tag": "팀", "dur": "30분", "tone": "green", "soon": false }
|
|
|
],
|
|
|
"task_summary": {
|
|
|
"open_count": 4,
|
|
|
"items": [
|
|
|
{ "id": "k1", "title": "분기 리포트 초안 마무리", "prio": "높음", "project": "분기 리포트" },
|
|
|
{ "id": "k2", "title": "온보딩 와이어프레임 피드백 정리", "prio": "높음", "project": "와이어프레임" },
|
|
|
{ "id": "k5", "title": "사용자 인터뷰 5건 정리", "prio": "보통", "project": "사용자 리서치" },
|
|
|
{ "id": "k4", "title": "구독 결제 카드 갱신", "prio": "보통", "project": "일상" }
|
|
|
]
|
|
|
},
|
|
|
"goals": [
|
|
|
{ "id": "g1", "title": "분기 OKR — 사용자 리텐션", "pct": 68, "sub": "12개 중 8개 달성", "tone": "blue" },
|
|
|
{ "id": "g2", "title": "주 4회 운동", "pct": 75, "sub": "이번 주 3/4회", "tone": "coral" },
|
|
|
{ "id": "g3", "title": "‘딥 워크’ 책 완독", "pct": 40, "sub": "320쪽 중 128쪽", "tone": "violet" }
|
|
|
],
|
|
|
"approvals_summary": [
|
|
|
{ "id": "a4", "icon": "mail", "tone": "violet", "title": "현우님께 회신 초안이 준비됐어요", "time": "보내기 대기" },
|
|
|
{ "id": "a5", "icon": "users", "tone": "green", "title": "민서님께 ‘데이터 전처리’ 위임 요청", "time": "전달 대기" },
|
|
|
{ "id": "a6", "icon": "wallet", "tone": "amber", "title": "Netflix 일시정지를 추천해요", "time": "확인 필요" }
|
|
|
],
|
|
|
"inbox_recent": [
|
|
|
{ "id": "s1", "kind": "text", "raw": "다음 주에 한국 놀러가는 비행기 티켓 사기", "type": "task", "proj_label": "개인 › 여행 — 한국", "tone": "coral" },
|
|
|
{ "id": "s2", "kind": "text", "raw": "수요일 11시 자전거 수리 맡기기", "type": "event", "proj_label": "개인", "tone": "blue" },
|
|
|
{ "id": "s3", "kind": "voice", "raw": "음성 메모 0:09 — 엄마 생신 선물 미리 알아보기", "type": "task", "proj_label": "개인", "tone": "green" }
|
|
|
],
|
|
|
"badges": { "appr": 3, "task": 4, "noti": 6 }
|
|
|
}
|
|
|
```
|
|
|
|
|
|
> `approvals_summary`는 `risk === "high"`인 a4·a5·a6만 노출된다(원본 `apPending = AP.items.filter(i => i.risk === "high")`와 동일). `badges.appr=3`은 Topbar 결재함 배지("3")와 일치, `badges.task`는 미완료 작업 수(시드상 4)와 일치.
|
|
|
|
|
|
---
|
|
|
|
|
|
### 3.6 프론트 — 타입(`lib/types.ts`)
|
|
|
|
|
|
```ts
|
|
|
// frontend/lib/types.ts (대시보드)
|
|
|
export interface User { name: string; initial: string }
|
|
|
|
|
|
export interface Weather { temp: number; cond: string; icon: string }
|
|
|
|
|
|
export interface Briefing {
|
|
|
today: string;
|
|
|
weather: Weather;
|
|
|
commute: string;
|
|
|
sleep: string;
|
|
|
note: string; // HTML (dangerouslySetInnerHTML)
|
|
|
}
|
|
|
|
|
|
export interface ScheduleItem {
|
|
|
id: string; time: string; title: string; tag: string; dur: string;
|
|
|
tone: Tone; soon: boolean;
|
|
|
}
|
|
|
|
|
|
export interface ApprovalSummary {
|
|
|
id: string; icon: string; tone: Tone; title: string; time: string;
|
|
|
}
|
|
|
|
|
|
export interface InboxRecent {
|
|
|
id: string; kind: "text" | "voice" | "image"; raw: string;
|
|
|
type: string; proj_label: string; tone: Tone;
|
|
|
}
|
|
|
|
|
|
export interface TaskSummaryItem {
|
|
|
id: string; title: string; project: string; prio: "높음" | "보통" | "낮음";
|
|
|
}
|
|
|
export interface TaskSummary { open_count: number; items: TaskSummaryItem[] }
|
|
|
|
|
|
export interface Goal { id: string; title: string; pct: number; sub: string; tone: Tone }
|
|
|
|
|
|
export interface Badges { appr: number; task: number; noti: number }
|
|
|
|
|
|
export interface Dashboard {
|
|
|
user: User;
|
|
|
briefing: Briefing;
|
|
|
saved_today: string; // "47분" (최상위)
|
|
|
today_routed: number; // 7 (최상위)
|
|
|
schedule: ScheduleItem[];
|
|
|
task_summary: TaskSummary;
|
|
|
goals: Goal[];
|
|
|
approvals_summary: ApprovalSummary[];
|
|
|
inbox_recent: InboxRecent[];
|
|
|
badges: Badges;
|
|
|
}
|
|
|
|
|
|
export type Tone = "blue" | "violet" | "coral" | "green" | "amber" | "ink" | "faint";
|
|
|
```
|
|
|
|
|
|
### 3.7 프론트 — API 클라이언트(`lib/api.ts`)
|
|
|
|
|
|
```ts
|
|
|
// frontend/lib/api.ts (발췌)
|
|
|
import type { Dashboard } from "./types";
|
|
|
|
|
|
const BASE = process.env.NEXT_PUBLIC_API_BASE ?? ""; // dev 프록시 사용 시 ""
|
|
|
|
|
|
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 captureInbox(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(); // { item, classification }
|
|
|
}
|
|
|
```
|
|
|
|
|
|
### 3.8 프론트 — 데이터 훅(`lib/hooks/useDashboard.ts`)
|
|
|
|
|
|
React Query(또는 SWR) 기준. 위젯별 부분 실패는 단일 호출이라 "전체 실패 vs 성공"이지만, **위젯 렌더 시 각 섹션이 null/빈 배열일 때를 방어**해 부분 표시(5.9)한다.
|
|
|
|
|
|
```ts
|
|
|
// frontend/lib/hooks/useDashboard.ts
|
|
|
"use client";
|
|
|
import { useQuery } from "@tanstack/react-query";
|
|
|
import { getDashboard } from "@/lib/api";
|
|
|
|
|
|
export function useDashboard() {
|
|
|
return useQuery({
|
|
|
queryKey: ["dashboard"],
|
|
|
queryFn: ({ signal }) => getDashboard(signal),
|
|
|
staleTime: 30_000,
|
|
|
retry: 1,
|
|
|
});
|
|
|
}
|
|
|
```
|
|
|
|
|
|
### 3.9 프론트 — 라우트 진입(`app/dashboard/page.tsx`)
|
|
|
|
|
|
```tsx
|
|
|
// frontend/app/dashboard/page.tsx
|
|
|
import type { Metadata } from "next";
|
|
|
import { DashboardClient } from "@/components/dashboard/DashboardClient";
|
|
|
|
|
|
export const metadata: Metadata = { title: "대시보드 · 아리" };
|
|
|
|
|
|
export default function DashboardPage() {
|
|
|
return <DashboardClient />;
|
|
|
}
|
|
|
```
|
|
|
|
|
|
> `app/page.tsx`(`/`)는 `phase-1-design-system.md`에서 `redirect("/dashboard")`로 구성됨.
|
|
|
|
|
|
### 3.10 프론트 — 페이지 셸 & 벤토 그리드(`DashboardClient.tsx`)
|
|
|
|
|
|
원본 `dash-v2.jsx`의 구조(상단 페이지 헤더 + `.work > .board` 벤토 그리드 + 진입 애니메이션 `entered`)를 재현한다. Topbar/테마는 `phase-1`의 앱 셸(`app/layout.tsx`)이 제공하므로 여기서는 페이지 본문만 그린다.
|
|
|
|
|
|
```tsx
|
|
|
// frontend/components/dashboard/DashboardClient.tsx
|
|
|
"use client";
|
|
|
import { useEffect, useRef } from "react";
|
|
|
import { useDashboard } from "@/lib/hooks/useDashboard";
|
|
|
import { HeroBriefing } from "./HeroBriefing";
|
|
|
import { ApprovalSummaryCard } from "./ApprovalSummaryCard";
|
|
|
import { InboxSummaryCard } from "./InboxSummaryCard";
|
|
|
import { ScheduleCard } from "./ScheduleCard";
|
|
|
import { TaskSummaryCard } from "./TaskSummaryCard";
|
|
|
import { GoalsCard } from "./GoalsCard";
|
|
|
import { DashboardSkeleton } from "./DashboardSkeleton";
|
|
|
import { Icon } from "@/components/Icon";
|
|
|
|
|
|
export function DashboardClient() {
|
|
|
const { data, isLoading, isError, refetch } = useDashboard();
|
|
|
const shellRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
// 원본 진입 애니메이션: requestAnimationFrame → .entered 부여
|
|
|
useEffect(() => {
|
|
|
const el = shellRef.current;
|
|
|
if (!el) return;
|
|
|
const id = requestAnimationFrame(() => el.classList.add("entered"));
|
|
|
return () => cancelAnimationFrame(id);
|
|
|
}, [data]);
|
|
|
|
|
|
if (isLoading) return <DashboardSkeleton />;
|
|
|
if (isError || !data) {
|
|
|
return (
|
|
|
<div className="dash-error" role="alert">
|
|
|
<p>대시보드를 불러오지 못했어요.</p>
|
|
|
<button className="mini-cta" onClick={() => refetch()}>
|
|
|
<Icon name="swap" /> 다시 시도
|
|
|
</button>
|
|
|
</div>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
const { user, briefing, saved_today, today_routed, schedule, task_summary, goals, approvals_summary, inbox_recent } = data;
|
|
|
|
|
|
return (
|
|
|
<div className="dash-page" ref={shellRef}>
|
|
|
{/* 페이지 헤더 (원본 .pagehead) */}
|
|
|
<div className="pagehead">
|
|
|
<div>
|
|
|
<div className="ph-eyebrow">
|
|
|
<span>{briefing.today}</span>
|
|
|
<span className="sep" />
|
|
|
<span className="wx"><Icon name={briefing.weather.icon} />{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">
|
|
|
{/* 히어로 — sp2(2칸) */}
|
|
|
<HeroBriefing briefing={briefing} />
|
|
|
|
|
|
{/* 요약 카드 2개 */}
|
|
|
<ApprovalSummaryCard items={approvals_summary} savedToday={saved_today} pending={data.badges.appr} />
|
|
|
<InboxSummaryCard items={inbox_recent} todayRouted={today_routed} />
|
|
|
|
|
|
{/* 오늘 요약 */}
|
|
|
<ScheduleCard items={schedule} />
|
|
|
<TaskSummaryCard summary={task_summary} />
|
|
|
<GoalsCard goals={goals} />
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
> 사용자명(인사 "좋은 아침이에요, {name}님")은 **`GET /api/dashboard` 응답 최상위 `user.name`**을 사용한다(R12). 하드코딩하지 않는다 — 백엔드가 phase-2 people 시드의 `is_me` 인물에서 `{name, initial}`을 채워 내려준다.
|
|
|
|
|
|
### 3.11 프론트 — 히어로 브리핑(`HeroBriefing.tsx`)
|
|
|
|
|
|
원본 `.card.hero.sp2` 구조 그대로: `hero-spark`(코럴 pill "아리 브리핑") + `p.brief`(HTML) + `.chips`(추천 칩 3개) + `.cmd`(자연어 명령 입력).
|
|
|
|
|
|
```tsx
|
|
|
// frontend/components/dashboard/HeroBriefing.tsx
|
|
|
"use client";
|
|
|
import { Icon } from "@/components/Icon";
|
|
|
import { CommandInput } from "./CommandInput";
|
|
|
import type { Briefing } from "@/lib/types";
|
|
|
|
|
|
// 원본 CHIPS (dash-v2.jsx)
|
|
|
const CHIPS = [
|
|
|
{ icon: "cal", text: "내일 오후 비워줘" },
|
|
|
{ icon: "mail", text: "중요 메일만 요약" },
|
|
|
{ icon: "video", text: "14시 미팅 준비" },
|
|
|
];
|
|
|
|
|
|
export function HeroBriefing({ briefing }: { briefing: Briefing }) {
|
|
|
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>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
> 보안 메모: `briefing.note`는 시드 출처의 HTML이며 `<b>`만 포함한다. 사용자 입력이 아니므로 `dangerouslySetInnerHTML` 사용이 안전하다. 향후 사용자 생성 텍스트를 넣게 되면 `DOMPurify` 같은 sanitizer를 거쳐야 한다(주석으로 명시).
|
|
|
|
|
|
### 3.12 프론트 — 자연어 명령 입력(`CommandInput.tsx`)
|
|
|
|
|
|
원본 `.cmd`(좌측 spark 아이콘 + input + 우측 검정 채움 send 버튼). MVP 동작은 5.5에서 정의: **전송 시 인박스 캡처 → 토스트 → `/inbox` 이동**.
|
|
|
|
|
|
```tsx
|
|
|
// frontend/components/dashboard/CommandInput.tsx
|
|
|
"use client";
|
|
|
import { useState } from "react";
|
|
|
import { useRouter } from "next/navigation";
|
|
|
import { Icon } from "@/components/Icon";
|
|
|
import { captureInbox } from "@/lib/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 captureInbox(raw); // POST /api/inbox/capture {kind:"text", raw}
|
|
|
// 토스트(phase-1 디자인 시스템 Toast): "인박스에 적어뒀어요 — 아리가 분류할게요"
|
|
|
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>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
> 원본 placeholder 문구 "오늘 하루, 무엇이든 맡겨보세요…" 그대로 사용. Enter 키 전송은 `<form onSubmit>`으로 자연스럽게 지원.
|
|
|
|
|
|
### 3.13 프론트 — 결재함 요약 카드(`ApprovalSummaryCard.tsx`)
|
|
|
|
|
|
원본 구조: `.ch`(라임 아이콘 `ico lime` + 제목 "아리 결재함" + 서브 "오늘 47분 아껴드렸어요" + `count warm` "대기 N건") + `.mini-list`(3행) + `.mini-cta`(검정 채움 "결재함에서 승인하기"). `appr` 클래스로 `approve.css`의 `.ico.lime`/`.count.warm` 적용.
|
|
|
|
|
|
```tsx
|
|
|
// frontend/components/dashboard/ApprovalSummaryCard.tsx
|
|
|
"use client";
|
|
|
import Link from "next/link";
|
|
|
import { Icon } from "@/components/Icon";
|
|
|
import { MiniRow } from "./MiniRow";
|
|
|
import type { ApprovalSummary } from "@/lib/types";
|
|
|
|
|
|
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="/결재함" aria-label="결재함에서 승인하기 (준비 중)">
|
|
|
<Icon name="tick" w={3} />결재함에서 승인하기
|
|
|
</Link>
|
|
|
</section>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
> 미니 행은 원본 `<span className="mini-text">{it.title}</span><span className="mini-sub">{it.time}</span>`와 동일. CTA `href`는 placeholder 라우트(예: `/결재함` 또는 `/(placeholder)/approvals`) — 클릭 시 phase-1의 "준비 중" 플레이스홀더 페이지를 보여준다.
|
|
|
|
|
|
### 3.14 프론트 — 인박스 요약 카드(`InboxSummaryCard.tsx`)
|
|
|
|
|
|
원본: `.ico`(inbox) + "스마트 인박스" + 서브 "적으면 아리가 제자리로" + `count` "오늘 7건" + `.mini-list`(3행: kind에 따라 아이콘 pen/mic/image) + `.mini-cta`(→ 인박스, "새로 적기 · 인박스 열기").
|
|
|
|
|
|
```tsx
|
|
|
// frontend/components/dashboard/InboxSummaryCard.tsx
|
|
|
"use client";
|
|
|
import Link from "next/link";
|
|
|
import { Icon } from "@/components/Icon";
|
|
|
import { MiniRow } from "./MiniRow";
|
|
|
import type { InboxRecent } from "@/lib/types";
|
|
|
|
|
|
const kindIcon = (k: InboxRecent["kind"]) =>
|
|
|
k === "voice" ? "mic" : k === "image" ? "image" : "pen";
|
|
|
|
|
|
// 분류 type → 한글 라벨(없으면 "분류 중")
|
|
|
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} icon={kindIcon(c.kind)} text={c.raw} sub={typeLabel(c.type)} />
|
|
|
))}
|
|
|
</div>
|
|
|
)}
|
|
|
|
|
|
<Link className="mini-cta" href="/inbox">
|
|
|
<Icon name="plus" />새로 적기 · 인박스 열기
|
|
|
</Link>
|
|
|
</section>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
### 3.15 프론트 — 공용 미니 행(`MiniRow.tsx`)
|
|
|
|
|
|
`approve.css`의 `.mini-row`/`.mini-ic`/`.mini-text`/`.mini-sub`를 그대로 사용. `--tone` 인라인 변수로 아이콘 칩 색을 입힌다.
|
|
|
|
|
|
```tsx
|
|
|
// frontend/components/dashboard/MiniRow.tsx
|
|
|
import { Icon } from "@/components/Icon";
|
|
|
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 React.CSSProperties}>
|
|
|
<Icon name={icon} />
|
|
|
</span>
|
|
|
<span className="mini-text">{text}</span>
|
|
|
<span className="mini-sub">{sub}</span>
|
|
|
</div>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
### 3.16 프론트 — 오늘 일정 카드(`ScheduleCard.tsx`)
|
|
|
|
|
|
원본 `.sched > .ev` 구조: 좌측 mono `ev-time`, `ev-body`(accent 좌측 바 `--accent`), `ev-title`, `ev-meta`(tag + `dur`), `soon` 배지("곧"), 하단 `sched-note`.
|
|
|
|
|
|
```tsx
|
|
|
// frontend/components/dashboard/ScheduleCard.tsx
|
|
|
"use client";
|
|
|
import { Icon } from "@/components/Icon";
|
|
|
import type { ScheduleItem } from "@/lib/types";
|
|
|
|
|
|
const TONE: Record<string, string> = {
|
|
|
blue: "var(--blue)", violet: "var(--violet)", coral: "var(--coral)", green: "var(--green)",
|
|
|
};
|
|
|
|
|
|
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>
|
|
|
<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": TONE[e.tone] } as React.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">
|
|
|
<Icon name="bell" />치과 예약은 16:00로 옮겨뒀어요 — 결재함에서 되돌릴 수 있어요.
|
|
|
</div>
|
|
|
</div>
|
|
|
</section>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
> `sched-note` 문구는 원본 그대로. "이미 해뒀어요" 원칙을 보여주는 핵심 카피이므로 변경하지 않는다.
|
|
|
|
|
|
### 3.17 프론트 — 할 일 카드(`TaskSummaryCard.tsx`)
|
|
|
|
|
|
원본 `.tasks > .task`(체크박스 `box` + `task-body`(title/meta) + 우측 `prio` 또는 첫 항목 `focus-tag` "지금"). MVP에서는 **읽기 전용 요약**이므로 체크 토글은 비활성(클릭 시 `/tasks`로 이동하거나 no-op). 원본 PRIO 매핑(`높음→high, 보통→mid, 낮음→low`) 유지.
|
|
|
|
|
|
```tsx
|
|
|
// frontend/components/dashboard/TaskSummaryCard.tsx
|
|
|
"use client";
|
|
|
import Link from "next/link";
|
|
|
import { Icon } from "@/components/Icon";
|
|
|
import type { TaskSummary } from "@/lib/types";
|
|
|
|
|
|
const PRIO: Record<string, string> = { 높음: "high", 보통: "mid", 낮음: "low" };
|
|
|
|
|
|
export function TaskSummaryCard({ summary }: { summary: TaskSummary }) {
|
|
|
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>
|
|
|
<div className="tasks">
|
|
|
{summary.items.map((t, idx) => {
|
|
|
// task_summary.items 는 미완료(open) 작업만 담긴다(백엔드가 done 제외) → 첫 항목을 focus 강조
|
|
|
const focus = idx === 0;
|
|
|
return (
|
|
|
<Link
|
|
|
key={t.id}
|
|
|
href={`/tasks?task=${t.id}`}
|
|
|
className={"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>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
> 원본 `dash-v2`는 첫 미완료 항목을 `focus`로 강조하고 "지금" 배지를 단다. `task_summary.items`는 백엔드(phase-2 §3.14)가 **미완료(open) 작업만** 내려주므로(`status` 필드 없음, done 제외) 프론트는 첫 항목(`idx === 0`)을 그대로 `focus`로 강조한다. MVP 대시보드 작업 카드는 토글이 아니라 **작업 페이지로의 진입점**(`/tasks?task={id}`, phase-3의 상세 드로어 딥링크)으로 동작시킨다.
|
|
|
|
|
|
### 3.18 프론트 — 목표 카드(`GoalsCard.tsx`)
|
|
|
|
|
|
원본 `.goals > div`(goal-top: title + pct / goal-bar i(width%, background) / goal-sub).
|
|
|
|
|
|
```tsx
|
|
|
// frontend/components/dashboard/GoalsCard.tsx
|
|
|
import { Icon } from "@/components/Icon";
|
|
|
import type { Goal } from "@/lib/types";
|
|
|
|
|
|
export function GoalsCard({ goals }: { goals: Goal[] }) {
|
|
|
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>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
> 원본은 `g.c`에 `var(--blue)` 문자열을 넣지만, 백엔드가 tone 키(`blue`)를 주므로 `var(--${g.tone})`로 변환. `goal-bar`에 `role="progressbar"` + aria 속성을 더해 접근성을 보강(원본에는 없으나 axe 통과용).
|
|
|
|
|
|
---
|
|
|
|
|
|
## 4. 데이터/타입/API 계약(이 phase 관련)
|
|
|
|
|
|
### 4.1 엔드포인트
|
|
|
|
|
|
| 메서드 | 경로 | 용도 | 응답 |
|
|
|
|---|---|---|---|
|
|
|
| GET | `/api/dashboard` | 대시보드 집계(사용자·브리핑·일정·작업요약·목표·결재요약·인박스최근·배지) | `DashboardOut` |
|
|
|
| POST | `/api/inbox/capture` | 자연어 명령 입력 → 인박스 캡처 + 분류(phase-4 재사용) | `{ item, classification }` |
|
|
|
|
|
|
> 사용자명은 `GET /api/dashboard` 응답 최상위 `user{name,initial}`로 내려온다(R12) — 별도 `/api/people` 호출 불필요.
|
|
|
|
|
|
### 4.2 필드 매핑(원본 → 백엔드 → 프론트)
|
|
|
|
|
|
| 원본(data.js / approve-data.js / sinbox-data.js) | 백엔드 필드 | 프론트 타입 |
|
|
|
|---|---|---|
|
|
|
| people `is_me` | `user{name,initial}` (최상위) | `User` |
|
|
|
| `today` | `briefing.today` | `Briefing.today` |
|
|
|
| `weather.cond` | `briefing.weather.cond` | `Briefing.weather.cond` |
|
|
|
| `commute` / `sleep` | `briefing.commute` / `briefing.sleep` | 동일 |
|
|
|
| `briefingNote` | `briefing.note` (HTML) | `Briefing.note` |
|
|
|
| `AriApprovals.savedToday` | `saved_today` (최상위) | `Dashboard.saved_today` |
|
|
|
| `AriSmartInbox.todayRouted` | `today_routed` (최상위) | `Dashboard.today_routed` |
|
|
|
| `schedule[]` | `schedule[]` (id 부여) | `ScheduleItem[]` |
|
|
|
| `AriApprovals.items[risk=="high"]` | `approvals_summary[]` (title/time/icon/tone) | `ApprovalSummary[]` |
|
|
|
| `AriSmartInbox.items[:3]` + classification | `inbox_recent[]` (`type`/`proj_label`/`tone`) | `InboxRecent[]` |
|
|
|
| `tasks[]` (작업 시드) | `task_summary` | `TaskSummary` |
|
|
|
| `goals[]` (`c`→tone) | `goals[]` (`tone`) | `Goal[]` |
|
|
|
| MAIN badge `appr:3 / task:4 / noti:6` | `badges` | `Badges` |
|
|
|
|
|
|
### 4.3 tone → CSS 변수
|
|
|
|
|
|
`tone` 값 집합 `blue|violet|coral|green|amber|ink|faint`. 프론트는 항상 `var(--${tone})`로 변환. 분류 미완료 인박스 행은 `tone="faint"`로 폴백.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 5. 디자인 충실도 노트(원본 토큰/레이아웃/인터랙션 재현)
|
|
|
|
|
|
이식 대상 CSS는 `frontend/styles/dashboard.css`에 모으되, **공통 토큰은 `tokens.css`(phase-1)** 에 이미 있으므로 여기서는 대시보드/요약 카드 전용 규칙만 옮긴다.
|
|
|
|
|
|
### 5.1 벤토 그리드 레이아웃(`dash.css` `.board`)
|
|
|
|
|
|
```css
|
|
|
.board {
|
|
|
flex: 1; min-width: 0;
|
|
|
display: grid;
|
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
|
gap: 16px;
|
|
|
}
|
|
|
.card.sp2 { grid-column: span 2; } /* 히어로 */
|
|
|
.card.r2 { grid-row: span 2; } /* (MVP 미사용 위젯용) */
|
|
|
```
|
|
|
|
|
|
- 데스크톱: 4열. 히어로 `.sp2`가 2열 차지 → 첫 줄에 [히어로(2)] [결재함(1)] [인박스(1)].
|
|
|
- 둘째 줄: [일정] [할 일] [목표] + 빈 1칸(또는 MVP에서 3개 카드를 1칸씩 배치).
|
|
|
- 반응형: `@media (max-width:1200px) → repeat(2, 1fr)`, `@media (max-width:720px) → 1fr`(`.sp2`도 1칸으로). 720px 이하에서는 `.mainnav` 숨김(phase-1 Topbar에서 처리).
|
|
|
|
|
|
### 5.2 카드 글래스(`dash.css` `.card`)
|
|
|
|
|
|
```css
|
|
|
.card {
|
|
|
background: var(--glass); /* rgba(255,255,255,.4) */
|
|
|
-webkit-backdrop-filter: var(--blur); /* blur(26px) saturate(190%) */
|
|
|
backdrop-filter: var(--blur);
|
|
|
border: 1px solid var(--glass-brd); /* rgba(255,255,255,.7) */
|
|
|
border-radius: var(--radius); /* 22px */
|
|
|
box-shadow: var(--shadow), var(--glass-hi);
|
|
|
padding: 20px;
|
|
|
display: flex; flex-direction: column;
|
|
|
min-width: 0;
|
|
|
}
|
|
|
```
|
|
|
|
|
|
### 5.3 히어로(`dash.css` `.hero`)
|
|
|
|
|
|
- 배경: 좌상단 코럴 글로우 + 우상단 화이트 글로우 + 글래스.
|
|
|
```css
|
|
|
.hero {
|
|
|
background:
|
|
|
radial-gradient(95% 105% at 0% 0%, rgba(231,128,82,.22), transparent 56%),
|
|
|
radial-gradient(130% 130% at 100% 0%, rgba(255,255,255,.42), transparent 50%),
|
|
|
var(--glass);
|
|
|
backdrop-filter: var(--blur);
|
|
|
border: 1px solid var(--glass-brd);
|
|
|
box-shadow: var(--shadow), var(--glass-hi);
|
|
|
}
|
|
|
```
|
|
|
- `hero-spark`(코럴 pill): `background: var(--coral)`, `color:#fff`, `padding:5px 12px`, `border-radius:999px`, `box-shadow:0 5px 14px -5px var(--coral)`.
|
|
|
- `p.brief`: `font-size: clamp(18px,1.7vw,22px)`, `line-height:1.5`, `letter-spacing:-0.02em`, `max-width:40ch`. `b` 강조는 코럴 underline(`border-bottom:2px solid color-mix(in oklab, var(--coral) 60%, transparent)`).
|
|
|
- `.cmd`(명령 입력): `background:var(--glass-2)`, `border:1px solid var(--glass-brd)`, `border-radius:15px`, `padding:7px 7px 7px 16px`, `margin-top:auto`. send 버튼 `38×38`, `border-radius:11px`, `background:var(--fill)`, `color:var(--on-fill)`.
|
|
|
- `.chips .chip`: `font-size:12.5px`, glass-2 배경, pill.
|
|
|
|
|
|
### 5.4 요약 카드(`approve.css` `.mini-*`)
|
|
|
|
|
|
```css
|
|
|
.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 .14s;
|
|
|
}
|
|
|
.mini-cta:hover { filter:brightness(1.12); }
|
|
|
```
|
|
|
|
|
|
- 결재함 카드 전용: `.appr .ico.lime { background:var(--lime); color:var(--lime-ink); }`, `.appr .count.warm { color:#fff; background:var(--coral); }`.
|
|
|
- 빈 상태: `.appr-empty`(녹색 글래스 + `ae-tick`).
|
|
|
|
|
|
### 5.5 자연어 명령 입력 — MVP 동작 정의(명세)
|
|
|
|
|
|
| 상태 | 동작 |
|
|
|
|---|---|
|
|
|
| 입력 후 Enter 또는 send 클릭 | `POST /api/inbox/capture {kind:"text", raw}` 호출(분류 동기 실행) |
|
|
|
| 성공 | 토스트 "인박스에 적어뒀어요 — 아리가 분류할게요" → `/inbox`로 라우팅(`router.push`). 분류 결과/이유는 인박스 페이지에서 확인(phase-4) |
|
|
|
| 실패 | 토스트(coral) "지금은 적어두지 못했어요. 잠시 후 다시 시도해 주세요." → 입력값 유지하지 않고 비움(또는 유지: 구현 선택, 본 문서는 비움) |
|
|
|
| 빈 입력 | send 버튼 비활성(`disabled`), no-op |
|
|
|
| 전송 중 | input/버튼 `disabled`(중복 전송 방지) |
|
|
|
|
|
|
> 제품 철학 반영: 사용자는 "그냥 적기"만 한다. 대시보드 명령 입력은 분류 UI를 띄우지 않고 인박스로 흘려보내 아리가 분류·배치하게 한다("적을 때는 분류하지 않는다").
|
|
|
> 히어로 상단 `.ph-search`("아리에게 무엇이든…")는 MVP에서 **장식/검색 placeholder**로 남기고 동작은 명령 입력(`.cmd`)에 집중한다(중복 입력 UI 혼선 방지).
|
|
|
|
|
|
### 5.6 일정/할 일/목표 픽셀(요약)
|
|
|
|
|
|
| 요소 | 원본 값 |
|
|
|
|---|---|
|
|
|
| `.ev-time` | mono, `13px`, `width:42px`, `color:var(--ink-2)` |
|
|
|
| `.ev-body::before` | 좌측 `3px` accent 바, `var(--accent, var(--blue))` |
|
|
|
| `.ev .soon` | `10.5px`, `#fff`/`var(--coral)`, pill "곧" |
|
|
|
| `.task` | glass-2, `border-radius:14px`, `padding:12px 13px` |
|
|
|
| `.task .box` | `21×21`, `border-radius:7px`, 체크 시 `var(--green)` 채움 |
|
|
|
| `.task.focus` | 코럴 그라데이션 + `inset 3px 0 0 0 var(--coral)`, "지금" 배지(`focus-tag`) |
|
|
|
| `.prio.high` | coral / `.mid` amber / `.low` muted |
|
|
|
| `.goal-bar` | `height:7px`, `var(--card-2)` 트랙, `i`에 width%+색 |
|
|
|
| `.goal-pct` | mono, `13px`, `font-weight:700` |
|
|
|
|
|
|
### 5.7 진입 애니메이션(`dash.css`)
|
|
|
|
|
|
```css
|
|
|
.entered .card { animation: cardin .5s cubic-bezier(.2,.7,.2,1) both; }
|
|
|
@keyframes cardin { from { opacity:0; transform:translateY(12px); } to { opacity:1; transform:none; } }
|
|
|
```
|
|
|
|
|
|
`DashboardClient`가 마운트 후 `requestAnimationFrame`으로 `.entered`를 부여(원본 `shellRef` 로직 동일). `prefers-reduced-motion: reduce`에서는 애니메이션 비활성:
|
|
|
|
|
|
```css
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
|
.entered .card { animation: none; }
|
|
|
}
|
|
|
```
|
|
|
|
|
|
### 5.8 라이트/다크
|
|
|
|
|
|
`tokens.css`의 `[data-theme="dark"]` 오버라이드(phase-1)를 그대로 활용. 대시보드 전용 추가 다크 규칙은 없음(모든 색이 토큰 참조). next-themes의 `data-theme`이 `<html>`에 적용되면 카드·히어로 글래스·잉크가 자동 전환. 검증은 7장 QA에서 두 테마 모두 스크린샷.
|
|
|
|
|
|
> 날씨 아이콘 충실도(R11/R12): 백엔드는 `weather_icon`을 원본 `data.js` 값인 `'cloudSun'`으로 저장하고, 집계 시 중앙 paths 맵에 존재하는 글리프 `'sun'`으로 매핑해 응답(`briefing.weather.icon`)에 담는다. 프론트는 `<Icon name={briefing.weather.icon} />`로 그대로 그린다(하드코딩 금지).
|
|
|
|
|
|
### 5.9 MVP에서 구현하지 않는 원본 위젯(참고)
|
|
|
|
|
|
원본 `dash-v2.jsx`의 다음 위젯은 **MVP 대시보드 범위 밖**(집계 API에 없음). 본 phase에서는 렌더하지 않으며, 향후 확장 시 동일 글래스 보캐뷸러리(`.card`, `.ch`)로 추가한다: 오늘의 집중(`.foc-*`), 미팅 준비(`.mtg-*`), 위임 현황(`.dg-*`), 이어서 하기/환경 우선순위(`.rs-*`/`.env-nudge`), 이번 주 집중도 차트(`.chart`), 메일 요약(`.mails`), 건강(`.rings`), 지출(`.fin-*`).
|
|
|
|
|
|
---
|
|
|
|
|
|
## 6. 상태 처리(로딩/빈/에러/오프라인) & 엣지 케이스
|
|
|
|
|
|
### 6.1 로딩 — 스켈레톤(`DashboardSkeleton.tsx`)
|
|
|
|
|
|
`isLoading` 동안 동일 그리드 레이아웃의 스켈레톤 카드를 그린다(레이아웃 점프 방지).
|
|
|
|
|
|
```tsx
|
|
|
// 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>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
```css
|
|
|
/* dashboard.css */
|
|
|
.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,.35), transparent);
|
|
|
transform: translateX(-100%); animation: shimmer 1.4s infinite;
|
|
|
}
|
|
|
@keyframes 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; }
|
|
|
@media (prefers-reduced-motion: reduce) { .skel::after { animation: none; } }
|
|
|
```
|
|
|
|
|
|
### 6.2 빈 상태(위젯별)
|
|
|
|
|
|
| 위젯 | 빈 조건 | 표시 |
|
|
|
|---|---|---|
|
|
|
| 결재함 요약 | `approvals_summary.length === 0` | `.appr-empty` "지금은 확인할 게 없어요 — 다 처리해뒀어요." |
|
|
|
| 인박스 요약 | `inbox_recent.length === 0` | `.appr-empty` "인박스가 비었어요 — 떠오르면 바로 적어두세요." (CTA "새로 적기"는 유지) |
|
|
|
| 오늘 일정 | `schedule.length === 0` | "오늘은 일정이 없어요. 여유로운 하루예요." (sched-note 대신 빈 안내) |
|
|
|
| 할 일 | `task_summary.items.length === 0` | "할 일이 모두 끝났어요 🎉" / `count`는 "0개 남음" |
|
|
|
| 목표 | `goals.length === 0` | "추적 중인 목표가 없어요." |
|
|
|
|
|
|
### 6.3 에러 — 전체 실패 vs 부분 방어
|
|
|
|
|
|
- `GET /api/dashboard`는 단일 호출이므로 네트워크/5xx 실패 시 **전체 에러 화면**(`role="alert"` + "다시 시도" 버튼, `refetch()`).
|
|
|
- 단, 응답 JSON에서 특정 섹션이 누락/null/빈 배열일 수 있으므로 **각 카드 컴포넌트는 옵셔널 체이닝/기본값으로 방어**(부분 표시). 예: `briefing.note ?? ""`, `schedule ?? []`. 한 위젯이 깨져도 나머지는 렌더되도록 각 카드를 ErrorBoundary(`WidgetError`)로 감쌀 수 있다:
|
|
|
|
|
|
```tsx
|
|
|
// frontend/components/dashboard/WidgetError.tsx (ErrorBoundary fallback)
|
|
|
export function WidgetErrorFallback({ name }: { name: string }) {
|
|
|
return (
|
|
|
<section className="card" role="alert">
|
|
|
<div className="ch"><div className="htext"><h3>{name}</h3></div></div>
|
|
|
<div className="appr-empty">이 카드를 불러오지 못했어요.</div>
|
|
|
</section>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
> 사용처(선택): `<ErrorBoundary fallback={<WidgetErrorFallback name="오늘 일정" />}><ScheduleCard .../></ErrorBoundary>`. React Query 단일 쿼리 구조에서는 "부분 실패"가 데이터 형태 결손으로만 발생하므로, ErrorBoundary는 렌더 예외(예상치 못한 데이터) 안전망 역할.
|
|
|
|
|
|
### 6.4 오프라인
|
|
|
|
|
|
- `navigator.onLine === false` 또는 fetch 실패 → 전체 에러 화면에 "오프라인 상태예요 — 연결되면 자동으로 불러올게요." 문구. `online` 이벤트 리스너로 자동 `refetch`.
|
|
|
- 자연어 명령 입력은 오프라인 시 전송 비활성 + 토스트 "오프라인이라 지금은 적어둘 수 없어요."
|
|
|
|
|
|
### 6.5 엣지 케이스
|
|
|
|
|
|
| 케이스 | 처리 |
|
|
|
|---|---|
|
|
|
| `briefing.note`에 `<b>` 외 태그 | 시드 출처라 안전. 사용자 입력 경로 없음(명령 입력은 인박스로만 감) |
|
|
|
| `approvals_summary`가 3건 초과 | 백엔드에서 이미 `[:3]` 슬라이스 |
|
|
|
| 인박스 최근 항목 분류 미완료 | `type=""`(프론트 라벨 "분류 중"), `proj_label=""`, `tone="faint"` 폴백 |
|
|
|
| 작업 요약 항목 0개지만 `open_count>0` | 데이터 불일치 — `open_count` 우선 표시, 목록 빈 안내 |
|
|
|
| 매우 긴 제목 | `.mini-text`/`.task-title` ellipsis(원본 CSS) |
|
|
|
| 자연어 명령 연속 전송 | `busy` 플래그로 중복 차단 |
|
|
|
|
|
|
---
|
|
|
|
|
|
## 7. 테스팅 & 검증
|
|
|
|
|
|
### 7.1 실행 명령
|
|
|
|
|
|
```bash
|
|
|
# 백엔드 (repo root에서)
|
|
|
cd backend && uv run pytest tests/test_dashboard.py -v
|
|
|
# 또는: cd backend && pytest tests/test_dashboard.py -v
|
|
|
|
|
|
# 프론트 컴포넌트 (Vitest + RTL)
|
|
|
cd frontend && pnpm test # 전체
|
|
|
cd frontend && pnpm test dashboard # 대시보드 관련만
|
|
|
|
|
|
# E2E (Playwright) — 백엔드 + 프론트 dev 서버 기동 후
|
|
|
cd frontend && pnpm playwright test playwright/dashboard.spec.ts
|
|
|
|
|
|
# 접근성 (axe, Playwright 통합)
|
|
|
cd frontend && pnpm playwright test playwright/dashboard.a11y.spec.ts
|
|
|
```
|
|
|
|
|
|
### 7.2 백엔드 테스트(`backend/tests/test_dashboard.py`)
|
|
|
|
|
|
```python
|
|
|
from fastapi.testclient import TestClient
|
|
|
from app.main import app
|
|
|
|
|
|
client = TestClient(app)
|
|
|
|
|
|
def test_dashboard_shape():
|
|
|
r = client.get("/api/dashboard")
|
|
|
assert r.status_code == 200
|
|
|
d = r.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():
|
|
|
d = client.get("/api/dashboard").json()
|
|
|
assert d["user"]["name"] == "지우"
|
|
|
assert d["user"]["initial"] == "지"
|
|
|
|
|
|
def test_briefing_seed_values():
|
|
|
d = client.get("/api/dashboard").json()
|
|
|
b = d["briefing"]
|
|
|
assert b["today"] == "6월 7일 일요일"
|
|
|
assert b["weather"]["cond"] == "맑음 · 한낮 28°"
|
|
|
assert d["saved_today"] == "47분" # 최상위
|
|
|
assert d["today_routed"] == 7 # 최상위
|
|
|
assert "<b>분기 리포트</b>" in b["note"] # HTML 강조 보존
|
|
|
|
|
|
def test_schedule_four_items_and_soon():
|
|
|
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():
|
|
|
d = client.get("/api/dashboard").json()
|
|
|
ap = d["approvals_summary"]
|
|
|
assert len(ap) == 3
|
|
|
titles = [a["title"] for a in ap]
|
|
|
assert "현우님께 회신 초안이 준비됐어요" in titles # a4
|
|
|
assert "Netflix 일시정지를 추천해요" in titles # a6
|
|
|
# low risk(a1 치과 예약)는 요약에 없어야 함
|
|
|
assert all("치과 예약" not in t for t in titles)
|
|
|
|
|
|
def test_inbox_recent_three_with_type_and_proj():
|
|
|
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():
|
|
|
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) 아님(tone 키)
|
|
|
|
|
|
def test_badges_match_counts():
|
|
|
d = client.get("/api/dashboard").json()
|
|
|
b = d["badges"]
|
|
|
assert b["appr"] == 3 # high risk 개수
|
|
|
assert b["task"] == d["task_summary"]["open_count"]
|
|
|
assert b["noti"] == 6
|
|
|
```
|
|
|
|
|
|
### 7.3 프론트 컴포넌트 테스트(Vitest + RTL)
|
|
|
|
|
|
`HeroBriefing.test.tsx`:
|
|
|
```tsx
|
|
|
import { render, screen } from "@testing-library/react";
|
|
|
import { HeroBriefing } from "@/components/dashboard/HeroBriefing";
|
|
|
|
|
|
const briefing = {
|
|
|
today: "6월 7일 일요일",
|
|
|
weather: { temp: 24, cond: "맑음 · 한낮 28°", icon: "sun" },
|
|
|
commute: "출근 23분", sleep: "7시간 12분",
|
|
|
note: "오늘은 오후 미팅이 핵심이에요. <b>분기 리포트</b>",
|
|
|
};
|
|
|
|
|
|
test("브리핑 노트의 HTML 강조를 렌더한다", () => {
|
|
|
render(<HeroBriefing briefing={briefing as any} />);
|
|
|
expect(screen.getByText("아리 브리핑")).toBeInTheDocument();
|
|
|
const b = document.querySelector(".brief b");
|
|
|
expect(b?.textContent).toBe("분기 리포트");
|
|
|
});
|
|
|
|
|
|
test("추천 칩 3개와 명령 입력창을 렌더한다", () => {
|
|
|
render(<HeroBriefing briefing={briefing as any} />);
|
|
|
expect(screen.getByText("내일 오후 비워줘")).toBeInTheDocument();
|
|
|
expect(screen.getByPlaceholderText("오늘 하루, 무엇이든 맡겨보세요…")).toBeInTheDocument();
|
|
|
});
|
|
|
```
|
|
|
|
|
|
`ApprovalSummaryCard.test.tsx`:
|
|
|
```tsx
|
|
|
test("대기 N건 배지와 미니 행, 결재함 CTA를 렌더한다", () => {
|
|
|
const items = [
|
|
|
{ id: "a4", icon: "mail", tone: "violet", title: "현우님께 회신 초안이 준비됐어요", time: "보내기 대기" },
|
|
|
];
|
|
|
render(<ApprovalSummaryCard items={items as any} savedToday="47분" pending={3} />);
|
|
|
expect(screen.getByText("아리 결재함")).toBeInTheDocument();
|
|
|
expect(screen.getByText("오늘 47분 아껴드렸어요")).toBeInTheDocument();
|
|
|
expect(screen.getByText("대기 3건")).toBeInTheDocument();
|
|
|
expect(screen.getByText("현우님께 회신 초안이 준비됐어요")).toBeInTheDocument();
|
|
|
const cta = screen.getByRole("link", { name: /결재함에서 승인하기/ });
|
|
|
expect(cta).toHaveAttribute("href", expect.stringContaining("결재함"));
|
|
|
});
|
|
|
|
|
|
test("대기 0건이면 빈 상태를 보여준다", () => {
|
|
|
render(<ApprovalSummaryCard items={[]} savedToday="0분" pending={0} />);
|
|
|
expect(screen.getByText(/다 처리해뒀어요/)).toBeInTheDocument();
|
|
|
});
|
|
|
```
|
|
|
|
|
|
`GoalsCard.test.tsx`:
|
|
|
```tsx
|
|
|
test("목표 진행 막대 width와 progressbar aria를 렌더한다", () => {
|
|
|
const goals = [{ id:"g1", title:"분기 OKR", pct:68, sub:"12개 중 8개", tone:"blue" }];
|
|
|
render(<GoalsCard goals={goals as any} />);
|
|
|
const bar = screen.getByRole("progressbar", { name: "분기 OKR" });
|
|
|
expect(bar).toHaveAttribute("aria-valuenow", "68");
|
|
|
const fill = bar.querySelector("i");
|
|
|
expect(fill).toHaveStyle({ width: "68%" });
|
|
|
});
|
|
|
```
|
|
|
|
|
|
`InboxSummaryCard.test.tsx`:
|
|
|
```tsx
|
|
|
test("kind별 아이콘과 새로 적기 CTA(/inbox)를 렌더한다", () => {
|
|
|
const items = [{ id:"s3", kind:"voice", raw:"엄마 생신 선물", type:"task", proj_label:"개인", tone:"green" }];
|
|
|
render(<InboxSummaryCard items={items as any} todayRouted={7} />);
|
|
|
expect(screen.getByText("오늘 7건")).toBeInTheDocument();
|
|
|
expect(screen.getByText("엄마 생신 선물")).toBeInTheDocument();
|
|
|
expect(screen.getByRole("link", { name: /새로 적기/ })).toHaveAttribute("href", "/inbox");
|
|
|
});
|
|
|
```
|
|
|
|
|
|
`CommandInput.test.tsx`(captureInbox·router 모킹):
|
|
|
```tsx
|
|
|
import { vi } from "vitest";
|
|
|
const push = vi.fn();
|
|
|
vi.mock("next/navigation", () => ({ useRouter: () => ({ push }) }));
|
|
|
const capture = vi.fn().mockResolvedValue({ item: {}, classification: {} });
|
|
|
vi.mock("@/lib/api", () => ({ captureInbox: (r: string) => capture(r) }));
|
|
|
|
|
|
test("전송 시 캡처 호출 후 /inbox로 이동", async () => {
|
|
|
const { default: userEvent } = await import("@testing-library/user-event");
|
|
|
render(<CommandInput />);
|
|
|
const input = screen.getByLabelText("아리에게 명령 입력");
|
|
|
await userEvent.type(input, "다음 주 한국 비행기 티켓 사기");
|
|
|
await userEvent.click(screen.getByLabelText("보내기"));
|
|
|
expect(capture).toHaveBeenCalledWith("다음 주 한국 비행기 티켓 사기");
|
|
|
expect(push).toHaveBeenCalledWith("/inbox");
|
|
|
});
|
|
|
|
|
|
test("빈 입력이면 send 비활성", () => {
|
|
|
render(<CommandInput />);
|
|
|
expect(screen.getByLabelText("보내기")).toBeDisabled();
|
|
|
});
|
|
|
```
|
|
|
|
|
|
### 7.4 E2E 테스트(`playwright/dashboard.spec.ts`)
|
|
|
|
|
|
```ts
|
|
|
import { test, expect } from "@playwright/test";
|
|
|
|
|
|
test.describe("대시보드", () => {
|
|
|
test("로드 → 집계가 시드 기준으로 표시된다", async ({ page }) => {
|
|
|
await page.goto("/dashboard");
|
|
|
await expect(page.getByText("아리 브리핑")).toBeVisible();
|
|
|
await expect(page.locator(".brief b")).toHaveText("분기 리포트");
|
|
|
// 결재함 요약: high risk 3건
|
|
|
await expect(page.getByText("아리 결재함")).toBeVisible();
|
|
|
await expect(page.getByText("대기 3건")).toBeVisible();
|
|
|
await expect(page.getByText("현우님께 회신 초안이 준비됐어요")).toBeVisible();
|
|
|
// 일정 4건 + "곧"
|
|
|
await expect(page.locator(".ev")).toHaveCount(4);
|
|
|
await expect(page.getByText("곧")).toBeVisible();
|
|
|
// 목표 3건
|
|
|
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("자연어 명령 입력 → 캡처 후 /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.getByText("수요일 11시 자전거 수리 맡기기")).toBeVisible();
|
|
|
});
|
|
|
|
|
|
test("작업 요약 항목 클릭 → /tasks 딥링크", async ({ page }) => {
|
|
|
await page.goto("/dashboard");
|
|
|
await page.getByText("분기 리포트 초안 마무리").click();
|
|
|
await expect(page).toHaveURL(/\/tasks\?task=/);
|
|
|
});
|
|
|
|
|
|
test("다크 테마 토글 후에도 렌더 정상", async ({ page }) => {
|
|
|
await page.goto("/dashboard");
|
|
|
await page.getByRole("button", { name: "테마" }).click();
|
|
|
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
|
|
await expect(page.getByText("아리 브리핑")).toBeVisible();
|
|
|
});
|
|
|
});
|
|
|
```
|
|
|
|
|
|
### 7.5 접근성 테스트(`playwright/dashboard.a11y.spec.ts`)
|
|
|
|
|
|
```ts
|
|
|
import { test, expect } from "@playwright/test";
|
|
|
import AxeBuilder from "@axe-core/playwright";
|
|
|
|
|
|
test("대시보드 a11y — 위반 없음(라이트)", async ({ page }) => {
|
|
|
await page.goto("/dashboard");
|
|
|
await page.getByText("아리 브리핑").waitFor();
|
|
|
const res = await new AxeBuilder({ page })
|
|
|
.withTags(["wcag2a", "wcag2aa"]).analyze();
|
|
|
expect(res.violations).toEqual([]);
|
|
|
});
|
|
|
|
|
|
test("대시보드 a11y — 다크 테마", async ({ page }) => {
|
|
|
await page.goto("/dashboard");
|
|
|
await page.getByRole("button", { name: "테마" }).click();
|
|
|
const res = await new AxeBuilder({ page }).withTags(["wcag2a","wcag2aa"]).analyze();
|
|
|
expect(res.violations).toEqual([]);
|
|
|
});
|
|
|
```
|
|
|
|
|
|
접근성 체크 포인트:
|
|
|
- 모든 아이콘 전용 버튼에 `aria-label`(검색/메일/테마/보내기/더보기).
|
|
|
- `goal-bar`에 `role="progressbar"` + `aria-valuenow/min/max`.
|
|
|
- 로딩 영역 `aria-busy="true"` + `aria-live="polite"`, 에러 `role="alert"`.
|
|
|
- 색 대비: 잉크(`#211f1c`)/배경(글래스) 4.5:1 이상, 다크 테마(`#f3eee6`/`#2c2925`)도 확인.
|
|
|
- 키보드: 명령 입력 Enter 전송, 카드 CTA/링크 Tab 포커스 가능, `:focus-visible` 아웃라인(blue) 보임.
|
|
|
|
|
|
### 7.6 수동 QA 체크리스트
|
|
|
|
|
|
- [ ] `/dashboard` 진입 시 카드 진입 애니메이션(`cardin`)이 한 번 재생된다.
|
|
|
- [ ] 히어로 코럴 pill "아리 브리핑", 브리핑 본문에 "분기 리포트"가 코럴 underline `<b>`로 강조된다.
|
|
|
- [ ] 추천 칩 3개("내일 오후 비워줘", "중요 메일만 요약", "14시 미팅 준비")가 표시된다.
|
|
|
- [ ] 명령 입력 placeholder "오늘 하루, 무엇이든 맡겨보세요…"가 보이고, 텍스트 입력 후 send 버튼이 활성화된다.
|
|
|
- [ ] 명령 전송 시 토스트가 뜨고 `/inbox`로 이동, 인박스에 방금 적은 항목이 보인다.
|
|
|
- [ ] 결재함 카드: 라임 spark 아이콘, "오늘 47분 아껴드렸어요", "대기 3건"(코럴 배지), 미니 행 3개(현우 회신/민서 위임/Netflix), CTA 클릭 시 "준비 중".
|
|
|
- [ ] 인박스 카드: inbox 아이콘, "오늘 7건", 미니 행 3개(kind별 pen/mic/image 아이콘 + tone 색), "새로 적기 · 인박스 열기" → `/inbox`.
|
|
|
- [ ] 일정 카드: 4건, 14:00 "분기 전략 미팅"에 "곧" 배지, accent 좌측 바 색(blue/violet/coral/green), 하단 sched-note 문구.
|
|
|
- [ ] 할 일 카드: "N개 남음" 배지, 첫 미완료 항목에 "지금" focus 강조(코럴), 나머지에 우선순위 배지(높음=coral/보통=amber/낮음=muted), 항목 클릭 시 `/tasks?task=...`.
|
|
|
- [ ] 목표 카드: 3건, 진행 막대 width(68/75/40%)와 색(blue/coral/violet), mono 퍼센트.
|
|
|
- [ ] 다크 토글: 모든 카드/히어로/배지가 다크 토큰으로 전환, 텍스트 가독성 유지.
|
|
|
- [ ] 반응형: 1200px↓ 2열, 720px↓ 1열 + Topbar 메인 nav 숨김 + 히어로 1칸.
|
|
|
- [ ] 로딩 중 스켈레톤이 동일 레이아웃으로 보이고 점프 없음.
|
|
|
- [ ] 백엔드 중단 상태에서 새로고침 → 전체 에러 화면 + "다시 시도" 동작.
|
|
|
- [ ] `prefers-reduced-motion` 켠 상태에서 진입/쉬머 애니메이션 비활성.
|
|
|
|
|
|
### 7.7 통과 기준
|
|
|
|
|
|
- 백엔드 `test_dashboard.py` 전 케이스 통과(시드 값 정확성 포함).
|
|
|
- 컴포넌트 테스트(Hero/Approval/Inbox/Goals/Command) 전 통과.
|
|
|
- E2E 6개 시나리오 전 통과(로드·인박스 링크·결재함 placeholder·명령 입력·작업 딥링크·다크).
|
|
|
- axe 위반 0건(라이트/다크).
|
|
|
- 수동 QA 체크리스트 100% 충족.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 8. 완료 기준(Definition of Done)
|
|
|
|
|
|
- [ ] `GET /api/dashboard`가 `DashboardOut` 형태로 시드 기준 집계를 반환(사용자/브리핑/saved_today·today_routed 최상위/일정/작업요약/목표/결재요약/인박스최근/배지).
|
|
|
- [ ] 인사 "좋은 아침이에요, {name}님"이 응답 최상위 `user.name`을 사용(하드코딩 제거, R12).
|
|
|
- [ ] `briefing.note`의 HTML(`<b>분기 리포트</b>`)이 보존되어 프론트에서 강조 렌더.
|
|
|
- [ ] 결재함 요약이 `risk==="high"` 3건만(현우/민서/Netflix), "대기 3건" 배지가 Topbar 결재함 배지(3)와 일치.
|
|
|
- [ ] 인박스 요약이 최근 3건 + 분류 `type`(프론트 한글 라벨)/`proj_label`/`tone`을 표시, kind별 아이콘 정확.
|
|
|
- [ ] 일정 4건 + "곧" 배지(분기 전략 미팅), 할 일 요약(open_count + 포커스/우선순위), 목표 3건(진행 막대) 정상.
|
|
|
- [ ] 인박스 카드 "새로 적기" → `/inbox`, 작업 항목 → `/tasks?task=`, 결재함 CTA → placeholder("준비 중").
|
|
|
- [ ] 자연어 명령 입력: 전송 → `POST /api/inbox/capture` → 토스트 → `/inbox` 이동(MVP 동작).
|
|
|
- [ ] 라이트/다크 테마 모두 정상 렌더(토큰 기반), 진입 애니메이션 동작, reduced-motion 대응.
|
|
|
- [ ] 로딩 스켈레톤/빈 상태(위젯별)/전체 에러/오프라인 처리 구현.
|
|
|
- [ ] 디자인 충실도: `.board` 4열 벤토, `.card` 글래스(22px radius, blur(26px) saturate(190%)), 히어로 코럴/화이트 글로우, `.mini-*`/`.appr .ico.lime`/`.count.warm` 픽셀·색 일치.
|
|
|
- [ ] 7장의 모든 테스트/QA 통과 기준 충족.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 9. 다음 단계
|
|
|
|
|
|
대시보드까지 3개 MVP 페이지(작업·인박스·대시보드)가 완성되었다. 다음은 **`phase-6-integration.md`** — 연합(federation) 흐름 통합 검증과 마무리:
|
|
|
|
|
|
- 대시보드 명령 입력 → 인박스 캡처/분류 → 확인(실체화) → 작업 생성 → **대시보드 작업 요약/배지에 반영**되는 end-to-end 흐름 E2E.
|
|
|
- 인박스 `confirm`으로 만든 작업이 `task_summary`/`badges.task`에 합산되는지, 결재함 처리 후 `badges.appr` 감소(추후 확장) 등 집계 정합성.
|
|
|
- 전체 접근성(axe) 스윕, 성능(LCP/번들), 실행법(README), MVP 수용 기준 최종 점검.
|
|
|
|
|
|
참조: `overview.md`(전체 아키텍처), `phase-2-backend.md`(집계/시드 원천), `phase-4-inbox.md`(명령 입력이 호출하는 캡처/분류), `phase-3-tasks.md`(작업 요약/딥링크 대상).
|