Files
sam-manage/resources/views/juil/construction-pmis.blade.php
김보곤 953cadfd99 feat: [pmis] BIM 뷰어 3D 프로토타입 구현
- Three.js 기반 3D 건물 모델 뷰어
- 기둥/보/벽/창/지붕 등 요소별 색상 구분 및 클릭 선택
- 시점 전환(투시도/정면/우측/상부/배면), 요소 토글, 와이어프레임
- PMIS 사이드바 아코디언 메뉴 + BIM 뷰어 링크 추가
2026-03-12 12:39:15 +09:00

1215 lines
65 KiB
PHP

@extends('layouts.app')
@section('title', '건설PMIS')
@section('content')
<div id="root"></div>
@endsection
@push('scripts')
<script src="https://cdn.tailwindcss.com"></script>
@include('partials.react-cdn')
<script type="text/babel">
@verbatim
const { useState, useEffect, useCallback } = React;
const CSRF_TOKEN = document.querySelector('meta[name="csrf-token"]')?.content || '';
/* ════════════════════════════════════════════════
PMIS 좌측 사이드바 + 개인정보 모달
════════════════════════════════════════════════ */
const PMIS_MENUS = [
{ icon: 'ri-building-2-line', label: 'BIM 관리', id: 'bim', children: [
{ label: 'BIM 뷰어', url: '/juil/construction-pmis/bim-viewer' },
]},
{ icon: 'ri-line-chart-line', label: '시공관리', id: 'construction' },
{ icon: 'ri-file-list-3-line', label: '품질관리', id: 'quality' },
{ icon: 'ri-shield-check-line', label: '안전관리', id: 'safety' },
{ icon: 'ri-folder-line', label: '자료실', id: 'archive' },
];
function PmisSidebar({ onOpenProfile }) {
const [profile, setProfile] = useState(null);
const [expandedMenu, setExpandedMenu] = useState(null);
useEffect(() => {
fetch('/juil/construction-pmis/profile', { headers: { 'Accept': 'application/json' } })
.then(r => r.json())
.then(data => setProfile(data.worker))
.catch(() => {});
}, []);
return (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm flex flex-col shrink-0" style={{ width: 220 }}>
{/* 사용자 프로필 영역 */}
<div className="p-4 border-b border-gray-100 text-center relative">
{/* 설정 아이콘 */}
<button
onClick={onOpenProfile}
className="absolute top-3 right-3 p-1 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition"
title="개인정보 관리"
>
<i className="ri-settings-3-line text-lg"></i>
</button>
{/* 아바타 */}
<div className="w-16 h-16 rounded-full bg-gray-200 mx-auto mb-2 flex items-center justify-center overflow-hidden">
{profile?.profile_photo_path ? (
<img src={profile.profile_photo_path} className="w-full h-full object-cover" />
) : (
<svg className="w-10 h-10 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/>
</svg>
)}
</div>
{/* 이름 */}
<div className="text-sm font-bold text-gray-800 mb-1">{profile?.name || '...'}</div>
{/* 현장 정보 */}
<div className="text-[11px] text-gray-400 bg-gray-50 rounded-lg px-2 py-1.5 leading-relaxed">
{profile?.department || '-'}
</div>
</div>
{/* 네비게이션 메뉴 */}
<div className="p-2 flex-1">
{PMIS_MENUS.map(menu => (
<div key={menu.id}>
<button
onClick={() => setExpandedMenu(expandedMenu === menu.id ? null : menu.id)}
className={`w-full flex items-center justify-between px-3 py-2.5 rounded-lg text-left transition text-sm group ${expandedMenu === menu.id ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'}`}
>
<div className="flex items-center gap-2.5">
<i className={`${menu.icon} text-base ${expandedMenu === menu.id ? 'text-blue-500' : 'text-gray-400 group-hover:text-blue-500'}`}></i>
<span className="font-medium">{menu.label}</span>
</div>
<i className={`${expandedMenu === menu.id ? 'ri-arrow-down-s-line' : 'ri-arrow-right-s-line'} text-gray-300`}></i>
</button>
{expandedMenu === menu.id && menu.children && (
<div className="ml-4 mt-0.5 mb-1">
{menu.children.map(child => (
<a key={child.url} href={child.url}
className="block px-3 py-2 text-sm text-gray-600 hover:text-blue-700 hover:bg-blue-50 rounded-lg transition">
{child.label}
</a>
))}
</div>
)}
</div>
))}
</div>
</div>
);
}
/* ─── 개인정보 관리 모달 ─── */
function ProfileModal({ isOpen, onClose }) {
const [profile, setProfile] = useState(null);
const [loading, setLoading] = useState(true);
const [editMode, setEditMode] = useState(false);
const [form, setForm] = useState({ phone: '', email: '', gender: '', position: '', company: '' });
const [saving, setSaving] = useState(false);
const loadProfile = useCallback(() => {
setLoading(true);
fetch('/juil/construction-pmis/profile', { headers: { 'Accept': 'application/json' } })
.then(r => r.json())
.then(data => {
setProfile(data.worker);
setForm({ phone: data.worker.phone || '', email: data.worker.email || '', gender: data.worker.gender || '', position: data.worker.position || '', company: data.worker.company || '' });
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
useEffect(() => {
if (isOpen) { loadProfile(); setEditMode(false); }
}, [isOpen]);
const handleSave = async () => {
setSaving(true);
try {
const res = await fetch('/juil/construction-pmis/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': CSRF_TOKEN, 'Accept': 'application/json' },
body: JSON.stringify(form),
});
const data = await res.json();
if (data.success) {
setEditMode(false);
loadProfile();
} else {
alert(data.message || '저장에 실패했습니다.');
}
} catch { alert('저장 중 오류가 발생했습니다.'); }
setSaving(false);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center" onClick={onClose}>
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm"></div>
<div
className="relative bg-white rounded-xl shadow-2xl flex flex-col"
style={{ width: '90vw', maxWidth: '900px', maxHeight: '90vh' }}
onClick={e => e.stopPropagation()}
>
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 shrink-0">
<h2 className="text-lg font-bold text-gray-800">개인정보 관리</h2>
<button onClick={onClose} className="p-2 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* 콘텐츠 */}
<div className="flex-1 overflow-auto p-6">
{loading ? (
<div className="flex items-center justify-center py-12 text-gray-400">불러오는 ...</div>
) : profile && (
<div className="flex gap-6" style={{ alignItems: 'flex-start' }}>
{/* 좌측: 아바타 + 비밀번호 변경 */}
<div className="flex flex-col items-center gap-3 shrink-0" style={{ width: 120 }}>
<div className="w-24 h-24 rounded-lg border-2 border-gray-200 bg-gray-100 flex items-center justify-center overflow-hidden">
{profile.profile_photo_path ? (
<img src={profile.profile_photo_path} className="w-full h-full object-cover" />
) : (
<svg className="w-14 h-14 text-gray-300" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/>
</svg>
)}
</div>
<button className="text-xs text-gray-500 border border-gray-300 rounded px-3 py-1 hover:bg-gray-50 transition">
비밀번호 변경
</button>
</div>
{/* 우측: 정보 테이블 */}
<div className="flex-1" style={{ minWidth: 0 }}>
<table className="w-full text-sm border-collapse">
<tbody>
<tr className="border-t border-gray-200">
<td className="bg-gray-50 px-4 py-3 font-semibold text-gray-600 whitespace-nowrap" style={{ width: 80 }}>이름</td>
<td className="px-4 py-3 text-gray-800">{profile.name}</td>
<td className="bg-gray-50 px-4 py-3 font-semibold text-gray-600 whitespace-nowrap" style={{ width: 80 }}>연락처</td>
<td className="px-4 py-3">
{editMode ? (
<input type="text" value={form.phone} onChange={e => setForm(f => ({...f, phone: e.target.value}))}
className="w-full px-2 py-1 border border-gray-300 rounded text-sm" />
) : (
<span className="text-gray-800">{profile.phone || '-'}</span>
)}
</td>
</tr>
<tr className="border-t border-gray-200">
<td className="bg-gray-50 px-4 py-3 font-semibold text-gray-600">부서</td>
<td className="px-4 py-3 text-gray-800">{profile.department}</td>
<td className="bg-gray-50 px-4 py-3 font-semibold text-gray-600">아이디</td>
<td className="px-4 py-3 text-gray-800">{profile.login_id}</td>
</tr>
<tr className="border-t border-gray-200">
<td className="bg-gray-50 px-4 py-3 font-semibold text-gray-600">직책</td>
<td className="px-4 py-3">
{editMode ? (
<input type="text" value={form.position} onChange={e => setForm(f => ({...f, position: e.target.value}))}
className="w-full px-2 py-1 border border-gray-300 rounded text-sm" />
) : (
<span className="text-gray-800">{profile.position || '-'}</span>
)}
</td>
<td className="bg-gray-50 px-4 py-3 font-semibold text-gray-600">이메일</td>
<td className="px-4 py-3">
{editMode ? (
<input type="email" value={form.email} onChange={e => setForm(f => ({...f, email: e.target.value}))}
className="w-full px-2 py-1 border border-gray-300 rounded text-sm" />
) : (
<span className="text-gray-800">{profile.email || '-'}</span>
)}
</td>
</tr>
<tr className="border-t border-gray-200">
<td className="bg-gray-50 px-4 py-3 font-semibold text-gray-600">권한</td>
<td className="px-4 py-3 text-gray-800">{profile.role_type}</td>
<td className="bg-gray-50 px-4 py-3 font-semibold text-gray-600">성별</td>
<td className="px-4 py-3">
{editMode ? (
<select value={form.gender} onChange={e => setForm(f => ({...f, gender: e.target.value}))}
className="px-2 py-1 border border-gray-300 rounded text-sm">
<option value="">선택</option>
<option value=""></option>
<option value=""></option>
</select>
) : (
<span className="text-gray-800">{profile.gender || '-'}</span>
)}
</td>
</tr>
<tr className="border-t border-b border-gray-200">
<td className="bg-gray-50 px-4 py-3 font-semibold text-gray-600">소속업체</td>
<td className="px-4 py-3" colSpan={3}>
{editMode ? (
<input type="text" value={form.company} onChange={e => setForm(f => ({...f, company: e.target.value}))}
className="px-2 py-1 border border-gray-300 rounded text-sm" style={{ width: '60%' }} />
) : (
<span className="text-gray-800">{profile.company || '-'}</span>
)}
</td>
</tr>
</tbody>
</table>
{/* 로그인 정보 */}
<div className="mt-5">
<h4 className="text-xs font-bold text-gray-500 mb-2">로그인 정보</h4>
<table className="w-full text-xs border-collapse">
<thead>
<tr className="bg-gray-50 border-y border-gray-200">
<th className="px-3 py-2 text-center text-gray-500">최근 로그인</th>
<th className="px-3 py-2 text-center text-gray-500">가입일</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-gray-100">
<td className="px-3 py-2 text-center text-gray-700">{profile.last_login_at || '-'}</td>
<td className="px-3 py-2 text-center text-gray-700">{profile.created_at || '-'}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
)}
</div>
{/* 푸터 버튼 */}
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-gray-200 shrink-0">
{editMode ? (
<>
<button
onClick={handleSave}
disabled={saving}
className="px-5 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition text-sm font-semibold disabled:opacity-50"
>
{saving ? '저장 중...' : '저장'}
</button>
<button onClick={() => setEditMode(false)} className="px-5 py-2 bg-white border border-gray-300 text-gray-600 rounded-lg hover:bg-gray-50 transition text-sm">
취소
</button>
</>
) : (
<>
<button
onClick={() => setEditMode(true)}
className="px-5 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition text-sm font-semibold"
>
수정
</button>
<button onClick={onClose} className="px-5 py-2 bg-white border border-gray-300 text-gray-600 rounded-lg hover:bg-gray-50 transition text-sm">
닫기
</button>
</>
)}
</div>
</div>
</div>
);
}
/* ─── 색상 팔레트 ─── */
const C = {
partner: { bg: '#FFF7ED', border: '#FB923C', text: '#9A3412', head: '#F97316', headText: '#fff', icon: '#EA580C' },
construct:{ bg: '#EFF6FF', border: '#60A5FA', text: '#1E40AF', head: '#3B82F6', headText: '#fff', icon: '#2563EB' },
safety: { bg: '#F0FDF4', border: '#4ADE80', text: '#166534', head: '#22C55E', headText: '#fff', icon: '#16A34A' },
arrow: '#94A3B8',
arrowHighlight: '#3B82F6',
feedback: '#F59E0B',
};
/* ─── 공통: 액터 헤더 ─── */
function ActorHeader({ label, color, icon }) {
return (
<div className="flex flex-col items-center gap-2 mb-4">
<div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ background: color.head }}>
{icon === 'partner' && (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M12 4a2.5 2.5 0 100 5 2.5 2.5 0 000-5zM6.5 7a2 2 0 100 4 2 2 0 000-4zM17.5 7a2 2 0 100 4 2 2 0 000-4zM12 10.5c-2.5 0-4.5 1.5-4.5 3v1h9v-1c0-1.5-2-3-4.5-3z" fill="#fff"/>
</svg>
)}
{icon === 'construct' && (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M12 3l-8 4.5v9L12 21l8-4.5v-9L12 3zm0 2.2L17.5 8 12 10.8 6.5 8 12 5.2zM5.5 9.1l5.5 3v6.5l-5.5-3V9.1zm7 9.5v-6.5l5.5-3v6.5l-5.5 3z" fill="#fff"/>
</svg>
)}
{icon === 'safety' && (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M12 2L4 5v6.09c0 5.05 3.41 9.76 8 10.91 4.59-1.15 8-5.86 8-10.91V5l-8-3zm-1 14.5l-3.5-3.5 1.41-1.41L11 13.67l5.09-5.09L17.5 10 11 16.5z" fill="#fff"/>
</svg>
)}
</div>
<span className="text-sm font-bold" style={{ color: color.text }}>{label}</span>
</div>
);
}
/* ─── 공통: 스텝 박스 ─── */
function StepBox({ children, color, dashed, small, className = '' }) {
return (
<div
className={`rounded-lg px-3 py-2 text-center ${className}`}
style={{
background: color.bg,
border: `${dashed ? '2px dashed' : '1.5px solid'} ${color.border}`,
color: color.text,
fontSize: small ? '11px' : '12.5px',
lineHeight: '1.5',
}}
>
{children}
</div>
);
}
/* ─── 공통: 화살표 SVG ─── */
function Arrow({ direction = 'down', color = C.arrow, label, length = 36, className = '' }) {
if (direction === 'down') {
return (
<div className={`flex flex-col items-center ${className}`}>
{label && <span style={{ fontSize: '10px', color: C.feedback, fontWeight: 600 }}>{label}</span>}
<svg width="16" height={length} viewBox={`0 0 16 ${length}`}>
<line x1="8" y1="0" x2="8" y2={length - 6} stroke={color} strokeWidth="2"/>
<polygon points={`3,${length - 8} 8,${length} 13,${length - 8}`} fill={color}/>
</svg>
</div>
);
}
if (direction === 'right') {
return (
<div className={`flex items-center gap-1 ${className}`}>
<svg width={length} height="16" viewBox={`0 0 ${length} 16`}>
<line x1="0" y1="8" x2={length - 6} y2="8" stroke={color} strokeWidth="2"/>
<polygon points={`${length - 8},3 ${length},8 ${length - 8},13`} fill={color}/>
</svg>
{label && <span style={{ fontSize: '10px', color, fontWeight: 600, whiteSpace: 'nowrap' }}>{label}</span>}
</div>
);
}
if (direction === 'left') {
return (
<div className={`flex items-center gap-1 ${className}`}>
{label && <span style={{ fontSize: '10px', color, fontWeight: 600, whiteSpace: 'nowrap' }}>{label}</span>}
<svg width={length} height="16" viewBox={`0 0 ${length} 16`}>
<line x1={length} y1="8" x2="6" y2="8" stroke={color} strokeWidth="2"/>
<polygon points={`8,3 0,8 8,13`} fill={color}/>
</svg>
</div>
);
}
return null;
}
/* ─── 커넥터: 수평 양방향 화살표 (피드백 등) ─── */
function BiArrow({ topLabel, bottomLabel, width = 80 }) {
return (
<div className="flex flex-col items-center justify-center" style={{ minWidth: width }}>
{topLabel && <span style={{ fontSize: '10px', color: C.arrowHighlight, fontWeight: 600, marginBottom: 2 }}>{topLabel}</span>}
<svg width={width} height="24" viewBox={`0 0 ${width} 24`}>
<line x1="8" y1="8" x2={width - 8} y2="8" stroke={C.arrowHighlight} strokeWidth="1.5"/>
<polygon points={`${width - 10},4 ${width - 2},8 ${width - 10},12`} fill={C.arrowHighlight}/>
<line x1={width - 8} y1="16" x2="8" y2="16" stroke={C.feedback} strokeWidth="1.5" strokeDasharray="4,3"/>
<polygon points="10,12 2,16 10,20" fill={C.feedback}/>
</svg>
{bottomLabel && <span style={{ fontSize: '10px', color: C.feedback, fontWeight: 600, marginTop: 2 }}>{bottomLabel}</span>}
</div>
);
}
/* ════════════════════════════════════════════════
Flow 1: 협력업체 최초등록
════════════════════════════════════════════════ */
function FlowRegistration() {
return (
<div className="flex gap-6 items-start justify-center" style={{ minHeight: 420 }}>
{/* 협력업체 */}
<div className="flex flex-col items-center" style={{ flex: '1 1 280px', maxWidth: 320 }}>
<ActorHeader label="협력업체" color={C.partner} icon="partner" />
<StepBox color={C.partner}>SAM 설치 로그인</StepBox>
<Arrow direction="down" />
<StepBox color={C.partner}>상호 기본정보 등록</StepBox>
<Arrow direction="down" />
<StepBox color={C.partner}>출력일보, 작업정보 양식 신청</StepBox>
<Arrow direction="down" />
<StepBox color={C.partner} dashed>
<div className="font-semibold mb-1">기준단가, 기준자재 확인</div>
</StepBox>
</div>
{/* 연결 화살표 영역 */}
<div className="flex flex-col items-center justify-center gap-3 pt-16" style={{ minWidth: 100 }}>
<div className="flex flex-col items-center">
<span style={{ fontSize: '10px', color: C.arrowHighlight, fontWeight: 600 }}>계정 생성 안내</span>
<svg width="100" height="20" viewBox="0 0 100 20">
<line x1="92" y1="10" x2="8" y2="10" stroke={C.arrowHighlight} strokeWidth="2"/>
<polygon points="10,5 0,10 10,15" fill={C.arrowHighlight}/>
</svg>
</div>
<div style={{ height: 160 }}/>
<div className="flex flex-col items-center">
<span style={{ fontSize: '10px', color: C.arrowHighlight, fontWeight: 600 }}>기준자재 등록</span>
<svg width="100" height="20" viewBox="0 0 100 20">
<line x1="8" y1="10" x2="92" y2="10" stroke={C.arrowHighlight} strokeWidth="2" strokeDasharray="5,3"/>
<polygon points="90,5 100,10 90,15" fill={C.arrowHighlight}/>
</svg>
</div>
</div>
{/* 원청사 (본사) */}
<div className="flex flex-col items-center" style={{ flex: '1 1 280px', maxWidth: 320 }}>
<ActorHeader label="원청사 (본사)" color={C.construct} icon="construct" />
<StepBox color={C.construct}>
<div className="font-semibold mb-1">협력업체 정보관리</div>
<div style={{ fontSize: 11 }}>
&bull; SAM에 등록된 업체 &rarr; 사용 권한 부여<br/>
&bull; 미등록 업체 &rarr; 정보 입력하여 신규등록
</div>
</StepBox>
<Arrow direction="down" />
<StepBox color={C.construct}>
업체 담당자 계정 생성<br/> 설치 SMS 발송
</StepBox>
<Arrow direction="down" />
<StepBox color={C.construct} dashed>
기준단가, 기준자재 등록 확인
</StepBox>
</div>
</div>
);
}
/* ════════════════════════════════════════════════
Flow 2: 일상 업무 흐름
════════════════════════════════════════════════ */
function FlowDaily() {
return (
<div className="flex gap-4 items-start justify-center" style={{ minHeight: 480 }}>
{/* 협력업체 */}
<div className="flex flex-col items-center" style={{ flex: '1 1 240px', maxWidth: 280 }}>
<ActorHeader label="협력업체" color={C.partner} icon="partner" />
<StepBox color={C.partner}>신규 투입 인원, 장비 등록</StepBox>
<Arrow direction="down" />
<StepBox color={C.partner}>출근 / 작업일보 작성</StepBox>
<Arrow direction="down" />
<StepBox color={C.partner}>
연·유안대기 매시 활용<br/>
SNS 건적 보고 진행
</StepBox>
<Arrow direction="down" />
<StepBox color={C.partner} dashed>작성된 일보 검토 요청</StepBox>
</div>
{/* 연결 */}
<div className="flex flex-col items-center gap-4 pt-16" style={{ minWidth: 70 }}>
<div style={{ height: 100 }}/>
<BiArrow topLabel="검토 요청" bottomLabel="보완 요청" width={70} />
<div style={{ height: 40 }}/>
<BiArrow topLabel="보고서" bottomLabel="" width={70} />
</div>
{/* 원청사 - 공사 */}
<div className="flex flex-col items-center" style={{ flex: '1 1 240px', maxWidth: 280 }}>
<ActorHeader label="원청사 - 공사" color={C.construct} icon="construct" />
<StepBox color={C.construct}>
작성 내용 데이터 확인
</StepBox>
<Arrow direction="down" />
<StepBox color={C.construct}>접수 내부 검토 진행</StepBox>
<Arrow direction="down" />
<StepBox color={C.construct}>공사일보 작성 내부결재 진행</StepBox>
<Arrow direction="down" />
<StepBox color={C.construct} dashed>발주처 감리단 보고 / 제출</StepBox>
</div>
{/* 연결 */}
<div className="flex flex-col items-center gap-4 pt-16" style={{ minWidth: 70 }}>
<div style={{ height: 20 }}/>
<div className="flex flex-col items-center">
<span style={{ fontSize: '10px', color: C.arrowHighlight, fontWeight: 600 }}>데이터 공유</span>
<svg width="70" height="20" viewBox="0 0 70 20">
<line x1="62" y1="10" x2="8" y2="10" stroke={C.arrowHighlight} strokeWidth="1.5" strokeDasharray="4,3"/>
<polygon points="10,5 0,10 10,15" fill={C.arrowHighlight}/>
</svg>
</div>
</div>
{/* 원청사 - 안전 */}
<div className="flex flex-col items-center" style={{ flex: '1 1 240px', maxWidth: 280 }}>
<ActorHeader label="원청사 - 안전" color={C.safety} icon="safety" />
<StepBox color={C.safety}>
신규자 교육 실시
</StepBox>
<Arrow direction="down" />
<StepBox color={C.safety}>관련 안전보건교육 자료 실시</StepBox>
</div>
</div>
);
}
/* ════════════════════════════════════════════════
Flow 3: 시정조치 및 재해예방조치
════════════════════════════════════════════════ */
function FlowCorrective() {
return (
<div className="flex flex-col gap-10">
{/* 시정조치 */}
<div>
<div className="flex items-center gap-2 mb-4 px-2">
<span className="px-2.5 py-1 rounded text-xs font-bold text-red-700 bg-red-50 border border-red-200">시정조치</span>
</div>
<div className="flex gap-4 items-center justify-center">
<div style={{ flex: '1 1 280px', maxWidth: 320 }}>
<StepBox color={C.partner}>
<div className="font-semibold">시정조치 진행 결과등록</div>
</StepBox>
</div>
<BiArrow topLabel="시정조치 요청" bottomLabel="피드백" width={120} />
<div style={{ flex: '1 1 280px', maxWidth: 320 }}>
<StepBox color={C.construct}>
<div className="font-semibold">시정조치 요청</div>
<div style={{ fontSize: 11, marginTop: 4 }}>시정조치 결과 확인</div>
</StepBox>
</div>
</div>
</div>
{/* 재해예방조치 */}
<div>
<div className="flex items-center gap-2 mb-4 px-2">
<span className="px-2.5 py-1 rounded text-xs font-bold text-amber-700 bg-amber-50 border border-amber-200">재해예방조치</span>
</div>
<div className="flex gap-4 items-center justify-center">
<div style={{ flex: '1 1 280px', maxWidth: 320 }}>
<StepBox color={C.partner}>
<div className="font-semibold">재해예방조치 진행 결과등록</div>
</StepBox>
</div>
<BiArrow topLabel="재해예방조치 요청" bottomLabel="피드백" width={120} />
<div style={{ flex: '1 1 280px', maxWidth: 320 }}>
<StepBox color={C.construct}>
<div className="font-semibold">재해예방조치 요청</div>
<div style={{ fontSize: 11, marginTop: 4 }}>재해예방조치 결과 확인</div>
</StepBox>
</div>
</div>
</div>
{/* 액터 범례 */}
<div className="flex justify-center gap-8 mt-2">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-sm" style={{ background: C.partner.border }}></div>
<span className="text-xs text-gray-500">협력업체</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-sm" style={{ background: C.construct.border }}></div>
<span className="text-xs text-gray-500">원청사 - 공사</span>
</div>
</div>
</div>
);
}
/* ════════════════════════════════════════════════
Flow 4: 안전관리업무
════════════════════════════════════════════════ */
function FlowSafety() {
return (
<div className="flex flex-col gap-10">
{/* 위험성평가 */}
<div>
<div className="flex items-center gap-2 mb-4 px-2">
<span className="px-2.5 py-1 rounded text-xs font-bold text-blue-700 bg-blue-50 border border-blue-200">위험성평가</span>
<span className="text-xs text-gray-400">* 작업시 진행</span>
</div>
<div className="flex gap-4 items-start justify-center">
{/* 협력업체 */}
<div className="flex flex-col items-center" style={{ flex: '1 1 220px', maxWidth: 260 }}>
<ActorHeader label="협력업체" color={C.partner} icon="partner" />
<StepBox color={C.partner}>위험성평가 작성</StepBox>
<Arrow direction="down" length={30} />
<StepBox color={C.partner}>
내용 검토 협의
</StepBox>
<Arrow direction="down" length={30} />
<StepBox color={C.partner} dashed>최종 내용 전달 교육 참석</StepBox>
</div>
{/* 연결 */}
<div className="flex flex-col items-center pt-20" style={{ minWidth: 60 }}>
<BiArrow topLabel="작성안" bottomLabel="검토" width={60} />
<div style={{ height: 30 }}/>
<BiArrow topLabel="확인" bottomLabel="" width={60} />
</div>
{/* 원청사 - 공사 */}
<div className="flex flex-col items-center" style={{ flex: '1 1 220px', maxWidth: 260 }}>
<ActorHeader label="원청사 - 공사" color={C.construct} icon="construct" />
<StepBox color={C.construct}>내용 검토 협의</StepBox>
<Arrow direction="down" length={30} />
<StepBox color={C.construct}>회의전달 검토</StepBox>
</div>
{/* 연결 */}
<div className="flex flex-col items-center pt-20" style={{ minWidth: 60 }}>
<BiArrow topLabel="공유" bottomLabel="" width={60} />
</div>
{/* 원청사 - 안전 */}
<div className="flex flex-col items-center" style={{ flex: '1 1 220px', maxWidth: 260 }}>
<ActorHeader label="원청사 - 안전" color={C.safety} icon="safety" />
<StepBox color={C.safety}>위험성평가 자수 관성</StepBox>
<Arrow direction="down" length={30} />
<StepBox color={C.safety}>회의전달 검토</StepBox>
<Arrow direction="down" length={30} />
<StepBox color={C.safety} dashed>반영 교육 문서관리</StepBox>
</div>
</div>
</div>
{/* 구분선 */}
<div className="border-t border-dashed border-gray-200"></div>
{/* 협의체회의 */}
<div>
<div className="flex items-center gap-2 mb-4 px-2">
<span className="px-2.5 py-1 rounded text-xs font-bold text-green-700 bg-green-50 border border-green-200">협의체회의</span>
<span className="text-xs text-gray-400">* 매월 시행</span>
</div>
<div className="flex gap-4 items-center justify-center">
<div style={{ flex: '1 1 280px', maxWidth: 320 }}>
<StepBox color={C.partner}>
<div className="font-semibold">협의체회의 참석 서명</div>
</StepBox>
</div>
<BiArrow topLabel="회의 안내" bottomLabel="참석 확인" width={120} />
<div style={{ flex: '1 1 280px', maxWidth: 320 }}>
<StepBox color={C.construct}>
<div className="font-semibold">협의체회의 등록 작성</div>
</StepBox>
</div>
</div>
</div>
</div>
);
}
/* ════════════════════════════════════════════════
대시보드 위젯
════════════════════════════════════════════════ */
/* ─── 날씨 아이콘 (WeatherService 아이콘명 매핑) ─── */
function WeatherIcon({ icon, size = 40 }) {
const s = size;
if (icon === 'sun') return (
<svg width={s} height={s} viewBox="0 0 64 64">
<circle cx="32" cy="32" r="12" fill="#FBBF24"/>
<g stroke="#FBBF24" strokeWidth="3" strokeLinecap="round">
<line x1="32" y1="6" x2="32" y2="14"/><line x1="32" y1="50" x2="32" y2="58"/>
<line x1="6" y1="32" x2="14" y2="32"/><line x1="50" y1="32" x2="58" y2="32"/>
<line x1="13.6" y1="13.6" x2="19.3" y2="19.3"/><line x1="44.7" y1="44.7" x2="50.4" y2="50.4"/>
<line x1="13.6" y1="50.4" x2="19.3" y2="44.7"/><line x1="44.7" y1="19.3" x2="50.4" y2="13.6"/>
</g>
</svg>
);
if (icon === 'cloud-sun') return (
<svg width={s} height={s} viewBox="0 0 64 64">
<circle cx="22" cy="22" r="9" fill="#FBBF24"/>
<g stroke="#FBBF24" strokeWidth="2.5" strokeLinecap="round">
<line x1="22" y1="5" x2="22" y2="10"/><line x1="22" y1="34" x2="22" y2="37"/>
<line x1="5" y1="22" x2="10" y2="22"/>
<line x1="10" y1="10" x2="13.5" y2="13.5"/><line x1="34" y1="10" x2="30.5" y2="13.5"/>
</g>
<path d="M48 44H20a10 10 0 0 1-.7-20 14 14 0 0 1 27.4 4A8 8 0 0 1 48 44z" fill="#E5E7EB" stroke="#D1D5DB" strokeWidth="1"/>
</svg>
);
if (icon === 'rain') return (
<svg width={s} height={s} viewBox="0 0 64 64">
<path d="M50 36H18a12 12 0 0 1-1-24 16 16 0 0 1 31 4 10 10 0 0 1 2 20z" fill="#D1D5DB" stroke="#9CA3AF" strokeWidth="1"/>
<g stroke="#60A5FA" strokeWidth="2.5" strokeLinecap="round">
<line x1="22" y1="40" x2="18" y2="52"/><line x1="32" y1="40" x2="28" y2="52"/><line x1="42" y1="40" x2="38" y2="52"/>
</g>
</svg>
);
if (icon === 'snow') return (
<svg width={s} height={s} viewBox="0 0 64 64">
<path d="M50 36H18a12 12 0 0 1-1-24 16 16 0 0 1 31 4 10 10 0 0 1 2 20z" fill="#D1D5DB" stroke="#9CA3AF" strokeWidth="1"/>
<circle cx="21" cy="46" r="2.5" fill="#93C5FD"/><circle cx="32" cy="44" r="2.5" fill="#93C5FD"/>
<circle cx="43" cy="46" r="2.5" fill="#93C5FD"/><circle cx="26" cy="53" r="2.5" fill="#93C5FD"/>
<circle cx="38" cy="53" r="2.5" fill="#93C5FD"/>
</svg>
);
if (icon === 'sleet') return (
<svg width={s} height={s} viewBox="0 0 64 64">
<path d="M50 36H18a12 12 0 0 1-1-24 16 16 0 0 1 31 4 10 10 0 0 1 2 20z" fill="#D1D5DB" stroke="#9CA3AF" strokeWidth="1"/>
<g stroke="#60A5FA" strokeWidth="2.5" strokeLinecap="round">
<line x1="22" y1="40" x2="19" y2="49"/><line x1="38" y1="40" x2="35" y2="49"/>
</g>
<circle cx="30" cy="47" r="2.5" fill="#93C5FD"/><circle cx="46" cy="47" r="2.5" fill="#93C5FD"/>
</svg>
);
// cloud (default)
return (
<svg width={s} height={s} viewBox="0 0 64 64">
<path d="M50 46H18a12 12 0 0 1-1-24 16 16 0 0 1 31 4 10 10 0 0 1 2 20z" fill="#D1D5DB" stroke="#9CA3AF" strokeWidth="1"/>
</svg>
);
}
/* ─── 날씨 위젯 (WeatherService API 연동) ─── */
function WeatherWidget() {
const [forecasts, setForecasts] = useState(null);
const [loading, setLoading] = useState(true);
const today = new Date();
const dateStr = `${today.getFullYear()}.${String(today.getMonth()+1).padStart(2,'0')}.${String(today.getDate()).padStart(2,'0')}`;
const dayNames = ['일','월','화','수','목','금','토'];
const dayStr = dayNames[today.getDay()];
React.useEffect(() => {
fetch('/juil/construction-pmis/weather', { headers: { 'Accept': 'application/json' } })
.then(r => r.json())
.then(data => { setForecasts(data.forecasts || []); setLoading(false); })
.catch(() => setLoading(false));
}, []);
const labels = ['오늘', '내일'];
return (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-5" style={{ flex: '0 0 320px' }}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-bold text-gray-700">현장 날씨</h3>
<span className="text-xs text-gray-400">{dateStr} ({dayStr})</span>
</div>
{loading ? (
<div className="flex items-center justify-center py-8 text-sm text-gray-400">
<svg className="animate-spin w-5 h-5 mr-2 text-gray-300" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" strokeDasharray="31.4 31.4" strokeLinecap="round"/>
</svg>
날씨 불러오는 ...
</div>
) : !forecasts || forecasts.length === 0 ? (
<div className="flex items-center justify-center py-8 text-sm text-gray-400">
날씨 정보를 불러올 없습니다
</div>
) : (
<div className="flex gap-4">
{forecasts.map((fc, idx) => (
<div key={idx} className={`flex-1 text-center rounded-xl p-3 ${idx === 0 ? 'bg-blue-50 border border-blue-100' : 'bg-gray-50 border border-gray-100'}`}>
<div className={`text-xs font-bold mb-2 ${idx === 0 ? 'text-blue-600' : 'text-gray-500'}`}>
{labels[idx] || ''}
</div>
<div className="flex justify-center mb-2">
{fc.icon ? <WeatherIcon icon={fc.icon} size={40} /> : <div style={{ width: 40, height: 40 }}/>}
</div>
{fc.weather_text && (
<div className="text-[11px] text-gray-500 mb-1.5">{fc.weather_text}</div>
)}
<div className="flex justify-center gap-2 text-xs mb-1">
<span>최고 <span className="text-red-500 font-bold">{fc.tmx !== null ? `${fc.tmx}°` : '-'}</span></span>
<span>최저 <span className="text-blue-500 font-bold">{fc.tmn !== null ? `${fc.tmn}°` : '-'}</span></span>
</div>
{fc.pop > 0 && (
<div className="text-[10px] text-blue-400">
강수 {fc.pop}%
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
/* ─── 금일 출역 현황 ─── */
function AttendanceWidget() {
const columns = ['공종', '업체', '일보', '금일인원', '안전(인원)', '안전(신규)'];
const mockData = [
{ type: '방화셔터', company: '-', report: '제출', today: 0, safetyTotal: 0, safetyNew: 0 },
];
return (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-5 flex-1" style={{ minWidth: 0 }}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-bold text-gray-700">금일 출역 현황</h3>
<span className="text-xs text-gray-400">더보기 &gt;</span>
</div>
<div className="overflow-auto">
<table className="w-full text-xs">
<thead>
<tr className="bg-blue-50">
{columns.map(col => (
<th key={col} className="px-3 py-2 text-center font-semibold text-blue-700 border-b border-blue-100 whitespace-nowrap">{col}</th>
))}
</tr>
</thead>
<tbody>
{mockData.length === 0 ? (
<tr><td colSpan={6} className="py-6 text-center text-gray-400">출역 정보가 없습니다.</td></tr>
) : mockData.map((row, i) => (
<tr key={i} className="border-b border-gray-50 hover:bg-gray-50">
<td className="px-3 py-2 text-center">{row.type}</td>
<td className="px-3 py-2 text-center">{row.company}</td>
<td className="px-3 py-2 text-center">{row.report}</td>
<td className="px-3 py-2 text-center font-semibold">{row.today}</td>
<td className="px-3 py-2 text-center text-blue-600 font-semibold">{row.safetyTotal}</td>
<td className="px-3 py-2 text-center text-orange-500 font-semibold">{row.safetyNew}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
/* ─── 업무 진행 현황 ─── */
function WorkProgressWidget() {
const [progressTab, setProgressTab] = useState('corrective');
const cards = [
{ id: 'corrective', label: '시정조치', count: 0, sub: '조치중/미', color: 'red' },
{ id: 'prevention', label: '재해예방', count: 0, sub: '조치중/미', color: 'amber' },
{ id: 'risk', label: '위험성평가', count: 0, sub: '작성중/미', color: 'blue' },
];
const tableCols = ['지시일', '지시자', '처리자', '처리일'];
return (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-bold text-gray-700">업무 진행 현황</h3>
<span className="text-xs text-gray-400">더보기 &gt;</span>
</div>
<div className="flex gap-5" style={{ alignItems: 'flex-start' }}>
{/* 카운트 카드 */}
<div className="flex flex-col gap-3 shrink-0" style={{ width: 280 }}>
{cards.map(card => (
<button
key={card.id}
onClick={() => setProgressTab(card.id)}
className={`flex items-center justify-between p-3.5 rounded-lg border transition text-left ${
progressTab === card.id
? 'border-blue-300 bg-blue-50 shadow-sm'
: 'border-gray-200 bg-white hover:bg-gray-50'
}`}
>
<div>
<div className="text-sm font-bold text-gray-700">{card.label}</div>
<div className="text-[10px] text-gray-400 mt-0.5">{card.sub}</div>
</div>
<span className={`text-2xl font-black ${
card.color === 'red' ? 'text-red-500' :
card.color === 'amber' ? 'text-amber-500' : 'text-blue-500'
}`}>{card.count}<span className="text-sm font-semibold text-gray-400"></span></span>
</button>
))}
</div>
{/* 상세 테이블 */}
<div className="flex-1" style={{ minWidth: 0 }}>
{/* 테이블 탭 */}
<div className="flex border-b border-gray-200 mb-3">
{cards.map(card => (
<button
key={card.id}
onClick={() => setProgressTab(card.id)}
className={`px-4 py-2 text-xs font-medium transition ${
progressTab === card.id
? 'text-blue-700 border-b-2 border-blue-600 -mb-px'
: 'text-gray-400 hover:text-gray-600'
}`}
>
{card.label}
</button>
))}
</div>
<table className="w-full text-xs">
<thead>
<tr className="bg-gray-50">
{tableCols.map(col => (
<th key={col} className="px-3 py-2 text-center font-semibold text-gray-500 border-b border-gray-100">{col}</th>
))}
</tr>
</thead>
<tbody>
<tr>
<td colSpan={4} className="py-8 text-center text-gray-400">
현황 정보가 없습니다.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
);
}
/* ─── 최근 접속 메뉴 (퀵메뉴) ─── */
function QuickMenuWidget() {
const menus = [
{ icon: 'ri-tools-line', label: '장비관리', color: '#6366F1' },
{ icon: 'ri-archive-line', label: '자재관리', color: '#8B5CF6' },
{ icon: 'ri-file-text-line', label: '공사일보관리', color: '#3B82F6' },
{ icon: 'ri-team-line', label: '출역정보', color: '#10B981' },
{ icon: 'ri-clipboard-line', label: '작업일보', color: '#F59E0B' },
];
return (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-4">
<div className="flex items-center gap-2 mb-3">
<svg className="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 className="text-sm font-bold text-gray-700">최근 접속 메뉴</h3>
</div>
<div className="flex flex-col gap-1.5">
{menus.map(menu => (
<button
key={menu.label}
className="flex items-center gap-2.5 px-3 py-2 rounded-lg text-left hover:bg-gray-50 transition text-xs text-gray-600 group"
>
<i className={`${menu.icon} text-sm`} style={{ color: menu.color }}></i>
<span className="group-hover:text-gray-800">{menu.label}</span>
</button>
))}
</div>
</div>
);
}
/* ─── KCC 원본 자료 참고 모달 ─── */
const KCC_IMAGES = [
{ id: 'registration', label: '협력업체 최초등록', image: '/images/juil/pmis-flow/flow-01-registration.png' },
{ id: 'daily', label: '일상 업무 흐름', image: '/images/juil/pmis-flow/flow-02-daily.png' },
{ id: 'corrective', label: '시정조치 및 재해예방조치', image: '/images/juil/pmis-flow/flow-03-corrective.png' },
{ id: 'safety', label: '안전관리업무', image: '/images/juil/pmis-flow/flow-04-safety.png' },
];
function KccRefModal({ isOpen, onClose }) {
const [activeTab, setActiveTab] = useState('registration');
if (!isOpen) return null;
const active = KCC_IMAGES.find(t => t.id === activeTab);
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center" onClick={onClose}>
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm"></div>
<div
className="relative bg-white rounded-xl shadow-2xl flex flex-col"
style={{ width: '90vw', maxWidth: '1100px', maxHeight: '90vh' }}
onClick={e => e.stopPropagation()}
>
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 shrink-0">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gray-600 flex items-center justify-center">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M12 6.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 12C2 6.5 6.5 2 12 2s10 4.5 10 10-4.5 10-10 10S2 17.5 2 12z" fill="#fff" fillOpacity="0.9"/>
<path d="M12 16v-4m0-2.5V9" stroke="#fff" strokeWidth="2" strokeLinecap="round"/>
</svg>
</div>
<div>
<h2 className="text-lg font-bold text-gray-800">KCC 자료 참고</h2>
<p className="text-xs text-gray-400">KOUP 활용 업무 FLOW 원본</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* 탭 */}
<div className="flex border-b border-gray-200 bg-gray-50 shrink-0">
{KCC_IMAGES.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex-1 px-4 py-3 text-sm font-medium transition-all ${
activeTab === tab.id
? 'text-gray-800 bg-white border-b-2 border-gray-600 -mb-px'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'
}`}
>
{tab.label}
</button>
))}
</div>
{/* 이미지 */}
<div className="flex-1 overflow-auto p-4">
{active && (
<img
src={active.image}
alt={active.label}
className="w-full h-auto rounded-lg border border-gray-200"
/>
)}
</div>
</div>
</div>
);
}
/* ─── 탭 정의 ─── */
const FLOW_TABS = [
{ id: 'registration', label: '협력업체 최초등록', component: FlowRegistration },
{ id: 'daily', label: '일상 업무 흐름', component: FlowDaily },
{ id: 'corrective', label: '시정조치 및 재해예방조치', component: FlowCorrective },
{ id: 'safety', label: '안전관리업무', component: FlowSafety },
];
/* ─── 모달 ─── */
function FlowModal({ isOpen, onClose }) {
const [activeTab, setActiveTab] = useState('registration');
if (!isOpen) return null;
const ActiveComponent = FLOW_TABS.find(t => t.id === activeTab)?.component;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm"></div>
<div
className="relative bg-white rounded-xl shadow-2xl flex flex-col"
style={{ width: '92vw', maxWidth: '1200px', maxHeight: '92vh' }}
onClick={e => e.stopPropagation()}
>
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 shrink-0">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M4 6h16M4 12h16M4 18h16" stroke="#fff" strokeWidth="2.5" strokeLinecap="round"/>
</svg>
</div>
<h2 className="text-lg font-bold text-gray-800">SAM PMIS 업무 Flow</h2>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* 탭 */}
<div className="flex border-b border-gray-200 bg-gray-50 shrink-0">
{FLOW_TABS.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex-1 px-4 py-3 text-sm font-medium transition-all ${
activeTab === tab.id
? 'text-blue-700 bg-white border-b-2 border-blue-600 -mb-px'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'
}`}
>
{tab.label}
</button>
))}
</div>
{/* 콘텐츠 */}
<div className="flex-1 overflow-auto p-6">
{ActiveComponent && <ActiveComponent />}
</div>
{/* 푸터 */}
<div className="px-6 py-3 border-t border-gray-100 bg-gray-50 rounded-b-xl shrink-0">
<p className="text-xs text-gray-400 text-center">
* 매뉴얼 메뉴에서 '업무 FLOW 보기' 클릭하시면 언제든 열람 가능합니다.
</p>
</div>
</div>
</div>
);
}
/* ─── 메인 페이지 ─── */
function ConstructionPmis() {
const [showFlowModal, setShowFlowModal] = useState(false);
const [showKccModal, setShowKccModal] = useState(false);
const [showProfileModal, setShowProfileModal] = useState(false);
return (
<div className="flex gap-5 p-5" style={{ alignItems: 'flex-start' }}>
{/* 좌측 PMIS 사이드바 */}
<PmisSidebar onOpenProfile={() => setShowProfileModal(true)} />
{/* 메인 콘텐츠 */}
<div className="flex-1 flex flex-col gap-5" style={{ minWidth: 0 }}>
{/* 상단 2컬럼: 날씨 + 금일 출역 현황 */}
<div className="flex gap-5" style={{ alignItems: 'stretch' }}>
<WeatherWidget />
<AttendanceWidget />
</div>
{/* 하단: 업무 진행 현황 */}
<WorkProgressWidget />
</div>
{/* 우측 사이드 */}
<div className="flex flex-col gap-4 shrink-0" style={{ width: 220 }}>
<QuickMenuWidget />
<button
onClick={() => setShowFlowModal(true)}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-blue-600 text-white rounded-xl hover:bg-blue-700 transition shadow-sm font-semibold text-sm"
>
업무 FLOW 보기
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
</svg>
</button>
<button
onClick={() => setShowKccModal(true)}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-white text-gray-500 border border-gray-200 rounded-xl hover:bg-gray-50 transition text-xs"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
KCC 자료 참고
</button>
</div>
{/* 모달들 */}
<FlowModal isOpen={showFlowModal} onClose={() => setShowFlowModal(false)} />
<KccRefModal isOpen={showKccModal} onClose={() => setShowKccModal(false)} />
<ProfileModal isOpen={showProfileModal} onClose={() => setShowProfileModal(false)} />
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<ConstructionPmis />);
@endverbatim
</script>
@endpush