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

1
frontend/.env.example Normal file
View File

@@ -0,0 +1 @@
NEXT_PUBLIC_API_URL=http://localhost:8000

10
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
CMD ["npm", "run", "dev"]

10
frontend/next.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000",
},
};
export default nextConfig;

37
frontend/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "conai-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@tanstack/react-query": "^5.62.0",
"axios": "^1.7.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"frappe-gantt": "^0.6.1",
"lucide-react": "^0.469.0",
"tailwind-merge": "^2.6.0",
"zustand": "^5.0.2"
},
"devDependencies": {
"@types/node": "^22.10.5",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0",
"eslint-config-next": "^15.1.0",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2"
}
}

View File

@@ -0,0 +1,8 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;

View File

@@ -0,0 +1,123 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { AppLayout } from "@/components/layout/AppLayout";
import api from "@/lib/api";
import type { Project, WeatherAlert } from "@/lib/types";
import { formatDate, formatCurrency, PROJECT_STATUS_LABELS } from "@/lib/utils";
import Link from "next/link";
export default function DashboardPage() {
const { data: projects = [] } = useQuery<Project[]>({
queryKey: ["projects"],
queryFn: () => api.get("/projects").then((r) => r.data),
});
const activeProjects = projects.filter((p) => p.status === "active");
const planningProjects = projects.filter((p) => p.status === "planning");
return (
<AppLayout>
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-gray-500 text-sm mt-1"> </p>
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-4">
<StatCard label="전체 현장" value={projects.length} icon="🏗" color="blue" />
<StatCard label="진행중" value={activeProjects.length} icon="🔄" color="green" />
<StatCard label="계획중" value={planningProjects.length} icon="📋" color="yellow" />
<StatCard label="완료" value={projects.filter((p) => p.status === "completed").length} icon="✅" color="gray" />
</div>
{/* Active Projects */}
<div className="card">
<div className="card-header">
<h2 className="font-semibold"> </h2>
<Link href="/projects" className="btn-secondary text-xs">
</Link>
</div>
<div className="card-body p-0">
{activeProjects.length === 0 ? (
<div className="p-6 text-center text-gray-400 text-sm">
.{" "}
<Link href="/projects" className="text-brand-500 hover:underline">
</Link>
</div>
) : (
<div className="divide-y divide-gray-100">
{activeProjects.map((p) => (
<Link
key={p.id}
href={`/projects/${p.id}`}
className="flex items-center justify-between px-6 py-4 hover:bg-gray-50 transition-colors"
>
<div>
<p className="font-medium text-gray-900">{p.name}</p>
<p className="text-xs text-gray-500 mt-0.5">
{p.code} · {formatDate(p.start_date)} ~ {formatDate(p.end_date)}
</p>
</div>
<div className="text-right">
<span className="badge badge-green">{PROJECT_STATUS_LABELS[p.status]}</span>
<p className="text-xs text-gray-400 mt-1">{formatCurrency(p.contract_amount)}</p>
</div>
</Link>
))}
</div>
)}
</div>
</div>
{/* Quick Actions */}
<div className="grid grid-cols-3 gap-4">
<QuickAction href="/rag" icon="📚" title="법규 Q&A" desc="KCS·법령 즉시 검색" />
<QuickAction href="/projects" icon="" title="현장 등록" desc="새 공사 현장 추가" />
<QuickAction href="/settings" icon="⚙️" title="설정" desc="발주처·공종 관리" />
</div>
</div>
</AppLayout>
);
}
function StatCard({
label, value, icon, color,
}: {
label: string; value: number; icon: string; color: "blue" | "green" | "yellow" | "gray";
}) {
const colors = {
blue: "bg-blue-50 text-blue-600",
green: "bg-green-50 text-green-600",
yellow: "bg-yellow-50 text-yellow-600",
gray: "bg-gray-50 text-gray-600",
};
return (
<div className="card p-4">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center text-lg mb-3 ${colors[color]}`}>
{icon}
</div>
<p className="text-2xl font-bold text-gray-900">{value}</p>
<p className="text-xs text-gray-500 mt-0.5">{label}</p>
</div>
);
}
function QuickAction({
href, icon, title, desc,
}: {
href: string; icon: string; title: string; desc: string;
}) {
return (
<Link href={href} className="card p-4 hover:shadow-md transition-shadow flex items-start gap-3">
<span className="text-2xl">{icon}</span>
<div>
<p className="font-semibold text-gray-900 text-sm">{title}</p>
<p className="text-xs text-gray-500 mt-0.5">{desc}</p>
</div>
</Link>
);
}

View File

@@ -0,0 +1,79 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--brand: #1a4b8c;
--brand-light: #e8eef8;
}
body {
@apply text-sm;
}
h1 { @apply text-2xl font-bold; }
h2 { @apply text-xl font-bold; }
h3 { @apply text-lg font-semibold; }
}
@layer components {
.btn {
@apply inline-flex items-center gap-2 px-4 py-2 rounded-lg font-medium text-sm transition-colors;
}
.btn-primary {
@apply btn bg-brand-500 text-white hover:bg-brand-600 disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-secondary {
@apply btn bg-white text-gray-700 border border-gray-200 hover:bg-gray-50;
}
.btn-danger {
@apply btn bg-red-600 text-white hover:bg-red-700;
}
.card {
@apply bg-white rounded-xl border border-gray-200 shadow-sm;
}
.card-header {
@apply px-6 py-4 border-b border-gray-100 flex items-center justify-between;
}
.card-body {
@apply px-6 py-4;
}
.badge {
@apply inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium;
}
.badge-blue { @apply badge bg-blue-100 text-blue-700; }
.badge-green { @apply badge bg-green-100 text-green-700; }
.badge-yellow { @apply badge bg-yellow-100 text-yellow-700; }
.badge-red { @apply badge bg-red-100 text-red-700; }
.badge-gray { @apply badge bg-gray-100 text-gray-700; }
.input {
@apply w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent;
}
.label {
@apply block text-xs font-medium text-gray-600 mb-1;
}
.table-container {
@apply overflow-x-auto rounded-lg border border-gray-200;
}
.table {
@apply w-full text-sm text-left;
}
.table thead tr {
@apply bg-brand-500 text-white;
}
.table thead th {
@apply px-4 py-3 font-medium text-xs uppercase tracking-wide;
}
.table tbody tr {
@apply border-b border-gray-100 hover:bg-gray-50 transition-colors;
}
.table tbody td {
@apply px-4 py-3;
}
}

View File

@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import "./globals.css";
import { QueryProvider } from "@/components/providers/QueryProvider";
export const metadata: Metadata = {
title: "CONAI — 건설 AI 통합관리",
description: "소형 건설업체를 위한 AI 기반 토목공사 통합관리 플랫폼",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body className="antialiased bg-gray-50 text-gray-900">
<QueryProvider>{children}</QueryProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,81 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const router = useRouter();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
await login(email, password);
router.push("/dashboard");
} catch {
setError("이메일 또는 비밀번호가 올바르지 않습니다");
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="w-full max-w-sm">
{/* Logo */}
<div className="text-center mb-8">
<div className="inline-block bg-brand-500 text-white text-2xl font-bold px-4 py-2 rounded-lg mb-3">
CONAI
</div>
<p className="text-gray-500 text-sm"> AI </p>
</div>
<div className="card p-6">
<h1 className="text-lg font-bold mb-6"></h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="label"></label>
<input
type="email"
className="input"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="site@example.com"
required
/>
</div>
<div>
<label className="label"></label>
<input
type="password"
className="input"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
/>
</div>
{error && (
<p className="text-red-600 text-xs bg-red-50 border border-red-200 rounded px-3 py-2">
{error}
</p>
)}
<button type="submit" className="btn-primary w-full justify-center" disabled={loading}>
{loading ? "로그인 중..." : "로그인"}
</button>
</form>
</div>
<p className="text-center text-xs text-gray-400 mt-6">
CONAI v1.0 · 22B Labs
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/dashboard");
}

View File

@@ -0,0 +1,150 @@
"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 { GanttData, Task } from "@/lib/types";
import { formatDate } from "@/lib/utils";
import { useState } from "react";
import Link from "next/link";
export default function GanttPage() {
const { id } = useParams<{ id: string }>();
const qc = useQueryClient();
const [showCreateForm, setShowCreateForm] = useState(false);
const { data: gantt, isLoading } = useQuery<GanttData>({
queryKey: ["gantt", id],
queryFn: () => api.get(`/projects/${id}/tasks/gantt`).then((r) => r.data),
});
const createTaskMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => api.post(`/projects/${id}/tasks`, data),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["gantt", id] }); setShowCreateForm(false); },
});
const updateProgressMutation = useMutation({
mutationFn: ({ taskId, progress }: { taskId: string; progress: number }) =>
api.put(`/projects/${id}/tasks/${taskId}`, { progress_pct: progress }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["gantt", id] }),
});
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"> (Gantt)</h1>
{gantt?.project_duration_days && (
<p className="text-sm text-gray-500 mt-0.5"> : {gantt.project_duration_days} | : {gantt.critical_path.length} </p>
)}
</div>
<button className="btn-primary" onClick={() => setShowCreateForm(!showCreateForm)}>
</button>
</div>
{showCreateForm && (
<div className="card p-5">
<h3 className="mb-4"> </h3>
<form
onSubmit={(e) => {
e.preventDefault();
const fd = new FormData(e.target as HTMLFormElement);
createTaskMutation.mutate({
name: fd.get("name"),
planned_start: fd.get("planned_start") || undefined,
planned_end: fd.get("planned_end") || undefined,
});
}}
className="grid grid-cols-3 gap-4"
>
<div className="col-span-3 md:col-span-1">
<label className="label"> *</label>
<input name="name" className="input" required placeholder="콘크리트 타설" />
</div>
<div>
<label className="label"> </label>
<input name="planned_start" className="input" type="date" />
</div>
<div>
<label className="label"> </label>
<input name="planned_end" className="input" type="date" />
</div>
<div className="col-span-3 flex gap-2 justify-end">
<button type="button" className="btn-secondary" onClick={() => setShowCreateForm(false)}></button>
<button type="submit" className="btn-primary" disabled={createTaskMutation.isPending}></button>
</div>
</form>
</div>
)}
{/* Task List with Gantt-like bars */}
<div className="card">
<div className="card-header">
<span className="font-semibold"> </span>
<span className="text-gray-400 text-sm">{gantt?.tasks.length || 0} </span>
</div>
<div className="table-container">
<table className="table">
<thead>
<tr>
<th></th>
<th> </th>
<th> </th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr><td colSpan={6} className="text-center py-8 text-gray-400">CPM ...</td></tr>
) : !gantt?.tasks.length ? (
<tr><td colSpan={6} className="text-center py-8 text-gray-400"> </td></tr>
) : (
gantt.tasks.map((task) => (
<tr key={task.id} className={task.is_critical ? "bg-red-50" : ""}>
<td className="font-medium">
{task.is_critical && <span className="text-red-500 mr-1"></span>}
{task.name}
</td>
<td>{formatDate(task.planned_start)}</td>
<td>{formatDate(task.planned_end)}</td>
<td>
<div className="flex items-center gap-2">
<div className="flex-1 bg-gray-200 rounded-full h-1.5 min-w-[60px]">
<div
className={`h-1.5 rounded-full ${task.progress_pct >= 100 ? "bg-green-500" : "bg-brand-500"}`}
style={{ width: `${task.progress_pct}%` }}
/>
</div>
<span className="text-xs text-gray-500 w-8">{task.progress_pct}%</span>
</div>
</td>
<td>
{task.is_critical ? (
<span className="badge badge-red"></span>
) : (
<span className="badge badge-gray"></span>
)}
</td>
<td>{task.total_float != null ? `${task.total_float}` : "-"}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Legend */}
<div className="flex items-center gap-4 text-xs text-gray-500">
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded-full bg-red-400 inline-block"></span> (Critical Path)</span>
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded-full bg-gray-300 inline-block"></span> </span>
</div>
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,143 @@
"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>
);
}

View File

@@ -0,0 +1,87 @@
"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 { Project } from "@/lib/types";
import { formatDate, formatCurrency, PROJECT_STATUS_LABELS, CONSTRUCTION_TYPE_LABELS } from "@/lib/utils";
import Link from "next/link";
const TABS = [
{ id: "gantt", label: "📅 공정표", href: (id: string) => `/projects/${id}/gantt` },
{ id: "reports", label: "📋 일보/보고서", href: (id: string) => `/projects/${id}/reports` },
{ id: "inspections", label: "🔬 검측", href: (id: string) => `/projects/${id}/inspections` },
{ 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` },
];
export default function ProjectDetailPage() {
const params = useParams();
const id = params.id as string;
const { data: project, isLoading } = useQuery<Project>({
queryKey: ["project", id],
queryFn: () => api.get(`/projects/${id}`).then((r) => r.data),
});
if (isLoading) return <AppLayout><div className="text-gray-400"> ...</div></AppLayout>;
if (!project) return <AppLayout><div className="text-red-500"> </div></AppLayout>;
const statusColors: Record<string, string> = {
active: "badge-green", planning: "badge-blue",
suspended: "badge-yellow", completed: "badge-gray",
};
return (
<AppLayout>
<div className="space-y-6">
{/* Header */}
<div className="card p-5">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-3 mb-1">
<Link href="/projects" className="text-gray-400 hover:text-gray-600 text-sm"> </Link>
<span className={`badge ${statusColors[project.status]}`}>{PROJECT_STATUS_LABELS[project.status]}</span>
</div>
<h1 className="text-xl font-bold">{project.name}</h1>
<p className="text-gray-500 text-sm mt-0.5">
{project.code} · {CONSTRUCTION_TYPE_LABELS[project.construction_type]}
</p>
</div>
</div>
<div className="grid grid-cols-4 gap-4 mt-4 pt-4 border-t border-gray-100">
<InfoItem label="착공일" value={formatDate(project.start_date)} />
<InfoItem label="준공예정일" value={formatDate(project.end_date)} />
<InfoItem label="계약금액" value={formatCurrency(project.contract_amount)} />
<InfoItem label="공사위치" value={project.location_address || "-"} />
</div>
</div>
{/* Module Tabs */}
<div className="grid grid-cols-3 gap-3">
{TABS.map((tab) => (
<Link
key={tab.id}
href={tab.href(id)}
className="card p-4 hover:shadow-md transition-shadow flex items-center gap-3"
>
<span className="text-2xl">{tab.label.split(" ")[0]}</span>
<span className="font-medium text-sm">{tab.label.split(" ").slice(1).join(" ")}</span>
</Link>
))}
</div>
</div>
</AppLayout>
);
}
function InfoItem({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-xs text-gray-400 uppercase tracking-wide">{label}</p>
<p className="font-medium text-sm mt-0.5">{value}</p>
</div>
);
}

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>
);
}

View File

@@ -0,0 +1,176 @@
"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 { formatDate } from "@/lib/utils";
import Link from "next/link";
interface QualityTest {
id: string;
test_type: string;
test_date: string;
location_detail?: string;
design_value?: number;
measured_value: number;
unit: string;
result: "pass" | "fail";
lab_name?: string;
notes?: string;
}
const TEST_TYPES = [
"콘크리트 압축강도", "슬럼프 시험", "공기량 시험",
"다짐도 시험", "CBR 시험", "체분석 시험", "기타",
];
export default function QualityPage() {
const { id } = useParams<{ id: string }>();
const qc = useQueryClient();
const [showForm, setShowForm] = useState(false);
const { data: tests = [], isLoading } = useQuery<QualityTest[]>({
queryKey: ["quality-tests", id],
queryFn: () => api.get(`/projects/${id}/quality-tests`).then((r) => r.data),
});
const createMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => api.post(`/projects/${id}/quality-tests`, data),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["quality-tests", id] }); setShowForm(false); },
});
const passCount = tests.filter((t) => t.result === "pass").length;
const failCount = tests.filter((t) => t.result === "fail").length;
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>
{/* Stats */}
<div className="grid grid-cols-3 gap-4">
<div className="card p-4 text-center">
<p className="text-2xl font-bold text-gray-900">{tests.length}</p>
<p className="text-xs text-gray-500"> </p>
</div>
<div className="card p-4 text-center">
<p className="text-2xl font-bold text-green-600">{passCount}</p>
<p className="text-xs text-gray-500"></p>
</div>
<div className="card p-4 text-center">
<p className="text-2xl font-bold text-red-600">{failCount}</p>
<p className="text-xs text-gray-500"></p>
</div>
</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);
const designVal = fd.get("design_value");
const measuredVal = Number(fd.get("measured_value"));
const designNum = designVal ? Number(designVal) : undefined;
// Auto-determine result
const result = designNum != null
? (measuredVal >= designNum ? "pass" : "fail")
: (fd.get("result") as string);
createMutation.mutate({
test_type: fd.get("test_type"),
test_date: fd.get("test_date"),
location_detail: fd.get("location_detail") || undefined,
design_value: designNum,
measured_value: measuredVal,
unit: fd.get("unit"),
result,
lab_name: fd.get("lab_name") || undefined,
notes: fd.get("notes") || undefined,
});
}}
className="grid grid-cols-2 gap-4"
>
<div>
<label className="label"> *</label>
<select name="test_type" className="input" required>
{TEST_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div>
<label className="label"> *</label>
<input name="test_date" className="input" type="date" required defaultValue={new Date().toISOString().split("T")[0]} />
</div>
<div>
<label className="label"></label>
<input name="design_value" className="input" type="number" step="0.1" />
</div>
<div>
<label className="label"> *</label>
<input name="measured_value" className="input" type="number" step="0.1" required />
</div>
<div>
<label className="label"> *</label>
<input name="unit" className="input" required placeholder="MPa, mm, % ..." defaultValue="MPa" />
</div>
<div>
<label className="label"></label>
<input name="lab_name" className="input" placeholder="○○시험연구원" />
</div>
<div className="col-span-2">
<label className="label"> </label>
<input name="location_detail" className="input" placeholder="3공구 A구간" />
</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><th></th></tr>
</thead>
<tbody>
{isLoading ? (
<tr><td colSpan={8} className="text-center py-8 text-gray-400"> ...</td></tr>
) : tests.length === 0 ? (
<tr><td colSpan={8} className="text-center py-8 text-gray-400"> </td></tr>
) : (
tests.map((t) => (
<tr key={t.id}>
<td>{formatDate(t.test_date)}</td>
<td className="font-medium">{t.test_type}</td>
<td>{t.location_detail || "-"}</td>
<td>{t.design_value != null ? t.design_value : "-"}</td>
<td className="font-semibold">{t.measured_value}</td>
<td>{t.unit}</td>
<td>
<span className={`badge ${t.result === "pass" ? "badge-green" : "badge-red"}`}>
{t.result === "pass" ? "합격" : "불합격"}
</span>
</td>
<td>{t.lab_name || "-"}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,172 @@
"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 { DailyReport } from "@/lib/types";
import { formatDate, DAILY_REPORT_STATUS_LABELS } from "@/lib/utils";
import Link from "next/link";
export default function ReportsPage() {
const { id } = useParams<{ id: string }>();
const qc = useQueryClient();
const [showGenerateForm, setShowGenerateForm] = useState(false);
const [generating, setGenerating] = useState(false);
const { data: reports = [], isLoading } = useQuery<DailyReport[]>({
queryKey: ["daily-reports", id],
queryFn: () => api.get(`/projects/${id}/daily-reports`).then((r) => r.data),
});
async function handleGenerate(formData: Record<string, unknown>) {
setGenerating(true);
try {
await api.post(`/projects/${id}/daily-reports/generate`, formData);
qc.invalidateQueries({ queryKey: ["daily-reports", id] });
setShowGenerateForm(false);
} finally {
setGenerating(false);
}
}
return (
<AppLayout>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<Link href={`/projects/${id}`} className="text-gray-400 hover:text-gray-600 text-sm"> </Link>
</div>
<h1> · </h1>
</div>
<button className="btn-primary" onClick={() => setShowGenerateForm(!showGenerateForm)}>
🤖 AI
</button>
</div>
{showGenerateForm && (
<div className="card p-5">
<h3 className="mb-4">AI </h3>
<GenerateDailyReportForm
onSubmit={handleGenerate}
onCancel={() => setShowGenerateForm(false)}
loading={generating}
/>
</div>
)}
<div className="card">
<div className="card-header">
<span className="font-semibold"> </span>
<span className="text-gray-400 text-sm">{reports.length}</span>
</div>
<div className="table-container">
<table className="table">
<thead>
<tr>
<th></th>
<th></th>
<th> ()</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr><td colSpan={5} className="text-center py-8 text-gray-400"> ...</td></tr>
) : reports.length === 0 ? (
<tr><td colSpan={5} className="text-center py-8 text-gray-400"> </td></tr>
) : (
reports.map((r) => (
<tr key={r.id}>
<td className="font-medium">{formatDate(r.report_date)}</td>
<td>{r.weather_summary || "-"}</td>
<td className="max-w-xs truncate text-gray-600">{r.work_content?.slice(0, 60) || "-"}...</td>
<td>{r.ai_generated ? <span className="badge badge-blue">AI </span> : <span className="badge badge-gray"></span>}</td>
<td>
<span className={`badge ${r.status === "confirmed" ? "badge-green" : r.status === "submitted" ? "badge-blue" : "badge-gray"}`}>
{DAILY_REPORT_STATUS_LABELS[r.status]}
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</AppLayout>
);
}
function GenerateDailyReportForm({
onSubmit, onCancel, loading,
}: {
onSubmit: (data: Record<string, unknown>) => void;
onCancel: () => void;
loading: boolean;
}) {
const today = new Date().toISOString().split("T")[0];
const [reportDate, setReportDate] = useState(today);
const [workItems, setWorkItems] = useState("");
const [workers, setWorkers] = useState(""); // "콘크리트 5, 철근 3"
const [issues, setIssues] = useState("");
function parseWorkers(input: string): Record<string, number> {
const result: Record<string, number> = {};
const parts = input.split(/[,]/).map((s) => s.trim());
for (const part of parts) {
const match = part.match(/^(.+?)\s+(\d+)$/);
if (match) result[match[1].trim()] = Number(match[2]);
}
return result;
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
onSubmit({
report_date: reportDate,
workers_count: parseWorkers(workers),
work_items: workItems.split("\n").filter(Boolean),
issues: issues || undefined,
equipment_list: [],
});
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label"> *</label>
<input className="input" type="date" value={reportDate} onChange={(e) => setReportDate(e.target.value)} required />
</div>
<div>
<label className="label"> (: 콘크리트 5, 3)</label>
<input className="input" value={workers} onChange={(e) => setWorkers(e.target.value)} placeholder="콘크리트 5, 철근 3, 목수 2" />
</div>
</div>
<div>
<label className="label"> ( ) *</label>
<textarea
className="input min-h-[80px]"
value={workItems}
onChange={(e) => setWorkItems(e.target.value)}
placeholder={"관로매설 50m 완료\n되메우기 작업\n시험성토 진행"}
required
/>
</div>
<div>
<label className="label"></label>
<input className="input" value={issues} onChange={(e) => setIssues(e.target.value)} placeholder="특이사항 없으면 빈칸" />
</div>
<div className="flex gap-2 justify-end pt-2">
<button type="button" className="btn-secondary" onClick={onCancel}></button>
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? "AI 생성중..." : "🤖 일보 생성"}
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,115 @@
"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 { WeatherForecastSummary } from "@/lib/types";
import { formatDate } from "@/lib/utils";
import Link from "next/link";
const WEATHER_CODE_ICONS: Record<string, string> = {
"1": "☀️", "2": "🌤", "3": "⛅", "4": "☁️",
"5": "🌧", "6": "🌨", "7": "🌨", "8": "❄️",
};
export default function WeatherPage() {
const { id } = useParams<{ id: string }>();
const qc = useQueryClient();
const { data, isLoading } = useQuery<WeatherForecastSummary>({
queryKey: ["weather", id],
queryFn: () => api.get(`/projects/${id}/weather`).then((r) => r.data),
});
const refreshMutation = useMutation({
mutationFn: () => api.post(`/projects/${id}/weather/refresh`),
onSuccess: () => qc.invalidateQueries({ queryKey: ["weather", id] }),
});
const acknowledgeMutation = useMutation({
mutationFn: (alertId: string) => api.put(`/projects/${id}/weather/alerts/${alertId}/acknowledge`),
onSuccess: () => qc.invalidateQueries({ queryKey: ["weather", id] }),
});
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-secondary"
onClick={() => refreshMutation.mutate()}
disabled={refreshMutation.isPending}
>
{refreshMutation.isPending ? "새로고침 중..." : "🔄 날씨 새로고침"}
</button>
</div>
{/* Active Alerts */}
{data?.active_alerts && data.active_alerts.length > 0 && (
<div className="space-y-2">
<h3 className="text-red-600"> ({data.active_alerts.length})</h3>
{data.active_alerts.map((alert) => (
<div
key={alert.id}
className={`card p-4 border-l-4 ${alert.severity === "critical" ? "border-red-500 bg-red-50" : "border-yellow-500 bg-yellow-50"}`}
>
<div className="flex items-start justify-between">
<div>
<p className="font-medium text-sm">
{alert.severity === "critical" ? "🚨" : "⚠️"} {formatDate(alert.alert_date)}
</p>
<p className="text-sm text-gray-700 mt-1">{alert.message}</p>
</div>
<button
className="btn-secondary text-xs"
onClick={() => acknowledgeMutation.mutate(alert.id)}
>
</button>
</div>
</div>
))}
</div>
)}
{/* Forecast */}
<div className="card">
<div className="card-header">
<h3 className="font-semibold"> </h3>
</div>
<div className="card-body">
{isLoading ? (
<p className="text-gray-400 text-center py-4"> ...</p>
) : !data?.forecast || data.forecast.length === 0 ? (
<p className="text-gray-400 text-center py-4"> . .</p>
) : (
<div className="grid grid-cols-4 gap-3">
{data.forecast.map((f) => (
<div key={f.id} className="bg-gray-50 rounded-lg p-3 text-center">
<p className="text-xs text-gray-500 mb-1">{formatDate(f.forecast_date)}</p>
<p className="text-2xl my-1">{WEATHER_CODE_ICONS[f.weather_code || "1"] || "🌤"}</p>
<p className="font-semibold text-sm">
{f.temperature_high != null ? `${f.temperature_high}°` : "-"}
<span className="text-gray-400"> / </span>
{f.temperature_low != null ? `${f.temperature_low}°` : "-"}
</p>
{f.precipitation_mm != null && f.precipitation_mm > 0 && (
<p className="text-xs text-blue-500 mt-0.5">💧 {f.precipitation_mm}mm</p>
)}
{f.wind_speed_ms != null && (
<p className="text-xs text-gray-400 mt-0.5">💨 {f.wind_speed_ms}m/s</p>
)}
</div>
))}
</div>
)}
</div>
</div>
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,192 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { AppLayout } from "@/components/layout/AppLayout";
import api from "@/lib/api";
import type { Project, ConstructionType } from "@/lib/types";
import { formatDate, formatCurrency, PROJECT_STATUS_LABELS, CONSTRUCTION_TYPE_LABELS } from "@/lib/utils";
import Link from "next/link";
export default function ProjectsPage() {
const [showForm, setShowForm] = useState(false);
const qc = useQueryClient();
const { data: projects = [], isLoading } = useQuery<Project[]>({
queryKey: ["projects"],
queryFn: () => api.get("/projects").then((r) => r.data),
});
const createMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => api.post("/projects", data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["projects"] });
setShowForm(false);
},
});
return (
<AppLayout>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1> </h1>
<p className="text-gray-500 text-sm mt-1"> </p>
</div>
<button className="btn-primary" onClick={() => setShowForm(!showForm)}>
</button>
</div>
{/* Create form */}
{showForm && (
<div className="card p-5">
<h3 className="mb-4"> </h3>
<CreateProjectForm
onSubmit={(data) => createMutation.mutate(data)}
onCancel={() => setShowForm(false)}
loading={createMutation.isPending}
/>
</div>
)}
{/* Projects table */}
<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>
<th></th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={8} className="text-center text-gray-400 py-8">
...
</td>
</tr>
) : projects.length === 0 ? (
<tr>
<td colSpan={8} className="text-center text-gray-400 py-8">
</td>
</tr>
) : (
projects.map((p) => (
<tr key={p.id}>
<td className="font-medium">{p.name}</td>
<td className="text-gray-500 font-mono text-xs">{p.code}</td>
<td>{CONSTRUCTION_TYPE_LABELS[p.construction_type]}</td>
<td>{formatDate(p.start_date)}</td>
<td>{formatDate(p.end_date)}</td>
<td>{formatCurrency(p.contract_amount)}</td>
<td>
<StatusBadge status={p.status} />
</td>
<td>
<Link href={`/projects/${p.id}`} className="btn-secondary text-xs py-1 px-2">
</Link>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</AppLayout>
);
}
function StatusBadge({ status }: { status: string }) {
const cls =
status === "active" ? "badge-green" :
status === "planning" ? "badge-blue" :
status === "suspended" ? "badge-yellow" :
"badge-gray";
return <span className={`badge ${cls}`}>{PROJECT_STATUS_LABELS[status]}</span>;
}
function CreateProjectForm({
onSubmit,
onCancel,
loading,
}: {
onSubmit: (data: Record<string, unknown>) => void;
onCancel: () => void;
loading: boolean;
}) {
const [form, setForm] = useState({
name: "",
code: "",
construction_type: "other" as ConstructionType,
start_date: "",
end_date: "",
contract_amount: "",
location_address: "",
});
const set = (field: string, val: string) => setForm((f) => ({ ...f, [field]: val }));
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
onSubmit({
...form,
contract_amount: form.contract_amount ? Number(form.contract_amount) : undefined,
start_date: form.start_date || undefined,
end_date: form.end_date || undefined,
});
}
return (
<form onSubmit={handleSubmit} className="grid grid-cols-2 gap-4">
<div>
<label className="label"> *</label>
<input className="input" value={form.name} onChange={(e) => set("name", e.target.value)} required />
</div>
<div>
<label className="label"> *</label>
<input className="input" value={form.code} onChange={(e) => set("code", e.target.value)} required placeholder="PROJ-2026-001" />
</div>
<div>
<label className="label"></label>
<select className="input" value={form.construction_type} onChange={(e) => set("construction_type", e.target.value)}>
{Object.entries(CONSTRUCTION_TYPE_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
</div>
<div>
<label className="label"> ()</label>
<input className="input" type="number" value={form.contract_amount} onChange={(e) => set("contract_amount", e.target.value)} />
</div>
<div>
<label className="label"></label>
<input className="input" type="date" value={form.start_date} onChange={(e) => set("start_date", e.target.value)} />
</div>
<div>
<label className="label"></label>
<input className="input" type="date" value={form.end_date} onChange={(e) => set("end_date", e.target.value)} />
</div>
<div className="col-span-2">
<label className="label"> </label>
<input className="input" value={form.location_address} onChange={(e) => set("location_address", e.target.value)} placeholder="시/군/구 위치" />
</div>
<div className="col-span-2 flex gap-2 justify-end pt-2">
<button type="button" className="btn-secondary" onClick={onCancel}></button>
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? "저장 중..." : "현장 등록"}
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,145 @@
"use client";
import { useState } from "react";
import { AppLayout } from "@/components/layout/AppLayout";
import api from "@/lib/api";
import type { RagAnswer } from "@/lib/types";
interface Message {
id: string;
type: "user" | "assistant";
content: string;
sources?: RagAnswer["sources"];
disclaimer?: string;
}
export default function RagPage() {
const [messages, setMessages] = useState<Message[]>([]);
const [question, setQuestion] = useState("");
const [loading, setLoading] = useState(false);
async function handleAsk(e: React.FormEvent) {
e.preventDefault();
if (!question.trim() || loading) return;
const userMsg: Message = {
id: Date.now().toString(),
type: "user",
content: question,
};
setMessages((prev) => [...prev, userMsg]);
setQuestion("");
setLoading(true);
try {
const resp = await api.post<RagAnswer>("/rag/ask", { question: userMsg.content, top_k: 5 });
const data = resp.data;
const aiMsg: Message = {
id: (Date.now() + 1).toString(),
type: "assistant",
content: data.answer,
sources: data.sources,
disclaimer: data.disclaimer,
};
setMessages((prev) => [...prev, aiMsg]);
} catch {
setMessages((prev) => [
...prev,
{ id: (Date.now() + 1).toString(), type: "assistant", content: "오류가 발생했습니다. 잠시 후 다시 시도해주세요." },
]);
} finally {
setLoading(false);
}
}
return (
<AppLayout>
<div className="max-w-3xl mx-auto space-y-4">
<div>
<h1>· Q&A</h1>
<p className="text-gray-500 text-sm mt-1">
, , , KCS
</p>
</div>
{/* Chat history */}
<div className="card min-h-[400px] flex flex-col">
<div className="flex-1 p-4 space-y-4 overflow-y-auto max-h-[500px]">
{messages.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<div className="text-4xl mb-3">📚</div>
<p className="font-medium text-gray-600"> · </p>
<div className="mt-4 space-y-2">
{[
"콘크리트 타설 최저기온 기준은?",
"굴착 5m 이상 흙막이 설치 기준",
"중대재해처벌법 적용 대상은?",
].map((q) => (
<button
key={q}
onClick={() => setQuestion(q)}
className="block w-full text-left px-3 py-2 rounded-lg bg-gray-50 hover:bg-brand-50 text-sm text-gray-600 border border-gray-200 transition-colors"
>
💬 {q}
</button>
))}
</div>
</div>
) : (
messages.map((msg) => (
<div key={msg.id} className={`flex ${msg.type === "user" ? "justify-end" : "justify-start"}`}>
<div className={`max-w-[80%] rounded-xl px-4 py-3 ${msg.type === "user" ? "bg-brand-500 text-white" : "bg-gray-100 text-gray-900"}`}>
<p className="text-sm leading-relaxed whitespace-pre-wrap">{msg.content}</p>
{msg.sources && msg.sources.length > 0 && (
<div className="mt-3 pt-3 border-t border-gray-200">
<p className="text-xs text-gray-500 font-medium mb-1"> </p>
<div className="space-y-1">
{msg.sources.slice(0, 3).map((s) => (
<div key={s.id} className="text-xs text-gray-600 bg-white rounded px-2 py-1 border border-gray-200">
<span className="font-medium">{s.title}</span>
<span className="text-gray-400 ml-1">({(s.relevance_score * 100).toFixed(0)}%)</span>
</div>
))}
</div>
</div>
)}
{msg.disclaimer && (
<p className="text-xs text-gray-400 mt-2"> {msg.disclaimer}</p>
)}
</div>
</div>
))
)}
{loading && (
<div className="flex justify-start">
<div className="bg-gray-100 rounded-xl px-4 py-3 text-sm text-gray-500">
...
</div>
</div>
)}
</div>
{/* Input */}
<div className="border-t border-gray-100 p-4">
<form onSubmit={handleAsk} className="flex gap-2">
<input
className="input flex-1"
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder="법규 또는 시방서에 대해 질문하세요..."
disabled={loading}
/>
<button type="submit" className="btn-primary px-5" disabled={loading || !question.trim()}>
</button>
</form>
</div>
</div>
{/* Disclaimer */}
<p className="text-xs text-gray-400 text-center">
. .
</p>
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,225 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { AppLayout } from "@/components/layout/AppLayout";
import api from "@/lib/api";
type Tab = "client-profiles" | "work-types" | "export-import";
export default function SettingsPage() {
const [activeTab, setActiveTab] = useState<Tab>("client-profiles");
const tabs: { id: Tab; label: string }[] = [
{ id: "client-profiles", label: "🏢 발주처 프로파일" },
{ id: "work-types", label: "⚙️ 공종 라이브러리" },
{ id: "export-import", label: "📦 설정 내보내기/가져오기" },
];
return (
<AppLayout>
<div className="space-y-6">
<div>
<h1></h1>
<p className="text-gray-500 text-sm mt-1">, , </p>
</div>
<div className="flex gap-1 border-b border-gray-200">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id ? "border-brand-500 text-brand-500" : "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
{tab.label}
</button>
))}
</div>
{activeTab === "client-profiles" && <ClientProfilesTab />}
{activeTab === "work-types" && <WorkTypesTab />}
{activeTab === "export-import" && <ExportImportTab />}
</div>
</AppLayout>
);
}
function ClientProfilesTab() {
const qc = useQueryClient();
const [showForm, setShowForm] = useState(false);
const { data: profiles = [] } = useQuery({
queryKey: ["client-profiles"],
queryFn: () => api.get("/settings/client-profiles").then((r) => r.data),
});
const createMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => api.post("/settings/client-profiles", data),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["client-profiles"] }); setShowForm(false); },
});
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500">{profiles.length} </span>
<button className="btn-primary" onClick={() => setShowForm(!showForm)}> </button>
</div>
{showForm && (
<div className="card p-4">
<form
onSubmit={(e) => {
e.preventDefault();
const fd = new FormData(e.target as HTMLFormElement);
createMutation.mutate({ name: fd.get("name"), report_frequency: fd.get("report_frequency") });
}}
className="flex gap-3 items-end"
>
<div className="flex-1">
<label className="label"> *</label>
<input name="name" className="input" required placeholder="LH공사, ○○시청 등" />
</div>
<div>
<label className="label"> </label>
<select name="report_frequency" className="input">
<option value="weekly"></option>
<option value="biweekly"></option>
<option value="monthly"></option>
</select>
</div>
<button type="submit" className="btn-primary"></button>
<button type="button" className="btn-secondary" onClick={() => setShowForm(false)}></button>
</form>
</div>
)}
<div className="card">
<div className="table-container">
<table className="table">
<thead>
<tr><th></th><th> </th></tr>
</thead>
<tbody>
{profiles.length === 0 ? (
<tr><td colSpan={2} className="text-center py-6 text-gray-400"> </td></tr>
) : (
profiles.map((p: { id: string; name: string; report_frequency: string }) => (
<tr key={p.id}>
<td className="font-medium">{p.name}</td>
<td>{p.report_frequency === "weekly" ? "주간" : p.report_frequency === "monthly" ? "월간" : "격주"}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
function WorkTypesTab() {
const { data: workTypes = [] } = useQuery({
queryKey: ["work-types"],
queryFn: () => api.get("/settings/work-types").then((r) => r.data),
});
return (
<div className="card">
<div className="table-container">
<table className="table">
<thead>
<tr><th></th><th></th><th></th><th> </th><th></th></tr>
</thead>
<tbody>
{workTypes.length === 0 ? (
<tr><td colSpan={5} className="text-center py-6 text-gray-400"> </td></tr>
) : (
workTypes.map((wt: { id: string; code: string; name: string; category: string; weather_constraints: Record<string, unknown>; is_system: boolean }) => (
<tr key={wt.id}>
<td className="font-mono text-xs">{wt.code}</td>
<td className="font-medium">{wt.name}</td>
<td>{wt.category}</td>
<td className="text-xs text-gray-500">
{wt.weather_constraints ? (
<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 && " 우천불가"}
</span>
) : "-"}
</td>
<td>{wt.is_system ? <span className="badge badge-blue"></span> : <span className="badge badge-gray"></span>}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}
function ExportImportTab() {
const [importing, setImporting] = useState(false);
const [importText, setImportText] = useState("");
const [importResult, setImportResult] = useState<string | null>(null);
async function handleExport() {
const resp = await api.get("/settings/export");
const blob = new Blob([JSON.stringify(resp.data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `conai-settings-${new Date().toISOString().split("T")[0]}.json`;
a.click();
URL.revokeObjectURL(url);
}
async function handleImport(e: React.FormEvent) {
e.preventDefault();
setImporting(true);
try {
const data = JSON.parse(importText);
const resp = await api.post("/settings/import", data);
setImportResult(`가져오기 완료: 발주처 ${resp.data.imported.client_profiles}개, 공종 ${resp.data.imported.work_types}`);
setImportText("");
} catch {
setImportResult("오류: JSON 형식을 확인해주세요");
} finally {
setImporting(false);
}
}
return (
<div className="space-y-4">
<div className="card p-5">
<h3 className="mb-2"> </h3>
<p className="text-sm text-gray-500 mb-4"> (, , ) JSON </p>
<button className="btn-primary" onClick={handleExport}>📥 </button>
</div>
<div className="card p-5">
<h3 className="mb-2"> </h3>
<p className="text-sm text-gray-500 mb-4"> JSON을 </p>
<form onSubmit={handleImport} className="space-y-3">
<textarea
className="input min-h-[120px] font-mono text-xs"
value={importText}
onChange={(e) => setImportText(e.target.value)}
placeholder={`{"version": "1.0", "client_profiles": [...], ...}`}
/>
{importResult && (
<p className={`text-sm px-3 py-2 rounded ${importResult.startsWith("오류") ? "bg-red-50 text-red-600" : "bg-green-50 text-green-600"}`}>
{importResult}
</p>
)}
<button type="submit" className="btn-primary" disabled={importing || !importText.trim()}>
{importing ? "가져오는 중..." : "📤 설정 가져오기"}
</button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
"use client";
import { Sidebar } from "./Sidebar";
export function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen">
<Sidebar />
<main className="flex-1 overflow-auto">
<div className="max-w-7xl mx-auto p-6">{children}</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,63 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
interface NavItem {
href: string;
label: string;
icon: string;
}
const NAV_ITEMS: NavItem[] = [
{ href: "/dashboard", label: "대시보드", icon: "🏠" },
{ href: "/projects", label: "프로젝트", icon: "🏗" },
{ href: "/rag", label: "법규 Q&A", icon: "📚" },
{ href: "/settings", label: "설정", icon: "⚙️" },
];
export function Sidebar() {
const pathname = usePathname();
return (
<aside className="w-56 min-h-screen bg-gray-900 text-white flex flex-col">
{/* Logo */}
<div className="px-4 py-5 border-b border-gray-800">
<Link href="/dashboard">
<span className="text-xl font-bold text-white">CONAI</span>
<span className="block text-xs text-gray-400 mt-0.5"> AI </span>
</Link>
</div>
{/* Navigation */}
<nav className="flex-1 p-3 space-y-1">
{NAV_ITEMS.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors",
pathname === item.href || pathname.startsWith(item.href + "/")
? "bg-brand-500 text-white"
: "text-gray-300 hover:bg-gray-800 hover:text-white"
)}
>
<span className="text-base">{item.icon}</span>
{item.label}
</Link>
))}
</nav>
{/* Footer */}
<div className="p-3 border-t border-gray-800">
<Link
href="/login"
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-400 hover:text-white hover:bg-gray-800"
>
<span>🚪</span>
</Link>
</div>
</aside>
);
}

View File

@@ -0,0 +1,18 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 minute
retry: 1,
},
},
})
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

View File

@@ -0,0 +1,45 @@
"use client";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import api from "@/lib/api";
import type { User, TokenResponse } from "@/lib/types";
interface AuthState {
user: User | null;
accessToken: string | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
setUser: (user: User) => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
accessToken: null,
login: async (email, password) => {
const formData = new FormData();
formData.append("username", email);
formData.append("password", password);
const resp = await api.post<TokenResponse>("/auth/login", formData, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
const { access_token, refresh_token, user } = resp.data;
localStorage.setItem("access_token", access_token);
localStorage.setItem("refresh_token", refresh_token);
set({ user, accessToken: access_token });
},
logout: () => {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
set({ user: null, accessToken: null });
},
setUser: (user) => set({ user }),
}),
{ name: "conai-auth", partialize: (state) => ({ user: state.user }) }
)
);
export function useAuth() {
return useAuthStore();
}

34
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,34 @@
import axios from "axios";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
export const api = axios.create({
baseURL: `${API_URL}/api/v1`,
headers: { "Content-Type": "application/json" },
});
// Request interceptor: attach JWT token
api.interceptors.request.use((config) => {
if (typeof window !== "undefined") {
const token = localStorage.getItem("access_token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
});
// Response interceptor: handle 401
api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
window.location.href = "/login";
}
return Promise.reject(error);
}
);
export default api;

193
frontend/src/lib/types.ts Normal file
View File

@@ -0,0 +1,193 @@
// Core types matching backend Pydantic schemas
export type UserRole = "admin" | "site_manager" | "supervisor" | "worker";
export interface User {
id: string;
email: string;
name: string;
role: UserRole;
phone?: string;
kakao_user_key?: string;
is_active: boolean;
created_at: string;
}
export interface TokenResponse {
access_token: string;
refresh_token: string;
token_type: string;
user: User;
}
export type ProjectStatus = "planning" | "active" | "suspended" | "completed";
export type ConstructionType = "road" | "sewer" | "water" | "bridge" | "site_work" | "other";
export interface Project {
id: string;
name: string;
code: string;
client_profile_id?: string;
construction_type: ConstructionType;
contract_amount?: number;
start_date?: string;
end_date?: string;
location_address?: string;
location_lat?: number;
location_lng?: number;
status: ProjectStatus;
owner_id: string;
created_at: string;
}
export interface WBSItem {
id: string;
project_id: string;
parent_id?: string;
code: string;
name: string;
level: number;
unit?: string;
design_qty?: number;
unit_price?: number;
sort_order: number;
children: WBSItem[];
}
export interface Task {
id: string;
project_id: string;
wbs_item_id?: string;
name: string;
planned_start?: string;
planned_end?: string;
actual_start?: string;
actual_end?: string;
progress_pct: number;
is_milestone: boolean;
is_critical: boolean;
early_start?: string;
early_finish?: string;
late_start?: string;
late_finish?: string;
total_float?: number;
sort_order: number;
created_at: string;
}
export interface GanttData {
tasks: Task[];
critical_path: string[];
project_duration_days?: number;
}
export type DailyReportStatus = "draft" | "confirmed" | "submitted";
export type InputSource = "kakao" | "web" | "api";
export interface DailyReport {
id: string;
project_id: string;
report_date: string;
weather_summary?: string;
temperature_high?: number;
temperature_low?: number;
workers_count?: Record<string, number>;
equipment_list?: Array<{ type: string; count: number; hours?: number }>;
work_content?: string;
issues?: string;
input_source: InputSource;
ai_generated: boolean;
status: DailyReportStatus;
confirmed_by?: string;
confirmed_at?: string;
pdf_s3_key?: string;
photos: Array<{ id: string; s3_key: string; caption?: string; sort_order: number }>;
created_at: string;
}
export type InspectionResult = "pass" | "fail" | "conditional_pass";
export type InspectionStatus = "draft" | "sent" | "completed";
export interface InspectionRequest {
id: string;
project_id: string;
wbs_item_id?: string;
inspection_type: string;
requested_date: string;
location_detail?: string;
checklist_items?: Array<{
item: string;
standard: string;
timing: string;
passed: boolean | null;
}>;
result?: InspectionResult;
inspector_name?: string;
notes?: string;
ai_generated: boolean;
status: InspectionStatus;
pdf_s3_key?: string;
created_at: string;
}
export type AlertSeverity = "warning" | "critical";
export interface WeatherAlert {
id: string;
project_id: string;
task_id?: string;
alert_date: string;
alert_type: string;
severity: AlertSeverity;
message: string;
is_acknowledged: boolean;
created_at: string;
}
export interface WeatherData {
id: string;
forecast_date: string;
forecast_type: string;
temperature_high?: number;
temperature_low?: number;
precipitation_mm?: number;
wind_speed_ms?: number;
weather_code?: string;
}
export interface WeatherForecastSummary {
forecast: WeatherData[];
active_alerts: WeatherAlert[];
}
export type PermitStatus = "not_started" | "submitted" | "in_review" | "approved" | "rejected";
export interface PermitItem {
id: string;
project_id: string;
permit_type: string;
authority?: string;
required: boolean;
deadline?: string;
status: PermitStatus;
submitted_date?: string;
approved_date?: string;
notes?: string;
sort_order: number;
created_at: string;
}
export interface RagSource {
id: string;
title: string;
source_type: string;
chunk_content: string;
relevance_score: number;
}
export interface RagAnswer {
question: string;
answer: string;
sources: RagSource[];
disclaimer: string;
}

59
frontend/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,59 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(dateStr: string | undefined): string {
if (!dateStr) return "-";
const d = new Date(dateStr);
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, "0")}.${String(d.getDate()).padStart(2, "0")}`;
}
export function formatCurrency(amount: number | undefined): string {
if (!amount) return "-";
return new Intl.NumberFormat("ko-KR", { style: "currency", currency: "KRW" }).format(amount);
}
export function formatProgress(pct: number): string {
return `${pct.toFixed(1)}%`;
}
export const PROJECT_STATUS_LABELS: Record<string, string> = {
planning: "계획",
active: "진행중",
suspended: "중단",
completed: "완료",
};
export const CONSTRUCTION_TYPE_LABELS: Record<string, string> = {
road: "도로공사",
sewer: "하수도공사",
water: "상수도공사",
bridge: "교량공사",
site_work: "부지조성",
other: "기타",
};
export const PERMIT_STATUS_LABELS: Record<string, string> = {
not_started: "미착수",
submitted: "제출완료",
in_review: "검토중",
approved: "승인",
rejected: "반려",
};
export const PERMIT_STATUS_COLORS: Record<string, string> = {
not_started: "bg-gray-100 text-gray-700",
submitted: "bg-blue-100 text-blue-700",
in_review: "bg-yellow-100 text-yellow-700",
approved: "bg-green-100 text-green-700",
rejected: "bg-red-100 text-red-700",
};
export const DAILY_REPORT_STATUS_LABELS: Record<string, string> = {
draft: "초안",
confirmed: "확인완료",
submitted: "제출완료",
};

View File

@@ -0,0 +1,32 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
brand: {
50: "#e8eef8",
100: "#c5d4ef",
500: "#1a4b8c",
600: "#163f77",
700: "#123362",
},
construction: {
50: "#fff3e0",
500: "#7a4a00",
},
},
fontFamily: {
sans: ["Pretendard", "Malgun Gothic", "Apple SD Gothic Neo", "sans-serif"],
},
},
},
plugins: [],
};
export default config;

23
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}