2025-12-17 12:59:26 +09:00
<! DOCTYPE html >
< html lang = " ko " >
< head >
< meta charset = " UTF-8 " >
< meta name = " viewport " content = " width=device-width, initial-scale=1.0 " >
2026-01-01 22:14:22 +09:00
< title > 영업 관리 시스템 </ title >
2026-01-01 22:37:33 +09:00
<!-- Favicon -->
< link rel = " apple-touch-icon " sizes = " 180x180 " href = " ../img/apple-touch-icon.png " >
< link rel = " icon " type = " image/png " sizes = " 32x32 " href = " ../img/favicon-32x32.png " >
< link rel = " icon " type = " image/png " sizes = " 16x16 " href = " ../img/favicon-16x16.png " >
< link rel = " shortcut icon " href = " ../img/favicon.png " >
2025-12-17 12:59:26 +09:00
2026-01-04 17:11:43 +09:00
<!-- Console Warning Filter -->
< script >
( function () {
const originalLog = console . log ;
2026-01-04 17:18:17 +09:00
const originalWarn = console . warn ;
const originalInfo = console . info ;
2026-01-04 17:11:43 +09:00
const suppressPatterns = [
2026-01-04 17:18:17 +09:00
'cdn.tailwindcss.com' ,
'in-browser Babel transformer' ,
'React DevTools' ,
'development version of React'
2026-01-04 17:11:43 +09:00
];
const filter = ( orig ) => function () {
2026-01-04 17:18:17 +09:00
const args = Array . from ( arguments );
const firstArg = args [ 0 ];
if ( typeof firstArg === 'string' && suppressPatterns . some ( p => firstArg . includes ( p ))) {
2026-01-04 17:11:43 +09:00
return ;
}
2026-01-04 17:18:17 +09:00
orig . apply ( console , args );
2026-01-04 17:11:43 +09:00
};
console . log = filter ( originalLog );
2026-01-04 17:18:17 +09:00
console . warn = filter ( originalWarn );
console . info = filter ( originalInfo );
2026-01-04 17:11:43 +09:00
})();
</ script >
2026-01-04 17:18:17 +09:00
<!-- Fonts : Pretendard -->
< link rel = " stylesheet " as = " style " crossorigin href = " https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.8/dist/web/static/pretendard.css " />
2025-12-17 12:59:26 +09:00
<!-- Tailwind CSS -->
2026-01-04 17:18:17 +09:00
< script src = " https://cdn.tailwindcss.com?v=3.4.1 " ></ script >
2025-12-17 12:59:26 +09:00
< script >
tailwind . config = {
theme : {
extend : {
fontFamily : {
sans : [ 'Pretendard' , 'sans-serif' ],
},
colors : {
background : 'rgb(250, 250, 250)' ,
primary : {
DEFAULT : '#2563eb' , // blue-600
foreground : '#ffffff' ,
},
},
borderRadius : {
'card' : '12px' ,
}
}
}
}
</ script >
2026-01-04 17:11:43 +09:00
<!-- React & ReactDOM ( Production Versions ) -->
2026-01-04 17:18:17 +09:00
< script crossorigin src = " https://unpkg.com/react@18/umd/react.production.min.js?v=18.2.0 " ></ script >
< script crossorigin src = " https://unpkg.com/react-dom@18/umd/react-dom.production.min.js?v=18.2.0 " ></ script >
2025-12-17 12:59:26 +09:00
<!-- Babel for JSX -->
< script src = " https://unpkg.com/@babel/standalone/babel.min.js " ></ script >
<!-- Icons : Lucide React ( via CDN is tricky , using simple SVG icons or a library wrapper if needed . For now , using text / simple SVGs ) -->
< script src = " https://unpkg.com/lucide@latest " ></ script >
</ head >
< body class = " bg-background text-slate-800 antialiased " >
< div id = " root " ></ div >
< script type = " text/babel " >
const { useState , useEffect , useRef } = React ;
2025-12-23 09:00:57 +09:00
// Utility Functions
const formatBusinessNo = ( val ) => {
if ( ! val ) return '' ;
const clean = val . replace ( / [ ^ 0 - 9 ] / g , '' );
if ( clean . length <= 3 ) return clean ;
if ( clean . length <= 5 ) return `${clean.slice(0, 3)}-${clean.slice(3)}` ;
return `${clean.slice(0, 3)}-${clean.slice(3, 5)}-${clean.slice(5, 10)}` ;
};
const formatPhone = ( val ) => {
if ( ! val ) return '' ;
const clean = val . replace ( / [ ^ 0 - 9 ] / g , '' );
if ( clean . length <= 3 ) return clean ;
if ( clean . length <= 7 ) return `${clean.slice(0, 3)}-${clean.slice(3)}` ;
if ( clean . length <= 11 ) {
if ( clean . length === 11 ) return `${clean.slice(0, 3)}-${clean.slice(3, 7)}-${clean.slice(7)}` ;
return `${clean.slice(0, 3)}-${clean.slice(3, 6)}-${clean.slice(6)}` ;
}
return `${clean.slice(0, 3)}-${clean.slice(3, 7)}-${clean.slice(7, 11)}` ;
};
2025-12-17 12:59:26 +09:00
// Lucide Icon Wrapper
const LucideIcon = ({ name , size , className , onClick }) => {
const ref = React . useRef ( null );
React . useEffect (() => {
if ( window . lucide && ref . current ) {
const i = document . createElement ( 'i' );
i . setAttribute ( 'data-lucide' , name );
if ( className ) i . className = className ;
ref . current . innerHTML = '' ;
ref . current . appendChild ( i );
window . lucide . createIcons ({ root : ref . current });
}
}, [ name , className ]);
2025-12-21 19:19:02 +09:00
// Ensure icon itself doesn't eat clicks if no specific handler
return < span ref = { ref } onClick = { onClick } className = { `inline-flex items-center justify-center ${className || ''}` } style = {{ pointerEvents : onClick ? 'auto' : 'none' }} ></ span > ;
2025-12-17 12:59:26 +09:00
};
// --- Components ---
2025-12-23 09:00:57 +09:00
const StatCard = ({ title , value , subtext , icon , onClick , className = " " }) => (
< div
className = { `bg-white rounded-card p-6 shadow-sm border border-slate-100 hover:shadow-md transition-shadow ${onClick ? 'cursor-pointer' : ''} ${className}` }
onClick = { onClick }
>
< div className = " flex items-start justify-between mb-4 " >
< h3 className = " text-sm font-medium text-slate-500 " > { title } </ h3 >
< div className = " p-2 bg-blue-50 rounded-lg text-blue-600 " >
{ icon }
</ div >
</ div >
< div className = " text-2xl font-bold text-slate-900 mb-1 " > { value } </ div >
{ subtext && < div className = " text-xs text-slate-400 " > { subtext } </ div > }
</ div >
);
2025-12-17 12:59:26 +09:00
// 1. Header Component
2025-12-21 19:19:02 +09:00
const Header = ({ companyInfo , onOpenHelp , selectedRole , onRoleChange , currentUser , onLogout }) => {
2025-12-17 12:59:26 +09:00
const [ isProfileMenuOpen , setIsProfileMenuOpen ] = useState ( false );
const profileMenuRef = React . useRef ( null );
// 외부 클릭 시 메뉴 닫기
useEffect (() => {
const handleClickOutside = ( event ) => {
if ( profileMenuRef . current && ! profileMenuRef . current . contains ( event . target )) {
setIsProfileMenuOpen ( false );
}
};
if ( isProfileMenuOpen ) {
document . addEventListener ( 'mousedown' , handleClickOutside );
}
return () => {
document . removeEventListener ( 'mousedown' , handleClickOutside );
};
}, [ isProfileMenuOpen ]);
if ( ! companyInfo ) return < div className = " h-16 bg-white shadow-sm animate-pulse " ></ div > ;
2025-12-21 19:19:02 +09:00
const roles = [ '운영자' , '영업관리' , '매니저' ];
2025-12-17 12:59:26 +09:00
return (
< header className = " bg-white border-b border-gray-100 sticky top-0 z-50 " >
< div className = " max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between " >
2025-12-21 19:19:02 +09:00
< div className = " flex items-center gap-6 " >
< h1 className = " text-lg font-bold text-slate-900 flex items-center gap-2 " >
< div className = " w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center text-white " >
< LucideIcon name = " briefcase " className = " w-5 h-5 " />
</ div >
< span > SAM 영업관리 </ span >
</ h1 >
{ currentUser && (
< div className = " hidden md:flex items-center gap-2 px-3 py-1 bg-slate-100 rounded-full " >
< div className = " w-2 h-2 rounded-full bg-green-500 animate-pulse " ></ div >
< span className = " text-xs font-bold text-slate-700 " >
{ currentUser . name } ({ currentUser . member_id })
</ span >
</ div >
)}
2025-12-17 12:59:26 +09:00
</ div >
2025-12-21 19:19:02 +09:00
2025-12-17 12:59:26 +09:00
< div className = " flex items-center gap-4 " >
2025-12-21 19:19:02 +09:00
< a href = " ../index.php " className = " text-sm text-slate-500 hover:text-slate-900 flex items-center gap-1 font-medium transition-colors " >
2025-12-17 12:59:26 +09:00
< LucideIcon name = " home " className = " w-4 h-4 " />
2025-12-21 19:19:02 +09:00
< span className = " hidden sm:inline " > 홈으로 </ span >
2025-12-17 12:59:26 +09:00
</ a >
2025-12-21 19:19:02 +09:00
< button onClick = { onOpenHelp } className = " text-sm text-slate-500 hover:text-slate-900 flex items-center gap-1 font-medium transition-colors " >
2025-12-17 12:59:26 +09:00
< LucideIcon name = " help-circle " className = " w-4 h-4 " />
2025-12-21 19:19:02 +09:00
< span className = " hidden sm:inline " > 도움말 </ span >
2025-12-17 12:59:26 +09:00
</ button >
2025-12-21 19:19:02 +09:00
{ currentUser && (
< button
onClick = { onLogout }
className = " text-sm text-red-500 hover:text-red-700 flex items-center gap-1 font-medium transition-colors ml-2 "
>
< LucideIcon name = " log-out " className = " w-4 h-4 " />
< span className = " hidden sm:inline " > 로그아웃 </ span >
</ button >
)}
2025-12-17 12:59:26 +09:00
< div className = " relative " ref = { profileMenuRef } >
< button
onClick = {() => setIsProfileMenuOpen ( ! isProfileMenuOpen )}
2025-12-21 19:19:02 +09:00
className = " px-3 py-1.5 rounded-lg bg-slate-100 hover:bg-slate-200 transition-all flex items-center gap-2 cursor-pointer text-sm font-bold text-slate-700 border border-slate-200 "
2025-12-17 12:59:26 +09:00
>
2025-12-21 19:19:02 +09:00
< LucideIcon name = " user-cog " className = " w-4 h-4 text-slate-500 " />
< span > { currentUser ? `${currentUser.name} (${currentUser.member_id})` : selectedRole } </ span >
2025-12-17 12:59:26 +09:00
< LucideIcon name = " chevron-down " className = " w-4 h-4 " />
</ button >
{ isProfileMenuOpen && (
2025-12-21 19:19:02 +09:00
< div className = " absolute right-0 mt-2 w-48 bg-white rounded-xl shadow-xl border border-slate-100 py-2 z-50 animate-in fade-in slide-in-from-top-1 duration-200 " >
< div className = " px-4 py-2 border-b border-slate-50 " >
< div className = " text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1 " > 접속 모드 변경 </ div >
< div className = " text-sm font-bold text-slate-900 " > { selectedRole } </ div >
2025-12-17 12:59:26 +09:00
</ div >
{ roles . map (( role ) => (
< button
key = { role }
onClick = {() => {
onRoleChange ( role );
setIsProfileMenuOpen ( false );
}}
className = { ` w - full text - left px - 4 py - 2 text - sm hover : bg - slate - 50 transition - colors flex items - center gap - 2 $ {
2025-12-21 19:19:02 +09:00
selectedRole === role ? 'bg-blue-50 text-blue-700 font-bold' : 'text-slate-700 font-medium'
2025-12-17 12:59:26 +09:00
} ` }
>
2025-12-21 19:19:02 +09:00
{ selectedRole === role ? (
< LucideIcon name = " check-circle-2 " className = " w-4 h-4 " />
) : (
< div className = " w-4 " />
2025-12-17 12:59:26 +09:00
)}
{ role }
</ button >
))}
</ div >
)}
</ div >
</ div >
</ div >
</ header >
);
};
// Operator View Component
2025-12-21 19:19:02 +09:00
const OperatorView = ({ currentUser }) => {
2025-12-17 12:59:26 +09:00
const [ selectedManager , setSelectedManager ] = useState ( null );
const [ managers , setManagers ] = useState ([]);
2025-12-21 19:19:02 +09:00
const [ members , setMembers ] = useState ([]);
const [ loading , setLoading ] = useState ( true );
const [ isModalOpen , setIsModalOpen ] = useState ( false );
const [ editingMember , setEditingMember ] = useState ( null );
const [ detailModalUser , setDetailModalUser ] = useState ( null );
const [ isIdChecked , setIsIdChecked ] = useState ( false );
const [ isIdChecking , setIsIdChecking ] = useState ( false );
const [ idCheckMessage , setIdCheckMessage ] = useState ( '' );
const [ deleteConfirmMember , setDeleteConfirmMember ] = useState ( null );
2025-12-17 12:59:26 +09:00
2025-12-21 19:19:02 +09:00
const [ formData , setFormData ] = useState ({
member_id : '' ,
password : '' ,
name : '' ,
phone : '' ,
email : '' ,
role : 'manager' ,
parent_id : '' ,
remarks : ''
});
2025-12-24 09:46:07 +09:00
const fillRandomOperatorData = () => {
const sampleNames = [ '성시경' , '아이유' , '조세호' , '양세형' , '박나래' , '장도연' , '유재석' , '하하' , '지석진' , '김광석' , '이문세' , '이선희' , '조용필' ];
const sampleIds = [ 'agent_k' , 'team_alpha' , 'pro_sales' , 'top_tier' , 'gold_star' , 'sky_line' , 'ace_manager' , 'super_star' ];
const randomItem = ( arr ) => arr [ Math . floor ( Math . random () * arr . length )];
const randomNum = ( len ) => Array . from ({ length : len }, () => Math . floor ( Math . random () * 10 )) . join ( '' );
const name = randomItem ( sampleNames );
const idSuffix = randomNum ( 3 );
setFormData ({
... formData ,
member_id : randomItem ( sampleIds ) + idSuffix ,
password : 'password123' ,
name : name ,
phone : formatPhone ( '010' + randomNum ( 8 )),
email : `sales_${randomNum(4)}@example.com` ,
remarks : '운영자 생성 샘플 데이터'
});
setIsIdChecked ( true );
setIdCheckMessage ( '신규 등록 시 아이디 중복 확인 권장' );
};
2025-12-17 12:59:26 +09:00
useEffect (() => {
2025-12-21 19:19:02 +09:00
fetchMembers ();
}, []);
const fetchMembers = async () => {
setLoading ( true );
try {
const res = await fetch ( `api/sales_members.php?action=list` );
const result = await res . json ();
if ( result . success ) setMembers ( result . data );
} catch ( err ) {
console . error ( 'Fetch error:' , err );
} finally {
setLoading ( false );
}
};
const handleOpenAdd = () => {
setEditingMember ( null );
setFormData ({ member_id : '' , password : '' , name : '' , phone : '' , email : '' , role : 'manager' , parent_id : '' , remarks : '' });
setIsIdChecked ( false );
setIdCheckMessage ( '' );
setIsModalOpen ( true );
};
const handleCheckId = async () => {
if ( ! formData . member_id ) {
setIdCheckMessage ( '아이디를 입력해주세요.' );
return ;
}
setIsIdChecking ( true );
setIdCheckMessage ( '' );
try {
const res = await fetch ( `api/sales_members.php?action=check_id` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ member_id : formData . member_id })
2025-12-17 12:59:26 +09:00
});
2025-12-21 19:19:02 +09:00
const result = await res . json ();
if ( result . success ) {
if ( result . exists ) {
setIdCheckMessage ( '이미 사용 중인 아이디입니다.' );
setIsIdChecked ( false );
} else {
setIdCheckMessage ( '사용 가능한 아이디입니다.' );
setIsIdChecked ( true );
}
} else {
setIdCheckMessage ( result . error || '확인 실패' );
}
} catch ( err ) {
setIdCheckMessage ( '오류가 발생했습니다.' );
} finally {
setIsIdChecking ( false );
}
};
const handleOpenEdit = ( member ) => {
setEditingMember ( member );
setFormData ({
member_id : member . member_id ,
password : '' ,
name : member . name ,
phone : member . phone || '' ,
email : member . email || '' ,
role : member . role || 'manager' ,
parent_id : member . parent_id || '' ,
remarks : member . remarks || ''
});
setIsModalOpen ( true );
};
const handleSubmit = async ( e ) => {
e . preventDefault ();
const action = editingMember ? 'update' : 'create' ;
const method = editingMember ? 'PUT' : 'POST' ;
2025-12-17 12:59:26 +09:00
2025-12-21 19:19:02 +09:00
try {
const res = await fetch ( `api/sales_members.php${action === 'create' ? '?action=create' : ''}` , {
method : method ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({
... formData ,
id : editingMember ? . id
})
});
const result = await res . json ();
if ( result . success ) {
alert ( result . message );
setIsModalOpen ( false );
fetchMembers ();
} else {
alert ( result . error );
}
} catch ( err ) {
alert ( '저장 중 오류가 발생했습니다.' );
}
};
const handleMemberDelete = ( member ) => {
console . log ( '[OperatorView] handleDelete triggered for:' , member . name , member . id );
setDeleteConfirmMember ( member );
};
const executeDelete = async () => {
const member = deleteConfirmMember ;
if ( ! member ) return ;
try {
const res = await fetch ( `api/sales_members.php?action=delete` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ id : member . id })
});
const result = await res . json ();
if ( result . success ) {
alert ( result . message || '삭제되었습니다.' );
setDeleteConfirmMember ( null );
fetchMembers ();
} else {
alert ( result . error || '삭제에 실패했습니다.' );
}
} catch ( err ) {
console . error ( 'Delete error exception:' , err );
alert ( '삭제 중 오류가 발생했습니다: ' + err . message );
}
};
2025-12-23 09:00:57 +09:00
const [ operatorStats , setOperatorStats ] = useState ({
2025-12-21 19:19:02 +09:00
totalSales : 0 ,
totalCommission : 0 ,
2025-12-23 09:00:57 +09:00
totalCount : 0 ,
2025-12-21 19:19:02 +09:00
monthlySales : 0 ,
2025-12-17 12:59:26 +09:00
monthlyCommission : 0 ,
2025-12-23 09:00:57 +09:00
monthlyCount : 0 ,
pendingApprovals : 0
});
useEffect (() => {
fetchOperatorDashboard ();
}, []);
const fetchOperatorDashboard = async () => {
try {
// Fetch global stats
const res = await fetch ( `api/get_performance.php` );
const result = await res . json ();
// Fetch tenant product statistics for pending count
const tenantRes = await fetch ( 'api/sales_tenants.php?action=list_tenants' );
const tenantData = await tenantRes . json ();
let pendingCount = 0 ;
if ( tenantData . success ) {
for ( const tenant of tenantData . data ) {
const prodRes = await fetch ( `api/sales_tenants.php?action=tenant_products&tenant_id=${tenant.id}` );
const prodData = await prodRes . json ();
if ( prodData . success ) {
pendingCount += prodData . data . filter ( p => ! p . operator_confirmed || p . operator_confirmed == 0 ) . length ;
}
}
}
if ( result . success && result . total_stats ) {
setOperatorStats ({
totalSales : result . total_stats . totalSales || 0 ,
totalCommission : result . total_stats . totalCommission || 0 ,
totalCount : result . total_stats . totalCount || 0 ,
monthlySales : result . period_stats ? . total_period_commission || 0 , // Just a placeholder for monthly
monthlyCommission : result . period_stats ? . total_period_commission || 0 ,
monthlyCount : 0 ,
pendingApprovals : pendingCount
});
}
} catch ( err ) {
console . error ( 'Operator dashboard fetch failed:' , err );
}
2025-12-21 19:19:02 +09:00
};
2025-12-17 12:59:26 +09:00
const formatCurrency = ( val ) => new Intl . NumberFormat ( 'ko-KR' , { style : 'currency' , currency : 'KRW' }) . format ( val );
return (
2025-12-21 19:19:02 +09:00
< main className = " max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-12 " >
< div className = " flex items-center justify-between " >
< div >
2025-12-23 09:00:57 +09:00
< h2 className = " text-3xl font-extrabold text-slate-900 tracking-tight " > 지능형 영업 통합 관리 </ h2 >
< p className = " text-slate-500 mt-2 text-lg " > 모든 테넌트 계약과 영업 자산을 중앙에서 제어합니다 .</ p >
2025-12-21 19:19:02 +09:00
</ div >
< button
onClick = { handleOpenAdd }
2025-12-23 09:00:57 +09:00
className = " bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-xl font-bold flex items-center gap-2 transition-all shadow-xl shadow-blue-200 "
2025-12-21 19:19:02 +09:00
>
< LucideIcon name = " user-plus " className = " w-5 h-5 " />
신규 담당자 등록
</ button >
</ div >
2025-12-23 09:00:57 +09:00
{ /* Dashboard Metrics */ }
< div className = " grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8 " >
< StatCard
title = " 전체 누적 매출 "
value = { formatCurrency ( operatorStats . totalSales )}
subtext = " 플랫폼 전체 계약 규모 "
icon = { < LucideIcon name = " trending-up " className = " w-5 h-5 " /> }
/>
< StatCard
title = " 전체 지급 수당 "
value = { formatCurrency ( operatorStats . totalCommission )}
subtext = " 영업 인력에게 지급된 총액 "
icon = { < LucideIcon name = " wallet " className = " w-5 h-5 " /> }
/>
< StatCard
title = " 전체 계약 건수 "
value = { `${operatorStats.totalCount}건` }
subtext = " 활성 테넌트 계약 총계 "
icon = { < LucideIcon name = " file-check " className = " w-5 h-5 " /> }
/>
< div className = { `bg-white rounded-card p-6 shadow-sm border ${operatorStats.pendingApprovals > 0 ? 'border-red-200 bg-red-50/10' : 'border-slate-100'}` } >
2025-12-17 12:59:26 +09:00
< div className = " flex items-start justify-between mb-4 " >
2025-12-23 09:00:57 +09:00
< h3 className = " text-sm font-medium text-slate-500 " > 대기 중인 승인 </ h3 >
< div className = { `p-2 rounded-lg ${operatorStats.pendingApprovals > 0 ? 'bg-red-100 text-red-600' : 'bg-slate-100 text-slate-400'}` } >
< LucideIcon name = " alert-circle " className = " w-5 h-5 " />
2025-12-17 12:59:26 +09:00
</ div >
</ div >
2025-12-23 09:00:57 +09:00
< div className = { `text-2xl font-black mb-1 ${operatorStats.pendingApprovals > 0 ? 'text-red-600' : 'text-slate-900'}` } >
{ operatorStats . pendingApprovals } 건
2025-12-17 12:59:26 +09:00
</ div >
2025-12-23 09:00:57 +09:00
< div className = " text-xs text-slate-400 " > 즉시 검토가 필요한 계약 건 </ div >
2025-12-17 12:59:26 +09:00
</ div >
2025-12-23 09:00:57 +09:00
</ div >
2025-12-21 19:19:02 +09:00
2025-12-23 09:00:57 +09:00
{ /* 테넌트 승인 관리 섹션 (운영자 전용) */ }
< div className = " mt-20 pt-12 border-t border-slate-200 " >
< h3 className = " text-2xl font-black text-slate-900 mb-8 flex items-center gap-3 " >
< div className = " p-2 bg-emerald-600 rounded-xl text-white shadow-lg shadow-emerald-100 " >
< LucideIcon name = " check-square " className = " w-6 h-6 " />
2025-12-17 12:59:26 +09:00
</ div >
2025-12-23 09:00:57 +09:00
테넌트 계약 및 수당 승인 관리
</ h3 >
< TenantConfirmationManager />
2025-12-17 12:59:26 +09:00
</ div >
2025-12-21 19:19:02 +09:00
{ /* 영업담당자 통합 관리 (CRUD Table) */ }
< section className = " bg-white rounded-card shadow-sm border border-slate-100 overflow-hidden " >
< div className = " p-6 border-b border-slate-100 bg-slate-50/50 flex justify-between items-center " >
< h3 className = " text-xl font-bold text-slate-900 flex items-center gap-2 " >
< LucideIcon name = " users " className = " w-5 h-5 text-blue-600 " />
전체 영업자 및 매니저 목록
</ h3 >
< div className = " flex gap-2 " >
< button onClick = { fetchMembers } className = " p-2 text-slate-500 hover:bg-slate-100 rounded-lg transition-colors " title = " 새로고침 " >
< LucideIcon name = " refresh-cw " className = { `${loading ? 'animate-spin' : ''} w-5 h-5` } />
2025-12-17 12:59:26 +09:00
</ button >
</ div >
2025-12-21 19:19:02 +09:00
</ div >
< div className = " overflow-x-auto text-sm " >
< table className = " w-full text-left " >
< thead className = " bg-slate-50 border-b border-slate-100 text-slate-500 font-bold uppercase text-xs " >
< tr >
< th className = " px-6 py-4 " > 성명 </ th >
< th className = " px-6 py-4 " > 아이디 </ th >
< th className = " px-6 py-4 " > 역할 </ th >
< th className = " px-6 py-4 " > 상위 관리자 </ th >
< th className = " px-6 py-4 " > 연락처 </ th >
< th className = " px-6 py-4 " > 가입일 </ th >
< th className = " px-6 py-4 text-center " > 관리 </ th >
</ tr >
</ thead >
< tbody className = " divide-y divide-slate-100 " >
{ loading ? (
< tr >< td colSpan = " 7 " className = " px-6 py-12 text-center text-slate-400 " > 데이터 로딩 중 ...</ td ></ tr >
) : members . length === 0 ? (
< tr >< td colSpan = " 7 " className = " px-6 py-12 text-center text-slate-400 " > 등록된 영업 인력이 없습니다 .</ td ></ tr >
) : members . map ( m => (
< tr key = { m . id } className = " hover:bg-blue-50/30 transition-colors " >
< td className = " px-6 py-4 font-bold text-slate-900 " > { m . name } </ td >
< td className = " px-6 py-4 text-slate-600 font-mono text-xs " > { m . member_id } </ td >
< td className = " px-6 py-4 " >
< span
onClick = {() => m . role === 'sales_admin' && setDetailModalUser ( m )}
className = { ` px - 2 py - 0.5 rounded - full text - [ 10 px ] font - bold uppercase transition - all $ {
m . role === 'sales_admin' ? 'bg-indigo-100 text-indigo-700 cursor-pointer hover:bg-indigo-200 ring-1 ring-indigo-200' :
m . role === 'manager' ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-700'
} ` }
title = { m . role === 'sales_admin' ? " 하위 멤버 보기 " : " " }
>
{ m . role === 'sales_admin' ? '영업관리' : m . role === 'manager' ? '매니저' : m . role }
</ span >
</ td >
< td className = " px-6 py-4 text-slate-500 " >
{ m . parent_id ? (() => {
const parent = members . find ( p => p . id == m . parent_id );
return (
< span className = " flex items-center gap-1 " >
< LucideIcon name = " corner-down-right " className = " w-3 h-3 text-slate-300 " />
{ parent ? `${parent.name} (${parent.member_id})` : m . parent_id }
</ span >
);
})() : '-' }
</ td >
2025-12-23 09:19:59 +09:00
< td className = " px-6 py-4 text-slate-600 " > { formatPhone ( m . phone ) || '-' } </ td >
2025-12-21 19:19:02 +09:00
< td className = " px-6 py-4 text-slate-400 text-xs " > { m . created_at ? . split ( ' ' )[ 0 ]} </ td >
< td className = " px-6 py-4 " >
< div className = " flex items-center justify-center gap-1 " >
< button onClick = {() => handleOpenEdit ( m )} className = " p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors " >
< LucideIcon name = " edit-2 " className = " w-4 h-4 " />
</ button >
< button
onClick = {( e ) => {
console . log ( '[OperatorView] Delete button clicked for:' , m . name );
handleMemberDelete ( m );
}}
className = " p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors "
title = " 인력 삭제 "
>
< LucideIcon name = " trash-2 " className = " w-4 h-4 " />
</ button >
</ div >
</ td >
</ tr >
))}
</ tbody >
</ table >
</ div >
</ section >
2025-12-23 09:00:57 +09:00
{ /* 아이템 설정 카드 (운영자 전용) */ }
< div className = " mt-20 pt-12 border-t border-slate-200 " >
< h3 className = " text-2xl font-black text-slate-900 mb-8 flex items-center gap-3 " >
< div className = " p-2 bg-indigo-600 rounded-xl text-white shadow-lg shadow-indigo-100 " >
< LucideIcon name = " settings " className = " w-6 h-6 " />
</ div >
베이직 요금 및 수당 설정
</ h3 >
< ItemPricingManager />
</ div >
2025-12-21 19:19:02 +09:00
{ /* Member CRUD Modal */ }
{ isModalOpen && (
< div className = " fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm " >
< div className = " bg-white rounded-3xl shadow-2xl w-full max-w-lg overflow-hidden animate-in fade-in zoom-in duration-200 border border-slate-200 " >
< form onSubmit = { handleSubmit } >
< div className = " p-8 border-b border-slate-100 flex justify-between items-center bg-slate-50/50 " >
< h3 className = " text-2xl font-black text-slate-900 flex items-center gap-3 " >
< div className = " p-2 bg-blue-600 rounded-xl text-white " >
< LucideIcon name = { editingMember ? " user-cog " : " user-plus " } className = " w-6 h-6 " />
</ div >
{ editingMember ? '정보 수정' : '신규 회원 등록' }
2025-12-24 09:46:07 +09:00
{ ! editingMember && (
< button
type = " button "
onClick = { fillRandomOperatorData }
className = " p-2 bg-amber-50 text-amber-600 rounded-xl hover:bg-amber-100 transition-all border border-amber-200 group relative ml-1 shadow-sm "
title = " 샘플 데이터 자동 입력 "
>
< LucideIcon name = " zap " className = " w-4 h-4 " />
< span className = " absolute -top-12 left-1/2 -translate-x-1/2 bg-slate-800 text-white text-[10px] px-3 py-1.5 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-50 shadow-2xl " > 랜덤 데이터 채우기 </ span >
</ button >
)}
2025-12-21 19:19:02 +09:00
</ h3 >
< button type = " button " onClick = {() => setIsModalOpen ( false )} className = " p-2 hover:bg-slate-200 rounded-full transition-colors text-slate-400 " >
< LucideIcon name = " x " className = " w-6 h-6 " />
</ button >
2025-12-17 12:59:26 +09:00
</ div >
2025-12-21 19:19:02 +09:00
< div className = " p-8 space-y-6 " >
< div className = " grid grid-cols-10 gap-6 " >
< div className = " space-y-1.5 col-span-3 " >
< label className = " text-xs font-black text-slate-500 uppercase tracking-wider " > 성명 *</ label >
< input
type = " text " required
value = { formData . name }
onChange = {( e ) => setFormData ({ ... formData , name : e . target . value })}
className = " w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all font-bold "
placeholder = " 홍길동 "
/>
</ div >
< div className = " space-y-1.5 col-span-7 " >
< label className = " text-xs font-black text-slate-500 uppercase tracking-wider " > 아이디 *</ label >
< div className = " flex gap-2 " >
< input
type = " text " required
disabled = { editingMember }
value = { formData . member_id }
onChange = {( e ) => {
setFormData ({ ... formData , member_id : e . target . value });
setIsIdChecked ( false );
setIdCheckMessage ( '' );
}}
className = { `w-full px-4 py-3 bg-slate-50 border ${isIdChecked ? 'border-emerald-500 bg-emerald-50' : 'border-slate-200'} rounded-xl outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all font-mono disabled:opacity-50` }
placeholder = " sales_01 "
/>
{ ! editingMember && (
< button
type = " button "
onClick = { handleCheckId }
disabled = { isIdChecking || ! formData . member_id }
className = { ` px - 4 py - 2 rounded - xl font - bold transition - all whitespace - nowrap flex items - center gap - 2 $ {
isIdChecked
? 'bg-emerald-500 text-white'
: 'bg-slate-200 text-slate-600 hover:bg-slate-300 shadow-sm'
} ` }
>
{ isIdChecking ? (
< div className = " w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin " ></ div >
) : isIdChecked ? (
< LucideIcon name = " check " className = " w-4 h-4 " />
) : (
< LucideIcon name = " search " className = " w-4 h-4 " />
)}
{ isIdChecking ? '...' : isIdChecked ? '완료' : '중복 확인' }
</ button >
)}
</ div >
{ ! editingMember && idCheckMessage && (
< p className = { `text-[10px] font-bold ml-1 mt-1 flex items-center gap-1 ${isIdChecked ? 'text-emerald-600' : 'text-red-500'}` } >
{ idCheckMessage }
</ p >
)}
</ div >
2025-12-17 12:59:26 +09:00
</ div >
2025-12-21 19:19:02 +09:00
< div className = " grid grid-cols-2 gap-6 " >
< div className = " space-y-1.5 " >
< label className = " text-xs font-black text-slate-500 uppercase tracking-wider " > 역할 설정 </ label >
< select
value = { formData . role }
onChange = {( e ) => setFormData ({ ... formData , role : e . target . value })}
className = " w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all font-bold "
>
< option value = " sales_admin " > 영업관리 ( Sales Admin ) </ option >
< option value = " manager " > 매니저 ( Manager ) </ option >
</ select >
</ div >
< div className = " space-y-1.5 " >
< label className = " text-xs font-black text-slate-500 uppercase tracking-wider " > 상위 보스 </ label >
< select
value = { formData . parent_id }
onChange = {( e ) => setFormData ({ ... formData , parent_id : e . target . value })}
className = " w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all font-bold "
>
< option value = " " > 없음 ( 최상위 ) </ option >
{ members . filter ( m => m . id != editingMember ? . id ) . map ( m => (
< option key = { m . id } value = { m . id } > { m . name } ({ m . member_id }) </ option >
))}
</ select >
</ div >
</ div >
< div className = " space-y-1.5 " >
< label className = " text-xs font-black text-slate-500 uppercase tracking-wider " > 비밀번호 { editingMember && '(변경 시에만 입력)' } </ label >
< input
type = " password "
required = { ! editingMember }
2026-01-04 16:05:15 +09:00
autoComplete = " new-password "
2025-12-21 19:19:02 +09:00
value = { formData . password }
onChange = {( e ) => setFormData ({ ... formData , password : e . target . value })}
className = " w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all "
placeholder = { editingMember ? " ******** " : " 비밀번호 입력 " }
/>
</ div >
< div className = " grid grid-cols-2 gap-6 " >
< div className = " space-y-1.5 " >
< label className = " text-xs font-black text-slate-500 uppercase tracking-wider " > 연락처 </ label >
< input
type = " tel "
value = { formData . phone }
2025-12-23 09:00:57 +09:00
onChange = {( e ) => setFormData ({ ... formData , phone : formatPhone ( e . target . value )})}
2025-12-21 19:19:02 +09:00
className = " w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all font-medium "
placeholder = " 010-0000-0000 "
/>
</ div >
< div className = " space-y-1.5 " >
< label className = " text-xs font-black text-slate-500 uppercase tracking-wider " > 이메일 </ label >
< input
type = " email "
value = { formData . email }
onChange = {( e ) => setFormData ({ ... formData , email : e . target . value })}
className = " w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all font-medium "
placeholder = " example@email.com "
/>
</ div >
2025-12-17 12:59:26 +09:00
</ div >
</ div >
2025-12-21 19:19:02 +09:00
< div className = " p-8 border-t border-slate-100 bg-slate-50/50 flex justify-end gap-4 " >
< button type = " button " onClick = {() => setIsModalOpen ( false )} className = " px-6 py-3 text-slate-600 bg-white border border-slate-200 rounded-xl font-bold hover:bg-slate-50 transition-all " > 취소 </ button >
< button type = " submit "
disabled = { ! editingMember && ! isIdChecked }
className = { `px-8 py-3 bg-blue-600 text-white rounded-xl font-bold shadow-xl shadow-blue-100 hover:bg-blue-700 transition-all ${(!editingMember && !isIdChecked) ? 'opacity-50 cursor-not-allowed grayscale' : ''}` } >
{ editingMember ? '수정 완료' : '등록 하기' }
</ button >
</ div >
</ form >
</ div >
</ div >
)}
{ /* Manager Detail Modal (하위 멤버 목록) */ }
{ detailModalUser && (
< div className = " fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm " >
< div className = " bg-white rounded-3xl shadow-2xl w-full max-w-4xl overflow-hidden animate-in fade-in zoom-in duration-200 border border-slate-200 flex flex-col max-h-[80vh] " >
< div className = " p-8 border-b border-slate-100 flex justify-between items-center bg-slate-50/50 " >
< div >
< h3 className = " text-2xl font-black text-slate-900 flex items-center gap-3 " >
< div className = " p-2 bg-indigo-600 rounded-xl text-white " >
< LucideIcon name = " users " className = " w-6 h-6 " />
</ div >
{ detailModalUser . name } ({ detailModalUser . member_id }) 하위 멤버
</ h3 >
< p className = " text-slate-500 mt-1 " > 이 영업관리자에게 소속된 모든 매니저 목록입니다 .</ p >
</ div >
< button onClick = {() => setDetailModalUser ( null )} className = " p-2 hover:bg-slate-200 rounded-full transition-colors text-slate-400 " >
< LucideIcon name = " x " className = " w-6 h-6 " />
</ button >
</ div >
< div className = " overflow-y-auto p-8 " >
< table className = " w-full text-left text-sm " >
< thead className = " bg-slate-50 border-b border-slate-100 text-slate-500 font-bold uppercase text-xs " >
2025-12-17 12:59:26 +09:00
< tr >
2025-12-21 19:19:02 +09:00
< th className = " px-6 py-4 " > 성명 </ th >
< th className = " px-6 py-4 " > 아이디 </ th >
< th className = " px-6 py-4 " > 역할 </ th >
< th className = " px-6 py-4 " > 연락처 </ th >
< th className = " px-6 py-4 text-center " > 작업 </ th >
2025-12-17 12:59:26 +09:00
</ tr >
</ thead >
< tbody className = " divide-y divide-slate-100 " >
2025-12-21 19:19:02 +09:00
{ members . filter ( m => m . parent_id == detailModalUser . id ) . length === 0 ? (
< tr >
< td colSpan = " 5 " className = " px-6 py-12 text-center text-slate-400 " >
하위 멤버가 없습니다 .
2025-12-17 12:59:26 +09:00
</ td >
</ tr >
2025-12-21 19:19:02 +09:00
) : (
members . filter ( m => m . parent_id == detailModalUser . id ) . map ( m => (
< tr key = { m . id } className = " hover:bg-blue-50/30 transition-colors " >
< td className = " px-6 py-4 font-bold text-slate-900 " > { m . name } </ td >
< td className = " px-6 py-4 text-slate-600 font-mono text-xs " > { m . member_id } </ td >
< td className = " px-6 py-4 " >
< span className = " px-2 py-0.5 rounded-full text-[10px] bg-emerald-100 text-emerald-700 font-bold uppercase " >
매니저
</ span >
</ td >
< td className = " px-6 py-4 text-slate-600 " > { m . phone || '-' } </ td >
< td className = " px-6 py-4 text-center " >
< div className = " flex items-center justify-center gap-2 " >
< button
onClick = {() => {
setDetailModalUser ( null );
handleOpenEdit ( m );
}}
className = " p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors "
title = " 수정 "
>
< LucideIcon name = " edit-2 " className = " w-4 h-4 " />
</ button >
< button
onClick = {() => {
2025-12-21 19:19:14 +09:00
console . log ( '[OperatorView-DetailModal] Delete button clicked for:' , m . name );
2025-12-21 19:19:02 +09:00
handleMemberDelete ( m );
}}
className = " p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors "
title = " 삭제 "
>
< LucideIcon name = " trash-2 " className = " w-4 h-4 " />
</ button >
</ div >
</ td >
</ tr >
))
)}
2025-12-17 12:59:26 +09:00
</ tbody >
</ table >
</ div >
2025-12-21 19:19:02 +09:00
< div className = " p-8 border-t border-slate-100 bg-slate-50/50 flex justify-end " >
< button
onClick = {() => setDetailModalUser ( null )}
className = " px-8 py-3 bg-slate-200 text-slate-700 rounded-xl font-bold hover:bg-slate-300 transition-all "
2025-12-17 12:59:26 +09:00
>
2025-12-21 19:19:02 +09:00
닫기
</ button >
</ div >
2025-12-17 12:59:26 +09:00
</ div >
</ div >
)}
2025-12-21 19:19:02 +09:00
{ /* Delete Confirmation Modal */ }
{ deleteConfirmMember && (
< div className = " fixed inset-0 z-[110] flex items-center justify-center p-4 bg-slate-900/80 backdrop-blur-md animate-in fade-in duration-300 " >
< div className = " bg-white rounded-[2rem] shadow-2xl w-full max-w-md overflow-hidden animate-in zoom-in duration-200 border border-white/20 " >
< div className = " p-8 text-center " >
< div className = " w-20 h-20 bg-red-50 rounded-full flex items-center justify-center mx-auto mb-6 ring-8 ring-red-50/50 " >
< LucideIcon name = " trash-2 " className = " w-10 h-10 text-red-500 " />
</ div >
< h3 className = " text-2xl font-black text-slate-900 mb-3 " > 인력 삭제 확인 </ h3 >
< p className = " text-slate-600 leading-relaxed font-medium " >
정말 < span className = " text-red-600 font-bold " > '{deleteConfirmMember.name}({deleteConfirmMember.member_id})' </ span > 인력을 삭제하시겠습니까 ?
</ p >
{ members . some ( m => m . parent_id == deleteConfirmMember . id ) && (
< div className = " mt-4 p-4 bg-amber-50 border border-amber-100 rounded-2xl flex items-start gap-3 text-left " >
< LucideIcon name = " alert-triangle " className = " w-5 h-5 text-amber-600 shrink-0 mt-0.5 " />
< div >
< p className = " text-sm font-bold text-amber-900 " > 주의 사항 </ p >
< p className = " text-xs text-amber-800 mt-1 leading-normal " >
이 인력은 하위 담당자가 있습니다 . 삭제 시 상위 관리자 정보가 유실될 수 있습니다 .
</ p >
</ div >
</ div >
)}
</ div >
< div className = " p-6 bg-slate-50 flex gap-3 " >
< button
onClick = {() => setDeleteConfirmMember ( null )}
className = " flex-1 py-4 bg-white text-slate-600 rounded-2xl font-bold border border-slate-200 hover:bg-slate-100 transition-all active:scale-[0.98] "
>
취소
</ button >
< button
onClick = { executeDelete }
className = " flex-1 py-4 bg-red-600 text-white rounded-2xl font-bold shadow-lg shadow-red-200 hover:bg-red-700 transition-all active:scale-[0.98] "
>
삭제 실행
</ button >
</ div >
</ div >
</ div >
)}
2025-12-17 12:59:26 +09:00
</ main >
);
};
2025-12-23 09:00:57 +09:00
// 테넌트 계약 승인 관리 컴포넌트 (운영자 전용)
const TenantConfirmationManager = () => {
const [ tenants , setTenants ] = useState ([]);
const [ loading , setLoading ] = useState ( true );
const [ expandedTenantId , setExpandedTenantId ] = useState ( null );
const [ tenantProducts , setTenantProducts ] = useState ({});
useEffect (() => {
fetchTenants ();
}, []);
const fetchTenants = async () => {
try {
setLoading ( true );
const res = await fetch ( 'api/sales_tenants.php?action=list_tenants' );
const data = await res . json ();
if ( data . success ) setTenants ( data . data );
} catch ( err ) {
console . error ( 'Fetch tenants error:' , err );
} finally {
setLoading ( false );
}
};
// 테넌트 목록이 로드되면 모든 테넌트의 상품을 미리 로드하여 미승인 건수를 계산
useEffect (() => {
if ( tenants . length > 0 ) {
tenants . forEach ( t => {
if ( ! tenantProducts [ t . id ]) {
fetchProducts ( t . id );
}
});
}
}, [ tenants ]);
const fetchProducts = async ( tenantId ) => {
try {
const res = await fetch ( `api/sales_tenants.php?action=tenant_products&tenant_id=${tenantId}` );
const result = await res . json ();
if ( result . success ) {
setTenantProducts ( prev => ({ ... prev , [ tenantId ] : result . data }));
}
} catch ( err ) {
console . error ( 'Fetch products error:' , err );
}
};
const handleToggleTenant = ( tenantId ) => {
if ( expandedTenantId === tenantId ) {
setExpandedTenantId ( null );
} else {
setExpandedTenantId ( tenantId );
if ( ! tenantProducts [ tenantId ]) {
fetchProducts ( tenantId );
}
}
};
const handleConfirmProduct = async ( productId , currentStatus , tenantId ) => {
try {
const res = await fetch ( 'api/sales_tenants.php?action=confirm_product' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ id : productId , confirmed : ! currentStatus })
});
const result = await res . json ();
if ( result . success ) {
// 제품 목록 업데이트
fetchProducts ( tenantId );
} else {
alert ( result . error );
}
} catch ( err ) {
alert ( '승인 처리 중 오류가 발생했습니다.' );
}
};
const formatCurrency = ( val ) => new Intl . NumberFormat ( 'ko-KR' , { style : 'currency' , currency : 'KRW' }) . format ( val || 0 );
return (
< div className = " bg-white rounded-card shadow-sm border border-slate-100 overflow-hidden " >
< div className = " p-6 border-b border-slate-100 bg-slate-50/50 flex justify-between items-center " >
< h3 className = " text-xl font-bold text-slate-900 flex items-center gap-2 " >
< LucideIcon name = " clipboard-check " className = " w-5 h-5 text-emerald-600 " />
테넌트 계약 승인 정산 관리
</ h3 >
< button onClick = { fetchTenants } className = " p-2 text-slate-500 hover:bg-slate-100 rounded-lg transition-colors " >
< LucideIcon name = " refresh-cw " className = { `${loading ? 'animate-spin' : ''} w-5 h-5` } />
</ button >
</ div >
< div className = " overflow-x-auto text-sm " >
< table className = " w-full text-left " >
< thead className = " bg-slate-50 border-b border-slate-100 text-slate-500 font-bold uppercase text-xs " >
< tr >
< th className = " px-6 py-4 w-12 " ></ th >
< th className = " px-6 py-4 " > 테넌트명 </ th >
< th className = " px-6 py-4 " > 담당 영업자 </ th >
< th className = " px-6 py-4 text-center " > 미승인 건수 </ th >
< th className = " px-6 py-4 text-center " > 등록일 </ th >
</ tr >
</ thead >
< tbody className = " divide-y divide-slate-100 " >
{ loading && tenants . length === 0 ? (
< tr >< td colSpan = " 5 " className = " px-6 py-10 text-center text-slate-400 " > 로딩 중 ...</ td ></ tr >
) : tenants . length === 0 ? (
< tr >< td colSpan = " 5 " className = " px-6 py-10 text-center text-slate-400 " > 등록된 테넌트가 없습니다 .</ td ></ tr >
) : tenants . map ( t => (
< React . Fragment key = { t . id } >
< tr className = { `hover:bg-slate-50 transition-colors ${expandedTenantId === t.id ? 'bg-emerald-50/30' : ''}` } >
< td className = " px-6 py-4 " >
< button onClick = {() => handleToggleTenant ( t . id )} className = " text-slate-400 hover:text-emerald-600 " >
< LucideIcon name = { expandedTenantId === t . id ? " chevron-down " : " chevron-right " } className = " w-4 h-4 " />
</ button >
</ td >
< td className = " px-6 py-4 font-bold text-slate-900 " > { t . tenant_name } </ td >
< td className = " px-6 py-4 " >
< span className = " px-2 py-1 bg-blue-50 text-blue-600 rounded text-xs font-bold " >
{ t . manager_name }
</ span >
</ td >
< td className = " px-6 py-4 text-center " >
< span className = { ` font - mono font - bold $ {
(() => {
const products = tenantProducts [ t . id ];
if ( ! products ) return 'text-slate-300' ;
const unconfirmed = products . filter ( p => p . operator_confirmed == 0 ) . length ;
return unconfirmed > 0 ? 'text-red-500 animate-pulse' : 'text-slate-400' ;
})()
} ` } >
{(() => {
const products = tenantProducts [ t . id ];
if ( ! products ) return '...' ;
return products . filter ( p => p . operator_confirmed == 0 ) . length ;
})()} 건
</ span >
</ td >
< td className = " px-6 py-4 text-center text-slate-400 text-xs " > { t . created_at ? . split ( ' ' )[ 0 ]} </ td >
</ tr >
{ expandedTenantId === t . id && (
< tr >
< td colSpan = " 5 " className = " px-6 py-4 bg-slate-50/50 border-b border-emerald-100 " >
< div className = " pl-12 space-y-4 animate-in slide-in-from-top-1 duration-200 " >
< h4 className = " text-sm font-black text-slate-700 flex items-center gap-2 " >
< div className = " w-1.5 h-1.5 bg-emerald-500 rounded-full " ></ div >
계약 상품 승인 현황
</ h4 >
< div className = " bg-white rounded-xl border border-slate-200 overflow-hidden shadow-sm " >
< table className = " w-full text-xs " >
< thead className = " bg-slate-100 text-slate-600 font-bold border-b border-slate-200 " >
< tr >
< th className = " px-4 py-2 " > 상품명 </ th >
< th className = " px-4 py-2 text-right " > 계약금액 </ th >
< th className = " px-4 py-2 text-right " > 지급 수수료 </ th >
< th className = " px-4 py-2 text-center " > 계약일 </ th >
< th className = " px-4 py-2 text-center " > 승인 처리 </ th >
</ tr >
</ thead >
< tbody className = " divide-y divide-slate-100 " >
{ ! tenantProducts [ t . id ] ? (
< tr >< td colSpan = " 5 " className = " px-4 py-8 text-center text-slate-400 " > 로딩 중 ...</ td ></ tr >
) : tenantProducts [ t . id ] . length === 0 ? (
< tr >< td colSpan = " 5 " className = " px-4 py-8 text-center text-slate-400 " > 등록된 계약이 없습니다 .</ td ></ tr >
) : tenantProducts [ t . id ] . map ( p => (
< tr key = { p . id } className = " hover:bg-slate-50 transition-colors " >
< td className = " px-4 py-3 font-medium text-slate-800 " > { p . product_name } </ td >
< td className = " px-4 py-3 text-right text-slate-600 font-mono " > { formatCurrency ( p . contract_amount )} </ td >
< td className = " px-4 py-3 text-right font-bold text-blue-600 font-mono " > { formatCurrency ( p . commission_amount )} </ td >
< td className = " px-4 py-3 text-center text-slate-400 " > { p . contract_date } </ td >
< td className = " px-4 py-3 text-center " >
< button
onClick = {() => handleConfirmProduct ( p . id , p . operator_confirmed == 1 , t . id )}
className = { ` px - 4 py - 1.5 rounded - xl font - bold transition - all flex items - center gap - 1 border $ {
p . operator_confirmed == 1
? 'bg-emerald-600 text-white border-emerald-600 hover:bg-emerald-700 shadow-md shadow-emerald-100'
: 'bg-white text-slate-400 border-slate-200 hover:border-emerald-500 hover:text-emerald-600 hover:bg-emerald-50'
} ` }
>
< LucideIcon name = { p . operator_confirmed == 1 ? " check-circle " : " circle " } className = " w-4 h-4 " />
{ p . operator_confirmed == 1 ? '지급승인됨' : '지급승인' }
</ button >
</ td >
</ tr >
))}
</ tbody >
</ table >
</ div >
</ div >
</ td >
</ tr >
)}
</ React . Fragment >
))}
</ tbody >
</ table >
</ div >
</ div >
);
};
2025-12-17 12:59:26 +09:00
// 아이템 가격 관리 컴포넌트 (운영자 전용)
const ItemPricingManager = () => {
const [ pricingItems , setPricingItems ] = useState ([]);
const [ loading , setLoading ] = useState ( true );
const [ editModalOpen , setEditModalOpen ] = useState ( false );
const [ editingItem , setEditingItem ] = useState ( null );
useEffect (() => {
fetchPricingItems ();
}, []);
const fetchPricingItems = async () => {
try {
setLoading ( true );
const response = await fetch ( 'api/package_pricing.php?action=list' );
const result = await response . json ();
if ( result . success ) {
setPricingItems ( result . data );
}
} catch ( error ) {
console . error ( '가격 정보 로드 실패:' , error );
} finally {
setLoading ( false );
}
};
const handleEditItem = ( item ) => {
setEditingItem ({ ... item });
setEditModalOpen ( true );
};
const handleSaveItem = async () => {
if ( ! editingItem ) return ;
try {
const response = await fetch ( 'api/package_pricing.php' , {
method : 'PUT' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({
item_type : editingItem . item_type ,
item_id : editingItem . item_id ,
join_fee : editingItem . join_fee ,
subscription_fee : editingItem . subscription_fee ,
total_amount : editingItem . total_amount ,
allow_flexible_pricing : editingItem . allow_flexible_pricing ? 1 : 0
})
});
const result = await response . json ();
if ( result . success ) {
await fetchPricingItems ();
// 모달 닫기
setEditModalOpen ( false );
setTimeout (() => {
setEditingItem ( null );
}, 100 );
} else {
alert ( '저장에 실패했습니다: ' + ( result . error || '알 수 없는 오류' ));
}
} catch ( error ) {
console . error ( '저장 실패:' , error );
alert ( '저장 중 오류가 발생했습니다.' );
}
};
const handleCloseModal = () => {
// 모달 닫기 - 상태 변경만
setEditModalOpen ( false );
// editingItem은 약간의 딜레이 후 정리
setTimeout (() => {
setEditingItem ( null );
}, 100 );
};
const formatCurrency = ( val ) => new Intl . NumberFormat ( 'ko-KR' , { style : 'currency' , currency : 'KRW' }) . format ( val || 0 );
if ( loading ) {
return (
< div className = " text-center py-8 text-slate-500 " >
< LucideIcon name = " loader " className = " w-6 h-6 animate-spin mx-auto mb-2 " />
로딩 중 ...
</ div >
);
}
// 모델과 패키지 분리
const models = pricingItems . filter ( item => item . item_type === 'model' );
const packages = pricingItems . filter ( item => item . item_type === 'package' );
return (
< div className = " space-y-6 " >
{ /* 모델 카드 그리드 */ }
{ models . length > 0 && (
< div >
< h4 className = " text-lg font-semibold text-slate-700 mb-4 " > 선택모델 </ h4 >
< div className = " grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 " >
{ models . map ( item => (
< div key = { `${item.item_type}_${item.item_id}` } className = " bg-white rounded-lg p-5 shadow-sm border border-slate-200 hover:shadow-md transition-shadow " >
< div className = " flex items-start justify-between mb-3 " >
< div className = " flex-1 " >
< h5 className = " font-semibold text-slate-900 " > { item . item_name } </ h5 >
{ item . sub_name && (
< p className = " text-xs text-slate-500 mt-1 " > { item . sub_name } </ p >
)}
</ div >
< button
onClick = {() => handleEditItem ( item )}
className = " p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors "
title = " 설정 "
>
< LucideIcon name = " edit " className = " w-4 h-4 " />
</ button >
</ div >
< div className = " space-y-2 text-sm " >
< div className = " flex justify-between " >
< span className = " text-slate-600 " > 총액 :</ span >
< span className = " font-semibold text-blue-600 " >
{ item . total_amount ? formatCurrency ( item . total_amount ) : '미설정' }
</ span >
</ div >
< div className = " flex justify-between " >
< span className = " text-slate-600 " > 가입비 :</ span >
< span className = " text-slate-900 " > { formatCurrency ( item . join_fee )} </ span >
</ div >
< div className = " flex justify-between " >
< span className = " text-slate-600 " > 월 구독료 :</ span >
< span className = " text-slate-900 " > { formatCurrency ( item . subscription_fee )} </ span >
</ div >
< div className = " pt-2 border-t border-slate-100 " >
< div className = " flex items-center justify-between " >
< span className = " text-slate-600 " > 재량권 허용 :</ span >
< span className = { ` px - 2 py - 1 rounded - full text - xs font - medium $ {
item . allow_flexible_pricing
? 'bg-green-100 text-green-800'
: 'bg-slate-100 text-slate-600'
} ` } >
{ item . allow_flexible_pricing ? '허용' : '불가' }
</ span >
</ div >
</ div >
</ div >
</ div >
))}
</ div >
</ div >
)}
{ /* 패키지 카드 그리드 */ }
{ packages . length > 0 && (
< div >
< h4 className = " text-lg font-semibold text-slate-700 mb-4 " > 패키지 </ h4 >
< div className = " grid grid-cols-1 md:grid-cols-2 gap-4 " >
{ packages . map ( item => (
< div key = { `${item.item_type}_${item.item_id}` } className = " bg-white rounded-lg p-5 shadow-sm border border-slate-200 hover:shadow-md transition-shadow " >
< div className = " flex items-start justify-between mb-3 " >
< div className = " flex-1 " >
< h5 className = " font-semibold text-slate-900 " > { item . item_name } </ h5 >
{ item . sub_name && (
< p className = " text-xs text-slate-500 mt-1 " > { item . sub_name } </ p >
)}
</ div >
< button
onClick = {() => handleEditItem ( item )}
className = " p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors "
title = " 설정 "
>
< LucideIcon name = " edit " className = " w-4 h-4 " />
</ button >
</ div >
< div className = " space-y-2 text-sm " >
< div className = " flex justify-between " >
< span className = " text-slate-600 " > 총액 :</ span >
< span className = " font-semibold text-blue-600 " >
{ item . total_amount ? formatCurrency ( item . total_amount ) : '미설정' }
</ span >
</ div >
< div className = " flex justify-between " >
< span className = " text-slate-600 " > 가입비 :</ span >
< span className = " text-slate-900 " > { formatCurrency ( item . join_fee )} </ span >
</ div >
< div className = " flex justify-between " >
< span className = " text-slate-600 " > 월 구독료 :</ span >
< span className = " text-slate-900 " > { formatCurrency ( item . subscription_fee )} </ span >
</ div >
< div className = " pt-2 border-t border-slate-100 " >
< div className = " flex items-center justify-between " >
< span className = " text-slate-600 " > 재량권 허용 :</ span >
< span className = { ` px - 2 py - 1 rounded - full text - xs font - medium $ {
item . allow_flexible_pricing
? 'bg-green-100 text-green-800'
: 'bg-slate-100 text-slate-600'
} ` } >
{ item . allow_flexible_pricing ? '허용' : '불가' }
</ span >
</ div >
</ div >
</ div >
</ div >
))}
</ div >
</ div >
)}
{ /* 편집 모달 */ }
{ editModalOpen && editingItem && (
< div key = { `edit-${editingItem.item_type}-${editingItem.item_id}` } className = " fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm " onClick = { handleCloseModal } >
< div className = " bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden " onClick = { e => e . stopPropagation ()} >
< div className = " p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50 " >
< h3 className = " text-xl font-bold text-slate-900 flex items-center gap-2 " >
< LucideIcon name = " settings " className = " w-5 h-5 text-blue-600 " />
아이템 가격 설정
</ h3 >
< button onClick = { handleCloseModal } className = " p-2 hover:bg-slate-200 rounded-full transition-colors " >
< LucideIcon name = " x " className = " w-5 h-5 text-slate-500 " />
</ button >
</ div >
< div className = " p-6 space-y-4 " >
< div >
< label className = " block text-sm font-medium text-slate-700 mb-1 " > 항목명 </ label >
< div className = " text-base font-semibold text-slate-900 " > { editingItem . item_name } </ div >
{ editingItem . sub_name && (
< div className = " text-sm text-slate-500 mt-1 " > { editingItem . sub_name } </ div >
)}
</ div >
< div >
< label className = " block text-sm font-medium text-slate-700 mb-2 " >
총액 ( 원 ) < span className = " text-xs text-slate-500 " >- 영업담당이 이 금액을 기준으로 가입비 / 구독료를 조정할 수 있습니다 </ span >
</ label >
< input
type = " text "
value = { editingItem . total_amount ? editingItem . total_amount . toLocaleString ( 'ko-KR' ) : '' }
onChange = {( e ) => {
const value = e . target . value . replace ( / , / g , '' );
const numValue = value === '' ? null : parseFloat ( value );
if ( ! isNaN ( numValue ) || value === '' ) {
setEditingItem ( prev => ({
... prev ,
total_amount : numValue
}));
}
}}
className = " w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 "
placeholder = " 총액을 입력하세요 "
/>
</ div >
< div >
< label className = " block text-sm font-medium text-slate-700 mb-2 " > 현재 가입비 ( 원 ) </ label >
< input
type = " text "
value = {( editingItem . join_fee || 0 ) . toLocaleString ( 'ko-KR' )}
onChange = {( e ) => {
const value = e . target . value . replace ( / , / g , '' );
const numValue = value === '' ? 0 : parseFloat ( value );
if ( ! isNaN ( numValue ) || value === '' ) {
setEditingItem ( prev => ({
... prev ,
join_fee : numValue
}));
}
}}
className = " w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 "
placeholder = " 가입비를 입력하세요 "
/>
< p className = " text-xs text-slate-500 mt-1 " > 운영자가 설정한 기본 가입비 ( 영업담당이 재량권으로 조정 가능 ) </ p >
</ div >
< div >
< label className = " block text-sm font-medium text-slate-700 mb-2 " > 현재 월 구독료 ( 원 ) </ label >
< input
type = " text "
value = {( editingItem . subscription_fee || 0 ) . toLocaleString ( 'ko-KR' )}
onChange = {( e ) => {
const value = e . target . value . replace ( / , / g , '' );
const numValue = value === '' ? 0 : parseFloat ( value );
if ( ! isNaN ( numValue ) || value === '' ) {
setEditingItem ( prev => ({
... prev ,
subscription_fee : numValue
}));
}
}}
className = " w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 "
placeholder = " 월 구독료를 입력하세요 "
/>
< p className = " text-xs text-slate-500 mt-1 " > 운영자가 설정한 기본 월 구독료 ( 영업담당이 재량권으로 조정 가능 ) </ p >
</ div >
< div className = " pt-4 border-t border-slate-200 " >
< label className = " flex items-center gap-3 cursor-pointer " >
< input
type = " checkbox "
checked = { editingItem . allow_flexible_pricing || false }
onChange = {( e ) => setEditingItem ( prev => ({
... prev ,
allow_flexible_pricing : e . target . checked
}))}
className = " w-4 h-4 text-blue-600 border-slate-300 rounded focus:ring-blue-500 "
/>
< div >
< span className = " text-sm font-medium text-slate-700 " > 영업담당 재량권 허용 </ span >
< p className = " text-xs text-slate-500 mt-1 " >
체크 시 영업담당이 총액 범위 내에서 가입비와 구독료를 자유롭게 조정할 수 있습니다 .
</ p >
</ div >
</ label >
</ div >
</ div >
< div className = " p-6 border-t border-slate-100 bg-slate-50 flex justify-end gap-3 " >
< button
onClick = { handleCloseModal }
className = " px-4 py-2 text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 font-medium transition-colors "
>
취소
</ button >
< button
onClick = { handleSaveItem }
className = " px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors shadow-sm "
>
저장
</ button >
</ div >
</ div >
</ div >
)}
</ div >
);
};
2025-12-23 09:00:57 +09:00
2025-12-17 12:59:26 +09:00
2025-12-21 19:19:02 +09:00
// --- NEW: Login View Component ---
2025-12-24 09:46:07 +09:00
const LoginView = ({ onLoginSuccess }) => {
2025-12-21 19:19:02 +09:00
const [ memberId , setMemberId ] = useState ( '' );
const [ password , setPassword ] = useState ( '' );
const [ error , setError ] = useState ( '' );
const [ loading , setLoading ] = useState ( false );
2026-01-01 22:33:40 +09:00
const executeLogin = async ( id , pw ) => {
2025-12-21 19:19:02 +09:00
setLoading ( true );
setError ( '' );
try {
const res = await fetch ( 'api/sales_members.php?action=login' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
2026-01-01 22:33:40 +09:00
body : JSON . stringify ({ member_id : id , password : pw })
2025-12-21 19:19:02 +09:00
});
const data = await res . json ();
if ( data . success ) {
if ( data . message && ! data . user ) {
2025-12-24 09:46:07 +09:00
alert ( data . message ); // Admin/Manager creation notice
2025-12-21 19:19:02 +09:00
} else {
onLoginSuccess ( data . user );
}
} else {
2025-12-24 09:46:07 +09:00
setError ( data . error || '아이디 또는 비밀번호가 일치하지 않습니다.' );
2025-12-21 19:19:02 +09:00
}
} catch ( err ) {
setError ( '서버 통신 오류가 발생했습니다.' );
} finally {
setLoading ( false );
}
};
2026-01-01 22:33:40 +09:00
const handleLogin = ( e ) => {
e . preventDefault ();
executeLogin ( memberId , password );
};
const handleAutoLogin = ( id , pw ) => {
setMemberId ( id );
setPassword ( pw );
executeLogin ( id , pw );
};
2025-12-21 19:19:02 +09:00
return (
< div className = " min-h-[calc(100vh-64px)] flex items-center justify-center p-4 " >
< div className = " max-w-md w-full bg-white rounded-2xl shadow-xl border border-slate-100 p-8 " >
< div className = " text-center mb-8 " >
2025-12-24 09:46:07 +09:00
< div className = " w-16 h-16 bg-blue-600 rounded-full flex items-center justify-center mx-auto mb-4 text-white shadow-lg shadow-blue-100 " >
< LucideIcon name = " lock " className = " w-8 h-8 " />
2025-12-21 19:19:02 +09:00
</ div >
2025-12-24 09:46:07 +09:00
< h2 className = " text-3xl font-black text-slate-900 tracking-tight " > 영업관리 통합 로그인 </ h2 >
< p className = " text-slate-500 mt-2 font-medium " > 서비스 이용을 위해 로그인해주세요 .</ p >
2025-12-21 19:19:02 +09:00
</ div >
< form onSubmit = { handleLogin } className = " space-y-6 " >
< div >
2025-12-24 09:46:07 +09:00
< label className = " block text-xs font-black text-slate-500 uppercase tracking-wider mb-1.5 ml-1 " > 아이디 </ label >
2025-12-21 19:19:02 +09:00
< input
type = " text "
2026-01-04 17:18:17 +09:00
id = " username "
name = " username "
2025-12-21 19:19:02 +09:00
required
2026-01-04 17:11:43 +09:00
autoComplete = " username "
2025-12-21 19:19:02 +09:00
value = { memberId }
onChange = {( e ) => setMemberId ( e . target . value )}
2025-12-24 09:46:07 +09:00
className = " w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all font-medium "
2025-12-21 19:19:02 +09:00
placeholder = " 아이디를 입력하세요 "
/>
</ div >
< div >
2025-12-24 09:46:07 +09:00
< label className = " block text-xs font-black text-slate-500 uppercase tracking-wider mb-1.5 ml-1 " > 비밀번호 </ label >
2025-12-21 19:19:02 +09:00
< input
type = " password "
2026-01-04 17:18:17 +09:00
id = " password "
name = " password "
2025-12-21 19:19:02 +09:00
required
2026-01-04 16:05:15 +09:00
autoComplete = " current-password "
2025-12-21 19:19:02 +09:00
value = { password }
onChange = {( e ) => setPassword ( e . target . value )}
2025-12-24 09:46:07 +09:00
className = " w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all font-medium "
2025-12-21 19:19:02 +09:00
placeholder = " 비밀번호를 입력하세요 "
/>
</ div >
{ error && (
2025-12-24 09:46:07 +09:00
< div className = " p-4 bg-red-50 border border-red-100 rounded-xl text-sm text-red-600 font-bold flex items-center gap-2 animate-shake " >
< LucideIcon name = " alert-circle " className = " w-4 h-4 " />
2025-12-21 19:19:02 +09:00
{ error }
</ div >
)}
< button
type = " submit "
disabled = { loading }
2025-12-24 09:46:07 +09:00
className = " w-full py-4 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold shadow-xl shadow-blue-200 transition-all transform active:scale-[0.98] disabled:opacity-50 flex items-center justify-center gap-2 text-lg "
2025-12-21 19:19:02 +09:00
>
2025-12-24 09:46:07 +09:00
{ loading ? (
< div className = " w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin " ></ div >
) : (
<>
< LucideIcon name = " log-in " className = " w-5 h-5 " />
로그인
</>
)}
2025-12-21 19:19:02 +09:00
</ button >
</ form >
2025-12-24 09:46:07 +09:00
< div className = " mt-10 pt-8 border-t border-slate-100 " >
< h4 className = " text-[10px] font-black text-slate-400 uppercase tracking-widest text-center mb-4 " > 테스트용 계정 정보 </ h4 >
< div className = " grid grid-cols-1 gap-2 " >
2026-01-01 22:33:40 +09:00
< div
onClick = {() => handleAutoLogin ( 'admin' , 'admin' )}
className = " flex items-center justify-between p-3 bg-slate-50 rounded-xl border border-slate-100 cursor-pointer hover:bg-blue-50 hover:border-blue-200 transition-all group shadow-sm "
>
< span className = " text-xs font-bold text-slate-500 group-hover:text-blue-600 " > 운영자 </ span >
< span className = " text-xs font-mono text-slate-600 group-hover:text-blue-500 " > admin / admin </ span >
2025-12-24 09:46:07 +09:00
</ div >
2026-01-01 22:33:40 +09:00
< div
onClick = {() => handleAutoLogin ( 'sales' , 'sales' )}
className = " flex items-center justify-between p-3 bg-slate-50 rounded-xl border border-slate-100 cursor-pointer hover:bg-blue-50 hover:border-blue-200 transition-all group shadow-sm "
>
< span className = " text-xs font-bold text-slate-500 group-hover:text-blue-600 " > 영업관리 </ span >
< span className = " text-xs font-mono text-slate-600 group-hover:text-blue-500 " > sales / sales </ span >
2025-12-24 09:46:07 +09:00
</ div >
2026-01-01 22:33:40 +09:00
< div
onClick = {() => handleAutoLogin ( 'manager' , 'manager' )}
className = " flex items-center justify-between p-3 bg-slate-50 rounded-xl border border-slate-100 cursor-pointer hover:bg-blue-50 hover:border-blue-200 transition-all group shadow-sm "
>
< span className = " text-xs font-bold text-slate-500 group-hover:text-blue-600 " > 매니저 </ span >
< span className = " text-xs font-mono text-slate-600 group-hover:text-blue-500 " > manager / manager </ span >
2025-12-24 09:46:07 +09:00
</ div >
</ div >
2025-12-21 19:19:02 +09:00
</ div >
</ div >
</ div >
);
};
// --- NEW: Manager Management View (CRUD) ---
const ManagerManagementView = ({ currentUser }) => {
const [ members , setMembers ] = useState ([]);
const [ loading , setLoading ] = useState ( true );
const [ isModalOpen , setIsModalOpen ] = useState ( false );
const [ editingMember , setEditingMember ] = useState ( null );
const [ isIdChecked , setIsIdChecked ] = useState ( false );
const [ isIdChecking , setIsIdChecking ] = useState ( false );
const [ idCheckMessage , setIdCheckMessage ] = useState ( '' );
const [ deleteConfirmMember , setDeleteConfirmMember ] = useState ( null );
// Form states
const [ formData , setFormData ] = useState ({
member_id : '' ,
password : '' ,
name : '' ,
phone : '' ,
email : '' ,
remarks : ''
});
2025-12-24 09:46:07 +09:00
const fillRandomManagerData = () => {
const sampleNames = [ '한효주' , '공유' , '이동욱' , '김고은' , '신세경' , '박서준' , '김수현' , '수지' , '남주혁' ];
const sampleIds = [ 'sales_pro' , 'mkt_king' , 'deal_maker' , 'win_win' , 'growth_lab' , 'biz_hero' ];
const randomItem = ( arr ) => arr [ Math . floor ( Math . random () * arr . length )];
const randomNum = ( len ) => Array . from ({ length : len }, () => Math . floor ( Math . random () * 10 )) . join ( '' );
const name = randomItem ( sampleNames );
const idSuffix = randomNum ( 3 );
setFormData ({
member_id : randomItem ( sampleIds ) + idSuffix ,
password : 'password123' ,
name : name ,
phone : formatPhone ( '010' + randomNum ( 8 )),
email : `manager_${randomNum(4)}@example.com` ,
remarks : '영업관리 앱 생성 샘플 데이터'
});
setIsIdChecked ( true );
setIdCheckMessage ( '신규 등록 시 아이디 중복 확인 권장' );
};
2025-12-21 19:19:02 +09:00
useEffect (() => {
if ( currentUser ) {
fetchMembers ();
}
}, [ currentUser ]);
const fetchMembers = async () => {
if ( ! currentUser ) return ;
setLoading ( true );
try {
const res = await fetch ( `api/sales_members.php?action=list&parent_id=${currentUser.id}` );
const data = await res . json ();
if ( data . success ) setMembers ( data . data );
} catch ( err ) {
console . error ( 'Fetch error:' , err );
} finally {
setLoading ( false );
}
};
const handleOpenAdd = () => {
setEditingMember ( null );
setFormData ({ member_id : '' , password : '' , name : '' , phone : '' , email : '' , remarks : '' });
setIsIdChecked ( false );
setIdCheckMessage ( '' );
setIsModalOpen ( true );
};
const handleCheckId = async () => {
if ( ! formData . member_id ) {
setIdCheckMessage ( '아이디를 입력해주세요.' );
return ;
}
setIsIdChecking ( true );
setIdCheckMessage ( '' );
try {
const res = await fetch ( `api/sales_members.php?action=check_id` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ member_id : formData . member_id })
});
const result = await res . json ();
if ( result . success ) {
if ( result . exists ) {
setIdCheckMessage ( '이미 사용 중인 아이디입니다.' );
setIsIdChecked ( false );
} else {
setIdCheckMessage ( '사용 가능한 아이디입니다.' );
setIsIdChecked ( true );
}
} else {
setIdCheckMessage ( result . error || '확인 실패' );
}
} catch ( err ) {
setIdCheckMessage ( '오류가 발생했습니다.' );
} finally {
setIsIdChecking ( false );
}
};
const handleOpenEdit = ( member ) => {
setEditingMember ( member );
setFormData ({
member_id : member . member_id ,
password : '' , // 비밀번호는 비워둠 (변경 시만 입력)
name : member . name ,
phone : member . phone || '' ,
email : member . email || '' ,
remarks : member . remarks || ''
});
setIsIdChecked ( true ); // 수정 시에는 이미 존재하므로 체크 완료 상태로 설정
setIsModalOpen ( true );
};
const handleSubmit = async ( e ) => {
e . preventDefault ();
const action = editingMember ? 'update' : 'create' ;
const method = editingMember ? 'PUT' : 'POST' ;
try {
const res = await fetch ( `api/sales_members.php${action === 'create' ? '?action=create' : ''}` , {
method : method ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({
... formData ,
id : editingMember ? . id
})
});
const result = await res . json ();
if ( result . success ) {
alert ( result . message );
setIsModalOpen ( false );
fetchMembers ();
} else {
alert ( result . error );
}
} catch ( err ) {
alert ( '저장 중 오류가 발생했습니다.' );
}
};
const handleDelete = ( member ) => {
console . log ( '[ManagerManagementView] handleDelete triggered for:' , member . name , member . id );
setDeleteConfirmMember ( member );
};
const executeDelete = async () => {
const member = deleteConfirmMember ;
if ( ! member ) return ;
try {
const res = await fetch ( `api/sales_members.php?action=delete` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ id : member . id })
});
const result = await res . json ();
if ( result . success ) {
alert ( result . message || '삭제되었습니다.' );
setDeleteConfirmMember ( null );
fetchMembers ();
} else {
alert ( result . error || '삭제에 실패했습니다.' );
}
} catch ( err ) {
console . error ( 'Delete error:' , err );
alert ( '삭제 중 오류가 발생했습니다.' );
}
};
return (
< div className = " space-y-6 " >
< div className = " flex items-center justify-between " >
< div >
< h2 className = " text-2xl font-bold text-slate-900 " > 내 하위 담당자 관리 </ h2 >
< p className = " text-slate-500 text-sm mt-1 " > 내가 직접 영입하여 관리하는 팀원들의 정보를 관리합니다 .</ p >
</ div >
< button
onClick = { handleOpenAdd }
className = " bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-bold flex items-center gap-2 transition-all shadow-lg shadow-blue-100 "
>
< LucideIcon name = " plus " className = " w-4 h-4 " />
담당자 등록
</ button >
</ div >
< div className = " bg-white rounded-card shadow-sm border border-slate-100 overflow-hidden text-sm " >
< table className = " w-full text-left " >
< thead className = " bg-slate-50 border-b border-slate-100 text-slate-500 font-bold uppercase text-xs " >
< tr >
< th className = " px-6 py-4 " > 성명 </ th >
< th className = " px-6 py-4 " > 아이디 </ th >
< th className = " px-6 py-4 " > 연락처 </ th >
< th className = " px-6 py-4 " > 이메일 </ th >
< th className = " px-6 py-4 " > 비고 </ th >
< th className = " px-6 py-4 " > 등록일 </ th >
< th className = " px-6 py-4 text-center " > 관리 </ th >
</ tr >
</ thead >
< tbody className = " divide-y divide-slate-100 " >
{ loading ? (
< tr >< td colSpan = " 7 " className = " px-6 py-10 text-center text-slate-400 " > 데이터 로딩 중 ...</ td ></ tr >
) : members . length === 0 ? (
< tr >< td colSpan = " 7 " className = " px-6 py-10 text-center text-slate-400 " > 등록된 담당자가 없습니다 .</ td ></ tr >
) : members . map ( m => (
< tr key = { m . id } className = " hover:bg-slate-50 transition-colors " >
< td className = " px-6 py-4 font-bold text-slate-900 " > { m . name } </ td >
< td className = " px-6 py-4 text-slate-600 " > { m . member_id } </ td >
2025-12-23 09:19:59 +09:00
< td className = " px-6 py-4 text-slate-600 " > { formatPhone ( m . phone ) || '-' } </ td >
2025-12-21 19:19:02 +09:00
< td className = " px-6 py-4 text-slate-600 " > { m . email || '-' } </ td >
< td className = " px-6 py-4 text-slate-500 italic max-w-xs truncate " > { m . remarks || '-' } </ td >
< td className = " px-6 py-4 text-slate-400 text-xs " > { m . created_at ? . split ( ' ' )[ 0 ]} </ td >
< td className = " px-6 py-4 " >
< div className = " flex items-center justify-center gap-2 " >
< button onClick = {() => handleOpenEdit ( m )} className = " p-2 text-blue-600 hover:bg-blue-50 rounded-lg " title = " 수정 " >
< LucideIcon name = " edit-2 " className = " w-4 h-4 " />
</ button >
< button
onClick = {() => {
console . log ( '[ManagerManagementView] Delete button clicked for:' , m . name );
handleDelete ( m );
}}
className = " p-2 text-red-600 hover:bg-red-50 rounded-lg "
title = " 삭제 "
>
< LucideIcon name = " trash-2 " className = " w-4 h-4 " />
</ button >
</ div >
</ td >
</ tr >
))}
</ tbody >
</ table >
</ div >
{ /* CRUD Modal */ }
{ isModalOpen && (
< div className = " fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm " >
< div className = " bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in duration-200 " >
< form onSubmit = { handleSubmit } >
< div className = " p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50 " >
< h3 className = " text-xl font-bold text-slate-900 flex items-center gap-2 " >
< LucideIcon name = { editingMember ? " edit " : " user-plus " } className = " w-5 h-5 text-blue-600 " />
{ editingMember ? '담당자 정보 수정' : '신규 담당자 등록' }
2025-12-24 09:46:07 +09:00
{ ! editingMember && (
< button
type = " button "
onClick = { fillRandomManagerData }
className = " ml-2 p-1.5 bg-amber-50 text-amber-600 rounded-lg hover:bg-amber-100 transition-all border border-amber-200 group relative "
title = " 샘플 데이터 자동 입력 "
>
< LucideIcon name = " zap " className = " w-3.5 h-3.5 " />
< span className = " absolute -top-10 left-1/2 -translate-x-1/2 bg-slate-800 text-white text-[10px] px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-50 shadow-xl " > 랜덤 데이터 채우기 </ span >
</ button >
)}
2025-12-21 19:19:02 +09:00
</ h3 >
< button type = " button " onClick = {() => setIsModalOpen ( false )} className = " p-2 hover:bg-slate-200 rounded-full transition-colors " >
< LucideIcon name = " x " className = " w-5 h-5 text-slate-500 " />
</ button >
</ div >
< div className = " p-6 space-y-4 " >
< div className = " grid grid-cols-10 gap-4 " >
< div className = " col-span-3 " >
< label className = " block text-xs font-bold text-slate-500 mb-1 " > 성명 *</ label >
< input
type = " text " required
value = { formData . name }
onChange = {( e ) => setFormData ({ ... formData , name : e . target . value })}
className = " w-full px-3 py-2 border border-slate-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 "
/>
</ div >
< div className = " col-span-7 " >
< label className = " block text-xs font-bold text-slate-500 mb-1 " > 아이디 *</ label >
< div className = " flex gap-2 " >
< input
type = " text " required
disabled = { editingMember }
value = { formData . member_id }
onChange = {( e ) => {
setFormData ({ ... formData , member_id : e . target . value });
setIsIdChecked ( false );
setIdCheckMessage ( '' );
}}
className = { `w-full px-3 py-2 border ${isIdChecked ? 'border-emerald-500 bg-emerald-50' : 'border-slate-200'} rounded-lg outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-slate-50 font-mono` }
/>
{ ! editingMember && (
< button
type = " button "
onClick = { handleCheckId }
disabled = { isIdChecking || ! formData . member_id }
className = { ` px - 3 py - 1 rounded - lg font - bold transition - all whitespace - nowrap text - xs flex items - center gap - 1 $ {
isIdChecked
? 'bg-emerald-500 text-white'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200 shadow-sm border border-slate-200'
} ` }
>
{ isIdChecking ? '...' : isIdChecked ? '완료' : '중복 확인' }
</ button >
)}
</ div >
{ ! editingMember && idCheckMessage && (
< p className = { `text-[10px] font-bold ml-1 mt-1 ${isIdChecked ? 'text-emerald-600' : 'text-red-500'}` } > { idCheckMessage } </ p >
)}
</ div >
</ div >
< div >
< label className = " block text-xs font-bold text-slate-500 mb-1 " > 비밀번호 { editingMember && '(변경 시에만 입력)' } </ label >
< input
type = " password "
required = { ! editingMember }
2026-01-04 16:05:15 +09:00
autoComplete = " new-password "
2025-12-21 19:19:02 +09:00
value = { formData . password }
onChange = {( e ) => setFormData ({ ... formData , password : e . target . value })}
className = " w-full px-3 py-2 border border-slate-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 "
placeholder = { editingMember ? " ******** " : " 비밀번호 입력 " }
/>
</ div >
< div >
< label className = " block text-xs font-bold text-slate-500 mb-1 " > 연락처 </ label >
< input
type = " tel "
value = { formData . phone }
2025-12-23 09:00:57 +09:00
onChange = {( e ) => setFormData ({ ... formData , phone : formatPhone ( e . target . value )})}
2025-12-21 19:19:02 +09:00
className = " w-full px-3 py-2 border border-slate-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 "
placeholder = " 010-0000-0000 "
/>
</ div >
< div >
< label className = " block text-xs font-bold text-slate-500 mb-1 " > 이메일 </ label >
< input
type = " email "
value = { formData . email }
onChange = {( e ) => setFormData ({ ... formData , email : e . target . value })}
className = " w-full px-3 py-2 border border-slate-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 "
placeholder = " example@email.com "
/>
</ div >
< div >
< label className = " block text-xs font-bold text-slate-500 mb-1 " > 비고 </ label >
< textarea
value = { formData . remarks }
onChange = {( e ) => setFormData ({ ... formData , remarks : e . target . value })}
className = " w-full px-3 py-2 border border-slate-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 h-20 resize-none "
/>
</ div >
</ div >
< div className = " p-6 border-t border-slate-100 bg-slate-50 flex justify-end gap-3 " >
< button type = " button " onClick = {() => setIsModalOpen ( false )} className = " px-4 py-2 text-slate-700 bg-white border border-slate-300 rounded-lg font-medium " > 취소 </ button >
< button type = " submit "
disabled = { ! editingMember && ! isIdChecked }
className = { `px-6 py-2 bg-blue-600 text-white rounded-lg font-bold shadow-lg shadow-blue-100 ${(!editingMember && !isIdChecked) ? 'opacity-50 grayscale cursor-not-allowed' : 'hover:bg-blue-700 transition-all'}` } > 저장 </ button >
</ div >
</ form >
</ div >
</ div >
)}
2025-12-21 19:19:14 +09:00
{ /* Delete Confirmation Modal for ManagerManagementView */ }
{ deleteConfirmMember && (
< div className = " fixed inset-0 z-[110] flex items-center justify-center p-4 bg-slate-900/80 backdrop-blur-md animate-in fade-in duration-300 " >
< div className = " bg-white rounded-[2rem] shadow-2xl w-full max-w-md overflow-hidden animate-in zoom-in duration-200 border border-white/20 " >
< div className = " p-8 text-center " >
< div className = " w-20 h-20 bg-red-50 rounded-full flex items-center justify-center mx-auto mb-6 ring-8 ring-red-50/50 " >
< LucideIcon name = " trash-2 " className = " w-10 h-10 text-red-500 " />
</ div >
< h3 className = " text-2xl font-black text-slate-900 mb-3 " > 담당자 삭제 확인 </ h3 >
< p className = " text-slate-600 leading-relaxed font-medium " >
정말 < span className = " text-red-600 font-bold " > '{deleteConfirmMember.name}({deleteConfirmMember.member_id})' </ span > 담당자를 삭제하시겠습니까 ?
</ p >
< div className = " p-6 bg-slate-50 flex gap-3 mt-4 " >
< button
onClick = {() => setDeleteConfirmMember ( null )}
className = " flex-1 py-4 bg-white text-slate-600 rounded-2xl font-bold border border-slate-200 "
>
취소
</ button >
< button
onClick = { executeDelete }
className = " flex-1 py-4 bg-red-600 text-white rounded-2xl font-bold shadow-lg shadow-red-200 "
>
삭제 실행
</ button >
</ div >
</ div >
</ div >
</ div >
)}
2025-12-21 19:19:02 +09:00
</ div >
);
};
2025-12-24 09:46:07 +09:00
// --- Data: Sales Scenario Steps (영업관리용) ---
const SALES_SCENARIO_STEPS = [
{ id : 1 , title : " 사전 준비 " , subtitle : " Preparation " , icon : " search " , color : " bg-blue-100 text-blue-600 " , description : " 고객사를 만나기 전, 철저한 분석을 통해 성공 확률을 높이는 단계입니다. " , checkpoints : [
{ title : " 고객사 심층 분석 " , detail : " 홈페이지, 뉴스 등을 통해 이슈와 비전을 파악하세요. " , pro_tip : " 직원들의 불만 사항을 미리 파악하면 미팅 시 강력한 무기가 됩니다. " },
{ title : " 재무 건전성 확인 " , detail : " 매출액, 영업이익 추이를 확인하고 IT 투자 여력을 가늠해보세요. " , pro_tip : " 성장 추세라면 '확장성'과 '관리 효율'을 강조하세요. " },
{ title : " 경쟁사 및 시장 동향 " , detail : " 핵심 기능에 집중하여 도입 속도가 빠르다는 점을 정리하세요. " , pro_tip : " 경쟁사를 비방하기보다 차별화된 가치를 제시하세요. " },
{ title : " 가설 수립 (Hypothesis) " , detail : " 구체적인 페인포인트 가설을 세우고 질문을 준비하세요. " , pro_tip : " '만약 ~하다면' 화법으로 고객의 'Yes'를 유도하세요. " }
]},
{ id : 2 , title : " 접근 및 탐색 " , subtitle : " Approach " , icon : " phone-call " , color : " bg-indigo-100 text-indigo-600 " , description : " 담당자와의 첫 접점을 만들고, 미팅 기회를 확보하는 단계입니다. " , checkpoints : [
{ title : " Key-man 식별 및 컨택 " , detail : " 실무 책임자(팀장급)와 의사결정권자(임원급) 라인을 파악하세요. " , pro_tip : " 전달드릴 자료가 있다고 하여 Gatekeeper를 통과하세요. " },
{ title : " 맞춤형 콜드메일/콜 " , detail : " 사전 조사 내용을 바탕으로 해결 방안을 제안하세요. " , pro_tip : " 제목에 고객사 이름을 넣어 클릭률을 높이세요. " },
{ title : " 미팅 일정 확정 " , detail : " 인사이트 공유를 목적으로 미팅을 제안하세요. " , pro_tip : " 두 가지 시간대를 제시하여 양자택일을 유도하세요. " }
]},
{ id : 3 , title : " 현장 진단 " , subtitle : " Diagnosis " , icon : " stethoscope " , color : " bg-purple-100 text-purple-600 " , description : " 고객의 업무 현장을 직접 확인하고 진짜 문제를 찾아내는 단계입니다. " , checkpoints : [
{ title : " AS-IS 프로세스 맵핑 " , detail : " 고객과 함께 업무 흐름도를 그리며 병목을 찾으세요. " , pro_tip : " 고객 스스로 문제를 깨닫게 하는 것이 가장 효과적입니다. " },
{ title : " 비효율/리스크 식별 " , detail : " 데이터 누락, 중복 입력 등 리스크를 수치화하세요. " , pro_tip : " 불편함을 시간과 비용으로 환산하여 설명하세요. " },
{ title : " To-Be 이미지 스케치 " , detail : " 도입 후 업무가 어떻게 간소화될지 시각화하세요. " , pro_tip : " 비포/애프터의 극명한 차이를 보여주세요. " }
]},
{ id : 4 , title : " 솔루션 제안 " , subtitle : " Proposal " , icon : " presentation " , color : " bg-pink-100 text-pink-600 " , description : " SAM을 통해 고객의 문제를 어떻게 해결할 수 있는지 증명하는 단계입니다. " , checkpoints : [
{ title : " 맞춤형 데모 시연 " , detail : " 핵심 기능을 위주로 고객사 데이터를 넣어 시연하세요. " , pro_tip : " 고객사 로고를 넣어 '이미 우리 것'이라는 느낌을 주세요. " },
{ title : " ROI 분석 보고서 " , detail : " 비용 대비 절감 가능한 수치를 산출하여 증명하세요. " , pro_tip : " 보수적인 ROI가 훨씬 더 높은 신뢰를 줍니다. " },
{ title : " 단계별 도입 로드맵 " , detail : " 부담을 줄이기 위해 단계적 확산 방안을 제시하세요. " , pro_tip : " 1단계는 핵심 문제 해결에만 집중하세요. " }
]},
{ id : 5 , title : " 협상 및 조율 " , subtitle : " Negotiation " , icon : " scale " , color : " bg-orange-100 text-orange-600 " , description : " 도입을 가로막는 장애물을 제거하고 조건을 합의하는 단계입니다. " , checkpoints : [
{ title : " 가격/조건 협상 " , detail : " 할인 대신 범위나 기간 조정 등으로 합의하세요. " , pro_tip : " Give & Take 원칙을 지키며 기대를 관리하세요. " },
{ title : " 의사결정권자 설득 " , detail : " CEO/CFO의 관심사에 맞는 보고용 장표를 제공하세요. " , pro_tip : " 실무자가 내부 보고 사업을 잘하게 돕는 것이 핵심입니다. " }
]},
{ id : 6 , title : " 계약 체결 " , subtitle : " Closing " , icon : " pen-tool " , color : " bg-green-100 text-green-600 " , description : " 공식적인 파트너십을 맺고 법적 효력을 발생시키는 단계입니다. " , checkpoints : [
{ title : " 계약서 날인 및 교부 " , detail : " 전자계약 등을 통해 체결 시간을 단축하세요. " , pro_tip : " 원본은 항상 안전하게 보관하고 백업하세요. " },
{ title : " 세금계산서 발행 " , detail : " 정확한 수금 일정을 확인하고 발행하세요. " , pro_tip : " 가입비 입금이 완료되어야 다음 단계가 시작됩니다. " },
{ title : " 계약 완료 (확정) " , detail : " 축하 인사를 전하고 후속 지원 일정을 잡으세요. " , pro_tip : " 계약은 진정한 서비스의 시작임을 강조하세요. " }
]}
];
// --- Data: Manager Scenario Steps (매니저용/프로젝트관리) ---
// --- Data: Manager Scenario Steps (매니저용/프로젝트관리) ---
const MANAGER_SCENARIO_STEPS = [
{ id : 1 , title : " 영업 이관 " , subtitle : " Handover " , icon : " file-input " , color : " bg-blue-100 text-blue-600 " , description : " 영업팀으로부터 고객 정보를 전달받고, 프로젝트의 배경과 핵심 요구사항을 파악하는 단계입니다. " , checkpoints : [
{ title : " 영업 히스토리 리뷰 " , detail : " 영업 담당자가 작성한 미팅록, 고객의 페인포인트, 예산 범위, 예상 일정 등을 꼼꼼히 확인하세요. " , pro_tip : " 영업 담당자에게 '고객이 가장 꽂힌 포인트'가 무엇인지 꼭 물어보세요. 그게 프로젝트의 CSF입니다. " },
{ title : " 고객사 기본 정보 파악 " , detail : " 고객사의 업종, 규모, 주요 경쟁사 등을 파악하여 커뮤니케이션 톤앤매너를 준비하세요. " , pro_tip : " IT 지식이 부족한 고객이라면 전문 용어 사용을 자제하고 쉬운 비유를 준비해야 합니다. " },
{ title : " RFP/요구사항 문서 분석 " , detail : " 고객이 전달한 요구사항 문서(RFP 등)가 있다면 기술적으로 실현 가능한지 1차 검토하세요. " , pro_tip : " 모호한 문장을 찾아내어 구체적인 수치나 기능으로 정의할 준비를 하세요. " },
{ title : " 내부 킥오프 (영업-매니저) " , detail : " 영업팀과 함께 프로젝트의 리스크 요인(까다로운 담당자 등)을 사전에 공유받으세요. " , pro_tip : " 영업 단계에서 '무리하게 약속한 기능'이 있는지 반드시 체크해야 합니다. " }
], tips : " 잘못된 시작은 엉뚱한 결말을 낳습니다. 영업팀의 약속을 검증하세요. " },
{ id : 2 , title : " 요구사항 파악 " , subtitle : " Requirements " , icon : " search " , color : " bg-indigo-100 text-indigo-600 " , description : " 고객과 직접 만나 구체적인 니즈를 청취하고, 숨겨진 요구사항까지 발굴하는 단계입니다. " , checkpoints : [
{ title : " 고객 인터뷰 및 실사 " , detail : " 현업 담당자를 만나 실제 업무 프로세스를 확인하고 시스템이 필요한 진짜 이유를 찾으세요. " , pro_tip : " '왜 이 기능이 필요하세요?'라고 3번 물어보세요(5 Whys). 목적을 찾아야 합니다. " },
{ title : " 요구사항 구체화 (Scope) " , detail : " 고객의 요구사항을 기능 단위로 쪼개고 우선순위(Must/Should/Could)를 매기세요. " , pro_tip : " '오픈 시점에 반드시 필요한 기능'과 '추후 고도화할 기능'을 명확히 구분해 주세요. " },
{ title : " 제약 사항 확인 " , detail : " 예산, 일정, 레거시 시스템 연동, 보안 규정 등 프로젝트의 제약 조건을 명확히 하세요. " , pro_tip : " 특히 '데이터 이관' 이슈를 조심하세요. 엑셀 데이터가 엉망인 경우가 태반입니다. " },
{ title : " 유사 레퍼런스 제시 " , detail : " 비슷한 고민을 했던 다른 고객사의 해결 사례를 보여주며 제안하는 방향의 신뢰를 얻으세요. " , pro_tip : " 'A사도 이렇게 푸셨습니다'라는 한마디가 백 마디 설명보다 강력합니다. " }
], tips : " 고객은 자기가 뭘 원하는지 모를 때가 많습니다. 질문으로 답을 찾아주세요. " },
{ id : 3 , title : " 개발자 협의 " , subtitle : " Dev Consult " , icon : " code-2 " , color : " bg-purple-100 text-purple-600 " , description : " 파악된 요구사항을 개발팀에 전달하고 기술적 실현 가능성과 공수를 산정합니다. " , checkpoints : [
{ title : " 요구사항 기술 검토 " , detail : " 개발 리드와 함께 고객의 요구사항이 기술적으로 구현 가능한지 검토하세요. " , pro_tip : " 개발자가 '안 돼요'라고 하면 '왜 안 되는지', '대안은 무엇인지'를 반드시 물어보세요. " },
{ title : " 공수 산정 (Estimation) " , detail : " 기능별 개발 예상 시간(M/M)을 산출하고 필요한 리소스를 파악하세요. " , pro_tip : " 개발 공수는 항상 버퍼(Buffer)를 20% 정도 두세요. 버그나 스펙 변경은 반드시 일어납니다. " },
{ title : " 아키텍처/스택 선정 " , detail : " 프로젝트에 적합한 기술 스택과 시스템 아키텍처를 확정하세요. " , pro_tip : " 최신 기술보다 유지보수 용이성과 개발팀의 숙련도를 최우선으로 고려하세요. " },
{ title : " 리스크 식별 및 대안 수립 " , detail : " 기술적 난이도가 높은 기능 등 리스크를 식별하고 대안(Plan B)을 마련하세요. " , pro_tip : " 리스크는 감추지 말고 공유해야 합니다. 미리 말하면 관리입니다. " }
], tips : " 개발자는 '기능'을 만들지만, 매니저는 '가치'를 만듭니다. 통역사가 되어주세요. " },
{ id : 4 , title : " 제안 및 견적 " , subtitle : " Proposal " , icon : " file-text " , color : " bg-pink-100 text-pink-600 " , description : " 개발팀 검토 내용을 바탕으로 수행 계획서(SOW)와 견적서를 작성하여 제안합니다. " , checkpoints : [
{ title : " WBS 및 일정 계획 수립 " , detail : " 분석/설계/개발/테스트/오픈 등 단계별 상세 일정을 수립하세요. " , pro_tip : " 고객의 검수(UAT) 기간을 충분히 잡으세요. 고객은 생각보다 바빠서 피드백이 늦어집니다. " },
{ title : " 견적서(Quotation) 작성 " , detail : " 개발 공수, 솔루션 비용, 인프라 비용 등을 포함한 상세 견적서를 작성하세요. " , pro_tip : " '기능별 상세 견적'을 제공하면 신뢰도가 높아지고 네고 방어에도 유리합니다. " },
{ title : " 제안서(SOW) 작성 " , detail : " 범위(Scope), 수행 방법론, 산출물 목록 등을 명시한 제안서를 작성하세요. " , pro_tip : " '제외 범위(Out of Scope)'를 명확히 적으세요. 나중에 딴소리 듣지 않으려면요. " },
{ title : " 제안 발표 (PT) " , detail : " 고객에게 제안 내용을 설명하고 우리가 가장 적임자임을 설득하세요. " , pro_tip : " 발표 자료는 '고객의 언어'로 작성하세요. 기술 용어 남발은 금물입니다. " }
], tips : " 견적서는 숫자가 아니라 '신뢰'를 담아야 합니다. " },
{ id : 5 , title : " 조율 및 협상 " , subtitle : " Negotiation " , icon : " scale " , color : " bg-orange-100 text-orange-600 " , description : " 제안 내용을 바탕으로 고객과 범위, 일정, 비용을 최종 조율하는 단계입니다. " , checkpoints : [
{ title : " 범위 및 일정 조정 " , detail : " 예산이나 일정에 맞춰 기능을 가감하거나 단계별 오픈 전략을 협의하세요. " , pro_tip : " 무리한 일정 단축은 단호하게 거절하되, '선오픈'과 같은 대안을 제시하세요. " },
{ title : " 추가 요구사항 대응 " , detail : " 제안 과정에서 나온 추가 요구사항에 대해 비용 청구 여부를 결정하세요. " , pro_tip : " 서비스로 해주더라도 '원래 얼마짜리인데 이번만 하는 것'이라고 인지시키세요. " },
{ title : " R&R 명확화 " , detail : " 우리 회사와 고객사가 각각 해야 할 역할을 명문화하세요. " , pro_tip : " 프로젝트 지연의 절반은 고객의 자료 전달 지연입니다. 숙제를 명확히 알려주세요. " },
{ title : " 최종 합의 도출 " , detail : " 모든 쟁점 사항을 정리하고 최종 합의된 내용을 문서로 남기세요. " , pro_tip : " 구두 합의는 힘이 없습니다. 반드시 이메일이나 회의록으로 남기세요. " }
], tips : " 협상은 이기는 게 아니라, 같이 갈 수 있는 길을 찾는 것입니다. " },
{ id : 6 , title : " 착수 및 계약 " , subtitle : " Kickoff " , icon : " flag " , color : " bg-green-100 text-green-600 " , description : " 계약을 체결하고 프로젝트를 공식적으로 시작하는 단계입니다. " , checkpoints : [
{ title : " 계약서 검토 및 날인 " , detail : " 과업지시서, 기술협약서 등 계약 부속 서류를 꼼꼼히 챙기세요. " , pro_tip : " 계약서에 '검수 조건'을 명확히 넣으세요. 실현 가능한 조건이어야 합니다. " },
{ title : " 프로젝트 팀 구성 " , detail : " 수행 인력을 확정하고 내부 킥오프를 진행하세요. " , pro_tip : " 팀원들에게 프로젝트 배경뿐만 아니라 '고객의 성향'도 공유해 주세요. " },
{ title : " 착수 보고회 (Kick-off) " , detail : " 전원이 모여 프로젝트의 목표, 일정, 커뮤니케이션 룰을 공유하세요. " , pro_tip : " 첫인상이 전문적이어야 프로젝트가 순탄합니다. 깔끔하게 준비하세요. " },
{ title : " 협업 도구 세팅 " , detail : " Jira, Slack 등 협업 도구를 세팅하고 고객을 초대하세요. " , pro_tip : " 소통 채널 단일화가 성공의 열쇠입니다. 간단 가이드를 제공하세요. " }
], tips : " 시작이 좋아야 끝도 좋습니다. 룰을 명확히 세우세요. " }
];
// --- Refined Voice Recorder Component ---
const VoiceRecorder = ({ tenantId , scenarioType , stepId }) => {
const [ isRecording , setIsRecording ] = useState ( false );
const [ audioBlob , setAudioBlob ] = useState ( null );
const [ timer , setTimer ] = useState ( 0 );
const [ transcript , setTranscript ] = useState ( '' );
const [ finalTranscript , setFinalTranscript ] = useState ( '' );
const [ status , setStatus ] = useState ( '대기중' );
const [ savedRecordings , setSavedRecordings ] = useState ([]);
const [ loading , setLoading ] = useState ( false );
const [ selectedRecording , setSelectedRecording ] = useState ( null );
const mediaRecorderRef = useRef ( null );
const audioChunksRef = useRef ([]);
const timerIntervalRef = useRef ( null );
const recognitionRef = useRef ( null );
const streamRef = useRef ( null );
const canvasRef = useRef ( null );
const animationIdRef = useRef ( null );
const audioContextRef = useRef ( null );
const analyserRef = useRef ( null );
const finalTranscriptRef = useRef ( '' );
useEffect (() => {
loadRecordings ();
return () => {
if ( timerIntervalRef . current ) clearInterval ( timerIntervalRef . current );
if ( animationIdRef . current ) cancelAnimationFrame ( animationIdRef . current );
if ( streamRef . current ) streamRef . current . getTracks () . forEach ( track => track . stop ());
if ( audioContextRef . current ) audioContextRef . current . close ();
};
}, [ tenantId , stepId ]);
const loadRecordings = async () => {
setLoading ( true );
try {
const res = await fetch ( `api/sales_tenants.php?action=get_consultations&tenant_id=${tenantId}&scenario_type=${scenarioType}` );
const result = await res . json ();
if ( result . success ) {
setSavedRecordings ( result . data . filter ( log => log . consultation_type === 'audio' && log . step_id == stepId ));
}
} catch ( err ) { console . error ( err ); }
setLoading ( false );
};
const formatTime = ( seconds ) => {
const mins = Math . floor ( seconds / 60 );
const secs = seconds % 60 ;
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}` ;
};
const drawWaveform = () => {
if ( ! analyserRef . current || ! canvasRef . current ) return ;
const canvas = canvasRef . current ;
const ctx = canvas . getContext ( '2d' );
const analyser = analyserRef . current ;
const dataArray = new Uint8Array ( analyser . frequencyBinCount );
analyser . getByteTimeDomainData ( dataArray );
ctx . fillStyle = '#f8fafc' ;
ctx . fillRect ( 0 , 0 , canvas . width , canvas . height );
ctx . lineWidth = 2 ;
ctx . strokeStyle = '#6366f1' ;
ctx . beginPath ();
const sliceWidth = canvas . width / dataArray . length ;
let x = 0 ;
for ( let i = 0 ; i < dataArray . length ; i ++ ) {
const v = dataArray [ i ] / 128.0 ;
const y = v * canvas . height / 2 ;
if ( i === 0 ) ctx . moveTo ( x , y );
else ctx . lineTo ( x , y );
x += sliceWidth ;
}
ctx . lineTo ( canvas . width , canvas . height / 2 );
ctx . stroke ();
animationIdRef . current = requestAnimationFrame ( drawWaveform );
};
const startRecording = async () => {
try {
const stream = await navigator . mediaDevices . getUserMedia ({ audio : true });
streamRef . current = stream ;
const audioContext = new ( window . AudioContext || window . webkitAudioContext )();
audioContextRef . current = audioContext ;
const analyser = audioContext . createAnalyser ();
analyserRef . current = analyser ;
const source = audioContext . createMediaStreamSource ( stream );
source . connect ( analyser );
analyser . fftSize = 2048 ;
if ( canvasRef . current ) {
canvasRef . current . width = canvasRef . current . offsetWidth ;
canvasRef . current . height = 100 ;
drawWaveform ();
}
const mediaRecorder = new MediaRecorder ( stream );
mediaRecorderRef . current = mediaRecorder ;
audioChunksRef . current = [];
mediaRecorder . ondataavailable = ( event ) => {
if ( event . data . size > 0 ) audioChunksRef . current . push ( event . data );
};
mediaRecorder . onstop = () => {
const blob = new Blob ( audioChunksRef . current , { type : 'audio/webm' });
setAudioBlob ( blob );
};
mediaRecorder . start ();
const SpeechRecognition = window . SpeechRecognition || window . webkitSpeechRecognition ;
if ( SpeechRecognition ) {
const recognition = new SpeechRecognition ();
recognition . lang = 'ko-KR' ;
recognition . continuous = true ;
recognition . interimResults = true ;
finalTranscriptRef . current = '' ;
setFinalTranscript ( '' );
recognition . onresult = ( event ) => {
let interim = '' ;
for ( let i = event . resultIndex ; i < event . results . length ; i ++ ) {
const transcriptText = event . results [ i ][ 0 ] . transcript ;
if ( event . results [ i ] . isFinal ) {
finalTranscriptRef . current += transcriptText + ' ' ;
setFinalTranscript ( finalTranscriptRef . current );
} else {
interim += transcriptText ;
}
}
const displayText = finalTranscriptRef . current + ( interim ? `<span class="text-slate-400">${interim}</span>` : '' );
setTranscript ( displayText || '음성을 인식하고 있습니다...' );
};
recognition . onend = () => {
if ( mediaRecorderRef . current && mediaRecorderRef . current . state !== 'inactive' ) {
try { recognition . start (); } catch ( e ) {}
}
};
recognition . start ();
recognitionRef . current = recognition ;
}
setIsRecording ( true );
setStatus ( '녹음 중...' );
setTimer ( 0 );
timerIntervalRef . current = setInterval (() => setTimer ( prev => prev + 1 ), 1000 );
} catch ( error ) {
console . error ( 'Recording failed:' , error );
alert ( '마이크 권한을 허용해주세요.' );
}
};
const stopRecording = () => {
if ( mediaRecorderRef . current && isRecording ) mediaRecorderRef . current . stop ();
if ( recognitionRef . current ) recognitionRef . current . stop ();
if ( timerIntervalRef . current ) clearInterval ( timerIntervalRef . current );
if ( animationIdRef . current ) cancelAnimationFrame ( animationIdRef . current );
if ( streamRef . current ) {
streamRef . current . getTracks () . forEach ( track => track . stop ());
streamRef . current = null ;
}
setIsRecording ( false );
setStatus ( '녹음 완료' );
setTranscript ( finalTranscriptRef . current . trim ());
};
const saveRecording = async () => {
if ( ! audioBlob ) return ;
const formData = new FormData ();
formData . append ( 'audio_file' , audioBlob , `rec_${Date.now()}.webm` );
formData . append ( 'log_text' , finalTranscriptRef . current . replace ( /< [ ^> ] *>/ g , '' ) . trim () || '음성 녹음 기록' );
formData . append ( 'tenant_id' , tenantId );
formData . append ( 'scenario_type' , scenarioType );
formData . append ( 'step_id' , stepId );
formData . append ( 'consultation_type' , 'audio' );
try {
const response = await fetch ( 'api/sales_tenants.php?action=save_consultation' , {
method : 'POST' ,
body : formData
});
const result = await response . json ();
if ( result . success ) {
setAudioBlob ( null );
setTranscript ( '' );
finalTranscriptRef . current = '' ;
setTimer ( 0 );
setStatus ( '대기중' );
loadRecordings ();
} else {
alert ( '저장 실패: ' + result . error );
}
} catch ( error ) {
console . error ( 'Save error:' , error );
alert ( '저장 중 오류가 발생했습니다.' );
}
};
const deleteLog = async ( id ) => {
if ( ! confirm ( '정말 삭제하시겠습니까?' )) return ;
try {
const res = await fetch ( 'api/sales_tenants.php?action=delete_consultation' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ id })
});
if (( await res . json ()) . success ) loadRecordings ();
} catch ( err ) { console . error ( err ); }
};
return (
< div className = " bg-white rounded-[2rem] border-2 border-slate-100 p-8 space-y-6 shadow-sm flex flex-col h-full " >
< div className = " flex items-center justify-between " >
< h4 className = " text-xl font-black text-slate-900 flex items-center gap-2 " >
< LucideIcon name = " mic " className = " w-6 h-6 text-indigo-600 " />
고객사 상담 녹음
</ h4 >
</ div >
< div className = " bg-slate-50/50 rounded-3xl p-8 flex flex-col items-center gap-4 relative overflow-hidden border border-slate-100 " >
< button
onClick = { isRecording ? stopRecording : startRecording }
className = { `w-24 h-24 rounded-full flex items-center justify-center text-white transition-all shadow-xl ${isRecording ? 'bg-red-500 animate-pulse' : 'bg-blue-600 hover:scale-105 shadow-blue-100'}` }
>
< LucideIcon name = { isRecording ? " square " : " mic " } className = " w-10 h-10 " />
</ button >
< div className = " text-center " >
< div className = " text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1 " > { status } </ div >
< div className = { `text-4xl font-black font-mono ${isRecording ? 'text-red-500' : 'text-slate-900'}` } > { formatTime ( timer )} </ div >
</ div >
< canvas ref = { canvasRef } className = " w-full h-20 bg-white rounded-2xl shadow-inner " style = {{ display : isRecording ? 'block' : 'none' }} ></ canvas >
{ transcript && (
< div className = " w-full p-4 bg-white rounded-2xl border border-slate-100 text-sm font-bold text-slate-600 max-h-32 overflow-y-auto italic " dangerouslySetInnerHTML = {{ __html : transcript }} ></ div >
)}
{ audioBlob && (
< div className = " w-full flex gap-2 animate-in slide-in-from-bottom-2 " >
< button onClick = { saveRecording } className = " flex-1 py-4 bg-slate-900 text-white rounded-2xl font-black text-sm hover:bg-black transition-all shadow-xl " >
저장하기
</ button >
< button onClick = {() => { setAudioBlob ( null ); setTranscript ( '' ); setTimer ( 0 ); setStatus ( '대기중' ); }} className = " px-8 py-4 bg-slate-100 text-slate-600 rounded-2xl font-black text-sm hover:bg-slate-200 transition-all " >
취소
</ button >
</ div >
)}
</ div >
{ /* Recorded List Integrated */ }
< div className = " flex-1 space-y-4 " >
< div className = " flex items-center justify-between pt-4 " >
< h5 className = " text-[10px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-2 " >
< LucideIcon name = " list " className = " w-4 h-4 " />
저장된 녹음 목록
</ h5 >
< button onClick = { loadRecordings } className = " text-[10px] font-black text-indigo-600 hover:text-indigo-800 flex items-center gap-1 " >
< LucideIcon name = " refresh-cw " className = { `w-3 h-3 ${loading ? 'animate-spin' : ''}` } />
새로고침
</ button >
</ div >
< div className = " overflow-hidden rounded-2xl border border-slate-100 " >
< table className = " w-full text-xs text-left " >
< thead className = " bg-slate-50 border-b border-slate-100 " >
< tr >
< th className = " px-4 py-3 font-black text-slate-400 uppercase tracking-tighter w-12 " > 번호 </ th >
< th className = " px-4 py-3 font-black text-slate-400 uppercase tracking-tighter w-32 " > 작성일 </ th >
< th className = " px-4 py-3 font-black text-slate-400 uppercase tracking-tighter " > 텍스트 미리보기 </ th >
< th className = " px-4 py-3 font-black text-slate-400 uppercase tracking-tighter text-right w-24 " > 동작 </ th >
</ tr >
</ thead >
< tbody className = " divide-y divide-slate-50 " >
{ savedRecordings . length === 0 ? (
< tr >
< td colSpan = " 4 " className = " px-4 py-12 text-center text-slate-300 font-bold italic " > 저장된 녹음 내용이 없습니다 .</ td >
</ tr >
) : (
savedRecordings . map (( rec , i ) => (
< tr key = { rec . id } className = " hover:bg-slate-50 transition-colors group " >
< td className = " px-4 py-3 text-slate-400 font-mono " > { savedRecordings . length - i } </ td >
< td className = " px-4 py-3 text-slate-600 font-bold " > { rec . created_at . split ( ' ' )[ 0 ]} </ td >
< td className = " px-4 py-3 text-slate-500 font-medium truncate max-w-[120px] " > { rec . log_text } </ td >
< td className = " px-4 py-3 text-right " >
< div className = " flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity " >
< button onClick = {() => setSelectedRecording ( rec )} className = " p-1.5 text-slate-400 hover:text-indigo-600 " title = " 상세보기 " >< LucideIcon name = " eye " className = " w-3.5 h-3.5 " /></ button >
< a href = { rec . audio_file_path } download className = " p-1.5 text-slate-400 hover:text-blue-600 " title = " 다운로드 " >< LucideIcon name = " download " className = " w-3.5 h-3.5 " /></ a >
< button onClick = {() => deleteLog ( rec . id )} className = " p-1.5 text-slate-400 hover:text-red-500 " title = " 삭제 " >< LucideIcon name = " trash-2 " className = " w-3.5 h-3.5 " /></ button >
</ div >
</ td >
</ tr >
))
)}
</ tbody >
</ table >
</ div >
</ div >
{ /* Details Modal */ }
{ selectedRecording && (
< div className = " fixed inset-0 z-[200] flex items-center justify-center p-6 bg-slate-900/40 backdrop-blur-sm " onClick = {() => setSelectedRecording ( null )} >
< div className = " bg-white rounded-[2.5rem] shadow-2xl w-full max-w-xl overflow-hidden animate-in zoom-in-95 duration-200 " onClick = { e => e . stopPropagation ()} >
< div className = " p-8 border-b border-slate-100 flex justify-between items-center bg-slate-50/50 " >
< h3 className = " text-xl font-black text-slate-900 flex items-center gap-3 " >
< div className = " p-2 bg-indigo-100 text-indigo-600 rounded-xl " >< LucideIcon name = " mic " className = " w-5 h-5 " /></ div >
상담 녹음 상세보기
</ h3 >
< button onClick = {() => setSelectedRecording ( null )} className = " p-2 hover:bg-slate-200 rounded-full transition-colors " >< LucideIcon name = " x " className = " w-5 h-5 text-slate-400 " /></ button >
</ div >
< div className = " p-8 space-y-8 " >
< div className = " bg-slate-50 rounded-2xl p-6 border border-slate-100 " >
< audio src = { selectedRecording . audio_file_path } controls className = " w-full h-10 " />
</ div >
< div className = " space-y-3 " >
< h6 className = " text-[10px] font-black text-slate-400 uppercase tracking-widest " > 전체 텍스트 기록 </ h6 >
< div className = " p-6 bg-slate-50 rounded-[2rem] border border-slate-100 text-sm font-bold text-slate-700 whitespace-pre-wrap leading-relaxed max-h-60 overflow-y-auto " >
{ selectedRecording . log_text }
</ div >
</ div >
</ div >
< div className = " p-6 bg-slate-50/50 border-t border-slate-100 flex justify-end " >
< button onClick = {() => setSelectedRecording ( null )} className = " px-8 py-3 bg-slate-900 text-white rounded-2xl font-black text-sm hover:bg-black transition-all " > 확인 </ button >
</ div >
</ div >
</ div >
)}
</ div >
);
};
// --- Refined File Uploader Component ---
const FileUploader = ({ tenantId , scenarioType , stepId }) => {
const [ uploading , setUploading ] = useState ( false );
const [ savedFiles , setSavedFiles ] = useState ([]);
const [ loading , setLoading ] = useState ( false );
const fileInputRef = useRef ( null );
useEffect (() => {
loadFiles ();
}, [ tenantId , stepId ]);
const loadFiles = async () => {
setLoading ( true );
try {
const res = await fetch ( `api/sales_tenants.php?action=get_consultations&tenant_id=${tenantId}&scenario_type=${scenarioType}` );
const result = await res . json ();
if ( result . success ) {
setSavedFiles ( result . data . filter ( log => log . consultation_type === 'file' && log . step_id == stepId ));
}
} catch ( err ) { console . error ( err ); }
setLoading ( false );
};
const handleFileSelect = async ( e ) => {
const selectedFiles = Array . from ( e . target . files );
if ( selectedFiles . length === 0 ) return ;
setUploading ( true );
const formData = new FormData ();
formData . append ( 'tenant_id' , tenantId );
formData . append ( 'scenario_type' , scenarioType );
formData . append ( 'step_id' , stepId );
formData . append ( 'consultation_type' , 'file' );
selectedFiles . forEach ( file => formData . append ( 'files[]' , file ));
try {
const response = await fetch ( 'api/sales_tenants.php?action=upload_attachments' , {
method : 'POST' ,
body : formData
});
const result = await response . json ();
if ( result . success ) loadFiles ();
else alert ( '업로드 실패: ' + result . error );
} catch ( error ) {
console . error ( 'Upload error:' , error );
alert ( '업로드 중 오류가 발생했습니다.' );
} finally {
setUploading ( false );
if ( fileInputRef . current ) fileInputRef . current . value = '' ;
}
};
const deleteLog = async ( id ) => {
if ( ! confirm ( '정말 삭제하시겠습니까?' )) return ;
try {
const res = await fetch ( 'api/sales_tenants.php?action=delete_consultation' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ id })
});
if (( await res . json ()) . success ) loadFiles ();
} catch ( err ) { console . error ( err ); }
};
return (
< div className = " bg-white rounded-[2rem] border-2 border-slate-100 p-8 space-y-6 shadow-sm flex flex-col h-full " >
< h4 className = " text-xl font-black text-slate-900 flex items-center gap-2 " >
< LucideIcon name = " paperclip " className = " w-6 h-6 text-blue-600 " />
첨부파일 추가
</ h4 >
< div
onClick = {() => fileInputRef . current ? . click ()}
className = " w-full h-48 border-2 border-dashed border-slate-200 rounded-[2rem] flex flex-col items-center justify-center gap-4 cursor-pointer hover:bg-slate-50/50 hover:border-blue-400 transition-all group "
>
< div className = " p-5 bg-blue-50 text-blue-600 rounded-3xl group-hover:scale-110 shadow-lg shadow-blue-50 transition-all " >
{ uploading ? < LucideIcon name = " loader " className = " w-8 h-8 animate-spin " /> : < LucideIcon name = " upload-cloud " className = " w-8 h-8 " /> }
</ div >
< div className = " text-center " >
< div className = " text-sm font-black text-slate-700 " >
{ uploading ? '파일을 분석하여 업로드 중...' : '클릭하여 파일 선택 (여러 개 가능)' }
</ div >
< div className = " text-[11px] font-bold text-slate-400 mt-1 " > 회의록 , 제안요청서 등 관련 서류 </ div >
</ div >
</ div >
< input type = " file " ref = { fileInputRef } className = " hidden " multiple onChange = { handleFileSelect } />
< div className = " flex-1 space-y-4 " >
< div className = " flex items-center justify-between pt-4 " >
< h5 className = " text-[10px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-2 " >
< LucideIcon name = " paperclip " className = " w-4 h-4 " />
업로드된 파일 목록
</ h5 >
< button onClick = { loadFiles } className = " text-[10px] font-black text-blue-600 hover:text-blue-800 flex items-center gap-1 " >
< LucideIcon name = " refresh-cw " className = { `w-3 h-3 ${loading ? 'animate-spin' : ''}` } />
새로고침
</ button >
</ div >
< div className = " space-y-3 max-h-[400px] overflow-y-auto " >
{ savedFiles . length === 0 ? (
< div className = " py-12 flex flex-col items-center gap-3 opacity-20 " >
< LucideIcon name = " inbox " className = " w-12 h-12 " />
< span className = " text-sm font-bold " > 업로드된 파일이 없습니다 </ span >
</ div >
) : (
savedFiles . map ( log => (
< div key = { log . id } className = " p-5 bg-slate-50/50 rounded-3xl border border-slate-100 group relative " >
< div className = " flex justify-between items-start mb-3 " >
< div className = " flex items-center gap-2 text-[10px] font-black text-slate-400 " >
< LucideIcon name = " calendar " className = " w-3 h-3 " />
{ log . created_at }
</ div >
< button onClick = {() => deleteLog ( log . id )} className = " p-1 text-slate-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all " >< LucideIcon name = " trash-2 " className = " w-3.5 h-3.5 " /></ button >
</ div >
< div className = " space-y-2 " >
{(() => {
try {
const paths = JSON . parse ( log . attachment_paths );
return paths . map (( p , i ) => (
< a key = { i } href = { p . path } target = " _blank " className = " flex items-center justify-between p-3 bg-white rounded-2xl border border-slate-100 hover:border-blue-400 transition-all " >
< div className = " flex items-center gap-3 min-w-0 " >
< LucideIcon name = " file " className = " w-4 h-4 text-slate-400 " />
< span className = " text-xs font-bold text-slate-700 truncate max-w-[150px] " > { p . name } </ span >
</ div >
< LucideIcon name = " download " className = " w-3.5 h-3.5 text-blue-500 " />
</ a >
));
} catch ( e ) { return < span className = " text-xs font-bold text-slate-400 " > 파일 정보 파싱 중 오류 </ span > }
})()}
</ div >
</ div >
))
)}
</ div >
</ div >
</ div >
);
};
const ManagerScenarioView = ({ tenant , onClose , scenarioType = 'manager' , onTriggerContract }) => {
const steps = scenarioType === 'sales' ? SALES_SCENARIO_STEPS : MANAGER_SCENARIO_STEPS ;
const [ activeStep , setActiveStep ] = useState ( steps [ 0 ]);
const [ checklist , setChecklist ] = useState ({});
const [ logs , setLogs ] = useState ([]);
const [ newLog , setNewLog ] = useState ( '' );
const [ loading , setLoading ] = useState ( false );
useEffect (() => {
2025-12-30 21:34:17 +09:00
const newSteps = scenarioType === 'sales' ? SALES_SCENARIO_STEPS : MANAGER_SCENARIO_STEPS ;
setActiveStep ( newSteps [ 0 ]);
2025-12-24 09:46:07 +09:00
fetchScenarioData ();
fetchConsultations ();
}, [ tenant . id , scenarioType ]);
const fetchScenarioData = async () => {
try {
const res = await fetch ( `api/sales_tenants.php?action=get_scenario&tenant_id=${tenant.id}&scenario_type=${scenarioType}` );
const result = await res . json ();
if ( result . success ) {
const map = {};
result . data . forEach ( item => {
2025-12-30 21:34:17 +09:00
const key = `${item.scenario_type}_${item.step_id}_${item.checkpoint_index}` ;
map [ key ] = ( item . is_checked == 1 );
2025-12-24 09:46:07 +09:00
});
setChecklist ( map );
}
2025-12-30 21:34:17 +09:00
} catch ( err ) { /* console.error('Fetch scenario error:', err); */ }
2025-12-24 09:46:07 +09:00
};
const fetchConsultations = async () => {
setLoading ( true );
try {
const res = await fetch ( `api/sales_tenants.php?action=get_consultations&tenant_id=${tenant.id}&scenario_type=${scenarioType}` );
const result = await res . json ();
if ( result . success ) setLogs ( result . data );
} catch ( err ) { console . error ( err ); }
setLoading ( false );
};
const toggleCheck = async ( stepId , index ) => {
2025-12-30 21:34:17 +09:00
const key = `${scenarioType}_${stepId}_${index}` ;
2025-12-24 09:46:07 +09:00
const newState = ! checklist [ key ];
// Optimistic UI Update
setChecklist ( prev => ({ ... prev , [ key ] : newState }));
try {
const res = await fetch ( 'api/sales_tenants.php?action=update_checklist' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({
tenant_id : tenant . id ,
scenario_type : scenarioType ,
step_id : stepId ,
checkpoint_index : index ,
is_checked : newState
})
});
const result = await res . json ();
if ( ! result . success ) {
alert ( '저장 실패: ' + ( result . error || '알 수 없는 오류' ));
setChecklist ( prev => ({ ... prev , [ key ] : ! newState })); // Rollback
}
} catch ( err ) {
2025-12-30 21:34:17 +09:00
// console.error('Checkbox toggle error:', err);
2025-12-24 09:46:07 +09:00
alert ( '서버와 통신하는 중 오류가 발생했습니다.' );
setChecklist ( prev => ({ ... prev , [ key ] : ! newState })); // Rollback
}
};
const saveLog = async () => {
if ( ! newLog . trim ()) return ;
try {
const res = await fetch ( 'api/sales_tenants.php?action=save_consultation' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({
tenant_id : tenant . id ,
scenario_type : scenarioType ,
step_id : activeStep . id ,
log_text : newLog ,
consultation_type : 'text'
})
});
if (( await res . json ()) . success ) {
setNewLog ( '' );
fetchConsultations ();
}
} catch ( err ) { console . error ( err ); }
};
const deleteLog = async ( id ) => {
if ( ! confirm ( '정말 삭제하시겠습니까?' )) return ;
try {
const res = await fetch ( 'api/sales_tenants.php?action=delete_consultation' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ id })
});
if (( await res . json ()) . success ) {
fetchConsultations ();
}
} catch ( err ) { console . error ( err ); }
};
const getStepProgress = ( stepId ) => {
const step = steps . find ( s => s . id === stepId );
if ( ! step ) return 0 ;
const total = step . checkpoints . length ;
let checked = 0 ;
for ( let i = 0 ; i < total ; i ++ ) {
2025-12-30 21:34:17 +09:00
if ( checklist [ `${scenarioType}_${stepId}_${i}` ]) checked ++ ;
2025-12-24 09:46:07 +09:00
}
return Math . round (( checked / total ) * 100 );
};
return (
< div className = " fixed inset-0 z-[120] bg-slate-900/60 backdrop-blur-sm flex justify-center p-4 lg:p-8 " >
< div className = " bg-white rounded-[2rem] shadow-2xl w-full max-w-7xl overflow-hidden flex flex-col animate-in zoom-in duration-200 " >
{ /* Header */ }
< div className = " p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50 " >
< div className = " flex items-center gap-4 " >
< div className = " p-3 bg-blue-600 rounded-2xl text-white shadow-lg shadow-blue-100 " >
< LucideIcon name = " clipboard-check " className = " w-6 h-6 " />
</ div >
< div >
< h3 className = " text-2xl font-black text-slate-900 leading-tight " >
{ scenarioType === 'sales' ? '영업 전략 시나리오 매니지먼트' : '상담 프로세스 매니지먼트' }
</ h3 >
< p className = " text-sm font-bold text-slate-500 flex items-center gap-2 " >
< LucideIcon name = " building " className = " w-3.5 h-3.5 " />
{ tenant . tenant_name } ({ tenant . representative })
</ p >
</ div >
</ div >
< button onClick = { onClose } className = " p-3 hover:bg-slate-200 rounded-full transition-colors " >
< LucideIcon name = " x " className = " w-6 h-6 text-slate-500 " />
</ button >
</ div >
< div className = " flex-1 flex overflow-hidden " >
{ /* Primary Sidebar: Steps */ }
< div className = " w-72 border-r border-slate-100 bg-slate-50/50 p-6 space-y-3 overflow-y-auto " >
< label className = " text-[10px] font-black text-slate-400 uppercase tracking-widest px-2 mb-4 block " >
{ scenarioType === 'sales' ? 'Sales Strategy' : 'Business Steps' }
</ label >
{ steps . map ( step => {
const progress = getStepProgress ( step . id );
const isActive = activeStep . id === step . id ;
return (
< button
key = { step . id }
onClick = {() => setActiveStep ( step )}
className = { `w-full group text-left p-4 rounded-2xl transition-all duration-300 relative overflow-hidden ${isActive ? 'bg-white shadow-md border-slate-200 ring-1 ring-slate-200' : 'hover:bg-slate-100'}` }
>
< div className = " flex items-center gap-3 relative z-10 " >
< div className = { `w-10 h-10 rounded-xl flex items-center justify-center transition-transform group-hover:scale-110 ${isActive ? step.color : 'bg-slate-200 text-slate-400'}` } >
< LucideIcon name = { step . icon } className = " w-5 h-5 " />
</ div >
< div className = " flex-1 " >
2025-12-30 21:34:17 +09:00
< div className = " flex items-center justify-between " >
< div className = { `text-sm font-black ${isActive ? 'text-slate-900' : 'text-slate-500'}` } > { step . title } </ div >
< span className = { `text-[10px] font-bold ${isActive ? 'text-blue-600' : 'text-slate-400'}` } > { progress } %</ span >
</ div >
2025-12-24 09:46:07 +09:00
< div className = " w-full bg-slate-200 h-1 rounded-full mt-2 " >
< div className = { `h-full rounded-full transition-all ${step.color.replace('text-', 'bg-').replace('100', '500')}` } style = {{ width : `${progress}%` }} ></ div >
</ div >
</ div >
</ div >
</ button >
);
})}
</ div >
{ /* Main Content Area */ }
< div className = " flex-1 overflow-y-auto p-10 bg-white " >
< div className = " max-w-4xl mx-auto space-y-12 " >
{ /* Step Info */ }
< div className = " animate-in slide-in-from-top-4 duration-500 " >
< div className = " flex items-center gap-3 mb-4 " >
< span className = { `px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest ${activeStep.color}` } > { activeStep . subtitle } </ span >
< span className = " text-xs font-bold text-slate-400 " > Step 0 { activeStep . id } </ span >
</ div >
< h2 className = " text-4xl font-black text-slate-900 mb-4 " > { activeStep . title } </ h2 >
< div className = " flex items-center justify-between gap-4 " >
< p className = " text-lg text-slate-600 font-medium leading-relaxed " > { activeStep . description } </ p >
{ scenarioType === 'sales' && activeStep . id === 6 && (
< button
onClick = {() => onTriggerContract ( tenant )}
className = " shrink-0 px-6 py-3 bg-blue-600 text-white rounded-2xl font-black hover:bg-blue-700 transition-all shadow-xl shadow-blue-100 flex items-center gap-2 animate-bounce "
>
< LucideIcon name = " shopping-cart " className = " w-5 h-5 " />
계약 상품 등록하기
</ button >
)}
</ div >
</ div >
{ /* Tips Area */ }
{ activeStep . tips && (
< div className = " p-6 bg-blue-50 border border-blue-100 rounded-3xl animate-in slide-in-from-bottom-4 duration-500 " >
< div className = " flex items-start gap-4 " >
< div className = " p-3 bg-blue-100 rounded-2xl text-blue-600 " >
< LucideIcon name = " lightbulb " className = " w-6 h-6 " />
</ div >
< div >
< h5 className = " font-black text-blue-900 mb-1 " > Manager Secret Tips </ h5 >
< p className = " text-blue-700 font-medium " > { activeStep . tips } </ p >
</ div >
</ div >
</ div >
)}
{ /* Checklist */ }
< div className = " grid grid-cols-1 md:grid-cols-2 gap-6 " >
{ activeStep . checkpoints . map (( cp , idx ) => (
< div key = { idx }
onClick = {() => toggleCheck ( activeStep . id , idx )}
2025-12-30 21:34:17 +09:00
className = { `p-6 rounded-3xl border-2 transition-all cursor-pointer group ${checklist[` $ { scenarioType } _ $ { activeStep . id } _ $ { idx } `] ? 'bg-emerald-50 border-emerald-100' : 'bg-white border-slate-100 hover:border-blue-200'}` }
2025-12-24 09:46:07 +09:00
>
< div className = " flex items-start gap-4 " >
2025-12-30 21:34:17 +09:00
< div className = { `w-6 h-6 rounded-lg border-2 flex items-center justify-center shrink-0 transition-colors ${checklist[` $ { scenarioType } _ $ { activeStep . id } _ $ { idx } `] ? 'bg-emerald-500 border-emerald-500 text-white' : 'border-slate-300 group-hover:border-blue-500'}` } >
{ checklist [ `${scenarioType}_${activeStep.id}_${idx}` ] && < LucideIcon name = " check " className = " w-4 h-4 " /> }
2025-12-24 09:46:07 +09:00
</ div >
< div >
2025-12-30 21:34:17 +09:00
< h4 className = { `font-black mb-2 transition-colors ${checklist[` $ { scenarioType } _ $ { activeStep . id } _ $ { idx } `] ? 'text-emerald-900' : 'text-slate-900 group-hover:text-blue-600'}` } > { cp . title } </ h4 >
2025-12-24 09:46:07 +09:00
< p className = " text-sm text-slate-500 leading-relaxed italic " > { cp . detail } </ p >
2025-12-30 21:34:17 +09:00
{ checklist [ `${scenarioType}_${activeStep.id}_${idx}` ] && cp . pro_tip && (
2025-12-24 09:46:07 +09:00
< div className = " mt-4 p-4 bg-white/60 rounded-xl text-xs text-emerald-700 font-bold border border-emerald-100 " >
💡 Tip : { cp . pro_tip }
</ div >
)}
</ div >
</ div >
</ div >
))}
</ div >
2025-12-30 21:34:17 +09:00
{ /* Step Features: Voice & Files - Available for all steps */ }
< div className = " grid grid-cols-1 xl:grid-cols-2 gap-8 animate-in slide-in-from-bottom-6 duration-700 pt-8 border-t border-slate-100 " >
< VoiceRecorder
tenantId = { tenant . id }
scenarioType = { scenarioType }
stepId = { activeStep . id }
/>
< FileUploader
tenantId = { tenant . id }
scenarioType = { scenarioType }
stepId = { activeStep . id }
/>
</ div >
2025-12-24 09:46:07 +09:00
{ /* Log Area */ }
< div className = " pt-12 border-t border-dashed border-slate-200 space-y-6 " >
< div className = " flex items-center justify-between " >
< h3 className = " text-xl font-black text-slate-900 flex items-center gap-2 " >
< LucideIcon name = " message-square " className = " w-5 h-5 text-blue-600 " />
실행 기록 및 상담 내용
</ h3 >
</ div >
< div className = " bg-slate-50 p-6 rounded-3xl border border-slate-100 " >
< textarea
value = { newLog }
onChange = {( e ) => setNewLog ( e . target . value )}
className = " w-full bg-white border border-slate-200 rounded-2xl p-4 text-sm font-medium focus:ring-4 focus:ring-blue-100 outline-none transition-all h-32 resize-none "
placeholder = " 오늘의 업무 내용이나 상담 특이사항을 기록하세요... "
></ textarea >
< div className = " mt-4 flex justify-end " >
< button
onClick = { saveLog }
className = " px-8 py-3 bg-slate-900 text-white rounded-2xl font-black hover:bg-black transition-all shadow-lg flex items-center gap-2 "
>
< LucideIcon name = " save " className = " w-4 h-4 " />
기록 저장
</ button >
</ div >
</ div >
{ /* Log Timeline - Only Text Logs */ }
< div className = " space-y-4 " >
{ logs . filter ( log => log . consultation_type === 'text' ) . length === 0 ? (
< div className = " text-center py-20 text-slate-400 font-bold bg-slate-50/50 rounded-3xl border border-dashed border-slate-200 " >
아직 기록된 내용이 없습니다 .
</ div >
) : logs . filter ( log => log . consultation_type === 'text' ) . map ( log => (
< div key = { log . id } className = " p-6 bg-white border border-slate-100 rounded-3xl shadow-sm hover:shadow-md transition-all group relative " >
< div className = " flex items-center justify-between mb-4 " >
< div className = " flex items-center gap-3 " >
< div className = " p-2 rounded-xl bg-slate-50 text-slate-600 " >
< LucideIcon name = " message-square " className = " w-4 h-4 " />
</ div >
< div >
< div className = " flex items-center gap-2 " >
< span className = " px-2 py-0.5 bg-slate-100 text-slate-600 rounded text-[10px] font-black uppercase tracking-tighter " > Step { log . step_id } </ span >
< span className = " text-[10px] font-bold text-slate-400 tracking-tighter " > { log . created_at } </ span >
</ div >
</ div >
</ div >
< button
onClick = {() => deleteLog ( log . id )}
className = " opacity-0 group-hover:opacity-100 p-2 text-slate-300 hover:text-red-500 transition-all "
>
< LucideIcon name = " trash-2 " className = " w-4 h-4 " />
</ button >
</ div >
< p className = " text-sm font-bold text-slate-800 leading-relaxed whitespace-pre-wrap " > { log . log_text } </ p >
</ div >
))}
</ div >
</ div >
</ div >
</ div >
</ div >
</ div >
</ div >
);
};
2025-12-23 09:00:57 +09:00
// --- NEW: Profit Management View (Tenants & Commissions) ---
2025-12-24 09:46:07 +09:00
const ProfitManagementView = ({ currentUser , salesConfig , currentRole }) => {
2025-12-23 09:00:57 +09:00
const [ tenants , setTenants ] = useState ([]);
const [ stats , setStats ] = useState ({ tenant_count : 0 , total_revenue : 0 , total_commission : 0 , confirmed_commission : 0 });
2025-12-24 09:46:07 +09:00
const [ tenantProducts , setTenantProducts ] = useState ({});
2025-12-23 09:00:57 +09:00
const [ loading , setLoading ] = useState ( true );
const [ isTenantModalOpen , setIsTenantModalOpen ] = useState ( false );
const [ isProductModalOpen , setIsProductModalOpen ] = useState ( false );
const [ selectedTenant , setSelectedTenant ] = useState ( null );
2025-12-24 09:46:07 +09:00
const [ activeSalesScenarioTenant , setActiveSalesScenarioTenant ] = useState ( null );
const [ activeManagerScenarioTenant , setActiveManagerScenarioTenant ] = useState ( null );
2025-12-23 09:00:57 +09:00
const [ expandedTenantId , setExpandedTenantId ] = useState ( null );
2025-12-24 09:46:07 +09:00
const [ pricingData , setPricingData ] = useState ({});
const [ selectedCategory , setSelectedCategory ] = useState ( null );
const [ selectedSubModels , setSelectedSubModels ] = useState ([]);
const [ editProductId , setEditProductId ] = useState ( null );
const [ isSaving , setIsSaving ] = useState ( false );
const [ potentialManagerList , setPotentialManagerList ] = useState ([]);
2025-12-30 21:34:17 +09:00
const [ activeManagerPopover , setActiveManagerPopover ] = useState ( null );
2026-01-04 17:39:59 +09:00
const [ editingTenantId , setEditingTenantId ] = useState ( null );
2025-12-30 21:34:17 +09:00
const popoverRef = useRef ( null );
// Filtered manager list based on role
const filteredManagers = ( currentRole === '운영자' )
? potentialManagerList
: potentialManagerList . filter ( m => m . id == currentUser . id || m . parent_id == currentUser . id );
2025-12-23 09:00:57 +09:00
const [ tenantFormData , setTenantFormData ] = useState ({
2025-12-24 09:46:07 +09:00
tenant_name : '' , representative : '' , business_no : '' , contact_phone : '' , email : '' , address : '' ,
sales_manager_id : currentUser ? currentUser . id : ''
2025-12-23 09:00:57 +09:00
});
const [ productFormData , setProductFormData ] = useState ({
product_name : '' , contract_amount : '' , commission_rate : '20' , contract_date : new Date () . toISOString () . split ( 'T' )[ 0 ]
});
2025-12-24 09:46:07 +09:00
const fillRandomTenantData = () => {
const sampleCompanies = [ '(주)가나다소프트' , '(주)에이비씨시스템' , '(주)코드브리지' , '(주)샘테크' , '(주)미래이노베이션' , '(주)글로벌네트웍스' , '(주)디지털솔루션' , '(주)한국IT연구소' , '(주)스마트플랫폼' ];
const sampleNames = [ '김철수' , '이영희' , '박지민' , '최민석' , '정수아' , '강동원' , '한예슬' , '홍길동' , '장미란' ];
const sampleAddresses = [ '서울특별시 강남구 테헤란로 123' , '경기도 성남시 분당구 판교역로 456' , '서울특별시 서초구 서초대로 789' , '부산광역시 해운대구 센텀중앙로 101' , '인천광역시 연수구 송도과학로 202' , '대구광역시 수성구 달구벌대로 303' ];
const randomItem = ( arr ) => arr [ Math . floor ( Math . random () * arr . length )];
const randomNum = ( len ) => Array . from ({ length : len }, () => Math . floor ( Math . random () * 10 )) . join ( '' );
const randomData = {
tenant_name : randomItem ( sampleCompanies ),
representative : randomItem ( sampleNames ),
business_no : formatBusinessNo ( randomNum ( 10 )),
contact_phone : formatPhone ( '010' + randomNum ( 8 )),
email : `test_${randomNum(4)}@example.com` ,
address : randomItem ( sampleAddresses )
};
setTenantFormData ( randomData );
};
const fillRandomProductData = () => {
const selectModelsPkg = ( salesConfig . package_types || []) . find ( p => p . id === 'select_models' );
if ( ! selectModelsPkg ) return ;
setSelectedCategory ( 'select_models' );
const allModels = selectModelsPkg . models || [];
// Randomly select 2-4 models
const randomCount = Math . floor ( Math . random () * 3 ) + 2 ;
const shuffled = [ ... allModels ] . sort (() => 0.5 - Math . random ());
const selectedIds = shuffled . slice ( 0 , Math . min ( randomCount , allModels . length )) . map ( m => m . id );
setSelectedSubModels ( selectedIds );
// Calculate total based on selected models and pricingData
const total = selectedIds . reduce (( sum , id ) => {
const modelObj = allModels . find ( m => m . id === id );
const pKey = `model_${id}` ;
const priceInfo = pricingData [ pKey ] || { join_fee : modelObj ? modelObj . join_fee : 0 };
return sum + ( Number ( priceInfo . join_fee ) || 0 );
}, 0 );
setProductFormData ({
... productFormData ,
product_name : `선택모델(${selectedIds.length}종)` ,
contract_amount : total ,
commission_rate : '20' ,
contract_date : new Date () . toISOString () . split ( 'T' )[ 0 ]
});
};
2025-12-23 09:00:57 +09:00
useEffect (() => {
fetchData ();
2025-12-24 09:46:07 +09:00
fetchPricing ();
fetchManagers ();
2025-12-23 09:00:57 +09:00
}, []);
2025-12-30 21:34:17 +09:00
useEffect (() => {
const handleClickOutside = ( event ) => {
if ( popoverRef . current && ! popoverRef . current . contains ( event . target )) {
setActiveManagerPopover ( null );
}
};
if ( activeManagerPopover ) {
document . addEventListener ( 'mousedown' , handleClickOutside );
}
return () => {
document . removeEventListener ( 'mousedown' , handleClickOutside );
};
}, [ activeManagerPopover ]);
2025-12-24 09:46:07 +09:00
const fetchManagers = async () => {
try {
const res = await fetch ( 'api/sales_tenants.php?action=list_managers' );
const result = await res . json ();
if ( result . success ) setPotentialManagerList ( result . data );
} catch ( err ) {
console . error ( 'Fetch managers error:' , err );
}
};
const fetchPricing = async () => {
try {
const response = await fetch ( 'api/package_pricing.php?action=list' );
const result = await response . json ();
if ( result . success ) {
const pricingMap = {};
result . data . forEach ( item => {
pricingMap [ `${item.item_type}_${item.item_id}` ] = item ;
});
setPricingData ( pricingMap );
}
} catch ( error ) {
console . error ( '가격 정보 로드 실패:' , error );
}
};
2025-12-23 09:00:57 +09:00
const fetchData = async () => {
setLoading ( true );
try {
const [ tenantsRes , statsRes ] = await Promise . all ([
fetch ( 'api/sales_tenants.php?action=list_tenants' ),
fetch ( 'api/sales_tenants.php?action=my_stats' )
]);
const tenantsData = await tenantsRes . json ();
const statsData = await statsRes . json ();
if ( tenantsData . success ) setTenants ( tenantsData . data );
if ( statsData . success ) setStats ( statsData . data );
} catch ( err ) {
console . error ( 'Fetch error:' , err );
} finally {
setLoading ( false );
}
};
const fetchProducts = async ( tenantId ) => {
try {
const res = await fetch ( `api/sales_tenants.php?action=tenant_products&tenant_id=${tenantId}` );
const result = await res . json ();
if ( result . success ) {
setTenantProducts ( prev => ({ ... prev , [ tenantId ] : result . data }));
}
} catch ( err ) {
console . error ( 'Fetch products error:' , err );
}
};
const handleToggleTenant = ( tenantId ) => {
if ( expandedTenantId === tenantId ) {
setExpandedTenantId ( null );
} else {
setExpandedTenantId ( tenantId );
if ( ! tenantProducts [ tenantId ]) {
fetchProducts ( tenantId );
}
}
};
2025-12-30 21:34:17 +09:00
const handleUpdateManagerAssignment = async ( tenantId , targetManagerId ) => {
2025-12-24 09:46:07 +09:00
if ( ! currentUser || ! currentUser . id ) {
2025-12-30 21:34:17 +09:00
alert ( '로그인 정보가 유효하지 않습니다.' );
2025-12-24 09:46:07 +09:00
return ;
}
try {
const res = await fetch ( 'api/sales_tenants.php?action=update_tenant_manager' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({
tenant_id : tenantId ,
2025-12-30 21:34:17 +09:00
sales_manager_id : targetManagerId
2025-12-24 09:46:07 +09:00
})
});
const result = await res . json ();
if ( result . success ) {
2025-12-30 21:34:17 +09:00
await fetchData ();
setActiveManagerPopover ( null );
if ( result . message ) alert ( result . message );
2025-12-24 09:46:07 +09:00
} else {
2025-12-30 21:34:17 +09:00
alert ( result . error || '업데이트 실패' );
2025-12-24 09:46:07 +09:00
}
} catch ( err ) {
2025-12-30 21:34:17 +09:00
console . error ( 'Assignment error:' , err );
alert ( '서버와 통신하는 중 오류가 발생했습니다.' );
2025-12-24 09:46:07 +09:00
}
};
2025-12-23 09:00:57 +09:00
const handleCreateTenant = async ( e ) => {
e . preventDefault ();
try {
2026-01-04 17:39:59 +09:00
const action = editingTenantId ? 'update_tenant' : 'create_tenant' ;
const payload = editingTenantId ? { ... tenantFormData , id : editingTenantId } : tenantFormData ;
const res = await fetch ( `api/sales_tenants.php?action=${action}` , {
2025-12-23 09:00:57 +09:00
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
2026-01-04 17:39:59 +09:00
body : JSON . stringify ( payload )
2025-12-23 09:00:57 +09:00
});
const result = await res . json ();
if ( result . success ) {
2025-12-24 09:46:07 +09:00
alert ( result . message );
2025-12-23 09:00:57 +09:00
setIsTenantModalOpen ( false );
2026-01-04 17:39:59 +09:00
setEditingTenantId ( null );
2025-12-24 09:46:07 +09:00
await fetchData ();
setTenantFormData ({ tenant_name : '' , representative : '' , business_no : '' , contact_phone : '' , email : '' , address : '' , sales_manager_id : currentUser . id });
2026-01-04 17:03:41 +09:00
2025-12-23 09:00:57 +09:00
} else {
alert ( result . error );
}
} catch ( err ) {
2026-01-04 17:39:59 +09:00
alert ( '처리 중 오류가 발생했습니다.' );
}
};
const handleOpenEditTenant = ( t ) => {
setEditingTenantId ( t . id );
setTenantFormData ({
tenant_name : t . tenant_name ,
representative : t . representative ,
business_no : t . business_no ,
contact_phone : t . contact_phone ,
email : t . email ,
address : t . address ,
sales_manager_id : t . sales_manager_id
});
setIsTenantModalOpen ( true );
};
const handleDeleteTenant = async ( tenantId ) => {
if ( ! confirm ( '정말로 이 테넌트를 삭제하시겠습니까? 관련 계약 및 모든 기록이 삭제됩니다.' )) return ;
try {
const res = await fetch ( 'api/sales_tenants.php?action=delete_tenant' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ id : tenantId })
});
const result = await res . json ();
if ( result . success ) {
alert ( '삭제되었습니다.' );
fetchData ();
} else {
alert ( result . error );
}
} catch ( err ) {
alert ( '삭제 중 오류가 발생했습니다.' );
2025-12-23 09:00:57 +09:00
}
};
const handleAddProduct = async ( e ) => {
e . preventDefault ();
2025-12-24 09:46:07 +09:00
if ( isSaving ) return ;
2025-12-23 09:00:57 +09:00
if ( ! productFormData . contract_amount || ! productFormData . product_name ) {
alert ( '필수 정보를 입력해주세요.' );
return ;
}
2025-12-24 09:46:07 +09:00
setIsSaving ( true );
2025-12-23 09:00:57 +09:00
try {
2025-12-24 09:46:07 +09:00
const action = editProductId ? 'update_product' : 'add_product' ;
const payload = editProductId
? { ... productFormData , id : editProductId , sub_models : selectedSubModels }
: { ... productFormData , tenant_id : selectedTenant . id , sub_models : selectedSubModels };
const res = await fetch ( `api/sales_tenants.php?action=${action}` , {
2025-12-23 09:00:57 +09:00
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
2025-12-24 09:46:07 +09:00
body : JSON . stringify ( payload )
2025-12-23 09:00:57 +09:00
});
const result = await res . json ();
if ( result . success ) {
2025-12-24 09:46:07 +09:00
alert ( editProductId ? '계약 정보가 수정되었습니다.' : '계약 정보가 등록되었습니다.' );
2025-12-23 09:00:57 +09:00
setIsProductModalOpen ( false );
2025-12-24 09:46:07 +09:00
setEditProductId ( null );
2025-12-23 09:00:57 +09:00
setProductFormData ({ product_name : '' , contract_amount : '' , commission_rate : '20' , contract_date : new Date () . toISOString () . split ( 'T' )[ 0 ] });
2025-12-24 09:46:07 +09:00
setSelectedCategory ( null );
setSelectedSubModels ([]);
2025-12-23 09:00:57 +09:00
fetchData ();
2025-12-24 09:46:07 +09:00
fetchProducts ( editProductId ? selectedTenant . id : selectedTenant . id ); // selectedTenant is still valid
2025-12-23 09:00:57 +09:00
} else {
alert ( result . error );
}
} catch ( err ) {
2025-12-24 09:46:07 +09:00
alert ( '처리 중 오류가 발생했습니다.' );
} finally {
setIsSaving ( false );
}
};
const handleDeleteProduct = async ( tenantId , productId ) => {
if ( ! confirm ( '정말로 이 계약 정보를 삭제하시겠습니까?' )) return ;
try {
const res = await fetch ( 'api/sales_tenants.php?action=delete_product' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ id : productId })
});
const result = await res . json ();
if ( result . success ) {
alert ( '계약 정보가 삭제되었습니다.' );
fetchData ();
fetchProducts ( tenantId );
} else {
alert ( result . error );
}
} catch ( err ) {
alert ( '삭제 중 오류가 발생했습니다.' );
2025-12-23 09:00:57 +09:00
}
};
2025-12-24 09:46:07 +09:00
const handleOpenEditProduct = ( tenant , product ) => {
setSelectedTenant ( tenant );
setEditProductId ( product . id );
setProductFormData ({
product_name : product . product_name ,
contract_amount : product . contract_amount ,
commission_rate : product . commission_rate ,
contract_date : product . contract_date
});
// Restore sub models if available
if ( product . sub_models ) {
try {
const subModels = JSON . parse ( product . sub_models );
setSelectedSubModels ( subModels || []);
setSelectedCategory ( 'select_models' );
} catch ( e ) {
console . error ( 'Sub-models parse error:' , e );
setSelectedSubModels ([]);
}
} else {
// Try to guess from name if it's a fixed package
const pkg = ( salesConfig . package_types || []) . find ( p => p . name === product . product_name );
if ( pkg ) setSelectedCategory ( pkg . id );
else setSelectedCategory ( null );
setSelectedSubModels ([]);
}
setIsProductModalOpen ( true );
};
2025-12-23 09:00:57 +09:00
const formatCurrency = ( val ) => new Intl . NumberFormat ( 'ko-KR' , { style : 'currency' , currency : 'KRW' }) . format ( val || 0 );
return (
< div className = " space-y-8 " >
{ /* 수익 통계 카드 */ }
< div className = " grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 " >
< StatCard
title = " 관리 테넌트 "
value = { `${stats.tenant_count || 0}개` }
subtext = " 등록된 총 업체 수 "
icon = { < LucideIcon name = " building-2 " className = " w-5 h-5 " /> }
/>
< StatCard
title = " 총 매출액 "
value = { formatCurrency ( stats . total_revenue )}
subtext = " 전체 계약 금액 합계 "
icon = { < LucideIcon name = " bar-chart-3 " className = " w-5 h-5 " /> }
/>
< StatCard
title = " 누적 예상 수익 "
value = { formatCurrency ( stats . total_commission )}
subtext = " 전체 수수료 합계 "
icon = { < LucideIcon name = " coins " className = " w-5 h-5 " /> }
/>
< div className = " bg-gradient-to-br from-emerald-50 to-teal-50 rounded-card p-6 shadow-sm border border-emerald-200 " >
< div className = " flex items-start justify-between mb-4 " >
< h3 className = " text-sm font-medium text-emerald-700 " > 확정 수익 ( 지급대상 ) </ h3 >
< div className = " p-2 bg-emerald-100 rounded-lg text-emerald-600 " >
< LucideIcon name = " check-circle " className = " w-5 h-5 " />
</ div >
</ div >
< div className = " text-2xl font-bold text-emerald-900 mb-1 " > { formatCurrency ( stats . confirmed_commission )} </ div >
< div className = " text-xs text-emerald-600 font-medium " > 운영팀 승인 완료된 금액 </ div >
</ div >
</ div >
{ /* 테넌트 목록 섹션 */ }
< section className = " bg-white rounded-card shadow-sm border border-slate-100 overflow-hidden " >
< div className = " p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50/50 " >
< h3 className = " text-xl font-bold text-slate-900 flex items-center gap-2 " >
< LucideIcon name = " layout-list " className = " w-5 h-5 text-blue-600 " />
테넌트 및 계약 관리
</ h3 >
2025-12-24 09:46:07 +09:00
{ currentRole === '영업관리' && (
< button
2026-01-04 17:39:59 +09:00
onClick = {() => {
setEditingTenantId ( null );
setTenantFormData ({
tenant_name : '' , representative : '' , business_no : '' , contact_phone : '' , email : '' , address : '' ,
sales_manager_id : currentUser ? currentUser . id : ''
});
setIsTenantModalOpen ( true );
}}
2025-12-24 09:46:07 +09:00
className = " bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-bold flex items-center gap-2 transition-all shadow-md "
>
< LucideIcon name = " plus " className = " w-4 h-4 " />
신규 테넌트 등록
</ button >
)}
2025-12-23 09:00:57 +09:00
</ div >
< div className = " overflow-x-auto text-sm " >
< table className = " w-full text-left " >
< thead className = " bg-slate-50 border-b border-slate-100 text-slate-500 font-bold uppercase text-xs " >
< tr >
< th className = " px-6 py-4 w-12 " ></ th >
< th className = " px-6 py-4 " > 업체명 </ th >
2025-12-24 09:46:07 +09:00
< th className = " px-6 py-4 " > 담당자 ( 영업 / 관리 ) </ th >
2025-12-23 09:00:57 +09:00
< th className = " px-6 py-4 " > 등록일 </ th >
< th className = " px-6 py-4 text-center " > 계약관리 </ th >
</ tr >
</ thead >
< tbody className = " divide-y divide-slate-100 " >
{ loading ? (
< tr >< td colSpan = " 6 " className = " px-6 py-10 text-center text-slate-400 " > 데이터 로딩 중 ...</ td ></ tr >
) : tenants . length === 0 ? (
< tr >< td colSpan = " 6 " className = " px-6 py-10 text-center text-slate-400 " > 등록된 테넌트가 없습니다 .</ td ></ tr >
) : tenants . map ( t => (
< React . Fragment key = { t . id } >
< tr className = { `hover:bg-blue-50/30 transition-colors ${expandedTenantId === t.id ? 'bg-blue-50/50' : ''}` } >
< td className = " px-6 py-4 " >
< button onClick = {() => handleToggleTenant ( t . id )} className = " text-slate-400 hover:text-blue-600 " >
< LucideIcon name = { expandedTenantId === t . id ? " chevron-down " : " chevron-right " } className = " w-4 h-4 " />
</ button >
</ td >
2025-12-24 09:46:07 +09:00
< td className = " px-6 py-4 font-bold text-slate-900 " >
{ t . tenant_name }
< div className = " text-[10px] text-slate-400 font-normal mt-0.5 " > { t . representative } | { formatPhone ( t . contact_phone )} </ div >
</ td >
< td className = " px-6 py-4 " >
< div className = " flex items-center gap-2 " >
< span className = " px-2 py-0.5 bg-slate-100 text-slate-600 rounded text-[10px] font-bold " > 영업 : { t . register_name } </ span >
2025-12-30 21:34:17 +09:00
< div className = " relative " >
{ t . sales_manager_id ? (
< button
onClick = {( e ) => { e . stopPropagation (); if ( currentRole === '영업관리' ) setActiveManagerPopover ( t . id ); }}
className = { ` px - 2 py - 0.5 rounded text - [ 10 px ] font - bold border transition - all flex items - center gap - 1 $ {
t . sales_manager_id == currentUser . id
? 'bg-blue-100 text-blue-700 border-blue-200'
: 'bg-blue-50 text-blue-600 border-blue-100'
} $ { currentRole === '영업관리' ? 'cursor-pointer hover:bg-white hover:shadow-sm' : 'cursor-default' } ` }
2025-12-24 09:46:07 +09:00
>
< LucideIcon name = " user-check " className = " w-2.5 h-2.5 " />
2025-12-30 21:34:17 +09:00
관리 : { t . sales_manager_id == currentUser . id ? '본인' : ( t . manager_name || '지정됨' )}
{ currentRole === '영업관리' && < LucideIcon name = " chevron-down " className = " w-2 h-2 " /> }
2025-12-24 09:46:07 +09:00
</ button >
) : (
2025-12-30 21:34:17 +09:00
currentRole === '영업관리' && (
< button
onClick = {( e ) => { e . stopPropagation (); setActiveManagerPopover ( t . id ); }}
className = " px-2 py-0.5 bg-amber-50 text-amber-600 hover:bg-white hover:shadow-sm rounded text-[10px] font-bold border border-amber-200 transition-all flex items-center gap-1 "
>
< LucideIcon name = " user-plus " className = " w-2.5 h-2.5 " />
관리 : 미지정
< LucideIcon name = " chevron-down " className = " w-2 h-2 " />
</ button >
)
)}
{ activeManagerPopover === t . id && (
< div
ref = { popoverRef }
className = " absolute left-0 mt-2 w-48 bg-white rounded-xl shadow-xl border border-slate-100 py-2 z-[110] animate-in fade-in slide-in-from-top-1 duration-200 "
onClick = {( e ) => e . stopPropagation ()}
2025-12-24 09:46:07 +09:00
>
2025-12-30 21:34:17 +09:00
< div className = " px-4 py-2 border-b border-slate-50 " >
< div className = " text-[10px] font-bold text-slate-400 uppercase tracking-wider " > 매니저 지정 / 변경 </ div >
</ div >
< div className = " max-h-60 overflow-y-auto " >
< button
onClick = {() => handleUpdateManagerAssignment ( t . id , currentUser . id )}
className = { `w-full text-left px-4 py-2 text-xs hover:bg-slate-50 flex items-center gap-2 ${t.sales_manager_id == currentUser.id ? 'text-blue-600 font-bold bg-blue-50' : 'text-slate-700 font-medium'}` }
>
< LucideIcon name = " user-check " className = " w-3 h-3 " />
본인이 직접수행
</ button >
{ filteredManagers . filter ( m => m . id != currentUser . id ) . map ( m => (
< button
key = { m . id }
onClick = {() => handleUpdateManagerAssignment ( t . id , m . id )}
className = { `w-full text-left px-4 py-2 text-xs hover:bg-slate-50 flex items-center gap-2 ${t.sales_manager_id == m.id ? 'text-blue-600 font-bold bg-blue-50' : 'text-slate-700 font-medium'}` }
>
< div className = " w-3 h-3 rounded bg-slate-100 flex items-center justify-center text-[8px] font-bold " > { m . role === 'sales_admin' ? '관' : '매' } </ div >
{ m . name } ({ m . member_id })
</ button >
))}
</ div >
{ t . sales_manager_id && (
< div className = " mt-1 pt-1 border-t border-slate-50 " >
< button
onClick = {() => handleUpdateManagerAssignment ( t . id , null )}
className = " w-full text-left px-4 py-2 text-xs text-red-500 font-bold hover:bg-red-50 flex items-center gap-2 "
>
< LucideIcon name = " user-minus " className = " w-3 h-3 " />
지정 해제
</ button >
</ div >
)}
</ div >
)}
</ div >
2025-12-24 09:46:07 +09:00
</ div >
</ td >
2025-12-23 09:00:57 +09:00
< td className = " px-6 py-4 text-slate-400 text-xs " > { t . created_at ? . split ( ' ' )[ 0 ]} </ td >
2026-01-04 17:39:59 +09:00
< td className = " px-6 py-4 text-center " >
< div className = " flex items-center gap-2 justify-center " >
{ currentRole === '영업관리' && (
< button
onClick = {() => setActiveSalesScenarioTenant ( t )}
className = " px-3 py-1.5 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 transition-all border border-blue-700 flex items-center gap-1 shadow-sm "
>
< LucideIcon name = " trending-up " className = " w-3.5 h-3.5 " />
영업 진행
</ button >
)}
{( currentRole === '매니저' || ( currentRole === '영업관리' && t . sales_manager_id == currentUser . id )) && (
< button
onClick = {() => setActiveManagerScenarioTenant ( t )}
className = " px-3 py-1.5 bg-emerald-600 text-white rounded-lg font-bold hover:bg-emerald-700 transition-all border border-emerald-700 flex items-center gap-1 shadow-sm "
>
< LucideIcon name = " clipboard-check " className = " w-3.5 h-3.5 " />
매니저 진행
</ button >
)}
< div className = " flex items-center gap-1 ml-2 border-l border-slate-200 pl-2 " >
2025-12-24 09:46:07 +09:00
< button
2026-01-04 17:39:59 +09:00
onClick = {() => handleOpenEditTenant ( t )}
className = " p-1.5 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors "
title = " 수정 및 계약 관리 "
2025-12-24 09:46:07 +09:00
>
2026-01-04 17:39:59 +09:00
< LucideIcon name = " edit-2 " className = " w-4 h-4 " />
2025-12-24 09:46:07 +09:00
</ button >
2026-01-04 17:39:59 +09:00
< button
onClick = {() => handleDeleteTenant ( t . id )}
className = " p-1.5 text-red-600 hover:bg-red-50 rounded-lg transition-colors "
title = " 삭제 "
>
< LucideIcon name = " trash-2 " className = " w-4 h-4 " />
</ button >
</ div >
</ div >
</ td >
2025-12-23 09:00:57 +09:00
</ tr >
{ expandedTenantId === t . id && (
< tr >
< td colSpan = " 6 " className = " px-6 py-4 bg-slate-50/50 border-b border-blue-100 " >
< div className = " pl-12 space-y-4 animate-in slide-in-from-top-1 duration-200 " >
< div className = " flex items-center justify-between " >
< h4 className = " text-sm font-black text-slate-700 flex items-center gap-2 " >
< div className = " w-1.5 h-1.5 bg-blue-500 rounded-full " ></ div >
체결 상품 및 수당 내역
</ h4 >
</ div >
< div className = " bg-white rounded-xl border border-slate-200 overflow-hidden shadow-sm " >
< table className = " w-full text-xs " >
< thead className = " bg-slate-100 text-slate-600 font-bold border-b border-slate-200 " >
< tr >
< th className = " px-4 py-2 " > 상품명 </ th >
< th className = " px-4 py-2 text-right " > 계약금액 </ th >
< th className = " px-4 py-2 text-center " > 수수료율 </ th >
< th className = " px-4 py-2 text-right text-blue-600 " > 내 수익 </ th >
< th className = " px-4 py-2 text-center " > 계약일 </ th >
< th className = " px-4 py-2 text-center " > 운영팀 승인 </ th >
2025-12-24 09:46:07 +09:00
< th className = " px-4 py-2 text-center " > 관리 </ th >
2025-12-23 09:00:57 +09:00
</ tr >
</ thead >
< tbody className = " divide-y divide-slate-100 " >
{ ! tenantProducts [ t . id ] ? (
< tr >< td colSpan = " 6 " className = " px-4 py-8 text-center text-slate-400 " > 로딩 중 ...</ td ></ tr >
) : tenantProducts [ t . id ] . length === 0 ? (
< tr >< td colSpan = " 6 " className = " px-4 py-8 text-center text-slate-400 " > 등록된 계약 정보가 없습니다 .</ td ></ tr >
) : tenantProducts [ t . id ] . map ( p => (
< tr key = { p . id } className = " hover:bg-slate-50 transition-colors " >
< td className = " px-4 py-3 font-medium text-slate-800 " > { p . product_name } </ td >
< td className = " px-4 py-3 text-right text-slate-600 font-mono " > { formatCurrency ( p . contract_amount )} </ td >
< td className = " px-4 py-3 text-center text-slate-500 " > { p . commission_rate } %</ td >
< td className = " px-4 py-3 text-right font-bold text-blue-600 font-mono " > { formatCurrency ( p . commission_amount )} </ td >
< td className = " px-4 py-3 text-center text-slate-400 " > { p . contract_date } </ td >
< td className = " px-4 py-3 text-center " >
{ p . operator_confirmed == 1 ? (
< span className = " px-2 py-0.5 bg-emerald-100 text-emerald-700 rounded-full font-bold text-[10px] uppercase " > Confirmed </ span >
) : (
< span className = " px-2 py-0.5 bg-amber-100 text-amber-700 rounded-full font-bold text-[10px] uppercase " > Pending </ span >
)}
</ td >
2025-12-24 09:46:07 +09:00
< td className = " px-4 py-3 text-center " >
{ p . operator_confirmed == 0 && (
< div className = " flex items-center justify-center gap-1 " >
< button
onClick = {() => handleOpenEditProduct ( t , p )}
className = " p-1.5 text-blue-500 hover:bg-blue-50 rounded-lg transition-colors group relative "
title = " 계약 수정 "
>
< LucideIcon name = " edit " className = " w-4 h-4 " />
< span className = " absolute -top-8 left-1/2 -translate-x-1/2 bg-slate-800 text-white text-[10px] px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-50 " > 수정 </ span >
</ button >
< button
onClick = {() => handleDeleteProduct ( t . id , p . id )}
className = " p-1.5 text-rose-500 hover:bg-rose-50 rounded-lg transition-colors group relative "
title = " 계약 삭제 "
>
< LucideIcon name = " trash-2 " className = " w-4 h-4 " />
< span className = " absolute -top-8 left-1/2 -translate-x-1/2 bg-slate-800 text-white text-[10px] px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-50 " > 삭제 </ span >
</ button >
</ div >
)}
</ td >
2025-12-23 09:00:57 +09:00
</ tr >
))}
</ tbody >
</ table >
</ div >
</ div >
</ td >
</ tr >
)}
</ React . Fragment >
))}
</ tbody >
</ table >
</ div >
</ section >
{ /* Tenant Modal */ }
{ isTenantModalOpen && (
< div className = " fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm " >
< div className = " bg-white rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden animate-in zoom-in duration-200 " >
< form onSubmit = { handleCreateTenant } >
< div className = " p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50 " >
< h3 className = " text-xl font-bold text-slate-900 flex items-center gap-2 " >
2026-01-04 17:39:59 +09:00
< LucideIcon name = { editingTenantId ? " edit-3 " : " building " } className = " w-5 h-5 text-blue-600 " />
{ editingTenantId ? '테넌트 정보 수정' : '신규 테넌트 등록' }
2025-12-24 09:46:07 +09:00
< button
type = " button "
onClick = { fillRandomTenantData }
className = " ml-2 p-1.5 bg-amber-50 text-amber-600 rounded-lg hover:bg-amber-100 transition-all border border-amber-200 group relative "
title = " 샘플 데이터 자동 입력 "
>
< LucideIcon name = " zap " className = " w-3.5 h-3.5 " />
< span className = " absolute -top-10 left-1/2 -translate-x-1/2 bg-slate-800 text-white text-[10px] px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-50 shadow-xl " > 랜덤 데이터 채우기 </ span >
</ button >
2025-12-23 09:00:57 +09:00
</ h3 >
2026-01-04 17:39:59 +09:00
< button type = " button " onClick = {() => { setIsTenantModalOpen ( false ); setEditingTenantId ( null ); }} className = " p-2 hover:bg-slate-200 rounded-full transition-colors " >
2025-12-23 09:00:57 +09:00
< LucideIcon name = " x " className = " w-5 h-5 text-slate-500 " />
</ button >
</ div >
< div className = " p-6 space-y-4 " >
< div >
< label className = " block text-xs font-bold text-slate-500 mb-1 " > 업체명 *</ label >
< input type = " text " required value = { tenantFormData . tenant_name } onChange = { e => setTenantFormData ({ ... tenantFormData , tenant_name : e . target . value })} className = " w-full px-3 py-2 border border-slate-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 " placeholder = " 예: (주)미래소프트 " />
</ div >
< div className = " grid grid-cols-2 gap-4 " >
< div >
< label className = " block text-xs font-bold text-slate-500 mb-1 " > 대표자명 </ label >
< input type = " text " value = { tenantFormData . representative } onChange = { e => setTenantFormData ({ ... tenantFormData , representative : e . target . value })} className = " w-full px-3 py-2 border border-slate-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 " placeholder = " 홍길동 " />
</ div >
< div >
< label className = " block text-xs font-bold text-slate-500 mb-1 " > 사업자번호 </ label >
< input type = " text " value = { tenantFormData . business_no } onChange = { e => setTenantFormData ({ ... tenantFormData , business_no : formatBusinessNo ( e . target . value )})} className = " w-full px-3 py-2 border border-slate-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 " placeholder = " 000-00-00000 " />
</ div >
</ div >
< div className = " grid grid-cols-2 gap-4 " >
< div >
< label className = " block text-xs font-bold text-slate-500 mb-1 " > 연락처 </ label >
< input type = " tel " value = { tenantFormData . contact_phone } onChange = { e => setTenantFormData ({ ... tenantFormData , contact_phone : formatPhone ( e . target . value )})} className = " w-full px-3 py-2 border border-slate-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 " placeholder = " 010-0000-0000 " />
</ div >
< div >
< label className = " block text-xs font-bold text-slate-500 mb-1 " > 이메일 </ label >
< input type = " email " value = { tenantFormData . email } onChange = { e => setTenantFormData ({ ... tenantFormData , email : e . target . value })} className = " w-full px-3 py-2 border border-slate-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 " placeholder = " example@mail.com " />
</ div >
</ div >
2025-12-24 09:46:07 +09:00
< div >
< label className = " block text-xs font-bold text-slate-500 mb-1 " > 담당 매니저 지정 </ label >
< select
value = { tenantFormData . sales_manager_id }
onChange = { e => setTenantFormData ({ ... tenantFormData , sales_manager_id : e . target . value })}
className = " w-full px-3 py-2 border border-slate-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 bg-white "
>
2025-12-30 21:34:17 +09:00
{ filteredManagers . map ( m => (
2025-12-24 09:46:07 +09:00
< option key = { m . id } value = { m . id } >
{ m . name } ({ m . role }) { m . id === currentUser . id ? '- 본인' : '' }
</ option >
))}
</ select >
< p className = " mt-1 text-[10px] text-slate-400 font-medium " >* 직접 관리하시려면 본인을 선택하고 , 별도 매니저에게 맡기려면 매니저를 선택하세요 .</ p >
</ div >
2025-12-23 09:00:57 +09:00
< div >
< label className = " block text-xs font-bold text-slate-500 mb-1 " > 주소 </ label >
< input type = " text " value = { tenantFormData . address } onChange = { e => setTenantFormData ({ ... tenantFormData , address : e . target . value })} className = " w-full px-3 py-2 border border-slate-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 " placeholder = " 상세 주소를 입력하세요 " />
</ div >
</ div >
2026-01-04 17:39:59 +09:00
< div className = " p-6 border-t border-slate-100 bg-slate-50 flex justify-between items-center gap-3 " >
< div className = " flex gap-2 " >
{ editingTenantId && (
< button
type = " button "
onClick = {() => {
const t = tenants . find ( tt => tt . id === editingTenantId );
setSelectedTenant ( t );
setIsTenantModalOpen ( false );
setIsProductModalOpen ( true );
}}
className = " px-4 py-2 bg-indigo-50 text-indigo-600 rounded-lg font-bold hover:bg-indigo-100 transition-all border border-indigo-100 flex items-center gap-1 "
>
< LucideIcon name = " plus-circle " className = " w-4 h-4 " />
계약 추가
</ button >
)}
</ div >
< div className = " flex gap-2 " >
< button type = " button " onClick = {() => { setIsTenantModalOpen ( false ); setEditingTenantId ( null ); }} className = " px-4 py-2 text-slate-700 bg-white border border-slate-300 rounded-lg font-medium " > 취소 </ button >
< button type = " submit " className = " px-6 py-2 bg-blue-600 text-white rounded-lg font-bold shadow-lg shadow-blue-100 hover:bg-blue-700 transition-all " >
{ editingTenantId ? '수정 완료' : '등록하기' }
</ button >
</ div >
2025-12-23 09:00:57 +09:00
</ div >
</ form >
</ div >
</ div >
)}
{ /* Product Modal */ }
{ isProductModalOpen && selectedTenant && (
< div className = " fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm " >
2025-12-24 09:46:07 +09:00
< div className = " bg-white rounded-2xl shadow-2xl w-full max-w-md flex flex-col max-h-[90vh] overflow-hidden animate-in zoom-in duration-200 " >
< form onSubmit = { handleAddProduct } className = " flex flex-col overflow-hidden " >
2025-12-23 09:00:57 +09:00
< div className = " p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50 " >
< h3 className = " text-xl font-bold text-slate-900 flex items-center gap-2 " >
< LucideIcon name = " package-plus " className = " w-5 h-5 text-indigo-600 " />
계약 정보 추가
2025-12-24 09:46:07 +09:00
< button
type = " button "
onClick = { fillRandomProductData }
className = " ml-2 p-1.5 bg-amber-50 text-amber-600 rounded-lg hover:bg-amber-100 transition-all border border-amber-200 group relative "
title = " 샘플 데이터 자동 입력 "
>
< LucideIcon name = " zap " className = " w-3.5 h-3.5 " />
< span className = " absolute -top-10 left-1/2 -translate-x-1/2 bg-slate-800 text-white text-[10px] px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-50 shadow-xl " > 랜덤 데이터 채우기 </ span >
</ button >
2025-12-23 09:00:57 +09:00
</ h3 >
2025-12-24 09:46:07 +09:00
< button type = " button " onClick = {() => {
setIsProductModalOpen ( false );
setEditProductId ( null );
setProductFormData ({ product_name : '' , contract_amount : '' , commission_rate : '20' , contract_date : new Date () . toISOString () . split ( 'T' )[ 0 ] });
}} className = " p-2 hover:bg-slate-200 rounded-full transition-colors " >
2025-12-23 09:00:57 +09:00
< LucideIcon name = " x " className = " w-5 h-5 text-slate-500 " />
</ button >
</ div >
2025-12-24 09:46:07 +09:00
< div className = " p-6 space-y-4 flex-1 overflow-y-auto " >
< div className = " p-3 bg-blue-50 rounded-lg border border-blue-100 mb-2 " >
< p className = " text-xs text-blue-600 font-bold uppercase tracking-wider mb-1 " > Target Tenant </ p >
< p className = " text-sm font-black text-blue-900 " > { selectedTenant . tenant_name } </ p >
2025-12-23 09:00:57 +09:00
</ div >
2025-12-24 09:46:07 +09:00
{ /* Simulator-like Selector */ }
< div className = " space-y-4 " >
< label className = " block text-xs font-bold text-slate-500 mb-1 " > 상품 및 패키지 선택 *</ label >
< div className = " grid grid-cols-1 gap-2 " >
{( salesConfig . package_types || []) . map ( pkg => {
const isSelected = selectedCategory === pkg . id ;
const key = `package_${pkg.id}` ;
const dbPrice = pricingData [ key ] || { join_fee : pkg . join_fee };
return (
< div key = { pkg . id }
onClick = {() => {
setSelectedCategory ( pkg . id );
if ( pkg . id !== 'select_models' ) {
setProductFormData ({
... productFormData ,
product_name : pkg . name ,
contract_amount : dbPrice . join_fee ,
commission_rate : '20' // 기본 20%
});
setSelectedSubModels ([]);
} else {
setProductFormData ({
... productFormData ,
product_name : '선택모델 하이브리드' ,
contract_amount : 0 ,
commission_rate : '20'
});
}
}}
className = { `p-3 border rounded-xl cursor-pointer transition-all ${isSelected ? 'border-indigo-500 bg-indigo-50 ring-2 ring-indigo-200' : 'border-slate-200 hover:border-slate-300 bg-white'}` }
>
< div className = " flex justify-between items-center " >
< div className = " font-bold text-slate-900 text-sm " > { pkg . name } </ div >
{ pkg . id !== 'select_models' && (
< div className = " text-xs font-black text-indigo-600 " > { formatCurrency ( dbPrice . join_fee )} </ div >
)}
</ div >
< div className = " text-[10px] text-slate-500 mt-0.5 " > { pkg . id === 'select_models' ? '세부 모델을 직접 선택합니다' : '고정형 패키지' } </ div >
</ div >
);
})}
</ div >
{ /* Select Models Detail */ }
{ selectedCategory === 'select_models' && (
< div className = " p-3 bg-slate-50 rounded-xl border border-slate-200 space-y-2 max-h-48 overflow-y-auto " >
< label className = " block text-[10px] font-black text-slate-400 uppercase mb-2 " > 세부 모델 선택 </ label >
{( salesConfig . package_types . find ( p => p . id === 'select_models' ) . models || []) . map ( model => {
const isChecked = selectedSubModels . includes ( model . id );
const key = `model_${model.id}` ;
const dbPrice = pricingData [ key ] || { join_fee : model . join_fee };
return (
< div key = { model . id } className = " flex items-center gap-2 " >
< input
type = " checkbox "
checked = { isChecked }
onChange = {( e ) => {
let newSubModels ;
if ( e . target . checked ) {
newSubModels = [ ... selectedSubModels , model . id ];
} else {
newSubModels = selectedSubModels . filter ( id => id !== model . id );
}
setSelectedSubModels ( newSubModels );
// Calculate total amount
const total = newSubModels . reduce (( sum , id ) => {
const m = salesConfig . package_types . find ( p => p . id === 'select_models' ) . models . find ( mod => mod . id === id );
const pKey = `model_${id}` ;
const mPrice = pricingData [ pKey ] || { join_fee : m . join_fee };
return sum + ( mPrice . join_fee || 0 );
}, 0 );
setProductFormData ({
... productFormData ,
product_name : newSubModels . length > 0 ? `선택모델(${newSubModels.length}종)` : '' ,
contract_amount : total
});
}}
className = " w-4 h-4 text-indigo-600 rounded "
/>
< div className = " flex-1 text-xs font-medium text-slate-700 " > { model . name } </ div >
< div className = " text-[10px] text-slate-500 " > { formatCurrency ( dbPrice . join_fee )} </ div >
</ div >
);
})}
</ div >
)}
</ div >
< div className = " grid grid-cols-2 gap-4 border-t border-slate-100 pt-4 " >
< div >
< label className = " block text-xs font-bold text-slate-500 mb-1 " > 상품명 ( 자동설정 ) </ label >
< input type = " text " required value = { productFormData . product_name } onChange = { e => setProductFormData ({ ... productFormData , product_name : e . target . value })} className = " w-full px-3 py-2 border border-slate-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 bg-slate-50 " placeholder = " 위에서 상품을 선택하세요 " />
</ div >
< div >
< label className = " block text-xs font-bold text-slate-500 mb-1 " > 총 계약금액 ( 자동설정 ) </ label >
< input
type = " text "
required
value = { productFormData . contract_amount ? Number ( productFormData . contract_amount ) . toLocaleString () : '' }
onChange = { e => setProductFormData ({ ... productFormData , contract_amount : e . target . value . replace ( / [ ^ 0 - 9 ] / g , '' )})}
className = " w-full px-3 py-2 border border-slate-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 bg-slate-50 font-bold text-blue-600 text-right "
/>
</ div >
</ div >
< div className = " grid grid-cols-2 gap-4 " >
< div >
< label className = " block text-xs font-bold text-slate-500 mb-1 " > 수수료율 ( % ) </ label >
< input type = " number " value = { productFormData . commission_rate } onChange = { e => setProductFormData ({ ... productFormData , commission_rate : e . target . value })} className = " w-full px-3 py-2 border border-slate-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 " />
</ div >
< div >
< label className = " block text-xs font-bold text-slate-500 mb-1 " > 계약일 </ label >
< input type = " date " value = { productFormData . contract_date } onChange = { e => setProductFormData ({ ... productFormData , contract_date : e . target . value })} className = " w-full px-3 py-2 border border-slate-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 " />
</ div >
2025-12-23 09:00:57 +09:00
</ div >
< div className = " p-3 bg-indigo-50 rounded-lg border border-indigo-100 flex justify-between items-center " >
< span className = " text-xs font-bold text-indigo-700 " > 예상 내 수익 :</ span >
< span className = " text-lg font-black text-indigo-900 " >
{ formatCurrency (( productFormData . contract_amount || 0 ) * ( productFormData . commission_rate || 0 ) / 100 )}
</ span >
</ div >
</ div >
< div className = " p-6 border-t border-slate-100 bg-slate-50 flex justify-end gap-3 " >
< button type = " button " onClick = {() => setIsProductModalOpen ( false )} className = " px-4 py-2 text-slate-700 bg-white border border-slate-300 rounded-lg font-medium " > 취소 </ button >
< button type = " submit " className = " px-6 py-2 bg-indigo-600 text-white rounded-lg font-bold shadow-lg shadow-indigo-100 hover:bg-indigo-700 transition-all " > 저장하기 </ button >
</ div >
</ form >
</ div >
</ div >
)}
2025-12-24 09:46:07 +09:00
{ /* Scenario Modals */ }
{ activeSalesScenarioTenant && (
< ManagerScenarioView
2025-12-30 21:34:17 +09:00
key = { `sales_${activeSalesScenarioTenant.id}` }
2025-12-24 09:46:07 +09:00
tenant = { activeSalesScenarioTenant }
scenarioType = " sales "
onClose = {() => setActiveSalesScenarioTenant ( null )}
onTriggerContract = {( t ) => {
setActiveSalesScenarioTenant ( null );
setSelectedTenant ( t );
setIsProductModalOpen ( true );
}}
/>
)}
{ activeManagerScenarioTenant && (
< ManagerScenarioView
2025-12-30 21:34:17 +09:00
key = { `manager_${activeManagerScenarioTenant.id}` }
2025-12-24 09:46:07 +09:00
tenant = { activeManagerScenarioTenant }
scenarioType = " manager "
onClose = {() => setActiveManagerScenarioTenant ( null )}
/>
)}
2025-12-23 09:00:57 +09:00
</ div >
);
};
2025-12-17 12:59:26 +09:00
// 3. Main App Component
const App = () => {
const [ loading , setLoading ] = useState ( true );
const [ data , setData ] = useState ( null );
const [ selectedRecord , setSelectedRecord ] = useState ( null );
const [ isHelpOpen , setIsHelpOpen ] = useState ( false );
const [ selectedRole , setSelectedRole ] = useState ( '영업관리' ); // 기본값: 영업관리
2025-12-21 19:19:02 +09:00
const [ organizationData , setOrganizationData ] = useState ( null );
const [ isOrgLoading , setIsOrgLoading ] = useState ( false );
// Session states
const [ currentUser , setCurrentUser ] = useState ( null );
const [ isLoggedIn , setIsLoggedIn ] = useState ( false );
2025-12-17 12:59:26 +09:00
useEffect (() => {
2025-12-21 19:19:02 +09:00
checkSession ();
// Fetch Mock Data (Remains same for UI parts)
2025-12-17 12:59:26 +09:00
fetch ( `api/company_info.php?role=${encodeURIComponent(selectedRole)}` )
. then ( res => res . json ())
. then ( jsonData => {
setData ( jsonData );
setLoading ( false );
})
. catch ( err => {
console . error ( " Failed to fetch data: " , err );
setLoading ( false );
});
}, [ selectedRole ]);
2025-12-21 19:19:02 +09:00
const checkSession = async () => {
try {
const res = await fetch ( 'api/sales_members.php?action=check_session' );
const sessData = await res . json ();
if ( sessData . success ) {
setCurrentUser ( sessData . user );
setIsLoggedIn ( true );
2025-12-24 09:46:07 +09:00
// 역할에 맞춰 화면 자동 전환
const userRole = sessData . user . role ;
if ( userRole === 'operator' ) setSelectedRole ( '운영자' );
else if ( userRole === 'sales_admin' ) setSelectedRole ( '영업관리' );
else if ( userRole === 'manager' ) setSelectedRole ( '매니저' );
2025-12-21 19:19:02 +09:00
} else {
2025-12-24 09:46:07 +09:00
setCurrentUser ( null );
setIsLoggedIn ( false );
2025-12-21 19:19:02 +09:00
}
} catch ( err ) {
console . error ( 'Session check failed:' , err );
}
};
2025-12-17 12:59:26 +09:00
2025-12-21 19:19:02 +09:00
const handleLoginSuccess = ( user ) => {
setCurrentUser ( user );
setIsLoggedIn ( true );
2025-12-24 09:46:07 +09:00
// 역할에 맞춰 화면 자동 전환
if ( user . role === 'operator' ) setSelectedRole ( '운영자' );
else if ( user . role === 'sales_admin' ) setSelectedRole ( '영업관리' );
else if ( user . role === 'manager' ) setSelectedRole ( '매니저' );
2025-12-21 19:19:02 +09:00
};
const handleLogout = async () => {
await fetch ( 'api/sales_members.php?action=logout' , { method : 'POST' });
setIsLoggedIn ( false );
setCurrentUser ( null );
setOrganizationData ( null );
};
useEffect (() => {
// 실적 데이터 가져오기 (로그인 상태일 때만)
if ( isLoggedIn ) {
fetchPerformanceData ();
} else {
setOrganizationData ( null );
setIsOrgLoading ( false );
}
}, [ isLoggedIn , selectedRole ]);
// 실적 데이터 가져오기 함수
const fetchPerformanceData = async () => {
try {
setIsOrgLoading ( true );
const res = await fetch ( `api/get_performance.php` );
const result = await res . json ();
if ( result . success ) {
setOrganizationData ( result . org_tree );
} else {
console . error ( 'Performance fetch failed:' , result . error );
setOrganizationData ( null );
}
} catch ( err ) {
console . error ( 'Fetch error:' , err );
setOrganizationData ( null );
} finally {
setIsOrgLoading ( false );
}
2025-12-17 12:59:26 +09:00
};
// 역할 변경 시 아이콘 업데이트 (조건부 return 전에 호출해야 함)
if ( loading ) {
return (
< div className = " min-h-screen flex items-center justify-center " >
< div className = " animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 " ></ div >
</ div >
);
}
if ( ! data ) return < div > 데이터를 불러올 수 없습니다 .</ div > ;
// 역할에 따른 화면 렌더링
const renderContentByRole = () => {
2025-12-21 19:19:02 +09:00
if ( ! isLoggedIn ) {
2025-12-24 09:46:07 +09:00
return < LoginView onLoginSuccess = { handleLoginSuccess } /> ;
2025-12-21 19:19:02 +09:00
}
2025-12-17 12:59:26 +09:00
switch ( selectedRole ) {
case '운영자' :
2025-12-21 19:19:02 +09:00
return < OperatorView currentUser = { currentUser } /> ;
2025-12-17 12:59:26 +09:00
case '영업관리' :
2025-12-21 19:19:02 +09:00
case '매니저' :
2025-12-17 12:59:26 +09:00
return (
2025-12-21 19:19:02 +09:00
< main className = " max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-12 " >
2025-12-17 12:59:26 +09:00
{ /* 수당 지급 일정 안내 */ }
2025-12-21 19:19:02 +09:00
< div className = " bg-blue-50 border border-blue-200 rounded-lg p-4 " >
2025-12-17 12:59:26 +09:00
< div className = " flex items-start gap-3 " >
< LucideIcon name = " info " className = " w-5 h-5 text-blue-600 mt-0.5 " />
< div >
< h3 className = " text-sm font-bold text-blue-900 mb-2 " > 수당 지급 일정 안내 </ h3 >
< p className = " text-sm text-blue-800 " > • 가입비 수당은 가입비 완료 후 지급됩니다 .</ p >
</ div >
</ div >
</ div >
2025-12-21 19:19:02 +09:00
{ /* Dashbaord & Org Tree */ }
2025-12-24 09:46:07 +09:00
{ selectedRole === '영업관리' && (
< SalesManagementDashboard organizationData = { organizationData } onRefresh = { fetchPerformanceData } isLoading = { isOrgLoading } />
)}
2025-12-21 19:19:02 +09:00
2025-12-23 09:00:57 +09:00
{ /* NEW: Profit & Tenant Management Section */ }
< section className = " mt-20 pt-12 border-t border-slate-200 " >
< div className = " flex items-center gap-3 mb-8 " >
< div className = " p-2 bg-blue-600 rounded-xl text-white shadow-lg shadow-blue-100 " >
< LucideIcon name = " wallet " className = " w-6 h-6 " />
</ div >
< h2 className = " text-3xl font-extrabold text-slate-900 tracking-tight " > 수익 및 테넌트 관리 </ h2 >
</ div >
2025-12-24 09:46:07 +09:00
< ProfitManagementView currentUser = { currentUser } salesConfig = { data . sales_config } currentRole = { selectedRole } />
2025-12-23 09:00:57 +09:00
</ section >
2025-12-21 19:19:02 +09:00
{ selectedRole === '영업관리' && (
2025-12-24 09:46:07 +09:00
<>
< ManagerManagementView currentUser = { currentUser } />
< SimulatorSection salesConfig = { data . sales_config } selectedRole = { selectedRole } />
</>
2025-12-21 19:19:02 +09:00
)}
2025-12-17 12:59:26 +09:00
</ main >
);
default :
return (
< main className = " max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8 " >
< div className = " text-center py-20 " >
< h2 className = " text-2xl font-bold text-slate-900 mb-4 " > 알 수 없는 역할 </ h2 >
2025-12-21 19:19:02 +09:00
</ div >
2025-12-17 12:59:26 +09:00
</ main >
);
}
};
return (
< div className = " min-h-screen pb-20 " >
< Header
companyInfo = { data . company_info }
onOpenHelp = {() => setIsHelpOpen ( true )}
selectedRole = { selectedRole }
2025-12-21 19:19:02 +09:00
onRoleChange = { async ( role ) => {
if ( role !== selectedRole ) {
await handleLogout ();
setSelectedRole ( role );
setIsOrgLoading ( true );
}
}}
currentUser = { currentUser }
onLogout = { handleLogout }
2025-12-17 12:59:26 +09:00
/>
{ renderContentByRole ()}
{ /* Detail Modal */ }
{ selectedRecord && (
< CommissionDetailModal
record = { selectedRecord }
programs = { data . sales_config . programs }
onClose = {() => setSelectedRecord ( null )}
/>
)}
{ /* Help Modal */ }
{ isHelpOpen && (
< HelpModal onClose = {() => setIsHelpOpen ( false )} />
)}
</ div >
);
};
// ... (SimulatorSection, SalesList, CommissionDetailModal remain same) ...
// 6. Sales Management Dashboard Component
2025-12-21 19:19:02 +09:00
const SalesManagementDashboard = ({ organizationData , onRefresh , isLoading }) => {
2025-12-17 12:59:26 +09:00
const [ periodType , setPeriodType ] = useState ( 'current_month' ); // current_month, custom
const [ startYear , setStartYear ] = useState ( new Date () . getFullYear ());
const [ startMonth , setStartMonth ] = useState ( new Date () . getMonth () + 1 );
const [ endYear , setEndYear ] = useState ( new Date () . getFullYear ());
const [ endMonth , setEndMonth ] = useState ( new Date () . getMonth () + 1 );
const [ periodOrgData , setPeriodOrgData ] = useState ( null );
const formatCurrency = ( val ) => new Intl . NumberFormat ( 'ko-KR' , { style : 'currency' , currency : 'KRW' }) . format ( val );
// 전체 누적 통계
const calculateTotalStats = ( orgData ) => {
if ( ! orgData ) return { totalRevenue : 0 , totalCommission : 0 , totalCount : 0 , commissionRate : 0 };
return {
2025-12-24 09:46:07 +09:00
totalRevenue : Number ( orgData . totalSales ) || 0 ,
totalCommission : Number ( orgData . commission ) || 0 ,
totalCount : Number ( orgData . contractCount ) || 0 ,
commissionRate : Number ( orgData . totalSales ) > 0 ? (( Number ( orgData . commission ) / Number ( orgData . totalSales )) * 100 ) . toFixed ( 1 ) : 0
2025-12-17 12:59:26 +09:00
};
};
// 기간별 통계
const calculatePeriodStats = ( orgData ) => {
if ( ! orgData ) return {
sellerCommission : 0 ,
managerCommission : 0 ,
educatorCommission : 0 ,
totalCommission : 0 ,
totalRevenue : 0 ,
commissionRate : 0
};
const myDirectSales = orgData . children . find ( c => c . isDirect );
2025-12-24 09:46:07 +09:00
const sellerCommission = myDirectSales ? Number ( myDirectSales . totalSales ) * 0.20 : 0 ;
2025-12-17 12:59:26 +09:00
const level1Children = orgData . children . filter ( c => ! c . isDirect && c . depth === 1 );
2025-12-24 09:46:07 +09:00
const level1Sales = level1Children . reduce (( sum , c ) => sum + ( Number ( c . totalSales ) || 0 ), 0 );
2025-12-17 12:59:26 +09:00
const managerCommission = level1Sales * 0.05 ;
const level2Sales = level1Children . reduce (( sum , c ) =>
2025-12-24 09:46:07 +09:00
c . children . reduce (( s , gc ) => s + ( Number ( gc . totalSales ) || 0 ), 0 ), 0 );
2025-12-20 21:46:23 +09:00
const educatorCommission = 0 ; // 메뉴제작 협업수당: 운영팀 별도 산정
2025-12-17 12:59:26 +09:00
const totalCommission = sellerCommission + managerCommission + educatorCommission ;
return {
sellerCommission ,
managerCommission ,
educatorCommission ,
totalCommission ,
2025-12-24 09:46:07 +09:00
totalRevenue : Number ( orgData . totalSales ) || 0 ,
commissionRate : Number ( orgData . totalSales ) > 0 ? (( totalCommission / Number ( orgData . totalSales )) * 100 ) . toFixed ( 1 ) : 0
2025-12-17 12:59:26 +09:00
};
};
// 기간별 데이터 필터링
useEffect (() => {
if ( ! organizationData ) return ;
let startDate , endDate ;
if ( periodType === 'current_month' ) {
// 당월
const now = new Date ();
startDate = new Date ( now . getFullYear (), now . getMonth (), 1 );
endDate = new Date ( now . getFullYear (), now . getMonth () + 1 , 0 );
} else {
// 커스텀 기간
startDate = new Date ( startYear , startMonth - 1 , 1 );
endDate = new Date ( endYear , endMonth , 0 );
}
// 날짜 범위로 계약 필터링
const filterNodeByDate = ( node ) => {
2025-12-21 19:19:02 +09:00
const nodeRole = node . isDirect ? ( node . depth === 0 ? 'direct' : ( node . depth === 1 ? 'manager' : 'educator' )) : 'manager' ; // Default role for children nodes
const filteredContracts = ( node . contracts || []) . map ( contract => ({
... contract ,
role : nodeRole
})) . filter ( contract => {
2025-12-17 12:59:26 +09:00
const contractDate = new Date ( contract . contractDate );
return contractDate >= startDate && contractDate <= endDate ;
});
const filteredChildren = node . children . map ( child => filterNodeByDate ( child )) . filter ( c => c !== null );
// 자신의 계약과 하위 계약 합산
2025-12-24 09:46:07 +09:00
const ownSales = filteredContracts . reduce (( sum , c ) => sum + ( Number ( c . amount ) || 0 ), 0 );
const childrenSales = filteredChildren . reduce (( sum , c ) => sum + ( Number ( c . totalSales ) || 0 ), 0 );
2025-12-17 12:59:26 +09:00
const totalSales = ownSales + childrenSales ;
2025-12-24 09:46:07 +09:00
const contractCount = filteredContracts . length + filteredChildren . reduce (( sum , c ) => sum + ( Number ( c . contractCount ) || 0 ), 0 );
2025-12-17 12:59:26 +09:00
// 데이터가 없으면 null 반환
if ( totalSales === 0 && filteredChildren . length === 0 ) {
return null ;
}
// 수당 재계산
let commission = 0 ;
if ( node . isDirect ) {
// 직접 판매
if ( node . depth === 0 ) commission = ownSales * 0.20 ;
else if ( node . depth === 1 ) commission = ownSales * 0.05 ;
2025-12-20 21:46:23 +09:00
else if ( node . depth === 2 ) commission = 0 ; // 메뉴제작 협업수당: 운영팀 별도 산정
2025-12-17 12:59:26 +09:00
} else {
// 영업관리
if ( node . depth === 0 ) {
2025-12-20 21:46:23 +09:00
// 내 조직: 직접 20% + 1차 하위 5% (메뉴제작 협업수당 별도)
2025-12-17 12:59:26 +09:00
const myDirect = filteredChildren . find ( c => c . isDirect );
const level1 = filteredChildren . filter ( c => ! c . isDirect && c . depth === 1 );
2025-12-24 09:46:07 +09:00
const level1Sales = level1 . reduce (( sum , c ) => sum + ( Number ( c . totalSales ) || 0 ), 0 );
2025-12-17 12:59:26 +09:00
const level2Sales = level1 . reduce (( sum , c ) =>
2025-12-24 09:46:07 +09:00
c . children . reduce (( s , gc ) => s + ( Number ( gc . totalSales ) || 0 ), 0 ), 0 );
commission = ( myDirect ? Number ( myDirect . totalSales ) * 0.20 : 0 ) + ( level1Sales * 0.05 ); // 메뉴제작 협업수당 제외
2025-12-17 12:59:26 +09:00
} else if ( node . depth === 1 ) {
commission = totalSales * 0.05 ;
} else if ( node . depth === 2 ) {
2025-12-20 21:46:23 +09:00
commission = 0 ; // 메뉴제작 협업수당: 운영팀 별도 산정
2025-12-17 12:59:26 +09:00
}
}
return {
... node ,
totalSales ,
contractCount ,
commission ,
contracts : filteredContracts ,
children : filteredChildren
};
};
const filtered = filterNodeByDate ( organizationData );
setPeriodOrgData ( filtered );
}, [ periodType , startYear , startMonth , endYear , endMonth , organizationData ]);
const totalStats = calculateTotalStats ( organizationData );
const periodStats = calculatePeriodStats ( periodOrgData );
const years = Array . from ({ length : 5 }, ( _ , i ) => new Date () . getFullYear () - i );
const months = Array . from ({ length : 12 }, ( _ , i ) => i + 1 );
return (
<>
{ /* 전체 누적 통계 */ }
< section >
< h2 className = " text-xl font-bold text-slate-900 mb-6 " > 전체 누적 실적 </ h2 >
< div className = " grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 " >
< StatCard
title = " 총 가입비 "
value = { formatCurrency ( totalStats . totalRevenue )}
subtext = " 전체 누적 가입비 "
icon = { < LucideIcon name = " trending-up " className = " w-5 h-5 " /> }
/>
< div className = " bg-gradient-to-br from-blue-50 to-indigo-50 rounded-card p-6 shadow-sm border border-blue-200 hover:shadow-md transition-shadow " >
< div className = " flex items-start justify-between mb-4 " >
< h3 className = " text-sm font-medium text-blue-700 " > 총 수당 </ h3 >
< div className = " p-2 bg-blue-100 rounded-lg text-blue-600 " >
< LucideIcon name = " wallet " className = " w-5 h-5 " />
</ div >
</ div >
< div className = " text-2xl font-bold text-blue-900 mb-1 " > { formatCurrency ( totalStats . totalCommission )} </ div >
< div className = " text-xs text-blue-600 font-medium " > 총 가입비의 { totalStats . commissionRate } %</ div >
</ div >
< StatCard
title = " 전체 건수 "
value = { `${totalStats.totalCount}건` }
subtext = " 전체 계약 건수 "
icon = { < LucideIcon name = " file-check " className = " w-5 h-5 " /> }
/>
</ div >
</ section >
{ /* 기간 선택 UI */ }
< section className = " bg-white rounded-card p-6 shadow-sm border border-slate-100 " >
< h2 className = " text-xl font-bold text-slate-900 mb-4 flex items-center gap-2 " >
< LucideIcon name = " calendar-range " className = " w-5 h-5 text-blue-600 " />
기간별 조회
</ h2 >
< div className = " flex flex-wrap items-center gap-4 " >
< div className = " flex items-center gap-2 " >
< button
onClick = {() => setPeriodType ( 'current_month' )}
className = { ` px - 4 py - 2 rounded - lg font - medium transition - colors $ {
periodType === 'current_month'
? 'bg-blue-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
} ` }
>
당월
</ button >
< button
onClick = {() => setPeriodType ( 'custom' )}
className = { ` px - 4 py - 2 rounded - lg font - medium transition - colors $ {
periodType === 'custom'
? 'bg-blue-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
} ` }
>
기간 설정
</ button >
</ div >
{ periodType === 'custom' && (
< div className = " flex items-center gap-3 " >
< select
value = { startYear }
onChange = {( e ) => {
const newStartYear = Number ( e . target . value );
setStartYear ( newStartYear );
// Logic: If Start Year > End Year, set End Year = Start Year.
// Also if Start Year == End Year and Start Month > End Month, set End Month = Start Month.
if ( newStartYear > endYear ) {
setEndYear ( newStartYear );
} else if ( newStartYear === endYear && startMonth > endMonth ) {
setEndMonth ( startMonth );
}
}}
className = " px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 "
>
{ years . map ( y => < option key = { y } value = { y } > { y } 년 </ option > )}
</ select >
< select
value = { startMonth }
onChange = {( e ) => {
const newStartMonth = Number ( e . target . value );
setStartMonth ( newStartMonth );
// Logic: If Start Year == End Year and Start Month > End Month, set End Month = Start Month.
if ( startYear === endYear && newStartMonth > endMonth ) {
setEndMonth ( newStartMonth );
}
// If Start Year > End Year, it should have been handled by Year change, but robustly:
if ( startYear > endYear ) {
setEndYear ( startYear );
}
}}
className = " px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 "
>
{ months . map ( m => < option key = { m } value = { m } > { m } 월 </ option > )}
</ select >
< span className = " text-slate-500 " >~</ span >
< select
value = { endYear }
onChange = {( e ) => {
const newEndYear = Number ( e . target . value );
setEndYear ( newEndYear );
// Logic: If End Year < Start Year, set Start Year = End Year.
// Also if End Year == Start Year and End Month < Start Month, set Start Month = End Month.
if ( newEndYear < startYear ) {
setStartYear ( newEndYear );
} else if ( newEndYear === startYear && endMonth < startMonth ) {
setStartMonth ( endMonth );
}
}}
className = " px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 "
>
{ years . map ( y => < option key = { y } value = { y } > { y } 년 </ option > )}
</ select >
< select
value = { endMonth }
onChange = {( e ) => {
const newEndMonth = Number ( e . target . value );
setEndMonth ( newEndMonth );
// Logic: If End Year == Start Year and End Month < Start Month, set Start Month = End Month.
if ( endYear === startYear && newEndMonth < startMonth ) {
setStartMonth ( newEndMonth );
}
// If End Year < Start Year, it should have been handled by Year change, but robustly:
if ( endYear < startYear ) {
setStartYear ( endYear );
}
}}
className = " px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 "
>
{ months . map ( m => < option key = { m } value = { m } > { m } 월 </ option > )}
</ select >
</ div >
)}
< div className = " text-sm text-slate-500 " >
{ periodType === 'current_month'
? `${new Date().getFullYear()}년 ${new Date().getMonth() + 1}월`
: `${startYear}년 ${startMonth}월 ~ ${endYear}년 ${endMonth}월` }
</ div >
</ div >
</ section >
{ /* 기간별 역할별 수당 상세 */ }
< section className = " bg-white rounded-card p-6 shadow-sm border border-slate-100 " >
< h3 className = " text-lg font-bold text-slate-900 mb-4 flex items-center gap-2 " >
< LucideIcon name = " layers " className = " w-5 h-5 text-blue-600 " />
역할별 수당 상세
</ h3 >
< div className = " grid grid-cols-1 md:grid-cols-3 gap-4 " >
< div className = " p-4 bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg border border-green-200 " >
< div className = " flex items-center justify-between mb-3 " >
< div className = " flex items-center gap-2 " >
< div className = " w-8 h-8 rounded-full bg-green-100 flex items-center justify-center " >
< LucideIcon name = " user " className = " w-4 h-4 text-green-600 " />
</ div >
2025-12-20 21:46:23 +09:00
< span className = " text-sm font-medium text-green-900 " > 판매자 </ span >
2025-12-17 12:59:26 +09:00
</ div >
< span className = " text-xs font-bold text-green-700 bg-green-100 px-2 py-1 rounded " > 20 %</ span >
</ div >
< div className = " text-2xl font-bold text-green-900 " > { formatCurrency ( periodStats . sellerCommission )} </ div >
2025-12-20 21:46:23 +09:00
< div className = " text-xs text-green-600 mt-1 " ></ div >
2025-12-17 12:59:26 +09:00
</ div >
< div className = " p-4 bg-gradient-to-br from-purple-50 to-violet-50 rounded-lg border border-purple-200 " >
< div className = " flex items-center justify-between mb-3 " >
< div className = " flex items-center gap-2 " >
< div className = " w-8 h-8 rounded-full bg-purple-100 flex items-center justify-center " >
< LucideIcon name = " users " className = " w-4 h-4 text-purple-600 " />
</ div >
2025-12-20 21:46:23 +09:00
< span className = " text-sm font-medium text-purple-900 " > 관리자 </ span >
2025-12-17 12:59:26 +09:00
</ div >
< span className = " text-xs font-bold text-purple-700 bg-purple-100 px-2 py-1 rounded " > 5 %</ span >
</ div >
< div className = " text-2xl font-bold text-purple-900 " > { formatCurrency ( periodStats . managerCommission )} </ div >
2025-12-20 21:46:23 +09:00
< div className = " text-xs text-purple-600 mt-1 " ></ div >
2025-12-17 12:59:26 +09:00
</ div >
< div className = " p-4 bg-gradient-to-br from-orange-50 to-amber-50 rounded-lg border border-orange-200 " >
< div className = " flex items-center justify-between mb-3 " >
< div className = " flex items-center gap-2 " >
< div className = " w-8 h-8 rounded-full bg-orange-100 flex items-center justify-center " >
2025-12-20 21:46:23 +09:00
< LucideIcon name = " users " className = " w-4 h-4 text-orange-600 " />
2025-12-17 12:59:26 +09:00
</ div >
2025-12-20 21:46:23 +09:00
< span className = " text-sm font-medium text-orange-900 " > 메뉴제작 협업수당 </ span >
2025-12-17 12:59:26 +09:00
</ div >
2025-12-20 21:46:23 +09:00
< span className = " text-xs font-bold text-orange-700 bg-orange-100 px-2 py-1 rounded " > 별도 </ span >
2025-12-17 12:59:26 +09:00
</ div >
2025-12-20 21:46:23 +09:00
< div className = " text-2xl font-bold text-orange-900 " > 운영팀 산정 </ div >
< div className = " text-xs text-orange-600 mt-1 " ></ div >
2025-12-17 12:59:26 +09:00
</ div >
</ div >
< div className = " mt-4 p-4 bg-blue-50 rounded-lg border border-blue-200 " >
2025-12-20 21:46:23 +09:00
< div className = " flex items-center justify-end " >
2025-12-17 12:59:26 +09:00
< div className = " text-right " >
< div className = " text-sm font-bold text-blue-900 " > { formatCurrency ( periodStats . totalCommission )} </ div >
2025-12-20 21:46:23 +09:00
< div className = " text-xs text-blue-600 " > 총 가입비 대비 수당 </ div >
</ div >
</ div >
</ div >
2025-12-17 12:59:26 +09:00
</ section >
{ /* 기간별 조직 트리 */ }
2025-12-21 19:19:02 +09:00
< OrganizationTree organizationData = { periodOrgData } onRefresh = { onRefresh } showPeriodData = { true } isLoading = { isLoading } />
2025-12-17 12:59:26 +09:00
</>
);
};
// 7. Hierarchical Organization Tree Component
2025-12-21 19:19:02 +09:00
const OrganizationTree = ({ organizationData , onRefresh , showPeriodData , isLoading }) => {
2025-12-17 12:59:26 +09:00
const [ expandedNodes , setExpandedNodes ] = useState ( new Set ([ 'root' ]));
const [ selectedManager , setSelectedManager ] = useState ( null );
const toggleNode = ( nodeId ) => {
const newExpanded = new Set ( expandedNodes );
if ( newExpanded . has ( nodeId )) {
newExpanded . delete ( nodeId );
} else {
newExpanded . add ( nodeId );
}
setExpandedNodes ( newExpanded );
};
const formatCurrency = ( val ) => new Intl . NumberFormat ( 'ko-KR' , { style : 'currency' , currency : 'KRW' }) . format ( val );
const getDepthColor = ( depth , isDirect ) => {
if ( isDirect ) return 'bg-yellow-50 border-yellow-200' ; // 직접 판매는 노란색
switch ( depth ) {
case 0 : return 'bg-blue-50 border-blue-200' ;
case 1 : return 'bg-green-50 border-green-200' ;
case 2 : return 'bg-purple-50 border-purple-200' ;
default : return 'bg-slate-50 border-slate-200' ;
}
};
const getDepthIcon = ( depth , isDirect ) => {
if ( isDirect ) return 'shopping-cart' ; // 직접 판매는 쇼핑카트 아이콘
switch ( depth ) {
case 0 : return 'crown' ;
case 1 : return 'user-check' ;
case 2 : return 'users' ;
default : return 'user' ;
}
};
const renderNode = ( node , depth = 0 ) => {
const isExpanded = expandedNodes . has ( node . id );
const hasChildren = node . children && node . children . length > 0 ;
const paddingLeft = depth * 24 ;
const isDirect = node . isDirect || false ;
return (
< div key = { node . id } >
< div
className = { `p-4 border ${getDepthColor(node.depth, isDirect)} rounded-lg hover:shadow-md transition-all cursor-pointer mb-2` }
style = {{ marginLeft : `${paddingLeft}px` }}
onClick = {() => {
if ( hasChildren && ! isDirect ) toggleNode ( node . id );
if ( ! isDirect ) setSelectedManager ( node );
}}
>
< div className = " flex items-center justify-between " >
< div className = " flex items-center gap-3 flex-1 " >
{ hasChildren && ! isDirect && (
< button
className = " p-1 hover:bg-white/50 rounded transition-colors "
onClick = {( e ) => {
e . stopPropagation ();
toggleNode ( node . id );
}}
>
< LucideIcon name = { isExpanded ? 'chevron-down' : 'chevron-right' } className = " w-4 h-4 " />
</ button >
)}
{( ! hasChildren || isDirect ) && < div className = " w-6 " ></ div > }
< div className = { `w-10 h-10 rounded-full ${isDirect ? 'bg-yellow-100' : 'bg-white/80'} flex items-center justify-center` } >
< LucideIcon name = { getDepthIcon ( node . depth , isDirect )} className = " w-5 h-5 text-slate-600 " />
</ div >
< div className = " flex-1 " >
< div className = " flex items-center gap-2 " >
< h4 className = { `font-bold ${isDirect ? 'text-orange-900' : 'text-slate-900'}` } > { node . name } </ h4 >
< span className = { `px-2 py-0.5 rounded-full text-xs font-medium ${isDirect ? 'bg-orange-100 text-orange-800' : 'bg-white/60'}` } >
{ node . role }
</ span >
{ hasChildren && ! isDirect && (
< span className = " px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 " >
하위 { node . children . filter ( c => ! c . isDirect ) . length } 명
</ span >
)}
</ div >
< div className = " flex gap-4 mt-1 text-xs text-slate-600 " >
< span > 매출 : < strong className = " text-slate-900 " > { formatCurrency ( node . totalSales )} </ strong ></ span >
< span > 계약 : < strong className = " text-slate-900 " > { node . contractCount } 건 </ strong ></ span >
{ node . depth === 0 && ! isDirect ? (
< span > 내 총 수당 : < strong className = " text-blue-600 " > { formatCurrency ( node . commission )} </ strong ></ span >
) : (
2025-12-20 21:46:23 +09:00
< span title = { `내가 받는 ${isDirect ? (node.depth === 0 ? '판매자 (20%)' : node.depth === 1 ? '관리자 (5%)' : '메뉴제작 협업자') : (node.depth === 1 ? '관리자 (5%)' : '메뉴제작 협업자')} 수당` } >
2025-12-17 12:59:26 +09:00
내 수당 : < strong className = " text-blue-600 " > { formatCurrency ( node . commission )} </ strong >
</ span >
)}
</ div >
</ div >
</ div >
{ ! isDirect && (
< button
onClick = {( e ) => {
e . stopPropagation ();
setSelectedManager ( node );
}}
className = " p-2 hover:bg-white/50 rounded-lg transition-colors "
>
< LucideIcon name = " more-vertical " className = " w-4 h-4 text-slate-400 " />
</ button >
)}
</ div >
</ div >
{ isExpanded && hasChildren && (
< div className = " space-y-2 " >
{ node . children . map ( child => renderNode ( child , depth + 1 ))}
</ div >
)}
</ div >
);
};
2025-12-21 19:19:02 +09:00
if ( isLoading ) {
return (
< section className = " bg-white rounded-card shadow-sm border border-slate-100 p-8 space-y-4 " >
{[ 1 , 2 , 3 ] . map ( i => (
< div key = { i } className = " p-4 border border-slate-100 rounded-lg animate-pulse bg-slate-50/50 " >
< div className = " flex items-center gap-3 " >
< div className = " w-10 h-10 rounded-full bg-slate-100 " ></ div >
< div className = " flex-1 space-y-2 " >
< div className = " h-4 bg-slate-100 rounded w-1/4 " ></ div >
< div className = " h-3 bg-slate-100 rounded w-1/2 " ></ div >
</ div >
</ div >
</ div >
))}
</ section >
);
}
2025-12-17 12:59:26 +09:00
if ( ! organizationData ) {
return (
2025-12-21 19:19:02 +09:00
< section className = " bg-white rounded-card shadow-sm border border-slate-100 p-12 text-center " >
< div className = " max-w-md mx-auto " >
< div className = " w-20 h-20 bg-slate-50 rounded-full flex items-center justify-center mx-auto mb-6 text-slate-300 border border-slate-50 shadow-inner " >
< LucideIcon name = " database-zap " className = " w-10 h-10 " />
</ div >
< h3 className = " text-xl font-bold text-slate-900 mb-2 " > 실적 데이터가 존재하지 않습니다 </ h3 >
< p className = " text-slate-500 text-sm mb-8 leading-relaxed " >
선택한 기간 내에 등록된 계약 정보나 조직 구성 데이터가 없습니다 .< br />
아직 실적이 발생하지 않았거나 , 시스템 동기화 중일 수 있습니다 .
</ p >
< button
onClick = {() => onRefresh && onRefresh ()}
className = " inline-flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-xl transition-all shadow-lg shadow-blue-100 hover:-translate-y-0.5 "
>
< LucideIcon name = " refresh-cw " className = " w-4 h-4 " />
실적 데이터 새로고침
</ button >
2025-12-17 12:59:26 +09:00
</ div >
</ section >
);
}
return (
<>
< section className = " bg-white rounded-card shadow-sm border border-slate-100 overflow-hidden " >
< div className = " p-6 border-b border-slate-100 bg-gradient-to-r from-blue-50 to-indigo-50 " >
< div className = " flex justify-between items-start mb-3 " >
< div >
< h2 className = " text-lg font-bold text-slate-900 flex items-center gap-2 " >
< LucideIcon name = " network " className = " w-5 h-5 text-blue-600 " />
{ showPeriodData ? '기간별 조직 구조 및 실적' : '조직 구조 및 실적' }
</ h2 >
< p className = " text-xs text-slate-500 mt-1 " > 계층별 가입비 및 수당 현황 ( 내 관점 ) </ p >
</ div >
{ ! showPeriodData && (
< button
onClick = {() => {
if ( onRefresh ) onRefresh ();
}}
className = " px-3 py-2 bg-white rounded-lg hover:bg-blue-50 transition-colors text-sm font-medium text-slate-700 flex items-center gap-2 shadow-sm "
>
< LucideIcon name = " refresh-cw " className = " w-4 h-4 " />
새로고침
</ button >
)}
</ div >
< div className = " flex gap-2 text-xs " >
< div className = " px-3 py-1.5 bg-blue-100 text-blue-800 rounded-full font-medium " >
2025-12-20 21:46:23 +09:00
판매자 ( 20 % )
2025-12-17 12:59:26 +09:00
</ div >
< div className = " px-3 py-1.5 bg-green-100 text-green-800 rounded-full font-medium " >
2025-12-20 21:46:23 +09:00
관리자 ( 5 % )
2025-12-17 12:59:26 +09:00
</ div >
< div className = " px-3 py-1.5 bg-purple-100 text-purple-800 rounded-full font-medium " >
2025-12-20 21:46:23 +09:00
메뉴제작 협업자 ( 별도 )
2025-12-17 12:59:26 +09:00
</ div >
</ div >
</ div >
< div className = " p-6 " >
{ renderNode ( organizationData )}
</ div >
</ section >
{ /* Detail Modal */ }
{ selectedManager && (
< ManagerDetailModal
manager = { selectedManager }
onClose = {() => setSelectedManager ( null )}
2025-12-24 09:46:07 +09:00
showPeriodData = { showPeriodData }
2025-12-17 12:59:26 +09:00
/>
)}
</>
);
};
// Manager Detail Modal
2025-12-24 09:46:07 +09:00
const ManagerDetailModal = ({ manager , onClose , showPeriodData }) => {
2025-12-17 12:59:26 +09:00
if ( ! manager ) return null ;
const formatCurrency = ( val ) => new Intl . NumberFormat ( 'ko-KR' , { style : 'currency' , currency : 'KRW' }) . format ( val );
const commissionRate = manager . totalSales > 0 ? (( manager . commission / manager . totalSales ) * 100 ) . toFixed ( 1 ) : 0 ;
2025-12-24 09:46:07 +09:00
const directSalesTotal = manager . contracts ? manager . contracts . reduce (( sum , c ) => sum + Number ( c . amount ), 0 ) : 0 ;
const directCommissionTotal = manager . contracts ? manager . contracts . reduce (( sum , c ) => sum + Number ( c . commission || 0 ), 0 ) : 0 ;
2025-12-17 12:59:26 +09:00
return (
< div className = " fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm " onClick = { onClose } >
< div className = " bg-white rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col " onClick = { e => e . stopPropagation ()} >
< div className = " p-6 border-b border-slate-100 flex justify-between items-center bg-gradient-to-r from-blue-50 to-indigo-50 flex-shrink-0 " >
< div className = " flex items-center gap-3 " >
< div className = " w-12 h-12 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 " >
< LucideIcon name = " user " className = " w-6 h-6 " />
</ div >
< div >
< h3 className = " text-xl font-bold text-slate-900 " > { manager . name } </ h3 >
2025-12-24 09:46:07 +09:00
< p className = " text-xs text-slate-500 " > { manager . role } | { showPeriodData ? '기간 실적 상세' : '누적 실적 상세' } </ p >
2025-12-17 12:59:26 +09:00
</ div >
</ div >
< button onClick = { onClose } className = " p-2 hover:bg-white/50 rounded-full transition-colors " >
< LucideIcon name = " x " className = " w-5 h-5 text-slate-500 " />
</ button >
</ div >
< div className = " p-6 space-y-6 overflow-y-auto flex-1 " >
< div className = " grid grid-cols-2 gap-4 " >
< div className = " p-4 bg-blue-50 rounded-xl border border-blue-100 " >
< div className = " text-xs text-blue-600 font-medium mb-1 flex items-center gap-1 " >
< LucideIcon name = " trending-up " className = " w-3 h-3 " />
2025-12-24 09:46:07 +09:00
조직 총 매출
</ div >
2025-12-17 12:59:26 +09:00
< div className = " text-xl font-bold text-blue-900 " > { formatCurrency ( manager . totalSales )} </ div >
</ div >
< div className = " p-4 bg-green-50 rounded-xl border border-green-100 " >
< div className = " text-xs text-green-600 font-medium mb-1 flex items-center gap-1 " >
< LucideIcon name = " file-check " className = " w-3 h-3 " />
계약 건수
</ div >
< div className = " text-xl font-bold text-green-900 " > { manager . contractCount } 건 </ div >
</ div >
</ div >
< div className = " p-4 bg-gradient-to-br from-purple-50 to-indigo-50 rounded-xl border border-purple-200 " >
< div className = " flex items-center justify-between " >
< div >
< div className = " text-xs text-purple-600 font-medium mb-1 flex items-center gap-1 " >
< LucideIcon name = " wallet " className = " w-3 h-3 " />
2025-12-24 09:46:07 +09:00
조직 예상 수당
2025-12-17 12:59:26 +09:00
</ div >
< div className = " text-2xl font-bold text-purple-900 " > { formatCurrency ( manager . commission )} </ div >
</ div >
< div className = " text-right " >
< div className = " text-xs text-purple-600 mb-1 " > 수당률 </ div >
< div className = " text-lg font-bold text-purple-700 " > { commissionRate } %</ div >
</ div >
</ div >
</ div >
{ /* 계약 목록 */ }
{ manager . contracts && manager . contracts . length > 0 && (
< div >
< h4 className = " text-sm font-bold text-slate-900 mb-3 flex items-center gap-2 " >
< LucideIcon name = " calendar-check " className = " w-4 h-4 text-blue-600 " />
계약 내역 ({ manager . contracts . length } 건 )
</ h4 >
< div className = " border border-slate-200 rounded-lg overflow-hidden max-h-80 overflow-y-auto " >
< table className = " w-full text-sm " >
< thead className = " bg-slate-50 border-b border-slate-200 " >
< tr >
< th className = " px-4 py-3 text-left text-xs font-medium text-slate-500 " > 번호 </ th >
< th className = " px-4 py-3 text-left text-xs font-medium text-slate-500 " > 계약일 </ th >
< th className = " px-4 py-3 text-right text-xs font-medium text-slate-500 " > 가입비 </ th >
< th className = " px-4 py-3 text-right text-xs font-medium text-slate-500 " > 내 수당 </ th >
</ tr >
</ thead >
< tbody className = " divide-y divide-slate-100 " >
{ manager . contracts . map (( contract , idx ) => {
2025-12-24 09:46:07 +09:00
const commissionForThis = contract . commission || 0 ;
2025-12-17 12:59:26 +09:00
return (
< tr key = { contract . id } className = " hover:bg-slate-50 " >
< td className = " px-4 py-3 text-slate-900 " > { idx + 1 } </ td >
< td className = " px-4 py-3 text-slate-900 " > { contract . contractDate } </ td >
< td className = " px-4 py-3 text-right font-medium text-slate-900 " > { formatCurrency ( contract . amount )} </ td >
< td className = " px-4 py-3 text-right font-bold text-blue-600 " > { formatCurrency ( commissionForThis )} </ td >
</ tr >
);
})}
</ tbody >
< tfoot className = " bg-slate-50 border-t border-slate-200 " >
2025-12-24 09:46:07 +09:00
< tr className = " bg-blue-50/30 " >
< td colSpan = " 2 " className = " px-4 py-3 text-sm font-bold text-slate-800 " > 직접 계약 합계 </ td >
< td className = " px-4 py-3 text-right text-sm font-bold text-slate-900 " > { formatCurrency ( directSalesTotal )} </ td >
< td className = " px-4 py-3 text-right text-sm font-bold text-blue-900 " > { formatCurrency ( directCommissionTotal )} </ td >
2025-12-17 12:59:26 +09:00
</ tr >
</ tfoot >
</ table >
</ div >
</ div >
)}
{ manager . children && manager . children . length > 0 && (
< div >
< h4 className = " text-sm font-bold text-slate-900 mb-3 flex items-center gap-2 " >
< LucideIcon name = " users " className = " w-4 h-4 text-blue-600 " />
하위 조직 ({ manager . children . length } 명 )
</ h4 >
< div className = " space-y-2 max-h-60 overflow-y-auto " >
{ manager . children . map ( child => (
< div key = { child . id } className = " p-3 bg-slate-50 rounded-lg border border-slate-200 flex items-center justify-between " >
< div className = " flex items-center gap-3 " >
< div className = " w-8 h-8 rounded-full bg-white flex items-center justify-center " >
< LucideIcon name = " user " className = " w-4 h-4 text-slate-600 " />
</ div >
< div >
< div className = " font-medium text-slate-900 text-sm " > { child . name } </ div >
< div className = " text-xs text-slate-500 " > { child . role } </ div >
</ div >
</ div >
< div className = " text-right " >
< div className = " text-sm font-bold text-slate-900 " > { formatCurrency ( child . totalSales )} </ div >
< div className = " text-xs text-slate-500 " > { child . contractCount } 건 </ div >
</ div >
</ div >
))}
</ div >
</ div >
)}
</ div >
< div className = " p-6 border-t border-slate-100 bg-slate-50 flex justify-end flex-shrink-0 " >
< button onClick = { onClose } className = " px-4 py-2 bg-white border border-slate-200 rounded-lg text-slate-700 hover:bg-slate-50 font-medium transition-colors " >
닫기
</ button >
</ div >
</ div >
</ div >
);
};
// 8. Sub-Manager Detail Modal
// 8. Help Modal
const HelpModal = ({ onClose }) => {
return (
< div className = " fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm " onClick = { onClose } >
< div className = " bg-white rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden " onClick = { e => e . stopPropagation ()} >
< div className = " p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50 " >
< h3 className = " text-xl font-bold text-slate-900 flex items-center gap-2 " >
< LucideIcon name = " book-open " className = " w-5 h-5 text-blue-600 " />
수당 체계 설명서
</ h3 >
< button onClick = { onClose } className = " p-2 hover:bg-slate-200 rounded-full transition-colors " >
< LucideIcon name = " x " className = " w-5 h-5 text-slate-500 " />
</ button >
</ div >
< div className = " p-6 space-y-6 max-h-[70vh] overflow-y-auto text-slate-600 text-sm leading-relaxed " >
< section >
< h4 className = " text-base font-bold text-slate-900 mb-2 " > 1. 수당 체계 개요 </ h4 >
< p className = " mb-3 " > 영업 수당은 < strong > 가입비 </ strong > 에 대해서만 지급됩니다 .</ p >
2025-12-20 21:46:23 +09:00
< p > 수당은 판매자와 그 상위 관리자 , 메뉴제작 협업자에게 지급됩니다 .</ p >
2025-12-17 12:59:26 +09:00
</ section >
< section >
< h4 className = " text-base font-bold text-slate-900 mb-2 " > 2. 수당 구성 </ h4 >
< p className = " mb-3 " > 영업 수당은 세 가지 역할에 따라 구분되어 지급됩니다 :</ p >
< ul className = " list-disc pl-5 space-y-2 " >
< li >< strong > 판매자 ( Seller ) </ strong >: 직접 영업을 성사시킨 담당자 - < span className = " text-blue-600 font-semibold " > 가입비의 20 %</ span ></ li >
< li >< strong > 관리자 ( Manager ) </ strong >: 판매자를 데려온 상위 담당자 - < span className = " text-green-600 font-semibold " > 가입비의 5 %</ span ></ li >
2025-12-20 21:46:23 +09:00
< li >< strong > 메뉴제작 협업자 ( Collaborator ) </ strong >: 관리자를 데려온 상위 담당자 - < span className = " text-purple-600 font-semibold " > 운영팀 별도 산정 </ span ></ li >
2025-12-17 12:59:26 +09:00
</ ul >
</ section >
< section >
< h4 className = " text-base font-bold text-slate-900 mb-2 " > 3. 수당 지급 요율 </ h4 >
< div className = " bg-slate-50 rounded-lg p-4 border border-slate-100 " >
< table className = " w-full text-left " >
< thead >
< tr className = " border-b border-slate-200 " >
< th className = " pb-2 " > 구분 </ th >
< th className = " pb-2 " > 가입비 요율 </ th >
</ tr >
</ thead >
< tbody className = " divide-y divide-slate-100 " >
< tr >
< td className = " py-2 font-medium " > 판매자 </ td >
< td className = " py-2 text-blue-600 font-bold " > 20 %</ td >
</ tr >
< tr >
< td className = " py-2 font-medium " > 관리자 </ td >
< td className = " py-2 text-green-600 font-bold " > 5 %</ td >
</ tr >
< tr >
2025-12-20 21:46:23 +09:00
< td className = " py-2 font-medium " > 메뉴제작 협업자 </ td >
< td className = " py-2 text-purple-600 font-bold " > 별도 산정 </ td >
2025-12-17 12:59:26 +09:00
</ tr >
</ tbody >
</ table >
</ div >
</ section >
< section >
< h4 className = " text-base font-bold text-slate-900 mb-2 " > 4. 계층 구조 이해 </ h4 >
< div className = " bg-blue-50 rounded-lg p-4 border border-blue-100 " >
< p className = " text-sm text-blue-900 mb-3 " >< strong > 예시 :</ strong ></ p >
< div className = " space-y-2 text-sm text-blue-900 " >
< p > • A가 B를 영입하고 , B가 C를 영입했습니다 .</ p >
< p > • C가 판매를 성사시키면 :</ p >
< ul className = " list-disc pl-5 mt-2 space-y-1 " >
< li >< strong > C ( 판매자 ) </ strong >: 가입비의 20 % 수당 </ li >
< li >< strong > B ( 관리자 ) </ strong >: 가입비의 5 % 수당 </ li >
2025-12-20 21:46:23 +09:00
< li >< strong > A ( 메뉴제작 협업자 ) </ strong >: 운영팀 별도 산정 </ li >
2025-12-17 12:59:26 +09:00
</ ul >
</ div >
</ div >
</ section >
< section >
< h4 className = " text-base font-bold text-slate-900 mb-2 " > 5. 지급일 정책 </ h4 >
< div className = " bg-slate-50 rounded-lg p-4 border border-slate-100 " >
< ul className = " list-disc pl-5 space-y-1 text-sm text-slate-700 " >
< li >< strong > 계약일 :</ strong > 가입비 완료일을 기준으로 합니다 .</ li >
< li >< strong > 수당 지급일 :</ strong > 가입비 완료 후 지급됩니다 .</ li >
</ ul >
</ div >
</ section >
< section >
< h4 className = " text-base font-bold text-slate-900 mb-2 " > 6. 회사 마진 </ h4 >
2025-12-20 21:46:23 +09:00
< p > 가입비에서 영업 수당 ( 총 25 % ) 을 제외한 금액이 회사 마진으로 귀속됩니다 . ( 메뉴제작 협업수당은 별도 ) </ p >
2025-12-17 12:59:26 +09:00
</ section >
</ div >
< div className = " p-6 border-t border-slate-100 bg-slate-50 flex justify-end " >
< button onClick = { onClose } className = " px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors shadow-sm " >
확인
</ button >
</ div >
</ div >
</ div >
);
};
// 4. Simulator Component
const SimulatorSection = ({ salesConfig , selectedRole }) => {
const [ duration , setDuration ] = useState ( salesConfig . default_contract_period || 84 );
const [ simulatorGuideOpen , setSimulatorGuideOpen ] = useState ( false );
const [ pricingData , setPricingData ] = useState ({});
const [ editModalOpen , setEditModalOpen ] = useState ( false );
const [ editingItem , setEditingItem ] = useState ( null );
const isOperator = selectedRole === '운영자' ;
// 1차 선택: 선택모델, 공사관리, 공정/정부지원사업
const [ selectedSelectModels , setSelectedSelectModels ] = useState ( false );
const [ selectedConstruction , setSelectedConstruction ] = useState ( false );
const [ selectedProcessGov , setSelectedProcessGov ] = useState ( false );
// 2차 선택: 선택모델의 세부 모델들
const [ selectedModels , setSelectedModels ] = useState ([]);
// DB에서 가격 정보 가져오기
useEffect (() => {
const fetchPricing = async () => {
try {
const response = await fetch ( 'api/package_pricing.php?action=list' );
const result = await response . json ();
if ( result . success ) {
const pricingMap = {};
result . data . forEach ( item => {
pricingMap [ `${item.item_type}_${item.item_id}` ] = item ;
});
setPricingData ( pricingMap );
}
} catch ( error ) {
console . error ( '가격 정보 로드 실패:' , error );
}
};
fetchPricing ();
}, []);
const packageTypes = salesConfig . package_types || [];
const selectModelsPackage = packageTypes . find ( p => p . id === 'select_models' );
const constructionPackage = packageTypes . find ( p => p . id === 'construction_management' );
const processGovPackage = packageTypes . find ( p => p . id === 'process_government' );
// DB 가격 정보와 기본 설정 병합
const getItemPrice = ( itemType , itemId , defaultJoinFee , defaultSubFee ) => {
const key = `${itemType}_${itemId}` ;
if ( pricingData [ key ]) {
return {
join_fee : pricingData [ key ] . join_fee ,
subscription_fee : pricingData [ key ] . subscription_fee
};
}
return {
join_fee : defaultJoinFee ,
subscription_fee : defaultSubFee
};
};
// 선택된 항목들의 총 가입비 계산 (구독료 제거)
let totalJoinFee = 0 ;
let totalSellerCommission = 0 ;
let totalManagerCommission = 0 ;
let totalEducatorCommission = 0 ;
// 선택모델의 세부 모델들 합산
if ( selectedSelectModels && selectModelsPackage ) {
selectedModels . forEach ( modelId => {
const model = selectModelsPackage . models . find ( m => m . id === modelId );
if ( model ) {
const price = getItemPrice ( 'model' , modelId , model . join_fee , model . subscription_fee );
totalJoinFee += price . join_fee || 0 ;
2025-12-20 21:46:23 +09:00
// 가입비에 대한 수당만 계산: 판매자 20%, 관리자 5%, 메뉴제작 협업수당 별도
const rates = model . commission_rates || { seller : { join : 0.20 }, manager : { join : 0.05 }, educator : { join : 0 } };
2025-12-17 12:59:26 +09:00
totalSellerCommission += ( price . join_fee * ( rates . seller . join || 0.20 ));
totalManagerCommission += ( price . join_fee * ( rates . manager . join || 0.05 ));
2025-12-20 21:46:23 +09:00
totalEducatorCommission += 0 ; // 운영팀 별도 산정
2025-12-17 12:59:26 +09:00
}
});
}
// 공사관리 패키지
if ( selectedConstruction && constructionPackage ) {
const price = getItemPrice ( 'package' , 'construction_management' , constructionPackage . join_fee , constructionPackage . subscription_fee );
totalJoinFee += price . join_fee || 0 ;
2025-12-20 21:46:23 +09:00
const rates = constructionPackage . commission_rates || { seller : { join : 0.20 }, manager : { join : 0.05 }, educator : { join : 0 } };
2025-12-17 12:59:26 +09:00
totalSellerCommission += ( price . join_fee * ( rates . seller . join || 0.20 ));
totalManagerCommission += ( price . join_fee * ( rates . manager . join || 0.05 ));
2025-12-20 21:46:23 +09:00
totalEducatorCommission += 0 ;
2025-12-17 12:59:26 +09:00
}
// 공정/정부지원사업 패키지
if ( selectedProcessGov && processGovPackage ) {
const price = getItemPrice ( 'package' , 'process_government' , processGovPackage . join_fee , processGovPackage . subscription_fee );
totalJoinFee += price . join_fee || 0 ;
2025-12-20 21:46:23 +09:00
const rates = processGovPackage . commission_rates || { seller : { join : 0.20 }, manager : { join : 0.05 }, educator : { join : 0 } };
2025-12-17 12:59:26 +09:00
totalSellerCommission += ( price . join_fee * ( rates . seller . join || 0.20 ));
totalManagerCommission += ( price . join_fee * ( rates . manager . join || 0.05 ));
2025-12-20 21:46:23 +09:00
totalEducatorCommission += 0 ;
2025-12-17 12:59:26 +09:00
}
const totalCommission = totalSellerCommission + totalManagerCommission + totalEducatorCommission ;
const totalRevenue = totalJoinFee ; // 구독료 제거
const commissionRate = totalRevenue > 0 ? (( totalCommission / totalRevenue ) * 100 ) . toFixed ( 1 ) : 0 ;
const formatCurrency = ( val ) => new Intl . NumberFormat ( 'ko-KR' , { style : 'currency' , currency : 'KRW' }) . format ( val );
const handleModelToggle = ( modelId ) => {
setSelectedModels ( prev =>
prev . includes ( modelId )
? prev . filter ( id => id !== modelId )
: [ ... prev , modelId ]
);
};
const handleEditPrice = ( itemType , itemId , itemName , subName , defaultJoinFee , defaultSubFee ) => {
const key = `${itemType}_${itemId}` ;
const currentPrice = pricingData [ key ] || { join_fee : defaultJoinFee , subscription_fee : defaultSubFee };
setEditingItem ({
item_type : itemType ,
item_id : itemId ,
item_name : itemName ,
sub_name : subName ,
join_fee : currentPrice . join_fee || defaultJoinFee ,
subscription_fee : currentPrice . subscription_fee || defaultSubFee
});
setEditModalOpen ( true );
};
const handleSavePrice = async () => {
if ( ! editingItem ) return ;
try {
const response = await fetch ( 'api/package_pricing.php' , {
method : 'PUT' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({
item_type : editingItem . item_type ,
item_id : editingItem . item_id ,
join_fee : editingItem . join_fee ,
subscription_fee : editingItem . subscription_fee
})
});
const result = await response . json ();
if ( result . success ) {
// 가격 정보 다시 로드
const fetchResponse = await fetch ( 'api/package_pricing.php?action=list' );
const fetchResult = await fetchResponse . json ();
if ( fetchResult . success ) {
const pricingMap = {};
fetchResult . data . forEach ( item => {
pricingMap [ `${item.item_type}_${item.item_id}` ] = item ;
});
setPricingData ( pricingMap );
}
setEditModalOpen ( false );
setEditingItem ( null );
} else {
alert ( '가격 저장에 실패했습니다: ' + ( result . error || '알 수 없는 오류' ));
}
} catch ( error ) {
console . error ( '가격 저장 실패:' , error );
alert ( '가격 저장 중 오류가 발생했습니다.' );
}
};
// 아이콘 업데이트
return (
< section className = " bg-white rounded-card shadow-sm border border-slate-100 overflow-hidden flex flex-col lg:flex-row " >
{ /* Input Form */ }
< div className = " p-8 lg:w-2/5 border-b lg:border-b-0 lg:border-r border-slate-100 " >
< div className = " flex items-center justify-between mb-6 " >
< h2 className = " text-xl font-bold text-slate-900 flex items-center gap-2 " >
< LucideIcon name = " calculator " className = " w-5 h-5 text-blue-600 " />
수당 시뮬레이터
</ h2 >
< button
onClick = {() => setSimulatorGuideOpen ( true )}
className = " p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors "
title = " 사용 가이드 보기 "
>
< LucideIcon name = " help-circle " className = " w-5 h-5 " />
</ button >
</ div >
< div className = " space-y-6 " >
{ /* 1차 선택 */ }
< div >
< label className = " block text-sm font-medium text-slate-700 mb-3 " > 패키지 선택 </ label >
< div className = " space-y-3 " >
{ /* 선택모델 */ }
< div className = " flex items-start gap-3 p-4 border border-slate-200 rounded-lg hover:border-blue-300 transition-colors " >
< input
type = " checkbox "
id = " select_models "
checked = { selectedSelectModels }
onChange = {( e ) => {
setSelectedSelectModels ( e . target . checked );
if ( ! e . target . checked ) {
setSelectedModels ([]);
}
}}
className = " mt-1 w-4 h-4 text-blue-600 border-slate-300 rounded focus:ring-blue-500 "
/>
< label htmlFor = " select_models " className = " flex-1 cursor-pointer " >
< div className = " font-medium text-slate-900 " > 선택모델 </ div >
< div className = " text-xs text-slate-500 mt-1 " > 여러 모델을 선택할 수 있습니다 </ div >
</ label >
</ div >
{ /* 공사관리 */ }
{ constructionPackage && (() => {
const price = getItemPrice ( 'package' , 'construction_management' , constructionPackage . join_fee , constructionPackage . subscription_fee );
return (
< div className = " flex items-start gap-3 p-4 border border-slate-200 rounded-lg hover:border-blue-300 transition-colors " >
< input
type = " checkbox "
id = " construction_management "
checked = { selectedConstruction }
onChange = {( e ) => setSelectedConstruction ( e . target . checked )}
className = " mt-1 w-4 h-4 text-blue-600 border-slate-300 rounded focus:ring-blue-500 "
/>
< label htmlFor = " construction_management " className = " flex-1 cursor-pointer " >
< div className = " flex items-center justify-between " >
< div >
< div className = " font-medium text-slate-900 " > 공사관리 </ div >
< div className = " text-xs text-slate-500 mt-1 " > 패키지 </ div >
< div className = " text-xs text-blue-600 mt-1 " >
가입비 : { formatCurrency ( price . join_fee )} / 월 구독료 : { formatCurrency ( price . subscription_fee )}
</ div >
</ div >
{ isOperator && (
< button
onClick = {( e ) => {
e . stopPropagation ();
handleEditPrice ( 'package' , 'construction_management' , '공사관리' , '패키지' , constructionPackage . join_fee , constructionPackage . subscription_fee );
}}
className = " p-1.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors "
title = " 가격 설정 "
>
< LucideIcon name = " settings " className = " w-4 h-4 " />
</ button >
)}
</ div >
</ label >
</ div >
);
})()}
{ /* 공정/정부지원사업 */ }
{ processGovPackage && (() => {
const price = getItemPrice ( 'package' , 'process_government' , processGovPackage . join_fee , processGovPackage . subscription_fee );
return (
< div className = " flex items-start gap-3 p-4 border border-slate-200 rounded-lg hover:border-blue-300 transition-colors " >
< input
type = " checkbox "
id = " process_government "
checked = { selectedProcessGov }
onChange = {( e ) => setSelectedProcessGov ( e . target . checked )}
className = " mt-1 w-4 h-4 text-blue-600 border-slate-300 rounded focus:ring-blue-500 "
/>
< label htmlFor = " process_government " className = " flex-1 cursor-pointer " >
< div className = " flex items-center justify-between " >
< div >
< div className = " font-medium text-slate-900 " > 공정 / 정부지원사업 </ div >
< div className = " text-xs text-slate-500 mt-1 " > 패키지 </ div >
< div className = " text-xs text-blue-600 mt-1 " >
가입비 : { formatCurrency ( price . join_fee )} / 월 구독료 : { formatCurrency ( price . subscription_fee )}
</ div >
</ div >
{ isOperator && (
< button
onClick = {( e ) => {
e . stopPropagation ();
handleEditPrice ( 'package' , 'process_government' , '공정/정부지원사업' , '패키지' , processGovPackage . join_fee , processGovPackage . subscription_fee );
}}
className = " p-1.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors "
title = " 가격 설정 "
>
< LucideIcon name = " settings " className = " w-4 h-4 " />
</ button >
)}
</ div >
</ label >
</ div >
);
})()}
</ div >
</ div >
{ /* 2차 선택: 선택모델의 세부 모델들 */ }
{ selectedSelectModels && selectModelsPackage && (
< div className = " border-t border-slate-200 pt-6 " >
< label className = " block text-sm font-medium text-slate-700 mb-3 " > 선택모델 세부 항목 </ label >
< div className = " space-y-2 max-h-60 overflow-y-auto " >
{ selectModelsPackage . models . map ( model => {
const price = getItemPrice ( 'model' , model . id , model . join_fee , model . subscription_fee );
return (
< div key = { model . id } className = " flex items-start gap-3 p-3 border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors " >
< input
type = " checkbox "
id = { model . id }
checked = { selectedModels . includes ( model . id )}
onChange = {() => handleModelToggle ( model . id )}
className = " mt-1 w-4 h-4 text-blue-600 border-slate-300 rounded focus:ring-blue-500 "
/>
< label htmlFor = { model . id } className = " flex-1 cursor-pointer " >
< div className = " flex items-center justify-between " >
< div >
< div className = " font-medium text-slate-900 text-sm " > { model . name } </ div >
{ model . sub_name && (
< div className = " text-xs text-slate-500 mt-0.5 " > { model . sub_name } </ div >
)}
< div className = " text-xs text-blue-600 mt-1 " >
가입비 : { formatCurrency ( price . join_fee )} / 월 구독료 : { formatCurrency ( price . subscription_fee )}
</ div >
</ div >
{ isOperator && (
< button
onClick = {( e ) => {
e . stopPropagation ();
handleEditPrice ( 'model' , model . id , model . name , model . sub_name , model . join_fee , model . subscription_fee );
}}
className = " p-1.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors ml-2 "
title = " 가격 설정 "
>
< LucideIcon name = " settings " className = " w-4 h-4 " />
</ button >
)}
</ div >
</ label >
</ div >
);
})}
</ div >
</ div >
)}
</ div >
</ div >
{ /* Result Card */ }
< div className = " p-8 lg:w-3/5 bg-slate-50 flex flex-col justify-center " >
< div className = " bg-white rounded-2xl shadow-sm border border-slate-200 p-6 relative overflow-hidden " >
< div className = " absolute top-0 right-0 w-32 h-32 bg-blue-50 rounded-full -mr-16 -mt-16 opacity-50 " ></ div >
< h3 className = " text-sm font-semibold text-slate-400 uppercase tracking-wider mb-6 " > 예상 수당 명세서 </ h3 >
< div className = " space-y-4 mb-6 " >
< div className = " flex justify-between items-center p-3 bg-slate-50 rounded-lg " >
< span className = " text-slate-600 " > 총 가입비 </ span >
< span className = " font-bold text-slate-900 " > { formatCurrency ( totalRevenue )} </ span >
</ div >
< div className = " flex justify-between items-center p-3 bg-slate-50 rounded-lg " >
< span className = " text-slate-600 " > 판매자 수당 ( 20 % ) </ span >
< span className = " font-bold text-slate-900 " > { formatCurrency ( totalSellerCommission )} </ span >
</ div >
< div className = " flex justify-between items-center p-3 bg-slate-50 rounded-lg " >
< span className = " text-slate-600 " > 관리자 수당 ( 5 % ) </ span >
< span className = " font-bold text-slate-900 " > { formatCurrency ( totalManagerCommission )} </ span >
</ div >
< div className = " flex justify-between items-center p-3 bg-slate-50 rounded-lg " >
2025-12-20 21:46:23 +09:00
< span className = " text-slate-600 " > 메뉴제작 협업수당 </ span >
< span className = " font-bold text-slate-900 " > 운영팀 별도 산정 </ span >
2025-12-17 12:59:26 +09:00
</ div >
</ div >
< div className = " pt-6 border-t border-dashed border-slate-200 " >
< div className = " flex justify-between items-end " >
< span className = " text-lg font-bold text-slate-800 " > 총 예상 수당 </ span >
< span className = " text-3xl font-extrabold text-blue-600 " > { formatCurrency ( totalCommission )} </ span >
</ div >
< div className = " flex justify-between items-center mt-2 " >
< p className = " text-xs text-slate-400 " >* 세전 금액 기준입니다 .</ p >
< p className = " text-xs font-medium text-blue-600 " > 총 매출의 { commissionRate } %</ p >
</ div >
</ div >
</ div >
</ div >
{ /* Simulator Guide Modal */ }
{ simulatorGuideOpen && (
< SimulatorGuideModal onClose = {() => setSimulatorGuideOpen ( false )} salesConfig = { salesConfig } />
)}
{ /* Price Edit Modal */ }
{ editModalOpen && editingItem && (
< PriceEditModal
key = { `edit-${editingItem.item_type}-${editingItem.item_id}` }
item = { editingItem }
onClose = {() => {
setEditModalOpen ( false );
setEditingItem ( null );
}}
onSave = { handleSavePrice }
setEditingItem = { setEditingItem }
/>
)}
</ section >
);
};
// Price Edit Modal Component
const PriceEditModal = ({ item , onClose , onSave , setEditingItem }) => {
const handleChange = ( field , value ) => {
const numValue = value . replace ( / , / g , '' );
const parsedValue = parseFloat ( numValue ) || 0 ;
setEditingItem ( prev => ({
... prev ,
[ field ] : parsedValue
}));
};
useEffect (() => {
setTimeout (() => {
lucide . createIcons ();
}, 100 );
}, []);
return (
< div className = " fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm " onClick = { onClose } >
< div className = " bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden " onClick = { e => e . stopPropagation ()} >
< div className = " p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50 " >
< h3 className = " text-xl font-bold text-slate-900 flex items-center gap-2 " >
< i data - lucide = " dollar-sign " className = " w-5 h-5 text-blue-600 " ></ i >
가격 설정
</ h3 >
< button onClick = { onClose } className = " p-2 hover:bg-slate-200 rounded-full transition-colors " >
< i data - lucide = " x " className = " w-5 h-5 text-slate-500 " ></ i >
</ button >
</ div >
< div className = " p-6 space-y-4 " >
< div >
< label className = " block text-sm font-medium text-slate-700 mb-1 " > 항목명 </ label >
< div className = " text-base font-semibold text-slate-900 " > { item . item_name } </ div >
{ item . sub_name && (
< div className = " text-sm text-slate-500 mt-1 " > { item . sub_name } </ div >
)}
</ div >
< div >
< label className = " block text-sm font-medium text-slate-700 mb-2 " > 가입비 ( 원 ) </ label >
< input
type = " text "
value = {( item . join_fee || 0 ) . toLocaleString ( 'ko-KR' )}
onChange = {( e ) => handleChange ( 'join_fee' , e . target . value )}
className = " w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 "
/>
</ div >
< div >
< label className = " block text-sm font-medium text-slate-700 mb-2 " > 월 구독료 ( 원 ) </ label >
< input
type = " text "
value = {( item . subscription_fee || 0 ) . toLocaleString ( 'ko-KR' )}
onChange = {( e ) => handleChange ( 'subscription_fee' , e . target . value )}
className = " w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 "
/>
</ div >
</ div >
< div className = " p-6 border-t border-slate-100 bg-slate-50 flex justify-end gap-3 " >
< button
onClick = { onClose }
className = " px-4 py-2 text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 font-medium transition-colors "
>
취소
</ button >
< button
onClick = { onSave }
className = " px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors shadow-sm "
>
저장
</ button >
</ div >
</ div >
</ div >
);
};
// Simulator Guide Modal Component
const SimulatorGuideModal = ({ onClose , salesConfig }) => {
return (
< div className = " fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm " onClick = { onClose } >
< div className = " bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col " onClick = { e => e . stopPropagation ()} >
< div className = " p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50 flex-shrink-0 " >
< h3 className = " text-xl font-bold text-slate-900 flex items-center gap-2 " >
< i data - lucide = " book-open " className = " w-5 h-5 text-blue-600 " ></ i >
수당 시뮬레이터 사용 가이드
</ h3 >
< button onClick = { onClose } className = " p-2 hover:bg-slate-200 rounded-full transition-colors " >
< i data - lucide = " x " className = " w-5 h-5 text-slate-500 " ></ i >
</ button >
</ div >
< div className = " p-6 space-y-6 overflow-y-auto flex-1 text-slate-600 text-sm leading-relaxed " >
< section >
< h4 className = " text-base font-bold text-slate-900 mb-3 flex items-center gap-2 " >
< i data - lucide = " info " className = " w-4 h-4 text-blue-600 " ></ i >
수당 시뮬레이터란 ?
</ h4 >
< p className = " mb-3 " > 수당 시뮬레이터는 고객과 계약을 체결하기 전에 예상 수당을 미리 계산해볼 수 있는 도구입니다 .</ p >
< div className = " bg-blue-50 rounded-lg p-4 border border-blue-100 " >
< p className = " text-sm text-blue-800 font-medium mb-2 " > 💡 활용 시나리오 </ p >
< ul className = " list-disc pl-5 space-y-1 text-sm text-blue-800 " >
< li > 고객과 계약 조건을 논의할 때 예상 수당을 즉시 확인 </ li >
< li > 다양한 패키지에 따른 수당 비교 </ li >
< li > 영업 목표 설정 및 수당 예측 </ li >
< li > 고객에게 맞춤형 제안서 작성 시 수당 정보 확인 </ li >
</ ul >
</ div >
</ section >
< section >
< h4 className = " text-base font-bold text-slate-900 mb-3 flex items-center gap-2 " >
< i data - lucide = " calculator " className = " w-4 h-4 text-blue-600 " ></ i >
수당 계산 방식
</ h4 >
< div className = " bg-slate-50 rounded-lg p-4 border border-slate-100 " >
< p className = " text-sm text-slate-700 mb-3 " > 수당은 < strong > 가입비 </ strong > 에 대해서만 계산됩니다 .</ p >
< div className = " space-y-3 " >
< div className = " border-l-4 border-blue-500 pl-3 " >
< h5 className = " font-semibold text-slate-900 mb-1 " > 판매자 수당 ( 20 % ) </ h5 >
< p className = " text-xs text-slate-600 " > 가입비 × 20 %</ p >
</ div >
< div className = " border-l-4 border-green-500 pl-3 " >
< h5 className = " font-semibold text-slate-900 mb-1 " > 관리자 수당 ( 5 % ) </ h5 >
< p className = " text-xs text-slate-600 " > 가입비 × 5 %</ p >
</ div >
< div className = " border-l-4 border-purple-500 pl-3 " >
2025-12-20 21:46:23 +09:00
< h5 className = " font-semibold text-slate-900 mb-1 " > 메뉴제작 협업수당 </ h5 >
< p className = " text-xs text-slate-600 " > 운영팀 별도 산정 </ p >
2025-12-17 12:59:26 +09:00
</ div >
</ div >
< div className = " mt-4 p-3 bg-blue-50 rounded-lg border border-blue-100 " >
< p className = " text-xs text-blue-800 font-medium mb-1 " > 📌 계산 예시 </ p >
< p className = " text-xs text-blue-800 mb-2 " > 가입비 100 만원인 경우 :</ p >
< ul className = " list-disc pl-5 space-y-1 text-xs text-blue-800 " >
< li > 판매자 수당 : 100 만원 × 20 % = 20 만원 </ li >
< li > 관리자 수당 : 100 만원 × 5 % = 5 만원 </ li >
2025-12-20 21:46:23 +09:00
< li > 메뉴제작 협업수당 : 운영팀 별도 산정 </ li >
< li > 총 수당 : 25 만원 + 메뉴제작 협업수당 </ li >
2025-12-17 12:59:26 +09:00
</ ul >
</ div >
</ div >
</ section >
< section >
< h4 className = " text-base font-bold text-slate-900 mb-3 flex items-center gap-2 " >
< i data - lucide = " users " className = " w-4 h-4 text-blue-600 " ></ i >
계층 구조
</ h4 >
< div className = " bg-blue-50 rounded-lg p-4 border border-blue-100 " >
< p className = " text-sm text-blue-900 mb-2 " >< strong > 수당 지급 구조 :</ strong ></ p >
< ul className = " list-disc pl-5 space-y-1 text-sm text-blue-900 " >
< li >< strong > 판매자 :</ strong > 직접 영업을 성사시킨 담당자 </ li >
< li >< strong > 관리자 :</ strong > 판매자를 데려온 상위 담당자 </ li >
2025-12-20 21:46:23 +09:00
< li >< strong > 메뉴제작 협업자 :</ strong > 관리자를 데려온 상위 담당자 </ li >
2025-12-17 12:59:26 +09:00
</ ul >
</ div >
</ section >
< section >
< h4 className = " text-base font-bold text-slate-900 mb-3 flex items-center gap-2 " >
< i data - lucide = " lightbulb " className = " w-4 h-4 text-blue-600 " ></ i >
활용 팁
</ h4 >
< div className = " bg-amber-50 rounded-lg p-4 border border-amber-100 " >
< ul className = " list-disc pl-5 space-y-2 text-sm text-amber-900 " >
< li >< strong > 실시간 계산 :</ strong > 패키지를 선택하면 즉시 예상 수당이 계산됩니다 .</ li >
< li >< strong > 패키지 비교 :</ strong > 다양한 패키지를 선택하여 수당 차이를 비교해보세요 .</ li >
< li >< strong > 영업 전략 :</ strong > 높은 수당이 나오는 패키지를 파악하여 영업 전략을 수립하세요 .</ li >
</ ul >
</ div >
</ section >
< section >
< h4 className = " text-base font-bold text-slate-900 mb-3 flex items-center gap-2 " >
< i data - lucide = " alert-circle " className = " w-4 h-4 text-blue-600 " ></ i >
주의사항
</ h4 >
< div className = " bg-red-50 rounded-lg p-4 border border-red-100 " >
< ul className = " list-disc pl-5 space-y-1 text-sm text-red-800 " >
< li > 계산된 수당은 < strong > 예상치 </ strong > 이며 , 실제 지급 수당은 계약 조건에 따라 달라질 수 있습니다 .</ li >
< li > 표시된 금액은 < strong > 세전 금액 </ strong > 이며 , 실제 지급 시에는 소득세가 공제됩니다 .</ li >
< li > 수당은 가입비 완료 후 지급됩니다 .</ li >
</ ul >
</ div >
</ section >
</ div >
< div className = " p-6 border-t border-slate-100 bg-slate-50 flex justify-end flex-shrink-0 " >
< button onClick = { onClose } className = " px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors shadow-sm " >
확인
</ button >
</ div >
</ div >
</ div >
);
};
// 5. Sales List Component
const SalesList = ({ salesRecords , programs , onSelectRecord }) => {
const getProgramName = ( id ) => programs . find ( p => p . id === id ) ? . name || id ;
return (
< section className = " bg-white rounded-card shadow-sm border border-slate-100 overflow-hidden " >
< div className = " p-6 border-b border-slate-100 flex justify-between items-center " >
< h2 className = " text-lg font-bold text-slate-900 " > 영업 실적 현황 </ h2 >
< button className = " text-sm text-blue-600 font-medium hover:underline " > 전체 보기 </ button >
</ div >
< div className = " overflow-x-auto " >
< table className = " w-full text-left text-sm text-slate-600 " >
< thead className = " bg-slate-50 text-xs uppercase font-medium text-slate-500 " >
< tr >
< th className = " px-6 py-4 " > 고객사 </ th >
< th className = " px-6 py-4 " > 프로그램 </ th >
< th className = " px-6 py-4 " > 계약일 </ th >
< th className = " px-6 py-4 " > 상태 </ th >
< th className = " px-6 py-4 text-right " > 상세보기 </ th >
</ tr >
</ thead >
< tbody className = " divide-y divide-slate-100 " >
{ salesRecords . map ( record => (
< tr key = { record . id } className = " hover:bg-slate-50 transition-colors cursor-pointer " onClick = {() => onSelectRecord ( record )} >
< td className = " px-6 py-4 font-medium text-slate-900 " > { record . customer_name } </ td >
< td className = " px-6 py-4 " > { getProgramName ( record . program_id )} </ td >
< td className = " px-6 py-4 " > { record . contract_date } </ td >
< td className = " px-6 py-4 " >
< span className = { ` inline - flex items - center px - 2.5 py - 0.5 rounded - full text - xs font - medium $ {
record . status === 'Active' ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'
} ` } >
{ record . status }
</ span >
</ td >
< td className = " px-6 py-4 text-right " >
< button className = " text-slate-400 hover:text-blue-600 " >
< i data - lucide = " chevron-right " className = " w-4 h-4 " ></ i >
</ button >
</ td >
</ tr >
))}
</ tbody >
</ table >
</ div >
</ section >
);
};
// 6. Commission Detail Modal
const CommissionDetailModal = ({ record , programs , onClose }) => {
if ( ! record ) return null ;
const program = programs . find ( p => p . id === record . program_id );
2025-12-20 21:46:23 +09:00
const rates = program ? . commission_rates || { seller : { join : 0.20 }, manager : { join : 0.05 }, educator : { join : 0 } };
2025-12-17 12:59:26 +09:00
// Calculations (구독료 제거, 가입비에 대한 수당만 계산)
const joinFee = record . join_fee ;
const calc = ( base , rate ) => base * rate ;
// 판매자: 가입비의 20%
const sellerJoin = calc ( joinFee , rates . seller . join || 0.20 );
const sellerTotal = sellerJoin ;
// 관리자: 가입비의 5%
const managerJoin = calc ( joinFee , rates . manager . join || 0.05 );
const managerTotal = managerJoin ;
2025-12-20 21:46:23 +09:00
// 메뉴제작 협업자: 운영팀 별도 산정
const educatorJoin = 0 ;
2025-12-17 12:59:26 +09:00
const educatorTotal = educatorJoin ;
const totalCommission = sellerTotal + managerTotal + educatorTotal ;
2025-12-20 21:46:23 +09:00
const companyMargin = joinFee - totalCommission ; // 회사 마진 = 가입비 - 영업 수당
2025-12-17 12:59:26 +09:00
const formatCurrency = ( val ) => new Intl . NumberFormat ( 'ko-KR' , { style : 'currency' , currency : 'KRW' }) . format ( val );
return (
< div className = " fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm " onClick = { onClose } >
< div className = " bg-white rounded-2xl shadow-2xl w-full max-w-3xl overflow-hidden flex flex-col max-h-[90vh] " onClick = { e => e . stopPropagation ()} >
< div className = " p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50 shrink-0 " >
< div >
< h3 className = " text-xl font-bold text-slate-900 " > { record . customer_name } 수당 상세 </ h3 >
< p className = " text-sm text-slate-500 " > { program ? . name } | { duration } 개월 계약 </ p >
</ div >
< button onClick = { onClose } className = " p-2 hover:bg-slate-200 rounded-full transition-colors " >
< i data - lucide = " x " className = " w-5 h-5 text-slate-500 " ></ i >
</ button >
</ div >
< div className = " p-6 space-y-8 overflow-y-auto " >
{ /* 1. Key Dates Section */ }
< section >
< h4 className = " text-sm font-bold text-slate-900 mb-3 flex items-center gap-2 " >
< i data - lucide = " calendar " className = " w-4 h-4 text-blue-600 " ></ i >
주요 일정 정보
</ h4 >
< div className = " grid grid-cols-2 md:grid-cols-4 gap-3 " >
< div className = " p-3 bg-slate-50 rounded-lg border border-slate-100 " >
< div className = " text-xs text-slate-500 mb-1 " > 계약일 </ div >
< div className = " font-medium text-slate-900 text-sm " > { record . dates ? . contract || '-' } </ div >
</ div >
< div className = " p-3 bg-slate-50 rounded-lg border border-slate-100 " >
< div className = " text-xs text-slate-500 mb-1 " > 가입비 지급일 </ div >
< div className = " font-medium text-slate-900 text-sm " > { record . dates ? . join_fee || '-' } </ div >
</ div >
< div className = " p-3 bg-slate-50 rounded-lg border border-slate-100 " >
< div className = " text-xs text-slate-500 mb-1 " > 서비스 시작일 </ div >
< div className = " font-medium text-slate-900 text-sm " > { record . dates ? . service_start || '-' } </ div >
</ div >
< div className = " p-3 bg-blue-50 rounded-lg border border-blue-100 " >
< div className = " text-xs text-blue-600 mb-1 " > 최근 수정일 </ div >
< div className = " font-bold text-blue-900 text-sm " > { record . dates ? . product_modified || '-' } </ div >
</ div >
</ div >
</ section >
{ /* 2. Commission Summary */ }
< section >
< h4 className = " text-sm font-bold text-slate-900 mb-3 flex items-center gap-2 " >
< i data - lucide = " calculator " className = " w-4 h-4 text-blue-600 " ></ i >
수당 상세 내역
</ h4 >
< div className = " border border-slate-200 rounded-xl overflow-hidden " >
< table className = " w-full text-sm text-left " >
< thead className = " bg-slate-50 text-xs uppercase font-medium text-slate-500 " >
< tr >
< th className = " px-4 py-3 border-b border-slate-200 " > 구분 </ th >
< th className = " px-4 py-3 border-b border-slate-200 " > 가입비 ({ formatCurrency ( joinFee )}) </ th >
< th className = " px-4 py-3 border-b border-slate-200 text-right " > 수당 </ th >
</ tr >
</ thead >
< tbody className = " divide-y divide-slate-100 " >
< tr >
< td className = " px-4 py-3 font-medium text-slate-900 " > 판매자 </ td >
< td className = " px-4 py-3 text-slate-600 " >
< span className = " text-xs text-slate-400 " > 20 %</ span >
</ td >
< td className = " px-4 py-3 text-right font-bold text-slate-900 " > { formatCurrency ( sellerTotal )} </ td >
</ tr >
< tr >
< td className = " px-4 py-3 font-medium text-slate-900 " > 관리자 </ td >
< td className = " px-4 py-3 text-slate-600 " >
< span className = " text-xs text-slate-400 " > 5 %</ span >
</ td >
< td className = " px-4 py-3 text-right font-bold text-slate-900 " > { formatCurrency ( managerTotal )} </ td >
</ tr >
< tr >
2025-12-20 21:46:23 +09:00
< td className = " px-4 py-3 font-medium text-slate-900 " > 메뉴제작 협업자 </ td >
2025-12-17 12:59:26 +09:00
< td className = " px-4 py-3 text-slate-600 " >
2025-12-20 21:46:23 +09:00
< span className = " text-xs text-slate-400 " > 별도 </ span >
2025-12-17 12:59:26 +09:00
</ td >
2025-12-20 21:46:23 +09:00
< td className = " px-4 py-3 text-right font-bold text-slate-900 " > 운영팀 산정 </ td >
2025-12-17 12:59:26 +09:00
</ tr >
</ tbody >
< tfoot className = " bg-slate-50 border-t border-slate-200 " >
< tr >
< td className = " px-4 py-3 font-bold text-slate-900 " > 회사 마진 </ td >
2025-12-20 21:46:23 +09:00
< td className = " px-4 py-3 text-slate-500 " > 가입비 - 영업 수당 </ td >
2025-12-17 12:59:26 +09:00
< td className = " px-4 py-3 text-right font-bold text-slate-900 " > { formatCurrency ( companyMargin )} </ td >
</ tr >
</ tfoot >
</ table >
</ div >
</ section >
{ /* 3. Product History Section */ }
{ record . history && (
< section >
< h4 className = " text-sm font-bold text-slate-900 mb-3 flex items-center gap-2 " >
< i data - lucide = " history " className = " w-4 h-4 text-blue-600 " ></ i >
상품 변경 이력
</ h4 >
< div className = " relative pl-4 border-l-2 border-slate-200 space-y-6 " >
{ record . history . map (( item , idx ) => (
< div key = { idx } className = " relative " >
< div className = { ` absolute - left - [ 21 px ] top - 1 w - 3 h - 3 rounded - full border - 2 border - white $ {
item . type === 'New Contract' ? 'bg-green-500' : 'bg-blue-500'
} ` } ></ div >
< div className = " flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 " >
< span className = " text-xs font-bold text-slate-500 " > { item . date } </ span >
< span className = { ` text - xs font - medium px - 2 py - 0.5 rounded - full w - fit $ {
item . type === 'New Contract' ? 'bg-green-100 text-green-700' : 'bg-blue-100 text-blue-700'
} ` } > { item . type } </ span >
</ div >
< div className = " mt-1 p-3 bg-slate-50 rounded-lg border border-slate-100 " >
< p className = " text-sm font-medium text-slate-900 " > { item . description } </ p >
< p className = " text-xs text-slate-500 mt-1 " > Program ID : { item . program_id } </ p >
</ div >
</ div >
))}
</ div >
</ section >
)}
</ div >
< div className = " p-6 border-t border-slate-100 bg-slate-50 flex justify-end shrink-0 " >
< button onClick = { onClose } className = " px-4 py-2 bg-white border border-slate-200 rounded-lg text-slate-700 hover:bg-slate-50 font-medium transition-colors " >
닫기
</ button >
</ div >
</ div >
</ div >
);
};
const root = ReactDOM . createRoot ( document . getElementById ( 'root' ));
root . render ( < App /> );
// Initialize Lucide Icons after render and on updates
const initIcons = () => {
setTimeout (() => {
lucide . createIcons ();
}, 100 );
};
initIcons ();
// 주기적으로 아이콘 업데이트 (동적 콘텐츠를 위해)
setInterval (() => {
lucide . createIcons ();
}, 500 );
</ script >
</ body >
</ html >