feat: Phase 2~3 프론트엔드 구현 — AI 에이전트, EVMS, Vision AI, 준공도서, 발주처 포털

- AI 에이전트 채팅 UI (협업 시나리오, 아침 브리핑 포함)
- EVMS 대시보드 (SPI/CPI, 공정률 추이, 공기 예측, 기성 청구)
- Vision AI 사진 분석 (공종 분류, 안전 점검, 도면 대조)
- 준공도서 패키지 (체크리스트, ZIP 다운로드)
- 발주처 포털 (토큰 기반 읽기 전용 대시보드)
- 프로젝트 상세 탭 4열로 확장

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sinmb79
2026-03-25 04:48:46 +09:00
parent 36c484a34e
commit 4e70f791f9
8 changed files with 1251 additions and 2 deletions

View File

@@ -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 (
<div className="bg-white rounded-xl border border-gray-200 p-4">
<p className="text-xs text-gray-400 mb-1">{label}</p>
<p className="text-xl font-bold text-gray-800">{value}</p>
{sub && <p className="text-xs text-gray-500 mt-0.5">{sub}</p>}
</div>
);
}
function ProgressBar({ planned, actual }: { planned: number | null; actual: number | null }) {
const p = planned ?? 0;
const a = actual ?? 0;
return (
<div className="space-y-2">
<div className="flex items-center gap-3 text-sm">
<span className="w-12 text-gray-500"></span>
<div className="flex-1 bg-gray-100 rounded-full h-4">
<div className="bg-blue-400 h-4 rounded-full" style={{ width: `${Math.min(p, 100)}%` }} />
</div>
<span className="w-12 text-right font-medium">{p.toFixed(1)}%</span>
</div>
<div className="flex items-center gap-3 text-sm">
<span className="w-12 text-gray-500"></span>
<div className="flex-1 bg-gray-100 rounded-full h-4">
<div
className={`h-4 rounded-full ${a >= p ? "bg-green-400" : "bg-red-400"}`}
style={{ width: `${Math.min(a, 100)}%` }}
/>
</div>
<span className="w-12 text-right font-medium">{a.toFixed(1)}%</span>
</div>
</div>
);
}
export default function PortalPage() {
const [token, setToken] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<DashboardData | null>(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<string, string> = {
active: "공사 중", planning: "착공 준비", suspended: "공사 중단", completed: "준공 완료",
};
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-gray-900 text-white px-6 py-4 flex items-center justify-between">
<div>
<span className="text-xl font-bold">CONAI</span>
<span className="ml-3 text-sm text-gray-400"> </span>
</div>
<span className="text-xs text-gray-500"> · </span>
</header>
<main className="max-w-4xl mx-auto p-6 space-y-6">
{/* Token Input */}
{!data && (
<div className="bg-white rounded-2xl border border-gray-200 p-8 space-y-4">
<div className="text-center mb-2">
<p className="text-2xl mb-1">🏗</p>
<h1 className="text-xl font-bold text-gray-800"> </h1>
<p className="text-sm text-gray-500 mt-1"> </p>
</div>
<input
className="w-full border border-gray-300 rounded-lg px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
placeholder="포털 접근 토큰 입력..."
value={token}
onChange={(e) => setToken(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleLogin()}
/>
{error && <p className="text-red-500 text-sm">{error}</p>}
<button
onClick={handleLogin}
disabled={loading || !token.trim()}
className="w-full bg-blue-500 text-white py-3 rounded-lg font-medium hover:bg-blue-600 transition-colors disabled:opacity-50"
>
{loading ? "확인 중..." : "현황 확인"}
</button>
</div>
)}
{/* Dashboard */}
{data && (
<>
{/* Project header */}
<div className="bg-white rounded-2xl border border-gray-200 p-5">
<div className="flex items-start justify-between">
<div>
<h2 className="text-xl font-bold text-gray-900">{data.project.name}</h2>
<p className="text-sm text-gray-500 mt-0.5">
{data.project.start_date} ~ {data.project.end_date ?? "미정"}
</p>
</div>
<div className="flex items-center gap-2">
<span className="px-3 py-1 bg-blue-50 text-blue-700 rounded-full text-sm font-medium">
{statusLabels[data.project.status] ?? data.project.status}
</span>
<button
onClick={() => setData(null)}
className="text-xs text-gray-400 hover:text-gray-600"
>
</button>
</div>
</div>
</div>
{/* Active alerts */}
{data.active_alerts.length > 0 && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 space-y-2">
<p className="text-sm font-semibold text-red-700">🚨 </p>
{data.active_alerts.map((a, i) => (
<p key={i} className="text-sm text-red-600">{a.type}: {a.message}</p>
))}
</div>
)}
{/* Stats */}
<div className="grid grid-cols-3 gap-4">
<StatBox
label="SPI (공정 성과 지수)"
value={data.progress.spi != null ? data.progress.spi.toFixed(2) : "-"}
sub={data.progress.spi != null
? data.progress.spi >= 1 ? "✅ 정상" : "⚠️ 지연"
: undefined}
/>
<StatBox
label="품질시험 합격률"
value={data.quality.pass_rate != null ? `${data.quality.pass_rate}%` : "-"}
sub={`${data.quality.total_tests}`}
/>
<StatBox
label="기준일"
value={data.progress.snapshot_date ?? "-"}
sub="EVMS 스냅샷"
/>
</div>
{/* Progress */}
<div className="bg-white rounded-2xl border border-gray-200 p-5">
<h3 className="text-sm font-semibold text-gray-600 mb-4"> </h3>
<ProgressBar planned={data.progress.planned} actual={data.progress.actual} />
</div>
{/* Recent reports */}
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
<div className="px-5 py-3 bg-gray-50 border-b border-gray-100">
<h3 className="text-sm font-semibold text-gray-600"> </h3>
</div>
{data.recent_reports.length === 0 ? (
<p className="text-sm text-gray-400 p-5"> </p>
) : (
<div className="divide-y divide-gray-50">
{data.recent_reports.map((r, i) => (
<div key={i} className="px-5 py-4">
<div className="flex items-center gap-3 mb-1">
<span className="text-sm font-medium text-gray-700">{r.date}</span>
<span className="text-xs text-gray-400">{r.weather}</span>
</div>
<p className="text-sm text-gray-600">{r.work || "-"}</p>
</div>
))}
</div>
)}
</div>
<p className="text-xs text-gray-400 text-right">: {data.generated_at.slice(0, 19).replace("T", " ")}</p>
</>
)}
</main>
</div>
);
}

View File

@@ -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<AgentType, { name: string; emoji: string; desc: string; color: string }> = {
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<string | null>(null);
const [inputText, setInputText] = useState("");
const [quickInput, setQuickInput] = useState("");
const [scenarioResult, setScenarioResult] = useState<Array<{ agent: string; agent_name: string; content: string }> | null>(null);
const [scenarioLoading, setScenarioLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const { data: conversations = [] } = useQuery<AgentConversation[]>({
queryKey: ["agents", projectId],
queryFn: () => api.get(`/projects/${projectId}/agents`).then((r) => r.data),
});
const { data: messages = [], isLoading: msgLoading } = useQuery<AgentMessage[]>({
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 (
<AppLayout>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold">AI </h1>
<button
onClick={() => briefingMutation.mutate()}
disabled={briefingMutation.isPending}
className="btn-primary text-sm"
>
{briefingMutation.isPending ? "생성 중..." : "🌅 오늘 아침 브리핑"}
</button>
</div>
{/* Agent Cards */}
<div className="grid grid-cols-4 gap-3">
{(Object.entries(AGENT_INFO) as [AgentType, typeof AGENT_INFO[AgentType]][]).map(([type, info]) => (
<div key={type} className={`card p-3 border ${info.color}`}>
<div className="text-2xl mb-1">{info.emoji}</div>
<div className="font-semibold text-sm">{info.name}</div>
<div className="text-xs text-gray-500 mt-0.5">{info.desc}</div>
</div>
))}
</div>
{/* Quick Chat */}
<div className="card p-4">
<h2 className="font-semibold mb-3 text-sm text-gray-600"> ( )</h2>
<div className="flex gap-2">
<input
className="input flex-1 text-sm"
placeholder="예: 오늘 콘크리트 타설 전 체크리스트 알려줘"
value={quickInput}
onChange={(e) => setQuickInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && quickInput.trim() && quickChatMutation.mutate(quickInput.trim())}
/>
<button
className="btn-primary text-sm"
onClick={() => quickInput.trim() && quickChatMutation.mutate(quickInput.trim())}
disabled={quickChatMutation.isPending || !quickInput.trim()}
>
{quickChatMutation.isPending ? "..." : "전송"}
</button>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
{/* Conversation List */}
<div className="card p-0 overflow-hidden">
<div className="px-4 py-3 border-b border-gray-100 text-sm font-semibold text-gray-600"> </div>
<div className="divide-y divide-gray-50 max-h-96 overflow-y-auto">
{conversations.length === 0 && (
<p className="text-xs text-gray-400 p-4"> </p>
)}
{conversations.map((conv) => {
const info = AGENT_INFO[conv.agent_type];
return (
<button
key={conv.id}
onClick={() => setSelectedConvId(conv.id)}
className={`w-full text-left px-4 py-3 hover:bg-gray-50 transition-colors ${selectedConvId === conv.id ? "bg-blue-50" : ""}`}
>
<div className="flex items-center gap-2">
<span>{info.emoji}</span>
<span className="text-xs font-medium truncate">{conv.title || info.name}</span>
</div>
<div className="text-xs text-gray-400 mt-0.5">{conv.message_count} </div>
</button>
);
})}
</div>
</div>
{/* Chat Window */}
<div className="col-span-2 card p-0 overflow-hidden flex flex-col" style={{ height: 420 }}>
{!selectedConvId ? (
<div className="flex-1 flex items-center justify-center text-gray-400 text-sm">
</div>
) : (
<>
<div className="px-4 py-3 border-b border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-2">
<span>{AGENT_INFO[selectedConv?.agent_type ?? "gongsa"].emoji}</span>
<span className="text-sm font-semibold">{selectedConv?.title}</span>
</div>
<button
onClick={() => deleteMutation.mutate(selectedConvId)}
className="text-xs text-red-400 hover:text-red-600"
>
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{msgLoading && <p className="text-xs text-gray-400"> ...</p>}
{messages.map((msg) => (
<div key={msg.id} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-[80%] text-sm px-3 py-2 rounded-xl whitespace-pre-wrap ${
msg.role === "user"
? "bg-blue-500 text-white rounded-br-sm"
: "bg-gray-100 text-gray-800 rounded-bl-sm"
}`}
>
{msg.content}
</div>
</div>
))}
{sendMsgMutation.isPending && (
<div className="flex justify-start">
<div className="bg-gray-100 text-gray-400 text-sm px-3 py-2 rounded-xl rounded-bl-sm">
...
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="px-4 py-3 border-t border-gray-100 flex gap-2">
<input
className="input flex-1 text-sm"
placeholder="메시지 입력..."
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={(e) =>
e.key === "Enter" && inputText.trim() && sendMsgMutation.mutate(inputText.trim())
}
/>
<button
className="btn-primary text-sm"
onClick={() => inputText.trim() && sendMsgMutation.mutate(inputText.trim())}
disabled={sendMsgMutation.isPending || !inputText.trim()}
>
</button>
</div>
</>
)}
</div>
</div>
{/* Collaboration Scenarios */}
<div className="card p-4">
<h2 className="font-semibold mb-3"> </h2>
<div className="grid grid-cols-3 gap-3">
{SCENARIOS.map((s) => (
<button
key={s.id}
onClick={() => runScenario(s.id)}
disabled={scenarioLoading}
className="card p-3 text-left hover:shadow-md transition-shadow border border-gray-200"
>
<div className="font-medium text-sm">{s.label}</div>
<div className="text-xs text-gray-500 mt-1">{s.desc}</div>
</button>
))}
</div>
{scenarioLoading && (
<div className="mt-4 text-sm text-blue-500 text-center"> ...</div>
)}
{scenarioResult && (
<div className="mt-4 space-y-3">
{scenarioResult.map((step, i) => {
const info = AGENT_INFO[step.agent as AgentType];
return (
<div key={i} className={`rounded-xl p-4 border ${info?.color || "bg-gray-50"}`}>
<div className="flex items-center gap-2 mb-2">
<span>{info?.emoji}</span>
<span className="font-semibold text-sm">{step.agent_name}</span>
<span className="text-xs text-gray-400">Step {i + 1}</span>
</div>
<p className="text-sm whitespace-pre-wrap text-gray-700">{step.content}</p>
</div>
);
})}
</div>
)}
</div>
</div>
</AppLayout>
);
}

View File

@@ -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<CompletionChecklist[]>({
queryKey: ["completion-checklist", projectId],
queryFn: () => api.get(`/projects/${projectId}/completion/checklist`).then((r) => r.data),
});
const grouped = checklist.reduce<Record<string, CompletionChecklist[]>>((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 (
<AppLayout>
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold"> </h1>
<button
onClick={handleDownload}
className="btn-primary"
>
ZIP
</button>
</div>
{/* Progress */}
<div className="card p-5">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-semibold text-gray-600"> </span>
<span className="text-xl font-bold text-blue-600">{pct}%</span>
</div>
<div className="w-full bg-gray-100 rounded-full h-3">
<div
className={`h-3 rounded-full transition-all ${pct >= 80 ? "bg-green-400" : pct >= 50 ? "bg-yellow-400" : "bg-red-400"}`}
style={{ width: `${pct}%` }}
/>
</div>
<p className="text-xs text-gray-400 mt-2">
{ready}/{total}
{pct < 100 && ` · ${total - ready}개 항목 미비`}
</p>
</div>
{isLoading && <p className="text-gray-400"> ...</p>}
{/* Checklist by category */}
{Object.entries(grouped).map(([category, items]) => {
const catReady = items.filter((i) => i.available).length;
return (
<div key={category} className="card p-0 overflow-hidden">
<div className="px-4 py-3 bg-gray-50 border-b border-gray-100 flex items-center justify-between">
<span className="font-semibold text-sm">{category}</span>
<span className="text-xs text-gray-500">
{catReady}/{items.length}
</span>
</div>
<div className="divide-y divide-gray-50">
{items.map((item, i) => (
<div key={i} className="px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-base">{item.available ? "✅" : "❌"}</span>
<span className={`text-sm ${item.available ? "text-gray-800" : "text-gray-400"}`}>
{item.item}
</span>
</div>
{item.count != null && (
<span className="text-xs text-gray-400">{item.count}</span>
)}
</div>
))}
</div>
</div>
);
})}
{!isLoading && checklist.length === 0 && (
<div className="card p-8 text-center text-gray-400">
<p className="text-lg mb-2"> </p>
<p className="text-sm"> .</p>
</div>
)}
<div className="card p-4 bg-blue-50 border border-blue-100">
<h3 className="text-sm font-semibold text-blue-700 mb-2">📦 ZIP </h3>
<ul className="text-xs text-blue-600 space-y-1">
<li> (PDF)</li>
<li> (PDF)</li>
<li> (PDF)</li>
<li> (PDF)</li>
<li> </li>
</ul>
</div>
</div>
</AppLayout>
);
}

View File

@@ -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 (
<div className="card p-4">
<p className="text-xs text-gray-400 mb-1">{label}</p>
<p className={`text-2xl font-bold ${color}`}>{value}</p>
{sub && <p className="text-xs text-gray-500 mt-0.5">{sub}</p>}
</div>
);
}
function IndexBadge({ label, value }: { label: string; value: number | null }) {
if (value == null) return <span className="text-gray-400 text-sm">-</span>;
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 (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-bold ${color} ${bg}`}>
{label} {value.toFixed(2)}
{value >= 1 ? " ✅" : value >= 0.9 ? " ⚠️" : " 🔴"}
</span>
);
}
function MiniBar({ planned, actual }: { planned: number; actual: number }) {
return (
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs">
<span className="w-10 text-gray-500"></span>
<div className="flex-1 bg-gray-100 rounded-full h-3">
<div className="bg-blue-400 h-3 rounded-full" style={{ width: `${Math.min(planned, 100)}%` }} />
</div>
<span className="w-10 text-right text-gray-600">{planned.toFixed(1)}%</span>
</div>
<div className="flex items-center gap-2 text-xs">
<span className="w-10 text-gray-500"></span>
<div className="flex-1 bg-gray-100 rounded-full h-3">
<div
className={`h-3 rounded-full ${actual >= planned ? "bg-green-400" : "bg-red-400"}`}
style={{ width: `${Math.min(actual, 100)}%` }}
/>
</div>
<span className="w-10 text-right text-gray-600">{actual.toFixed(1)}%</span>
</div>
</div>
);
}
export default function EvmsPage() {
const params = useParams();
const projectId = params.id as string;
const qc = useQueryClient();
const { data: latest, isLoading } = useQuery<EVMSSnapshot>({
queryKey: ["evms-latest", projectId],
queryFn: () => api.get(`/projects/${projectId}/evms/latest`).then((r) => r.data),
});
const { data: chart } = useQuery<EVMSChart>({
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 (
<AppLayout>
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold">EVMS / </h1>
<button
onClick={() => computeMutation.mutate()}
disabled={computeMutation.isPending}
className="btn-primary text-sm"
>
{computeMutation.isPending ? "계산 중..." : "📊 EVMS 재계산"}
</button>
</div>
{isLoading && <p className="text-gray-400"> ...</p>}
{!isLoading && !latest && (
<div className="card p-8 text-center text-gray-400">
<p className="text-lg mb-2">EVMS </p>
<p className="text-sm">EVMS .</p>
</div>
)}
{latest && (
<>
{/* Index badges */}
<div className="flex items-center gap-3 flex-wrap">
<IndexBadge label="SPI" value={latest.spi} />
<IndexBadge label="CPI" value={latest.cpi} />
<span className="text-xs text-gray-400">: {latest.snapshot_date}</span>
</div>
{/* Progress bars */}
<div className="card p-4">
<h2 className="text-sm font-semibold text-gray-600 mb-3"></h2>
<MiniBar planned={latest.planned_progress} actual={latest.actual_progress} />
</div>
{/* EVM stats */}
<div className="grid grid-cols-3 gap-3">
<StatCard label="PV (계획가치)" value={fmt(latest.pv)} sub="Planned Value" />
<StatCard label="EV (획득가치)" value={fmt(latest.ev)} sub="Earned Value" />
<StatCard
label="AC (실제원가)"
value={fmt(latest.ac)}
sub="Actual Cost"
color={latest.ac > latest.ev ? "text-red-600" : "text-green-600"}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<StatCard
label="EAC (완료시점 예산)"
value={fmt(latest.eac)}
sub="Estimate at Completion"
color="text-purple-600"
/>
<StatCard
label="ETC (잔여 예산)"
value={fmt(latest.etc)}
sub="Estimate to Complete"
/>
</div>
{/* Chart (simple ASCII-style bar) */}
{chart && chart.labels.length > 0 && (
<div className="card p-4">
<h2 className="text-sm font-semibold text-gray-600 mb-3"> ( {chart.labels.length})</h2>
<div className="overflow-x-auto">
<div className="flex items-end gap-1 h-24 min-w-max">
{chart.labels.map((label, i) => (
<div key={label} className="flex flex-col items-center gap-0.5 w-5">
<div className="relative flex gap-0.5 items-end h-20">
<div
className="w-2 bg-blue-300 rounded-t"
style={{ height: `${(chart.planned[i] ?? 0) * 0.8}%` }}
/>
<div
className={`w-2 rounded-t ${(chart.actual[i] ?? 0) >= (chart.planned[i] ?? 0) ? "bg-green-400" : "bg-red-400"}`}
style={{ height: `${(chart.actual[i] ?? 0) * 0.8}%` }}
/>
</div>
<span className="text-[9px] text-gray-400 rotate-45 w-8 text-left ml-1">
{label.slice(5)}
</span>
</div>
))}
</div>
<div className="flex gap-4 mt-2 text-xs text-gray-500">
<span className="flex items-center gap-1"><span className="w-3 h-3 bg-blue-300 inline-block rounded" /> </span>
<span className="flex items-center gap-1"><span className="w-3 h-3 bg-green-400 inline-block rounded" /> </span>
</div>
</div>
</div>
)}
{/* Forecast + Claim */}
<div className="grid grid-cols-2 gap-4">
{forecast && (
<div className="card p-4">
<h2 className="text-sm font-semibold mb-2">📅 </h2>
<p className="text-sm text-gray-700">{forecast.message}</p>
{forecast.predicted_end && (
<p className="text-xs text-gray-500 mt-1"> : {forecast.predicted_end}</p>
)}
</div>
)}
{claim && (
<div className="card p-4">
<h2 className="text-sm font-semibold mb-2">💰 </h2>
<p className="text-sm text-gray-700">{claim.milestone}</p>
<p className="text-base font-bold text-purple-600 mt-1">
{(claim.estimated_amount / 1e6).toFixed(1)} ({claim.pct}%)
</p>
<p className="text-xs text-gray-500 mt-1">{claim.note}</p>
</div>
)}
</div>
</>
)}
</div>
</AppLayout>
);
}

View File

@@ -14,6 +14,10 @@ const TABS = [
{ id: "quality", label: "✅ 품질시험", href: (id: string) => `/projects/${id}/quality` }, { id: "quality", label: "✅ 품질시험", href: (id: string) => `/projects/${id}/quality` },
{ id: "weather", label: "🌤 날씨", href: (id: string) => `/projects/${id}/weather` }, { id: "weather", label: "🌤 날씨", href: (id: string) => `/projects/${id}/weather` },
{ id: "permits", label: "🏛 인허가", href: (id: string) => `/projects/${id}/permits` }, { 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() { export default function ProjectDetailPage() {
@@ -60,7 +64,7 @@ export default function ProjectDetailPage() {
</div> </div>
{/* Module Tabs */} {/* Module Tabs */}
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-4 gap-3">
{TABS.map((tab) => ( {TABS.map((tab) => (
<Link <Link
key={tab.id} key={tab.id}

View File

@@ -0,0 +1,328 @@
"use client";
import { useState, useRef } from "react";
import { useParams } from "next/navigation";
import { useMutation } from "@tanstack/react-query";
import { AppLayout } from "@/components/layout/AppLayout";
import api from "@/lib/api";
import type { VisionClassifyResult, VisionSafetyResult, VisionDrawingResult } from "@/lib/types";
type TabId = "classify" | "safety" | "drawing";
const TABS: { id: TabId; label: string; desc: string }[] = [
{ id: "classify", label: "📸 공종 분류", desc: "사진에서 공종 자동 태깅" },
{ id: "safety", label: "⛑ 안전 점검", desc: "안전모·조끼 착용 감지" },
{ id: "drawing", label: "📐 도면 대조", desc: "설계도면 vs 현장사진 비교" },
];
function ImageUploadBox({
label,
onChange,
preview,
}: {
label: string;
onChange: (file: File) => void;
preview: string | null;
}) {
const inputRef = useRef<HTMLInputElement>(null);
return (
<div
className="border-2 border-dashed border-gray-200 rounded-xl p-4 text-center cursor-pointer hover:border-blue-300 transition-colors"
onClick={() => inputRef.current?.click()}
>
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => e.target.files?.[0] && onChange(e.target.files[0])}
/>
{preview ? (
<img src={preview} alt="preview" className="max-h-40 mx-auto rounded-lg object-contain" />
) : (
<div className="text-gray-400 py-6">
<p className="text-2xl mb-1">📁</p>
<p className="text-sm">{label}</p>
</div>
)}
</div>
);
}
function RiskBadge({ level }: { level: string }) {
const map: Record<string, string> = {
high: "bg-red-100 text-red-700",
medium: "bg-yellow-100 text-yellow-700",
low: "bg-green-100 text-green-700",
};
return (
<span className={`px-2 py-0.5 rounded-full text-xs font-bold ${map[level] ?? "bg-gray-100 text-gray-600"}`}>
{level === "high" ? "🔴 고위험" : level === "medium" ? "🟡 주의" : "🟢 양호"}
</span>
);
}
export default function VisionPage() {
const params = useParams();
const projectId = params.id as string;
const [tab, setTab] = useState<TabId>("classify");
const [classifyFile, setClassifyFile] = useState<File | null>(null);
const [classifyPreview, setClassifyPreview] = useState<string | null>(null);
const [safetyFile, setSafetyFile] = useState<File | null>(null);
const [safetyPreview, setSafetyPreview] = useState<string | null>(null);
const [drawingFile, setDrawingFile] = useState<File | null>(null);
const [drawingPreview, setDrawingPreview] = useState<string | null>(null);
const [fieldFile, setFieldFile] = useState<File | null>(null);
const [fieldPreview, setFieldPreview] = useState<string | null>(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<VisionClassifyResult>({
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<VisionSafetyResult>({
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<VisionDrawingResult>({
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 (
<AppLayout>
<div className="space-y-6">
<h1 className="text-xl font-bold">Vision AI </h1>
{/* Tabs */}
<div className="flex gap-2">
{TABS.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
tab === t.id ? "bg-blue-500 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
{t.label}
</button>
))}
</div>
{/* Tab: Classify */}
{tab === "classify" && (
<div className="grid grid-cols-2 gap-6">
<div className="space-y-4">
<ImageUploadBox
label="현장 사진을 업로드하세요"
onChange={(f) => handleFileChange(f, setClassifyFile, setClassifyPreview)}
preview={classifyPreview}
/>
<button
className="btn-primary w-full"
onClick={() => classifyMutation.mutate()}
disabled={!classifyFile || classifyMutation.isPending}
>
{classifyMutation.isPending ? "분석 중..." : "🔍 공종 분류 실행"}
</button>
</div>
<div>
{classifyMutation.data && (
<div className="card p-4 space-y-3">
<h3 className="font-semibold"> </h3>
<div>
<p className="text-xs text-gray-400"></p>
<p className="text-lg font-bold text-blue-600">{classifyMutation.data.work_type}</p>
<p className="text-xs text-gray-500">: {classifyMutation.data.confidence}</p>
</div>
<div>
<p className="text-xs text-gray-400 mb-1"></p>
<div className="flex flex-wrap gap-1">
{classifyMutation.data.tags.map((tag) => (
<span key={tag} className="px-2 py-0.5 bg-blue-50 text-blue-700 text-xs rounded-full">
#{tag}
</span>
))}
</div>
</div>
<div>
<p className="text-xs text-gray-400 mb-1"></p>
<p className="text-sm text-gray-700">{classifyMutation.data.description}</p>
</div>
</div>
)}
{!classifyMutation.data && !classifyMutation.isPending && (
<div className="card p-8 text-center text-gray-400 text-sm">
</div>
)}
</div>
</div>
)}
{/* Tab: Safety */}
{tab === "safety" && (
<div className="grid grid-cols-2 gap-6">
<div className="space-y-4">
<ImageUploadBox
label="근로자 사진을 업로드하세요"
onChange={(f) => handleFileChange(f, setSafetyFile, setSafetyPreview)}
preview={safetyPreview}
/>
<button
className="btn-primary w-full"
onClick={() => safetyMutation.mutate()}
disabled={!safetyFile || safetyMutation.isPending}
>
{safetyMutation.isPending ? "분석 중..." : "⛑ 안전 점검 실행"}
</button>
</div>
<div>
{safetyMutation.data && (
<div className="card p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-semibold"> </h3>
<RiskBadge level={safetyMutation.data.risk_level} />
</div>
<div className="grid grid-cols-2 gap-3">
<div className={`p-3 rounded-lg text-center ${safetyMutation.data.helmet ? "bg-green-50" : "bg-red-50"}`}>
<p className="text-2xl">{safetyMutation.data.helmet ? "✅" : "❌"}</p>
<p className="text-sm font-medium mt-1"></p>
</div>
<div className={`p-3 rounded-lg text-center ${safetyMutation.data.vest ? "bg-green-50" : "bg-red-50"}`}>
<p className="text-2xl">{safetyMutation.data.vest ? "✅" : "❌"}</p>
<p className="text-sm font-medium mt-1"></p>
</div>
</div>
{safetyMutation.data.violations.length > 0 && (
<div>
<p className="text-xs text-red-500 font-semibold mb-1"> </p>
<ul className="text-sm text-gray-700 space-y-0.5">
{safetyMutation.data.violations.map((v, i) => (
<li key={i} className="flex items-start gap-1">
<span className="text-red-400"></span> {v}
</li>
))}
</ul>
</div>
)}
<div>
<p className="text-xs text-gray-400 mb-1"> </p>
<p className="text-sm text-gray-700">{safetyMutation.data.recommendation}</p>
</div>
</div>
)}
{!safetyMutation.data && !safetyMutation.isPending && (
<div className="card p-8 text-center text-gray-400 text-sm">
</div>
)}
</div>
</div>
)}
{/* Tab: Drawing Comparison */}
{tab === "drawing" && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<p className="text-sm font-medium text-gray-600"> </p>
<ImageUploadBox
label="설계 도면 이미지"
onChange={(f) => handleFileChange(f, setDrawingFile, setDrawingPreview)}
preview={drawingPreview}
/>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-gray-600"> </p>
<ImageUploadBox
label="현장 시공 사진"
onChange={(f) => handleFileChange(f, setFieldFile, setFieldPreview)}
preview={fieldPreview}
/>
</div>
</div>
<button
className="btn-primary w-full"
onClick={() => drawingMutation.mutate()}
disabled={!drawingFile || !fieldFile || drawingMutation.isPending}
>
{drawingMutation.isPending ? "비교 분석 중..." : "📐 도면 대조 실행"}
</button>
{drawingMutation.data && (
<div className="card p-4 space-y-4">
<div className="flex items-center gap-3">
<h3 className="font-semibold"> </h3>
<span className="text-sm font-bold text-blue-600">{drawingMutation.data.overall_match}</span>
</div>
{drawingMutation.data.matches.length > 0 && (
<div>
<p className="text-xs text-green-600 font-semibold mb-1"> </p>
<ul className="text-sm text-gray-700 space-y-0.5">
{drawingMutation.data.matches.map((m, i) => (
<li key={i} className="flex items-start gap-1">
<span className="text-green-400"></span> {m}
</li>
))}
</ul>
</div>
)}
{drawingMutation.data.discrepancies.length > 0 && (
<div>
<p className="text-xs text-red-500 font-semibold mb-1"> </p>
<ul className="text-sm text-gray-700 space-y-0.5">
{drawingMutation.data.discrepancies.map((d, i) => (
<li key={i} className="flex items-start gap-1">
<span className="text-red-400"></span> {d}
</li>
))}
</ul>
</div>
)}
<div>
<p className="text-xs text-gray-400 mb-1"> </p>
<p className="text-sm text-gray-700">{drawingMutation.data.recommendation}</p>
</div>
</div>
)}
</div>
)}
</div>
</AppLayout>
);
}

View File

@@ -146,7 +146,7 @@ function WorkTypesTab() {
<span> <span>
{wt.weather_constraints.min_temp != null && `최저 ${wt.weather_constraints.min_temp}°C`} {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.max_wind != null && ` 최대 ${wt.weather_constraints.max_wind}m/s`}
{wt.weather_constraints.no_rain && " 우천불가"} {wt.weather_constraints.no_rain ? " 우천불가" : ""}
</span> </span>
) : "-"} ) : "-"}
</td> </td>

View File

@@ -191,3 +191,87 @@ export interface RagAnswer {
sources: RagSource[]; sources: RagSource[];
disclaimer: string; 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<string, unknown> | 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;
}