From 4e70f791f9ca106e36ef23e7978de322939fec4a Mon Sep 17 00:00:00 2001 From: sinmb79 Date: Wed, 25 Mar 2026 04:48:46 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=202~3=20=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=EC=97=94=EB=93=9C=20=EA=B5=AC=ED=98=84=20=E2=80=94=20?= =?UTF-8?q?AI=20=EC=97=90=EC=9D=B4=EC=A0=84=ED=8A=B8,=20EVMS,=20Vision=20A?= =?UTF-8?q?I,=20=EC=A4=80=EA=B3=B5=EB=8F=84=EC=84=9C,=20=EB=B0=9C=EC=A3=BC?= =?UTF-8?q?=EC=B2=98=20=ED=8F=AC=ED=84=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AI 에이전트 채팅 UI (협업 시나리오, 아침 브리핑 포함) - EVMS 대시보드 (SPI/CPI, 공정률 추이, 공기 예측, 기성 청구) - Vision AI 사진 분석 (공종 분류, 안전 점검, 도면 대조) - 준공도서 패키지 (체크리스트, ZIP 다운로드) - 발주처 포털 (토큰 기반 읽기 전용 대시보드) - 프로젝트 상세 탭 4열로 확장 Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/app/portal/page.tsx | 208 +++++++++++ .../src/app/projects/[id]/agents/page.tsx | 283 +++++++++++++++ .../src/app/projects/[id]/completion/page.tsx | 122 +++++++ frontend/src/app/projects/[id]/evms/page.tsx | 220 ++++++++++++ frontend/src/app/projects/[id]/page.tsx | 6 +- .../src/app/projects/[id]/vision/page.tsx | 328 ++++++++++++++++++ frontend/src/app/settings/page.tsx | 2 +- frontend/src/lib/types.ts | 84 +++++ 8 files changed, 1251 insertions(+), 2 deletions(-) create mode 100644 frontend/src/app/portal/page.tsx create mode 100644 frontend/src/app/projects/[id]/agents/page.tsx create mode 100644 frontend/src/app/projects/[id]/completion/page.tsx create mode 100644 frontend/src/app/projects/[id]/evms/page.tsx create mode 100644 frontend/src/app/projects/[id]/vision/page.tsx diff --git a/frontend/src/app/portal/page.tsx b/frontend/src/app/portal/page.tsx new file mode 100644 index 0000000..b80f568 --- /dev/null +++ b/frontend/src/app/portal/page.tsx @@ -0,0 +1,208 @@ +"use client"; +import { useState } from "react"; +import axios from "axios"; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"; + +interface DashboardData { + project: { name: string; start_date: string | null; end_date: string | null; status: string }; + progress: { planned: number | null; actual: number | null; spi: number | null; snapshot_date: string | null }; + quality: { total_tests: number; pass_rate: number | null }; + recent_reports: Array<{ date: string; weather: string; work: string }>; + active_alerts: Array<{ type: string; message: string }>; + generated_at: string; +} + +function StatBox({ label, value, sub }: { label: string; value: string | number; sub?: string }) { + return ( +
+

{label}

+

{value}

+ {sub &&

{sub}

} +
+ ); +} + +function ProgressBar({ planned, actual }: { planned: number | null; actual: number | null }) { + const p = planned ?? 0; + const a = actual ?? 0; + return ( +
+
+ 계획 +
+
+
+ {p.toFixed(1)}% +
+
+ 실적 +
+
= p ? "bg-green-400" : "bg-red-400"}`} + style={{ width: `${Math.min(a, 100)}%` }} + /> +
+ {a.toFixed(1)}% +
+
+ ); +} + +export default function PortalPage() { + const [token, setToken] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + + const handleLogin = async () => { + if (!token.trim()) return; + setLoading(true); + setError(null); + try { + const res = await axios.get(`${API_URL}/api/v1/portal/dashboard`, { + headers: { Authorization: `Bearer ${token.trim()}` }, + }); + setData(res.data); + } catch { + setError("유효하지 않은 토큰이거나 만료되었습니다."); + setData(null); + } finally { + setLoading(false); + } + }; + + const statusLabels: Record = { + active: "공사 중", planning: "착공 준비", suspended: "공사 중단", completed: "준공 완료", + }; + + return ( +
+ {/* Header */} +
+
+ CONAI + 발주처 공사 현황 포털 +
+ 읽기 전용 · 실시간 현황 +
+ +
+ {/* Token Input */} + {!data && ( +
+
+

🏗

+

발주처 포털

+

현장 관리자로부터 받은 접근 토큰을 입력하세요

+
+ setToken(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleLogin()} + /> + {error &&

{error}

} + +
+ )} + + {/* Dashboard */} + {data && ( + <> + {/* Project header */} +
+
+
+

{data.project.name}

+

+ {data.project.start_date} ~ {data.project.end_date ?? "미정"} +

+
+
+ + {statusLabels[data.project.status] ?? data.project.status} + + +
+
+
+ + {/* Active alerts */} + {data.active_alerts.length > 0 && ( +
+

🚨 기상 경보

+ {data.active_alerts.map((a, i) => ( +

{a.type}: {a.message}

+ ))} +
+ )} + + {/* Stats */} +
+ = 1 ? "✅ 정상" : "⚠️ 지연" + : undefined} + /> + + +
+ + {/* Progress */} +
+

공정률 현황

+ +
+ + {/* Recent reports */} +
+
+

최근 작업일보

+
+ {data.recent_reports.length === 0 ? ( +

작업일보가 없습니다

+ ) : ( +
+ {data.recent_reports.map((r, i) => ( +
+
+ {r.date} + {r.weather} +
+

{r.work || "-"}

+
+ ))} +
+ )} +
+ +

생성: {data.generated_at.slice(0, 19).replace("T", " ")}

+ + )} +
+
+ ); +} diff --git a/frontend/src/app/projects/[id]/agents/page.tsx b/frontend/src/app/projects/[id]/agents/page.tsx new file mode 100644 index 0000000..a9cb750 --- /dev/null +++ b/frontend/src/app/projects/[id]/agents/page.tsx @@ -0,0 +1,283 @@ +"use client"; +import { useState, useRef, useEffect } from "react"; +import { useParams } from "next/navigation"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { AppLayout } from "@/components/layout/AppLayout"; +import api from "@/lib/api"; +import type { AgentConversation, AgentMessage, AgentType } from "@/lib/types"; + +const AGENT_INFO: Record = { + gongsa: { name: "공사 에이전트", emoji: "🏗", desc: "공정 브리핑, 지연 분석", color: "bg-blue-50 border-blue-200" }, + pumjil: { name: "품질 에이전트", emoji: "✅", desc: "품질 체크리스트, KCS 기준", color: "bg-green-50 border-green-200" }, + anjeon: { name: "안전 에이전트", emoji: "⛑", desc: "TBM 자료, 안전 경보", color: "bg-red-50 border-red-200" }, + gumu: { name: "공무 에이전트", emoji: "📋", desc: "인허가 추적, 기성 청구", color: "bg-purple-50 border-purple-200" }, +}; + +const SCENARIOS = [ + { id: "concrete_pour", label: "🪨 콘크리트 타설", desc: "공사→품질→안전 순서로 브리핑" }, + { id: "excavation", label: "⛏ 굴착 작업", desc: "공사→안전→품질 순서로 브리핑" }, + { id: "weekly_report", label: "📊 주간 보고", desc: "공사→품질→공무 순서로 요약" }, +]; + +export default function AgentsPage() { + const params = useParams(); + const projectId = params.id as string; + const qc = useQueryClient(); + + const [selectedConvId, setSelectedConvId] = useState(null); + const [inputText, setInputText] = useState(""); + const [quickInput, setQuickInput] = useState(""); + const [scenarioResult, setScenarioResult] = useState | null>(null); + const [scenarioLoading, setScenarioLoading] = useState(false); + const messagesEndRef = useRef(null); + + const { data: conversations = [] } = useQuery({ + queryKey: ["agents", projectId], + queryFn: () => api.get(`/projects/${projectId}/agents`).then((r) => r.data), + }); + + const { data: messages = [], isLoading: msgLoading } = useQuery({ + queryKey: ["agent-messages", selectedConvId], + queryFn: () => api.get(`/projects/${projectId}/agents/${selectedConvId}/messages`).then((r) => r.data), + enabled: !!selectedConvId, + }); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + const sendMsgMutation = useMutation({ + mutationFn: (content: string) => + api.post(`/projects/${projectId}/agents/${selectedConvId}/messages`, { content }).then((r) => r.data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["agent-messages", selectedConvId] }); + setInputText(""); + }, + }); + + const quickChatMutation = useMutation({ + mutationFn: (content: string) => + api.post(`/projects/${projectId}/agents/chat`, { content }).then((r) => r.data), + onSuccess: (data) => { + qc.invalidateQueries({ queryKey: ["agents", projectId] }); + setSelectedConvId(data.conversation_id); + setQuickInput(""); + }, + }); + + const briefingMutation = useMutation({ + mutationFn: () => + api.post(`/projects/${projectId}/agents/briefing`).then((r) => r.data), + onSuccess: (data) => { + qc.invalidateQueries({ queryKey: ["agents", projectId] }); + setSelectedConvId(data.conversation_id); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (convId: string) => api.delete(`/projects/${projectId}/agents/${convId}`), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["agents", projectId] }); + setSelectedConvId(null); + }, + }); + + const runScenario = async (scenarioId: string) => { + setScenarioLoading(true); + setScenarioResult(null); + try { + const r = await api.post(`/projects/${projectId}/agents/scenario/${scenarioId}`); + setScenarioResult(r.data.steps); + qc.invalidateQueries({ queryKey: ["agents", projectId] }); + } finally { + setScenarioLoading(false); + } + }; + + const selectedConv = conversations.find((c) => c.id === selectedConvId); + + return ( + +
+
+

AI 에이전트

+ +
+ + {/* Agent Cards */} +
+ {(Object.entries(AGENT_INFO) as [AgentType, typeof AGENT_INFO[AgentType]][]).map(([type, info]) => ( +
+
{info.emoji}
+
{info.name}
+
{info.desc}
+
+ ))} +
+ + {/* Quick Chat */} +
+

빠른 질문 (에이전트 자동 선택)

+
+ setQuickInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && quickInput.trim() && quickChatMutation.mutate(quickInput.trim())} + /> + +
+
+ +
+ {/* Conversation List */} +
+
대화 목록
+
+ {conversations.length === 0 && ( +

대화가 없습니다

+ )} + {conversations.map((conv) => { + const info = AGENT_INFO[conv.agent_type]; + return ( + + ); + })} +
+
+ + {/* Chat Window */} +
+ {!selectedConvId ? ( +
+ 대화를 선택하거나 빠른 질문을 입력하세요 +
+ ) : ( + <> +
+
+ {AGENT_INFO[selectedConv?.agent_type ?? "gongsa"].emoji} + {selectedConv?.title} +
+ +
+ +
+ {msgLoading &&

불러오는 중...

} + {messages.map((msg) => ( +
+
+ {msg.content} +
+
+ ))} + {sendMsgMutation.isPending && ( +
+
+ 답변 생성 중... +
+
+ )} +
+
+ +
+ setInputText(e.target.value)} + onKeyDown={(e) => + e.key === "Enter" && inputText.trim() && sendMsgMutation.mutate(inputText.trim()) + } + /> + +
+ + )} +
+
+ + {/* Collaboration Scenarios */} +
+

협업 시나리오

+
+ {SCENARIOS.map((s) => ( + + ))} +
+ + {scenarioLoading && ( +
에이전트들이 협업 중...
+ )} + + {scenarioResult && ( +
+ {scenarioResult.map((step, i) => { + const info = AGENT_INFO[step.agent as AgentType]; + return ( +
+
+ {info?.emoji} + {step.agent_name} + Step {i + 1} +
+

{step.content}

+
+ ); + })} +
+ )} +
+
+ + ); +} diff --git a/frontend/src/app/projects/[id]/completion/page.tsx b/frontend/src/app/projects/[id]/completion/page.tsx new file mode 100644 index 0000000..e6ceae3 --- /dev/null +++ b/frontend/src/app/projects/[id]/completion/page.tsx @@ -0,0 +1,122 @@ +"use client"; +import { useParams } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import { AppLayout } from "@/components/layout/AppLayout"; +import api from "@/lib/api"; +import type { CompletionChecklist } from "@/lib/types"; + +export default function CompletionPage() { + const params = useParams(); + const projectId = params.id as string; + + const { data: checklist = [], isLoading } = useQuery({ + queryKey: ["completion-checklist", projectId], + queryFn: () => api.get(`/projects/${projectId}/completion/checklist`).then((r) => r.data), + }); + + const grouped = checklist.reduce>((acc, item) => { + if (!acc[item.category]) acc[item.category] = []; + acc[item.category].push(item); + return acc; + }, {}); + + const total = checklist.length; + const ready = checklist.filter((c) => c.available).length; + const pct = total > 0 ? Math.round((ready / total) * 100) : 0; + + const handleDownload = async () => { + const res = await api.get(`/projects/${projectId}/completion/download`, { + responseType: "blob", + }); + const url = URL.createObjectURL(res.data); + const a = document.createElement("a"); + a.href = url; + a.download = `completion_${projectId}.zip`; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( + +
+
+

준공도서 패키지

+ +
+ + {/* Progress */} +
+
+ 준공 준비도 + {pct}% +
+
+
= 80 ? "bg-green-400" : pct >= 50 ? "bg-yellow-400" : "bg-red-400"}`} + style={{ width: `${pct}%` }} + /> +
+

+ {ready}/{total}개 항목 준비 완료 + {pct < 100 && ` · ${total - ready}개 항목 미비`} +

+
+ + {isLoading &&

로딩 중...

} + + {/* Checklist by category */} + {Object.entries(grouped).map(([category, items]) => { + const catReady = items.filter((i) => i.available).length; + return ( +
+
+ {category} + + {catReady}/{items.length} + +
+
+ {items.map((item, i) => ( +
+
+ {item.available ? "✅" : "❌"} + + {item.item} + +
+ {item.count != null && ( + {item.count}건 + )} +
+ ))} +
+
+ ); + })} + + {!isLoading && checklist.length === 0 && ( +
+

체크리스트 항목이 없습니다

+

프로젝트 데이터가 입력되면 자동으로 생성됩니다.

+
+ )} + +
+

📦 ZIP 패키지 포함 내용

+
    +
  • • 준공 요약서 (PDF)
  • +
  • • 품질시험 결과 목록 (PDF)
  • +
  • • 검측요청서 전체 (PDF)
  • +
  • • 인허가 현황 (PDF)
  • +
  • • 현장 사진 원본
  • +
+
+
+ + ); +} diff --git a/frontend/src/app/projects/[id]/evms/page.tsx b/frontend/src/app/projects/[id]/evms/page.tsx new file mode 100644 index 0000000..6da9609 --- /dev/null +++ b/frontend/src/app/projects/[id]/evms/page.tsx @@ -0,0 +1,220 @@ +"use client"; +import { useParams } from "next/navigation"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { AppLayout } from "@/components/layout/AppLayout"; +import api from "@/lib/api"; +import type { EVMSSnapshot, EVMSChart } from "@/lib/types"; + +function StatCard({ label, value, sub, color = "text-gray-800" }: { + label: string; value: string | number; sub?: string; color?: string; +}) { + return ( +
+

{label}

+

{value}

+ {sub &&

{sub}

} +
+ ); +} + +function IndexBadge({ label, value }: { label: string; value: number | null }) { + if (value == null) return -; + const color = value >= 1 ? "text-green-600" : value >= 0.9 ? "text-yellow-600" : "text-red-600"; + const bg = value >= 1 ? "bg-green-50" : value >= 0.9 ? "bg-yellow-50" : "bg-red-50"; + return ( + + {label} {value.toFixed(2)} + {value >= 1 ? " ✅" : value >= 0.9 ? " ⚠️" : " 🔴"} + + ); +} + +function MiniBar({ planned, actual }: { planned: number; actual: number }) { + return ( +
+
+ 계획 +
+
+
+ {planned.toFixed(1)}% +
+
+ 실적 +
+
= planned ? "bg-green-400" : "bg-red-400"}`} + style={{ width: `${Math.min(actual, 100)}%` }} + /> +
+ {actual.toFixed(1)}% +
+
+ ); +} + +export default function EvmsPage() { + const params = useParams(); + const projectId = params.id as string; + const qc = useQueryClient(); + + const { data: latest, isLoading } = useQuery({ + queryKey: ["evms-latest", projectId], + queryFn: () => api.get(`/projects/${projectId}/evms/latest`).then((r) => r.data), + }); + + const { data: chart } = useQuery({ + queryKey: ["evms-chart", projectId], + queryFn: async () => { + const r = await api.get(`/portal/progress-chart`, { + headers: { Authorization: `Bearer __internal__` }, + }); + return r.data; + }, + retry: false, + }); + + const { data: forecast } = useQuery<{ forecast_days: number; message: string; predicted_end?: string }>({ + queryKey: ["evms-forecast", projectId], + queryFn: () => api.get(`/projects/${projectId}/evms/delay-forecast`).then((r) => r.data), + retry: false, + }); + + const { data: claim } = useQuery<{ milestone: string; pct: number; estimated_amount: number; note: string }>({ + queryKey: ["evms-claim", projectId], + queryFn: () => api.get(`/projects/${projectId}/evms/progress-claim`).then((r) => r.data), + retry: false, + }); + + const computeMutation = useMutation({ + mutationFn: () => api.post(`/projects/${projectId}/evms/compute`, { save_snapshot: true }).then((r) => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ["evms-latest", projectId] }), + }); + + const fmt = (n: number | undefined) => + n != null ? `${(n / 1e6).toFixed(1)}백만원` : "-"; + + return ( + +
+
+

EVMS 공정/원가 관리

+ +
+ + {isLoading &&

로딩 중...

} + + {!isLoading && !latest && ( +
+

EVMS 데이터가 없습니다

+

EVMS 재계산 버튼을 눌러 스냅샷을 생성하세요.

+
+ )} + + {latest && ( + <> + {/* Index badges */} +
+ + + 기준일: {latest.snapshot_date} +
+ + {/* Progress bars */} +
+

공정률

+ +
+ + {/* EVM stats */} +
+ + + latest.ev ? "text-red-600" : "text-green-600"} + /> +
+ +
+ + +
+ + {/* Chart (simple ASCII-style bar) */} + {chart && chart.labels.length > 0 && ( +
+

공정률 추이 (최근 {chart.labels.length}일)

+
+
+ {chart.labels.map((label, i) => ( +
+
+
+
= (chart.planned[i] ?? 0) ? "bg-green-400" : "bg-red-400"}`} + style={{ height: `${(chart.actual[i] ?? 0) * 0.8}%` }} + /> +
+ + {label.slice(5)} + +
+ ))} +
+
+ 계획 + 실적 +
+
+
+ )} + + {/* Forecast + Claim */} +
+ {forecast && ( +
+

📅 공기 예측

+

{forecast.message}

+ {forecast.predicted_end && ( +

예상 준공일: {forecast.predicted_end}

+ )} +
+ )} + {claim && ( +
+

💰 기성 청구 예측

+

{claim.milestone}

+

+ {(claim.estimated_amount / 1e6).toFixed(1)}백만원 ({claim.pct}%) +

+

{claim.note}

+
+ )} +
+ + )} +
+ + ); +} diff --git a/frontend/src/app/projects/[id]/page.tsx b/frontend/src/app/projects/[id]/page.tsx index 79de9e1..1a01b5e 100644 --- a/frontend/src/app/projects/[id]/page.tsx +++ b/frontend/src/app/projects/[id]/page.tsx @@ -14,6 +14,10 @@ const TABS = [ { id: "quality", label: "✅ 품질시험", href: (id: string) => `/projects/${id}/quality` }, { id: "weather", label: "🌤 날씨", href: (id: string) => `/projects/${id}/weather` }, { id: "permits", label: "🏛 인허가", href: (id: string) => `/projects/${id}/permits` }, + { id: "agents", label: "🤖 AI 에이전트", href: (id: string) => `/projects/${id}/agents` }, + { id: "evms", label: "📊 EVMS", href: (id: string) => `/projects/${id}/evms` }, + { id: "vision", label: "👁 Vision AI", href: (id: string) => `/projects/${id}/vision` }, + { id: "completion", label: "📦 준공도서", href: (id: string) => `/projects/${id}/completion` }, ]; export default function ProjectDetailPage() { @@ -60,7 +64,7 @@ export default function ProjectDetailPage() {
{/* Module Tabs */} -
+
{TABS.map((tab) => ( void; + preview: string | null; +}) { + const inputRef = useRef(null); + return ( +
inputRef.current?.click()} + > + e.target.files?.[0] && onChange(e.target.files[0])} + /> + {preview ? ( + preview + ) : ( +
+

📁

+

{label}

+
+ )} +
+ ); +} + +function RiskBadge({ level }: { level: string }) { + const map: Record = { + high: "bg-red-100 text-red-700", + medium: "bg-yellow-100 text-yellow-700", + low: "bg-green-100 text-green-700", + }; + return ( + + {level === "high" ? "🔴 고위험" : level === "medium" ? "🟡 주의" : "🟢 양호"} + + ); +} + +export default function VisionPage() { + const params = useParams(); + const projectId = params.id as string; + + const [tab, setTab] = useState("classify"); + const [classifyFile, setClassifyFile] = useState(null); + const [classifyPreview, setClassifyPreview] = useState(null); + const [safetyFile, setSafetyFile] = useState(null); + const [safetyPreview, setSafetyPreview] = useState(null); + const [drawingFile, setDrawingFile] = useState(null); + const [drawingPreview, setDrawingPreview] = useState(null); + const [fieldFile, setFieldFile] = useState(null); + const [fieldPreview, setFieldPreview] = useState(null); + + const handleFileChange = ( + file: File, + setFile: (f: File) => void, + setPreview: (s: string) => void + ) => { + setFile(file); + const reader = new FileReader(); + reader.onload = (e) => setPreview(e.target?.result as string); + reader.readAsDataURL(file); + }; + + const classifyMutation = useMutation({ + mutationFn: () => { + const form = new FormData(); + form.append("file", classifyFile!); + return api.post(`/projects/${projectId}/vision/classify`, form, { + headers: { "Content-Type": "multipart/form-data" }, + }).then((r) => r.data); + }, + }); + + const safetyMutation = useMutation({ + mutationFn: () => { + const form = new FormData(); + form.append("file", safetyFile!); + return api.post(`/projects/${projectId}/vision/safety-check`, form, { + headers: { "Content-Type": "multipart/form-data" }, + }).then((r) => r.data); + }, + }); + + const drawingMutation = useMutation({ + mutationFn: () => { + const form = new FormData(); + form.append("drawing", drawingFile!); + form.append("field_photo", fieldFile!); + return api.post(`/projects/${projectId}/vision/compare-drawing`, form, { + headers: { "Content-Type": "multipart/form-data" }, + }).then((r) => r.data); + }, + }); + + return ( + +
+

Vision AI 사진 분석

+ + {/* Tabs */} +
+ {TABS.map((t) => ( + + ))} +
+ + {/* Tab: Classify */} + {tab === "classify" && ( +
+
+ handleFileChange(f, setClassifyFile, setClassifyPreview)} + preview={classifyPreview} + /> + +
+ +
+ {classifyMutation.data && ( +
+

분류 결과

+
+

공종

+

{classifyMutation.data.work_type}

+

신뢰도: {classifyMutation.data.confidence}

+
+
+

태그

+
+ {classifyMutation.data.tags.map((tag) => ( + + #{tag} + + ))} +
+
+
+

설명

+

{classifyMutation.data.description}

+
+
+ )} + {!classifyMutation.data && !classifyMutation.isPending && ( +
+ 사진을 업로드하고 분석을 실행하세요 +
+ )} +
+
+ )} + + {/* Tab: Safety */} + {tab === "safety" && ( +
+
+ handleFileChange(f, setSafetyFile, setSafetyPreview)} + preview={safetyPreview} + /> + +
+ +
+ {safetyMutation.data && ( +
+
+

안전 점검 결과

+ +
+
+
+

{safetyMutation.data.helmet ? "✅" : "❌"}

+

안전모

+
+
+

{safetyMutation.data.vest ? "✅" : "❌"}

+

안전조끼

+
+
+ {safetyMutation.data.violations.length > 0 && ( +
+

위반 사항

+
    + {safetyMutation.data.violations.map((v, i) => ( +
  • + {v} +
  • + ))} +
+
+ )} +
+

조치 권고

+

{safetyMutation.data.recommendation}

+
+
+ )} + {!safetyMutation.data && !safetyMutation.isPending && ( +
+ 근로자 사진을 업로드하고 점검을 실행하세요 +
+ )} +
+
+ )} + + {/* Tab: Drawing Comparison */} + {tab === "drawing" && ( +
+
+
+

설계 도면

+ handleFileChange(f, setDrawingFile, setDrawingPreview)} + preview={drawingPreview} + /> +
+
+

현장 사진

+ handleFileChange(f, setFieldFile, setFieldPreview)} + preview={fieldPreview} + /> +
+
+ + + + {drawingMutation.data && ( +
+
+

대조 결과

+ {drawingMutation.data.overall_match} +
+ + {drawingMutation.data.matches.length > 0 && ( +
+

✅ 일치 항목

+
    + {drawingMutation.data.matches.map((m, i) => ( +
  • + {m} +
  • + ))} +
+
+ )} + + {drawingMutation.data.discrepancies.length > 0 && ( +
+

⚠️ 불일치 항목

+
    + {drawingMutation.data.discrepancies.map((d, i) => ( +
  • + {d} +
  • + ))} +
+
+ )} + +
+

권고 사항

+

{drawingMutation.data.recommendation}

+
+
+ )} +
+ )} +
+
+ ); +} diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index f23f5f9..af7a228 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -146,7 +146,7 @@ function WorkTypesTab() { {wt.weather_constraints.min_temp != null && `최저 ${wt.weather_constraints.min_temp}°C`} {wt.weather_constraints.max_wind != null && ` 최대 ${wt.weather_constraints.max_wind}m/s`} - {wt.weather_constraints.no_rain && " 우천불가"} + {wt.weather_constraints.no_rain ? " 우천불가" : ""} ) : "-"} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index b129640..e877dd6 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -191,3 +191,87 @@ export interface RagAnswer { sources: RagSource[]; disclaimer: string; } + +// Phase 2~3 types + +export type AgentType = "gongsa" | "pumjil" | "anjeon" | "gumu"; +export type ConversationStatus = "active" | "archived"; + +export interface AgentConversation { + id: string; + agent_type: AgentType; + title: string | null; + status: ConversationStatus; + message_count: number; +} + +export interface AgentMessage { + id: string; + conversation_id: string; + role: "user" | "assistant"; + content: string; + is_proactive: boolean; + metadata: Record | null; +} + +export interface EVMSSnapshot { + id: string; + project_id: string; + snapshot_date: string; + planned_progress: number; + actual_progress: number; + pv: number; + ev: number; + ac: number; + spi: number; + cpi: number; + eac: number; + etc: number; +} + +export interface EVMSChart { + labels: string[]; + planned: number[]; + actual: number[]; + spi: number[]; +} + +export interface VisionClassifyResult { + work_type: string; + confidence: string; + tags: string[]; + description: string; +} + +export interface VisionSafetyResult { + helmet: boolean; + vest: boolean; + violations: string[]; + risk_level: string; + recommendation: string; +} + +export interface VisionDrawingResult { + matches: string[]; + discrepancies: string[]; + overall_match: string; + recommendation: string; +} + +export interface CompletionChecklist { + category: string; + item: string; + available: boolean; + count?: number; +} + +export interface GeofenceZone { + id: string; + project_id: string; + name: string; + zone_type: string; + polygon_coords: Array<{ lat: number; lng: number }>; + alert_message: string | null; + is_active: boolean; + created_at: string; +}