feat: [rd] 중대재해처벌법 실무 점검 대시보드 추가

- 6개 카테고리 34개 점검항목 인터랙티브 체크리스트
- Chart.js 도넛/막대 차트 실시간 통계
- React 기반 SPA 대시보드
This commit is contained in:
김보곤
2026-03-05 21:57:00 +09:00
parent 53a851740a
commit e7e0f55a27
3 changed files with 338 additions and 0 deletions

View File

@@ -28,6 +28,18 @@ public function index(Request $request): View|\Illuminate\Http\Response
return view('rd.index', compact('dashboard', 'statuses'));
}
/**
* 중대재해처벌법 실무 점검
*/
public function safetyAudit(Request $request): View|\Illuminate\Http\Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('rd.safety-audit'));
}
return view('rd.safety-audit');
}
/**
* AI 견적 목록
*/

View File

@@ -0,0 +1,323 @@
@extends('layouts.app')
@section('title', '중대재해처벌법 실무 점검')
@section('content')
<div id="root"></div>
@endsection
@push('scripts')
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
@include('partials.react-cdn')
<script type="text/babel">
@verbatim
const { useState, useEffect, useRef, useMemo, useCallback } = React;
// --- 점검 데이터 ---
const initialAuditData = [
// Category 1: 경영방침 및 목표
{ id: "c1-1", category: "경영방침 및 목표", title: "최고경영자 명의의 안전보건 경영방침 수립", desc: "형식적인 선언을 넘어, 회사 홈페이지/게시판 등에 공표되어 모든 종사자가 인지하고 있는가?" },
{ id: "c1-2", category: "경영방침 및 목표", title: "구체적이고 측정 가능한 안전보건 목표 설정", desc: "단순 '사고 예방'이 아닌, '중대재해 0건', '위험성평가 실시율 100%' 등 정량적 목표가 설정되었는가?" },
{ id: "c1-3", category: "경영방침 및 목표", title: "안전보건 경영방침의 정기적 평가 및 개정", desc: "최소 연 1회 이상 경영방침과 목표의 달성도를 평가하고 필요한 경우 개선하여 반영하고 있는가?" },
{ id: "c1-4", category: "경영방침 및 목표", title: "전사적 자원 배분 의지 표명", desc: "안전 목표 달성을 위해 인력, 예산, 시설 등 필요한 자원을 제공하겠다는 명시적 약속이 포함되어 있는가?" },
{ id: "c1-5", category: "경영방침 및 목표", title: "협력업체(하청) 포함 방침 적용", desc: "수급인 및 파견근로자를 포함한 모든 종사자에게 동일한 안전보건 방침이 적용됨을 명시하고 있는가?" },
// Category 2: 전담 조직 및 인력
{ id: "c2-1", category: "전담 조직 및 인력", title: "안전보건 전담 조직(CSO 등) 구성", desc: "경영책임자를 보좌하여 안전보건 업무를 총괄하는 전담 조직이 실질적으로 운영되고 있는가?" },
{ id: "c2-2", category: "전담 조직 및 인력", title: "전담 조직의 실질적 권한 부여", desc: "해당 조직이 타 부서에 안전 조치를 요구하고 관철할 수 있는 권한(작업중지권 등)을 가지고 있는가?" },
{ id: "c2-3", category: "전담 조직 및 인력", title: "안전/보건관리자 법정 인원 선임", desc: "사업장 규모 및 특성에 맞는 법정 자격 요건을 갖춘 안전관리자 및 보건관리자를 선임하였는가?" },
{ id: "c2-4", category: "전담 조직 및 인력", title: "안전보건 인력의 겸직 금지 준수", desc: "선임된 전문 인력이 생산, 총무 등 다른 업무를 겸직하여 안전 업무를 소홀히 하지 않는가?" },
{ id: "c2-5", category: "전담 조직 및 인력", title: "안전보건 관리감독자 지정 및 업무 부여", desc: "현장 관리감독자에게 안전보건 업무를 명확히 부여하고, 이를 수행할 시간적 여유를 보장하는가?" },
{ id: "c2-6", category: "전담 조직 및 인력", title: "관리감독자 업무 수행 평가", desc: "관리감독자가 부여된 안전보건 임무(순회점검 등)를 충실히 수행하고 있는지 반기 1회 이상 평가하는가?" },
// Category 3: 예산 편성 및 집행
{ id: "c3-1", category: "예산 편성 및 집행", title: "안전보건 전용 예산 편성", desc: "재해 예방에 필요한 예산(시설 개선, 장비 구입, 교육비 등)이 다른 예산과 독립적으로 편성되었는가?" },
{ id: "c3-2", category: "예산 편성 및 집행", title: "위험성평가 결과에 따른 예산 반영", desc: "위험성평가 및 현장 점검에서 도출된 개선 필요 사항을 차기 예산에 즉각적으로 반영하고 있는가?" },
{ id: "c3-3", category: "예산 편성 및 집행", title: "계획 대비 예산 집행률 관리", desc: "편성된 안전 예산이 타 용도로 전용되지 않고 목적에 맞게 100% 정상 집행되고 있는가?" },
{ id: "c3-4", category: "예산 편성 및 집행", title: "예산 집행 내역 증빙 관리", desc: "예산 집행 내역(영수증, 기안서 등)이 명확히 문서화되어 보관되고 있는가? (수사 대비 핵심)" },
{ id: "c3-5", category: "예산 편성 및 집행", title: "협력업체 안전보건 지원 예산", desc: "도급을 준 하청업체의 안전보건 확보를 위해 필요한 예산(안전장비 지원 등)을 배정하였는가?" },
// Category 4: 유해·위험요인 확인 및 개선
{ id: "c4-1", category: "유해·위험요인 확인 및 개선", title: "정기 위험성평가 실시 (반기 1회 이상)", desc: "법적 기준에 따라 사업장 내 모든 작업에 대해 위험성평가를 정기적으로 실시하고 있는가?" },
{ id: "c4-2", category: "유해·위험요인 확인 및 개선", title: "수시 위험성평가 체계 작동", desc: "기계기구 도입, 작업 방법 변경 등 변경 발생 시 즉각적으로 수시 위험성평가를 실시하는가?" },
{ id: "c4-3", category: "유해·위험요인 확인 및 개선", title: "위험성평가 과정에 현장 근로자 참여", desc: "관리자 단독이 아닌, 실제 해당 작업을 수행하는 근로자가 평가 과정에 직접 참여하여 의견을 내는가?" },
{ id: "c4-4", category: "유해·위험요인 확인 및 개선", title: "아차사고(Near Miss) 발굴 및 분석", desc: "실제 사고로 이어지지 않은 '아차사고' 사례를 적극적으로 수집하고 원인을 분석하여 대책을 수립하는가?" },
{ id: "c4-5", category: "유해·위험요인 확인 및 개선", title: "허용 불가 위험에 대한 즉각 조치", desc: "위험성평가 결과 '허용 불가' 등급으로 판정된 위험요인에 대해 작업 중지 또는 즉각적인 개선 조치를 취하는가?" },
{ id: "c4-6", category: "유해·위험요인 확인 및 개선", title: "개선 대책의 효과성 검증", desc: "위험요인 개선 조치 완료 후, 해당 조치가 실제로 위험을 낮췄는지 재평가하여 검증하는가?" },
{ id: "c4-7", category: "유해·위험요인 확인 및 개선", title: "안전보건 관계 법령 의무이행 점검", desc: "산업안전보건법 등 관련 법령에서 요구하는 안전/보건 조치가 실제 이행되고 있는지 반기 1회 이상 점검하는가?" },
// Category 5: 종사자 의견 청취 및 대응
{ id: "c5-1", category: "종사자 의견 청취 및 대응", title: "안전보건 의견 청취 절차 마련", desc: "산업안전보건위원회 외에도 모바일 앱, 제안함, 익명 게시판 등 종사자가 쉽게 의견을 낼 수 있는 창구가 있는가?" },
{ id: "c5-2", category: "종사자 의견 청취 및 대응", title: "접수된 의견의 실질적 검토 및 반영", desc: "종사자가 제안한 의견을 경영책임자 등에게 보고하고, 타당한 경우 실제 개선 조치로 이어지는가?" },
{ id: "c5-3", category: "종사자 의견 청취 및 대응", title: "의견 반영 결과 피드백 제공", desc: "의견을 낸 종사자에게 검토 결과 및 조치 계획을 명확하게 회신(피드백) 해주고 있는가?" },
{ id: "c5-4", category: "종사자 의견 청취 및 대응", title: "급박한 위험 시 작업중지권 보장", desc: "종사자가 위험을 인지하고 작업을 중지했을 때, 이를 이유로 인사상 불이익을 주지 않는 규정이 실효성 있게 작동하는가?" },
{ id: "c5-5", category: "종사자 의견 청취 및 대응", title: "재난 대응 매뉴얼 수립 및 훈련", desc: "중대재해 발생 또는 발생 급박 상황에 대비한 매뉴얼이 구비되어 있고, 반기 1회 이상 모의 훈련을 실시하는가?" },
// Category 6: 도급·용역·위탁 안전 확보
{ id: "c6-1", category: "도급·용역·위탁 안전 확보", title: "수급인 선정 시 안전 역량 평가 기준 마련", desc: "하청, 용역, 위탁 업체 선정 시 최저가가 아닌 '안전보건관리 수준'을 필수 평가 항목으로 반영하는가?" },
{ id: "c6-2", category: "도급·용역·위탁 안전 확보", title: "수급인의 안전보건 조치 비용(적정 단가) 보장", desc: "수급인이 안전하게 작업할 수 있도록 적정한 공사비(용역비)와 공사 기간을 보장하고 있는가?" },
{ id: "c6-3", category: "도급·용역·위탁 안전 확보", title: "도급인-수급인 간 안전보건 협의체 운영", desc: "원청과 하청이 참여하는 안전보건 협의체를 정기적으로(월 1회 이상) 개최하여 소통하고 있는가?" },
{ id: "c6-4", category: "도급·용역·위탁 안전 확보", title: "합동 안전보건 점검 실시", desc: "도급인(원청) 주관으로 수급인(하청)과 함께 정기적인 현장 합동 안전 점검을 실시하고 있는가?" },
{ id: "c6-5", category: "도급·용역·위탁 안전 확보", title: "수급인 근로자에 대한 안전 교육 지원", desc: "하청 근로자가 유해/위험 작업에 투입되기 전 충분한 안전 교육을 받도록 원청이 지원하거나 확인하는가?" },
{ id: "c6-6", category: "도급·용역·위탁 안전 확보", title: "불법 파견 및 편법 고용 방지 체계", desc: "실질적 지휘·명령을 하면서도 형식적 도급계약을 맺어 안전 책임을 회피하는 사례가 없는지 점검하는가?" },
];
const CATEGORY_COLORS = ['#3B82F6','#8B5CF6','#F59E0B','#EF4444','#10B981','#EC4899'];
// --- 토글 버튼 ---
function StatusToggle({ status, onChange }) {
const opts = [
{ value: 'pass', label: '양호', active: 'bg-green-100 text-green-700 shadow-sm', inactive: 'text-slate-400 hover:text-slate-600 hover:bg-slate-100' },
{ value: 'fail', label: '미흡', active: 'bg-red-100 text-red-700 shadow-sm', inactive: 'text-slate-400 hover:text-slate-600 hover:bg-slate-100' },
{ value: 'pending', label: '대기', active: 'bg-slate-300 text-slate-700 shadow-sm', inactive: 'text-slate-400 hover:text-slate-600 hover:bg-slate-100' },
];
return (
<div style={{ display: 'flex', gap: '2px', border: '1px solid #E2E8F0', borderRadius: '8px', padding: '3px', backgroundColor: '#F8FAFC', flexShrink: 0 }}>
{opts.map(o => (
<button key={o.value} onClick={() => onChange(o.value)}
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-all ${status === o.value ? o.active : o.inactive}`}
>{o.label}</button>
))}
</div>
);
}
// --- 체크리스트 항목 ---
function CheckItem({ item, index, status, onStatusChange }) {
return (
<div style={{ padding: '14px 18px', display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '16px', borderBottom: '1px solid #F1F5F9' }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
<span style={{ fontSize: '11px', fontWeight: 600, color: '#94A3B8' }}>항목 {index + 1}</span>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#1E293B' }}>{item.title}</span>
</div>
<p style={{ fontSize: '13px', color: '#64748B', lineHeight: 1.6 }}>{item.desc}</p>
</div>
<StatusToggle status={status} onChange={onStatusChange} />
</div>
);
}
// --- 카테고리 섹션 ---
function CategorySection({ name, index, items, statuses, onStatusChange }) {
const passed = items.filter(i => statuses[i.id] === 'pass').length;
const total = items.length;
const allDone = passed === total;
return (
<div style={{ backgroundColor: '#FFF', borderRadius: '12px', border: '1px solid #E2E8F0', overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.05)' }}>
<div style={{ backgroundColor: '#F8FAFC', borderBottom: '1px solid #E2E8F0', padding: '14px 18px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<div style={{
width: '30px', height: '30px', borderRadius: '50%',
backgroundColor: CATEGORY_COLORS[index] + '20', color: CATEGORY_COLORS[index],
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '13px', fontWeight: 700,
}}>{index + 1}</div>
<span style={{ fontSize: '16px', fontWeight: 700, color: '#1E293B' }}>{name}</span>
</div>
<span style={{ fontSize: '13px', fontWeight: allDone ? 700 : 500, color: allDone ? '#16A34A' : '#64748B' }}>
{passed} / {total} 완료
</span>
</div>
<div>
{items.map((item, idx) => (
<CheckItem key={item.id} item={item} index={idx} status={statuses[item.id]}
onStatusChange={(val) => onStatusChange(item.id, val)} />
))}
</div>
</div>
);
}
// --- 도넛 차트 ---
function DonutChart({ passed, failed, pending, total }) {
const canvasRef = useRef(null);
const chartRef = useRef(null);
useEffect(() => {
if (chartRef.current) chartRef.current.destroy();
chartRef.current = new Chart(canvasRef.current, {
type: 'doughnut',
data: {
labels: ['양호', '미흡', '대기'],
datasets: [{ data: [passed, failed, pending], backgroundColor: ['#22c55e','#ef4444','#e2e8f0'], borderWidth: 0, hoverOffset: 4 }]
},
options: {
responsive: true, maintainAspectRatio: false, cutout: '70%',
plugins: {
legend: { position: 'bottom', labels: { padding: 16, usePointStyle: true, boxWidth: 8, font: { size: 12 } } },
tooltip: { callbacks: { label: (ctx) => ` ${ctx.label}: ${ctx.raw}항목 (${Math.round((ctx.raw/total)*100)}%)` } }
}
}
});
return () => { if (chartRef.current) chartRef.current.destroy(); };
}, [passed, failed, pending]);
return <div style={{ position: 'relative', height: '240px' }}><canvas ref={canvasRef}></canvas></div>;
}
// --- 바 차트 ---
function BarChart({ categories, categoryRates }) {
const canvasRef = useRef(null);
const chartRef = useRef(null);
useEffect(() => {
if (chartRef.current) chartRef.current.destroy();
const labels = categories.map(c => c.length > 10 ? c.substring(0, 10) + '...' : c);
chartRef.current = new Chart(canvasRef.current, {
type: 'bar',
data: {
labels,
datasets: [{ label: '달성률 (%)', data: categoryRates, backgroundColor: CATEGORY_COLORS, borderRadius: 4, barPercentage: 0.6 }]
},
options: {
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
scales: {
x: { beginAtZero: true, max: 100, grid: { color: '#f1f5f9' }, ticks: { callback: v => v + '%' } },
y: { grid: { display: false }, ticks: { font: { size: 11 } } }
},
plugins: { legend: { display: false }, tooltip: { callbacks: { label: ctx => ` 달성률: ${ctx.raw}%` } } }
}
});
return () => { if (chartRef.current) chartRef.current.destroy(); };
}, [categoryRates]);
return <div style={{ position: 'relative', height: '260px' }}><canvas ref={canvasRef}></canvas></div>;
}
// --- 메인 앱 ---
function App() {
const [statuses, setStatuses] = useState(() => {
const init = {};
initialAuditData.forEach(i => { init[i.id] = 'pending'; });
return init;
});
const categories = useMemo(() => [...new Set(initialAuditData.map(i => i.category))], []);
const total = initialAuditData.length;
const stats = useMemo(() => {
const passed = Object.values(statuses).filter(s => s === 'pass').length;
const failed = Object.values(statuses).filter(s => s === 'fail').length;
const pending = Object.values(statuses).filter(s => s === 'pending').length;
const percent = Math.round((passed / total) * 100) || 0;
const catRates = categories.map(cat => {
const items = initialAuditData.filter(i => i.category === cat);
const p = items.filter(i => statuses[i.id] === 'pass').length;
return Math.round((p / items.length) * 100);
});
return { passed, failed, pending, percent, catRates };
}, [statuses]);
const handleChange = useCallback((id, val) => {
setStatuses(prev => ({ ...prev, [id]: val }));
}, []);
const setAll = useCallback((val) => {
setStatuses(prev => {
const next = { ...prev };
Object.keys(next).forEach(k => { next[k] = val; });
return next;
});
}, []);
const statusLabel = stats.percent === 100 ? { text: '준비 완료', cls: 'bg-green-100 text-green-700' }
: stats.percent > 70 ? { text: '양호 수준', cls: 'bg-blue-100 text-blue-700' }
: stats.percent > 30 ? { text: '개선 필요', cls: 'bg-yellow-100 text-yellow-700' }
: { text: '위험 수준', cls: 'bg-red-100 text-red-700' };
return (
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 64px)', overflow: 'hidden' }}>
{/* 헤더 */}
<div style={{ padding: '16px 24px', borderBottom: '1px solid #E2E8F0', backgroundColor: '#FFF', flexShrink: 0, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h1 style={{ fontSize: '20px', fontWeight: 800, color: '#0F172A', margin: 0 }}>중대재해처벌법 실무 점검</h1>
<p style={{ fontSize: '13px', color: '#64748B', margin: '2px 0 0' }}>서류 중심이 아닌 "실제 작동 여부" 검증하는 6 영역 심층 진단 도구</p>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '11px', fontWeight: 600, color: '#94A3B8', letterSpacing: '0.05em', marginBottom: '2px' }}> 점검 진행률</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '4px', justifyContent: 'flex-end' }}>
<span style={{ fontSize: '28px', fontWeight: 800, color: '#2563EB' }}>{stats.percent}</span>
<span style={{ fontSize: '16px', fontWeight: 500, color: '#94A3B8' }}>%</span>
</div>
</div>
</div>
{/* 2컬럼 레이아웃 */}
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* 왼쪽: 대시보드 */}
<div style={{ width: '340px', flexShrink: 0, borderRight: '1px solid #E2E8F0', backgroundColor: '#F8FAFC', overflowY: 'auto', padding: '20px', display: 'flex', flexDirection: 'column', gap: '16px' }}>
{/* 진단 개요 */}
<div style={{ backgroundColor: '#FFF', borderRadius: '12px', border: '1px solid #E2E8F0', padding: '18px' }}>
<h2 style={{ fontSize: '16px', fontWeight: 700, color: '#1E293B', marginBottom: '8px' }}>진단 개요</h2>
<p style={{ fontSize: '13px', color: '#64748B', lineHeight: 1.6, marginBottom: '12px' }}>
검찰 조사 핵심 쟁점이 되는 {total} 항목을 6 카테고리로 재구성했습니다.
실제 현장에서 제도가 <strong>'작동'</strong>하고 있는지 평가하십시오.
</p>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px' }}>
<span style={{ fontSize: '13px', fontWeight: 600, color: '#475569' }}>이행 완료도</span>
<span className={`text-xs font-bold px-2.5 py-1 rounded-full ${statusLabel.cls}`}>{statusLabel.text}</span>
</div>
<DonutChart passed={stats.passed} failed={stats.failed} pending={stats.pending} total={total} />
</div>
{/* 영역별 현황 */}
<div style={{ backgroundColor: '#FFF', borderRadius: '12px', border: '1px solid #E2E8F0', padding: '18px', flex: 1 }}>
<h2 style={{ fontSize: '16px', fontWeight: 700, color: '#1E293B', marginBottom: '12px' }}>영역별 이행 현황</h2>
<BarChart categories={categories} categoryRates={stats.catRates} />
</div>
<div style={{ fontSize: '11px', color: '#94A3B8', borderTop: '1px solid #E2E8F0', paddingTop: '12px' }}>
* 대시보드는 자가 진단용이며, 법적 책임을 면제하지 않습니다.
</div>
</div>
{/* 오른쪽: 체크리스트 */}
<div style={{ flex: 1, overflowY: 'auto', padding: '24px', backgroundColor: '#FFF' }}>
<div style={{ maxWidth: '800px', margin: '0 auto' }}>
<div style={{ marginBottom: '24px' }}>
<h2 style={{ fontSize: '20px', fontWeight: 700, color: '#0F172A', marginBottom: '8px' }}>심층 점검 체크리스트</h2>
<p style={{ fontSize: '13px', color: '#64748B', lineHeight: 1.6 }}>
항목에 대해 현재 기업의 실태를 객관적으로 평가해 주십시오.
<strong> '양호'</strong> 단순 문서 구비가 아닌 실질적 이행과 증빙이 가능한 상태를 의미합니다.
</p>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px', marginBottom: '16px' }}>
<button onClick={() => setAll('pass')}
className="text-xs px-3 py-1.5 rounded bg-green-50 text-green-700 hover:bg-green-100 border border-green-200 transition-colors">
전체 양호 처리
</button>
<button onClick={() => setAll('pending')}
className="text-xs px-3 py-1.5 rounded bg-slate-100 text-slate-600 hover:bg-slate-200 border border-slate-200 transition-colors">
초기화
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{categories.map((cat, idx) => (
<CategorySection
key={cat}
name={cat}
index={idx}
items={initialAuditData.filter(i => i.category === cat)}
statuses={statuses}
onStatusChange={handleChange}
/>
))}
</div>
</div>
</div>
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
@endverbatim
</script>
@endpush

View File

@@ -381,6 +381,9 @@
Route::get('/ai-quotation/{id}/edit', [RdController::class, 'editQuotation'])->name('ai-quotation.edit');
Route::get('/ai-quotation/{id}', [RdController::class, 'showQuotation'])->name('ai-quotation.show');
// 중대재해처벌법 실무 점검
Route::get('/safety-audit', [RdController::class, 'safetyAudit'])->name('safety-audit');
// CM송 제작
Route::prefix('cm-song')->name('cm-song.')->group(function () {
Route::get('/', [CmSongController::class, 'index'])->name('index');