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:
208
frontend/src/app/portal/page.tsx
Normal file
208
frontend/src/app/portal/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
283
frontend/src/app/projects/[id]/agents/page.tsx
Normal file
283
frontend/src/app/projects/[id]/agents/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
frontend/src/app/projects/[id]/completion/page.tsx
Normal file
122
frontend/src/app/projects/[id]/completion/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
220
frontend/src/app/projects/[id]/evms/page.tsx
Normal file
220
frontend/src/app/projects/[id]/evms/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
</div>
|
||||
|
||||
{/* Module Tabs */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{TABS.map((tab) => (
|
||||
<Link
|
||||
key={tab.id}
|
||||
|
||||
328
frontend/src/app/projects/[id]/vision/page.tsx
Normal file
328
frontend/src/app/projects/[id]/vision/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -146,7 +146,7 @@ function WorkTypesTab() {
|
||||
<span>
|
||||
{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 ? " 우천불가" : ""}
|
||||
</span>
|
||||
) : "-"}
|
||||
</td>
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user