feat:개발 승인 메뉴 구현

- 영업관리 하위에 "개발 승인" 메뉴 추가
- 영업/매니저 100% 완료 고객의 개발 진행 상태 관리
- 3분할 레이아웃: 승인대기 / 개발진행중 / 완료
- 7단계 진행 상태: 대기→검토→기획안작성→개발코드작성→개발테스트→개발완료→통합테스트→인계
- 승인/반려/상태변경 기능 구현
- 통계 카드 및 상세 모달 지원

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-01-31 20:15:03 +09:00
parent d96cdc1975
commit f83d2a1333
10 changed files with 1237 additions and 0 deletions

View File

@@ -0,0 +1,193 @@
<?php
namespace App\Http\Controllers\Sales;
use App\Http\Controllers\Controller;
use App\Models\Sales\SalesTenantManagement;
use App\Services\Sales\SalesDevelopmentApprovalService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
/**
* 개발 승인 관리 컨트롤러
*/
class SalesDevelopmentApprovalController extends Controller
{
public function __construct(
private SalesDevelopmentApprovalService $service
) {}
/**
* 개발 승인 메인 페이지
*/
public function index(Request $request): View|Response
{
// 권한 체크: admin 역할만 접근 가능
if (!auth()->user()->isAdmin()) {
abort(403, '접근 권한이 없습니다.');
}
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('sales.development.approvals.index'));
}
$search = $request->get('search');
// 통계
$stats = $this->service->getStats();
// 3개 목록
$pendingItems = $this->service->getPendingApprovals($search);
$progressItems = $this->service->getInProgressItems($search);
$completedItems = $this->service->getCompletedItems($search);
// 본사 진행 상태 정보 (뷰에서 사용)
$hqStatuses = SalesTenantManagement::$hqStatusLabels;
$hqStatusOrder = SalesTenantManagement::$hqStatusOrder;
return view('sales.development.approvals', compact(
'stats',
'pendingItems',
'progressItems',
'completedItems',
'hqStatuses',
'hqStatusOrder'
));
}
/**
* 승인 처리
*/
public function approve(Request $request, int $id)
{
// 권한 체크
if (!auth()->user()->isAdmin()) {
abort(403, '접근 권한이 없습니다.');
}
try {
$management = $this->service->approve($id);
$tenantName = $management->tenant?->company_name ?? '알 수 없음';
if ($request->header('HX-Request')) {
return response()->json([
'success' => true,
'message' => "{$tenantName}의 개발이 승인되었습니다.",
]);
}
return redirect()->route('sales.development.approvals.index')
->with('success', "{$tenantName}의 개발이 승인되었습니다.");
} catch (\InvalidArgumentException $e) {
if ($request->header('HX-Request')) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
return redirect()->back()->with('error', $e->getMessage());
}
}
/**
* 반려 처리
*/
public function reject(Request $request, int $id)
{
// 권한 체크
if (!auth()->user()->isAdmin()) {
abort(403, '접근 권한이 없습니다.');
}
$validated = $request->validate([
'rejection_reason' => 'required|string|max:1000',
]);
try {
$management = $this->service->reject($id, $validated['rejection_reason']);
$tenantName = $management->tenant?->company_name ?? '알 수 없음';
if ($request->header('HX-Request')) {
return response()->json([
'success' => true,
'message' => "{$tenantName}이(가) 반려되었습니다.",
]);
}
return redirect()->route('sales.development.approvals.index')
->with('success', "{$tenantName}이(가) 반려되었습니다.");
} catch (\InvalidArgumentException $e) {
if ($request->header('HX-Request')) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
return redirect()->back()->with('error', $e->getMessage());
}
}
/**
* 상태 변경
*/
public function updateStatus(Request $request, int $id)
{
// 권한 체크
if (!auth()->user()->isAdmin()) {
abort(403, '접근 권한이 없습니다.');
}
$validated = $request->validate([
'status' => 'required|string',
]);
try {
$management = $this->service->updateHqStatus($id, $validated['status']);
$tenantName = $management->tenant?->company_name ?? '알 수 없음';
$statusLabel = SalesTenantManagement::$hqStatusLabels[$validated['status']] ?? $validated['status'];
if ($request->header('HX-Request')) {
return response()->json([
'success' => true,
'message' => "{$tenantName}의 상태가 '{$statusLabel}'(으)로 변경되었습니다.",
]);
}
return redirect()->route('sales.development.approvals.index')
->with('success', "{$tenantName}의 상태가 '{$statusLabel}'(으)로 변경되었습니다.");
} catch (\InvalidArgumentException $e) {
if ($request->header('HX-Request')) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
return redirect()->back()->with('error', $e->getMessage());
}
}
/**
* 상세 정보 모달
*/
public function detail(int $id): View
{
// 권한 체크
if (!auth()->user()->isAdmin()) {
abort(403, '접근 권한이 없습니다.');
}
$management = $this->service->getDetail($id);
$hqStatuses = SalesTenantManagement::$hqStatusLabels;
$hqStatusOrder = SalesTenantManagement::$hqStatusOrder;
return view('sales.development.partials.detail-modal', compact(
'management',
'hqStatuses',
'hqStatusOrder'
));
}
}

View File

@@ -0,0 +1,217 @@
<?php
namespace App\Services\Sales;
use App\Models\Sales\SalesTenantManagement;
use App\Models\Tenants\Tenant;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
/**
* 개발 승인 관리 서비스
* 영업/매니저 진행률이 100% 완료된 고객의 개발 진행 상태를 관리
*/
class SalesDevelopmentApprovalService
{
/**
* 통계 조회
*/
public function getStats(): array
{
// 승인 대기 (영업 100% + 매니저 100% + hq_status = pending)
$pendingCount = SalesTenantManagement::query()
->where('sales_progress', 100)
->where('manager_progress', 100)
->where('hq_status', SalesTenantManagement::HQ_STATUS_PENDING)
->count();
// 개발 진행 중 (review ~ int_test)
$progressStatuses = [
SalesTenantManagement::HQ_STATUS_REVIEW,
SalesTenantManagement::HQ_STATUS_PLANNING,
SalesTenantManagement::HQ_STATUS_CODING,
SalesTenantManagement::HQ_STATUS_DEV_TEST,
SalesTenantManagement::HQ_STATUS_DEV_DONE,
SalesTenantManagement::HQ_STATUS_INT_TEST,
];
$inProgressCount = SalesTenantManagement::query()
->where('sales_progress', 100)
->where('manager_progress', 100)
->whereIn('hq_status', $progressStatuses)
->count();
// 오늘 완료 (handover 상태이고 오늘 업데이트된)
$todayCompletedCount = SalesTenantManagement::query()
->where('hq_status', SalesTenantManagement::HQ_STATUS_HANDOVER)
->whereDate('updated_at', today())
->count();
// 총 완료
$totalCompletedCount = SalesTenantManagement::query()
->where('hq_status', SalesTenantManagement::HQ_STATUS_HANDOVER)
->count();
return [
'pending' => $pendingCount,
'in_progress' => $inProgressCount,
'today_completed' => $todayCompletedCount,
'total_completed' => $totalCompletedCount,
];
}
/**
* 승인 대기 목록 조회
*/
public function getPendingApprovals(?string $search = null, int $perPage = 10): LengthAwarePaginator
{
$query = SalesTenantManagement::query()
->with(['tenant', 'salesPartner', 'manager'])
->where('sales_progress', 100)
->where('manager_progress', 100)
->where('hq_status', SalesTenantManagement::HQ_STATUS_PENDING);
// 검색
if ($search) {
$query->whereHas('tenant', function ($q) use ($search) {
$q->where('company_name', 'like', "%{$search}%")
->orWhere('business_number', 'like', "%{$search}%");
});
}
return $query->latest('updated_at')->paginate($perPage, ['*'], 'pending_page');
}
/**
* 개발 진행 중 목록 조회
*/
public function getInProgressItems(?string $search = null, int $perPage = 10): LengthAwarePaginator
{
$progressStatuses = [
SalesTenantManagement::HQ_STATUS_REVIEW,
SalesTenantManagement::HQ_STATUS_PLANNING,
SalesTenantManagement::HQ_STATUS_CODING,
SalesTenantManagement::HQ_STATUS_DEV_TEST,
SalesTenantManagement::HQ_STATUS_DEV_DONE,
SalesTenantManagement::HQ_STATUS_INT_TEST,
];
$query = SalesTenantManagement::query()
->with(['tenant', 'salesPartner', 'manager'])
->where('sales_progress', 100)
->where('manager_progress', 100)
->whereIn('hq_status', $progressStatuses);
// 검색
if ($search) {
$query->whereHas('tenant', function ($q) use ($search) {
$q->where('company_name', 'like', "%{$search}%")
->orWhere('business_number', 'like', "%{$search}%");
});
}
return $query->latest('updated_at')->paginate($perPage, ['*'], 'progress_page');
}
/**
* 완료 목록 조회
*/
public function getCompletedItems(?string $search = null, int $perPage = 10): LengthAwarePaginator
{
$query = SalesTenantManagement::query()
->with(['tenant', 'salesPartner', 'manager'])
->where('hq_status', SalesTenantManagement::HQ_STATUS_HANDOVER);
// 검색
if ($search) {
$query->whereHas('tenant', function ($q) use ($search) {
$q->where('company_name', 'like', "%{$search}%")
->orWhere('business_number', 'like', "%{$search}%");
});
}
return $query->latest('updated_at')->paginate($perPage, ['*'], 'completed_page');
}
/**
* 개발 승인 처리 (pending → review)
*/
public function approve(int $id): SalesTenantManagement
{
$management = SalesTenantManagement::findOrFail($id);
// 승인 조건 확인
if ($management->sales_progress < 100 || $management->manager_progress < 100) {
throw new \InvalidArgumentException('영업/매니저 진행률이 100%가 아닙니다.');
}
if ($management->hq_status !== SalesTenantManagement::HQ_STATUS_PENDING) {
throw new \InvalidArgumentException('이미 승인된 항목입니다.');
}
$management->update([
'hq_status' => SalesTenantManagement::HQ_STATUS_REVIEW,
]);
return $management->fresh();
}
/**
* 반려 처리
*/
public function reject(int $id, string $reason): SalesTenantManagement
{
$management = SalesTenantManagement::findOrFail($id);
if ($management->hq_status !== SalesTenantManagement::HQ_STATUS_PENDING) {
throw new \InvalidArgumentException('승인 대기 상태가 아닙니다.');
}
// notes 필드에 반려 사유 추가
$currentNotes = $management->notes ?? '';
$rejectionNote = '[반려 ' . now()->format('Y-m-d H:i') . '] ' . $reason;
$newNotes = $currentNotes ? $currentNotes . "\n" . $rejectionNote : $rejectionNote;
$management->update([
'notes' => $newNotes,
]);
return $management->fresh();
}
/**
* 본사 진행 상태 업데이트
*/
public function updateHqStatus(int $id, string $status): SalesTenantManagement
{
$management = SalesTenantManagement::findOrFail($id);
// 유효한 상태인지 확인
if (!array_key_exists($status, SalesTenantManagement::$hqStatusLabels)) {
throw new \InvalidArgumentException('유효하지 않은 상태입니다.');
}
// pending 상태에서는 review로만 변경 가능 (승인 처리)
if ($management->hq_status === SalesTenantManagement::HQ_STATUS_PENDING && $status !== SalesTenantManagement::HQ_STATUS_REVIEW) {
throw new \InvalidArgumentException('승인 대기 상태에서는 검토 상태로만 변경 가능합니다. 먼저 승인 처리를 해주세요.');
}
$management->update([
'hq_status' => $status,
]);
return $management->fresh();
}
/**
* 상세 정보 조회
*/
public function getDetail(int $id): SalesTenantManagement
{
return SalesTenantManagement::with([
'tenant',
'salesPartner',
'manager',
'contractProducts.product',
])->findOrFail($id);
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace Database\Seeders;
use App\Models\Commons\Menu;
use Illuminate\Database\Seeder;
/**
* 개발 승인 메뉴 추가 시더
* - 영업관리 > 영업파트너 승인 아래에 "개발 승인" 메뉴 추가
*/
class DevelopmentApprovalMenuSeeder extends Seeder
{
public function run(): void
{
$tenantId = 1;
// 영업관리 메뉴 ID 찾기
$salesParentId = Menu::where('tenant_id', $tenantId)
->where('name', '영업관리')
->whereNull('parent_id')
->value('id');
if (!$salesParentId) {
$this->command->error('영업관리 메뉴를 찾을 수 없습니다.');
return;
}
// 기존 개발 승인 메뉴 확인
$existingMenu = Menu::where('tenant_id', $tenantId)
->where('name', '개발 승인')
->where('parent_id', $salesParentId)
->first();
if ($existingMenu) {
$this->command->info('개발 승인 메뉴가 이미 존재합니다.');
return;
}
// 영업파트너 승인 메뉴의 sort_order 확인
$approvalMenu = Menu::where('tenant_id', $tenantId)
->where('name', '영업파트너 승인')
->where('parent_id', $salesParentId)
->first();
$sortOrder = 6; // 기본값
if ($approvalMenu) {
$sortOrder = $approvalMenu->sort_order + 1;
// 기존 메뉴들의 sort_order를 뒤로 밀기
Menu::where('tenant_id', $tenantId)
->where('parent_id', $salesParentId)
->where('sort_order', '>=', $sortOrder)
->increment('sort_order');
}
// 개발 승인 메뉴 생성
Menu::create([
'tenant_id' => $tenantId,
'parent_id' => $salesParentId,
'name' => '개발 승인',
'url' => '/sales/development/approvals',
'icon' => 'code',
'sort_order' => $sortOrder,
'is_active' => true,
]);
$this->command->info("개발 승인 메뉴 생성 완료 (sort_order: {$sortOrder})");
// 결과 출력
$this->command->info('');
$this->command->info('=== 영업관리 하위 메뉴 ===');
$children = Menu::where('parent_id', $salesParentId)
->orderBy('sort_order')
->get(['name', 'url', 'sort_order']);
foreach ($children as $child) {
$this->command->info("{$child->sort_order}. {$child->name} ({$child->url})");
}
}
}

View File

@@ -0,0 +1,247 @@
@extends('layouts.app')
@section('title', '개발 승인')
@section('content')
<div class="flex flex-col h-full">
<!-- 페이지 헤더 -->
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-4 flex-shrink-0">
<div>
<h1 class="text-2xl font-bold text-gray-800">개발 승인</h1>
<p class="text-sm text-gray-500 mt-1">영업 완료된 고객의 개발 진행 상태를 관리합니다</p>
</div>
</div>
<!-- 통계 카드 -->
<div class="grid grid-cols-4 gap-4 mb-4 flex-shrink-0">
<div class="bg-yellow-50 rounded-lg shadow-sm p-3">
<div class="text-xs text-yellow-600">승인 대기</div>
<div class="text-xl font-bold text-yellow-800">{{ number_format($stats['pending']) }}</div>
</div>
<div class="bg-purple-50 rounded-lg shadow-sm p-3">
<div class="text-xs text-purple-600">개발 진행중</div>
<div class="text-xl font-bold text-purple-800">{{ number_format($stats['in_progress']) }}</div>
</div>
<div class="bg-blue-50 rounded-lg shadow-sm p-3">
<div class="text-xs text-blue-600">오늘 완료</div>
<div class="text-xl font-bold text-blue-800">{{ number_format($stats['today_completed']) }}</div>
</div>
<div class="bg-emerald-50 rounded-lg shadow-sm p-3">
<div class="text-xs text-emerald-600"> 완료</div>
<div class="text-xl font-bold text-emerald-800">{{ number_format($stats['total_completed']) }}</div>
</div>
</div>
<!-- 검색 영역 -->
<div class="flex-shrink-0 mb-4">
<form method="GET" class="flex flex-wrap gap-2 sm:gap-4 items-center bg-white p-3 rounded-lg shadow-sm">
<div class="flex-1 min-w-0 w-full sm:w-auto">
<input type="text"
name="search"
value="{{ request('search') }}"
placeholder="업체명, 사업자번호로 검색..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm">
</div>
<button type="submit" class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg transition text-sm">
검색
</button>
</form>
</div>
<!-- 3분할 레이아웃 -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 flex-1 min-h-0">
<!-- 좌측: 승인 대기 -->
@include('sales.development.partials.pending-list')
<!-- 중앙: 개발 진행 -->
@include('sales.development.partials.progress-list')
<!-- 우측: 완료 -->
@include('sales.development.partials.completed-list')
</div>
</div>
<!-- 반려 사유 모달 -->
@include('sales.development.partials.reject-modal')
<!-- 상세 모달 -->
<div id="detailModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity" onclick="closeDetailModal()"></div>
<div class="flex min-h-full items-center justify-center p-4">
<div id="detailModalContent" class="relative bg-white rounded-xl shadow-2xl w-full max-w-2xl">
<div class="p-6 text-center">
<svg class="w-8 h-8 animate-spin text-blue-600 mx-auto" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<p class="mt-2 text-gray-500">로딩 ...</p>
</div>
</div>
</div>
</div>
@endsection
@push('scripts')
<script>
// 승인 처리
function approveItem(id, name) {
showConfirm(
`<strong>${name}</strong>의 개발을 승인하시겠습니까?<br><small class="text-gray-500">승인 후 '검토' 상태로 변경됩니다.</small>`,
() => {
fetch(`/sales/development/approvals/${id}/approve`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
'HX-Request': 'true'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast(data.message, 'success');
window.location.reload();
} else {
showToast(data.message || '승인 처리에 실패했습니다.', 'error');
}
})
.catch(error => {
showToast('서버 오류가 발생했습니다.', 'error');
console.error(error);
});
},
{ title: '개발 승인 확인', icon: 'question', confirmText: '승인' }
);
}
// 반려 모달 열기
function openRejectModal(id, name) {
document.getElementById('rejectItemId').value = id;
document.getElementById('rejectItemName').textContent = name;
document.getElementById('rejectionReason').value = '';
document.getElementById('rejectModal').classList.remove('hidden');
}
// 반려 모달 닫기
function closeRejectModal() {
document.getElementById('rejectModal').classList.add('hidden');
}
// 반려 제출
function submitReject(event) {
event.preventDefault();
const id = document.getElementById('rejectItemId').value;
const reason = document.getElementById('rejectionReason').value;
fetch(`/sales/development/approvals/${id}/reject`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
'HX-Request': 'true'
},
body: JSON.stringify({ rejection_reason: reason })
})
.then(response => response.json())
.then(data => {
if (data.success) {
closeRejectModal();
showToast(data.message, 'success');
window.location.reload();
} else {
showToast(data.message || '반려 처리에 실패했습니다.', 'error');
}
})
.catch(error => {
showToast('서버 오류가 발생했습니다.', 'error');
console.error(error);
});
}
// 상태 변경
function updateStatus(id, name) {
const select = document.getElementById(`status-select-${id}`);
const status = select.value;
const statusText = select.options[select.selectedIndex].text;
showConfirm(
`<strong>${name}</strong>의 상태를 '${statusText}'(으)로 변경하시겠습니까?`,
() => {
fetch(`/sales/development/approvals/${id}/status`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
'HX-Request': 'true'
},
body: JSON.stringify({ status: status })
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast(data.message, 'success');
window.location.reload();
} else {
showToast(data.message || '상태 변경에 실패했습니다.', 'error');
}
})
.catch(error => {
showToast('서버 오류가 발생했습니다.', 'error');
console.error(error);
});
},
{ title: '상태 변경 확인', icon: 'question', confirmText: '변경' }
);
}
// 상세 모달 열기
function openDetailModal(id) {
document.getElementById('detailModal').classList.remove('hidden');
document.getElementById('detailModalContent').innerHTML = `
<div class="p-6 text-center">
<svg class="w-8 h-8 animate-spin text-blue-600 mx-auto" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<p class="mt-2 text-gray-500">로딩 중...</p>
</div>
`;
fetch(`/sales/development/approvals/${id}/detail`, {
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'text/html'
}
})
.then(response => response.text())
.then(html => {
document.getElementById('detailModalContent').innerHTML = html;
})
.catch(error => {
document.getElementById('detailModalContent').innerHTML = `
<div class="p-6 text-center">
<p class="text-red-500">오류가 발생했습니다.</p>
<button onclick="closeDetailModal()" class="mt-4 px-4 py-2 bg-gray-600 text-white rounded-lg">닫기</button>
</div>
`;
});
}
// 상세 모달 닫기
function closeDetailModal() {
document.getElementById('detailModal').classList.add('hidden');
}
// ESC 키로 모달 닫기
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeRejectModal();
closeDetailModal();
}
});
</script>
@endpush

View File

@@ -0,0 +1,94 @@
{{-- 완료 목록 --}}
<div class="bg-white rounded-lg shadow-sm overflow-hidden flex flex-col min-h-0">
<div class="bg-emerald-500 text-white px-4 py-3 flex items-center gap-2 flex-shrink-0">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="font-semibold">완료</span>
<span class="ml-auto bg-emerald-600 px-2 py-0.5 rounded-full text-xs">{{ $completedItems->total() }}</span>
</div>
<div class="overflow-y-auto flex-1">
<div class="divide-y divide-gray-200">
@forelse($completedItems as $item)
@php
$tenant = $item->tenant;
$companyName = $tenant?->company_name ?? '알 수 없음';
@endphp
<div class="p-4 hover:bg-emerald-50 transition" id="completed-row-{{ $item->id }}">
<div class="flex justify-between items-start mb-2">
<div>
<div class="font-medium text-gray-900 text-sm">{{ $companyName }}</div>
<div class="text-xs text-gray-500">{{ $tenant?->business_number ?? '-' }}</div>
</div>
</div>
{{-- 완료 정보 --}}
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-emerald-100 text-emerald-800">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
인계 완료
</span>
</div>
<div class="text-xs text-gray-500">
{{ $item->updated_at->format('Y-m-d') }}
</div>
</div>
{{-- 7단계 프로그레스 (완료 상태) --}}
<div class="mb-3">
<div class="flex items-center gap-0.5">
@foreach($hqStatuses as $statusKey => $statusLabel)
@if($statusKey !== 'pending')
<div class="group relative flex-1">
<div class="h-2 rounded-full bg-emerald-500"></div>
<div class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 bg-gray-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-10 pointer-events-none">
{{ $statusLabel }}
</div>
</div>
@endif
@endforeach
</div>
</div>
{{-- 담당자 정보 --}}
<div class="text-xs text-gray-500 mb-2">
<span class="inline-flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
영업: {{ $item->salesPartner?->user?->name ?? '-' }}
</span>
<span class="mx-2">|</span>
<span class="inline-flex items-center gap-1">
매니저: {{ $item->manager?->name ?? '-' }}
</span>
</div>
{{-- 상세 버튼 --}}
<div class="flex items-center justify-end">
<button type="button"
onclick="openDetailModal({{ $item->id }})"
class="px-2 py-1 bg-gray-400 hover:bg-gray-500 text-white text-xs font-medium rounded transition">
상세
</button>
</div>
</div>
@empty
<div class="p-8 text-center text-gray-500 text-sm">
<svg class="w-12 h-12 mx-auto text-gray-300 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
완료된 항목이 없습니다.
</div>
@endforelse
</div>
</div>
@if($completedItems->hasPages())
<div class="px-4 py-2 border-t border-gray-200 flex-shrink-0 bg-gray-50">
{{ $completedItems->withQueryString()->links() }}
</div>
@endif
</div>

View File

@@ -0,0 +1,161 @@
{{-- 상세 정보 모달 --}}
@php
$tenant = $management->tenant;
$companyName = $tenant?->company_name ?? '알 수 없음';
$currentHqStep = $hqStatusOrder[$management->hq_status ?? 'pending'] ?? 0;
@endphp
<div class="p-6">
{{-- 헤더 --}}
<div class="flex justify-between items-start mb-6">
<div>
<h3 class="text-xl font-bold text-gray-900">{{ $companyName }}</h3>
<p class="text-sm text-gray-500 mt-1">{{ $tenant?->business_number ?? '-' }}</p>
</div>
<button type="button" onclick="closeDetailModal()"
class="text-gray-400 hover:text-gray-600 transition">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{{-- 현재 상태 --}}
<div class="mb-6">
<h4 class="text-sm font-semibold text-gray-700 mb-3">개발 진행 상태</h4>
<div class="bg-gray-50 rounded-lg p-4">
{{-- 7단계 프로그레스 --}}
<div class="mb-4">
<div class="flex items-center gap-1">
@foreach($hqStatuses as $statusKey => $statusLabel)
@php
$stepNum = $hqStatusOrder[$statusKey];
$isCompleted = $stepNum < $currentHqStep;
$isCurrent = $stepNum === $currentHqStep;
@endphp
<div class="group relative flex-1">
<div class="h-3 rounded-full transition-all {{ $isCompleted ? 'bg-purple-500' : ($isCurrent ? 'bg-purple-300' : 'bg-gray-200') }}"></div>
<div class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 bg-gray-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-10 pointer-events-none">
{{ $statusLabel }}
</div>
</div>
@endforeach
</div>
<div class="flex justify-between mt-1 text-xs text-gray-500">
<span>대기</span>
<span>인계</span>
</div>
</div>
<div class="text-center">
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
@if($management->hq_status === 'handover') bg-emerald-100 text-emerald-800
@elseif($management->hq_status === 'pending') bg-yellow-100 text-yellow-800
@else bg-purple-100 text-purple-800 @endif">
{{ $management->hq_status_label }}
</span>
</div>
</div>
</div>
{{-- 진행률 정보 --}}
<div class="grid grid-cols-2 gap-4 mb-6">
<div class="bg-blue-50 rounded-lg p-4">
<div class="text-xs text-blue-600 mb-1">영업 진행률</div>
<div class="flex items-center gap-2">
<div class="flex-1 bg-blue-200 rounded-full h-2">
<div class="bg-blue-500 h-2 rounded-full" style="width: {{ $management->sales_progress }}%"></div>
</div>
<span class="text-sm font-bold text-blue-700">{{ $management->sales_progress }}%</span>
</div>
<div class="text-xs text-blue-600 mt-2">
담당: {{ $management->salesPartner?->user?->name ?? '-' }}
</div>
</div>
<div class="bg-green-50 rounded-lg p-4">
<div class="text-xs text-green-600 mb-1">매니저 진행률</div>
<div class="flex items-center gap-2">
<div class="flex-1 bg-green-200 rounded-full h-2">
<div class="bg-green-500 h-2 rounded-full" style="width: {{ $management->manager_progress }}%"></div>
</div>
<span class="text-sm font-bold text-green-700">{{ $management->manager_progress }}%</span>
</div>
<div class="text-xs text-green-600 mt-2">
담당: {{ $management->manager?->name ?? '-' }}
</div>
</div>
</div>
{{-- 테넌트 정보 --}}
<div class="mb-6">
<h4 class="text-sm font-semibold text-gray-700 mb-3">고객 정보</h4>
<div class="bg-gray-50 rounded-lg p-4">
<dl class="grid grid-cols-2 gap-3 text-sm">
<div>
<dt class="text-gray-500">업체명</dt>
<dd class="font-medium text-gray-900">{{ $tenant?->company_name ?? '-' }}</dd>
</div>
<div>
<dt class="text-gray-500">사업자번호</dt>
<dd class="font-medium text-gray-900">{{ $tenant?->business_number ?? '-' }}</dd>
</div>
<div>
<dt class="text-gray-500">대표자</dt>
<dd class="font-medium text-gray-900">{{ $tenant?->representative_name ?? '-' }}</dd>
</div>
<div>
<dt class="text-gray-500">연락처</dt>
<dd class="font-medium text-gray-900">{{ $tenant?->phone ?? '-' }}</dd>
</div>
<div class="col-span-2">
<dt class="text-gray-500">주소</dt>
<dd class="font-medium text-gray-900">{{ $tenant?->address ?? '-' }}</dd>
</div>
</dl>
</div>
</div>
{{-- 계약 상품 정보 --}}
@if($management->contractProducts->count() > 0)
<div class="mb-6">
<h4 class="text-sm font-semibold text-gray-700 mb-3">계약 상품</h4>
<div class="bg-gray-50 rounded-lg overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-100">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">상품명</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">가입비</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase"> 구독료</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@foreach($management->contractProducts as $cp)
<tr>
<td class="px-4 py-2 text-sm text-gray-900">{{ $cp->product?->name ?? '-' }}</td>
<td class="px-4 py-2 text-sm text-gray-900 text-right">{{ number_format($cp->registration_fee ?? 0) }}</td>
<td class="px-4 py-2 text-sm text-gray-900 text-right">{{ number_format($cp->subscription_fee ?? 0) }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endif
{{-- 메모 --}}
@if($management->notes)
<div class="mb-6">
<h4 class="text-sm font-semibold text-gray-700 mb-3">메모</h4>
<div class="bg-gray-50 rounded-lg p-4">
<p class="text-sm text-gray-700 whitespace-pre-line">{{ $management->notes }}</p>
</div>
</div>
@endif
{{-- 닫기 버튼 --}}
<div class="flex justify-end">
<button type="button" onclick="closeDetailModal()"
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition">
닫기
</button>
</div>
</div>

View File

@@ -0,0 +1,92 @@
{{-- 승인 대기 목록 --}}
<div class="bg-white rounded-lg shadow-sm overflow-hidden flex flex-col min-h-0">
<div class="bg-yellow-500 text-white px-4 py-3 flex items-center gap-2 flex-shrink-0">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="font-semibold">승인 대기</span>
<span class="ml-auto bg-yellow-600 px-2 py-0.5 rounded-full text-xs">{{ $pendingItems->total() }}</span>
</div>
<div class="overflow-y-auto flex-1">
<div class="divide-y divide-gray-200">
@forelse($pendingItems as $item)
@php
$tenant = $item->tenant;
$companyName = $tenant?->company_name ?? '알 수 없음';
@endphp
<div class="p-4 hover:bg-yellow-50 transition" id="pending-row-{{ $item->id }}">
<div class="flex justify-between items-start mb-2">
<div>
<div class="font-medium text-gray-900 text-sm">{{ $companyName }}</div>
<div class="text-xs text-gray-500">{{ $tenant?->business_number ?? '-' }}</div>
</div>
<div class="text-xs text-gray-400">{{ $item->updated_at->format('m/d') }}</div>
</div>
{{-- 진행률 표시 --}}
<div class="grid grid-cols-2 gap-2 mb-3">
<div class="flex items-center gap-1">
<span class="text-xs text-blue-600 w-6">영업</span>
<div class="flex-1 bg-gray-200 rounded-full h-1.5">
<div class="bg-blue-500 h-1.5 rounded-full" style="width: {{ $item->sales_progress }}%"></div>
</div>
<span class="text-xs text-gray-500 w-8 text-right">{{ $item->sales_progress }}%</span>
</div>
<div class="flex items-center gap-1">
<span class="text-xs text-green-600 w-6">매니</span>
<div class="flex-1 bg-gray-200 rounded-full h-1.5">
<div class="bg-green-500 h-1.5 rounded-full" style="width: {{ $item->manager_progress }}%"></div>
</div>
<span class="text-xs text-gray-500 w-8 text-right">{{ $item->manager_progress }}%</span>
</div>
</div>
{{-- 담당자 정보 --}}
<div class="text-xs text-gray-500 mb-3">
<span class="inline-flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
영업: {{ $item->salesPartner?->user?->name ?? '-' }}
</span>
<span class="mx-2">|</span>
<span class="inline-flex items-center gap-1">
매니저: {{ $item->manager?->name ?? '-' }}
</span>
</div>
{{-- 버튼 --}}
<div class="flex items-center justify-end gap-1">
<button type="button"
onclick="approveItem({{ $item->id }}, '{{ addslashes($companyName) }}')"
class="px-2 py-1 bg-emerald-500 hover:bg-emerald-600 text-white text-xs font-medium rounded transition">
승인
</button>
<button type="button"
onclick="openRejectModal({{ $item->id }}, '{{ addslashes($companyName) }}')"
class="px-2 py-1 bg-red-500 hover:bg-red-600 text-white text-xs font-medium rounded transition">
반려
</button>
<button type="button"
onclick="openDetailModal({{ $item->id }})"
class="px-2 py-1 bg-gray-400 hover:bg-gray-500 text-white text-xs font-medium rounded transition">
상세
</button>
</div>
</div>
@empty
<div class="p-8 text-center text-gray-500 text-sm">
<svg class="w-12 h-12 mx-auto text-gray-300 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
승인 대기 중인 항목이 없습니다.
</div>
@endforelse
</div>
</div>
@if($pendingItems->hasPages())
<div class="px-4 py-2 border-t border-gray-200 flex-shrink-0 bg-gray-50">
{{ $pendingItems->withQueryString()->links() }}
</div>
@endif
</div>

View File

@@ -0,0 +1,106 @@
{{-- 개발 진행 목록 --}}
<div class="bg-white rounded-lg shadow-sm overflow-hidden flex flex-col min-h-0">
<div class="bg-purple-500 text-white px-4 py-3 flex items-center gap-2 flex-shrink-0">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
<span class="font-semibold">개발 진행중</span>
<span class="ml-auto bg-purple-600 px-2 py-0.5 rounded-full text-xs">{{ $progressItems->total() }}</span>
</div>
<div class="overflow-y-auto flex-1">
<div class="divide-y divide-gray-200">
@forelse($progressItems as $item)
@php
$tenant = $item->tenant;
$companyName = $tenant?->company_name ?? '알 수 없음';
$currentHqStep = $hqStatusOrder[$item->hq_status ?? 'pending'] ?? 0;
@endphp
<div class="p-4 hover:bg-purple-50 transition" id="progress-row-{{ $item->id }}">
<div class="flex justify-between items-start mb-2">
<div>
<div class="font-medium text-gray-900 text-sm">{{ $companyName }}</div>
<div class="text-xs text-gray-500">{{ $tenant?->business_number ?? '-' }}</div>
</div>
<div class="text-xs text-gray-400">{{ $item->updated_at->format('m/d') }}</div>
</div>
{{-- 7단계 프로그레스 --}}
<div class="mb-3">
<div class="flex items-center gap-0.5">
@foreach($hqStatuses as $statusKey => $statusLabel)
@if($statusKey !== 'pending')
@php
$stepNum = $hqStatusOrder[$statusKey];
$isCompleted = $stepNum < $currentHqStep;
$isCurrent = $stepNum === $currentHqStep;
@endphp
<div class="group relative flex-1">
<div class="h-2 rounded-full transition-all {{ $isCompleted ? 'bg-purple-500' : ($isCurrent ? 'bg-purple-300' : 'bg-gray-200') }}"></div>
<div class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 bg-gray-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-10 pointer-events-none">
{{ $statusLabel }}
</div>
</div>
@endif
@endforeach
</div>
<div class="text-xs text-purple-600 font-medium mt-1 text-center">{{ $item->hq_status_label }}</div>
</div>
{{-- 상태 변경 드롭다운 --}}
<div class="flex items-center gap-2 mb-3">
<select id="status-select-{{ $item->id }}"
class="flex-1 text-xs border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-purple-500">
@foreach($hqStatuses as $statusKey => $statusLabel)
@if($statusKey !== 'pending')
<option value="{{ $statusKey }}" {{ $item->hq_status === $statusKey ? 'selected' : '' }}>
{{ $statusLabel }}
</option>
@endif
@endforeach
</select>
<button type="button"
onclick="updateStatus({{ $item->id }}, '{{ addslashes($companyName) }}')"
class="px-2 py-1 bg-purple-500 hover:bg-purple-600 text-white text-xs font-medium rounded transition">
변경
</button>
</div>
{{-- 담당자 정보 --}}
<div class="text-xs text-gray-500 mb-2">
<span class="inline-flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
영업: {{ $item->salesPartner?->user?->name ?? '-' }}
</span>
<span class="mx-2">|</span>
<span class="inline-flex items-center gap-1">
매니저: {{ $item->manager?->name ?? '-' }}
</span>
</div>
{{-- 상세 버튼 --}}
<div class="flex items-center justify-end">
<button type="button"
onclick="openDetailModal({{ $item->id }})"
class="px-2 py-1 bg-gray-400 hover:bg-gray-500 text-white text-xs font-medium rounded transition">
상세
</button>
</div>
</div>
@empty
<div class="p-8 text-center text-gray-500 text-sm">
<svg class="w-12 h-12 mx-auto text-gray-300 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
개발 진행 중인 항목이 없습니다.
</div>
@endforelse
</div>
</div>
@if($progressItems->hasPages())
<div class="px-4 py-2 border-t border-gray-200 flex-shrink-0 bg-gray-50">
{{ $progressItems->withQueryString()->links() }}
</div>
@endif
</div>

View File

@@ -0,0 +1,32 @@
{{-- 반려 사유 모달 --}}
<div id="rejectModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity"></div>
<div class="flex min-h-full items-center justify-center p-4">
<div class="relative bg-white rounded-xl shadow-2xl w-full max-w-md p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">반려 사유 입력</h3>
<p class="text-sm text-gray-600 mb-4">
<span id="rejectItemName" class="font-medium text-red-600"></span> 개발 승인을 반려합니다.
</p>
<form id="rejectForm" onsubmit="submitReject(event)">
<input type="hidden" id="rejectItemId" name="item_id">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">반려 사유 <span class="text-red-500">*</span></label>
<textarea name="rejection_reason" id="rejectionReason" rows="4" required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
placeholder="반려 사유를 입력하세요..."></textarea>
<p class="mt-1 text-xs text-gray-500">반려 사유는 고객의 메모에 기록됩니다.</p>
</div>
<div class="flex justify-end gap-3">
<button type="button" onclick="closeRejectModal()"
class="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition">
취소
</button>
<button type="submit"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition">
반려 처리
</button>
</div>
</form>
</div>
</div>
</div>

View File

@@ -887,6 +887,20 @@
Route::post('managers/approvals/{id}/approve', [\App\Http\Controllers\Sales\SalesManagerController::class, 'approveFromList'])->name('managers.approvals.approve');
Route::post('managers/approvals/{id}/reject', [\App\Http\Controllers\Sales\SalesManagerController::class, 'rejectFromList'])->name('managers.approvals.reject');
// 개발 승인 관리 (본사 관리자 전용)
Route::prefix('development')->name('development.')->group(function () {
Route::get('/approvals', [\App\Http\Controllers\Sales\SalesDevelopmentApprovalController::class, 'index'])
->name('approvals.index');
Route::post('/approvals/{id}/approve', [\App\Http\Controllers\Sales\SalesDevelopmentApprovalController::class, 'approve'])
->name('approvals.approve');
Route::post('/approvals/{id}/reject', [\App\Http\Controllers\Sales\SalesDevelopmentApprovalController::class, 'reject'])
->name('approvals.reject');
Route::post('/approvals/{id}/status', [\App\Http\Controllers\Sales\SalesDevelopmentApprovalController::class, 'updateStatus'])
->name('approvals.status');
Route::get('/approvals/{id}/detail', [\App\Http\Controllers\Sales\SalesDevelopmentApprovalController::class, 'detail'])
->name('approvals.detail');
});
// 영업 담당자 관리
Route::resource('managers', \App\Http\Controllers\Sales\SalesManagerController::class);
Route::get('managers/{id}/modal-show', [\App\Http\Controllers\Sales\SalesManagerController::class, 'modalShow'])->name('managers.modal-show');