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:
1
frontend/.env.example
Normal file
1
frontend/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||
10
frontend/Dockerfile
Normal file
10
frontend/Dockerfile
Normal 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
10
frontend/next.config.ts
Normal 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
37
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
8
frontend/postcss.config.mjs
Normal file
8
frontend/postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
123
frontend/src/app/dashboard/page.tsx
Normal file
123
frontend/src/app/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
frontend/src/app/globals.css
Normal file
79
frontend/src/app/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
22
frontend/src/app/layout.tsx
Normal file
22
frontend/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
frontend/src/app/login/page.tsx
Normal file
81
frontend/src/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
frontend/src/app/page.tsx
Normal file
5
frontend/src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
150
frontend/src/app/projects/[id]/gantt/page.tsx
Normal file
150
frontend/src/app/projects/[id]/gantt/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
143
frontend/src/app/projects/[id]/inspections/page.tsx
Normal file
143
frontend/src/app/projects/[id]/inspections/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
frontend/src/app/projects/[id]/page.tsx
Normal file
87
frontend/src/app/projects/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
153
frontend/src/app/projects/[id]/permits/page.tsx
Normal file
153
frontend/src/app/projects/[id]/permits/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
176
frontend/src/app/projects/[id]/quality/page.tsx
Normal file
176
frontend/src/app/projects/[id]/quality/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
172
frontend/src/app/projects/[id]/reports/page.tsx
Normal file
172
frontend/src/app/projects/[id]/reports/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
frontend/src/app/projects/[id]/weather/page.tsx
Normal file
115
frontend/src/app/projects/[id]/weather/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
192
frontend/src/app/projects/page.tsx
Normal file
192
frontend/src/app/projects/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
frontend/src/app/rag/page.tsx
Normal file
145
frontend/src/app/rag/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
225
frontend/src/app/settings/page.tsx
Normal file
225
frontend/src/app/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
frontend/src/components/layout/AppLayout.tsx
Normal file
13
frontend/src/components/layout/AppLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
frontend/src/components/layout/Sidebar.tsx
Normal file
63
frontend/src/components/layout/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
frontend/src/components/providers/QueryProvider.tsx
Normal file
18
frontend/src/components/providers/QueryProvider.tsx
Normal 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>;
|
||||
}
|
||||
45
frontend/src/hooks/useAuth.ts
Normal file
45
frontend/src/hooks/useAuth.ts
Normal 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
34
frontend/src/lib/api.ts
Normal 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
193
frontend/src/lib/types.ts
Normal 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
59
frontend/src/lib/utils.ts
Normal 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: "제출완료",
|
||||
};
|
||||
32
frontend/tailwind.config.ts
Normal file
32
frontend/tailwind.config.ts
Normal 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
23
frontend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user