- URL 하드코딩 → .env APP_URL 기반 동적 URL로 변경 - DB 연결 하드코딩 → .env 기반으로 변경 - MySQL strict mode DATE 오류 수정
289 lines
15 KiB
PHP
289 lines
15 KiB
PHP
<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>영업 관리 시스템 - CodeBridgeExy</title>
|
|
|
|
<!-- Fonts: Pretendard -->
|
|
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.8/dist/web/static/pretendard.css" />
|
|
|
|
<!-- Tailwind CSS -->
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<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>
|
|
|
|
<!-- React & ReactDOM -->
|
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
|
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
|
|
|
|
<!-- 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 } = React;
|
|
|
|
// --- Components ---
|
|
|
|
// 1. Header Component
|
|
const Header = ({ companyInfo }) => {
|
|
if (!companyInfo) return <div className="h-16 bg-white shadow-sm animate-pulse"></div>;
|
|
|
|
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">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center text-blue-600 font-bold">
|
|
{companyInfo.name.substring(0, 1)}
|
|
</div>
|
|
<h1 className="text-lg font-semibold text-slate-900">{companyInfo.name} 영업 관리</h1>
|
|
</div>
|
|
<div className="flex items-center gap-4">
|
|
<button className="text-sm text-slate-500 hover:text-slate-900">도움말</button>
|
|
<div className="w-8 h-8 rounded-full bg-slate-200"></div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
);
|
|
};
|
|
|
|
// 2. Dashboard Card Component
|
|
const StatCard = ({ title, value, subtext, icon }) => (
|
|
<div className="bg-white rounded-card p-6 shadow-sm border border-slate-100 hover:shadow-md transition-shadow">
|
|
<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>
|
|
);
|
|
|
|
// 3. Main App Component
|
|
const App = () => {
|
|
const [loading, setLoading] = useState(true);
|
|
const [data, setData] = useState(null);
|
|
|
|
useEffect(() => {
|
|
// Fetch Mock Data
|
|
fetch('api/company_info.php')
|
|
.then(res => res.json())
|
|
.then(jsonData => {
|
|
setData(jsonData);
|
|
setLoading(false);
|
|
})
|
|
.catch(err => {
|
|
console.error("Failed to fetch data:", err);
|
|
setLoading(false);
|
|
});
|
|
}, []);
|
|
|
|
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>;
|
|
|
|
return (
|
|
<div className="min-h-screen pb-20">
|
|
<Header companyInfo={data.company_info} />
|
|
|
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
|
|
|
|
{/* Dashboard Section */}
|
|
<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-4 gap-6">
|
|
<StatCard
|
|
title="총 매출액"
|
|
value="₩ 124,800,000"
|
|
subtext="전월 대비 +12%"
|
|
icon={<i data-lucide="dollar-sign" className="w-5 h-5"></i>}
|
|
/>
|
|
<StatCard
|
|
title="이번 달 예상 수당"
|
|
value="₩ 8,500,000"
|
|
subtext="지급 예정일: 25일"
|
|
icon={<i data-lucide="wallet" className="w-5 h-5"></i>}
|
|
/>
|
|
<StatCard
|
|
title="활성 계약"
|
|
value="14 건"
|
|
subtext="신규 2건"
|
|
icon={<i data-lucide="file-check" className="w-5 h-5"></i>}
|
|
/>
|
|
<StatCard
|
|
title="평균 구독 유지율"
|
|
value="98.5%"
|
|
subtext="업계 평균 상회"
|
|
icon={<i data-lucide="trending-up" className="w-5 h-5"></i>}
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Simulator Section */}
|
|
<SimulatorSection salesConfig={data.sales_config} />
|
|
</main>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// 4. Simulator Component
|
|
const SimulatorSection = ({ salesConfig }) => {
|
|
const [selectedProgramId, setSelectedProgramId] = useState(salesConfig.programs[0]?.id);
|
|
const [duration, setDuration] = useState(salesConfig.default_contract_period);
|
|
const [customJoinFee, setCustomJoinFee] = useState(null);
|
|
const [customSubFee, setCustomSubFee] = useState(null);
|
|
|
|
const selectedProgram = salesConfig.programs.find(p => p.id === selectedProgramId);
|
|
|
|
// Calculate Commissions
|
|
const joinFee = customJoinFee !== null ? customJoinFee : (selectedProgram?.join_fee || 0);
|
|
const subFee = customSubFee !== null ? customSubFee : (selectedProgram?.subscription_fee || 0);
|
|
|
|
const rates = selectedProgram?.commission_rates || { seller: { join: 0, sub: 0 }, manager: { join: 0, sub: 0 } };
|
|
|
|
const sellerCommission = (joinFee * rates.seller.join) + (subFee * rates.seller.sub * duration);
|
|
const managerCommission = (joinFee * rates.manager.join) + (subFee * rates.manager.sub * duration);
|
|
const totalCommission = sellerCommission + managerCommission;
|
|
|
|
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(val);
|
|
|
|
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-1/2 border-b lg:border-b-0 lg:border-r border-slate-100">
|
|
<h2 className="text-xl font-bold text-slate-900 mb-6 flex items-center gap-2">
|
|
<i data-lucide="calculator" className="w-5 h-5 text-blue-600"></i>
|
|
수당 시뮬레이터
|
|
</h2>
|
|
|
|
<div className="space-y-6">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">프로그램 선택</label>
|
|
<select
|
|
className="w-full rounded-lg border-slate-200 border p-3 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all"
|
|
value={selectedProgramId}
|
|
onChange={(e) => {
|
|
setSelectedProgramId(e.target.value);
|
|
setCustomJoinFee(null); // Reset custom values on program change
|
|
setCustomSubFee(null);
|
|
}}
|
|
>
|
|
{salesConfig.programs.map(prog => (
|
|
<option key={prog.id} value={prog.id}>{prog.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">가입비 (원)</label>
|
|
<input
|
|
type="number"
|
|
className="w-full rounded-lg border-slate-200 border p-3 focus:ring-2 focus:ring-blue-500 outline-none"
|
|
value={joinFee}
|
|
onChange={(e) => setCustomJoinFee(Number(e.target.value))}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">월 구독료 (원)</label>
|
|
<input
|
|
type="number"
|
|
className="w-full rounded-lg border-slate-200 border p-3 focus:ring-2 focus:ring-blue-500 outline-none"
|
|
value={subFee}
|
|
onChange={(e) => setCustomSubFee(Number(e.target.value))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">계약 기간 (개월)</label>
|
|
<div className="flex items-center gap-4">
|
|
<input
|
|
type="range"
|
|
min="12"
|
|
max="120"
|
|
step="12"
|
|
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
|
value={duration}
|
|
onChange={(e) => setDuration(Number(e.target.value))}
|
|
/>
|
|
<span className="text-sm font-bold text-blue-600 w-16 text-right">{duration}개월</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Result Card */}
|
|
<div className="p-8 lg:w-1/2 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(sellerCommission)}</span>
|
|
</div>
|
|
<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(managerCommission)}</span>
|
|
</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>
|
|
<p className="text-right text-xs text-slate-400 mt-2">* 세전 금액 기준입니다.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
|
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
|
root.render(<App />);
|
|
|
|
// Initialize Lucide Icons after render
|
|
setTimeout(() => {
|
|
lucide.createIcons();
|
|
}, 100);
|
|
</script>
|
|
</body>
|
|
</html>
|