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>
This commit is contained in:
sinmb79
2026-03-24 20:06:36 +09:00
commit 2a4950d8a0
99 changed files with 7447 additions and 0 deletions

View File

@@ -0,0 +1,153 @@
"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 { PermitItem, PermitStatus } from "@/lib/types";
import { formatDate, PERMIT_STATUS_LABELS, PERMIT_STATUS_COLORS } from "@/lib/utils";
import Link from "next/link";
export default function PermitsPage() {
const { id } = useParams<{ id: string }>();
const qc = useQueryClient();
const [showForm, setShowForm] = useState(false);
const { data: permits = [], isLoading } = useQuery<PermitItem[]>({
queryKey: ["permits", id],
queryFn: () => api.get(`/projects/${id}/permits`).then((r) => r.data),
});
const createMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => api.post(`/projects/${id}/permits`, data),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["permits", id] }); setShowForm(false); },
});
const updateMutation = useMutation({
mutationFn: ({ permitId, status }: { permitId: string; status: PermitStatus }) =>
api.put(`/projects/${id}/permits/${permitId}`, { status }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["permits", id] }),
});
const approvedCount = permits.filter((p) => p.status === "approved").length;
const progress = permits.length > 0 ? Math.round((approvedCount / permits.length) * 100) : 0;
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)}>
</button>
</div>
{/* Progress */}
<div className="card p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium"> </span>
<span className="text-sm text-gray-500">{approvedCount}/{permits.length} </span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div className="bg-green-500 h-2 rounded-full transition-all" style={{ width: `${progress}%` }} />
</div>
<p className="text-xs text-gray-400 mt-1">{progress}% </p>
</div>
{showForm && (
<div className="card p-5">
<h3 className="mb-4"> </h3>
<form
onSubmit={(e) => {
e.preventDefault();
const fd = new FormData(e.target as HTMLFormElement);
createMutation.mutate({
permit_type: fd.get("permit_type"),
authority: fd.get("authority") || undefined,
deadline: fd.get("deadline") || undefined,
notes: fd.get("notes") || undefined,
});
}}
className="grid grid-cols-2 gap-4"
>
<div>
<label className="label"> *</label>
<input name="permit_type" className="input" required placeholder="도로점용허가" />
</div>
<div>
<label className="label"> </label>
<input name="authority" className="input" placeholder="○○시청 건설과" />
</div>
<div>
<label className="label"> </label>
<input name="deadline" className="input" type="date" />
</div>
<div>
<label className="label"></label>
<input name="notes" className="input" />
</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={createMutation.isPending}></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>
) : permits.length === 0 ? (
<tr><td colSpan={7} className="text-center py-8 text-gray-400"> </td></tr>
) : (
permits.map((p) => (
<tr key={p.id}>
<td className="font-medium">{p.permit_type}</td>
<td>{p.authority || "-"}</td>
<td>{p.deadline ? formatDate(p.deadline) : "-"}</td>
<td>{p.submitted_date ? formatDate(p.submitted_date) : "-"}</td>
<td>{p.approved_date ? formatDate(p.approved_date) : "-"}</td>
<td>
<span className={`badge ${PERMIT_STATUS_COLORS[p.status]}`}>
{PERMIT_STATUS_LABELS[p.status]}
</span>
</td>
<td>
<select
className="text-xs border border-gray-200 rounded px-2 py-1"
value={p.status}
onChange={(e) => updateMutation.mutate({ permitId: p.id, status: e.target.value as PermitStatus })}
>
{Object.entries(PERMIT_STATUS_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</AppLayout>
);
}