Files
conai/frontend/src/app/projects/[id]/permits/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

154 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}