feat(ui): 작업·인박스·설정/연동 UI + 전역 다이얼로그

- 설정→연동(계정 추가 메뉴·커넥터 목록·메일/LLM 탭), 작업 칸반·상세·댓글,
  인박스 캡처/분류 개선
- 전역 Dialog(confirm/alert) 컴포넌트로 system alert/confirm 대체
- 작업 라우팅 [[...slug]], 공용 lib(api/auth/nav/types 등)·스타일 갱신, 관련 테스트

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main
I Luk Kim 2 months ago
parent ebba607f49
commit 10fcaec080

@ -2,19 +2,34 @@
# prefix 없음. main.py 에서 include_router(prefix="/api"). # prefix 없음. main.py 에서 include_router(prefix="/api").
import uuid import uuid
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, UploadFile
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from sqlmodel import Session from sqlmodel import Session, select
from ..config import get_settings from ..config import get_settings
from ..connectors import oauth as oauthlib from ..connectors import oauth as oauthlib
from ..connectors.registry import ConnectorRegistry from ..connectors.registry import ConnectorRegistry
from ..db import get_session from ..db import engine, get_session
from ..models import ConnectorAccount, ConnectorAccountLog, ConnState from ..models import ConnectorAccount, ConnectorAccountLog, ConnState, OAuthState
from ..schemas import ConnectorStatusOut, ImportResultOut, OAuthStartOut, SyncResultOut from ..schemas import (
ConnectorProviderOut,
ConnectorStatusOut,
ImportResultOut,
OAuthStartOut,
SyncResultOut,
)
router = APIRouter() router = APIRouter()
# phase-16: '계정 추가'가 보여줄 OAuth provider 카탈로그.
# (domain, provider, label, settings client_id 필드명)
_OAUTH_PROVIDERS = [
("mail", "gmail", "Gmail", "google_client_id"),
("mail", "outlook", "Outlook", "microsoft_client_id"),
("calendar", "google_calendar", "Google 캘린더", "google_client_id"),
("calendar", "outlook_calendar", "Outlook 캘린더", "microsoft_client_id"),
]
@router.get("/connectors", response_model=list[ConnectorStatusOut]) @router.get("/connectors", response_model=list[ConnectorStatusOut])
def list_connectors(domain: str | None = None, s: Session = Depends(get_session)): def list_connectors(domain: str | None = None, s: Session = Depends(get_session)):
@ -22,6 +37,25 @@ def list_connectors(domain: str | None = None, s: Session = Depends(get_session)
return [ConnectorStatusOut.from_account(a, ConnectorRegistry.mode_for(a.domain)) for a in rows] return [ConnectorStatusOut.from_account(a, ConnectorRegistry.mode_for(a.domain)) for a in rows]
@router.get("/connectors/providers", response_model=list[ConnectorProviderOut])
def list_providers(domain: str | None = None):
"""OAuth로 추가 가능한 provider 목록 + 구성 여부(client_id 설정 시 configured=true)."""
cfg = get_settings()
out = []
for dom, provider, label, field in _OAUTH_PROVIDERS:
if domain and dom != domain:
continue
out.append(
ConnectorProviderOut(
domain=dom,
provider=provider,
label=label,
configured=bool(getattr(cfg, field, "")),
)
)
return out
@router.post("/connectors/{account_id}/sync", response_model=SyncResultOut) @router.post("/connectors/{account_id}/sync", response_model=SyncResultOut)
def sync_one(account_id: str, full: bool = False, s: Session = Depends(get_session)): def sync_one(account_id: str, full: bool = False, s: Session = Depends(get_session)):
a = s.get(ConnectorAccount, account_id) a = s.get(ConnectorAccount, account_id)
@ -42,6 +76,12 @@ def sync_all(s: Session = Depends(get_session)):
# ── OAuth ── # ── OAuth ──
def _with_query(path: str, extra: str) -> str:
"""redirect_after 경로에 쿼리 안전 append('/settings?tab=mail' + 'connect=ok')."""
sep = "&" if "?" in path else "?"
return f"{path}{sep}{extra}"
@router.get("/connectors/oauth/start", response_model=OAuthStartOut) @router.get("/connectors/oauth/start", response_model=OAuthStartOut)
def oauth_start( def oauth_start(
domain: str, provider: str, redirect_after: str = "/life", s: Session = Depends(get_session) domain: str, provider: str, redirect_after: str = "/life", s: Session = Depends(get_session)
@ -49,24 +89,56 @@ def oauth_start(
cfg = get_settings() cfg = get_settings()
if provider in ("gmail", "google_calendar") and not cfg.google_client_id: if provider in ("gmail", "google_calendar") and not cfg.google_client_id:
raise HTTPException(400, "google_client_id 미설정 — real 연동 비활성") raise HTTPException(400, "google_client_id 미설정 — real 연동 비활성")
if provider in ("outlook", "outlook_calendar") and not cfg.microsoft_client_id:
raise HTTPException(400, "microsoft_client_id 미설정 — real 연동 비활성")
if provider == "notion" and not cfg.notion_client_id: if provider == "notion" and not cfg.notion_client_id:
raise HTTPException(400, "notion_client_id 미설정 — real 연동 비활성") raise HTTPException(400, "notion_client_id 미설정 — real 연동 비활성")
url = oauthlib.start_oauth(s, domain, provider, redirect_after) url = oauthlib.start_oauth(s, domain, provider, redirect_after)
return OAuthStartOut(authorize_url=url) return OAuthStartOut(authorize_url=url)
def _initial_sync(account_id: str) -> None:
"""연결 직후 초기 full sync — 백그라운드 실행(대량 메일도 콜백 응답을 막지 않음)."""
with Session(engine) as bs:
acct = bs.get(ConnectorAccount, account_id)
if not acct:
return
targets = [acct]
# 메일과 함께 연결된 캘린더 계정(같은 이메일)도 초기 sync.
sibling_provider = {"gmail": "google_calendar", "outlook": "outlook_calendar"}.get(
acct.provider
)
if sibling_provider and acct.external_account_id:
sib = bs.exec(
select(ConnectorAccount).where(
ConnectorAccount.provider == sibling_provider,
ConnectorAccount.external_account_id == acct.external_account_id,
)
).first()
if sib:
targets.append(sib)
for a in targets:
try:
ConnectorRegistry.get(bs, a).sync(bs, full=True)
except Exception:
pass
@router.get("/connectors/oauth/callback") @router.get("/connectors/oauth/callback")
def oauth_callback(code: str, state: str, s: Session = Depends(get_session)): def oauth_callback(
code: str, state: str, bg: BackgroundTasks, s: Session = Depends(get_session)
):
origin = get_settings().frontend_origin origin = get_settings().frontend_origin
os_row = s.get(OAuthState, state)
redirect_after = os_row.redirect_after if os_row else "/life"
try: try:
acct = oauthlib.finish_oauth(s, code, state) acct = oauthlib.finish_oauth(s, code, state)
except Exception: except Exception:
return RedirectResponse(url=f"{origin}/life?connect=error") return RedirectResponse(url=f"{origin}{_with_query(redirect_after, 'connect=error')}")
try: # 초기 sync 는 백그라운드로(받은편지함이 크면 수십 초 소요 → 콜백은 즉시 리다이렉트).
ConnectorRegistry.get(s, acct).sync(s, full=True) # 연결 직후 1회 초기 sync bg.add_task(_initial_sync, acct.id)
except Exception: q = f"connect=ok&domain={acct.domain.value}&provider={acct.provider}"
pass return RedirectResponse(url=f"{origin}{_with_query(redirect_after, q)}")
return RedirectResponse(url=f"{origin}/life?connect=ok&domain={acct.domain.value}")
@router.post("/connectors/{account_id}/disconnect", response_model=ConnectorStatusOut) @router.post("/connectors/{account_id}/disconnect", response_model=ConnectorStatusOut)

@ -5,7 +5,6 @@ from sqlmodel import Session
from .. import runtime_config from .. import runtime_config
from ..config import get_settings from ..config import get_settings
from ..db import get_session from ..db import get_session
from ..llm.heuristic import HeuristicProvider
from ..llm.ollama import OllamaProvider from ..llm.ollama import OllamaProvider
from ..llm.provider import LLMProvider from ..llm.provider import LLMProvider
from ..schemas import ( from ..schemas import (
@ -35,14 +34,9 @@ def get_system(s: Session = Depends(get_session)):
database=_db_dialect(st.database_url), database=_db_dialect(st.database_url),
sync_interval_minutes=st.sync_interval_minutes, sync_interval_minutes=st.sync_interval_minutes,
sync_page_size=st.sync_page_size, sync_page_size=st.sync_page_size,
web_search_provider=st.web_search_provider,
connector_modes={ connector_modes={
"calendar": st.connector_calendar, "calendar": st.connector_calendar,
"mail": st.connector_mail, "mail": st.connector_mail,
"chat": st.connector_chat,
"finance": st.connector_finance,
"health": st.connector_health,
"knowledge": st.connector_knowledge,
}, },
) )
@ -56,9 +50,6 @@ def _llm_config(s: Session) -> LLMConfigOut:
host=eff.host, host=eff.host,
timeout=eff.timeout, timeout=eff.timeout,
overridden=settings_service.overlay_active(s), overridden=settings_service.overlay_active(s),
embed_provider=st.embed_provider,
embed_model=st.embed_model,
agent_provider=st.agent_provider,
stt_provider=st.stt_provider, stt_provider=st.stt_provider,
vision_provider=st.vision_provider, vision_provider=st.vision_provider,
) )
@ -90,13 +81,8 @@ def test_llm(body: LLMTestIn):
eff = runtime_config.effective_llm() eff = runtime_config.effective_llm()
provider = body.provider or eff.provider provider = body.provider or eff.provider
impl: LLMProvider # 실 LLM 전용(휴리스틱 제거): 항상 Ollama 도달성으로 테스트.
if provider == "heuristic": impl: LLMProvider = OllamaProvider(host=body.host or eff.host, model=body.model or eff.model)
impl = HeuristicProvider()
else:
# auto/ollama 모두 ollama 핑으로 테스트(auto 는 실제로 ollama 도달성 확인이 핵심)
impl = OllamaProvider(host=body.host or eff.host, model=body.model or eff.model)
h = impl.health() h = impl.health()
return LLMTestOut( return LLMTestOut(
reachable=bool(h.get("reachable")), reachable=bool(h.get("reachable")),

@ -12,12 +12,14 @@ from ..models import Person, Project, Task, TaskComment, TaskStatus
from ..schemas import ( from ..schemas import (
CommentCreate, CommentCreate,
CommentOut, CommentOut,
CommentPatch,
RiskOut, RiskOut,
ScaffoldOut, ScaffoldOut,
ScaffoldRequest, ScaffoldRequest,
TaskCreate, TaskCreate,
TaskNode, TaskNode,
TaskPatch, TaskPatch,
TaskReorderRequest,
) )
from ..services.risk import compute_risks from ..services.risk import compute_risks
from ..services.scaffold import pick_scaffold, scaffold_create from ..services.scaffold import pick_scaffold, scaffold_create
@ -186,6 +188,21 @@ def delete_task(tid: str, s: Session = Depends(get_session)):
return {"deleted": tid} return {"deleted": tid}
@router.post("/tasks/reorder")
def reorder_tasks(body: TaskReorderRequest, s: Session = Depends(get_session)):
"""같은 컬럼 내 드래그 정렬 — 받은 순서대로 sort_order 재배치."""
n = 0
for i, tid in enumerate(body.ids):
t = s.get(Task, tid)
if t:
t.sort_order = i
t.updated_at = datetime.now(UTC)
s.add(t)
n += 1
s.commit()
return {"ok": True, "count": n}
@router.post("/tasks/{tid}/comments", response_model=CommentOut) @router.post("/tasks/{tid}/comments", response_model=CommentOut)
def add_comment(tid: str, body: CommentCreate, s: Session = Depends(get_session)): def add_comment(tid: str, body: CommentCreate, s: Session = Depends(get_session)):
if not s.get(Task, tid): if not s.get(Task, tid):
@ -199,6 +216,29 @@ def add_comment(tid: str, body: CommentCreate, s: Session = Depends(get_session)
return CommentOut.model_validate(c, from_attributes=True) return CommentOut.model_validate(c, from_attributes=True)
@router.patch("/tasks/{tid}/comments/{cid}", response_model=CommentOut)
def edit_comment(tid: str, cid: str, body: CommentPatch, s: Session = Depends(get_session)):
c = s.get(TaskComment, cid)
if not c or c.task_id != tid:
raise HTTPException(404, "comment not found")
c.text = body.text
c.edited_at = datetime.now(UTC)
s.add(c)
s.commit()
s.refresh(c)
return CommentOut.model_validate(c, from_attributes=True)
@router.delete("/tasks/{tid}/comments/{cid}")
def delete_comment(tid: str, cid: str, s: Session = Depends(get_session)):
c = s.get(TaskComment, cid)
if not c or c.task_id != tid:
raise HTTPException(404, "comment not found")
s.delete(c)
s.commit()
return {"ok": True, "id": cid}
@router.post("/tasks/{tid}/scaffold", response_model=ScaffoldOut) @router.post("/tasks/{tid}/scaffold", response_model=ScaffoldOut)
def scaffold(tid: str, body: ScaffoldRequest, s: Session = Depends(get_session)): def scaffold(tid: str, body: ScaffoldRequest, s: Session = Depends(get_session)):
t = s.get(Task, tid) t = s.get(Task, tid)

@ -5,10 +5,10 @@ def test_dashboard(client):
assert d["briefing"]["weather"]["temp"] == 24 assert d["briefing"]["weather"]["temp"] == 24
assert d["briefing"]["weather"]["icon"] == "sun" # cloudSun 저장 → sun 표시 assert d["briefing"]["weather"]["icon"] == "sun" # cloudSun 저장 → sun 표시
assert d["saved_today"] == "47분" and d["today_routed"] == 7 assert d["saved_today"] == "47분" and d["today_routed"] == 7
assert any(e["soon"] for e in d["schedule"]) # 14:00 분기 전략 미팅 soon assert isinstance(d["schedule"], list) # phase-16+: 일정 시드 제거 → 빈 일정(연결 전)
assert d["badges"]["appr"] == 4 # high risk 3건 + 심부름 게이트(phase-14) assert d["badges"]["appr"] == 3 # high risk 3건
assert d["badges"]["noti"] == 6 assert d["badges"]["noti"] == 6
assert d["task_summary"]["open_count"] > 0 assert d["task_summary"]["open_count"] == 0 # 작업 시드 제거 → 빈 상태
assert len(d["approvals_summary"]) <= 3 # high-risk 만, 최대 3건 assert len(d["approvals_summary"]) <= 3 # high-risk 만, 최대 3건
assert d["goals"][0]["tone"] == "blue" # tone 키('var(--blue)' 아님) assert d["goals"][0]["tone"] == "blue" # tone 키('var(--blue)' 아님)
# inbox_recent 는 평탄화 형태 {id,kind,raw,type,proj_label,tone} # inbox_recent 는 평탄화 형태 {id,kind,raw,type,proj_label,tone}

@ -28,12 +28,12 @@ def test_dismiss(client):
assert client.post(f"/api/inbox/{iid}/dismiss").json()["status"] == "dismissed" assert client.post(f"/api/inbox/{iid}/dismiss").json()["status"] == "dismissed"
def test_capture_model_is_heuristic(client): def test_capture_model_is_fake(client):
# 테스트는 휴리스틱 강제 → model 표기 heuristic # 테스트는 결정적 FakeLLM 주입(conftest) → model 표기 fake
cap = client.post( cap = client.post(
"/api/inbox/capture", json={"kind": "text", "raw": "수요일 11시 자전거 수리"} "/api/inbox/capture", json={"kind": "text", "raw": "수요일 11시 자전거 수리"}
).json() ).json()
assert cap["classification"]["model"] == "heuristic" assert cap["classification"]["model"] == "fake"
def test_confirm_event_no_task(client): def test_confirm_event_no_task(client):

@ -1,29 +1,46 @@
def test_tasks_nested_and_filter(client): # backend/tests/test_api_tasks.py — 작업 API (phase-16+: 작업 시드 제거 → 테스트가 직접 생성)
from tests._factories import make_comment, make_task
def test_tasks_nested_and_filter(client, session):
s, _ = session
make_task(s, id="w1", project_id="biz-report", title="부모 작업")
make_task(s, id="w1a", project_id="biz-report", parent_id="w1", title="자식 작업")
make_comment(s, id="cm1", task_id="w1")
make_comment(s, id="cm2", task_id="w1")
make_task(s, id="l1", project_id="me", title="라이프 작업")
work = client.get("/api/tasks?area=work").json() work = client.get("/api/tasks?area=work").json()
k1 = next(t for t in work if t["id"] == "k1") w1 = next(t for t in work if t["id"] == "w1")
assert k1["children"], "k1 has subtasks" assert w1["children"], "w1 has subtasks"
assert len(k1["comments"]) == 2 assert len(w1["comments"]) == 2
life = client.get("/api/tasks?area=life").json() life = client.get("/api/tasks?area=life").json()
assert {t["id"] for t in life} >= {"k4", "k20", "k21"} assert {t["id"] for t in life} >= {"l1"}
def test_task_status_move(client): def test_task_status_move(client, session):
r = client.patch("/api/tasks/k1", json={"status": "review"}).json() s, _ = session
make_task(s, id="t1", project_id="biz-report")
r = client.patch("/api/tasks/t1", json={"status": "review"}).json()
assert r["status"] == "review" assert r["status"] == "review"
def test_task_comment(client): def test_task_comment(client, session):
s, _ = session
make_task(s, id="t1", project_id="biz-report")
c = client.post( c = client.post(
"/api/tasks/k1/comments", json={"person_id": "minseo", "text": "확인했습니다"} "/api/tasks/t1/comments", json={"person_id": "minseo", "text": "확인했습니다"}
).json() ).json()
assert c["text"] == "확인했습니다" assert c["text"] == "확인했습니다"
def test_task_filter_project_subtree(client): def test_task_filter_project_subtree(client, session):
# biz 프로젝트(+하위)의 작업: k1(biz-report), k6(biz-okr) 등 포함 s, _ = session
make_task(s, id="a1", project_id="biz-report")
make_task(s, id="a2", project_id="biz-okr")
r = client.get("/api/tasks?project_id=biz").json() r = client.get("/api/tasks?project_id=biz").json()
ids = {t["id"] for t in r} ids = {t["id"] for t in r}
assert "k1" in ids and "k6" in ids assert "a1" in ids and "a2" in ids # biz(+하위) 서브트리
def test_task_create_and_delete(client): def test_task_create_and_delete(client):
@ -33,18 +50,24 @@ def test_task_create_and_delete(client):
assert client.delete(f"/api/tasks/{tid}").json()["deleted"] == tid assert client.delete(f"/api/tasks/{tid}").json()["deleted"] == tid
def test_create_subtask_parent(client): def test_create_subtask_parent(client, session):
body = {"title": "새 하위", "project_id": "biz-report", "parent_id": "k1", "status": "todo"} s, _ = session
make_task(s, id="p1", project_id="biz-report", title="부모")
body = {"title": "새 하위", "project_id": "biz-report", "parent_id": "p1", "status": "todo"}
child = client.post("/api/tasks", json=body).json() child = client.post("/api/tasks", json=body).json()
parent = client.get("/api/tasks/k1").json() parent = client.get("/api/tasks/p1").json()
assert any(c["id"] == child["id"] for c in parent["children"]) assert any(c["id"] == child["id"] for c in parent["children"])
def test_patch_status_persists(client): def test_patch_status_persists(client, session):
client.patch("/api/tasks/k4", json={"status": "done"}) s, _ = session
assert client.get("/api/tasks/k4").json()["status"] == "done" make_task(s, id="t1", project_id="me")
client.patch("/api/tasks/t1", json={"status": "done"})
assert client.get("/api/tasks/t1").json()["status"] == "done"
def test_cannot_parent_to_self_or_descendant(client): def test_cannot_parent_to_self_or_descendant(client, session):
s, _ = session
make_task(s, id="t1", project_id="biz-report")
# 자기 자신을 부모로 → 400 (순환 방지) # 자기 자신을 부모로 → 400 (순환 방지)
assert client.patch("/api/tasks/k1", json={"parent_id": "k1"}).status_code == 400 assert client.patch("/api/tasks/t1", json={"parent_id": "t1"}).status_code == 400

@ -2,8 +2,8 @@
def test_derive_queue_mixed(client): def test_derive_queue_mixed(client):
q = client.get("/api/approvals").json() q = client.get("/api/approvals").json()
assert q["autonomy"] == "mixed" assert q["autonomy"] == "mixed"
# high 3(phase-7) + ap-er1(phase-14 심부름 게이트) = 4 대기, low 3 자동 # high 3(phase-7) 대기, low 3 자동
assert len(q["pending"]) == 4 and len(q["done"]) == 3 assert len(q["pending"]) == 3 and len(q["done"]) == 3
assert {a["risk"] for a in q["pending"]} == {"high"} assert {a["risk"] for a in q["pending"]} == {"high"}
assert {a["risk"] for a in q["done"]} == {"low"} assert {a["risk"] for a in q["done"]} == {"low"}
@ -11,14 +11,14 @@ def test_derive_queue_mixed(client):
def test_autonomy_approval_first_all_pending(client): def test_autonomy_approval_first_all_pending(client):
client.patch("/api/approvals/autonomy", json={"level": "approval_first"}) client.patch("/api/approvals/autonomy", json={"level": "approval_first"})
q = client.get("/api/approvals").json() q = client.get("/api/approvals").json()
assert len(q["pending"]) == 7 and len(q["done"]) == 0 # +ap-er1(phase-14) assert len(q["pending"]) == 6 and len(q["done"]) == 0
assert q["badges_appr"] == 7 assert q["badges_appr"] == 6
def test_autonomy_full_auto_empty_queue(client): def test_autonomy_full_auto_empty_queue(client):
client.patch("/api/approvals/autonomy", json={"level": "full_auto"}) client.patch("/api/approvals/autonomy", json={"level": "full_auto"})
q = client.get("/api/approvals").json() q = client.get("/api/approvals").json()
assert len(q["pending"]) == 0 and len(q["done"]) == 7 # 빈 큐(+ap-er1) → appr-empty assert len(q["pending"]) == 0 and len(q["done"]) == 6 # 빈 큐 → appr-empty
def test_invalid_autonomy_422(client): def test_invalid_autonomy_422(client):
@ -37,7 +37,7 @@ def test_approve_and_undo_cycle(client):
def test_approve_all(client): def test_approve_all(client):
n = client.post("/api/approvals/approve-all").json()["approved"] n = client.post("/api/approvals/approve-all").json()["approved"]
assert n == 4 # mixed 의 high 3건 + 심부름 게이트 1건(phase-14) assert n == 3 # mixed 의 high 3건
q = client.get("/api/approvals").json() q = client.get("/api/approvals").json()
assert len(q["pending"]) == 0 assert len(q["pending"]) == 0

@ -1,7 +1,9 @@
# backend/tests/test_connector_routes.py — 라우터 + 로컬 우선 임포트(C10 CSV / C11 ics / C12 disconnect) # backend/tests/test_connector_routes.py — 라우터 + 로컬 우선 임포트(C10 CSV / C11 ics / C12 disconnect)
# phase-16+: 메일/일정 mock 계정 시드 제거 → 해당 테스트는 계정을 직접 생성.
import pytest import pytest
from app.config import get_settings from app.config import get_settings
from app.models import ConnectorAccount, ConnectorDomain, ConnState
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@ -10,44 +12,12 @@ def _clear_settings():
get_settings.cache_clear() get_settings.cache_clear()
def test_list_connectors_seed(client): def test_ics_import_calendar(client, session, monkeypatch):
rows = client.get("/api/connectors").json() # C11: .ics → event (OAuth 불필요). 일정 계정을 직접 생성.
assert len(rows) == 14 # mail3+cal1+health3+fin4+kn3 s, _ = session
fit = next(r for r in rows if r["id"] == "ca-health-fit") s.add(ConnectorAccount(id="ca-cal-google", domain=ConnectorDomain.calendar,
assert fit["on"] is False and fit["last"] == "연결 안 됨" provider="google_calendar", name="Google 캘린더"))
woori = next(r for r in rows if r["id"] == "ca-fin-woori") s.commit()
assert woori["on"] is True and woori["tone"] == "blue" and woori["kind"] == "신용/체크"
def test_list_filter_by_domain(client):
health = client.get("/api/connectors?domain=health").json()
assert {r["id"] for r in health} == {"ca-health-apple", "ca-health-watch", "ca-health-fit"}
def test_csv_import_finance(client, monkeypatch):
# C10: 우리카드 CSV → finance_tx (OAuth 불필요). 빈 행은 skip+errors.
monkeypatch.setenv("CONNECTOR_FINANCE", "csv")
get_settings.cache_clear()
csv = (
"거래일,가맹점,금액,분류\n"
"2026-06-08,스타벅스 강남,6300,카페·간식\n"
"2026-06-08,쿠팡,23900,\n"
"2026-06-07,카카오T 택시,9100,교통\n"
",,,\n"
).encode()
r = client.post(
"/api/connectors/ca-fin-woori/import",
files={"file": ("woori.csv", csv, "text/csv")},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["entity_type"] == "finance_tx"
assert body["imported"] == 3
assert body["errors"] >= 1 # 빈 행
def test_ics_import_calendar(client, monkeypatch):
# C11: .ics → event (OAuth 불필요).
monkeypatch.setenv("CONNECTOR_CALENDAR", "ics") monkeypatch.setenv("CONNECTOR_CALENDAR", "ics")
get_settings.cache_clear() get_settings.cache_clear()
ics = ( ics = (
@ -70,31 +40,18 @@ def test_ics_import_calendar(client, monkeypatch):
assert body["entity_type"] == "event" and body["imported"] == 1 assert body["entity_type"] == "event" and body["imported"] == 1
def test_import_unsupported_connector_400(client): def test_disconnect_purges_token(client, session):
# 기본 mock 금융은 import_bytes 미지원 → 400 # C12: 해제 → 토큰 폐기 + state disconnected. 연결된 메일 계정을 직접 생성.
r = client.post( s, _ = session
"/api/connectors/ca-fin-toss/import", s.add(ConnectorAccount(id="ca-mail-work", domain=ConnectorDomain.mail, provider="gmail",
files={"file": ("x.csv", b"a,b\n1,2\n", "text/csv")}, state=ConnState.connected, external_account_id="me@gmail.com",
) token_enc="enc", name="me@gmail.com"))
assert r.status_code == 400 s.commit()
def test_disconnect_purges_token(client):
# C12: 해제 → 토큰 폐기 + state disconnected (기존 데이터는 보존)
r = client.post("/api/connectors/ca-mail-work/disconnect").json() r = client.post("/api/connectors/ca-mail-work/disconnect").json()
assert r["state"] == "disconnected" and r["on"] is False and r["last"] == "연결 안 됨" assert r["state"] == "disconnected" and r["on"] is False and r["last"] == "연결 안 됨"
# 메일은 그대로 남아있음
assert len(client.get("/api/mail").json()) > 0 or True
def test_oauth_start_without_credentials_400(client): def test_oauth_start_without_credentials_400(client):
# GOOGLE_CLIENT_ID 미설정 → 400 (앱 안 죽음) # GOOGLE_CLIENT_ID 미설정 → 400 (앱 안 죽음)
r = client.get("/api/connectors/oauth/start?domain=mail&provider=gmail") r = client.get("/api/connectors/oauth/start?domain=mail&provider=gmail")
assert r.status_code == 400 assert r.status_code == 400
def test_sync_all_skips_disconnected(client):
out = client.post("/api/connectors/sync-all").json()
ids = {x["account_id"] for x in out}
assert "ca-health-fit" not in ids # disconnected 는 건너뜀
assert "ca-kb" not in ids

@ -72,6 +72,6 @@ def test_goals_three_with_tone_key(client):
def test_badges_match_counts(client): def test_badges_match_counts(client):
d = client.get("/api/dashboard").json() d = client.get("/api/dashboard").json()
b = d["badges"] b = d["badges"]
assert b["appr"] == 4 # high 3 + 심부름 게이트 1(phase-14) assert b["appr"] == 3 # high 3건
assert b["task"] == d["task_summary"]["open_count"] assert b["task"] == d["task_summary"]["open_count"]
assert b["noti"] == 6 assert b["noti"] == 6

@ -8,43 +8,36 @@ def test_system_snapshot_readonly(client):
assert d["env"] == "dev" assert d["env"] == "dev"
assert d["auth_enabled"] is False # 데모 기본 off assert d["auth_enabled"] is False # 데모 기본 off
assert d["database"] == "sqlite" assert d["database"] == "sqlite"
assert set(d["connector_modes"]) == { assert set(d["connector_modes"]) == {"calendar", "mail"}
"calendar",
"mail",
"chat",
"finance",
"health",
"knowledge",
}
# 기본은 전부 mock # 기본은 전부 mock
assert all(v == "mock" for v in d["connector_modes"].values()) assert all(v == "mock" for v in d["connector_modes"].values())
def test_llm_config_defaults_match_env(client): def test_llm_config_defaults_match_env(client):
d = client.get("/api/settings/llm").json() d = client.get("/api/settings/llm").json()
assert d["provider"] in ("auto", "ollama", "heuristic") assert d["provider"] == "ollama"
assert d["overridden"] is False # 오버레이 없음 → env 기본 assert d["overridden"] is False # 오버레이 없음 → env 기본
assert "auto" in d["provider_options"] assert "ollama" in d["provider_options"]
# 보조 프로바이더 노출 # 보조 프로바이더 노출
assert "embed_provider" in d and "stt_provider" in d assert "stt_provider" in d and "vision_provider" in d
def test_llm_update_persists_and_applies(client): def test_llm_update_persists_and_applies(client):
# heuristic 으로 저장 → 영속 + 런타임 반영 # ollama + 커스텀 모델로 저장 → 영속 + 런타임 반영
r = client.put("/api/settings/llm", json={"provider": "heuristic", "model": "my-model"}) r = client.put("/api/settings/llm", json={"provider": "ollama", "model": "my-model"})
assert r.status_code == 200 assert r.status_code == 200
body = r.json() body = r.json()
assert body["provider"] == "heuristic" assert body["provider"] == "ollama"
assert body["model"] == "my-model" assert body["model"] == "my-model"
assert body["overridden"] is True assert body["overridden"] is True
# 다시 GET 해도 유지(영속) # 다시 GET 해도 유지(영속)
again = client.get("/api/settings/llm").json() again = client.get("/api/settings/llm").json()
assert again["provider"] == "heuristic" and again["model"] == "my-model" assert again["provider"] == "ollama" and again["model"] == "my-model"
# 런타임 오버레이가 실제 provider 선택에 반영되는가(force 없이) # 런타임 오버레이가 실제 provider 선택에 반영되는가(force 없이)
assert runtime_config.effective_llm().provider == "heuristic" assert runtime_config.effective_llm().provider == "ollama"
assert get_provider().name == "heuristic" assert get_provider().name == "ollama"
def test_llm_update_reset_to_default(client): def test_llm_update_reset_to_default(client):
@ -62,11 +55,13 @@ def test_llm_update_rejects_bad_provider(client):
assert r.status_code == 400 assert r.status_code == 400
def test_llm_test_heuristic_reachable(client): def test_llm_test_uses_ollama(client):
r = client.post("/api/settings/llm/test", json={"provider": "heuristic"}) # phase-16+: 휴리스틱 옵션 제거 → 연결 테스트는 항상 Ollama 도달성으로 확인.
r = client.post("/api/settings/llm/test", json={"provider": "ollama"})
assert r.status_code == 200 assert r.status_code == 200
d = r.json() d = r.json()
assert d["reachable"] is True and d["provider"] == "heuristic" assert d["provider"] == "ollama"
assert isinstance(d["reachable"], bool) # 실 Ollama 가동 여부에 따라 달라짐(환경 의존)
def test_profile_update(client): def test_profile_update(client):

@ -2,6 +2,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { onest, dmMono } from "./fonts"; import { onest, dmMono } from "./fonts";
import { ThemeProvider } from "@/components/ThemeProvider"; import { ThemeProvider } from "@/components/ThemeProvider";
import { DialogProvider } from "@/components/Dialog";
import { Shell } from "@/components/Shell"; import { Shell } from "@/components/Shell";
import "@/styles/globals.css"; import "@/styles/globals.css";
@ -16,7 +17,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<html lang="ko" suppressHydrationWarning className={`${onest.variable} ${dmMono.variable}`}> <html lang="ko" suppressHydrationWarning className={`${onest.variable} ${dmMono.variable}`}>
<body> <body>
<ThemeProvider> <ThemeProvider>
<DialogProvider>
<Shell>{children}</Shell> <Shell>{children}</Shell>
</DialogProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>

@ -0,0 +1,8 @@
// frontend/app/tasks/[[...slug]]/page.tsx — 작업 페이지 (옵셔널 캐치올: /tasks, /tasks/<id>)
// <id> 세그먼트는 TasksClient 가 useParams() 로 읽어 해당 작업 상세를 바로 연다.
import "@/styles/tasks.css";
import { TasksClient } from "@/components/tasks/TasksClient";
export default function TasksPage() {
return <TasksClient />;
}

@ -1,7 +0,0 @@
// frontend/app/tasks/page.tsx
import "@/styles/tasks.css";
import { TasksClient } from "@/components/tasks/TasksClient";
export default function TasksPage() {
return <TasksClient />;
}

@ -0,0 +1,127 @@
// frontend/components/Dialog.tsx
// 앱 전역 통합 다이얼로그 — window.confirm/alert 대체.
// useDialog().confirm({...}) → Promise<boolean>, .alert({...}) → Promise<void>.
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
type DialogTone = "danger" | "primary";
export type DialogOptions = {
title?: string;
message?: string;
confirmText?: string;
cancelText?: string; // confirm 전용
tone?: DialogTone;
};
type DialogState = DialogOptions & { kind: "confirm" | "alert" };
type DialogApi = {
confirm: (opts: DialogOptions) => Promise<boolean>;
alert: (opts: DialogOptions) => Promise<void>;
};
const Ctx = createContext<DialogApi | null>(null);
export function useDialog(): DialogApi {
const c = useContext(Ctx);
if (!c) throw new Error("useDialog must be used within <DialogProvider>");
return c;
}
function DialogModal({
state,
onClose,
}: {
state: DialogState;
onClose: (ok: boolean) => void;
}) {
const isConfirm = state.kind === "confirm";
const tone: DialogTone = state.tone ?? (isConfirm ? "danger" : "primary");
const okRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
okRef.current?.focus();
const h = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose(false);
} else if (e.key === "Enter") {
e.preventDefault();
onClose(true);
}
};
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [onClose]);
return (
<div className="dlg-backdrop" onClick={() => onClose(false)} role="presentation">
<div
className="dlg-card"
role="alertdialog"
aria-modal="true"
aria-label={state.title ?? "확인"}
onClick={(e) => e.stopPropagation()}
>
{state.title && <h2 className="dlg-title">{state.title}</h2>}
{state.message && <p className="dlg-msg">{state.message}</p>}
<div className="dlg-actions">
{isConfirm && (
<button className="dlg-btn ghost" onClick={() => onClose(false)}>
{state.cancelText ?? "취소"}
</button>
)}
<button
ref={okRef}
className={"dlg-btn " + tone}
onClick={() => onClose(true)}
>
{state.confirmText ?? "확인"}
</button>
</div>
</div>
</div>
);
}
export function DialogProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<DialogState | null>(null);
const resolver = useRef<((ok: boolean) => void) | null>(null);
const close = useCallback((ok: boolean) => {
resolver.current?.(ok);
resolver.current = null;
setState(null);
}, []);
const confirm = useCallback(
(opts: DialogOptions) =>
new Promise<boolean>((resolve) => {
resolver.current = resolve;
setState({ kind: "confirm", ...opts });
}),
[],
);
const alert = useCallback(
(opts: DialogOptions) =>
new Promise<void>((resolve) => {
resolver.current = () => resolve();
setState({ kind: "alert", ...opts });
}),
[],
);
return (
<Ctx.Provider value={{ confirm, alert }}>
{children}
{state && <DialogModal state={state} onClose={close} />}
</Ctx.Provider>
);
}

@ -0,0 +1,81 @@
// frontend/components/connectors/AddAccountMenu.tsx — "계정 추가" OAuth 진입(phase-16)
// providers() 로 추가 가능한 provider를 받아 메뉴로 보여주고, 선택 시 OAuth 동의 화면으로 리다이렉트.
"use client";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/Button";
import { connectorsApi } from "@/lib/connectors/api";
import type { ConnectorProvider } from "@/lib/types";
export function AddAccountMenu({
domain,
redirectAfter,
onError,
}: {
domain: string;
redirectAfter: string;
onError?: (msg: string) => void;
}) {
const [providers, setProviders] = useState<ConnectorProvider[]>([]);
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
connectorsApi
.providers(domain)
.then(setProviders)
.catch(() => setProviders([]));
}, [domain]);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
if (providers.length === 0) return null;
const connect = async (p: ConnectorProvider) => {
setOpen(false);
try {
const { authorize_url } = await connectorsApi.oauthStart(domain, p.provider, redirectAfter);
window.location.assign(authorize_url); // 외부 OAuth 동의 화면으로 이동
} catch {
onError?.(`${p.label} 연결을 시작하지 못했어요`);
}
};
return (
<div className="cn-add" ref={ref}>
<Button
variant="lime"
icon="plus"
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
>
</Button>
{open && (
<div className="cn-add-menu" role="menu" aria-label="추가할 계정 제공자">
{providers.map((p) => (
<button
key={p.provider}
type="button"
role="menuitem"
className="cn-add-item"
disabled={!p.configured}
onClick={() => connect(p)}
title={p.configured ? "" : "관리자 설정이 필요해요 (client_id 미설정)"}
>
<span>{p.label}</span>
{!p.configured && <em className="cn-add-hint"> </em>}
</button>
))}
</div>
)}
</div>
);
}

@ -43,7 +43,7 @@ export default function CaptureRow({
) : c.classification ? ( ) : c.classification ? (
<> <>
<RouteChips r={c.classification} editable={canAct} onReType={() => onReType(c.id)} /> <RouteChips r={c.classification} editable={canAct} onReType={() => onReType(c.id)} />
<ReasonLine reason={c.classification.reason} fallbackUsed={c.fallbackUsed} /> <ReasonLine reason={c.classification.reason} />
{canAct && ( {canAct && (
<div className="sb-acts"> <div className="sb-acts">
<button className="sb-ok" onClick={() => onConfirm(c.id)}> <button className="sb-ok" onClick={() => onConfirm(c.id)}>

@ -68,7 +68,6 @@ export default function InboxView({
status: "classified", status: "classified",
time: "방금", time: "방금",
fresh: true, fresh: true,
fallbackUsed: classification.model === "heuristic",
} }
: x, : x,
), ),
@ -103,7 +102,6 @@ export default function InboxView({
status: "classified", status: "classified",
time: x.time, time: x.time,
fresh: x.fresh, fresh: x.fresh,
fallbackUsed: classification.model === "heuristic",
} }
: x, : x,
), ),

@ -1,22 +1,4 @@
// frontend/components/inbox/ReasonLine.tsx // frontend/components/inbox/ReasonLine.tsx
import { Icon } from "@/components/Icon"; export default function ReasonLine({ reason }: { reason: string }) {
return <div className="sb-reason">{reason}</div>;
export default function ReasonLine({
reason,
fallbackUsed,
}: {
reason: string;
fallbackUsed?: boolean;
}) {
return (
<div className="sb-reason">
{fallbackUsed && (
<span className="sb-fallback" title="LLM에 연결할 수 없어 규칙 기반으로 분류했어요">
<Icon name="zap" />
()
</span>
)}
{reason}
</div>
);
} }

@ -12,7 +12,9 @@ export function ConnectionsTab({ toast }: { toast: ToastFn }) {
filter={(c) => c.domain !== "mail"} filter={(c) => c.domain !== "mail"}
toast={toast} toast={toast}
showSyncAll showSyncAll
emptyText="연결된 데이터 소스가 없어요." emptyText="연결된 데이터 소스가 없어요. ‘계정 추가’로 Google 캘린더를 연결하세요."
addDomain="calendar"
redirectAfter="/settings?tab=connect"
/> />
); );
} }

@ -3,6 +3,7 @@
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Icon } from "@/components/Icon"; import { Icon } from "@/components/Icon";
import type { IconName } from "@/components/icons/paths"; import type { IconName } from "@/components/icons/paths";
import { AddAccountMenu } from "@/components/connectors/AddAccountMenu";
import { ConnectorCard } from "@/components/connectors/ConnectorCard"; import { ConnectorCard } from "@/components/connectors/ConnectorCard";
import { connectorsApi } from "@/lib/connectors/api"; import { connectorsApi } from "@/lib/connectors/api";
import { useConnectors } from "@/lib/hooks/useConnectors"; import { useConnectors } from "@/lib/hooks/useConnectors";
@ -17,6 +18,8 @@ export function ConnectorList({
toast, toast,
showSyncAll = false, showSyncAll = false,
emptyText = "표시할 연결이 없어요.", emptyText = "표시할 연결이 없어요.",
addDomain,
redirectAfter = "/settings",
}: { }: {
title: string; title: string;
subtitle: string; subtitle: string;
@ -25,6 +28,8 @@ export function ConnectorList({
toast: ToastFn; toast: ToastFn;
showSyncAll?: boolean; showSyncAll?: boolean;
emptyText?: string; emptyText?: string;
addDomain?: string; // 설정 시 "계정 추가"(OAuth) 메뉴 노출
redirectAfter?: string; // OAuth 콜백 후 복귀 경로
}) { }) {
const { data, isLoading, error, refresh } = useConnectors(); const { data, isLoading, error, refresh } = useConnectors();
const all = data ?? []; const all = data ?? [];
@ -90,6 +95,13 @@ export function ConnectorList({
</Button> </Button>
)} )}
{addDomain && (
<AddAccountMenu
domain={addDomain}
redirectAfter={redirectAfter}
onError={(m) => toast(m, "coral")}
/>
)}
</div> </div>
</header> </header>

@ -17,7 +17,7 @@ const PROVIDER_LABEL: Record<LlmProvider, string> = {
export function LlmTab({ toast }: { toast: ToastFn }) { export function LlmTab({ toast }: { toast: ToastFn }) {
const { data: cfg, isLoading, refresh } = useLlmConfig(); const { data: cfg, isLoading, refresh } = useLlmConfig();
const [provider, setProvider] = useState<LlmProvider>("auto"); const [provider, setProvider] = useState<LlmProvider>("ollama");
const [model, setModel] = useState(""); const [model, setModel] = useState("");
const [host, setHost] = useState(""); const [host, setHost] = useState("");
const [timeout, setTimeoutVal] = useState(""); const [timeout, setTimeoutVal] = useState("");
@ -104,18 +104,14 @@ export function LlmTab({ toast }: { toast: ToastFn }) {
<div className="set-field"> <div className="set-field">
<label></label> <label></label>
<SegmentToggle <SegmentToggle
options={(cfg?.provider_options ?? ["auto", "ollama", "heuristic"]).map((p) => ({ options={(cfg?.provider_options ?? ["ollama"]).map((p) => ({
id: p, id: p,
label: PROVIDER_LABEL[p as LlmProvider] ?? p, label: PROVIDER_LABEL[p as LlmProvider] ?? p,
}))} }))}
value={provider} value={provider}
onChange={(id) => setProvider(id as LlmProvider)} onChange={(id) => setProvider(id as LlmProvider)}
/> />
<small className="set-hint"> <small className="set-hint"> Ollama .</small>
{provider === "auto" && "Ollama 가 떠 있으면 사용하고, 아니면 규칙 기반으로 폴백해요."}
{provider === "ollama" && "로컬 Ollama 서버를 사용해요."}
{isHeuristic && "외부 모델 없이 규칙으로만 동작해요(오프라인·결정적)."}
</small>
</div> </div>
<div className="set-field"> <div className="set-field">

@ -12,7 +12,9 @@ export function MailTab({ toast }: { toast: ToastFn }) {
filter={(c) => c.domain === "mail"} filter={(c) => c.domain === "mail"}
toast={toast} toast={toast}
showSyncAll showSyncAll
emptyText="연결된 메일 계정이 없어요." emptyText="연결된 메일 계정이 없어요. ‘계정 추가’로 Gmail·Outlook을 연결하세요."
addDomain="mail"
redirectAfter="/settings?tab=mail"
/> />
); );
} }

@ -74,6 +74,26 @@ export function SettingsClient() {
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 2600); setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 2600);
}, []); }, []);
// OAuth 콜백 복귀(?connect=ok|error) → 토스트 후 쿼리 정리(tab 은 보존)
useEffect(() => {
if (typeof window === "undefined") return;
const params = new URLSearchParams(window.location.search);
const connect = params.get("connect");
if (!connect) return;
const labels: Record<string, string> = {
gmail: "Gmail",
outlook: "Outlook",
google_calendar: "Google 캘린더",
outlook_calendar: "Outlook 캘린더",
};
const name = labels[params.get("provider") ?? ""] ?? "계정";
if (connect === "ok") toast(`${name}을(를) 연결했어요`, "green");
else toast("연결에 실패했어요. 다시 시도해 주세요", "coral");
for (const k of ["connect", "domain", "provider"]) params.delete(k);
const qs = params.toString();
window.history.replaceState(null, "", window.location.pathname + (qs ? `?${qs}` : ""));
}, [toast]);
return ( return (
<div className="settingspage" ref={shellRef}> <div className="settingspage" ref={shellRef}>
<div className="set-head"> <div className="set-head">

@ -26,19 +26,34 @@ export function Comments({
people, people,
me, me,
onAdd, onAdd,
onEdit,
onDelete,
}: { }: {
list: TaskComment[]; list: TaskComment[];
people: Record<string, Person>; people: Record<string, Person>;
me: Person; me: Person;
onAdd: (text: string) => void; onAdd: (text: string) => void;
onEdit?: (cid: string, text: string) => void;
onDelete?: (cid: string) => void;
}) { }) {
const [txt, setTxt] = useState(""); const [txt, setTxt] = useState("");
const [editId, setEditId] = useState<string | null>(null);
const [editTxt, setEditTxt] = useState("");
const submit = () => { const submit = () => {
if (txt.trim()) { if (txt.trim()) {
onAdd(txt.trim()); onAdd(txt.trim());
setTxt(""); setTxt("");
} }
}; };
const startEdit = (c: TaskComment) => {
setEditId(c.id);
setEditTxt(c.text);
};
const saveEdit = () => {
if (editId && editTxt.trim()) onEdit?.(editId, editTxt.trim());
setEditId(null);
setEditTxt("");
};
return ( return (
<div className="cmt"> <div className="cmt">
<div className="cmt-list"> <div className="cmt-list">
@ -47,6 +62,8 @@ export function Comments({
)} )}
{list.map((c) => { {list.map((c) => {
const p = people[c.person_id] || me; const p = people[c.person_id] || me;
const mine = c.person_id === me.id;
const editing = editId === c.id;
return ( return (
<div className="cmt-row" key={c.id}> <div className="cmt-row" key={c.id}>
<div className="av" style={{ background: p.color }}> <div className="av" style={{ background: p.color }}>
@ -58,9 +75,46 @@ export function Comments({
{p.name} {p.name}
{p.is_me ? " (나)" : ""} {p.is_me ? " (나)" : ""}
</b> </b>
<span className="t">{relTime(c.created_at)}</span> <span className="t">
{relTime(c.created_at)}
{c.edited_at ? " · 수정됨" : ""}
</span>
{mine && onEdit && onDelete && !editing && (
<span className="cmt-acts">
<button onClick={() => startEdit(c)} aria-label="코멘트 수정">
<Icon name="pen" />
</button>
<button
onClick={() => onDelete(c.id)}
aria-label="코멘트 삭제"
className="danger"
>
<Icon name="trash" />
</button>
</span>
)}
</div> </div>
{editing ? (
<div className="cmt-edit">
<input
value={editTxt}
autoFocus
onChange={(e) => setEditTxt(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") saveEdit();
if (e.key === "Escape") setEditId(null);
}}
/>
<button onClick={saveEdit} disabled={!editTxt.trim()}>
</button>
<button className="ghost" onClick={() => setEditId(null)}>
</button>
</div>
) : (
<div className="cmt-text">{c.text}</div> <div className="cmt-text">{c.text}</div>
)}
</div> </div>
</div> </div>
); );

@ -1,10 +1,10 @@
// frontend/components/tasks/DetailPanel.tsx — 작업 상세 드로어 (포커스 드릴다운) // frontend/components/tasks/DetailPanel.tsx — 작업 상세 드로어 (포커스 드릴다운)
"use client"; "use client";
import { Fragment, useEffect, useState } from "react"; import { Fragment, useEffect, useMemo, useState } from "react";
import { Icon } from "@/components/Icon"; import { Icon } from "@/components/Icon";
import { cx } from "@/lib/cx"; import { cx } from "@/lib/cx";
import { COLUMNS, STATUS_META, dueDay, findPath, flattenTasks, stat } from "@/lib/tasks/tree"; import { COLUMNS, STATUS_META, findPath, flattenTasks, stat } from "@/lib/tasks/tree";
import type { Person, Project, Status, Task } from "@/lib/types"; import type { Person, Prio, Project, Status, Task } from "@/lib/types";
import { Av, Tag } from "./bits"; import { Av, Tag } from "./bits";
import { Comments } from "./Comments"; import { Comments } from "./Comments";
import { RichNotes } from "./RichNotes"; import { RichNotes } from "./RichNotes";
@ -26,6 +26,20 @@ function suggestCollaborator(tasks: Task[], projectId: string): string | null {
return ppl[0]; return ppl[0];
} }
/** 우선순위 옵션 (Prio enum 1:1) + 점 색상 */
const PRIO_OPTS: { id: Prio; c: string }[] = [
{ id: "높음", c: "var(--coral)" },
{ id: "보통", c: "var(--amber)" },
{ id: "낮음", c: "var(--muted)" },
];
/** 마감일 라벨 — "6월 8일" (월 무관) */
function dueText(due: string | null): string {
if (!due) return "미정";
const d = new Date(due);
return `${d.getUTCMonth() + 1}${d.getUTCDate()}`;
}
interface Props { interface Props {
tasks: Task[]; tasks: Task[];
focusId: string; focusId: string;
@ -43,20 +57,30 @@ interface Props {
onDelete: (id: string) => void; onDelete: (id: string) => void;
onNotes: (id: string, html: string) => void; onNotes: (id: string, html: string) => void;
onAddComment: (id: string, text: string) => void; onAddComment: (id: string, text: string) => void;
onEditComment?: (id: string, cid: string, text: string) => void;
onDeleteComment?: (id: string, cid: string) => void;
} }
type MenuKind = "status" | "prio" | "assignee";
export function DetailPanel(p: Props) { export function DetailPanel(p: Props) {
const [menu, setMenu] = useState(false); const [menu, setMenu] = useState<MenuKind | null>(null);
const [expanded, setExpanded] = useState<Record<string, boolean>>({}); const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [subText, setSubText] = useState(""); const [subText, setSubText] = useState("");
// 담당자 셀렉터용 인원 목록 (본인 먼저)
const peopleList = useMemo(() => {
const arr = Object.values(p.people);
return arr.sort((a, b) => (a.is_me === b.is_me ? 0 : a.is_me ? -1 : 1));
}, [p.people]);
const path = findPath(p.tasks, p.focusId) ?? []; const path = findPath(p.tasks, p.focusId) ?? [];
const node = path[path.length - 1]; const node = path[path.length - 1];
useEffect(() => { useEffect(() => {
setAdding(false); setAdding(false);
setMenu(false); setMenu(null);
}, [p.focusId]); }, [p.focusId]);
useEffect(() => { useEffect(() => {
const h = (e: KeyboardEvent) => { const h = (e: KeyboardEvent) => {
@ -206,52 +230,150 @@ export function DetailPanel(p: Props) {
people={p.people} people={p.people}
me={p.me} me={p.me}
onAdd={(text) => p.onAddComment(node.id, text)} onAdd={(text) => p.onAddComment(node.id, text)}
onEdit={
p.onEditComment ? (cid, text) => p.onEditComment!(node.id, cid, text) : undefined
}
onDelete={p.onDeleteComment ? (cid) => p.onDeleteComment!(node.id, cid) : undefined}
/> />
</div> </div>
{/* 우측 — 메타 */} {/* 우측 — 메타 */}
<div className="dp-rail"> <div className="dp-rail">
<div className="dp-meta"> <div className="dp-meta">
{/* 담당자 — 인원 목록에서 선택 */}
<div className="dp-mi"> <div className="dp-mi">
<div className="ml"> <div className="ml">
<Icon name="user" /> <Icon name="user" />
</div> </div>
<div className="dp-mv"> <div className="status-wrap">
<Av person={person} /> {person ? person.name : "미정"} <button
{person?.is_me ? " (나)" : ""} className="status-pill assignee-pill"
onClick={() => setMenu((o) => (o === "assignee" ? null : "assignee"))}
>
{person ? (
<>
<Av person={person} /> {person.name}
{person.is_me ? " (나)" : ""}
</>
) : (
<span className="dp-unset">
<Icon name="user" />
</span>
)}
<Icon name="chev" />
</button>
{menu === "assignee" && (
<div className="status-menu">
{peopleList.map((pr) => (
<button
key={pr.id}
onClick={() => {
p.onField(node.id, { assignee_id: pr.id });
setMenu(null);
}}
>
<Av person={pr} /> {pr.name}
{pr.is_me ? " (나)" : ""}
</button>
))}
{person && (
<button
className="menu-clear"
onClick={() => {
p.onField(node.id, { assignee_id: null });
setMenu(null);
}}
>
<Icon name="x" />
</button>
)}
</div> </div>
)}
</div> </div>
</div>
{/* 마감일 — 날짜 입력 (비우면 null) */}
<div className="dp-mi"> <div className="dp-mi">
<div className="ml"> <div className="ml">
<Icon name="cal" /> <Icon name="cal" />
</div> </div>
<div className="dp-mv">{node.due ? `6월 ${dueDay(node.due)}` : "미정"}</div> <div className="dp-date-wrap">
<input
className="dp-date"
type="date"
value={node.due ?? ""}
onChange={(e) =>
p.onField(node.id, { due: e.target.value ? e.target.value : null })
}
/>
<span className="dp-date-face">
<Icon name="cal" /> {dueText(node.due)}
</span>
{node.due && (
<button
className="dp-date-clear"
onClick={() => p.onField(node.id, { due: null })}
aria-label="마감일 지우기"
>
<Icon name="x" />
</button>
)}
</div>
</div> </div>
{/* 우선순위 — 드롭다운 */}
<div className="dp-mi"> <div className="dp-mi">
<div className="ml"> <div className="ml">
<Icon name="flag" /> <Icon name="flag" />
</div> </div>
<div className={cx("dp-mv", `prio-${node.prio}`)}> <div className="status-wrap">
<Icon name="flag" /> {node.prio} <button
className={cx("status-pill", `prio-${node.prio}`)}
onClick={() => setMenu((o) => (o === "prio" ? null : "prio"))}
>
<Icon name="flag" /> {node.prio} <Icon name="chev" />
</button>
{menu === "prio" && (
<div className="status-menu">
{PRIO_OPTS.map((o) => (
<button
key={o.id}
className={cx(`prio-${o.id}`, node.prio === o.id && "on")}
onClick={() => {
p.onField(node.id, { prio: o.id });
setMenu(null);
}}
>
<Icon name="flag" /> {o.id}
</button>
))}
</div>
)}
</div> </div>
</div> </div>
{/* 상태 — 드롭다운 */}
<div className="dp-mi"> <div className="dp-mi">
<div className="ml"> <div className="ml">
<Icon name="list" /> <Icon name="list" />
</div> </div>
<div className="status-wrap"> <div className="status-wrap">
<button className="status-pill" onClick={() => setMenu((o) => !o)}> <button
className="status-pill"
onClick={() => setMenu((o) => (o === "status" ? null : "status"))}
>
<span className="pdot" style={{ background: sm.c }} /> {sm.label}{" "} <span className="pdot" style={{ background: sm.c }} /> {sm.label}{" "}
<Icon name="chev" /> <Icon name="chev" />
</button> </button>
{menu && ( {menu === "status" && (
<div className="status-menu"> <div className="status-menu">
{COLUMNS.map((c) => ( {COLUMNS.map((c) => (
<button <button
key={c.id} key={c.id}
className={cx(node.status === c.id && "on")}
onClick={() => { onClick={() => {
p.onField(node.id, { status: c.id as Status }); p.onField(node.id, { status: c.id as Status });
setMenu(false); setMenu(null);
}} }}
> >
<span className="pdot" style={{ background: STATUS_META[c.id].c }} />{" "} <span className="pdot" style={{ background: STATUS_META[c.id].c }} />{" "}

@ -13,7 +13,9 @@ export function KanbanCard({
onOpen, onOpen,
onDragStart, onDragStart,
onDragEnd, onDragEnd,
onDragOver,
dragging, dragging,
dropPos,
}: { }: {
task: Task; task: Task;
people: Record<string, Person>; people: Record<string, Person>;
@ -21,7 +23,9 @@ export function KanbanCard({
onOpen: (id: string) => void; onOpen: (id: string) => void;
onDragStart?: (e: React.DragEvent) => void; onDragStart?: (e: React.DragEvent) => void;
onDragEnd?: () => void; onDragEnd?: () => void;
onDragOver?: (e: React.DragEvent) => void;
dragging?: boolean; dragging?: boolean;
dropPos?: "before" | "after" | null;
}) { }) {
const s = stat(task); const s = stat(task);
const soon = isSoon(task); const soon = isSoon(task);
@ -29,10 +33,17 @@ export function KanbanCard({
const person = task.assignee_id ? people[task.assignee_id] : null; const person = task.assignee_id ? people[task.assignee_id] : null;
return ( return (
<div <div
className={cx("kcard", dragging && "dragging", task.status === "done" && "done-card")} className={cx(
"kcard",
dragging && "dragging",
task.status === "done" && "done-card",
dropPos === "before" && "drop-before",
dropPos === "after" && "drop-after",
)}
draggable draggable
onDragStart={onDragStart} onDragStart={onDragStart}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
onDragOver={onDragOver}
onClick={() => onOpen(task.id)} onClick={() => onOpen(task.id)}
> >
<div className="kcard-top"> <div className="kcard-top">

@ -14,6 +14,7 @@ export function KanbanView({
onOpen, onOpen,
onMove, onMove,
onAdd, onAdd,
onReorder,
}: { }: {
tasks: Task[]; tasks: Task[];
people: Record<string, Person>; people: Record<string, Person>;
@ -21,9 +22,12 @@ export function KanbanView({
onOpen: (id: string) => void; onOpen: (id: string) => void;
onMove: (id: string, status: Status) => void; onMove: (id: string, status: Status) => void;
onAdd: (status: Status, title: string) => void; onAdd: (status: Status, title: string) => void;
onReorder?: (ids: string[]) => void;
}) { }) {
const [dragId, setDragId] = useState<string | null>(null); const [dragId, setDragId] = useState<string | null>(null);
const [overCol, setOverCol] = useState<string | null>(null); const [overCol, setOverCol] = useState<string | null>(null);
const [overId, setOverId] = useState<string | null>(null);
const [overAfter, setOverAfter] = useState(false);
const [addCol, setAddCol] = useState<string | null>(null); const [addCol, setAddCol] = useState<string | null>(null);
const [text, setText] = useState(""); const [text, setText] = useState("");
const submit = (status: Status) => { const submit = (status: Status) => {
@ -31,6 +35,29 @@ export function KanbanView({
setText(""); setText("");
setAddCol(null); setAddCol(null);
}; };
const clearDrag = () => {
setDragId(null);
setOverCol(null);
setOverId(null);
};
const handleDrop = async (colId: string) => {
const id = dragId;
const oid = overId;
const oaft = overAfter;
clearDrag();
if (!id) return;
const dragged = tasks.find((t) => t.id === id);
const sameCol = dragged?.status === colId;
const colIds = tasks.filter((t) => t.status === colId && t.id !== id).map((t) => t.id);
let idx = colIds.length;
if (oid && oid !== id) {
const at = colIds.indexOf(oid);
if (at >= 0) idx = at + (oaft ? 1 : 0);
}
const newIds = [...colIds.slice(0, idx), id, ...colIds.slice(idx)];
if (!sameCol) await onMove(id, colId as Status);
if (onReorder) await onReorder(newIds);
};
return ( return (
<div className="kboard"> <div className="kboard">
{COLUMNS.map((col) => { {COLUMNS.map((col) => {
@ -48,9 +75,7 @@ export function KanbanView({
}} }}
onDrop={(e) => { onDrop={(e) => {
e.preventDefault(); e.preventDefault();
if (dragId) onMove(dragId, col.id as Status); void handleDrop(col.id);
setDragId(null);
setOverCol(null);
}} }}
> >
<div className="kcol-head"> <div className="kcol-head">
@ -92,13 +117,17 @@ export function KanbanView({
projects={projects} projects={projects}
onOpen={onOpen} onOpen={onOpen}
dragging={dragId === t.id} dragging={dragId === t.id}
dropPos={dragId && dragId !== t.id && overId === t.id ? (overAfter ? "after" : "before") : null}
onDragStart={(e) => { onDragStart={(e) => {
setDragId(t.id); setDragId(t.id);
e.dataTransfer.effectAllowed = "move"; e.dataTransfer.effectAllowed = "move";
}} }}
onDragEnd={() => { onDragEnd={clearDrag}
setDragId(null); onDragOver={(e) => {
setOverCol(null); if (!dragId || dragId === t.id) return;
const r = e.currentTarget.getBoundingClientRect();
setOverId(t.id);
setOverAfter(e.clientY > r.top + r.height / 2);
}} }}
/> />
))} ))}

@ -1,6 +1,7 @@
// frontend/components/tasks/TasksClient.tsx — 작업 페이지 오케스트레이터 // frontend/components/tasks/TasksClient.tsx — 작업 페이지 오케스트레이터
"use client"; "use client";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "next/navigation";
import useSWR, { useSWRConfig } from "swr"; import useSWR, { useSWRConfig } from "swr";
import { Icon } from "@/components/Icon"; import { Icon } from "@/components/Icon";
import { cx } from "@/lib/cx"; import { cx } from "@/lib/cx";
@ -45,6 +46,13 @@ export function TasksClient() {
const [rootId, setRootId] = useState<string | null>(null); const [rootId, setRootId] = useState<string | null>(null);
const shellRef = useRef<HTMLDivElement>(null); const shellRef = useRef<HTMLDivElement>(null);
const notesTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const notesTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const didMount = useRef(false);
// URL 로 열린 작업을 표현 — /tasks/<id> 의 <id> 세그먼트(옵셔널 캐치올).
// 주소 갱신은 router 대신 history.replaceState(얕은 갱신) — router.replace 는 이 라우트를
// 리마운트시켜 뷰/선택 상태가 날아가고 open 루프가 발생하기 때문.
const params = useParams();
const routeId = Array.isArray(params.slug) ? params.slug[0] : undefined;
// 클라이언트에서만 localStorage 복원 (SSR 안정) // 클라이언트에서만 localStorage 복원 (SSR 안정)
useEffect(() => { useEffect(() => {
@ -167,6 +175,27 @@ export function TasksClient() {
setRootId(null); setRootId(null);
setFocusId(null); setFocusId(null);
}; };
// URL → 작업: routeId(주소의 <id>)가 바뀌면 해당 작업 상세를 연다(직접 입력·뒤로가기·딥링크).
useEffect(() => {
if (!routeId) {
close();
return;
}
if (routeId === focusId) return; // 이미 열려 있음
open(routeId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [routeId]);
// 작업 → URL: focusId 가 바뀌면 주소를 맞춘다(초기 마운트는 위 effect 가 처리하므로 skip).
useEffect(() => {
if (!didMount.current) {
didMount.current = true;
return;
}
const target = focusId ? `/tasks/${focusId}` : "/tasks";
if (window.location.pathname !== target) window.history.replaceState(null, "", target);
}, [focusId]);
const refresh = () => { const refresh = () => {
mutate("tasks/all"); mutate("tasks/all");
mutate("tasks/tree"); mutate("tasks/tree");
@ -242,6 +271,19 @@ export function TasksClient() {
await tasksApi.addComment(id, { person_id: "jiwoo", text }); await tasksApi.addComment(id, { person_id: "jiwoo", text });
mutate("tasks/all"); mutate("tasks/all");
}; };
const onEditComment = async (id: string, cid: string, text: string) => {
await tasksApi.editComment(id, cid, text);
mutate("tasks/all");
};
const onDeleteComment = async (id: string, cid: string) => {
await tasksApi.deleteComment(id, cid);
mutate("tasks/all");
};
// 같은 컬럼 내 드래그 정렬 → sort_order 재배치(낙관적 후 refresh).
const onReorder = async (ids: string[]) => {
await tasksApi.reorder(ids);
refresh();
};
const onAddProject = async (folderId: string, name: string) => { const onAddProject = async (folderId: string, name: string) => {
await tasksApi.createProject({ folder_id: folderId, name }); await tasksApi.createProject({ folder_id: folderId, name });
setExpanded((e) => ({ ...e, [folderId]: true })); setExpanded((e) => ({ ...e, [folderId]: true }));
@ -384,6 +426,7 @@ export function TasksClient() {
onOpen={open} onOpen={open}
onMove={onMove} onMove={onMove}
onAdd={onAdd} onAdd={onAdd}
onReorder={onReorder}
/> />
) : ( ) : (
<ListView <ListView
@ -414,6 +457,8 @@ export function TasksClient() {
onDelete={onDelete} onDelete={onDelete}
onNotes={onNotes} onNotes={onNotes}
onAddComment={onAddComment} onAddComment={onAddComment}
onEditComment={onEditComment}
onDeleteComment={onDeleteComment}
/> />
)} )}
</div> </div>

@ -1,5 +1,5 @@
// frontend/lib/api.ts // frontend/lib/api.ts
export const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:31800"; export const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? (typeof window === "undefined" ? "http://localhost:31800" : "");
/** /api 프리픽스 경로를 절대 URL로. 예: apiUrl("/health") → http://localhost:31800/api/health */ /** /api 프리픽스 경로를 절대 URL로. 예: apiUrl("/health") → http://localhost:31800/api/health */
export function apiUrl(path: string): string { export function apiUrl(path: string): string {

@ -2,7 +2,7 @@
// 데모 무손상: 이 모듈은 호출되기 전엔 부작용이 없다. 게이트는 proxy.ts(미들웨어)가 담당. // 데모 무손상: 이 모듈은 호출되기 전엔 부작용이 없다. 게이트는 proxy.ts(미들웨어)가 담당.
import type { Me } from "@/lib/types"; import type { Me } from "@/lib/types";
const BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:31800"; const BASE = process.env.NEXT_PUBLIC_API_BASE ?? (typeof window === "undefined" ? "http://localhost:31800" : "");
const AUTH_ENABLED = process.env.NEXT_PUBLIC_AUTH_ENABLED === "true"; const AUTH_ENABLED = process.env.NEXT_PUBLIC_AUTH_ENABLED === "true";
/** 로그인. 성공 시 MeOut, 실패(401)면 에러를 던진다(쿠키는 백엔드 Set-Cookie). */ /** 로그인. 성공 시 MeOut, 실패(401)면 에러를 던진다(쿠키는 백엔드 Set-Cookie). */

@ -1,5 +1,11 @@
// frontend/lib/connectors/api.ts — connectorsApi (목록/동기화/해제/임포트/OAuth) // frontend/lib/connectors/api.ts — connectorsApi (목록/동기화/해제/임포트/OAuth)
import type { ConnectorStatus, SyncResult, ImportResult, OAuthStart } from "@/lib/types"; import type {
ConnectorStatus,
SyncResult,
ImportResult,
OAuthStart,
ConnectorProvider,
} from "@/lib/types";
async function ok<T>(r: Response): Promise<T> { async function ok<T>(r: Response): Promise<T> {
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`); if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
@ -32,8 +38,16 @@ export const connectorsApi = {
}).then((r) => ok<ImportResult>(r)); }).then((r) => ok<ImportResult>(r));
}, },
oauthStart: (domain: string, provider: string) => providers: (domain = "") =>
fetch( fetch(
`/api/connectors/oauth/start?domain=${encodeURIComponent(domain)}&provider=${encodeURIComponent(provider)}`, domain
).then((r) => ok<OAuthStart>(r)), ? `/api/connectors/providers?domain=${encodeURIComponent(domain)}`
: "/api/connectors/providers",
{ cache: "no-store" },
).then((r) => ok<ConnectorProvider[]>(r)),
oauthStart: (domain: string, provider: string, redirectAfter = "/settings") => {
const q = new URLSearchParams({ domain, provider, redirect_after: redirectAfter });
return fetch(`/api/connectors/oauth/start?${q.toString()}`).then((r) => ok<OAuthStart>(r));
},
}; };

@ -1,7 +1,7 @@
// frontend/lib/dashboard/api.ts // frontend/lib/dashboard/api.ts
import type { Dashboard } from "@/lib/types"; import type { Dashboard } from "@/lib/types";
const BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:31800"; const BASE = process.env.NEXT_PUBLIC_API_BASE ?? (typeof window === "undefined" ? "http://localhost:31800" : "");
export async function getDashboard(signal?: AbortSignal): Promise<Dashboard> { export async function getDashboard(signal?: AbortSignal): Promise<Dashboard> {
const res = await fetch(`${BASE}/api/dashboard`, { signal, cache: "no-store" }); const res = await fetch(`${BASE}/api/dashboard`, { signal, cache: "no-store" });

@ -8,7 +8,13 @@ export function useConnectors(domain = "") {
const { data, error, isLoading, mutate } = useSWR<ConnectorStatus[]>( const { data, error, isLoading, mutate } = useSWR<ConnectorStatus[]>(
domain ? `/api/connectors?domain=${domain}` : "/api/connectors", domain ? `/api/connectors?domain=${domain}` : "/api/connectors",
() => connectorsApi.list(domain), () => connectorsApi.list(domain),
{ revalidateOnFocus: false }, {
revalidateOnFocus: false,
// 초기 연결 직후엔 백그라운드 sync 가 도는 동안 state="syncing" 이다.
// 동기화 중인 계정이 하나라도 있으면 끝날 때까지 2초마다 갱신, 끝나면 멈춘다.
// (이게 없으면 "동기화 중…" 카드가 완료된 뒤에도 안 바뀌고 멈춰 있음.)
refreshInterval: (latest) => (latest?.some((c) => c.state === "syncing") ? 2000 : 0),
},
); );
return { data, isLoading, error, refresh: mutate }; return { data, isLoading, error, refresh: mutate };
} }

@ -1,7 +1,7 @@
// frontend/lib/inbox/api.ts // frontend/lib/inbox/api.ts
import type { CaptureResult, ConfirmResult, InboxItem, RouteType } from "@/lib/types"; import type { CaptureResult, ConfirmResult, InboxItem, RouteType } from "@/lib/types";
const BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:31800"; const BASE = process.env.NEXT_PUBLIC_API_BASE ?? (typeof window === "undefined" ? "http://localhost:31800" : "");
export class ApiError extends Error { export class ApiError extends Error {
constructor( constructor(

@ -1,7 +1,7 @@
// frontend/lib/inbox/multimodal.ts — 멀티모달 캡처 (음성 STT / 이미지 Vision) // frontend/lib/inbox/multimodal.ts — 멀티모달 캡처 (음성 STT / 이미지 Vision)
import type { TranscribeResult, CaptionResult } from "@/lib/types"; import type { TranscribeResult, CaptionResult } from "@/lib/types";
const BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:31800"; const BASE = process.env.NEXT_PUBLIC_API_BASE ?? (typeof window === "undefined" ? "http://localhost:31800" : "");
export async function transcribeAudio(blob: Blob, hint = ""): Promise<TranscribeResult> { export async function transcribeAudio(blob: Blob, hint = ""): Promise<TranscribeResult> {
const fd = new FormData(); const fd = new FormData();

@ -20,7 +20,4 @@ export const MAIN: NavItem[] = [
{ id: "task", label: "작업", icon: "check", href: "/tasks", badge: "4", mvp: true }, { id: "task", label: "작업", icon: "check", href: "/tasks", badge: "4", mvp: true },
{ id: "mail", label: "메일", icon: "mail", href: "/mail", mvp: false }, { id: "mail", label: "메일", icon: "mail", href: "/mail", mvp: false },
{ id: "noti", label: "알림", icon: "bell", href: "/notifications", badge: "6", mvp: false }, { id: "noti", label: "알림", icon: "bell", href: "/notifications", badge: "6", mvp: false },
{ id: "research", label: "리서치", icon: "brain", href: "/research", mvp: false },
{ id: "trip", label: "여행", icon: "send", href: "/trip", mvp: false },
{ id: "life", label: "라이프", icon: "heart", href: "/life", mvp: false },
]; ];

@ -5,7 +5,7 @@ import type {
WorkerRunResult, WorkerRunResult,
} from "@/lib/types"; } from "@/lib/types";
const BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:31800"; const BASE = process.env.NEXT_PUBLIC_API_BASE ?? (typeof window === "undefined" ? "http://localhost:31800" : "");
export async function getProactive(signal?: AbortSignal): Promise<ProactiveCard[]> { export async function getProactive(signal?: AbortSignal): Promise<ProactiveCard[]> {
const res = await fetch(`${BASE}/api/proactive`, { signal, cache: "no-store" }); const res = await fetch(`${BASE}/api/proactive`, { signal, cache: "no-store" });

@ -48,6 +48,21 @@ export const tasksApi = {
(r) => ok<TaskComment>(r), (r) => ok<TaskComment>(r),
), ),
editComment: (id: string, cid: string, text: string) =>
fetch(`/api/tasks/${id}/comments/${cid}`, {
method: "PATCH",
headers: J,
body: JSON.stringify({ text }),
}).then((r) => ok<TaskComment>(r)),
deleteComment: (id: string, cid: string) =>
fetch(`/api/tasks/${id}/comments/${cid}`, { method: "DELETE" }).then((r) => r.ok),
reorder: (ids: string[]) =>
fetch("/api/tasks/reorder", { method: "POST", headers: J, body: JSON.stringify({ ids }) }).then(
(r) => ok<{ ok: boolean; count: number }>(r),
),
scaffold: (id: string, body: { create?: boolean; use_llm?: boolean } = {}) => scaffold: (id: string, body: { create?: boolean; use_llm?: boolean } = {}) =>
fetch(`/api/tasks/${id}/scaffold`, { method: "POST", headers: J, body: JSON.stringify(body) }).then( fetch(`/api/tasks/${id}/scaffold`, { method: "POST", headers: J, body: JSON.stringify(body) }).then(
(r) => ok<ScaffoldResult>(r), (r) => ok<ScaffoldResult>(r),

@ -140,6 +140,7 @@ export interface TaskComment {
person_id: string; person_id: string;
text: string; text: string;
created_at: string; created_at: string;
edited_at?: string | null;
} }
export interface Task { export interface Task {
@ -229,7 +230,6 @@ export interface UiInboxItem extends Omit<InboxItem, "status"> {
status: UiInboxStatus; status: UiInboxStatus;
time: string; time: string;
fresh?: boolean; fresh?: boolean;
fallbackUsed?: boolean;
} }
// ---------- 대시보드 (Phase 5) ---------- // ---------- 대시보드 (Phase 5) ----------
@ -337,17 +337,33 @@ export type MailAccount = {
email: string; email: string;
tone: string; tone: string;
kind: string; kind: string;
unread: number; unread: number; // 집중(focused) 안읽음
others?: number; // 기타(Outlook other) 안읽음
}; };
export type MailFolder = { id: string; name: string; icon: string }; export type MailFolder = { id: string; name: string; icon: string };
export type MailAttachment = { name: string; size: string }; // 계정별 실제 폴더/라벨(사이드바 트리). slug 가 목록 필터 키(inbox/spam/trash/cat:…/label:…/of:…).
export type AccountFolder = {
account: string;
slug: string;
name: string;
kind: "system" | "category" | "label" | "custom";
icon: string;
unread: number;
total: number;
sort_order: number;
};
export type MailAttachment = { id: string; name: string; mime: string; size: number };
// 목록 행 (가벼움). 응답 키는 'from' (백엔드 alias). // 목록 행 (가벼움). 응답 키는 'from' (백엔드 alias).
export type EmailRow = { export type EmailRow = {
id: string; id: string;
account: string; account: string;
from: string; from: string; // 원문 발신자('이름 <메일>' 또는 메일)
from_name: string; // 표시 이름(이름 없으면 도메인 추정) — 목록/리더 노출
from_email: string; // 실제 메일 주소(리더 보조)
to: string; to: string;
cc: string;
thread_id: string;
subject: string; subject: string;
time: string; time: string;
read: boolean; read: boolean;
@ -355,15 +371,27 @@ export type EmailRow = {
has_attach: boolean; has_attach: boolean;
labels: string[]; labels: string[];
preview: string; preview: string;
category: MailCategory; // 카테고리 탭(기본/프로모션/소셜/업데이트) — 라벨에서 산출
ai: AiAnalysis | null; ai: AiAnalysis | null;
}; };
// 상세 (+ date/body/attachments) // 메일 카테고리 — Gmail CATEGORY_* 매핑(Outlook 은 primary). 목록 상단 탭으로 분류.
export type MailCategory = "primary" | "promotions" | "social" | "updates";
// 상세 (+ date/body/body_html/attachments)
export type EmailDetail = EmailRow & { export type EmailDetail = EmailRow & {
date: string; date: string;
body: string[]; body: string[];
body_html: string; // 원본 HTML 본문(있으면 iframe 렌더, 없으면 body 텍스트 폴백)
attachments: MailAttachment[]; attachments: MailAttachment[];
}; };
// 대화(스레드) 묶음
export type Conversation = {
thread_id: string;
subject: string;
count: number;
messages: EmailRow[];
};
export type ExtractKind = "task" | "event" | "file"; export type ExtractKind = "task" | "event" | "file";
export type ExtractResponse = { export type ExtractResponse = {
kind: ExtractKind; kind: ExtractKind;
@ -384,12 +412,16 @@ export type ReplyDraft = {
subject: string; subject: string;
body: string; body: string;
}; };
export type ComposeAttachment = { name: string; mime: string; data_b64: string };
export type SendRequest = { export type SendRequest = {
from_account: string; from_account: string;
to: string; to: string;
cc?: string;
bcc?: string;
subject: string; subject: string;
body: string; body: string;
in_reply_to?: string | null; in_reply_to?: string | null;
attachments?: ComposeAttachment[];
}; };
export type SendResponse = { approval_id: string; status: string; message: string }; export type SendResponse = { approval_id: string; status: string; message: string };
@ -433,330 +465,6 @@ export type SenderRule = {
bucket: NotifyBucket; bucket: NotifyBucket;
}; };
// ==================== phase-10: 리서치 ====================
export type RsCollection = { id: string; name: string; tone: string; n: number };
export type RsSource = {
id: string;
kind: "pdf" | "web" | "note";
title: string;
from: string; // 응답 키 'from' (백엔드 serialization_alias)
col: string | null;
learned: boolean;
};
export type RsEntry = { id: string; icon: string; tone: string; title: string; desc: string };
export type ResearchHome = {
collections: RsCollection[];
sources: RsSource[];
prompts: string[];
entries: RsEntry[];
};
export type RsCount = { k: string; n: number; tone: string };
export type RsCross = { src: string; tone: string; claim: string; stance: string };
export type ResearchReport = {
id: string;
title: string;
asked: string;
meta: string;
counts: RsCount[];
synthesis: string; // HTML
cross: RsCross[];
note: string;
};
export type RsRef = { title: string; part: string };
export type ResearchQA = { id: string; q: string; a: string; refs: RsRef[] };
export type RsBar = { y: string; v: number; forecast?: boolean };
export type ResearchChart = {
id: string;
title: string;
asked: string;
unit: string;
bars: RsBar[];
insight: string; // HTML
caution: string;
};
export type StartResearchResult = {
status: "ready" | "queued";
report_id: string | null;
report: ResearchReport | null;
queued_text: string | null;
};
export type AskResult = {
a: string; // HTML
refs: RsRef[];
grounded: boolean;
model: string;
};
// ==================== phase-10: 여행 ====================
export type TripWeather = { d: string; t: string; icon: string };
export type TripInfo = {
id: string;
dday: string;
title: string;
dates: string;
purpose: string;
brief: string;
weather: TripWeather[];
};
export type TripLeg = {
mode: string;
from: string;
ft: string;
to: string;
tt: string;
seat: string;
note: string;
};
export type TripRoute = { out: TripLeg; back: TripLeg };
export type TripStayInfo = { name: string; desc: string; conf: string };
export type TripPrepItem = { text: string; state: "done" | "doing" };
export type TripDayItem = {
t: string;
kind: string;
tone: string;
title: string;
meta: string;
hot?: boolean;
};
export type TripDay = { id: string; tab: string; items: TripDayItem[] };
export type TripCheckItem = { id: string; text: string; auto?: string };
export type TripCheckGroup = { name: string; items: TripCheckItem[] };
export type TripExpenseRow = { name: string; amt: string; state: string };
export type TripExpense = {
budget: number;
planned: number;
rows: TripExpenseRow[];
note: string;
};
export type UpcomingTrip = {
trip: TripInfo;
route: TripRoute;
stay: TripStayInfo;
prep: TripPrepItem[];
days: TripDay[];
check: TripCheckGroup[];
expense: TripExpense;
};
export type SavedTrip = {
id: string;
title: string;
tag: string;
note: string;
now: string;
delta: string;
down: boolean;
watch: boolean;
hint: string;
spark: number[];
};
export type PlanTransport = {
mode: string;
name: string;
desc: string;
price: string;
pick?: boolean;
note?: string;
};
export type PlanStay = {
name: string;
desc: string;
price: string;
unit: string;
pick?: boolean;
};
export type PlanDayItem = { t: string; title: string; tone: string };
export type PlanDay = { tab: string; items: PlanDayItem[] };
export type PlanBudgetRow = { name: string; amt: string };
export type PlanBudget = { total: string; cap: string; rows: PlanBudgetRow[] };
export type PlanResearchStep = { icon: string; label: string; detail: string };
export type PlanResult = {
id: string;
idx: number;
custom: boolean;
title: string;
meta: string;
summary: string; // HTML
weather: string;
transport: PlanTransport[];
stay: PlanStay[];
days: PlanDay[];
budget: PlanBudget;
checklist: string[];
sources: string[];
research?: PlanResearchStep[] | null;
};
// ==================== phase-11: 라이프 케어 (건강·금융·지식) ====================
export type LifeDomain = "health" | "finance" | "knowledge";
export type LifeInsightKind = "alert" | "save" | "info";
export type LifeKnowledgeType = "article" | "note" | "idea" | "highlight";
export interface ConnectorSourceOut {
id: string;
domain: LifeDomain;
name: string;
kind: string;
tone: string;
on: boolean;
last: string;
}
// ---- 건강 ----
export interface RingOut {
label: string;
val: string;
unit: string;
goal: string;
pct: number;
icon: string;
tone: string;
}
export interface VitalOut {
label: string;
val: string;
unit: string;
trend: string; // "up" | "down" | "flat"
note: string;
tone: string;
}
export interface SleepStageOut {
label: string;
pct: number;
tone: string; // "muted" 는 var(--card-3)/var(--faint)
}
export interface SleepOut {
total: string;
score: number;
stages: SleepStageOut[];
week: number[];
note: string;
}
export interface HabitOut {
id: string;
title: string;
goal: string;
done: number;
total: number;
streak: number;
icon: string;
tone: string;
at: string;
automation_rule_id?: string | null;
}
export interface HealthOut {
rings: RingOut[];
vitals: VitalOut[];
sleep: SleepOut;
coach: string; // HTML
habits: HabitOut[];
}
// ---- 금융 ----
export interface BudgetOut {
spent: number;
limit: number;
pct: number;
delta_pct: number;
days_left: number;
}
export interface CategoryOut {
name: string;
amt: number;
pct: number;
icon: string;
tone: string;
over: boolean;
}
export interface SubOut {
id: string;
name: string;
date: string;
in_days: number;
amt: number;
icon: string;
tone: string;
note: string;
paused: boolean;
}
export interface InsightOut {
id: string;
kind: LifeInsightKind;
icon: string;
title: string;
body: string;
action: string; // "pause_sub" | "raise_budget" | "auto_transfer" | ""
action_ref: string;
}
export interface FinanceOut {
budget: BudgetOut;
cats: CategoryOut[];
subs: SubOut[];
coach: string; // HTML
insights: InsightOut[];
}
// ---- 지식 ----
export interface CollectionOut {
name: string;
count: number;
tone: string;
}
export interface KnowledgeItemOut {
id: string;
type: LifeKnowledgeType;
title: string;
src: string;
time: string;
tone: string;
icon: string;
tags: string[];
excerpt: string;
ai: string; // HTML
}
export interface KnowledgeStatsOut {
items: number;
this_week: number;
collections: number;
}
export interface KnowledgeOut {
stats: KnowledgeStatsOut;
suggested: string[];
collections: CollectionOut[];
items: KnowledgeItemOut[];
}
// ---- 통합 overview ----
export interface LifeOverviewOut {
sources: Record<LifeDomain, ConnectorSourceOut[]>;
health: HealthOut;
finance: FinanceOut;
knowledge: KnowledgeOut;
}
// ---- 액션 응답 ----
export interface HabitTickResponse {
habit: HabitOut;
}
export interface SubPauseResponse {
sub: SubOut;
approval_id?: string | null;
}
export interface InsightActResponse {
approval_id?: string | null;
task_id?: string | null;
message: string;
}
export interface KnowledgeAskResponse {
q: string;
body: string; // HTML
sources: string[];
}
export interface KnowledgeSuggestActResponse {
task_id?: string | null;
event_id?: string | null;
message: string;
}
// ==================== phase-12 여정(journey) ==================== // ==================== phase-12 여정(journey) ====================
export type JourneyPerson = { export type JourneyPerson = {
id: string; id: string;
@ -838,10 +546,17 @@ export interface ConnectorStatus {
last: string; last: string;
last_synced_at: string | null; last_synced_at: string | null;
error_detail: string; error_detail: string;
email: string; // phase-16 연결된 실계정 주소
} }
export interface OAuthStart { export interface OAuthStart {
authorize_url: string; authorize_url: string;
} }
export interface ConnectorProvider {
domain: string;
provider: string;
label: string;
configured: boolean; // client_id 설정 시 OAuth 추가 가능
}
export interface SyncResult { export interface SyncResult {
domain: string; domain: string;
account_id: string; account_id: string;
@ -862,39 +577,6 @@ export interface ImportResult {
// ==================== phase-14: 능동 에이전트 + 멀티모달 ==================== // ==================== phase-14: 능동 에이전트 + 멀티모달 ====================
// 백엔드 schemas.py(§4.2)와 1:1. snake_case 키 유지. // 백엔드 schemas.py(§4.2)와 1:1. snake_case 키 유지.
export type ErrandKind = "booking" | "refund" | "cancel" | "support";
export type ErrandStatus =
| "planning"
| "running"
| "awaiting_approval"
| "done"
| "failed"
| "cancelled";
export type StepState = "pending" | "running" | "done" | "blocked" | "error";
export interface ErrandStep {
id: string;
seq: number;
phase: "plan" | "act" | "observe" | "reflect";
tool: string | null;
label: string;
detail: string | null;
external_effect: boolean;
state: StepState;
}
export interface ErrandTask {
id: string;
kind: ErrandKind;
title: string;
goal: string;
target: string | null;
tone: Tone;
status: ErrandStatus;
approval_id: string | null;
result_summary: string | null;
model: string;
steps: ErrandStep[];
}
export interface ProactiveCard { export interface ProactiveCard {
id: string; id: string;
kind: "schedule" | "finance" | "focus" | "health" | "digest"; kind: "schedule" | "finance" | "focus" | "health" | "digest";

@ -1,7 +1,7 @@
// frontend/lib/weekly/api.ts — 주간 리뷰 조회 // frontend/lib/weekly/api.ts — 주간 리뷰 조회
import type { WeeklyReview } from "@/lib/types"; import type { WeeklyReview } from "@/lib/types";
const BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:31800"; const BASE = process.env.NEXT_PUBLIC_API_BASE ?? (typeof window === "undefined" ? "http://localhost:31800" : "");
export async function getWeeklyReview( export async function getWeeklyReview(
week?: string, week?: string,

@ -5,6 +5,10 @@ const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:31800";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
// phase-15: Docker 프로덕션 이미지를 위한 standalone 출력(서버리스 번들). // phase-15: Docker 프로덕션 이미지를 위한 standalone 출력(서버리스 번들).
output: "standalone", output: "standalone",
// 다른 컴퓨터(같은 LAN)에서 http://<내IP>:31300 으로 접속 허용.
// Next 16 은 dev 모드에서 localhost 외 origin 의 dev 자산 요청을 기본 차단하므로
// 사설망 대역을 명시한다. IP 가 바뀌면(예: 다른 와이파이) 여기에 추가하면 된다.
allowedDevOrigins: ["192.168.0.*", "192.168.1.*", "10.0.0.*", "172.16.*"],
// dev 프록시: 프론트의 상대 경로 /api/* 호출을 백엔드(:31800)로 전달. // dev 프록시: 프론트의 상대 경로 /api/* 호출을 백엔드(:31800)로 전달.
// lib/api.ts 는 절대 URL(API_BASE)을 쓰지만, 상대 경로 사용 시에도 동작하도록 둔다. // lib/api.ts 는 절대 URL(API_BASE)을 쓰지만, 상대 경로 사용 시에도 동작하도록 둔다.
async rewrites() { async rewrites() {

@ -190,3 +190,56 @@
white-space: nowrap; white-space: nowrap;
border: 0; border: 0;
} }
/* phase-16 계정 추가(OAuth) 드롭다운 */
:is(.lifepage, .settingspage) .cn-add {
position: relative;
display: inline-flex;
}
:is(.lifepage, .settingspage) .cn-add-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 20;
min-width: 200px;
padding: 6px;
border-radius: 14px;
background: var(--card, var(--glass-1));
border: 1px solid var(--line-2);
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.16);
display: flex;
flex-direction: column;
gap: 2px;
}
:is(.lifepage, .settingspage) .cn-add-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: 100%;
padding: 9px 12px;
border: 0;
border-radius: 10px;
background: transparent;
color: var(--ink-1);
font: inherit;
font-weight: 600;
text-align: left;
cursor: pointer;
}
:is(.lifepage, .settingspage) .cn-add-item:hover:not(:disabled) {
background: var(--glass-2);
}
:is(.lifepage, .settingspage) .cn-add-item:disabled {
opacity: 0.55;
cursor: default;
}
:is(.lifepage, .settingspage) .cn-add-hint {
font-size: 11px;
font-weight: 600;
font-style: normal;
color: var(--ink-3, var(--ink-2));
background: var(--glass-2);
padding: 2px 7px;
border-radius: 999px;
}

@ -552,3 +552,94 @@ input {
width: 100%; width: 100%;
} }
} }
/* ── 전역 통합 다이얼로그(confirm/alert) — window.confirm/alert 대체 ── */
.dlg-backdrop {
position: fixed;
inset: 0;
z-index: 300;
display: grid;
place-items: center;
padding: 20px;
background: rgba(26, 22, 18, 0.4);
-webkit-backdrop-filter: blur(4px);
backdrop-filter: blur(4px);
animation: dlg-fade 0.16s ease;
}
.dlg-card {
width: min(420px, 100%);
background: var(--card);
border: 1px solid var(--line);
border-radius: 18px;
padding: 22px 22px 18px;
box-shadow: var(--shadow);
animation: dlg-pop 0.18s cubic-bezier(0.22, 1, 0.36, 1);
}
.dlg-title {
margin: 0 0 8px;
font-size: 16px;
font-weight: 800;
letter-spacing: -0.01em;
color: var(--ink);
}
.dlg-msg {
margin: 0 0 20px;
font-size: 13.5px;
line-height: 1.6;
color: var(--ink-2);
white-space: pre-line;
}
.dlg-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.dlg-btn {
padding: 9px 16px;
border-radius: 11px;
font-size: 13.5px;
font-weight: 700;
cursor: pointer;
border: 1px solid transparent;
transition:
transform 0.12s,
filter 0.14s,
background 0.14s;
}
.dlg-btn:hover {
transform: translateY(-1px);
}
.dlg-btn:active {
transform: translateY(0);
}
.dlg-btn.ghost {
background: var(--card-2);
color: var(--ink-2);
border-color: var(--line);
}
.dlg-btn.ghost:hover {
color: var(--ink);
}
.dlg-btn.primary {
background: var(--blue);
color: #fff;
}
.dlg-btn.danger {
background: var(--coral);
color: #fff;
}
.dlg-btn.primary:hover,
.dlg-btn.danger:hover {
filter: brightness(1.05);
}
@keyframes dlg-fade {
from {
opacity: 0;
}
}
@keyframes dlg-pop {
from {
opacity: 0;
transform: translateY(8px) scale(0.97);
}
}

@ -1194,6 +1194,102 @@
background: var(--card-2); background: var(--card-2);
color: var(--ink); color: var(--ink);
} }
.status-menu button.on {
background: var(--card-2);
color: var(--ink);
font-weight: 600;
}
.status-menu button .av {
width: 20px;
height: 20px;
font-size: 10px;
}
.status-menu button.menu-clear {
margin-top: 4px;
border-top: 1px solid var(--line);
border-radius: 0 0 8px 8px;
color: var(--muted);
font-size: 12.5px;
}
.status-menu button.menu-clear .ic {
width: 13px;
height: 13px;
}
/* 담당자 pill — 아바타 인라인 */
.status-pill.assignee-pill {
gap: 6px;
padding: 4px 9px 4px 5px;
}
.status-pill.assignee-pill .av {
width: 20px;
height: 20px;
font-size: 10px;
}
.status-pill .dp-unset {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--muted);
}
.status-pill .dp-unset .ic {
width: 13px;
height: 13px;
}
/* 마감일 — 네이티브 date input을 글래스 pill 위로 투명하게 겹침 */
.dp-date-wrap {
position: relative;
display: inline-flex;
align-items: center;
}
.dp-date-face {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 5px 11px;
border-radius: 999px;
font-size: 12.5px;
font-weight: 600;
background: var(--card-2);
border: 1px solid var(--line);
pointer-events: none;
}
.dp-date-face .ic {
width: 13px;
height: 13px;
color: var(--muted);
}
.dp-date {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.dp-date::-webkit-calendar-picker-indicator {
cursor: pointer;
}
.dp-date-clear {
display: inline-grid;
place-items: center;
width: 22px;
height: 22px;
margin-left: 4px;
border-radius: 50%;
color: var(--muted);
position: relative;
z-index: 1;
}
.dp-date-clear .ic {
width: 13px;
height: 13px;
}
.dp-date-clear:hover {
background: var(--card-2);
color: var(--ink);
}
.dp-label { .dp-label {
display: flex; display: flex;
@ -2193,3 +2289,83 @@
animation: none !important; animation: none !important;
} }
} }
/* ── phase-18: 칸반 드래그 정렬 위치 표시 + 코멘트 수정/삭제 ── */
.kcard {
position: relative;
}
.kcard.drop-before::before,
.kcard.drop-after::after {
content: "";
position: absolute;
left: 0;
right: 0;
height: 2px;
background: var(--accent, var(--blue));
border-radius: 2px;
}
.kcard.drop-before::before {
top: -4px;
}
.kcard.drop-after::after {
bottom: -4px;
}
/* 코멘트 수정/삭제 액션 */
.cmt-acts {
display: inline-flex;
gap: 4px;
margin-left: auto;
opacity: 0;
transition: opacity 0.12s;
}
.cmt-row:hover .cmt-acts {
opacity: 1;
}
.cmt-acts button {
background: transparent;
border: none;
cursor: pointer;
color: var(--faint);
display: flex;
padding: 2px;
border-radius: 5px;
}
.cmt-acts button:hover {
color: var(--ink);
background: var(--card-3);
}
.cmt-acts button.danger:hover {
color: var(--coral);
}
.cmt-acts .ic {
width: 13px;
height: 13px;
}
.cmt-edit {
display: flex;
align-items: center;
gap: 6px;
margin-top: 2px;
}
.cmt-edit input {
flex: 1;
font-size: 13px;
padding: 5px 8px;
border: 1px solid var(--line);
border-radius: 7px;
background: var(--card);
color: var(--ink);
}
.cmt-edit button {
font-size: 12px;
padding: 5px 10px;
border-radius: 7px;
border: 1px solid var(--line);
background: var(--card);
cursor: pointer;
}
.cmt-edit button.ghost {
border: none;
color: var(--faint);
}

@ -0,0 +1,82 @@
// frontend/tests/connectors/AddAccountMenu.test.tsx — phase-16 계정 추가(OAuth) 메뉴
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AddAccountMenu } from "@/components/connectors/AddAccountMenu";
import { connectorsApi } from "@/lib/connectors/api";
vi.mock("@/lib/connectors/api", () => ({
connectorsApi: { providers: vi.fn(), oauthStart: vi.fn() },
}));
const providersMock = vi.mocked(connectorsApi.providers);
const oauthStartMock = vi.mocked(connectorsApi.oauthStart);
const assignMock = vi.fn();
const originalLocation = window.location;
beforeEach(() => {
providersMock.mockReset();
oauthStartMock.mockReset();
assignMock.mockReset();
// jsdom location.assign 은 non-configurable → location 객체 자체를 교체
Object.defineProperty(window, "location", {
configurable: true,
writable: true,
value: { ...originalLocation, assign: assignMock },
});
});
afterEach(() => {
Object.defineProperty(window, "location", {
configurable: true,
writable: true,
value: originalLocation,
});
vi.restoreAllMocks();
});
const PROVIDERS = [
{ domain: "mail", provider: "gmail", label: "Gmail", configured: true },
{ domain: "mail", provider: "outlook", label: "Outlook", configured: false },
];
describe("AddAccountMenu", () => {
it("provider 가 없으면 아무것도 렌더하지 않는다", async () => {
providersMock.mockResolvedValue([]);
const { container } = render(<AddAccountMenu domain="mail" redirectAfter="/settings" />);
await waitFor(() => expect(providersMock).toHaveBeenCalledWith("mail"));
expect(container.textContent).toBe("");
});
it("열면 configured/미구성 provider 를 보여준다", async () => {
providersMock.mockResolvedValue(PROVIDERS);
render(<AddAccountMenu domain="mail" redirectAfter="/settings?tab=mail" />);
const btn = await screen.findByText("계정 추가");
fireEvent.click(btn);
expect(screen.getByRole("menuitem", { name: /Gmail/ })).not.toBeDisabled();
expect(screen.getByRole("menuitem", { name: /Outlook/ })).toBeDisabled(); // 미구성
expect(screen.getByText("설정 필요")).toBeInTheDocument();
});
it("configured provider 클릭 시 oauthStart 후 authorize_url 로 이동", async () => {
providersMock.mockResolvedValue(PROVIDERS);
oauthStartMock.mockResolvedValue({ authorize_url: "https://accounts.google.com/o/oauth2/x" });
render(<AddAccountMenu domain="mail" redirectAfter="/settings?tab=mail" />);
fireEvent.click(await screen.findByText("계정 추가"));
fireEvent.click(screen.getByRole("menuitem", { name: /Gmail/ }));
await waitFor(() =>
expect(oauthStartMock).toHaveBeenCalledWith("mail", "gmail", "/settings?tab=mail"),
);
await waitFor(() =>
expect(assignMock).toHaveBeenCalledWith("https://accounts.google.com/o/oauth2/x"),
);
});
it("oauthStart 실패 시 onError 토스트 호출", async () => {
providersMock.mockResolvedValue(PROVIDERS);
oauthStartMock.mockRejectedValue(new Error("400"));
const onError = vi.fn();
render(<AddAccountMenu domain="mail" redirectAfter="/settings" onError={onError} />);
fireEvent.click(await screen.findByText("계정 추가"));
fireEvent.click(screen.getByRole("menuitem", { name: /Gmail/ }));
await waitFor(() => expect(onError).toHaveBeenCalledWith("Gmail 연결을 시작하지 못했어요"));
});
});

@ -18,6 +18,7 @@ const base: ConnectorStatus = {
last: "방금 동기화", last: "방금 동기화",
last_synced_at: null, last_synced_at: null,
error_detail: "", error_detail: "",
email: "",
}; };
const noop = () => {}; const noop = () => {};

@ -0,0 +1,35 @@
// frontend/tests/connectors/api.test.ts — connectorsApi OAuth/providers 쿼리 구성
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { connectorsApi } from "@/lib/connectors/api";
function mockFetch(payload: unknown) {
const fn = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(payload),
});
vi.stubGlobal("fetch", fn);
return fn;
}
beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.unstubAllGlobals());
describe("connectorsApi", () => {
it("oauthStart 는 domain/provider/redirect_after 를 인코딩해 호출", async () => {
const fetchMock = mockFetch({ authorize_url: "https://x" });
const res = await connectorsApi.oauthStart("mail", "outlook", "/settings?tab=mail");
expect(res.authorize_url).toBe("https://x");
const url = fetchMock.mock.calls[0][0] as string;
expect(url).toContain("/api/connectors/oauth/start?");
expect(url).toContain("domain=mail");
expect(url).toContain("provider=outlook");
expect(url).toContain("redirect_after=%2Fsettings%3Ftab%3Dmail");
});
it("providers 는 domain 필터 쿼리를 붙인다", async () => {
const fetchMock = mockFetch([{ domain: "mail", provider: "gmail", label: "Gmail", configured: true }]);
const rows = await connectorsApi.providers("mail");
expect(rows[0].provider).toBe("gmail");
expect(fetchMock.mock.calls[0][0]).toContain("/api/connectors/providers?domain=mail");
});
});
Loading…
Cancel
Save