feat:개발 승인 메뉴 구현
- 영업관리 하위에 "개발 승인" 메뉴 추가 - 영업/매니저 100% 완료 고객의 개발 진행 상태 관리 - 3분할 레이아웃: 승인대기 / 개발진행중 / 완료 - 7단계 진행 상태: 대기→검토→기획안작성→개발코드작성→개발테스트→개발완료→통합테스트→인계 - 승인/반려/상태변경 기능 구현 - 통계 카드 및 상세 모달 지원 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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'
|
||||
));
|
||||
}
|
||||
}
|
||||
217
app/Services/Sales/SalesDevelopmentApprovalService.php
Normal file
217
app/Services/Sales/SalesDevelopmentApprovalService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
81
database/seeders/DevelopmentApprovalMenuSeeder.php
Normal file
81
database/seeders/DevelopmentApprovalMenuSeeder.php
Normal 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})");
|
||||
}
|
||||
}
|
||||
}
|
||||
247
resources/views/sales/development/approvals.blade.php
Normal file
247
resources/views/sales/development/approvals.blade.php
Normal 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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user