- Three.js 기반 3D 건물 모델 뷰어 - 기둥/보/벽/창/지붕 등 요소별 색상 구분 및 클릭 선택 - 시점 전환(투시도/정면/우측/상부/배면), 요소 토글, 와이어프레임 - PMIS 사이드바 아코디언 메뉴 + BIM 뷰어 링크 추가
1215 lines
65 KiB
PHP
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 }}>
|
|
• SAM에 등록된 업체 → 사용 권한 부여<br/>
|
|
• 미등록 업체 → 정보 입력하여 신규등록
|
|
</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">더보기 ></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">더보기 ></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
|