테넌트 하위 개념 추가

This commit is contained in:
2025-12-23 09:00:57 +09:00
parent f1738953b0
commit 2bbd0e30a3
3 changed files with 882 additions and 103 deletions

View File

@@ -0,0 +1,109 @@
<?php
header("Content-Type: application/json; charset=utf-8");
require_once(__DIR__ . "/../../lib/mydb.php");
session_start();
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
if (!isset($_SESSION['sales_user'])) {
echo json_encode(['success' => false, 'error' => '로그인이 필요합니다.']);
exit;
}
$currentUser = $_SESSION['sales_user'];
$pdo = db_connect();
try {
switch ($method) {
case 'GET':
if ($action === 'list_tenants') {
// 운영자는 모든 테넌트, 영업관리/매니저는 본인 소속 테넌트만
if ($currentUser['role'] === 'operator') {
$stmt = $pdo->prepare("SELECT t.*, m.name as manager_name FROM sales_tenants t JOIN sales_member m ON t.manager_id = m.id ORDER BY t.created_at DESC");
$stmt->execute();
} else {
$stmt = $pdo->prepare("SELECT * FROM sales_tenants WHERE manager_id = ? ORDER BY created_at DESC");
$stmt->execute([$currentUser['id']]);
}
$tenants = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['success' => true, 'data' => $tenants]);
} elseif ($action === 'tenant_products') {
$tenant_id = $_GET['tenant_id'] ?? null;
if (!$tenant_id) throw new Exception("테넌트 ID가 필요합니다.");
$stmt = $pdo->prepare("SELECT * FROM sales_tenant_products WHERE tenant_id = ? ORDER BY created_at DESC");
$stmt->execute([$tenant_id]);
$products = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['success' => true, 'data' => $products]);
} elseif ($action === 'my_stats') {
// 현재 로그인한 사용자의 요약 통계
$stmt = $pdo->prepare("
SELECT
COUNT(DISTINCT t.id) as tenant_count,
SUM(p.contract_amount) as total_revenue,
SUM(p.commission_amount) as total_commission,
SUM(CASE WHEN p.operator_confirmed = 1 THEN p.commission_amount ELSE 0 END) as confirmed_commission
FROM sales_tenants t
LEFT JOIN sales_tenant_products p ON t.id = p.tenant_id
WHERE t.manager_id = ?
");
$stmt->execute([$currentUser['id']]);
$stats = $stmt->fetch(PDO::FETCH_ASSOC);
echo json_encode(['success' => true, 'data' => $stats]);
}
break;
case 'POST':
$data = json_decode(file_get_contents('php://input'), true);
if ($action === 'create_tenant') {
$tenant_name = $data['tenant_name'] ?? '';
$representative = $data['representative'] ?? '';
$business_no = $data['business_no'] ?? '';
$contact_phone = $data['contact_phone'] ?? '';
$email = $data['email'] ?? '';
$address = $data['address'] ?? '';
if (!$tenant_name) throw new Exception("업체명은 필수입니다.");
$stmt = $pdo->prepare("INSERT INTO sales_tenants (manager_id, tenant_name, representative, business_no, contact_phone, email, address) VALUES (?, ?, ?, ?, ?, ?, ?)");
$stmt->execute([$currentUser['id'], $tenant_name, $representative, $business_no, $contact_phone, $email, $address]);
echo json_encode(['success' => true, 'id' => $pdo->lastInsertId(), 'message' => '테넌트가 등록되었습니다.']);
} elseif ($action === 'add_product') {
$tenant_id = $data['tenant_id'] ?? null;
$product_name = $data['product_name'] ?? '';
$contract_amount = $data['contract_amount'] ?? 0;
$commission_rate = $data['commission_rate'] ?? 0;
$contract_date = $data['contract_date'] ?? date('Y-m-d');
if (!$tenant_id || !$product_name) throw new Exception("필수 정보가 누락되었습니다.");
$stmt = $pdo->prepare("INSERT INTO sales_tenant_products (tenant_id, product_name, contract_amount, commission_rate, contract_date) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([$tenant_id, $product_name, $contract_amount, $commission_rate, $contract_date]);
echo json_encode(['success' => true, 'message' => '상품 계약 정보가 등록되었습니다.']);
} elseif ($action === 'confirm_product') {
if ($currentUser['role'] !== 'operator') throw new Exception("권한이 없습니다.");
$product_id = $data['id'] ?? null;
$confirmed = $data['confirmed'] ? 1 : 0;
if (!$product_id) throw new Exception("ID가 누락되었습니다.");
$stmt = $pdo->prepare("UPDATE sales_tenant_products SET operator_confirmed = ? WHERE id = ?");
$stmt->execute([$confirmed, $product_id]);
echo json_encode(['success' => true, 'message' => $confirmed ? '승인되었습니다.' : '승인이 취소되었습니다.']);
}
break;
}
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}

View File

@@ -48,6 +48,27 @@
<script type="text/babel">
const { useState, useEffect, useRef } = React;
// 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)}`;
};
// Lucide Icon Wrapper
const LucideIcon = ({ name, size, className, onClick }) => {
const ref = React.useRef(null);
@@ -67,6 +88,22 @@
// --- Components ---
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>
);
// 1. Header Component
const Header = ({ companyInfo, onOpenHelp, selectedRole, onRoleChange, currentUser, onLogout }) => {
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
@@ -331,16 +368,55 @@
alert('삭제 중 오류가 발생했습니다: ' + err.message);
}
};
// 통계 계산 (실제 데이터 기반)
const totalStats = {
contractCount: 0, // 나중에 실적 API와 연동 필요
const [operatorStats, setOperatorStats] = useState({
totalSales: 0,
totalCommission: 0,
monthlyContractCount: 0,
totalCount: 0,
monthlySales: 0,
monthlyCommission: 0,
lastMonthCommission: 0
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);
}
};
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(val);
@@ -349,88 +425,60 @@
<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>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">영업 전체 관리</h2>
<p className="text-slate-500 mt-2 text-lg">플랫폼의 모든 영업 관리자와 매니저를 총괄 관리합니다.</p>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">지능형 영업 통합 관리</h2>
<p className="text-slate-500 mt-2 text-lg">모든 테넌트 계약과 영업 자산을 중앙에서 제어합니다.</p>
</div>
<button
onClick={handleOpenAdd}
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 hover:-translate-y-0.5 active:translate-y-0"
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"
>
<LucideIcon name="user-plus" className="w-5 h-5" />
신규 담당자 등록
</button>
</div>
{/* 통계 카드 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<div
className="bg-white rounded-card p-6 shadow-sm border border-slate-100 hover:shadow-md transition-shadow cursor-pointer"
onClick={() => setSelectedManager(null)}
>
{/* 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'}`}>
<div className="flex items-start justify-between mb-4">
<h3 className="text-sm font-medium text-slate-500"> 건수</h3>
<div className="p-2 bg-blue-50 rounded-lg text-blue-600">
<LucideIcon name="file-check" className="w-5 h-5" />
<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" />
</div>
</div>
<div className="text-xl font-bold text-slate-900 mb-1 break-words">{totalStats.contractCount}</div>
<div className="text-xs text-slate-400">전체 계약 건수</div>
<div className={`text-2xl font-black mb-1 ${operatorStats.pendingApprovals > 0 ? 'text-red-600' : 'text-slate-900'}`}>
{operatorStats.pendingApprovals}
</div>
<div className="text-xs text-slate-400">즉시 검토가 필요한 계약 </div>
</div>
</div>
<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">이번달 건수</h3>
<div className="p-2 bg-indigo-50 rounded-lg text-indigo-600">
<LucideIcon name="calendar" className="w-5 h-5" />
</div>
{/* 테넌트 승인 관리 섹션 (운영자 전용) */}
<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" />
</div>
<div className="text-xl font-bold text-slate-900 mb-1 break-words">{totalStats.monthlyContractCount}</div>
<div className="text-xs text-slate-400">이번달 신규 계약</div>
</div>
<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"> 가입비</h3>
<div className="p-2 bg-green-50 rounded-lg text-green-600">
<LucideIcon name="trending-up" className="w-5 h-5" />
</div>
</div>
<div className="text-lg font-bold text-slate-900 mb-1 break-words leading-tight">{formatCurrency(totalStats.totalSales)}</div>
<div className="text-xs text-slate-400">전체 누적 가입비</div>
</div>
<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"> 수당 지급</h3>
<div className="p-2 bg-purple-50 rounded-lg text-purple-600">
<LucideIcon name="wallet" className="w-5 h-5" />
</div>
</div>
<div className="text-lg font-bold text-slate-900 mb-1 break-words leading-tight">{formatCurrency(totalStats.totalCommission)}</div>
<div className="text-xs text-slate-400">전체 누적 수당</div>
</div>
<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">이번달 수당</h3>
<div className="p-2 bg-teal-50 rounded-lg text-teal-600">
<LucideIcon name="credit-card" className="w-5 h-5" />
</div>
</div>
<div className="text-lg font-bold text-slate-900 mb-1 break-words leading-tight">{formatCurrency(totalStats.monthlyCommission)}</div>
<div className="text-xs text-slate-400">이번달 지급 예정</div>
</div>
<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">지난달 수당</h3>
<div className="p-2 bg-orange-50 rounded-lg text-orange-600">
<LucideIcon name="calendar-check" className="w-5 h-5" />
</div>
</div>
<div className="text-lg font-bold text-slate-900 mb-1 break-words leading-tight">{formatCurrency(totalStats.lastMonthCommission)}</div>
<div className="text-xs text-slate-400">지난달 지급 완료</div>
</div>
테넌트 계약 수당 승인 관리
</h3>
<TenantConfirmationManager />
</div>
{/* 영업담당자 통합 관리 (CRUD Table) */}
@@ -517,6 +565,17 @@
</div>
</section>
{/* 아이템 설정 카드 (운영자 전용) */}
<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>
{/* 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">
@@ -636,7 +695,7 @@
<input
type="tel"
value={formData.phone}
onChange={(e) => setFormData({...formData, phone: e.target.value})}
onChange={(e) => setFormData({...formData, phone: formatPhone(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="010-0000-0000"
/>
@@ -756,17 +815,6 @@
</div>
)}
{/* 아이템 설정 카드 (운영자 전용) */}
<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>
{/* 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">
@@ -813,6 +861,204 @@
);
};
// 테넌트 계약 승인 관리 컴포넌트 (운영자 전용)
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>
);
};
// 아이템 가격 관리 컴포넌트 (운영자 전용)
const ItemPricingManager = () => {
const [pricingItems, setPricingItems] = useState([]);
@@ -1147,22 +1393,7 @@
);
};
// 2. StatCard Component (Move up to be reusable)
const StatCard = ({ title, value, subtext, icon, onClick }) => (
<div
className={`bg-white rounded-card p-6 shadow-sm border border-slate-100 hover:shadow-md transition-shadow ${onClick ? 'cursor-pointer' : ''}`}
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>
);
// --- NEW: Login View Component ---
const LoginView = ({ onLoginSuccess, selectedRole }) => {
@@ -1562,7 +1793,7 @@
<input
type="tel"
value={formData.phone}
onChange={(e) => setFormData({...formData, phone: e.target.value})}
onChange={(e) => setFormData({...formData, 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"
/>
@@ -1632,6 +1863,374 @@
);
};
// --- NEW: Profit Management View (Tenants & Commissions) ---
const ProfitManagementView = ({ currentUser }) => {
const [tenants, setTenants] = useState([]);
const [stats, setStats] = useState({ tenant_count: 0, total_revenue: 0, total_commission: 0, confirmed_commission: 0 });
const [loading, setLoading] = useState(true);
const [isTenantModalOpen, setIsTenantModalOpen] = useState(false);
const [isProductModalOpen, setIsProductModalOpen] = useState(false);
const [selectedTenant, setSelectedTenant] = useState(null);
const [expandedTenantId, setExpandedTenantId] = useState(null);
const [tenantProducts, setTenantProducts] = useState({});
const [tenantFormData, setTenantFormData] = useState({
tenant_name: '', representative: '', business_no: '', contact_phone: '', email: '', address: ''
});
const [productFormData, setProductFormData] = useState({
product_name: '', contract_amount: '', commission_rate: '20', contract_date: new Date().toISOString().split('T')[0]
});
useEffect(() => {
fetchData();
}, []);
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);
}
}
};
const handleCreateTenant = async (e) => {
e.preventDefault();
try {
const res = await fetch('api/sales_tenants.php?action=create_tenant', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tenantFormData)
});
const result = await res.json();
if (result.success) {
alert('테넌트가 등록되었습니다.');
setIsTenantModalOpen(false);
setTenantFormData({ tenant_name: '', representative: '', business_no: '', contact_phone: '', email: '', address: '' });
fetchData();
} else {
alert(result.error);
}
} catch (err) {
alert('등록 중 오류가 발생했습니다.');
}
};
const handleAddProduct = async (e) => {
e.preventDefault();
if (!productFormData.contract_amount || !productFormData.product_name) {
alert('필수 정보를 입력해주세요.');
return;
}
try {
const res = await fetch('api/sales_tenants.php?action=add_product', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...productFormData, tenant_id: selectedTenant.id })
});
const result = await res.json();
if (result.success) {
alert('계약 정보가 등록되었습니다.');
setIsProductModalOpen(false);
setProductFormData({ product_name: '', contract_amount: '', commission_rate: '20', contract_date: new Date().toISOString().split('T')[0] });
fetchData();
fetchProducts(selectedTenant.id);
} 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="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>
<button
onClick={() => setIsTenantModalOpen(true)}
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>
</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">연락처</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="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>
<td className="px-6 py-4 font-bold text-slate-900">{t.tenant_name}</td>
<td className="px-6 py-4 text-slate-600">{t.representative || '-'}</td>
<td className="px-6 py-4 text-slate-600">{t.contact_phone || '-'}</td>
<td className="px-6 py-4 text-slate-400 text-xs">{t.created_at?.split(' ')[0]}</td>
<td className="px-6 py-4 text-center">
<button
onClick={() => { setSelectedTenant(t); setIsProductModalOpen(true); }}
className="px-3 py-1.5 bg-indigo-50 text-indigo-600 rounded-lg font-bold hover:bg-indigo-100 transition-all border border-indigo-100"
>
계약 추가
</button>
</td>
</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>
</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>
</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">
<LucideIcon name="building" className="w-5 h-5 text-blue-600" />
신규 테넌트 등록
</h3>
<button type="button" onClick={() => setIsTenantModalOpen(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>
<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>
<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>
<div className="p-6 border-t border-slate-100 bg-slate-50 flex justify-end gap-3">
<button type="button" onClick={() => setIsTenantModalOpen(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-blue-600 text-white rounded-lg font-bold shadow-lg shadow-blue-100 hover:bg-blue-700 transition-all">등록하기</button>
</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">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden animate-in zoom-in duration-200">
<form onSubmit={handleAddProduct}>
<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" />
계약 정보 추가
</h3>
<button type="button" onClick={() => setIsProductModalOpen(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="p-3 bg-blue-50 rounded-lg border border-blue-100 mb-4">
<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>
</div>
<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" placeholder="예: 베이직 패키지 A" />
</div>
<div>
<label className="block text-xs font-bold text-slate-500 mb-1"> 계약금액 () *</label>
<input type="number" required value={productFormData.contract_amount} onChange={e => setProductFormData({...productFormData, contract_amount: 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="0" />
</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>
</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>
)}
</div>
);
};
// 3. Main App Component
const App = () => {
const [loading, setLoading] = useState(true);
@@ -1789,6 +2388,17 @@
{/* Dashbaord & Org Tree */}
<SalesManagementDashboard organizationData={organizationData} onRefresh={fetchPerformanceData} isLoading={isOrgLoading} />
{/* 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>
<ProfitManagementView currentUser={currentUser} />
</section>
{selectedRole === '영업관리' && (
<ManagerManagementView currentUser={currentUser} />
)}