feat: [juil] 업무 Workflow 플로우차트 메뉴 추가

- 주일기업 기획 하위 '업무 Workflow' 메뉴 추가
- 11단계 업무처리과정 인터랙티브 플로우차트 구현
- 각 단계 클릭 시 상세정보(담당부서, 필요서류, SAM 연동) 표시
This commit is contained in:
김보곤
2026-03-05 19:41:26 +09:00
parent 21f930a52f
commit 561883676e
3 changed files with 580 additions and 0 deletions

View File

@@ -26,4 +26,13 @@ public function project(Request $request): View|Response
return view('juil.project');
}
public function workflow(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('juil.workflow'));
}
return view('juil.workflow');
}
}

View File

@@ -0,0 +1,570 @@
@extends('layouts.app')
@section('title', '업무 Workflow')
@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, useRef, useEffect } = React;
// --- 업무 프로세스 데이터 ---
const processes = [
{
id: 1,
phase: 'sales',
name: '영업/수주',
icon: '📋',
dept: '영업팀',
color: '#3B82F6',
bgColor: '#EFF6FF',
description: '건설사/시행사로부터 프로젝트 정보 수집 및 수주 활동',
details: [
'건설사 입찰공고 모니터링',
'현장 실측 및 요구사항 파악',
'고객사 미팅 및 관계 관리',
],
documents: ['입찰공고문', '현장조사서', '고객 요구사항서'],
samLink: null,
samMenu: null,
},
{
id: 2,
phase: 'estimate',
name: '견적서 작성',
icon: '🧮',
dept: '견적팀',
color: '#8B5CF6',
bgColor: '#F5F3FF',
description: '자재/인건비/경비를 산출하여 견적서 작성',
details: [
'자재 단가 산출 (블라인드, 스크린, 셔터)',
'인건비/시공비 산정',
'이윤율 적용 및 최종 견적가 확정',
],
documents: ['견적서', '단가산출서', '자재목록'],
samLink: '/juil/estimate',
samMenu: '견적/입찰/공사관리',
},
{
id: 3,
phase: 'bid',
name: '입찰 참여',
icon: '🏷️',
dept: '영업팀',
color: '#EC4899',
bgColor: '#FDF2F8',
description: '견적서 기반으로 입찰에 참여',
details: [
'입찰서류 준비 및 제출',
'기술제안서 작성',
'가격 협상',
],
documents: ['입찰서', '기술제안서', '사업자등록증 사본'],
samLink: '/juil/estimate',
samMenu: '견적/입찰/공사관리',
branch: true,
},
{
id: 4,
phase: 'contract',
name: '낙찰/계약',
icon: '📝',
dept: '영업팀',
color: '#10B981',
bgColor: '#ECFDF5',
description: '낙찰 후 공사 계약 체결',
details: [
'계약서 작성 및 검토',
'계약금 수령',
'프로젝트 등록 및 담당자 배정',
],
documents: ['공사계약서', '착공계', '공정표'],
samLink: '/juil/project',
samMenu: '프로젝트관리/기성청구',
},
{
id: 5,
phase: 'order',
name: '자재 발주',
icon: '📦',
dept: '구매팀',
color: '#F59E0B',
bgColor: '#FFFBEB',
description: '시공에 필요한 자재를 발주',
details: [
'BOM(자재명세서) 기반 발주량 산정',
'협력업체 발주서 발행',
'납기일 관리',
],
documents: ['발주서', 'BOM', '납품요청서'],
samLink: null,
samMenu: null,
},
{
id: 6,
phase: 'receive',
name: '자재 입고/검수',
icon: '🔍',
dept: '자재팀',
color: '#06B6D4',
bgColor: '#ECFEFF',
description: '입고된 자재의 수량/품질 검수',
details: [
'입고 수량 확인',
'품질 검사 (규격, 색상, 하자)',
'불량 자재 반품 처리',
],
documents: ['입고검수서', '거래명세서', '반품요청서'],
samLink: null,
samMenu: null,
},
{
id: 7,
phase: 'construct',
name: '현장 시공',
icon: '🔧',
dept: '시공팀',
color: '#EF4444',
bgColor: '#FEF2F2',
description: '현장에서 블라인드/스크린/셔터 설치 시공',
details: [
'시공 일정 조율 (건설사와 협의)',
'현장 설치 작업',
'시공 사진 촬영 및 기록',
],
documents: ['시공계획서', '작업일보', '시공사진'],
samLink: '/juil/construction-photos',
samMenu: '공사현장 사진대지',
},
{
id: 8,
phase: 'inspect',
name: '시공 검수',
icon: '✅',
dept: '현장팀',
color: '#14B8A6',
bgColor: '#F0FDFA',
description: '시공 완료 후 품질 검수 및 하자 보수',
details: [
'건설사 합동 검수',
'하자 사항 보수',
'검수 완료 확인서 수령',
],
documents: ['검수확인서', '하자보수보고서', '준공사진'],
samLink: '/juil/construction-photos',
samMenu: '공사현장 사진대지',
},
{
id: 9,
phase: 'billing',
name: '기성 청구',
icon: '💰',
dept: '경리팀',
color: '#7C3AED',
bgColor: '#F5F3FF',
description: '시공 진행률에 따른 기성금 청구',
details: [
'기성 내역서 작성',
'세금계산서 발행',
'기성금 청구서 제출',
],
documents: ['기성내역서', '세금계산서', '기성청구서'],
samLink: '/juil/project',
samMenu: '프로젝트관리/기성청구',
},
{
id: 10,
phase: 'payment',
name: '입금 확인',
icon: '🏦',
dept: '경리팀',
color: '#059669',
bgColor: '#ECFDF5',
description: '기성금 입금 확인 및 매출 처리',
details: [
'입금 내역 대사',
'미수금 관리',
'매출 장부 기록',
],
documents: ['입금확인서', '매출대장'],
samLink: null,
samMenu: null,
},
{
id: 11,
phase: 'as',
name: 'A/S 관리',
icon: '🛠️',
dept: '시공팀',
color: '#6B7280',
bgColor: '#F9FAFB',
description: '하자보수기간 내 A/S 대응',
details: [
'A/S 접수 및 처리',
'하자보수 비용 관리',
'보증기간 만료 관리',
],
documents: ['A/S 접수대장', '하자보수 보고서'],
samLink: null,
samMenu: null,
},
];
// --- 화살표 컴포넌트 ---
function Arrow({ direction = 'right', branch = false }) {
if (direction === 'down') {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0' }}>
<div style={{
width: 0, height: 0,
borderLeft: '8px solid transparent',
borderRight: '8px solid transparent',
borderTop: '10px solid #9CA3AF',
}}></div>
</div>
);
}
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
minWidth: '40px', position: 'relative',
}}>
<div style={{
width: '100%', height: '2px', backgroundColor: '#9CA3AF',
}}></div>
<div style={{
position: 'absolute', right: 0,
width: 0, height: 0,
borderTop: '6px solid transparent',
borderBottom: '6px solid transparent',
borderLeft: '8px solid #9CA3AF',
}}></div>
</div>
);
}
// --- 프로세스 노드 컴포넌트 ---
function ProcessNode({ process, isActive, onClick }) {
return (
<div
onClick={() => onClick(process)}
style={{
minWidth: '140px',
maxWidth: '160px',
padding: '12px',
borderRadius: '12px',
border: `2px solid ${isActive ? process.color : '#E5E7EB'}`,
backgroundColor: isActive ? process.bgColor : '#FFFFFF',
cursor: 'pointer',
transition: 'all 0.2s ease',
boxShadow: isActive ? `0 4px 12px ${process.color}33` : '0 1px 3px rgba(0,0,0,0.1)',
textAlign: 'center',
position: 'relative',
}}
>
<div style={{ fontSize: '24px', marginBottom: '6px' }}>{process.icon}</div>
<div style={{
fontSize: '13px', fontWeight: 700, color: '#1F2937',
marginBottom: '4px', whiteSpace: 'nowrap',
}}>{process.name}</div>
<div style={{
fontSize: '11px',
color: '#FFFFFF',
backgroundColor: process.color,
borderRadius: '10px',
padding: '1px 8px',
display: 'inline-block',
}}>{process.dept}</div>
{process.branch && (
<div style={{
position: 'absolute', top: '-8px', right: '-8px',
width: '20px', height: '20px', borderRadius: '50%',
backgroundColor: '#FEF3C7', border: '2px solid #F59E0B',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '10px',
}}></div>
)}
</div>
);
}
// --- 상세 패널 컴포넌트 ---
function DetailPanel({ process, onClose }) {
if (!process) return null;
return (
<div style={{
backgroundColor: '#FFFFFF',
borderRadius: '16px',
border: `2px solid ${process.color}`,
padding: '24px',
boxShadow: '0 10px 25px rgba(0,0,0,0.1)',
position: 'relative',
}}>
<button
onClick={onClose}
style={{
position: 'absolute', top: '12px', right: '12px',
width: '28px', height: '28px', borderRadius: '50%',
border: '1px solid #E5E7EB', backgroundColor: '#F9FAFB',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '14px', color: '#6B7280',
}}
>&times;</button>
{/* 헤더 */}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' }}>
<div style={{
width: '48px', height: '48px', borderRadius: '12px',
backgroundColor: process.bgColor, display: 'flex',
alignItems: 'center', justifyContent: 'center', fontSize: '24px',
}}>{process.icon}</div>
<div>
<div style={{ fontSize: '18px', fontWeight: 700, color: '#1F2937' }}>
<span style={{
display: 'inline-block', width: '24px', height: '24px',
borderRadius: '50%', backgroundColor: process.color, color: '#FFF',
fontSize: '12px', textAlign: 'center', lineHeight: '24px',
marginRight: '8px',
}}>{process.id}</span>
{process.name}
</div>
<div style={{ fontSize: '13px', color: '#6B7280', marginTop: '2px' }}>
담당: <span style={{ color: process.color, fontWeight: 600 }}>{process.dept}</span>
</div>
</div>
</div>
{/* 설명 */}
<div style={{
fontSize: '14px', color: '#374151', marginBottom: '16px',
padding: '12px', backgroundColor: process.bgColor, borderRadius: '8px',
borderLeft: `4px solid ${process.color}`,
}}>
{process.description}
</div>
{/* 3컬럼 그리드 */}
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
{/* 상세 업무 */}
<div style={{ flex: '1 1 200px' }}>
<div style={{
fontSize: '13px', fontWeight: 700, color: '#374151',
marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '4px',
}}>
<span style={{ color: process.color }}></span> 상세 업무
</div>
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{process.details.map((d, i) => (
<li key={i} style={{
fontSize: '12px', color: '#4B5563', padding: '4px 0',
borderBottom: '1px solid #F3F4F6',
paddingLeft: '12px', position: 'relative',
}}>
<span style={{ position: 'absolute', left: 0, color: '#9CA3AF' }}>-</span>
{d}
</li>
))}
</ul>
</div>
{/* 필요 서류 */}
<div style={{ flex: '1 1 200px' }}>
<div style={{
fontSize: '13px', fontWeight: 700, color: '#374151',
marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '4px',
}}>
<span style={{ color: process.color }}></span> 필요 서류
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
{process.documents.map((doc, i) => (
<span key={i} style={{
fontSize: '11px', padding: '3px 10px',
backgroundColor: '#F3F4F6', borderRadius: '12px',
color: '#374151', whiteSpace: 'nowrap',
}}>{doc}</span>
))}
</div>
</div>
{/* SAM 연동 */}
<div style={{ flex: '1 1 150px' }}>
<div style={{
fontSize: '13px', fontWeight: 700, color: '#374151',
marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '4px',
}}>
<span style={{ color: process.color }}></span> SAM 연동
</div>
{process.samLink ? (
<a
href={process.samLink}
style={{
display: 'inline-flex', alignItems: 'center', gap: '6px',
fontSize: '12px', color: process.color, fontWeight: 600,
padding: '6px 12px', borderRadius: '8px',
backgroundColor: process.bgColor,
border: `1px solid ${process.color}40`,
textDecoration: 'none',
}}
>
{process.samMenu}
</a>
) : (
<span style={{
fontSize: '12px', color: '#9CA3AF', fontStyle: 'italic',
}}>추후 연동 예정</span>
)}
</div>
</div>
</div>
);
}
// --- 입찰 분기 표시 ---
function BranchInfo() {
return (
<div style={{
display: 'flex', gap: '8px', justifyContent: 'center',
margin: '8px 0', fontSize: '11px',
}}>
<span style={{
padding: '2px 10px', borderRadius: '10px',
backgroundColor: '#DCFCE7', color: '#166534', fontWeight: 600,
}}>낙찰 계약 진행</span>
<span style={{
padding: '2px 10px', borderRadius: '10px',
backgroundColor: '#FEE2E2', color: '#991B1B', fontWeight: 600,
}}>유찰 재입찰/종료</span>
</div>
);
}
// --- 메인 앱 ---
function App() {
const [selected, setSelected] = useState(null);
const topRow = processes.slice(0, 6); // 영업~입고검수
const bottomRow = processes.slice(6); // 시공~A/S
return (
<div style={{ padding: '20px' }}>
{/* 헤더 */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginBottom: '24px',
}}>
<div>
<h1 style={{
fontSize: '22px', fontWeight: 800, color: '#111827', margin: 0,
}}>업무 Workflow</h1>
<p style={{ fontSize: '13px', color: '#6B7280', margin: '4px 0 0' }}>
주일기업 업무처리과정 플로우차트 단계를 클릭하면 상세 정보를 확인할 있습니다
</p>
</div>
<div style={{
display: 'flex', gap: '6px', flexWrap: 'wrap', justifyContent: 'flex-end',
}}>
{['영업', '견적', '시공', '정산'].map((label, i) => (
<span key={i} style={{
fontSize: '11px', padding: '2px 10px',
borderRadius: '10px', backgroundColor: '#F3F4F6',
color: '#6B7280', fontWeight: 500,
}}>{label}</span>
))}
</div>
</div>
{/* 플로우차트 영역 */}
<div style={{
backgroundColor: '#FAFBFC',
borderRadius: '16px',
border: '1px solid #E5E7EB',
padding: '24px 16px',
marginBottom: '24px',
overflowX: 'auto',
}}>
{/* 상단 행: 영업 → 입고검수 */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
gap: '4px', minWidth: 'fit-content', margin: '0 auto',
}}>
{topRow.map((p, i) => (
<React.Fragment key={p.id}>
<ProcessNode
process={p}
isActive={selected?.id === p.id}
onClick={setSelected}
/>
{i < topRow.length - 1 && <Arrow />}
</React.Fragment>
))}
</div>
{/* 입찰 분기 표시 (3번째 노드 아래) */}
<BranchInfo />
{/* 연결 화살표 (상단 → 하단) */}
<div style={{
display: 'flex', justifyContent: 'center', padding: '4px 0',
}}>
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
}}>
<div style={{ width: '2px', height: '16px', backgroundColor: '#9CA3AF' }}></div>
<div style={{
width: 0, height: 0,
borderLeft: '6px solid transparent',
borderRight: '6px solid transparent',
borderTop: '8px solid #9CA3AF',
}}></div>
</div>
</div>
{/* 하단 행: 시공 → A/S */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
gap: '4px', minWidth: 'fit-content', margin: '0 auto',
}}>
{bottomRow.map((p, i) => (
<React.Fragment key={p.id}>
<ProcessNode
process={p}
isActive={selected?.id === p.id}
onClick={setSelected}
/>
{i < bottomRow.length - 1 && <Arrow />}
</React.Fragment>
))}
</div>
</div>
{/* 상세 패널 */}
{selected && (
<DetailPanel process={selected} onClose={() => setSelected(null)} />
)}
{/* 범례 */}
<div style={{
display: 'flex', gap: '16px', flexWrap: 'wrap',
padding: '12px 16px', backgroundColor: '#F9FAFB',
borderRadius: '8px', border: '1px solid #E5E7EB',
fontSize: '12px', color: '#6B7280',
}}>
<span><strong>범례:</strong></span>
<span>클릭 상세 보기</span>
<span> 분기점 (입찰 결과)</span>
<span> SAM 메뉴 바로가기</span>
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
@endverbatim
</script>
@endpush

View File

@@ -1641,6 +1641,7 @@
Route::middleware('auth')->prefix('juil')->name('juil.')->group(function () {
Route::get('/estimate', [PlanningController::class, 'estimate'])->name('estimate');
Route::get('/project', [PlanningController::class, 'project'])->name('project');
Route::get('/workflow', [PlanningController::class, 'workflow'])->name('workflow');
// 공사현장 사진대지
Route::prefix('construction-photos')->name('construction-photos.')->group(function () {