Files
conai/frontend/src/app/projects/[id]/inspections/page.tsx
sinmb79 2a4950d8a0 feat: CONAI Phase 1 MVP 초기 구현
소형 건설업체(100억 미만)를 위한 AI 기반 토목공사 통합관리 플랫폼

Backend (FastAPI):
- SQLAlchemy 모델 13개 (users, projects, wbs, tasks, daily_reports, reports, inspections, quality, weather, permits, rag, settings)
- API 라우터 11개 (auth, projects, tasks, daily_reports, reports, inspections, weather, rag, kakao, permits, settings)
- Services: Claude AI 래퍼, CPM Gantt 계산, 기상청 API, RAG(pgvector), 카카오 Skill API
- Alembic 마이그레이션 (pgvector 포함)
- pytest 테스트 (CPM, 날씨 경보)

Frontend (Next.js 15):
- 11개 페이지 (대시보드, 프로젝트, Gantt, 일보, 검측, 품질, 날씨, 인허가, RAG, 설정)
- TanStack Query + Zustand + Tailwind CSS

인프라:
- Docker Compose (PostgreSQL pgvector + backend + frontend)
- 한국어 README 및 설치 가이드

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:06:36 +09:00

144 lines
5.8 KiB
TypeScript

"use client";
import { useState } 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 { InspectionRequest } from "@/lib/types";
import { formatDate } from "@/lib/utils";
import Link from "next/link";
const INSPECTION_TYPES = [
"철근 검측", "거푸집 검측", "콘크리트 타설 전 검측",
"관로 매설 검측", "성토 다짐 검측", "도로 포장 검측", "기타",
];
export default function InspectionsPage() {
const { id } = useParams<{ id: string }>();
const qc = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [generating, setGenerating] = useState(false);
const { data: inspections = [], isLoading } = useQuery<InspectionRequest[]>({
queryKey: ["inspections", id],
queryFn: () => api.get(`/projects/${id}/inspections`).then((r) => r.data),
});
async function handleGenerate(data: Record<string, unknown>) {
setGenerating(true);
try {
await api.post(`/projects/${id}/inspections/generate`, data);
qc.invalidateQueries({ queryKey: ["inspections", id] });
setShowForm(false);
} finally {
setGenerating(false);
}
}
const resultLabels: Record<string, { label: string; cls: string }> = {
pass: { label: "합격", cls: "badge-green" },
fail: { label: "불합격", cls: "badge-red" },
conditional_pass: { label: "조건부 합격", cls: "badge-yellow" },
};
const statusLabels: Record<string, string> = {
draft: "초안", sent: "발송완료", completed: "검측완료",
};
return (
<AppLayout>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Link href={`/projects/${id}`} className="text-gray-400 hover:text-gray-600 text-sm"> </Link>
<h1 className="mt-1"></h1>
</div>
<button className="btn-primary" onClick={() => setShowForm(!showForm)}>
🤖 AI
</button>
</div>
{showForm && (
<div className="card p-5">
<h3 className="mb-4"> AI </h3>
<form
onSubmit={(e) => {
e.preventDefault();
const fd = new FormData(e.target as HTMLFormElement);
handleGenerate({
inspection_type: fd.get("inspection_type"),
requested_date: fd.get("requested_date"),
location_detail: fd.get("location_detail") || undefined,
});
}}
className="grid grid-cols-2 gap-4"
>
<div>
<label className="label"> *</label>
<select name="inspection_type" className="input" required>
{INSPECTION_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div>
<label className="label"> *</label>
<input name="requested_date" className="input" type="date" required defaultValue={new Date().toISOString().split("T")[0]} />
</div>
<div className="col-span-2">
<label className="label"> </label>
<input name="location_detail" className="input" placeholder="예: 3공구 A구간 STA.1+200~1+350" />
</div>
<div className="col-span-2 flex gap-2 justify-end">
<button type="button" className="btn-secondary" onClick={() => setShowForm(false)}></button>
<button type="submit" className="btn-primary" disabled={generating}>
{generating ? "AI 생성중..." : "🤖 체크리스트 생성"}
</button>
</div>
</form>
</div>
)}
<div className="card">
<div className="table-container">
<table className="table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr><td colSpan={7} className="text-center py-8 text-gray-400"> ...</td></tr>
) : inspections.length === 0 ? (
<tr><td colSpan={7} className="text-center py-8 text-gray-400"> </td></tr>
) : (
inspections.map((insp) => (
<tr key={insp.id}>
<td>{formatDate(insp.requested_date)}</td>
<td className="font-medium">{insp.inspection_type}</td>
<td>{insp.location_detail || "-"}</td>
<td>{insp.checklist_items ? `${insp.checklist_items.length}개 항목` : "-"}</td>
<td>
{insp.result ? (
<span className={`badge ${resultLabels[insp.result]?.cls}`}>{resultLabels[insp.result]?.label}</span>
) : "-"}
</td>
<td><span className="badge badge-gray">{statusLabels[insp.status]}</span></td>
<td>{insp.ai_generated ? <span className="badge badge-blue">AI</span> : <span className="badge badge-gray"></span>}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</AppLayout>
);
}