feat: [juil] 업무 Workflow 분기형 UI 구현

- 입찰 참여 기업 / 수의계약 기업 두 경로로 분기
- A경로: 영업 → 견적서 작성 → 입찰 참여 → 수주/계약
- B경로: 영업 → 견적서 작성 → 수주/계약 (입찰 생략)
- 분기/합류 시각적 연결선으로 표현
- 수주/계약 이후 공통 프로세스로 합류
This commit is contained in:
김보곤
2026-03-05 21:27:50 +09:00
parent 106e654cbd
commit 53a851740a

View File

@@ -61,15 +61,59 @@
],
},
{
id: 2, phase: 'bid', name: '입찰 참여', icon: '🏷️', dept: '영업팀',
color: '#EC4899', bgColor: '#FDF2F8',
description: '공공/대형 프로젝트의 경우 입찰에 참여한다. 소규모 업체나 수의계약의 경우 이 단계를 생략하고 바로 견적서 작성으로 진행한다.',
documents: ['입찰서', '기술제안서', '사업자등록증 사본'],
id: 2, phase: 'estimate', name: '견적서 작성', icon: '🧮', dept: '견적팀',
color: '#8B5CF6', bgColor: '#F5F3FF',
description: '자재/인건비/경비를 산출하여 견적서를 작성한다. 입찰 프로젝트는 입찰가 산정에, 수의계약은 고객 제출용으로 활용한다.',
documents: ['견적서', '단가산출서', '자재목록'],
samLink: '/juil/estimate', samMenu: '견적/입찰/공사관리',
branch: true, optional: true,
subSteps: [
{
id: 'S2-1', name: '입찰서류 준비', icon: '📂',
id: 'S2-1', name: '물량 산출', icon: '📏',
description: '도면과 실측 데이터를 기반으로 자재별 물량(수량)을 산출한다.',
input: ['도면', '실측 데이터', '현장조사서'],
output: ['물량산출서 (자재별 수량)'],
responsible: '견적 담당자',
tips: ['로스율(5~10%) 반드시 반영', '현장 여건에 따른 추가 물량 고려'],
duration: '2~3일',
},
{
id: 'S2-2', name: '단가 산정', icon: '💵',
description: '자재 단가, 인건비, 장비비, 경비 등 각 항목의 단가를 산정한다.',
input: ['물량산출서', '최근 자재 시세', '노무단가표'],
output: ['단가산출서'],
responsible: '견적 담당자',
tips: ['자재 시세는 최근 3개월 평균 적용', '노무단가는 대한건설협회 기준 참조'],
duration: '1~2일',
},
{
id: 'S2-3', name: '견적가 산출', icon: '🧮',
description: '물량 x 단가를 계산하고, 이윤율/관리비를 적용하여 최종 견적가를 산출한다.',
input: ['물량산출서', '단가산출서', '이윤율 기준'],
output: ['원가계산서', '내부 견적서'],
responsible: '견적팀장',
tips: ['이윤율은 프로젝트 규모별 차등 적용', '원가 대비 견적가 검증 필수'],
duration: '1일',
},
{
id: 'S2-4', name: '견적서 작성/검토', icon: '📋',
description: '고객 제출용 견적서를 작성하고, 팀장 검토 후 최종 확정한다.',
input: ['원가계산서', '고객 요구 양식'],
output: ['공식 견적서 (PDF)'],
responsible: '견적팀장 → 대표이사 승인',
tips: ['고객사 양식이 별도로 있는지 확인', '견적 유효기간 명시 (보통 30일)'],
duration: '1일',
},
],
},
{
id: 3, phase: 'bid', name: '입찰 참여', icon: '🏷️', dept: '영업팀',
color: '#EC4899', bgColor: '#FDF2F8',
description: '공공/대형 프로젝트의 경우 견적서를 기반으로 입찰에 참여한다. 소규모 업체나 수의계약은 이 단계를 생략한다.',
documents: ['입찰서', '기술제안서', '사업자등록증 사본'],
samLink: '/juil/estimate', samMenu: '견적/입찰/공사관리',
subSteps: [
{
id: 'S3-1', name: '입찰서류 준비', icon: '📂',
description: '입찰 참여에 필요한 서류(사업자등록증, 실적증명, 재무제표 등)를 준비한다.',
input: ['입찰 공고문 (필요서류 목록)', '회사 기본서류'],
output: ['입찰서류 패키지'],
@@ -106,51 +150,6 @@
},
],
},
{
id: 3, phase: 'estimate', name: '견적서 작성', icon: '🧮', dept: '견적팀',
color: '#8B5CF6', bgColor: '#F5F3FF',
description: '자재/인건비/경비를 산출하여 견적서를 작성한다. 입찰 프로젝트는 입찰가 산정에, 수의계약은 고객 제출용으로 활용한다.',
documents: ['견적서', '단가산출서', '자재목록'],
samLink: '/juil/estimate', samMenu: '견적/입찰/공사관리',
subSteps: [
{
id: 'S3-1', name: '물량 산출', icon: '📏',
description: '도면과 실측 데이터를 기반으로 자재별 물량(수량)을 산출한다.',
input: ['도면', '실측 데이터', '현장조사서'],
output: ['물량산출서 (자재별 수량)'],
responsible: '견적 담당자',
tips: ['로스율(5~10%) 반드시 반영', '현장 여건에 따른 추가 물량 고려'],
duration: '2~3일',
},
{
id: 'S3-2', name: '단가 산정', icon: '💵',
description: '자재 단가, 인건비, 장비비, 경비 등 각 항목의 단가를 산정한다.',
input: ['물량산출서', '최근 자재 시세', '노무단가표'],
output: ['단가산출서'],
responsible: '견적 담당자',
tips: ['자재 시세는 최근 3개월 평균 적용', '노무단가는 대한건설협회 기준 참조'],
duration: '1~2일',
},
{
id: 'S3-3', name: '견적가 산출', icon: '🧮',
description: '물량 x 단가를 계산하고, 이윤율/관리비를 적용하여 최종 견적가를 산출한다.',
input: ['물량산출서', '단가산출서', '이윤율 기준'],
output: ['원가계산서', '내부 견적서'],
responsible: '견적팀장',
tips: ['이윤율은 프로젝트 규모별 차등 적용', '원가 대비 견적가 검증 필수'],
duration: '1일',
},
{
id: 'S3-4', name: '견적서 작성/검토', icon: '📋',
description: '고객 제출용 견적서를 작성하고, 팀장 검토 후 최종 확정한다.',
input: ['원가계산서', '고객 요구 양식'],
output: ['공식 견적서 (PDF)'],
responsible: '견적팀장 → 대표이사 승인',
tips: ['고객사 양식이 별도로 있는지 확인', '견적 유효기간 명시 (보통 30일)'],
duration: '1일',
},
],
},
{
id: 4, phase: 'contract', name: '수주/계약', icon: '📝', dept: '영업팀',
color: '#10B981', bgColor: '#ECFDF5',
@@ -583,18 +582,13 @@ function ProcessNode({ process, isActive, onClick }) {
fontSize: '11px', color: '#FFF', backgroundColor: process.color,
borderRadius: '10px', padding: '1px 8px', display: 'inline-block',
}}>{process.dept}</div>
{process.optional && (
{process.phase === 'bid' && (
<div style={{
position: 'absolute', top: '-8px', right: '-8px',
width: '20px', height: '20px', borderRadius: '50%',
backgroundColor: '#FEF3C7', border: '2px solid #F59E0B',
backgroundColor: '#FCE7F3', border: '2px solid #EC4899',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '10px',
}}></div>
)}
{process.optional && (
<div style={{
fontSize: '9px', color: '#F59E0B', fontWeight: 600, marginTop: '4px',
}}>생략 가능</div>
}}>A</div>
)}
</div>
);
@@ -875,26 +869,16 @@ function DetailModal({ process, onClose }) {
);
}
// --- 분기 표시 ---
function BranchInfo() {
return (
<div style={{ display: 'flex', gap: '8px', justifyContent: 'center', margin: '8px 0', fontSize: '11px' }}>
<span style={{ padding: '2px 10px', borderRadius: '10px', backgroundColor: '#FEF3C7', color: '#92400E', fontWeight: 600 }}>
대형/공공 입찰 참여 견적서
</span>
<span style={{ padding: '2px 10px', borderRadius: '10px', backgroundColor: '#DBEAFE', color: '#1E40AF', fontWeight: 600 }}>
소규모/수의계약 입찰 생략 견적서
</span>
</div>
);
}
// --- 메인 앱 ---
function App() {
const [modalProcess, setModalProcess] = useState(null);
const topRow = processes.slice(0, 6);
const bottomRow = processes.slice(6);
// 프로세스 참조
const p = {};
processes.forEach(proc => { p[proc.phase] = proc; });
// 공통 후반 프로세스 (수주/계약 이후)
const postContract = [p.order, p.receive, p.construct, p.inspect, p.billing, p.payment, p.as].filter(Boolean);
return (
<div style={{ padding: '20px' }}>
@@ -908,57 +892,128 @@ function App() {
주일기업 업무처리과정 플로우차트 단계를 클릭하면 상세 업무 흐름을 확인할 있습니다
</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',
padding: '32px 24px', 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={false} onClick={setModalProcess} />
{i < topRow.length - 1 && <Arrow />}
</React.Fragment>
))}
{/* === 1단계: 영업 (공통 시작점) === */}
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: '8px' }}>
<ProcessNode process={p.sales} isActive={false} onClick={setModalProcess} />
</div>
<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 style={{ display: 'flex', justifyContent: 'center', position: 'relative', height: '60px' }}>
{/* 중앙 세로선 */}
<div style={{ position: 'absolute', top: 0, width: '2px', height: '20px', backgroundColor: '#9CA3AF' }}></div>
{/* 가로 분기선 */}
<div style={{ position: 'absolute', top: '20px', width: '400px', height: '2px', backgroundColor: '#9CA3AF', left: '50%', transform: 'translateX(-50%)' }}></div>
{/* 왼쪽 세로선 + 화살표 */}
<div style={{ position: 'absolute', top: '20px', left: '50%', transform: 'translateX(calc(-200px))', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div style={{ width: '2px', height: '20px', backgroundColor: '#9CA3AF' }}></div>
<div style={{ width: 0, height: 0, borderLeft: '6px solid transparent', borderRight: '6px solid transparent', borderTop: '8px solid #9CA3AF' }}></div>
</div>
{/* 오른쪽 세로선 + 화살표 */}
<div style={{ position: 'absolute', top: '20px', left: '50%', transform: 'translateX(calc(200px - 2px))', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div style={{ width: '2px', height: '20px', backgroundColor: '#9CA3AF' }}></div>
<div style={{ width: 0, height: 0, borderLeft: '6px solid transparent', borderRight: '6px solid transparent', borderTop: '8px solid #9CA3AF' }}></div>
</div>
</div>
{/* 하단 행 */}
{/* === 2단계: 두 경로 분기 === */}
<div style={{ display: 'flex', justifyContent: 'center', gap: '24px', marginBottom: '8px' }}>
{/* A 경로: 입찰 기업 */}
<div style={{
flex: '0 0 auto', padding: '16px', borderRadius: '16px',
border: '2px dashed #EC4899', backgroundColor: '#FDF2F820',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '4px',
minWidth: '360px',
}}>
<div style={{
fontSize: '12px', fontWeight: 700, color: '#BE185D',
padding: '3px 14px', borderRadius: '12px', backgroundColor: '#FCE7F3',
marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '6px',
}}>
<span style={{ fontSize: '14px' }}>🏢</span>
A. 입찰 참여 기업 (대형/공공 프로젝트)
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<ProcessNode process={p.estimate} isActive={false} onClick={setModalProcess} />
<Arrow />
<ProcessNode process={p.bid} isActive={false} onClick={setModalProcess} />
</div>
<div style={{ fontSize: '10px', color: '#9CA3AF', marginTop: '4px' }}>
견적서 작성 입찰 참여 낙찰 수주
</div>
</div>
{/* B 경로: 수의계약 */}
<div style={{
flex: '0 0 auto', padding: '16px', borderRadius: '16px',
border: '2px dashed #3B82F6', backgroundColor: '#EFF6FF20',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '4px',
minWidth: '200px',
}}>
<div style={{
fontSize: '12px', fontWeight: 700, color: '#1D4ED8',
padding: '3px 14px', borderRadius: '12px', backgroundColor: '#DBEAFE',
marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '6px',
}}>
<span style={{ fontSize: '14px' }}>🏠</span>
B. 수의계약 (소규모/직접 의뢰)
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<ProcessNode process={p.estimate} isActive={false} onClick={setModalProcess} />
</div>
<div style={{ fontSize: '10px', color: '#9CA3AF', marginTop: '4px' }}>
견적서 작성 고객 승인 수주
</div>
</div>
</div>
{/* 합류 화살표 */}
<div style={{ display: 'flex', justifyContent: 'center', position: 'relative', height: '60px' }}>
{/* 왼쪽 세로선 */}
<div style={{ position: 'absolute', top: 0, left: '50%', transform: 'translateX(calc(-200px))', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div style={{ width: '2px', height: '20px', backgroundColor: '#9CA3AF' }}></div>
</div>
{/* 오른쪽 세로선 */}
<div style={{ position: 'absolute', top: 0, left: '50%', transform: 'translateX(calc(200px - 2px))', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div style={{ width: '2px', height: '20px', backgroundColor: '#9CA3AF' }}></div>
</div>
{/* 가로 합류선 */}
<div style={{ position: 'absolute', top: '20px', width: '400px', height: '2px', backgroundColor: '#9CA3AF', left: '50%', transform: 'translateX(-50%)' }}></div>
{/* 중앙 세로선 + 화살표 */}
<div style={{ position: 'absolute', top: '20px', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div style={{ width: '2px', height: '20px', backgroundColor: '#9CA3AF' }}></div>
<div style={{ width: 0, height: 0, borderLeft: '6px solid transparent', borderRight: '6px solid transparent', borderTop: '8px solid #9CA3AF' }}></div>
</div>
</div>
{/* === 3단계: 수주/계약 (합류 지점) === */}
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: '8px' }}>
<ProcessNode process={p.contract} isActive={false} onClick={setModalProcess} />
</div>
{/* 아래 화살표 */}
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div style={{ width: '2px', height: '12px', backgroundColor: '#9CA3AF' }}></div>
<div style={{ width: 0, height: 0, borderLeft: '6px solid transparent', borderRight: '6px solid transparent', borderTop: '8px solid #9CA3AF' }}></div>
</div>
</div>
{/* === 4단계: 공통 후반 프로세스 === */}
<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={false} onClick={setModalProcess} />
{i < bottomRow.length - 1 && <Arrow />}
{postContract.map((proc, i) => (
<React.Fragment key={proc.id}>
<ProcessNode process={proc} isActive={false} onClick={setModalProcess} />
{i < postContract.length - 1 && <Arrow />}
</React.Fragment>
))}
</div>
@@ -969,11 +1024,18 @@ function App() {
display: 'flex', gap: '16px', flexWrap: 'wrap',
padding: '12px 16px', backgroundColor: '#F9FAFB',
borderRadius: '8px', border: '1px solid #E5E7EB',
fontSize: '12px', color: '#6B7280',
fontSize: '12px', color: '#6B7280', alignItems: 'center',
}}>
<span><strong>범례:</strong></span>
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ display: 'inline-block', width: '16px', height: '3px', borderTop: '2px dashed #EC4899' }}></span>
입찰 참여 경로
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ display: 'inline-block', width: '16px', height: '3px', borderTop: '2px dashed #3B82F6' }}></span>
수의계약 경로
</span>
<span>클릭 상세 업무 모달</span>
<span> 생략 가능 단계 (입찰)</span>
<span> SAM 메뉴 바로가기</span>
<span>ESC 키로 모달 닫기</span>
</div>