위험성평가, 안전 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>
135 lines
4.7 KiB
TypeScript
135 lines
4.7 KiB
TypeScript
"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>
|
|
);
|
|
}
|