feat: TK 안전관리 플랫폼 초기 구현
위험성평가, 안전 RAG Q&A, 안전점검 체크리스트를 통합한 안전관리자 전용 웹 플랫폼 전체 구현. - Next.js 15 (App Router) + TypeScript + Tailwind + shadcn/ui - Drizzle ORM + PostgreSQL 16 (12개 테이블) - 위험성평가 CRUD + 5x5 위험성 매트릭스 + 인쇄 내보내기 - 체크리스트 템플릿/점검/NCR 추적 - RAG 문서 파이프라인 (Tika + bge-m3 + Qdrant) - SSE 스트리밍 RAG 채팅 (qwen3.5:35b-a3b) - AI 어시스트 (위험요인 추천, 감소대책, 점검항목 생성) - 대시보드 통계/차트 (recharts) - 단일 사용자 인증 (HMAC 쿠키 세션) - 다크모드 지원 - Docker 멀티스테이지 빌드 (standalone) - 프로젝트 가이드 문서 (docs/) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
134
src/components/risk-assessment/ai-hazard-panel.tsx
Normal file
134
src/components/risk-assessment/ai-hazard-panel.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Sparkles, Plus, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { getRiskGrade } from "@/lib/constants";
|
||||
import { addHazard } from "@/actions/risk-assessment";
|
||||
|
||||
interface SuggestedHazard {
|
||||
category: string;
|
||||
hazard: string;
|
||||
consequence: string;
|
||||
severity: number;
|
||||
likelihood: number;
|
||||
existingControls: string;
|
||||
}
|
||||
|
||||
export function AiHazardPanel({ assessmentId }: { assessmentId: string }) {
|
||||
const router = useRouter();
|
||||
const [activity, setActivity] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [suggestions, setSuggestions] = useState<SuggestedHazard[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleSuggest() {
|
||||
if (!activity.trim()) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setSuggestions([]);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/ai/suggest-hazards", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ activity }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
setSuggestions(data.hazards || []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "AI 오류");
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function handleAdd(hazard: SuggestedHazard) {
|
||||
await addHazard(assessmentId, {
|
||||
category: hazard.category,
|
||||
activity: activity,
|
||||
hazard: hazard.hazard,
|
||||
consequence: hazard.consequence,
|
||||
severity: hazard.severity,
|
||||
likelihood: hazard.likelihood,
|
||||
existingControls: hazard.existingControls,
|
||||
});
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
AI 추천
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[500px] sm:max-w-[500px] overflow-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>AI 유해위험요인 추천</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={activity}
|
||||
onChange={(e) => setActivity(e.target.value)}
|
||||
placeholder="작업/공정을 설명하세요. 예: 5층 건물 외벽 페인트 작업, 비계 사용"
|
||||
rows={3}
|
||||
/>
|
||||
<Button onClick={handleSuggest} disabled={loading || !activity.trim()}>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{loading ? "분석 중..." : "위험요인 분석"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
{suggestions.map((h, i) => {
|
||||
const risk = h.severity * h.likelihood;
|
||||
const grade = getRiskGrade(risk);
|
||||
return (
|
||||
<Card key={i}>
|
||||
<CardContent className="py-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="outline">{h.category}</Badge>
|
||||
<Badge className={`${grade.color} ${grade.textColor} border`} variant="outline">
|
||||
{risk} ({grade.label})
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm font-medium">{h.hazard}</p>
|
||||
{h.consequence && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
예상 재해: {h.consequence}
|
||||
</p>
|
||||
)}
|
||||
{h.existingControls && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
기존 조치: {h.existingControls}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-between items-center text-xs text-muted-foreground">
|
||||
<span>S={h.severity} x L={h.likelihood}</span>
|
||||
<Button size="sm" variant="outline" onClick={() => handleAdd(h)}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user