From 5f7e0051cd75c46d2e4e7587cdbefd280df0cc3f Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Fri, 15 May 2026 13:39:56 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A9=94=EC=9D=B8=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=20=EC=9B=90=EB=B3=B5=EC=8B=9C=ED=82=A4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../channels/components/MultiChannelInput.tsx | 25 ++--- .../clinics/hooks/useClinicWorkspace.ts | 74 ++++++++++++ .../clinics/pages/ClinicWorkspacePage.tsx | 46 +++++--- src/features/landing/components/CTA.tsx | 3 +- .../report/hooks/useAnalysisPipeline.ts | 106 ++++++++---------- src/shared/api/api.ts | 18 +-- src/shared/layouts/Navbar.tsx | 14 --- src/shared/lib/utils.ts | 4 + vite.config.ts | 11 +- 9 files changed, 178 insertions(+), 123 deletions(-) create mode 100644 src/features/clinics/hooks/useClinicWorkspace.ts diff --git a/src/features/channels/components/MultiChannelInput.tsx b/src/features/channels/components/MultiChannelInput.tsx index babb2a1..f8c6129 100644 --- a/src/features/channels/components/MultiChannelInput.tsx +++ b/src/features/channels/components/MultiChannelInput.tsx @@ -31,7 +31,6 @@ import { pickPrimaryUrl, type ClassifiedUrls, } from '../lib/classifyUrls'; -import { Button } from '@/shared/ui/button'; import { PLATFORM_META } from '@/features/clinics/components/PlatformChips'; import type { PlatformKey } from '@/features/clinics/types/workspace'; @@ -159,15 +158,13 @@ export default function MultiChannelInput({ variant = 'hero', onAnalyze }: Multi const Icon = meta.Icon; const value = urls[key]; const status = validateField(value, key); - const inactiveColor = isHero ? '#94a3b8' /* slate-400 */ : 'rgba(255,255,255,0.4)'; return (
- {/* 좌측 채널 아이콘 — 입력 시 브랜드 컬러, 빈 칸일 땐 회색. - z-10 으로 input 의 bg/backdrop-blur 위에 올림 (없으면 input 배경이 아이콘을 가림) */} -
+ {/* 좌측 채널 아이콘 — 입력 시 컬러 적용, 빈 칸일 땐 회색 */} +
- {/* 우측 검증 상태 아이콘 — 동일하게 z-10 */} -
+ {/* 우측 검증 상태 아이콘 */} +
{status === 'valid' && ( )} @@ -201,18 +198,18 @@ export default function MultiChannelInput({ variant = 'hero', onAnalyze }: Multi

{/* 분석 시작 버튼 — DS Primary pill */} - + + {/* 보조 안내 (하단) */}

diff --git a/src/features/clinics/hooks/useClinicWorkspace.ts b/src/features/clinics/hooks/useClinicWorkspace.ts new file mode 100644 index 0000000..e1388f3 --- /dev/null +++ b/src/features/clinics/hooks/useClinicWorkspace.ts @@ -0,0 +1,74 @@ +import { useMemo } from 'react'; +import { useGetClinic, useGetClinicHistory } from '@/shared/api/generated/clinics/clinics'; +import { AnalysisStatus } from '@/shared/api/model/analysisStatus'; +import type { + WorkspaceData, + WorkspaceRun, + WorkspaceRunStatus, +} from '../types/workspace'; + +/** + * /clinics/:clinicId 워크스페이스용 데이터 통합 훅. + * + * - useGetClinic: 클리닉 기본 정보 (hospital_name, hospital_name_en 등) + * - useGetClinicHistory: 분석 run 목록 + 시계열 지표 + * + * 백엔드에 plans 엔드포인트가 아직 없어 plans 는 빈 배열로 둠. + * (필요해지면 useGetPlan / 별도 list 엔드포인트 추가 시 합쳐 채워넣기) + */ +export function useClinicWorkspace(clinicId: string | undefined) { + const clinicQuery = useGetClinic(clinicId ?? '', { + query: { enabled: !!clinicId }, + }); + const historyQuery = useGetClinicHistory(clinicId ?? '', { + query: { enabled: !!clinicId }, + }); + + const data = useMemo(() => { + if (!clinicId) return null; + if (clinicQuery.data?.status !== 200) return null; + if (historyQuery.data?.status !== 200) return null; + + const clinic = clinicQuery.data.data; + const history = historyQuery.data.data; + + const runs: WorkspaceRun[] = history.runs.map((r) => ({ + runId: r.run_id, + startedAt: r.started_at, + completedAt: r.completed_at ?? null, + status: normalizeRunStatus(r.status), + overallScore: typeof r.overall_score === 'number' ? r.overall_score : null, + // 백엔드에 분석 대상 targets 가 아직 없음 — 빈 배열 + targets: [], + })); + + return { + clinic: { + clinicId: clinic.hospital_id, + name: clinic.hospital_name, + nameEn: clinic.hospital_name_en ?? undefined, + location: clinic.road_address ?? undefined, + defaultTargets: [], + }, + runs, + plans: [], + }; + }, [clinicId, clinicQuery.data, historyQuery.data]); + + return { + data, + isLoading: clinicQuery.isLoading || historyQuery.isLoading, + error: + clinicQuery.error?.message || + historyQuery.error?.message || + (clinicQuery.data && clinicQuery.data.status !== 200 ? '클리닉 정보를 불러올 수 없습니다.' : null) || + (historyQuery.data && historyQuery.data.status !== 200 ? '분석 이력을 불러올 수 없습니다.' : null), + }; +} + +function normalizeRunStatus(s: string): WorkspaceRunStatus { + if (s === AnalysisStatus.completed) return 'completed'; + if (s === AnalysisStatus.failed) return 'failed'; + if (s === 'queued') return 'queued'; + return 'running'; +} diff --git a/src/features/clinics/pages/ClinicWorkspacePage.tsx b/src/features/clinics/pages/ClinicWorkspacePage.tsx index 2e02582..5c1ceae 100644 --- a/src/features/clinics/pages/ClinicWorkspacePage.tsx +++ b/src/features/clinics/pages/ClinicWorkspacePage.tsx @@ -15,7 +15,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/shared/ui/tabs'; import { WorkspaceHeader } from '../components/WorkspaceHeader'; import { AnalysisTab } from '../components/tabs/AnalysisTab'; import { SettingsTab } from '../components/tabs/SettingsTab'; -import { mockWorkspace } from '../data/mockClinicWorkspace'; +import { useClinicWorkspace } from '../hooks/useClinicWorkspace'; import { PageContainer } from '@/shared/ui/page-container'; const VALID_TABS = ['analysis', 'settings'] as const; @@ -31,24 +31,16 @@ export default function ClinicWorkspacePage() { const requested = searchParams.get('tab'); const activeTab: TabKey = isTabKey(requested) ? requested : 'analysis'; - // TODO: 실 API 연동 (useGetClinicHistory + report 메타 보강) - const data = useMemo( - () => ({ - ...mockWorkspace, - clinic: { - ...mockWorkspace.clinic, - clinicId: clinicId ?? mockWorkspace.clinic.clinicId, - }, - }), - [clinicId], - ); + const { data, isLoading, error } = useClinicWorkspace(clinicId); const sortedRuns = useMemo( () => - [...data.runs].sort( - (a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(), - ), - [data.runs], + data + ? [...data.runs].sort( + (a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(), + ) + : [], + [data], ); const latestRun = sortedRuns[0]; const previousRun = sortedRuns[1]; @@ -79,6 +71,28 @@ export default function ClinicWorkspacePage() { window.scrollTo({ top: y, behavior: 'auto' }); }; + if (isLoading) { + return ( +

+
+
+

워크스페이스를 불러오는 중...

+
+
+ ); + } + + if (error || !data) { + return ( +
+
+

오류가 발생했습니다

+

{error ?? '워크스페이스 데이터를 찾을 수 없습니다.'}

+
+
+ ); + } + return (
- {/* 보조 CTA — 가격 플랜 (임시 비활성) + {/* 보조 CTA — 가격 플랜 */}
- */}
diff --git a/src/features/report/hooks/useAnalysisPipeline.ts b/src/features/report/hooks/useAnalysisPipeline.ts index ea20eee..a0d2081 100644 --- a/src/features/report/hooks/useAnalysisPipeline.ts +++ b/src/features/report/hooks/useAnalysisPipeline.ts @@ -2,16 +2,25 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { useNavigate, useLocation, useParams } from 'react-router'; import { useCreateClinic } from '@/shared/api/generated/clinics/clinics'; import { useStartAnalysis, getAnalysisStatus } from '@/shared/api/generated/analyses/analyses'; -import { getReport } from '@/shared/api/generated/reports/reports'; +import { AnalysisStatus } from '@/shared/api/model/analysisStatus'; +import { sleep } from '@/shared/lib/utils'; +// AnalyzePayload(MultiChannelInput) 와 호환되도록 string | string[] 둘 다 허용. +// 백엔드 SDK 는 단일 string 만 받으므로 내부에서 첫 요소만 추출. export type ManualChannels = { - instagram?: string; - youtube?: string; - facebook?: string; - naverBlog?: string; - gangnamUnni?: string; + instagram?: string | string[]; + youtube?: string | string[]; + facebook?: string | string[]; + naverBlog?: string | string[]; + gangnamUnni?: string | string[]; }; +function pickFirst(v: string | string[] | undefined): string | null { + if (!v) return null; + if (Array.isArray(v)) return v[0] ?? null; + return v; +} + export type Phase = | 'resuming' | 'discovering' @@ -47,22 +56,9 @@ function clearSession() { Object.values(SESSION_KEYS).forEach((k) => sessionStorage.removeItem(k)); } -// 백엔드 AnalysisStatus enum: discovering | collecting | analyzing | completed | failed -// UI Phase 스텝과 매핑 -function phaseFromStatus(s: string): Phase { - if (s === 'discovering') return 'collecting'; - if (s === 'collecting') return 'generating'; - if (s === 'analyzing') return 'planning'; - if (s === 'completed') return 'complete'; - return 'discovering'; -} - -function resumePhaseFromStatus(s: string): Phase { - if (s === 'discovering') return 'collecting'; - if (s === 'collecting') return 'generating'; - if (s === 'analyzing') return 'planning'; - return 'discovering'; -} +// 백엔드 AnalysisStatus 4단계를 UI 5단계로 매핑. +// analyzing 한 단계는 길어서 generating → planning 으로 시간 기반 분할. +const ANALYZING_SPLIT_MS = 30_000; interface UseAnalysisPipelineResult { phase: Phase; @@ -98,13 +94,26 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult { const runPipeline = useCallback( async ( startUrl?: string, - resumeFrom?: { reportId: string; clinicId?: string; runId?: string; phase: Phase }, + resumeFrom?: { reportId: string; clinicId?: string; runId?: string }, ) => { try { let reportId = resumeFrom?.reportId || ''; let clinicId = resumeFrom?.clinicId; let runId = resumeFrom?.runId; + // analyzing 진입 시각을 기록해서 generating → planning 시점을 시간으로 판단 + let analyzingStartedAt: number | null = null; + const mapPhase = (s: AnalysisStatus): Phase => { + if (s === AnalysisStatus.discovering) return 'discovering'; + if (s === AnalysisStatus.collecting) return 'collecting'; + if (s === AnalysisStatus.analyzing) { + if (analyzingStartedAt === null) analyzingStartedAt = Date.now(); + return Date.now() - analyzingStartedAt > ANALYZING_SPLIT_MS ? 'planning' : 'generating'; + } + if (s === AnalysisStatus.completed) return 'complete'; + return 'discovering'; + }; + // Phase 1: 신규 분석이면 createClinic → startAnalysis if (!resumeFrom?.runId) { if (!startUrl) throw new Error('No URL provided'); @@ -120,11 +129,11 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult { data: { clinic_id: clinicId, channels: { - youtube: manualChannels?.youtube ?? null, - instagram: manualChannels?.instagram ?? null, - facebook: manualChannels?.facebook ?? null, - naver_blog: manualChannels?.naverBlog ?? null, - gangnam_unni: manualChannels?.gangnamUnni ?? null, + youtube: pickFirst(manualChannels?.youtube), + instagram: pickFirst(manualChannels?.instagram), + facebook: pickFirst(manualChannels?.facebook), + naver_blog: pickFirst(manualChannels?.naverBlog), + gangnam_unni: pickFirst(manualChannels?.gangnamUnni), }, }, }); @@ -146,40 +155,20 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult { const statusRes = await getAnalysisStatus(runId); if (statusRes.status !== 200) throw new Error('분석 상태 조회에 실패했습니다.'); const statusData = statusRes.data; - setPhase(phaseFromStatus(statusData.status)); + setPhase(mapPhase(statusData.status)); if (statusData.channel_errors && Object.keys(statusData.channel_errors).length > 0) { console.warn('[pipeline] Partial failures:', statusData.channel_errors); } - if (statusData.status === 'completed') break; - if (statusData.status === 'failed') { + if (statusData.status === AnalysisStatus.completed) break; + if (statusData.status === AnalysisStatus.failed) { throw new Error('파이프라인 실패: failed'); } - await new Promise((r) => setTimeout(r, 1500)); + await sleep(1500); } setPhase('complete'); clearSession(); - - // 최종 리포트 조회 (best effort) - const reportRes = await getReport(reportId).catch(() => null); - const reportPayload = reportRes && reportRes.status === 200 ? reportRes.data : null; - setTimeout(() => { - navigate(`/report/${reportId}`, { - replace: true, - state: reportPayload - ? { - report: reportPayload, - metadata: { - url: startUrl, - clinicName: '', - generatedAt: new Date().toISOString(), - }, - reportId, - clinicId, - } - : undefined, - }); - }, 800); + navigate(`/report/${reportId}`, { replace: true }); } catch (err) { const msg = err instanceof Error ? err.message : 'An error occurred'; setError(msg); @@ -203,13 +192,12 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult { reportId: session.reportId, clinicId: session.clinicId || undefined, runId: session.runId || undefined, - phase, }); } else if (url || session.url) { hasStarted.current = false; runPipeline(url || session.url || undefined); } - }, [phase, url, runPipeline]); + }, [url, runPipeline]); const handleAbort = useCallback(() => { clearSession(); @@ -229,11 +217,11 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult { .then((res) => { if (res.status !== 200) throw new Error('status fetch failed'); const status = res.data; - if (status.status === 'completed') { + if (status.status === AnalysisStatus.completed) { navigate(`/report/${urlReportId}`, { replace: true }); return; } - if (status.status === 'failed') { + if (status.status === AnalysisStatus.failed) { setError('Data collection failed. Please retry.'); setPhase('collecting'); return; @@ -248,7 +236,6 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult { reportId: urlReportId, clinicId: session.clinicId || undefined, runId, - phase: resumePhaseFromStatus(status.status), }); }) .catch(() => { @@ -266,7 +253,7 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult { .then((res) => { if (res.status !== 200) throw new Error('status fetch failed'); const status = res.data; - if (status.status === 'completed') { + if (status.status === AnalysisStatus.completed) { clearSession(); navigate(`/report/${session.reportId}`, { replace: true }); return; @@ -275,7 +262,6 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult { reportId: session.reportId!, clinicId: session.clinicId || undefined, runId, - phase: resumePhaseFromStatus(status.status), }); }) .catch(() => { diff --git a/src/shared/api/api.ts b/src/shared/api/api.ts index 70e5577..196a1c4 100644 --- a/src/shared/api/api.ts +++ b/src/shared/api/api.ts @@ -8,16 +8,7 @@ */ import ky, { type KyInstance } from 'ky' -// Vite 가 빌드 시 치환하는 환경변수 (개발 서버에서도 동일). -// import.meta 를 모듈 최상단에서 읽으면 일부 번들러가 cjs 변환 시 경고를 내므로 -// 함수 내부에서 lazy 하게 접근. -function getApiKey(): string | undefined { - try { - return (import.meta as { env?: { VITE_API_KEY?: string } }).env?.VITE_API_KEY; - } catch { - return undefined; - } -} +const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? '').replace(/\/$/, '') export const kyInstance: KyInstance = ky.create({ timeout: 10_000, @@ -27,7 +18,7 @@ export const kyInstance: KyInstance = ky.create({ hooks: { beforeRequest: [ (request) => { - const apiKey = getApiKey(); + const apiKey = import.meta.env.VITE_API_KEY; if (apiKey) request.headers.set('x-api-key', apiKey); }, // TODO: 인증 토큰 주입 @@ -46,7 +37,10 @@ export const customFetcher = async ( url: string, init?: RequestInit, ): Promise => { - const response = await kyInstance(url, init as Parameters[1]) + // orval 이 생성하는 url 은 `/api/...` 상대경로라서, 그대로 두면 dev 서버 origin 으로 감. + // VITE_API_BASE_URL 이 있으면 절대 URL 로 만들어서 백엔드로 직접 요청. + const fullUrl = /^https?:\/\//.test(url) ? url : `${API_BASE_URL}${url}` + const response = await kyInstance(fullUrl, init as Parameters[1]) let data: unknown = null try { data = await response.json() diff --git a/src/shared/layouts/Navbar.tsx b/src/shared/layouts/Navbar.tsx index 6949fa3..ad6825b 100644 --- a/src/shared/layouts/Navbar.tsx +++ b/src/shared/layouts/Navbar.tsx @@ -39,26 +39,12 @@ export default function Navbar() { Product - - Audience - - - Problems - - - Solution - - - Modules - Use Cases - {/* Pricing 메뉴 임시 비활성 Pricing - */}
{/* 우측 CTA — Login(Secondary) + 문의하기(Primary) */} diff --git a/src/shared/lib/utils.ts b/src/shared/lib/utils.ts index fed2fe9..05d138b 100644 --- a/src/shared/lib/utils.ts +++ b/src/shared/lib/utils.ts @@ -4,3 +4,7 @@ import { twMerge } from 'tailwind-merge' export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/vite.config.ts b/vite.config.ts index f13aaae..802f25d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,17 +1,18 @@ import path from 'node:path' import tailwindcss from '@tailwindcss/vite' import react from '@vitejs/plugin-react' -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') -export default defineConfig(() => { // Docker on macOS/Windows에서 파일 변경 감지를 위해 polling 필요 // CHOKIDAR_USEPOLLING=true 환경변수가 있을 때만 켬 (로컬 dev에선 비활성) const usePolling = process.env.CHOKIDAR_USEPOLLING === 'true' - // Docker 안에서 dev 서버가 돌면 호스트의 localhost는 host.docker.internal로 접근해야 함 - // VITE_API_TARGET 환경변수로 오버라이드 가능 const apiTarget = - process.env.VITE_API_TARGET ?? + env.VITE_API_TARGET || + env.VITE_API_BASE_URL || (usePolling ? 'http://host.docker.internal:8001' : 'http://localhost:8001') return {