From 10fcaec08085dd6d94c317b4ee2c714eab4cab19 Mon Sep 17 00:00:00 2001 From: I Luk Kim Date: Fri, 19 Jun 2026 15:53:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=EC=9E=91=EC=97=85=C2=B7=EC=9D=B8?= =?UTF-8?q?=EB=B0=95=EC=8A=A4=C2=B7=EC=84=A4=EC=A0=95/=EC=97=B0=EB=8F=99?= =?UTF-8?q?=20UI=20+=20=EC=A0=84=EC=97=AD=20=EB=8B=A4=EC=9D=B4=EC=96=BC?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 설정→연동(계정 추가 메뉴·커넥터 목록·메일/LLM 탭), 작업 칸반·상세·댓글, 인박스 캡처/분류 개선 - 전역 Dialog(confirm/alert) 컴포넌트로 system alert/confirm 대체 - 작업 라우팅 [[...slug]], 공용 lib(api/auth/nav/types 등)·스타일 갱신, 관련 테스트 Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/routers/connectors.py | 96 ++++- backend/app/routers/settings.py | 18 +- backend/app/routers/tasks.py | 40 ++ backend/tests/test_api_dashboard.py | 6 +- backend/tests/test_api_inbox.py | 6 +- backend/tests/test_api_tasks.py | 63 ++- backend/tests/test_approvals_service.py | 12 +- backend/tests/test_connector_routes.py | 73 +--- backend/tests/test_dashboard.py | 2 +- backend/tests/test_settings.py | 35 +- frontend/app/layout.tsx | 5 +- frontend/app/tasks/[[...slug]]/page.tsx | 8 + frontend/app/tasks/page.tsx | 7 - frontend/components/Dialog.tsx | 127 ++++++ .../components/connectors/AddAccountMenu.tsx | 81 ++++ frontend/components/inbox/CaptureRow.tsx | 2 +- frontend/components/inbox/InboxView.tsx | 2 - frontend/components/inbox/ReasonLine.tsx | 22 +- .../components/settings/ConnectionsTab.tsx | 4 +- .../components/settings/ConnectorList.tsx | 12 + frontend/components/settings/LlmTab.tsx | 10 +- frontend/components/settings/MailTab.tsx | 4 +- .../components/settings/SettingsClient.tsx | 20 + frontend/components/tasks/Comments.tsx | 58 ++- frontend/components/tasks/DetailPanel.tsx | 150 ++++++- frontend/components/tasks/KanbanCard.tsx | 13 +- frontend/components/tasks/KanbanView.tsx | 41 +- frontend/components/tasks/TasksClient.tsx | 45 ++ frontend/lib/api.ts | 2 +- frontend/lib/auth.ts | 2 +- frontend/lib/connectors/api.ts | 22 +- frontend/lib/dashboard/api.ts | 2 +- frontend/lib/hooks/useConnectors.ts | 8 +- frontend/lib/inbox/api.ts | 2 +- frontend/lib/inbox/multimodal.ts | 2 +- frontend/lib/nav.ts | 3 - frontend/lib/proactive/api.ts | 2 +- frontend/lib/tasks/api.ts | 15 + frontend/lib/types.ts | 406 ++---------------- frontend/lib/weekly/api.ts | 2 +- frontend/next.config.ts | 4 + frontend/styles/connectors.css | 53 +++ frontend/styles/globals.css | 91 ++++ frontend/styles/tasks.css | 176 ++++++++ .../tests/connectors/AddAccountMenu.test.tsx | 82 ++++ .../tests/connectors/ConnectorCard.test.tsx | 1 + frontend/tests/connectors/api.test.ts | 35 ++ 47 files changed, 1293 insertions(+), 579 deletions(-) create mode 100644 frontend/app/tasks/[[...slug]]/page.tsx delete mode 100644 frontend/app/tasks/page.tsx create mode 100644 frontend/components/Dialog.tsx create mode 100644 frontend/components/connectors/AddAccountMenu.tsx create mode 100644 frontend/tests/connectors/AddAccountMenu.test.tsx create mode 100644 frontend/tests/connectors/api.test.ts diff --git a/backend/app/routers/connectors.py b/backend/app/routers/connectors.py index 2d18f23..21160f0 100644 --- a/backend/app/routers/connectors.py +++ b/backend/app/routers/connectors.py @@ -2,19 +2,34 @@ # prefix 없음. main.py 에서 include_router(prefix="/api"). 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 sqlmodel import Session +from sqlmodel import Session, select from ..config import get_settings from ..connectors import oauth as oauthlib from ..connectors.registry import ConnectorRegistry -from ..db import get_session -from ..models import ConnectorAccount, ConnectorAccountLog, ConnState -from ..schemas import ConnectorStatusOut, ImportResultOut, OAuthStartOut, SyncResultOut +from ..db import engine, get_session +from ..models import ConnectorAccount, ConnectorAccountLog, ConnState, OAuthState +from ..schemas import ( + ConnectorProviderOut, + ConnectorStatusOut, + ImportResultOut, + OAuthStartOut, + SyncResultOut, +) 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]) 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] +@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) def sync_one(account_id: str, full: bool = False, s: Session = Depends(get_session)): a = s.get(ConnectorAccount, account_id) @@ -42,6 +76,12 @@ def sync_all(s: Session = Depends(get_session)): # ── 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) def oauth_start( domain: str, provider: str, redirect_after: str = "/life", s: Session = Depends(get_session) @@ -49,24 +89,56 @@ def oauth_start( cfg = get_settings() if provider in ("gmail", "google_calendar") and not cfg.google_client_id: 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: raise HTTPException(400, "notion_client_id 미설정 — real 연동 비활성") url = oauthlib.start_oauth(s, domain, provider, redirect_after) 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") -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 + os_row = s.get(OAuthState, state) + redirect_after = os_row.redirect_after if os_row else "/life" try: acct = oauthlib.finish_oauth(s, code, state) except Exception: - return RedirectResponse(url=f"{origin}/life?connect=error") - try: - ConnectorRegistry.get(s, acct).sync(s, full=True) # 연결 직후 1회 초기 sync - except Exception: - pass - return RedirectResponse(url=f"{origin}/life?connect=ok&domain={acct.domain.value}") + return RedirectResponse(url=f"{origin}{_with_query(redirect_after, 'connect=error')}") + # 초기 sync 는 백그라운드로(받은편지함이 크면 수십 초 소요 → 콜백은 즉시 리다이렉트). + bg.add_task(_initial_sync, acct.id) + q = f"connect=ok&domain={acct.domain.value}&provider={acct.provider}" + return RedirectResponse(url=f"{origin}{_with_query(redirect_after, q)}") @router.post("/connectors/{account_id}/disconnect", response_model=ConnectorStatusOut) diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py index 6582d0d..c7f3391 100644 --- a/backend/app/routers/settings.py +++ b/backend/app/routers/settings.py @@ -5,7 +5,6 @@ from sqlmodel import Session from .. import runtime_config from ..config import get_settings from ..db import get_session -from ..llm.heuristic import HeuristicProvider from ..llm.ollama import OllamaProvider from ..llm.provider import LLMProvider from ..schemas import ( @@ -35,14 +34,9 @@ def get_system(s: Session = Depends(get_session)): database=_db_dialect(st.database_url), sync_interval_minutes=st.sync_interval_minutes, sync_page_size=st.sync_page_size, - web_search_provider=st.web_search_provider, connector_modes={ "calendar": st.connector_calendar, "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, timeout=eff.timeout, 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, vision_provider=st.vision_provider, ) @@ -90,13 +81,8 @@ def test_llm(body: LLMTestIn): eff = runtime_config.effective_llm() provider = body.provider or eff.provider - impl: LLMProvider - if provider == "heuristic": - impl = HeuristicProvider() - else: - # auto/ollama 모두 ollama 핑으로 테스트(auto 는 실제로 ollama 도달성 확인이 핵심) - impl = OllamaProvider(host=body.host or eff.host, model=body.model or eff.model) - + # 실 LLM 전용(휴리스틱 제거): 항상 Ollama 도달성으로 테스트. + impl: LLMProvider = OllamaProvider(host=body.host or eff.host, model=body.model or eff.model) h = impl.health() return LLMTestOut( reachable=bool(h.get("reachable")), diff --git a/backend/app/routers/tasks.py b/backend/app/routers/tasks.py index c05447e..799f422 100644 --- a/backend/app/routers/tasks.py +++ b/backend/app/routers/tasks.py @@ -12,12 +12,14 @@ from ..models import Person, Project, Task, TaskComment, TaskStatus from ..schemas import ( CommentCreate, CommentOut, + CommentPatch, RiskOut, ScaffoldOut, ScaffoldRequest, TaskCreate, TaskNode, TaskPatch, + TaskReorderRequest, ) from ..services.risk import compute_risks 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} +@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) def add_comment(tid: str, body: CommentCreate, s: Session = Depends(get_session)): 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) +@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) def scaffold(tid: str, body: ScaffoldRequest, s: Session = Depends(get_session)): t = s.get(Task, tid) diff --git a/backend/tests/test_api_dashboard.py b/backend/tests/test_api_dashboard.py index b928c1e..a08ab74 100644 --- a/backend/tests/test_api_dashboard.py +++ b/backend/tests/test_api_dashboard.py @@ -5,10 +5,10 @@ def test_dashboard(client): assert d["briefing"]["weather"]["temp"] == 24 assert d["briefing"]["weather"]["icon"] == "sun" # cloudSun 저장 → sun 표시 assert d["saved_today"] == "47분" and d["today_routed"] == 7 - assert any(e["soon"] for e in d["schedule"]) # 14:00 분기 전략 미팅 soon - assert d["badges"]["appr"] == 4 # high risk 3건 + 심부름 게이트(phase-14) + assert isinstance(d["schedule"], list) # phase-16+: 일정 시드 제거 → 빈 일정(연결 전) + assert d["badges"]["appr"] == 3 # high risk 3건 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 d["goals"][0]["tone"] == "blue" # tone 키('var(--blue)' 아님) # inbox_recent 는 평탄화 형태 {id,kind,raw,type,proj_label,tone} diff --git a/backend/tests/test_api_inbox.py b/backend/tests/test_api_inbox.py index 01ec824..7a0e305 100644 --- a/backend/tests/test_api_inbox.py +++ b/backend/tests/test_api_inbox.py @@ -28,12 +28,12 @@ def test_dismiss(client): assert client.post(f"/api/inbox/{iid}/dismiss").json()["status"] == "dismissed" -def test_capture_model_is_heuristic(client): - # 테스트는 휴리스틱 강제 → model 표기 heuristic +def test_capture_model_is_fake(client): + # 테스트는 결정적 FakeLLM 주입(conftest) → model 표기 fake cap = client.post( "/api/inbox/capture", json={"kind": "text", "raw": "수요일 11시 자전거 수리"} ).json() - assert cap["classification"]["model"] == "heuristic" + assert cap["classification"]["model"] == "fake" def test_confirm_event_no_task(client): diff --git a/backend/tests/test_api_tasks.py b/backend/tests/test_api_tasks.py index 19ff9ee..8cd2e96 100644 --- a/backend/tests/test_api_tasks.py +++ b/backend/tests/test_api_tasks.py @@ -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() - k1 = next(t for t in work if t["id"] == "k1") - assert k1["children"], "k1 has subtasks" - assert len(k1["comments"]) == 2 + w1 = next(t for t in work if t["id"] == "w1") + assert w1["children"], "w1 has subtasks" + assert len(w1["comments"]) == 2 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): - r = client.patch("/api/tasks/k1", json={"status": "review"}).json() +def test_task_status_move(client, session): + 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" -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( - "/api/tasks/k1/comments", json={"person_id": "minseo", "text": "확인했습니다"} + "/api/tasks/t1/comments", json={"person_id": "minseo", "text": "확인했습니다"} ).json() assert c["text"] == "확인했습니다" -def test_task_filter_project_subtree(client): - # biz 프로젝트(+하위)의 작업: k1(biz-report), k6(biz-okr) 등 포함 +def test_task_filter_project_subtree(client, session): + 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() 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): @@ -33,18 +50,24 @@ def test_task_create_and_delete(client): assert client.delete(f"/api/tasks/{tid}").json()["deleted"] == tid -def test_create_subtask_parent(client): - body = {"title": "새 하위", "project_id": "biz-report", "parent_id": "k1", "status": "todo"} +def test_create_subtask_parent(client, session): + 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() - 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"]) -def test_patch_status_persists(client): - client.patch("/api/tasks/k4", json={"status": "done"}) - assert client.get("/api/tasks/k4").json()["status"] == "done" +def test_patch_status_persists(client, session): + s, _ = session + 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 (순환 방지) - 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 diff --git a/backend/tests/test_approvals_service.py b/backend/tests/test_approvals_service.py index 43f3198..80e0828 100644 --- a/backend/tests/test_approvals_service.py +++ b/backend/tests/test_approvals_service.py @@ -2,8 +2,8 @@ def test_derive_queue_mixed(client): q = client.get("/api/approvals").json() assert q["autonomy"] == "mixed" - # high 3(phase-7) + ap-er1(phase-14 심부름 게이트) = 4 대기, low 3 자동 - assert len(q["pending"]) == 4 and len(q["done"]) == 3 + # high 3(phase-7) 대기, low 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["done"]} == {"low"} @@ -11,14 +11,14 @@ def test_derive_queue_mixed(client): def test_autonomy_approval_first_all_pending(client): client.patch("/api/approvals/autonomy", json={"level": "approval_first"}) q = client.get("/api/approvals").json() - assert len(q["pending"]) == 7 and len(q["done"]) == 0 # +ap-er1(phase-14) - assert q["badges_appr"] == 7 + assert len(q["pending"]) == 6 and len(q["done"]) == 0 + assert q["badges_appr"] == 6 def test_autonomy_full_auto_empty_queue(client): client.patch("/api/approvals/autonomy", json={"level": "full_auto"}) 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): @@ -37,7 +37,7 @@ def test_approve_and_undo_cycle(client): def test_approve_all(client): 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() assert len(q["pending"]) == 0 diff --git a/backend/tests/test_connector_routes.py b/backend/tests/test_connector_routes.py index e4c9bb6..ad2ccce 100644 --- a/backend/tests/test_connector_routes.py +++ b/backend/tests/test_connector_routes.py @@ -1,7 +1,9 @@ # backend/tests/test_connector_routes.py — 라우터 + 로컬 우선 임포트(C10 CSV / C11 ics / C12 disconnect) +# phase-16+: 메일/일정 mock 계정 시드 제거 → 해당 테스트는 계정을 직접 생성. import pytest from app.config import get_settings +from app.models import ConnectorAccount, ConnectorDomain, ConnState @pytest.fixture(autouse=True) @@ -10,44 +12,12 @@ def _clear_settings(): get_settings.cache_clear() -def test_list_connectors_seed(client): - rows = client.get("/api/connectors").json() - assert len(rows) == 14 # mail3+cal1+health3+fin4+kn3 - fit = next(r for r in rows if r["id"] == "ca-health-fit") - assert fit["on"] is False and fit["last"] == "연결 안 됨" - woori = next(r for r in rows if r["id"] == "ca-fin-woori") - 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 불필요). +def test_ics_import_calendar(client, session, monkeypatch): + # C11: .ics → event (OAuth 불필요). 일정 계정을 직접 생성. + s, _ = session + s.add(ConnectorAccount(id="ca-cal-google", domain=ConnectorDomain.calendar, + provider="google_calendar", name="Google 캘린더")) + s.commit() monkeypatch.setenv("CONNECTOR_CALENDAR", "ics") get_settings.cache_clear() ics = ( @@ -70,31 +40,18 @@ def test_ics_import_calendar(client, monkeypatch): assert body["entity_type"] == "event" and body["imported"] == 1 -def test_import_unsupported_connector_400(client): - # 기본 mock 금융은 import_bytes 미지원 → 400 - r = client.post( - "/api/connectors/ca-fin-toss/import", - files={"file": ("x.csv", b"a,b\n1,2\n", "text/csv")}, - ) - assert r.status_code == 400 - - -def test_disconnect_purges_token(client): - # C12: 해제 → 토큰 폐기 + state disconnected (기존 데이터는 보존) +def test_disconnect_purges_token(client, session): + # C12: 해제 → 토큰 폐기 + state disconnected. 연결된 메일 계정을 직접 생성. + s, _ = session + s.add(ConnectorAccount(id="ca-mail-work", domain=ConnectorDomain.mail, provider="gmail", + state=ConnState.connected, external_account_id="me@gmail.com", + token_enc="enc", name="me@gmail.com")) + s.commit() r = client.post("/api/connectors/ca-mail-work/disconnect").json() 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): # GOOGLE_CLIENT_ID 미설정 → 400 (앱 안 죽음) r = client.get("/api/connectors/oauth/start?domain=mail&provider=gmail") 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 diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py index f2a61d4..1dbce6d 100644 --- a/backend/tests/test_dashboard.py +++ b/backend/tests/test_dashboard.py @@ -72,6 +72,6 @@ def test_goals_three_with_tone_key(client): def test_badges_match_counts(client): d = client.get("/api/dashboard").json() 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["noti"] == 6 diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py index 67a810d..5dbfc6b 100644 --- a/backend/tests/test_settings.py +++ b/backend/tests/test_settings.py @@ -8,43 +8,36 @@ def test_system_snapshot_readonly(client): assert d["env"] == "dev" assert d["auth_enabled"] is False # 데모 기본 off assert d["database"] == "sqlite" - assert set(d["connector_modes"]) == { - "calendar", - "mail", - "chat", - "finance", - "health", - "knowledge", - } + assert set(d["connector_modes"]) == {"calendar", "mail"} # 기본은 전부 mock assert all(v == "mock" for v in d["connector_modes"].values()) def test_llm_config_defaults_match_env(client): 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 "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): - # heuristic 으로 저장 → 영속 + 런타임 반영 - r = client.put("/api/settings/llm", json={"provider": "heuristic", "model": "my-model"}) + # ollama + 커스텀 모델로 저장 → 영속 + 런타임 반영 + r = client.put("/api/settings/llm", json={"provider": "ollama", "model": "my-model"}) assert r.status_code == 200 body = r.json() - assert body["provider"] == "heuristic" + assert body["provider"] == "ollama" assert body["model"] == "my-model" assert body["overridden"] is True # 다시 GET 해도 유지(영속) 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 없이) - assert runtime_config.effective_llm().provider == "heuristic" - assert get_provider().name == "heuristic" + assert runtime_config.effective_llm().provider == "ollama" + assert get_provider().name == "ollama" 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 -def test_llm_test_heuristic_reachable(client): - r = client.post("/api/settings/llm/test", json={"provider": "heuristic"}) +def test_llm_test_uses_ollama(client): + # phase-16+: 휴리스틱 옵션 제거 → 연결 테스트는 항상 Ollama 도달성으로 확인. + r = client.post("/api/settings/llm/test", json={"provider": "ollama"}) assert r.status_code == 200 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): diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index a787de4..6f3fd69 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { onest, dmMono } from "./fonts"; import { ThemeProvider } from "@/components/ThemeProvider"; +import { DialogProvider } from "@/components/Dialog"; import { Shell } from "@/components/Shell"; import "@/styles/globals.css"; @@ -16,7 +17,9 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - {children} + + {children} + diff --git a/frontend/app/tasks/[[...slug]]/page.tsx b/frontend/app/tasks/[[...slug]]/page.tsx new file mode 100644 index 0000000..00d47f6 --- /dev/null +++ b/frontend/app/tasks/[[...slug]]/page.tsx @@ -0,0 +1,8 @@ +// frontend/app/tasks/[[...slug]]/page.tsx — 작업 페이지 (옵셔널 캐치올: /tasks, /tasks/) +// 세그먼트는 TasksClient 가 useParams() 로 읽어 해당 작업 상세를 바로 연다. +import "@/styles/tasks.css"; +import { TasksClient } from "@/components/tasks/TasksClient"; + +export default function TasksPage() { + return ; +} diff --git a/frontend/app/tasks/page.tsx b/frontend/app/tasks/page.tsx deleted file mode 100644 index 3d004c3..0000000 --- a/frontend/app/tasks/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -// frontend/app/tasks/page.tsx -import "@/styles/tasks.css"; -import { TasksClient } from "@/components/tasks/TasksClient"; - -export default function TasksPage() { - return ; -} diff --git a/frontend/components/Dialog.tsx b/frontend/components/Dialog.tsx new file mode 100644 index 0000000..982a471 --- /dev/null +++ b/frontend/components/Dialog.tsx @@ -0,0 +1,127 @@ +// frontend/components/Dialog.tsx +// 앱 전역 통합 다이얼로그 — window.confirm/alert 대체. +// useDialog().confirm({...}) → Promise, .alert({...}) → Promise. +"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; + alert: (opts: DialogOptions) => Promise; +}; + +const Ctx = createContext(null); + +export function useDialog(): DialogApi { + const c = useContext(Ctx); + if (!c) throw new Error("useDialog must be used within "); + 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(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 ( +
onClose(false)} role="presentation"> +
e.stopPropagation()} + > + {state.title &&

{state.title}

} + {state.message &&

{state.message}

} +
+ {isConfirm && ( + + )} + +
+
+
+ ); +} + +export function DialogProvider({ children }: { children: React.ReactNode }) { + const [state, setState] = useState(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((resolve) => { + resolver.current = resolve; + setState({ kind: "confirm", ...opts }); + }), + [], + ); + + const alert = useCallback( + (opts: DialogOptions) => + new Promise((resolve) => { + resolver.current = () => resolve(); + setState({ kind: "alert", ...opts }); + }), + [], + ); + + return ( + + {children} + {state && } + + ); +} diff --git a/frontend/components/connectors/AddAccountMenu.tsx b/frontend/components/connectors/AddAccountMenu.tsx new file mode 100644 index 0000000..9cf2a47 --- /dev/null +++ b/frontend/components/connectors/AddAccountMenu.tsx @@ -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([]); + const [open, setOpen] = useState(false); + const ref = useRef(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 ( +
+ + {open && ( +
+ {providers.map((p) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/components/inbox/CaptureRow.tsx b/frontend/components/inbox/CaptureRow.tsx index d7c820e..d0f2ab5 100644 --- a/frontend/components/inbox/CaptureRow.tsx +++ b/frontend/components/inbox/CaptureRow.tsx @@ -43,7 +43,7 @@ export default function CaptureRow({ ) : c.classification ? ( <> onReType(c.id)} /> - + {canAct && (
)} + {addDomain && ( + toast(m, "coral")} + /> + )}
diff --git a/frontend/components/settings/LlmTab.tsx b/frontend/components/settings/LlmTab.tsx index 0a3b1ea..3fc0420 100644 --- a/frontend/components/settings/LlmTab.tsx +++ b/frontend/components/settings/LlmTab.tsx @@ -17,7 +17,7 @@ const PROVIDER_LABEL: Record = { export function LlmTab({ toast }: { toast: ToastFn }) { const { data: cfg, isLoading, refresh } = useLlmConfig(); - const [provider, setProvider] = useState("auto"); + const [provider, setProvider] = useState("ollama"); const [model, setModel] = useState(""); const [host, setHost] = useState(""); const [timeout, setTimeoutVal] = useState(""); @@ -104,18 +104,14 @@ export function LlmTab({ toast }: { toast: ToastFn }) {
({ + options={(cfg?.provider_options ?? ["ollama"]).map((p) => ({ id: p, label: PROVIDER_LABEL[p as LlmProvider] ?? p, }))} value={provider} onChange={(id) => setProvider(id as LlmProvider)} /> - - {provider === "auto" && "Ollama 가 떠 있으면 사용하고, 아니면 규칙 기반으로 폴백해요."} - {provider === "ollama" && "로컬 Ollama 서버를 사용해요."} - {isHeuristic && "외부 모델 없이 규칙으로만 동작해요(오프라인·결정적)."} - + 로컬 Ollama 서버를 사용해요.
diff --git a/frontend/components/settings/MailTab.tsx b/frontend/components/settings/MailTab.tsx index 10c7698..1099887 100644 --- a/frontend/components/settings/MailTab.tsx +++ b/frontend/components/settings/MailTab.tsx @@ -12,7 +12,9 @@ export function MailTab({ toast }: { toast: ToastFn }) { filter={(c) => c.domain === "mail"} toast={toast} showSyncAll - emptyText="연결된 메일 계정이 없어요." + emptyText="연결된 메일 계정이 없어요. ‘계정 추가’로 Gmail·Outlook을 연결하세요." + addDomain="mail" + redirectAfter="/settings?tab=mail" /> ); } diff --git a/frontend/components/settings/SettingsClient.tsx b/frontend/components/settings/SettingsClient.tsx index 3c9be62..a053c0f 100644 --- a/frontend/components/settings/SettingsClient.tsx +++ b/frontend/components/settings/SettingsClient.tsx @@ -74,6 +74,26 @@ export function SettingsClient() { 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 = { + 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 (
diff --git a/frontend/components/tasks/Comments.tsx b/frontend/components/tasks/Comments.tsx index e84cde0..6e0c6d1 100644 --- a/frontend/components/tasks/Comments.tsx +++ b/frontend/components/tasks/Comments.tsx @@ -26,19 +26,34 @@ export function Comments({ people, me, onAdd, + onEdit, + onDelete, }: { list: TaskComment[]; people: Record; me: Person; onAdd: (text: string) => void; + onEdit?: (cid: string, text: string) => void; + onDelete?: (cid: string) => void; }) { const [txt, setTxt] = useState(""); + const [editId, setEditId] = useState(null); + const [editTxt, setEditTxt] = useState(""); const submit = () => { if (txt.trim()) { onAdd(txt.trim()); 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 (
@@ -47,6 +62,8 @@ export function Comments({ )} {list.map((c) => { const p = people[c.person_id] || me; + const mine = c.person_id === me.id; + const editing = editId === c.id; return (
@@ -58,9 +75,46 @@ export function Comments({ {p.name} {p.is_me ? " (나)" : ""} - {relTime(c.created_at)} + + {relTime(c.created_at)} + {c.edited_at ? " · 수정됨" : ""} + + {mine && onEdit && onDelete && !editing && ( + + + + + )}
-
{c.text}
+ {editing ? ( +
+ setEditTxt(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") saveEdit(); + if (e.key === "Escape") setEditId(null); + }} + /> + + +
+ ) : ( +
{c.text}
+ )}
); diff --git a/frontend/components/tasks/DetailPanel.tsx b/frontend/components/tasks/DetailPanel.tsx index 0e47b6a..05cc8af 100644 --- a/frontend/components/tasks/DetailPanel.tsx +++ b/frontend/components/tasks/DetailPanel.tsx @@ -1,10 +1,10 @@ // frontend/components/tasks/DetailPanel.tsx — 작업 상세 드로어 (포커스 드릴다운) "use client"; -import { Fragment, useEffect, useState } from "react"; +import { Fragment, useEffect, useMemo, useState } from "react"; import { Icon } from "@/components/Icon"; import { cx } from "@/lib/cx"; -import { COLUMNS, STATUS_META, dueDay, findPath, flattenTasks, stat } from "@/lib/tasks/tree"; -import type { Person, Project, Status, Task } from "@/lib/types"; +import { COLUMNS, STATUS_META, findPath, flattenTasks, stat } from "@/lib/tasks/tree"; +import type { Person, Prio, Project, Status, Task } from "@/lib/types"; import { Av, Tag } from "./bits"; import { Comments } from "./Comments"; import { RichNotes } from "./RichNotes"; @@ -26,6 +26,20 @@ function suggestCollaborator(tasks: Task[], projectId: string): string | null { 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 { tasks: Task[]; focusId: string; @@ -43,20 +57,30 @@ interface Props { onDelete: (id: string) => void; onNotes: (id: string, html: 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) { - const [menu, setMenu] = useState(false); + const [menu, setMenu] = useState(null); const [expanded, setExpanded] = useState>({}); const [adding, setAdding] = useState(false); 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 node = path[path.length - 1]; useEffect(() => { setAdding(false); - setMenu(false); + setMenu(null); }, [p.focusId]); useEffect(() => { const h = (e: KeyboardEvent) => { @@ -206,52 +230,150 @@ export function DetailPanel(p: Props) { people={p.people} me={p.me} 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} />
{/* 우측 — 메타 */}
+ {/* 담당자 — 인원 목록에서 선택 */}
담당자
-
- {person ? person.name : "미정"} - {person?.is_me ? " (나)" : ""} +
+ + {menu === "assignee" && ( +
+ {peopleList.map((pr) => ( + + ))} + {person && ( + + )} +
+ )}
+ + {/* 마감일 — 날짜 입력 (비우면 null) */}
마감일
-
{node.due ? `6월 ${dueDay(node.due)}일` : "미정"}
+
+ + p.onField(node.id, { due: e.target.value ? e.target.value : null }) + } + /> + + {dueText(node.due)} + + {node.due && ( + + )} +
+ + {/* 우선순위 — 드롭다운 */}
우선순위
-
- {node.prio} +
+ + {menu === "prio" && ( +
+ {PRIO_OPTS.map((o) => ( + + ))} +
+ )}
+ + {/* 상태 — 드롭다운 */}
상태
- - {menu && ( + {menu === "status" && (
{COLUMNS.map((c) => (